diff options
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 160 |
1 files changed, 151 insertions, 9 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 5ab7dfd..13a880f 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -34,7 +34,7 @@ use fparkan_prototype::{ UnitComponentRecord, }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; -use fparkan_script::ScriptPackage; +use fparkan_script::{ScriptPackage, VarSet}; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; use fparkan_world::{ @@ -48,6 +48,7 @@ use std::sync::Arc; pub use fparkan_assets::MissionAssets; const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256; +const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var"; /// Engine mode. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -251,12 +252,33 @@ pub struct MissionScriptBundle { pub package: ScriptPackage, } +/// Numeric script defaults selected for the mission's first script bundle. +/// +/// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared +/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source +/// without claiming that the not-yet-recovered VM executes its declarations. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionScriptVarSet { + /// Resolved `.var` path selected by the original fallback order. + pub path: String, + /// Whether the shared `MISSIONS/SCRIPTS/varset.var` fallback was used. + pub used_fallback: bool, + /// Typed numeric declaration defaults in source order. + pub declarations: VarSet, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] struct MissionLoadOptions { fail_after_registered_objects: Option<usize>, asset_scope: MissionAssetScope, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct LoadedMissionScripts { + bundles: Vec<MissionScriptBundle>, + varset: Option<MissionScriptVarSet>, +} + /// Selects how much of the resolved mission asset graph to prepare. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum MissionAssetScope { @@ -342,6 +364,8 @@ pub struct LoadedMission { pub script_bundle_count: usize, /// Total named events in loaded script packages. pub script_event_count: usize, + /// Numeric script defaults loaded through the original bundle/fallback rule. + pub script_varset_declaration_count: usize, } /// Frame result. @@ -379,6 +403,7 @@ struct LoadedMissionState { asset_plan: MissionAssetPlan, object_drafts: Vec<MissionObjectDraft>, script_bundles: Vec<MissionScriptBundle>, + script_varset: Option<MissionScriptVarSet>, } /// Engine error. @@ -409,6 +434,13 @@ pub enum EngineError { /// Decode diagnostic. message: String, }, + /// Script defaults could not be decoded. + VarSet { + /// Script-default path. + path: String, + /// Parse diagnostic. + message: String, + }, /// `NRes` decode error. Nres { /// Resource path. @@ -544,6 +576,7 @@ impl std::fmt::Display for EngineError { } Self::Vfs { path, source } => write!(f, "{path}: {source}"), Self::Script { path, message } => write!(f, "{path}: script decode failed: {message}"), + Self::VarSet { path, message } => write!(f, "{path}: varset 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}"), @@ -585,6 +618,7 @@ impl std::error::Error for EngineError { Self::AssetPreparation { source, .. } => Some(source), Self::MissingVfs | Self::Script { .. } + | Self::VarSet { .. } | Self::Movement(_) | Self::PrototypeGraph { .. } | Self::SchedulerPhaseOrder { .. } @@ -803,7 +837,7 @@ fn load_mission_with_options_and_progress( source, } })?; - let script_bundles = load_mission_script_bundles(&vfs, &mission)?; + let loaded_scripts = load_mission_scripts(&vfs, &mission)?; trace.transforms = mission .objects .iter() @@ -1020,11 +1054,16 @@ 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 + script_bundle_count: loaded_scripts.bundles.len(), + script_event_count: loaded_scripts + .bundles .iter() .map(|bundle| bundle.package.events.len()) .sum(), + script_varset_declaration_count: loaded_scripts + .varset + .as_ref() + .map_or(0, |varset| varset.declarations.declarations.len()), }; engine.world = new_runtime_world; @@ -1038,7 +1077,8 @@ fn load_mission_with_options_and_progress( mission_assets, asset_plan: mission_asset_plan, object_drafts, - script_bundles, + script_bundles: loaded_scripts.bundles, + script_varset: loaded_scripts.varset, }); Ok((summary, trace)) } @@ -1128,6 +1168,15 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB .map(|state| state.script_bundles.as_slice()) } +/// Returns the numeric script defaults selected by the original `.var` fallback rule. +#[must_use] +pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVarSet> { + engine + .loaded + .as_ref() + .and_then(|state| state.script_varset.as_ref()) +} + /// Returns terrain runtime data for the loaded mission. #[must_use] pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> { @@ -1258,12 +1307,13 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa }) } -fn load_mission_script_bundles( +fn load_mission_scripts( vfs: &Arc<dyn Vfs>, mission: &MissionDocument, -) -> Result<Vec<MissionScriptBundle>, EngineError> { +) -> Result<LoadedMissionScripts, EngineError> { let script_bases = mission_script_bundle_bases(mission); let mut bundles = Vec::with_capacity(script_bases.len()); + let mut first_base_path = None; 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") { @@ -1277,6 +1327,9 @@ fn load_mission_script_bundles( path: normalized.as_str().to_string(), message: source.to_string(), })?; + if first_base_path.is_none() { + first_base_path = Some(base_path.clone()); + } bundles.push(MissionScriptBundle { clan_index: script_base.clan_index, base_path, @@ -1284,7 +1337,47 @@ fn load_mission_script_bundles( package, }); } - Ok(bundles) + let varset = first_base_path + .as_deref() + .map(|base_path| load_script_varset(vfs, base_path)) + .transpose()?; + Ok(LoadedMissionScripts { bundles, varset }) +} + +fn load_script_varset( + vfs: &Arc<dyn Vfs>, + base_path: &str, +) -> Result<MissionScriptVarSet, EngineError> { + let base_path = base_path + .strip_suffix(".scr") + .or_else(|| base_path.strip_suffix(".SCR")) + .unwrap_or(base_path); + let primary_path = normalize_engine_path("mission script varset", &format!("{base_path}.var"))?; + let (path, bytes, used_fallback) = match vfs.read(&primary_path) { + Ok(bytes) => (primary_path, bytes, false), + Err(VfsError::NotFound(_)) => { + let fallback_path = + normalize_engine_path("fallback script varset", FALLBACK_SCRIPT_VARSET_PATH)?; + let bytes = read_vfs(vfs, &fallback_path)?; + (fallback_path, bytes, true) + } + Err(source) => { + return Err(EngineError::Vfs { + path: primary_path.as_str().to_string(), + source, + }); + } + }; + let declarations = + fparkan_script::parse_varset(&bytes).map_err(|source| EngineError::VarSet { + path: path.as_str().to_string(), + message: source.to_string(), + })?; + Ok(MissionScriptVarSet { + path: path.as_str().to_string(), + used_fallback, + declarations, + }) } fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, EngineError> { @@ -1297,7 +1390,7 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi #[cfg(test)] mod tests { use super::*; - use fparkan_vfs::{DirectoryVfs, VfsEntry, VfsMetadata}; + use fparkan_vfs::{DirectoryVfs, MemoryVfs, VfsEntry, VfsMetadata}; use std::path::{Path, PathBuf}; #[test] @@ -1347,6 +1440,55 @@ mod tests { } #[test] + fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() { + let mut personal_files = MemoryVfs::default(); + personal_files.insert( + normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.var").expect("path"), + Arc::from(&b"VAR( DWORD, personal, 7)\n"[..]), + ); + personal_files.insert( + normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"), + Arc::from(&b"VAR( DWORD, fallback, 8)\n"[..]), + ); + let personal_vfs: Arc<dyn Vfs> = Arc::new(personal_files); + let personal = + load_script_varset(&personal_vfs, "MISSIONS/SCRIPTS/custom").expect("personal varset"); + assert_eq!(personal.path, "MISSIONS/SCRIPTS/custom.var"); + assert!(!personal.used_fallback); + assert_eq!(personal.declarations.declarations[0].name, "personal"); + + let mut fallback_files = MemoryVfs::default(); + fallback_files.insert( + normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"), + Arc::from(&b"VAR( float, fallback, 0.5)\n"[..]), + ); + let fallback_vfs: Arc<dyn Vfs> = Arc::new(fallback_files); + let fallback = + load_script_varset(&fallback_vfs, "MISSIONS/SCRIPTS/custom").expect("fallback varset"); + assert_eq!(fallback.path, FALLBACK_SCRIPT_VARSET_PATH); + assert!(fallback.used_fallback); + assert_eq!(fallback.declarations.declarations[0].name, "fallback"); + } + + #[test] + fn malformed_personal_script_varset_does_not_silently_use_fallback() { + let mut files = MemoryVfs::default(); + files.insert( + normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.var").expect("path"), + Arc::from(&b"VAR( DWORD, broken, nope)\n"[..]), + ); + files.insert( + normalize_engine_path("test", FALLBACK_SCRIPT_VARSET_PATH).expect("path"), + Arc::from(&b"VAR( DWORD, fallback, 8)\n"[..]), + ); + let vfs: Arc<dyn Vfs> = Arc::new(files); + assert!(matches!( + load_script_varset(&vfs, "MISSIONS/SCRIPTS/custom"), + Err(EngineError::VarSet { path, .. }) if path == "MISSIONS/SCRIPTS/custom.var" + )); + } + + #[test] fn headless_scheduler_trace_skips_presentation_phases() { let mut engine = create( EngineConfig { |
