Compare commits
51 Commits
828106ba81
...
devel
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a25b6c0e | |||
| f4262cf369 | |||
| 9b100b8fc3 | |||
| 9fceeb9a0a | |||
| 4b7f1a16b9 | |||
|
ada3b903ad
|
|||
| 31d849ddbf | |||
| 4ef08d0bf6 | |||
|
598137ed13
|
|||
|
cb0ca2f2f0
|
|||
|
7346e695c4
|
|||
|
bb827c3928
|
|||
|
efab61a45c
|
|||
| 0d7ae6a017 | |||
| a281ffa32e | |||
| 18d4c6cf9f | |||
| 0e19660eb5 | |||
|
8a69872576
|
|||
|
aa68906a3d
|
|||
|
8bf3b7b209
|
|||
|
669fb40a70
|
|||
|
9c0df3d299
|
|||
| 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
|
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:
|
||||||
|
- devel
|
||||||
|
|
||||||
|
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:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "@daily"
|
- cron: "@daily"
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
renovate:
|
renovate:
|
||||||
|
|||||||
@@ -3,11 +3,53 @@ name: Test
|
|||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
lint:
|
||||||
name: cargo test
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- run: cargo check --all
|
with:
|
||||||
- run: cargo test --all-features
|
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
|
||||||
|
|
||||||
|
render-parity:
|
||||||
|
name: Render parity
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Install headless GL runtime
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y xvfb libgl1-mesa-dri libgles2-mesa-dev mesa-utils
|
||||||
|
- name: Build render-demo binary
|
||||||
|
run: cargo build -p render-demo --features demo
|
||||||
|
- name: Run frame parity suite
|
||||||
|
run: |
|
||||||
|
xvfb-run -s "-screen 0 1280x720x24" cargo run -p render-parity -- \
|
||||||
|
--manifest parity/cases.toml \
|
||||||
|
--output-dir target/render-parity/current \
|
||||||
|
--demo-bin target/debug/parkan-render-demo \
|
||||||
|
--keep-going
|
||||||
|
- name: Upload parity artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
name: render-parity-artifacts
|
||||||
|
path: target/render-parity/current
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|||||||
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"
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["crates/*"]
|
members = ["crates/*", "apps/resource-viewer"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
55
README.md
55
README.md
@@ -0,0 +1,55 @@
|
|||||||
|
# FParkan
|
||||||
|
|
||||||
|
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования.
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Проект находится в активной разработке и включает:
|
||||||
|
|
||||||
|
- библиотеки для работы с форматами игровых архивов;
|
||||||
|
- инструменты для валидации/подготовки тестовых данных;
|
||||||
|
- спецификации форматов и сопутствующую документацию.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Проект находится в начальной стадии, подробная инструкция по установке пока отсутствует.
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- локально: каталог [`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)**.
|
||||||
|
|||||||
11
apps/resource-viewer/Cargo.toml
Normal file
11
apps/resource-viewer/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "resource-viewer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iced = "0.14"
|
||||||
|
rfd = "0.17"
|
||||||
|
nres = { path = "../../crates/nres" }
|
||||||
|
rsli = { path = "../../crates/rsli" }
|
||||||
518
apps/resource-viewer/src/main.rs
Normal file
518
apps/resource-viewer/src/main.rs
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
use iced::widget::{button, column, container, horizontal_space, row, scrollable, text};
|
||||||
|
use iced::{application, Element, Length, Task, Theme};
|
||||||
|
use rfd::FileDialog;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
application("Parkan Resource Viewer", update, view)
|
||||||
|
.theme(theme)
|
||||||
|
.run_with(|| (ViewerApp::default(), Task::none()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme(_state: &ViewerApp) -> Theme {
|
||||||
|
Theme::Light
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct ViewerApp {
|
||||||
|
document: Option<DocumentModel>,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Message {
|
||||||
|
OpenRequested,
|
||||||
|
SelectNode(Selection),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(state: &mut ViewerApp, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::OpenRequested => {
|
||||||
|
if let Some(path) = pick_archive_file() {
|
||||||
|
match load_document(&path) {
|
||||||
|
Ok(document) => {
|
||||||
|
state.status =
|
||||||
|
format!("Loaded {} as {}", path.display(), document.format.label());
|
||||||
|
state.document = Some(document);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
state.status = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::SelectNode(selection) => {
|
||||||
|
if let Some(document) = state.document.as_mut() {
|
||||||
|
document.selected = selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(state: &ViewerApp) -> Element<'_, Message> {
|
||||||
|
let top_bar = row![
|
||||||
|
button("Open archive").on_press(Message::OpenRequested),
|
||||||
|
text(status_text(state)).size(14)
|
||||||
|
]
|
||||||
|
.spacing(12);
|
||||||
|
|
||||||
|
let content = if let Some(document) = &state.document {
|
||||||
|
view_document(document)
|
||||||
|
} else {
|
||||||
|
container(text("Open an .nres/.rsli/.lib archive to start.").size(16))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
container(column![top_bar, content].spacing(12).padding(12))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_text(state: &ViewerApp) -> String {
|
||||||
|
if state.status.is_empty() {
|
||||||
|
String::from("Ready")
|
||||||
|
} else {
|
||||||
|
state.status.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_document(document: &DocumentModel) -> Element<'_, Message> {
|
||||||
|
let mut tree = column![text("Archive tree").size(18)].spacing(6);
|
||||||
|
for item in &document.tree_rows {
|
||||||
|
let indent = horizontal_space().width(Length::Fixed(f32::from(item.depth) * 16.0));
|
||||||
|
|
||||||
|
let line = row![indent, text(&item.label).size(14)].spacing(6);
|
||||||
|
if let Some(selection) = item.selection {
|
||||||
|
let mut node_button = button(line)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_press(Message::SelectNode(selection));
|
||||||
|
|
||||||
|
if selection == document.selected {
|
||||||
|
node_button = node_button.style(button::primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
tree = tree.push(node_button);
|
||||||
|
} else {
|
||||||
|
tree = tree.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (panel_title, fields) = selected_fields(document);
|
||||||
|
let mut fields_column = column![text(panel_title).size(18)].spacing(8);
|
||||||
|
|
||||||
|
for field in fields {
|
||||||
|
fields_column = fields_column.push(
|
||||||
|
row![
|
||||||
|
text(&field.key).size(14).width(Length::Fixed(220.0)),
|
||||||
|
text(&field.value).size(14).width(Length::Fill)
|
||||||
|
]
|
||||||
|
.spacing(12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = container(scrollable(tree))
|
||||||
|
.width(Length::FillPortion(2))
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
let right = container(scrollable(fields_column))
|
||||||
|
.width(Length::FillPortion(5))
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
row![left, right].spacing(12).height(Length::Fill).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_fields(document: &DocumentModel) -> (String, &[FieldRow]) {
|
||||||
|
match document.selected {
|
||||||
|
Selection::Archive => (
|
||||||
|
format!(
|
||||||
|
"{} fields ({})",
|
||||||
|
document.format.label(),
|
||||||
|
document.path.display()
|
||||||
|
),
|
||||||
|
&document.archive_fields,
|
||||||
|
),
|
||||||
|
Selection::Entry(index) => {
|
||||||
|
if let Some(entry) = document.entries.get(index) {
|
||||||
|
(entry.panel_title.clone(), &entry.fields)
|
||||||
|
} else {
|
||||||
|
(String::from("Entry"), &[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_archive_file() -> Option<PathBuf> {
|
||||||
|
FileDialog::new()
|
||||||
|
.set_title("Open Parkan archive")
|
||||||
|
.pick_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_document(path: &Path) -> Result<DocumentModel, String> {
|
||||||
|
let bytes =
|
||||||
|
fs::read(path).map_err(|err| format!("Failed to read {}: {err}", path.display()))?;
|
||||||
|
let Some(format) = detect_archive_format(&bytes) else {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is not recognized as NRes/RsLi (unsupported magic).",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
match format {
|
||||||
|
ArchiveFormat::Nres => load_nres_document(path),
|
||||||
|
ArchiveFormat::Rsli => load_rsli_document(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_archive_format(bytes: &[u8]) -> Option<ArchiveFormat> {
|
||||||
|
if bytes.len() >= 4 && &bytes[0..4] == b"NRes" {
|
||||||
|
return Some(ArchiveFormat::Nres);
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.len() >= 2 && &bytes[0..2] == b"NL" {
|
||||||
|
return Some(ArchiveFormat::Rsli);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_nres_document(path: &Path) -> Result<DocumentModel, String> {
|
||||||
|
let archive = nres::Archive::open_path(path)
|
||||||
|
.map_err(|err| format!("NRes open failed for {}: {err}", path.display()))?;
|
||||||
|
|
||||||
|
let info = archive.info();
|
||||||
|
let mut archive_fields = vec![
|
||||||
|
FieldRow::new("format", "NRes"),
|
||||||
|
FieldRow::new("file_size", info.file_size.to_string()),
|
||||||
|
FieldRow::new("raw_mode", info.raw_mode.to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(header) = &info.header {
|
||||||
|
archive_fields.push(FieldRow::new(
|
||||||
|
"magic",
|
||||||
|
String::from_utf8_lossy(&header.magic).into_owned(),
|
||||||
|
));
|
||||||
|
archive_fields.push(FieldRow::new("version", format_u32_dec_hex(header.version)));
|
||||||
|
archive_fields.push(FieldRow::new("entry_count", header.entry_count.to_string()));
|
||||||
|
archive_fields.push(FieldRow::new(
|
||||||
|
"total_size",
|
||||||
|
format!("{} (0x{:08X})", header.total_size, header.total_size),
|
||||||
|
));
|
||||||
|
archive_fields.push(FieldRow::new(
|
||||||
|
"directory_offset",
|
||||||
|
header.directory_offset.to_string(),
|
||||||
|
));
|
||||||
|
archive_fields.push(FieldRow::new(
|
||||||
|
"directory_size",
|
||||||
|
header.directory_size.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in archive.entries_inspect() {
|
||||||
|
let meta = entry.meta;
|
||||||
|
let mut fields = vec![
|
||||||
|
FieldRow::new("id", entry.id.0.to_string()),
|
||||||
|
FieldRow::new("name", meta.name.clone()),
|
||||||
|
FieldRow::new("type_id", format_u32_dec_hex(meta.kind)),
|
||||||
|
FieldRow::new("attr1", format_u32_dec_hex(meta.attr1)),
|
||||||
|
FieldRow::new("attr2", format_u32_dec_hex(meta.attr2)),
|
||||||
|
FieldRow::new("attr3", format_u32_dec_hex(meta.attr3)),
|
||||||
|
FieldRow::new("data_offset", meta.data_offset.to_string()),
|
||||||
|
FieldRow::new("data_size", meta.data_size.to_string()),
|
||||||
|
FieldRow::new("sort_index", meta.sort_index.to_string()),
|
||||||
|
FieldRow::new("name_raw_hex", bytes_as_hex(entry.name_raw)),
|
||||||
|
FieldRow::new("name_raw_ascii", bytes_as_ascii(entry.name_raw)),
|
||||||
|
];
|
||||||
|
|
||||||
|
fields.push(FieldRow::new("find_key", meta.name.to_ascii_lowercase()));
|
||||||
|
|
||||||
|
entries.push(EntryView {
|
||||||
|
full_name: meta.name.clone(),
|
||||||
|
panel_title: format!("NRes entry #{}: {}", entry.id.0, meta.name),
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tree_rows = build_tree_rows(&entries);
|
||||||
|
|
||||||
|
Ok(DocumentModel {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
format: ArchiveFormat::Nres,
|
||||||
|
archive_fields,
|
||||||
|
entries,
|
||||||
|
tree_rows,
|
||||||
|
selected: Selection::Archive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_rsli_document(path: &Path) -> Result<DocumentModel, String> {
|
||||||
|
let library = rsli::Library::open_path(path)
|
||||||
|
.map_err(|err| format!("RsLi open failed for {}: {err}", path.display()))?;
|
||||||
|
|
||||||
|
let header = library.header();
|
||||||
|
let mut archive_fields = vec![
|
||||||
|
FieldRow::new("format", "RsLi"),
|
||||||
|
FieldRow::new("magic", String::from_utf8_lossy(&header.magic).into_owned()),
|
||||||
|
FieldRow::new(
|
||||||
|
"reserved",
|
||||||
|
format!("{} (0x{:02X})", header.reserved, header.reserved),
|
||||||
|
),
|
||||||
|
FieldRow::new(
|
||||||
|
"version",
|
||||||
|
format!("{} (0x{:02X})", header.version, header.version),
|
||||||
|
),
|
||||||
|
FieldRow::new("entry_count", header.entry_count.to_string()),
|
||||||
|
FieldRow::new("presorted_flag", format!("0x{:04X}", header.presorted_flag)),
|
||||||
|
FieldRow::new("xor_seed", format!("0x{:08X}", header.xor_seed)),
|
||||||
|
FieldRow::new("header_raw_hex", bytes_as_hex(&header.raw)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(ao) = library.ao_trailer() {
|
||||||
|
archive_fields.push(FieldRow::new("ao_trailer", "present"));
|
||||||
|
archive_fields.push(FieldRow::new("ao_overlay", ao.overlay.to_string()));
|
||||||
|
archive_fields.push(FieldRow::new("ao_raw_hex", bytes_as_hex(&ao.raw)));
|
||||||
|
} else {
|
||||||
|
archive_fields.push(FieldRow::new("ao_trailer", "absent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in library.entries_inspect() {
|
||||||
|
let meta = entry.meta;
|
||||||
|
let method_raw = (meta.flags as u16 as u32) & 0x1E0;
|
||||||
|
|
||||||
|
let fields = vec![
|
||||||
|
FieldRow::new("id", entry.id.0.to_string()),
|
||||||
|
FieldRow::new("name", meta.name.clone()),
|
||||||
|
FieldRow::new(
|
||||||
|
"flags",
|
||||||
|
format!("{} (0x{:04X})", meta.flags, meta.flags as u16),
|
||||||
|
),
|
||||||
|
FieldRow::new("method", format!("{:?}", meta.method)),
|
||||||
|
FieldRow::new("method_raw", format!("0x{:03X}", method_raw)),
|
||||||
|
FieldRow::new("packed_size", meta.packed_size.to_string()),
|
||||||
|
FieldRow::new("unpacked_size", meta.unpacked_size.to_string()),
|
||||||
|
FieldRow::new("data_offset_effective", meta.data_offset.to_string()),
|
||||||
|
FieldRow::new("data_offset_raw", entry.data_offset_raw.to_string()),
|
||||||
|
FieldRow::new("sort_to_original", entry.sort_to_original.to_string()),
|
||||||
|
FieldRow::new("name_raw_hex", bytes_as_hex(entry.name_raw)),
|
||||||
|
FieldRow::new("name_raw_ascii", bytes_as_ascii(entry.name_raw)),
|
||||||
|
FieldRow::new("service_tail_hex", bytes_as_hex(entry.service_tail)),
|
||||||
|
FieldRow::new("service_tail_ascii", bytes_as_ascii(entry.service_tail)),
|
||||||
|
];
|
||||||
|
|
||||||
|
entries.push(EntryView {
|
||||||
|
full_name: meta.name.clone(),
|
||||||
|
panel_title: format!("RsLi entry #{}: {}", entry.id.0, meta.name),
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tree_rows = build_tree_rows(&entries);
|
||||||
|
|
||||||
|
Ok(DocumentModel {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
format: ArchiveFormat::Rsli,
|
||||||
|
archive_fields,
|
||||||
|
entries,
|
||||||
|
tree_rows,
|
||||||
|
selected: Selection::Archive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tree_rows(entries: &[EntryView]) -> Vec<TreeRow> {
|
||||||
|
let mut root = FolderNode::default();
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
insert_tree_path(&mut root, &entry.full_name, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows = vec![TreeRow {
|
||||||
|
depth: 0,
|
||||||
|
label: String::from("[Archive fields]"),
|
||||||
|
selection: Some(Selection::Archive),
|
||||||
|
}];
|
||||||
|
|
||||||
|
flatten_tree(&root, 0, &mut rows);
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_tree_path(root: &mut FolderNode, full_name: &str, entry_index: usize) {
|
||||||
|
let mut parts: Vec<&str> = full_name
|
||||||
|
.split(['/', '\\'])
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
parts.push(full_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.len() == 1 {
|
||||||
|
root.files.push((parts[0].to_string(), entry_index));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = parts.pop().unwrap_or(full_name);
|
||||||
|
let mut node = root;
|
||||||
|
for part in parts {
|
||||||
|
node = node.folders.entry(part.to_string()).or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
node.files.push((file_name.to_string(), entry_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_tree(node: &FolderNode, depth: u16, out: &mut Vec<TreeRow>) {
|
||||||
|
for (folder_name, folder_node) in &node.folders {
|
||||||
|
out.push(TreeRow {
|
||||||
|
depth,
|
||||||
|
label: format!("{folder_name}/"),
|
||||||
|
selection: None,
|
||||||
|
});
|
||||||
|
flatten_tree(folder_node, depth.saturating_add(1), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files = node.files.clone();
|
||||||
|
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||||||
|
|
||||||
|
for (name, index) in files {
|
||||||
|
out.push(TreeRow {
|
||||||
|
depth,
|
||||||
|
label: name,
|
||||||
|
selection: Some(Selection::Entry(index)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_as_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (index, byte) in bytes.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
let _ = write!(&mut out, "{byte:02X}");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_as_ascii(bytes: &[u8]) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|byte| {
|
||||||
|
if byte.is_ascii_graphic() || *byte == b' ' {
|
||||||
|
char::from(*byte)
|
||||||
|
} else {
|
||||||
|
'.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_u32_dec_hex(value: u32) -> String {
|
||||||
|
format!("{} (0x{:08X})", value, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct DocumentModel {
|
||||||
|
path: PathBuf,
|
||||||
|
format: ArchiveFormat,
|
||||||
|
archive_fields: Vec<FieldRow>,
|
||||||
|
entries: Vec<EntryView>,
|
||||||
|
tree_rows: Vec<TreeRow>,
|
||||||
|
selected: Selection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum ArchiveFormat {
|
||||||
|
Nres,
|
||||||
|
Rsli,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArchiveFormat {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Nres => "NRes",
|
||||||
|
Self::Rsli => "RsLi",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct EntryView {
|
||||||
|
full_name: String,
|
||||||
|
panel_title: String,
|
||||||
|
fields: Vec<FieldRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct FieldRow {
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldRow {
|
||||||
|
fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
key: key.into(),
|
||||||
|
value: value.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TreeRow {
|
||||||
|
depth: u16,
|
||||||
|
label: String,
|
||||||
|
selection: Option<Selection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Selection {
|
||||||
|
Archive,
|
||||||
|
Entry(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
struct FolderNode {
|
||||||
|
folders: BTreeMap<String, FolderNode>,
|
||||||
|
files: Vec<(String, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_builds_nested_paths() {
|
||||||
|
let entries = vec![
|
||||||
|
EntryView {
|
||||||
|
full_name: String::from("textures/ui/hud.texm"),
|
||||||
|
panel_title: String::new(),
|
||||||
|
fields: vec![],
|
||||||
|
},
|
||||||
|
EntryView {
|
||||||
|
full_name: String::from("textures/world/ground.texm"),
|
||||||
|
panel_title: String::new(),
|
||||||
|
fields: vec![],
|
||||||
|
},
|
||||||
|
EntryView {
|
||||||
|
full_name: String::from("root_file.msh"),
|
||||||
|
panel_title: String::new(),
|
||||||
|
fields: vec![],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let rows = build_tree_rows(&entries);
|
||||||
|
assert!(rows.iter().any(|row| row.label == "textures/"));
|
||||||
|
assert!(rows.iter().any(|row| row.label == "ui/"));
|
||||||
|
assert!(rows.iter().any(|row| row.label == "hud.texm"));
|
||||||
|
assert!(rows.iter().any(|row| row.label == "root_file.msh"));
|
||||||
|
}
|
||||||
|
}
|
||||||
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]
|
||||||
61
crates/common/src/lib.rs
Normal file
61
crates/common/src/lib.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively collects all files under `root`.
|
||||||
|
pub 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
crates/msh-core/Cargo.toml
Normal file
12
crates/msh-core/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "msh-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
proptest = "1"
|
||||||
14
crates/msh-core/README.md
Normal file
14
crates/msh-core/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# msh-core
|
||||||
|
|
||||||
|
Парсер core-части формата `MSH`.
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
|
||||||
|
- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные);
|
||||||
|
- `Res4`, `Res5`, `Res10` (опциональные);
|
||||||
|
- slot lookup по `node/lod/group`.
|
||||||
|
|
||||||
|
Тесты:
|
||||||
|
|
||||||
|
- прогон по всем `.msh` в `testdata`;
|
||||||
|
- синтетическая минимальная модель.
|
||||||
75
crates/msh-core/src/error.rs
Normal file
75
crates/msh-core/src/error.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Error {
|
||||||
|
Nres(nres::error::Error),
|
||||||
|
MissingResource {
|
||||||
|
kind: u32,
|
||||||
|
label: &'static str,
|
||||||
|
},
|
||||||
|
InvalidResourceSize {
|
||||||
|
label: &'static str,
|
||||||
|
size: usize,
|
||||||
|
stride: usize,
|
||||||
|
},
|
||||||
|
InvalidRes2Size {
|
||||||
|
size: usize,
|
||||||
|
},
|
||||||
|
UnsupportedNodeStride {
|
||||||
|
stride: usize,
|
||||||
|
},
|
||||||
|
IndexOutOfBounds {
|
||||||
|
label: &'static str,
|
||||||
|
index: usize,
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
IntegerOverflow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nres::error::Error> for Error {
|
||||||
|
fn from(value: nres::error::Error) -> Self {
|
||||||
|
Self::Nres(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Nres(err) => write!(f, "{err}"),
|
||||||
|
Self::MissingResource { kind, label } => {
|
||||||
|
write!(f, "missing required resource type={kind} ({label})")
|
||||||
|
}
|
||||||
|
Self::InvalidResourceSize {
|
||||||
|
label,
|
||||||
|
size,
|
||||||
|
stride,
|
||||||
|
} => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"invalid {label} size={size}, expected multiple of stride={stride}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::InvalidRes2Size { size } => {
|
||||||
|
write!(f, "invalid Res2 size={size}, expected >= 140")
|
||||||
|
}
|
||||||
|
Self::UnsupportedNodeStride { stride } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"unsupported Res1 node stride={stride}, expected 38 or 24"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::IndexOutOfBounds {
|
||||||
|
label,
|
||||||
|
index,
|
||||||
|
limit,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"{label} index out of bounds: index={index}, limit={limit}"
|
||||||
|
),
|
||||||
|
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {}
|
||||||
434
crates/msh-core/src/lib.rs
Normal file
434
crates/msh-core/src/lib.rs
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
pub mod error;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
pub const RES1_NODE_TABLE: u32 = 1;
|
||||||
|
pub const RES2_SLOTS: u32 = 2;
|
||||||
|
pub const RES3_POSITIONS: u32 = 3;
|
||||||
|
pub const RES4_NORMALS: u32 = 4;
|
||||||
|
pub const RES5_UV0: u32 = 5;
|
||||||
|
pub const RES6_INDICES: u32 = 6;
|
||||||
|
pub const RES10_NAMES: u32 = 10;
|
||||||
|
pub const RES13_BATCHES: u32 = 13;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Slot {
|
||||||
|
pub tri_start: u16,
|
||||||
|
pub tri_count: u16,
|
||||||
|
pub batch_start: u16,
|
||||||
|
pub batch_count: u16,
|
||||||
|
pub aabb_min: [f32; 3],
|
||||||
|
pub aabb_max: [f32; 3],
|
||||||
|
pub sphere_center: [f32; 3],
|
||||||
|
pub sphere_radius: f32,
|
||||||
|
pub opaque: [u32; 5],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Batch {
|
||||||
|
pub batch_flags: u16,
|
||||||
|
pub material_index: u16,
|
||||||
|
pub opaque4: u16,
|
||||||
|
pub opaque6: u16,
|
||||||
|
pub index_count: u16,
|
||||||
|
pub index_start: u32,
|
||||||
|
pub opaque14: u16,
|
||||||
|
pub base_vertex: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Model {
|
||||||
|
pub node_stride: usize,
|
||||||
|
pub node_count: usize,
|
||||||
|
pub nodes_raw: Vec<u8>,
|
||||||
|
pub slots: Vec<Slot>,
|
||||||
|
pub positions: Vec<[f32; 3]>,
|
||||||
|
pub normals: Option<Vec<[i8; 4]>>,
|
||||||
|
pub uv0: Option<Vec<[i16; 2]>>,
|
||||||
|
pub indices: Vec<u16>,
|
||||||
|
pub batches: Vec<Batch>,
|
||||||
|
pub node_names: Option<Vec<Option<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub fn slot_index(&self, node_index: usize, lod: usize, group: usize) -> Option<usize> {
|
||||||
|
if node_index >= self.node_count || lod >= 3 || group >= 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self.node_stride != 38 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let node_off = node_index.checked_mul(self.node_stride)?;
|
||||||
|
let matrix_off = node_off.checked_add(8)?;
|
||||||
|
let word_off = matrix_off.checked_add((lod * 5 + group) * 2)?;
|
||||||
|
let raw = read_u16(&self.nodes_raw, word_off).ok()?;
|
||||||
|
if raw == u16::MAX {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let idx = usize::from(raw);
|
||||||
|
if idx >= self.slots.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_model_payload(payload: &[u8]) -> Result<Model> {
|
||||||
|
let archive = nres::Archive::open_bytes(
|
||||||
|
Arc::from(payload.to_vec().into_boxed_slice()),
|
||||||
|
nres::OpenOptions::default(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let res1 = read_required(&archive, RES1_NODE_TABLE, "Res1")?;
|
||||||
|
let res2 = read_required(&archive, RES2_SLOTS, "Res2")?;
|
||||||
|
let res3 = read_required(&archive, RES3_POSITIONS, "Res3")?;
|
||||||
|
let res6 = read_required(&archive, RES6_INDICES, "Res6")?;
|
||||||
|
let res13 = read_required(&archive, RES13_BATCHES, "Res13")?;
|
||||||
|
|
||||||
|
let res4 = read_optional(&archive, RES4_NORMALS)?;
|
||||||
|
let res5 = read_optional(&archive, RES5_UV0)?;
|
||||||
|
let res10 = read_optional(&archive, RES10_NAMES)?;
|
||||||
|
|
||||||
|
let node_stride = usize::try_from(res1.meta.attr3).map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
if node_stride != 38 && node_stride != 24 {
|
||||||
|
return Err(Error::UnsupportedNodeStride {
|
||||||
|
stride: node_stride,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if res1.bytes.len() % node_stride != 0 {
|
||||||
|
return Err(Error::InvalidResourceSize {
|
||||||
|
label: "Res1",
|
||||||
|
size: res1.bytes.len(),
|
||||||
|
stride: node_stride,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let node_count = res1.bytes.len() / node_stride;
|
||||||
|
|
||||||
|
if res2.bytes.len() < 0x8C {
|
||||||
|
return Err(Error::InvalidRes2Size {
|
||||||
|
size: res2.bytes.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let slot_blob = res2
|
||||||
|
.bytes
|
||||||
|
.len()
|
||||||
|
.checked_sub(0x8C)
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
if slot_blob % 68 != 0 {
|
||||||
|
return Err(Error::InvalidResourceSize {
|
||||||
|
label: "Res2.slots",
|
||||||
|
size: slot_blob,
|
||||||
|
stride: 68,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let slot_count = slot_blob / 68;
|
||||||
|
let mut slots = Vec::with_capacity(slot_count);
|
||||||
|
for i in 0..slot_count {
|
||||||
|
let off = 0x8Cusize
|
||||||
|
.checked_add(i.checked_mul(68).ok_or(Error::IntegerOverflow)?)
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
slots.push(Slot {
|
||||||
|
tri_start: read_u16(&res2.bytes, off)?,
|
||||||
|
tri_count: read_u16(&res2.bytes, off + 2)?,
|
||||||
|
batch_start: read_u16(&res2.bytes, off + 4)?,
|
||||||
|
batch_count: read_u16(&res2.bytes, off + 6)?,
|
||||||
|
aabb_min: [
|
||||||
|
read_f32(&res2.bytes, off + 8)?,
|
||||||
|
read_f32(&res2.bytes, off + 12)?,
|
||||||
|
read_f32(&res2.bytes, off + 16)?,
|
||||||
|
],
|
||||||
|
aabb_max: [
|
||||||
|
read_f32(&res2.bytes, off + 20)?,
|
||||||
|
read_f32(&res2.bytes, off + 24)?,
|
||||||
|
read_f32(&res2.bytes, off + 28)?,
|
||||||
|
],
|
||||||
|
sphere_center: [
|
||||||
|
read_f32(&res2.bytes, off + 32)?,
|
||||||
|
read_f32(&res2.bytes, off + 36)?,
|
||||||
|
read_f32(&res2.bytes, off + 40)?,
|
||||||
|
],
|
||||||
|
sphere_radius: read_f32(&res2.bytes, off + 44)?,
|
||||||
|
opaque: [
|
||||||
|
read_u32(&res2.bytes, off + 48)?,
|
||||||
|
read_u32(&res2.bytes, off + 52)?,
|
||||||
|
read_u32(&res2.bytes, off + 56)?,
|
||||||
|
read_u32(&res2.bytes, off + 60)?,
|
||||||
|
read_u32(&res2.bytes, off + 64)?,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let positions = parse_positions(&res3.bytes)?;
|
||||||
|
let indices = parse_u16_array(&res6.bytes, "Res6")?;
|
||||||
|
let batches = parse_batches(&res13.bytes)?;
|
||||||
|
validate_slot_batch_ranges(&slots, batches.len())?;
|
||||||
|
validate_batch_index_ranges(&batches, indices.len())?;
|
||||||
|
|
||||||
|
let normals = match res4 {
|
||||||
|
Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let uv0 = match res5 {
|
||||||
|
Some(raw) => Some(parse_i16x2_array(&raw.bytes, "Res5")?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let node_names = match res10 {
|
||||||
|
Some(raw) => Some(parse_res10_names(&raw.bytes, node_count)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Model {
|
||||||
|
node_stride,
|
||||||
|
node_count,
|
||||||
|
nodes_raw: res1.bytes,
|
||||||
|
slots,
|
||||||
|
positions,
|
||||||
|
normals,
|
||||||
|
uv0,
|
||||||
|
indices,
|
||||||
|
batches,
|
||||||
|
node_names,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<()> {
|
||||||
|
for slot in slots {
|
||||||
|
let start = usize::from(slot.batch_start);
|
||||||
|
let end = start
|
||||||
|
.checked_add(usize::from(slot.batch_count))
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
if end > batch_count {
|
||||||
|
return Err(Error::IndexOutOfBounds {
|
||||||
|
label: "Res2.batch_range",
|
||||||
|
index: end,
|
||||||
|
limit: batch_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<()> {
|
||||||
|
for batch in batches {
|
||||||
|
let start = usize::try_from(batch.index_start).map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
let end = start
|
||||||
|
.checked_add(usize::from(batch.index_count))
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
if end > index_count {
|
||||||
|
return Err(Error::IndexOutOfBounds {
|
||||||
|
label: "Res13.index_range",
|
||||||
|
index: end,
|
||||||
|
limit: index_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>> {
|
||||||
|
if !data.len().is_multiple_of(12) {
|
||||||
|
return Err(Error::InvalidResourceSize {
|
||||||
|
label: "Res3",
|
||||||
|
size: data.len(),
|
||||||
|
stride: 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let count = data.len() / 12;
|
||||||
|
let mut out = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let off = i * 12;
|
||||||
|
out.push([
|
||||||
|
read_f32(data, off)?,
|
||||||
|
read_f32(data, off + 4)?,
|
||||||
|
read_f32(data, off + 8)?,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_batches(data: &[u8]) -> Result<Vec<Batch>> {
|
||||||
|
if !data.len().is_multiple_of(20) {
|
||||||
|
return Err(Error::InvalidResourceSize {
|
||||||
|
label: "Res13",
|
||||||
|
size: data.len(),
|
||||||
|
stride: 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let count = data.len() / 20;
|
||||||
|
let mut out = Vec::with_capacity(count);
|
||||||
|
for i in 0..count {
|
||||||
|
let off = i * 20;
|
||||||
|
out.push(Batch {
|
||||||
|
batch_flags: read_u16(data, off)?,
|
||||||
|
material_index: read_u16(data, off + 2)?,
|
||||||
|
opaque4: read_u16(data, off + 4)?,
|
||||||
|
opaque6: read_u16(data, off + 6)?,
|
||||||
|
index_count: read_u16(data, off + 8)?,
|
||||||
|
index_start: read_u32(data, off + 10)?,
|
||||||
|
opaque14: read_u16(data, off + 14)?,
|
||||||
|
base_vertex: read_u32(data, off + 16)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>> {
|
||||||
|
if !data.len().is_multiple_of(2) {
|
||||||
|
return Err(Error::InvalidResourceSize {
|
||||||
|
label,
|
||||||
|
size: data.len(),
|
||||||
|
stride: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(data.len() / 2);
|
||||||
|
for i in (0..data.len()).step_by(2) {
|
||||||
|
out.push(read_u16(data, i)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>> {
|
||||||
|
if !data.len().is_multiple_of(4) {
|
||||||
|
return Err(Error::InvalidResourceSize {
|
||||||
|
label,
|
||||||
|
size: data.len(),
|
||||||
|
stride: 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(data.len() / 4);
|
||||||
|
for i in (0..data.len()).step_by(4) {
|
||||||
|
out.push([
|
||||||
|
read_i8(data, i)?,
|
||||||
|
read_i8(data, i + 1)?,
|
||||||
|
read_i8(data, i + 2)?,
|
||||||
|
read_i8(data, i + 3)?,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>> {
|
||||||
|
if !data.len().is_multiple_of(4) {
|
||||||
|
return Err(Error::InvalidResourceSize {
|
||||||
|
label,
|
||||||
|
size: data.len(),
|
||||||
|
stride: 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(data.len() / 4);
|
||||||
|
for i in (0..data.len()).step_by(4) {
|
||||||
|
out.push([read_i16(data, i)?, read_i16(data, i + 2)?]);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>> {
|
||||||
|
let mut out = Vec::with_capacity(node_count);
|
||||||
|
let mut off = 0usize;
|
||||||
|
for _ in 0..node_count {
|
||||||
|
let len = usize::try_from(read_u32(data, off)?).map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
off = off.checked_add(4).ok_or(Error::IntegerOverflow)?;
|
||||||
|
if len == 0 {
|
||||||
|
out.push(None);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let need = len.checked_add(1).ok_or(Error::IntegerOverflow)?;
|
||||||
|
let end = off.checked_add(need).ok_or(Error::IntegerOverflow)?;
|
||||||
|
let slice = data.get(off..end).ok_or(Error::InvalidResourceSize {
|
||||||
|
label: "Res10",
|
||||||
|
size: data.len(),
|
||||||
|
stride: 1,
|
||||||
|
})?;
|
||||||
|
let text = if slice.last().copied() == Some(0) {
|
||||||
|
&slice[..slice.len().saturating_sub(1)]
|
||||||
|
} else {
|
||||||
|
slice
|
||||||
|
};
|
||||||
|
let decoded = decode_cp1251(text);
|
||||||
|
out.push(Some(decoded));
|
||||||
|
off = end;
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RawResource {
|
||||||
|
meta: nres::EntryMeta,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_required(archive: &nres::Archive, kind: u32, label: &'static str) -> Result<RawResource> {
|
||||||
|
let id = archive
|
||||||
|
.entries()
|
||||||
|
.find(|entry| entry.meta.kind == kind)
|
||||||
|
.map(|entry| entry.id)
|
||||||
|
.ok_or(Error::MissingResource { kind, label })?;
|
||||||
|
let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
|
||||||
|
label,
|
||||||
|
index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
|
||||||
|
limit: archive.entry_count(),
|
||||||
|
})?;
|
||||||
|
let data = archive.read(id)?.into_owned();
|
||||||
|
Ok(RawResource {
|
||||||
|
meta: entry.meta.clone(),
|
||||||
|
bytes: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_optional(archive: &nres::Archive, kind: u32) -> Result<Option<RawResource>> {
|
||||||
|
let Some(id) = archive
|
||||||
|
.entries()
|
||||||
|
.find(|entry| entry.meta.kind == kind)
|
||||||
|
.map(|entry| entry.id)
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
|
||||||
|
label: "optional",
|
||||||
|
index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
|
||||||
|
limit: archive.entry_count(),
|
||||||
|
})?;
|
||||||
|
let data = archive.read(id)?.into_owned();
|
||||||
|
Ok(Some(RawResource {
|
||||||
|
meta: entry.meta.clone(),
|
||||||
|
bytes: data,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u16(data: &[u8], offset: usize) -> Result<u16> {
|
||||||
|
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
|
||||||
|
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
Ok(u16::from_le_bytes(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
|
||||||
|
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
|
||||||
|
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
Ok(i16::from_le_bytes(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i8(data: &[u8], offset: usize) -> Result<i8> {
|
||||||
|
let byte = data.get(offset).copied().ok_or(Error::IntegerOverflow)?;
|
||||||
|
Ok(i8::from_le_bytes([byte]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||||
|
let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
|
||||||
|
let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
Ok(u32::from_le_bytes(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_f32(data: &[u8], offset: usize) -> Result<f32> {
|
||||||
|
Ok(f32::from_bits(read_u32(data, offset)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
438
crates/msh-core/src/tests.rs
Normal file
438
crates/msh-core/src/tests.rs
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use nres::Archive;
|
||||||
|
use proptest::prelude::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn nres_test_files() -> Vec<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|path| {
|
||||||
|
fs::read(path)
|
||||||
|
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_msh_name(name: &str) -> bool {
|
||||||
|
name.to_ascii_lowercase().ends_with(".msh")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SyntheticEntry {
|
||||||
|
kind: u32,
|
||||||
|
name: String,
|
||||||
|
attr1: u32,
|
||||||
|
attr2: u32,
|
||||||
|
attr3: u32,
|
||||||
|
data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_nested_nres(entries: &[SyntheticEntry]) -> Vec<u8> {
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
payload.extend_from_slice(b"NRes");
|
||||||
|
payload.extend_from_slice(&0x100u32.to_le_bytes());
|
||||||
|
payload.extend_from_slice(
|
||||||
|
&u32::try_from(entries.len())
|
||||||
|
.expect("entry count overflow in test")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
|
||||||
|
|
||||||
|
let mut resource_offsets = Vec::with_capacity(entries.len());
|
||||||
|
for entry in entries {
|
||||||
|
resource_offsets.push(u32::try_from(payload.len()).expect("offset overflow in test"));
|
||||||
|
payload.extend_from_slice(&entry.data);
|
||||||
|
while !payload.len().is_multiple_of(8) {
|
||||||
|
payload.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
payload.extend_from_slice(&entry.kind.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&entry.attr1.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&entry.attr2.to_le_bytes());
|
||||||
|
payload.extend_from_slice(
|
||||||
|
&u32::try_from(entry.data.len())
|
||||||
|
.expect("size overflow in test")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
payload.extend_from_slice(&entry.attr3.to_le_bytes());
|
||||||
|
|
||||||
|
let mut name_raw = [0u8; 36];
|
||||||
|
let name_bytes = entry.name.as_bytes();
|
||||||
|
assert!(name_bytes.len() <= 35, "name too long for synthetic test");
|
||||||
|
name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
|
||||||
|
payload.extend_from_slice(&name_raw);
|
||||||
|
|
||||||
|
payload.extend_from_slice(&resource_offsets[index].to_le_bytes());
|
||||||
|
payload.extend_from_slice(&(index as u32).to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_size = u32::try_from(payload.len()).expect("size overflow in test");
|
||||||
|
payload[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthetic_entry(kind: u32, name: &str, attr3: u32, data: Vec<u8>) -> SyntheticEntry {
|
||||||
|
SyntheticEntry {
|
||||||
|
kind,
|
||||||
|
name: name.to_string(),
|
||||||
|
attr1: 1,
|
||||||
|
attr2: 0,
|
||||||
|
attr3,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res1_stride38_nodes(node_count: usize, node0_slot00: Option<u16>) -> Vec<u8> {
|
||||||
|
let mut out = vec![0u8; node_count.saturating_mul(38)];
|
||||||
|
for node in 0..node_count {
|
||||||
|
let node_off = node * 38;
|
||||||
|
for i in 0..15 {
|
||||||
|
let off = node_off + 8 + i * 2;
|
||||||
|
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(slot) = node0_slot00 {
|
||||||
|
out[8..10].copy_from_slice(&slot.to_le_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res1_stride24_nodes(node_count: usize) -> Vec<u8> {
|
||||||
|
vec![0u8; node_count.saturating_mul(24)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res2_single_slot(batch_start: u16, batch_count: u16) -> Vec<u8> {
|
||||||
|
let mut res2 = vec![0u8; 0x8C + 68];
|
||||||
|
res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start
|
||||||
|
res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count
|
||||||
|
res2[0x8C + 4..0x8C + 6].copy_from_slice(&batch_start.to_le_bytes()); // batch_start
|
||||||
|
res2[0x8C + 6..0x8C + 8].copy_from_slice(&batch_count.to_le_bytes()); // batch_count
|
||||||
|
res2
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res3_triangle_positions() -> Vec<u8> {
|
||||||
|
[0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|v| v.to_le_bytes())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res4_normals() -> Vec<u8> {
|
||||||
|
vec![127u8, 0u8, 128u8, 0u8]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res5_uv0() -> Vec<u8> {
|
||||||
|
[1024i16, -1024i16]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|v| v.to_le_bytes())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res6_triangle_indices() -> Vec<u8> {
|
||||||
|
[0u16, 1u16, 2u16]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|v| v.to_le_bytes())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res13_single_batch(index_start: u32, index_count: u16) -> Vec<u8> {
|
||||||
|
let mut batch = vec![0u8; 20];
|
||||||
|
batch[0..2].copy_from_slice(&0u16.to_le_bytes());
|
||||||
|
batch[2..4].copy_from_slice(&0u16.to_le_bytes());
|
||||||
|
batch[8..10].copy_from_slice(&index_count.to_le_bytes());
|
||||||
|
batch[10..14].copy_from_slice(&index_start.to_le_bytes());
|
||||||
|
batch[16..20].copy_from_slice(&0u32.to_le_bytes());
|
||||||
|
batch
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
match name {
|
||||||
|
Some(name) => {
|
||||||
|
out.extend_from_slice(
|
||||||
|
&u32::try_from(name.len())
|
||||||
|
.expect("name size overflow in test")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
out.extend_from_slice(name);
|
||||||
|
out.push(0);
|
||||||
|
}
|
||||||
|
None => out.extend_from_slice(&0u32.to_le_bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res10_names(names: &[Option<&str>]) -> Vec<u8> {
|
||||||
|
let raw: Vec<Option<&[u8]>> = names.iter().map(|name| name.map(str::as_bytes)).collect();
|
||||||
|
res10_names_raw(&raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_synthetic_entries() -> Vec<SyntheticEntry> {
|
||||||
|
vec![
|
||||||
|
synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))),
|
||||||
|
synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 1)),
|
||||||
|
synthetic_entry(RES3_POSITIONS, "Res3", 12, res3_triangle_positions()),
|
||||||
|
synthetic_entry(RES6_INDICES, "Res6", 2, res6_triangle_indices()),
|
||||||
|
synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(0, 3)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_all_game_msh_models() {
|
||||||
|
let archives = nres_test_files();
|
||||||
|
if archives.is_empty() {
|
||||||
|
eprintln!("skipping parse_all_game_msh_models: no NRes files in testdata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut model_count = 0usize;
|
||||||
|
let mut renderable_count = 0usize;
|
||||||
|
let mut legacy_stride24_count = 0usize;
|
||||||
|
|
||||||
|
for archive_path in archives {
|
||||||
|
let archive = Archive::open_path(&archive_path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
|
||||||
|
|
||||||
|
for entry in archive.entries() {
|
||||||
|
if !is_msh_name(&entry.meta.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
model_count += 1;
|
||||||
|
let payload = archive.read(entry.id).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"failed to read model '{}' in {}: {err}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"failed to parse model '{}' in {}: {err}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if model.node_stride == 24 {
|
||||||
|
legacy_stride24_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for node_index in 0..model.node_count {
|
||||||
|
for lod in 0..3 {
|
||||||
|
for group in 0..5 {
|
||||||
|
if let Some(slot_idx) = model.slot_index(node_index, lod, group) {
|
||||||
|
assert!(
|
||||||
|
slot_idx < model.slots.len(),
|
||||||
|
"slot index out of bounds in '{}' ({})",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut has_renderable_batch = false;
|
||||||
|
for node_index in 0..model.node_count {
|
||||||
|
let Some(slot_idx) = model.slot_index(node_index, 0, 0) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let slot = &model.slots[slot_idx];
|
||||||
|
let batch_end =
|
||||||
|
usize::from(slot.batch_start).saturating_add(usize::from(slot.batch_count));
|
||||||
|
if batch_end > model.batches.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for batch in &model.batches[usize::from(slot.batch_start)..batch_end] {
|
||||||
|
let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
|
||||||
|
let index_count = usize::from(batch.index_count);
|
||||||
|
let end = index_start.saturating_add(index_count);
|
||||||
|
if end <= model.indices.len() && index_count >= 3 {
|
||||||
|
has_renderable_batch = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_renderable_batch {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if has_renderable_batch {
|
||||||
|
renderable_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(model_count > 0, "no .msh entries found");
|
||||||
|
assert!(
|
||||||
|
renderable_count > 0,
|
||||||
|
"no renderable models (lod0/group0) were detected"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
legacy_stride24_count <= model_count,
|
||||||
|
"internal test accounting error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_minimal_synthetic_model() {
|
||||||
|
let payload = build_nested_nres(&base_synthetic_entries());
|
||||||
|
let model = parse_model_payload(&payload).expect("failed to parse synthetic model");
|
||||||
|
assert_eq!(model.node_count, 1);
|
||||||
|
assert_eq!(model.positions.len(), 3);
|
||||||
|
assert_eq!(model.indices.len(), 3);
|
||||||
|
assert_eq!(model.batches.len(), 1);
|
||||||
|
assert_eq!(model.slot_index(0, 0, 0), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_synthetic_stride24_variant() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 24, res1_stride24_nodes(1));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
let model = parse_model_payload(&payload).expect("failed to parse stride24 model");
|
||||||
|
assert_eq!(model.node_stride, 24);
|
||||||
|
assert_eq!(model.node_count, 1);
|
||||||
|
assert_eq!(model.slot_index(0, 0, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_synthetic_model_with_optional_res4_res5_res10() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, res4_normals()));
|
||||||
|
entries.push(synthetic_entry(RES5_UV0, "Res5", 4, res5_uv0()));
|
||||||
|
entries.push(synthetic_entry(
|
||||||
|
RES10_NAMES,
|
||||||
|
"Res10",
|
||||||
|
1,
|
||||||
|
res10_names(&[Some("Hull"), None]),
|
||||||
|
));
|
||||||
|
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(2, Some(0)));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
let model = parse_model_payload(&payload).expect("failed to parse model with optional data");
|
||||||
|
assert_eq!(model.node_count, 2);
|
||||||
|
assert_eq!(model.normals.as_ref().map(Vec::len), Some(1));
|
||||||
|
assert_eq!(model.uv0.as_ref().map(Vec::len), Some(1));
|
||||||
|
assert_eq!(model.node_names, Some(vec![Some("Hull".to_string()), None]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_res10_names_decodes_cp1251() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0)));
|
||||||
|
entries.push(synthetic_entry(
|
||||||
|
RES10_NAMES,
|
||||||
|
"Res10",
|
||||||
|
1,
|
||||||
|
res10_names_raw(&[Some(&[0xC0])]),
|
||||||
|
));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
let model = parse_model_payload(&payload).expect("failed to parse model with cp1251 name");
|
||||||
|
assert_eq!(model.node_names, Some(vec![Some("А".to_string())]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_when_required_resource_missing() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries.retain(|entry| entry.kind != RES13_BATCHES);
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::MissingResource {
|
||||||
|
kind: RES13_BATCHES,
|
||||||
|
label: "Res13"
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_invalid_res2_size() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, vec![0u8; 0x8B]);
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::InvalidRes2Size { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_unsupported_node_stride() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 30, vec![0u8; 30]);
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::UnsupportedNodeStride { stride: 30 })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_invalid_optional_resource_size() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, vec![1, 2, 3]));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::InvalidResourceSize { label: "Res4", .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_slot_batch_range_out_of_bounds() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 2));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::IndexOutOfBounds {
|
||||||
|
label: "Res2.batch_range",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_batch_index_range_out_of_bounds() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[4] = synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(1, 3));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::IndexOutOfBounds {
|
||||||
|
label: "Res13.index_range",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_model_payload_never_panics_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..8192)) {
|
||||||
|
let _ = parse_model_payload(&data);
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
772
crates/nres/src/lib.rs
Normal file
772
crates/nres/src/lib.rs
Normal file
@@ -0,0 +1,772 @@
|
|||||||
|
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(Clone, Debug)]
|
||||||
|
pub struct ArchiveHeader {
|
||||||
|
pub magic: [u8; 4],
|
||||||
|
pub version: u32,
|
||||||
|
pub entry_count: u32,
|
||||||
|
pub total_size: u32,
|
||||||
|
pub directory_offset: u64,
|
||||||
|
pub directory_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ArchiveInfo {
|
||||||
|
pub raw_mode: bool,
|
||||||
|
pub file_size: u64,
|
||||||
|
pub header: Option<ArchiveHeader>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Archive {
|
||||||
|
bytes: Arc<[u8]>,
|
||||||
|
entries: Vec<EntryRecord>,
|
||||||
|
info: ArchiveInfo,
|
||||||
|
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(Copy, Clone, Debug)]
|
||||||
|
pub struct EntryInspect<'a> {
|
||||||
|
pub id: EntryId,
|
||||||
|
pub meta: &'a EntryMeta,
|
||||||
|
pub name_raw: &'a [u8; 36],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 file_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
let (entries, header) = parse_archive(&bytes, opts.raw_mode)?;
|
||||||
|
if opts.prefetch_pages {
|
||||||
|
prefetch_pages(&bytes);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
bytes,
|
||||||
|
entries,
|
||||||
|
info: ArchiveInfo {
|
||||||
|
raw_mode: opts.raw_mode,
|
||||||
|
file_size,
|
||||||
|
header,
|
||||||
|
},
|
||||||
|
raw_mode: opts.raw_mode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info(&self) -> &ArchiveInfo {
|
||||||
|
&self.info
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry_count(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||||
|
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
Some(EntryRef {
|
||||||
|
id: EntryId(id),
|
||||||
|
meta: &entry.meta,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
|
||||||
|
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
Some(EntryInspect {
|
||||||
|
id: EntryId(id),
|
||||||
|
meta: &entry.meta,
|
||||||
|
name_raw: &entry.name_raw,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
let id = u32::try_from(target_idx).ok()?;
|
||||||
|
return Some(EntryId(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
||||||
|
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
|
||||||
|
== Ordering::Equal
|
||||||
|
{
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
Some(EntryId(id))
|
||||||
|
} 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 inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
|
||||||
|
let idx = usize::try_from(id.0).ok()?;
|
||||||
|
let entry = self.entries.get(idx)?;
|
||||||
|
Some(EntryInspect {
|
||||||
|
id,
|
||||||
|
meta: &entry.meta,
|
||||||
|
name_raw: &entry.name_raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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: saturating_u32_len(self.entries.len()),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
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().filter_map(|(idx, entry)| {
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
Some(EntryRef {
|
||||||
|
id: EntryId(id),
|
||||||
|
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: saturating_u32_len(self.entries.len()),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
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: saturating_u32_len(self.entries.len()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
// sort_index stores the original-entry index at sorted position `idx`.
|
||||||
|
// This mirrors the format emitted by the retail assets and test fixtures.
|
||||||
|
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>, Option<ArchiveHeader>)> {
|
||||||
|
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], None));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
Some(ArchiveHeader {
|
||||||
|
magic: *b"NRes",
|
||||||
|
version,
|
||||||
|
entry_count: u32::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?,
|
||||||
|
total_size,
|
||||||
|
directory_offset,
|
||||||
|
directory_size: directory_len,
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 saturating_u32_len(len: usize) -> u32 {
|
||||||
|
u32::try_from(len).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prefetch_pages(bytes: &[u8]) {
|
||||||
|
use std::hint::black_box;
|
||||||
|
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let mut sink = 0u8;
|
||||||
|
while cursor < bytes.len() {
|
||||||
|
sink ^= bytes[cursor];
|
||||||
|
cursor = cursor.saturating_add(4096);
|
||||||
|
}
|
||||||
|
black_box(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();
|
||||||
|
|
||||||
|
// SAFETY: pointers reference NUL-terminated UTF-16 buffers that stay alive
|
||||||
|
// for the duration of the call; flags and argument contract match WinAPI.
|
||||||
|
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;
|
||||||
983
crates/nres/src/tests.rs
Normal file
983
crates/nres/src/tests.rs
Normal file
@@ -0,0 +1,983 @@
|
|||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
11
crates/render-core/Cargo.toml
Normal file
11
crates/render-core/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "render-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
msh-core = { path = "../msh-core" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
nres = { path = "../nres" }
|
||||||
14
crates/render-core/README.md
Normal file
14
crates/render-core/README.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# render-core
|
||||||
|
|
||||||
|
CPU-подготовка draw-данных для моделей `MSH`.
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
|
||||||
|
- обход `node -> slot -> batch`;
|
||||||
|
- раскрытие индексов в triangle-list (`position + uv0`);
|
||||||
|
- расчёт bounds по вершинам.
|
||||||
|
|
||||||
|
Тесты:
|
||||||
|
|
||||||
|
- построение рендер-сеток на реальных `.msh` из `testdata`;
|
||||||
|
- unit-test bounds.
|
||||||
146
crates/render-core/src/lib.rs
Normal file
146
crates/render-core/src/lib.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
use msh_core::Model;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub const DEFAULT_UV_SCALE: f32 = 1024.0;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RenderVertex {
|
||||||
|
pub position: [f32; 3],
|
||||||
|
pub uv0: [f32; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RenderMesh {
|
||||||
|
pub vertices: Vec<RenderVertex>,
|
||||||
|
pub indices: Vec<u16>,
|
||||||
|
pub batch_count: usize,
|
||||||
|
pub index_overflow: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderMesh {
|
||||||
|
pub fn triangle_count(&self) -> usize {
|
||||||
|
self.indices.len() / 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an indexed triangle mesh for a specific LOD/group pair.
|
||||||
|
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
|
||||||
|
let mut vertices = Vec::new();
|
||||||
|
let mut indices = Vec::new();
|
||||||
|
let mut index_remap: HashMap<usize, u16> = HashMap::new();
|
||||||
|
let mut batch_count = 0usize;
|
||||||
|
let mut index_overflow = false;
|
||||||
|
let uv0 = model.uv0.as_ref();
|
||||||
|
|
||||||
|
for node_index in 0..model.node_count {
|
||||||
|
let Some(slot_idx) = model.slot_index(node_index, lod, group) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(slot) = model.slots.get(slot_idx) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let batch_start = usize::from(slot.batch_start);
|
||||||
|
let batch_end = batch_start.saturating_add(usize::from(slot.batch_count));
|
||||||
|
if batch_end > model.batches.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for batch in &model.batches[batch_start..batch_end] {
|
||||||
|
let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
|
||||||
|
let index_count = usize::from(batch.index_count);
|
||||||
|
let index_end = index_start.saturating_add(index_count);
|
||||||
|
if index_end > model.indices.len() || index_count < 3 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let batch_out_start = indices.len();
|
||||||
|
let mut batch_valid = true;
|
||||||
|
for &idx in &model.indices[index_start..index_end] {
|
||||||
|
let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx));
|
||||||
|
let Ok(final_idx) = usize::try_from(final_idx_u64) else {
|
||||||
|
batch_valid = false;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(pos) = model.positions.get(final_idx) else {
|
||||||
|
batch_valid = false;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
|
||||||
|
let local_index = if let Some(&mapped) = index_remap.get(&final_idx) {
|
||||||
|
mapped
|
||||||
|
} else {
|
||||||
|
let Ok(mapped) = u16::try_from(vertices.len()) else {
|
||||||
|
index_overflow = true;
|
||||||
|
batch_valid = false;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let uv = uv0
|
||||||
|
.and_then(|uvs| uvs.get(final_idx))
|
||||||
|
.copied()
|
||||||
|
.map(|packed| {
|
||||||
|
[
|
||||||
|
packed[0] as f32 / DEFAULT_UV_SCALE,
|
||||||
|
packed[1] as f32 / DEFAULT_UV_SCALE,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.unwrap_or([0.0, 0.0]);
|
||||||
|
vertices.push(RenderVertex {
|
||||||
|
position: *pos,
|
||||||
|
uv0: uv,
|
||||||
|
});
|
||||||
|
index_remap.insert(final_idx, mapped);
|
||||||
|
mapped
|
||||||
|
};
|
||||||
|
|
||||||
|
indices.push(local_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !batch_valid {
|
||||||
|
indices.truncate(batch_out_start);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
batch_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RenderMesh {
|
||||||
|
vertices,
|
||||||
|
indices,
|
||||||
|
batch_count,
|
||||||
|
index_overflow,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
|
||||||
|
compute_bounds_impl(vertices.iter().copied())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> {
|
||||||
|
compute_bounds_impl(vertices.iter().map(|v| v.position))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_bounds_impl<I>(mut positions: I) -> Option<([f32; 3], [f32; 3])>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = [f32; 3]>,
|
||||||
|
{
|
||||||
|
let first = positions.next()?;
|
||||||
|
let mut min_v = first;
|
||||||
|
let mut max_v = first;
|
||||||
|
|
||||||
|
for pos in positions {
|
||||||
|
for i in 0..3 {
|
||||||
|
if pos[i] < min_v[i] {
|
||||||
|
min_v[i] = pos[i];
|
||||||
|
}
|
||||||
|
if pos[i] > max_v[i] {
|
||||||
|
max_v[i] = pos[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((min_v, max_v))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
256
crates/render-core/src/tests.rs
Normal file
256
crates/render-core/src/tests.rs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use msh_core::parse_model_payload;
|
||||||
|
use nres::Archive;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn nres_test_files() -> Vec<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|path| {
|
||||||
|
fs::read(path)
|
||||||
|
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_render_mesh_for_real_models() {
|
||||||
|
let archives = nres_test_files();
|
||||||
|
if archives.is_empty() {
|
||||||
|
eprintln!("skipping build_render_mesh_for_real_models: no NRes files in testdata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut models_checked = 0usize;
|
||||||
|
let mut meshes_non_empty = 0usize;
|
||||||
|
let mut bounds_non_empty = 0usize;
|
||||||
|
|
||||||
|
for archive_path in archives {
|
||||||
|
let archive = Archive::open_path(&archive_path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
|
||||||
|
for entry in archive.entries() {
|
||||||
|
if !entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
models_checked += 1;
|
||||||
|
let payload = archive.read(entry.id).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"failed to read model '{}' from {}: {err}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"failed to parse model '{}' from {}: {err}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
|
if !mesh.indices.is_empty() {
|
||||||
|
meshes_non_empty += 1;
|
||||||
|
}
|
||||||
|
if compute_bounds_for_mesh(&mesh.vertices).is_some() {
|
||||||
|
bounds_non_empty += 1;
|
||||||
|
}
|
||||||
|
for &index in &mesh.indices {
|
||||||
|
assert!(
|
||||||
|
usize::from(index) < mesh.vertices.len(),
|
||||||
|
"index out of bounds for '{}' in {}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for vertex in &mesh.vertices {
|
||||||
|
assert!(
|
||||||
|
vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(),
|
||||||
|
"UV must be finite for '{}' in {}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(models_checked > 0, "no MSH models found");
|
||||||
|
assert!(
|
||||||
|
meshes_non_empty > 0,
|
||||||
|
"all generated render meshes are empty"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
meshes_non_empty, bounds_non_empty,
|
||||||
|
"bounds must be available for every non-empty mesh"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_bounds_handles_empty_and_non_empty() {
|
||||||
|
assert!(compute_bounds(&[]).is_none());
|
||||||
|
let bounds = compute_bounds(&[[1.0, 2.0, 3.0], [-2.0, 5.0, 0.5], [0.0, -1.0, 9.0]])
|
||||||
|
.expect("bounds expected");
|
||||||
|
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
|
||||||
|
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_bounds_for_mesh_handles_empty_and_non_empty() {
|
||||||
|
assert!(compute_bounds_for_mesh(&[]).is_none());
|
||||||
|
let bounds = compute_bounds_for_mesh(&[
|
||||||
|
RenderVertex {
|
||||||
|
position: [1.0, 2.0, 3.0],
|
||||||
|
uv0: [0.0, 0.0],
|
||||||
|
},
|
||||||
|
RenderVertex {
|
||||||
|
position: [-2.0, 5.0, 0.5],
|
||||||
|
uv0: [0.2, 0.3],
|
||||||
|
},
|
||||||
|
RenderVertex {
|
||||||
|
position: [0.0, -1.0, 9.0],
|
||||||
|
uv0: [1.0, 1.0],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.expect("bounds expected");
|
||||||
|
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
|
||||||
|
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nodes_with_slot_refs(slot_ids: &[Option<u16>]) -> Vec<u8> {
|
||||||
|
let mut out = vec![0u8; slot_ids.len().saturating_mul(38)];
|
||||||
|
for (node_index, slot_id) in slot_ids.iter().copied().enumerate() {
|
||||||
|
let node_off = node_index * 38;
|
||||||
|
for i in 0..15 {
|
||||||
|
let off = node_off + 8 + i * 2;
|
||||||
|
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||||
|
}
|
||||||
|
if let Some(slot_id) = slot_id {
|
||||||
|
out[node_off + 8..node_off + 10].copy_from_slice(&slot_id.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot(batch_start: u16, batch_count: u16) -> msh_core::Slot {
|
||||||
|
msh_core::Slot {
|
||||||
|
tri_start: 0,
|
||||||
|
tri_count: 0,
|
||||||
|
batch_start,
|
||||||
|
batch_count,
|
||||||
|
aabb_min: [0.0; 3],
|
||||||
|
aabb_max: [0.0; 3],
|
||||||
|
sphere_center: [0.0; 3],
|
||||||
|
sphere_radius: 0.0,
|
||||||
|
opaque: [0; 5],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> msh_core::Batch {
|
||||||
|
msh_core::Batch {
|
||||||
|
batch_flags: 0,
|
||||||
|
material_index: 0,
|
||||||
|
opaque4: 0,
|
||||||
|
opaque6: 0,
|
||||||
|
index_count,
|
||||||
|
index_start,
|
||||||
|
opaque14: 0,
|
||||||
|
base_vertex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_render_mesh_handles_empty_slot_model() {
|
||||||
|
let model = msh_core::Model {
|
||||||
|
node_stride: 38,
|
||||||
|
node_count: 1,
|
||||||
|
nodes_raw: nodes_with_slot_refs(&[None]),
|
||||||
|
slots: Vec::new(),
|
||||||
|
positions: vec![[0.0, 0.0, 0.0]],
|
||||||
|
normals: None,
|
||||||
|
uv0: None,
|
||||||
|
indices: Vec::new(),
|
||||||
|
batches: Vec::new(),
|
||||||
|
node_names: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
|
assert!(mesh.vertices.is_empty());
|
||||||
|
assert!(mesh.indices.is_empty());
|
||||||
|
assert_eq!(mesh.batch_count, 0);
|
||||||
|
assert_eq!(mesh.triangle_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_render_mesh_supports_multi_node_and_uv_scaling() {
|
||||||
|
let model = msh_core::Model {
|
||||||
|
node_stride: 38,
|
||||||
|
node_count: 2,
|
||||||
|
nodes_raw: nodes_with_slot_refs(&[Some(0), Some(1)]),
|
||||||
|
slots: vec![slot(0, 1), slot(1, 1)],
|
||||||
|
positions: vec![
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[2.0, 0.0, 0.0],
|
||||||
|
[3.0, 0.0, 0.0],
|
||||||
|
[2.0, 1.0, 0.0],
|
||||||
|
],
|
||||||
|
normals: None,
|
||||||
|
uv0: Some(vec![
|
||||||
|
[1024, -1024],
|
||||||
|
[512, 256],
|
||||||
|
[0, 0],
|
||||||
|
[1024, 1024],
|
||||||
|
[2048, 1024],
|
||||||
|
[1024, 0],
|
||||||
|
]),
|
||||||
|
indices: vec![0, 1, 2, 0, 1, 2],
|
||||||
|
batches: vec![batch(0, 3, 0), batch(3, 3, 3)],
|
||||||
|
node_names: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
|
assert_eq!(mesh.batch_count, 2);
|
||||||
|
assert_eq!(mesh.vertices.len(), 6);
|
||||||
|
assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]);
|
||||||
|
assert_eq!(mesh.triangle_count(), 2);
|
||||||
|
assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]);
|
||||||
|
assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]);
|
||||||
|
assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]);
|
||||||
|
assert_eq!(mesh.vertices[3].uv0, [1.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_render_mesh_deduplicates_shared_vertices() {
|
||||||
|
let model = msh_core::Model {
|
||||||
|
node_stride: 38,
|
||||||
|
node_count: 1,
|
||||||
|
nodes_raw: nodes_with_slot_refs(&[Some(0)]),
|
||||||
|
slots: vec![slot(0, 1)],
|
||||||
|
positions: vec![
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[1.0, 1.0, 0.0],
|
||||||
|
],
|
||||||
|
normals: None,
|
||||||
|
uv0: None,
|
||||||
|
indices: vec![0, 1, 2, 2, 1, 3],
|
||||||
|
batches: vec![batch(0, 6, 0)],
|
||||||
|
node_names: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
|
assert_eq!(mesh.vertices.len(), 4);
|
||||||
|
assert_eq!(mesh.indices, vec![0, 1, 2, 2, 1, 3]);
|
||||||
|
assert_eq!(mesh.triangle_count(), 2);
|
||||||
|
}
|
||||||
31
crates/render-demo/Cargo.toml
Normal file
31
crates/render-demo/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "render-demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
demo = ["dep:sdl2", "dep:glow", "dep:image"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
msh-core = { path = "../msh-core" }
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
render-core = { path = "../render-core" }
|
||||||
|
texm = { path = "../texm" }
|
||||||
|
glow = { version = "0.17", optional = true }
|
||||||
|
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
sdl2 = { version = "0.38", optional = true, default-features = false, features = ["use-pkgconfig"] }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||||
|
sdl2 = { version = "0.38", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "parkan-render-demo"
|
||||||
|
path = "src/main.rs"
|
||||||
|
required-features = ["demo"]
|
||||||
84
crates/render-demo/README.md
Normal file
84
crates/render-demo/README.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# render-demo
|
||||||
|
|
||||||
|
Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL`: GLES2 с fallback на Core 3.3).
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
|
||||||
|
- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах.
|
||||||
|
- Проверить текстурный path `WEAR -> MAT0 -> Texm` на реальных ассетах.
|
||||||
|
- Служить минимальным reference-приложением.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p render-demo --features demo -- \
|
||||||
|
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
|
||||||
|
--model "A_L_01.msh" \
|
||||||
|
--lod 0 \
|
||||||
|
--group 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS prerequisites
|
||||||
|
|
||||||
|
Для macOS `render-demo` ожидает системный SDL2 через `pkg-config`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install sdl2 pkg-config
|
||||||
|
```
|
||||||
|
|
||||||
|
После этого запускайте той же командой `cargo run ... --features demo`.
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
|
||||||
|
- `--archive` (обязательный): NRes-архив с `.msh` entry.
|
||||||
|
- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
|
||||||
|
- `--lod` (опционально, default `0`).
|
||||||
|
- `--group` (опционально, default `0`).
|
||||||
|
- `--width`, `--height` (опционально, default `1280x720`).
|
||||||
|
- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
|
||||||
|
- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
|
||||||
|
- В интерактивном режиме FPS выводится в заголовок окна и в stdout (обновление примерно каждые 0.5 сек).
|
||||||
|
- `--texture <name>`: явное имя `Texm` (override авто-резолва).
|
||||||
|
- `--texture-archive <path>`: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`).
|
||||||
|
- `--material-archive <path>`: путь к `material.lib` (по умолчанию соседний `material.lib`).
|
||||||
|
- `--wear <name.wea>`: имя wear-entry внутри модельного архива (по умолчанию `<model_stem>.wea`).
|
||||||
|
- `--no-texture`: отключить текстуры и рендерить однотонным цветом.
|
||||||
|
|
||||||
|
## Авто-резолв текстуры
|
||||||
|
|
||||||
|
Если не передан `--texture`, демо пытается взять текстуру из игровых данных:
|
||||||
|
|
||||||
|
1. `model.msh -> model.wea` (первый wear-материал),
|
||||||
|
2. `material.lib` (`MAT0`) по имени материала с fallback `DEFAULT`,
|
||||||
|
3. первая непустая `textureName` фаза материала,
|
||||||
|
4. загрузка `Texm` из `textures.lib` (или `lightmap.lib` как fallback).
|
||||||
|
|
||||||
|
## Детерминированный снимок кадра
|
||||||
|
|
||||||
|
Для parity-проверок используется headless-сценарий с фиксированными параметрами:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p render-demo --features demo -- \
|
||||||
|
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
|
||||||
|
--model "A_L_01.msh" \
|
||||||
|
--lod 0 \
|
||||||
|
--group 0 \
|
||||||
|
--width 1280 \
|
||||||
|
--height 720 \
|
||||||
|
--angle 0.0 \
|
||||||
|
--capture "target/render-parity/current/animals_a_l_01.png"
|
||||||
|
```
|
||||||
|
|
||||||
|
Явный выбор текстуры:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p render-demo --features demo -- \
|
||||||
|
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
|
||||||
|
--model "A_L_01.msh" \
|
||||||
|
--texture "PG09.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Используется только базовая texture-фаза (без полной material/fx анимации).
|
||||||
|
- Вывод через `glDrawElements(GL_TRIANGLES)` с index-buffer (позиции+UV).
|
||||||
4
crates/render-demo/build.rs
Normal file
4
crates/render-demo/build.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fn main() {
|
||||||
|
#[cfg(windows)]
|
||||||
|
println!("cargo:rustc-link-lib=advapi32");
|
||||||
|
}
|
||||||
591
crates/render-demo/src/lib.rs
Normal file
591
crates/render-demo/src/lib.rs
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
use msh_core::{parse_model_payload, Model};
|
||||||
|
use nres::{Archive, EntryRef};
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use texm::{decode_mip_rgba8, parse_texm};
|
||||||
|
|
||||||
|
const WEAR_KIND: u32 = 0x5241_4557;
|
||||||
|
const MAT0_KIND: u32 = 0x3054_414D;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Nres(nres::error::Error),
|
||||||
|
Msh(msh_core::error::Error),
|
||||||
|
Texm(texm::error::Error),
|
||||||
|
Io(std::io::Error),
|
||||||
|
NoMshEntries,
|
||||||
|
ModelNotFound(String),
|
||||||
|
NoTexmEntries,
|
||||||
|
TextureNotFound(String),
|
||||||
|
MaterialNotFound(String),
|
||||||
|
WearNotFound(String),
|
||||||
|
InvalidWear(String),
|
||||||
|
InvalidMaterial(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Nres(err) => write!(f, "{err}"),
|
||||||
|
Self::Msh(err) => write!(f, "{err}"),
|
||||||
|
Self::Texm(err) => write!(f, "{err}"),
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
Self::NoMshEntries => write!(f, "archive does not contain .msh entries"),
|
||||||
|
Self::ModelNotFound(name) => write!(f, "model not found: {name}"),
|
||||||
|
Self::NoTexmEntries => write!(f, "archive does not contain Texm entries"),
|
||||||
|
Self::TextureNotFound(name) => write!(f, "texture not found: {name}"),
|
||||||
|
Self::MaterialNotFound(name) => write!(f, "material not found: {name}"),
|
||||||
|
Self::WearNotFound(name) => write!(f, "wear entry not found: {name}"),
|
||||||
|
Self::InvalidWear(reason) => write!(f, "invalid WEAR payload: {reason}"),
|
||||||
|
Self::InvalidMaterial(reason) => write!(f, "invalid MAT0 payload: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Nres(err) => Some(err),
|
||||||
|
Self::Msh(err) => Some(err),
|
||||||
|
Self::Texm(err) => Some(err),
|
||||||
|
Self::Io(err) => Some(err),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nres::error::Error> for Error {
|
||||||
|
fn from(value: nres::error::Error) -> Self {
|
||||||
|
Self::Nres(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<msh_core::error::Error> for Error {
|
||||||
|
fn from(value: msh_core::error::Error) -> Self {
|
||||||
|
Self::Msh(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<texm::error::Error> for Error {
|
||||||
|
fn from(value: texm::error::Error) -> Self {
|
||||||
|
Self::Texm(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(value: std::io::Error) -> Self {
|
||||||
|
Self::Io(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LoadedModel {
|
||||||
|
pub name: String,
|
||||||
|
pub model: Model,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LoadedTexture {
|
||||||
|
pub name: String,
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub rgba8: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_model_with_name_from_archive(
|
||||||
|
path: &Path,
|
||||||
|
model_name: Option<&str>,
|
||||||
|
) -> Result<LoadedModel> {
|
||||||
|
let archive = Archive::open_path(path)?;
|
||||||
|
let mut msh_entries = Vec::new();
|
||||||
|
for entry in archive.entries() {
|
||||||
|
if entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
|
||||||
|
msh_entries.push((entry.id, entry.meta.name.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if msh_entries.is_empty() {
|
||||||
|
return Err(Error::NoMshEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_id = if let Some(name) = model_name {
|
||||||
|
msh_entries
|
||||||
|
.iter()
|
||||||
|
.find(|(_, n)| n.eq_ignore_ascii_case(name))
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
.ok_or_else(|| Error::ModelNotFound(name.to_string()))?
|
||||||
|
} else {
|
||||||
|
msh_entries[0].0
|
||||||
|
};
|
||||||
|
|
||||||
|
let target_name = archive
|
||||||
|
.get(target_id)
|
||||||
|
.map(|entry| entry.meta.name.clone())
|
||||||
|
.unwrap_or_else(|| String::from("<unknown>"));
|
||||||
|
let payload = archive.read(target_id)?;
|
||||||
|
Ok(LoadedModel {
|
||||||
|
name: target_name,
|
||||||
|
model: parse_model_payload(payload.as_slice())?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
|
||||||
|
Ok(load_model_with_name_from_archive(path, model_name)?.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result<LoadedTexture> {
|
||||||
|
let archive = Archive::open_path(path)?;
|
||||||
|
if let Some(name) = texture_name {
|
||||||
|
return load_texture_from_archive_by_name(&archive, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut texm_entries = archive
|
||||||
|
.entries()
|
||||||
|
.filter(|entry| entry.meta.kind == texm::TEXM_MAGIC)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if texm_entries.is_empty() {
|
||||||
|
return Err(Error::NoTexmEntries);
|
||||||
|
}
|
||||||
|
texm_entries.sort_by(|a, b| {
|
||||||
|
a.meta
|
||||||
|
.name
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.cmp(&b.meta.name.to_ascii_lowercase())
|
||||||
|
});
|
||||||
|
let first = texm_entries[0];
|
||||||
|
decode_texture_entry(&archive, first)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_texture_for_model(
|
||||||
|
model_archive_path: &Path,
|
||||||
|
model_entry_name: &str,
|
||||||
|
texture_name_override: Option<&str>,
|
||||||
|
textures_archive_override: Option<&Path>,
|
||||||
|
material_archive_override: Option<&Path>,
|
||||||
|
wear_entry_override: Option<&str>,
|
||||||
|
) -> Result<Option<LoadedTexture>> {
|
||||||
|
if let Some(name) = texture_name_override {
|
||||||
|
return load_texture_by_name_from_candidate_archives(
|
||||||
|
name,
|
||||||
|
candidate_texture_archives(model_archive_path, textures_archive_override),
|
||||||
|
)
|
||||||
|
.map(Some);
|
||||||
|
}
|
||||||
|
|
||||||
|
let wear_entry_name = if let Some(name) = wear_entry_override {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
derive_wear_entry_name(model_entry_name).ok_or_else(|| {
|
||||||
|
Error::WearNotFound(format!(
|
||||||
|
"cannot derive WEAR name from model '{model_entry_name}'"
|
||||||
|
))
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let model_archive = Archive::open_path(model_archive_path)?;
|
||||||
|
let wear_materials = parse_wear_material_names(
|
||||||
|
read_entry_by_name_kind(&model_archive, &wear_entry_name, WEAR_KIND)?
|
||||||
|
.0
|
||||||
|
.as_slice(),
|
||||||
|
)?;
|
||||||
|
let Some(primary_material) = wear_materials.first() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let material_path = if let Some(path) = material_archive_override {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
sibling_archive_path(model_archive_path, "material.lib")
|
||||||
|
.ok_or_else(|| Error::MaterialNotFound(String::from("material.lib")))?
|
||||||
|
};
|
||||||
|
let material_archive = Archive::open_path(&material_path)?;
|
||||||
|
let material_entry = find_material_entry_with_fallback(&material_archive, primary_material)?;
|
||||||
|
let material_payload = material_archive.read(material_entry.id)?.into_owned();
|
||||||
|
let texture_name =
|
||||||
|
parse_primary_texture_name_from_mat0(&material_payload, material_entry.meta.attr2)?;
|
||||||
|
let Some(texture_name) = texture_name else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let texture = load_texture_by_name_from_candidate_archives(
|
||||||
|
&texture_name,
|
||||||
|
candidate_texture_archives(model_archive_path, textures_archive_override),
|
||||||
|
)?;
|
||||||
|
Ok(Some(texture))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_texture_by_name_from_candidate_archives(
|
||||||
|
texture_name: &str,
|
||||||
|
archives: Vec<PathBuf>,
|
||||||
|
) -> Result<LoadedTexture> {
|
||||||
|
let mut last_not_found = None;
|
||||||
|
for archive_path in archives {
|
||||||
|
if !archive_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let archive = Archive::open_path(&archive_path)?;
|
||||||
|
match load_texture_from_archive_by_name(&archive, texture_name) {
|
||||||
|
Ok(texture) => return Ok(texture),
|
||||||
|
Err(Error::TextureNotFound(name)) => {
|
||||||
|
last_not_found = Some(name);
|
||||||
|
}
|
||||||
|
Err(other) => return Err(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::TextureNotFound(
|
||||||
|
last_not_found.unwrap_or_else(|| texture_name.to_string()),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn candidate_texture_archives(
|
||||||
|
model_archive_path: &Path,
|
||||||
|
textures_archive_override: Option<&Path>,
|
||||||
|
) -> Vec<PathBuf> {
|
||||||
|
if let Some(path) = textures_archive_override {
|
||||||
|
return vec![path.to_path_buf()];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Some(path) = sibling_archive_path(model_archive_path, "textures.lib") {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
if let Some(path) = sibling_archive_path(model_archive_path, "lightmap.lib") {
|
||||||
|
out.push(path);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sibling_archive_path(model_archive_path: &Path, name: &str) -> Option<PathBuf> {
|
||||||
|
let parent = model_archive_path.parent()?;
|
||||||
|
Some(parent.join(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_wear_entry_name(model_entry_name: &str) -> Option<String> {
|
||||||
|
let stem = model_entry_name.rsplit_once('.').map(|(left, _)| left)?;
|
||||||
|
Some(format!("{stem}.wea"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_entry_by_name_kind(
|
||||||
|
archive: &Archive,
|
||||||
|
name: &str,
|
||||||
|
expected_kind: u32,
|
||||||
|
) -> Result<(Vec<u8>, String)> {
|
||||||
|
let Some(id) = archive.find(name) else {
|
||||||
|
return Err(Error::WearNotFound(name.to_string()));
|
||||||
|
};
|
||||||
|
let Some(entry) = archive.get(id) else {
|
||||||
|
return Err(Error::WearNotFound(name.to_string()));
|
||||||
|
};
|
||||||
|
if entry.meta.kind != expected_kind {
|
||||||
|
return Err(Error::WearNotFound(name.to_string()));
|
||||||
|
}
|
||||||
|
let payload = archive.read(id)?.into_owned();
|
||||||
|
Ok((payload, entry.meta.name.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_material_entry_with_fallback<'a>(
|
||||||
|
archive: &'a Archive,
|
||||||
|
requested_name: &str,
|
||||||
|
) -> Result<EntryRef<'a>> {
|
||||||
|
if let Some(id) = archive.find(requested_name) {
|
||||||
|
if let Some(entry) = archive.get(id) {
|
||||||
|
if entry.meta.kind == MAT0_KIND {
|
||||||
|
return Ok(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(id) = archive.find("DEFAULT") {
|
||||||
|
if let Some(entry) = archive.get(id) {
|
||||||
|
if entry.meta.kind == MAT0_KIND {
|
||||||
|
return Ok(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(entry) = archive.entries().find(|entry| entry.meta.kind == MAT0_KIND) else {
|
||||||
|
return Err(Error::MaterialNotFound(requested_name.to_string()));
|
||||||
|
};
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
|
||||||
|
let text = decode_cp1251(payload).replace('\r', "");
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let Some(first) = lines.next() else {
|
||||||
|
return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
|
||||||
|
};
|
||||||
|
let count = first
|
||||||
|
.trim()
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| Error::InvalidWear(format!("invalid wearCount line: '{first}'")))?;
|
||||||
|
if count == 0 {
|
||||||
|
return Err(Error::InvalidWear(String::from("wearCount must be > 0")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut materials = Vec::with_capacity(count);
|
||||||
|
for idx in 0..count {
|
||||||
|
let Some(line) = lines.next() else {
|
||||||
|
return Err(Error::InvalidWear(format!(
|
||||||
|
"missing material line {idx} of {count}"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let _legacy = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
|
||||||
|
let name = parts
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
|
||||||
|
materials.push(name.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(materials)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
|
||||||
|
if payload.len() < 4 {
|
||||||
|
return Err(Error::InvalidMaterial(String::from(
|
||||||
|
"MAT0 payload is too small for header",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
|
||||||
|
if phase_count == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut offset = 4usize;
|
||||||
|
if attr2 >= 2 {
|
||||||
|
offset = offset
|
||||||
|
.checked_add(2)
|
||||||
|
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
|
||||||
|
}
|
||||||
|
if attr2 >= 3 {
|
||||||
|
offset = offset
|
||||||
|
.checked_add(4)
|
||||||
|
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
|
||||||
|
}
|
||||||
|
if attr2 >= 4 {
|
||||||
|
offset = offset
|
||||||
|
.checked_add(4)
|
||||||
|
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for phase in 0..phase_count {
|
||||||
|
let phase_off = offset
|
||||||
|
.checked_add(phase.checked_mul(34).ok_or_else(|| {
|
||||||
|
Error::InvalidMaterial(String::from("MAT0 phase offset overflow"))
|
||||||
|
})?)
|
||||||
|
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
|
||||||
|
let phase_end = phase_off
|
||||||
|
.checked_add(34)
|
||||||
|
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
|
||||||
|
let Some(rec) = payload.get(phase_off..phase_end) else {
|
||||||
|
return Err(Error::InvalidMaterial(format!(
|
||||||
|
"MAT0 phase {phase} is out of bounds"
|
||||||
|
)));
|
||||||
|
};
|
||||||
|
let name_raw = &rec[18..34];
|
||||||
|
let name_end = name_raw
|
||||||
|
.iter()
|
||||||
|
.position(|&b| b == 0)
|
||||||
|
.unwrap_or(name_raw.len());
|
||||||
|
let name = decode_cp1251(&name_raw[..name_end]).trim().to_string();
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Ok(Some(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
|
||||||
|
let Some(id) = archive.find(name) else {
|
||||||
|
return Err(Error::TextureNotFound(name.to_string()));
|
||||||
|
};
|
||||||
|
let Some(entry) = archive.get(id) else {
|
||||||
|
return Err(Error::TextureNotFound(name.to_string()));
|
||||||
|
};
|
||||||
|
if entry.meta.kind != texm::TEXM_MAGIC {
|
||||||
|
return Err(Error::TextureNotFound(name.to_string()));
|
||||||
|
}
|
||||||
|
decode_texture_entry(archive, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result<LoadedTexture> {
|
||||||
|
let payload = archive.read(entry.id)?.into_owned();
|
||||||
|
let parsed = parse_texm(&payload)?;
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0)?;
|
||||||
|
Ok(LoadedTexture {
|
||||||
|
name: entry.meta.name.clone(),
|
||||||
|
width: decoded.width,
|
||||||
|
height: decoded.height,
|
||||||
|
rgba8: decoded.rgba8,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn archive_with_msh() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
for path in files {
|
||||||
|
let Ok(bytes) = fs::read(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if bytes.get(0..4) != Some(b"NRes") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(archive) = Archive::open_path(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if archive
|
||||||
|
.entries()
|
||||||
|
.any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh"))
|
||||||
|
{
|
||||||
|
return Some(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("Parkan - Iron Strategy");
|
||||||
|
if path.is_dir() {
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_model_from_real_archive() {
|
||||||
|
let Some(path) = archive_with_msh() else {
|
||||||
|
eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let model = load_model_from_archive(&path, None)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display()));
|
||||||
|
assert!(model.node_count > 0);
|
||||||
|
assert!(!model.positions.is_empty());
|
||||||
|
assert!(!model.indices.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_texture_for_real_model_via_wear_and_material() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!(
|
||||||
|
"skipping resolve_texture_for_real_model_via_wear_and_material: no game root"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let archive = root.join("animals.rlb");
|
||||||
|
if !archive.is_file() {
|
||||||
|
eprintln!("skipping resolve_texture_for_real_model_via_wear_and_material: missing animals.rlb");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let loaded = load_model_with_name_from_archive(&archive, Some("A_L_01.msh"))
|
||||||
|
.unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"failed to load model A_L_01.msh from {}: {err:?}",
|
||||||
|
archive.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let texture = resolve_texture_for_model(&archive, &loaded.name, None, None, None, None)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to resolve texture for {}: {err:?}", loaded.name))
|
||||||
|
.expect("texture must be resolved for A_L_01.msh");
|
||||||
|
assert!(texture.width > 0 && texture.height > 0);
|
||||||
|
assert_eq!(
|
||||||
|
texture.rgba8.len(),
|
||||||
|
usize::try_from(texture.width)
|
||||||
|
.ok()
|
||||||
|
.and_then(|w| usize::try_from(texture.height).ok().map(|h| w * h * 4))
|
||||||
|
.unwrap_or(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_first_texture_from_real_archive() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping load_first_texture_from_real_archive: no game root");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let archive = root.join("textures.lib");
|
||||||
|
if !archive.is_file() {
|
||||||
|
eprintln!("skipping load_first_texture_from_real_archive: missing textures.lib");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let texture = load_texture_from_archive(&archive, None).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"failed to load first texture from {}: {err:?}",
|
||||||
|
archive.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(texture.width > 0 && texture.height > 0);
|
||||||
|
assert!(!texture.rgba8.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_wear_material_names_parses_counted_lines() {
|
||||||
|
let payload = b"2\r\n0 MAT_A\r\n1 MAT_B\r\n";
|
||||||
|
let materials =
|
||||||
|
parse_wear_material_names(payload).expect("failed to parse valid WEAR payload");
|
||||||
|
assert_eq!(materials, vec!["MAT_A".to_string(), "MAT_B".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_wear_material_names_rejects_invalid_payload() {
|
||||||
|
let payload = b"2\n0 ONLY_ONE\n";
|
||||||
|
assert!(matches!(
|
||||||
|
parse_wear_material_names(payload),
|
||||||
|
Err(Error::InvalidWear(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primary_texture_name_from_mat0_respects_attr2_layout() {
|
||||||
|
let mut payload = vec![0u8; 4 + 10 + 34];
|
||||||
|
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
|
||||||
|
// attr2=4 adds 10 bytes before phase table
|
||||||
|
let name = b"TEX_MAIN";
|
||||||
|
payload[4 + 10 + 18..4 + 10 + 18 + name.len()].copy_from_slice(name);
|
||||||
|
|
||||||
|
let parsed = parse_primary_texture_name_from_mat0(&payload, 4)
|
||||||
|
.expect("failed to parse MAT0 payload with attr2=4");
|
||||||
|
assert_eq!(parsed, Some("TEX_MAIN".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primary_texture_name_from_mat0_decodes_cp1251_bytes() {
|
||||||
|
let mut payload = vec![0u8; 4 + 34];
|
||||||
|
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
|
||||||
|
payload[4 + 18] = 0xC0; // 'А' in CP1251
|
||||||
|
|
||||||
|
let parsed =
|
||||||
|
parse_primary_texture_name_from_mat0(&payload, 0).expect("failed to parse MAT0");
|
||||||
|
assert_eq!(parsed, Some("А".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
997
crates/render-demo/src/main.rs
Normal file
997
crates/render-demo/src/main.rs
Normal file
@@ -0,0 +1,997 @@
|
|||||||
|
use glow::HasContext as _;
|
||||||
|
use render_core::{build_render_mesh, compute_bounds_for_mesh};
|
||||||
|
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
archive: PathBuf,
|
||||||
|
model: Option<String>,
|
||||||
|
lod: usize,
|
||||||
|
group: usize,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fov_deg: f32,
|
||||||
|
capture: Option<PathBuf>,
|
||||||
|
angle: Option<f32>,
|
||||||
|
spin_rate: f32,
|
||||||
|
texture: Option<String>,
|
||||||
|
texture_archive: Option<PathBuf>,
|
||||||
|
material_archive: Option<PathBuf>,
|
||||||
|
wear: Option<String>,
|
||||||
|
no_texture: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GpuTexture {
|
||||||
|
handle: glow::NativeTexture,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
enum GlBackend {
|
||||||
|
Gles2,
|
||||||
|
Core33,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
let mut archive = None;
|
||||||
|
let mut model = None;
|
||||||
|
let mut lod = 0usize;
|
||||||
|
let mut group = 0usize;
|
||||||
|
let mut width = 1280u32;
|
||||||
|
let mut height = 720u32;
|
||||||
|
let mut fov_deg = 60.0f32;
|
||||||
|
let mut capture = None;
|
||||||
|
let mut angle = None;
|
||||||
|
let mut spin_rate = 0.35f32;
|
||||||
|
let mut texture = None;
|
||||||
|
let mut texture_archive = None;
|
||||||
|
let mut material_archive = None;
|
||||||
|
let mut wear = None;
|
||||||
|
let mut no_texture = false;
|
||||||
|
|
||||||
|
let mut it = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = it.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--archive" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --archive"))?;
|
||||||
|
archive = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--model" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --model"))?;
|
||||||
|
model = Some(value);
|
||||||
|
}
|
||||||
|
"--lod" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --lod"))?;
|
||||||
|
lod = value
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| String::from("invalid --lod value"))?;
|
||||||
|
}
|
||||||
|
"--group" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --group"))?;
|
||||||
|
group = value
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| String::from("invalid --group value"))?;
|
||||||
|
}
|
||||||
|
"--width" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --width"))?;
|
||||||
|
width = value
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| String::from("invalid --width value"))?;
|
||||||
|
if width == 0 {
|
||||||
|
return Err(String::from("--width must be > 0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--height" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --height"))?;
|
||||||
|
height = value
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| String::from("invalid --height value"))?;
|
||||||
|
if height == 0 {
|
||||||
|
return Err(String::from("--height must be > 0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--fov" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --fov"))?;
|
||||||
|
fov_deg = value
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| String::from("invalid --fov value"))?;
|
||||||
|
if !(1.0..=179.0).contains(&fov_deg) {
|
||||||
|
return Err(String::from("--fov must be in range [1, 179]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--capture" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --capture"))?;
|
||||||
|
capture = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--angle" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --angle"))?;
|
||||||
|
angle = Some(
|
||||||
|
value
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| String::from("invalid --angle value"))?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--spin-rate" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --spin-rate"))?;
|
||||||
|
spin_rate = value
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| String::from("invalid --spin-rate value"))?;
|
||||||
|
}
|
||||||
|
"--texture" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --texture"))?;
|
||||||
|
texture = Some(value);
|
||||||
|
}
|
||||||
|
"--texture-archive" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --texture-archive"))?;
|
||||||
|
texture_archive = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--material-archive" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --material-archive"))?;
|
||||||
|
material_archive = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--wear" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --wear"))?;
|
||||||
|
wear = Some(value);
|
||||||
|
}
|
||||||
|
"--no-texture" => {
|
||||||
|
no_texture = true;
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_help();
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(format!("unknown argument: {other}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let archive = archive.ok_or_else(|| String::from("missing required --archive"))?;
|
||||||
|
Ok(Args {
|
||||||
|
archive,
|
||||||
|
model,
|
||||||
|
lod,
|
||||||
|
group,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fov_deg,
|
||||||
|
capture,
|
||||||
|
angle,
|
||||||
|
spin_rate,
|
||||||
|
texture,
|
||||||
|
texture_archive,
|
||||||
|
material_archive,
|
||||||
|
wear,
|
||||||
|
no_texture,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_help() {
|
||||||
|
eprintln!(
|
||||||
|
"parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H] [--fov DEG]"
|
||||||
|
);
|
||||||
|
eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
|
||||||
|
eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = match parse_args() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{err}");
|
||||||
|
print_help();
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = run(args) {
|
||||||
|
eprintln!("{err}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: Args) -> Result<(), String> {
|
||||||
|
let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
|
||||||
|
.map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"failed to load model from archive {}: {err}",
|
||||||
|
args.archive.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
|
||||||
|
if mesh.indices.is_empty() {
|
||||||
|
return Err(format!(
|
||||||
|
"model has no renderable triangles for lod={} group={}",
|
||||||
|
args.lod, args.group
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if mesh.index_overflow {
|
||||||
|
eprintln!(
|
||||||
|
"warning: mesh exceeds u16 index space and may be partially rendered on GLES2 targets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else {
|
||||||
|
return Err(String::from("failed to compute mesh bounds"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let resolved_texture = resolve_texture(&args, &loaded_model.name)?;
|
||||||
|
if let Some(tex) = resolved_texture.as_ref() {
|
||||||
|
println!(
|
||||||
|
"resolved texture '{}' ({}x{})",
|
||||||
|
tex.name, tex.width, tex.height
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!("texture path disabled or unresolved; rendering with fallback color");
|
||||||
|
}
|
||||||
|
|
||||||
|
let center = [
|
||||||
|
0.5 * (bounds_min[0] + bounds_max[0]),
|
||||||
|
0.5 * (bounds_min[1] + bounds_max[1]),
|
||||||
|
0.5 * (bounds_min[2] + bounds_max[2]),
|
||||||
|
];
|
||||||
|
let extent = [
|
||||||
|
bounds_max[0] - bounds_min[0],
|
||||||
|
bounds_max[1] - bounds_min[1],
|
||||||
|
bounds_max[2] - bounds_min[2],
|
||||||
|
];
|
||||||
|
let radius =
|
||||||
|
(extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5;
|
||||||
|
let camera_distance = (radius * 2.5).max(2.0);
|
||||||
|
|
||||||
|
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
|
||||||
|
let video = sdl
|
||||||
|
.video()
|
||||||
|
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
|
||||||
|
|
||||||
|
let (mut window, _gl_ctx, gl_backend) = create_window_and_context(&video, &args)?;
|
||||||
|
let _ = if args.capture.is_some() {
|
||||||
|
video.gl_set_swap_interval(0)
|
||||||
|
} else {
|
||||||
|
video.gl_set_swap_interval(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
|
||||||
|
for vertex in &mesh.vertices {
|
||||||
|
vertex_data.push(vertex.position[0]);
|
||||||
|
vertex_data.push(vertex.position[1]);
|
||||||
|
vertex_data.push(vertex.position[2]);
|
||||||
|
vertex_data.push(vertex.uv0[0]);
|
||||||
|
vertex_data.push(vertex.uv0[1]);
|
||||||
|
}
|
||||||
|
let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data);
|
||||||
|
let index_bytes = u16_slice_to_ne_bytes(&mesh.indices);
|
||||||
|
|
||||||
|
let gl = unsafe {
|
||||||
|
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
|
||||||
|
};
|
||||||
|
|
||||||
|
let program = unsafe { create_program(&gl, gl_backend)? };
|
||||||
|
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
||||||
|
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
|
||||||
|
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
|
||||||
|
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
|
||||||
|
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
|
||||||
|
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
|
||||||
|
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
|
||||||
|
|
||||||
|
let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
|
||||||
|
let ebo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
|
||||||
|
unsafe {
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
|
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||||
|
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
}
|
||||||
|
let vao = unsafe { create_vertex_layout_if_needed(&gl, gl_backend, vbo, ebo, a_pos, a_uv)? };
|
||||||
|
|
||||||
|
let gpu_texture = if let Some(texture) = resolved_texture.as_ref() {
|
||||||
|
Some(unsafe { create_texture(&gl, texture)? })
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = if let Some(capture_path) = args.capture.as_ref() {
|
||||||
|
run_capture(
|
||||||
|
&gl,
|
||||||
|
program,
|
||||||
|
u_mvp.as_ref(),
|
||||||
|
u_use_tex.as_ref(),
|
||||||
|
u_tex.as_ref(),
|
||||||
|
a_pos,
|
||||||
|
a_uv,
|
||||||
|
vbo,
|
||||||
|
ebo,
|
||||||
|
vao,
|
||||||
|
gpu_texture.as_ref(),
|
||||||
|
mesh.indices.len(),
|
||||||
|
&args,
|
||||||
|
center,
|
||||||
|
camera_distance,
|
||||||
|
capture_path,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
run_interactive(
|
||||||
|
&sdl,
|
||||||
|
&mut window,
|
||||||
|
&gl,
|
||||||
|
program,
|
||||||
|
u_mvp.as_ref(),
|
||||||
|
u_use_tex.as_ref(),
|
||||||
|
u_tex.as_ref(),
|
||||||
|
a_pos,
|
||||||
|
a_uv,
|
||||||
|
vbo,
|
||||||
|
ebo,
|
||||||
|
vao,
|
||||||
|
gpu_texture.as_ref(),
|
||||||
|
mesh.indices.len(),
|
||||||
|
&args,
|
||||||
|
center,
|
||||||
|
camera_distance,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
if let Some(texture) = gpu_texture {
|
||||||
|
gl.delete_texture(texture.handle);
|
||||||
|
}
|
||||||
|
if let Some(vao) = vao {
|
||||||
|
gl.delete_vertex_array(vao);
|
||||||
|
}
|
||||||
|
gl.delete_buffer(ebo);
|
||||||
|
gl.delete_buffer(vbo);
|
||||||
|
gl.delete_program(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_window_and_context(
|
||||||
|
video: &sdl2::VideoSubsystem,
|
||||||
|
args: &Args,
|
||||||
|
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
|
||||||
|
let candidates = [
|
||||||
|
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
|
||||||
|
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
|
||||||
|
];
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for (backend, profile, major, minor) in candidates {
|
||||||
|
{
|
||||||
|
let gl_attr = video.gl_attr();
|
||||||
|
gl_attr.set_context_profile(profile);
|
||||||
|
gl_attr.set_context_version(major, minor);
|
||||||
|
gl_attr.set_depth_size(24);
|
||||||
|
gl_attr.set_double_buffer(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut window_builder = video.window(
|
||||||
|
"Parkan Render Demo (SDL2 + OpenGL)",
|
||||||
|
args.width,
|
||||||
|
args.height,
|
||||||
|
);
|
||||||
|
window_builder.opengl();
|
||||||
|
if args.capture.is_some() {
|
||||||
|
window_builder.hidden();
|
||||||
|
} else {
|
||||||
|
window_builder.resizable();
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = match window_builder.build() {
|
||||||
|
Ok(window) => window,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: window build failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let gl_ctx = match window.gl_create_context() {
|
||||||
|
Ok(ctx) => ctx,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: context create failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = window.gl_make_current(&gl_ctx) {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: make current failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok((window, gl_ctx, backend));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"failed to create OpenGL context. Attempts: {}",
|
||||||
|
errors.join(" | ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn create_vertex_layout_if_needed(
|
||||||
|
gl: &glow::Context,
|
||||||
|
backend: GlBackend,
|
||||||
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
|
) -> Result<Option<glow::NativeVertexArray>, String> {
|
||||||
|
if backend != GlBackend::Core33 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vao = gl.create_vertex_array().map_err(|e| e.to_string())?;
|
||||||
|
gl.bind_vertex_array(Some(vao));
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||||
|
gl.enable_vertex_attrib_array(a_pos);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
||||||
|
gl.enable_vertex_attrib_array(a_uv);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
||||||
|
gl.bind_vertex_array(None);
|
||||||
|
Ok(Some(vao))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, String> {
|
||||||
|
if args.no_texture {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
match resolve_texture_for_model(
|
||||||
|
&args.archive,
|
||||||
|
model_name,
|
||||||
|
args.texture.as_deref(),
|
||||||
|
args.texture_archive.as_deref(),
|
||||||
|
args.material_archive.as_deref(),
|
||||||
|
args.wear.as_deref(),
|
||||||
|
) {
|
||||||
|
Ok(texture) => Ok(texture),
|
||||||
|
Err(err) => {
|
||||||
|
if args.texture.is_some()
|
||||||
|
|| args.texture_archive.is_some()
|
||||||
|
|| args.material_archive.is_some()
|
||||||
|
|| args.wear.is_some()
|
||||||
|
{
|
||||||
|
Err(format!("failed to resolve texture: {err}"))
|
||||||
|
} else {
|
||||||
|
eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color");
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn create_texture(
|
||||||
|
gl: &glow::Context,
|
||||||
|
texture: &LoadedTexture,
|
||||||
|
) -> Result<GpuTexture, String> {
|
||||||
|
let handle = gl.create_texture().map_err(|e| e.to_string())?;
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_MIN_FILTER,
|
||||||
|
glow::LINEAR as i32,
|
||||||
|
);
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_MAG_FILTER,
|
||||||
|
glow::LINEAR as i32,
|
||||||
|
);
|
||||||
|
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
|
||||||
|
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
|
||||||
|
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
|
||||||
|
gl.tex_image_2d(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
glow::RGBA as i32,
|
||||||
|
texture.width.min(i32::MAX as u32) as i32,
|
||||||
|
texture.height.min(i32::MAX as u32) as i32,
|
||||||
|
0,
|
||||||
|
glow::RGBA,
|
||||||
|
glow::UNSIGNED_BYTE,
|
||||||
|
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
|
||||||
|
);
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
|
Ok(GpuTexture { handle })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn run_capture(
|
||||||
|
gl: &glow::Context,
|
||||||
|
program: glow::NativeProgram,
|
||||||
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
vao: Option<glow::NativeVertexArray>,
|
||||||
|
texture: Option<&GpuTexture>,
|
||||||
|
index_count: usize,
|
||||||
|
args: &Args,
|
||||||
|
center: [f32; 3],
|
||||||
|
camera_distance: f32,
|
||||||
|
capture_path: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let angle = args.angle.unwrap_or(0.0);
|
||||||
|
let mvp = compute_mvp(
|
||||||
|
args.width,
|
||||||
|
args.height,
|
||||||
|
args.fov_deg,
|
||||||
|
center,
|
||||||
|
camera_distance,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
|
unsafe {
|
||||||
|
draw_frame(
|
||||||
|
gl,
|
||||||
|
program,
|
||||||
|
u_mvp,
|
||||||
|
u_use_tex,
|
||||||
|
u_tex,
|
||||||
|
a_pos,
|
||||||
|
a_uv,
|
||||||
|
vbo,
|
||||||
|
ebo,
|
||||||
|
vao,
|
||||||
|
texture,
|
||||||
|
index_count,
|
||||||
|
args.width,
|
||||||
|
args.height,
|
||||||
|
&mvp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut rgba = unsafe { read_pixels_rgba(gl, args.width, args.height)? };
|
||||||
|
flip_image_y_rgba(&mut rgba, args.width as usize, args.height as usize);
|
||||||
|
save_png(capture_path, args.width, args.height, rgba)?;
|
||||||
|
println!("captured frame to {}", capture_path.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn run_interactive(
|
||||||
|
sdl: &sdl2::Sdl,
|
||||||
|
window: &mut sdl2::video::Window,
|
||||||
|
gl: &glow::Context,
|
||||||
|
program: glow::NativeProgram,
|
||||||
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
vao: Option<glow::NativeVertexArray>,
|
||||||
|
texture: Option<&GpuTexture>,
|
||||||
|
index_count: usize,
|
||||||
|
args: &Args,
|
||||||
|
center: [f32; 3],
|
||||||
|
camera_distance: f32,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let mut events = sdl
|
||||||
|
.event_pump()
|
||||||
|
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
|
||||||
|
let start = Instant::now();
|
||||||
|
let mut fps_window_start = Instant::now();
|
||||||
|
let mut fps_frames: u32 = 0;
|
||||||
|
let mut fps_printed = false;
|
||||||
|
let base_title = "Parkan Render Demo (SDL2 + OpenGL)";
|
||||||
|
|
||||||
|
'main_loop: loop {
|
||||||
|
for event in events.poll_iter() {
|
||||||
|
match event {
|
||||||
|
sdl2::event::Event::Quit { .. } => break 'main_loop,
|
||||||
|
sdl2::event::Event::KeyDown {
|
||||||
|
keycode: Some(sdl2::keyboard::Keycode::Escape),
|
||||||
|
..
|
||||||
|
} => break 'main_loop,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (w, h) = window.size();
|
||||||
|
let angle = args
|
||||||
|
.angle
|
||||||
|
.unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
|
||||||
|
let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
draw_frame(
|
||||||
|
gl,
|
||||||
|
program,
|
||||||
|
u_mvp,
|
||||||
|
u_use_tex,
|
||||||
|
u_tex,
|
||||||
|
a_pos,
|
||||||
|
a_uv,
|
||||||
|
vbo,
|
||||||
|
ebo,
|
||||||
|
vao,
|
||||||
|
texture,
|
||||||
|
index_count,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
&mvp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
window.gl_swap_window();
|
||||||
|
|
||||||
|
fps_frames = fps_frames.saturating_add(1);
|
||||||
|
let elapsed = fps_window_start.elapsed();
|
||||||
|
if elapsed >= Duration::from_millis(500) {
|
||||||
|
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
|
||||||
|
let frame_time_ms = 1000.0 / fps.max(0.000_1);
|
||||||
|
let _ = window.set_title(&format!(
|
||||||
|
"{base_title} | FPS: {fps:.1} ({frame_time_ms:.2} ms)"
|
||||||
|
));
|
||||||
|
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
fps_printed = true;
|
||||||
|
fps_frames = 0;
|
||||||
|
fps_window_start = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fps_printed {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_mvp(
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fov_deg: f32,
|
||||||
|
center: [f32; 3],
|
||||||
|
camera_distance: f32,
|
||||||
|
angle_rad: f32,
|
||||||
|
) -> [f32; 16] {
|
||||||
|
let aspect = (width as f32 / (height.max(1) as f32)).max(0.01);
|
||||||
|
let proj = mat4_perspective(fov_deg.to_radians(), aspect, 0.01, camera_distance * 10.0);
|
||||||
|
let view = mat4_translation(0.0, 0.0, -camera_distance);
|
||||||
|
let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
|
||||||
|
let rot = mat4_rotation_y(angle_rad);
|
||||||
|
let model_m = mat4_mul(&rot, ¢er_shift);
|
||||||
|
let vp = mat4_mul(&view, &model_m);
|
||||||
|
mat4_mul(&proj, &vp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
unsafe fn draw_frame(
|
||||||
|
gl: &glow::Context,
|
||||||
|
program: glow::NativeProgram,
|
||||||
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
vao: Option<glow::NativeVertexArray>,
|
||||||
|
texture: Option<&GpuTexture>,
|
||||||
|
index_count: usize,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
mvp: &[f32; 16],
|
||||||
|
) {
|
||||||
|
gl.viewport(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width.min(i32::MAX as u32) as i32,
|
||||||
|
height.min(i32::MAX as u32) as i32,
|
||||||
|
);
|
||||||
|
gl.enable(glow::DEPTH_TEST);
|
||||||
|
gl.clear_color(0.06, 0.08, 0.12, 1.0);
|
||||||
|
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
|
||||||
|
|
||||||
|
gl.use_program(Some(program));
|
||||||
|
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
|
||||||
|
|
||||||
|
let texture_enabled = texture.is_some();
|
||||||
|
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
|
||||||
|
if let Some(tex) = texture {
|
||||||
|
gl.active_texture(glow::TEXTURE0);
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
|
||||||
|
gl.uniform_1_i32(u_tex, 0);
|
||||||
|
} else {
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(vao) = vao {
|
||||||
|
gl.bind_vertex_array(Some(vao));
|
||||||
|
gl.draw_elements(
|
||||||
|
glow::TRIANGLES,
|
||||||
|
index_count.min(i32::MAX as usize) as i32,
|
||||||
|
glow::UNSIGNED_SHORT,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
gl.bind_vertex_array(None);
|
||||||
|
} else {
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||||
|
gl.enable_vertex_attrib_array(a_pos);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
||||||
|
gl.enable_vertex_attrib_array(a_uv);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
||||||
|
gl.draw_elements(
|
||||||
|
glow::TRIANGLES,
|
||||||
|
index_count.min(i32::MAX as usize) as i32,
|
||||||
|
glow::UNSIGNED_SHORT,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
gl.disable_vertex_attrib_array(a_uv);
|
||||||
|
gl.disable_vertex_attrib_array(a_pos);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
}
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
|
gl.use_program(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn read_pixels_rgba(gl: &glow::Context, width: u32, height: u32) -> Result<Vec<u8>, String> {
|
||||||
|
let pixel_count = usize::try_from(width)
|
||||||
|
.ok()
|
||||||
|
.and_then(|w| usize::try_from(height).ok().map(|h| w.saturating_mul(h)))
|
||||||
|
.ok_or_else(|| String::from("frame dimensions are too large"))?;
|
||||||
|
let mut pixels = vec![0u8; pixel_count.saturating_mul(4)];
|
||||||
|
gl.read_pixels(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width.min(i32::MAX as u32) as i32,
|
||||||
|
height.min(i32::MAX as u32) as i32,
|
||||||
|
glow::RGBA,
|
||||||
|
glow::UNSIGNED_BYTE,
|
||||||
|
glow::PixelPackData::Slice(Some(pixels.as_mut_slice())),
|
||||||
|
);
|
||||||
|
Ok(pixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) {
|
||||||
|
let stride = width.saturating_mul(4);
|
||||||
|
if stride == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for y in 0..(height / 2) {
|
||||||
|
let top = y * stride;
|
||||||
|
let bottom = (height - 1 - y) * stride;
|
||||||
|
for i in 0..stride {
|
||||||
|
rgba.swap(top + i, bottom + i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), String> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
if !parent.as_os_str().is_empty() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"failed to create output directory {}: {err}",
|
||||||
|
parent.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let image = image::RgbaImage::from_raw(width, height, rgba)
|
||||||
|
.ok_or_else(|| String::from("failed to build image from framebuffer bytes"))?;
|
||||||
|
image
|
||||||
|
.save(path)
|
||||||
|
.map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn create_program(
|
||||||
|
gl: &glow::Context,
|
||||||
|
backend: GlBackend,
|
||||||
|
) -> Result<glow::NativeProgram, String> {
|
||||||
|
let (vs_src, fs_src) = match backend {
|
||||||
|
GlBackend::Gles2 => (
|
||||||
|
r#"
|
||||||
|
attribute vec3 a_pos;
|
||||||
|
attribute vec2 a_uv;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
v_uv = a_uv;
|
||||||
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
r#"
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D u_tex;
|
||||||
|
uniform float u_use_tex;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
|
||||||
|
vec4 texColor = texture2D(u_tex, v_uv);
|
||||||
|
gl_FragColor = mix(base, texColor, u_use_tex);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
GlBackend::Core33 => (
|
||||||
|
r#"#version 330 core
|
||||||
|
in vec3 a_pos;
|
||||||
|
in vec2 a_uv;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
out vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
v_uv = a_uv;
|
||||||
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
r#"#version 330 core
|
||||||
|
uniform sampler2D u_tex;
|
||||||
|
uniform float u_use_tex;
|
||||||
|
in vec2 v_uv;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
|
||||||
|
vec4 texColor = texture(u_tex, v_uv);
|
||||||
|
fragColor = mix(base, texColor, u_use_tex);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let program = gl.create_program().map_err(|e| e.to_string())?;
|
||||||
|
let vs = gl
|
||||||
|
.create_shader(glow::VERTEX_SHADER)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let fs = gl
|
||||||
|
.create_shader(glow::FRAGMENT_SHADER)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
gl.shader_source(vs, vs_src);
|
||||||
|
gl.compile_shader(vs);
|
||||||
|
if !gl.get_shader_compile_status(vs) {
|
||||||
|
let log = gl.get_shader_info_log(vs);
|
||||||
|
gl.delete_shader(vs);
|
||||||
|
gl.delete_shader(fs);
|
||||||
|
gl.delete_program(program);
|
||||||
|
return Err(format!("vertex shader compile failed: {log}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.shader_source(fs, fs_src);
|
||||||
|
gl.compile_shader(fs);
|
||||||
|
if !gl.get_shader_compile_status(fs) {
|
||||||
|
let log = gl.get_shader_info_log(fs);
|
||||||
|
gl.delete_shader(vs);
|
||||||
|
gl.delete_shader(fs);
|
||||||
|
gl.delete_program(program);
|
||||||
|
return Err(format!("fragment shader compile failed: {log}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.attach_shader(program, vs);
|
||||||
|
gl.attach_shader(program, fs);
|
||||||
|
gl.link_program(program);
|
||||||
|
|
||||||
|
gl.detach_shader(program, vs);
|
||||||
|
gl.detach_shader(program, fs);
|
||||||
|
gl.delete_shader(vs);
|
||||||
|
gl.delete_shader(fs);
|
||||||
|
|
||||||
|
if !gl.get_program_link_status(program) {
|
||||||
|
let log = gl.get_program_info_log(program);
|
||||||
|
gl.delete_program(program);
|
||||||
|
return Err(format!("program link failed: {log}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(program)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
|
||||||
|
for &value in slice {
|
||||||
|
out.extend_from_slice(&value.to_ne_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
|
||||||
|
for &value in slice {
|
||||||
|
out.extend_from_slice(&value.to_ne_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_identity() -> [f32; 16] {
|
||||||
|
[
|
||||||
|
1.0, 0.0, 0.0, 0.0, //
|
||||||
|
0.0, 1.0, 0.0, 0.0, //
|
||||||
|
0.0, 0.0, 1.0, 0.0, //
|
||||||
|
0.0, 0.0, 0.0, 1.0, //
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
|
||||||
|
let mut m = mat4_identity();
|
||||||
|
m[12] = x;
|
||||||
|
m[13] = y;
|
||||||
|
m[14] = z;
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
|
||||||
|
let c = rad.cos();
|
||||||
|
let s = rad.sin();
|
||||||
|
[
|
||||||
|
c, 0.0, -s, 0.0, //
|
||||||
|
0.0, 1.0, 0.0, 0.0, //
|
||||||
|
s, 0.0, c, 0.0, //
|
||||||
|
0.0, 0.0, 0.0, 1.0, //
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
|
||||||
|
let f = 1.0 / (0.5 * fovy).tan();
|
||||||
|
let nf = 1.0 / (near - far);
|
||||||
|
[
|
||||||
|
f / aspect,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
f,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
(far + near) * nf,
|
||||||
|
-1.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
(2.0 * far * near) * nf,
|
||||||
|
0.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
|
||||||
|
let mut out = [0.0f32; 16];
|
||||||
|
for c in 0..4 {
|
||||||
|
for r in 0..4 {
|
||||||
|
let mut acc = 0.0f32;
|
||||||
|
for k in 0..4 {
|
||||||
|
acc += a[k * 4 + r] * b[c * 4 + k];
|
||||||
|
}
|
||||||
|
out[c * 4 + r] = acc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
33
crates/render-mission-demo/Cargo.toml
Normal file
33
crates/render-mission-demo/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[package]
|
||||||
|
name = "render-mission-demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
demo = ["dep:sdl2", "dep:glow"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
glow = { version = "0.16", optional = true }
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
render-core = { path = "../render-core" }
|
||||||
|
render-demo = { path = "../render-demo" }
|
||||||
|
tma = { path = "../tma" }
|
||||||
|
terrain-core = { path = "../terrain-core" }
|
||||||
|
texm = { path = "../texm" }
|
||||||
|
unitdat = { path = "../unitdat" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||||
|
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "parkan-render-mission-demo"
|
||||||
|
path = "src/main.rs"
|
||||||
|
required-features = ["demo"]
|
||||||
881
crates/render-mission-demo/src/lib.rs
Normal file
881
crates/render-mission-demo/src/lib.rs
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
use nres::Archive;
|
||||||
|
use render_core::{build_render_mesh, RenderMesh};
|
||||||
|
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use terrain_core::TerrainMesh;
|
||||||
|
use tma::MissionFile;
|
||||||
|
|
||||||
|
const MAT0_KIND: u32 = 0x3054_414D;
|
||||||
|
const MESH_KIND: u32 = 0x4853_454D;
|
||||||
|
const OBJECT_REF_STRIDE: usize = 64;
|
||||||
|
const OBJECT_REF_ARCHIVE_BYTES: usize = 32;
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Io(std::io::Error),
|
||||||
|
Mission(tma::Error),
|
||||||
|
Terrain(terrain_core::Error),
|
||||||
|
UnitDat(unitdat::Error),
|
||||||
|
RenderDemo(render_demo::Error),
|
||||||
|
Nres(nres::error::Error),
|
||||||
|
Texm(texm::error::Error),
|
||||||
|
InvalidMapPath(String),
|
||||||
|
GameRootNotFound(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
Self::Mission(err) => write!(f, "{err}"),
|
||||||
|
Self::Terrain(err) => write!(f, "{err}"),
|
||||||
|
Self::UnitDat(err) => write!(f, "{err}"),
|
||||||
|
Self::RenderDemo(err) => write!(f, "{err}"),
|
||||||
|
Self::Nres(err) => write!(f, "{err}"),
|
||||||
|
Self::Texm(err) => write!(f, "{err}"),
|
||||||
|
Self::InvalidMapPath(path) => write!(f, "invalid mission map path: {path}"),
|
||||||
|
Self::GameRootNotFound(path) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"failed to detect game root from mission path {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => Some(err),
|
||||||
|
Self::Mission(err) => Some(err),
|
||||||
|
Self::Terrain(err) => Some(err),
|
||||||
|
Self::UnitDat(err) => Some(err),
|
||||||
|
Self::RenderDemo(err) => Some(err),
|
||||||
|
Self::Nres(err) => Some(err),
|
||||||
|
Self::Texm(err) => Some(err),
|
||||||
|
Self::InvalidMapPath(_) | Self::GameRootNotFound(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(value: std::io::Error) -> Self {
|
||||||
|
Self::Io(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tma::Error> for Error {
|
||||||
|
fn from(value: tma::Error) -> Self {
|
||||||
|
Self::Mission(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<terrain_core::Error> for Error {
|
||||||
|
fn from(value: terrain_core::Error) -> Self {
|
||||||
|
Self::Terrain(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<unitdat::Error> for Error {
|
||||||
|
fn from(value: unitdat::Error) -> Self {
|
||||||
|
Self::UnitDat(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<render_demo::Error> for Error {
|
||||||
|
fn from(value: render_demo::Error) -> Self {
|
||||||
|
Self::RenderDemo(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nres::error::Error> for Error {
|
||||||
|
fn from(value: nres::error::Error) -> Self {
|
||||||
|
Self::Nres(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<texm::error::Error> for Error {
|
||||||
|
fn from(value: texm::error::Error) -> Self {
|
||||||
|
Self::Texm(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct LoadOptions {
|
||||||
|
pub load_model_textures: bool,
|
||||||
|
pub load_terrain_texture: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoadOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
load_model_textures: true,
|
||||||
|
load_terrain_texture: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MissionScene {
|
||||||
|
pub game_root: PathBuf,
|
||||||
|
pub mission_path: PathBuf,
|
||||||
|
pub mission: MissionFile,
|
||||||
|
pub map_folder_rel: PathBuf,
|
||||||
|
pub land_msh_path: PathBuf,
|
||||||
|
pub terrain: TerrainMesh,
|
||||||
|
pub terrain_texture: Option<LoadedTexture>,
|
||||||
|
pub models: Vec<SceneModel>,
|
||||||
|
pub skipped_objects: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SceneModel {
|
||||||
|
pub archive_path: PathBuf,
|
||||||
|
pub model_name: String,
|
||||||
|
pub mesh: RenderMesh,
|
||||||
|
pub texture: Option<LoadedTexture>,
|
||||||
|
pub instances: Vec<ModelInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct ModelInstance {
|
||||||
|
pub position: [f32; 3],
|
||||||
|
pub yaw_rad: f32,
|
||||||
|
pub scale: [f32; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ObjectPrototype {
|
||||||
|
archive_path: PathBuf,
|
||||||
|
model_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ObjectRef {
|
||||||
|
archive_name: String,
|
||||||
|
resource_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||||
|
struct ModelKey {
|
||||||
|
archive_path: PathBuf,
|
||||||
|
model_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_game_root_from_mission_path(mission_path: &Path) -> Option<PathBuf> {
|
||||||
|
let mut cursor = mission_path.parent();
|
||||||
|
while let Some(dir) = cursor {
|
||||||
|
if dir.join("DATA").is_dir() && dir.join("objects.rlb").is_file() {
|
||||||
|
return Some(dir.to_path_buf());
|
||||||
|
}
|
||||||
|
cursor = dir.parent();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_scene(
|
||||||
|
game_root: impl AsRef<Path>,
|
||||||
|
mission_path: impl AsRef<Path>,
|
||||||
|
) -> Result<MissionScene> {
|
||||||
|
load_scene_with_options(game_root, mission_path, LoadOptions::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_scene_with_options(
|
||||||
|
game_root: impl AsRef<Path>,
|
||||||
|
mission_path: impl AsRef<Path>,
|
||||||
|
options: LoadOptions,
|
||||||
|
) -> Result<MissionScene> {
|
||||||
|
let game_root = game_root.as_ref().to_path_buf();
|
||||||
|
let mission_path = mission_path.as_ref().to_path_buf();
|
||||||
|
|
||||||
|
let mission = tma::parse_path(&mission_path)?;
|
||||||
|
let map_folder_rel = map_folder_from_footer(&mission.footer.map_path)?;
|
||||||
|
let land_msh_path = game_root.join(&map_folder_rel).join("Land.msh");
|
||||||
|
let terrain = terrain_core::load_land_mesh(&land_msh_path)?;
|
||||||
|
let terrain_texture = if options.load_terrain_texture {
|
||||||
|
resolve_terrain_texture(&game_root, &map_folder_rel)?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut grouped_instances: HashMap<ModelKey, Vec<ModelInstance>> = HashMap::new();
|
||||||
|
let mut prototype_cache: HashMap<String, Option<ObjectPrototype>> = HashMap::new();
|
||||||
|
let mut skipped = 0usize;
|
||||||
|
|
||||||
|
for object in &mission.objects {
|
||||||
|
let cache_key = object.resource_name.to_ascii_lowercase();
|
||||||
|
let proto = if let Some(cached) = prototype_cache.get(&cache_key) {
|
||||||
|
cached.clone()
|
||||||
|
} else {
|
||||||
|
let resolved = resolve_object_prototype(&game_root, object)?;
|
||||||
|
prototype_cache.insert(cache_key, resolved.clone());
|
||||||
|
resolved
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(proto) = proto else {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let instance = ModelInstance {
|
||||||
|
position: object.position,
|
||||||
|
yaw_rad: object.orientation[2],
|
||||||
|
scale: normalize_scale(object.scale),
|
||||||
|
};
|
||||||
|
|
||||||
|
grouped_instances
|
||||||
|
.entry(ModelKey {
|
||||||
|
archive_path: proto.archive_path,
|
||||||
|
model_name: proto.model_name,
|
||||||
|
})
|
||||||
|
.or_default()
|
||||||
|
.push(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut models = Vec::new();
|
||||||
|
for (key, instances) in grouped_instances {
|
||||||
|
let loaded =
|
||||||
|
match load_model_with_name_from_archive(&key.archive_path, Some(&key.model_name)) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
skipped += instances.len();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mesh = build_render_mesh(&loaded.model, 0, 0);
|
||||||
|
if mesh.indices.is_empty() {
|
||||||
|
skipped += instances.len();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let texture = if options.load_model_textures {
|
||||||
|
resolve_texture_for_model(&key.archive_path, &loaded.name, None, None, None, None)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
models.push(SceneModel {
|
||||||
|
archive_path: key.archive_path,
|
||||||
|
model_name: loaded.name,
|
||||||
|
mesh,
|
||||||
|
texture,
|
||||||
|
instances,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
models.sort_by(|a, b| a.model_name.cmp(&b.model_name));
|
||||||
|
|
||||||
|
Ok(MissionScene {
|
||||||
|
game_root,
|
||||||
|
mission_path,
|
||||||
|
mission,
|
||||||
|
map_folder_rel,
|
||||||
|
land_msh_path,
|
||||||
|
terrain,
|
||||||
|
terrain_texture,
|
||||||
|
models,
|
||||||
|
skipped_objects: skipped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_scene_bounds(scene: &MissionScene) -> Option<([f32; 3], [f32; 3])> {
|
||||||
|
let mut min_v = [f32::INFINITY; 3];
|
||||||
|
let mut max_v = [f32::NEG_INFINITY; 3];
|
||||||
|
let mut any = false;
|
||||||
|
|
||||||
|
for pos in &scene.terrain.positions {
|
||||||
|
merge_bounds(&mut min_v, &mut max_v, *pos);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for model in &scene.models {
|
||||||
|
for instance in &model.instances {
|
||||||
|
merge_bounds(&mut min_v, &mut max_v, instance.position);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
any.then_some((min_v, max_v))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_bounds(min_v: &mut [f32; 3], max_v: &mut [f32; 3], p: [f32; 3]) {
|
||||||
|
for i in 0..3 {
|
||||||
|
if p[i] < min_v[i] {
|
||||||
|
min_v[i] = p[i];
|
||||||
|
}
|
||||||
|
if p[i] > max_v[i] {
|
||||||
|
max_v[i] = p[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_scale(scale: [f32; 3]) -> [f32; 3] {
|
||||||
|
let mut out = scale;
|
||||||
|
for item in &mut out {
|
||||||
|
if !item.is_finite() || item.abs() < 0.000_1 {
|
||||||
|
*item = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_folder_from_footer(map_path: &str) -> Result<PathBuf> {
|
||||||
|
let mut parts = split_relative_path(map_path);
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return Err(Error::InvalidMapPath(map_path.to_string()));
|
||||||
|
}
|
||||||
|
parts.pop(); // remove 'land'
|
||||||
|
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for part in parts {
|
||||||
|
out.push(part);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_object_prototype(
|
||||||
|
game_root: &Path,
|
||||||
|
object: &tma::MissionObject,
|
||||||
|
) -> Result<Option<ObjectPrototype>> {
|
||||||
|
if object.resource_name.to_ascii_lowercase().ends_with(".dat") {
|
||||||
|
let dat_path = game_root.join(pathbuf_from_rel(&object.resource_name));
|
||||||
|
if !dat_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = unitdat::parse_path(&dat_path)?;
|
||||||
|
let archive_path = game_root.join(pathbuf_from_rel(&parsed.archive_name));
|
||||||
|
if !archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return resolve_archive_model(game_root, &archive_path, &parsed.model_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let archive_path = game_root.join("objects.rlb");
|
||||||
|
if !archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
resolve_archive_model(game_root, &archive_path, &object.resource_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_archive_model(
|
||||||
|
game_root: &Path,
|
||||||
|
archive_path: &Path,
|
||||||
|
model_key: &str,
|
||||||
|
) -> Result<Option<ObjectPrototype>> {
|
||||||
|
if !archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_objects_registry_archive(archive_path) {
|
||||||
|
if let Some(proto) = resolve_objects_registry_model(game_root, archive_path, model_key)? {
|
||||||
|
return Ok(Some(proto));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_name = ensure_msh_suffix(model_key);
|
||||||
|
if !archive_has_mesh_entry(archive_path, &model_name)? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(ObjectPrototype {
|
||||||
|
archive_path: archive_path.to_path_buf(),
|
||||||
|
model_name: model_name.to_ascii_lowercase(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_objects_registry_archive(archive_path: &Path) -> bool {
|
||||||
|
archive_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case("objects.rlb"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_objects_registry_model(
|
||||||
|
game_root: &Path,
|
||||||
|
registry_archive_path: &Path,
|
||||||
|
object_key: &str,
|
||||||
|
) -> Result<Option<ObjectPrototype>> {
|
||||||
|
let archive = Archive::open_path(registry_archive_path)?;
|
||||||
|
let Some(entry_id) = find_registry_entry_id(&archive, object_key) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = archive.read(entry_id)?.into_owned();
|
||||||
|
let refs = parse_object_refs(&payload);
|
||||||
|
if refs.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in refs
|
||||||
|
.iter()
|
||||||
|
.filter(|item| has_extension(&item.resource_name, "msh"))
|
||||||
|
{
|
||||||
|
if let Some(proto) = resolve_object_ref_model(game_root, item, &item.resource_name)? {
|
||||||
|
return Ok(Some(proto));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in refs
|
||||||
|
.iter()
|
||||||
|
.filter(|item| has_extension(&item.resource_name, "bas"))
|
||||||
|
{
|
||||||
|
let Some(stem) = Path::new(&item.resource_name)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if stem.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let candidate = format!("{stem}.msh");
|
||||||
|
if let Some(proto) = resolve_object_ref_model(game_root, item, &candidate)? {
|
||||||
|
return Ok(Some(proto));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_registry_entry_id(archive: &Archive, object_key: &str) -> Option<nres::EntryId> {
|
||||||
|
mesh_name_candidates(object_key)
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|candidate| archive.find(&candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_object_ref_model(
|
||||||
|
game_root: &Path,
|
||||||
|
item: &ObjectRef,
|
||||||
|
model_name: &str,
|
||||||
|
) -> Result<Option<ObjectPrototype>> {
|
||||||
|
let archive_path = game_root.join(pathbuf_from_rel(&item.archive_name));
|
||||||
|
if !archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !archive_has_mesh_entry(&archive_path, model_name)? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(ObjectPrototype {
|
||||||
|
archive_path,
|
||||||
|
model_name: model_name.to_ascii_lowercase(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_object_refs(payload: &[u8]) -> Vec<ObjectRef> {
|
||||||
|
if !payload.len().is_multiple_of(OBJECT_REF_STRIDE) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut refs = Vec::with_capacity(payload.len() / OBJECT_REF_STRIDE);
|
||||||
|
for chunk in payload.chunks_exact(OBJECT_REF_STRIDE) {
|
||||||
|
let archive_name = decode_cp1251_cstr(&chunk[..OBJECT_REF_ARCHIVE_BYTES]);
|
||||||
|
let resource_name = decode_cp1251_cstr(&chunk[OBJECT_REF_ARCHIVE_BYTES..]);
|
||||||
|
if archive_name.is_empty() || resource_name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
refs.push(ObjectRef {
|
||||||
|
archive_name,
|
||||||
|
resource_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archive_has_mesh_entry(archive_path: &Path, requested_name: &str) -> Result<bool> {
|
||||||
|
let archive = Archive::open_path(archive_path)?;
|
||||||
|
Ok(find_mesh_entry_id(&archive, requested_name).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_mesh_entry_id(archive: &Archive, requested_name: &str) -> Option<nres::EntryId> {
|
||||||
|
for candidate in mesh_name_candidates(requested_name) {
|
||||||
|
let Some(id) = archive.find(&candidate) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(entry) = archive.get(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if entry.meta.kind == MESH_KIND || has_extension(&entry.meta.name, "msh") {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mesh_name_candidates(name: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
push_unique_string(&mut out, trimmed.to_string());
|
||||||
|
if let Some(stem) = trimmed
|
||||||
|
.strip_suffix(".msh")
|
||||||
|
.or_else(|| trimmed.strip_suffix(".MSH"))
|
||||||
|
{
|
||||||
|
if !stem.is_empty() {
|
||||||
|
push_unique_string(&mut out, stem.to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
push_unique_string(&mut out, format!("{trimmed}.msh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_unique_string(items: &mut Vec<String>, value: String) {
|
||||||
|
if !items.iter().any(|item| item.eq_ignore_ascii_case(&value)) {
|
||||||
|
items.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_msh_suffix(name: &str) -> String {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.to_ascii_lowercase().ends_with(".msh") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{trimmed}.msh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_extension(name: &str, ext: &str) -> bool {
|
||||||
|
Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.is_some_and(|value| value.eq_ignore_ascii_case(ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_terrain_texture(
|
||||||
|
game_root: &Path,
|
||||||
|
map_folder_rel: &Path,
|
||||||
|
) -> Result<Option<LoadedTexture>> {
|
||||||
|
let material_archive_path = game_root.join("material.lib");
|
||||||
|
let texture_archive_path = game_root.join("textures.lib");
|
||||||
|
if !material_archive_path.is_file() || !texture_archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for wear_name in ["Land1.wea", "Land2.wea"] {
|
||||||
|
let wear_path = game_root.join(map_folder_rel).join(wear_name);
|
||||||
|
if !wear_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let wear_payload = fs::read(&wear_path)?;
|
||||||
|
let Some(material_name) = parse_primary_material_from_wear(&wear_payload) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(texture_name) =
|
||||||
|
resolve_texture_name_from_material_archive(&material_archive_path, &material_name)?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(texture) = load_texm_by_name(&texture_archive_path, &texture_name)? {
|
||||||
|
return Ok(Some(texture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_primary_material_from_wear(bytes: &[u8]) -> Option<String> {
|
||||||
|
let text = decode_cp1251(bytes).replace('\r', "");
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let count = lines.next()?.trim().parse::<usize>().ok()?;
|
||||||
|
if count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in lines.take(count) {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let _legacy = parts.next()?;
|
||||||
|
let name = parts.next()?;
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Some(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_texture_name_from_material_archive(
|
||||||
|
archive_path: &Path,
|
||||||
|
material_name: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let archive = Archive::open_path(archive_path)?;
|
||||||
|
|
||||||
|
let entry = if let Some(id) = archive.find(material_name) {
|
||||||
|
archive
|
||||||
|
.get(id)
|
||||||
|
.filter(|entry| entry.meta.kind == MAT0_KIND)
|
||||||
|
.or_else(|| {
|
||||||
|
archive
|
||||||
|
.find("DEFAULT")
|
||||||
|
.and_then(|id| archive.get(id))
|
||||||
|
.filter(|entry| entry.meta.kind == MAT0_KIND)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
archive
|
||||||
|
.find("DEFAULT")
|
||||||
|
.and_then(|id| archive.get(id))
|
||||||
|
.filter(|entry| entry.meta.kind == MAT0_KIND)
|
||||||
|
}
|
||||||
|
.or_else(|| archive.entries().find(|entry| entry.meta.kind == MAT0_KIND));
|
||||||
|
|
||||||
|
let Some(entry) = entry else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = archive.read(entry.id)?.into_owned();
|
||||||
|
parse_primary_texture_name_from_mat0(&payload, entry.meta.attr2)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
|
||||||
|
if payload.len() < 4 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
|
||||||
|
if phase_count == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut offset = 4usize;
|
||||||
|
if attr2 >= 2 {
|
||||||
|
offset = offset.saturating_add(2);
|
||||||
|
}
|
||||||
|
if attr2 >= 3 {
|
||||||
|
offset = offset.saturating_add(4);
|
||||||
|
}
|
||||||
|
if attr2 >= 4 {
|
||||||
|
offset = offset.saturating_add(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
for phase in 0..phase_count {
|
||||||
|
let phase_off = offset.saturating_add(phase.saturating_mul(34));
|
||||||
|
let Some(rec) = payload.get(phase_off..phase_off + 34) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let name_raw = &rec[18..34];
|
||||||
|
let end = name_raw
|
||||||
|
.iter()
|
||||||
|
.position(|&b| b == 0)
|
||||||
|
.unwrap_or(name_raw.len());
|
||||||
|
let name = decode_cp1251(&name_raw[..end]).trim().to_string();
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Ok(Some(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_texm_by_name(archive_path: &Path, texture_name: &str) -> Result<Option<LoadedTexture>> {
|
||||||
|
let archive = Archive::open_path(archive_path)?;
|
||||||
|
let Some(id) = archive.find(texture_name) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(entry) = archive.get(id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if entry.meta.kind != texm::TEXM_MAGIC {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = archive.read(id)?.into_owned();
|
||||||
|
let parsed = texm::parse_texm(&payload)?;
|
||||||
|
let decoded = texm::decode_mip_rgba8(&parsed, &payload, 0)?;
|
||||||
|
|
||||||
|
Ok(Some(LoadedTexture {
|
||||||
|
name: entry.meta.name.clone(),
|
||||||
|
width: decoded.width,
|
||||||
|
height: decoded.height,
|
||||||
|
rgba8: decoded.rgba8,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_relative_path(path: &str) -> Vec<&str> {
|
||||||
|
path.split(['\\', '/'])
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pathbuf_from_rel(path: &str) -> PathBuf {
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for part in split_relative_path(path) {
|
||||||
|
out.push(part);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251_cstr(bytes: &[u8]) -> String {
|
||||||
|
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..end]);
|
||||||
|
decoded.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("Parkan - Iron Strategy");
|
||||||
|
root.is_dir().then_some(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_game_root_from_mission_path() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mission = root
|
||||||
|
.join("MISSIONS")
|
||||||
|
.join("CAMPAIGN")
|
||||||
|
.join("CAMPAIGN.00")
|
||||||
|
.join("Mission.01")
|
||||||
|
.join("data.tma");
|
||||||
|
if !mission.is_file() {
|
||||||
|
eprintln!("skipping missing mission sample");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detected = detect_game_root_from_mission_path(&mission)
|
||||||
|
.expect("failed to detect game root from mission path");
|
||||||
|
assert_eq!(detected, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_scene_cpu_without_textures() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mission = root
|
||||||
|
.join("MISSIONS")
|
||||||
|
.join("CAMPAIGN")
|
||||||
|
.join("CAMPAIGN.00")
|
||||||
|
.join("Mission.01")
|
||||||
|
.join("data.tma");
|
||||||
|
if !mission.is_file() {
|
||||||
|
eprintln!("skipping missing mission sample");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scene = load_scene_with_options(
|
||||||
|
&root,
|
||||||
|
&mission,
|
||||||
|
LoadOptions {
|
||||||
|
load_model_textures: false,
|
||||||
|
load_terrain_texture: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to load scene {}: {err}", mission.display()));
|
||||||
|
|
||||||
|
assert!(!scene.terrain.positions.is_empty());
|
||||||
|
assert!(!scene.terrain.faces.is_empty());
|
||||||
|
assert!(!scene.models.is_empty());
|
||||||
|
|
||||||
|
let instance_count = scene
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|model| model.instances.len())
|
||||||
|
.sum::<usize>();
|
||||||
|
assert!(instance_count >= 10);
|
||||||
|
|
||||||
|
let bounds = compute_scene_bounds(&scene).expect("scene bounds should exist");
|
||||||
|
assert!(bounds.0[0] <= bounds.1[0]);
|
||||||
|
assert!(bounds.0[1] <= bounds.1[1]);
|
||||||
|
assert!(bounds.0[2] <= bounds.1[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_scene_with_textures() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mission = root
|
||||||
|
.join("MISSIONS")
|
||||||
|
.join("CAMPAIGN")
|
||||||
|
.join("CAMPAIGN.00")
|
||||||
|
.join("Mission.01")
|
||||||
|
.join("data.tma");
|
||||||
|
if !mission.is_file() {
|
||||||
|
eprintln!("skipping missing mission sample");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scene = load_scene_with_options(&root, &mission, LoadOptions::default())
|
||||||
|
.unwrap_or_else(|err| panic!("failed to load textured scene {}: {err}", mission.display()));
|
||||||
|
|
||||||
|
assert!(!scene.models.is_empty());
|
||||||
|
let textured_models = scene.models.iter().filter(|model| model.texture.is_some()).count();
|
||||||
|
assert!(textured_models > 0, "no model textures resolved");
|
||||||
|
assert!(scene.terrain_texture.is_some(), "terrain texture was not resolved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_objects_registry_models() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let registry = root.join("objects.rlb");
|
||||||
|
if !registry.is_file() {
|
||||||
|
eprintln!("skipping missing objects.rlb");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cases = [
|
||||||
|
("r_h_01", "bases.rlb", "r_h_01.msh"),
|
||||||
|
("s_tree_04", "static.rlb", "s_tree_0_04.msh"),
|
||||||
|
("fr_m_brige", "fortif.rlb", "fr_m_brige.msh"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (key, archive_name, model_name) in cases {
|
||||||
|
let proto = resolve_objects_registry_model(&root, ®istry, key)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}"))
|
||||||
|
.unwrap_or_else(|| panic!("missing model resolution for '{key}'"));
|
||||||
|
|
||||||
|
let got_archive = proto
|
||||||
|
.archive_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(|name| name.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(got_archive, archive_name.to_ascii_lowercase());
|
||||||
|
assert!(
|
||||||
|
proto.model_name.eq_ignore_ascii_case(model_name),
|
||||||
|
"unexpected model for key '{key}': got '{}', expected '{}'",
|
||||||
|
proto.model_name,
|
||||||
|
model_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
924
crates/render-mission-demo/src/main.rs
Normal file
924
crates/render-mission-demo/src/main.rs
Normal file
@@ -0,0 +1,924 @@
|
|||||||
|
use glow::HasContext as _;
|
||||||
|
use render_mission_demo::{
|
||||||
|
compute_scene_bounds, detect_game_root_from_mission_path, load_scene_with_options, LoadOptions,
|
||||||
|
MissionScene, ModelInstance,
|
||||||
|
};
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
mission: PathBuf,
|
||||||
|
game_root: Option<PathBuf>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fov_deg: f32,
|
||||||
|
no_model_texture: bool,
|
||||||
|
no_terrain_texture: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
enum GlBackend {
|
||||||
|
Gles2,
|
||||||
|
Core33,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GpuTexture {
|
||||||
|
handle: glow::NativeTexture,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GpuRenderable {
|
||||||
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
index_count: usize,
|
||||||
|
texture: Option<GpuTexture>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ModelRenderable {
|
||||||
|
gpu: GpuRenderable,
|
||||||
|
instances: Vec<ModelInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
struct Camera {
|
||||||
|
position: [f32; 3],
|
||||||
|
yaw: f32,
|
||||||
|
pitch: f32,
|
||||||
|
move_speed: f32,
|
||||||
|
mouse_sensitivity: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
let mut mission = None;
|
||||||
|
let mut game_root = None;
|
||||||
|
let mut width = 1600u32;
|
||||||
|
let mut height = 900u32;
|
||||||
|
let mut fov_deg = 60.0f32;
|
||||||
|
let mut no_model_texture = false;
|
||||||
|
let mut no_terrain_texture = false;
|
||||||
|
|
||||||
|
let mut it = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = it.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--mission" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --mission"))?;
|
||||||
|
mission = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--game-root" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --game-root"))?;
|
||||||
|
game_root = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--width" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --width"))?;
|
||||||
|
width = value
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| String::from("invalid --width value"))?;
|
||||||
|
if width == 0 {
|
||||||
|
return Err(String::from("--width must be > 0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--height" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --height"))?;
|
||||||
|
height = value
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| String::from("invalid --height value"))?;
|
||||||
|
if height == 0 {
|
||||||
|
return Err(String::from("--height must be > 0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--fov" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --fov"))?;
|
||||||
|
fov_deg = value
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| String::from("invalid --fov value"))?;
|
||||||
|
if !(1.0..=179.0).contains(&fov_deg) {
|
||||||
|
return Err(String::from("--fov must be in range [1, 179]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--no-model-texture" => {
|
||||||
|
no_model_texture = true;
|
||||||
|
}
|
||||||
|
"--no-terrain-texture" => {
|
||||||
|
no_terrain_texture = true;
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_help();
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(format!("unknown argument: {other}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mission = mission.ok_or_else(|| String::from("missing required --mission"))?;
|
||||||
|
Ok(Args {
|
||||||
|
mission,
|
||||||
|
game_root,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fov_deg,
|
||||||
|
no_model_texture,
|
||||||
|
no_terrain_texture,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_help() {
|
||||||
|
eprintln!("parkan-render-mission-demo --mission <path/to/data.tma> [--game-root <path>] [--width W] [--height H] [--fov DEG]");
|
||||||
|
eprintln!(" [--no-model-texture] [--no-terrain-texture]");
|
||||||
|
eprintln!("controls: arrows/WASD move, PageUp/PageDown vertical move, Right Mouse drag look, Shift speed-up, Esc exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = match parse_args() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{err}");
|
||||||
|
print_help();
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = run(args) {
|
||||||
|
eprintln!("{err}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: Args) -> Result<(), String> {
|
||||||
|
let game_root = if let Some(path) = args.game_root.clone() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
detect_game_root_from_mission_path(&args.mission).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"failed to detect game root from mission path {} (use --game-root)",
|
||||||
|
args.mission.display()
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let scene = load_scene_with_options(
|
||||||
|
&game_root,
|
||||||
|
&args.mission,
|
||||||
|
LoadOptions {
|
||||||
|
load_model_textures: !args.no_model_texture,
|
||||||
|
load_terrain_texture: !args.no_terrain_texture,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|err| format!("failed to load mission scene: {err}"))?;
|
||||||
|
|
||||||
|
let terrain_mesh = terrain_core::build_render_mesh(&scene.terrain)
|
||||||
|
.map_err(|err| format!("failed to build terrain render mesh: {err}"))?;
|
||||||
|
|
||||||
|
let instance_count = scene
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|model| model.instances.len())
|
||||||
|
.sum::<usize>();
|
||||||
|
println!(
|
||||||
|
"mission loaded: map='{}', terrain_vertices={}, terrain_faces={}, models={}, instances={}, skipped={}",
|
||||||
|
scene.mission.footer.map_path,
|
||||||
|
scene.terrain.positions.len(),
|
||||||
|
scene.terrain.faces.len(),
|
||||||
|
scene.models.len(),
|
||||||
|
instance_count,
|
||||||
|
scene.skipped_objects
|
||||||
|
);
|
||||||
|
|
||||||
|
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
|
||||||
|
let video = sdl
|
||||||
|
.video()
|
||||||
|
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
|
||||||
|
|
||||||
|
let (mut window, _gl_ctx, gl_backend) =
|
||||||
|
create_window_and_context(&video, args.width, args.height)?;
|
||||||
|
let _ = video.gl_set_swap_interval(1);
|
||||||
|
|
||||||
|
let gl = unsafe {
|
||||||
|
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
|
||||||
|
};
|
||||||
|
|
||||||
|
let program = unsafe { create_program(&gl, gl_backend)? };
|
||||||
|
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
||||||
|
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
|
||||||
|
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
|
||||||
|
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
|
||||||
|
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
|
||||||
|
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
|
||||||
|
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
|
||||||
|
|
||||||
|
let terrain_gpu =
|
||||||
|
unsafe { upload_terrain_renderable(&gl, &terrain_mesh, scene.terrain_texture.as_ref())? };
|
||||||
|
|
||||||
|
let mut model_gpus = Vec::new();
|
||||||
|
for model in &scene.models {
|
||||||
|
let renderable = unsafe { upload_model_renderable(&gl, model)? };
|
||||||
|
model_gpus.push(renderable);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (scene_center, scene_radius) = initial_scene_sphere(&scene);
|
||||||
|
let mut camera = Camera {
|
||||||
|
position: [
|
||||||
|
scene_center[0],
|
||||||
|
scene_center[1] + scene_radius * 0.6,
|
||||||
|
scene_center[2] + scene_radius * 1.4,
|
||||||
|
],
|
||||||
|
yaw: std::f32::consts::PI,
|
||||||
|
pitch: -0.28,
|
||||||
|
move_speed: (scene_radius * 0.55).max(60.0),
|
||||||
|
mouse_sensitivity: 0.005,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut events = sdl
|
||||||
|
.event_pump()
|
||||||
|
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
|
||||||
|
let mut last = Instant::now();
|
||||||
|
let mut fps_window_start = Instant::now();
|
||||||
|
let mut fps_frames = 0u32;
|
||||||
|
let mut fps_printed = false;
|
||||||
|
let mut mouse_look = false;
|
||||||
|
|
||||||
|
'main_loop: loop {
|
||||||
|
for event in events.poll_iter() {
|
||||||
|
match event {
|
||||||
|
sdl2::event::Event::Quit { .. } => break 'main_loop,
|
||||||
|
sdl2::event::Event::KeyDown {
|
||||||
|
keycode: Some(sdl2::keyboard::Keycode::Escape),
|
||||||
|
..
|
||||||
|
} => break 'main_loop,
|
||||||
|
sdl2::event::Event::MouseButtonDown {
|
||||||
|
mouse_btn: sdl2::mouse::MouseButton::Right,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
mouse_look = true;
|
||||||
|
sdl.mouse().set_relative_mouse_mode(true);
|
||||||
|
}
|
||||||
|
sdl2::event::Event::MouseButtonUp {
|
||||||
|
mouse_btn: sdl2::mouse::MouseButton::Right,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
mouse_look = false;
|
||||||
|
sdl.mouse().set_relative_mouse_mode(false);
|
||||||
|
}
|
||||||
|
sdl2::event::Event::MouseMotion { xrel, yrel, .. } if mouse_look => {
|
||||||
|
camera.yaw += xrel as f32 * camera.mouse_sensitivity;
|
||||||
|
camera.pitch -= yrel as f32 * camera.mouse_sensitivity;
|
||||||
|
camera.pitch = camera.pitch.clamp(-1.54, 1.54);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let dt = (now - last).as_secs_f32().clamp(0.0, 0.05);
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
update_camera(&events, &mut camera, dt);
|
||||||
|
|
||||||
|
let (w, h) = window.size();
|
||||||
|
let proj = mat4_perspective(
|
||||||
|
args.fov_deg.to_radians(),
|
||||||
|
(w as f32 / h.max(1) as f32).max(0.01),
|
||||||
|
0.1,
|
||||||
|
(scene_radius * 25.0).max(5000.0),
|
||||||
|
);
|
||||||
|
let forward = camera_forward(camera.yaw, camera.pitch);
|
||||||
|
let view = mat4_look_at(
|
||||||
|
camera.position,
|
||||||
|
[
|
||||||
|
camera.position[0] + forward[0],
|
||||||
|
camera.position[1] + forward[1],
|
||||||
|
camera.position[2] + forward[2],
|
||||||
|
],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
draw_frame_begin(&gl, w, h);
|
||||||
|
|
||||||
|
let terrain_mvp = mat4_mul(&proj, &view);
|
||||||
|
draw_gpu_renderable(
|
||||||
|
&gl,
|
||||||
|
program,
|
||||||
|
u_mvp.as_ref(),
|
||||||
|
u_use_tex.as_ref(),
|
||||||
|
u_tex.as_ref(),
|
||||||
|
a_pos,
|
||||||
|
a_uv,
|
||||||
|
&terrain_gpu,
|
||||||
|
&terrain_mvp,
|
||||||
|
);
|
||||||
|
|
||||||
|
for model in &model_gpus {
|
||||||
|
for instance in &model.instances {
|
||||||
|
let model_m = model_matrix(instance.position, instance.yaw_rad, instance.scale);
|
||||||
|
let view_model = mat4_mul(&view, &model_m);
|
||||||
|
let mvp = mat4_mul(&proj, &view_model);
|
||||||
|
draw_gpu_renderable(
|
||||||
|
&gl,
|
||||||
|
program,
|
||||||
|
u_mvp.as_ref(),
|
||||||
|
u_use_tex.as_ref(),
|
||||||
|
u_tex.as_ref(),
|
||||||
|
a_pos,
|
||||||
|
a_uv,
|
||||||
|
&model.gpu,
|
||||||
|
&mvp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.gl_swap_window();
|
||||||
|
|
||||||
|
fps_frames = fps_frames.saturating_add(1);
|
||||||
|
let elapsed = fps_window_start.elapsed();
|
||||||
|
if elapsed >= Duration::from_millis(500) {
|
||||||
|
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
|
||||||
|
let frame_time_ms = 1000.0 / fps.max(0.000_1);
|
||||||
|
let _ = window.set_title(&format!(
|
||||||
|
"Parkan Mission Demo | FPS: {fps:.1} ({frame_time_ms:.2} ms) | objects: {instance_count}"
|
||||||
|
));
|
||||||
|
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
fps_printed = true;
|
||||||
|
fps_frames = 0;
|
||||||
|
fps_window_start = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fps_printed {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
cleanup_renderable(&gl, terrain_gpu);
|
||||||
|
for model in model_gpus {
|
||||||
|
cleanup_renderable(&gl, model.gpu);
|
||||||
|
}
|
||||||
|
gl.delete_program(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_scene_sphere(scene: &MissionScene) -> ([f32; 3], f32) {
|
||||||
|
if let Some((min_v, max_v)) = compute_scene_bounds(scene) {
|
||||||
|
let center = [
|
||||||
|
0.5 * (min_v[0] + max_v[0]),
|
||||||
|
0.5 * (min_v[1] + max_v[1]),
|
||||||
|
0.5 * (min_v[2] + max_v[2]),
|
||||||
|
];
|
||||||
|
let extent = [
|
||||||
|
max_v[0] - min_v[0],
|
||||||
|
max_v[1] - min_v[1],
|
||||||
|
max_v[2] - min_v[2],
|
||||||
|
];
|
||||||
|
let radius = ((extent[0] * extent[0]) + (extent[1] * extent[1]) + (extent[2] * extent[2]))
|
||||||
|
.sqrt()
|
||||||
|
.max(10.0)
|
||||||
|
* 0.5;
|
||||||
|
return (center, radius);
|
||||||
|
}
|
||||||
|
([0.0, 0.0, 0.0], 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_camera(events: &sdl2::EventPump, camera: &mut Camera, dt: f32) {
|
||||||
|
use sdl2::keyboard::Scancode;
|
||||||
|
|
||||||
|
let keys = events.keyboard_state();
|
||||||
|
let mut move_dir = [0.0f32, 0.0f32, 0.0f32];
|
||||||
|
|
||||||
|
let forward = camera_forward(camera.yaw, camera.pitch);
|
||||||
|
let right = normalize3(cross3(forward, [0.0, 1.0, 0.0]));
|
||||||
|
|
||||||
|
if keys.is_scancode_pressed(Scancode::Up) || keys.is_scancode_pressed(Scancode::W) {
|
||||||
|
move_dir[0] += forward[0];
|
||||||
|
move_dir[1] += forward[1];
|
||||||
|
move_dir[2] += forward[2];
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::Down) || keys.is_scancode_pressed(Scancode::S) {
|
||||||
|
move_dir[0] -= forward[0];
|
||||||
|
move_dir[1] -= forward[1];
|
||||||
|
move_dir[2] -= forward[2];
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::Left) || keys.is_scancode_pressed(Scancode::A) {
|
||||||
|
move_dir[0] -= right[0];
|
||||||
|
move_dir[1] -= right[1];
|
||||||
|
move_dir[2] -= right[2];
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::Right) || keys.is_scancode_pressed(Scancode::D) {
|
||||||
|
move_dir[0] += right[0];
|
||||||
|
move_dir[1] += right[1];
|
||||||
|
move_dir[2] += right[2];
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::PageUp) || keys.is_scancode_pressed(Scancode::E) {
|
||||||
|
move_dir[1] += 1.0;
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::PageDown) || keys.is_scancode_pressed(Scancode::Q) {
|
||||||
|
move_dir[1] -= 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shift =
|
||||||
|
keys.is_scancode_pressed(Scancode::LShift) || keys.is_scancode_pressed(Scancode::RShift);
|
||||||
|
let speed_mul = if shift { 3.0 } else { 1.0 };
|
||||||
|
|
||||||
|
let norm = normalize3(move_dir);
|
||||||
|
camera.position[0] += norm[0] * camera.move_speed * speed_mul * dt;
|
||||||
|
camera.position[1] += norm[1] * camera.move_speed * speed_mul * dt;
|
||||||
|
camera.position[2] += norm[2] * camera.move_speed * speed_mul * dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn upload_model_renderable(
|
||||||
|
gl: &glow::Context,
|
||||||
|
model: &render_mission_demo::SceneModel,
|
||||||
|
) -> Result<ModelRenderable, String> {
|
||||||
|
let mut vertex_data = Vec::with_capacity(model.mesh.vertices.len() * 5);
|
||||||
|
for vertex in &model.mesh.vertices {
|
||||||
|
vertex_data.push(vertex.position[0]);
|
||||||
|
vertex_data.push(vertex.position[1]);
|
||||||
|
vertex_data.push(vertex.position[2]);
|
||||||
|
vertex_data.push(vertex.uv0[0]);
|
||||||
|
vertex_data.push(vertex.uv0[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let gpu = upload_gpu_renderable(
|
||||||
|
gl,
|
||||||
|
&vertex_data,
|
||||||
|
&model.mesh.indices,
|
||||||
|
model.texture.as_ref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(ModelRenderable {
|
||||||
|
gpu,
|
||||||
|
instances: model.instances.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn upload_terrain_renderable(
|
||||||
|
gl: &glow::Context,
|
||||||
|
mesh: &terrain_core::TerrainRenderMesh,
|
||||||
|
texture: Option<&render_demo::LoadedTexture>,
|
||||||
|
) -> Result<GpuRenderable, String> {
|
||||||
|
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
|
||||||
|
for vertex in &mesh.vertices {
|
||||||
|
vertex_data.push(vertex.position[0]);
|
||||||
|
vertex_data.push(vertex.position[1]);
|
||||||
|
vertex_data.push(vertex.position[2]);
|
||||||
|
vertex_data.push(vertex.uv0[0]);
|
||||||
|
vertex_data.push(vertex.uv0[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_gpu_renderable(gl, &vertex_data, &mesh.indices, texture)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn upload_gpu_renderable(
|
||||||
|
gl: &glow::Context,
|
||||||
|
vertices: &[f32],
|
||||||
|
indices: &[u16],
|
||||||
|
texture: Option<&render_demo::LoadedTexture>,
|
||||||
|
) -> Result<GpuRenderable, String> {
|
||||||
|
let vbo = gl.create_buffer().map_err(|e| e.to_string())?;
|
||||||
|
let ebo = gl.create_buffer().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let vertex_bytes = f32_slice_to_ne_bytes(vertices);
|
||||||
|
let index_bytes = u16_slice_to_ne_bytes(indices);
|
||||||
|
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
|
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||||
|
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
|
||||||
|
let gpu_texture = if let Some(texture) = texture {
|
||||||
|
Some(create_texture(gl, texture)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(GpuRenderable {
|
||||||
|
vbo,
|
||||||
|
ebo,
|
||||||
|
index_count: indices.len(),
|
||||||
|
texture: gpu_texture,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn cleanup_renderable(gl: &glow::Context, renderable: GpuRenderable) {
|
||||||
|
if let Some(tex) = renderable.texture {
|
||||||
|
gl.delete_texture(tex.handle);
|
||||||
|
}
|
||||||
|
gl.delete_buffer(renderable.ebo);
|
||||||
|
gl.delete_buffer(renderable.vbo);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn draw_frame_begin(gl: &glow::Context, width: u32, height: u32) {
|
||||||
|
gl.viewport(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width.min(i32::MAX as u32) as i32,
|
||||||
|
height.min(i32::MAX as u32) as i32,
|
||||||
|
);
|
||||||
|
gl.enable(glow::DEPTH_TEST);
|
||||||
|
gl.clear_color(0.06, 0.08, 0.12, 1.0);
|
||||||
|
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn draw_gpu_renderable(
|
||||||
|
gl: &glow::Context,
|
||||||
|
program: glow::NativeProgram,
|
||||||
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
|
renderable: &GpuRenderable,
|
||||||
|
mvp: &[f32; 16],
|
||||||
|
) {
|
||||||
|
gl.use_program(Some(program));
|
||||||
|
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
|
||||||
|
|
||||||
|
let texture_enabled = renderable.texture.is_some();
|
||||||
|
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
|
||||||
|
|
||||||
|
if let Some(tex) = &renderable.texture {
|
||||||
|
gl.active_texture(glow::TEXTURE0);
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
|
||||||
|
gl.uniform_1_i32(u_tex, 0);
|
||||||
|
} else {
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(renderable.vbo));
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(renderable.ebo));
|
||||||
|
gl.enable_vertex_attrib_array(a_pos);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
||||||
|
gl.enable_vertex_attrib_array(a_uv);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
||||||
|
|
||||||
|
gl.draw_elements(
|
||||||
|
glow::TRIANGLES,
|
||||||
|
renderable.index_count.min(i32::MAX as usize) as i32,
|
||||||
|
glow::UNSIGNED_SHORT,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
gl.disable_vertex_attrib_array(a_uv);
|
||||||
|
gl.disable_vertex_attrib_array(a_pos);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
|
gl.use_program(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_window_and_context(
|
||||||
|
video: &sdl2::VideoSubsystem,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
|
||||||
|
let candidates = [
|
||||||
|
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
|
||||||
|
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
|
||||||
|
];
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for (backend, profile, major, minor) in candidates {
|
||||||
|
{
|
||||||
|
let gl_attr = video.gl_attr();
|
||||||
|
gl_attr.set_context_profile(profile);
|
||||||
|
gl_attr.set_context_version(major, minor);
|
||||||
|
gl_attr.set_depth_size(24);
|
||||||
|
gl_attr.set_double_buffer(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut window_builder = video.window("Parkan Mission Demo", width, height);
|
||||||
|
window_builder.opengl().resizable();
|
||||||
|
|
||||||
|
let window = match window_builder.build() {
|
||||||
|
Ok(window) => window,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: window build failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let gl_ctx = match window.gl_create_context() {
|
||||||
|
Ok(ctx) => ctx,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: context create failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = window.gl_make_current(&gl_ctx) {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: make current failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok((window, gl_ctx, backend));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"failed to create OpenGL context. Attempts: {}",
|
||||||
|
errors.join(" | ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn create_texture(
|
||||||
|
gl: &glow::Context,
|
||||||
|
texture: &render_demo::LoadedTexture,
|
||||||
|
) -> Result<GpuTexture, String> {
|
||||||
|
let handle = gl.create_texture().map_err(|e| e.to_string())?;
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_MIN_FILTER,
|
||||||
|
glow::LINEAR as i32,
|
||||||
|
);
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_MAG_FILTER,
|
||||||
|
glow::LINEAR as i32,
|
||||||
|
);
|
||||||
|
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
|
||||||
|
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
|
||||||
|
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
|
||||||
|
gl.tex_image_2d(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
glow::RGBA as i32,
|
||||||
|
texture.width.min(i32::MAX as u32) as i32,
|
||||||
|
texture.height.min(i32::MAX as u32) as i32,
|
||||||
|
0,
|
||||||
|
glow::RGBA,
|
||||||
|
glow::UNSIGNED_BYTE,
|
||||||
|
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
|
||||||
|
);
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
|
Ok(GpuTexture { handle })
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn create_program(
|
||||||
|
gl: &glow::Context,
|
||||||
|
backend: GlBackend,
|
||||||
|
) -> Result<glow::NativeProgram, String> {
|
||||||
|
let (vs_src, fs_src) = match backend {
|
||||||
|
GlBackend::Gles2 => (
|
||||||
|
r#"
|
||||||
|
attribute vec3 a_pos;
|
||||||
|
attribute vec2 a_uv;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
v_uv = a_uv;
|
||||||
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
r#"
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D u_tex;
|
||||||
|
uniform float u_use_tex;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
|
||||||
|
vec4 texColor = texture2D(u_tex, v_uv);
|
||||||
|
gl_FragColor = mix(base, texColor, u_use_tex);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
GlBackend::Core33 => (
|
||||||
|
r#"#version 330 core
|
||||||
|
in vec3 a_pos;
|
||||||
|
in vec2 a_uv;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
out vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
v_uv = a_uv;
|
||||||
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
r#"#version 330 core
|
||||||
|
uniform sampler2D u_tex;
|
||||||
|
uniform float u_use_tex;
|
||||||
|
in vec2 v_uv;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
|
||||||
|
vec4 texColor = texture(u_tex, v_uv);
|
||||||
|
fragColor = mix(base, texColor, u_use_tex);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let program = gl.create_program().map_err(|e| e.to_string())?;
|
||||||
|
let vs = gl
|
||||||
|
.create_shader(glow::VERTEX_SHADER)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let fs = gl
|
||||||
|
.create_shader(glow::FRAGMENT_SHADER)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
gl.shader_source(vs, vs_src);
|
||||||
|
gl.compile_shader(vs);
|
||||||
|
if !gl.get_shader_compile_status(vs) {
|
||||||
|
let log = gl.get_shader_info_log(vs);
|
||||||
|
gl.delete_shader(vs);
|
||||||
|
gl.delete_shader(fs);
|
||||||
|
gl.delete_program(program);
|
||||||
|
return Err(format!("vertex shader compile failed: {log}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.shader_source(fs, fs_src);
|
||||||
|
gl.compile_shader(fs);
|
||||||
|
if !gl.get_shader_compile_status(fs) {
|
||||||
|
let log = gl.get_shader_info_log(fs);
|
||||||
|
gl.delete_shader(vs);
|
||||||
|
gl.delete_shader(fs);
|
||||||
|
gl.delete_program(program);
|
||||||
|
return Err(format!("fragment shader compile failed: {log}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.attach_shader(program, vs);
|
||||||
|
gl.attach_shader(program, fs);
|
||||||
|
gl.link_program(program);
|
||||||
|
|
||||||
|
gl.detach_shader(program, vs);
|
||||||
|
gl.detach_shader(program, fs);
|
||||||
|
gl.delete_shader(vs);
|
||||||
|
gl.delete_shader(fs);
|
||||||
|
|
||||||
|
if !gl.get_program_link_status(program) {
|
||||||
|
let log = gl.get_program_info_log(program);
|
||||||
|
gl.delete_program(program);
|
||||||
|
return Err(format!("program link failed: {log}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(program)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_matrix(position: [f32; 3], yaw: f32, scale: [f32; 3]) -> [f32; 16] {
|
||||||
|
let translation = mat4_translation(position[0], position[1], position[2]);
|
||||||
|
let rotation = mat4_rotation_y(yaw);
|
||||||
|
let scaling = mat4_scale(scale[0], scale[1], scale[2]);
|
||||||
|
let tr = mat4_mul(&translation, &rotation);
|
||||||
|
mat4_mul(&tr, &scaling)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn camera_forward(yaw: f32, pitch: f32) -> [f32; 3] {
|
||||||
|
let cp = pitch.cos();
|
||||||
|
normalize3([yaw.sin() * cp, pitch.sin(), yaw.cos() * cp])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||||
|
[
|
||||||
|
a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||||
|
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize3(v: [f32; 3]) -> [f32; 3] {
|
||||||
|
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||||
|
if len <= 1e-6 {
|
||||||
|
[0.0, 0.0, 0.0]
|
||||||
|
} else {
|
||||||
|
[v[0] / len, v[1] / len, v[2] / len]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_identity() -> [f32; 16] {
|
||||||
|
[
|
||||||
|
1.0, 0.0, 0.0, 0.0, //
|
||||||
|
0.0, 1.0, 0.0, 0.0, //
|
||||||
|
0.0, 0.0, 1.0, 0.0, //
|
||||||
|
0.0, 0.0, 0.0, 1.0, //
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
|
||||||
|
let mut m = mat4_identity();
|
||||||
|
m[12] = x;
|
||||||
|
m[13] = y;
|
||||||
|
m[14] = z;
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_scale(x: f32, y: f32, z: f32) -> [f32; 16] {
|
||||||
|
[
|
||||||
|
x, 0.0, 0.0, 0.0, //
|
||||||
|
0.0, y, 0.0, 0.0, //
|
||||||
|
0.0, 0.0, z, 0.0, //
|
||||||
|
0.0, 0.0, 0.0, 1.0, //
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
|
||||||
|
let c = rad.cos();
|
||||||
|
let s = rad.sin();
|
||||||
|
[
|
||||||
|
c, 0.0, -s, 0.0, //
|
||||||
|
0.0, 1.0, 0.0, 0.0, //
|
||||||
|
s, 0.0, c, 0.0, //
|
||||||
|
0.0, 0.0, 0.0, 1.0, //
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
|
||||||
|
let f = 1.0 / (0.5 * fovy).tan();
|
||||||
|
let nf = 1.0 / (near - far);
|
||||||
|
[
|
||||||
|
f / aspect,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
f,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
(far + near) * nf,
|
||||||
|
-1.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
(2.0 * far * near) * nf,
|
||||||
|
0.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [f32; 16] {
|
||||||
|
let f = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
|
||||||
|
let s = normalize3(cross3(f, up));
|
||||||
|
let u = cross3(s, f);
|
||||||
|
|
||||||
|
[
|
||||||
|
s[0],
|
||||||
|
u[0],
|
||||||
|
-f[0],
|
||||||
|
0.0,
|
||||||
|
s[1],
|
||||||
|
u[1],
|
||||||
|
-f[1],
|
||||||
|
0.0,
|
||||||
|
s[2],
|
||||||
|
u[2],
|
||||||
|
-f[2],
|
||||||
|
0.0,
|
||||||
|
-dot3(s, eye),
|
||||||
|
-dot3(u, eye),
|
||||||
|
dot3(f, eye),
|
||||||
|
1.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
|
||||||
|
let mut out = [0.0f32; 16];
|
||||||
|
for c in 0..4 {
|
||||||
|
for r in 0..4 {
|
||||||
|
let mut acc = 0.0f32;
|
||||||
|
for k in 0..4 {
|
||||||
|
acc += a[k * 4 + r] * b[c * 4 + k];
|
||||||
|
}
|
||||||
|
out[c * 4 + r] = acc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
|
||||||
|
for &value in slice {
|
||||||
|
out.extend_from_slice(&value.to_ne_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
|
||||||
|
for &value in slice {
|
||||||
|
out.extend_from_slice(&value.to_ne_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
9
crates/render-parity/Cargo.toml
Normal file
9
crates/render-parity/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "render-parity"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "1.0"
|
||||||
16
crates/render-parity/README.md
Normal file
16
crates/render-parity/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# render-parity
|
||||||
|
|
||||||
|
Deterministic frame-diff runner for `parkan-render-demo`.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p render-parity -- \
|
||||||
|
--manifest parity/cases.toml \
|
||||||
|
--output-dir target/render-parity/current
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
- `--demo-bin <path>`: use prebuilt `parkan-render-demo` binary instead of `cargo run`.
|
||||||
|
- `--keep-going`: continue all cases even after failures.
|
||||||
212
crates/render-parity/src/lib.rs
Normal file
212
crates/render-parity/src/lib.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
use image::{ImageBuffer, Rgba, RgbaImage};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
pub struct ManifestMeta {
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub lod: Option<usize>,
|
||||||
|
pub group: Option<usize>,
|
||||||
|
pub angle: Option<f32>,
|
||||||
|
pub diff_threshold: Option<u8>,
|
||||||
|
pub max_mean_abs: Option<f32>,
|
||||||
|
pub max_changed_ratio: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct CaseSpec {
|
||||||
|
pub id: String,
|
||||||
|
pub archive: String,
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub reference: String,
|
||||||
|
pub width: Option<u32>,
|
||||||
|
pub height: Option<u32>,
|
||||||
|
pub lod: Option<usize>,
|
||||||
|
pub group: Option<usize>,
|
||||||
|
pub angle: Option<f32>,
|
||||||
|
pub diff_threshold: Option<u8>,
|
||||||
|
pub max_mean_abs: Option<f32>,
|
||||||
|
pub max_changed_ratio: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ParityManifest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub meta: ManifestMeta,
|
||||||
|
#[serde(rename = "case", default)]
|
||||||
|
pub cases: Vec<CaseSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiffMetrics {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub mean_abs: f32,
|
||||||
|
pub max_abs: u8,
|
||||||
|
pub changed_pixels: u64,
|
||||||
|
pub changed_ratio: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compare_images(
|
||||||
|
reference: &RgbaImage,
|
||||||
|
actual: &RgbaImage,
|
||||||
|
diff_threshold: u8,
|
||||||
|
) -> Result<DiffMetrics, String> {
|
||||||
|
let (rw, rh) = reference.dimensions();
|
||||||
|
let (aw, ah) = actual.dimensions();
|
||||||
|
if rw != aw || rh != ah {
|
||||||
|
return Err(format!(
|
||||||
|
"image size mismatch: reference={}x{}, actual={}x{}",
|
||||||
|
rw, rh, aw, ah
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut diff_sum = 0u64;
|
||||||
|
let mut max_abs = 0u8;
|
||||||
|
let mut changed_pixels = 0u64;
|
||||||
|
let pixel_count = u64::from(rw).saturating_mul(u64::from(rh));
|
||||||
|
|
||||||
|
for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) {
|
||||||
|
let mut pixel_changed = false;
|
||||||
|
for chan in 0..3 {
|
||||||
|
let a = i16::from(ref_px[chan]);
|
||||||
|
let b = i16::from(act_px[chan]);
|
||||||
|
let diff = (a - b).unsigned_abs() as u8;
|
||||||
|
diff_sum = diff_sum.saturating_add(u64::from(diff));
|
||||||
|
if diff > max_abs {
|
||||||
|
max_abs = diff;
|
||||||
|
}
|
||||||
|
if diff > diff_threshold {
|
||||||
|
pixel_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pixel_changed {
|
||||||
|
changed_pixels = changed_pixels.saturating_add(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let channels = pixel_count.saturating_mul(3);
|
||||||
|
let mean_abs = if channels == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
diff_sum as f32 / channels as f32
|
||||||
|
};
|
||||||
|
let changed_ratio = if pixel_count == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
changed_pixels as f32 / pixel_count as f32
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(DiffMetrics {
|
||||||
|
width: rw,
|
||||||
|
height: rh,
|
||||||
|
mean_abs,
|
||||||
|
max_abs,
|
||||||
|
changed_pixels,
|
||||||
|
changed_ratio,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result<RgbaImage, String> {
|
||||||
|
let (rw, rh) = reference.dimensions();
|
||||||
|
let (aw, ah) = actual.dimensions();
|
||||||
|
if rw != aw || rh != ah {
|
||||||
|
return Err(format!(
|
||||||
|
"image size mismatch: reference={}x{}, actual={}x{}",
|
||||||
|
rw, rh, aw, ah
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(rw, rh);
|
||||||
|
for (dst, (ref_px, act_px)) in out
|
||||||
|
.pixels_mut()
|
||||||
|
.zip(reference.pixels().zip(actual.pixels()))
|
||||||
|
{
|
||||||
|
let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8;
|
||||||
|
let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8;
|
||||||
|
let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8;
|
||||||
|
*dst = Rgba([dr, dg, db, 255]);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate_metrics(
|
||||||
|
metrics: &DiffMetrics,
|
||||||
|
max_mean_abs: f32,
|
||||||
|
max_changed_ratio: f32,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut violations = Vec::new();
|
||||||
|
if metrics.mean_abs > max_mean_abs {
|
||||||
|
violations.push(format!(
|
||||||
|
"mean_abs {:.4} > allowed {:.4}",
|
||||||
|
metrics.mean_abs, max_mean_abs
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if metrics.changed_ratio > max_changed_ratio {
|
||||||
|
violations.push(format!(
|
||||||
|
"changed_ratio {:.4}% > allowed {:.4}%",
|
||||||
|
metrics.changed_ratio * 100.0,
|
||||||
|
max_changed_ratio * 100.0
|
||||||
|
));
|
||||||
|
}
|
||||||
|
violations
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage {
|
||||||
|
let mut img = RgbaImage::new(w, h);
|
||||||
|
for px in img.pixels_mut() {
|
||||||
|
*px = Rgba([r, g, b, 255]);
|
||||||
|
}
|
||||||
|
img
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compare_identical_images() {
|
||||||
|
let ref_img = solid(4, 3, 10, 20, 30);
|
||||||
|
let act_img = solid(4, 3, 10, 20, 30);
|
||||||
|
let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed");
|
||||||
|
assert_eq!(metrics.width, 4);
|
||||||
|
assert_eq!(metrics.height, 3);
|
||||||
|
assert_eq!(metrics.max_abs, 0);
|
||||||
|
assert_eq!(metrics.changed_pixels, 0);
|
||||||
|
assert_eq!(metrics.mean_abs, 0.0);
|
||||||
|
assert_eq!(metrics.changed_ratio, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compare_detects_changes_and_thresholds() {
|
||||||
|
let mut ref_img = solid(2, 2, 100, 100, 100);
|
||||||
|
let mut act_img = solid(2, 2, 100, 100, 100);
|
||||||
|
ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255]));
|
||||||
|
act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255]));
|
||||||
|
|
||||||
|
let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed");
|
||||||
|
assert_eq!(metrics.max_abs, 20);
|
||||||
|
assert_eq!(metrics.changed_pixels, 1);
|
||||||
|
assert!((metrics.changed_ratio - 0.25).abs() < 1e-6);
|
||||||
|
assert!(metrics.mean_abs > 0.0);
|
||||||
|
|
||||||
|
let violations = evaluate_metrics(&metrics, 2.0, 0.20);
|
||||||
|
assert_eq!(violations.len(), 1);
|
||||||
|
assert!(violations[0].contains("changed_ratio"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_diff_image_returns_per_channel_abs_diff() {
|
||||||
|
let mut ref_img = solid(1, 1, 100, 150, 200);
|
||||||
|
let mut act_img = solid(1, 1, 90, 180, 170);
|
||||||
|
ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255]));
|
||||||
|
act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255]));
|
||||||
|
|
||||||
|
let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build");
|
||||||
|
let px = diff.get_pixel(0, 0);
|
||||||
|
assert_eq!(px[0], 10);
|
||||||
|
assert_eq!(px[1], 30);
|
||||||
|
assert_eq!(px[2], 30);
|
||||||
|
assert_eq!(px[3], 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
405
crates/render-parity/src/main.rs
Normal file
405
crates/render-parity/src/main.rs
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
use image::RgbaImage;
|
||||||
|
use render_parity::{
|
||||||
|
build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest,
|
||||||
|
};
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
const DEFAULT_MANIFEST: &str = "parity/cases.toml";
|
||||||
|
const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current";
|
||||||
|
const DEFAULT_WIDTH: u32 = 1280;
|
||||||
|
const DEFAULT_HEIGHT: u32 = 720;
|
||||||
|
const DEFAULT_LOD: usize = 0;
|
||||||
|
const DEFAULT_GROUP: usize = 0;
|
||||||
|
const DEFAULT_ANGLE: f32 = 0.0;
|
||||||
|
const DEFAULT_DIFF_THRESHOLD: u8 = 8;
|
||||||
|
const DEFAULT_MAX_MEAN_ABS: f32 = 2.0;
|
||||||
|
const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01;
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
manifest: PathBuf,
|
||||||
|
output_dir: PathBuf,
|
||||||
|
demo_bin: Option<PathBuf>,
|
||||||
|
keep_going: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct EffectiveCase {
|
||||||
|
id: String,
|
||||||
|
archive: PathBuf,
|
||||||
|
model: Option<String>,
|
||||||
|
reference: PathBuf,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
lod: usize,
|
||||||
|
group: usize,
|
||||||
|
angle: f32,
|
||||||
|
diff_threshold: u8,
|
||||||
|
max_mean_abs: f32,
|
||||||
|
max_changed_ratio: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = match parse_args() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{err}");
|
||||||
|
print_help();
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = run(args) {
|
||||||
|
eprintln!("{err}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
let mut manifest = PathBuf::from(DEFAULT_MANIFEST);
|
||||||
|
let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR);
|
||||||
|
let mut demo_bin = None;
|
||||||
|
let mut keep_going = false;
|
||||||
|
|
||||||
|
let mut it = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = it.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--manifest" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --manifest"))?;
|
||||||
|
manifest = PathBuf::from(value);
|
||||||
|
}
|
||||||
|
"--output-dir" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --output-dir"))?;
|
||||||
|
output_dir = PathBuf::from(value);
|
||||||
|
}
|
||||||
|
"--demo-bin" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --demo-bin"))?;
|
||||||
|
demo_bin = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--keep-going" => {
|
||||||
|
keep_going = true;
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_help();
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(format!("unknown argument: {other}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Args {
|
||||||
|
manifest,
|
||||||
|
output_dir,
|
||||||
|
demo_bin,
|
||||||
|
keep_going,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_help() {
|
||||||
|
eprintln!(
|
||||||
|
"render-parity [--manifest <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--keep-going]"
|
||||||
|
);
|
||||||
|
eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})");
|
||||||
|
eprintln!(" --output-dir where current renders and diff images are written");
|
||||||
|
eprintln!(" --demo-bin prebuilt parkan-render-demo binary path");
|
||||||
|
eprintln!(" --keep-going continue all cases even after failures");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: Args) -> Result<(), String> {
|
||||||
|
let workspace = workspace_root()?;
|
||||||
|
let manifest_path = resolve_path(&workspace, &args.manifest);
|
||||||
|
let output_dir = resolve_path(&workspace, &args.output_dir);
|
||||||
|
let demo_bin = args
|
||||||
|
.demo_bin
|
||||||
|
.as_ref()
|
||||||
|
.map(|path| resolve_path(&workspace, path));
|
||||||
|
|
||||||
|
let manifest_raw = fs::read_to_string(&manifest_path)
|
||||||
|
.map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?;
|
||||||
|
let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"failed to parse manifest {}: {err}",
|
||||||
|
manifest_path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if manifest.cases.is_empty() {
|
||||||
|
println!(
|
||||||
|
"render-parity: no cases in {} (nothing to validate)",
|
||||||
|
manifest_path.display()
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::create_dir_all(&output_dir).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"failed to create output directory {}: {err}",
|
||||||
|
output_dir.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let manifest_dir = manifest_path
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| workspace.clone());
|
||||||
|
|
||||||
|
let mut failed_cases = 0usize;
|
||||||
|
for case in &manifest.cases {
|
||||||
|
let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?;
|
||||||
|
let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id)));
|
||||||
|
let diff_file = output_dir
|
||||||
|
.join("diff")
|
||||||
|
.join(format!("{}.png", sanitize_case_id(&effective.id)));
|
||||||
|
|
||||||
|
let run_res = run_single_case(
|
||||||
|
&workspace, // ensure `cargo run` executes from workspace root
|
||||||
|
demo_bin.as_deref(),
|
||||||
|
&effective,
|
||||||
|
&case_file,
|
||||||
|
&diff_file,
|
||||||
|
);
|
||||||
|
|
||||||
|
match run_res {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(err) => {
|
||||||
|
failed_cases = failed_cases.saturating_add(1);
|
||||||
|
eprintln!("[FAIL] {}: {}", effective.id, err);
|
||||||
|
if !args.keep_going {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed_cases > 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"render-parity failed: {} case(s) did not match reference frames",
|
||||||
|
failed_cases
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("render-parity: all cases passed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_single_case(
|
||||||
|
workspace: &Path,
|
||||||
|
demo_bin: Option<&Path>,
|
||||||
|
case: &EffectiveCase,
|
||||||
|
case_file: &Path,
|
||||||
|
diff_file: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
run_render_capture(workspace, demo_bin, case, case_file)?;
|
||||||
|
|
||||||
|
let reference = load_rgba(&case.reference)?;
|
||||||
|
let actual = load_rgba(case_file)?;
|
||||||
|
let metrics = compare_images(&reference, &actual, case.diff_threshold)?;
|
||||||
|
let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio);
|
||||||
|
|
||||||
|
if violations.is_empty() {
|
||||||
|
println!(
|
||||||
|
"[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})",
|
||||||
|
case.id,
|
||||||
|
metrics.mean_abs,
|
||||||
|
metrics.changed_ratio * 100.0,
|
||||||
|
metrics.max_abs,
|
||||||
|
metrics.width,
|
||||||
|
metrics.height
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(parent) = diff_file.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"failed to create diff output directory {}: {err}",
|
||||||
|
parent.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
let diff = build_diff_image(&reference, &actual)?;
|
||||||
|
diff.save(diff_file)
|
||||||
|
.map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?;
|
||||||
|
|
||||||
|
let mut details = String::new();
|
||||||
|
for item in violations {
|
||||||
|
if !details.is_empty() {
|
||||||
|
details.push_str("; ");
|
||||||
|
}
|
||||||
|
details.push_str(&item);
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}",
|
||||||
|
details,
|
||||||
|
diff_file.display(),
|
||||||
|
metrics.mean_abs,
|
||||||
|
metrics.changed_ratio * 100.0,
|
||||||
|
metrics.changed_pixels,
|
||||||
|
metrics.max_abs
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_render_capture(
|
||||||
|
workspace: &Path,
|
||||||
|
demo_bin: Option<&Path>,
|
||||||
|
case: &EffectiveCase,
|
||||||
|
out_path: &Path,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let Some(parent) = out_path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|err| {
|
||||||
|
format!(
|
||||||
|
"failed to create capture directory {}: {err}",
|
||||||
|
parent.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cmd = if let Some(bin) = demo_bin {
|
||||||
|
Command::new(bin)
|
||||||
|
} else {
|
||||||
|
let mut command = Command::new("cargo");
|
||||||
|
command.args(["run", "-p", "render-demo", "--features", "demo", "--"]);
|
||||||
|
command
|
||||||
|
};
|
||||||
|
|
||||||
|
cmd.current_dir(workspace)
|
||||||
|
.arg("--archive")
|
||||||
|
.arg(&case.archive)
|
||||||
|
.arg("--lod")
|
||||||
|
.arg(case.lod.to_string())
|
||||||
|
.arg("--group")
|
||||||
|
.arg(case.group.to_string())
|
||||||
|
.arg("--width")
|
||||||
|
.arg(case.width.to_string())
|
||||||
|
.arg("--height")
|
||||||
|
.arg(case.height.to_string())
|
||||||
|
.arg("--angle")
|
||||||
|
.arg(case.angle.to_string())
|
||||||
|
.arg("--capture")
|
||||||
|
.arg(out_path);
|
||||||
|
|
||||||
|
if let Some(model) = case.model.as_deref() {
|
||||||
|
cmd.arg("--model").arg(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = cmd.output().map_err(|err| {
|
||||||
|
let mode = if demo_bin.is_some() {
|
||||||
|
"parkan-render-demo"
|
||||||
|
} else {
|
||||||
|
"cargo run -p render-demo"
|
||||||
|
};
|
||||||
|
format!("failed to execute {} for case {}: {err}", mode, case.id)
|
||||||
|
})?;
|
||||||
|
if !output.status.success() {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(format!(
|
||||||
|
"render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}",
|
||||||
|
output.status.code(),
|
||||||
|
stdout,
|
||||||
|
stderr
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_rgba(path: &Path) -> Result<RgbaImage, String> {
|
||||||
|
image::open(path)
|
||||||
|
.map_err(|err| format!("failed to load image {}: {err}", path.display()))
|
||||||
|
.map(|img| img.to_rgba8())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_effective_case(
|
||||||
|
meta: &ManifestMeta,
|
||||||
|
case: &CaseSpec,
|
||||||
|
manifest_dir: &Path,
|
||||||
|
) -> Result<EffectiveCase, String> {
|
||||||
|
let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH);
|
||||||
|
let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT);
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"case '{}' has invalid dimensions {}x{}",
|
||||||
|
case.id, width, height
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let archive = resolve_path(manifest_dir, Path::new(&case.archive));
|
||||||
|
let reference = resolve_path(manifest_dir, Path::new(&case.reference));
|
||||||
|
if !archive.is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"case '{}' archive not found: {}",
|
||||||
|
case.id,
|
||||||
|
archive.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !reference.is_file() {
|
||||||
|
return Err(format!(
|
||||||
|
"case '{}' reference frame not found: {}",
|
||||||
|
case.id,
|
||||||
|
reference.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(EffectiveCase {
|
||||||
|
id: case.id.clone(),
|
||||||
|
archive,
|
||||||
|
model: case.model.clone(),
|
||||||
|
reference,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD),
|
||||||
|
group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP),
|
||||||
|
angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE),
|
||||||
|
diff_threshold: case
|
||||||
|
.diff_threshold
|
||||||
|
.or(meta.diff_threshold)
|
||||||
|
.unwrap_or(DEFAULT_DIFF_THRESHOLD),
|
||||||
|
max_mean_abs: case
|
||||||
|
.max_mean_abs
|
||||||
|
.or(meta.max_mean_abs)
|
||||||
|
.unwrap_or(DEFAULT_MAX_MEAN_ABS),
|
||||||
|
max_changed_ratio: case
|
||||||
|
.max_changed_ratio
|
||||||
|
.or(meta.max_changed_ratio)
|
||||||
|
.unwrap_or(DEFAULT_MAX_CHANGED_RATIO),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_case_id(id: &str) -> String {
|
||||||
|
id.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_root() -> Result<PathBuf, String> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|err| format!("failed to resolve workspace root: {err}"))?;
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path(base: &Path, path: &Path) -> PathBuf {
|
||||||
|
if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
base.join(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/rsli/Cargo.toml
Normal file
11
crates/rsli/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "rsli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = "1"
|
||||||
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)
|
||||||
|
}
|
||||||
303
crates/rsli/src/compress/lzh.rs
Normal file
303
crates/rsli/src/compress/lzh.rs
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
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()?);
|
||||||
|
let branch = node
|
||||||
|
.checked_add(bit)
|
||||||
|
.ok_or(Error::DecompressionFailed("lzss-huffman tree overflow"))?;
|
||||||
|
node = *self.son.get(branch).ok_or(Error::DecompressionFailed(
|
||||||
|
"lzss-huffman tree out of bounds",
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
470
crates/rsli/src/lib.rs
Normal file
470
crates/rsli/src/lib.rs
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
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(Clone, Debug)]
|
||||||
|
pub struct LibraryHeader {
|
||||||
|
pub raw: [u8; 32],
|
||||||
|
pub magic: [u8; 2],
|
||||||
|
pub reserved: u8,
|
||||||
|
pub version: u8,
|
||||||
|
pub entry_count: i16,
|
||||||
|
pub presorted_flag: u16,
|
||||||
|
pub xor_seed: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AoTrailer {
|
||||||
|
pub raw: [u8; 6],
|
||||||
|
pub overlay: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Library {
|
||||||
|
bytes: Arc<[u8]>,
|
||||||
|
entries: Vec<EntryRecord>,
|
||||||
|
header: LibraryHeader,
|
||||||
|
ao_trailer: Option<AoTrailer>,
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) table_plain_original: Vec<u8>,
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) source_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct EntryInspect<'a> {
|
||||||
|
pub id: EntryId,
|
||||||
|
pub meta: &'a EntryMeta,
|
||||||
|
pub name_raw: &'a [u8; 12],
|
||||||
|
pub service_tail: &'a [u8; 4],
|
||||||
|
pub sort_to_original: i16,
|
||||||
|
pub data_offset_raw: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) service_tail: [u8; 4],
|
||||||
|
pub(crate) sort_to_original: i16,
|
||||||
|
pub(crate) key16: u16,
|
||||||
|
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 header(&self) -> &LibraryHeader {
|
||||||
|
&self.header
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ao_trailer(&self) -> Option<&AoTrailer> {
|
||||||
|
self.ao_trailer.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry_count(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||||
|
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
Some(EntryRef {
|
||||||
|
id: EntryId(id),
|
||||||
|
meta: &entry.meta,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
|
||||||
|
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
Some(EntryInspect {
|
||||||
|
id: EntryId(id),
|
||||||
|
meta: &entry.meta,
|
||||||
|
name_raw: &entry.name_raw,
|
||||||
|
service_tail: &entry.service_tail,
|
||||||
|
sort_to_original: entry.sort_to_original,
|
||||||
|
data_offset_raw: entry.data_offset_raw,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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 => {
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
return Some(EntryId(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
Some(EntryId(id))
|
||||||
|
} 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 inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
|
||||||
|
let idx = usize::try_from(id.0).ok()?;
|
||||||
|
let entry = self.entries.get(idx)?;
|
||||||
|
Some(EntryInspect {
|
||||||
|
id,
|
||||||
|
meta: &entry.meta,
|
||||||
|
name_raw: &entry.name_raw,
|
||||||
|
service_tail: &entry.service_tail,
|
||||||
|
sort_to_original: entry.sort_to_original,
|
||||||
|
data_offset_raw: entry.data_offset_raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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: saturating_u32_len(self.entries.len()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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.ao_trailer.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.header.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 id = u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
let packed = self.load_packed(EntryId(id))?.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,
|
||||||
|
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.ao_trailer {
|
||||||
|
out.extend_from_slice(&trailer.raw);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn saturating_u32_len(len: usize) -> u32 {
|
||||||
|
u32::try_from(len).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
278
crates/rsli/src/parse.rs
Normal file
278
crates/rsli/src/parse.rs
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
use crate::compress::xor::xor_stream;
|
||||||
|
use crate::error::Error;
|
||||||
|
use crate::{
|
||||||
|
AoTrailer, EntryMeta, EntryRecord, Library, LibraryHeader, 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]);
|
||||||
|
|
||||||
|
let mut magic = [0u8; 2];
|
||||||
|
magic.copy_from_slice(&bytes[0..2]);
|
||||||
|
if &magic != b"NL" {
|
||||||
|
let mut got = [0u8; 2];
|
||||||
|
got.copy_from_slice(&bytes[0..2]);
|
||||||
|
return Err(Error::InvalidMagic { got });
|
||||||
|
}
|
||||||
|
let reserved = bytes[2];
|
||||||
|
let version = bytes[3];
|
||||||
|
if version != 0x01 {
|
||||||
|
return Err(Error::UnsupportedVersion { got: version });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
||||||
|
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||||
|
let header = LibraryHeader {
|
||||||
|
raw: header_raw,
|
||||||
|
magic,
|
||||||
|
reserved,
|
||||||
|
version,
|
||||||
|
entry_count,
|
||||||
|
presorted_flag,
|
||||||
|
xor_seed,
|
||||||
|
};
|
||||||
|
|
||||||
|
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)?;
|
||||||
|
|
||||||
|
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 mut service_tail = [0u8; 4];
|
||||||
|
service_tail.copy_from_slice(&row[12..16]);
|
||||||
|
|
||||||
|
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).map_err(|_| Error::IntegerOverflow)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(Error::PackedSizePastEof {
|
||||||
|
id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
|
||||||
|
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).map_err(|_| Error::IntegerOverflow)?,
|
||||||
|
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,
|
||||||
|
service_tail,
|
||||||
|
sort_to_original,
|
||||||
|
key16: sort_to_original as u16,
|
||||||
|
data_offset_raw,
|
||||||
|
packed_size_declared,
|
||||||
|
packed_size_available,
|
||||||
|
effective_offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
header,
|
||||||
|
ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }),
|
||||||
|
#[cfg(test)]
|
||||||
|
table_plain_original,
|
||||||
|
#[cfg(test)]
|
||||||
|
source_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
1338
crates/rsli/src/tests.rs
Normal file
1338
crates/rsli/src/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
10
crates/terrain-core/Cargo.toml
Normal file
10
crates/terrain-core/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "terrain-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
281
crates/terrain-core/src/lib.rs
Normal file
281
crates/terrain-core/src/lib.rs
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
use nres::Archive;
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub const TERRAIN_UV_SCALE: f32 = 1024.0;
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Nres(nres::error::Error),
|
||||||
|
MissingChunk(&'static str),
|
||||||
|
InvalidChunkSize {
|
||||||
|
label: &'static str,
|
||||||
|
size: usize,
|
||||||
|
stride: usize,
|
||||||
|
},
|
||||||
|
VertexCountOverflow {
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Nres(err) => write!(f, "{err}"),
|
||||||
|
Self::MissingChunk(label) => write!(f, "missing required terrain chunk: {label}"),
|
||||||
|
Self::InvalidChunkSize {
|
||||||
|
label,
|
||||||
|
size,
|
||||||
|
stride,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"invalid chunk size for {label}: {size} (must be divisible by {stride})"
|
||||||
|
),
|
||||||
|
Self::VertexCountOverflow { count } => {
|
||||||
|
write!(f, "terrain vertex count {count} exceeds u16 range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Nres(err) => Some(err),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nres::error::Error> for Error {
|
||||||
|
fn from(value: nres::error::Error) -> Self {
|
||||||
|
Self::Nres(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TerrainMesh {
|
||||||
|
pub positions: Vec<[f32; 3]>,
|
||||||
|
pub uv0: Vec<[f32; 2]>,
|
||||||
|
pub faces: Vec<TerrainFace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct TerrainFace {
|
||||||
|
pub indices: [u16; 3],
|
||||||
|
pub flags: u32,
|
||||||
|
pub material_tag: u16,
|
||||||
|
pub aux_tag: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TerrainRenderMesh {
|
||||||
|
pub vertices: Vec<TerrainRenderVertex>,
|
||||||
|
pub indices: Vec<u16>,
|
||||||
|
pub face_count_raw: usize,
|
||||||
|
pub face_count_kept: usize,
|
||||||
|
pub face_count_dropped_invalid: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct TerrainRenderVertex {
|
||||||
|
pub position: [f32; 3],
|
||||||
|
pub uv0: [f32; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_land_mesh(path: impl AsRef<Path>) -> Result<TerrainMesh> {
|
||||||
|
let archive = Archive::open_path(path.as_ref())?;
|
||||||
|
|
||||||
|
let positions_entry = archive
|
||||||
|
.entries()
|
||||||
|
.find(|entry| entry.meta.kind == 3)
|
||||||
|
.ok_or(Error::MissingChunk("type=3 (positions)"))?;
|
||||||
|
let uv_entry = archive.entries().find(|entry| entry.meta.kind == 5);
|
||||||
|
let faces_entry = archive
|
||||||
|
.entries()
|
||||||
|
.find(|entry| entry.meta.kind == 21)
|
||||||
|
.ok_or(Error::MissingChunk("type=21 (faces)"))?;
|
||||||
|
|
||||||
|
let positions_payload = archive.read(positions_entry.id)?.into_owned();
|
||||||
|
if positions_payload.len() % 12 != 0 {
|
||||||
|
return Err(Error::InvalidChunkSize {
|
||||||
|
label: "type=3 (positions)",
|
||||||
|
size: positions_payload.len(),
|
||||||
|
stride: 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut positions = Vec::with_capacity(positions_payload.len() / 12);
|
||||||
|
for chunk in positions_payload.chunks_exact(12) {
|
||||||
|
let x = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
|
||||||
|
let y = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4]));
|
||||||
|
let z = f32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4]));
|
||||||
|
positions.push([x, y, z]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut uv0 = vec![[0.0f32, 0.0f32]; positions.len()];
|
||||||
|
if let Some(uv_entry) = uv_entry {
|
||||||
|
let uv_payload = archive.read(uv_entry.id)?.into_owned();
|
||||||
|
if uv_payload.len() % 4 != 0 {
|
||||||
|
return Err(Error::InvalidChunkSize {
|
||||||
|
label: "type=5 (uv)",
|
||||||
|
size: uv_payload.len(),
|
||||||
|
stride: 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let uv_count = uv_payload.len() / 4;
|
||||||
|
for idx in 0..uv_count.min(uv0.len()) {
|
||||||
|
let off = idx * 4;
|
||||||
|
let u = i16::from_le_bytes([uv_payload[off], uv_payload[off + 1]]) as f32;
|
||||||
|
let v = i16::from_le_bytes([uv_payload[off + 2], uv_payload[off + 3]]) as f32;
|
||||||
|
uv0[idx] = [u / TERRAIN_UV_SCALE, v / TERRAIN_UV_SCALE];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let face_payload = archive.read(faces_entry.id)?.into_owned();
|
||||||
|
if face_payload.len() % 28 != 0 {
|
||||||
|
return Err(Error::InvalidChunkSize {
|
||||||
|
label: "type=21 (faces)",
|
||||||
|
size: face_payload.len(),
|
||||||
|
stride: 28,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut faces = Vec::with_capacity(face_payload.len() / 28);
|
||||||
|
for chunk in face_payload.chunks_exact(28) {
|
||||||
|
let flags = u32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
|
||||||
|
let material_tag = u16::from_le_bytes(chunk[4..6].try_into().unwrap_or([0; 2]));
|
||||||
|
let aux_tag = u16::from_le_bytes(chunk[6..8].try_into().unwrap_or([0; 2]));
|
||||||
|
let i0 = u16::from_le_bytes(chunk[8..10].try_into().unwrap_or([0; 2]));
|
||||||
|
let i1 = u16::from_le_bytes(chunk[10..12].try_into().unwrap_or([0; 2]));
|
||||||
|
let i2 = u16::from_le_bytes(chunk[12..14].try_into().unwrap_or([0; 2]));
|
||||||
|
if usize::from(i0) >= positions.len()
|
||||||
|
|| usize::from(i1) >= positions.len()
|
||||||
|
|| usize::from(i2) >= positions.len()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
faces.push(TerrainFace {
|
||||||
|
indices: [i0, i1, i2],
|
||||||
|
flags,
|
||||||
|
material_tag,
|
||||||
|
aux_tag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TerrainMesh {
|
||||||
|
positions,
|
||||||
|
uv0,
|
||||||
|
faces,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_render_mesh(mesh: &TerrainMesh) -> Result<TerrainRenderMesh> {
|
||||||
|
if mesh.positions.len() > usize::from(u16::MAX) + 1 {
|
||||||
|
return Err(Error::VertexCountOverflow {
|
||||||
|
count: mesh.positions.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let vertices = mesh
|
||||||
|
.positions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, &position)| TerrainRenderVertex {
|
||||||
|
position,
|
||||||
|
uv0: mesh.uv0.get(idx).copied().unwrap_or([0.0, 0.0]),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut indices = Vec::with_capacity(mesh.faces.len() * 3);
|
||||||
|
for face in &mesh.faces {
|
||||||
|
indices.extend_from_slice(&face.indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TerrainRenderMesh {
|
||||||
|
vertices,
|
||||||
|
indices,
|
||||||
|
face_count_raw: mesh.faces.len(),
|
||||||
|
face_count_kept: mesh.faces.len(),
|
||||||
|
face_count_dropped_invalid: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("Parkan - Iron Strategy");
|
||||||
|
root.is_dir().then_some(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_known_land_mesh() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let land = root
|
||||||
|
.join("DATA")
|
||||||
|
.join("MAPS")
|
||||||
|
.join("Tut_1")
|
||||||
|
.join("Land.msh");
|
||||||
|
if !land.is_file() {
|
||||||
|
eprintln!("skipping missing sample {}", land.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mesh = load_land_mesh(&land)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", land.display()));
|
||||||
|
assert!(mesh.positions.len() > 1000);
|
||||||
|
assert!(mesh.faces.len() > 1000);
|
||||||
|
|
||||||
|
let render = build_render_mesh(&mesh).expect("failed to build render mesh");
|
||||||
|
assert_eq!(render.vertices.len(), mesh.positions.len());
|
||||||
|
assert_eq!(render.indices.len(), mesh.faces.len() * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_all_retail_land_meshes() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let maps_root = root.join("DATA").join("MAPS");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&maps_root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut parsed = 0usize;
|
||||||
|
for path in files {
|
||||||
|
if !path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.eq_ignore_ascii_case("Land.msh"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mesh = load_land_mesh(&path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||||
|
assert!(
|
||||||
|
!mesh.positions.is_empty() && !mesh.faces.is_empty(),
|
||||||
|
"{} parsed but empty",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
parsed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(parsed > 0, "no Land.msh files parsed");
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/texm/Cargo.toml
Normal file
9
crates/texm/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "texm"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
proptest = "1"
|
||||||
15
crates/texm/README.md
Normal file
15
crates/texm/README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# texm
|
||||||
|
|
||||||
|
Парсер формата текстур `Texm`.
|
||||||
|
|
||||||
|
Покрывает:
|
||||||
|
|
||||||
|
- header (`width/height/mipCount/flags/format`);
|
||||||
|
- core size расчёт;
|
||||||
|
- optional `Page` chunk;
|
||||||
|
- строгую валидацию layout.
|
||||||
|
|
||||||
|
Тесты:
|
||||||
|
|
||||||
|
- прогон по реальным `Texm` из `testdata`;
|
||||||
|
- синтетические edge-cases (indexed + page, minimal rgba).
|
||||||
86
crates/texm/src/error.rs
Normal file
86
crates/texm/src/error.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub enum Error {
|
||||||
|
HeaderTooSmall {
|
||||||
|
size: usize,
|
||||||
|
},
|
||||||
|
InvalidMagic {
|
||||||
|
got: u32,
|
||||||
|
},
|
||||||
|
InvalidDimensions {
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
},
|
||||||
|
InvalidMipCount {
|
||||||
|
mip_count: u32,
|
||||||
|
},
|
||||||
|
UnknownFormat {
|
||||||
|
format: u32,
|
||||||
|
},
|
||||||
|
IntegerOverflow,
|
||||||
|
CoreDataOutOfBounds {
|
||||||
|
expected_end: usize,
|
||||||
|
actual_size: usize,
|
||||||
|
},
|
||||||
|
MipIndexOutOfRange {
|
||||||
|
requested: usize,
|
||||||
|
mip_count: usize,
|
||||||
|
},
|
||||||
|
MipDataOutOfBounds {
|
||||||
|
offset: usize,
|
||||||
|
size: usize,
|
||||||
|
payload_size: usize,
|
||||||
|
},
|
||||||
|
InvalidPageMagic,
|
||||||
|
InvalidPageSize {
|
||||||
|
expected: usize,
|
||||||
|
actual: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::HeaderTooSmall { size } => {
|
||||||
|
write!(f, "Texm payload too small for header: {size}")
|
||||||
|
}
|
||||||
|
Self::InvalidMagic { got } => write!(f, "invalid Texm magic: 0x{got:08X}"),
|
||||||
|
Self::InvalidDimensions { width, height } => {
|
||||||
|
write!(f, "invalid Texm dimensions: {width}x{height}")
|
||||||
|
}
|
||||||
|
Self::InvalidMipCount { mip_count } => write!(f, "invalid Texm mip_count={mip_count}"),
|
||||||
|
Self::UnknownFormat { format } => write!(f, "unknown Texm format={format}"),
|
||||||
|
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||||
|
Self::CoreDataOutOfBounds {
|
||||||
|
expected_end,
|
||||||
|
actual_size,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}"
|
||||||
|
),
|
||||||
|
Self::MipIndexOutOfRange {
|
||||||
|
requested,
|
||||||
|
mip_count,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"Texm mip index out of range: requested={requested}, mip_count={mip_count}"
|
||||||
|
),
|
||||||
|
Self::MipDataOutOfBounds {
|
||||||
|
offset,
|
||||||
|
size,
|
||||||
|
payload_size,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"Texm mip data out of bounds: offset={offset}, size={size}, payload_size={payload_size}"
|
||||||
|
),
|
||||||
|
Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"),
|
||||||
|
Self::InvalidPageSize { expected, actual } => {
|
||||||
|
write!(f, "invalid Page chunk size: expected={expected}, actual={actual}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {}
|
||||||
417
crates/texm/src/lib.rs
Normal file
417
crates/texm/src/lib.rs
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
pub mod error;
|
||||||
|
|
||||||
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
pub const TEXM_MAGIC: u32 = 0x6D78_6554;
|
||||||
|
pub const PAGE_MAGIC: u32 = 0x6567_6150;
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum PixelFormat {
|
||||||
|
Indexed8,
|
||||||
|
Rgb565,
|
||||||
|
Rgb556,
|
||||||
|
Argb4444,
|
||||||
|
LuminanceAlpha88,
|
||||||
|
Rgb888,
|
||||||
|
Argb8888,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PixelFormat {
|
||||||
|
pub fn from_raw(raw: u32) -> Option<Self> {
|
||||||
|
match raw {
|
||||||
|
0 => Some(Self::Indexed8),
|
||||||
|
565 => Some(Self::Rgb565),
|
||||||
|
556 => Some(Self::Rgb556),
|
||||||
|
4444 => Some(Self::Argb4444),
|
||||||
|
88 => Some(Self::LuminanceAlpha88),
|
||||||
|
888 => Some(Self::Rgb888),
|
||||||
|
8888 => Some(Self::Argb8888),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bytes_per_pixel(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Indexed8 => 1,
|
||||||
|
Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2,
|
||||||
|
// Parkan stores format 888 as 32-bit RGBX in texture payloads.
|
||||||
|
Self::Rgb888 | Self::Argb8888 => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Header {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub mip_count: u32,
|
||||||
|
pub flags4: u32,
|
||||||
|
pub flags5: u32,
|
||||||
|
pub unk6: u32,
|
||||||
|
pub format_raw: u32,
|
||||||
|
pub format: PixelFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct MipLevel {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub offset: usize,
|
||||||
|
pub size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PageRect {
|
||||||
|
pub x: i16,
|
||||||
|
pub w: i16,
|
||||||
|
pub y: i16,
|
||||||
|
pub h: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Texture {
|
||||||
|
pub header: Header,
|
||||||
|
pub palette: Option<[u8; 1024]>,
|
||||||
|
pub mip_levels: Vec<MipLevel>,
|
||||||
|
pub page_rects: Vec<PageRect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Texture {
|
||||||
|
pub fn core_size(&self) -> usize {
|
||||||
|
let mut size = 32usize;
|
||||||
|
if self.palette.is_some() {
|
||||||
|
size += 1024;
|
||||||
|
}
|
||||||
|
for level in &self.mip_levels {
|
||||||
|
size += level.size;
|
||||||
|
}
|
||||||
|
size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DecodedMip {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub rgba8: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
|
||||||
|
if payload.len() < 32 {
|
||||||
|
return Err(Error::HeaderTooSmall {
|
||||||
|
size: payload.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let magic = read_u32(payload, 0)?;
|
||||||
|
if magic != TEXM_MAGIC {
|
||||||
|
return Err(Error::InvalidMagic { got: magic });
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = read_u32(payload, 4)?;
|
||||||
|
let height = read_u32(payload, 8)?;
|
||||||
|
let mip_count = read_u32(payload, 12)?;
|
||||||
|
let flags4 = read_u32(payload, 16)?;
|
||||||
|
let flags5 = read_u32(payload, 20)?;
|
||||||
|
let unk6 = read_u32(payload, 24)?;
|
||||||
|
let format_raw = read_u32(payload, 28)?;
|
||||||
|
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
return Err(Error::InvalidDimensions { width, height });
|
||||||
|
}
|
||||||
|
if mip_count == 0 {
|
||||||
|
return Err(Error::InvalidMipCount { mip_count });
|
||||||
|
}
|
||||||
|
|
||||||
|
let format =
|
||||||
|
PixelFormat::from_raw(format_raw).ok_or(Error::UnknownFormat { format: format_raw })?;
|
||||||
|
let bytes_per_pixel = format.bytes_per_pixel();
|
||||||
|
|
||||||
|
let mut offset = 32usize;
|
||||||
|
let palette = if format == PixelFormat::Indexed8 {
|
||||||
|
let end = offset.checked_add(1024).ok_or(Error::IntegerOverflow)?;
|
||||||
|
if end > payload.len() {
|
||||||
|
return Err(Error::CoreDataOutOfBounds {
|
||||||
|
expected_end: end,
|
||||||
|
actual_size: payload.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut pal = [0u8; 1024];
|
||||||
|
pal.copy_from_slice(&payload[offset..end]);
|
||||||
|
offset = end;
|
||||||
|
Some(pal)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut mip_levels =
|
||||||
|
Vec::with_capacity(usize::try_from(mip_count).map_err(|_| Error::IntegerOverflow)?);
|
||||||
|
let mut w = width;
|
||||||
|
let mut h = height;
|
||||||
|
for _ in 0..mip_count {
|
||||||
|
let pixel_count_u64 = u64::from(w)
|
||||||
|
.checked_mul(u64::from(h))
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
let level_size_u64 = pixel_count_u64
|
||||||
|
.checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| Error::IntegerOverflow)?)
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
let level_size = usize::try_from(level_size_u64).map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
let level_offset = offset;
|
||||||
|
offset = offset
|
||||||
|
.checked_add(level_size)
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
if offset > payload.len() {
|
||||||
|
return Err(Error::CoreDataOutOfBounds {
|
||||||
|
expected_end: offset,
|
||||||
|
actual_size: payload.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mip_levels.push(MipLevel {
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
offset: level_offset,
|
||||||
|
size: level_size,
|
||||||
|
});
|
||||||
|
w = (w >> 1).max(1);
|
||||||
|
h = (h >> 1).max(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let page_rects = parse_page_tail(payload, offset)?;
|
||||||
|
|
||||||
|
Ok(Texture {
|
||||||
|
header: Header {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
mip_count,
|
||||||
|
flags4,
|
||||||
|
flags5,
|
||||||
|
unk6,
|
||||||
|
format_raw,
|
||||||
|
format,
|
||||||
|
},
|
||||||
|
palette,
|
||||||
|
mip_levels,
|
||||||
|
page_rects,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) -> Result<DecodedMip> {
|
||||||
|
let Some(level) = texture.mip_levels.get(mip_index).copied() else {
|
||||||
|
return Err(Error::MipIndexOutOfRange {
|
||||||
|
requested: mip_index,
|
||||||
|
mip_count: texture.mip_levels.len(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let end = level
|
||||||
|
.offset
|
||||||
|
.checked_add(level.size)
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
let Some(level_data) = payload.get(level.offset..end) else {
|
||||||
|
return Err(Error::MipDataOutOfBounds {
|
||||||
|
offset: level.offset,
|
||||||
|
size: level.size,
|
||||||
|
payload_size: payload.len(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let pixel_count = usize::try_from(level.width)
|
||||||
|
.ok()
|
||||||
|
.and_then(|w| {
|
||||||
|
usize::try_from(level.height)
|
||||||
|
.ok()
|
||||||
|
.map(|h| w.saturating_mul(h))
|
||||||
|
})
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
let mut rgba = vec![0u8; pixel_count.saturating_mul(4)];
|
||||||
|
|
||||||
|
match texture.header.format {
|
||||||
|
PixelFormat::Indexed8 => {
|
||||||
|
let palette = texture.palette.as_ref().ok_or(Error::IntegerOverflow)?;
|
||||||
|
for (i, &index) in level_data.iter().enumerate() {
|
||||||
|
if i >= pixel_count {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let poff = usize::from(index).saturating_mul(4);
|
||||||
|
// Keep this form to accept the last palette item (index 255).
|
||||||
|
if poff + 4 > palette.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let out = i.saturating_mul(4);
|
||||||
|
rgba[out] = palette[poff];
|
||||||
|
rgba[out + 1] = palette[poff + 1];
|
||||||
|
rgba[out + 2] = palette[poff + 2];
|
||||||
|
rgba[out + 3] = palette[poff + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PixelFormat::Rgb565 => {
|
||||||
|
decode_words(level_data, pixel_count, &mut rgba, decode_rgb565);
|
||||||
|
}
|
||||||
|
PixelFormat::Rgb556 => {
|
||||||
|
decode_words(level_data, pixel_count, &mut rgba, decode_rgb556);
|
||||||
|
}
|
||||||
|
PixelFormat::Argb4444 => {
|
||||||
|
decode_words(level_data, pixel_count, &mut rgba, decode_argb4444);
|
||||||
|
}
|
||||||
|
PixelFormat::LuminanceAlpha88 => {
|
||||||
|
decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88);
|
||||||
|
}
|
||||||
|
PixelFormat::Rgb888 => {
|
||||||
|
decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x);
|
||||||
|
}
|
||||||
|
PixelFormat::Argb8888 => {
|
||||||
|
decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(DecodedMip {
|
||||||
|
width: level.width,
|
||||||
|
height: level.height,
|
||||||
|
rgba8: rgba,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_page_tail(payload: &[u8], core_end: usize) -> Result<Vec<PageRect>> {
|
||||||
|
if core_end == payload.len() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
if payload.len().saturating_sub(core_end) < 8 {
|
||||||
|
return Err(Error::InvalidPageSize {
|
||||||
|
expected: 8,
|
||||||
|
actual: payload.len().saturating_sub(core_end),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let magic = read_u32(payload, core_end)?;
|
||||||
|
if magic != PAGE_MAGIC {
|
||||||
|
return Err(Error::InvalidPageMagic);
|
||||||
|
}
|
||||||
|
let rect_count = read_u32(payload, core_end + 4)?;
|
||||||
|
let rect_count_usize = usize::try_from(rect_count).map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
let expected_size = 8usize
|
||||||
|
.checked_add(
|
||||||
|
rect_count_usize
|
||||||
|
.checked_mul(8)
|
||||||
|
.ok_or(Error::IntegerOverflow)?,
|
||||||
|
)
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
let actual = payload.len().saturating_sub(core_end);
|
||||||
|
if expected_size != actual {
|
||||||
|
return Err(Error::InvalidPageSize {
|
||||||
|
expected: expected_size,
|
||||||
|
actual,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rects = Vec::with_capacity(rect_count_usize);
|
||||||
|
for i in 0..rect_count_usize {
|
||||||
|
let off = core_end
|
||||||
|
.checked_add(8)
|
||||||
|
.and_then(|v| v.checked_add(i * 8))
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
rects.push(PageRect {
|
||||||
|
x: read_i16(payload, off)?,
|
||||||
|
w: read_i16(payload, off + 2)?,
|
||||||
|
y: read_i16(payload, off + 4)?,
|
||||||
|
h: read_i16(payload, off + 6)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(rects)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
|
||||||
|
let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
|
||||||
|
let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
Ok(u32::from_le_bytes(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
|
||||||
|
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
|
||||||
|
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
Ok(i16::from_le_bytes(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) {
|
||||||
|
for i in 0..pixel_count {
|
||||||
|
let off = i.saturating_mul(2);
|
||||||
|
let Some(bytes) = data.get(off..off + 2) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let word = u16::from_le_bytes([bytes[0], bytes[1]]);
|
||||||
|
let px = decode(word);
|
||||||
|
let out = i.saturating_mul(4);
|
||||||
|
rgba[out..out + 4].copy_from_slice(&px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) {
|
||||||
|
for i in 0..pixel_count {
|
||||||
|
let off = i.saturating_mul(4);
|
||||||
|
let Some(bytes) = data.get(off..off + 4) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
|
||||||
|
let px = decode(dword);
|
||||||
|
let out = i.saturating_mul(4);
|
||||||
|
rgba[out..out + 4].copy_from_slice(&px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand5(v: u16) -> u8 {
|
||||||
|
((u32::from(v) * 255 + 15) / 31) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand6(v: u16) -> u8 {
|
||||||
|
((u32::from(v) * 255 + 31) / 63) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand4(v: u16) -> u8 {
|
||||||
|
(u32::from(v) * 17) as u8
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_rgb565(word: u16) -> [u8; 4] {
|
||||||
|
let r = expand5((word >> 11) & 0x1F);
|
||||||
|
let g = expand6((word >> 5) & 0x3F);
|
||||||
|
let b = expand5(word & 0x1F);
|
||||||
|
[r, g, b, 255]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_rgb556(word: u16) -> [u8; 4] {
|
||||||
|
let r = expand5((word >> 11) & 0x1F);
|
||||||
|
let g = expand5((word >> 6) & 0x1F);
|
||||||
|
let b = expand6(word & 0x3F);
|
||||||
|
[r, g, b, 255]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_argb4444(word: u16) -> [u8; 4] {
|
||||||
|
let a = expand4((word >> 12) & 0x0F);
|
||||||
|
let r = expand4((word >> 8) & 0x0F);
|
||||||
|
let g = expand4((word >> 4) & 0x0F);
|
||||||
|
let b = expand4(word & 0x0F);
|
||||||
|
[r, g, b, a]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_luminance_alpha88(word: u16) -> [u8; 4] {
|
||||||
|
let l = ((word >> 8) & 0xFF) as u8;
|
||||||
|
let a = (word & 0xFF) as u8;
|
||||||
|
[l, l, l, a]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_rgb888x(dword: u32) -> [u8; 4] {
|
||||||
|
let r = (dword & 0xFF) as u8;
|
||||||
|
let g = ((dword >> 8) & 0xFF) as u8;
|
||||||
|
let b = ((dword >> 16) & 0xFF) as u8;
|
||||||
|
[r, g, b, 255]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_argb8888(dword: u32) -> [u8; 4] {
|
||||||
|
let a = (dword & 0xFF) as u8;
|
||||||
|
let r = ((dword >> 8) & 0xFF) as u8;
|
||||||
|
let g = ((dword >> 16) & 0xFF) as u8;
|
||||||
|
let b = ((dword >> 24) & 0xFF) as u8;
|
||||||
|
[r, g, b, a]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
330
crates/texm/src/tests.rs
Normal file
330
crates/texm/src/tests.rs
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use nres::Archive;
|
||||||
|
use proptest::prelude::*;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn nres_test_files() -> Vec<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
files
|
||||||
|
.into_iter()
|
||||||
|
.filter(|path| {
|
||||||
|
fs::read(path)
|
||||||
|
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_texm_payload(
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
format_raw: u32,
|
||||||
|
flags5: u32,
|
||||||
|
palette: Option<[u8; 1024]>,
|
||||||
|
mip_levels: &[&[u8]],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&width.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&height.to_le_bytes());
|
||||||
|
payload.extend_from_slice(
|
||||||
|
&u32::try_from(mip_levels.len())
|
||||||
|
.expect("mip level count overflow in test")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
|
||||||
|
payload.extend_from_slice(&flags5.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
|
||||||
|
payload.extend_from_slice(&format_raw.to_le_bytes());
|
||||||
|
if let Some(palette) = palette {
|
||||||
|
payload.extend_from_slice(&palette);
|
||||||
|
}
|
||||||
|
for level in mip_levels {
|
||||||
|
payload.extend_from_slice(level);
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_parse_all_game_textures() {
|
||||||
|
let archives = nres_test_files();
|
||||||
|
if archives.is_empty() {
|
||||||
|
eprintln!("skipping texm_parse_all_game_textures: no NRes files in testdata");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut texm_total = 0usize;
|
||||||
|
let mut texm_with_page = 0usize;
|
||||||
|
for archive_path in archives {
|
||||||
|
let archive = Archive::open_path(&archive_path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
|
||||||
|
|
||||||
|
for entry in archive.entries() {
|
||||||
|
if entry.meta.kind != TEXM_MAGIC {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
texm_total += 1;
|
||||||
|
let payload = archive.read(entry.id).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"failed to read Texm entry '{}' in {}: {err}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let texture = parse_texm(payload.as_slice()).unwrap_or_else(|err| {
|
||||||
|
panic!(
|
||||||
|
"failed to parse Texm '{}' in {}: {err}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if !texture.page_rects.is_empty() {
|
||||||
|
texm_with_page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
texture.core_size() <= payload.as_slice().len(),
|
||||||
|
"core size must be within payload for '{}' in {}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
usize::try_from(texture.header.mip_count).ok(),
|
||||||
|
Some(texture.mip_levels.len()),
|
||||||
|
"mip count mismatch for '{}' in {}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(texm_total > 0, "no Texm textures found");
|
||||||
|
assert!(
|
||||||
|
texm_with_page > 0,
|
||||||
|
"expected at least one Texm texture with Page chunk"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_parse_minimal_argb8888_no_page() {
|
||||||
|
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
|
||||||
|
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
|
||||||
|
assert_eq!(parsed.header.width, 1);
|
||||||
|
assert_eq!(parsed.header.height, 1);
|
||||||
|
assert_eq!(parsed.mip_levels.len(), 1);
|
||||||
|
assert!(parsed.page_rects.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_minimal_argb8888_no_page() {
|
||||||
|
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[0x40, 0x11, 0x22, 0x33]]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip");
|
||||||
|
assert_eq!(decoded.width, 1);
|
||||||
|
assert_eq!(decoded.height, 1);
|
||||||
|
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_rgb565() {
|
||||||
|
let word = 0xFFE0u16; // r=31 g=63 b=0
|
||||||
|
let payload = build_texm_payload(1, 1, 565, 0, None, &[&word.to_le_bytes()]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse rgb565 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb565 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![255, 255, 0, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_rgb556() {
|
||||||
|
let word = 0xF800u16; // r=31 g=0 b=0
|
||||||
|
let payload = build_texm_payload(1, 1, 556, 0, None, &[&word.to_le_bytes()]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse rgb556 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb556 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![255, 0, 0, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_argb4444() {
|
||||||
|
let word = 0xF12Eu16; // a=F r=1 g=2 b=E
|
||||||
|
let payload = build_texm_payload(1, 1, 4444, 0, None, &[&word.to_le_bytes()]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse argb4444 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode argb4444 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![17, 34, 238, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_luminance_alpha88() {
|
||||||
|
let word = 0x7F40u16; // luminance=0x7F alpha=0x40
|
||||||
|
let payload = build_texm_payload(1, 1, 88, 0, None, &[&word.to_le_bytes()]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse la88 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode la88 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![0x7F, 0x7F, 0x7F, 0x40]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_rgb888x() {
|
||||||
|
let payload = build_texm_payload(1, 1, 888, 0, None, &[&[0x11, 0x22, 0x33, 0x99]]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse rgb888 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb888 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_parse_indexed_with_page_chunk() {
|
||||||
|
let mut palette = [0u8; 1024];
|
||||||
|
palette[4..8].copy_from_slice(&[10, 20, 30, 255]);
|
||||||
|
let mut payload = build_texm_payload(2, 2, 0, 0, Some(palette), &[&[1, 1, 1, 1]]);
|
||||||
|
payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count
|
||||||
|
payload.extend_from_slice(&0i16.to_le_bytes()); // x
|
||||||
|
payload.extend_from_slice(&2i16.to_le_bytes()); // w
|
||||||
|
payload.extend_from_slice(&0i16.to_le_bytes()); // y
|
||||||
|
payload.extend_from_slice(&2i16.to_le_bytes()); // h
|
||||||
|
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
|
||||||
|
assert!(parsed.palette.is_some());
|
||||||
|
assert_eq!(parsed.page_rects.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.page_rects[0],
|
||||||
|
PageRect {
|
||||||
|
x: 0,
|
||||||
|
w: 2,
|
||||||
|
y: 0,
|
||||||
|
h: 2
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_indexed_with_palette_last_entry() {
|
||||||
|
let mut palette = [0u8; 1024];
|
||||||
|
palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1
|
||||||
|
palette[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2
|
||||||
|
palette[1020..1024].copy_from_slice(&[1, 2, 3, 4]); // index 255 (last)
|
||||||
|
let payload = build_texm_payload(3, 1, 0, 0, Some(palette), &[&[1u8, 2u8, 255u8]]);
|
||||||
|
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm");
|
||||||
|
assert_eq!(decoded.width, 3);
|
||||||
|
assert_eq!(decoded.height, 1);
|
||||||
|
assert_eq!(
|
||||||
|
decoded.rgba8,
|
||||||
|
vec![10, 20, 30, 255, 40, 50, 60, 200, 1, 2, 3, 4]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_parse_multi_mip_offsets() {
|
||||||
|
let mip0 = [0x10u8; 32]; // 4*2*4
|
||||||
|
let mip1 = [0x20u8; 8]; // 2*1*4
|
||||||
|
let mip2 = [0x30u8; 4]; // 1*1*4
|
||||||
|
let payload = build_texm_payload(4, 2, 8888, 0, None, &[&mip0, &mip1, &mip2]);
|
||||||
|
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse multi-mip texm");
|
||||||
|
assert_eq!(parsed.header.mip_count, 3);
|
||||||
|
assert_eq!(parsed.mip_levels.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.mip_levels,
|
||||||
|
vec![
|
||||||
|
MipLevel {
|
||||||
|
width: 4,
|
||||||
|
height: 2,
|
||||||
|
offset: 32,
|
||||||
|
size: 32
|
||||||
|
},
|
||||||
|
MipLevel {
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
offset: 64,
|
||||||
|
size: 8
|
||||||
|
},
|
||||||
|
MipLevel {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
offset: 72,
|
||||||
|
size: 4
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_preserves_flags5_for_mip_skip_metadata() {
|
||||||
|
let payload = build_texm_payload(1, 1, 8888, 0x0000_00A5, None, &[&[0, 0, 0, 0]]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse texm");
|
||||||
|
assert_eq!(parsed.header.flags5, 0x0000_00A5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_errors_for_invalid_header_values() {
|
||||||
|
let mut bad_magic = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
|
||||||
|
bad_magic[0..4].copy_from_slice(&0u32.to_le_bytes());
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&bad_magic),
|
||||||
|
Err(Error::InvalidMagic { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let zero_dims = build_texm_payload(0, 1, 8888, 0, None, &[&[]]);
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&zero_dims),
|
||||||
|
Err(Error::InvalidDimensions { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut bad_mips = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
|
||||||
|
bad_mips[12..16].copy_from_slice(&0u32.to_le_bytes());
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&bad_mips),
|
||||||
|
Err(Error::InvalidMipCount { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let bad_format = build_texm_payload(1, 1, 12345, 0, None, &[&[0, 0, 0, 0]]);
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&bad_format),
|
||||||
|
Err(Error::UnknownFormat { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_errors_for_page_chunk_and_mip_bounds() {
|
||||||
|
let mut bad_page = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
|
||||||
|
bad_page.extend_from_slice(b"X");
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&bad_page),
|
||||||
|
Err(Error::InvalidPageSize { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse valid texm");
|
||||||
|
assert!(matches!(
|
||||||
|
decode_mip_rgba8(&parsed, &payload, 7),
|
||||||
|
Err(Error::MipIndexOutOfRange { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let truncated = &payload[..payload.len() - 1];
|
||||||
|
assert!(matches!(
|
||||||
|
decode_mip_rgba8(&parsed, truncated, 0),
|
||||||
|
Err(Error::MipDataOutOfBounds { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_texm_is_panic_free_on_random_bytes(payload in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||||
|
if let Ok(texture) = parse_texm(&payload) {
|
||||||
|
for mip_index in 0..texture.mip_levels.len() {
|
||||||
|
let _ = decode_mip_rgba8(&texture, &payload, mip_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/tma/Cargo.toml
Normal file
10
crates/tma/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "tma"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
485
crates/tma/src/lib.rs
Normal file
485
crates/tma/src/lib.rs
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const OBJECT_RECORD_FLAGS: u32 = 0x8000_0002;
|
||||||
|
const FOOTER_MAGIC: &[u8; 4] = b"MtPr";
|
||||||
|
const MAP_PATH_TOKEN: &[u8; 10] = b"DATA\\MAPS\\";
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Io(std::io::Error),
|
||||||
|
FooterNotFound,
|
||||||
|
FooterCorrupt(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
Self::FooterNotFound => write!(f, "footer magic 'MtPr' not found"),
|
||||||
|
Self::FooterCorrupt(reason) => write!(f, "corrupt mission footer: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => Some(err),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(value: std::io::Error) -> Self {
|
||||||
|
Self::Io(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MissionFile {
|
||||||
|
pub footer: MissionFooter,
|
||||||
|
pub objects: Vec<MissionObject>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MissionFooter {
|
||||||
|
pub map_path: String,
|
||||||
|
pub title: String,
|
||||||
|
pub version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MissionObject {
|
||||||
|
pub offset: usize,
|
||||||
|
pub group_id: u32,
|
||||||
|
pub flags: u32,
|
||||||
|
pub resource_name: String,
|
||||||
|
pub logical_id: i32,
|
||||||
|
pub clan_id: i32,
|
||||||
|
pub position: [f32; 3],
|
||||||
|
pub orientation: [f32; 3],
|
||||||
|
pub scale: [f32; 3],
|
||||||
|
pub alias: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_path(path: impl AsRef<Path>) -> Result<MissionFile> {
|
||||||
|
let bytes = fs::read(path.as_ref())?;
|
||||||
|
parse_bytes(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_bytes(bytes: &[u8]) -> Result<MissionFile> {
|
||||||
|
let footer = parse_footer(bytes)?;
|
||||||
|
let objects = parse_objects(bytes);
|
||||||
|
Ok(MissionFile { footer, objects })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_footer(bytes: &[u8]) -> Result<MissionFooter> {
|
||||||
|
let map_positions = find_all_map_path_positions(bytes);
|
||||||
|
if map_positions.is_empty() {
|
||||||
|
return Err(Error::FooterNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
for map_start in map_positions.into_iter().rev() {
|
||||||
|
if map_start < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let map_end = scan_path_end(bytes, map_start);
|
||||||
|
if map_end <= map_start {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let map_len = map_end - map_start;
|
||||||
|
let Some(declared_map_len) = read_u32(bytes, map_start - 4).map(|v| v as usize) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if declared_map_len != map_len {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(zero_pad) = read_u32(bytes, map_end) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if zero_pad != 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title_len_off = map_end + 4;
|
||||||
|
let Some(title_len) = read_u32(bytes, title_len_off).map(|v| v as usize) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if title_len == 0 || title_len > 256 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title_start = title_len_off + 4;
|
||||||
|
let Some(title_end) = title_start.checked_add(title_len) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if title_end > bytes.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let map_path = decode_cp1251(&bytes[map_start..map_end]);
|
||||||
|
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title = decode_title(&bytes[title_start..title_end]);
|
||||||
|
let version = parse_footer_version(bytes, title_end)?;
|
||||||
|
|
||||||
|
return Ok(MissionFooter {
|
||||||
|
map_path,
|
||||||
|
title,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for multiplayer/legacy variants where the footer tail differs,
|
||||||
|
// but map path is still present in clear text near EOF.
|
||||||
|
let Some(map_start) = bytes
|
||||||
|
.windows(MAP_PATH_TOKEN.len())
|
||||||
|
.rposition(|window| window == MAP_PATH_TOKEN)
|
||||||
|
else {
|
||||||
|
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
|
||||||
|
};
|
||||||
|
let map_end = scan_path_end(bytes, map_start);
|
||||||
|
if map_end <= map_start {
|
||||||
|
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
|
||||||
|
}
|
||||||
|
let map_path = decode_cp1251(&bytes[map_start..map_end]);
|
||||||
|
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
|
||||||
|
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut title = String::new();
|
||||||
|
if let Some(title_len) = read_u32(bytes, map_end + 8).map(|v| v as usize) {
|
||||||
|
let title_start = map_end + 12;
|
||||||
|
let title_end = title_start.saturating_add(title_len);
|
||||||
|
if title_len > 0 && title_len <= 256 && title_end <= bytes.len() {
|
||||||
|
let raw = &bytes[title_start..title_end];
|
||||||
|
if raw.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
|
||||||
|
title = decode_title(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = if let Some(magic_off) = bytes
|
||||||
|
.windows(FOOTER_MAGIC.len())
|
||||||
|
.rposition(|window| window == FOOTER_MAGIC)
|
||||||
|
{
|
||||||
|
read_u32(bytes, magic_off + 4).unwrap_or(1)
|
||||||
|
} else {
|
||||||
|
read_u32(bytes, map_end).unwrap_or(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(MissionFooter {
|
||||||
|
map_path,
|
||||||
|
title,
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_footer_version(bytes: &[u8], after_title_off: usize) -> Result<u32> {
|
||||||
|
if after_title_off + 8 <= bytes.len()
|
||||||
|
&& &bytes[after_title_off..after_title_off + 4] == FOOTER_MAGIC
|
||||||
|
{
|
||||||
|
let version = read_u32(bytes, after_title_off + 4)
|
||||||
|
.ok_or(Error::FooterCorrupt("missing version after MtPr"))?;
|
||||||
|
return Ok(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = read_u32(bytes, after_title_off)
|
||||||
|
.ok_or(Error::FooterCorrupt("missing version after title"))?;
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_all_map_path_positions(bytes: &[u8]) -> Vec<usize> {
|
||||||
|
bytes
|
||||||
|
.windows(MAP_PATH_TOKEN.len())
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(idx, window)| (window == MAP_PATH_TOKEN).then_some(idx))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_path_end(bytes: &[u8], start: usize) -> usize {
|
||||||
|
let mut off = start;
|
||||||
|
while off < bytes.len() && is_path_byte(bytes[off]) {
|
||||||
|
off += 1;
|
||||||
|
}
|
||||||
|
off
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_path_byte(byte: u8) -> bool {
|
||||||
|
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-' | b' ' | b':')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_objects(bytes: &[u8]) -> Vec<MissionObject> {
|
||||||
|
let mut objects = Vec::new();
|
||||||
|
let min_record_tail = 48usize;
|
||||||
|
|
||||||
|
for offset in 0..bytes.len().saturating_sub(16) {
|
||||||
|
let Some(flags) = read_u32(bytes, offset + 4) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if flags != OBJECT_RECORD_FLAGS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(name_len) = read_u32(bytes, offset + 8).map(|v| v as usize) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !(3..=260).contains(&name_len) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_start = offset + 12;
|
||||||
|
let Some(name_end) = name_start.checked_add(name_len) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if name_end + min_record_tail > bytes.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_raw = &bytes[name_start..name_end];
|
||||||
|
if !is_object_name_bytes(name_raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resource_name = decode_cp1251(name_raw);
|
||||||
|
if !looks_like_object_name(&resource_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(group_id) = read_u32(bytes, offset) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(logical_id) = read_i32(bytes, name_end) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(clan_id) = read_i32(bytes, name_end + 4) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(position) = read_vec3(bytes, name_end + 8) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(orientation) = read_vec3(bytes, name_end + 20) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(scale) = read_vec3(bytes, name_end + 32) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alias = parse_alias(bytes, name_end + 44);
|
||||||
|
|
||||||
|
objects.push(MissionObject {
|
||||||
|
offset,
|
||||||
|
group_id,
|
||||||
|
flags,
|
||||||
|
resource_name,
|
||||||
|
logical_id,
|
||||||
|
clan_id,
|
||||||
|
position,
|
||||||
|
orientation,
|
||||||
|
scale,
|
||||||
|
alias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
objects.sort_by_key(|obj| obj.offset);
|
||||||
|
objects.dedup_by_key(|obj| obj.offset);
|
||||||
|
objects
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_alias(bytes: &[u8], alias_len_off: usize) -> String {
|
||||||
|
let Some(alias_len) = read_u32(bytes, alias_len_off).map(|v| v as usize) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
if alias_len == 0 || alias_len > 96 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let alias_start = alias_len_off + 4;
|
||||||
|
let Some(alias_end) = alias_start.checked_add(alias_len) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
if alias_end > bytes.len() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let alias_raw = &bytes[alias_start..alias_end];
|
||||||
|
if !alias_raw
|
||||||
|
.iter()
|
||||||
|
.all(|&b| b == b'_' || b == b'-' || b == b'.' || b.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
decode_cp1251(alias_raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_object_name(name: &str) -> bool {
|
||||||
|
if name.ends_with(".dat") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
name.contains('_')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_object_name_bytes(bytes: &[u8]) -> bool {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'.' | b'/' | b'\\' | b'-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_finite(v: &[f32; 3]) -> bool {
|
||||||
|
v.iter().all(|c| c.is_finite())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_title(bytes: &[u8]) -> String {
|
||||||
|
let end = bytes
|
||||||
|
.iter()
|
||||||
|
.rposition(|b| *b != 0 && *b != 0xCD)
|
||||||
|
.map(|idx| idx + 1)
|
||||||
|
.unwrap_or(0);
|
||||||
|
decode_cp1251(&bytes[..end]).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||||
|
let end = offset.checked_add(4)?;
|
||||||
|
let chunk = bytes.get(offset..end)?;
|
||||||
|
Some(u32::from_le_bytes(chunk.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i32(bytes: &[u8], offset: usize) -> Option<i32> {
|
||||||
|
read_u32(bytes, offset).map(|v| v as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> {
|
||||||
|
let end = offset.checked_add(4)?;
|
||||||
|
let chunk = bytes.get(offset..end)?;
|
||||||
|
Some(f32::from_le_bytes(chunk.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> {
|
||||||
|
Some([
|
||||||
|
read_f32(bytes, offset)?,
|
||||||
|
read_f32(bytes, offset + 4)?,
|
||||||
|
read_f32(bytes, offset + 8)?,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("Parkan - Iron Strategy");
|
||||||
|
root.is_dir().then_some(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_known_mission_footer_and_objects() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root is missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = root
|
||||||
|
.join("MISSIONS")
|
||||||
|
.join("CAMPAIGN")
|
||||||
|
.join("CAMPAIGN.00")
|
||||||
|
.join("Mission.01")
|
||||||
|
.join("data.tma");
|
||||||
|
if !path.is_file() {
|
||||||
|
eprintln!("skipping: sample mission is missing ({})", path.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mission = parse_path(&path).expect("parse mission failed");
|
||||||
|
assert_eq!(mission.footer.version, 1);
|
||||||
|
assert!(
|
||||||
|
mission
|
||||||
|
.footer
|
||||||
|
.map_path
|
||||||
|
.eq_ignore_ascii_case("DATA\\MAPS\\Tut_1\\land"),
|
||||||
|
"unexpected map path: {}",
|
||||||
|
mission.footer.map_path
|
||||||
|
);
|
||||||
|
assert!(mission.objects.len() >= 20);
|
||||||
|
assert!(mission
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.any(|obj| obj.resource_name.eq_ignore_ascii_case("s_tree_04")));
|
||||||
|
assert!(mission.objects.iter().any(|obj| {
|
||||||
|
obj.resource_name
|
||||||
|
.eq_ignore_ascii_case("UNITS\\UNITS\\HERO\\tut1_p.dat")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_all_retail_missions() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root is missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mission_root = root.join("MISSIONS");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&mission_root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut mission_count = 0usize;
|
||||||
|
for path in files {
|
||||||
|
if !path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.eq_ignore_ascii_case("data.tma"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mission_count += 1;
|
||||||
|
let mission = parse_path(&path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||||
|
assert!(
|
||||||
|
mission
|
||||||
|
.footer
|
||||||
|
.map_path
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
.contains("DATA\\MAPS\\"),
|
||||||
|
"{}: invalid map path '{}'",
|
||||||
|
path.display(),
|
||||||
|
mission.footer.map_path
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!mission.objects.is_empty(),
|
||||||
|
"{}: mission has no parsed object records",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mission
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.all(|obj| obj.position.iter().all(|v| v.is_finite())),
|
||||||
|
"{}: mission has non-finite position",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(mission_count > 0, "no data.tma files found");
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/unitdat/Cargo.toml
Normal file
10
crates/unitdat/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "unitdat"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
180
crates/unitdat/src/lib.rs
Normal file
180
crates/unitdat/src/lib.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const MIN_SIZE: usize = 0x48;
|
||||||
|
const MAGIC: u32 = 0x0000_F0F1;
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Io(std::io::Error),
|
||||||
|
TooSmall { got: usize },
|
||||||
|
InvalidMagic { got: u32 },
|
||||||
|
MissingArchiveName,
|
||||||
|
MissingModelKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
Self::TooSmall { got } => write!(f, "unit .dat is too small: {got} bytes"),
|
||||||
|
Self::InvalidMagic { got } => write!(f, "invalid .dat magic: 0x{got:08X}"),
|
||||||
|
Self::MissingArchiveName => write!(f, "unit .dat has empty archive name"),
|
||||||
|
Self::MissingModelKey => write!(f, "unit .dat has empty model key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => Some(err),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(value: std::io::Error) -> Self {
|
||||||
|
Self::Io(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UnitDat {
|
||||||
|
pub magic: u32,
|
||||||
|
pub flags: u32,
|
||||||
|
pub archive_name: String,
|
||||||
|
pub model_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_path(path: impl AsRef<Path>) -> Result<UnitDat> {
|
||||||
|
let bytes = fs::read(path.as_ref())?;
|
||||||
|
parse_bytes(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_bytes(bytes: &[u8]) -> Result<UnitDat> {
|
||||||
|
if bytes.len() < MIN_SIZE {
|
||||||
|
return Err(Error::TooSmall { got: bytes.len() });
|
||||||
|
}
|
||||||
|
|
||||||
|
let magic = read_u32(bytes, 0).ok_or(Error::TooSmall { got: bytes.len() })?;
|
||||||
|
if magic != MAGIC {
|
||||||
|
return Err(Error::InvalidMagic { got: magic });
|
||||||
|
}
|
||||||
|
|
||||||
|
let flags = read_u32(bytes, 4).ok_or(Error::TooSmall { got: bytes.len() })?;
|
||||||
|
let archive_name = decode_c_string_fixed(&bytes[0x08..0x28]);
|
||||||
|
if archive_name.is_empty() {
|
||||||
|
return Err(Error::MissingArchiveName);
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_key = decode_c_string_fixed(&bytes[0x28..0x48]);
|
||||||
|
if model_key.is_empty() {
|
||||||
|
return Err(Error::MissingModelKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(UnitDat {
|
||||||
|
magic,
|
||||||
|
flags,
|
||||||
|
archive_name,
|
||||||
|
model_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||||
|
let end = offset.checked_add(4)?;
|
||||||
|
let chunk = bytes.get(offset..end)?;
|
||||||
|
Some(u32::from_le_bytes(chunk.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_c_string_fixed(bytes: &[u8]) -> String {
|
||||||
|
let used = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..used]);
|
||||||
|
decoded.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("Parkan - Iron Strategy");
|
||||||
|
root.is_dir().then_some(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_known_dat_files() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let samples = [
|
||||||
|
root.join("UNITS/UNITS/HERO/tut1_p.dat"),
|
||||||
|
root.join("UNITS/UNITS/BATTLE/l_targ.dat"),
|
||||||
|
root.join("UNITS/BUILDS/BRIDGE/m_bridge.dat"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in samples {
|
||||||
|
if !path.is_file() {
|
||||||
|
eprintln!("skipping missing sample {}", path.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dat = parse_path(&path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||||
|
assert_eq!(dat.magic, MAGIC);
|
||||||
|
assert!(dat.archive_name.to_ascii_lowercase().ends_with(".rlb"));
|
||||||
|
assert!(dat.model_key.contains('_'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_retail_dat_corpus() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let units_root = root.join("UNITS");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&units_root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut parsed = 0usize;
|
||||||
|
for path in files {
|
||||||
|
if !path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("dat"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dat = parse_path(&path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||||
|
assert!(
|
||||||
|
!dat.archive_name.is_empty(),
|
||||||
|
"{} empty archive",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!dat.model_key.is_empty(),
|
||||||
|
"{} empty model key",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
parsed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(parsed > 0, "no .dat files parsed");
|
||||||
|
}
|
||||||
|
}
|
||||||
35
docs/specs/ai.md
Normal file
35
docs/specs/ai.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# AI system
|
||||||
|
|
||||||
|
Страница фиксирует границы подсистемы AI на уровне движка:
|
||||||
|
|
||||||
|
- выбор целей;
|
||||||
|
- тактические приоритеты;
|
||||||
|
- координация с `Behavior`, `ArealMap`, `Missions`.
|
||||||
|
|
||||||
|
## 1. Текущая зафиксированная часть
|
||||||
|
|
||||||
|
1. AI работает поверх ареалов/клеток карты, а не напрямую поверх render-геометрии.
|
||||||
|
2. Результат AI передается в behavior/command-слой как набор целевых состояний и команд.
|
||||||
|
3. Решения AI зависят от миссионных триггеров и состояния объектов мира.
|
||||||
|
|
||||||
|
## 2. Контракт интеграции
|
||||||
|
|
||||||
|
В 1:1 реализации AI должен быть совместим с:
|
||||||
|
|
||||||
|
1. системой ареалов (`Land.map`);
|
||||||
|
2. объектными категориями (`BuildDat.lst`);
|
||||||
|
3. поведением юнитов (`behavior.md`);
|
||||||
|
4. миссионными условиями (`missions.md`).
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- роль AI в общей архитектуре и точки интеграции с соседними подсистемами.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полный формат runtime-AI состояний и таблиц решений.
|
||||||
|
2. Полные правила выбора цели/маршрута/приоритета огня.
|
||||||
|
3. Полная спецификация влияния миссионных скриптов на AI.
|
||||||
|
4. Набор тест-кейсов «AI tick parity» для побайтного/пошагового сравнения с оригиналом.
|
||||||
31
docs/specs/arealmap.md
Normal file
31
docs/specs/arealmap.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# ArealMap
|
||||||
|
|
||||||
|
`ArealMap` — подсистема топологии мира и логических зон.
|
||||||
|
|
||||||
|
Подробный бинарный формат `Land.map` и связь с terrain описаны в:
|
||||||
|
|
||||||
|
- [Terrain + ArealMap](terrain-map-loading.md)
|
||||||
|
|
||||||
|
## 1. Роль в движке
|
||||||
|
|
||||||
|
1. Хранит ареалы, связи между ареалами и клеточный индекс.
|
||||||
|
2. Используется для навигации, логики объектов и AI-решений.
|
||||||
|
3. Связывает геометрию карты с миссионной и поведенческой логикой.
|
||||||
|
|
||||||
|
## 2. Минимальный runtime-контракт
|
||||||
|
|
||||||
|
1. Валидный граф ареалов и edge-link связей.
|
||||||
|
2. Валидная cell-grid индексация (`cellsX/cellsY` + hit lists).
|
||||||
|
3. Согласованные идентификаторы ареалов для AI/Behavior/Missions.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- бинарный контракт `Land.map` и pair-загрузка с `Land.msh`.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная доменная семантика `class_id`/`logic_flag` по всем игровым сценариям.
|
||||||
|
2. Формальная спецификация API-запросов к ArealMap (поиск зон, фильтры, события).
|
||||||
|
3. Набор parity-тестов поведения навигационных запросов на одинаковых входах.
|
||||||
28
docs/specs/behavior.md
Normal file
28
docs/specs/behavior.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Behavior system
|
||||||
|
|
||||||
|
`Behavior` — слой исполнения состояний юнитов между AI-решением и низкоуровневым control-командованием.
|
||||||
|
|
||||||
|
## 1. Роль в кадре
|
||||||
|
|
||||||
|
1. Принимает решения из AI.
|
||||||
|
2. Переводит их в state machine юнита.
|
||||||
|
3. Формирует команды движения/атаки/действий в world/control-слой.
|
||||||
|
|
||||||
|
## 2. Внешние зависимости
|
||||||
|
|
||||||
|
1. `ArealMap` (доступность/топология).
|
||||||
|
2. `Missions` (триггеры и ограничения сценария).
|
||||||
|
3. `Control` (выполнение команд).
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- архитектурная роль подсистемы и ее место в runtime-пайплайне.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация finite-state машин по типам юнитов.
|
||||||
|
2. Полная таблица переходов, таймаутов и приоритетов.
|
||||||
|
3. Формализация входных/выходных структур поведения для 1:1 эмуляции.
|
||||||
|
4. Поведенческие parity-тесты на фиксированных replay-сценариях.
|
||||||
28
docs/specs/control.md
Normal file
28
docs/specs/control.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Control system
|
||||||
|
|
||||||
|
`Control` — подсистема входа и маршрутизации команд (пользовательских и системных).
|
||||||
|
|
||||||
|
## 1. Роль
|
||||||
|
|
||||||
|
1. Преобразует ввод устройств в команды движка.
|
||||||
|
2. Синхронизирует управление камерой, UI и объектами мира.
|
||||||
|
3. Передает команды в gameplay-подсистемы с учетом активного режима игры.
|
||||||
|
|
||||||
|
## 2. Минимальный контракт совместимости
|
||||||
|
|
||||||
|
1. Детерминированный mapping input -> command.
|
||||||
|
2. Стабильная обработка очереди команд в пределах кадра.
|
||||||
|
3. Корректный приоритет UI-фокуса над world-input.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- место control-слоя в архитектуре и базовый runtime-контур.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная карта input actions и режимов обработки.
|
||||||
|
2. Формат внутренних очередей команд и их сериализация.
|
||||||
|
3. Спецификация edge-case поведения (повтор клавиш, захват мыши, hotkey-конфликты).
|
||||||
|
4. Пошаговые parity-тесты на записанных последовательностях ввода.
|
||||||
51
docs/specs/coverage-audit.md
Normal file
51
docs/specs/coverage-audit.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Documentation coverage audit
|
||||||
|
|
||||||
|
Дата аудита: `2026-02-19`
|
||||||
|
Корпус данных: `testdata/Parkan - Iron Strategy`
|
||||||
|
|
||||||
|
## 1. Проверка форматов архивов
|
||||||
|
|
||||||
|
Результаты:
|
||||||
|
|
||||||
|
- `NRes`: `120` архивов, roundtrip `120/120` (byte-identical)
|
||||||
|
- `RsLi`: `2` архива, roundtrip `2/2` (byte-identical)
|
||||||
|
- подтвержден один совместимый quirk: `sprites.lib`, entry `23`, `deflate EOF+1`
|
||||||
|
|
||||||
|
Инструмент:
|
||||||
|
|
||||||
|
- `tools/archive_roundtrip_validator.py`
|
||||||
|
|
||||||
|
## 2. Проверка рендерных форматов
|
||||||
|
|
||||||
|
Результаты:
|
||||||
|
|
||||||
|
- `MSH`: `435/435` валидны
|
||||||
|
- `Texm`: `518/518` валидны
|
||||||
|
- `FXID`: `923/923` валидны
|
||||||
|
- `Terrain/Map` (`Land.msh` + `Land.map`): `33/33` без ошибок/предупреждений
|
||||||
|
|
||||||
|
Инструменты:
|
||||||
|
|
||||||
|
- `tools/msh_doc_validator.py`
|
||||||
|
- `tools/fxid_abs100_audit.py`
|
||||||
|
- `tools/terrain_map_doc_validator.py`
|
||||||
|
|
||||||
|
## 3. Глобальный статус по подсистемам
|
||||||
|
|
||||||
|
| Подсистема | Статус | Что блокирует 100% |
|
||||||
|
|---|---|---|
|
||||||
|
| Архивы (`NRes`, `RsLi`) | практически закрыта | формализация редких не-ASCII/служебных edge-case |
|
||||||
|
| 3D geometry (`MSH core`) | высокая готовность | семантика opaque-полей и канонический writer «с нуля» |
|
||||||
|
| Animation (`Res8/Res19`) | высокая готовность | полный FP-parity на всех edge-case |
|
||||||
|
| Material/Wear/Texture | высокая готовность | полная field-level семантика служебных флагов и writer-профиль |
|
||||||
|
| FXID | высокая готовность | полная field-level семантика payload по каждому opcode |
|
||||||
|
| Terrain/Areal map formats | высокая готовность | доменная семантика `class_id/logic_flag`, ветка `poly_count>0` |
|
||||||
|
| Render pipeline | хорошая | полный pixel-parity набор эталонных кадров в CI |
|
||||||
|
| AI/Behavior/Control/Missions/UI/Sound/Network | начальное покрытие | требуется полная спецификация форматов и runtime-контрактов |
|
||||||
|
|
||||||
|
## 4. План доведения до 100%
|
||||||
|
|
||||||
|
1. Закрыть field-level семантику opaque/служебных полей в 3D/FX/terrain подсистемах.
|
||||||
|
2. Завершить canonical writer paths для авторинга новых ассетов без copy-through.
|
||||||
|
3. Зафиксировать и автоматизировать pixel/frame parity-критерии в CI.
|
||||||
|
4. Расширить подсистемные спецификации (`AI`, `Behavior`, `Missions`, `Control`, `UI`, `Sound`, `Network`) до уровня «полный формат + полный runtime-контракт + parity-тесты».
|
||||||
@@ -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”.
|
|
||||||
202
docs/specs/fxid.md
Normal file
202
docs/specs/fxid.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# FXID
|
||||||
|
|
||||||
|
`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
|
||||||
|
Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
|
||||||
|
|
||||||
|
Связанные контейнеры: [NRes](nres.md), [RsLi](rsli.md).
|
||||||
|
|
||||||
|
## 1. Контейнер
|
||||||
|
|
||||||
|
- Тип ресурса в `NRes`: `0x44495846` (`FXID`).
|
||||||
|
- Значения `attr1/attr2/attr3` в типовых игровых данных стабильны, но при редактуре их нужно сохранять как есть.
|
||||||
|
|
||||||
|
## 2. Бинарный формат
|
||||||
|
|
||||||
|
Все значения little-endian.
|
||||||
|
|
||||||
|
### 2.1. Заголовок (60 байт)
|
||||||
|
|
||||||
|
```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`.
|
||||||
|
|
||||||
|
### 2.2. Команда
|
||||||
|
|
||||||
|
Каждая команда:
|
||||||
|
|
||||||
|
1. `uint32 cmd_word`
|
||||||
|
2. body фиксированного размера, зависящего от `opcode`
|
||||||
|
|
||||||
|
Поля `cmd_word`:
|
||||||
|
|
||||||
|
- `opcode = cmd_word & 0xFF`
|
||||||
|
- `enabled = (cmd_word >> 8) & 1`
|
||||||
|
- `bits 9..31` нужно сохранять 1:1
|
||||||
|
|
||||||
|
Выравнивания между командами нет.
|
||||||
|
|
||||||
|
### 2.3. Размеры команд
|
||||||
|
|
||||||
|
| Opcode | Размер |
|
||||||
|
|---:|---:|
|
||||||
|
| 1 | 224 |
|
||||||
|
| 2 | 148 |
|
||||||
|
| 3 | 200 |
|
||||||
|
| 4 | 204 |
|
||||||
|
| 5 | 112 |
|
||||||
|
| 6 | 4 |
|
||||||
|
| 7 | 208 |
|
||||||
|
| 8 | 248 |
|
||||||
|
| 9 | 208 |
|
||||||
|
| 10 | 208 |
|
||||||
|
|
||||||
|
## 3. Смысл заголовка
|
||||||
|
|
||||||
|
- `cmd_count`: число команд в потоке.
|
||||||
|
- `time_mode`: способ вычисления текущего коэффициента эффекта.
|
||||||
|
- `duration_sec`: длительность (в рантайме переводится в миллисекунды).
|
||||||
|
- `phase_jitter`: амплитуда случайного фазового сдвига.
|
||||||
|
- `flags`: флаги поведения (видимость, альфа-модификаторы, режимы гейтинга).
|
||||||
|
- `settings_id`: индекс профиля/настроек эффекта.
|
||||||
|
- `rand_shift_*`: случайный пространственный сдвиг.
|
||||||
|
- `pivot_*`: локальная опора.
|
||||||
|
- `scale_*`: базовый масштаб инстанса эффекта.
|
||||||
|
|
||||||
|
## 4. Флаги заголовка
|
||||||
|
|
||||||
|
Практически важные биты:
|
||||||
|
|
||||||
|
- `0x0001`: случайный сдвиг фазы
|
||||||
|
- `0x0008`: случайный пространственный сдвиг (`rand_shift_*`)
|
||||||
|
- `0x0010`: ветки видимости/окклюзии
|
||||||
|
- `0x0020`: треугольный ремап альфы
|
||||||
|
- `0x0040`: инверсия исходного active-state
|
||||||
|
- `0x0080`, `0x0100`: фильтрация по времени суток
|
||||||
|
- `0x0200`: умножение альфы на нормализованное время жизни
|
||||||
|
- `0x0400`, `0x1000`: дополнительные биты состояния менеджера эффекта
|
||||||
|
- `0x0800`: дополнительный гейтинг
|
||||||
|
|
||||||
|
Неизвестные биты должны сохраняться без изменений.
|
||||||
|
|
||||||
|
## 5. `time_mode` (0..17)
|
||||||
|
|
||||||
|
База:
|
||||||
|
|
||||||
|
- `tn = (now - start) / (end - start)`
|
||||||
|
- `prev = предыдущая вычисленная альфа`
|
||||||
|
|
||||||
|
Поддерживаемые семейства режимов:
|
||||||
|
|
||||||
|
- константный режим;
|
||||||
|
- линейный (`tn`), обратный (`1-tn`), циклический (`fract(tn)`);
|
||||||
|
- режимы от внешних параметров мира/очереди;
|
||||||
|
- режимы на основе норм векторов состояния;
|
||||||
|
- режимы с ограничением вниз/вверх относительно `prev`.
|
||||||
|
|
||||||
|
После вычисления:
|
||||||
|
|
||||||
|
- при `flags & 0x0200` применяется `alpha *= tn`;
|
||||||
|
- при `flags & 0x0020` применяется triangular remap.
|
||||||
|
|
||||||
|
## 6. Resource-ссылки внутри команд
|
||||||
|
|
||||||
|
Для opcode `2/3/4/5/7/8/9/10` используется ссылка:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct ResourceRef64 {
|
||||||
|
char archive[32];
|
||||||
|
char name[32];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Контракт:
|
||||||
|
|
||||||
|
- строки ASCII, нуль-терминированные;
|
||||||
|
- сравнение имён регистронезависимое;
|
||||||
|
- обычно:
|
||||||
|
- `opcode 2`: `sounds.lib` + `*.wav`
|
||||||
|
- остальные: `material.lib` + имя материала/эффекта.
|
||||||
|
|
||||||
|
## 7. Runtime-контракт исполнения
|
||||||
|
|
||||||
|
На создании инстанса:
|
||||||
|
|
||||||
|
1. Заголовок копируется в runtime-состояние.
|
||||||
|
2. Вычисляется `end_time`.
|
||||||
|
3. Для каждой команды создаётся runtime-объект по `opcode`.
|
||||||
|
4. В объект копируется `enabled`.
|
||||||
|
5. Объект инициализируется контекстом эффекта.
|
||||||
|
|
||||||
|
На каждом кадре:
|
||||||
|
|
||||||
|
1. Вычисляется текущий коэффициент/альфа по `time_mode` и `flags`.
|
||||||
|
2. Выполняется update каждой команды.
|
||||||
|
3. Выполняется emit/render часть активных команд.
|
||||||
|
4. Применяются события Start/Stop/Restart.
|
||||||
|
|
||||||
|
## 8. Строгий парсер (рекомендуемый)
|
||||||
|
|
||||||
|
1. Проверить `len(payload) >= 60`.
|
||||||
|
2. Прочитать `cmd_count`.
|
||||||
|
3. Идти от `ptr = 0x3C`.
|
||||||
|
4. Для каждой команды:
|
||||||
|
- проверить `ptr + 4 <= len`;
|
||||||
|
- прочитать `opcode`;
|
||||||
|
- проверить, что `opcode` поддержан;
|
||||||
|
- проверить `ptr + size(opcode) <= len`;
|
||||||
|
- сдвинуть `ptr += size(opcode)`.
|
||||||
|
5. Проверить `ptr == len(payload)`.
|
||||||
|
|
||||||
|
## 9. Writer и редактор
|
||||||
|
|
||||||
|
Для lossless-совместимости:
|
||||||
|
|
||||||
|
- сохранять все неизвестные поля/биты;
|
||||||
|
- не менять фиксированные размеры команд;
|
||||||
|
- не добавлять padding;
|
||||||
|
- пересчитывать только `cmd_count` и размеры контейнера;
|
||||||
|
- сохранять порядок команд.
|
||||||
|
|
||||||
|
## 10. Что требуется для 1:1 переноса
|
||||||
|
|
||||||
|
1. Полная поддержка opcode `1..10`.
|
||||||
|
2. Точный контракт вычисления `time_mode` и `flags`.
|
||||||
|
3. Точное поведение `ResourceRef64`.
|
||||||
|
4. Повторяемый RNG и одинаковая политика плавающей точки.
|
||||||
|
|
||||||
|
## 11. Статус валидации
|
||||||
|
|
||||||
|
- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
|
||||||
|
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `923/923` FXID payload без ошибок.
|
||||||
|
|
||||||
|
## 12. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Контейнер FXID, fixed-size командный поток, opcode-покрытие `1..10`.
|
||||||
|
2. Базовый runtime-контур исполнения эффекта.
|
||||||
|
3. Корпусная валидация формата на retail-данных.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная field-level семантика payload каждого opcode для авторинга новых эффектов «с нуля».
|
||||||
|
2. Формальная спецификация всех `time_mode` веток на уровне точных числовых формул и edge-case поведения.
|
||||||
|
3. Полный набор пиксельных parity-тестов FX (оригинал vs новый рендер) на фиксированных сценах.
|
||||||
144
docs/specs/material.md
Normal file
144
docs/specs/material.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Material (`MAT0`)
|
||||||
|
|
||||||
|
`MAT0` описывает материал и его фазовую анимацию.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [Wear table (`WEAR`)](wear.md)
|
||||||
|
- [Texture (`Texm`)](texture.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## 1. Контейнер
|
||||||
|
|
||||||
|
- Тип ресурса: `0x3054414D` (`MAT0`).
|
||||||
|
- Обычно хранится в `Material.lib`.
|
||||||
|
- `attr1` используется как битовое поле runtime-флагов материала.
|
||||||
|
- `attr2` задаёт версию заголовка payload.
|
||||||
|
|
||||||
|
## 2. Бинарный layout
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Mat0Payload {
|
||||||
|
uint16_t phaseCount;
|
||||||
|
uint16_t animBlockCount; // должно быть < 20
|
||||||
|
|
||||||
|
// если attr2 >= 2
|
||||||
|
uint8_t metaA8;
|
||||||
|
uint8_t metaB8;
|
||||||
|
// если attr2 >= 3
|
||||||
|
uint32_t metaC32;
|
||||||
|
// если attr2 >= 4
|
||||||
|
uint32_t metaD32;
|
||||||
|
|
||||||
|
PhaseRecord34 phases[phaseCount];
|
||||||
|
AnimBlockRaw anim[animBlockCount];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `attr2 < 2`, используются runtime-значения по умолчанию:
|
||||||
|
|
||||||
|
- `metaA = 255`
|
||||||
|
- `metaB = 255`
|
||||||
|
- `metaC = 1.0f`
|
||||||
|
- `metaD = 0`
|
||||||
|
|
||||||
|
## 3. Фазы материала
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct PhaseRecord34 {
|
||||||
|
uint8_t params[18];
|
||||||
|
char textureName[16];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
В рантайме запись разворачивается в структуру ~76 байт:
|
||||||
|
|
||||||
|
- набор коэффициентов цвета/освещения/прозрачности;
|
||||||
|
- индекс слота текстуры;
|
||||||
|
- дополнительные целочисленные поля.
|
||||||
|
|
||||||
|
`textureName`:
|
||||||
|
|
||||||
|
- пустая строка -> фаза без текстуры (`texSlot = -1`);
|
||||||
|
- непустая строка -> загрузка текстуры по имени.
|
||||||
|
|
||||||
|
## 4. Анимационные блоки
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct AnimBlockRaw {
|
||||||
|
uint32_t headerRaw; // mode = low 3 bits, interpMask = остальные
|
||||||
|
uint16_t keyCount;
|
||||||
|
KeyRaw keys[keyCount];
|
||||||
|
};
|
||||||
|
|
||||||
|
struct KeyRaw {
|
||||||
|
uint16_t k0;
|
||||||
|
uint16_t k1;
|
||||||
|
uint16_t k2; // opaque, сохранять 1:1
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`k2` нельзя удалять или нормализовать: это часть бинарного контракта.
|
||||||
|
|
||||||
|
## 5. Выбор текущей фазы
|
||||||
|
|
||||||
|
Материал выбирает фазу по времени и по режиму анимации блока:
|
||||||
|
|
||||||
|
- loop;
|
||||||
|
- ping-pong;
|
||||||
|
- one-shot с clamp;
|
||||||
|
- random-offset.
|
||||||
|
|
||||||
|
При смешивании интерполируется только часть полей, остальные копируются из активной фазы.
|
||||||
|
Для 1:1 совместимости важно сохранить эту выборочную интерполяцию.
|
||||||
|
|
||||||
|
## 6. Загрузка и fallback
|
||||||
|
|
||||||
|
При запросе материала по имени:
|
||||||
|
|
||||||
|
1. Точный поиск по имени.
|
||||||
|
2. Если не найдено — fallback на `DEFAULT`.
|
||||||
|
3. Если `DEFAULT` отсутствует — используется запись с индексом `0`.
|
||||||
|
|
||||||
|
## 7. Атрибуты и флаги
|
||||||
|
|
||||||
|
Практически важные биты `attr1`:
|
||||||
|
|
||||||
|
- бит загрузки текстурной фазы с расширенными флагами;
|
||||||
|
- флаги аппаратного профиля;
|
||||||
|
- 4-битный режим (`nibbleMode`);
|
||||||
|
- дополнительный флаг material-поведения.
|
||||||
|
|
||||||
|
Неизвестные биты должны сохраняться без изменений.
|
||||||
|
|
||||||
|
## 8. Ограничения
|
||||||
|
|
||||||
|
- `animBlockCount < 20`
|
||||||
|
- `phaseCount` и фактический размер секции фаз должны совпадать
|
||||||
|
- `textureName` должен быть NUL-terminated и укладываться в 16 байт
|
||||||
|
|
||||||
|
## 9. Правила writer/editor
|
||||||
|
|
||||||
|
1. Сохранять `attr1/attr2/attr3`.
|
||||||
|
2. Не менять `metaA/B/C/D` без явного запроса.
|
||||||
|
3. Сохранять opaque-поля анимации (включая `k2`) 1:1.
|
||||||
|
4. Проверять выход за границы payload при парсинге.
|
||||||
|
|
||||||
|
## 10. Статус валидации
|
||||||
|
|
||||||
|
- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`).
|
||||||
|
- Структурная валидация MAT0 включена в корпусный прогон `tools/msh_doc_validator.py` на полном retail-наборе.
|
||||||
|
|
||||||
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Бинарный layout `MAT0` и правила чтения фаз/анимационных блоков.
|
||||||
|
2. Fallback-цепочка материала.
|
||||||
|
3. Контракт сохранения opaque-полей для lossless editor path.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная семантика всех битов `attr1` и `metaA/B/C/D` для авторинга новых материалов.
|
||||||
|
2. Полный writer-профиль «канонический MAT0» для генерации ассетов без copy-through.
|
||||||
|
3. Набор визуальных parity-тестов по material phase animation на реальных моделях.
|
||||||
18
docs/specs/materials-texm.md
Normal file
18
docs/specs/materials-texm.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Materials, WEAR, Texm
|
||||||
|
|
||||||
|
Старая объединённая страница разбита по объектам.
|
||||||
|
|
||||||
|
- [Material (`MAT0`)](material.md)
|
||||||
|
- [Wear table (`WEAR`)](wear.md)
|
||||||
|
- [Texture (`Texm`)](texture.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Страница корректно декомпозирована на отдельные объектные спецификации.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Поддерживать единый changelog согласованности между `material.md`, `wear.md`, `texture.md` и `render.md`.
|
||||||
46
docs/specs/missions.md
Normal file
46
docs/specs/missions.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Missions
|
||||||
|
|
||||||
|
Подсистема `Missions` управляет сценарием:
|
||||||
|
|
||||||
|
- стартовыми условиями;
|
||||||
|
- триггерами;
|
||||||
|
- победой/поражением;
|
||||||
|
- синхронизацией с AI/Behavior/World.
|
||||||
|
|
||||||
|
## 1. Что уже зафиксировано
|
||||||
|
|
||||||
|
1. Миссии связаны с картами (`Land.msh`/`Land.map`) и объектными категориями.
|
||||||
|
2. Скриптовые ресурсы хранятся в архивных контейнерах (`NRes`) и участвуют в runtime-логике.
|
||||||
|
3. Миссионные события влияют на AI и поведение объектов через общий gameplay-слой.
|
||||||
|
|
||||||
|
## 2. Минимальный runtime-контракт
|
||||||
|
|
||||||
|
1. Детерминированный порядок обработки триггеров в кадре.
|
||||||
|
2. Единая шкала времени миссии для всех подсистем.
|
||||||
|
3. Согласованность идентификаторов объектов между mission-data и world-state.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- связь миссионной подсистемы с форматом ресурсов и runtime-контуром.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация форматов миссионных скриптов/таблиц.
|
||||||
|
2. Полный перечень типов триггеров и их параметров.
|
||||||
|
3. Формальные правила разрешения конфликтов триггеров в одном кадре.
|
||||||
|
4. Набор replay parity-тестов «миссия от старта до завершения».
|
||||||
|
## 4. Mission -> Prototype -> Mesh bridge
|
||||||
|
|
||||||
|
Для 3D-объектов миссии обязательна промежуточная стадия `objects.rlb`:
|
||||||
|
|
||||||
|
1. `data.tma` задаёт либо прямой ключ объекта, либо путь к `*.dat`.
|
||||||
|
2. `*.dat` даёт `model_key` (в retail-наборе через `objects.rlb`).
|
||||||
|
3. Ключ резолвится в запись прототипа внутри `objects.rlb`.
|
||||||
|
4. Из прототипа выбирается фактический `*.msh` и архив (например `bases.rlb`, `static.rlb`, `fortif.rlb`).
|
||||||
|
5. Только после этого запускается стандартная цепочка материалов и текстур.
|
||||||
|
|
||||||
|
Детальный формат и алгоритм вынесены в отдельную страницу:
|
||||||
|
|
||||||
|
- [Object registry (`objects.rlb`)](object-registry.md)
|
||||||
126
docs/specs/msh-animation.md
Normal file
126
docs/specs/msh-animation.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# MSH animation
|
||||||
|
|
||||||
|
`MSH animation` описывает связку `Res8 + Res19` и runtime-правила сэмплирования/смешивания поз.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [MSH core](msh-core.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## 1. Ресурсы анимации
|
||||||
|
|
||||||
|
### 1.1. `Res8` (пул ключей)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct AnimKey24 {
|
||||||
|
float pos_x;
|
||||||
|
float pos_y;
|
||||||
|
float pos_z;
|
||||||
|
float time;
|
||||||
|
int16_t qx;
|
||||||
|
int16_t qy;
|
||||||
|
int16_t qz;
|
||||||
|
int16_t qw;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Декодирование quaternion-компонент: `q = s16 / 32767.0`.
|
||||||
|
|
||||||
|
### 1.2. `Res19` (карта кадров)
|
||||||
|
|
||||||
|
```c
|
||||||
|
uint16_t map_words[]; // size/2 элементов
|
||||||
|
```
|
||||||
|
|
||||||
|
`Res19.attr2` хранит глобальную длину таймлайна (число кадров).
|
||||||
|
|
||||||
|
### 1.3. Связь с `Res1`
|
||||||
|
|
||||||
|
Для каждого узла:
|
||||||
|
|
||||||
|
- `anim_map_start` (`hdr2`) — начало блока в `Res19` или `0xFFFF`.
|
||||||
|
- `fallback_key` (`hdr3`) — индекс fallback-ключа в `Res8`.
|
||||||
|
|
||||||
|
## 2. Сэмплирование узла
|
||||||
|
|
||||||
|
Вход: время `t`, текущий узел.
|
||||||
|
Выход: `quat(w,x,y,z)` и `pos(x,y,z)`.
|
||||||
|
|
||||||
|
### 2.1. Индекс кадра
|
||||||
|
|
||||||
|
Движок использует x87-совместимое округление для выражения `t - 0.5`.
|
||||||
|
Для 1:1 повторения нужно сохранить ту же политику плавающей точки.
|
||||||
|
|
||||||
|
### 2.2. Выбор key index
|
||||||
|
|
||||||
|
1. Если кадр вне диапазона `frame_count` -> `fallback_key`.
|
||||||
|
2. Если `anim_map_start == 0xFFFF` -> `fallback_key`.
|
||||||
|
3. Иначе берётся `map_words[anim_map_start + frame]`:
|
||||||
|
- если значение `>= fallback_key`, тоже используется `fallback_key`;
|
||||||
|
- иначе используется значение из map.
|
||||||
|
|
||||||
|
### 2.3. Интерполяция
|
||||||
|
|
||||||
|
Если выбран fallback, возвращается ровно этот ключ без интерполяции.
|
||||||
|
|
||||||
|
Иначе:
|
||||||
|
|
||||||
|
1. Берутся соседние ключи `k0` и `k1`.
|
||||||
|
2. Если `t` точно равен `k0.time` или `k1.time`, возвращается соответствующий ключ.
|
||||||
|
3. Иначе:
|
||||||
|
- `alpha = (t - k0.time) / (k1.time - k0.time)`
|
||||||
|
- `pos = lerp(k0.pos, k1.pos, alpha)`
|
||||||
|
- `quat = slerp_like(k0.quat, k1.quat, alpha)`
|
||||||
|
|
||||||
|
Кватернион в runtime хранится в порядке `[w, x, y, z]`.
|
||||||
|
|
||||||
|
## 3. Смешивание двух сэмплов
|
||||||
|
|
||||||
|
При blending между позами A и B:
|
||||||
|
|
||||||
|
1. Выбираются валидные стороны по `blend` и валидности времени.
|
||||||
|
2. Если активна одна сторона, берётся она.
|
||||||
|
3. Если активны обе:
|
||||||
|
- применяется shortest-path flip для `qB`;
|
||||||
|
- выполняется quaternion blend;
|
||||||
|
- позиция смешивается линейно.
|
||||||
|
|
||||||
|
Матрица строится из quaternion, а translation подставляется отдельным шагом.
|
||||||
|
|
||||||
|
## 4. Каноника writer
|
||||||
|
|
||||||
|
Рекомендуемые правила:
|
||||||
|
|
||||||
|
1. Ключи узлов писать подряд в `Res8` в порядке узлов.
|
||||||
|
2. `fallback_key` узла указывает на последний ключ его трека.
|
||||||
|
3. Для узлов с map выделять блок длины `frame_count` в `Res19`.
|
||||||
|
4. Для статических узлов: `anim_map_start = 0xFFFF`, один ключ с `time=0`.
|
||||||
|
5. `Res8.attr1 = key_count`, `Res8.attr3 = 4`.
|
||||||
|
6. `Res19.attr1 = map_word_count`, `Res19.attr2 = frame_count`, `Res19.attr3 = 2`.
|
||||||
|
|
||||||
|
## 5. Валидация перед сохранением
|
||||||
|
|
||||||
|
- `Res8.size % 24 == 0`
|
||||||
|
- `Res19.size % 2 == 0`
|
||||||
|
- каждый `fallback_key < key_count`
|
||||||
|
- для узла с map: `anim_map_start + frame_count <= map_word_count`
|
||||||
|
- внутри трека времена ключей строго возрастают
|
||||||
|
|
||||||
|
## 6. Статус валидации
|
||||||
|
|
||||||
|
- Форматные проверки включены в `tools/msh_doc_validator.py`.
|
||||||
|
- Корпусная валидация анимационных инвариантов включена в прогон `tools/msh_doc_validator.py` на полном retail-наборе.
|
||||||
|
|
||||||
|
## 7. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Контракт `Res8 + Res19` и fallback-логика выбора ключа.
|
||||||
|
2. Базовая интерполяция поз и blending двух сэмплов.
|
||||||
|
3. Канонические инварианты writer path для существующих ассетов.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная фиксация численного поведения на всех FP-edge-case (включая платформенные различия округления).
|
||||||
|
2. Полный writer-профиль для авторинга новых анимаций без опоры на reference copy-through.
|
||||||
|
3. Набор runtime parity-тестов «frame-by-frame pose equivalence» на длинных анимациях.
|
||||||
193
docs/specs/msh-core.md
Normal file
193
docs/specs/msh-core.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# MSH core
|
||||||
|
|
||||||
|
`MSH core` описывает геометрию, слоты, батчи и базовые таблицы модели.
|
||||||
|
Документ покрывает контракт, необходимый для 1:1 воспроизведения рендера и коллизии.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [MSH animation](msh-animation.md)
|
||||||
|
- [Material](material.md)
|
||||||
|
- [Texture (Texm)](texture.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
- [NRes](nres.md)
|
||||||
|
- [RsLi](rsli.md)
|
||||||
|
|
||||||
|
## 1. Общая модель
|
||||||
|
|
||||||
|
MSH-модель хранится как `NRes`-контейнер.
|
||||||
|
Связь таблиц строится по `type`, а не по порядку записей.
|
||||||
|
|
||||||
|
Базовый путь геометрии:
|
||||||
|
|
||||||
|
1. `Res1` выбирает slot по `(node, lod, group)`.
|
||||||
|
2. `Res2.slot` задаёт диапазоны треугольников и батчей.
|
||||||
|
3. `Res13` задаёт диапазон индексов и `baseVertex`.
|
||||||
|
4. `Res6` даёт `uint16` индексы.
|
||||||
|
5. `Res3/Res4/Res5` дают вершины, нормали и UV.
|
||||||
|
|
||||||
|
## 2. Карта core-ресурсов
|
||||||
|
|
||||||
|
| Type | Ресурс | Обязательность | Stride / layout |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 1 | Node table | обязательный | обычно 38 байт |
|
||||||
|
| 2 | Header + slots | обязательный | `0x8C + n*68` |
|
||||||
|
| 3 | Positions | обязательный | 12 |
|
||||||
|
| 4 | Packed normals | обычно обязательный | 4 |
|
||||||
|
| 5 | Packed UV0 | обычно обязательный | 4 |
|
||||||
|
| 6 | Index buffer | обязательный | 2 |
|
||||||
|
| 7 | Tri descriptors | для коллизии/пикинга | 16 |
|
||||||
|
| 8 | Anim key pool | для анимированных | 24 |
|
||||||
|
| 10 | Node strings | опциональный | variable |
|
||||||
|
| 13 | Batch table | обязательный | 20 |
|
||||||
|
| 15 | Доп. stream | опциональный | 8 |
|
||||||
|
| 16 | Доп. stream | опциональный | 8 |
|
||||||
|
| 18 | Доп. stream | опциональный | 4 |
|
||||||
|
| 19 | Anim map | для анимированных | 2 |
|
||||||
|
| 20 | Доп. таблица | опциональный | variable |
|
||||||
|
|
||||||
|
## 3. Основные структуры
|
||||||
|
|
||||||
|
### 3.1. `Res1` (узлы)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Node38 {
|
||||||
|
uint16_t hdr0;
|
||||||
|
uint16_t parent_or_link;
|
||||||
|
uint16_t anim_map_start;
|
||||||
|
uint16_t fallback_key;
|
||||||
|
uint16_t slotIndex[15]; // lod0:g0..g4, lod1:g0..g4, lod2:g0..g4
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Формула slot-выбора:
|
||||||
|
|
||||||
|
```c
|
||||||
|
slot = node.slotIndex[lod * 5 + group]
|
||||||
|
```
|
||||||
|
|
||||||
|
`0xFFFF` означает отсутствие слота.
|
||||||
|
|
||||||
|
### 3.2. `Res2` (header + slot records)
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Slot68 {
|
||||||
|
uint16_t triStart;
|
||||||
|
uint16_t triCount;
|
||||||
|
uint16_t batchStart;
|
||||||
|
uint16_t batchCount;
|
||||||
|
float aabbMin[3];
|
||||||
|
float aabbMax[3];
|
||||||
|
float sphereCenter[3];
|
||||||
|
float sphereRadius;
|
||||||
|
uint32_t opaque[5];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`opaque[5]` должны сохраняться 1:1.
|
||||||
|
|
||||||
|
### 3.3. `Res3`, `Res4`, `Res5`, `Res6`
|
||||||
|
|
||||||
|
- `Res3`: `float3` позиции (`stride=12`)
|
||||||
|
- `Res4`: `int8[4]` packed normal (`stride=4`)
|
||||||
|
- `Res5`: `int16[2]` UV (`stride=4`)
|
||||||
|
- `Res6`: `uint16` индексы (`stride=2`)
|
||||||
|
|
||||||
|
Декодирование:
|
||||||
|
|
||||||
|
- normal = `clamp(n / 127.0, -1..1)`
|
||||||
|
- uv = `packed / 1024.0`
|
||||||
|
|
||||||
|
### 3.4. `Res7` и `Res13`
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct TriDesc16 {
|
||||||
|
uint16_t triFlags;
|
||||||
|
uint16_t link0;
|
||||||
|
uint16_t link1;
|
||||||
|
uint16_t link2;
|
||||||
|
int16_t nx;
|
||||||
|
int16_t ny;
|
||||||
|
int16_t nz;
|
||||||
|
uint16_t selPacked;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Batch20 {
|
||||||
|
uint16_t batchFlags;
|
||||||
|
uint16_t materialIndex;
|
||||||
|
uint16_t opaque4;
|
||||||
|
uint16_t opaque6;
|
||||||
|
uint16_t indexCount;
|
||||||
|
uint32_t indexStart;
|
||||||
|
uint16_t opaque14;
|
||||||
|
uint32_t baseVertex;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`selPacked` хранит 3 селектора по 2 бита; значение `3` трактуется как `0xFFFF`.
|
||||||
|
|
||||||
|
## 4. Runtime-обход модели
|
||||||
|
|
||||||
|
Псевдокод рендера:
|
||||||
|
|
||||||
|
```c
|
||||||
|
for each node:
|
||||||
|
slot = resolve_slot(node, lod, group)
|
||||||
|
if slot == none: continue
|
||||||
|
|
||||||
|
if culled(slot.bounds, node_transform): continue
|
||||||
|
|
||||||
|
for b in slot.batchRange:
|
||||||
|
batch = batches[b]
|
||||||
|
bind_material(batch.materialIndex)
|
||||||
|
|
||||||
|
draw_indexed(
|
||||||
|
baseVertex = batch.baseVertex,
|
||||||
|
indexStart = batch.indexStart,
|
||||||
|
indexCount = batch.indexCount
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Критические инварианты
|
||||||
|
|
||||||
|
Обязательно проверять:
|
||||||
|
|
||||||
|
- `Res2.size >= 0x8C`
|
||||||
|
- `(Res2.size - 0x8C) % 68 == 0`
|
||||||
|
- `batchStart + batchCount` не выходит за `Res13`
|
||||||
|
- `triStart + triCount` не выходит за `Res7`
|
||||||
|
- `indexStart + indexCount` не выходит за `Res6`
|
||||||
|
- `baseVertex + max(indexSlice) < vertexCount`
|
||||||
|
- `slotIndex == 0xFFFF` или `< slotCount`
|
||||||
|
|
||||||
|
## 6. Важные edge-cases
|
||||||
|
|
||||||
|
- Встречается редкий вариант `Res1.attr3 = 24`; для существующих ассетов нужен copy-through.
|
||||||
|
- Для строгого writer лучше генерировать `Res1` в основном формате `38` байт/узел.
|
||||||
|
- Неизвестные поля таблиц нельзя нормализовать или обнулять.
|
||||||
|
|
||||||
|
## 7. Правила для writer/editor
|
||||||
|
|
||||||
|
1. Сохранять неизвестные поля и неизвестные `type`-ресурсы.
|
||||||
|
2. Пересчитывать только явно вычислимые атрибуты (`attr1/attr3` и size-зависимые поля).
|
||||||
|
3. Не менять порядок/контент opaque-данных без явной цели.
|
||||||
|
4. Сериализовать little-endian, без внутреннего padding.
|
||||||
|
|
||||||
|
## 8. Статус валидации
|
||||||
|
|
||||||
|
- Инварианты формата реализованы в `tools/msh_doc_validator.py`.
|
||||||
|
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `435/435` MSH-моделей без структурных ошибок.
|
||||||
|
|
||||||
|
## 9. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Базовые таблицы geometry path (`Res1/2/3/4/5/6/7/13`).
|
||||||
|
2. Критичные range-инварианты slot/batch/index.
|
||||||
|
3. Правила совместимого writer/editor для lossless работы с существующими ассетами.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная семантика части opaque-полей (`Slot68` tail, `Batch20` opaque-поля) для authoring без copy-through.
|
||||||
|
2. Полная формализация редких веток (`Res1.attr3 != 38`) на расширенном корпусе.
|
||||||
|
3. End-to-end writer для генерации новых игровых MSH с подтвержденным runtime-паритетом.
|
||||||
|
|
||||||
118
docs/specs/msh-notes.md
Normal file
118
docs/specs/msh-notes.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# 3D implementation notes
|
||||||
|
|
||||||
|
Контрольная страница с практическими правилами реализации 3D-пайплайна и с перечнем незакрытых зон.
|
||||||
|
Документ intentionally high-level: без ссылок на внутренние функции/адреса.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [MSH core](msh-core.md)
|
||||||
|
- [MSH animation](msh-animation.md)
|
||||||
|
- [Material (`MAT0`)](material.md)
|
||||||
|
- [Texture (`Texm`)](texture.md)
|
||||||
|
- [FXID](fxid.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## 1. Базовые двоичные правила
|
||||||
|
|
||||||
|
1. Все форматы в этой подсистеме little-endian.
|
||||||
|
2. Внутри NRes данные ресурсов выравниваются по 8 байт.
|
||||||
|
3. Внутри payload таблиц padding между записями обычно отсутствует: записи идут подряд по stride.
|
||||||
|
|
||||||
|
## 2. Быстрая карта stride'ов
|
||||||
|
|
||||||
|
| Ресурс | Запись | Stride |
|
||||||
|
|---|---|---:|
|
||||||
|
| Res1 | Node | 38 |
|
||||||
|
| Res2 | Slot | 68 (после header `0x8C`) |
|
||||||
|
| Res3 | Position | 12 |
|
||||||
|
| Res4 | Normal | 4 |
|
||||||
|
| Res5 | UV0 | 4 |
|
||||||
|
| Res6 | Index | 2 |
|
||||||
|
| Res7 | Tri descriptor | 16 |
|
||||||
|
| Res8 | Animation key | 24 |
|
||||||
|
| Res13 | Batch | 20 |
|
||||||
|
| Res19 | Animation map | 2 |
|
||||||
|
|
||||||
|
## 3. Декодирование ключевых потоков
|
||||||
|
|
||||||
|
## 3.1. Позиции (Res3)
|
||||||
|
|
||||||
|
`float3`, stride `12`.
|
||||||
|
|
||||||
|
## 3.2. Нормали (Res4)
|
||||||
|
|
||||||
|
`int8[4]`, используются первые 3 компоненты:
|
||||||
|
|
||||||
|
```text
|
||||||
|
n = clamp(s8 / 127.0, -1..1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.3. UV (Res5)
|
||||||
|
|
||||||
|
`int16[2]`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
u = s16 / 1024.0
|
||||||
|
v = s16 / 1024.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3.4. Animation key (Res8)
|
||||||
|
|
||||||
|
`pos(float3) + time(float) + quat(int16x4)`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
q = s16 / 32767.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Практический reader-контракт
|
||||||
|
|
||||||
|
Для runtime-совместимого чтения модели:
|
||||||
|
|
||||||
|
1. Найти нужные ресурсы по `type_id` в NRes.
|
||||||
|
2. Проверить `size/stride`-инварианты.
|
||||||
|
3. Проверить диапазоны ссылок:
|
||||||
|
- slot -> batch/triangles;
|
||||||
|
- batch -> indices;
|
||||||
|
- indices -> vertices;
|
||||||
|
- anim_map -> anim_keys.
|
||||||
|
4. Неизвестные поля и неизвестные ресурсы сохранять через copy-through.
|
||||||
|
|
||||||
|
## 5. Практический writer-контракт
|
||||||
|
|
||||||
|
1. Пересчитывать только явно вычислимые поля.
|
||||||
|
2. Не нормализовать opaque-данные без уверенной спецификации.
|
||||||
|
3. При roundtrip неизмененных данных требовать byte-identical результат.
|
||||||
|
4. Для новых ассетов фиксировать отдельную политику «генерация vs preserve».
|
||||||
|
|
||||||
|
## 6. Runtime-связка материалов и текстур
|
||||||
|
|
||||||
|
Канонический путь резолва:
|
||||||
|
|
||||||
|
1. Модель -> wear-таблица (`*.wea`).
|
||||||
|
2. Wear-слот -> material name.
|
||||||
|
3. Material -> текущая фаза -> `textureName`.
|
||||||
|
4. `Texm` ищется в `Textures.lib` (или lightmap-библиотеке для lightmap-ветки).
|
||||||
|
|
||||||
|
Fallback:
|
||||||
|
|
||||||
|
- материал: `DEFAULT`, затем индекс `0`;
|
||||||
|
- текстура/lightmap: fallback-слот движка.
|
||||||
|
|
||||||
|
## 7. Что уже закрыто для 1:1
|
||||||
|
|
||||||
|
1. Бинарный контракт базовых MSH таблиц.
|
||||||
|
2. Контракт animation sampling (`Res8 + Res19`).
|
||||||
|
3. Контракт MAT0/WEAR/Texm на уровне чтения и применения в кадре.
|
||||||
|
4. Формат FXID-контейнера, командный поток и fixed command sizes.
|
||||||
|
5. Валидация на retail-корпусе через `tools/msh_doc_validator.py` (0 ошибок/предупреждений).
|
||||||
|
|
||||||
|
## 8. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
1. Полная field-level семантика части служебных полей:
|
||||||
|
- `Batch20` opaque-поля;
|
||||||
|
- хвостовые служебные поля slot-записей;
|
||||||
|
- часть флагов узлов/групп.
|
||||||
|
2. Полный writer-путь для авторинга новых анимированных ассетов (не только roundtrip существующих).
|
||||||
|
3. Полная формализация семантики FX payload полей по каждому opcode для генерации новых эффектов, а не только для корректного чтения/исполнения.
|
||||||
|
4. Полный канонический writer `Texm` для всех редких форматов и edge-case комбинаций служебных флагов.
|
||||||
|
5. Сквозной «импорт внешнего ассета -> игровой пакет» с формальной спецификацией sidecar-метаданных (материал/эффект/анимация).
|
||||||
@@ -1,314 +1,39 @@
|
|||||||
# 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. [Material (`MAT0`)](material.md) — формат материала и фазовая анимация.
|
||||||
|
4. [Wear (`WEAR`)](wear.md) — текстовая таблица привязки материалов/lightmap.
|
||||||
|
5. [Texture (`Texm`)](texture.md) — форматы текстур, mip-chain и `Page`.
|
||||||
|
6. [FXID](fxid.md) — контейнер эффекта и поток команд.
|
||||||
|
7. [Render pipeline](render.md) — полный процесс рендера кадра.
|
||||||
|
8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
|
||||||
|
9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы.
|
||||||
|
10. [Documentation coverage audit](coverage-audit.md) — сводка покрытия и оставшиеся блокеры.
|
||||||
|
|
||||||
- **Модель** — набор геометрии + иерархия узлов (node/bone) + дополнительные таблицы (батчи/слоты/треки).
|
## Связанные спецификации
|
||||||
- **Node** — узел иерархии (часть/кость). Визуально: “кусок” модели, которому можно применять transform (rigid).
|
|
||||||
- **LOD** — уровень детализации. В коде обнаружены **3 уровня LOD: 0..2** (и “текущий” LOD через `-1`).
|
|
||||||
- **Slot** — связка “(node, LOD, group) → диапазоны геометрии + bounds”.
|
|
||||||
- **Batch** — рендер‑пакет: “материал + диапазон индексов + baseVertex”.
|
|
||||||
|
|
||||||
---
|
- [NRes](nres.md)
|
||||||
|
- [RsLi](rsli.md)
|
||||||
|
|
||||||
## 1) Архитектура модели в движке (как это реально рисуется)
|
## Принцип декомпозиции
|
||||||
|
|
||||||
### 1.1 Рендер‑модель: rigid‑скининг (по узлам), без весов вершин
|
- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
|
||||||
|
- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько runtime-подсистем и не является форматом на диске.
|
||||||
|
|
||||||
По коду выборка геометрии делается так:
|
## Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
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/часть привязан к одному узлу (или группе узлов) и рендерится с матрицей этого узла.
|
1. Документация декомпозирована по объектам: geometry, animation, material, texture, wear, fx, render, terrain.
|
||||||
|
2. Форматные инварианты ключевых 3D-ресурсов проверяются автоматическими валидаторами на retail-корпусе.
|
||||||
|
|
||||||
---
|
Осталось:
|
||||||
|
|
||||||
## 2) Набор ресурсов модели (что лежит внутри “файла модели”)
|
1. Полный сквозной writer-путь для генерации новых игровых ассетов без copy-through зависимостей.
|
||||||
|
2. Полный паритетный рендер-тест (эталонные кадры оригинала vs новый рендер) на расширенном наборе моделей/материалов/FX.
|
||||||
Ниже перечислены ресурсы, которые гарантированно встречаются в загрузчике `AniMesh`:
|
3. Полное покрытие соседних геймплейных подсистем (`AI`, `Behavior`, `Missions`, `Control`, `UI`, `Sound`, `Network`) до уровня точных форматов и runtime-контрактов.
|
||||||
|
|
||||||
- **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) с материалами/узлами (кто именно как индексирует строки).
|
|
||||||
|
|||||||
28
docs/specs/network.md
Normal file
28
docs/specs/network.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Network system
|
||||||
|
|
||||||
|
`Network` — подсистема синхронизации состояния игры между узлами (мультиплеер/обмен состоянием).
|
||||||
|
|
||||||
|
## 1. Роль
|
||||||
|
|
||||||
|
1. Транспортирует игровые события и state-delta.
|
||||||
|
2. Синхронизирует критичные объекты мира и таймеры.
|
||||||
|
3. Обеспечивает согласованность simulation между участниками.
|
||||||
|
|
||||||
|
## 2. Минимальный контракт для 1:1
|
||||||
|
|
||||||
|
1. Детеминированная сериализация сетевых сообщений.
|
||||||
|
2. Согласованная обработка порядка/потерь/повторов пакетов.
|
||||||
|
3. Единая политика authority и коррекции расхождений.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- определено место сетевого слоя в общей архитектуре движка.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация wire-протокола (header, message types, payload layout).
|
||||||
|
2. Полный контракт handshake/session lifecycle.
|
||||||
|
3. Формальные правила resync/rollback/correction.
|
||||||
|
4. Набор сетевых parity-тестов на контролируемой потере/задержке.
|
||||||
@@ -1,718 +1,202 @@
|
|||||||
# Форматы игровых ресурсов
|
# NRes
|
||||||
|
|
||||||
## Обзор
|
`NRes` — базовый контейнер ресурсов движка Parkan: Iron Strategy.
|
||||||
|
Страница фиксирует формат на диске и runtime-контракт чтения/поиска/сохранения в высокоуровневом виде, без привязки к внутренним адресам и именам из дизассемблера.
|
||||||
|
|
||||||
Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
|
Связанная страница:
|
||||||
|
|
||||||
1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
|
- [RsLi](rsli.md)
|
||||||
|
|
||||||
2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
|
## 1. Назначение
|
||||||
|
|
||||||
---
|
`NRes` используется как универсальный архив:
|
||||||
|
|
||||||
# Часть 1. Формат NRes
|
- 3D-модели (`*.msh`, `*.rlb`);
|
||||||
|
- текстуры (`Texm`);
|
||||||
|
- материалы (`MAT0`);
|
||||||
|
- эффекты (`FXID`);
|
||||||
|
- миссионные и служебные ресурсы.
|
||||||
|
|
||||||
## 1.1. Общая структура файла
|
Формат поддерживает:
|
||||||
|
|
||||||
```
|
- чтение;
|
||||||
┌──────────────────────────┐ Смещение 0
|
- поиск по имени;
|
||||||
│ Заголовок (16 байт) │
|
- редактирование (add/replace/remove);
|
||||||
├──────────────────────────┤ Смещение 16
|
- полную пересборку архива.
|
||||||
│ │
|
|
||||||
│ Данные ресурсов │
|
## 2. Общий layout файла
|
||||||
│ (выровнены по 8 байт) │
|
|
||||||
│ │
|
```text
|
||||||
├──────────────────────────┤ Смещение = total_size - entry_count × 64
|
[Header: 16]
|
||||||
│ Каталог записей │
|
[Data region: variable, 8-byte aligned chunks]
|
||||||
│ (entry_count × 64 байт) │
|
[Directory: entry_count * 64, всегда в конце файла]
|
||||||
└──────────────────────────┘ Смещение = total_size
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 1.2. Заголовок файла (16 байт)
|
Критично: каталог всегда расположен в конце файла.
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Значение | Описание |
|
## 3. Заголовок (16 байт)
|
||||||
| -------- | ------ | ------- | ------------------- | ------------------------------------ |
|
|
||||||
| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
|
|
||||||
| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
|
|
||||||
| 8 | 4 | int32 | — | Количество записей в каталоге |
|
|
||||||
| 12 | 4 | int32 | — | Полный размер файла в байтах |
|
|
||||||
|
|
||||||
**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
|
Все значения little-endian.
|
||||||
|
|
||||||
## 1.3. Положение каталога в файле
|
| Offset | Size | Type | Значение |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 0 | 4 | char[4] | `NRes` |
|
||||||
|
| 4 | 4 | u32 | `0x00000100` (версия 1.0) |
|
||||||
|
| 8 | 4 | i32 | `entry_count` (должен быть `>= 0`) |
|
||||||
|
| 12 | 4 | u32 | `total_size` (должен быть равен фактическому размеру файла) |
|
||||||
|
|
||||||
Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
|
Производные значения:
|
||||||
|
|
||||||
```
|
- `directory_size = entry_count * 64`;
|
||||||
directory_offset = total_size - entry_count × 64
|
- `directory_offset = total_size - directory_size`.
|
||||||
```
|
|
||||||
|
|
||||||
Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
|
Ограничения:
|
||||||
|
|
||||||
## 1.4. Запись каталога (64 байта)
|
- `directory_offset >= 16`;
|
||||||
|
- `directory_offset + directory_size == total_size`.
|
||||||
|
|
||||||
Каждая запись каталога занимает ровно **64 байта** (0x40):
|
## 4. Запись каталога (64 байта)
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Описание |
|
| Offset | Size | Type | Поле |
|
||||||
| -------- | ------ | -------- | ------------------------------------------------- |
|
|---:|---:|---|---|
|
||||||
| 0 | 4 | uint32 | Тип / идентификатор ресурса |
|
| 0 | 4 | u32 | `type_id` |
|
||||||
| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) |
|
| 4 | 4 | u32 | `attr1` |
|
||||||
| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) |
|
| 8 | 4 | u32 | `attr2` |
|
||||||
| 12 | 4 | uint32 | Размер данных ресурса в байтах |
|
| 12 | 4 | u32 | `size` (размер payload) |
|
||||||
| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) |
|
| 16 | 4 | u32 | `attr3` |
|
||||||
| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) |
|
| 20 | 36 | char[36] | `name_raw` (C-строка) |
|
||||||
| 56 | 4 | uint32 | Смещение данных от начала файла |
|
| 56 | 4 | u32 | `data_offset` |
|
||||||
| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
|
| 60 | 4 | u32 | `sort_index` |
|
||||||
|
|
||||||
### Поле «Имя файла» (смещение 20, 36 байт)
|
### 4.1. Имя ресурса (`name_raw`)
|
||||||
|
|
||||||
- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
|
Контракт:
|
||||||
- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
|
|
||||||
- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
|
|
||||||
|
|
||||||
### Поле «Индекс сортировки» (смещение 60)
|
- максимум 35 полезных байт + NUL;
|
||||||
|
- допускается ровно один терминатор внутри 36-байтового поля;
|
||||||
|
- имя сравнивается регистронезависимо по ASCII-правилу (`A..Z` -> `a..z`).
|
||||||
|
|
||||||
Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
|
Для writer/editor:
|
||||||
|
|
||||||
**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
|
- запрещено писать NUL внутри полезной части имени;
|
||||||
|
- запрещены имена длиной > 35 байт.
|
||||||
|
|
||||||
### Поле «Смещение данных» (смещение 56)
|
### 4.2. Диапазон данных (`data_offset`, `size`)
|
||||||
|
|
||||||
Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
|
Для каждой записи:
|
||||||
|
|
||||||
## 1.5. Выравнивание данных
|
- `data_offset >= 16`;
|
||||||
|
- `data_offset + size <= directory_offset`.
|
||||||
|
|
||||||
При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
|
Практически (канонический writer): каждый payload начинается с 8-байтного выравнивания.
|
||||||
|
|
||||||
```c
|
## 5. Таблица сортировки (`sort_index`)
|
||||||
padding = ((data_size + 7) & ~7) - data_size;
|
|
||||||
// Если padding > 0, записываются нулевые байты
|
|
||||||
```
|
|
||||||
|
|
||||||
Таким образом, каждый блок данных начинается с адреса, кратного 8.
|
`sort_index` задает перестановку «отсортированный список -> исходный индекс записи».
|
||||||
|
|
||||||
При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
|
Пусть:
|
||||||
|
|
||||||
## 1.6. Создание файла (API `niCreateResFile`)
|
- `entries[i]` — i-я запись каталога в исходном порядке;
|
||||||
|
- `P` — массив индексов `0..entry_count-1`, отсортированный по `entries[idx].name` (ASCII case-insensitive).
|
||||||
|
|
||||||
При создании нового файла:
|
Тогда в канонической записи:
|
||||||
|
|
||||||
1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
|
- `entries[i].sort_index = P[i]`.
|
||||||
2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
|
|
||||||
|
|
||||||
При закрытии файла (`sub_100122D0`):
|
Это именно таблица для бинарного поиска по имени, а не «ранг текущей записи».
|
||||||
|
|
||||||
1. Заголовок переписывается в начало файла (16 байт).
|
## 6. Поиск по имени
|
||||||
2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
|
|
||||||
3. Индексы сортировки пересчитываются.
|
|
||||||
4. Каталог записей записывается в конец файла.
|
|
||||||
|
|
||||||
## 1.7. Режимы сортировки каталога
|
Алгоритм поиска:
|
||||||
|
|
||||||
Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
|
1. Выполнить бинарный поиск по диапазону `i in [0, entry_count)`.
|
||||||
|
2. На шаге `i` взять `target = entries[i].sort_index`.
|
||||||
|
3. Сравнить искомое имя с `entries[target].name` (ASCII case-insensitive).
|
||||||
|
4. При совпадении вернуть `target`.
|
||||||
|
|
||||||
| Режим | Порядок сортировки |
|
Fail-safe поведение:
|
||||||
| ----- | --------------------------------- |
|
|
||||||
| 0 | Без сортировки (сброс) |
|
|
||||||
| 1 | По атрибуту 1 (смещение 4) |
|
|
||||||
| 2 | По атрибуту 2 (смещение 8) |
|
|
||||||
| 3 | По (атрибут 1, атрибут 2) |
|
|
||||||
| 4 | По типу ресурса (смещение 0) |
|
|
||||||
| 5 | По (тип, атрибут 1) |
|
|
||||||
| 6 | По (тип, атрибут 1) — идентичен 5 |
|
|
||||||
| 7 | По (тип, атрибут 1, атрибут 2) |
|
|
||||||
| 8 | По имени (регистронезависимо) |
|
|
||||||
| 9 | По (тип, имя) |
|
|
||||||
| 10 | По (атрибут 1, имя) |
|
|
||||||
| 11 | По (атрибут 2, имя) |
|
|
||||||
|
|
||||||
## 1.8. Операция `niOpenResFileEx` — флаги открытия
|
- если `sort_index` некорректен (выход за диапазон), реализация должна перейти на линейный fallback по всем записям;
|
||||||
|
- fallback использует то же ASCII case-insensitive сравнение.
|
||||||
|
|
||||||
Второй параметр — битовые флаги:
|
## 7. Каноническая пересборка архива
|
||||||
|
|
||||||
| Бит | Маска | Описание |
|
Канонический writer выполняет:
|
||||||
| --- | ----- | ----------------------------------------------------------------------------------- |
|
|
||||||
| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) |
|
|
||||||
| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение |
|
|
||||||
| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
|
|
||||||
| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
|
|
||||||
|
|
||||||
## 1.9. Виртуальное касание страниц
|
1. Пишет заглушку заголовка (16 байт).
|
||||||
|
2. Пишет payload всех записей в текущем порядке.
|
||||||
|
3. После каждого payload добавляет 0-padding до кратности 8.
|
||||||
|
4. Пересчитывает `sort_index` через сортировку имен.
|
||||||
|
5. Дописывает каталог (`entry_count * 64`).
|
||||||
|
6. Пересчитывает и записывает `total_size`.
|
||||||
|
|
||||||
Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
|
Итоговый файл должен удовлетворять всем ограничениям из разделов 3–5.
|
||||||
|
|
||||||
```
|
## 8. Режим `raw` (совместимость инструментов)
|
||||||
for (result = 0x10000; result < size; result += 4096);
|
|
||||||
```
|
|
||||||
|
|
||||||
Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
|
Для служебных инструментов допускается `raw_mode`:
|
||||||
|
|
||||||
---
|
- любой бинарный файл трактуется как один «сырой» ресурс;
|
||||||
|
- возвращается одна запись (`name = RAW`, `data_offset = 0`, `size = len(file)`).
|
||||||
|
|
||||||
# Часть 2. Формат RsLi
|
Этот режим не является форматом `NRes` на диске, это только режим открытия.
|
||||||
|
|
||||||
## 2.1. Общая структура файла
|
## 9. Контрольные инварианты
|
||||||
|
|
||||||
```
|
Минимальный набор проверок при чтении:
|
||||||
┌───────────────────────────────┐ Смещение 0
|
|
||||||
│ Заголовок файла (32 байта) │
|
|
||||||
├───────────────────────────────┤ Смещение 32
|
|
||||||
│ Таблица записей (зашифрована)│
|
|
||||||
│ (entry_count × 32 байт) │
|
|
||||||
├───────────────────────────────┤ Смещение 32 + entry_count × 32
|
|
||||||
│ │
|
|
||||||
│ Данные ресурсов │
|
|
||||||
│ │
|
|
||||||
├───────────────────────────────┤
|
|
||||||
│ [Опциональный трейлер — 6 б] │
|
|
||||||
└───────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2.2. Заголовок файла (32 байта)
|
1. `magic == "NRes"`.
|
||||||
|
2. `version == 0x100`.
|
||||||
|
3. `entry_count >= 0`.
|
||||||
|
4. `header.total_size == file_size`.
|
||||||
|
5. Каталог находится в конце файла.
|
||||||
|
6. Для каждой записи диапазон данных не пересекает каталог.
|
||||||
|
7. Имя корректно C-терминировано и не длиннее 35 байт.
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Значение | Описание |
|
Минимальный набор проверок при записи:
|
||||||
| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
|
|
||||||
| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура |
|
|
||||||
| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) |
|
|
||||||
| 3 | 1 | uint8 | `0x01` | Версия формата |
|
|
||||||
| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) |
|
|
||||||
| 6 | 8 | — | — | Зарезервировано / не используется |
|
|
||||||
| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) |
|
|
||||||
| 16 | 4 | — | — | Зарезервировано |
|
|
||||||
| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
|
|
||||||
| 24 | 8 | — | — | Зарезервировано |
|
|
||||||
|
|
||||||
### Флаг предсортировки (смещение 14)
|
1. Все имена <= 35 байт и без внутренних NUL.
|
||||||
|
2. `sort_index` формирует валидную перестановку `0..N-1`.
|
||||||
|
3. Все паддинги между payload состоят из нулевых байт.
|
||||||
|
4. `total_size` равен фактической длине выходного файла.
|
||||||
|
|
||||||
- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных).
|
## 10. Эмпирическая проверка на retail-корпусе
|
||||||
- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит).
|
|
||||||
|
|
||||||
## 2.3. XOR-шифр таблицы записей
|
Валидация на полном наборе `testdata/Parkan - Iron Strategy`:
|
||||||
|
|
||||||
Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
|
- найдено `120` архивов `NRes`;
|
||||||
|
- roundtrip `unpack -> repack -> byte-compare`: `120/120` совпали побайтно;
|
||||||
|
- критических расхождений формата не обнаружено.
|
||||||
|
|
||||||
### Начальное состояние
|
Инструмент:
|
||||||
|
|
||||||
```
|
- `tools/archive_roundtrip_validator.py`
|
||||||
seed = *(uint32*)(header + 20)
|
|
||||||
lo = seed & 0xFF // Младший байт
|
|
||||||
hi = (seed >> 8) & 0xFF // Второй байт
|
|
||||||
```
|
|
||||||
|
|
||||||
### Алгоритм дешифровки (побайтовый)
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
|
Закрыто:
|
||||||
|
|
||||||
```
|
- формат заголовка/каталога;
|
||||||
step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi
|
- правила поиска;
|
||||||
step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта
|
- каноническая пересборка;
|
||||||
step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo
|
- строгие инварианты валидатора;
|
||||||
```
|
- побайтовый roundtrip на retail-корпусе.
|
||||||
|
|
||||||
**Пример реализации:**
|
Осталось до полного 100% архитектурного покрытия движка:
|
||||||
|
|
||||||
```python
|
1. Формальная семантика `attr1/attr2/attr3` для всех типов ресурсов (частично вынесена в профильные страницы `msh`, `material`, `texture`, `fxid`, `terrain`).
|
||||||
def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
2. Полная спецификация поведения при не-ASCII именах (в реальных игровых архивах используется ASCII-практика; для Unicode-коллации движок не документирован).
|
||||||
lo = seed & 0xFF
|
3. Полная спецификация платформенных гарантий атомарной записи (формат данных закрыт, но OS-уровневые гарантии замены файла зависят от платформы и файловой системы).
|
||||||
hi = (seed >> 8) & 0xFF
|
## 12. Специализация `objects.rlb`
|
||||||
result = bytearray(len(encrypted_data))
|
|
||||||
for i in range(len(encrypted_data)):
|
|
||||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
|
||||||
result[i] = lo ^ encrypted_data[i]
|
|
||||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
|
||||||
return bytes(result)
|
|
||||||
```
|
|
||||||
|
|
||||||
Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
|
Хотя `objects.rlb` формально является обычным `NRes`, его payload имеет отдельный семантический контракт:
|
||||||
|
|
||||||
## 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
|
- запись каталога соответствует одному объектному прототипу;
|
||||||
|
- payload записи - массив фиксированных ссылок `ObjectRef64` (`archive_name[32] + resource_name[32]`);
|
||||||
|
- runtime-резолв меша выполняется через эти ссылки, а не через имя entry `*.msh` внутри `objects.rlb`.
|
||||||
|
|
||||||
После дешифровки каждая запись имеет следующую структуру:
|
Это означает, что `objects.rlb` должен рассматриваться не как архив мешей, а как реестр привязок между mission/unit-ключами и фактическими ресурсами.
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Описание |
|
См. детальную страницу:
|
||||||
| -------- | ------ | -------- | -------------------------------------------------------------- |
|
|
||||||
| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) |
|
|
||||||
| 12 | 4 | — | Зарезервировано (движком игнорируется) |
|
|
||||||
| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) |
|
|
||||||
| 18 | 2 | int16 | **`sort_to_original[i]` / XOR‑ключ** (см. ниже) |
|
|
||||||
| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) |
|
|
||||||
| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
|
|
||||||
| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
|
|
||||||
|
|
||||||
### Имена ресурсов
|
- [Object registry (`objects.rlb`)](object-registry.md)
|
||||||
|
|
||||||
- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
|
|
||||||
- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
|
|
||||||
- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
|
|
||||||
|
|
||||||
### Поле `sort_to_original[i]` (смещение 18)
|
|
||||||
|
|
||||||
Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
|
|
||||||
|
|
||||||
- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`.
|
|
||||||
- Тем же значением (младшие 16 бит) инициализируется XOR‑шифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2).
|
|
||||||
|
|
||||||
Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
|
|
||||||
|
|
||||||
## 2.5. Поле флагов (смещение 16 записи)
|
|
||||||
|
|
||||||
Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
|
|
||||||
|
|
||||||
```
|
|
||||||
Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования
|
|
||||||
Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Методы сжатия (биты 8–5, маска 0x1E0)
|
|
||||||
|
|
||||||
| Значение | Hex | Описание |
|
|
||||||
| -------- | ----- | --------------------------------------- |
|
|
||||||
| 0x000 | 0x00 | Без сжатия (копирование) |
|
|
||||||
| 0x020 | 0x20 | Только XOR-шифр |
|
|
||||||
| 0x040 | 0x40 | LZSS (простой вариант) |
|
|
||||||
| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) |
|
|
||||||
| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана |
|
|
||||||
| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом |
|
|
||||||
| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) |
|
|
||||||
|
|
||||||
Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому:
|
|
||||||
|
|
||||||
- для 0x20 вернётся 0x00,
|
|
||||||
- для 0x60 вернётся 0x40,
|
|
||||||
- для 0xA0 вернётся 0x80.
|
|
||||||
|
|
||||||
### Бит 0x40 (выделение +0x12 и последующее `realloc`)
|
|
||||||
|
|
||||||
Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
|
|
||||||
|
|
||||||
Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
|
|
||||||
|
|
||||||
## 2.6. Размеры данных
|
|
||||||
|
|
||||||
В каждой записи на диске хранятся оба значения:
|
|
||||||
|
|
||||||
- `unpacked_size` (смещение 20) — размер распакованных данных.
|
|
||||||
- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода).
|
|
||||||
|
|
||||||
Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`.
|
|
||||||
|
|
||||||
`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
|
|
||||||
|
|
||||||
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
|
|
||||||
|
|
||||||
## 2.7. Опциональный трейлер медиа (6 байт)
|
|
||||||
|
|
||||||
При открытии с флагом `a2 & 2`:
|
|
||||||
|
|
||||||
| Смещение от конца | Размер | Тип | Описание |
|
|
||||||
| ----------------- | ------ | ------- | ----------------------- |
|
|
||||||
| −6 | 2 | char[2] | Сигнатура `AO` (0x4F41) |
|
|
||||||
| −4 | 4 | uint32 | Смещение медиа-оверлея |
|
|
||||||
|
|
||||||
Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 3. Алгоритмы сжатия (формат RsLi)
|
|
||||||
|
|
||||||
## 3.1. XOR-шифр данных (метод 0x20)
|
|
||||||
|
|
||||||
Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
|
|
||||||
|
|
||||||
Важно про размер входа:
|
|
||||||
|
|
||||||
- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
|
|
||||||
- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
|
|
||||||
|
|
||||||
### Инициализация
|
|
||||||
|
|
||||||
```
|
|
||||||
key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
|
|
||||||
lo = key16 & 0xFF
|
|
||||||
hi = (key16 >> 8) & 0xFF
|
|
||||||
```
|
|
||||||
|
|
||||||
### Дешифровка (псевдокод)
|
|
||||||
|
|
||||||
```
|
|
||||||
for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
|
|
||||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
|
||||||
out[i] = in[i] ^ lo
|
|
||||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3.2. LZSS — простой вариант (метод 0x40)
|
|
||||||
|
|
||||||
Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
|
|
||||||
|
|
||||||
### Параметры
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
| ----------------------------- | ------------------ |
|
|
||||||
| Размер кольцевого буфера | 4096 байт (0x1000) |
|
|
||||||
| Начальная позиция записи | 4078 (0xFEE) |
|
|
||||||
| Начальное заполнение | 0x20 (пробел) |
|
|
||||||
| Минимальная длина совпадения | 3 |
|
|
||||||
| Максимальная длина совпадения | 18 (4 бита + 3) |
|
|
||||||
|
|
||||||
### Алгоритм декомпрессии
|
|
||||||
|
|
||||||
```
|
|
||||||
Инициализация:
|
|
||||||
ring_buffer[0..4095] = 0x20 (заполнить пробелами)
|
|
||||||
ring_pos = 4078
|
|
||||||
flags_byte = 0
|
|
||||||
flags_bits_remaining = 0
|
|
||||||
|
|
||||||
Цикл (пока не заполнен выходной буфер И не исчерпан входной):
|
|
||||||
|
|
||||||
1. Если flags_bits_remaining == 0:
|
|
||||||
- Прочитать 1 байт из входного потока → flags_byte
|
|
||||||
- flags_bits_remaining = 8
|
|
||||||
|
|
||||||
Декодировать как:
|
|
||||||
- Старший бит устанавливается в 0x7F (маркер)
|
|
||||||
- Оставшиеся 7 бит — флаги текущей группы
|
|
||||||
|
|
||||||
Реально в коде: control_word = (flags_byte) | (0x7F << 8)
|
|
||||||
Каждый бит проверяется сдвигом вправо.
|
|
||||||
|
|
||||||
2. Проверить младший бит control_word:
|
|
||||||
|
|
||||||
Если бит = 1 (литерал):
|
|
||||||
- Прочитать 1 байт из входного потока → byte
|
|
||||||
- ring_buffer[ring_pos] = byte
|
|
||||||
- ring_pos = (ring_pos + 1) & 0xFFF
|
|
||||||
- Записать byte в выходной буфер
|
|
||||||
|
|
||||||
Если бит = 0 (ссылка):
|
|
||||||
- Прочитать 2 байта: low_byte, high_byte
|
|
||||||
- offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
|
|
||||||
- length = (high_byte & 0x0F) + 3 // 4 бита + 3
|
|
||||||
- Скопировать length байт из ring_buffer[offset...]:
|
|
||||||
для j от 0 до length-1:
|
|
||||||
byte = ring_buffer[(offset + j) & 0xFFF]
|
|
||||||
ring_buffer[ring_pos] = byte
|
|
||||||
ring_pos = (ring_pos + 1) & 0xFFF
|
|
||||||
записать byte в выходной буфер
|
|
||||||
|
|
||||||
3. Сдвинуть control_word вправо на 1 бит
|
|
||||||
4. flags_bits_remaining -= 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Подробная раскладка пары ссылки (2 байта)
|
|
||||||
|
|
||||||
```
|
|
||||||
Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
|
|
||||||
Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3
|
|
||||||
|
|
||||||
offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
|
|
||||||
length = (high & 0x0F) + 3 // Диапазон: 3–18
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
|
|
||||||
|
|
||||||
Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
|
|
||||||
|
|
||||||
### Параметры
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
| -------------------------------- | ------------------------------ |
|
|
||||||
| Размер кольцевого буфера | 4096 байт |
|
|
||||||
| Начальная позиция записи | **4036** (0xFC4) |
|
|
||||||
| Начальное заполнение | 0x20 (пробел) |
|
|
||||||
| Количество листовых узлов дерева | 314 |
|
|
||||||
| Символы литералов | 0–255 (байты) |
|
|
||||||
| Символы длин | 256–313 (длина = символ − 253) |
|
|
||||||
| Начальная длина | 3 (при символе 256) |
|
|
||||||
| Максимальная длина | 60 (при символе 313) |
|
|
||||||
|
|
||||||
### Дерево Хаффмана
|
|
||||||
|
|
||||||
Дерево строится как **адаптивное** (dynamic, self-adjusting):
|
|
||||||
|
|
||||||
- **627 узлов**: 314 листовых + 313 внутренних.
|
|
||||||
- Все листья изначально имеют **вес 1**.
|
|
||||||
- Корень дерева — узел с индексом 0 (в массиве `parent`).
|
|
||||||
- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
|
|
||||||
- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
|
|
||||||
|
|
||||||
### Кодирование позиции
|
|
||||||
|
|
||||||
Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
|
|
||||||
|
|
||||||
- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
|
|
||||||
- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
|
|
||||||
- Финальная позиция: `pos = (ring_pos − 1 − decoded_position) & 0xFFF`
|
|
||||||
|
|
||||||
**Таблицы инициализации** (d-коды):
|
|
||||||
|
|
||||||
```
|
|
||||||
Таблица базовых значений — byte_100371D0[6]:
|
|
||||||
{ 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 }
|
|
||||||
|
|
||||||
Таблица дополнительных битов — byte_100371D6[6]:
|
|
||||||
{ 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Алгоритм декомпрессии (высокоуровневый)
|
|
||||||
|
|
||||||
```
|
|
||||||
Инициализация:
|
|
||||||
ring_buffer[0..4095] = 0x20
|
|
||||||
ring_pos = 4036
|
|
||||||
Инициализировать дерево Хаффмана (314 листьев, все веса = 1)
|
|
||||||
Инициализировать таблицы d-кодов
|
|
||||||
|
|
||||||
Цикл:
|
|
||||||
1. Декодировать символ из потока по дереву Хаффмана:
|
|
||||||
- Начать с корня
|
|
||||||
- Читать биты, спускаться по дереву (0 = левый, 1 = правый)
|
|
||||||
- Пока не достигнут лист → символ = лист − 627
|
|
||||||
|
|
||||||
2. Обновить дерево Хаффмана для декодированного символа
|
|
||||||
|
|
||||||
3. Если символ < 256 (литерал):
|
|
||||||
- ring_buffer[ring_pos] = символ
|
|
||||||
- ring_pos = (ring_pos + 1) & 0xFFF
|
|
||||||
- Записать символ в выходной буфер
|
|
||||||
|
|
||||||
4. Если символ >= 256 (ссылка):
|
|
||||||
- length = символ − 253
|
|
||||||
- Декодировать позицию через d-код:
|
|
||||||
a) Прочитать 8 бит из потока
|
|
||||||
b) Найти d-код и дополнительные биты по таблице
|
|
||||||
c) Прочитать дополнительные биты
|
|
||||||
d) position = (ring_pos − 1 − full_position) & 0xFFF
|
|
||||||
- Скопировать length байт из ring_buffer[position...]
|
|
||||||
|
|
||||||
5. Если выходной буфер заполнен → завершить
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3.4. XOR + LZSS (методы 0x60 и 0xA0)
|
|
||||||
|
|
||||||
Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
|
|
||||||
|
|
||||||
### Алгоритм
|
|
||||||
|
|
||||||
1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
|
|
||||||
2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
|
|
||||||
3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
|
|
||||||
4. Освободить временный буфер.
|
|
||||||
|
|
||||||
- **0x60** — XOR + простой LZSS (раздел 3.2)
|
|
||||||
- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
|
|
||||||
|
|
||||||
### Начальное состояние XOR для данных
|
|
||||||
|
|
||||||
При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
|
|
||||||
|
|
||||||
## 3.5. Deflate (метод 0x100)
|
|
||||||
|
|
||||||
Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
|
|
||||||
|
|
||||||
### Общая структура
|
|
||||||
|
|
||||||
Данные состоят из последовательности блоков. Каждый блок начинается с:
|
|
||||||
|
|
||||||
- **1 бит** — `is_final`: признак последнего блока
|
|
||||||
- **2 бита** — `block_type`: тип блока
|
|
||||||
|
|
||||||
### Типы блоков
|
|
||||||
|
|
||||||
| block_type | Описание | Функция |
|
|
||||||
| ---------- | --------------------------- | ---------------- |
|
|
||||||
| 0 | Без сжатия (stored) | `sub_1001A750` |
|
|
||||||
| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` |
|
|
||||||
| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
|
|
||||||
| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
|
|
||||||
|
|
||||||
### Блок типа 0 (stored)
|
|
||||||
|
|
||||||
1. Отбросить оставшиеся биты до границы байта (выравнивание).
|
|
||||||
2. Прочитать 16 бит — `LEN` (длина блока).
|
|
||||||
3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`).
|
|
||||||
4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка.
|
|
||||||
5. Скопировать `LEN` байт из входного потока в выходной.
|
|
||||||
|
|
||||||
Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
|
|
||||||
|
|
||||||
### Блок типа 1 (фиксированные коды)
|
|
||||||
|
|
||||||
Стандартные коды Deflate:
|
|
||||||
|
|
||||||
- Литералы/длины: 288 кодов
|
|
||||||
- 0–143: 8-битные коды
|
|
||||||
- 144–255: 9-битные коды
|
|
||||||
- 256–279: 7-битные коды
|
|
||||||
- 280–287: 8-битные коды
|
|
||||||
- Дистанции: 30 кодов, все 5-битные
|
|
||||||
|
|
||||||
Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
|
|
||||||
|
|
||||||
### Блок типа 2 (динамические коды)
|
|
||||||
|
|
||||||
1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286.
|
|
||||||
2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
|
|
||||||
3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19.
|
|
||||||
4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин.
|
|
||||||
5. Построить дерево Хаффмана для алфавита длин (19 символов).
|
|
||||||
6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
|
|
||||||
7. Построить два дерева Хаффмана: для литералов/длин и для дистанций.
|
|
||||||
8. Декодировать данные.
|
|
||||||
|
|
||||||
**Порядок кодов длин** (стандартный Deflate):
|
|
||||||
|
|
||||||
```
|
|
||||||
{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
|
|
||||||
```
|
|
||||||
|
|
||||||
Хранится в `dword_10037060`.
|
|
||||||
|
|
||||||
### Валидации
|
|
||||||
|
|
||||||
- `HLIT + 257 <= 286` (max 0x11E)
|
|
||||||
- `HDIST + 1 <= 30` (max 0x1E)
|
|
||||||
- При нарушении — возвращается ошибка 1.
|
|
||||||
|
|
||||||
## 3.6. Метод 0x00 (без сжатия)
|
|
||||||
|
|
||||||
Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 4. Внутренние структуры в памяти
|
|
||||||
|
|
||||||
## 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct NResArchive { // Размер: 0x68 (104 байта)
|
|
||||||
void* vtable; // +0: Указатель на таблицу виртуальных методов
|
|
||||||
int32_t entry_count; // +4: Количество записей
|
|
||||||
void* mapped_base; // +8: Базовый адрес mapped view
|
|
||||||
void* directory_ptr; // +12: Указатель на каталог записей в памяти
|
|
||||||
char* filename; // +16: Путь к файлу (_strdup)
|
|
||||||
int32_t ref_count; // +20: Счётчик ссылок
|
|
||||||
uint32_t last_release_time; // +24: timeGetTime() при последнем Release
|
|
||||||
// +28..+91: Для raw-режима — встроенная запись (единственный File entry)
|
|
||||||
NResArchive* next; // +92: Следующий архив в связном списке
|
|
||||||
uint8_t is_writable; // +100: Файл открыт для записи
|
|
||||||
uint8_t is_cacheable; // +101: Не выгружать при refcount = 0
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct RsLibHeader { // 56 байт (14 DWORD)
|
|
||||||
uint32_t magic; // +0: 'RsLi' (0x694C7352)
|
|
||||||
int32_t entry_count; // +4: Количество записей
|
|
||||||
uint32_t media_offset; // +8: Смещение медиа-оверлея
|
|
||||||
uint32_t reserved_0C; // +12: 0
|
|
||||||
HANDLE file_handle_2; // +16: -1 (дополнительный хэндл)
|
|
||||||
uint32_t reserved_14; // +20: 0
|
|
||||||
uint32_t reserved_18; // +24: —
|
|
||||||
uint32_t reserved_1C; // +28: 0
|
|
||||||
HANDLE mapping_handle_2; // +32: -1
|
|
||||||
uint32_t reserved_24; // +36: 0
|
|
||||||
uint32_t flag_28; // +40: (flags >> 7) & 1
|
|
||||||
HANDLE file_handle; // +44: Хэндл файла
|
|
||||||
HANDLE mapping_handle; // +48: Хэндл файлового маппинга
|
|
||||||
void* mapped_view; // +52: Указатель на mapped view
|
|
||||||
};
|
|
||||||
// Далее следуют entry_count записей по 64 байта каждая
|
|
||||||
```
|
|
||||||
|
|
||||||
### Внутренняя запись RsLi (64 байта)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct RsLibEntry { // 64 байта (16 DWORD)
|
|
||||||
char name[16]; // +0: Имя (12 из файла + 4 нуля)
|
|
||||||
int32_t flags; // +16: Флаги (sign-extended из int16)
|
|
||||||
int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XOR‑ключ)
|
|
||||||
uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи)
|
|
||||||
void* data_ptr; // +28: Указатель на данные в mapped view
|
|
||||||
uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи)
|
|
||||||
uint32_t reserved_24; // +36: 0
|
|
||||||
uint32_t reserved_28; // +40: 0
|
|
||||||
uint32_t reserved_2C; // +44: 0
|
|
||||||
void* loaded_data; // +48: Указатель на декомпрессированные данные
|
|
||||||
// +52..+63: дополнительные поля
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 5. Экспортируемые API-функции
|
|
||||||
|
|
||||||
## 5.1. NRes API
|
|
||||||
|
|
||||||
| Функция | Описание |
|
|
||||||
| ------------------------------ | ------------------------------------------------------------------------- |
|
|
||||||
| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
|
|
||||||
| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
|
|
||||||
| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
|
|
||||||
| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
|
|
||||||
|
|
||||||
## 5.2. RsLi API
|
|
||||||
|
|
||||||
| Функция | Описание |
|
|
||||||
| ------------------------------- | -------------------------------------------------------- |
|
|
||||||
| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку |
|
|
||||||
| `rsCloseLib(lib)` | Закрыть библиотеку |
|
|
||||||
| `rsLibNum(lib)` | Получить количество записей |
|
|
||||||
| `rsFind(lib, name)` | Найти запись по имени (→ индекс или −1) |
|
|
||||||
| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс |
|
|
||||||
| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) |
|
|
||||||
| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) |
|
|
||||||
| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` |
|
|
||||||
| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
|
|
||||||
| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
|
|
||||||
| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
|
|
||||||
| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
|
|
||||||
| `ngiFree(ptr)` | Освободить память |
|
|
||||||
| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 6. Контрольные заметки для реализации
|
|
||||||
|
|
||||||
## 6.1. Кодировки и регистр
|
|
||||||
|
|
||||||
- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
|
|
||||||
- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
|
|
||||||
|
|
||||||
## 6.2. Порядок байт
|
|
||||||
|
|
||||||
Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
|
|
||||||
|
|
||||||
## 6.3. Выравнивание
|
|
||||||
|
|
||||||
- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
|
|
||||||
- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
|
|
||||||
|
|
||||||
## 6.4. Размер записей на диске
|
|
||||||
|
|
||||||
- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
|
|
||||||
- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
|
|
||||||
|
|
||||||
## 6.5. Кэширование и memory mapping
|
|
||||||
|
|
||||||
Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
|
|
||||||
|
|
||||||
## 6.6. Размер seed XOR
|
|
||||||
|
|
||||||
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
|
|
||||||
- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
|
|
||||||
- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
|
|
||||||
|
|
||||||
## 6.7. Эмпирическая проверка на данных игры
|
|
||||||
|
|
||||||
- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
|
|
||||||
- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
|
|
||||||
- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
|
|
||||||
|
|
||||||
Подтверждённые нюансы:
|
|
||||||
|
|
||||||
- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`.
|
|
||||||
- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла.
|
|
||||||
|
|||||||
145
docs/specs/object-registry.md
Normal file
145
docs/specs/object-registry.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Object Registry (`objects.rlb`)
|
||||||
|
|
||||||
|
`objects.rlb` - это не архив с готовыми мешами.
|
||||||
|
Это реестр игровых прототипов, который связывает логический идентификатор объекта (`r_h_01`, `s_tree_04`, `fr_m_brige`, ...) с набором реальных ресурсов в других архивах.
|
||||||
|
|
||||||
|
Документ описывает формат и runtime-контракт на высоком уровне, без привязки к внутренним именам/адресам из дизассемблера.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [Missions](missions.md)
|
||||||
|
- [NRes](nres.md)
|
||||||
|
- [MSH core](msh-core.md)
|
||||||
|
- [Wear (`WEAR`)](wear.md)
|
||||||
|
- [Material (`MAT0`)](material.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## 1. Роль в пайплайне
|
||||||
|
|
||||||
|
При загрузке миссии движок работает так:
|
||||||
|
|
||||||
|
1. Из `data.tma` получает `resource_name` объекта:
|
||||||
|
- либо прямой ключ (`s_tree_04`);
|
||||||
|
- либо путь к `*.dat` (например `UNITS\\UNITS\\HERO\\tut1_p.dat`).
|
||||||
|
2. Для `*.dat` читает заголовок и получает:
|
||||||
|
- `archive_name` (в retail-корпусе всегда `objects.rlb`);
|
||||||
|
- `model_key` (например `R_H_02`).
|
||||||
|
3. В `objects.rlb` по ключу (`model_key`/`resource_name`) ищет запись прототипа.
|
||||||
|
4. Из записи прототипа резолвит фактический `*.msh` и архив, где лежит геометрия.
|
||||||
|
5. Дальше запускается стандартная цепочка:
|
||||||
|
`MSH -> WEAR -> MAT0 -> Texm`.
|
||||||
|
|
||||||
|
## 2. Контейнер
|
||||||
|
|
||||||
|
`objects.rlb` сам является обычным `NRes`-архивом.
|
||||||
|
|
||||||
|
Практические наблюдения на retail-корпусе:
|
||||||
|
|
||||||
|
- формат заголовка/каталога полностью совпадает с `NRes`;
|
||||||
|
- payload каждой записи прототипа кратен `64` байтам;
|
||||||
|
- имя entry в каталоге - это логический ключ объекта (например `r_h_01`, `s_tree_04`).
|
||||||
|
|
||||||
|
## 3. Формат payload записи прототипа
|
||||||
|
|
||||||
|
Payload состоит из массива фиксированных записей:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct ObjectRef64 {
|
||||||
|
char archive_name[32]; // C-строка (CP1251/ASCII)
|
||||||
|
char resource_name[32]; // C-строка (CP1251/ASCII)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Интерпретация:
|
||||||
|
|
||||||
|
- `archive_name`: архив-источник (`bases.rlb`, `static.rlb`, `fortif.rlb`, `effects.rlb`, ...).
|
||||||
|
- `resource_name`: имя ресурса в этом архиве (`*.msh`, `*.wea`, `*.cpt`, `*.ctl`, `*.bas`, ...).
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
|
||||||
|
- после первого `NUL` в 32-байтовом поле могут встречаться служебные байты; для runtime-резолва используется только C-строка до первого `NUL`;
|
||||||
|
- неизвестные хвостовые байты должны сохраняться 1:1 при writer/roundtrip-редактировании.
|
||||||
|
|
||||||
|
## 4. Runtime-резолв геометрии
|
||||||
|
|
||||||
|
Канонический порядок выбора меша:
|
||||||
|
|
||||||
|
1. Найти запись прототипа по ключу в `objects.rlb`.
|
||||||
|
2. Прочитать список `ObjectRef64`.
|
||||||
|
3. Если есть ссылка на `*.msh`:
|
||||||
|
- взять первую валидную ссылку;
|
||||||
|
- открыть указанный архив;
|
||||||
|
- загрузить этот `*.msh`.
|
||||||
|
4. Если `*.msh` нет, но есть `*.bas`:
|
||||||
|
- взять stem от `*.bas` (`fr_m_brige.bas` -> `fr_m_brige`);
|
||||||
|
- искать `<stem>.msh` в том же архиве (`fortif.rlb`).
|
||||||
|
5. Если нет ни `*.msh`, ни `*.bas`, объект трактуется как не-геометрический (пример: солнечный/системный объект) и в 3D-проход не попадает.
|
||||||
|
|
||||||
|
## 5. Типовые примеры
|
||||||
|
|
||||||
|
`r_h_01`:
|
||||||
|
|
||||||
|
- `bases.rlb :: r_h_01.msh`
|
||||||
|
- `bases.rlb :: r_h_01.wea`
|
||||||
|
- `bases.rlb :: r_h_01.cpt`
|
||||||
|
- ...
|
||||||
|
|
||||||
|
`s_tree_04`:
|
||||||
|
|
||||||
|
- `static.rlb :: s_tree_0_04.msh`
|
||||||
|
- `static.rlb :: s_tree_0_04.wea`
|
||||||
|
- ...
|
||||||
|
|
||||||
|
`fr_m_brige`:
|
||||||
|
|
||||||
|
- прямого `*.msh` в записи нет;
|
||||||
|
- есть `fortif.rlb :: fr_m_brige.bas`;
|
||||||
|
- меш резолвится как `fortif.rlb :: fr_m_brige.msh`.
|
||||||
|
|
||||||
|
`sun_01`:
|
||||||
|
|
||||||
|
- ссылки на `*.sun`/effect-ресурсы;
|
||||||
|
- 3D-меш отсутствует.
|
||||||
|
|
||||||
|
## 6. Инварианты для reader/writer
|
||||||
|
|
||||||
|
Reader:
|
||||||
|
|
||||||
|
- payload записи прототипа должен быть кратен `64`;
|
||||||
|
- каждая запись читается как две независимые C-строки фиксированной длины;
|
||||||
|
- поиск в архивах должен быть case-insensitive по ASCII.
|
||||||
|
|
||||||
|
Writer/editor:
|
||||||
|
|
||||||
|
- сохранять порядок `ObjectRef64` без перестановок;
|
||||||
|
- сохранять неизвестные служебные байты полей 1:1;
|
||||||
|
- не нормализовать имена, если это не требуется задачей.
|
||||||
|
|
||||||
|
## 7. Валидация
|
||||||
|
|
||||||
|
Проверено на retail-корпусе `testdata/Parkan - Iron Strategy`:
|
||||||
|
|
||||||
|
- все `590` записей `objects.rlb` имеют payload, кратный `64`;
|
||||||
|
- `554` записей имеют прямую ссылку на `*.msh`;
|
||||||
|
- `34` записи используют ветку через `*.bas`;
|
||||||
|
- `2` записи не содержат геометрии (системные/sun).
|
||||||
|
|
||||||
|
Интеграционные тесты в Rust подтверждают резолв:
|
||||||
|
|
||||||
|
- `r_h_01 -> bases.rlb :: r_h_01.msh`
|
||||||
|
- `s_tree_04 -> static.rlb :: s_tree_0_04.msh`
|
||||||
|
- `fr_m_brige -> fortif.rlb :: fr_m_brige.msh`
|
||||||
|
|
||||||
|
## 8. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Формат payload записи прототипа (`ObjectRef64`) и правила чтения.
|
||||||
|
2. Runtime-алгоритм выбора меша (`*.msh` напрямую и fallback через `*.bas`).
|
||||||
|
3. Корпусная проверка структуры и интеграционные тесты резолва.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная field-level семантика служебных байтов после `NUL` в `resource_name[32]`.
|
||||||
|
2. Формальная семантика всех категорий ссылок (`*.ctl`, `*.cpt`, `*.ndp`, `*.sun`) в терминах систем движка (не только render-пути).
|
||||||
|
3. Writer-спецификация уровня "authoring new prototype from scratch" с гарантией runtime-паритета.
|
||||||
90
docs/specs/render-parity.md
Normal file
90
docs/specs/render-parity.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Рендер-паритет (кадровый diff)
|
||||||
|
|
||||||
|
Документ описывает процесс проверки соответствия рендера:
|
||||||
|
`оригинальный движок -> эталонный кадр -> render-demo -> diff-метрики`.
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
- Зафиксировать объективный критерий "паритет достигнут / не достигнут".
|
||||||
|
- Убрать субъективную визуальную оценку "похоже/не похоже".
|
||||||
|
- Дать CI-проверку, которая ловит регрессии сразу после коммита.
|
||||||
|
|
||||||
|
## Единица проверки
|
||||||
|
|
||||||
|
Один тест-кейс = один объект (одна модель) + фиксированная конфигурация:
|
||||||
|
|
||||||
|
- архив ресурса;
|
||||||
|
- имя модели;
|
||||||
|
- `lod`;
|
||||||
|
- `group`;
|
||||||
|
- размер кадра (`width`, `height`);
|
||||||
|
- угол камеры (`angle`);
|
||||||
|
- PNG-эталон из оригинального рендера.
|
||||||
|
|
||||||
|
## Инварианты детерминизма
|
||||||
|
|
||||||
|
Для корректного сравнения кадры должны быть сняты в одинаковых условиях:
|
||||||
|
|
||||||
|
- одинаковый FOV и расстояние камеры до объекта;
|
||||||
|
- одинаковый clear-color/фон;
|
||||||
|
- одинаковые `lod/group`;
|
||||||
|
- фиксированный угол (`angle`), без анимации;
|
||||||
|
- фиксированное разрешение.
|
||||||
|
|
||||||
|
## Метрики сравнения
|
||||||
|
|
||||||
|
Сравнение выполняется по RGB-каналам:
|
||||||
|
|
||||||
|
- `mean_abs`: средняя абсолютная разница канала (0..255);
|
||||||
|
- `max_abs`: максимальная разница канала;
|
||||||
|
- `changed_ratio`: доля пикселей, где хотя бы один канал превышает `diff_threshold`.
|
||||||
|
|
||||||
|
Кейс считается пройденным, если:
|
||||||
|
|
||||||
|
- `mean_abs <= max_mean_abs`;
|
||||||
|
- `changed_ratio <= max_changed_ratio`.
|
||||||
|
|
||||||
|
## Конфигурация кейсов
|
||||||
|
|
||||||
|
Файл: `parity/cases.toml`.
|
||||||
|
|
||||||
|
- секция `[meta]`: глобальные дефолты;
|
||||||
|
- `[[case]]`: параметры конкретной модели и путь к эталонному PNG.
|
||||||
|
|
||||||
|
Эталонные кадры хранятся в `parity/reference/`.
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p render-parity -- \
|
||||||
|
--manifest parity/cases.toml \
|
||||||
|
--output-dir target/render-parity/current
|
||||||
|
```
|
||||||
|
|
||||||
|
При расхождении утилита пишет diff-изображение в:
|
||||||
|
|
||||||
|
- `target/render-parity/current/diff/<case>.png`
|
||||||
|
|
||||||
|
## CI-модель
|
||||||
|
|
||||||
|
CI запускает `render-parity` на каждом push/PR:
|
||||||
|
|
||||||
|
1. собирает `parkan-render-demo`;
|
||||||
|
2. прогоняет кейсы из `cases.toml`;
|
||||||
|
3. при падении публикует текущие кадры и diff как артефакт.
|
||||||
|
|
||||||
|
Важно: оригинальный движок в CI обычно не запускается.
|
||||||
|
Эталонные PNG снимаются офлайн и версионируются в репозитории.
|
||||||
|
|
||||||
|
## Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Определена метрика сравнения кадров (`mean_abs`, `max_abs`, `changed_ratio`).
|
||||||
|
2. Описан единый manifest-формат кейсов и CI-процедура.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Снять и зафиксировать расширенный эталонный набор кадров оригинала (10-20+ ключевых моделей и режимов).
|
||||||
|
2. Зафиксировать пороговые критерии pass/fail по каждому классу сцен (статик, анимация, FX, lightmap).
|
||||||
|
3. Добавить автоматическую публикацию diff-артефактов и регрессионных отчетов в CI.
|
||||||
182
docs/specs/render.md
Normal file
182
docs/specs/render.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Render pipeline
|
||||||
|
|
||||||
|
Документ описывает полный процесс рендера кадра в движке Parkan: Iron Strategy, без привязки к внутренним адресам/именам дизассемблера.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [MSH core](msh-core.md)
|
||||||
|
- [MSH animation](msh-animation.md)
|
||||||
|
- [Material (`MAT0`)](material.md)
|
||||||
|
- [Wear table (`WEAR`)](wear.md)
|
||||||
|
- [Texture (`Texm`)](texture.md)
|
||||||
|
- [FXID](fxid.md)
|
||||||
|
|
||||||
|
## 1. Инициализация рендера
|
||||||
|
|
||||||
|
На старте движок:
|
||||||
|
|
||||||
|
1. Выбирает видеодрайвер (software или аппаратный).
|
||||||
|
2. Создаёт render backend.
|
||||||
|
3. Подключает библиотеки ресурсов:
|
||||||
|
- `Material.lib`
|
||||||
|
- `Textures.lib`
|
||||||
|
- `LightMap.lib`
|
||||||
|
- `palettes.lib`
|
||||||
|
4. Инициализирует менеджеры:
|
||||||
|
- material manager
|
||||||
|
- texture/lightmap cache
|
||||||
|
- effect manager
|
||||||
|
5. Загружает базовые world-ресурсы (включая наборы объектов сцены).
|
||||||
|
|
||||||
|
## 2. Структура кадра
|
||||||
|
|
||||||
|
Кадр выполняется как последовательность:
|
||||||
|
|
||||||
|
1. `Simulation update`
|
||||||
|
2. `Animation sampling`
|
||||||
|
3. `Visibility / culling`
|
||||||
|
4. `Material + texture resolve`
|
||||||
|
5. `Mesh draw`
|
||||||
|
6. `FX update + draw`
|
||||||
|
7. `UI/overlay draw`
|
||||||
|
8. `Present`
|
||||||
|
|
||||||
|
## 3. Geometry path
|
||||||
|
|
||||||
|
### 3.1. Подготовка инстансов
|
||||||
|
|
||||||
|
Для каждого видимого объекта:
|
||||||
|
|
||||||
|
1. Вычисляется `world transform`.
|
||||||
|
2. Выбирается `LOD`.
|
||||||
|
3. Для каждого узла выбирается slot через `Res1`.
|
||||||
|
|
||||||
|
### 3.2. Culling
|
||||||
|
|
||||||
|
Сначала отсекаются узлы/слоты по bounds (`AABB/sphere`) из `Res2`.
|
||||||
|
|
||||||
|
### 3.3. Батчи
|
||||||
|
|
||||||
|
Для каждого прошедшего slot:
|
||||||
|
|
||||||
|
1. Берутся батчи из диапазона `Res13`.
|
||||||
|
2. По `materialIndex` выбирается активный материал.
|
||||||
|
3. По фазе материала выбирается текстура/lightmap.
|
||||||
|
4. Выполняется `DrawIndexedPrimitive`:
|
||||||
|
- индексный диапазон: `indexStart/indexCount`
|
||||||
|
- базовая вершина: `baseVertex`
|
||||||
|
- индексы читаются из `Res6`
|
||||||
|
- вершины/атрибуты читаются из `Res3/Res4/Res5` (+ optional streams)
|
||||||
|
|
||||||
|
## 4. Animation path
|
||||||
|
|
||||||
|
Для анимированных моделей:
|
||||||
|
|
||||||
|
1. Для узла выбирается ключ через `Res19` и fallback-логику.
|
||||||
|
2. Декодируются `pos + quat` из `Res8`.
|
||||||
|
3. При необходимости выполняется blending двух сэмплов.
|
||||||
|
4. Узловая матрица передаётся в geometry path.
|
||||||
|
|
||||||
|
## 5. Material path
|
||||||
|
|
||||||
|
Material pipeline на кадре:
|
||||||
|
|
||||||
|
1. По material handle выбирается запись `MAT0`.
|
||||||
|
2. По игровому времени выбирается текущая фаза.
|
||||||
|
3. Применяются коэффициенты фазы (цвет/альфа/параметры).
|
||||||
|
4. Резолвятся ссылки на texture/lightmap.
|
||||||
|
5. Невалидные ссылки обрабатываются fallback-стратегией.
|
||||||
|
|
||||||
|
Практическая цепочка привязки для большинства `*.msh` ассетов из `*.rlb`:
|
||||||
|
|
||||||
|
1. Для модели выбирается одноимённый `WEAR` (`<model_stem>.wea`).
|
||||||
|
2. Из `WEAR` берётся material-слот (по имени, `legacyId` не участвует в выборе).
|
||||||
|
3. В `Material.lib` ищется `MAT0` по имени (`DEFAULT`, затем индекс `0` как fallback).
|
||||||
|
4. Из выбранной material-фазы берётся `textureName`.
|
||||||
|
5. `Texm` ищется в `Textures.lib` (и/или lightmap-архиве для lightmap-ветки).
|
||||||
|
|
||||||
|
## 6. Texture path
|
||||||
|
|
||||||
|
При резолве текстуры:
|
||||||
|
|
||||||
|
1. Ищется `Texm` entry по имени.
|
||||||
|
2. Проверяется и декодируется заголовок.
|
||||||
|
3. При необходимости применяется `mipSkip`.
|
||||||
|
4. Для indexed-формата подключается палитра.
|
||||||
|
5. Optional `Page` chunk интерпретируется как atlas-таблица.
|
||||||
|
6. Объект текстуры кладётся/берётся из cache.
|
||||||
|
|
||||||
|
## 7. FX path
|
||||||
|
|
||||||
|
Эффекты выполняются параллельно mesh-рендеру:
|
||||||
|
|
||||||
|
1. Для активных инстансов FX вычисляется runtime-коэффициент (`time_mode + flags`).
|
||||||
|
2. Команды FX обновляют внутреннее состояние.
|
||||||
|
3. Команды emit-этапа формируют примитивы/батчи эффектов.
|
||||||
|
4. Эффекты рисуются в 3D-кадре с собственным счётчиком батчей.
|
||||||
|
|
||||||
|
## 8. Псевдокод кадра
|
||||||
|
|
||||||
|
```c
|
||||||
|
void RenderFrame(Scene* scene, Camera* cam, float dt) {
|
||||||
|
UpdateGame(scene, dt);
|
||||||
|
|
||||||
|
for (Object* obj : scene->objects) {
|
||||||
|
if (!obj->visible) continue;
|
||||||
|
|
||||||
|
UpdateObjectAnimation(obj, scene->time);
|
||||||
|
BuildObjectNodeTransforms(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
BeginFrame(cam);
|
||||||
|
|
||||||
|
for (Object* obj : scene->objects) {
|
||||||
|
if (!obj->visible) continue;
|
||||||
|
RenderObjectMeshes(obj, cam);
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateAndRenderFx(scene, dt, cam);
|
||||||
|
RenderUI(scene);
|
||||||
|
Present();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Критичные условия для 1:1
|
||||||
|
|
||||||
|
1. Та же политика округления/FP для анимации и FX.
|
||||||
|
2. Та же логика fallback по материалам и текстурам.
|
||||||
|
3. Та же очередность стадий кадра.
|
||||||
|
4. Тот же контракт интерпретации `Res1/Res2/Res13/Res6`.
|
||||||
|
5. Тот же контракт `FXID` командного потока.
|
||||||
|
|
||||||
|
## 10. Статус валидации
|
||||||
|
|
||||||
|
- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущей runtime-валидацией проекта.
|
||||||
|
- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
|
||||||
|
|
||||||
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Высокоуровневый кадр: simulation -> animation -> culling -> material/texture resolve -> mesh draw -> fx -> ui -> present.
|
||||||
|
2. Связка MSH/MAT0/WEAR/Texm/FXID в едином runtime-процессе.
|
||||||
|
3. Форматная валидация входных данных на полном retail-корпусе.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полный pixel-parity контур с эталонными кадрами оригинального рендера по набору моделей/сцен.
|
||||||
|
2. Формализация всех render-state деталей (точные blend/depth/cull/state transitions) для гарантии 1:1 в каждом draw-pass.
|
||||||
|
3. Полный coverage-пакет по динамическим веткам (FX-heavy кадры, сложные material-режимы, lightmap-комбинации).
|
||||||
|
|
||||||
|
## 12. Object registry bridge (`objects.rlb`)
|
||||||
|
|
||||||
|
Для миссионного/юнитного рендера критично учитывать промежуточный слой прототипов:
|
||||||
|
|
||||||
|
1. `TMA`/`*.dat` обычно дают не прямой `*.msh`, а ключ прототипа.
|
||||||
|
2. Ключ резолвится через `objects.rlb` (реестр ссылок на реальные архивы ресурсов).
|
||||||
|
3. Только после этого выполняется стандартный путь:
|
||||||
|
`MSH -> WEAR -> MAT0 -> Texm`.
|
||||||
|
|
||||||
|
Детальная спецификация этого шага вынесена в отдельную страницу:
|
||||||
|
|
||||||
|
- [Object registry (`objects.rlb`)](object-registry.md)
|
||||||
230
docs/specs/rsli.md
Normal file
230
docs/specs/rsli.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# RsLi
|
||||||
|
|
||||||
|
`RsLi` — библиотечный контейнер ресурсов движка Parkan: Iron Strategy с зашифрованной таблицей записей и несколькими методами упаковки данных.
|
||||||
|
|
||||||
|
Страница описывает формат и runtime-контракт в высокоуровневом виде, без ссылок на внутренние адреса/функции дизассемблера.
|
||||||
|
|
||||||
|
Связанная страница:
|
||||||
|
|
||||||
|
- [NRes](nres.md)
|
||||||
|
|
||||||
|
## 1. Общая структура файла
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Header: 32]
|
||||||
|
[Entry table: entry_count * 32, XOR-encrypted]
|
||||||
|
[Packed payloads]
|
||||||
|
[Optional trailer: "AO" + overlay:u32]
|
||||||
|
```
|
||||||
|
|
||||||
|
В отличие от `NRes`, таблица записей у `RsLi` расположена в начале файла.
|
||||||
|
|
||||||
|
## 2. Заголовок (32 байта)
|
||||||
|
|
||||||
|
Все значения little-endian.
|
||||||
|
|
||||||
|
| Offset | Size | Type | Поле |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 0 | 2 | char[2] | `NL` (магия) |
|
||||||
|
| 2 | 1 | u8 | зарезервировано, в retail = `0` |
|
||||||
|
| 3 | 1 | u8 | версия, в retail = `1` |
|
||||||
|
| 4 | 2 | i16 | `entry_count` (должен быть `>= 0`) |
|
||||||
|
| 14 | 2 | u16 | `presorted_flag` (`0xABBA` = таблица сортировки уже задана) |
|
||||||
|
| 20 | 4 | u32 | `xor_seed` |
|
||||||
|
|
||||||
|
Остальные байты заголовка считаются служебными и должны сохраняться без нормализации.
|
||||||
|
|
||||||
|
## 3. Таблица записей (после дешифровки)
|
||||||
|
|
||||||
|
Таблица начинается с `offset = 32`, размер `entry_count * 32`.
|
||||||
|
|
||||||
|
Каждая запись (32 байта):
|
||||||
|
|
||||||
|
| Offset | Size | Type | Поле |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 0 | 12 | char[12] | `name_raw` (обычно uppercase ASCII, NUL optional) |
|
||||||
|
| 12 | 4 | bytes | служебный хвост, сохранять как есть |
|
||||||
|
| 16 | 2 | i16 | `flags` |
|
||||||
|
| 18 | 2 | i16 | `sort_to_original` |
|
||||||
|
| 20 | 4 | u32 | `unpacked_size` |
|
||||||
|
| 24 | 4 | u32 | `data_offset_raw` |
|
||||||
|
| 28 | 4 | u32 | `packed_size` |
|
||||||
|
|
||||||
|
### 3.1. Метод упаковки
|
||||||
|
|
||||||
|
`method = flags & 0x1E0`
|
||||||
|
|
||||||
|
Поддерживаемые значения:
|
||||||
|
|
||||||
|
| Маска | Метод |
|
||||||
|
|---:|---|
|
||||||
|
| `0x000` | без сжатия |
|
||||||
|
| `0x020` | XOR only |
|
||||||
|
| `0x040` | LZSS |
|
||||||
|
| `0x060` | XOR + LZSS |
|
||||||
|
| `0x080` | LZSS + адаптивный Huffman |
|
||||||
|
| `0x0A0` | XOR + LZSS + адаптивный Huffman |
|
||||||
|
| `0x100` | raw Deflate (RFC1951) |
|
||||||
|
|
||||||
|
Другие значения считаются неподдерживаемыми.
|
||||||
|
|
||||||
|
## 4. XOR-дешифрование таблицы и данных
|
||||||
|
|
||||||
|
Для таблицы и XOR-методов payload используется один и тот же потоковый XOR-алгоритм.
|
||||||
|
|
||||||
|
Ключ:
|
||||||
|
|
||||||
|
- `key16 = xor_seed & 0xFFFF` (используются только младшие 16 бит seed).
|
||||||
|
|
||||||
|
Состояние:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lo = key16 & 0xFF
|
||||||
|
hi = key16 >> 8
|
||||||
|
```
|
||||||
|
|
||||||
|
Для каждого байта:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lo = hi XOR ((lo << 1) mod 256)
|
||||||
|
out = in XOR lo
|
||||||
|
hi = lo XOR (hi >> 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. `sort_to_original` и поиск по имени
|
||||||
|
|
||||||
|
### 5.1. Режим `presorted_flag == 0xABBA`
|
||||||
|
|
||||||
|
`sort_to_original` обязан быть перестановкой `0..entry_count-1` без дубликатов.
|
||||||
|
|
||||||
|
### 5.2. Режим без presorted-флага
|
||||||
|
|
||||||
|
Слой загрузки строит `sort_to_original` самостоятельно:
|
||||||
|
|
||||||
|
- сортирует индексы по `strcmp`-порядку имен (байтовое сравнение);
|
||||||
|
- записывает эту перестановку в lookup-таблицу.
|
||||||
|
|
||||||
|
### 5.3. Поиск
|
||||||
|
|
||||||
|
Поиск выполняется бинарным поиском по lookup-таблице:
|
||||||
|
|
||||||
|
1. запрос переводится в uppercase ASCII;
|
||||||
|
2. на шаге бинарного поиска используется индекс `sort_to_original[mid]`;
|
||||||
|
3. сравнение имен — bytewise (`strcmp`-логика).
|
||||||
|
|
||||||
|
Fail-safe:
|
||||||
|
|
||||||
|
- при невалидном индексе lookup-таблицы выполняется линейный fallback.
|
||||||
|
|
||||||
|
## 6. AO-трейлер и media overlay
|
||||||
|
|
||||||
|
Опциональный трейлер в конце файла:
|
||||||
|
|
||||||
|
```text
|
||||||
|
"AO" + overlay:u32
|
||||||
|
```
|
||||||
|
|
||||||
|
Если трейлер присутствует:
|
||||||
|
|
||||||
|
- эффективный offset payload: `effective_offset = data_offset_raw + overlay`.
|
||||||
|
|
||||||
|
Ограничение:
|
||||||
|
|
||||||
|
- `overlay <= file_size`.
|
||||||
|
|
||||||
|
## 7. Декодирование payload по методам
|
||||||
|
|
||||||
|
## 7.1. Без сжатия (`0x000`)
|
||||||
|
|
||||||
|
Берутся первые `unpacked_size` байт из packed-диапазона.
|
||||||
|
|
||||||
|
## 7.2. XOR only (`0x020`)
|
||||||
|
|
||||||
|
XOR-дешифрование первых `unpacked_size` байт.
|
||||||
|
|
||||||
|
## 7.3. LZSS (`0x040`, `0x060`)
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
|
||||||
|
- ring buffer: `4096` байт;
|
||||||
|
- начальное заполнение ring: `0x20`;
|
||||||
|
- стартовый указатель ring: `0xFEE`;
|
||||||
|
- control-биты читаются LSB-first.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- `bit=1`: literal byte;
|
||||||
|
- `bit=0`: ссылка из 2 байт
|
||||||
|
`offset = low | ((high & 0xF0) << 4)`
|
||||||
|
`length = (high & 0x0F) + 3`.
|
||||||
|
|
||||||
|
Для `0x060` XOR применяется на лету к packed-потоку до LZSS-декодирования.
|
||||||
|
|
||||||
|
## 7.4. LZSS + адаптивный Huffman (`0x080`, `0x0A0`)
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
|
||||||
|
- `N=4096`, `F=60`, `THRESHOLD=2`;
|
||||||
|
- адаптивное дерево Huffman обновляется по мере декодирования.
|
||||||
|
|
||||||
|
Для `0x0A0` XOR применяется на лету к битовому потоку до Huffman/LZSS-декодирования.
|
||||||
|
|
||||||
|
## 7.5. Deflate (`0x100`)
|
||||||
|
|
||||||
|
Используется raw Deflate-поток (RFC1951).
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
|
||||||
|
- zlib-обертка (`RFC1950`) не принимается.
|
||||||
|
|
||||||
|
## 8. Quirk: Deflate EOF+1
|
||||||
|
|
||||||
|
На retail-корпусе встречается один подтвержденный случай, где:
|
||||||
|
|
||||||
|
- `effective_offset + packed_size == file_size + 1`.
|
||||||
|
|
||||||
|
Совместимое поведение:
|
||||||
|
|
||||||
|
- для метода `0x100` допустить чтение `packed_size - 1` байт (если включен режим совместимости);
|
||||||
|
- в строгом режиме считать это ошибкой.
|
||||||
|
|
||||||
|
## 9. Контрольные инварианты
|
||||||
|
|
||||||
|
Минимальные проверки:
|
||||||
|
|
||||||
|
1. `magic == "NL"`, `reserved == 0`, `version == 1`.
|
||||||
|
2. `entry_count >= 0`.
|
||||||
|
3. `table_end <= file_size`.
|
||||||
|
4. Если `presorted_flag == 0xABBA`, `sort_to_original` — валидная перестановка.
|
||||||
|
5. `effective_offset + packed_size` не выходит за EOF (кроме разрешенного deflate EOF+1 quirk).
|
||||||
|
6. Итоговый распакованный размер равен `unpacked_size`.
|
||||||
|
|
||||||
|
## 10. Эмпирическая проверка на retail-корпусе
|
||||||
|
|
||||||
|
Проверка на полном наборе `testdata/Parkan - Iron Strategy`:
|
||||||
|
|
||||||
|
- обнаружено `2` архива `RsLi`;
|
||||||
|
- roundtrip `unpack -> repack -> byte-compare`: `2/2` совпали побайтно;
|
||||||
|
- подтвержден ровно один `deflate EOF+1` случай (`sprites.lib`, entry `23`).
|
||||||
|
|
||||||
|
Инструменты:
|
||||||
|
|
||||||
|
- `tools/archive_roundtrip_validator.py`
|
||||||
|
- `crates/rsli` tests
|
||||||
|
|
||||||
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- формат заголовка/таблицы;
|
||||||
|
- XOR-алгоритм;
|
||||||
|
- все используемые методы декодирования;
|
||||||
|
- AO overlay;
|
||||||
|
- lookup-поиск и fallback;
|
||||||
|
- retail-валидация и побайтовый roundtrip.
|
||||||
|
|
||||||
|
Осталось до полного 100% архитектурного покрытия движка:
|
||||||
|
|
||||||
|
1. Полная функциональная семантика битов `flags` вне маски метода (`0x1E0`) для геймплейных подсистем.
|
||||||
|
2. Канонический writer для авторинга новых архивов со стабильной стратегией выбора методов (`0x080/0x0A0/0x100`) и параметров компрессии.
|
||||||
|
3. Формализация поведения для не-ASCII имен (на практике архивы используют ASCII-диапазон).
|
||||||
18
docs/specs/runtime-pipeline.md
Normal file
18
docs/specs/runtime-pipeline.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Runtime pipeline
|
||||||
|
|
||||||
|
Актуальный документ по полному кадру находится здесь:
|
||||||
|
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
Эта страница оставлена как совместимый указатель для старых ссылок.
|
||||||
|
|
||||||
|
## Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Актуальный runtime-пайплайн централизован в `render.md`.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Поддерживать обратную совместимость ссылок при дальнейшей декомпозиции render-документа.
|
||||||
|
|
||||||
32
docs/specs/sound.md
Normal file
32
docs/specs/sound.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Sound system
|
||||||
|
|
||||||
|
`Sound` — подсистема аудио:
|
||||||
|
|
||||||
|
- загрузка и кеширование звуковых ресурсов;
|
||||||
|
- воспроизведение SFX/voice/music;
|
||||||
|
- пространственное позиционирование и микширование.
|
||||||
|
|
||||||
|
## 1. Архитектурная роль
|
||||||
|
|
||||||
|
1. Получает события от gameplay/FX/mission/UI.
|
||||||
|
2. Резолвит аудиоресурсы через архивные библиотеки.
|
||||||
|
3. Управляет каналами, приоритетами и жизненным циклом источников звука.
|
||||||
|
|
||||||
|
## 2. Минимальный runtime-контракт
|
||||||
|
|
||||||
|
1. Стабильный выбор источника и fallback при отсутствии ресурса.
|
||||||
|
2. Детерминированные правила приоритета при переполнении каналов.
|
||||||
|
3. Согласованная модель пространственного затухания и панорамирования.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- место аудио-подсистемы в общем runtime-контуре.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация форматов аудио-ресурсов и lookup-таблиц.
|
||||||
|
2. Полный контракт 2D/3D микширования и лимитов каналов.
|
||||||
|
3. Правила взаимодействия с FXID-командами, которые инициируют звук.
|
||||||
|
4. Набор audio parity-тестов (тайминг/громкость/панорама).
|
||||||
293
docs/specs/terrain-map-loading.md
Normal file
293
docs/specs/terrain-map-loading.md
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
# Terrain + ArealMap
|
||||||
|
|
||||||
|
Документ описывает подсистему ландшафта и ареалов мира в движке Parkan: Iron Strategy:
|
||||||
|
|
||||||
|
- `Land.msh` (terrain-геометрия и вспомогательные таблицы);
|
||||||
|
- `Land.map` (ареалы и навигационные связи);
|
||||||
|
- `BuildDat.lst` (категории объектных зон).
|
||||||
|
|
||||||
|
Описание дано в высокоуровневом переносимом виде, без ссылок на внутренние адреса и имена из дизассемблера.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [NRes](nres.md)
|
||||||
|
- [RsLi](rsli.md)
|
||||||
|
- [MSH core](msh-core.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## 1. End-to-End загрузка уровня
|
||||||
|
|
||||||
|
Для каждой карты движок загружает пару файлов:
|
||||||
|
|
||||||
|
- `.../Land.msh`
|
||||||
|
- `.../Land.map`
|
||||||
|
|
||||||
|
Высокоуровневый порядок:
|
||||||
|
|
||||||
|
1. Открыть `Land.msh` как `NRes`.
|
||||||
|
2. Прочитать обязательные terrain-chunk'и.
|
||||||
|
3. Построить runtime-структуры terrain (slots, faces, spatial grid).
|
||||||
|
4. Открыть `Land.map` как `NRes`.
|
||||||
|
5. Найти единственный chunk `type=12`.
|
||||||
|
6. Прочитать ареалы, их связи и cell-grid.
|
||||||
|
7. Применить инициализацию объектных категорий из `BuildDat.lst`.
|
||||||
|
|
||||||
|
## 2. Формат `Land.msh`
|
||||||
|
|
||||||
|
`Land.msh` — обычный `NRes` архив с фиксированным набором terrain-ресурсов.
|
||||||
|
|
||||||
|
## 2.1. Состав chunk'ов
|
||||||
|
|
||||||
|
Обязательные типы:
|
||||||
|
|
||||||
|
- `1`, `2`, `3`, `4`, `5`, `11`, `18`, `21`
|
||||||
|
|
||||||
|
Опциональные типы:
|
||||||
|
|
||||||
|
- `14`
|
||||||
|
|
||||||
|
Наблюдаемый retail-порядок chunk'ов:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[1, 2, 3, 4, 5, 18, 14, 11, 21]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2.2. Stride и атрибуты
|
||||||
|
|
||||||
|
| Type | Назначение | Stride |
|
||||||
|
|---:|---|---:|
|
||||||
|
| 1 | node/slot матрица | 38 |
|
||||||
|
| 3 | позиции вершин | 12 |
|
||||||
|
| 4 | нормали (packed) | 4 |
|
||||||
|
| 5 | UV (packed) | 4 |
|
||||||
|
| 11 | cell-ускоритель | 4 |
|
||||||
|
| 14 | доп. поток | 4 |
|
||||||
|
| 18 | доп. поток | 4 |
|
||||||
|
| 21 | terrain face | 28 |
|
||||||
|
|
||||||
|
Общее правило для этих chunk'ов:
|
||||||
|
|
||||||
|
- `attr1 == size / stride`
|
||||||
|
- `attr3 == stride`
|
||||||
|
|
||||||
|
## 2.3. Type `2`: slot table
|
||||||
|
|
||||||
|
`type=2` содержит:
|
||||||
|
|
||||||
|
- заголовок `0x8C` байт;
|
||||||
|
- затем таблицу slots по `68` байт.
|
||||||
|
|
||||||
|
Инварианты:
|
||||||
|
|
||||||
|
- `size >= 0x8C`
|
||||||
|
- `(size - 0x8C) % 68 == 0`
|
||||||
|
- `attr1 == (size - 0x8C) / 68`
|
||||||
|
- `attr3 == 68`
|
||||||
|
|
||||||
|
## 2.4. Type `21`: terrain face (28 байт)
|
||||||
|
|
||||||
|
Высокоуровневая структура face:
|
||||||
|
|
||||||
|
- флаги face;
|
||||||
|
- индексы треугольника (`i0, i1, i2`);
|
||||||
|
- индексы соседей (`n0, n1, n2`, значение `0xFFFF` = нет соседа);
|
||||||
|
- служебные поля (материал/класс/edge-поля и др.).
|
||||||
|
|
||||||
|
Критичные проверки:
|
||||||
|
|
||||||
|
- `i0/i1/i2 < vertex_count` (`type=3`);
|
||||||
|
- `nX == 0xFFFF` или `nX < face_count`.
|
||||||
|
|
||||||
|
## 2.5. Маски face и compact-представления
|
||||||
|
|
||||||
|
В рантайме используются:
|
||||||
|
|
||||||
|
- полная 32-битная маска (`full`);
|
||||||
|
- компактные представления (`compactMain16`, `compactMaterial6`).
|
||||||
|
|
||||||
|
Подтвержденный remap `full -> compactMain16`:
|
||||||
|
|
||||||
|
| Full bit | Compact bit |
|
||||||
|
|---:|---:|
|
||||||
|
| `0x00000001` | `0x0001` |
|
||||||
|
| `0x00000008` | `0x0002` |
|
||||||
|
| `0x00000010` | `0x0004` |
|
||||||
|
| `0x00000020` | `0x0008` |
|
||||||
|
| `0x00001000` | `0x0010` |
|
||||||
|
| `0x00004000` | `0x0020` |
|
||||||
|
| `0x00000002` | `0x0040` |
|
||||||
|
| `0x00000400` | `0x0080` |
|
||||||
|
| `0x00000800` | `0x0100` |
|
||||||
|
| `0x00020000` | `0x0200` |
|
||||||
|
| `0x00002000` | `0x0400` |
|
||||||
|
| `0x00000200` | `0x0800` |
|
||||||
|
| `0x00000004` | `0x1000` |
|
||||||
|
| `0x00000040` | `0x2000` |
|
||||||
|
| `0x00200000` | `0x8000` |
|
||||||
|
|
||||||
|
Подтвержденный remap `full -> compactMaterial6`:
|
||||||
|
|
||||||
|
| Full bit | Compact bit |
|
||||||
|
|---:|---:|
|
||||||
|
| `0x00000100` | `0x01` |
|
||||||
|
| `0x00008000` | `0x02` |
|
||||||
|
| `0x00010000` | `0x04` |
|
||||||
|
| `0x00040000` | `0x08` |
|
||||||
|
| `0x00080000` | `0x10` |
|
||||||
|
| `0x00000080` | `0x20` |
|
||||||
|
|
||||||
|
Для 1:1 реализации нужно поддерживать оба представления и обратное восстановление `compact -> full`.
|
||||||
|
|
||||||
|
## 2.6. Type `11` и cell-ускоритель terrain
|
||||||
|
|
||||||
|
`type=11` служит источником cell-ускорителя для terrain-запросов.
|
||||||
|
|
||||||
|
Практические требования для editor/toolchain:
|
||||||
|
|
||||||
|
- не переупорядочивать содержимое без полного пересчета зависимых таблиц;
|
||||||
|
- сохранять служебные/неизвестные поля побайтно;
|
||||||
|
- выполнять валидацию диапазонов face/slot после любых правок.
|
||||||
|
|
||||||
|
## 3. Формат `Land.map` (chunk `type=12`)
|
||||||
|
|
||||||
|
`Land.map` — `NRes`, содержащий ровно один ресурс `type=12`.
|
||||||
|
|
||||||
|
Контракт верхнего уровня:
|
||||||
|
|
||||||
|
- `entry.attr1` = `areal_count`;
|
||||||
|
- payload включает:
|
||||||
|
- `areal_count` переменных записей ареалов;
|
||||||
|
- затем grid-секцию cell-попаданий.
|
||||||
|
|
||||||
|
## 3.1. Запись ареала
|
||||||
|
|
||||||
|
Старт записи:
|
||||||
|
|
||||||
|
```c
|
||||||
|
float anchor_x; // +0
|
||||||
|
float anchor_y; // +4
|
||||||
|
float anchor_z; // +8
|
||||||
|
float reserved_12; // +12
|
||||||
|
float area_metric; // +16
|
||||||
|
float normal_x; // +20
|
||||||
|
float normal_y; // +24
|
||||||
|
float normal_z; // +28
|
||||||
|
uint32_t logic_flag; // +32
|
||||||
|
uint32_t reserved_36; // +36
|
||||||
|
uint32_t class_id; // +40
|
||||||
|
uint32_t reserved_44; // +44
|
||||||
|
uint32_t vertex_count; // +48
|
||||||
|
uint32_t poly_count; // +52
|
||||||
|
```
|
||||||
|
|
||||||
|
Далее:
|
||||||
|
|
||||||
|
1. `float3 vertices[vertex_count]`
|
||||||
|
2. `EdgeLink8 links[vertex_count + 3 * poly_count]`, где
|
||||||
|
`EdgeLink8 = { int32 area_ref; int32 edge_ref; }`
|
||||||
|
3. для каждого полигона block:
|
||||||
|
- `uint32 n`
|
||||||
|
- `4 * (3*n + 1)` байт данных полигона
|
||||||
|
|
||||||
|
## 3.2. Семантика edge-link
|
||||||
|
|
||||||
|
Для `links[0 .. vertex_count-1]`:
|
||||||
|
|
||||||
|
- `(-1, -1)` означает «соседа нет»;
|
||||||
|
- иначе `area_ref` указывает на индекс соседнего ареала, `edge_ref` — на ребро в соседнем ареале.
|
||||||
|
|
||||||
|
## 3.3. Grid-секция после ареалов
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
|
||||||
|
```c
|
||||||
|
uint32 cellsX;
|
||||||
|
uint32 cellsY;
|
||||||
|
for (x=0; x<cellsX; x++) {
|
||||||
|
for (y=0; y<cellsY; y++) {
|
||||||
|
uint16 hitCount;
|
||||||
|
uint16 areaIds[hitCount];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
В runtime существует упакованное cell-meta представление:
|
||||||
|
|
||||||
|
- high 10 бит: `hitCount`;
|
||||||
|
- low 22 бита: `startIndex` (в общем `areaIds` пуле).
|
||||||
|
|
||||||
|
## 3.4. Валидация целостности chunk 12
|
||||||
|
|
||||||
|
Обязательные проверки:
|
||||||
|
|
||||||
|
- `areal_count > 0`;
|
||||||
|
- `cellsX > 0 && cellsY > 0`;
|
||||||
|
- каждый `area_id` из cell-списков `< areal_count`;
|
||||||
|
- все `area_ref/edge_ref` валидны относительно целевых ареалов;
|
||||||
|
- полный объем прочитанных байт должен точно совпасть с размером payload.
|
||||||
|
|
||||||
|
## 4. `BuildDat.lst`
|
||||||
|
|
||||||
|
Используются 12 объектных категорий ареалов:
|
||||||
|
|
||||||
|
| Имя | Маска |
|
||||||
|
|---|---:|
|
||||||
|
| `Bunker_Small` | `0x80010000` |
|
||||||
|
| `Bunker_Medium` | `0x80020000` |
|
||||||
|
| `Bunker_Large` | `0x80040000` |
|
||||||
|
| `Generator` | `0x80000002` |
|
||||||
|
| `Mine` | `0x80000004` |
|
||||||
|
| `Storage` | `0x80000008` |
|
||||||
|
| `Plant` | `0x80000010` |
|
||||||
|
| `Hangar` | `0x80000040` |
|
||||||
|
| `MainTeleport` | `0x80000200` |
|
||||||
|
| `Institute` | `0x80000400` |
|
||||||
|
| `Tower_Medium` | `0x80100000` |
|
||||||
|
| `Tower_Large` | `0x80200000` |
|
||||||
|
|
||||||
|
Файл должен парситься строго секционно; поврежденный формат считается ошибкой.
|
||||||
|
|
||||||
|
## 5. Требования к reader/writer/editor
|
||||||
|
|
||||||
|
1. Сохранять порядок и бинарную форму chunk'ов, если не выполняется осознанная нормализация.
|
||||||
|
2. Все неизвестные поля хранить и писать побайтно (`preserve-as-is`).
|
||||||
|
3. После правок пересчитывать только вычислимые поля, не «чистить» opaque-данные.
|
||||||
|
4. Проверять диапазоны индексов между связанными таблицами (`nodes/slots/faces/vertices/areas/cells`).
|
||||||
|
5. Для неизмененных ресурсов обеспечивать byte-identical roundtrip.
|
||||||
|
|
||||||
|
## 6. Эмпирическая верификация (retail)
|
||||||
|
|
||||||
|
Валидация на `testdata/Parkan - Iron Strategy`:
|
||||||
|
|
||||||
|
- карт: `33`
|
||||||
|
- `Land.msh`: `33/33` валидны
|
||||||
|
- `Land.map`: `33/33` валидны
|
||||||
|
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`
|
||||||
|
|
||||||
|
Подтвержденные наблюдения:
|
||||||
|
|
||||||
|
- `Land.msh` порядок chunk'ов стабилен: `[1,2,3,4,5,18,14,11,21]`;
|
||||||
|
- `Land.map` всегда содержит один chunk `type=12`;
|
||||||
|
- `cellsX == cellsY == 128` во всех retail-картах;
|
||||||
|
- `poly_count == 0` во всем проверенном retail-корпусе;
|
||||||
|
- `normal` имеет длину ~1.0;
|
||||||
|
- `reserved_12`, `reserved_36`, `reserved_44` в retail наблюдаются как `0`.
|
||||||
|
|
||||||
|
Инструмент:
|
||||||
|
|
||||||
|
- `tools/terrain_map_doc_validator.py`
|
||||||
|
|
||||||
|
## 7. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- бинарный контракт `Land.msh` и `Land.map`;
|
||||||
|
- диапазонные и структурные инварианты;
|
||||||
|
- remap масок `full/compact`;
|
||||||
|
- валидация на полном retail-корпусе карт.
|
||||||
|
|
||||||
|
Осталось до полного 100% архитектурного покрытия движка:
|
||||||
|
|
||||||
|
1. Полная доменная семантика `class_id` и `logic_flag` (игровые значения/поведенческие правила).
|
||||||
|
2. Полная спецификация ветки `poly_count > 0` на живых данных (в retail не встречена).
|
||||||
|
3. Полная field-level семантика части битов `TerrainFace28.flags` (бинарный контракт и remap закрыты, но не все биты имеют документированные геймплейные имена).
|
||||||
153
docs/specs/texture.md
Normal file
153
docs/specs/texture.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Texture (`Texm`)
|
||||||
|
|
||||||
|
`Texm` — основной формат текстур движка.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [Material (`MAT0`)](material.md)
|
||||||
|
- [Wear table (`WEAR`)](wear.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## 1. Контейнер
|
||||||
|
|
||||||
|
- Тип ресурса: `0x6D786554` (`Texm`).
|
||||||
|
- Используется в `Textures.lib`, `LightMap.lib` и других `NRes` архивах.
|
||||||
|
|
||||||
|
## 2. Заголовок
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct TexmHeader32 {
|
||||||
|
uint32_t magic; // 'Texm'
|
||||||
|
uint32_t width;
|
||||||
|
uint32_t height;
|
||||||
|
uint32_t mipCount;
|
||||||
|
uint32_t flags4;
|
||||||
|
uint32_t flags5;
|
||||||
|
uint32_t unk6;
|
||||||
|
uint32_t format;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Поддерживаемые форматы
|
||||||
|
|
||||||
|
Базовые форматы:
|
||||||
|
|
||||||
|
- `0` (8-bit indexed + palette)
|
||||||
|
- `565`
|
||||||
|
- `4444`
|
||||||
|
- `888`
|
||||||
|
- `8888`
|
||||||
|
|
||||||
|
Дополнительные ветки загрузки поддерживают также `556` и `88`.
|
||||||
|
|
||||||
|
## 4. Layout payload
|
||||||
|
|
||||||
|
1. `TexmHeader32` (32 байта)
|
||||||
|
2. palette `1024` байта, если `format == 0`
|
||||||
|
3. mip-chain пикселей
|
||||||
|
4. optional `Page` chunk
|
||||||
|
|
||||||
|
Расчёт ядра:
|
||||||
|
|
||||||
|
```c
|
||||||
|
bytesPerPixel =
|
||||||
|
(format == 0) ? 1 :
|
||||||
|
(format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
|
||||||
|
4;
|
||||||
|
|
||||||
|
pixelCount = sum(max(1, width>>i) * max(1, height>>i), i=0..mipCount-1);
|
||||||
|
sizeCore = 32 + (format==0 ? 1024 : 0) + bytesPerPixel * pixelCount;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4.1. Декодирование в RGBA8 (runtime/инструменты)
|
||||||
|
|
||||||
|
Для CPU-пути (preview, валидация, оффлайн-конвертация) используется декодирование:
|
||||||
|
|
||||||
|
- `0` (`Indexed8`): `index -> palette[index]` (`RGBA` из палитры 256×4).
|
||||||
|
- `565`: `R5 G6 B5`, `A=255`.
|
||||||
|
- `556`: `R5 G5 B6`, `A=255`.
|
||||||
|
- `4444`: `A4 R4 G4 B4` (с расширением 4-битных каналов в 8-битные).
|
||||||
|
- `88`: `L8 A8` (`R=G=B=L`).
|
||||||
|
- `888`: `R8 G8 B8` + padding/служебный байт, `A=255`.
|
||||||
|
- `8888`: `A8 R8 G8 B8`.
|
||||||
|
|
||||||
|
Это декодирование соответствует текущему test/demo pipeline проекта.
|
||||||
|
|
||||||
|
## 5. `Page` chunk
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct PageChunk {
|
||||||
|
uint32_t magic; // 'Page'
|
||||||
|
uint32_t rectCount;
|
||||||
|
Rect16 rects[rectCount];
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Rect16 {
|
||||||
|
int16_t x;
|
||||||
|
int16_t w;
|
||||||
|
int16_t y;
|
||||||
|
int16_t h;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`Page` задаёт atlas-прямоугольники для выборки под-областей текстуры.
|
||||||
|
|
||||||
|
## 6. Mip-skip политика
|
||||||
|
|
||||||
|
Загрузчик может пропускать первые mip-уровни в зависимости от:
|
||||||
|
|
||||||
|
- `flags5`,
|
||||||
|
- размеров текстуры,
|
||||||
|
- количества mip.
|
||||||
|
|
||||||
|
После `mipSkip`:
|
||||||
|
|
||||||
|
- уменьшаются `width/height/mipCount`;
|
||||||
|
- сдвигается начало пиксельных данных;
|
||||||
|
- `Page`-координаты пересчитываются в соответствии с новым базовым уровнем.
|
||||||
|
|
||||||
|
## 7. Палитры
|
||||||
|
|
||||||
|
Для части текстур движок связывает палитру по суффиксу имени.
|
||||||
|
|
||||||
|
Практический формат:
|
||||||
|
|
||||||
|
- буква `A..Z` + вариант `""` или `0..9`
|
||||||
|
- всего `26 * 11 = 286` возможных слотов палитр.
|
||||||
|
|
||||||
|
Невалидные суффиксы нужно считать ошибкой входных данных в инструментах.
|
||||||
|
|
||||||
|
## 8. Кэширование
|
||||||
|
|
||||||
|
Движок ведёт отдельные кэши:
|
||||||
|
|
||||||
|
- общий texture cache;
|
||||||
|
- lightmap cache.
|
||||||
|
|
||||||
|
Для обычных текстур используется отложенный сбор неиспользуемых слотов (по времени нулевого refcount).
|
||||||
|
|
||||||
|
## 9. Правила writer/editor
|
||||||
|
|
||||||
|
1. Не нормализовать `flags4/flags5/unk6`.
|
||||||
|
2. Сохранять payload без лишних хвостовых байт.
|
||||||
|
3. Если есть `Page`, его размер должен быть ровно `8 + rectCount * 8`.
|
||||||
|
4. Проверять `width > 0`, `height > 0`, `mipCount > 0`.
|
||||||
|
|
||||||
|
## 10. Статус валидации
|
||||||
|
|
||||||
|
- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`.
|
||||||
|
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `518/518` текстурных payload (`Texm`) без ошибок.
|
||||||
|
|
||||||
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Заголовок `Texm`, mip-chain layout и `Page` chunk.
|
||||||
|
2. Базовые decode-пути в RGBA8 для проверок/preview.
|
||||||
|
3. Корпусная валидация структурных инвариантов.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная формальная спецификация всех редких служебных комбинаций `flags4/flags5/unk6`.
|
||||||
|
2. Канонический writer для полного набора форматов (`indexed`, `565`, `556`, `4444`, `88`, `888`, `8888`) с проверенным roundtrip-профилем.
|
||||||
|
3. Pixel-parity тесты «оригинальный рендер vs новый рендер» с учетом mipSkip/atlas-page веток.
|
||||||
@@ -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` к материалам:
|
|
||||||
- это имена материалов?
|
|
||||||
- это имена текстур?
|
|
||||||
- или это имена узлов/анимаций?
|
|
||||||
|
|
||||||
До подтверждения этих пунктов разумнее держать документацию как “архитектурную карту”, а не как точный байтовый формат.
|
|
||||||
33
docs/specs/ui.md
Normal file
33
docs/specs/ui.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# UI system
|
||||||
|
|
||||||
|
`UI` — подсистема интерфейса:
|
||||||
|
|
||||||
|
- экранные панели и HUD;
|
||||||
|
- меню;
|
||||||
|
- шрифты;
|
||||||
|
- minimap и служебные оверлеи.
|
||||||
|
|
||||||
|
## 1. Архитектурная роль
|
||||||
|
|
||||||
|
1. Работает поверх render-пайплайна как отдельный этап кадра.
|
||||||
|
2. Использует UI-ресурсы из архивных библиотек.
|
||||||
|
3. Перехватывает пользовательский ввод по правилам фокуса.
|
||||||
|
|
||||||
|
## 2. Минимальный runtime-контракт
|
||||||
|
|
||||||
|
1. Детерминированный порядок draw-проходов UI.
|
||||||
|
2. Консистентный фокус и приоритет ввода (UI vs world).
|
||||||
|
3. Стабильная загрузка font/minimap/ui-ресурсов по именам.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- позиция UI-слоя в общем кадре и его связи с render/input.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация форматов UI layout и контролов.
|
||||||
|
2. Полный контракт ресурсов шрифтов и text-rendering поведения.
|
||||||
|
3. Формат minimap-данных и правила трансформации координат.
|
||||||
|
4. UI parity-тесты (скриншотные и событийные).
|
||||||
96
docs/specs/wear.md
Normal file
96
docs/specs/wear.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Wear table (`WEAR`)
|
||||||
|
|
||||||
|
`WEAR` — текстовый ресурс, который связывает слоты wear с именами материалов и lightmap.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [Material (`MAT0`)](material.md)
|
||||||
|
- [Texture (`Texm`)](texture.md)
|
||||||
|
|
||||||
|
## 1. Контейнер
|
||||||
|
|
||||||
|
- Тип ресурса: `0x52414557` (`WEAR`).
|
||||||
|
- Обычно хранится как `*.wea` внутри world/mission архивов.
|
||||||
|
|
||||||
|
## 2. Формат текста
|
||||||
|
|
||||||
|
```text
|
||||||
|
<wearCount:int>
|
||||||
|
<legacyId:int> <materialName>
|
||||||
|
... (wearCount строк)
|
||||||
|
|
||||||
|
[пустая строка]
|
||||||
|
[LIGHTMAPS
|
||||||
|
<lightmapCount:int>
|
||||||
|
<legacyId:int> <lightmapName>
|
||||||
|
... (lightmapCount строк)]
|
||||||
|
```
|
||||||
|
|
||||||
|
`legacyId` читается, но логика выбора работает по имени.
|
||||||
|
|
||||||
|
## 3. Совместимость парсинга
|
||||||
|
|
||||||
|
В движке используются два режима чтения (`из файла` и `из буфера`), у которых различается обработка блока `LIGHTMAPS`.
|
||||||
|
|
||||||
|
Практическое правило для полного совпадения:
|
||||||
|
|
||||||
|
- если присутствует блок `LIGHTMAPS`, перед строкой `LIGHTMAPS` должна быть пустая строка-разделитель.
|
||||||
|
|
||||||
|
## 4. Runtime-ограничения
|
||||||
|
|
||||||
|
- Число wear-таблиц в менеджере ограничено: максимум `70`.
|
||||||
|
- Для `wearCount <= 0` ресурс считается некорректным.
|
||||||
|
- Для `LIGHTMAPS` блока `lightmapCount <= 0` — также ошибка формата.
|
||||||
|
|
||||||
|
## 5. Поведение резолва
|
||||||
|
|
||||||
|
### 5.1. Материал
|
||||||
|
|
||||||
|
Для каждого wear-слота:
|
||||||
|
|
||||||
|
1. Ищется материал по имени.
|
||||||
|
2. Если не найден — используется fallback (`DEFAULT`, затем индекс 0).
|
||||||
|
|
||||||
|
### 5.2. Lightmap
|
||||||
|
|
||||||
|
Для каждого lightmap-слота:
|
||||||
|
|
||||||
|
1. Ищется текстура lightmap по имени.
|
||||||
|
2. Если не найдено — слот получает `-1`.
|
||||||
|
|
||||||
|
## 6. Handle-кодирование
|
||||||
|
|
||||||
|
Движок кодирует ссылку на material-slot как:
|
||||||
|
|
||||||
|
```c
|
||||||
|
handle = (tableIndex << 16) | wearIndex
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tableIndex` — номер wear-таблицы.
|
||||||
|
- `wearIndex` — индекс строки внутри таблицы.
|
||||||
|
|
||||||
|
## 7. Правила writer/editor
|
||||||
|
|
||||||
|
1. Сохранять порядок строк.
|
||||||
|
2. Не переставлять и не нормализовать `legacyId`.
|
||||||
|
3. Для совместимости buffer-парсинга сохранять пустую строку перед `LIGHTMAPS`.
|
||||||
|
4. Проверять, что число строк соответствует `wearCount`/`lightmapCount`.
|
||||||
|
|
||||||
|
## 8. Статус валидации
|
||||||
|
|
||||||
|
- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном.
|
||||||
|
- Корпусные проверки связки `WEAR -> MAT0 -> Texm` включены в текущий валидаторный контур проекта.
|
||||||
|
|
||||||
|
## 9. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Текстовый формат `WEAR`, включая блок `LIGHTMAPS`.
|
||||||
|
2. Handle-кодирование material slot и fallback-резолв.
|
||||||
|
3. Правила совместимого writer/editor path.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация edge-case форматов строк (кодировки, редкие разделители, возможные legacy-варианты).
|
||||||
|
2. Формализация всех ограничений менеджера wear-таблиц в runtime (лимиты и политики вытеснения).
|
||||||
|
3. Интеграционные parity-тесты на полном цикле «модель -> wear -> material -> texture/lightmap».
|
||||||
27
mkdocs.yml
27
mkdocs.yml
@@ -10,7 +10,7 @@ repo_name: valentineus/fparkan
|
|||||||
repo_url: https://github.com/valentineus/fparkan
|
repo_url: https://github.com/valentineus/fparkan
|
||||||
|
|
||||||
# Copyright
|
# Copyright
|
||||||
copyright: Copyright © 2023 — 2024 Valentin Popov
|
copyright: Copyright © 2023 — 2026 Valentin Popov
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
theme:
|
theme:
|
||||||
@@ -23,10 +23,29 @@ theme:
|
|||||||
nav:
|
nav:
|
||||||
- Home: index.md
|
- Home: index.md
|
||||||
- Specs:
|
- 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
|
||||||
|
- Material (MAT0): specs/material.md
|
||||||
|
- Wear (WEAR): specs/wear.md
|
||||||
|
- Texture (Texm): specs/texture.md
|
||||||
|
- Materials index: specs/materials-texm.md
|
||||||
|
- Missions: specs/missions.md
|
||||||
|
- Object registry (objects.rlb): specs/object-registry.md
|
||||||
|
- MSH animation: specs/msh-animation.md
|
||||||
|
- MSH core: specs/msh-core.md
|
||||||
|
- Network system: specs/network.md
|
||||||
- NRes / RsLi: specs/nres.md
|
- NRes / RsLi: specs/nres.md
|
||||||
- 3D модели: specs/msh.md
|
- Render pipeline: specs/render.md
|
||||||
- Текстуры и материалы: specs/textures.md
|
- Render parity: specs/render-parity.md
|
||||||
- Эффекты и частицы: specs/effects.md
|
- Runtime pointer: 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
|
# Additional configuration
|
||||||
extra:
|
extra:
|
||||||
|
|||||||
20
parity/README.md
Normal file
20
parity/README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Render Parity Dataset
|
||||||
|
|
||||||
|
This folder stores parity-test input for `crates/render-parity`.
|
||||||
|
|
||||||
|
- `cases.toml`: list of deterministic render cases.
|
||||||
|
- `reference/*.png`: baseline frames captured from the original renderer.
|
||||||
|
|
||||||
|
Expected workflow:
|
||||||
|
|
||||||
|
1. Capture baseline PNG frames from original game/editor for each case.
|
||||||
|
2. Add entries to `cases.toml`.
|
||||||
|
3. Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p render-parity -- \
|
||||||
|
--manifest parity/cases.toml \
|
||||||
|
--output-dir target/render-parity/current
|
||||||
|
```
|
||||||
|
|
||||||
|
On failure, diff images are saved to `target/render-parity/current/diff`.
|
||||||
27
parity/cases.toml
Normal file
27
parity/cases.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[meta]
|
||||||
|
# Global defaults for all cases.
|
||||||
|
width = 1280
|
||||||
|
height = 720
|
||||||
|
lod = 0
|
||||||
|
group = 0
|
||||||
|
angle = 0.0
|
||||||
|
|
||||||
|
# Per-pixel change threshold for the "changed pixel ratio" metric.
|
||||||
|
diff_threshold = 8
|
||||||
|
|
||||||
|
# Allowed thresholds (case fails if any limit is exceeded).
|
||||||
|
max_mean_abs = 2.0
|
||||||
|
max_changed_ratio = 0.010
|
||||||
|
|
||||||
|
# Add one block per model.
|
||||||
|
#
|
||||||
|
# [[case]]
|
||||||
|
# id = "animals_a_l_01"
|
||||||
|
# archive = "../testdata/Parkan - Iron Strategy/animals.rlb"
|
||||||
|
# model = "A_L_01.msh"
|
||||||
|
# reference = "reference/animals_a_l_01.png"
|
||||||
|
# lod = 0
|
||||||
|
# group = 0
|
||||||
|
# angle = 0.0
|
||||||
|
# max_mean_abs = 2.0
|
||||||
|
# max_changed_ratio = 0.010
|
||||||
0
parity/reference/.gitkeep
Normal file
0
parity/reference/.gitkeep
Normal file
@@ -105,3 +105,97 @@ python3 tools/init_testdata.py --input tmp/gamedata --output testdata --force
|
|||||||
- если `--output` указывает на существующий файл, скрипт завершится с ошибкой;
|
- если `--output` указывает на существующий файл, скрипт завершится с ошибкой;
|
||||||
- если `--output` расположен внутри `--input`, каталог вывода исключается из сканирования;
|
- если `--output` расположен внутри `--input`, каталог вывода исключается из сканирования;
|
||||||
- если `stdin` неинтерактивный и требуется перезапись, нужно явно указать `--force`.
|
- если `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)`).
|
||||||
|
|||||||
262
tools/fxid_abs100_audit.py
Normal file
262
tools/fxid_abs100_audit.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Deterministic audit for FXID "absolute parity" checklist.
|
||||||
|
|
||||||
|
What this script produces:
|
||||||
|
1) strict parsing stats across all FXID payloads in NRes archives,
|
||||||
|
2) opcode histogram and rare-branch counters (op6, op1 tail usage),
|
||||||
|
3) reference vectors for RNG core (sub_10002220 semantics).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import archive_roundtrip_validator as arv
|
||||||
|
|
||||||
|
TYPE_FXID = 0x44495846
|
||||||
|
FX_CMD_SIZE = {1: 224, 2: 148, 3: 200, 4: 204, 5: 112, 6: 4, 7: 208, 8: 248, 9: 208, 10: 208}
|
||||||
|
|
||||||
|
|
||||||
|
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 _cstr32(raw: bytes) -> str:
|
||||||
|
return raw.split(b"\x00", 1)[0].decode("latin1", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _rng_step_sub_10002220(state32: int) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
sub_10002220 semantics in 32-bit packed state form:
|
||||||
|
lo = state[15:0], hi = state[31:16]
|
||||||
|
new_lo = hi ^ (lo << 1)
|
||||||
|
new_hi = (hi >> 1) ^ new_lo
|
||||||
|
return new_hi (u16), update state=(new_hi<<16)|new_lo
|
||||||
|
"""
|
||||||
|
lo = state32 & 0xFFFF
|
||||||
|
hi = (state32 >> 16) & 0xFFFF
|
||||||
|
new_lo = (hi ^ ((lo << 1) & 0xFFFF)) & 0xFFFF
|
||||||
|
new_hi = ((hi >> 1) ^ new_lo) & 0xFFFF
|
||||||
|
return ((new_hi << 16) | new_lo), new_hi
|
||||||
|
|
||||||
|
|
||||||
|
def _rng_vectors() -> dict[str, Any]:
|
||||||
|
seeds = [0x00000000, 0x00000001, 0x12345678, 0x89ABCDEF, 0xFFFFFFFF]
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for seed in seeds:
|
||||||
|
state = seed
|
||||||
|
outputs: list[int] = []
|
||||||
|
states: list[int] = []
|
||||||
|
for _ in range(16):
|
||||||
|
state, value = _rng_step_sub_10002220(state)
|
||||||
|
outputs.append(value)
|
||||||
|
states.append(state)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"seed_hex": f"0x{seed:08X}",
|
||||||
|
"outputs_u16_hex": [f"0x{x:04X}" for x in outputs],
|
||||||
|
"states_u32_hex": [f"0x{x:08X}" for x in states],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"generator": "sub_10002220", "vectors": out}
|
||||||
|
|
||||||
|
|
||||||
|
def run_audit(root: Path) -> dict[str, Any]:
|
||||||
|
counters: Counter[str] = Counter()
|
||||||
|
opcode_hist: Counter[int] = Counter()
|
||||||
|
issues: list[dict[str, Any]] = []
|
||||||
|
op1_tail6_samples: list[dict[str, Any]] = []
|
||||||
|
op1_optref_samples: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for item in arv.scan_archives(root):
|
||||||
|
if item["type"] != "nres":
|
||||||
|
continue
|
||||||
|
archive_path = root / item["relative_path"]
|
||||||
|
counters["archives_total"] += 1
|
||||||
|
data = archive_path.read_bytes()
|
||||||
|
try:
|
||||||
|
parsed = arv.parse_nres(data, source=str(archive_path))
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": None,
|
||||||
|
"message": f"cannot parse NRes: {exc}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in parsed["entries"]:
|
||||||
|
if int(entry["type_id"]) != TYPE_FXID:
|
||||||
|
continue
|
||||||
|
counters["fxid_total"] += 1
|
||||||
|
payload = _entry_payload(data, entry)
|
||||||
|
entry_name = str(entry["name"])
|
||||||
|
|
||||||
|
if len(payload) < 60:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"payload too small: {len(payload)}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
cmd_count = struct.unpack_from("<I", payload, 0)[0]
|
||||||
|
ptr = 0x3C
|
||||||
|
ok = True
|
||||||
|
for idx in range(cmd_count):
|
||||||
|
if ptr + 4 > len(payload):
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"command {idx}: missing header at offset={ptr}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
|
||||||
|
word = struct.unpack_from("<I", payload, ptr)[0]
|
||||||
|
opcode = word & 0xFF
|
||||||
|
size = FX_CMD_SIZE.get(opcode)
|
||||||
|
if size is None:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"command {idx}: unknown opcode={opcode} at offset={ptr}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if ptr + size > len(payload):
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"command {idx}: truncated end={ptr + size}, payload={len(payload)}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
|
||||||
|
opcode_hist[opcode] += 1
|
||||||
|
if opcode == 6:
|
||||||
|
counters["op6_commands"] += 1
|
||||||
|
if opcode == 1:
|
||||||
|
tail6 = payload[ptr + 136 : ptr + 160]
|
||||||
|
if any(tail6):
|
||||||
|
counters["op1_tail6_nonzero"] += 1
|
||||||
|
if len(op1_tail6_samples) < 16:
|
||||||
|
dwords = list(struct.unpack("<6I", tail6))
|
||||||
|
op1_tail6_samples.append(
|
||||||
|
{
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"cmd_index": idx,
|
||||||
|
"tail6_u32_hex": [f"0x{x:08X}" for x in dwords],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
archive_s = _cstr32(payload[ptr + 160 : ptr + 192])
|
||||||
|
name_s = _cstr32(payload[ptr + 192 : ptr + 224])
|
||||||
|
if archive_s or name_s:
|
||||||
|
counters["op1_optref_nonempty"] += 1
|
||||||
|
if len(op1_optref_samples) < 16:
|
||||||
|
op1_optref_samples.append(
|
||||||
|
{
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"cmd_index": idx,
|
||||||
|
"opt_archive": archive_s,
|
||||||
|
"opt_name": name_s,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ptr += size
|
||||||
|
|
||||||
|
if ok and ptr != len(payload):
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"tail bytes after command stream: parsed_end={ptr}, payload={len(payload)}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
counters["fxid_ok"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"input_root": str(root),
|
||||||
|
"summary": {
|
||||||
|
"archives_total": counters["archives_total"],
|
||||||
|
"fxid_total": counters["fxid_total"],
|
||||||
|
"fxid_ok": counters["fxid_ok"],
|
||||||
|
"issues_total": len(issues),
|
||||||
|
"op6_commands": counters["op6_commands"],
|
||||||
|
"op1_tail6_nonzero": counters["op1_tail6_nonzero"],
|
||||||
|
"op1_optref_nonempty": counters["op1_optref_nonempty"],
|
||||||
|
},
|
||||||
|
"opcode_histogram": {str(k): opcode_hist[k] for k in sorted(opcode_hist)},
|
||||||
|
"op1_tail6_samples": op1_tail6_samples,
|
||||||
|
"op1_optref_samples": op1_optref_samples,
|
||||||
|
"rng_reference": _rng_vectors(),
|
||||||
|
"rng_states_fx_path": [
|
||||||
|
{"state": "dword_10023688", "seed_init": "sub_10002660", "used_by": ["sub_10001720", "sub_10001A40"]},
|
||||||
|
{"state": "dword_100238C0", "seed_init": "sub_10003A50", "used_by": ["sub_10002BE0"]},
|
||||||
|
{"state": "dword_10024110", "seed_init": "sub_10009180", "used_by": ["sub_10008120", "sub_10007D10"]},
|
||||||
|
{"state": "dword_10024810", "seed_init": "sub_1000D370", "used_by": ["sub_1000BF30", "sub_1000C1A0"]},
|
||||||
|
{"state": "dword_10024A48", "seed_init": "sub_1000F420", "used_by": ["sub_1000EC50"]},
|
||||||
|
{"state": "dword_10024C80", "seed_init": "sub_10010370", "used_by": ["sub_1000F6E0"]},
|
||||||
|
{"state": "dword_100250F0", "seed_init": "sub_10012C70", "used_by": ["sub_10011230", "sub_100115C0"]},
|
||||||
|
],
|
||||||
|
"issues": issues,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="FXID absolute parity audit.")
|
||||||
|
parser.add_argument("--input", required=True, help="Root directory with game/test archives.")
|
||||||
|
parser.add_argument("--report", required=True, help="Output JSON report path.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(args.input).resolve()
|
||||||
|
report_path = Path(args.report).resolve()
|
||||||
|
payload = run_audit(root)
|
||||||
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
summary = payload["summary"]
|
||||||
|
print(f"Input root : {root}")
|
||||||
|
print(f"NRes archives : {summary['archives_total']}")
|
||||||
|
print(f"FXID payloads : {summary['fxid_ok']}/{summary['fxid_total']} valid")
|
||||||
|
print(f"Issues : {summary['issues_total']}")
|
||||||
|
print(f"Opcode6 commands : {summary['op6_commands']}")
|
||||||
|
print(f"Op1 tail6 nonzero : {summary['op1_tail6_nonzero']}")
|
||||||
|
print(f"Op1 optref non-empty : {summary['op1_optref_nonempty']}")
|
||||||
|
print(f"Report : {report_path}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
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())
|
||||||
809
tools/terrain_map_doc_validator.py
Normal file
809
tools/terrain_map_doc_validator.py
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Validate terrain/map documentation assumptions against real game data.
|
||||||
|
|
||||||
|
Targets:
|
||||||
|
- tmp/gamedata/DATA/MAPS/**/Land.msh
|
||||||
|
- tmp/gamedata/DATA/MAPS/**/Land.map
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import archive_roundtrip_validator as arv
|
||||||
|
|
||||||
|
MAGIC_NRES = b"NRes"
|
||||||
|
|
||||||
|
REQUIRED_MSH_TYPES = (1, 2, 3, 4, 5, 11, 18, 21)
|
||||||
|
OPTIONAL_MSH_TYPES = (14,)
|
||||||
|
EXPECTED_MSH_ORDER = (1, 2, 3, 4, 5, 18, 14, 11, 21)
|
||||||
|
|
||||||
|
MSH_STRIDES = {
|
||||||
|
1: 38,
|
||||||
|
3: 12,
|
||||||
|
4: 4,
|
||||||
|
5: 4,
|
||||||
|
11: 4,
|
||||||
|
14: 4,
|
||||||
|
18: 4,
|
||||||
|
21: 28,
|
||||||
|
}
|
||||||
|
|
||||||
|
SLOT_TABLE_OFFSET = 0x8C
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationIssue:
|
||||||
|
severity: str # error | warning
|
||||||
|
category: str
|
||||||
|
resource: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class TerrainMapDocValidator:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.issues: list[ValidationIssue] = []
|
||||||
|
self.stats: dict[str, Any] = {
|
||||||
|
"maps_total": 0,
|
||||||
|
"msh_total": 0,
|
||||||
|
"map_total": 0,
|
||||||
|
"msh_type_orders": Counter(),
|
||||||
|
"msh_attr_triplets": defaultdict(Counter), # type_id -> Counter[(a1,a2,a3)]
|
||||||
|
"msh_type11_header_words": Counter(),
|
||||||
|
"msh_type21_flags_top": Counter(),
|
||||||
|
"map_logic_flags": Counter(),
|
||||||
|
"map_class_ids": Counter(), # record +40
|
||||||
|
"map_poly_count": Counter(),
|
||||||
|
"map_vertex_count_min": None,
|
||||||
|
"map_vertex_count_max": None,
|
||||||
|
"map_cell_dims": Counter(),
|
||||||
|
"map_reserved_u12": Counter(),
|
||||||
|
"map_reserved_u36": Counter(),
|
||||||
|
"map_reserved_u44": Counter(),
|
||||||
|
"map_area_delta_abs_max": 0.0,
|
||||||
|
"map_area_delta_rel_max": 0.0,
|
||||||
|
"map_area_rel_gt_05_count": 0,
|
||||||
|
"map_normal_len_min": None,
|
||||||
|
"map_normal_len_max": None,
|
||||||
|
"map_records_total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_issue(self, severity: str, category: str, resource: Path, message: str) -> None:
|
||||||
|
self.issues.append(
|
||||||
|
ValidationIssue(
|
||||||
|
severity=severity,
|
||||||
|
category=category,
|
||||||
|
resource=str(resource),
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _entry_payload(self, blob: bytes, entry: dict[str, Any]) -> bytes:
|
||||||
|
start = int(entry["data_offset"])
|
||||||
|
end = start + int(entry["size"])
|
||||||
|
return blob[start:end]
|
||||||
|
|
||||||
|
def _entry_by_type(self, entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
|
||||||
|
by_type: dict[int, list[dict[str, Any]]] = {}
|
||||||
|
for item in entries:
|
||||||
|
by_type.setdefault(int(item["type_id"]), []).append(item)
|
||||||
|
return by_type
|
||||||
|
|
||||||
|
def _expect_single_type(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
by_type: dict[int, list[dict[str, Any]]],
|
||||||
|
type_id: int,
|
||||||
|
label: str,
|
||||||
|
resource: Path,
|
||||||
|
required: bool,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
rows = by_type.get(type_id, [])
|
||||||
|
if not rows:
|
||||||
|
if required:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-chunk",
|
||||||
|
resource,
|
||||||
|
f"missing required chunk type={type_id} ({label})",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if len(rows) > 1:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-chunk",
|
||||||
|
resource,
|
||||||
|
f"multiple chunks type={type_id} ({label}); using first",
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
def _check_stride(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
resource: Path,
|
||||||
|
entry: dict[str, Any],
|
||||||
|
stride: int,
|
||||||
|
label: str,
|
||||||
|
) -> int:
|
||||||
|
size = int(entry["size"])
|
||||||
|
attr1 = int(entry["attr1"])
|
||||||
|
attr2 = int(entry["attr2"])
|
||||||
|
attr3 = int(entry["attr3"])
|
||||||
|
self.stats["msh_attr_triplets"][int(entry["type_id"])][(attr1, attr2, attr3)] += 1
|
||||||
|
|
||||||
|
if size % stride != 0:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-stride",
|
||||||
|
resource,
|
||||||
|
f"{label}: size={size} is not divisible by stride={stride}",
|
||||||
|
)
|
||||||
|
return -1
|
||||||
|
|
||||||
|
count = size // stride
|
||||||
|
if attr1 != count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-attr",
|
||||||
|
resource,
|
||||||
|
f"{label}: attr1={attr1} != size/stride={count}",
|
||||||
|
)
|
||||||
|
if attr3 != stride:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-attr",
|
||||||
|
resource,
|
||||||
|
f"{label}: attr3={attr3} != {stride}",
|
||||||
|
)
|
||||||
|
if attr2 != 0 and int(entry["type_id"]) not in (1,):
|
||||||
|
# type 1 has non-zero attr2 in real assets, others are expected zero.
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-attr",
|
||||||
|
resource,
|
||||||
|
f"{label}: attr2={attr2} (expected 0 for this chunk type)",
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def validate_msh(self, path: Path) -> None:
|
||||||
|
self.stats["msh_total"] += 1
|
||||||
|
blob = path.read_bytes()
|
||||||
|
if blob[:4] != MAGIC_NRES:
|
||||||
|
self.add_issue("error", "msh-container", path, "file is not NRes")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = arv.parse_nres(blob, source=str(path))
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
self.add_issue("error", "msh-container", path, f"failed to parse NRes: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for issue in parsed.get("issues", []):
|
||||||
|
self.add_issue("warning", "msh-nres", path, issue)
|
||||||
|
|
||||||
|
entries = parsed["entries"]
|
||||||
|
types_order = tuple(int(item["type_id"]) for item in entries)
|
||||||
|
self.stats["msh_type_orders"][types_order] += 1
|
||||||
|
if types_order != EXPECTED_MSH_ORDER:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-order",
|
||||||
|
path,
|
||||||
|
f"unexpected chunk order {types_order}, expected {EXPECTED_MSH_ORDER}",
|
||||||
|
)
|
||||||
|
|
||||||
|
by_type = self._entry_by_type(entries)
|
||||||
|
|
||||||
|
chunks: dict[int, dict[str, Any]] = {}
|
||||||
|
for type_id in REQUIRED_MSH_TYPES:
|
||||||
|
chunk = self._expect_single_type(
|
||||||
|
by_type=by_type,
|
||||||
|
type_id=type_id,
|
||||||
|
label=f"type{type_id}",
|
||||||
|
resource=path,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
if chunk:
|
||||||
|
chunks[type_id] = chunk
|
||||||
|
for type_id in OPTIONAL_MSH_TYPES:
|
||||||
|
chunk = self._expect_single_type(
|
||||||
|
by_type=by_type,
|
||||||
|
type_id=type_id,
|
||||||
|
label=f"type{type_id}",
|
||||||
|
resource=path,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
if chunk:
|
||||||
|
chunks[type_id] = chunk
|
||||||
|
|
||||||
|
for type_id, stride in MSH_STRIDES.items():
|
||||||
|
chunk = chunks.get(type_id)
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
self._check_stride(resource=path, entry=chunk, stride=stride, label=f"type{type_id}")
|
||||||
|
|
||||||
|
# type 2 includes 0x8C-byte header + 68-byte slot table entries.
|
||||||
|
type2 = chunks.get(2)
|
||||||
|
if type2:
|
||||||
|
size = int(type2["size"])
|
||||||
|
attr1 = int(type2["attr1"])
|
||||||
|
attr2 = int(type2["attr2"])
|
||||||
|
attr3 = int(type2["attr3"])
|
||||||
|
self.stats["msh_attr_triplets"][2][(attr1, attr2, attr3)] += 1
|
||||||
|
if attr3 != 68:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-attr",
|
||||||
|
path,
|
||||||
|
f"type2: attr3={attr3} != 68",
|
||||||
|
)
|
||||||
|
if attr2 != 0:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-attr",
|
||||||
|
path,
|
||||||
|
f"type2: attr2={attr2} (expected 0)",
|
||||||
|
)
|
||||||
|
if size < SLOT_TABLE_OFFSET:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-size",
|
||||||
|
path,
|
||||||
|
f"type2: size={size} < header_size={SLOT_TABLE_OFFSET}",
|
||||||
|
)
|
||||||
|
elif (size - SLOT_TABLE_OFFSET) % 68 != 0:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-size",
|
||||||
|
path,
|
||||||
|
f"type2: (size - 0x8C) is not divisible by 68 (size={size})",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
slots_by_size = (size - SLOT_TABLE_OFFSET) // 68
|
||||||
|
if attr1 != slots_by_size:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-attr",
|
||||||
|
path,
|
||||||
|
f"type2: attr1={attr1} != (size-0x8C)/68={slots_by_size}",
|
||||||
|
)
|
||||||
|
|
||||||
|
verts = chunks.get(3)
|
||||||
|
face = chunks.get(21)
|
||||||
|
slots = chunks.get(2)
|
||||||
|
nodes = chunks.get(1)
|
||||||
|
type11 = chunks.get(11)
|
||||||
|
|
||||||
|
if verts and face:
|
||||||
|
vcount = int(verts["attr1"])
|
||||||
|
face_payload = self._entry_payload(blob, face)
|
||||||
|
fcount = int(face["attr1"])
|
||||||
|
if len(face_payload) >= 28:
|
||||||
|
for idx in range(fcount):
|
||||||
|
off = idx * 28
|
||||||
|
if off + 28 > len(face_payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-face",
|
||||||
|
path,
|
||||||
|
f"type21 truncated at face {idx}",
|
||||||
|
)
|
||||||
|
break
|
||||||
|
flags = struct.unpack_from("<I", face_payload, off)[0]
|
||||||
|
self.stats["msh_type21_flags_top"][flags] += 1
|
||||||
|
i0, i1, i2 = struct.unpack_from("<HHH", face_payload, off + 8)
|
||||||
|
for name, value in (("i0", i0), ("i1", i1), ("i2", i2)):
|
||||||
|
if value >= vcount:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-face-index",
|
||||||
|
path,
|
||||||
|
f"type21[{idx}].{name}={value} out of range vertex_count={vcount}",
|
||||||
|
)
|
||||||
|
n0, n1, n2 = struct.unpack_from("<HHH", face_payload, off + 14)
|
||||||
|
for name, value in (("n0", n0), ("n1", n1), ("n2", n2)):
|
||||||
|
if value != 0xFFFF and value >= fcount:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-face-neighbour",
|
||||||
|
path,
|
||||||
|
f"type21[{idx}].{name}={value} out of range face_count={fcount}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if slots and face:
|
||||||
|
slot_count = int(slots["attr1"])
|
||||||
|
face_count = int(face["attr1"])
|
||||||
|
slot_payload = self._entry_payload(blob, slots)
|
||||||
|
need = SLOT_TABLE_OFFSET + slot_count * 68
|
||||||
|
if len(slot_payload) < need:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-slot",
|
||||||
|
path,
|
||||||
|
f"type2 payload too short: size={len(slot_payload)}, need_at_least={need}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if len(slot_payload) != need:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-slot",
|
||||||
|
path,
|
||||||
|
f"type2 payload has trailing bytes: size={len(slot_payload)}, expected={need}",
|
||||||
|
)
|
||||||
|
for idx in range(slot_count):
|
||||||
|
off = SLOT_TABLE_OFFSET + idx * 68
|
||||||
|
tri_start, tri_count = struct.unpack_from("<HH", slot_payload, off)
|
||||||
|
if tri_start + tri_count > face_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-slot-range",
|
||||||
|
path,
|
||||||
|
f"type2 slot[{idx}] range [{tri_start}, {tri_start + tri_count}) exceeds face_count={face_count}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if nodes and slots:
|
||||||
|
node_payload = self._entry_payload(blob, nodes)
|
||||||
|
slot_count = int(slots["attr1"])
|
||||||
|
node_count = int(nodes["attr1"])
|
||||||
|
for node_idx in range(node_count):
|
||||||
|
off = node_idx * 38
|
||||||
|
if off + 38 > len(node_payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-node",
|
||||||
|
path,
|
||||||
|
f"type1 truncated at node {node_idx}",
|
||||||
|
)
|
||||||
|
break
|
||||||
|
for j in range(19):
|
||||||
|
slot_id = struct.unpack_from("<H", node_payload, off + j * 2)[0]
|
||||||
|
if slot_id != 0xFFFF and slot_id >= slot_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-node-slot",
|
||||||
|
path,
|
||||||
|
f"type1 node[{node_idx}] slot[{j}]={slot_id} out of range slot_count={slot_count}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if type11:
|
||||||
|
payload = self._entry_payload(blob, type11)
|
||||||
|
if len(payload) >= 8:
|
||||||
|
w0, w1 = struct.unpack_from("<II", payload, 0)
|
||||||
|
self.stats["msh_type11_header_words"][(w0, w1)] += 1
|
||||||
|
else:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-type11",
|
||||||
|
path,
|
||||||
|
f"type11 payload too short: {len(payload)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_minmax(self, key_min: str, key_max: str, value: float) -> None:
|
||||||
|
if self.stats[key_min] is None or value < self.stats[key_min]:
|
||||||
|
self.stats[key_min] = value
|
||||||
|
if self.stats[key_max] is None or value > self.stats[key_max]:
|
||||||
|
self.stats[key_max] = value
|
||||||
|
|
||||||
|
def validate_map(self, path: Path) -> None:
|
||||||
|
self.stats["map_total"] += 1
|
||||||
|
blob = path.read_bytes()
|
||||||
|
if blob[:4] != MAGIC_NRES:
|
||||||
|
self.add_issue("error", "map-container", path, "file is not NRes")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = arv.parse_nres(blob, source=str(path))
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
self.add_issue("error", "map-container", path, f"failed to parse NRes: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for issue in parsed.get("issues", []):
|
||||||
|
self.add_issue("warning", "map-nres", path, issue)
|
||||||
|
|
||||||
|
entries = parsed["entries"]
|
||||||
|
if len(entries) != 1 or int(entries[0]["type_id"]) != 12:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-chunk",
|
||||||
|
path,
|
||||||
|
f"expected single chunk type=12, got {[int(e['type_id']) for e in entries]}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
entry = entries[0]
|
||||||
|
areal_count = int(entry["attr1"])
|
||||||
|
if areal_count <= 0:
|
||||||
|
self.add_issue("error", "map-areal", path, f"invalid areal_count={areal_count}")
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = self._entry_payload(blob, entry)
|
||||||
|
ptr = 0
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for idx in range(areal_count):
|
||||||
|
if ptr + 56 > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-record",
|
||||||
|
path,
|
||||||
|
f"truncated areal header at index={idx}, ptr={ptr}, size={len(payload)}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
anchor_x, anchor_y, anchor_z = struct.unpack_from("<fff", payload, ptr)
|
||||||
|
u12 = struct.unpack_from("<I", payload, ptr + 12)[0]
|
||||||
|
area_f = struct.unpack_from("<f", payload, ptr + 16)[0]
|
||||||
|
nx, ny, nz = struct.unpack_from("<fff", payload, ptr + 20)
|
||||||
|
logic_flag = struct.unpack_from("<I", payload, ptr + 32)[0]
|
||||||
|
u36 = struct.unpack_from("<I", payload, ptr + 36)[0]
|
||||||
|
class_id = struct.unpack_from("<I", payload, ptr + 40)[0]
|
||||||
|
u44 = struct.unpack_from("<I", payload, ptr + 44)[0]
|
||||||
|
vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48)
|
||||||
|
|
||||||
|
self.stats["map_records_total"] += 1
|
||||||
|
self.stats["map_logic_flags"][logic_flag] += 1
|
||||||
|
self.stats["map_class_ids"][class_id] += 1
|
||||||
|
self.stats["map_poly_count"][poly_count] += 1
|
||||||
|
self.stats["map_reserved_u12"][u12] += 1
|
||||||
|
self.stats["map_reserved_u36"][u36] += 1
|
||||||
|
self.stats["map_reserved_u44"][u44] += 1
|
||||||
|
self._update_minmax("map_vertex_count_min", "map_vertex_count_max", float(vertex_count))
|
||||||
|
|
||||||
|
normal_len = math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||||
|
self._update_minmax("map_normal_len_min", "map_normal_len_max", normal_len)
|
||||||
|
if abs(normal_len - 1.0) > 1e-3:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"map-normal",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] normal length={normal_len:.6f} (expected ~1.0)",
|
||||||
|
)
|
||||||
|
|
||||||
|
vertices_off = ptr + 56
|
||||||
|
vertices_size = 12 * vertex_count
|
||||||
|
if vertices_off + vertices_size > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-vertices",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] vertices out of bounds",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
vertices: list[tuple[float, float, float]] = []
|
||||||
|
for i in range(vertex_count):
|
||||||
|
vertices.append(struct.unpack_from("<fff", payload, vertices_off + i * 12))
|
||||||
|
|
||||||
|
if vertex_count >= 3:
|
||||||
|
# signed shoelace area in XY.
|
||||||
|
shoelace = 0.0
|
||||||
|
for i in range(vertex_count):
|
||||||
|
x1, y1, _ = vertices[i]
|
||||||
|
x2, y2, _ = vertices[(i + 1) % vertex_count]
|
||||||
|
shoelace += x1 * y2 - x2 * y1
|
||||||
|
area_xy = abs(shoelace) * 0.5
|
||||||
|
delta = abs(area_xy - area_f)
|
||||||
|
if delta > self.stats["map_area_delta_abs_max"]:
|
||||||
|
self.stats["map_area_delta_abs_max"] = delta
|
||||||
|
rel_delta = delta / max(1.0, area_xy)
|
||||||
|
if rel_delta > self.stats["map_area_delta_rel_max"]:
|
||||||
|
self.stats["map_area_delta_rel_max"] = rel_delta
|
||||||
|
if rel_delta > 0.05:
|
||||||
|
self.stats["map_area_rel_gt_05_count"] += 1
|
||||||
|
|
||||||
|
links_off = vertices_off + vertices_size
|
||||||
|
link_count = vertex_count + 3 * poly_count
|
||||||
|
links_size = 8 * link_count
|
||||||
|
if links_off + links_size > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-links",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] link table out of bounds",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
edge_links: list[tuple[int, int]] = []
|
||||||
|
for i in range(vertex_count):
|
||||||
|
area_ref, edge_ref = struct.unpack_from("<ii", payload, links_off + i * 8)
|
||||||
|
edge_links.append((area_ref, edge_ref))
|
||||||
|
|
||||||
|
poly_links_off = links_off + 8 * vertex_count
|
||||||
|
poly_links: list[tuple[int, int]] = []
|
||||||
|
for i in range(3 * poly_count):
|
||||||
|
area_ref, edge_ref = struct.unpack_from("<ii", payload, poly_links_off + i * 8)
|
||||||
|
poly_links.append((area_ref, edge_ref))
|
||||||
|
|
||||||
|
p = links_off + links_size
|
||||||
|
for poly_idx in range(poly_count):
|
||||||
|
if p + 4 > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-poly",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] poly header truncated at poly_idx={poly_idx}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
n = struct.unpack_from("<I", payload, p)[0]
|
||||||
|
poly_size = 4 * (3 * n + 1)
|
||||||
|
if p + poly_size > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-poly",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] poly data out of bounds at poly_idx={poly_idx}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
p += poly_size
|
||||||
|
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"index": idx,
|
||||||
|
"anchor": (anchor_x, anchor_y, anchor_z),
|
||||||
|
"logic": logic_flag,
|
||||||
|
"class_id": class_id,
|
||||||
|
"vertex_count": vertex_count,
|
||||||
|
"poly_count": poly_count,
|
||||||
|
"edge_links": edge_links,
|
||||||
|
"poly_links": poly_links,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ptr = p
|
||||||
|
|
||||||
|
vertex_counts = [int(item["vertex_count"]) for item in records]
|
||||||
|
for rec in records:
|
||||||
|
idx = int(rec["index"])
|
||||||
|
for link_idx, (area_ref, edge_ref) in enumerate(rec["edge_links"]):
|
||||||
|
if area_ref == -1:
|
||||||
|
if edge_ref != -1:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"map-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] edge_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if area_ref < 0 or area_ref >= areal_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] edge_link[{link_idx}] area_ref={area_ref} out of range",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
dst_vcount = vertex_counts[area_ref]
|
||||||
|
if edge_ref < 0 or edge_ref >= dst_vcount:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] edge_link[{link_idx}] edge_ref={edge_ref} out of range dst_vertex_count={dst_vcount}",
|
||||||
|
)
|
||||||
|
|
||||||
|
for link_idx, (area_ref, edge_ref) in enumerate(rec["poly_links"]):
|
||||||
|
if area_ref == -1:
|
||||||
|
if edge_ref != -1:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"map-poly-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] poly_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if area_ref < 0 or area_ref >= areal_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-poly-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] poly_link[{link_idx}] area_ref={area_ref} out of range",
|
||||||
|
)
|
||||||
|
|
||||||
|
if ptr + 8 > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"missing cells header at ptr={ptr}, size={len(payload)}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cells_x, cells_y = struct.unpack_from("<II", payload, ptr)
|
||||||
|
self.stats["map_cell_dims"][(cells_x, cells_y)] += 1
|
||||||
|
ptr += 8
|
||||||
|
if cells_x <= 0 or cells_y <= 0:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"invalid cells dimensions {cells_x}x{cells_y}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for x in range(cells_x):
|
||||||
|
for y in range(cells_y):
|
||||||
|
if ptr + 2 > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"truncated hitCount at cell ({x},{y})",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
hit_count = struct.unpack_from("<H", payload, ptr)[0]
|
||||||
|
ptr += 2
|
||||||
|
need = 2 * hit_count
|
||||||
|
if ptr + need > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"truncated areaIds at cell ({x},{y}), hitCount={hit_count}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for i in range(hit_count):
|
||||||
|
area_id = struct.unpack_from("<H", payload, ptr + 2 * i)[0]
|
||||||
|
if area_id >= areal_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"cell ({x},{y}) has area_id={area_id} out of range areal_count={areal_count}",
|
||||||
|
)
|
||||||
|
ptr += need
|
||||||
|
|
||||||
|
if ptr != len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-size",
|
||||||
|
path,
|
||||||
|
f"payload tail mismatch: consumed={ptr}, payload_size={len(payload)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, maps_root: Path) -> None:
|
||||||
|
msh_paths = sorted(maps_root.rglob("Land.msh"))
|
||||||
|
map_paths = sorted(maps_root.rglob("Land.map"))
|
||||||
|
|
||||||
|
msh_by_dir = {path.parent: path for path in msh_paths}
|
||||||
|
map_by_dir = {path.parent: path for path in map_paths}
|
||||||
|
|
||||||
|
all_dirs = sorted(set(msh_by_dir) | set(map_by_dir))
|
||||||
|
self.stats["maps_total"] = len(all_dirs)
|
||||||
|
|
||||||
|
for folder in all_dirs:
|
||||||
|
msh_path = msh_by_dir.get(folder)
|
||||||
|
map_path = map_by_dir.get(folder)
|
||||||
|
if msh_path is None:
|
||||||
|
self.add_issue("error", "pairing", folder, "missing Land.msh")
|
||||||
|
continue
|
||||||
|
if map_path is None:
|
||||||
|
self.add_issue("error", "pairing", folder, "missing Land.map")
|
||||||
|
continue
|
||||||
|
self.validate_msh(msh_path)
|
||||||
|
self.validate_map(map_path)
|
||||||
|
|
||||||
|
def build_report(self) -> dict[str, Any]:
|
||||||
|
errors = [i for i in self.issues if i.severity == "error"]
|
||||||
|
warnings = [i for i in self.issues if i.severity == "warning"]
|
||||||
|
|
||||||
|
# Convert counters/defaultdicts to JSON-friendly dicts.
|
||||||
|
msh_orders = {
|
||||||
|
str(list(order)): count
|
||||||
|
for order, count in self.stats["msh_type_orders"].most_common()
|
||||||
|
}
|
||||||
|
msh_attrs = {
|
||||||
|
str(type_id): {str(list(k)): v for k, v in counter.most_common()}
|
||||||
|
for type_id, counter in self.stats["msh_attr_triplets"].items()
|
||||||
|
}
|
||||||
|
type11_hdr = {
|
||||||
|
str(list(key)): value
|
||||||
|
for key, value in self.stats["msh_type11_header_words"].most_common()
|
||||||
|
}
|
||||||
|
type21_flags = {
|
||||||
|
f"0x{key:08X}": value
|
||||||
|
for key, value in self.stats["msh_type21_flags_top"].most_common(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"maps_total": self.stats["maps_total"],
|
||||||
|
"msh_total": self.stats["msh_total"],
|
||||||
|
"map_total": self.stats["map_total"],
|
||||||
|
"issues_total": len(self.issues),
|
||||||
|
"errors_total": len(errors),
|
||||||
|
"warnings_total": len(warnings),
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"msh_type_orders": msh_orders,
|
||||||
|
"msh_attr_triplets": msh_attrs,
|
||||||
|
"msh_type11_header_words": type11_hdr,
|
||||||
|
"msh_type21_flags_top": type21_flags,
|
||||||
|
"map_logic_flags": dict(self.stats["map_logic_flags"]),
|
||||||
|
"map_class_ids": dict(self.stats["map_class_ids"]),
|
||||||
|
"map_poly_count": dict(self.stats["map_poly_count"]),
|
||||||
|
"map_vertex_count_min": self.stats["map_vertex_count_min"],
|
||||||
|
"map_vertex_count_max": self.stats["map_vertex_count_max"],
|
||||||
|
"map_cell_dims": {str(list(k)): v for k, v in self.stats["map_cell_dims"].items()},
|
||||||
|
"map_reserved_u12": dict(self.stats["map_reserved_u12"]),
|
||||||
|
"map_reserved_u36": dict(self.stats["map_reserved_u36"]),
|
||||||
|
"map_reserved_u44": dict(self.stats["map_reserved_u44"]),
|
||||||
|
"map_area_delta_abs_max": self.stats["map_area_delta_abs_max"],
|
||||||
|
"map_area_delta_rel_max": self.stats["map_area_delta_rel_max"],
|
||||||
|
"map_area_rel_gt_05_count": self.stats["map_area_rel_gt_05_count"],
|
||||||
|
"map_normal_len_min": self.stats["map_normal_len_min"],
|
||||||
|
"map_normal_len_max": self.stats["map_normal_len_max"],
|
||||||
|
"map_records_total": self.stats["map_records_total"],
|
||||||
|
},
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"severity": item.severity,
|
||||||
|
"category": item.category,
|
||||||
|
"resource": item.resource,
|
||||||
|
"message": item.message,
|
||||||
|
}
|
||||||
|
for item in self.issues
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Validate terrain/map doc assumptions")
|
||||||
|
parser.add_argument(
|
||||||
|
"--maps-root",
|
||||||
|
type=Path,
|
||||||
|
default=Path("tmp/gamedata/DATA/MAPS"),
|
||||||
|
help="Root directory containing MAPS/**/Land.msh and Land.map",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--report-json",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Optional path to save full JSON report",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fail-on-warning",
|
||||||
|
action="store_true",
|
||||||
|
help="Return non-zero exit code on warnings too",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
validator = TerrainMapDocValidator()
|
||||||
|
validator.validate(args.maps_root)
|
||||||
|
report = validator.build_report()
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
report["summary"],
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.report_json:
|
||||||
|
args.report_json.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with args.report_json.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(report, handle, indent=2, ensure_ascii=False)
|
||||||
|
handle.write("\n")
|
||||||
|
print(f"report written: {args.report_json}")
|
||||||
|
|
||||||
|
has_errors = report["summary"]["errors_total"] > 0
|
||||||
|
has_warnings = report["summary"]["warnings_total"] > 0
|
||||||
|
if has_errors:
|
||||||
|
return 1
|
||||||
|
if args.fail_on_warning and has_warnings:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
679
tools/terrain_map_preview_renderer.py
Normal file
679
tools/terrain_map_preview_renderer.py
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Software 3D renderer for terrain Land.msh + Land.map overlay.
|
||||||
|
|
||||||
|
Output format: binary PPM (P6), dependency-free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 _downsample_faces(
|
||||||
|
faces: list[tuple[int, int, int]],
|
||||||
|
max_faces: int,
|
||||||
|
) -> list[tuple[int, int, int]]:
|
||||||
|
if max_faces <= 0 or len(faces) <= max_faces:
|
||||||
|
return faces
|
||||||
|
step = len(faces) / max_faces
|
||||||
|
out: list[tuple[int, int, int]] = []
|
||||||
|
pos = 0.0
|
||||||
|
while len(out) < max_faces and int(pos) < len(faces):
|
||||||
|
out.append(faces[int(pos)])
|
||||||
|
pos += step
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_terrain_msh(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
max_faces: int,
|
||||||
|
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]:
|
||||||
|
blob = path.read_bytes()
|
||||||
|
parsed = _parse_nres(blob, str(path))
|
||||||
|
by_type = _by_type(parsed["entries"])
|
||||||
|
|
||||||
|
res3 = _get_single(by_type, 3, "positions")
|
||||||
|
res21 = _get_single(by_type, 21, "terrain faces")
|
||||||
|
|
||||||
|
pos_blob = _entry_payload(blob, res3)
|
||||||
|
if len(pos_blob) % 12 != 0:
|
||||||
|
raise RuntimeError(f"{path}: type 3 payload size is not divisible by 12")
|
||||||
|
vertex_count = len(pos_blob) // 12
|
||||||
|
positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)]
|
||||||
|
|
||||||
|
face_blob = _entry_payload(blob, res21)
|
||||||
|
if len(face_blob) % 28 != 0:
|
||||||
|
raise RuntimeError(f"{path}: type 21 payload size is not divisible by 28")
|
||||||
|
all_faces: list[tuple[int, int, int]] = []
|
||||||
|
raw_face_count = len(face_blob) // 28
|
||||||
|
dropped = 0
|
||||||
|
for i in range(raw_face_count):
|
||||||
|
off = i * 28
|
||||||
|
i0, i1, i2 = struct.unpack_from("<HHH", face_blob, off + 8)
|
||||||
|
if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
|
||||||
|
dropped += 1
|
||||||
|
continue
|
||||||
|
all_faces.append((i0, i1, i2))
|
||||||
|
|
||||||
|
faces = _downsample_faces(all_faces, max_faces)
|
||||||
|
meta = {
|
||||||
|
"vertex_count": vertex_count,
|
||||||
|
"face_count_raw": raw_face_count,
|
||||||
|
"face_count_valid": len(all_faces),
|
||||||
|
"face_count_rendered": len(faces),
|
||||||
|
"face_dropped_invalid": dropped,
|
||||||
|
}
|
||||||
|
return positions, faces, meta
|
||||||
|
|
||||||
|
|
||||||
|
def load_areal_map(path: Path) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
||||||
|
blob = path.read_bytes()
|
||||||
|
parsed = _parse_nres(blob, str(path))
|
||||||
|
by_type = _by_type(parsed["entries"])
|
||||||
|
chunk = _get_single(by_type, 12, "ArealMapGeometry")
|
||||||
|
|
||||||
|
payload = _entry_payload(blob, chunk)
|
||||||
|
areal_count = int(chunk["attr1"])
|
||||||
|
ptr = 0
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
for idx in range(areal_count):
|
||||||
|
if ptr + 56 > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: truncated areal header at index={idx}")
|
||||||
|
class_id = struct.unpack_from("<I", payload, ptr + 40)[0]
|
||||||
|
vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48)
|
||||||
|
verts_off = ptr + 56
|
||||||
|
verts_size = 12 * vertex_count
|
||||||
|
if verts_off + verts_size > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: areal[{idx}] vertices out of bounds")
|
||||||
|
verts = [struct.unpack_from("<3f", payload, verts_off + 12 * i) for i in range(vertex_count)]
|
||||||
|
|
||||||
|
links_off = verts_off + verts_size
|
||||||
|
links_size = 8 * (vertex_count + 3 * poly_count)
|
||||||
|
p = links_off + links_size
|
||||||
|
for _ in range(poly_count):
|
||||||
|
if p + 4 > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: areal[{idx}] poly header out of bounds")
|
||||||
|
n = struct.unpack_from("<I", payload, p)[0]
|
||||||
|
p += 4 * (3 * n + 1)
|
||||||
|
if p > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: areal[{idx}] poly data out of bounds")
|
||||||
|
|
||||||
|
areals.append(
|
||||||
|
{
|
||||||
|
"index": idx,
|
||||||
|
"class_id": class_id,
|
||||||
|
"vertices": verts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ptr = p
|
||||||
|
|
||||||
|
if ptr + 8 > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: missing cells section")
|
||||||
|
cells_x, cells_y = struct.unpack_from("<II", payload, ptr)
|
||||||
|
ptr += 8
|
||||||
|
for _x in range(cells_x):
|
||||||
|
for _y in range(cells_y):
|
||||||
|
if ptr + 2 > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: cells section truncated")
|
||||||
|
hit_count = struct.unpack_from("<H", payload, ptr)[0]
|
||||||
|
ptr += 2 + 2 * hit_count
|
||||||
|
if ptr > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: cells section out of bounds")
|
||||||
|
if ptr != len(payload):
|
||||||
|
raise RuntimeError(f"{path}: trailing bytes in chunk12 parse ({len(payload) - ptr})")
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"areal_count": areal_count,
|
||||||
|
"cells_x": cells_x,
|
||||||
|
"cells_y": cells_y,
|
||||||
|
}
|
||||||
|
return areals, meta
|
||||||
|
|
||||||
|
|
||||||
|
def _color_for_class(class_id: int) -> tuple[int, int, int]:
|
||||||
|
x = (class_id * 1103515245 + 12345) & 0x7FFFFFFF
|
||||||
|
r = 60 + (x & 0x7F)
|
||||||
|
g = 60 + ((x >> 7) & 0x7F)
|
||||||
|
b = 60 + ((x >> 14) & 0x7F)
|
||||||
|
return r, g, b
|
||||||
|
|
||||||
|
|
||||||
|
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 _write_obj(
|
||||||
|
path: Path,
|
||||||
|
terrain_positions: list[tuple[float, float, float]],
|
||||||
|
terrain_faces: list[tuple[int, int, int]],
|
||||||
|
areals: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
include_areals: bool,
|
||||||
|
) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8", newline="\n") as out:
|
||||||
|
out.write("# Exported by terrain_map_preview_renderer.py\n")
|
||||||
|
out.write("o terrain\n")
|
||||||
|
for x, y, z in terrain_positions:
|
||||||
|
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
|
||||||
|
for i0, i1, i2 in terrain_faces:
|
||||||
|
# OBJ indices are 1-based.
|
||||||
|
out.write(f"f {i0 + 1} {i1 + 1} {i2 + 1}\n")
|
||||||
|
|
||||||
|
if include_areals and areals:
|
||||||
|
base = len(terrain_positions)
|
||||||
|
area_vertex_counts: list[int] = []
|
||||||
|
out.write("o areal_edges\n")
|
||||||
|
for area in areals:
|
||||||
|
verts = area["vertices"]
|
||||||
|
area_vertex_counts.append(len(verts))
|
||||||
|
for x, y, z in verts:
|
||||||
|
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
|
||||||
|
|
||||||
|
ptr = base
|
||||||
|
for area_idx, area in enumerate(areals):
|
||||||
|
cnt = area_vertex_counts[area_idx]
|
||||||
|
if cnt < 2:
|
||||||
|
ptr += cnt
|
||||||
|
continue
|
||||||
|
# closed polyline.
|
||||||
|
line = [str(ptr + i + 1) for i in range(cnt)]
|
||||||
|
line.append(str(ptr + 1))
|
||||||
|
out.write("l " + " ".join(line) + "\n")
|
||||||
|
ptr += cnt
|
||||||
|
|
||||||
|
|
||||||
|
def _render_scene(
|
||||||
|
terrain_positions: list[tuple[float, float, float]],
|
||||||
|
terrain_faces: list[tuple[int, int, int]],
|
||||||
|
areals: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
yaw_deg: float,
|
||||||
|
pitch_deg: float,
|
||||||
|
wireframe: bool,
|
||||||
|
areal_overlay: bool,
|
||||||
|
) -> bytearray:
|
||||||
|
all_positions = list(terrain_positions)
|
||||||
|
if areal_overlay:
|
||||||
|
for area in areals:
|
||||||
|
all_positions.extend(area["vertices"])
|
||||||
|
if not all_positions:
|
||||||
|
raise RuntimeError("scene is empty")
|
||||||
|
|
||||||
|
xs = [p[0] for p in all_positions]
|
||||||
|
ys = [p[1] for p in all_positions]
|
||||||
|
zs = [p[2] for p in all_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.96
|
||||||
|
|
||||||
|
# Terrain transform cache.
|
||||||
|
vx: list[float] = []
|
||||||
|
vy: list[float] = []
|
||||||
|
vz: list[float] = []
|
||||||
|
sx: list[float] = []
|
||||||
|
sy: list[float] = []
|
||||||
|
for x, y, z in terrain_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)
|
||||||
|
|
||||||
|
def project_point(x: float, y: float, z: float) -> tuple[float, float, float]:
|
||||||
|
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
|
||||||
|
px = width * 0.5 + (x1 / z2) * scale
|
||||||
|
py = height * 0.5 - (y2 / z2) * scale
|
||||||
|
return px, py, z2
|
||||||
|
|
||||||
|
rgb = bytearray([14, 16, 20] * (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 terrain_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
|
||||||
|
|
||||||
|
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 + 185 * intensity)
|
||||||
|
color = (min(255, shade + 6), min(255, shade + 14), min(255, shade + 28))
|
||||||
|
|
||||||
|
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]
|
||||||
|
inv_area = 1.0 / area
|
||||||
|
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
|
||||||
|
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]
|
||||||
|
|
||||||
|
def draw_line(
|
||||||
|
xa: float,
|
||||||
|
ya: float,
|
||||||
|
xb: float,
|
||||||
|
yb: float,
|
||||||
|
color: tuple[int, int, int],
|
||||||
|
) -> 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] = color[0]
|
||||||
|
rgb[p + 1] = color[1]
|
||||||
|
rgb[p + 2] = color[2]
|
||||||
|
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
|
||||||
|
|
||||||
|
if wireframe:
|
||||||
|
wf = (225, 232, 246)
|
||||||
|
for i0, i1, i2 in terrain_faces:
|
||||||
|
draw_line(sx[i0], sy[i0], sx[i1], sy[i1], wf)
|
||||||
|
draw_line(sx[i1], sy[i1], sx[i2], sy[i2], wf)
|
||||||
|
draw_line(sx[i2], sy[i2], sx[i0], sy[i0], wf)
|
||||||
|
|
||||||
|
if areal_overlay:
|
||||||
|
for area in areals:
|
||||||
|
verts = area["vertices"]
|
||||||
|
if len(verts) < 2:
|
||||||
|
continue
|
||||||
|
color = _color_for_class(int(area["class_id"]))
|
||||||
|
projected = [project_point(x, y, z + 0.35) for x, y, z in verts]
|
||||||
|
for i in range(len(projected)):
|
||||||
|
x0, y0, _ = projected[i]
|
||||||
|
x1, y1, _ = projected[(i + 1) % len(projected)]
|
||||||
|
draw_line(x0, y0, x1, y1, color)
|
||||||
|
|
||||||
|
return rgb
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_render(args: argparse.Namespace) -> int:
|
||||||
|
msh_path = Path(args.land_msh).resolve()
|
||||||
|
map_path = Path(args.land_map).resolve() if args.land_map else None
|
||||||
|
output_path = Path(args.output).resolve()
|
||||||
|
|
||||||
|
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
map_meta: dict[str, int] = {"areal_count": 0, "cells_x": 0, "cells_y": 0}
|
||||||
|
if map_path:
|
||||||
|
areals, map_meta = load_areal_map(map_path)
|
||||||
|
|
||||||
|
rgb = _render_scene(
|
||||||
|
positions,
|
||||||
|
faces,
|
||||||
|
areals,
|
||||||
|
width=int(args.width),
|
||||||
|
height=int(args.height),
|
||||||
|
yaw_deg=float(args.yaw),
|
||||||
|
pitch_deg=float(args.pitch),
|
||||||
|
wireframe=bool(args.wireframe),
|
||||||
|
areal_overlay=bool(args.overlay_areals),
|
||||||
|
)
|
||||||
|
_write_ppm(output_path, int(args.width), int(args.height), rgb)
|
||||||
|
|
||||||
|
print(f"Rendered terrain : {msh_path}")
|
||||||
|
if map_path:
|
||||||
|
print(f"Areal overlay : {map_path}")
|
||||||
|
print(f"Output : {output_path}")
|
||||||
|
print(
|
||||||
|
"Terrain geometry : "
|
||||||
|
f"vertices={terrain_meta['vertex_count']}, "
|
||||||
|
f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']} "
|
||||||
|
f"(raw={terrain_meta['face_count_raw']}, dropped={terrain_meta['face_dropped_invalid']})"
|
||||||
|
)
|
||||||
|
if map_path:
|
||||||
|
print(
|
||||||
|
"Areal map : "
|
||||||
|
f"areals={map_meta['areal_count']}, cells={map_meta['cells_x']}x{map_meta['cells_y']}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_export_obj(args: argparse.Namespace) -> int:
|
||||||
|
msh_path = Path(args.land_msh).resolve()
|
||||||
|
map_path = Path(args.land_map).resolve() if args.land_map else None
|
||||||
|
output_path = Path(args.output).resolve()
|
||||||
|
|
||||||
|
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
if map_path and bool(args.include_areals):
|
||||||
|
areals, _ = load_areal_map(map_path)
|
||||||
|
|
||||||
|
_write_obj(
|
||||||
|
output_path,
|
||||||
|
positions,
|
||||||
|
faces,
|
||||||
|
areals,
|
||||||
|
include_areals=bool(args.include_areals),
|
||||||
|
)
|
||||||
|
|
||||||
|
areal_vertices = sum(len(a["vertices"]) for a in areals)
|
||||||
|
print(f"Terrain source : {msh_path}")
|
||||||
|
if map_path:
|
||||||
|
print(f"Areal source : {map_path}")
|
||||||
|
print(f"OBJ output : {output_path}")
|
||||||
|
print(
|
||||||
|
"Terrain geometry : "
|
||||||
|
f"vertices={terrain_meta['vertex_count']}, "
|
||||||
|
f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']}"
|
||||||
|
)
|
||||||
|
if bool(args.include_areals):
|
||||||
|
print(f"Areal edges : areals={len(areals)}, extra_vertices={areal_vertices}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_render_turntable(args: argparse.Namespace) -> int:
|
||||||
|
msh_path = Path(args.land_msh).resolve()
|
||||||
|
map_path = Path(args.land_map).resolve() if args.land_map else None
|
||||||
|
output_dir = Path(args.output_dir).resolve()
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
frames = int(args.frames)
|
||||||
|
if frames <= 0:
|
||||||
|
raise RuntimeError("--frames must be > 0")
|
||||||
|
|
||||||
|
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
if map_path:
|
||||||
|
areals, _ = load_areal_map(map_path)
|
||||||
|
|
||||||
|
yaw_start = float(args.yaw_start)
|
||||||
|
yaw_end = float(args.yaw_end)
|
||||||
|
if frames == 1:
|
||||||
|
yaws = [yaw_start]
|
||||||
|
else:
|
||||||
|
step = (yaw_end - yaw_start) / (frames - 1)
|
||||||
|
yaws = [yaw_start + i * step for i in range(frames)]
|
||||||
|
|
||||||
|
prefix = str(args.prefix)
|
||||||
|
for i, yaw in enumerate(yaws):
|
||||||
|
rgb = _render_scene(
|
||||||
|
positions,
|
||||||
|
faces,
|
||||||
|
areals,
|
||||||
|
width=int(args.width),
|
||||||
|
height=int(args.height),
|
||||||
|
yaw_deg=yaw,
|
||||||
|
pitch_deg=float(args.pitch),
|
||||||
|
wireframe=bool(args.wireframe),
|
||||||
|
areal_overlay=bool(args.overlay_areals),
|
||||||
|
)
|
||||||
|
out = output_dir / f"{prefix}_{i:03d}.ppm"
|
||||||
|
_write_ppm(out, int(args.width), int(args.height), rgb)
|
||||||
|
|
||||||
|
print(f"Turntable source : {msh_path}")
|
||||||
|
if map_path:
|
||||||
|
print(f"Areal source : {map_path}")
|
||||||
|
print(f"Output dir : {output_dir}")
|
||||||
|
print(f"Frames : {frames} ({yaws[0]:.3f} -> {yaws[-1]:.3f} yaw)")
|
||||||
|
print(
|
||||||
|
"Terrain geometry : "
|
||||||
|
f"vertices={terrain_meta['vertex_count']}, faces={terrain_meta['face_count_rendered']}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_render_batch(args: argparse.Namespace) -> int:
|
||||||
|
maps_root = Path(args.maps_root).resolve()
|
||||||
|
output_dir = Path(args.output_dir).resolve()
|
||||||
|
msh_paths = sorted(maps_root.rglob("Land.msh"))
|
||||||
|
if not msh_paths:
|
||||||
|
raise RuntimeError(f"no Land.msh files under {maps_root}")
|
||||||
|
|
||||||
|
rendered = 0
|
||||||
|
skipped = 0
|
||||||
|
for msh_path in msh_paths:
|
||||||
|
map_path = msh_path.with_name("Land.map")
|
||||||
|
if not map_path.exists():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
rel = msh_path.parent.relative_to(maps_root)
|
||||||
|
out = output_dir / f"{rel.as_posix().replace('/', '__')}.ppm"
|
||||||
|
cmd_render(
|
||||||
|
argparse.Namespace(
|
||||||
|
land_msh=str(msh_path),
|
||||||
|
land_map=str(map_path),
|
||||||
|
output=str(out),
|
||||||
|
max_faces=args.max_faces,
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
yaw=args.yaw,
|
||||||
|
pitch=args.pitch,
|
||||||
|
wireframe=args.wireframe,
|
||||||
|
overlay_areals=args.overlay_areals,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rendered += 1
|
||||||
|
|
||||||
|
print(f"Batch summary: rendered={rendered}, skipped_no_map={skipped}, output_dir={output_dir}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Software 3D terrain renderer (Land.msh + optional Land.map overlay)."
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
render = sub.add_parser("render", help="Render one terrain map to PPM.")
|
||||||
|
render.add_argument("--land-msh", required=True, help="Path to Land.msh")
|
||||||
|
render.add_argument("--land-map", help="Path to Land.map (optional)")
|
||||||
|
render.add_argument("--output", required=True, help="Output .ppm path")
|
||||||
|
render.add_argument("--max-faces", type=int, default=220000, help="Face limit (default: 220000)")
|
||||||
|
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=38.0, help="Yaw angle in degrees (default: 38)")
|
||||||
|
render.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
|
||||||
|
render.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
|
||||||
|
render.add_argument(
|
||||||
|
"--overlay-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Draw ArealMap polygon overlay",
|
||||||
|
)
|
||||||
|
render.set_defaults(func=cmd_render)
|
||||||
|
|
||||||
|
export_obj = sub.add_parser("export-obj", help="Export terrain (and optional areal edges) to OBJ.")
|
||||||
|
export_obj.add_argument("--land-msh", required=True, help="Path to Land.msh")
|
||||||
|
export_obj.add_argument("--land-map", help="Path to Land.map (optional)")
|
||||||
|
export_obj.add_argument("--output", required=True, help="Output .obj path")
|
||||||
|
export_obj.add_argument("--max-faces", type=int, default=0, help="Face limit (0 = all)")
|
||||||
|
export_obj.add_argument(
|
||||||
|
"--include-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Export areal polygons as OBJ polyline object",
|
||||||
|
)
|
||||||
|
export_obj.set_defaults(func=cmd_export_obj)
|
||||||
|
|
||||||
|
turn = sub.add_parser("render-turntable", help="Render turntable frame sequence to PPM.")
|
||||||
|
turn.add_argument("--land-msh", required=True, help="Path to Land.msh")
|
||||||
|
turn.add_argument("--land-map", help="Path to Land.map (optional)")
|
||||||
|
turn.add_argument("--output-dir", required=True, help="Output directory for frames")
|
||||||
|
turn.add_argument("--prefix", default="frame", help="Frame filename prefix (default: frame)")
|
||||||
|
turn.add_argument("--frames", type=int, default=36, help="Frame count (default: 36)")
|
||||||
|
turn.add_argument("--yaw-start", type=float, default=0.0, help="Start yaw in degrees (default: 0)")
|
||||||
|
turn.add_argument("--yaw-end", type=float, default=360.0, help="End yaw in degrees (default: 360)")
|
||||||
|
turn.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
|
||||||
|
turn.add_argument("--max-faces", type=int, default=160000, help="Face limit (default: 160000)")
|
||||||
|
turn.add_argument("--width", type=int, default=960, help="Image width (default: 960)")
|
||||||
|
turn.add_argument("--height", type=int, default=540, help="Image height (default: 540)")
|
||||||
|
turn.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
|
||||||
|
turn.add_argument(
|
||||||
|
"--overlay-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Draw ArealMap polygon overlay",
|
||||||
|
)
|
||||||
|
turn.set_defaults(func=cmd_render_turntable)
|
||||||
|
|
||||||
|
batch = sub.add_parser("render-batch", help="Render all MAPS/**/Land.msh under root.")
|
||||||
|
batch.add_argument(
|
||||||
|
"--maps-root",
|
||||||
|
default="tmp/gamedata/DATA/MAPS",
|
||||||
|
help="Root directory with MAPS subfolders (default: tmp/gamedata/DATA/MAPS)",
|
||||||
|
)
|
||||||
|
batch.add_argument("--output-dir", required=True, help="Directory for output PPM files")
|
||||||
|
batch.add_argument("--max-faces", type=int, default=90000, help="Face limit per map (default: 90000)")
|
||||||
|
batch.add_argument("--width", type=int, default=960, help="Image width (default: 960)")
|
||||||
|
batch.add_argument("--height", type=int, default=540, help="Image height (default: 540)")
|
||||||
|
batch.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)")
|
||||||
|
batch.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
|
||||||
|
batch.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
|
||||||
|
batch.add_argument(
|
||||||
|
"--overlay-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Draw ArealMap polygon overlay",
|
||||||
|
)
|
||||||
|
batch.set_defaults(func=cmd_render_batch)
|
||||||
|
|
||||||
|
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