Compare commits

17 Commits

Author SHA1 Message Date
96a25b6c0e fix(deps): update rust crate glow to 0.17
Some checks failed
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 1m1s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 46s
Test / Lint (push) Failing after 1m5s
RenovateBot / renovate (push) Successful in 26s
2026-03-08 00:02:26 +00:00
f4262cf369 Merge pull request 'fix(deps): update rust crate toml to v1' (#14) from renovate/toml-1.x into devel
Some checks failed
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s
Test / Lint (push) Failing after 1m3s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
RenovateBot / renovate (push) Successful in 28s
Reviewed-on: #14
2026-03-02 18:42:03 +04:00
9b100b8fc3 chore(deps): update actions/upload-artifact action to v7
Some checks failed
Test / Lint (pull_request) Failing after 1m6s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Test / Lint (push) Has been cancelled
Test / Test (push) Has been cancelled
Test / Render parity (push) Has been cancelled
Docs Deploy / Build and Deploy MkDocs (push) Has been cancelled
2026-02-27 00:01:47 +00:00
9fceeb9a0a fix(deps): update rust crate toml to v1
Some checks failed
Test / Lint (push) Failing after 1m2s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 1m4s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
2026-02-26 00:01:14 +00:00
4b7f1a16b9 fix(deps): update all digest updates
Some checks failed
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 59s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 32s
Test / Lint (push) Failing after 1m2s
RenovateBot / renovate (push) Successful in 28s
2026-02-25 00:01:21 +00:00
ada3b903ad chore: update docs deployment branch from master to devel
Some checks failed
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m9s
Test / Lint (push) Failing after 2m11s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
RenovateBot / renovate (push) Successful in 1m7s
2026-02-24 22:42:18 +00:00
31d849ddbf updated docs
Some checks failed
Test / Lint (push) Failing after 1m57s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-02-19 16:10:57 +04:00
4ef08d0bf6 feat: add terrain-core, tma, and unitdat crates with parsing functionality
- Introduced `terrain-core` crate for loading and processing terrain mesh data.
- Added `tma` crate for parsing mission files, including footer and object records.
- Created `unitdat` crate for reading unit data files with validation of structure.
- Implemented error handling and tests for all new crates.
- Documented object registry format and rendering pipeline in specifications.
2026-02-19 16:07:01 +04:00
598137ed13 feat(resource-viewer): добавить новый ресурсный просмотрщик с базовой функциональностью
Some checks failed
Test / Lint (push) Failing after 2m30s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
feat(nres): улучшить структуру архива с добавлением заголовка и информации о записях
feat(rsli): добавить поддержку заголовка библиотеки и улучшить обработку записей
2026-02-19 10:51:54 +00:00
cb0ca2f2f0 feat(render-demo): добавить отображение FPS в заголовок окна и stdout в интерактивном режиме
Some checks failed
Test / Lint (push) Failing after 1m16s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-02-19 10:27:10 +00:00
7346e695c4 feat(render-demo): обновить поддержку OpenGL с добавлением выбора между GLES2 и Core 3.3
Some checks failed
Test / Lint (push) Failing after 1m17s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-02-19 10:17:14 +00:00
bb827c3928 feat: Refactor code structure and enhance functionality across multiple crates 2026-02-19 10:09:18 +00:00
efab61a45c feat(render-core): add default UV scale and refactor UV mapping logic
Some checks failed
Test / Lint (push) Failing after 1m12s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Introduced a constant `DEFAULT_UV_SCALE` for UV scaling.
- Refactored UV mapping in `build_render_mesh` to use the new constant.
- Simplified `compute_bounds` functions by extracting common logic into `compute_bounds_impl`.

test(render-core): add tests for rendering with empty and multi-node models

- Added tests to verify behavior when building render meshes from models with no slots and multiple nodes.
- Ensured UV scaling is correctly applied in tests.

feat(render-demo): add FOV argument and improve error handling

- Added a `--fov` command-line argument to set the field of view.
- Enhanced error messages for texture resolution failures.
- Updated MVP computation to use the new FOV parameter.

fix(rsli): improve error handling in LZH decompression

- Added checks to prevent out-of-bounds access in LZH decoding logic.

refactor(texm): streamline texture parsing and decoding tests

- Created a helper function `build_texm_payload` for constructing test payloads.
- Added tests for various texture formats including RGB565, RGB556, ARGB4444, and Luminance Alpha.
- Improved error handling for invalid TEXM headers and mip bounds.
2026-02-19 09:46:23 +00:00
0d7ae6a017 Документирование и обновление спецификаций
Some checks failed
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Обновлены спецификации `runtime-pipeline`, `sound`, `terrain-map-loading`, `texture`, `ui` и `wear`.
- Добавлены разделы о статусе покрытия и оставшихся задачах для достижения 100% завершенности.
- Внесены уточнения по архитектурным ролям, минимальным контрактам и требованиям к toolchain для каждой подсистемы.
- Уточнены форматы данных и правила взаимодействия между компонентами системы.
2026-02-19 11:07:04 +04:00
a281ffa32e feat: Enhance model and texture loading with improved error handling and new features
Some checks failed
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Introduced `LoadedModel` and `LoadedTexture` structs for better encapsulation of model and texture data.
- Added functions to load models and textures from archives, including support for resolving textures based on materials and wear entries.
- Implemented error handling for missing textures, materials, and wear entries.
- Updated the rendering pipeline to support texture loading and binding, including command-line arguments for texture customization.
- Enhanced the `texm` crate with new decoding capabilities for various pixel formats, including indexed textures.
- Added tests for texture decoding and loading to ensure reliability and correctness.
- Updated documentation to reflect changes in the material and texture resolution process.
2026-02-19 05:19:18 +04:00
18d4c6cf9f feat(render-parity): add deterministic frame comparison tool
- Introduced `render-parity` crate for comparing rendered frames against reference images.
- Added command-line options for specifying manifest and output directory.
- Implemented image comparison metrics: mean absolute difference, maximum absolute difference, and changed pixel ratio.
- Created a configuration file `cases.toml` for defining test cases with global defaults and specific parameters.
- Added functionality to capture frames from `render-demo` and save diff images on discrepancies.
- Updated documentation to include usage instructions and CI model for automated testing.
2026-02-19 05:02:26 +04:00
0e19660eb5 Refactor documentation structure and add new specifications
- Updated MSH documentation to reflect changes in material, wear, and texture specifications.
- Introduced new `render.md` file detailing the render pipeline process.
- Removed outdated sections from `runtime-pipeline.md` and redirected to `render.md`.
- Added detailed specifications for `Texm` texture format and `WEAR` wear table.
- Updated navigation in `mkdocs.yml` to align with new documentation structure.
2026-02-19 04:46:23 +04:00
74 changed files with 10366 additions and 4216 deletions

View File

@@ -3,7 +3,7 @@ name: Docs Deploy
on: on:
push: push:
branches: branches:
- master - devel
jobs: jobs:
deploy-docs: deploy-docs:

View File

@@ -25,3 +25,31 @@ jobs:
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- name: Cargo test - name: Cargo test
run: cargo test --workspace --all-features -- --nocapture 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

View File

@@ -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

View 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" }

View 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"));
}
}

View File

@@ -1,4 +1,6 @@
use std::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf};
/// Resource payload that can be either borrowed from mapped bytes or owned. /// Resource payload that can be either borrowed from mapped bytes or owned.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -42,3 +44,18 @@ impl OutputBuffer for Vec<u8> {
Ok(()) 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);
}
}
}

View 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
View File

@@ -0,0 +1,14 @@
# msh-core
Парсер core-части формата `MSH`.
Покрывает:
- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные);
- `Res4`, `Res5`, `Res10` (опциональные);
- slot lookup по `node/lod/group`.
Тесты:
- прогон по всем `.msh` в `testdata`;
- синтетическая минимальная модель.

View 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
View 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;

View 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);
}
}

View File

@@ -26,10 +26,28 @@ pub enum OpenMode {
ReadWrite, 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)] #[derive(Debug)]
pub struct Archive { pub struct Archive {
bytes: Arc<[u8]>, bytes: Arc<[u8]>,
entries: Vec<EntryRecord>, entries: Vec<EntryRecord>,
info: ArchiveInfo,
raw_mode: bool, raw_mode: bool,
} }
@@ -54,6 +72,13 @@ pub struct EntryRef<'a> {
pub meta: &'a EntryMeta, 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)] #[derive(Clone, Debug)]
struct EntryRecord { struct EntryRecord {
meta: EntryMeta, meta: EntryMeta,
@@ -76,29 +101,50 @@ impl Archive {
} }
pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> { pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> {
let (entries, _) = parse_archive(&bytes, opts.raw_mode)?; 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 { if opts.prefetch_pages {
prefetch_pages(&bytes); prefetch_pages(&bytes);
} }
Ok(Self { Ok(Self {
bytes, bytes,
entries, entries,
info: ArchiveInfo {
raw_mode: opts.raw_mode,
file_size,
header,
},
raw_mode: opts.raw_mode, raw_mode: opts.raw_mode,
}) })
} }
pub fn info(&self) -> &ArchiveInfo {
&self.info
}
pub fn entry_count(&self) -> usize { pub fn entry_count(&self) -> usize {
self.entries.len() self.entries.len()
} }
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> { pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
self.entries self.entries.iter().enumerate().filter_map(|(idx, entry)| {
.iter() let id = u32::try_from(idx).ok()?;
.enumerate() Some(EntryRef {
.map(|(idx, entry)| EntryRef { id: EntryId(id),
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
meta: &entry.meta, 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> { pub fn find(&self, name: &str) -> Option<EntryId> {
@@ -125,9 +171,8 @@ impl Archive {
Ordering::Less => high = mid, Ordering::Less => high = mid,
Ordering::Greater => low = mid + 1, Ordering::Greater => low = mid + 1,
Ordering::Equal => { Ordering::Equal => {
return Some(EntryId( let id = u32::try_from(target_idx).ok()?;
u32::try_from(target_idx).expect("entry count validated at parse"), return Some(EntryId(id));
))
} }
} }
} }
@@ -137,9 +182,8 @@ impl Archive {
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw)) if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
== Ordering::Equal == Ordering::Equal
{ {
Some(EntryId( let id = u32::try_from(idx).ok()?;
u32::try_from(idx).expect("entry count validated at parse"), Some(EntryId(id))
))
} else { } else {
None None
} }
@@ -155,6 +199,16 @@ impl Archive {
}) })
} }
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<'_>> { pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
let range = self.entry_range(id)?; let range = self.entry_range(id)?;
Ok(ResourceData::Borrowed(&self.bytes[range])) Ok(ResourceData::Borrowed(&self.bytes[range]))
@@ -197,7 +251,7 @@ impl Archive {
let Some(entry) = self.entries.get(idx) else { let Some(entry) = self.entries.get(idx) else {
return Err(Error::EntryIdOutOfRange { return Err(Error::EntryIdOutOfRange {
id: id.0, id: id.0,
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), entry_count: saturating_u32_len(self.entries.len()),
}); });
}; };
checked_range( checked_range(
@@ -248,13 +302,13 @@ pub struct NewEntry<'a> {
impl Editor { impl Editor {
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> { pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
self.entries self.entries.iter().enumerate().filter_map(|(idx, entry)| {
.iter() let id = u32::try_from(idx).ok()?;
.enumerate() Some(EntryRef {
.map(|(idx, entry)| EntryRef { id: EntryId(id),
id: EntryId(u32::try_from(idx).expect("entry count validated at add")),
meta: &entry.meta, meta: &entry.meta,
}) })
})
} }
pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> { pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> {
@@ -283,7 +337,7 @@ impl Editor {
let Some(entry) = self.entries.get_mut(idx) else { let Some(entry) = self.entries.get_mut(idx) else {
return Err(Error::EntryIdOutOfRange { return Err(Error::EntryIdOutOfRange {
id: id.0, id: id.0,
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), entry_count: saturating_u32_len(self.entries.len()),
}); });
}; };
entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?; entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?;
@@ -297,7 +351,7 @@ impl Editor {
if idx >= self.entries.len() { if idx >= self.entries.len() {
return Err(Error::EntryIdOutOfRange { return Err(Error::EntryIdOutOfRange {
id: id.0, id: id.0,
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), entry_count: saturating_u32_len(self.entries.len()),
}); });
} }
self.entries.remove(idx); self.entries.remove(idx);
@@ -350,6 +404,8 @@ impl Editor {
}); });
for (idx, entry) in self.entries.iter_mut().enumerate() { 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 = entry.meta.sort_index =
u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?; u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?;
} }
@@ -377,7 +433,10 @@ impl Editor {
} }
} }
fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)> { fn parse_archive(
bytes: &[u8],
raw_mode: bool,
) -> Result<(Vec<EntryRecord>, Option<ArchiveHeader>)> {
if raw_mode { if raw_mode {
let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?; let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
let entry = EntryRecord { let entry = EntryRecord {
@@ -398,10 +457,7 @@ fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)
name name
}, },
}; };
return Ok(( return Ok((vec![entry], None));
vec![entry],
u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
));
} }
if bytes.len() < 16 { if bytes.len() < 16 {
@@ -526,7 +582,17 @@ fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)
}); });
} }
Ok((entries, directory_offset)) 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>> { fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
@@ -599,8 +665,12 @@ fn ascii_lower(value: u8) -> u8 {
} }
} }
fn saturating_u32_len(len: usize) -> u32 {
u32::try_from(len).unwrap_or(u32::MAX)
}
fn prefetch_pages(bytes: &[u8]) { fn prefetch_pages(bytes: &[u8]) {
use std::sync::atomic::{compiler_fence, Ordering}; use std::hint::black_box;
let mut cursor = 0usize; let mut cursor = 0usize;
let mut sink = 0u8; let mut sink = 0u8;
@@ -608,8 +678,7 @@ fn prefetch_pages(bytes: &[u8]) {
sink ^= bytes[cursor]; sink ^= bytes[cursor];
cursor = cursor.saturating_add(4096); cursor = cursor.saturating_add(4096);
} }
compiler_fence(Ordering::SeqCst); black_box(sink);
let _ = sink;
} }
fn write_atomic(path: &Path, content: &[u8]) -> Result<()> { fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
@@ -675,7 +744,8 @@ fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect(); 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(); let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect();
// Replace destination in one OS call, avoiding remove+rename gaps on Windows. // 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 { let ok = unsafe {
MoveFileExW( MoveFileExW(
src_wide.as_ptr(), src_wide.as_ptr(),

View File

@@ -1,4 +1,5 @@
use super::*; use super::*;
use common::collect_files_recursive;
use std::any::Any; use std::any::Any;
use std::fs; use std::fs;
use std::panic::{catch_unwind, AssertUnwindSafe}; use std::panic::{catch_unwind, AssertUnwindSafe};
@@ -13,20 +14,6 @@ struct SyntheticEntry<'a> {
data: &'a [u8], data: &'a [u8],
} }
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files_recursive(&path, out);
} else if path.is_file() {
out.push(path);
}
}
}
fn nres_test_files() -> Vec<PathBuf> { fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..") .join("..")

View 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" }

View File

@@ -0,0 +1,14 @@
# render-core
CPU-подготовка draw-данных для моделей `MSH`.
Покрывает:
- обход `node -> slot -> batch`;
- раскрытие индексов в triangle-list (`position + uv0`);
- расчёт bounds по вершинам.
Тесты:
- построение рендер-сеток на реальных `.msh` из `testdata`;
- unit-test bounds.

View 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;

View 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);
}

View 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"]

View 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).

View File

@@ -0,0 +1,4 @@
fn main() {
#[cfg(windows)]
println!("cargo:rustc-link-lib=advapi32");
}

View 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()));
}
}

View 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, &center_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
}

View 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"]

View 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, &registry, 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
);
}
}
}

View 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
}

View 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"

View 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.

View 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);
}
}

View 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)
}
}

View File

@@ -6,3 +6,6 @@ edition = "2021"
[dependencies] [dependencies]
common = { path = "../common" } common = { path = "../common" }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] } flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
[dev-dependencies]
proptest = "1"

View File

