diff --git a/Cargo.lock b/Cargo.lock index 065741e..f95afdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -608,6 +608,7 @@ dependencies = [ "fparkan-prototype", "fparkan-render", "fparkan-resource", + "fparkan-script", "fparkan-terrain", "fparkan-vfs", "fparkan-world", diff --git a/apps/fparkan-headless/src/main.rs b/apps/fparkan-headless/src/main.rs index a31dd58..e7a1c14 100644 --- a/apps/fparkan-headless/src/main.rs +++ b/apps/fparkan-headless/src/main.rs @@ -56,7 +56,7 @@ fn run() -> Result<(), String> { let loaded = load_mission(&mut engine, MissionRequest { key: mission }) .map_err(|err| format!("{err}"))?; println!( - "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}", + "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} graph_failures={}", loaded.object_count, loaded.areal_count, loaded.surface_count, @@ -66,6 +66,8 @@ fn run() -> Result<(), String> { loaded.graph_material_resolved_count, loaded.graph_texture_resolved_count, loaded.graph_lightmap_resolved_count, + loaded.script_bundle_count, + loaded.script_event_count, loaded.graph_failure_count ); } diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 2dc9ef5..708993f 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -24,7 +24,7 @@ use fparkan_material::{ decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable, MAT0_KIND, WEAR_KIND, }; -use fparkan_mission_format::{decode_tma, decode_tma_land_path}; +use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody}; pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile}; use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError}; use fparkan_nres::{decode as decode_nres, ReadProfile}; @@ -63,6 +63,17 @@ pub struct MissionTerrainPaths { pub land_map: NormalizedPath, } +/// Raw compiled-script base selected by one TMA clan. +/// +/// The path remains raw legacy bytes until the runtime applies its path policy. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionScriptBundleBase { + /// Index in the mission clan array. + pub clan_index: usize, + /// First clan resource path, without a required `.scr` suffix. + pub path_raw: Vec, +} + /// Terrain loading errors that include runtime world construction failures. #[derive(Debug)] pub enum TerrainPreparationError { @@ -121,6 +132,29 @@ pub fn decode_mission_land_path( decode_tma_land_path(bytes, profile) } +/// Returns non-empty script/formula bases selected by TMA clans. +/// +/// The first resource has the same position in the standard and spatial clan +/// layouts. Its consumer-specific meaning is intentionally left to runtime. +#[must_use] +pub fn mission_script_bundle_bases(mission: &MissionDocument) -> Vec { + mission + .clans + .iter() + .enumerate() + .filter_map(|(clan_index, clan)| { + let path_raw = match &clan.body { + ClanBody::Standard { first_resource, .. } => first_resource.path.raw.clone(), + ClanBody::Spatial { first_resource, .. } => first_resource.raw.clone(), + }; + (!path_raw.is_empty()).then_some(MissionScriptBundleBase { + clan_index, + path_raw, + }) + }) + .collect() +} + /// Builds canonical mission terrain paths from the mission `Land` reference. /// /// # Errors diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml index 6754de1..858e5af 100644 --- a/crates/fparkan-runtime/Cargo.toml +++ b/crates/fparkan-runtime/Cargo.toml @@ -13,6 +13,7 @@ fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" } fparkan-render = { path = "../fparkan-render", version = "0.1.0" } fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" } fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" } +fparkan-script = { path = "../fparkan-script", version = "0.1.0" } fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" } fparkan-world = { path = "../fparkan-world", version = "0.1.0" } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 696bb33..5ab7dfd 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -23,10 +23,10 @@ use fparkan_assets::{ decode_mission_land_path, decode_mission_payload, decode_nres_payload, derive_mission_land_paths, extend_graph_report_with_visual_dependencies_with_progress, - prepare_terrain_world, AssetError as AssetPreparationError, AssetId, AssetManager, - AssetPreparationPhase, BuildCategory, MissionAssetPlan, MissionDocument, MissionError, - MissionTerrainPaths, NresError, PreparedVisual, TerrainFormatError, TerrainPreparationError, - TerrainWorld, TmaProfile, VisualDependencyPhase, + mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError, + AssetId, AssetManager, AssetPreparationPhase, BuildCategory, MissionAssetPlan, MissionDocument, + MissionError, MissionTerrainPaths, NresError, PreparedVisual, TerrainFormatError, + TerrainPreparationError, TerrainWorld, TmaProfile, VisualDependencyPhase, }; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_prototype::{ @@ -34,6 +34,7 @@ use fparkan_prototype::{ UnitComponentRecord, }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; +use fparkan_script::ScriptPackage; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; use fparkan_world::{ @@ -237,6 +238,19 @@ pub struct MissionObjectDraft { pub unit_components: Vec, } +/// A compiled script bundle selected by one mission clan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionScriptBundle { + /// TMA clan index selecting this bundle. + pub clan_index: usize, + /// Lossy display form of the raw bundle base path from the clan resource. + pub base_path: String, + /// Resolved compiled `.scr` path. + pub script_path: String, + /// Losslessly decoded compiled package; not yet executed by runtime. + pub package: ScriptPackage, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] struct MissionLoadOptions { fail_after_registered_objects: Option, @@ -324,6 +338,10 @@ pub struct LoadedMission { pub asset_texture_count: usize, /// Mission asset plan lightmap count after dependency preparation. pub asset_lightmap_count: usize, + /// Script bundles selected by mission clans. + pub script_bundle_count: usize, + /// Total named events in loaded script packages. + pub script_event_count: usize, } /// Frame result. @@ -360,6 +378,7 @@ struct LoadedMissionState { mission_assets: MissionAssets, asset_plan: MissionAssetPlan, object_drafts: Vec, + script_bundles: Vec, } /// Engine error. @@ -383,6 +402,13 @@ pub enum EngineError { /// Source error. source: VfsError, }, + /// Compiled script package could not be decoded. + Script { + /// Script path. + path: String, + /// Decode diagnostic. + message: String, + }, /// `NRes` decode error. Nres { /// Resource path. @@ -517,6 +543,7 @@ impl std::fmt::Display for EngineError { write!(f, "invalid {role} path '{value}': {source}") } Self::Vfs { path, source } => write!(f, "{path}: {source}"), + Self::Script { path, message } => write!(f, "{path}: script decode failed: {message}"), Self::Nres { path, source } => write!(f, "{path}: {source}"), Self::Mission { path, source } => write!(f, "{path}: {source}"), Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"), @@ -557,6 +584,7 @@ impl std::error::Error for EngineError { Self::World(source) => Some(source), Self::AssetPreparation { source, .. } => Some(source), Self::MissingVfs + | Self::Script { .. } | Self::Movement(_) | Self::PrototypeGraph { .. } | Self::SchedulerPhaseOrder { .. } @@ -775,6 +803,7 @@ fn load_mission_with_options_and_progress( source, } })?; + let script_bundles = load_mission_script_bundles(&vfs, &mission)?; trace.transforms = mission .objects .iter() @@ -991,6 +1020,11 @@ fn load_mission_with_options_and_progress( asset_material_count: mission_asset_plan.material_count, asset_texture_count: mission_asset_plan.texture_count, asset_lightmap_count: mission_asset_plan.lightmap_count, + script_bundle_count: script_bundles.len(), + script_event_count: script_bundles + .iter() + .map(|bundle| bundle.package.events.len()) + .sum(), }; engine.world = new_runtime_world; @@ -1004,6 +1038,7 @@ fn load_mission_with_options_and_progress( mission_assets, asset_plan: mission_asset_plan, object_drafts, + script_bundles, }); Ok((summary, trace)) } @@ -1084,6 +1119,15 @@ pub fn loaded_mission_document(engine: &Engine) -> Option<&MissionDocument> { engine.loaded.as_ref().map(|state| &state.mission) } +/// Returns compiled script bundles selected by TMA clan resources. +#[must_use] +pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptBundle]> { + engine + .loaded + .as_ref() + .map(|state| state.script_bundles.as_slice()) +} + /// Returns terrain runtime data for the loaded mission. #[must_use] pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> { @@ -1214,6 +1258,35 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result, + mission: &MissionDocument, +) -> Result, EngineError> { + let script_bases = mission_script_bundle_bases(mission); + let mut bundles = Vec::with_capacity(script_bases.len()); + for script_base in script_bases { + let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned(); + let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") { + base_path.clone() + } else { + format!("{base_path}.scr") + }; + let normalized = normalize_engine_path("mission script", &script_path)?; + let bytes = read_vfs(vfs, &normalized)?; + let package = fparkan_script::decode(&bytes).map_err(|source| EngineError::Script { + path: normalized.as_str().to_string(), + message: source.to_string(), + })?; + bundles.push(MissionScriptBundle { + clan_index: script_base.clan_index, + base_path, + script_path: normalized.as_str().to_string(), + package, + }); + } + Ok(bundles) +} + fn read_vfs(vfs: &Arc, path: &NormalizedPath) -> Result, EngineError> { vfs.read(path).map_err(|source| EngineError::Vfs { path: path.as_str().to_string(), diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index d5b1607..71e2793 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -73,6 +73,14 @@ raw seven slots, resolved variable values и вход/выход вызова `0 controlled mission. До него compatibility VM возвращает явный unsupported result, а не «примерный» game command. +На границе mission runtime выбранный TMA clan `first_resource` теперь +материализуется как отдельный `MissionScriptBundle`: loader нормализует +`.scr`, декодирует его тем же bounded reader-ом и публикует immutable +package вместе с clan provenance. Headless report выводит число таких packages +и их named events. Это именно wiring входных данных, не VM execution: Init и +остальные events пока не dispatch-ятся, а ошибка чтения сохраняет +transactional rollback mission loader-а. + TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state, diff --git a/docs/tomes/04-world.md b/docs/tomes/04-world.md index 9961a49..f9c0e67 100644 --- a/docs/tomes/04-world.md +++ b/docs/tomes/04-world.md @@ -135,6 +135,14 @@ repeat relation_count: TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как raw-поля, пока их потребительская семантика не закрыта. +Runtime уже использует первую строку как доказанный путь выбора пакета: к ней +добавляется `.scr`, если расширение отсутствует, и полученный compiled package +декодируется losslessly до регистрации мира. Для каждого клана сохраняются +индекс, исходный base path, нормализованный `.scr` path и пакет событий. Это +не запускает opcode и не приписывает ему gameplay-эффект, но делает сценарный +input частью транзакции загрузки: missing/invalid package отменяет mission +load до публикации частичного world state. + Mode `0` имеет отдельный count-driven layout: ```text