kitgit

tirbofish/dropbear · diff

3182754 · Thribhu K

new package command, redback-runtime scripting works now, other features as well jarvis, run github actions

Unverified

diff --git a/.cargo/.config.toml b/.cargo/.config.toml
index 3a589a1..8d15538 100644
--- a/.cargo/.config.toml
+++ b/.cargo/.config.toml
@@ -1,2 +1,2 @@
 [target.x86_64-pc-windows-msvc]
-rustflags = ["-C", "target-feature=+crt-static", "-C", "prefer-dynamic=no"]
\ No newline at end of file
+rustflags = ["-C", "target-feature=+crt-static", "-C", "prefer-dynamic"]
\ No newline at end of file
diff --git a/Cargo.toml b/Cargo.toml
index 0c0e35e..a6f5375 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -25,7 +25,7 @@ arboard = "3.6"
 bincode = { version = "2.0", features = ["serde"] }
 bytemuck = { version = "1.23", features = ["derive"] }
 chrono = "0.4"
-clap = "4.5"
+clap = { version = "4.5", features = ["derive"] }
 colored = "3.0"
 crossbeam-channel = "0.5"
 dropbear-engine = { path = "dropbear-engine" }
diff --git a/dropbear-engine/src/camera.rs b/dropbear-engine/src/camera.rs
index 9463625..db9907c 100644
--- a/dropbear-engine/src/camera.rs
+++ b/dropbear-engine/src/camera.rs
@@ -40,7 +40,7 @@ impl CameraSettings {
 
 impl Default for CameraSettings {
     fn default() -> Self {
-        Self::new(1.0, 0.1, 45.0)
+        Self::new(1.0, 0.002, 45.0)
     }
 }
 
diff --git a/eucalyptus-core/src/scene.rs b/eucalyptus-core/src/scene.rs
index 68e2a72..72d1379 100644
--- a/eucalyptus-core/src/scene.rs
+++ b/eucalyptus-core/src/scene.rs
@@ -105,7 +105,7 @@ impl SceneConfig {
 
     /// Helper function to load a component and add it to the entity builder
     async fn load_component(
-        component: Box<dyn dropbear_traits::SerializableComponent>,
+        component: Box<dyn SerializableComponent>,
         builder: &mut hecs::EntityBuilder,
         graphics: Arc<SharedGraphicsContext>,
         registry: Option<&ComponentRegistry>,
diff --git a/eucalyptus-core/src/scripting.rs b/eucalyptus-core/src/scripting.rs
index c8ae9d8..d85cc32 100644
--- a/eucalyptus-core/src/scripting.rs
+++ b/eucalyptus-core/src/scripting.rs
@@ -15,6 +15,7 @@ use std::collections::HashMap;
 use std::path::{Path, PathBuf};
 use tokio::io::{AsyncBufReadExt, BufReader};
 use tokio::process::Command;
+use magna_carta::Target;
 
 /// The target of the script. This can be either a JVM or a native library.
 #[derive(Default, Clone)]
@@ -323,6 +324,15 @@ pub async fn build_jvm(
         return Err(anyhow::anyhow!(err));
     }
 
+    let _ = status_sender.send(BuildStatus::Started);
+
+    let _ = status_sender.send(BuildStatus::Building(format!("Building manifest at {}", project_root.join("build/magna-carta/jvmMain/RunnableRegistry.kt").display())));
+    if let Err(e) = magna_carta::parse(project_root.join("src"), Target::Jvm, project_root.join("build/magna-carta/jvmMain")) {
+        let _ = status_sender.send(BuildStatus::Failed(format!("Failed to build manifest: {}", e)));
+        return Err(e);
+    }
+    let _ = status_sender.send(BuildStatus::Building(String::from("Successfully built manifest!")));
+
     if !(project_root.join("build.gradle").exists()
         || project_root.join("build.gradle.kts").exists())
     {
@@ -331,8 +341,6 @@ pub async fn build_jvm(
         return Err(anyhow::anyhow!(err));
     }
 
-    let _ = status_sender.send(BuildStatus::Started);
-
     let gradle_cmd = get_gradle_command(project_root);
 
     let _ = status_sender.send(BuildStatus::Building(format!("Running: {}", gradle_cmd)));
@@ -442,6 +450,8 @@ pub async fn build_native(
         return Err(anyhow::anyhow!(err));
     }
 
+    let _ = status_sender.send(BuildStatus::Started);
+
     let _ = status_sender.send(BuildStatus::Building("Copying core library...".to_string()));
     let libs_dir = project_root.join("libs");
     if !libs_dir.exists() {
@@ -479,7 +489,12 @@ pub async fn build_native(
         }
     }
 
-    let _ = status_sender.send(BuildStatus::Started);
+    let _ = status_sender.send(BuildStatus::Building(format!("Building manifest at {}", project_root.join("build/magna-carta/jvmMain/RunnableRegistry.kt").display())));
+    if let Err(e) = magna_carta::parse(project_root.join("src"), Target::Jvm, project_root.join("build/magna-carta/jvmMain")) {
+        let _ = status_sender.send(BuildStatus::Failed(format!("Failed to build manifest: {}", e)));
+        return Err(e);
+    }
+    let _ = status_sender.send(BuildStatus::Building(String::from("Successfully built manifest!")));
 
     let gradle_cmd = get_gradle_command(project_root);
     let _ = status_sender.send(BuildStatus::Building(format!(
diff --git a/eucalyptus-core/src/scripting/jni/exports.rs b/eucalyptus-core/src/scripting/jni/exports.rs
index ada2aec..7ba33f8 100644
--- a/eucalyptus-core/src/scripting/jni/exports.rs
+++ b/eucalyptus-core/src/scripting/jni/exports.rs
@@ -164,16 +164,17 @@ pub fn Java_com_dropbear_ffi_JNINative_getTransform(
 
         return match env.new_object(
             &entity_transform_class,
-            "(Lcom/dropbear/math/Transform;Lcom/dropbear/math/Transform)V",
+            "(Lcom/dropbear/math/Transform;Lcom/dropbear/math/Transform;)V",
             &[
                 JValue::Object(&local_transform_java),
                 JValue::Object(&world_transform_java),
             ],
         ) {
             Ok(java_transform) => java_transform,
-            Err(_) => {
+            Err(e) => {
                 println!(
-                    "[Java_com_dropbear_ffi_JNINative_getTransform] [ERROR] Failed to create Transform object"
+                    "[Java_com_dropbear_ffi_JNINative_getTransform] [ERROR] Failed to create Transform object: {}",
+                    e
                 );
                 JObject::null()
             }
diff --git a/eucalyptus-core/src/scripting/native.rs b/eucalyptus-core/src/scripting/native.rs
index a1c3ca8..428c97e 100644
--- a/eucalyptus-core/src/scripting/native.rs
+++ b/eucalyptus-core/src/scripting/native.rs
@@ -10,6 +10,7 @@ use crate::scripting::error::LastErrorMessage;
 use crate::scripting::native::sig::{
     DestroyAll, DestroyTagged, Init, LoadTagged, UpdateAll, UpdateTagged,
 };
+use anyhow::anyhow;
 use libloading::{Library, Symbol};
 use std::ffi::CString;
 use std::path::Path;
@@ -36,8 +37,15 @@ impl NativeLibrary {
     /// Creates a new instance of [`NativeLibrary`]
     pub fn new(lib_path: impl AsRef<Path>) -> anyhow::Result<Self> {
         let lib_path = lib_path.as_ref();
+        if !lib_path.exists() {
+            anyhow::bail!(
+                "Native script library missing at '{}'. Expected this file to be copied next to the runtime executable or inside its 'libs' directory.",
+                lib_path.display()
+            );
+        }
         unsafe {
-            let library: Library = Library::new(lib_path)?;
+            let library: Library = Library::new(lib_path)
+                .map_err(|err| enhance_library_error(lib_path, err))?;
 
             let init_fn = load_symbol(&library, &[b"dropbear_init\0"], "dropbear_init")?;
             let load_systems_fn = load_symbol(
@@ -152,6 +160,26 @@ impl NativeLibrary {
     }
 }
 
+fn enhance_library_error(path: &Path, err: libloading::Error) -> anyhow::Error {
+    #[cfg(windows)]
+    {
+        let err_str = err.to_string();
+        if err_str.contains("os error 126") {
+            return anyhow!(
+                "Failed to load native script library '{}': {}. Windows error 126 means a dependent DLL is missing—copy every *.dll (and matching *.dll.lib) produced by your Gradle native build next to the runtime or into its 'libs' folder.",
+                path.display(),
+                err
+            );
+        }
+    }
+
+    anyhow!(
+        "Failed to load native script library '{}': {}",
+        path.display(),
+        err
+    )
+}
+
 fn load_symbol<T>(
     library: &Library,
     candidates: &[&[u8]],
diff --git a/eucalyptus-core/src/utils.rs b/eucalyptus-core/src/utils.rs
index 77b6eda..df95046 100644
--- a/eucalyptus-core/src/utils.rs
+++ b/eucalyptus-core/src/utils.rs
@@ -1,6 +1,6 @@
 use crate::states::Node;
 use dropbear_engine::utils::{ResourceReference, ResourceReferenceType, relative_path_from_euca};
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
 use winit::keyboard::KeyCode;
 
 pub const PROTO_TEXTURE: &[u8] = include_bytes!("../../resources/textures/proto.png");
@@ -261,25 +261,21 @@ impl ResolveReference for ResourceReference {
 
                 #[cfg(feature = "editor")]
                 {
-                    let project_config = {
+                    let project_path = {
                         use crate::states::PROJECT;
 
                         let cfg = PROJECT.read();
                         cfg.project_path.clone()
                     };
 
-                    let path = project_config.join("resources").join(relative);
-                    return Ok(path);
+                    if !project_path.as_os_str().is_empty() {
+                        let root = project_path.join("resources");
+                        return resolve_resource_from_root(relative, &root);
+                    }
                 }
 
-                #[cfg(not(feature = "editor"))]
-                {
-                    let current_exe = std::env::current_exe()?;
-                    let dir = current_exe
-                        .parent()
-                        .ok_or_else(|| anyhow::anyhow!("Unable to get path"))?;
-                    return Ok(dir.join("resources").join(relative));
-                }
+                let root = runtime_resources_dir()?;
+                return resolve_resource_from_root(relative, &root);
             }
             _ => {
                 anyhow::bail!("Cannot resolve any other ResourceReferenceType that is not File")
@@ -288,6 +284,44 @@ impl ResolveReference for ResourceReference {
     }
 }
 
+fn runtime_resources_dir() -> anyhow::Result<PathBuf> {
+    let current_exe = std::env::current_exe()?;
+    let dir = current_exe
+        .parent()
+        .ok_or_else(|| anyhow::anyhow!("Unable to locate parent folder of runtime executable"))?;
+    let resources_dir = dir.join("resources");
+    if !resources_dir.exists() {
+        anyhow::bail!(
+            "Runtime resources directory is missing at '{}'. Ensure the packaged build includes a 'resources' folder next to the executable (current exe: {}).",
+            resources_dir.display(),
+            current_exe.display()
+        );
+    }
+    Ok(resources_dir)
+}
+
+fn resolve_resource_from_root(relative: &str, root: &Path) -> anyhow::Result<PathBuf> {
+    let resolved = root.join(relative);
+    if resolved.exists() {
+        return Ok(resolved);
+    }
+
+    if !root.exists() {
+        anyhow::bail!(
+            "Resource '{}' could not be resolved because the base directory '{}' does not exist",
+            relative,
+            root.display()
+        );
+    }
+
+    anyhow::bail!(
+        "Resource '{}' was resolved to '{}' but the file does not exist. Ensure the asset is packaged under '{}'",
+        relative,
+        resolved.display(),
+        root.display()
+    );
+}
+
 /// Validates and converts a raw pointer to a reference.
 /// Returns early if the pointer is null.
 ///
diff --git a/eucalyptus-editor/src/build.rs b/eucalyptus-editor/src/build.rs
index 73e3554..0fec837 100644
--- a/eucalyptus-editor/src/build.rs
+++ b/eucalyptus-editor/src/build.rs
@@ -1,9 +1,15 @@
+use anyhow::{bail, Context};
+use app_dirs2::{app_root, AppDataType};
+use crossbeam_channel::Sender;
 use eucalyptus_core::config::ProjectConfig;
 use eucalyptus_core::runtime::RuntimeProjectConfig;
 use eucalyptus_core::scene::SceneConfig;
+use eucalyptus_core::APP_INFO;
 use semver::Version;
 use std::fs;
 use std::path::{Path, PathBuf};
+use std::time::UNIX_EPOCH;
+use tokio::{fs as tokio_fs, process::Command, task};
 
 /// Builds a eucalyptus project into a single bundle.
 ///
@@ -110,3 +116,494 @@ pub fn read(eupak: PathBuf) -> anyhow::Result<RuntimeProjectConfig> {
     println!("{} contents: {:#?}", eupak.display(), content);
     Ok(content)
 }
+
+#[derive(Debug, Clone)]
+pub enum PackageStatus {
+    Info(String),
+    Progress { step: &'static str, detail: String },
+    Error(String),
+}
+
+fn emit_status(tx: &Option<Sender<PackageStatus>>, status: PackageStatus) {
+    if let Some(sender) = tx {
+        let _ = sender.send(status);
+    }
+}
+
+pub async fn package(
+    project_config: PathBuf,
+    status_tx: Option<Sender<PackageStatus>>,
+) -> anyhow::Result<PathBuf> {
+    if !project_config.exists() {
+        bail!("Project config not found: {}", project_config.display());
+    }
+
+    emit_status(
+        &status_tx,
+        PackageStatus::Info(format!("Packaging project at {}", project_config.display())),
+    );
+
+    let project_root = project_config
+        .parent()
+        .ok_or_else(|| anyhow::anyhow!("Unable to locate parent folder of config"))?
+        .to_path_buf();
+
+    let project_contents = tokio_fs::read_to_string(&project_config).await?;
+    let project_data: ProjectConfig = ron::de::from_str(&project_contents)?;
+    let project_name = sanitize_filename(&project_data.project_name);
+
+    let templates_dir = app_root(AppDataType::UserData, &APP_INFO)?.join("templates");
+    tokio_fs::create_dir_all(&templates_dir).await?;
+
+    emit_status(
+        &status_tx,
+        PackageStatus::Progress {
+            step: "Locating runtime",
+            detail: format!("Searching for runtime executable in {}", templates_dir.display()),
+        },
+    );
+
+    let runtime_source = {
+        let dir = templates_dir.clone();
+        task::spawn_blocking(move || locate_runtime_binary(&dir)).await??
+    };
+
+    let package_dir = project_root.join("build/package");
+    if package_dir.exists() {
+        tokio_fs::remove_dir_all(&package_dir).await?;
+    }
+    tokio_fs::create_dir_all(&package_dir).await?;
+
+    let runtime_filename = runtime_source
+        .file_name()
+        .ok_or_else(|| anyhow::anyhow!("Runtime template missing filename"))?;
+    let runtime_dest = package_dir.join(runtime_filename);
+    tokio_fs::copy(&runtime_source, &runtime_dest).await?;
+    emit_status(
+        &status_tx,
+        PackageStatus::Info(format!(
+            "Copied runtime template to {}",
+            runtime_dest.display()
+        )),
+    );
+
+    emit_status(
+        &status_tx,
+        PackageStatus::Progress {
+            step: "Build project",
+            detail: "Building runtime configuration".to_string(),
+        },
+    );
+
+    let build_dir = {
+        let config_path = project_config.clone();
+        task::spawn_blocking(move || build(config_path)).await??
+    };
+
+    let data_src = build_dir.join("data.eupak");
+    if !data_src.exists() {
+        bail!("Expected {} to exist", data_src.display());
+    }
+    tokio_fs::copy(&data_src, package_dir.join("data.eupak")).await?;
+
+    let resources_src = build_dir.join("resources");
+    let resources_dst = package_dir.join("resources");
+    if resources_src.exists() && !resources_dst.exists() {
+        let src = resources_src.clone();
+        let dst = resources_dst.clone();
+        task::spawn_blocking(move || copy_dir_recursive(&src, &dst)).await??;
+    }
+
+    emit_status(
+        &status_tx,
+        PackageStatus::Progress {
+            step: "magna-carta",
+            detail: "Generating script bindings via magna-carta".to_string(),
+        },
+    );
+
+    let magna_output_dir = project_root.join("build/magna-carta/nativeLibMain");
+    tokio_fs::create_dir_all(&magna_output_dir).await?;
+
+    let magna_status = Command::new("magna-carta")
+        .arg("--input")
+        .arg(project_root.join("src"))
+        .arg("--target")
+        .arg("native")
+        .arg("--output")
+        .arg(&magna_output_dir)
+        .current_dir(&project_root)
+        .status()
+        .await
+        .context("Failed to execute magna-carta")?;
+
+    if !magna_status.success() {
+        bail!("magna-carta failed with status {magna_status:?}");
+    }
+
+    emit_status(
+        &status_tx,
+        PackageStatus::Progress {
+            step: "Gradle",
+            detail: "Running Gradle build".to_string(),
+        },
+    );
+    run_gradle_build(&project_root).await?;
+    log::info!("Gradle build completed successfully");
+
+    emit_status(
+        &status_tx,
+        PackageStatus::Progress {
+            step: "NativeLibrary",
+            detail: "Copying native library artifact".to_string(),
+        },
+    );
+    log::info!("Copying native library artifact");
+
+    let release_dir = project_root.join("build/bin/nativeLib/releaseShared");
+    let extension = native_library_extension();
+    let library_source = {
+        let dir = release_dir.clone();
+        let ext = extension.to_string();
+        task::spawn_blocking(move || pick_latest_library(&dir, &ext)).await??
+    };
+
+    let library_dest = package_dir.join(format!("{}.{}", project_name, extension));
+    tokio_fs::copy(&library_source, &library_dest).await?;
+
+    emit_status(
+        &status_tx,
+        PackageStatus::Progress {
+            step: "RuntimeLibs",
+            detail: "Copying runtime dependencies".to_string(),
+        },
+    );
+
+    let libs_dir = project_root.join("libs");
+    tokio_fs::create_dir_all(&libs_dir).await?;
+
+    let discovered_libs = task::spawn_blocking(discover_runtime_libraries).await??;
+
+    #[cfg(windows)]
+    let (shared_objects, import_libs) = {
+        let DiscoveredLibraries {
+            shared_objects,
+            import_libs,
+        } = discovered_libs;
+        (shared_objects, import_libs)
+    };
+
+    #[cfg(not(windows))]
+    let shared_objects = {
+        let DiscoveredLibraries { shared_objects, .. } = discovered_libs;
+        shared_objects
+    };
+
+    for lib in shared_objects {
+        let file_name = lib
+            .file_name()
+            .ok_or_else(|| anyhow::anyhow!("Runtime dependency missing filename: {}", lib.display()))?
+            .to_owned();
+        let libs_target = libs_dir.join(&file_name);
+        if !format!("{}", file_name.display()).contains("eucalyptus_core") { continue; }
+        tokio_fs::copy(&lib, &libs_target).await?;
+        let package_target = package_dir.join(&file_name);
+        tokio_fs::copy(&lib, &package_target).await?;
+        log::info!(
+            "Copied runtime dependency {} to {} and {}",
+            lib.display(),
+            libs_target.display(),
+            package_target.display()
+        );
+    }
+
+    #[cfg(windows)]
+    {
+        for import_lib in import_libs {
+            let file_name = import_lib
+                .file_name()
+                .ok_or_else(|| anyhow::anyhow!("Import library missing filename: {}", import_lib.display()))?
+                .to_owned();
+            let libs_target = libs_dir.join(&file_name);
+            tokio_fs::copy(&import_lib, &libs_target).await?;
+            log::info!(
+                "Copied Windows import library {} to {}",
+                import_lib.display(),
+                libs_target.display()
+            );
+        }
+    }
+
+    emit_status(
+        &status_tx,
+        PackageStatus::Info(format!(
+            "Packaged build available at {}",
+            package_dir.display()
+        )),
+    );
+
+    log::info!("Packaged build available at {}", package_dir.display());
+
+    Ok(package_dir)
+}
+
+fn locate_runtime_binary(templates_dir: &Path) -> anyhow::Result<PathBuf> {
+    for name in runtime_name_candidates() {
+        let candidate = templates_dir.join(name);
+        if is_runtime_binary(&candidate) {
+            return Ok(candidate);
+        }
+    }
+
+    for entry in fs::read_dir(templates_dir)? {
+        let entry = entry?;
+        let path = entry.path();
+        if !is_runtime_binary(&path) {
+            continue;
+        }
+        if entry
+            .file_name()
+            .to_string_lossy()
+            .to_ascii_lowercase()
+            .contains("redback-runtime")
+        {
+            return Ok(path);
+        }
+    }
+
+    let current_exe = std::env::current_exe()?;
+    let current_dir = current_exe.parent().ok_or(anyhow::anyhow!("Unable to locate parent folder of current executable"))?;
+    for entry in fs::read_dir(current_dir)? {
+        let entry = entry?;
+        let path = entry.path();
+        if !is_runtime_binary(&path) {
+            continue;
+        }
+        if entry
+            .file_name()
+            .to_string_lossy()
+            .to_ascii_lowercase()
+            .contains("redback-runtime")
+        {
+            return Ok(path);
+        }
+    }
+
+    bail!(
+        "Unable to locate redback runtime executable in {}",
+        templates_dir.display()
+    )
+}
+
+fn runtime_name_candidates() -> &'static [&'static str] {
+    #[cfg(windows)]
+    {
+        &["redback-runtime.exe", "redback-runtime"]
+    }
+    #[cfg(target_os = "macos")]
+    {
+        &["redback-runtime.app", "redback-runtime"]
+    }
+    #[cfg(all(not(windows), not(target_os = "macos")))]
+    {
+        &["redback-runtime"]
+    }
+}
+
+fn is_runtime_binary(path: &Path) -> bool {
+    let Ok(metadata) = path.metadata() else {
+        return false;
+    };
+
+    #[cfg(windows)]
+    {
+        if !metadata.is_file() {
+            return false;
+        }
+        return path
+            .extension()
+            .and_then(|ext| ext.to_str())
+            .map(|ext| ext.eq_ignore_ascii_case("exe"))
+            .unwrap_or(false);
+    }
+
+    #[cfg(target_os = "macos")]
+    {
+        if metadata.is_dir() {
+            return path
+                .extension()
+                .and_then(|ext| ext.to_str())
+                .map(|ext| ext.eq_ignore_ascii_case("app"))
+                .unwrap_or(false);
+        }
+        metadata.is_file()
+    }
+
+    #[cfg(all(not(windows), not(target_os = "macos")))]
+    {
+        metadata.is_file()
+    }
+}
+
+fn sanitize_filename(name: &str) -> String {
+    let mut result = String::new();
+    for ch in name.chars() {
+        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
+            result.push(ch);
+        } else if ch.is_whitespace() {
+            result.push('_');
+        }
+    }
+    if result.is_empty() {
+        "project".to_string()
+    } else {
+        result
+    }
+}
+
+fn native_library_extension() -> &'static str {
+    if cfg!(windows) {
+        "dll"
+    } else if cfg!(target_os = "macos") {
+        "dylib"
+    } else {
+        "so"
+    }
+}
+
+fn pick_latest_library(dir: &Path, extension: &str) -> anyhow::Result<PathBuf> {
+    if !dir.exists() {
+        bail!("Native library directory does not exist: {}", dir.display());
+    }
+
+    let mut matches = Vec::new();
+    for entry in fs::read_dir(dir)? {
+        let entry = entry?;
+        if !entry.file_type()?.is_file() {
+            continue;
+        }
+        let path = entry.path();
+        let ext_matches = path
+            .extension()
+            .and_then(|ext| ext.to_str())
+            .map(|ext| ext.eq_ignore_ascii_case(extension))
+            .unwrap_or(false);
+        if !ext_matches {
+            continue;
+        }
+        let modified = entry
+            .metadata()
+            .ok()
+            .and_then(|meta| meta.modified().ok())
+            .unwrap_or(UNIX_EPOCH);
+        matches.push((modified, path));
+    }
+
+    if matches.is_empty() {
+        bail!(
+            "Unable to locate any *.{extension} libraries in {}",
+            dir.display()
+        );
+    }
+
+    matches.sort_by_key(|(modified, _)| *modified);
+    let (_, path) = matches
+        .pop()
+        .expect("matches is not empty so pop must succeed");
+    Ok(path)
+}
+
+struct DiscoveredLibraries {
+    shared_objects: Vec<PathBuf>,
+    #[cfg_attr(not(windows), allow(dead_code))]
+    import_libs: Vec<PathBuf>,
+}
+
+fn discover_runtime_libraries() -> anyhow::Result<DiscoveredLibraries> {
+    let current_exe = std::env::current_exe()?;
+    let current_dir = current_exe
+        .parent()
+        .ok_or_else(|| anyhow::anyhow!("Unable to locate parent folder of current executable"))?
+        .to_path_buf();
+
+    let mut libs = DiscoveredLibraries {
+        shared_objects: Vec::new(),
+        import_libs: Vec::new(),
+    };
+
+    for entry in fs::read_dir(&current_dir)? {
+        let entry = entry?;
+        if !entry.file_type()?.is_file() {
+            continue;
+        }
+
+        let path = entry.path();
+        let extension = path
+            .extension()
+            .and_then(|ext| ext.to_str())
+            .map(|ext| ext.to_ascii_lowercase())
+            .unwrap_or_default();
+
+        if matches!(extension.as_str(), "dll" | "dylib" | "so") {
+            libs.shared_objects.push(path);
+            continue;
+        }
+
+        if extension.as_str() == "lib" {
+            if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) {
+                if file_name.to_ascii_lowercase().ends_with(".dll.lib") {
+                    libs.import_libs.push(path);
+                }
+            }
+        }
+    }
+
+    if libs.shared_objects.is_empty() {
+        bail!(
+            "Unable to locate any runtime libraries next to {}",
+            current_dir.display()
+        );
+    }
+
+    Ok(libs)
+}
+
+async fn run_gradle_build(project_root: &Path) -> anyhow::Result<()> {
+    #[cfg(windows)]
+    {
+        let script = project_root.join("gradlew.bat");
+        if !script.exists() {
+            bail!("Gradle wrapper not found at {}", script.display());
+        }
+        let status = Command::new("cmd")
+            .arg("/C")
+            .arg(script)
+            .arg("build")
+            .current_dir(project_root)
+            .status()
+            .await
+            .context("Failed to run gradlew.bat")?;
+        if !status.success() {
+            bail!("Gradle build failed (status {status:?})");
+        }
+    }
+
+    #[cfg(not(windows))]
+    {
+        let script = project_root.join("gradlew");
+        if !script.exists() {
+            bail!("Gradle wrapper not found at {}", script.display());
+        }
+        let status = Command::new(script)
+            .arg("build")
+            .current_dir(project_root)
+            .status()
+            .await
+            .context("Failed to run ./gradlew")?;
+        if !status.success() {
+            bail!("Gradle build failed (status {status:?})");
+        }
+    }
+
+    Ok(())
+}
diff --git a/eucalyptus-editor/src/editor/input.rs b/eucalyptus-editor/src/editor/input.rs
index bb88b64..ed61a6f 100644
--- a/eucalyptus-editor/src/editor/input.rs
+++ b/eucalyptus-editor/src/editor/input.rs
@@ -96,6 +96,13 @@ impl Keyboard for Editor {
                         self.selected_entity = None;
                         log::debug!("Deselected entity");
                     }
+                } else if matches!(self.editor_state, EditorState::Playing) && shift_pressed {
+                    self.viewport_mode = ViewportMode::None;
+                    info!("Switched to Viewport::None");
+                    if let Some(window) = &self.window {
+                        window.set_cursor_visible(true);
+                        let _ = window.set_cursor_grab(CursorGrabMode::None);
+                    }
                 } else if self.is_viewport_focused && !is_playing {
                     self.viewport_mode = ViewportMode::None;
                     info!("Switched to Viewport::None");
@@ -293,10 +300,7 @@ impl Mouse for Editor {
                 && let Some((camera, _)) = q.get()
             {
                 if let Some((dx, dy)) = delta {
-                    camera.track_mouse_delta(
-                        dx * camera.settings.sensitivity,
-                        dy * camera.settings.sensitivity,
-                    );
+                    camera.track_mouse_delta(dx, dy);
                     self.input_state.mouse_delta = Some((dx, dy));
                 } else {
                     log_once::warn_once!("Unable to track mouse delta, attempting fallback");
@@ -304,10 +308,7 @@ impl Mouse for Editor {
                     if let Some(old_mouse_pos) = self.input_state.last_mouse_pos {
                         let dx = position.x - old_mouse_pos.0;
                         let dy = position.y - old_mouse_pos.1;
-                        camera.track_mouse_delta(
-                            dx * camera.settings.sensitivity,
-                            dy * camera.settings.sensitivity,
-                        );
+                        camera.track_mouse_delta(dx, dy);
                         self.input_state.mouse_delta = Some((dx, dy));
                         log_once::debug_once!("Fallback mouse tracking used");
                     } else {
diff --git a/eucalyptus-editor/src/editor/mod.rs b/eucalyptus-editor/src/editor/mod.rs
index f481e12..cd40ace 100644
--- a/eucalyptus-editor/src/editor/mod.rs
+++ b/eucalyptus-editor/src/editor/mod.rs
@@ -1182,7 +1182,7 @@ impl Editor {
                     mesh_renderer.sync_asset_registry();
                 }
 
-                if let Ok(mut transform) = self.world.get::<&mut Transform>(*entity_id) {
+                if let Ok(mut transform) = self.world.get::<&mut EntityTransform>(*entity_id) {
                     *transform = *original_transform;
                 }
 
@@ -1232,7 +1232,7 @@ impl Editor {
 
         for (entity_id, (mesh_renderer, transform, properties)) in self
             .world
-            .query::<(&MeshRenderer, &Transform, &ModelProperties)>()
+            .query::<(&MeshRenderer, &EntityTransform, &ModelProperties)>()
             .iter()
         {
             let script = self
@@ -1254,23 +1254,22 @@ impl Editor {
         for (entity_id, (camera, component)) in
             self.world.query::<(&Camera, &CameraComponent)>().iter()
         {
-            camera_data.push((
-                entity_id,
-                camera.clone(),
-                component.clone(),
-                // follow_target.cloned(),
-            ));
+            camera_data.push((entity_id, camera.clone(), component.clone()));
         }
 
-        self.play_mode_backup = Some(PlayModeBackup {
+        let backup = PlayModeBackup {
             entities,
             camera_data,
-        });
+        };
+
+        let entity_count = backup.entities.len();
+        let camera_count = backup.camera_data.len();
+        self.play_mode_backup = Some(backup);
 
         log::info!(
             "Created play mode backup with {} entities and {} cameras",
-            self.play_mode_backup.as_ref().unwrap().entities.len(),
-            self.play_mode_backup.as_ref().unwrap().camera_data.len()
+            entity_count,
+            camera_count
         );
         Ok(())
     }
@@ -1582,7 +1581,7 @@ pub struct PlayModeBackup {
     entities: Vec<(
         Entity,
         MeshRenderer,
-        Transform,
+        EntityTransform,
         ModelProperties,
         Option<Script>,
     )>,
diff --git a/eucalyptus-editor/src/main.rs b/eucalyptus-editor/src/main.rs
index a86a069..0de495c 100644
--- a/eucalyptus-editor/src/main.rs
+++ b/eucalyptus-editor/src/main.rs
@@ -1,6 +1,7 @@
 // #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
 // note to self: when it becomes release, remember to re-add this back
 
+use anyhow::{bail, Context};
 use clap::{Arg, Command};
 use dropbear_engine::future::FutureQueue;
 use dropbear_engine::{MutableWindowConfiguration, WindowConfiguration, scene};
@@ -8,7 +9,7 @@ use eucalyptus_core::APP_INFO;
 use eucalyptus_editor::{build, editor, menu};
 use parking_lot::RwLock;
 use std::sync::Arc;
-use std::{fs, path::PathBuf, rc::Rc};
+use std::{fs, path::{Path, PathBuf}, rc::Rc};
 
 #[tokio::main]
 async fn main() -> anyhow::Result<()> {
@@ -115,6 +116,16 @@ async fn main() -> anyhow::Result<()> {
                 ),
         )
         .subcommand(
+            Command::new("package")
+                .about("Package a eucalyptus project into a runnable bundle")
+                .arg(
+                    Arg::new("project")
+                        .help("Path to the project directory or .eucp file")
+                        .value_name("PROJECT_PATH")
+                        .required(true),
+                ),
+        )
+        .subcommand(
             Command::new("read").about("Reads a .eupak file").arg(
                 Arg::new("eupak_file")
                     .help("Path to the .eupak data file")
@@ -126,44 +137,15 @@ async fn main() -> anyhow::Result<()> {
 
     match matches.subcommand() {
         Some(("build", sub_matches)) => {
-            let project_path = match sub_matches.get_one::<String>("project") {
-                Some(path) => PathBuf::from(path),
-                None => match find_eucp_file() {
-                    Ok(path) => path,
-                    Err(e) => {
-                        log::error!("Error: {}", e);
-                        std::process::exit(1);
-                    }
-                },
-            };
-
-            let path = if project_path.is_dir() {
-                log::warn!("Path provided is a directory, checking if eucalyptus project file may be there");
-                log::debug!("Locating");
-                let file = fs::read_dir(&project_path)?.filter_map(|entry| {
-                    if let Ok(entry) = entry {
-                        if entry.file_name().to_str().unwrap().ends_with(".eucp") {
-                            Some(entry.path())
-                        } else {
-                            None
-                        }
-                    } else {
-                        None
-                    }
-                })
-                    .collect::<Vec<_>>()
-                    .first()
-                    .cloned()
-                    .ok_or(anyhow::anyhow!("No .eucp file found in directory"))?;
-                file
-            } else {
-                project_path
-            };
-
+            let path = resolve_project_argument(sub_matches.get_one::<String>("project"))?;
             log::info!("Building project at {:?}", path);
-
             build::build(path)?;
         }
+        Some(("package", sub_matches)) => {
+            let path = resolve_project_argument(sub_matches.get_one::<String>("project"))?;
+            log::info!("Packaging project at {:?}", path);
+            build::package(path, None).await?;
+        }
         Some(("read", sub_matches)) => {
             let eupak = match sub_matches.get_one::<String>("eupak_file") {
                 Some(path) => PathBuf::from(path),
@@ -226,10 +208,58 @@ async fn main() -> anyhow::Result<()> {
     Ok(())
 }
 
-fn find_eucp_file() -> Result<PathBuf, String> {
-    let current_dir = std::env::current_dir().map_err(|_| "Failed to get current directory")?;
+fn resolve_project_argument(arg: Option<&String>) -> anyhow::Result<PathBuf> {
+    match arg {
+        Some(path) => {
+            let provided = PathBuf::from(path);
+            if provided.is_dir() {
+                find_eucp_in_dir(&provided)
+            } else if provided.exists() {
+                Ok(provided)
+            } else {
+                bail!("Provided project path does not exist: {}", provided.display());
+            }
+        }
+        None => find_eucp_file(),
+    }
+}
+
+fn find_eucp_in_dir(dir: &Path) -> anyhow::Result<PathBuf> {
+    if !dir.exists() {
+        bail!("Directory does not exist: {}", dir.display());
+    }
+
+    let mut matches = Vec::new();
+    for entry in fs::read_dir(dir).with_context(|| format!("Unable to read {}", dir.display()))? {
+        let entry = entry?;
+        if !entry.file_type()?.is_file() {
+            continue;
+        }
+        if entry
+            .path()
+            .extension()
+            .and_then(|ext| ext.to_str())
+            .map(|ext| ext.eq_ignore_ascii_case("eucp"))
+            .unwrap_or(false)
+        {
+            matches.push(entry.path());
+        }
+    }
+
+    match matches.len() {
+        0 => bail!("No .eucp file found in {}", dir.display()),
+        1 => Ok(matches.remove(0)),
+        _ => bail!(
+            "Multiple .eucp files found in {}. Please specify one explicitly.",
+            dir.display()
+        ),
+    }
+}
 
-    let entries = fs::read_dir(&current_dir).map_err(|_| "Failed to read current directory")?;
+fn find_eucp_file() -> anyhow::Result<PathBuf> {
+    let current_dir = std::env::current_dir().context("Failed to get current directory")?;
+    let entries =
+        fs::read_dir(&current_dir).context("Failed to read current directory for .eucp files")?;
 
     let mut eucp_files = Vec::new();
 
@@ -243,11 +273,11 @@ fn find_eucp_file() -> Result<PathBuf, String> {
     }
 
     match eucp_files.len() {
-        0 => Err("No .eucp files found in current directory".to_string()),
+        0 => bail!("No .eucp files found in current directory"),
         1 => Ok(eucp_files[0].clone()),
-        _ => Err(format!(
+        _ => bail!(
             "Multiple .eucp files found: {:#?}. Please specify which one to use.",
             eucp_files
-        )),
+        ),
     }
 }
diff --git a/magna-carta/Cargo.toml b/magna-carta/Cargo.toml
index a19a9b9..c669bb5 100644
--- a/magna-carta/Cargo.toml
+++ b/magna-carta/Cargo.toml
@@ -10,5 +10,6 @@ readme = "README.md"
 anyhow.workspace = true
 tree-sitter.workspace = true
 tree-sitter-kotlin.workspace = true
-clap = { version = "4.0", features = ["derive"] }
-chrono = "0.4"
\ No newline at end of file
+clap.workspace = true
+chrono.workspace = true
+log.workspace = true
\ No newline at end of file
diff --git a/magna-carta/src/lib.rs b/magna-carta/src/lib.rs
index b7002ce..37b55e5 100644
--- a/magna-carta/src/lib.rs
+++ b/magna-carta/src/lib.rs
@@ -1,7 +1,12 @@
 pub mod generator;
 
-use std::path::PathBuf;
+use std::fs;
+use std::path::{Path, PathBuf};
+use clap::ValueEnum;
 use tree_sitter::{Parser, Query, QueryCursor};
+use crate::generator::Generator;
+use crate::generator::jvm::KotlinJVMGenerator;
+use crate::generator::native::KotlinNativeGenerator;
 
 /// A group of manifests.
 #[derive(Debug, Clone)]
@@ -291,6 +296,94 @@ impl KotlinProcessor {
     }
 }
 
+/// The target
+#[derive(ValueEnum, Clone, Debug)]
+pub enum Target {
+    Jvm,
+    Native,
+}
+
+/// Walks through all the input kotlin files and generates a manifest file for the target platform
+/// at the directory provided by output.
+///
+/// Identically the same thing as the executable, except as a function in a Rust library instead.
+///
+/// # Target Behaviours
+/// - [Target::Jvm] - Stores the manifest in `{output}/RunnableRegistry.kt`
+/// - [Target::Native] - Stored the manifest in `{output}/ScriptManifest.kt`
+pub fn parse(input: impl AsRef<Path>, target: Target, output: impl AsRef<Path>) -> anyhow::Result<()> {
+    let input = input.as_ref().to_path_buf();
+    let output = output.as_ref().to_path_buf();
+
+    let mut processor = KotlinProcessor::new()?;
+    let mut manifest = ScriptManifest::new();
+
+    if !input.exists() {
+        return Err(anyhow::anyhow!(
+            "Input directory does not exist: {:?}",
+            input
+        ));
+    }
+
+    visit_kotlin_files(&input, &mut processor, &mut manifest)?;
+
+    let generated_content = match target {
+        Target::Jvm => {
+            let generator = KotlinJVMGenerator;
+            generator.generate(&manifest)?
+        }
+        Target::Native => {
+            let generator = KotlinNativeGenerator;
+            generator.generate(&manifest)?
+        }
+    };
+
+    fs::create_dir_all(&output)?;
+
+    let filename = match target {
+        Target::Jvm => "RunnableRegistry.kt",
+        Target::Native => "ScriptManifest.kt",
+    };
+    let output_path = output.join(filename);
+    fs::write(&output_path, generated_content)?;
+    log::info!(
+        "Generated {:?} manifest at: {}",
+        target,
+        output_path.display()
+    );
+
+    log::debug!("Found {} script classes", manifest.items().len());
+    Ok(())
+}
+
+/// Helper function that visits all kotlin files in a directory recursively and processes them with
+/// the [KotlinProcesser]
+pub fn visit_kotlin_files(
+    dir: &PathBuf,
+    processor: &mut KotlinProcessor,
+    manifest: &mut ScriptManifest,
+) -> anyhow::Result<()> {
+    if dir.is_dir() {
+        for entry in fs::read_dir(dir)? {
+            let entry = entry?;
+            let path = entry.path();
+
+            if path.is_dir() {
+                visit_kotlin_files(&path, processor, manifest)?;
+            } else if path.extension() == Some(std::ffi::OsStr::new("kt")) {
+                let source_code = fs::read_to_string(&path)?;
+
+                if let Some(item) = processor.process_file(&source_code, path.clone())? {
+                    manifest.add_item(item);
+                }
+            }
+        }
+    }
+
+    Ok(())
+}
+
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/magna-carta/src/main.rs b/magna-carta/src/main.rs
index 72b7301..71aff48 100644
--- a/magna-carta/src/main.rs
+++ b/magna-carta/src/main.rs
@@ -1,6 +1,6 @@
-use clap::{Parser, ValueEnum};
+use clap::{Parser};
 use magna_carta::generator::{Generator, jvm::KotlinJVMGenerator, native::KotlinNativeGenerator};
-use magna_carta::{KotlinProcessor, ScriptManifest};
+use magna_carta::{KotlinProcessor, ScriptManifest, Target};
 use std::fs;
 use std::path::PathBuf;
 
@@ -31,12 +31,6 @@ struct Cli {
     raw: bool,
 }
 
-#[derive(ValueEnum, Clone, Debug)]
-enum Target {
-    Jvm,
-    Native,
-}
-
 fn main() -> anyhow::Result<()> {
     let cli = Cli::parse();
 
@@ -62,7 +56,7 @@ fn main() -> anyhow::Result<()> {
         ));
     }
 
-    visit_kotlin_files(&cli.input, &mut processor, &mut manifest)?;
+    magna_carta::visit_kotlin_files(&cli.input, &mut processor, &mut manifest)?;
 
     let generated_content = match cli.target {
         Target::Jvm => {
@@ -100,28 +94,3 @@ fn main() -> anyhow::Result<()> {
     println!("Found {} script classes", manifest.items().len());
     Ok(())
 }
-
-fn visit_kotlin_files(
-    dir: &PathBuf,
-    processor: &mut KotlinProcessor,
-    manifest: &mut ScriptManifest,
-) -> anyhow::Result<()> {
-    if dir.is_dir() {
-        for entry in fs::read_dir(dir)? {
-            let entry = entry?;
-            let path = entry.path();
-
-            if path.is_dir() {
-                visit_kotlin_files(&path, processor, manifest)?;
-            } else if path.extension() == Some(std::ffi::OsStr::new("kt")) {
-                let source_code = fs::read_to_string(&path)?;
-
-                if let Some(item) = processor.process_file(&source_code, path.clone())? {
-                    manifest.add_item(item);
-                }
-            }
-        }
-    }
-
-    Ok(())
-}
diff --git a/redback-runtime/src/scene.rs b/redback-runtime/src/scene.rs
index 4ffecd2..893ce02 100644
--- a/redback-runtime/src/scene.rs
+++ b/redback-runtime/src/scene.rs
@@ -202,8 +202,7 @@ impl RuntimeScene {
                         self.on_scene_loaded(loaded, pending.name, graphics);
                     }
                     Err(err) => {
-                        log::error!("Failed to load scene: {err:?}");
-                        self.scene_command = SceneCommand::Quit;
+                        panic!("Failed to load scene: {err:?}");
                     }
                 }
             }
@@ -211,12 +210,11 @@ impl RuntimeScene {
                 self.pending_scene = Some(pending);
             }
             Err(TryRecvError::Closed) => {
-                log::error!("Scene load task for '{}' closed unexpectedly", pending.name);
                 let _ = graphics
                     .shared
                     .future_queue
                     .exchange_owned_as::<()>(&pending.handle);
-                self.scene_command = SceneCommand::Quit;
+                panic!("Scene load task for '{}' closed unexpectedly", pending.name);
             }
         }
     }