@@ -135,7 +135,12 @@ impl<'a> LzhDecoder<'a> {
let mut node = self.son[LZH_R]; let mut node = self.son[LZH_R];
while node < LZH_T { while node < LZH_T {
let bit = usize::from(self.bit_reader.read_bit()?); let bit = usize::from(self.bit_reader.read_bit()?);
node = self.son[node + 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; let c = node - LZH_T;

View File

@@ -30,20 +30,33 @@ impl Default for OpenOptions {
} }
} }
#[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)] #[derive(Debug)]
pub struct Library { pub struct Library {
bytes: Arc<[u8]>, bytes: Arc<[u8]>,
entries: Vec<EntryRecord>, entries: Vec<EntryRecord>,
#[cfg(test)] header: LibraryHeader,
pub(crate) header_raw: [u8; 32], ao_trailer: Option<AoTrailer>,
#[cfg(test)] #[cfg(test)]
pub(crate) table_plain_original: Vec<u8>, pub(crate) table_plain_original: Vec<u8>,
#[cfg(test)] #[cfg(test)]
pub(crate) xor_seed: u32,
#[cfg(test)]
pub(crate) source_size: usize, pub(crate) source_size: usize,
#[cfg(test)]
pub(crate) trailer_raw: Option<[u8; 6]>,
} }
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
@@ -77,6 +90,16 @@ pub struct EntryRef<'a> {
pub meta: &'a EntryMeta, 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 struct PackedResource {
pub meta: EntryMeta, pub meta: EntryMeta,
pub packed: Vec<u8>, pub packed: Vec<u8>,
@@ -86,9 +109,9 @@ pub struct PackedResource {
pub(crate) struct EntryRecord { pub(crate) struct EntryRecord {
pub(crate) meta: EntryMeta, pub(crate) meta: EntryMeta,
pub(crate) name_raw: [u8; 12], pub(crate) name_raw: [u8; 12],
pub(crate) service_tail: [u8; 4],
pub(crate) sort_to_original: i16, pub(crate) sort_to_original: i16,
pub(crate) key16: u16, pub(crate) key16: u16,
#[cfg(test)]
pub(crate) data_offset_raw: u32, pub(crate) data_offset_raw: u32,
pub(crate) packed_size_declared: u32, pub(crate) packed_size_declared: u32,
pub(crate) packed_size_available: usize, pub(crate) packed_size_available: usize,
@@ -106,18 +129,40 @@ impl Library {
parse_library(arc, opts) 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 { pub fn entry_count(&self) -> usize {
self.entries.len() self.entries.len()
} }
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> { pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
self.entries self.entries.iter().enumerate().filter_map(|(idx, entry)| {
.iter() let id = u32::try_from(idx).ok()?;
.enumerate() Some(EntryRef {
.map(|(idx, entry)| EntryRef { id: EntryId(id),
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
meta: &entry.meta, 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> { pub fn find(&self, name: &str) -> Option<EntryId> {
@@ -161,9 +206,8 @@ impl Library {
Ordering::Less => high = mid, Ordering::Less => high = mid,
Ordering::Greater => low = mid + 1, Ordering::Greater => low = mid + 1,
Ordering::Equal => { Ordering::Equal => {
return Some(EntryId( let id = u32::try_from(idx).ok()?;
u32::try_from(idx).expect("entry count validated at parse"), return Some(EntryId(id));
))
} }
} }
} }
@@ -171,9 +215,8 @@ impl Library {
// Linear fallback search // Linear fallback search
self.entries.iter().enumerate().find_map(|(idx, entry)| { self.entries.iter().enumerate().find_map(|(idx, entry)| {
if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal { if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal {
Some(EntryId( let id = u32::try_from(idx).ok()?;
u32::try_from(idx).expect("entry count validated at parse"), Some(EntryId(id))
))
} else { } else {
None None
} }
@@ -189,6 +232,19 @@ impl Library {
}) })
} }
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>> { pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
let entry = self.entry_by_id(id)?; let entry = self.entry_by_id(id)?;
let packed = self.packed_slice(id, entry)?; let packed = self.packed_slice(id, entry)?;
@@ -251,7 +307,7 @@ impl Library {
.get(idx) .get(idx)
.ok_or_else(|| Error::EntryIdOutOfRange { .ok_or_else(|| Error::EntryIdOutOfRange {
id: id.0, id: id.0,
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX), entry_count: saturating_u32_len(self.entries.len()),
}) })
} }
@@ -286,7 +342,7 @@ impl Library {
#[cfg(test)] #[cfg(test)]
pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> { pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> {
let trailer_len = usize::from(self.trailer_raw.is_some()) * 6; let trailer_len = usize::from(self.ao_trailer.is_some()) * 6;
let pre_trailer_size = self let pre_trailer_size = self
.source_size .source_size
.checked_sub(trailer_len) .checked_sub(trailer_len)
@@ -306,9 +362,11 @@ impl Library {
} }
let mut out = vec![0u8; pre_trailer_size]; let mut out = vec![0u8; pre_trailer_size];
out[0..32].copy_from_slice(&self.header_raw); out[0..32].copy_from_slice(&self.header.raw);
let encrypted_table = let encrypted_table = xor_stream(
xor_stream(&self.table_plain_original, (self.xor_seed & 0xFFFF) as u16); &self.table_plain_original,
(self.header.xor_seed & 0xFFFF) as u16,
);
out[32..table_end].copy_from_slice(&encrypted_table); out[32..table_end].copy_from_slice(&encrypted_table);
let mut occupied = vec![false; pre_trailer_size]; let mut occupied = vec![false; pre_trailer_size];
@@ -317,18 +375,15 @@ impl Library {
} }
for (idx, entry) in self.entries.iter().enumerate() { for (idx, entry) in self.entries.iter().enumerate() {
let packed = self let id = u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
.load_packed(EntryId( let packed = self.load_packed(EntryId(id))?.packed;
u32::try_from(idx).expect("entry count validated at parse"),
))?
.packed;
let start = let start =
usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?; usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?;
for (offset, byte) in packed.iter().copied().enumerate() { for (offset, byte) in packed.iter().copied().enumerate() {
let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?; let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?;
if pos >= out.len() { if pos >= out.len() {
return Err(Error::PackedSizePastEof { return Err(Error::PackedSizePastEof {
id: u32::try_from(idx).expect("entry count validated at parse"), id,
offset: u64::from(entry.data_offset_raw), offset: u64::from(entry.data_offset_raw),
packed_size: entry.packed_size_declared, packed_size: entry.packed_size_declared,
file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?, file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?,
@@ -342,8 +397,8 @@ impl Library {
} }
} }
if let Some(trailer) = self.trailer_raw { if let Some(trailer) = &self.ao_trailer {
out.extend_from_slice(&trailer); out.extend_from_slice(&trailer.raw);
} }
Ok(out) Ok(out)
} }
@@ -407,5 +462,9 @@ fn needs_xor_key(method: PackMethod) -> bool {
) )
} }
fn saturating_u32_len(len: usize) -> u32 {
u32::try_from(len).unwrap_or(u32::MAX)
}
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -1,6 +1,8 @@
use crate::compress::xor::xor_stream; use crate::compress::xor::xor_stream;
use crate::error::Error; use crate::error::Error;
use crate::{EntryMeta, EntryRecord, Library, OpenOptions, PackMethod, Result}; use crate::{
AoTrailer, EntryMeta, EntryRecord, Library, LibraryHeader, OpenOptions, PackMethod, Result,
};
use std::cmp::Ordering; use std::cmp::Ordering;
use std::sync::Arc; use std::sync::Arc;
@@ -16,13 +18,17 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
let mut header_raw = [0u8; 32]; let mut header_raw = [0u8; 32];
header_raw.copy_from_slice(&bytes[0..32]); header_raw.copy_from_slice(&bytes[0..32]);
if &bytes[0..2] != b"NL" { let mut magic = [0u8; 2];
magic.copy_from_slice(&bytes[0..2]);
if &magic != b"NL" {
let mut got = [0u8; 2]; let mut got = [0u8; 2];
got.copy_from_slice(&bytes[0..2]); got.copy_from_slice(&bytes[0..2]);
return Err(Error::InvalidMagic { got }); return Err(Error::InvalidMagic { got });
} }
if bytes[3] != 0x01 { let reserved = bytes[2];
return Err(Error::UnsupportedVersion { got: bytes[3] }); let version = bytes[3];
if version != 0x01 {
return Err(Error::UnsupportedVersion { got: version });
} }
let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]); let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
@@ -36,7 +42,17 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
return Err(Error::TooManyEntries { got: count }); 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 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_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
let table_offset = 32usize; let table_offset = 32usize;
@@ -58,8 +74,6 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
} }
let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?; let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?;
#[cfg(not(test))]
let _ = trailer_raw;
let mut entries = Vec::with_capacity(count); let mut entries = Vec::with_capacity(count);
for idx in 0..count { for idx in 0..count {
@@ -67,6 +81,8 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
let mut name_raw = [0u8; 12]; let mut name_raw = [0u8; 12];
name_raw.copy_from_slice(&row[0..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 flags_signed = i16::from_le_bytes([row[16], row[17]]);
let sort_to_original = i16::from_le_bytes([row[18], row[19]]); let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
@@ -100,12 +116,12 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
.ok_or(Error::IntegerOverflow)?; .ok_or(Error::IntegerOverflow)?;
} else { } else {
return Err(Error::DeflateEofPlusOneQuirkRejected { return Err(Error::DeflateEofPlusOneQuirkRejected {
id: u32::try_from(idx).expect("entry count validated at parse"), id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
}); });
} }
} else { } else {
return Err(Error::PackedSizePastEof { return Err(Error::PackedSizePastEof {
id: u32::try_from(idx).expect("entry count validated at parse"), id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
offset: effective_offset_u64, offset: effective_offset_u64,
packed_size: packed_size_declared, packed_size: packed_size_declared,
file_len: file_len_u64, file_len: file_len_u64,
@@ -118,7 +134,7 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
.ok_or(Error::IntegerOverflow)?; .ok_or(Error::IntegerOverflow)?;
if available_end > bytes.len() { if available_end > bytes.len() {
return Err(Error::EntryDataOutOfBounds { return Err(Error::EntryDataOutOfBounds {
id: u32::try_from(idx).expect("entry count validated at parse"), id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
offset: effective_offset_u64, offset: effective_offset_u64,
size: packed_size_declared, size: packed_size_declared,
file_len: file_len_u64, file_len: file_len_u64,
@@ -137,9 +153,9 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
unpacked_size, unpacked_size,
}, },
name_raw, name_raw,
service_tail,
sort_to_original, sort_to_original,
key16: sort_to_original as u16, key16: sort_to_original as u16,
#[cfg(test)]
data_offset_raw, data_offset_raw,
packed_size_declared, packed_size_declared,
packed_size_available, packed_size_available,
@@ -147,7 +163,6 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
}); });
} }
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
if presorted_flag == 0xABBA { if presorted_flag == 0xABBA {
let mut seen = vec![false; count]; let mut seen = vec![false; count];
for entry in &entries { for entry in &entries {
@@ -196,16 +211,12 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
Ok(Library { Ok(Library {
bytes, bytes,
entries, entries,
#[cfg(test)] header,
header_raw, ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }),
#[cfg(test)] #[cfg(test)]
table_plain_original, table_plain_original,
#[cfg(test)] #[cfg(test)]
xor_seed,
#[cfg(test)]
source_size, source_size,
#[cfg(test)]
trailer_raw,
}) })
} }

View File

@@ -1,14 +1,17 @@
use super::*; use super::*;
use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T}; use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T};
use crate::compress::xor::xor_stream; use crate::compress::xor::xor_stream;
use common::collect_files_recursive;
use flate2::write::DeflateEncoder; use flate2::write::DeflateEncoder;
use flate2::write::ZlibEncoder; use flate2::write::ZlibEncoder;
use flate2::Compression; use flate2::Compression;
use proptest::prelude::*;
use std::any::Any; use std::any::Any;
use std::fs; use std::fs;
use std::io::Write as _; use std::io::Write as _;
use std::panic::{catch_unwind, AssertUnwindSafe}; use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
struct SyntheticRsliEntry { struct SyntheticRsliEntry {
@@ -37,20 +40,6 @@ impl Default for RsliBuildOptions {
} }
} }
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(root) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_files_recursive(&path, out);
} else if path.is_file() {
out.push(path);
}
}
}
fn rsli_test_files() -> Vec<PathBuf> { fn rsli_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..") .join("..")
@@ -1335,3 +1324,15 @@ fn rsli_validation_error_cases() {
} }
let _ = fs::remove_file(&path); let _ = fs::remove_file(&path);
} }
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn parse_library_is_panic_free_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..4096)) {
let _ = crate::parse::parse_library(
Arc::from(data.into_boxed_slice()),
OpenOptions::default(),
);
}
}

View File

@@ -0,0 +1,10 @@
[package]
name = "terrain-core"
version = "0.1.0"
edition = "2021"
[dependencies]
nres = { path = "../nres" }
[dev-dependencies]
common = { path = "../common" }

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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");
}
}

View File

@@ -1,5 +1,35 @@
# AI system # AI system
Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников. Страница фиксирует границы подсистемы AI на уровне движка:
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`. - выбор целей;
- тактические приоритеты;
- координация с `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» для побайтного/пошагового сравнения с оригиналом.

View File

@@ -1,5 +1,31 @@
# ArealMap # ArealMap
Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями. `ArealMap` — подсистема топологии мира и логических зон.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`. Подробный бинарный формат `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-тестов поведения навигационных запросов на одинаковых входах.

View File

@@ -1,5 +1,28 @@
# Behavior system # Behavior system
Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения. `Behavior` — слой исполнения состояний юнитов между AI-решением и низкоуровневым control-командованием.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`. ## 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-сценариях.

View File

@@ -1,5 +1,28 @@
# Control system # Control system
Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд. `Control` — подсистема входа и маршрутизации команд (пользовательских и системных).
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`. ## 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-тесты на записанных последовательностях ввода.

View 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-тесты».

View File

