feat(runtime): load mission script bundles

This commit is contained in:
2026-07-18 20:56:34 +04:00
parent 3592b46b0f
commit 9dcf6d456e
7 changed files with 133 additions and 6 deletions
Generated
+1
View File
@@ -608,6 +608,7 @@ dependencies = [
"fparkan-prototype", "fparkan-prototype",
"fparkan-render", "fparkan-render",
"fparkan-resource", "fparkan-resource",
"fparkan-script",
"fparkan-terrain", "fparkan-terrain",
"fparkan-vfs", "fparkan-vfs",
"fparkan-world", "fparkan-world",
+3 -1
View File
@@ -56,7 +56,7 @@ fn run() -> Result<(), String> {
let loaded = load_mission(&mut engine, MissionRequest { key: mission }) let loaded = load_mission(&mut engine, MissionRequest { key: mission })
.map_err(|err| format!("{err}"))?; .map_err(|err| format!("{err}"))?;
println!( 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.object_count,
loaded.areal_count, loaded.areal_count,
loaded.surface_count, loaded.surface_count,
@@ -66,6 +66,8 @@ fn run() -> Result<(), String> {
loaded.graph_material_resolved_count, loaded.graph_material_resolved_count,
loaded.graph_texture_resolved_count, loaded.graph_texture_resolved_count,
loaded.graph_lightmap_resolved_count, loaded.graph_lightmap_resolved_count,
loaded.script_bundle_count,
loaded.script_event_count,
loaded.graph_failure_count loaded.graph_failure_count
); );
} }
+35 -1
View File
@@ -24,7 +24,7 @@ use fparkan_material::{
decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable, decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable,
MAT0_KIND, WEAR_KIND, 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}; pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError}; use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
use fparkan_nres::{decode as decode_nres, ReadProfile}; use fparkan_nres::{decode as decode_nres, ReadProfile};
@@ -63,6 +63,17 @@ pub struct MissionTerrainPaths {
pub land_map: NormalizedPath, 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<u8>,
}
/// Terrain loading errors that include runtime world construction failures. /// Terrain loading errors that include runtime world construction failures.
#[derive(Debug)] #[derive(Debug)]
pub enum TerrainPreparationError { pub enum TerrainPreparationError {
@@ -121,6 +132,29 @@ pub fn decode_mission_land_path(
decode_tma_land_path(bytes, profile) 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<MissionScriptBundleBase> {
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. /// Builds canonical mission terrain paths from the mission `Land` reference.
/// ///
/// # Errors /// # Errors
+1
View File
@@ -13,6 +13,7 @@ fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
fparkan-render = { path = "../fparkan-render", version = "0.1.0" } fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" } fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
fparkan-resource = { path = "../fparkan-resource", 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-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
fparkan-world = { path = "../fparkan-world", version = "0.1.0" } fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
+77 -4
View File
@@ -23,10 +23,10 @@
use fparkan_assets::{ use fparkan_assets::{
decode_mission_land_path, decode_mission_payload, decode_nres_payload, decode_mission_land_path, decode_mission_payload, decode_nres_payload,
derive_mission_land_paths, extend_graph_report_with_visual_dependencies_with_progress, derive_mission_land_paths, extend_graph_report_with_visual_dependencies_with_progress,
prepare_terrain_world, AssetError as AssetPreparationError, AssetId, AssetManager, mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError,
AssetPreparationPhase, BuildCategory, MissionAssetPlan, MissionDocument, MissionError, AssetId, AssetManager, AssetPreparationPhase, BuildCategory, MissionAssetPlan, MissionDocument,
MissionTerrainPaths, NresError, PreparedVisual, TerrainFormatError, TerrainPreparationError, MissionError, MissionTerrainPaths, NresError, PreparedVisual, TerrainFormatError,
TerrainWorld, TmaProfile, VisualDependencyPhase, TerrainPreparationError, TerrainWorld, TmaProfile, VisualDependencyPhase,
}; };
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{ use fparkan_prototype::{
@@ -34,6 +34,7 @@ use fparkan_prototype::{
UnitComponentRecord, UnitComponentRecord,
}; };
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository};
use fparkan_script::ScriptPackage;
use fparkan_terrain::SurfaceQuery; use fparkan_terrain::SurfaceQuery;
use fparkan_vfs::{Vfs, VfsError}; use fparkan_vfs::{Vfs, VfsError};
use fparkan_world::{ use fparkan_world::{
@@ -237,6 +238,19 @@ pub struct MissionObjectDraft {
pub unit_components: Vec<UnitComponentRecord>, pub unit_components: Vec<UnitComponentRecord>,
} }
/// 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)] #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct MissionLoadOptions { struct MissionLoadOptions {
fail_after_registered_objects: Option<usize>, fail_after_registered_objects: Option<usize>,
@@ -324,6 +338,10 @@ pub struct LoadedMission {
pub asset_texture_count: usize, pub asset_texture_count: usize,
/// Mission asset plan lightmap count after dependency preparation. /// Mission asset plan lightmap count after dependency preparation.
pub asset_lightmap_count: usize, 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. /// Frame result.
@@ -360,6 +378,7 @@ struct LoadedMissionState {
mission_assets: MissionAssets, mission_assets: MissionAssets,
asset_plan: MissionAssetPlan, asset_plan: MissionAssetPlan,
object_drafts: Vec<MissionObjectDraft>, object_drafts: Vec<MissionObjectDraft>,
script_bundles: Vec<MissionScriptBundle>,
} }
/// Engine error. /// Engine error.
@@ -383,6 +402,13 @@ pub enum EngineError {
/// Source error. /// Source error.
source: VfsError, source: VfsError,
}, },
/// Compiled script package could not be decoded.
Script {
/// Script path.
path: String,
/// Decode diagnostic.
message: String,
},
/// `NRes` decode error. /// `NRes` decode error.
Nres { Nres {
/// Resource path. /// Resource path.
@@ -517,6 +543,7 @@ impl std::fmt::Display for EngineError {
write!(f, "invalid {role} path '{value}': {source}") write!(f, "invalid {role} path '{value}': {source}")
} }
Self::Vfs { path, source } => write!(f, "{path}: {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::Nres { path, source } => write!(f, "{path}: {source}"),
Self::Mission { path, source } => write!(f, "{path}: {source}"), Self::Mission { path, source } => write!(f, "{path}: {source}"),
Self::TerrainFormat { 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::World(source) => Some(source),
Self::AssetPreparation { source, .. } => Some(source), Self::AssetPreparation { source, .. } => Some(source),
Self::MissingVfs Self::MissingVfs
| Self::Script { .. }
| Self::Movement(_) | Self::Movement(_)
| Self::PrototypeGraph { .. } | Self::PrototypeGraph { .. }
| Self::SchedulerPhaseOrder { .. } | Self::SchedulerPhaseOrder { .. }
@@ -775,6 +803,7 @@ fn load_mission_with_options_and_progress(
source, source,
} }
})?; })?;
let script_bundles = load_mission_script_bundles(&vfs, &mission)?;
trace.transforms = mission trace.transforms = mission
.objects .objects
.iter() .iter()
@@ -991,6 +1020,11 @@ fn load_mission_with_options_and_progress(
asset_material_count: mission_asset_plan.material_count, asset_material_count: mission_asset_plan.material_count,
asset_texture_count: mission_asset_plan.texture_count, asset_texture_count: mission_asset_plan.texture_count,
asset_lightmap_count: mission_asset_plan.lightmap_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; engine.world = new_runtime_world;
@@ -1004,6 +1038,7 @@ fn load_mission_with_options_and_progress(
mission_assets, mission_assets,
asset_plan: mission_asset_plan, asset_plan: mission_asset_plan,
object_drafts, object_drafts,
script_bundles,
}); });
Ok((summary, trace)) Ok((summary, trace))
} }
@@ -1084,6 +1119,15 @@ pub fn loaded_mission_document(engine: &Engine) -> Option<&MissionDocument> {
engine.loaded.as_ref().map(|state| &state.mission) 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. /// Returns terrain runtime data for the loaded mission.
#[must_use] #[must_use]
pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> { pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> {
@@ -1214,6 +1258,35 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa
}) })
} }
fn load_mission_script_bundles(
vfs: &Arc<dyn Vfs>,
mission: &MissionDocument,
) -> Result<Vec<MissionScriptBundle>, 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<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, EngineError> { fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, EngineError> {
vfs.read(path).map_err(|source| EngineError::Vfs { vfs.read(path).map_err(|source| EngineError::Vfs {
path: path.as_str().to_string(), path: path.as_str().to_string(),
+8
View File
@@ -73,6 +73,14 @@ raw seven slots, resolved variable values и вход/выход вызова `0
controlled mission. До него compatibility VM возвращает явный unsupported controlled mission. До него compatibility VM возвращает явный unsupported
result, а не «примерный» game command. result, а не «примерный» game command.
На границе mission runtime выбранный TMA clan `first_resource` теперь
материализуется как отдельный `MissionScriptBundle`: loader нормализует
`<base>.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 не TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state, сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
+8
View File
@@ -135,6 +135,14 @@ repeat relation_count:
TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как
raw-поля, пока их потребительская семантика не закрыта. 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: Mode `0` имеет отдельный count-driven layout:
```text ```text