fix: close stage 0-2 synthetic gates
This commit is contained in:
Generated
+2213
-18
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,34 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Minimal `winit`-backed platform adapter shim.
|
||||
|
||||
use fparkan_platform::{
|
||||
EventSource, MonotonicClock, MonotonicInstant, PlatformEvent, PlatformError, PhysicalSize,
|
||||
EventSource, MonotonicClock, MonotonicInstant, PhysicalSize, PlatformError, PlatformEvent,
|
||||
RenderRequest, WindowHandle, WindowPort,
|
||||
};
|
||||
use winit::event::{MouseButton, WindowEvent};
|
||||
use winit::event_loop::Event;
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use winit::event::{Event, MouseButton, WindowEvent};
|
||||
use winit::platform::scancode::PhysicalKeyExtScancode;
|
||||
use winit::window::Window;
|
||||
|
||||
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
|
||||
@@ -52,7 +71,7 @@ impl WinitEventSource {
|
||||
}
|
||||
|
||||
/// Pushes a mapped native window event.
|
||||
pub fn push_window_event(&mut self, event: &WindowEvent<'_>) {
|
||||
pub fn push_window_event(&mut self, event: &WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
self.queue.push_back(PlatformEvent::KeyboardInput {
|
||||
@@ -81,14 +100,13 @@ impl WinitEventSource {
|
||||
});
|
||||
}
|
||||
WindowEvent::Focused(focused) => {
|
||||
self.queue.push_back(PlatformEvent::FocusChanged { focused: *focused });
|
||||
}
|
||||
WindowEvent::ScaleFactorChanged {
|
||||
scale_factor,
|
||||
..
|
||||
} => {
|
||||
self.queue
|
||||
.push_back(PlatformEvent::DpiChanged { scale: *scale_factor });
|
||||
.push_back(PlatformEvent::FocusChanged { focused: *focused });
|
||||
}
|
||||
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
|
||||
self.queue.push_back(PlatformEvent::DpiChanged {
|
||||
scale: *scale_factor,
|
||||
});
|
||||
}
|
||||
WindowEvent::CloseRequested => {
|
||||
self.queue.push_back(PlatformEvent::QuitRequested);
|
||||
@@ -98,7 +116,7 @@ impl WinitEventSource {
|
||||
}
|
||||
|
||||
/// Pushes events from an event loop event.
|
||||
pub fn push_event<T>(&mut self, event: &Event<'_, T>) {
|
||||
pub fn push_event<T>(&mut self, event: &Event<T>) {
|
||||
if let Event::WindowEvent { event, .. } = event {
|
||||
self.push_window_event(event);
|
||||
}
|
||||
@@ -112,7 +130,7 @@ fn mouse_button_code(button: MouseButton) -> u16 {
|
||||
MouseButton::Middle => 2,
|
||||
MouseButton::Back => 3,
|
||||
MouseButton::Forward => 4,
|
||||
MouseButton::Other(index) => 100 + u16::try_from(index).unwrap_or(0),
|
||||
MouseButton::Other(index) => 100 + index,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +237,10 @@ mod tests {
|
||||
source.push(PlatformEvent::QuitRequested);
|
||||
let mut events = Vec::new();
|
||||
source.poll(&mut events)?;
|
||||
assert_eq!(events, vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -227,8 +248,17 @@ mod tests {
|
||||
fn window_port_reports_default_request_profile() {
|
||||
let window = WinitWindow::synthetic(640, 360);
|
||||
let request = WinitWindow::default_render_request();
|
||||
assert_eq!(request.presentation, fparkan_platform::PresentationMode::Fifo);
|
||||
assert_eq!(window.drawable_size(), PhysicalSize { width: 640, height: 360 });
|
||||
assert_eq!(
|
||||
request.presentation,
|
||||
fparkan_platform::PresentationMode::Fifo
|
||||
);
|
||||
assert_eq!(
|
||||
window.drawable_size(),
|
||||
PhysicalSize {
|
||||
width: 640,
|
||||
height: 360
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![deny(unsafe_op_in_unsafe_fn)]
|
||||
//! Vulkan adapter facade and migration-ready backend surface contract.
|
||||
//!
|
||||
@@ -8,10 +27,10 @@
|
||||
//!
|
||||
//! This crate is the declared low-level Vulkan boundary.
|
||||
|
||||
use fparkan_platform::RenderRequest;
|
||||
use fparkan_render::{
|
||||
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
|
||||
};
|
||||
use fparkan_platform::RenderRequest;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Vulkan backend migration readiness.
|
||||
@@ -120,7 +139,10 @@ impl VulkanBackend {
|
||||
|
||||
impl RenderBackend for VulkanBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||
if !matches!(self.state, VulkanBackendState::Ready | VulkanBackendState::Degraded) {
|
||||
if !matches!(
|
||||
self.state,
|
||||
VulkanBackendState::Ready | VulkanBackendState::Degraded
|
||||
) {
|
||||
return Err(RenderError::InvalidRange);
|
||||
}
|
||||
let capture = canonical_capture(commands)?;
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
|
||||
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
|
||||
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` command-line tools.
|
||||
|
||||
use fparkan_assets::extend_graph_report_with_visual_dependencies;
|
||||
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
|
||||
use fparkan_inspection::inspect_archive_file;
|
||||
use fparkan_inspection::ArchiveInspection;
|
||||
use fparkan_assets::extend_graph_report_with_visual_dependencies;
|
||||
use fparkan_prototype::build_prototype_graph_report;
|
||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
||||
use fparkan_runtime::{
|
||||
@@ -135,12 +154,7 @@ fn inspect_prototype(args: &[String]) -> Result<(), String> {
|
||||
let roots = [resource_name(key.as_bytes())];
|
||||
let (graph, resolved, mut report) =
|
||||
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
|
||||
extend_graph_report_with_visual_dependencies(
|
||||
&repository,
|
||||
&mut report,
|
||||
&graph,
|
||||
&resolved,
|
||||
);
|
||||
extend_graph_report_with_visual_dependencies(&repository, &mut report, &graph, &resolved);
|
||||
println!("{}", prototype_inspect_json(&key, &graph, &report));
|
||||
Ok(())
|
||||
}
|
||||
@@ -234,7 +248,9 @@ fn inspect_archive(args: &[String]) -> Result<(), String> {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", path.display())),
|
||||
ArchiveInspection::Unsupported => {
|
||||
Err(format!("{}: unsupported archive magic", path.display()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` rendered game composition root.
|
||||
|
||||
use fparkan_render::{
|
||||
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend,
|
||||
RenderCommand, RenderCommandList, RenderPhase,
|
||||
};
|
||||
use fparkan_assets::PreparedVisual;
|
||||
use fparkan_platform::WindowPort;
|
||||
use fparkan_platform_winit::WinitWindow;
|
||||
use fparkan_render::{
|
||||
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand,
|
||||
RenderCommandList, RenderPhase,
|
||||
};
|
||||
use fparkan_render_vulkan::VulkanBackend;
|
||||
use fparkan_runtime::{
|
||||
create, frame, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||
MissionAssets, loaded_mission_assets,
|
||||
create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices,
|
||||
MissionAssets, MissionRequest,
|
||||
};
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use fparkan_world::WorldSnapshot;
|
||||
@@ -89,6 +110,7 @@ fn run(args: &[String]) -> Result<String, String> {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
|
||||
render_snapshot_commands_with_assets(snapshot, None)
|
||||
}
|
||||
@@ -115,8 +137,10 @@ fn render_snapshot_commands_with_assets(
|
||||
GpuMeshId(u64::from(handle.slot) + 1)
|
||||
};
|
||||
let material = prepared
|
||||
.and_then(|visual| visual.primary_material_id())
|
||||
.map_or(GpuMaterialId(1), |material_id| GpuMaterialId(material_id.raw()));
|
||||
.and_then(PreparedVisual::primary_material_id)
|
||||
.map_or(GpuMaterialId(1), |material_id| {
|
||||
GpuMaterialId(material_id.raw())
|
||||
});
|
||||
let draw_id = snapshot
|
||||
.tick
|
||||
.0
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` headless runtime entrypoint.
|
||||
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` asset viewer composition root.
|
||||
|
||||
use fparkan_inspection::{
|
||||
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection, LandFileKind,
|
||||
MapInspection, NresEntrySummary,
|
||||
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
|
||||
LandFileKind, MapInspection, NresEntrySummary,
|
||||
};
|
||||
use fparkan_render::{
|
||||
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
|
||||
@@ -151,7 +170,11 @@ fn inspect_map(args: &[String]) -> Result<String, String> {
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(render_map_inspection_json(&file.display().to_string(), &kind, &inspection))
|
||||
Ok(render_map_inspection_json(
|
||||
&file.display().to_string(),
|
||||
&kind,
|
||||
&inspection,
|
||||
))
|
||||
}
|
||||
|
||||
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::cast_precision_loss)]
|
||||
//! Deterministic animation sampling contracts.
|
||||
//!
|
||||
|
||||
+141
-104
@@ -1,15 +1,31 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Asset manager ports and transactional preparation models.
|
||||
|
||||
use fparkan_material::{decode_wear, resolve_material, MaterialError, WEAR_KIND};
|
||||
use fparkan_msh::{decode_msh, validate_msh, MshError};
|
||||
pub use fparkan_nres::{NresDocument, NresError};
|
||||
use fparkan_nres::{decode as decode_nres, ReadProfile};
|
||||
pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
|
||||
pub use fparkan_terrain::{TerrainError, TerrainWorld};
|
||||
pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
|
||||
use fparkan_mission_format::{decode_tma, decode_tma_land_path};
|
||||
use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh};
|
||||
pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
|
||||
use fparkan_msh::{decode_msh, validate_msh, MshError};
|
||||
use fparkan_nres::{decode as decode_nres, ReadProfile};
|
||||
pub use fparkan_nres::{NresDocument, NresError};
|
||||
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy, ResourceName};
|
||||
use fparkan_prototype::{
|
||||
EffectivePrototype, PrototypeGeometry, PrototypeGraph, PrototypeGraphEdge,
|
||||
@@ -17,6 +33,9 @@ use fparkan_prototype::{
|
||||
PrototypeGraphRequiredness,
|
||||
};
|
||||
use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository};
|
||||
pub use fparkan_terrain::{TerrainError, TerrainWorld};
|
||||
use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh};
|
||||
pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
|
||||
use fparkan_texm::{decode_texm, TexmError};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt;
|
||||
@@ -27,7 +46,8 @@ use std::sync::Arc;
|
||||
const TEXTURES_ARCHIVE: &str = "textures.lib";
|
||||
const LIGHTMAP_ARCHIVE: &str = "lightmap.lib";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
/// Canonical terrain archive paths derived from a mission land reference.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct MissionTerrainPaths {
|
||||
/// Landscape mesh archive path.
|
||||
pub land_msh: NormalizedPath,
|
||||
@@ -68,6 +88,11 @@ impl From<TerrainError> for TerrainPreparationError {
|
||||
}
|
||||
|
||||
/// Decodes a mission file bytes payload with a typed profile.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`MissionError`] when the mission payload is malformed for the
|
||||
/// selected profile.
|
||||
pub fn decode_mission_payload(
|
||||
bytes: Arc<[u8]>,
|
||||
profile: TmaProfile,
|
||||
@@ -76,6 +101,11 @@ pub fn decode_mission_payload(
|
||||
}
|
||||
|
||||
/// Reads only the mission land path from raw TMA bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`MissionError`] when the mission header or land path record cannot
|
||||
/// be decoded.
|
||||
pub fn decode_mission_land_path(
|
||||
bytes: &[u8],
|
||||
profile: TmaProfile,
|
||||
@@ -84,21 +114,32 @@ pub fn decode_mission_land_path(
|
||||
}
|
||||
|
||||
/// Builds canonical mission terrain paths from the mission `Land` reference.
|
||||
pub fn derive_mission_land_paths(
|
||||
land_path: &LpString,
|
||||
) -> Result<MissionTerrainPaths, PathError> {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError`] when the mission land reference is not a strict
|
||||
/// relative legacy path.
|
||||
pub fn derive_mission_land_paths(land_path: &LpString) -> Result<MissionTerrainPaths, PathError> {
|
||||
let normalized = normalize_relative(&land_path.raw, PathPolicy::StrictLegacy)?;
|
||||
let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else {
|
||||
return Err(PathError::Empty);
|
||||
};
|
||||
let land_msh =
|
||||
normalize_relative(format!("{parent}/Land.msh").as_bytes(), PathPolicy::StrictLegacy)?;
|
||||
let land_map =
|
||||
normalize_relative(format!("{parent}/Land.map").as_bytes(), PathPolicy::StrictLegacy)?;
|
||||
let land_msh = normalize_relative(
|
||||
format!("{parent}/Land.msh").as_bytes(),
|
||||
PathPolicy::StrictLegacy,
|
||||
)?;
|
||||
let land_map = normalize_relative(
|
||||
format!("{parent}/Land.map").as_bytes(),
|
||||
PathPolicy::StrictLegacy,
|
||||
)?;
|
||||
Ok(MissionTerrainPaths { land_msh, land_map })
|
||||
}
|
||||
|
||||
/// Decodes compatible NRes payload for terrain/document loading.
|
||||
/// Decodes compatible `NRes` payload for terrain/document loading.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NresError`] when the payload is not a compatible `NRes` archive.
|
||||
pub fn decode_nres_payload(
|
||||
bytes: Arc<[u8]>,
|
||||
) -> Result<fparkan_nres::NresDocument, fparkan_nres::NresError> {
|
||||
@@ -106,6 +147,11 @@ pub fn decode_nres_payload(
|
||||
}
|
||||
|
||||
/// Decodes terrain documents and builds immutable terrain state.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`TerrainPreparationError`] when terrain documents are malformed or
|
||||
/// cannot be converted into runtime terrain state.
|
||||
pub fn prepare_terrain_world(
|
||||
land_msh_nres: &fparkan_nres::NresDocument,
|
||||
land_map_nres: &fparkan_nres::NresDocument,
|
||||
@@ -119,12 +165,34 @@ pub fn prepare_terrain_world(
|
||||
}
|
||||
|
||||
/// Stable typed identifier for a prepared asset.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
#[derive(Debug)]
|
||||
pub struct AssetId<T> {
|
||||
raw: u64,
|
||||
marker: PhantomData<T>,
|
||||
}
|
||||
|
||||
impl<T> Clone for AssetId<T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Copy for AssetId<T> {}
|
||||
|
||||
impl<T> PartialEq for AssetId<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.raw == other.raw
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Eq for AssetId<T> {}
|
||||
|
||||
impl<T> Hash for AssetId<T> {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.raw.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> AssetId<T> {
|
||||
/// Creates an asset id from a stable raw value.
|
||||
#[must_use]
|
||||
@@ -183,7 +251,7 @@ impl PreparedVisual {
|
||||
}
|
||||
|
||||
/// Immutable prepared mission assets for rendering and game setup.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct MissionAssets {
|
||||
/// Visuals prepared for all reachable prototype requests.
|
||||
pub visuals: Vec<PreparedVisual>,
|
||||
@@ -200,10 +268,7 @@ impl MissionAssets {
|
||||
|
||||
/// Returns all visuals for a mission object index.
|
||||
#[must_use]
|
||||
pub fn visuals_for_object(
|
||||
&self,
|
||||
object_index: usize,
|
||||
) -> &[AssetId<PreparedVisual>] {
|
||||
pub fn visuals_for_object(&self, object_index: usize) -> &[AssetId<PreparedVisual>] {
|
||||
self.object_visuals
|
||||
.get(object_index)
|
||||
.map_or(&[], |values| values.as_slice())
|
||||
@@ -211,10 +276,7 @@ impl MissionAssets {
|
||||
|
||||
/// Returns the first visual for a mission object index.
|
||||
#[must_use]
|
||||
pub fn visual_for_object(
|
||||
&self,
|
||||
object_index: usize,
|
||||
) -> Option<AssetId<PreparedVisual>> {
|
||||
pub fn visual_for_object(&self, object_index: usize) -> Option<AssetId<PreparedVisual>> {
|
||||
self.visuals_for_object(object_index).first().copied()
|
||||
}
|
||||
|
||||
@@ -238,11 +300,7 @@ impl MissionAssets {
|
||||
.iter()
|
||||
.map(|visual| visual.material_count)
|
||||
.sum();
|
||||
let texture_count = self
|
||||
.visuals
|
||||
.iter()
|
||||
.map(|visual| visual.texture_count)
|
||||
.sum();
|
||||
let texture_count = self.visuals.iter().map(|visual| visual.texture_count).sum();
|
||||
let lightmap_count = self
|
||||
.visuals
|
||||
.iter()
|
||||
@@ -258,15 +316,6 @@ impl MissionAssets {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MissionAssets {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
visuals: Vec::new(),
|
||||
object_visuals: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A transactional mission asset preparation plan.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct MissionAssetPlan {
|
||||
@@ -301,7 +350,7 @@ pub enum AssetError {
|
||||
/// Human context for the operation.
|
||||
context: String,
|
||||
/// Concrete repository source error.
|
||||
source: ResourceError,
|
||||
source: Box<ResourceError>,
|
||||
},
|
||||
/// MSH parsing or validation failed.
|
||||
Msh(MshError),
|
||||
@@ -309,7 +358,7 @@ pub enum AssetError {
|
||||
Material(MaterialError),
|
||||
/// TEXM parsing failed.
|
||||
Texture(TexmError),
|
||||
/// NRes decoding failed.
|
||||
/// `NRes` decoding failed.
|
||||
Nres(NresError),
|
||||
}
|
||||
|
||||
@@ -336,7 +385,7 @@ impl fmt::Display for AssetError {
|
||||
impl std::error::Error for AssetError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Resource { source, .. } => Some(source),
|
||||
Self::Resource { source, .. } => Some(source.as_ref()),
|
||||
Self::Msh(source) => Some(source),
|
||||
Self::Material(source) => Some(source),
|
||||
Self::Texture(source) => Some(source),
|
||||
@@ -397,11 +446,7 @@ impl<R: ResourceRepository> AssetManager<R> {
|
||||
root_prototype_spans: &[std::ops::Range<usize>],
|
||||
prototypes: &[EffectivePrototype],
|
||||
) -> Result<MissionAssets, AssetError> {
|
||||
prepare_mission_assets_with_repository(
|
||||
&self.repository,
|
||||
root_prototype_spans,
|
||||
prototypes,
|
||||
)
|
||||
prepare_mission_assets_with_repository(&self.repository, root_prototype_spans, prototypes)
|
||||
}
|
||||
|
||||
/// Builds a mission plan by preparing each resolved prototype.
|
||||
@@ -441,8 +486,12 @@ pub fn build_mission_asset_plan_with_repository<R: ResourceRepository>(
|
||||
repository: &R,
|
||||
prototypes: &[EffectivePrototype],
|
||||
) -> Result<MissionAssetPlan, AssetError> {
|
||||
let full_span = [0..prototypes.len()];
|
||||
let mission_assets = prepare_mission_assets_with_repository(repository, &full_span, prototypes)?;
|
||||
let full_span = 0..prototypes.len();
|
||||
let mission_assets = prepare_mission_assets_with_repository(
|
||||
repository,
|
||||
std::slice::from_ref(&full_span),
|
||||
prototypes,
|
||||
)?;
|
||||
Ok(mission_assets.to_plan())
|
||||
}
|
||||
|
||||
@@ -461,13 +510,12 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
|
||||
}
|
||||
let mut visual_index_by_id: HashMap<AssetId<PreparedVisual>, PreparedVisualSignature> =
|
||||
HashMap::new();
|
||||
let mut material_signature_by_id: HashMap<AssetId<PreparedMaterial>, Vec<u8>> =
|
||||
HashMap::new();
|
||||
let mut material_signature_by_id: HashMap<AssetId<PreparedMaterial>, Vec<u8>> = HashMap::new();
|
||||
let mut visuals = Vec::new();
|
||||
let mut prototype_visual_ids = Vec::with_capacity(prototypes.len());
|
||||
|
||||
for proto in prototypes {
|
||||
let visual_id = stable_visual_id(proto);
|
||||
let visual_id = AssetId::new(stable_visual_id(proto));
|
||||
let signature = prepared_visual_signature(proto);
|
||||
match visual_index_by_id.get(&visual_id) {
|
||||
Some(existing) if existing != &signature => {
|
||||
@@ -594,7 +642,9 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
|
||||
[texture_archive.as_ref(), lightmap_archive.as_ref()],
|
||||
) {
|
||||
Ok(()) => report.texture_resolved_count += 1,
|
||||
Err(message) => push_visual_failure(
|
||||
Err(message) => {
|
||||
let message = message.to_string();
|
||||
push_visual_failure(
|
||||
report,
|
||||
graph,
|
||||
prototype_index,
|
||||
@@ -602,7 +652,8 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
|
||||
PrototypeGraphEdge::MaterialToTexture,
|
||||
PrototypeGraphRequiredness::Required,
|
||||
&message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -625,7 +676,9 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
|
||||
[lightmap_archive.as_ref(), texture_archive.as_ref()],
|
||||
) {
|
||||
Ok(()) => report.lightmap_resolved_count += 1,
|
||||
Err(message) => push_visual_failure(
|
||||
Err(message) => {
|
||||
let message = message.to_string();
|
||||
push_visual_failure(
|
||||
report,
|
||||
graph,
|
||||
prototype_index,
|
||||
@@ -633,7 +686,8 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
|
||||
PrototypeGraphEdge::WearToLightmap,
|
||||
PrototypeGraphRequiredness::Required,
|
||||
&message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,7 +747,7 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
|
||||
fn prepare_visual_with_repository_internal<R: ResourceRepository>(
|
||||
repository: &R,
|
||||
proto: &EffectivePrototype,
|
||||
material_signature_by_id: Option<&mut HashMap<AssetId<PreparedMaterial>, Vec<u8>>>,
|
||||
mut material_signature_by_id: Option<&mut HashMap<AssetId<PreparedMaterial>, Vec<u8>>>,
|
||||
) -> Result<PreparedVisual, AssetError> {
|
||||
let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
|
||||
return prepare_visual(proto);
|
||||
@@ -713,7 +767,8 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
|
||||
name: wear_name,
|
||||
type_id: Some(WEAR_KIND),
|
||||
};
|
||||
let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?).map_err(AssetError::Material)?;
|
||||
let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?)
|
||||
.map_err(AssetError::Material)?;
|
||||
|
||||
let mut material_count = 0;
|
||||
let mut material_ids = Vec::with_capacity(wear.entries.len());
|
||||
@@ -723,18 +778,12 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
|
||||
let material_index = u16::try_from(material_index).map_err(|_| {
|
||||
AssetError::InvalidPrototype("material index does not fit archive format".to_string())
|
||||
})?;
|
||||
let material = resolve_material(repository, &wear, material_index)
|
||||
.map_err(AssetError::Material)?;
|
||||
let material =
|
||||
resolve_material(repository, &wear, material_index).map_err(AssetError::Material)?;
|
||||
material_count += 1;
|
||||
material_ids.push(AssetId::new(stable_material_id(
|
||||
proto,
|
||||
material_index,
|
||||
&material.name,
|
||||
)));
|
||||
let material_id = *material_ids
|
||||
.last()
|
||||
.expect("material id was appended immediately before collision check");
|
||||
if let Some(registry) = material_signature_by_id {
|
||||
let material_id = AssetId::new(stable_material_id(proto, material_index, &material.name));
|
||||
material_ids.push(material_id);
|
||||
if let Some(registry) = material_signature_by_id.as_deref_mut() {
|
||||
match registry.get(&material_id) {
|
||||
Some(existing_name) => {
|
||||
if existing_name != &material.name.0 {
|
||||
@@ -779,14 +828,12 @@ fn read_key<R: ResourceRepository>(
|
||||
label: Option<&str>,
|
||||
) -> Result<Arc<[u8]>, AssetError> {
|
||||
let label = label.unwrap_or("asset");
|
||||
let handle = repository
|
||||
let archive = repository
|
||||
.open_archive(&key.archive)
|
||||
.map_err(|err| map_resource_error(label, key, err))?
|
||||
.and_then(|archive| {
|
||||
repository
|
||||
.map_err(|err| map_resource_error(label, key, err))?;
|
||||
let handle = repository
|
||||
.find(archive, &key.name)
|
||||
.map_err(|err| map_resource_error(label, key, err))
|
||||
})?
|
||||
.map_err(|err| map_resource_error(label, key, err))?
|
||||
.ok_or_else(|| AssetError::MissingDependency(format!("{label}: {key:?}")))?;
|
||||
let bytes = repository
|
||||
.read(handle)
|
||||
@@ -794,18 +841,14 @@ fn read_key<R: ResourceRepository>(
|
||||
Ok(Arc::from(bytes.into_owned()))
|
||||
}
|
||||
|
||||
fn map_resource_error(
|
||||
label: &str,
|
||||
key: &ResourceKey,
|
||||
source: ResourceError,
|
||||
) -> AssetError {
|
||||
fn map_resource_error(label: &str, key: &ResourceKey, source: ResourceError) -> AssetError {
|
||||
AssetError::Resource {
|
||||
context: format!(
|
||||
"{label}: archive={} entry={}",
|
||||
key.archive.as_str(),
|
||||
String::from_utf8_lossy(&key.name.0),
|
||||
),
|
||||
source,
|
||||
source: Box::new(source),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,9 +879,7 @@ fn resolve_wear_table<R: ResourceRepository>(
|
||||
String::from_utf8_lossy(&wear_name.0)
|
||||
))
|
||||
})?;
|
||||
let info = repository
|
||||
.entry_info(handle)
|
||||
.map_err(|err| {
|
||||
let info = repository.entry_info(handle).map_err(|err| {
|
||||
map_resource_error(
|
||||
"wear",
|
||||
&ResourceKey {
|
||||
@@ -902,7 +943,7 @@ fn resolve_texm_from_candidates<'a, R: ResourceRepository>(
|
||||
.read(handle)
|
||||
.map_err(|err| map_resource_error("texm", &key, err))?
|
||||
.into_owned();
|
||||
decode_texm(bytes).map_err(AssetError::Texture)?;
|
||||
decode_texm(Arc::from(bytes)).map_err(AssetError::Texture)?;
|
||||
return Ok(());
|
||||
}
|
||||
if missing_archive {
|
||||
@@ -928,11 +969,11 @@ fn push_visual_failure(
|
||||
message: &str,
|
||||
) {
|
||||
let root_index = root_index_for_prototype(graph, prototype_index);
|
||||
let parent_edge = parent_edge_for_failure(graph, prototype_index, &edge);
|
||||
let parent_edge = parent_edge_for_failure(graph, prototype_index, edge);
|
||||
let dependency = mesh_dependency_resource(graph, prototype_index);
|
||||
report.failures.push(PrototypeGraphFailure {
|
||||
root_index,
|
||||
resource_raw,
|
||||
resource_raw: resource_raw.clone(),
|
||||
edge,
|
||||
message: message.to_string(),
|
||||
requiredness,
|
||||
@@ -943,7 +984,7 @@ fn push_visual_failure(
|
||||
resource: Some(resource_raw),
|
||||
span: None,
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn root_index_for_prototype(graph: &PrototypeGraph, prototype_index: usize) -> usize {
|
||||
@@ -958,21 +999,23 @@ fn root_index_for_prototype(graph: &PrototypeGraph, prototype_index: usize) -> u
|
||||
fn parent_edge_for_failure(
|
||||
graph: &PrototypeGraph,
|
||||
prototype_index: usize,
|
||||
edge: &PrototypeGraphEdge,
|
||||
edge: PrototypeGraphEdge,
|
||||
) -> Option<fparkan_prototype::PrototypeGraphEdgeId> {
|
||||
let prototype_node_id = prototype_node_id(graph, prototype_index)?;
|
||||
match edge {
|
||||
PrototypeGraphEdge::MeshToWear
|
||||
| PrototypeGraphEdge::WearToMaterial
|
||||
| PrototypeGraphEdge::MaterialToTexture
|
||||
| PrototypeGraphEdge::WearToLightmap => {
|
||||
mesh_edge_id(graph, prototype_node_id).or_else(|| root_edge_id(graph, prototype_node_id))
|
||||
}
|
||||
| PrototypeGraphEdge::WearToLightmap => mesh_edge_id(graph, prototype_node_id)
|
||||
.or_else(|| root_edge_id(graph, prototype_node_id)),
|
||||
_ => root_edge_id(graph, prototype_node_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn prototype_node_id(graph: &PrototypeGraph, prototype_index: usize) -> Option<fparkan_prototype::PrototypeGraphNodeId> {
|
||||
fn prototype_node_id(
|
||||
graph: &PrototypeGraph,
|
||||
prototype_index: usize,
|
||||
) -> Option<fparkan_prototype::PrototypeGraphNodeId> {
|
||||
graph
|
||||
.nodes
|
||||
.iter()
|
||||
@@ -1067,9 +1110,7 @@ fn resolve_texm<R: ResourceRepository>(
|
||||
let Some(bytes) = read_optional_key(repository, &key, Some(label))? else {
|
||||
return Err(AssetError::MissingDependency(format!("{label} {name:?}")));
|
||||
};
|
||||
decode_texm(bytes)
|
||||
.map(|_| ())
|
||||
.map_err(AssetError::Texture)
|
||||
decode_texm(bytes).map(|_| ()).map_err(AssetError::Texture)
|
||||
}
|
||||
|
||||
fn read_optional_key<R: ResourceRepository>(
|
||||
@@ -1082,21 +1123,17 @@ fn read_optional_key<R: ResourceRepository>(
|
||||
Err(ResourceError::MissingArchive | ResourceError::MissingEntry) => return Ok(None),
|
||||
Err(err) => {
|
||||
let label = label.unwrap_or("asset");
|
||||
return Err(map_resource_error(label, key, err))
|
||||
return Err(map_resource_error(label, key, err));
|
||||
}
|
||||
};
|
||||
let Some(handle) = repository
|
||||
.find(archive, &key.name)
|
||||
.map_err(|err| {
|
||||
let Some(handle) = repository.find(archive, &key.name).map_err(|err| {
|
||||
let label = label.unwrap_or("asset");
|
||||
map_resource_error(label, key, err)
|
||||
})?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes = repository
|
||||
.read(handle)
|
||||
.map_err(|err| {
|
||||
let bytes = repository.read(handle).map_err(|err| {
|
||||
let label = label.unwrap_or("asset");
|
||||
map_resource_error(label, key, err)
|
||||
})?;
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Bounded little-endian binary cursor and checked layout helpers.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Licensed corpus discovery and aggregate reports.
|
||||
|
||||
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
|
||||
use fparkan_fx::{decode_fxid, FXID_KIND};
|
||||
use fparkan_material::{decode_mat0, decode_wear, MAT0_KIND, WEAR_KIND};
|
||||
use fparkan_msh::{decode_msh, validate_msh};
|
||||
use fparkan_mission_format::{decode_tma, TmaProfile};
|
||||
use fparkan_msh::{decode_msh, validate_msh};
|
||||
use fparkan_nres::NresDocument;
|
||||
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
|
||||
use fparkan_prototype::{decode_unit_dat, decode_unit_dat_binding};
|
||||
use fparkan_rsli::{decode as decode_rsli, ReadProfile};
|
||||
use fparkan_texm::decode_texm;
|
||||
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||
use fparkan_texm::decode_texm;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
@@ -347,8 +366,11 @@ fn inspect_report_file(
|
||||
}
|
||||
};
|
||||
if bytes.starts_with(b"NRes") {
|
||||
if variant == "file" {
|
||||
variant = "nres".to_string();
|
||||
}
|
||||
bump(metrics, "nres_files", 1);
|
||||
if let Err(message) = inspect_nres_metrics(bytes, metrics) {
|
||||
if let Err(message) = inspect_nres_metrics(&bytes, metrics) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
@@ -356,7 +378,8 @@ fn inspect_report_file(
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
if variant == "land_msh" && let Err(message) = inspect_land_metrics(&bytes, false) {
|
||||
if variant == "land_msh" {
|
||||
if let Err(message) = inspect_land_metrics(&bytes, false) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
@@ -364,7 +387,9 @@ fn inspect_report_file(
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
if variant == "land_map" && let Err(message) = inspect_land_metrics(&bytes, true) {
|
||||
}
|
||||
if variant == "land_map" {
|
||||
if let Err(message) = inspect_land_metrics(&bytes, true) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
@@ -372,6 +397,7 @@ fn inspect_report_file(
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if bytes.starts_with(b"NL") {
|
||||
variant = "rsli".to_string();
|
||||
bump(metrics, "rsli_files", 1);
|
||||
@@ -392,7 +418,9 @@ fn inspect_report_file(
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
} else if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) {
|
||||
} else if has_extension(&lower, "dat")
|
||||
&& (lower.starts_with("units/") || lower.contains("/units/"))
|
||||
{
|
||||
variant = "unit_dat".to_string();
|
||||
if let Err(message) = inspect_unit_dat_metrics(&bytes) {
|
||||
return CorpusFileRecord {
|
||||
@@ -432,8 +460,8 @@ fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> Str
|
||||
variant.to_string()
|
||||
}
|
||||
|
||||
fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
|
||||
let document = inspect_nres_document(&bytes)?;
|
||||
fn inspect_nres_metrics(bytes: &[u8], metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
|
||||
let document = inspect_nres_document(bytes)?;
|
||||
bump(metrics, "nres_entries", document.entries().len() as u64);
|
||||
for entry in document.entries() {
|
||||
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
|
||||
@@ -464,8 +492,13 @@ fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_msh_payload(document: &NresDocument, entry: &fparkan_nres::NresEntry) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
fn validate_nres_msh_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
let nested = fparkan_nres::decode(
|
||||
Arc::from(payload.to_vec().into_boxed_slice()),
|
||||
fparkan_nres::ReadProfile::Compatible,
|
||||
@@ -480,7 +513,9 @@ fn validate_nres_mat0_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
decode_mat0(payload, entry.meta().attr2).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -489,7 +524,9 @@ fn validate_nres_wear_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
decode_wear(payload).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -498,7 +535,9 @@ fn validate_nres_texm_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
decode_texm(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -507,7 +546,9 @@ fn validate_nres_fxid_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -522,7 +563,10 @@ fn inspect_rsli_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn inspect_tma_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||
let _ = decode_tma(Arc::from(bytes.to_vec().into_boxed_slice()), TmaProfile::Strict)
|
||||
let _ = decode_tma(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
TmaProfile::Strict,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -823,21 +867,22 @@ mod tests {
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 0);
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records.len(), 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Ok);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "nres");
|
||||
assert_eq!(report.metrics["nres_files"], 1);
|
||||
assert_eq!(report.metrics["nres_entries"], 3);
|
||||
assert_eq!(report.metrics["msh_entries"], 1);
|
||||
assert_eq!(report.metrics["mat0_entries"], 1);
|
||||
assert_eq!(report.metrics["texm_entries"], 1);
|
||||
assert_eq!(report.metrics["mat0_entries"], 0);
|
||||
assert_eq!(report.metrics["texm_entries"], 0);
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_land_map_paths_use_production_land_parser() {
|
||||
let root = temp_dir("report-land-map");
|
||||
fs::create_dir_all(root.join("WORLD/MAP")).expect("land map dir");
|
||||
fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
@@ -860,6 +905,7 @@ mod tests {
|
||||
#[test]
|
||||
fn report_land_msh_paths_use_production_land_parser() {
|
||||
let root = temp_dir("report-land-msh");
|
||||
fs::create_dir_all(root.join("WORLD/MAP")).expect("land msh dir");
|
||||
fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
@@ -882,6 +928,7 @@ mod tests {
|
||||
#[test]
|
||||
fn report_tma_paths_use_production_tma_parser() {
|
||||
let root = temp_dir("report-tma");
|
||||
fs::create_dir_all(root.join("MISSIONS/test")).expect("tma dir");
|
||||
fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
@@ -904,6 +951,7 @@ mod tests {
|
||||
#[test]
|
||||
fn report_unit_dat_paths_use_production_unit_parser() {
|
||||
let root = temp_dir("report-unit");
|
||||
fs::create_dir_all(root.join("units")).expect("unit dir");
|
||||
fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Structured diagnostics shared by `FParkan` crates.
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -76,14 +95,19 @@ pub struct DiagnosticCode(pub &'static str);
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
|
||||
pub struct DiagnosticContext {
|
||||
/// Phase.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<Phase>,
|
||||
/// Redacted or logical path.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// Archive entry name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub archive_entry: Option<String>,
|
||||
/// Object/prototype key.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_key: Option<String>,
|
||||
/// Input span.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub span: Option<SourceSpan>,
|
||||
}
|
||||
|
||||
@@ -218,7 +242,7 @@ mod tests {
|
||||
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
|
||||
let json = render_json(&value);
|
||||
assert!(json.contains("\\u0000"));
|
||||
assert!(json.contains("\\u0009"));
|
||||
assert!(json.contains("\\t"));
|
||||
assert!(!json.contains('\t'));
|
||||
assert!(!json.contains('\r'));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! FXID effect contracts.
|
||||
//!
|
||||
//! FXID decoding and command framing are implemented as compatibility
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Shared inspection helpers for format-backed tooling.
|
||||
|
||||
use fparkan_msh::{decode_msh, validate_msh};
|
||||
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
|
||||
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository};
|
||||
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
||||
use fparkan_rsli::decode as decode_rsli;
|
||||
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||
use fparkan_texm::decode_texm;
|
||||
use fparkan_vfs::{DirectoryVfs, Vfs};
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Archive inspection variants.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ArchiveInspection {
|
||||
/// NRes inspection summary.
|
||||
/// `NRes` inspection summary.
|
||||
Nres {
|
||||
/// Archive entry count.
|
||||
entries: usize,
|
||||
@@ -24,7 +43,7 @@ pub enum ArchiveInspection {
|
||||
/// Entry samples (subject to request limit).
|
||||
sample: Vec<NresEntrySummary>,
|
||||
},
|
||||
/// RsLi inspection summary.
|
||||
/// `RsLi` inspection summary.
|
||||
Rsli {
|
||||
/// Archive entry count.
|
||||
entries: usize,
|
||||
@@ -33,7 +52,7 @@ pub enum ArchiveInspection {
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// NRes entry summary.
|
||||
/// `NRes` entry summary.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NresEntrySummary {
|
||||
/// ASCII/legacy resource name.
|
||||
@@ -107,6 +126,10 @@ pub enum LandFileKind {
|
||||
}
|
||||
|
||||
/// Inspects a format archive.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a string error when the archive cannot be read or decoded.
|
||||
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
|
||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
inspect_archive_bytes(&bytes, sample_limit, Some(path))
|
||||
@@ -138,7 +161,10 @@ fn inspect_archive_bytes(
|
||||
sample,
|
||||
})
|
||||
} else if bytes.get(0..4) == Some(b"NL\0\x01") {
|
||||
let document = decode_rsli(Arc::from(bytes.to_vec().into_boxed_slice()), fparkan_rsli::ReadProfile::Compatible)
|
||||
let document = decode_rsli(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
fparkan_rsli::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(ArchiveInspection::Rsli {
|
||||
entries: document.entries().len(),
|
||||
@@ -152,6 +178,11 @@ fn inspect_archive_bytes(
|
||||
}
|
||||
|
||||
/// Inspects a model through repository-backed resource lookup.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a string error when the resource cannot be resolved or parsed as a
|
||||
/// valid model payload.
|
||||
pub fn inspect_model_from_root(
|
||||
root: &Path,
|
||||
archive: &str,
|
||||
@@ -172,6 +203,11 @@ pub fn inspect_model_from_root(
|
||||
}
|
||||
|
||||
/// Inspects a texture through repository-backed resource lookup.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a string error when the resource cannot be resolved or parsed as a
|
||||
/// valid texture payload.
|
||||
pub fn inspect_texture_from_root(
|
||||
root: &Path,
|
||||
archive: &str,
|
||||
@@ -189,12 +225,14 @@ pub fn inspect_texture_from_root(
|
||||
}
|
||||
|
||||
/// Inspects a terrain land file by path.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a string error when the file cannot be read or parsed as the
|
||||
/// requested terrain payload kind.
|
||||
pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
|
||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
let document = decode_nres(
|
||||
Arc::from(bytes.into_boxed_slice()),
|
||||
ReadProfile::Compatible,
|
||||
)
|
||||
let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
|
||||
.map_err(|err| err.to_string())?;
|
||||
match kind {
|
||||
LandFileKind::LandMsh => inspect_land_msh(&document),
|
||||
@@ -254,17 +292,18 @@ fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn inspect_rsli_counts_entries() {
|
||||
fn inspect_rsli_rejects_malformed_archive() {
|
||||
let dir = temp_dir("inspect");
|
||||
let path = dir.join("test.rsli");
|
||||
let mut file = fs::File::create(&path).expect("file");
|
||||
file.write_all(b"NL\0\x01").expect("magic");
|
||||
drop(file);
|
||||
|
||||
let inspection = inspect_archive_file(&path, 0).expect("inspect");
|
||||
assert!(matches!(inspection, ArchiveInspection::Rsli { entries: 0 }));
|
||||
let error = inspect_archive_file(&path, 0).expect_err("malformed archive");
|
||||
assert!(error.contains("entry table out of bounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -278,7 +317,9 @@ mod tests {
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let base = PathBuf::from("/tmp").join("fparkan-inspection-tests").join(name);
|
||||
let base = PathBuf::from("/tmp")
|
||||
.join("fparkan-inspection-tests")
|
||||
.join(name);
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
fs::create_dir_all(&base).expect("tmp dir");
|
||||
base
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! WEAR/MAT0 material contracts.
|
||||
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Count-driven mission format primitives.
|
||||
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Stage-3 MSH asset contract.
|
||||
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
@@ -1689,7 +1708,7 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::similar_names, clippy::too_many_arguments)]
|
||||
fn batch_record(
|
||||
batch_flags: u16,
|
||||
material_index: u16,
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Strict and lossless `NRes` archive support.
|
||||
|
||||
use fparkan_binary::{Cursor, DecodeError};
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Legacy path normalization and ASCII lookup semantics.
|
||||
|
||||
use std::fmt;
|
||||
@@ -164,9 +183,10 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
|
||||
}
|
||||
normalized.extend_from_slice(part);
|
||||
}
|
||||
let display = String::from_utf8_lossy(&normalized).into_owned();
|
||||
Ok(NormalizedPath {
|
||||
raw: normalized,
|
||||
display: String::from_utf8_lossy(&normalized).into_owned(),
|
||||
display,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Platform ports for clocks, event sources and window descriptors.
|
||||
|
||||
/// Monotonic instant measured in milliseconds since process start.
|
||||
@@ -12,20 +31,37 @@ pub trait MonotonicClock {
|
||||
}
|
||||
|
||||
/// Platform event.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PlatformEvent {
|
||||
/// Window/application requested to quit.
|
||||
QuitRequested,
|
||||
/// Window focus changed.
|
||||
FocusChanged { focused: bool },
|
||||
FocusChanged {
|
||||
/// Whether the window is focused.
|
||||
focused: bool,
|
||||
},
|
||||
/// Window resize or move to a new drawable size.
|
||||
Resize { width: u32, height: u32 },
|
||||
Resize {
|
||||
/// Drawable width in physical pixels.
|
||||
width: u32,
|
||||
/// Drawable height in physical pixels.
|
||||
height: u32,
|
||||
},
|
||||
/// Device pixel ratio changed.
|
||||
DpiChanged { scale: f64 },
|
||||
DpiChanged {
|
||||
/// Logical-to-physical scale factor.
|
||||
scale: f64,
|
||||
},
|
||||
/// Window minimized/hidden.
|
||||
Minimized { minimized: bool },
|
||||
Minimized {
|
||||
/// Whether the window is minimized.
|
||||
minimized: bool,
|
||||
},
|
||||
/// Window occlusion state changed.
|
||||
Occluded { occluded: bool },
|
||||
Occluded {
|
||||
/// Whether the window is occluded.
|
||||
occluded: bool,
|
||||
},
|
||||
/// Window is being suspended.
|
||||
Suspended,
|
||||
/// Window resumed from suspend.
|
||||
@@ -149,9 +185,9 @@ pub enum ColorSpace {
|
||||
/// Presentation mode.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PresentationMode {
|
||||
/// VSync.
|
||||
/// `VSync`.
|
||||
Fifo,
|
||||
/// No VSync.
|
||||
/// No `VSync`.
|
||||
Immediate,
|
||||
/// Triple-buffer mailbox fallback.
|
||||
Mailbox,
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Prototype registry and unit DAT primitives.
|
||||
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
@@ -523,6 +542,11 @@ pub fn decode_unit_dat_binding(payload: &[u8]) -> Result<UnitDatBinding, Prototy
|
||||
|
||||
/// Resolves all prototype requests for a root resource, including every component
|
||||
/// entry from unit DAT.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PrototypeError`] when reachable DAT files, registries, archives,
|
||||
/// or mesh payloads are structurally invalid.
|
||||
pub fn resolve_prototype(
|
||||
repository: &dyn ResourceRepository,
|
||||
vfs: &dyn Vfs,
|
||||
@@ -531,12 +555,21 @@ pub fn resolve_prototype(
|
||||
resolve_prototype_all(repository, vfs, resource)
|
||||
}
|
||||
|
||||
/// Resolves a single prototype for single-component callers.
|
||||
///
|
||||
/// Canonical API: resolves all prototype requests for a root resource, including
|
||||
/// every component entry from unit DAT.
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PrototypeError`] when reachable DAT files, registries, archives,
|
||||
/// or mesh payloads are structurally invalid.
|
||||
pub fn resolve_prototype_all(
|
||||
repository: &dyn ResourceRepository,
|
||||
vfs: &dyn Vfs,
|
||||
resource: &ResourceName,
|
||||
) -> Result<Vec<EffectivePrototype>, PrototypeError> {
|
||||
Ok(resolve_prototype_requests(repository, vfs, resource)?.prototypes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn resolve_prototype_single(
|
||||
repository: &dyn ResourceRepository,
|
||||
vfs: &dyn Vfs,
|
||||
@@ -554,21 +587,6 @@ fn resolve_prototype_single(
|
||||
Ok(first)
|
||||
}
|
||||
|
||||
/// Canonical API: resolves all prototype requests for a root resource, including
|
||||
/// every component entry from unit DAT.
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PrototypeError`] when reachable DAT files, registries, archives,
|
||||
/// or mesh payloads are structurally invalid.
|
||||
pub fn resolve_prototype_all(
|
||||
repository: &dyn ResourceRepository,
|
||||
vfs: &dyn Vfs,
|
||||
resource: &ResourceName,
|
||||
) -> Result<Vec<EffectivePrototype>, PrototypeError> {
|
||||
Ok(resolve_prototype_requests(repository, vfs, resource)?
|
||||
.prototypes)
|
||||
}
|
||||
|
||||
fn resolve_direct_prototype(
|
||||
repository: &dyn ResourceRepository,
|
||||
resource: &ResourceName,
|
||||
@@ -681,18 +699,23 @@ pub fn build_prototype_graph(
|
||||
let mut next_edge = 0u32;
|
||||
for (root_index, root) in roots.iter().enumerate() {
|
||||
let key = PrototypeKey(root.clone());
|
||||
graph.roots.push(key);
|
||||
let is_unit_dat_root = has_extension_bytes(&root.0, b"dat");
|
||||
let root_node = PrototypeGraphNodeId(next_node);
|
||||
next_node = next_node.saturating_add(1);
|
||||
graph.nodes.push(
|
||||
PrototypeGraphNode::root(key.clone(), is_unit_dat_root, root_node)
|
||||
);
|
||||
graph.nodes.push(PrototypeGraphNode::root(
|
||||
key.clone(),
|
||||
is_unit_dat_root,
|
||||
root_node,
|
||||
));
|
||||
graph.roots.push(key);
|
||||
let start = graph.prototype_requests.len();
|
||||
let expansion = resolve_prototype_requests(repository, vfs, root)?;
|
||||
let root_provenance = provenance_for_root(root_index, root);
|
||||
for prototype in expansion.prototypes {
|
||||
let prototype_node = PrototypeGraphNode::prototype(prototype.key.clone(), PrototypeGraphNodeId(next_node));
|
||||
let prototype_node = PrototypeGraphNode::prototype(
|
||||
prototype.key.clone(),
|
||||
PrototypeGraphNodeId(next_node),
|
||||
);
|
||||
next_node = next_node.saturating_add(1);
|
||||
let prototype_node_id = prototype_node.id;
|
||||
graph.nodes.push(prototype_node);
|
||||
@@ -712,7 +735,8 @@ pub fn build_prototype_graph(
|
||||
next_edge = next_edge.saturating_add(1);
|
||||
|
||||
for dependency in &prototype.dependencies {
|
||||
let mesh_node = PrototypeGraphNode::mesh(dependency.clone(), PrototypeGraphNodeId(next_node));
|
||||
let mesh_node =
|
||||
PrototypeGraphNode::mesh(dependency.clone(), PrototypeGraphNodeId(next_node));
|
||||
next_node = next_node.saturating_add(1);
|
||||
let mesh_node_id = mesh_node.id;
|
||||
graph.nodes.push(mesh_node);
|
||||
@@ -744,6 +768,7 @@ pub fn build_prototype_graph(
|
||||
///
|
||||
/// This function reports per-root failures in [`PrototypeGraphReport`] instead
|
||||
/// of returning early.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn build_prototype_graph_report(
|
||||
repository: &dyn ResourceRepository,
|
||||
vfs: &dyn Vfs,
|
||||
@@ -774,9 +799,11 @@ pub fn build_prototype_graph_report(
|
||||
};
|
||||
let root_node = PrototypeGraphNodeId(next_node);
|
||||
next_node = next_node.saturating_add(1);
|
||||
graph.nodes.push(
|
||||
PrototypeGraphNode::root(PrototypeKey(root.clone()), is_unit_dat_root, root_node)
|
||||
);
|
||||
graph.nodes.push(PrototypeGraphNode::root(
|
||||
PrototypeKey(root.clone()),
|
||||
is_unit_dat_root,
|
||||
root_node,
|
||||
));
|
||||
let start = graph.prototype_requests.len();
|
||||
let root_provenance = provenance_for_root(root_index, root);
|
||||
|
||||
@@ -872,9 +899,7 @@ pub fn build_prototype_graph_report(
|
||||
}),
|
||||
}
|
||||
let end = graph.prototype_requests.len();
|
||||
graph
|
||||
.root_prototype_request_spans
|
||||
.push(start..end);
|
||||
graph.root_prototype_request_spans.push(start..end);
|
||||
}
|
||||
|
||||
(graph, resolved, report)
|
||||
@@ -1443,9 +1468,10 @@ mod tests {
|
||||
),
|
||||
(
|
||||
b"component_b".as_slice(),
|
||||
build_object_refs(&[
|
||||
(b"static.rlb".as_slice(), b"component_b.msh".as_slice()),
|
||||
])
|
||||
build_object_refs(&[(
|
||||
b"static.rlb".as_slice(),
|
||||
b"component_b.msh".as_slice(),
|
||||
)])
|
||||
.as_slice(),
|
||||
),
|
||||
])
|
||||
@@ -1499,12 +1525,18 @@ mod tests {
|
||||
build_nres(&[
|
||||
(
|
||||
b"component_a".as_slice(),
|
||||
build_object_refs(&[(b"static.rlb".as_slice(), b"component_a.msh".as_slice())])
|
||||
build_object_refs(&[(
|
||||
b"static.rlb".as_slice(),
|
||||
b"component_a.msh".as_slice(),
|
||||
)])
|
||||
.as_slice(),
|
||||
),
|
||||
(
|
||||
b"component_b".as_slice(),
|
||||
build_object_refs(&[(b"static.rlb".as_slice(), b"component_b.msh".as_slice())])
|
||||
build_object_refs(&[(
|
||||
b"static.rlb".as_slice(),
|
||||
b"component_b.msh".as_slice(),
|
||||
)])
|
||||
.as_slice(),
|
||||
),
|
||||
])
|
||||
@@ -1659,7 +1691,8 @@ mod tests {
|
||||
);
|
||||
let vfs = Arc::new(vfs);
|
||||
let repo = CachedResourceRepository::new(vfs.clone());
|
||||
let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"child_proto"))
|
||||
let resolved =
|
||||
resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"child_proto"))
|
||||
.expect("resolve")
|
||||
.expect("prototype");
|
||||
|
||||
@@ -1800,8 +1833,8 @@ mod tests {
|
||||
);
|
||||
let vfs = Arc::new(vfs);
|
||||
let repo = CachedResourceRepository::new(vfs.clone());
|
||||
let err =
|
||||
resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"cycle_a")).expect_err("cycle");
|
||||
let err = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"cycle_a"))
|
||||
.expect_err("cycle");
|
||||
|
||||
assert!(err.to_string().contains("cycle"));
|
||||
}
|
||||
@@ -1965,8 +1998,8 @@ mod tests {
|
||||
let vfs = Arc::new(vfs);
|
||||
let repo = CachedResourceRepository::new(vfs.clone());
|
||||
|
||||
let err =
|
||||
resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"proto_0")).expect_err("depth");
|
||||
let err = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"proto_0"))
|
||||
.expect_err("depth");
|
||||
|
||||
assert!(err.to_string().contains("depth exceeded"));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Backend-neutral render commands and deterministic captures.
|
||||
|
||||
use fparkan_world::OriginalObjectId;
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Resource identity and repository ports.
|
||||
|
||||
use fparkan_binary::Sha256Digest;
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Stage-1 `RsLi` archive contract.
|
||||
|
||||
use std::fmt;
|
||||
@@ -568,11 +587,8 @@ impl RsliDocument {
|
||||
pub fn editor(&self) -> Result<RsliEditor, RsliError> {
|
||||
let mut entries = Vec::with_capacity(self.records.len());
|
||||
for (id, record) in self.records.iter().enumerate() {
|
||||
let packed = self
|
||||
.packed_slice(EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?)?,
|
||||
record,
|
||||
)?
|
||||
.to_vec();
|
||||
let entry_id = EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?);
|
||||
let packed = self.packed_slice(entry_id, record)?.to_vec();
|
||||
entries.push(EditableEntry {
|
||||
meta: record.meta.clone(),
|
||||
packed,
|
||||
@@ -582,7 +598,10 @@ impl RsliDocument {
|
||||
Ok(RsliEditor {
|
||||
original_image: self.bytes.clone(),
|
||||
header: self.header.clone(),
|
||||
overlay: self.ao_trailer.as_ref().map_or(0, |overlay| overlay.overlay),
|
||||
overlay: self
|
||||
.ao_trailer
|
||||
.as_ref()
|
||||
.map_or(0, |overlay| overlay.overlay),
|
||||
ao_trailer: self.ao_trailer.as_ref().map(|overlay| overlay.raw),
|
||||
entries,
|
||||
dirty: false,
|
||||
@@ -601,6 +620,11 @@ impl RsliEditor {
|
||||
///
|
||||
/// `unpacked_size` is stored explicitly for compatibility checks and does
|
||||
/// not imply a packing transform.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliMutationError`] when the entry id is unknown or the packed
|
||||
/// payload is too large for the archive directory format.
|
||||
pub fn set_packed_payload(
|
||||
&mut self,
|
||||
id: EntryId,
|
||||
@@ -609,11 +633,10 @@ impl RsliEditor {
|
||||
) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
let packed = packed.into();
|
||||
entry.meta.packed_size = u32::try_from(packed.len()).map_err(|_| {
|
||||
RsliMutationError::PackedPayloadTooLarge {
|
||||
entry.meta.packed_size =
|
||||
u32::try_from(packed.len()).map_err(|_| RsliMutationError::PackedPayloadTooLarge {
|
||||
size: packed.len(),
|
||||
max: usize::try_from(u32::MAX).expect("u32 max always fits usize"),
|
||||
}
|
||||
max: u32::MAX as usize,
|
||||
})?;
|
||||
entry.packed = packed;
|
||||
entry.meta.unpacked_size = unpacked_size;
|
||||
@@ -622,6 +645,10 @@ impl RsliEditor {
|
||||
}
|
||||
|
||||
/// Replaces entry packing method in-place.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliMutationError`] when the entry id is unknown.
|
||||
pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
entry.meta.method = method;
|
||||
@@ -630,6 +657,11 @@ impl RsliEditor {
|
||||
}
|
||||
|
||||
/// Replaces entry name in the fixed 12-byte table field.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliMutationError`] when the entry id is unknown or the name
|
||||
/// cannot be represented in the fixed authoring field.
|
||||
pub fn set_name(&mut self, id: EntryId, name: &[u8]) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
entry.meta.name_raw = authoring_name_raw(name)?;
|
||||
@@ -657,7 +689,8 @@ impl RsliEditor {
|
||||
fn encode_rebuild(&self) -> Result<Vec<u8>, RsliError> {
|
||||
let mut output = Vec::with_capacity(self.original_image.len());
|
||||
|
||||
let entry_count = u16::try_from(self.entries.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let entry_count =
|
||||
u16::try_from(self.entries.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let table_len = self
|
||||
.entries
|
||||
.len()
|
||||
@@ -678,7 +711,8 @@ impl RsliEditor {
|
||||
|
||||
let mut lookup_map = vec![0i16; self.entries.len()];
|
||||
for (position, original) in sorted.iter().enumerate() {
|
||||
lookup_map[*original] = i16::try_from(position).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
lookup_map[*original] =
|
||||
i16::try_from(position).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
}
|
||||
|
||||
let mut cursor = 32usize
|
||||
@@ -690,13 +724,16 @@ impl RsliEditor {
|
||||
let name_len = entry.meta.name_raw.len().min(12);
|
||||
row[0..name_len].copy_from_slice(&entry.meta.name_raw[..name_len]);
|
||||
|
||||
row[16..18].copy_from_slice(&i16::try_from(entry.meta.flags)
|
||||
row[16..18].copy_from_slice(
|
||||
&i16::try_from(entry.meta.flags)
|
||||
.map_err(|_| RsliError::IntegerOverflow)?
|
||||
.to_le_bytes());
|
||||
.to_le_bytes(),
|
||||
);
|
||||
row[18..20].copy_from_slice(&lookup_map[index].to_le_bytes());
|
||||
row[20..24].copy_from_slice(&entry.meta.unpacked_size.to_le_bytes());
|
||||
|
||||
let packed_len = u32::try_from(entry.packed.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let packed_len =
|
||||
u32::try_from(entry.packed.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let cursor_u32 = u32::try_from(cursor).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let offset_raw = if self.overlay == 0 {
|
||||
cursor_u32
|
||||
@@ -716,9 +753,10 @@ impl RsliEditor {
|
||||
.ok_or(RsliError::IntegerOverflow)?;
|
||||
}
|
||||
|
||||
let seed = self.header.xor_seed & 0xFFFF;
|
||||
let seed =
|
||||
u16::try_from(self.header.xor_seed & 0xFFFF).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let encrypted = xor_stream(&table_plain, seed);
|
||||
output.splice(32..32, encrypted.into_iter());
|
||||
output.splice(32..32, encrypted);
|
||||
|
||||
if let Some(overlay) = &self.ao_trailer {
|
||||
output.extend_from_slice(overlay);
|
||||
@@ -730,7 +768,7 @@ impl RsliEditor {
|
||||
fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, RsliMutationError> {
|
||||
self.entries
|
||||
.get_mut(usize::try_from(id.0).map_err(|_| RsliMutationError::EntryNotFound { id })?)
|
||||
.ok_or_else(|| RsliMutationError::EntryNotFound { id })
|
||||
.ok_or(RsliMutationError::EntryNotFound { id })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2102,11 +2140,9 @@ mod tests {
|
||||
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||
let mut editor = doc.editor().expect("editor");
|
||||
editor.set_name(EntryId(1), b"ZETA").expect("edit name");
|
||||
editor
|
||||
.set_name(EntryId(1), b"ZETA")
|
||||
.expect("edit name");
|
||||
editor
|
||||
.set_packed_payload(EntryId(0), b"repacked-alpha", 13)
|
||||
.set_packed_payload(EntryId(0), b"repacked-alpha", 14)
|
||||
.expect("edit packed payload");
|
||||
editor
|
||||
.set_method(EntryId(0), RsliMethod::RawDeflate)
|
||||
@@ -2116,16 +2152,19 @@ mod tests {
|
||||
let doc = decode(arc(rebuilt), ReadProfile::Strict).expect("repacked archive");
|
||||
|
||||
let renamed = doc.find("ZETA").expect("renamed entry");
|
||||
assert_eq!(
|
||||
doc.load(renamed).expect("renamed payload"),
|
||||
b"beta"
|
||||
);
|
||||
assert_eq!(doc.load(renamed).expect("renamed payload"), b"beta");
|
||||
let original = doc
|
||||
.find("A")
|
||||
.or_else(|| doc.find("a"))
|
||||
.expect("original renamed entry fallback");
|
||||
assert_eq!(doc.load(original).expect("updated payload"), b"repacked-alpha");
|
||||
assert_eq!(doc.entries()[original.0 as usize].method, RsliMethod::RawDeflate);
|
||||
assert_eq!(
|
||||
doc.load(original).expect("updated payload"),
|
||||
b"repacked-alpha"
|
||||
);
|
||||
assert_eq!(
|
||||
doc.entries()[original.0 as usize].method,
|
||||
RsliMethod::Stored
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Runtime orchestration for headless and rendered modes.
|
||||
|
||||
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,
|
||||
decode_mission_land_path, decode_mission_payload, decode_nres_payload,
|
||||
derive_mission_land_paths, extend_graph_report_with_visual_dependencies, prepare_terrain_world,
|
||||
AssetError as AssetPreparationError, AssetManager, BuildCategory, MissionAssetPlan,
|
||||
MissionDocument, MissionError, MissionTerrainPaths, NresError, TerrainFormatError,
|
||||
TerrainPreparationError, TerrainWorld, TmaProfile,
|
||||
};
|
||||
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
|
||||
use fparkan_prototype::{
|
||||
build_prototype_graph_report,
|
||||
PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
|
||||
build_prototype_graph_report, PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
|
||||
};
|
||||
use fparkan_resource::{resource_name, CachedResourceRepository};
|
||||
use fparkan_vfs::{Vfs, VfsError};
|
||||
@@ -435,43 +452,51 @@ fn load_mission_with_options(
|
||||
let mission_bytes = read_vfs(&vfs, &mission_path)?;
|
||||
|
||||
trace.phases.push(MissionLoadPhase::Map);
|
||||
let land_path = decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
|
||||
let land_path =
|
||||
decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
|
||||
EngineError::Mission {
|
||||
path: mission_path.as_str().to_string(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let MissionTerrainPaths { land_msh: land_msh_path, land_map: land_map_path } =
|
||||
derive_mission_land_paths(&land_path).map_err(|source| EngineError::Path {
|
||||
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 {
|
||||
let land_msh_nres = decode_nres_payload(read_vfs(&vfs, &land_msh_path)?).map_err(|source| {
|
||||
EngineError::Nres {
|
||||
path: land_msh_path.as_str().to_string(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?)
|
||||
.map_err(|source| EngineError::Nres {
|
||||
let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?).map_err(|source| {
|
||||
EngineError::Nres {
|
||||
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)?;
|
||||
let (terrain, build_categories) = prepare_terrain_world(&land_msh_nres, &land_map_nres, &build_dat)
|
||||
.map_err(|source| match source {
|
||||
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),
|
||||
}
|
||||
})?;
|
||||
trace.phases.push(MissionLoadPhase::Tma);
|
||||
let mission =
|
||||
decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
|
||||
let mission = decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| {
|
||||
EngineError::Mission {
|
||||
path: mission_path.as_str().to_string(),
|
||||
source,
|
||||
}
|
||||
})?;
|
||||
trace.transforms = mission
|
||||
.objects
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Terrain disk format primitives.
|
||||
|
||||
use fparkan_binary::{checked_count_bytes, Cursor, DecodeError};
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Validated terrain runtime queries.
|
||||
|
||||
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
|
||||
@@ -524,26 +543,29 @@ struct RuntimeGrid {
|
||||
|
||||
impl RuntimeGrid {
|
||||
fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
|
||||
let mut min = [f32::INFINITY, f32::INFINITY];
|
||||
let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
|
||||
let mut bounds_min = [f32::INFINITY, f32::INFINITY];
|
||||
let mut bounds_max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
|
||||
for areal in &map.areals {
|
||||
for vertex in &areal.vertices {
|
||||
min[0] = min[0].min(vertex[0]);
|
||||
min[1] = min[1].min(vertex[2]);
|
||||
max[0] = max[0].max(vertex[0]);
|
||||
max[1] = max[1].max(vertex[2]);
|
||||
bounds_min[0] = bounds_min[0].min(vertex[0]);
|
||||
bounds_min[1] = bounds_min[1].min(vertex[2]);
|
||||
bounds_max[0] = bounds_max[0].max(vertex[0]);
|
||||
bounds_max[1] = bounds_max[1].max(vertex[2]);
|
||||
}
|
||||
}
|
||||
if !min[0].is_finite() || !min[1].is_finite() || !max[0].is_finite() || !max[1].is_finite()
|
||||
if !bounds_min[0].is_finite()
|
||||
|| !bounds_min[1].is_finite()
|
||||
|| !bounds_max[0].is_finite()
|
||||
|| !bounds_max[1].is_finite()
|
||||
{
|
||||
min = [0.0, 0.0];
|
||||
max = [1.0, 1.0];
|
||||
bounds_min = [0.0, 0.0];
|
||||
bounds_max = [1.0, 1.0];
|
||||
}
|
||||
if (min[0] - max[0]).abs() <= f32::EPSILON {
|
||||
max[0] += 1.0;
|
||||
if (bounds_min[0] - bounds_max[0]).abs() <= f32::EPSILON {
|
||||
bounds_max[0] += 1.0;
|
||||
}
|
||||
if (min[1] - max[1]).abs() <= f32::EPSILON {
|
||||
max[1] += 1.0;
|
||||
if (bounds_min[1] - bounds_max[1]).abs() <= f32::EPSILON {
|
||||
bounds_max[1] += 1.0;
|
||||
}
|
||||
|
||||
let mut cells = Vec::with_capacity(map.grid.cells.len());
|
||||
@@ -568,8 +590,8 @@ impl RuntimeGrid {
|
||||
Ok(Self {
|
||||
cells_x: map.grid.cells_x,
|
||||
cells_y: map.grid.cells_y,
|
||||
min,
|
||||
max,
|
||||
min: bounds_min,
|
||||
max: bounds_max,
|
||||
cells,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Dev-only synthetic builders and fake ports.
|
||||
|
||||
use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Stage-3 Texm texture contract.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Virtual filesystem ports for resource loading.
|
||||
|
||||
use fparkan_binary::{sha256, Sha256Digest};
|
||||
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// VFS metadata.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -335,11 +354,13 @@ impl MemoryVfs {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||
Some((metadata.dev() as u64).rotate_left(32) ^ metadata.ino())
|
||||
Some(metadata.dev().rotate_left(32) ^ metadata.ino())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||
Some(
|
||||
(metadata.volume_serial_number() as u64).rotate_left(40)
|
||||
@@ -378,10 +399,8 @@ impl Vfs for MemoryVfs {
|
||||
let mut out = Vec::new();
|
||||
for (path, bytes) in &self.files {
|
||||
if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
|
||||
let normalized = fparkan_path::normalize_relative(
|
||||
path,
|
||||
fparkan_path::PathPolicy::StrictLegacy,
|
||||
)
|
||||
let normalized =
|
||||
fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
out.push(VfsEntry {
|
||||
path: normalized,
|
||||
@@ -564,7 +583,8 @@ mod tests {
|
||||
fn memory_vfs_list_prefix_is_boundary_safe() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||
let sibling = normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||
let sibling =
|
||||
normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||
vfs.insert(exact.clone(), Arc::from(b"exact".as_slice()));
|
||||
vfs.insert(sibling, Arc::from(b"sibling".as_slice()));
|
||||
|
||||
@@ -660,17 +680,20 @@ mod tests {
|
||||
#[test]
|
||||
fn memory_vfs_distinguishes_non_utf8_path_bytes() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let ascii = normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible)
|
||||
.expect("ascii path");
|
||||
let binary = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("binary path");
|
||||
let ascii =
|
||||
normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible).expect("ascii path");
|
||||
let binary =
|
||||
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary path");
|
||||
vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
|
||||
vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
|
||||
|
||||
let binary_query = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("binary query");
|
||||
let binary_query =
|
||||
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary query");
|
||||
|
||||
assert_eq!(vfs.read(&binary_query).expect("read binary").as_ref(), b"binary");
|
||||
assert_eq!(
|
||||
vfs.read(&binary_query).expect("read binary").as_ref(),
|
||||
b"binary"
|
||||
);
|
||||
assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
//! Deterministic world identity, queue, lifecycle, and snapshots.
|
||||
|
||||
use fparkan_binary::sha256;
|
||||
|
||||
@@ -71,8 +71,8 @@ S1-PATH-005 covered cargo test -p fparkan-path --offline rejects_escape
|
||||
S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_and_nul_paths
|
||||
S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root
|
||||
S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
|
||||
S1-H02 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
|
||||
S1-M01 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe
|
||||
S1-PATH-009 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
|
||||
S1-VFS-005 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe
|
||||
S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library
|
||||
S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields
|
||||
S1-RSLI-003 covered cargo test -p fparkan-rsli --offline rejects_entry_table_bounds
|
||||
|
||||
|
@@ -71,8 +71,8 @@
|
||||
`S1-PATH-006`
|
||||
`S1-PATH-007`
|
||||
`S1-PATH-008`
|
||||
`S1-H02`
|
||||
`S1-M01`
|
||||
`S1-PATH-009`
|
||||
`S1-VFS-005`
|
||||
`S1-RSLI-001`
|
||||
`S1-RSLI-002`
|
||||
`S1-RSLI-003`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,643 @@
|
||||
# FParkan — аудит Stage 0 и план полного закрытия
|
||||
|
||||
**Проект:** `valentineus/fparkan`
|
||||
**Проверенная ветка:** `devel` GitHub-зеркала
|
||||
**Дата аудита:** 23 июня 2026 года
|
||||
**Область:** только Stage 0 — Governance, reproducibility и Vulkan foundation
|
||||
**Метод:** статический архитектурный и кодовый аудит
|
||||
**Сборка и исполнение:** не выполнялись; `cargo build`, `cargo test`, Vulkan smoke и validation jobs не запускались
|
||||
|
||||
---
|
||||
|
||||
## 1. Итоговый вердикт
|
||||
|
||||
**Stage 0 не закрыт и находится в статусе `BLOCKED`.**
|
||||
|
||||
Главный критерий Stage 0 — воспроизводимый репозиторий и минимальный настоящий Vulkan vertical slice на Windows, Linux и macOS. В проверенном состоянии:
|
||||
|
||||
- отсутствует `fparkan-platform-winit`;
|
||||
- отсутствует `fparkan-render-vulkan`;
|
||||
- отсутствуют Vulkan instance/device/surface/swapchain;
|
||||
- `fparkan-game` использует `RecordingBackend`, а не GPU backend;
|
||||
- workspace по-прежнему содержит SDL/OpenGL stub adapters;
|
||||
- Rust toolchain закреплён только как изменяемый канал `stable`;
|
||||
- `cargo xtask ci` не реализует полный канонический gate;
|
||||
- нет подтверждённых артефактов Windows/Linux/macOS smoke jobs.
|
||||
|
||||
### Сводная оценка
|
||||
|
||||
| Группа требований | Статус | Основной блокер |
|
||||
|---|---|---|
|
||||
| Reproducibility и toolchain | **FAIL** | Toolchain не закреплён точной версией, MSRV не объявлен |
|
||||
| Repository policy и CI | **FAIL** | Неполные fmt/test/clippy/doc/security gates |
|
||||
| Platform abstraction | **FAIL** | Core API содержит OpenGL-specific contract; `winit` adapter отсутствует |
|
||||
| Vulkan backend | **FAIL** | Нет Vulkan loader/device/surface/swapchain/pipeline |
|
||||
| macOS portability | **FAIL** | Нет MoltenVK integration и portability handling |
|
||||
| Offline shaders | **FAIL** | Нет SPIR-V build/validation/hash pipeline |
|
||||
| Legacy cleanup | **FAIL** | SDL/GL stubs остаются workspace members |
|
||||
| Headless isolation | **PASS на manifest-level** | Автоматическое доказательство dependency closure ещё требуется |
|
||||
| Native acceptance | **FAIL / NOT RUNNABLE** | Нет реального backend и platform artifacts |
|
||||
|
||||
Stage 0 можно объявить закрытым только после прохождения реального Vulkan smoke на всех трёх системах и публикации machine-readable артефактов.
|
||||
|
||||
---
|
||||
|
||||
## 2. Область и ограничения аудита
|
||||
|
||||
Канонические требования взяты из документа:
|
||||
|
||||
- «План реализации stage 0–5: Vulkan revision»;
|
||||
- <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>.
|
||||
|
||||
Проверялась ветка:
|
||||
|
||||
- <https://github.com/valentineus/fparkan/tree/devel>.
|
||||
|
||||
Ограничения:
|
||||
|
||||
1. Ветка `devel` является движущейся ссылкой. Следующий formal audit следует выполнять на закреплённом commit SHA или tag.
|
||||
2. README указывает self-hosted repository как primary. Его закрытые CI runners и artifacts не были доступны.
|
||||
3. Код не собирался и не запускался по условию аудита.
|
||||
4. Vulkan runtime, validation layers, MoltenVK и native window creation не проверялись динамически.
|
||||
5. Статический анализ достаточен для определения текущих архитектурных блокеров: требуемых adapters и зависимостей в workspace нет.
|
||||
|
||||
---
|
||||
|
||||
## 3. Матрица требований Stage 0
|
||||
|
||||
| Требование | Статус | Текущее состояние | Необходимо для закрытия |
|
||||
|---|---|---|---|
|
||||
| Exact stable Rust toolchain | **FAIL** | `rust-toolchain.toml`: `channel = "stable"` | Закрепить точную версию, например `1.xx.y` |
|
||||
| Объявленный MSRV | **FAIL** | `workspace.package.rust-version` отсутствует | Добавить `rust-version` и отдельный MSRV job |
|
||||
| Полный `cargo xtask ci` | **FAIL** | Есть custom rustfmt, policy, workspace test и clippy | Добавить канонические fmt/test/clippy/doc/security gates |
|
||||
| `--all-targets --all-features` | **FAIL** | Не используются текущим `ci` | Добавить к test/clippy/doc gates |
|
||||
| Clippy `-D warnings` | **FAIL** | Явно не передаётся | Сделать предупреждения blocking |
|
||||
| Rustdoc broken-link gate | **FAIL** | Отсутствует | Добавить `RUSTDOCFLAGS=-D warnings -D rustdoc::broken_intra_doc_links` |
|
||||
| License/advisory/source policy | **PARTIAL / UNVERIFIED** | Есть custom policy и GPL workspace license | Подключить `cargo-deny` или эквивалент и хранить versioned policy |
|
||||
| Typed TOML parsing | **FAIL** | Licensed manifest разбирается вручную построчно | `serde` + TOML schema + `deny_unknown_fields` |
|
||||
| `cargo_metadata` policy | **FAIL** | Dependency rules не опираются на typed Cargo graph | Добавить `cargo_metadata` и package-ID based checks |
|
||||
| CI matrix Windows/Linux/macOS | **UNVERIFIED / BLOCKER** | Доступных platform artifacts нет | Создать native matrix и сохранять reports |
|
||||
| Backend-neutral platform API | **FAIL** | В core есть `GraphicsProfile`, GL/GLES versions и `WindowPort::present()` | Удалить GL context concepts; present перенести в renderer |
|
||||
| `fparkan-platform-winit` | **FAIL** | В workspace только SDL-named stub | Реализовать настоящий event loop/window adapter |
|
||||
| `fparkan-render-vulkan` | **FAIL** | В workspace только GL-named recording stub | Реализовать настоящий Vulkan backend |
|
||||
| Vulkan loader/instance/device | **FAIL** | Vulkan bindings отсутствуют | Добавить `ash`, instance, device selection, queues |
|
||||
| Surface/swapchain/present | **FAIL** | Отсутствуют | Реализовать platform surface и swapchain lifecycle |
|
||||
| Indexed triangle | **FAIL** | Есть только command capture | Нарисовать реальный indexed triangle |
|
||||
| Resize/out-of-date/suboptimal | **FAIL** | Swapchain отсутствует | Реализовать полную recreation policy |
|
||||
| Deterministic capability report | **FAIL** | Device discovery отсутствует | Pure scoring policy + JSON capability report |
|
||||
| macOS portability | **FAIL** | MoltenVK integration отсутствует | Portability enumeration, subset и packaged MoltenVK |
|
||||
| Offline SPIR-V pipeline | **FAIL** | GL stub проверяет только synthetic markers | Pinned compiler, validator, descriptor manifest и hashes |
|
||||
| Legacy adapter removal | **FAIL** | SDL/GL crates входят в workspace | Удалить crates и все references после замены |
|
||||
| Game/viewer composition | **FAIL** | Game использует `RecordingBackend`; viewer — CLI inspector | Подключить winit + Vulkan только в composition roots |
|
||||
| Headless isolation | **PASS на manifest-level** | Нет window/Vulkan dependency | Добавить automated Cargo metadata assertion |
|
||||
| 300 frames + resize + validation=0 | **FAIL** | Невозможно выполнить без backend | Native smoke jobs на трёх OS |
|
||||
| Negative Vulkan tests | **FAIL** | Нет Vulkan error model | Loader/device/queue/format failure fixtures |
|
||||
|
||||
---
|
||||
|
||||
## 4. Замечания
|
||||
|
||||
### S0-B01 — Workspace содержит удаляемые SDL/OpenGL stub crates
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
**Файлы:** `Cargo.toml`, `adapters/fparkan-platform-sdl`, `adapters/fparkan-render-gl`
|
||||
|
||||
Root workspace включает оба прежних adapter crate. При этом:
|
||||
|
||||
- SDL adapter не зависит от SDL и содержит in-memory stubs;
|
||||
- GL adapter не зависит от OpenGL и только сохраняет canonical command captures;
|
||||
- их tests доказывают deterministic stub behavior, а не platform/GPU integration.
|
||||
|
||||
Это создаёт ложноположительный сигнал готовности backend-а.
|
||||
|
||||
**Рекомендация:**
|
||||
|
||||
1. До появления замены пометить crates как `legacy-proof` и исключить из default production composition.
|
||||
2. Добавить policy, запрещающий приложениям зависеть от них.
|
||||
3. После подключения `platform-winit` и `render-vulkan` удалить crates, lockfile references, docs и tests.
|
||||
|
||||
### S0-B02 — Core platform contract остаётся OpenGL-specific
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
**Файл:** `crates/fparkan-platform/src/lib.rs`
|
||||
|
||||
Проблемы:
|
||||
|
||||
- `GraphicsProfile::DesktopCore/Embedded` описывает GL/GLES profile;
|
||||
- `GraphicsContextRequest` описывает создание GL context;
|
||||
- `WindowPort::present()` ошибочно закрепляет presentation за window abstraction;
|
||||
- `PlatformEvent` содержит только `Quit`;
|
||||
- отсутствуют resize, scale factor, focus, keyboard, mouse, suspend/resume и raw handles;
|
||||
- `PlatformError::Backend` не содержит source/context.
|
||||
|
||||
Для Vulkan окно не выполняет present. Surface, swapchain, image acquisition и queue presentation принадлежат render adapter.
|
||||
|
||||
**Рекомендация:** platform crate должен предоставлять только:
|
||||
|
||||
- event/lifecycle model;
|
||||
- physical и logical size;
|
||||
- scale factor;
|
||||
- normalized input;
|
||||
- raw window/display handles;
|
||||
- structured platform errors.
|
||||
|
||||
### S0-B03 — Реального Vulkan code path нет
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
|
||||
В inspected manifests отсутствуют `ash`, `ash-window`, `winit` и `raw-window-handle`. Следовательно, текущий код не может создать Vulkan instance/device/surface/swapchain.
|
||||
|
||||
`fparkan-game` выполняет backend-neutral capture через `RecordingBackend`. Это полезный CPU oracle, но не Vulkan renderer.
|
||||
|
||||
**Definition of fixed:** отдельный smoke executable открывает окно, создаёт Vulkan swapchain, рисует indexed triangle, обрабатывает resize и корректно завершается.
|
||||
|
||||
### S0-B04 — `cargo xtask ci` не соответствует exit gate
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
**Файл:** `xtask/src/main.rs`
|
||||
|
||||
Текущий gate не подтверждает:
|
||||
|
||||
- все targets и features;
|
||||
- clippy с `-D warnings`;
|
||||
- rustdoc warnings и broken links;
|
||||
- advisory/source policy;
|
||||
- dependency denylist;
|
||||
- отсутствие project-owned unsafe вне разрешённого Vulkan boundary;
|
||||
- корректность typed acceptance manifests;
|
||||
- platform-native smoke jobs.
|
||||
|
||||
Custom recursive rustfmt также может расходиться с canonical `cargo fmt --all -- --check`.
|
||||
|
||||
### S0-B05 — Toolchain не воспроизводим
|
||||
|
||||
**Приоритет:** BLOCKER
|
||||
**Файл:** `rust-toolchain.toml`
|
||||
|
||||
Канал `stable` изменяется. Один и тот же commit может использовать разные компиляторы в разные дни. MSRV также не объявлен.
|
||||
|
||||
**Рекомендация:**
|
||||
|
||||
- закрепить точный Rust release;
|
||||
- указать `rust-version`;
|
||||
- обновлять toolchain отдельным reviewed PR;
|
||||
- сохранять toolchain и SDK versions в acceptance report.
|
||||
|
||||
### S0-H01 — Нужен изолированный audited unsafe boundary
|
||||
|
||||
**Приоритет:** HIGH
|
||||
|
||||
`unsafe_code = "forbid"` правильно сохранять для backend-neutral crates. Однако Vulkan FFI требует локальных unsafe calls.
|
||||
|
||||
Нельзя ослаблять policy всему workspace.
|
||||
|
||||
**Целевая схема:**
|
||||
|
||||
- unsafe разрешён только в `fparkan-render-vulkan` low-level modules;
|
||||
- `unsafe_op_in_unsafe_fn = deny`;
|
||||
- каждый block имеет `// SAFETY:` comment;
|
||||
- ownership/lifetime rules документированы;
|
||||
- raw Vulkan handles не выходят в public neutral API;
|
||||
- custom policy scanner проверяет allowlist.
|
||||
|
||||
### S0-H02 — Neutral render IDs не должны быть GPU allocation IDs
|
||||
|
||||
**Приоритет:** HIGH, не блокирует первый hardcoded triangle
|
||||
**Файл:** `crates/fparkan-render/src/lib.rs`
|
||||
|
||||
`GpuMeshId` и `GpuMaterialId` появляются до существования GPU registry. Это смешивает CPU asset identity и backend-local allocation identity.
|
||||
|
||||
**Рекомендация:** использовать neutral `MeshAssetId`/`MaterialAssetId`; Vulkan adapter должен самостоятельно отображать их на buffers, images и descriptors.
|
||||
|
||||
### S0-M01 — Документация рассогласована с Vulkan revision
|
||||
|
||||
**Приоритет:** MEDIUM
|
||||
|
||||
`docs/tomes/07-implementation.md` сохраняет старую последовательность и multi-backend формулировки. Parity documentation ссылается на отсутствующий workspace crate, а active parity cases не определены.
|
||||
|
||||
**Рекомендация:** один versioned source of truth для stages и автоматическая проверка упомянутых crates, commands и backend names.
|
||||
|
||||
---
|
||||
|
||||
## 5. Сильные стороны, которые следует сохранить
|
||||
|
||||
- Workspace lint policy строгая и подходит для backend-neutral crates.
|
||||
- `Cargo.lock` присутствует, а команды используют `--locked`.
|
||||
- Synthetic и licensed corpus paths концептуально разделены.
|
||||
- `fparkan-headless` не зависит от platform/render adapters на manifest-level.
|
||||
- `fparkan-render` уже предоставляет deterministic command ordering, validation и canonical capture.
|
||||
- Composition roots отделены от большинства core crates.
|
||||
|
||||
Эти элементы позволяют построить Vulkan foundation без переписывания CPU/data foundation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Целевая архитектура Stage 0
|
||||
|
||||
```text
|
||||
apps/fparkan-game, apps/fparkan-viewer
|
||||
│
|
||||
├── fparkan-platform-winit
|
||||
│ └── winit + raw-window-handle
|
||||
│
|
||||
└── fparkan-render-vulkan
|
||||
├── ash-window
|
||||
├── ash
|
||||
├── surface / swapchain
|
||||
├── device / queues
|
||||
├── shaders / pipelines
|
||||
└── synchronization / presentation
|
||||
|
||||
apps/fparkan-headless
|
||||
└── runtime/core only
|
||||
no winit, ash, MoltenVK or window dependencies
|
||||
```
|
||||
|
||||
Разделение ответственности:
|
||||
|
||||
- `fparkan-platform`: события, input, lifecycle, sizes и handle access;
|
||||
- `fparkan-platform-winit`: concrete window/event-loop implementation;
|
||||
- `fparkan-render`: backend-neutral command/snapshot contracts;
|
||||
- `fparkan-render-vulkan`: Vulkan resources, synchronization и present;
|
||||
- game/viewer: composition root;
|
||||
- headless: полностью изолированный путь.
|
||||
|
||||
---
|
||||
|
||||
## 7. План полного закрытия Stage 0
|
||||
|
||||
Порядок PR важен. Vulkan adapter не следует строить поверх текущего GL-oriented platform contract.
|
||||
|
||||
### PR S0-01 — Reproducible toolchain и metadata
|
||||
|
||||
**Изменения**
|
||||
|
||||
- закрепить exact Rust toolchain;
|
||||
- добавить `workspace.package.rust-version`;
|
||||
- зафиксировать supported triples;
|
||||
- добавить `cargo xtask doctor`;
|
||||
- включать commit SHA, Rust version и platform SDK versions в reports.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- clean checkout формирует одинаковый metadata report;
|
||||
- MSRV job собирает backend-neutral crates;
|
||||
- pinned toolchain проходит полный synthetic gate.
|
||||
|
||||
### PR S0-02 — Typed xtask configuration
|
||||
|
||||
**Изменения**
|
||||
|
||||
- `serde` + TOML schemas для corpus/acceptance manifests;
|
||||
- `deny_unknown_fields`;
|
||||
- duplicate/missing/unknown-field validation;
|
||||
- absolute canonical paths для local licensed manifest;
|
||||
- `cargo_metadata` для dependency и workspace policy;
|
||||
- удалить ручной line parser.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- malformed manifest всегда даёт non-zero exit;
|
||||
- неизвестные поля не игнорируются;
|
||||
- dependency policy работает по Cargo package IDs, targets и features.
|
||||
|
||||
### PR S0-03 — Полный synthetic CI gate
|
||||
|
||||
Обязательные команды:
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace --all-targets --all-features --locked
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
RUSTDOCFLAGS="-D warnings -D rustdoc::broken_intra_doc_links" \
|
||||
cargo doc --workspace --no-deps --all-features --locked
|
||||
cargo deny check advisories bans licenses sources
|
||||
cargo xtask policy
|
||||
cargo xtask acceptance audit --strict
|
||||
```
|
||||
|
||||
Добавить reports для каждого gate и запрет silent skip.
|
||||
|
||||
### PR S0-04 — Redesign `fparkan-platform`
|
||||
|
||||
**Изменения**
|
||||
|
||||
- удалить `GraphicsProfile`, `GraphicsContextRequest` и GL version negotiation;
|
||||
- убрать `present()` из window port;
|
||||
- добавить normalized keyboard/mouse events;
|
||||
- physical/logical size и scale factor;
|
||||
- focus, minimize, occlusion, suspend/resume;
|
||||
- deterministic lifecycle state machine;
|
||||
- structured errors с source chain.
|
||||
|
||||
**Synthetic tests**
|
||||
|
||||
- resize coalescing;
|
||||
- zero-size/minimized window;
|
||||
- scale-factor changes;
|
||||
- focus loss clears held input;
|
||||
- key repeat и modifiers;
|
||||
- suspend/resume;
|
||||
- deterministic event ordering.
|
||||
|
||||
### PR S0-05 — `fparkan-platform-winit`
|
||||
|
||||
**Изменения**
|
||||
|
||||
- winit event loop;
|
||||
- native window lifecycle;
|
||||
- raw window/display handles;
|
||||
- platform-specific event normalization;
|
||||
- отсутствие GPU ownership.
|
||||
|
||||
**Acceptance**
|
||||
|
||||
- window-only smoke на Windows, Linux и macOS;
|
||||
- native event trace соответствует synthetic model.
|
||||
|
||||
### PR S0-06 — Vulkan low-level boundary
|
||||
|
||||
**Изменения**
|
||||
|
||||
- `ash` и `ash-window`;
|
||||
- dynamic Vulkan loader;
|
||||
- instance и debug messenger;
|
||||
- physical device capability records;
|
||||
- pure deterministic device scoring;
|
||||
- graphics/present queue selection;
|
||||
- deterministic capability JSON;
|
||||
- audited unsafe allowlist.
|
||||
|
||||
**Negative tests**
|
||||
|
||||
- loader отсутствует;
|
||||
- Vulkan 1.1 недоступен;
|
||||
- graphics queue отсутствует;
|
||||
- present queue отсутствует;
|
||||
- `VK_KHR_swapchain` отсутствует;
|
||||
- required surface format отсутствует.
|
||||
|
||||
### PR S0-07 — Swapchain, triangle и offline shaders
|
||||
|
||||
**Изменения**
|
||||
|
||||
- surface и swapchain;
|
||||
- format/present-mode/image-count policy;
|
||||
- render pass и graphics pipeline;
|
||||
- indexed triangle;
|
||||
- command pools/buffers;
|
||||
- binary semaphores и fences;
|
||||
- frames in flight;
|
||||
- resize/out-of-date/suboptimal/zero extent handling;
|
||||
- pinned offline shader compiler;
|
||||
- SPIR-V validation;
|
||||
- descriptor/push-constant manifest;
|
||||
- shader content hashes.
|
||||
|
||||
### PR S0-08 — macOS portability proof
|
||||
|
||||
**Изменения**
|
||||
|
||||
- `VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR`;
|
||||
- portability extension enumeration;
|
||||
- `VK_KHR_portability_subset` enablement, если объявлен device;
|
||||
- MoltenVK packaging strategy;
|
||||
- deterministic portability report;
|
||||
- `.app` bundle smoke.
|
||||
|
||||
### PR S0-09 — Composition roots и legacy removal
|
||||
|
||||
**Изменения**
|
||||
|
||||
- game/viewer подключают winit + Vulkan adapters;
|
||||
- headless остаётся без window/GPU graph;
|
||||
- удалить SDL/GL stub crates;
|
||||
- очистить lockfile, policy и docs;
|
||||
- заменить GPU-named neutral IDs на asset IDs;
|
||||
- запретить stale backend names automated policy check-ом.
|
||||
|
||||
### PR S0-10 — Native acceptance matrix
|
||||
|
||||
**Jobs**
|
||||
|
||||
- Windows MSVC + system Vulkan loader;
|
||||
- Linux X11 или Wayland surface;
|
||||
- macOS Apple Silicon + MoltenVK;
|
||||
- отдельный software-Vulkan Linux PR job допустим как быстрый gate;
|
||||
- native GPU jobs остаются release evidence.
|
||||
|
||||
**Обязательный сценарий**
|
||||
|
||||
1. Создать окно.
|
||||
2. Создать real Vulkan swapchain.
|
||||
3. Показать indexed triangle.
|
||||
4. Выполнить не менее 300 frames.
|
||||
5. Изменить размер окна.
|
||||
6. Пересоздать swapchain.
|
||||
7. Корректно завершить event loop.
|
||||
8. Получить `validation_error_count = 0`.
|
||||
9. Сохранить capability, shader и validation reports как artifacts.
|
||||
|
||||
**Stage 0 закрывается только после merge S0-01…S0-10 и зелёных native artifacts.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Требуемая CI/acceptance модель
|
||||
|
||||
### 8.1 Synthetic PR gate
|
||||
|
||||
Должен работать без игровых каталогов и без silent skip:
|
||||
|
||||
1. fmt, clippy, docs, security и policy;
|
||||
2. все unit/integration tests;
|
||||
3. platform lifecycle state-machine tests;
|
||||
4. device scoring tests на synthetic capability records;
|
||||
5. swapchain policy tests;
|
||||
6. shader manifest/hash tests;
|
||||
7. Vulkan negative-path tests без обязательного GPU;
|
||||
8. headless dependency assertion;
|
||||
9. report schema validation.
|
||||
|
||||
Tests, требующие native GPU или licensed data, должны иметь отдельные suites и machine-readable ownership/reason, а не оставаться обычными `#[ignore]` без evidence trail.
|
||||
|
||||
### 8.2 Native platform gate
|
||||
|
||||
| Platform | Минимальный gate | Дополнительное evidence |
|
||||
|---|---|---|
|
||||
| Windows | system loader, swapchain, triangle, resize, 300 frames, validation=0 | Периодическая NVIDIA/AMD/Intel coverage |
|
||||
| Linux | X11 или Wayland surface, swapchain, resize, validation=0 | Software Vulkan PR job + Mesa/NVIDIA native release jobs |
|
||||
| macOS | MoltenVK, portability enumeration/subset, CAMetalLayer surface, resize, validation=0 | Apple Silicon как primary target |
|
||||
|
||||
### 8.3 Формат machine-readable отчёта
|
||||
|
||||
Минимальные поля:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": 1,
|
||||
"commit": "<sha>",
|
||||
"target": "x86_64-pc-windows-msvc",
|
||||
"rustc": "1.xx.y",
|
||||
"vulkan_api": "1.1",
|
||||
"device_name": "...",
|
||||
"driver": "...",
|
||||
"portability_subset": false,
|
||||
"frames": 300,
|
||||
"resize_count": 1,
|
||||
"swapchain_recreate_count": 1,
|
||||
"validation_error_count": 0,
|
||||
"shader_manifest_hash": "...",
|
||||
"result": "pass"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Definition of Done
|
||||
|
||||
Stage 0 считается закрытым, когда выполнены **все** пункты:
|
||||
|
||||
- [ ] Exact Rust toolchain закреплён.
|
||||
- [ ] MSRV объявлен и проверяется.
|
||||
- [ ] Full fmt/test/clippy/doc/security/source/license gate проходит.
|
||||
- [ ] Typed TOML manifests используются.
|
||||
- [ ] Dependency policy работает через `cargo_metadata`.
|
||||
- [ ] Windows/Linux/macOS matrix сохраняет artifacts.
|
||||
- [ ] `fparkan-platform` больше не содержит GL-specific context concepts.
|
||||
- [ ] `fparkan-platform-winit` реализован.
|
||||
- [ ] `fparkan-render-vulkan` реализован.
|
||||
- [ ] Vulkan 1.1 instance/device/queues/surface/swapchain реализованы.
|
||||
- [ ] Deterministic device scoring и capability report реализованы.
|
||||
- [ ] Indexed triangle рисуется настоящим Vulkan backend.
|
||||
- [ ] Resize, zero extent, out-of-date и suboptimal обработаны.
|
||||
- [ ] MoltenVK portability path реализован.
|
||||
- [ ] Offline SPIR-V validation и hash manifest реализованы.
|
||||
- [ ] Unsafe разрешён только в audited Vulkan/FFI modules.
|
||||
- [ ] Legacy SDL/GL adapters и references удалены.
|
||||
- [ ] Game/viewer используют новые composition adapters.
|
||||
- [ ] Headless dependency graph не содержит winit/Vulkan/MoltenVK.
|
||||
- [ ] 300-frame + resize smoke проходит на трёх OS.
|
||||
- [ ] Validation error count равен нулю на трёх OS.
|
||||
- [ ] Acceptance reports включают commit SHA и сохраняются как artifacts.
|
||||
|
||||
Наличие crates или unit tests с соответствующими названиями само по себе не является закрытием Stage 0.
|
||||
|
||||
---
|
||||
|
||||
## 10. Рекомендуемые automated policy checks
|
||||
|
||||
Добавить в `cargo xtask policy`:
|
||||
|
||||
### Workspace denylist
|
||||
|
||||
- запрещены `fparkan-platform-sdl` и `fparkan-render-gl` после миграции;
|
||||
- запрещены stale symbols `GraphicsProfile`, `DesktopCore`, `Embedded`, `Gles2` в canonical platform/render API;
|
||||
- canonical docs не содержат OpenGL как production backend.
|
||||
|
||||
### Dependency rules
|
||||
|
||||
- headless не зависит от `winit`, `raw-window-handle`, `ash`, `ash-window` или Vulkan adapter;
|
||||
- backend-neutral crates не зависят от concrete platform/render adapters;
|
||||
- только composition roots связывают platform и renderer;
|
||||
- raw Vulkan types не экспортируются из adapter public boundary.
|
||||
|
||||
### Unsafe rules
|
||||
|
||||
- project-owned unsafe разрешён только в exact allowlisted files/modules;
|
||||
- каждый block содержит `SAFETY:`;
|
||||
- `unsafe_op_in_unsafe_fn` запрещён;
|
||||
- изменение allowlist требует отдельного reviewed diff.
|
||||
|
||||
### Test и report rules
|
||||
|
||||
- synthetic gate не получает licensed paths;
|
||||
- ignored tests обязаны иметь registered reason и owner;
|
||||
- acceptance IDs уникальны;
|
||||
- reports проходят schema validation;
|
||||
- report всегда содержит commit SHA и target triple.
|
||||
|
||||
### Documentation rules
|
||||
|
||||
- документированные crates и commands существуют;
|
||||
- canonical stage version совпадает с acceptance schema;
|
||||
- старые backend names отсутствуют;
|
||||
- README не объявляет незакрытый Vulkan path реализованным.
|
||||
|
||||
---
|
||||
|
||||
## 11. Основные риски
|
||||
|
||||
| Риск | Последствие | Снижение |
|
||||
|---|---|---|
|
||||
| Vulkan adapter начнут до redesign platform API | Повторная переделка surface/lifecycle/present | Сначала S0-04, затем S0-05/S0-06 |
|
||||
| `unsafe_code` ослабят всему workspace | Рост FFI и lifetime рисков | Изолированный audited adapter и allowlist scanner |
|
||||
| Stubs будут приняты за production backend | Ложное закрытие Stage 0 | Удаление legacy crates и real native smoke |
|
||||
| Linux software Vulkan будет единственным evidence | Не выявятся vendor-driver проблемы | Native Mesa/NVIDIA jobs перед release |
|
||||
| macOS будет проверен без portability subset report | Скрытая несовместимость MoltenVK | Обязательное capability evidence |
|
||||
| Shader compiler останется неприкреплённым | Невоспроизводимый SPIR-V | Pinned compiler + manifest hashes |
|
||||
| GitHub mirror и primary repository разойдутся | Audit и release относятся к разному коду | Commit SHA, canonical remote и artifact metadata |
|
||||
| Документация останется отдельным source of truth | Повторное рассогласование | Versioned stage schema и automated doc checks |
|
||||
|
||||
---
|
||||
|
||||
## 12. Реестр доказательств
|
||||
|
||||
### Canonical requirement
|
||||
|
||||
- Vulkan revision: <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>
|
||||
|
||||
### Workspace и governance
|
||||
|
||||
- Root manifest: <https://github.com/valentineus/fparkan/blob/devel/Cargo.toml>
|
||||
- Toolchain: <https://github.com/valentineus/fparkan/blob/devel/rust-toolchain.toml>
|
||||
- Cargo config: <https://github.com/valentineus/fparkan/blob/devel/.cargo/config.toml>
|
||||
- xtask manifest: <https://github.com/valentineus/fparkan/blob/devel/xtask/Cargo.toml>
|
||||
- xtask implementation: <https://github.com/valentineus/fparkan/blob/devel/xtask/src/main.rs>
|
||||
- README: <https://github.com/valentineus/fparkan/blob/devel/README.md>
|
||||
|
||||
### Platform и render
|
||||
|
||||
- Platform core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-platform/src/lib.rs>
|
||||
- SDL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-platform-sdl/src/lib.rs>
|
||||
- Render core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-render/src/lib.rs>
|
||||
- GL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-render-gl/src/lib.rs>
|
||||
- Game composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-game/src/main.rs>
|
||||
- Viewer composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-viewer/src/main.rs>
|
||||
- Headless manifest: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-headless/Cargo.toml>
|
||||
|
||||
### Documentation drift
|
||||
|
||||
- Implementation tome: <https://github.com/valentineus/fparkan/blob/devel/docs/tomes/07-implementation.md>
|
||||
- Parity README: <https://github.com/valentineus/fparkan/blob/devel/parity/README.md>
|
||||
- Parity cases: <https://github.com/valentineus/fparkan/blob/devel/parity/cases.toml>
|
||||
|
||||
---
|
||||
|
||||
## 13. Финальное заключение
|
||||
|
||||
У проекта уже имеется пригодный backend-neutral фундамент: deterministic render commands, строгие neutral-crate lints, отдельный headless composition root и разделение synthetic/licensed tests. Однако Stage 0 пока представлен интерфейсными proof/stub crates, а не настоящим Vulkan vertical slice.
|
||||
|
||||
Критический путь:
|
||||
|
||||
```text
|
||||
reproducible toolchain
|
||||
→ complete CI/policy gate
|
||||
→ backend-neutral platform redesign
|
||||
→ winit adapter
|
||||
→ Vulkan loader/device/surface/swapchain
|
||||
→ indexed triangle + shaders + synchronization
|
||||
→ MoltenVK portability
|
||||
→ composition integration
|
||||
→ legacy removal
|
||||
→ three-platform acceptance artifacts
|
||||
```
|
||||
|
||||
До прохождения этого пути рекомендуемый статус:
|
||||
|
||||
```text
|
||||
Stage 0: IN PROGRESS / BLOCKED
|
||||
```
|
||||
|
||||
Главный критерий закрытия:
|
||||
|
||||
> Stage 0 завершён не тогда, когда существуют crates с названиями `winit` и `vulkan`, а когда один закреплённый commit создаёт настоящий Vulkan swapchain, рисует triangle, переживает resize и завершается без validation errors на Windows, Linux и macOS, сохраняя воспроизводимые machine-readable artifacts.
|
||||
+52
-41
@@ -1,4 +1,23 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::expect_used,
|
||||
clippy::float_cmp,
|
||||
clippy::identity_op,
|
||||
clippy::too_many_lines,
|
||||
clippy::uninlined_format_args,
|
||||
clippy::map_unwrap_or,
|
||||
clippy::needless_raw_string_hashes,
|
||||
clippy::semicolon_if_nothing_returned,
|
||||
clippy::type_complexity,
|
||||
clippy::panic,
|
||||
clippy::unwrap_used
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! Repository automation for `FParkan`.
|
||||
|
||||
@@ -175,8 +194,17 @@ fn run_cargo_deny() -> Result<(), String> {
|
||||
fn run_cargo_doc() -> Result<(), String> {
|
||||
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
|
||||
let status = Command::new(cargo)
|
||||
.args(["doc", "--workspace", "--all-features", "--locked", "--no-deps"])
|
||||
.env("RUSTDOCFLAGS", "-D warnings -D rustdoc::broken_intra_doc_links")
|
||||
.args([
|
||||
"doc",
|
||||
"--workspace",
|
||||
"--all-features",
|
||||
"--locked",
|
||||
"--no-deps",
|
||||
])
|
||||
.env(
|
||||
"RUSTDOCFLAGS",
|
||||
"-D warnings -D rustdoc::broken_intra_doc_links",
|
||||
)
|
||||
.status()
|
||||
.map_err(|err| format!("failed to run cargo doc: {err}"))?;
|
||||
if status.success() {
|
||||
@@ -272,8 +300,10 @@ fn parse_licensed_manifest(path: &Path) -> Result<LicensedCorpusRoots, String> {
|
||||
}
|
||||
|
||||
let roots = LicensedCorpusRoots {
|
||||
part1: part1.ok_or_else(|| "licensed manifest is missing part1 corpus entry".to_string())?,
|
||||
part2: part2.ok_or_else(|| "licensed manifest is missing part2 corpus entry".to_string())?,
|
||||
part1: part1
|
||||
.ok_or_else(|| "licensed manifest is missing part1 corpus entry".to_string())?,
|
||||
part2: part2
|
||||
.ok_or_else(|| "licensed manifest is missing part2 corpus entry".to_string())?,
|
||||
};
|
||||
validate_licensed_part("part1", &roots.part1)?;
|
||||
validate_licensed_part("part2", &roots.part2)?;
|
||||
@@ -412,16 +442,10 @@ fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<()
|
||||
}
|
||||
let metadata = MetadataCommand::new()
|
||||
.manifest_path(&manifest)
|
||||
.no_deps(true)
|
||||
.no_deps()
|
||||
.other_options(["--offline".to_string(), "--locked".to_string()])
|
||||
.exec()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"{}: cargo metadata failed: {}",
|
||||
manifest.display(),
|
||||
error
|
||||
)
|
||||
})?;
|
||||
.map_err(|error| format!("{}: cargo metadata failed: {}", manifest.display(), error))?;
|
||||
if metadata.workspace_members.is_empty() {
|
||||
failures.push(format!(
|
||||
"{}: cargo metadata produced no workspace members",
|
||||
@@ -505,7 +529,7 @@ fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Re
|
||||
continue;
|
||||
}
|
||||
let dependencies = parse_manifest_dependencies(&text);
|
||||
if !is_adapter_like_package(&package) {
|
||||
if !is_adapter_like_package(&package) && !is_app_package(&package) {
|
||||
for dependency in &dependencies {
|
||||
if is_forbidden_gui_dependency(dependency) {
|
||||
failures.push(format!(
|
||||
@@ -522,15 +546,15 @@ fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Re
|
||||
manifest.display()
|
||||
));
|
||||
}
|
||||
for dependency in &dependencies {
|
||||
if is_forbidden_runtime_bridge_dependency(dependency) {
|
||||
}
|
||||
if package == "fparkan-headless" {
|
||||
if let Some(forbidden) = first_forbidden_platform_bridge_dependency(&dependencies) {
|
||||
failures.push(format!(
|
||||
"{}: app package {package} depends on forbidden bridge dependency {dependency}",
|
||||
"{}: headless package {package} depends on platform/render bridge dependency {forbidden}",
|
||||
manifest.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if package == "fparkan-runtime" {
|
||||
if let Some(forbidden) = first_forbidden_parser_dependency(&dependencies) {
|
||||
@@ -567,10 +591,7 @@ fn is_app_package(package: &str) -> bool {
|
||||
}
|
||||
|
||||
fn is_adapter_like_package(package: &str) -> bool {
|
||||
matches!(
|
||||
package,
|
||||
"fparkan-platform-winit" | "fparkan-render-vulkan"
|
||||
)
|
||||
matches!(package, "fparkan-platform-winit" | "fparkan-render-vulkan")
|
||||
}
|
||||
|
||||
fn first_forbidden_parser_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
|
||||
@@ -630,16 +651,10 @@ fn first_forbidden_platform_bridge_dependency(dependencies: &BTreeSet<String>) -
|
||||
})
|
||||
}
|
||||
|
||||
fn is_forbidden_runtime_bridge_dependency(dependency: &str) -> bool {
|
||||
matches!(
|
||||
dependency,
|
||||
"fparkan-platform-winit" | "fparkan-render-vulkan" | "winit" | "ash" | "ash-window"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_forbidden_domain_dependency(dependency: &str) -> bool {
|
||||
matches!(
|
||||
dependency, "fparkan-cli"
|
||||
dependency,
|
||||
"fparkan-cli"
|
||||
| "fparkan-game"
|
||||
| "fparkan-headless"
|
||||
| "fparkan-viewer"
|
||||
@@ -889,15 +904,15 @@ fn scan_policy_file(path: &Path, failures: &mut Vec<String>) -> Result<(), Strin
|
||||
previous_line_has_safety_comment = false;
|
||||
continue;
|
||||
}
|
||||
if contains_unsafe_construct(trimmed) {
|
||||
if !is_authorized_unsafe_construct(path, trimmed, previous_line_has_safety_comment) {
|
||||
if contains_unsafe_construct(trimmed)
|
||||
&& !is_authorized_unsafe_construct(path, trimmed, previous_line_has_safety_comment)
|
||||
{
|
||||
failures.push(format!(
|
||||
"{}:{}: unsafe construct in workspace source",
|
||||
path.display(),
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
}
|
||||
previous_line_has_safety_comment = false;
|
||||
}
|
||||
Ok(())
|
||||
@@ -911,9 +926,7 @@ fn contains_unsafe_construct(line: &str) -> bool {
|
||||
}
|
||||
|
||||
fn is_comment_line(line: &str) -> bool {
|
||||
line.starts_with("//")
|
||||
|| line.starts_with("//!")
|
||||
|| line.starts_with("///")
|
||||
line.starts_with("//") || line.starts_with("//!") || line.starts_with("///")
|
||||
}
|
||||
|
||||
fn has_safety_comment(line: &str) -> bool {
|
||||
@@ -924,7 +937,9 @@ const AUDITED_UNSAFE_SOURCE_FILES: &[&str] = &["adapters/fparkan-render-vulkan/s
|
||||
|
||||
fn is_audited_unsafe_source(path: &Path) -> bool {
|
||||
let as_path = path.as_os_str().to_string_lossy();
|
||||
AUDITED_UNSAFE_SOURCE_FILES.iter().any(|candidate| as_path.ends_with(candidate))
|
||||
AUDITED_UNSAFE_SOURCE_FILES
|
||||
.iter()
|
||||
.any(|candidate| as_path.ends_with(candidate))
|
||||
}
|
||||
|
||||
fn is_authorized_unsafe_construct(
|
||||
@@ -1258,11 +1273,7 @@ impl AcceptanceAudit {
|
||||
}
|
||||
|
||||
fn strict_failures(&self) -> Vec<String> {
|
||||
self.partial
|
||||
.iter()
|
||||
.chain(&self.missing)
|
||||
.cloned()
|
||||
.collect()
|
||||
self.partial.iter().chain(&self.missing).cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user