@@ -1,89 +1,20 @@
# FXID # FXID
Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для: `FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
- 1:1 загрузки и исполнения в совместимом runtime; Связанные контейнеры: [NRes](nres.md), [RsLi](rsli.md).
- построения валидатора payload;
- создания lossless-конвертера (`binary -> IR -> binary`);
- создания редактора с безопасным редактированием полей.
Связанный контейнер: [NRes / RsLi](nres.md). ## 1. Контейнер
--- - Тип ресурса в `NRes`: `0x44495846` (`FXID`).
- Значения `attr1/attr2/attr3` в типовых игровых данных стабильны, но при редактуре их нужно сохранять как есть.
## 1. Источники и статус восстановления ## 2. Бинарный формат
Спецификация восстановлена по:
- `tmp/disassembler1/Effect.dll.c`;
- `tmp/disassembler2/Effect.dll.asm`;
- интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`;
- проверке реальных архивов `testdata/nres`.
Ключевые функции:
- parser FXID: `Effect.dll!sub_10007650`;
- runtime loop: `sub_10003D30(case 28)`, `sub_10006170`, `sub_10008120`, `sub_10007D10`;
- alpha/time: `sub_10005C60`;
- exports: `CreateFxManager`, `InitializeSettings`.
Проверка по данным:
- `923/923` FXID payload валидны в `testdata/nres`.
---
## 2. Контейнер и runtime API
### 2.1. NRes entry
FXID хранится как NRes-entry:
- `type_id = 0x44495846` (`"FXID"`).
Наблюдение по датасету (923 эффекта):
- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`.
### 2.2. Export API `Effect.dll`
Экспортируются:
- `CreateFxManager(int a1, int a2, int owner)`;
- `InitializeSettings()`.
`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`).
### 2.3. Интерфейс менеджера
Рабочая vtable (`off_1001E478`):
| Смещение | Функция | Назначение |
|---|---|---|
| +0x08 | `sub_10003D30` | Event dispatcher (`4/20/23/24/28`) |
| +0x10 | `sub_10004320` | Открыть/закэшировать FX resource |
| +0x14 | `sub_10004590` | Создать runtime instance |
| +0x18 | `sub_10004780` | Удалить instance |
| +0x1C | `sub_100047B0` | Установить time/interp mode |
| +0x20 | `sub_100047D0` | Установить scale |
| +0x24 | `sub_10004830` | Установить позицию |
| +0x28 | `sub_10004930` | Установить matrix transform |
| +0x2C | `sub_10004B00` | Restart/retime |
| +0x38 | `sub_10004BA0` | Duration modifier |
| +0x3C | `sub_10004BD0` | Start/Enable |
| +0x40 | `sub_10004C10` | Stop/Disable |
| +0x44 | `sub_10004C50` | Bind emitter/context |
| +0x48 | `sub_10004D50` | Сброс frame flags |
`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса.
---
## 3. Бинарный формат FXID payload
Все значения little-endian. Все значения little-endian.
### 3.1. Header (60 байт, `0x3C`) ### 2.1. Заголовок (60 байт)
```c ```c
struct FxHeader60 { struct FxHeader60 {
@@ -105,94 +36,26 @@ struct FxHeader60 {
}; };
``` ```
Командный поток начинается строго с `offset = 0x3C`. Поток команд начинается строго с `offset = 0x3C`.
### 3.2. Header-поля (подтвержденная семантика) ### 2.2. Команда
- `cmd_count`: число команд (engine итерирует ровно столько шагов).
- `time_mode`: базовый режим вычисления alpha/time (`sub_10005C60`).
- `duration_sec`: в runtime -> `duration_ms = duration_sec * 1000`.
- `phase_jitter`: используется при `flags & 0x1`.
- `flags`: runtime-gating/alpha/visibility (см. ниже).
- `settings_id`: в `sub_1000EC40` используется `settings_id & 0xFF`.
- `rand_shift_*`: используется при `flags & 0x8`.
- `pivot_*`: используется в ветках `sub_10007D10`.
- `scale_*`: копируется в runtime scale и влияет на матрицы.
### 3.3. `flags` (битовая карта)
| Бит | Маска | Наблюдаемое поведение |
|---|---:|---|
| 0 | `0x0001` | Random phase jitter (`phase_jitter`) |
| 3 | `0x0008` | Random positional shift (`rand_shift_*`) |
| 4 | `0x0010` | Visibility/occlusion ветки |
| 5 | `0x0020` | Triangular remap в `sub_10005C60` |
| 6 | `0x0040` | Инверсия начального active-state |
| 7 | `0x0080` | Day/night filter (ветка A) |
| 8 | `0x0100` | Day/night filter (ветка B, инверсия) |
| 9 | `0x0200` | Alpha *= normalized lifetime |
| 10 | `0x0400` | Установка manager bit1 (`+0xA0`) |
| 11 | `0x0800` | Изменение gating в `sub_10007D10` |
| 12 | `0x1000` | Установка manager-state bit `0x10` |
Нерасшифрованные биты должны сохраняться 1:1.
### 3.4. `time_mode` (`0..17`)
Обозначения (`sub_10005C60`):
- `t0 = instance.start_ms`, `t1 = instance.end_ms`;
- `tn = (now_ms - t0) / (t1 - t0)`;
- `prev = instance.cached_alpha` (`v4+52` в дизассембле).
Режимы:
- `0`: constant (`instance.alpha_const`, поле `v4+40`);
- `1`: `tn`;
- `2`: `fract(tn)`;
- `3`: `1 - tn`;
- `4`: external value из queue/world API (manager `+36`, id из `this+104[a2]`);
- `5`: `|param33.xyz| / |param17.vecA.xyz|`;
- `6`: `param33.x / param17.vecA.x`;
- `7`: `param33.y / param17.vecA.y`;
- `8`: `param33.z / param17.vecA.z`;
- `9`: `|param36.xyz| / |param17.vecB.xyz|`;
- `10`: `param36.x / param17.vecB.x`;
- `11`: `param36.y / param17.vecB.y`;
- `12`: `param36.z / param17.vecB.z`;
- `13`: `1 - external_resource_value`;
- `14`: `1 - queue_param(49)`;
- `15`: `max(norm(param33/vecA), norm(param36/vecB))`;
- `16`: external (`mode 4`) с нижним clamp к `prev` (`0` не зажимается);
- `17`: external (`mode 4`) с верхним clamp к `prev` (`1` не зажимается).
Post-обработка после mode:
- если `flags & 0x200`: `alpha *= tn`;
- если `flags & 0x20`: triangular remap (`alpha = (alpha < 0.5 ? alpha : 1-alpha) * 2`).
---
## 4. Командный поток
### 4.1. Общий формат команды
Каждая команда: Каждая команда:
- `uint32 cmd_word`; 1. `uint32 cmd_word`
- далее body фиксированного размера по opcode. 2. body фиксированного размера, зависящего от `opcode`
`cmd_word`: Поля `cmd_word`:
- `opcode = cmd_word & 0xFF`; - `opcode = cmd_word & 0xFF`
- `enabled = (cmd_word >> 8) & 1`; - `enabled = (cmd_word >> 8) & 1`
- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1. - `bits 9..31` нужно сохранять 1:1
Выравнивания между командами нет. Выравнивания между командами нет.
### 4.2. Размеры ### 2.3. Размеры команд
| Opcode | Размер записи | | Opcode | Размер |
|---:|---:| |---:|---:|
| 1 | 224 | | 1 | 224 |
| 2 | 148 | | 2 | 148 |
@@ -205,630 +68,135 @@ Post-обработка после mode:
| 9 | 208 | | 9 | 208 |
| 10 | 208 | | 10 | 208 |
### 4.3. Opcode -> runtime-класс (vtable) ## 3. Смысл заголовка
| Opcode | `new(size)` | vtable | - `cmd_count`: число команд в потоке.
|---:|---:|---| - `time_mode`: способ вычисления текущего коэффициента эффекта.
| 1 | `0xF0` | `off_1001E78C` | - `duration_sec`: длительность (в рантайме переводится в миллисекунды).
| 2 | `0xA0` | `off_1001F048` | - `phase_jitter`: амплитуда случайного фазового сдвига.
| 3 | `0xFC` | `off_1001E770` | - `flags`: флаги поведения (видимость, альфа-модификаторы, режимы гейтинга).
| 4 | `0x104` | `off_1001E754` | - `settings_id`: индекс профиля/настроек эффекта.
| 5 | `0x54` | `off_1001E360` | - `rand_shift_*`: случайный пространственный сдвиг.
| 6 | `0x1C` | `off_1001E738` | - `pivot_*`: локальная опора.
| 7 | `0x48` | `off_1001E228` | - `scale_*`: базовый масштаб инстанса эффекта.
| 8 | `0xAC` | `off_1001E71C` |
| 9 | `0x100` | `off_1001E700` |
| 10 | `0x48` | `off_1001E24C` |
### 4.4. Общий вызовной контракт команды ## 4. Флаги заголовка
После создания команды (`sub_10007650`): Практически важные биты:
1. `cmd->enabled = cmd_word.bit8`. - `0x0001`: случайный сдвиг фазы
2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`). - `0x0008`: случайный пространственный сдвиг (`rand_shift_*`)
3. команда добавляется в список инстанса. - `0x0010`: ветки видимости/окклюзии
- `0x0020`: треугольный ремап альфы
- `0x0040`: инверсия исходного active-state
- `0x0080`, `0x0100`: фильтрация по времени суток
- `0x0200`: умножение альфы на нормализованное время жизни
- `0x0400`, `0x1000`: дополнительные биты состояния менеджера эффекта
- `0x0800`: дополнительный гейтинг
В runtime cycle: Неизвестные биты должны сохраняться без изменений.
- `vfunc +8`: update/compute (bool); ## 5. `time_mode` (0..17)
- `vfunc +12`: emission/render callback;
- `vfunc +20`: toggle active;
- `vfunc +16`/`+24`: служебные функции (зависят от opcode).
--- База:
## 5. Загрузка FXID (engine-accurate) - `tn = (now - start) / (end - start)`
- `prev = предыдущая вычисленная альфа`
`sub_10007650`: Поддерживаемые семейства режимов:
```c - константный режим;
void FxLoad(FxInstance* fx, uint8_t* payload) { - линейный (`tn`), обратный (`1-tn`), циклический (`fract(tn)`);
FxHeader60* h = (FxHeader60*)payload; - режимы от внешних параметров мира/очереди;
- режимы на основе норм векторов состояния;
- режимы с ограничением вниз/вверх относительно `prev`.
fx->raw_header = h; После вычисления:
fx->mode = h->time_mode;
fx->end_ms = fx->start_ms + h->duration_sec * 1000.0f;
fx->scale = {h->scale_x, h->scale_y, h->scale_z};
fx->active_default = ((h->flags & 0x40) == 0);
uint8_t* ptr = payload + 0x3C; - при `flags & 0x0200` применяется `alpha *= tn`;
for (uint32_t i = 0; i < h->cmd_count; ++i) { - при `flags & 0x0020` применяется triangular remap.
uint32_t w = *(uint32_t*)ptr;
uint8_t op = (uint8_t)(w & 0xFF);
Command* cmd = CreateByOpcode(op, ptr); // может вернуть null ## 6. Resource-ссылки внутри команд
if (cmd) {
cmd->enabled = (w >> 8) & 1;
if (h->flags & 0x400) fx->manager_flags |= 0x0100; Для opcode `2/3/4/5/7/8/9/10` используется ссылка:
if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010;
cmd->Init(fx->queue, fx);
fx->commands.push_back(cmd);
}
ptr += size_by_opcode(op); // без bounds checks в оригинале
}
}
```
Критичные edge-case оригинала:
- bounds checks отсутствуют;
- при unknown opcode `ptr` не двигается (`advance = 0`);
- при `new == null` команда пропускается, но `ptr` двигается.
Фактический `advance` в `sub_10007650` задан hardcoded в DWORD:
- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`,
- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`,
- `default:+0`.
---
## 6. Runtime lifecycle
- `sub_10007470`: ctor instance.
- `sub_10003D30(case 28)`: per-frame update manager.
- `sub_10006170`: gate + alpha/time + command updates.
- `sub_10008120` / `sub_10007D10`: update/render branches.
- Start/Stop: `sub_10004BD0` / `sub_10004C10`.
Event-codes `sub_10003D30`:
- `4`: bootstrap/time init;
- `20`: range-removal + index repair;
- `23`: set manager bit0;
- `24`: clear manager bit0;
- `28`: main tick.
---
## 7. Общий тип `ResourceRef64`
Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида:
```c ```c
struct ResourceRef64 { struct ResourceRef64 {
char archive[32]; // null-terminated ASCII, case-insensitive compare char archive[32];
char name[32]; // null-terminated ASCII char name[32];
}; };
``` ```
Поведение loader'а: Контракт:
- оба имени обязаны быть непустыми; - строки ASCII, нуль-терминированные;
- кэширование по `(_strcmpi archive, _strcmpi name)`; - сравнение имён регистронезависимое;
- загрузка/резолв через manager resource API. - обычно:
- `opcode 2`: `sounds.lib` + `*.wav`
- остальные: `material.lib` + имя материала/эффекта.
Наблюдение по данным: ## 7. Runtime-контракт исполнения
- для `opcode 2`: обычно `sounds.lib` + `*.wav`; На создании инстанса:
- для остальных: обычно `material.lib` + material name.
--- 1. Заголовок копируется в runtime-состояние.
2. Вычисляется `end_time`.
3. Для каждой команды создаётся runtime-объект по `opcode`.
4. В объект копируется `enabled`.
5. Объект инициализируется контекстом эффекта.
## 8. Полная карта body по opcode (field-level) На каждом кадре:
Смещения указаны от начала команды (включая `cmd_word`). 1. Вычисляется текущий коэффициент/альфа по `time_mode` и `flags`.
2. Выполняется update каждой команды.
3. Выполняется emit/render часть активных команд.
4. Применяются события Start/Stop/Restart.
### 8.1. Opcode 1 (`off_1001E78C`, size=224) ## 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)`.
- init: `sub_1000F4B0`; ## 9. Writer и редактор
- update: `sub_1000F6E0`;
- emit: `nullsub_2`;
- toggle: `sub_1000F490`.
```c Для lossless-совместимости:
struct FxCmd01 {
uint32_t word; // +0
uint32_t mode; // +4 (enum, см. ниже)
float t_start; // +8
float t_end; // +12
float p0_min[3]; // +16..24 - сохранять все неизвестные поля/биты;
float p0_max[3]; // +28..36 - не менять фиксированные размеры команд;
float p1_min[3]; // +40..48
float p1_max[3]; // +52..60
float q0_min[4]; // +64..76
float q0_max[4]; // +80..92
float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0)
float scalar_min; // +112
float scalar_max; // +116
float scalar_rand_amp; // +120
float color_rgb[3]; // +124..132 (вызов manager+16)
float opaque_tail6[6]; // +136..156 (сохранять 1:1; в датасете почти всегда 0)
char opt_archive[32]; // +160..191 (редко, напр. "material.lib")
char opt_name[32]; // +192..223 (редко, напр. "light_w")
};
```
Замечания по полям op1:
- `+108` не резерв: участвует в random-выборке как 4-я компонента блока `+96..108`;
- `+136..156` не читается vtable-методами класса `off_1001E78C` в `Effect.dll` (init/update/toggle/accessor), но должно сохраняться 1:1;
- редкий кейс с ненулевыми `+136..156` и строками `+160/+192` зафиксирован в `effects.rlb:r_lightray_w`.
`mode` (`+4`) -> параметры вызова manager (`sub_1000F4B0`):
- `1 -> create_kind=1, flags=0x80000000`;
- `2/5 -> create_kind=1, flags=0x00000000`;
- `3 -> create_kind=3, flags=0x00000000`;
- `4 -> create_kind=4, flags=0x00000000`;
- `6 -> create_kind=1, flags=0xA0000000`;
- `7 -> create_kind=1, flags=0x20000000`.
### 8.2. Opcode 2 (`off_1001F048`, size=148)
Основные методы:
- init: `sub_10012D10`;
- update: `sub_10012EB0`;
- emit: `nullsub_2`;
- toggle: `sub_10013170`.
```c
struct FxCmd02 {
uint32_t word; // +0
uint32_t mode; // +4 (0..3; влияет на sub_100065A0 mapping)
float t_start; // +8
float t_end; // +12
float a_min[3]; // +16..24
float a_max[3]; // +28..36
float b_min[3]; // +40..48
float b_max[3]; // +52..60
float c0_base; // +64
float c1_base; // +68
float c2_base; // +72
float c2_max; // +76
uint32_t param_910; // +80 (передаётся в manager cmd=910)
ResourceRef64 ref; // +84..147 (обычно sounds.lib + wav)
};
```
`mode` -> внутренний map в `sub_100065A0`:
- `0 -> 0`, `1 -> 512`, `2 -> 2`, `3 -> 514`.
### 8.3. Opcode 3 (`off_1001E770`, size=200)
Методы:
- init: `sub_100103B0`;
- update: `sub_100105F0`;
- emit: `sub_100106C0`.
```c
struct FxCmd03 {
uint32_t word; // +0
uint32_t mode; // +4
float alpha_source; // +8 (>=0: norm time, <0: global time)
float alpha_pow_a; // +12
float alpha_pow_b; // +16
float out_min; // +20
float out_max; // +24
float out_pow; // +28
float active_t0; // +32
float active_t1; // +36
float v0_min[3]; // +40..48
float v0_max[3]; // +52..60
float pow0[3]; // +64..72
float v1_min[3]; // +76..84
float v1_max[3]; // +88..96
float v2_min[3]; // +100..108
float v2_max[3]; // +112..120
float pow1[3]; // +124..132
ResourceRef64 ref; // +136..199
};
```
### 8.4. Opcode 4 (`off_1001E754`, size=204)
Layout как opcode 3 + последний коэффициент:
```c
struct FxCmd04 {
FxCmd03 base; // +0..199
float dist_norm_inv_base; // +200 (используется в sub_100108C0/100109B0)
};
```
`sub_100108C0`: `obj->inv = 1.0 / raw[200]`.
### 8.5. Opcode 5 (`off_1001E360`, size=112)
Методы:
- init: `sub_100028A0`;
- update: `sub_10002A20`;
- emit: `sub_10002BE0`;
- context update: `sub_10003070`.
```c
struct FxCmd05 {
uint32_t word; // +0
uint32_t mode; // +4 (в данных обычно 1)
uint32_t unused_08; // +8 (в текущем коде opcode5 не читается)
uint32_t unused_0C; // +12 (в текущем коде opcode5 не читается)
float active_t0; // +16
uint32_t max_segments; // +20
float active_t1_min; // +24
float active_t1_max; // +28
float step_norm; // +32
float segment_len; // +36
float alpha_source; // +40 (>=0 norm, <0 random)
float alpha_pow; // +44
ResourceRef64 ref; // +48..111
};
```
### 8.6. Opcode 6 (`off_1001E738`, size=4)
Только `cmd_word`:
```c
struct FxCmd06 {
uint32_t word; // +0
};
```
`init/update/emit` фактически no-op (`sub_100030B0` возвращает `0`).
### 8.7. Opcode 7 (`off_1001E228`, size=208)
Методы:
- init: `sub_10001720`;
- update: `sub_10001230`;
- emit: `sub_10001300`;
- element accessor: `sub_10002780`.
```c
struct FxCmd07 {
uint32_t word; // +0
uint32_t mode; // +4
float eval_min; // +8
float eval_max; // +12
float eval_pow; // +16
float active_t0; // +20
float active_t1; // +24
float phase_span; // +28
float phase_rate; // +32
uint32_t count_a; // +36
uint32_t count_b; // +40
float set0_min[3]; // +44..52
float set0_max[3]; // +56..64
float set0_rand[3]; // +68..76
float set0_pow[3]; // +80..88
float set1_min[3]; // +92..100
float set1_max[3]; // +104..112
float set1_rand[3]; // +116..124
float set1_pow[3]; // +128..136
float gravity_or_drag_k; // +140
ResourceRef64 ref; // +144..207
};
```
### 8.8. Opcode 8 (`off_1001E71C`, size=248)
Методы:
- init: `sub_10011230`;
- update: `sub_100115C0`;
- emit: `sub_10012030`.
```c
struct FxCmd08 {
uint32_t word; // +0
uint32_t mode; // +4
float eval_t0; // +8
float eval_t1; // +12
float gate_t0; // +16
float gate_t1; // +20
float period_min; // +24
float period_max; // +28
float phase_pow; // +32
uint32_t slots; // +36
float set0_min[3]; // +40..48
float set0_max[3]; // +52..60
float set0_rand[3]; // +64..72
float set1_min[3]; // +76..84
float set1_max[3]; // +88..96
float set1_rand[3]; // +100..108
float set2_rand[3]; // +112..120
float set2_pow[3]; // +124..132
float rmax_set0[3]; // +136..144 (bound/radius calc)
float rmax_set1[3]; // +148..156 (bound/radius calc)
float rmax_set2[3]; // +160..168 (bound/radius calc)
float render_pow[3]; // +172..180
ResourceRef64 ref; // +184..247
};
```
### 8.9. Opcode 9 (`off_1001E700`, size=208)
Layout как opcode 3 с двумя final-полями:
```c
struct FxCmd09 {
FxCmd03 base; // +0..199
uint32_t render_kind; // +200 (0/1/2 -> 3/5/6 in sub_100138C0)
uint32_t render_flag; // +204 (0 -> добавляет bit 0x08000000)
};
```
Методы:
- init/update как у opcode 3 (`sub_100103B0`, `sub_100105F0`);
- emit: `sub_100138C0` -> формирует код рендера и вызывает `sub_100106C0`.
### 8.10. Opcode 10 (`off_1001E24C`, size=208)
Body-layout совпадает с opcode 7 (`FxCmd07`), но другой runtime класс.
- init: `sub_10001A40`;
- update: `sub_10001230`;
- emit: `sub_10001300`;
- element accessor: `sub_10002830`.
Наблюдение по данным:
- `mode` (`+4`) встречается как `16` или `32`.
---
## 9. Runtime-специфика по opcode (важные отличия)
### 9.1. Opcode 1
- создаёт handle через manager (`vfunc +48`);
- задаёт флаги handle (`vfunc +52`);
- в update пушит:
- позиционный вектор 1 (`vfunc +32`),
- позиционный вектор 2 (`vfunc +36`),
- 4-компонентный параметр (`vfunc +12`),
- scalar+rgb (`vfunc +16`).
### 9.2. Opcode 2
- `ResourceRef64` резолвится через `sub_100065A0` (режим-зависимая загрузка, в данных обычно `sounds.lib`/`wav`);
- использует manager-команду id `910`.
### 9.3. Opcode 3/4/9
- общий core-emitter в `sub_100106C0`;
- opcode 4 добавляет нормализацию по `raw+200`;
- opcode 9 добавляет переключение render-кода (`raw+200/+204`).
### 9.4. Opcode 5
- держит массив внутренних сегментов (`332` байта/элемент, ctor `sub_100099F0`);
- context-matrix приходит через `vfunc +24` (`sub_10003070`).
### 9.5. Opcode 7/10
- общий update/render (`sub_10001230`, `sub_10001300`);
- разные внутренние element-форматы:
- opcode 7: `204` байта/элемент (`sub_100092D0`),
- opcode 10: `492` байта/элемент (`sub_1000BB40`).
### 9.6. Opcode 8
- самый тяжёлый спавнер, хранит ring/slot-структуры;
- emit фаза (`sub_10012030`) использует `mode`, `render_pow`, per-slot transforms.
---
## 10. Спецификация инструментов
### 10.1. Reader (strict)
Алгоритм:
1. `len(payload) >= 60`;
2. читаем `cmd_count`;
3. `ptr = 0x3C`;
4. цикл `cmd_count`:
- `ptr + 4 <= len`;
- `opcode in 1..10`;
- `ptr + size(opcode) <= len`;
- `ptr += size(opcode)`;
5. strict-tail: `ptr == len(payload)`.
### 10.2. Reader (engine-compatible)
Legacy-режим (опасный, только при необходимости byte-совместимости):
- без bounds-check;
- tolerant к unknown opcode как в оригинале.
### 10.3. Writer (canonical)
1. записать `FxHeader60`;
2. `cmd_count = commands.len()`;
3. команды сериализуются как `cmd_word + fixed-body`;
4. размер payload: `0x3C + sum(size(op_i))`;
5. без хвостовых байт.
### 10.4. Editor (lossless)
Правила:
- все поля little-endian;
- не менять fixed size команды;
- не добавлять padding; - не добавлять padding;
- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through; - пересчитывать только `cmd_count` и размеры контейнера;
- для частично-известных полей поддерживать режим `opaque`. - сохранять порядок команд.
### 10.5. IR/JSON (рекомендуемая форма) ## 10. Что требуется для 1:1 переноса
```json 1. Полная поддержка opcode `1..10`.
{ 2. Точный контракт вычисления `time_mode` и `flags`.
"header": { 3. Точное поведение `ResourceRef64`.
"time_mode": 1, 4. Повторяемый RNG и одинаковая политика плавающей точки.
"duration_sec": 2.5,
"phase_jitter": 0.2,
"flags": 22,
"settings_id": 785,
"rand_shift": [0.0, 0.0, 0.0],
"pivot": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"commands": [
{
"opcode": 8,
"word_raw": 264,
"enabled": 1,
"fields": {
"mode": 1065353216,
"eval_t0": 0.0,
"eval_t1": 1.0,
"resource": {"archive": "material.lib", "name": "fire_smoke"}
},
"opaque_extra_hex": "..."
}
]
}
```
--- ## 11. Статус валидации
## 11. Проверка на реальных данных - Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `923/923` FXID payload без ошибок.
`testdata/nres`: ## 12. Статус покрытия и что осталось до 100%
- FXID payload: `923`; Закрыто:
- валидация parser'а: `923/923 valid`.
Распределение opcode: 1. Контейнер FXID, fixed-size командный поток, opcode-покрытие `1..10`.
2. Базовый runtime-контур исполнения эффекта.
3. Корпусная валидация формата на retail-данных.
- `1: 618` Осталось:
- `2: 517`
- `3: 1545`
- `4: 202`
- `5: 31`
- `6: 0` (в датасете не встречен, но поддержан)
- `7: 1161`
- `8: 237`
- `9: 266`
- `10: 160`
Подтверждённые `ResourceRef64` оффсеты: 1. Полная field-level семантика payload каждого opcode для авторинга новых эффектов «с нуля».
2. Формальная спецификация всех `time_mode` веток на уровне точных числовых формул и edge-case поведения.
- op2 `+84`, op3/4/9 `+136`, op5 `+48`, op7/10 `+144`, op8 `+184`. 3. Полный набор пиксельных parity-тестов FX (оригинал vs новый рендер) на фиксированных сценах.
Для op1 найден редкий расширенный хвост (`+160/+192`) в `effects.rlb:r_lightray_w`:
- `material.lib` / `light_w`.
---
## 12. Практический чек-лист 1:1
Для runtime-порта:
- реализовать `FxHeader60` и parser `sub_10007650`;
- реализовать opcode-классы с методами как в vtable;
- учитывать start/stop/restart контракт manager API;
- воспроизвести `sub_10005C60` + post-flags (`0x20`, `0x200`);
- воспроизвести event loop `sub_10003D30(case 28)`.
Для toolchain:
- strict validator по разделу 10.1;
- canonical writer по разделу 10.3;
- field-aware editor + opaque fallback для неизвестных зон.
---
## 13. Что считать «полной» совместимостью
Практический критерий завершения:
1. Парсер и writer дают byte-identical round-trip для всех 923 FXID.
2. Runtime-порт выдаёт совпадающие state transitions на одинаковом `dt/seed` (по ключевым полям instance + command state).
3. Все opcode `1..10` поддержаны (включая `6`, даже если отсутствует в текущем датасете).
4. `ResourceRef64` и mode-ветки (`op1`, `op2`, `op9`) совпадают с оригиналом.
Эта страница покрывает весь наблюдаемый контракт формата/рантайма и полную карту body-полей по всем opcode.
---
## 14. Что осталось до «абсолютных 100%»
Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно.
Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта:
1. FP-детерминизм: оригинал опирается на x87-style вычисления; SSE/fast-math могут давать расхождения в alpha/таймингах.
2. RNG parity: используется `sub_10002220` (16-bit генератор) и глобальные seed-состояния; для bit-exact воспроизведения нужны контрольные трассы оригинала.
3. Редкие ветки данных: в текущем датасете нет opcode `6`, и почти не встречаются хвосты op1 (`+136..223`); для исчерпывающей валидации нужны дополнительные FXID-образцы.
Что нужно собрать, чтобы закрыть это полностью:
- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state);
- контрольные прогоны при фиксированном `dt` и seed;
- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`).

144
docs/specs/material.md Normal file
View 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 на реальных моделях.

View File

@@ -1,874 +1,18 @@
# Materials, WEAR, MAT0 и Texm # Materials, WEAR, Texm
Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для: Старая объединённая страница разбита по объектам.
- реализации runtime 1:1; - [Material (`MAT0`)](material.md)
- создания инструментов чтения/валидации; - [Wear table (`WEAR`)](wear.md)
- создания инструментов конвертации и редактирования с lossless round-trip. - [Texture (`Texm`)](texture.md)
- [Render pipeline](render.md)
Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`. ## Статус покрытия и что осталось до 100%
--- Закрыто:
## 1. Идентификаторы и сущности 1. Страница корректно декомпозирована на отдельные объектные спецификации.
| Сущность | ID (LE uint32) | ASCII | Где используется | Осталось:
|---|---:|---|---|
| Material resource | `0x3054414D` | `MAT0` | `Material.lib` |
| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` |
| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` |
| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` |
Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`. 1. Поддерживать единый changelog согласованности между `material.md`, `wear.md`, `texture.md` и `render.md`.
---
## 2. Архитектура подсистемы
### 2.1 Экспортируемые точки входа (World3D)
- `LoadMatManager`
- `SetPalettesLib`
- `SetTexturesLib`
- `SetMaterialLib`
- `SetLightMapLib`
- `SetGameTime`
- `UnloadAllTextures`
`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет.
### 2.2 Дефолтные библиотеки (из `iron3d.dll`)
- `Textures.lib`
- `Material.lib`
- `LightMap.lib`
- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`)
### 2.3 Ключевые runtime-хранилища
1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт.
2. Кэш текстурных объектов.
3. Кэш lightmap-объектов.
4. Банк загруженных палитр.
5. Глобальный пул определений материалов (`MAT0`).
---
## 3. Layout `MatManager` (0x470)
Объект содержит 70 таблиц wear/lightmaps (не 140).
```c
// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470
// [0] vtable
// [1] callback iface
// [2] callback data
// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт
// [73..142] wearCounts[70]
// [143] tableCount
// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта
// [214..283] lightmapCounts[70]
```
### 3.1 Vtable методов (`off_100209E4`)
| Индекс | Функция | Назначение |
|---:|---|---|
| 0 | `loc_10002CE0` | служебный/RTTI-заглушка |
| 1 | `sub_10002D10` | деструктор + освобождение таблиц |
| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) |
| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` |
| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` |
| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` |
| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) |
| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера |
| 8 | `sub_100031A0` | получить указатель на lightmap texture object |
| 9 | `sub_10003AB0` | получить runtime-метаданные материала |
| 10 | `sub_100031D0` | получить `wearCount` для таблицы |
### 3.2 Кодирование material-handle
`uint32 handle = (tableIndex << 16) | wearIndex`.
- `HIWORD(handle)` -> индекс таблицы `0..69`
- `LOWORD(handle)` -> индекс материала в wear-таблице
---
## 4. Глобальные кэши и их ёмкость
Ёмкости подтверждены границами циклов/адресов в дизассемблере.
### 4.1 Кэш текстур (`dword_1014E910`...)
- Размер слота: `5 DWORD` (20 байт)
- Ёмкость: `777`
```c
struct TextureSlot {
int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно
void* textureObject; // +4
int32_t refCount; // +8
uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0
uint32_t loadFlags; // +16 флаги загрузки
};
```
`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC.
### 4.2 Кэш lightmaps (`dword_10029C98`...)
- Тот же layout `5 DWORD`
- Ёмкость: `100`
Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается.
### 4.3 Пул материалов (`dword_100669F0`...)
- Шаг: `92 DWORD` (`368` байт)
- Ёмкость: `700`
Фиксированные поля на шаг `i*92`:
| DWORD offset | Byte offset | Поле |
|---:|---:|---|
| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free |
| 1 | 4 | `refCount` |
| 2 | 8 | `phaseCount` |
| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) |
| 4 | 16 | `animBlockCount` (`< 20`) |
| 5..84 | 20..339 | `animBlocks[20]` по 16 байт |
| 85 | 340 | metaA (`dword_10066B44`) |
| 86 | 344 | metaB (`dword_10066B48`) |
| 87 | 348 | metaC (`dword_10066B4C`) |
| 88 | 352 | metaD (`dword_10066B50`) |
| 89 | 356 | flagA (`dword_10066B54`) |
| 90 | 360 | nibbleMode (`dword_10066B58`) |
| 91 | 364 | flagB (`dword_10066B5C`) |
### 4.4 Банк палитр
- `dword_1013DA58[]`
- Загружается до `286` элементов (26 букв * 11 вариантов)
---
## 5. Загрузка палитр (`sub_10002B40`)
### 5.1 Генерация имён
Движок перебирает:
- буквы `'A'..'Z'`
- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"`
И формирует имя:
- `<Letter><Suffix>.PAL`
- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL`
### 5.2 Индекс палитры
`paletteIndex = letterIndex * 11 + variantIndex`
- `letterIndex = 0..25`
- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10)
### 5.3 Поведение
- Если запись не найдена: `paletteSlots[idx] = 0`
- Если найдена: payload отдаётся в рендер (`render->method+60`)
---
## 6. Формат `MAT0` (`Material.lib`)
### 6.1 Атрибуты NRes entry
`sub_10004310` использует:
- `entry.type` = `MAT0`
- `entry.attr1` (bitfield runtime-флагов)
- `entry.attr2` (версия/вариант заголовка payload)
- `entry.attr3` не используется в runtime-парсере
Маппинг `attr1`:
- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы
- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000`
- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF`
- bit6 (`0x40`) -> `flagB=1`
### 6.2 Payload layout
```c
struct Mat0Payload {
uint16_t phaseCount;
uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material."
// Если attr2 >= 2:
uint8_t metaA8;
uint8_t metaB8;
// Если attr2 >= 3:
uint32_t metaC32;
// Если attr2 >= 4:
uint32_t metaD32;
PhaseRecordByte34 phases[phaseCount];
AnimBlockRaw anim[animBlockCount];
};
```
Если `attr2 < 2`, runtime-значения по умолчанию:
- `metaA = 255`
- `metaB = 255`
- `metaC = 1.0f` (`0x3F800000`)
- `metaD = 0`
### 6.3 `PhaseRecordByte34` -> runtime `76 bytes`
Сырые 34 байта:
```c
struct PhaseRecordByte34 {
uint8_t p[18]; // параметры
char textureName[16];// если textureName[0]==0, текстуры нет
};
```
Преобразование в runtime-структуру (точный порядок):
| Из `p[i]` | В offset runtime | Преобразование |
|---:|---:|---|
| `p[0]` | `+16` | `p[0] / 255.0f` |
| `p[1]` | `+20` | `p[1] / 255.0f` |
| `p[2]` | `+24` | `p[2] / 255.0f` |
| `p[3]` | `+28` | `p[3] * 0.01f` |
| `p[4]` | `+0` | `p[4] / 255.0f` |
| `p[5]` | `+4` | `p[5] / 255.0f` |
| `p[6]` | `+8` | `p[6] / 255.0f` |
| `p[7]` | `+12` | `p[7] / 255.0f` |
| `p[8]` | `+32` | `p[8] / 255.0f` |
| `p[9]` | `+36` | `p[9] / 255.0f` |
| `p[10]` | `+40` | `p[10] / 255.0f` |
| `p[11]` | `+44` | `p[11] / 255.0f` |
| `p[12]` | `+48` | `p[12] / 255.0f` |
| `p[13]` | `+52` | `p[13] / 255.0f` |
| `p[14]` | `+56` | `p[14] / 255.0f` |
| `p[15]` | `+60` | `p[15] / 255.0f` |
| `p[16]` | `+64` | `uint32 = p[16]` |
| `p[17]` | `+72` | `int32 = p[17]` |
Текстура:
- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1`
- иначе `runtime[+68] = LoadTexture(textureName, flags)`
### 6.4 Runtime-запись фазы (76 байт)
```c
struct MaterialPhase76 {
float f0; // +0
float f1; // +4
float f2; // +8
float f3; // +12
float f4; // +16
float f5; // +20
float f6; // +24
float f7; // +28
float f8; // +32
float f9; // +36
float f10; // +40
float f11; // +44
float f12; // +48
float f13; // +52
float f14; // +56
float f15; // +60
uint32_t u16; // +64
int32_t texSlot; // +68 (индекс в texture cache, либо -1)
int32_t i18; // +72
};
```
### 6.5 Анимационные блоки (`animBlockCount`, максимум 19)
Каждый блок в payload:
```c
struct AnimBlockRaw {
uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3
uint16_t keyCount;
struct KeyRaw {
uint16_t k0;
uint16_t k1;
uint16_t k2;
} keys[keyCount];
};
```
Runtime-представление блока = 16 байт:
```c
struct AnimBlockRuntime {
uint32_t mode; // headerRaw & 7
uint32_t interpMask;// headerRaw >> 3
int32_t keyCount;
void* keysPtr; // массив keyCount * 8
};
```
Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`).
`k2` в `sub_100031F0/sub_10003680` не используется.
Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате.
### 6.6 Поиск и fallback
При `LoadMaterial(name)`:
- сначала точный поиск в `Material.lib`;
- при промахе лог: `"Material %s not found."`;
- fallback на `DEFAULT`;
- если и `DEFAULT` не найден, берётся индекс `0`.
---
## 7. Выбор текущей material-фазы
### 7.1 Интерполяция (`sub_10003030`)
Интерполируются только следующие поля (по `interpMask`):
- bit `0x02`: `+4,+8,+12`
- bit `0x01`: `+20,+24,+28`
- bit `0x04`: `+36,+40,+44`
- bit `0x08`: `+52,+56,+60`
- bit `0x10`: `+32`
Не интерполируются и копируются из «текущей» фазы:
- `+0,+16,+48,+64,+68,+72`
### 7.2 Выбор по времени (`sub_100031F0`)
Вход:
- `handle` (`tableIndex|wearIndex`)
- `animBlockIndex`
- глобальное время `SetGameTime()` (`dword_10032A38`)
Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`).
Режимы `mode = headerRaw & 7`:
- `0`: loop
- `1`: ping-pong
- `2`: one-shot clamp
- `3`: random (`rand() % cycleLength`)
Важные детали 1:1:
- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением);
- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную).
- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case).
После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр.
### 7.3 Выбор по нормализованному `t` (`sub_10003680`)
Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`.
Перед вычислением времени применяется runtime-нормализация:
- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`.
### 7.4 Сброс времени записи
`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`.
---
## 8. Формат `WEAR` (текст)
`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`.
### 8.1 Грамматика
```text
<wearCount:int>\n
<legacyId:int> <materialName>\n // повторить wearCount раз
[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка
[LIGHTMAPS\n
<lightmapCount:int>\n
<legacyId:int> <lightmapName>\n // повторить lightmapCount раз]
```
- `<legacyId>` читается, но как ключ не используется.
- Идентификатором реально является имя (`materialName` / `lightmapName`).
### 8.2 Парсеры
1. `sub_10003B10`: файл/ресурсный режим.
2. `sub_10003F80`: парсер из строкового буфера.
Различие важно для совместимости:
- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`.
- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать.
### 8.3 Поведение и ошибки
- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."`
- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."`
- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."`
- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT`
- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1`
- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения.
### 8.4 Ограничения runtime
- Таблиц в `MatManager`: максимум 70 (физический layout).
- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет.
Инструментам нужно явно валидировать `tableCount < 70`.
---
## 9. Загрузка texture/lightmap по имени
Общие функции:
- `sub_10004B10` — texture (`Textures.lib`)
- `sub_10004CB0` — lightmap (`LightMap.lib`)
### 9.1 Валидация имени
Алгоритм требует наличие `'.'` в позиции `0..16`.
Иначе:
- `"Bad texture name."`
- возврат `-1`
### 9.2 Palette index из суффикса
После точки разбирается:
- `L = toupper(name[dot+1])`
- `D = name[dot+2]` (опционально)
- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)`
Если `idx < 0`, палитра не подставляется (`0`).
Верхняя граница `idx` в runtime не проверяется.
Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки.
Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива.
### 9.3 Кэширование
- Дедупликация по `resIndex`.
- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`.
- При освобождении материала `refCount` texture/lightmap уменьшается.
- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд.
- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor).
---
## 10. Формат `Texm`
### 10.1 Заголовок 32 байта
```c
struct TexmHeader32 {
uint32_t magic; // 'Texm' = 0x6D786554
uint32_t width;
uint32_t height;
uint32_t mipCount;
uint32_t flags4;
uint32_t flags5;
uint32_t unk6;
uint32_t format;
};
```
### 10.2 Поддерживаемые `format`
Подтверждённые в данных:
- `0` (палитровый 8-bit)
- `565`
- `4444`
- `888`
- `8888`
Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации):
- `556`
- `88`
### 10.3 Layout payload
1. `TexmHeader32`
2. если `format == 0`: palette table `256 * 4 = 1024` байта
3. mip-chain пикселей
4. опциональный `Page` chunk
Расчёт:
```c
bytesPerPixel =
(format == 0) ? 1 :
(format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
4;
pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i));
sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount;
```
### 10.4 `Page` chunk
```c
struct PageChunk {
uint32_t magic; // 'Page'
uint32_t rectCount;
struct Rect16 {
int16_t x;
int16_t w;
int16_t y;
int16_t h;
} rects[rectCount];
};
```
Runtime конвертирует `Rect16` в:
- пиксельные прямоугольники;
- UV-границы с учётом возможного `mipSkip`.
Формулы (`s = mipSkip`):
- `x0 = x << s`, `x1 = (x + w) << s`
- `y0 = y << s`, `y1 = (y + h) << s`
- `u0 = x / (width << s)`, `du = w / (width << s)`
- `v0 = y / (height << s)`, `dv = h / (height << s)`
Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`.
### 10.5 Loader-поведение (`sub_1000FB30`)
- Читает header в внутренние поля (`+56..+84`) напрямую:
- `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`,
- `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`.
- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу.
- Считает `sizeCore`, находит tail.
- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`.
- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов.
- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime.
### 10.6 Политика `mipSkip` (`sub_1000F580`)
`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`:
- если `mipCount <= 1` -> `0`
- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1`
- если `flags5Mask == 0x10000000` -> `1`
- если `flags5Mask == 0x20000000`:
- `1`, если `width >= 256` или `height >= 256`
- иначе `0`
- если `flags5Mask == 0x40000000`:
- если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1`
- если `width == 128` или `height == 128`: `1`
- иначе `0`
- иначе `0`
Применение в loader:
- `mipCount -= mipSkip`
- `width >>= mipSkip`, `height >>= mipSkip`
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1`
- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня)
---
## 11. Флаги профиля/рендера (Ngi32)
Ключ реестра: `HKCU\Software\Nikita\NgiTool`.
Подтверждённые значения:
- `Disable MultiTexturing`
- `DisableMipmap`
- `Force 16-bit textures`
- `UseFirstCard`
- `DisableD3DCalls`
- `DisableDSound`
- `ForceCpu`
Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки.
---
## 12. Спецификация для toolchain (read/edit/write)
### 12.1 Каноническая модель данных
1. `MAT0`:
- хранить исходные `attr1/attr2/attr3`;
- хранить сырой payload + декодированную структуру;
- при записи сохранять порядок/размеры секций точно.
2. `WEAR`:
- хранить строки wear/lightmaps как текст;
- сохранять порядок строк;
- допускать отсутствие блока `LIGHTMAPS`.
- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`.
3. `Texm`:
- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать);
- хранить palette (если есть), mip data, `Page`.
### 12.2 Правила lossless записи
- Не менять значения `flags4/flags5/unk6` без явной причины.
- Не менять `NRes` entry attrs, если цель — бинарный round-trip.
- Для `MAT0`:
- `animBlockCount < 20`.
- `phaseCount` и фактический размер секции должны совпадать.
- textureName в фазе всегда укладывать в 16 байт и NUL-терминировать.
- Для `Texm`:
- `magic == 'Texm'`.
- `mipCount > 0`, `width>0`, `height>0`.
- tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт.
- при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`.
### 12.3 Рекомендованные валидации редактора
- `WEAR`:
- `wearCount > 0`.
- число строк wear соответствует `wearCount`.
- если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает.
- для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`.
- `MAT0`:
- не выходить за payload при распаковке.
- все ссылки фаз/keys проверять на диапазоны.
- `Texm`:
- `sizeCore <= payload_size`.
- проверка `Page` как `8 + rectCount*8`.
- предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime.
---
## 13. Проверка на реальных данных (`tmp/gamedata`)
### 13.1 `Material.lib`
- `905` entries, все `type=MAT0`
- `attr2 = 6` у всех
- `attr3 = 0` у всех
- `phaseCount` до `29`
- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается)
### 13.2 `Textures.lib`
- `393` entries, все `type=Texm`
- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)`
- `flags4`: `32(361), 0(32)`
- `flags5`: `0(312), 0x04000000(81)`
- `Page` chunk присутствует у `65` текстур
### 13.3 `lightmap.lib`
- `25` entries, все `Texm`
- формат: `565`
- `mipCount=1`
- `flags5`: в основном `0`, встречается `0x00800000`
### 13.4 `WEAR`
- `439` entries `type=WEAR`
- `attr1=0, attr2=0, attr3=1`
- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`)
- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`.
---
## 14. Opaque-поля и границы знания
Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`:
- `MAT0`:
- `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений);
- `metaA/metaB/metaC/metaD``World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено).
- `Texm`:
- `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1).
Это не блокирует реализацию движка/конвертеров 1:1.
---
## 15. Минимальные псевдокоды для реализации
### 15.1 `parse_mat0(payload, attr2)`
```python
def parse_mat0(payload: bytes, attr2: int):
cur = 0
phase_count = u16(payload, cur); cur += 2
anim_count = u16(payload, cur); cur += 2
if anim_count >= 20:
raise ValueError("Too many animations for material")
if attr2 < 2:
metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0
else:
metaA = u8(payload, cur); cur += 1
metaB = u8(payload, cur); cur += 1
metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000
cur += 4 if attr2 >= 3 else 0
metaD = u32(payload, cur) if attr2 >= 4 else 0
cur += 4 if attr2 >= 4 else 0
phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)]
cur += 34 * phase_count
anim = []
for _ in range(anim_count):
raw = u32(payload, cur); cur += 4
key_count = u16(payload, cur); cur += 2
keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)]
cur += 6 * key_count
anim.append((raw, keys))
if cur != len(payload):
raise ValueError("MAT0 tail bytes")
return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim
```
### 15.2 `parse_texm(payload)`
```python
def parse_texm(payload: bytes):
magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0)
if magic != 0x6D786554:
raise ValueError("not Texm")
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4)
pix = 0
mw, mh = w, h
for _ in range(mips):
pix += mw * mh
mw = max(1, mw >> 1)
mh = max(1, mh >> 1)
core = 32 + (1024 if fmt == 0 else 0) + bpp * pix
if core > len(payload):
raise ValueError("truncated")
page = None
if core < len(payload):
if core + 8 > len(payload) or payload[core:core+4] != b"Page":
raise ValueError("tail without Page")
n = u32(payload, core + 4)
need = 8 + n * 8
if core + need != len(payload):
raise ValueError("invalid Page size")
page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)]
return (w, h, mips, fmt, f4, f5, unk6, page)
```
### 15.3 `mip_skip_policy(flags5, width, height, mip_count)`
```python
def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int:
if mip_count <= 1:
return 0
m = flags5 & 0x72000000
if m == 0x02000000:
return 2 if mip_count > 2 else 1
if m == 0x10000000:
return 1
if m == 0x20000000:
return 1 if (width >= 256 or height >= 256) else 0
if m == 0x40000000:
if width > 128 and height > 128:
return 2 if mip_count > 2 else 1
if width == 128 or height == 128:
return 1
return 0
```
### 15.4 `parse_wear_buffer_compatible(text)`
```python
def parse_wear_buffer_compatible(text: str):
lines = text.splitlines()
i = 0
wear_count = int(lines[i].strip()); i += 1
if wear_count <= 0:
raise ValueError("Illegal wear length.")
wear = []
for _ in range(wear_count):
legacy, name = lines[i].split(maxsplit=1)
wear.append((int(legacy), name.strip()))
i += 1
lightmaps = []
tail = lines[i:] if i < len(lines) else []
if tail and tail[0].strip() == "":
# sub_10003F80-совместимый разделитель перед LIGHTMAPS
i += 1
tail = lines[i:]
if tail and tail[0].strip().upper() == "LIGHTMAPS":
i += 1
if i >= len(lines):
raise ValueError("Illegal lightmaps length.")
light_count = int(lines[i].strip()); i += 1
if light_count <= 0:
raise ValueError("Illegal lightmaps length.")
for _ in range(light_count):
legacy, name = lines[i].split(maxsplit=1)
lightmaps.append((int(legacy), name.strip()))
i += 1
return wear, lightmaps
```
### 15.5 `select_phase_time_1to1(...)`
```python
def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int):
# keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len
cycle_len = keys[-1][2]
if cycle_len <= 0:
return 0, 0.0
# unsigned div/mod как в runtime
delta = (game_time - start_time) & 0xFFFFFFFF
q = delta // cycle_len
r = delta % cycle_len
if mode == 1: # ping-pong
if q & 1:
r = cycle_len - r
elif mode == 2: # one-shot
if q > 0:
k = len(keys) - 1
return k, 0.0
elif mode == 3: # random
r = rand32() % cycle_len
start_time = r # side effect как в sub_100031F0
k = find_segment(keys, r) # t_start <= r < t_end
kn = 0 if (k + 1 == len(keys)) else (k + 1)
t0, t1 = keys[k][1], keys[k][2]
alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0)
return (k, kn), alpha
```

View File

@@ -1,5 +1,46 @@
# Missions # Missions
Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира. Подсистема `Missions` управляет сценарием:
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`. - стартовыми условиями;
- триггерами;
- победой/поражением;
- синхронизацией с 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)

