diff options
60 files changed, 6344 insertions, 8081 deletions
@@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "apps/resource-viewer"] +members = ["crates/*"] [profile.release] codegen-units = 1 @@ -1,13 +1,12 @@ # FParkan -Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования. +Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»**. ## Описание Проект находится в активной разработке и включает: - библиотеки для работы с форматами игровых архивов; -- инструменты для валидации/подготовки тестовых данных; - спецификации форматов и сопутствующую документацию. ## Установка @@ -19,13 +18,6 @@ Open source проект с реализацией компонентов игр - локально: каталог [`docs/`](docs) - сайт: <https://fparkan.popov.link> -## Инструменты - -Вспомогательные инструменты находятся в каталоге [`tools/`](tools). - -- [tools/archive_roundtrip_validator.py](tools/archive_roundtrip_validator.py) — инструмент верификации документации по архивам `NRes`/`RsLi` на реальных файлах (включая `unpack -> repack -> byte-compare`). -- [tools/init_testdata.py](tools/init_testdata.py) — подготовка тестовых данных по сигнатурам с раскладкой по каталогам. - ## Библиотеки - [crates/nres](crates/nres) — библиотека для работы с файлами архивов NRes (чтение, поиск, редактирование, сохранение). @@ -37,8 +29,8 @@ Open source проект с реализацией компонентов игр Для дополнительного тестирования на реальных игровых ресурсах: -- используйте [tools/init_testdata.py](tools/init_testdata.py) для подготовки локального набора; - используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy)); +- разместите игровые каталоги в [`testdata/`](testdata); - игровые ресурсы в репозиторий не включаются, так как защищены авторским правом. ## Contributing & Support diff --git a/apps/resource-viewer/Cargo.toml b/apps/resource-viewer/Cargo.toml deleted file mode 100644 index beadefe..0000000 --- a/apps/resource-viewer/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[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" } diff --git a/apps/resource-viewer/src/main.rs b/apps/resource-viewer/src/main.rs deleted file mode 100644 index 508c407..0000000 --- a/apps/resource-viewer/src/main.rs +++ /dev/null @@ -1,518 +0,0 @@ -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")); - } -} diff --git a/docs/appendices/glossary.md b/docs/appendices/glossary.md new file mode 100644 index 0000000..2bef280 --- /dev/null +++ b/docs/appendices/glossary.md @@ -0,0 +1,200 @@ +# Глоссарий + +Глоссарий объясняет термины в том смысле, в котором они используются в этой +книге. Короткое определение не заменяет профильную главу: практический контракт +понятия раскрывается в соответствующем томе или справочной странице. + +## Бинарные файлы и ABI + +**PE (Portable Executable)** -- формат исполняемых файлов Windows: EXE и DLL. +Он содержит заголовки, секции, таблицы импортов и экспортов, relocations и +адрес точки входа. + +**Image base** -- предпочтительный адрес начала загруженного PE-образа. +**VA** -- виртуальный адрес в процессе. **RVA** -- адрес относительно image +base. + +**Import** -- внешняя функция или переменная, которую модуль получает из другой +DLL. **Export** -- символ, предоставляемый другим модулям. Имя, ordinal и +calling convention вместе образуют часть binary contract. + +**ABI** -- соглашение о двоичном взаимодействии: размещение аргументов, возврат +значений, очистка stack, layout структур, порядок virtual methods и правила +владения. + +**Calling convention** -- часть ABI, определяющая передачу аргументов и очистку +stack. Для исследованного 32-bit code важны `__cdecl`, `__stdcall` и +`__thiscall`. + +**Vtable** -- массив указателей на virtual methods C++-объекта. Запись +`vtable +0x34` означает вызов указателя по байтовому смещению `0x34` от начала +таблицы. + +**Static analysis** исследует файл без исполнения: disassembly, strings, +imports, call graph и data flow. **Dynamic analysis** наблюдает работающую +программу: breakpoints, traces, API hooks, memory state и packet/frame captures. + +**Evidence** -- повторяемое наблюдение. **Inference** -- вывод, объединяющий +несколько наблюдений. **Hypothesis** -- рабочее предположение, ещё не +подтверждённое достаточным экспериментом. + +## Форматы данных + +**Archive** -- контейнер, объединяющий множество ресурсов. **Entry** -- запись +его каталога. **Payload** -- полезные bytes конкретной записи. + +**Magic** -- короткая сигнатура формата, например `NRes` или `Texm`. +**Version** -- номер варианта layout. Проверка одной magic без проверки version +и размеров недостаточна. + +**Offset** -- положение данных относительно начала файла или структуры. +**Size** -- число bytes. **Stride** -- размер одного элемента массива. +**Alignment** -- требование начинать данные на offset, кратном заданному числу. + +**Little-endian** -- порядок, в котором младший byte многобайтного числа +расположен первым. Основные числовые поля форматов Iron3D используют этот +порядок. + +**Fixed-size string** -- поле заранее известной длины. Полезная строка +заканчивается первым NUL, но оставшиеся bytes могут содержать служебный хвост и +должны сохраняться. + +**Opaque field** -- поле с доказанными offset и size, но не установленным +предметным смыслом. Его безопасно читать и копировать, но нельзя очищать или +переосмысливать без эксперимента. + +**Invariant** -- условие, которое обязано выполняться: range лежит внутри +payload, индекс указывает на существующий элемент, count соответствует размеру +секции. + +**Strict reader** отклоняет любое нарушение контракта. **Compatibility reader** +дополнительно воспроизводит только известные особенности оригинала. + +**Fallback** -- явно предписанный запасной путь, например material `DEFAULT`, +затем entry 0. **Heuristic** -- догадка по похожим данным; она не должна +незаметно заменять доказанный fallback. + +**Roundtrip** -- последовательность decode -> encode. **Byte-identical +roundtrip** создаёт файл, полностью совпадающий с исходным. **Lossless editor** +может изменить известное поле, сохранив все остальные bytes и порядок записей. + +## Ресурсы + +**NRes** -- основной контейнер ресурсов с каталогом в конце файла. + +**RsLi** -- библиотечный архив с каталогом в начале файла и несколькими методами +упаковки payload. + +**TMA** -- mission data: paths, clans, placed objects, properties, land path и +extras. + +**MSH** -- модель Iron3D, представленная как NRes с entries для geometry, +nodes, slots, batches, animation и auxiliary streams. + +**WEAR** -- таблица внешнего вида модели, переводящая material index в MAT0 +name и lightmap slots. + +**MAT0** -- материал: phases, parameters, animation blocks и texture references. + +**Texm** -- texture payload с header, palette, mip chain и optional Page atlas. + +**FXID** -- ресурс эффектов: команды, references, lifetime, random/time modes и +runtime instances. + +## Игровой runtime + +**Engine** -- программная среда, которая загружает данные, ведёт время, +исполняет мир и формирует изображение/звук. **Game** -- правила, миссии и +content поверх engine services. + +**World** -- долгоживущее состояние миссии: objects, terrain, время, кланы и +managers. **Scene** -- представление части мира для конкретной обработки, +обычно текущей камеры. + +**Game object** -- сущность с идентичностью, transform, properties и lifecycle. +**Component/controller** -- специализированная часть поведения: animation, +physics, AI или rendering representation. + +**Simulation** отвечает за изменение мира. **Tick** -- один расчётный шаг. +**Frame** -- одно подготовленное изображение. Число ticks и frames за единицу +времени не обязано совпадать. + +**Event/message** -- типизированное сообщение между objects или subsystems. +**Queue traversal** -- стабильный обход зарегистрированных объектов. +**Deferred deletion** -- перенос фактического удаления до безопасной границы. + +**Snapshot** -- согласованное состояние, которое renderer читает без изменения +simulation. **Determinism** -- одинаковый результат при одинаковом initial +state, input, времени и порядке событий. + +**Authority** -- subsystem или network peer, которому разрешено окончательно +менять состояние объекта. **Mirror object** -- локальное представление объекта, +authority которого находится у другого player. + +## Геометрия и рендеринг + +**Vertex** -- вершина geometry. **Index** -- номер вершины. **Triangle** -- +примитив из трёх индексов. + +**Node** -- элемент hierarchy модели со своим local transform. **Slot** в MSH +-- выбранная геометрическая группа для комбинации node, LOD и group. **Batch** +-- непрерывный индексный диапазон с material slot и render state. + +**Transform** переводит данные между coordinate spaces. **Matrix** задаёт +линейное преобразование и translation. Порядок умножения matrices является +частью контракта. + +**Bounds** -- упрощённый объём для быстрых тестов. **AABB** -- min/max по осям. +**Bounding sphere** -- center и radius. + +**Renderer** преобразует подготовленную сцену в изображение. **Backend** -- +реализация поверх конкретного API или устройства. + +**Draw call** -- команда нарисовать диапазон primitives. **Indexed draw** +использует index buffer и base vertex. + +**Material phase** -- одно временное состояние анимированного материала. +**Texture** -- двумерный массив texels. **Mip chain** -- последовательность +уменьшенных уровней texture. **Atlas** -- texture с несколькими под- +изображениями. + +**Fixed-function pipeline** -- старый graphics pipeline, где приложение +выбирает predefined transform, lighting, texture-stage и blend states вместо +пользовательских shaders. + +**Depth test**, **culling**, **alpha test** и **blending** -- render states, +которые влияют на порядок и видимость fragments. + +**Pixel parity** -- совпадение конечного изображения при фиксированных camera, +time, seed, resolution и device profile. + +## Навигация, звук и сеть + +**Areal** -- логическая область карты с границей, class/flags и связями с +соседями. **Areal graph** -- граф областей и переходов. **Cell grid** -- +пространственный индекс для быстрых candidate queries. + +**Pathfinding** -- поиск маршрута по graph. **Corridor** -- локальная полоса, +построенная из последовательности areals. **Local steering** корректирует +ближайший шаг внутри corridor. + +**Collision proxy** -- упрощённое представление объекта для столкновений. +**Broad phase** быстро находит потенциальные пары. **Narrow phase** выполняет +точную проверку и вычисляет contact. + +**Sample** -- декодированные звуковые данные. **Source** -- конкретный +экземпляр воспроизведения с position, gain, loop state и временем. **Listener** +-- положение и ориентация слушателя для 3D spatialization. + +**Transport** -- механизм доставки bytes между peers. **Protocol** -- framing, +message types, порядок и правила подтверждения. **Wire compatibility** -- +способность обмениваться данными с оригинальным клиентом. + +**Serialization** -- преобразование typed state в byte sequence. **Framing** -- +способ отделить одно сообщение от следующего. **Reliable delivery** гарантирует +доставку/порядок в пределах выбранной модели; **unreliable delivery** допускает +потери ради задержки. + +**Player ID** транспорта и **game player number** -- разные идентичности. +**Ownership transfer** меняет authority объекта. **Replication** передаёт +состояние или события remote mirrors. diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md new file mode 100644 index 0000000..f9d7d0e --- /dev/null +++ b/docs/appendices/knowledge-boundaries.md @@ -0,0 +1,120 @@ +# Границы знания + +Этот раздел перечисляет области, где контракт ещё не закрыт полностью. Они не +мешают безопасному чтению и lossless сохранению, но не должны превращаться в +authoring API без динамического подтверждения. + +## Render state + +Доказаны frame boundaries, world traversal, material resolve и крупные проходы. +Не доказаны символами точные имена renderer vtable slots, полный набор CShade +state transitions и окончательный порядок части transparent/FX/shadow subpasses. + +Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX +среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state +log на минимальных сценах с одним типом материала. + +## FXID field-level semantics + +Размеры команд, resource references, lifecycle, flags families и используемые +time modes известны. Не закрыто значение каждого поля body opcodes 1--10, +отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких +time modes. + +Закрывающий эксперимент: изменять по одному полю копии эффекта, воспроизводить +его в контролируемой сцене и логировать runtime command object, emitted +primitives, sound events и reads в `Effect.dll`. + +## Script VM + +Доступны packages, symbols, event sections, variable declarations и version +checks. Полная instruction grammar `.scr`, semantics opcodes и serialization +state ещё не восстановлены. + +Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump +table с instruction sizes, построить disassembler и сравнить выполнение +коротких scripts с оригиналом. + +## Saves and campaign state + +Найдены `saveslots.cfg` и `missions/dispatcher.ini`, но binary savegame payload, +serialization World3D/AI/script/RNG и migration rules не закрыты. + +Нужны сохранения оригинала в контролируемых состояниях: старт миссии, изменение +позиции, здоровья, order/path, FX/timer, script variable, research/economy, +mission completion, pause и non-default game time. + +## Physical/control formats + +CTLD и связанные resources структурно читаются, count patterns и variants +известны. Не названы все секции, shape types, coefficients и точный contact +solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags. + +Закрывающий эксперимент: трассировать `LoadControlSystem`, +`LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать +каждый изменяемый field с созданным shape, contact или реакцией на движение. + +## DirectPlay wire + +DirectPlay lifecycle и имена игровых messages известны. Wire framing, payload +schema, reliability flags и `netZipData` требуют записи обмена двух +оригинальных клиентов. + +Native interoperability подтверждается только успешным обменом original client +<-> compatibility implementation в обе стороны. + +## Shell, HUD, шрифты и локализация + +Граница shell подтверждена exports `createShell/getIShell`, `IGUIServer`, +верхнеуровневым UI-pass и файлами `ui/*.cfg`, `DATA/TextRes.cfg`, +`gamefont.rlb` и `sprites.lib`. RsLi framing библиотек закрыт, но widget tree, +layout rules, glyph metrics, sprite command semantics, focus/navigation и HUD +state machine пока не восстановлены до field-level спецификации. + +Закрывающий эксперимент: трассировать загрузку `shell_ctrls.cfg`, +`menu_resources.cfg`, `cursor.cfg`, `game_resources.cfg` и `hq.cfg`, сопоставить +GUI object factories и снять command/event captures для меню, HUD, briefing и +диалогов. + +## Research, economy and properties + +Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA +properties доказывают отдельный слой исследований, стоимости, добычи и +производственных параметров. Формулы стоимости, dependency graph технологий, +inventory/economy transitions и точная типизация всех 16-byte property values +не закрыты. + +Закрывающий эксперимент: сопоставить research functions с ресурсами и UI, +снять изменения state на контролируемых покупках/исследованиях и построить +typed schema свойств по consumers, а не по одному имени. + +## Rare branches + +- `Land.map poly_count > 0`; +- RsLi adaptive methods `0x080` и `0x0A0`; +- Texm formats 556 и 88; +- FX opcode 6; +- редкие material flags и MSH auxiliary streams. + +Такие ветки реализуются по бинарному коду и synthetic tests, а статус +corpus-verified получают только после реального файла или runtime trace. + +## Dynamic-stage requirements + +Оставшиеся вопросы нельзя закрыть только статическими архивами. Нужна +изолированная 32-bit Windows-среда, неизменённые игровые каталоги, manifest +SHA-256, debugger, API/vtable hooks, controlled clocks/input и автоматический +launcher, который восстанавливает snapshot, запускает один test case, собирает +логи и завершает процесс без ручного вмешательства. + +Для каждого capture сохраняются build profile, module hashes, mission/resource +key, configuration, device profile, initial state, input/time script и версии +инструментов. + +## Closure criteria + +Вопрос считается закрытым только при наличии build fingerprint, raw trace, +parser trace-а, минимального воспроизводимого input/resource/save/message, +формального контракта или явно ограниченной гипотезы, differential test для +изменённых DLL, обновления тематической главы и regression case, запускаемого +без ручного анализа. diff --git a/docs/index.md b/docs/index.md index 000ea34..2775ed7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,17 +1,51 @@ -# Welcome to MkDocs +# FParkan -For full documentation visit [mkdocs.org](https://www.mkdocs.org). +FParkan -- самостоятельная техническая книга о восстановлении игрового движка +Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска оригинальной программы +и карты DLL к форматам ресурсов, загрузке миссии, геометрии, материалам, +рендеру, поведению, звуку, сети и плану чистой совместимой реализации. -## Commands +Сайт оформлен как онлайн-книга: тома читаются последовательно, а справочник +используется как быстрый доступ к форматам, проверочным правилам и границам +доказанного знания. -* `mkdocs new [dir-name]` - Create a new project. -* `mkdocs serve` - Start the live-reloading docs server. -* `mkdocs build` - Build the documentation site. -* `mkdocs -h` - Print help message and exit. +## Как читать -## Project layout +Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится +лексика, доказательная политика, модульная архитектура и жизненный цикл кадра. - mkdocs.yml # The configuration file. - docs/ - index.md # The documentation homepage. - ... # Other markdown pages, images and other files. +Если нужна реализация совместимого движка, читайте тома III--VII линейно: +ресурсы, миссии, мир, рендер, интерактивные подсистемы и порядок работ. + +Если вы проверяете выводы, переходите к тому VIII и приложениям. Там собраны +уровни уверенности, corpus gates, открытые вопросы и критерии закрытия. + +## Восемь томов + +1. **Путеводитель и методика** -- назначение книги, маршруты чтения, язык + предметной области и правила проверки. +2. **Запуск, архитектура и игровой цикл** -- `iron_3d.exe`, пятнадцать DLL, + сервисы, World3D, очередь объектов и границы кадра. +3. **Ресурсная система и форматы** -- NRes, RsLi, кэши, имена, `objects.rlb`, + unit DAT и сквозное разрешение ресурсов. +4. **Мир, миссии и runtime** -- TMA, ландшафт, ареалы, маршруты, создание мира + и свойства размещённых объектов. +5. **Геометрия, материалы и рендер** -- MSH, анимация, WEAR, MAT0, Texm, FXID, + свет, атмосфера и полный render frame. +6. **Поведение, управление, звук и сеть** -- AI, Behavior, Wizard, Control, + ввод, камера, звук и DirectPlay-слой. +7. **Руководство по полной реализации** -- целевая архитектура, этапы работ, + тестовый контур, точность, скорость и критерий совместимости. +8. **Справочник и доказательная база** -- ABI, конфигурация, статистика + корпусов, границы знания и глоссарий. + +## Политика доказательств + +Специфические утверждения об Iron3D принимаются только после локальной проверки +на исполняемых файлах, DLL, демоверсии, полных каталогах Частей 1 и 2 или на +взаимных инвариантах реальных ресурсов. Внешние описания и текущий код FParkan +могут подсказывать вопросы, но не заменяют проверку. + +Неизвестные поля не получают правдоподобных имён. Пока смысл не закрыт, +документация фиксирует raw layout, границы, безопасное чтение и lossless +сохранение. diff --git a/docs/reference/materials.md b/docs/reference/materials.md new file mode 100644 index 0000000..8146a2c --- /dev/null +++ b/docs/reference/materials.md @@ -0,0 +1,69 @@ +# WEAR и MAT0 + +MSH batch хранит только `material_index`. WEAR переводит этот индекс в имя +материала, а MAT0 по этому имени описывает phases, parameters и texture +references. + +```text +Batch20.material_index + -> WEAR row + -> MAT0 entry + -> active phase + -> textureName +``` + +## WEAR + +WEAR -- текстовый ресурс type ID `0x52414557`, обычно `*.wea` рядом с моделью. + +```text +<wearCount> +<legacyId> <materialName> +... + +[empty line] +[LIGHTMAPS +<lightmapCount> +<legacyId> <lightmapName> +...] +``` + +`legacyId` сохраняется, но выбор выполняется по позиции строки и имени. Между +основной таблицей и `LIGHTMAPS` нужен пустой разделитель. + +## MAT0 + +MAT0 имеет type ID `0x3054414D`, обычно расположен в `Material.lib`. `attr1` +содержит runtime flags, `attr2` -- версию payload. + +```c +#pragma pack(push, 1) +struct Mat0PrefixV4Plus { + uint16_t phase_count; + uint16_t animation_block_count; + uint8_t metadata_a; + uint8_t metadata_b; + uint32_t metadata_c_raw; + uint32_t metadata_d_raw; +}; + +struct Phase34 { + uint8_t parameters[18]; + char texture_name[16]; +}; +#pragma pack(pop) +``` + +Versioned fields читаются только если версия их содержит. Для старых версий +используются runtime defaults, а raw values сохраняются. + +## Fallback + +Material resolve: + +1. имя из WEAR; +2. `DEFAULT`; +3. entry с индексом 0. + +Пустое texture name означает намеренно нетекстурированную поверхность. Lightmap +fallback отдельный: отсутствующий lightmap даёт slot `-1`. diff --git a/docs/reference/msh.md b/docs/reference/msh.md new file mode 100644 index 0000000..25aaad2 --- /dev/null +++ b/docs/reference/msh.md @@ -0,0 +1,82 @@ +# MSH + +Файл `*.msh` является NRes-контейнером. Geometry, узлы, slots, batches, +animation и служебные streams лежат в entries с разными `type_id`. + +## Entry map + +```text +type 1 nodes and slot selection +type 2 header 0x8C + Slot68 records +type 3 positions float3 +type 4 packed normals +type 5 packed UV0 +type 6 index buffer u16 +type 7 triangle descriptors +type 8 animation keys +type 9 service stream +type 10 strings and node names +type 13 Batch20 records +type 15 auxiliary stream +type 17 auxiliary data +type 18 rare stream +type 19 animation frame map +type 20 rare auxiliary table +``` + +Reader ищет entries по type, но сохраняет исходный порядок для roundtrip. + +## Node and slot selection + +Type 1 обычно состоит из records по 38 bytes: + +```c +struct Node38 { + uint16_t hdr0; + uint16_t parent_or_link; + uint16_t anim_map_start; + uint16_t fallback_key; + uint16_t slot_index[15]; +}; +``` + +`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает +отсутствие геометрии для комбинации LOD/group. + +## Slot and batch + +Type 2 содержит header `0x8C`, затем `Slot68`: + +```c +struct Slot68 { + uint16_t tri_start; + uint16_t tri_count; + uint16_t batch_start; + uint16_t batch_count; + float aabb_min[3]; + float aabb_max[3]; + float sphere_center[3]; + float sphere_radius; + uint32_t opaque[5]; +}; +``` + +Type 13 задаёт draw ranges: + +```c +#pragma pack(push, 1) +struct Batch20 { + uint16_t batch_flags; + uint16_t material_index; + uint16_t opaque4; + uint16_t opaque6; + uint16_t index_count; + uint32_t index_start; + uint16_t opaque14; + uint32_t base_vertex; +}; +#pragma pack(pop) +``` + +Index check выполняется как `base_vertex + index < vertex_count` для всего +используемого slice. diff --git a/docs/reference/nres.md b/docs/reference/nres.md new file mode 100644 index 0000000..3b8384f --- /dev/null +++ b/docs/reference/nres.md @@ -0,0 +1,61 @@ +# NRes + +`NRes` -- основной контейнер ресурсов Iron3D. Он используется как внешний +архив и как внутренний контейнер модели `*.msh`. + +```text +[Header: 16 bytes] +[Data region: payload with alignment] +[Directory: entry_count * 64 bytes] +``` + +## Header + +```c +struct NResHeader16 { + char magic[4]; // "NRes" + uint32_t version; // 0x00000100 + int32_t entry_count; // >= 0 + uint32_t total_size; // equals file size +}; +``` + +`directory_offset = total_size - entry_count * 64`. Reader проверяет отсутствие +переполнений, `directory_offset >= 16` и точное окончание каталога на +`total_size`. + +## Entry + +```c +#pragma pack(push, 1) +struct NResEntry64 { + uint32_t type_id; + uint32_t attr1; + uint32_t attr2; + uint32_t size; + uint32_t attr3; + char name[36]; + uint32_t data_offset; + uint32_t sort_index; +}; +#pragma pack(pop) +``` + +Имя содержит bounded C-string до 35 полезных bytes. `sort_index` задаёт +отображение из sorted position в original entry index. В строгом режиме все +`sort_index` образуют перестановку `0..N-1`. + +## Data region + +Payload каждой записи лежит после header и до начала каталога. Игровые архивы +выравнивают следующий payload до 8 bytes нулями, но reader не должен требовать +плотного покрытия data region. + +Различаются: + +- active payload -- диапазон, на который указывает entry; +- gap/padding -- bytes между активными диапазонами; +- unindexed preserved region -- произвольные bytes, не принадлежащие entry. + +Lossless editor сохраняет все три категории. Compact writer может исключить +unindexed regions только при явной операции repack. diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md new file mode 100644 index 0000000..6f0975c --- /dev/null +++ b/docs/reference/render-frame.md @@ -0,0 +1,56 @@ +# Render frame + +Кадр является последней стадией цикла, а не самостоятельной функцией renderer-а. +До draw calls уже накоплен input, рассчитан tick, применены отложенные операции, +выбрана камера и обновлён 3D sound listener. + +## Frame skeleton + +```text +system messages and input + -> simulation calculation + -> deferred object operations + -> animation and transforms + -> camera and sound listener + -> visibility and render queues + -> materials and draw passes + -> renderer completion + -> end-of-render callbacks and UI +``` + +В `World3D::stdRenderGame` доказан крупный порядок: camera передаётся Terrain, +настраиваются viewport/matrices, вызываются renderer boundary slots, +устанавливается `in_render`, выполняется traversal мира, закрывается world/shade +pass, вызывается renderer completion, снимается `in_render`, рассылается +end-of-render. + +## Draw item + +Подготовленный draw item содержит: + +- node world matrix; +- batch flags and index range; +- WEAR material handle; +- MAT0 active phase and coefficients; +- texture handle; +- optional lightmap handle; +- render phase and sorting key; +- legacy pipeline state. + +Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase +или texture cache посреди прохода не должно менять уже собранную очередь. + +## Parity risks + +- x87 precision and rounding; +- scalar/SIMD `g_FastProc` differences; +- object, batch and transparent primitive order; +- depth, cull, alpha test and blend transitions; +- mip-skip, palette and Page coordinates; +- material fallback and phase selection; +- RNG sequence for FX and atmosphere; +- device capability fallback; +- simulation time quantization. + +Для отладки нужен deterministic frame capture: camera state, visible object IDs, +draw-item list, pipeline keys, matrices и hashes промежуточных buffers. diff --git a/docs/reference/rsli.md b/docs/reference/rsli.md new file mode 100644 index 0000000..a28aa1d --- /dev/null +++ b/docs/reference/rsli.md @@ -0,0 +1,69 @@ +# RsLi + +`RsLi` -- библиотечный архив Iron3D с каталогом в начале файла и payloads после +него. + +```text +[Header: 32 bytes] +[Entry table: entry_count * 32 bytes] +[Payloads] +[optional trailer] +``` + +## Header fields + +```text ++0x00 char[2] "NL" ++0x02 u8 reserved ++0x03 u8 version = 1 ++0x04 i16 entry_count ++0x0E u16 presorted_flag = 0xABBA ++0x14 u32 xor_seed +``` + +Остальные bytes сохраняются без нормализации. + +## Entry + +```c +struct RsLiEntry32 { + char name[12]; + uint8_t service[4]; + int16_t flags; + int16_t sort_to_original; + uint32_t unpacked_size; + uint32_t data_offset_raw; + uint32_t packed_size; +}; +``` + +Имя обычно хранится в uppercase ASCII. `sort_to_original` связывает sorted +position с исходной записью. + +## Table transform + +Entry table проходит обратимое потоковое XOR-преобразование. Начальное +состояние берётся из младших 16 bits `xor_seed` и продолжается через всю +таблицу, не сбрасываясь на границе записи. + +## Storage methods + +```text +0x000 raw block +0x020 byte transform only +0x040 LZSS +0x060 transform + LZSS +0x080 adaptive Huffman + LZSS +0x0A0 transform + adaptive Huffman + LZSS +0x100 raw Deflate +``` + +После любого пути должно получиться ровно `unpacked_size` bytes. Методы +`0x080` и `0x0A0` подтверждены decoder-кодом, но не живыми payload демоверсии +или обеих частей. + +## Compatibility quirk + +`sprites.lib::INTERF8.TEX` объявляет Deflate range на один byte дальше EOF. +Совместимый reader допускает `packed_size - 1` только для этого именованного +случая. Строгий режим сообщает `deflate_eof_plus_one`. diff --git a/docs/reference/texm.md b/docs/reference/texm.md new file mode 100644 index 0000000..db4321e --- /dev/null +++ b/docs/reference/texm.md @@ -0,0 +1,67 @@ +# Texm + +`Texm` -- основной формат изображений Iron3D. Payload содержит header, +необязательную палитру, mip chain и иногда `Page` chunk. + +```c +struct TexmHeader32 { + uint32_t magic; // 'Texm' + uint32_t width; + uint32_t height; + uint32_t mip_count; + uint32_t flags4; + uint32_t flags5; + uint32_t unknown6; + uint32_t format; +}; +``` + +## Pixel formats + +```text +0 Indexed8 + palette 256 * 4 bytes +565 R5 G6 B5 +556 R5 G5 B6 +4444 A4 R4 G4 B4 +88 L8 A8 +888 RGB8 in four-byte element +8888 A8 R8 G8 B8 +``` + +Короткие каналы расширяются до 8 bits повторением значимых bits. Для 888 +служебный четвёртый byte сохраняется при roundtrip. + +## Layout + +```text +TexmHeader32 +[palette 1024 bytes, only for format 0] +level 0 pixels +level 1 pixels +... +level mip_count-1 pixels +[optional Page chunk] +``` + +Размер mip level вычисляется через `max(1, width >> i)` и +`max(1, height >> i)`. Parser суммирует размеры с проверкой переполнения до +чтения данных. + +## Page chunk + +```c +struct PageHeader8 { + uint32_t magic; // 'Page' + uint32_t rect_count; +}; + +struct PageRect8 { + int16_t x; + int16_t width; + int16_t y; + int16_t height; +}; +``` + +Chunk обязан иметь размер `8 + rect_count * 8`. Rectangles находятся в pixel +space базового mip и масштабируются после mip-skip. diff --git a/docs/reference/tma.md b/docs/reference/tma.md new file mode 100644 index 0000000..30ee495 --- /dev/null +++ b/docs/reference/tma.md @@ -0,0 +1,64 @@ +# TMA + +`data.tma` -- основное описание расстановки и логической конфигурации миссии. +Файл перечисляет paths, clans, objects, свойства, ссылку на ландшафт и extras. + +## String primitive + +```c +struct LpString { + uint32_t byte_length; + uint8_t bytes[byte_length]; +}; +``` + +Reader продвигается ровно на `4 + byte_length`. Завершающий NUL не является +обязательной частью framing. Для человекочитаемого вида используется legacy +ANSI/CP1251 view, но исходные bytes сохраняются. + +## Top level + +```text +u32 format_version +u32 path_count +PathRecord paths[path_count] +u32 clan_section_version +u32 clan_count +ClanRecord clans[clan_count] +u32 object_section_version +u32 object_count +PlacedObject objects[object_count] +LpString land_path +u32 mission_flag +LpString description_raw +u32 extra_section_version +u32 extra_count +ExtraRecord28 extras[extra_count] +``` + +Все 60 TMA Частей 1 и 2 проходят parser до точного EOF. Версии стабильны: +верхний уровень `1`, clan section `6`, object section `10`, property schema +`1`, trailing section `1`. + +## PlacedObject + +```text +u32 raw_kind +u32 class_or_flags +LpString resource_name +u32 raw_after_resource +u32 identity_or_clan_raw +f32 position[3] +f32 orientation[3] +f32 scale[3] +LpString instance_name +u32 raw_after_name +i32 link0 +i32 link1 +u32 property_schema_version +u32 property_count +Property properties[property_count] +``` + +`Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только +для доказанных property names и consumers. diff --git a/docs/specs/ai.md b/docs/specs/ai.md deleted file mode 100644 index 7570cd0..0000000 --- a/docs/specs/ai.md +++ /dev/null @@ -1,35 +0,0 @@ -# AI system - -Страница фиксирует границы подсистемы AI на уровне движка: - -- выбор целей; -- тактические приоритеты; -- координация с `Behavior`, `ArealMap`, `Missions`. - -## 1. Текущая зафиксированная часть - -1. AI работает поверх ареалов/клеток карты, а не напрямую поверх render-геометрии. -2. Результат AI передается в behavior/command-слой как набор целевых состояний и команд. -3. Решения AI зависят от миссионных триггеров и состояния объектов мира. - -## 2. Контракт интеграции - -В 1:1 реализации AI должен быть совместим с: - -1. системой ареалов (`Land.map`); -2. объектными категориями (`BuildDat.lst`); -3. поведением юнитов (`behavior.md`); -4. миссионными условиями (`missions.md`). - -## 3. Статус покрытия и что осталось до 100% - -Закрыто: - -- роль AI в общей архитектуре и точки интеграции с соседними подсистемами. - -Осталось: - -1. Полный формат runtime-AI состояний и таблиц решений. -2. Полные правила выбора цели/маршрута/приоритета огня. -3. Полная спецификация влияния миссионных скриптов на AI. -4. Набор тест-кейсов «AI tick parity» для побайтного/пошагового сравнения с оригиналом. diff --git a/docs/specs/arealmap.md b/docs/specs/arealmap.md deleted file mode 100644 index 3b234c9..0000000 --- a/docs/specs/arealmap.md +++ /dev/null @@ -1,31 +0,0 @@ -# ArealMap - -`ArealMap` — подсистема топологии мира и логических зон. - -Подробный бинарный формат `Land.map` и связь с terrain описаны в: - -- [Terrain + ArealMap](terrain-map-loading.md) - -## 1. Роль в движке - -1. Хранит ареалы, связи между ареалами и клеточный индекс. -2. Используется для навигации, логики объектов и AI-решений. -3. Связывает геометрию карты с миссионной и поведенческой логикой. - -## 2. Минимальный runtime-контракт - -1. Валидный граф ареалов и edge-link связей. -2. Валидная cell-grid индексация (`cellsX/cellsY` + hit lists). -3. Согласованные идентификаторы ареалов для AI/Behavior/Missions. - -## 3. Статус покрытия и что осталось до 100% - -Закрыто: - -- бинарный контракт `Land.map` и pair-загрузка с `Land.msh`. - -Осталось: - -1. Полная доменная семантика `class_id`/`logic_flag` по всем игровым сценариям. -2. Формальная спецификация API-запросов к ArealMap (поиск зон, фильтры, события). -3. Набор parity-тестов поведения навигационных запросов на одинаковых входах. diff --git a/docs/specs/behavior.md b/docs/specs/behavior.md deleted file mode 100644 index 33d403d..0000000 --- a/docs/specs/behavior.md +++ /dev/null @@ -1,28 +0,0 @@ -# Behavior system - -`Behavior` — слой исполнения состояний юнитов между AI-решением и низкоуровневым control-командованием. - -## 1. Роль в кадре - -1. Принимает решения из AI. -2. Переводит их в state machine юнита. -3. Формирует команды движения/атаки/действий в world/control-слой. - -## 2. Внешние зависимости - -1. `ArealMap` (доступность/топология). -2. `Missions` (триггеры и ограничения сценария). -3. `Control` (выполнение команд). - -## 3. Статус покрытия и что осталось до 100% - -Закрыто: - -- архитектурная роль подсистемы и ее место в runtime-пайплайне. - -Осталось: - -1. Полная спецификация finite-state машин по типам юнитов. -2. Полная таблица переходов, таймаутов и приоритетов. -3. Формализация входных/выходных структур поведения для 1:1 эмуляции. -4. Поведенческие parity-тесты на фиксированных replay-сценариях. diff --git a/docs/specs/control.md b/docs/specs/control.md deleted file mode 100644 index eb1e535..0000000 --- a/docs/specs/control.md +++ /dev/null @@ -1,28 +0,0 @@ -# Control system - -`Control` — подсистема входа и маршрутизации команд (пользовательских и системных). - -## 1. Роль - -1. Преобразует ввод устройств в команды движка. -2. Синхронизирует управление камерой, UI и объектами мира. -3. Передает команды в gameplay-подсистемы с учетом активного режима игры. - -## 2. Минимальный контракт совместимости - -1. Детерминированный mapping input -> command. -2. Стабильная обработка очереди команд в пределах кадра. -3. Корректный приоритет UI-фокуса над world-input. - -## 3. Статус покрытия и что осталось до 100% - -Закрыто: - -- место control-слоя в архитектуре и базовый runtime-контур. - -Осталось: - -1. Полная карта input actions и режимов обработки. -2. Формат внутренних очередей команд и их сериализация. -3. Спецификация edge-case поведения (повтор клавиш, захват мыши, hotkey-конфликты). -4. Пошаговые parity-тесты на записанных последовательностях ввода. diff --git a/docs/specs/coverage-audit.md b/docs/specs/coverage-audit.md deleted file mode 100644 index 638f4c1..0000000 --- a/docs/specs/coverage-audit.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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-тесты». diff --git a/docs/specs/fxid.md b/docs/specs/fxid.md deleted file mode 100644 index f723e17..0000000 --- a/docs/specs/fxid.md +++ /dev/null @@ -1,202 +0,0 @@ -# FXID - -`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy. -Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов. - -Связанные контейнеры: [NRes](nres.md), [RsLi](rsli.md). - -## 1. Контейнер - -- Тип ресурса в `NRes`: `0x44495846` (`FXID`). -- Значения `attr1/attr2/attr3` в типовых игровых данных стабильны, но при редактуре их нужно сохранять как есть. - -## 2. Бинарный формат - -Все значения little-endian. - -### 2.1. Заголовок (60 байт) - -```c -struct FxHeader60 { - uint32_t cmd_count; // 0x00 - uint32_t time_mode; // 0x04 - float duration_sec; // 0x08 - float phase_jitter; // 0x0C - uint32_t flags; // 0x10 - uint32_t settings_id; // 0x14 - float rand_shift_x; // 0x18 - float rand_shift_y; // 0x1C - float rand_shift_z; // 0x20 - float pivot_x; // 0x24 - float pivot_y; // 0x28 - float pivot_z; // 0x2C - float scale_x; // 0x30 - float scale_y; // 0x34 - float scale_z; // 0x38 -}; -``` - -Поток команд начинается строго с `offset = 0x3C`. - -### 2.2. Команда - -Каждая команда: - -1. `uint32 cmd_word` -2. body фиксированного размера, зависящего от `opcode` - -Поля `cmd_word`: - -- `opcode = cmd_word & 0xFF` -- `enabled = (cmd_word >> 8) & 1` -- `bits 9..31` нужно сохранять 1:1 - -Выравнивания между командами нет. - -### 2.3. Размеры команд - -| Opcode | Размер | -|---:|---:| -| 1 | 224 | -| 2 | 148 | -| 3 | 200 | -| 4 | 204 | -| 5 | 112 | -| 6 | 4 | -| 7 | 208 | -| 8 | 248 | -| 9 | 208 | -| 10 | 208 | - -## 3. Смысл заголовка - -- `cmd_count`: число команд в потоке. -- `time_mode`: способ вычисления текущего коэффициента эффекта. -- `duration_sec`: длительность (в рантайме переводится в миллисекунды). -- `phase_jitter`: амплитуда случайного фазового сдвига. -- `flags`: флаги поведения (видимость, альфа-модификаторы, режимы гейтинга). -- `settings_id`: индекс профиля/настроек эффекта. -- `rand_shift_*`: случайный пространственный сдвиг. -- `pivot_*`: локальная опора. -- `scale_*`: базовый масштаб инстанса эффекта. - -## 4. Флаги заголовка - -Практически важные биты: - -- `0x0001`: случайный сдвиг фазы -- `0x0008`: случайный пространственный сдвиг (`rand_shift_*`) -- `0x0010`: ветки видимости/окклюзии -- `0x0020`: треугольный ремап альфы -- `0x0040`: инверсия исходного active-state -- `0x0080`, `0x0100`: фильтрация по времени суток -- `0x0200`: умножение альфы на нормализованное время жизни -- `0x0400`, `0x1000`: дополнительные биты состояния менеджера эффекта -- `0x0800`: дополнительный гейтинг - -Неизвестные биты должны сохраняться без изменений. - -## 5. `time_mode` (0..17) - -База: - -- `tn = (now - start) / (end - start)` -- `prev = предыдущая вычисленная альфа` - -Поддерживаемые семейства режимов: - -- константный режим; -- линейный (`tn`), обратный (`1-tn`), циклический (`fract(tn)`); -- режимы от внешних параметров мира/очереди; -- режимы на основе норм векторов состояния; -- режимы с ограничением вниз/вверх относительно `prev`. - -После вычисления: - -- при `flags & 0x0200` применяется `alpha *= tn`; -- при `flags & 0x0020` применяется triangular remap. - -## 6. Resource-ссылки внутри команд - -Для opcode `2/3/4/5/7/8/9/10` используется ссылка: - -```c -struct ResourceRef64 { - char archive[32]; - char name[32]; -}; -``` - -Контракт: - -- строки ASCII, нуль-терминированные; -- сравнение имён регистронезависимое; -- обычно: - - `opcode 2`: `sounds.lib` + `*.wav` - - остальные: `material.lib` + имя материала/эффекта. - -## 7. Runtime-контракт исполнения - -На создании инстанса: - -1. Заголовок копируется в runtime-состояние. -2. Вычисляется `end_time`. -3. Для каждой команды создаётся runtime-объект по `opcode`. -4. В объект копируется `enabled`. -5. Объект инициализируется контекстом эффекта. - -На каждом кадре: - -1. Вычисляется текущий коэффициент/альфа по `time_mode` и `flags`. -2. Выполняется update каждой команды. -3. Выполняется emit/render часть активных команд. -4. Применяются события Start/Stop/Restart. - -## 8. Строгий парсер (рекомендуемый) - -1. Проверить `len(payload) >= 60`. -2. Прочитать `cmd_count`. -3. Идти от `ptr = 0x3C`. -4. Для каждой команды: - - проверить `ptr + 4 <= len`; - - прочитать `opcode`; - - проверить, что `opcode` поддержан; - - проверить `ptr + size(opcode) <= len`; - - сдвинуть `ptr += size(opcode)`. -5. Проверить `ptr == len(payload)`. - -## 9. Writer и редактор - -Для lossless-совместимости: - -- сохранять все неизвестные поля/биты; -- не менять фиксированные размеры команд; -- не добавлять padding; -- пересчитывать только `cmd_count` и размеры контейнера; -- сохранять порядок команд. - -## 10. Что требуется для 1:1 переноса - -1. Полная поддержка opcode `1..10`. -2. Точный контракт вычисления `time_mode` и `flags`. -3. Точное поведение `ResourceRef64`. -4. Повторяемый RNG и одинаковая политика плавающей точки. - -## 11. Статус валидации - -- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`. -- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `923/923` FXID payload без ошибок. - -## 12. Статус покрытия и что осталось до 100% - -Закрыто: - -1. Контейнер FXID, fixed-size командный поток, opcode-покрытие `1..10`. -2. Базовый runtime-контур исполнения эффекта. -3. Корпусная валидация формата на retail-данных. - -Осталось: - -1. Полная field-level семантика payload каждого opcode для авторинга новых эффектов «с нуля». -2. Формальная спецификация всех `time_mode` веток на уровне точных числовых формул и edge-case поведения. -3. Полный набор пиксельных parity-тестов FX (оригинал vs новый рендер) на фиксированных сценах. diff --git a/docs/specs/material.md b/docs/specs/material.md deleted file mode 100644 index 12c8296..0000000 --- a/docs/specs/material.md +++ /dev/null @@ -1,144 +0,0 @@ -# Material (`MAT0`) - -`MAT0` описывает материал и его фазовую анимацию. - -Связанные страницы: - -- [Wear table (`WEAR`)](wear.md) -- [Texture (`Texm`)](texture.md) -- [Render pipeline](render.md) - -## 1. Контейнер - -- Тип ресурса: `0x3054414D` (`MAT0`). -- Обычно хранится в `Material.lib`. -- `attr1` используется как битовое поле runtime-флагов материала. -- `attr2` задаёт версию заголовка payload. - -## 2. Бинарный layout - -```c -struct Mat0Payload { - uint16_t phaseCount; - uint16_t animBlockCount; // должно быть < 20 - - // если attr2 >= 2 - uint8_t metaA8; - uint8_t metaB8; - // если attr2 >= 3 - uint32_t metaC32; - // если attr2 >= 4 - uint32_t metaD32; - - PhaseRecord34 phases[phaseCount]; - AnimBlockRaw anim[animBlockCount]; -}; -``` - -Если `attr2 < 2`, используются runtime-значения по умолчанию: - -- `metaA = 255` -- `metaB = 255` -- `metaC = 1.0f` -- `metaD = 0` - -## 3. Фазы материала - -```c -struct PhaseRecord34 { - uint8_t params[18]; - char textureName[16]; -}; -``` - -В рантайме запись разворачивается в структуру ~76 байт: - -- набор коэффициентов цвета/освещения/прозрачности; -- индекс слота текстуры; -- дополнительные целочисленные поля. - -`textureName`: - -- пустая строка -> фаза без текстуры (`texSlot = -1`); -- непустая строка -> загрузка текстуры по имени. - -## 4. Анимационные блоки - -```c -struct AnimBlockRaw { - uint32_t headerRaw; // mode = low 3 bits, interpMask = остальные - uint16_t keyCount; - KeyRaw keys[keyCount]; -}; - -struct KeyRaw { - uint16_t k0; - uint16_t k1; - uint16_t k2; // opaque, сохранять 1:1 -}; -``` - -`k2` нельзя удалять или нормализовать: это часть бинарного контракта. - -## 5. Выбор текущей фазы - -Материал выбирает фазу по времени и по режиму анимации блока: - -- loop; -- ping-pong; -- one-shot с clamp; -- random-offset. - -При смешивании интерполируется только часть полей, остальные копируются из активной фазы. -Для 1:1 совместимости важно сохранить эту выборочную интерполяцию. - -## 6. Загрузка и fallback - -При запросе материала по имени: - -1. Точный поиск по имени. -2. Если не найдено — fallback на `DEFAULT`. -3. Если `DEFAULT` отсутствует — используется запись с индексом `0`. - -## 7. Атрибуты и флаги - -Практически важные биты `attr1`: - -- бит загрузки текстурной фазы с расширенными флагами; -- флаги аппаратного профиля; -- 4-битный режим (`nibbleMode`); -- дополнительный флаг material-поведения. - -Неизвестные биты должны сохраняться без изменений. - -## 8. Ограничения - -- `animBlockCount < 20` -- `phaseCount` и фактический размер секции фаз должны совпадать -- `textureName` должен быть NUL-terminated и укладываться в 16 байт - -## 9. Правила writer/editor - -1. Сохранять `attr1/attr2/attr3`. -2. Не менять `metaA/B/C/D` без явного запроса. -3. Сохранять opaque-поля анимации (включая `k2`) 1:1. -4. Проверять выход за границы payload при парсинге. - -## 10. Статус валидации - -- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`). -- Структурная валидация MAT0 включена в корпусный прогон `tools/msh_doc_validator.py` на полном retail-наборе. - -## 11. Статус покрытия и что осталось до 100% - -Закрыто: - -1. Бинарный layout `MAT0` и правила чтения фаз/анимационных блоков. -2. Fallback-цепочка материала. -3. Контракт сохранения opaque-полей для lossless editor path. - -Осталось: - -1. Полная семантика всех битов `attr1` и `metaA/B/C/D` для авторинга новых материалов. -2. Полный writer-профиль «канонический MAT0» для генерации ассетов без copy-through. -3. Набор визуальных parity-тестов по material phase animation на реальных моделях. diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md deleted file mode 100644 index beef3ee..0000000 --- a/docs/specs/materials-texm.md +++ /dev/null @@ -1,18 +0,0 @@ -# Materials, WEAR, Texm - -Старая объединённая страница разбита по объектам. - -- [Material (`MAT0`)](material.md) -- [Wear table (`WEAR`)](wear.md) -- [Texture (`Texm`)](texture.md) -- [Render pipeline](render.md) - -## Статус покрытия и что осталось до 100% - -Закрыто: - -1. Страница корректно декомпозирована на отдельные объектные спецификации. - -Осталось: - -1. Поддерживать единый changelog согласованности между `material.md`, `wear.md`, `texture.md` и `render.md`. diff --git a/docs/specs/missions.md b/docs/specs/missions.md deleted file mode 100644 index f8b2cd4..0000000 --- a/docs/specs/missions.md +++ /dev/null @@ -1,46 +0,0 @@ -# Missions - -Подсистема `Missions` управляет сценарием: - -- стартовыми условиями; -- триггерами; -- победой/поражением; -- синхронизацией с AI/Behavior/World. - -## 1. Что уже зафиксировано - -1. Миссии связаны с картами (`Land.msh`/`Land.map`) и объектными категориями. -2. Скриптовые ресурсы хранятся в архивных контейнерах (`NRes`) и участвуют в runtime-логике. -3. Миссионные события влияют на AI и поведение объектов через общий gameplay-слой. - -## 2. Минимальный runtime-контракт - -1. Детерминированный порядок обработки триггеров в кадре. -2. Единая шкала времени миссии для всех подсистем. -3. Согласованность идентификаторов объектов между mission-data и world-state. - -## 3. Статус покрытия и что осталось до 100% - -Закрыто: - -- связь миссионной подсистемы с форматом ресурсов и runtime-контуром. - -Осталось: - -1. Полная спецификация форматов миссионных скриптов/таблиц. -2. Полный перечень типов триггеров и их параметров. -3. Формальные правила разрешения конфликтов триггеров в одном кадре. -4. Набор replay parity-тестов «миссия от старта до завершения». -## 4. Mission -> Prototype -> Mesh bridge - -Для 3D-объектов миссии обязательна промежуточная стадия `objects.rlb`: - -1. `data.tma` задаёт либо прямой ключ объекта, либо путь к `*.dat`. -2. `*.dat` даёт `model_key` (в retail-наборе через `objects.rlb`). -3. Ключ резолвится в запись прототипа внутри `objects.rlb`. -4. Из прототипа выбирается фактический `*.msh` и архив (например `bases.rlb`, `static.rlb`, `fortif.rlb`). -5. Только после этого запускается стандартная цепочка материалов и текстур. - -Детальный формат и алгоритм вынесены в отдельную страницу: - -- [Object registry (`objects.rlb`)](object-registry.md) diff --git a/docs/specs/msh-animation.md b/docs/specs/msh-animation.md deleted file mode 100644 index ec5a256..0000000 --- a/docs/specs/msh-animation.md +++ /dev/null @@ -1,126 +0,0 @@ -# MSH animation - -`MSH animation` описывает связку `Res8 + Res19` и runtime-правила сэмплирования/смешивания поз. - -Связанные страницы: - -- [MSH core](msh-core.md) -- [Render pipeline](render.md) - -## 1. Ресурсы анимации - -### 1.1. `Res8` (пул ключей) - -```c -struct AnimKey24 { - float pos_x; - float pos_y; - float pos_z; - float time; - int16_t qx; - int16_t qy; - int16_t qz; - int16_t qw; -}; -``` - -Декодирование quaternion-компонент: `q = s16 / 32767.0`. - -### 1.2. `Res19` (карта кадров) - -```c -uint16_t map_words[]; // size/2 элементов -``` - -`Res19.attr2` хранит глобальную длину таймлайна (число кадров). - -### 1.3. Связь с `Res1` - -Для каждого узла: - -- `anim_map_start` (`hdr2`) — начало блока в `Res19` или `0xFFFF`. -- `fallback_key` (`hdr3`) — индекс fallback-ключа в `Res8`. - -## 2. Сэмплирование узла - -Вход: время `t`, текущий узел. -Выход: `quat(w,x,y,z)` и `pos(x,y,z)`. - -### 2.1. Индекс кадра - -Движок использует x87-совместимое округление для выражения `t - 0.5`. -Для 1:1 повторения нужно сохранить ту же политику плавающей точки. - -### 2.2. Выбор key index - -1. Если кадр вне диапазона `frame_count` -> `fallback_key`. -2. Если `anim_map_start == 0xFFFF` -> `fallback_key`. -3. Иначе берётся `map_words[anim_map_start + frame]`: - - если значение `>= fallback_key`, тоже используется `fallback_key`; - - иначе используется значение из map. - -### 2.3. Интерполяция - -Если выбран fallback, возвращается ровно этот ключ без интерполяции. - -Иначе: - -1. Берутся соседние ключи `k0` и `k1`. -2. Если `t` точно равен `k0.time` или `k1.time`, возвращается соответствующий ключ. -3. Иначе: - - `alpha = (t - k0.time) / (k1.time - k0.time)` - - `pos = lerp(k0.pos, k1.pos, alpha)` - - `quat = slerp_like(k0.quat, k1.quat, alpha)` - -Кватернион в runtime хранится в порядке `[w, x, y, z]`. - -## 3. Смешивание двух сэмплов - -При blending между позами A и B: - -1. Выбираются валидные стороны по `blend` и валидности времени. -2. Если активна одна сторона, берётся она. -3. Если активны обе: - - применяется shortest-path flip для `qB`; - - выполняется quaternion blend; - - позиция смешивается линейно. - -Матрица строится из quaternion, а translation подставляется отдельным шагом. - -## 4. Каноника writer - -Рекомендуемые правила: - -1. Ключи узлов писать подряд в `Res8` в порядке узлов. -2. `fallback_key` узла указывает на последний ключ его трека. -3. Для узлов с map выделять блок длины `frame_count` в `Res19`. -4. Для статических узлов: `anim_map_start = 0xFFFF`, один ключ с `time=0`. -5. `Res8.attr1 = key_count`, `Res8.attr3 = 4`. -6. `Res19.attr1 = map_word_count`, `Res19.attr2 = frame_count`, `Res19.attr3 = 2`. - -## 5. Валидация перед сохранением - -- `Res8.size % 24 == 0` -- `Res19.size % 2 == 0` -- каждый `fallback_key < key_count` -- для узла с map: `anim_map_start + frame_count <= map_word_count` -- внутри трека времена ключей строго возрастают - -## 6. Статус валидации - -- Форматные проверки включены в `tools/msh_doc_validator.py`. -- Корпусная валидация анимационных инвариантов включена в прогон `tools/msh_doc_validator.py` на полном retail-наборе. - -## 7. Статус покрытия и что осталось до 100% - -Закрыто: - -1. Контракт `Res8 + Res19` и fallback-логика выбора ключа. -2. Базовая интерполяция поз и blending двух сэмплов. -3. Канонические инварианты writer path для существующих ассетов. - -Осталось: - -1. Полная фиксация численного поведения на всех FP-edge-case (включая платформенные различия округления). -2. Полный writer-профиль для авторинга новых анимаций без опоры на reference copy-through. -3. Набор runtime parity-тестов «frame-by-frame pose equivalence» на длинных анимациях. diff --git a/docs/specs/msh-core.md b/docs/specs/msh-core.md deleted file mode 100644 index 60a4453..0000000 --- a/docs/specs/msh-core.md +++ /dev/null @@ -1,193 +0,0 @@ -# MSH core - -`MSH core` описывает геометрию, слоты, батчи и базовые таблицы модели. -Документ покрывает контракт, необходимый для 1:1 воспроизведения рендера и коллизии. - -Связанные страницы: - -- [MSH animation](msh-animation.md) -- [Material](material.md) -- [Texture (Texm)](texture.md) -- [Render pipeline](render.md) -- [NRes](nres.md) -- [RsLi](rsli.md) - -## 1. Общая модель - -MSH-модель хранится как `NRes`-контейнер. -Связь таблиц строится по `type`, а не по порядку записей. - -Базовый путь геометрии: - -1. `Res1` выбирает slot по `(node, lod, group)`. -2. `Res2.slot` задаёт диапазоны треугольников и батчей. -3. `Res13` задаёт диапазон индексов и `baseVertex`. -4. `Res6` даёт `uint16` индексы. -5. `Res3/Res4/Res5` дают вершины, нормали и UV. - -## 2. Карта core-ресурсов - -| Type | Ресурс | Обязательность | Stride / layout | -|---:|---|---|---| -| 1 | Node table | обязательный | обычно 38 байт | -| 2 | Header + slots | обязательный | `0x8C + n*68` | -| 3 | Positions | обязательный | 12 | -| 4 | Packed normals | обычно обязательный | 4 | -| 5 | Packed UV0 | обычно обязательный | 4 | -| 6 | Index buffer | обязательный | 2 | -| 7 | Tri descriptors | для коллизии/пикинга | 16 | -| 8 | Anim key pool | для анимированных | 24 | -| 10 | Node strings | опциональный | variable | -| 13 | Batch table | обязательный | 20 | -| 15 | Доп. stream | опциональный | 8 | -| 16 | Доп. stream | опциональный | 8 | -| 18 | Доп. stream | опциональный | 4 | -| 19 | Anim map | для анимированных | 2 | -| 20 | Доп. таблица | опциональный | variable | - -## 3. Основные структуры - -### 3.1. `Res1` (узлы) - -```c -struct Node38 { - uint16_t hdr0; - uint16_t parent_or_link; - uint16_t anim_map_start; - uint16_t fallback_key; - uint16_t slotIndex[15]; // lod0:g0..g4, lod1:g0..g4, lod2:g0..g4 -}; -``` - -Формула slot-выбора: - -```c -slot = node.slotIndex[lod * 5 + group] -``` - -`0xFFFF` означает отсутствие слота. - -### 3.2. `Res2` (header + slot records) - -```c -struct Slot68 { - uint16_t triStart; - uint16_t triCount; - uint16_t batchStart; - uint16_t batchCount; - float aabbMin[3]; - float aabbMax[3]; - float sphereCenter[3]; - float sphereRadius; - uint32_t opaque[5]; -}; -``` - -`opaque[5]` должны сохраняться 1:1. - -### 3.3. `Res3`, `Res4`, `Res5`, `Res6` - -- `Res3`: `float3` позиции (`stride=12`) -- `Res4`: `int8[4]` packed normal (`stride=4`) -- `Res5`: `int16[2]` UV (`stride=4`) -- `Res6`: `uint16` индексы (`stride=2`) - -Декодирование: - -- normal = `clamp(n / 127.0, -1..1)` -- uv = `packed / 1024.0` - -### 3.4. `Res7` и `Res13` - -```c -struct TriDesc16 { - uint16_t triFlags; - uint16_t link0; - uint16_t link1; - uint16_t link2; - int16_t nx; - int16_t ny; - int16_t nz; - uint16_t selPacked; -}; - -struct Batch20 { - uint16_t batchFlags; - uint16_t materialIndex; - uint16_t opaque4; - uint16_t opaque6; - uint16_t indexCount; - uint32_t indexStart; - uint16_t opaque14; - uint32_t baseVertex; -}; -``` - -`selPacked` хранит 3 селектора по 2 бита; значение `3` трактуется как `0xFFFF`. - -## 4. Runtime-обход модели - -Псевдокод рендера: - -```c -for each node: - slot = resolve_slot(node, lod, group) - if slot == none: continue - - if culled(slot.bounds, node_transform): continue - - for b in slot.batchRange: - batch = batches[b] - bind_material(batch.materialIndex) - - draw_indexed( - baseVertex = batch.baseVertex, - indexStart = batch.indexStart, - indexCount = batch.indexCount - ) -``` - -## 5. Критические инварианты - -Обязательно проверять: - -- `Res2.size >= 0x8C` -- `(Res2.size - 0x8C) % 68 == 0` -- `batchStart + batchCount` не выходит за `Res13` -- `triStart + triCount` не выходит за `Res7` -- `indexStart + indexCount` не выходит за `Res6` -- `baseVertex + max(indexSlice) < vertexCount` -- `slotIndex == 0xFFFF` или `< slotCount` - -## 6. Важные edge-cases - -- Встречается редкий вариант `Res1.attr3 = 24`; для существующих ассетов нужен copy-through. -- Для строгого writer лучше генерировать `Res1` в основном формате `38` байт/узел. -- Неизвестные поля таблиц нельзя нормализовать или обнулять. - -## 7. Правила для writer/editor - -1. Сохранять неизвестные поля и неизвестные `type`-ресурсы. -2. Пересчитывать только явно вычислимые атрибуты (`attr1/attr3` и size-зависимые поля). -3. Не менять порядок/контент opaque-данных без явной цели. -4. Сериализовать little-endian, без внутреннего padding. - -## 8. Статус валидации - -- Инварианты формата реализованы в `tools/msh_doc_validator.py`. -- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `435/435` MSH-моделей без структурных ошибок. - -## 9. Статус покрытия и что осталось до 100% - -Закрыто: - -1. Базовые таблицы geometry path (`Res1/2/3/4/5/6/7/13`). -2. Критичные range-инварианты slot/batch/index. -3. Правила совместимого writer/editor для lossless работы с существующими ассетами. - -Осталось: - -1. Полная семантика части opaque-полей (`Slot68` tail, `Batch20` opaque-поля) для authoring без copy-through. -2. Полная формализация редких веток (`Res1.attr3 != 38`) на расширенном корпусе. -3. End-to-end writer для генерации новых игровых MSH с подтвержденным runtime-паритетом. - diff --git a/docs/specs/msh-notes.md b/docs/specs/msh-notes.md deleted file mode 100644 index 6e77c4f..0000000 --- a/docs/specs/msh-notes.md +++ /dev/null @@ -1,118 +0,0 @@ -# 3D implementation notes - -Контрольная страница с практическими правилами реализации 3D-пайплайна и с перечнем незакрытых зон. -Документ intentionally high-level: без ссылок на внутренние функции/адреса. - -Связанные страницы: - -- [MSH core](msh-core.md) -- [MSH animation](msh-animation.md) -- [Material (`MAT0`)](material.md) -- [Texture (`Texm`)](texture.md) -- [FXID](fxid.md) -- [Render pipeline](render.md) - -## 1. Базовые двоичные правила - -1. Все форматы в этой подсистеме little-endian. -2. Внутри NRes данные ресурсов выравниваются по 8 байт. -3. Внутри payload таблиц padding между записями обычно отсутствует: записи идут подряд по stride. - -## 2. Быстрая карта stride'ов - -| Ресурс | Запись | Stride | -|---|---|---:| -| Res1 | Node | 38 | -| Res2 | Slot | 68 (после header `0x8C`) | -| Res3 | Position | 12 | -| Res4 | Normal | 4 | -| Res5 | UV0 | 4 | -| Res6 | Index | 2 | -| Res7 | Tri descriptor | 16 | -| Res8 | Animation key | 24 | -| Res13 | Batch | 20 | -| Res19 | Animation map | 2 | - -## 3. Декодирование ключевых потоков - -## 3.1. Позиции (Res3) - -`float3`, stride `12`. - -## 3.2. Нормали (Res4) - -`int8[4]`, используются первые 3 компоненты: - -```text -n = clamp(s8 / 127.0, -1..1) -``` - -## 3.3. UV (Res5) - -`int16[2]`: - -```text -u = s16 / 1024.0 -v = s16 / 1024.0 -``` - -## 3.4. Animation key (Res8) - -`pos(float3) + time(float) + quat(int16x4)`: - -```text -q = s16 / 32767.0 -``` - -## 4. Практический reader-контракт - -Для runtime-совместимого чтения модели: - -1. Найти нужные ресурсы по `type_id` в NRes. -2. Проверить `size/stride`-инварианты. -3. Проверить диапазоны ссылок: - - slot -> batch/triangles; - - batch -> indices; - - indices -> vertices; - - anim_map -> anim_keys. -4. Неизвестные поля и неизвестные ресурсы сохранять через copy-through. - -## 5. Практический writer-контракт - -1. Пересчитывать только явно вычислимые поля. -2. Не нормализовать opaque-данные без уверенной спецификации. -3. При roundtrip неизмененных данных требовать byte-identical результат. -4. Для новых ассетов фиксировать отдельную политику «генерация vs preserve». - -## 6. Runtime-связка материалов и текстур - -Канонический путь резолва: - -1. Модель -> wear-таблица (`*.wea`). -2. Wear-слот -> material name. -3. Material -> текущая фаза -> `textureName`. -4. `Texm` ищется в `Textures.lib` (или lightmap-библиотеке для lightmap-ветки). - -Fallback: - -- материал: `DEFAULT`, затем индекс `0`; -- текстура/lightmap: fallback-слот движка. - -## 7. Что уже закрыто для 1:1 - -1. Бинарный контракт базовых MSH таблиц. -2. Контракт animation sampling (`Res8 + Res19`). -3. Контракт MAT0/WEAR/Texm на уровне чтения и применения в кадре. -4. Формат FXID-контейнера, командный поток и fixed command sizes. -5. Валидация на retail-корпусе через `tools/msh_doc_validator.py` (0 ошибок/предупреждений). - -## 8. Статус покрытия и что осталось до 100% - -1. Полная field-level семантика части служебных полей: - - `Batch20` opaque-поля; - - хвостовые служебные поля slot-записей; - - часть флагов узлов/групп. -2. Полный writer-путь для авторинга новых анимированных ассетов (не только roundtrip существующих). -3. Полная формализация семантики FX payload полей по каждому opcode для генерации новых эффектов, а не только для корректного чтения/исполнения. -4. Полный канонический writer `Texm` для всех редких форматов и edge-case комбинаций служебных флагов. -5. Сквозной «импорт внешнего ассета -> игровой пакет» с формальной спецификацией sidecar-метаданных (материал/эффект/анимация). diff --git a/docs/specs/msh.md b/docs/specs/msh.md deleted file mode 100644 index 0581502..0000000 --- a/docs/specs/msh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Форматы 3D-ресурсов движка NGI - -Этот документ теперь является обзором и точкой входа в набор отдельных спецификаций. - -## Структура спецификаций - -1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица. -2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция. -3. [Material (`MAT0`)](material.md) — формат материала и фазовая анимация. -4. [Wear (`WEAR`)](wear.md) — текстовая таблица привязки материалов/lightmap. -5. [Texture (`Texm`)](texture.md) — форматы текстур, mip-chain и `Page`. -6. [FXID](fxid.md) — контейнер эффекта и поток команд. -7. [Render pipeline](render.md) — полный процесс рендера кадра. -8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру. -9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы. -10. [Documentation coverage audit](coverage-audit.md) — сводка покрытия и оставшиеся блокеры. - -## Связанные спецификации - -- [NRes](nres.md) -- [RsLi](rsli.md) - -## Принцип декомпозиции - -- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо. -- 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-контрактов. diff --git a/docs/specs/network.md b/docs/specs/network.md deleted file mode 100644 index 9411c34..0000000 --- a/docs/specs/network.md +++ /dev/null @@ -1,28 +0,0 @@ -# Network system - -`Network` — подсистема синхронизации состояния игры между узлами (мультиплеер/обмен состоянием). - -## 1. Роль - -1. Транспортирует игровые события и state-delta. -2. Синхронизирует критичные объекты мира и таймеры. -3. Обеспечивает согласованность simulation между участниками. - -## 2. Минимальный контракт для 1:1 - -1. Детеминированная сериализация сетевых сообщений. -2. Согласованная обработка порядка/потерь/повторов пакетов. -3. Единая политика authority и коррекции расхождений. - -## 3. Статус покрытия и что осталось до 100% - -Закрыто: - -- определено место сетевого слоя в общей архитектуре движка. - -Осталось: - -1. Полная спецификация wire-протокола (header, message types, payload layout). -2. Полный контракт handshake/session lifecycle. -3. Формальные правила resync/rollback/correction. -4. Набор сетевых parity-тестов на контролируемой потере/задержке. diff --git a/docs/specs/nres.md b/docs/specs/nres.md deleted file mode 100644 index bb31823..0000000 --- a/docs/specs/nres.md +++ /dev/null @@ -1,202 +0,0 @@ -# NRes - -`NRes` — базовый контейнер ресурсов движка Parkan: Iron Strategy. -Страница фиксирует формат на диске и runtime-контракт чтения/поиска/сохранения в высокоуровневом виде, без привязки к внутренним адресам и именам из дизассемблера. - -Связанная страница: - -- [RsLi](rsli.md) - -## 1. Назначение - -`NRes` используется как универсальный архив: - -- 3D-модели (`*.msh`, `*.rlb`); -- текстуры (`Texm`); -- материалы (`MAT0`); -- эффекты (`FXID`); -- миссионные и служебные ресурсы. - -Формат поддерживает: - -- чтение; -- поиск по имени; -- редактирование (add/replace/remove); -- полную пересборку архива. - -## 2. Общий layout файла - -```text -[Header: 16] -[Data region: variable, 8-byte aligned chunks] -[Directory: entry_count * 64, всегда в конце файла] -``` - -Критично: каталог всегда расположен в конце файла. - -## 3. Заголовок (16 байт) - -Все значения little-endian. - -| 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 - directory_size`. - -Ограничения: - -- `directory_offset >= 16`; -- `directory_offset + directory_size == total_size`. - -## 4. Запись каталога (64 байта) - -| Offset | Size | Type | Поле | -|---:|---:|---|---| -| 0 | 4 | u32 | `type_id` | -| 4 | 4 | u32 | `attr1` | -| 8 | 4 | u32 | `attr2` | -| 12 | 4 | u32 | `size` (размер payload) | -| 16 | 4 | u32 | `attr3` | -| 20 | 36 | char[36] | `name_raw` (C-строка) | -| 56 | 4 | u32 | `data_offset` | -| 60 | 4 | u32 | `sort_index` | - -### 4.1. Имя ресурса (`name_raw`) - -Контракт: - -- максимум 35 полезных байт + NUL; -- допускается ровно один терминатор внутри 36-байтового поля; -- имя сравнивается регистронезависимо по ASCII-правилу (`A..Z` -> `a..z`). - -Для writer/editor: - -- запрещено писать NUL внутри полезной части имени; -- запрещены имена длиной > 35 байт. - -### 4.2. Диапазон данных (`data_offset`, `size`) - -Для каждой записи: - -- `data_offset >= 16`; -- `data_offset + size <= directory_offset`. - -Практически (канонический writer): каждый payload начинается с 8-байтного выравнивания. - -## 5. Таблица сортировки (`sort_index`) - -`sort_index` задает перестановку «отсортированный список -> исходный индекс записи». - -Пусть: - -- `entries[i]` — i-я запись каталога в исходном порядке; -- `P` — массив индексов `0..entry_count-1`, отсортированный по `entries[idx].name` (ASCII case-insensitive). - -Тогда в канонической записи: - -- `entries[i].sort_index = P[i]`. - -Это именно таблица для бинарного поиска по имени, а не «ранг текущей записи». - -## 6. Поиск по имени - -Алгоритм поиска: - -1. Выполнить бинарный поиск по диапазону `i in [0, entry_count)`. -2. На шаге `i` взять `target = entries[i].sort_index`. -3. Сравнить искомое имя с `entries[target].name` (ASCII case-insensitive). -4. При совпадении вернуть `target`. - -Fail-safe поведение: - -- если `sort_index` некорректен (выход за диапазон), реализация должна перейти на линейный fallback по всем записям; -- fallback использует то же ASCII case-insensitive сравнение. - -## 7. Каноническая пересборка архива - -Канонический writer выполняет: - -1. Пишет заглушку заголовка (16 байт). -2. Пишет payload всех записей в текущем порядке. -3. После каждого payload добавляет 0-padding до кратности 8. -4. Пересчитывает `sort_index` через сортировку имен. -5. Дописывает каталог (`entry_count * 64`). -6. Пересчитывает и записывает `total_size`. - -Итоговый файл должен удовлетворять всем ограничениям из разделов 3–5. - -## 8. Режим `raw` (совместимость инструментов) - -Для служебных инструментов допускается `raw_mode`: - -- любой бинарный файл трактуется как один «сырой» ресурс; -- возвращается одна запись (`name = RAW`, `data_offset = 0`, `size = len(file)`). - -Этот режим не является форматом `NRes` на диске, это только режим открытия. - -## 9. Контрольные инварианты - -Минимальный набор проверок при чтении: - -1. `magic == "NRes"`. -2. `version == 0x100`. -3. `entry_count >= 0`. -4. `header.total_size == file_size`. -5. Каталог находится в конце файла. -6. Для каждой записи диапазон данных не пересекает каталог. -7. Имя корректно C-терминировано и не длиннее 35 байт. - -Минимальный набор проверок при записи: - -1. Все имена <= 35 байт и без внутренних NUL. -2. `sort_index` формирует валидную перестановку `0..N-1`. -3. Все паддинги между payload состоят из нулевых байт. -4. `total_size` равен фактической длине выходного файла. - -## 10. Эмпирическая проверка на retail-корпусе - -Валидация на полном наборе `testdata/Parkan - Iron Strategy`: - -- найдено `120` архивов `NRes`; -- roundtrip `unpack -> repack -> byte-compare`: `120/120` совпали побайтно; -- критических расхождений формата не обнаружено. - -Инструмент: - -- `tools/archive_roundtrip_validator.py` - -## 11. Статус покрытия и что осталось до 100% - -Закрыто: - -- формат заголовка/каталога; -- правила поиска; -- каноническая пересборка; -- строгие инварианты валидатора; -- побайтовый roundtrip на retail-корпусе. - -Осталось до полного 100% архитектурного покрытия движка: - -1. Формальная семантика `attr1/attr2/attr3` для всех типов ресурсов (частично вынесена в профильные страницы `msh`, `material`, `texture`, `fxid`, `terrain`). -2. Полная спецификация поведения при не-ASCII именах (в реальных игровых архивах используется ASCII-практика; для Unicode-коллации движок не документирован). -3. Полная спецификация платформенных гарантий атомарной записи (формат данных закрыт, но OS-уровневые гарантии замены файла зависят от платформы и файловой системы). -## 12. Специализация `objects.rlb` - -Хотя `objects.rlb` формально является обычным `NRes`, его payload имеет отдельный семантический контракт: - -- запись каталога соответствует одному объектному прототипу; -- payload записи - массив фиксированных ссылок `ObjectRef64` (`archive_name[32] + resource_name[32]`); -- runtime-резолв меша выполняется через эти ссылки, а не через имя entry `*.msh` внутри `objects.rlb`. - -Это означает, что `objects.rlb` должен рассматриваться не как архив мешей, а как реестр привязок между mission/unit-ключами и фактическими ресурсами. - -См. детальную страницу: - -- [Object registry (`objects.rlb`)](object-registry.md) diff --git a/docs/specs/object-registry.md b/docs/specs/object-registry.md deleted file mode 100644 index 0e6e2dd..0000000 --- a/docs/specs/object-registry.md +++ /dev/null @@ -1,145 +0,0 @@ -# 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-паритета. diff --git a/docs/specs/render-parity.md b/docs/specs/render-parity.md deleted file mode 100644 index 8955414..0000000 --- a/docs/specs/render-parity.md +++ /dev/null @@ -1,90 +0,0 @@ -# Рендер-паритет (кадровый diff) - -Документ описывает процесс проверки соответствия рендера: -`оригинальный движок -> эталонный кадр -> render-demo -> diff-метрики`. - -## Цель - -- Зафиксировать объективный критерий "паритет достигнут / не достигнут". -- Убрать субъективную визуальную оценку "похоже/не похоже". -- Дать CI-проверку, которая ловит регрессии сразу после коммита. - -## Единица проверки - -Один тест-кейс = один объект (одна модель) + фиксированная конфигурация: - -- архив ресурса; -- имя модели; -- `lod`; -- `group`; -- размер кадра (`width`, `height`); -- угол камеры (`angle`); -- PNG-эталон из оригинального рендера. - -## Инварианты детерминизма - -Для корректного сравнения кадры должны быть сняты в одинаковых условиях: - -- одинаковый FOV и расстояние камеры до объекта; -- одинаковый clear-color/фон; -- одинаковые `lod/group`; -- фиксированный угол (`angle`), без анимации; -- фиксированное разрешение. - -## Метрики сравнения - -Сравнение выполняется по RGB-каналам: - -- `mean_abs`: средняя абсолютная разница канала (0..255); -- `max_abs`: максимальная разница канала; -- `changed_ratio`: доля пикселей, где хотя бы один канал превышает `diff_threshold`. - -Кейс считается пройденным, если: - -- `mean_abs <= max_mean_abs`; -- `changed_ratio <= max_changed_ratio`. - -## Конфигурация кейсов - -Файл: `parity/cases.toml`. - -- секция `[meta]`: глобальные дефолты; -- `[[case]]`: параметры конкретной модели и путь к эталонному PNG. - -Эталонные кадры хранятся в `parity/reference/`. - -## Локальный запуск - -```bash -cargo run -p render-parity -- \ - --manifest parity/cases.toml \ - --output-dir target/render-parity/current -``` - -При расхождении утилита пишет diff-изображение в: - -- `target/render-parity/current/diff/<case>.png` - -## CI-модель - -CI запускает `render-parity` на каждом push/PR: - -1. собирает `parkan-render-demo`; -2. прогоняет кейсы из `cases.toml`; -3. при падении публикует текущие кадры и diff как артефакт. - -Важно: оригинальный движок в CI обычно не запускается. -Эталонные PNG снимаются офлайн и версионируются в репозитории. - -## Статус покрытия и что осталось до 100% - -Закрыто: - -1. Определена метрика сравнения кадров (`mean_abs`, `max_abs`, `changed_ratio`). -2. Описан единый manifest-формат кейсов и CI-процедура. - -Осталось: - -1. Снять и зафиксировать расширенный эталонный набор кадров оригинала (10-20+ ключевых моделей и режимов). -2. Зафиксировать пороговые критерии pass/fail по каждому классу сцен (статик, анимация, FX, lightmap). -3. Добавить автоматическую публикацию diff-артефактов и регрессионных отчетов в CI. diff --git a/docs/specs/render.md b/docs/specs/render.md deleted file mode 100644 index ccc941b..0000000 --- a/docs/specs/render.md +++ /dev/null @@ -1,182 +0,0 @@ -# Render pipeline - -Документ описывает полный процесс рендера кадра в движке Parkan: Iron Strategy, без привязки к внутренним адресам/именам дизассемблера. - -Связанные страницы: - -- [MSH core](msh-core.md) -- [MSH animation](msh-animation.md) -- [Material (`MAT0`)](material.md) -- [Wear table (`WEAR`)](wear.md) -- [Texture (`Texm`)](texture.md) -- [FXID](fxid.md) - -## 1. Инициализация рендера - -На старте движок: - -1. Выбирает видеодрайвер (software или аппаратный). -2. Создаёт render backend. -3. Подключает библиотеки ресурсов: - - `Material.lib` - - `Textures.lib` - - `LightMap.lib` - - `palettes.lib` -4. Инициализирует менеджеры: - - material manager - - texture/lightmap cache - - effect manager -5. Загружает базовые world-ресурсы (включая наборы объектов сцены). - -## 2. Структура кадра - -Кадр выполняется как последовательность: - -1. `Simulation update` -2. `Animation sampling` -3. `Visibility / culling` -4. `Material + texture resolve` -5. `Mesh draw` -6. `FX update + draw` -7. `UI/overlay draw` -8. `Present` - -## 3. Geometry path - -### 3.1. Подготовка инстансов - -Для каждого видимого объекта: - -1. Вычисляется `world transform`. -2. Выбирается `LOD`. -3. Для каждого узла выбирается slot через `Res1`. - -### 3.2. Culling - -Сначала отсекаются узлы/слоты по bounds (`AABB/sphere`) из `Res2`. - -### 3.3. Батчи - -Для каждого прошедшего slot: - -1. Берутся батчи из диапазона `Res13`. -2. По `materialIndex` выбирается активный материал. -3. По фазе материала выбирается текстура/lightmap. -4. Выполняется `DrawIndexedPrimitive`: - - индексный диапазон: `indexStart/indexCount` - - базовая вершина: `baseVertex` - - индексы читаются из `Res6` - - вершины/атрибуты читаются из `Res3/Res4/Res5` (+ optional streams) - -## 4. Animation path - -Для анимированных моделей: - -1. Для узла выбирается ключ через `Res19` и fallback-логику. -2. Декодируются `pos + quat` из `Res8`. -3. При необходимости выполняется blending двух сэмплов. -4. Узловая матрица передаётся в geometry path. - -## 5. Material path - -Material pipeline на кадре: - -1. По material handle выбирается запись `MAT0`. -2. По игровому времени выбирается текущая фаза. -3. Применяются коэффициенты фазы (цвет/альфа/параметры). -4. Резолвятся ссылки на texture/lightmap. -5. Невалидные ссылки обрабатываются fallback-стратегией. - -Практическая цепочка привязки для большинства `*.msh` ассетов из `*.rlb`: - -1. Для модели выбирается одноимённый `WEAR` (`<model_stem>.wea`). -2. Из `WEAR` берётся material-слот (по имени, `legacyId` не участвует в выборе). -3. В `Material.lib` ищется `MAT0` по имени (`DEFAULT`, затем индекс `0` как fallback). -4. Из выбранной material-фазы берётся `textureName`. -5. `Texm` ищется в `Textures.lib` (и/или lightmap-архиве для lightmap-ветки). - -## 6. Texture path - -При резолве текстуры: - -1. Ищется `Texm` entry по имени. -2. Проверяется и декодируется заголовок. -3. При необходимости применяется `mipSkip`. -4. Для indexed-формата подключается палитра. -5. Optional `Page` chunk интерпретируется как atlas-таблица. -6. Объект текстуры кладётся/берётся из cache. - -## 7. FX path - -Эффекты выполняются параллельно mesh-рендеру: - -1. Для активных инстансов FX вычисляется runtime-коэффициент (`time_mode + flags`). -2. Команды FX обновляют внутреннее состояние. -3. Команды emit-этапа формируют примитивы/батчи эффектов. -4. Эффекты рисуются в 3D-кадре с собственным счётчиком батчей. - -## 8. Псевдокод кадра - -```c -void RenderFrame(Scene* scene, Camera* cam, float dt) { - UpdateGame(scene, dt); - - for (Object* obj : scene->objects) { - if (!obj->visible) continue; - - UpdateObjectAnimation(obj, scene->time); - BuildObjectNodeTransforms(obj); - } - - BeginFrame(cam); - - for (Object* obj : scene->objects) { - if (!obj->visible) continue; - RenderObjectMeshes(obj, cam); - } - - UpdateAndRenderFx(scene, dt, cam); - RenderUI(scene); - Present(); -} -``` - -## 9. Критичные условия для 1:1 - -1. Та же политика округления/FP для анимации и FX. -2. Та же логика fallback по материалам и текстурам. -3. Та же очередность стадий кадра. -4. Тот же контракт интерпретации `Res1/Res2/Res13/Res6`. -5. Тот же контракт `FXID` командного потока. - -## 10. Статус валидации - -- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущей runtime-валидацией проекта. -- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`. - -## 11. Статус покрытия и что осталось до 100% - -Закрыто: - -1. Высокоуровневый кадр: simulation -> animation -> culling -> material/texture resolve -> mesh draw -> fx -> ui -> present. -2. Связка MSH/MAT0/WEAR/Texm/FXID в едином runtime-процессе. -3. Форматная валидация входных данных на полном retail-корпусе. - -Осталось: - -1. Полный pixel-parity контур с эталонными кадрами оригинального рендера по набору моделей/сцен. -2. Формализация всех render-state деталей (точные blend/depth/cull/state transitions) для гарантии 1:1 в каждом draw-pass. -3. Полный coverage-пакет по динамическим веткам (FX-heavy кадры, сложные material-режимы, lightmap-комбинации). - -## 12. Object registry bridge (`objects.rlb`) - -Для миссионного/юнитного рендера критично учитывать промежуточный слой прототипов: - -1. `TMA`/`*.dat` обычно дают не прямой `*.msh`, а ключ прототипа. -2. Ключ резолвится через `objects.rlb` (реестр ссылок на реальные архивы ресурсов). -3. Только после этого выполняется стандартный путь: - `MSH -> WEAR -> MAT0 -> Texm`. - -Детальная спецификация этого шага вынесена в отдельную страницу: - -- [Object registry (`objects.rlb`)](object-registry.md) diff --git a/docs/specs/rsli.md b/docs/specs/rsli.md deleted file mode 100644 index 298cf2a..0000000 --- a/docs/specs/rsli.md +++ /dev/null @@ -1,230 +0,0 @@ -# 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-диапазон). diff --git a/docs/specs/runtime-pipeline.md b/docs/specs/runtime-pipeline.md deleted file mode 100644 index fb8af06..0000000 --- a/docs/specs/runtime-pipeline.md +++ /dev/null @@ -1,18 +0,0 @@ -# Runtime pipeline - -Актуальный документ по полному кадру находится здесь: - -- [Render pipeline](render.md) - -Эта страница оставлена как совместимый указатель для старых ссылок. - -## Статус покрытия и что осталось до 100% - -Закрыто: - -1. Актуальный runtime-пайплайн централизован в `render.md`. - -Осталось: - -1. Поддерживать обратную совместимость ссылок при дальнейшей декомпозиции render-документа. - diff --git a/docs/specs/sound.md b/docs/specs/sound.md deleted file mode 100644 index 360f590..0000000 --- a/docs/specs/sound.md +++ /dev/null @@ -1,32 +0,0 @@ -# Sound system - -`Sound` — подсистема аудио: - -- загрузка и кеширование звуковых ресурсов; -- воспроизведение SFX/voice/music; -- пространственное позиционирование и микширование. - -## 1. Архитектурная роль - -1. Получает события от gameplay/FX/mission/UI. -2. Резолвит аудиоресурсы через архивные библиотеки. -3. Управляет каналами, приоритетами и жизненным циклом источников звука. - -## 2. Минимальный runtime-контракт - -1. Стабильный выбор источника и fallback при отсутствии ресурса. -2. Детерминированные правила приоритета при переполнении каналов. -3. Согласованная модель пространственного затухания и панорамирования. - -## 3. Статус покрытия и что осталось до 100% - -Закрыто: - -- место аудио-подсистемы в общем runtime-контуре. - -Осталось: - -1. Полная спецификация форматов аудио-ресурсов и lookup-таблиц. -2. Полный контракт 2D/3D микширования и лимитов каналов. -3. Правила взаимодействия с FXID-командами, которые инициируют звук. -4. Набор audio parity-тестов (тайминг/громкость/панорама). diff --git a/docs/specs/terrain-map-loading.md b/docs/specs/terrain-map-loading.md deleted file mode 100644 index 62c1e0a..0000000 --- a/docs/specs/terrain-map-loading.md +++ /dev/null @@ -1,293 +0,0 @@ -# Terrain + ArealMap - -Документ описывает подсистему ландшафта и ареалов мира в движке Parkan: Iron Strategy: - -- `Land.msh` (terrain-геометрия и вспомогательные таблицы); -- `Land.map` (ареалы и навигационные связи); -- `BuildDat.lst` (категории объектных зон). - -Описание дано в высокоуровневом переносимом виде, без ссылок на внутренние адреса и имена из дизассемблера. - -Связанные страницы: - -- [NRes](nres.md) -- [RsLi](rsli.md) -- [MSH core](msh-core.md) -- [Render pipeline](render.md) - -## 1. End-to-End загрузка уровня - -Для каждой карты движок загружает пару файлов: - -- `.../Land.msh` -- `.../Land.map` - -Высокоуровневый порядок: - -1. Открыть `Land.msh` как `NRes`. -2. Прочитать обязательные terrain-chunk'и. -3. Построить runtime-структуры terrain (slots, faces, spatial grid). -4. Открыть `Land.map` как `NRes`. -5. Найти единственный chunk `type=12`. -6. Прочитать ареалы, их связи и cell-grid. -7. Применить инициализацию объектных категорий из `BuildDat.lst`. - -## 2. Формат `Land.msh` - -`Land.msh` — обычный `NRes` архив с фиксированным набором terrain-ресурсов. - -## 2.1. Состав chunk'ов - -Обязательные типы: - -- `1`, `2`, `3`, `4`, `5`, `11`, `18`, `21` - -Опциональные типы: - -- `14` - -Наблюдаемый retail-порядок chunk'ов: - -```text -[1, 2, 3, 4, 5, 18, 14, 11, 21] -``` - -## 2.2. Stride и атрибуты - -| Type | Назначение | Stride | -|---:|---|---:| -| 1 | node/slot матрица | 38 | -| 3 | позиции вершин | 12 | -| 4 | нормали (packed) | 4 | -| 5 | UV (packed) | 4 | -| 11 | cell-ускоритель | 4 | -| 14 | доп. поток | 4 | -| 18 | доп. поток | 4 | -| 21 | terrain face | 28 | - -Общее правило для этих chunk'ов: - -- `attr1 == size / stride` -- `attr3 == stride` - -## 2.3. Type `2`: slot table - -`type=2` содержит: - -- заголовок `0x8C` байт; -- затем таблицу slots по `68` байт. - -Инварианты: - -- `size >= 0x8C` -- `(size - 0x8C) % 68 == 0` -- `attr1 == (size - 0x8C) / 68` -- `attr3 == 68` - -## 2.4. Type `21`: terrain face (28 байт) - -Высокоуровневая структура face: - -- флаги face; -- индексы треугольника (`i0, i1, i2`); -- индексы соседей (`n0, n1, n2`, значение `0xFFFF` = нет соседа); -- служебные поля (материал/класс/edge-поля и др.). - -Критичные проверки: - -- `i0/i1/i2 < vertex_count` (`type=3`); -- `nX == 0xFFFF` или `nX < face_count`. - -## 2.5. Маски face и compact-представления - -В рантайме используются: - -- полная 32-битная маска (`full`); -- компактные представления (`compactMain16`, `compactMaterial6`). - -Подтвержденный remap `full -> compactMain16`: - -| Full bit | Compact bit | -|---:|---:| -| `0x00000001` | `0x0001` | -| `0x00000008` | `0x0002` | -| `0x00000010` | `0x0004` | -| `0x00000020` | `0x0008` | -| `0x00001000` | `0x0010` | -| `0x00004000` | `0x0020` | -| `0x00000002` | `0x0040` | -| `0x00000400` | `0x0080` | -| `0x00000800` | `0x0100` | -| `0x00020000` | `0x0200` | -| `0x00002000` | `0x0400` | -| `0x00000200` | `0x0800` | -| `0x00000004` | `0x1000` | -| `0x00000040` | `0x2000` | -| `0x00200000` | `0x8000` | - -Подтвержденный remap `full -> compactMaterial6`: - -| Full bit | Compact bit | -|---:|---:| -| `0x00000100` | `0x01` | -| `0x00008000` | `0x02` | -| `0x00010000` | `0x04` | -| `0x00040000` | `0x08` | -| `0x00080000` | `0x10` | -| `0x00000080` | `0x20` | - -Для 1:1 реализации нужно поддерживать оба представления и обратное восстановление `compact -> full`. - -## 2.6. Type `11` и cell-ускоритель terrain - -`type=11` служит источником cell-ускорителя для terrain-запросов. - -Практические требования для editor/toolchain: - -- не переупорядочивать содержимое без полного пересчета зависимых таблиц; -- сохранять служебные/неизвестные поля побайтно; -- выполнять валидацию диапазонов face/slot после любых правок. - -## 3. Формат `Land.map` (chunk `type=12`) - -`Land.map` — `NRes`, содержащий ровно один ресурс `type=12`. - -Контракт верхнего уровня: - -- `entry.attr1` = `areal_count`; -- payload включает: - - `areal_count` переменных записей ареалов; - - затем grid-секцию cell-попаданий. - -## 3.1. Запись ареала - -Старт записи: - -```c -float anchor_x; // +0 -float anchor_y; // +4 -float anchor_z; // +8 -float reserved_12; // +12 -float area_metric; // +16 -float normal_x; // +20 -float normal_y; // +24 -float normal_z; // +28 -uint32_t logic_flag; // +32 -uint32_t reserved_36; // +36 -uint32_t class_id; // +40 -uint32_t reserved_44; // +44 -uint32_t vertex_count; // +48 -uint32_t poly_count; // +52 -``` - -Далее: - -1. `float3 vertices[vertex_count]` -2. `EdgeLink8 links[vertex_count + 3 * poly_count]`, где - `EdgeLink8 = { int32 area_ref; int32 edge_ref; }` -3. для каждого полигона block: - - `uint32 n` - - `4 * (3*n + 1)` байт данных полигона - -## 3.2. Семантика edge-link - -Для `links[0 .. vertex_count-1]`: - -- `(-1, -1)` означает «соседа нет»; -- иначе `area_ref` указывает на индекс соседнего ареала, `edge_ref` — на ребро в соседнем ареале. - -## 3.3. Grid-секция после ареалов - -Формат: - -```c -uint32 cellsX; -uint32 cellsY; -for (x=0; x<cellsX; x++) { - for (y=0; y<cellsY; y++) { - uint16 hitCount; - uint16 areaIds[hitCount]; - } -} -``` - -В runtime существует упакованное cell-meta представление: - -- high 10 бит: `hitCount`; -- low 22 бита: `startIndex` (в общем `areaIds` пуле). - -## 3.4. Валидация целостности chunk 12 - -Обязательные проверки: - -- `areal_count > 0`; -- `cellsX > 0 && cellsY > 0`; -- каждый `area_id` из cell-списков `< areal_count`; -- все `area_ref/edge_ref` валидны относительно целевых ареалов; -- полный объем прочитанных байт должен точно совпасть с размером payload. - -## 4. `BuildDat.lst` - -Используются 12 объектных категорий ареалов: - -| Имя | Маска | -|---|---:| -| `Bunker_Small` | `0x80010000` | -| `Bunker_Medium` | `0x80020000` | -| `Bunker_Large` | `0x80040000` | -| `Generator` | `0x80000002` | -| `Mine` | `0x80000004` | -| `Storage` | `0x80000008` | -| `Plant` | `0x80000010` | -| `Hangar` | `0x80000040` | -| `MainTeleport` | `0x80000200` | -| `Institute` | `0x80000400` | -| `Tower_Medium` | `0x80100000` | -| `Tower_Large` | `0x80200000` | - -Файл должен парситься строго секционно; поврежденный формат считается ошибкой. - -## 5. Требования к reader/writer/editor - -1. Сохранять порядок и бинарную форму chunk'ов, если не выполняется осознанная нормализация. -2. Все неизвестные поля хранить и писать побайтно (`preserve-as-is`). -3. После правок пересчитывать только вычислимые поля, не «чистить» opaque-данные. -4. Проверять диапазоны индексов между связанными таблицами (`nodes/slots/faces/vertices/areas/cells`). -5. Для неизмененных ресурсов обеспечивать byte-identical roundtrip. - -## 6. Эмпирическая верификация (retail) - -Валидация на `testdata/Parkan - Iron Strategy`: - -- карт: `33` -- `Land.msh`: `33/33` валидны -- `Land.map`: `33/33` валидны -- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0` - -Подтвержденные наблюдения: - -- `Land.msh` порядок chunk'ов стабилен: `[1,2,3,4,5,18,14,11,21]`; -- `Land.map` всегда содержит один chunk `type=12`; -- `cellsX == cellsY == 128` во всех retail-картах; -- `poly_count == 0` во всем проверенном retail-корпусе; -- `normal` имеет длину ~1.0; -- `reserved_12`, `reserved_36`, `reserved_44` в retail наблюдаются как `0`. - -Инструмент: - -- `tools/terrain_map_doc_validator.py` - -## 7. Статус покрытия и что осталось до 100% - -Закрыто: - -- бинарный контракт `Land.msh` и `Land.map`; -- диапазонные и структурные инварианты; -- remap масок `full/compact`; -- валидация на полном retail-корпусе карт. - -Осталось до полного 100% архитектурного покрытия движка: - -1. Полная доменная семантика `class_id` и `logic_flag` (игровые значения/поведенческие правила). -2. Полная спецификация ветки `poly_count > 0` на живых данных (в retail не встречена). -3. Полная field-level семантика части битов `TerrainFace28.flags` (бинарный контракт и remap закрыты, но не все биты имеют документированные геймплейные имена). diff --git a/docs/specs/texture.md b/docs/specs/texture.md deleted file mode 100644 index b43ab1a..0000000 --- a/docs/specs/texture.md +++ /dev/null @@ -1,153 +0,0 @@ -# Texture (`Texm`) - -`Texm` — основной формат текстур движка. - -Связанные страницы: - -- [Material (`MAT0`)](material.md) -- [Wear table (`WEAR`)](wear.md) -- [Render pipeline](render.md) - -## 1. Контейнер - -- Тип ресурса: `0x6D786554` (`Texm`). -- Используется в `Textures.lib`, `LightMap.lib` и других `NRes` архивах. - -## 2. Заголовок - -```c -struct TexmHeader32 { - uint32_t magic; // 'Texm' - uint32_t width; - uint32_t height; - uint32_t mipCount; - uint32_t flags4; - uint32_t flags5; - uint32_t unk6; - uint32_t format; -}; -``` - -## 3. Поддерживаемые форматы - -Базовые форматы: - -- `0` (8-bit indexed + palette) -- `565` -- `4444` -- `888` -- `8888` - -Дополнительные ветки загрузки поддерживают также `556` и `88`. - -## 4. Layout payload - -1. `TexmHeader32` (32 байта) -2. palette `1024` байта, если `format == 0` -3. mip-chain пикселей -4. optional `Page` chunk - -Расчёт ядра: - -```c -bytesPerPixel = - (format == 0) ? 1 : - (format == 565 || format == 556 || format == 4444 || format == 88) ? 2 : - 4; - -pixelCount = sum(max(1, width>>i) * max(1, height>>i), i=0..mipCount-1); -sizeCore = 32 + (format==0 ? 1024 : 0) + bytesPerPixel * pixelCount; -``` - -## 4.1. Декодирование в RGBA8 (runtime/инструменты) - -Для CPU-пути (preview, валидация, оффлайн-конвертация) используется декодирование: - -- `0` (`Indexed8`): `index -> palette[index]` (`RGBA` из палитры 256×4). -- `565`: `R5 G6 B5`, `A=255`. -- `556`: `R5 G5 B6`, `A=255`. -- `4444`: `A4 R4 G4 B4` (с расширением 4-битных каналов в 8-битные). -- `88`: `L8 A8` (`R=G=B=L`). -- `888`: `R8 G8 B8` + padding/служебный байт, `A=255`. -- `8888`: `A8 R8 G8 B8`. - -Это декодирование соответствует текущему test/demo pipeline проекта. - -## 5. `Page` chunk - -```c -struct PageChunk { - uint32_t magic; // 'Page' - uint32_t rectCount; - Rect16 rects[rectCount]; -}; - -struct Rect16 { - int16_t x; - int16_t w; - int16_t y; - int16_t h; -}; -``` - -`Page` задаёт atlas-прямоугольники для выборки под-областей текстуры. - -## 6. Mip-skip политика - -Загрузчик может пропускать первые mip-уровни в зависимости от: - -- `flags5`, -- размеров текстуры, -- количества mip. - -После `mipSkip`: - -- уменьшаются `width/height/mipCount`; -- сдвигается начало пиксельных данных; -- `Page`-координаты пересчитываются в соответствии с новым базовым уровнем. - -## 7. Палитры - -Для части текстур движок связывает палитру по суффиксу имени. - -Практический формат: - -- буква `A..Z` + вариант `""` или `0..9` -- всего `26 * 11 = 286` возможных слотов палитр. - -Невалидные суффиксы нужно считать ошибкой входных данных в инструментах. - -## 8. Кэширование - -Движок ведёт отдельные кэши: - -- общий texture cache; -- lightmap cache. - -Для обычных текстур используется отложенный сбор неиспользуемых слотов (по времени нулевого refcount). - -## 9. Правила writer/editor - -1. Не нормализовать `flags4/flags5/unk6`. -2. Сохранять payload без лишних хвостовых байт. -3. Если есть `Page`, его размер должен быть ровно `8 + rectCount * 8`. -4. Проверять `width > 0`, `height > 0`, `mipCount > 0`. - -## 10. Статус валидации - -- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`. -- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `518/518` текстурных payload (`Texm`) без ошибок. - -## 11. Статус покрытия и что осталось до 100% - -Закрыто: - -1. Заголовок `Texm`, mip-chain layout и `Page` chunk. -2. Базовые decode-пути в RGBA8 для проверок/preview. -3. Корпусная валидация структурных инвариантов. - -Осталось: - -1. Полная формальная спецификация всех редких служебных комбинаций `flags4/flags5/unk6`. -2. Канонический writer для полного набора форматов (`indexed`, `565`, `556`, `4444`, `88`, `888`, `8888`) с проверенным roundtrip-профилем. -3. Pixel-parity тесты «оригинальный рендер vs новый рендер» с учетом mipSkip/atlas-page веток. diff --git a/docs/specs/ui.md b/docs/specs/ui.md deleted file mode 100644 index bb915cb..0000000 --- a/docs/specs/ui.md +++ /dev/null @@ -1,33 +0,0 @@ -# UI system - -`UI` — подсистема интерфейса: - -- экранные панели и HUD; -- меню; -- шрифты; -- minimap и служебные оверлеи. - -## 1. Архитектурная роль - -1. Работает поверх render-пайплайна как отдельный этап кадра. -2. Использует UI-ресурсы из архивных библиотек. -3. Перехватывает пользовательский ввод по правилам фокуса. - -## 2. Минимальный runtime-контракт - -1. Детерминированный порядок draw-проходов UI. -2. Консистентный фокус и приоритет ввода (UI vs world). -3. Стабильная загрузка font/minimap/ui-ресурсов по именам. - -## 3. Статус покрытия и что осталось до 100% - -Закрыто: - -- позиция UI-слоя в общем кадре и его связи с render/input. - -Осталось: - -1. Полная спецификация форматов UI layout и контролов. -2. Полный контракт ресурсов шрифтов и text-rendering поведения. -3. Формат minimap-данных и правила трансформации координат. -4. UI parity-тесты (скриншотные и событийные). diff --git a/docs/specs/wear.md b/docs/specs/wear.md deleted file mode 100644 index e969f9c..0000000 --- a/docs/specs/wear.md +++ /dev/null @@ -1,96 +0,0 @@ -# Wear table (`WEAR`) - -`WEAR` — текстовый ресурс, который связывает слоты wear с именами материалов и lightmap. - -Связанные страницы: - -- [Material (`MAT0`)](material.md) -- [Texture (`Texm`)](texture.md) - -## 1. Контейнер - -- Тип ресурса: `0x52414557` (`WEAR`). -- Обычно хранится как `*.wea` внутри world/mission архивов. - -## 2. Формат текста - -```text -<wearCount:int> -<legacyId:int> <materialName> -... (wearCount строк) - -[пустая строка] -[LIGHTMAPS -<lightmapCount:int> -<legacyId:int> <lightmapName> -... (lightmapCount строк)] -``` - -`legacyId` читается, но логика выбора работает по имени. - -## 3. Совместимость парсинга - -В движке используются два режима чтения (`из файла` и `из буфера`), у которых различается обработка блока `LIGHTMAPS`. - -Практическое правило для полного совпадения: - -- если присутствует блок `LIGHTMAPS`, перед строкой `LIGHTMAPS` должна быть пустая строка-разделитель. - -## 4. Runtime-ограничения - -- Число wear-таблиц в менеджере ограничено: максимум `70`. -- Для `wearCount <= 0` ресурс считается некорректным. -- Для `LIGHTMAPS` блока `lightmapCount <= 0` — также ошибка формата. - -## 5. Поведение резолва - -### 5.1. Материал - -Для каждого wear-слота: - -1. Ищется материал по имени. -2. Если не найден — используется fallback (`DEFAULT`, затем индекс 0). - -### 5.2. Lightmap - -Для каждого lightmap-слота: - -1. Ищется текстура lightmap по имени. -2. Если не найдено — слот получает `-1`. - -## 6. Handle-кодирование - -Движок кодирует ссылку на material-slot как: - -```c -handle = (tableIndex << 16) | wearIndex -``` - -- `tableIndex` — номер wear-таблицы. -- `wearIndex` — индекс строки внутри таблицы. - -## 7. Правила writer/editor - -1. Сохранять порядок строк. -2. Не переставлять и не нормализовать `legacyId`. -3. Для совместимости buffer-парсинга сохранять пустую строку перед `LIGHTMAPS`. -4. Проверять, что число строк соответствует `wearCount`/`lightmapCount`. - -## 8. Статус валидации - -- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном. -- Корпусные проверки связки `WEAR -> MAT0 -> Texm` включены в текущий валидаторный контур проекта. - -## 9. Статус покрытия и что осталось до 100% - -Закрыто: - -1. Текстовый формат `WEAR`, включая блок `LIGHTMAPS`. -2. Handle-кодирование material slot и fallback-резолв. -3. Правила совместимого writer/editor path. - -Осталось: - -1. Полная спецификация edge-case форматов строк (кодировки, редкие разделители, возможные legacy-варианты). -2. Формализация всех ограничений менеджера wear-таблиц в runtime (лимиты и политики вытеснения). -3. Интеграционные parity-тесты на полном цикле «модель -> wear -> material -> texture/lightmap». diff --git a/docs/tomes/01-guide.md b/docs/tomes/01-guide.md new file mode 100644 index 0000000..cc4e4c0 --- /dev/null +++ b/docs/tomes/01-guide.md @@ -0,0 +1,371 @@ +# I. Путеводитель и методика + +Первый том задаёт язык и правила всей документации. Он объясняет, как читать +технические главы, какие термины используются для игрового runtime, как +разделяются уровни уверенности и какие требования предъявляются к реализации, +которая должна работать с оригинальными данными без потери информации. + +Документация рассчитана на разработчика, который уже умеет читать C/C++, +байтовые форматы, PE-модули и графические pipeline, но не обязательно знаком с +Iron3D. Поэтому этот том не описывает один конкретный crate, package или +физическое деление будущего кода. Он фиксирует контракты: что должно быть +прочитано, сохранено, рассчитано и показано. + +## Назначение книги + +Книга ведёт от общей архитектуры Iron3D к точным форматам данных и алгоритмам +исполнения. Практическая цель -- реализация, способная открыть оригинальный +каталог *Parkan: Iron Strategy*, загрузить миссию, создать мир, провести +игровой шаг и сформировать кадр. + +Форматы в главах описываются как байтовые контракты. Если указано поле +`+0x10`, это означает расположение в потоке или структуре данных, а не +разрешение читать файл прямым `reinterpret_cast`. Для постоянных layouts +используются offsets, проверки размеров, bounded cursor и явное сохранение +неизвестных байтов. Для versioned и variable-length записей приоритет имеет +последовательный parser с контролем границ. + +Игровое поведение описывается не только размером структур. Совместимая +реализация должна учитывать порядок событий, время, fallback-правила, +идентификаторы объектов, численные ограничения, состояние материалов, +границы кадра и правила завершения операций. + +## Маршруты чтения + +**Читатель, новый для игровой разработки**, начинает с базовых понятий этого +тома, затем переходит к архитектуре, игровому циклу и вводу в рендер. После +этого имеет смысл читать главы о миссиях, мире и ресурсных форматах. + +**Разработчик совместимого движка** читает тома II-VII линейно. Технические +главы имеют одинаковую логику: назначение подсистемы, данные на диске, +представление в памяти, алгоритм работы, проверки и требования к новой +реализации. + +**Аналитик оригинальной программы** использует этот том вместе с разделами о +доказательной базе, ABI, результатах корпусных проверок и границах знания. +Факты, согласованные выводы и открытые вопросы должны оставаться разделёнными: +это позволяет расширять реализацию без подмены проверенных контрактов +удобными догадками. + +## Состав документации + +1. **Путеводитель и методика** -- язык предметной области, правила чтения и + процедура проверки. +2. [**Запуск, архитектура и игровой цикл**](02-architecture.md) -- от + `iron_3d.exe` до расчёта и вывода кадра. +3. [**Ресурсная система и форматы**](03-resources.md) -- архивы, кэши, реестры + и служебные данные. +4. [**Мир, миссии и игровой runtime**](04-world.md) -- TMA, ландшафт, ареалы и + создание объектов. +5. [**Геометрия, материалы и рендер**](05-render.md) -- от вершины модели до + изображения на экране. +6. [**Поведение, управление, звук и сеть**](06-behavior.md) -- интерактивные + подсистемы. +7. [**Руководство по полной реализации**](07-implementation.md) -- предлагаемая + архитектура и порядок работ. +8. [**Справочник и доказательная база**](08-evidence.md) -- ABI, + конфигурация, статистика и открытые вопросы. + +Дополнительные краткие определения собраны в +[глоссарии](../appendices/glossary.md). Технические области, где контракт ещё +не закрыт полностью, перечислены в +[границах знания](../appendices/knowledge-boundaries.md). + +## Условные обозначения + +`+0x10` означает смещение поля относительно начала структуры или записи. +`RVA 0x13B60` -- адрес относительно базы PE-модуля. `u16`, `u32`, `i16` и +`float32` обозначают типы фиксированной ширины. `LE` означает little-endian. +`payload` -- полезные данные записи после метаданных контейнера. `EOF` -- точное +завершение файла или ограниченного блока. + +Если в тексте указан hash, RVA или ordinal, значение относится к явно +обозначенному binary profile. Адреса разных сборок не объединяются по имени +функции. При публикации функции нужны минимум модуль, SHA-256 сборки и RVA. + +Размеры структур выражаются в байтах. Счётчики и offsets считаются частью +формата, даже когда их можно восстановить из длины файла. Padding, reserved +поля, неизвестные хвосты и gaps не нормализуются без доказанного правила. + +## Совместимость + +Слово "совместимость" в этой книге имеет несколько уровней. + +**Reader** умеет открыть файл, проверить границы, извлечь известные поля и +сохранить неизвестные bytes так, чтобы данные можно было записать обратно. + +**Viewer** умеет показать ресурс: модель, texture, material, эффект или карту. +Viewer может быть полезен для анализа, но он не доказывает поведение runtime. + +**Runtime** умеет создать мир, зарегистрировать объекты, исполнять события, +обновлять время, применять контроллеры, выбирать видимое состояние и передавать +его рендеру. + +**Полноценный движок** дополнительно воспроизводит порядок операций, численные +правила, fallback-поведение, resource lifetime, reference ownership, pause, +manual input, сетевые идентификаторы, boundaries кадра и состояние +интерактивных подсистем. + +Поэтому файл может быть "прочитан правильно", но всё ещё не быть реализованным +на уровне движка. Например, reader MSH может восстановить вершины и индексы, +viewer может нарисовать mesh, а runtime обязан ещё сохранить material slots, +animation state, bounds, LOD, visibility, collision и связи с объектом мира. + +## Движок как программа длительного действия + +Обычная прикладная программа получает запрос, вычисляет результат и заканчивает +работу. Игра живёт в цикле: прочитать ввод, обновить состояние мира, +сформировать звук и изображение, показать кадр и повторить. Движок -- набор +подсистем и соглашений, которые делают этот цикл устойчивым. + +**Simulation** отвечает на вопрос "что произошло в мире": куда переместился +объект, кого он видит, сколько у него здоровья, сработал ли эффект, изменился +ли маршрут или приказ. **Rendering** отвечает на другой вопрос: "как текущее +состояние показать". В корректной архитектуре рендер не решает игровые правила, +а читает подготовленное состояние. + +**Tick** -- один шаг расчёта. **Frame** -- одно изображение. Они могут +выполняться с разной частотой: игра способна рассчитать несколько шагов между +двумя показами или временно не рисовать, не останавливая логику. Поэтому время, +накопление input, порядок callbacks и момент удаления объектов считаются частью +контракта. + +## Мир, сцена и объект + +**Мир** -- долгоживущее состояние миссии: ландшафт, объекты, время, погода, +принадлежность к кланам и глобальные сервисы. **Сцена** -- представление той +части мира, которую можно обработать для текущей камеры. **Игровой объект** -- +сущность с идентификатором, положением, набором свойств и поведением. + +В Iron3D объектами управляет World3D. Объекты регистрируются в общей очереди, +получают события, участвуют в расчёте и могут быть удалены отложенно, чтобы не +разрушить обход коллекции посреди шага. Это важнее, чем конкретный контейнер в +новой реализации: совместимость определяется моментом наблюдаемого добавления, +обновления и удаления. + +Мир не равен renderer scene graph. Один объект может иметь runtime state, +controller, сетевой mirror, визуальную модель, collision bounds и script state. +Часть этих данных нужна для gameplay, часть -- для вывода, часть -- для +сохранения и воспроизведения. + +## Ресурс, модель и материал + +**Ресурс** -- именованный блок данных, который можно найти и загрузить. Архивы +`NRes` и `RsLi` содержат таблицы таких блоков. Имя, индекс, размер, offset, +compression method и fallback-правило являются частью контракта загрузки. + +**Модель** описывает форму объекта. Она состоит из вершин, индексов, узлов, +групп треугольников, слотов материалов и auxiliary streams. **Vertex** хранит +положение и обычно дополнительные атрибуты: нормаль для освещения и +UV-координату для выборки texture. **Triangle** -- три вершины, образующие +примитив. **Index buffer** хранит номера вершин и позволяет переиспользовать их +между треугольниками. **Batch** -- непрерывный диапазон индексов, который +рисуется одним материалом и одним набором состояний. + +**Материал** описывает способ отображения поверхности: texture references, +цвет, прозрачность, режимы смешивания и анимацию параметров. **Texture** -- +изображение в памяти графической системы. **Mip-уровни** -- уменьшенные копии +изображения для дальних объектов. **Lightmap** -- дополнительная texture с +заранее рассчитанным освещением. + +Runtime должен связывать эти уровни по цепочке: миссия выбирает объект, объект +ссылается на prototype, prototype приводит к модели, модель -- к WEAR, +материалам, textures и lightmaps. Ошибка на любом участке этой цепочки может +не проявиться в parser-е, но проявится в игровом кадре. + +## Пространственные понятия + +**Transform** переводит точку из локальных координат модели в координаты мира, +камеры и экрана. **Иерархия узлов** позволяет одному элементу наследовать +движение другого. **LOD** выбирает менее подробную геометрию вдали. **Culling** +отбрасывает то, что не видно. **Bounds** -- упрощённая оболочка объекта, +обычно сфера или AABB, используемая для быстрых тестов. + +**Collision** отвечает на геометрические пересечения. **Navigation** ищет +допустимый маршрут. В Iron3D эти задачи разделены: Control обслуживает +физическую модель и столкновения, а ArealMap хранит пространственные области и +связи между ними. + +Важно не смешивать визуальные и игровые упрощения. Render bounds могут быть +достаточны для отсечения, но не обязаны совпадать с collision shape. Навигация +может использовать areal graph, который не является ни mesh-ем модели, ни +геометрией ландшафта в renderer-е. + +## Графический конвейер + +Процессор выбирает видимые объекты, готовит матрицы, материалы и списки +примитивов. Графический backend передаёт вершины, индексы, textures и state +драйверу. Видеокарта преобразует вершины в координаты экрана, разбивает +треугольники на фрагменты, проверяет глубину, смешивает цвет и записывает +результат в буфер кадра. После завершения буфер становится видимым +пользователю. + +Для совместимости важны не только данные draw call. Контракт включает frame +boundaries, viewport, camera state, порядок world traversal, material resolve, +shadow/transparent/FX subpasses, завершение renderer-а, восстановление state и +callbacks после рендера. Если часть имён vtable slots ещё не доказана, новая +реализация должна фиксировать крупный порядок операций и оставлять +детализацию проверяемой. + +## Практический словарь реализации + +**Handle** -- компактная ссылка на управляемый объект. **Cache** -- сохранённый +результат загрузки или декодирования. **Reference count** -- число владельцев +ресурса. **Fallback** -- предписанный запасной вариант при отсутствии данных. +**Invariant** -- условие, которое всегда должно быть истинным для корректного +файла или runtime-состояния. **Determinism** -- повторяемость результата при +одинаковых входных данных и порядке событий. + +**Strict mode** -- режим parser-а, который принимает только корректный файл: +верные magic, версии, размеры, ranges, индексы и точный EOF. **Lossless mode** +-- режим чтения/записи, который сохраняет неизвестные поля, padding, gaps и raw +payload без нормализации. **Quirk** -- именованное отклонение, разрешённое +только после проверки на реальных данных или исполняемом коде. + +Эти слова используются как технические термины. Если глава называет значение +fallback-ом, invariant-ом или quirk-ом, это должно иметь проверяемое +последствие в reader-е, writer-е или runtime. + +## Как читать C/C++-схемы структур + +Структуры в главах описывают байтовый layout, а не переносимый C++ object +model. Если поля на диске идут без padding, reader должен читать их по offsets +либо использовать явно проверенный packed layout. Прямое отображение native +struct допустимо только при доказанном размере, выравнивании и endian-правиле. + +`sizeof` обязательно проверяется `static_assert` или эквивалентным compile-time +test. Это особенно важно для records, где 32-битное поле начинается после +нечётного числа 16-битных или 8-битных полей: стандартное выравнивание +современного compiler-а может вставить скрытые bytes и изменить offsets. + +Для variable-length форматов предпочтителен bounded cursor: + +1. Прочитать header и проверить минимальный размер. +2. Проверить, что offsets и sizes лежат внутри текущего блока. +3. Прочитать таблицы до объявленного count, не до "пока получается". +4. Проверить ссылки между таблицами. +5. Дойти до точного EOF или сохранить явно разрешённый trailing payload. + +Writer пересчитывает только производные значения: размеры, offsets, число +записей, сортировочные таблицы и padding, если правило доказано. Unknown fields +и reserved ranges сохраняются побайтно. + +## Иерархия доказательств + +Документация использует четыре уровня уверенности. + +**Прямое наблюдение** -- поле, значение или последовательность видны в +инструкции программы, таблице PE, экспорте, строке, обработчике файла или в +самом ресурсе. Это самый сильный уровень. + +**Корпусное подтверждение** -- правило проверено на всех подходящих файлах +одного или нескольких явно названных наборов: демоверсии, Части 1 и Части 2. +Например, базовый корпус содержит 435 моделей MSH, 518 textures Texm и 923 +эффекта FXID, прошедших структурные проверки без ошибок; полные части расширяют +эту матрицу вариантов. + +**Согласованный вывод** -- назначение восстановлено по нескольким независимым +признакам: вызывающим функциям, vtable slots, строкам ошибок, диапазонам +значений и связям между форматами. Такой вывод пригоден для реализации, но его +численные детали следует проверять тестами. + +**Открытый вопрос** -- данные можно читать и сохранять, однако предметный смысл +поля или редкой ветки не доказан. Такие bytes нельзя обнулять, +переупорядочивать или превращать в authoring API. + +Уровень уверенности должен быть виден из формулировки. "Поле равно" означает +проверенный layout или значение. "Вероятно отвечает за" означает согласованный +вывод. "Неизвестно" означает сохранять без изменения и не строить вокруг этого +публичный контракт. + +## Проверенные материалы + +Локальный набор проверки включает демоверсию, полные каталоги Частей 1 и 2, +исполняемые файлы, 15 DLL каждой сборки и игровые ресурсы. DLL из +первоначального архива и DLL демоверсии совпали по SHA-256: `15/15`, поэтому +выводы по этому коду и demo-ресурсам образуют один доказательный профиль. + +Исполняемый файл демоверсии `iron_3d.exe` имеет размер 36 864 байта, PE32/x86, +entry RVA `0x141E`, image base `0x400000` и SHA-256 +`b0a8b0db1c3a8698c4d4604d89c655496bd91ac1f8859a455e8a45838aebfbd6`. + +Исполняемые файлы Частей 1 и 2 также имеют размер 36 864 байта и побайтно +совпадают между собой, но относятся к другому binary profile: entry RVA +`0x147E`, SHA-256 +`f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7`. + +Полные каталоги Частей 1 и 2 суммарно включают 60 TMA, 1 101 unit DAT, 254 +NRes-файла и 14 975 NRes entries. Все контейнеры и TMA прошли bounded parser до +точного EOF; полный достижимый граф обеих частей разрешился без ошибок. + +## Процедура проверки + +Проверка строится как воспроизводимая цепочка: + +1. Снять PE-метаданные, хэши, импорты, экспорты, ordinals, RTTI и строки. +2. Построить граф вызовов между модулями и отметить фабрики подсистем. +3. Разобрать функции запуска, загрузчики файлов, главный цикл и критические + vtable-вызовы. +4. Проверить форматы независимыми reader-скриптами с контролем границ и точного + завершения файла. +5. Построить цепочку миссия -> объект -> прототип -> модель -> материал -> + texture. +6. Сравнить счётчики, диапазоны, ссылки и размеры на всём доступном корпусе. + +Ключевой результат сквозной проверки демо-миссий: все 201 объектов шести +миссий разрешились в 501 запрос прототипов, затем в 501 модель, 501 таблицу +WEAR, 3 879 слотов материалов и 5 085 ссылок на textures или lightmaps. Ошибок +в фактически исполняемом пути нет. + +## Что не считается доказательством + +Удобное имя поля не доказывает его назначение. Совпадение layout с текущей +реализацией не доказывает поведение оригинального runtime. Успешный viewer не +доказывает writer. Успешный reader одного файла не доказывает формат всего +корпуса. Совпадение ABI не доказывает побайтную идентичность всех сборок. + +Если локальные данные и предположение расходятся, приоритет имеют исполняемый +код, реальные ресурсы и взаимные invariants между форматами. Неизвестное поле +лучше оставить без имени, чем дать ему ложное предметное значение. + +## Требования к воспроизводимости + +Каждая новая реализация должна иметь strict parser mode, lossless roundtrip +mode и набор corpus tests. Неизвестные поля сохраняются побайтно. Любое +присвоенное полю имя должно сопровождаться наблюдаемым поведением или тестом. +Численные правила -- округление, порядок умножения, RNG и время -- считаются +частью формата исполнения, даже если файл читается правильно. + +Минимальный отчёт проверки должен фиксировать: + +1. build profile и hashes модулей; +2. путь или ключ ресурса; +3. размер входного файла и hash входных bytes; +4. версию parser-а или commit реализации; +5. список включённых quirks; +6. число прочитанных записей и точку EOF; +7. ошибки, предупреждения и unknown ranges; +8. результат roundtrip, если writer участвует в проверке. + +Для runtime-проверок дополнительно нужны mission key, configuration, device +profile, начальное состояние, input/time script и trace значимых callbacks. + +## Разделение профилей + +Binary profile описывает исполняемый код: PE-метаданные, exports/imports, +ordinals, hashes, RVA и layout функций. Corpus profile описывает набор файлов: +каталог, миссии, ресурсы, размеры, counts, variants и статистику parser-а. + +Эти профили нельзя смешивать без явной пометки. Один и тот же формат может +иметь общий смысл в разных сборках, но отличаться редкими ветками, адресами +функций или набором встреченных вариантов. Один и тот же address может иметь +смысл только внутри конкретного module hash. + +При расширении документации новое утверждение должно отвечать на три вопроса: + +1. Где это видно напрямую? +2. На каком корпусе это проверено? +3. Что должна сделать реализация, если правило нарушено? + +Если на один из вопросов нет ответа, утверждение остаётся согласованным выводом +или открытым вопросом, а не закрытым контрактом. diff --git a/docs/tomes/02-architecture.md b/docs/tomes/02-architecture.md new file mode 100644 index 0000000..b44bd51 --- /dev/null +++ b/docs/tomes/02-architecture.md @@ -0,0 +1,472 @@ +# II. Запуск, архитектура и игровой цикл + +Этот том описывает путь от запуска `iron_3d.exe` до устойчивого кадра: +загрузку `iron3d.dll`, создание shell/game objects, поднятие платформенных +сервисов, запуск World3D, расчёт simulation step, безопасное удаление объектов, +рендер и завершение программы. + +Главная особенность Iron3D -- это не один монолитный engine object, а связка +небольшого Win32 bootstrap и набора DLL, которые обмениваются фабриками, +singleton-интерфейсами и C++ vtable. Совместимая реализация может изменить +физическое деление на библиотеки, но не может произвольно менять порядок +инициализации, object identity, правила владения, fallback ресурсов и порядок +событий. + +```text +iron_3d.exe + -> iron3d.dll + -> services.dll + -> World3D.dll + -> Terrain.dll + -> Ngi32.dll + -> AniMesh.dll / ArealMap.dll / Effect.dll + -> ai.dll / Behavior.dll / Wizard.dll + -> Control.dll / MisLoad.dll / Net.dll / Joystick.dll +``` + +## Карта модулей + +Во внешней архитектуре обнаружено пятнадцать DLL. Экспортов сравнительно мало: +они обычно создают объект, возвращают singleton или дают доступ к уже поднятой +подсистеме. Основная работа выполняется через C++-интерфейсы, поэтому порядок +виртуальных слотов является частью ABI, особенно для compatibility shim эпохи +MSVC6. + +```text +iron_3d.exe + | + v +iron3d.dll -- композиция игры, shell и главный цикл + | + +-- services.dll -- доступ к display, GUI, ресурсам, звуку, таймеру и сети + +-- World3D.dll -- объекты, очередь, время, камера и кадр + +-- Terrain.dll -- ландшафт, свет, атмосфера и визуальный слой мира + +-- ai.dll / Behavior.dll / Wizard.dll + +-- Control.dll / Effect.dll / MisLoad.dll + +-- Net.dll / Joystick.dll + +-- Ngi32.dll -- ресурсы, графика, звук, математика и CPU dispatch +``` + +Циклы импортов между DLL ожидаемы. Terrain создаёт визуальные объекты и +обращается к World3D, а World3D получает world-interface из Terrain. Это не +значит, что обе библиотеки совместно владеют всем состоянием. Реальные границы +задаются интерфейсами, refcount, очередью объектов и порядком shutdown. + +Практичная новая структура может быть внутренним набором модулей `platform`, +`resources`, `world`, `mission`, `terrain`, `render`, `animation`, `effects`, +`behavior`, `physics`, `audio` и `network`. Важно сохранить не DLL-границы, а +контракты: имена ресурсов, порядок поиска, fallback-ветки, object ID, момент +создания mirror objects, численное поведение и последовательность событий. + +## Роли модулей + +`iron3d.dll` создаёт shell и game objects, читает `iron_3d.ini`, поднимает +display, sound, CD-audio, network и настройки World3D, загружает миссионные и +UI-конфигурации, содержит message pump и вызывает расчёт/рендер игры. + +`services.dll` работает как service locator. Через него запрашиваются display, +GUI, network manager, resource manager, sound server и timer. Этот слой отделяет +высокоуровневую игру от деталей создания устройств. + +`World3D.dll` -- центральный runtime: очередь объектов, идентификаторы, +события, отложенное удаление, game time, pause, manual input, камера, +material/texture/lightmap managers, сетевые mirrors, расчёт и 3D-проход. + +`Terrain.dll` отвечает не только за землю. В его область входят ландшафт, +здания, визуальный слой мира, камера, shade/state layer, primitive buffers, +сортировочные слои, источники света, тени, microtextures, атмосфера, дождь, +молнии, солнце и flares. + +`Ngi32.dll` содержит низкоуровневые сервисы: DirectDraw/Direct3D-era renderer, +DirectSound, readers `NRes`/`RsLi`, память, часы, математику, пересечения, +определение CPU и таблицу быстрых процедур `g_FastProc`. + +Предметные DLL закрывают отдельные области. `AniMesh.dll` загружает модели и +агентов. `ArealMap.dll` строит spatial graph и маршруты. `Behavior.dll` +реализует поведение юнитов. `ai.dll` содержит стратегический AI и миссионные +сценарии. `Wizard.dll` корректирует локальное движение. `Control.dll` +обслуживает физическую модель и столкновения. `Effect.dll` создаёт runtime-FX. +`MisLoad.dll` читает миссионные данные. `Net.dll` инкапсулирует DirectPlay. +`Joystick.dll` работает через DirectInput. + +## Поток данных + +Миссия не создаёт готовый кадр напрямую. Данные проходят через несколько +уровней: описание объекта, прототипы, ресурсы, runtime-object, контроллеры, +simulation state, render items и только затем платформенный renderer. + +```text +mission data + -> object identity and properties + -> prototype registry + -> model/material/texture/effect resources + -> World3D object + domain controllers + -> simulation state + -> visible render items + -> Ngi32 render interface + -> DirectX-era device +``` + +Этот поток объясняет, почему нельзя объединять физический архив, metadata entry, +декодированный payload и готовый runtime-кэш. У каждого уровня свой срок жизни, +собственный refcount и собственные ошибки. Детали ресурсного конвейера описаны +в [Томе III](03-resources.md), а сборка мира из миссии -- в [Томе IV](04-world.md). + +## Bootstrap + +`iron_3d.exe` -- небольшой PE32/x86 bootstrap размером 36 864 байта. Основная +игровая логика находится в `iron3d.dll`. Исполняемый файл создаёт Win32-процесс, +подготавливает окружение, загружает библиотеку и получает восемь публичных +точек входа: + +```text +createShell deleteShell +createGame deleteGame +createSubsystems deleteSubsystems +getIGame getIShell +``` + +Эти функции образуют внешнюю границу игры. `createShell` создаёт оболочку +интерфейса и меню, `createGame` -- объект игровой логики, `createSubsystems` -- +аппаратные и runtime-сервисы. Getter-функции возвращают уже созданные объекты. + +Запуск удобно читать как конечный автомат: + +```text +PROCESS_CREATED + -> LIBRARY_READY + -> ENTRYPOINTS_READY + -> SHELL_CREATED + -> GAME_CREATED + -> SUBSYSTEMS_READY + -> MAIN_LOOP + -> SUBSYSTEMS_CLOSED + -> GAME_DELETED + -> SHELL_DELETED +``` + +Каждый переход имеет обратное действие. Если display, sound или другой +обязательный сервис не создан, главный цикл не начинается, но уже созданные +объекты освобождаются в обратном порядке. Новая оболочка запуска должна +работать из каталога оригинальной установки, сохранять смысл относительных +путей, создавать окно до графической подсистемы и закрывать частично поднятые +сервисы без предположения, что init дошёл до конца. + +Bootstrap обеих полных частей побайтно одинаков, хотя файл второй части может +иметь другое имя: + +```text +size 36 864 +entry RVA 0x147E +SHA-256 f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7 +``` + +Следовательно, различия полных частей начинаются после передачи управления DLL +и игровым данным. Адреса executable демоверсии относятся к другой binary +profile и не должны переноситься на полные версии без проверки hash. + +## Инициализация подсистем + +Iron3D разделяет создание высокоуровневых объектов и создание подсистем. +`createShell` конструирует оболочку пользовательского интерфейса, `createGame` +создаёт объект игры, а `createSubsystems` связывает их с display, sound, +network и World3D. + +Высокоуровневая последовательность выглядит так: + +```text +прочитать iron_3d.ini + -> получить display service + -> создать окно и графическое устройство + -> проверить доступность 3D-драйвера + -> выбрать CURRENT_D3DCARD + -> получить sound service и настроить громкость + -> создать network instance и передать application GUID + -> создать World3D game settings +``` + +Ошибка отсутствующего 3D-устройства обрабатывается отдельно от ошибок ресурсов: +это разные стадии запуска. Конфигурация влияет не только на разрешение. В +runtime попадают графическая карта, громкость эффектов, CD-audio, режим +CD-sound, сетевое приложение и World3D settings. Application GUID сетевой +подсистемы: + +```text +{3C1D1F01-A870-11D1-8400-000021B14415} +``` + +Один и тот же GUID передаётся сетевому объекту и service layer. Если он +разойдётся, экземпляры игры станут логически разными приложениями, даже при +исправном транспорте. + +## `stdInitGame` + +После платформенных сервисов World3D создаёт внутренний runtime: + +1. Создаёт глобальную очередь объектов. +2. Сохраняет window handle и режим игры. +3. При нужном режиме ограничивает курсор областью окна. +4. Получает или создаёт 3D sound object. +5. Загружает реестр адресов компонентов из `Comp.ini`. +6. Получает или создаёт 3D renderer. +7. Читает профиль возможностей renderer. +8. Загружает component type 6. +9. Для multiplayer создаёт NetWatcher. +10. Получает world-interface из Terrain. +11. Устанавливает исходные параметры света и тумана. + +Порядок важен. World objects не должны появляться до queue, ресурсы рендера -- +до renderer, сетевые mirror objects -- до NetWatcher. В новой реализации у +каждого этапа должен быть явный признак успешного создания, чтобы shutdown мог +безопасно разобрать неполный init. + +## Завершение + +Shutdown идёт в обратном направлении: прекращаются игровые расчёты и сетевые +наблюдатели, разбираются отложенные операции, освобождаются world objects и +менеджеры, затем renderer и sound, затем game settings и platform services. +Ограничение курсора снимается, глобальные ссылки очищаются. + +Полезный протокол завершения: + +1. Запретить новые события и новые объекты. +2. Дождаться выхода из calculation/render traversal. +3. Разобрать очередь deferred operations. +4. Отсоединить объекты от очереди, контроллеров и менеджеров. +5. Освободить managers и singletons после их consumers. +6. Закрыть устройства и платформенные сервисы. + +Такой порядок защищает от dangling-ссылок между World3D, Terrain, renderer, +sound и сетевым слоем. + +## Главный цикл + +Главный цикл -- не одна функция `update_and_render`, а расписание, связывающее +Win32 messages, input, игровые события, таймеры, сеть и renderer. Системная +очередь сообщает об активации окна, вводе, изменении состояния процесса и +выходе. Очередь World3D рассчитывает игровые объекты. У этих очередей разные +правила времени и владения, поэтому их нельзя смешивать в один контейнер. + +Подтверждённые точки вызова в одном из профилей: + +```text +stdCalculateGame RVA 0x5FA94, 0x604C1, 0x6086B +ClearManualEventsList RVA 0x6052F +stdRenderGame RVA 0x60B2F +UpdateManualEventsList в обработчике сообщений около RVA 0xA3759 +``` + +Смысловой skeleton: + +```c +while (running) { + stdCalculateGame(); + clear_keyboard_snapshot(); + update_shell_and_mode(); + ClearManualEventsList(); + process_window_messages(); + update_timers_ui_gameplay_network(); + if (mode_requires_extra_step) stdCalculateGame(); + if (render_enabled) { + stdSetCurrentCamera(camera); + stdRenderGame(camera); + } else { + sleep_briefly(); + } + update_post_render_state(); +} +``` + +Ввод из window messages накапливается между расчётными шагами. Если читать +клавиатуру только внутри рендера, события будут теряться при пропущенных кадрах +или отключённом выводе. + +## `stdCalculateGame` + +Calculation pass сначала очищает или подготавливает список manual events, +увеличивает внутренний depth/counter и опрашивает input device. Если устройство +временно потеряно, выполняется повторное получение доступа и чтение повторяется. +Затем при незамороженной игре выставляется признак `in_calculation` и вызывается +основной traversal очереди объектов. + +```text +prepare input/events + -> enter calculation + -> dispatch queue events + -> objects update behavior and transforms + -> leave calculation + -> apply deferred operations + -> occasional cache maintenance +``` + +После traversal разбирается deferred-delete list. Объект может запросить +собственное удаление во время события, но память освобождается только после +завершения обхода. Периодически также очищаются давно неиспользуемые ресурсы и +объекты по порогам часов порядка 20 и 60 секунд. + +Совместимый runtime должен иметь явный traversal depth или флаг +`in_calculation`. Нельзя полагаться на то, что контейнер выдержит удаление +текущего элемента из обработчика события. + +## Жизненный цикл кадра + +Рендер читает состояние, подготовленное расчётом. Кадр начинается до renderer-а: +message pump уже накопил ввод, World3D уже обновил объекты, отложенные операции +и анимации, после чего выбирается камера и обновляется listener звука. + +```text +system messages and input + -> simulation calculation + -> deferred object operations + -> animation and transforms + -> camera and sound listener + -> visibility and render queues + -> materials and draw passes + -> renderer completion + -> end-of-render callbacks and UI +``` + +В `World3D::stdRenderGame` виден крупный каркас: установка camera/viewport, +renderer frame boundaries, traversal мира, завершение world/shade path, +renderer completion, снятие `in_render`, восстановление viewport и рассылка +end-of-render callbacks. Эти callbacks позволяют объектам безопасно обновить +временные ресурсы после того, как draw-команды больше их не используют. + +Один calculation step не обязан соответствовать одному изображению. Главный +цикл допускает дополнительный вызов `stdCalculateGame` и режим, в котором +расчёт продолжается без вывода кадра. Поэтому нужно хранить отдельно: + +1. монотонные платформенные часы; +2. игровое время с pause и масштабированием; +3. длительность текущего calculation step; +4. локальное время анимации и FX; +5. реальные часы обслуживания кэшей. + +Игровую логику нельзя выводить из render delta: изменение частоты кадров тогда +изменит движение, камеру и сценарные таймеры. Подробности render item и рисков +кадровой совместимости вынесены в справочник [Render frame](../reference/render-frame.md). + +## World3D + +World3D связывает игровые объекты, события, время, ввод, камеру, сетевые +отражения и визуальное представление. Он не содержит всю предметную логику: +движение делегируется Behavior/Wizard, физика -- Control, мир -- Terrain. Его +задача -- общая идентичность, порядок вызовов и безопасный жизненный цикл. + +`CreateQueue` создаёт singleton-объект размером 20 байт, а `GetQueue` +возвращает его. Очередь служит центральным маршрутизатором событий и операций +над объектами. + +Публичный слой предоставляет отдельные функции для локальных и сетевых +объектов: + +```text +CreateObject +AddObjectToGame +AddNewObjectToGame +CreateMirrorObject +AddMirrorObjectToGame +AddNewMirrorToGame +``` + +Разделение "создать" и "добавить в игру" означает два этапа: сначала выделить и +настроить instance, затем зарегистрировать его в общей системе. Это позволяет +loader-у заполнить свойства до появления объекта в расчётной очереди. + +## Идентичность объектов + +Object ID кодирует не только порядковый номер. Проверки диапазонов показывают +разбиение на номер игрока, класс и индекс. Mirror object представляет объект, +владельцем которого является другой участник. Локальный runtime хранит его +видимое состояние, но источник авторитетных изменений находится удалённо. + +```c +struct ObjectId { + uint32_t raw; + uint16_t owner_player; + uint16_t class_and_index; +}; +``` + +Точное битовое разбиение нужно брать из сетевых функций. На уровне API уже +сейчас полезно разделить логические свойства: `is_local`, `is_mirror`, `owner`, +`class` и `index`. + +Минимальный runtime-object должен хранить identifier, type, owner, transform, +active state, ordered property bag, ссылки на controllers, участие в расчёте и +рендере, сетевой статус и флаг отложенного удаления. Специализированные DLL +могут быть представлены компонентами, но порядок их вызовов задаёт World3D. + +## Отложенное удаление + +`DeleteGameObject` проверяет, идёт ли calculation pass. Если обход активен и +удаление не принудительное, объект помещается в deferred list. `KillGameObject` +отправляет запрос через очередь, а не освобождает память напрямую. + +```c +void request_delete(Object* o) { + if (world.in_calculation) { + world.deferred_delete.push_back(o); + o->pending_delete = true; + } else { + world.detach_and_release(o); + } +} +``` + +Это защищает итераторы, связи и текущий стек вызовов. Любая новая подсистема, +способная удалить объект из обработчика события, обязана пользоваться тем же +механизмом. + +Регистрация в очереди и владение памятью -- разные понятия. Удаление из мира не +всегда означает немедленное освобождение instance: часть объектов и managers +использует intrusive reference count, а renderer, sound и resource managers +могут возвращать уже существующий singleton с увеличенным счётчиком. Поэтому +global manager закрывается после всех объектов, которые на него ссылаются. + +## Детерминизм + +Даже при одинаковых формулах результат зависит от порядка. Стабильный runtime +сохраняет последовательность queue traversal, момент формирования input +snapshot, порядок сетевых сообщений, обработку deferred operations и порядок +обращений к RNG. Оптимизация и многопоточность допустимы только при +детерминированном объединении результатов. + +Для переносимой реализации полезно разделить scheduler phases и immutable +render snapshot. Это архитектурная рекомендация для новой реализации, а не +утверждение о точном layout исходных C++ classes. + +## Стабильность между сборками + +Внешняя архитектура полных Частей 1 и 2 сохраняет те же пятнадцать DLL, 313 +exports, имена, ordinals и import sets. Побайтно идентичны: + +```text +ai.dll, Behavior.dll, Joystick.dll, MisLoad.dll, Net.dll, +Ngi32.dll, Terrain.dll, Wizard.dll, World3D.dll +``` + +Пересобраны: + +```text +AniMesh.dll, ArealMap.dll, Control.dll, Effect.dll, +iron3d.dll, services.dll +``` + +Это разделяет переносимость выводов. World3D lifecycle, Terrain, NRes/RsLi +readers, mission loader, AI/Behavior/Wizard, DirectPlay wrapper и joystick +adapter подтверждаются одной машинной реализацией. Model/agent runtime, +collision, effects, shell/composition и service layer требуют отдельного +сравнения поведения Частей 1 и 2. + +Для `World3D.dll` Частей 1 и 2 применим общий hash: + +```text +World3D.dll SHA-256 +17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e +``` + +RVA внутри `iron3d.dll` нельзя считать общими без проверки конкретного файла: +эта DLL пересобрана между частями, а демоверсия имеет отдельный binary profile. +Смысловая последовательность цикла переносится как контракт scheduler-а, но +адреса остаются build-specific. diff --git a/docs/tomes/03-resources.md b/docs/tomes/03-resources.md new file mode 100644 index 0000000..acf97a8 --- /dev/null +++ b/docs/tomes/03-resources.md @@ -0,0 +1,561 @@ +# III. Ресурсная система и форматы + +Ресурсная система Iron3D переводит имена из миссий и прототипов в объекты, +которыми пользуются подсистемы мира, рендера, анимации, звука, эффектов и +управления. В этом пути участвуют несколько разных сущностей: файл на диске, +открытый архив, запись каталога, подготовленный payload и готовый runtime-объект. +Их нельзя смешивать, потому что у каждого уровня свой срок жизни, свои правила +кэширования и свой набор проверок. + +Основной контейнер ресурсов -- [NRes](../reference/nres.md). Он используется как +внешний архив (`objects.rlb`, `Material.lib`, `Textures.lib`) и как внутренний +контейнер модели `*.msh`. Второй библиотечный формат -- [RsLi](../reference/rsli.md): +его каталог находится в начале файла, а payload может храниться raw, через +потоковое преобразование, LZSS, адаптивный Huffman + LZSS или raw Deflate. +Визуальная часть прототипа дальше проходит через [MSH](../reference/msh.md), +[WEAR/MAT0](../reference/materials.md) и [Texm](../reference/texm.md), но этот +том описывает именно ресурсный слой: как найти, проверить, раскрыть и сохранить +данные до передачи их предметным подсистемам. + +```text +TMA или unit DAT + -> логический ключ + -> objects.rlb + -> archive.rlb :: model.msh + -> model.wea + -> Material.lib :: MAT0 + -> Textures.lib / LightMap.lib :: Texm +``` + +На демо-корпусе эта цепочка проверена целиком для всех реально размещённых +объектов. При этом полная таблица прототипов может содержать ссылки на контент, +которого нет в урезанной поставке. Диагностика должна различать недостижимую +ссылку в общем реестре и ресурс, реально требуемый выбранной миссией. + +## Ресурсный конвейер + +Загрузка ресурса состоит из последовательных стадий: + +1. Разрешить относительный путь с учётом глобального resource path и текущего + каталога игры. +2. Открыть архив или вернуть уже открытый archive object из кэша. +3. Найти запись каталога по имени, не меняя исходный порядок каталога. +4. Проверить bounds, размер payload и способ хранения. +5. Подготовить bytes: распаковать, применить потоковое преобразование или + вернуть raw-диапазон. +6. Разобрать предметный формат и создать объект подсистемы. +7. Сохранить готовый объект в отдельном кэше, если формат допускает повторное + использование. + +Эти стадии дают четыре независимых уровня кэша: + +1. Открытые архивы. +2. Каталоги имён, offsets и размеров. +3. Подготовленные блоки данных. +4. Кэши моделей, материалов, текстур, lightmaps, эффектов и служебных объектов. + +Повторное открытие того же нормализованного пути возвращает существующий +archive object и увеличивает счётчик владельцев. Готовая texture или model при +этом может жить дольше file handle и иметь собственную политику удаления. Кэш +предметного объекта не должен напрямую закрывать архив: он зависит от данных, +но не владеет файлом как ресурсом операционной системы. + +## Имена и пути + +Большинство игровых имён сравнивается без учёта регистра в ASCII-диапазоне. Это +не Unicode case folding. Для совместимости достаточно нормализовать `A..Z` в +`a..z`, а для RsLi-поиска -- переводить запрос в uppercase ASCII и укладывать его +в фиксированный ключ. + +Фиксированные строки читаются bounded parser-ом: строковая часть заканчивается +на первом NUL, но оставшийся хвост поля сохраняется. Нельзя очищать хвосты, +пересобирать регистр, заменять смешанные разделители или заранее переводить все +пути в абсолютные имена. Старые данные используют исторические имена библиотек, +разный регистр исходных путей и фиксированные поля, где после терминатора могут +оставаться значимые для roundtrip bytes. + +## Строгий и совместимый режимы + +Строгий reader нужен тестам, редактору и проверке корпуса. Он валидирует +структуру до выдачи любого `EntryView`: magic, версию, счётчики, арифметические +переполнения, bounds, sort permutation, alignment и точное завершение payload. +Если формат требует NUL-терминатор, строгий режим проверяет его именно в пределах +фиксированного поля. + +Совместимый reader повторяет только известные особенности оригинала: + +- линейный поиск при повреждённой сортировочной таблице; +- RsLi-исключение `deflate_eof_plus_one` для `sprites.lib::INTERF8.TEX`; +- material fallbacks, подтверждённые ресурсной цепочкой; +- отсутствие геометрии у системных и солнечных объектов, где mesh pass не + требуется. + +Режим совместимости не должен скрывать произвольные ошибки. Каждое послабление +оформляется как именованное правило и покрывается отдельным тестом. Если quirk +применим только к Deflate-записи, он не распространяется на LZSS, Huffman или +raw-диапазоны. + +## NRes + +`NRes` хранит произвольные именованные payload и их атрибуты. Каталог расположен +в конце файла, поэтому начало каталога вычисляется из полного размера файла и +числа записей. + +```text +[Header: 16 байт] +[Data region: payload с выравниванием] +[Directory: entry_count x 64 байта] +``` + +Все числа little-endian. + +```c +struct NResHeader16 { + char magic[4]; // "NRes" + uint32_t version; // 0x00000100 + int32_t entry_count; // >= 0 + uint32_t total_size; // равен фактическому размеру файла +}; +``` + +Производные значения: + +```text +directory_size = entry_count * 64 +directory_offset = total_size - directory_size +``` + +Reader проверяет, что `directory_offset >= 16`, умножение не переполнено, а +каталог заканчивается точно на `total_size`. + +### Запись каталога NRes + +```c +#pragma pack(push, 1) +struct NResEntry64 { + uint32_t type_id; // +0x00 + uint32_t attr1; // +0x04 + uint32_t attr2; // +0x08 + uint32_t size; // +0x0C + uint32_t attr3; // +0x10 + char name[36]; // +0x14 + uint32_t data_offset; // +0x38 + uint32_t sort_index; // +0x3C +}; +#pragma pack(pop) +``` + +Имя содержит не более 35 полезных байт и завершающий ноль. Writer запрещает +внутренний NUL и слишком длинное имя, но сохраняет неизвестные атрибуты +`attr1`, `attr2`, `attr3` без нормализации. Их смысл зависит от конкретного +типа ресурса и не может быть выведен из контейнера. + +Поле `sort_index` задаёт отображение из позиции в отсортированном списке в +исходный индекс записи. Каталог остаётся в исходном порядке. Поиск идёт по +отсортированному отображению, но возвращает исходную запись. При сохранении +writer строит массив исходных индексов, сортирует его по ASCII-case-insensitive +именам и записывает результат в `sort_index`. Если отображение нельзя использовать +или оно не является перестановкой в строгом режиме, совместимый путь переходит к +последовательному сравнению имён. + +### Размещение данных NRes + +Каждый active payload должен лежать после 16-байтового заголовка и полностью до +начала каталога. Канонические игровые файлы выравнивают начало следующего +payload до границы 8 байт нулевым заполнением. + +Порядок canonical save: + +1. Записать временный заголовок. +2. Записать payload всех записей в текущем порядке. +3. После каждого блока добавить нули до кратности 8. +4. Построить таблицу поиска имён. +5. Дописать каталог. +6. Записать окончательный `total_size`. + +Строгий reader выполняет проверки до выдачи записи: + +- `magic == "NRes"` и `version == 0x100`; +- `entry_count >= 0`, а `entry_count * 64` вычисляется без переполнения; +- `total_size` равен фактической длине файла; +- `directory_offset = total_size - entry_count * 64` не меньше 16; +- для каждой записи `data_offset >= 16` и `data_offset + size <= directory_offset`; +- поле имени содержит NUL в пределах 36 байт; +- каждый `sort_index < entry_count`; +- в строгом режиме все `sort_index` образуют перестановку `0..N-1`. + +Нулевое заполнение до границы 8 байт -- подтверждённое поведение игровых +архивов и canonical writer-а. Reader не должен считать ненулевой gap частью +соседнего payload, но lossless-редактор сохраняет исходные bytes, если файл +открыт не в режиме канонической пересборки. + +### Неплотная data region + +Проверка 120 NRes-файлов / 6 804 entries Части 1 и 134 файлов / 8 171 entries +Части 2 не выявила нарушений magic, version, total size, bounds, sort +permutation, ASCII-order, 8-byte alignment или перекрытий активных payload. +Однако `Textures.lib` Части 2 содержит большой ненулевой диапазон в data region, +который не адресуется ни одной записью каталога. Первый активный payload +начинается значительно позже начала файла, а каталог и все активные entries +остаются корректными. + +Следовательно, parser не должен требовать плотного покрытия data region. Нужно +различать три вида диапазонов: + +- `active payload` -- bytes, на которые указывает запись каталога; +- `gap/padding` -- bytes между активными диапазонами; +- `unindexed preserved region` -- произвольные bytes, не принадлежащие ни одной + записи. + +Canonical compact writer может исключить unindexed region только при явной +операции repack. Lossless editor сохраняет её побайтно вместе с исходным +порядком entries и gaps. + +## RsLi + +`RsLi` -- библиотечный архив с каталогом в начале файла. Записи могут храниться +в исходном виде или проходить один из поддержанных путей подготовки. + +```text +[Header: 32 байта] +[Entry table: entry_count x 32 байта] +[Payloads] +[необязательный trailer] +``` + +Заголовок начинается с двух байт `NL`. Версия равна `1`, число записей хранится +как знаковое 16-битное значение. Поле по смещению `0x0E` может содержать +`0xABBA`: это означает, что отображение сортировки уже подготовлено. + +Подтверждённые поля header: + +```text ++0x00 char[2] "NL" ++0x02 u8 reserved, в корпусе 0 ++0x03 u8 version, в корпусе 1 ++0x04 i16 entry_count ++0x0E u16 presorted_flag, значение 0xABBA ++0x14 u32 xor_seed +``` + +Остальные bytes заголовка сохраняются без нормализации. + +### Запись каталога RsLi + +После подготовки таблицы каждая запись имеет layout 32 байта: + +```c +struct RsLiEntry32 { + char name[12]; + uint8_t service[4]; + int16_t flags; + int16_t sort_to_original; + uint32_t unpacked_size; + uint32_t data_offset_raw; + uint32_t packed_size; +}; +``` + +Имя обычно хранится в uppercase ASCII. Четыре служебных байта после имени +сохраняются без изменения. `sort_to_original` играет ту же роль, что и +`sort_index` в NRes: связывает отсортированную позицию с исходной записью. + +Таблица на диске проходит обратимое побайтовое преобразование. Начальное +состояние берётся из младших 16 бит `xor_seed`. Если обозначить два байта +состояния как `lo` и `hi`, для каждого входного байта выполняется: + +```text +lo = hi XOR ((lo << 1) mod 256) +out = in XOR lo +hi = lo XOR (hi >> 1) +``` + +Операция симметрична: один и тот же цикл используется для подготовки и +восстановления. Состояние непрерывно проходит по всей таблице; его нельзя +перезапускать на каждой записи. + +### Способы хранения RsLi + +Способ определяется выражением `flags & 0x1E0`: + +```text +0x000 исходный блок +0x020 только потоковое байтовое преобразование +0x040 LZSS +0x060 преобразование, затем LZSS +0x080 адаптивный Huffman, затем LZSS +0x0A0 преобразование, адаптивный Huffman и LZSS +0x100 raw Deflate без оболочки zlib +``` + +Reader обязан различать все значения, а неизвестную маску отклонять как +неподдерживаемую. После любого пути должно быть получено ровно `unpacked_size` +байт. Методы `0x080` и `0x0A0` подтверждены decoder-кодом и синтетическими +тестами, но живых payload этих веток в проверенных RsLi-файлах не найдено. + +Параметры LZSS: + +- размер кольцевого окна -- `4096`; +- начальное заполнение -- байт `0x20`; +- начальная позиция -- `0xFEE`; +- управляющие признаки читаются от младшего бита к старшему; +- двухбайтовая ссылка кодирует 12-битную позицию и длину `n + 3`; +- восстановленные bytes сразу записываются обратно в кольцевое окно. + +В конце файла может находиться шестибайтовый media overlay trailer: два символа +`AO` и 32-битное значение `overlay`. В таком режиме фактическая позиция блока +равна `data_offset_raw + overlay`. Reader сначала проверяет, что overlay не +выходит за размер отображённого файла, затем проверяет весь диапазон записи. + +### Поиск, кэш и проверки RsLi + +Запрос имени переводится в uppercase ASCII и укладывается в фиксированный ключ. +При признаке `0xABBA` используется сохранённое отображение сортировки. Если +признака нет, loader строит его после чтения каталога. Некорректный индекс +приводит к последовательному поиску. + +Файл открывается через memory mapping. Runtime-запись хранит указатель на +упакованный диапазон, размеры и необязательный указатель на подготовленные +данные. Первый обычный `load` создаёт буфер и сохраняет результат; повторный +возвращает его из кэша. Быстрый путь может вернуть указатель непосредственно в +mapped file только для исходного блока. + +Reader проверяет: + +- сигнатуру `NL`, служебный байт и версию; +- неотрицательное число записей; +- размещение всей таблицы в файле; +- что сохранённое отображение сортировки является перестановкой; +- что эффективный диапазон каждого блока не выходит за конец файла; +- что способ хранения известен; +- что после подготовки получено ровно `unpacked_size` байт. + +В demo-каталоге и полных каталогах обеих частей наблюдаются два RsLi-файла: + +```text +gamefont.rlb 2 entries, все 0x040 LZSS +sprites.lib 24 entries, все 0x100 raw Deflate +``` + +Последняя запись `sprites.lib::INTERF8.TEX` объявляет packed range, который +заканчивается на один байт после физического EOF. Совместимый путь читает на +один байт меньше; строгий путь регистрирует именованный quirk +`deflate_eof_plus_one`. Это исключение не распространяется на другие записи, +методы или произвольные выходы за конец файла. + +Writer, который редактирует существующий архив, сохраняет все служебные bytes +заголовка и записей. Выбор оптимального способа упаковки для новых файлов +является отдельной политикой и не должен менять уже существующие entries без +явного запроса. + +## Реестр объектов + +Имя объекта в миссии является логическим ключом. Связь этого ключа с файлами +модели, материалов и служебных данных хранится в `objects.rlb`, который сам +использует формат NRes. Имя записи каталога -- ключ прототипа. Payload записи +состоит из записей по 64 байта: + +```c +struct ObjectRef64 { + char archive_name[32]; + char resource_name[32]; +}; +``` + +Payload каждой записи `objects.rlb` обязан быть кратен 64 байтам. Это +проверяется до чтения первой ссылки. Оба поля читаются как строки до первого +NUL, но полный 32-байтовый блок сохраняется при редактировании без очистки +хвоста. + +Разрешение прототипа: + +1. Найти entry реестра по логическому ключу без учёта ASCII-регистра. +2. Прочитать все `ObjectRef64` в исходном порядке. +3. Если ссылка указывает обратно в `objects.rlb`, рекурсивно раскрыть указанный + родительский prototype. +4. Объединить effective references родителя с локальными references дочерней + записи, сохранив порядок и происхождение. +5. Выбрать первую существующую ссылку с расширением `.msh`, открыть указанный + архив и найти модель по имени. +6. Загружать `.bas` как отдельный служебный ресурс сооружения, а не как замену + MSH. +7. Если effective prototype не содержит MSH, считать объект негеометрическим, + если это допускает его назначение. + +Resolver обязан детектировать циклы наследования, ограничивать глубину и +кэшировать результат раскрытия. В обеих частях fortification-прототипы используют +явного родителя из `objects.rlb`: родитель предоставляет MSH/WEAR/CPT/NDP/CTL, +а дочерняя запись добавляет собственный BASE. Негеометрический объект не является +ошибкой сам по себе: системные и солнечные сущности могут участвовать в логике +или эффектах без mesh pass. + +Контракт реализации: + +- сохранять порядок ссылок внутри прототипа; +- не выводить имя модели из имени entry, если имеется явная ссылка; +- проверять существование указанного архива и ресурса независимо; +- отделять статус «негеометрический объект» от статуса «повреждённая ссылка»; +- кэшировать результат разрешения ключа, но инвалидировать его при замене архива; +- в diagnostic mode строить полный граф зависимостей и отмечать узлы, достижимые + из выбранной миссии. + +В demo-варианте `objects.rlb` содержит 590 прототипов. У 554 есть прямая ссылка +на MSH; 549 таких ссылок разрешаются в доступных demo-архивах. Ещё 34 прототипа +раскрываются через родительскую запись `objects.rlb` и дополняются локальным +BASE. Семь записей не дают геометрию, а 41 ссылка всего реестра указывает на +контент, которого нет в урезанной поставке. Для 501 запросов прототипов, +порождаемых шестью demo-миссиями, найдены прототип, MSH и WEAR. + +## Unit DAT + +Запись миссии может ссылаться не на один ключ, а на unit-файл `*.dat`. Такой файл +перечисляет компоненты сложного игрового объекта. + +```text +TMA object + -> путь к unit DAT + -> список component keys + -> несколько entries objects.rlb + -> модели, WEAR, control points, effects и другие ресурсы +``` + +Это объясняет, почему один размещённый unit может состоять из корпуса, башен, +оружия, эффектов и служебных частей. В демоверсии найдено 425 unit-файлов и +5 219 записей; все разобраны без ошибок. Наблюдаемый тип записи равен `1`, а +архив назначения -- `objects.rlb`. В 5 205 из 5 219 фиксированных полей имени +обнаружены ненулевые bytes после строкового терминатора; reader использует +строковую часть, а lossless writer сохраняет весь исходный блок. + +Размер каждого unit DAT удовлетворяет формуле: + +```text +file_size = 8 + record_count * 112 +``` + +Первые два байта header равны `F1 F0`. Оставшиеся шесть bytes имеют несколько +наблюдаемых вариантов; их семантика пока не названа и они сохраняются как +`header_opaque[6]`. + +```c +#pragma pack(push, 1) +struct UnitDatRecord112 { + char archive_name[32]; // +0x00 + char resource_name[32]; // +0x20 + uint32_t kind; // +0x40, в корпусе всегда 1 + int32_t parent_or_link; // +0x44 + char description[32]; // +0x48 + uint32_t tail0; // +0x68, opaque + uint32_t tail1; // +0x6C, opaque +}; +#pragma pack(pop) +``` + +Во всех проверенных records `archive_name == "objects.rlb"` и `kind == 1`. +Поле `parent_or_link` встречается как `-1`, `0`, `1` и другие небольшие индексы +и связывает компоненты составного unit; точная предметная классификация ссылки +ещё не закрыта. `description` -- человекочитаемое описание компонента. В Части 2 +есть поля `description[32]`, полностью заполненные без NUL; это валидная bounded +string длиной 32 байта. Требование обязательного terminator применяется только +к полям, где оно доказано форматом. `tail0` и `tail1` нельзя нормализовать. + +Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records +Части 2. Все соответствуют формуле размера, `kind == 1` и +`archive_name == "objects.rlb"`. + +## Вспомогательные форматы + +MSH, материал и текстура отвечают за видимую форму. Полноценный прототип +дополнительно хранит точки крепления, зависимости, управляющие параметры, +области взаимодействия и ссылки на эффекты. Эти данные распределены между +несколькими небольшими форматами. + +Для них действует строгая граница знания: framing, counts и валидность корпуса +могут быть подтверждены parser-ом, тогда как предметный смысл части полей +остаётся неизвестным. Reader предоставляет typed view для доказанных полей и +raw bytes для остальных. Инструмент должен показывать статус поля: +`layout-confirmed`, `consumer-inferred` или `opaque`. + +### CTPT + +В demo-корпусе найдено 284 CTPT-ресурса и 3 599 точек; все прочитаны без ошибок. +Имена показывают назначение слоя: `TurretCenter`, `TurretDirect`, +`CameraCenter`, `TargetDirect`, `Root`, `Sfx_1`, `Sign_Entrance1`, `Width`, +`Height`, `Dir`. + +CTPT хранит локальные marker-точки модели. После применения transform такая точка +становится позицией или направлением в мире. Оружие может использовать её для +дула или оси башни, камера -- для привязки обзора, эффект -- для точки появления. +Конкретное назначение определяется именем и consumer-ом, а не одним общим флагом. +Первое 32-битное поле чаще равно `0`; встречаются `0x80000000` и редкий +вариант. До установления точной семантики оно хранится как `flags_raw`. + +### NDPR + +Проверено 494 NDPR-ресурса и 1 915 записей. Они ссылаются на `animals.rlb`, +`system.rlb`, `static.rlb`, `turrets.rlb`, `weapon.rlb` или используют пустое +имя архива. В 89 записях присутствует связанный эффект. Пустое имя архива +разрешается относительно текущего контекста. Reader хранит ссылку и остальные +параметры раздельно; writer сохраняет исходный порядок. + +### EXPL и reference arrays + +Проверено 144 ресурса EXPL: 26 используют версию 1, 54 -- версию 2, 64 -- +версию 3. Reader выбирает layout по version field и требует точного завершения +payload. Полная field-level семантика всех версий пока не доказана, поэтому +version-specific opaque sections сохраняются. + +Отдельная проверенная группа из 585 ресурсов содержит 2 956 однотипных +ссылочных records. Их границы и counts закрыты, однако единое предметное имя +всего семейства не подтверждено всеми consumers. В API безопаснее использовать +нейтральное `ReferenceArray` и конкретизировать назначение на уровне типа entry. + +### SUND и CTLD + +Два ресурса SUND содержат суммарно 12 ключей. Их следует загружать как параметры +системного объекта, а не как геометрию. + +Для CTLD проверено 531 payload. Размеры и сочетания счётчиков сильно различаются, +поэтому parser должен быть версионно- и счётчик-ориентированным, а неизвестные +секции -- храниться в исходном виде. + +### TRF, ANI и SKE + +В демоверсии обнаружены 5 файлов TRF, 38 preload-записей, 8 ANI-ресурсов и +6 SKE-ресурсов. Все проходят структурный разбор. Эти семейства участвуют в +подготовке компонентов и анимационных или управляющих данных до создания +runtime-объекта. + +Поскольку живой корпус невелик, редактор не должен синтезировать новые варианты +этих форматов по догадке. Безопасный режим -- читать доказанные счётчики и +ссылки, предоставлять raw-view неизвестных секций и обеспечивать побайтовое +сохранение неизменённых данных. + +### BASE + +Проверено 30 BASE-ресурсов; каждый содержит ровно один polygon record и проходит +структурную проверку. BASE payload и ссылка `.bas` в `objects.rlb` выполняют +связанные, но разные роли: + +- наличие ссылки `.bas` позволяет registry resolver-у искать одноимённый + `<stem>.msh` в том же архиве; +- сам BASE payload загружается отдельной подсистемой сооружений и не заменяет + MSH geometry. + +Resolver не должен интерпретировать bytes BASE как mesh. Writer сохраняет +polygon record и неизвестные поля 1:1, пока полный gameplay-контракт BASE не +подтверждён. + +## Правило сохранения + +Lossless editor сохраняет неизвестные поля, хвосты фиксированных строк, +служебные bytes, gaps, padding и unindexed regions. Writer пересчитывает только +явно производные значения: размеры, offsets, число записей, сортировочную +перестановку и padding. Такая дисциплина позволяет редактировать известную +часть ресурса, не разрушая данные, смысл которых пока не установлен. + +Canonical repack допустим только как явная операция. Он может исключать +неиндексируемые диапазоны, пересортировывать таблицы и пересобирать padding, но +не должен быть побочным эффектом обычного редактирования. Если пользователь +открыл существующий архив и изменил один известный атрибут, все остальные bytes, +не являющиеся производными от этого изменения, должны пройти roundtrip без +потери. diff --git a/docs/tomes/04-world.md b/docs/tomes/04-world.md new file mode 100644 index 0000000..82d9e47 --- /dev/null +++ b/docs/tomes/04-world.md @@ -0,0 +1,648 @@ +# IV. Мир, миссии и игровой runtime + +Миссия в Iron3D не является готовым снимком мира. Она задаёт исходные данные: +маршруты, кланы, размещённые объекты, свойства, ссылку на ландшафт и +дополнительные записи. Runtime строит из этого карту, пространственные +структуры, очередь `World3D`, визуальные представления, controllers и связи с +ресурсной системой. + +Для совместимой реализации важно не смешивать три слоя: + +1. **Disk data** -- `data.tma`, `Land.msh`, `Land.map`, `BuildDat.lst` и + связанные resource archives. +2. **Prepared data** -- разобранные paths, clans, terrain streams, areal graph, + prototype graph, material и texture handles. +3. **Runtime objects** -- World3D instances, domain controllers, spatial + registration, AI/scripts, timers и расчётный tick. + +Граница между этими слоями нужна для диагностики и отката. Ошибка в достижимой +цепочке размещённого объекта должна остановить создание миссии до публикации +объекта в очереди событий. Недостижимая запись общего архива может быть +inventory warning и не обязана блокировать текущую карту. + +## `data.tma`: данные миссии + +`data.tma` -- основное описание расстановки и логической конфигурации миссии. +Он не содержит всю геометрию, материалы или AI-код. Файл перечисляет paths, +clans, objects, свойства и ссылки на внешние прототипы. Подробный справочный +контракт формата вынесен в [TMA](../reference/tma.md), но глава использует его +как часть сквозного runtime pipeline. + +TMA читается строго последовательно bounded cursor-ом. Записи имеют переменную +длину, поэтому offsets следующих секций получаются только после разбора +предыдущих. Секции нельзя искать по сигнатурам: порядок управляется счётчиками, +длинами и mode-dependent ветками. + +Главный критерий корректности -- `cursor.offset == file_size` после последней +записи. Неописанный хвост, переполнение при вычислении размеров, отрицательный +или чрезмерный count и выход за bounds являются ошибками parser-а, а не +материалом для эвристического восстановления. + +### Верхний уровень + +Все переменные строки в проверенных TMA используют length-prefixed primitive: + +```c +struct LpString { + uint32_t byte_length; + uint8_t bytes[byte_length]; +}; +``` + +Завершающий NUL не является обязательной частью framing. Reader продвигается +ровно на `4 + byte_length`. Текст можно декодировать как legacy ANSI/CP1251 для +человекочитаемого представления, но исходные bytes сохраняются для lossless +режима. + +Подтверждённый верхний уровень: + +```text +u32 format_version // 1 +u32 path_count +PathRecord paths[path_count] +u32 clan_section_version // 6 +u32 clan_count +ClanRecord clans[clan_count] +u32 object_section_version // 10 +u32 object_count +PlacedObject objects[object_count] +LpString land_path +u32 mission_flag +LpString description_raw +u32 extra_section_version // 1 +u32 extra_count +ExtraRecord28 extras[extra_count] +``` + +Имена `clan_section_version`, `object_section_version` и +`extra_section_version` описывают устойчивое положение полей в контракте. Они +не доказывают исходные имена C++-структур. Strict mode проверяет известные +значения, compatible mode сохраняет raw value и сообщает диагностический +контекст. + +### Paths + +```c +struct PathRecord { + int32_t path_id; + uint32_t point_count; + float points[point_count][3]; +}; +``` + +Paths идут сразу после `path_count` без имён и padding. `path_id` не обязан +совпадать с физической позицией записи: script/gameplay reference должен +использовать сохранённый ID, а не индекс массива. + +Перед выделением массива проверяются `point_count`, умножение `point_count * +12` и наличие всего диапазона в файле. Координаты хранятся как little-endian +`float32` triples в общей системе координат мира. + +### Clans + +Clan section задаёт участников миссии, их ресурсные связи, позиционные anchors +и таблицы отношений. Общая prefix-часть: + +```text +LpString name +i32 raw_id +f32 anchor_x +f32 anchor_y +u32 mode +mode-dependent body +relation table +``` + +Для обычных modes `1..3` тело содержит две пары: + +```text +LpString resource_path +i32 resource_tag +LpString resource_path +i32 resource_tag +``` + +После них идёт relation table: + +```text +u32 relation_count +repeat relation_count: + LpString other_clan_name + i32 relation_value +``` + +Первая ресурсная строка обычно указывает на script/formula base, вторая -- на +TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как +raw-поля, пока их потребительская семантика не закрыта. + +Mode `0` имеет отдельный count-driven layout: + +```text +LpString first_resource +u32 spatial_group_count +repeat spatial_group_count: + u32 record_count + repeat record_count: + float raw_spatial[5] +LpString second_resource +i32 second_tag +u32 relation_count +relations... +``` + +Внутренний `record_count` в известных живых образцах равен `1`, но parser читает +объявленное значение. Нельзя разбирать mode `0` как обычные две resource +references: это сдвигает cursor и ломает последующую relation table. + +### PlacedObject и свойства + +Ключевое поле размещённого объекта -- `resource_name`. Оно имеет два рабочих +варианта: + +1. прямой логический ключ прототипа, который ищется в `objects.rlb`; +2. путь к unit DAT, из которого получается список компонентных ключей. + +Доказанное framing объектной записи: + +```text +u32 raw_kind +u32 class_or_flags +LpString resource_name +u32 raw_after_resource +u32 identity_or_clan_raw +f32 position[3] +f32 orientation[3] +f32 scale[3] +LpString instance_name +u32 raw_after_name +i32 link0 +i32 link1 +u32 property_schema_version // 1 +u32 property_count +Property properties[property_count] +``` + +`orientation[3]` названа по наблюдаемому использованию как transform-поле, но +точный Euler order должен подтверждаться pose/render parity. `scale` в +большинстве записей равен `(1,1,1)`. `instance_name` может быть пустым у +unit-ссылки или содержать stem размещённого прототипа. + +Свойства хранятся как ordered property bag: + +```text +Property: + u32 raw_value[4] + LpString name +``` + +Порядок, повторяемость имени и raw 16-byte value важнее удобного словаря. +Разные consumers интерпретируют четыре слова как integer, float, default или +range data в зависимости от имени свойства. Typed view допустим только для +доказанных property names; базовый parser обязан сохранить исходный порядок. + +В раннем проверенном корпусе на каждом из 201 размещённого объекта встречаются +`Invulnerability` и `Life state`. Для 48 unit-ссылок дополнительно наблюдаются +`LogicalID`, `ClanID`, `Type`, `MaxSpeedPercent`, `MaximumOre`, `CurrentOre`, +`ChargeRadius`, `FreeBotNum`, `FreeTechnoNum`, `FreeConstructionTime` и +`FreeResearchTime`. Имя `NOT USED` встречается массово и сохраняется как +обычное поле, несмотря на исторический смысл названия. + +### Epilogue и extras + +После объектов идут путь к ландшафту, флаг миссии, raw-описание и trailing +section. `description_raw` не всегда является чистым текстом: внутри +объявленной длины встречаются служебные bytes и остатки путей. Поэтому decoded +view является вспомогательным, а не каноническим представлением. + +```c +struct ExtraRecord28 { + float position[3]; + uint32_t raw[4]; +}; +``` + +Последние четыре слова `ExtraRecord28` пока не нормализуются. Reader хранит их +как raw data и не позволяет extra record поглотить начало следующей секции или +файловый хвост. + +Покрытие полных каталогов: + +```text +Часть 1: 29 TMA, 34 paths, 101 clans, 864 objects, 28 extra records +Часть 2: 31 TMA, 61 paths, 91 clans, 885 objects, 41 extra records +``` + +Версии стабильны: верхний уровень `1`, clan section `6`, object section `10`, +property schema `1`, trailing section `1`. У всех размещённых объектов +`class_or_flags == 0x80000002`. + +## Сквозная загрузка миссии + +`data.tma` описывает размещение, но видимый runtime-объект появляется только +после прохождения dependency graph. Простая загрузка файлов с похожим stem +работает на отдельных объектах, но ломается на составных unit DAT, изменённых +именах моделей и наследовании прототипов через `objects.rlb`. + +Сквозная цепочка: + +```text +TMA object + -> direct prototype key или unit DAT + -> component key + -> objects.rlb entry + -> MSH и WEAR + -> material slots + -> MAT0 phases + -> Texm и lightmap + -> prepared World3D instance +``` + +Контейнеры и графические форматы описаны отдельно в [NRes](../reference/nres.md), +[MSH](../reference/msh.md), [WEAR и MAT0](../reference/materials.md) и +[Texm](../reference/texm.md). В этой главе они рассматриваются как ребра +создания мира. + +### Фазы loader-а + +1. **Mission context.** Выбрать каталог миссии, прочитать конфигурацию и + определить карту. +2. **World foundation.** Загрузить `Land.msh`, `Land.map`, `BuildDat.lst` и + создать spatial managers. +3. **Mission description.** Разобрать TMA, paths и clans, но пока не публиковать + объекты. +4. **Prototype resolution.** Для каждой размещённой сущности раскрыть прямой + ключ или unit DAT и построить component list. +5. **Resource preparation.** Открыть требуемые RLB/LIB, проверить MSH, WEAR, + MAT0, textures, lightmaps и effects. +6. **Instance construction.** Создать World3D objects и domain controllers, + заполнить transform, ownership и properties. +7. **Registration.** Только после успешной настройки добавить instances в + queue и spatial structures. +8. **Scenario start.** Подключить AI/scripts, активировать timers и разрешить + первый calculation tick. + +Разделение construction и registration предотвращает появление наполовину +созданного объекта в очереди событий. Если ошибка возникает до регистрации, +pending objects освобождаются без рассылки gameplay-событий. После регистрации +откат выполняется через обычный lifecycle очереди. + +### Статистика dependency graph + +Для ранних шести миссий 201 размещённый объект даёт 48 ссылок на unit-файлы и +153 прямых ключа. Unit-файлы раскрываются в 348 компонентов. Всего получается +501 запрос прототипа; для каждого достижимого запроса найдены запись реестра, +MSH и WEAR. + +Полный dependency graph частей 1 и 2: + +```text +Часть 1 +864 placed objects +463 unit references -> 4 300 components +4 701 prototype/MSH/WEAR requests +36 954 material slots +48 806 texture requests + 139 lightmaps +failures 0 + +Часть 2 +885 placed objects +561 unit references -> 5 521 components +5 845 prototype/MSH/WEAR requests +50 888 material slots +68 603 texture requests + 214 lightmaps +failures 0 +``` + +`failures 0` означает, что для каждой достижимой ветви найдены prototype, +effective MSH/WEAR, MAT0, Texm и lightmap. Это не означает, что во всём +глобальном каталоге нет недостижимых или служебных записей. + +Метрики нужно помечать областью. Чистая object chain шести ранних миссий даёт +3 873 material slots и 5 049 texture requests. Mission total включает по одной +environment WEAR-таблице на миссию и становится 3 879 material slots и 5 067 +texture references. + +### Диагностика ошибок + +Ошибка привязывается к конкретному ребру графа: + +- миссия ссылается на отсутствующий unit-файл; +- unit DAT раскрывается в component key, которого нет в реестре; +- prototype найден, но его MSH отсутствует в ожидаемом archive; +- WEAR указывает на неизвестный MAT0; +- MAT0 phase ссылается на отсутствующий Texm или lightmap; +- prepared object не прошёл валидацию transform/properties. + +Сообщение вида `resource not found` недостаточно для восстановления каталога. +Диагностика должна содержать исходный placed object, раскрытый ключ, archive, +entry и тип связи. + +## `Land.msh`: ландшафт как специализированная модель + +`Land.msh` является [NRes](../reference/nres.md)-архивом, но его содержимое +отличается от обычной объектной MSH. Он хранит геометрию поверхности, таблицы +участков и ускорители пространственных запросов. Видимые buffers являются лишь +частью данных: CPU-подсистемам остаются нужны adjacency, surface classes и +cell accelerator streams. + +Во всех проверенных картах порядок типов одинаков: + +```text +1, 2, 3, 4, 5, 18, 14, 11, 21 +``` + +Типы `1`, `3`, `4` и `5` совместимы по базовому представлению с узлами, +позициями, нормалями и UV обычной модели. Типы `11` и `21` специфичны для +terrain; `14` и `18` являются дополнительными потоками. + +### Streams и размеры элементов + +```text +type 1 38 байт node/slot mapping +type 3 12 байт float3 positions +type 4 4 байта packed normals +type 5 4 байта packed UV +type 11 4 байта cell accelerator data +type 14 4 байта auxiliary stream +type 18 4 байта auxiliary stream +type 21 28 байт terrain face +``` + +Для этих streams `attr1` соответствует числу элементов, а `attr3` -- stride. +Тип `2` начинается заголовком размером `0x8C`, после которого идут slot records +по 68 байт. Число slots вычисляется как `(size - 0x8C) / 68`; reader проверяет +делимость, bounds и отсутствие хвоста. + +### `TerrainFace28` + +Запись type `21` связывает triangles, соседей и surface metadata: + +```text ++0x00 .. +0x07 flags и служебные поля ++0x08 u16 vertex0 ++0x0A u16 vertex1 ++0x0C u16 vertex2 ++0x0E u16 neighbor0 ++0x10 u16 neighbor1 ++0x12 u16 neighbor2 ++0x14 .. +0x1B material/class/edge fields +``` + +Каждый vertex index обязан быть меньше числа позиций type `3`. Neighbor равен +`0xFFFF` либо указывает на другой элемент type `21`. Последние восемь bytes +сохраняются без нормализации до полного закрытия предметной семантики. + +### Маски поверхности + +Runtime использует полную 32-битную маску face и два compact-представления. +Основное 16-битное поле собирается из отдельных битов полной маски; второе +шестибитное поле хранит material classes. Это не усечение младших битов. + +Для совместимого writer-а нужны явные функции `full_to_compact()` и +`compact_to_full()`. Неизвестные биты полной маски сохраняются отдельно, иначе +обратное преобразование потеряет информацию. + +Основное соответствие: + +```text +full 00000001 -> compact 0001 +full 00000008 -> compact 0002 +full 00000010 -> compact 0004 +full 00000020 -> compact 0008 +full 00001000 -> compact 0010 +full 00004000 -> compact 0020 +full 00000002 -> compact 0040 +full 00000400 -> compact 0080 +full 00000800 -> compact 0100 +full 00020000 -> compact 0200 +full 00002000 -> compact 0400 +full 00000200 -> compact 0800 +full 00000004 -> compact 1000 +full 00000040 -> compact 2000 +full 00200000 -> compact 8000 +``` + +Для шестибитного material-поля используются full-биты `0x100`, `0x8000`, +`0x10000`, `0x40000`, `0x80000` и `0x80`; они переходят соответственно в +compact-биты `1`, `2`, `4`, `8`, `0x10`, `0x20`. + +### Проверенное покрытие + +```text +AutoMAP 3 051 вершина, 3 174 faces +PROL 11 125 вершин, 9 234 faces +Tut_1 8 827 вершин, 8 290 faces +Tut_2 9 456 вершин, 8 996 faces +Tut_3 9 833 вершины, 8 560 faces +Tut_4 9 022 вершины, 8 612 faces +``` + +Расширенное покрытие: + +```text +Часть 1: 33 карты, 299 450 vertices, 275 882 faces +Часть 2: 32 карты, 188 024 vertices, 184 454 faces +``` + +Во всех 65 картах порядок типов равен `[1,2,3,4,5,18,14,11,21]`. Strides, +count-driven размеры, vertex indices, neighbor indices и payload bounds +валидны. Различия карт являются различиями данных, а не новым вариантом +loader-а. + +## `Land.map` и ArealMap + +`Land.map` хранит логическое разбиение пространства на связанные области. Это +NRes-архив с одной записью type `12`. Payload содержит переменное число +ареалов, links и grid быстрого поиска. + +Ареал -- участок мира с геометрической границей и метаданными. Граф соседств +позволяет искать маршрут между крупными областями вместо обхода каждой +terrain-вершины. Grid отвечает на быстрый вопрос: какие области потенциально +находятся рядом с координатой. + +### Prefix ареала + +```c +struct ArealPrefix56 { + float anchor_x; + float anchor_y; + float anchor_z; + float reserved_12; + float area_metric; + float normal_x; + float normal_y; + float normal_z; + uint32_t logic_flag; + uint32_t reserved_36; + uint32_t class_id; + uint32_t reserved_44; + uint32_t vertex_count; + uint32_t poly_count; +}; +``` + +После prefix идут `float3 vertices[vertex_count]`. Нормаль в проверенных +записях имеет длину, практически равную единице. Поля `reserved_12`, +`reserved_36` и `reserved_44` в живом корпусе равны нулю, но writer сохраняет +их без нормализации. + +### Links и polygon blocks + +За вершинами хранится массив: + +```c +struct EdgeLink8 { + int32_t area_ref; + int32_t edge_ref; +}; +``` + +Пара `(-1, -1)` означает отсутствие соседа. Иначе `area_ref` указывает на +другую область, а `edge_ref` -- на соответствующее ребро. Число пар равно +`vertex_count + 3 * poly_count`. + +После links для каждого polygon читается `u32 n`, затем block размером +`4 * (3*n + 1)` bytes. Во всех 65 проверенных картах `poly_count == 0`. +Framing ветки восстановлен по loader path, но предметное поведение polygon +blocks не получает статус corpus-verified. + +### Grid быстрого поиска + +После всех ареалов записаны `cellsX` и `cellsY`. Далее для каждой ячейки идут +`u16 hitCount` и `hitCount` номеров областей. Runtime уплотняет это в одно +32-битное значение: старшие 10 бит содержат число попаданий, младшие 22 -- +начальный индекс в общем пуле. + +Grid не является точной геометрической проверкой. Он возвращает короткий список +candidates, после чего выполняется проверка принадлежности области. При +загрузке каждый area ID обязан быть меньше общего числа ареалов. + +Покрытие: + +```text +Ранние шесть карт: 3 811 areals, grid 128 x 128 +Часть 1: 33 карты, 34 662 areals, 197 698 areal vertices +Часть 2: 32 карты, 18 984 areals, 114 968 areal vertices +``` + +Во всех картах grid равен `128 x 128`. Максимальное число candidates в ячейке +-- 20 для Части 1 и 14 для Части 2. Все area/edge references находятся в +диапазоне, normals имеют единичную длину в пределах float32-погрешности, parser +заканчивается точно на конце payload. + +## Пространственные задачи runtime + +Движок решает три похожих, но независимых вопроса: + +- **видимость** -- нужно ли рисовать объект для текущей камеры; +- **столкновение** -- пересекается ли движение с поверхностью или другим телом; +- **навигация** -- через какие области допустимо провести маршрут. + +Terrain, Control и ArealMap используют общие координаты мира, но разные +структуры данных. Нельзя заменять навигационный граф видимыми triangles или +вычислять collision только по границе areal. Render frame описан отдельно в +[Render frame](../reference/render-frame.md); здесь важна подготовка world data, +которую renderer получает уже после загрузки миссии. + +### Поиск области + +Координата переводится в ячейку grid из `Land.map`. Ячейка даёт список +candidate areas, затем выполняется точная геометрическая проверка. Такой запрос +не перебирает все области карты и не зависит от количества terrain faces. + +Если координата попадает в несколько candidates, выбор должен учитывать +геометрию boundary и class/logic flags, а не только первый ID из grid cell. +Если область не найдена, caller получает явный miss и решает, допустим ли +fallback к ближайшей области. + +### Маршрут + +После определения начальной и целевой областей маршрут строится по графу +соседств. Результат высокого уровня -- последовательность areal IDs. Из неё +формируется локальный corridor, внутри которого movement controller выбирает +конкретное движение по поверхности. + +Такое разделение оставляет навигацию устойчивой к деталям terrain mesh: +изменение density triangles не должно менять high-level route, пока areal graph +и links остаются теми же. + +### Категории зон объектов + +`BuildDat.lst` связывает 12 имён категорий с 32-битными масками: + +```text +Bunker_Small 80010000 +Bunker_Medium 80020000 +Bunker_Large 80040000 +Generator 80000002 +Mine 80000004 +Storage 80000008 +Plant 80000010 +Hangar 80000040 +MainTeleport 80000200 +Institute 80000400 +Tower_Medium 80100000 +Tower_Large 80200000 +``` + +Файл читается секционно. Неизвестное имя, дублирование или нарушенная структура +не должны тихо превращаться в нулевую маску. Нулевая маска является +диагностируемым состоянием, а не универсальным default. + +## Создание мира + +Инициализация карты должна быть staged pipeline, а не набором независимых +autoload-ов: + +1. открыть `Land.msh` и построить geometry/spatial данные terrain; +2. открыть `Land.map` и создать areals, links и cell grid; +3. загрузить категории `BuildDat.lst`; +4. создать world managers для поверхности, областей, света и атмосферы; +5. разобрать TMA, paths и clans; +6. раскрыть object resources через unit DAT и `objects.rlb`; +7. подготовить MSH, WEAR, MAT0, Texm, lightmap и FXID dependencies; +8. создать World3D objects и domain controllers в pending state; +9. проверить cross references между components, controllers и spatial data; +10. зарегистрировать visual, physical и behavior components; +11. подключить AI/scripts и разрешить первый calculation tick. + +Минимальный псевдокод объектной части: + +```c +for (const PlacedObject& placed : mission.objects) { + vector<string> keys = expand_resource_name(placed.resource_name); + + for (const string& key : keys) { + Prototype p = registry.resolve(key); + PreparedVisual v = prepare_visual(p); + Object* o = construct_component(p, v, placed.properties); + + o->set_world_transform(placed.transform); + pending_registration.push_back(o); + } +} + +validate_cross_references(pending_registration); +register_all(pending_registration); +``` + +`prepare_visual` использует явные ссылки прототипа и правила fallback ресурсной +системы. Она не должна угадывать модель по имени placed object, если prototype +уже задаёт другой effective MSH/WEAR. + +## Инварианты реализации + +- Reader всех count-driven структур проверяет overflow до выделения памяти. +- Parser TMA, `Land.msh` и `Land.map` завершает работу точно на конце своего + payload. +- Неизвестные поля, reserved bytes, raw strings и property values сохраняются + lossless. +- Object properties остаются ordered property bag; сортировка имён запрещена. +- Clan relations и area links проверяются на диапазон, но физический порядок + записей сохраняется. +- Terrain vertex indices, face neighbors и areal references валидируются до + публикации spatial managers. +- Достижимый missing resource останавливает mission load до регистрации + объектов; недостижимая запись общего каталога остаётся диагностикой. +- Calculation tick включается только после успешной сборки terrain, areal graph, + managers, object queue и scenario bindings. diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md new file mode 100644 index 0000000..6804fa6 --- /dev/null +++ b/docs/tomes/05-render.md @@ -0,0 +1,863 @@ +# V. Геометрия, материалы и рендер + +Этот том описывает путь от загруженного игрового состояния до pixels в back +buffer. Renderer не решает игровые правила: он получает transforms, geometry, +материалы, свет, эффекты, камеру и список видимых объектов, затем превращает +их в упорядоченный набор draw calls и fixed-function states. + +Графический pipeline FParkan держится на нескольких слоях данных: + +```text +MSH node/slot/batch + -> Batch20.material_index + -> строка WEAR + -> имя MAT0 + -> активная phase + -> textureName и lightmap slot + -> Texm payload + -> LegacyRenderState + -> draw item кадра +``` + +Важное практическое правило: форматы ресурсов, runtime-состояние renderer-а и +современный backend являются разными уровнями. Файл можно прочитать правильно и +всё равно получить неверный кадр из-за другой сортировки, другого mip-skip, +другой ветки material fallback или другого округления animation time. + +## Контур рендера + +Изображение является последней стадией длинного цикла. До renderer-а уже +накоплен ввод, рассчитан simulation step, применены отложенные операции, +обновлены animation states, выбрана camera и выставлен listener для 3D sound. + +```text +system messages and input + -> simulation calculation + -> deferred object operations + -> animation and transforms + -> camera and sound listener + -> visibility and render queues + -> materials and draw passes + -> renderer completion + -> end-of-render callbacks and UI +``` + +CPU делает отбор объектов, сэмплирует animation, собирает matrices, выбирает +LOD/slot, группирует batches и готовит состояния. Графический pipeline +преобразует вершины из model space в screen space, rasterizes triangles, +проверяет depth, применяет texture stages, lighting, alpha test/blend и пишет +pixels. + +Координатный путь вершины: + +```text +local/model space + -> world space + -> view/camera space + -> clip space + -> normalized device coordinates + -> viewport pixels +``` + +Порядок умножения матриц и соглашение о layout должны быть едины во всём +движке. Ошибка транспонирования часто выглядит как сломанная анимация, хотя +ключи модели прочитаны верно. + +## Граница Ngi32 + +`Ngi32.dll` является платформенной границей Iron3D-era renderer-а. Она создаёт +графический и звуковой interfaces, перечисляет устройства, хранит capability +profile, предоставляет память, часы и быстрые математические процедуры. +Высокоуровневые DLL должны обращаться к interface Ngi32, а не напрямую к +конкретному DirectDraw/Direct3D device. + +`iron_3d.ini` задаёт выбранный `CURRENT_D3DCARD`. Display layer перечисляет +drivers и video modes, проверяет поддержку 3D, переводит native capabilities во +внутренний профиль и создаёт render object. `niCreate3DRender` принимает +выбранный driver/mode, window handle и flags владения, динамически получает +функции DirectDraw/Direct3D семейства 5-7 и публикует refcounted renderer. +`niGet3DRender` возвращает уже созданный объект и увеличивает число владельцев. + +```text +enumerate adapters and video modes + -> choose CURRENT_D3DCARD + -> translate native capabilities + -> create DirectDraw surfaces and 3D interface + -> construct engine renderer + -> publish global refcounted pointer +``` + +Старый API работает как state machine. Перед draw подсистема terrain/shade +выбирает matrices, texture stages, filtering, depth test/write, culling, alpha +test, blending и vertex format. Современный backend может собрать это в +immutable pipeline key и реализовать через shaders, но compatibility layer +должен видеть исходную fixed-function модель. + +```c +struct LegacyRenderState { + Mat4 world, view, projection; + TextureStage stages[2]; + BlendMode blend; + DepthMode depth; + CullMode cull; + bool alpha_test; + uint8_t alpha_ref; + VertexFormat vertex_format; +}; +``` + +Эта структура является переносимой моделью наблюдаемого контракта, а не +утверждением о точном layout оригинального объекта renderer-а. + +Отдельная часть ABI -- таблица `g_FastProc`. При запуске выбираются scalar, +MMX, Katmai/SSE, 3DNow или PPro-реализации процедур, а `niGetProcAddress(index)` +возвращает pointer из изменяемой таблицы. Номер slot является частью ABI: +signature менять нельзя. Различия scalar/SIMD округления способны менять +animation sampling, culling, particles и даже gameplay-adjacent decisions. + +## MSH как граф модели + +`*.msh` является nested NRes, а не одной монолитной структурой. Geometry, +nodes, slots, batches, animation и служебные streams лежат в отдельных entries +и связываются по `type_id`. Физический порядок entries сохраняется для +roundtrip, но reader не должен выводить из него смысловую связь. + +Карта основных entries: + +```text +type 1 узлы и выбор slot, обычно stride 38 +type 2 header 0x8C + slots по 68 байт +type 3 positions float3, stride 12 +type 4 packed normals, stride 4 +type 5 packed UV0, stride 4 +type 6 index buffer, u16 +type 7 triangle descriptors, stride 16 +type 8 animation keys, stride 24 +type 9 служебный поток модели +type 10 строки и имена узлов +type 13 draw batches, stride 20 +type 15 дополнительный поток, stride 8 +type 17 вспомогательные данные +type 18 редкий поток, stride 4 +type 19 animation frame map, u16 +type 20 редкая вспомогательная таблица +``` + +Базовый набор types стабилен для проверенных моделей Частей 1 и 2. Расширенный +вариант добавляет types 18 и 20. Редкий вариант `MTCHECK.MSH` имеет +альтернативный атрибут type 1; его payload нужно поддерживать copy-through до +закрытия layout. + +### Узлы и slots + +Type 1 обычно состоит из записей по 38 байт: + +```c +struct Node38 { + uint16_t hdr0; + uint16_t parent_or_link; + uint16_t anim_map_start; + uint16_t fallback_key; + uint16_t slot_index[15]; +}; +``` + +`slot_index` образует матрицу `3 LOD x 5 groups`. Выбор выполняется как +`slot_index[lod * 5 + group]`; `0xFFFF` означает отсутствие geometry для этой +комбинации. Поле `parent_or_link` участвует в иерархии или связи узлов, но +название остаётся описательным. + +Type 2 начинается с header `0x8C`, затем содержит slots по 68 байт: + +```c +struct Slot68 { + uint16_t tri_start; + uint16_t tri_count; + uint16_t batch_start; + uint16_t batch_count; + float aabb_min[3]; + float aabb_max[3]; + float sphere_center[3]; + float sphere_radius; + uint32_t opaque[5]; +}; +``` + +Slot связывает диапазон triangle descriptors, диапазон draw batches, AABB и +sphere bounds. AABB удобен для более точных осевых тестов, sphere -- для +быстрого отбрасывания. Последние пять слов сохраняются без интерпретации. + +Обязательные проверки: + +- `type 2` имеет размер не меньше `0x8C`; +- остаток после header кратен 68; +- каждый `slot_index` либо `0xFFFF`, либо меньше числа slots; +- `tri_start + tri_count` не выходит за type 7; +- `batch_start + batch_count` не выходит за type 13. + +### Vertex streams, triangles и batches + +Основные vertex streams: + +```text +type 3: position = три float32 +type 4: normal = четыре int8 +type 5: UV0 = два int16 +type 6: index = uint16 +``` + +Normal XYZ декодируется как signed component / `127.0` с clamp в `[-1, 1]`. +Четвёртый byte normal stream не отбрасывается при roundtrip. UV декодируется +как `packed / 1024.0`. Index buffer адресует вершины относительно `base_vertex` +batch-а, поэтому проверка допустимости всегда использует +`base_vertex + index < vertex_count`. + +Type 7 хранит descriptors triangles: + +```c +struct TriDesc16 { + uint16_t tri_flags; + uint16_t link0; + uint16_t link1; + uint16_t link2; + int16_t nx; + int16_t ny; + int16_t nz; + uint16_t sel_packed; +}; +``` + +Descriptors используются коллизией, выбором и связями triangles. `sel_packed` +содержит три двухбитовых selector-а; значение `3` преобразуется в отсутствие +ссылки (`0xFFFF`). Полная семантика links и flags не закрывается одним layout. + +Type 13 задаёт draw ranges: + +```c +#pragma pack(push, 1) +struct Batch20 { + uint16_t batch_flags; // +0x00 + uint16_t material_index; // +0x02 + uint16_t opaque4; // +0x04 + uint16_t opaque6; // +0x06 + uint16_t index_count; // +0x08 + uint32_t index_start; // +0x0A + uint16_t opaque14; // +0x0E + uint32_t base_vertex; // +0x10 +}; +#pragma pack(pop) +static_assert(sizeof(Batch20) == 20); +``` + +`material_index` выбирает строку WEAR. `index_start`, `index_count` и +`base_vertex` описывают один indexed draw. Неизвестные поля могут влиять на +редкие проходы или state grouping, поэтому writer сохраняет их 1:1. + +Типовой обход модели: + +```c +for (Node& node : model.nodes) { + Matrix node_world = parent_world * local_transform(node); + uint16_t sid = node.slot_index[lod * 5 + group]; + if (sid == 0xFFFF) continue; + + Slot& slot = model.slots[sid]; + if (camera.culls(transform(slot.bounds, node_world))) continue; + + for (uint32_t i = 0; i < slot.batch_count; ++i) { + Batch& b = model.batches[slot.batch_start + i]; + bind_wear_material(b.material_index); + draw_indexed(b.base_vertex, b.index_start, b.index_count); + } +} +``` + +В реальном кадре между culling и draw добавляются material resolve, lightmap, +render queues и сортировка, но связи данных остаются такими. + +## Иерархия и анимация + +Анимация MSH меняет локальный transform узлов. Geometry streams не изменяются: +для каждого узла на кадр строится matrix из position и quaternion. Дочерний +узел наследует transform родителя, поэтому изменение корпуса переносит башню, +точки крепления и все связанные slots. + +Связка состоит из: + +- type 8: пул animation keys; +- type 19: карта кадров; +- `anim_map_start` и `fallback_key` в `Node38`; +- parent links, задающих порядок умножения matrices. + +Ключ type 8 занимает 24 байта: + +```c +struct AnimKey24 { + float position[3]; + float time; + int16_t qx; + int16_t qy; + int16_t qz; + int16_t qw; +}; +``` + +Quaternion components декодируются как signed value / `32767.0`. На диске +порядок полей XYZ-W, но runtime math использует логическое `[w, x, y, z]`. +Безусловная современная нормализация после чтения не добавляется без parity +проверки: она может изменить крайние кадры. + +Type 19 является массивом `uint16_t`; его `attr2` задаёт общее число кадров +timeline. Для конкретного узла `anim_map_start` указывает на блок длиной +`frame_count` либо равен `0xFFFF`. + +Выбор ключа: + +1. вычислить frame index из времени; +2. если frame вне диапазона, взять `fallback_key`; +3. если `anim_map_start == 0xFFFF`, взять `fallback_key`; +4. иначе прочитать `map_words[anim_map_start + frame]`; +5. если значение не меньше `fallback_key`, снова использовать fallback; +6. иначе использовать mapped key и следующий key для interpolation. + +Fallback возвращается без interpolation. Это защищает статические узлы и конец +track-а. + +Для времени между двумя keys: + +```text +alpha = (t - k0.time) / (k1.time - k0.time) +position = lerp(k0.position, k1.position, alpha) +rotation = shortest-path quaternion blend +``` + +Перед quaternion blend проверяется dot product. Если стороны находятся в +противоположных полусферах, знак второй стороны меняется, чтобы пройти по +короткому пути. При точном совпадении времени возвращается соответствующий key +без вычисления alpha. + +Объект может переходить между двумя animation states. Тогда для каждого узла +сэмплируются позы A и B, затем position смешивается линейно, а quaternion -- +через shortest-path blend. Если одна сторона невалидна, используется другая. + +```c +Pose sample_node(Node n, float t); +Pose blend_pose(Pose a, Pose b, float weight); +Mat4 local = quaternion_matrix(pose.rotation); +local.set_translation(pose.position); +world[n] = world[parent(n)] * local; +``` + +Для parity особенно важны x87-compatible округление при выборе frame index и +порядок операций. Одинаковая формула на SSE может выбрать соседний кадр возле +границы. + +Проверки animation data: + +- размер type 8 кратен 24; +- размер type 19 кратен 2; +- каждый `fallback_key` меньше числа keys; +- блок карты узла полностью помещается в type 19; +- времена keys внутри track возрастают; +- parent links не образуют cycle; +- quaternion components читаются как signed 16-bit. + +## WEAR и MAT0 + +MSH batch хранит только числовой `material_index`. WEAR переводит позиционный +slot в имя материала. MAT0 по этому имени описывает phases, parameters, +texture names и animation blocks. Такое разделение позволяет одной geometry +использовать разные appearances. + +```text +Batch20.material_index + -> строка WEAR + -> имя MAT0 + -> активная phase + -> textureName и render parameters +``` + +### WEAR + +WEAR имеет type ID `0x52414557` и обычно хранится как `*.wea` рядом с моделью. +Формат текстовый: + +```text +<wearCount> +<legacyId> <materialName> +... wearCount строк + +[пустая строка] +[LIGHTMAPS +<lightmapCount> +<legacyId> <lightmapName> +... lightmapCount строк] +``` + +`legacyId` читается и сохраняется, но material выбирается по позиции строки и +имени. Пустая строка перед `LIGHTMAPS` является частью совместимого framing: +parser paths по-разному обрабатывают переход, и отсутствие разделителя ломает +совместимость. Material handle кодируется как `(table_index << 16) | +wear_index`; manager поддерживает ограниченное число wear tables. + +Fallback material resolve строго разделён: + +1. имя из WEAR; +2. `DEFAULT`; +3. entry 0; +4. для lightmap отсутствие означает slot `-1`, а не замену обычной texture. + +Пустое имя texture внутри phase означает намеренно untextured surface. +Lightmap ищется в отдельном cache и не подменяется diffuse texture. + +### MAT0 + +MAT0 имеет type ID `0x3054414D` и обычно находится в `Material.lib`. `attr1` +содержит runtime flags, `attr2` -- версию payload. Versioned metadata читается +cursor-ом: старые версии получают runtime defaults, но reader не пытается +насильно читать поля новой версии. + +```c +#pragma pack(push, 1) +struct Mat0PrefixV4Plus { + uint16_t phase_count; // +0x00 + uint16_t animation_block_count; // +0x02, меньше 20 + uint8_t metadata_a; // +0x04, attr2 >= 2 + uint8_t metadata_b; // +0x05, attr2 >= 2 + uint32_t metadata_c_raw; // +0x06, attr2 >= 3 + uint32_t metadata_d_raw; // +0x0A, attr2 >= 4 +}; + +struct Phase34 { + uint8_t parameters[18]; + char texture_name[16]; +}; +#pragma pack(pop) +static_assert(sizeof(Phase34) == 34); +``` + +Если `attr2 < 2`, metadata A/B получают default `255`; при `attr2 < 3` +значение C соответствует `1.0f`; при `attr2 < 4` D равно 0. C/D сохраняются +как raw 32-bit values до полного подтверждения интерпретации. Phase parameters +сохраняются как 18 raw bytes даже там, где часть bytes уже имеет понятный +смысл. + +Каждая phase разворачивается в runtime-запись примерно 76 байт: коэффициенты +цвета, освещения и прозрачности, texture slot и служебные поля. Material time +выбирает одну или две phases; только часть полей интерполируется, остальные +копируются из активной записи. + +Animation block MAT0 имеет плотный framing без 4-byte tail alignment: + +```text +u32 header_raw +u16 key_count +repeat key_count: + u16 k0 + u16 k1 + u16 k2 +``` + +Младшие три бита `header_raw` задают числовой mode, остальные образуют mask +interpolation. Наблюдаются modes 0, 1, 2 и 3, связанные с семействами loop, +ping-pong, one-shot/clamp и random-offset, но точные boundary cases остаются +предметом runtime parity. Поле `k2` сохраняется всегда. + +Проверки MAT0: + +- `animation_block_count < 20`; +- все versioned metadata помещаются в payload; +- секция phases имеет ровно `phase_count * 34` байта; +- `texture_name` ограничено 16 байтами; +- каждый animation block и его keys помещаются в payload; +- parser заканчивает чтение на точном конце записи. + +Material manager кэширует разобранный MAT0 и texture handles. Current phase +лучше вычислять на экземпляр материала, если random offset или локальное время +различаются между объектами; immutable phase data остаются общими. + +## Texm: текстуры, mip-уровни и атласы + +`Texm` -- основной формат изображений. Он хранится в `Textures.lib`, +`LightMap.lib` и других NRes-архивах. Payload содержит header, необязательную +palette, mip chain и иногда `Page` chunk для atlas rectangles. + +```c +struct TexmHeader32 { + uint32_t magic; // 'Texm' + uint32_t width; + uint32_t height; + uint32_t mip_count; + uint32_t flags4; + uint32_t flags5; + uint32_t unknown6; + uint32_t format; +}; +``` + +Подтверждённые formats: + +```text +0 Indexed8 + palette 256 x 4 байта +565 R5 G6 B5 +556 R5 G5 B6 +4444 A4 R4 G4 B4 +88 L8 A8 +888 RGB8 в четырёхбайтовом element +8888 A8 R8 G8 B8 +``` + +Formats 556 и 88 являются loader-confirmed, но не corpus-verified для +доступных игровых payload. CPU decoder расширяет короткие каналы до 8 bit через +повторение значимых bit, а не простым shift. Для 888 служебный четвёртый byte +сохраняется при roundtrip. + +Layout: + +```text +TexmHeader32 +[palette 1024 байта, только для format 0] +level 0 pixels +level 1 pixels +... +level mip_count-1 pixels +[optional Page chunk] +``` + +Размер уровня `i` вычисляется из `max(1, width >> i)` и +`max(1, height >> i)`. Bytes per pixel: 1 для indexed; 2 для 565, 556, 4444 и +88; 4 для 888 и 8888. Parser суммирует размеры с проверкой overflow до чтения. + +`Page` chunk: + +```c +struct PageHeader8 { + uint32_t magic; // 'Page' + uint32_t rect_count; +}; + +struct PageRect8 { + int16_t x; + int16_t width; + int16_t y; + int16_t height; +}; +``` + +Chunk обязан иметь размер `8 + rect_count * 8`; произвольный tail не +допускается. Rectangles задаются в pixel space базового mip. Если loader +пропускает верхние mip-уровни, rectangles масштабируются вместе с новым base +level. + +Mip-skip является поведением loader-а, а не offline-изменением файла. После +skip меняются runtime width, height, mip count и pointer на первый загружаемый +уровень. Современный renderer должен повторить выбор base level или +эквивалентно эмулировать его upload policy; использование полной texture при +тех же UV меняет резкость и atlas coordinates. + +Indexed texture требует связанную palette. Часть palettes выбирается по suffix +имени: буква `A..Z` и вариант пустой или `0..9`, всего 286 возможных slots. +Невалидный suffix диагностируется явно. + +Обычные textures и lightmaps находятся в разных managers. Обычный cache +отслеживает refcount и время неиспользования, а eviction выполняется +отложенно. Lightmap lifetime связан с world/mission и не должен попадать под +ту же политику удаления. + +Строгий Texm parser проверяет положительные dimensions, положительный +`mip_count`, известный format, точный размер palette/mip chain, корректный +`Page` и отсутствие лишних bytes. `flags4`, `flags5` и `unknown6` сохраняются +1:1; участие `flags5` в mip-skip подтверждено, но полная семантика всех bits не +закрыта. + +## Свет, тени, атмосфера и сортировка + +Свет является отдельной world-подсистемой. Terrain layer создаёт +`LightManager`, `Shader` и primitive managers. Это не один глобальный +коэффициент яркости: world управляет point lights, lightmaps, shadows, +atmospheric objects и sort phases. Материал сообщает свойства поверхности, а +CShade превращает их в states renderer-а. + +Подтверждённые точки: `CreateLightManager`, `CreateShader`, +`CreateAtmosphere`, `CreatePrimitives`, `CreatePrimitives2`, +`CShade::StartMeshRender`, `CShade::EndMeshRender` и +`CShade::ConfigureTextureAndAlphaBlendModes`. + +CShade получает active MAT0 phase, capability profile устройства и pass +context. Он выбирает texture mode, alpha blending, depth/cull behavior и способ +освещения. Наличие fallback вроде `TEXTUREMODE_MODULATE not supported` +означает, что material нельзя напрямую преобразовать в современный PBR. +Сначала строится legacy state, затем он сопоставляется shader permutation. + +CLightManager выдаёт numeric IDs источникам и проверяет допустимое количество. +Ветка `EmulatePointLights()` позволяет воспроизводить point lights даже при +ограничениях hardware lighting. Неизвестный type light должен давать отдельную +ошибку. + +Lightmap не является обычной diffuse texture. WEAR содержит отдельный блок +`LIGHTMAPS`, manager открывает `LightMap.lib`, а shade path подаёт lightmap +отдельным slot или texture stage. Замена lightmap предварительным умножением в +diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию. + +Тени проходят отдельным render pass. Terrain содержит пути для теней зданий и +роботов, ограничения максимального числа, detail level и smoothing. Доказаны +shadow manager/pass, настройки detail/smoothing/count и зависимость от +Terrain/CShade; полная формула projection geometry для каждого caster требует +dynamic trace. Unknown settings из `shade.cfg` читаются и сохраняются по +именам, а не заменяются произвольными modern defaults. + +Atmosphere manager создаёт world objects для фоновых и погодных явлений. +Отдельно подтверждены lightning, sun render, flare, `env_lightning`, rain +background sound и обязательные ссылки на lightning effect. Эти объекты +обновляются по игровому времени, но часть параметров зависит от camera: flare +требует screen position и occlusion test, rain -- области рядом с observer, +sound -- listener. Их нельзя один раз запечь в terrain. + +RNG для lightning, atmosphere phases и FX должен иметь стабильный порядок. +Даже правильный средний интервал не даёт повторяемый кадр, если random values +запрашиваются в другой последовательности. + +Согласованная модель sort phases: + +```text +opaque terrain and models + -> lightmapped/state-grouped passes + -> shadows and projected primitives + -> alpha-tested surfaces + -> transparent objects/effects back-to-front + -> atmosphere, flares and overlays +``` + +Точный взаимный порядок отдельных FX, shadow и atmosphere subpasses требует +capture. Новый renderer должен хранить явный `RenderPhase` и стабильный +secondary sort key, а не сортировать всё только по material ID. + +## FXID: система эффектов + +FXID -- не готовая картинка, а описание небольшого runtime command stream. +Header задаёт lifetime, time mode, random shifts и transform. Затем идут +команды разных types. При создании manager превращает disk-команды в runtime +objects; во время кадра они обновляются и выпускают sounds, particles, +materials или projected primitives. + +Type ID равен `0x44495846`. Header занимает 60 байт: + +```c +struct FxHeader60 { + uint32_t command_count; + uint32_t time_mode; + float duration_seconds; + float phase_jitter; + uint32_t flags; + uint32_t settings_id; + float random_shift[3]; + float pivot[3]; + float scale[3]; +}; +``` + +Поток команд начинается строго с offset `0x3C`. `duration_seconds` +преобразуется runtime-ом во внутреннюю шкалу времени. `phase_jitter` и +`random_shift` используются только при соответствующих flags. Pivot задаёт +локальную точку опоры, scale -- базовый масштаб экземпляра. Unknown flags и +settings ID сохраняются. + +Каждая команда начинается с `uint32_t command_word`: + +```text +opcode = command_word & 0xFF +enabled = (command_word >> 8) & 1 +``` + +Bits 9-31 являются частью данных и сохраняются. Между командами нет +выравнивания. Размер команды, включая word: + +```text +opcode 1 224 байта +opcode 2 148 байт +opcode 3 200 байт +opcode 4 204 байта +opcode 5 112 байт +opcode 6 4 байта +opcode 7 208 байт +opcode 8 248 байт +opcode 9 208 байт +opcode 10 208 байт +``` + +Parser использует opcode только для выбора фиксированного размера. Неизвестный +opcode отклоняется: попытка угадать длину потеряет синхронизацию всего stream. + +Opcodes 2, 3, 4, 5, 7, 8, 9 и 10 содержат pair fixed strings: + +```c +struct FxResourceRef64 { + char archive[32]; + char name[32]; +}; +``` + +Имена сравниваются case-insensitive по ASCII, а tail после первого nul byte +сохраняется. Resolve выполняется при создании command object или лениво при +первом запуске, но ошибка должна включать имя эффекта, номер команды, archive +и resource name. + +Базовый normalized age: + +```text +tn = (now - start_time) / (end_time - start_time) +``` + +`time_mode` выбирает источник коэффициента: constant, forward/reverse age, +cyclic phase, external world state и варианты с ограничением относительно +предыдущего значения. Точные формулы редких modes являются parity-задачей. +Flags могут умножать alpha на lifetime, применять triangular remap, случайно +сдвигать phase/space, инвертировать active-state, фильтровать по времени суток +или включать manager gates. + +Lifecycle: + +```text +create instance + -> copy header and external transform + -> calculate end time and random offsets + -> create command objects in disk order + -> resolve required resources + -> Start + +on each calculation/render frame + -> evaluate time coefficient and gates + -> update commands in stable order + -> emit active primitives or sounds + -> collect render batches + -> handle Stop / Restart / end-of-life +``` + +Update и emit разделяются. Simulation может продолжаться в кадре без render, а +emit не должен повторно менять игровое состояние. Для authoring безопасно +типизировать header и resource references, а body редких commands сохранять raw +до подтверждения field-level semantics. + +## Полный кадр + +Крупный вход в world render проходит через `World3D::stdRenderGame`. Доказан +следующий порядок boundary операций: + +1. передать camera в Terrain через `stdSetCurrentCamera2` и сохранить её как + текущую; +2. получить camera/view/viewport interfaces через virtual queries; +3. обновить положение и ориентацию 3D sound listener; +4. настроить renderer viewport и matrices; +5. вызвать два renderer boundary slots перед traversal; +6. установить глобальный флаг `in_render`; +7. вызвать главный virtual метод camera/world traversal; +8. выполнить дополнительную post queue при включённом режиме; +9. завершить world/shade pass; +10. вызвать renderer completion slot; +11. снять `in_render`, восстановить viewport и разослать end-of-render. + +Семантические имена нескольких slots перед и после traversal не подтверждены, +поэтому в compatibility code их лучше временно называть +`frame_boundary_0`, `frame_boundary_1`, `frame_boundary_2`. + +Обход видимого мира: + +```text +проверить active/visible state + -> выбрать LOD по расстоянию и настройкам + -> получить node matrices из animation state + -> выбрать slot для каждого node/group + -> преобразовать bounds в world space + -> выполнить culling + -> добавить batches в подходящую render queue +``` + +Material/texture resolve желательно выполнять после visibility и slot +selection, чтобы невидимые объекты не меняли порядок обращений к caches и не +создавали лишние side effects. Невидимость объекта и отсутствие slot являются +разными причинами пропуска и диагностируются отдельно. + +Подготовленный draw item содержит: + +```text +node world matrix +batch flags and index range +WEAR material handle +MAT0 active phase and coefficients +texture handle +optional lightmap handle +render phase and sorting key +legacy pipeline state +``` + +Draw item должен ссылаться на immutable данные кадра. Изменение phase или +texture cache посреди прохода не должно менять уже собранную очередь. + +Согласованная декомпозиция внутренних render phases: + +1. подготовка frame state, camera и viewport; +2. непрозрачный terrain; +3. непрозрачные object batches; +4. lightmap и дополнительные material passes; +5. projected primitives и тени; +6. alpha-tested geometry; +7. transparent objects и FX в сортировочных слоях; +8. atmosphere, sun, flare и weather; +9. renderer completion boundary; +10. end-of-render callbacks; +11. shell/UI и post-render state. + +Точный взаимный порядок пунктов 4-8 и связь completion slot с физическим +DirectDraw flip/present требуют dynamic capture. Сортировка внутри каждой фазы +должна быть стабильной: для opaque первичен pipeline/material key, для +transparent -- distance layer и depth order, затем stable insertion ID. + +Геометрический draw использует streams type 3/4/5, optional streams, index +buffer type 6, `base_vertex`, `index_start` и `index_count`. Матрица узла +устанавливается как world transform, затем CShade привязывает texture stages и +fixed-function state. + +```c +set_world_matrix(item.node_world); +bind_vertex_streams(model.streams); +bind_index_buffer(model.indices); +apply_legacy_state(item.pipeline); +bind_texture(0, item.texture); +bind_texture(1, item.lightmap); +draw_indexed(item.batch.base_vertex, + item.batch.index_start, + item.batch.index_count); +``` + +После последнего world pass renderer закрывает сцену и выводит back buffer. +World3D снимает `in_render`, восстанавливает временный viewport state и вызывает +`on_end_render` у active objects. Только после этого допустимо освобождать +temporary vertex buffers или заменять render representation. UI/shell +обслуживается верхним уровнем после возврата из world-render path; для +диагностики полезно уметь сохранять world-only command list и финальный +framebuffer отдельно. + +## Проверки паритета + +Главные риски совпадения кадра: + +- x87 extended precision и правила округления; +- различия scalar/SIMD slots `g_FastProc`; +- порядок objects, batches и transparent primitives; +- depth write/test, cull, alpha test и blend transitions; +- mip-skip, palette и `Page` coordinates; +- material fallback и выбор phase; +- последовательность RNG для FX и atmosphere; +- capability fallback конкретного устройства; +- quantization времени и дополнительный simulation step; +- eager/lazy resource resolve и cache side effects. + +Минимальный deterministic frame capture должен включать camera state, viewport, +visible object IDs, выбранные LOD/group/slot, draw-item list, material и texture +handles, pipeline keys, matrices, render phase, sort key, причины culling и +hashes промежуточных buffers. Без такой трассировки нельзя уверенно отделить +ошибку формата MSH от ошибки state machine renderer-а или сортировки. + +Связанные справочные страницы с таблицами форматов: [MSH](../reference/msh.md), +[materials](../reference/materials.md), [Texm](../reference/texm.md) и +[render frame](../reference/render-frame.md). diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md new file mode 100644 index 0000000..93ec301 --- /dev/null +++ b/docs/tomes/06-behavior.md @@ -0,0 +1,769 @@ +# VI. Поведение, управление, звук и сеть + +Шестой том описывает подсистемы, которые превращают загруженный мир в +реагирующую игру: AI, Behavior, Wizard, Control, ввод, камеру, звук и сеть. +Эти области нельзя восстанавливать только по структуре файлов. Для них важны +порядок кадра, ownership объектов, timing событий и доказуемые границы между +решением, движением, presentation и транспортом. + +Ключевой принцип: reader compatibility не равна gameplay compatibility. +Корректно разобранный ресурс ещё не доказывает, что runtime выбирает ту же +цель, строит тот же маршрут, применяет ту же collision correction, создаёт тот +же sound event или отправляет тот же network payload. Поэтому все утверждения +ниже разделяют подтверждённую структуру, восстановленный архитектурный +контракт и открытые участки, требующие динамической трассировки. + +```text +AI / mission script + -> стратегическая цель, условия, команды миссии +Behavior + -> состояние объекта, target, global/local path +Wizard + -> локальная коррекция траектории +Control + -> physical step, collision proxy, итоговый transform +World3D + -> очередь событий, ownership, deferred deletion +Render / Sound / Net + -> представление, listener, mirrors и сообщения +``` + +Связанные главы: [мир и миссии](04-world.md), [геометрия и рендер](05-render.md) +и справочный [render frame](../reference/render-frame.md). + +## AI, Behavior и Wizard + +Iron3D разделяет стратегическое принятие решений, поведение конкретного объекта +и локальную коррекцию движения. Это разделение должно сохраниться в новой +реализации: стратегический AI не меняет transform напрямую, а collision manager +не выбирает игровую цель. + +```text +ai.dll / SuperAI + -> цель клана, миссии и группы +Behavior.dll + -> состояние юнита, target, global path, local corridor +Wizard.dll + -> ближайшая допустимая траектория +Control.dll + -> физическое движение и столкновения +``` + +### Behavior + +`CreateBehaviour` создаёт controller для отдельного игрового объекта. +`CreateDistributor` восстановлен по consumers как посредник распределения +команд или ресурсов; это высокоуверенный архитектурный вывод, а не доказанное +имя внутреннего класса. Behavior получает `IArealMap` через AI/клановый +контекст, ведёт radar/target state, строит global path, превращает его в local +corridor и передаёт движение Wizard. + +Ошибочные состояния проверяются явно: + +1. отсутствует system map; +2. отсутствует terrain interface; +3. active behavior не имеет `IArealMap`; +4. объект попал в non-reachable area; +5. объект пытается выйти из non-walkable area; +6. path generator вошёл в infinite cycle. + +Эти случаи являются fatal или diagnostic conditions. Совместимая реализация не +должна тихо исправлять их teleport-ом, потому что такое исправление скрывает +ошибку areal graph, terrain query или state machine. + +### Параметры Behavior.ini + +Подтверждены настройки: + +```text +PathFind_BuildingHitDist +PathFind_BuildingNearestDist +PathFind_NearBuildSpeedPercent +PathFind_CorridorRadius +PathFind_NearDoorCoeff +PathFind_fStepOffBuilding +PathFind_MaxAccel +PathFind_MaxRotation +PathFind_fStepDist +PathFind_MinPointInTrajectory +Network_ResourceTransferMaxDelay +``` + +Они задают геометрию corridor, дистанции реакции на здания, снижение скорости +возле препятствий, пределы ускорения и поворота, дискретизацию trajectory и +сетевой timeout передачи ресурсов. Значения читаются как runtime-конфигурация, +а не компилируются в код. Parser должен поддерживать комментарии `//`, пробелы +вокруг `=` и CRLF. + +Файл также содержит logging/debug switches: `Behavior.log`, уровни ошибок, +show vectors и z-buffer debug. Эти переключатели полезны не только для +совместимости, но и как модель современных trace flags. + +### Wizard + +Wizard получает желаемое направление и corridor, анализирует ближайшие +ограничения и выдаёт скорректированную локальную траекторию. Behavior может +очищать её через `ClearWizardPath` при смене цели, повреждении global path или +переходе объекта в неактивное состояние. + +Нужно различать четыре уровня движения: + +- **global path** -- последовательность areals; +- **local path** -- точки или сегменты внутри corridor; +- **wizard path** -- краткосрочное движение с учётом ближайших препятствий; +- **physical step** -- фактически разрешённое Control перемещение. + +Хранение всего маршрута одним массивом лишает систему возможности локально +обойти препятствие без полного повторного поиска. Граница Behavior/Wizard +существует именно для того, чтобы краткосрочная геометрическая коррекция не +ломала стратегический path state. + +### SuperAI и миссионные сценарии + +`CreateSuperAI` создаёт центральный controller клана; `GetSuperAI` возвращает +его. AI загружает файлы из `MISSIONS\SCRIPTS\`, проверяет версию и пишет ошибки +в `ai.log`. Несовпадение версии является отдельной ошибкой, а не неизвестной +командой. + +Сценарный корпус содержит binary `.scr`, formula exports `.fml`, таблицу +переменных `varset.var` и `.trf`-данные. `.scr` хранит именованные секции и +события, например `Init`, `Mission`, `Problems0`, `Fort_Task_Complete` и +`Hero_Teleported`, вместе с числовыми ссылками на compiled instructions. +`.fml` является текстовым экспортом formula set. `varset.var` декларативно +описывает типы, defaults, ranges и строки через макросоподобные формы +`VAR(...)` и `STRING(...)`. + +Безопасная runtime-модель: + +```text +load script bundle + -> validate version and symbol tables + -> create global/formula variables + -> bind named events to instruction offsets + -> instantiate SuperAI per clan + -> dispatch MISSION_START and object events + -> update timers/conditions each simulation tick + -> enqueue game commands through World3D/Behavior +``` + +Сценарий не должен владеть игровым объектом напрямую. Он хранит logical/object +IDs и отправляет команды через игровые interfaces, чтобы удаление объекта или +сетевой mirror не оставили dangling pointer. + +Полная grammar compiled instructions и точное значение всех opcodes остаются +открытым направлением. До появления decompiler-а `.scr` binary body сохраняется +lossless, а доказанные symbol/event tables документируются отдельно. + +### TRF и preload-данные + +TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial +variants имеют сигнатуру [NRes](../reference/nres.md) и содержат большие +таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов. +Также найдены preload-записи, ANI и SKE resources. + +По содержимому, порядку загрузки и consumers TRF с высокой вероятностью +предоставляет AI/сценарному слою заранее подготовленную таблицу типов и +связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика +каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же +resource registry, что и миссионные объекты. + +### Стабильность AI-слоя + +`ai.dll`, `Behavior.dll` и `Wizard.dll` побайтно идентичны в Частях 1 и 2. Это +подтверждает, что разделение SuperAI -> Behavior -> Wizard и бинарная +реализация этих трёх уровней не менялись. + +Сценарный корпус: + +```text +Часть 1: 58 SCR, 58 FML, 29 TRF +Часть 2: 59 SCR, 59 FML, 44 TRF +``` + +Все TRF являются структурно валидными NRes. Неизменность DLL усиливает вывод о +стабильной VM, но не закрывает instruction grammar `.scr`: для неё нужен +dispatcher/jump-table decompiler. Дополнительные сценарные данные расширяют +differential corpus, но не заменяют анализ VM. + +## Control, физика и коллизии + +Control превращает желаемое движение в физически допустимое изменение +состояния. World3D владеет жизненным циклом объекта; Terrain предоставляет +поверхность и world queries; Behavior/Wizard задают намерение; Control создаёт +physical controller и collision representation. + +Публичная поверхность: + +```text +InitializeSettings +LoadControlSystem +LoadPhysicalModel +CreateCollManager +CreateCollObject +``` + +Модуль импортирует World3D queue/object functions, `Terrain::GetWorld`, часы, +тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay +object и геометрией мира. + +### Control system и physical model + +`LoadControlSystem` загружает настройки controller-а: ограничения скорости, +ускорения, поворота и режимы управления. `LoadPhysicalModel` загружает форму и +параметры, используемые для столкновений. Visible MSH не обязан совпадать с +collision representation: для физики часто нужна более простая и устойчивая +форма. + +Практичная runtime-модель: + +```c +struct PhysicalState { + Transform transform; + Vec3 linear_velocity; + Vec3 angular_velocity; + float requested_speed; + float requested_turn; + uint32_t flags; +}; + +struct CollisionProxy { + ObjectId owner; + ShapeSet shapes; + Bounds broad_phase_bounds; + uint32_t category_mask; +}; +``` + +Названия полей здесь описывают контракт совместимой реализации, а не точный +layout исходного C++-объекта. + +### Collision pipeline + +Один расчётный шаг удобно разделить так: + +1. controller получает желаемые `speed`/`turn` от Behavior или manual input; +2. вычисляет кандидатный transform на основе `dt`; +3. обновляет broad-phase bounds collision object; +4. collision manager находит потенциальные пары и terrain candidates; +5. narrow phase вычисляет контакт или допустимый остаток перемещения; +6. physical state корректируется; +7. World3D получает итоговый transform; +8. событие `GMSG_COLLISION_DETECTED` отправляется в согласованной фазе. + +Позиция collision event после narrow phase является рекомендуемой фазой +реализации и согласуется с назначением сообщения, но точный call-site +относительно всех correction steps требует динамической трассировки Control. +Удаление объекта из обработчика остаётся отложенным по правилам World3D. +Collision manager не должен хранить прямую незащищённую ссылку на объект, +который уже pending-delete. + +### CTLD и physical resources + +Реестр прототипов ссылается на `*.ctl`, `*.cpt` и связанные control resources. +В Части 1 структурно проверен 531 CTLD payload без ошибок. Размеры и пять +внутренних счётчиков образуют множество вариантов: наиболее частый размер +392 байта с pattern `(0,0,0,1,0)`, но встречаются блоки от примерно 212 до +1868 байт и более сложные комбинации. + +CTLD является составным count-driven форматом, а не фиксированной struct. +Parser должен: + +- прочитать prefix и все счётчики с проверкой переполнения; +- вычислить границы секций по их counts; +- сохранять неизвестные records в typed raw containers; +- требовать точного завершения payload; +- не использовать размер одного популярного варианта как универсальный layout. + +Полная предметная семантика всех секций ещё не доказана, но существующие файлы +можно безопасно читать, индексировать и сохранять. + +### Terrain queries и movement handoff + +Control получает world-interface Terrain и использует поверхность, faces и +ускорители для высоты, нормали и пересечений. Навигационный маршрут сообщает, +куда двигаться, но итоговый transform определяется по физической поверхности. +При переходе через склон controller должен согласовать горизонтальный шаг, +высоту и ориентацию с terrain normal. + +Порядок операций должен быть детерминированным: пары collision objects +сортируются по стабильному ID, contacts обрабатываются в фиксированной +последовательности, а интеграция использует одну политику `dt` и округления. +Иначе одинаковая миссия постепенно расходится даже без сети. + +### Различия Control в Части 2 + +`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals +exports; RVA всех пяти exports изменились. Форматы и cross-module boundary +сохранились, но точное physical/collision behavior нельзя считать побайтно тем +же. + +CTLD-корпус расширен с 531 до 623 payload. Новых framing errors не найдено; +большинство общих CTLD изменено вместе с переработанными моделями. Это +подтверждает count-driven parser, но не закрывает предметную семантику shape +records и contact solver. + +Differential test обеих частей должен воспроизводить движение без препятствий, +slope following, pair collision, timing collision event и удаление объекта в +callback. Сравниваются transforms и contact events по tick, а не только факт +успешной загрузки. + +## Ввод, камера и управление + +World3D нормализует клавиатуру, мышь и joystick в общие scan codes и manual +commands. Win32 message handler вызывает `UpdateManualEventsList`; перед +обработкой новой порции сообщений основной цикл вызывает +`ClearManualEventsList`. Снимок клавиатуры очищается отдельно через +`stdClearKeyboard`. + +Публичная поверхность включает `WinMsg2ScanCode`, converters для +keyboard/mouse/joystick/predicate, `ScanCode2Str`, `ManualCommand2Str`, +`stdIsKeyPressed`, lock/unlock keyboard и чтение mouse shift. Это позволяет +хранить конфигурацию управления независимо от физического устройства. + +### Event, state и axis + +Ввод имеет минимум три семантики: + +- **edge event** -- нажатие или отпускание в текущей порции сообщений; +- **held state** -- клавиша остаётся нажатой между кадрами; +- **analog value** -- смещение мыши или положение joystick axis. + +Manual command дополняет источник коэффициентом, режимом wrap, dead +zone/threshold и временной характеристикой. Строки camera bindings показывают +команды `MCMD_STATE`, `MCMD_ANGLE_X`, `MCMD_ANGLE_Y`, режимы `MAN_WRAP` и +`MAN_NOTWRAP`, а также параметры ускорения в миллисекундах. + +Simulation читает подготовленный input snapshot. Renderer не должен +самостоятельно опрашивать OS, иначе одно и то же нажатие будет зависеть от +частоты кадров. + +### Joystick через DirectInput + +`Joystick.dll` экспортирует: + +```text +QueryJoy +CreateJoy +ReleaseJoy +SetJoyRange +PeekJoyMessage +GetJoyCaps +``` + +`QueryJoy` обнаруживает устройство, `CreateJoy` получает интерфейс DirectInput, +`SetJoyRange` нормализует оси в диапазон движка, `PeekJoyMessage` выдаёт +очередное унифицированное событие. + +При потере устройства чтение может вернуть ошибку acquired state. Интерфейс +следует повторно получить, очистить устаревшее состояние и продолжить. +Hot-unplug не должен оставлять последнюю ось навсегда отклонённой. +`GetInstalledJoyNames` и `SetActiveJoy` в World3D связывают device list с +game-facing выбором. + +### Два camera interface + +World3D предоставляет `stdSetCurrentCamera`/`stdGetCurrentCamera`: это камера +как часть игрового состояния. Terrain имеет +`stdSetCurrentCamera2`/`stdGetCurrentCamera2`: concrete camera, которую world +renderer использует для matrices, viewport и visibility. + +`LoadCamera` экспортирован обоими модулями. По call graph World3D-вариант +играет роль component bridge, а Terrain-вариант связан с concrete +camera/world implementation. Это архитектурный вывод: точные class names и +layout не восстановлены. + +Минимальные данные камеры: + +```text +world position and orientation +view matrix +projection parameters / field of view +near and far planes +viewport rectangle +camera mode and target object +manual angles/state +``` + +Такая граница позволяет game code работать с абстрактной камерой, не зная +внутреннего renderer representation. + +### Camera commands и порядок кадра + +Подтверждены команды `CMD_CAMERA_LEFT`, `CMD_CAMERA_RIGHT`, `CMD_CAMERA_UP`, +`CMD_CAMERA_DOWN`, `CMD_CAMERA_CENTER`, `CMD_CAMERA_INFRARED`, а также +spotlight и внешние/миссионные camera modes. Горизонтальный угол использует +wrap, вертикальный -- ограниченный диапазон. Center плавно возвращает обе оси к +заданному значению. + +Порядок кадра: + +1. собрать manual events; +2. обновить camera controller во время calculation; +3. вычислить итоговый transform и ограничения; +4. перед render установить current camera; +5. передать её Terrain и sound listener; +6. после кадра сохранить mode-specific state. + +Camera smoothing должно использовать игровое время или специально +подтверждённые часы. Привязка к render delta делает управление разным при 30 и +144 FPS. + +## Звуковая подсистема + +Ngi32 создаёт низкоуровневый DirectSound backend. `services.dll` публикует +`ISoundServer`. Game, Terrain и FX работают уже через эти интерфейсы: +воспроизводят 2D/3D sources, меняют volume и связывают listener с camera. + +Публичные функции Ngi32: + +```text +niCreate3DSound +niGet3DSound +niGet3DSoundCaps +niMuteSound +``` + +Backend динамически вызывает `DirectSoundEnumerateA` и `DirectSoundCreate`; +параметр `DisableDSound` может полностью отключить этот путь. + +### Устройство и capabilities + +Конфигурация учитывает `3D Sound`, качество, reverse sound, частоту buffer, +режим постоянного воспроизведения и автоматический выбор лучшего устройства. +Эти значения преобразуются во внутренний capability/profile object до создания +sources. + +Код содержит отдельный no-device state и строку `3D Sound was not initialized`. +Отсутствие 3D sound обрабатывается отдельно от ошибок simulation/resources. +Новый runtime не должен позволять отсутствию звука разрушать simulation и +обязан возвращать звуковым командам явный no-device result. + +Общий sound object разделяется между подсистемами и использует счётчик +владельцев. Закрывать DirectSound следует после остановки всех sources и +atmosphere/FX managers. + +### Sound resources и SWAV + +Основная библиотека называется `sounds.lib`; `mission.cfg` также создаёт +именованные sound resources и variations. Legacy API `rsLoadWave` загружает +waveform из archive. Импорт `MSACM32` подтверждает путь преобразования сжатых +wave-данных в формат playback buffer. + +Resource identity состоит из library и name. Один sound asset может иметь +несколько runtime sources с различными position, volume, pitch/flags и временем +запуска. Поэтому кэшировать следует decoded sample/buffer, а source object +создавать на событие. + +FX opcode 2 хранит `archive[32] + name[32]` и обычно создаёт sound command. +Atmosphere использует отдельные loop/variation sources, например rain +background. Миссионный слой содержит voice events для завершения или провала +задания. + +Проверенный SWAV-корпус: + +```text +Часть 1: 399 — 306 MS ADPCM, 93 PCM +Часть 2: 540 — 446 MS ADPCM, 93 PCM, 1 empty entry +``` + +Все непустые записи имеют RIFF/WAVE framing и частоту 22 050 Hz. В Части 2 +entry `ALIEN_ME.WAV` имеет размер 0. Это присутствующий archive key без +decodable waveform. + +Sound loader должен различать: + +- `entry_missing`; +- `entry_empty`; +- `wave_invalid`; +- `decoded_sample`. + +Нулевой payload не передаётся RIFF parser-у и не должен приводить к чтению +header за границей. + +### 3D listener и sources + +Перед world traversal `stdRenderGame` обновляет listener из camera transform. +Listener содержит position, orientation и, при наличии, velocity. Source +содержит world position и параметры затухания. Spatialization выполняется +backend-ом либо совместимой программной моделью. + +```text +camera transform + -> listener position/front/up +object or effect transform + -> source position +sample + source parameters + -> DirectSound 3D buffer +``` + +Прямо подтверждено обновление listener в начале `stdRenderGame`, до world +traversal. Sound events могут создаваться и в calculation/FX path, поэтому +нельзя утверждать, что listener предшествует созданию каждого source. Важно, +что spatial backend получает camera state текущего отображаемого кадра до +завершения его обработки. Перенос listener update после world render создаст +как минимум однокадровое рассогласование presentation. + +### Громкость, mute и CD-аудио + +`iron3d.dll` применяет отдельные настройки эффектов и CD sound. Параметр +`FORCE_CD_SOUND` меняет политику выбора музыкального источника. `niMuteSound` +должен временно остановить вывод без разрушения sample cache и logical playback +state. + +В новой реализации полезно разделить buses: master, effects, ambient, voice и +music/CD. Это проектное решение совместимого backend-а, а не доказанный layout +оригинального mixer-а. Оно позволяет применять старые коэффициенты, не +переписывая individual source volume. + +### Граница service layer + +`Ngi32.dll` с DirectSound/backend code не изменилась между Частями 1 и 2, но +`services.dll` пересобрана и уменьшилась на 4 096 байт. Поэтому low-level +decoder/device path подтверждается одной машинной реализацией, а service +lifecycle, GUI/audio wiring и defaults требуют раздельной трассировки обеих +частей. + +## Сетевая подсистема + +Net инкапсулирует DirectPlay4A и lobby/service-provider API. World3D строит над +транспортом player identity, mirror objects и игровые сообщения. Эти уровни +следует разделять: DirectPlay отвечает за доставку bytes между players, +World3D -- за смысл сообщения и владение объектом. + +Application GUID: + +```text +{3C1D1F01-A870-11D1-8400-000021B14415} +``` + +Он передаётся network instance и service layer. Экземпляры с другим GUID не +принадлежат одному логическому приложению. + +### Lifecycle соединения + +Публичные функции Net покрывают полный цикл: + +```text +CreateNetworkInstance + -> select/use service provider + -> setup connection + -> enumerate or create session + -> join/create session + -> create local player + -> send/receive messages and player data + -> destroy player + -> close session + -> close connection +``` + +Поддерживаются providers эпохи DirectPlay: TCP/IP, IPX и modem/lobby варианты, +если они установлены в системе. Функции явно проверяют, что DirectPlay enabled +до enumeration, session и player operations. Неверный порядок вызовов должен +возвращать понятную ошибку, а не разыменовывать пустой interface. + +### Sessions, players и адреса + +Net предоставляет enumeration service providers и sessions, выбор host/join, +player name/password/data, latency, максимальный размер сообщения, размер +очереди, server player info и provider address. Lobby launch обрабатывается +отдельной веткой. + +Внутренняя модель должна хранить как минимум: + +```c +struct NetPlayer { + TransportPlayerId transport_id; + uint16_t game_player_number; + string name; + RawBytes player_data; + bool is_local; + bool is_host; +}; +``` + +Transport ID нельзя использовать как постоянный `ObjectId`. NetWatcher связывает +временный DirectPlay identifier с номером игрока и World3D entities. + +### Игровые сообщения World3D + +Подтверждённые имена message surface: + +```text +GMSG_CREATE_REMOTE_PLAYER +GMSG_APPEND_RESOURCE +GMSG_CHANGE_OBJECT_OWNER +GMSG_SET_PLAYER_DATA +GMSG_MISSION_DATA_PATH +GMSG_TAKE_OBJECT +GMSG_TEXT_FOR_PLAYER +GMSG_SYNC_STATE +GMSG_CREATE_MIRROR +GMSG_PAUSE_REMOTE_PLAYER +GMSG_CONFIRM_PLAYER_DATA +GMSG_KILL_PLAYER +SYSMSG_SET_TIME +SYSMSG_SET_PLAYER_NUMBER +GMSG_END_MESSAGE_SEQ +GMSG_REMOVE_RESOURCE +``` + +`GMSG_COLLISION_DETECTED` относится к общей очереди, но не обязательно +передаётся по сети. Message ID, payload size и delivery policy должны быть +частью явной schema. Нельзя сериализовать C++ pointers или native padding. + +### Mirror objects и ownership + +Удалённо принадлежащий объект представлен local mirror instance. Он участвует в +рендере и spatial queries, но authority над его созданием, ключевыми properties +и удалением находится у owner player. Сообщение смены владельца обновляет эту +границу; оно не должно создавать второй объект с тем же ID. + +Типовой путь: + +```text +remote create message + -> validate player and ObjectId + -> resolve prototype/resources + -> CreateMirrorObject + -> apply initial state + -> AddMirrorObjectToGame + -> subsequent sync messages update mirror +``` + +При потере player NetWatcher инициирует предписанное удаление или transfer +ownership через World3D queue. Мгновенное освобождение во время receive callback +запрещено по тем же причинам, что и в calculation pass. + +### Сжатие и wire compatibility + +`netZipData` и `netUnZipData` образуют встроенный слой упаковки payload. Он +находится выше транспорта: переход с DirectPlay на UDP/ENet не отменяет +необходимость воспроизводить формат упакованного сообщения, если требуется +соединение с оригинальной игрой. + +Полный wire schema, framing и алгоритм сжатия пока не доказаны packet +capture-ом. Поэтому нужны два режима: + +- **native compatibility** -- отдельный adapter, реализуемый после трассировки + оригинальных packets; +- **modern multiplayer** -- новая versioned protocol schema, использующая ту же + game-message семантику, но не заявляющая совместимость с DirectPlay client. + +Эти режимы нельзя незаметно смешивать. До доказательства native wire +compatibility современный transport должен быть versioned и отделён от слоя, +который претендует на совместимость с оригинальным клиентом. + +### Стабильность сетевого слоя + +`Net.dll` и `World3D.dll` побайтно идентичны в обеих частях. Application GUID, +DirectPlay wrapper, mirror-object API и World3D message surface относятся к +одной машинной реализации. + +Это подтверждает отсутствие отдельной сетевой реализации для Части 2, но не +закрывает wire schema: без packet/send-receive capture по-прежнему неизвестны +точное framing, reliability flags, payload layouts и алгоритм `netZipData` для +native interoperability. + +Для binary regression достаточно одного профиля неизменённых DLL, но message +captures должны включать контент обеих частей, потому что prototype/resource IDs +и mission data различаются. + +## Контракты реализации + +Совместимая реализация должна фиксировать не только результат, но и момент его +появления в кадре. Для Behavior, Control, input, sound и network особенно важны +tick boundaries: одна и та же команда, применённая на один tick раньше или +позже, меняет дальнейшую симуляцию. + +### Trace-события + +Минимальный trace для этого тома: + +- input snapshot: edge events, held state, analog values; +- camera state: mode, target, angles, matrices, viewport; +- Behavior: target, areal, global path revision, local corridor; +- Wizard: requested vector, constraints, wizard path; +- Control: candidate transform, contacts, correction, final transform; +- World3D queue: message name, ObjectId, dispatch phase, deferred deletion; +- sound: sample key, source owner, position, event tick, listener state; +- network: player mapping, message ID, payload length, delivery policy. + +Для рендера это связывается с [render frame](../reference/render-frame.md): +camera и listener должны попадать в trace до world traversal, иначе нельзя +отделить ошибку presentation от ошибки управления. + +### Проверки Behavior и сценариев + +- script version mismatch даёт отдельную ошибку; +- event table читается lossless; +- VM body сохраняется без потери неизвестных bytes; +- отсутствующий `IArealMap` не замалчивается; +- non-walkable/non-reachable states дают diagnostic condition; +- одинаковый input log воспроизводит одинаковый sequence Behavior commands; +- resource names из TRF разрешаются через общий registry. + +### Проверки Control + +- движение без препятствий; +- slope/terrain-following; +- симметричные pair-collision tests с переставленными IDs; +- contact event отправляется один раз в предписанной фазе; +- удаление объекта в collision callback безопасно; +- replay одинакового input log даёт одинаковые transforms; +- collision proxy перестраивается после смены component/model state. + +### Проверки input и камеры + +- edge event не повторяется как held state; +- mouse/joystick axis сбрасывается по правилам snapshot; +- hot-unplug joystick не оставляет старое отклонение; +- camera horizontal angle wraps, vertical angle clamps; +- center command использует подтверждённое время, а не render FPS; +- Terrain и sound получают одну и ту же camera frame. + +### Проверки звука + +- backend может отсутствовать без нарушения simulation; +- один decoded sample переиспользуется несколькими sources; +- `entry_missing`, `entry_empty` и `wave_invalid` различаются; +- listener совпадает с camera frame; +- loop source корректно переживает pause/resume; +- mute не сбрасывает position и time; +- missing sound resource содержит полную диагностическую цепочку; +- deterministic test сравнивает список sound events, а не waveform устройства. + +### Проверки сети + +- нельзя создавать queue с активной сетью и нулевым player ID; +- session/player operations до enable/setup возвращают ошибку; +- сообщения проверяют длину до чтения payload; +- sequence/end markers обрабатываются в стабильном порядке; +- duplicate create mirror не создаёт второй instance; +- ownership change атомарно обновляет routing; +- pause/time messages применяются в одной simulation boundary; +- resource transfer имеет timeout `Network_ResourceTransferMaxDelay`; +- disconnect не оставляет objects с несуществующим owner; +- replay записанного message log даёт одинаковое World3D state. + +`resnet.log` и `NetWatch.log` следует поддерживать как отдельные каналы: первый +относится к transport/resource exchange, второй -- к связи players и game +objects. + +## Границы знания + +Подтверждены внешние interfaces, часть runtime order, значимые строки, +конфигурационные параметры, corpus-level counts и стабильность ряда DLL между +двумя частями. Открытыми остаются: + +- instruction grammar `.scr` и semantics всех VM opcodes; +- точная семантика всех TRF-записей; +- полный layout CTLD shape records; +- contact solver и порядок всех correction steps; +- class layout камер, контроллеров, sound service и network watcher; +- DirectPlay wire framing, reliability flags и payload schema; +- алгоритм `netZipData`/`netUnZipData`; +- точные defaults service layer там, где DLL пересобраны. + +Эти границы должны оставаться видимыми в документации и тестах. Если новая +реализация вводит удобный современный abstraction layer, он обязан быть +отделён от утверждений о native compatibility и покрыт отдельным trace. diff --git a/docs/tomes/07-implementation.md b/docs/tomes/07-implementation.md new file mode 100644 index 0000000..968d61b --- /dev/null +++ b/docs/tomes/07-implementation.md @@ -0,0 +1,674 @@ +# VII. Руководство по полной реализации + +Этот том описывает инженерный путь к совместимому движку FParkan. Он опирается +на доказанные форматы и runtime-контракты, но не требует повторять физическое +деление оригинала на пятнадцать DLL. Повторить нужно наблюдаемое поведение: +форматы, имена, fallback, object IDs, порядок событий, численную политику, +границы кадра, сохранения и воспроизводимость прохождения. + +Предложенные ниже modules, handles, snapshots, queues и scheduler phases являются +целевой архитектурой новой реализации, а не восстановленным внутренним layout +оригинального Iron3D. Главная практическая цель: запускаться из неизменённого +оригинального каталога игры, проходить corpus gates для демоверсии, Части 1 и +Части 2, а затем измеримо двигаться от archive compatibility к полной игровой +совместимости. + +## Целевая архитектура + +Практичная форма новой реализации -- модульный монолит с узкими интерфейсами и +отдельными platform adapters. Внутренние границы должны соответствовать ролям +Iron3D, а не обязательно его DLL. Это упрощает перенос на современные платформы +и оставляет возможность поддерживать разные compatibility profiles для разных +сборок данных. + +```text +application запуск, окно, конфигурация, shutdown +platform filesystem, clocks, input, threads, dynamic libraries +resources NRes, RsLi, paths, archives, cache and diagnostics +assets MSH, WEAR, MAT0, Texm, FXID and auxiliary formats +mission TMA, unit DAT, prototype graph, scenario data +world ObjectId, queue, lifecycle, time, messages, mirrors +terrain Land.msh, Land.map, surface and spatial queries +navigation areals, graph search, corridors +behavior unit state machines, target and path requests +physics control systems, collision proxies and contacts +animation pose sampling, hierarchy and blending +audio sample cache, sources, listener and buses +render legacy-state compatibility and modern backend +network game message schema plus transport adapters +tools validators, extractors, viewers, captures and editors +``` + +Каждый модуль зависит от нижележащих интерфейсов, а не от concrete managers. +Behavior видит `INavigation` и `IPhysicsCommandSink`, но не включает headers +renderer-а. Render получает immutable snapshot, а не mutable world. Network +receive не меняет мир напрямую: validated messages попадают в очередь следующей +calculation boundary. + +### Центральные идентичности + +Resource identity хранит и исходное написание, и нормализованный ASCII-key для +поиска: + +```c +struct ResourceKey { + NormalizedRelativePath archive; + FixedAsciiName name; + uint32_t type_id; +}; +``` + +Normalization сохраняет исходную строку для diagnostics и roundtrip, а отдельный +ASCII-casefold key используется только для lookup. Эта граница важна для +архивов [NRes](../reference/nres.md), таблиц [RsLi](../reference/rsli.md), +prototype references и fallback-путей материалов. + +Object identity разделяет внутреннюю защиту от dangling references и исходную +сетевую/script-семантику: + +```c +struct ObjectHandle { uint32_t generation; uint32_t slot; }; +struct OriginalObjectId { uint32_t raw; }; +``` + +`ObjectHandle` нужен для безопасного внутреннего владения, deferred deletion и +weak references. `OriginalObjectId` сохраняет наблюдаемую семантику исходной +игры: scripts, mirrors, network messages и savegame references должны видеть +логический ID, а не адрес объекта или номер slot в новом allocator-е. + +Frame snapshot отделяет simulation от render. Simulation пишет mutable state; +renderer читает опубликованное состояние или строго ограниченную фазу +`in_render`. Deferred deletion применяется между фазами, а не во время traversal. +Командный контур renderer-а должен сверяться с [описанием кадра](../reference/render-frame.md) +до pixel comparison. + +### Владение ресурсами + +Ресурс проходит несколько уровней: + +```text +ArchiveHandle -> EntryView -> DecodedBlob -> ParsedAsset -> RuntimeResource +``` + +`EntryView` ссылается на metadata архива, `DecodedBlob` владеет подготовленными +bytes, `ParsedAsset` является CPU-представлением, `RuntimeResource` может +дополнительно владеть GPU/audio objects. Eviction верхнего уровня не закрывает +архив, если он ещё нужен другому entry. Ссылки идут вниз только через явные +handles. + +Для shared objects допустимы reference counting или generation handles. +Intrusive refcount нужен только в ABI-shim; внутренний современный код +предпочтительно держит понятное владение и weak handles. Архивы, decoded blobs, +CPU assets и GPU resources имеют отдельные бюджеты и отдельные diagnostics. + +### Backend adapters + +Render, audio, input и network получают отдельные adapters. Legacy compatibility +state живёт выше Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт +отдельно от modern transport. Так можно заменить платформу, не меняя форматы, +игровую семантику и regression corpus. + +Backend adapter не должен быть местом, где исправляются данные. Если +[MSH](../reference/msh.md), [MAT0](../reference/materials.md) или +[Texm](../reference/texm.md) требуют fallback, это фиксируется в asset/runtime +слое и попадает в trace. Backend получает уже выбранные resources, states и +draw items. + +### Scheduler phases + +```text +collect_platform_events +build_input_snapshot +advance_game_clock +calculate_world_queue +apply_deferred_operations +update_navigation_physics_animation_fx +publish_render_snapshot +render_world +render_ui +end_frame_callbacks +maintenance_and_eviction +``` + +Фазы имеют стабильный порядок и запрещённые операции. Registry mutation +запрещена во время world traversal, GPU upload не изменяет simulation state, а +maintenance не влияет на gameplay. Script timers, material animation и FX +lifetime относятся к game time, если обратное не доказано. + +Сначала реализуется однопоточный эталон. Параллелизм добавляется только внутри +фаз с детерминированным merge: decoding независимых assets, culling chunks или +подготовка immutable draw items. Это снижает риск скрытых race conditions и +расхождений replay. + +### Структурированные ошибки + +Каждая ошибка должна содержать фазу, путь, archive entry, object/prototype key, +offset и цепочку причины. + +```text +MissionLoadError + mission: Campaign.00/Mission.02 + object: 17 + resource_name: UNITS/.../unit.dat + component: e_tur_... + prototype: objects.rlb::e_tur_... + cause: model archive missing +``` + +Логическое отсутствие необязательного lightmap, отсутствующий entry в архиве, +неизвестное opaque поле, выход ссылки за диапазон и повреждённый offset имеют +разный severity и разные способы исправления. Ошибка данных должна быть +actionable chain, а не строка вида `failed to load resource`. + +## Порядок работ + +Движок строится от данных к поведению и от детерминированных CPU-компонентов к +аппаратным. Каждый этап заканчивается исполняемым инструментом и тестовым +критерием. Нельзя начинать полноценный gameplay, пока ресурсный граф и +model/material path не дают воспроизводимый результат. + +### Этап 0. Corpus harness + +- индексировать оригинальный каталог и вычислить hashes; +- реализовать bounded binary cursor и structured diagnostics; +- создать CLI для массового запуска parser-ов; +- сохранять JSON-отчёт с counts, variants, warnings и failures; +- зафиксировать демоверсию, Часть 1 и Часть 2 как независимые baselines. + +Готовность: повторный запуск на каждом неизменённом каталоге даёт идентичный +отчёт. Любой parser умеет завершиться контролируемой ошибкой с offset и +контекстом, а не crash или allocation по непроверенному count. + +### Этап 1. Архивы и пути + +- реализовать strict/lossless [NRes](../reference/nres.md) reader/writer; +- реализовать [RsLi](../reference/rsli.md) mapping, table transform, lookup, + LZSS и Deflate; +- добавить адаптивный decoder для методов `0x080` и `0x0A0`; +- воспроизвести overlay и известные compatibility quirks; +- реализовать archive-handle cache и ASCII name policy. + +Готовность: неизменённые архивы проходят byte-identical roundtrip; поиск всех +имён совпадает с каталогом; malformed corpus отклоняется без выхода за память. +NRes с ненулевым unindexed region обязательно остаётся regression case. + +### Этап 2. Граф ресурсов + +- разобрать `objects.rlb` и unit DAT; +- построить resolver прямой MSH, рекурсивного parent prototype через + `objects.rlb` и отдельного BASE payload; +- реализовать dependency graph с reachability от миссии; +- добавить parsers CTPT, NDPR и остальных служебных форматов в lossless-режиме; +- создать инспектор прототипа, показывающий все связанные ресурсы. + +Готовность: 201 demo-объект раскрывается в 501 прототип. Затем все миссии +Частей 1 и 2 дают 4 701 и 5 845 prototype requests без failures. Недостижимые +отсутствующие ресурсы отмечаются отдельно от критических ошибок в reachable +graph. + +### Этап 3. Статический asset viewer + +- реализовать [MSH](../reference/msh.md) core streams, slots и batches; +- декодировать Texm во все подтверждённые pixel formats; +- разобрать WEAR и [MAT0](../reference/materials.md) с точными fallback; +- построить современный renderer compatibility layer; +- добавить wireframe, normals, bounds, LOD/group и material debug views. + +Готовность: открываются 435/511 моделей, 518/631 textures и 905/1 127 materials +Частей 1/2; batch/index bounds не нарушаются; viewer показывает корректно +текстурированную статическую модель из исходного архива. Красивый viewer всё ещё +означает только asset compatibility, а не готовую игру. + +### Этап 4. Анимация и эффекты + +- реализовать MSH type 8/type 19 sampling и hierarchy; +- добавить x87-compatible reference path для чувствительных формул; +- реализовать material phase animation; +- разобрать FXID header/commands и runtime instances; +- сначала поддержать все opcodes, встречающиеся в корпусе, сохраняя raw body; +- добавить deterministic RNG stream и effect capture. + +Готовность: frame-by-frame poses совпадают с golden reference своей части; все +923/1 065 FXID создаются без parser errors; перезапуск одинакового effect seed +даёт идентичный список emitted primitives. + +### Этап 5. Карта и мир + +- реализовать `Land.msh` и corrected `TerrainFace28` layout; +- построить terrain rendering и CPU surface queries; +- реализовать `Land.map`, cell grid и graph links; +- визуализировать areals и найденные маршруты; +- разобрать [TMA](../reference/tma.md) и выполнять staged mission loading; +- создать World3D queue, ObjectId и deferred deletion. + +Готовность: 65 карт и 60 TMA Частей 1 и 2 загружаются до EOF; все areal links +валидны; objects появляются в правильных transforms; мир выдерживает расчётные +шаги без рендера. + +### Этап 6. Gameplay controllers + +- подключить input snapshot и camera controller; +- реализовать navigation corridor, Behavior state machine и Wizard boundary; +- создать physical controller и collision manager; +- загрузить control resources в lossless typed model; +- внедрить game time, pause, event queue и end-of-frame callbacks; +- подключить AI layer и symbol/event layer сценариев. + +Готовность: юнит получает цель, строит маршрут, движется по terrain, реагирует +на collision и исполняет базовые миссионные события в детерминированном replay. +На этом этапе вводится differential branch для изменённых `AniMesh`, `Control` и +`Effect`; неизменённые DLL используют общий reference path. + +### Этап 7. Полный кадр, звук и UI + +- реализовать render phases, sorting, lighting, shadows и atmosphere; +- подключить 3D listener, sample cache, FX sounds и mission audio; +- воспроизвести shell/UI loading и post-world pass; +- добавить frame capture до UI и после UI; +- зафиксировать capability fallback profiles. + +Готовность: миссия визуально и звуково проходима; каждый draw и sound event +имеет trace; одинаковый replay создаёт одинаковые command lists. На этом этапе +вводится differential branch для `iron3d` и `services`. + +### Этап 8. Сеть, сохранения и динамическая совместимость + +- реализовать modern transport над versioned game-message schema; +- отдельно исследовать DirectPlay wire и `netZipData` для native compatibility; +- добавить mirrors, ownership transfer и disconnect cleanup; +- восстановить save/campaign state и dispatcher; +- выполнить динамические captures оригинала для render states, script VM и + physics edge cases. + +Готовность: одиночная кампания запускается из оригинального каталога, +сохраняется и продолжается; multiplayer replay согласован между peers; full +corpus не создаёт новых parser variants без явной регистрации. + +## Тестовый контур + +Совместимость нельзя подтвердить одним screenshot. Нужны тесты на уровне bytes, +структур, ссылок, simulation state, команд renderer-а и конечного изображения. +Каждый слой локализует свой класс ошибки. + +```text +unit tests + -> parser/property tests + -> corpus validation + -> cross-resource integration + -> deterministic simulation replay + -> render/audio command captures + -> pixel and gameplay parity +``` + +Failure верхнего уровня всегда должен позволять спуститься к меньшему тесту и +понять причину. + +### Unit, property и fuzz tests + +Для каждого binary primitive проверяются little-endian чтение, bounded strings, +checked arithmetic и cursor boundaries. Для структур -- минимальный размер, +максимальные counts, пустые arrays, нулевые варианты и редкие branches. + +Property tests генерируют случайные корректные NRes/RsLi/WEAR records, +выполняют encode -> decode и сравнивают семантику. Fuzz tests изменяют длины, +offsets, counts и termination bytes и требуют контролируемой ошибки без crash и +чрезмерного выделения памяти. + +Критические алгоритмы имеют отдельные vectors: ASCII casefold, NRes permutation +search, RsLi byte transform, LZSS backreferences, quaternion shortest path, +matrix composition и terrain mask remap. + +### Corpus validation + +Каждый файл оригинального каталога проходит parser своего семейства. Отчёт +содержит hash, variant, counts, warnings, errors и точный offset сбоя. Baseline +демоверсии: + +```text +MSH 435 +MAT0 905 +Texm 518 +FXID 923 +WEAR 457 +Land.msh 6 +Land.map 6 +TMA 6 +unit DAT 425 +errors 0 +``` + +Изменение parser-а принимается только если baseline остаётся стабильной либо +новый variant зарегистрирован с образцом и объяснением. Warnings должны быть +именованными: «неизвестное opaque поле» не равно «выход ссылки за диапазон». + +### Cross-resource integration + +Интеграционный тест начинается с миссии и проходит весь dependency graph: +object -> prototype -> MSH -> WEAR -> MAT0 -> Texm/lightmap/FXID. Он не +ограничивается тем, что файлы существуют: material slot должен указывать на +допустимый MAT0, phase -- на допустимую texture, model batch -- на существующий +WEAR index. + +Demo mission total: 201 objects -> 501 prototypes -> 501 object MSH/WEAR. +Чистый object graph даёт 3 873 material slots и 5 049 texture requests; после +включения environment WEAR итог равен 3 879 material slots, 5 067 textures и +18 lightmaps, failures 0. Такой тест ловит ошибки casefold, suffix, fallback и +путей, которые отдельный parser не замечает. + +Для каждого отсутствующего узла отчёт хранит полный parent chain, чтобы +различать broken global archive и реально достижимый mission failure. + +### Deterministic simulation replay + +Записывается начальная миссия, seed, input events, network messages и значения +внешних часов. На контрольных ticks сохраняется canonical state hash: + +```text +sorted ObjectId list +transforms and velocities +critical properties and owners +AI/behavior state IDs +active effect state +game clock and RNG states +``` + +Pointer addresses, allocator order и GPU handles в hash не входят. Два запуска с +одинаковым log должны давать одинаковый state hash на каждом checkpoint. Первое +расхождение гораздо информативнее финального разного результата миссии. + +### Render command parity + +До pixel comparison сравнивается command list: + +```text +camera matrices and viewport +visible ObjectIds +render phase and stable order +model/node/slot/batch IDs +material phase and texture handles +legacy pipeline states +index ranges and transforms +``` + +Если command lists совпадают, но pixels различаются, проблема находится в +shader/backend, sampling или численной точности. Если command lists уже +различаются, pixel diff лишь скрывает более раннюю ошибку. + +Golden captures следует хранить отдельно для статической модели, анимации, +terrain, transparent FX, shadows, lightmap и atmosphere. + +### Pixel, audio и network tests + +Pixel tests используют фиксированное разрешение, camera, device profile, seed и +timeline. Сравниваются exact pixels для CPU/reference path и tolerance metrics +для GPU path, но tolerance не должна скрывать переставленные прозрачные +primitives. + +Audio tests сравнивают список sound events, sample IDs, positions, loop flags и +gains; waveform зависит от mixer/device и является вторичным уровнем. Network +tests воспроизводят captured message sequences, проверяют mirrors, ownership и +disconnect. Для native DirectPlay compatibility дополнительно нужен packet-level +corpus. + +## Regression baselines + +Corpus validation формирует три независимых отчёта: демоверсия, Часть 1 и +Часть 2. Каждый сохраняет manifest файлов, hashes executable/DLL, variants, +warnings, global archive health и mission reachability. + +Ключевые corpus gates: + +```text +NRes: 120 файлов / 6 804 entries и 134 / 8 171 для Частей 1/2 +TMA: 29 миссий / 864 objects / 28 extras и 31 / 885 / 41 +MSH: 435 и 511 моделей +MAT0: 905 и 1 127 материалов +Texm: 518 и 631 текстура +FXID: 923 и 1 065 эффектов +full reachability: 4 701 и 5 845 prototype requests, failures 0 +``` + +Расширенные mission-reachability totals: + +```text +Часть 1: 29 TMA, 864 objects, 4 701 prototypes, + 36 954 materials, 48 806 textures, 139 lightmaps, failures 0 +Часть 2: 31 TMA, 885 objects, 5 845 prototypes, + 50 888 materials, 68 603 textures, 214 lightmaps, failures 0 +``` + +Обязательные regression cases: + +- NRes с ненулевым unindexed region; +- prototype inheritance через `objects.rlb`; +- unit DAT `description[32]` без NUL; +- TMA epilogue и `extra_count` 0--4; +- empty SWAV entry; +- stale save-slot metadata без payload; +- build-scoped RVA lookup. + +Byte-identical asset comparison выполняется только внутри одного корпуса. Между +Частями 1 и 2 сравниваются semantic invariants и decoded representation, +поскольку многие assets пересобраны. + +## Точность, скорость и повторяемость + +Совместимый движок должен быть корректным, повторяемым и достаточно быстрым. +Эти свойства нельзя получать одним и тем же приёмом. Сначала создаётся простой +эталонный путь, затем он измеряется и оптимизируется без изменения результата. + +Главные источники расхождений: x87 extended precision, преобразование float в +integer, порядок операций, старые SIMD implementations, нестабильная сортировка, +RNG и использование разных часов. + +### x87 и округление + +Оригинальный x86-код мог хранить промежуточные значения в 80-битных регистрах +x87, а в память записывать 32-битный float. Современный compiler чаще использует +SSE с округлением после каждой операции. Различие заметно на границах animation +frame, culling plane и collision threshold. + +Для критических формул нужен reference mode: + +- фиксированный порядок операций без reassociation; +- запрещённый fast-math; +- явные преобразования и проверенный режим округления; +- тесты возле half-integer и epsilon boundaries; +- при необходимости extended intermediate через `long double` на проверенной + платформе. + +Не требуется эмулировать x87 во всём движке. Нужно локализовать функции, где +малое отличие меняет дискретное решение, и держать для них scalar reference path. + +### RNG как часть состояния + +FX, atmosphere и, вероятно, AI используют случайные значения. Один глобальный +RNG легко расходится, если новая реализация запрашивает дополнительное число для +визуальной оптимизации. Для трассировки полезны именованные streams: + +```text +world/gameplay RNG +AI/script RNG +FX instance RNG +atmosphere RNG +non-deterministic cosmetic RNG +``` + +Для native parity может потребоваться один общий алгоритм и точная sequence. До +подтверждения capture каждый stream хранит seed и счётчик вызовов в trace. +Cosmetic stream не входит в simulation hash. + +### Стабильный порядок + +Коллекции не должны зависеть от адресов, unordered containers или порядка +завершения worker threads. Для объектов, collision pairs, opaque/transparent +draws и network messages задаются явные stable keys: + +- objects -- queue insertion sequence или OriginalObjectId; +- collision pairs -- упорядоченная пара IDs; +- opaque draws -- phase, pipeline key, material, stable insertion ID; +- transparent draws -- layer, quantized distance, stable insertion ID; +- network messages -- sequence и sender. + +Даже когда математический результат коммутативен, side effects, cache accesses и +RNG делают порядок наблюдаемым. + +### Часы и fixed-step + +Monotonic platform clock хранится отдельно от game clock. Pause и time scaling +применяются к game clock. Simulation работает с фиксированным или точно +воспроизводимым шагом, а render может интерполировать presentation state, не +изменяя authoritative world. + +Maintenance timers кэшей используют реальные часы или отдельную подтверждённую +шкалу; их срабатывание не должно менять gameplay. При перегрузке лучше выполнить +ограниченное число simulation steps и явно зафиксировать dropped presentation +frames, чем передать огромный `dt` в AI/physics. + +### Оптимизация без потери эталона + +1. Сохранить scalar reference implementation. +2. Добавить profiler counters на decoding, culling, sorting, animation, upload + и draw. +3. Оптимизировать только измеренный bottleneck. +4. Сравнить SIMD/parallel результат с reference на полном corpus. +5. Оставить runtime switch для отключения оптимизации при диагностике. + +`g_FastProc` удобно моделировать как таблицу function objects: все slots сначала +указывают на scalar path, затем безопасные slots заменяются SIMD-вариантами +после self-test на старте. + +### Кэш и память + +Архивы, decoded blobs, CPU assets и GPU resources имеют отдельные budgets. +Eviction разрешена только для объектов с нулевым external refcount и после +безопасной frame fence. Original delayed cleanup порядка десятков секунд можно +воспроизвести policy-параметрами, не сканируя все entries каждый кадр. + +Основные показатели: число открытых архивов, decoded bytes, resident +textures/lightmaps, models, active FX, draw items и deferred-delete size. Любой +неограниченно растущий счётчик является regression. Производительность считается +достаточной только после корректности: стабильные 60 FPS с неверным LOD или +пропущенными эффектами не являются успехом. + +## Release gates + +Версия не выпускается, если: + +- появился новый corpus error; +- изменился byte roundtrip неизменённых ресурсов; +- dependency graph получил failure в достижимом пути; +- deterministic replay расходится; +- command capture изменился без ожидаемого changelog; +- parser допускает allocation по непроверенному count; +- новая оптимизация не имеет scalar reference comparison. + +Каждое исправление регистрирует минимальный regression asset или synthetic +vector. Если новый behavior намеренно отличается от предыдущего, изменение +должно иметь compatibility profile, corpus sample и объяснение, почему старый +baseline был неполным или неверным. + +## Уровни совместимости + +Слово «совместимый» используется только с уровнем: + +1. **Archive-compatible** -- открывает и сохраняет контейнеры. +2. **Asset-compatible** -- декодирует модели, материалы, текстуры и эффекты. +3. **Mission-compatible** -- загружает карту и создаёт все объекты. +4. **Runtime-compatible** -- исполняет время, события, поведение и физику. +5. **Presentation-compatible** -- воспроизводит рендер и звук. +6. **Game-compatible** -- позволяет пройти миссии, сохраняться и продолжать. +7. **Native-interoperable** -- взаимодействует с оригинальной сетью и внешним + ABI. + +Viewer с красивой моделью находится только на втором уровне. + +### Обязательные критерии запуска и данных + +- приложение запускается из неизменённого оригинального каталога; +- относительные пути, регистр и legacy encodings разрешаются по исходным + правилам; +- все требуемые NRes/RsLi открываются без предварительной конвертации; +- parsers проверяют границы и не используют неопределённые bytes как указатели; +- неизвестные поля сохраняются lossless; +- все mission-reachable prototype, model, material, texture, lightmap и effect + references разрешаются; +- отсутствие необязательного ресурса следует документированному fallback, а не + случайному default. + +### Обязательные критерии мира + +- TMA разбирается до точного EOF; +- `Land.msh` и `Land.map` создают корректную поверхность и areal graph; +- ObjectId, owner и mirror semantics устойчивы; +- queue traversal и deferred deletion безопасны; +- pause, game time и simulation steps повторяемы; +- AI/Behavior/Wizard/Control взаимодействуют через заданные границы; +- collision и navigation не подменяют друг друга; +- script events используют logical IDs и переживают удаление объектов; +- deterministic replay совпадает на контрольных ticks. + +### Обязательные критерии presentation + +- static и animated MSH используют правильные slots, batches и transforms; +- WEAR/MAT0/Texm fallback и phase timing совпадают; +- mip-skip, palettes, Page atlases и lightmaps работают; +- render phases, depth/cull/blend state и transparent order подтверждены + captures; +- FXID commands и RNG дают устойчивый результат; +- camera и 3D sound listener синхронизированы; +- atmosphere, тени, солнце и flares не являются декоративными заглушками; +- UI и world rendering имеют правильную границу; +- golden command captures стабильны, pixel parity измеряется на фиксированных + сценах. + +### Обязательные критерии полной игры + +- все доступные миссии стартуют, завершаются и корректно сообщают + success/failure; +- campaign dispatcher сохраняет прогресс; +- savegame восстанавливает world, script, AI, RNG и clocks, а не только + placement; +- input remapping, pause, camera modes, sound и настройки работают из UI; +- длительный прогон не накапливает objects, resources или audio sources; +- ошибки данных показывают actionable chain; +- производительность приемлема без отключения подсистем; +- демоверсия, Часть 1 и Часть 2 проходят один и тот же тестовый контур с + раздельными manifests и эталонами. + +### Native interoperability + +Самый строгий уровень дополнительно требует совпадения x86 ABI экспортов, vtable +slots и calling conventions для подключаемых оригинальных модулей, а также +DirectPlay wire/framing и compression. Этот уровень независим от возможности +играть в новом standalone runtime. + +Проект может честно заявлять game compatibility без native DLL/network +interoperability, но это должно быть явно указано. Аналогично pixel-perfect режим +может быть отдельным compatibility profile поверх функционально корректного +renderer-а. + +### Совместимость нескольких наборов данных + +Критерий полной совместимости применяется отдельно к демоверсии, Части 1 и +Части 2. Прохождение одного набора не позволяет заявлять поддержку остальных. + +Обязательное различие: + +- **format compatibility** -- один parser принимает все три набора; +- **content compatibility** -- конкретная миссия разрешает весь reachable graph; +- **behavior compatibility** -- runtime совпадает с соответствующей сборкой + изменённых DLL; +- **cross-version support** -- один новый движок выбирает корректные данные и + defaults по fingerprint установки. + +Content fingerprint включает hashes executable/DLL и manifest ключевых архивов. +Он не используется для запрета модификаций, но выбирает compatibility profile и +делает отклонение диагностируемым. + +## Definition of done + +Полное документирование и реализация считаются завершёнными только когда каждый +критерий связан с главой спецификации, executable test и хотя бы одним +corpus/golden case. Утверждение без проверяемого критерия остаётся +исследовательской заметкой, а не контрактом. diff --git a/docs/tomes/08-evidence.md b/docs/tomes/08-evidence.md new file mode 100644 index 0000000..fab5805 --- /dev/null +++ b/docs/tomes/08-evidence.md @@ -0,0 +1,1087 @@ +# VIII. Справочник и доказательная база + +Восьмой том фиксирует, на чём держится книга: ABI, exports/imports, файловая +поверхность, статистика корпусов, открытые вопросы, критерии доказанности и +словарь терминов. Это самостоятельная справочная глава: она не заменяет +профильные статьи о форматах, но задаёт общий контракт, по которому проверяются +реализация, parser-ы, compatibility layer и будущие динамические эксперименты. + +## Как читать доказательства + +Доказательством считается наблюдение, которое можно повторить на конкретном +файле, сборке или трассе. Вывод может объединять несколько наблюдений, но он +должен сохранять происхождение данных: демоверсия, полная Часть 1 и полная +Часть 2 не смешиваются в один безымянный corpus. + +Для каждого утверждения полезно различать четыре уровня: + +- `layout-confirmed`: известны offset, size, count, bounds и правила безопасного + чтения; +- `corpus-verified`: branch или вариант реально встречается в доступных игровых + данных; +- `code-confirmed`: branch виден в бинарном коде, но отсутствует в доступном + corpus; +- `behavior-confirmed`: поведение подтверждено исполнением оригинальной + программы, трассой API/vtable или controlled differential test. + +Если поле не имеет доказанного предметного смысла, документация хранит его как +opaque field. Это не мешает lossless read/write, но запрещает строить writer, +который очищает, переименовывает или пересчитывает такое поле на основании +правдоподобной догадки. + +## ABI и границы модулей + +### Базовый binary profile + +Все исследованные модули -- 32-битные PE для x86, собранные C++-компилятором +эпохи MSVC6. Публичная граница сочетает именованные exports, фабрики C++- +объектов, singleton getters и дальнейшие вызовы через vtable. + +Для binary shim необходимо учитывать: + +- `__cdecl` и `__stdcall` у свободных функций; +- `__thiscall` у методов, где `this` передаётся в `ECX`; +- очистку stack, видимую по `ret N`; +- точный порядок virtual slots; +- multiple-interface pointer adjustments; +- 4-byte alignment и native little-endian types; +- отсутствие безопасного ABI для STL-контейнеров между современным и старым + compiler-ом. + +Внутренний новый движок не обязан использовать этот ABI. Он нужен только +compatibility layer, который принимает старые DLL-facing interfaces, старый +порядок slots и старые ownership rules. + +### Публичная поверхность DLL + +В 15 DLL обнаружено 313 exports: + +```text +AniMesh.dll 2 ArealMap.dll 9 +Behavior.dll 3 Control.dll 5 +Effect.dll 2 Joystick.dll 6 +MisLoad.dll 2 Net.dll 37 +Ngi32.dll 145 Terrain.dll 13 +Wizard.dll 1 World3D.dll 72 +ai.dll 2 iron3d.dll 8 +services.dll 6 +``` + +Демоверсия содержит 1 126 imported function slots, а полные Части 1 и 2 -- +1 134. Они включают Win32 runtime, DirectX и межмодульные связи. Большое число +exports `Ngi32.dll` состоит из активного объектного API, математических/resource +functions и legacy compatibility stubs. + +Compatibility headers должны фиксировать symbol, ordinal, decorated или +undecorated name и signature конкретной сборки. Смысловое имя недостаточно: +порядок exports и calling convention входят в бинарный контракт. + +### Композиционный и сервисный слой + +`iron3d.dll` экспортирует восемь функций: + +```text +createShell deleteShell +createGame deleteGame +createSubsystems deleteSubsystems +getIGame getIShell +``` + +`services.dll` публикует шесть getters: + +```text +getDisplay +getGUIServer +getNetManager +getResManager +getSoundServer +getTimer +``` + +Эти getters возвращают shared interfaces. Caller не должен конструировать +concrete implementation или уничтожать singleton напрямую. Для совместимости +важны не только адреса функций, но и порядок startup/shutdown, owner/refcount +transitions и реакция на failure paths: отсутствие sound device, ошибка display, +прерванная загрузка миссии и normal shutdown. + +### Предметные фабрики + +```text +AniMesh: LoadAgent, LoadAniMesh +ArealMap: CreateArealMap, CreateSystemArealMap, GetSystemArealMap, + CreateHallWay, CreateObjectFromScheme, CreateObjectsForDebug, + CalcFullResearchCost, Debug_TestSchemeType, ShowDebugVector +Behavior: CreateBehaviour, CreateDistributor, PressDebugKey +Control: InitializeSettings, LoadControlSystem, LoadPhysicalModel, + CreateCollManager, CreateCollObject +Effect: InitializeSettings, CreateFxManager +MisLoad: CreateMissionData, LoadResearch +AI: CreateSuperAI, GetSuperAI +Wizard: CreateWizard +Terrain: CreateAtmosphere, CreateLightManager, CreatePrimitives, + CreatePrimitives2, CreateShader, GetShade, GetWorld, + LoadCamera, stdGetCurrentCamera2, stdSetCurrentCamera2 +``` + +Фабрика возвращает interface pointer. Конкретный размер объекта и layout +остаются внутренними; внешнему коду важны vtable, QueryInterface-подобная +negotiation, lifetime methods и правила владения. + +### World3D export families + +72 exports `World3D.dll` группируются по назначению: + +```text +lifecycle: stdInitGame, stdCloseGame, stdCalculateGame, stdRenderGame +objects: CreateObject, AddObjectToGame, AddNewObjectToGame, + CreateMirrorObject, AddMirrorObjectToGame, AddNewMirrorToGame, + DeleteGameObject, KillGameObject, CreateQueue, GetQueue +camera: LoadCamera, stdSetCurrentCamera, stdGetCurrentCamera +input: UpdateManualEventsList, ClearManualEventsList, stdClearKeyboard, + converters, scan/string functions, key lock/query, mouse shift +clock: SetGameTime, PauseGameTime, ResumeGameTime, GetGameTime family +network: netCreateNetWatcher, GetNetPlayerNum and mirror/player helpers +resources/render: material, texture, lightmap and end-of-render helpers +settings/state: CreateGameSettings, SetGameRender, SetStateForGameObjects +``` + +World3D является главным местом, где внешний ABI превращается в game loop: +input обновляет manual events, calculation проходит queue/world traversal, +deferred deletion откладывает фактическое уничтожение объектов, render читает +подготовленный snapshot, а end-of-render helpers закрывают временные ресурсы. + +### Net и Joystick + +`Net.dll` экспортирует создание instance/interface и 33 операции transport +lifecycle: provider/session enumeration, setup, create/join/close, player +operations, send/receive, latency, addresses, queue size, lobby и +`netZipData`/`netUnZipData`. + +`Joystick.dll` имеет компактную границу: + +```text +QueryJoy +CreateJoy +ReleaseJoy +SetJoyRange +PeekJoyMessage +GetJoyCaps +``` + +Эти модули легче всего заменить adapter-ами, потому что их публичная +поверхность достаточно узкая. Для native interoperability сохраняются исходные +signatures; modern runtime может использовать внутренние typed interfaces. + +### Ngi32 export families + +145 exports `Ngi32.dll` включают: + +```text +resource archives: niOpenResFile, niOpenResFileEx, niOpenResInMem, + niCreateResFile, rsOpenLib, rsFind, rsLoad +renderer: niGetD3DDriverAmount, niSelectD3DDriver, + niGetD3DDriverCaps, niGetD3DVideoModeList, + niCreate3DRender, niGet3DRender, niGetMaxTextureSize +audio: niCreate3DSound, niGet3DSound, niGet3DSoundCaps, + niMuteSound, rsLoadWave +platform: allocation, clocks, fixed-memory helpers +math/geometry: plane, ray, polygon and volume intersection routines +CPU dispatch: g_FastProc, niGetProcAddress and feature detection +legacy ABI: n3d*, vrt*, bsp* compatibility entries +``` + +Экспорт переменной `g_FastProc` требует особого shim: consumer получает адрес +таблицы, а не результат функции. + +### Подтверждённые RVA + +Адреса указаны как RVA конкретной исследованной сборки: + +```text +World3D stdCalculateGame 0x13910 +World3D stdRenderGame 0x13B60 +World3D sendEndOfRender 0x13D20 +World3D UpdateManualEvents 0x10E10 +World3D ClearManualEvents 0x11180 +World3D DeleteGameObject 0x087B0 +Ngi32 g_FastProc 0x3A058 +``` + +`iron3d.dll` вызывает calculation около RVA `0x5FA94`, `0x604C1`, `0x6086B`, +render около `0x60B2F`, а manual-event update находится в Win32 message path +около `0xA3759`. + +RVA используются только для сопоставления и трассировки этой версии. Runtime +implementation не должна встраивать их как постоянные игровые идентификаторы. +Таблица внутренних RVA хранится по SHA-256 конкретного модуля. + +Подтверждённые hashes неизменённых DLL: + +```text +World3D.dll 17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e +Ngi32.dll bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5 +``` + +### Vtable и interface negotiation + +Вызовы вида `object->vfunc(offset)` доказывают порядок slots, даже когда имя +метода неизвестно. Renderer slots около `+0x28`, `+0x30`, `+0x34` окружают +world traversal; camera и viewport получаются через selector-based interface +calls; shared objects используют ранний slot как AddRef-подобную операцию. + +Правила реконструкции: + +1. Зафиксировать byte offset slot и число аргументов. +2. Найти все call sites и типы передаваемых значений. +3. Отделить доказанное поведение от назначенного имени. +4. Построить C-compatible shim vtable с точным порядком. +5. Внутри adapter-а перевести вызов в современный typed interface. + +Нельзя добавлять virtual destructor в начало reconstructed interface: это +сдвинет все slots. + +### ABI-матрица Частей 1 и 2 + +Во всех пятнадцати DLL совпадают export names, ordinals и import sets. Общее +число exports остаётся 313. Обе полные части содержат 1 134 imported function +slots; значение 1 126 относится к демоверсии и хранится отдельно. + +Побайтно идентичны девять DLL: + +```text +ai.dll +Behavior.dll +Joystick.dll +MisLoad.dll +Net.dll +Ngi32.dll +Terrain.dll +Wizard.dll +World3D.dll +``` + +Пересобраны `AniMesh.dll`, `ArealMap.dll`, `Control.dll`, `Effect.dll`, +`iron3d.dll`, `services.dll`. + +Изменение export RVA: + +```text +AniMesh 2 / 2 +Control 5 / 5 +iron3d 8 / 8 +services 6 / 6 +ArealMap 0 / 9 +Effect 0 / 2 +``` + +Нулевое изменение export RVA не доказывает идентичность тела функции: +`ArealMap.dll` и `Effect.dll` имеют изменённый `.text` при прежних адресах +exports. Compatibility headers фиксируют внешний ABI один раз, но внутренняя +таблица адресов, тестов и semantic deltas выбирается по build fingerprint. + +## Файловая поверхность + +### Каталог как внешний API + +Оригинальная установка -- не просто набор assets. Имена файлов, относительные +пути, регистр, конфигурационные ключи и разделение библиотек образуют внешний +контракт. Совместимый движок должен принимать каталог без переименования и +предварительной распаковки. + +Основные root-файлы включают executable и 15 DLL, `Iron_3D.ini`, `Comp.ini`, +`Behavior.ini`, `ArealMap.ini`, `BuildDat.lst`, input/preload descriptions и +набор `.rlb/.lib` архивов: + +```text +objects.rlb +system.rlb +static.rlb +effects.rlb +Material.lib +Textures.lib +LightMap.lib +Palettes.lib +sounds.lib +voices.lib +``` + +Parser конфигураций должен сохранять неизвестные keys и секции, поддерживать +quoted strings, хранить provenance значения и отличать absent key от explicit +default. + +### `Iron_3D.ini` + +Демоверсия содержит секции `[CS]`, `[MULTIPLAYER]`, `[TEMP]` и +`[LEVEL_RATIO]`. + +```text +DISPLAY_WIDTH=640 DISPLAY_HEIGHT=480 +BITDEPTH=16 CURRENT_D3DCARD=0 +WINDOW_MODE=0 FORCE_SOFTWARE_CURSOR=1 +RENDER_QUALITY=2 REFLECTIONS=0 +EMBOSS_BUMP=0 EMBM=0 +PLAY_CD_MUSIC=1 MOUSE_SENS=100 +JOY_SENS=100 MOUSE_REV_Y=0 +JOY_REV_Y=0 JOY_ENABLE=0 +SUBTITLES=1 +``` + +`FORCE_CD_SOUND` хранит строку пути. Multiplayer задаёт default IP, login и +password. `[TEMP]` содержит normalization и offence/defence ranges, +`[LEVEL_RATIO]` -- коэффициенты сложности `0.5`, `0.7`, `1.0`. + +Parser не должен считать имена регистрозависимыми без отдельного +доказательства. Effective value, raw value и факт присутствия ключа хранятся +раздельно. + +### `Comp.ini`: реестр компонентов + +Формат строки: + +```text +<CID> <DLL-name> <Function-name> [comment] +``` + +Подтверждённая таблица: + +```text +0 terrain.dll LoadLandscape +1 terrain.dll LoadBuilding +2 terrain.dll LoadCamera +3 animesh.dll LoadAgent +4 animesh.dll LoadAgent +5 terrain.dll CreateAtmosphere +6 terrain.dll CreateShader +7 misload.dll LoadResearch +``` + +World3D использует этот файл как динамический component registry. Standalone +runtime может сопоставить CID внутренним фабрикам, но compatibility loader +должен поддерживать исходные DLL/function strings и комментарии `//`. + +### `Behavior.ini` и `ArealMap.ini` + +Demo `Behavior.ini` задаёт logging, debug rendering и controller switches: + +```text +LogFile=Behavior.log SaveLog=0 +MaxErrorLevel=1 DefErrorLevel=2 +LookBugMode=0 ShowVectors=0 +NoZBuffer=0 LockBehaviour=0 +UseDebugKey=1 GiveDefaultOrder=0 +DefaultOrderPhase=10 DeterminMode=0 +ImmortalHero=0 UseWizard=1 +``` + +Код Behavior также ищет дополнительные `PathFind_*` и network parameters. В +demo-файле они отсутствуют, следовательно используются compiled defaults или +другой источник; нельзя приписывать им произвольные значения. + +`ArealMap.ini` содержит log switches, `ShowAreals`, `Areal_NoZBuffer`, +`HallWay_NoZBuffer`, `EdgeUp` и `RunBehDebug`. + +### Миссии, UI и сохранения + +Типичный каталог миссии содержит: + +```text +data.tma +mission.cfg +briefing.cfg +messages.cfg +``` + +`mission.cfg` -- текстовое описание именованных resource objects. Блок +начинается `object <name>`, содержит `desc`, `library`, `libtype`, числовой +`type` и произвольные именованные параметры, затем `end`. В демоверсии через +него определяются ambient music loops/variations и другие mission services. + +`briefing.cfg` и `messages.cfg` относятся к пользовательскому представлению и +текстовым событиям. Binary TMA остаётся источником placement и properties; эти +файлы дополняют, а не заменяют его. + +Отдельные поверхности: + +```text +MISSIONS/SCRIPTS/*.scr, *.fml, *.trf, varset.var +MISSIONS/dispatcher.ini +ui/shell_ctrls.cfg +ui/menu_resources.cfg +ui/cursor.cfg +ui/game_resources.cfg +ui/hq.cfg +DATA/TextRes.cfg +SAVE/saveslots.cfg +``` + +Dispatcher демоверсии содержит секцию `[COMPLETE]`; полные части расширяют +campaign state и набор миссионных файлов. UI-config следует читать отдельным +generic object/config parser-ом, сохраняя порядок блоков и неизвестные fields. +`TextRes.cfg` связывает ключи с локализованными строками. + +Save slot list не является полным savegame state. Для полной совместимости +нужно отдельно восстановить binary save payload, campaign dispatcher и +serialization world/script/AI/RNG. + +### Правила файловой совместимости + +- Поддерживать `/` и `\` во входных legacy paths. +- Разрешать paths относительно root игры и mission context. +- Сохранять исходное написание для log и roundtrip. +- Использовать ASCII case-insensitive lookup внутри архивов. +- Учитывать CP1251/ANSI строки там, где встречается локализованный текст. +- Не применять Unicode normalization к фиксированным resource names. +- Различать физически отсутствующий файл и отсутствующий entry в существующем + архиве. +- Не требовать одинакового регистра имени файла на case-sensitive системах: + resolver строит индекс каталога. + +Все найденные конфигурации должны иметь schema с defaults, provenance и +признаком `present`. Это позволяет отличить исходный default от явно заданного +пользователем значения. + +### Различия файловой поверхности Частей 1 и 2 + +Часть 2 добавляет `ui_factory.lib` -- NRes с шестью Texm entries. +`ui/minimap.lib` увеличен примерно с 6,95 до 10,10 МБ. `gamefont.rlb` и +`sprites.lib` побайтно совпадают между частями. + +`Iron_3D.ini` Части 2 добавляет ключи `SFX_VOLUME`, `CD_VOLUME`, +`DEBUG_KEYS_ON`, меняет некоторые defaults (`MOUSE_SENS`, `MAP_ALPHA128`) и +локализует строки login/password. Это подтверждает правило schema + +provenance: parser хранит не только effective value, но и признак присутствия +ключа в конкретной сборке. + +`BuildDat.lst` Части 2 использует более полные пути под +`UNITS\BUILDS\AI\...`; category masks при этом остаются логическим контрактом, +а physical path -- частью content profile. + +`TextRes.cfg` и `TextRes.dll` значительно расширены. Localized text, resource +identifier и path normalization должны оставаться разными слоями: локализация +текста не меняет ASCII-casefold policy имён entries. + +## Результаты проверки корпусов + +### Demo baseline + +Демоверсия содержит `iron_3d.exe`, те же 15 DLL и сокращённый набор +миссий/ресурсов. Все 15 DLL совпали с первоначально исследованными файлами по +SHA-256. Поэтому executable, бинарный код DLL и demo-assets относятся к одной +совместимой технологической сборке. + +```text +modules: 16, из них DLL: 15 +DLL exports: 313 +DLL imports: 1126 +DLL identity: 15/15 +``` + +`iron_3d.exe`: 36 864 байта, PE32/x86, image base `0x400000`, entry RVA +`0x141E`, timestamp 28 июня 2001 года, SHA-256 +`b0a8b0db1c3a8698c4d4604d89c655496bd91ac1f8859a455e8a45838aebfbd6`. + +### Миссии и сквозные ссылки + +Шесть TMA разобраны до точного EOF: суммарно 20 paths, 15 clans, 201 placed +objects и 1 extra record. 48 объектов ссылаются на unit DAT, 153 -- на прямые +prototype keys. Unit-файлы раскрыли 348 компонентов. + +Сквозной результат: + +```text +501 prototype requests 501 resolved +501 MSH requests 501 resolved +501 WEAR requests 501 resolved +3879 material slots 3879 resolved +5067 texture requests 5067 resolved +18 lightmap requests 18 resolved +failures 0 +``` + +Это самое сильное интеграционное подтверждение текущего корпуса: имена, +архивы, ASCII casefold и fallback согласуются между реальными форматами. + +### Реестр и unit DAT + +`objects.rlb` содержит 590 prototype entries: + +```text +554 имеют прямую MSH-ссылку +549 прямых MSH разрешаются в demo-каталоге +34 раскрываются через родительский prototype и локальный BASE +7 не дают доступной геометрии +41 ссылка общего реестра указывает на отсутствующий demo-content +``` + +Негеометрические или неразрешённые глобальные entries: + +```text +sun_01 +sun_02 +ws_al_01 +ws_al_02 +ws_fl_01 +ws_hm_01 +ws_hm_02 +``` + +Они не входят в фактически требуемую цепочку проверенных миссий. + +Проверено 425 unit DAT, 5 219 records, errors 0. Все records имеют kind 1 и +archive `objects.rlb`; в 5 205 name fields есть ненулевые хвостовые байты после +string terminator. Такой tail является данными, а не мусором, если цель -- +lossless roundtrip. + +### Модели + +Проверено 435 MSH без errors/warnings; 157 анимированных. Диапазоны: 1-38 +nodes, 1-112 slots, 12-9 686 vertices, 1-439 batches. + +```text +414 моделей: types [1,2,3,4,5,15,13,6,7,8,19,9,10,17] +21 модель: [1,2,3,4,5,18,15,13,6,7,8,19,9,10,17,20] +``` + +Type 17 непуст у 29 моделей; type 20 встречается у 21. Редкий variant type 1 +найден в `system.rlb::MTCHECK.MSH`. + +Повторная проверка terrain исправила layout face: vertex indices находятся с +`+0x08`, neighbor indices с `+0x0E`. Эта локальная проверка имеет приоритет над +ранними черновыми описаниями. + +### Материалы и текстуры + +Проверено 457 WEAR, 905 MAT0 и 518 Texm без ошибок. У всех MAT0 `attr2 = 6`. +531 материал содержит одну phase; максимальное число phases -- 29. У 860 +материалов один animation block, у 43 -- два, у 2 -- восемь. + +Распределение Texm по форматам: + +```text +indexed 15 +565 155 +4444 59 +888 52 +8888 237 +``` + +Форматы 556 и 88 присутствуют в loader-е, но не встречаются в demo-assets. +65 текстур содержат `Page`; размеры лежат от `8x8` до `256x256`. Все 385 +уникальных texture references из MAT0 разрешаются. + +### Эффекты + +Проверено 923 FXID без ошибок. Наиболее часты команды 3, 7, 1 и 2. Команда 6 в +данных демоверсии не встречается. Наблюдаются режимы времени 0, 1, 2, 4, 5, +14, 15, 16 и 17. + +### Карты + +Шесть `Land.msh` и шесть `Land.map` проходят проверку без ошибок. Всего 3 811 +ареалов; grid всегда `128x128`, максимальное число candidates в ячейке -- 10, +`poly_count` во всех записях равен нулю. + +```text +AutoMAP 3051 vertices, 3174 faces, 343 areas +PROL 11125 vertices, 9234 faces, 731 area +Tut_1 8827 vertices, 8290 faces, 378 areas +Tut_2 9456 vertices, 8996 faces, 900 areas +Tut_3 9833 vertices, 8560 faces, 722 areas +Tut_4 9022 vertices, 8612 faces, 737 areas +``` + +Максимальное отклонение длины areal normal от единицы около `1.05e-7`. + +### Вспомогательные форматы + +```text +CTPT 284 resources, 3599 points, errors 0 +NDPR 494 resources, 1915 records, errors 0 +BASE 30 resources, errors 0 +EXPL 144 resources, versions 1/2/3, errors 0 +reference arrays 585 resources, 2956 records, errors 0 +SUND 2 resources, 12 keys, errors 0 +CTLD 531 payloads, errors 0 +TRF 5 files, errors 0 +preload 38 entries +ANI 8 resources +SKE 6 resources +``` + +CTPT names подтверждают attachment semantics: `TurretCenter`, `TurretDirect`, +`CameraCenter`, `TargetDirect`, `Root`, `Sfx`, `Width`, `Height`, `Dir` и +другие. + +### Как читать статистику + +Нулевое число parser errors подтверждает layout и диапазонные инварианты на +имеющихся variants, но не автоматически раскрывает предметный смысл каждого +opaque field. Отсутствие opcode или poly branch в corpus означает, что эту +ветку нельзя считать corpus-verified. + +Особенно важно различать весь архив и достижимый runtime path. В `objects.rlb` +есть ссылки на вырезанный demo-content, однако шесть миссий не требуют их. +Поэтому quality gate имеет два отчёта: global archive health и mission +reachability. + +### Полные каталоги Частей 1 и 2 + +Статистика демоверсии остаётся неизменной. Полные Части 1 и 2 образуют два +самостоятельных профиля с отдельными manifests, hashes и golden data. + +Часть 1: + +```text +files 1 017, bytes 197 056 957 +NRes 120 / 6 804 entries +TMA 29 / 864 objects / 28 extras +unit DAT 425 / 5 219 records +objects.rlb 590 prototypes +MSH 435, MAT0 905, Texm 518, FXID 923 +Land maps 33 / 34 662 areals +reachable prototypes 4 701 +materials 36 954, textures 48 806, lightmaps 139 +reachability failures 0 +``` + +Часть 2: + +```text +files 1 302, bytes 358 004 931 +NRes 134 / 8 171 entries +TMA 31 / 885 objects / 41 extras +unit DAT 676 / 8 145 records +objects.rlb 683 prototypes +MSH 511, MAT0 1 127, Texm 631, FXID 1 065 +Land maps 32 / 18 984 areals +reachable prototypes 5 845 +materials 50 888, textures 68 603, lightmaps 214 +reachability failures 0 +``` + +Bootstrap Частей 1 и 2 идентичен. Девять DLL идентичны, шесть пересобраны при +сохранённом ABI. Активные NRes entries сравниваются так: 3 733 идентичны, 2 503 +имеют изменённый payload, 1 934 добавлены в Части 2, 567 удалены. Это +показывает стабильность форматов при существенной переработке content, +особенно MSH, CTLD и FXID. + +## Границы знания + +### Закрытые или практически закрытые области + +- Startup bootstrap и восемь exports `iron3d.dll`. +- Карта 15 DLL, exports/imports и основные interface boundaries. +- NRes layout, поиск и writer rules. +- RsLi header, table transform, lookup, mapping и используемые decode paths. +- TMA всех 60 проверенных миссий, unit DAT и `objects.rlb` resolution. +- MSH core/animation range contracts. +- WEAR, MAT0, Texm и FXID framing. +- `Land.msh`/`Land.map` и areal grid. +- World3D calculation/render order и deferred deletion. +- Сквозная mission-to-texture цепочка. + +Полная проверка доступных каталогов усилила NRes active ranges, recursive +prototype inheritance через `objects.rlb`, bounded non-NUL unit descriptions, +полный TMA epilogue, extra records и Clan mode 0, MSH/MAT0/Texm/FXID variant +matrix Частей 1 и 2, 65 `Land.msh`/`Land.map`, полный reachable graph 60 +миссий, stability matrix пятнадцати DLL, empty SWAV и stale save-slot metadata. + +### Render-state и pixel parity + +Доказан порядок frame boundaries, world traversal, material resolve и крупных +проходов. Не доказаны символами точные названия renderer vtable slots +`+0x28/+0x30/+0x34`, полный набор state transitions CShade и окончательный +взаимный порядок некоторых transparent/FX/shadow subpasses. + +Pixel parity требует эталонных кадров оригинала с фиксированными camera, +timing, seed, разрешением и capability profile. Вместе с изображением +необходимо сохранять command/state trace; иначе pixel difference не позволяет +отличить ошибку формата от ошибки backend-а. + +Минимальный capture должен фиксировать resolution, bit depth, selected driver, +device capabilities, camera matrices, mission, game time, seed, input log, +scene boundaries, transforms, render states, texture-stage states, texture +binds, viewport, clear, draw calls и `Blt/Flip`. Сначала сравниваются command +lists; pixel diff имеет смысл только после совпадения geometry/state sequence. + +### FXID field-level semantics + +Размеры команд, resource references, lifecycle, flags families и используемые +time modes известны. Не закрыто значение каждого поля body opcodes 1-10, +отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких +time modes. + +Закрывающий эксперимент: создать инструмент, который изменяет по одному полю +копии эффекта, воспроизводить его в контролируемой сцене и логировать runtime +command object, emitted primitives и sound events. Одновременно reads в +`Effect.dll` сопоставляются с offsets body. + +### Script VM + +Сценарные packages, symbol names, event sections, variable declarations и +version check доступны. Полная instruction grammar `.scr`, semantics всех +opcodes и serialization состояния VM ещё не восстановлены. + +План реконструкции: + +1. Найти loader `.scr`, version check, границы bytecode, таблицы + strings/symbols/events. +2. Найти dispatcher loop по повторяющемуся чтению opcode и indirect branch или + jump table. +3. Для каждого handler определить instruction size, operands, чтения/записи VM + state, stack effect, branch target и world side effects. +4. Hook-нуть dispatcher и писать запись `package,event,ip,opcode,raw + operands,state before,state after,next ip`. +5. Построить disassembler и CFG; branch target обязан попадать на + подтверждённую границу инструкции. +6. Закрывать opcode после статического handler contract, одного динамического + trace и одного regression script. + +После opcode table отдельно восстанавливаются serialization IP, call/event +frames, variables, timers и RNG. + +### Physical/control formats + +CTLD и связанные resources структурно читаются, count patterns и variants +известны. Не названы все секции, shape types, coefficients и точный contact +solver. То же относится к редким MSH types 17/20 и части CTPT/NDPR flags. + +Закрывающий эксперимент: трассировать `LoadControlSystem`, +`LoadPhysicalModel` и создание collision objects на нескольких прототипах; +записать offsets, созданные shape instances и реакции на контролируемое +движение. Изменение одного resource field должно связываться с одним +наблюдаемым параметром. + +### Сеть + +DirectPlay lifecycle и имена игровых сообщений известны. Точные framing, +payload schema, reliability flags и алгоритм `netZipData` пока не подтверждены +записью сетевого обмена. Поэтому совместимость с оригинальным сетевым клиентом +ещё не доказана. + +Для закрытия нужны два оригинальных клиента в изолированной среде и логирование +`netZipData`, `netUnZipData`, DirectPlay Send/Receive и World3D message +enqueue/dequeue. Native interoperability подтверждается только успешным +обменом original client <-> compatibility implementation в обе стороны. + +### Редкие или отсутствующие corpus-ветки + +- `Land.map poly_count > 0`: layout читается из loader-а, но ни одна из 65 + проверенных карт не содержит живой записи. +- RsLi adaptive methods `0x080`/`0x0A0`: decoder path известен, однако + демоверсия и обе полные части их не используют. +- Texm formats 556 и 88: loader поддерживает их, но ни один проверенный Texm не + использует эти значения. +- FX opcode 6: размер известен, однако живой command отсутствует во всём + доступном corpus. +- Некоторые material flags и MSH auxiliary streams встречаются слишком редко + для полного authoring contract. + +Такие ветки реализуются строго по бинарному коду и synthetic tests, а статус +corpus-verified получают только после появления реального файла. + +### Сохранения и campaign state + +`saveslots.cfg` и `missions/dispatcher.ini` найдены, но полный бинарный +savegame payload, serialization World3D/AI/script/RNG и правила миграции версии +не восстановлены. Без этого нельзя честно заявлять полную campaign +compatibility. + +Минимальный набор сохранений для каждой части: + +```text +S0 сразу после старта миссии +S1 тот же state без simulation step +S2 изменена только позиция одного объекта +S3 изменено только здоровье/свойство +S4 активен один Behavior order/path +S5 активен один FX и timer +S6 изменена одна script variable +S7 изменён research/economy state +S8 перед/после mission completion +S9 pause и non-default game time +``` + +Без самих binary save payload возможно описать обязательный state и найти код +сериализации, но невозможно доказать disk layout и roundtrip. + +### Shell, HUD, шрифты и локализация + +Граница shell подтверждена экспортами `createShell`/`getIShell`, `IGUIServer`, +верхнеуровневым UI-pass и файлами `ui/*.cfg`, `DATA/TextRes.cfg`, +`gamefont.rlb` и `sprites.lib`. RsLi framing двух библиотек закрыт, но widget +tree, layout rules, font glyph metrics, sprite command semantics, +focus/navigation и полный HUD state machine пока не восстановлены до +field-level спецификации. + +До закрытия новая реализация может построить функционально эквивалентный UI +поверх известных ресурсов, но не заявлять native layout/behavior parity. + +### Исследования, экономика и игровые свойства + +Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA +properties доказывают отдельный слой исследований, стоимости, добычи и +производственных параметров. Сквозные имена (`MaximumOre`, `CurrentOre`, +`FreeResearchTime`, `FreeConstructionTime` и другие) доступны, однако формулы +стоимости, dependency graph технологий, inventory/economy transitions и точная +типизация всех 16-byte property values не закрыты. + +Закрывающий эксперимент: сопоставить `LoadResearch`/`CalcFullResearchCost` с +ресурсами и UI, снять изменения state на контролируемых покупках/исследованиях +и построить typed schema свойств по consumers, не по одному имени. + +### Условия динамического этапа + +Полное закрытие оставшихся вопросов технически возможно, но не только по +статическим архивам. Нужна среда, способная запускать оригинальный 32-битный +код, и набор эталонных наблюдений: + +1. Изолированная 32-битная Windows VM или отдельная машина с исходными + DirectDraw/Direct3D/DirectSound/DirectPlay interfaces. +2. Два неизменённых игровых каталога и manifest SHA-256 для executable, DLL, + конфигураций и ключевых архивов. +3. Отладчик с hardware/software breakpoints, просмотром x87/SSE state и + сохранением memory dumps. +4. API/vtable hooking для Win32 file I/O, DirectDraw/Direct3D, DirectSound и + DirectPlay; hooks должны писать binary trace, не изменяя порядок вызовов. +5. Управляемые clocks, input log и RNG seed либо trace всех вызовов источника + случайности. +6. Автоматический launcher, который восстанавливает snapshot VM, запускает один + test case, собирает логи и завершает процесс без ручного вмешательства. + +Для каждого capture сохраняются profile сборки, hash модулей, +mission/resource key, конфигурация, device profile, начальное состояние, +input/time script и версии инструментов. + +### Критерий закрытия открытого вопроса + +Для каждого открытого вопроса должны существовать: + +- build fingerprint и адреса наблюдаемых функций; +- raw trace и автоматический parser trace-а; +- минимальный воспроизводимый input/resource/save/message; +- формальный контракт или явно ограниченная гипотеза; +- differential test для Частей 1 и 2, если модуль изменён; +- обновление тематической статьи; +- regression case, запускаемый без ручного анализа. + +До выполнения этих условий статический контракт пригоден для реализации, но +утверждение о полном поведенческом или native-паритете не публикуется. + +## Глоссарий + +### Бинарные файлы и reverse engineering + +**PE (Portable Executable)** -- формат исполняемых файлов Windows: EXE и DLL. +Он содержит заголовки, секции, таблицы импортов и экспортов, relocations и +адрес точки входа. + +**Image base** -- предпочтительный адрес начала загруженного PE-образа. +**VA** -- виртуальный адрес в процессе. **RVA** -- адрес относительно image +base. Адрес функции в памяти обычно равен `image_base + RVA`. + +**Import** -- внешняя функция или переменная, которую модуль получает из другой +DLL. **Export** -- символ, предоставляемый другим модулям. Имя, ordinal и +calling convention вместе образуют часть бинарного контракта. + +**ABI** -- соглашение о двоичном взаимодействии: размещение аргументов, возврат +значений, очистка stack, layout структур, порядок virtual methods и правила +владения. + +**Calling convention** -- часть ABI, определяющая передачу аргументов и очистку +stack. Для исследованного 32-битного кода важны `__cdecl`, `__stdcall` и +`__thiscall`. + +**Vtable** -- массив указателей на virtual methods C++-объекта. Запись +`vtable +0x34` означает вызов указателя по байтовому смещению `0x34` от начала +таблицы. + +**Static analysis** исследует файл без его исполнения: disassembly, strings, +imports, call graph и data flow. **Dynamic analysis** наблюдает работающую +программу: breakpoints, traces, API hooks, memory state и packet/frame captures. + +**Evidence** -- наблюдение, которое можно повторить. **Inference** -- вывод, +объединяющий несколько наблюдений. **Hypothesis** -- рабочее предположение, ещё +не подтверждённое достаточным экспериментом. + +### Форматы данных и ресурсы + +**Archive** -- контейнер, объединяющий множество ресурсов. **Entry** -- запись +его каталога. **Payload** -- полезные bytes конкретной записи. + +**Magic** -- короткая сигнатура формата, например `NRes` или `Texm`. +**Version** -- номер варианта layout. Проверка одной magic без проверки version +и размеров недостаточна. + +**Offset** -- положение данных относительно начала файла или структуры. +**Size** -- число занимаемых bytes. **Stride** -- размер одного элемента +массива. **Alignment** -- требование начинать данные на address или offset, +кратном заданному числу. + +**Little-endian** -- порядок, в котором младший byte многобайтного числа +расположен первым. Все основные числовые поля исследованных форматов Iron3D +используют этот порядок. + +**Fixed-size string** -- поле заранее известной длины. Полезная строка +заканчивается первым NUL, но оставшиеся bytes поля могут содержать служебный +хвост и должны сохраняться. + +**Opaque field** -- поле с доказанными offset и размером, но не установленным +предметным смыслом. Его безопасно читать и копировать, но нельзя очищать или +переосмысливать без эксперимента. + +**Invariant** -- условие, которое обязано выполняться: диапазон находится +внутри payload, индекс указывает на существующий элемент, число записей +соответствует размеру секции. + +**Strict reader** отклоняет любое нарушение контракта. **Compatibility reader** +дополнительно воспроизводит только известные особенности оригинала, например +именованный fallback. Compatibility mode не означает игнорирование произвольной +порчи. + +**Roundtrip** -- последовательность decode -> encode. **Byte-identical +roundtrip** создаёт файл, полностью совпадающий с исходным. **Lossless editor** +может изменить известное поле, сохранив все остальные bytes и порядок записей. + +**Fallback** -- явно предписанный запасной путь, например материал `DEFAULT`, +затем entry 0. **Heuristic** -- догадка по похожим данным; она не должна +незаметно заменять доказанный fallback. + +### Игровой runtime + +**Engine** -- программная среда, которая загружает данные, ведёт время, +исполняет мир и формирует изображение/звук. **Game** -- конкретные правила, +миссии и содержимое, работающие поверх engine services. + +**World** -- долгоживущее состояние миссии: objects, terrain, время, кланы и +managers. **Scene** -- представление части мира для конкретной обработки, чаще +всего текущей камеры. + +**Game object** -- сущность с идентичностью, transform, properties и lifecycle. +**Component/controller** -- специализированная часть поведения: animation, +physics, AI или rendering representation. + +**Simulation** отвечает за изменение мира. **Tick** -- один расчётный шаг +simulation. **Frame** -- одно подготовленное изображение. Число ticks и frames +за единицу времени не обязано совпадать. + +**Game loop** -- повторяющийся порядок ввода, расчёта, рендера и обслуживания. +**Scheduler phase** -- явно ограниченный участок loop, где разрешены +определённые операции. + +**Event/message** -- типизированное сообщение между objects или subsystems. +**Queue traversal** -- стабильный обход зарегистрированных объектов. +**Deferred deletion** -- перенос фактического удаления до безопасной границы +после traversal. + +**Determinism** -- одинаковый результат при одинаковом initial state, input, +времени и порядке событий. **Replay** -- повторное исполнение записанной +последовательности входов/сообщений для проверки determinism. + +**Authority** -- subsystem или network peer, которому разрешено окончательно +менять состояние объекта. **Mirror object** -- локальное представление объекта, +authority которого находится у другого player. + +### Геометрия, анимация и рендеринг + +**Mesh** -- набор vertex/index streams и draw-групп, описывающий форму. +**Node** -- элемент hierarchy модели со своим local transform. **Slot** в MSH +-- выбранная геометрическая группа для комбинации node, LOD и group; он также +хранит bounds и диапазоны batches. + +**Batch** -- непрерывный индексный диапазон с одним material slot и общим +render state. **Transform** переводит данные между local, world, view и clip +spaces. Порядок умножения matrices является частью контракта. + +**Quaternion** -- четырёхкомпонентное представление вращения. **Keyframe** -- +pose в определённое время. **Sampling** выбирает pose для времени, а +**blending** смешивает animation states. + +**Bounds** -- упрощённый объём для быстрых тестов. **AABB** -- пара +minimum/maximum по осям. **Bounding sphere** -- center и radius. + +**Renderer** -- subsystem, преобразующая подготовленную сцену в изображение. +**Backend** -- реализация renderer поверх конкретного API или устройства. + +**Draw call** -- команда нарисовать диапазон primitives с текущими resources и +states. **Material** -- правила отображения поверхности: texture, коэффициенты, +прозрачность и режимы pipeline. **Material phase** -- одно временное состояние +анимированного материала. + +**Texture** -- двумерный массив texels. **UV coordinates** -- координаты +выборки. **Mip chain** -- последовательность уменьшенных уровней texture. +**Lightmap** -- texture с заранее рассчитанным вкладом освещения. + +**Fixed-function pipeline** -- старый графический pipeline, где приложение +выбирает predefined transform, lighting, texture-stage и blend states вместо +пользовательских shaders. + +**Depth buffer** хранит глубину уже принятой поверхности. **Alpha test** +полностью принимает или отвергает fragment. **Blending** смешивает новый цвет с +framebuffer. + +**Back buffer** -- скрытый framebuffer. **Present/flip** делает завершённый +кадр видимым. **Pixel parity** -- совпадение конечного изображения при +фиксированных условиях. + +### Навигация, физика, звук и сеть + +**Areal** -- логическая область карты с границей, class/flags и связями с +соседями. **Areal graph** -- граф, вершинами которого служат области, а рёбрами +-- допустимые переходы. **Cell grid** -- пространственный индекс для candidate +areas или objects. + +**Pathfinding** -- поиск маршрута по графу. **A\*** использует стоимость уже +пройденного пути и оценку расстояния до цели. Навигационная проходимость, +отсутствие collision и видимость -- разные свойства. + +**Collision proxy** -- упрощённое представление объекта для столкновений. +**Broad phase** быстро находит потенциальные пары; **narrow phase** выполняет +точную проверку и вычисляет contact. + +**Sample** -- декодированные звуковые данные. **Source** -- экземпляр +воспроизведения с position, gain, loop state и временем. **Listener** -- позиция +и ориентация слушателя для 3D spatialization. + +**Transport** -- механизм доставки bytes между peers. **Protocol** -- framing, +message types, порядок и правила подтверждения. **Serialization** -- +преобразование typed state в byte sequence. + +**Reliable delivery** гарантирует доставку/порядок в пределах выбранной модели; +**unreliable delivery** допускает потери ради задержки. **Wire compatibility** +-- способность обмениваться данными с оригинальным клиентом, а не только +воспроизводить ту же игровую семантику в новом протоколе. + +## Связанные локальные справки + +- [NRes](../reference/nres.md) +- [RsLi](../reference/rsli.md) +- [TMA](../reference/tma.md) +- [MSH](../reference/msh.md) +- [Texm](../reference/texm.md) +- [Materials](../reference/materials.md) +- [Render frame](../reference/render-frame.md) +- [Границы знания](../appendices/knowledge-boundaries.md) +- [Глоссарий](../appendices/glossary.md) + +## Дополнительное чтение + +Эти материалы помогают понять PE, ABI, сжатие, graphics pipeline, game loop и +навигацию. Они не являются доказательством поведения Iron3D: детали движка +принимаются только после проверки его бинарного кода и игровых ресурсов. + +- [Microsoft PE/COFF specification](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format) +- [Microsoft x86 calling conventions](https://learn.microsoft.com/en-us/cpp/build/x86-calling-conventions) +- [Intel Software Developer Manuals](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html) +- [Ghidra documentation](https://ghidra-sre.org/) +- [RFC 1951: DEFLATE](https://www.rfc-editor.org/rfc/rfc1951) +- [zlib manual](https://zlib.net/manual.html) +- [Kaitai Struct user guide](https://doc.kaitai.io/user_guide.html) +- [Microsoft Direct3D documentation](https://learn.microsoft.com/en-us/windows/win32/direct3d) +- [Vulkan specification](https://registry.khronos.org/vulkan/specs/1.4-extensions/html/vkspec.html) +- [Real-Time Rendering resources](https://www.realtimerendering.com/) +- [LearnOpenGL](https://learnopengl.com/) +- [Scratchapixel](https://www.scratchapixel.com/) +- [Game Programming Patterns](https://gameprogrammingpatterns.com/) +- [Fix Your Timestep](https://gafferongames.com/post/fix_your_timestep/) +- [Red Blob Games: A*](https://www.redblobgames.com/pathfinding/a-star/introduction.html) @@ -3,7 +3,7 @@ site_name: FParkan site_url: https://fparkan.popov.link/ site_author: Valentin Popov site_description: >- - Utilities and tools for the game “Parkan: Iron Strategy”. + Техническая книга о восстановлении игрового движка Iron3D из Parkan: Iron Strategy. # Repository repo_name: valentineus/fparkan @@ -16,36 +16,66 @@ copyright: Copyright © 2023 — 2026 Valentin Popov theme: name: material language: ru + features: + - navigation.instant + - navigation.sections + - navigation.indexes + - navigation.top + - toc.follow + - search.highlight + - search.suggest palette: - scheme: slate + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: deep orange + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: deep orange + +markdown_extensions: + - admonition + - attr_list + - def_list + - md_in_html + - toc: + permalink: true + - pymdownx.details + - pymdownx.highlight + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + +plugins: + - search: + lang: + - ru + - en # Navigation nav: - - Home: index.md - - Specs: - - 3D implementation notes: specs/msh-notes.md - - AI system: specs/ai.md - - ArealMap: specs/arealmap.md - - Behavior system: specs/behavior.md - - Control system: specs/control.md - - FXID: specs/fxid.md - - Material (MAT0): specs/material.md - - Wear (WEAR): specs/wear.md - - Texture (Texm): specs/texture.md - - Materials index: specs/materials-texm.md - - Missions: specs/missions.md - - Object registry (objects.rlb): specs/object-registry.md - - MSH animation: specs/msh-animation.md - - MSH core: specs/msh-core.md - - Network system: specs/network.md - - NRes / RsLi: specs/nres.md - - Render pipeline: specs/render.md - - Render parity: specs/render-parity.md - - Runtime pointer: specs/runtime-pipeline.md - - Sound system: specs/sound.md - - Terrain + map loading: specs/terrain-map-loading.md - - UI system: specs/ui.md - - Форматы 3D‑ресурсов (обзор): specs/msh.md + - Начало: index.md + - Книга: + - I. Путеводитель и методика: tomes/01-guide.md + - II. Запуск, архитектура и игровой цикл: tomes/02-architecture.md + - III. Ресурсная система и форматы: tomes/03-resources.md + - IV. Мир, миссии и игровой runtime: tomes/04-world.md + - V. Геометрия, материалы и рендер: tomes/05-render.md + - VI. Поведение, управление, звук и сеть: tomes/06-behavior.md + - VII. Руководство по полной реализации: tomes/07-implementation.md + - VIII. Справочник и доказательная база: tomes/08-evidence.md + - Справочник: + - NRes: reference/nres.md + - RsLi: reference/rsli.md + - TMA: reference/tma.md + - MSH: reference/msh.md + - WEAR и MAT0: reference/materials.md + - Texm: reference/texm.md + - Render frame: reference/render-frame.md + - Приложения: + - Глоссарий: appendices/glossary.md + - Границы знания: appendices/knowledge-boundaries.md # Additional configuration extra: diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 0000000..ca8627f --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,5 @@ +# Тестовые данные + +Для тестирования на реальных ресурсах разместите в этом каталоге игровые каталоги. + +Игровые файлы не включаются в репозиторий. diff --git a/testdata/nres/.gitignore b/testdata/nres/.gitignore deleted file mode 100644 index c96a04f..0000000 --- a/testdata/nres/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore
\ No newline at end of file diff --git a/testdata/rsli/.gitignore b/testdata/rsli/.gitignore deleted file mode 100644 index c96a04f..0000000 --- a/testdata/rsli/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore
\ No newline at end of file diff --git a/tools/README.md b/tools/README.md deleted file mode 100644 index 2418567..0000000 --- a/tools/README.md +++ /dev/null @@ -1,201 +0,0 @@ -# Инструменты в каталоге `tools` - -## `archive_roundtrip_validator.py` - -Скрипт предназначен для **валидации документации по форматам NRes и RsLi на реальных данных игры**. - -Что делает утилита: - -- находит архивы по сигнатуре заголовка (а не по расширению файла); -- распаковывает архивы в структуру `manifest.json + entries/*`; -- собирает архивы обратно из `manifest.json`; -- выполняет проверку `unpack -> repack -> byte-compare`; -- формирует отчёт о расхождениях со спецификацией. - -Скрипт не изменяет оригинальные файлы игры. Рабочие файлы создаются только в указанном `--workdir` (или во временной папке). - -## Поддерживаемые сигнатуры - -- `NRes` (`4E 52 65 73`) -- `RsLi` в файловом формате библиотеки: `NL 00 01` - -## Основные команды - -Сканирование архива по сигнатурам: - -```bash -python3 tools/archive_roundtrip_validator.py scan --input tmp/gamedata -``` - -Распаковка/упаковка одного NRes: - -```bash -python3 tools/archive_roundtrip_validator.py nres-unpack \ - --archive tmp/gamedata/sounds.lib \ - --output tmp/work/nres_sounds - -python3 tools/archive_roundtrip_validator.py nres-pack \ - --manifest tmp/work/nres_sounds/manifest.json \ - --output tmp/work/sounds.repacked.lib -``` - -Распаковка/упаковка одного RsLi: - -```bash -python3 tools/archive_roundtrip_validator.py rsli-unpack \ - --archive tmp/gamedata/sprites.lib \ - --output tmp/work/rsli_sprites - -python3 tools/archive_roundtrip_validator.py rsli-pack \ - --manifest tmp/work/rsli_sprites/manifest.json \ - --output tmp/work/sprites.repacked.lib -``` - -Полная валидация документации на всём наборе данных: - -```bash -python3 tools/archive_roundtrip_validator.py validate \ - --input tmp/gamedata \ - --workdir tmp/validation_work \ - --report tmp/validation_report.json \ - --fail-on-diff -``` - -## Формат распаковки - -Для каждого архива создаются: - -- `manifest.json` — все поля заголовка, записи, индексы, смещения, контрольные суммы; -- `entries/*.bin` — payload-файлы. - -Имена файлов в `entries` включают индекс записи, поэтому коллизии одинаковых имён внутри архива обрабатываются корректно. - -## `init_testdata.py` - -Скрипт инициализирует тестовые данные по сигнатурам архивов из спецификации: - -- `NRes` (`4E 52 65 73`); -- `RsLi` (`NL 00 01`). - -Что делает утилита: - -- рекурсивно сканирует все файлы в `--input`; -- копирует найденные `NRes` в `--output/nres/`; -- копирует найденные `RsLi` в `--output/rsli/`; -- сохраняет относительный путь исходного файла внутри целевого каталога; -- создаёт целевые каталоги автоматически, если их нет. - -Базовый запуск: - -```bash -python3 tools/init_testdata.py --input tmp/gamedata --output testdata -``` - -Если целевой файл уже существует, скрипт спрашивает подтверждение перезаписи (`yes/no/all/quit`). - -Для перезаписи без вопросов используйте `--force`: - -```bash -python3 tools/init_testdata.py --input tmp/gamedata --output testdata --force -``` - -Проверки надёжности: - -- `--input` должен существовать и быть каталогом; -- если `--output` указывает на существующий файл, скрипт завершится с ошибкой; -- если `--output` расположен внутри `--input`, каталог вывода исключается из сканирования; -- если `stdin` неинтерактивный и требуется перезапись, нужно явно указать `--force`. - -## `msh_doc_validator.py` - -Скрипт валидирует ключевые инварианты из документации `/Users/valentineus/Developer/personal/fparkan/docs/specs/msh.md` на реальных данных. - -Проверяемые группы: - -- модели `*.msh` (вложенные `NRes` в архивах `NRes`); -- текстуры `Texm` (`type_id = 0x6D786554`); -- эффекты `FXID` (`type_id = 0x44495846`). - -Что проверяет для моделей: - -- обязательные ресурсы (`Res1/2/3/6/13`) и известные опциональные (`Res4/5/7/8/10/15/16/18/19`); -- `size/attr1/attr3` и шаги структур по таблицам; -- диапазоны индексов, батчей и ссылок между таблицами; -- разбор `Res10` как `len + bytes + NUL` для каждого узла; -- матрицу слотов в `Res1` (LOD/group) и границы по `Res2/Res7/Res13/Res19`. - -Быстрый запуск: - -```bash -python3 tools/msh_doc_validator.py scan --input testdata/nres -python3 tools/msh_doc_validator.py validate --input testdata/nres --print-limit 20 -``` - -С отчётом в JSON: - -```bash -python3 tools/msh_doc_validator.py validate \ - --input testdata/nres \ - --report tmp/msh_validation_report.json \ - --fail-on-warnings -``` - -## `msh_preview_renderer.py` - -Примитивный программный рендерер моделей `*.msh` без внешних зависимостей. - -- вход: архив `NRes` (например `animals.rlb`) или прямой payload модели; -- выход: изображение `PPM` (`P6`); -- использует `Res3` (позиции), `Res6` (индексы), `Res13` (батчи), `Res1/Res2` (выбор слотов по `lod/group`). - -Показать доступные модели в архиве: - -```bash -python3 tools/msh_preview_renderer.py list-models --archive testdata/nres/animals.rlb -``` - -Сгенерировать тестовый рендер: - -```bash -python3 tools/msh_preview_renderer.py render \ - --archive testdata/nres/animals.rlb \ - --model A_L_01.msh \ - --output tmp/renders/A_L_01.ppm \ - --width 800 \ - --height 600 \ - --lod 0 \ - --group 0 \ - --wireframe -``` - -Ограничения: - -- инструмент предназначен для smoke-теста геометрии, а не для пиксельно-точного рендера движка; -- текстуры/материалы/эффектные проходы не эмулируются. - -## `msh_export_obj.py` - -Экспортирует геометрию `*.msh` в `Wavefront OBJ`, чтобы открыть модель в Blender/MeshLab. - -- вход: `NRes` архив (например `animals.rlb`) или прямой payload модели; -- выбор геометрии: через `Res1` slot matrix (`lod/group`) как в рендерере; -- опция `--all-batches` экспортирует все батчи, игнорируя slot matrix. - -Показать модели в архиве: - -```bash -python3 tools/msh_export_obj.py list-models --archive testdata/nres/animals.rlb -``` - -Экспорт в OBJ: - -```bash -python3 tools/msh_export_obj.py export \ - --archive testdata/nres/animals.rlb \ - --model A_L_01.msh \ - --output tmp/renders/A_L_01.obj \ - --lod 0 \ - --group 0 -``` - -Файл `OBJ` можно открыть напрямую в Blender (`File -> Import -> Wavefront (.obj)`). diff --git a/tools/archive_roundtrip_validator.py b/tools/archive_roundtrip_validator.py deleted file mode 100644 index 073fd9b..0000000 --- a/tools/archive_roundtrip_validator.py +++ /dev/null @@ -1,944 +0,0 @@ -#!/usr/bin/env python3 -""" -Roundtrip tools for NRes and RsLi archives. - -The script can: -1) scan archives by header signature (ignores file extensions), -2) unpack / pack NRes archives, -3) unpack / pack RsLi archives, -4) validate docs assumptions by full roundtrip and byte-to-byte comparison. -""" - -from __future__ import annotations - -import argparse -import hashlib -import json -import re -import shutil -import struct -import tempfile -import zlib -from pathlib import Path -from typing import Any - -MAGIC_NRES = b"NRes" -MAGIC_RSLI = b"NL\x00\x01" - - -class ArchiveFormatError(RuntimeError): - pass - - -def sha256_hex(data: bytes) -> str: - return hashlib.sha256(data).hexdigest() - - -def safe_component(value: str, fallback: str = "item", max_len: int = 80) -> str: - clean = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-") - if not clean: - clean = fallback - return clean[:max_len] - - -def first_diff(a: bytes, b: bytes) -> tuple[int | None, str | None]: - if a == b: - return None, None - limit = min(len(a), len(b)) - for idx in range(limit): - if a[idx] != b[idx]: - return idx, f"{a[idx]:02x}!={b[idx]:02x}" - return limit, f"len {len(a)}!={len(b)}" - - -def load_json(path: Path) -> dict[str, Any]: - with path.open("r", encoding="utf-8") as handle: - return json.load(handle) - - -def dump_json(path: Path, payload: dict[str, Any]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8") as handle: - json.dump(payload, handle, indent=2, ensure_ascii=False) - handle.write("\n") - - -def xor_stream(data: bytes, key16: int) -> bytes: - lo = key16 & 0xFF - hi = (key16 >> 8) & 0xFF - out = bytearray(len(data)) - for i, value in enumerate(data): - lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF - out[i] = value ^ lo - hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF - return bytes(out) - - -def lzss_decompress_simple(data: bytes, expected_size: int) -> bytes: - ring = bytearray([0x20] * 0x1000) - ring_pos = 0xFEE - out = bytearray() - in_pos = 0 - control = 0 - bits_left = 0 - - while len(out) < expected_size and in_pos < len(data): - if bits_left == 0: - control = data[in_pos] - in_pos += 1 - bits_left = 8 - - if control & 1: - if in_pos >= len(data): - break - byte = data[in_pos] - in_pos += 1 - out.append(byte) - ring[ring_pos] = byte - ring_pos = (ring_pos + 1) & 0x0FFF - else: - if in_pos + 1 >= len(data): - break - low = data[in_pos] - high = data[in_pos + 1] - in_pos += 2 - # Real files indicate nibble layout opposite to common LZSS variant: - # high nibble extends offset, low nibble stores (length - 3). - offset = low | ((high & 0xF0) << 4) - length = (high & 0x0F) + 3 - for step in range(length): - byte = ring[(offset + step) & 0x0FFF] - out.append(byte) - ring[ring_pos] = byte - ring_pos = (ring_pos + 1) & 0x0FFF - if len(out) >= expected_size: - break - - control >>= 1 - bits_left -= 1 - - if len(out) != expected_size: - raise ArchiveFormatError( - f"LZSS size mismatch: expected {expected_size}, got {len(out)}" - ) - return bytes(out) - - -def decode_rsli_payload( - packed: bytes, method: int, sort_to_original: int, unpacked_size: int -) -> bytes: - key16 = sort_to_original & 0xFFFF - - if method == 0x000: - out = packed - elif method == 0x020: - if len(packed) < unpacked_size: - raise ArchiveFormatError( - f"method 0x20 packed too short: {len(packed)} < {unpacked_size}" - ) - out = xor_stream(packed[:unpacked_size], key16) - elif method == 0x040: - out = lzss_decompress_simple(packed, unpacked_size) - elif method == 0x060: - out = lzss_decompress_simple(xor_stream(packed, key16), unpacked_size) - elif method == 0x100: - try: - out = zlib.decompress(packed, -15) - except zlib.error: - out = zlib.decompress(packed) - else: - raise ArchiveFormatError(f"unsupported RsLi method: 0x{method:03X}") - - if len(out) != unpacked_size: - raise ArchiveFormatError( - f"unpacked_size mismatch: expected {unpacked_size}, got {len(out)}" - ) - return out - - -def detect_archive_type(path: Path) -> str | None: - try: - with path.open("rb") as handle: - magic = handle.read(4) - except OSError: - return None - - if magic == MAGIC_NRES: - return "nres" - if magic == MAGIC_RSLI: - return "rsli" - return None - - -def scan_archives(root: Path) -> list[dict[str, Any]]: - found: list[dict[str, Any]] = [] - for path in sorted(root.rglob("*")): - if not path.is_file(): - continue - archive_type = detect_archive_type(path) - if not archive_type: - continue - found.append( - { - "path": str(path), - "relative_path": str(path.relative_to(root)), - "type": archive_type, - "size": path.stat().st_size, - } - ) - return found - - -def parse_nres(data: bytes, source: str = "<memory>") -> dict[str, Any]: - if len(data) < 16: - raise ArchiveFormatError(f"{source}: NRes too short ({len(data)} bytes)") - - magic, version, entry_count, total_size = struct.unpack_from("<4sIII", data, 0) - if magic != MAGIC_NRES: - raise ArchiveFormatError(f"{source}: invalid NRes magic") - - issues: list[str] = [] - if total_size != len(data): - issues.append( - f"header.total_size={total_size} != actual_size={len(data)} (spec 1.2)" - ) - if version != 0x100: - issues.append(f"version=0x{version:08X} != 0x00000100 (spec 1.2)") - - directory_offset = total_size - entry_count * 64 - if directory_offset < 16 or directory_offset > len(data): - raise ArchiveFormatError( - f"{source}: invalid directory offset {directory_offset} for entry_count={entry_count}" - ) - if directory_offset + entry_count * 64 != len(data): - issues.append( - "directory_offset + entry_count*64 != file_size (spec 1.3)" - ) - - entries: list[dict[str, Any]] = [] - for index in range(entry_count): - offset = directory_offset + index * 64 - if offset + 64 > len(data): - raise ArchiveFormatError(f"{source}: truncated directory entry {index}") - - ( - type_id, - attr1, - attr2, - size, - attr3, - name_raw, - data_offset, - sort_index, - ) = struct.unpack_from("<IIIII36sII", data, offset) - name_bytes = name_raw.split(b"\x00", 1)[0] - name = name_bytes.decode("latin1", errors="replace") - entries.append( - { - "index": index, - "type_id": type_id, - "attr1": attr1, - "attr2": attr2, - "size": size, - "attr3": attr3, - "name": name, - "name_bytes_hex": name_bytes.hex(), - "name_raw_hex": name_raw.hex(), - "data_offset": data_offset, - "sort_index": sort_index, - } - ) - - # Spec checks. - expected_sort = sorted( - range(entry_count), - key=lambda idx: bytes.fromhex(entries[idx]["name_bytes_hex"]).lower(), - ) - current_sort = [item["sort_index"] for item in entries] - if current_sort != expected_sort: - issues.append( - "sort_index table does not match case-insensitive name order (spec 1.4)" - ) - - data_regions = sorted( - ( - item["index"], - item["data_offset"], - item["size"], - ) - for item in entries - ) - for idx, data_offset, size in data_regions: - if data_offset % 8 != 0: - issues.append(f"entry {idx}: data_offset={data_offset} not aligned to 8 (spec 1.5)") - if data_offset < 16 or data_offset + size > directory_offset: - issues.append( - f"entry {idx}: data range [{data_offset}, {data_offset + size}) out of data area (spec 1.3)" - ) - for i in range(len(data_regions) - 1): - _, start, size = data_regions[i] - _, next_start, _ = data_regions[i + 1] - if start + size > next_start: - issues.append( - f"entry overlap at data_offset={start}, next={next_start}" - ) - padding = data[start + size : next_start] - if any(padding): - issues.append( - f"non-zero padding after data block at offset={start + size} (spec 1.5)" - ) - - return { - "format": "NRes", - "header": { - "magic": "NRes", - "version": version, - "entry_count": entry_count, - "total_size": total_size, - "directory_offset": directory_offset, - }, - "entries": entries, - "issues": issues, - } - - -def build_nres_name_field(entry: dict[str, Any]) -> bytes: - if "name_bytes_hex" in entry: - raw = bytes.fromhex(entry["name_bytes_hex"]) - else: - raw = entry.get("name", "").encode("latin1", errors="replace") - raw = raw[:35] - return raw + b"\x00" * (36 - len(raw)) - - -def unpack_nres_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]: - data = archive_path.read_bytes() - parsed = parse_nres(data, source=str(archive_path)) - - out_dir.mkdir(parents=True, exist_ok=True) - entries_dir = out_dir / "entries" - entries_dir.mkdir(parents=True, exist_ok=True) - - manifest: dict[str, Any] = { - "format": "NRes", - "source_path": str(archive_path), - "source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path), - "header": parsed["header"], - "entries": [], - "issues": parsed["issues"], - "source_sha256": sha256_hex(data), - } - - for entry in parsed["entries"]: - begin = entry["data_offset"] - end = begin + entry["size"] - if begin < 0 or end > len(data): - raise ArchiveFormatError( - f"{archive_path}: entry {entry['index']} data range outside file" - ) - payload = data[begin:end] - base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}") - file_name = ( - f"{entry['index']:05d}__{base}" - f"__t{entry['type_id']:08X}_a1{entry['attr1']:08X}_a2{entry['attr2']:08X}.bin" - ) - (entries_dir / file_name).write_bytes(payload) - - manifest_entry = dict(entry) - manifest_entry["data_file"] = f"entries/{file_name}" - manifest_entry["sha256"] = sha256_hex(payload) - manifest["entries"].append(manifest_entry) - - dump_json(out_dir / "manifest.json", manifest) - return manifest - - -def pack_nres_manifest(manifest_path: Path, out_file: Path) -> bytes: - manifest = load_json(manifest_path) - if manifest.get("format") != "NRes": - raise ArchiveFormatError(f"{manifest_path}: not an NRes manifest") - - entries = manifest["entries"] - count = len(entries) - version = int(manifest.get("header", {}).get("version", 0x100)) - - out = bytearray(b"\x00" * 16) - data_offsets: list[int] = [] - data_sizes: list[int] = [] - - for entry in entries: - payload_path = manifest_path.parent / entry["data_file"] - payload = payload_path.read_bytes() - offset = len(out) - out.extend(payload) - padding = (-len(out)) % 8 - if padding: - out.extend(b"\x00" * padding) - data_offsets.append(offset) - data_sizes.append(len(payload)) - - directory_offset = len(out) - expected_sort = sorted( - range(count), - key=lambda idx: bytes.fromhex(entries[idx].get("name_bytes_hex", "")).lower(), - ) - - for index, entry in enumerate(entries): - name_field = build_nres_name_field(entry) - out.extend( - struct.pack( - "<IIIII36sII", - int(entry["type_id"]), - int(entry["attr1"]), - int(entry["attr2"]), - data_sizes[index], - int(entry["attr3"]), - name_field, - data_offsets[index], - expected_sort[index], - ) - ) - - total_size = len(out) - struct.pack_into("<4sIII", out, 0, MAGIC_NRES, version, count, total_size) - - out_file.parent.mkdir(parents=True, exist_ok=True) - out_file.write_bytes(out) - return bytes(out) - - -def parse_rsli(data: bytes, source: str = "<memory>") -> dict[str, Any]: - if len(data) < 32: - raise ArchiveFormatError(f"{source}: RsLi too short ({len(data)} bytes)") - if data[:4] != MAGIC_RSLI: - raise ArchiveFormatError(f"{source}: invalid RsLi magic") - - issues: list[str] = [] - reserved_zero = data[2] - version = data[3] - entry_count = struct.unpack_from("<h", data, 4)[0] - presorted_flag = struct.unpack_from("<H", data, 14)[0] - seed = struct.unpack_from("<I", data, 20)[0] - - if reserved_zero != 0: - issues.append(f"header[2]={reserved_zero} != 0 (spec 2.2)") - if version != 1: - issues.append(f"version={version} != 1 (spec 2.2)") - if entry_count < 0: - raise ArchiveFormatError(f"{source}: negative entry_count={entry_count}") - - table_offset = 32 - table_size = entry_count * 32 - if table_offset + table_size > len(data): - raise ArchiveFormatError( - f"{source}: encrypted table out of file bounds ({table_offset}+{table_size}>{len(data)})" - ) - - table_encrypted = data[table_offset : table_offset + table_size] - table_plain = xor_stream(table_encrypted, seed & 0xFFFF) - - trailer: dict[str, Any] = {"present": False} - overlay_offset = 0 - if len(data) >= 6 and data[-6:-4] == b"AO": - overlay_offset = struct.unpack_from("<I", data, len(data) - 4)[0] - trailer = { - "present": True, - "signature": "AO", - "overlay_offset": overlay_offset, - "raw_hex": data[-6:].hex(), - } - - entries: list[dict[str, Any]] = [] - sort_values: list[int] = [] - for index in range(entry_count): - row = table_plain[index * 32 : (index + 1) * 32] - name_raw = row[0:12] - reserved4 = row[12:16] - flags_signed, sort_to_original = struct.unpack_from("<hh", row, 16) - unpacked_size, data_offset, packed_size = struct.unpack_from("<III", row, 20) - method = flags_signed & 0x1E0 - name = name_raw.split(b"\x00", 1)[0].decode("latin1", errors="replace") - effective_offset = data_offset + overlay_offset - entries.append( - { - "index": index, - "name": name, - "name_raw_hex": name_raw.hex(), - "reserved_raw_hex": reserved4.hex(), - "flags_signed": flags_signed, - "flags_u16": flags_signed & 0xFFFF, - "method": method, - "sort_to_original": sort_to_original, - "unpacked_size": unpacked_size, - "data_offset": data_offset, - "effective_data_offset": effective_offset, - "packed_size": packed_size, - } - ) - sort_values.append(sort_to_original) - - if effective_offset < 0: - issues.append(f"entry {index}: negative effective_data_offset={effective_offset}") - elif effective_offset + packed_size > len(data): - end = effective_offset + packed_size - if method == 0x100 and end == len(data) + 1: - issues.append( - f"entry {index}: deflate packed_size reaches EOF+1 ({end}); " - "observed in game data, likely decoder lookahead byte" - ) - else: - issues.append( - f"entry {index}: packed range [{effective_offset}, {end}) out of file" - ) - - if presorted_flag == 0xABBA: - if sorted(sort_values) != list(range(entry_count)): - issues.append( - "presorted flag is 0xABBA but sort_to_original is not a permutation [0..N-1] (spec 2.2/2.4)" - ) - - return { - "format": "RsLi", - "header_raw_hex": data[:32].hex(), - "header": { - "magic": "NL\\x00\\x01", - "entry_count": entry_count, - "seed": seed, - "presorted_flag": presorted_flag, - }, - "entries": entries, - "issues": issues, - "trailer": trailer, - } - - -def unpack_rsli_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]: - data = archive_path.read_bytes() - parsed = parse_rsli(data, source=str(archive_path)) - - out_dir.mkdir(parents=True, exist_ok=True) - entries_dir = out_dir / "entries" - entries_dir.mkdir(parents=True, exist_ok=True) - - manifest: dict[str, Any] = { - "format": "RsLi", - "source_path": str(archive_path), - "source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path), - "source_size": len(data), - "header_raw_hex": parsed["header_raw_hex"], - "header": parsed["header"], - "entries": [], - "issues": list(parsed["issues"]), - "trailer": parsed["trailer"], - "source_sha256": sha256_hex(data), - } - - for entry in parsed["entries"]: - begin = int(entry["effective_data_offset"]) - end = begin + int(entry["packed_size"]) - packed = data[begin:end] - base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}") - packed_name = f"{entry['index']:05d}__{base}__packed.bin" - (entries_dir / packed_name).write_bytes(packed) - - manifest_entry = dict(entry) - manifest_entry["packed_file"] = f"entries/{packed_name}" - manifest_entry["packed_file_size"] = len(packed) - manifest_entry["packed_sha256"] = sha256_hex(packed) - - try: - unpacked = decode_rsli_payload( - packed=packed, - method=int(entry["method"]), - sort_to_original=int(entry["sort_to_original"]), - unpacked_size=int(entry["unpacked_size"]), - ) - unpacked_name = f"{entry['index']:05d}__{base}__unpacked.bin" - (entries_dir / unpacked_name).write_bytes(unpacked) - manifest_entry["unpacked_file"] = f"entries/{unpacked_name}" - manifest_entry["unpacked_sha256"] = sha256_hex(unpacked) - except ArchiveFormatError as exc: - manifest_entry["unpack_error"] = str(exc) - manifest["issues"].append( - f"entry {entry['index']}: cannot decode method 0x{entry['method']:03X}: {exc}" - ) - - manifest["entries"].append(manifest_entry) - - dump_json(out_dir / "manifest.json", manifest) - return manifest - - -def _pack_i16(value: int) -> int: - if not (-32768 <= int(value) <= 32767): - raise ArchiveFormatError(f"int16 overflow: {value}") - return int(value) - - -def pack_rsli_manifest(manifest_path: Path, out_file: Path) -> bytes: - manifest = load_json(manifest_path) - if manifest.get("format") != "RsLi": - raise ArchiveFormatError(f"{manifest_path}: not an RsLi manifest") - - entries = manifest["entries"] - count = len(entries) - - header_raw = bytes.fromhex(manifest["header_raw_hex"]) - if len(header_raw) != 32: - raise ArchiveFormatError(f"{manifest_path}: header_raw_hex must be 32 bytes") - header = bytearray(header_raw) - header[:4] = MAGIC_RSLI - struct.pack_into("<h", header, 4, count) - seed = int(manifest["header"]["seed"]) - struct.pack_into("<I", header, 20, seed) - - rows = bytearray() - packed_chunks: list[tuple[dict[str, Any], bytes]] = [] - - for entry in entries: - packed_path = manifest_path.parent / entry["packed_file"] - packed = packed_path.read_bytes() - declared_size = int(entry["packed_size"]) - if len(packed) > declared_size: - raise ArchiveFormatError( - f"{packed_path}: packed size {len(packed)} > manifest packed_size {declared_size}" - ) - - data_offset = int(entry["data_offset"]) - packed_chunks.append((entry, packed)) - - row = bytearray(32) - name_raw = bytes.fromhex(entry["name_raw_hex"]) - reserved_raw = bytes.fromhex(entry["reserved_raw_hex"]) - if len(name_raw) != 12 or len(reserved_raw) != 4: - raise ArchiveFormatError( - f"entry {entry['index']}: invalid name/reserved raw length" - ) - row[0:12] = name_raw - row[12:16] = reserved_raw - struct.pack_into( - "<hhIII", - row, - 16, - _pack_i16(int(entry["flags_signed"])), - _pack_i16(int(entry["sort_to_original"])), - int(entry["unpacked_size"]), - data_offset, - declared_size, - ) - rows.extend(row) - - encrypted_table = xor_stream(bytes(rows), seed & 0xFFFF) - trailer = manifest.get("trailer", {}) - trailer_raw = b"" - if trailer.get("present"): - raw_hex = trailer.get("raw_hex", "") - trailer_raw = bytes.fromhex(raw_hex) - if len(trailer_raw) != 6: - raise ArchiveFormatError("trailer raw length must be 6 bytes") - - source_size = manifest.get("source_size") - table_end = 32 + count * 32 - if source_size is not None: - pre_trailer_size = int(source_size) - len(trailer_raw) - if pre_trailer_size < table_end: - raise ArchiveFormatError( - f"invalid source_size={source_size}: smaller than header+table" - ) - else: - pre_trailer_size = table_end - for entry, packed in packed_chunks: - pre_trailer_size = max( - pre_trailer_size, int(entry["data_offset"]) + len(packed) - ) - - out = bytearray(pre_trailer_size) - out[0:32] = header - out[32:table_end] = encrypted_table - occupied = bytearray(pre_trailer_size) - occupied[0:table_end] = b"\x01" * table_end - - for entry, packed in packed_chunks: - base_offset = int(entry["data_offset"]) - for index, byte in enumerate(packed): - pos = base_offset + index - if pos >= pre_trailer_size: - raise ArchiveFormatError( - f"entry {entry['index']}: data write at {pos} beyond output size {pre_trailer_size}" - ) - if occupied[pos] and out[pos] != byte: - raise ArchiveFormatError( - f"entry {entry['index']}: overlapping packed data conflict at offset {pos}" - ) - out[pos] = byte - occupied[pos] = 1 - - out.extend(trailer_raw) - if source_size is not None and len(out) != int(source_size): - raise ArchiveFormatError( - f"packed size {len(out)} != source_size {source_size} from manifest" - ) - - out_file.parent.mkdir(parents=True, exist_ok=True) - out_file.write_bytes(out) - return bytes(out) - - -def cmd_scan(args: argparse.Namespace) -> int: - root = Path(args.input).resolve() - archives = scan_archives(root) - if args.json: - print(json.dumps(archives, ensure_ascii=False, indent=2)) - else: - print(f"Found {len(archives)} archive(s) in {root}") - for item in archives: - print(f"{item['type']:4} {item['size']:10d} {item['relative_path']}") - return 0 - - -def cmd_nres_unpack(args: argparse.Namespace) -> int: - archive_path = Path(args.archive).resolve() - out_dir = Path(args.output).resolve() - manifest = unpack_nres_file(archive_path, out_dir) - print(f"NRes unpacked: {archive_path}") - print(f"Manifest: {out_dir / 'manifest.json'}") - print(f"Entries : {len(manifest['entries'])}") - if manifest["issues"]: - print("Issues:") - for issue in manifest["issues"]: - print(f"- {issue}") - return 0 - - -def cmd_nres_pack(args: argparse.Namespace) -> int: - manifest_path = Path(args.manifest).resolve() - out_file = Path(args.output).resolve() - packed = pack_nres_manifest(manifest_path, out_file) - print(f"NRes packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})") - return 0 - - -def cmd_rsli_unpack(args: argparse.Namespace) -> int: - archive_path = Path(args.archive).resolve() - out_dir = Path(args.output).resolve() - manifest = unpack_rsli_file(archive_path, out_dir) - print(f"RsLi unpacked: {archive_path}") - print(f"Manifest: {out_dir / 'manifest.json'}") - print(f"Entries : {len(manifest['entries'])}") - if manifest["issues"]: - print("Issues:") - for issue in manifest["issues"]: - print(f"- {issue}") - return 0 - - -def cmd_rsli_pack(args: argparse.Namespace) -> int: - manifest_path = Path(args.manifest).resolve() - out_file = Path(args.output).resolve() - packed = pack_rsli_manifest(manifest_path, out_file) - print(f"RsLi packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})") - return 0 - - -def cmd_validate(args: argparse.Namespace) -> int: - input_root = Path(args.input).resolve() - archives = scan_archives(input_root) - - temp_created = False - if args.workdir: - workdir = Path(args.workdir).resolve() - workdir.mkdir(parents=True, exist_ok=True) - else: - workdir = Path(tempfile.mkdtemp(prefix="nres-rsli-validate-")) - temp_created = True - - report: dict[str, Any] = { - "input_root": str(input_root), - "workdir": str(workdir), - "archives_total": len(archives), - "results": [], - "summary": {}, - } - - failures = 0 - try: - for idx, item in enumerate(archives): - rel = item["relative_path"] - archive_path = input_root / rel - marker = f"{idx:04d}_{safe_component(rel, fallback='archive')}" - unpack_dir = workdir / "unpacked" / marker - repacked_file = workdir / "repacked" / f"{marker}.bin" - try: - if item["type"] == "nres": - manifest = unpack_nres_file(archive_path, unpack_dir, source_root=input_root) - repacked = pack_nres_manifest(unpack_dir / "manifest.json", repacked_file) - elif item["type"] == "rsli": - manifest = unpack_rsli_file(archive_path, unpack_dir, source_root=input_root) - repacked = pack_rsli_manifest(unpack_dir / "manifest.json", repacked_file) - else: - continue - - original = archive_path.read_bytes() - match = original == repacked - diff_offset, diff_desc = first_diff(original, repacked) - issues = list(manifest.get("issues", [])) - result = { - "relative_path": rel, - "type": item["type"], - "size_original": len(original), - "size_repacked": len(repacked), - "sha256_original": sha256_hex(original), - "sha256_repacked": sha256_hex(repacked), - "match": match, - "first_diff_offset": diff_offset, - "first_diff": diff_desc, - "issues": issues, - "entries": len(manifest.get("entries", [])), - "error": None, - } - except Exception as exc: # pylint: disable=broad-except - result = { - "relative_path": rel, - "type": item["type"], - "size_original": item["size"], - "size_repacked": None, - "sha256_original": None, - "sha256_repacked": None, - "match": False, - "first_diff_offset": None, - "first_diff": None, - "issues": [f"processing error: {exc}"], - "entries": None, - "error": str(exc), - } - - report["results"].append(result) - - if not result["match"]: - failures += 1 - if result["issues"] and args.fail_on_issues: - failures += 1 - - matches = sum(1 for row in report["results"] if row["match"]) - mismatches = len(report["results"]) - matches - nres_count = sum(1 for row in report["results"] if row["type"] == "nres") - rsli_count = sum(1 for row in report["results"] if row["type"] == "rsli") - issues_total = sum(len(row["issues"]) for row in report["results"]) - report["summary"] = { - "nres_count": nres_count, - "rsli_count": rsli_count, - "matches": matches, - "mismatches": mismatches, - "issues_total": issues_total, - } - - if args.report: - dump_json(Path(args.report).resolve(), report) - - print(f"Input root : {input_root}") - print(f"Work dir : {workdir}") - print(f"NRes archives : {nres_count}") - print(f"RsLi archives : {rsli_count}") - print(f"Roundtrip match: {matches}/{len(report['results'])}") - print(f"Doc issues : {issues_total}") - - if mismatches: - print("\nMismatches:") - for row in report["results"]: - if row["match"]: - continue - print( - f"- {row['relative_path']} [{row['type']}] " - f"diff@{row['first_diff_offset']}: {row['first_diff']}" - ) - - if issues_total: - print("\nIssues:") - for row in report["results"]: - if not row["issues"]: - continue - print(f"- {row['relative_path']} [{row['type']}]") - for issue in row["issues"]: - print(f" * {issue}") - - finally: - if temp_created or args.cleanup: - shutil.rmtree(workdir, ignore_errors=True) - - if failures > 0: - return 1 - if report["summary"].get("mismatches", 0) > 0 and args.fail_on_diff: - return 1 - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="NRes/RsLi tools: scan, unpack, repack, and roundtrip validation." - ) - sub = parser.add_subparsers(dest="command", required=True) - - scan = sub.add_parser("scan", help="Scan files by header signatures.") - scan.add_argument("--input", required=True, help="Root directory to scan.") - scan.add_argument("--json", action="store_true", help="Print JSON output.") - scan.set_defaults(func=cmd_scan) - - nres_unpack = sub.add_parser("nres-unpack", help="Unpack a single NRes archive.") - nres_unpack.add_argument("--archive", required=True, help="Path to NRes file.") - nres_unpack.add_argument("--output", required=True, help="Output directory.") - nres_unpack.set_defaults(func=cmd_nres_unpack) - - nres_pack = sub.add_parser("nres-pack", help="Pack NRes archive from manifest.") - nres_pack.add_argument("--manifest", required=True, help="Path to manifest.json.") - nres_pack.add_argument("--output", required=True, help="Output file path.") - nres_pack.set_defaults(func=cmd_nres_pack) - - rsli_unpack = sub.add_parser("rsli-unpack", help="Unpack a single RsLi archive.") - rsli_unpack.add_argument("--archive", required=True, help="Path to RsLi file.") - rsli_unpack.add_argument("--output", required=True, help="Output directory.") - rsli_unpack.set_defaults(func=cmd_rsli_unpack) - - rsli_pack = sub.add_parser("rsli-pack", help="Pack RsLi archive from manifest.") - rsli_pack.add_argument("--manifest", required=True, help="Path to manifest.json.") - rsli_pack.add_argument("--output", required=True, help="Output file path.") - rsli_pack.set_defaults(func=cmd_rsli_pack) - - validate = sub.add_parser( - "validate", - help="Scan all archives and run unpack->repack->byte-compare validation.", - ) - validate.add_argument("--input", required=True, help="Root with game data files.") - validate.add_argument( - "--workdir", - help="Working directory for temporary unpack/repack files. " - "If omitted, a temporary directory is used and removed automatically.", - ) - validate.add_argument("--report", help="Optional JSON report output path.") - validate.add_argument( - "--fail-on-diff", - action="store_true", - help="Return non-zero exit code if any byte mismatch exists.", - ) - validate.add_argument( - "--fail-on-issues", - action="store_true", - help="Return non-zero exit code if any spec issue was detected.", - ) - validate.add_argument( - "--cleanup", - action="store_true", - help="Remove --workdir after completion.", - ) - validate.set_defaults(func=cmd_validate) - - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - return int(args.func(args)) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/fxid_abs100_audit.py b/tools/fxid_abs100_audit.py deleted file mode 100644 index 79f3b92..0000000 --- a/tools/fxid_abs100_audit.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python3 -""" -Deterministic audit for FXID "absolute parity" checklist. - -What this script produces: -1) strict parsing stats across all FXID payloads in NRes archives, -2) opcode histogram and rare-branch counters (op6, op1 tail usage), -3) reference vectors for RNG core (sub_10002220 semantics). -""" - -from __future__ import annotations - -import argparse -import json -import struct -from collections import Counter -from pathlib import Path -from typing import Any - -import archive_roundtrip_validator as arv - -TYPE_FXID = 0x44495846 -FX_CMD_SIZE = {1: 224, 2: 148, 3: 200, 4: 204, 5: 112, 6: 4, 7: 208, 8: 248, 9: 208, 10: 208} - - -def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: - start = int(entry["data_offset"]) - end = start + int(entry["size"]) - return blob[start:end] - - -def _cstr32(raw: bytes) -> str: - return raw.split(b"\x00", 1)[0].decode("latin1", errors="replace") - - -def _rng_step_sub_10002220(state32: int) -> tuple[int, int]: - """ - sub_10002220 semantics in 32-bit packed state form: - lo = state[15:0], hi = state[31:16] - new_lo = hi ^ (lo << 1) - new_hi = (hi >> 1) ^ new_lo - return new_hi (u16), update state=(new_hi<<16)|new_lo - """ - lo = state32 & 0xFFFF - hi = (state32 >> 16) & 0xFFFF - new_lo = (hi ^ ((lo << 1) & 0xFFFF)) & 0xFFFF - new_hi = ((hi >> 1) ^ new_lo) & 0xFFFF - return ((new_hi << 16) | new_lo), new_hi - - -def _rng_vectors() -> dict[str, Any]: - seeds = [0x00000000, 0x00000001, 0x12345678, 0x89ABCDEF, 0xFFFFFFFF] - out: list[dict[str, Any]] = [] - for seed in seeds: - state = seed - outputs: list[int] = [] - states: list[int] = [] - for _ in range(16): - state, value = _rng_step_sub_10002220(state) - outputs.append(value) - states.append(state) - out.append( - { - "seed_hex": f"0x{seed:08X}", - "outputs_u16_hex": [f"0x{x:04X}" for x in outputs], - "states_u32_hex": [f"0x{x:08X}" for x in states], - } - ) - return {"generator": "sub_10002220", "vectors": out} - - -def run_audit(root: Path) -> dict[str, Any]: - counters: Counter[str] = Counter() - opcode_hist: Counter[int] = Counter() - issues: list[dict[str, Any]] = [] - op1_tail6_samples: list[dict[str, Any]] = [] - op1_optref_samples: list[dict[str, Any]] = [] - - for item in arv.scan_archives(root): - if item["type"] != "nres": - continue - archive_path = root / item["relative_path"] - counters["archives_total"] += 1 - data = archive_path.read_bytes() - try: - parsed = arv.parse_nres(data, source=str(archive_path)) - except Exception as exc: # pylint: disable=broad-except - issues.append( - { - "severity": "error", - "archive": str(archive_path), - "entry": None, - "message": f"cannot parse NRes: {exc}", - } - ) - continue - - for entry in parsed["entries"]: - if int(entry["type_id"]) != TYPE_FXID: - continue - counters["fxid_total"] += 1 - payload = _entry_payload(data, entry) - entry_name = str(entry["name"]) - - if len(payload) < 60: - issues.append( - { - "severity": "error", - "archive": str(archive_path), - "entry": entry_name, - "message": f"payload too small: {len(payload)}", - } - ) - continue - - cmd_count = struct.unpack_from("<I", payload, 0)[0] - ptr = 0x3C - ok = True - for idx in range(cmd_count): - if ptr + 4 > len(payload): - issues.append( - { - "severity": "error", - "archive": str(archive_path), - "entry": entry_name, - "message": f"command {idx}: missing header at offset={ptr}", - } - ) - ok = False - break - - word = struct.unpack_from("<I", payload, ptr)[0] - opcode = word & 0xFF - size = FX_CMD_SIZE.get(opcode) - if size is None: - issues.append( - { - "severity": "error", - "archive": str(archive_path), - "entry": entry_name, - "message": f"command {idx}: unknown opcode={opcode} at offset={ptr}", - } - ) - ok = False - break - - if ptr + size > len(payload): - issues.append( - { - "severity": "error", - "archive": str(archive_path), - "entry": entry_name, - "message": f"command {idx}: truncated end={ptr + size}, payload={len(payload)}", - } - ) - ok = False - break - - opcode_hist[opcode] += 1 - if opcode == 6: - counters["op6_commands"] += 1 - if opcode == 1: - tail6 = payload[ptr + 136 : ptr + 160] - if any(tail6): - counters["op1_tail6_nonzero"] += 1 - if len(op1_tail6_samples) < 16: - dwords = list(struct.unpack("<6I", tail6)) - op1_tail6_samples.append( - { - "archive": str(archive_path), - "entry": entry_name, - "cmd_index": idx, - "tail6_u32_hex": [f"0x{x:08X}" for x in dwords], - } - ) - - archive_s = _cstr32(payload[ptr + 160 : ptr + 192]) - name_s = _cstr32(payload[ptr + 192 : ptr + 224]) - if archive_s or name_s: - counters["op1_optref_nonempty"] += 1 - if len(op1_optref_samples) < 16: - op1_optref_samples.append( - { - "archive": str(archive_path), - "entry": entry_name, - "cmd_index": idx, - "opt_archive": archive_s, - "opt_name": name_s, - } - ) - - ptr += size - - if ok and ptr != len(payload): - issues.append( - { - "severity": "error", - "archive": str(archive_path), - "entry": entry_name, - "message": f"tail bytes after command stream: parsed_end={ptr}, payload={len(payload)}", - } - ) - ok = False - - if ok: - counters["fxid_ok"] += 1 - - return { - "input_root": str(root), - "summary": { - "archives_total": counters["archives_total"], - "fxid_total": counters["fxid_total"], - "fxid_ok": counters["fxid_ok"], - "issues_total": len(issues), - "op6_commands": counters["op6_commands"], - "op1_tail6_nonzero": counters["op1_tail6_nonzero"], - "op1_optref_nonempty": counters["op1_optref_nonempty"], - }, - "opcode_histogram": {str(k): opcode_hist[k] for k in sorted(opcode_hist)}, - "op1_tail6_samples": op1_tail6_samples, - "op1_optref_samples": op1_optref_samples, - "rng_reference": _rng_vectors(), - "rng_states_fx_path": [ - {"state": "dword_10023688", "seed_init": "sub_10002660", "used_by": ["sub_10001720", "sub_10001A40"]}, - {"state": "dword_100238C0", "seed_init": "sub_10003A50", "used_by": ["sub_10002BE0"]}, - {"state": "dword_10024110", "seed_init": "sub_10009180", "used_by": ["sub_10008120", "sub_10007D10"]}, - {"state": "dword_10024810", "seed_init": "sub_1000D370", "used_by": ["sub_1000BF30", "sub_1000C1A0"]}, - {"state": "dword_10024A48", "seed_init": "sub_1000F420", "used_by": ["sub_1000EC50"]}, - {"state": "dword_10024C80", "seed_init": "sub_10010370", "used_by": ["sub_1000F6E0"]}, - {"state": "dword_100250F0", "seed_init": "sub_10012C70", "used_by": ["sub_10011230", "sub_100115C0"]}, - ], - "issues": issues, - } - - -def main() -> int: - parser = argparse.ArgumentParser(description="FXID absolute parity audit.") - parser.add_argument("--input", required=True, help="Root directory with game/test archives.") - parser.add_argument("--report", required=True, help="Output JSON report path.") - args = parser.parse_args() - - root = Path(args.input).resolve() - report_path = Path(args.report).resolve() - payload = run_audit(root) - report_path.parent.mkdir(parents=True, exist_ok=True) - report_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - - summary = payload["summary"] - print(f"Input root : {root}") - print(f"NRes archives : {summary['archives_total']}") - print(f"FXID payloads : {summary['fxid_ok']}/{summary['fxid_total']} valid") - print(f"Issues : {summary['issues_total']}") - print(f"Opcode6 commands : {summary['op6_commands']}") - print(f"Op1 tail6 nonzero : {summary['op1_tail6_nonzero']}") - print(f"Op1 optref non-empty : {summary['op1_optref_nonempty']}") - print(f"Report : {report_path}") - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/init_testdata.py b/tools/init_testdata.py deleted file mode 100644 index 4079cdb..0000000 --- a/tools/init_testdata.py +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env python3 -""" -Initialize test data folders by archive signatures. - -The script scans all files in --input and copies matching archives into: - --output/nres/<relative path> - --output/rsli/<relative path> -""" - -from __future__ import annotations - -import argparse -import shutil -import sys -from pathlib import Path - -MAGIC_NRES = b"NRes" -MAGIC_RSLI = b"NL\x00\x01" - - -def is_relative_to(path: Path, base: Path) -> bool: - try: - path.relative_to(base) - except ValueError: - return False - return True - - -def detect_archive_type(path: Path) -> str | None: - try: - with path.open("rb") as handle: - magic = handle.read(4) - except OSError as exc: - print(f"[warn] cannot read {path}: {exc}", file=sys.stderr) - return None - - if magic == MAGIC_NRES: - return "nres" - if magic == MAGIC_RSLI: - return "rsli" - return None - - -def scan_archives(input_root: Path, excluded_root: Path | None) -> list[tuple[Path, str]]: - found: list[tuple[Path, str]] = [] - for path in sorted(input_root.rglob("*")): - if not path.is_file(): - continue - if excluded_root and is_relative_to(path.resolve(), excluded_root): - continue - - archive_type = detect_archive_type(path) - if archive_type: - found.append((path, archive_type)) - return found - - -def confirm_overwrite(path: Path) -> str: - prompt = ( - f"File exists: {path}\n" - "Overwrite? [y]es / [n]o / [a]ll / [q]uit (default: n): " - ) - while True: - try: - answer = input(prompt).strip().lower() - except EOFError: - return "quit" - - if answer in {"", "n", "no"}: - return "no" - if answer in {"y", "yes"}: - return "yes" - if answer in {"a", "all"}: - return "all" - if answer in {"q", "quit"}: - return "quit" - print("Please answer with y, n, a, or q.") - - -def copy_archives( - archives: list[tuple[Path, str]], - input_root: Path, - output_root: Path, - force: bool, -) -> int: - copied = 0 - skipped = 0 - overwritten = 0 - overwrite_all = force - - type_counts = {"nres": 0, "rsli": 0} - for _, archive_type in archives: - type_counts[archive_type] += 1 - - print( - f"Found archives: total={len(archives)}, " - f"nres={type_counts['nres']}, rsli={type_counts['rsli']}" - ) - - for source, archive_type in archives: - rel_path = source.relative_to(input_root) - destination = output_root / archive_type / rel_path - destination.parent.mkdir(parents=True, exist_ok=True) - - if destination.exists(): - if destination.is_dir(): - print( - f"[error] destination is a directory, expected file: {destination}", - file=sys.stderr, - ) - return 2 - - if not overwrite_all: - if not sys.stdin.isatty(): - print( - "[error] destination file exists but stdin is not interactive. " - "Use --force to overwrite without prompts.", - file=sys.stderr, - ) - return 2 - - decision = confirm_overwrite(destination) - if decision == "quit": - print("Aborted by user.") - return 130 - if decision == "no": - skipped += 1 - continue - if decision == "all": - overwrite_all = True - - overwritten += 1 - - try: - shutil.copy2(source, destination) - except OSError as exc: - print(f"[error] failed to copy {source} -> {destination}: {exc}", file=sys.stderr) - return 2 - copied += 1 - - print( - f"Done: copied={copied}, overwritten={overwritten}, skipped={skipped}, " - f"output={output_root}" - ) - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Initialize test data by scanning NRes/RsLi signatures." - ) - parser.add_argument( - "--input", - required=True, - help="Input directory to scan recursively.", - ) - parser.add_argument( - "--output", - required=True, - help="Output root directory (archives go to nres/ and rsli/ subdirs).", - ) - parser.add_argument( - "--force", - action="store_true", - help="Overwrite destination files without confirmation prompts.", - ) - return parser - - -def main() -> int: - args = build_parser().parse_args() - - input_root = Path(args.input) - if not input_root.exists(): - print(f"[error] input directory does not exist: {input_root}", file=sys.stderr) - return 2 - if not input_root.is_dir(): - print(f"[error] input path is not a directory: {input_root}", file=sys.stderr) - return 2 - - output_root = Path(args.output) - if output_root.exists() and not output_root.is_dir(): - print(f"[error] output path exists and is not a directory: {output_root}", file=sys.stderr) - return 2 - - input_resolved = input_root.resolve() - output_resolved = output_root.resolve() - if input_resolved == output_resolved: - print("[error] input and output directories must be different.", file=sys.stderr) - return 2 - - excluded_root: Path | None = None - if is_relative_to(output_resolved, input_resolved): - excluded_root = output_resolved - print(f"Notice: output is inside input, skipping scan under: {excluded_root}") - - archives = scan_archives(input_root, excluded_root) - - output_root.mkdir(parents=True, exist_ok=True) - return copy_archives(archives, input_root, output_root, force=args.force) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/msh_doc_validator.py b/tools/msh_doc_validator.py deleted file mode 100644 index ff096a4..0000000 --- a/tools/msh_doc_validator.py +++ /dev/null @@ -1,1000 +0,0 @@ -#!/usr/bin/env python3 -""" -Validate assumptions from docs/specs/msh.md on real game archives. - -The tool checks three groups: -1) MSH model payloads (nested NRes in *.msh entries), -2) Texm texture payloads, -3) FXID effect payloads. -""" - -from __future__ import annotations - -import argparse -import json -import math -import struct -from collections import Counter -from pathlib import Path -from typing import Any - -import archive_roundtrip_validator as arv - -MAGIC_NRES = b"NRes" -MAGIC_PAGE = b"Page" - -TYPE_FXID = 0x44495846 -TYPE_TEXM = 0x6D786554 - -FX_CMD_SIZE = {1: 224, 2: 148, 3: 200, 4: 204, 5: 112, 6: 4, 7: 208, 8: 248, 9: 208, 10: 208} -TEXM_KNOWN_FORMATS = {0, 565, 556, 4444, 888, 8888} - - -def _add_issue( - issues: list[dict[str, Any]], - severity: str, - category: str, - archive: Path, - entry_name: str | None, - message: str, -) -> None: - issues.append( - { - "severity": severity, - "category": category, - "archive": str(archive), - "entry": entry_name, - "message": message, - } - ) - - -def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: - start = int(entry["data_offset"]) - end = start + int(entry["size"]) - return blob[start:end] - - -def _entry_by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: - by_type: dict[int, list[dict[str, Any]]] = {} - for item in entries: - by_type.setdefault(int(item["type_id"]), []).append(item) - return by_type - - -def _expect_single_resource( - by_type: dict[int, list[dict[str, Any]]], - type_id: int, - label: str, - issues: list[dict[str, Any]], - archive: Path, - model_name: str, - required: bool, -) -> dict[str, Any] | None: - rows = by_type.get(type_id, []) - if not rows: - if required: - _add_issue( - issues, - "error", - "model-resource", - archive, - model_name, - f"missing required resource type={type_id} ({label})", - ) - return None - if len(rows) > 1: - _add_issue( - issues, - "warning", - "model-resource", - archive, - model_name, - f"multiple resources type={type_id} ({label}); using first entry", - ) - return rows[0] - - -def _check_fixed_stride( - *, - entry: dict[str, Any], - stride: int, - label: str, - issues: list[dict[str, Any]], - archive: Path, - model_name: str, - enforce_attr3: bool = True, - enforce_attr2_zero: bool = True, -) -> int: - size = int(entry["size"]) - attr1 = int(entry["attr1"]) - attr2 = int(entry["attr2"]) - attr3 = int(entry["attr3"]) - - count = -1 - if size % stride != 0: - _add_issue( - issues, - "error", - "model-stride", - archive, - model_name, - f"{label}: size={size} is not divisible by stride={stride}", - ) - else: - count = size // stride - if attr1 != count: - _add_issue( - issues, - "error", - "model-attr", - archive, - model_name, - f"{label}: attr1={attr1} != size/stride={count}", - ) - if enforce_attr3 and attr3 != stride: - _add_issue( - issues, - "error", - "model-attr", - archive, - model_name, - f"{label}: attr3={attr3} != {stride}", - ) - if enforce_attr2_zero and attr2 != 0: - _add_issue( - issues, - "warning", - "model-attr", - archive, - model_name, - f"{label}: attr2={attr2} (expected 0 in known assets)", - ) - return count - - -def _validate_res10( - data: bytes, - node_count: int, - issues: list[dict[str, Any]], - archive: Path, - model_name: str, -) -> None: - off = 0 - for idx in range(node_count): - if off + 4 > len(data): - _add_issue( - issues, - "error", - "res10", - archive, - model_name, - f"record {idx}: missing u32 length (offset={off}, size={len(data)})", - ) - return - ln = struct.unpack_from("<I", data, off)[0] - off += 4 - need = ln + 1 if ln else 0 - if off + need > len(data): - _add_issue( - issues, - "error", - "res10", - archive, - model_name, - f"record {idx}: out of bounds (len={ln}, need={need}, offset={off}, size={len(data)})", - ) - return - if ln and data[off + ln] != 0: - _add_issue( - issues, - "warning", - "res10", - archive, - model_name, - f"record {idx}: missing trailing NUL at payload end", - ) - off += need - - if off != len(data): - _add_issue( - issues, - "error", - "res10", - archive, - model_name, - f"tail bytes after node records: consumed={off}, size={len(data)}", - ) - - -def _validate_model_payload( - model_blob: bytes, - archive: Path, - model_name: str, - issues: list[dict[str, Any]], - counters: Counter[str], -) -> None: - counters["models_total"] += 1 - - if model_blob[:4] != MAGIC_NRES: - _add_issue( - issues, - "error", - "model-container", - archive, - model_name, - "payload is not NRes (missing magic)", - ) - return - - try: - parsed = arv.parse_nres(model_blob, source=f"{archive}:{model_name}") - except Exception as exc: # pylint: disable=broad-except - _add_issue( - issues, - "error", - "model-container", - archive, - model_name, - f"cannot parse nested NRes: {exc}", - ) - return - - for item in parsed.get("issues", []): - _add_issue(issues, "warning", "model-container", archive, model_name, str(item)) - - entries = parsed["entries"] - by_type = _entry_by_type(entries) - - res1 = _expect_single_resource(by_type, 1, "Res1", issues, archive, model_name, True) - res2 = _expect_single_resource(by_type, 2, "Res2", issues, archive, model_name, True) - res3 = _expect_single_resource(by_type, 3, "Res3", issues, archive, model_name, True) - res4 = _expect_single_resource(by_type, 4, "Res4", issues, archive, model_name, False) - res5 = _expect_single_resource(by_type, 5, "Res5", issues, archive, model_name, False) - res6 = _expect_single_resource(by_type, 6, "Res6", issues, archive, model_name, True) - res7 = _expect_single_resource(by_type, 7, "Res7", issues, archive, model_name, False) - res8 = _expect_single_resource(by_type, 8, "Res8", issues, archive, model_name, False) - res10 = _expect_single_resource(by_type, 10, "Res10", issues, archive, model_name, False) - res13 = _expect_single_resource(by_type, 13, "Res13", issues, archive, model_name, True) - res15 = _expect_single_resource(by_type, 15, "Res15", issues, archive, model_name, False) - res16 = _expect_single_resource(by_type, 16, "Res16", issues, archive, model_name, False) - res18 = _expect_single_resource(by_type, 18, "Res18", issues, archive, model_name, False) - res19 = _expect_single_resource(by_type, 19, "Res19", issues, archive, model_name, False) - - if not (res1 and res2 and res3 and res6 and res13): - return - - # Res1 - res1_stride = int(res1["attr3"]) - if res1_stride not in (38, 24): - _add_issue( - issues, - "warning", - "res1", - archive, - model_name, - f"unexpected Res1 stride attr3={res1_stride} (known: 38 or 24)", - ) - if res1_stride <= 0: - _add_issue(issues, "error", "res1", archive, model_name, f"invalid Res1 stride={res1_stride}") - return - if int(res1["size"]) % res1_stride != 0: - _add_issue( - issues, - "error", - "res1", - archive, - model_name, - f"Res1 size={res1['size']} not divisible by stride={res1_stride}", - ) - return - node_count = int(res1["size"]) // res1_stride - if int(res1["attr1"]) != node_count: - _add_issue( - issues, - "error", - "res1", - archive, - model_name, - f"Res1 attr1={res1['attr1']} != node_count={node_count}", - ) - - # Res2 - res2_size = int(res2["size"]) - res2_attr1 = int(res2["attr1"]) - res2_attr2 = int(res2["attr2"]) - res2_attr3 = int(res2["attr3"]) - if res2_size < 0x8C: - _add_issue(issues, "error", "res2", archive, model_name, f"Res2 too small: size={res2_size}") - return - slot_bytes = res2_size - 0x8C - slot_count = -1 - if slot_bytes % 68 != 0: - _add_issue( - issues, - "error", - "res2", - archive, - model_name, - f"Res2 slot area not divisible by 68: slot_bytes={slot_bytes}", - ) - else: - slot_count = slot_bytes // 68 - if res2_attr1 != slot_count: - _add_issue( - issues, - "error", - "res2", - archive, - model_name, - f"Res2 attr1={res2_attr1} != slot_count={slot_count}", - ) - if res2_attr2 != 0: - _add_issue( - issues, - "warning", - "res2", - archive, - model_name, - f"Res2 attr2={res2_attr2} (expected 0 in known assets)", - ) - if res2_attr3 != 68: - _add_issue( - issues, - "error", - "res2", - archive, - model_name, - f"Res2 attr3={res2_attr3} != 68", - ) - - # Fixed-stride resources - vertex_count = _check_fixed_stride( - entry=res3, - stride=12, - label="Res3", - issues=issues, - archive=archive, - model_name=model_name, - ) - _ = _check_fixed_stride( - entry=res4, - stride=4, - label="Res4", - issues=issues, - archive=archive, - model_name=model_name, - ) if res4 else None - _ = _check_fixed_stride( - entry=res5, - stride=4, - label="Res5", - issues=issues, - archive=archive, - model_name=model_name, - ) if res5 else None - index_count = _check_fixed_stride( - entry=res6, - stride=2, - label="Res6", - issues=issues, - archive=archive, - model_name=model_name, - ) - tri_desc_count = _check_fixed_stride( - entry=res7, - stride=16, - label="Res7", - issues=issues, - archive=archive, - model_name=model_name, - ) if res7 else -1 - anim_key_count = _check_fixed_stride( - entry=res8, - stride=24, - label="Res8", - issues=issues, - archive=archive, - model_name=model_name, - enforce_attr3=False, # format stores attr3=4 in data set - ) if res8 else -1 - if res8 and int(res8["attr3"]) != 4: - _add_issue( - issues, - "error", - "res8", - archive, - model_name, - f"Res8 attr3={res8['attr3']} != 4", - ) - if res13: - batch_count = _check_fixed_stride( - entry=res13, - stride=20, - label="Res13", - issues=issues, - archive=archive, - model_name=model_name, - ) - else: - batch_count = -1 - if res15: - _check_fixed_stride( - entry=res15, - stride=8, - label="Res15", - issues=issues, - archive=archive, - model_name=model_name, - ) - if res16: - _check_fixed_stride( - entry=res16, - stride=8, - label="Res16", - issues=issues, - archive=archive, - model_name=model_name, - ) - if res18: - _check_fixed_stride( - entry=res18, - stride=4, - label="Res18", - issues=issues, - archive=archive, - model_name=model_name, - ) - - if res19: - anim_map_count = _check_fixed_stride( - entry=res19, - stride=2, - label="Res19", - issues=issues, - archive=archive, - model_name=model_name, - enforce_attr3=False, - enforce_attr2_zero=False, - ) - if int(res19["attr3"]) != 2: - _add_issue( - issues, - "error", - "res19", - archive, - model_name, - f"Res19 attr3={res19['attr3']} != 2", - ) - else: - anim_map_count = -1 - - # Res10 - if res10: - if int(res10["attr1"]) != int(res1["attr1"]): - _add_issue( - issues, - "error", - "res10", - archive, - model_name, - f"Res10 attr1={res10['attr1']} != Res1.attr1={res1['attr1']}", - ) - if int(res10["attr3"]) != 0: - _add_issue( - issues, - "warning", - "res10", - archive, - model_name, - f"Res10 attr3={res10['attr3']} (known assets use 0)", - ) - _validate_res10(_entry_payload(model_blob, res10), node_count, issues, archive, model_name) - - # Cross-table checks. - if vertex_count > 0 and (res4 and int(res4["size"]) // 4 != vertex_count): - _add_issue(issues, "error", "model-cross", archive, model_name, "Res4 count != Res3 count") - if vertex_count > 0 and (res5 and int(res5["size"]) // 4 != vertex_count): - _add_issue(issues, "error", "model-cross", archive, model_name, "Res5 count != Res3 count") - - indices: list[int] = [] - if index_count > 0: - res6_data = _entry_payload(model_blob, res6) - indices = list(struct.unpack_from(f"<{index_count}H", res6_data, 0)) - - if batch_count > 0: - res13_data = _entry_payload(model_blob, res13) - for batch_idx in range(batch_count): - b_off = batch_idx * 20 - ( - _batch_flags, - _mat_idx, - _unk4, - _unk6, - idx_count, - idx_start, - _unk14, - base_vertex, - ) = struct.unpack_from("<HHHHHIHI", res13_data, b_off) - end = idx_start + idx_count - if index_count > 0 and end > index_count: - _add_issue( - issues, - "error", - "res13", - archive, - model_name, - f"batch {batch_idx}: index range [{idx_start}, {end}) outside Res6 count={index_count}", - ) - continue - if idx_count % 3 != 0: - _add_issue( - issues, - "warning", - "res13", - archive, - model_name, - f"batch {batch_idx}: indexCount={idx_count} is not divisible by 3", - ) - if vertex_count > 0 and index_count > 0 and idx_count > 0: - raw_slice = indices[idx_start:end] - max_raw = max(raw_slice) - if base_vertex + max_raw >= vertex_count: - _add_issue( - issues, - "error", - "res13", - archive, - model_name, - f"batch {batch_idx}: baseVertex+maxIndex={base_vertex + max_raw} >= vertex_count={vertex_count}", - ) - - if slot_count > 0: - res2_data = _entry_payload(model_blob, res2) - for slot_idx in range(slot_count): - s_off = 0x8C + slot_idx * 68 - tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", res2_data, s_off) - if tri_desc_count > 0 and tri_start + tri_count > tri_desc_count: - _add_issue( - issues, - "error", - "res2-slot", - archive, - model_name, - f"slot {slot_idx}: tri range [{tri_start}, {tri_start + tri_count}) outside Res7 count={tri_desc_count}", - ) - if batch_count > 0 and batch_start + slot_batch_count > batch_count: - _add_issue( - issues, - "error", - "res2-slot", - archive, - model_name, - f"slot {slot_idx}: batch range [{batch_start}, {batch_start + slot_batch_count}) outside Res13 count={batch_count}", - ) - # Slot bounds are 10 float values. - for f_idx in range(10): - value = struct.unpack_from("<f", res2_data, s_off + 8 + f_idx * 4)[0] - if not math.isfinite(value): - _add_issue( - issues, - "error", - "res2-slot", - archive, - model_name, - f"slot {slot_idx}: non-finite bound float at field {f_idx}", - ) - break - - if tri_desc_count > 0: - res7_data = _entry_payload(model_blob, res7) - for tri_idx in range(tri_desc_count): - t_off = tri_idx * 16 - _flags, l0, l1, l2 = struct.unpack_from("<4H", res7_data, t_off) - for link in (l0, l1, l2): - if link != 0xFFFF and link >= tri_desc_count: - _add_issue( - issues, - "error", - "res7", - archive, - model_name, - f"tri {tri_idx}: link {link} outside tri_desc_count={tri_desc_count}", - ) - _ = struct.unpack_from("<H", res7_data, t_off + 14)[0] - - # Node-level constraints for slot matrix / animation mapping. - if res1_stride == 38: - res1_data = _entry_payload(model_blob, res1) - map_words: list[int] = [] - if anim_map_count > 0 and res19: - res19_data = _entry_payload(model_blob, res19) - map_words = list(struct.unpack_from(f"<{anim_map_count}H", res19_data, 0)) - frame_count = int(res19["attr2"]) if res19 else 0 - - for node_idx in range(node_count): - n_off = node_idx * 38 - hdr2 = struct.unpack_from("<H", res1_data, n_off + 4)[0] - hdr3 = struct.unpack_from("<H", res1_data, n_off + 6)[0] - # Slot matrix: 15 uint16 at +8. - for w_idx in range(15): - slot_idx = struct.unpack_from("<H", res1_data, n_off + 8 + w_idx * 2)[0] - if slot_idx != 0xFFFF and slot_count > 0 and slot_idx >= slot_count: - _add_issue( - issues, - "error", - "res1-slot", - archive, - model_name, - f"node {node_idx}: slotIndex[{w_idx}]={slot_idx} outside slot_count={slot_count}", - ) - - if anim_key_count > 0 and hdr3 != 0xFFFF and hdr3 >= anim_key_count: - _add_issue( - issues, - "error", - "res1-anim", - archive, - model_name, - f"node {node_idx}: fallbackKeyIndex={hdr3} outside Res8 count={anim_key_count}", - ) - if map_words and hdr2 != 0xFFFF and frame_count > 0: - end = hdr2 + frame_count - if end > len(map_words): - _add_issue( - issues, - "error", - "res19-map", - archive, - model_name, - f"node {node_idx}: map range [{hdr2}, {end}) outside Res19 count={len(map_words)}", - ) - - counters["models_ok"] += 1 - - -def _validate_texm_payload( - payload: bytes, - archive: Path, - entry_name: str, - issues: list[dict[str, Any]], - counters: Counter[str], -) -> None: - counters["texm_total"] += 1 - - if len(payload) < 32: - _add_issue( - issues, - "error", - "texm", - archive, - entry_name, - f"payload too small: {len(payload)}", - ) - return - - magic, width, height, mip_count, flags4, flags5, unk6, fmt = struct.unpack_from("<8I", payload, 0) - if magic != TYPE_TEXM: - _add_issue(issues, "error", "texm", archive, entry_name, f"magic=0x{magic:08X} != Texm") - return - if width == 0 or height == 0: - _add_issue(issues, "error", "texm", archive, entry_name, f"invalid size {width}x{height}") - return - if mip_count == 0: - _add_issue(issues, "error", "texm", archive, entry_name, "mipCount=0") - return - if fmt not in TEXM_KNOWN_FORMATS: - _add_issue( - issues, - "error", - "texm", - archive, - entry_name, - f"unknown format code {fmt}", - ) - return - if flags4 not in (0, 32): - _add_issue( - issues, - "warning", - "texm", - archive, - entry_name, - f"flags4={flags4} (known values: 0 or 32)", - ) - if flags5 not in (0, 0x04000000, 0x00800000): - _add_issue( - issues, - "warning", - "texm", - archive, - entry_name, - f"flags5=0x{flags5:08X} (known values: 0, 0x00800000, 0x04000000)", - ) - - bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4) - pix_sum = 0 - w = width - h = height - for _ in range(mip_count): - pix_sum += w * h - w = max(1, w >> 1) - h = max(1, h >> 1) - size_core = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum - if size_core > len(payload): - _add_issue( - issues, - "error", - "texm", - archive, - entry_name, - f"sizeCore={size_core} exceeds payload size={len(payload)}", - ) - return - - tail = len(payload) - size_core - if tail > 0: - off = size_core - if tail < 8: - _add_issue( - issues, - "error", - "texm", - archive, - entry_name, - f"tail too short for Page chunk: tail={tail}", - ) - return - if payload[off : off + 4] != MAGIC_PAGE: - _add_issue( - issues, - "error", - "texm", - archive, - entry_name, - f"tail is present but no Page magic at offset {off}", - ) - return - rect_count = struct.unpack_from("<I", payload, off + 4)[0] - need = 8 + rect_count * 8 - if need > tail: - _add_issue( - issues, - "error", - "texm", - archive, - entry_name, - f"Page chunk truncated: need={need}, tail={tail}", - ) - return - if need != tail: - _add_issue( - issues, - "error", - "texm", - archive, - entry_name, - f"extra bytes after Page chunk: tail={tail}, pageSize={need}", - ) - return - - _ = unk6 # carried as raw field in spec, semantics intentionally unknown. - counters["texm_ok"] += 1 - - -def _validate_fxid_payload( - payload: bytes, - archive: Path, - entry_name: str, - issues: list[dict[str, Any]], - counters: Counter[str], -) -> None: - counters["fxid_total"] += 1 - - if len(payload) < 60: - _add_issue( - issues, - "error", - "fxid", - archive, - entry_name, - f"payload too small: {len(payload)}", - ) - return - - cmd_count = struct.unpack_from("<I", payload, 0)[0] - ptr = 0x3C - for idx in range(cmd_count): - if ptr + 4 > len(payload): - _add_issue( - issues, - "error", - "fxid", - archive, - entry_name, - f"command {idx}: missing header at offset={ptr}", - ) - return - word = struct.unpack_from("<I", payload, ptr)[0] - opcode = word & 0xFF - if opcode not in FX_CMD_SIZE: - _add_issue( - issues, - "error", - "fxid", - archive, - entry_name, - f"command {idx}: unknown opcode={opcode} at offset={ptr}", - ) - return - size = FX_CMD_SIZE[opcode] - if ptr + size > len(payload): - _add_issue( - issues, - "error", - "fxid", - archive, - entry_name, - f"command {idx}: truncated, need end={ptr + size}, payload={len(payload)}", - ) - return - ptr += size - - if ptr != len(payload): - _add_issue( - issues, - "error", - "fxid", - archive, - entry_name, - f"tail bytes after command stream: parsed_end={ptr}, payload={len(payload)}", - ) - return - - counters["fxid_ok"] += 1 - - -def _scan_nres_files(root: Path) -> list[Path]: - rows = arv.scan_archives(root) - out: list[Path] = [] - for item in rows: - if item["type"] != "nres": - continue - out.append(root / item["relative_path"]) - return out - - -def run_validation(input_root: Path) -> dict[str, Any]: - archives = _scan_nres_files(input_root) - issues: list[dict[str, Any]] = [] - counters: Counter[str] = Counter() - - for archive_path in archives: - counters["archives_total"] += 1 - data = archive_path.read_bytes() - try: - parsed = arv.parse_nres(data, source=str(archive_path)) - except Exception as exc: # pylint: disable=broad-except - _add_issue(issues, "error", "archive", archive_path, None, f"cannot parse NRes: {exc}") - continue - - for item in parsed.get("issues", []): - _add_issue(issues, "warning", "archive", archive_path, None, str(item)) - - for entry in parsed["entries"]: - name = str(entry["name"]) - payload = _entry_payload(data, entry) - type_id = int(entry["type_id"]) - - if name.lower().endswith(".msh"): - _validate_model_payload(payload, archive_path, name, issues, counters) - - if type_id == TYPE_TEXM: - _validate_texm_payload(payload, archive_path, name, issues, counters) - - if type_id == TYPE_FXID: - _validate_fxid_payload(payload, archive_path, name, issues, counters) - - errors = sum(1 for row in issues if row["severity"] == "error") - warnings = sum(1 for row in issues if row["severity"] == "warning") - - return { - "input_root": str(input_root), - "summary": { - "archives_total": counters["archives_total"], - "models_total": counters["models_total"], - "models_ok": counters["models_ok"], - "texm_total": counters["texm_total"], - "texm_ok": counters["texm_ok"], - "fxid_total": counters["fxid_total"], - "fxid_ok": counters["fxid_ok"], - "errors": errors, - "warnings": warnings, - "issues_total": len(issues), - }, - "issues": issues, - } - - -def cmd_scan(args: argparse.Namespace) -> int: - root = Path(args.input).resolve() - report = run_validation(root) - summary = report["summary"] - print(f"Input root : {root}") - print(f"NRes archives : {summary['archives_total']}") - print(f"MSH models : {summary['models_total']}") - print(f"Texm textures : {summary['texm_total']}") - print(f"FXID effects : {summary['fxid_total']}") - return 0 - - -def cmd_validate(args: argparse.Namespace) -> int: - root = Path(args.input).resolve() - report = run_validation(root) - summary = report["summary"] - - if args.report: - arv.dump_json(Path(args.report).resolve(), report) - - print(f"Input root : {root}") - print(f"NRes archives : {summary['archives_total']}") - print(f"MSH models : {summary['models_ok']}/{summary['models_total']} valid") - print(f"Texm textures : {summary['texm_ok']}/{summary['texm_total']} valid") - print(f"FXID effects : {summary['fxid_ok']}/{summary['fxid_total']} valid") - print(f"Issues : {summary['issues_total']} (errors={summary['errors']}, warnings={summary['warnings']})") - - if report["issues"]: - limit = max(1, int(args.print_limit)) - print("\nSample issues:") - for item in report["issues"][:limit]: - where = item["archive"] - if item["entry"]: - where = f"{where}::{item['entry']}" - print(f"- [{item['severity']}] [{item['category']}] {where}: {item['message']}") - if len(report["issues"]) > limit: - print(f"... and {len(report['issues']) - limit} more issue(s)") - - if summary["errors"] > 0: - return 1 - if args.fail_on_warnings and summary["warnings"] > 0: - return 1 - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Validate docs/specs/msh.md assumptions on real archives." - ) - sub = parser.add_subparsers(dest="command", required=True) - - scan = sub.add_parser("scan", help="Quick scan and counts (models/textures/effects).") - scan.add_argument("--input", required=True, help="Root directory with game/test archives.") - scan.set_defaults(func=cmd_scan) - - validate = sub.add_parser("validate", help="Run full spec validation.") - validate.add_argument("--input", required=True, help="Root directory with game/test archives.") - validate.add_argument("--report", help="Optional JSON report output path.") - validate.add_argument( - "--print-limit", - type=int, - default=50, - help="How many issues to print to stdout (default: 50).", - ) - validate.add_argument( - "--fail-on-warnings", - action="store_true", - help="Return non-zero if warnings are present.", - ) - validate.set_defaults(func=cmd_validate) - - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - return int(args.func(args)) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/msh_export_obj.py b/tools/msh_export_obj.py deleted file mode 100644 index 75a9602..0000000 --- a/tools/msh_export_obj.py +++ /dev/null @@ -1,357 +0,0 @@ -#!/usr/bin/env python3 -""" -Export NGI MSH geometry to Wavefront OBJ. - -The exporter is intended for inspection/debugging and uses the same -batch/slot selection logic as msh_preview_renderer.py. -""" - -from __future__ import annotations - -import argparse -import math -import struct -from pathlib import Path -from typing import Any - -import archive_roundtrip_validator as arv - -MAGIC_NRES = b"NRes" - - -def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: - start = int(entry["data_offset"]) - end = start + int(entry["size"]) - return blob[start:end] - - -def _parse_nres(blob: bytes, source: str) -> dict[str, Any]: - if blob[:4] != MAGIC_NRES: - raise RuntimeError(f"{source}: not an NRes payload") - return arv.parse_nres(blob, source=source) - - -def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: - out: dict[int, list[dict[str, Any]]] = {} - for row in entries: - out.setdefault(int(row["type_id"]), []).append(row) - return out - - -def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]: - rows = by_type.get(type_id, []) - if not rows: - raise RuntimeError(f"missing resource type {type_id} ({label})") - return rows[0] - - -def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]: - root_blob = archive_path.read_bytes() - parsed = _parse_nres(root_blob, str(archive_path)) - - msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] - if msh_entries: - chosen: dict[str, Any] | None = None - if model_name: - model_l = model_name.lower() - for row in msh_entries: - name_l = str(row["name"]).lower() - if name_l == model_l: - chosen = row - break - if chosen is None: - for row in msh_entries: - if str(row["name"]).lower().startswith(model_l): - chosen = row - break - else: - chosen = msh_entries[0] - - if chosen is None: - names = ", ".join(str(row["name"]) for row in msh_entries[:12]) - raise RuntimeError( - f"model '{model_name}' not found in {archive_path}. Available: {names}" - ) - return _entry_payload(root_blob, chosen), str(chosen["name"]) - - by_type = _by_type(parsed["entries"]) - if all(k in by_type for k in (1, 2, 3, 6, 13)): - return root_blob, archive_path.name - - raise RuntimeError( - f"{archive_path} does not contain .msh entries and does not look like a direct model payload" - ) - - -def _extract_geometry( - model_blob: bytes, - *, - lod: int, - group: int, - max_faces: int, - all_batches: bool, -) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]: - parsed = _parse_nres(model_blob, "<model>") - by_type = _by_type(parsed["entries"]) - - res1 = _get_single(by_type, 1, "Res1") - res2 = _get_single(by_type, 2, "Res2") - res3 = _get_single(by_type, 3, "Res3") - res6 = _get_single(by_type, 6, "Res6") - res13 = _get_single(by_type, 13, "Res13") - - pos_blob = _entry_payload(model_blob, res3) - if len(pos_blob) % 12 != 0: - raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}") - vertex_count = len(pos_blob) // 12 - positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)] - - idx_blob = _entry_payload(model_blob, res6) - if len(idx_blob) % 2 != 0: - raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}") - index_count = len(idx_blob) // 2 - indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0)) - - batch_blob = _entry_payload(model_blob, res13) - if len(batch_blob) % 20 != 0: - raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}") - batch_count = len(batch_blob) // 20 - batches: list[tuple[int, int, int, int]] = [] - for i in range(batch_count): - off = i * 20 - idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0] - idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0] - base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0] - batches.append((idx_count, idx_start, base_vertex, i)) - - res2_blob = _entry_payload(model_blob, res2) - if len(res2_blob) < 0x8C: - raise RuntimeError("Res2 is too small (< 0x8C)") - slot_blob = res2_blob[0x8C:] - if len(slot_blob) % 68 != 0: - raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}") - slot_count = len(slot_blob) // 68 - slots: list[tuple[int, int, int, int]] = [] - for i in range(slot_count): - off = i * 68 - tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off) - slots.append((tri_start, tri_count, batch_start, slot_batch_count)) - - res1_blob = _entry_payload(model_blob, res1) - node_stride = int(res1["attr3"]) - node_count = int(res1["attr1"]) - node_slot_indices: list[int] = [] - if not all_batches and node_stride >= 38 and len(res1_blob) >= node_count * node_stride: - if lod < 0 or lod > 2: - raise RuntimeError(f"lod must be 0..2 (got {lod})") - if group < 0 or group > 4: - raise RuntimeError(f"group must be 0..4 (got {group})") - matrix_index = lod * 5 + group - for n in range(node_count): - off = n * node_stride + 8 + matrix_index * 2 - slot_idx = struct.unpack_from("<H", res1_blob, off)[0] - if slot_idx == 0xFFFF: - continue - if slot_idx >= slot_count: - continue - node_slot_indices.append(slot_idx) - - faces: list[tuple[int, int, int]] = [] - used_batches = 0 - used_slots = 0 - - def append_batch(batch_idx: int) -> None: - nonlocal used_batches - if batch_idx < 0 or batch_idx >= len(batches): - return - idx_count, idx_start, base_vertex, _ = batches[batch_idx] - if idx_count < 3: - return - end = idx_start + idx_count - if end > len(indices): - return - used_batches += 1 - tri_count = idx_count // 3 - for t in range(tri_count): - i0 = indices[idx_start + t * 3 + 0] + base_vertex - i1 = indices[idx_start + t * 3 + 1] + base_vertex - i2 = indices[idx_start + t * 3 + 2] + base_vertex - if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count: - continue - faces.append((i0, i1, i2)) - if len(faces) >= max_faces: - return - - if node_slot_indices: - for slot_idx in node_slot_indices: - if len(faces) >= max_faces: - break - _tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx] - used_slots += 1 - for bi in range(batch_start, batch_start + slot_batch_count): - append_batch(bi) - if len(faces) >= max_faces: - break - else: - for bi in range(batch_count): - append_batch(bi) - if len(faces) >= max_faces: - break - - if not faces: - raise RuntimeError("no faces selected for export") - - meta = { - "vertex_count": vertex_count, - "index_count": index_count, - "batch_count": batch_count, - "slot_count": slot_count, - "node_count": node_count, - "used_slots": used_slots, - "used_batches": used_batches, - "face_count": len(faces), - } - return positions, faces, meta - - -def _compute_vertex_normals( - positions: list[tuple[float, float, float]], - faces: list[tuple[int, int, int]], -) -> list[tuple[float, float, float]]: - acc = [[0.0, 0.0, 0.0] for _ in positions] - for i0, i1, i2 in faces: - p0 = positions[i0] - p1 = positions[i1] - p2 = positions[i2] - ux = p1[0] - p0[0] - uy = p1[1] - p0[1] - uz = p1[2] - p0[2] - vx = p2[0] - p0[0] - vy = p2[1] - p0[1] - vz = p2[2] - p0[2] - nx = uy * vz - uz * vy - ny = uz * vx - ux * vz - nz = ux * vy - uy * vx - acc[i0][0] += nx - acc[i0][1] += ny - acc[i0][2] += nz - acc[i1][0] += nx - acc[i1][1] += ny - acc[i1][2] += nz - acc[i2][0] += nx - acc[i2][1] += ny - acc[i2][2] += nz - - normals: list[tuple[float, float, float]] = [] - for nx, ny, nz in acc: - ln = math.sqrt(nx * nx + ny * ny + nz * nz) - if ln <= 1e-12: - normals.append((0.0, 1.0, 0.0)) - else: - normals.append((nx / ln, ny / ln, nz / ln)) - return normals - - -def _write_obj( - output_path: Path, - object_name: str, - positions: list[tuple[float, float, float]], - faces: list[tuple[int, int, int]], -) -> None: - output_path.parent.mkdir(parents=True, exist_ok=True) - normals = _compute_vertex_normals(positions, faces) - - with output_path.open("w", encoding="utf-8", newline="\n") as out: - out.write("# Exported by msh_export_obj.py\n") - out.write(f"o {object_name}\n") - for x, y, z in positions: - out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n") - for nx, ny, nz in normals: - out.write(f"vn {nx:.9g} {ny:.9g} {nz:.9g}\n") - for i0, i1, i2 in faces: - a = i0 + 1 - b = i1 + 1 - c = i2 + 1 - out.write(f"f {a}//{a} {b}//{b} {c}//{c}\n") - - -def cmd_list_models(args: argparse.Namespace) -> int: - archive_path = Path(args.archive).resolve() - blob = archive_path.read_bytes() - parsed = _parse_nres(blob, str(archive_path)) - rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] - print(f"Archive: {archive_path}") - print(f"MSH entries: {len(rows)}") - for row in rows: - print(f"- {row['name']}") - return 0 - - -def cmd_export(args: argparse.Namespace) -> int: - archive_path = Path(args.archive).resolve() - output_path = Path(args.output).resolve() - - model_blob, model_label = _pick_model_payload(archive_path, args.model) - positions, faces, meta = _extract_geometry( - model_blob, - lod=int(args.lod), - group=int(args.group), - max_faces=int(args.max_faces), - all_batches=bool(args.all_batches), - ) - obj_name = Path(model_label).stem or "msh_model" - _write_obj(output_path, obj_name, positions, faces) - - print(f"Exported model : {model_label}") - print(f"Output OBJ : {output_path}") - print(f"Object name : {obj_name}") - print( - "Geometry : " - f"vertices={meta['vertex_count']}, faces={meta['face_count']}, " - f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}" - ) - print( - "Mode : " - f"lod={args.lod}, group={args.group}, all_batches={bool(args.all_batches)}" - ) - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Export NGI MSH geometry to Wavefront OBJ." - ) - sub = parser.add_subparsers(dest="command", required=True) - - list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.") - list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).") - list_models.set_defaults(func=cmd_list_models) - - export = sub.add_parser("export", help="Export one model to OBJ.") - export.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.") - export.add_argument( - "--model", - help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.", - ) - export.add_argument("--output", required=True, help="Output .obj path.") - export.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).") - export.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).") - export.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).") - export.add_argument( - "--all-batches", - action="store_true", - help="Ignore slot matrix selection and export all batches.", - ) - export.set_defaults(func=cmd_export) - - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - return int(args.func(args)) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/msh_preview_renderer.py b/tools/msh_preview_renderer.py deleted file mode 100644 index 53b4e63..0000000 --- a/tools/msh_preview_renderer.py +++ /dev/null @@ -1,481 +0,0 @@ -#!/usr/bin/env python3 -""" -Primitive software renderer for NGI MSH models. - -Output format: binary PPM (P6), no external dependencies. -""" - -from __future__ import annotations - -import argparse -import math -import struct -from pathlib import Path -from typing import Any - -import archive_roundtrip_validator as arv - -MAGIC_NRES = b"NRes" - - -def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: - start = int(entry["data_offset"]) - end = start + int(entry["size"]) - return blob[start:end] - - -def _parse_nres(blob: bytes, source: str) -> dict[str, Any]: - if blob[:4] != MAGIC_NRES: - raise RuntimeError(f"{source}: not an NRes payload") - return arv.parse_nres(blob, source=source) - - -def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: - out: dict[int, list[dict[str, Any]]] = {} - for row in entries: - out.setdefault(int(row["type_id"]), []).append(row) - return out - - -def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]: - root_blob = archive_path.read_bytes() - parsed = _parse_nres(root_blob, str(archive_path)) - - msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] - if msh_entries: - chosen: dict[str, Any] | None = None - if model_name: - model_l = model_name.lower() - for row in msh_entries: - name_l = str(row["name"]).lower() - if name_l == model_l: - chosen = row - break - if chosen is None: - for row in msh_entries: - if str(row["name"]).lower().startswith(model_l): - chosen = row - break - else: - chosen = msh_entries[0] - - if chosen is None: - names = ", ".join(str(row["name"]) for row in msh_entries[:12]) - raise RuntimeError( - f"model '{model_name}' not found in {archive_path}. Available: {names}" - ) - return _entry_payload(root_blob, chosen), str(chosen["name"]) - - # Fallback: treat file itself as a model NRes payload. - by_type = _by_type(parsed["entries"]) - if all(k in by_type for k in (1, 2, 3, 6, 13)): - return root_blob, archive_path.name - - raise RuntimeError( - f"{archive_path} does not contain .msh entries and does not look like a direct model payload" - ) - - -def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]: - rows = by_type.get(type_id, []) - if not rows: - raise RuntimeError(f"missing resource type {type_id} ({label})") - return rows[0] - - -def _extract_geometry( - model_blob: bytes, - *, - lod: int, - group: int, - max_faces: int, -) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]: - parsed = _parse_nres(model_blob, "<model>") - by_type = _by_type(parsed["entries"]) - - res1 = _get_single(by_type, 1, "Res1") - res2 = _get_single(by_type, 2, "Res2") - res3 = _get_single(by_type, 3, "Res3") - res6 = _get_single(by_type, 6, "Res6") - res13 = _get_single(by_type, 13, "Res13") - - # Positions - pos_blob = _entry_payload(model_blob, res3) - if len(pos_blob) % 12 != 0: - raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}") - vertex_count = len(pos_blob) // 12 - positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)] - - # Indices - idx_blob = _entry_payload(model_blob, res6) - if len(idx_blob) % 2 != 0: - raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}") - index_count = len(idx_blob) // 2 - indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0)) - - # Batches - batch_blob = _entry_payload(model_blob, res13) - if len(batch_blob) % 20 != 0: - raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}") - batch_count = len(batch_blob) // 20 - batches: list[tuple[int, int, int, int]] = [] - for i in range(batch_count): - off = i * 20 - # Keep only fields used by renderer: - # indexCount, indexStart, baseVertex - idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0] - idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0] - base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0] - batches.append((idx_count, idx_start, base_vertex, i)) - - # Slots - res2_blob = _entry_payload(model_blob, res2) - if len(res2_blob) < 0x8C: - raise RuntimeError("Res2 is too small (< 0x8C)") - slot_blob = res2_blob[0x8C:] - if len(slot_blob) % 68 != 0: - raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}") - slot_count = len(slot_blob) // 68 - slots: list[tuple[int, int, int, int]] = [] - for i in range(slot_count): - off = i * 68 - tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off) - slots.append((tri_start, tri_count, batch_start, slot_batch_count)) - - # Nodes / slot matrix - res1_blob = _entry_payload(model_blob, res1) - node_stride = int(res1["attr3"]) - node_count = int(res1["attr1"]) - node_slot_indices: list[int] = [] - if node_stride >= 38 and len(res1_blob) >= node_count * node_stride: - if lod < 0 or lod > 2: - raise RuntimeError(f"lod must be 0..2 (got {lod})") - if group < 0 or group > 4: - raise RuntimeError(f"group must be 0..4 (got {group})") - matrix_index = lod * 5 + group - for n in range(node_count): - off = n * node_stride + 8 + matrix_index * 2 - slot_idx = struct.unpack_from("<H", res1_blob, off)[0] - if slot_idx == 0xFFFF: - continue - if slot_idx >= slot_count: - continue - node_slot_indices.append(slot_idx) - - # Build triangle list. - faces: list[tuple[int, int, int]] = [] - used_batches = 0 - used_slots = 0 - - def append_batch(batch_idx: int) -> None: - nonlocal used_batches - if batch_idx < 0 or batch_idx >= len(batches): - return - idx_count, idx_start, base_vertex, _ = batches[batch_idx] - if idx_count < 3: - return - end = idx_start + idx_count - if end > len(indices): - return - used_batches += 1 - tri_count = idx_count // 3 - for t in range(tri_count): - i0 = indices[idx_start + t * 3 + 0] + base_vertex - i1 = indices[idx_start + t * 3 + 1] + base_vertex - i2 = indices[idx_start + t * 3 + 2] + base_vertex - if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count: - continue - faces.append((i0, i1, i2)) - if len(faces) >= max_faces: - return - - if node_slot_indices: - for slot_idx in node_slot_indices: - if len(faces) >= max_faces: - break - _tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx] - used_slots += 1 - for bi in range(batch_start, batch_start + slot_batch_count): - append_batch(bi) - if len(faces) >= max_faces: - break - else: - # Fallback if slot matrix is unavailable: draw all batches. - for bi in range(batch_count): - append_batch(bi) - if len(faces) >= max_faces: - break - - meta = { - "vertex_count": vertex_count, - "index_count": index_count, - "batch_count": batch_count, - "slot_count": slot_count, - "node_count": node_count, - "used_slots": used_slots, - "used_batches": used_batches, - "face_count": len(faces), - } - if not faces: - raise RuntimeError("no faces selected for rendering") - return positions, faces, meta - - -def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("wb") as handle: - handle.write(f"P6\n{width} {height}\n255\n".encode("ascii")) - handle.write(rgb) - - -def _render_software( - positions: list[tuple[float, float, float]], - faces: list[tuple[int, int, int]], - *, - width: int, - height: int, - yaw_deg: float, - pitch_deg: float, - wireframe: bool, -) -> bytearray: - xs = [p[0] for p in positions] - ys = [p[1] for p in positions] - zs = [p[2] for p in positions] - cx = (min(xs) + max(xs)) * 0.5 - cy = (min(ys) + max(ys)) * 0.5 - cz = (min(zs) + max(zs)) * 0.5 - span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs)) - radius = max(span * 0.5, 1e-3) - - yaw = math.radians(yaw_deg) - pitch = math.radians(pitch_deg) - cyaw = math.cos(yaw) - syaw = math.sin(yaw) - cpitch = math.cos(pitch) - spitch = math.sin(pitch) - - camera_dist = radius * 3.2 - scale = min(width, height) * 0.95 - - # Transform all vertices once. - vx: list[float] = [] - vy: list[float] = [] - vz: list[float] = [] - sx: list[float] = [] - sy: list[float] = [] - for x, y, z in positions: - x0 = x - cx - y0 = y - cy - z0 = z - cz - x1 = cyaw * x0 + syaw * z0 - z1 = -syaw * x0 + cyaw * z0 - y2 = cpitch * y0 - spitch * z1 - z2 = spitch * y0 + cpitch * z1 + camera_dist - if z2 < 1e-3: - z2 = 1e-3 - vx.append(x1) - vy.append(y2) - vz.append(z2) - sx.append(width * 0.5 + (x1 / z2) * scale) - sy.append(height * 0.5 - (y2 / z2) * scale) - - rgb = bytearray([16, 18, 24] * (width * height)) - zbuf = [float("inf")] * (width * height) - light_dir = (0.35, 0.45, 1.0) - l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2) - light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len) - - def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float: - return (px - ax) * (by - ay) - (py - ay) * (bx - ax) - - for i0, i1, i2 in faces: - x0 = sx[i0] - y0 = sy[i0] - x1 = sx[i1] - y1 = sy[i1] - x2 = sx[i2] - y2 = sy[i2] - area = edge(x0, y0, x1, y1, x2, y2) - if area == 0.0: - continue - - # Shading from camera-space normal. - ux = vx[i1] - vx[i0] - uy = vy[i1] - vy[i0] - uz = vz[i1] - vz[i0] - wx = vx[i2] - vx[i0] - wy = vy[i2] - vy[i0] - wz = vz[i2] - vz[i0] - nx = uy * wz - uz * wy - ny = uz * wx - ux * wz - nz = ux * wy - uy * wx - n_len = math.sqrt(nx * nx + ny * ny + nz * nz) - if n_len > 0.0: - nx /= n_len - ny /= n_len - nz /= n_len - intensity = nx * light[0] + ny * light[1] + nz * light[2] - if intensity < 0.0: - intensity = 0.0 - shade = int(45 + 200 * intensity) - color = (shade, shade, min(255, shade + 18)) - - minx = int(max(0, math.floor(min(x0, x1, x2)))) - maxx = int(min(width - 1, math.ceil(max(x0, x1, x2)))) - miny = int(max(0, math.floor(min(y0, y1, y2)))) - maxy = int(min(height - 1, math.ceil(max(y0, y1, y2)))) - if minx > maxx or miny > maxy: - continue - - z0 = vz[i0] - z1 = vz[i1] - z2 = vz[i2] - - for py in range(miny, maxy + 1): - fy = py + 0.5 - row = py * width - for px in range(minx, maxx + 1): - fx = px + 0.5 - w0 = edge(x1, y1, x2, y2, fx, fy) - w1 = edge(x2, y2, x0, y0, fx, fy) - w2 = edge(x0, y0, x1, y1, fx, fy) - if area > 0: - if w0 < 0 or w1 < 0 or w2 < 0: - continue - else: - if w0 > 0 or w1 > 0 or w2 > 0: - continue - inv_area = 1.0 / area - bz0 = w0 * inv_area - bz1 = w1 * inv_area - bz2 = w2 * inv_area - depth = bz0 * z0 + bz1 * z1 + bz2 * z2 - idx = row + px - if depth >= zbuf[idx]: - continue - zbuf[idx] = depth - p = idx * 3 - rgb[p + 0] = color[0] - rgb[p + 1] = color[1] - rgb[p + 2] = color[2] - - if wireframe: - def draw_line(xa: float, ya: float, xb: float, yb: float) -> None: - x0i = int(round(xa)) - y0i = int(round(ya)) - x1i = int(round(xb)) - y1i = int(round(yb)) - dx = abs(x1i - x0i) - sx_step = 1 if x0i < x1i else -1 - dy = -abs(y1i - y0i) - sy_step = 1 if y0i < y1i else -1 - err = dx + dy - x = x0i - y = y0i - while True: - if 0 <= x < width and 0 <= y < height: - p = (y * width + x) * 3 - rgb[p + 0] = 240 - rgb[p + 1] = 245 - rgb[p + 2] = 255 - if x == x1i and y == y1i: - break - e2 = 2 * err - if e2 >= dy: - err += dy - x += sx_step - if e2 <= dx: - err += dx - y += sy_step - - for i0, i1, i2 in faces: - draw_line(sx[i0], sy[i0], sx[i1], sy[i1]) - draw_line(sx[i1], sy[i1], sx[i2], sy[i2]) - draw_line(sx[i2], sy[i2], sx[i0], sy[i0]) - - return rgb - - -def cmd_list_models(args: argparse.Namespace) -> int: - archive_path = Path(args.archive).resolve() - blob = archive_path.read_bytes() - parsed = _parse_nres(blob, str(archive_path)) - rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] - print(f"Archive: {archive_path}") - print(f"MSH entries: {len(rows)}") - for row in rows: - print(f"- {row['name']}") - return 0 - - -def cmd_render(args: argparse.Namespace) -> int: - archive_path = Path(args.archive).resolve() - output_path = Path(args.output).resolve() - - model_blob, model_label = _pick_model_payload(archive_path, args.model) - positions, faces, meta = _extract_geometry( - model_blob, - lod=int(args.lod), - group=int(args.group), - max_faces=int(args.max_faces), - ) - rgb = _render_software( - positions, - faces, - width=int(args.width), - height=int(args.height), - yaw_deg=float(args.yaw), - pitch_deg=float(args.pitch), - wireframe=bool(args.wireframe), - ) - _write_ppm(output_path, int(args.width), int(args.height), rgb) - - print(f"Rendered model: {model_label}") - print(f"Output : {output_path}") - print( - "Geometry : " - f"vertices={meta['vertex_count']}, faces={meta['face_count']}, " - f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}" - ) - print(f"Mode : lod={args.lod}, group={args.group}, wireframe={bool(args.wireframe)}") - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Primitive NGI MSH renderer (software, dependency-free)." - ) - sub = parser.add_subparsers(dest="command", required=True) - - list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.") - list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).") - list_models.set_defaults(func=cmd_list_models) - - render = sub.add_parser("render", help="Render one model to PPM image.") - render.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.") - render.add_argument( - "--model", - help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.", - ) - render.add_argument("--output", required=True, help="Output .ppm file path.") - render.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).") - render.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).") - render.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).") - render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280).") - render.add_argument("--height", type=int, default=720, help="Image height (default: 720).") - render.add_argument("--yaw", type=float, default=35.0, help="Yaw angle in degrees (default: 35).") - render.add_argument("--pitch", type=float, default=18.0, help="Pitch angle in degrees (default: 18).") - render.add_argument("--wireframe", action="store_true", help="Draw white wireframe overlay.") - render.set_defaults(func=cmd_render) - - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - return int(args.func(args)) - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/terrain_map_doc_validator.py b/tools/terrain_map_doc_validator.py deleted file mode 100644 index 63c3077..0000000 --- a/tools/terrain_map_doc_validator.py +++ /dev/null @@ -1,809 +0,0 @@ -#!/usr/bin/env python3 -""" -Validate terrain/map documentation assumptions against real game data. - -Targets: -- tmp/gamedata/DATA/MAPS/**/Land.msh -- tmp/gamedata/DATA/MAPS/**/Land.map -""" - -from __future__ import annotations - -import argparse -import json -import math -import struct -from collections import Counter, defaultdict -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import archive_roundtrip_validator as arv - -MAGIC_NRES = b"NRes" - -REQUIRED_MSH_TYPES = (1, 2, 3, 4, 5, 11, 18, 21) -OPTIONAL_MSH_TYPES = (14,) -EXPECTED_MSH_ORDER = (1, 2, 3, 4, 5, 18, 14, 11, 21) - -MSH_STRIDES = { - 1: 38, - 3: 12, - 4: 4, - 5: 4, - 11: 4, - 14: 4, - 18: 4, - 21: 28, -} - -SLOT_TABLE_OFFSET = 0x8C - - -@dataclass -class ValidationIssue: - severity: str # error | warning - category: str - resource: str - message: str - - -class TerrainMapDocValidator: - def __init__(self) -> None: - self.issues: list[ValidationIssue] = [] - self.stats: dict[str, Any] = { - "maps_total": 0, - "msh_total": 0, - "map_total": 0, - "msh_type_orders": Counter(), - "msh_attr_triplets": defaultdict(Counter), # type_id -> Counter[(a1,a2,a3)] - "msh_type11_header_words": Counter(), - "msh_type21_flags_top": Counter(), - "map_logic_flags": Counter(), - "map_class_ids": Counter(), # record +40 - "map_poly_count": Counter(), - "map_vertex_count_min": None, - "map_vertex_count_max": None, - "map_cell_dims": Counter(), - "map_reserved_u12": Counter(), - "map_reserved_u36": Counter(), - "map_reserved_u44": Counter(), - "map_area_delta_abs_max": 0.0, - "map_area_delta_rel_max": 0.0, - "map_area_rel_gt_05_count": 0, - "map_normal_len_min": None, - "map_normal_len_max": None, - "map_records_total": 0, - } - - def add_issue(self, severity: str, category: str, resource: Path, message: str) -> None: - self.issues.append( - ValidationIssue( - severity=severity, - category=category, - resource=str(resource), - message=message, - ) - ) - - def _entry_payload(self, blob: bytes, entry: dict[str, Any]) -> bytes: - start = int(entry["data_offset"]) - end = start + int(entry["size"]) - return blob[start:end] - - def _entry_by_type(self, entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: - by_type: dict[int, list[dict[str, Any]]] = {} - for item in entries: - by_type.setdefault(int(item["type_id"]), []).append(item) - return by_type - - def _expect_single_type( - self, - *, - by_type: dict[int, list[dict[str, Any]]], - type_id: int, - label: str, - resource: Path, - required: bool, - ) -> dict[str, Any] | None: - rows = by_type.get(type_id, []) - if not rows: - if required: - self.add_issue( - "error", - "msh-chunk", - resource, - f"missing required chunk type={type_id} ({label})", - ) - return None - if len(rows) > 1: - self.add_issue( - "warning", - "msh-chunk", - resource, - f"multiple chunks type={type_id} ({label}); using first", - ) - return rows[0] - - def _check_stride( - self, - *, - resource: Path, - entry: dict[str, Any], - stride: int, - label: str, - ) -> int: - size = int(entry["size"]) - attr1 = int(entry["attr1"]) - attr2 = int(entry["attr2"]) - attr3 = int(entry["attr3"]) - self.stats["msh_attr_triplets"][int(entry["type_id"])][(attr1, attr2, attr3)] += 1 - - if size % stride != 0: - self.add_issue( - "error", - "msh-stride", - resource, - f"{label}: size={size} is not divisible by stride={stride}", - ) - return -1 - - count = size // stride - if attr1 != count: - self.add_issue( - "error", - "msh-attr", - resource, - f"{label}: attr1={attr1} != size/stride={count}", - ) - if attr3 != stride: - self.add_issue( - "error", - "msh-attr", - resource, - f"{label}: attr3={attr3} != {stride}", - ) - if attr2 != 0 and int(entry["type_id"]) not in (1,): - # type 1 has non-zero attr2 in real assets, others are expected zero. - self.add_issue( - "warning", - "msh-attr", - resource, - f"{label}: attr2={attr2} (expected 0 for this chunk type)", - ) - return count - - def validate_msh(self, path: Path) -> None: - self.stats["msh_total"] += 1 - blob = path.read_bytes() - if blob[:4] != MAGIC_NRES: - self.add_issue("error", "msh-container", path, "file is not NRes") - return - - try: - parsed = arv.parse_nres(blob, source=str(path)) - except Exception as exc: # pylint: disable=broad-except - self.add_issue("error", "msh-container", path, f"failed to parse NRes: {exc}") - return - - for issue in parsed.get("issues", []): - self.add_issue("warning", "msh-nres", path, issue) - - entries = parsed["entries"] - types_order = tuple(int(item["type_id"]) for item in entries) - self.stats["msh_type_orders"][types_order] += 1 - if types_order != EXPECTED_MSH_ORDER: - self.add_issue( - "warning", - "msh-order", - path, - f"unexpected chunk order {types_order}, expected {EXPECTED_MSH_ORDER}", - ) - - by_type = self._entry_by_type(entries) - - chunks: dict[int, dict[str, Any]] = {} - for type_id in REQUIRED_MSH_TYPES: - chunk = self._expect_single_type( - by_type=by_type, - type_id=type_id, - label=f"type{type_id}", - resource=path, - required=True, - ) - if chunk: - chunks[type_id] = chunk - for type_id in OPTIONAL_MSH_TYPES: - chunk = self._expect_single_type( - by_type=by_type, - type_id=type_id, - label=f"type{type_id}", - resource=path, - required=False, - ) - if chunk: - chunks[type_id] = chunk - - for type_id, stride in MSH_STRIDES.items(): - chunk = chunks.get(type_id) - if not chunk: - continue - self._check_stride(resource=path, entry=chunk, stride=stride, label=f"type{type_id}") - - # type 2 includes 0x8C-byte header + 68-byte slot table entries. - type2 = chunks.get(2) - if type2: - size = int(type2["size"]) - attr1 = int(type2["attr1"]) - attr2 = int(type2["attr2"]) - attr3 = int(type2["attr3"]) - self.stats["msh_attr_triplets"][2][(attr1, attr2, attr3)] += 1 - if attr3 != 68: - self.add_issue( - "error", - "msh-attr", - path, - f"type2: attr3={attr3} != 68", - ) - if attr2 != 0: - self.add_issue( - "warning", - "msh-attr", - path, - f"type2: attr2={attr2} (expected 0)", - ) - if size < SLOT_TABLE_OFFSET: - self.add_issue( - "error", - "msh-size", - path, - f"type2: size={size} < header_size={SLOT_TABLE_OFFSET}", - ) - elif (size - SLOT_TABLE_OFFSET) % 68 != 0: - self.add_issue( - "error", - "msh-size", - path, - f"type2: (size - 0x8C) is not divisible by 68 (size={size})", - ) - else: - slots_by_size = (size - SLOT_TABLE_OFFSET) // 68 - if attr1 != slots_by_size: - self.add_issue( - "error", - "msh-attr", - path, - f"type2: attr1={attr1} != (size-0x8C)/68={slots_by_size}", - ) - - verts = chunks.get(3) - face = chunks.get(21) - slots = chunks.get(2) - nodes = chunks.get(1) - type11 = chunks.get(11) - - if verts and face: - vcount = int(verts["attr1"]) - face_payload = self._entry_payload(blob, face) - fcount = int(face["attr1"]) - if len(face_payload) >= 28: - for idx in range(fcount): - off = idx * 28 - if off + 28 > len(face_payload): - self.add_issue( - "error", - "msh-face", - path, - f"type21 truncated at face {idx}", - ) - break - flags = struct.unpack_from("<I", face_payload, off)[0] - self.stats["msh_type21_flags_top"][flags] += 1 - i0, i1, i2 = struct.unpack_from("<HHH", face_payload, off + 8) - for name, value in (("i0", i0), ("i1", i1), ("i2", i2)): - if value >= vcount: - self.add_issue( - "error", - "msh-face-index", - path, - f"type21[{idx}].{name}={value} out of range vertex_count={vcount}", - ) - n0, n1, n2 = struct.unpack_from("<HHH", face_payload, off + 14) - for name, value in (("n0", n0), ("n1", n1), ("n2", n2)): - if value != 0xFFFF and value >= fcount: - self.add_issue( - "error", - "msh-face-neighbour", - path, - f"type21[{idx}].{name}={value} out of range face_count={fcount}", - ) - - if slots and face: - slot_count = int(slots["attr1"]) - face_count = int(face["attr1"]) - slot_payload = self._entry_payload(blob, slots) - need = SLOT_TABLE_OFFSET + slot_count * 68 - if len(slot_payload) < need: - self.add_issue( - "error", - "msh-slot", - path, - f"type2 payload too short: size={len(slot_payload)}, need_at_least={need}", - ) - else: - if len(slot_payload) != need: - self.add_issue( - "warning", - "msh-slot", - path, - f"type2 payload has trailing bytes: size={len(slot_payload)}, expected={need}", - ) - for idx in range(slot_count): - off = SLOT_TABLE_OFFSET + idx * 68 - tri_start, tri_count = struct.unpack_from("<HH", slot_payload, off) - if tri_start + tri_count > face_count: - self.add_issue( - "error", - "msh-slot-range", - path, - f"type2 slot[{idx}] range [{tri_start}, {tri_start + tri_count}) exceeds face_count={face_count}", - ) - - if nodes and slots: - node_payload = self._entry_payload(blob, nodes) - slot_count = int(slots["attr1"]) - node_count = int(nodes["attr1"]) - for node_idx in range(node_count): - off = node_idx * 38 - if off + 38 > len(node_payload): - self.add_issue( - "error", - "msh-node", - path, - f"type1 truncated at node {node_idx}", - ) - break - for j in range(19): - slot_id = struct.unpack_from("<H", node_payload, off + j * 2)[0] - if slot_id != 0xFFFF and slot_id >= slot_count: - self.add_issue( - "error", - "msh-node-slot", - path, - f"type1 node[{node_idx}] slot[{j}]={slot_id} out of range slot_count={slot_count}", - ) - - if type11: - payload = self._entry_payload(blob, type11) - if len(payload) >= 8: - w0, w1 = struct.unpack_from("<II", payload, 0) - self.stats["msh_type11_header_words"][(w0, w1)] += 1 - else: - self.add_issue( - "error", - "msh-type11", - path, - f"type11 payload too short: {len(payload)}", - ) - - def _update_minmax(self, key_min: str, key_max: str, value: float) -> None: - if self.stats[key_min] is None or value < self.stats[key_min]: - self.stats[key_min] = value - if self.stats[key_max] is None or value > self.stats[key_max]: - self.stats[key_max] = value - - def validate_map(self, path: Path) -> None: - self.stats["map_total"] += 1 - blob = path.read_bytes() - if blob[:4] != MAGIC_NRES: - self.add_issue("error", "map-container", path, "file is not NRes") - return - - try: - parsed = arv.parse_nres(blob, source=str(path)) - except Exception as exc: # pylint: disable=broad-except - self.add_issue("error", "map-container", path, f"failed to parse NRes: {exc}") - return - - for issue in parsed.get("issues", []): - self.add_issue("warning", "map-nres", path, issue) - - entries = parsed["entries"] - if len(entries) != 1 or int(entries[0]["type_id"]) != 12: - self.add_issue( - "error", - "map-chunk", - path, - f"expected single chunk type=12, got {[int(e['type_id']) for e in entries]}", - ) - return - - entry = entries[0] - areal_count = int(entry["attr1"]) - if areal_count <= 0: - self.add_issue("error", "map-areal", path, f"invalid areal_count={areal_count}") - return - - payload = self._entry_payload(blob, entry) - ptr = 0 - records: list[dict[str, Any]] = [] - - for idx in range(areal_count): - if ptr + 56 > len(payload): - self.add_issue( - "error", - "map-record", - path, - f"truncated areal header at index={idx}, ptr={ptr}, size={len(payload)}", - ) - return - - anchor_x, anchor_y, anchor_z = struct.unpack_from("<fff", payload, ptr) - u12 = struct.unpack_from("<I", payload, ptr + 12)[0] - area_f = struct.unpack_from("<f", payload, ptr + 16)[0] - nx, ny, nz = struct.unpack_from("<fff", payload, ptr + 20) - logic_flag = struct.unpack_from("<I", payload, ptr + 32)[0] - u36 = struct.unpack_from("<I", payload, ptr + 36)[0] - class_id = struct.unpack_from("<I", payload, ptr + 40)[0] - u44 = struct.unpack_from("<I", payload, ptr + 44)[0] - vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48) - - self.stats["map_records_total"] += 1 - self.stats["map_logic_flags"][logic_flag] += 1 - self.stats["map_class_ids"][class_id] += 1 - self.stats["map_poly_count"][poly_count] += 1 - self.stats["map_reserved_u12"][u12] += 1 - self.stats["map_reserved_u36"][u36] += 1 - self.stats["map_reserved_u44"][u44] += 1 - self._update_minmax("map_vertex_count_min", "map_vertex_count_max", float(vertex_count)) - - normal_len = math.sqrt(nx * nx + ny * ny + nz * nz) - self._update_minmax("map_normal_len_min", "map_normal_len_max", normal_len) - if abs(normal_len - 1.0) > 1e-3: - self.add_issue( - "warning", - "map-normal", - path, - f"record[{idx}] normal length={normal_len:.6f} (expected ~1.0)", - ) - - vertices_off = ptr + 56 - vertices_size = 12 * vertex_count - if vertices_off + vertices_size > len(payload): - self.add_issue( - "error", - "map-vertices", - path, - f"record[{idx}] vertices out of bounds", - ) - return - - vertices: list[tuple[float, float, float]] = [] - for i in range(vertex_count): - vertices.append(struct.unpack_from("<fff", payload, vertices_off + i * 12)) - - if vertex_count >= 3: - # signed shoelace area in XY. - shoelace = 0.0 - for i in range(vertex_count): - x1, y1, _ = vertices[i] - x2, y2, _ = vertices[(i + 1) % vertex_count] - shoelace += x1 * y2 - x2 * y1 - area_xy = abs(shoelace) * 0.5 - delta = abs(area_xy - area_f) - if delta > self.stats["map_area_delta_abs_max"]: - self.stats["map_area_delta_abs_max"] = delta - rel_delta = delta / max(1.0, area_xy) - if rel_delta > self.stats["map_area_delta_rel_max"]: - self.stats["map_area_delta_rel_max"] = rel_delta - if rel_delta > 0.05: - self.stats["map_area_rel_gt_05_count"] += 1 - - links_off = vertices_off + vertices_size - link_count = vertex_count + 3 * poly_count - links_size = 8 * link_count - if links_off + links_size > len(payload): - self.add_issue( - "error", - "map-links", - path, - f"record[{idx}] link table out of bounds", - ) - return - - edge_links: list[tuple[int, int]] = [] - for i in range(vertex_count): - area_ref, edge_ref = struct.unpack_from("<ii", payload, links_off + i * 8) - edge_links.append((area_ref, edge_ref)) - - poly_links_off = links_off + 8 * vertex_count - poly_links: list[tuple[int, int]] = [] - for i in range(3 * poly_count): - area_ref, edge_ref = struct.unpack_from("<ii", payload, poly_links_off + i * 8) - poly_links.append((area_ref, edge_ref)) - - p = links_off + links_size - for poly_idx in range(poly_count): - if p + 4 > len(payload): - self.add_issue( - "error", - "map-poly", - path, - f"record[{idx}] poly header truncated at poly_idx={poly_idx}", - ) - return - n = struct.unpack_from("<I", payload, p)[0] - poly_size = 4 * (3 * n + 1) - if p + poly_size > len(payload): - self.add_issue( - "error", - "map-poly", - path, - f"record[{idx}] poly data out of bounds at poly_idx={poly_idx}", - ) - return - p += poly_size - - records.append( - { - "index": idx, - "anchor": (anchor_x, anchor_y, anchor_z), - "logic": logic_flag, - "class_id": class_id, - "vertex_count": vertex_count, - "poly_count": poly_count, - "edge_links": edge_links, - "poly_links": poly_links, - } - ) - ptr = p - - vertex_counts = [int(item["vertex_count"]) for item in records] - for rec in records: - idx = int(rec["index"]) - for link_idx, (area_ref, edge_ref) in enumerate(rec["edge_links"]): - if area_ref == -1: - if edge_ref != -1: - self.add_issue( - "warning", - "map-link", - path, - f"record[{idx}] edge_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}", - ) - continue - if area_ref < 0 or area_ref >= areal_count: - self.add_issue( - "error", - "map-link", - path, - f"record[{idx}] edge_link[{link_idx}] area_ref={area_ref} out of range", - ) - continue - dst_vcount = vertex_counts[area_ref] - if edge_ref < 0 or edge_ref >= dst_vcount: - self.add_issue( - "error", - "map-link", - path, - f"record[{idx}] edge_link[{link_idx}] edge_ref={edge_ref} out of range dst_vertex_count={dst_vcount}", - ) - - for link_idx, (area_ref, edge_ref) in enumerate(rec["poly_links"]): - if area_ref == -1: - if edge_ref != -1: - self.add_issue( - "warning", - "map-poly-link", - path, - f"record[{idx}] poly_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}", - ) - continue - if area_ref < 0 or area_ref >= areal_count: - self.add_issue( - "error", - "map-poly-link", - path, - f"record[{idx}] poly_link[{link_idx}] area_ref={area_ref} out of range", - ) - - if ptr + 8 > len(payload): - self.add_issue( - "error", - "map-cells", - path, - f"missing cells header at ptr={ptr}, size={len(payload)}", - ) - return - - cells_x, cells_y = struct.unpack_from("<II", payload, ptr) - self.stats["map_cell_dims"][(cells_x, cells_y)] += 1 - ptr += 8 - if cells_x <= 0 or cells_y <= 0: - self.add_issue( - "error", - "map-cells", - path, - f"invalid cells dimensions {cells_x}x{cells_y}", - ) - return - - for x in range(cells_x): - for y in range(cells_y): - if ptr + 2 > len(payload): - self.add_issue( - "error", - "map-cells", - path, - f"truncated hitCount at cell ({x},{y})", - ) - return - hit_count = struct.unpack_from("<H", payload, ptr)[0] - ptr += 2 - need = 2 * hit_count - if ptr + need > len(payload): - self.add_issue( - "error", - "map-cells", - path, - f"truncated areaIds at cell ({x},{y}), hitCount={hit_count}", - ) - return - for i in range(hit_count): - area_id = struct.unpack_from("<H", payload, ptr + 2 * i)[0] - if area_id >= areal_count: - self.add_issue( - "error", - "map-cells", - path, - f"cell ({x},{y}) has area_id={area_id} out of range areal_count={areal_count}", - ) - ptr += need - - if ptr != len(payload): - self.add_issue( - "error", - "map-size", - path, - f"payload tail mismatch: consumed={ptr}, payload_size={len(payload)}", - ) - - def validate(self, maps_root: Path) -> None: - msh_paths = sorted(maps_root.rglob("Land.msh")) - map_paths = sorted(maps_root.rglob("Land.map")) - - msh_by_dir = {path.parent: path for path in msh_paths} - map_by_dir = {path.parent: path for path in map_paths} - - all_dirs = sorted(set(msh_by_dir) | set(map_by_dir)) - self.stats["maps_total"] = len(all_dirs) - - for folder in all_dirs: - msh_path = msh_by_dir.get(folder) - map_path = map_by_dir.get(folder) - if msh_path is None: - self.add_issue("error", "pairing", folder, "missing Land.msh") - continue - if map_path is None: - self.add_issue("error", "pairing", folder, "missing Land.map") - continue - self.validate_msh(msh_path) - self.validate_map(map_path) - - def build_report(self) -> dict[str, Any]: - errors = [i for i in self.issues if i.severity == "error"] - warnings = [i for i in self.issues if i.severity == "warning"] - - # Convert counters/defaultdicts to JSON-friendly dicts. - msh_orders = { - str(list(order)): count - for order, count in self.stats["msh_type_orders"].most_common() - } - msh_attrs = { - str(type_id): {str(list(k)): v for k, v in counter.most_common()} - for type_id, counter in self.stats["msh_attr_triplets"].items() - } - type11_hdr = { - str(list(key)): value - for key, value in self.stats["msh_type11_header_words"].most_common() - } - type21_flags = { - f"0x{key:08X}": value - for key, value in self.stats["msh_type21_flags_top"].most_common(32) - } - - return { - "summary": { - "maps_total": self.stats["maps_total"], - "msh_total": self.stats["msh_total"], - "map_total": self.stats["map_total"], - "issues_total": len(self.issues), - "errors_total": len(errors), - "warnings_total": len(warnings), - }, - "stats": { - "msh_type_orders": msh_orders, - "msh_attr_triplets": msh_attrs, - "msh_type11_header_words": type11_hdr, - "msh_type21_flags_top": type21_flags, - "map_logic_flags": dict(self.stats["map_logic_flags"]), - "map_class_ids": dict(self.stats["map_class_ids"]), - "map_poly_count": dict(self.stats["map_poly_count"]), - "map_vertex_count_min": self.stats["map_vertex_count_min"], - "map_vertex_count_max": self.stats["map_vertex_count_max"], - "map_cell_dims": {str(list(k)): v for k, v in self.stats["map_cell_dims"].items()}, - "map_reserved_u12": dict(self.stats["map_reserved_u12"]), - "map_reserved_u36": dict(self.stats["map_reserved_u36"]), - "map_reserved_u44": dict(self.stats["map_reserved_u44"]), - "map_area_delta_abs_max": self.stats["map_area_delta_abs_max"], - "map_area_delta_rel_max": self.stats["map_area_delta_rel_max"], - "map_area_rel_gt_05_count": self.stats["map_area_rel_gt_05_count"], - "map_normal_len_min": self.stats["map_normal_len_min"], - "map_normal_len_max": self.stats["map_normal_len_max"], - "map_records_total": self.stats["map_records_total"], - }, - "issues": [ - { - "severity": item.severity, - "category": item.category, - "resource": item.resource, - "message": item.message, - } - for item in self.issues - ], - } - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Validate terrain/map doc assumptions") - parser.add_argument( - "--maps-root", - type=Path, - default=Path("tmp/gamedata/DATA/MAPS"), - help="Root directory containing MAPS/**/Land.msh and Land.map", - ) - parser.add_argument( - "--report-json", - type=Path, - default=None, - help="Optional path to save full JSON report", - ) - parser.add_argument( - "--fail-on-warning", - action="store_true", - help="Return non-zero exit code on warnings too", - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - validator = TerrainMapDocValidator() - validator.validate(args.maps_root) - report = validator.build_report() - - print( - json.dumps( - report["summary"], - indent=2, - ensure_ascii=False, - ) - ) - - if args.report_json: - args.report_json.parent.mkdir(parents=True, exist_ok=True) - with args.report_json.open("w", encoding="utf-8") as handle: - json.dump(report, handle, indent=2, ensure_ascii=False) - handle.write("\n") - print(f"report written: {args.report_json}") - - has_errors = report["summary"]["errors_total"] > 0 - has_warnings = report["summary"]["warnings_total"] > 0 - if has_errors: - return 1 - if args.fail_on_warning and has_warnings: - return 1 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/tools/terrain_map_preview_renderer.py b/tools/terrain_map_preview_renderer.py deleted file mode 100644 index 86d72d7..0000000 --- a/tools/terrain_map_preview_renderer.py +++ /dev/null @@ -1,679 +0,0 @@ -#!/usr/bin/env python3 -""" -Software 3D renderer for terrain Land.msh + Land.map overlay. - -Output format: binary PPM (P6), dependency-free. -""" - -from __future__ import annotations - -import argparse -import math -import struct -from pathlib import Path -from typing import Any - -import archive_roundtrip_validator as arv - -MAGIC_NRES = b"NRes" - - -def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: - start = int(entry["data_offset"]) - end = start + int(entry["size"]) - return blob[start:end] - - -def _parse_nres(blob: bytes, source: str) -> dict[str, Any]: - if blob[:4] != MAGIC_NRES: - raise RuntimeError(f"{source}: not an NRes payload") - return arv.parse_nres(blob, source=source) - - -def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: - out: dict[int, list[dict[str, Any]]] = {} - for row in entries: - out.setdefault(int(row["type_id"]), []).append(row) - return out - - -def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]: - rows = by_type.get(type_id, []) - if not rows: - raise RuntimeError(f"missing resource type {type_id} ({label})") - return rows[0] - - -def _downsample_faces( - faces: list[tuple[int, int, int]], - max_faces: int, -) -> list[tuple[int, int, int]]: - if max_faces <= 0 or len(faces) <= max_faces: - return faces - step = len(faces) / max_faces - out: list[tuple[int, int, int]] = [] - pos = 0.0 - while len(out) < max_faces and int(pos) < len(faces): - out.append(faces[int(pos)]) - pos += step - return out - - -def load_terrain_msh( - path: Path, - *, - max_faces: int, -) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]: - blob = path.read_bytes() - parsed = _parse_nres(blob, str(path)) - by_type = _by_type(parsed["entries"]) - - res3 = _get_single(by_type, 3, "positions") - res21 = _get_single(by_type, 21, "terrain faces") - - pos_blob = _entry_payload(blob, res3) - if len(pos_blob) % 12 != 0: - raise RuntimeError(f"{path}: type 3 payload size is not divisible by 12") - vertex_count = len(pos_blob) // 12 - positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)] - - face_blob = _entry_payload(blob, res21) - if len(face_blob) % 28 != 0: - raise RuntimeError(f"{path}: type 21 payload size is not divisible by 28") - all_faces: list[tuple[int, int, int]] = [] - raw_face_count = len(face_blob) // 28 - dropped = 0 - for i in range(raw_face_count): - off = i * 28 - i0, i1, i2 = struct.unpack_from("<HHH", face_blob, off + 8) - if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count: - dropped += 1 - continue - all_faces.append((i0, i1, i2)) - - faces = _downsample_faces(all_faces, max_faces) - meta = { - "vertex_count": vertex_count, - "face_count_raw": raw_face_count, - "face_count_valid": len(all_faces), - "face_count_rendered": len(faces), - "face_dropped_invalid": dropped, - } - return positions, faces, meta - - -def load_areal_map(path: Path) -> tuple[list[dict[str, Any]], dict[str, int]]: - blob = path.read_bytes() - parsed = _parse_nres(blob, str(path)) - by_type = _by_type(parsed["entries"]) - chunk = _get_single(by_type, 12, "ArealMapGeometry") - - payload = _entry_payload(blob, chunk) - areal_count = int(chunk["attr1"]) - ptr = 0 - areals: list[dict[str, Any]] = [] - for idx in range(areal_count): - if ptr + 56 > len(payload): - raise RuntimeError(f"{path}: truncated areal header at index={idx}") - class_id = struct.unpack_from("<I", payload, ptr + 40)[0] - vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48) - verts_off = ptr + 56 - verts_size = 12 * vertex_count - if verts_off + verts_size > len(payload): - raise RuntimeError(f"{path}: areal[{idx}] vertices out of bounds") - verts = [struct.unpack_from("<3f", payload, verts_off + 12 * i) for i in range(vertex_count)] - - links_off = verts_off + verts_size - links_size = 8 * (vertex_count + 3 * poly_count) - p = links_off + links_size - for _ in range(poly_count): - if p + 4 > len(payload): - raise RuntimeError(f"{path}: areal[{idx}] poly header out of bounds") - n = struct.unpack_from("<I", payload, p)[0] - p += 4 * (3 * n + 1) - if p > len(payload): - raise RuntimeError(f"{path}: areal[{idx}] poly data out of bounds") - - areals.append( - { - "index": idx, - "class_id": class_id, - "vertices": verts, - } - ) - ptr = p - - if ptr + 8 > len(payload): - raise RuntimeError(f"{path}: missing cells section") - cells_x, cells_y = struct.unpack_from("<II", payload, ptr) - ptr += 8 - for _x in range(cells_x): - for _y in range(cells_y): - if ptr + 2 > len(payload): - raise RuntimeError(f"{path}: cells section truncated") - hit_count = struct.unpack_from("<H", payload, ptr)[0] - ptr += 2 + 2 * hit_count - if ptr > len(payload): - raise RuntimeError(f"{path}: cells section out of bounds") - if ptr != len(payload): - raise RuntimeError(f"{path}: trailing bytes in chunk12 parse ({len(payload) - ptr})") - - meta = { - "areal_count": areal_count, - "cells_x": cells_x, - "cells_y": cells_y, - } - return areals, meta - - -def _color_for_class(class_id: int) -> tuple[int, int, int]: - x = (class_id * 1103515245 + 12345) & 0x7FFFFFFF - r = 60 + (x & 0x7F) - g = 60 + ((x >> 7) & 0x7F) - b = 60 + ((x >> 14) & 0x7F) - return r, g, b - - -def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("wb") as handle: - handle.write(f"P6\n{width} {height}\n255\n".encode("ascii")) - handle.write(rgb) - - -def _write_obj( - path: Path, - terrain_positions: list[tuple[float, float, float]], - terrain_faces: list[tuple[int, int, int]], - areals: list[dict[str, Any]], - *, - include_areals: bool, -) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8", newline="\n") as out: - out.write("# Exported by terrain_map_preview_renderer.py\n") - out.write("o terrain\n") - for x, y, z in terrain_positions: - out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n") - for i0, i1, i2 in terrain_faces: - # OBJ indices are 1-based. - out.write(f"f {i0 + 1} {i1 + 1} {i2 + 1}\n") - - if include_areals and areals: - base = len(terrain_positions) - area_vertex_counts: list[int] = [] - out.write("o areal_edges\n") - for area in areals: - verts = area["vertices"] - area_vertex_counts.append(len(verts)) - for x, y, z in verts: - out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n") - - ptr = base - for area_idx, area in enumerate(areals): - cnt = area_vertex_counts[area_idx] - if cnt < 2: - ptr += cnt - continue - # closed polyline. - line = [str(ptr + i + 1) for i in range(cnt)] - line.append(str(ptr + 1)) - out.write("l " + " ".join(line) + "\n") - ptr += cnt - - -def _render_scene( - terrain_positions: list[tuple[float, float, float]], - terrain_faces: list[tuple[int, int, int]], - areals: list[dict[str, Any]], - *, - width: int, - height: int, - yaw_deg: float, - pitch_deg: float, - wireframe: bool, - areal_overlay: bool, -) -> bytearray: - all_positions = list(terrain_positions) - if areal_overlay: - for area in areals: - all_positions.extend(area["vertices"]) - if not all_positions: - raise RuntimeError("scene is empty") - - xs = [p[0] for p in all_positions] - ys = [p[1] for p in all_positions] - zs = [p[2] for p in all_positions] - cx = (min(xs) + max(xs)) * 0.5 - cy = (min(ys) + max(ys)) * 0.5 - cz = (min(zs) + max(zs)) * 0.5 - span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs)) - radius = max(span * 0.5, 1e-3) - - yaw = math.radians(yaw_deg) - pitch = math.radians(pitch_deg) - cyaw = math.cos(yaw) - syaw = math.sin(yaw) - cpitch = math.cos(pitch) - spitch = math.sin(pitch) - camera_dist = radius * 3.2 - scale = min(width, height) * 0.96 - - # Terrain transform cache. - vx: list[float] = [] - vy: list[float] = [] - vz: list[float] = [] - sx: list[float] = [] - sy: list[float] = [] - for x, y, z in terrain_positions: - x0 = x - cx - y0 = y - cy - z0 = z - cz - x1 = cyaw * x0 + syaw * z0 - z1 = -syaw * x0 + cyaw * z0 - y2 = cpitch * y0 - spitch * z1 - z2 = spitch * y0 + cpitch * z1 + camera_dist - if z2 < 1e-3: - z2 = 1e-3 - vx.append(x1) - vy.append(y2) - vz.append(z2) - sx.append(width * 0.5 + (x1 / z2) * scale) - sy.append(height * 0.5 - (y2 / z2) * scale) - - def project_point(x: float, y: float, z: float) -> tuple[float, float, float]: - x0 = x - cx - y0 = y - cy - z0 = z - cz - x1 = cyaw * x0 + syaw * z0 - z1 = -syaw * x0 + cyaw * z0 - y2 = cpitch * y0 - spitch * z1 - z2 = spitch * y0 + cpitch * z1 + camera_dist - if z2 < 1e-3: - z2 = 1e-3 - px = width * 0.5 + (x1 / z2) * scale - py = height * 0.5 - (y2 / z2) * scale - return px, py, z2 - - rgb = bytearray([14, 16, 20] * (width * height)) - zbuf = [float("inf")] * (width * height) - light_dir = (0.35, 0.45, 1.0) - l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2) - light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len) - - def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float: - return (px - ax) * (by - ay) - (py - ay) * (bx - ax) - - for i0, i1, i2 in terrain_faces: - x0 = sx[i0] - y0 = sy[i0] - x1 = sx[i1] - y1 = sy[i1] - x2 = sx[i2] - y2 = sy[i2] - area = edge(x0, y0, x1, y1, x2, y2) - if area == 0.0: - continue - - ux = vx[i1] - vx[i0] - uy = vy[i1] - vy[i0] - uz = vz[i1] - vz[i0] - wx = vx[i2] - vx[i0] - wy = vy[i2] - vy[i0] - wz = vz[i2] - vz[i0] - nx = uy * wz - uz * wy - ny = uz * wx - ux * wz - nz = ux * wy - uy * wx - n_len = math.sqrt(nx * nx + ny * ny + nz * nz) - if n_len > 0.0: - nx /= n_len - ny /= n_len - nz /= n_len - intensity = nx * light[0] + ny * light[1] + nz * light[2] - if intensity < 0.0: - intensity = 0.0 - shade = int(45 + 185 * intensity) - color = (min(255, shade + 6), min(255, shade + 14), min(255, shade + 28)) - - minx = int(max(0, math.floor(min(x0, x1, x2)))) - maxx = int(min(width - 1, math.ceil(max(x0, x1, x2)))) - miny = int(max(0, math.floor(min(y0, y1, y2)))) - maxy = int(min(height - 1, math.ceil(max(y0, y1, y2)))) - if minx > maxx or miny > maxy: - continue - - z0 = vz[i0] - z1 = vz[i1] - z2 = vz[i2] - inv_area = 1.0 / area - for py in range(miny, maxy + 1): - fy = py + 0.5 - row = py * width - for px in range(minx, maxx + 1): - fx = px + 0.5 - w0 = edge(x1, y1, x2, y2, fx, fy) - w1 = edge(x2, y2, x0, y0, fx, fy) - w2 = edge(x0, y0, x1, y1, fx, fy) - if area > 0: - if w0 < 0 or w1 < 0 or w2 < 0: - continue - else: - if w0 > 0 or w1 > 0 or w2 > 0: - continue - bz0 = w0 * inv_area - bz1 = w1 * inv_area - bz2 = w2 * inv_area - depth = bz0 * z0 + bz1 * z1 + bz2 * z2 - idx = row + px - if depth >= zbuf[idx]: - continue - zbuf[idx] = depth - p = idx * 3 - rgb[p + 0] = color[0] - rgb[p + 1] = color[1] - rgb[p + 2] = color[2] - - def draw_line( - xa: float, - ya: float, - xb: float, - yb: float, - color: tuple[int, int, int], - ) -> None: - x0i = int(round(xa)) - y0i = int(round(ya)) - x1i = int(round(xb)) - y1i = int(round(yb)) - dx = abs(x1i - x0i) - sx_step = 1 if x0i < x1i else -1 - dy = -abs(y1i - y0i) - sy_step = 1 if y0i < y1i else -1 - err = dx + dy - x = x0i - y = y0i - while True: - if 0 <= x < width and 0 <= y < height: - p = (y * width + x) * 3 - rgb[p + 0] = color[0] - rgb[p + 1] = color[1] - rgb[p + 2] = color[2] - if x == x1i and y == y1i: - break - e2 = 2 * err - if e2 >= dy: - err += dy - x += sx_step - if e2 <= dx: - err += dx - y += sy_step - - if wireframe: - wf = (225, 232, 246) - for i0, i1, i2 in terrain_faces: - draw_line(sx[i0], sy[i0], sx[i1], sy[i1], wf) - draw_line(sx[i1], sy[i1], sx[i2], sy[i2], wf) - draw_line(sx[i2], sy[i2], sx[i0], sy[i0], wf) - - if areal_overlay: - for area in areals: - verts = area["vertices"] - if len(verts) < 2: - continue - color = _color_for_class(int(area["class_id"])) - projected = [project_point(x, y, z + 0.35) for x, y, z in verts] - for i in range(len(projected)): - x0, y0, _ = projected[i] - x1, y1, _ = projected[(i + 1) % len(projected)] - draw_line(x0, y0, x1, y1, color) - - return rgb - - -def cmd_render(args: argparse.Namespace) -> int: - msh_path = Path(args.land_msh).resolve() - map_path = Path(args.land_map).resolve() if args.land_map else None - output_path = Path(args.output).resolve() - - positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces)) - areals: list[dict[str, Any]] = [] - map_meta: dict[str, int] = {"areal_count": 0, "cells_x": 0, "cells_y": 0} - if map_path: - areals, map_meta = load_areal_map(map_path) - - rgb = _render_scene( - positions, - faces, - areals, - width=int(args.width), - height=int(args.height), - yaw_deg=float(args.yaw), - pitch_deg=float(args.pitch), - wireframe=bool(args.wireframe), - areal_overlay=bool(args.overlay_areals), - ) - _write_ppm(output_path, int(args.width), int(args.height), rgb) - - print(f"Rendered terrain : {msh_path}") - if map_path: - print(f"Areal overlay : {map_path}") - print(f"Output : {output_path}") - print( - "Terrain geometry : " - f"vertices={terrain_meta['vertex_count']}, " - f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']} " - f"(raw={terrain_meta['face_count_raw']}, dropped={terrain_meta['face_dropped_invalid']})" - ) - if map_path: - print( - "Areal map : " - f"areals={map_meta['areal_count']}, cells={map_meta['cells_x']}x{map_meta['cells_y']}" - ) - return 0 - - -def cmd_export_obj(args: argparse.Namespace) -> int: - msh_path = Path(args.land_msh).resolve() - map_path = Path(args.land_map).resolve() if args.land_map else None - output_path = Path(args.output).resolve() - - positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces)) - areals: list[dict[str, Any]] = [] - if map_path and bool(args.include_areals): - areals, _ = load_areal_map(map_path) - - _write_obj( - output_path, - positions, - faces, - areals, - include_areals=bool(args.include_areals), - ) - - areal_vertices = sum(len(a["vertices"]) for a in areals) - print(f"Terrain source : {msh_path}") - if map_path: - print(f"Areal source : {map_path}") - print(f"OBJ output : {output_path}") - print( - "Terrain geometry : " - f"vertices={terrain_meta['vertex_count']}, " - f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']}" - ) - if bool(args.include_areals): - print(f"Areal edges : areals={len(areals)}, extra_vertices={areal_vertices}") - return 0 - - -def cmd_render_turntable(args: argparse.Namespace) -> int: - msh_path = Path(args.land_msh).resolve() - map_path = Path(args.land_map).resolve() if args.land_map else None - output_dir = Path(args.output_dir).resolve() - output_dir.mkdir(parents=True, exist_ok=True) - - frames = int(args.frames) - if frames <= 0: - raise RuntimeError("--frames must be > 0") - - positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces)) - areals: list[dict[str, Any]] = [] - if map_path: - areals, _ = load_areal_map(map_path) - - yaw_start = float(args.yaw_start) - yaw_end = float(args.yaw_end) - if frames == 1: - yaws = [yaw_start] - else: - step = (yaw_end - yaw_start) / (frames - 1) - yaws = [yaw_start + i * step for i in range(frames)] - - prefix = str(args.prefix) - for i, yaw in enumerate(yaws): - rgb = _render_scene( - positions, - faces, - areals, - width=int(args.width), - height=int(args.height), - yaw_deg=yaw, - pitch_deg=float(args.pitch), - wireframe=bool(args.wireframe), - areal_overlay=bool(args.overlay_areals), - ) - out = output_dir / f"{prefix}_{i:03d}.ppm" - _write_ppm(out, int(args.width), int(args.height), rgb) - - print(f"Turntable source : {msh_path}") - if map_path: - print(f"Areal source : {map_path}") - print(f"Output dir : {output_dir}") - print(f"Frames : {frames} ({yaws[0]:.3f} -> {yaws[-1]:.3f} yaw)") - print( - "Terrain geometry : " - f"vertices={terrain_meta['vertex_count']}, faces={terrain_meta['face_count_rendered']}" - ) - return 0 - - -def cmd_render_batch(args: argparse.Namespace) -> int: - maps_root = Path(args.maps_root).resolve() - output_dir = Path(args.output_dir).resolve() - msh_paths = sorted(maps_root.rglob("Land.msh")) - if not msh_paths: - raise RuntimeError(f"no Land.msh files under {maps_root}") - - rendered = 0 - skipped = 0 - for msh_path in msh_paths: - map_path = msh_path.with_name("Land.map") - if not map_path.exists(): - skipped += 1 - continue - rel = msh_path.parent.relative_to(maps_root) - out = output_dir / f"{rel.as_posix().replace('/', '__')}.ppm" - cmd_render( - argparse.Namespace( - land_msh=str(msh_path), - land_map=str(map_path), - output=str(out), - max_faces=args.max_faces, - width=args.width, - height=args.height, - yaw=args.yaw, - pitch=args.pitch, - wireframe=args.wireframe, - overlay_areals=args.overlay_areals, - ) - ) - rendered += 1 - - print(f"Batch summary: rendered={rendered}, skipped_no_map={skipped}, output_dir={output_dir}") - return 0 - - -def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - description="Software 3D terrain renderer (Land.msh + optional Land.map overlay)." - ) - sub = parser.add_subparsers(dest="command", required=True) - - render = sub.add_parser("render", help="Render one terrain map to PPM.") - render.add_argument("--land-msh", required=True, help="Path to Land.msh") - render.add_argument("--land-map", help="Path to Land.map (optional)") - render.add_argument("--output", required=True, help="Output .ppm path") - render.add_argument("--max-faces", type=int, default=220000, help="Face limit (default: 220000)") - render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280)") - render.add_argument("--height", type=int, default=720, help="Image height (default: 720)") - render.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)") - render.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)") - render.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay") - render.add_argument( - "--overlay-areals", - action="store_true", - help="Draw ArealMap polygon overlay", - ) - render.set_defaults(func=cmd_render) - - export_obj = sub.add_parser("export-obj", help="Export terrain (and optional areal edges) to OBJ.") - export_obj.add_argument("--land-msh", required=True, help="Path to Land.msh") - export_obj.add_argument("--land-map", help="Path to Land.map (optional)") - export_obj.add_argument("--output", required=True, help="Output .obj path") - export_obj.add_argument("--max-faces", type=int, default=0, help="Face limit (0 = all)") - export_obj.add_argument( - "--include-areals", - action="store_true", - help="Export areal polygons as OBJ polyline object", - ) - export_obj.set_defaults(func=cmd_export_obj) - - turn = sub.add_parser("render-turntable", help="Render turntable frame sequence to PPM.") - turn.add_argument("--land-msh", required=True, help="Path to Land.msh") - turn.add_argument("--land-map", help="Path to Land.map (optional)") - turn.add_argument("--output-dir", required=True, help="Output directory for frames") - turn.add_argument("--prefix", default="frame", help="Frame filename prefix (default: frame)") - turn.add_argument("--frames", type=int, default=36, help="Frame count (default: 36)") - turn.add_argument("--yaw-start", type=float, default=0.0, help="Start yaw in degrees (default: 0)") - turn.add_argument("--yaw-end", type=float, default=360.0, help="End yaw in degrees (default: 360)") - turn.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)") - turn.add_argument("--max-faces", type=int, default=160000, help="Face limit (default: 160000)") - turn.add_argument("--width", type=int, default=960, help="Image width (default: 960)") - turn.add_argument("--height", type=int, default=540, help="Image height (default: 540)") - turn.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay") - turn.add_argument( - "--overlay-areals", - action="store_true", - help="Draw ArealMap polygon overlay", - ) - turn.set_defaults(func=cmd_render_turntable) - - batch = sub.add_parser("render-batch", help="Render all MAPS/**/Land.msh under root.") - batch.add_argument( - "--maps-root", - default="tmp/gamedata/DATA/MAPS", - help="Root directory with MAPS subfolders (default: tmp/gamedata/DATA/MAPS)", - ) - batch.add_argument("--output-dir", required=True, help="Directory for output PPM files") - batch.add_argument("--max-faces", type=int, default=90000, help="Face limit per map (default: 90000)") - batch.add_argument("--width", type=int, default=960, help="Image width (default: 960)") - batch.add_argument("--height", type=int, default=540, help="Image height (default: 540)") - batch.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)") - batch.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)") - batch.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay") - batch.add_argument( - "--overlay-areals", - action="store_true", - help="Draw ArealMap polygon overlay", - ) - batch.set_defaults(func=cmd_render_batch) - - return parser - - -def main() -> int: - parser = build_parser() - args = parser.parse_args() - return int(args.func(args)) - - -if __name__ == "__main__": - raise SystemExit(main()) |
