2026-06-22 13:12:27 +04:00
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
|
//! Runtime orchestration for headless and rendered modes.
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
use fparkan_assets::{
|
|
|
|
|
AssetError as AssetPreparationError, AssetManager, MissionAssetPlan,
|
|
|
|
|
decode_mission_land_path, decode_nres_payload, decode_mission_payload, prepare_terrain_world,
|
|
|
|
|
derive_mission_land_paths, BuildCategory, MissionDocument, MissionError, MissionTerrainPaths,
|
|
|
|
|
TerrainFormatError, TerrainPreparationError, TmaProfile, TerrainWorld,
|
|
|
|
|
NresError,
|
|
|
|
|
extend_graph_report_with_visual_dependencies,
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
|
|
|
|
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
|
|
|
|
|
use fparkan_prototype::{
|
2026-06-23 22:05:16 +04:00
|
|
|
build_prototype_graph_report,
|
2026-06-22 13:12:27 +04:00
|
|
|
PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
|
|
|
|
|
};
|
|
|
|
|
use fparkan_resource::{resource_name, CachedResourceRepository};
|
|
|
|
|
use fparkan_vfs::{Vfs, VfsError};
|
|
|
|
|
use fparkan_world::{
|
|
|
|
|
construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
|
|
|
|
|
OriginalObjectId, World, WorldConfig, WorldSnapshot,
|
|
|
|
|
};
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
pub use fparkan_assets::MissionAssets;
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Engine mode.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum EngineMode {
|
|
|
|
|
/// Headless.
|
|
|
|
|
Headless,
|
|
|
|
|
/// Rendered.
|
|
|
|
|
Rendered,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Scheduler phase.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum SchedulerPhase {
|
|
|
|
|
/// Collect platform events.
|
|
|
|
|
CollectPlatformEvents,
|
|
|
|
|
/// Build input snapshot.
|
|
|
|
|
BuildInputSnapshot,
|
|
|
|
|
/// Advance clock.
|
|
|
|
|
AdvanceGameClock,
|
|
|
|
|
/// Calculate world queue.
|
|
|
|
|
CalculateWorldQueue,
|
|
|
|
|
/// Apply deferred operations.
|
|
|
|
|
ApplyDeferredOperations,
|
|
|
|
|
/// Update animation/effects.
|
|
|
|
|
UpdateAnimationAndEffects,
|
|
|
|
|
/// Publish render snapshot.
|
|
|
|
|
PublishRenderSnapshot,
|
|
|
|
|
/// Render world.
|
|
|
|
|
RenderWorld,
|
|
|
|
|
/// End frame callbacks.
|
|
|
|
|
EndFrameCallbacks,
|
|
|
|
|
/// Maintenance.
|
|
|
|
|
Maintenance,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Engine config.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct EngineConfig {
|
|
|
|
|
/// Mode.
|
|
|
|
|
pub mode: EngineMode,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Injectable engine services used by composition roots.
|
|
|
|
|
#[derive(Clone, Default)]
|
|
|
|
|
pub struct EngineServices {
|
|
|
|
|
/// Resource filesystem.
|
|
|
|
|
pub vfs: Option<Arc<dyn Vfs>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl EngineServices {
|
|
|
|
|
/// Creates services with a VFS.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new(vfs: Arc<dyn Vfs>) -> Self {
|
|
|
|
|
Self { vfs: Some(vfs) }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mission request.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct MissionRequest {
|
|
|
|
|
/// Mission key/path.
|
|
|
|
|
pub key: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mission loading phase captured for diagnostics and acceptance tests.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum MissionLoadPhase {
|
|
|
|
|
/// Resolve services and mission request context.
|
|
|
|
|
Context,
|
|
|
|
|
/// Decode and validate TMA.
|
|
|
|
|
Tma,
|
|
|
|
|
/// Decode and validate terrain map assets.
|
|
|
|
|
Map,
|
|
|
|
|
/// Expand object roots into a prototype graph.
|
|
|
|
|
Graph,
|
|
|
|
|
/// Prepare all reachable visual/resource dependencies.
|
|
|
|
|
Assets,
|
|
|
|
|
/// Construct all object drafts before registration.
|
|
|
|
|
Construct,
|
|
|
|
|
/// Register constructed objects.
|
|
|
|
|
Register,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Raw placed transform preserved by the mission loader.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct PlacedTransformProfile {
|
|
|
|
|
/// Object index in TMA order.
|
|
|
|
|
pub object_index: usize,
|
|
|
|
|
/// Raw position vector.
|
|
|
|
|
pub position: [f32; 3],
|
|
|
|
|
/// Raw orientation vector. No Euler order is inferred here.
|
|
|
|
|
pub orientation_raw: [f32; 3],
|
|
|
|
|
/// Raw scale vector.
|
|
|
|
|
pub scale: [f32; 3],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Mission loading trace.
|
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
|
|
|
pub struct MissionLoadTrace {
|
|
|
|
|
/// Observed phases in execution order.
|
|
|
|
|
pub phases: Vec<MissionLoadPhase>,
|
|
|
|
|
/// Number of object drafts constructed before the first registration.
|
|
|
|
|
pub drafts_before_registration: usize,
|
|
|
|
|
/// Number of objects registered.
|
|
|
|
|
pub registered_objects: usize,
|
|
|
|
|
/// Raw transform profile for placed objects.
|
|
|
|
|
pub transforms: Vec<PlacedTransformProfile>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
struct MissionLoadOptions {
|
|
|
|
|
fail_after_registered_objects: Option<usize>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loaded mission.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct LoadedMission {
|
|
|
|
|
/// Mission key.
|
|
|
|
|
pub key: String,
|
|
|
|
|
/// Decoded mission path count.
|
|
|
|
|
pub path_count: usize,
|
|
|
|
|
/// Decoded clan count.
|
|
|
|
|
pub clan_count: usize,
|
|
|
|
|
/// Decoded placed object count.
|
|
|
|
|
pub object_count: usize,
|
|
|
|
|
/// Decoded extra record count.
|
|
|
|
|
pub extra_count: usize,
|
|
|
|
|
/// `Land.msh` path.
|
|
|
|
|
pub land_msh_path: String,
|
|
|
|
|
/// `Land.map` path.
|
|
|
|
|
pub land_map_path: String,
|
|
|
|
|
/// Build category count.
|
|
|
|
|
pub build_category_count: usize,
|
|
|
|
|
/// Runtime navigation area count.
|
|
|
|
|
pub areal_count: usize,
|
|
|
|
|
/// Runtime surface triangle count.
|
|
|
|
|
pub surface_count: usize,
|
|
|
|
|
/// Registered world object count.
|
|
|
|
|
pub registered_objects: usize,
|
|
|
|
|
/// Mission resource roots that point to unit DAT files.
|
|
|
|
|
pub graph_unit_reference_count: usize,
|
|
|
|
|
/// Mission resource roots that point directly to prototype keys.
|
|
|
|
|
pub graph_direct_reference_count: usize,
|
|
|
|
|
/// Component records reached from unit DAT roots.
|
|
|
|
|
pub graph_unit_component_count: usize,
|
|
|
|
|
/// Mission prototype graph root count.
|
|
|
|
|
pub graph_root_count: usize,
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Mission asset plan visual count after dependency preparation.
|
|
|
|
|
pub asset_visual_count: usize,
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Expanded prototype requests resolved to effective prototypes.
|
|
|
|
|
pub graph_resolved_count: usize,
|
|
|
|
|
/// Reached mesh dependency count.
|
|
|
|
|
pub graph_mesh_dependency_count: usize,
|
|
|
|
|
/// Graph failure count.
|
|
|
|
|
pub graph_failure_count: usize,
|
|
|
|
|
/// WEAR requests derived from graph meshes.
|
|
|
|
|
pub graph_wear_request_count: usize,
|
|
|
|
|
/// WEAR entries decoded.
|
|
|
|
|
pub graph_wear_resolved_count: usize,
|
|
|
|
|
/// WEAR material slots requested.
|
|
|
|
|
pub graph_material_slot_count: usize,
|
|
|
|
|
/// MAT0 entries decoded.
|
|
|
|
|
pub graph_material_resolved_count: usize,
|
|
|
|
|
/// Texture requests derived from MAT0 phases.
|
|
|
|
|
pub graph_texture_request_count: usize,
|
|
|
|
|
/// Texm texture entries decoded.
|
|
|
|
|
pub graph_texture_resolved_count: usize,
|
|
|
|
|
/// Lightmap requests declared by WEAR tables.
|
|
|
|
|
pub graph_lightmap_request_count: usize,
|
|
|
|
|
/// Lightmap Texm entries decoded.
|
|
|
|
|
pub graph_lightmap_resolved_count: usize,
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Mission asset plan mesh-backed count after dependency preparation.
|
|
|
|
|
pub asset_model_count: usize,
|
|
|
|
|
/// Mission asset plan material count after dependency preparation.
|
|
|
|
|
pub asset_material_count: usize,
|
|
|
|
|
/// Mission asset plan texture count after dependency preparation.
|
|
|
|
|
pub asset_texture_count: usize,
|
|
|
|
|
/// Mission asset plan lightmap count after dependency preparation.
|
|
|
|
|
pub asset_lightmap_count: usize,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Frame result.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct FrameResult {
|
|
|
|
|
/// Snapshot.
|
|
|
|
|
pub snapshot: WorldSnapshot,
|
2026-06-22 17:32:56 +04:00
|
|
|
/// Scheduler phases executed for this frame.
|
|
|
|
|
pub trace: FrameTrace,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Scheduler trace for a completed frame.
|
|
|
|
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
pub struct FrameTrace {
|
|
|
|
|
/// Frame phases in execution order.
|
|
|
|
|
pub phases: Vec<SchedulerPhase>,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Engine.
|
|
|
|
|
pub struct Engine {
|
|
|
|
|
config: EngineConfig,
|
|
|
|
|
services: EngineServices,
|
|
|
|
|
world: World,
|
|
|
|
|
loaded: Option<LoadedMissionState>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct LoadedMissionState {
|
|
|
|
|
summary: LoadedMission,
|
|
|
|
|
mission: MissionDocument,
|
|
|
|
|
terrain: TerrainWorld,
|
|
|
|
|
build_categories: Vec<BuildCategory>,
|
|
|
|
|
prototype_graph: PrototypeGraph,
|
|
|
|
|
prototype_report: PrototypeGraphReport,
|
2026-06-23 22:05:16 +04:00
|
|
|
mission_assets: MissionAssets,
|
|
|
|
|
asset_plan: MissionAssetPlan,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Engine error.
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum EngineError {
|
|
|
|
|
/// Engine was created without a resource VFS.
|
|
|
|
|
MissingVfs,
|
|
|
|
|
/// Invalid resource path.
|
|
|
|
|
Path {
|
|
|
|
|
/// Path role.
|
|
|
|
|
role: &'static str,
|
|
|
|
|
/// Raw value.
|
|
|
|
|
value: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: PathError,
|
|
|
|
|
},
|
|
|
|
|
/// VFS error.
|
|
|
|
|
Vfs {
|
|
|
|
|
/// Resource path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: VfsError,
|
|
|
|
|
},
|
|
|
|
|
/// `NRes` decode error.
|
|
|
|
|
Nres {
|
|
|
|
|
/// Resource path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Source error.
|
2026-06-23 22:05:16 +04:00
|
|
|
source: NresError,
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
|
|
|
|
/// Mission decode error.
|
|
|
|
|
Mission {
|
|
|
|
|
/// Resource path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: MissionError,
|
|
|
|
|
},
|
|
|
|
|
/// Terrain disk format error.
|
|
|
|
|
TerrainFormat {
|
|
|
|
|
/// Resource path.
|
|
|
|
|
path: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: TerrainFormatError,
|
|
|
|
|
},
|
|
|
|
|
/// Terrain runtime build error.
|
2026-06-23 22:05:16 +04:00
|
|
|
Terrain(fparkan_assets::TerrainError),
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Prototype graph errors.
|
|
|
|
|
PrototypeGraph {
|
|
|
|
|
/// Root failures.
|
|
|
|
|
failures: Vec<PrototypeGraphFailure>,
|
|
|
|
|
},
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Asset preparation errors.
|
|
|
|
|
AssetPreparation {
|
|
|
|
|
/// Mission key.
|
|
|
|
|
mission: String,
|
|
|
|
|
/// Source error.
|
|
|
|
|
source: AssetPreparationError,
|
|
|
|
|
},
|
2026-06-22 13:12:27 +04:00
|
|
|
/// World error.
|
|
|
|
|
World(fparkan_world::WorldError),
|
2026-06-22 17:32:56 +04:00
|
|
|
/// Scheduler phase order was violated.
|
|
|
|
|
SchedulerPhaseOrder {
|
|
|
|
|
/// Previous phase.
|
|
|
|
|
previous: SchedulerPhase,
|
|
|
|
|
/// Current phase.
|
|
|
|
|
current: SchedulerPhase,
|
|
|
|
|
},
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Staged mission world was torn down after a registration-phase failure.
|
|
|
|
|
RegistrationTeardown {
|
|
|
|
|
/// Registered objects before the forced failure.
|
|
|
|
|
registered_objects: usize,
|
|
|
|
|
/// Objects released by normal world shutdown.
|
|
|
|
|
released_objects: usize,
|
|
|
|
|
/// Managers were released after objects.
|
|
|
|
|
managers_released: bool,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<fparkan_world::WorldError> for EngineError {
|
|
|
|
|
fn from(value: fparkan_world::WorldError) -> Self {
|
|
|
|
|
Self::World(value)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Display for EngineError {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::MissingVfs => write!(f, "mission loading requires a VFS service"),
|
|
|
|
|
Self::Path {
|
|
|
|
|
role,
|
|
|
|
|
value,
|
|
|
|
|
source,
|
|
|
|
|
} => {
|
|
|
|
|
write!(f, "invalid {role} path '{value}': {source}")
|
|
|
|
|
}
|
|
|
|
|
Self::Vfs { path, source } => write!(f, "{path}: {source}"),
|
|
|
|
|
Self::Nres { path, source } => write!(f, "{path}: {source}"),
|
|
|
|
|
Self::Mission { path, source } => write!(f, "{path}: {source}"),
|
|
|
|
|
Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"),
|
|
|
|
|
Self::Terrain(source) => write!(f, "{source}"),
|
|
|
|
|
Self::PrototypeGraph { failures } => {
|
|
|
|
|
write!(f, "mission prototype graph has {} failures", failures.len())
|
|
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
Self::AssetPreparation { mission, source } => {
|
|
|
|
|
write!(f, "{mission}: asset preparation failed: {source}")
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
Self::World(source) => write!(f, "{source}"),
|
2026-06-22 17:32:56 +04:00
|
|
|
Self::SchedulerPhaseOrder { previous, current } => write!(
|
|
|
|
|
f,
|
|
|
|
|
"scheduler phase order regressed from {previous:?} to {current:?}"
|
|
|
|
|
),
|
2026-06-22 13:12:27 +04:00
|
|
|
Self::RegistrationTeardown {
|
|
|
|
|
registered_objects,
|
|
|
|
|
released_objects,
|
|
|
|
|
managers_released,
|
|
|
|
|
} => write!(
|
|
|
|
|
f,
|
|
|
|
|
"mission registration failed after {registered_objects} objects; teardown released {released_objects}, managers_released={managers_released}"
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::error::Error for EngineError {
|
|
|
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Path { source, .. } => Some(source),
|
|
|
|
|
Self::Vfs { source, .. } => Some(source),
|
|
|
|
|
Self::Nres { source, .. } => Some(source),
|
|
|
|
|
Self::Mission { source, .. } => Some(source),
|
|
|
|
|
Self::TerrainFormat { source, .. } => Some(source),
|
|
|
|
|
Self::Terrain(source) => Some(source),
|
|
|
|
|
Self::World(source) => Some(source),
|
2026-06-23 22:05:16 +04:00
|
|
|
Self::AssetPreparation { source, .. } => Some(source),
|
2026-06-22 17:32:56 +04:00
|
|
|
Self::MissingVfs
|
|
|
|
|
| Self::PrototypeGraph { .. }
|
|
|
|
|
| Self::SchedulerPhaseOrder { .. }
|
|
|
|
|
| Self::RegistrationTeardown { .. } => None,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Creates engine.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Currently this constructor is infallible, but it returns
|
|
|
|
|
/// [`EngineError`] to keep the composition-root API stable as services become
|
|
|
|
|
/// mandatory.
|
|
|
|
|
pub fn create(config: EngineConfig, services: EngineServices) -> Result<Engine, EngineError> {
|
|
|
|
|
Ok(Engine {
|
|
|
|
|
config,
|
|
|
|
|
services,
|
|
|
|
|
world: new_world(WorldConfig),
|
|
|
|
|
loaded: None,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads mission transactionally.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`EngineError`] when VFS services are missing, mission paths are
|
|
|
|
|
/// invalid, required files cannot be read, disk formats fail validation, terrain
|
|
|
|
|
/// runtime data cannot be built, prototype graph roots do not resolve, or
|
|
|
|
|
/// object registration fails.
|
|
|
|
|
pub fn load_mission(
|
|
|
|
|
engine: &mut Engine,
|
|
|
|
|
request: MissionRequest,
|
|
|
|
|
) -> Result<LoadedMission, EngineError> {
|
|
|
|
|
load_mission_with_trace(engine, request).map(|(loaded, _trace)| loaded)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads mission transactionally and returns a diagnostic trace.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`EngineError`] under the same conditions as [`load_mission`].
|
|
|
|
|
pub fn load_mission_with_trace(
|
|
|
|
|
engine: &mut Engine,
|
|
|
|
|
request: MissionRequest,
|
|
|
|
|
) -> Result<(LoadedMission, MissionLoadTrace), EngineError> {
|
|
|
|
|
load_mission_with_options(engine, request, MissionLoadOptions::default())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[allow(clippy::too_many_lines)]
|
|
|
|
|
fn load_mission_with_options(
|
|
|
|
|
engine: &mut Engine,
|
|
|
|
|
request: MissionRequest,
|
|
|
|
|
options: MissionLoadOptions,
|
|
|
|
|
) -> Result<(LoadedMission, MissionLoadTrace), EngineError> {
|
|
|
|
|
let mut trace = MissionLoadTrace::default();
|
|
|
|
|
trace.phases.push(MissionLoadPhase::Context);
|
|
|
|
|
let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?;
|
|
|
|
|
let mission_path = normalize_engine_path("mission", &request.key)?;
|
|
|
|
|
let mission_bytes = read_vfs(&vfs, &mission_path)?;
|
|
|
|
|
|
|
|
|
|
trace.phases.push(MissionLoadPhase::Map);
|
2026-06-23 22:05:16 +04:00
|
|
|
let land_path = decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
|
2026-06-22 13:12:27 +04:00
|
|
|
EngineError::Mission {
|
|
|
|
|
path: mission_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
}
|
|
|
|
|
})?;
|
2026-06-23 22:05:16 +04:00
|
|
|
let MissionTerrainPaths { land_msh: land_msh_path, land_map: land_map_path } =
|
|
|
|
|
derive_mission_land_paths(&land_path).map_err(|source| EngineError::Path {
|
|
|
|
|
role: "mission land",
|
|
|
|
|
value: mission_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
})?;
|
|
|
|
|
let land_msh_nres = decode_nres_payload(read_vfs(&vfs, &land_msh_path)?)
|
|
|
|
|
.map_err(|source| EngineError::Nres {
|
2026-06-22 13:12:27 +04:00
|
|
|
path: land_msh_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
})?;
|
2026-06-23 22:05:16 +04:00
|
|
|
let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?)
|
|
|
|
|
.map_err(|source| EngineError::Nres {
|
2026-06-22 13:12:27 +04:00
|
|
|
path: land_map_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
})?;
|
|
|
|
|
let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
|
|
|
|
|
let build_dat = read_vfs(&vfs, &build_dat_path)?;
|
2026-06-23 22:05:16 +04:00
|
|
|
let (terrain, build_categories) = prepare_terrain_world(&land_msh_nres, &land_map_nres, &build_dat)
|
|
|
|
|
.map_err(|source| match source {
|
|
|
|
|
TerrainPreparationError::Decode(source) => EngineError::TerrainFormat {
|
|
|
|
|
path: build_dat_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
},
|
|
|
|
|
TerrainPreparationError::Runtime(source) => EngineError::Terrain(source),
|
2026-06-22 13:12:27 +04:00
|
|
|
})?;
|
|
|
|
|
trace.phases.push(MissionLoadPhase::Tma);
|
|
|
|
|
let mission =
|
2026-06-23 22:05:16 +04:00
|
|
|
decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
|
2026-06-22 13:12:27 +04:00
|
|
|
path: mission_path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
})?;
|
|
|
|
|
trace.transforms = mission
|
|
|
|
|
.objects
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(object_index, object)| PlacedTransformProfile {
|
|
|
|
|
object_index,
|
|
|
|
|
position: object.position,
|
|
|
|
|
orientation_raw: object.orientation,
|
|
|
|
|
scale: object.scale,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
trace.phases.push(MissionLoadPhase::Graph);
|
|
|
|
|
let repository = CachedResourceRepository::new(vfs.clone());
|
|
|
|
|
let graph_roots: Vec<_> = mission
|
|
|
|
|
.objects
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|object| resource_name(&object.resource_name.raw))
|
|
|
|
|
.collect();
|
|
|
|
|
let (prototype_graph, resolved_prototypes, mut prototype_report) =
|
|
|
|
|
build_prototype_graph_report(&repository, vfs.as_ref(), &graph_roots);
|
|
|
|
|
extend_graph_report_with_visual_dependencies(
|
|
|
|
|
&repository,
|
|
|
|
|
&mut prototype_report,
|
2026-06-23 22:05:16 +04:00
|
|
|
&prototype_graph,
|
2026-06-22 13:12:27 +04:00
|
|
|
&resolved_prototypes,
|
|
|
|
|
);
|
|
|
|
|
if !prototype_report.is_success() {
|
|
|
|
|
return Err(EngineError::PrototypeGraph {
|
|
|
|
|
failures: prototype_report.failures.clone(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
let mission_assets = AssetManager::new(repository)
|
|
|
|
|
.prepare_mission_assets(
|
|
|
|
|
&prototype_graph.root_prototype_request_spans,
|
|
|
|
|
&resolved_prototypes,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|source| EngineError::AssetPreparation {
|
|
|
|
|
mission: request.key.clone(),
|
|
|
|
|
source,
|
|
|
|
|
})?;
|
|
|
|
|
let mission_asset_plan = mission_assets.to_plan();
|
2026-06-22 13:12:27 +04:00
|
|
|
trace.phases.push(MissionLoadPhase::Assets);
|
|
|
|
|
|
|
|
|
|
let mut new_runtime_world = new_world(WorldConfig);
|
|
|
|
|
let mut handles = Vec::with_capacity(mission.objects.len());
|
|
|
|
|
trace.phases.push(MissionLoadPhase::Construct);
|
|
|
|
|
for (index, _object) in mission.objects.iter().enumerate() {
|
|
|
|
|
let original_id = u32::try_from(index).ok().map(OriginalObjectId);
|
|
|
|
|
let handle = construct_object(&mut new_runtime_world, ObjectDraft { original_id })?;
|
|
|
|
|
handles.push(handle);
|
|
|
|
|
}
|
|
|
|
|
trace.drafts_before_registration = handles.len();
|
|
|
|
|
trace.phases.push(MissionLoadPhase::Register);
|
|
|
|
|
for handle in &handles {
|
|
|
|
|
if options.fail_after_registered_objects == Some(trace.registered_objects) {
|
|
|
|
|
let report = fparkan_world::shutdown(new_runtime_world);
|
|
|
|
|
return Err(EngineError::RegistrationTeardown {
|
|
|
|
|
registered_objects: trace.registered_objects,
|
|
|
|
|
released_objects: report.released_objects.len(),
|
|
|
|
|
managers_released: report.managers_released,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
register_object(&mut new_runtime_world, *handle)?;
|
|
|
|
|
trace.registered_objects += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let summary = LoadedMission {
|
|
|
|
|
key: request.key,
|
|
|
|
|
path_count: mission.paths.len(),
|
|
|
|
|
clan_count: mission.clans.len(),
|
|
|
|
|
object_count: mission.objects.len(),
|
|
|
|
|
extra_count: mission.extras.len(),
|
|
|
|
|
land_msh_path: land_msh_path.as_str().to_string(),
|
|
|
|
|
land_map_path: land_map_path.as_str().to_string(),
|
|
|
|
|
build_category_count: build_categories.len(),
|
|
|
|
|
areal_count: terrain.areal_count(),
|
|
|
|
|
surface_count: terrain.surface_count(),
|
|
|
|
|
registered_objects: handles.len(),
|
|
|
|
|
graph_unit_reference_count: prototype_report.unit_reference_count,
|
|
|
|
|
graph_direct_reference_count: prototype_report.direct_reference_count,
|
|
|
|
|
graph_unit_component_count: prototype_report.unit_component_count,
|
|
|
|
|
graph_root_count: prototype_report.root_count,
|
2026-06-23 22:05:16 +04:00
|
|
|
asset_visual_count: mission_asset_plan.visual_count,
|
2026-06-22 13:12:27 +04:00
|
|
|
graph_resolved_count: prototype_report.resolved_count,
|
|
|
|
|
graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
|
|
|
|
|
graph_failure_count: prototype_report.failures.len(),
|
|
|
|
|
graph_wear_request_count: prototype_report.wear_request_count,
|
|
|
|
|
graph_wear_resolved_count: prototype_report.wear_resolved_count,
|
|
|
|
|
graph_material_slot_count: prototype_report.material_slot_count,
|
|
|
|
|
graph_material_resolved_count: prototype_report.material_resolved_count,
|
|
|
|
|
graph_texture_request_count: prototype_report.texture_request_count,
|
|
|
|
|
graph_texture_resolved_count: prototype_report.texture_resolved_count,
|
|
|
|
|
graph_lightmap_request_count: prototype_report.lightmap_request_count,
|
|
|
|
|
graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count,
|
2026-06-23 22:05:16 +04:00
|
|
|
asset_model_count: mission_asset_plan.model_count,
|
|
|
|
|
asset_material_count: mission_asset_plan.material_count,
|
|
|
|
|
asset_texture_count: mission_asset_plan.texture_count,
|
|
|
|
|
asset_lightmap_count: mission_asset_plan.lightmap_count,
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
engine.world = new_runtime_world;
|
|
|
|
|
engine.loaded = Some(LoadedMissionState {
|
|
|
|
|
summary: summary.clone(),
|
|
|
|
|
mission,
|
|
|
|
|
terrain,
|
|
|
|
|
build_categories,
|
|
|
|
|
prototype_graph,
|
|
|
|
|
prototype_report,
|
2026-06-23 22:05:16 +04:00
|
|
|
mission_assets,
|
|
|
|
|
asset_plan: mission_asset_plan,
|
2026-06-22 13:12:27 +04:00
|
|
|
});
|
|
|
|
|
Ok((summary, trace))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Steps headless mode.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`EngineError`] when the world step fails.
|
|
|
|
|
pub fn step_headless(
|
|
|
|
|
engine: &mut Engine,
|
|
|
|
|
input: InputSnapshot,
|
|
|
|
|
) -> Result<FrameResult, EngineError> {
|
2026-06-22 17:32:56 +04:00
|
|
|
run_frame(engine, input, SchedulerPresentation::Headless)
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Steps rendered mode.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`EngineError`] when the world step fails.
|
|
|
|
|
pub fn frame(engine: &mut Engine) -> Result<FrameResult, EngineError> {
|
|
|
|
|
match engine.config.mode {
|
2026-06-22 17:32:56 +04:00
|
|
|
EngineMode::Headless => step_headless(engine, InputSnapshot),
|
|
|
|
|
EngineMode::Rendered => run_frame(engine, InputSnapshot, SchedulerPresentation::Rendered),
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Shuts down engine.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Currently shutdown is infallible, but the `Result` preserves the lifecycle
|
|
|
|
|
/// API for future service teardown failures.
|
|
|
|
|
pub fn shutdown(_engine: Engine) -> Result<(), EngineError> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the loaded mission summary.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission(engine: &Engine) -> Option<&LoadedMission> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.summary)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the decoded mission document for the loaded mission.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_document(engine: &Engine) -> Option<&MissionDocument> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.mission)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns terrain runtime data for the loaded mission.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.terrain)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns decoded build categories for the loaded game root.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_build_categories(engine: &Engine) -> Option<&[BuildCategory]> {
|
|
|
|
|
engine
|
|
|
|
|
.loaded
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|state| state.build_categories.as_slice())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the loaded prototype graph.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_prototype_graph(engine: &Engine) -> Option<&PrototypeGraph> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.prototype_graph)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the loaded prototype graph report.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphReport> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.prototype_report)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Returns the prepared mission asset plan for the loaded mission.
|
2026-06-22 13:12:27 +04:00
|
|
|
#[must_use]
|
2026-06-23 22:05:16 +04:00
|
|
|
pub fn loaded_mission_asset_plan(engine: &Engine) -> Option<&MissionAssetPlan> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.asset_plan)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns prepared mission assets for the loaded mission.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn loaded_mission_assets(engine: &Engine) -> Option<&MissionAssets> {
|
|
|
|
|
engine.loaded.as_ref().map(|state| &state.mission_assets)
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
2026-06-22 17:32:56 +04:00
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
enum SchedulerPresentation {
|
|
|
|
|
Headless,
|
|
|
|
|
Rendered,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Default)]
|
|
|
|
|
struct Scheduler {
|
|
|
|
|
phase: Option<SchedulerPhase>,
|
|
|
|
|
trace: FrameTrace,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Scheduler {
|
|
|
|
|
fn enter(&mut self, phase: SchedulerPhase) -> Result<(), EngineError> {
|
|
|
|
|
if let Some(previous) = self.phase {
|
|
|
|
|
if scheduler_phase_index(phase) <= scheduler_phase_index(previous) {
|
|
|
|
|
return Err(EngineError::SchedulerPhaseOrder {
|
|
|
|
|
previous,
|
|
|
|
|
current: phase,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.phase = Some(phase);
|
|
|
|
|
self.trace.phases.push(phase);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn finish(self) -> FrameTrace {
|
|
|
|
|
self.trace
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_frame(
|
|
|
|
|
engine: &mut Engine,
|
|
|
|
|
input: InputSnapshot,
|
|
|
|
|
presentation: SchedulerPresentation,
|
|
|
|
|
) -> Result<FrameResult, EngineError> {
|
|
|
|
|
let mut scheduler = Scheduler::default();
|
|
|
|
|
scheduler.enter(SchedulerPhase::CollectPlatformEvents)?;
|
|
|
|
|
scheduler.enter(SchedulerPhase::BuildInputSnapshot)?;
|
|
|
|
|
scheduler.enter(SchedulerPhase::AdvanceGameClock)?;
|
|
|
|
|
scheduler.enter(SchedulerPhase::CalculateWorldQueue)?;
|
|
|
|
|
let snapshot = step(&mut engine.world, &input)?;
|
|
|
|
|
scheduler.enter(SchedulerPhase::ApplyDeferredOperations)?;
|
|
|
|
|
scheduler.enter(SchedulerPhase::UpdateAnimationAndEffects)?;
|
|
|
|
|
if presentation == SchedulerPresentation::Rendered {
|
|
|
|
|
scheduler.enter(SchedulerPhase::PublishRenderSnapshot)?;
|
|
|
|
|
scheduler.enter(SchedulerPhase::RenderWorld)?;
|
|
|
|
|
}
|
|
|
|
|
scheduler.enter(SchedulerPhase::EndFrameCallbacks)?;
|
|
|
|
|
scheduler.enter(SchedulerPhase::Maintenance)?;
|
|
|
|
|
Ok(FrameResult {
|
|
|
|
|
snapshot,
|
|
|
|
|
trace: scheduler.finish(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn scheduler_phase_index(phase: SchedulerPhase) -> u8 {
|
|
|
|
|
match phase {
|
|
|
|
|
SchedulerPhase::CollectPlatformEvents => 0,
|
|
|
|
|
SchedulerPhase::BuildInputSnapshot => 1,
|
|
|
|
|
SchedulerPhase::AdvanceGameClock => 2,
|
|
|
|
|
SchedulerPhase::CalculateWorldQueue => 3,
|
|
|
|
|
SchedulerPhase::ApplyDeferredOperations => 4,
|
|
|
|
|
SchedulerPhase::UpdateAnimationAndEffects => 5,
|
|
|
|
|
SchedulerPhase::PublishRenderSnapshot => 6,
|
|
|
|
|
SchedulerPhase::RenderWorld => 7,
|
|
|
|
|
SchedulerPhase::EndFrameCallbacks => 8,
|
|
|
|
|
SchedulerPhase::Maintenance => 9,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPath, EngineError> {
|
|
|
|
|
normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
|
|
|
|
|
EngineError::Path {
|
|
|
|
|
role,
|
|
|
|
|
value: value.to_string(),
|
|
|
|
|
source,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, EngineError> {
|
|
|
|
|
vfs.read(path).map_err(|source| EngineError::Vfs {
|
|
|
|
|
path: path.as_str().to_string(),
|
|
|
|
|
source,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use fparkan_vfs::{DirectoryVfs, VfsEntry, VfsMetadata};
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
|
|
|
|
|
let mut engine = create(
|
|
|
|
|
EngineConfig {
|
|
|
|
|
mode: EngineMode::Headless,
|
|
|
|
|
},
|
|
|
|
|
EngineServices::default(),
|
|
|
|
|
)
|
|
|
|
|
.expect("engine");
|
|
|
|
|
let before = step_headless(&mut engine, InputSnapshot).expect("step");
|
|
|
|
|
let err = load_mission(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect_err("missing VFS");
|
|
|
|
|
assert!(matches!(err, EngineError::MissingVfs));
|
|
|
|
|
let after = step_headless(&mut engine, InputSnapshot).expect("step");
|
|
|
|
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 17:32:56 +04:00
|
|
|
#[test]
|
|
|
|
|
fn headless_scheduler_trace_skips_presentation_phases() {
|
|
|
|
|
let mut engine = create(
|
|
|
|
|
EngineConfig {
|
|
|
|
|
mode: EngineMode::Headless,
|
|
|
|
|
},
|
|
|
|
|
EngineServices::default(),
|
|
|
|
|
)
|
|
|
|
|
.expect("engine");
|
|
|
|
|
|
|
|
|
|
let result = frame(&mut engine).expect("frame");
|
|
|
|
|
|
|
|
|
|
assert_eq!(result.snapshot.tick.0, 1);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result.trace.phases,
|
|
|
|
|
vec![
|
|
|
|
|
SchedulerPhase::CollectPlatformEvents,
|
|
|
|
|
SchedulerPhase::BuildInputSnapshot,
|
|
|
|
|
SchedulerPhase::AdvanceGameClock,
|
|
|
|
|
SchedulerPhase::CalculateWorldQueue,
|
|
|
|
|
SchedulerPhase::ApplyDeferredOperations,
|
|
|
|
|
SchedulerPhase::UpdateAnimationAndEffects,
|
|
|
|
|
SchedulerPhase::EndFrameCallbacks,
|
|
|
|
|
SchedulerPhase::Maintenance,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rendered_scheduler_trace_includes_presentation_after_simulation() {
|
|
|
|
|
let mut engine = create(
|
|
|
|
|
EngineConfig {
|
|
|
|
|
mode: EngineMode::Rendered,
|
|
|
|
|
},
|
|
|
|
|
EngineServices::default(),
|
|
|
|
|
)
|
|
|
|
|
.expect("engine");
|
|
|
|
|
|
|
|
|
|
let result = frame(&mut engine).expect("frame");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
result.trace.phases,
|
|
|
|
|
vec![
|
|
|
|
|
SchedulerPhase::CollectPlatformEvents,
|
|
|
|
|
SchedulerPhase::BuildInputSnapshot,
|
|
|
|
|
SchedulerPhase::AdvanceGameClock,
|
|
|
|
|
SchedulerPhase::CalculateWorldQueue,
|
|
|
|
|
SchedulerPhase::ApplyDeferredOperations,
|
|
|
|
|
SchedulerPhase::UpdateAnimationAndEffects,
|
|
|
|
|
SchedulerPhase::PublishRenderSnapshot,
|
|
|
|
|
SchedulerPhase::RenderWorld,
|
|
|
|
|
SchedulerPhase::EndFrameCallbacks,
|
|
|
|
|
SchedulerPhase::Maintenance,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn scheduler_rejects_phase_regressions() {
|
|
|
|
|
let mut scheduler = Scheduler::default();
|
|
|
|
|
scheduler
|
|
|
|
|
.enter(SchedulerPhase::BuildInputSnapshot)
|
|
|
|
|
.expect("enter build input");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
scheduler.enter(SchedulerPhase::CollectPlatformEvents),
|
|
|
|
|
Err(EngineError::SchedulerPhaseOrder {
|
|
|
|
|
previous: SchedulerPhase::BuildInputSnapshot,
|
|
|
|
|
current: SchedulerPhase::CollectPlatformEvents,
|
|
|
|
|
})
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn load_trace_records_preparation_before_registration_and_raw_transforms() {
|
2026-06-22 17:29:33 +04:00
|
|
|
let root = licensed_root("IS");
|
2026-06-22 13:12:27 +04:00
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
|
|
|
|
|
let mut engine = create(
|
|
|
|
|
EngineConfig {
|
|
|
|
|
mode: EngineMode::Headless,
|
|
|
|
|
},
|
|
|
|
|
EngineServices::new(vfs),
|
|
|
|
|
)
|
|
|
|
|
.expect("engine");
|
|
|
|
|
|
|
|
|
|
let (loaded, trace) = load_mission_with_trace(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("load mission with trace");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
trace.phases,
|
|
|
|
|
vec![
|
|
|
|
|
MissionLoadPhase::Context,
|
|
|
|
|
MissionLoadPhase::Map,
|
|
|
|
|
MissionLoadPhase::Tma,
|
|
|
|
|
MissionLoadPhase::Graph,
|
|
|
|
|
MissionLoadPhase::Assets,
|
|
|
|
|
MissionLoadPhase::Construct,
|
|
|
|
|
MissionLoadPhase::Register,
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(trace.drafts_before_registration, loaded.object_count);
|
|
|
|
|
assert_eq!(trace.registered_objects, loaded.object_count);
|
|
|
|
|
assert_eq!(trace.transforms.len(), loaded.object_count);
|
|
|
|
|
assert!(trace.transforms.iter().all(|transform| transform
|
|
|
|
|
.orientation_raw
|
|
|
|
|
.iter()
|
|
|
|
|
.all(|component| component.is_finite())));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn missing_map_and_missing_reachable_resource_fail_before_registration() {
|
2026-06-22 17:29:33 +04:00
|
|
|
let root = licensed_root("IS");
|
2026-06-22 13:12:27 +04:00
|
|
|
for (denied, mission) in [
|
|
|
|
|
(
|
|
|
|
|
DenyRule::Suffix("Land.map"),
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
DenyRule::Suffix("objects.rlb"),
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
] {
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DenyVfs {
|
|
|
|
|
inner: DirectoryVfs::new(&root),
|
|
|
|
|
denied,
|
|
|
|
|
});
|
|
|
|
|
let mut engine = create(
|
|
|
|
|
EngineConfig {
|
|
|
|
|
mode: EngineMode::Headless,
|
|
|
|
|
},
|
|
|
|
|
EngineServices::new(vfs),
|
|
|
|
|
)
|
|
|
|
|
.expect("engine");
|
|
|
|
|
let before = step_headless(&mut engine, InputSnapshot).expect("before");
|
|
|
|
|
let err = load_mission(&mut engine, mission).expect_err("load error");
|
|
|
|
|
match denied {
|
|
|
|
|
DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })),
|
|
|
|
|
DenyRule::Suffix("objects.rlb") => {
|
|
|
|
|
assert!(matches!(err, EngineError::PrototypeGraph { .. }))
|
|
|
|
|
}
|
|
|
|
|
DenyRule::Suffix(unexpected) => panic!("unexpected deny rule {unexpected}"),
|
|
|
|
|
}
|
|
|
|
|
assert!(loaded_mission(&engine).is_none());
|
|
|
|
|
let after = step_headless(&mut engine, InputSnapshot).expect("after");
|
|
|
|
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() {
|
2026-06-22 17:29:33 +04:00
|
|
|
let root = licensed_root("IS");
|
2026-06-22 13:12:27 +04:00
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
|
|
|
|
let mut engine = create(
|
|
|
|
|
EngineConfig {
|
|
|
|
|
mode: EngineMode::Headless,
|
|
|
|
|
},
|
|
|
|
|
EngineServices::new(vfs),
|
|
|
|
|
)
|
|
|
|
|
.expect("engine");
|
|
|
|
|
let before = step_headless(&mut engine, InputSnapshot).expect("before");
|
|
|
|
|
|
|
|
|
|
let err = load_mission_with_options(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
|
|
|
|
|
},
|
|
|
|
|
MissionLoadOptions {
|
|
|
|
|
fail_after_registered_objects: Some(1),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect_err("forced registration failure");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
err,
|
|
|
|
|
EngineError::RegistrationTeardown {
|
|
|
|
|
registered_objects: 1,
|
|
|
|
|
released_objects: 1,
|
|
|
|
|
managers_released: true,
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
assert!(loaded_mission(&engine).is_none());
|
|
|
|
|
let after = step_headless(&mut engine, InputSnapshot).expect("after");
|
|
|
|
|
assert_eq!(before.snapshot.objects, after.snapshot.objects);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
|
|
|
|
|
for case in [
|
|
|
|
|
HeadlessCase {
|
|
|
|
|
root: "IS",
|
|
|
|
|
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
|
|
|
|
|
object_count: 33,
|
|
|
|
|
expected_hash: [
|
2026-06-22 17:29:33 +04:00
|
|
|
0xc7, 0xb0, 0x6e, 0x0a, 0x31, 0x1f, 0x5d, 0x8c, 0xde, 0x64, 0xa5, 0x33, 0x1f,
|
|
|
|
|
0x2c, 0xd0, 0x2c, 0x21, 0x44, 0x2f, 0x34, 0x5d, 0x16, 0xe8, 0x94, 0xaf, 0xa2,
|
|
|
|
|
0x2b, 0xa9, 0xd4, 0x24, 0xd2, 0xf9,
|
2026-06-22 13:12:27 +04:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
HeadlessCase {
|
|
|
|
|
root: "IS2",
|
|
|
|
|
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
|
|
|
|
object_count: 10,
|
|
|
|
|
expected_hash: [
|
2026-06-22 17:29:33 +04:00
|
|
|
0x3c, 0xe5, 0xa6, 0x39, 0x47, 0x86, 0x76, 0xe1, 0xb2, 0x1a, 0x8e, 0x96, 0x3d,
|
|
|
|
|
0x60, 0x6e, 0xc6, 0x8c, 0xe2, 0x28, 0x4f, 0x57, 0xd9, 0xe1, 0xe4, 0xb5, 0x95,
|
|
|
|
|
0xdf, 0x88, 0xd3, 0x2f, 0x4a, 0x4d,
|
2026-06-22 13:12:27 +04:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
let first = run_headless_case(case);
|
|
|
|
|
let second = run_headless_case(case);
|
|
|
|
|
assert_eq!(first, second);
|
|
|
|
|
assert_eq!(first.tick.0, 10_000);
|
|
|
|
|
assert_eq!(first.objects.len(), case.object_count);
|
|
|
|
|
assert_eq!(first.hash.0, case.expected_hash);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn licensed_corpora_load_all_mission_foundations() {
|
2026-06-22 17:29:33 +04:00
|
|
|
let part1 = load_all(&licensed_root("IS"));
|
2026-06-22 13:12:27 +04:00
|
|
|
assert_eq!(part1.missions, 29);
|
|
|
|
|
assert_eq!(part1.paths, 34);
|
|
|
|
|
assert_eq!(part1.clans, 101);
|
|
|
|
|
assert_eq!(part1.objects, 864);
|
|
|
|
|
assert_eq!(part1.extras, 28);
|
|
|
|
|
assert_eq!(part1.unit_references, 463);
|
|
|
|
|
assert_eq!(part1.direct_references, 401);
|
|
|
|
|
assert_eq!(part1.unit_components, 4_300);
|
|
|
|
|
assert_eq!(part1.prototype_requests, 4_701);
|
|
|
|
|
assert_eq!(part1.material_slots, 36_954);
|
|
|
|
|
assert_eq!(part1.texture_requests, 48_806);
|
|
|
|
|
assert_eq!(part1.lightmap_requests, 139);
|
|
|
|
|
assert_eq!(part1.graph_failures, 0);
|
|
|
|
|
assert_eq!(part1.wear_requests, part1.prototype_requests);
|
|
|
|
|
assert_eq!(part1.wear_requests, part1.wear_resolved);
|
|
|
|
|
assert_eq!(part1.material_slots, part1.material_resolved);
|
|
|
|
|
assert_eq!(part1.texture_requests, part1.texture_resolved);
|
|
|
|
|
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
|
|
|
|
|
|
2026-06-22 17:29:33 +04:00
|
|
|
let part2 = load_all(&licensed_root("IS2"));
|
2026-06-22 13:12:27 +04:00
|
|
|
assert_eq!(part2.missions, 31);
|
|
|
|
|
assert_eq!(part2.paths, 61);
|
|
|
|
|
assert_eq!(part2.clans, 91);
|
|
|
|
|
assert_eq!(part2.objects, 885);
|
|
|
|
|
assert_eq!(part2.extras, 41);
|
|
|
|
|
assert_eq!(part2.unit_references, 561);
|
|
|
|
|
assert_eq!(part2.direct_references, 324);
|
|
|
|
|
assert_eq!(part2.unit_components, 5_521);
|
|
|
|
|
assert_eq!(part2.prototype_requests, 5_845);
|
|
|
|
|
assert_eq!(part2.material_slots, 50_888);
|
|
|
|
|
assert_eq!(part2.texture_requests, 68_603);
|
|
|
|
|
assert_eq!(part2.lightmap_requests, 214);
|
|
|
|
|
assert_eq!(part2.graph_failures, 0);
|
|
|
|
|
assert_eq!(part2.wear_requests, part2.prototype_requests);
|
|
|
|
|
assert_eq!(part2.wear_requests, part2.wear_resolved);
|
|
|
|
|
assert_eq!(part2.material_slots, part2.material_resolved);
|
|
|
|
|
assert_eq!(part2.texture_requests, part2.texture_resolved);
|
|
|
|
|
assert_eq!(part2.lightmap_requests, part2.lightmap_resolved);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
struct LoadTotals {
|
|
|
|
|
missions: usize,
|
|
|
|
|
paths: usize,
|
|
|
|
|
clans: usize,
|
|
|
|
|
objects: usize,
|
|
|
|
|
extras: usize,
|
|
|
|
|
unit_references: usize,
|
|
|
|
|
direct_references: usize,
|
|
|
|
|
unit_components: usize,
|
|
|
|
|
prototype_requests: usize,
|
|
|
|
|
wear_requests: usize,
|
|
|
|
|
wear_resolved: usize,
|
|
|
|
|
material_slots: usize,
|
|
|
|
|
material_resolved: usize,
|
|
|
|
|
texture_requests: usize,
|
|
|
|
|
texture_resolved: usize,
|
|
|
|
|
lightmap_requests: usize,
|
|
|
|
|
lightmap_resolved: usize,
|
|
|
|
|
graph_failures: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
struct HeadlessCase {
|
|
|
|
|
root: &'static str,
|
|
|
|
|
mission: &'static str,
|
|
|
|
|
object_count: usize,
|
|
|
|
|
expected_hash: [u8; 32],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_headless_case(case: HeadlessCase) -> WorldSnapshot {
|
2026-06-22 17:29:33 +04:00
|
|
|
let root = licensed_root(case.root);
|
2026-06-22 13:12:27 +04:00
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
|
|
|
|
let mut engine = create(
|
|
|
|
|
EngineConfig {
|
|
|
|
|
mode: EngineMode::Headless,
|
|
|
|
|
},
|
|
|
|
|
EngineServices::new(vfs),
|
|
|
|
|
)
|
|
|
|
|
.expect("engine");
|
|
|
|
|
let loaded = load_mission(
|
|
|
|
|
&mut engine,
|
|
|
|
|
MissionRequest {
|
|
|
|
|
key: case.mission.to_string(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect("load selected mission");
|
|
|
|
|
assert_eq!(loaded.object_count, case.object_count);
|
|
|
|
|
|
|
|
|
|
let mut snapshot = None;
|
|
|
|
|
for _ in 0..10_000 {
|
|
|
|
|
snapshot = Some(
|
|
|
|
|
step_headless(&mut engine, InputSnapshot)
|
|
|
|
|
.expect("selected mission deterministic tick")
|
|
|
|
|
.snapshot,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
snapshot.expect("at least one tick")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_all(root: &Path) -> LoadTotals {
|
|
|
|
|
assert!(root.is_dir(), "missing licensed corpus {}", root.display());
|
|
|
|
|
let mut missions = mission_paths(root);
|
|
|
|
|
missions.sort();
|
|
|
|
|
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
|
|
|
|
|
let mut totals = LoadTotals::default();
|
|
|
|
|
for mission in missions {
|
|
|
|
|
let mut engine = create(
|
|
|
|
|
EngineConfig {
|
|
|
|
|
mode: EngineMode::Headless,
|
|
|
|
|
},
|
|
|
|
|
EngineServices::new(vfs.clone()),
|
|
|
|
|
)
|
|
|
|
|
.expect("engine");
|
|
|
|
|
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
|
|
|
|
|
.expect("load mission foundation");
|
|
|
|
|
assert_eq!(loaded.object_count, loaded.registered_objects);
|
|
|
|
|
assert_eq!(loaded.object_count, loaded.graph_root_count);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_direct_reference_count + loaded.graph_unit_component_count,
|
|
|
|
|
loaded.graph_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.graph_failure_count, 0);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_wear_request_count,
|
|
|
|
|
loaded.graph_wear_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_material_slot_count,
|
|
|
|
|
loaded.graph_material_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_texture_request_count,
|
|
|
|
|
loaded.graph_texture_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
loaded.graph_lightmap_request_count,
|
|
|
|
|
loaded.graph_lightmap_resolved_count
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(loaded.build_category_count, 12);
|
|
|
|
|
assert!(loaded.areal_count > 0);
|
|
|
|
|
assert!(loaded.surface_count > 0);
|
|
|
|
|
totals.missions += 1;
|
|
|
|
|
totals.paths += loaded.path_count;
|
|
|
|
|
totals.clans += loaded.clan_count;
|
|
|
|
|
totals.objects += loaded.object_count;
|
|
|
|
|
totals.extras += loaded.extra_count;
|
|
|
|
|
totals.unit_references += loaded.graph_unit_reference_count;
|
|
|
|
|
totals.direct_references += loaded.graph_direct_reference_count;
|
|
|
|
|
totals.unit_components += loaded.graph_unit_component_count;
|
|
|
|
|
totals.prototype_requests += loaded.graph_resolved_count;
|
|
|
|
|
totals.wear_requests += loaded.graph_wear_request_count;
|
|
|
|
|
totals.wear_resolved += loaded.graph_wear_resolved_count;
|
|
|
|
|
totals.material_slots += loaded.graph_material_slot_count;
|
|
|
|
|
totals.material_resolved += loaded.graph_material_resolved_count;
|
|
|
|
|
totals.texture_requests += loaded.graph_texture_request_count;
|
|
|
|
|
totals.texture_resolved += loaded.graph_texture_resolved_count;
|
|
|
|
|
totals.lightmap_requests += loaded.graph_lightmap_request_count;
|
|
|
|
|
totals.lightmap_resolved += loaded.graph_lightmap_resolved_count;
|
|
|
|
|
totals.graph_failures += loaded.graph_failure_count;
|
|
|
|
|
}
|
|
|
|
|
totals
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn mission_paths(root: &Path) -> Vec<String> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
collect_missions(root, root, &mut out);
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn collect_missions(root: &Path, dir: &Path, out: &mut Vec<String>) {
|
|
|
|
|
let mut children: Vec<PathBuf> = std::fs::read_dir(dir)
|
|
|
|
|
.expect("read dir")
|
|
|
|
|
.map(|entry| entry.expect("entry").path())
|
|
|
|
|
.collect();
|
|
|
|
|
children.sort();
|
|
|
|
|
for child in children {
|
|
|
|
|
if child.is_dir() {
|
|
|
|
|
collect_missions(root, &child, out);
|
|
|
|
|
} else if child
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.is_some_and(|name| name.eq_ignore_ascii_case("data.tma"))
|
|
|
|
|
{
|
|
|
|
|
let rel = child.strip_prefix(root).expect("relative");
|
|
|
|
|
let rel = rel.to_str().expect("utf8 path").replace('\\', "/");
|
|
|
|
|
out.push(rel);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 17:29:33 +04:00
|
|
|
fn licensed_root(name: &str) -> PathBuf {
|
|
|
|
|
let variable = match name {
|
|
|
|
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
|
|
|
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
|
|
|
|
_ => panic!("unknown licensed corpus part: {name}"),
|
|
|
|
|
};
|
|
|
|
|
let root = std::env::var_os(variable)
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
|
|
|
|
assert!(
|
|
|
|
|
root.is_dir(),
|
|
|
|
|
"licensed corpus root is missing: {}",
|
|
|
|
|
root.display()
|
|
|
|
|
);
|
|
|
|
|
root
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
enum DenyRule {
|
|
|
|
|
Suffix(&'static str),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct DenyVfs {
|
|
|
|
|
inner: DirectoryVfs,
|
|
|
|
|
denied: DenyRule,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DenyVfs {
|
|
|
|
|
fn denied(&self, path: &NormalizedPath) -> bool {
|
|
|
|
|
match self.denied {
|
|
|
|
|
DenyRule::Suffix(suffix) => path
|
|
|
|
|
.as_str()
|
|
|
|
|
.to_ascii_uppercase()
|
|
|
|
|
.ends_with(&suffix.to_ascii_uppercase()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Vfs for DenyVfs {
|
|
|
|
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
|
|
|
|
if self.denied(path) {
|
|
|
|
|
return Err(VfsError::NotFound(path.as_str().to_string()));
|
|
|
|
|
}
|
|
|
|
|
self.inner.metadata(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
|
|
|
|
if self.denied(path) {
|
|
|
|
|
return Err(VfsError::NotFound(path.as_str().to_string()));
|
|
|
|
|
}
|
|
|
|
|
self.inner.read(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
|
|
|
|
self.inner.list(prefix).map(|entries| {
|
|
|
|
|
entries
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|entry| !self.denied(&entry.path))
|
|
|
|
|
.collect()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|