View File

@@ -1,517 +1,126 @@
# MSH animation # MSH animation
Документ фиксирует анимационную часть формата MSH (`Res8`, `Res19`) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor). `MSH animation` описывает связку `Res8 + Res19` и runtime-правила сэмплирования/смешивания поз.
Связанные документы: Связанные страницы:
- [MSH core](msh-core.md) — общая структура модели и `Res1`/`Res2`.
- [NRes / RsLi](nres.md) — контейнер и атрибуты записей.
--- - [MSH core](msh-core.md)
- [Render pipeline](render.md)
## 1. Область и источники ## 1. Ресурсы анимации
Спецификация основана на: ### 1.1. `Res8` (пул ключей)
- `tmp/disassembler1/AniMesh.dll.c` (псевдо-C): `sub_10015FD0`, `sub_10012880`, `sub_10012560`.
- `tmp/disassembler2/AniMesh.dll.asm` (ASM): подтверждение x87-пути (`FISTP`) и ветвлений.
- `tmp/disassembler1/Ngi32.dll.c` (псевдо-C): `sub_10002F90`, `sub_10014540`, `sub_10014630`, `sub_10015D80`, `sub_10017E60`, `sub_10017F50`, `sub_10006D00`, `niGetProcAddress`.
- `tmp/disassembler2/Ngi32.dll.asm` (ASM): подтверждение таблицы `g_FastProc` и FPU control-word setup.
- валидации corpus (`testdata`): 435 моделей `*.msh`.
Ниже разделено на:
- **Нормативно**: обязательно для runtime-совместимости.
- **Канонично**: как устроены исходные ассеты; важно для детерминированного writer/editor.
---
## 2. Ресурсы и поля модели
### 2.1. Res8 — key pool (нормативно)
`Res8` — массив ключей фиксированного шага 24 байта.
```c ```c
struct AnimKey24 { struct AnimKey24 {
float pos_x; // +0x00 float pos_x;
float pos_y; // +0x04 float pos_y;
float pos_z; // +0x08 float pos_z;
float time; // +0x0C float time;
int16_t qx; // +0x10 int16_t qx;
int16_t qy; // +0x12 int16_t qy;
int16_t qz; // +0x14 int16_t qz;
int16_t qw; // +0x16 int16_t qw;
}; };
``` ```
Декодирование quaternion-компонент: Декодирование quaternion-компонент: `q = s16 / 32767.0`.
### 1.2. `Res19` (карта кадров)
```c ```c
float q = (float)s16 * (1.0f / 32767.0f); uint16_t map_words[]; // size/2 элементов
``` ```
Атрибуты NRes: `Res19.attr2` хранит глобальную длину таймлайна (число кадров).
- `attr1 = size / 24` (количество ключей).
- `attr2 = 0` (в observed corpus).
- `attr3 = 4` (не stride; это фактический runtime-инвариант формата).
### 2.2. Res19 — frame->segment map (нормативно) ### 1.3. Связь с `Res1`
`Res19` — непрерывный `uint16` массив: Для каждого узла:
```c - `anim_map_start` (`hdr2`) — начало блока в `Res19` или `0xFFFF`.
uint16_t map_words[]; // count = size / 2 - `fallback_key` (`hdr3`) — индекс fallback-ключа в `Res8`.
```
Атрибуты NRes: ## 2. Сэмплирование узла
- `attr1 = size / 2` (число `uint16` слов).
- `attr2 = animFrameCount` (глобальная длина таймлайна модели в кадрах).
- `attr3 = 2`.
### 2.3. Связь с Res1 node header (нормативно) Вход: время `t`, текущий узел.
Выход: `quat(w,x,y,z)` и `pos(x,y,z)`.
Для `Res1` со stride 38 (основной формат): ### 2.1. Индекс кадра
- `hdr2` (`node + 0x04`) = `mapStart` (`0xFFFF` => map для узла отсутствует).
- `hdr3` (`node + 0x06`) = `fallbackKeyIndex` (индекс ключа в `Res8`).
Runtime читает эти поля напрямую в `sub_10012880`. Движок использует x87-совместимое округление для выражения `t - 0.5`.
Для 1:1 повторения нужно сохранить ту же политику плавающей точки.
### 2.4. Поля runtime-модели, задействованные анимацией (нормативно) ### 2.2. Выбор key index
Инициализация в `sub_10015FD0`: 1. Если кадр вне диапазона `frame_count` -> `fallback_key`.
- `model+0x18` -> `Res8` pointer. 2. Если `anim_map_start == 0xFFFF` -> `fallback_key`.
- `model+0x1C` -> `Res19` pointer. 3. Иначе берётся `map_words[anim_map_start + frame]`:
- `model+0x9C` <- `NResEntry(Res19).attr2` (`animFrameCount`). - если значение `>= fallback_key`, тоже используется `fallback_key`;
- иначе используется значение из map.
--- ### 2.3. Интерполяция
## 3. Runtime-сэмплирование узла (`sub_10012880`) Если выбран fallback, возвращается ровно этот ключ без интерполяции.
Функция возвращает: Иначе:
- quaternion (4 float) в буфер `outQuat`,
- позицию (3 float) в `outPos`.
Вход: 1. Берутся соседние ключи `k0` и `k1`.
- `t` — sample time. 2. Если `t` точно равен `k0.time` или `k1.time`, возвращается соответствующий ключ.
- текущий `nodeIndex` берётся из runtime-объекта (не из аргумента). 3. Иначе:
- `alpha = (t - k0.time) / (k1.time - k0.time)`
- `pos = lerp(k0.pos, k1.pos, alpha)`
- `quat = slerp_like(k0.quat, k1.quat, alpha)`
### 3.1. Вычисление frame index (нормативно) Кватернион в runtime хранится в порядке `[w, x, y, z]`.
Алгоритм: ## 3. Смешивание двух сэмплов
1. `x = t - 0.5`.
2. `frame = x87 FISTP(x)` (через 64-битный промежуточный буфер).
Важно: При blending между позами A и B:
- это не «просто floor»;
- поведение зависит от x87 control word.
В оригинальном runtime control word приводится к каноничному виду в `Ngi32::sub_10006D00`: 1. Выбираются валидные стороны по `blend` и валидности времени.
- `cw = (cw & 0xF0FF) | 0x003F`; 2. Если активна одна сторона, берётся она.
- это даёт `round-to-nearest` (RC=00), precision control `PC=00` и маскирование x87-исключений. 3. Если активны обе:
- применяется shortest-path flip для `qB`;
- выполняется quaternion blend;
- позиция смешивается линейно.
Если нужен byte/behavior 1:1, надо повторить именно x87-ветку или её точный эквивалент. Матрица строится из quaternion, а translation подставляется отдельным шагом.
### 3.2. Выбор `keyIndex` (нормативно) ## 4. Каноника writer
```c Рекомендуемые правила:
node = Res1 + nodeIndex * 38;
mapStart = u16(node + 4); // hdr2
fallback = u16(node + 6); // hdr3
if ((uint32_t)frame >= animFrameCount 1. Ключи узлов писать подряд в `Res8` в порядке узлов.
|| mapStart == 0xFFFF 2. `fallback_key` узла указывает на последний ключ его трека.
|| map_words[mapStart + (uint32_t)frame] >= fallback) { 3. Для узлов с map выделять блок длины `frame_count` в `Res19`.
keyIndex = fallback; 4. Для статических узлов: `anim_map_start = 0xFFFF`, один ключ с `time=0`.
} else { 5. `Res8.attr1 = key_count`, `Res8.attr3 = 4`.
keyIndex = map_words[mapStart + (uint32_t)frame]; 6. `Res19.attr1 = map_word_count`, `Res19.attr2 = frame_count`, `Res19.attr3 = 2`.
}
```
Критично: ## 5. Валидация перед сохранением
- runtime не проверяет bounds у `fallback` и `mapStart + frame`; некорректные данные приводят к OOB.
### 3.3. Сэмплирование ключей (нормативно) - `Res8.size % 24 == 0`
- `Res19.size % 2 == 0`
- каждый `fallback_key < key_count`
- для узла с map: `anim_map_start + frame_count <= map_word_count`
- внутри трека времена ключей строго возрастают
`k0 = Res8[keyIndex]`. ## 6. Статус валидации
Ветки: - Форматные проверки включены в `tools/msh_doc_validator.py`.
1. fallback-ветка из п.3.2: возвращается строго `k0` (без `k1`). - Корпусная валидация анимационных инвариантов включена в прогон `tools/msh_doc_validator.py` на полном retail-наборе.
2. map-ветка:
- если `t == k0.time` -> вернуть `k0`;
- иначе берётся `k1 = Res8[keyIndex + 1]`;
- если `t == k1.time` -> вернуть `k1`;
- иначе:
- `alpha = (t - k0.time) / (k1.time - k0.time)`;
- `pos = lerp(k0.pos, k1.pos, alpha)`;
- `quat = fastproc_interp(k0.quat, k1.quat, alpha)` (`g_FastProc[17]`).
Сравнение `t == key.time` строгое (битовая float-эквивалентность по FPU compare), без epsilon. ## 7. Статус покрытия и что осталось до 100%
### 3.4. Порядок quaternion-компонент в runtime (нормативно) Закрыто:
В `Res8` компоненты лежат как `qx,qy,qz,qw`, но в runtime-буферы они попадают в порядке: 1. Контракт `Res8 + Res19` и fallback-логика выбора ключа.
- `outQuat[0] = qw`; 2. Базовая интерполяция поз и blending двух сэмплов.
- `outQuat[1] = qx`; 3. Канонические инварианты writer path для существующих ассетов.
- `outQuat[2] = qy`;
- `outQuat[3] = qz`.
То есть все `g_FastProc`-пути в анимации работают с quaternion в порядке `float4 = [w, x, y, z]`. Осталось:
--- 1. Полная фиксация численного поведения на всех FP-edge-case (включая платформенные различия округления).
2. Полный writer-профиль для авторинга новых анимаций без опоры на reference copy-through.
## 4. Runtime-смешивание двух сэмплов (`sub_10012560`) 3. Набор runtime parity-тестов «frame-by-frame pose equivalence» на длинных анимациях.
`sub_10012560(this, tA, tB, blend, outMatrix4x4)` смешивает две позы.
### 4.1. Валидация входов (нормативно)
Выбор доступных сэмплов:
- `hasA = (blend < 1.0f) && (tA >= 0.0f)`.
- `hasB = (blend > 0.0f) && (tB >= 0.0f)`.
Ветки:
- только `hasA`: матрица из A.
- только `hasB`: матрица из B.
- оба: полноценное смешивание.
- ни одного: в оригинале путь не защищён (caller contract).
### 4.2. Смешивание quaternion (нормативно)
Перед интерполяцией выполняется shortest-path flip:
```c
if (|qA + qB|^2 < |qA - qB|^2) {
qB = -qB;
}
```
Далее:
- `q = fastproc_blend(qA, qB, blend)` (`g_FastProc[22]`);
- `outMatrix = quat_to_matrix(q)` (`g_FastProc[14]`).
### 4.3. Смешивание translation (нормативно)
Позиция смешивается отдельно:
```c
pos = (1-blend) * posA + blend * posB;
outMatrix[3] = pos.x;
outMatrix[7] = pos.y;
outMatrix[11] = pos.z;
```
(`sub_1000B8E0` подтверждает, что используются именно эти ячейки).
### 4.4. Точные `g_FastProc[14/17/22]` (нормативно)
`niGetProcAddress(i)` в `Ngi32` возвращает `g_FastProc[i]` (таблица function pointers).
В `AniMesh` используются:
- `call [g_FastProc + 0x38]` -> index 14 -> `quat_to_matrix`.
- `call [g_FastProc + 0x44]` -> index 17 -> `quat_interp`.
- `call [g_FastProc + 0x58]` -> index 22 -> `quat_blend`.
Связь с символами `Ngi32` (по адресам таблицы):
- `g_FastProc` base = `0x1003A058`;
- index 14 -> `0x1003A090`;
- index 17 -> `0x1003A09C`;
- index 22 -> `0x1003A0B0`.
Назначения по CPU-веткам (`sub_10002F90`) и семантика:
- scalar path: `14=sub_10017E60` (или `sub_10014540`), `17=22=sub_10017F50` (или `sub_10014630`);
- SIMD path (`dword_1003A168`): `14=sub_1001D830`, `17=22=sub_10015D80`;
- все варианты эквивалентны по математике.
Точная формула `quat_to_matrix` для `q=[w,x,y,z]`:
```c
m[0] = 1 - 2*(y*y + z*z);
m[1] = 2*(x*y + w*z);
m[2] = 2*(x*z - w*y);
m[3] = 0;
m[4] = 2*(x*y - w*z);
m[5] = 1 - 2*(x*x + z*z);
m[6] = 2*(y*z + w*x);
m[7] = 0;
m[8] = 2*(x*z + w*y);
m[9] = 2*(y*z - w*x);
m[10] = 1 - 2*(x*x + y*y);
m[11] = 0;
m[12] = 0;
m[13] = 0;
m[14] = 0;
m[15] = 1;
```
Точная формула `quat_interp`/`quat_blend` (`index 17` и `22`, один и тот же алгоритм):
```c
float dot = dot4(q0, q1);
float sign = 1.0f;
if (dot < 0.0f) { dot = -dot; sign = -1.0f; }
float w0, w1;
if (1.0f - dot <= 9.9999997e-6f) {
w0 = 1.0f - a;
w1 = a;
} else {
float theta = acos(dot);
float inv_sin_theta = 1.0f / sin(theta);
w1 = sin(a * theta) * inv_sin_theta;
w0 = cos(a * theta) - w1 * dot;
}
w1 *= sign;
out = w0 * q0 + w1 * q1;
```
Примечание: явной нормализации `out` в конце нет; используется закрытая форма SLERP-весов.
Reference pseudocode:
```c
void blend_pose(Model *m, float tA, float tB, float blend, float out_m[16]) {
bool hasA = (blend < 1.0f) && (tA >= 0.0f);
bool hasB = (blend > 0.0f) && (tB >= 0.0f);
float qA[4], qB[4], pA[3], pB[3];
if (hasA) sample_node_pose(m, m->node_index, tA, qA, pA);
if (hasB) sample_node_pose(m, m->node_index, tB, qB, pB);
if (hasA && !hasB) { quat_to_matrix(qA, out_m); set_translation(out_m, pA); return; }
if (!hasA && hasB) { quat_to_matrix(qB, out_m); set_translation(out_m, pB); return; }
// !hasA && !hasB: undefined by design, caller does not use this path.
if (dot4(qA + qB, qA + qB) < dot4(qA - qB, qA - qB)) negate4(qB);
float q[4];
fastproc_quat_blend(qA, qB, blend, q); // g_FastProc[22]
quat_to_matrix(q, out_m); // g_FastProc[14]
float p[3];
p[0] = (1.0f - blend) * pA[0] + blend * pB[0];
p[1] = (1.0f - blend) * pA[1] + blend * pB[1];
p[2] = (1.0f - blend) * pA[2] + blend * pB[2];
out_m[3] = p[0];
out_m[7] = p[1];
out_m[11] = p[2];
}
```
---
## 5. Каноническая модель данных для toolchain
Ниже правила, по которым удобно строить editor/writer. Они верифицированы на corpus (435 моделей), и совпадают с тем, как устроены оригинальные ассеты.
### 5.1. Декомпозиция key pool на track-и узлов (канонично)
Для `Res1` stride 38:
- `fallback_i = node[i].hdr3`.
- `start_i = (i == 0) ? 0 : (fallback_{i-1} + 1)`.
- track узла `i` = `Res8[start_i .. fallback_i]`.
Наблюдаемые инварианты:
- `fallback_i` строго возрастает по `i`.
- track всегда непустой (`fallback_i >= start_i`).
- для узлов без map (`hdr2 == 0xFFFF`) track длиной ровно 1 ключ.
- для узлов с map track длиной минимум 2 ключа.
### 5.2. Временная ось ключей (канонично)
В observed corpus:
- `time` всех ключей — целые неотрицательные float (`0.0, 1.0, ...`).
- внутри track: строго возрастают.
- `time(start_i) == 0.0` у каждого узла.
- глобальный `Res19.attr2 == max_i(time(fallback_i)) + 1`.
### 5.3. Компоновка Res19 map-блоков (канонично)
Если `Res19.size > 0`:
- map-блоки есть только у узлов с `hdr2 != 0xFFFF`;
- длина блока каждого такого узла: `frameCount = Res19.attr2`;
- блоки идут подряд, без дыр и overlap;
- итог: `Res19.attr1 == animated_node_count * frameCount`.
Если модель статическая:
- `Res19.size == 0`, `Res19.attr1 == 0`, `Res19.attr2 == 1`, `Res19.attr3 == 2`;
- у всех узлов `hdr2 == 0xFFFF`.
### 5.4. Семантика `map_words[f]` в каноничном writer
Для кадра `f` и track `keys[start..end]`:
- если `f < keys[start].time` или `f >= keys[end].time` -> писать `fallback = end`;
- иначе писать индекс левого ключа сегмента (`start <= idx < end`) такого, что:
- `keys[idx].time <= f < keys[idx+1].time`.
В исходных данных fallback-фреймы кодируются значением `== fallback` (не просто `>= fallback`).
---
## 6. Reference IR для редактора/конвертера
Рекомендуемое промежуточное представление:
```c
struct NodeAnimTrack {
uint32_t node_index;
bool has_map; // hdr2 != 0xFFFF
uint16_t fallback_key; // hdr3 (derived on write)
vector<AnimKey> keys; // local keys for node
vector<uint16_t> frame_map; // optional, size == frame_count when has_map
};
struct AnimModel {
uint32_t frame_count; // Res19.attr2
vector<NodeAnimTrack> tracks; // in node order
};
```
Где `AnimKey`:
- `pos: float3`,
- `time: float`,
- `quat_raw: int16[4]` (для lossless),
- `quat_decoded: float4` (опционально для API/UI).
---
## 7. Алгоритм чтения (reader)
1. Загрузить `Res1`, `Res8`, `Res19`.
2. Проверить `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
3. Для каждого узла `i` (stride 38):
- взять `hdr2/hdr3`;
- вычислить `start_i` через предыдущий `hdr3`;
- извлечь `keys[start_i..hdr3]`;
- если `hdr2 != 0xFFFF`, взять `frame_map = Res19[hdr2 : hdr2 + frame_count]`.
4. Валидировать, что map-значения либо `< hdr3`, либо fallback (`== hdr3` канонично).
---
## 8. Алгоритм записи (writer)
Нормативный минимум для runtime-совместимости:
1. Собрать keys всех узлов в один `Res8` pool в node-order.
2. Записать `hdr3 = end_index` каждого узла.
3. Вычислить `frame_count` и записать в `Res19.attr2`.
4. Для узлов с map:
- `hdr2 = cursor`;
- append `frame_count` слов в `Res19`;
- `cursor += frame_count`.
5. Для узлов без map: `hdr2 = 0xFFFF`.
6. Выставить атрибуты:
- `Res8.attr1 = key_count`, `Res8.attr2 = 0`, `Res8.attr3 = 4`;
- `Res19.attr1 = map_word_count`, `Res19.attr3 = 2`.
Каноничный writer (рекомендуется):
- генерирует map по правилу §5.4;
- fallback-фреймы записывает `== fallback`;
- для статических узлов использует 1 ключ (`time=0`, `hdr2=0xFFFF`).
---
## 9. Валидация перед сохранением
Обязательные проверки:
1. `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
2. Для каждого узла: `fallbackKeyIndex < key_count`.
3. Если `hdr2 != 0xFFFF`: `hdr2 + frame_count <= map_word_count`.
4. Для map-сегмента узла:
- любое значение `< fallback` должно удовлетворять `value + 1 < key_count`.
5. В track узла:
- `time` строго возрастает;
- при наличии map минимум 2 ключа.
6. `frame_count > 0` (игровые ассеты используют минимум 1).
Рекомендуемые проверки (каноничность):
1. `fallback_i` строго возрастает по узлам.
2. track каждого узла начинается с `time == 0`.
3. `frame_count == max_end_time + 1`.
4. map-блоки узлов без дыр/overlap.
---
## 10. Edge cases и совместимость
### 10.1. `Res19.size == 0`
Поддерживается runtime-ом:
- `frame_count` обычно 1;
- `hdr2 == 0xFFFF` у всех узлов;
- сэмплирование всегда через fallback key (`hdr3`).
### 10.2. Узлы без map
Это нормальный режим для статических/квазистатических узлов:
- `hdr2 = 0xFFFF`;
- `hdr3` указывает на единственный ключ узла (канонично).
### 10.3. `Res1.attr3 == 24` (legacy outlier)
В corpus встречается единично (`MTCHECK.MSH`, `testdata/nres/system.rlb`):
- `Res1.attr3 = 24`;
- `Res8` содержит 1 ключ;
- `Res19.size == 0`.
Алгоритм `sub_10012880` адресует node как stride 38, поэтому этот случай нельзя интерпретировать правилами текущего 38-byte формата. Практически это отдельный legacy-формат/legacy-path вне описанного runtime-контракта.
### 10.4. Квантование quaternion при экспорте
Для новых данных:
- используйте `round(q * 32767)`;
- clamp к `[-32767, 32767]` (каноничный диапазон ассетов).
---
## 11. Reference pseudocode (1:1 runtime path)
```c
void sample_node_pose(Model *m, int node_idx, float t, float out_quat[4], float out_pos[3]) {
Node38 *node = (Node38 *)((uint8_t *)m->res1 + node_idx * 38);
uint16_t map_start = node->hdr2;
uint16_t fallback = node->hdr3;
uint32_t frame_cnt = m->anim_frame_count; // Res19.attr2
int32_t frame = x87_fistp_i32((double)t - 0.5); // strict path
uint16_t key_idx;
if ((uint32_t)frame >= frame_cnt ||
map_start == 0xFFFF ||
m->res19[map_start + (uint32_t)frame] >= fallback) {
key_idx = fallback;
decode_key_quat_pos(&m->res8[key_idx], out_quat, out_pos);
return;
}
key_idx = m->res19[map_start + (uint32_t)frame];
AnimKey24 *k0 = &m->res8[key_idx];
if (t == k0->time) {
decode_key_quat_pos(k0, out_quat, out_pos);
return;
}
AnimKey24 *k1 = &m->res8[key_idx + 1];
if (t == k1->time) {
decode_key_quat_pos(k1, out_quat, out_pos);
return;
}
float a = (t - k0->time) / (k1->time - k0->time);
out_pos[0] = lerp(k0->pos_x, k1->pos_x, a);
out_pos[1] = lerp(k0->pos_y, k1->pos_y, a);
out_pos[2] = lerp(k0->pos_z, k1->pos_z, a);
fastproc_quat_interp(decode_quat(k0), decode_quat(k1), a, out_quat); // g_FastProc[17]
}
```
## 12. Границы полноты
Для основного формата (`Res1` stride 38 + `Res8` + `Res19`) эта страница покрывает runtime и toolchain-поведение на уровне, достаточном для 1:1 реализации (reader/writer/converter/editor).
Единственный подтверждённый неполный сегмент:
- legacy `Res1.attr3 == 24` (`MTCHECK.MSH`), для которого в `AniMesh` не найден отдельный открытый decode-path в рамках текущего реверса.
Для абсолютных 100% по всем историческим вариантам формата дополнительно нужно:
- найти и дореверсить runtime-код, который реально обрабатывает `Res1.attr3==24` (если он есть в других модулях/ветках);
- получить больше образцов `*.msh` с `attr3==24` для проверки writer/validator-инвариантов.

