tirbofish/dropbear · commit
2bb2544254bc1711f1a6c29e01a197f2e1bd8954
transfer to my PC, i think i have gotten most of the hot reload managers
Signature present but could not be verified.
Unverified
@@ -2,16 +2,23 @@ ## hybrid approach: -- during dev builds, interpret using the JVM -- on production build, build to native. +* during dev builds, interpret using the JVM +* on production build, build to native. to check if its prod or dev, we use a config to build. workflow: -- user creates a new project, which loads the project -- use jvm to watch (because editor is always on desktop) -- on production build, create native build for specific OS + +* user creates a new project, which loads the project +* use jvm to watch (because editor is always on desktop) +* on production build, create native build for specific OS ## required dependencies -- jdk 21 +* jdk 21 + + +## Kotlin REPL +get hot reloading working, then the kotlin repl acts as a way to evaluate .kts files. + + @@ -34,6 +34,7 @@ interprocess = "2.2" serde_json = "1.0.143" libloading = "0.8.9" async-process = "2.5" +tokio-util = "0.7" [features] editor = ["jvm"] @@ -6,3 +6,4 @@ pub mod scripting; pub mod spawn; pub mod states; pub mod utils; +pub mod ptr; @@ -0,0 +1,3 @@ +use hecs::World; + +pub type WorldPtr = *mut World; @@ -2,8 +2,8 @@ pub mod jni; pub mod kmp; use crate::input::InputState; -use crate::scripting::jni::{JavaContext, WorldPtr}; -use crate::states::{EntityNode, PROJECT, SOURCE, ScriptComponent}; +use crate::scripting::jni::JavaContext; +use crate::states::{EntityNode, ScriptComponent, PROJECT, SOURCE}; use dropbear_engine::entity::{AdoptedEntity, Transform}; use hecs::{Entity, World}; use libloading::Library; @@ -14,6 +14,7 @@ use anyhow::Context; use crossbeam_channel::Sender; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; +use crate::ptr::WorldPtr; pub const TEMPLATE_SCRIPT: &str = include_str!("../../resources/scripting/kotlin/Template.kt"); @@ -43,10 +44,10 @@ pub struct ScriptManager { script_target: ScriptTarget, entity_tag_database: HashMap<String, Vec<Entity>>, jvm_created: bool, + lib_path: Option<PathBuf>, #[cfg(feature = "jvm")] entity_scripts: HashMap<u32, Vec<GlobalRef>>, - // the native version must be stateless, and doesn't have such a thing } impl ScriptManager { @@ -58,6 +59,7 @@ impl ScriptManager { entity_tag_database: HashMap::new(), jvm_created: false, entity_scripts: Default::default(), + lib_path: None, }) } @@ -81,21 +83,7 @@ impl ScriptManager { let _ = status_sender.send(BuildStatus::Started); - let gradle_cmd = if cfg!(target_os = "windows") { - let gradlew = project_root.join("gradlew.bat"); - if gradlew.exists() { - gradlew.to_string_lossy().to_string() - } else { - "gradle.bat".to_string() - } - } else { - let gradlew = project_root.join("gradlew"); - if gradlew.exists() { - "./gradlew".to_string() - } else { - "gradle".to_string() - } - }; + let gradle_cmd = get_gradle_command(project_root); let _ = status_sender.send(BuildStatus::Building(format!("Running: {}", gradle_cmd))); @@ -194,6 +182,8 @@ impl ScriptManager { match &target { ScriptTarget::JVM { library_path } => { + self.lib_path = Some(library_path.clone()); + if !self.jvm_created { let jvm = JavaContext::new(library_path)?; self.jvm = Some(jvm); @@ -201,6 +191,9 @@ impl ScriptManager { log::debug!("Created new JVM instance"); } else { log::debug!("Reusing existing JVM instance"); + if let Some(jvm) = &mut self.jvm { + jvm.jar_path = library_path.clone(); + } } if let Some(jvm) = &mut self.jvm { @@ -215,6 +208,7 @@ impl ScriptManager { self.jvm = None; self.library = None; self.jvm_created = false; + self.lib_path = None; } } @@ -264,27 +258,21 @@ impl ScriptManager { { let mut env = jvm.jvm.attach_current_thread()?; - for (tag, entities) in &self.entity_tag_database { - for entity in entities { - let entity_id = entity.id(); - log::debug!("Updating scripts with tag {} for entity {}", tag, entity_id); - - if let Some(scripts) = self.entity_scripts.get(&entity_id) { - let engine_ref = jvm.create_engine_for_entity(world, entity_id)?; - - for script_ref in scripts { - log::debug!("Calling method update"); - env.call_method( - script_ref, - "update", - "(Lcom/dropbear/DropbearEngine;F)V", - &[ - JValue::Object(engine_ref.as_obj()), - JValue::Float(dt), - ], - )?; - } - } + for (entity_id, scripts) in &self.entity_scripts { + log::debug!("Updating {} scripts for entity {}", scripts.len(), entity_id); + + let engine_ref = jvm.create_engine_for_entity(world, *entity_id)?; + + for script_ref in scripts { + env.call_method( + script_ref, + "update", + "(Lcom/dropbear/DropbearEngine;F)V", + &[ + JValue::Object(engine_ref.as_obj()), + JValue::Float(dt), + ], + )?; } } return Ok(()); @@ -292,6 +280,32 @@ impl ScriptManager { Err(anyhow::anyhow!("Native implementation not implemented yet")) } + + pub fn reload(&mut self, world_ptr: WorldPtr) -> anyhow::Result<()> { + if let Some(jvm) = &mut self.jvm { + jvm.reload(world_ptr)? + } + Ok(()) + } +} + +fn get_gradle_command(project_root: impl AsRef<Path>) -> String { + let project_root = project_root.as_ref().to_owned(); + if cfg!(target_os = "windows") { + let gradlew = project_root.join("gradlew.bat"); + if gradlew.exists() { + gradlew.to_string_lossy().to_string() + } else { + "gradle.bat".to_string() + } + } else { + let gradlew = project_root.join("gradlew"); + if gradlew.exists() { + "./gradlew".to_string() + } else { + "gradle".to_string() + } + } } pub fn move_script_to_src(script_path: &PathBuf) -> anyhow::Result<PathBuf> { @@ -342,7 +356,8 @@ pub fn move_script_to_src(script_path: &PathBuf) -> anyhow::Result<PathBuf> { break; } Err(e) => { - if e.raw_os_error() == Some(32) && attempt < MAX_RETRIES { + if let Some(e_int) = e.raw_os_error() && e_int == 32 + && attempt < MAX_RETRIES { log::warn!( "Sharing violation copying script (attempt {}/{}). Retrying in {}ms: {}", attempt + 1, @@ -1,20 +1,23 @@ #![allow(non_snake_case)] //! Deals with the Java Native Interface (JNI) with the help of the [`jni`] crate +pub mod hotreload; + use dropbear_engine::entity::AdoptedEntity; use hecs::World; use jni::objects::{GlobalRef, JClass, JString, JValue}; use jni::sys::jlong; use jni::sys::jobject; use jni::{InitArgsBuilder, JNIEnv, JNIVersion, JavaVM}; -use std::path::{Path}; - -pub type WorldPtr = *mut World; +use std::path::{Path, PathBuf}; +use crate::ptr::WorldPtr; /// Provides a context for any eucalyptus-core JNI calls and JVM hosting. pub struct JavaContext { pub(crate) jvm: JavaVM, dropbear_engine_class: Option<GlobalRef>, + pub(crate) jar_path: PathBuf, + hot_reload_loader: Option<GlobalRef>, } impl JavaContext { @@ -31,12 +34,29 @@ impl JavaContext { Ok(Self { jvm, dropbear_engine_class: None, + jar_path: jar_path.as_ref().to_owned(), + hot_reload_loader: None, }) } pub fn init(&mut self, world: WorldPtr) -> anyhow::Result<()> { let mut env = self.jvm.attach_current_thread()?; + if self.hot_reload_loader.is_none() { + log::trace!("Creating HotSwappableJarLoader for hot-reload support"); + + let loader_class = env.find_class("com/dropbear/reload/HotReloadManager")?; + let jar_path_str = env.new_string(self.jar_path.to_str().unwrap())?; + + let loader_obj = env.new_object( + loader_class, + "(Ljava/lang/String;)V", + &[JValue::Object(&jar_path_str)], + )?; + + self.hot_reload_loader = Some(env.new_global_ref(loader_obj)?); + } + if let Some(old_ref) = self.dropbear_engine_class.take() { let _ = old_ref; // drop } @@ -72,11 +92,29 @@ impl JavaContext { Ok(()) } - pub fn clear_engine(&mut self) -> anyhow::Result<()> { - if let Some(old_ref) = self.dropbear_engine_class.take() { - let _ = old_ref; // drop + pub fn reload(&mut self, world: WorldPtr) -> anyhow::Result<()> { + log::info!("Hot-reloading JAR: {}", self.jar_path.display()); + + { + let mut env = self.jvm.attach_current_thread()?; + + if let Some(loader) = &self.hot_reload_loader { + log::trace!("Calling HotSwappableJarLoader.reload()"); + env.call_method(loader.as_obj(), "reload", "()V", &[])?; + } + + log::trace!("Calling RunnableRegistry.reload()"); + let registry_class = env.find_class("com/dropbear/decl/RunnableRegistry")?; + env.call_static_method(registry_class, "reload", "()V", &[])?; + + log::trace!("Re-initialising engine with new classes"); } - self.dropbear_engine_class = None; + + self.clear_engine()?; + self.init(world)?; + + log::info!("Hot-reload complete!"); + Ok(()) } @@ -206,13 +244,22 @@ impl JavaContext { "(Lcom/dropbear/ffi/NativeEngine;)V", &[ JValue::Object(&native_engine_obj), - // JValue::Object(&entity_ref_obj), ], )?; log::trace!("Creating new global ref for DropbearEngine and returning"); Ok(env.new_global_ref(dropbear_obj)?) } + + pub fn clear_engine(&mut self) -> anyhow::Result<()> { + if let Some(old_ref) = self.dropbear_engine_class.take() { + let _ = old_ref; // drop + } + if let Some(ref loader) = self.hot_reload_loader { + let _ = loader; + } + Ok(()) + } } impl Drop for JavaContext { @@ -222,6 +269,7 @@ impl Drop for JavaContext { } } } + #[unsafe(no_mangle)] // JNIEXPORT jlong JNICALL Java_com_dropbear_ffi_JNINative_getEntity // (JNIEnv *, jobject, jlong, jstring); @@ -0,0 +1,186 @@ +use std::path::Path; +use std::sync::Arc; +use crossbeam_channel::Sender; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; +use tokio_util::sync::CancellationToken; +use dropbear_engine::future::{FutureHandle, FutureQueue}; +use crate::scripting::{get_gradle_command}; + +pub enum HotReloadEvent { + SuccessBuild, + FailedBuild(String), +} + +pub struct HotReloader { + cancellation_token: CancellationToken, + handle: Option<FutureHandle>, +} + +impl HotReloader { + pub fn new() -> Self { + Self { + cancellation_token: CancellationToken::new(), + handle: None, + } + } + + pub fn start( + &mut self, + project_root: impl AsRef<Path>, + future_queue: Arc<FutureQueue>, + sender: Sender<HotReloadEvent>, + ) { + let project_root = project_root.as_ref().to_path_buf(); + let token = self.cancellation_token.clone(); + let sender_clone = sender.clone(); + + let handle = future_queue.push(async move { + let gradle_cmd = get_gradle_command(project_root.clone()); + + loop { + if token.is_cancelled() { + log::info!("Hot reloader cancelled, stopping..."); + break; + } + + let mut child = match Command::new(&gradle_cmd) + .current_dir(&project_root) + .args(["--continuous", "--console=plain", "fatJar"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(e) => { + log::error!("Failed to start continuous build: {}", e); + + tokio::select! { + _ = token.cancelled() => break, + _ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => continue, + } + } + }; + + let stdout = child.stdout.take().expect("Stdout was piped"); + let stderr = child.stderr.take().expect("Stderr was piped"); + + // Spawn child tasks with cancellation + let stdout_handle = tokio::spawn({ + let sender = sender_clone.clone(); + let token = token.clone(); + + async move { + let mut reader = BufReader::new(stdout).lines(); + + loop { + tokio::select! { + _ = token.cancelled() => { + log::debug!("Stdout reader cancelled"); + break; + } + result = reader.next_line() => { + match result { + Ok(Some(line)) => { + log::debug!("[Gradle] {}", line); + + if line.contains("BUILD SUCCESSFUL") { + log::info!("Build completed, triggering hot-reload..."); + let _ = sender.send(HotReloadEvent::SuccessBuild); + } else if line.contains("BUILD FAILED") { + log::error!("Build failed, waiting for next change..."); + let _ = sender.send(HotReloadEvent::FailedBuild(line.clone())); + } + } + Ok(None) => break, // EOF + Err(e) => { + log::error!("Error reading stdout: {}", e); + break; + } + } + } + } + } + } + }); + + let stderr_handle = tokio::spawn({ + let token = token.clone(); + + async move { + let mut reader = BufReader::new(stderr).lines(); + + loop { + tokio::select! { + _ = token.cancelled() => { + log::debug!("Stderr reader cancelled"); + break; + } + result = reader.next_line() => { + match result { + Ok(Some(line)) => { + log::warn!("[Gradle Error] {}", line); + } + Ok(None) => break, // EOF + Err(e) => { + log::error!("Error reading stderr: {}", e); + break; + } + } + } + } + } + } + }); + + tokio::select! { + _ = token.cancelled() => { + log::info!("Hot reloader cancelled, cleaning up..."); + let _ = child.kill().await; + stdout_handle.abort(); + stderr_handle.abort(); + break; + } + _ = child.wait() => { + log::warn!("Gradle process exited unexpectedly, restarting..."); + let _ = tokio::join!(stdout_handle, stderr_handle); + } + } + + tokio::select! { + _ = token.cancelled() => break, + _ = tokio::time::sleep(tokio::time::Duration::from_secs(2)) => {} + } + } + + log::info!("Hot reloader task finished"); + }); + + self.handle = Some(handle.clone()); + } + + pub fn stop(&mut self, future_queue: Arc<FutureQueue>) { + self.cancellation_token.cancel(); + + if let Some(handle) = self.handle.take() { + future_queue.cancel(&handle); + log::info!("Hot reloader stopped"); + } + } + + pub fn is_running(&self) -> bool { + self.handle.is_some() && !self.cancellation_token.is_cancelled() + } +} + +impl Default for HotReloader { + fn default() -> Self { + Self::new() + } +} + +impl Drop for HotReloader { + fn drop(&mut self) { + self.cancellation_token.cancel(); + } +} @@ -12,7 +12,7 @@ use std::{ sync::{Arc, LazyLock}, time::{Duration, Instant}, }; - +use crossbeam_channel::Receiver; use crate::build::build; use crate::camera::UndoableCameraAction; use crate::debug; @@ -47,6 +47,7 @@ use tokio::sync::oneshot; use transform_gizmo_egui::{EnumSet, Gizmo, GizmoMode}; use wgpu::{Color, Extent3d, RenderPipeline}; use winit::{keyboard::KeyCode, window::Window}; +use eucalyptus_core::scripting::jni::hotreload::{HotReloadEvent, HotReloader}; pub struct Editor { scene_command: SceneCommand, @@ -89,8 +90,6 @@ pub struct Editor { // channels /// A threadsafe Unbounded Receiver, typically used for checking the status of the world loading progress_tx: Option<UnboundedReceiver<WorldLoadingStatus>>, - /// Unused: A threadsafe Unbounded Sender - _progress_rx: Option<UnboundedSender<WorldLoadingStatus>>, /// Used to check if the world has been loaded in is_world_loaded: IsWorldLoadedYet, /// Used to fetch the current status of the loading, so it can be used for different @@ -103,7 +102,7 @@ pub struct Editor { world_receiver: Option<oneshot::Receiver<hecs::World>>, // building - pub progress_rx: Option<crossbeam_channel::Receiver<BuildStatus>>, + pub progress_rx: Option<Receiver<BuildStatus>>, pub handle_created: Option<FutureHandle>, pub build_logs: Vec<String>, pub build_progress: f32, @@ -111,6 +110,11 @@ pub struct Editor { pub last_build_error: Option<String>, pub show_build_error_window: bool, + // hot reloading + pub hot_reloader: HotReloader, + pub hot_reload_rx: Option<Receiver<HotReloadEvent>>, + + dock_state_shared: Option<Arc<Mutex<DockState<EditorTab>>>>, } @@ -127,7 +131,7 @@ impl Editor { let [_old, _] = surface.split_below( right, 0.5, - vec![EditorTab::AssetViewer, EditorTab::KotlinREPL], + vec![EditorTab::AssetViewer], ); // this shit doesn't work :( @@ -181,7 +185,6 @@ impl Editor { input_state: InputState::new(), light_manager: LightManager::new(), active_camera: Arc::new(Mutex::new(None)), - _progress_rx: None, progress_tx: None, is_world_loaded: IsWorldLoadedYet::new(), current_state: WorldLoadingStatus::Idle, @@ -195,6 +198,8 @@ impl Editor { show_build_window: false, last_build_error: None, show_build_error_window: false, + hot_reloader: HotReloader::new(), + hot_reload_rx: None, dock_state_shared: None, }) } @@ -1,3 +1,4 @@ +use crossbeam_channel::TryRecvError; use super::*; use crate::signal::SignalController; use crate::spawn::PendingSpawnController; @@ -112,6 +113,32 @@ impl Scene for Editor { self.signal = Signal::StopPlaying; } + if let Some(rx) = &self.hot_reload_rx { + match rx.try_recv() { + Ok(val) => { + match val { + HotReloadEvent::SuccessBuild => { + let world_ptr = self.world.as_mut() as *mut World; + match self.script_manager.reload(world_ptr) { + Ok(_) => { + log::info!("Reloaded script"); + } + Err(e) => { + log::error!("Failed to reload script: {}", e); + } + } + } + HotReloadEvent::FailedBuild(e) => { + log_once::warn_once!("Failed to build: {}", e); + } + } + } + Err(e) => { + log_once::warn_once!("Failed to receive hot reload signal: {}", e); + } + } + } + let world_ptr = self.world.as_mut() as *mut World; if let Err(e) = self.script_manager.update_script( @@ -304,6 +304,16 @@ impl SignalController for Editor { return Err(anyhow::anyhow!(e)); } + let project_path = { + PROJECT.read().project_path.clone() + }; + + let (tx, rx) = crossbeam_channel::unbounded(); + + self.hot_reload_rx = Some(rx); + + self.hot_reloader.start(project_path, graphics.future_queue.clone(), tx); + let world_ptr = self.world.as_mut() as *mut World; if let Err(e) = self.script_manager @@ -397,6 +407,8 @@ impl SignalController for Editor { Ok(()) } Signal::StopPlaying => { + self.hot_reloader.stop(graphics.future_queue.clone()); + if let Err(e) = self.restore() { warn!("Failed to restore from play mode backup: {}", e); log::warn!("Failed to restore scene state: {}", e); @@ -406,11 +418,6 @@ impl SignalController for Editor { self.switch_to_debug_camera(); - // already kills itself - // for (entity_id, _) in Arc::get_mut(&mut self.world).unwrap().query::<&ScriptComponent>().iter() { - // self.script_manager.remove_entity_script(entity_id); - // } - success!("Exited play mode"); log::info!("Back to the editor you go..."); @@ -20,8 +20,8 @@ impl Generator for KotlinJVMGenerator { writeln!(output)?; writeln!(output, "package com.dropbear.decl")?; - writeln!(output)?; + let mut imported_packages = std::collections::HashSet::new(); for item in manifest.items() { if let Some(last_dot) = item.fqcn().rfind('.') { @@ -47,31 +47,40 @@ impl Generator for KotlinJVMGenerator { } writeln!(output, "object RunnableRegistry {{")?; - writeln!(output, " private val TAG_REGISTRY = mapOf(")?; - - let tag_entries: Vec<String> = tag_map - .iter() - .map(|(tag, classes)| { - let factories: Vec<String> = classes - .iter() - .map(|cls| format!("::{}", cls)) - .collect(); - format!(" \"{}\" to listOf({})", tag, factories.join(", ")) - }) - .collect(); - - if !tag_entries.is_empty() { - writeln!(output, "{}", tag_entries.join(",\n"))?; + writeln!(output, " private val tagRegistry = mutableMapOf<String, MutableList<() -> com.dropbear.System>>()")?; + writeln!(output)?; + + writeln!(output, " init {{")?; + writeln!(output, " registerStaticScripts()")?; + writeln!(output, " }}")?; + writeln!(output)?; + + writeln!(output, " private fun registerStaticScripts() {{")?; + for (tag, classes) in &tag_map { + writeln!(output, " // Tag: {}", tag)?; + for class in classes { + writeln!( + output, + " tagRegistry.computeIfAbsent(\"{}\") {{ mutableListOf() }}.add(::{})", + tag, class + )?; + } } + writeln!(output, " }}")?; + writeln!(output)?; - writeln!(output, " )")?; + writeln!(output, " @Synchronized")?; + writeln!(output, " fun reload() {{")?; + writeln!(output, " tagRegistry.clear()")?; + writeln!(output, " registerStaticScripts()")?; + writeln!(output, " }}")?; writeln!(output)?; writeln!( output, " fun getScriptFactories(tag: String): List<() -> com.dropbear.System> {{" )?; - writeln!(output, " return TAG_REGISTRY[tag] ?: emptyList()")?; + writeln!(output, " return tagRegistry[tag]?.toList() ?: emptyList()")?; writeln!(output, " }}")?; writeln!(output)?; @@ -84,6 +93,11 @@ impl Generator for KotlinJVMGenerator { " return getScriptFactories(tag).map {{ it() }}" )?; writeln!(output, " }}")?; + writeln!(output)?; + + writeln!(output, " fun getAllTags(): Set<String> {{")?; + writeln!(output, " return tagRegistry.keys.toSet()")?; + writeln!(output, " }}")?; writeln!(output, "}}")?; @@ -0,0 +1,44 @@ +package com.dropbear.reload + +import java.net.URLClassLoader +import java.nio.file.Paths + +class HotReloadManager(private val jarPath: String) : AutoCloseable { + @Volatile + private var currentLoader: URLClassLoader? = null + + init { + reload() + } + + @Synchronized + fun reload() { + currentLoader?.close() + + val jarUrl = Paths.get(jarPath).toUri().toURL() + + currentLoader = URLClassLoader( + arrayOf(jarUrl), + ClassLoader.getSystemClassLoader().parent + ) + } + + fun loadClass(className: String): Class<*> { + return currentLoader?.loadClass(className) + ?: throw IllegalStateException("ClassLoader not initialised") + } + + fun createInstance(className: String): Any { + val clazz = loadClass(className) + return clazz.getDeclaredConstructor().newInstance() + } + + fun getCurrentLoader(): ClassLoader { + return currentLoader ?: throw IllegalStateException("ClassLoader not initialised") + } + + override fun close() { + currentLoader?.close() + currentLoader = null + } +}