Compare commits

14 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
64 changed files with 5977 additions and 1809 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

@@ -48,7 +48,7 @@ jobs:
--keep-going --keep-going
- name: Upload parity artifacts - name: Upload parity artifacts
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: render-parity-artifacts name: render-parity-artifacts
path: target/render-parity/current path: target/render-parity/current

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

@@ -4,4 +4,9 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
encoding_rs = "0.8"
nres = { path = "../nres" } nres = { path = "../nres" }
[dev-dependencies]
common = { path = "../common" }
proptest = "1"

View File

@@ -1,6 +1,7 @@
use core::fmt; use core::fmt;
#[derive(Debug)] #[derive(Debug)]
#[non_exhaustive]
pub enum Error { pub enum Error {
Nres(nres::error::Error), Nres(nres::error::Error),
MissingResource { MissingResource {

View File

@@ -1,6 +1,7 @@
pub mod error; pub mod error;
use crate::error::Error; use crate::error::Error;
use encoding_rs::WINDOWS_1251;
use std::sync::Arc; use std::sync::Arc;
pub type Result<T> = core::result::Result<T, Error>; pub type Result<T> = core::result::Result<T, Error>;
@@ -164,6 +165,8 @@ pub fn parse_model_payload(payload: &[u8]) -> Result<Model> {
let positions = parse_positions(&res3.bytes)?; let positions = parse_positions(&res3.bytes)?;
let indices = parse_u16_array(&res6.bytes, "Res6")?; let indices = parse_u16_array(&res6.bytes, "Res6")?;
let batches = parse_batches(&res13.bytes)?; let batches = parse_batches(&res13.bytes)?;
validate_slot_batch_ranges(&slots, batches.len())?;
validate_batch_index_ranges(&batches, indices.len())?;
let normals = match res4 { let normals = match res4 {
Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?), Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?),
@@ -192,6 +195,40 @@ pub fn parse_model_payload(payload: &[u8]) -> Result<Model> {
}) })
} }
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]>> { fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>> {
if !data.len().is_multiple_of(12) { if !data.len().is_multiple_of(12) {
return Err(Error::InvalidResourceSize { return Err(Error::InvalidResourceSize {
@@ -311,13 +348,18 @@ fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String
} else { } else {
slice slice
}; };
let decoded = String::from_utf8_lossy(text).to_string(); let decoded = decode_cp1251(text);
out.push(Some(decoded)); out.push(Some(decoded));
off = end; off = end;
} }
Ok(out) Ok(out)
} }
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
struct RawResource { struct RawResource {
meta: nres::EntryMeta, meta: nres::EntryMeta,
bytes: Vec<u8>, bytes: Vec<u8>,

View File

@@ -1,22 +1,10 @@
use super::*; use super::*;
use common::collect_files_recursive;
use nres::Archive; use nres::Archive;
use proptest::prelude::*;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
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("..")
@@ -39,6 +27,170 @@ fn is_msh_name(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".msh") 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] #[test]
fn parse_all_game_msh_models() { fn parse_all_game_msh_models() {
let archives = nres_test_files(); let archives = nres_test_files();
@@ -137,156 +289,7 @@ fn parse_all_game_msh_models() {
#[test] #[test]
fn parse_minimal_synthetic_model() { fn parse_minimal_synthetic_model() {
// Nested NRes with required resources only. let payload = build_nested_nres(&base_synthetic_entries());
let mut payload = Vec::new();
payload.extend_from_slice(b"NRes");
payload.extend_from_slice(&0x100u32.to_le_bytes());
payload.extend_from_slice(&5u32.to_le_bytes()); // entry_count
payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
let mut resource_offsets = Vec::new();
let mut resource_sizes = Vec::new();
let mut resource_types = Vec::new();
let mut resource_attr3 = Vec::new();
let mut resource_names = Vec::new();
let add_resource = |payload: &mut Vec<u8>,
offsets: &mut Vec<u32>,
sizes: &mut Vec<u32>,
types: &mut Vec<u32>,
attr3: &mut Vec<u32>,
names: &mut Vec<String>,
kind: u32,
name: &str,
data: &[u8],
attr3_val: u32| {
offsets.push(u32::try_from(payload.len()).expect("offset overflow"));
payload.extend_from_slice(data);
while !payload.len().is_multiple_of(8) {
payload.push(0);
}
sizes.push(u32::try_from(data.len()).expect("size overflow"));
types.push(kind);
attr3.push(attr3_val);
names.push(name.to_string());
};
let node = {
let mut b = vec![0u8; 38];
// slot[0][0] = 0
b[8..10].copy_from_slice(&0u16.to_le_bytes());
for i in 1..15 {
let off = 8 + i * 2;
b[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
}
b
};
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(&0u16.to_le_bytes()); // batch_start
res2[0x8C + 6..0x8C + 8].copy_from_slice(&1u16.to_le_bytes()); // batch_count
let positions = [0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect::<Vec<_>>();
let indices = [0u16, 1, 2]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect::<Vec<_>>();
let batch = {
let mut b = vec![0u8; 20];
b[0..2].copy_from_slice(&0u16.to_le_bytes());
b[2..4].copy_from_slice(&0u16.to_le_bytes());
b[8..10].copy_from_slice(&3u16.to_le_bytes()); // index_count
b[10..14].copy_from_slice(&0u32.to_le_bytes()); // index_start
b[16..20].copy_from_slice(&0u32.to_le_bytes()); // base_vertex
b
};
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES1_NODE_TABLE,
"Res1",
&node,
38,
);
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES2_SLOTS,
"Res2",
&res2,
68,
);
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES3_POSITIONS,
"Res3",
&positions,
12,
);
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES6_INDICES,
"Res6",
&indices,
2,
);
add_resource(
&mut payload,
&mut resource_offsets,
&mut resource_sizes,
&mut resource_types,
&mut resource_attr3,
&mut resource_names,
RES13_BATCHES,
"Res13",
&batch,
20,
);
let directory_offset = payload.len();
for i in 0..resource_types.len() {
payload.extend_from_slice(&resource_types[i].to_le_bytes());
payload.extend_from_slice(&1u32.to_le_bytes()); // attr1
payload.extend_from_slice(&0u32.to_le_bytes()); // attr2
payload.extend_from_slice(&resource_sizes[i].to_le_bytes());
payload.extend_from_slice(&resource_attr3[i].to_le_bytes());
let mut name_raw = [0u8; 36];
let bytes = resource_names[i].as_bytes();
name_raw[..bytes.len()].copy_from_slice(bytes);
payload.extend_from_slice(&name_raw);
payload.extend_from_slice(&resource_offsets[i].to_le_bytes());
payload.extend_from_slice(&(i as u32).to_le_bytes()); // sort index
}
let total_size = u32::try_from(payload.len()).expect("size overflow");
payload[12..16].copy_from_slice(&total_size.to_le_bytes());
assert_eq!(
directory_offset + resource_types.len() * 64,
payload.len(),
"synthetic nested NRes layout invalid"
);
let model = parse_model_payload(&payload).expect("failed to parse synthetic model"); let model = parse_model_payload(&payload).expect("failed to parse synthetic model");
assert_eq!(model.node_count, 1); assert_eq!(model.node_count, 1);
assert_eq!(model.positions.len(), 3); assert_eq!(model.positions.len(), 3);
@@ -294,3 +297,142 @@ fn parse_minimal_synthetic_model() {
assert_eq!(model.batches.len(), 1); assert_eq!(model.batches.len(), 1);
assert_eq!(model.slot_index(0, 0, 0), Some(0)); 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

@@ -5,4 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
msh-core = { path = "../msh-core" } msh-core = { path = "../msh-core" }
[dev-dependencies]
common = { path = "../common" }
nres = { path = "../nres" } nres = { path = "../nres" }

View File

@@ -1,4 +1,7 @@
use msh_core::Model; use msh_core::Model;
use std::collections::HashMap;
pub const DEFAULT_UV_SCALE: f32 = 1024.0;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RenderVertex { pub struct RenderVertex {
@@ -9,21 +12,24 @@ pub struct RenderVertex {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct RenderMesh { pub struct RenderMesh {
pub vertices: Vec<RenderVertex>, pub vertices: Vec<RenderVertex>,
pub indices: Vec<u16>,
pub batch_count: usize, pub batch_count: usize,
pub index_overflow: bool,
} }
impl RenderMesh { impl RenderMesh {
pub fn triangle_count(&self) -> usize { pub fn triangle_count(&self) -> usize {
self.vertices.len() / 3 self.indices.len() / 3
} }
} }
/// Builds an expanded triangle list for a specific LOD/group pair. /// Builds an indexed triangle mesh for a specific LOD/group pair.
///
/// The output is suitable for simple `glDrawArrays(GL_TRIANGLES, ...)` paths.
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh { pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
let mut vertices = Vec::new(); 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 batch_count = 0usize;
let mut index_overflow = false;
let uv0 = model.uv0.as_ref(); let uv0 = model.uv0.as_ref();
for node_index in 0..model.node_count { for node_index in 0..model.node_count {
@@ -47,67 +53,88 @@ pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh
continue; continue;
} }
let batch_out_start = indices.len();
let mut batch_valid = true;
for &idx in &model.indices[index_start..index_end] { for &idx in &model.indices[index_start..index_end] {
let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx)); 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 { let Ok(final_idx) = usize::try_from(final_idx_u64) else {
continue; batch_valid = false;
break;
}; };
let Some(pos) = model.positions.get(final_idx) else { let Some(pos) = model.positions.get(final_idx) else {
continue; batch_valid = false;
break;
}; };
let uv = uv0
.and_then(|uvs| uvs.get(final_idx)) let local_index = if let Some(&mapped) = index_remap.get(&final_idx) {
.copied() mapped
.map(|packed| [packed[0] as f32 / 1024.0, packed[1] as f32 / 1024.0]) } else {
.unwrap_or([0.0, 0.0]); let Ok(mapped) = u16::try_from(vertices.len()) else {
vertices.push(RenderVertex { index_overflow = true;
position: *pos, batch_valid = false;
uv0: uv, 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; batch_count += 1;
} }
} }
RenderMesh { RenderMesh {
vertices, vertices,
indices,
batch_count, batch_count,
index_overflow,
} }
} }
pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> { pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
let mut iter = vertices.iter(); compute_bounds_impl(vertices.iter().copied())
let first = iter.next()?;
let mut min_v = *first;
let mut max_v = *first;
for v in iter {
for i in 0..3 {
if v[i] < min_v[i] {
min_v[i] = v[i];
}
if v[i] > max_v[i] {
max_v[i] = v[i];
}
}
}
Some((min_v, max_v))
} }
pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> { pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> {
let mut iter = vertices.iter(); compute_bounds_impl(vertices.iter().map(|v| v.position))
let first = iter.next()?; }
let mut min_v = first.position;
let mut max_v = first.position;
for v in iter { 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 { for i in 0..3 {
if v.position[i] < min_v[i] { if pos[i] < min_v[i] {
min_v[i] = v.position[i]; min_v[i] = pos[i];
} }
if v.position[i] > max_v[i] { if pos[i] > max_v[i] {
max_v[i] = v.position[i]; max_v[i] = pos[i];
} }
} }
} }

View File

@@ -1,23 +1,10 @@
use super::*; use super::*;
use common::collect_files_recursive;
use msh_core::parse_model_payload; use msh_core::parse_model_payload;
use nres::Archive; use nres::Archive;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
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("..")
@@ -71,12 +58,20 @@ fn build_render_mesh_for_real_models() {
) )
}); });
let mesh = build_render_mesh(&model, 0, 0); let mesh = build_render_mesh(&model, 0, 0);
if !mesh.vertices.is_empty() { if !mesh.indices.is_empty() {
meshes_non_empty += 1; meshes_non_empty += 1;
} }
if compute_bounds_for_mesh(&mesh.vertices).is_some() { if compute_bounds_for_mesh(&mesh.vertices).is_some() {
bounds_non_empty += 1; 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 { for vertex in &mesh.vertices {
assert!( assert!(
vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(), vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(),
@@ -129,3 +124,133 @@ fn compute_bounds_for_mesh_handles_empty_and_non_empty() {
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]); assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
assert_eq!(bounds.1, [1.0, 5.0, 9.0]); 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

@@ -8,14 +8,23 @@ default = []
demo = ["dep:sdl2", "dep:glow", "dep:image"] demo = ["dep:sdl2", "dep:glow", "dep:image"]
[dependencies] [dependencies]
encoding_rs = "0.8"
msh-core = { path = "../msh-core" } msh-core = { path = "../msh-core" }
nres = { path = "../nres" } nres = { path = "../nres" }
render-core = { path = "../render-core" } render-core = { path = "../render-core" }
texm = { path = "../texm" } texm = { path = "../texm" }
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] } glow = { version = "0.17", optional = true }
glow = { version = "0.16", optional = true }
image = { version = "0.25", optional = true, default-features = false, features = ["png"] } 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]] [[bin]]
name = "parkan-render-demo" name = "parkan-render-demo"
path = "src/main.rs" path = "src/main.rs"

View File

@@ -1,6 +1,6 @@
# render-demo # render-demo
Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL ES 2.0`). Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL`: GLES2 с fallback на Core 3.3).
## Назначение ## Назначение
@@ -18,6 +18,16 @@ cargo run -p render-demo --features demo -- \
--group 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. - `--archive` (обязательный): NRes-архив с `.msh` entry.
@@ -27,6 +37,7 @@ cargo run -p render-demo --features demo -- \
- `--width`, `--height` (опционально, default `1280x720`). - `--width`, `--height` (опционально, default `1280x720`).
- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах). - `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме. - `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
- В интерактивном режиме FPS выводится в заголовок окна и в stdout (обновление примерно каждые 0.5 сек).
- `--texture <name>`: явное имя `Texm` (override авто-резолва). - `--texture <name>`: явное имя `Texm` (override авто-резолва).
- `--texture-archive <path>`: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`). - `--texture-archive <path>`: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`).
- `--material-archive <path>`: путь к `material.lib` (по умолчанию соседний `material.lib`). - `--material-archive <path>`: путь к `material.lib` (по умолчанию соседний `material.lib`).
@@ -70,4 +81,4 @@ cargo run -p render-demo --features demo -- \
## Ограничения ## Ограничения
- Используется только базовая texture-фаза (без полной material/fx анимации). - Используется только базовая texture-фаза (без полной material/fx анимации).
- Вывод через `glDrawArrays(GL_TRIANGLES)` из расширенного triangle-list (позиции+UV). - Вывод через `glDrawElements(GL_TRIANGLES)` с index-buffer (позиции+UV).

View File

@@ -1,5 +1,7 @@
use encoding_rs::WINDOWS_1251;
use msh_core::{parse_model_payload, Model}; use msh_core::{parse_model_payload, Model};
use nres::{Archive, EntryRef}; use nres::{Archive, EntryRef};
use std::fmt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use texm::{decode_mip_rgba8, parse_texm}; use texm::{decode_mip_rgba8, parse_texm};
@@ -22,6 +24,37 @@ pub enum Error {
InvalidMaterial(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 { impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self { fn from(value: nres::error::Error) -> Self {
Self::Nres(value) Self::Nres(value)
@@ -280,7 +313,7 @@ fn find_material_entry_with_fallback<'a>(
} }
fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> { fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
let text = String::from_utf8_lossy(payload).replace('\r', ""); let text = decode_cp1251(payload).replace('\r', "");
let mut lines = text.lines(); let mut lines = text.lines();
let Some(first) = lines.next() else { let Some(first) = lines.next() else {
return Err(Error::InvalidWear(String::from("WEAR payload is empty"))); return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
@@ -360,9 +393,7 @@ fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Op
.iter() .iter()
.position(|&b| b == 0) .position(|&b| b == 0)
.unwrap_or(name_raw.len()); .unwrap_or(name_raw.len());
let name = String::from_utf8_lossy(&name_raw[..name_end]) let name = decode_cp1251(&name_raw[..name_end]).trim().to_string();
.trim()
.to_string();
if !name.is_empty() { if !name.is_empty() {
return Ok(Some(name)); return Ok(Some(name));
} }
@@ -371,6 +402,11 @@ fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Op
Ok(None) 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> { fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
let Some(id) = archive.find(name) else { let Some(id) = archive.find(name) else {
return Err(Error::TextureNotFound(name.to_string())); return Err(Error::TextureNotFound(name.to_string()));
@@ -399,23 +435,10 @@ fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result<Loaded
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use common::collect_files_recursive;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
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 archive_with_msh() -> Option<PathBuf> { fn archive_with_msh() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR")) let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..") .join("..")
@@ -524,4 +547,45 @@ mod tests {
assert!(texture.width > 0 && texture.height > 0); assert!(texture.width > 0 && texture.height > 0);
assert!(!texture.rgba8.is_empty()); 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

@@ -1,8 +1,9 @@
use glow::HasContext as _; use glow::HasContext as _;
use render_core::{build_render_mesh, compute_bounds_for_mesh}; 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 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::path::{Path, PathBuf};
use std::time::Instant; use std::time::{Duration, Instant};
struct Args { struct Args {
archive: PathBuf, archive: PathBuf,
@@ -11,6 +12,7 @@ struct Args {
group: usize, group: usize,
width: u32, width: u32,
height: u32, height: u32,
fov_deg: f32,
capture: Option<PathBuf>, capture: Option<PathBuf>,
angle: Option<f32>, angle: Option<f32>,
spin_rate: f32, spin_rate: f32,
@@ -25,6 +27,12 @@ struct GpuTexture {
handle: glow::NativeTexture, handle: glow::NativeTexture,
} }
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum GlBackend {
Gles2,
Core33,
}
fn parse_args() -> Result<Args, String> { fn parse_args() -> Result<Args, String> {
let mut archive = None; let mut archive = None;
let mut model = None; let mut model = None;
@@ -32,6 +40,7 @@ fn parse_args() -> Result<Args, String> {
let mut group = 0usize; let mut group = 0usize;
let mut width = 1280u32; let mut width = 1280u32;
let mut height = 720u32; let mut height = 720u32;
let mut fov_deg = 60.0f32;
let mut capture = None; let mut capture = None;
let mut angle = None; let mut angle = None;
let mut spin_rate = 0.35f32; let mut spin_rate = 0.35f32;
@@ -94,6 +103,17 @@ fn parse_args() -> Result<Args, String> {
return Err(String::from("--height must be > 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" => { "--capture" => {
let value = it let value = it
.next() .next()
@@ -163,6 +183,7 @@ fn parse_args() -> Result<Args, String> {
group, group,
width, width,
height, height,
fov_deg,
capture, capture,
angle, angle,
spin_rate, spin_rate,
@@ -176,7 +197,7 @@ fn parse_args() -> Result<Args, String> {
fn print_help() { fn print_help() {
eprintln!( eprintln!(
"parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H]" "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!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]"); eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
@@ -202,17 +223,22 @@ fn run(args: Args) -> Result<(), String> {
let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref()) let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
.map_err(|err| { .map_err(|err| {
format!( format!(
"failed to load model from archive {}: {err:?}", "failed to load model from archive {}: {err}",
args.archive.display() args.archive.display()
) )
})?; })?;
let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group); let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
if mesh.vertices.is_empty() { if mesh.indices.is_empty() {
return Err(format!( return Err(format!(
"model has no renderable triangles for lod={} group={}", "model has no renderable triangles for lod={} group={}",
args.lod, args.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 { let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else {
return Err(String::from("failed to compute mesh bounds")); return Err(String::from("failed to compute mesh bounds"));
}; };
@@ -246,35 +272,7 @@ fn run(args: Args) -> Result<(), String> {
.video() .video()
.map_err(|err| format!("failed to init SDL2 video: {err}"))?; .map_err(|err| format!("failed to init SDL2 video: {err}"))?;
{ let (mut window, _gl_ctx, gl_backend) = create_window_and_context(&video, &args)?;
let gl_attr = video.gl_attr();
gl_attr.set_context_profile(sdl2::video::GLProfile::GLES);
gl_attr.set_context_version(2, 0);
gl_attr.set_depth_size(24);
gl_attr.set_double_buffer(true);
}
let mut window_builder = video.window(
"Parkan Render Demo (SDL2 + OpenGL ES 2.0)",
args.width,
args.height,
);
window_builder.opengl();
if args.capture.is_some() {
window_builder.hidden();
} else {
window_builder.resizable();
}
let window = window_builder
.build()
.map_err(|err| format!("failed to create window: {err}"))?;
let gl_ctx = window
.gl_create_context()
.map_err(|err| format!("failed to create OpenGL context: {err}"))?;
window
.gl_make_current(&gl_ctx)
.map_err(|err| format!("failed to make GL context current: {err}"))?;
let _ = if args.capture.is_some() { let _ = if args.capture.is_some() {
video.gl_set_swap_interval(0) video.gl_set_swap_interval(0)
} else { } else {
@@ -289,12 +287,14 @@ fn run(args: Args) -> Result<(), String> {
vertex_data.push(vertex.uv0[0]); vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]); 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 { let gl = unsafe {
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _) glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
}; };
let program = unsafe { create_program(&gl)? }; let program = unsafe { create_program(&gl, gl_backend)? };
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") }; 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_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") }; let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
@@ -304,15 +304,16 @@ fn run(args: Args) -> Result<(), String> {
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?; .ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? }; 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 { unsafe {
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.buffer_data_u8_slice( gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
glow::ARRAY_BUFFER, gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
cast_slice_u8(&vertex_data), gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
glow::STATIC_DRAW, gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
);
gl.bind_buffer(glow::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() { let gpu_texture = if let Some(texture) = resolved_texture.as_ref() {
Some(unsafe { create_texture(&gl, texture)? }) Some(unsafe { create_texture(&gl, texture)? })
@@ -330,8 +331,10 @@ fn run(args: Args) -> Result<(), String> {
a_pos, a_pos,
a_uv, a_uv,
vbo, vbo,
ebo,
vao,
gpu_texture.as_ref(), gpu_texture.as_ref(),
mesh.vertices.len(), mesh.indices.len(),
&args, &args,
center, center,
camera_distance, camera_distance,
@@ -340,7 +343,7 @@ fn run(args: Args) -> Result<(), String> {
} else { } else {
run_interactive( run_interactive(
&sdl, &sdl,
&window, &mut window,
&gl, &gl,
program, program,
u_mvp.as_ref(), u_mvp.as_ref(),
@@ -349,8 +352,10 @@ fn run(args: Args) -> Result<(), String> {
a_pos, a_pos,
a_uv, a_uv,
vbo, vbo,
ebo,
vao,
gpu_texture.as_ref(), gpu_texture.as_ref(),
mesh.vertices.len(), mesh.indices.len(),
&args, &args,
center, center,
camera_distance, camera_distance,
@@ -361,6 +366,10 @@ fn run(args: Args) -> Result<(), String> {
if let Some(texture) = gpu_texture { if let Some(texture) = gpu_texture {
gl.delete_texture(texture.handle); 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_buffer(vbo);
gl.delete_program(program); gl.delete_program(program);
} }
@@ -368,6 +377,97 @@ fn run(args: Args) -> Result<(), String> {
result 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> { fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, String> {
if args.no_texture { if args.no_texture {
return Ok(None); return Ok(None);
@@ -388,11 +488,9 @@ fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture
|| args.material_archive.is_some() || args.material_archive.is_some()
|| args.wear.is_some() || args.wear.is_some()
{ {
Err(format!("failed to resolve texture: {err:?}")) Err(format!("failed to resolve texture: {err}"))
} else { } else {
eprintln!( eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color");
"warning: auto texture resolve failed ({err:?}), fallback to solid color"
);
Ok(None) Ok(None)
} }
} }
@@ -443,15 +541,24 @@ fn run_capture(
a_pos: u32, a_pos: u32,
a_uv: u32, a_uv: u32,
vbo: glow::NativeBuffer, vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
vao: Option<glow::NativeVertexArray>,
texture: Option<&GpuTexture>, texture: Option<&GpuTexture>,
vertex_count: usize, index_count: usize,
args: &Args, args: &Args,
center: [f32; 3], center: [f32; 3],
camera_distance: f32, camera_distance: f32,
capture_path: &Path, capture_path: &Path,
) -> Result<(), String> { ) -> Result<(), String> {
let angle = args.angle.unwrap_or(0.0); let angle = args.angle.unwrap_or(0.0);
let mvp = compute_mvp(args.width, args.height, center, camera_distance, angle); let mvp = compute_mvp(
args.width,
args.height,
args.fov_deg,
center,
camera_distance,
angle,
);
unsafe { unsafe {
draw_frame( draw_frame(
gl, gl,
@@ -462,8 +569,10 @@ fn run_capture(
a_pos, a_pos,
a_uv, a_uv,
vbo, vbo,
ebo,
vao,
texture, texture,
vertex_count, index_count,
args.width, args.width,
args.height, args.height,
&mvp, &mvp,
@@ -479,7 +588,7 @@ fn run_capture(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn run_interactive( fn run_interactive(
sdl: &sdl2::Sdl, sdl: &sdl2::Sdl,
window: &sdl2::video::Window, window: &mut sdl2::video::Window,
gl: &glow::Context, gl: &glow::Context,
program: glow::NativeProgram, program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>, u_mvp: Option<&glow::NativeUniformLocation>,
@@ -488,8 +597,10 @@ fn run_interactive(
a_pos: u32, a_pos: u32,
a_uv: u32, a_uv: u32,
vbo: glow::NativeBuffer, vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
vao: Option<glow::NativeVertexArray>,
texture: Option<&GpuTexture>, texture: Option<&GpuTexture>,
vertex_count: usize, index_count: usize,
args: &Args, args: &Args,
center: [f32; 3], center: [f32; 3],
camera_distance: f32, camera_distance: f32,
@@ -498,6 +609,10 @@ fn run_interactive(
.event_pump() .event_pump()
.map_err(|err| format!("failed to get SDL event pump: {err}"))?; .map_err(|err| format!("failed to get SDL event pump: {err}"))?;
let start = Instant::now(); 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 { 'main_loop: loop {
for event in events.poll_iter() { for event in events.poll_iter() {
@@ -515,7 +630,7 @@ fn run_interactive(
let angle = args let angle = args
.angle .angle
.unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate); .unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
let mvp = compute_mvp(w, h, center, camera_distance, angle); let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle);
unsafe { unsafe {
draw_frame( draw_frame(
@@ -527,14 +642,35 @@ fn run_interactive(
a_pos, a_pos,
a_uv, a_uv,
vbo, vbo,
ebo,
vao,
texture, texture,
vertex_count, index_count,
w, w,
h, h,
&mvp, &mvp,
); );
} }
window.gl_swap_window(); 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(()) Ok(())
@@ -543,12 +679,13 @@ fn run_interactive(
fn compute_mvp( fn compute_mvp(
width: u32, width: u32,
height: u32, height: u32,
fov_deg: f32,
center: [f32; 3], center: [f32; 3],
camera_distance: f32, camera_distance: f32,
angle_rad: f32, angle_rad: f32,
) -> [f32; 16] { ) -> [f32; 16] {
let aspect = (width as f32 / (height.max(1) as f32)).max(0.01); let aspect = (width as f32 / (height.max(1) as f32)).max(0.01);
let proj = mat4_perspective(60.0_f32.to_radians(), aspect, 0.01, camera_distance * 10.0); 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 view = mat4_translation(0.0, 0.0, -camera_distance);
let center_shift = mat4_translation(-center[0], -center[1], -center[2]); let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
let rot = mat4_rotation_y(angle_rad); let rot = mat4_rotation_y(angle_rad);
@@ -567,8 +704,10 @@ unsafe fn draw_frame(
a_pos: u32, a_pos: u32,
a_uv: u32, a_uv: u32,
vbo: glow::NativeBuffer, vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
vao: Option<glow::NativeVertexArray>,
texture: Option<&GpuTexture>, texture: Option<&GpuTexture>,
vertex_count: usize, index_count: usize,
width: u32, width: u32,
height: u32, height: u32,
mvp: &[f32; 16], mvp: &[f32; 16],
@@ -596,19 +735,33 @@ unsafe fn draw_frame(
gl.bind_texture(glow::TEXTURE_2D, None); gl.bind_texture(glow::TEXTURE_2D, None);
} }
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); if let Some(vao) = vao {
gl.enable_vertex_attrib_array(a_pos); gl.bind_vertex_array(Some(vao));
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0); gl.draw_elements(
gl.enable_vertex_attrib_array(a_uv); glow::TRIANGLES,
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12); index_count.min(i32::MAX as usize) as i32,
gl.draw_arrays( glow::UNSIGNED_SHORT,
glow::TRIANGLES, 0,
0, );
vertex_count.min(i32::MAX as usize) as i32, gl.bind_vertex_array(None);
); } else {
gl.disable_vertex_attrib_array(a_uv); gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.disable_vertex_attrib_array(a_pos); gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.bind_buffer(glow::ARRAY_BUFFER, None); 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.bind_texture(glow::TEXTURE_2D, None);
gl.use_program(None); gl.use_program(None);
} }
@@ -663,8 +816,13 @@ fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), S
.map_err(|err| format!("failed to save PNG {}: {err}", path.display())) .map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
} }
unsafe fn create_program(gl: &glow::Context) -> Result<glow::NativeProgram, String> { unsafe fn create_program(
let vs_src = r#" gl: &glow::Context,
backend: GlBackend,
) -> Result<glow::NativeProgram, String> {
let (vs_src, fs_src) = match backend {
GlBackend::Gles2 => (
r#"
attribute vec3 a_pos; attribute vec3 a_pos;
attribute vec2 a_uv; attribute vec2 a_uv;
uniform mat4 u_mvp; uniform mat4 u_mvp;
@@ -673,9 +831,8 @@ void main() {
v_uv = a_uv; v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0); gl_Position = u_mvp * vec4(a_pos, 1.0);
} }
"#; "#,
r#"
let fs_src = r#"
precision mediump float; precision mediump float;
uniform sampler2D u_tex; uniform sampler2D u_tex;
uniform float u_use_tex; uniform float u_use_tex;
@@ -685,7 +842,32 @@ void main() {
vec4 texColor = texture2D(u_tex, v_uv); vec4 texColor = texture2D(u_tex, v_uv);
gl_FragColor = mix(base, texColor, u_use_tex); 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 program = gl.create_program().map_err(|e| e.to_string())?;
let vs = gl let vs = gl
@@ -733,8 +915,20 @@ void main() {
Ok(program) Ok(program)
} }
fn cast_slice_u8<T>(slice: &[T]) -> &[u8] { fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
unsafe { std::slice::from_raw_parts(slice.as_ptr() as *const u8, std::mem::size_of_val(slice)) } 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] { fn mat4_identity() -> [f32; 16] {

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

@@ -6,4 +6,4 @@ edition = "2021"
[dependencies] [dependencies]
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
toml = "0.8" toml = "1.0"

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

View File

@@ -3,5 +3,7 @@ name = "texm"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dev-dependencies]
common = { path = "../common" }
nres = { path = "../nres" } nres = { path = "../nres" }
proptest = "1"

View File

@@ -1,6 +1,7 @@
use core::fmt; use core::fmt;
#[derive(Debug)] #[derive(Debug)]
#[non_exhaustive]
pub enum Error { pub enum Error {
HeaderTooSmall { HeaderTooSmall {
size: usize, size: usize,

View File

@@ -36,6 +36,7 @@ impl PixelFormat {
match self { match self {
Self::Indexed8 => 1, Self::Indexed8 => 1,
Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2, 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, Self::Rgb888 | Self::Argb8888 => 4,
} }
} }
@@ -173,14 +174,8 @@ pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
offset: level_offset, offset: level_offset,
size: level_size, size: level_size,
}); });
w = w.max(1) >> 1; w = (w >> 1).max(1);
h = h.max(1) >> 1; h = (h >> 1).max(1);
if w == 0 {
w = 1;
}
if h == 0 {
h = 1;
}
} }
let page_rects = parse_page_tail(payload, offset)?; let page_rects = parse_page_tail(payload, offset)?;
@@ -240,7 +235,8 @@ pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) ->
break; break;
} }
let poff = usize::from(index).saturating_mul(4); let poff = usize::from(index).saturating_mul(4);
if poff + 3 >= palette.len() { // Keep this form to accept the last palette item (index 255).
if poff + 4 > palette.len() {
continue; continue;
} }
let out = i.saturating_mul(4); let out = i.saturating_mul(4);

View File

@@ -1,22 +1,10 @@
use super::*; use super::*;
use common::collect_files_recursive;
use nres::Archive; use nres::Archive;
use proptest::prelude::*;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
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("..")
@@ -35,6 +23,36 @@ fn nres_test_files() -> Vec<PathBuf> {
.collect() .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] #[test]
fn texm_parse_all_game_textures() { fn texm_parse_all_game_textures() {
let archives = nres_test_files(); let archives = nres_test_files();
@@ -97,16 +115,7 @@ fn texm_parse_all_game_textures() {
#[test] #[test]
fn texm_parse_minimal_argb8888_no_page() { fn texm_parse_minimal_argb8888_no_page() {
let mut payload = Vec::new(); let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
payload.extend_from_slice(&1u32.to_le_bytes()); // width
payload.extend_from_slice(&1u32.to_le_bytes()); // height
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&8888u32.to_le_bytes()); // format
payload.extend_from_slice(&[1, 2, 3, 4]); // one pixel
let parsed = parse_texm(&payload).expect("failed to parse minimal texm"); let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
assert_eq!(parsed.header.width, 1); assert_eq!(parsed.header.width, 1);
@@ -117,17 +126,7 @@ fn texm_parse_minimal_argb8888_no_page() {
#[test] #[test]
fn texm_decode_minimal_argb8888_no_page() { fn texm_decode_minimal_argb8888_no_page() {
let mut payload = Vec::new(); let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[0x40, 0x11, 0x22, 0x33]]);
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
payload.extend_from_slice(&1u32.to_le_bytes()); // width
payload.extend_from_slice(&1u32.to_le_bytes()); // height
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&8888u32.to_le_bytes()); // format
payload.extend_from_slice(&[0x40, 0x11, 0x22, 0x33]); // A,R,G,B in little-endian order
let parsed = parse_texm(&payload).expect("failed to parse minimal texm"); let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip"); let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip");
assert_eq!(decoded.width, 1); assert_eq!(decoded.width, 1);
@@ -135,19 +134,55 @@ fn texm_decode_minimal_argb8888_no_page() {
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]); 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] #[test]
fn texm_parse_indexed_with_page_chunk() { fn texm_parse_indexed_with_page_chunk() {
let mut payload = Vec::new(); let mut palette = [0u8; 1024];
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes()); palette[4..8].copy_from_slice(&[10, 20, 30, 255]);
payload.extend_from_slice(&2u32.to_le_bytes()); // width let mut payload = build_texm_payload(2, 2, 0, 0, Some(palette), &[&[1, 1, 1, 1]]);
payload.extend_from_slice(&2u32.to_le_bytes()); // height
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8
payload.extend_from_slice(&[0u8; 1024]); // palette
payload.extend_from_slice(&[1, 2, 3, 4]); // pixels
payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes()); payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count
payload.extend_from_slice(&0i16.to_le_bytes()); // x payload.extend_from_slice(&0i16.to_le_bytes()); // x
@@ -170,26 +205,126 @@ fn texm_parse_indexed_with_page_chunk() {
} }
#[test] #[test]
fn texm_decode_indexed_with_palette() { fn texm_decode_indexed_with_palette_last_entry() {
let mut payload = Vec::new();
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
payload.extend_from_slice(&2u32.to_le_bytes()); // width
payload.extend_from_slice(&1u32.to_le_bytes()); // height
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8
let mut palette = [0u8; 1024]; let mut palette = [0u8; 1024];
palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1 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[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2
payload.extend_from_slice(&palette); palette[1020..1024].copy_from_slice(&[1, 2, 3, 4]); // index 255 (last)
payload.extend_from_slice(&[1u8, 2u8]); // two pixels 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 parsed = parse_texm(&payload).expect("failed to parse indexed texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm"); let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm");
assert_eq!(decoded.width, 2); assert_eq!(decoded.width, 3);
assert_eq!(decoded.height, 1); assert_eq!(decoded.height, 1);
assert_eq!(decoded.rgba8, vec![10, 20, 30, 255, 40, 50, 60, 200]); 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

@@ -3,7 +3,7 @@
`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy. `FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов. Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
Связанный контейнер: [NRes / RsLi](nres.md). Связанные контейнеры: [NRes](nres.md), [RsLi](rsli.md).
## 1. Контейнер ## 1. Контейнер
@@ -185,4 +185,18 @@ struct ResourceRef64 {
## 11. Статус валидации ## 11. Статус валидации
- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`. - Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
- В текущем рабочем окружении нет полного набора игровых архивов (`testdata` без payload), поэтому массовая повторная проверка корпуса здесь не выполнялась. - На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `923/923` FXID payload без ошибок.
## 12. Статус покрытия и что осталось до 100%
Закрыто:
1. Контейнер FXID, fixed-size командный поток, opcode-покрытие `1..10`.
2. Базовый runtime-контур исполнения эффекта.
3. Корпусная валидация формата на retail-данных.
Осталось:
1. Полная field-level семантика payload каждого opcode для авторинга новых эффектов «с нуля».
2. Формальная спецификация всех `time_mode` веток на уровне точных числовых формул и edge-case поведения.
3. Полный набор пиксельных parity-тестов FX (оригинал vs новый рендер) на фиксированных сценах.

View File

@@ -127,4 +127,18 @@ struct KeyRaw {
## 10. Статус валидации ## 10. Статус валидации
- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`). - Инварианты 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

@@ -6,3 +6,13 @@
- [Wear table (`WEAR`)](wear.md) - [Wear table (`WEAR`)](wear.md)
- [Texture (`Texm`)](texture.md) - [Texture (`Texm`)](texture.md)
- [Render pipeline](render.md) - [Render pipeline](render.md)
## Статус покрытия и что осталось до 100%
Закрыто:
1. Страница корректно декомпозирована на отдельные объектные спецификации.
Осталось:
1. Поддерживать единый changelog согласованности между `material.md`, `wear.md`, `texture.md` и `render.md`.

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

@@ -109,4 +109,18 @@ uint16_t map_words[]; // size/2 элементов
## 6. Статус валидации ## 6. Статус валидации
- Форматные проверки включены в `tools/msh_doc_validator.py`. - Форматные проверки включены в `tools/msh_doc_validator.py`.
- В текущем окружении полный игровой корпус MSH не подключен в `testdata`, поэтому массовый прогон здесь не выполнялся. - Корпусная валидация анимационных инвариантов включена в прогон `tools/msh_doc_validator.py` на полном retail-наборе.
## 7. Статус покрытия и что осталось до 100%
Закрыто:
1. Контракт `Res8 + Res19` и fallback-логика выбора ключа.
2. Базовая интерполяция поз и blending двух сэмплов.
3. Канонические инварианты writer path для существующих ассетов.
Осталось:
1. Полная фиксация численного поведения на всех FP-edge-case (включая платформенные различия округления).
2. Полный writer-профиль для авторинга новых анимаций без опоры на reference copy-through.
3. Набор runtime parity-тестов «frame-by-frame pose equivalence» на длинных анимациях.

View File

@@ -9,7 +9,8 @@
- [Material](material.md) - [Material](material.md)
- [Texture (Texm)](texture.md) - [Texture (Texm)](texture.md)
- [Render pipeline](render.md) - [Render pipeline](render.md)
- [NRes / RsLi](nres.md) - [NRes](nres.md)
- [RsLi](rsli.md)
## 1. Общая модель ## 1. Общая модель
@@ -174,5 +175,19 @@ for each node:
## 8. Статус валидации ## 8. Статус валидации
- Инварианты формата реализованы в `tools/msh_doc_validator.py`. - Инварианты формата реализованы в `tools/msh_doc_validator.py`.
- В текущем окружении нет загруженного полного корпуса игровых MSH в `testdata`, поэтому массовый прогон по ассетам здесь не выполнялся. - На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `435/435` MSH-моделей без структурных ошибок.
## 9. Статус покрытия и что осталось до 100%
Закрыто:
1. Базовые таблицы geometry path (`Res1/2/3/4/5/6/7/13`).
2. Критичные range-инварианты slot/batch/index.
3. Правила совместимого writer/editor для lossless работы с существующими ассетами.
Осталось:
1. Полная семантика части opaque-полей (`Slot68` tail, `Batch20` opaque-поля) для authoring без copy-through.
2. Полная формализация редких веток (`Res1.attr3 != 38`) на расширенном корпусе.
3. End-to-end writer для генерации новых игровых MSH с подтвержденным runtime-паритетом.

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

@@ -13,12 +13,27 @@
7. [Render pipeline](render.md) — полный процесс рендера кадра. 7. [Render pipeline](render.md) — полный процесс рендера кадра.
8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру. 8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
9. [3D implementation notes](msh-notes.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

@@ -75,3 +75,16 @@ CI запускает `render-parity` на каждом push/PR:
Важно: оригинальный движок в CI обычно не запускается. Важно: оригинальный движок в CI обычно не запускается.
Эталонные PNG снимаются офлайн и версионируются в репозитории. Эталонные PNG снимаются офлайн и версионируются в репозитории.
## Статус покрытия и что осталось до 100%
Закрыто:
1. Определена метрика сравнения кадров (`mean_abs`, `max_abs`, `changed_ratio`).
2. Описан единый manifest-формат кейсов и CI-процедура.
Осталось:
1. Снять и зафиксировать расширенный эталонный набор кадров оригинала (10-20+ ключевых моделей и режимов).
2. Зафиксировать пороговые критерии pass/fail по каждому классу сцен (статик, анимация, FX, lightmap).
3. Добавить автоматическую публикацию diff-артефактов и регрессионных отчетов в CI.

View File

@@ -151,5 +151,32 @@ void RenderFrame(Scene* scene, Camera* cam, float dt) {
## 10. Статус валидации ## 10. Статус валидации
- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущим runtime-кодом приложения и импортами движковых DLL. - Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущей runtime-валидацией проекта.
- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`. - Детальные инварианты форматов зафиксированы в `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

@@ -6,3 +6,13 @@
Эта страница оставлена как совместимый указатель для старых ссылок. Эта страница оставлена как совместимый указатель для старых ссылок.
## Статус покрытия и что осталось до 100%
Закрыто:
1. Актуальный runtime-пайплайн централизован в `render.md`.
Осталось:
1. Поддерживать обратную совместимость ссылок при дальнейшей декомпозиции render-документа.

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 закрыты, но не все биты имеют документированные геймплейные имена).

View File

@@ -136,4 +136,18 @@ struct Rect16 {
## 10. Статус валидации ## 10. Статус валидации
- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`. - Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`.
- В текущем окружении нет полного игрового набора текстур в `testdata`, поэтому массовая перепроверка не запускалась. - На полном 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-тесты (скриншотные и событийные).

View File

@@ -79,4 +79,18 @@ handle = (tableIndex << 16) | wearIndex
## 8. Статус валидации ## 8. Статус валидации
- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном. - Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном.
- Массовый прогон по полному игровому набору в этом окружении не выполнялся из-за отсутствия корпуса данных в `testdata`. - Корпусные проверки связки `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

@@ -34,6 +34,7 @@ nav:
- Texture (Texm): specs/texture.md - Texture (Texm): specs/texture.md
- Materials index: specs/materials-texm.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