View File

@@ -1,678 +1,193 @@
# MSH core # MSH core
Документ фиксирует core-часть формата MSH на уровне, достаточном для: `MSH core` описывает геометрию, слоты, батчи и базовые таблицы модели.
Документ покрывает контракт, необходимый для 1:1 воспроизведения рендера и коллизии.
- реализации runtime-совместимого движка (поведение 1:1); Связанные страницы:
- реализации reader/writer/editor/converter с lossless round-trip;
- валидации ассетов и диагностики повреждений.
Связанные документы: - [MSH animation](msh-animation.md)
- [Material](material.md)
- [Texture (Texm)](texture.md)
- [Render pipeline](render.md)
- [NRes](nres.md)
- [RsLi](rsli.md)
- [NRes / RsLi](nres.md) — контейнер, каталог, атрибуты, выравнивание. ## 1. Общая модель
- [MSH animation](msh-animation.md) — детальная спецификация `Res8`/`Res19`.
- [Materials + Texm](materials-texm.md) — материальная часть и текстуры.
- [Terrain + map loading](terrain-map-loading.md) — отдельная ветка terrain-ресурсов.
--- MSH-модель хранится как `NRes`-контейнер.
Связь таблиц строится по `type`, а не по порядку записей.
## 1. Область и источники Базовый путь геометрии:
### 1.1. Что покрывает этот документ 1. `Res1` выбирает slot по `(node, lod, group)`.
2. `Res2.slot` задаёт диапазоны треугольников и батчей.
3. `Res13` задаёт диапазон индексов и `baseVertex`.
4. `Res6` даёт `uint16` индексы.
5. `Res3/Res4/Res5` дают вершины, нормали и UV.
Этот документ покрывает именно **core-геометрию и её runtime-связи**: ## 2. Карта core-ресурсов
- `Res1` (node table), | Type | Ресурс | Обязательность | Stride / layout |
- `Res2` (header + slots),
- `Res3/4/5` (позиции/нормали/UV0),
- `Res6` (индексы),
- `Res7` (triangle descriptors),
- `Res10` (node string table),
- `Res13` (batch table),
- optional `Res15/16/18/20`,
- точки стыка с анимацией (`Res8/Res19`).
### 1.2. Что не покрывает
- детальную семантику материалов/текстурных фаз (см. `materials-texm.md`),
- terrain-ветку (`type 11/14/21` и связанные структуры, см. `terrain-map-loading.md`),
- полную математику анимационного сэмплирования (см. `msh-animation.md`).
### 1.3. Источники реверса
Основные подтверждения:
- `tmp/disassembler1/AniMesh.dll.c`:
- `sub_10015FD0` (загрузка ресурсов core-модели),
- `sub_100124D0` (поиск slot по node/lod/group),
- `sub_10012530` (доступ к строке узла в `Res10`),
- `sub_1000B2C0`/`sub_10013680` (tri/batch path),
- `sub_1000A460` (инициализация runtime-инстансов, копирование глобальных bounds).
- `tmp/disassembler2/AniMesh.dll.asm` — подтверждение смещений/stride/ветвлений.
- валидация corpus: `testdata/nres` (435 MSH моделей, нулевые ошибки в `tools/msh_doc_validator.py`).
---
## 2. Модель данных MSH (high-level)
MSH-модель — это NRes-контейнер, где ресурсы связаны **не по порядку, а по type-id**.
Базовая связь таблиц:
1. `Res1` для `(node, lod, group)` выбирает `slotIndex`.
2. `Res2.slot[slotIndex]` даёт диапазоны triangle/batch (`triStart/triCount`, `batchStart/batchCount`).
3. `Res13.batch` даёт `indexStart/indexCount/baseVertex`.
4. `Res6` даёт сырые `uint16` индексы.
5. `Res3/4/5` дают vertex-атрибуты по `baseVertex + index`.
Ключевая особенность runtime:
- скиннинг по узлам жёсткий (rigid attachment), без per-vertex bone weights в core-ресурсах.
---
## 3. Карта ресурсов и границы core
### 3.1. Ресурсы, которые читает core-loader (`sub_10015FD0`)
| Type | Ресурс | Статус в core-loader | Формат/stride |
|---:|---|---|---| |---:|---|---|---|
| 1 | Node table | required | 38 байт/узел (основной случай) | | 1 | Node table | обязательный | обычно 38 байт |
| 2 | Model header + slots | required | `0x8C + slotCount*0x44` | | 2 | Header + slots | обязательный | `0x8C + n*68` |
| 3 | Positions | required | 12 | | 3 | Positions | обязательный | 12 |
| 4 | Packed normals | обычно required | 4 | | 4 | Packed normals | обычно обязательный | 4 |
| 5 | Packed UV0 | обычно required | 4 | | 5 | Packed UV0 | обычно обязательный | 4 |
| 6 | Index buffer | required | 2 | | 6 | Index buffer | обязательный | 2 |
| 7 | Triangle descriptors | обычно required | 16 | | 7 | Tri descriptors | для коллизии/пикинга | 16 |
| 8 | Anim key pool | optional для статических | 24 | | 8 | Anim key pool | для анимированных | 24 |
| 10 | String table | обычно required | variable | | 10 | Node strings | опциональный | variable |
| 13 | Batch table | required | 20 | | 13 | Batch table | обязательный | 20 |
| 15 | Доп. stream | optional | 8 | | 15 | Доп. stream | опциональный | 8 |
| 16 | Tangent/bitangent stream | optional | 8 | | 16 | Доп. stream | опциональный | 8 |
| 18 | Vertex color stream | optional | 4 | | 18 | Доп. stream | опциональный | 4 |
| 19 | Anim mapping | optional для статических | 2 | | 19 | Anim map | для анимированных | 2 |
| 20 | Доп. таблица | optional | variable | | 20 | Доп. таблица | опциональный | variable |
### 3.2. Ресурсы, которые встречаются в MSH, но вне этого документа ## 3. Основные структуры
В corpus из 435 моделей стабильно встречаются также `type 9` и `type 17`. ### 3.1. `Res1` (узлы)
Они **не загружаются** `sub_10015FD0` и относятся к некоревым подсистемам (материалы/эффекты/прочие runtime-ветки).
### 3.3. Прямая MSH и вложенная MSH
Tooling должен поддерживать два режима входа:
- файл уже является модельным NRes (`magic NRes` и содержит `type 1/2/3/6/13`),
- файл-архив содержит `.msh` entry, внутри которой вложенный NRes модели.
---
## 4. Runtime-контракт загрузки (`sub_10015FD0`)
`sub_10015FD0` заполняет структуру модели размером `0xA4` байт и строит derived pointers/stride.
### 4.1. Порядок `find/open`
Фактический порядок загрузки:
1. `type 1 -> this+0x00`
2. `type 2 -> this+0x04`
3. `type 3 -> this+0x0C`
4. `type 4 -> this+0x10`
5. `type 5 -> this+0x14`
6. `type 10 -> this+0x20`
7. `type 8 -> this+0x18`
8. `type 19 -> this+0x1C`
9. `type 7 -> this+0x24`
10. `type 13 -> this+0x28`
11. `type 6 -> this+0x2C`
12. `type 15 -> this+0x34`
13. `type 16 -> this+0x38`
14. `type 18 -> this+0x64` (через отдельный `find`, optional)
15. `type 20 -> this+0x30` (optional)
### 4.2. Derived-поля (стримы)
После загрузки ставятся derived-поля:
- `this+0x08 = Res2 + 0x8C` (начало slot table),
- `this+0x3C = Res3`, `this+0x40 = 12`,
- `this+0x44 = Res4`, `this+0x48 = 4`,
- `this+0x5C = Res5`, `this+0x60 = 4`,
- `this+0x8C = Res15`, `this+0x90 = 8`,
- `this+0x94 = 0` (инициализация нулём).
Для `Res16`:
- если есть: `this+0x4C = Res16`, `this+0x50 = 8`, `this+0x54 = Res16+4`, `this+0x58 = 8`;
- если нет: `this+0x4C = 0`, `this+0x54 = 0` (stride остаются несущественными, т.к. указатели нулевые).
Для `Res18`:
- если найден: `this+0x64 = ptr`, `this+0x68 = 4`;
- иначе: `this+0x64 = 0`, `this+0x68 = 0`.
### 4.3. Метаданные из каталога NRes
- `this+0x9C` получает `entry(type19).attr2` (читается из поля `+8` каталожной записи, индекс `entry * 64`).
- `this+0xA0` получает `entry(type20).attr1` (поле `+4`) только если `type20` существует и успешно открыт; иначе `0`.
---
## 5. Бинарные структуры core-ресурсов
Все структуры little-endian.
### 5.1. `Res1` — Node table
Базовый stride: `38` байт (`19 * uint16`).
```c ```c
struct Node38 { struct Node38 {
uint16_t hdr0; // +0 uint16_t hdr0;
uint16_t hdr1; // +2 uint16_t parent_or_link;
uint16_t hdr2; // +4 uint16_t anim_map_start;
uint16_t hdr3; // +6 uint16_t fallback_key;
uint16_t slotIndex[15]; // +8: [lod0 g0..g4][lod1 g0..g4][lod2 g0..g4] uint16_t slotIndex[15]; // lod0:g0..g4, lod1:g0..g4, lod2:g0..g4
}; };
``` ```
#### Подтверждённые поля Формула slot-выбора:
- `hdr1`: parent/index-link (используется при построении инстанса), `0xFFFF` = нет.
- `hdr2`: `mapStart` для `Res19` (см. `msh-animation.md`), `0xFFFF` = нет map.
- `hdr3`: fallback key index в `Res8`.
- `hdr0`: node flags (есть битовые проверки, но полная доменная семантика не закрыта).
#### Адресация slot (runtime-функция `sub_100124D0`)
```c ```c
uint16_t get_slot_index(const Node38* node_table, uint32_t nodeIndex, int lod, int group, int current_lod) { slot = node.slotIndex[lod * 5 + group]
int use_lod = (lod == -1) ? current_lod : lod;
int word_index = 4 + (int)nodeIndex * 19 + use_lod * 5 + group;
return *(uint16_t*)((const uint8_t*)node_table + word_index * 2);
}
``` ```
`0xFFFF` означает "слот отсутствует". `0xFFFF` означает отсутствие слота.
#### Вариант stride=24 ### 3.2. `Res2` (header + slot records)
В corpus есть единичный служебный outlier с `Res1.attr3 = 24`.
Для 1:1 editing существующих ассетов требуется copy-through этого варианта.
Новая генерация должна ориентироваться на stride `38`, если нет чёткой цели поддержать legacy-вариант.
---
### 5.2. `Res2` — Model header + Slot table
```
Res2:
[0x00 .. 0x8B] model header (140 bytes)
[0x8C .. end] slot records (68 bytes each)
```
#### 5.2.1. Header (0x8C)
Runtime копирует блоки как float-массивы:
- `0x00..0x5F` (`24 float`) — глобальный hull (`vec3[8]`),
- `0x60..0x6F` (`4 float`) — глобальная sphere (`center.xyz + radius`),
- `0x70..0x8B` (`7 float`) — сегмент/капсула (`A.xyz`, `B.xyz`, `radius`).
#### 5.2.2. Slot record (68 bytes)
```c ```c
struct Slot68 { struct Slot68 {
uint16_t triStart; // +0 uint16_t triStart;
uint16_t triCount; // +2 uint16_t triCount;
uint16_t batchStart; // +4 uint16_t batchStart;
uint16_t batchCount; // +6 uint16_t batchCount;
float aabbMin[3];
float aabbMin[3]; // +8 float aabbMax[3];
float aabbMax[3]; // +20 float sphereCenter[3];
float sphereCenter[3]; // +32 float sphereRadius;
float sphereRadius; // +44 uint32_t opaque[5];
uint32_t unk30; // +48
uint32_t unk34; // +52
uint32_t unk38; // +56
uint32_t unk3C; // +60
uint32_t unk40; // +64
}; };
``` ```
`triCount` подтверждён как длина диапазона: `opaque[5]` должны сохраняться 1:1.
```c ### 3.3. `Res3`, `Res4`, `Res5`, `Res6`
triId >= triStart && triId < triStart + triCount
```
Хвост `unk30..unk40` должен сохраняться без изменений в editor/writer. - `Res3`: `float3` позиции (`stride=12`)
- `Res4`: `int8[4]` packed normal (`stride=4`)
#### 5.2.3. Bounds semantics - `Res5`: `int16[2]` UV (`stride=4`)
- `Res6`: `uint16` индексы (`stride=2`)
- Slot bounds локальны относительно узла.
- При world-трансформации sphere radius масштабируется по `max(scaleX, scaleY, scaleZ)` при неравномерном scale.
---
### 5.3. `Res3` — Positions
```c
struct Position12 {
float x;
float y;
float z;
};
```
Stride `12`.
---
### 5.4. `Res4` — Packed normals
```c
struct PackedNormal4 {
int8_t nx;
int8_t ny;
int8_t nz;
int8_t nw; // семантика 4-го байта не зафиксирована
};
```
Декодирование: Декодирование:
```c - normal = `clamp(n / 127.0, -1..1)`
normal = clamp((float)n / 127.0f, -1.0f, 1.0f) - uv = `packed / 1024.0`
```
- делитель строго `127.0`; ### 3.4. `Res7` и `Res13`
- clamp обязателен из-за `-128 / 127.0`.
Кодирование (writer):
```c
int8_t q = (int8_t)clamp(round(v * 127.0f), -128, 127);
```
---
### 5.5. `Res5` — Packed UV0
```c
struct PackedUV4 {
int16_t u;
int16_t v;
};
```
Декодирование:
```c
uv = packed / 1024.0f
```
Кодирование:
```c
int16_t q = (int16_t)clamp(round(uv * 1024.0f), -32768, 32767);
```
---
### 5.6. `Res6` — Index buffer
Массив `uint16`, stride `2`.
Runtime-путь:
```c
vertexIndex = Res6[indexStart + i] + batch.baseVertex;
```
`indexStart` хранится в элементах, не в байтах.
---
### 5.7. `Res7` — Triangle descriptors (16 bytes)
```c ```c
struct TriDesc16 { struct TriDesc16 {
uint16_t triFlags; // +0 uint16_t triFlags;
uint16_t linkTri0; // +2 uint16_t link0;
uint16_t linkTri1; // +4 uint16_t link1;
uint16_t linkTri2; // +6 uint16_t link2;
int16_t nX; // +8 int16_t nx;
int16_t nY; // +10 int16_t ny;
int16_t nZ; // +12 int16_t nz;
uint16_t selPacked; // +14 uint16_t selPacked;
}; };
```
- `nX/nY/nZ` декодируются через `1/32767`.
- `linkTri*` используются в tri-neighbour/collision path.
Раскладка `selPacked` (3 селектора по 2 бита):
```c
sel0 = (selPacked >> 0) & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;
```
---
### 5.8. `Res13` — Batch table (20 bytes)
```c
struct Batch20 { struct Batch20 {
uint16_t batchFlags; // +0 uint16_t batchFlags;
uint16_t materialIndex; // +2 uint16_t materialIndex;
uint16_t unk4; // +4 uint16_t opaque4;
uint16_t unk6; // +6 uint16_t opaque6;
uint16_t indexCount; // +8 uint16_t indexCount;
uint32_t indexStart; // +10 uint32_t indexStart;
uint16_t unk14; // +14 uint16_t opaque14;
uint32_t baseVertex; // +16 uint32_t baseVertex;
}; };
``` ```
`unk4/unk6/unk14` семантически не закрыты; writer/editor должны сохранять. `selPacked` хранит 3 селектора по 2 бита; значение `3` трактуется как `0xFFFF`.
--- ## 4. Runtime-обход модели
### 5.9. `Res10` — Node string table Псевдокод рендера:
Последовательность записей variable-length:
```c
struct Res10Record {
uint32_t len; // длина строки без '\0'
char text[]; // если len>0: len+1 байт (с '\0'), иначе payload нет
};
```
Переход:
```c
next = cur + 4 + (len ? len + 1 : 0);
```
`sub_10012530` возвращает:
- `NULL`, если `len == 0`,
- `record + 4`, если `len > 0`.
Индекс записи в `Res10` соответствует `nodeIndex`.
---
### 5.10. Optional streams
#### `Res15` (stride 8)
Дополнительный поток на вершину (семантика не полностью подтверждена).
#### `Res16` (stride 8, split 2x4)
Runtime делит поток на два interleaved подпотока:
- stream A: `base+0`, stride 8,
- stream B: `base+4`, stride 8.
В corpus из `testdata/nres` этот ресурс не встретился, но loader поддерживает.
#### `Res18` (stride 4)
Vertex color / доп. packed-канал. В corpus встречается на части моделей.
#### `Res20`
Доп. таблица неизвестной доменной семантики. Loader хранит pointer и метаданные каталога (`attr1`).
---
### 5.11. Точки стыка с анимацией (`Res8`/`Res19`)
Core-loader загружает:
- `Res8` в `this+0x18`,
- `Res19` в `this+0x1C`,
- `Res19.attr2` в `this+0x9C`.
Полный runtime-алгоритм сэмплирования/смешивания описан в [MSH animation](msh-animation.md).
---
## 6. Runtime-алгоритмы core
### 6.1. Slot lookup (`sub_100124D0`)
Вход: runtime-node-instance, `group`, `lod`.
1. Если нет model pointer -> `NULL`.
2. `lod == -1` -> подставить `current_lod` инстанса.
3. Вычислить `slotIndex` через формулу `4 + node*19 + lod*5 + group`.
4. Если `slotIndex == 0xFFFF` -> `NULL`.
5. Иначе вернуть `Res2.slotBase + slotIndex * 68`.
### 6.2. Node string lookup (`sub_10012530`)
1. Идти по `Res10`-записям `nodeIndex` раз.
2. Возвращать `NULL` или `char*` по правилу `len==0`.
### 6.3. Геометрический обход для рендера
Reference-путь, эквивалентный runtime-логике:
```c ```c
for each node: for each node:
slot = resolve_slot(node, lod, group) slot = resolve_slot(node, lod, group)
if (!slot) continue if slot == none: continue
for b in [slot.batchStart .. slot.batchStart + slot.batchCount): if culled(slot.bounds, node_transform): continue
batch = Res13[b]
for i in [0 .. batch.indexCount):
idx = Res6[batch.indexStart + i]
vtx = batch.baseVertex + idx
pos = Res3[vtx] for b in slot.batchRange:
nrm = decode_res4(Res4[vtx]) batch = batches[b]
uv0 = decode_res5(Res5[vtx]) bind_material(batch.materialIndex)
draw_indexed(
baseVertex = batch.baseVertex,
indexStart = batch.indexStart,
indexCount = batch.indexCount
)
``` ```
### 6.4. Tri/collision path (обобщённо) ## 5. Критические инварианты
- `sub_1000B2C0` и `sub_10013680` используют tri-диапазоны слота + `Res7` link/select-поля. Обязательно проверять:
- Для collision/picking-контекста должны быть валидны:
- `slot.triStart + slot.triCount <= triDescCount`,
- `linkTri*` либо `0xFFFF`, либо `< triDescCount`.
--- - `Res2.size >= 0x8C`
- `(Res2.size - 0x8C) % 68 == 0`
- `batchStart + batchCount` не выходит за `Res13`
- `triStart + triCount` не выходит за `Res7`
- `indexStart + indexCount` не выходит за `Res6`
- `baseVertex + max(indexSlice) < vertexCount`
- `slotIndex == 0xFFFF` или `< slotCount`
## 7. Инварианты и валидация (reader) ## 6. Важные edge-cases
### 7.1. Базовые проверки целостности - Встречается редкий вариант `Res1.attr3 = 24`; для существующих ассетов нужен copy-through.
- Для строгого writer лучше генерировать `Res1` в основном формате `38` байт/узел.
- Неизвестные поля таблиц нельзя нормализовать или обнулять.
- каждый fixed-stride ресурс делится на stride без остатка; ## 7. Правила для writer/editor
- `Res2.size >= 0x8C`;
- `(Res2.size - 0x8C) % 68 == 0`;
- `Res2.attr1 == slotCount`, `Res2.attr3 == 68`;
- `Res3.attr3 == 12`, `Res4.attr3 == 4`, `Res5.attr3 == 4`, `Res6.attr3 == 2`, `Res7.attr3 == 16`, `Res13.attr3 == 20`;
- `Res8.attr3 == 4` (не stride), `Res19.attr3 == 2`, `Res10.attr3 == 0` (в observed assets).
### 7.2. Cross-table проверки 1. Сохранять неизвестные поля и неизвестные `type`-ресурсы.
2. Пересчитывать только явно вычислимые атрибуты (`attr1/attr3` и size-зависимые поля).
3. Не менять порядок/контент opaque-данных без явной цели.
4. Сериализовать little-endian, без внутреннего padding.
- `slot.batchStart + slot.batchCount <= batchCount`; ## 8. Статус валидации
- `slot.triStart + slot.triCount <= triDescCount`;
- `batch.indexStart + batch.indexCount <= indexCount`;
- `batch.baseVertex + max(indexSlice) < vertexCount`;
- все `Res1.slotIndex[*]` либо `0xFFFF`, либо `< slotCount`;
- для `Res10`: парсинг ровно `nodeCount` записей без хвостовых байт;
- для `Res7.linkTri*`: либо `0xFFFF`, либо `< triDescCount`.
### 7.3. Strict vs tolerant режим - Инварианты формата реализованы в `tools/msh_doc_validator.py`.
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `435/435` MSH-моделей без структурных ошибок.
Рекомендуется 2 режима reader: ## 9. Статус покрытия и что осталось до 100%
- `strict`: любое нарушение инвариантов -> ошибка; Закрыто:
- `tolerant`: безопасно отбрасывать/игнорировать только локально повреждённые диапазоны (без OOB).
--- 1. Базовые таблицы geometry path (`Res1/2/3/4/5/6/7/13`).
2. Критичные range-инварианты slot/batch/index.
3. Правила совместимого writer/editor для lossless работы с существующими ассетами.
## 8. Правила writer/editor Осталось:
### 8.1. Обязательная политика для 1:1 editing 1. Полная семантика части opaque-полей (`Slot68` tail, `Batch20` opaque-поля) для authoring без copy-through.
2. Полная формализация редких веток (`Res1.attr3 != 38`) на расширенном корпусе.
- сохранять неизвестные поля (`Slot68.unk*`, `Batch20.unk*`, `Node.hdr0` и т.д.) без модификации, если нет осознанного пересчёта; 3. End-to-end writer для генерации новых игровых MSH с подтвержденным runtime-паритетом.
- сохранять неизвестные resource types и их payload/атрибуты;
- не полагаться на порядок ресурсов в контейнере: lookup в runtime идёт по type-id.
### 8.2. Пересчёт атрибутов каталога
При записи изменённых ресурсов:
- `attr1` = count (или форматно-специфичное значение),
- `attr2` — по формату/семантике ресурса,
- `attr3` — stride/константа формата.
Практические правила для core:
- `Res1`: `attr1=nodeCount`, `attr3=38` (или исходный вариант, если copy-through legacy), `attr2` лучше сохранять из исходника;
- `Res2`: `attr1=slotCount`, `attr2=0`, `attr3=68`;
- `Res3/4/5/6/7/13/15/16/18`: `attr1=size/stride`, `attr2=0`, `attr3=stride`;
- `Res8`: `attr1=size/24`, `attr3=4`;
- `Res10`: `attr1=nodeCount`, `attr2=0`, `attr3=0`;
- `Res19`: `attr1=size/2`, `attr2=frameCount`, `attr3=2`.
### 8.3. Матрица зависимостей при редактировании
| Операция | Какие ресурсы обновлять |
|---|---|
| Смещение/деформация вершин | `Res3`, при необходимости `Res4`, bounds в `Res2` |
| Изменение UV | `Res5` (и опционально `Res15`) |
| Изменение topology (индексы/треугольники) | `Res6`, `Res13`, `Res7`, диапазоны `Res2.slot` |
| Изменение LOD/group назначения | `Res1.slotIndex`, возможно `Res2.slot` |
| Изменение имени узла | `Res10` |
| Изменение иерархии/анимации узлов | `Res1.hdr1/hdr2/hdr3`, `Res8`, `Res19` |
| Добавление/удаление slot | `Res2`, ссылки из `Res1`, диапазоны batch/tri |
### 8.4. Детерминированная сериализация
- little-endian для всех чисел;
- без внутреннего padding в таблицах ресурсов;
- выравнивание блоков ресурсов в NRes по 8 байт (через контейнер).
---
## 9. Рекомендованный canonical IR для toolchain
Минимальный IR для безопасного round-trip:
```c
struct ModelCoreIR {
// raw payloads for unknown/passthrough types
map<uint32_t, RawResource> raw_passthrough;
vector<Node> nodes; // Res1 decoded (hdr + matrix)
Header140 header; // Res2[0x00..0x8B]
vector<Slot> slots; // Res2 slot table (включая unk tail)
vector<float3> positions; // Res3
vector<PackedNormal4> normals_raw; // Res4 raw + optional decoded cache
vector<PackedUV4> uv0_raw; // Res5 raw + optional decoded cache
vector<uint16_t> indices; // Res6
vector<TriDesc16> tri; // Res7
vector<Batch20> batches; // Res13
vector<optional<string>> node_names; // Res10
optional<vector<uint8_t>> res15_raw;
optional<vector<uint8_t>> res16_raw;
optional<vector<uint32_t>> colors_raw; // Res18
optional<RawResource> res20_raw;
// animation bridge
optional<vector<AnimKey24>> anim_keys; // Res8
optional<vector<uint16_t>> anim_map_words; // Res19
uint32_t anim_frame_count;
};
```
Принцип: где семантика неполная, хранить raw и переизлучать байт-в-байт.
---
## 10. Практика конвертации
### 10.1. MSH -> OBJ/GLTF
- `Res3` напрямую в позиции;
- `Res6 + Res13` в faces;
- нормали/UV декодировать через коэффициенты `1/127`, `1/1024`;
- при экспорте по LOD/group использовать `Res1` матрицу слотов, а не "все batch подряд" (если нужен runtime-эквивалент);
- пометить ограничения: core не содержит классический weight-скиннинг.
### 10.2. Обратный импорт (OBJ/GLTF -> MSH)
Для 1:1 ожидаемого поведения импортёр должен:
- строить корректные `Res13` диапазоны,
- строить/обновлять `Res2.slot` ranges и bounds,
- поддерживать quantization при упаковке (`Res4/Res5`),
- сохранять unknown-поля таблиц, если вход был редактированием существующей модели.
---
## 11. Наблюдения по corpus (testdata/nres)
Сводка по 435 MSH-моделям:
- валидны все 435/435 по `tools/msh_doc_validator.py`;
- основной порядок типов:
- `414`: `(1,2,3,4,5,15,13,6,7,8,19,9,10,17)`
- `21`: `(1,2,3,4,5,18,15,13,6,7,8,19,9,10,17,20)`
- `Res1.attr3`: `38` в 434 моделях, `24` в 1 модели;
- `Res18` и `Res20` встречаются в 21 модели;
- `Res16` в данном corpus не встретился;
- `Res8/Res19` присутствуют во всех моделях, но `Res19.attr2=1` часто соответствует статике.
---
## 12. Открытые вопросы (не блокируют 1:1)
- точная доменная семантика `Node.hdr0` битов;
- полные имена/назначения `Batch20.unk4/unk6/unk14`;
- назначение `Slot68.unk30..unk40`;
- полная семантика `Res15/Res16/Res18/Res20` payload beyond stride-level;
- точная семантика 4-го байта в `PackedNormal4`.
Для runtime/reader/writer это не критично при условии byte-preserving policy.
---
## 13. Чеклист реализации 1:1
### 13.1. Engine runtime
- реализован loader-порядок как в `sub_10015FD0`;
- slot lookup по формуле `4 + node*19 + lod*5 + group`;
- декодирование `Res4` через `/127.0` с clamp;
- декодирование `Res5` через `/1024.0`;
- tri селекторы `selPacked` трактуются как 2-битные с `3 -> 0xFFFF`;
- корректная обработка `0xFFFF` sentinel во всех таблицах.
### 13.2. Reader/validator
- строгая проверка stride/размеров/диапазонов;
- OOB-защита всех индексных доступов;
- поддержка both direct-model и nested `.msh` payload.
### 13.3. Writer/editor
- стабильный пересчёт `attr1/attr2/attr3`;
- сохранение unknown fields и unknown resource types;
- детерминированная сериализация NRes (8-byte align);
- regression-проверка round-trip: `decode -> encode -> decode` без расхождений структуры/диапазонов.

