//! A fly-camera over one level's terrain. //! //! The first thing in the project that puts the disc's own geometry on screen. //! It is deliberately crude: no GS emulation, no sky, no tie/moby, no fog, no //! lighting. Its job is to answer the one question parser tests cannot — //! whether everything decoded so far actually looks like a place. //! //! Run it against unpacked level data: //! //! ```text //! cargo run -p gc-engine -- [level] //! ``` //! //! Controls: `A`/`D` orbit, `W`/`S` dolly, `Q`/`E` change height, `R` reloads //! the level so LOD selection can be seen changing. use std::collections::BTreeMap; use std::path::PathBuf; use anyhow::{Context, Result}; use bevy::asset::RenderAssetUsages; use bevy::image::{ImageFilterMode, ImageSampler, ImageSamplerDescriptor}; use bevy::prelude::*; use bevy::render::mesh::{Indices, PrimitiveTopology}; use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use bevy::render::view::window::screenshot::{CapturedScreenshots, Screenshot}; use bevy::window::WindowResolution; use gc_engine::level::{build_level, level_bounds, LevelGeometry, LevelSource, TriangleBatch}; fn main() -> Result<()> { let args: Vec = std::env::args().skip(1).collect(); let (dir, level) = match args.len() { 0 => { println!("usage: gc-engine [level]"); return Ok(()); } 1 => (PathBuf::from(&args[0]), 0usize), _ => ( PathBuf::from(&args[0]), args[1].parse().context("level number")?, ), }; // Read the level once here rather than in a startup system: a resource // inserted through `Commands` is deferred to the end of the schedule, so // `setup` could not see a focus inserted that way. let (files, focus) = load_source(&dir, level)?; let geometry = build_level(&files.as_source(), [focus[0], focus[1] + 150.0, focus[2]])?; report(&geometry); let mut app = App::new(); app.add_plugins(DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { title: format!("Going Commando — level{level}"), resolution: WindowResolution::new(1280, 720), ..default() }), ..default() })) .add_systems(Startup, (first_load, setup).chain()) .add_systems(Update, (orbit, reload, request_shot, save_shot)) .insert_resource(Level(level)) .insert_resource(LoadedDir(dir)) .insert_resource(Focus(focus)) .insert_resource(LoadedFiles(files)) .init_resource::(); if let Ok(path) = std::env::var("GC_ENGINE_SHOT") { app.insert_resource(Shot { path: PathBuf::from(path), frames: 20, }); } app.run(); Ok(()) } #[derive(Resource)] struct LoadedDir(PathBuf); /// The level's bytes, loaded in `main` so every system sees the same data. #[derive(Resource)] struct LoadedFiles(LevelFiles); /// The middle of the level's terrain, which is what the camera orbits. #[derive(Resource)] struct Focus([f32; 3]); /// Set `GC_ENGINE_SHOT` to a path to capture one frame and exit. /// /// Everything here can be verified by parsing, and the parse can be wrong in /// ways no assertion catches -- a mirrored axis, a UV in the wrong half of the /// texture, a hundredfold scale error all produce a well-formed frame. One /// look is worth more than any of them. #[derive(Resource)] struct Shot { path: std::path::PathBuf, frames: u32, } fn request_shot( mut commands: Commands, shot: Res, mut frame: Local, mut done: Local, ) { *frame += 1; if *done || *frame < shot.frames { return; } *done = true; commands.spawn(Screenshot::primary_window()); } fn save_shot( captured: Res, shot: Res, mut exit: MessageWriter, ) { let Ok((_entity, image)) = captured.0.lock().unwrap().try_recv() else { return; }; let (w, h) = (image.width(), image.height()); // Bevy hands back RGBA; PPM has no alpha, so composite over black rather // than pretending the level has an alpha channel. let Some(data) = image.data.as_ref() else { eprintln!("screenshot returned no pixels"); let _ = exit.write(bevy::app::AppExit::error()); return; }; let mut out = format!("P6\n{w} {h}\n255\n").into_bytes(); for px in data.chunks_exact(4) { out.extend_from_slice(&px[..3]); } if let Err(e) = std::fs::write(&shot.path, &out) { eprintln!("screenshot: {e}"); } else { eprintln!("screenshot written to {}", shot.path.display()); } let _ = exit.write(bevy::app::AppExit::Success); } #[derive(Resource)] struct Level(usize); #[derive(Resource)] struct Orbit { azimuth: f32, pitch: f32, distance: f32, height: f32, } impl Default for Orbit { fn default() -> Self { // Looking down at roughly 30 degrees from a few hundred units out. Orbit { azimuth: 0.6, pitch: 0.5, distance: 260.0, height: 40.0, } } } fn setup(mut commands: Commands, focus: Res, orbit: Res) { // Start where `orbit` would put it. `orbit` only runs when a key changes // something, so a camera spawned anywhere else sits at its initial pose // until the first keypress -- and pointing at the world origin, which is // nowhere near the terrain, that is an empty screen. let target = Vec3::from_array(focus.0); let (sin_p, cos_p) = orbit.pitch.sin_cos(); let (sin_a, cos_a) = orbit.azimuth.sin_cos(); let offset = Vec3::new( cos_p * cos_a * orbit.distance, sin_p * orbit.distance, cos_p * sin_a * orbit.distance, ); commands.spawn(( Camera3d::default(), Transform::from_translation(target + offset).looking_at(target, Vec3::Y), // A long far plane: levels are hundreds of units across and the whole // level is drawn at once, so culling is the patch bounding spheres' // job, not the projection's. Projection::Perspective(PerspectiveProjection { far: 8000.0, ..default() }), )); // Ambient only. The game's lighting is a per-vertex light array that is // parsed but not yet reproduced, and a directional light would make the // terrain look lit when it is not. commands.spawn(AmbientLight { color: Color::WHITE, brightness: 1600.0, ..default() }); } /// Load the level once at startup. It is the same path `R` takes afterwards, /// so the first frame and a reload cannot disagree. /// Spawn the level `main` already loaded. fn first_load( mut commands: Commands, images: ResMut>, meshes: ResMut>, materials: ResMut>, files: Res, focus: Res, ) { let camera = [focus.0[0], focus.0[1] + 150.0, focus.0[2]]; match build_level(&files.0.as_source(), camera) { Ok(g) => spawn_level(&mut commands, images, meshes, materials, &g), Err(e) => eprintln!("could not build level: {e:#}"), } } fn reload( keys: Res>, mut commands: Commands, images: ResMut>, meshes: ResMut>, materials: ResMut>, level: Res, dir: Res, ) { if !keys.just_pressed(KeyCode::KeyR) { return; } let src = match load(&dir.0, level.0) { Ok(s) => s, Err(e) => { eprintln!("reload failed: {e:#}"); return; } }; spawn_level(&mut commands, images, meshes, materials, &src); } /// Read a level's three files and find the middle of its terrain. fn load_source(dir: &std::path::Path, level: usize) -> Result<(LevelFiles, [f32; 3])> { let level_dir = dir.join(format!("level{level}")); let core_data = std::fs::read(level_dir.join("core_data.bin")).context("core_data.bin")?; let core_index = std::fs::read(level_dir.join("core_index.bin")).context("core_index.bin")?; let gs_ram = std::fs::read(level_dir.join("gs_ram.bin")).context("gs_ram.bin")?; let overlay = std::fs::read(level_dir.join("overlay.bin")).ok(); let gameplay = std::fs::read(level_dir.join("gameplay.bin")).ok(); let src = LevelSource { core_data: &core_data, core_index: &core_index, gs_ram: &gs_ram, overlay: overlay.as_deref(), gameplay: gameplay.as_deref(), }; let (centre, _radius) = level_bounds(&src)?; Ok(( LevelFiles { core_data, core_index, gs_ram, overlay, gameplay, }, centre, )) } /// The level's bytes, owned so a system can outlive the load call. struct LevelFiles { core_data: Vec, core_index: Vec, gs_ram: Vec, overlay: Option>, gameplay: Option>, } impl LevelFiles { fn as_source(&self) -> LevelSource<'_> { LevelSource { core_data: &self.core_data, core_index: &self.core_index, gs_ram: &self.gs_ram, overlay: self.overlay.as_deref(), gameplay: self.gameplay.as_deref(), } } } /// Load a level as seen from above the middle of its terrain. fn load(dir: &std::path::Path, level: usize) -> Result { let (files, focus) = load_source(dir, level)?; build_level(&files.as_source(), [focus[0], focus[1] + 150.0, focus[2]]) } fn spawn_level( commands: &mut Commands, mut images: ResMut>, mut meshes: ResMut>, mut materials: ResMut>, g: &LevelGeometry, ) { report(g); for (image, mesh) in to_gpu(g) { let texture = images.add(image); let material = materials.add(StandardMaterial { base_color_texture: Some(texture), // Double-sided: the strips as stored do not carry a consistent // winding, and the game relies on GS defaults rather than culling. cull_mode: None, ..default() }); commands.spawn(( Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::IDENTITY, )); } } fn report(g: &LevelGeometry) { let s = &g.stats; eprintln!( "level: {} patches, {} drawn, {} unparsed | {tri} triangles, {v} vertices, {t} textures\n\ LOD distances {d:?} (raw {r:?})", s.patches, s.drawn, s.skipped_unparsed, tri = s.triangles, v = s.vertices, t = g.textures.len(), d = g.lod_distances, r = g.lod_thresholds_raw, ); let mut modes: BTreeMap<&str, usize> = BTreeMap::new(); for p in &g.patches { *modes.entry(mode_name(p)).or_default() += 1; } for (m, n) in modes { eprintln!(" {m}: {n} patches"); } // Say so when the level is not lit. Falling back silently to the tfrag's // stored colours produces a plausible but wrong picture. if s.lit_vertices < s.vertices { eprintln!( " lighting: {} of {} vertices shaded by LightTfrags; the rest fall back to the \ tfrag's stored colours (no usable light bank)", s.lit_vertices, s.vertices ); } } fn mode_name(p: &gc_engine::level::PatchBounds) -> &'static str { use gc_formats::tfrag::TfragDrawMode::*; match p.mode { Lod2 => "LOD2", Lod1Collapse => "LOD1 collapse", Lod1Morph => "LOD1 morph", Lod0Collapse => "LOD0 collapse", Lod0Morph => "LOD0 morph", Lod0 => "LOD0", } } fn to_gpu(g: &LevelGeometry) -> Vec<(Image, Mesh)> { let mut out = Vec::new(); for (&tex, batch) in &g.batches { let Some(image) = g.textures.get(&tex) else { continue; }; let Some(mesh) = mesh_of(batch) else { continue }; out.push((image_of(image), mesh)); } out } fn image_of(t: &gc_formats::texture::Texture) -> Image { let mut image = Image::new( Extent3d { width: t.width, height: t.height, depth_or_array_layers: 1, }, TextureDimension::D2, t.rgba.clone(), TextureFormat::Rgba8UnormSrgb, RenderAssetUsages::default(), ); // Only mip level 0 is decoded, so minification must clamp rather than // invent a level the disc does not have here. image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { label: Some("tfrag texture".into()), mipmap_filter: ImageFilterMode::Linear, mag_filter: ImageFilterMode::Linear, ..default() }); image } fn mesh_of(b: &TriangleBatch) -> Option { if b.indices.is_empty() { return None; } let mut mesh = Mesh::new( PrimitiveTopology::TriangleList, RenderAssetUsages::default(), ); mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, b.positions.clone()); mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, b.uvs.clone()); mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, b.colors.clone()); mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, b.normals.clone()); // No normals and no colours: lighting is the game's own per-vertex light // array, which is parsed but not yet reproduced. Inventing normals would // make unlit terrain look lit and hide that gap. mesh.insert_indices(Indices::U32(b.indices.clone())); Some(mesh) } fn orbit( keys: Res>, time: Res