Compare commits
12 Commits
efab61a45c
...
devel
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a25b6c0e | |||
| f4262cf369 | |||
| 9b100b8fc3 | |||
| 9fceeb9a0a | |||
| 4b7f1a16b9 | |||
|
ada3b903ad
|
|||
| 31d849ddbf | |||
| 4ef08d0bf6 | |||
|
598137ed13
|
|||
|
cb0ca2f2f0
|
|||
|
7346e695c4
|
|||
|
bb827c3928
|
@@ -3,7 +3,7 @@ name: Docs Deploy
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- devel
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-docs:
|
deploy-docs:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
11
apps/resource-viewer/Cargo.toml
Normal file
11
apps/resource-viewer/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "resource-viewer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
iced = "0.14"
|
||||||
|
rfd = "0.17"
|
||||||
|
nres = { path = "../../crates/nres" }
|
||||||
|
rsli = { path = "../../crates/rsli" }
|
||||||
518
apps/resource-viewer/src/main.rs
Normal file
518
apps/resource-viewer/src/main.rs
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
use iced::widget::{button, column, container, horizontal_space, row, scrollable, text};
|
||||||
|
use iced::{application, Element, Length, Task, Theme};
|
||||||
|
use rfd::FileDialog;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
application("Parkan Resource Viewer", update, view)
|
||||||
|
.theme(theme)
|
||||||
|
.run_with(|| (ViewerApp::default(), Task::none()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme(_state: &ViewerApp) -> Theme {
|
||||||
|
Theme::Light
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct ViewerApp {
|
||||||
|
document: Option<DocumentModel>,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Message {
|
||||||
|
OpenRequested,
|
||||||
|
SelectNode(Selection),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(state: &mut ViewerApp, message: Message) -> Task<Message> {
|
||||||
|
match message {
|
||||||
|
Message::OpenRequested => {
|
||||||
|
if let Some(path) = pick_archive_file() {
|
||||||
|
match load_document(&path) {
|
||||||
|
Ok(document) => {
|
||||||
|
state.status =
|
||||||
|
format!("Loaded {} as {}", path.display(), document.format.label());
|
||||||
|
state.document = Some(document);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
state.status = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::SelectNode(selection) => {
|
||||||
|
if let Some(document) = state.document.as_mut() {
|
||||||
|
document.selected = selection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(state: &ViewerApp) -> Element<'_, Message> {
|
||||||
|
let top_bar = row![
|
||||||
|
button("Open archive").on_press(Message::OpenRequested),
|
||||||
|
text(status_text(state)).size(14)
|
||||||
|
]
|
||||||
|
.spacing(12);
|
||||||
|
|
||||||
|
let content = if let Some(document) = &state.document {
|
||||||
|
view_document(document)
|
||||||
|
} else {
|
||||||
|
container(text("Open an .nres/.rsli/.lib archive to start.").size(16))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
container(column![top_bar, content].spacing(12).padding(12))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_text(state: &ViewerApp) -> String {
|
||||||
|
if state.status.is_empty() {
|
||||||
|
String::from("Ready")
|
||||||
|
} else {
|
||||||
|
state.status.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_document(document: &DocumentModel) -> Element<'_, Message> {
|
||||||
|
let mut tree = column![text("Archive tree").size(18)].spacing(6);
|
||||||
|
for item in &document.tree_rows {
|
||||||
|
let indent = horizontal_space().width(Length::Fixed(f32::from(item.depth) * 16.0));
|
||||||
|
|
||||||
|
let line = row![indent, text(&item.label).size(14)].spacing(6);
|
||||||
|
if let Some(selection) = item.selection {
|
||||||
|
let mut node_button = button(line)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_press(Message::SelectNode(selection));
|
||||||
|
|
||||||
|
if selection == document.selected {
|
||||||
|
node_button = node_button.style(button::primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
tree = tree.push(node_button);
|
||||||
|
} else {
|
||||||
|
tree = tree.push(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (panel_title, fields) = selected_fields(document);
|
||||||
|
let mut fields_column = column![text(panel_title).size(18)].spacing(8);
|
||||||
|
|
||||||
|
for field in fields {
|
||||||
|
fields_column = fields_column.push(
|
||||||
|
row![
|
||||||
|
text(&field.key).size(14).width(Length::Fixed(220.0)),
|
||||||
|
text(&field.value).size(14).width(Length::Fill)
|
||||||
|
]
|
||||||
|
.spacing(12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let left = container(scrollable(tree))
|
||||||
|
.width(Length::FillPortion(2))
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
let right = container(scrollable(fields_column))
|
||||||
|
.width(Length::FillPortion(5))
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
row![left, right].spacing(12).height(Length::Fill).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_fields(document: &DocumentModel) -> (String, &[FieldRow]) {
|
||||||
|
match document.selected {
|
||||||
|
Selection::Archive => (
|
||||||
|
format!(
|
||||||
|
"{} fields ({})",
|
||||||
|
document.format.label(),
|
||||||
|
document.path.display()
|
||||||
|
),
|
||||||
|
&document.archive_fields,
|
||||||
|
),
|
||||||
|
Selection::Entry(index) => {
|
||||||
|
if let Some(entry) = document.entries.get(index) {
|
||||||
|
(entry.panel_title.clone(), &entry.fields)
|
||||||
|
} else {
|
||||||
|
(String::from("Entry"), &[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_archive_file() -> Option<PathBuf> {
|
||||||
|
FileDialog::new()
|
||||||
|
.set_title("Open Parkan archive")
|
||||||
|
.pick_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_document(path: &Path) -> Result<DocumentModel, String> {
|
||||||
|
let bytes =
|
||||||
|
fs::read(path).map_err(|err| format!("Failed to read {}: {err}", path.display()))?;
|
||||||
|
let Some(format) = detect_archive_format(&bytes) else {
|
||||||
|
return Err(format!(
|
||||||
|
"{} is not recognized as NRes/RsLi (unsupported magic).",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
match format {
|
||||||
|
ArchiveFormat::Nres => load_nres_document(path),
|
||||||
|
ArchiveFormat::Rsli => load_rsli_document(path),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_archive_format(bytes: &[u8]) -> Option<ArchiveFormat> {
|
||||||
|
if bytes.len() >= 4 && &bytes[0..4] == b"NRes" {
|
||||||
|
return Some(ArchiveFormat::Nres);
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.len() >= 2 && &bytes[0..2] == b"NL" {
|
||||||
|
return Some(ArchiveFormat::Rsli);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_nres_document(path: &Path) -> Result<DocumentModel, String> {
|
||||||
|
let archive = nres::Archive::open_path(path)
|
||||||
|
.map_err(|err| format!("NRes open failed for {}: {err}", path.display()))?;
|
||||||
|
|
||||||
|
let info = archive.info();
|
||||||
|
let mut archive_fields = vec![
|
||||||
|
FieldRow::new("format", "NRes"),
|
||||||
|
FieldRow::new("file_size", info.file_size.to_string()),
|
||||||
|
FieldRow::new("raw_mode", info.raw_mode.to_string()),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(header) = &info.header {
|
||||||
|
archive_fields.push(FieldRow::new(
|
||||||
|
"magic",
|
||||||
|
String::from_utf8_lossy(&header.magic).into_owned(),
|
||||||
|
));
|
||||||
|
archive_fields.push(FieldRow::new("version", format_u32_dec_hex(header.version)));
|
||||||
|
archive_fields.push(FieldRow::new("entry_count", header.entry_count.to_string()));
|
||||||
|
archive_fields.push(FieldRow::new(
|
||||||
|
"total_size",
|
||||||
|
format!("{} (0x{:08X})", header.total_size, header.total_size),
|
||||||
|
));
|
||||||
|
archive_fields.push(FieldRow::new(
|
||||||
|
"directory_offset",
|
||||||
|
header.directory_offset.to_string(),
|
||||||
|
));
|
||||||
|
archive_fields.push(FieldRow::new(
|
||||||
|
"directory_size",
|
||||||
|
header.directory_size.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in archive.entries_inspect() {
|
||||||
|
let meta = entry.meta;
|
||||||
|
let mut fields = vec![
|
||||||
|
FieldRow::new("id", entry.id.0.to_string()),
|
||||||
|
FieldRow::new("name", meta.name.clone()),
|
||||||
|
FieldRow::new("type_id", format_u32_dec_hex(meta.kind)),
|
||||||
|
FieldRow::new("attr1", format_u32_dec_hex(meta.attr1)),
|
||||||
|
FieldRow::new("attr2", format_u32_dec_hex(meta.attr2)),
|
||||||
|
FieldRow::new("attr3", format_u32_dec_hex(meta.attr3)),
|
||||||
|
FieldRow::new("data_offset", meta.data_offset.to_string()),
|
||||||
|
FieldRow::new("data_size", meta.data_size.to_string()),
|
||||||
|
FieldRow::new("sort_index", meta.sort_index.to_string()),
|
||||||
|
FieldRow::new("name_raw_hex", bytes_as_hex(entry.name_raw)),
|
||||||
|
FieldRow::new("name_raw_ascii", bytes_as_ascii(entry.name_raw)),
|
||||||
|
];
|
||||||
|
|
||||||
|
fields.push(FieldRow::new("find_key", meta.name.to_ascii_lowercase()));
|
||||||
|
|
||||||
|
entries.push(EntryView {
|
||||||
|
full_name: meta.name.clone(),
|
||||||
|
panel_title: format!("NRes entry #{}: {}", entry.id.0, meta.name),
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tree_rows = build_tree_rows(&entries);
|
||||||
|
|
||||||
|
Ok(DocumentModel {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
format: ArchiveFormat::Nres,
|
||||||
|
archive_fields,
|
||||||
|
entries,
|
||||||
|
tree_rows,
|
||||||
|
selected: Selection::Archive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_rsli_document(path: &Path) -> Result<DocumentModel, String> {
|
||||||
|
let library = rsli::Library::open_path(path)
|
||||||
|
.map_err(|err| format!("RsLi open failed for {}: {err}", path.display()))?;
|
||||||
|
|
||||||
|
let header = library.header();
|
||||||
|
let mut archive_fields = vec![
|
||||||
|
FieldRow::new("format", "RsLi"),
|
||||||
|
FieldRow::new("magic", String::from_utf8_lossy(&header.magic).into_owned()),
|
||||||
|
FieldRow::new(
|
||||||
|
"reserved",
|
||||||
|
format!("{} (0x{:02X})", header.reserved, header.reserved),
|
||||||
|
),
|
||||||
|
FieldRow::new(
|
||||||
|
"version",
|
||||||
|
format!("{} (0x{:02X})", header.version, header.version),
|
||||||
|
),
|
||||||
|
FieldRow::new("entry_count", header.entry_count.to_string()),
|
||||||
|
FieldRow::new("presorted_flag", format!("0x{:04X}", header.presorted_flag)),
|
||||||
|
FieldRow::new("xor_seed", format!("0x{:08X}", header.xor_seed)),
|
||||||
|
FieldRow::new("header_raw_hex", bytes_as_hex(&header.raw)),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some(ao) = library.ao_trailer() {
|
||||||
|
archive_fields.push(FieldRow::new("ao_trailer", "present"));
|
||||||
|
archive_fields.push(FieldRow::new("ao_overlay", ao.overlay.to_string()));
|
||||||
|
archive_fields.push(FieldRow::new("ao_raw_hex", bytes_as_hex(&ao.raw)));
|
||||||
|
} else {
|
||||||
|
archive_fields.push(FieldRow::new("ao_trailer", "absent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
for entry in library.entries_inspect() {
|
||||||
|
let meta = entry.meta;
|
||||||
|
let method_raw = (meta.flags as u16 as u32) & 0x1E0;
|
||||||
|
|
||||||
|
let fields = vec![
|
||||||
|
FieldRow::new("id", entry.id.0.to_string()),
|
||||||
|
FieldRow::new("name", meta.name.clone()),
|
||||||
|
FieldRow::new(
|
||||||
|
"flags",
|
||||||
|
format!("{} (0x{:04X})", meta.flags, meta.flags as u16),
|
||||||
|
),
|
||||||
|
FieldRow::new("method", format!("{:?}", meta.method)),
|
||||||
|
FieldRow::new("method_raw", format!("0x{:03X}", method_raw)),
|
||||||
|
FieldRow::new("packed_size", meta.packed_size.to_string()),
|
||||||
|
FieldRow::new("unpacked_size", meta.unpacked_size.to_string()),
|
||||||
|
FieldRow::new("data_offset_effective", meta.data_offset.to_string()),
|
||||||
|
FieldRow::new("data_offset_raw", entry.data_offset_raw.to_string()),
|
||||||
|
FieldRow::new("sort_to_original", entry.sort_to_original.to_string()),
|
||||||
|
FieldRow::new("name_raw_hex", bytes_as_hex(entry.name_raw)),
|
||||||
|
FieldRow::new("name_raw_ascii", bytes_as_ascii(entry.name_raw)),
|
||||||
|
FieldRow::new("service_tail_hex", bytes_as_hex(entry.service_tail)),
|
||||||
|
FieldRow::new("service_tail_ascii", bytes_as_ascii(entry.service_tail)),
|
||||||
|
];
|
||||||
|
|
||||||
|
entries.push(EntryView {
|
||||||
|
full_name: meta.name.clone(),
|
||||||
|
panel_title: format!("RsLi entry #{}: {}", entry.id.0, meta.name),
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let tree_rows = build_tree_rows(&entries);
|
||||||
|
|
||||||
|
Ok(DocumentModel {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
format: ArchiveFormat::Rsli,
|
||||||
|
archive_fields,
|
||||||
|
entries,
|
||||||
|
tree_rows,
|
||||||
|
selected: Selection::Archive,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_tree_rows(entries: &[EntryView]) -> Vec<TreeRow> {
|
||||||
|
let mut root = FolderNode::default();
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
insert_tree_path(&mut root, &entry.full_name, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows = vec![TreeRow {
|
||||||
|
depth: 0,
|
||||||
|
label: String::from("[Archive fields]"),
|
||||||
|
selection: Some(Selection::Archive),
|
||||||
|
}];
|
||||||
|
|
||||||
|
flatten_tree(&root, 0, &mut rows);
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_tree_path(root: &mut FolderNode, full_name: &str, entry_index: usize) {
|
||||||
|
let mut parts: Vec<&str> = full_name
|
||||||
|
.split(['/', '\\'])
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
parts.push(full_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if parts.len() == 1 {
|
||||||
|
root.files.push((parts[0].to_string(), entry_index));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file_name = parts.pop().unwrap_or(full_name);
|
||||||
|
let mut node = root;
|
||||||
|
for part in parts {
|
||||||
|
node = node.folders.entry(part.to_string()).or_default();
|
||||||
|
}
|
||||||
|
|
||||||
|
node.files.push((file_name.to_string(), entry_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten_tree(node: &FolderNode, depth: u16, out: &mut Vec<TreeRow>) {
|
||||||
|
for (folder_name, folder_node) in &node.folders {
|
||||||
|
out.push(TreeRow {
|
||||||
|
depth,
|
||||||
|
label: format!("{folder_name}/"),
|
||||||
|
selection: None,
|
||||||
|
});
|
||||||
|
flatten_tree(folder_node, depth.saturating_add(1), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut files = node.files.clone();
|
||||||
|
files.sort_by(|left, right| left.0.cmp(&right.0));
|
||||||
|
|
||||||
|
for (name, index) in files {
|
||||||
|
out.push(TreeRow {
|
||||||
|
depth,
|
||||||
|
label: name,
|
||||||
|
selection: Some(Selection::Entry(index)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_as_hex(bytes: &[u8]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (index, byte) in bytes.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
let _ = write!(&mut out, "{byte:02X}");
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_as_ascii(bytes: &[u8]) -> String {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.map(|byte| {
|
||||||
|
if byte.is_ascii_graphic() || *byte == b' ' {
|
||||||
|
char::from(*byte)
|
||||||
|
} else {
|
||||||
|
'.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_u32_dec_hex(value: u32) -> String {
|
||||||
|
format!("{} (0x{:08X})", value, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct DocumentModel {
|
||||||
|
path: PathBuf,
|
||||||
|
format: ArchiveFormat,
|
||||||
|
archive_fields: Vec<FieldRow>,
|
||||||
|
entries: Vec<EntryView>,
|
||||||
|
tree_rows: Vec<TreeRow>,
|
||||||
|
selected: Selection,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
enum ArchiveFormat {
|
||||||
|
Nres,
|
||||||
|
Rsli,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ArchiveFormat {
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Nres => "NRes",
|
||||||
|
Self::Rsli => "RsLi",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct EntryView {
|
||||||
|
full_name: String,
|
||||||
|
panel_title: String,
|
||||||
|
fields: Vec<FieldRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct FieldRow {
|
||||||
|
key: String,
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FieldRow {
|
||||||
|
fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
key: key.into(),
|
||||||
|
value: value.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct TreeRow {
|
||||||
|
depth: u16,
|
||||||
|
label: String,
|
||||||
|
selection: Option<Selection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Selection {
|
||||||
|
Archive,
|
||||||
|
Entry(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Debug)]
|
||||||
|
struct FolderNode {
|
||||||
|
folders: BTreeMap<String, FolderNode>,
|
||||||
|
files: Vec<(String, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_builds_nested_paths() {
|
||||||
|
let entries = vec![
|
||||||
|
EntryView {
|
||||||
|
full_name: String::from("textures/ui/hud.texm"),
|
||||||
|
panel_title: String::new(),
|
||||||
|
fields: vec![],
|
||||||
|
},
|
||||||
|
EntryView {
|
||||||
|
full_name: String::from("textures/world/ground.texm"),
|
||||||
|
panel_title: String::new(),
|
||||||
|
fields: vec![],
|
||||||
|
},
|
||||||
|
EntryView {
|
||||||
|
full_name: String::from("root_file.msh"),
|
||||||
|
panel_title: String::new(),
|
||||||
|
fields: vec![],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let rows = build_tree_rows(&entries);
|
||||||
|
assert!(rows.iter().any(|row| row.label == "textures/"));
|
||||||
|
assert!(rows.iter().any(|row| row.label == "ui/"));
|
||||||
|
assert!(rows.iter().any(|row| row.label == "hud.texm"));
|
||||||
|
assert!(rows.iter().any(|row| row.label == "root_file.msh"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -347,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>,
|
||||||
|
|||||||
@@ -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("..")
|
||||||
@@ -169,18 +157,17 @@ fn res13_single_batch(index_start: u32, index_count: u16) -> Vec<u8> {
|
|||||||
batch
|
batch
|
||||||
}
|
}
|
||||||
|
|
||||||
fn res10_names(names: &[Option<&str>]) -> Vec<u8> {
|
fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec<u8> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for name in names {
|
for name in names {
|
||||||
match name {
|
match name {
|
||||||
Some(name) => {
|
Some(name) => {
|
||||||
let bytes = name.as_bytes();
|
|
||||||
out.extend_from_slice(
|
out.extend_from_slice(
|
||||||
&u32::try_from(bytes.len())
|
&u32::try_from(name.len())
|
||||||
.expect("name size overflow in test")
|
.expect("name size overflow in test")
|
||||||
.to_le_bytes(),
|
.to_le_bytes(),
|
||||||
);
|
);
|
||||||
out.extend_from_slice(bytes);
|
out.extend_from_slice(name);
|
||||||
out.push(0);
|
out.push(0);
|
||||||
}
|
}
|
||||||
None => out.extend_from_slice(&0u32.to_le_bytes()),
|
None => out.extend_from_slice(&0u32.to_le_bytes()),
|
||||||
@@ -189,6 +176,11 @@ fn res10_names(names: &[Option<&str>]) -> Vec<u8> {
|
|||||||
out
|
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> {
|
fn base_synthetic_entries() -> Vec<SyntheticEntry> {
|
||||||
vec![
|
vec![
|
||||||
synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))),
|
synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))),
|
||||||
@@ -339,6 +331,22 @@ fn parse_synthetic_model_with_optional_res4_res5_res10() {
|
|||||||
assert_eq!(model.node_names, Some(vec![Some("Hull".to_string()), None]));
|
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]
|
#[test]
|
||||||
fn parse_fails_when_required_resource_missing() {
|
fn parse_fails_when_required_resource_missing() {
|
||||||
let mut entries = base_synthetic_entries();
|
let mut entries = base_synthetic_entries();
|
||||||
@@ -419,3 +427,12 @@ fn parse_fails_for_batch_index_range_out_of_bounds() {
|
|||||||
})
|
})
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,17 +101,27 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -101,6 +136,17 @@ impl Archive {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
if self.entries.is_empty() {
|
if self.entries.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -153,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]))
|
||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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("..")
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ edition = "2021"
|
|||||||
msh-core = { path = "../msh-core" }
|
msh-core = { path = "../msh-core" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
nres = { path = "../nres" }
|
nres = { path = "../nres" }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use msh_core::Model;
|
use msh_core::Model;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
pub const DEFAULT_UV_SCALE: f32 = 1024.0;
|
pub const DEFAULT_UV_SCALE: f32 = 1024.0;
|
||||||
|
|
||||||
@@ -11,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 {
|
||||||
@@ -49,36 +53,62 @@ 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| {
|
} else {
|
||||||
[
|
let Ok(mapped) = u16::try_from(vertices.len()) else {
|
||||||
packed[0] as f32 / DEFAULT_UV_SCALE,
|
index_overflow = true;
|
||||||
packed[1] as f32 / DEFAULT_UV_SCALE,
|
batch_valid = false;
|
||||||
]
|
break;
|
||||||
})
|
};
|
||||||
.unwrap_or([0.0, 0.0]);
|
let uv = uv0
|
||||||
vertices.push(RenderVertex {
|
.and_then(|uvs| uvs.get(final_idx))
|
||||||
position: *pos,
|
.copied()
|
||||||
uv0: uv,
|
.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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
@@ -189,6 +184,7 @@ fn build_render_mesh_handles_empty_slot_model() {
|
|||||||
|
|
||||||
let mesh = build_render_mesh(&model, 0, 0);
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
assert!(mesh.vertices.is_empty());
|
assert!(mesh.vertices.is_empty());
|
||||||
|
assert!(mesh.indices.is_empty());
|
||||||
assert_eq!(mesh.batch_count, 0);
|
assert_eq!(mesh.batch_count, 0);
|
||||||
assert_eq!(mesh.triangle_count(), 0);
|
assert_eq!(mesh.triangle_count(), 0);
|
||||||
}
|
}
|
||||||
@@ -225,9 +221,36 @@ fn build_render_mesh_supports_multi_node_and_uv_scaling() {
|
|||||||
let mesh = build_render_mesh(&model, 0, 0);
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
assert_eq!(mesh.batch_count, 2);
|
assert_eq!(mesh.batch_count, 2);
|
||||||
assert_eq!(mesh.vertices.len(), 6);
|
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.triangle_count(), 2);
|
||||||
assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]);
|
assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]);
|
||||||
assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]);
|
assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]);
|
||||||
assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]);
|
assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]);
|
||||||
assert_eq!(mesh.vertices[3].uv0, [1.0, 1.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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,10 +13,18 @@ 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"
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -435,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("..")
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -26,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;
|
||||||
@@ -221,12 +228,17 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
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"));
|
||||||
};
|
};
|
||||||
@@ -260,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 {
|
||||||
@@ -304,12 +288,13 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
vertex_data.push(vertex.uv0[1]);
|
vertex_data.push(vertex.uv0[1]);
|
||||||
}
|
}
|
||||||
let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data);
|
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") };
|
||||||
@@ -319,11 +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(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
|
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);
|
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)? })
|
||||||
@@ -341,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,
|
||||||
@@ -351,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(),
|
||||||
@@ -360,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,
|
||||||
@@ -372,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);
|
||||||
}
|
}
|
||||||
@@ -379,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);
|
||||||
@@ -452,8 +541,10 @@ 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,
|
||||||
@@ -478,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,
|
||||||
@@ -495,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>,
|
||||||
@@ -504,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,
|
||||||
@@ -514,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() {
|
||||||
@@ -543,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(())
|
||||||
@@ -584,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],
|
||||||
@@ -613,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);
|
||||||
}
|
}
|
||||||
@@ -680,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;
|
||||||
@@ -690,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;
|
||||||
@@ -702,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
|
||||||
@@ -758,6 +923,14 @@ fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
|
|||||||
out
|
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] {
|
||||||
[
|
[
|
||||||
1.0, 0.0, 0.0, 0.0, //
|
1.0, 0.0, 0.0, 0.0, //
|
||||||
|
|||||||
33
crates/render-mission-demo/Cargo.toml
Normal file
33
crates/render-mission-demo/Cargo.toml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[package]
|
||||||
|
name = "render-mission-demo"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
demo = ["dep:sdl2", "dep:glow"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
glow = { version = "0.16", optional = true }
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
render-core = { path = "../render-core" }
|
||||||
|
render-demo = { path = "../render-demo" }
|
||||||
|
tma = { path = "../tma" }
|
||||||
|
terrain-core = { path = "../terrain-core" }
|
||||||
|
texm = { path = "../texm" }
|
||||||
|
unitdat = { path = "../unitdat" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||||
|
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "parkan-render-mission-demo"
|
||||||
|
path = "src/main.rs"
|
||||||
|
required-features = ["demo"]
|
||||||
881
crates/render-mission-demo/src/lib.rs
Normal file
881
crates/render-mission-demo/src/lib.rs
Normal file
@@ -0,0 +1,881 @@
|
|||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
use nres::Archive;
|
||||||
|
use render_core::{build_render_mesh, RenderMesh};
|
||||||
|
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use terrain_core::TerrainMesh;
|
||||||
|
use tma::MissionFile;
|
||||||
|
|
||||||
|
const MAT0_KIND: u32 = 0x3054_414D;
|
||||||
|
const MESH_KIND: u32 = 0x4853_454D;
|
||||||
|
const OBJECT_REF_STRIDE: usize = 64;
|
||||||
|
const OBJECT_REF_ARCHIVE_BYTES: usize = 32;
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Io(std::io::Error),
|
||||||
|
Mission(tma::Error),
|
||||||
|
Terrain(terrain_core::Error),
|
||||||
|
UnitDat(unitdat::Error),
|
||||||
|
RenderDemo(render_demo::Error),
|
||||||
|
Nres(nres::error::Error),
|
||||||
|
Texm(texm::error::Error),
|
||||||
|
InvalidMapPath(String),
|
||||||
|
GameRootNotFound(PathBuf),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
Self::Mission(err) => write!(f, "{err}"),
|
||||||
|
Self::Terrain(err) => write!(f, "{err}"),
|
||||||
|
Self::UnitDat(err) => write!(f, "{err}"),
|
||||||
|
Self::RenderDemo(err) => write!(f, "{err}"),
|
||||||
|
Self::Nres(err) => write!(f, "{err}"),
|
||||||
|
Self::Texm(err) => write!(f, "{err}"),
|
||||||
|
Self::InvalidMapPath(path) => write!(f, "invalid mission map path: {path}"),
|
||||||
|
Self::GameRootNotFound(path) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"failed to detect game root from mission path {}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => Some(err),
|
||||||
|
Self::Mission(err) => Some(err),
|
||||||
|
Self::Terrain(err) => Some(err),
|
||||||
|
Self::UnitDat(err) => Some(err),
|
||||||
|
Self::RenderDemo(err) => Some(err),
|
||||||
|
Self::Nres(err) => Some(err),
|
||||||
|
Self::Texm(err) => Some(err),
|
||||||
|
Self::InvalidMapPath(_) | Self::GameRootNotFound(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(value: std::io::Error) -> Self {
|
||||||
|
Self::Io(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<tma::Error> for Error {
|
||||||
|
fn from(value: tma::Error) -> Self {
|
||||||
|
Self::Mission(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<terrain_core::Error> for Error {
|
||||||
|
fn from(value: terrain_core::Error) -> Self {
|
||||||
|
Self::Terrain(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<unitdat::Error> for Error {
|
||||||
|
fn from(value: unitdat::Error) -> Self {
|
||||||
|
Self::UnitDat(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<render_demo::Error> for Error {
|
||||||
|
fn from(value: render_demo::Error) -> Self {
|
||||||
|
Self::RenderDemo(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nres::error::Error> for Error {
|
||||||
|
fn from(value: nres::error::Error) -> Self {
|
||||||
|
Self::Nres(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<texm::error::Error> for Error {
|
||||||
|
fn from(value: texm::error::Error) -> Self {
|
||||||
|
Self::Texm(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct LoadOptions {
|
||||||
|
pub load_model_textures: bool,
|
||||||
|
pub load_terrain_texture: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LoadOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
load_model_textures: true,
|
||||||
|
load_terrain_texture: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MissionScene {
|
||||||
|
pub game_root: PathBuf,
|
||||||
|
pub mission_path: PathBuf,
|
||||||
|
pub mission: MissionFile,
|
||||||
|
pub map_folder_rel: PathBuf,
|
||||||
|
pub land_msh_path: PathBuf,
|
||||||
|
pub terrain: TerrainMesh,
|
||||||
|
pub terrain_texture: Option<LoadedTexture>,
|
||||||
|
pub models: Vec<SceneModel>,
|
||||||
|
pub skipped_objects: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct SceneModel {
|
||||||
|
pub archive_path: PathBuf,
|
||||||
|
pub model_name: String,
|
||||||
|
pub mesh: RenderMesh,
|
||||||
|
pub texture: Option<LoadedTexture>,
|
||||||
|
pub instances: Vec<ModelInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct ModelInstance {
|
||||||
|
pub position: [f32; 3],
|
||||||
|
pub yaw_rad: f32,
|
||||||
|
pub scale: [f32; 3],
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ObjectPrototype {
|
||||||
|
archive_path: PathBuf,
|
||||||
|
model_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ObjectRef {
|
||||||
|
archive_name: String,
|
||||||
|
resource_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||||
|
struct ModelKey {
|
||||||
|
archive_path: PathBuf,
|
||||||
|
model_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_game_root_from_mission_path(mission_path: &Path) -> Option<PathBuf> {
|
||||||
|
let mut cursor = mission_path.parent();
|
||||||
|
while let Some(dir) = cursor {
|
||||||
|
if dir.join("DATA").is_dir() && dir.join("objects.rlb").is_file() {
|
||||||
|
return Some(dir.to_path_buf());
|
||||||
|
}
|
||||||
|
cursor = dir.parent();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_scene(
|
||||||
|
game_root: impl AsRef<Path>,
|
||||||
|
mission_path: impl AsRef<Path>,
|
||||||
|
) -> Result<MissionScene> {
|
||||||
|
load_scene_with_options(game_root, mission_path, LoadOptions::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_scene_with_options(
|
||||||
|
game_root: impl AsRef<Path>,
|
||||||
|
mission_path: impl AsRef<Path>,
|
||||||
|
options: LoadOptions,
|
||||||
|
) -> Result<MissionScene> {
|
||||||
|
let game_root = game_root.as_ref().to_path_buf();
|
||||||
|
let mission_path = mission_path.as_ref().to_path_buf();
|
||||||
|
|
||||||
|
let mission = tma::parse_path(&mission_path)?;
|
||||||
|
let map_folder_rel = map_folder_from_footer(&mission.footer.map_path)?;
|
||||||
|
let land_msh_path = game_root.join(&map_folder_rel).join("Land.msh");
|
||||||
|
let terrain = terrain_core::load_land_mesh(&land_msh_path)?;
|
||||||
|
let terrain_texture = if options.load_terrain_texture {
|
||||||
|
resolve_terrain_texture(&game_root, &map_folder_rel)?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut grouped_instances: HashMap<ModelKey, Vec<ModelInstance>> = HashMap::new();
|
||||||
|
let mut prototype_cache: HashMap<String, Option<ObjectPrototype>> = HashMap::new();
|
||||||
|
let mut skipped = 0usize;
|
||||||
|
|
||||||
|
for object in &mission.objects {
|
||||||
|
let cache_key = object.resource_name.to_ascii_lowercase();
|
||||||
|
let proto = if let Some(cached) = prototype_cache.get(&cache_key) {
|
||||||
|
cached.clone()
|
||||||
|
} else {
|
||||||
|
let resolved = resolve_object_prototype(&game_root, object)?;
|
||||||
|
prototype_cache.insert(cache_key, resolved.clone());
|
||||||
|
resolved
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(proto) = proto else {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let instance = ModelInstance {
|
||||||
|
position: object.position,
|
||||||
|
yaw_rad: object.orientation[2],
|
||||||
|
scale: normalize_scale(object.scale),
|
||||||
|
};
|
||||||
|
|
||||||
|
grouped_instances
|
||||||
|
.entry(ModelKey {
|
||||||
|
archive_path: proto.archive_path,
|
||||||
|
model_name: proto.model_name,
|
||||||
|
})
|
||||||
|
.or_default()
|
||||||
|
.push(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut models = Vec::new();
|
||||||
|
for (key, instances) in grouped_instances {
|
||||||
|
let loaded =
|
||||||
|
match load_model_with_name_from_archive(&key.archive_path, Some(&key.model_name)) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
skipped += instances.len();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mesh = build_render_mesh(&loaded.model, 0, 0);
|
||||||
|
if mesh.indices.is_empty() {
|
||||||
|
skipped += instances.len();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let texture = if options.load_model_textures {
|
||||||
|
resolve_texture_for_model(&key.archive_path, &loaded.name, None, None, None, None)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
models.push(SceneModel {
|
||||||
|
archive_path: key.archive_path,
|
||||||
|
model_name: loaded.name,
|
||||||
|
mesh,
|
||||||
|
texture,
|
||||||
|
instances,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
models.sort_by(|a, b| a.model_name.cmp(&b.model_name));
|
||||||
|
|
||||||
|
Ok(MissionScene {
|
||||||
|
game_root,
|
||||||
|
mission_path,
|
||||||
|
mission,
|
||||||
|
map_folder_rel,
|
||||||
|
land_msh_path,
|
||||||
|
terrain,
|
||||||
|
terrain_texture,
|
||||||
|
models,
|
||||||
|
skipped_objects: skipped,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_scene_bounds(scene: &MissionScene) -> Option<([f32; 3], [f32; 3])> {
|
||||||
|
let mut min_v = [f32::INFINITY; 3];
|
||||||
|
let mut max_v = [f32::NEG_INFINITY; 3];
|
||||||
|
let mut any = false;
|
||||||
|
|
||||||
|
for pos in &scene.terrain.positions {
|
||||||
|
merge_bounds(&mut min_v, &mut max_v, *pos);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for model in &scene.models {
|
||||||
|
for instance in &model.instances {
|
||||||
|
merge_bounds(&mut min_v, &mut max_v, instance.position);
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
any.then_some((min_v, max_v))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_bounds(min_v: &mut [f32; 3], max_v: &mut [f32; 3], p: [f32; 3]) {
|
||||||
|
for i in 0..3 {
|
||||||
|
if p[i] < min_v[i] {
|
||||||
|
min_v[i] = p[i];
|
||||||
|
}
|
||||||
|
if p[i] > max_v[i] {
|
||||||
|
max_v[i] = p[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_scale(scale: [f32; 3]) -> [f32; 3] {
|
||||||
|
let mut out = scale;
|
||||||
|
for item in &mut out {
|
||||||
|
if !item.is_finite() || item.abs() < 0.000_1 {
|
||||||
|
*item = 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_folder_from_footer(map_path: &str) -> Result<PathBuf> {
|
||||||
|
let mut parts = split_relative_path(map_path);
|
||||||
|
if parts.len() < 2 {
|
||||||
|
return Err(Error::InvalidMapPath(map_path.to_string()));
|
||||||
|
}
|
||||||
|
parts.pop(); // remove 'land'
|
||||||
|
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for part in parts {
|
||||||
|
out.push(part);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_object_prototype(
|
||||||
|
game_root: &Path,
|
||||||
|
object: &tma::MissionObject,
|
||||||
|
) -> Result<Option<ObjectPrototype>> {
|
||||||
|
if object.resource_name.to_ascii_lowercase().ends_with(".dat") {
|
||||||
|
let dat_path = game_root.join(pathbuf_from_rel(&object.resource_name));
|
||||||
|
if !dat_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = unitdat::parse_path(&dat_path)?;
|
||||||
|
let archive_path = game_root.join(pathbuf_from_rel(&parsed.archive_name));
|
||||||
|
if !archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return resolve_archive_model(game_root, &archive_path, &parsed.model_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let archive_path = game_root.join("objects.rlb");
|
||||||
|
if !archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
resolve_archive_model(game_root, &archive_path, &object.resource_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_archive_model(
|
||||||
|
game_root: &Path,
|
||||||
|
archive_path: &Path,
|
||||||
|
model_key: &str,
|
||||||
|
) -> Result<Option<ObjectPrototype>> {
|
||||||
|
if !archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_objects_registry_archive(archive_path) {
|
||||||
|
if let Some(proto) = resolve_objects_registry_model(game_root, archive_path, model_key)? {
|
||||||
|
return Ok(Some(proto));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_name = ensure_msh_suffix(model_key);
|
||||||
|
if !archive_has_mesh_entry(archive_path, &model_name)? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(ObjectPrototype {
|
||||||
|
archive_path: archive_path.to_path_buf(),
|
||||||
|
model_name: model_name.to_ascii_lowercase(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_objects_registry_archive(archive_path: &Path) -> bool {
|
||||||
|
archive_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case("objects.rlb"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_objects_registry_model(
|
||||||
|
game_root: &Path,
|
||||||
|
registry_archive_path: &Path,
|
||||||
|
object_key: &str,
|
||||||
|
) -> Result<Option<ObjectPrototype>> {
|
||||||
|
let archive = Archive::open_path(registry_archive_path)?;
|
||||||
|
let Some(entry_id) = find_registry_entry_id(&archive, object_key) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = archive.read(entry_id)?.into_owned();
|
||||||
|
let refs = parse_object_refs(&payload);
|
||||||
|
if refs.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in refs
|
||||||
|
.iter()
|
||||||
|
.filter(|item| has_extension(&item.resource_name, "msh"))
|
||||||
|
{
|
||||||
|
if let Some(proto) = resolve_object_ref_model(game_root, item, &item.resource_name)? {
|
||||||
|
return Ok(Some(proto));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in refs
|
||||||
|
.iter()
|
||||||
|
.filter(|item| has_extension(&item.resource_name, "bas"))
|
||||||
|
{
|
||||||
|
let Some(stem) = Path::new(&item.resource_name)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|stem| stem.to_str())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if stem.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let candidate = format!("{stem}.msh");
|
||||||
|
if let Some(proto) = resolve_object_ref_model(game_root, item, &candidate)? {
|
||||||
|
return Ok(Some(proto));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_registry_entry_id(archive: &Archive, object_key: &str) -> Option<nres::EntryId> {
|
||||||
|
mesh_name_candidates(object_key)
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|candidate| archive.find(&candidate))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_object_ref_model(
|
||||||
|
game_root: &Path,
|
||||||
|
item: &ObjectRef,
|
||||||
|
model_name: &str,
|
||||||
|
) -> Result<Option<ObjectPrototype>> {
|
||||||
|
let archive_path = game_root.join(pathbuf_from_rel(&item.archive_name));
|
||||||
|
if !archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if !archive_has_mesh_entry(&archive_path, model_name)? {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(ObjectPrototype {
|
||||||
|
archive_path,
|
||||||
|
model_name: model_name.to_ascii_lowercase(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_object_refs(payload: &[u8]) -> Vec<ObjectRef> {
|
||||||
|
if !payload.len().is_multiple_of(OBJECT_REF_STRIDE) {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut refs = Vec::with_capacity(payload.len() / OBJECT_REF_STRIDE);
|
||||||
|
for chunk in payload.chunks_exact(OBJECT_REF_STRIDE) {
|
||||||
|
let archive_name = decode_cp1251_cstr(&chunk[..OBJECT_REF_ARCHIVE_BYTES]);
|
||||||
|
let resource_name = decode_cp1251_cstr(&chunk[OBJECT_REF_ARCHIVE_BYTES..]);
|
||||||
|
if archive_name.is_empty() || resource_name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
refs.push(ObjectRef {
|
||||||
|
archive_name,
|
||||||
|
resource_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refs
|
||||||
|
}
|
||||||
|
|
||||||
|
fn archive_has_mesh_entry(archive_path: &Path, requested_name: &str) -> Result<bool> {
|
||||||
|
let archive = Archive::open_path(archive_path)?;
|
||||||
|
Ok(find_mesh_entry_id(&archive, requested_name).is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_mesh_entry_id(archive: &Archive, requested_name: &str) -> Option<nres::EntryId> {
|
||||||
|
for candidate in mesh_name_candidates(requested_name) {
|
||||||
|
let Some(id) = archive.find(&candidate) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(entry) = archive.get(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if entry.meta.kind == MESH_KIND || has_extension(&entry.meta.name, "msh") {
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mesh_name_candidates(name: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
push_unique_string(&mut out, trimmed.to_string());
|
||||||
|
if let Some(stem) = trimmed
|
||||||
|
.strip_suffix(".msh")
|
||||||
|
.or_else(|| trimmed.strip_suffix(".MSH"))
|
||||||
|
{
|
||||||
|
if !stem.is_empty() {
|
||||||
|
push_unique_string(&mut out, stem.to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
push_unique_string(&mut out, format!("{trimmed}.msh"));
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_unique_string(items: &mut Vec<String>, value: String) {
|
||||||
|
if !items.iter().any(|item| item.eq_ignore_ascii_case(&value)) {
|
||||||
|
items.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_msh_suffix(name: &str) -> String {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.to_ascii_lowercase().ends_with(".msh") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{trimmed}.msh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_extension(name: &str, ext: &str) -> bool {
|
||||||
|
Path::new(name)
|
||||||
|
.extension()
|
||||||
|
.and_then(|value| value.to_str())
|
||||||
|
.is_some_and(|value| value.eq_ignore_ascii_case(ext))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_terrain_texture(
|
||||||
|
game_root: &Path,
|
||||||
|
map_folder_rel: &Path,
|
||||||
|
) -> Result<Option<LoadedTexture>> {
|
||||||
|
let material_archive_path = game_root.join("material.lib");
|
||||||
|
let texture_archive_path = game_root.join("textures.lib");
|
||||||
|
if !material_archive_path.is_file() || !texture_archive_path.is_file() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
for wear_name in ["Land1.wea", "Land2.wea"] {
|
||||||
|
let wear_path = game_root.join(map_folder_rel).join(wear_name);
|
||||||
|
if !wear_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let wear_payload = fs::read(&wear_path)?;
|
||||||
|
let Some(material_name) = parse_primary_material_from_wear(&wear_payload) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(texture_name) =
|
||||||
|
resolve_texture_name_from_material_archive(&material_archive_path, &material_name)?
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Some(texture) = load_texm_by_name(&texture_archive_path, &texture_name)? {
|
||||||
|
return Ok(Some(texture));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_primary_material_from_wear(bytes: &[u8]) -> Option<String> {
|
||||||
|
let text = decode_cp1251(bytes).replace('\r', "");
|
||||||
|
let mut lines = text.lines();
|
||||||
|
let count = lines.next()?.trim().parse::<usize>().ok()?;
|
||||||
|
if count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
for line in lines.take(count) {
|
||||||
|
let mut parts = line.split_whitespace();
|
||||||
|
let _legacy = parts.next()?;
|
||||||
|
let name = parts.next()?;
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Some(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_texture_name_from_material_archive(
|
||||||
|
archive_path: &Path,
|
||||||
|
material_name: &str,
|
||||||
|
) -> Result<Option<String>> {
|
||||||
|
let archive = Archive::open_path(archive_path)?;
|
||||||
|
|
||||||
|
let entry = if let Some(id) = archive.find(material_name) {
|
||||||
|
archive
|
||||||
|
.get(id)
|
||||||
|
.filter(|entry| entry.meta.kind == MAT0_KIND)
|
||||||
|
.or_else(|| {
|
||||||
|
archive
|
||||||
|
.find("DEFAULT")
|
||||||
|
.and_then(|id| archive.get(id))
|
||||||
|
.filter(|entry| entry.meta.kind == MAT0_KIND)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
archive
|
||||||
|
.find("DEFAULT")
|
||||||
|
.and_then(|id| archive.get(id))
|
||||||
|
.filter(|entry| entry.meta.kind == MAT0_KIND)
|
||||||
|
}
|
||||||
|
.or_else(|| archive.entries().find(|entry| entry.meta.kind == MAT0_KIND));
|
||||||
|
|
||||||
|
let Some(entry) = entry else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = archive.read(entry.id)?.into_owned();
|
||||||
|
parse_primary_texture_name_from_mat0(&payload, entry.meta.attr2)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
|
||||||
|
if payload.len() < 4 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
|
||||||
|
if phase_count == 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut offset = 4usize;
|
||||||
|
if attr2 >= 2 {
|
||||||
|
offset = offset.saturating_add(2);
|
||||||
|
}
|
||||||
|
if attr2 >= 3 {
|
||||||
|
offset = offset.saturating_add(4);
|
||||||
|
}
|
||||||
|
if attr2 >= 4 {
|
||||||
|
offset = offset.saturating_add(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
for phase in 0..phase_count {
|
||||||
|
let phase_off = offset.saturating_add(phase.saturating_mul(34));
|
||||||
|
let Some(rec) = payload.get(phase_off..phase_off + 34) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let name_raw = &rec[18..34];
|
||||||
|
let end = name_raw
|
||||||
|
.iter()
|
||||||
|
.position(|&b| b == 0)
|
||||||
|
.unwrap_or(name_raw.len());
|
||||||
|
let name = decode_cp1251(&name_raw[..end]).trim().to_string();
|
||||||
|
if !name.is_empty() {
|
||||||
|
return Ok(Some(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_texm_by_name(archive_path: &Path, texture_name: &str) -> Result<Option<LoadedTexture>> {
|
||||||
|
let archive = Archive::open_path(archive_path)?;
|
||||||
|
let Some(id) = archive.find(texture_name) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(entry) = archive.get(id) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if entry.meta.kind != texm::TEXM_MAGIC {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = archive.read(id)?.into_owned();
|
||||||
|
let parsed = texm::parse_texm(&payload)?;
|
||||||
|
let decoded = texm::decode_mip_rgba8(&parsed, &payload, 0)?;
|
||||||
|
|
||||||
|
Ok(Some(LoadedTexture {
|
||||||
|
name: entry.meta.name.clone(),
|
||||||
|
width: decoded.width,
|
||||||
|
height: decoded.height,
|
||||||
|
rgba8: decoded.rgba8,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_relative_path(path: &str) -> Vec<&str> {
|
||||||
|
path.split(['\\', '/'])
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pathbuf_from_rel(path: &str) -> PathBuf {
|
||||||
|
let mut out = PathBuf::new();
|
||||||
|
for part in split_relative_path(path) {
|
||||||
|
out.push(part);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251_cstr(bytes: &[u8]) -> String {
|
||||||
|
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..end]);
|
||||||
|
decoded.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("Parkan - Iron Strategy");
|
||||||
|
root.is_dir().then_some(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_game_root_from_mission_path() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mission = root
|
||||||
|
.join("MISSIONS")
|
||||||
|
.join("CAMPAIGN")
|
||||||
|
.join("CAMPAIGN.00")
|
||||||
|
.join("Mission.01")
|
||||||
|
.join("data.tma");
|
||||||
|
if !mission.is_file() {
|
||||||
|
eprintln!("skipping missing mission sample");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detected = detect_game_root_from_mission_path(&mission)
|
||||||
|
.expect("failed to detect game root from mission path");
|
||||||
|
assert_eq!(detected, root);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_scene_cpu_without_textures() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mission = root
|
||||||
|
.join("MISSIONS")
|
||||||
|
.join("CAMPAIGN")
|
||||||
|
.join("CAMPAIGN.00")
|
||||||
|
.join("Mission.01")
|
||||||
|
.join("data.tma");
|
||||||
|
if !mission.is_file() {
|
||||||
|
eprintln!("skipping missing mission sample");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scene = load_scene_with_options(
|
||||||
|
&root,
|
||||||
|
&mission,
|
||||||
|
LoadOptions {
|
||||||
|
load_model_textures: false,
|
||||||
|
load_terrain_texture: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to load scene {}: {err}", mission.display()));
|
||||||
|
|
||||||
|
assert!(!scene.terrain.positions.is_empty());
|
||||||
|
assert!(!scene.terrain.faces.is_empty());
|
||||||
|
assert!(!scene.models.is_empty());
|
||||||
|
|
||||||
|
let instance_count = scene
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|model| model.instances.len())
|
||||||
|
.sum::<usize>();
|
||||||
|
assert!(instance_count >= 10);
|
||||||
|
|
||||||
|
let bounds = compute_scene_bounds(&scene).expect("scene bounds should exist");
|
||||||
|
assert!(bounds.0[0] <= bounds.1[0]);
|
||||||
|
assert!(bounds.0[1] <= bounds.1[1]);
|
||||||
|
assert!(bounds.0[2] <= bounds.1[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_scene_with_textures() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mission = root
|
||||||
|
.join("MISSIONS")
|
||||||
|
.join("CAMPAIGN")
|
||||||
|
.join("CAMPAIGN.00")
|
||||||
|
.join("Mission.01")
|
||||||
|
.join("data.tma");
|
||||||
|
if !mission.is_file() {
|
||||||
|
eprintln!("skipping missing mission sample");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scene = load_scene_with_options(&root, &mission, LoadOptions::default())
|
||||||
|
.unwrap_or_else(|err| panic!("failed to load textured scene {}: {err}", mission.display()));
|
||||||
|
|
||||||
|
assert!(!scene.models.is_empty());
|
||||||
|
let textured_models = scene.models.iter().filter(|model| model.texture.is_some()).count();
|
||||||
|
assert!(textured_models > 0, "no model textures resolved");
|
||||||
|
assert!(scene.terrain_texture.is_some(), "terrain texture was not resolved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolves_objects_registry_models() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let registry = root.join("objects.rlb");
|
||||||
|
if !registry.is_file() {
|
||||||
|
eprintln!("skipping missing objects.rlb");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cases = [
|
||||||
|
("r_h_01", "bases.rlb", "r_h_01.msh"),
|
||||||
|
("s_tree_04", "static.rlb", "s_tree_0_04.msh"),
|
||||||
|
("fr_m_brige", "fortif.rlb", "fr_m_brige.msh"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (key, archive_name, model_name) in cases {
|
||||||
|
let proto = resolve_objects_registry_model(&root, ®istry, key)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}"))
|
||||||
|
.unwrap_or_else(|| panic!("missing model resolution for '{key}'"));
|
||||||
|
|
||||||
|
let got_archive = proto
|
||||||
|
.archive_path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.map(|name| name.to_ascii_lowercase())
|
||||||
|
.unwrap_or_default();
|
||||||
|
assert_eq!(got_archive, archive_name.to_ascii_lowercase());
|
||||||
|
assert!(
|
||||||
|
proto.model_name.eq_ignore_ascii_case(model_name),
|
||||||
|
"unexpected model for key '{key}': got '{}', expected '{}'",
|
||||||
|
proto.model_name,
|
||||||
|
model_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
924
crates/render-mission-demo/src/main.rs
Normal file
924
crates/render-mission-demo/src/main.rs
Normal file
@@ -0,0 +1,924 @@
|
|||||||
|
use glow::HasContext as _;
|
||||||
|
use render_mission_demo::{
|
||||||
|
compute_scene_bounds, detect_game_root_from_mission_path, load_scene_with_options, LoadOptions,
|
||||||
|
MissionScene, ModelInstance,
|
||||||
|
};
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
mission: PathBuf,
|
||||||
|
game_root: Option<PathBuf>,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
fov_deg: f32,
|
||||||
|
no_model_texture: bool,
|
||||||
|
no_terrain_texture: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
enum GlBackend {
|
||||||
|
Gles2,
|
||||||
|
Core33,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GpuTexture {
|
||||||
|
handle: glow::NativeTexture,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GpuRenderable {
|
||||||
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
index_count: usize,
|
||||||
|
texture: Option<GpuTexture>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ModelRenderable {
|
||||||
|
gpu: GpuRenderable,
|
||||||
|
instances: Vec<ModelInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
struct Camera {
|
||||||
|
position: [f32; 3],
|
||||||
|
yaw: f32,
|
||||||
|
pitch: f32,
|
||||||
|
move_speed: f32,
|
||||||
|
mouse_sensitivity: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args() -> Result<Args, String> {
|
||||||
|
let mut mission = None;
|
||||||
|
let mut game_root = None;
|
||||||
|
let mut width = 1600u32;
|
||||||
|
let mut height = 900u32;
|
||||||
|
let mut fov_deg = 60.0f32;
|
||||||
|
let mut no_model_texture = false;
|
||||||
|
let mut no_terrain_texture = false;
|
||||||
|
|
||||||
|
let mut it = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = it.next() {
|
||||||
|
match arg.as_str() {
|
||||||
|
"--mission" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --mission"))?;
|
||||||
|
mission = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--game-root" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --game-root"))?;
|
||||||
|
game_root = Some(PathBuf::from(value));
|
||||||
|
}
|
||||||
|
"--width" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --width"))?;
|
||||||
|
width = value
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| String::from("invalid --width value"))?;
|
||||||
|
if width == 0 {
|
||||||
|
return Err(String::from("--width must be > 0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--height" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --height"))?;
|
||||||
|
height = value
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| String::from("invalid --height value"))?;
|
||||||
|
if height == 0 {
|
||||||
|
return Err(String::from("--height must be > 0"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--fov" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --fov"))?;
|
||||||
|
fov_deg = value
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| String::from("invalid --fov value"))?;
|
||||||
|
if !(1.0..=179.0).contains(&fov_deg) {
|
||||||
|
return Err(String::from("--fov must be in range [1, 179]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"--no-model-texture" => {
|
||||||
|
no_model_texture = true;
|
||||||
|
}
|
||||||
|
"--no-terrain-texture" => {
|
||||||
|
no_terrain_texture = true;
|
||||||
|
}
|
||||||
|
"--help" | "-h" => {
|
||||||
|
print_help();
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
return Err(format!("unknown argument: {other}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mission = mission.ok_or_else(|| String::from("missing required --mission"))?;
|
||||||
|
Ok(Args {
|
||||||
|
mission,
|
||||||
|
game_root,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fov_deg,
|
||||||
|
no_model_texture,
|
||||||
|
no_terrain_texture,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_help() {
|
||||||
|
eprintln!("parkan-render-mission-demo --mission <path/to/data.tma> [--game-root <path>] [--width W] [--height H] [--fov DEG]");
|
||||||
|
eprintln!(" [--no-model-texture] [--no-terrain-texture]");
|
||||||
|
eprintln!("controls: arrows/WASD move, PageUp/PageDown vertical move, Right Mouse drag look, Shift speed-up, Esc exit");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = match parse_args() {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{err}");
|
||||||
|
print_help();
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = run(args) {
|
||||||
|
eprintln!("{err}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: Args) -> Result<(), String> {
|
||||||
|
let game_root = if let Some(path) = args.game_root.clone() {
|
||||||
|
path
|
||||||
|
} else {
|
||||||
|
detect_game_root_from_mission_path(&args.mission).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"failed to detect game root from mission path {} (use --game-root)",
|
||||||
|
args.mission.display()
|
||||||
|
)
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let scene = load_scene_with_options(
|
||||||
|
&game_root,
|
||||||
|
&args.mission,
|
||||||
|
LoadOptions {
|
||||||
|
load_model_textures: !args.no_model_texture,
|
||||||
|
load_terrain_texture: !args.no_terrain_texture,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|err| format!("failed to load mission scene: {err}"))?;
|
||||||
|
|
||||||
|
let terrain_mesh = terrain_core::build_render_mesh(&scene.terrain)
|
||||||
|
.map_err(|err| format!("failed to build terrain render mesh: {err}"))?;
|
||||||
|
|
||||||
|
let instance_count = scene
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|model| model.instances.len())
|
||||||
|
.sum::<usize>();
|
||||||
|
println!(
|
||||||
|
"mission loaded: map='{}', terrain_vertices={}, terrain_faces={}, models={}, instances={}, skipped={}",
|
||||||
|
scene.mission.footer.map_path,
|
||||||
|
scene.terrain.positions.len(),
|
||||||
|
scene.terrain.faces.len(),
|
||||||
|
scene.models.len(),
|
||||||
|
instance_count,
|
||||||
|
scene.skipped_objects
|
||||||
|
);
|
||||||
|
|
||||||
|
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
|
||||||
|
let video = sdl
|
||||||
|
.video()
|
||||||
|
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
|
||||||
|
|
||||||
|
let (mut window, _gl_ctx, gl_backend) =
|
||||||
|
create_window_and_context(&video, args.width, args.height)?;
|
||||||
|
let _ = video.gl_set_swap_interval(1);
|
||||||
|
|
||||||
|
let gl = unsafe {
|
||||||
|
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
|
||||||
|
};
|
||||||
|
|
||||||
|
let program = unsafe { create_program(&gl, gl_backend)? };
|
||||||
|
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
||||||
|
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
|
||||||
|
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
|
||||||
|
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
|
||||||
|
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
|
||||||
|
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
|
||||||
|
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
|
||||||
|
|
||||||
|
let terrain_gpu =
|
||||||
|
unsafe { upload_terrain_renderable(&gl, &terrain_mesh, scene.terrain_texture.as_ref())? };
|
||||||
|
|
||||||
|
let mut model_gpus = Vec::new();
|
||||||
|
for model in &scene.models {
|
||||||
|
let renderable = unsafe { upload_model_renderable(&gl, model)? };
|
||||||
|
model_gpus.push(renderable);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (scene_center, scene_radius) = initial_scene_sphere(&scene);
|
||||||
|
let mut camera = Camera {
|
||||||
|
position: [
|
||||||
|
scene_center[0],
|
||||||
|
scene_center[1] + scene_radius * 0.6,
|
||||||
|
scene_center[2] + scene_radius * 1.4,
|
||||||
|
],
|
||||||
|
yaw: std::f32::consts::PI,
|
||||||
|
pitch: -0.28,
|
||||||
|
move_speed: (scene_radius * 0.55).max(60.0),
|
||||||
|
mouse_sensitivity: 0.005,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut events = sdl
|
||||||
|
.event_pump()
|
||||||
|
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
|
||||||
|
let mut last = Instant::now();
|
||||||
|
let mut fps_window_start = Instant::now();
|
||||||
|
let mut fps_frames = 0u32;
|
||||||
|
let mut fps_printed = false;
|
||||||
|
let mut mouse_look = false;
|
||||||
|
|
||||||
|
'main_loop: loop {
|
||||||
|
for event in events.poll_iter() {
|
||||||
|
match event {
|
||||||
|
sdl2::event::Event::Quit { .. } => break 'main_loop,
|
||||||
|
sdl2::event::Event::KeyDown {
|
||||||
|
keycode: Some(sdl2::keyboard::Keycode::Escape),
|
||||||
|
..
|
||||||
|
} => break 'main_loop,
|
||||||
|
sdl2::event::Event::MouseButtonDown {
|
||||||
|
mouse_btn: sdl2::mouse::MouseButton::Right,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
mouse_look = true;
|
||||||
|
sdl.mouse().set_relative_mouse_mode(true);
|
||||||
|
}
|
||||||
|
sdl2::event::Event::MouseButtonUp {
|
||||||
|
mouse_btn: sdl2::mouse::MouseButton::Right,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
mouse_look = false;
|
||||||
|
sdl.mouse().set_relative_mouse_mode(false);
|
||||||
|
}
|
||||||
|
sdl2::event::Event::MouseMotion { xrel, yrel, .. } if mouse_look => {
|
||||||
|
camera.yaw += xrel as f32 * camera.mouse_sensitivity;
|
||||||
|
camera.pitch -= yrel as f32 * camera.mouse_sensitivity;
|
||||||
|
camera.pitch = camera.pitch.clamp(-1.54, 1.54);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Instant::now();
|
||||||
|
let dt = (now - last).as_secs_f32().clamp(0.0, 0.05);
|
||||||
|
last = now;
|
||||||
|
|
||||||
|
update_camera(&events, &mut camera, dt);
|
||||||
|
|
||||||
|
let (w, h) = window.size();
|
||||||
|
let proj = mat4_perspective(
|
||||||
|
args.fov_deg.to_radians(),
|
||||||
|
(w as f32 / h.max(1) as f32).max(0.01),
|
||||||
|
0.1,
|
||||||
|
(scene_radius * 25.0).max(5000.0),
|
||||||
|
);
|
||||||
|
let forward = camera_forward(camera.yaw, camera.pitch);
|
||||||
|
let view = mat4_look_at(
|
||||||
|
camera.position,
|
||||||
|
[
|
||||||
|
camera.position[0] + forward[0],
|
||||||
|
camera.position[1] + forward[1],
|
||||||
|
camera.position[2] + forward[2],
|
||||||
|
],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
draw_frame_begin(&gl, w, h);
|
||||||
|
|
||||||
|
let terrain_mvp = mat4_mul(&proj, &view);
|
||||||
|
draw_gpu_renderable(
|
||||||
|
&gl,
|
||||||
|
program,
|
||||||
|
u_mvp.as_ref(),
|
||||||
|
u_use_tex.as_ref(),
|
||||||
|
u_tex.as_ref(),
|
||||||
|
a_pos,
|
||||||
|
a_uv,
|
||||||
|
&terrain_gpu,
|
||||||
|
&terrain_mvp,
|
||||||
|
);
|
||||||
|
|
||||||
|
for model in &model_gpus {
|
||||||
|
for instance in &model.instances {
|
||||||
|
let model_m = model_matrix(instance.position, instance.yaw_rad, instance.scale);
|
||||||
|
let view_model = mat4_mul(&view, &model_m);
|
||||||
|
let mvp = mat4_mul(&proj, &view_model);
|
||||||
|
draw_gpu_renderable(
|
||||||
|
&gl,
|
||||||
|
program,
|
||||||
|
u_mvp.as_ref(),
|
||||||
|
u_use_tex.as_ref(),
|
||||||
|
u_tex.as_ref(),
|
||||||
|
a_pos,
|
||||||
|
a_uv,
|
||||||
|
&model.gpu,
|
||||||
|
&mvp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.gl_swap_window();
|
||||||
|
|
||||||
|
fps_frames = fps_frames.saturating_add(1);
|
||||||
|
let elapsed = fps_window_start.elapsed();
|
||||||
|
if elapsed >= Duration::from_millis(500) {
|
||||||
|
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
|
||||||
|
let frame_time_ms = 1000.0 / fps.max(0.000_1);
|
||||||
|
let _ = window.set_title(&format!(
|
||||||
|
"Parkan Mission Demo | FPS: {fps:.1} ({frame_time_ms:.2} ms) | objects: {instance_count}"
|
||||||
|
));
|
||||||
|
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
fps_printed = true;
|
||||||
|
fps_frames = 0;
|
||||||
|
fps_window_start = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fps_printed {
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
cleanup_renderable(&gl, terrain_gpu);
|
||||||
|
for model in model_gpus {
|
||||||
|
cleanup_renderable(&gl, model.gpu);
|
||||||
|
}
|
||||||
|
gl.delete_program(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initial_scene_sphere(scene: &MissionScene) -> ([f32; 3], f32) {
|
||||||
|
if let Some((min_v, max_v)) = compute_scene_bounds(scene) {
|
||||||
|
let center = [
|
||||||
|
0.5 * (min_v[0] + max_v[0]),
|
||||||
|
0.5 * (min_v[1] + max_v[1]),
|
||||||
|
0.5 * (min_v[2] + max_v[2]),
|
||||||
|
];
|
||||||
|
let extent = [
|
||||||
|
max_v[0] - min_v[0],
|
||||||
|
max_v[1] - min_v[1],
|
||||||
|
max_v[2] - min_v[2],
|
||||||
|
];
|
||||||
|
let radius = ((extent[0] * extent[0]) + (extent[1] * extent[1]) + (extent[2] * extent[2]))
|
||||||
|
.sqrt()
|
||||||
|
.max(10.0)
|
||||||
|
* 0.5;
|
||||||
|
return (center, radius);
|
||||||
|
}
|
||||||
|
([0.0, 0.0, 0.0], 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_camera(events: &sdl2::EventPump, camera: &mut Camera, dt: f32) {
|
||||||
|
use sdl2::keyboard::Scancode;
|
||||||
|
|
||||||
|
let keys = events.keyboard_state();
|
||||||
|
let mut move_dir = [0.0f32, 0.0f32, 0.0f32];
|
||||||
|
|
||||||
|
let forward = camera_forward(camera.yaw, camera.pitch);
|
||||||
|
let right = normalize3(cross3(forward, [0.0, 1.0, 0.0]));
|
||||||
|
|
||||||
|
if keys.is_scancode_pressed(Scancode::Up) || keys.is_scancode_pressed(Scancode::W) {
|
||||||
|
move_dir[0] += forward[0];
|
||||||
|
move_dir[1] += forward[1];
|
||||||
|
move_dir[2] += forward[2];
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::Down) || keys.is_scancode_pressed(Scancode::S) {
|
||||||
|
move_dir[0] -= forward[0];
|
||||||
|
move_dir[1] -= forward[1];
|
||||||
|
move_dir[2] -= forward[2];
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::Left) || keys.is_scancode_pressed(Scancode::A) {
|
||||||
|
move_dir[0] -= right[0];
|
||||||
|
move_dir[1] -= right[1];
|
||||||
|
move_dir[2] -= right[2];
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::Right) || keys.is_scancode_pressed(Scancode::D) {
|
||||||
|
move_dir[0] += right[0];
|
||||||
|
move_dir[1] += right[1];
|
||||||
|
move_dir[2] += right[2];
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::PageUp) || keys.is_scancode_pressed(Scancode::E) {
|
||||||
|
move_dir[1] += 1.0;
|
||||||
|
}
|
||||||
|
if keys.is_scancode_pressed(Scancode::PageDown) || keys.is_scancode_pressed(Scancode::Q) {
|
||||||
|
move_dir[1] -= 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shift =
|
||||||
|
keys.is_scancode_pressed(Scancode::LShift) || keys.is_scancode_pressed(Scancode::RShift);
|
||||||
|
let speed_mul = if shift { 3.0 } else { 1.0 };
|
||||||
|
|
||||||
|
let norm = normalize3(move_dir);
|
||||||
|
camera.position[0] += norm[0] * camera.move_speed * speed_mul * dt;
|
||||||
|
camera.position[1] += norm[1] * camera.move_speed * speed_mul * dt;
|
||||||
|
camera.position[2] += norm[2] * camera.move_speed * speed_mul * dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn upload_model_renderable(
|
||||||
|
gl: &glow::Context,
|
||||||
|
model: &render_mission_demo::SceneModel,
|
||||||
|
) -> Result<ModelRenderable, String> {
|
||||||
|
let mut vertex_data = Vec::with_capacity(model.mesh.vertices.len() * 5);
|
||||||
|
for vertex in &model.mesh.vertices {
|
||||||
|
vertex_data.push(vertex.position[0]);
|
||||||
|
vertex_data.push(vertex.position[1]);
|
||||||
|
vertex_data.push(vertex.position[2]);
|
||||||
|
vertex_data.push(vertex.uv0[0]);
|
||||||
|
vertex_data.push(vertex.uv0[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let gpu = upload_gpu_renderable(
|
||||||
|
gl,
|
||||||
|
&vertex_data,
|
||||||
|
&model.mesh.indices,
|
||||||
|
model.texture.as_ref(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(ModelRenderable {
|
||||||
|
gpu,
|
||||||
|
instances: model.instances.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn upload_terrain_renderable(
|
||||||
|
gl: &glow::Context,
|
||||||
|
mesh: &terrain_core::TerrainRenderMesh,
|
||||||
|
texture: Option<&render_demo::LoadedTexture>,
|
||||||
|
) -> Result<GpuRenderable, String> {
|
||||||
|
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
|
||||||
|
for vertex in &mesh.vertices {
|
||||||
|
vertex_data.push(vertex.position[0]);
|
||||||
|
vertex_data.push(vertex.position[1]);
|
||||||
|
vertex_data.push(vertex.position[2]);
|
||||||
|
vertex_data.push(vertex.uv0[0]);
|
||||||
|
vertex_data.push(vertex.uv0[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_gpu_renderable(gl, &vertex_data, &mesh.indices, texture)
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn upload_gpu_renderable(
|
||||||
|
gl: &glow::Context,
|
||||||
|
vertices: &[f32],
|
||||||
|
indices: &[u16],
|
||||||
|
texture: Option<&render_demo::LoadedTexture>,
|
||||||
|
) -> Result<GpuRenderable, String> {
|
||||||
|
let vbo = gl.create_buffer().map_err(|e| e.to_string())?;
|
||||||
|
let ebo = gl.create_buffer().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let vertex_bytes = f32_slice_to_ne_bytes(vertices);
|
||||||
|
let index_bytes = u16_slice_to_ne_bytes(indices);
|
||||||
|
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
|
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||||
|
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
|
||||||
|
let gpu_texture = if let Some(texture) = texture {
|
||||||
|
Some(create_texture(gl, texture)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(GpuRenderable {
|
||||||
|
vbo,
|
||||||
|
ebo,
|
||||||
|
index_count: indices.len(),
|
||||||
|
texture: gpu_texture,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn cleanup_renderable(gl: &glow::Context, renderable: GpuRenderable) {
|
||||||
|
if let Some(tex) = renderable.texture {
|
||||||
|
gl.delete_texture(tex.handle);
|
||||||
|
}
|
||||||
|
gl.delete_buffer(renderable.ebo);
|
||||||
|
gl.delete_buffer(renderable.vbo);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn draw_frame_begin(gl: &glow::Context, width: u32, height: u32) {
|
||||||
|
gl.viewport(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width.min(i32::MAX as u32) as i32,
|
||||||
|
height.min(i32::MAX as u32) as i32,
|
||||||
|
);
|
||||||
|
gl.enable(glow::DEPTH_TEST);
|
||||||
|
gl.clear_color(0.06, 0.08, 0.12, 1.0);
|
||||||
|
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn draw_gpu_renderable(
|
||||||
|
gl: &glow::Context,
|
||||||
|
program: glow::NativeProgram,
|
||||||
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_use_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
u_tex: Option<&glow::NativeUniformLocation>,
|
||||||
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
|
renderable: &GpuRenderable,
|
||||||
|
mvp: &[f32; 16],
|
||||||
|
) {
|
||||||
|
gl.use_program(Some(program));
|
||||||
|
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
|
||||||
|
|
||||||
|
let texture_enabled = renderable.texture.is_some();
|
||||||
|
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
|
||||||
|
|
||||||
|
if let Some(tex) = &renderable.texture {
|
||||||
|
gl.active_texture(glow::TEXTURE0);
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
|
||||||
|
gl.uniform_1_i32(u_tex, 0);
|
||||||
|
} else {
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(renderable.vbo));
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(renderable.ebo));
|
||||||
|
gl.enable_vertex_attrib_array(a_pos);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
||||||
|
gl.enable_vertex_attrib_array(a_uv);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
||||||
|
|
||||||
|
gl.draw_elements(
|
||||||
|
glow::TRIANGLES,
|
||||||
|
renderable.index_count.min(i32::MAX as usize) as i32,
|
||||||
|
glow::UNSIGNED_SHORT,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
gl.disable_vertex_attrib_array(a_uv);
|
||||||
|
gl.disable_vertex_attrib_array(a_pos);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
|
gl.use_program(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_window_and_context(
|
||||||
|
video: &sdl2::VideoSubsystem,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
|
||||||
|
let candidates = [
|
||||||
|
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
|
||||||
|
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
|
||||||
|
];
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for (backend, profile, major, minor) in candidates {
|
||||||
|
{
|
||||||
|
let gl_attr = video.gl_attr();
|
||||||
|
gl_attr.set_context_profile(profile);
|
||||||
|
gl_attr.set_context_version(major, minor);
|
||||||
|
gl_attr.set_depth_size(24);
|
||||||
|
gl_attr.set_double_buffer(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut window_builder = video.window("Parkan Mission Demo", width, height);
|
||||||
|
window_builder.opengl().resizable();
|
||||||
|
|
||||||
|
let window = match window_builder.build() {
|
||||||
|
Ok(window) => window,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: window build failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let gl_ctx = match window.gl_create_context() {
|
||||||
|
Ok(ctx) => ctx,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: context create failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = window.gl_make_current(&gl_ctx) {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: make current failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok((window, gl_ctx, backend));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"failed to create OpenGL context. Attempts: {}",
|
||||||
|
errors.join(" | ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn create_texture(
|
||||||
|
gl: &glow::Context,
|
||||||
|
texture: &render_demo::LoadedTexture,
|
||||||
|
) -> Result<GpuTexture, String> {
|
||||||
|
let handle = gl.create_texture().map_err(|e| e.to_string())?;
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_MIN_FILTER,
|
||||||
|
glow::LINEAR as i32,
|
||||||
|
);
|
||||||
|
gl.tex_parameter_i32(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
glow::TEXTURE_MAG_FILTER,
|
||||||
|
glow::LINEAR as i32,
|
||||||
|
);
|
||||||
|
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
|
||||||
|
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
|
||||||
|
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
|
||||||
|
gl.tex_image_2d(
|
||||||
|
glow::TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
glow::RGBA as i32,
|
||||||
|
texture.width.min(i32::MAX as u32) as i32,
|
||||||
|
texture.height.min(i32::MAX as u32) as i32,
|
||||||
|
0,
|
||||||
|
glow::RGBA,
|
||||||
|
glow::UNSIGNED_BYTE,
|
||||||
|
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
|
||||||
|
);
|
||||||
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
|
Ok(GpuTexture { handle })
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn create_program(
|
||||||
|
gl: &glow::Context,
|
||||||
|
backend: GlBackend,
|
||||||
|
) -> Result<glow::NativeProgram, String> {
|
||||||
|
let (vs_src, fs_src) = match backend {
|
||||||
|
GlBackend::Gles2 => (
|
||||||
|
r#"
|
||||||
|
attribute vec3 a_pos;
|
||||||
|
attribute vec2 a_uv;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
v_uv = a_uv;
|
||||||
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
r#"
|
||||||
|
precision mediump float;
|
||||||
|
uniform sampler2D u_tex;
|
||||||
|
uniform float u_use_tex;
|
||||||
|
varying vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
|
||||||
|
vec4 texColor = texture2D(u_tex, v_uv);
|
||||||
|
gl_FragColor = mix(base, texColor, u_use_tex);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
GlBackend::Core33 => (
|
||||||
|
r#"#version 330 core
|
||||||
|
in vec3 a_pos;
|
||||||
|
in vec2 a_uv;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
out vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
v_uv = a_uv;
|
||||||
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
r#"#version 330 core
|
||||||
|
uniform sampler2D u_tex;
|
||||||
|
uniform float u_use_tex;
|
||||||
|
in vec2 v_uv;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
|
||||||
|
vec4 texColor = texture(u_tex, v_uv);
|
||||||
|
fragColor = mix(base, texColor, u_use_tex);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let program = gl.create_program().map_err(|e| e.to_string())?;
|
||||||
|
let vs = gl
|
||||||
|
.create_shader(glow::VERTEX_SHADER)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let fs = gl
|
||||||
|
.create_shader(glow::FRAGMENT_SHADER)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
gl.shader_source(vs, vs_src);
|
||||||
|
gl.compile_shader(vs);
|
||||||
|
if !gl.get_shader_compile_status(vs) {
|
||||||
|
let log = gl.get_shader_info_log(vs);
|
||||||
|
gl.delete_shader(vs);
|
||||||
|
gl.delete_shader(fs);
|
||||||
|
gl.delete_program(program);
|
||||||
|
return Err(format!("vertex shader compile failed: {log}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.shader_source(fs, fs_src);
|
||||||
|
gl.compile_shader(fs);
|
||||||
|
if !gl.get_shader_compile_status(fs) {
|
||||||
|
let log = gl.get_shader_info_log(fs);
|
||||||
|
gl.delete_shader(vs);
|
||||||
|
gl.delete_shader(fs);
|
||||||
|
gl.delete_program(program);
|
||||||
|
return Err(format!("fragment shader compile failed: {log}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.attach_shader(program, vs);
|
||||||
|
gl.attach_shader(program, fs);
|
||||||
|
gl.link_program(program);
|
||||||
|
|
||||||
|
gl.detach_shader(program, vs);
|
||||||
|
gl.detach_shader(program, fs);
|
||||||
|
gl.delete_shader(vs);
|
||||||
|
gl.delete_shader(fs);
|
||||||
|
|
||||||
|
if !gl.get_program_link_status(program) {
|
||||||
|
let log = gl.get_program_info_log(program);
|
||||||
|
gl.delete_program(program);
|
||||||
|
return Err(format!("program link failed: {log}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(program)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_matrix(position: [f32; 3], yaw: f32, scale: [f32; 3]) -> [f32; 16] {
|
||||||
|
let translation = mat4_translation(position[0], position[1], position[2]);
|
||||||
|
let rotation = mat4_rotation_y(yaw);
|
||||||
|
let scaling = mat4_scale(scale[0], scale[1], scale[2]);
|
||||||
|
let tr = mat4_mul(&translation, &rotation);
|
||||||
|
mat4_mul(&tr, &scaling)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn camera_forward(yaw: f32, pitch: f32) -> [f32; 3] {
|
||||||
|
let cp = pitch.cos();
|
||||||
|
normalize3([yaw.sin() * cp, pitch.sin(), yaw.cos() * cp])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||||
|
[
|
||||||
|
a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[2] * b[0] - a[0] * b[2],
|
||||||
|
a[0] * b[1] - a[1] * b[0],
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||||
|
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize3(v: [f32; 3]) -> [f32; 3] {
|
||||||
|
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||||
|
if len <= 1e-6 {
|
||||||
|
[0.0, 0.0, 0.0]
|
||||||
|
} else {
|
||||||
|
[v[0] / len, v[1] / len, v[2] / len]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_identity() -> [f32; 16] {
|
||||||
|
[
|
||||||
|
1.0, 0.0, 0.0, 0.0, //
|
||||||
|
0.0, 1.0, 0.0, 0.0, //
|
||||||
|
0.0, 0.0, 1.0, 0.0, //
|
||||||
|
0.0, 0.0, 0.0, 1.0, //
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
|
||||||
|
let mut m = mat4_identity();
|
||||||
|
m[12] = x;
|
||||||
|
m[13] = y;
|
||||||
|
m[14] = z;
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_scale(x: f32, y: f32, z: f32) -> [f32; 16] {
|
||||||
|
[
|
||||||
|
x, 0.0, 0.0, 0.0, //
|
||||||
|
0.0, y, 0.0, 0.0, //
|
||||||
|
0.0, 0.0, z, 0.0, //
|
||||||
|
0.0, 0.0, 0.0, 1.0, //
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
|
||||||
|
let c = rad.cos();
|
||||||
|
let s = rad.sin();
|
||||||
|
[
|
||||||
|
c, 0.0, -s, 0.0, //
|
||||||
|
0.0, 1.0, 0.0, 0.0, //
|
||||||
|
s, 0.0, c, 0.0, //
|
||||||
|
0.0, 0.0, 0.0, 1.0, //
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
|
||||||
|
let f = 1.0 / (0.5 * fovy).tan();
|
||||||
|
let nf = 1.0 / (near - far);
|
||||||
|
[
|
||||||
|
f / aspect,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
f,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
(far + near) * nf,
|
||||||
|
-1.0,
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
(2.0 * far * near) * nf,
|
||||||
|
0.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [f32; 16] {
|
||||||
|
let f = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
|
||||||
|
let s = normalize3(cross3(f, up));
|
||||||
|
let u = cross3(s, f);
|
||||||
|
|
||||||
|
[
|
||||||
|
s[0],
|
||||||
|
u[0],
|
||||||
|
-f[0],
|
||||||
|
0.0,
|
||||||
|
s[1],
|
||||||
|
u[1],
|
||||||
|
-f[1],
|
||||||
|
0.0,
|
||||||
|
s[2],
|
||||||
|
u[2],
|
||||||
|
-f[2],
|
||||||
|
0.0,
|
||||||
|
-dot3(s, eye),
|
||||||
|
-dot3(u, eye),
|
||||||
|
dot3(f, eye),
|
||||||
|
1.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
|
||||||
|
let mut out = [0.0f32; 16];
|
||||||
|
for c in 0..4 {
|
||||||
|
for r in 0..4 {
|
||||||
|
let mut acc = 0.0f32;
|
||||||
|
for k in 0..4 {
|
||||||
|
acc += a[k * 4 + r] * b[c * 4 + k];
|
||||||
|
}
|
||||||
|
out[c * 4 + r] = acc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
|
||||||
|
for &value in slice {
|
||||||
|
out.extend_from_slice(&value.to_ne_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
|
||||||
|
for &value in slice {
|
||||||
|
out.extend_from_slice(&value.to_ne_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,6 +129,14 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -120,6 +151,20 @@ impl Library {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
if self.entries.is_empty() {
|
if self.entries.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -187,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)?;
|
||||||
@@ -284,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)
|
||||||
@@ -304,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];
|
||||||
@@ -337,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]]);
|
||||||
@@ -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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
10
crates/terrain-core/Cargo.toml
Normal file
10
crates/terrain-core/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "terrain-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nres = { path = "../nres" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
281
crates/terrain-core/src/lib.rs
Normal file
281
crates/terrain-core/src/lib.rs
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
use nres::Archive;
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub const TERRAIN_UV_SCALE: f32 = 1024.0;
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Nres(nres::error::Error),
|
||||||
|
MissingChunk(&'static str),
|
||||||
|
InvalidChunkSize {
|
||||||
|
label: &'static str,
|
||||||
|
size: usize,
|
||||||
|
stride: usize,
|
||||||
|
},
|
||||||
|
VertexCountOverflow {
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Nres(err) => write!(f, "{err}"),
|
||||||
|
Self::MissingChunk(label) => write!(f, "missing required terrain chunk: {label}"),
|
||||||
|
Self::InvalidChunkSize {
|
||||||
|
label,
|
||||||
|
size,
|
||||||
|
stride,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"invalid chunk size for {label}: {size} (must be divisible by {stride})"
|
||||||
|
),
|
||||||
|
Self::VertexCountOverflow { count } => {
|
||||||
|
write!(f, "terrain vertex count {count} exceeds u16 range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Nres(err) => Some(err),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<nres::error::Error> for Error {
|
||||||
|
fn from(value: nres::error::Error) -> Self {
|
||||||
|
Self::Nres(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TerrainMesh {
|
||||||
|
pub positions: Vec<[f32; 3]>,
|
||||||
|
pub uv0: Vec<[f32; 2]>,
|
||||||
|
pub faces: Vec<TerrainFace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct TerrainFace {
|
||||||
|
pub indices: [u16; 3],
|
||||||
|
pub flags: u32,
|
||||||
|
pub material_tag: u16,
|
||||||
|
pub aux_tag: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct TerrainRenderMesh {
|
||||||
|
pub vertices: Vec<TerrainRenderVertex>,
|
||||||
|
pub indices: Vec<u16>,
|
||||||
|
pub face_count_raw: usize,
|
||||||
|
pub face_count_kept: usize,
|
||||||
|
pub face_count_dropped_invalid: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct TerrainRenderVertex {
|
||||||
|
pub position: [f32; 3],
|
||||||
|
pub uv0: [f32; 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_land_mesh(path: impl AsRef<Path>) -> Result<TerrainMesh> {
|
||||||
|
let archive = Archive::open_path(path.as_ref())?;
|
||||||
|
|
||||||
|
let positions_entry = archive
|
||||||
|
.entries()
|
||||||
|
.find(|entry| entry.meta.kind == 3)
|
||||||
|
.ok_or(Error::MissingChunk("type=3 (positions)"))?;
|
||||||
|
let uv_entry = archive.entries().find(|entry| entry.meta.kind == 5);
|
||||||
|
let faces_entry = archive
|
||||||
|
.entries()
|
||||||
|
.find(|entry| entry.meta.kind == 21)
|
||||||
|
.ok_or(Error::MissingChunk("type=21 (faces)"))?;
|
||||||
|
|
||||||
|
let positions_payload = archive.read(positions_entry.id)?.into_owned();
|
||||||
|
if positions_payload.len() % 12 != 0 {
|
||||||
|
return Err(Error::InvalidChunkSize {
|
||||||
|
label: "type=3 (positions)",
|
||||||
|
size: positions_payload.len(),
|
||||||
|
stride: 12,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut positions = Vec::with_capacity(positions_payload.len() / 12);
|
||||||
|
for chunk in positions_payload.chunks_exact(12) {
|
||||||
|
let x = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
|
||||||
|
let y = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4]));
|
||||||
|
let z = f32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4]));
|
||||||
|
positions.push([x, y, z]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut uv0 = vec![[0.0f32, 0.0f32]; positions.len()];
|
||||||
|
if let Some(uv_entry) = uv_entry {
|
||||||
|
let uv_payload = archive.read(uv_entry.id)?.into_owned();
|
||||||
|
if uv_payload.len() % 4 != 0 {
|
||||||
|
return Err(Error::InvalidChunkSize {
|
||||||
|
label: "type=5 (uv)",
|
||||||
|
size: uv_payload.len(),
|
||||||
|
stride: 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let uv_count = uv_payload.len() / 4;
|
||||||
|
for idx in 0..uv_count.min(uv0.len()) {
|
||||||
|
let off = idx * 4;
|
||||||
|
let u = i16::from_le_bytes([uv_payload[off], uv_payload[off + 1]]) as f32;
|
||||||
|
let v = i16::from_le_bytes([uv_payload[off + 2], uv_payload[off + 3]]) as f32;
|
||||||
|
uv0[idx] = [u / TERRAIN_UV_SCALE, v / TERRAIN_UV_SCALE];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let face_payload = archive.read(faces_entry.id)?.into_owned();
|
||||||
|
if face_payload.len() % 28 != 0 {
|
||||||
|
return Err(Error::InvalidChunkSize {
|
||||||
|
label: "type=21 (faces)",
|
||||||
|
size: face_payload.len(),
|
||||||
|
stride: 28,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut faces = Vec::with_capacity(face_payload.len() / 28);
|
||||||
|
for chunk in face_payload.chunks_exact(28) {
|
||||||
|
let flags = u32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
|
||||||
|
let material_tag = u16::from_le_bytes(chunk[4..6].try_into().unwrap_or([0; 2]));
|
||||||
|
let aux_tag = u16::from_le_bytes(chunk[6..8].try_into().unwrap_or([0; 2]));
|
||||||
|
let i0 = u16::from_le_bytes(chunk[8..10].try_into().unwrap_or([0; 2]));
|
||||||
|
let i1 = u16::from_le_bytes(chunk[10..12].try_into().unwrap_or([0; 2]));
|
||||||
|
let i2 = u16::from_le_bytes(chunk[12..14].try_into().unwrap_or([0; 2]));
|
||||||
|
if usize::from(i0) >= positions.len()
|
||||||
|
|| usize::from(i1) >= positions.len()
|
||||||
|
|| usize::from(i2) >= positions.len()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
faces.push(TerrainFace {
|
||||||
|
indices: [i0, i1, i2],
|
||||||
|
flags,
|
||||||
|
material_tag,
|
||||||
|
aux_tag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TerrainMesh {
|
||||||
|
positions,
|
||||||
|
uv0,
|
||||||
|
faces,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_render_mesh(mesh: &TerrainMesh) -> Result<TerrainRenderMesh> {
|
||||||
|
if mesh.positions.len() > usize::from(u16::MAX) + 1 {
|
||||||
|
return Err(Error::VertexCountOverflow {
|
||||||
|
count: mesh.positions.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let vertices = mesh
|
||||||
|
.positions
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, &position)| TerrainRenderVertex {
|
||||||
|
position,
|
||||||
|
uv0: mesh.uv0.get(idx).copied().unwrap_or([0.0, 0.0]),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let mut indices = Vec::with_capacity(mesh.faces.len() * 3);
|
||||||
|
for face in &mesh.faces {
|
||||||
|
indices.extend_from_slice(&face.indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(TerrainRenderMesh {
|
||||||
|
vertices,
|
||||||
|
indices,
|
||||||
|
face_count_raw: mesh.faces.len(),
|
||||||
|
face_count_kept: mesh.faces.len(),
|
||||||
|
face_count_dropped_invalid: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("Parkan - Iron Strategy");
|
||||||
|
root.is_dir().then_some(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_known_land_mesh() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let land = root
|
||||||
|
.join("DATA")
|
||||||
|
.join("MAPS")
|
||||||
|
.join("Tut_1")
|
||||||
|
.join("Land.msh");
|
||||||
|
if !land.is_file() {
|
||||||
|
eprintln!("skipping missing sample {}", land.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mesh = load_land_mesh(&land)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", land.display()));
|
||||||
|
assert!(mesh.positions.len() > 1000);
|
||||||
|
assert!(mesh.faces.len() > 1000);
|
||||||
|
|
||||||
|
let render = build_render_mesh(&mesh).expect("failed to build render mesh");
|
||||||
|
assert_eq!(render.vertices.len(), mesh.positions.len());
|
||||||
|
assert_eq!(render.indices.len(), mesh.faces.len() * 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loads_all_retail_land_meshes() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let maps_root = root.join("DATA").join("MAPS");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&maps_root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut parsed = 0usize;
|
||||||
|
for path in files {
|
||||||
|
if !path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.eq_ignore_ascii_case("Land.msh"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mesh = load_land_mesh(&path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||||
|
assert!(
|
||||||
|
!mesh.positions.is_empty() && !mesh.faces.is_empty(),
|
||||||
|
"{} parsed but empty",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
parsed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(parsed > 0, "no Land.msh files parsed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,4 +4,6 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
nres = { path = "../nres" }
|
nres = { path = "../nres" }
|
||||||
|
proptest = "1"
|
||||||
|
|||||||
@@ -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("..")
|
||||||
@@ -327,3 +315,16 @@ fn texm_errors_for_page_chunk_and_mip_bounds() {
|
|||||||
Err(Error::MipDataOutOfBounds { .. })
|
Err(Error::MipDataOutOfBounds { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_texm_is_panic_free_on_random_bytes(payload in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||||
|
if let Ok(texture) = parse_texm(&payload) {
|
||||||
|
for mip_index in 0..texture.mip_levels.len() {
|
||||||
|
let _ = decode_mip_rgba8(&texture, &payload, mip_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
10
crates/tma/Cargo.toml
Normal file
10
crates/tma/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "tma"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
485
crates/tma/src/lib.rs
Normal file
485
crates/tma/src/lib.rs
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const OBJECT_RECORD_FLAGS: u32 = 0x8000_0002;
|
||||||
|
const FOOTER_MAGIC: &[u8; 4] = b"MtPr";
|
||||||
|
const MAP_PATH_TOKEN: &[u8; 10] = b"DATA\\MAPS\\";
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Io(std::io::Error),
|
||||||
|
FooterNotFound,
|
||||||
|
FooterCorrupt(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
Self::FooterNotFound => write!(f, "footer magic 'MtPr' not found"),
|
||||||
|
Self::FooterCorrupt(reason) => write!(f, "corrupt mission footer: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => Some(err),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(value: std::io::Error) -> Self {
|
||||||
|
Self::Io(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MissionFile {
|
||||||
|
pub footer: MissionFooter,
|
||||||
|
pub objects: Vec<MissionObject>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MissionFooter {
|
||||||
|
pub map_path: String,
|
||||||
|
pub title: String,
|
||||||
|
pub version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MissionObject {
|
||||||
|
pub offset: usize,
|
||||||
|
pub group_id: u32,
|
||||||
|
pub flags: u32,
|
||||||
|
pub resource_name: String,
|
||||||
|
pub logical_id: i32,
|
||||||
|
pub clan_id: i32,
|
||||||
|
pub position: [f32; 3],
|
||||||
|
pub orientation: [f32; 3],
|
||||||
|
pub scale: [f32; 3],
|
||||||
|
pub alias: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_path(path: impl AsRef<Path>) -> Result<MissionFile> {
|
||||||
|
let bytes = fs::read(path.as_ref())?;
|
||||||
|
parse_bytes(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_bytes(bytes: &[u8]) -> Result<MissionFile> {
|
||||||
|
let footer = parse_footer(bytes)?;
|
||||||
|
let objects = parse_objects(bytes);
|
||||||
|
Ok(MissionFile { footer, objects })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_footer(bytes: &[u8]) -> Result<MissionFooter> {
|
||||||
|
let map_positions = find_all_map_path_positions(bytes);
|
||||||
|
if map_positions.is_empty() {
|
||||||
|
return Err(Error::FooterNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
for map_start in map_positions.into_iter().rev() {
|
||||||
|
if map_start < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let map_end = scan_path_end(bytes, map_start);
|
||||||
|
if map_end <= map_start {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let map_len = map_end - map_start;
|
||||||
|
let Some(declared_map_len) = read_u32(bytes, map_start - 4).map(|v| v as usize) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if declared_map_len != map_len {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(zero_pad) = read_u32(bytes, map_end) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if zero_pad != 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title_len_off = map_end + 4;
|
||||||
|
let Some(title_len) = read_u32(bytes, title_len_off).map(|v| v as usize) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if title_len == 0 || title_len > 256 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title_start = title_len_off + 4;
|
||||||
|
let Some(title_end) = title_start.checked_add(title_len) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if title_end > bytes.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let map_path = decode_cp1251(&bytes[map_start..map_end]);
|
||||||
|
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let title = decode_title(&bytes[title_start..title_end]);
|
||||||
|
let version = parse_footer_version(bytes, title_end)?;
|
||||||
|
|
||||||
|
return Ok(MissionFooter {
|
||||||
|
map_path,
|
||||||
|
title,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for multiplayer/legacy variants where the footer tail differs,
|
||||||
|
// but map path is still present in clear text near EOF.
|
||||||
|
let Some(map_start) = bytes
|
||||||
|
.windows(MAP_PATH_TOKEN.len())
|
||||||
|
.rposition(|window| window == MAP_PATH_TOKEN)
|
||||||
|
else {
|
||||||
|
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
|
||||||
|
};
|
||||||
|
let map_end = scan_path_end(bytes, map_start);
|
||||||
|
if map_end <= map_start {
|
||||||
|
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
|
||||||
|
}
|
||||||
|
let map_path = decode_cp1251(&bytes[map_start..map_end]);
|
||||||
|
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
|
||||||
|
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut title = String::new();
|
||||||
|
if let Some(title_len) = read_u32(bytes, map_end + 8).map(|v| v as usize) {
|
||||||
|
let title_start = map_end + 12;
|
||||||
|
let title_end = title_start.saturating_add(title_len);
|
||||||
|
if title_len > 0 && title_len <= 256 && title_end <= bytes.len() {
|
||||||
|
let raw = &bytes[title_start..title_end];
|
||||||
|
if raw.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
|
||||||
|
title = decode_title(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = if let Some(magic_off) = bytes
|
||||||
|
.windows(FOOTER_MAGIC.len())
|
||||||
|
.rposition(|window| window == FOOTER_MAGIC)
|
||||||
|
{
|
||||||
|
read_u32(bytes, magic_off + 4).unwrap_or(1)
|
||||||
|
} else {
|
||||||
|
read_u32(bytes, map_end).unwrap_or(1)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(MissionFooter {
|
||||||
|
map_path,
|
||||||
|
title,
|
||||||
|
version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_footer_version(bytes: &[u8], after_title_off: usize) -> Result<u32> {
|
||||||
|
if after_title_off + 8 <= bytes.len()
|
||||||
|
&& &bytes[after_title_off..after_title_off + 4] == FOOTER_MAGIC
|
||||||
|
{
|
||||||
|
let version = read_u32(bytes, after_title_off + 4)
|
||||||
|
.ok_or(Error::FooterCorrupt("missing version after MtPr"))?;
|
||||||
|
return Ok(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = read_u32(bytes, after_title_off)
|
||||||
|
.ok_or(Error::FooterCorrupt("missing version after title"))?;
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_all_map_path_positions(bytes: &[u8]) -> Vec<usize> {
|
||||||
|
bytes
|
||||||
|
.windows(MAP_PATH_TOKEN.len())
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(idx, window)| (window == MAP_PATH_TOKEN).then_some(idx))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scan_path_end(bytes: &[u8], start: usize) -> usize {
|
||||||
|
let mut off = start;
|
||||||
|
while off < bytes.len() && is_path_byte(bytes[off]) {
|
||||||
|
off += 1;
|
||||||
|
}
|
||||||
|
off
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_path_byte(byte: u8) -> bool {
|
||||||
|
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-' | b' ' | b':')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_objects(bytes: &[u8]) -> Vec<MissionObject> {
|
||||||
|
let mut objects = Vec::new();
|
||||||
|
let min_record_tail = 48usize;
|
||||||
|
|
||||||
|
for offset in 0..bytes.len().saturating_sub(16) {
|
||||||
|
let Some(flags) = read_u32(bytes, offset + 4) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if flags != OBJECT_RECORD_FLAGS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(name_len) = read_u32(bytes, offset + 8).map(|v| v as usize) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !(3..=260).contains(&name_len) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_start = offset + 12;
|
||||||
|
let Some(name_end) = name_start.checked_add(name_len) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if name_end + min_record_tail > bytes.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name_raw = &bytes[name_start..name_end];
|
||||||
|
if !is_object_name_bytes(name_raw) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resource_name = decode_cp1251(name_raw);
|
||||||
|
if !looks_like_object_name(&resource_name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(group_id) = read_u32(bytes, offset) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(logical_id) = read_i32(bytes, name_end) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(clan_id) = read_i32(bytes, name_end + 4) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(position) = read_vec3(bytes, name_end + 8) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(orientation) = read_vec3(bytes, name_end + 20) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(scale) = read_vec3(bytes, name_end + 32) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alias = parse_alias(bytes, name_end + 44);
|
||||||
|
|
||||||
|
objects.push(MissionObject {
|
||||||
|
offset,
|
||||||
|
group_id,
|
||||||
|
flags,
|
||||||
|
resource_name,
|
||||||
|
logical_id,
|
||||||
|
clan_id,
|
||||||
|
position,
|
||||||
|
orientation,
|
||||||
|
scale,
|
||||||
|
alias,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
objects.sort_by_key(|obj| obj.offset);
|
||||||
|
objects.dedup_by_key(|obj| obj.offset);
|
||||||
|
objects
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_alias(bytes: &[u8], alias_len_off: usize) -> String {
|
||||||
|
let Some(alias_len) = read_u32(bytes, alias_len_off).map(|v| v as usize) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
if alias_len == 0 || alias_len > 96 {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let alias_start = alias_len_off + 4;
|
||||||
|
let Some(alias_end) = alias_start.checked_add(alias_len) else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
if alias_end > bytes.len() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
let alias_raw = &bytes[alias_start..alias_end];
|
||||||
|
if !alias_raw
|
||||||
|
.iter()
|
||||||
|
.all(|&b| b == b'_' || b == b'-' || b == b'.' || b.is_ascii_alphanumeric())
|
||||||
|
{
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
decode_cp1251(alias_raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_object_name(name: &str) -> bool {
|
||||||
|
if name.ends_with(".dat") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
name.contains('_')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_object_name_bytes(bytes: &[u8]) -> bool {
|
||||||
|
bytes
|
||||||
|
.iter()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'.' | b'/' | b'\\' | b'-'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_finite(v: &[f32; 3]) -> bool {
|
||||||
|
v.iter().all(|c| c.is_finite())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_title(bytes: &[u8]) -> String {
|
||||||
|
let end = bytes
|
||||||
|
.iter()
|
||||||
|
.rposition(|b| *b != 0 && *b != 0xCD)
|
||||||
|
.map(|idx| idx + 1)
|
||||||
|
.unwrap_or(0);
|
||||||
|
decode_cp1251(&bytes[..end]).trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||||
|
let end = offset.checked_add(4)?;
|
||||||
|
let chunk = bytes.get(offset..end)?;
|
||||||
|
Some(u32::from_le_bytes(chunk.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_i32(bytes: &[u8], offset: usize) -> Option<i32> {
|
||||||
|
read_u32(bytes, offset).map(|v| v as i32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> {
|
||||||
|
let end = offset.checked_add(4)?;
|
||||||
|
let chunk = bytes.get(offset..end)?;
|
||||||
|
Some(f32::from_le_bytes(chunk.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> {
|
||||||
|
Some([
|
||||||
|
read_f32(bytes, offset)?,
|
||||||
|
read_f32(bytes, offset + 4)?,
|
||||||
|
read_f32(bytes, offset + 8)?,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("Parkan - Iron Strategy");
|
||||||
|
root.is_dir().then_some(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_known_mission_footer_and_objects() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root is missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = root
|
||||||
|
.join("MISSIONS")
|
||||||
|
.join("CAMPAIGN")
|
||||||
|
.join("CAMPAIGN.00")
|
||||||
|
.join("Mission.01")
|
||||||
|
.join("data.tma");
|
||||||
|
if !path.is_file() {
|
||||||
|
eprintln!("skipping: sample mission is missing ({})", path.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mission = parse_path(&path).expect("parse mission failed");
|
||||||
|
assert_eq!(mission.footer.version, 1);
|
||||||
|
assert!(
|
||||||
|
mission
|
||||||
|
.footer
|
||||||
|
.map_path
|
||||||
|
.eq_ignore_ascii_case("DATA\\MAPS\\Tut_1\\land"),
|
||||||
|
"unexpected map path: {}",
|
||||||
|
mission.footer.map_path
|
||||||
|
);
|
||||||
|
assert!(mission.objects.len() >= 20);
|
||||||
|
assert!(mission
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.any(|obj| obj.resource_name.eq_ignore_ascii_case("s_tree_04")));
|
||||||
|
assert!(mission.objects.iter().any(|obj| {
|
||||||
|
obj.resource_name
|
||||||
|
.eq_ignore_ascii_case("UNITS\\UNITS\\HERO\\tut1_p.dat")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_all_retail_missions() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root is missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mission_root = root.join("MISSIONS");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&mission_root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut mission_count = 0usize;
|
||||||
|
for path in files {
|
||||||
|
if !path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.is_some_and(|n| n.eq_ignore_ascii_case("data.tma"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mission_count += 1;
|
||||||
|
let mission = parse_path(&path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||||
|
assert!(
|
||||||
|
mission
|
||||||
|
.footer
|
||||||
|
.map_path
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
.contains("DATA\\MAPS\\"),
|
||||||
|
"{}: invalid map path '{}'",
|
||||||
|
path.display(),
|
||||||
|
mission.footer.map_path
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!mission.objects.is_empty(),
|
||||||
|
"{}: mission has no parsed object records",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
mission
|
||||||
|
.objects
|
||||||
|
.iter()
|
||||||
|
.all(|obj| obj.position.iter().all(|v| v.is_finite())),
|
||||||
|
"{}: mission has non-finite position",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(mission_count > 0, "no data.tma files found");
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/unitdat/Cargo.toml
Normal file
10
crates/unitdat/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "unitdat"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
180
crates/unitdat/src/lib.rs
Normal file
180
crates/unitdat/src/lib.rs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
const MIN_SIZE: usize = 0x48;
|
||||||
|
const MAGIC: u32 = 0x0000_F0F1;
|
||||||
|
|
||||||
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Error {
|
||||||
|
Io(std::io::Error),
|
||||||
|
TooSmall { got: usize },
|
||||||
|
InvalidMagic { got: u32 },
|
||||||
|
MissingArchiveName,
|
||||||
|
MissingModelKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
Self::TooSmall { got } => write!(f, "unit .dat is too small: {got} bytes"),
|
||||||
|
Self::InvalidMagic { got } => write!(f, "invalid .dat magic: 0x{got:08X}"),
|
||||||
|
Self::MissingArchiveName => write!(f, "unit .dat has empty archive name"),
|
||||||
|
Self::MissingModelKey => write!(f, "unit .dat has empty model key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(err) => Some(err),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(value: std::io::Error) -> Self {
|
||||||
|
Self::Io(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UnitDat {
|
||||||
|
pub magic: u32,
|
||||||
|
pub flags: u32,
|
||||||
|
pub archive_name: String,
|
||||||
|
pub model_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_path(path: impl AsRef<Path>) -> Result<UnitDat> {
|
||||||
|
let bytes = fs::read(path.as_ref())?;
|
||||||
|
parse_bytes(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_bytes(bytes: &[u8]) -> Result<UnitDat> {
|
||||||
|
if bytes.len() < MIN_SIZE {
|
||||||
|
return Err(Error::TooSmall { got: bytes.len() });
|
||||||
|
}
|
||||||
|
|
||||||
|
let magic = read_u32(bytes, 0).ok_or(Error::TooSmall { got: bytes.len() })?;
|
||||||
|
if magic != MAGIC {
|
||||||
|
return Err(Error::InvalidMagic { got: magic });
|
||||||
|
}
|
||||||
|
|
||||||
|
let flags = read_u32(bytes, 4).ok_or(Error::TooSmall { got: bytes.len() })?;
|
||||||
|
let archive_name = decode_c_string_fixed(&bytes[0x08..0x28]);
|
||||||
|
if archive_name.is_empty() {
|
||||||
|
return Err(Error::MissingArchiveName);
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_key = decode_c_string_fixed(&bytes[0x28..0x48]);
|
||||||
|
if model_key.is_empty() {
|
||||||
|
return Err(Error::MissingModelKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(UnitDat {
|
||||||
|
magic,
|
||||||
|
flags,
|
||||||
|
archive_name,
|
||||||
|
model_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
|
||||||
|
let end = offset.checked_add(4)?;
|
||||||
|
let chunk = bytes.get(offset..end)?;
|
||||||
|
Some(u32::from_le_bytes(chunk.try_into().ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_c_string_fixed(bytes: &[u8]) -> String {
|
||||||
|
let used = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..used]);
|
||||||
|
decoded.trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn game_root() -> Option<PathBuf> {
|
||||||
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("..")
|
||||||
|
.join("..")
|
||||||
|
.join("testdata")
|
||||||
|
.join("Parkan - Iron Strategy");
|
||||||
|
root.is_dir().then_some(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_known_dat_files() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let samples = [
|
||||||
|
root.join("UNITS/UNITS/HERO/tut1_p.dat"),
|
||||||
|
root.join("UNITS/UNITS/BATTLE/l_targ.dat"),
|
||||||
|
root.join("UNITS/BUILDS/BRIDGE/m_bridge.dat"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in samples {
|
||||||
|
if !path.is_file() {
|
||||||
|
eprintln!("skipping missing sample {}", path.display());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dat = parse_path(&path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||||
|
assert_eq!(dat.magic, MAGIC);
|
||||||
|
assert!(dat.archive_name.to_ascii_lowercase().ends_with(".rlb"));
|
||||||
|
assert!(dat.model_key.contains('_'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_retail_dat_corpus() {
|
||||||
|
let Some(root) = game_root() else {
|
||||||
|
eprintln!("skipping: game root missing");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let units_root = root.join("UNITS");
|
||||||
|
let mut files = Vec::new();
|
||||||
|
collect_files_recursive(&units_root, &mut files);
|
||||||
|
files.sort();
|
||||||
|
|
||||||
|
let mut parsed = 0usize;
|
||||||
|
for path in files {
|
||||||
|
if !path
|
||||||
|
.extension()
|
||||||
|
.and_then(|ext| ext.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("dat"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let dat = parse_path(&path)
|
||||||
|
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
|
||||||
|
assert!(
|
||||||
|
!dat.archive_name.is_empty(),
|
||||||
|
"{} empty archive",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!dat.model_key.is_empty(),
|
||||||
|
"{} empty model key",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
parsed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(parsed > 0, "no .dat files parsed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,3 +31,16 @@
|
|||||||
2. Полный перечень типов триггеров и их параметров.
|
2. Полный перечень типов триггеров и их параметров.
|
||||||
3. Формальные правила разрешения конфликтов триггеров в одном кадре.
|
3. Формальные правила разрешения конфликтов триггеров в одном кадре.
|
||||||
4. Набор replay parity-тестов «миссия от старта до завершения».
|
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)
|
||||||
|
|||||||
@@ -187,3 +187,16 @@ Fail-safe поведение:
|
|||||||
1. Формальная семантика `attr1/attr2/attr3` для всех типов ресурсов (частично вынесена в профильные страницы `msh`, `material`, `texture`, `fxid`, `terrain`).
|
1. Формальная семантика `attr1/attr2/attr3` для всех типов ресурсов (частично вынесена в профильные страницы `msh`, `material`, `texture`, `fxid`, `terrain`).
|
||||||
2. Полная спецификация поведения при не-ASCII именах (в реальных игровых архивах используется ASCII-практика; для Unicode-коллации движок не документирован).
|
2. Полная спецификация поведения при не-ASCII именах (в реальных игровых архивах используется ASCII-практика; для Unicode-коллации движок не документирован).
|
||||||
3. Полная спецификация платформенных гарантий атомарной записи (формат данных закрыт, но OS-уровневые гарантии замены файла зависят от платформы и файловой системы).
|
3. Полная спецификация платформенных гарантий атомарной записи (формат данных закрыт, но OS-уровневые гарантии замены файла зависят от платформы и файловой системы).
|
||||||
|
## 12. Специализация `objects.rlb`
|
||||||
|
|
||||||
|
Хотя `objects.rlb` формально является обычным `NRes`, его payload имеет отдельный семантический контракт:
|
||||||
|
|
||||||
|
- запись каталога соответствует одному объектному прототипу;
|
||||||
|
- payload записи - массив фиксированных ссылок `ObjectRef64` (`archive_name[32] + resource_name[32]`);
|
||||||
|
- runtime-резолв меша выполняется через эти ссылки, а не через имя entry `*.msh` внутри `objects.rlb`.
|
||||||
|
|
||||||
|
Это означает, что `objects.rlb` должен рассматриваться не как архив мешей, а как реестр привязок между mission/unit-ключами и фактическими ресурсами.
|
||||||
|
|
||||||
|
См. детальную страницу:
|
||||||
|
|
||||||
|
- [Object registry (`objects.rlb`)](object-registry.md)
|
||||||
|
|||||||
145
docs/specs/object-registry.md
Normal file
145
docs/specs/object-registry.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Object Registry (`objects.rlb`)
|
||||||
|
|
||||||
|
`objects.rlb` - это не архив с готовыми мешами.
|
||||||
|
Это реестр игровых прототипов, который связывает логический идентификатор объекта (`r_h_01`, `s_tree_04`, `fr_m_brige`, ...) с набором реальных ресурсов в других архивах.
|
||||||
|
|
||||||
|
Документ описывает формат и runtime-контракт на высоком уровне, без привязки к внутренним именам/адресам из дизассемблера.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [Missions](missions.md)
|
||||||
|
- [NRes](nres.md)
|
||||||
|
- [MSH core](msh-core.md)
|
||||||
|
- [Wear (`WEAR`)](wear.md)
|
||||||
|
- [Material (`MAT0`)](material.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## 1. Роль в пайплайне
|
||||||
|
|
||||||
|
При загрузке миссии движок работает так:
|
||||||
|
|
||||||
|
1. Из `data.tma` получает `resource_name` объекта:
|
||||||
|
- либо прямой ключ (`s_tree_04`);
|
||||||
|
- либо путь к `*.dat` (например `UNITS\\UNITS\\HERO\\tut1_p.dat`).
|
||||||
|
2. Для `*.dat` читает заголовок и получает:
|
||||||
|
- `archive_name` (в retail-корпусе всегда `objects.rlb`);
|
||||||
|
- `model_key` (например `R_H_02`).
|
||||||
|
3. В `objects.rlb` по ключу (`model_key`/`resource_name`) ищет запись прототипа.
|
||||||
|
4. Из записи прототипа резолвит фактический `*.msh` и архив, где лежит геометрия.
|
||||||
|
5. Дальше запускается стандартная цепочка:
|
||||||
|
`MSH -> WEAR -> MAT0 -> Texm`.
|
||||||
|
|
||||||
|
## 2. Контейнер
|
||||||
|
|
||||||
|
`objects.rlb` сам является обычным `NRes`-архивом.
|
||||||
|
|
||||||
|
Практические наблюдения на retail-корпусе:
|
||||||
|
|
||||||
|
- формат заголовка/каталога полностью совпадает с `NRes`;
|
||||||
|
- payload каждой записи прототипа кратен `64` байтам;
|
||||||
|
- имя entry в каталоге - это логический ключ объекта (например `r_h_01`, `s_tree_04`).
|
||||||
|
|
||||||
|
## 3. Формат payload записи прототипа
|
||||||
|
|
||||||
|
Payload состоит из массива фиксированных записей:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct ObjectRef64 {
|
||||||
|
char archive_name[32]; // C-строка (CP1251/ASCII)
|
||||||
|
char resource_name[32]; // C-строка (CP1251/ASCII)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Интерпретация:
|
||||||
|
|
||||||
|
- `archive_name`: архив-источник (`bases.rlb`, `static.rlb`, `fortif.rlb`, `effects.rlb`, ...).
|
||||||
|
- `resource_name`: имя ресурса в этом архиве (`*.msh`, `*.wea`, `*.cpt`, `*.ctl`, `*.bas`, ...).
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
|
||||||
|
- после первого `NUL` в 32-байтовом поле могут встречаться служебные байты; для runtime-резолва используется только C-строка до первого `NUL`;
|
||||||
|
- неизвестные хвостовые байты должны сохраняться 1:1 при writer/roundtrip-редактировании.
|
||||||
|
|
||||||
|
## 4. Runtime-резолв геометрии
|
||||||
|
|
||||||
|
Канонический порядок выбора меша:
|
||||||
|
|
||||||
|
1. Найти запись прототипа по ключу в `objects.rlb`.
|
||||||
|
2. Прочитать список `ObjectRef64`.
|
||||||
|
3. Если есть ссылка на `*.msh`:
|
||||||
|
- взять первую валидную ссылку;
|
||||||
|
- открыть указанный архив;
|
||||||
|
- загрузить этот `*.msh`.
|
||||||
|
4. Если `*.msh` нет, но есть `*.bas`:
|
||||||
|
- взять stem от `*.bas` (`fr_m_brige.bas` -> `fr_m_brige`);
|
||||||
|
- искать `<stem>.msh` в том же архиве (`fortif.rlb`).
|
||||||
|
5. Если нет ни `*.msh`, ни `*.bas`, объект трактуется как не-геометрический (пример: солнечный/системный объект) и в 3D-проход не попадает.
|
||||||
|
|
||||||
|
## 5. Типовые примеры
|
||||||
|
|
||||||
|
`r_h_01`:
|
||||||
|
|
||||||
|
- `bases.rlb :: r_h_01.msh`
|
||||||
|
- `bases.rlb :: r_h_01.wea`
|
||||||
|
- `bases.rlb :: r_h_01.cpt`
|
||||||
|
- ...
|
||||||
|
|
||||||
|
`s_tree_04`:
|
||||||
|
|
||||||
|
- `static.rlb :: s_tree_0_04.msh`
|
||||||
|
- `static.rlb :: s_tree_0_04.wea`
|
||||||
|
- ...
|
||||||
|
|
||||||
|
`fr_m_brige`:
|
||||||
|
|
||||||
|
- прямого `*.msh` в записи нет;
|
||||||
|
- есть `fortif.rlb :: fr_m_brige.bas`;
|
||||||
|
- меш резолвится как `fortif.rlb :: fr_m_brige.msh`.
|
||||||
|
|
||||||
|
`sun_01`:
|
||||||
|
|
||||||
|
- ссылки на `*.sun`/effect-ресурсы;
|
||||||
|
- 3D-меш отсутствует.
|
||||||
|
|
||||||
|
## 6. Инварианты для reader/writer
|
||||||
|
|
||||||
|
Reader:
|
||||||
|
|
||||||
|
- payload записи прототипа должен быть кратен `64`;
|
||||||
|
- каждая запись читается как две независимые C-строки фиксированной длины;
|
||||||
|
- поиск в архивах должен быть case-insensitive по ASCII.
|
||||||
|
|
||||||
|
Writer/editor:
|
||||||
|
|
||||||
|
- сохранять порядок `ObjectRef64` без перестановок;
|
||||||
|
- сохранять неизвестные служебные байты полей 1:1;
|
||||||
|
- не нормализовать имена, если это не требуется задачей.
|
||||||
|
|
||||||
|
## 7. Валидация
|
||||||
|
|
||||||
|
Проверено на retail-корпусе `testdata/Parkan - Iron Strategy`:
|
||||||
|
|
||||||
|
- все `590` записей `objects.rlb` имеют payload, кратный `64`;
|
||||||
|
- `554` записей имеют прямую ссылку на `*.msh`;
|
||||||
|
- `34` записи используют ветку через `*.bas`;
|
||||||
|
- `2` записи не содержат геометрии (системные/sun).
|
||||||
|
|
||||||
|
Интеграционные тесты в Rust подтверждают резолв:
|
||||||
|
|
||||||
|
- `r_h_01 -> bases.rlb :: r_h_01.msh`
|
||||||
|
- `s_tree_04 -> static.rlb :: s_tree_0_04.msh`
|
||||||
|
- `fr_m_brige -> fortif.rlb :: fr_m_brige.msh`
|
||||||
|
|
||||||
|
## 8. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Формат payload записи прототипа (`ObjectRef64`) и правила чтения.
|
||||||
|
2. Runtime-алгоритм выбора меша (`*.msh` напрямую и fallback через `*.bas`).
|
||||||
|
3. Корпусная проверка структуры и интеграционные тесты резолва.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная field-level семантика служебных байтов после `NUL` в `resource_name[32]`.
|
||||||
|
2. Формальная семантика всех категорий ссылок (`*.ctl`, `*.cpt`, `*.ndp`, `*.sun`) в терминах систем движка (не только render-пути).
|
||||||
|
3. Writer-спецификация уровня "authoring new prototype from scratch" с гарантией runtime-паритета.
|
||||||
@@ -167,3 +167,16 @@ void RenderFrame(Scene* scene, Camera* cam, float dt) {
|
|||||||
1. Полный pixel-parity контур с эталонными кадрами оригинального рендера по набору моделей/сцен.
|
1. Полный pixel-parity контур с эталонными кадрами оригинального рендера по набору моделей/сцен.
|
||||||
2. Формализация всех render-state деталей (точные blend/depth/cull/state transitions) для гарантии 1:1 в каждом draw-pass.
|
2. Формализация всех render-state деталей (точные blend/depth/cull/state transitions) для гарантии 1:1 в каждом draw-pass.
|
||||||
3. Полный coverage-пакет по динамическим веткам (FX-heavy кадры, сложные material-режимы, lightmap-комбинации).
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user