View File

@@ -1,277 +1,118 @@
# 3D implementation notes # 3D implementation notes
Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам. Контрольная страница с практическими правилами реализации 3D-пайплайна и с перечнем незакрытых зон.
Документ intentionally high-level: без ссылок на внутренние функции/адреса.
--- Связанные страницы:
## 5.1. Порядок байт - [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)
Все значения хранятся в **littleendian** порядке (платформа x86/Win32). ## 1. Базовые двоичные правила
## 5.2. Выравнивание 1. Все форматы в этой подсистеме little-endian.
2. Внутри NRes данные ресурсов выравниваются по 8 байт.
3. Внутри payload таблиц padding между записями обычно отсутствует: записи идут подряд по stride.
- **NResресурсы:** данные каждого ресурса внутри NResархива выровнены по границе **8 байт** (0padding). ## 2. Быстрая карта stride'ов
- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд.
- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга.
## 5.3. Размеры записей на диске | Ресурс | Запись | 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 |
| Ресурс | Запись | Размер (байт) | Stride | ## 3. Декодирование ключевых потоков
|--------|-----------|---------------|-------------------------|
| Res1 | Node | 38 | 38 (19×u16) |
| Res2 | Slot | 68 | 68 |
| Res3 | Position | 12 | 12 (3×f32) |
| Res4 | Normal | 4 | 4 (4×s8) |
| Res5 | UV0 | 4 | 4 (2×s16) |
| Res6 | Index | 2 | 2 (u16) |
| Res7 | TriDesc | 16 | 16 |
| Res8 | AnimKey | 24 | 24 |
| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` |
| Res13 | Batch | 20 | 20 |
| Res19 | AnimMap | 2 | 2 (u16) |
| Res15 | VtxStr | 8 | 8 |
| Res16 | VtxStr | 8 | 8 (2×4) |
| Res18 | VtxStr | 4 | 4 |
## 5.4. Вычисление количества элементов ## 3.1. Позиции (Res3)
Количество записей вычисляется из размера ресурса: `float3`, stride `12`.
``` ## 3.2. Нормали (Res4)
count = resource_data_size / record_stride
`int8[4]`, используются первые 3 компоненты:
```text
n = clamp(s8 / 127.0, -1..1)
``` ```
Например: ## 3.3. UV (Res5)
- `vertex_count = res3_size / 12` `int16[2]`:
- `index_count = res6_size / 2`
- `batch_count = res13_size / 20`
- `slot_count = (res2_size - 140) / 68`
- `node_count = res1_size / 38`
- `tri_desc_count = res7_size / 16`
- `anim_key_count = res8_size / 24`
- `anim_map_count = res19_size / 2`
Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт. ```text
u = s16 / 1024.0
## 5.5. Идентификация ресурсов в NRes v = s16 / 1024.0
Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика).
## 5.6. Минимальный набор для рендера
Для статической модели без анимации достаточно:
| Ресурс | Обязательность |
|--------|------------------------------------------------|
| Res1 | Да |
| Res2 | Да |
| Res3 | Да |
| Res4 | Рекомендуется |
| Res5 | Рекомендуется |
| Res6 | Да |
| Res7 | Для коллизии |
| Res13 | Да |
| Res10 | Желательно (узловые имена/поведенческие ветки) |
| Res8 | Нет (анимация) |
| Res19 | Нет (анимация) |
| Res15 | Нет |
| Res16 | Нет |
| Res18 | Нет |
| Res20 | Нет |
## 5.7. Сводка алгоритмов декодирования
### Позиции (Res3)
```python
def decode_position(data, vertex_index):
offset = vertex_index * 12
x = struct.unpack_from('<f', data, offset)[0]
y = struct.unpack_from('<f', data, offset + 4)[0]
z = struct.unpack_from('<f', data, offset + 8)[0]
return (x, y, z)
``` ```
### Нормали (Res4) ## 3.4. Animation key (Res8)
```python `pos(float3) + time(float) + quat(int16x4)`:
def decode_normal(data, vertex_index):
offset = vertex_index * 4 ```text
nx = struct.unpack_from('<b', data, offset)[0] # int8 q = s16 / 32767.0
ny = struct.unpack_from('<b', data, offset + 1)[0]
nz = struct.unpack_from('<b', data, offset + 2)[0]
# nw = data[offset + 3] # не используется
return (
max(-1.0, min(1.0, nx / 127.0)),
max(-1.0, min(1.0, ny / 127.0)),
max(-1.0, min(1.0, nz / 127.0)),
)
``` ```
### UVкоординаты (Res5) ## 4. Практический reader-контракт
```python Для runtime-совместимого чтения модели:
def decode_uv(data, vertex_index):
offset = vertex_index * 4
u = struct.unpack_from('<h', data, offset)[0] # int16
v = struct.unpack_from('<h', data, offset + 2)[0]
return (u / 1024.0, v / 1024.0)
```
### Кодирование нормали (для экспортёра) 1. Найти нужные ресурсы по `type_id` в NRes.
2. Проверить `size/stride`-инварианты.
3. Проверить диапазоны ссылок:
- slot -> batch/triangles;
- batch -> indices;
- indices -> vertices;
- anim_map -> anim_keys.
4. Неизвестные поля и неизвестные ресурсы сохранять через copy-through.
```python ## 5. Практический writer-контракт
def encode_normal(nx, ny, nz):
return (
max(-128, min(127, int(round(nx * 127.0)))),
max(-128, min(127, int(round(ny * 127.0)))),
max(-128, min(127, int(round(nz * 127.0)))),
0 # nw = 0 (безопасное значение)
)
```
### Кодирование UV (для экспортёра) 1. Пересчитывать только явно вычислимые поля.
2. Не нормализовать opaque-данные без уверенной спецификации.
3. При roundtrip неизмененных данных требовать byte-identical результат.
4. Для новых ассетов фиксировать отдельную политику «генерация vs preserve».
```python ## 6. Runtime-связка материалов и текстур
def encode_uv(u, v):
return (
max(-32768, min(32767, int(round(u * 1024.0)))),
max(-32768, min(32767, int(round(v * 1024.0))))
)
```
### Строки узлов (Res10) Канонический путь резолва:
```python 1. Модель -> wear-таблица (`*.wea`).
def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]: 2. Wear-слот -> material name.
out = [] 3. Material -> текущая фаза -> `textureName`.
off = 0 4. `Texm` ищется в `Textures.lib` (или lightmap-библиотеке для lightmap-ветки).
for _ in range(node_count):
ln = struct.unpack_from('<I', buf, off)[0]
off += 4
if ln == 0:
out.append(None)
continue
raw = buf[off:off + ln + 1] # len + '\0'
out.append(raw[:-1].decode('ascii', errors='replace'))
off += ln + 1
return out
```
### Ключ анимации (Res8) и mapping (Res19) Fallback:
```python - материал: `DEFAULT`, затем индекс `0`;
def decode_anim_key24(buf: bytes, idx: int): - текстура/lightmap: fallback-слот движка.
o = idx * 24
px, py, pz, t = struct.unpack_from('<4f', buf, o)
qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16)
s = 1.0 / 32767.0
return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s)
```
### Эффектный поток (FXID) ## 7. Что уже закрыто для 1:1
```python 1. Бинарный контракт базовых MSH таблиц.
FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208} 2. Контракт animation sampling (`Res8 + Res19`).
3. Контракт MAT0/WEAR/Texm на уровне чтения и применения в кадре.
4. Формат FXID-контейнера, командный поток и fixed command sizes.
5. Валидация на retail-корпусе через `tools/msh_doc_validator.py` (0 ошибок/предупреждений).
def parse_fx_payload(raw: bytes): ## 8. Статус покрытия и что осталось до 100%
cmd_count = struct.unpack_from('<I', raw, 0)[0]
ptr = 0x3C
cmds = []
for _ in range(cmd_count):
w = struct.unpack_from('<I', raw, ptr)[0]
op = w & 0xFF
enabled = (w >> 8) & 1
size = FX_CMD_SIZE[op]
cmds.append((op, enabled, ptr, size))
ptr += size
if ptr != len(raw):
raise ValueError('tail bytes after command stream')
return cmds
```
### Texm (header + mips + Page) 1. Полная field-level семантика части служебных полей:
- `Batch20` opaque-поля;
```python - хвостовые служебные поля slot-записей;
def parse_texm(raw: bytes): - часть флагов узлов/групп.
magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0) 2. Полный writer-путь для авторинга новых анимированных ассетов (не только roundtrip существующих).
assert magic == 0x6D786554 # 'Texm' 3. Полная формализация семантики FX payload полей по каждому opcode для генерации новых эффектов, а не только для корректного чтения/исполнения.
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4) 4. Полный канонический writer `Texm` для всех редких форматов и edge-case комбинаций служебных флагов.
pix_sum = 0 5. Сквозной «импорт внешнего ассета -> игровой пакет» с формальной спецификацией sidecar-метаданных (материал/эффект/анимация).
mw, mh = w, h
for _ in range(mips):
pix_sum += mw * mh
mw = max(1, mw >> 1)
mh = max(1, mh >> 1)
off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum
page = None
if off + 8 <= len(raw) and raw[off:off+4] == b'Page':
n = struct.unpack_from('<I', raw, off + 4)[0]
page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)]
return (w, h, mips, fmt, f4, f5, unk6, page)
```
---
# Часть 6. Остаточные семантические вопросы
Пункты ниже **не блокируют 1:1-парсинг/рендер/интерполяцию** (все бинарные структуры уже определены), но их человеко‑читаемая трактовка может быть уточнена дополнительно.
## 6.1. Batch table — смысл `unk4/unk6/unk14`
Физическое расположение полей известно, но доменное имя/назначение не зафиксировано:
- `unk4` (`+0x04`)
- `unk6` (`+0x06`)
- `unk14` (`+0x0E`)
## 6.2. Node flags и имена групп
- Биты в `Res1.hdr0` используются в ряде рантайм‑веток, но их «геймдизайн‑имена» неизвестны.
- Для groupиндекса `0..4` не найдено текстовых label'ов в ресурсах; для совместимости нужно сохранять числовой индекс как есть.
## 6.3. Slot tail `unk30..unk40`
Хвост слота (`+0x30..+0x43`, `5×uint32`) стабильно присутствует в формате, но движок не делает явной семантической декомпозиции этих пяти слов в path'ах загрузки/рендера/коллизии.
## 6.4. Effect command payload semantics
Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode.
## 6.5. Поля `TexmHeader.flags4/flags5/unk6`
Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации.
## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`)
Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1поведения при импорте внешней геометрии обратно в формат игры.
### A) Неполная «авторская» семантика бинарных таблиц
1. `Res2` header (`первые 0x8C`): не зафиксированы все поля и правила их вычисления при генерации нового файла (а не copy-through из оригинала).
2. `Res7` tri-descriptor: для 16байтной записи декодирован базовый каркас, но остаётся неформализованной часть служебных бит/полей, нужных для стабильной генерации adjacency/служебной топологии.
3. `Res13` поля `unk4/unk6/unk14`: для парсинга достаточно, но для генерации «канонических» значений из голого `OBJ` правила не определены.
4. `Res2` slot tail (`unk30..unk40`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения.
### B) Анимационный path ещё не закрыт как writer
1. Нужен полный writer для `Res8/Res19`:
- точная спецификация байтового формата на запись;
- правила генерации mapping (`Res19`) по узлам/кадрам;
- жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра).
2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtimeсущностям пока описаны частично и требуют формализации именно для импорта новых данных.
### C) Материалы, текстуры, эффекты для «полного ассета»
1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей).
2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну.
3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный.
### D) Что это означает на практике
1. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры).
2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C.
3. До закрытия пунктов A/B/C рекомендуется использовать режим:
- геометрия экспортируется из `OBJ`;
- неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры.

