From 9dcf6d456e0c2e06d45be001874ac72d7911703e Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 20:56:34 +0400 Subject: feat(runtime): load mission script bundles --- crates/fparkan-assets/src/lib.rs | 36 ++++++++++++++++- crates/fparkan-runtime/Cargo.toml | 1 + crates/fparkan-runtime/src/lib.rs | 81 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 113 insertions(+), 5 deletions(-) (limited to 'crates') 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(), -- cgit v1.2.3