fix: close stage 0-2 synthetic gates

This commit is contained in:
2026-06-23 22:32:50 +04:00
parent f8e447ffee
commit 9cc24e715d
38 changed files with 4038 additions and 1737 deletions
Generated
+2213 -18
View File
File diff suppressed because it is too large Load Diff
+46 -16
View File
@@ -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]
+24 -2
View File
@@ -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)?;
+1
View File
@@ -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" }
+24 -8
View File
@@ -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()))
}
}
}
+2
View File
@@ -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" }
+32 -8
View File
@@ -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
+19
View File
@@ -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.
+26 -3
View File
@@ -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 {
+19
View File
@@ -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.
//!
+174 -137
View File
@@ -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 => {
@@ -571,7 +619,7 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
report.wear_resolved_count += 1;
report.material_slot_count += table.entries.len();
for (material_index, _entry) in table.entries.iter().enumerate() {
let Ok(material_index) = u16::try_from(material_index) else {
let Ok(material_index) = u16::try_from(material_index) else {
push_visual_failure(
report,
graph,
@@ -594,15 +642,18 @@ 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(
report,
graph,
prototype_index,
texture.0,
PrototypeGraphEdge::MaterialToTexture,
PrototypeGraphRequiredness::Required,
&message,
),
Err(message) => {
let message = message.to_string();
push_visual_failure(
report,
graph,
prototype_index,
texture.0,
PrototypeGraphEdge::MaterialToTexture,
PrototypeGraphRequiredness::Required,
&message,
);
}
}
}
}
@@ -615,7 +666,7 @@ pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
PrototypeGraphRequiredness::Required,
&message.to_string(),
),
}
}
}
for lightmap in &table.lightmaps {
report.lightmap_request_count += 1;
@@ -625,15 +676,18 @@ 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(
report,
graph,
prototype_index,
lightmap.lightmap.0.clone(),
PrototypeGraphEdge::WearToLightmap,
PrototypeGraphRequiredness::Required,
&message,
),
Err(message) => {
let message = message.to_string();
push_visual_failure(
report,
graph,
prototype_index,
lightmap.lightmap.0.clone(),
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 {
@@ -750,7 +799,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
}
for texture in material.document.texture_requests() {
resolve_texture(repository, &texture)?;
resolve_texture(repository, &texture)?;
texture_count += 1;
}
}
@@ -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))?;
let handle = repository
.find(archive, &key.name)
.map_err(|err| map_resource_error(label, key, err))?
.and_then(|archive| {
repository
.find(archive, &key.name)
.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,19 +879,17 @@ fn resolve_wear_table<R: ResourceRepository>(
String::from_utf8_lossy(&wear_name.0)
))
})?;
let info = repository
.entry_info(handle)
.map_err(|err| {
map_resource_error(
"wear",
&ResourceKey {
archive: mesh.archive.clone(),
name: wear_name.clone(),
type_id: Some(WEAR_KIND),
},
err,
)
})?;
let info = repository.entry_info(handle).map_err(|err| {
map_resource_error(
"wear",
&ResourceKey {
archive: mesh.archive.clone(),
name: wear_name.clone(),
type_id: Some(WEAR_KIND),
},
err,
)
})?;
if info.key.type_id != Some(WEAR_KIND) {
return Err(AssetError::InvalidPrototype(format!(
"entry {} is not WEAR",
@@ -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,24 +1123,20 @@ 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 label = label.unwrap_or("asset");
map_resource_error(label, key, 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 label = label.unwrap_or("asset");
map_resource_error(label, key, err)
})?;
let bytes = repository.read(handle).map_err(|err| {
let label = label.unwrap_or("asset");
map_resource_error(label, key, err)
})?;
Ok(Some(Arc::from(bytes.into_owned())))
}
+19
View File
@@ -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;
+80 -32
View File
@@ -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,21 +378,25 @@ fn inspect_report_file(
message: Some(message),
};
}
if variant == "land_msh" && let Err(message) = inspect_land_metrics(&bytes, false) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(message),
};
if variant == "land_msh" {
if let Err(message) = inspect_land_metrics(&bytes, false) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(message),
};
}
}
if variant == "land_map" && let Err(message) = inspect_land_metrics(&bytes, true) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(message),
};
if variant == "land_map" {
if let Err(message) = inspect_land_metrics(&bytes, true) {
return CorpusFileRecord {
path: entry.path.clone(),
status: CorpusFileStatus::Error,
variant,
message: Some(message),
};
}
}
} else if bytes.starts_with(b"NL") {
variant = "rsli".to_string();
@@ -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,8 +563,11 @@ 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)
.map_err(|err| err.to_string())?;
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,
+25 -1
View File
@@ -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'));
}
+19
View File
@@ -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
+58 -17
View File
@@ -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,8 +161,11 @@ 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)
.map_err(|err| err.to_string())?;
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,13 +225,15 @@ 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,
)
.map_err(|err| err.to_string())?;
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),
LandFileKind::LandMap => inspect_land_map(&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
+19
View File
@@ -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;
+19
View File
@@ -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;
+20 -1
View File
@@ -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,
+19
View File
@@ -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};
+21 -1
View File
@@ -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,
})
}
+44 -8
View File
@@ -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,
+82 -49
View File
@@ -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)
@@ -1004,12 +1029,12 @@ fn collect_registry_refs(
let parent_key = ResourceName(cstr_bytes(&item.resource_raw).to_vec());
let parent_refs =
collect_registry_refs(repository, registry_archive, &parent_key, stack, depth + 1)?
.ok_or_else(|| {
PrototypeError::Resource(ResourceError::Format(format!(
"missing parent prototype {}",
String::from_utf8_lossy(&parent_key.0)
)))
})?;
.ok_or_else(|| {
PrototypeError::Resource(ResourceError::Format(format!(
"missing parent prototype {}",
String::from_utf8_lossy(&parent_key.0)
)))
})?;
effective_refs.extend(parent_refs);
} else {
effective_refs.push(item);
@@ -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,13 +1525,19 @@ mod tests {
build_nres(&[
(
b"component_a".as_slice(),
build_object_refs(&[(b"static.rlb".as_slice(), b"component_a.msh".as_slice())])
.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())])
.as_slice(),
build_object_refs(&[(
b"static.rlb".as_slice(),
b"component_b.msh".as_slice(),
)])
.as_slice(),
),
])
.into_boxed_slice(),
@@ -1659,9 +1691,10 @@ 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"))
.expect("resolve")
.expect("prototype");
let resolved =
resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"child_proto"))
.expect("resolve")
.expect("prototype");
let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
panic!("expected inherited mesh");
@@ -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"));
}
+19
View File
@@ -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;
+19
View File
@@ -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;
+69 -30
View File
@@ -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,12 +633,11 @@ 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;
self.dirty = true;
@@ -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)
.map_err(|_| RsliError::IntegerOverflow)?
.to_le_bytes());
row[16..18].copy_from_slice(
&i16::try_from(entry.meta.flags)
.map_err(|_| RsliError::IntegerOverflow)?
.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]
+66 -41
View File
@@ -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,44 +452,52 @@ 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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
path: mission_path.as_str().to_string(),
source,
})?;
trace.transforms = mission
.objects
.iter()
+19
View File
@@ -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};
+37 -15
View File
@@ -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,
})
}
+19
View File
@@ -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};
+19
View File
@@ -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;
+40 -17
View File
@@ -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,11 +399,9 @@ 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,
)
.map_err(|_| VfsError::Path)?;
let normalized =
fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
.map_err(|_| VfsError::Path)?;
out.push(VfsEntry {
path: normalized,
metadata: VfsMetadata {
@@ -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");
}
+19
View File
@@ -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;
+2 -2
View File
@@ -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
1 # Acceptance coverage manifest.
71 S1-PATH-006
72 S1-PATH-007
73 S1-PATH-008
74 S1-H02 S1-PATH-009
75 S1-M01 S1-VFS-005
76 S1-RSLI-001
77 S1-RSLI-002
78 S1-RSLI-003
+2 -2
View File
@@ -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
+643
View File
@@ -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 05: 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.
+60 -49
View File
@@ -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,13 +546,13 @@ fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Re
manifest.display()
));
}
for dependency in &dependencies {
if is_forbidden_runtime_bridge_dependency(dependency) {
failures.push(format!(
"{}: app package {package} depends on forbidden bridge dependency {dependency}",
manifest.display()
));
}
}
if package == "fparkan-headless" {
if let Some(forbidden) = first_forbidden_platform_bridge_dependency(&dependencies) {
failures.push(format!(
"{}: headless package {package} depends on platform/render bridge dependency {forbidden}",
manifest.display()
));
}
}
@@ -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,14 +904,14 @@ 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) {
failures.push(format!(
"{}:{}: unsafe construct in workspace source",
path.display(),
index + 1
));
}
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;
}
@@ -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()
}
}