View File

@@ -6,17 +6,34 @@
1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица. 1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица.
2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция. 2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция.
3. [Materials + Texm](materials-texm.md) — материалы, текстуры, палитры, `WEAR`, `LIGHTMAPS`, `Texm`. 3. [Material (`MAT0`)](material.md) — формат материала и фазовая анимация.
4. [FXID](fxid.md) — контейнер эффекта и команды runtime-потока. 4. [Wear (`WEAR`)](wear.md) — текстовая таблица привязки материалов/lightmap.
5. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру. 5. [Texture (`Texm`)](texture.md) — форматы текстур, mip-chain и `Page`.
6. [Runtime pipeline](runtime-pipeline.md) — межмодульное поведение движка в кадре. 6. [FXID](fxid.md) — контейнер эффекта и поток команд.
7. [3D implementation notes](msh-notes.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) — сводка покрытия и оставшиеся блокеры.
## Связанные спецификации ## Связанные спецификации
- [NRes / RsLi](nres.md) - [NRes](nres.md)
- [RsLi](rsli.md)
## Принцип декомпозиции ## Принцип декомпозиции
- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо. - Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько DLL и не является форматом на диске. - Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько runtime-подсистем и не является форматом на диске.
## Статус покрытия и что осталось до 100%
Закрыто:
1. Документация декомпозирована по объектам: geometry, animation, material, texture, wear, fx, render, terrain.
2. Форматные инварианты ключевых 3D-ресурсов проверяются автоматическими валидаторами на retail-корпусе.
Осталось:
1. Полный сквозной writer-путь для генерации новых игровых ассетов без copy-through зависимостей.
2. Полный паритетный рендер-тест (эталонные кадры оригинала vs новый рендер) на расширенном наборе моделей/материалов/FX.
3. Полное покрытие соседних геймплейных подсистем (`AI`, `Behavior`, `Missions`, `Control`, `UI`, `Sound`, `Network`) до уровня точных форматов и runtime-контрактов.

View File

@@ -1,5 +1,28 @@
# Network system # Network system
Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P). `Network` — подсистема синхронизации состояния игры между узлами (мультиплеер/обмен состоянием).
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`. ## 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-тестов на контролируемой потере/задержке.

View File

@@ -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 режимов сортировки (011): 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 КБ): Итоговый файл должен удовлетворять всем ограничениям из разделов 35.
``` ## 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 (буфер декомпрессии может быть больше)
```
#### Методы сжатия (биты 85, маска 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) // Диапазон: 04095
length = (high & 0x0F) + 3 // Диапазон: 318
```
### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
#### Параметры
| Параметр | Значение |
| -------------------------------- | ------------------------------ |
| Размер кольцевого буфера | 4096 байт |
| Начальная позиция записи | **4036** (0xFC4) |
| Начальное заполнение | 0x20 (пробел) |
| Количество листовых узлов дерева | 314 |
| Символы литералов | 0255 (байты) |
| Символы длин | 256313 (длина = символ 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 кодов
- 0143: 8-битные коды
- 144255: 9-битные коды
- 256279: 7-битные коды
- 280287: 8-битные коды
- Дистанции: 30 кодов, все 5-битные
Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
#### Блок типа 2 (динамические коды)
1. Прочитать 5 бит → `HLIT` (количество литералов/длин 257). Диапазон: 257286.
2. Прочитать 5 бит → `HDIST` (количество дистанций 1). Диапазон: 130.
3. Прочитать 4 бита → `HCLEN` (количество кодов длин 4). Диапазон: 419.
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` на последней записи файла.

View 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-паритета.

View 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
View 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
View 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-диапазон).

View File

@@ -1,123 +1,18 @@
# Runtime pipeline # Runtime pipeline
Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов. Актуальный документ по полному кадру находится здесь:
--- - [Render pipeline](render.md)
## 1.15. Алгоритм рендера модели (реконструкция) Эта страница оставлена как совместимый указатель для старых ссылок.
``` ## Статус покрытия и что осталось до 100%
Вход: model, instanceTransform, cameraFrustum
1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам). Закрыто:
2. Для каждого node (nodeIndex = 0 .. nodeCount1): 1. Актуальный runtime-пайплайн централизован в `render.md`.
a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform
b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0] Осталось:
если slotIndex == 0xFFFF → пропустить узел
c. slot = slotTable[slotIndex] 1. Поддерживать обратную совместимость ссылок при дальнейшей декомпозиции render-документа.
d. // Frustum culling:
transformedAABB = transform(slot.aabb, nodeTransform)
если transformedAABB вне cameraFrustum → пропустить
// Альтернативно по сфере:
transformedCenter = nodeTransform × slot.sphereCenter
scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ)
если сфера вне frustum → пропустить
e. Для i = 0 .. slot.batchCount 1:
batch = batchTable[slot.batchStart + i]
// Фильтрация по batchFlags (если нужна)
// Установить материал:
setMaterial(batch.materialIndex)
// Установить transform:
setWorldMatrix(nodeTransform)
// Нарисовать:
DrawIndexedPrimitive(
baseVertex = batch.baseVertex,
indexStart = batch.indexStart,
indexCount = batch.indexCount,
primitiveType = TRIANGLE_LIST
)
```
---
## 1.16. Алгоритм обхода треугольников (коллизия / пикинг)
```
Вход: model, nodeIndex, lod, group, filterMask, callback
1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group]
если slotIndex == 0xFFFF → выход
2. slot = slotTable[slotIndex]
triDescIndex = slot.triStart
3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount 1]:
batch = batchTable[batchIndex]
triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3
Для t = 0 .. triCount 1:
triDesc = triDescTable[triDescIndex]
// Фильтрация:
если (triDesc.triFlags & filterMask) → пропустить
// Получить индексы вершин:
idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex
idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex
idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex
// Получить позиции:
p0 = positions[idx0]
p1 = positions[idx1]
p2 = positions[idx2]
callback(triDesc, idx0, idx1, idx2, p0, p1, p2)
triDescIndex += 1
```
---
---
## 3.1. Архитектурный обзор
Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`.
### Экспорты Effect.dll
| Функция | Описание |
|----------------------|--------------------------------------------------------|
| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) |
| `InitializeSettings` | Инициализировать настройки эффектов |
`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами.
### Телеметрия из Terrain.dll
Terrain.dll содержит отладочную статистику рендера:
```
"Rendered meshes : %d"
"Rendered primitives : %d"
"Rendered faces : %d"
"Rendered particles/batches : %d/%d"
```
Из этого следует:
- Частицы рендерятся **батчами** (группами).
- Статистика частиц отделена от статистики мешей.
- Частицы интегрированы в общий 3Dрендерпайплайн.

View File

@@ -1,5 +1,32 @@
# Sound system # Sound system
Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API. `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-тестов (тайминг/громкость/панорама).

View File

