Compare commits
14 Commits
a281ffa32e
...
devel
| Author | SHA1 | Date | |
|---|---|---|---|
| 96a25b6c0e | |||
| f4262cf369 | |||
| 9b100b8fc3 | |||
| 9fceeb9a0a | |||
| 4b7f1a16b9 | |||
|
ada3b903ad
|
|||
| 31d849ddbf | |||
| 4ef08d0bf6 | |||
|
598137ed13
|
|||
|
cb0ca2f2f0
|
|||
|
7346e695c4
|
|||
|
bb827c3928
|
|||
|
efab61a45c
|
|||
| 0d7ae6a017 |
@@ -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 @@
|
|||||||
use core::fmt;
|
use core::fmt;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
Nres(nres::error::Error),
|
Nres(nres::error::Error),
|
||||||
MissingResource {
|
MissingResource {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub type Result<T> = core::result::Result<T, Error>;
|
pub type Result<T> = core::result::Result<T, Error>;
|
||||||
@@ -164,6 +165,8 @@ pub fn parse_model_payload(payload: &[u8]) -> Result<Model> {
|
|||||||
let positions = parse_positions(&res3.bytes)?;
|
let positions = parse_positions(&res3.bytes)?;
|
||||||
let indices = parse_u16_array(&res6.bytes, "Res6")?;
|
let indices = parse_u16_array(&res6.bytes, "Res6")?;
|
||||||
let batches = parse_batches(&res13.bytes)?;
|
let batches = parse_batches(&res13.bytes)?;
|
||||||
|
validate_slot_batch_ranges(&slots, batches.len())?;
|
||||||
|
validate_batch_index_ranges(&batches, indices.len())?;
|
||||||
|
|
||||||
let normals = match res4 {
|
let normals = match res4 {
|
||||||
Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?),
|
Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?),
|
||||||
@@ -192,6 +195,40 @@ pub fn parse_model_payload(payload: &[u8]) -> Result<Model> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<()> {
|
||||||
|
for slot in slots {
|
||||||
|
let start = usize::from(slot.batch_start);
|
||||||
|
let end = start
|
||||||
|
.checked_add(usize::from(slot.batch_count))
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
if end > batch_count {
|
||||||
|
return Err(Error::IndexOutOfBounds {
|
||||||
|
label: "Res2.batch_range",
|
||||||
|
index: end,
|
||||||
|
limit: batch_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<()> {
|
||||||
|
for batch in batches {
|
||||||
|
let start = usize::try_from(batch.index_start).map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
let end = start
|
||||||
|
.checked_add(usize::from(batch.index_count))
|
||||||
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
|
if end > index_count {
|
||||||
|
return Err(Error::IndexOutOfBounds {
|
||||||
|
label: "Res13.index_range",
|
||||||
|
index: end,
|
||||||
|
limit: index_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>> {
|
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>> {
|
||||||
if !data.len().is_multiple_of(12) {
|
if !data.len().is_multiple_of(12) {
|
||||||
return Err(Error::InvalidResourceSize {
|
return Err(Error::InvalidResourceSize {
|
||||||
@@ -311,13 +348,18 @@ fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String
|
|||||||
} else {
|
} else {
|
||||||
slice
|
slice
|
||||||
};
|
};
|
||||||
let decoded = String::from_utf8_lossy(text).to_string();
|
let decoded = decode_cp1251(text);
|
||||||
out.push(Some(decoded));
|
out.push(Some(decoded));
|
||||||
off = end;
|
off = end;
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
struct RawResource {
|
struct RawResource {
|
||||||
meta: nres::EntryMeta,
|
meta: nres::EntryMeta,
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
use nres::Archive;
|
use nres::Archive;
|
||||||
|
use proptest::prelude::*;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
|
||||||
let Ok(entries) = fs::read_dir(root) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
collect_files_recursive(&path, out);
|
|
||||||
} else if path.is_file() {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nres_test_files() -> Vec<PathBuf> {
|
fn nres_test_files() -> Vec<PathBuf> {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("..")
|
.join("..")
|
||||||
@@ -39,6 +27,170 @@ fn is_msh_name(name: &str) -> bool {
|
|||||||
name.to_ascii_lowercase().ends_with(".msh")
|
name.to_ascii_lowercase().ends_with(".msh")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SyntheticEntry {
|
||||||
|
kind: u32,
|
||||||
|
name: String,
|
||||||
|
attr1: u32,
|
||||||
|
attr2: u32,
|
||||||
|
attr3: u32,
|
||||||
|
data: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_nested_nres(entries: &[SyntheticEntry]) -> Vec<u8> {
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
payload.extend_from_slice(b"NRes");
|
||||||
|
payload.extend_from_slice(&0x100u32.to_le_bytes());
|
||||||
|
payload.extend_from_slice(
|
||||||
|
&u32::try_from(entries.len())
|
||||||
|
.expect("entry count overflow in test")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
|
||||||
|
|
||||||
|
let mut resource_offsets = Vec::with_capacity(entries.len());
|
||||||
|
for entry in entries {
|
||||||
|
resource_offsets.push(u32::try_from(payload.len()).expect("offset overflow in test"));
|
||||||
|
payload.extend_from_slice(&entry.data);
|
||||||
|
while !payload.len().is_multiple_of(8) {
|
||||||
|
payload.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
payload.extend_from_slice(&entry.kind.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&entry.attr1.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&entry.attr2.to_le_bytes());
|
||||||
|
payload.extend_from_slice(
|
||||||
|
&u32::try_from(entry.data.len())
|
||||||
|
.expect("size overflow in test")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
payload.extend_from_slice(&entry.attr3.to_le_bytes());
|
||||||
|
|
||||||
|
let mut name_raw = [0u8; 36];
|
||||||
|
let name_bytes = entry.name.as_bytes();
|
||||||
|
assert!(name_bytes.len() <= 35, "name too long for synthetic test");
|
||||||
|
name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
|
||||||
|
payload.extend_from_slice(&name_raw);
|
||||||
|
|
||||||
|
payload.extend_from_slice(&resource_offsets[index].to_le_bytes());
|
||||||
|
payload.extend_from_slice(&(index as u32).to_le_bytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_size = u32::try_from(payload.len()).expect("size overflow in test");
|
||||||
|
payload[12..16].copy_from_slice(&total_size.to_le_bytes());
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
fn synthetic_entry(kind: u32, name: &str, attr3: u32, data: Vec<u8>) -> SyntheticEntry {
|
||||||
|
SyntheticEntry {
|
||||||
|
kind,
|
||||||
|
name: name.to_string(),
|
||||||
|
attr1: 1,
|
||||||
|
attr2: 0,
|
||||||
|
attr3,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res1_stride38_nodes(node_count: usize, node0_slot00: Option<u16>) -> Vec<u8> {
|
||||||
|
let mut out = vec![0u8; node_count.saturating_mul(38)];
|
||||||
|
for node in 0..node_count {
|
||||||
|
let node_off = node * 38;
|
||||||
|
for i in 0..15 {
|
||||||
|
let off = node_off + 8 + i * 2;
|
||||||
|
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(slot) = node0_slot00 {
|
||||||
|
out[8..10].copy_from_slice(&slot.to_le_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res1_stride24_nodes(node_count: usize) -> Vec<u8> {
|
||||||
|
vec![0u8; node_count.saturating_mul(24)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res2_single_slot(batch_start: u16, batch_count: u16) -> Vec<u8> {
|
||||||
|
let mut res2 = vec![0u8; 0x8C + 68];
|
||||||
|
res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start
|
||||||
|
res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count
|
||||||
|
res2[0x8C + 4..0x8C + 6].copy_from_slice(&batch_start.to_le_bytes()); // batch_start
|
||||||
|
res2[0x8C + 6..0x8C + 8].copy_from_slice(&batch_count.to_le_bytes()); // batch_count
|
||||||
|
res2
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res3_triangle_positions() -> Vec<u8> {
|
||||||
|
[0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|v| v.to_le_bytes())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res4_normals() -> Vec<u8> {
|
||||||
|
vec![127u8, 0u8, 128u8, 0u8]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res5_uv0() -> Vec<u8> {
|
||||||
|
[1024i16, -1024i16]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|v| v.to_le_bytes())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res6_triangle_indices() -> Vec<u8> {
|
||||||
|
[0u16, 1u16, 2u16]
|
||||||
|
.iter()
|
||||||
|
.flat_map(|v| v.to_le_bytes())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res13_single_batch(index_start: u32, index_count: u16) -> Vec<u8> {
|
||||||
|
let mut batch = vec![0u8; 20];
|
||||||
|
batch[0..2].copy_from_slice(&0u16.to_le_bytes());
|
||||||
|
batch[2..4].copy_from_slice(&0u16.to_le_bytes());
|
||||||
|
batch[8..10].copy_from_slice(&index_count.to_le_bytes());
|
||||||
|
batch[10..14].copy_from_slice(&index_start.to_le_bytes());
|
||||||
|
batch[16..20].copy_from_slice(&0u32.to_le_bytes());
|
||||||
|
batch
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for name in names {
|
||||||
|
match name {
|
||||||
|
Some(name) => {
|
||||||
|
out.extend_from_slice(
|
||||||
|
&u32::try_from(name.len())
|
||||||
|
.expect("name size overflow in test")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
out.extend_from_slice(name);
|
||||||
|
out.push(0);
|
||||||
|
}
|
||||||
|
None => out.extend_from_slice(&0u32.to_le_bytes()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn res10_names(names: &[Option<&str>]) -> Vec<u8> {
|
||||||
|
let raw: Vec<Option<&[u8]>> = names.iter().map(|name| name.map(str::as_bytes)).collect();
|
||||||
|
res10_names_raw(&raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_synthetic_entries() -> Vec<SyntheticEntry> {
|
||||||
|
vec![
|
||||||
|
synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))),
|
||||||
|
synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 1)),
|
||||||
|
synthetic_entry(RES3_POSITIONS, "Res3", 12, res3_triangle_positions()),
|
||||||
|
synthetic_entry(RES6_INDICES, "Res6", 2, res6_triangle_indices()),
|
||||||
|
synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(0, 3)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_all_game_msh_models() {
|
fn parse_all_game_msh_models() {
|
||||||
let archives = nres_test_files();
|
let archives = nres_test_files();
|
||||||
@@ -137,156 +289,7 @@ fn parse_all_game_msh_models() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_minimal_synthetic_model() {
|
fn parse_minimal_synthetic_model() {
|
||||||
// Nested NRes with required resources only.
|
let payload = build_nested_nres(&base_synthetic_entries());
|
||||||
let mut payload = Vec::new();
|
|
||||||
payload.extend_from_slice(b"NRes");
|
|
||||||
payload.extend_from_slice(&0x100u32.to_le_bytes());
|
|
||||||
payload.extend_from_slice(&5u32.to_le_bytes()); // entry_count
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
|
|
||||||
|
|
||||||
let mut resource_offsets = Vec::new();
|
|
||||||
let mut resource_sizes = Vec::new();
|
|
||||||
let mut resource_types = Vec::new();
|
|
||||||
let mut resource_attr3 = Vec::new();
|
|
||||||
let mut resource_names = Vec::new();
|
|
||||||
|
|
||||||
let add_resource = |payload: &mut Vec<u8>,
|
|
||||||
offsets: &mut Vec<u32>,
|
|
||||||
sizes: &mut Vec<u32>,
|
|
||||||
types: &mut Vec<u32>,
|
|
||||||
attr3: &mut Vec<u32>,
|
|
||||||
names: &mut Vec<String>,
|
|
||||||
kind: u32,
|
|
||||||
name: &str,
|
|
||||||
data: &[u8],
|
|
||||||
attr3_val: u32| {
|
|
||||||
offsets.push(u32::try_from(payload.len()).expect("offset overflow"));
|
|
||||||
payload.extend_from_slice(data);
|
|
||||||
while !payload.len().is_multiple_of(8) {
|
|
||||||
payload.push(0);
|
|
||||||
}
|
|
||||||
sizes.push(u32::try_from(data.len()).expect("size overflow"));
|
|
||||||
types.push(kind);
|
|
||||||
attr3.push(attr3_val);
|
|
||||||
names.push(name.to_string());
|
|
||||||
};
|
|
||||||
|
|
||||||
let node = {
|
|
||||||
let mut b = vec![0u8; 38];
|
|
||||||
// slot[0][0] = 0
|
|
||||||
b[8..10].copy_from_slice(&0u16.to_le_bytes());
|
|
||||||
for i in 1..15 {
|
|
||||||
let off = 8 + i * 2;
|
|
||||||
b[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
|
|
||||||
}
|
|
||||||
b
|
|
||||||
};
|
|
||||||
let mut res2 = vec![0u8; 0x8C + 68];
|
|
||||||
res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start
|
|
||||||
res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count
|
|
||||||
res2[0x8C + 4..0x8C + 6].copy_from_slice(&0u16.to_le_bytes()); // batch_start
|
|
||||||
res2[0x8C + 6..0x8C + 8].copy_from_slice(&1u16.to_le_bytes()); // batch_count
|
|
||||||
let positions = [0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
|
|
||||||
.iter()
|
|
||||||
.flat_map(|v| v.to_le_bytes())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let indices = [0u16, 1, 2]
|
|
||||||
.iter()
|
|
||||||
.flat_map(|v| v.to_le_bytes())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let batch = {
|
|
||||||
let mut b = vec![0u8; 20];
|
|
||||||
b[0..2].copy_from_slice(&0u16.to_le_bytes());
|
|
||||||
b[2..4].copy_from_slice(&0u16.to_le_bytes());
|
|
||||||
b[8..10].copy_from_slice(&3u16.to_le_bytes()); // index_count
|
|
||||||
b[10..14].copy_from_slice(&0u32.to_le_bytes()); // index_start
|
|
||||||
b[16..20].copy_from_slice(&0u32.to_le_bytes()); // base_vertex
|
|
||||||
b
|
|
||||||
};
|
|
||||||
|
|
||||||
add_resource(
|
|
||||||
&mut payload,
|
|
||||||
&mut resource_offsets,
|
|
||||||
&mut resource_sizes,
|
|
||||||
&mut resource_types,
|
|
||||||
&mut resource_attr3,
|
|
||||||
&mut resource_names,
|
|
||||||
RES1_NODE_TABLE,
|
|
||||||
"Res1",
|
|
||||||
&node,
|
|
||||||
38,
|
|
||||||
);
|
|
||||||
add_resource(
|
|
||||||
&mut payload,
|
|
||||||
&mut resource_offsets,
|
|
||||||
&mut resource_sizes,
|
|
||||||
&mut resource_types,
|
|
||||||
&mut resource_attr3,
|
|
||||||
&mut resource_names,
|
|
||||||
RES2_SLOTS,
|
|
||||||
"Res2",
|
|
||||||
&res2,
|
|
||||||
68,
|
|
||||||
);
|
|
||||||
add_resource(
|
|
||||||
&mut payload,
|
|
||||||
&mut resource_offsets,
|
|
||||||
&mut resource_sizes,
|
|
||||||
&mut resource_types,
|
|
||||||
&mut resource_attr3,
|
|
||||||
&mut resource_names,
|
|
||||||
RES3_POSITIONS,
|
|
||||||
"Res3",
|
|
||||||
&positions,
|
|
||||||
12,
|
|
||||||
);
|
|
||||||
add_resource(
|
|
||||||
&mut payload,
|
|
||||||
&mut resource_offsets,
|
|
||||||
&mut resource_sizes,
|
|
||||||
&mut resource_types,
|
|
||||||
&mut resource_attr3,
|
|
||||||
&mut resource_names,
|
|
||||||
RES6_INDICES,
|
|
||||||
"Res6",
|
|
||||||
&indices,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
add_resource(
|
|
||||||
&mut payload,
|
|
||||||
&mut resource_offsets,
|
|
||||||
&mut resource_sizes,
|
|
||||||
&mut resource_types,
|
|
||||||
&mut resource_attr3,
|
|
||||||
&mut resource_names,
|
|
||||||
RES13_BATCHES,
|
|
||||||
"Res13",
|
|
||||||
&batch,
|
|
||||||
20,
|
|
||||||
);
|
|
||||||
|
|
||||||
let directory_offset = payload.len();
|
|
||||||
for i in 0..resource_types.len() {
|
|
||||||
payload.extend_from_slice(&resource_types[i].to_le_bytes());
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // attr1
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // attr2
|
|
||||||
payload.extend_from_slice(&resource_sizes[i].to_le_bytes());
|
|
||||||
payload.extend_from_slice(&resource_attr3[i].to_le_bytes());
|
|
||||||
let mut name_raw = [0u8; 36];
|
|
||||||
let bytes = resource_names[i].as_bytes();
|
|
||||||
name_raw[..bytes.len()].copy_from_slice(bytes);
|
|
||||||
payload.extend_from_slice(&name_raw);
|
|
||||||
payload.extend_from_slice(&resource_offsets[i].to_le_bytes());
|
|
||||||
payload.extend_from_slice(&(i as u32).to_le_bytes()); // sort index
|
|
||||||
}
|
|
||||||
let total_size = u32::try_from(payload.len()).expect("size overflow");
|
|
||||||
payload[12..16].copy_from_slice(&total_size.to_le_bytes());
|
|
||||||
assert_eq!(
|
|
||||||
directory_offset + resource_types.len() * 64,
|
|
||||||
payload.len(),
|
|
||||||
"synthetic nested NRes layout invalid"
|
|
||||||
);
|
|
||||||
|
|
||||||
let model = parse_model_payload(&payload).expect("failed to parse synthetic model");
|
let model = parse_model_payload(&payload).expect("failed to parse synthetic model");
|
||||||
assert_eq!(model.node_count, 1);
|
assert_eq!(model.node_count, 1);
|
||||||
assert_eq!(model.positions.len(), 3);
|
assert_eq!(model.positions.len(), 3);
|
||||||
@@ -294,3 +297,142 @@ fn parse_minimal_synthetic_model() {
|
|||||||
assert_eq!(model.batches.len(), 1);
|
assert_eq!(model.batches.len(), 1);
|
||||||
assert_eq!(model.slot_index(0, 0, 0), Some(0));
|
assert_eq!(model.slot_index(0, 0, 0), Some(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_synthetic_stride24_variant() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 24, res1_stride24_nodes(1));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
let model = parse_model_payload(&payload).expect("failed to parse stride24 model");
|
||||||
|
assert_eq!(model.node_stride, 24);
|
||||||
|
assert_eq!(model.node_count, 1);
|
||||||
|
assert_eq!(model.slot_index(0, 0, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_synthetic_model_with_optional_res4_res5_res10() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, res4_normals()));
|
||||||
|
entries.push(synthetic_entry(RES5_UV0, "Res5", 4, res5_uv0()));
|
||||||
|
entries.push(synthetic_entry(
|
||||||
|
RES10_NAMES,
|
||||||
|
"Res10",
|
||||||
|
1,
|
||||||
|
res10_names(&[Some("Hull"), None]),
|
||||||
|
));
|
||||||
|
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(2, Some(0)));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
let model = parse_model_payload(&payload).expect("failed to parse model with optional data");
|
||||||
|
assert_eq!(model.node_count, 2);
|
||||||
|
assert_eq!(model.normals.as_ref().map(Vec::len), Some(1));
|
||||||
|
assert_eq!(model.uv0.as_ref().map(Vec::len), Some(1));
|
||||||
|
assert_eq!(model.node_names, Some(vec![Some("Hull".to_string()), None]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_res10_names_decodes_cp1251() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0)));
|
||||||
|
entries.push(synthetic_entry(
|
||||||
|
RES10_NAMES,
|
||||||
|
"Res10",
|
||||||
|
1,
|
||||||
|
res10_names_raw(&[Some(&[0xC0])]),
|
||||||
|
));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
let model = parse_model_payload(&payload).expect("failed to parse model with cp1251 name");
|
||||||
|
assert_eq!(model.node_names, Some(vec![Some("А".to_string())]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_when_required_resource_missing() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries.retain(|entry| entry.kind != RES13_BATCHES);
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::MissingResource {
|
||||||
|
kind: RES13_BATCHES,
|
||||||
|
label: "Res13"
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_invalid_res2_size() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, vec![0u8; 0x8B]);
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::InvalidRes2Size { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_unsupported_node_stride() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 30, vec![0u8; 30]);
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::UnsupportedNodeStride { stride: 30 })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_invalid_optional_resource_size() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, vec![1, 2, 3]));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::InvalidResourceSize { label: "Res4", .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_slot_batch_range_out_of_bounds() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 2));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::IndexOutOfBounds {
|
||||||
|
label: "Res2.batch_range",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_fails_for_batch_index_range_out_of_bounds() {
|
||||||
|
let mut entries = base_synthetic_entries();
|
||||||
|
entries[4] = synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(1, 3));
|
||||||
|
let payload = build_nested_nres(&entries);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
parse_model_payload(&payload),
|
||||||
|
Err(Error::IndexOutOfBounds {
|
||||||
|
label: "Res13.index_range",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_model_payload_never_panics_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..8192)) {
|
||||||
|
let _ = parse_model_payload(&data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,10 +26,28 @@ pub enum OpenMode {
|
|||||||
ReadWrite,
|
ReadWrite,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ArchiveHeader {
|
||||||
|
pub magic: [u8; 4],
|
||||||
|
pub version: u32,
|
||||||
|
pub entry_count: u32,
|
||||||
|
pub total_size: u32,
|
||||||
|
pub directory_offset: u64,
|
||||||
|
pub directory_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ArchiveInfo {
|
||||||
|
pub raw_mode: bool,
|
||||||
|
pub file_size: u64,
|
||||||
|
pub header: Option<ArchiveHeader>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Archive {
|
pub struct Archive {
|
||||||
bytes: Arc<[u8]>,
|
bytes: Arc<[u8]>,
|
||||||
entries: Vec<EntryRecord>,
|
entries: Vec<EntryRecord>,
|
||||||
|
info: ArchiveInfo,
|
||||||
raw_mode: bool,
|
raw_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +72,13 @@ pub struct EntryRef<'a> {
|
|||||||
pub meta: &'a EntryMeta,
|
pub meta: &'a EntryMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct EntryInspect<'a> {
|
||||||
|
pub id: EntryId,
|
||||||
|
pub meta: &'a EntryMeta,
|
||||||
|
pub name_raw: &'a [u8; 36],
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
struct EntryRecord {
|
struct EntryRecord {
|
||||||
meta: EntryMeta,
|
meta: EntryMeta,
|
||||||
@@ -76,29 +101,50 @@ impl Archive {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> {
|
pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> {
|
||||||
let (entries, _) = parse_archive(&bytes, opts.raw_mode)?;
|
let file_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||||
|
let (entries, header) = parse_archive(&bytes, opts.raw_mode)?;
|
||||||
if opts.prefetch_pages {
|
if opts.prefetch_pages {
|
||||||
prefetch_pages(&bytes);
|
prefetch_pages(&bytes);
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
bytes,
|
bytes,
|
||||||
entries,
|
entries,
|
||||||
|
info: ArchiveInfo {
|
||||||
|
raw_mode: opts.raw_mode,
|
||||||
|
file_size,
|
||||||
|
header,
|
||||||
|
},
|
||||||
raw_mode: opts.raw_mode,
|
raw_mode: opts.raw_mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn info(&self) -> &ArchiveInfo {
|
||||||
|
&self.info
|
||||||
|
}
|
||||||
|
|
||||||
pub fn entry_count(&self) -> usize {
|
pub fn entry_count(&self) -> usize {
|
||||||
self.entries.len()
|
self.entries.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||||
self.entries
|
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||||
.iter()
|
let id = u32::try_from(idx).ok()?;
|
||||||
.enumerate()
|
Some(EntryRef {
|
||||||
.map(|(idx, entry)| EntryRef {
|
id: EntryId(id),
|
||||||
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
|
|
||||||
meta: &entry.meta,
|
meta: &entry.meta,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
|
||||||
|
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
Some(EntryInspect {
|
||||||
|
id: EntryId(id),
|
||||||
|
meta: &entry.meta,
|
||||||
|
name_raw: &entry.name_raw,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
pub fn find(&self, name: &str) -> Option<EntryId> {
|
||||||
@@ -125,9 +171,8 @@ impl Archive {
|
|||||||
Ordering::Less => high = mid,
|
Ordering::Less => high = mid,
|
||||||
Ordering::Greater => low = mid + 1,
|
Ordering::Greater => low = mid + 1,
|
||||||
Ordering::Equal => {
|
Ordering::Equal => {
|
||||||
return Some(EntryId(
|
let id = u32::try_from(target_idx).ok()?;
|
||||||
u32::try_from(target_idx).expect("entry count validated at parse"),
|
return Some(EntryId(id));
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,9 +182,8 @@ impl Archive {
|
|||||||
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
|
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
|
||||||
== Ordering::Equal
|
== Ordering::Equal
|
||||||
{
|
{
|
||||||
Some(EntryId(
|
let id = u32::try_from(idx).ok()?;
|
||||||
u32::try_from(idx).expect("entry count validated at parse"),
|
Some(EntryId(id))
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -155,6 +199,16 @@ impl Archive {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
|
||||||
|
let idx = usize::try_from(id.0).ok()?;
|
||||||
|
let entry = self.entries.get(idx)?;
|
||||||
|
Some(EntryInspect {
|
||||||
|
id,
|
||||||
|
meta: &entry.meta,
|
||||||
|
name_raw: &entry.name_raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
|
pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
|
||||||
let range = self.entry_range(id)?;
|
let range = self.entry_range(id)?;
|
||||||
Ok(ResourceData::Borrowed(&self.bytes[range]))
|
Ok(ResourceData::Borrowed(&self.bytes[range]))
|
||||||
@@ -197,7 +251,7 @@ impl Archive {
|
|||||||
let Some(entry) = self.entries.get(idx) else {
|
let Some(entry) = self.entries.get(idx) else {
|
||||||
return Err(Error::EntryIdOutOfRange {
|
return Err(Error::EntryIdOutOfRange {
|
||||||
id: id.0,
|
id: id.0,
|
||||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
entry_count: saturating_u32_len(self.entries.len()),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
checked_range(
|
checked_range(
|
||||||
@@ -248,13 +302,13 @@ pub struct NewEntry<'a> {
|
|||||||
|
|
||||||
impl Editor {
|
impl Editor {
|
||||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||||
self.entries
|
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||||
.iter()
|
let id = u32::try_from(idx).ok()?;
|
||||||
.enumerate()
|
Some(EntryRef {
|
||||||
.map(|(idx, entry)| EntryRef {
|
id: EntryId(id),
|
||||||
id: EntryId(u32::try_from(idx).expect("entry count validated at add")),
|
|
||||||
meta: &entry.meta,
|
meta: &entry.meta,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> {
|
pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> {
|
||||||
@@ -283,7 +337,7 @@ impl Editor {
|
|||||||
let Some(entry) = self.entries.get_mut(idx) else {
|
let Some(entry) = self.entries.get_mut(idx) else {
|
||||||
return Err(Error::EntryIdOutOfRange {
|
return Err(Error::EntryIdOutOfRange {
|
||||||
id: id.0,
|
id: id.0,
|
||||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
entry_count: saturating_u32_len(self.entries.len()),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?;
|
entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||||
@@ -297,7 +351,7 @@ impl Editor {
|
|||||||
if idx >= self.entries.len() {
|
if idx >= self.entries.len() {
|
||||||
return Err(Error::EntryIdOutOfRange {
|
return Err(Error::EntryIdOutOfRange {
|
||||||
id: id.0,
|
id: id.0,
|
||||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
entry_count: saturating_u32_len(self.entries.len()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
self.entries.remove(idx);
|
self.entries.remove(idx);
|
||||||
@@ -350,6 +404,8 @@ impl Editor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (idx, entry) in self.entries.iter_mut().enumerate() {
|
for (idx, entry) in self.entries.iter_mut().enumerate() {
|
||||||
|
// sort_index stores the original-entry index at sorted position `idx`.
|
||||||
|
// This mirrors the format emitted by the retail assets and test fixtures.
|
||||||
entry.meta.sort_index =
|
entry.meta.sort_index =
|
||||||
u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?;
|
u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?;
|
||||||
}
|
}
|
||||||
@@ -377,7 +433,10 @@ impl Editor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)> {
|
fn parse_archive(
|
||||||
|
bytes: &[u8],
|
||||||
|
raw_mode: bool,
|
||||||
|
) -> Result<(Vec<EntryRecord>, Option<ArchiveHeader>)> {
|
||||||
if raw_mode {
|
if raw_mode {
|
||||||
let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
||||||
let entry = EntryRecord {
|
let entry = EntryRecord {
|
||||||
@@ -398,10 +457,7 @@ fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)
|
|||||||
name
|
name
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
return Ok((
|
return Ok((vec![entry], None));
|
||||||
vec![entry],
|
|
||||||
u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if bytes.len() < 16 {
|
if bytes.len() < 16 {
|
||||||
@@ -526,7 +582,17 @@ fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok((entries, directory_offset))
|
Ok((
|
||||||
|
entries,
|
||||||
|
Some(ArchiveHeader {
|
||||||
|
magic: *b"NRes",
|
||||||
|
version,
|
||||||
|
entry_count: u32::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?,
|
||||||
|
total_size,
|
||||||
|
directory_offset,
|
||||||
|
directory_size: directory_len,
|
||||||
|
}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
|
fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
|
||||||
@@ -599,8 +665,12 @@ fn ascii_lower(value: u8) -> u8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn saturating_u32_len(len: usize) -> u32 {
|
||||||
|
u32::try_from(len).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
fn prefetch_pages(bytes: &[u8]) {
|
fn prefetch_pages(bytes: &[u8]) {
|
||||||
use std::sync::atomic::{compiler_fence, Ordering};
|
use std::hint::black_box;
|
||||||
|
|
||||||
let mut cursor = 0usize;
|
let mut cursor = 0usize;
|
||||||
let mut sink = 0u8;
|
let mut sink = 0u8;
|
||||||
@@ -608,8 +678,7 @@ fn prefetch_pages(bytes: &[u8]) {
|
|||||||
sink ^= bytes[cursor];
|
sink ^= bytes[cursor];
|
||||||
cursor = cursor.saturating_add(4096);
|
cursor = cursor.saturating_add(4096);
|
||||||
}
|
}
|
||||||
compiler_fence(Ordering::SeqCst);
|
black_box(sink);
|
||||||
let _ = sink;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
|
fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
|
||||||
@@ -675,7 +744,8 @@ fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
|
|||||||
let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
||||||
let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect();
|
||||||
|
|
||||||
// Replace destination in one OS call, avoiding remove+rename gaps on Windows.
|
// SAFETY: pointers reference NUL-terminated UTF-16 buffers that stay alive
|
||||||
|
// for the duration of the call; flags and argument contract match WinAPI.
|
||||||
let ok = unsafe {
|
let ok = unsafe {
|
||||||
MoveFileExW(
|
MoveFileExW(
|
||||||
src_wide.as_ptr(),
|
src_wide.as_ptr(),
|
||||||
|
|||||||
@@ -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("..")
|
||||||
|
|||||||
@@ -5,4 +5,7 @@ edition = "2021"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
msh-core = { path = "../msh-core" }
|
msh-core = { path = "../msh-core" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
nres = { path = "../nres" }
|
nres = { path = "../nres" }
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use msh_core::Model;
|
use msh_core::Model;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub const DEFAULT_UV_SCALE: f32 = 1024.0;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RenderVertex {
|
pub struct RenderVertex {
|
||||||
@@ -9,21 +12,24 @@ pub struct RenderVertex {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct RenderMesh {
|
pub struct RenderMesh {
|
||||||
pub vertices: Vec<RenderVertex>,
|
pub vertices: Vec<RenderVertex>,
|
||||||
|
pub indices: Vec<u16>,
|
||||||
pub batch_count: usize,
|
pub batch_count: usize,
|
||||||
|
pub index_overflow: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderMesh {
|
impl RenderMesh {
|
||||||
pub fn triangle_count(&self) -> usize {
|
pub fn triangle_count(&self) -> usize {
|
||||||
self.vertices.len() / 3
|
self.indices.len() / 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds an expanded triangle list for a specific LOD/group pair.
|
/// Builds an indexed triangle mesh for a specific LOD/group pair.
|
||||||
///
|
|
||||||
/// The output is suitable for simple `glDrawArrays(GL_TRIANGLES, ...)` paths.
|
|
||||||
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
|
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
|
||||||
let mut vertices = Vec::new();
|
let mut vertices = Vec::new();
|
||||||
|
let mut indices = Vec::new();
|
||||||
|
let mut index_remap: HashMap<usize, u16> = HashMap::new();
|
||||||
let mut batch_count = 0usize;
|
let mut batch_count = 0usize;
|
||||||
|
let mut index_overflow = false;
|
||||||
let uv0 = model.uv0.as_ref();
|
let uv0 = model.uv0.as_ref();
|
||||||
|
|
||||||
for node_index in 0..model.node_count {
|
for node_index in 0..model.node_count {
|
||||||
@@ -47,67 +53,88 @@ pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let batch_out_start = indices.len();
|
||||||
|
let mut batch_valid = true;
|
||||||
for &idx in &model.indices[index_start..index_end] {
|
for &idx in &model.indices[index_start..index_end] {
|
||||||
let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx));
|
let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx));
|
||||||
let Ok(final_idx) = usize::try_from(final_idx_u64) else {
|
let Ok(final_idx) = usize::try_from(final_idx_u64) else {
|
||||||
continue;
|
batch_valid = false;
|
||||||
|
break;
|
||||||
};
|
};
|
||||||
let Some(pos) = model.positions.get(final_idx) else {
|
let Some(pos) = model.positions.get(final_idx) else {
|
||||||
continue;
|
batch_valid = false;
|
||||||
|
break;
|
||||||
};
|
};
|
||||||
let uv = uv0
|
|
||||||
.and_then(|uvs| uvs.get(final_idx))
|
let local_index = if let Some(&mapped) = index_remap.get(&final_idx) {
|
||||||
.copied()
|
mapped
|
||||||
.map(|packed| [packed[0] as f32 / 1024.0, packed[1] as f32 / 1024.0])
|
} else {
|
||||||
.unwrap_or([0.0, 0.0]);
|
let Ok(mapped) = u16::try_from(vertices.len()) else {
|
||||||
vertices.push(RenderVertex {
|
index_overflow = true;
|
||||||
position: *pos,
|
batch_valid = false;
|
||||||
uv0: uv,
|
break;
|
||||||
});
|
};
|
||||||
|
let uv = uv0
|
||||||
|
.and_then(|uvs| uvs.get(final_idx))
|
||||||
|
.copied()
|
||||||
|
.map(|packed| {
|
||||||
|
[
|
||||||
|
packed[0] as f32 / DEFAULT_UV_SCALE,
|
||||||
|
packed[1] as f32 / DEFAULT_UV_SCALE,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.unwrap_or([0.0, 0.0]);
|
||||||
|
vertices.push(RenderVertex {
|
||||||
|
position: *pos,
|
||||||
|
uv0: uv,
|
||||||
|
});
|
||||||
|
index_remap.insert(final_idx, mapped);
|
||||||
|
mapped
|
||||||
|
};
|
||||||
|
|
||||||
|
indices.push(local_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !batch_valid {
|
||||||
|
indices.truncate(batch_out_start);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
batch_count += 1;
|
batch_count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderMesh {
|
RenderMesh {
|
||||||
vertices,
|
vertices,
|
||||||
|
indices,
|
||||||
batch_count,
|
batch_count,
|
||||||
|
index_overflow,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
|
pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
|
||||||
let mut iter = vertices.iter();
|
compute_bounds_impl(vertices.iter().copied())
|
||||||
let first = iter.next()?;
|
|
||||||
let mut min_v = *first;
|
|
||||||
let mut max_v = *first;
|
|
||||||
|
|
||||||
for v in iter {
|
|
||||||
for i in 0..3 {
|
|
||||||
if v[i] < min_v[i] {
|
|
||||||
min_v[i] = v[i];
|
|
||||||
}
|
|
||||||
if v[i] > max_v[i] {
|
|
||||||
max_v[i] = v[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some((min_v, max_v))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> {
|
pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> {
|
||||||
let mut iter = vertices.iter();
|
compute_bounds_impl(vertices.iter().map(|v| v.position))
|
||||||
let first = iter.next()?;
|
}
|
||||||
let mut min_v = first.position;
|
|
||||||
let mut max_v = first.position;
|
|
||||||
|
|
||||||
for v in iter {
|
fn compute_bounds_impl<I>(mut positions: I) -> Option<([f32; 3], [f32; 3])>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = [f32; 3]>,
|
||||||
|
{
|
||||||
|
let first = positions.next()?;
|
||||||
|
let mut min_v = first;
|
||||||
|
let mut max_v = first;
|
||||||
|
|
||||||
|
for pos in positions {
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
if v.position[i] < min_v[i] {
|
if pos[i] < min_v[i] {
|
||||||
min_v[i] = v.position[i];
|
min_v[i] = pos[i];
|
||||||
}
|
}
|
||||||
if v.position[i] > max_v[i] {
|
if pos[i] > max_v[i] {
|
||||||
max_v[i] = v.position[i];
|
max_v[i] = pos[i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
use msh_core::parse_model_payload;
|
use msh_core::parse_model_payload;
|
||||||
use nres::Archive;
|
use nres::Archive;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
|
||||||
let Ok(entries) = fs::read_dir(root) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
collect_files_recursive(&path, out);
|
|
||||||
} else if path.is_file() {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nres_test_files() -> Vec<PathBuf> {
|
fn nres_test_files() -> Vec<PathBuf> {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("..")
|
.join("..")
|
||||||
@@ -71,12 +58,20 @@ fn build_render_mesh_for_real_models() {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
let mesh = build_render_mesh(&model, 0, 0);
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
if !mesh.vertices.is_empty() {
|
if !mesh.indices.is_empty() {
|
||||||
meshes_non_empty += 1;
|
meshes_non_empty += 1;
|
||||||
}
|
}
|
||||||
if compute_bounds_for_mesh(&mesh.vertices).is_some() {
|
if compute_bounds_for_mesh(&mesh.vertices).is_some() {
|
||||||
bounds_non_empty += 1;
|
bounds_non_empty += 1;
|
||||||
}
|
}
|
||||||
|
for &index in &mesh.indices {
|
||||||
|
assert!(
|
||||||
|
usize::from(index) < mesh.vertices.len(),
|
||||||
|
"index out of bounds for '{}' in {}",
|
||||||
|
entry.meta.name,
|
||||||
|
archive_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
for vertex in &mesh.vertices {
|
for vertex in &mesh.vertices {
|
||||||
assert!(
|
assert!(
|
||||||
vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(),
|
vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(),
|
||||||
@@ -129,3 +124,133 @@ fn compute_bounds_for_mesh_handles_empty_and_non_empty() {
|
|||||||
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
|
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
|
||||||
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
|
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn nodes_with_slot_refs(slot_ids: &[Option<u16>]) -> Vec<u8> {
|
||||||
|
let mut out = vec![0u8; slot_ids.len().saturating_mul(38)];
|
||||||
|
for (node_index, slot_id) in slot_ids.iter().copied().enumerate() {
|
||||||
|
let node_off = node_index * 38;
|
||||||
|
for i in 0..15 {
|
||||||
|
let off = node_off + 8 + i * 2;
|
||||||
|
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||||
|
}
|
||||||
|
if let Some(slot_id) = slot_id {
|
||||||
|
out[node_off + 8..node_off + 10].copy_from_slice(&slot_id.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot(batch_start: u16, batch_count: u16) -> msh_core::Slot {
|
||||||
|
msh_core::Slot {
|
||||||
|
tri_start: 0,
|
||||||
|
tri_count: 0,
|
||||||
|
batch_start,
|
||||||
|
batch_count,
|
||||||
|
aabb_min: [0.0; 3],
|
||||||
|
aabb_max: [0.0; 3],
|
||||||
|
sphere_center: [0.0; 3],
|
||||||
|
sphere_radius: 0.0,
|
||||||
|
opaque: [0; 5],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> msh_core::Batch {
|
||||||
|
msh_core::Batch {
|
||||||
|
batch_flags: 0,
|
||||||
|
material_index: 0,
|
||||||
|
opaque4: 0,
|
||||||
|
opaque6: 0,
|
||||||
|
index_count,
|
||||||
|
index_start,
|
||||||
|
opaque14: 0,
|
||||||
|
base_vertex,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_render_mesh_handles_empty_slot_model() {
|
||||||
|
let model = msh_core::Model {
|
||||||
|
node_stride: 38,
|
||||||
|
node_count: 1,
|
||||||
|
nodes_raw: nodes_with_slot_refs(&[None]),
|
||||||
|
slots: Vec::new(),
|
||||||
|
positions: vec![[0.0, 0.0, 0.0]],
|
||||||
|
normals: None,
|
||||||
|
uv0: None,
|
||||||
|
indices: Vec::new(),
|
||||||
|
batches: Vec::new(),
|
||||||
|
node_names: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
|
assert!(mesh.vertices.is_empty());
|
||||||
|
assert!(mesh.indices.is_empty());
|
||||||
|
assert_eq!(mesh.batch_count, 0);
|
||||||
|
assert_eq!(mesh.triangle_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_render_mesh_supports_multi_node_and_uv_scaling() {
|
||||||
|
let model = msh_core::Model {
|
||||||
|
node_stride: 38,
|
||||||
|
node_count: 2,
|
||||||
|
nodes_raw: nodes_with_slot_refs(&[Some(0), Some(1)]),
|
||||||
|
slots: vec![slot(0, 1), slot(1, 1)],
|
||||||
|
positions: vec![
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[2.0, 0.0, 0.0],
|
||||||
|
[3.0, 0.0, 0.0],
|
||||||
|
[2.0, 1.0, 0.0],
|
||||||
|
],
|
||||||
|
normals: None,
|
||||||
|
uv0: Some(vec![
|
||||||
|
[1024, -1024],
|
||||||
|
[512, 256],
|
||||||
|
[0, 0],
|
||||||
|
[1024, 1024],
|
||||||
|
[2048, 1024],
|
||||||
|
[1024, 0],
|
||||||
|
]),
|
||||||
|
indices: vec![0, 1, 2, 0, 1, 2],
|
||||||
|
batches: vec![batch(0, 3, 0), batch(3, 3, 3)],
|
||||||
|
node_names: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
|
assert_eq!(mesh.batch_count, 2);
|
||||||
|
assert_eq!(mesh.vertices.len(), 6);
|
||||||
|
assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]);
|
||||||
|
assert_eq!(mesh.triangle_count(), 2);
|
||||||
|
assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]);
|
||||||
|
assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]);
|
||||||
|
assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]);
|
||||||
|
assert_eq!(mesh.vertices[3].uv0, [1.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_render_mesh_deduplicates_shared_vertices() {
|
||||||
|
let model = msh_core::Model {
|
||||||
|
node_stride: 38,
|
||||||
|
node_count: 1,
|
||||||
|
nodes_raw: nodes_with_slot_refs(&[Some(0)]),
|
||||||
|
slots: vec![slot(0, 1)],
|
||||||
|
positions: vec![
|
||||||
|
[0.0, 0.0, 0.0],
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[1.0, 1.0, 0.0],
|
||||||
|
],
|
||||||
|
normals: None,
|
||||||
|
uv0: None,
|
||||||
|
indices: vec![0, 1, 2, 2, 1, 3],
|
||||||
|
batches: vec![batch(0, 6, 0)],
|
||||||
|
node_names: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mesh = build_render_mesh(&model, 0, 0);
|
||||||
|
assert_eq!(mesh.vertices.len(), 4);
|
||||||
|
assert_eq!(mesh.indices, vec![0, 1, 2, 2, 1, 3]);
|
||||||
|
assert_eq!(mesh.triangle_count(), 2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,14 +8,23 @@ default = []
|
|||||||
demo = ["dep:sdl2", "dep:glow", "dep:image"]
|
demo = ["dep:sdl2", "dep:glow", "dep:image"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
msh-core = { path = "../msh-core" }
|
msh-core = { path = "../msh-core" }
|
||||||
nres = { path = "../nres" }
|
nres = { path = "../nres" }
|
||||||
render-core = { path = "../render-core" }
|
render-core = { path = "../render-core" }
|
||||||
texm = { path = "../texm" }
|
texm = { path = "../texm" }
|
||||||
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
glow = { version = "0.17", optional = true }
|
||||||
glow = { version = "0.16", optional = true }
|
|
||||||
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
|
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
sdl2 = { version = "0.38", optional = true, default-features = false, features = ["use-pkgconfig"] }
|
||||||
|
|
||||||
|
[target.'cfg(not(target_os = "macos"))'.dependencies]
|
||||||
|
sdl2 = { version = "0.38", optional = true, default-features = false, features = ["bundled", "static-link"] }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "parkan-render-demo"
|
name = "parkan-render-demo"
|
||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
use encoding_rs::WINDOWS_1251;
|
||||||
use msh_core::{parse_model_payload, Model};
|
use msh_core::{parse_model_payload, Model};
|
||||||
use nres::{Archive, EntryRef};
|
use nres::{Archive, EntryRef};
|
||||||
|
use std::fmt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use texm::{decode_mip_rgba8, parse_texm};
|
use texm::{decode_mip_rgba8, parse_texm};
|
||||||
|
|
||||||
@@ -22,6 +24,37 @@ pub enum Error {
|
|||||||
InvalidMaterial(String),
|
InvalidMaterial(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Nres(err) => write!(f, "{err}"),
|
||||||
|
Self::Msh(err) => write!(f, "{err}"),
|
||||||
|
Self::Texm(err) => write!(f, "{err}"),
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
Self::NoMshEntries => write!(f, "archive does not contain .msh entries"),
|
||||||
|
Self::ModelNotFound(name) => write!(f, "model not found: {name}"),
|
||||||
|
Self::NoTexmEntries => write!(f, "archive does not contain Texm entries"),
|
||||||
|
Self::TextureNotFound(name) => write!(f, "texture not found: {name}"),
|
||||||
|
Self::MaterialNotFound(name) => write!(f, "material not found: {name}"),
|
||||||
|
Self::WearNotFound(name) => write!(f, "wear entry not found: {name}"),
|
||||||
|
Self::InvalidWear(reason) => write!(f, "invalid WEAR payload: {reason}"),
|
||||||
|
Self::InvalidMaterial(reason) => write!(f, "invalid MAT0 payload: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for Error {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Nres(err) => Some(err),
|
||||||
|
Self::Msh(err) => Some(err),
|
||||||
|
Self::Texm(err) => Some(err),
|
||||||
|
Self::Io(err) => Some(err),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<nres::error::Error> for Error {
|
impl From<nres::error::Error> for Error {
|
||||||
fn from(value: nres::error::Error) -> Self {
|
fn from(value: nres::error::Error) -> Self {
|
||||||
Self::Nres(value)
|
Self::Nres(value)
|
||||||
@@ -280,7 +313,7 @@ fn find_material_entry_with_fallback<'a>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
|
fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
|
||||||
let text = String::from_utf8_lossy(payload).replace('\r', "");
|
let text = decode_cp1251(payload).replace('\r', "");
|
||||||
let mut lines = text.lines();
|
let mut lines = text.lines();
|
||||||
let Some(first) = lines.next() else {
|
let Some(first) = lines.next() else {
|
||||||
return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
|
return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
|
||||||
@@ -360,9 +393,7 @@ fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Op
|
|||||||
.iter()
|
.iter()
|
||||||
.position(|&b| b == 0)
|
.position(|&b| b == 0)
|
||||||
.unwrap_or(name_raw.len());
|
.unwrap_or(name_raw.len());
|
||||||
let name = String::from_utf8_lossy(&name_raw[..name_end])
|
let name = decode_cp1251(&name_raw[..name_end]).trim().to_string();
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
return Ok(Some(name));
|
return Ok(Some(name));
|
||||||
}
|
}
|
||||||
@@ -371,6 +402,11 @@ fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Op
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_cp1251(bytes: &[u8]) -> String {
|
||||||
|
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
|
fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
|
||||||
let Some(id) = archive.find(name) else {
|
let Some(id) = archive.find(name) else {
|
||||||
return Err(Error::TextureNotFound(name.to_string()));
|
return Err(Error::TextureNotFound(name.to_string()));
|
||||||
@@ -399,23 +435,10 @@ fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result<Loaded
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
|
||||||
let Ok(entries) = fs::read_dir(root) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
collect_files_recursive(&path, out);
|
|
||||||
} else if path.is_file() {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn archive_with_msh() -> Option<PathBuf> {
|
fn archive_with_msh() -> Option<PathBuf> {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("..")
|
.join("..")
|
||||||
@@ -524,4 +547,45 @@ mod tests {
|
|||||||
assert!(texture.width > 0 && texture.height > 0);
|
assert!(texture.width > 0 && texture.height > 0);
|
||||||
assert!(!texture.rgba8.is_empty());
|
assert!(!texture.rgba8.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_wear_material_names_parses_counted_lines() {
|
||||||
|
let payload = b"2\r\n0 MAT_A\r\n1 MAT_B\r\n";
|
||||||
|
let materials =
|
||||||
|
parse_wear_material_names(payload).expect("failed to parse valid WEAR payload");
|
||||||
|
assert_eq!(materials, vec!["MAT_A".to_string(), "MAT_B".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_wear_material_names_rejects_invalid_payload() {
|
||||||
|
let payload = b"2\n0 ONLY_ONE\n";
|
||||||
|
assert!(matches!(
|
||||||
|
parse_wear_material_names(payload),
|
||||||
|
Err(Error::InvalidWear(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primary_texture_name_from_mat0_respects_attr2_layout() {
|
||||||
|
let mut payload = vec![0u8; 4 + 10 + 34];
|
||||||
|
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
|
||||||
|
// attr2=4 adds 10 bytes before phase table
|
||||||
|
let name = b"TEX_MAIN";
|
||||||
|
payload[4 + 10 + 18..4 + 10 + 18 + name.len()].copy_from_slice(name);
|
||||||
|
|
||||||
|
let parsed = parse_primary_texture_name_from_mat0(&payload, 4)
|
||||||
|
.expect("failed to parse MAT0 payload with attr2=4");
|
||||||
|
assert_eq!(parsed, Some("TEX_MAIN".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_primary_texture_name_from_mat0_decodes_cp1251_bytes() {
|
||||||
|
let mut payload = vec![0u8; 4 + 34];
|
||||||
|
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
|
||||||
|
payload[4 + 18] = 0xC0; // 'А' in CP1251
|
||||||
|
|
||||||
|
let parsed =
|
||||||
|
parse_primary_texture_name_from_mat0(&payload, 0).expect("failed to parse MAT0");
|
||||||
|
assert_eq!(parsed, Some("А".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use glow::HasContext as _;
|
use glow::HasContext as _;
|
||||||
use render_core::{build_render_mesh, compute_bounds_for_mesh};
|
use render_core::{build_render_mesh, compute_bounds_for_mesh};
|
||||||
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
|
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
|
||||||
|
use std::io::Write as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Instant;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
struct Args {
|
struct Args {
|
||||||
archive: PathBuf,
|
archive: PathBuf,
|
||||||
@@ -11,6 +12,7 @@ struct Args {
|
|||||||
group: usize,
|
group: usize,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
fov_deg: f32,
|
||||||
capture: Option<PathBuf>,
|
capture: Option<PathBuf>,
|
||||||
angle: Option<f32>,
|
angle: Option<f32>,
|
||||||
spin_rate: f32,
|
spin_rate: f32,
|
||||||
@@ -25,6 +27,12 @@ struct GpuTexture {
|
|||||||
handle: glow::NativeTexture,
|
handle: glow::NativeTexture,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
enum GlBackend {
|
||||||
|
Gles2,
|
||||||
|
Core33,
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_args() -> Result<Args, String> {
|
fn parse_args() -> Result<Args, String> {
|
||||||
let mut archive = None;
|
let mut archive = None;
|
||||||
let mut model = None;
|
let mut model = None;
|
||||||
@@ -32,6 +40,7 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
let mut group = 0usize;
|
let mut group = 0usize;
|
||||||
let mut width = 1280u32;
|
let mut width = 1280u32;
|
||||||
let mut height = 720u32;
|
let mut height = 720u32;
|
||||||
|
let mut fov_deg = 60.0f32;
|
||||||
let mut capture = None;
|
let mut capture = None;
|
||||||
let mut angle = None;
|
let mut angle = None;
|
||||||
let mut spin_rate = 0.35f32;
|
let mut spin_rate = 0.35f32;
|
||||||
@@ -94,6 +103,17 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
return Err(String::from("--height must be > 0"));
|
return Err(String::from("--height must be > 0"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"--fov" => {
|
||||||
|
let value = it
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| String::from("missing value for --fov"))?;
|
||||||
|
fov_deg = value
|
||||||
|
.parse::<f32>()
|
||||||
|
.map_err(|_| String::from("invalid --fov value"))?;
|
||||||
|
if !(1.0..=179.0).contains(&fov_deg) {
|
||||||
|
return Err(String::from("--fov must be in range [1, 179]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
"--capture" => {
|
"--capture" => {
|
||||||
let value = it
|
let value = it
|
||||||
.next()
|
.next()
|
||||||
@@ -163,6 +183,7 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
group,
|
group,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
fov_deg,
|
||||||
capture,
|
capture,
|
||||||
angle,
|
angle,
|
||||||
spin_rate,
|
spin_rate,
|
||||||
@@ -176,7 +197,7 @@ fn parse_args() -> Result<Args, String> {
|
|||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H]"
|
"parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H] [--fov DEG]"
|
||||||
);
|
);
|
||||||
eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
|
eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
|
||||||
eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
|
eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
|
||||||
@@ -202,17 +223,22 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
|
let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
format!(
|
format!(
|
||||||
"failed to load model from archive {}: {err:?}",
|
"failed to load model from archive {}: {err}",
|
||||||
args.archive.display()
|
args.archive.display()
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
|
let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
|
||||||
if mesh.vertices.is_empty() {
|
if mesh.indices.is_empty() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"model has no renderable triangles for lod={} group={}",
|
"model has no renderable triangles for lod={} group={}",
|
||||||
args.lod, args.group
|
args.lod, args.group
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if mesh.index_overflow {
|
||||||
|
eprintln!(
|
||||||
|
"warning: mesh exceeds u16 index space and may be partially rendered on GLES2 targets"
|
||||||
|
);
|
||||||
|
}
|
||||||
let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else {
|
let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else {
|
||||||
return Err(String::from("failed to compute mesh bounds"));
|
return Err(String::from("failed to compute mesh bounds"));
|
||||||
};
|
};
|
||||||
@@ -246,35 +272,7 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
.video()
|
.video()
|
||||||
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
|
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
|
||||||
|
|
||||||
{
|
let (mut window, _gl_ctx, gl_backend) = create_window_and_context(&video, &args)?;
|
||||||
let gl_attr = video.gl_attr();
|
|
||||||
gl_attr.set_context_profile(sdl2::video::GLProfile::GLES);
|
|
||||||
gl_attr.set_context_version(2, 0);
|
|
||||||
gl_attr.set_depth_size(24);
|
|
||||||
gl_attr.set_double_buffer(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut window_builder = video.window(
|
|
||||||
"Parkan Render Demo (SDL2 + OpenGL ES 2.0)",
|
|
||||||
args.width,
|
|
||||||
args.height,
|
|
||||||
);
|
|
||||||
window_builder.opengl();
|
|
||||||
if args.capture.is_some() {
|
|
||||||
window_builder.hidden();
|
|
||||||
} else {
|
|
||||||
window_builder.resizable();
|
|
||||||
}
|
|
||||||
let window = window_builder
|
|
||||||
.build()
|
|
||||||
.map_err(|err| format!("failed to create window: {err}"))?;
|
|
||||||
|
|
||||||
let gl_ctx = window
|
|
||||||
.gl_create_context()
|
|
||||||
.map_err(|err| format!("failed to create OpenGL context: {err}"))?;
|
|
||||||
window
|
|
||||||
.gl_make_current(&gl_ctx)
|
|
||||||
.map_err(|err| format!("failed to make GL context current: {err}"))?;
|
|
||||||
let _ = if args.capture.is_some() {
|
let _ = if args.capture.is_some() {
|
||||||
video.gl_set_swap_interval(0)
|
video.gl_set_swap_interval(0)
|
||||||
} else {
|
} else {
|
||||||
@@ -289,12 +287,14 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
vertex_data.push(vertex.uv0[0]);
|
vertex_data.push(vertex.uv0[0]);
|
||||||
vertex_data.push(vertex.uv0[1]);
|
vertex_data.push(vertex.uv0[1]);
|
||||||
}
|
}
|
||||||
|
let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data);
|
||||||
|
let index_bytes = u16_slice_to_ne_bytes(&mesh.indices);
|
||||||
|
|
||||||
let gl = unsafe {
|
let gl = unsafe {
|
||||||
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
|
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
|
||||||
};
|
};
|
||||||
|
|
||||||
let program = unsafe { create_program(&gl)? };
|
let program = unsafe { create_program(&gl, gl_backend)? };
|
||||||
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
|
||||||
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
|
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
|
||||||
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
|
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
|
||||||
@@ -304,15 +304,16 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
|
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
|
||||||
|
|
||||||
let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
|
let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
|
||||||
|
let ebo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
|
||||||
unsafe {
|
unsafe {
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
gl.buffer_data_u8_slice(
|
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
|
||||||
glow::ARRAY_BUFFER,
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||||
cast_slice_u8(&vertex_data),
|
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
|
||||||
glow::STATIC_DRAW,
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||||
);
|
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
}
|
}
|
||||||
|
let vao = unsafe { create_vertex_layout_if_needed(&gl, gl_backend, vbo, ebo, a_pos, a_uv)? };
|
||||||
|
|
||||||
let gpu_texture = if let Some(texture) = resolved_texture.as_ref() {
|
let gpu_texture = if let Some(texture) = resolved_texture.as_ref() {
|
||||||
Some(unsafe { create_texture(&gl, texture)? })
|
Some(unsafe { create_texture(&gl, texture)? })
|
||||||
@@ -330,8 +331,10 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
a_pos,
|
a_pos,
|
||||||
a_uv,
|
a_uv,
|
||||||
vbo,
|
vbo,
|
||||||
|
ebo,
|
||||||
|
vao,
|
||||||
gpu_texture.as_ref(),
|
gpu_texture.as_ref(),
|
||||||
mesh.vertices.len(),
|
mesh.indices.len(),
|
||||||
&args,
|
&args,
|
||||||
center,
|
center,
|
||||||
camera_distance,
|
camera_distance,
|
||||||
@@ -340,7 +343,7 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
} else {
|
} else {
|
||||||
run_interactive(
|
run_interactive(
|
||||||
&sdl,
|
&sdl,
|
||||||
&window,
|
&mut window,
|
||||||
&gl,
|
&gl,
|
||||||
program,
|
program,
|
||||||
u_mvp.as_ref(),
|
u_mvp.as_ref(),
|
||||||
@@ -349,8 +352,10 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
a_pos,
|
a_pos,
|
||||||
a_uv,
|
a_uv,
|
||||||
vbo,
|
vbo,
|
||||||
|
ebo,
|
||||||
|
vao,
|
||||||
gpu_texture.as_ref(),
|
gpu_texture.as_ref(),
|
||||||
mesh.vertices.len(),
|
mesh.indices.len(),
|
||||||
&args,
|
&args,
|
||||||
center,
|
center,
|
||||||
camera_distance,
|
camera_distance,
|
||||||
@@ -361,6 +366,10 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
if let Some(texture) = gpu_texture {
|
if let Some(texture) = gpu_texture {
|
||||||
gl.delete_texture(texture.handle);
|
gl.delete_texture(texture.handle);
|
||||||
}
|
}
|
||||||
|
if let Some(vao) = vao {
|
||||||
|
gl.delete_vertex_array(vao);
|
||||||
|
}
|
||||||
|
gl.delete_buffer(ebo);
|
||||||
gl.delete_buffer(vbo);
|
gl.delete_buffer(vbo);
|
||||||
gl.delete_program(program);
|
gl.delete_program(program);
|
||||||
}
|
}
|
||||||
@@ -368,6 +377,97 @@ fn run(args: Args) -> Result<(), String> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_window_and_context(
|
||||||
|
video: &sdl2::VideoSubsystem,
|
||||||
|
args: &Args,
|
||||||
|
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
|
||||||
|
let candidates = [
|
||||||
|
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
|
||||||
|
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
|
||||||
|
];
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
|
||||||
|
for (backend, profile, major, minor) in candidates {
|
||||||
|
{
|
||||||
|
let gl_attr = video.gl_attr();
|
||||||
|
gl_attr.set_context_profile(profile);
|
||||||
|
gl_attr.set_context_version(major, minor);
|
||||||
|
gl_attr.set_depth_size(24);
|
||||||
|
gl_attr.set_double_buffer(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut window_builder = video.window(
|
||||||
|
"Parkan Render Demo (SDL2 + OpenGL)",
|
||||||
|
args.width,
|
||||||
|
args.height,
|
||||||
|
);
|
||||||
|
window_builder.opengl();
|
||||||
|
if args.capture.is_some() {
|
||||||
|
window_builder.hidden();
|
||||||
|
} else {
|
||||||
|
window_builder.resizable();
|
||||||
|
}
|
||||||
|
|
||||||
|
let window = match window_builder.build() {
|
||||||
|
Ok(window) => window,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: window build failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let gl_ctx = match window.gl_create_context() {
|
||||||
|
Ok(ctx) => ctx,
|
||||||
|
Err(err) => {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: context create failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = window.gl_make_current(&gl_ctx) {
|
||||||
|
errors.push(format!(
|
||||||
|
"{profile:?} {major}.{minor}: make current failed ({err})"
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok((window, gl_ctx, backend));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"failed to create OpenGL context. Attempts: {}",
|
||||||
|
errors.join(" | ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn create_vertex_layout_if_needed(
|
||||||
|
gl: &glow::Context,
|
||||||
|
backend: GlBackend,
|
||||||
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
a_pos: u32,
|
||||||
|
a_uv: u32,
|
||||||
|
) -> Result<Option<glow::NativeVertexArray>, String> {
|
||||||
|
if backend != GlBackend::Core33 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let vao = gl.create_vertex_array().map_err(|e| e.to_string())?;
|
||||||
|
gl.bind_vertex_array(Some(vao));
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||||
|
gl.enable_vertex_attrib_array(a_pos);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
||||||
|
gl.enable_vertex_attrib_array(a_uv);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
||||||
|
gl.bind_vertex_array(None);
|
||||||
|
Ok(Some(vao))
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, String> {
|
fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, String> {
|
||||||
if args.no_texture {
|
if args.no_texture {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -388,11 +488,9 @@ fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture
|
|||||||
|| args.material_archive.is_some()
|
|| args.material_archive.is_some()
|
||||||
|| args.wear.is_some()
|
|| args.wear.is_some()
|
||||||
{
|
{
|
||||||
Err(format!("failed to resolve texture: {err:?}"))
|
Err(format!("failed to resolve texture: {err}"))
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color");
|
||||||
"warning: auto texture resolve failed ({err:?}), fallback to solid color"
|
|
||||||
);
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,15 +541,24 @@ fn run_capture(
|
|||||||
a_pos: u32,
|
a_pos: u32,
|
||||||
a_uv: u32,
|
a_uv: u32,
|
||||||
vbo: glow::NativeBuffer,
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
vao: Option<glow::NativeVertexArray>,
|
||||||
texture: Option<&GpuTexture>,
|
texture: Option<&GpuTexture>,
|
||||||
vertex_count: usize,
|
index_count: usize,
|
||||||
args: &Args,
|
args: &Args,
|
||||||
center: [f32; 3],
|
center: [f32; 3],
|
||||||
camera_distance: f32,
|
camera_distance: f32,
|
||||||
capture_path: &Path,
|
capture_path: &Path,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let angle = args.angle.unwrap_or(0.0);
|
let angle = args.angle.unwrap_or(0.0);
|
||||||
let mvp = compute_mvp(args.width, args.height, center, camera_distance, angle);
|
let mvp = compute_mvp(
|
||||||
|
args.width,
|
||||||
|
args.height,
|
||||||
|
args.fov_deg,
|
||||||
|
center,
|
||||||
|
camera_distance,
|
||||||
|
angle,
|
||||||
|
);
|
||||||
unsafe {
|
unsafe {
|
||||||
draw_frame(
|
draw_frame(
|
||||||
gl,
|
gl,
|
||||||
@@ -462,8 +569,10 @@ fn run_capture(
|
|||||||
a_pos,
|
a_pos,
|
||||||
a_uv,
|
a_uv,
|
||||||
vbo,
|
vbo,
|
||||||
|
ebo,
|
||||||
|
vao,
|
||||||
texture,
|
texture,
|
||||||
vertex_count,
|
index_count,
|
||||||
args.width,
|
args.width,
|
||||||
args.height,
|
args.height,
|
||||||
&mvp,
|
&mvp,
|
||||||
@@ -479,7 +588,7 @@ fn run_capture(
|
|||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn run_interactive(
|
fn run_interactive(
|
||||||
sdl: &sdl2::Sdl,
|
sdl: &sdl2::Sdl,
|
||||||
window: &sdl2::video::Window,
|
window: &mut sdl2::video::Window,
|
||||||
gl: &glow::Context,
|
gl: &glow::Context,
|
||||||
program: glow::NativeProgram,
|
program: glow::NativeProgram,
|
||||||
u_mvp: Option<&glow::NativeUniformLocation>,
|
u_mvp: Option<&glow::NativeUniformLocation>,
|
||||||
@@ -488,8 +597,10 @@ fn run_interactive(
|
|||||||
a_pos: u32,
|
a_pos: u32,
|
||||||
a_uv: u32,
|
a_uv: u32,
|
||||||
vbo: glow::NativeBuffer,
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
vao: Option<glow::NativeVertexArray>,
|
||||||
texture: Option<&GpuTexture>,
|
texture: Option<&GpuTexture>,
|
||||||
vertex_count: usize,
|
index_count: usize,
|
||||||
args: &Args,
|
args: &Args,
|
||||||
center: [f32; 3],
|
center: [f32; 3],
|
||||||
camera_distance: f32,
|
camera_distance: f32,
|
||||||
@@ -498,6 +609,10 @@ fn run_interactive(
|
|||||||
.event_pump()
|
.event_pump()
|
||||||
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
|
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
let mut fps_window_start = Instant::now();
|
||||||
|
let mut fps_frames: u32 = 0;
|
||||||
|
let mut fps_printed = false;
|
||||||
|
let base_title = "Parkan Render Demo (SDL2 + OpenGL)";
|
||||||
|
|
||||||
'main_loop: loop {
|
'main_loop: loop {
|
||||||
for event in events.poll_iter() {
|
for event in events.poll_iter() {
|
||||||
@@ -515,7 +630,7 @@ fn run_interactive(
|
|||||||
let angle = args
|
let angle = args
|
||||||
.angle
|
.angle
|
||||||
.unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
|
.unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
|
||||||
let mvp = compute_mvp(w, h, center, camera_distance, angle);
|
let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle);
|
||||||
|
|
||||||
unsafe {
|
unsafe {
|
||||||
draw_frame(
|
draw_frame(
|
||||||
@@ -527,14 +642,35 @@ fn run_interactive(
|
|||||||
a_pos,
|
a_pos,
|
||||||
a_uv,
|
a_uv,
|
||||||
vbo,
|
vbo,
|
||||||
|
ebo,
|
||||||
|
vao,
|
||||||
texture,
|
texture,
|
||||||
vertex_count,
|
index_count,
|
||||||
w,
|
w,
|
||||||
h,
|
h,
|
||||||
&mvp,
|
&mvp,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
window.gl_swap_window();
|
window.gl_swap_window();
|
||||||
|
|
||||||
|
fps_frames = fps_frames.saturating_add(1);
|
||||||
|
let elapsed = fps_window_start.elapsed();
|
||||||
|
if elapsed >= Duration::from_millis(500) {
|
||||||
|
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
|
||||||
|
let frame_time_ms = 1000.0 / fps.max(0.000_1);
|
||||||
|
let _ = window.set_title(&format!(
|
||||||
|
"{base_title} | FPS: {fps:.1} ({frame_time_ms:.2} ms)"
|
||||||
|
));
|
||||||
|
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
fps_printed = true;
|
||||||
|
fps_frames = 0;
|
||||||
|
fps_window_start = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if fps_printed {
|
||||||
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -543,12 +679,13 @@ fn run_interactive(
|
|||||||
fn compute_mvp(
|
fn compute_mvp(
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
|
fov_deg: f32,
|
||||||
center: [f32; 3],
|
center: [f32; 3],
|
||||||
camera_distance: f32,
|
camera_distance: f32,
|
||||||
angle_rad: f32,
|
angle_rad: f32,
|
||||||
) -> [f32; 16] {
|
) -> [f32; 16] {
|
||||||
let aspect = (width as f32 / (height.max(1) as f32)).max(0.01);
|
let aspect = (width as f32 / (height.max(1) as f32)).max(0.01);
|
||||||
let proj = mat4_perspective(60.0_f32.to_radians(), aspect, 0.01, camera_distance * 10.0);
|
let proj = mat4_perspective(fov_deg.to_radians(), aspect, 0.01, camera_distance * 10.0);
|
||||||
let view = mat4_translation(0.0, 0.0, -camera_distance);
|
let view = mat4_translation(0.0, 0.0, -camera_distance);
|
||||||
let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
|
let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
|
||||||
let rot = mat4_rotation_y(angle_rad);
|
let rot = mat4_rotation_y(angle_rad);
|
||||||
@@ -567,8 +704,10 @@ unsafe fn draw_frame(
|
|||||||
a_pos: u32,
|
a_pos: u32,
|
||||||
a_uv: u32,
|
a_uv: u32,
|
||||||
vbo: glow::NativeBuffer,
|
vbo: glow::NativeBuffer,
|
||||||
|
ebo: glow::NativeBuffer,
|
||||||
|
vao: Option<glow::NativeVertexArray>,
|
||||||
texture: Option<&GpuTexture>,
|
texture: Option<&GpuTexture>,
|
||||||
vertex_count: usize,
|
index_count: usize,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
mvp: &[f32; 16],
|
mvp: &[f32; 16],
|
||||||
@@ -596,19 +735,33 @@ unsafe fn draw_frame(
|
|||||||
gl.bind_texture(glow::TEXTURE_2D, None);
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
if let Some(vao) = vao {
|
||||||
gl.enable_vertex_attrib_array(a_pos);
|
gl.bind_vertex_array(Some(vao));
|
||||||
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
gl.draw_elements(
|
||||||
gl.enable_vertex_attrib_array(a_uv);
|
glow::TRIANGLES,
|
||||||
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
index_count.min(i32::MAX as usize) as i32,
|
||||||
gl.draw_arrays(
|
glow::UNSIGNED_SHORT,
|
||||||
glow::TRIANGLES,
|
0,
|
||||||
0,
|
);
|
||||||
vertex_count.min(i32::MAX as usize) as i32,
|
gl.bind_vertex_array(None);
|
||||||
);
|
} else {
|
||||||
gl.disable_vertex_attrib_array(a_uv);
|
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
|
||||||
gl.disable_vertex_attrib_array(a_pos);
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
|
||||||
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
gl.enable_vertex_attrib_array(a_pos);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
|
||||||
|
gl.enable_vertex_attrib_array(a_uv);
|
||||||
|
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
|
||||||
|
gl.draw_elements(
|
||||||
|
glow::TRIANGLES,
|
||||||
|
index_count.min(i32::MAX as usize) as i32,
|
||||||
|
glow::UNSIGNED_SHORT,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
gl.disable_vertex_attrib_array(a_uv);
|
||||||
|
gl.disable_vertex_attrib_array(a_pos);
|
||||||
|
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
|
||||||
|
gl.bind_buffer(glow::ARRAY_BUFFER, None);
|
||||||
|
}
|
||||||
gl.bind_texture(glow::TEXTURE_2D, None);
|
gl.bind_texture(glow::TEXTURE_2D, None);
|
||||||
gl.use_program(None);
|
gl.use_program(None);
|
||||||
}
|
}
|
||||||
@@ -663,8 +816,13 @@ fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), S
|
|||||||
.map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
|
.map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe fn create_program(gl: &glow::Context) -> Result<glow::NativeProgram, String> {
|
unsafe fn create_program(
|
||||||
let vs_src = r#"
|
gl: &glow::Context,
|
||||||
|
backend: GlBackend,
|
||||||
|
) -> Result<glow::NativeProgram, String> {
|
||||||
|
let (vs_src, fs_src) = match backend {
|
||||||
|
GlBackend::Gles2 => (
|
||||||
|
r#"
|
||||||
attribute vec3 a_pos;
|
attribute vec3 a_pos;
|
||||||
attribute vec2 a_uv;
|
attribute vec2 a_uv;
|
||||||
uniform mat4 u_mvp;
|
uniform mat4 u_mvp;
|
||||||
@@ -673,9 +831,8 @@ void main() {
|
|||||||
v_uv = a_uv;
|
v_uv = a_uv;
|
||||||
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
}
|
}
|
||||||
"#;
|
"#,
|
||||||
|
r#"
|
||||||
let fs_src = r#"
|
|
||||||
precision mediump float;
|
precision mediump float;
|
||||||
uniform sampler2D u_tex;
|
uniform sampler2D u_tex;
|
||||||
uniform float u_use_tex;
|
uniform float u_use_tex;
|
||||||
@@ -685,7 +842,32 @@ void main() {
|
|||||||
vec4 texColor = texture2D(u_tex, v_uv);
|
vec4 texColor = texture2D(u_tex, v_uv);
|
||||||
gl_FragColor = mix(base, texColor, u_use_tex);
|
gl_FragColor = mix(base, texColor, u_use_tex);
|
||||||
}
|
}
|
||||||
"#;
|
"#,
|
||||||
|
),
|
||||||
|
GlBackend::Core33 => (
|
||||||
|
r#"#version 330 core
|
||||||
|
in vec3 a_pos;
|
||||||
|
in vec2 a_uv;
|
||||||
|
uniform mat4 u_mvp;
|
||||||
|
out vec2 v_uv;
|
||||||
|
void main() {
|
||||||
|
v_uv = a_uv;
|
||||||
|
gl_Position = u_mvp * vec4(a_pos, 1.0);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
r#"#version 330 core
|
||||||
|
uniform sampler2D u_tex;
|
||||||
|
uniform float u_use_tex;
|
||||||
|
in vec2 v_uv;
|
||||||
|
out vec4 fragColor;
|
||||||
|
void main() {
|
||||||
|
vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
|
||||||
|
vec4 texColor = texture(u_tex, v_uv);
|
||||||
|
fragColor = mix(base, texColor, u_use_tex);
|
||||||
|
}
|
||||||
|
"#,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
let program = gl.create_program().map_err(|e| e.to_string())?;
|
let program = gl.create_program().map_err(|e| e.to_string())?;
|
||||||
let vs = gl
|
let vs = gl
|
||||||
@@ -733,8 +915,20 @@ void main() {
|
|||||||
Ok(program)
|
Ok(program)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cast_slice_u8<T>(slice: &[T]) -> &[u8] {
|
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
|
||||||
unsafe { std::slice::from_raw_parts(slice.as_ptr() as *const u8, std::mem::size_of_val(slice)) }
|
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
|
||||||
|
for &value in slice {
|
||||||
|
out.extend_from_slice(&value.to_ne_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
|
||||||
|
for &value in slice {
|
||||||
|
out.extend_from_slice(&value.to_ne_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mat4_identity() -> [f32; 16] {
|
fn mat4_identity() -> [f32; 16] {
|
||||||
|
|||||||
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"
|
||||||
|
|||||||
@@ -135,7 +135,12 @@ impl<'a> LzhDecoder<'a> {
|
|||||||
let mut node = self.son[LZH_R];
|
let mut node = self.son[LZH_R];
|
||||||
while node < LZH_T {
|
while node < LZH_T {
|
||||||
let bit = usize::from(self.bit_reader.read_bit()?);
|
let bit = usize::from(self.bit_reader.read_bit()?);
|
||||||
node = self.son[node + bit];
|
let branch = node
|
||||||
|
.checked_add(bit)
|
||||||
|
.ok_or(Error::DecompressionFailed("lzss-huffman tree overflow"))?;
|
||||||
|
node = *self.son.get(branch).ok_or(Error::DecompressionFailed(
|
||||||
|
"lzss-huffman tree out of bounds",
|
||||||
|
))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let c = node - LZH_T;
|
let c = node - LZH_T;
|
||||||
|
|||||||
@@ -30,20 +30,33 @@ impl Default for OpenOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct LibraryHeader {
|
||||||
|
pub raw: [u8; 32],
|
||||||
|
pub magic: [u8; 2],
|
||||||
|
pub reserved: u8,
|
||||||
|
pub version: u8,
|
||||||
|
pub entry_count: i16,
|
||||||
|
pub presorted_flag: u16,
|
||||||
|
pub xor_seed: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct AoTrailer {
|
||||||
|
pub raw: [u8; 6],
|
||||||
|
pub overlay: u32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
bytes: Arc<[u8]>,
|
bytes: Arc<[u8]>,
|
||||||
entries: Vec<EntryRecord>,
|
entries: Vec<EntryRecord>,
|
||||||
#[cfg(test)]
|
header: LibraryHeader,
|
||||||
pub(crate) header_raw: [u8; 32],
|
ao_trailer: Option<AoTrailer>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) table_plain_original: Vec<u8>,
|
pub(crate) table_plain_original: Vec<u8>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) xor_seed: u32,
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) source_size: usize,
|
pub(crate) source_size: usize,
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) trailer_raw: Option<[u8; 6]>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
@@ -77,6 +90,16 @@ pub struct EntryRef<'a> {
|
|||||||
pub meta: &'a EntryMeta,
|
pub meta: &'a EntryMeta,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
pub struct EntryInspect<'a> {
|
||||||
|
pub id: EntryId,
|
||||||
|
pub meta: &'a EntryMeta,
|
||||||
|
pub name_raw: &'a [u8; 12],
|
||||||
|
pub service_tail: &'a [u8; 4],
|
||||||
|
pub sort_to_original: i16,
|
||||||
|
pub data_offset_raw: u32,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct PackedResource {
|
pub struct PackedResource {
|
||||||
pub meta: EntryMeta,
|
pub meta: EntryMeta,
|
||||||
pub packed: Vec<u8>,
|
pub packed: Vec<u8>,
|
||||||
@@ -86,9 +109,9 @@ pub struct PackedResource {
|
|||||||
pub(crate) struct EntryRecord {
|
pub(crate) struct EntryRecord {
|
||||||
pub(crate) meta: EntryMeta,
|
pub(crate) meta: EntryMeta,
|
||||||
pub(crate) name_raw: [u8; 12],
|
pub(crate) name_raw: [u8; 12],
|
||||||
|
pub(crate) service_tail: [u8; 4],
|
||||||
pub(crate) sort_to_original: i16,
|
pub(crate) sort_to_original: i16,
|
||||||
pub(crate) key16: u16,
|
pub(crate) key16: u16,
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) data_offset_raw: u32,
|
pub(crate) data_offset_raw: u32,
|
||||||
pub(crate) packed_size_declared: u32,
|
pub(crate) packed_size_declared: u32,
|
||||||
pub(crate) packed_size_available: usize,
|
pub(crate) packed_size_available: usize,
|
||||||
@@ -106,18 +129,40 @@ impl Library {
|
|||||||
parse_library(arc, opts)
|
parse_library(arc, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn header(&self) -> &LibraryHeader {
|
||||||
|
&self.header
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ao_trailer(&self) -> Option<&AoTrailer> {
|
||||||
|
self.ao_trailer.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn entry_count(&self) -> usize {
|
pub fn entry_count(&self) -> usize {
|
||||||
self.entries.len()
|
self.entries.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
||||||
self.entries
|
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||||
.iter()
|
let id = u32::try_from(idx).ok()?;
|
||||||
.enumerate()
|
Some(EntryRef {
|
||||||
.map(|(idx, entry)| EntryRef {
|
id: EntryId(id),
|
||||||
id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
|
|
||||||
meta: &entry.meta,
|
meta: &entry.meta,
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
|
||||||
|
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
|
||||||
|
let id = u32::try_from(idx).ok()?;
|
||||||
|
Some(EntryInspect {
|
||||||
|
id: EntryId(id),
|
||||||
|
meta: &entry.meta,
|
||||||
|
name_raw: &entry.name_raw,
|
||||||
|
service_tail: &entry.service_tail,
|
||||||
|
sort_to_original: entry.sort_to_original,
|
||||||
|
data_offset_raw: entry.data_offset_raw,
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
pub fn find(&self, name: &str) -> Option<EntryId> {
|
||||||
@@ -161,9 +206,8 @@ impl Library {
|
|||||||
Ordering::Less => high = mid,
|
Ordering::Less => high = mid,
|
||||||
Ordering::Greater => low = mid + 1,
|
Ordering::Greater => low = mid + 1,
|
||||||
Ordering::Equal => {
|
Ordering::Equal => {
|
||||||
return Some(EntryId(
|
let id = u32::try_from(idx).ok()?;
|
||||||
u32::try_from(idx).expect("entry count validated at parse"),
|
return Some(EntryId(id));
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,9 +215,8 @@ impl Library {
|
|||||||
// Linear fallback search
|
// Linear fallback search
|
||||||
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
||||||
if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal {
|
if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal {
|
||||||
Some(EntryId(
|
let id = u32::try_from(idx).ok()?;
|
||||||
u32::try_from(idx).expect("entry count validated at parse"),
|
Some(EntryId(id))
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -189,6 +232,19 @@ impl Library {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
|
||||||
|
let idx = usize::try_from(id.0).ok()?;
|
||||||
|
let entry = self.entries.get(idx)?;
|
||||||
|
Some(EntryInspect {
|
||||||
|
id,
|
||||||
|
meta: &entry.meta,
|
||||||
|
name_raw: &entry.name_raw,
|
||||||
|
service_tail: &entry.service_tail,
|
||||||
|
sort_to_original: entry.sort_to_original,
|
||||||
|
data_offset_raw: entry.data_offset_raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
|
pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
|
||||||
let entry = self.entry_by_id(id)?;
|
let entry = self.entry_by_id(id)?;
|
||||||
let packed = self.packed_slice(id, entry)?;
|
let packed = self.packed_slice(id, entry)?;
|
||||||
@@ -251,7 +307,7 @@ impl Library {
|
|||||||
.get(idx)
|
.get(idx)
|
||||||
.ok_or_else(|| Error::EntryIdOutOfRange {
|
.ok_or_else(|| Error::EntryIdOutOfRange {
|
||||||
id: id.0,
|
id: id.0,
|
||||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
entry_count: saturating_u32_len(self.entries.len()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +342,7 @@ impl Library {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> {
|
pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> {
|
||||||
let trailer_len = usize::from(self.trailer_raw.is_some()) * 6;
|
let trailer_len = usize::from(self.ao_trailer.is_some()) * 6;
|
||||||
let pre_trailer_size = self
|
let pre_trailer_size = self
|
||||||
.source_size
|
.source_size
|
||||||
.checked_sub(trailer_len)
|
.checked_sub(trailer_len)
|
||||||
@@ -306,9 +362,11 @@ impl Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut out = vec![0u8; pre_trailer_size];
|
let mut out = vec![0u8; pre_trailer_size];
|
||||||
out[0..32].copy_from_slice(&self.header_raw);
|
out[0..32].copy_from_slice(&self.header.raw);
|
||||||
let encrypted_table =
|
let encrypted_table = xor_stream(
|
||||||
xor_stream(&self.table_plain_original, (self.xor_seed & 0xFFFF) as u16);
|
&self.table_plain_original,
|
||||||
|
(self.header.xor_seed & 0xFFFF) as u16,
|
||||||
|
);
|
||||||
out[32..table_end].copy_from_slice(&encrypted_table);
|
out[32..table_end].copy_from_slice(&encrypted_table);
|
||||||
|
|
||||||
let mut occupied = vec![false; pre_trailer_size];
|
let mut occupied = vec![false; pre_trailer_size];
|
||||||
@@ -317,18 +375,15 @@ impl Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (idx, entry) in self.entries.iter().enumerate() {
|
for (idx, entry) in self.entries.iter().enumerate() {
|
||||||
let packed = self
|
let id = u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
|
||||||
.load_packed(EntryId(
|
let packed = self.load_packed(EntryId(id))?.packed;
|
||||||
u32::try_from(idx).expect("entry count validated at parse"),
|
|
||||||
))?
|
|
||||||
.packed;
|
|
||||||
let start =
|
let start =
|
||||||
usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?;
|
usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?;
|
||||||
for (offset, byte) in packed.iter().copied().enumerate() {
|
for (offset, byte) in packed.iter().copied().enumerate() {
|
||||||
let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?;
|
let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?;
|
||||||
if pos >= out.len() {
|
if pos >= out.len() {
|
||||||
return Err(Error::PackedSizePastEof {
|
return Err(Error::PackedSizePastEof {
|
||||||
id: u32::try_from(idx).expect("entry count validated at parse"),
|
id,
|
||||||
offset: u64::from(entry.data_offset_raw),
|
offset: u64::from(entry.data_offset_raw),
|
||||||
packed_size: entry.packed_size_declared,
|
packed_size: entry.packed_size_declared,
|
||||||
file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?,
|
file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?,
|
||||||
@@ -342,8 +397,8 @@ impl Library {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(trailer) = self.trailer_raw {
|
if let Some(trailer) = &self.ao_trailer {
|
||||||
out.extend_from_slice(&trailer);
|
out.extend_from_slice(&trailer.raw);
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -407,5 +462,9 @@ fn needs_xor_key(method: PackMethod) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn saturating_u32_len(len: usize) -> u32 {
|
||||||
|
u32::try_from(len).unwrap_or(u32::MAX)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use crate::compress::xor::xor_stream;
|
use crate::compress::xor::xor_stream;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::{EntryMeta, EntryRecord, Library, OpenOptions, PackMethod, Result};
|
use crate::{
|
||||||
|
AoTrailer, EntryMeta, EntryRecord, Library, LibraryHeader, OpenOptions, PackMethod, Result,
|
||||||
|
};
|
||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -16,13 +18,17 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|||||||
let mut header_raw = [0u8; 32];
|
let mut header_raw = [0u8; 32];
|
||||||
header_raw.copy_from_slice(&bytes[0..32]);
|
header_raw.copy_from_slice(&bytes[0..32]);
|
||||||
|
|
||||||
if &bytes[0..2] != b"NL" {
|
let mut magic = [0u8; 2];
|
||||||
|
magic.copy_from_slice(&bytes[0..2]);
|
||||||
|
if &magic != b"NL" {
|
||||||
let mut got = [0u8; 2];
|
let mut got = [0u8; 2];
|
||||||
got.copy_from_slice(&bytes[0..2]);
|
got.copy_from_slice(&bytes[0..2]);
|
||||||
return Err(Error::InvalidMagic { got });
|
return Err(Error::InvalidMagic { got });
|
||||||
}
|
}
|
||||||
if bytes[3] != 0x01 {
|
let reserved = bytes[2];
|
||||||
return Err(Error::UnsupportedVersion { got: bytes[3] });
|
let version = bytes[3];
|
||||||
|
if version != 0x01 {
|
||||||
|
return Err(Error::UnsupportedVersion { got: version });
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
|
let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
|
||||||
@@ -36,7 +42,17 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|||||||
return Err(Error::TooManyEntries { got: count });
|
return Err(Error::TooManyEntries { got: count });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
||||||
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||||
|
let header = LibraryHeader {
|
||||||
|
raw: header_raw,
|
||||||
|
magic,
|
||||||
|
reserved,
|
||||||
|
version,
|
||||||
|
entry_count,
|
||||||
|
presorted_flag,
|
||||||
|
xor_seed,
|
||||||
|
};
|
||||||
|
|
||||||
let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
|
let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
|
||||||
let table_offset = 32usize;
|
let table_offset = 32usize;
|
||||||
@@ -58,8 +74,6 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?;
|
let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?;
|
||||||
#[cfg(not(test))]
|
|
||||||
let _ = trailer_raw;
|
|
||||||
|
|
||||||
let mut entries = Vec::with_capacity(count);
|
let mut entries = Vec::with_capacity(count);
|
||||||
for idx in 0..count {
|
for idx in 0..count {
|
||||||
@@ -67,6 +81,8 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|||||||
|
|
||||||
let mut name_raw = [0u8; 12];
|
let mut name_raw = [0u8; 12];
|
||||||
name_raw.copy_from_slice(&row[0..12]);
|
name_raw.copy_from_slice(&row[0..12]);
|
||||||
|
let mut service_tail = [0u8; 4];
|
||||||
|
service_tail.copy_from_slice(&row[12..16]);
|
||||||
|
|
||||||
let flags_signed = i16::from_le_bytes([row[16], row[17]]);
|
let flags_signed = i16::from_le_bytes([row[16], row[17]]);
|
||||||
let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
|
let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
|
||||||
@@ -100,12 +116,12 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|||||||
.ok_or(Error::IntegerOverflow)?;
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
} else {
|
} else {
|
||||||
return Err(Error::DeflateEofPlusOneQuirkRejected {
|
return Err(Error::DeflateEofPlusOneQuirkRejected {
|
||||||
id: u32::try_from(idx).expect("entry count validated at parse"),
|
id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Err(Error::PackedSizePastEof {
|
return Err(Error::PackedSizePastEof {
|
||||||
id: u32::try_from(idx).expect("entry count validated at parse"),
|
id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
|
||||||
offset: effective_offset_u64,
|
offset: effective_offset_u64,
|
||||||
packed_size: packed_size_declared,
|
packed_size: packed_size_declared,
|
||||||
file_len: file_len_u64,
|
file_len: file_len_u64,
|
||||||
@@ -118,7 +134,7 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|||||||
.ok_or(Error::IntegerOverflow)?;
|
.ok_or(Error::IntegerOverflow)?;
|
||||||
if available_end > bytes.len() {
|
if available_end > bytes.len() {
|
||||||
return Err(Error::EntryDataOutOfBounds {
|
return Err(Error::EntryDataOutOfBounds {
|
||||||
id: u32::try_from(idx).expect("entry count validated at parse"),
|
id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
|
||||||
offset: effective_offset_u64,
|
offset: effective_offset_u64,
|
||||||
size: packed_size_declared,
|
size: packed_size_declared,
|
||||||
file_len: file_len_u64,
|
file_len: file_len_u64,
|
||||||
@@ -137,9 +153,9 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|||||||
unpacked_size,
|
unpacked_size,
|
||||||
},
|
},
|
||||||
name_raw,
|
name_raw,
|
||||||
|
service_tail,
|
||||||
sort_to_original,
|
sort_to_original,
|
||||||
key16: sort_to_original as u16,
|
key16: sort_to_original as u16,
|
||||||
#[cfg(test)]
|
|
||||||
data_offset_raw,
|
data_offset_raw,
|
||||||
packed_size_declared,
|
packed_size_declared,
|
||||||
packed_size_available,
|
packed_size_available,
|
||||||
@@ -147,7 +163,6 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
|
||||||
if presorted_flag == 0xABBA {
|
if presorted_flag == 0xABBA {
|
||||||
let mut seen = vec![false; count];
|
let mut seen = vec![false; count];
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
@@ -196,16 +211,12 @@ pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|||||||
Ok(Library {
|
Ok(Library {
|
||||||
bytes,
|
bytes,
|
||||||
entries,
|
entries,
|
||||||
#[cfg(test)]
|
header,
|
||||||
header_raw,
|
ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
table_plain_original,
|
table_plain_original,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
xor_seed,
|
|
||||||
#[cfg(test)]
|
|
||||||
source_size,
|
source_size,
|
||||||
#[cfg(test)]
|
|
||||||
trailer_raw,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,7 @@ name = "texm"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dev-dependencies]
|
||||||
|
common = { path = "../common" }
|
||||||
nres = { path = "../nres" }
|
nres = { path = "../nres" }
|
||||||
|
proptest = "1"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use core::fmt;
|
use core::fmt;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
HeaderTooSmall {
|
HeaderTooSmall {
|
||||||
size: usize,
|
size: usize,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ impl PixelFormat {
|
|||||||
match self {
|
match self {
|
||||||
Self::Indexed8 => 1,
|
Self::Indexed8 => 1,
|
||||||
Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2,
|
Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2,
|
||||||
|
// Parkan stores format 888 as 32-bit RGBX in texture payloads.
|
||||||
Self::Rgb888 | Self::Argb8888 => 4,
|
Self::Rgb888 | Self::Argb8888 => 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,14 +174,8 @@ pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
|
|||||||
offset: level_offset,
|
offset: level_offset,
|
||||||
size: level_size,
|
size: level_size,
|
||||||
});
|
});
|
||||||
w = w.max(1) >> 1;
|
w = (w >> 1).max(1);
|
||||||
h = h.max(1) >> 1;
|
h = (h >> 1).max(1);
|
||||||
if w == 0 {
|
|
||||||
w = 1;
|
|
||||||
}
|
|
||||||
if h == 0 {
|
|
||||||
h = 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let page_rects = parse_page_tail(payload, offset)?;
|
let page_rects = parse_page_tail(payload, offset)?;
|
||||||
@@ -240,7 +235,8 @@ pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) ->
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let poff = usize::from(index).saturating_mul(4);
|
let poff = usize::from(index).saturating_mul(4);
|
||||||
if poff + 3 >= palette.len() {
|
// Keep this form to accept the last palette item (index 255).
|
||||||
|
if poff + 4 > palette.len() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let out = i.saturating_mul(4);
|
let out = i.saturating_mul(4);
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use common::collect_files_recursive;
|
||||||
use nres::Archive;
|
use nres::Archive;
|
||||||
|
use proptest::prelude::*;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
|
||||||
let Ok(entries) = fs::read_dir(root) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
collect_files_recursive(&path, out);
|
|
||||||
} else if path.is_file() {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nres_test_files() -> Vec<PathBuf> {
|
fn nres_test_files() -> Vec<PathBuf> {
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
.join("..")
|
.join("..")
|
||||||
@@ -35,6 +23,36 @@ fn nres_test_files() -> Vec<PathBuf> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_texm_payload(
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
format_raw: u32,
|
||||||
|
flags5: u32,
|
||||||
|
palette: Option<[u8; 1024]>,
|
||||||
|
mip_levels: &[&[u8]],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&width.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&height.to_le_bytes());
|
||||||
|
payload.extend_from_slice(
|
||||||
|
&u32::try_from(mip_levels.len())
|
||||||
|
.expect("mip level count overflow in test")
|
||||||
|
.to_le_bytes(),
|
||||||
|
);
|
||||||
|
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
|
||||||
|
payload.extend_from_slice(&flags5.to_le_bytes());
|
||||||
|
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
|
||||||
|
payload.extend_from_slice(&format_raw.to_le_bytes());
|
||||||
|
if let Some(palette) = palette {
|
||||||
|
payload.extend_from_slice(&palette);
|
||||||
|
}
|
||||||
|
for level in mip_levels {
|
||||||
|
payload.extend_from_slice(level);
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn texm_parse_all_game_textures() {
|
fn texm_parse_all_game_textures() {
|
||||||
let archives = nres_test_files();
|
let archives = nres_test_files();
|
||||||
@@ -97,16 +115,7 @@ fn texm_parse_all_game_textures() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn texm_parse_minimal_argb8888_no_page() {
|
fn texm_parse_minimal_argb8888_no_page() {
|
||||||
let mut payload = Vec::new();
|
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
|
||||||
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // width
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // height
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
|
|
||||||
payload.extend_from_slice(&8888u32.to_le_bytes()); // format
|
|
||||||
payload.extend_from_slice(&[1, 2, 3, 4]); // one pixel
|
|
||||||
|
|
||||||
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
|
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
|
||||||
assert_eq!(parsed.header.width, 1);
|
assert_eq!(parsed.header.width, 1);
|
||||||
@@ -117,17 +126,7 @@ fn texm_parse_minimal_argb8888_no_page() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn texm_decode_minimal_argb8888_no_page() {
|
fn texm_decode_minimal_argb8888_no_page() {
|
||||||
let mut payload = Vec::new();
|
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[0x40, 0x11, 0x22, 0x33]]);
|
||||||
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // width
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // height
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
|
|
||||||
payload.extend_from_slice(&8888u32.to_le_bytes()); // format
|
|
||||||
payload.extend_from_slice(&[0x40, 0x11, 0x22, 0x33]); // A,R,G,B in little-endian order
|
|
||||||
|
|
||||||
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
|
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
|
||||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip");
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip");
|
||||||
assert_eq!(decoded.width, 1);
|
assert_eq!(decoded.width, 1);
|
||||||
@@ -135,19 +134,55 @@ fn texm_decode_minimal_argb8888_no_page() {
|
|||||||
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
|
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_rgb565() {
|
||||||
|
let word = 0xFFE0u16; // r=31 g=63 b=0
|
||||||
|
let payload = build_texm_payload(1, 1, 565, 0, None, &[&word.to_le_bytes()]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse rgb565 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb565 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![255, 255, 0, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_rgb556() {
|
||||||
|
let word = 0xF800u16; // r=31 g=0 b=0
|
||||||
|
let payload = build_texm_payload(1, 1, 556, 0, None, &[&word.to_le_bytes()]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse rgb556 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb556 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![255, 0, 0, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_argb4444() {
|
||||||
|
let word = 0xF12Eu16; // a=F r=1 g=2 b=E
|
||||||
|
let payload = build_texm_payload(1, 1, 4444, 0, None, &[&word.to_le_bytes()]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse argb4444 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode argb4444 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![17, 34, 238, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_luminance_alpha88() {
|
||||||
|
let word = 0x7F40u16; // luminance=0x7F alpha=0x40
|
||||||
|
let payload = build_texm_payload(1, 1, 88, 0, None, &[&word.to_le_bytes()]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse la88 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode la88 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![0x7F, 0x7F, 0x7F, 0x40]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_decode_rgb888x() {
|
||||||
|
let payload = build_texm_payload(1, 1, 888, 0, None, &[&[0x11, 0x22, 0x33, 0x99]]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse rgb888 texm");
|
||||||
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb888 texm");
|
||||||
|
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 255]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn texm_parse_indexed_with_page_chunk() {
|
fn texm_parse_indexed_with_page_chunk() {
|
||||||
let mut payload = Vec::new();
|
let mut palette = [0u8; 1024];
|
||||||
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
|
palette[4..8].copy_from_slice(&[10, 20, 30, 255]);
|
||||||
payload.extend_from_slice(&2u32.to_le_bytes()); // width
|
let mut payload = build_texm_payload(2, 2, 0, 0, Some(palette), &[&[1, 1, 1, 1]]);
|
||||||
payload.extend_from_slice(&2u32.to_le_bytes()); // height
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8
|
|
||||||
payload.extend_from_slice(&[0u8; 1024]); // palette
|
|
||||||
payload.extend_from_slice(&[1, 2, 3, 4]); // pixels
|
|
||||||
payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
|
payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count
|
payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count
|
||||||
payload.extend_from_slice(&0i16.to_le_bytes()); // x
|
payload.extend_from_slice(&0i16.to_le_bytes()); // x
|
||||||
@@ -170,26 +205,126 @@ fn texm_parse_indexed_with_page_chunk() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn texm_decode_indexed_with_palette() {
|
fn texm_decode_indexed_with_palette_last_entry() {
|
||||||
let mut payload = Vec::new();
|
|
||||||
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
|
|
||||||
payload.extend_from_slice(&2u32.to_le_bytes()); // width
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // height
|
|
||||||
payload.extend_from_slice(&1u32.to_le_bytes()); // mip_count
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // flags5
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
|
|
||||||
payload.extend_from_slice(&0u32.to_le_bytes()); // format indexed8
|
|
||||||
|
|
||||||
let mut palette = [0u8; 1024];
|
let mut palette = [0u8; 1024];
|
||||||
palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1
|
palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1
|
||||||
palette[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2
|
palette[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2
|
||||||
payload.extend_from_slice(&palette);
|
palette[1020..1024].copy_from_slice(&[1, 2, 3, 4]); // index 255 (last)
|
||||||
payload.extend_from_slice(&[1u8, 2u8]); // two pixels
|
let payload = build_texm_payload(3, 1, 0, 0, Some(palette), &[&[1u8, 2u8, 255u8]]);
|
||||||
|
|
||||||
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
|
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
|
||||||
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm");
|
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm");
|
||||||
assert_eq!(decoded.width, 2);
|
assert_eq!(decoded.width, 3);
|
||||||
assert_eq!(decoded.height, 1);
|
assert_eq!(decoded.height, 1);
|
||||||
assert_eq!(decoded.rgba8, vec![10, 20, 30, 255, 40, 50, 60, 200]);
|
assert_eq!(
|
||||||
|
decoded.rgba8,
|
||||||
|
vec![10, 20, 30, 255, 40, 50, 60, 200, 1, 2, 3, 4]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_parse_multi_mip_offsets() {
|
||||||
|
let mip0 = [0x10u8; 32]; // 4*2*4
|
||||||
|
let mip1 = [0x20u8; 8]; // 2*1*4
|
||||||
|
let mip2 = [0x30u8; 4]; // 1*1*4
|
||||||
|
let payload = build_texm_payload(4, 2, 8888, 0, None, &[&mip0, &mip1, &mip2]);
|
||||||
|
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse multi-mip texm");
|
||||||
|
assert_eq!(parsed.header.mip_count, 3);
|
||||||
|
assert_eq!(parsed.mip_levels.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
parsed.mip_levels,
|
||||||
|
vec![
|
||||||
|
MipLevel {
|
||||||
|
width: 4,
|
||||||
|
height: 2,
|
||||||
|
offset: 32,
|
||||||
|
size: 32
|
||||||
|
},
|
||||||
|
MipLevel {
|
||||||
|
width: 2,
|
||||||
|
height: 1,
|
||||||
|
offset: 64,
|
||||||
|
size: 8
|
||||||
|
},
|
||||||
|
MipLevel {
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
offset: 72,
|
||||||
|
size: 4
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_preserves_flags5_for_mip_skip_metadata() {
|
||||||
|
let payload = build_texm_payload(1, 1, 8888, 0x0000_00A5, None, &[&[0, 0, 0, 0]]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse texm");
|
||||||
|
assert_eq!(parsed.header.flags5, 0x0000_00A5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_errors_for_invalid_header_values() {
|
||||||
|
let mut bad_magic = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
|
||||||
|
bad_magic[0..4].copy_from_slice(&0u32.to_le_bytes());
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&bad_magic),
|
||||||
|
Err(Error::InvalidMagic { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let zero_dims = build_texm_payload(0, 1, 8888, 0, None, &[&[]]);
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&zero_dims),
|
||||||
|
Err(Error::InvalidDimensions { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut bad_mips = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
|
||||||
|
bad_mips[12..16].copy_from_slice(&0u32.to_le_bytes());
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&bad_mips),
|
||||||
|
Err(Error::InvalidMipCount { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let bad_format = build_texm_payload(1, 1, 12345, 0, None, &[&[0, 0, 0, 0]]);
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&bad_format),
|
||||||
|
Err(Error::UnknownFormat { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn texm_errors_for_page_chunk_and_mip_bounds() {
|
||||||
|
let mut bad_page = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
|
||||||
|
bad_page.extend_from_slice(b"X");
|
||||||
|
assert!(matches!(
|
||||||
|
parse_texm(&bad_page),
|
||||||
|
Err(Error::InvalidPageSize { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
|
||||||
|
let parsed = parse_texm(&payload).expect("failed to parse valid texm");
|
||||||
|
assert!(matches!(
|
||||||
|
decode_mip_rgba8(&parsed, &payload, 7),
|
||||||
|
Err(Error::MipIndexOutOfRange { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let truncated = &payload[..payload.len() - 1];
|
||||||
|
assert!(matches!(
|
||||||
|
decode_mip_rgba8(&parsed, truncated, 0),
|
||||||
|
Err(Error::MipDataOutOfBounds { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
proptest! {
|
||||||
|
#![proptest_config(ProptestConfig::with_cases(64))]
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_texm_is_panic_free_on_random_bytes(payload in proptest::collection::vec(any::<u8>(), 0..4096)) {
|
||||||
|
if let Ok(texture) = parse_texm(&payload) {
|
||||||
|
for mip_index in 0..texture.mip_levels.len() {
|
||||||
|
let _ = decode_mip_rgba8(&texture, &payload, mip_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
crates/tma/Cargo.toml
Normal file
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,35 @@
|
|||||||
# AI system
|
# AI system
|
||||||
|
|
||||||
Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников.
|
Страница фиксирует границы подсистемы AI на уровне движка:
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`.
|
- выбор целей;
|
||||||
|
- тактические приоритеты;
|
||||||
|
- координация с `Behavior`, `ArealMap`, `Missions`.
|
||||||
|
|
||||||
|
## 1. Текущая зафиксированная часть
|
||||||
|
|
||||||
|
1. AI работает поверх ареалов/клеток карты, а не напрямую поверх render-геометрии.
|
||||||
|
2. Результат AI передается в behavior/command-слой как набор целевых состояний и команд.
|
||||||
|
3. Решения AI зависят от миссионных триггеров и состояния объектов мира.
|
||||||
|
|
||||||
|
## 2. Контракт интеграции
|
||||||
|
|
||||||
|
В 1:1 реализации AI должен быть совместим с:
|
||||||
|
|
||||||
|
1. системой ареалов (`Land.map`);
|
||||||
|
2. объектными категориями (`BuildDat.lst`);
|
||||||
|
3. поведением юнитов (`behavior.md`);
|
||||||
|
4. миссионными условиями (`missions.md`).
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- роль AI в общей архитектуре и точки интеграции с соседними подсистемами.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полный формат runtime-AI состояний и таблиц решений.
|
||||||
|
2. Полные правила выбора цели/маршрута/приоритета огня.
|
||||||
|
3. Полная спецификация влияния миссионных скриптов на AI.
|
||||||
|
4. Набор тест-кейсов «AI tick parity» для побайтного/пошагового сравнения с оригиналом.
|
||||||
|
|||||||
@@ -1,5 +1,31 @@
|
|||||||
# ArealMap
|
# ArealMap
|
||||||
|
|
||||||
Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями.
|
`ArealMap` — подсистема топологии мира и логических зон.
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`.
|
Подробный бинарный формат `Land.map` и связь с terrain описаны в:
|
||||||
|
|
||||||
|
- [Terrain + ArealMap](terrain-map-loading.md)
|
||||||
|
|
||||||
|
## 1. Роль в движке
|
||||||
|
|
||||||
|
1. Хранит ареалы, связи между ареалами и клеточный индекс.
|
||||||
|
2. Используется для навигации, логики объектов и AI-решений.
|
||||||
|
3. Связывает геометрию карты с миссионной и поведенческой логикой.
|
||||||
|
|
||||||
|
## 2. Минимальный runtime-контракт
|
||||||
|
|
||||||
|
1. Валидный граф ареалов и edge-link связей.
|
||||||
|
2. Валидная cell-grid индексация (`cellsX/cellsY` + hit lists).
|
||||||
|
3. Согласованные идентификаторы ареалов для AI/Behavior/Missions.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- бинарный контракт `Land.map` и pair-загрузка с `Land.msh`.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная доменная семантика `class_id`/`logic_flag` по всем игровым сценариям.
|
||||||
|
2. Формальная спецификация API-запросов к ArealMap (поиск зон, фильтры, события).
|
||||||
|
3. Набор parity-тестов поведения навигационных запросов на одинаковых входах.
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# Behavior system
|
# Behavior system
|
||||||
|
|
||||||
Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения.
|
`Behavior` — слой исполнения состояний юнитов между AI-решением и низкоуровневым control-командованием.
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`.
|
## 1. Роль в кадре
|
||||||
|
|
||||||
|
1. Принимает решения из AI.
|
||||||
|
2. Переводит их в state machine юнита.
|
||||||
|
3. Формирует команды движения/атаки/действий в world/control-слой.
|
||||||
|
|
||||||
|
## 2. Внешние зависимости
|
||||||
|
|
||||||
|
1. `ArealMap` (доступность/топология).
|
||||||
|
2. `Missions` (триггеры и ограничения сценария).
|
||||||
|
3. `Control` (выполнение команд).
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- архитектурная роль подсистемы и ее место в runtime-пайплайне.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация finite-state машин по типам юнитов.
|
||||||
|
2. Полная таблица переходов, таймаутов и приоритетов.
|
||||||
|
3. Формализация входных/выходных структур поведения для 1:1 эмуляции.
|
||||||
|
4. Поведенческие parity-тесты на фиксированных replay-сценариях.
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# Control system
|
# Control system
|
||||||
|
|
||||||
Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд.
|
`Control` — подсистема входа и маршрутизации команд (пользовательских и системных).
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`.
|
## 1. Роль
|
||||||
|
|
||||||
|
1. Преобразует ввод устройств в команды движка.
|
||||||
|
2. Синхронизирует управление камерой, UI и объектами мира.
|
||||||
|
3. Передает команды в gameplay-подсистемы с учетом активного режима игры.
|
||||||
|
|
||||||
|
## 2. Минимальный контракт совместимости
|
||||||
|
|
||||||
|
1. Детерминированный mapping input -> command.
|
||||||
|
2. Стабильная обработка очереди команд в пределах кадра.
|
||||||
|
3. Корректный приоритет UI-фокуса над world-input.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- место control-слоя в архитектуре и базовый runtime-контур.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная карта input actions и режимов обработки.
|
||||||
|
2. Формат внутренних очередей команд и их сериализация.
|
||||||
|
3. Спецификация edge-case поведения (повтор клавиш, захват мыши, hotkey-конфликты).
|
||||||
|
4. Пошаговые parity-тесты на записанных последовательностях ввода.
|
||||||
|
|||||||
51
docs/specs/coverage-audit.md
Normal file
51
docs/specs/coverage-audit.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Documentation coverage audit
|
||||||
|
|
||||||
|
Дата аудита: `2026-02-19`
|
||||||
|
Корпус данных: `testdata/Parkan - Iron Strategy`
|
||||||
|
|
||||||
|
## 1. Проверка форматов архивов
|
||||||
|
|
||||||
|
Результаты:
|
||||||
|
|
||||||
|
- `NRes`: `120` архивов, roundtrip `120/120` (byte-identical)
|
||||||
|
- `RsLi`: `2` архива, roundtrip `2/2` (byte-identical)
|
||||||
|
- подтвержден один совместимый quirk: `sprites.lib`, entry `23`, `deflate EOF+1`
|
||||||
|
|
||||||
|
Инструмент:
|
||||||
|
|
||||||
|
- `tools/archive_roundtrip_validator.py`
|
||||||
|
|
||||||
|
## 2. Проверка рендерных форматов
|
||||||
|
|
||||||
|
Результаты:
|
||||||
|
|
||||||
|
- `MSH`: `435/435` валидны
|
||||||
|
- `Texm`: `518/518` валидны
|
||||||
|
- `FXID`: `923/923` валидны
|
||||||
|
- `Terrain/Map` (`Land.msh` + `Land.map`): `33/33` без ошибок/предупреждений
|
||||||
|
|
||||||
|
Инструменты:
|
||||||
|
|
||||||
|
- `tools/msh_doc_validator.py`
|
||||||
|
- `tools/fxid_abs100_audit.py`
|
||||||
|
- `tools/terrain_map_doc_validator.py`
|
||||||
|
|
||||||
|
## 3. Глобальный статус по подсистемам
|
||||||
|
|
||||||
|
| Подсистема | Статус | Что блокирует 100% |
|
||||||
|
|---|---|---|
|
||||||
|
| Архивы (`NRes`, `RsLi`) | практически закрыта | формализация редких не-ASCII/служебных edge-case |
|
||||||
|
| 3D geometry (`MSH core`) | высокая готовность | семантика opaque-полей и канонический writer «с нуля» |
|
||||||
|
| Animation (`Res8/Res19`) | высокая готовность | полный FP-parity на всех edge-case |
|
||||||
|
| Material/Wear/Texture | высокая готовность | полная field-level семантика служебных флагов и writer-профиль |
|
||||||
|
| FXID | высокая готовность | полная field-level семантика payload по каждому opcode |
|
||||||
|
| Terrain/Areal map formats | высокая готовность | доменная семантика `class_id/logic_flag`, ветка `poly_count>0` |
|
||||||
|
| Render pipeline | хорошая | полный pixel-parity набор эталонных кадров в CI |
|
||||||
|
| AI/Behavior/Control/Missions/UI/Sound/Network | начальное покрытие | требуется полная спецификация форматов и runtime-контрактов |
|
||||||
|
|
||||||
|
## 4. План доведения до 100%
|
||||||
|
|
||||||
|
1. Закрыть field-level семантику opaque/служебных полей в 3D/FX/terrain подсистемах.
|
||||||
|
2. Завершить canonical writer paths для авторинга новых ассетов без copy-through.
|
||||||
|
3. Зафиксировать и автоматизировать pixel/frame parity-критерии в CI.
|
||||||
|
4. Расширить подсистемные спецификации (`AI`, `Behavior`, `Missions`, `Control`, `UI`, `Sound`, `Network`) до уровня «полный формат + полный runtime-контракт + parity-тесты».
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
|
`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
|
||||||
Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
|
Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
|
||||||
|
|
||||||
Связанный контейнер: [NRes / RsLi](nres.md).
|
Связанные контейнеры: [NRes](nres.md), [RsLi](rsli.md).
|
||||||
|
|
||||||
## 1. Контейнер
|
## 1. Контейнер
|
||||||
|
|
||||||
@@ -185,4 +185,18 @@ struct ResourceRef64 {
|
|||||||
## 11. Статус валидации
|
## 11. Статус валидации
|
||||||
|
|
||||||
- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
|
- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
|
||||||
- В текущем рабочем окружении нет полного набора игровых архивов (`testdata` без payload), поэтому массовая повторная проверка корпуса здесь не выполнялась.
|
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `923/923` FXID payload без ошибок.
|
||||||
|
|
||||||
|
## 12. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Контейнер FXID, fixed-size командный поток, opcode-покрытие `1..10`.
|
||||||
|
2. Базовый runtime-контур исполнения эффекта.
|
||||||
|
3. Корпусная валидация формата на retail-данных.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная field-level семантика payload каждого opcode для авторинга новых эффектов «с нуля».
|
||||||
|
2. Формальная спецификация всех `time_mode` веток на уровне точных числовых формул и edge-case поведения.
|
||||||
|
3. Полный набор пиксельных parity-тестов FX (оригинал vs новый рендер) на фиксированных сценах.
|
||||||
|
|||||||
@@ -127,4 +127,18 @@ struct KeyRaw {
|
|||||||
## 10. Статус валидации
|
## 10. Статус валидации
|
||||||
|
|
||||||
- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`).
|
- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`).
|
||||||
- В этом окружении нет полного игрового корпуса, поэтому статистика по всем материалам не пересчитывалась.
|
- Структурная валидация MAT0 включена в корпусный прогон `tools/msh_doc_validator.py` на полном retail-наборе.
|
||||||
|
|
||||||
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Бинарный layout `MAT0` и правила чтения фаз/анимационных блоков.
|
||||||
|
2. Fallback-цепочка материала.
|
||||||
|
3. Контракт сохранения opaque-полей для lossless editor path.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная семантика всех битов `attr1` и `metaA/B/C/D` для авторинга новых материалов.
|
||||||
|
2. Полный writer-профиль «канонический MAT0» для генерации ассетов без copy-through.
|
||||||
|
3. Набор визуальных parity-тестов по material phase animation на реальных моделях.
|
||||||
|
|||||||
@@ -6,3 +6,13 @@
|
|||||||
- [Wear table (`WEAR`)](wear.md)
|
- [Wear table (`WEAR`)](wear.md)
|
||||||
- [Texture (`Texm`)](texture.md)
|
- [Texture (`Texm`)](texture.md)
|
||||||
- [Render pipeline](render.md)
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Страница корректно декомпозирована на отдельные объектные спецификации.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Поддерживать единый changelog согласованности между `material.md`, `wear.md`, `texture.md` и `render.md`.
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
# Missions
|
# Missions
|
||||||
|
|
||||||
Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира.
|
Подсистема `Missions` управляет сценарием:
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`.
|
- стартовыми условиями;
|
||||||
|
- триггерами;
|
||||||
|
- победой/поражением;
|
||||||
|
- синхронизацией с AI/Behavior/World.
|
||||||
|
|
||||||
|
## 1. Что уже зафиксировано
|
||||||
|
|
||||||
|
1. Миссии связаны с картами (`Land.msh`/`Land.map`) и объектными категориями.
|
||||||
|
2. Скриптовые ресурсы хранятся в архивных контейнерах (`NRes`) и участвуют в runtime-логике.
|
||||||
|
3. Миссионные события влияют на AI и поведение объектов через общий gameplay-слой.
|
||||||
|
|
||||||
|
## 2. Минимальный runtime-контракт
|
||||||
|
|
||||||
|
1. Детерминированный порядок обработки триггеров в кадре.
|
||||||
|
2. Единая шкала времени миссии для всех подсистем.
|
||||||
|
3. Согласованность идентификаторов объектов между mission-data и world-state.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- связь миссионной подсистемы с форматом ресурсов и runtime-контуром.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация форматов миссионных скриптов/таблиц.
|
||||||
|
2. Полный перечень типов триггеров и их параметров.
|
||||||
|
3. Формальные правила разрешения конфликтов триггеров в одном кадре.
|
||||||
|
4. Набор replay parity-тестов «миссия от старта до завершения».
|
||||||
|
## 4. Mission -> Prototype -> Mesh bridge
|
||||||
|
|
||||||
|
Для 3D-объектов миссии обязательна промежуточная стадия `objects.rlb`:
|
||||||
|
|
||||||
|
1. `data.tma` задаёт либо прямой ключ объекта, либо путь к `*.dat`.
|
||||||
|
2. `*.dat` даёт `model_key` (в retail-наборе через `objects.rlb`).
|
||||||
|
3. Ключ резолвится в запись прототипа внутри `objects.rlb`.
|
||||||
|
4. Из прототипа выбирается фактический `*.msh` и архив (например `bases.rlb`, `static.rlb`, `fortif.rlb`).
|
||||||
|
5. Только после этого запускается стандартная цепочка материалов и текстур.
|
||||||
|
|
||||||
|
Детальный формат и алгоритм вынесены в отдельную страницу:
|
||||||
|
|
||||||
|
- [Object registry (`objects.rlb`)](object-registry.md)
|
||||||
|
|||||||
@@ -109,4 +109,18 @@ uint16_t map_words[]; // size/2 элементов
|
|||||||
## 6. Статус валидации
|
## 6. Статус валидации
|
||||||
|
|
||||||
- Форматные проверки включены в `tools/msh_doc_validator.py`.
|
- Форматные проверки включены в `tools/msh_doc_validator.py`.
|
||||||
- В текущем окружении полный игровой корпус MSH не подключен в `testdata`, поэтому массовый прогон здесь не выполнялся.
|
- Корпусная валидация анимационных инвариантов включена в прогон `tools/msh_doc_validator.py` на полном retail-наборе.
|
||||||
|
|
||||||
|
## 7. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Контракт `Res8 + Res19` и fallback-логика выбора ключа.
|
||||||
|
2. Базовая интерполяция поз и blending двух сэмплов.
|
||||||
|
3. Канонические инварианты writer path для существующих ассетов.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная фиксация численного поведения на всех FP-edge-case (включая платформенные различия округления).
|
||||||
|
2. Полный writer-профиль для авторинга новых анимаций без опоры на reference copy-through.
|
||||||
|
3. Набор runtime parity-тестов «frame-by-frame pose equivalence» на длинных анимациях.
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
- [Material](material.md)
|
- [Material](material.md)
|
||||||
- [Texture (Texm)](texture.md)
|
- [Texture (Texm)](texture.md)
|
||||||
- [Render pipeline](render.md)
|
- [Render pipeline](render.md)
|
||||||
- [NRes / RsLi](nres.md)
|
- [NRes](nres.md)
|
||||||
|
- [RsLi](rsli.md)
|
||||||
|
|
||||||
## 1. Общая модель
|
## 1. Общая модель
|
||||||
|
|
||||||
@@ -174,5 +175,19 @@ for each node:
|
|||||||
## 8. Статус валидации
|
## 8. Статус валидации
|
||||||
|
|
||||||
- Инварианты формата реализованы в `tools/msh_doc_validator.py`.
|
- Инварианты формата реализованы в `tools/msh_doc_validator.py`.
|
||||||
- В текущем окружении нет загруженного полного корпуса игровых MSH в `testdata`, поэтому массовый прогон по ассетам здесь не выполнялся.
|
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `435/435` MSH-моделей без структурных ошибок.
|
||||||
|
|
||||||
|
## 9. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Базовые таблицы geometry path (`Res1/2/3/4/5/6/7/13`).
|
||||||
|
2. Критичные range-инварианты slot/batch/index.
|
||||||
|
3. Правила совместимого writer/editor для lossless работы с существующими ассетами.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная семантика части opaque-полей (`Slot68` tail, `Batch20` opaque-поля) для authoring без copy-through.
|
||||||
|
2. Полная формализация редких веток (`Res1.attr3 != 38`) на расширенном корпусе.
|
||||||
|
3. End-to-end writer для генерации новых игровых MSH с подтвержденным runtime-паритетом.
|
||||||
|
|
||||||
|
|||||||
@@ -1,277 +1,118 @@
|
|||||||
# 3D implementation notes
|
# 3D implementation notes
|
||||||
|
|
||||||
Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам.
|
Контрольная страница с практическими правилами реализации 3D-пайплайна и с перечнем незакрытых зон.
|
||||||
|
Документ intentionally high-level: без ссылок на внутренние функции/адреса.
|
||||||
|
|
||||||
---
|
Связанные страницы:
|
||||||
|
|
||||||
## 5.1. Порядок байт
|
- [MSH core](msh-core.md)
|
||||||
|
- [MSH animation](msh-animation.md)
|
||||||
|
- [Material (`MAT0`)](material.md)
|
||||||
|
- [Texture (`Texm`)](texture.md)
|
||||||
|
- [FXID](fxid.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
Все значения хранятся в **little‑endian** порядке (платформа x86/Win32).
|
## 1. Базовые двоичные правила
|
||||||
|
|
||||||
## 5.2. Выравнивание
|
1. Все форматы в этой подсистеме little-endian.
|
||||||
|
2. Внутри NRes данные ресурсов выравниваются по 8 байт.
|
||||||
|
3. Внутри payload таблиц padding между записями обычно отсутствует: записи идут подряд по stride.
|
||||||
|
|
||||||
- **NRes‑ресурсы:** данные каждого ресурса внутри NRes‑архива выровнены по границе **8 байт** (0‑padding).
|
## 2. Быстрая карта stride'ов
|
||||||
- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд.
|
|
||||||
- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга.
|
|
||||||
|
|
||||||
## 5.3. Размеры записей на диске
|
| Ресурс | Запись | Stride |
|
||||||
|
|---|---|---:|
|
||||||
|
| Res1 | Node | 38 |
|
||||||
|
| Res2 | Slot | 68 (после header `0x8C`) |
|
||||||
|
| Res3 | Position | 12 |
|
||||||
|
| Res4 | Normal | 4 |
|
||||||
|
| Res5 | UV0 | 4 |
|
||||||
|
| Res6 | Index | 2 |
|
||||||
|
| Res7 | Tri descriptor | 16 |
|
||||||
|
| Res8 | Animation key | 24 |
|
||||||
|
| Res13 | Batch | 20 |
|
||||||
|
| Res19 | Animation map | 2 |
|
||||||
|
|
||||||
| Ресурс | Запись | Размер (байт) | Stride |
|
## 3. Декодирование ключевых потоков
|
||||||
|--------|-----------|---------------|-------------------------|
|
|
||||||
| Res1 | Node | 38 | 38 (19×u16) |
|
|
||||||
| Res2 | Slot | 68 | 68 |
|
|
||||||
| Res3 | Position | 12 | 12 (3×f32) |
|
|
||||||
| Res4 | Normal | 4 | 4 (4×s8) |
|
|
||||||
| Res5 | UV0 | 4 | 4 (2×s16) |
|
|
||||||
| Res6 | Index | 2 | 2 (u16) |
|
|
||||||
| Res7 | TriDesc | 16 | 16 |
|
|
||||||
| Res8 | AnimKey | 24 | 24 |
|
|
||||||
| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` |
|
|
||||||
| Res13 | Batch | 20 | 20 |
|
|
||||||
| Res19 | AnimMap | 2 | 2 (u16) |
|
|
||||||
| Res15 | VtxStr | 8 | 8 |
|
|
||||||
| Res16 | VtxStr | 8 | 8 (2×4) |
|
|
||||||
| Res18 | VtxStr | 4 | 4 |
|
|
||||||
|
|
||||||
## 5.4. Вычисление количества элементов
|
## 3.1. Позиции (Res3)
|
||||||
|
|
||||||
Количество записей вычисляется из размера ресурса:
|
`float3`, stride `12`.
|
||||||
|
|
||||||
```
|
## 3.2. Нормали (Res4)
|
||||||
count = resource_data_size / record_stride
|
|
||||||
|
`int8[4]`, используются первые 3 компоненты:
|
||||||
|
|
||||||
|
```text
|
||||||
|
n = clamp(s8 / 127.0, -1..1)
|
||||||
```
|
```
|
||||||
|
|
||||||
Например:
|
## 3.3. UV (Res5)
|
||||||
|
|
||||||
- `vertex_count = res3_size / 12`
|
`int16[2]`:
|
||||||
- `index_count = res6_size / 2`
|
|
||||||
- `batch_count = res13_size / 20`
|
|
||||||
- `slot_count = (res2_size - 140) / 68`
|
|
||||||
- `node_count = res1_size / 38`
|
|
||||||
- `tri_desc_count = res7_size / 16`
|
|
||||||
- `anim_key_count = res8_size / 24`
|
|
||||||
- `anim_map_count = res19_size / 2`
|
|
||||||
|
|
||||||
Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт.
|
```text
|
||||||
|
u = s16 / 1024.0
|
||||||
## 5.5. Идентификация ресурсов в NRes
|
v = s16 / 1024.0
|
||||||
|
|
||||||
Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика).
|
|
||||||
|
|
||||||
## 5.6. Минимальный набор для рендера
|
|
||||||
|
|
||||||
Для статической модели без анимации достаточно:
|
|
||||||
|
|
||||||
| Ресурс | Обязательность |
|
|
||||||
|--------|------------------------------------------------|
|
|
||||||
| Res1 | Да |
|
|
||||||
| Res2 | Да |
|
|
||||||
| Res3 | Да |
|
|
||||||
| Res4 | Рекомендуется |
|
|
||||||
| Res5 | Рекомендуется |
|
|
||||||
| Res6 | Да |
|
|
||||||
| Res7 | Для коллизии |
|
|
||||||
| Res13 | Да |
|
|
||||||
| Res10 | Желательно (узловые имена/поведенческие ветки) |
|
|
||||||
| Res8 | Нет (анимация) |
|
|
||||||
| Res19 | Нет (анимация) |
|
|
||||||
| Res15 | Нет |
|
|
||||||
| Res16 | Нет |
|
|
||||||
| Res18 | Нет |
|
|
||||||
| Res20 | Нет |
|
|
||||||
|
|
||||||
## 5.7. Сводка алгоритмов декодирования
|
|
||||||
|
|
||||||
### Позиции (Res3)
|
|
||||||
|
|
||||||
```python
|
|
||||||
def decode_position(data, vertex_index):
|
|
||||||
offset = vertex_index * 12
|
|
||||||
x = struct.unpack_from('<f', data, offset)[0]
|
|
||||||
y = struct.unpack_from('<f', data, offset + 4)[0]
|
|
||||||
z = struct.unpack_from('<f', data, offset + 8)[0]
|
|
||||||
return (x, y, z)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Нормали (Res4)
|
## 3.4. Animation key (Res8)
|
||||||
|
|
||||||
```python
|
`pos(float3) + time(float) + quat(int16x4)`:
|
||||||
def decode_normal(data, vertex_index):
|
|
||||||
offset = vertex_index * 4
|
```text
|
||||||
nx = struct.unpack_from('<b', data, offset)[0] # int8
|
q = s16 / 32767.0
|
||||||
ny = struct.unpack_from('<b', data, offset + 1)[0]
|
|
||||||
nz = struct.unpack_from('<b', data, offset + 2)[0]
|
|
||||||
# nw = data[offset + 3] # не используется
|
|
||||||
return (
|
|
||||||
max(-1.0, min(1.0, nx / 127.0)),
|
|
||||||
max(-1.0, min(1.0, ny / 127.0)),
|
|
||||||
max(-1.0, min(1.0, nz / 127.0)),
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### UV‑координаты (Res5)
|
## 4. Практический reader-контракт
|
||||||
|
|
||||||
```python
|
Для runtime-совместимого чтения модели:
|
||||||
def decode_uv(data, vertex_index):
|
|
||||||
offset = vertex_index * 4
|
|
||||||
u = struct.unpack_from('<h', data, offset)[0] # int16
|
|
||||||
v = struct.unpack_from('<h', data, offset + 2)[0]
|
|
||||||
return (u / 1024.0, v / 1024.0)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Кодирование нормали (для экспортёра)
|
1. Найти нужные ресурсы по `type_id` в NRes.
|
||||||
|
2. Проверить `size/stride`-инварианты.
|
||||||
|
3. Проверить диапазоны ссылок:
|
||||||
|
- slot -> batch/triangles;
|
||||||
|
- batch -> indices;
|
||||||
|
- indices -> vertices;
|
||||||
|
- anim_map -> anim_keys.
|
||||||
|
4. Неизвестные поля и неизвестные ресурсы сохранять через copy-through.
|
||||||
|
|
||||||
```python
|
## 5. Практический writer-контракт
|
||||||
def encode_normal(nx, ny, nz):
|
|
||||||
return (
|
|
||||||
max(-128, min(127, int(round(nx * 127.0)))),
|
|
||||||
max(-128, min(127, int(round(ny * 127.0)))),
|
|
||||||
max(-128, min(127, int(round(nz * 127.0)))),
|
|
||||||
0 # nw = 0 (безопасное значение)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Кодирование UV (для экспортёра)
|
1. Пересчитывать только явно вычислимые поля.
|
||||||
|
2. Не нормализовать opaque-данные без уверенной спецификации.
|
||||||
|
3. При roundtrip неизмененных данных требовать byte-identical результат.
|
||||||
|
4. Для новых ассетов фиксировать отдельную политику «генерация vs preserve».
|
||||||
|
|
||||||
```python
|
## 6. Runtime-связка материалов и текстур
|
||||||
def encode_uv(u, v):
|
|
||||||
return (
|
|
||||||
max(-32768, min(32767, int(round(u * 1024.0)))),
|
|
||||||
max(-32768, min(32767, int(round(v * 1024.0))))
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Строки узлов (Res10)
|
Канонический путь резолва:
|
||||||
|
|
||||||
```python
|
1. Модель -> wear-таблица (`*.wea`).
|
||||||
def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]:
|
2. Wear-слот -> material name.
|
||||||
out = []
|
3. Material -> текущая фаза -> `textureName`.
|
||||||
off = 0
|
4. `Texm` ищется в `Textures.lib` (или lightmap-библиотеке для lightmap-ветки).
|
||||||
for _ in range(node_count):
|
|
||||||
ln = struct.unpack_from('<I', buf, off)[0]
|
|
||||||
off += 4
|
|
||||||
if ln == 0:
|
|
||||||
out.append(None)
|
|
||||||
continue
|
|
||||||
raw = buf[off:off + ln + 1] # len + '\0'
|
|
||||||
out.append(raw[:-1].decode('ascii', errors='replace'))
|
|
||||||
off += ln + 1
|
|
||||||
return out
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ключ анимации (Res8) и mapping (Res19)
|
Fallback:
|
||||||
|
|
||||||
```python
|
- материал: `DEFAULT`, затем индекс `0`;
|
||||||
def decode_anim_key24(buf: bytes, idx: int):
|
- текстура/lightmap: fallback-слот движка.
|
||||||
o = idx * 24
|
|
||||||
px, py, pz, t = struct.unpack_from('<4f', buf, o)
|
|
||||||
qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16)
|
|
||||||
s = 1.0 / 32767.0
|
|
||||||
return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Эффектный поток (FXID)
|
## 7. Что уже закрыто для 1:1
|
||||||
|
|
||||||
```python
|
1. Бинарный контракт базовых MSH таблиц.
|
||||||
FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208}
|
2. Контракт animation sampling (`Res8 + Res19`).
|
||||||
|
3. Контракт MAT0/WEAR/Texm на уровне чтения и применения в кадре.
|
||||||
|
4. Формат FXID-контейнера, командный поток и fixed command sizes.
|
||||||
|
5. Валидация на retail-корпусе через `tools/msh_doc_validator.py` (0 ошибок/предупреждений).
|
||||||
|
|
||||||
def parse_fx_payload(raw: bytes):
|
## 8. Статус покрытия и что осталось до 100%
|
||||||
cmd_count = struct.unpack_from('<I', raw, 0)[0]
|
|
||||||
ptr = 0x3C
|
|
||||||
cmds = []
|
|
||||||
for _ in range(cmd_count):
|
|
||||||
w = struct.unpack_from('<I', raw, ptr)[0]
|
|
||||||
op = w & 0xFF
|
|
||||||
enabled = (w >> 8) & 1
|
|
||||||
size = FX_CMD_SIZE[op]
|
|
||||||
cmds.append((op, enabled, ptr, size))
|
|
||||||
ptr += size
|
|
||||||
if ptr != len(raw):
|
|
||||||
raise ValueError('tail bytes after command stream')
|
|
||||||
return cmds
|
|
||||||
```
|
|
||||||
|
|
||||||
### Texm (header + mips + Page)
|
1. Полная field-level семантика части служебных полей:
|
||||||
|
- `Batch20` opaque-поля;
|
||||||
```python
|
- хвостовые служебные поля slot-записей;
|
||||||
def parse_texm(raw: bytes):
|
- часть флагов узлов/групп.
|
||||||
magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0)
|
2. Полный writer-путь для авторинга новых анимированных ассетов (не только roundtrip существующих).
|
||||||
assert magic == 0x6D786554 # 'Texm'
|
3. Полная формализация семантики FX payload полей по каждому opcode для генерации новых эффектов, а не только для корректного чтения/исполнения.
|
||||||
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4)
|
4. Полный канонический writer `Texm` для всех редких форматов и edge-case комбинаций служебных флагов.
|
||||||
pix_sum = 0
|
5. Сквозной «импорт внешнего ассета -> игровой пакет» с формальной спецификацией sidecar-метаданных (материал/эффект/анимация).
|
||||||
mw, mh = w, h
|
|
||||||
for _ in range(mips):
|
|
||||||
pix_sum += mw * mh
|
|
||||||
mw = max(1, mw >> 1)
|
|
||||||
mh = max(1, mh >> 1)
|
|
||||||
off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum
|
|
||||||
page = None
|
|
||||||
if off + 8 <= len(raw) and raw[off:off+4] == b'Page':
|
|
||||||
n = struct.unpack_from('<I', raw, off + 4)[0]
|
|
||||||
page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)]
|
|
||||||
return (w, h, mips, fmt, f4, f5, unk6, page)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 6. Остаточные семантические вопросы
|
|
||||||
|
|
||||||
Пункты ниже **не блокируют 1:1-парсинг/рендер/интерполяцию** (все бинарные структуры уже определены), но их человеко‑читаемая трактовка может быть уточнена дополнительно.
|
|
||||||
|
|
||||||
## 6.1. Batch table — смысл `unk4/unk6/unk14`
|
|
||||||
|
|
||||||
Физическое расположение полей известно, но доменное имя/назначение не зафиксировано:
|
|
||||||
|
|
||||||
- `unk4` (`+0x04`)
|
|
||||||
- `unk6` (`+0x06`)
|
|
||||||
- `unk14` (`+0x0E`)
|
|
||||||
|
|
||||||
## 6.2. Node flags и имена групп
|
|
||||||
|
|
||||||
- Биты в `Res1.hdr0` используются в ряде рантайм‑веток, но их «геймдизайн‑имена» неизвестны.
|
|
||||||
- Для group‑индекса `0..4` не найдено текстовых label'ов в ресурсах; для совместимости нужно сохранять числовой индекс как есть.
|
|
||||||
|
|
||||||
## 6.3. Slot tail `unk30..unk40`
|
|
||||||
|
|
||||||
Хвост слота (`+0x30..+0x43`, `5×uint32`) стабильно присутствует в формате, но движок не делает явной семантической декомпозиции этих пяти слов в path'ах загрузки/рендера/коллизии.
|
|
||||||
|
|
||||||
## 6.4. Effect command payload semantics
|
|
||||||
|
|
||||||
Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode.
|
|
||||||
|
|
||||||
## 6.5. Поля `TexmHeader.flags4/flags5/unk6`
|
|
||||||
|
|
||||||
Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации.
|
|
||||||
|
|
||||||
## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`)
|
|
||||||
|
|
||||||
Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1‑поведения при импорте внешней геометрии обратно в формат игры.
|
|
||||||
|
|
||||||
### A) Неполная «авторская» семантика бинарных таблиц
|
|
||||||
|
|
||||||
1. `Res2` header (`первые 0x8C`): не зафиксированы все поля и правила их вычисления при генерации нового файла (а не copy-through из оригинала).
|
|
||||||
2. `Res7` tri-descriptor: для 16‑байтной записи декодирован базовый каркас, но остаётся неформализованной часть служебных бит/полей, нужных для стабильной генерации adjacency/служебной топологии.
|
|
||||||
3. `Res13` поля `unk4/unk6/unk14`: для парсинга достаточно, но для генерации «канонических» значений из голого `OBJ` правила не определены.
|
|
||||||
4. `Res2` slot tail (`unk30..unk40`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения.
|
|
||||||
|
|
||||||
### B) Анимационный path ещё не закрыт как writer
|
|
||||||
|
|
||||||
1. Нужен полный writer для `Res8/Res19`:
|
|
||||||
- точная спецификация байтового формата на запись;
|
|
||||||
- правила генерации mapping (`Res19`) по узлам/кадрам;
|
|
||||||
- жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра).
|
|
||||||
2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtime‑сущностям пока описаны частично и требуют формализации именно для импорта новых данных.
|
|
||||||
|
|
||||||
### C) Материалы, текстуры, эффекты для «полного ассета»
|
|
||||||
|
|
||||||
1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей).
|
|
||||||
2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну.
|
|
||||||
3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный.
|
|
||||||
|
|
||||||
### D) Что это означает на практике
|
|
||||||
|
|
||||||
1. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры).
|
|
||||||
2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C.
|
|
||||||
3. До закрытия пунктов A/B/C рекомендуется использовать режим:
|
|
||||||
- геометрия экспортируется из `OBJ`;
|
|
||||||
- неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры.
|
|
||||||
|
|||||||
@@ -13,12 +13,27 @@
|
|||||||
7. [Render pipeline](render.md) — полный процесс рендера кадра.
|
7. [Render pipeline](render.md) — полный процесс рендера кадра.
|
||||||
8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
|
8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
|
||||||
9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы.
|
9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы.
|
||||||
|
10. [Documentation coverage audit](coverage-audit.md) — сводка покрытия и оставшиеся блокеры.
|
||||||
|
|
||||||
## Связанные спецификации
|
## Связанные спецификации
|
||||||
|
|
||||||
- [NRes / RsLi](nres.md)
|
- [NRes](nres.md)
|
||||||
|
- [RsLi](rsli.md)
|
||||||
|
|
||||||
## Принцип декомпозиции
|
## Принцип декомпозиции
|
||||||
|
|
||||||
- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
|
- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
|
||||||
- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько DLL и не является форматом на диске.
|
- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько runtime-подсистем и не является форматом на диске.
|
||||||
|
|
||||||
|
## Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Документация декомпозирована по объектам: geometry, animation, material, texture, wear, fx, render, terrain.
|
||||||
|
2. Форматные инварианты ключевых 3D-ресурсов проверяются автоматическими валидаторами на retail-корпусе.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полный сквозной writer-путь для генерации новых игровых ассетов без copy-through зависимостей.
|
||||||
|
2. Полный паритетный рендер-тест (эталонные кадры оригинала vs новый рендер) на расширенном наборе моделей/материалов/FX.
|
||||||
|
3. Полное покрытие соседних геймплейных подсистем (`AI`, `Behavior`, `Missions`, `Control`, `UI`, `Sound`, `Network`) до уровня точных форматов и runtime-контрактов.
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# Network system
|
# Network system
|
||||||
|
|
||||||
Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P).
|
`Network` — подсистема синхронизации состояния игры между узлами (мультиплеер/обмен состоянием).
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`.
|
## 1. Роль
|
||||||
|
|
||||||
|
1. Транспортирует игровые события и state-delta.
|
||||||
|
2. Синхронизирует критичные объекты мира и таймеры.
|
||||||
|
3. Обеспечивает согласованность simulation между участниками.
|
||||||
|
|
||||||
|
## 2. Минимальный контракт для 1:1
|
||||||
|
|
||||||
|
1. Детеминированная сериализация сетевых сообщений.
|
||||||
|
2. Согласованная обработка порядка/потерь/повторов пакетов.
|
||||||
|
3. Единая политика authority и коррекции расхождений.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- определено место сетевого слоя в общей архитектуре движка.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация wire-протокола (header, message types, payload layout).
|
||||||
|
2. Полный контракт handshake/session lifecycle.
|
||||||
|
3. Формальные правила resync/rollback/correction.
|
||||||
|
4. Набор сетевых parity-тестов на контролируемой потере/задержке.
|
||||||
|
|||||||
@@ -1,718 +1,202 @@
|
|||||||
# Форматы игровых ресурсов
|
# NRes
|
||||||
|
|
||||||
## Обзор
|
`NRes` — базовый контейнер ресурсов движка Parkan: Iron Strategy.
|
||||||
|
Страница фиксирует формат на диске и runtime-контракт чтения/поиска/сохранения в высокоуровневом виде, без привязки к внутренним адресам и именам из дизассемблера.
|
||||||
|
|
||||||
Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
|
Связанная страница:
|
||||||
|
|
||||||
1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
|
- [RsLi](rsli.md)
|
||||||
|
|
||||||
2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
|
## 1. Назначение
|
||||||
|
|
||||||
---
|
`NRes` используется как универсальный архив:
|
||||||
|
|
||||||
## Часть 1. Формат NRes
|
- 3D-модели (`*.msh`, `*.rlb`);
|
||||||
|
- текстуры (`Texm`);
|
||||||
|
- материалы (`MAT0`);
|
||||||
|
- эффекты (`FXID`);
|
||||||
|
- миссионные и служебные ресурсы.
|
||||||
|
|
||||||
### 1.1. Общая структура файла
|
Формат поддерживает:
|
||||||
|
|
||||||
```
|
- чтение;
|
||||||
┌──────────────────────────┐ Смещение 0
|
- поиск по имени;
|
||||||
│ Заголовок (16 байт) │
|
- редактирование (add/replace/remove);
|
||||||
├──────────────────────────┤ Смещение 16
|
- полную пересборку архива.
|
||||||
│ │
|
|
||||||
│ Данные ресурсов │
|
## 2. Общий layout файла
|
||||||
│ (выровнены по 8 байт) │
|
|
||||||
│ │
|
```text
|
||||||
├──────────────────────────┤ Смещение = total_size - entry_count × 64
|
[Header: 16]
|
||||||
│ Каталог записей │
|
[Data region: variable, 8-byte aligned chunks]
|
||||||
│ (entry_count × 64 байт) │
|
[Directory: entry_count * 64, всегда в конце файла]
|
||||||
└──────────────────────────┘ Смещение = total_size
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.2. Заголовок файла (16 байт)
|
Критично: каталог всегда расположен в конце файла.
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Значение | Описание |
|
## 3. Заголовок (16 байт)
|
||||||
| -------- | ------ | ------- | ------------------- | ------------------------------------ |
|
|
||||||
| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
|
|
||||||
| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
|
|
||||||
| 8 | 4 | int32 | — | Количество записей в каталоге |
|
|
||||||
| 12 | 4 | int32 | — | Полный размер файла в байтах |
|
|
||||||
|
|
||||||
**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
|
Все значения little-endian.
|
||||||
|
|
||||||
### 1.3. Положение каталога в файле
|
| Offset | Size | Type | Значение |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 0 | 4 | char[4] | `NRes` |
|
||||||
|
| 4 | 4 | u32 | `0x00000100` (версия 1.0) |
|
||||||
|
| 8 | 4 | i32 | `entry_count` (должен быть `>= 0`) |
|
||||||
|
| 12 | 4 | u32 | `total_size` (должен быть равен фактическому размеру файла) |
|
||||||
|
|
||||||
Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
|
Производные значения:
|
||||||
|
|
||||||
```
|
- `directory_size = entry_count * 64`;
|
||||||
directory_offset = total_size - entry_count × 64
|
- `directory_offset = total_size - directory_size`.
|
||||||
```
|
|
||||||
|
|
||||||
Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
|
Ограничения:
|
||||||
|
|
||||||
### 1.4. Запись каталога (64 байта)
|
- `directory_offset >= 16`;
|
||||||
|
- `directory_offset + directory_size == total_size`.
|
||||||
|
|
||||||
Каждая запись каталога занимает ровно **64 байта** (0x40):
|
## 4. Запись каталога (64 байта)
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Описание |
|
| Offset | Size | Type | Поле |
|
||||||
| -------- | ------ | -------- | ------------------------------------------------- |
|
|---:|---:|---|---|
|
||||||
| 0 | 4 | uint32 | Тип / идентификатор ресурса |
|
| 0 | 4 | u32 | `type_id` |
|
||||||
| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) |
|
| 4 | 4 | u32 | `attr1` |
|
||||||
| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) |
|
| 8 | 4 | u32 | `attr2` |
|
||||||
| 12 | 4 | uint32 | Размер данных ресурса в байтах |
|
| 12 | 4 | u32 | `size` (размер payload) |
|
||||||
| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) |
|
| 16 | 4 | u32 | `attr3` |
|
||||||
| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) |
|
| 20 | 36 | char[36] | `name_raw` (C-строка) |
|
||||||
| 56 | 4 | uint32 | Смещение данных от начала файла |
|
| 56 | 4 | u32 | `data_offset` |
|
||||||
| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
|
| 60 | 4 | u32 | `sort_index` |
|
||||||
|
|
||||||
#### Поле «Имя файла» (смещение 20, 36 байт)
|
### 4.1. Имя ресурса (`name_raw`)
|
||||||
|
|
||||||
- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
|
Контракт:
|
||||||
- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
|
|
||||||
- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
|
|
||||||
|
|
||||||
#### Поле «Индекс сортировки» (смещение 60)
|
- максимум 35 полезных байт + NUL;
|
||||||
|
- допускается ровно один терминатор внутри 36-байтового поля;
|
||||||
|
- имя сравнивается регистронезависимо по ASCII-правилу (`A..Z` -> `a..z`).
|
||||||
|
|
||||||
Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
|
Для writer/editor:
|
||||||
|
|
||||||
**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
|
- запрещено писать NUL внутри полезной части имени;
|
||||||
|
- запрещены имена длиной > 35 байт.
|
||||||
|
|
||||||
#### Поле «Смещение данных» (смещение 56)
|
### 4.2. Диапазон данных (`data_offset`, `size`)
|
||||||
|
|
||||||
Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
|
Для каждой записи:
|
||||||
|
|
||||||
### 1.5. Выравнивание данных
|
- `data_offset >= 16`;
|
||||||
|
- `data_offset + size <= directory_offset`.
|
||||||
|
|
||||||
При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
|
Практически (канонический writer): каждый payload начинается с 8-байтного выравнивания.
|
||||||
|
|
||||||
```c
|
## 5. Таблица сортировки (`sort_index`)
|
||||||
padding = ((data_size + 7) & ~7) - data_size;
|
|
||||||
// Если padding > 0, записываются нулевые байты
|
|
||||||
```
|
|
||||||
|
|
||||||
Таким образом, каждый блок данных начинается с адреса, кратного 8.
|
`sort_index` задает перестановку «отсортированный список -> исходный индекс записи».
|
||||||
|
|
||||||
При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
|
Пусть:
|
||||||
|
|
||||||
### 1.6. Создание файла (API `niCreateResFile`)
|
- `entries[i]` — i-я запись каталога в исходном порядке;
|
||||||
|
- `P` — массив индексов `0..entry_count-1`, отсортированный по `entries[idx].name` (ASCII case-insensitive).
|
||||||
|
|
||||||
При создании нового файла:
|
Тогда в канонической записи:
|
||||||
|
|
||||||
1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
|
- `entries[i].sort_index = P[i]`.
|
||||||
2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
|
|
||||||
|
|
||||||
При закрытии файла (`sub_100122D0`):
|
Это именно таблица для бинарного поиска по имени, а не «ранг текущей записи».
|
||||||
|
|
||||||
1. Заголовок переписывается в начало файла (16 байт).
|
## 6. Поиск по имени
|
||||||
2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
|
|
||||||
3. Индексы сортировки пересчитываются.
|
|
||||||
4. Каталог записей записывается в конец файла.
|
|
||||||
|
|
||||||
### 1.7. Режимы сортировки каталога
|
Алгоритм поиска:
|
||||||
|
|
||||||
Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
|
1. Выполнить бинарный поиск по диапазону `i in [0, entry_count)`.
|
||||||
|
2. На шаге `i` взять `target = entries[i].sort_index`.
|
||||||
|
3. Сравнить искомое имя с `entries[target].name` (ASCII case-insensitive).
|
||||||
|
4. При совпадении вернуть `target`.
|
||||||
|
|
||||||
| Режим | Порядок сортировки |
|
Fail-safe поведение:
|
||||||
| ----- | --------------------------------- |
|
|
||||||
| 0 | Без сортировки (сброс) |
|
|
||||||
| 1 | По атрибуту 1 (смещение 4) |
|
|
||||||
| 2 | По атрибуту 2 (смещение 8) |
|
|
||||||
| 3 | По (атрибут 1, атрибут 2) |
|
|
||||||
| 4 | По типу ресурса (смещение 0) |
|
|
||||||
| 5 | По (тип, атрибут 1) |
|
|
||||||
| 6 | По (тип, атрибут 1) — идентичен 5 |
|
|
||||||
| 7 | По (тип, атрибут 1, атрибут 2) |
|
|
||||||
| 8 | По имени (регистронезависимо) |
|
|
||||||
| 9 | По (тип, имя) |
|
|
||||||
| 10 | По (атрибут 1, имя) |
|
|
||||||
| 11 | По (атрибут 2, имя) |
|
|
||||||
|
|
||||||
### 1.8. Операция `niOpenResFileEx` — флаги открытия
|
- если `sort_index` некорректен (выход за диапазон), реализация должна перейти на линейный fallback по всем записям;
|
||||||
|
- fallback использует то же ASCII case-insensitive сравнение.
|
||||||
|
|
||||||
Второй параметр — битовые флаги:
|
## 7. Каноническая пересборка архива
|
||||||
|
|
||||||
| Бит | Маска | Описание |
|
Канонический writer выполняет:
|
||||||
| --- | ----- | ----------------------------------------------------------------------------------- |
|
|
||||||
| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) |
|
|
||||||
| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение |
|
|
||||||
| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
|
|
||||||
| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
|
|
||||||
|
|
||||||
### 1.9. Виртуальное касание страниц
|
1. Пишет заглушку заголовка (16 байт).
|
||||||
|
2. Пишет payload всех записей в текущем порядке.
|
||||||
|
3. После каждого payload добавляет 0-padding до кратности 8.
|
||||||
|
4. Пересчитывает `sort_index` через сортировку имен.
|
||||||
|
5. Дописывает каталог (`entry_count * 64`).
|
||||||
|
6. Пересчитывает и записывает `total_size`.
|
||||||
|
|
||||||
Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
|
Итоговый файл должен удовлетворять всем ограничениям из разделов 3–5.
|
||||||
|
|
||||||
```
|
## 8. Режим `raw` (совместимость инструментов)
|
||||||
for (result = 0x10000; result < size; result += 4096);
|
|
||||||
```
|
|
||||||
|
|
||||||
Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
|
Для служебных инструментов допускается `raw_mode`:
|
||||||
|
|
||||||
---
|
- любой бинарный файл трактуется как один «сырой» ресурс;
|
||||||
|
- возвращается одна запись (`name = RAW`, `data_offset = 0`, `size = len(file)`).
|
||||||
|
|
||||||
## Часть 2. Формат RsLi
|
Этот режим не является форматом `NRes` на диске, это только режим открытия.
|
||||||
|
|
||||||
### 2.1. Общая структура файла
|
## 9. Контрольные инварианты
|
||||||
|
|
||||||
```
|
Минимальный набор проверок при чтении:
|
||||||
┌───────────────────────────────┐ Смещение 0
|
|
||||||
│ Заголовок файла (32 байта) │
|
|
||||||
├───────────────────────────────┤ Смещение 32
|
|
||||||
│ Таблица записей (зашифрована)│
|
|
||||||
│ (entry_count × 32 байт) │
|
|
||||||
├───────────────────────────────┤ Смещение 32 + entry_count × 32
|
|
||||||
│ │
|
|
||||||
│ Данные ресурсов │
|
|
||||||
│ │
|
|
||||||
├───────────────────────────────┤
|
|
||||||
│ [Опциональный трейлер — 6 б] │
|
|
||||||
└───────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2. Заголовок файла (32 байта)
|
1. `magic == "NRes"`.
|
||||||
|
2. `version == 0x100`.
|
||||||
|
3. `entry_count >= 0`.
|
||||||
|
4. `header.total_size == file_size`.
|
||||||
|
5. Каталог находится в конце файла.
|
||||||
|
6. Для каждой записи диапазон данных не пересекает каталог.
|
||||||
|
7. Имя корректно C-терминировано и не длиннее 35 байт.
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Значение | Описание |
|
Минимальный набор проверок при записи:
|
||||||
| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
|
|
||||||
| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура |
|
|
||||||
| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) |
|
|
||||||
| 3 | 1 | uint8 | `0x01` | Версия формата |
|
|
||||||
| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) |
|
|
||||||
| 6 | 8 | — | — | Зарезервировано / не используется |
|
|
||||||
| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) |
|
|
||||||
| 16 | 4 | — | — | Зарезервировано |
|
|
||||||
| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
|
|
||||||
| 24 | 8 | — | — | Зарезервировано |
|
|
||||||
|
|
||||||
#### Флаг предсортировки (смещение 14)
|
1. Все имена <= 35 байт и без внутренних NUL.
|
||||||
|
2. `sort_index` формирует валидную перестановку `0..N-1`.
|
||||||
|
3. Все паддинги между payload состоят из нулевых байт.
|
||||||
|
4. `total_size` равен фактической длине выходного файла.
|
||||||
|
|
||||||
- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных).
|
## 10. Эмпирическая проверка на retail-корпусе
|
||||||
- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит).
|
|
||||||
|
|
||||||
### 2.3. XOR-шифр таблицы записей
|
Валидация на полном наборе `testdata/Parkan - Iron Strategy`:
|
||||||
|
|
||||||
Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
|
- найдено `120` архивов `NRes`;
|
||||||
|
- roundtrip `unpack -> repack -> byte-compare`: `120/120` совпали побайтно;
|
||||||
|
- критических расхождений формата не обнаружено.
|
||||||
|
|
||||||
#### Начальное состояние
|
Инструмент:
|
||||||
|
|
||||||
```
|
- `tools/archive_roundtrip_validator.py`
|
||||||
seed = *(uint32*)(header + 20)
|
|
||||||
lo = seed & 0xFF // Младший байт
|
|
||||||
hi = (seed >> 8) & 0xFF // Второй байт
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Алгоритм дешифровки (побайтовый)
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
|
Закрыто:
|
||||||
|
|
||||||
```
|
- формат заголовка/каталога;
|
||||||
step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi
|
- правила поиска;
|
||||||
step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта
|
- каноническая пересборка;
|
||||||
step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo
|
- строгие инварианты валидатора;
|
||||||
```
|
- побайтовый roundtrip на retail-корпусе.
|
||||||
|
|
||||||
**Пример реализации:**
|
Осталось до полного 100% архитектурного покрытия движка:
|
||||||
|
|
||||||
```python
|
1. Формальная семантика `attr1/attr2/attr3` для всех типов ресурсов (частично вынесена в профильные страницы `msh`, `material`, `texture`, `fxid`, `terrain`).
|
||||||
def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
2. Полная спецификация поведения при не-ASCII именах (в реальных игровых архивах используется ASCII-практика; для Unicode-коллации движок не документирован).
|
||||||
lo = seed & 0xFF
|
3. Полная спецификация платформенных гарантий атомарной записи (формат данных закрыт, но OS-уровневые гарантии замены файла зависят от платформы и файловой системы).
|
||||||
hi = (seed >> 8) & 0xFF
|
## 12. Специализация `objects.rlb`
|
||||||
result = bytearray(len(encrypted_data))
|
|
||||||
for i in range(len(encrypted_data)):
|
|
||||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
|
||||||
result[i] = lo ^ encrypted_data[i]
|
|
||||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
|
||||||
return bytes(result)
|
|
||||||
```
|
|
||||||
|
|
||||||
Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
|
Хотя `objects.rlb` формально является обычным `NRes`, его payload имеет отдельный семантический контракт:
|
||||||
|
|
||||||
### 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
|
- запись каталога соответствует одному объектному прототипу;
|
||||||
|
- payload записи - массив фиксированных ссылок `ObjectRef64` (`archive_name[32] + resource_name[32]`);
|
||||||
|
- runtime-резолв меша выполняется через эти ссылки, а не через имя entry `*.msh` внутри `objects.rlb`.
|
||||||
|
|
||||||
После дешифровки каждая запись имеет следующую структуру:
|
Это означает, что `objects.rlb` должен рассматриваться не как архив мешей, а как реестр привязок между mission/unit-ключами и фактическими ресурсами.
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Описание |
|
См. детальную страницу:
|
||||||
| -------- | ------ | -------- | -------------------------------------------------------------- |
|
|
||||||
| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) |
|
|
||||||
| 12 | 4 | — | Зарезервировано (движком игнорируется) |
|
|
||||||
| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) |
|
|
||||||
| 18 | 2 | int16 | **`sort_to_original[i]` / XOR‑ключ** (см. ниже) |
|
|
||||||
| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) |
|
|
||||||
| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
|
|
||||||
| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
|
|
||||||
|
|
||||||
#### Имена ресурсов
|
- [Object registry (`objects.rlb`)](object-registry.md)
|
||||||
|
|
||||||
- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
|
|
||||||
- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
|
|
||||||
- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
|
|
||||||
|
|
||||||
#### Поле `sort_to_original[i]` (смещение 18)
|
|
||||||
|
|
||||||
Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
|
|
||||||
|
|
||||||
- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`.
|
|
||||||
- Тем же значением (младшие 16 бит) инициализируется XOR‑шифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2).
|
|
||||||
|
|
||||||
Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
|
|
||||||
|
|
||||||
### 2.5. Поле флагов (смещение 16 записи)
|
|
||||||
|
|
||||||
Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
|
|
||||||
|
|
||||||
```
|
|
||||||
Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования
|
|
||||||
Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Методы сжатия (биты 8–5, маска 0x1E0)
|
|
||||||
|
|
||||||
| Значение | Hex | Описание |
|
|
||||||
| -------- | ----- | --------------------------------------- |
|
|
||||||
| 0x000 | 0x00 | Без сжатия (копирование) |
|
|
||||||
| 0x020 | 0x20 | Только XOR-шифр |
|
|
||||||
| 0x040 | 0x40 | LZSS (простой вариант) |
|
|
||||||
| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) |
|
|
||||||
| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана |
|
|
||||||
| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом |
|
|
||||||
| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) |
|
|
||||||
|
|
||||||
Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому:
|
|
||||||
|
|
||||||
- для 0x20 вернётся 0x00,
|
|
||||||
- для 0x60 вернётся 0x40,
|
|
||||||
- для 0xA0 вернётся 0x80.
|
|
||||||
|
|
||||||
#### Бит 0x40 (выделение +0x12 и последующее `realloc`)
|
|
||||||
|
|
||||||
Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
|
|
||||||
|
|
||||||
Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
|
|
||||||
|
|
||||||
### 2.6. Размеры данных
|
|
||||||
|
|
||||||
В каждой записи на диске хранятся оба значения:
|
|
||||||
|
|
||||||
- `unpacked_size` (смещение 20) — размер распакованных данных.
|
|
||||||
- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода).
|
|
||||||
|
|
||||||
Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`.
|
|
||||||
|
|
||||||
`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
|
|
||||||
|
|
||||||
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
|
|
||||||
|
|
||||||
### 2.7. Опциональный трейлер медиа (6 байт)
|
|
||||||
|
|
||||||
При открытии с флагом `a2 & 2`:
|
|
||||||
|
|
||||||
| Смещение от конца | Размер | Тип | Описание |
|
|
||||||
| ----------------- | ------ | ------- | ----------------------- |
|
|
||||||
| −6 | 2 | char[2] | Сигнатура `AO` (0x4F41) |
|
|
||||||
| −4 | 4 | uint32 | Смещение медиа-оверлея |
|
|
||||||
|
|
||||||
Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Часть 3. Алгоритмы сжатия (формат RsLi)
|
|
||||||
|
|
||||||
### 3.1. XOR-шифр данных (метод 0x20)
|
|
||||||
|
|
||||||
Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
|
|
||||||
|
|
||||||
Важно про размер входа:
|
|
||||||
|
|
||||||
- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
|
|
||||||
- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
|
|
||||||
|
|
||||||
#### Инициализация
|
|
||||||
|
|
||||||
```
|
|
||||||
key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
|
|
||||||
lo = key16 & 0xFF
|
|
||||||
hi = (key16 >> 8) & 0xFF
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Дешифровка (псевдокод)
|
|
||||||
|
|
||||||
```
|
|
||||||
for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
|
|
||||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
|
||||||
out[i] = in[i] ^ lo
|
|
||||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2. LZSS — простой вариант (метод 0x40)
|
|
||||||
|
|
||||||
Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
|
|
||||||
|
|
||||||
#### Параметры
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
| ----------------------------- | ------------------ |
|
|
||||||
| Размер кольцевого буфера | 4096 байт (0x1000) |
|
|
||||||
| Начальная позиция записи | 4078 (0xFEE) |
|
|
||||||
| Начальное заполнение | 0x20 (пробел) |
|
|
||||||
| Минимальная длина совпадения | 3 |
|
|
||||||
| Максимальная длина совпадения | 18 (4 бита + 3) |
|
|
||||||
|
|
||||||
#### Алгоритм декомпрессии
|
|
||||||
|
|
||||||
```
|
|
||||||
Инициализация:
|
|
||||||
ring_buffer[0..4095] = 0x20 (заполнить пробелами)
|
|
||||||
ring_pos = 4078
|
|
||||||
flags_byte = 0
|
|
||||||
flags_bits_remaining = 0
|
|
||||||
|
|
||||||
Цикл (пока не заполнен выходной буфер И не исчерпан входной):
|
|
||||||
|
|
||||||
1. Если flags_bits_remaining == 0:
|
|
||||||
- Прочитать 1 байт из входного потока → flags_byte
|
|
||||||
- flags_bits_remaining = 8
|
|
||||||
|
|
||||||
Декодировать как:
|
|
||||||
- Старший бит устанавливается в 0x7F (маркер)
|
|
||||||
- Оставшиеся 7 бит — флаги текущей группы
|
|
||||||
|
|
||||||
Реально в коде: control_word = (flags_byte) | (0x7F << 8)
|
|
||||||
Каждый бит проверяется сдвигом вправо.
|
|
||||||
|
|
||||||
2. Проверить младший бит control_word:
|
|
||||||
|
|
||||||
Если бит = 1 (литерал):
|
|
||||||
- Прочитать 1 байт из входного потока → byte
|
|
||||||
- ring_buffer[ring_pos] = byte
|
|
||||||
- ring_pos = (ring_pos + 1) & 0xFFF
|
|
||||||
- Записать byte в выходной буфер
|
|
||||||
|
|
||||||
Если бит = 0 (ссылка):
|
|
||||||
- Прочитать 2 байта: low_byte, high_byte
|
|
||||||
- offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
|
|
||||||
- length = (high_byte & 0x0F) + 3 // 4 бита + 3
|
|
||||||
- Скопировать length байт из ring_buffer[offset...]:
|
|
||||||
для j от 0 до length-1:
|
|
||||||
byte = ring_buffer[(offset + j) & 0xFFF]
|
|
||||||
ring_buffer[ring_pos] = byte
|
|
||||||
ring_pos = (ring_pos + 1) & 0xFFF
|
|
||||||
записать byte в выходной буфер
|
|
||||||
|
|
||||||
3. Сдвинуть control_word вправо на 1 бит
|
|
||||||
4. flags_bits_remaining -= 1
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Подробная раскладка пары ссылки (2 байта)
|
|
||||||
|
|
||||||
```
|
|
||||||
Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
|
|
||||||
Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3
|
|
||||||
|
|
||||||
offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
|
|
||||||
length = (high & 0x0F) + 3 // Диапазон: 3–18
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
|
|
||||||
|
|
||||||
Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
|
|
||||||
|
|
||||||
#### Параметры
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
| -------------------------------- | ------------------------------ |
|
|
||||||
| Размер кольцевого буфера | 4096 байт |
|
|
||||||
| Начальная позиция записи | **4036** (0xFC4) |
|
|
||||||
| Начальное заполнение | 0x20 (пробел) |
|
|
||||||
| Количество листовых узлов дерева | 314 |
|
|
||||||
| Символы литералов | 0–255 (байты) |
|
|
||||||
| Символы длин | 256–313 (длина = символ − 253) |
|
|
||||||
| Начальная длина | 3 (при символе 256) |
|
|
||||||
| Максимальная длина | 60 (при символе 313) |
|
|
||||||
|
|
||||||
#### Дерево Хаффмана
|
|
||||||
|
|
||||||
Дерево строится как **адаптивное** (dynamic, self-adjusting):
|
|
||||||
|
|
||||||
- **627 узлов**: 314 листовых + 313 внутренних.
|
|
||||||
- Все листья изначально имеют **вес 1**.
|
|
||||||
- Корень дерева — узел с индексом 0 (в массиве `parent`).
|
|
||||||
- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
|
|
||||||
- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
|
|
||||||
|
|
||||||
#### Кодирование позиции
|
|
||||||
|
|
||||||
Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
|
|
||||||
|
|
||||||
- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
|
|
||||||
- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
|
|
||||||
- Финальная позиция: `pos = (ring_pos − 1 − decoded_position) & 0xFFF`
|
|
||||||
|
|
||||||
**Таблицы инициализации** (d-коды):
|
|
||||||
|
|
||||||
```
|
|
||||||
Таблица базовых значений — byte_100371D0[6]:
|
|
||||||
{ 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 }
|
|
||||||
|
|
||||||
Таблица дополнительных битов — byte_100371D6[6]:
|
|
||||||
{ 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Алгоритм декомпрессии (высокоуровневый)
|
|
||||||
|
|
||||||
```
|
|
||||||
Инициализация:
|
|
||||||
ring_buffer[0..4095] = 0x20
|
|
||||||
ring_pos = 4036
|
|
||||||
Инициализировать дерево Хаффмана (314 листьев, все веса = 1)
|
|
||||||
Инициализировать таблицы d-кодов
|
|
||||||
|
|
||||||
Цикл:
|
|
||||||
1. Декодировать символ из потока по дереву Хаффмана:
|
|
||||||
- Начать с корня
|
|
||||||
- Читать биты, спускаться по дереву (0 = левый, 1 = правый)
|
|
||||||
- Пока не достигнут лист → символ = лист − 627
|
|
||||||
|
|
||||||
2. Обновить дерево Хаффмана для декодированного символа
|
|
||||||
|
|
||||||
3. Если символ < 256 (литерал):
|
|
||||||
- ring_buffer[ring_pos] = символ
|
|
||||||
- ring_pos = (ring_pos + 1) & 0xFFF
|
|
||||||
- Записать символ в выходной буфер
|
|
||||||
|
|
||||||
4. Если символ >= 256 (ссылка):
|
|
||||||
- length = символ − 253
|
|
||||||
- Декодировать позицию через d-код:
|
|
||||||
a) Прочитать 8 бит из потока
|
|
||||||
b) Найти d-код и дополнительные биты по таблице
|
|
||||||
c) Прочитать дополнительные биты
|
|
||||||
d) position = (ring_pos − 1 − full_position) & 0xFFF
|
|
||||||
- Скопировать length байт из ring_buffer[position...]
|
|
||||||
|
|
||||||
5. Если выходной буфер заполнен → завершить
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4. XOR + LZSS (методы 0x60 и 0xA0)
|
|
||||||
|
|
||||||
Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
|
|
||||||
|
|
||||||
#### Алгоритм
|
|
||||||
|
|
||||||
1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
|
|
||||||
2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
|
|
||||||
3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
|
|
||||||
4. Освободить временный буфер.
|
|
||||||
|
|
||||||
- **0x60** — XOR + простой LZSS (раздел 3.2)
|
|
||||||
- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
|
|
||||||
|
|
||||||
#### Начальное состояние XOR для данных
|
|
||||||
|
|
||||||
При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
|
|
||||||
|
|
||||||
### 3.5. Deflate (метод 0x100)
|
|
||||||
|
|
||||||
Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
|
|
||||||
|
|
||||||
#### Общая структура
|
|
||||||
|
|
||||||
Данные состоят из последовательности блоков. Каждый блок начинается с:
|
|
||||||
|
|
||||||
- **1 бит** — `is_final`: признак последнего блока
|
|
||||||
- **2 бита** — `block_type`: тип блока
|
|
||||||
|
|
||||||
#### Типы блоков
|
|
||||||
|
|
||||||
| block_type | Описание | Функция |
|
|
||||||
| ---------- | --------------------------- | ---------------- |
|
|
||||||
| 0 | Без сжатия (stored) | `sub_1001A750` |
|
|
||||||
| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` |
|
|
||||||
| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
|
|
||||||
| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
|
|
||||||
|
|
||||||
#### Блок типа 0 (stored)
|
|
||||||
|
|
||||||
1. Отбросить оставшиеся биты до границы байта (выравнивание).
|
|
||||||
2. Прочитать 16 бит — `LEN` (длина блока).
|
|
||||||
3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`).
|
|
||||||
4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка.
|
|
||||||
5. Скопировать `LEN` байт из входного потока в выходной.
|
|
||||||
|
|
||||||
Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
|
|
||||||
|
|
||||||
#### Блок типа 1 (фиксированные коды)
|
|
||||||
|
|
||||||
Стандартные коды Deflate:
|
|
||||||
|
|
||||||
- Литералы/длины: 288 кодов
|
|
||||||
- 0–143: 8-битные коды
|
|
||||||
- 144–255: 9-битные коды
|
|
||||||
- 256–279: 7-битные коды
|
|
||||||
- 280–287: 8-битные коды
|
|
||||||
- Дистанции: 30 кодов, все 5-битные
|
|
||||||
|
|
||||||
Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
|
|
||||||
|
|
||||||
#### Блок типа 2 (динамические коды)
|
|
||||||
|
|
||||||
1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286.
|
|
||||||
2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
|
|
||||||
3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19.
|
|
||||||
4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин.
|
|
||||||
5. Построить дерево Хаффмана для алфавита длин (19 символов).
|
|
||||||
6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
|
|
||||||
7. Построить два дерева Хаффмана: для литералов/длин и для дистанций.
|
|
||||||
8. Декодировать данные.
|
|
||||||
|
|
||||||
**Порядок кодов длин** (стандартный Deflate):
|
|
||||||
|
|
||||||
```
|
|
||||||
{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
|
|
||||||
```
|
|
||||||
|
|
||||||
Хранится в `dword_10037060`.
|
|
||||||
|
|
||||||
#### Валидации
|
|
||||||
|
|
||||||
- `HLIT + 257 <= 286` (max 0x11E)
|
|
||||||
- `HDIST + 1 <= 30` (max 0x1E)
|
|
||||||
- При нарушении — возвращается ошибка 1.
|
|
||||||
|
|
||||||
### 3.6. Метод 0x00 (без сжатия)
|
|
||||||
|
|
||||||
Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Часть 4. Внутренние структуры в памяти
|
|
||||||
|
|
||||||
### 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct NResArchive { // Размер: 0x68 (104 байта)
|
|
||||||
void* vtable; // +0: Указатель на таблицу виртуальных методов
|
|
||||||
int32_t entry_count; // +4: Количество записей
|
|
||||||
void* mapped_base; // +8: Базовый адрес mapped view
|
|
||||||
void* directory_ptr; // +12: Указатель на каталог записей в памяти
|
|
||||||
char* filename; // +16: Путь к файлу (_strdup)
|
|
||||||
int32_t ref_count; // +20: Счётчик ссылок
|
|
||||||
uint32_t last_release_time; // +24: timeGetTime() при последнем Release
|
|
||||||
// +28..+91: Для raw-режима — встроенная запись (единственный File entry)
|
|
||||||
NResArchive* next; // +92: Следующий архив в связном списке
|
|
||||||
uint8_t is_writable; // +100: Файл открыт для записи
|
|
||||||
uint8_t is_cacheable; // +101: Не выгружать при refcount = 0
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct RsLibHeader { // 56 байт (14 DWORD)
|
|
||||||
uint32_t magic; // +0: 'RsLi' (0x694C7352)
|
|
||||||
int32_t entry_count; // +4: Количество записей
|
|
||||||
uint32_t media_offset; // +8: Смещение медиа-оверлея
|
|
||||||
uint32_t reserved_0C; // +12: 0
|
|
||||||
HANDLE file_handle_2; // +16: -1 (дополнительный хэндл)
|
|
||||||
uint32_t reserved_14; // +20: 0
|
|
||||||
uint32_t reserved_18; // +24: —
|
|
||||||
uint32_t reserved_1C; // +28: 0
|
|
||||||
HANDLE mapping_handle_2; // +32: -1
|
|
||||||
uint32_t reserved_24; // +36: 0
|
|
||||||
uint32_t flag_28; // +40: (flags >> 7) & 1
|
|
||||||
HANDLE file_handle; // +44: Хэндл файла
|
|
||||||
HANDLE mapping_handle; // +48: Хэндл файлового маппинга
|
|
||||||
void* mapped_view; // +52: Указатель на mapped view
|
|
||||||
};
|
|
||||||
// Далее следуют entry_count записей по 64 байта каждая
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Внутренняя запись RsLi (64 байта)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct RsLibEntry { // 64 байта (16 DWORD)
|
|
||||||
char name[16]; // +0: Имя (12 из файла + 4 нуля)
|
|
||||||
int32_t flags; // +16: Флаги (sign-extended из int16)
|
|
||||||
int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XOR‑ключ)
|
|
||||||
uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи)
|
|
||||||
void* data_ptr; // +28: Указатель на данные в mapped view
|
|
||||||
uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи)
|
|
||||||
uint32_t reserved_24; // +36: 0
|
|
||||||
uint32_t reserved_28; // +40: 0
|
|
||||||
uint32_t reserved_2C; // +44: 0
|
|
||||||
void* loaded_data; // +48: Указатель на декомпрессированные данные
|
|
||||||
// +52..+63: дополнительные поля
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Часть 5. Экспортируемые API-функции
|
|
||||||
|
|
||||||
### 5.1. NRes API
|
|
||||||
|
|
||||||
| Функция | Описание |
|
|
||||||
| ------------------------------ | ------------------------------------------------------------------------- |
|
|
||||||
| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
|
|
||||||
| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
|
|
||||||
| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
|
|
||||||
| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
|
|
||||||
|
|
||||||
### 5.2. RsLi API
|
|
||||||
|
|
||||||
| Функция | Описание |
|
|
||||||
| ------------------------------- | -------------------------------------------------------- |
|
|
||||||
| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку |
|
|
||||||
| `rsCloseLib(lib)` | Закрыть библиотеку |
|
|
||||||
| `rsLibNum(lib)` | Получить количество записей |
|
|
||||||
| `rsFind(lib, name)` | Найти запись по имени (→ индекс или −1) |
|
|
||||||
| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс |
|
|
||||||
| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) |
|
|
||||||
| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) |
|
|
||||||
| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` |
|
|
||||||
| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
|
|
||||||
| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
|
|
||||||
| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
|
|
||||||
| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
|
|
||||||
| `ngiFree(ptr)` | Освободить память |
|
|
||||||
| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Часть 6. Контрольные заметки для реализации
|
|
||||||
|
|
||||||
### 6.1. Кодировки и регистр
|
|
||||||
|
|
||||||
- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
|
|
||||||
- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
|
|
||||||
|
|
||||||
### 6.2. Порядок байт
|
|
||||||
|
|
||||||
Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
|
|
||||||
|
|
||||||
### 6.3. Выравнивание
|
|
||||||
|
|
||||||
- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
|
|
||||||
- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
|
|
||||||
|
|
||||||
### 6.4. Размер записей на диске
|
|
||||||
|
|
||||||
- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
|
|
||||||
- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
|
|
||||||
|
|
||||||
### 6.5. Кэширование и memory mapping
|
|
||||||
|
|
||||||
Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
|
|
||||||
|
|
||||||
### 6.6. Размер seed XOR
|
|
||||||
|
|
||||||
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
|
|
||||||
- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
|
|
||||||
- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
|
|
||||||
|
|
||||||
### 6.7. Эмпирическая проверка на данных игры
|
|
||||||
|
|
||||||
- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
|
|
||||||
- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
|
|
||||||
- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
|
|
||||||
|
|
||||||
Подтверждённые нюансы:
|
|
||||||
|
|
||||||
- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`.
|
|
||||||
- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла.
|
|
||||||
|
|||||||
145
docs/specs/object-registry.md
Normal file
145
docs/specs/object-registry.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# Object Registry (`objects.rlb`)
|
||||||
|
|
||||||
|
`objects.rlb` - это не архив с готовыми мешами.
|
||||||
|
Это реестр игровых прототипов, который связывает логический идентификатор объекта (`r_h_01`, `s_tree_04`, `fr_m_brige`, ...) с набором реальных ресурсов в других архивах.
|
||||||
|
|
||||||
|
Документ описывает формат и runtime-контракт на высоком уровне, без привязки к внутренним именам/адресам из дизассемблера.
|
||||||
|
|
||||||
|
Связанные страницы:
|
||||||
|
|
||||||
|
- [Missions](missions.md)
|
||||||
|
- [NRes](nres.md)
|
||||||
|
- [MSH core](msh-core.md)
|
||||||
|
- [Wear (`WEAR`)](wear.md)
|
||||||
|
- [Material (`MAT0`)](material.md)
|
||||||
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
|
## 1. Роль в пайплайне
|
||||||
|
|
||||||
|
При загрузке миссии движок работает так:
|
||||||
|
|
||||||
|
1. Из `data.tma` получает `resource_name` объекта:
|
||||||
|
- либо прямой ключ (`s_tree_04`);
|
||||||
|
- либо путь к `*.dat` (например `UNITS\\UNITS\\HERO\\tut1_p.dat`).
|
||||||
|
2. Для `*.dat` читает заголовок и получает:
|
||||||
|
- `archive_name` (в retail-корпусе всегда `objects.rlb`);
|
||||||
|
- `model_key` (например `R_H_02`).
|
||||||
|
3. В `objects.rlb` по ключу (`model_key`/`resource_name`) ищет запись прототипа.
|
||||||
|
4. Из записи прототипа резолвит фактический `*.msh` и архив, где лежит геометрия.
|
||||||
|
5. Дальше запускается стандартная цепочка:
|
||||||
|
`MSH -> WEAR -> MAT0 -> Texm`.
|
||||||
|
|
||||||
|
## 2. Контейнер
|
||||||
|
|
||||||
|
`objects.rlb` сам является обычным `NRes`-архивом.
|
||||||
|
|
||||||
|
Практические наблюдения на retail-корпусе:
|
||||||
|
|
||||||
|
- формат заголовка/каталога полностью совпадает с `NRes`;
|
||||||
|
- payload каждой записи прототипа кратен `64` байтам;
|
||||||
|
- имя entry в каталоге - это логический ключ объекта (например `r_h_01`, `s_tree_04`).
|
||||||
|
|
||||||
|
## 3. Формат payload записи прототипа
|
||||||
|
|
||||||
|
Payload состоит из массива фиксированных записей:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct ObjectRef64 {
|
||||||
|
char archive_name[32]; // C-строка (CP1251/ASCII)
|
||||||
|
char resource_name[32]; // C-строка (CP1251/ASCII)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Интерпретация:
|
||||||
|
|
||||||
|
- `archive_name`: архив-источник (`bases.rlb`, `static.rlb`, `fortif.rlb`, `effects.rlb`, ...).
|
||||||
|
- `resource_name`: имя ресурса в этом архиве (`*.msh`, `*.wea`, `*.cpt`, `*.ctl`, `*.bas`, ...).
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
|
||||||
|
- после первого `NUL` в 32-байтовом поле могут встречаться служебные байты; для runtime-резолва используется только C-строка до первого `NUL`;
|
||||||
|
- неизвестные хвостовые байты должны сохраняться 1:1 при writer/roundtrip-редактировании.
|
||||||
|
|
||||||
|
## 4. Runtime-резолв геометрии
|
||||||
|
|
||||||
|
Канонический порядок выбора меша:
|
||||||
|
|
||||||
|
1. Найти запись прототипа по ключу в `objects.rlb`.
|
||||||
|
2. Прочитать список `ObjectRef64`.
|
||||||
|
3. Если есть ссылка на `*.msh`:
|
||||||
|
- взять первую валидную ссылку;
|
||||||
|
- открыть указанный архив;
|
||||||
|
- загрузить этот `*.msh`.
|
||||||
|
4. Если `*.msh` нет, но есть `*.bas`:
|
||||||
|
- взять stem от `*.bas` (`fr_m_brige.bas` -> `fr_m_brige`);
|
||||||
|
- искать `<stem>.msh` в том же архиве (`fortif.rlb`).
|
||||||
|
5. Если нет ни `*.msh`, ни `*.bas`, объект трактуется как не-геометрический (пример: солнечный/системный объект) и в 3D-проход не попадает.
|
||||||
|
|
||||||
|
## 5. Типовые примеры
|
||||||
|
|
||||||
|
`r_h_01`:
|
||||||
|
|
||||||
|
- `bases.rlb :: r_h_01.msh`
|
||||||
|
- `bases.rlb :: r_h_01.wea`
|
||||||
|
- `bases.rlb :: r_h_01.cpt`
|
||||||
|
- ...
|
||||||
|
|
||||||
|
`s_tree_04`:
|
||||||
|
|
||||||
|
- `static.rlb :: s_tree_0_04.msh`
|
||||||
|
- `static.rlb :: s_tree_0_04.wea`
|
||||||
|
- ...
|
||||||
|
|
||||||
|
`fr_m_brige`:
|
||||||
|
|
||||||
|
- прямого `*.msh` в записи нет;
|
||||||
|
- есть `fortif.rlb :: fr_m_brige.bas`;
|
||||||
|
- меш резолвится как `fortif.rlb :: fr_m_brige.msh`.
|
||||||
|
|
||||||
|
`sun_01`:
|
||||||
|
|
||||||
|
- ссылки на `*.sun`/effect-ресурсы;
|
||||||
|
- 3D-меш отсутствует.
|
||||||
|
|
||||||
|
## 6. Инварианты для reader/writer
|
||||||
|
|
||||||
|
Reader:
|
||||||
|
|
||||||
|
- payload записи прототипа должен быть кратен `64`;
|
||||||
|
- каждая запись читается как две независимые C-строки фиксированной длины;
|
||||||
|
- поиск в архивах должен быть case-insensitive по ASCII.
|
||||||
|
|
||||||
|
Writer/editor:
|
||||||
|
|
||||||
|
- сохранять порядок `ObjectRef64` без перестановок;
|
||||||
|
- сохранять неизвестные служебные байты полей 1:1;
|
||||||
|
- не нормализовать имена, если это не требуется задачей.
|
||||||
|
|
||||||
|
## 7. Валидация
|
||||||
|
|
||||||
|
Проверено на retail-корпусе `testdata/Parkan - Iron Strategy`:
|
||||||
|
|
||||||
|
- все `590` записей `objects.rlb` имеют payload, кратный `64`;
|
||||||
|
- `554` записей имеют прямую ссылку на `*.msh`;
|
||||||
|
- `34` записи используют ветку через `*.bas`;
|
||||||
|
- `2` записи не содержат геометрии (системные/sun).
|
||||||
|
|
||||||
|
Интеграционные тесты в Rust подтверждают резолв:
|
||||||
|
|
||||||
|
- `r_h_01 -> bases.rlb :: r_h_01.msh`
|
||||||
|
- `s_tree_04 -> static.rlb :: s_tree_0_04.msh`
|
||||||
|
- `fr_m_brige -> fortif.rlb :: fr_m_brige.msh`
|
||||||
|
|
||||||
|
## 8. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Формат payload записи прототипа (`ObjectRef64`) и правила чтения.
|
||||||
|
2. Runtime-алгоритм выбора меша (`*.msh` напрямую и fallback через `*.bas`).
|
||||||
|
3. Корпусная проверка структуры и интеграционные тесты резолва.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная field-level семантика служебных байтов после `NUL` в `resource_name[32]`.
|
||||||
|
2. Формальная семантика всех категорий ссылок (`*.ctl`, `*.cpt`, `*.ndp`, `*.sun`) в терминах систем движка (не только render-пути).
|
||||||
|
3. Writer-спецификация уровня "authoring new prototype from scratch" с гарантией runtime-паритета.
|
||||||
@@ -75,3 +75,16 @@ CI запускает `render-parity` на каждом push/PR:
|
|||||||
|
|
||||||
Важно: оригинальный движок в CI обычно не запускается.
|
Важно: оригинальный движок в CI обычно не запускается.
|
||||||
Эталонные PNG снимаются офлайн и версионируются в репозитории.
|
Эталонные PNG снимаются офлайн и версионируются в репозитории.
|
||||||
|
|
||||||
|
## Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Определена метрика сравнения кадров (`mean_abs`, `max_abs`, `changed_ratio`).
|
||||||
|
2. Описан единый manifest-формат кейсов и CI-процедура.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Снять и зафиксировать расширенный эталонный набор кадров оригинала (10-20+ ключевых моделей и режимов).
|
||||||
|
2. Зафиксировать пороговые критерии pass/fail по каждому классу сцен (статик, анимация, FX, lightmap).
|
||||||
|
3. Добавить автоматическую публикацию diff-артефактов и регрессионных отчетов в CI.
|
||||||
|
|||||||
@@ -151,5 +151,32 @@ void RenderFrame(Scene* scene, Camera* cam, float dt) {
|
|||||||
|
|
||||||
## 10. Статус валидации
|
## 10. Статус валидации
|
||||||
|
|
||||||
- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущим runtime-кодом приложения и импортами движковых DLL.
|
- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущей runtime-валидацией проекта.
|
||||||
- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
|
- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
|
||||||
|
|
||||||
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Высокоуровневый кадр: simulation -> animation -> culling -> material/texture resolve -> mesh draw -> fx -> ui -> present.
|
||||||
|
2. Связка MSH/MAT0/WEAR/Texm/FXID в едином runtime-процессе.
|
||||||
|
3. Форматная валидация входных данных на полном retail-корпусе.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полный pixel-parity контур с эталонными кадрами оригинального рендера по набору моделей/сцен.
|
||||||
|
2. Формализация всех render-state деталей (точные blend/depth/cull/state transitions) для гарантии 1:1 в каждом draw-pass.
|
||||||
|
3. Полный coverage-пакет по динамическим веткам (FX-heavy кадры, сложные material-режимы, lightmap-комбинации).
|
||||||
|
|
||||||
|
## 12. Object registry bridge (`objects.rlb`)
|
||||||
|
|
||||||
|
Для миссионного/юнитного рендера критично учитывать промежуточный слой прототипов:
|
||||||
|
|
||||||
|
1. `TMA`/`*.dat` обычно дают не прямой `*.msh`, а ключ прототипа.
|
||||||
|
2. Ключ резолвится через `objects.rlb` (реестр ссылок на реальные архивы ресурсов).
|
||||||
|
3. Только после этого выполняется стандартный путь:
|
||||||
|
`MSH -> WEAR -> MAT0 -> Texm`.
|
||||||
|
|
||||||
|
Детальная спецификация этого шага вынесена в отдельную страницу:
|
||||||
|
|
||||||
|
- [Object registry (`objects.rlb`)](object-registry.md)
|
||||||
|
|||||||
230
docs/specs/rsli.md
Normal file
230
docs/specs/rsli.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# RsLi
|
||||||
|
|
||||||
|
`RsLi` — библиотечный контейнер ресурсов движка Parkan: Iron Strategy с зашифрованной таблицей записей и несколькими методами упаковки данных.
|
||||||
|
|
||||||
|
Страница описывает формат и runtime-контракт в высокоуровневом виде, без ссылок на внутренние адреса/функции дизассемблера.
|
||||||
|
|
||||||
|
Связанная страница:
|
||||||
|
|
||||||
|
- [NRes](nres.md)
|
||||||
|
|
||||||
|
## 1. Общая структура файла
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Header: 32]
|
||||||
|
[Entry table: entry_count * 32, XOR-encrypted]
|
||||||
|
[Packed payloads]
|
||||||
|
[Optional trailer: "AO" + overlay:u32]
|
||||||
|
```
|
||||||
|
|
||||||
|
В отличие от `NRes`, таблица записей у `RsLi` расположена в начале файла.
|
||||||
|
|
||||||
|
## 2. Заголовок (32 байта)
|
||||||
|
|
||||||
|
Все значения little-endian.
|
||||||
|
|
||||||
|
| Offset | Size | Type | Поле |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 0 | 2 | char[2] | `NL` (магия) |
|
||||||
|
| 2 | 1 | u8 | зарезервировано, в retail = `0` |
|
||||||
|
| 3 | 1 | u8 | версия, в retail = `1` |
|
||||||
|
| 4 | 2 | i16 | `entry_count` (должен быть `>= 0`) |
|
||||||
|
| 14 | 2 | u16 | `presorted_flag` (`0xABBA` = таблица сортировки уже задана) |
|
||||||
|
| 20 | 4 | u32 | `xor_seed` |
|
||||||
|
|
||||||
|
Остальные байты заголовка считаются служебными и должны сохраняться без нормализации.
|
||||||
|
|
||||||
|
## 3. Таблица записей (после дешифровки)
|
||||||
|
|
||||||
|
Таблица начинается с `offset = 32`, размер `entry_count * 32`.
|
||||||
|
|
||||||
|
Каждая запись (32 байта):
|
||||||
|
|
||||||
|
| Offset | Size | Type | Поле |
|
||||||
|
|---:|---:|---|---|
|
||||||
|
| 0 | 12 | char[12] | `name_raw` (обычно uppercase ASCII, NUL optional) |
|
||||||
|
| 12 | 4 | bytes | служебный хвост, сохранять как есть |
|
||||||
|
| 16 | 2 | i16 | `flags` |
|
||||||
|
| 18 | 2 | i16 | `sort_to_original` |
|
||||||
|
| 20 | 4 | u32 | `unpacked_size` |
|
||||||
|
| 24 | 4 | u32 | `data_offset_raw` |
|
||||||
|
| 28 | 4 | u32 | `packed_size` |
|
||||||
|
|
||||||
|
### 3.1. Метод упаковки
|
||||||
|
|
||||||
|
`method = flags & 0x1E0`
|
||||||
|
|
||||||
|
Поддерживаемые значения:
|
||||||
|
|
||||||
|
| Маска | Метод |
|
||||||
|
|---:|---|
|
||||||
|
| `0x000` | без сжатия |
|
||||||
|
| `0x020` | XOR only |
|
||||||
|
| `0x040` | LZSS |
|
||||||
|
| `0x060` | XOR + LZSS |
|
||||||
|
| `0x080` | LZSS + адаптивный Huffman |
|
||||||
|
| `0x0A0` | XOR + LZSS + адаптивный Huffman |
|
||||||
|
| `0x100` | raw Deflate (RFC1951) |
|
||||||
|
|
||||||
|
Другие значения считаются неподдерживаемыми.
|
||||||
|
|
||||||
|
## 4. XOR-дешифрование таблицы и данных
|
||||||
|
|
||||||
|
Для таблицы и XOR-методов payload используется один и тот же потоковый XOR-алгоритм.
|
||||||
|
|
||||||
|
Ключ:
|
||||||
|
|
||||||
|
- `key16 = xor_seed & 0xFFFF` (используются только младшие 16 бит seed).
|
||||||
|
|
||||||
|
Состояние:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lo = key16 & 0xFF
|
||||||
|
hi = key16 >> 8
|
||||||
|
```
|
||||||
|
|
||||||
|
Для каждого байта:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lo = hi XOR ((lo << 1) mod 256)
|
||||||
|
out = in XOR lo
|
||||||
|
hi = lo XOR (hi >> 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. `sort_to_original` и поиск по имени
|
||||||
|
|
||||||
|
### 5.1. Режим `presorted_flag == 0xABBA`
|
||||||
|
|
||||||
|
`sort_to_original` обязан быть перестановкой `0..entry_count-1` без дубликатов.
|
||||||
|
|
||||||
|
### 5.2. Режим без presorted-флага
|
||||||
|
|
||||||
|
Слой загрузки строит `sort_to_original` самостоятельно:
|
||||||
|
|
||||||
|
- сортирует индексы по `strcmp`-порядку имен (байтовое сравнение);
|
||||||
|
- записывает эту перестановку в lookup-таблицу.
|
||||||
|
|
||||||
|
### 5.3. Поиск
|
||||||
|
|
||||||
|
Поиск выполняется бинарным поиском по lookup-таблице:
|
||||||
|
|
||||||
|
1. запрос переводится в uppercase ASCII;
|
||||||
|
2. на шаге бинарного поиска используется индекс `sort_to_original[mid]`;
|
||||||
|
3. сравнение имен — bytewise (`strcmp`-логика).
|
||||||
|
|
||||||
|
Fail-safe:
|
||||||
|
|
||||||
|
- при невалидном индексе lookup-таблицы выполняется линейный fallback.
|
||||||
|
|
||||||
|
## 6. AO-трейлер и media overlay
|
||||||
|
|
||||||
|
Опциональный трейлер в конце файла:
|
||||||
|
|
||||||
|
```text
|
||||||
|
"AO" + overlay:u32
|
||||||
|
```
|
||||||
|
|
||||||
|
Если трейлер присутствует:
|
||||||
|
|
||||||
|
- эффективный offset payload: `effective_offset = data_offset_raw + overlay`.
|
||||||
|
|
||||||
|
Ограничение:
|
||||||
|
|
||||||
|
- `overlay <= file_size`.
|
||||||
|
|
||||||
|
## 7. Декодирование payload по методам
|
||||||
|
|
||||||
|
## 7.1. Без сжатия (`0x000`)
|
||||||
|
|
||||||
|
Берутся первые `unpacked_size` байт из packed-диапазона.
|
||||||
|
|
||||||
|
## 7.2. XOR only (`0x020`)
|
||||||
|
|
||||||
|
XOR-дешифрование первых `unpacked_size` байт.
|
||||||
|
|
||||||
|
## 7.3. LZSS (`0x040`, `0x060`)
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
|
||||||
|
- ring buffer: `4096` байт;
|
||||||
|
- начальное заполнение ring: `0x20`;
|
||||||
|
- стартовый указатель ring: `0xFEE`;
|
||||||
|
- control-биты читаются LSB-first.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- `bit=1`: literal byte;
|
||||||
|
- `bit=0`: ссылка из 2 байт
|
||||||
|
`offset = low | ((high & 0xF0) << 4)`
|
||||||
|
`length = (high & 0x0F) + 3`.
|
||||||
|
|
||||||
|
Для `0x060` XOR применяется на лету к packed-потоку до LZSS-декодирования.
|
||||||
|
|
||||||
|
## 7.4. LZSS + адаптивный Huffman (`0x080`, `0x0A0`)
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
|
||||||
|
- `N=4096`, `F=60`, `THRESHOLD=2`;
|
||||||
|
- адаптивное дерево Huffman обновляется по мере декодирования.
|
||||||
|
|
||||||
|
Для `0x0A0` XOR применяется на лету к битовому потоку до Huffman/LZSS-декодирования.
|
||||||
|
|
||||||
|
## 7.5. Deflate (`0x100`)
|
||||||
|
|
||||||
|
Используется raw Deflate-поток (RFC1951).
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
|
||||||
|
- zlib-обертка (`RFC1950`) не принимается.
|
||||||
|
|
||||||
|
## 8. Quirk: Deflate EOF+1
|
||||||
|
|
||||||
|
На retail-корпусе встречается один подтвержденный случай, где:
|
||||||
|
|
||||||
|
- `effective_offset + packed_size == file_size + 1`.
|
||||||
|
|
||||||
|
Совместимое поведение:
|
||||||
|
|
||||||
|
- для метода `0x100` допустить чтение `packed_size - 1` байт (если включен режим совместимости);
|
||||||
|
- в строгом режиме считать это ошибкой.
|
||||||
|
|
||||||
|
## 9. Контрольные инварианты
|
||||||
|
|
||||||
|
Минимальные проверки:
|
||||||
|
|
||||||
|
1. `magic == "NL"`, `reserved == 0`, `version == 1`.
|
||||||
|
2. `entry_count >= 0`.
|
||||||
|
3. `table_end <= file_size`.
|
||||||
|
4. Если `presorted_flag == 0xABBA`, `sort_to_original` — валидная перестановка.
|
||||||
|
5. `effective_offset + packed_size` не выходит за EOF (кроме разрешенного deflate EOF+1 quirk).
|
||||||
|
6. Итоговый распакованный размер равен `unpacked_size`.
|
||||||
|
|
||||||
|
## 10. Эмпирическая проверка на retail-корпусе
|
||||||
|
|
||||||
|
Проверка на полном наборе `testdata/Parkan - Iron Strategy`:
|
||||||
|
|
||||||
|
- обнаружено `2` архива `RsLi`;
|
||||||
|
- roundtrip `unpack -> repack -> byte-compare`: `2/2` совпали побайтно;
|
||||||
|
- подтвержден ровно один `deflate EOF+1` случай (`sprites.lib`, entry `23`).
|
||||||
|
|
||||||
|
Инструменты:
|
||||||
|
|
||||||
|
- `tools/archive_roundtrip_validator.py`
|
||||||
|
- `crates/rsli` tests
|
||||||
|
|
||||||
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- формат заголовка/таблицы;
|
||||||
|
- XOR-алгоритм;
|
||||||
|
- все используемые методы декодирования;
|
||||||
|
- AO overlay;
|
||||||
|
- lookup-поиск и fallback;
|
||||||
|
- retail-валидация и побайтовый roundtrip.
|
||||||
|
|
||||||
|
Осталось до полного 100% архитектурного покрытия движка:
|
||||||
|
|
||||||
|
1. Полная функциональная семантика битов `flags` вне маски метода (`0x1E0`) для геймплейных подсистем.
|
||||||
|
2. Канонический writer для авторинга новых архивов со стабильной стратегией выбора методов (`0x080/0x0A0/0x100`) и параметров компрессии.
|
||||||
|
3. Формализация поведения для не-ASCII имен (на практике архивы используют ASCII-диапазон).
|
||||||
@@ -6,3 +6,13 @@
|
|||||||
|
|
||||||
Эта страница оставлена как совместимый указатель для старых ссылок.
|
Эта страница оставлена как совместимый указатель для старых ссылок.
|
||||||
|
|
||||||
|
## Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Актуальный runtime-пайплайн централизован в `render.md`.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Поддерживать обратную совместимость ссылок при дальнейшей декомпозиции render-документа.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
# Sound system
|
# Sound system
|
||||||
|
|
||||||
Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API.
|
`Sound` — подсистема аудио:
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга звуковых модулей движка.
|
- загрузка и кеширование звуковых ресурсов;
|
||||||
|
- воспроизведение SFX/voice/music;
|
||||||
|
- пространственное позиционирование и микширование.
|
||||||
|
|
||||||
|
## 1. Архитектурная роль
|
||||||
|
|
||||||
|
1. Получает события от gameplay/FX/mission/UI.
|
||||||
|
2. Резолвит аудиоресурсы через архивные библиотеки.
|
||||||
|
3. Управляет каналами, приоритетами и жизненным циклом источников звука.
|
||||||
|
|
||||||
|
## 2. Минимальный runtime-контракт
|
||||||
|
|
||||||
|
1. Стабильный выбор источника и fallback при отсутствии ресурса.
|
||||||
|
2. Детерминированные правила приоритета при переполнении каналов.
|
||||||
|
3. Согласованная модель пространственного затухания и панорамирования.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- место аудио-подсистемы в общем runtime-контуре.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация форматов аудио-ресурсов и lookup-таблиц.
|
||||||
|
2. Полный контракт 2D/3D микширования и лимитов каналов.
|
||||||
|
3. Правила взаимодействия с FXID-командами, которые инициируют звук.
|
||||||
|
4. Набор audio parity-тестов (тайминг/громкость/панорама).
|
||||||
|
|||||||
@@ -1,170 +1,111 @@
|
|||||||
# Terrain + map loading
|
# Terrain + ArealMap
|
||||||
|
|
||||||
Документ описывает полный runtime-пайплайн загрузки ландшафта и карты (`Terrain.dll` + `ArealMap.dll`) и требования к toolchain для 1:1 совместимости (чтение, конвертация, редактирование, обратная сборка).
|
Документ описывает подсистему ландшафта и ареалов мира в движке Parkan: Iron Strategy:
|
||||||
|
|
||||||
Источник реверса:
|
- `Land.msh` (terrain-геометрия и вспомогательные таблицы);
|
||||||
|
- `Land.map` (ареалы и навигационные связи);
|
||||||
|
- `BuildDat.lst` (категории объектных зон).
|
||||||
|
|
||||||
- `tmp/disassembler1/Terrain.dll.c`
|
Описание дано в высокоуровневом переносимом виде, без ссылок на внутренние адреса и имена из дизассемблера.
|
||||||
- `tmp/disassembler1/ArealMap.dll.c`
|
|
||||||
- `tmp/disassembler2/Terrain.dll.asm`
|
|
||||||
- `tmp/disassembler2/ArealMap.dll.asm`
|
|
||||||
|
|
||||||
Связанные спецификации:
|
Связанные страницы:
|
||||||
|
|
||||||
- [NRes / RsLi](nres.md)
|
- [NRes](nres.md)
|
||||||
|
- [RsLi](rsli.md)
|
||||||
- [MSH core](msh-core.md)
|
- [MSH core](msh-core.md)
|
||||||
- [ArealMap](arealmap.md)
|
- [Render pipeline](render.md)
|
||||||
|
|
||||||
---
|
## 1. End-to-End загрузка уровня
|
||||||
|
|
||||||
## 1. Назначение подсистем
|
Для каждой карты движок загружает пару файлов:
|
||||||
|
|
||||||
### 1.1. `Terrain.dll`
|
- `.../Land.msh`
|
||||||
|
- `.../Land.map`
|
||||||
|
|
||||||
Отвечает за:
|
Высокоуровневый порядок:
|
||||||
|
|
||||||
- загрузку и хранение terrain-геометрии из `*.msh` (NRes);
|
1. Открыть `Land.msh` как `NRes`.
|
||||||
- фильтрацию и выборку треугольников для коллизий/трассировки/рендера;
|
2. Прочитать обязательные terrain-chunk'и.
|
||||||
- рендер terrain-примитивов и связанного shading;
|
3. Построить runtime-структуры terrain (slots, faces, spatial grid).
|
||||||
- использование микро-текстурного канала (chunk type 18).
|
4. Открыть `Land.map` как `NRes`.
|
||||||
|
5. Найти единственный chunk `type=12`.
|
||||||
|
6. Прочитать ареалы, их связи и cell-grid.
|
||||||
|
7. Применить инициализацию объектных категорий из `BuildDat.lst`.
|
||||||
|
|
||||||
Характерные runtime-строки:
|
## 2. Формат `Land.msh`
|
||||||
|
|
||||||
- `CLandscape::CLandscape()`
|
`Land.msh` — обычный `NRes` архив с фиксированным набором terrain-ресурсов.
|
||||||
- `Unable to find microtexture mapping chunk`
|
|
||||||
- `Rendering empty primitive!`
|
|
||||||
- `Rendering empty primitive2!`
|
|
||||||
|
|
||||||
### 1.2. `ArealMap.dll`
|
## 2.1. Состав chunk'ов
|
||||||
|
|
||||||
Отвечает за:
|
Обязательные типы:
|
||||||
|
|
||||||
- загрузку геометрии ареалов из `*.map` (NRes, chunk type 12);
|
- `1`, `2`, `3`, `4`, `5`, `11`, `18`, `21`
|
||||||
- построение связей "ареал <-> соседи/подграфы";
|
|
||||||
- grid-ускорение по ячейкам карты;
|
|
||||||
- runtime-доступ к `ISystemArealMap` (интерфейс id `770`) и ареалам (id `771`).
|
|
||||||
|
|
||||||
Характерные runtime-строки:
|
Опциональные типы:
|
||||||
|
|
||||||
- `SystemArealMap panic: Cannot load ArealMapGeometry`
|
- `14`
|
||||||
- `SystemArealMap panic: Cannot find chunk in resource`
|
|
||||||
- `SystemArealMap panic: ArealMap Cells are empty`
|
|
||||||
- `SystemArealMap panic: Incorrect ArealMap`
|
|
||||||
|
|
||||||
---
|
Наблюдаемый retail-порядок chunk'ов:
|
||||||
|
|
||||||
## 2. End-to-End загрузка уровня
|
```text
|
||||||
|
[1, 2, 3, 4, 5, 18, 14, 11, 21]
|
||||||
### 2.1. Имена файлов уровня
|
|
||||||
|
|
||||||
В `CLandscape::CLandscape()` базовое имя уровня `levelBase` разворачивается в:
|
|
||||||
|
|
||||||
- `levelBase + ".msh"`: terrain-геометрия;
|
|
||||||
- `levelBase + ".map"`: геометрия ареалов/навигация;
|
|
||||||
- `levelBase + "1.wea"` и `levelBase + "2.wea"`: weather/материалы.
|
|
||||||
|
|
||||||
### 2.2. Порядок инициализации (высокоуровнево)
|
|
||||||
|
|
||||||
1. Получение `3DRender` и `3DSound`.
|
|
||||||
2. Загрузка `MatManager` (`*.wea`), `LightManager`, `CollManager`, `FxManager`.
|
|
||||||
3. Создание `SystemArealMap` через `CreateSystemArealMap(..., "<level>.map", ...)`.
|
|
||||||
4. Открытие terrain-библиотеки `niOpenResFile("<level>.msh")`.
|
|
||||||
5. Загрузка terrain-chunk-ов (см. §3).
|
|
||||||
6. Построение runtime-границ, grid-ускорителей и рабочих массивов.
|
|
||||||
|
|
||||||
Критичные ошибки на любом шаге приводят к `ngiProcessError`/panic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Формат terrain `*.msh` (NRes)
|
|
||||||
|
|
||||||
### 3.1. Используемые chunk type в `Terrain.dll`
|
|
||||||
|
|
||||||
Порядок загрузки в `CLandscape::CLandscape()`:
|
|
||||||
|
|
||||||
| Порядок | Type | Обяз. | Использование (подтверждено кодом) |
|
|
||||||
|---|---:|---|---|
|
|
||||||
| 1 | 3 | да | поток позиций (`stride = 12`) |
|
|
||||||
| 2 | 4 | да | поток packed normal (`stride = 4`) |
|
|
||||||
| 3 | 5 | да | UV-поток (`stride = 4`) |
|
|
||||||
| 4 | 18 | да | microtexture mapping (`stride = 4`) |
|
|
||||||
| 5 | 14 | нет | опциональный доп. поток (`stride = 4`, отсутствует на части карт) |
|
|
||||||
| 6 | 21 | да | таблица terrain-face (по 28 байт) |
|
|
||||||
| 7 | 2 | да | header + slot-таблицы (используются диапазоны face) |
|
|
||||||
| 8 | 1 | да | node/grid-таблица (stride 38) |
|
|
||||||
| 9 | 11 | да | доп. индекс/ускоритель для запросов (cell->list) |
|
|
||||||
|
|
||||||
Ключевые проверки:
|
|
||||||
|
|
||||||
- отсутствие type `18` вызывает `Unable to find microtexture mapping chunk`;
|
|
||||||
- отсутствие остальных обязательных чанков вызывает `Unable to open file`.
|
|
||||||
|
|
||||||
### 3.2. Node/slot структура для terrain
|
|
||||||
|
|
||||||
Terrain-код использует те же stride и адресацию, что и core-описание:
|
|
||||||
|
|
||||||
- node-запись: `38` байт;
|
|
||||||
- slot-запись: `68` байт;
|
|
||||||
- доступ к первому slot-index: `node + 8`;
|
|
||||||
- tri-диапазон в slot: `slot + 140` (offset 0 внутри slot), `slot + 142` (offset 2).
|
|
||||||
|
|
||||||
Это согласуется с [MSH core](msh-core.md) для `Res1/Res2`:
|
|
||||||
|
|
||||||
- `Res1`: `uint16[19]` на node;
|
|
||||||
- `Res2`: header + slot table (`0x8C + N * 0x44`).
|
|
||||||
|
|
||||||
### 3.3. Terrain face record (type 21, 28 bytes)
|
|
||||||
|
|
||||||
Подтвержденные поля из runtime-декодирования face:
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct TerrainFace28 {
|
|
||||||
uint32_t flags; // +0
|
|
||||||
uint8_t materialId; // +4 (читается как byte)
|
|
||||||
uint8_t auxByte; // +5
|
|
||||||
uint16_t unk06; // +6
|
|
||||||
uint16_t i0; // +8 (индекс вершины)
|
|
||||||
uint16_t i1; // +10
|
|
||||||
uint16_t i2; // +12
|
|
||||||
uint16_t n0; // +14 (сосед, 0xFFFF -> нет)
|
|
||||||
uint16_t n1; // +16
|
|
||||||
uint16_t n2; // +18
|
|
||||||
int16_t nx; // +20 packed normal component
|
|
||||||
int16_t ny; // +22
|
|
||||||
int16_t nz; // +24
|
|
||||||
uint8_t edgeClass; // +26 (три 2-бит значения)
|
|
||||||
uint8_t unk27; // +27
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`edgeClass` декодируется как:
|
## 2.2. Stride и атрибуты
|
||||||
|
|
||||||
- `edge0 = byte26 & 0x3`
|
| Type | Назначение | Stride |
|
||||||
- `edge1 = (byte26 >> 2) & 0x3`
|
|---:|---|---:|
|
||||||
- `edge2 = (byte26 >> 4) & 0x3`
|
| 1 | node/slot матрица | 38 |
|
||||||
|
| 3 | позиции вершин | 12 |
|
||||||
|
| 4 | нормали (packed) | 4 |
|
||||||
|
| 5 | UV (packed) | 4 |
|
||||||
|
| 11 | cell-ускоритель | 4 |
|
||||||
|
| 14 | доп. поток | 4 |
|
||||||
|
| 18 | доп. поток | 4 |
|
||||||
|
| 21 | terrain face | 28 |
|
||||||
|
|
||||||
### 3.4. Маски флагов face
|
Общее правило для этих chunk'ов:
|
||||||
|
|
||||||
Во многих запросах применяется фильтр:
|
- `attr1 == size / stride`
|
||||||
|
- `attr3 == stride`
|
||||||
|
|
||||||
```c
|
## 2.3. Type `2`: slot table
|
||||||
(faceFlags & requiredMask) == requiredMask &&
|
|
||||||
(faceFlags | ~forbiddenMask) == ~forbiddenMask
|
|
||||||
```
|
|
||||||
|
|
||||||
Эквивалентно: "все required-биты выставлены, forbidden-биты отсутствуют".
|
`type=2` содержит:
|
||||||
|
|
||||||
Подтверждено активное использование битов:
|
- заголовок `0x8C` байт;
|
||||||
|
- затем таблицу slots по `68` байт.
|
||||||
|
|
||||||
- `0x8` (особая обработка в трассировке)
|
Инварианты:
|
||||||
- `0x2000`
|
|
||||||
- `0x20000`
|
|
||||||
- `0x100000`
|
|
||||||
- `0x200000`
|
|
||||||
|
|
||||||
Кроме "полной" 32-бит маски, runtime использует компактные маски в API-запросах.
|
- `size >= 0x8C`
|
||||||
|
- `(size - 0x8C) % 68 == 0`
|
||||||
|
- `attr1 == (size - 0x8C) / 68`
|
||||||
|
- `attr3 == 68`
|
||||||
|
|
||||||
Подтверждённый remap `full -> compactMain16` (функции `sub_10013FC0`, `sub_1004BA00`, `sub_1004BB40`):
|
## 2.4. Type `21`: terrain face (28 байт)
|
||||||
|
|
||||||
|
Высокоуровневая структура face:
|
||||||
|
|
||||||
|
- флаги face;
|
||||||
|
- индексы треугольника (`i0, i1, i2`);
|
||||||
|
- индексы соседей (`n0, n1, n2`, значение `0xFFFF` = нет соседа);
|
||||||
|
- служебные поля (материал/класс/edge-поля и др.).
|
||||||
|
|
||||||
|
Критичные проверки:
|
||||||
|
|
||||||
|
- `i0/i1/i2 < vertex_count` (`type=3`);
|
||||||
|
- `nX == 0xFFFF` или `nX < face_count`.
|
||||||
|
|
||||||
|
## 2.5. Маски face и compact-представления
|
||||||
|
|
||||||
|
В рантайме используются:
|
||||||
|
|
||||||
|
- полная 32-битная маска (`full`);
|
||||||
|
- компактные представления (`compactMain16`, `compactMaterial6`).
|
||||||
|
|
||||||
|
Подтвержденный remap `full -> compactMain16`:
|
||||||
|
|
||||||
| Full bit | Compact bit |
|
| Full bit | Compact bit |
|
||||||
|---:|---:|
|
|---:|---:|
|
||||||
@@ -184,7 +125,7 @@ struct TerrainFace28 {
|
|||||||
| `0x00000040` | `0x2000` |
|
| `0x00000040` | `0x2000` |
|
||||||
| `0x00200000` | `0x8000` |
|
| `0x00200000` | `0x8000` |
|
||||||
|
|
||||||
Подтверждённый remap `full -> compactMaterial6` (функции `sub_10014090`, `sub_10015540`, `sub_1004BB40`):
|
Подтвержденный remap `full -> compactMaterial6`:
|
||||||
|
|
||||||
| Full bit | Compact bit |
|
| Full bit | Compact bit |
|
||||||
|---:|---:|
|
|---:|---:|
|
||||||
@@ -195,180 +136,99 @@ struct TerrainFace28 {
|
|||||||
| `0x00080000` | `0x10` |
|
| `0x00080000` | `0x10` |
|
||||||
| `0x00000080` | `0x20` |
|
| `0x00000080` | `0x20` |
|
||||||
|
|
||||||
Подтверждённый remap `compact -> full` (функция `sub_10015680`):
|
Для 1:1 реализации нужно поддерживать оба представления и обратное восстановление `compact -> full`.
|
||||||
|
|
||||||
- `a2[4]`/`a2[5]` (compactMain16 required/forbidden) + `a2[6]`/`a2[7]` (compactMaterial6 required/forbidden)
|
## 2.6. Type `11` и cell-ускоритель terrain
|
||||||
- разворачиваются в `fullRequired/fullForbidden` в `this[4]/this[5]`.
|
|
||||||
|
|
||||||
Для toolchain это означает:
|
`type=11` служит источником cell-ускорителя для terrain-запросов.
|
||||||
|
|
||||||
- если редактируется только бинарник `type 21`, достаточно сохранять `flags` как есть;
|
Практические требования для editor/toolchain:
|
||||||
- если реализуется API-совместимый runtime-слой, нужно поддерживать оба представления (`full` и `compact`) и точный remap выше.
|
|
||||||
|
|
||||||
### 3.5. Grid-ускоритель terrain-запросов
|
- не переупорядочивать содержимое без полного пересчета зависимых таблиц;
|
||||||
|
- сохранять служебные/неизвестные поля побайтно;
|
||||||
|
- выполнять валидацию диапазонов face/slot после любых правок.
|
||||||
|
|
||||||
Runtime строит grid descriptor с параметрами:
|
## 3. Формат `Land.map` (chunk `type=12`)
|
||||||
|
|
||||||
- origin (`baseX/baseY`);
|
`Land.map` — `NRes`, содержащий ровно один ресурс `type=12`.
|
||||||
- масштабные коэффициенты (`invSizeX/invSizeY`);
|
|
||||||
- размеры сетки (`cellsX`, `cellsY`).
|
|
||||||
|
|
||||||
Дальше запросы:
|
Контракт верхнего уровня:
|
||||||
|
|
||||||
1. переводят world AABB в диапазон grid-ячеек (`floor(...)`);
|
- `entry.attr1` = `areal_count`;
|
||||||
2. берут диапазон face через `Res1/Res2` (slot `triStart/triCount`);
|
- payload включает:
|
||||||
3. дополняют кандидаты из cell-списков (chunk type 11);
|
- `areal_count` переменных записей ареалов;
|
||||||
4. применяют маски флагов;
|
- затем grid-секцию cell-попаданий.
|
||||||
5. выполняют геометрию (plane/intersection/point-in-triangle).
|
|
||||||
|
|
||||||
### 3.6. Cell-списки по ячейкам (`type 11` и runtime-массивы)
|
## 3.1. Запись ареала
|
||||||
|
|
||||||
В `CLandscape` после инициализации используются три параллельных массива по ячейкам (`cellsX * cellsY`):
|
Старт записи:
|
||||||
|
|
||||||
- `this+31588` (`sub_100164B0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `8`-байтовых элементов;
|
|
||||||
- `this+31592` (`sub_100164E0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `4`-байтовых элементов;
|
|
||||||
- `this+31596` (`sub_1001F880` ctor): массив записей по `12` байт для runtime-объектов/агентов (буфер `4`-байтовых идентификаторов/указателей).
|
|
||||||
|
|
||||||
Общий header записи списка:
|
|
||||||
|
|
||||||
```c
|
```c
|
||||||
struct CellListHdr {
|
float anchor_x; // +0
|
||||||
void* ptr; // +0
|
float anchor_y; // +4
|
||||||
int count; // +4
|
float anchor_z; // +8
|
||||||
int capacity; // +8
|
float reserved_12; // +12
|
||||||
};
|
float area_metric; // +16
|
||||||
|
float normal_x; // +20
|
||||||
|
float normal_y; // +24
|
||||||
|
float normal_z; // +28
|
||||||
|
uint32_t logic_flag; // +32
|
||||||
|
uint32_t reserved_36; // +36
|
||||||
|
uint32_t class_id; // +40
|
||||||
|
uint32_t reserved_44; // +44
|
||||||
|
uint32_t vertex_count; // +48
|
||||||
|
uint32_t poly_count; // +52
|
||||||
```
|
```
|
||||||
|
|
||||||
Подтвержденные element-layout:
|
Далее:
|
||||||
|
|
||||||
- `this+31588`: элемент `8` байт (`uint32_t id`, `uint32_t aux`), добавление через `sub_10012E20` пишет `aux = 0`;
|
1. `float3 vertices[vertex_count]`
|
||||||
- `this+31592`: элемент `4` байта (`uint32_t id`);
|
2. `EdgeLink8 links[vertex_count + 3 * poly_count]`, где
|
||||||
- `this+31596`: элемент `4` байта (runtime object handle/pointer id).
|
`EdgeLink8 = { int32 area_ref; int32 edge_ref; }`
|
||||||
|
3. для каждого полигона block:
|
||||||
|
- `uint32 n`
|
||||||
|
- `4 * (3*n + 1)` байт данных полигона
|
||||||
|
|
||||||
Практический вывод для редактора:
|
## 3.2. Семантика edge-link
|
||||||
|
|
||||||
- `type 11` должен считаться источником cell-ускорителя;
|
Для `links[0 .. vertex_count-1]`:
|
||||||
- неизвестные/дополнительные поля внутри списков должны сохраняться как есть;
|
|
||||||
- нельзя "нормализовать" или переупорядочивать списки без полного пересчёта всех зависимых runtime-структур.
|
|
||||||
|
|
||||||
---
|
- `(-1, -1)` означает «соседа нет»;
|
||||||
|
- иначе `area_ref` указывает на индекс соседнего ареала, `edge_ref` — на ребро в соседнем ареале.
|
||||||
|
|
||||||
## 4. Формат `*.map` (ArealMapGeometry, chunk type 12)
|
## 3.3. Grid-секция после ареалов
|
||||||
|
|
||||||
### 4.1. Точка входа
|
Формат:
|
||||||
|
|
||||||
`CreateSystemArealMap(..., "<level>.map", ...)` вызывает `sub_1001E0D0`:
|
|
||||||
|
|
||||||
1. `niOpenResFile("<level>.map")`;
|
|
||||||
2. поиск chunk type `12`;
|
|
||||||
3. чтение chunk-данных;
|
|
||||||
4. разбор `ArealMapGeometry`.
|
|
||||||
|
|
||||||
При ошибках выдаются panic-строки `SystemArealMap panic: ...`.
|
|
||||||
|
|
||||||
### 4.2. Верхний уровень chunk 12
|
|
||||||
|
|
||||||
Используются:
|
|
||||||
|
|
||||||
- `entry.attr1` (из каталога NRes) как `areal_count`;
|
|
||||||
- `entry[+0x0C]` как размер payload chunk для контроля полного разбора.
|
|
||||||
|
|
||||||
Данные chunk:
|
|
||||||
|
|
||||||
1. `areal_count` переменных записей ареалов;
|
|
||||||
2. секция grid-ячеек (`cellsX/cellsY` + списки попаданий).
|
|
||||||
|
|
||||||
### 4.3. Переменная запись ареала
|
|
||||||
|
|
||||||
Полностью подтверждённые элементы layout:
|
|
||||||
|
|
||||||
```c
|
```c
|
||||||
// record = начало записи ареала
|
uint32 cellsX;
|
||||||
float anchor_x = *(float*)(record + 0);
|
uint32 cellsY;
|
||||||
float anchor_y = *(float*)(record + 4);
|
for (x=0; x<cellsX; x++) {
|
||||||
float anchor_z = *(float*)(record + 8);
|
for (y=0; y<cellsY; y++) {
|
||||||
float reserved_12 = *(float*)(record + 12); // в retail-данных всегда 0
|
uint16 hitCount;
|
||||||
float area_metric = *(float*)(record + 16); // предрасчитанная площадь ареала
|
uint16 areaIds[hitCount];
|
||||||
float normal_x = *(float*)(record + 20);
|
|
||||||
float normal_y = *(float*)(record + 24);
|
|
||||||
float normal_z = *(float*)(record + 28); // unit vector (|n| ~= 1)
|
|
||||||
uint32_t logic_flag = *(uint32_t*)(record + 32); // активно используется в runtime
|
|
||||||
uint32_t reserved_36 = *(uint32_t*)(record + 36); // в retail-данных всегда 0
|
|
||||||
uint32_t class_id = *(uint32_t*)(record + 40); // runtime-class/type id ареала
|
|
||||||
uint32_t reserved_44 = *(uint32_t*)(record + 44); // в retail-данных всегда 0
|
|
||||||
uint32_t vertex_count = *(uint32_t*)(record + 48);
|
|
||||||
uint32_t poly_count = *(uint32_t*)(record + 52);
|
|
||||||
float* vertices = (float*)(record + 56); // float3[vertex_count]
|
|
||||||
|
|
||||||
// сразу после vertices:
|
|
||||||
// EdgeLink8[vertex_count + 3*poly_count]
|
|
||||||
// где EdgeLink8 = { int32_t area_ref; int32_t edge_ref; }
|
|
||||||
// первые vertex_count записей используются как per-edge соседство границы ареала.
|
|
||||||
EdgeLink8* links = (EdgeLink8*)(record + 56 + 12 * vertex_count);
|
|
||||||
|
|
||||||
uint8_t* p = (uint8_t*)(links + (vertex_count + 3 * poly_count));
|
|
||||||
for (i=0; i<poly_count; i++) {
|
|
||||||
uint32_t n = *(uint32_t*)p;
|
|
||||||
p += 4 * (3*n + 1);
|
|
||||||
}
|
|
||||||
// p -> начало следующей записи ареала
|
|
||||||
```
|
|
||||||
|
|
||||||
То есть для toolchain:
|
|
||||||
|
|
||||||
- поля `+0/+4/+8`, `+16`, `+20..+28`, `+32`, `+40`, `+48`, `+52` являются runtime-значимыми;
|
|
||||||
- для `links[0..vertex_count-1]` подтверждена интерпретация как `(area_ref, edge_ref)`:
|
|
||||||
- `area_ref == -1 && edge_ref == -1` = нет соседа;
|
|
||||||
- иначе `area_ref` указывает на индекс ареала, `edge_ref` — на индекс ребра в целевом ареале;
|
|
||||||
- при редактировании безопасно работать через parser+writer этой формулы;
|
|
||||||
- неизвестные байты внутри записи должны сохраняться без изменений.
|
|
||||||
|
|
||||||
Дополнительно по runtime-поведению:
|
|
||||||
|
|
||||||
- `anchor_x/anchor_y` валидируются на попадание внутрь полигона; при промахе движок делает случайный re-seed позиции (см. §4.5);
|
|
||||||
- `logic_flag` по смещению `+32` используется как gating-условие в логике `SystemArealMap`.
|
|
||||||
|
|
||||||
### 4.4. Секция grid-ячеек в chunk 12
|
|
||||||
|
|
||||||
После массива ареалов идёт:
|
|
||||||
|
|
||||||
```c
|
|
||||||
uint32_t cellsX;
|
|
||||||
uint32_t cellsY;
|
|
||||||
for (x in 0..cellsX-1) {
|
|
||||||
for (y in 0..cellsY-1) {
|
|
||||||
uint16_t hitCount;
|
|
||||||
uint16_t areaIds[hitCount];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime упаковывает метаданные ячейки в `uint32`:
|
В runtime существует упакованное cell-meta представление:
|
||||||
|
|
||||||
- high 10 bits: `hitCount` (`value >> 22`);
|
- high 10 бит: `hitCount`;
|
||||||
- low 22 bits: `startIndex` (1-based индекс в общем `uint16`-пуле areaIds).
|
- low 22 бита: `startIndex` (в общем `areaIds` пуле).
|
||||||
|
|
||||||
Контроль целостности:
|
## 3.4. Валидация целостности chunk 12
|
||||||
|
|
||||||
- после разбора `ptr_end - chunk_begin` должен строго совпасть с `entry[+0x0C]`;
|
Обязательные проверки:
|
||||||
- иначе `SystemArealMap panic: Incorrect ArealMap`.
|
|
||||||
|
|
||||||
### 4.5. Нормализация геометрии при загрузке
|
- `areal_count > 0`;
|
||||||
|
- `cellsX > 0 && cellsY > 0`;
|
||||||
|
- каждый `area_id` из cell-списков `< areal_count`;
|
||||||
|
- все `area_ref/edge_ref` валидны относительно целевых ареалов;
|
||||||
|
- полный объем прочитанных байт должен точно совпасть с размером payload.
|
||||||
|
|
||||||
Если опорная точка ареала не попадает внутрь его полигона:
|
## 4. `BuildDat.lst`
|
||||||
|
|
||||||
- до 100 попыток случайного сдвига в радиусе ~30;
|
Используются 12 объектных категорий ареалов:
|
||||||
- затем до 200 попыток в радиусе ~100.
|
|
||||||
|
|
||||||
Это runtime-correction; для 1:1-офлайн инструментов лучше генерировать валидные данные, чтобы не зависеть от недетерминизма `rand()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. `BuildDat.lst` и объектные категории ареалов
|
|
||||||
|
|
||||||
`ArealMap.dll` инициализирует 12 категорий и читает `BuildDat.lst`.
|
|
||||||
|
|
||||||
Хардкод-категории (имя -> mask):
|
|
||||||
|
|
||||||
| Имя | Маска |
|
| Имя | Маска |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -385,127 +245,49 @@ Runtime упаковывает метаданные ячейки в `uint32`:
|
|||||||
| `Tower_Medium` | `0x80100000` |
|
| `Tower_Medium` | `0x80100000` |
|
||||||
| `Tower_Large` | `0x80200000` |
|
| `Tower_Large` | `0x80200000` |
|
||||||
|
|
||||||
Файл `BuildDat.lst` парсится секционно; при сбое формата используется panic `BuildDat.lst is corrupted`.
|
Файл должен парситься строго секционно; поврежденный формат считается ошибкой.
|
||||||
|
|
||||||
---
|
## 5. Требования к reader/writer/editor
|
||||||
|
|
||||||
## 6. Требования к toolchain (конвертер/ридер/редактор)
|
1. Сохранять порядок и бинарную форму chunk'ов, если не выполняется осознанная нормализация.
|
||||||
|
2. Все неизвестные поля хранить и писать побайтно (`preserve-as-is`).
|
||||||
|
3. После правок пересчитывать только вычислимые поля, не «чистить» opaque-данные.
|
||||||
|
4. Проверять диапазоны индексов между связанными таблицами (`nodes/slots/faces/vertices/areas/cells`).
|
||||||
|
5. Для неизмененных ресурсов обеспечивать byte-identical roundtrip.
|
||||||
|
|
||||||
### 6.1. Общие принципы 1:1
|
## 6. Эмпирическая верификация (retail)
|
||||||
|
|
||||||
1. Никаких "переупорядочиваний по вкусу": сохранять порядок chunk-ов, если не требуется явная нормализация.
|
Валидация на `testdata/Parkan - Iron Strategy`:
|
||||||
2. Все неизвестные поля сохранять побайтно.
|
|
||||||
3. При roundtrip обеспечивать byte-identical для неизмененных сущностей.
|
|
||||||
4. Валидации должны повторять runtime-ожидания (размеры, count-формулы, обязательность chunk-ов).
|
|
||||||
|
|
||||||
### 6.2. Для terrain `*.msh`
|
- карт: `33`
|
||||||
|
- `Land.msh`: `33/33` валидны
|
||||||
|
- `Land.map`: `33/33` валидны
|
||||||
|
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`
|
||||||
|
|
||||||
Обязательные проверки:
|
Подтвержденные наблюдения:
|
||||||
|
|
||||||
- наличие chunk types `1,2,3,4,5,11,18,21`;
|
- `Land.msh` порядок chunk'ов стабилен: `[1,2,3,4,5,18,14,11,21]`;
|
||||||
- type `14` опционален;
|
- `Land.map` всегда содержит один chunk `type=12`;
|
||||||
- для `type 2`: `size >= 0x8C`, `(size - 0x8C) % 68 == 0`, `attr1 == (size - 0x8C) / 68`;
|
- `cellsX == cellsY == 128` во всех retail-картах;
|
||||||
- `type21_size % 28 == 0`;
|
- `poly_count == 0` во всем проверенном retail-корпусе;
|
||||||
- индексы `i0/i1/i2` в `TerrainFace28` не выходят за `vertex_count` (type 3);
|
- `normal` имеет длину ~1.0;
|
||||||
- `slot.triStart + slot.triCount` не выходит за `face_count`.
|
- `reserved_12`, `reserved_36`, `reserved_44` в retail наблюдаются как `0`.
|
||||||
|
|
||||||
Сериализация:
|
Инструмент:
|
||||||
|
|
||||||
- `flags`, соседи, `edgeClass`, material байты в `TerrainFace28` сохранять как есть;
|
|
||||||
- содержимое `type 11`-derived cell-списков (`id`, `aux`) сохранять без "починки";
|
|
||||||
- для packed normal не делать "улучшений" нормализации, если цель 1:1.
|
|
||||||
|
|
||||||
### 6.3. Для `*.map` (chunk 12)
|
|
||||||
|
|
||||||
Обязательные проверки:
|
|
||||||
|
|
||||||
- chunk type `12` существует;
|
|
||||||
- `areal_count > 0`;
|
|
||||||
- `cellsX > 0 && cellsY > 0`;
|
|
||||||
- `|normal_x,normal_y,normal_z| ~= 1` для каждого ареала;
|
|
||||||
- `links[0..vertex_count-1]` валидны (`-1/-1` или корректные `(area_ref, edge_ref)`);
|
|
||||||
- полный consumed-bytes строго равен `entry[+0x0C]`.
|
|
||||||
|
|
||||||
При редактировании:
|
|
||||||
|
|
||||||
- перестраивать только то, что действительно изменено;
|
|
||||||
- пересчитывать cell-списки и packed `cellMeta` синхронно;
|
|
||||||
- сохранять неизвестные части записи ареала без изменений.
|
|
||||||
|
|
||||||
### 6.4. Рекомендуемая архитектура редактора
|
|
||||||
|
|
||||||
1. `Parser`:
|
|
||||||
- NRes-слой;
|
|
||||||
- `TerrainMsh`-слой;
|
|
||||||
- `ArealMapChunk12`-слой.
|
|
||||||
2. `Model`:
|
|
||||||
- явные известные поля;
|
|
||||||
- `raw_unknown` для непросаженных блоков.
|
|
||||||
3. `Writer`:
|
|
||||||
- стабильная сериализация;
|
|
||||||
- проверка контрольных инвариантов перед записью.
|
|
||||||
4. `Verifier`:
|
|
||||||
- roundtrip hash/byte-compare;
|
|
||||||
- runtime-совместимые asserts.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Практический чеклист "движок 1:1"
|
|
||||||
|
|
||||||
Для runtime-совместимого движка нужно реализовать:
|
|
||||||
|
|
||||||
1. NRes API-уровень (`niOpenResFile`, `niOpenResInMem`, поиск chunk по type, получение data/attrs).
|
|
||||||
2. `CLandscape` пайплайн загрузки `*.msh` + менеджеров + `CreateSystemArealMap`.
|
|
||||||
3. Terrain face decode (28-byte запись), mask-фильтр, spatial grid queries.
|
|
||||||
4. Загрузчик `ArealMapGeometry` (chunk 12) с той же валидацией и packed-cell логикой.
|
|
||||||
5. Пост-обработку ареалов (пересвязка, корректировки опорных точек).
|
|
||||||
6. Поддержку `BuildDat.lst` для объектных категорий/схем.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Нерасшифрованные зоны (важно для редакторов)
|
|
||||||
|
|
||||||
Ниже поля, которые пока нельзя безопасно "пересобирать по смыслу":
|
|
||||||
|
|
||||||
- семантика `class_id` (`record + 40`) на уровне геймдизайна/скриптов (числовое поле подтверждено, но человекочитаемая таблица соответствий не восстановлена полностью);
|
|
||||||
- ветки формата для `poly_count > 0` (в retail `tmp/gamedata` это всегда `0`, поэтому поведение этих веток подтверждено только по коду, без живых образцов);
|
|
||||||
- человекочитаемая семантика части битов `TerrainFace28.flags` (при этом remap и бинарные значения подтверждены);
|
|
||||||
- семантика поля `aux` во `8`-байтовом элементе cell-списка (`this+31588`, второй `uint32_t`), которое в известных runtime-путях инициализируется нулем.
|
|
||||||
|
|
||||||
Правило до полного реверса: `preserve-as-is`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Эмпирическая верификация (retail `tmp/gamedata`)
|
|
||||||
|
|
||||||
Для массовой проверки спецификации добавлен валидатор:
|
|
||||||
|
|
||||||
- `tools/terrain_map_doc_validator.py`
|
- `tools/terrain_map_doc_validator.py`
|
||||||
|
|
||||||
Запуск:
|
## 7. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
```bash
|
Закрыто:
|
||||||
python3 tools/terrain_map_doc_validator.py \
|
|
||||||
--maps-root tmp/gamedata/DATA/MAPS \
|
|
||||||
--report-json tmp/terrain_map_doc_validator.report.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверенные инварианты (на 33 картах, 2026-02-12):
|
- бинарный контракт `Land.msh` и `Land.map`;
|
||||||
|
- диапазонные и структурные инварианты;
|
||||||
|
- remap масок `full/compact`;
|
||||||
|
- валидация на полном retail-корпусе карт.
|
||||||
|
|
||||||
- `Land.msh`:
|
Осталось до полного 100% архитектурного покрытия движка:
|
||||||
- порядок chunk-ов всегда `[1,2,3,4,5,18,14,11,21]`;
|
|
||||||
- `type11` первые dword всегда `[5767168, 4718593]`;
|
|
||||||
- `type21` индексы вершин/соседей валидны;
|
|
||||||
- `type2` slot-таблица валидна по формуле `0x8C + 68*N`.
|
|
||||||
- `Land.map`:
|
|
||||||
- всегда один chunk `type 12`;
|
|
||||||
- `cellsX == cellsY == 128` на всех картах;
|
|
||||||
- `poly_count == 0` для всех `34662` записей ареалов в retail-наборе;
|
|
||||||
- `record+12`, `record+36`, `record+44` всегда `0`;
|
|
||||||
- `area_metric` (`record+16`) стабильно коррелирует с площадью XY-полигона (макс. абсолютное отклонение `51.39`, макс. относительное `14.73%`, `18` кейсов > `5%`);
|
|
||||||
- `normal` в `record+20..28` всегда unit (диапазон длины `0.9999998758..1.0000001194`);
|
|
||||||
- link-таблицы `EdgeLink8` проходят строгую валидацию ссылочной целостности.
|
|
||||||
|
|
||||||
Сводный результат текущего набора данных:
|
1. Полная доменная семантика `class_id` и `logic_flag` (игровые значения/поведенческие правила).
|
||||||
|
2. Полная спецификация ветки `poly_count > 0` на живых данных (в retail не встречена).
|
||||||
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`.
|
3. Полная field-level семантика части битов `TerrainFace28.flags` (бинарный контракт и remap закрыты, но не все биты имеют документированные геймплейные имена).
|
||||||
|
|||||||
@@ -136,4 +136,18 @@ struct Rect16 {
|
|||||||
## 10. Статус валидации
|
## 10. Статус валидации
|
||||||
|
|
||||||
- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`.
|
- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`.
|
||||||
- В текущем окружении нет полного игрового набора текстур в `testdata`, поэтому массовая перепроверка не запускалась.
|
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `518/518` текстурных payload (`Texm`) без ошибок.
|
||||||
|
|
||||||
|
## 11. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Заголовок `Texm`, mip-chain layout и `Page` chunk.
|
||||||
|
2. Базовые decode-пути в RGBA8 для проверок/preview.
|
||||||
|
3. Корпусная валидация структурных инвариантов.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная формальная спецификация всех редких служебных комбинаций `flags4/flags5/unk6`.
|
||||||
|
2. Канонический writer для полного набора форматов (`indexed`, `565`, `556`, `4444`, `88`, `888`, `8888`) с проверенным roundtrip-профилем.
|
||||||
|
3. Pixel-parity тесты «оригинальный рендер vs новый рендер» с учетом mipSkip/atlas-page веток.
|
||||||
|
|||||||
@@ -1,5 +1,33 @@
|
|||||||
# UI system
|
# UI system
|
||||||
|
|
||||||
Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе.
|
`UI` — подсистема интерфейса:
|
||||||
|
|
||||||
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга UI-компонентов движка.
|
- экранные панели и HUD;
|
||||||
|
- меню;
|
||||||
|
- шрифты;
|
||||||
|
- minimap и служебные оверлеи.
|
||||||
|
|
||||||
|
## 1. Архитектурная роль
|
||||||
|
|
||||||
|
1. Работает поверх render-пайплайна как отдельный этап кадра.
|
||||||
|
2. Использует UI-ресурсы из архивных библиотек.
|
||||||
|
3. Перехватывает пользовательский ввод по правилам фокуса.
|
||||||
|
|
||||||
|
## 2. Минимальный runtime-контракт
|
||||||
|
|
||||||
|
1. Детерминированный порядок draw-проходов UI.
|
||||||
|
2. Консистентный фокус и приоритет ввода (UI vs world).
|
||||||
|
3. Стабильная загрузка font/minimap/ui-ресурсов по именам.
|
||||||
|
|
||||||
|
## 3. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
- позиция UI-слоя в общем кадре и его связи с render/input.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация форматов UI layout и контролов.
|
||||||
|
2. Полный контракт ресурсов шрифтов и text-rendering поведения.
|
||||||
|
3. Формат minimap-данных и правила трансформации координат.
|
||||||
|
4. UI parity-тесты (скриншотные и событийные).
|
||||||
|
|||||||
@@ -79,4 +79,18 @@ handle = (tableIndex << 16) | wearIndex
|
|||||||
## 8. Статус валидации
|
## 8. Статус валидации
|
||||||
|
|
||||||
- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном.
|
- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном.
|
||||||
- Массовый прогон по полному игровому набору в этом окружении не выполнялся из-за отсутствия корпуса данных в `testdata`.
|
- Корпусные проверки связки `WEAR -> MAT0 -> Texm` включены в текущий валидаторный контур проекта.
|
||||||
|
|
||||||
|
## 9. Статус покрытия и что осталось до 100%
|
||||||
|
|
||||||
|
Закрыто:
|
||||||
|
|
||||||
|
1. Текстовый формат `WEAR`, включая блок `LIGHTMAPS`.
|
||||||
|
2. Handle-кодирование material slot и fallback-резолв.
|
||||||
|
3. Правила совместимого writer/editor path.
|
||||||
|
|
||||||
|
Осталось:
|
||||||
|
|
||||||
|
1. Полная спецификация edge-case форматов строк (кодировки, редкие разделители, возможные legacy-варианты).
|
||||||
|
2. Формализация всех ограничений менеджера wear-таблиц в runtime (лимиты и политики вытеснения).
|
||||||
|
3. Интеграционные parity-тесты на полном цикле «модель -> wear -> material -> texture/lightmap».
|
||||||
|
|||||||
@@ -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