use super::{CapturedViewport, PreviewCameraFrame}; use fparkan_render::{LegacyD3d7Projection, RawCameraTransform}; use serde::Deserialize; #[derive(Clone, Debug, Deserialize, PartialEq)] pub(super) struct LegacyCameraCapture { pub(super) schema: String, pub(super) render_input_usable: Option, pub(super) selector0_words: [u32; 16], pub(super) parented_camera: Option, pub(super) effective_world_matrix_space: Option, pub(super) effective_world_matrix_words: Option<[u32; 16]>, pub(super) viewport: [i32; 4], pub(super) near_plane: f32, pub(super) far_plane: f32, pub(super) field_of_view_radians: f32, pub(super) atmosphere_seconds: Option, pub(super) geometry_projection: Option, } #[derive(Clone, Debug, Deserialize, PartialEq)] pub(super) struct GeometryProjection { verified: bool, viewport: [i32; 4], near_plane: f32, far_plane: f32, field_of_view_radians: f32, } pub(super) fn selected_atmosphere_seconds( command_line: Option, capture: Option<&LegacyCameraCapture>, ) -> Option { command_line.or_else(|| capture.and_then(|capture| capture.atmosphere_seconds)) } impl LegacyCameraCapture { pub(super) fn preview_matrix_words(&self) -> Result<[u32; 16], String> { match ( self.effective_world_matrix_space.as_deref(), self.effective_world_matrix_words, ) { (Some("world"), Some(words)) => Ok(words), (Some(_), _) => Err( "legacy camera effective_world_matrix_space must be 'world'".to_string(), ), (None, Some(_)) => Err( "legacy camera effective_world_matrix_words requires an explicit world-space marker" .to_string(), ), (None, None) if self.parented_camera == Some(true) => Err( "parented legacy camera requires an effective world-space matrix".to_string(), ), (None, None) => Ok(self.selector0_words), } } pub(super) fn captured_viewport(&self) -> Result { let width = self.viewport[2] .checked_sub(self.viewport[0]) .filter(|width| *width > 0) .ok_or_else(|| "legacy camera capture has an invalid viewport width".to_string())?; let height = self.viewport[3] .checked_sub(self.viewport[1]) .filter(|height| *height > 0) .ok_or_else(|| "legacy camera capture has an invalid viewport height".to_string())?; Ok(CapturedViewport { rect: self.viewport, extent: [ u32::try_from(width) .map_err(|_| "legacy camera viewport width exceeds u32".to_string())?, u32::try_from(height) .map_err(|_| "legacy camera viewport height exceeds u32".to_string())?, ], }) } pub(super) fn preview_camera_frame(&self) -> Result { let projection = if let Some(geometry) = &self.geometry_projection { if !geometry.verified || geometry.viewport != self.viewport { return Err( "legacy camera geometry projection is unverified or has a different viewport" .to_string(), ); } LegacyD3d7Projection { viewport: geometry.viewport, near_plane: geometry.near_plane, far_plane: geometry.far_plane, field_of_view_radians: geometry.field_of_view_radians, } } else { LegacyD3d7Projection { viewport: self.viewport, near_plane: self.near_plane, far_plane: self.far_plane, field_of_view_radians: self.field_of_view_radians, } }; let frame = PreviewCameraFrame::from_legacy_d3d7( RawCameraTransform { words: self.preview_matrix_words()?, }, projection, ) .ok_or_else(|| "legacy camera capture contains an invalid D3D7 camera".to_string())?; self.captured_viewport()?; Ok(frame) } } pub(super) fn load_legacy_camera_capture( path: &std::path::Path, ) -> Result { let bytes = std::fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; let capture = parse_legacy_camera_capture(&bytes).map_err(|err| format!("{}: {err}", path.display()))?; if capture.geometry_projection.is_none() { eprintln!("render_coverage captured_projection_unverified=1 (capture has no geometry_projection; using the recorded camera boundary, which can clip nearby surfaces)"); } Ok(capture) } pub(super) fn parse_legacy_camera_capture(bytes: &[u8]) -> Result { // Windows PowerShell 5.1 writes redirected text as UTF-16LE with a BOM. // `capture-original-camera.ps1` intentionally emits plain JSON, so accept // that normal hand-off format as well as UTF-8 without making the caller // re-encode a read-only capture file. let json = decode_legacy_camera_capture_json(bytes)?; let capture: LegacyCameraCapture = serde_json::from_str(&json).map_err(|err| format!("invalid legacy camera JSON: {err}"))?; if capture.schema != "fparkan-legacy-camera-v1" { return Err("unsupported legacy camera capture schema".to_string()); } if capture.effective_world_matrix_space.is_some() != capture.effective_world_matrix_words.is_some() { return Err( "effective_world_matrix_space and effective_world_matrix_words must be supplied together" .to_string(), ); } if capture.render_input_usable == Some(false) { return Err("legacy camera capture is marked render_input_usable=false".to_string()); } if capture .atmosphere_seconds .is_some_and(|seconds| !seconds.is_finite() || seconds < 0.0) { return Err( "legacy camera capture atmosphere_seconds must be finite and non-negative".to_string(), ); } capture.preview_camera_frame()?; Ok(capture) } fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result { let decode_utf16 = |words: Vec| { String::from_utf16(&words) .map_err(|err| format!("invalid UTF-16 legacy camera JSON: {err}")) }; match bytes { [0xff, 0xfe, rest @ ..] => { let chunks = rest.chunks_exact(2); if !chunks.remainder().is_empty() { return Err("invalid UTF-16LE legacy camera JSON length".to_string()); } decode_utf16( chunks .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect(), ) } [0xfe, 0xff, rest @ ..] => { let chunks = rest.chunks_exact(2); if !chunks.remainder().is_empty() { return Err("invalid UTF-16BE legacy camera JSON length".to_string()); } decode_utf16( chunks .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) .collect(), ) } [0xef, 0xbb, 0xbf, rest @ ..] => std::str::from_utf8(rest) .map(str::to_owned) .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), _ => std::str::from_utf8(bytes) .map(str::to_owned) .map_err(|err| format!("invalid UTF-8 legacy camera JSON: {err}")), } } #[cfg(test)] mod tests { use super::*; fn json() -> serde_json::Value { let words = [ 1.0_f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, ] .map(f32::to_bits); serde_json::json!({ "schema": "fparkan-legacy-camera-v1", "selector0_words": words, "viewport": [0, 0, 1280, 1024], "near_plane": 5.0, "far_plane": 700.0, "field_of_view_radians": 1.3, "geometry_projection": { "verified": true, "viewport": [0, 0, 1280, 1024], "near_plane": 0.5, "far_plane": 700.0, "field_of_view_radians": 1.3, } }) } #[test] fn geometry_projection_preserves_surfaces_closer_than_restored_camera_near() { let mut json = json(); let captured = parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).unwrap(); let camera = captured.preview_camera_frame().unwrap(); let point = [1.0, 0.0, 0.0, 1.0]; let depth = |matrix: [f32; 16]| { (0..4) .map(|row| point[row] * matrix[row * 4 + 2]) .sum::() }; assert_eq!(camera.near_plane, 0.5); assert!(depth(camera.clip_from_world) > 0.0); json.as_object_mut().unwrap().remove("geometry_projection"); let old = parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).unwrap(); let old_camera = old.preview_camera_frame().unwrap(); assert_eq!(old_camera.near_plane, 5.0); assert!(depth(old_camera.clip_from_world) < 0.0); } #[test] fn geometry_projection_rejects_unverified_mismatched_or_invalid_input() { for (field, value) in [ ("verified", serde_json::json!(false)), ("viewport", serde_json::json!([0, 0, 640, 480])), ("near_plane", serde_json::json!(0.0)), ("far_plane", serde_json::json!(0.25)), ] { let mut json = json(); json["geometry_projection"][field] = value; assert!( parse_legacy_camera_capture(&serde_json::to_vec(&json).unwrap()).is_err(), "{field}" ); } } }