@@ -1,170 +1,111 @@
# Terrain + map loading # Terrain + ArealMap
Документ описывает полный runtime-пайплайн загрузки ландшафта и карты (`Terrain.dll` + `ArealMap.dll`) и требования к toolchain для 1:1 совместимости (чтение, конвертация, редактирование, обратная сборка). Документ описывает подсистему ландшафта и ареалов мира в движке Parkan: Iron Strategy:
Источник реверса: - `Land.msh` (terrain-геометрия и вспомогательные таблицы);
- `Land.map` (ареалы и навигационные связи);
- `BuildDat.lst` (категории объектных зон).
- `tmp/disassembler1/Terrain.dll.c` Описание дано в высокоуровневом переносимом виде, без ссылок на внутренние адреса и имена из дизассемблера.
- `tmp/disassembler1/ArealMap.dll.c`
- `tmp/disassembler2/Terrain.dll.asm`
- `tmp/disassembler2/ArealMap.dll.asm`
Связанные спецификации: Связанные страницы:
- [NRes / RsLi](nres.md) - [NRes](nres.md)
- [RsLi](rsli.md)
- [MSH core](msh-core.md) - [MSH core](msh-core.md)
- [ArealMap](arealmap.md) - [Render pipeline](render.md)
--- ## 1. End-to-End загрузка уровня
## 1. Назначение подсистем Для каждой карты движок загружает пару файлов:
### 1.1. `Terrain.dll` - `.../Land.msh`
- `.../Land.map`
Отвечает за: Высокоуровневый порядок:
- загрузку и хранение terrain-геометрии из `*.msh` (NRes); 1. Открыть `Land.msh` как `NRes`.
- фильтрацию и выборку треугольников для коллизий/трассировки/рендера; 2. Прочитать обязательные terrain-chunk'и.
- рендер terrain-примитивов и связанного shading; 3. Построить runtime-структуры terrain (slots, faces, spatial grid).
- использование микро-текстурного канала (chunk type 18). 4. Открыть `Land.map` как `NRes`.
5. Найти единственный chunk `type=12`.
6. Прочитать ареалы, их связи и cell-grid.
7. Применить инициализацию объектных категорий из `BuildDat.lst`.
Характерные runtime-строки: ## 2. Формат `Land.msh`
- `CLandscape::CLandscape()` `Land.msh` — обычный `NRes` архив с фиксированным набором terrain-ресурсов.
- `Unable to find microtexture mapping chunk`
- `Rendering empty primitive!`
- `Rendering empty primitive2!`
### 1.2. `ArealMap.dll` ## 2.1. Состав chunk'ов
Отвечает за: Обязательные типы:
- загрузку геометрии ареалов из `*.map` (NRes, chunk type 12); - `1`, `2`, `3`, `4`, `5`, `11`, `18`, `21`
- построение связей "ареал <-> соседи/подграфы";
- grid-ускорение по ячейкам карты;
- runtime-доступ к `ISystemArealMap` (интерфейс id `770`) и ареалам (id `771`).
Характерные runtime-строки: Опциональные типы:
- `SystemArealMap panic: Cannot load ArealMapGeometry` - `14`
- `SystemArealMap panic: Cannot find chunk in resource`
- `SystemArealMap panic: ArealMap Cells are empty`
- `SystemArealMap panic: Incorrect ArealMap`
--- Наблюдаемый retail-порядок chunk'ов:
## 2. End-to-End загрузка уровня ```text
[1, 2, 3, 4, 5, 18, 14, 11, 21]
### 2.1. Имена файлов уровня
В `CLandscape::CLandscape()` базовое имя уровня `levelBase` разворачивается в:
- `levelBase + ".msh"`: terrain-геометрия;
- `levelBase + ".map"`: геометрия ареалов/навигация;
- `levelBase + "1.wea"` и `levelBase + "2.wea"`: weather/материалы.
### 2.2. Порядок инициализации (высокоуровнево)
1. Получение `3DRender` и `3DSound`.
2. Загрузка `MatManager` (`*.wea`), `LightManager`, `CollManager`, `FxManager`.
3. Создание `SystemArealMap` через `CreateSystemArealMap(..., "<level>.map", ...)`.
4. Открытие terrain-библиотеки `niOpenResFile("<level>.msh")`.
5. Загрузка terrain-chunk-ов (см. §3).
6. Построение runtime-границ, grid-ускорителей и рабочих массивов.
Критичные ошибки на любом шаге приводят к `ngiProcessError`/panic.
---
## 3. Формат terrain `*.msh` (NRes)
### 3.1. Используемые chunk type в `Terrain.dll`
Порядок загрузки в `CLandscape::CLandscape()`:
| Порядок | Type | Обяз. | Использование (подтверждено кодом) |
|---|---:|---|---|
| 1 | 3 | да | поток позиций (`stride = 12`) |
| 2 | 4 | да | поток packed normal (`stride = 4`) |
| 3 | 5 | да | UV-поток (`stride = 4`) |
| 4 | 18 | да | microtexture mapping (`stride = 4`) |
| 5 | 14 | нет | опциональный доп. поток (`stride = 4`, отсутствует на части карт) |
| 6 | 21 | да | таблица terrain-face (по 28 байт) |
| 7 | 2 | да | header + slot-таблицы (используются диапазоны face) |
| 8 | 1 | да | node/grid-таблица (stride 38) |
| 9 | 11 | да | доп. индекс/ускоритель для запросов (cell->list) |
Ключевые проверки:
- отсутствие type `18` вызывает `Unable to find microtexture mapping chunk`;
- отсутствие остальных обязательных чанков вызывает `Unable to open file`.
### 3.2. Node/slot структура для terrain
Terrain-код использует те же stride и адресацию, что и core-описание:
- node-запись: `38` байт;
- slot-запись: `68` байт;
- доступ к первому slot-index: `node + 8`;
- tri-диапазон в slot: `slot + 140` (offset 0 внутри slot), `slot + 142` (offset 2).
Это согласуется с [MSH core](msh-core.md) для `Res1/Res2`:
- `Res1`: `uint16[19]` на node;
- `Res2`: header + slot table (`0x8C + N * 0x44`).
### 3.3. Terrain face record (type 21, 28 bytes)
Подтвержденные поля из runtime-декодирования face:
```c
struct TerrainFace28 {
uint32_t flags; // +0
uint8_t materialId; // +4 (читается как byte)
uint8_t auxByte; // +5
uint16_t unk06; // +6
uint16_t i0; // +8 (индекс вершины)
uint16_t i1; // +10
uint16_t i2; // +12
uint16_t n0; // +14 (сосед, 0xFFFF -> нет)
uint16_t n1; // +16
uint16_t n2; // +18
int16_t nx; // +20 packed normal component
int16_t ny; // +22
int16_t nz; // +24
uint8_t edgeClass; // +26 (три 2-бит значения)
uint8_t unk27; // +27
};
``` ```
`edgeClass` декодируется как: ## 2.2. Stride и атрибуты
- `edge0 = byte26 & 0x3` | Type | Назначение | Stride |
- `edge1 = (byte26 >> 2) & 0x3` |---:|---|---:|
- `edge2 = (byte26 >> 4) & 0x3` | 1 | node/slot матрица | 38 |
| 3 | позиции вершин | 12 |
| 4 | нормали (packed) | 4 |
| 5 | UV (packed) | 4 |
| 11 | cell-ускоритель | 4 |
| 14 | доп. поток | 4 |
| 18 | доп. поток | 4 |
| 21 | terrain face | 28 |
### 3.4. Маски флагов face Общее правило для этих chunk'ов:
Во многих запросах применяется фильтр: - `attr1 == size / stride`
- `attr3 == stride`
```c ## 2.3. Type `2`: slot table
(faceFlags & requiredMask) == requiredMask &&
(faceFlags | ~forbiddenMask) == ~forbiddenMask
```
Эквивалентно: "все required-биты выставлены, forbidden-биты отсутствуют". `type=2` содержит:
Подтверждено активное использование битов: - заголовок `0x8C` байт;
- затем таблицу slots по `68` байт.
- `0x8` (особая обработка в трассировке) Инварианты:
- `0x2000`
- `0x20000`
- `0x100000`
- `0x200000`
Кроме "полной" 32-бит маски, runtime использует компактные маски в API-запросах. - `size >= 0x8C`
- `(size - 0x8C) % 68 == 0`
- `attr1 == (size - 0x8C) / 68`
- `attr3 == 68`
Подтверждённый remap `full -> compactMain16` (функции `sub_10013FC0`, `sub_1004BA00`, `sub_1004BB40`): ## 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 | | Full bit | Compact bit |
|---:|---:| |---:|---:|
@@ -184,7 +125,7 @@ struct TerrainFace28 {
| `0x00000040` | `0x2000` | | `0x00000040` | `0x2000` |
| `0x00200000` | `0x8000` | | `0x00200000` | `0x8000` |
Подтверждённый remap `full -> compactMaterial6` (функции `sub_10014090`, `sub_10015540`, `sub_1004BB40`): Подтвержденный remap `full -> compactMaterial6`:
| Full bit | Compact bit | | Full bit | Compact bit |
|---:|---:| |---:|---:|
@@ -195,180 +136,99 @@ struct TerrainFace28 {
| `0x00080000` | `0x10` | | `0x00080000` | `0x10` |
| `0x00000080` | `0x20` | | `0x00000080` | `0x20` |
Подтверждённый remap `compact -> full` (функция `sub_10015680`): Для 1:1 реализации нужно поддерживать оба представления и обратное восстановление `compact -> full`.
- `a2[4]`/`a2[5]` (compactMain16 required/forbidden) + `a2[6]`/`a2[7]` (compactMaterial6 required/forbidden) ## 2.6. Type `11` и cell-ускоритель terrain
- разворачиваются в `fullRequired/fullForbidden` в `this[4]/this[5]`.
Для toolchain это означает: `type=11` служит источником cell-ускорителя для terrain-запросов.
- если редактируется только бинарник `type 21`, достаточно сохранять `flags` как есть; Практические требования для editor/toolchain:
- если реализуется API-совместимый runtime-слой, нужно поддерживать оба представления (`full` и `compact`) и точный remap выше.
### 3.5. Grid-ускоритель terrain-запросов - не переупорядочивать содержимое без полного пересчета зависимых таблиц;
- сохранять служебные/неизвестные поля побайтно;
- выполнять валидацию диапазонов face/slot после любых правок.
Runtime строит grid descriptor с параметрами: ## 3. Формат `Land.map` (chunk `type=12`)
- origin (`baseX/baseY`); `Land.map``NRes`, содержащий ровно один ресурс `type=12`.
- масштабные коэффициенты (`invSizeX/invSizeY`);
- размеры сетки (`cellsX`, `cellsY`).
Дальше запросы: Контракт верхнего уровня:
1. переводят world AABB в диапазон grid-ячеек (`floor(...)`); - `entry.attr1` = `areal_count`;
2. берут диапазон face через `Res1/Res2` (slot `triStart/triCount`); - payload включает:
3. дополняют кандидаты из cell-списков (chunk type 11); - `areal_count` переменных записей ареалов;
4. применяют маски флагов; - затем grid-секцию cell-попаданий.
5. выполняют геометрию (plane/intersection/point-in-triangle).
### 3.6. Cell-списки по ячейкам (`type 11` и runtime-массивы) ## 3.1. Запись ареала
В `CLandscape` после инициализации используются три параллельных массива по ячейкам (`cellsX * cellsY`): Старт записи:
- `this+31588` (`sub_100164B0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `8`-байтовых элементов;
- `this+31592` (`sub_100164E0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `4`-байтовых элементов;
- `this+31596` (`sub_1001F880` ctor): массив записей по `12` байт для runtime-объектов/агентов (буфер `4`-байтовых идентификаторов/указателей).
Общий header записи списка:
```c ```c
struct CellListHdr { float anchor_x; // +0
void* ptr; // +0 float anchor_y; // +4
int count; // +4 float anchor_z; // +8
int capacity; // +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
``` ```
Подтвержденные element-layout: Далее:
- `this+31588`: элемент `8` байт (`uint32_t id`, `uint32_t aux`), добавление через `sub_10012E20` пишет `aux = 0`; 1. `float3 vertices[vertex_count]`
- `this+31592`: элемент `4` байта (`uint32_t id`); 2. `EdgeLink8 links[vertex_count + 3 * poly_count]`, где
- `this+31596`: элемент `4` байта (runtime object handle/pointer id). `EdgeLink8 = { int32 area_ref; int32 edge_ref; }`
3. для каждого полигона block:
- `uint32 n`
- `4 * (3*n + 1)` байт данных полигона
Практический вывод для редактора: ## 3.2. Семантика edge-link
- `type 11` должен считаться источником cell-ускорителя; Для `links[0 .. vertex_count-1]`:
- неизвестные/дополнительные поля внутри списков должны сохраняться как есть;
- нельзя "нормализовать" или переупорядочивать списки без полного пересчёта всех зависимых runtime-структур.
--- - `(-1, -1)` означает «соседа нет»;
- иначе `area_ref` указывает на индекс соседнего ареала, `edge_ref` — на ребро в соседнем ареале.
## 4. Формат `*.map` (ArealMapGeometry, chunk type 12) ## 3.3. Grid-секция после ареалов
### 4.1. Точка входа Формат:
`CreateSystemArealMap(..., "<level>.map", ...)` вызывает `sub_1001E0D0`:
1. `niOpenResFile("<level>.map")`;
2. поиск chunk type `12`;
3. чтение chunk-данных;
4. разбор `ArealMapGeometry`.
При ошибках выдаются panic-строки `SystemArealMap panic: ...`.
### 4.2. Верхний уровень chunk 12
Используются:
- `entry.attr1` (из каталога NRes) как `areal_count`;
- `entry[+0x0C]` как размер payload chunk для контроля полного разбора.
Данные chunk:
1. `areal_count` переменных записей ареалов;
2. секция grid-ячеек (`cellsX/cellsY` + списки попаданий).
### 4.3. Переменная запись ареала
Полностью подтверждённые элементы layout:
```c ```c
// record = начало записи ареала uint32 cellsX;
float anchor_x = *(float*)(record + 0); uint32 cellsY;
float anchor_y = *(float*)(record + 4); for (x=0; x<cellsX; x++) {
float anchor_z = *(float*)(record + 8); for (y=0; y<cellsY; y++) {
float reserved_12 = *(float*)(record + 12); // в retail-данных всегда 0 uint16 hitCount;
float area_metric = *(float*)(record + 16); // предрасчитанная площадь ареала uint16 areaIds[hitCount];
float normal_x = *(float*)(record + 20);
float normal_y = *(float*)(record + 24);
float normal_z = *(float*)(record + 28); // unit vector (|n| ~= 1)
uint32_t logic_flag = *(uint32_t*)(record + 32); // активно используется в runtime
uint32_t reserved_36 = *(uint32_t*)(record + 36); // в retail-данных всегда 0
uint32_t class_id = *(uint32_t*)(record + 40); // runtime-class/type id ареала
uint32_t reserved_44 = *(uint32_t*)(record + 44); // в retail-данных всегда 0
uint32_t vertex_count = *(uint32_t*)(record + 48);
uint32_t poly_count = *(uint32_t*)(record + 52);
float* vertices = (float*)(record + 56); // float3[vertex_count]
// сразу после vertices:
// EdgeLink8[vertex_count + 3*poly_count]
// где EdgeLink8 = { int32_t area_ref; int32_t edge_ref; }
// первые vertex_count записей используются как per-edge соседство границы ареала.
EdgeLink8* links = (EdgeLink8*)(record + 56 + 12 * vertex_count);
uint8_t* p = (uint8_t*)(links + (vertex_count + 3 * poly_count));
for (i=0; i<poly_count; i++) {
uint32_t n = *(uint32_t*)p;
p += 4 * (3*n + 1);
}
// p -> начало следующей записи ареала
```
То есть для toolchain:
- поля `+0/+4/+8`, `+16`, `+20..+28`, `+32`, `+40`, `+48`, `+52` являются runtime-значимыми;
- для `links[0..vertex_count-1]` подтверждена интерпретация как `(area_ref, edge_ref)`:
- `area_ref == -1 && edge_ref == -1` = нет соседа;
- иначе `area_ref` указывает на индекс ареала, `edge_ref` — на индекс ребра в целевом ареале;
- при редактировании безопасно работать через parser+writer этой формулы;
- неизвестные байты внутри записи должны сохраняться без изменений.
Дополнительно по runtime-поведению:
- `anchor_x/anchor_y` валидируются на попадание внутрь полигона; при промахе движок делает случайный re-seed позиции (см. §4.5);
- `logic_flag` по смещению `+32` используется как gating-условие в логике `SystemArealMap`.
### 4.4. Секция grid-ячеек в chunk 12
После массива ареалов идёт:
```c
uint32_t cellsX;
uint32_t cellsY;
for (x in 0..cellsX-1) {
for (y in 0..cellsY-1) {
uint16_t hitCount;
uint16_t areaIds[hitCount];
} }
} }
``` ```
Runtime упаковывает метаданные ячейки в `uint32`: В runtime существует упакованное cell-meta представление:
- high 10 bits: `hitCount` (`value >> 22`); - high 10 бит: `hitCount`;
- low 22 bits: `startIndex` (1-based индекс в общем `uint16`-пуле areaIds). - low 22 бита: `startIndex` (в общем `areaIds` пуле).
Контроль целостности: ## 3.4. Валидация целостности chunk 12
- после разбора `ptr_end - chunk_begin` должен строго совпасть с `entry[+0x0C]`; Обязательные проверки:
- иначе `SystemArealMap panic: Incorrect ArealMap`.
### 4.5. Нормализация геометрии при загрузке - `areal_count > 0`;
- `cellsX > 0 && cellsY > 0`;
- каждый `area_id` из cell-списков `< areal_count`;
- все `area_ref/edge_ref` валидны относительно целевых ареалов;
- полный объем прочитанных байт должен точно совпасть с размером payload.
Если опорная точка ареала не попадает внутрь его полигона: ## 4. `BuildDat.lst`
- до 100 попыток случайного сдвига в радиусе ~30; Используются 12 объектных категорий ареалов:
- затем до 200 попыток в радиусе ~100.
Это runtime-correction; для 1:1-офлайн инструментов лучше генерировать валидные данные, чтобы не зависеть от недетерминизма `rand()`.
---
## 5. `BuildDat.lst` и объектные категории ареалов
`ArealMap.dll` инициализирует 12 категорий и читает `BuildDat.lst`.
Хардкод-категории (имя -> mask):
| Имя | Маска | | Имя | Маска |
|---|---:| |---|---:|
@@ -385,127 +245,49 @@ Runtime упаковывает метаданные ячейки в `uint32`:
| `Tower_Medium` | `0x80100000` | | `Tower_Medium` | `0x80100000` |
| `Tower_Large` | `0x80200000` | | `Tower_Large` | `0x80200000` |
Файл `BuildDat.lst` парсится секционно; при сбое формата используется panic `BuildDat.lst is corrupted`. Файл должен парситься строго секционно; поврежденный формат считается ошибкой.
--- ## 5. Требования к reader/writer/editor
## 6. Требования к toolchain (конвертер/ридер/редактор) 1. Сохранять порядок и бинарную форму chunk'ов, если не выполняется осознанная нормализация.
2. Все неизвестные поля хранить и писать побайтно (`preserve-as-is`).
3. После правок пересчитывать только вычислимые поля, не «чистить» opaque-данные.
4. Проверять диапазоны индексов между связанными таблицами (`nodes/slots/faces/vertices/areas/cells`).
5. Для неизмененных ресурсов обеспечивать byte-identical roundtrip.
### 6.1. Общие принципы 1:1 ## 6. Эмпирическая верификация (retail)
1. Никаких "переупорядочиваний по вкусу": сохранять порядок chunk-ов, если не требуется явная нормализация. Валидация на `testdata/Parkan - Iron Strategy`:
2. Все неизвестные поля сохранять побайтно.
3. При roundtrip обеспечивать byte-identical для неизмененных сущностей.
4. Валидации должны повторять runtime-ожидания (размеры, count-формулы, обязательность chunk-ов).
### 6.2. Для terrain `*.msh` - карт: `33`
- `Land.msh`: `33/33` валидны
- `Land.map`: `33/33` валидны
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`
Обязательные проверки: Подтвержденные наблюдения:
- наличие chunk types `1,2,3,4,5,11,18,21`; - `Land.msh` порядок chunk'ов стабилен: `[1,2,3,4,5,18,14,11,21]`;
- type `14` опционален; - `Land.map` всегда содержит один chunk `type=12`;
- для `type 2`: `size >= 0x8C`, `(size - 0x8C) % 68 == 0`, `attr1 == (size - 0x8C) / 68`; - `cellsX == cellsY == 128` во всех retail-картах;
- `type21_size % 28 == 0`; - `poly_count == 0` во всем проверенном retail-корпусе;
- индексы `i0/i1/i2` в `TerrainFace28` не выходят за `vertex_count` (type 3); - `normal` имеет длину ~1.0;
- `slot.triStart + slot.triCount` не выходит за `face_count`. - `reserved_12`, `reserved_36`, `reserved_44` в retail наблюдаются как `0`.
Сериализация: Инструмент:
- `flags`, соседи, `edgeClass`, material байты в `TerrainFace28` сохранять как есть;
- содержимое `type 11`-derived cell-списков (`id`, `aux`) сохранять без "починки";
- для packed normal не делать "улучшений" нормализации, если цель 1:1.
### 6.3. Для `*.map` (chunk 12)
Обязательные проверки:
- chunk type `12` существует;
- `areal_count > 0`;
- `cellsX > 0 && cellsY > 0`;
- `|normal_x,normal_y,normal_z| ~= 1` для каждого ареала;
- `links[0..vertex_count-1]` валидны (`-1/-1` или корректные `(area_ref, edge_ref)`);
- полный consumed-bytes строго равен `entry[+0x0C]`.
При редактировании:
- перестраивать только то, что действительно изменено;
- пересчитывать cell-списки и packed `cellMeta` синхронно;
- сохранять неизвестные части записи ареала без изменений.
### 6.4. Рекомендуемая архитектура редактора
1. `Parser`:
- NRes-слой;
- `TerrainMsh`-слой;
- `ArealMapChunk12`-слой.
2. `Model`:
- явные известные поля;
- `raw_unknown` для непросаженных блоков.
3. `Writer`:
- стабильная сериализация;
- проверка контрольных инвариантов перед записью.
4. `Verifier`:
- roundtrip hash/byte-compare;
- runtime-совместимые asserts.
---
## 7. Практический чеклист "движок 1:1"
Для runtime-совместимого движка нужно реализовать:
1. NRes API-уровень (`niOpenResFile`, `niOpenResInMem`, поиск chunk по type, получение data/attrs).
2. `CLandscape` пайплайн загрузки `*.msh` + менеджеров + `CreateSystemArealMap`.
3. Terrain face decode (28-byte запись), mask-фильтр, spatial grid queries.
4. Загрузчик `ArealMapGeometry` (chunk 12) с той же валидацией и packed-cell логикой.
5. Пост-обработку ареалов (пересвязка, корректировки опорных точек).
6. Поддержку `BuildDat.lst` для объектных категорий/схем.
---
## 8. Нерасшифрованные зоны (важно для редакторов)
Ниже поля, которые пока нельзя безопасно "пересобирать по смыслу":
- семантика `class_id` (`record + 40`) на уровне геймдизайна/скриптов (числовое поле подтверждено, но человекочитаемая таблица соответствий не восстановлена полностью);
- ветки формата для `poly_count > 0` (в retail `tmp/gamedata` это всегда `0`, поэтому поведение этих веток подтверждено только по коду, без живых образцов);
- человекочитаемая семантика части битов `TerrainFace28.flags` (при этом remap и бинарные значения подтверждены);
- семантика поля `aux` во `8`-байтовом элементе cell-списка (`this+31588`, второй `uint32_t`), которое в известных runtime-путях инициализируется нулем.
Правило до полного реверса: `preserve-as-is`.
---
## 9. Эмпирическая верификация (retail `tmp/gamedata`)
Для массовой проверки спецификации добавлен валидатор:
- `tools/terrain_map_doc_validator.py` - `tools/terrain_map_doc_validator.py`
Запуск: ## 7. Статус покрытия и что осталось до 100%
```bash Закрыто:
python3 tools/terrain_map_doc_validator.py \
--maps-root tmp/gamedata/DATA/MAPS \
--report-json tmp/terrain_map_doc_validator.report.json
```
Проверенные инварианты (на 33 картах, 2026-02-12): - бинарный контракт `Land.msh` и `Land.map`;
- диапазонные и структурные инварианты;
- remap масок `full/compact`;
- валидация на полном retail-корпусе карт.
- `Land.msh`: Осталось до полного 100% архитектурного покрытия движка:
- порядок chunk-ов всегда `[1,2,3,4,5,18,14,11,21]`;
- `type11` первые dword всегда `[5767168, 4718593]`;
- `type21` индексы вершин/соседей валидны;
- `type2` slot-таблица валидна по формуле `0x8C + 68*N`.
- `Land.map`:
- всегда один chunk `type 12`;
- `cellsX == cellsY == 128` на всех картах;
- `poly_count == 0` для всех `34662` записей ареалов в retail-наборе;
- `record+12`, `record+36`, `record+44` всегда `0`;
- `area_metric` (`record+16`) стабильно коррелирует с площадью XY-полигона (макс. абсолютное отклонение `51.39`, макс. относительное `14.73%`, `18` кейсов > `5%`);
- `normal` в `record+20..28` всегда unit (диапазон длины `0.9999998758..1.0000001194`);
- link-таблицы `EdgeLink8` проходят строгую валидацию ссылочной целостности.
Сводный результат текущего набора данных: 1. Полная доменная семантика `class_id` и `logic_flag` (игровые значения/поведенческие правила).
2. Полная спецификация ветки `poly_count > 0` на живых данных (в retail не встречена).
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`. 3. Полная field-level семантика части битов `TerrainFace28.flags` (бинарный контракт и remap закрыты, но не все биты имеют документированные геймплейные имена).

153
docs/specs/texture.md Normal file
View 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 веток.

View File

@@ -1,5 +1,33 @@
# UI system # UI system
Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе. `UI` — подсистема интерфейса:
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга 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
View 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».

View File

@@ -29,13 +29,19 @@ nav:
- Behavior system: specs/behavior.md - Behavior system: specs/behavior.md
- Control system: specs/control.md - Control system: specs/control.md
- FXID: specs/fxid.md - FXID: specs/fxid.md
- Materials + Texm: specs/materials-texm.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 - Missions: specs/missions.md
- Object registry (objects.rlb): specs/object-registry.md
- MSH animation: specs/msh-animation.md - MSH animation: specs/msh-animation.md
- MSH core: specs/msh-core.md - MSH core: specs/msh-core.md
- Network system: specs/network.md - Network system: specs/network.md
- NRes / RsLi: specs/nres.md - NRes / RsLi: specs/nres.md
- Runtime pipeline: specs/runtime-pipeline.md - Render pipeline: specs/render.md
- Render parity: specs/render-parity.md
- Runtime pointer: specs/runtime-pipeline.md
- Sound system: specs/sound.md - Sound system: specs/sound.md
- Terrain + map loading: specs/terrain-map-loading.md - Terrain + map loading: specs/terrain-map-loading.md
- UI system: specs/ui.md - UI system: specs/ui.md

20
parity/README.md Normal file
View 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
View 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

View File