aboutsummaryrefslogtreecommitdiff
path: root/apps/fparkan-game/src/camera_capture.rs
diff options
context:
space:
mode:
Diffstat (limited to 'apps/fparkan-game/src/camera_capture.rs')
-rw-r--r--apps/fparkan-game/src/camera_capture.rs269
1 files changed, 269 insertions, 0 deletions
diff --git a/apps/fparkan-game/src/camera_capture.rs b/apps/fparkan-game/src/camera_capture.rs
new file mode 100644
index 0000000..dccb738
--- /dev/null
+++ b/apps/fparkan-game/src/camera_capture.rs
@@ -0,0 +1,269 @@
+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<bool>,
+ pub(super) selector0_words: [u32; 16],
+ pub(super) parented_camera: Option<bool>,
+ pub(super) effective_world_matrix_space: Option<String>,
+ 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<f32>,
+ pub(super) sky_uv_clock: Option<SkyUvClock>,
+ pub(super) geometry_projection: Option<GeometryProjection>,
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+pub(super) struct SkyUvClock {
+ pub(super) verified: bool,
+ pub(super) elapsed_milliseconds: u32,
+ pub(super) raw_clock: Option<u32>,
+}
+
+#[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<f32>,
+ capture: Option<&LegacyCameraCapture>,
+) -> Option<f32> {
+ 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<CapturedViewport, String> {
+ 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<PreviewCameraFrame, String> {
+ 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<LegacyCameraCapture, String> {
+ 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)");
+ }
+ if capture.sky_uv_clock.is_none() {
+ eprintln!("render_coverage captured_sky_phase_unverified=1 (capture has no sky UV clock; using replay elapsed time)");
+ }
+ Ok(capture)
+}
+
+pub(super) fn parse_legacy_camera_capture(bytes: &[u8]) -> Result<LegacyCameraCapture, String> {
+ // 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
+ .sky_uv_clock
+ .as_ref()
+ .is_some_and(|clock| !clock.verified)
+ {
+ return Err("legacy camera sky UV clock is unverified".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<String, String> {
+ let decode_utf16 = |words: Vec<u16>| {
+ 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::<f32>()
+ };
+ 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}"
+ );
+ }
+ }
+}