Skip to main content

dreamwell_runtime/
app.rs

1// RuntimeApp — implements winit ApplicationHandler for the game loop.
2
3use winit::application::ApplicationHandler;
4use winit::event::WindowEvent;
5use winit::event_loop::ActiveEventLoop;
6use winit::window::WindowId;
7
8use dreamwell_engine::game_object::PrimitiveKind;
9use dreamwell_engine::TopologyLayer;
10use dreamwell_fabric::packets::SyncPacket;
11
12use crate::authority::{AuthorityClient, AuthorityEvent, LocalAuthority};
13use crate::game_state::GameState;
14use crate::input::InputState;
15use crate::play::SimulationService;
16use crate::renderer::SceneRenderer;
17use crate::scene::Scene;
18use crate::sync::stage::StagingBuffer;
19use crate::time::FrameTimer;
20use crate::window::WindowState;
21use crate::{AuthorityMode, RuntimeConfig};
22use dreamwell_fabric::decoder::{CausalEngineDecoder, DecoderInput};
23
24// ── Chase Camera Constants ────────────────────────────────────────────
25// Tuned for responsive third-person feel: camera tracks player with
26// minimal perceived lag while avoiding mechanical snap.
27//
28// Reference: Unreal SpringArm defaults (lag speed 10, rotation lag speed 10).
29// We use dt-scaled exponential lerp: alpha = 1 - e^(-rate * dt).
30// At 60fps (dt=0.0167): rate=16 → alpha≈0.23 (4-frame 90% settle).
31
32/// Minimum camera pitch (radians). -0.3 ≈ -17° allows looking slightly below horizon.
33const MIN_PITCH: f32 = -0.3;
34/// Maximum camera pitch (radians). 1.40 ≈ 80° near-overhead view.
35const MAX_PITCH: f32 = 1.40;
36/// Minimum camera distance from player (meters).
37const MIN_CAMERA_DIST: f32 = 0.5;
38/// Maximum camera distance from player (meters).
39const MAX_CAMERA_DIST: f32 = 200.0;
40/// Default camera distance from player (meters). 3.0m — tight third-person for 0.4m capsule.
41const DEFAULT_CAMERA_DIST: f32 = 3.0;
42/// Camera position smoothing rate. Higher = snappier. 16 ≈ 4-frame settle at 60fps.
43const CAMERA_POSITION_LERP_RATE: f32 = 16.0;
44/// Camera look-at smoothing rate. Slightly faster than position for crisp tracking.
45const CAMERA_LOOKAT_LERP_RATE: f32 = 20.0;
46/// Zoom smoothing rate.
47const ZOOM_LERP_RATE: f32 = 14.0;
48/// Over-shoulder horizontal offset (meters). Subtle — keeps character slightly left of center.
49const SHOULDER_OFFSET: f32 = 0.3;
50/// Look-at target height above player origin (meters). ~waist/chest height for particle centroid.
51const LOOKAT_HEIGHT: f32 = 0.9;
52/// Extra camera pull-back distance while sprinting (meters).
53const SPRINT_PULL_BACK: f32 = 1.0;
54
55/// Form-aware camera tuning profile.
56/// Lerped between Cohere and Wave forms based on coherence parameter.
57pub struct CameraProfile {
58    pub boom_length: f32,
59    pub yaw_lag: f32,
60    pub fov: f32,
61    pub shoulder_offset: f32,
62    pub lookat_height: f32,
63}
64
65impl CameraProfile {
66    /// Compact fibonacci particle — tighter camera.
67    pub fn cohere() -> Self {
68        Self {
69            boom_length: 5.0,
70            yaw_lag: 0.15,
71            fov: 60.0,
72            shoulder_offset: 0.4,
73            lookat_height: 1.2,
74        }
75    }
76
77    /// Broad wave fabric — wider camera.
78    pub fn wave() -> Self {
79        Self {
80            boom_length: 7.0,
81            yaw_lag: 0.25,
82            fov: 65.0,
83            shoulder_offset: 0.6,
84            lookat_height: 1.0,
85        }
86    }
87
88    /// Linearly interpolate between two profiles.
89    pub fn lerp(a: &Self, b: &Self, t: f32) -> Self {
90        let t = t.clamp(0.0, 1.0);
91        Self {
92            boom_length: a.boom_length + (b.boom_length - a.boom_length) * t,
93            yaw_lag: a.yaw_lag + (b.yaw_lag - a.yaw_lag) * t,
94            fov: a.fov + (b.fov - a.fov) * t,
95            shoulder_offset: a.shoulder_offset + (b.shoulder_offset - a.shoulder_offset) * t,
96            lookat_height: a.lookat_height + (b.lookat_height - a.lookat_height) * t,
97        }
98    }
99}
100
101/// Error type for runtime operations.
102#[derive(Debug)]
103pub enum RuntimeError {
104    /// A scene or pack loading error.
105    Load(String),
106    /// Window or GPU initialization error.
107    Init(String),
108}
109
110impl std::fmt::Display for RuntimeError {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            Self::Load(msg) => write!(f, "load error: {msg}"),
114            Self::Init(msg) => write!(f, "init error: {msg}"),
115        }
116    }
117}
118
119impl std::error::Error for RuntimeError {}
120
121/// The runtime application, driven by winit's event loop.
122pub struct RuntimeApp {
123    config: RuntimeConfig,
124    window_state: Option<WindowState>,
125    renderer: Option<SceneRenderer>,
126    scene: Scene,
127    game_state: GameState,
128    input: InputState,
129    timer: FrameTimer,
130    /// Authority backend (local or remote).
131    authority: Box<dyn AuthorityClient>,
132    /// Staging buffer for authority events awaiting tick-boundary application.
133    staging: StagingBuffer,
134    /// Per-frame sync packet drained from DreamFabric.
135    sync_packet: SyncPacket,
136    /// Mounted content pack JSON, stored for deferred scene loading.
137    mounted_pack: Option<String>,
138    /// Pre-allocated scratch buffer for authority events — reused every frame.
139    /// Capacity is set at construction and never reallocated on the hot path.
140    authority_events: Vec<AuthorityEvent>,
141    /// SimulationService — CausalComputeKernel + SDK dispatch (gate-routed).
142    /// Active when a tapestry lock file is loaded (Shapes and Dimensions demo).
143    simulation: Option<SimulationService>,
144    /// CausalEngineDecoder — formal GPU orchestration (encoder→bridge→decoder).
145    decoder: Option<CausalEngineDecoder>,
146    /// Chase camera orbit yaw in radians (accumulated from mouse drag).
147    camera_orbit_yaw: f32,
148    /// Chase camera orbit pitch in radians (clamped to [MIN_PITCH, MAX_PITCH]).
149    camera_orbit_pitch: f32,
150    /// Current camera distance from player (smoothed toward target).
151    camera_distance: f32,
152    /// Target camera distance — scroll sets this, actual distance lerps toward it.
153    camera_target_distance: f32,
154    /// Gilrs context for gamepad input (present only when `gamepad` feature is enabled).
155    #[cfg(feature = "gamepad")]
156    gilrs: gilrs::Gilrs,
157    /// Audio system with OddioBackend (present only when `audio` feature is enabled).
158    /// OddioBackend provides spatial 3D audio via oddio + cpal. Falls back to
159    /// headless (no-op) mode when no audio device is available.
160    #[cfg(feature = "audio")]
161    audio: dreamwell_audio::AudioSystem<dreamwell_audio::OddioBackend>,
162}
163
164fn create_authority(mode: AuthorityMode) -> Box<dyn AuthorityClient> {
165    match mode {
166        AuthorityMode::Local => Box::new(LocalAuthority::new()),
167        #[cfg(feature = "multiplayer")]
168        AuthorityMode::Remote => Box::new(crate::authority::RemoteAuthority::new()),
169        #[cfg(not(feature = "multiplayer"))]
170        AuthorityMode::Remote => {
171            log::warn!("Remote authority requested but multiplayer feature not enabled, falling back to local");
172            Box::new(LocalAuthority::new())
173        }
174    }
175}
176
177impl RuntimeApp {
178    /// Create a new runtime application.
179    ///
180    /// Returns `Err` if authority backend creation fails for the requested mode.
181    pub fn new(config: RuntimeConfig) -> Result<Self, RuntimeError> {
182        let authority = create_authority(config.authority_mode);
183        Ok(Self {
184            config,
185            window_state: None,
186            renderer: None,
187            scene: Scene::default(),
188            game_state: GameState::default(),
189            input: InputState::default(),
190            timer: FrameTimer::new(),
191            authority,
192            staging: StagingBuffer::new(),
193            sync_packet: SyncPacket::new(),
194            mounted_pack: None,
195            // Pre-allocate with a reasonable capacity. Reallocates only if
196            // more than 64 authority events arrive in a single frame (rare).
197            authority_events: Vec::with_capacity(64),
198            simulation: None,
199            decoder: None,
200            camera_orbit_yaw: 0.0,
201            camera_orbit_pitch: 0.35, // ~20 degrees above horizon (natural third-person)
202            camera_distance: 6.0,
203            camera_target_distance: 6.0,
204            #[cfg(feature = "gamepad")]
205            gilrs: gilrs::Gilrs::new().unwrap_or_else(|e| {
206                log::warn!("gamepad_init_failed:{e} — gamepad input disabled");
207                // gilrs::Gilrs::new() returns a Result; the error variant also
208                // contains a valid (empty) Gilrs instance.
209                e.into()
210            }),
211            #[cfg(feature = "audio")]
212            audio: {
213                match dreamwell_audio::OddioBackend::new() {
214                    Ok(backend) => dreamwell_audio::AudioSystem::new(backend),
215                    Err(e) => {
216                        log::warn!("audio_init_failed:{e} — falling back to headless");
217                        dreamwell_audio::AudioSystem::new(dreamwell_audio::OddioBackend::headless())
218                    }
219                }
220            },
221        })
222    }
223
224    /// Mount a content pack from JSON. The pack data is stored and available
225    /// for subsequent `load_scene` calls to reference.
226    ///
227    /// This does not immediately alter the running scene; it stages the pack
228    /// for deferred loading via `load_scene`.
229    pub fn mount_pack(&mut self, pack_json: &str) -> Result<(), String> {
230        // Validate that the JSON is at least well-formed.
231        if pack_json.is_empty() {
232            return Err("mount_pack:empty pack JSON".into());
233        }
234        // Store the pack for later scene loading.
235        self.mounted_pack = Some(pack_json.to_string());
236        log::info!("Pack mounted ({} bytes)", pack_json.len());
237        Ok(())
238    }
239
240    /// Load a scene by name. If a content pack has been mounted, the scene
241    /// definition is resolved from the pack. Otherwise, falls back to the
242    /// built-in demo scene.
243    ///
244    /// This is a structural stub that resets the scene and seeds objects.
245    /// Full pack-driven scene deserialization is planned for a future release.
246    pub fn load_scene(&mut self, scene_name: &str) -> Result<(), String> {
247        if scene_name.is_empty() {
248            return Err("load_scene:empty scene name".into());
249        }
250        // Reset scene state.
251        self.scene = Scene::default();
252        self.game_state = GameState::default();
253
254        if let Some(ref pack_json) = self.mounted_pack {
255            match dreamwell_engine::waymark::loader::PackLoader::load_pack_config(pack_json) {
256                Ok(pack) => {
257                    let loaded = dreamwell_engine::waymark::loader::load_pack_to_scene(&pack);
258                    self.scene.game_objects = loaded.objects;
259                    log::info!(
260                        "Loaded scene '{}' from pack: {} objects",
261                        scene_name,
262                        self.scene.game_objects.len()
263                    );
264                    return Ok(());
265                }
266                Err(e) => {
267                    log::warn!("Pack parse failed, falling back to demo: {}", e);
268                }
269            }
270        }
271
272        self.seed_demo_scene();
273        log::info!("Scene '{}' loaded (demo fallback)", scene_name);
274        Ok(())
275    }
276
277    /// Switch authority mode at runtime. Replaces the authority backend and
278    /// clears staged events.
279    ///
280    /// Note: switching from Remote to Local while connected will drop the
281    /// remote connection. Switching to Remote requires the `multiplayer`
282    /// feature to be enabled; otherwise it falls back to Local with a warning.
283    pub fn set_authority_mode(&mut self, mode: AuthorityMode) {
284        if mode == self.config.authority_mode {
285            return;
286        }
287        log::info!(
288            "Switching authority mode: {:?} -> {:?}",
289            self.config.authority_mode,
290            mode
291        );
292        self.authority = create_authority(mode);
293        self.staging = StagingBuffer::new();
294        self.config.authority_mode = mode;
295    }
296
297    /// Load scene from tapestry.lock if available, else seed demo primitives.
298    /// When a lock file is present, initializes the full CausalComputeKernel
299    /// simulation with gate-routed SDK dispatch per THE_BRAIDED_PATH.
300    fn seed_demo_scene(&mut self) {
301        // Try loading from tapestry.lock (Shapes and Dimensions or any authored scene).
302        if let Some(ref lock_path) = self.config.lock_file {
303            if let Ok(json) = std::fs::read_to_string(lock_path) {
304                if let Ok(lock) = dreamwell_sdk::tapestry::TapestryLock::from_json(&json) {
305                    log::info!("Loading scene from tapestry.lock: {} objects", lock.object_count);
306                    self.load_scene_from_lock(&lock);
307                    return;
308                }
309            }
310            log::warn!("Failed to load lock file '{}', falling back to demo", lock_path);
311        }
312
313        // Fallback: canonical 3D template (player particle + ground + skybox).
314        // Uses the same template as CLI `dream up init` and Editor New Project.
315        let tmpl = dreamwell_sdk::templates::default_3d_scene();
316
317        // Set active layer to Point (9) BEFORE anything else — template objects live on layer 9.
318        // This must happen unconditionally so the observer matches the scene content.
319        self.game_state.active_layer = TopologyLayer::Point;
320
321        let scene = &mut self.scene.game_objects;
322        for obj in &tmpl.objects {
323            let kind = match obj.name.as_str() {
324                "Ground" => Some(PrimitiveKind::Plane),
325                _ => None,
326            };
327            let id = if let Some(k) = kind {
328                scene.spawn_primitive(obj.name.clone(), k)
329            } else {
330                scene.spawn(obj.name.clone())
331            };
332            if let Ok(id) = id {
333                if let Some(go) = scene.find_mut(id) {
334                    go.transform.position = obj.position;
335                    go.transform.scale = obj.scale;
336                    go.property_tags = obj.tags.clone();
337                    // Transfer material preset from template.
338                    if let Some(ref mat) = obj.material {
339                        go.property_tags.push(format!("material={mat}"));
340                    }
341                }
342            }
343        }
344
345        // Template lights will be added to the renderer's DreamFabric after it's created.
346        // Store them for deferred application in the resumed() handler.
347        // (GpuScene adds a default directional sun if no lights are explicitly set.)
348
349        log::info!(
350            "Demo scene seeded from template '{}': {} objects, {} lights",
351            tmpl.name,
352            scene.len(),
353            tmpl.lights.len(),
354        );
355
356        // Initialize SimulationService from template scene.
357        let spawn_pos = tmpl
358            .objects
359            .iter()
360            .find(|o| o.tags.iter().any(|t| t == "isInputReceiver"))
361            .map(|o| o.position)
362            .unwrap_or([0.0, 0.5, 0.0]);
363
364        let mut go_scene = dreamwell_engine::game_object::GameObjectScene::new(tmpl.name.clone());
365        for obj in &tmpl.objects {
366            let kind = match obj.name.as_str() {
367                "Ground" => Some(PrimitiveKind::Plane),
368                _ => None,
369            };
370            let id = if let Some(k) = kind {
371                go_scene.spawn_primitive(obj.name.clone(), k)
372            } else {
373                go_scene.spawn(obj.name.clone())
374            };
375            if let Ok(id) = id {
376                if let Some(go) = go_scene.find_mut(id) {
377                    go.transform.position = obj.position;
378                    go.transform.scale = obj.scale;
379                    go.property_tags = obj.tags.clone();
380                }
381            }
382        }
383
384        let particle_count = dreamwell_metaphors::DEFAULT_PARTICLE_COUNT;
385        let mut sim = SimulationService::new(&tmpl.name, crate::play::SimulationMode::Published);
386        if let Err(e) = sim.initialize(go_scene, spawn_pos, particle_count) {
387            log::error!("Simulation init failed: {e}");
388            // Layer is already set to Point — scene will still render even without simulation
389        } else {
390            self.simulation = Some(sim);
391            self.decoder = Some(dreamwell_fabric::decoder::CausalEngineDecoder::new());
392        }
393        self.game_state.player_position = glam::Vec3::from_array(spawn_pos);
394
395        // Position chase camera behind and above the player.
396        // 0.30 rad ≈ 17° elevation — standard third-person overhead angle.
397        self.camera_orbit_pitch = 0.30;
398        self.camera_distance = DEFAULT_CAMERA_DIST;
399        self.camera_target_distance = DEFAULT_CAMERA_DIST;
400        let pitch = self.camera_orbit_pitch;
401        let yaw = self.camera_orbit_yaw;
402        let horiz = pitch.cos() * self.camera_distance;
403        let height = pitch.sin() * self.camera_distance;
404        let cam_offset = glam::Vec3::new(
405            -yaw.cos() * horiz + yaw.sin() * SHOULDER_OFFSET,
406            height,
407            -yaw.sin() * horiz - yaw.cos() * SHOULDER_OFFSET,
408        );
409        let player = glam::Vec3::from_array(spawn_pos);
410        self.scene.camera.position = player + cam_offset;
411        self.scene.camera.center = glam::Vec3::new(spawn_pos[0], spawn_pos[1] + LOOKAT_HEIGHT, spawn_pos[2]);
412        self.scene.camera.update_view_matrix();
413    }
414
415    /// Load a full scene from TapestryLock and initialize SimulationService.
416    /// Follows THE_BRAIDED_PATH: tapestry.lock → Loom::weave → WovenScene
417    /// → CausalComputeKernel + DreamGate + SDK Decoherence on layer 9.
418    fn load_scene_from_lock(&mut self, lock: &dreamwell_sdk::tapestry::TapestryLock) {
419        use dreamwell_engine::loom;
420
421        // Reconstruct GameObjectScene from lock objects.
422        let mut scene = dreamwell_engine::game_object::GameObjectScene::new(lock.scene_name.clone());
423        for obj in &lock.objects {
424            // Determine primitive kind from tags/name for mesh geometry.
425            let primitive = Self::infer_primitive_kind(&obj.tags, &obj.name);
426            let id = if let Some(kind) = primitive {
427                scene.spawn_primitive(obj.name.clone(), kind)
428            } else {
429                scene.spawn(obj.name.clone())
430            };
431            if let Ok(id) = id {
432                if let Some(go) = scene.find_mut(id) {
433                    go.transform.position = obj.position;
434                    go.transform.rotation = obj.rotation;
435                    go.transform.scale = obj.scale;
436                    go.visible = obj.visible;
437                    go.parent_id = obj.parent_id;
438                    go.property_tags = obj.tags.clone();
439                    // Parse topology layer from tags.
440                    for tag in &obj.tags {
441                        if let Some(rest) = tag.strip_prefix("isTopologyLayer") {
442                            if let Ok(layer) = rest.parse::<u8>() {
443                                go.topology_layer = layer.min(9);
444                            }
445                        }
446                    }
447                }
448            }
449        }
450
451        // Hydrate tags from template if lock has empty tag arrays.
452        Self::hydrate_tags_from_template(&mut scene);
453
454        // Weave through Loom for validation + physics body extraction.
455        let woven = match loom::weave(scene, loom::RenderConfig::default()) {
456            Ok(w) => w,
457            Err(errs) => {
458                log::error!("Loom validation failed: {:?}", errs);
459                return;
460            }
461        };
462
463        // Find spawn position (isInputReceiver tag).
464        let spawn_pos = woven
465            .scene
466            .objects
467            .iter()
468            .find(|o| o.property_tags.iter().any(|t| t == "isInputReceiver"))
469            .map(|o| o.transform.position)
470            .unwrap_or([0.0, 0.5, 0.0]);
471
472        // Initialize SimulationService with full gate-routed SDK dispatch.
473        let mut sim = SimulationService::new(&woven.scene.name, crate::play::SimulationMode::Published);
474
475        // Collect decoherence fields from scene tags.
476        sim.decoherence_fields = Self::collect_decoherence_fields(&woven);
477
478        // Set active layer BEFORE simulation init — scene content lives on layer 9.
479        self.game_state.active_layer = TopologyLayer::Point;
480
481        // Initialize kernel at spawn point on topology layer 9 (Point).
482        // sim.initialize() sets sim.scene internally from the woven scene.
483        let particle_count = dreamwell_metaphors::DEFAULT_PARTICLE_COUNT;
484        if let Err(e) = sim.initialize(woven.scene, spawn_pos, particle_count) {
485            log::error!("Simulation init failed: {e}");
486            return;
487        }
488        self.game_state.player_position = glam::Vec3::from_array(spawn_pos);
489
490        // Store the woven scene as the renderable scene.
491        self.scene.game_objects = sim.scene.clone();
492
493        // Initialize chase camera from kernel facing direction.
494        let init_yaw = sim
495            .encoder
496            .as_ref()
497            .map(|e| e.kernel().facing_yaw)
498            .or_else(|| sim.kernel.as_ref().map(|k| k.facing_yaw));
499        if let Some(yaw) = init_yaw {
500            self.camera_orbit_yaw = yaw;
501        }
502        self.camera_orbit_pitch = 0.35; // ~20 degrees above horizon
503        self.camera_distance = DEFAULT_CAMERA_DIST;
504        self.camera_target_distance = DEFAULT_CAMERA_DIST;
505
506        // Position camera behind and above the spawn point, looking forward.
507        let yaw = self.camera_orbit_yaw;
508        let pitch = self.camera_orbit_pitch;
509        let horiz = pitch.cos() * self.camera_distance;
510        let height = pitch.sin() * self.camera_distance;
511        let cam_offset = glam::Vec3::new(
512            -yaw.cos() * horiz + yaw.sin() * SHOULDER_OFFSET,
513            height,
514            -yaw.sin() * horiz - yaw.cos() * SHOULDER_OFFSET,
515        );
516        self.scene.camera.position = glam::Vec3::from_array(spawn_pos) + cam_offset;
517        self.scene.camera.center = glam::Vec3::new(spawn_pos[0], spawn_pos[1] + LOOKAT_HEIGHT, spawn_pos[2]);
518        self.scene.camera.update_view_matrix();
519
520        // Diagnostic: count mesh bindings for debugging visibility issues.
521        let mesh_count = self
522            .scene
523            .game_objects
524            .objects
525            .iter()
526            .filter(|o| matches!(o.mesh, dreamwell_engine::game_object::MeshBinding::Primitive { .. }))
527            .count();
528        let visible_mesh_count = self
529            .scene
530            .game_objects
531            .objects
532            .iter()
533            .filter(|o| matches!(o.mesh, dreamwell_engine::game_object::MeshBinding::Primitive { .. }) && o.visible)
534            .count();
535        for obj in &self.scene.game_objects.objects {
536            log::debug!(
537                "  obj '{}': mesh={:?}, visible={}, pos=[{:.1},{:.1},{:.1}]",
538                obj.name,
539                obj.mesh,
540                obj.visible,
541                obj.transform.position[0],
542                obj.transform.position[1],
543                obj.transform.position[2],
544            );
545        }
546        log::info!(
547            "Tapestry scene loaded: {} objects ({} meshes, {} visible), spawn at [{:.1}, {:.1}, {:.1}], layer 9 (Point)",
548            self.scene.game_objects.len(),
549            mesh_count, visible_mesh_count,
550            spawn_pos[0], spawn_pos[1], spawn_pos[2],
551        );
552
553        self.simulation = Some(sim);
554        self.decoder = Some(CausalEngineDecoder::new());
555    }
556
557    /// Convert CPU InterferenceKernels to GPU-uploadable GpuParticleKernel array.
558    /// Maps 10 Complex pairs × 3 axes to flat 60-float per-particle kernel.
559    fn convert_interference_kernels(
560        kernels: &dreamwell_quantum::InterferenceKernels,
561        particle_count: u32,
562    ) -> Vec<dreamwell_gpu::dreamlet_catalog::GpuParticleKernel> {
563        let n = (particle_count as usize).min(kernels.kernels.len());
564        let mut gpu_kernels = Vec::with_capacity(n);
565        for p in 0..n {
566            let mut flat = [0.0f32; 60];
567            for pair in 0..10 {
568                for axis in 0..3 {
569                    let c = kernels.kernels[p][pair][axis];
570                    flat[pair * 6 + axis * 2] = c.re;
571                    flat[pair * 6 + axis * 2 + 1] = c.im;
572                }
573            }
574            gpu_kernels.push(dreamwell_gpu::dreamlet_catalog::GpuParticleKernel {
575                kernels: flat,
576                _pad: [0.0; 4],
577            });
578        }
579        gpu_kernels
580    }
581
582    /// Extract quantum DecoherenceFields from woven scene objects.
583    fn collect_decoherence_fields(
584        woven: &dreamwell_engine::loom::WovenScene,
585    ) -> Vec<dreamwell_quantum::DecoherenceField> {
586        use dreamwell_engine::input::parse_decoherence_tag;
587        use dreamwell_engine::input::wave::{DecoherenceTagValue, WaveForm};
588
589        fn to_quantum_form(f: WaveForm) -> dreamwell_quantum::WaveForm {
590            match f {
591                WaveForm::Particle => dreamwell_quantum::WaveForm::Particle,
592                WaveForm::Humanoid => dreamwell_quantum::WaveForm::Humanoid,
593                WaveForm::Vehicle => dreamwell_quantum::WaveForm::Vehicle,
594                WaveForm::Fluid => dreamwell_quantum::WaveForm::Fluid,
595            }
596        }
597
598        let mut fields = Vec::new();
599        for obj in &woven.scene.objects {
600            let is_zone = obj.property_tags.iter().any(|t| t == "isDecoherenceZone");
601            if !is_zone {
602                continue;
603            }
604
605            let mut target_form = WaveForm::Humanoid;
606            let mut gamma = 2.0f32;
607            let mut clip = String::new();
608            for tag in &obj.property_tags {
609                if let Some(val) = parse_decoherence_tag(tag) {
610                    match val {
611                        DecoherenceTagValue::Form(form, animation) => {
612                            target_form = form;
613                            clip = animation;
614                        }
615                        DecoherenceTagValue::Gamma(rate) => {
616                            gamma = rate;
617                        }
618                    }
619                }
620            }
621
622            let radius = obj
623                .property_tags
624                .iter()
625                .find_map(|t| t.strip_prefix("decohere.radius=").and_then(|v| v.parse::<f32>().ok()))
626                .unwrap_or(10.0);
627
628            fields.push(dreamwell_quantum::DecoherenceField {
629                position: obj.transform.position,
630                radius,
631                target_form: to_quantum_form(target_form),
632                gamma,
633                animation_clip: clip,
634                source_tag: "isDecoherenceZone".into(),
635            });
636        }
637        fields
638    }
639
640    #[cfg(feature = "bundled-skybox")]
641    fn default_skybox_bytes() -> &'static [u8] {
642        include_bytes!("../assets/skyboxes/textures/basic_skybox.jpeg")
643    }
644
645    #[cfg(not(feature = "bundled-skybox"))]
646    fn default_skybox_bytes() -> &'static [u8] {
647        &[]
648    }
649
650    /// Infer PrimitiveKind from object tags and name.
651    /// Returns None for objects that shouldn't have geometry (skybox, player, zone markers).
652    fn infer_primitive_kind(tags: &[String], name: &str) -> Option<PrimitiveKind> {
653        let has_tag = |t: &str| tags.iter().any(|s| s == t);
654        // Skybox, player avatar, and zone markers have no mesh geometry.
655        if has_tag("isSkybox") || has_tag("isInputReceiver") || has_tag("isPlayer") {
656            return None;
657        }
658        // Zone topology markers (no position/scale) are invisible.
659        if name.starts_with("Zone ") {
660            return None;
661        }
662        // Ground/floor → Plane (rendered as a flat slab via cube with thin Y scale)
663        if has_tag("isGround") {
664            return Some(PrimitiveKind::Cube);
665        }
666        // Walls → Cube
667        if has_tag("isWall") || has_tag("isCollider") {
668            return Some(PrimitiveKind::Cube);
669        }
670        // POI/Fractal → Sphere
671        if has_tag("poi") || has_tag("isInteractable") || has_tag("isFractal") {
672            return Some(PrimitiveKind::Sphere);
673        }
674        // Default: Cube for any remaining visible object
675        if !tags.is_empty() {
676            return Some(PrimitiveKind::Cube);
677        }
678        None
679    }
680
681    /// Hydrate tags from template when lock file has empty tags arrays.
682    /// Also assigns MeshBinding::Primitive based on hydrated tags, since
683    /// objects from stale locks have MeshBinding::None.
684    fn hydrate_tags_from_template(scene: &mut dreamwell_engine::game_object::GameObjectScene) {
685        if scene.objects.iter().any(|o| !o.property_tags.is_empty()) {
686            // Tags already present — still ensure mesh bindings are assigned.
687            for obj in &mut scene.objects {
688                if matches!(obj.mesh, dreamwell_engine::game_object::MeshBinding::None) {
689                    if let Some(kind) = Self::infer_primitive_kind(&obj.property_tags, &obj.name) {
690                        obj.mesh = dreamwell_engine::game_object::MeshBinding::Primitive {
691                            kind,
692                            color: [0.7, 0.7, 0.7, 1.0],
693                        };
694                    }
695                }
696            }
697            return;
698        }
699        let tmpl = dreamwell_sdk::templates::shapes_and_dimensions();
700        for obj in &mut scene.objects {
701            if let Some(tmpl_obj) = tmpl.objects.iter().find(|t| t.name == obj.name) {
702                obj.property_tags = tmpl_obj.tags.clone();
703                for tag in &obj.property_tags {
704                    if let Some(rest) = tag.strip_prefix("isTopologyLayer") {
705                        if let Ok(layer) = rest.parse::<u8>() {
706                            obj.topology_layer = layer.min(9);
707                        }
708                    }
709                }
710                // Assign mesh binding from hydrated tags.
711                if matches!(obj.mesh, dreamwell_engine::game_object::MeshBinding::None) {
712                    if let Some(kind) = Self::infer_primitive_kind(&obj.property_tags, &obj.name) {
713                        obj.mesh = dreamwell_engine::game_object::MeshBinding::Primitive {
714                            kind,
715                            color: [0.7, 0.7, 0.7, 1.0],
716                        };
717                    }
718                }
719            }
720        }
721        log::info!("Tag hydration: applied template tags to stale lock");
722    }
723}
724
725impl ApplicationHandler for RuntimeApp {
726    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
727        if self.window_state.is_some() {
728            return;
729        }
730
731        let window_state = WindowState::new(event_loop, &self.config.title, self.config.width, self.config.height);
732        let renderer = SceneRenderer::with_msaa(&window_state, self.config.msaa_samples);
733        self.window_state = Some(window_state);
734        self.renderer = Some(renderer);
735
736        // Validate GPU shaders via naga IR (no GPU device needed).
737        let naga_report = dreamwell_naga::validate_startup();
738        if naga_report.is_ok() {
739            log::info!(
740                "Naga: {}/{} shaders OK",
741                naga_report.passed,
742                naga_report.shader_results.len()
743            );
744        } else {
745            log::warn!("Naga validation warnings:\n{}", naga_report.summary());
746        }
747
748        // Seed demo scene and upload to GPU.
749        self.seed_demo_scene();
750        if let (Some(ws), Some(r)) = (&self.window_state, &mut self.renderer) {
751            // Set projection matrix from actual window dimensions.
752            let aspect = ws.surface_config.width as f32 / ws.surface_config.height.max(1) as f32;
753            self.scene.camera.update_projection(aspect, 60.0);
754
755            r.fabric.upload_scene(&ws.device, &self.scene.game_objects);
756
757            // Add template directional sun — required for PBR lighting.
758            // Without this, Cook-Torrance BRDF receives zero light and outputs black.
759            {
760                let sun_dir = glam::Vec3::new(0.3, -1.0, 0.4).normalize();
761                r.fabric.gpu_scene_mut().scene_lights.add_directional(
762                    dreamwell_engine::lighting::DirectionalLightDesc {
763                        direction: sun_dir.to_array(),
764                        color: [1.0, 0.98, 0.92],
765                        intensity_lux: 2.0,
766                    },
767                );
768            }
769
770            // Load skybox environment map.
771            let skybox_bytes = Self::default_skybox_bytes();
772            if !skybox_bytes.is_empty() {
773                if r.fabric.load_skybox_image(&ws.device, &ws.queue, skybox_bytes) {
774                    log::info!("Skybox loaded ({} bytes)", skybox_bytes.len());
775                } else {
776                    log::warn!("Skybox decode failed");
777                }
778            }
779
780            // If simulation is active (tapestry.lock loaded), initialize particle dreamlets
781            // for the wave controller avatar. 256 particles on golden spiral, uploaded to
782            // DreamFabric for GPU particle physics compute (THE_BRAIDED_PATH Phase G).
783            if let Some(ref sim) = self.simulation {
784                if !sim.particle_offsets.is_empty() {
785                    let spawn_pos = sim
786                        .encoder
787                        .as_ref()
788                        .map(|e| e.kernel().position)
789                        .or_else(|| sim.kernel.as_ref().map(|k| k.position))
790                        .unwrap_or([0.0, 0.5, 0.0]);
791                    let dreamlets = dreamwell_gpu::dreamlet_catalog::generate_particle(
792                        spawn_pos,
793                        sim.particle_offsets.len() as u32,
794                        0.8,
795                        [0.4, 0.6, 1.0, 0.85],
796                        &sim.particle_offsets,
797                    );
798                    r.fabric.ensure_dreamlet_catalog_ready(&ws.device, &ws.queue);
799                    r.fabric.init_particle_physics(&ws.device);
800                    r.fabric.upload_dreamlets(&ws.queue, &dreamlets);
801                    r.fabric.mark_particle_uploaded();
802
803                    // Initialize quantum bridge (CPU→GPU density matrix upload).
804                    let particle_count = dreamlets.len() as u32;
805                    r.fabric.init_quantum_bridge(&ws.device, particle_count);
806
807                    // Upload per-particle interference kernels from quantum state (once).
808                    let quantum_kernels = sim
809                        .encoder
810                        .as_ref()
811                        .map(|e| &e.kernel().quantum.kernels)
812                        .or_else(|| sim.kernel.as_ref().map(|k| &k.quantum.kernels));
813                    if let Some(kernels) = quantum_kernels {
814                        let gpu_kernels = Self::convert_interference_kernels(kernels, particle_count);
815                        r.fabric.upload_interference_kernels(&ws.queue, &gpu_kernels);
816                    }
817
818                    log::info!(
819                        "Particle dreamlets uploaded: {} particles, quantum bridge active",
820                        dreamlets.len()
821                    );
822                }
823            }
824
825            // Warm up pipeline cache synchronously before first frame.
826            let mut warmup = dreamwell_fabric::WarmupPhase::standard_entries();
827            warmup.complete_all(&ws.device, r.fabric.pipeline_cache_mut());
828            r.fabric.init_post_process_pipelines(&ws.device);
829            log::info!("Tapestry warmed: {} pipelines compiled", warmup.total());
830        }
831
832        // Configure render mode. PbrDefault auto-enables HDR (Rgba16Float) framebuffer.
833        // Particles render to HDR, then tonemap composites to swapchain surface.
834        // Without tonemap enabled, HDR content is never presented — particles invisible.
835        if let (Some(_ws), Some(r)) = (&self.window_state, &mut self.renderer) {
836            r.set_scene_dream_mode(dreamwell_engine::material::SceneDreamMode::PbrDefault);
837            // for_pbr() enables bloom + ACES tonemap — ensures HDR→surface compositing.
838            r.set_post_process_config(dreamwell_gpu::post::PostProcessConfig::for_pbr());
839            log::info!("Render mode: PbrDefault, post-processing: bloom + ACES tonemap");
840        }
841
842        log::info!("Runtime initialized");
843    }
844
845    fn window_event(&mut self, event_loop: &ActiveEventLoop, _window_id: WindowId, event: WindowEvent) {
846        match event {
847            WindowEvent::CloseRequested => {
848                event_loop.exit();
849            }
850            WindowEvent::Resized(size) => {
851                if let Some(ws) = &mut self.window_state {
852                    ws.resize(size.width, size.height);
853                    if let Some(r) = &mut self.renderer {
854                        r.resize(size.width, size.height, &ws.device);
855                    }
856                }
857            }
858            WindowEvent::KeyboardInput { event, .. } => {
859                self.input.handle_keyboard(&event);
860                if let winit::keyboard::PhysicalKey::Code(code) = event.physical_key {
861                    use winit::event::ElementState;
862                    use winit::keyboard::KeyCode;
863                    match (code, event.state) {
864                        // TAB: cycle active metaphor profile (Wave → Fibonacci → Stream → Wave).
865                        (KeyCode::Tab, ElementState::Pressed) => {
866                            if let Some(ref mut sim) = self.simulation {
867                                let name = sim.cycle_metaphor();
868                                log::info!("Metaphor: {name}");
869                            }
870                        }
871                        // Q: Cohere form (fibonacci, coherent particles)
872                        (KeyCode::KeyQ, ElementState::Pressed) => {
873                            if let Some(ref mut sim) = self.simulation {
874                                sim.set_coherence_target(1.0);
875                            }
876                        }
877                        // E: Wave form (sinusoidal, decoherent particles)
878                        (KeyCode::KeyE, ElementState::Pressed) => {
879                            if let Some(ref mut sim) = self.simulation {
880                                sim.set_coherence_target(0.0);
881                            }
882                        }
883                        // Ctrl: Gather mode (hold)
884                        (KeyCode::ControlLeft | KeyCode::ControlRight, ElementState::Pressed) => {
885                            if let Some(ref mut sim) = self.simulation {
886                                sim.set_gather(true);
887                            }
888                        }
889                        (KeyCode::ControlLeft | KeyCode::ControlRight, ElementState::Released) => {
890                            if let Some(ref mut sim) = self.simulation {
891                                sim.set_gather(false);
892                            }
893                        }
894                        _ => {}
895                    }
896                }
897            }
898            WindowEvent::MouseInput { state, button, .. } => {
899                self.input.handle_mouse_button(button, state);
900                // Primary mouse button: emit strength
901                if button == winit::event::MouseButton::Left {
902                    if let Some(ref mut sim) = self.simulation {
903                        match state {
904                            winit::event::ElementState::Pressed => sim.set_emit_strength(1.0),
905                            winit::event::ElementState::Released => sim.set_emit_strength(0.0),
906                        }
907                    }
908                }
909            }
910            WindowEvent::CursorMoved { position, .. } => {
911                self.input.handle_cursor_move(position.x as f32, position.y as f32);
912            }
913            WindowEvent::MouseWheel { delta, .. } => {
914                self.input.handle_scroll(&delta);
915            }
916            WindowEvent::RedrawRequested => {
917                self.timer.tick();
918
919                // Phase 1: PollInput — collect window/input events.
920                // When simulation is active, WASD drives the kernel (not camera pan).
921                // Camera follows the player via chase-cam in Phase 5a.
922                if self.simulation.is_none() {
923                    self.input
924                        .apply_to_camera(&mut self.scene.camera, self.timer.delta_time());
925                } else {
926                    // Only apply mouse look (scroll zoom, drag rotation) — not WASD pan.
927                    self.input
928                        .apply_mouse_to_camera(&mut self.scene.camera, self.timer.delta_time());
929                }
930
931                // Phase 1b: Gamepad input (feature-gated, no-op when disabled).
932                #[cfg(feature = "gamepad")]
933                self.input.handle_gamepad(&mut self.gilrs, &mut self.scene.camera);
934
935                // Phase 2: PollAuthority — non-blocking check for authority events
936                self.authority_events.clear();
937                self.authority.poll_events(&mut self.authority_events);
938
939                // Phase 3: StageAuthorityEvents — buffer events for atomic application.
940                // Explicit reborrow to satisfy the borrow checker: staging and
941                // authority_events are disjoint fields but accessed through `self`.
942                {
943                    let staging = &mut self.staging;
944                    let events = &mut self.authority_events;
945                    staging.extend(events.drain(..));
946                }
947
948                // Phase 4: ApplyAuthorityEvents — apply staged events at tick boundary
949                for event in self.staging.drain() {
950                    match event {
951                        AuthorityEvent::Ack { .. } => { /* intent acknowledged */ }
952                        AuthorityEvent::Reject { seq, reason } => {
953                            log::debug!("Authority rejected intent {seq}: {reason}");
954                        }
955                        AuthorityEvent::CanonEvent { tick, event_type, .. } => {
956                            log::trace!("Canon event at tick {tick}: {event_type}");
957                        }
958                        AuthorityEvent::SnapshotChunk { .. } => { /* snapshot processing */ }
959                    }
960                }
961
962                // Phase 5: UpdateMirror — update client mirror from applied events
963                self.game_state.update(self.timer.delta_time());
964
965                // Phase 5a: CausalComputeKernel tick (THE_BRAIDED_PATH phases B-E).
966                // Single canonical input path: FabricInput.build_frame() → InputPacket::from_frame().
967                // Binding-aware: rebinding WASD works. Gamepad overlays via synthetic key presses.
968                if let Some(ref mut sim) = self.simulation {
969                    let dt_secs = self.timer.delta_time();
970
971                    // Gamepad → synthetic key presses into FabricInput (before build_frame).
972                    #[cfg(feature = "gamepad")]
973                    {
974                        use dreamwell_engine::input::VirtualKey;
975                        const DEADZONE: f32 = 0.2;
976                        use gilrs::Axis;
977                        if let Some((_id, gamepad)) = self.gilrs.gamepads().next() {
978                            let lx = gamepad.value(Axis::LeftStickX);
979                            let ly = gamepad.value(Axis::LeftStickY);
980                            if ly > DEADZONE {
981                                self.input.fabric.key_down(VirtualKey::W);
982                            }
983                            if ly < -DEADZONE {
984                                self.input.fabric.key_down(VirtualKey::S);
985                            }
986                            if lx < -DEADZONE {
987                                self.input.fabric.key_down(VirtualKey::A);
988                            }
989                            if lx > DEADZONE {
990                                self.input.fabric.key_down(VirtualKey::D);
991                            }
992                            if gamepad.is_pressed(gilrs::Button::South) {
993                                self.input.fabric.key_down(VirtualKey::Space);
994                            }
995                            if gamepad.is_pressed(gilrs::Button::LeftTrigger2) {
996                                self.input.fabric.key_down(VirtualKey::ShiftLeft);
997                            }
998                        }
999                    }
1000
1001                    // Consume orbit input BEFORE building InputPacket so camera_yaw is current.
1002                    self.camera_orbit_yaw += self.input.orbit_dx;
1003                    self.camera_orbit_pitch =
1004                        (self.camera_orbit_pitch + self.input.orbit_dy).clamp(MIN_PITCH, MAX_PITCH);
1005                    self.camera_target_distance =
1006                        (self.camera_target_distance - self.input.scroll_delta).clamp(MIN_CAMERA_DIST, MAX_CAMERA_DIST);
1007
1008                    // Build binding-aware InputFrame → InputPacket (single canonical path).
1009                    // coherence_current is the smoothly-lerped value (lerped inside sim.tick
1010                    // last frame). This gives the encoder a gradual transition, not a hard snap.
1011                    let frame = self.input.fabric.build_frame();
1012                    let packet = dreamwell_engine::input::InputPacket::from_frame(
1013                        &frame,
1014                        self.camera_orbit_yaw,
1015                        dt_secs,
1016                        sim.elapsed_time as f64,
1017                        sim.tick + 1,
1018                        sim.encoder.as_ref().map(|e| e.kernel().grounded).unwrap_or(true),
1019                        sim.coherence_current,
1020                        sim.gather_active,
1021                        sim.emit_strength,
1022                    );
1023
1024                    // Smooth zoom: lerp actual distance toward target for buttery scroll feel.
1025                    let zoom_t = 1.0 - (-ZOOM_LERP_RATE * dt_secs).exp();
1026                    self.camera_distance += (self.camera_target_distance - self.camera_distance) * zoom_t;
1027
1028                    if sim.frame == 0 {
1029                        log::info!(
1030                            "Sim pre-tick: input_enabled={} readiness={:?} encoder={} movement=[{:.2},{:.2}]",
1031                            sim.input_enabled(),
1032                            sim.readiness,
1033                            sim.encoder.is_some(),
1034                            packet.movement[0],
1035                            packet.movement[1],
1036                        );
1037                    }
1038                    if let Some(result) = sim.tick(&packet) {
1039                        if sim.frame <= 3 || sim.frame % 600 == 0 {
1040                            log::info!(
1041                                "Sim tick #{}: pos=[{:.2},{:.2},{:.2}] mode={} bridge={}",
1042                                sim.frame,
1043                                result.position[0],
1044                                result.position[1],
1045                                result.position[2],
1046                                result.locomotion_mode,
1047                                sim.last_bridge_packet.is_some(),
1048                            );
1049                        }
1050                        // Sync kernel result back to game state for GPU upload.
1051                        self.game_state.player_position = glam::Vec3::from_array(result.position);
1052                        if result.layer_changed {
1053                            if let Some(layer) = TopologyLayer::from_u8(result.topology_layer) {
1054                                self.game_state.active_layer = layer;
1055                            }
1056                        }
1057                        // Sync scene from simulation (mutations applied by DreamGate).
1058                        self.scene.game_objects = sim.scene.clone();
1059
1060                        // POI interaction + collision dispatch.
1061                        sim.tick_collisions();
1062                        sim.tick_poi_interactions();
1063
1064                        // Chase camera: third-person over-shoulder follow.
1065                        // Sprint pull-back: widen the view slightly while sprinting.
1066                        let sprint_extra = if packet.sprint { SPRINT_PULL_BACK } else { 0.0 };
1067                        let effective_dist = self.camera_distance + sprint_extra;
1068
1069                        // Use encoder kernel if available, else fall back to direct kernel.
1070                        let kernel_pos = sim
1071                            .encoder
1072                            .as_ref()
1073                            .map(|e| e.kernel().position)
1074                            .or_else(|| sim.kernel.as_ref().map(|k| k.position));
1075                        if let Some(kpos) = kernel_pos {
1076                            let pos = glam::Vec3::from_array(kpos);
1077                            let yaw = self.camera_orbit_yaw;
1078                            let pitch = self.camera_orbit_pitch;
1079
1080                            // Spherical offset: pitch controls elevation, yaw controls azimuth.
1081                            // cos(pitch) = horizontal distance factor, sin(pitch) = height factor.
1082                            let horiz = pitch.cos() * effective_dist;
1083                            let height = pitch.sin() * effective_dist;
1084                            let offset = glam::Vec3::new(
1085                                -yaw.cos() * horiz + yaw.sin() * SHOULDER_OFFSET,
1086                                height,
1087                                -yaw.sin() * horiz - yaw.cos() * SHOULDER_OFFSET,
1088                            );
1089                            let target_cam_pos = pos + offset;
1090                            // Exponential smoothing: alpha = 1 - e^(-rate * dt).
1091                            // Frame-rate independent — identical behavior at 30/60/144fps.
1092                            let pos_t = 1.0 - (-CAMERA_POSITION_LERP_RATE * dt_secs).exp();
1093                            let look_t = 1.0 - (-CAMERA_LOOKAT_LERP_RATE * dt_secs).exp();
1094                            self.scene.camera.position = self.scene.camera.position.lerp(target_cam_pos, pos_t);
1095                            self.scene.camera.center = self
1096                                .scene
1097                                .camera
1098                                .center
1099                                .lerp(pos + glam::Vec3::Y * LOOKAT_HEIGHT, look_t);
1100                            // Recompute view matrix from updated position/center.
1101                            // Without this, shaders use the stale default VP matrix.
1102                            self.scene.camera.update_view_matrix();
1103                        }
1104
1105                        // Bridge packet is produced inside sim.tick() and consumed in Phase 5c.
1106                    }
1107                }
1108
1109                // Phase 5b: Audio tick — mixer update, not render pass (feature-gated).
1110                #[cfg(feature = "audio")]
1111                {
1112                    let _ = &self.audio;
1113                }
1114
1115                // Phase 5c: Consume pre-bridged packet from SimulationService.
1116                // The bridge was already called in sim.tick(), so we just consume the packet.
1117                let mut decoder_handled = false;
1118                if let (Some(ref mut sim), Some(ref mut decoder), Some(r)) =
1119                    (&mut self.simulation, &mut self.decoder, &mut self.renderer)
1120                {
1121                    if let Some(ref packet) = sim.last_bridge_packet {
1122                        let input = DecoderInput::from_bridge_packet(packet);
1123                        decoder.submit(&mut r.fabric, &input);
1124                        decoder_handled = true;
1125                    }
1126                }
1127
1128                // Phase 6: UpdatePresentation — rebuild render-facing interpolated state
1129                // (currently handled by game_state.update above)
1130
1131                // Phase 7: AssembleFrame — prepare observer context + render packet
1132                let dt = self.timer.delta_time();
1133                let player_pos = self.game_state.player_position();
1134                let active_layer = self.game_state.active_layer();
1135
1136                // Phase 7: AssembleFrame — observer context from game state.
1137                // The bridge packet (Phase 5c) is the sole source of GPU state.
1138                // No fallback path — if the bridge didn't produce a packet, GPU uses last-frame state.
1139                let _ = decoder_handled;
1140
1141                // Phase 8: Render — submit DreamFabric frame
1142                if let (Some(ws), Some(r)) = (&self.window_state, &mut self.renderer) {
1143                    r.render(ws, &self.scene, dt, player_pos, active_layer);
1144                }
1145
1146                // Phase 9: DrainSync — drain sync packet and enqueue outgoing work
1147                if let Some(r) = &mut self.renderer {
1148                    self.sync_packet.clear();
1149                    r.fabric.drain_sync(&mut self.sync_packet);
1150                    if !self.sync_packet.intents.is_empty() {
1151                        self.authority.submit_intents(&self.sync_packet.intents);
1152                    }
1153                }
1154
1155                // End-of-frame input cleanup
1156                self.input.end_frame();
1157
1158                // Request next frame
1159                if let Some(ws) = &self.window_state {
1160                    ws.window.request_redraw();
1161                }
1162            }
1163            _ => {}
1164        }
1165    }
1166}