Files
fparkan/apps/fparkan-game/src/main.rs
T

1341 lines
47 KiB
Rust
Raw Normal View History

#![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
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` render-planning composition root.
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};
use fparkan_render::{
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend,
RenderCommand, RenderCommandList, RenderPhase, RenderProfile, RenderSnapshot,
RenderSnapshotDraw,
};
2026-07-18 09:31:32 +04:00
use fparkan_render_vulkan::{
project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_xy_frame,
VulkanPlanningBackend, VulkanSmokeFrameOutcome, VulkanSmokeRenderer,
VulkanSmokeRendererCreateInfo, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
VulkanStaticXyFrame,
2026-07-18 09:31:32 +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,
};
use fparkan_terrain::TerrainWorld;
use fparkan_vfs::DirectoryVfs;
#[cfg(test)]
use fparkan_world::OriginalObjectId;
use fparkan_world::WorldSnapshot;
2026-07-18 10:36:29 +04:00
use std::num::NonZeroUsize;
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};
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-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())?;
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
}
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();
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);
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}"))?;
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();
Ok(format!(
"{{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"submission_plans\":{},\"last_command_capture_bytes\":{},\"hash\":{}}}",
json_string(&args.mission),
loaded.object_count,
args.frames,
last_tick,
last_draw_count,
capture_report.execution.submission_plans,
capture_report.execution.last_capture_size,
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,
terrain_components: usize,
2026-07-18 10:06:34 +04:00
}
/// Projects the mission terrain plus every MSH component of the explicitly
/// 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
/// 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],
) -> Result<StaticPreviewScene, String> {
let terrain_mesh = terrain
.source_mesh()
.ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?;
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(),
};
let mut materials = vec![VulkanStaticMaterial {
material_index: 0,
texture: VulkanStaticTexture {
width: 1,
height: 1,
rgba8: vec![255, 255, 255, 255],
},
}];
let terrain_component = project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame)
.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:?}")
})?;
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,
terrain_components: 1,
2026-07-18 10:06:34 +04:00
})
}
fn static_preview_xy_frame(
assets: &MissionAssets,
terrain: &TerrainWorld,
2026-07-18 10:36:29 +04:00
roots: &[MissionObjectDraft],
) -> Result<VulkanStaticXyFrame, String> {
let mut min_x = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut min_y = f32::INFINITY;
let mut max_y = f32::NEG_INFINITY;
let terrain_positions = terrain
.source_positions()
.ok_or_else(|| "runtime terrain does not retain source positions".to_string())?;
for position in terrain_positions {
extend_static_preview_xy_bounds(*position, &mut min_x, &mut max_x, &mut min_y, &mut max_y)?;
}
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 {
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,
&mut min_y,
&mut max_y,
2026-07-18 10:36:29 +04:00
)?;
}
}
}
VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)
.map_err(|err| format!("build static preview XY frame: {err}"))
}
fn extend_static_preview_xy_bounds(
position: [f32; 3],
min_x: &mut f32,
max_x: &mut f32,
min_y: &mut f32,
max_y: &mut f32,
) -> Result<(), String> {
if !position.iter().all(|value| value.is_finite()) {
return Err("static preview contains a non-finite XY position".to_string());
}
*min_x = min_x.min(position[0]);
*max_x = max_x.max(position[0]);
*min_y = min_y.min(position[1]);
*max_y = max_y.max(position[1]);
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,
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,
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)]
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> Result<RenderCommandList, String> {
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>,
mission_drafts: Option<&[MissionObjectDraft]>,
) -> Result<RenderCommandList, String> {
let render_snapshot = render_snapshot_with_assets(snapshot, mission_assets, mission_drafts);
build_commands(&render_snapshot, RenderProfile::default()).map_err(|err| err.to_string())
}
fn render_snapshot_with_assets(
snapshot: &WorldSnapshot,
mission_assets: Option<&MissionAssets>,
mission_drafts: Option<&[MissionObjectDraft]>,
) -> RenderSnapshot {
let mut draws = Vec::with_capacity(snapshot.objects.len());
for (index, handle) in snapshot.objects.iter().enumerate() {
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),
|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,
object_id: mission_drafts
.and_then(|drafts| drafts.get(index))
.and_then(|draft| draft.original_id),
mesh,
material_slots: vec![material],
material_index: 0,
pipeline_state: fparkan_render::LegacyPipelineState::default(),
transform: mission_drafts
.and_then(|drafts| drafts.get(index))
.map_or_else(
|| identity_transform(index_to_f32(index)),
mission_position_scale_transform,
),
range: IndexRange { start: 0, count: 3 },
stable_order,
2026-06-23 22:32:50 +04:00
});
}
}
RenderSnapshot {
camera: CameraSnapshot::default(),
draws,
}
}
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,
]
}
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,
}
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;
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())?;
}
_ => 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,
})
}
}
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);
}
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()
}
#[cfg(test)]
mod tests {
use super::*;
use fparkan_assets::AssetId;
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-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),
MissionLoadPhase::GraphVisualMaterialRequests(64),
)?;
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:06:44 +04:00
"Starting\telapsed_ms=0\nGraphVisualMaterials\telapsed_ms=12\nGraphVisualMaterialRequests(64)\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(())
}
#[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]),
};
let commands = render_snapshot_commands(&snapshot)?;
assert_eq!(commands.commands.len(), 4);
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 {
return Err("expected draw".to_string());
};
assert_eq!(first.mesh, GpuMeshId(3));
assert_eq!(first.stable_order, 2);
Ok(())
}
#[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()
};
let commands = render_snapshot_commands_with_assets(&snapshot, Some(&assets), None)?;
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(())
}
#[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,]
);
}
#[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(())
}
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,
}
}
#[test]
#[ignore = "requires licensed corpus"]
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",
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\"}",
},
RenderCase {
root: "IS2",
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
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\"}",
},
] {
assert_eq!(
2026-06-22 17:29:33 +04:00
run(&render_args(&licensed_root(case.root), case.mission)),
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
}
}