From 057ff613aface24e01c6cd3a46663af48bc9977f Mon Sep 17 00:00:00 2001 From: Willow Sparks Date: Wed, 10 Jun 2026 09:28:39 +0100 Subject: [PATCH] fix: stop slice-plane drift during WASD movement after 4D rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The '4D movement bug': after a 4D camera rotation, WASD movement caused all cross-sections on screen to morph. Root cause: CharacterController4D scaled world axes anisotropically (X/Z by move_speed, W by w_move_speed). After a 4D rotation the WASD direction mixes world Z and W, so per-axis scaling tilted the velocity out of the slice plane: drift rate (move_speed - w_move_speed)·sinθcosθ ≈ 0.5 u/s at 45° with default config. Fix: speeds now scale semantic inputs, not world axes. apply_movement takes slice movement (WASD, ⊥ ana) and ana movement (Q/E) separately, each scaled uniformly, so directions are preserved. Verification: - tests/slice_invariant.rs: drives the full app stack (scene, physics, controller, simulation) and asserts the invariant 'camera-space W of any world point is constant during WASD' across rotations and pitch. Failed at 0.47-0.50 drift before the fix; all pass after. - examples/headless_protocol.rs: offscreen GPU captures through the real slice+render pipelines confirm cross-sections no longer morph visually. - regression unit test in character_controller.rs pins the orthogonality property for anisotropic speed configs. --- Cargo.toml | 4 + .../rust4d_game/src/character_controller.rs | 85 +++- crates/rust4d_game/src/scene_helpers.rs | 4 +- examples/headless_protocol.rs | 470 ++++++++++++++++++ tests/game_integration.rs | 4 +- tests/slice_invariant.rs | 308 ++++++++++++ 6 files changed, 852 insertions(+), 23 deletions(-) create mode 100644 examples/headless_protocol.rs create mode 100644 tests/slice_invariant.rs diff --git a/Cargo.toml b/Cargo.toml index b5bf320..0ca136e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,3 +92,7 @@ path = "examples/ron_preview.rs" [[example]] name = "05_audio_demo" path = "examples/05_audio_demo.rs" + +[[example]] +name = "headless_protocol" +path = "examples/headless_protocol.rs" diff --git a/crates/rust4d_game/src/character_controller.rs b/crates/rust4d_game/src/character_controller.rs index 8b62bf7..09c6649 100644 --- a/crates/rust4d_game/src/character_controller.rs +++ b/crates/rust4d_game/src/character_controller.rs @@ -50,19 +50,26 @@ impl CharacterController4D { self.body_key } - /// Apply horizontal movement (XZW plane, preserves Y for gravity/jumping) + /// Apply movement as two semantic components (preserves Y for gravity/jumping) /// - /// The X and Z components are scaled by `config.move_speed`, while the W - /// component is scaled by `config.w_move_speed`. Y is preserved as-is - /// (gravity/jumping handles vertical movement). - pub fn apply_movement(&self, physics: &mut PhysicsWorld, movement: Vec4) { - let scaled = Vec4::new( - movement.x * self.config.move_speed, - movement.y * self.config.move_speed, - movement.z * self.config.move_speed, - movement.w * self.config.w_move_speed, - ); - physics.apply_body_movement(self.body_key, scaled); + /// * `slice_dir` — movement within the player's current 3D slice (WASD). + /// In world space this direction is orthogonal to the camera's ana axis; + /// it is scaled uniformly by `config.move_speed`. + /// * `ana_dir` — deliberate movement along the camera's ana axis (Q/E), + /// scaled uniformly by `config.w_move_speed`. + /// + /// # Why two parameters + /// + /// Speeds must scale each *semantic input* uniformly, never individual + /// world axes. The old single-vector API scaled X/Z by `move_speed` and W + /// by `w_move_speed` in world space; after a 4D camera rotation the WASD + /// direction mixes world Z and W, so anisotropic world-axis scaling tilted + /// the velocity across the slice plane. The camera then drifted along its + /// ana axis while walking, and every cross-section on screen morphed + /// (the "4D movement bug", see `tests/slice_invariant.rs`). + pub fn apply_movement(&self, physics: &mut PhysicsWorld, slice_dir: Vec4, ana_dir: Vec4) { + let velocity = slice_dir * self.config.move_speed + ana_dir * self.config.w_move_speed; + physics.apply_body_movement(self.body_key, velocity); } /// Attempt to jump. Returns true if successful (only works when grounded). @@ -145,7 +152,7 @@ mod tests { let controller = CharacterController4D::new(body_key, config); // Apply movement (1, 0, 1, 0) with speed 2.0 => physics gets (2, 0, 2, 0) - controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 1.0, 0.0)); + controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 1.0, 0.0), Vec4::ZERO); // Step physics and check position changed physics.step(1.0); @@ -155,7 +162,7 @@ mod tests { } #[test] - fn test_apply_movement_uses_w_move_speed_for_w_axis() { + fn test_apply_movement_uses_w_move_speed_for_ana_component() { let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); // No gravity let body_key = physics.add_body( RigidBody4D::new_sphere(Vec4::new(0.0, 1.0, 0.0, 0.0), 0.5) @@ -169,8 +176,12 @@ mod tests { }; let controller = CharacterController4D::new(body_key, config); - // Apply movement with W component: W should use w_move_speed (5.0), X should use move_speed (2.0) - controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 0.0, 1.0)); + // Slice movement uses move_speed (2.0), ana movement uses w_move_speed (5.0) + controller.apply_movement( + &mut physics, + Vec4::new(1.0, 0.0, 0.0, 0.0), + Vec4::new(0.0, 0.0, 0.0, 1.0), + ); physics.step(1.0); let pos = physics.body_position(body_key).unwrap(); @@ -178,6 +189,42 @@ mod tests { assert!((pos.w - 5.0).abs() < 0.01, "Expected w=5.0 (w_move_speed), got {}", pos.w); } + #[test] + fn test_apply_movement_preserves_slice_direction_after_4d_rotation() { + // THE regression test for the 4D movement bug: a slice-movement + // direction that is orthogonal to the camera's ana axis must remain + // orthogonal after speed scaling, for ANY speed configuration. + // World-axis anisotropic scaling violated this. + let mut physics = PhysicsWorld::with_config(PhysicsConfig::new(0.0)); + let body_key = physics.add_body( + RigidBody4D::new_sphere(Vec4::ZERO, 0.5).with_body_type(BodyType::Kinematic), + ); + let config = CharacterConfig { + move_speed: 3.0, + w_move_speed: 2.0, // anisotropic, like config/default.toml + jump_velocity: 8.0, + }; + let controller = CharacterController4D::new(body_key, config); + + // Camera rotated 45° in ZW: forward and ana both mix Z and W + let theta = std::f32::consts::FRAC_PI_4; + let forward = Vec4::new(0.0, 0.0, -theta.cos(), -theta.sin()); + let ana = Vec4::new(0.0, 0.0, -theta.sin(), theta.cos()); + assert!(forward.dot(ana).abs() < 1e-6, "test setup: forward ⊥ ana"); + + controller.apply_movement(&mut physics, forward, Vec4::ZERO); + physics.step(1.0); + let pos = physics.body_position(body_key).unwrap(); + + // The displacement must stay orthogonal to ana — zero drift across + // the slice plane. + let drift = pos.dot(ana); + assert!( + drift.abs() < 1e-4, + "slice movement drifted {drift} along the ana axis — shapes would morph" + ); + } + #[test] fn test_jump_only_when_grounded() { let mut physics = test_world_with_floor(); @@ -291,7 +338,7 @@ mod tests { ); // apply_movement should not panic for stale key - controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 1.0, 0.0)); + controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 1.0, 0.0), Vec4::ZERO); } #[test] @@ -308,11 +355,11 @@ mod tests { ); // First apply some movement - controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 1.0, 0.0)); + controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 1.0, 0.0), Vec4::ZERO); physics.step(0.016); // Now apply zero movement -- should stop horizontal movement - controller.apply_movement(&mut physics, Vec4::ZERO); + controller.apply_movement(&mut physics, Vec4::ZERO, Vec4::ZERO); physics.step(0.016); let body = physics.get_body(body_key).unwrap(); diff --git a/crates/rust4d_game/src/scene_helpers.rs b/crates/rust4d_game/src/scene_helpers.rs index 07827dd..f514f7d 100644 --- a/crates/rust4d_game/src/scene_helpers.rs +++ b/crates/rust4d_game/src/scene_helpers.rs @@ -163,7 +163,7 @@ mod tests { assert_eq!(pos, spawn, "Initial position should match spawn"); // Apply movement and step - controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 0.0, 0.0)); + controller.apply_movement(&mut physics, Vec4::new(1.0, 0.0, 0.0, 0.0), Vec4::ZERO); physics.step(0.016); // Position should have changed @@ -176,7 +176,7 @@ mod tests { // Step physics until grounded for _ in 0..100 { - controller.apply_movement(&mut physics, Vec4::ZERO); + controller.apply_movement(&mut physics, Vec4::ZERO, Vec4::ZERO); physics.step(1.0 / 60.0); } diff --git a/examples/headless_protocol.rs b/examples/headless_protocol.rs new file mode 100644 index 0000000..eeb4b52 --- /dev/null +++ b/examples/headless_protocol.rs @@ -0,0 +1,470 @@ +//! Headless visual verification of the slice-plane invariant. +//! +//! Runs the full app stack (scene, physics, camera, simulation) without a +//! window, renders frames through the REAL slice + render pipelines into an +//! offscreen texture, and saves them as PPM images. +//! +//! Protocol (mirrors scratchpad/plans/2026-02-06-camera-debug.md): +//! 1. settle, capture `00-baseline` +//! 2. strafe right 1s (no rotation), capture every 20 frames +//! 3. rotate 45° in the QE/ana plane, capture `10-rotated` +//! 4. strafe left 1s, capture every 20 frames <- morphs when buggy +//! 5. walk forward 1s, capture every 20 frames <- morphs when buggy +//! +//! If the slice invariant holds, the tesseract cross-section may translate +//! and parallax across frames within a phase, but its shape/extent in the +//! W direction must not change. Triangle counts are printed as a coarse +//! numeric morph indicator. +//! +//! Usage: cargo run --example headless_protocol [output_dir] + +use rust4d::systems::{build_geometry, SimulationSystem}; +use rust4d_core::SceneManager; +use rust4d_game::{scene_helpers, CharacterConfig, CharacterController4D}; +use rust4d_input::CameraController; +use rust4d_math::Vec4; +use rust4d_physics::PhysicsConfig; +use rust4d_render::camera4d::Camera4D; +use rust4d_render::pipeline::{ + perspective_matrix, RenderPipeline, RenderUniforms, SliceParams, SlicePipeline, +}; +use winit::event::ElementState; +use winit::keyboard::KeyCode; + +use std::f32::consts::FRAC_PI_4; +use std::path::{Path, PathBuf}; + +const WIDTH: u32 = 800; +const HEIGHT: u32 = 600; +const DT: f32 = 1.0 / 60.0; +const MOVE_SPEED: f32 = 3.0; +const W_MOVE_SPEED: f32 = 2.0; +const MAX_TRIANGLES: usize = 900_000; + +// ============================================================================ +// Headless GPU context +// ============================================================================ + +struct HeadlessGpu { + device: wgpu::Device, + queue: wgpu::Queue, + slice_pipeline: SlicePipeline, + render_pipeline: RenderPipeline, + color_texture: wgpu::Texture, + readback_buffer: wgpu::Buffer, + padded_bytes_per_row: u32, +} + +impl HeadlessGpu { + fn new() -> Self { + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + ..Default::default() + }); + + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::default(), + compatible_surface: None, + force_fallback_adapter: false, + })) + .expect("no GPU adapter available"); + + let info = adapter.get_info(); + println!("[GPU] adapter: {} ({:?}, {:?})", info.name, info.device_type, info.backend); + + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("Headless Device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + }, + None, + )) + .expect("failed to create device"); + + let slice_pipeline = SlicePipeline::new(&device, MAX_TRIANGLES); + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let mut render_pipeline = RenderPipeline::new(&device, format); + render_pipeline.ensure_depth_texture(&device, WIDTH, HEIGHT); + + let color_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Offscreen Color"), + size: wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + + // wgpu requires bytes_per_row aligned to 256 + let unpadded = WIDTH * 4; + let padded_bytes_per_row = unpadded.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + + let readback_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Readback"), + size: (padded_bytes_per_row * HEIGHT) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + + Self { + device, + queue, + slice_pipeline, + render_pipeline, + color_texture, + readback_buffer, + padded_bytes_per_row, + } + } + + fn upload_geometry(&mut self, geometry: &rust4d_render::RenderableGeometry) { + self.slice_pipeline.upload_tetrahedra( + &self.device, + &geometry.vertices, + &geometry.tetrahedra, + ); + println!( + "[GPU] uploaded {} vertices, {} tetrahedra", + geometry.vertex_count(), + geometry.tetrahedron_count() + ); + } + + /// Render one frame exactly like `RenderSystem::render_frame` and save it. + fn capture( + &mut self, + camera: &Camera4D, + tetrahedron_count: u32, + path: &Path, + ) -> u32 { + let pos = camera.position; + let camera_matrix = camera.rotation_matrix(); + let slice_params = SliceParams { + slice_w: camera.get_slice_w(), + tetrahedron_count, + _padding: [0.0; 2], + camera_matrix, + camera_eye: [pos.x, pos.y, pos.z], + _padding2: 0.0, + camera_position: [pos.x, pos.y, pos.z, pos.w], + }; + self.slice_pipeline.update_params(&self.queue, &slice_params); + + let proj = perspective_matrix( + 45.0_f32.to_radians(), + WIDTH as f32 / HEIGHT as f32, + 0.1, + 100.0, + ); + let identity = [ + [1.0, 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], + ]; + self.render_pipeline.update_uniforms( + &self.queue, + &RenderUniforms { + view_matrix: identity, + projection_matrix: proj, + light_dir: [0.5, 1.0, 0.3], + _padding: 0.0, + ambient_strength: 0.3, + diffuse_strength: 0.7, + w_color_strength: 0.3, + w_range: 2.0, + }, + ); + + let view = self + .color_texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("Headless Encoder"), + }); + + self.slice_pipeline.reset_counter(&self.queue); + self.slice_pipeline.run_slice_pass(&mut encoder); + self.render_pipeline + .prepare_indirect_draw(&mut encoder, self.slice_pipeline.counter_buffer()); + self.render_pipeline.render( + &mut encoder, + &view, + self.slice_pipeline.output_buffer(), + wgpu::Color { + r: 0.05, + g: 0.05, + b: 0.08, + a: 1.0, + }, + ); + + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &self.color_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &self.readback_buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(self.padded_bytes_per_row), + rows_per_image: Some(HEIGHT), + }, + }, + wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + ); + + self.queue.submit(std::iter::once(encoder.finish())); + + // Map and save + let slice = self.readback_buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap()); + self.device.poll(wgpu::Maintain::Wait); + rx.recv().unwrap().expect("readback map failed"); + + { + let data = slice.get_mapped_range(); + write_ppm(path, &data, WIDTH, HEIGHT, self.padded_bytes_per_row); + } + self.readback_buffer.unmap(); + + // Read the vertex counter for a coarse numeric morph indicator + let vertex_count = self.read_counter(); + println!( + "[CAPTURE] {} cam=({:.3},{:.3},{:.3},{:.3}) triangles={}", + path.file_name().unwrap().to_string_lossy(), + pos.x, + pos.y, + pos.z, + pos.w, + vertex_count / 3 + ); + vertex_count / 3 + } + + fn read_counter(&self) -> u32 { + let staging = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Counter Staging"), + size: 4, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + encoder.copy_buffer_to_buffer(self.slice_pipeline.counter_buffer(), 0, &staging, 0, 4); + self.queue.submit(std::iter::once(encoder.finish())); + + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap()); + self.device.poll(wgpu::Maintain::Wait); + rx.recv().unwrap().expect("counter map failed"); + let count = { + let data = slice.get_mapped_range(); + u32::from_le_bytes(data[..4].try_into().unwrap()) + }; + staging.unmap(); + count + } +} + +fn write_ppm(path: &Path, data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) { + let mut out = Vec::with_capacity((width * height * 3) as usize + 32); + out.extend_from_slice(format!("P6\n{} {}\n255\n", width, height).as_bytes()); + for y in 0..height { + let row_start = (y * padded_bytes_per_row) as usize; + for x in 0..width { + let px = row_start + (x * 4) as usize; + out.extend_from_slice(&data[px..px + 3]); // RGB, drop A + } + } + std::fs::write(path, out).expect("failed to write ppm"); +} + +// ============================================================================ +// Simulation rig (mirrors App::new in src/main.rs) +// ============================================================================ + +struct Rig { + scene_manager: SceneManager, + camera: Camera4D, + controller: CameraController, + character: Option, + simulation: SimulationSystem, +} + +impl Rig { + fn new() -> Self { + let mut scene_manager = SceneManager::new().with_physics(PhysicsConfig::new(-20.0)); + let scene_name = scene_manager + .load_scene("scenes/default.ron") + .expect("load default scene"); + scene_manager.instantiate(&scene_name).expect("instantiate"); + scene_manager.push_scene(&scene_name).expect("push"); + + let mut player_start = Vec4::new(0.0, 0.0, 5.0, 0.0); + if let Some(scene) = scene_manager.active_scene_mut() { + if let Some(spawn) = scene.player_spawn { + let spawn_pos = Vec4::new(spawn[0], spawn[1], spawn[2], spawn[3]); + player_start = spawn_pos; + if let Some(physics) = scene.world.physics_mut() { + let key = scene_helpers::create_player_body(physics, spawn_pos, 0.5); + scene.player_body_key = Some(key); + } + } + } + + let mut camera = Camera4D::new(); + camera.position = player_start; + + let controller = CameraController::new() + .with_move_speed(MOVE_SPEED) + .with_w_move_speed(W_MOVE_SPEED); + + let character = scene_manager + .active_scene() + .and_then(|s| s.player_body_key) + .map(|key| { + CharacterController4D::new( + key, + CharacterConfig { + move_speed: MOVE_SPEED, + w_move_speed: W_MOVE_SPEED, + jump_velocity: 8.0, + }, + ) + }); + + Self { + scene_manager, + camera, + controller, + character, + simulation: SimulationSystem::new(), + } + } + + fn step(&mut self) { + self.simulation.update_with_dt( + DT, + &mut self.scene_manager, + &mut self.camera, + &mut self.controller, + self.character.as_ref(), + false, + ); + } + + fn run(&mut self, frames: usize) { + for _ in 0..frames { + self.step(); + } + } + + fn slice_w_of(&self, p: Vec4) -> f32 { + self.camera.ana().dot(p - self.camera.position) + } +} + +// ============================================================================ +// Protocol +// ============================================================================ + +fn main() { + env_logger::init(); + + let out_dir = std::env::args() + .nth(1) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".scratchpad/captures")); + std::fs::create_dir_all(&out_dir).expect("create output dir"); + + let mut rig = Rig::new(); + let geometry = build_geometry(rig.scene_manager.active_world().unwrap()); + let tetra_count = geometry.tetrahedron_count() as u32; + + let mut gpu = HeadlessGpu::new(); + gpu.upload_geometry(&geometry); + + let tesseract = Vec4::new(0.0, 0.0, 0.0, 0.0); + let cap = |rig: &Rig, gpu: &mut HeadlessGpu, name: &str| { + let path = out_dir.join(format!("{name}.ppm")); + let tris = gpu.capture(&rig.camera, tetra_count, &path); + println!( + "[STATE] {name}: slice_w(tesseract)={:.6} tris={tris}", + rig.slice_w_of(tesseract) + ); + }; + + // Phase 0: settle + rig.run(120); + cap(&rig, &mut gpu, "00-baseline"); + + // Phase 1: strafe right then back, no rotation (control: shape must only + // translate/parallax, never morph) + rig.controller + .process_keyboard(KeyCode::KeyD, ElementState::Pressed); + for i in 1..=2 { + rig.run(30); + cap(&rig, &mut gpu, &format!("0{i}-strafe-no-rotation")); + } + rig.controller + .process_keyboard(KeyCode::KeyD, ElementState::Released); + rig.controller + .process_keyboard(KeyCode::KeyA, ElementState::Pressed); + rig.run(60); // return to x ≈ 0 so the rotated slice passes through the tesseract + rig.controller + .process_keyboard(KeyCode::KeyA, ElementState::Released); + cap(&rig, &mut gpu, "03-returned-to-start"); + + // Phase 2: rotate 45° into the 4th dimension + rig.camera.rotate_w(FRAC_PI_4); + cap(&rig, &mut gpu, "10-rotated-45"); + + // Phase 3: strafe left after rotation (bug trigger: with the anisotropic + // scaling bug, the slice plane drifts 0.5 units/s and the cross-section + // visibly morphs/shrinks; when fixed it only translates) + rig.controller + .process_keyboard(KeyCode::KeyA, ElementState::Pressed); + for i in 1..=4 { + rig.run(30); + cap(&rig, &mut gpu, &format!("1{i}-strafe-after-rotation")); + } + rig.controller + .process_keyboard(KeyCode::KeyA, ElementState::Released); + + // Phase 4: walk forward after rotation. At this particular orientation + // forward stays -Z, so even the buggy build must not morph here — + // an in-protocol control. + rig.controller + .process_keyboard(KeyCode::KeyW, ElementState::Pressed); + for i in 1..=2 { + rig.run(30); + cap(&rig, &mut gpu, &format!("2{i}-forward-after-rotation")); + } + rig.controller + .process_keyboard(KeyCode::KeyW, ElementState::Released); + + println!("\nCaptures written to {}", out_dir.display()); +} diff --git a/tests/game_integration.rs b/tests/game_integration.rs index a79185b..82735fa 100644 --- a/tests/game_integration.rs +++ b/tests/game_integration.rs @@ -58,7 +58,7 @@ fn test_character_controller_with_active_scene() { // Step physics to let player fall and settle for _ in 0..100 { if let Some(physics) = active.world.physics_mut() { - controller.apply_movement(physics, Vec4::ZERO); + controller.apply_movement(physics, Vec4::ZERO, Vec4::ZERO); } active.update(1.0 / 60.0); } @@ -74,7 +74,7 @@ fn test_character_controller_with_active_scene() { // Apply movement in X direction if let Some(physics) = active.world.physics_mut() { - controller.apply_movement(physics, Vec4::new(1.0, 0.0, 0.0, 0.0)); + controller.apply_movement(physics, Vec4::new(1.0, 0.0, 0.0, 0.0), Vec4::ZERO); } active.update(1.0 / 60.0); diff --git a/tests/slice_invariant.rs b/tests/slice_invariant.rs new file mode 100644 index 0000000..0cf4c2b --- /dev/null +++ b/tests/slice_invariant.rs @@ -0,0 +1,308 @@ +//! Slice-plane invariant tests (the "4D movement bug") +//! +//! THE INVARIANT: for any world point `p`, its camera-space W coordinate +//! `dot(ana, p - camera_pos)` must not change while the player moves with +//! WASD — regardless of the camera's 4D rotation state. If it changes, the +//! slice plane sweeps through geometry and shapes visibly morph. +//! +//! Only deliberate W movement (Q/E) and 4D rotation may change it. +//! +//! These tests drive the REAL app stack — scene file, physics world, player +//! body, camera controller, simulation system — with a fixed timestep, +//! mirroring `App::new()` in `src/main.rs`. + +use rust4d::systems::SimulationSystem; +use rust4d_core::SceneManager; +use rust4d_game::{scene_helpers, CharacterConfig, CharacterController4D}; +use rust4d_input::CameraController; +use rust4d_math::Vec4; +use rust4d_physics::PhysicsConfig; +use rust4d_render::camera4d::Camera4D; +use winit::event::ElementState; +use winit::keyboard::KeyCode; + +use std::f32::consts::FRAC_PI_4; + +const DT: f32 = 1.0 / 60.0; + +/// Mirror of config/default.toml input values +const MOVE_SPEED: f32 = 3.0; +const W_MOVE_SPEED: f32 = 2.0; + +/// World-space reference point: the tesseract's center in scenes/default.ron +const TESSERACT_CENTER: Vec4 = Vec4 { + x: 0.0, + y: 0.0, + z: 0.0, + w: 0.0, +}; + +struct TestRig { + scene_manager: SceneManager, + camera: Camera4D, + controller: CameraController, + character: Option, + simulation: SimulationSystem, +} + +impl TestRig { + /// Build the full app stack the same way `App::new()` does. + fn new() -> Self { + let mut scene_manager = SceneManager::new().with_physics(PhysicsConfig::new(-20.0)); + + let scene_name = scene_manager + .load_scene("scenes/default.ron") + .expect("load default scene"); + scene_manager.instantiate(&scene_name).expect("instantiate"); + scene_manager.push_scene(&scene_name).expect("push scene"); + + // Player body from spawn point (mirrors main.rs) + let mut player_start = Vec4::new(0.0, 0.0, 5.0, 0.0); + if let Some(scene) = scene_manager.active_scene_mut() { + if let Some(spawn) = scene.player_spawn { + let spawn_pos = Vec4::new(spawn[0], spawn[1], spawn[2], spawn[3]); + player_start = spawn_pos; + if let Some(physics) = scene.world.physics_mut() { + let key = scene_helpers::create_player_body(physics, spawn_pos, 0.5); + scene.player_body_key = Some(key); + } + } + } + + let mut camera = Camera4D::new(); + camera.position = player_start; + + let controller = CameraController::new() + .with_move_speed(MOVE_SPEED) + .with_w_move_speed(W_MOVE_SPEED); + + let character = scene_manager + .active_scene() + .and_then(|s| s.player_body_key) + .map(|key| { + CharacterController4D::new( + key, + CharacterConfig { + move_speed: MOVE_SPEED, + w_move_speed: W_MOVE_SPEED, + jump_velocity: 8.0, + }, + ) + }); + assert!(character.is_some(), "default scene should produce a player body"); + + Self { + scene_manager, + camera, + controller, + character, + simulation: SimulationSystem::new(), + } + } + + fn step(&mut self) { + self.simulation.update_with_dt( + DT, + &mut self.scene_manager, + &mut self.camera, + &mut self.controller, + self.character.as_ref(), + false, + ); + } + + /// Run n frames with the current input state. + fn run(&mut self, frames: usize) { + for _ in 0..frames { + self.step(); + } + } + + /// Camera-space W of a world point: dot(ana, p - camera_pos). + /// This is exactly what the slice shader computes as `pos.w` before + /// comparing against slice_w. + fn camera_space_w(&self, p: Vec4) -> f32 { + let ana = self.camera.ana(); + ana.dot(p - self.camera.position) + } + + fn press(&mut self, key: KeyCode) { + self.controller.process_keyboard(key, ElementState::Pressed); + } + + fn release(&mut self, key: KeyCode) { + self.controller.process_keyboard(key, ElementState::Released); + } + + fn debug_state(&self, label: &str) { + let fwd = self.camera.forward(); + let ana = self.camera.ana(); + let pos = self.camera.position; + println!( + "[CAM {label}] pos=({:.4},{:.4},{:.4},{:.4})", + pos.x, pos.y, pos.z, pos.w + ); + println!( + "[CAM {label}] forward=({:.4},{:.4},{:.4},{:.4}) ana=({:.4},{:.4},{:.4},{:.4})", + fwd.x, fwd.y, fwd.z, fwd.w, ana.x, ana.y, ana.z, ana.w + ); + println!( + "[CAM {label}] slice_w(tesseract)={:.6}", + self.camera_space_w(TESSERACT_CENTER) + ); + } +} + +/// Walk with the given key held for `frames`, returning the maximum absolute +/// drift of the tesseract's camera-space W relative to the start. +fn max_drift_while_walking(rig: &mut TestRig, key: KeyCode, frames: usize) -> f32 { + let w_start = rig.camera_space_w(TESSERACT_CENTER); + let mut max_drift: f32 = 0.0; + + rig.press(key); + for frame in 0..frames { + rig.step(); + let w_now = rig.camera_space_w(TESSERACT_CENTER); + let drift = (w_now - w_start).abs(); + max_drift = max_drift.max(drift); + if frame % 15 == 0 { + let pos = rig.camera.position; + println!( + "[MOVE frame {frame:3}] cam_pos=({:.4},{:.4},{:.4},{:.4}) slice_w={:.6} drift={:.6}", + pos.x, pos.y, pos.z, pos.w, w_now, drift + ); + } + } + rig.release(key); + max_drift +} + +/// Let the player body settle onto the floor so gravity transients don't +/// pollute the measurement window (Y movement can't affect the invariant — +/// ana.y is always 0 — but a clean baseline makes failures unambiguous). +fn settle(rig: &mut TestRig) { + rig.run(120); +} + +const DRIFT_TOLERANCE: f32 = 1e-3; + +// ============================================================================ +// Control: no 4D rotation +// ============================================================================ + +#[test] +fn wasd_without_rotation_keeps_slice_plane() { + let mut rig = TestRig::new(); + settle(&mut rig); + rig.debug_state("baseline"); + + for key in [KeyCode::KeyW, KeyCode::KeyS, KeyCode::KeyA, KeyCode::KeyD] { + let drift = max_drift_while_walking(&mut rig, key, 60); + assert!( + drift < DRIFT_TOLERANCE, + "slice plane drifted {drift} during {key:?} with no 4D rotation" + ); + } +} + +// ============================================================================ +// THE BUG: WASD after a ZW rotation must not drift the slice plane +// ============================================================================ + +#[test] +fn wasd_after_45deg_zw_rotation_keeps_slice_plane() { + let mut rig = TestRig::new(); + settle(&mut rig); + + // Rotate 45° in ZW — the worst case for anisotropic speed scaling + rig.camera.rotate_w(FRAC_PI_4); + rig.debug_state("after 45° ZW rotation"); + + for key in [KeyCode::KeyW, KeyCode::KeyS, KeyCode::KeyA, KeyCode::KeyD] { + let drift = max_drift_while_walking(&mut rig, key, 60); + assert!( + drift < DRIFT_TOLERANCE, + "SLICE PLANE DRIFTED {drift} during {key:?} after 45° ZW rotation \ + — this is the shape-morphing bug" + ); + } +} + +#[test] +fn wasd_after_combined_4d_rotations_keeps_slice_plane() { + let mut rig = TestRig::new(); + settle(&mut rig); + + // A messier orientation: ZW + XW + yaw + rig.camera.rotate_w(0.6); + rig.camera.rotate_xw(0.35); + rig.camera.rotate_3d(0.8, 0.0); + rig.debug_state("after combined rotations"); + + for key in [KeyCode::KeyW, KeyCode::KeyD] { + let drift = max_drift_while_walking(&mut rig, key, 60); + assert!( + drift < DRIFT_TOLERANCE, + "slice plane drifted {drift} during {key:?} after combined 4D rotations" + ); + } +} + +#[test] +fn wasd_with_pitch_after_zw_rotation_keeps_slice_plane() { + let mut rig = TestRig::new(); + settle(&mut rig); + + rig.camera.rotate_w(FRAC_PI_4); + rig.camera.rotate_3d(0.0, 0.5); // look up — pitched forward movement + rig.debug_state("after ZW rotation + pitch"); + + let drift = max_drift_while_walking(&mut rig, KeyCode::KeyW, 60); + assert!( + drift < DRIFT_TOLERANCE, + "slice plane drifted {drift} during pitched forward movement after ZW rotation" + ); +} + +// ============================================================================ +// Sanity: deliberate W movement and rotation SHOULD change the slice +// ============================================================================ + +#[test] +fn qe_movement_deliberately_changes_slice() { + let mut rig = TestRig::new(); + settle(&mut rig); + + let w_before = rig.camera_space_w(TESSERACT_CENTER); + rig.press(KeyCode::KeyQ); + rig.run(60); + rig.release(KeyCode::KeyQ); + let w_after = rig.camera_space_w(TESSERACT_CENTER); + + let delta = (w_after - w_before).abs(); + println!("[Q/E] slice W moved by {delta:.4} over 1s (expected ~{W_MOVE_SPEED})"); + assert!( + delta > 0.5, + "Q should move the camera along its ana axis (slice W change), got {delta}" + ); +} + +#[test] +fn wasd_actually_moves_the_player() { + let mut rig = TestRig::new(); + settle(&mut rig); + rig.camera.rotate_w(FRAC_PI_4); + + let before = rig.camera.position; + rig.press(KeyCode::KeyW); + rig.run(60); + rig.release(KeyCode::KeyW); + let after = rig.camera.position; + + let dist = (after - before).length(); + println!("[SPEED] moved {dist:.4} units in 1s (move_speed={MOVE_SPEED})"); + assert!( + dist > MOVE_SPEED * 0.8, + "player should move roughly move_speed units per second, got {dist}" + ); +} -- knotmirror 0.0.1