2026-06-22 13:12:27 +04:00
|
|
|
#![forbid(unsafe_code)]
|
2026-06-23 22:32:50 +04:00
|
|
|
#![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
|
|
|
|
|
)
|
|
|
|
|
)]
|
2026-06-22 13:12:27 +04:00
|
|
|
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
2026-07-04 01:50:59 +04:00
|
|
|
//! `FParkan` render-planning composition root.
|
2026-06-22 13:12:27 +04:00
|
|
|
|
2026-07-18 09:52:20 +04:00
|
|
|
use fparkan_assets::{PreparedTextureUsage, PreparedVisual};
|
2026-06-23 22:32:50 +04:00
|
|
|
use fparkan_platform::WindowPort;
|
2026-07-18 09:31:32 +04:00
|
|
|
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
|
2026-06-22 13:12:27 +04:00
|
|
|
use fparkan_render::{
|
2026-07-18 06:16:44 +04:00
|
|
|
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend,
|
|
|
|
|
RenderCommand, RenderCommandList, RenderPhase, RenderProfile, RenderSnapshot,
|
|
|
|
|
RenderSnapshotDraw,
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
2026-07-18 09:31:32 +04:00
|
|
|
use fparkan_render_vulkan::{
|
2026-07-18 10:53:03 +04:00
|
|
|
project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_xy_frame,
|
2026-07-18 10:21:43 +04:00
|
|
|
VulkanPlanningBackend, VulkanSmokeFrameOutcome, VulkanSmokeRenderer,
|
|
|
|
|
VulkanSmokeRendererCreateInfo, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
|
2026-07-18 10:53:03 +04:00
|
|
|
VulkanStaticXyFrame,
|
2026-07-18 09:31:32 +04:00
|
|
|
};
|
2026-06-22 13:12:27 +04:00
|
|
|
use fparkan_runtime::{
|
2026-07-18 10:36:29 +04:00
|
|
|
create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots,
|
|
|
|
|
load_mission_static_preview_roots_with_progress, load_mission_with_progress,
|
|
|
|
|
loaded_mission_assets, loaded_mission_object_drafts, loaded_terrain, EngineConfig, EngineMode,
|
|
|
|
|
EngineServices, MissionAssets, MissionLoadPhase, MissionObjectDraft, MissionRequest,
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
2026-07-18 10:21:43 +04:00
|
|
|
use fparkan_terrain::TerrainWorld;
|
2026-06-22 13:12:27 +04:00
|
|
|
use fparkan_vfs::DirectoryVfs;
|
2026-07-18 09:24:45 +04:00
|
|
|
#[cfg(test)]
|
|
|
|
|
use fparkan_world::OriginalObjectId;
|
2026-06-22 13:12:27 +04:00
|
|
|
use fparkan_world::WorldSnapshot;
|
2026-07-18 10:36:29 +04:00
|
|
|
use std::num::NonZeroUsize;
|
2026-06-22 13:12:27 +04:00
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::Arc;
|
2026-07-18 09:31:32 +04:00
|
|
|
use winit::application::ApplicationHandler;
|
|
|
|
|
use winit::dpi::PhysicalSize as WinitPhysicalSize;
|
|
|
|
|
use winit::event::WindowEvent;
|
|
|
|
|
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
|
|
|
|
use winit::window::{Window, WindowId};
|
2026-06-22 13:12:27 +04:00
|
|
|
|
|
|
|
|
fn main() {
|
|
|
|
|
let raw_args = std::env::args().skip(1).collect::<Vec<_>>();
|
|
|
|
|
let code = match run(&raw_args) {
|
|
|
|
|
Ok(output) => {
|
|
|
|
|
println!("{output}");
|
|
|
|
|
0
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
eprintln!("{err}");
|
|
|
|
|
2
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
std::process::exit(code);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run(args: &[String]) -> Result<String, String> {
|
|
|
|
|
let args = Args::parse(args)?;
|
|
|
|
|
let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root)));
|
|
|
|
|
let mut engine = create(
|
|
|
|
|
EngineConfig {
|
|
|
|
|
mode: EngineMode::Rendered,
|
|
|
|
|
},
|
|
|
|
|
services,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
2026-07-18 09:42:34 +04:00
|
|
|
let loaded = load_requested_mission(&mut engine, &args)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
|
2026-07-18 09:31:32 +04:00
|
|
|
if args.backend == RenderBackendMode::StaticVulkan {
|
|
|
|
|
let mission_assets = loaded_mission_assets(&engine)
|
|
|
|
|
.ok_or_else(|| "mission assets are unavailable after loading".to_string())?;
|
2026-07-18 10:21:43 +04:00
|
|
|
let terrain = loaded_terrain(&engine)
|
|
|
|
|
.ok_or_else(|| "mission terrain is unavailable after loading".to_string())?;
|
2026-07-18 10:36:29 +04:00
|
|
|
let roots = loaded_mission_object_drafts(&engine)
|
|
|
|
|
.map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())])
|
|
|
|
|
.filter(|roots| !roots.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
"selected mission object drafts are unavailable after loading".to_string()
|
|
|
|
|
})?;
|
|
|
|
|
let preview = static_preview_mesh_and_materials(mission_assets, terrain, roots)?;
|
2026-07-18 09:52:20 +04:00
|
|
|
return run_static_vulkan_mode(
|
2026-07-18 10:36:29 +04:00
|
|
|
preview,
|
|
|
|
|
roots.len(),
|
2026-07-18 09:52:20 +04:00
|
|
|
args.frames,
|
|
|
|
|
&args.mission,
|
|
|
|
|
loaded.object_count,
|
|
|
|
|
);
|
2026-07-18 09:31:32 +04:00
|
|
|
}
|
|
|
|
|
|
2026-06-25 04:18:32 +04:00
|
|
|
let mut backend = VulkanPlanningBackend::new();
|
2026-06-23 22:05:16 +04:00
|
|
|
let _request = WinitWindow::default_render_request();
|
|
|
|
|
let window = WinitWindow::synthetic(1280, 720);
|
|
|
|
|
let _ = window.drawable_size();
|
|
|
|
|
let _ = window.handle();
|
2026-06-22 13:12:27 +04:00
|
|
|
let mut last_draw_count = 0usize;
|
|
|
|
|
let mut last_tick = 0u64;
|
|
|
|
|
let mut last_hash = [0u8; 32];
|
|
|
|
|
for _ in 0..args.frames {
|
|
|
|
|
let result = frame(&mut engine).map_err(|err| err.to_string())?;
|
|
|
|
|
last_tick = result.snapshot.tick.0;
|
|
|
|
|
last_hash = result.snapshot.hash.0;
|
2026-06-23 22:05:16 +04:00
|
|
|
let mission_assets = loaded_mission_assets(&engine);
|
2026-07-18 09:21:58 +04:00
|
|
|
let mission_drafts = fparkan_runtime::loaded_mission_object_drafts(&engine);
|
|
|
|
|
let commands =
|
|
|
|
|
render_snapshot_commands_with_assets(&result.snapshot, mission_assets, mission_drafts)
|
|
|
|
|
.map_err(|err| format!("render snapshot: {err}"))?;
|
2026-06-22 13:12:27 +04:00
|
|
|
last_draw_count = commands
|
|
|
|
|
.commands
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|command| matches!(command, RenderCommand::Draw(_)))
|
|
|
|
|
.count();
|
|
|
|
|
backend
|
|
|
|
|
.execute(&commands)
|
|
|
|
|
.map_err(|err| format!("render backend: {err}"))?;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
let capture_report = backend.report();
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
Ok(format!(
|
2026-07-04 01:50:59 +04:00
|
|
|
"{{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"submission_plans\":{},\"last_command_capture_bytes\":{},\"hash\":{}}}",
|
2026-06-22 13:12:27 +04:00
|
|
|
json_string(&args.mission),
|
|
|
|
|
loaded.object_count,
|
|
|
|
|
args.frames,
|
|
|
|
|
last_tick,
|
|
|
|
|
last_draw_count,
|
2026-06-25 06:57:26 +04:00
|
|
|
capture_report.execution.submission_plans,
|
|
|
|
|
capture_report.execution.last_capture_size,
|
2026-06-22 13:12:27 +04:00
|
|
|
json_hash(&last_hash)
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:42:34 +04:00
|
|
|
fn load_requested_mission(
|
|
|
|
|
engine: &mut fparkan_runtime::Engine,
|
|
|
|
|
args: &Args,
|
|
|
|
|
) -> Result<fparkan_runtime::LoadedMission, String> {
|
|
|
|
|
let request = MissionRequest {
|
|
|
|
|
key: args.mission.clone(),
|
|
|
|
|
};
|
|
|
|
|
if let Some(progress_path) = args.load_progress.as_ref() {
|
|
|
|
|
prepare_load_progress_path(progress_path)?;
|
2026-07-18 13:06:44 +04:00
|
|
|
let progress_started = std::time::Instant::now();
|
2026-07-18 09:42:34 +04:00
|
|
|
let mut write_error = None;
|
|
|
|
|
let loaded = if args.backend == RenderBackendMode::StaticVulkan {
|
2026-07-18 10:36:29 +04:00
|
|
|
load_mission_static_preview_roots_with_progress(
|
|
|
|
|
engine,
|
|
|
|
|
request,
|
|
|
|
|
args.preview_roots,
|
|
|
|
|
|phase| {
|
|
|
|
|
if write_error.is_none() {
|
2026-07-18 13:06:44 +04:00
|
|
|
if let Err(err) =
|
|
|
|
|
write_load_progress(progress_path, progress_started.elapsed(), phase)
|
|
|
|
|
{
|
2026-07-18 10:36:29 +04:00
|
|
|
write_error = Some(err);
|
|
|
|
|
}
|
2026-07-18 09:42:34 +04:00
|
|
|
}
|
2026-07-18 10:36:29 +04:00
|
|
|
},
|
|
|
|
|
)
|
2026-07-18 09:42:34 +04:00
|
|
|
} else {
|
|
|
|
|
load_mission_with_progress(engine, request, |phase| {
|
|
|
|
|
if write_error.is_none() {
|
2026-07-18 13:06:44 +04:00
|
|
|
if let Err(err) =
|
|
|
|
|
write_load_progress(progress_path, progress_started.elapsed(), phase)
|
|
|
|
|
{
|
2026-07-18 09:42:34 +04:00
|
|
|
write_error = Some(err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
if let Some(err) = write_error {
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
2026-07-18 13:06:44 +04:00
|
|
|
std::fs::OpenOptions::new()
|
|
|
|
|
.append(true)
|
|
|
|
|
.open(progress_path)
|
|
|
|
|
.and_then(|mut file| {
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
writeln!(
|
|
|
|
|
file,
|
|
|
|
|
"Complete\telapsed_ms={}",
|
|
|
|
|
progress_started.elapsed().as_millis()
|
|
|
|
|
)
|
|
|
|
|
})
|
2026-07-18 09:42:34 +04:00
|
|
|
.map_err(|err| format!("{}: {err}", progress_path.display()))?;
|
|
|
|
|
return Ok(loaded);
|
|
|
|
|
}
|
|
|
|
|
if args.backend == RenderBackendMode::StaticVulkan {
|
2026-07-18 10:36:29 +04:00
|
|
|
if args.preview_roots.get() == 1 {
|
|
|
|
|
load_mission_static_preview(engine, request)
|
|
|
|
|
} else {
|
|
|
|
|
load_mission_static_preview_roots(engine, request, args.preview_roots)
|
|
|
|
|
}
|
2026-07-18 09:42:34 +04:00
|
|
|
} else {
|
|
|
|
|
load_mission(engine, request)
|
|
|
|
|
}
|
|
|
|
|
.map_err(|err| err.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 10:06:34 +04:00
|
|
|
/// Static geometry and descriptors belonging to the first preview root.
|
|
|
|
|
struct StaticPreviewScene {
|
|
|
|
|
mesh: VulkanStaticMesh,
|
|
|
|
|
materials: Vec<VulkanStaticMaterial>,
|
|
|
|
|
mesh_components: usize,
|
2026-07-18 10:21:43 +04:00
|
|
|
terrain_components: usize,
|
2026-07-18 10:06:34 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 10:21:43 +04:00
|
|
|
/// Projects the mission terrain plus every MSH component of the explicitly
|
2026-07-18 10:53:03 +04:00
|
|
|
/// bounded static-preview root into one diagnostic XY frame.
|
2026-07-18 09:52:20 +04:00
|
|
|
///
|
|
|
|
|
/// This intentionally takes only the first MAT0 texture request for each MSH
|
2026-07-18 10:21:43 +04:00
|
|
|
/// batch selector. The terrain uses an explicit white diagnostic texture;
|
|
|
|
|
/// terrain slot/material selection, orientation, camera, later material phases,
|
|
|
|
|
/// animation, lightmaps and gameplay visibility remain outside this bridge.
|
|
|
|
|
fn static_preview_mesh_and_materials(
|
|
|
|
|
assets: &MissionAssets,
|
|
|
|
|
terrain: &TerrainWorld,
|
2026-07-18 10:36:29 +04:00
|
|
|
roots: &[MissionObjectDraft],
|
2026-07-18 10:21:43 +04:00
|
|
|
) -> Result<StaticPreviewScene, String> {
|
|
|
|
|
let terrain_mesh = terrain
|
|
|
|
|
.source_mesh()
|
|
|
|
|
.ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?;
|
2026-07-18 10:53:03 +04:00
|
|
|
let frame = static_preview_xy_frame(assets, terrain, roots)?;
|
2026-07-18 10:06:34 +04:00
|
|
|
let mut mesh = VulkanStaticMesh {
|
|
|
|
|
vertices: Vec::new(),
|
|
|
|
|
indices: Vec::new(),
|
|
|
|
|
draw_ranges: Vec::new(),
|
|
|
|
|
};
|
2026-07-18 10:21:43 +04:00
|
|
|
let mut materials = vec![VulkanStaticMaterial {
|
|
|
|
|
material_index: 0,
|
|
|
|
|
texture: VulkanStaticTexture {
|
|
|
|
|
width: 1,
|
|
|
|
|
height: 1,
|
|
|
|
|
rgba8: vec![255, 255, 255, 255],
|
|
|
|
|
},
|
|
|
|
|
}];
|
2026-07-18 10:53:03 +04:00
|
|
|
let terrain_component = project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame)
|
2026-07-18 10:21:43 +04:00
|
|
|
.map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
|
|
|
|
|
append_static_preview_component(&mut mesh, terrain_component, &[(0, 0)])?;
|
2026-07-18 10:06:34 +04:00
|
|
|
let mut mesh_components = 0;
|
2026-07-18 10:36:29 +04:00
|
|
|
for (object_index, root) in roots.iter().enumerate() {
|
|
|
|
|
for visual_id in assets.visuals_for_object(object_index) {
|
|
|
|
|
let visual = assets.visual_by_id(*visual_id).ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"static preview root {object_index} references unknown visual {visual_id:?}"
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let Some(model_id) = visual.model_id else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let model = assets.model_by_id(model_id).ok_or_else(|| {
|
|
|
|
|
format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
|
|
|
|
|
})?;
|
2026-07-18 10:53:03 +04:00
|
|
|
let component = project_msh_to_static_mesh_in_xy_frame(
|
2026-07-18 10:36:29 +04:00
|
|
|
&model.validated,
|
|
|
|
|
frame,
|
|
|
|
|
root.position,
|
|
|
|
|
root.scale,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
|
|
|
|
|
let selector_remap =
|
|
|
|
|
static_preview_component_materials(assets, visual, &component, &mut materials)?;
|
|
|
|
|
append_static_preview_component(&mut mesh, component, &selector_remap)?;
|
|
|
|
|
mesh_components += 1;
|
|
|
|
|
}
|
2026-07-18 10:06:34 +04:00
|
|
|
}
|
|
|
|
|
if mesh_components == 0 {
|
2026-07-18 10:36:29 +04:00
|
|
|
return Err("selected static preview roots have no mesh-backed visual".to_string());
|
2026-07-18 10:06:34 +04:00
|
|
|
}
|
|
|
|
|
Ok(StaticPreviewScene {
|
|
|
|
|
mesh,
|
|
|
|
|
materials,
|
|
|
|
|
mesh_components,
|
2026-07-18 10:21:43 +04:00
|
|
|
terrain_components: 1,
|
2026-07-18 10:06:34 +04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 10:53:03 +04:00
|
|
|
fn static_preview_xy_frame(
|
2026-07-18 10:21:43 +04:00
|
|
|
assets: &MissionAssets,
|
|
|
|
|
terrain: &TerrainWorld,
|
2026-07-18 10:36:29 +04:00
|
|
|
roots: &[MissionObjectDraft],
|
2026-07-18 10:53:03 +04:00
|
|
|
) -> Result<VulkanStaticXyFrame, String> {
|
2026-07-18 10:21:43 +04:00
|
|
|
let mut min_x = f32::INFINITY;
|
|
|
|
|
let mut max_x = f32::NEG_INFINITY;
|
2026-07-18 10:53:03 +04:00
|
|
|
let mut min_y = f32::INFINITY;
|
|
|
|
|
let mut max_y = f32::NEG_INFINITY;
|
2026-07-18 10:21:43 +04:00
|
|
|
let terrain_positions = terrain
|
|
|
|
|
.source_positions()
|
|
|
|
|
.ok_or_else(|| "runtime terrain does not retain source positions".to_string())?;
|
|
|
|
|
for position in terrain_positions {
|
2026-07-18 10:53:03 +04:00
|
|
|
extend_static_preview_xy_bounds(*position, &mut min_x, &mut max_x, &mut min_y, &mut max_y)?;
|
2026-07-18 10:21:43 +04:00
|
|
|
}
|
2026-07-18 10:36:29 +04:00
|
|
|
for (object_index, root) in roots.iter().enumerate() {
|
|
|
|
|
if !root
|
|
|
|
|
.position
|
|
|
|
|
.iter()
|
|
|
|
|
.chain(root.scale.iter())
|
|
|
|
|
.all(|value| value.is_finite())
|
|
|
|
|
{
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"static preview root {object_index} has a non-finite position or scale"
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
for visual_id in assets.visuals_for_object(object_index) {
|
|
|
|
|
let visual = assets.visual_by_id(*visual_id).ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"static preview root {object_index} references unknown visual {visual_id:?}"
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let Some(model_id) = visual.model_id else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let model = assets.model_by_id(model_id).ok_or_else(|| {
|
|
|
|
|
format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
|
|
|
|
|
})?;
|
|
|
|
|
for position in &model.validated.positions {
|
2026-07-18 10:53:03 +04:00
|
|
|
extend_static_preview_xy_bounds(
|
2026-07-18 10:36:29 +04:00
|
|
|
[
|
|
|
|
|
position[0] * root.scale[0] + root.position[0],
|
|
|
|
|
position[1] * root.scale[1] + root.position[1],
|
|
|
|
|
position[2] * root.scale[2] + root.position[2],
|
|
|
|
|
],
|
|
|
|
|
&mut min_x,
|
|
|
|
|
&mut max_x,
|
2026-07-18 10:53:03 +04:00
|
|
|
&mut min_y,
|
|
|
|
|
&mut max_y,
|
2026-07-18 10:36:29 +04:00
|
|
|
)?;
|
|
|
|
|
}
|
2026-07-18 10:21:43 +04:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-18 10:53:03 +04:00
|
|
|
VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)
|
|
|
|
|
.map_err(|err| format!("build static preview XY frame: {err}"))
|
2026-07-18 10:21:43 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 10:53:03 +04:00
|
|
|
fn extend_static_preview_xy_bounds(
|
2026-07-18 10:21:43 +04:00
|
|
|
position: [f32; 3],
|
|
|
|
|
min_x: &mut f32,
|
|
|
|
|
max_x: &mut f32,
|
2026-07-18 10:53:03 +04:00
|
|
|
min_y: &mut f32,
|
|
|
|
|
max_y: &mut f32,
|
2026-07-18 10:21:43 +04:00
|
|
|
) -> Result<(), String> {
|
|
|
|
|
if !position.iter().all(|value| value.is_finite()) {
|
2026-07-18 10:53:03 +04:00
|
|
|
return Err("static preview contains a non-finite XY position".to_string());
|
2026-07-18 10:21:43 +04:00
|
|
|
}
|
|
|
|
|
*min_x = min_x.min(position[0]);
|
|
|
|
|
*max_x = max_x.max(position[0]);
|
2026-07-18 10:53:03 +04:00
|
|
|
*min_y = min_y.min(position[1]);
|
|
|
|
|
*max_y = max_y.max(position[1]);
|
2026-07-18 10:21:43 +04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 10:06:34 +04:00
|
|
|
fn static_preview_component_materials(
|
2026-07-18 09:52:20 +04:00
|
|
|
assets: &MissionAssets,
|
2026-07-18 10:06:34 +04:00
|
|
|
visual: &PreparedVisual,
|
|
|
|
|
mesh: &VulkanStaticMesh,
|
|
|
|
|
materials: &mut Vec<VulkanStaticMaterial>,
|
|
|
|
|
) -> Result<Vec<(u16, u16)>, String> {
|
|
|
|
|
let mut source_selectors = mesh
|
2026-07-18 09:52:20 +04:00
|
|
|
.draw_ranges
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|range| range.material_index)
|
|
|
|
|
.collect::<Vec<_>>();
|
2026-07-18 10:06:34 +04:00
|
|
|
source_selectors.sort_unstable();
|
|
|
|
|
source_selectors.dedup();
|
|
|
|
|
source_selectors
|
2026-07-18 09:52:20 +04:00
|
|
|
.into_iter()
|
2026-07-18 10:06:34 +04:00
|
|
|
.map(|source_selector| {
|
|
|
|
|
let material_id = visual
|
|
|
|
|
.material_ids
|
|
|
|
|
.get(usize::from(source_selector))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"static preview MSH batch selector {source_selector} has no prepared WEAR material"
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-07-18 09:52:20 +04:00
|
|
|
let material = assets.material_by_id(*material_id).ok_or_else(|| {
|
2026-07-18 10:06:34 +04:00
|
|
|
format!("static preview prepared material {source_selector} is unavailable")
|
2026-07-18 09:52:20 +04:00
|
|
|
})?;
|
|
|
|
|
let texture_name = material.texture_requests.first().ok_or_else(|| {
|
2026-07-18 10:06:34 +04:00
|
|
|
format!("static preview material {source_selector} has no MAT0 diffuse texture")
|
2026-07-18 09:52:20 +04:00
|
|
|
})?;
|
|
|
|
|
let texture = assets
|
|
|
|
|
.textures
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|texture| {
|
|
|
|
|
texture.usage == PreparedTextureUsage::Diffuse
|
|
|
|
|
&& texture.source.name == *texture_name
|
|
|
|
|
})
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!(
|
2026-07-18 10:06:34 +04:00
|
|
|
"static preview diffuse texture {texture_name:?} for material {source_selector} is unavailable"
|
2026-07-18 09:52:20 +04:00
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let image = texture.decode_mip_rgba8(0).map_err(|err| {
|
|
|
|
|
format!(
|
2026-07-18 10:06:34 +04:00
|
|
|
"decode static preview diffuse texture {texture_name:?} for material {source_selector}: {err}"
|
2026-07-18 09:52:20 +04:00
|
|
|
)
|
|
|
|
|
})?;
|
2026-07-18 10:06:34 +04:00
|
|
|
let preview_selector = u16::try_from(materials.len()).map_err(|_| {
|
|
|
|
|
"static preview exceeds the available 16-bit material selector space".to_string()
|
|
|
|
|
})?;
|
|
|
|
|
materials.push(VulkanStaticMaterial {
|
|
|
|
|
material_index: preview_selector,
|
2026-07-18 09:52:20 +04:00
|
|
|
texture: VulkanStaticTexture {
|
|
|
|
|
width: image.width,
|
|
|
|
|
height: image.height,
|
|
|
|
|
rgba8: image.rgba8,
|
|
|
|
|
},
|
2026-07-18 10:06:34 +04:00
|
|
|
});
|
|
|
|
|
Ok((source_selector, preview_selector))
|
2026-07-18 09:52:20 +04:00
|
|
|
})
|
2026-07-18 10:06:34 +04:00
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn append_static_preview_component(
|
|
|
|
|
target: &mut VulkanStaticMesh,
|
|
|
|
|
component: VulkanStaticMesh,
|
|
|
|
|
selector_remap: &[(u16, u16)],
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let vertex_base = u16::try_from(target.vertices.len()).map_err(|_| {
|
|
|
|
|
"static preview exceeds the available 16-bit vertex index space".to_string()
|
|
|
|
|
})?;
|
|
|
|
|
let first_index_base = u32::try_from(target.indices.len())
|
|
|
|
|
.map_err(|_| "static preview index count exceeds u32".to_string())?;
|
|
|
|
|
target
|
|
|
|
|
.vertices
|
|
|
|
|
.len()
|
|
|
|
|
.checked_add(component.vertices.len())
|
|
|
|
|
.filter(|count| *count <= usize::from(u16::MAX) + 1)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
"static preview exceeds the available 16-bit vertex index space".to_string()
|
|
|
|
|
})?;
|
|
|
|
|
target.vertices.extend(component.vertices);
|
|
|
|
|
target.indices.extend(
|
|
|
|
|
component
|
|
|
|
|
.indices
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|index| {
|
|
|
|
|
index.checked_add(vertex_base).ok_or_else(|| {
|
|
|
|
|
"static preview exceeds the available 16-bit vertex index space".to_string()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect::<Result<Vec<_>, _>>()?,
|
|
|
|
|
);
|
|
|
|
|
for range in component.draw_ranges {
|
|
|
|
|
let material_index = selector_remap
|
|
|
|
|
.iter()
|
|
|
|
|
.find_map(|(source, preview)| (*source == range.material_index).then_some(*preview))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"static preview component has no material remap for selector {}",
|
|
|
|
|
range.material_index
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
target
|
|
|
|
|
.draw_ranges
|
|
|
|
|
.push(fparkan_render_vulkan::VulkanStaticDrawRange {
|
|
|
|
|
first_index: first_index_base
|
|
|
|
|
.checked_add(range.first_index)
|
|
|
|
|
.ok_or_else(|| "static preview index range exceeds u32".to_string())?,
|
|
|
|
|
material_index,
|
|
|
|
|
..range
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
2026-07-18 09:52:20 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:42:34 +04:00
|
|
|
fn prepare_load_progress_path(path: &std::path::Path) -> Result<(), String> {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
|
|
|
|
|
}
|
2026-07-18 13:06:44 +04:00
|
|
|
std::fs::write(path, "Starting\telapsed_ms=0\n")
|
|
|
|
|
.map_err(|err| format!("{}: {err}", path.display()))
|
2026-07-18 09:42:34 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 13:06:44 +04:00
|
|
|
fn write_load_progress(
|
|
|
|
|
path: &std::path::Path,
|
|
|
|
|
elapsed: std::time::Duration,
|
|
|
|
|
phase: MissionLoadPhase,
|
|
|
|
|
) -> Result<(), String> {
|
2026-07-18 12:46:00 +04:00
|
|
|
use std::io::Write;
|
|
|
|
|
|
|
|
|
|
let mut file = std::fs::OpenOptions::new()
|
|
|
|
|
.append(true)
|
|
|
|
|
.open(path)
|
|
|
|
|
.map_err(|err| format!("{}: {err}", path.display()))?;
|
2026-07-18 13:06:44 +04:00
|
|
|
writeln!(file, "{phase:?}\telapsed_ms={}", elapsed.as_millis())
|
|
|
|
|
.map_err(|err| format!("{}: {err}", path.display()))
|
2026-07-18 09:42:34 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:31:32 +04:00
|
|
|
fn run_static_vulkan_mode(
|
2026-07-18 10:36:29 +04:00
|
|
|
preview: StaticPreviewScene,
|
|
|
|
|
preview_roots: usize,
|
2026-07-18 09:31:32 +04:00
|
|
|
target_frames: u64,
|
|
|
|
|
mission: &str,
|
|
|
|
|
object_count: usize,
|
|
|
|
|
) -> Result<String, String> {
|
|
|
|
|
let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
|
|
|
|
|
event_loop.set_control_flow(ControlFlow::Poll);
|
2026-07-18 10:36:29 +04:00
|
|
|
let mut app =
|
|
|
|
|
StaticVulkanApp::new(preview, preview_roots, target_frames, mission, object_count);
|
2026-07-18 09:31:32 +04:00
|
|
|
if let Err(err) = event_loop.run_app(&mut app) {
|
|
|
|
|
app.error = Some(format!("winit event loop: {err}"));
|
|
|
|
|
}
|
|
|
|
|
app.finish()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct StaticVulkanApp {
|
|
|
|
|
mesh: VulkanStaticMesh,
|
2026-07-18 09:52:20 +04:00
|
|
|
materials: Vec<VulkanStaticMaterial>,
|
2026-07-18 10:06:34 +04:00
|
|
|
mesh_components: usize,
|
2026-07-18 10:21:43 +04:00
|
|
|
terrain_components: usize,
|
2026-07-18 10:36:29 +04:00
|
|
|
preview_roots: usize,
|
2026-07-18 09:31:32 +04:00
|
|
|
target_frames: u64,
|
|
|
|
|
mission: String,
|
|
|
|
|
object_count: usize,
|
|
|
|
|
window_id: Option<WindowId>,
|
|
|
|
|
window: Option<Window>,
|
|
|
|
|
renderer: Option<VulkanSmokeRenderer>,
|
|
|
|
|
frames_presented: u64,
|
|
|
|
|
output: Option<String>,
|
|
|
|
|
error: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl StaticVulkanApp {
|
2026-07-18 09:52:20 +04:00
|
|
|
fn new(
|
2026-07-18 10:36:29 +04:00
|
|
|
preview: StaticPreviewScene,
|
|
|
|
|
preview_roots: usize,
|
2026-07-18 09:52:20 +04:00
|
|
|
target_frames: u64,
|
|
|
|
|
mission: &str,
|
|
|
|
|
object_count: usize,
|
|
|
|
|
) -> Self {
|
2026-07-18 09:31:32 +04:00
|
|
|
Self {
|
2026-07-18 10:36:29 +04:00
|
|
|
mesh: preview.mesh,
|
|
|
|
|
materials: preview.materials,
|
|
|
|
|
mesh_components: preview.mesh_components,
|
|
|
|
|
terrain_components: preview.terrain_components,
|
|
|
|
|
preview_roots,
|
2026-07-18 09:31:32 +04:00
|
|
|
target_frames,
|
|
|
|
|
mission: mission.to_string(),
|
|
|
|
|
object_count,
|
|
|
|
|
window_id: None,
|
|
|
|
|
window: None,
|
|
|
|
|
renderer: None,
|
|
|
|
|
frames_presented: 0,
|
|
|
|
|
output: None,
|
|
|
|
|
error: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn schedule_next_redraw(&self) {
|
|
|
|
|
if let Some(window) = self.window.as_ref() {
|
|
|
|
|
window.request_redraw();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn complete(&mut self, event_loop: &ActiveEventLoop) {
|
|
|
|
|
let Some(renderer) = self.renderer.take() else {
|
|
|
|
|
self.error = Some("native Vulkan renderer was not initialized".to_string());
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let report = match renderer.shutdown() {
|
|
|
|
|
Ok(report) => report,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
self.error = Some(err.to_string());
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
self.window.take();
|
|
|
|
|
if report.validation.warning_count != 0 || report.validation.error_count != 0 {
|
|
|
|
|
self.error = Some(format!(
|
|
|
|
|
"native Vulkan validation must stay clean (warnings={}, errors={})",
|
|
|
|
|
report.validation.warning_count, report.validation.error_count
|
|
|
|
|
));
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
self.output = Some(format!(
|
2026-07-18 10:36:29 +04:00
|
|
|
"{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"mesh_components\":{},\"terrain_components\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}",
|
2026-07-18 09:31:32 +04:00
|
|
|
json_string(&self.mission),
|
|
|
|
|
self.object_count,
|
|
|
|
|
self.frames_presented,
|
2026-07-18 10:36:29 +04:00
|
|
|
self.preview_roots,
|
2026-07-18 10:06:34 +04:00
|
|
|
self.mesh_components,
|
2026-07-18 10:21:43 +04:00
|
|
|
self.terrain_components,
|
2026-07-18 09:52:20 +04:00
|
|
|
self.materials.len(),
|
2026-07-18 09:31:32 +04:00
|
|
|
report.renderer_report.swapchain_extent.0,
|
|
|
|
|
report.renderer_report.swapchain_extent.1,
|
|
|
|
|
report.renderer_report.swapchain_image_count,
|
|
|
|
|
report.validation.warning_count,
|
|
|
|
|
report.validation.error_count,
|
|
|
|
|
report.renderer_report.readback_byte_count,
|
|
|
|
|
report.renderer_report.readback_fnv1a64,
|
|
|
|
|
));
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn finish(self) -> Result<String, String> {
|
|
|
|
|
self.output.ok_or_else(|| {
|
|
|
|
|
self.error.unwrap_or_else(|| {
|
|
|
|
|
"native Vulkan mode exited before producing a report".to_string()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ApplicationHandler for StaticVulkanApp {
|
|
|
|
|
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
|
|
|
|
if self.window.is_some() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let plan = match WinitWindowPlan::smoke().validate() {
|
|
|
|
|
Ok(plan) => plan,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
self.error = Some(err.to_string());
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let attributes = Window::default_attributes()
|
|
|
|
|
.with_title("FParkan static mission Vulkan")
|
|
|
|
|
.with_inner_size(WinitPhysicalSize::new(plan.width, plan.height));
|
|
|
|
|
let window = match event_loop.create_window(attributes) {
|
|
|
|
|
Ok(window) => window,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
self.error = Some(format!("winit window: {err}"));
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let Some(native_handles) = window_native_handles(&window) else {
|
|
|
|
|
self.error = Some("winit window does not expose native handles".to_string());
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
let size = window.inner_size();
|
|
|
|
|
let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo {
|
|
|
|
|
application_name: "fparkan-game".to_string(),
|
|
|
|
|
native_handles,
|
|
|
|
|
drawable_extent: (size.width.max(1), size.height.max(1)),
|
|
|
|
|
render_request: WinitWindow::default_render_request(),
|
|
|
|
|
enable_validation: true,
|
|
|
|
|
mesh: self.mesh.clone(),
|
2026-07-18 09:52:20 +04:00
|
|
|
materials: self.materials.clone(),
|
2026-07-18 09:31:32 +04:00
|
|
|
bootstrap_progress: None,
|
|
|
|
|
}) {
|
|
|
|
|
Ok(renderer) => renderer,
|
|
|
|
|
Err(err) => {
|
|
|
|
|
self.error = Some(err.to_string());
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
self.window_id = Some(window.id());
|
|
|
|
|
self.renderer = Some(renderer);
|
|
|
|
|
self.window = Some(window);
|
|
|
|
|
self.schedule_next_redraw();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn window_event(
|
|
|
|
|
&mut self,
|
|
|
|
|
event_loop: &ActiveEventLoop,
|
|
|
|
|
window_id: WindowId,
|
|
|
|
|
event: WindowEvent,
|
|
|
|
|
) {
|
|
|
|
|
if Some(window_id) != self.window_id {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
match event {
|
|
|
|
|
WindowEvent::CloseRequested => {
|
|
|
|
|
self.error = Some("native Vulkan window closed before completion".to_string());
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
}
|
|
|
|
|
WindowEvent::Resized(size) => {
|
|
|
|
|
if let Some(renderer) = self.renderer.as_mut() {
|
|
|
|
|
renderer.request_resize((size.width, size.height));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
WindowEvent::RedrawRequested => {
|
|
|
|
|
let Some(renderer) = self.renderer.as_mut() else {
|
|
|
|
|
self.error = Some("native Vulkan renderer was not initialized".to_string());
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
match renderer.draw_frame() {
|
|
|
|
|
Ok(VulkanSmokeFrameOutcome::Presented) => {
|
|
|
|
|
self.frames_presented = self.frames_presented.saturating_add(1);
|
|
|
|
|
}
|
|
|
|
|
Ok(
|
|
|
|
|
VulkanSmokeFrameOutcome::Recreated | VulkanSmokeFrameOutcome::ZeroExtent,
|
|
|
|
|
) => {}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
self.error = Some(err.to_string());
|
|
|
|
|
event_loop.exit();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if self.frames_presented >= self.target_frames {
|
|
|
|
|
self.complete(event_loop);
|
|
|
|
|
} else {
|
|
|
|
|
self.schedule_next_redraw();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
|
|
|
|
|
if self.output.is_none() && self.error.is_none() {
|
|
|
|
|
self.schedule_next_redraw();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:32:50 +04:00
|
|
|
#[cfg(test)]
|
2026-07-18 06:16:44 +04:00
|
|
|
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> Result<RenderCommandList, String> {
|
2026-07-18 09:21:58 +04:00
|
|
|
render_snapshot_commands_with_assets(snapshot, None, None)
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_snapshot_commands_with_assets(
|
|
|
|
|
snapshot: &WorldSnapshot,
|
|
|
|
|
mission_assets: Option<&MissionAssets>,
|
2026-07-18 09:21:58 +04:00
|
|
|
mission_drafts: Option<&[MissionObjectDraft]>,
|
2026-07-18 06:16:44 +04:00
|
|
|
) -> Result<RenderCommandList, String> {
|
2026-07-18 09:21:58 +04:00
|
|
|
let render_snapshot = render_snapshot_with_assets(snapshot, mission_assets, mission_drafts);
|
2026-07-18 06:16:44 +04:00
|
|
|
build_commands(&render_snapshot, RenderProfile::default()).map_err(|err| err.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_snapshot_with_assets(
|
|
|
|
|
snapshot: &WorldSnapshot,
|
|
|
|
|
mission_assets: Option<&MissionAssets>,
|
2026-07-18 09:21:58 +04:00
|
|
|
mission_drafts: Option<&[MissionObjectDraft]>,
|
2026-07-18 06:16:44 +04:00
|
|
|
) -> RenderSnapshot {
|
|
|
|
|
let mut draws = Vec::with_capacity(snapshot.objects.len());
|
2026-06-22 13:12:27 +04:00
|
|
|
for (index, handle) in snapshot.objects.iter().enumerate() {
|
2026-07-18 09:19:56 +04:00
|
|
|
let visuals = mission_assets.map_or(&[][..], |assets| assets.visuals_for_object(index));
|
|
|
|
|
let visual_count = visuals.len().max(1);
|
|
|
|
|
for visual_index in 0..visual_count {
|
|
|
|
|
let prepared = mission_assets.and_then(|assets| {
|
|
|
|
|
visuals
|
|
|
|
|
.get(visual_index)
|
|
|
|
|
.and_then(|visual_id| assets.visual_by_id(*visual_id))
|
|
|
|
|
});
|
|
|
|
|
let mesh = prepared.map_or_else(
|
2026-06-23 22:05:16 +04:00
|
|
|
|| GpuMeshId(u64::from(handle.slot) + 1),
|
2026-07-18 09:19:56 +04:00
|
|
|
|visual| GpuMeshId(visual.id.raw()),
|
|
|
|
|
);
|
|
|
|
|
let material = prepared
|
|
|
|
|
.and_then(PreparedVisual::primary_material_id)
|
|
|
|
|
.map_or(GpuMaterialId(1), |material_id| {
|
|
|
|
|
GpuMaterialId(material_id.raw())
|
|
|
|
|
});
|
|
|
|
|
let stable_order = if visual_count == 1 {
|
|
|
|
|
u64::from(handle.slot)
|
|
|
|
|
} else {
|
|
|
|
|
u64::from(handle.slot)
|
|
|
|
|
.wrapping_mul(1_000_003)
|
|
|
|
|
.wrapping_add(u64::try_from(visual_index).unwrap_or(u64::MAX))
|
|
|
|
|
};
|
|
|
|
|
let draw_id = snapshot
|
|
|
|
|
.tick
|
|
|
|
|
.0
|
|
|
|
|
.wrapping_mul(1_000_003)
|
|
|
|
|
.wrapping_add(stable_order);
|
|
|
|
|
draws.push(RenderSnapshotDraw {
|
|
|
|
|
id: DrawId(draw_id),
|
|
|
|
|
phase: RenderPhase::Opaque,
|
2026-07-18 09:24:45 +04:00
|
|
|
object_id: mission_drafts
|
|
|
|
|
.and_then(|drafts| drafts.get(index))
|
|
|
|
|
.and_then(|draft| draft.original_id),
|
2026-07-18 09:19:56 +04:00
|
|
|
mesh,
|
|
|
|
|
material_slots: vec![material],
|
|
|
|
|
material_index: 0,
|
|
|
|
|
pipeline_state: fparkan_render::LegacyPipelineState::default(),
|
2026-07-18 09:21:58 +04:00
|
|
|
transform: mission_drafts
|
|
|
|
|
.and_then(|drafts| drafts.get(index))
|
|
|
|
|
.map_or_else(
|
|
|
|
|
|| identity_transform(index_to_f32(index)),
|
|
|
|
|
mission_position_scale_transform,
|
|
|
|
|
),
|
2026-07-18 09:19:56 +04:00
|
|
|
range: IndexRange { start: 0, count: 3 },
|
|
|
|
|
stable_order,
|
2026-06-23 22:32:50 +04:00
|
|
|
});
|
2026-07-18 09:19:56 +04:00
|
|
|
}
|
2026-07-18 06:16:44 +04:00
|
|
|
}
|
|
|
|
|
RenderSnapshot {
|
|
|
|
|
camera: CameraSnapshot::default(),
|
|
|
|
|
draws,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:21:58 +04:00
|
|
|
fn mission_position_scale_transform(draft: &MissionObjectDraft) -> [f32; 16] {
|
|
|
|
|
[
|
|
|
|
|
draft.scale[0],
|
|
|
|
|
0.0,
|
|
|
|
|
0.0,
|
|
|
|
|
draft.position[0],
|
|
|
|
|
0.0,
|
|
|
|
|
draft.scale[1],
|
|
|
|
|
0.0,
|
|
|
|
|
draft.position[1],
|
|
|
|
|
0.0,
|
|
|
|
|
0.0,
|
|
|
|
|
draft.scale[2],
|
|
|
|
|
draft.position[2],
|
|
|
|
|
0.0,
|
|
|
|
|
0.0,
|
|
|
|
|
0.0,
|
|
|
|
|
1.0,
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
fn identity_transform(x: f32) -> [f32; 16] {
|
|
|
|
|
[
|
|
|
|
|
1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn index_to_f32(index: usize) -> f32 {
|
|
|
|
|
u16::try_from(index).map_or(f32::from(u16::MAX), f32::from)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
struct Args {
|
|
|
|
|
root: PathBuf,
|
|
|
|
|
mission: String,
|
|
|
|
|
frames: u64,
|
2026-07-18 09:31:32 +04:00
|
|
|
backend: RenderBackendMode,
|
2026-07-18 09:42:34 +04:00
|
|
|
load_progress: Option<PathBuf>,
|
2026-07-18 10:36:29 +04:00
|
|
|
preview_roots: NonZeroUsize,
|
2026-07-18 09:31:32 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
enum RenderBackendMode {
|
|
|
|
|
Planning,
|
|
|
|
|
StaticVulkan,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Args {
|
|
|
|
|
fn parse(args: &[String]) -> Result<Self, String> {
|
|
|
|
|
let mut root = None;
|
|
|
|
|
let mut mission = None;
|
|
|
|
|
let mut frames = 1;
|
2026-07-18 09:31:32 +04:00
|
|
|
let mut backend = RenderBackendMode::Planning;
|
2026-07-18 09:42:34 +04:00
|
|
|
let mut load_progress = None;
|
2026-07-18 10:36:29 +04:00
|
|
|
let mut preview_roots = NonZeroUsize::MIN;
|
2026-06-22 13:12:27 +04:00
|
|
|
let mut iter = args.iter();
|
|
|
|
|
while let Some(arg) = iter.next() {
|
|
|
|
|
match arg.as_str() {
|
|
|
|
|
"--root" => {
|
|
|
|
|
root = Some(
|
|
|
|
|
iter.next()
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.ok_or_else(|| "--root requires a path".to_string())?,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
"--mission" => {
|
|
|
|
|
mission = Some(
|
|
|
|
|
iter.next()
|
|
|
|
|
.cloned()
|
|
|
|
|
.ok_or_else(|| "--mission requires a path".to_string())?,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
"--frames" => {
|
|
|
|
|
frames = iter
|
|
|
|
|
.next()
|
|
|
|
|
.ok_or_else(|| "--frames requires a value".to_string())?
|
|
|
|
|
.parse()
|
|
|
|
|
.map_err(|_| "--frames must be an integer".to_string())?;
|
|
|
|
|
}
|
2026-07-18 09:31:32 +04:00
|
|
|
"--backend" => {
|
|
|
|
|
backend = match iter
|
|
|
|
|
.next()
|
|
|
|
|
.ok_or_else(|| "--backend requires a value".to_string())?
|
|
|
|
|
.as_str()
|
|
|
|
|
{
|
|
|
|
|
"planning" => RenderBackendMode::Planning,
|
|
|
|
|
"static-vulkan" => RenderBackendMode::StaticVulkan,
|
|
|
|
|
_ => return Err("--backend must be planning or static-vulkan".to_string()),
|
|
|
|
|
};
|
|
|
|
|
}
|
2026-07-18 09:42:34 +04:00
|
|
|
"--load-progress" => {
|
|
|
|
|
load_progress = Some(
|
|
|
|
|
iter.next()
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.ok_or_else(|| "--load-progress requires a path".to_string())?,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-18 10:36:29 +04:00
|
|
|
"--preview-roots" => {
|
|
|
|
|
preview_roots = iter
|
|
|
|
|
.next()
|
|
|
|
|
.ok_or_else(|| "--preview-roots requires a value".to_string())?
|
|
|
|
|
.parse()
|
|
|
|
|
.map_err(|_| "--preview-roots must be a non-zero integer".to_string())?;
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
_ => return Err(usage()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let root = root.ok_or_else(|| "missing --root".to_string())?;
|
|
|
|
|
let mission = mission.ok_or_else(|| "missing --mission".to_string())?;
|
|
|
|
|
if frames == 0 {
|
|
|
|
|
return Err("--frames must be greater than zero".to_string());
|
|
|
|
|
}
|
|
|
|
|
Ok(Self {
|
|
|
|
|
root,
|
|
|
|
|
mission,
|
|
|
|
|
frames,
|
2026-07-18 09:31:32 +04:00
|
|
|
backend,
|
2026-07-18 09:42:34 +04:00
|
|
|
load_progress,
|
2026-07-18 10:36:29 +04:00
|
|
|
preview_roots,
|
2026-06-22 13:12:27 +04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn json_string(value: &str) -> String {
|
|
|
|
|
let mut out = String::with_capacity(value.len() + 2);
|
|
|
|
|
out.push('"');
|
|
|
|
|
for ch in value.chars() {
|
|
|
|
|
match ch {
|
|
|
|
|
'"' => out.push_str("\\\""),
|
|
|
|
|
'\\' => out.push_str("\\\\"),
|
|
|
|
|
'\n' => out.push_str("\\n"),
|
|
|
|
|
'\r' => out.push_str("\\r"),
|
|
|
|
|
'\t' => out.push_str("\\t"),
|
|
|
|
|
c if c.is_control() => {
|
|
|
|
|
use std::fmt::Write as _;
|
2026-06-23 22:05:16 +04:00
|
|
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
c => out.push(c),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
out.push('"');
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn json_hash(hash: &[u8; 32]) -> String {
|
|
|
|
|
let mut out = String::from("\"");
|
|
|
|
|
for byte in hash {
|
|
|
|
|
use std::fmt::Write as _;
|
|
|
|
|
let _ = write!(out, "{byte:02x}");
|
|
|
|
|
}
|
|
|
|
|
out.push('"');
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn usage() -> String {
|
2026-07-18 10:36:29 +04:00
|
|
|
"usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--load-progress <path>]".to_string()
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2026-07-18 09:19:56 +04:00
|
|
|
use fparkan_assets::AssetId;
|
2026-06-22 13:12:27 +04:00
|
|
|
use fparkan_world::{ObjectHandle, StateHash, Tick};
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
fn strings(values: &[&str]) -> Vec<String> {
|
|
|
|
|
values.iter().map(|value| (*value).to_string()).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_required_args() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
Args::parse(&strings(&[
|
|
|
|
|
"--root",
|
|
|
|
|
"testdata/IS",
|
|
|
|
|
"--mission",
|
|
|
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
|
|
|
"--frames",
|
|
|
|
|
"3",
|
|
|
|
|
])),
|
|
|
|
|
Ok(Args {
|
|
|
|
|
root: PathBuf::from("testdata/IS"),
|
|
|
|
|
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
|
|
|
frames: 3,
|
2026-07-18 09:31:32 +04:00
|
|
|
backend: RenderBackendMode::Planning,
|
2026-07-18 09:42:34 +04:00
|
|
|
load_progress: None,
|
2026-07-18 10:36:29 +04:00
|
|
|
preview_roots: NonZeroUsize::MIN,
|
2026-07-18 09:31:32 +04:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parses_static_vulkan_backend() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
Args::parse(&strings(&[
|
|
|
|
|
"--root",
|
|
|
|
|
"testdata/IS",
|
|
|
|
|
"--mission",
|
|
|
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
|
|
|
"--backend",
|
|
|
|
|
"static-vulkan",
|
|
|
|
|
])),
|
|
|
|
|
Ok(Args {
|
|
|
|
|
root: PathBuf::from("testdata/IS"),
|
|
|
|
|
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
|
|
|
frames: 1,
|
|
|
|
|
backend: RenderBackendMode::StaticVulkan,
|
2026-07-18 09:42:34 +04:00
|
|
|
load_progress: None,
|
2026-07-18 10:36:29 +04:00
|
|
|
preview_roots: NonZeroUsize::MIN,
|
2026-07-18 09:42:34 +04:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 10:36:29 +04:00
|
|
|
#[test]
|
|
|
|
|
fn parses_nonzero_static_preview_root_count() {
|
|
|
|
|
let parsed = Args::parse(&strings(&[
|
|
|
|
|
"--root",
|
|
|
|
|
"testdata/IS",
|
|
|
|
|
"--mission",
|
|
|
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
|
|
|
"--backend",
|
|
|
|
|
"static-vulkan",
|
|
|
|
|
"--preview-roots",
|
|
|
|
|
"2",
|
|
|
|
|
]))
|
|
|
|
|
.expect("valid static preview arguments");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parsed.preview_roots,
|
|
|
|
|
NonZeroUsize::new(2).expect("non-zero literal")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn rejects_zero_static_preview_root_count() {
|
|
|
|
|
let error = Args::parse(&strings(&[
|
|
|
|
|
"--root",
|
|
|
|
|
"testdata/IS",
|
|
|
|
|
"--mission",
|
|
|
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
|
|
|
"--preview-roots",
|
|
|
|
|
"0",
|
|
|
|
|
]));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
error,
|
|
|
|
|
Err("--preview-roots must be a non-zero integer".to_string())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:42:34 +04:00
|
|
|
#[test]
|
|
|
|
|
fn parses_load_progress_path() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
Args::parse(&strings(&[
|
|
|
|
|
"--root",
|
|
|
|
|
"testdata/IS",
|
|
|
|
|
"--mission",
|
|
|
|
|
"MISSIONS/Autodemo.00/data.tma",
|
|
|
|
|
"--load-progress",
|
|
|
|
|
"target/probe.txt",
|
|
|
|
|
])),
|
|
|
|
|
Ok(Args {
|
|
|
|
|
root: PathBuf::from("testdata/IS"),
|
|
|
|
|
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
|
|
|
|
frames: 1,
|
|
|
|
|
backend: RenderBackendMode::Planning,
|
|
|
|
|
load_progress: Some(PathBuf::from("target/probe.txt")),
|
2026-07-18 10:36:29 +04:00
|
|
|
preview_roots: NonZeroUsize::MIN,
|
2026-06-22 13:12:27 +04:00
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 12:46:00 +04:00
|
|
|
#[test]
|
|
|
|
|
fn load_progress_retains_prior_milestones() -> Result<(), String> {
|
|
|
|
|
let path =
|
|
|
|
|
std::env::temp_dir().join(format!("fparkan-game-progress-{}.txt", std::process::id()));
|
|
|
|
|
prepare_load_progress_path(&path)?;
|
2026-07-18 13:06:44 +04:00
|
|
|
write_load_progress(
|
|
|
|
|
&path,
|
|
|
|
|
std::time::Duration::from_millis(12),
|
|
|
|
|
MissionLoadPhase::GraphVisualMaterials,
|
|
|
|
|
)?;
|
|
|
|
|
write_load_progress(
|
|
|
|
|
&path,
|
|
|
|
|
std::time::Duration::from_millis(34),
|
2026-07-18 13:24:00 +04:00
|
|
|
MissionLoadPhase::GraphVisualMaterialRequests {
|
|
|
|
|
request_count: 64,
|
|
|
|
|
graph_node_count: 128,
|
|
|
|
|
graph_edge_count: 192,
|
|
|
|
|
},
|
2026-07-18 13:06:44 +04:00
|
|
|
)?;
|
2026-07-18 12:46:00 +04:00
|
|
|
let progress = std::fs::read_to_string(&path).map_err(|err| err.to_string())?;
|
|
|
|
|
std::fs::remove_file(&path).map_err(|err| err.to_string())?;
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
progress,
|
2026-07-18 13:24:00 +04:00
|
|
|
"Starting\telapsed_ms=0\nGraphVisualMaterials\telapsed_ms=12\nGraphVisualMaterialRequests { request_count: 64, graph_node_count: 128, graph_edge_count: 192 }\telapsed_ms=34\n"
|
2026-07-18 12:46:00 +04:00
|
|
|
);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 10:06:34 +04:00
|
|
|
#[test]
|
|
|
|
|
fn static_preview_component_merge_offsets_indices_and_remaps_local_selectors(
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
let mut merged = VulkanStaticMesh {
|
|
|
|
|
vertices: Vec::new(),
|
|
|
|
|
indices: Vec::new(),
|
|
|
|
|
draw_ranges: Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
append_static_preview_component(
|
|
|
|
|
&mut merged,
|
|
|
|
|
VulkanStaticMesh::smoke_triangle(),
|
|
|
|
|
&[(0, 4)],
|
|
|
|
|
)?;
|
|
|
|
|
append_static_preview_component(
|
|
|
|
|
&mut merged,
|
|
|
|
|
VulkanStaticMesh::smoke_triangle(),
|
|
|
|
|
&[(0, 9)],
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
assert_eq!(merged.vertices.len(), 6);
|
|
|
|
|
assert_eq!(merged.indices, vec![0, 1, 2, 3, 4, 5]);
|
|
|
|
|
assert_eq!(merged.draw_ranges.len(), 2);
|
|
|
|
|
assert_eq!(merged.draw_ranges[0].first_index, 0);
|
|
|
|
|
assert_eq!(merged.draw_ranges[0].material_index, 4);
|
|
|
|
|
assert_eq!(merged.draw_ranges[1].first_index, 3);
|
|
|
|
|
assert_eq!(merged.draw_ranges[1].material_index, 9);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
|
|
|
|
fn render_commands_follow_snapshot_order() -> Result<(), String> {
|
|
|
|
|
let snapshot = WorldSnapshot {
|
|
|
|
|
tick: Tick(7),
|
|
|
|
|
objects: vec![
|
|
|
|
|
ObjectHandle {
|
|
|
|
|
generation: 1,
|
|
|
|
|
slot: 2,
|
|
|
|
|
},
|
|
|
|
|
ObjectHandle {
|
|
|
|
|
generation: 1,
|
|
|
|
|
slot: 5,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
events: Vec::new(),
|
|
|
|
|
hash: StateHash([0; 32]),
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-18 06:16:44 +04:00
|
|
|
let commands = render_snapshot_commands(&snapshot)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
|
|
|
|
|
assert_eq!(commands.commands.len(), 4);
|
2026-07-18 06:16:44 +04:00
|
|
|
assert!(matches!(
|
|
|
|
|
commands.commands[0],
|
|
|
|
|
fparkan_render::RenderCommand::BeginFrame
|
|
|
|
|
));
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
commands.commands[3],
|
|
|
|
|
fparkan_render::RenderCommand::EndFrame
|
|
|
|
|
));
|
|
|
|
|
let fparkan_render::RenderCommand::Draw(first) = &commands.commands[1] else {
|
2026-06-22 13:12:27 +04:00
|
|
|
return Err("expected draw".to_string());
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(first.mesh, GpuMeshId(3));
|
|
|
|
|
assert_eq!(first.stable_order, 2);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:19:56 +04:00
|
|
|
#[test]
|
|
|
|
|
fn render_snapshot_retains_every_prepared_visual_for_an_object() -> Result<(), String> {
|
|
|
|
|
let snapshot = WorldSnapshot {
|
|
|
|
|
tick: Tick(3),
|
|
|
|
|
objects: vec![ObjectHandle {
|
|
|
|
|
generation: 1,
|
|
|
|
|
slot: 7,
|
|
|
|
|
}],
|
|
|
|
|
events: Vec::new(),
|
|
|
|
|
hash: StateHash([0; 32]),
|
|
|
|
|
};
|
|
|
|
|
let first = prepared_visual(101, 201);
|
|
|
|
|
let second = prepared_visual(102, 202);
|
|
|
|
|
let assets = MissionAssets {
|
|
|
|
|
visuals: vec![first, second],
|
|
|
|
|
object_visuals: vec![vec![AssetId::new(101), AssetId::new(102)]],
|
|
|
|
|
..MissionAssets::default()
|
|
|
|
|
};
|
2026-07-18 09:21:58 +04:00
|
|
|
let commands = render_snapshot_commands_with_assets(&snapshot, Some(&assets), None)?;
|
2026-07-18 09:19:56 +04:00
|
|
|
let draws: Vec<_> = commands
|
|
|
|
|
.commands
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|command| match command {
|
|
|
|
|
RenderCommand::Draw(draw) => Some(draw),
|
|
|
|
|
_ => None,
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(draws.len(), 2);
|
|
|
|
|
assert_eq!(draws[0].mesh, GpuMeshId(101));
|
|
|
|
|
assert_eq!(draws[1].mesh, GpuMeshId(102));
|
|
|
|
|
assert_eq!(draws[0].material, GpuMaterialId(201));
|
|
|
|
|
assert_eq!(draws[1].material, GpuMaterialId(202));
|
|
|
|
|
assert_ne!(draws[0].stable_order, draws[1].stable_order);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:21:58 +04:00
|
|
|
#[test]
|
|
|
|
|
fn render_snapshot_uses_mission_position_and_scale_without_interpreting_orientation() {
|
|
|
|
|
let draft = MissionObjectDraft {
|
|
|
|
|
original_id: None,
|
|
|
|
|
resource_name_raw: Vec::new(),
|
|
|
|
|
identity_or_clan_raw: 0,
|
|
|
|
|
position: [10.0, 20.0, 30.0],
|
|
|
|
|
orientation_raw: [1.0, 2.0, 3.0],
|
|
|
|
|
scale: [2.0, 3.0, 4.0],
|
|
|
|
|
visual_ids: Vec::new(),
|
|
|
|
|
properties: Vec::new(),
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(
|
|
|
|
|
mission_position_scale_transform(&draft),
|
|
|
|
|
[2.0, 0.0, 0.0, 10.0, 0.0, 3.0, 0.0, 20.0, 0.0, 0.0, 4.0, 30.0, 0.0, 0.0, 0.0, 1.0,]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:24:45 +04:00
|
|
|
#[test]
|
|
|
|
|
fn render_snapshot_preserves_mission_original_object_id() -> Result<(), String> {
|
|
|
|
|
let snapshot = WorldSnapshot {
|
|
|
|
|
tick: Tick(1),
|
|
|
|
|
objects: vec![ObjectHandle {
|
|
|
|
|
generation: 1,
|
|
|
|
|
slot: 0,
|
|
|
|
|
}],
|
|
|
|
|
events: Vec::new(),
|
|
|
|
|
hash: StateHash([0; 32]),
|
|
|
|
|
};
|
|
|
|
|
let drafts = vec![MissionObjectDraft {
|
|
|
|
|
original_id: Some(OriginalObjectId(42)),
|
|
|
|
|
resource_name_raw: Vec::new(),
|
|
|
|
|
identity_or_clan_raw: 0,
|
|
|
|
|
position: [0.0; 3],
|
|
|
|
|
orientation_raw: [0.0; 3],
|
|
|
|
|
scale: [1.0; 3],
|
|
|
|
|
visual_ids: Vec::new(),
|
|
|
|
|
properties: Vec::new(),
|
|
|
|
|
}];
|
|
|
|
|
|
|
|
|
|
let commands = render_snapshot_commands_with_assets(&snapshot, None, Some(&drafts))?;
|
|
|
|
|
let RenderCommand::Draw(draw) = &commands.commands[1] else {
|
|
|
|
|
return Err("expected draw".to_string());
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(draw.object_id, Some(OriginalObjectId(42)));
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:19:56 +04:00
|
|
|
fn prepared_visual(id: u64, material: u64) -> PreparedVisual {
|
|
|
|
|
PreparedVisual {
|
|
|
|
|
id: AssetId::new(id),
|
|
|
|
|
mesh: None,
|
|
|
|
|
model_id: None,
|
|
|
|
|
wear_id: None,
|
|
|
|
|
model_nodes: 0,
|
|
|
|
|
model_slots: 0,
|
|
|
|
|
model_batches: 0,
|
|
|
|
|
material_count: 1,
|
|
|
|
|
material_ids: vec![AssetId::new(material)],
|
|
|
|
|
texture_ids: Vec::new(),
|
|
|
|
|
lightmap_ids: Vec::new(),
|
|
|
|
|
texture_count: 0,
|
|
|
|
|
lightmap_count: 0,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
fn selected_is_and_is2_missions_produce_approved_render_captures() {
|
|
|
|
|
for case in [
|
|
|
|
|
RenderCase {
|
|
|
|
|
root: "IS",
|
|
|
|
|
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
|
2026-07-18 07:09:24 +04:00
|
|
|
expected: "{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"submission_plans\":1,\"last_command_capture_bytes\":2008,\"hash\":\"ca17cc76e55c45e83c1c9c1c088e84bf1a698be91a7730943210fe27596af841\"}",
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
|
|
|
|
RenderCase {
|
|
|
|
|
root: "IS2",
|
|
|
|
|
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
2026-07-18 07:09:24 +04:00
|
|
|
expected: "{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"submission_plans\":1,\"last_command_capture_bytes\":605,\"hash\":\"5d720b3ab690076a398a79a404850bbeaee2e33811b5bb570ec8a96d4a7a2fc4\"}",
|
2026-06-22 13:12:27 +04:00
|
|
|
},
|
|
|
|
|
] {
|
|
|
|
|
assert_eq!(
|
2026-06-22 17:29:33 +04:00
|
|
|
run(&render_args(&licensed_root(case.root), case.mission)),
|
2026-06-22 13:12:27 +04:00
|
|
|
Ok(case.expected.to_string())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn json_hash_is_hex() {
|
|
|
|
|
let mut hash = [0; 32];
|
|
|
|
|
hash[0] = 0xab;
|
|
|
|
|
hash[31] = 0xcd;
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
json_hash(&hash),
|
|
|
|
|
"\"ab000000000000000000000000000000000000000000000000000000000000cd\""
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
struct RenderCase {
|
|
|
|
|
root: &'static str,
|
|
|
|
|
mission: &'static str,
|
|
|
|
|
expected: &'static str,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_args(root: &Path, mission: &str) -> Vec<String> {
|
|
|
|
|
vec![
|
|
|
|
|
"--root".to_string(),
|
|
|
|
|
root.to_str().expect("utf8 root").to_string(),
|
|
|
|
|
"--mission".to_string(),
|
|
|
|
|
mission.to_string(),
|
|
|
|
|
"--frames".to_string(),
|
|
|
|
|
"1".to_string(),
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 17:29:33 +04:00
|
|
|
fn licensed_root(name: &str) -> PathBuf {
|
|
|
|
|
let variable = match name {
|
|
|
|
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
|
|
|
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
|
|
|
|
_ => panic!("unknown licensed corpus part: {name}"),
|
|
|
|
|
};
|
|
|
|
|
let root = std::env::var_os(variable)
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
|
|
|
|
assert!(
|
|
|
|
|
root.is_dir(),
|
|
|
|
|
"licensed corpus root is missing: {}",
|
|
|
|
|
root.display()
|
|
|
|
|
);
|
|
|
|
|
root
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
}
|