use concinnity_core::components::GraphicsConfig;
use concinnity_core::ecs::ScheduleMode;
use concinnity_core::error::WorldError;
use concinnity_core::render::rt_geom::RtDynamicMode;
use concinnity_host::store::paths::StateTree;
use std::path::Path;
use tracing_subscriber::EnvFilter;
use crate::app::runloop;
use crate::app::runtime::Runtime;
use crate::app::startup_error::StartupError;
use crate::gfx::quality_preset::QualityPreset;
fn default_log_directive() -> &'static str {
if cfg!(debug_assertions) {
"info"
} else {
"warn"
}
}
fn log_filter() -> EnvFilter {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_log_directive()))
}
pub fn init_logging() {
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let fmt = tracing_subscriber::fmt::layer().with_filter(log_filter());
let _ = tracing_subscriber::registry()
.with(fmt)
.with(crate::crash::RingLayer)
.try_init();
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PipelineMode {
#[default]
Pipelined,
Serial,
}
#[derive(Debug, Default)]
pub struct RunOptions {
pub mode: PipelineMode,
pub schedule: ScheduleMode,
pub screenshot: Option<String>,
pub max_frames: Option<u64>,
pub launch: LaunchRequest,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LaunchRequest {
pub capture: bool,
pub dev_loop: bool,
pub validation: Option<bool>,
pub quality_preset: Option<QualityPreset>,
pub rt_dynamic: Option<RtDynamicMode>,
pub rt_skinned_geometry: Option<bool>,
}
impl LaunchRequest {
pub fn resolve_validation(&self) -> bool {
self.validation.unwrap_or(cfg!(debug_assertions))
}
pub fn resolve_quality_preset(
&self,
persisted: Option<QualityPreset>,
) -> Option<QualityPreset> {
self.quality_preset.or(persisted)
}
pub fn resolve_rt_dynamic(&self) -> RtDynamicMode {
self.rt_dynamic.unwrap_or_default()
}
pub fn resolve_rt_skinned_geometry(&self) -> bool {
self.rt_skinned_geometry.unwrap_or(true)
}
pub fn frame_capture(&self) -> bool {
self.dev_loop || self.capture
}
}
pub fn run(tree: &StateTree, options: RunOptions) -> std::io::Result<()> {
init_logging();
let mut runtime = Runtime::new()
.in_tree(tree.clone())
.with_launch(options.launch);
let data_dir = tree.data_dir();
if let Err(error) = load_world(&mut runtime, BlobSource::Directory(&data_dir)) {
return Err(report_startup_error(error));
}
start_runtime(runtime, options).map_err(start_failure)
}
fn start_failure(e: WorldError) -> std::io::Error {
std::io::Error::other(format!("failed to start app: {e}"))
}
fn report_startup_error(error: StartupError) -> std::io::Error {
tracing::error!("{error}");
if !crate::error_screen::show("Concinnity", &error.user_message()) {
eprintln!("{error}");
}
std::io::Error::new(error.io_kind(), error.to_string())
}
fn load_world(runtime: &mut Runtime, source: BlobSource<'_>) -> Result<(), StartupError> {
let max_blob_index = runtime.load_blob_from(&source.primary())?;
source.check_span(max_blob_index).map_or(Ok(()), Err)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlobSource<'a> {
Directory(&'a Path),
File(&'a Path),
}
impl BlobSource<'_> {
fn primary(&self) -> std::path::PathBuf {
match self {
BlobSource::Directory(dir) => dir.join("0"),
BlobSource::File(file) => file.to_path_buf(),
}
}
fn check_span(&self, max_blob_index: u32) -> Option<StartupError> {
match self {
BlobSource::File(file) if max_blob_index > 0 => {
Some(StartupError::OverflowUnsupported {
blob: file.to_path_buf(),
needed: max_blob_index,
})
}
_ => None,
}
}
}
pub fn run_from(tree: &StateTree, blob: BlobSource<'_>) -> std::io::Result<()> {
init_logging();
let mut runtime = Runtime::new().in_tree(tree.clone());
if let Err(error) = load_world(&mut runtime, blob) {
return Err(report_startup_error(error));
}
start_runtime(runtime, RunOptions::default()).map_err(start_failure)
}
pub(crate) fn start_runtime(mut runtime: Runtime, options: RunOptions) -> Result<(), WorldError> {
init_logging();
tracing::info!("Running app...");
runloop::install_ctrlc_handler(&runtime);
let renders = runtime.render_mode().renders();
if let Some(max) = options.max_frames {
for config in runtime.world_mut().query_mut::<GraphicsConfig>() {
config.max_frames = Some(max);
}
}
if options.screenshot.is_some() {
runtime.launch_mut().capture = true;
}
runtime.world_mut().insert_resource(options.schedule);
#[cfg(target_os = "macos")]
if renders {
runloop::activate_app_macos();
}
if let Err(e) = runtime.start() {
if let WorldError::RenderUnavailable(cause) = &e {
report_render_failure(cause);
}
tracing::error!("failed to start app: {e}");
return Err(e);
}
match options.mode {
PipelineMode::Pipelined if renders => {
crate::app::pipeline::run_pipelined(runtime, options.screenshot.as_deref());
}
_ => {
runloop::run_loop(&mut runtime, cfg!(target_os = "macos") && renders, |_| {});
capture_exit_screenshot(&mut runtime, options.screenshot.as_deref());
}
}
Ok(())
}
fn report_render_failure(cause: &concinnity_core::render::error::RenderError) {
let message = format!(
"This app could not start its renderer.\n\n{cause}\n\n\
Updating your graphics driver is the usual fix."
);
if !crate::error_screen::show("Concinnity", &message) {
eprintln!("{message}");
}
}
fn capture_exit_screenshot(runtime: &mut Runtime, path: Option<&str>) {
let Some(path) = path else { return };
let Some(mut backend) = crate::ecs::take_render_backend(runtime.world_mut()) else {
tracing::warn!("screenshot skipped: no live backend at exit");
return;
};
match backend.screenshot(path) {
Ok(saved) => tracing::info!("screenshot saved: {}", saved),
Err(e) => tracing::warn!("screenshot failed: {}", e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_directive_matches_build_profile() {
let expected = if cfg!(debug_assertions) {
"info"
} else {
"warn"
};
assert_eq!(default_log_directive(), expected);
}
#[test]
fn default_directive_is_a_valid_filter() {
EnvFilter::new(default_log_directive());
}
#[test]
fn the_validation_request_outranks_the_build_profile() {
let request = |validation| LaunchRequest {
validation,
..Default::default()
};
assert_eq!(request(None).resolve_validation(), cfg!(debug_assertions));
assert!(!request(Some(false)).resolve_validation());
assert!(request(Some(true)).resolve_validation());
}
#[test]
fn the_quality_preset_request_outranks_the_persisted_choice() {
let unset = LaunchRequest::default();
assert_eq!(unset.resolve_quality_preset(None), None);
assert_eq!(
unset.resolve_quality_preset(Some(QualityPreset::Auto)),
Some(QualityPreset::Auto)
);
let forced = LaunchRequest {
quality_preset: Some(QualityPreset::Ultra),
..Default::default()
};
assert_eq!(
forced.resolve_quality_preset(Some(QualityPreset::Auto)),
Some(QualityPreset::Ultra)
);
assert_eq!(
forced.resolve_quality_preset(None),
Some(QualityPreset::Ultra)
);
}
#[test]
fn an_unset_rt_dynamic_request_is_auto() {
assert_eq!(
LaunchRequest::default().resolve_rt_dynamic(),
RtDynamicMode::Auto
);
for mode in [
RtDynamicMode::Off,
RtDynamicMode::Auto,
RtDynamicMode::Rebuild,
RtDynamicMode::Tlas,
] {
let request = LaunchRequest {
rt_dynamic: Some(mode),
..Default::default()
};
assert_eq!(request.resolve_rt_dynamic(), mode);
}
}
#[test]
fn skinned_rt_geometry_is_in_unless_the_request_clears_it() {
let request = |rt_skinned_geometry| LaunchRequest {
rt_skinned_geometry,
..Default::default()
};
assert!(request(None).resolve_rt_skinned_geometry());
assert!(request(Some(true)).resolve_rt_skinned_geometry());
assert!(!request(Some(false)).resolve_rt_skinned_geometry());
}
#[test]
fn frame_capture_is_armed_by_a_dev_loop_or_a_capture_request() {
assert!(!LaunchRequest::default().frame_capture());
let dev_loop = LaunchRequest {
dev_loop: true,
..Default::default()
};
assert!(dev_loop.frame_capture());
let capture = LaunchRequest {
capture: true,
..Default::default()
};
assert!(capture.frame_capture());
}
#[test]
fn each_blob_source_names_the_same_primary_file() {
let dir = Path::new("/apps/MyGame/data");
assert_eq!(
BlobSource::Directory(dir).primary(),
dir.join("0"),
"a directory holds blob 0"
);
let file = Path::new("/apps/MyGame/data");
assert_eq!(
BlobSource::File(file).primary(),
file.to_path_buf(),
"a single file is blob 0"
);
}
#[test]
fn a_single_file_source_refuses_a_world_that_overflows() {
let file = Path::new("/apps/MyGame/data");
assert!(BlobSource::File(file).check_span(0).is_none());
assert!(matches!(
BlobSource::File(file).check_span(2),
Some(StartupError::OverflowUnsupported { blob, needed: 2 }) if blob == file
));
let dir = Path::new("/apps/MyGame/data");
assert!(BlobSource::Directory(dir).check_span(0).is_none());
assert!(BlobSource::Directory(dir).check_span(7).is_none());
}
#[test]
fn loading_a_world_that_was_never_built_reports_missing_data() {
let tmp = concinnity_testing::TempTree::new();
let tree = StateTree::at(tmp.path());
let data_dir = tree.data_dir();
let mut runtime = Runtime::new().in_tree(tree.clone());
let error = load_world(&mut runtime, BlobSource::Directory(&data_dir))
.expect_err("an empty tree has no world");
assert!(
matches!(&error, StartupError::MissingData { blob } if *blob == data_dir.join("0")),
"{error:?}"
);
assert_eq!(error.io_kind(), std::io::ErrorKind::NotFound);
let file = tmp.join("missing.blob");
let mut runtime = Runtime::new().in_tree(tree);
let error = load_world(&mut runtime, BlobSource::File(&file))
.expect_err("a named blob that is not there has no world");
assert!(
matches!(&error, StartupError::MissingData { blob } if *blob == file),
"{error:?}"
);
}
#[test]
fn a_refused_start_returns_instead_of_exiting_the_process() {
let mut runtime = Runtime::new();
runtime.start().expect("the first start succeeds");
assert!(
matches!(
runtime.run_with(RunOptions::default()),
Err(WorldError::AlreadyStarted)
),
"a second start is refused"
);
}
}