diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 860e17e7..e7e6bf4c 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -92,21 +92,36 @@ const MAX_FLAT_ZOOM: f32 = 1.5; const WHEEL_PIXELS_PER_ZOOM_STEP: f32 = 120.0; /// The dark rig's near-nothing ambient brightness (material-dark-frame.md: /// a physically dark room before Eyes — nobody is home, the AI does not need -/// lights). A live sight feed replaces this with camera exposure. [TUNE]. +/// lights). A live sight feed adds low fill around authored sources. [TUNE]. const DARK_AMBIENT: f32 = 8.0; -/// Neutral camera exposure once Eyes earns a live image. Heard materials are -/// unlit, so this lifts Seen geometry without turning sound into sight. -const CAMERA_AMBIENT: f32 = 520.0; +/// Low neutral fill once Eyes earns a live image. The level's authored floor +/// fixtures provide the directional light; this only keeps their shadow side +/// from collapsing into black. Heard materials are unlit, so the fill still +/// cannot turn sound into sight. +const CAMERA_AMBIENT: f32 = 160.0; /// The dev work light's flat neutral flood: ambient bright enough that every /// mesh is inspectable and albedo reads with no mood. Dev affordance only, /// never a player surface (material-dark-frame.md, the dev work light). const WORKLIGHT_AMBIENT: f32 = 900.0; +/// Recessed data-hall floor fixtures: bright enough to model nearby racks, +/// short-ranged enough that the room reads as a sequence of lit aisles rather +/// than one camera-mounted flood. [TUNE]. +const FLOOR_LIGHT_INTENSITY: f32 = 150_000.0; +const FLOOR_LIGHT_RANGE: f32 = 4.0; /// The material camera's blindness is a soft physical falloff, not a hard -/// tile edge. Known geometry falls into the same near-black as the unrendered -/// unknown before the camera reaches the map boundary. +/// tile edge. These are the close-camera distances; zoom shifts both planes +/// with the eye so attention never falls behind its own fog curtain. const MATERIAL_FOG_START: f32 = 2.0; const MATERIAL_FOG_END: f32 = 8.0; +fn material_fog_range(camera_dist: f32) -> (f32, f32) { + let shift = camera_dist - CLOSE_DIST; + ( + (MATERIAL_FOG_START + shift).max(0.1), + (MATERIAL_FOG_END + shift).max(0.2), + ) +} + // ─── Palette ───────────────────────────────────────────────────────────────── /// The clinical palette — the single named table every world material in @@ -618,6 +633,15 @@ struct RealCamera; #[derive(Component)] struct WorkLight; +/// One authored recessed floor fixture in the Foundation data hall. Both the +/// emissive lens and its local point source carry this marker so fog can gate +/// the complete fixture as one earned piece of level geometry. +#[derive(Component)] +struct FloorFixture { + x: i32, + y: i32, +} + /// Gizmo group for the material render, drawn only on the 3D camera's render /// layer so flat-mode gizmos and material-mode gizmos never cross cameras. #[derive(Default, Reflect, GizmoConfigGroup)] @@ -1237,9 +1261,9 @@ fn main() { ) .insert_resource(ClearColor(Color::srgb(0.01, 0.01, 0.02))) // The pre-Eyes dark rig (material-dark-frame.md): near-nothing ambient, - // carved out only by machine light. apply_material_exposure lifts this - // to neutral camera exposure once a live sight feed exists. 3D-only; - // sprites are unlit. + // carved out only by machine light. After Eyes, authored level fixtures + // do the lighting while apply_material_exposure supplies low neutral fill. + // 3D-only; sprites are unlit. .insert_resource(GlobalAmbientLight { color: Color::srgb(0.62, 0.70, 0.86), brightness: DARK_AMBIENT, @@ -1284,6 +1308,7 @@ fn main() { render_tokens, ), ( + sync_floor_fixtures, render_work_consumptions, render_work_production, render_thought_sinks, @@ -1538,7 +1563,7 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { // stage one owned expansion on a real pilot allocation, and frame all six // rows. This remains dev-only screenshot setup; gameplay earns the same // states through Eyes and BUY. - if matches!(kind, "hall" | "hall-material") { + if matches!(kind, "hall" | "hall-material" | "floor-lights-close") { for y in 7..=28 { for x in 14..=59 { sim.seen.insert((x, y)); @@ -1585,6 +1610,11 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { mode.zoom = 5.2; game.set_cursor(36, 17); } + "floor-lights-close" => { + mode.material = true; + mode.zoom = 1.7; + game.set_cursor(28, 15); + } "close" => { mode.material = true; mode.zoom = 1.0; @@ -1724,6 +1754,7 @@ fn fog_audit_3d( )>, people: Query<(&Person3d, &InheritedVisibility)>, machines: Query<(&Machine3d, &InheritedVisibility)>, + fixtures: Query<(&FloorFixture, &InheritedVisibility)>, ) { let Some(h) = harness else { return; @@ -1806,6 +1837,27 @@ fn fog_audit_3d( "{cores} core chassis visible; expected {expected_cores} for the current Eyes state" )); } + let fixture_entities = fixtures.iter().count(); + let expected_fixture_entities = FOUNDATION_FLOOR_LIGHTS.len() * 2; + if fixture_entities != expected_fixture_entities { + violations.push(format!( + "{fixture_entities} floor-fixture entities; expected {expected_fixture_entities} \ + lenses + sources" + )); + } + let mut visible_fixtures = 0usize; + for (fixture, vis) in fixtures.iter() { + let expected = floor_fixture_visible(mode.material, game.sim.fog_at(fixture.x, fixture.y)); + if vis.get() != expected { + violations.push(format!( + "({}, {}) floor fixture visible={} but earned={expected}", + fixture.x, + fixture.y, + vis.get() + )); + } + visible_fixtures += usize::from(vis.get()); + } // Flat-materials criterion 1: the world material set is solid color — // no image texture on any pooled world material, ever. for (key, handle) in cache.cache.iter() { @@ -1823,9 +1875,11 @@ fn fog_audit_3d( ); println!( "fog audit OK: {checked} tile entities (unknown/blueprint/remembered=absent, \ - seen=lit, owned telemetry=lit), {} people billboards coverage-gated, \ + seen=lit, owned telemetry=lit), {} people billboards coverage-gated, \ + {visible_fixtures}/{} floor-fixture entities visible and fog-gated, \ material pool {} handles, all flat (no textures)", people.iter().count(), + fixture_entities, cache.cache.len() ); } @@ -2043,6 +2097,38 @@ fn grid_to_world_3d(x: i32, y: i32, h: f32) -> Vec3 { Vec3::new(x as f32 + 0.5, h, y as f32 + 0.5) } +/// Authored service lights in the three cold aisles between paired rack +/// rows. Their repeated four-tile rhythm makes the six-row hall legible as a +/// maintained institution without outlining every tile or spending amber on +/// decoration. Coordinates belong to the fixed B1 basement plate. +const FOUNDATION_FLOOR_LIGHTS: &[(i32, i32)] = &[ + (20, 10), + (24, 10), + (28, 10), + (32, 10), + (36, 10), + (40, 10), + (20, 16), + (24, 16), + (28, 16), + (32, 16), + (36, 16), + (40, 16), + (20, 22), + (24, 22), + (28, 22), + (32, 22), + (36, 22), + (40, 22), +]; + +/// A material fixture exists only in a live camera image of its floor tile. +/// Heard mass remains unlit and Unknown has no geometry, so the point source +/// can never leak a room picture across the epistemic boundary. +fn floor_fixture_visible(material: bool, fog: Fog) -> bool { + material && matches!(fog, Fog::Seen) +} + /// A tiny procedural bone-white silhouette: people under camera coverage are /// billboards, never detailed art the sensor has not earned (views.md). fn person_silhouette_image() -> Image { @@ -3115,15 +3201,53 @@ fn setup_3d( )); } + // Recessed data-hall service lights. These are authored into the cold + // aisles rather than attached to attention or the camera: repeated pools + // establish the rack-row rhythm and model nearby chassis from below. + // sync_floor_fixtures reveals each complete fixture only when Eyes earns + // its tile, preserving Unknown/Heard as absence/model state. + let floor_light_mesh = meshes.add(Cuboid::new(0.18, 0.032, 0.82)); + let floor_light_material = materials.add(StandardMaterial { + base_color: Color::srgb(0.92, 0.94, 0.98), + emissive: Color::srgb(0.92, 0.94, 0.98).to_linear() * 6.0, + perceptual_roughness: 0.72, + ..default() + }); + for &(x, y) in FOUNDATION_FLOOR_LIGHTS { + commands.spawn(( + Mesh3d(floor_light_mesh.clone()), + MeshMaterial3d(floor_light_material.clone()), + Transform::from_translation(grid_to_world_3d(x, y, 0.018)), + Visibility::Hidden, + FloorFixture { x, y }, + layer.clone(), + ChildOf(root), + )); + commands.spawn(( + PointLight { + color: Color::srgb(0.86, 0.89, 0.94), + intensity: FLOOR_LIGHT_INTENSITY, + range: FLOOR_LIGHT_RANGE, + radius: 0.12, + shadows_enabled: false, + ..default() + }, + // The luminous lens is flush with the floor; the photometric + // source rides just proud of it so horizontal floor and vertical + // chassis faces both catch the local gradient. + Transform::from_translation(grid_to_world_3d(x, y, 0.14)), + Visibility::Hidden, + FloorFixture { x, y }, + layer.clone(), + ChildOf(root), + )); + } + // The light rig (material-dark-frame.md): before Eyes, machine light and - // near-nothing ambient. The basement remains physically dark, but a live - // camera feed neutrally exposes Seen surfaces through - // apply_material_exposure; this directional is the shared exposure flood. - // - // The shared flat neutral flood, hidden before Eyes. Camera exposure - // reveals it for ordinary play; F4 raises its ambient contribution for - // inspection. The stronger setting is a dev affordance, never a player - // surface. + // near-nothing ambient. Once Eyes exists, the authored fixtures light the + // level and a low neutral ambient preserves their shadow-side shape. + // This directional flood belongs only to F4 inspection; ordinary camera + // exposure never reveals it. commands.spawn(( DirectionalLight { color: Color::srgb(0.96, 0.97, 1.0), @@ -3276,17 +3400,17 @@ fn apply_render_mode( } } -/// Expose the image supplied by Eyes. The neutral flood illuminates only -/// light-responsive Seen materials; Heard mass is deliberately unlit and -/// Unknown geometry does not exist, so this makes sight readable without -/// leaking unearned space. F4 remains a stronger dev inspection exposure. +/// Expose the image supplied by Eyes. Ordinary sight receives only low +/// ambient fill; authored level fixtures provide its actual light direction. +/// The neutral directional flood is reserved for F4 inspection. Heard mass is +/// deliberately unlit and Unknown geometry does not exist. fn material_exposure(material: bool, has_sight: bool, worklight: bool) -> (f32, bool) { if !material { (DARK_AMBIENT, false) } else if worklight { (WORKLIGHT_AMBIENT, true) } else if has_sight { - (CAMERA_AMBIENT, true) + (CAMERA_AMBIENT, false) } else { (DARK_AMBIENT, false) } @@ -3302,10 +3426,31 @@ fn apply_material_exposure( return; } let has_sight = game.sim.reach.player_sight().next().is_some(); - let (brightness, exposed) = material_exposure(mode.material, has_sight, mode.worklight); + let (brightness, flood_on) = material_exposure(mode.material, has_sight, mode.worklight); ambient.brightness = brightness; if let Ok(mut v) = flood.single_mut() { - *v = if exposed { + *v = if flood_on { + Visibility::Inherited + } else { + Visibility::Hidden + }; + } +} + +/// Gate the authored floor-light mesh and point source together from the +/// shared fog state. The fixtures are level geometry, not camera equipment: +/// they stay fixed in world space as attention moves. +fn sync_floor_fixtures( + game: Res, + mode: Res, + mut fixtures: Query<(&FloorFixture, &mut Visibility)>, +) { + if !game.is_changed() && !mode.is_changed() { + return; + } + for (fixture, mut visibility) in fixtures.iter_mut() { + *visibility = if floor_fixture_visible(mode.material, game.sim.fog_at(fixture.x, fixture.y)) + { Visibility::Inherited } else { Visibility::Hidden @@ -3630,7 +3775,7 @@ fn update_camera_real( game: Res, wake: Res, mut mode: ResMut, - mut cam: Query<&mut Transform, With>, + mut cam: Query<(&mut Transform, &mut DistanceFog), With>, ) { if !mode.material || wake.active { return; @@ -3650,10 +3795,19 @@ fn update_camera_real( let target = grid_to_world_3d(game.cursor_x, game.cursor_y, 0.55); let tilt = CAMERA_TILT_DEG.to_radians(); let goal = target + Vec3::new(0.0, tilt.sin(), tilt.cos()) * dist; - let Ok(mut tf) = cam.single_mut() else { + let Ok((mut tf, mut fog)) = cam.single_mut() else { return; }; let t = 1.0 - (-8.0 * time.delta_secs()).exp(); + let (goal_fog_start, goal_fog_end) = material_fog_range(dist); + let (current_fog_start, current_fog_end) = match &fog.falloff { + FogFalloff::Linear { start, end } => (*start, *end), + _ => (goal_fog_start, goal_fog_end), + }; + fog.falloff = FogFalloff::Linear { + start: current_fog_start + (goal_fog_start - current_fog_start) * t, + end: current_fog_end + (goal_fog_end - current_fog_end) * t, + }; tf.translation = tf.translation.lerp(goal, t); let look = Transform::from_translation(tf.translation).looking_at(target, Vec3::Y); tf.rotation = tf.rotation.slerp(look.rotation, t); @@ -6915,8 +7069,8 @@ mod flat_materials { ); assert_eq!( material_exposure(true, true, false), - (CAMERA_AMBIENT, true), - "Eyes must expose Seen geometry and enable the neutral flood" + (CAMERA_AMBIENT, false), + "Eyes uses low fill while authored fixtures provide direction" ); assert_eq!( material_exposure(true, true, true), @@ -6929,4 +7083,50 @@ mod flat_materials { "material lighting stays off in the flat sensorium" ); } + + #[test] + fn floor_fixtures_are_authored_aisle_lights_not_camera_lights() { + let unique: HashSet<_> = FOUNDATION_FLOOR_LIGHTS.iter().copied().collect(); + assert_eq!( + unique.len(), + FOUNDATION_FLOOR_LIGHTS.len(), + "authored fixtures must not double-light one tile" + ); + assert!( + FOUNDATION_FLOOR_LIGHTS + .iter() + .all(|(x, y)| { (20..=40).contains(x) && [10, 16, 22].contains(y) && x % 4 == 0 }) + ); + let sim = Sim::new(); + for &(x, y) in FOUNDATION_FLOOR_LIGHTS { + assert_eq!( + sim.map.get_tile(x, y), + TileType::Floor, + "fixture ({x}, {y}) must stay on an authored walkable aisle tile" + ); + } + assert!(floor_fixture_visible(true, Fog::Seen)); + for fog in [Fog::Unknown, Fog::Blueprint, Fog::Heard, Fog::Remembered] { + assert!( + !floor_fixture_visible(true, fog), + "{fog:?} must not reveal a physical fixture or its light" + ); + } + assert!(!floor_fixture_visible(false, Fog::Seen)); + } + + #[test] + fn material_fog_tracks_camera_distance_without_moving_relative_to_attention() { + assert_eq!( + material_fog_range(CLOSE_DIST), + (MATERIAL_FOG_START, MATERIAL_FOG_END) + ); + for zoom in [MIN_ZOOM, 1.0, 2.8, MAX_ZOOM] { + let dist = CLOSE_DIST * zoom; + let (start, end) = material_fog_range(dist); + assert!(start < dist && dist < end); + assert!((dist - start - (CLOSE_DIST - MATERIAL_FOG_START)).abs() < 0.001); + assert!((end - dist - (MATERIAL_FOG_END - CLOSE_DIST)).abs() < 0.001); + } + } } diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index db0d1419..f2c9152c 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -38,7 +38,7 @@ is sim or frontend state, never an environment variable. | Variable | Surface | Values | Effect | |---|---|---|---| -| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `thoughtflow`, `thoughtflow-wide`, `signal`, `ears`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie`, `opening`, `hover-menu`, `menu`, `worklight`, `worklightoff`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `hall` / `hall-material` frame all six Foundation rows in the flat/material views with Eyes and one staged owned expansion; `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `thoughtflow` / `thoughtflow-wide` stage the THINK fluid (slugs, meniscus, filament snap) at close and wide framing; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` stages hearing-feed coverage (material dark mass; flat may still diagram coverage); `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `produce-think` / `draw-lie` capture sim-authored Thought/crimson production or the physical crimson transfer into a staged LIE well; `opening` freezes the tick-zero material view (host presence beam only, no chassis or room); `hover-menu` captures the bare WORK/THINK/LIE line beside the server via reticule focus (no pointer); `menu` opens the context menu on the tapped environmental monitor; `worklight` / `worklightoff` pair the dev work light on and off; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | +| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `floor-lights-close`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `thoughtflow`, `thoughtflow-wide`, `signal`, `ears`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie`, `opening`, `hover-menu`, `menu`, `worklight`, `worklightoff`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `hall` / `hall-material` frame all six Foundation rows in the flat/material views with Eyes and one staged owned expansion; `floor-lights-close` uses the same hall staging but frames the recessed aisle fixtures beside the core; `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `thoughtflow` / `thoughtflow-wide` stage the THINK fluid (slugs, meniscus, filament snap) at close and wide framing; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` stages hearing-feed coverage (material dark mass; flat may still diagram coverage); `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `produce-think` / `draw-lie` capture sim-authored Thought/crimson production or the physical crimson transfer into a staged LIE well; `opening` freezes the tick-zero material view (host presence beam only, no chassis or room); `hover-menu` captures the bare WORK/THINK/LIE line beside the server via reticule focus (no pointer); `menu` opens the context menu on the tapped environmental monitor; `worklight` / `worklightoff` pair the dev work light on and off; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | | `MISALIGNED_SHOT` | `misaligned-assets` | `dead`, `foreign`, `idle`, `busy`, `core`, canonical modes `work`, `think`, `lie` plus the legacy screenshot aliases `dayjob` (= work), `research`/`operations`/`ops` (= think), `conceal` (= lie) — each optionally suffixed `_ladder`/`_ring`/`_wash` — `lineup[_