From e85a2facef67e4980f3aa4f9ffb4eb40c4e74692 Mon Sep 17 00:00:00 2001 From: Abdulmujeeb Raji Date: Wed, 14 Aug 2024 12:53:51 +0100 Subject: [PATCH] Seamless hot-reloading --- .gitignore | 3 + README.md | 2 +- app.odin | 55 ++++++++++++ releaser/releaser.odin | 75 +++++++++++++++++ reloader/reloader.odin | 168 +++++++++++++++++++++++++++++++++++++ scripts/build_debug.bat | 3 + scripts/build_dll.bat | 4 + scripts/build_release.bat | 3 + scripts/build_reloader.bat | 3 + 9 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 app.odin create mode 100644 releaser/releaser.odin create mode 100644 reloader/reloader.odin create mode 100644 scripts/build_debug.bat create mode 100644 scripts/build_dll.bat create mode 100644 scripts/build_release.bat create mode 100644 scripts/build_reloader.bat diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f98d936 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vscode/ +build/ +ols.json \ No newline at end of file diff --git a/README.md b/README.md index a501643..96e7ac0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Powervessel is a batteries-included framework made for real-time applications, particularly games. It provides: -- [ ] Seamless hot-reloading of user code +- [x] Seamless hot-reloading of user code - [ ] Cross-platform windowing and graphics via [GLFW](https://glfw.org) - [ ] An Extensible WebGPU Renderer that supports both 2D and 3D - [ ] A Uniform Keyboard + Gamepad Input System diff --git a/app.odin b/app.odin new file mode 100644 index 0000000..e0714fa --- /dev/null +++ b/app.odin @@ -0,0 +1,55 @@ +package app + +// NOTE: Restart means it's like the program's *just* been opened. Typically happens when memory layout changes +// Reload means that the code has changed, but state is still the same. More seamless and you're in the same place in your program. + +// All global data in your program MUST be in this struct +App_Memory :: struct { + current_frame: int, +} +g_mem: ^App_Memory + +// Called upon first run OR full restart. Use it to set starting values (i.e. Player position, menu state) +@(export) +app_init :: proc() { + g_mem = new(App_Memory) +} + +// Typical update loop +@(export) +app_update :: proc() -> bool { + g_mem.current_frame += 1 + + return true +} + +// Called when program closes OR full restart. Primarily for clearing memory +@(export) +app_shutdown :: proc() { + free(g_mem) +} + +// You can set conditions in your program for it to reload other than recompiling the DLL +// e.g. check for a keypress, or if the user clicks a button +@(export) +app_should_reload :: proc() -> bool { + return false +} + +// Same as app_should_reload, but for complete restart +@(export) +app_should_restart :: proc() -> bool { + return false +} + +// Returns the global memory pointer and the size of the struct. Used to tell if we need to full restart +@(export) +app_memory :: proc() -> (rawptr, int) { + return g_mem, size_of(g_mem) +} + +// Replace global state. Used when reloading in order to maintain state. +@(export) +app_memory_set :: proc(mem: ^App_Memory) { + g_mem = mem +} \ No newline at end of file diff --git a/releaser/releaser.odin b/releaser/releaser.odin new file mode 100644 index 0000000..91658ee --- /dev/null +++ b/releaser/releaser.odin @@ -0,0 +1,75 @@ +package releaser + +import "core:log" +import "core:os" +import "core:mem" + +import app ".." + +USE_TRACKING_ALLOCATOR :: ODIN_DEBUG + +main :: proc() { + when USE_TRACKING_ALLOCATOR { + default_allocator := context.allocator + tracking_allocator: mem.Tracking_Allocator + mem.tracking_allocator_init(&tracking_allocator, default_allocator) + context.allocator = mem.tracking_allocator(&tracking_allocator) + } + + mode: int = 0 + when ODIN_OS == .Linux || ODIN_OS == .Darwin { + mode = os.S_IRUSR | os.S_IWUSR | os.S_IRGRP | os.S_IROTH + } + + logh, logh_err := os.open("log.txt", (os.O_CREATE | os.O_TRUNC | os.O_RDWR), mode) + + if logh_err == os.ERROR_NONE { + os.stdout = logh + os.stderr = logh + } + + logger := logh_err == os.ERROR_NONE ? log.create_file_logger(logh) : log.create_console_logger() + context.logger = logger + + // TODO Create Windoww + app.app_init() + + window_open := true + for window_open { + window_open = app.app_update() + + when USE_TRACKING_ALLOCATOR { + for b in tracking_allocator.bad_free_array { + log.error("Bad free at: %v", b.location) + } + + clear(&tracking_allocator.bad_free_array) + } + + free_all(context.temp_allocator) + } + + free_all(context.temp_allocator) + app.app_shutdown() + // TODO Shutdown Window + + if logh_err == os.ERROR_NONE { + log.destroy_file_logger(logger) + } + + when USE_TRACKING_ALLOCATOR { + for key, value in tracking_allocator.allocation_map { + log.error("%v: Leaked %v bytes\n", value.location, value.size) + } + + mem.tracking_allocator_destroy(&tracking_allocator) + } +} + +// make app use good GPU on laptops etc + +@(export) +NvOptimusEnablement: u32 = 1 + +@(export) +AmdPowerXpressRequestHighPerformance: i32 = 1 \ No newline at end of file diff --git a/reloader/reloader.odin b/reloader/reloader.odin new file mode 100644 index 0000000..6156148 --- /dev/null +++ b/reloader/reloader.odin @@ -0,0 +1,168 @@ +/* +A simple hot reloading app that loads a dll of the application as it is compiled +https://zylinski.se/posts/hot-reload-gameplay-code +*/ +package reloader + +import "core:dynlib" +import "core:os" +import "core:fmt" +import "core:log" +import "core:c/libc" +import "core:mem" + +when ODIN_OS == .Windows { + DLL_EXT :: ".dll" + COPY_CMD :: "copy" +} else when ODIN_OS == .Darwin { + DLL_EXT :: ".dylib" + COPY_CMD :: "cp" +} else { + DLL_EXT :: ".so" + COPY_CMD :: "cp" +} + +Api :: struct { + init: proc(), + update: proc() -> bool, + shutdown: proc(), + memory: proc() -> (rawptr, int), + memory_set: proc(rawptr), + should_reload: proc() -> bool, + should_restart: proc() -> bool, + + lib: dynlib.Library, + + dll_time: os.File_Time, + version: int +} + +api_load :: proc(version: int) -> (Api, bool) { + dll_time, dll_time_err := os.last_write_time_by_name("app" + DLL_EXT) + + if dll_time_err != os.ERROR_NONE { + log.errorf("Could not fetch last write date of game" + DLL_EXT) + return {}, false + } + + dll_name := fmt.tprintf("app_{0}" + DLL_EXT, version) + + copy_cmd := fmt.ctprintf("{0} app{1} {2}", COPY_CMD, DLL_EXT, dll_name) + if libc.system(copy_cmd) != 0 { + log.errorf("Failed to copy app{0} to {1}", DLL_EXT, dll_name) + return {}, false + } + + api: Api + symbols_loaded, ok := dynlib.initialize_symbols(&api, dll_name, "app_", "lib") + if !ok { + log.errorf("Failed to load symbols from {0}", dll_name) + return {}, false + } + + api.dll_time = dll_time + api.version = version + return api, true +} + +api_unload :: proc(api: Api) { + if api.lib != nil { + dynlib.unload_library(api.lib) + } + + del_cmd := fmt.ctprintf("del app_{0}" + DLL_EXT, api.version) + if libc.system(del_cmd) != 0 { + fmt.println("Failed to remove app_{0}.dll copy", api.version) + } +} + +main :: proc() { + context.logger = log.create_console_logger() + + default_allocator := context.allocator + tracking_allocator: mem.Tracking_Allocator + mem.tracking_allocator_init(&tracking_allocator, default_allocator) + context.allocator = mem.tracking_allocator(&tracking_allocator) + + reset_tracking_allocator :: proc(a: ^mem.Tracking_Allocator) -> bool { + err := false + + for _, value in a.allocation_map { + fmt.printf("%v: Leaked %v bytes\n", value.location, value.size) + err = true + } + + mem.tracking_allocator_clear(a) + return err + } + + api_version := 0 + api, api_ok := api_load(api_version) + + if !api_ok { + log.fatalf("Failed to load Application API") + return + } + + api_version += 1 + + // TODO Create Window + api.init() + + for { + if api.update() == false { + break + } + + dll_time, dll_time_err := os.last_write_time_by_name("app" + DLL_EXT) + + restart := api.should_restart() + reload := api.should_reload() || restart + if dll_time_err == os.ERROR_NONE && api.dll_time != dll_time { + reload = true + } + + if reload { + new_api, new_api_ok := api_load(api_version) + + if new_api_ok { + memory, size := api.memory() + _, new_size := new_api.memory() + if size != new_size || restart { + api.shutdown() + reset_tracking_allocator(&tracking_allocator) + api_unload(api) + api = new_api + api.init() + } else { + api_unload(api) + api = new_api + api.memory_set(memory) + } + + api_version += 1 + } + } + + if len(tracking_allocator.bad_free_array) > 0 { + for b in tracking_allocator.bad_free_array { + log.errorf("Bad free at: %v", b.location) + } + + libc.getchar() + panic("Bad free detected") + } + + free_all(context.temp_allocator) + } + + free_all(context.temp_allocator) + api.shutdown() + if reset_tracking_allocator(&tracking_allocator) { + libc.getchar() + } + + // TODO: Shutdown Window + api_unload(api) + mem.tracking_allocator_destroy(&tracking_allocator) +} \ No newline at end of file diff --git a/scripts/build_debug.bat b/scripts/build_debug.bat new file mode 100644 index 0000000..8b0f3a0 --- /dev/null +++ b/scripts/build_debug.bat @@ -0,0 +1,3 @@ +@echo off + +odin build releaser -debug -out:build/app_debug.exe \ No newline at end of file diff --git a/scripts/build_dll.bat b/scripts/build_dll.bat new file mode 100644 index 0000000..8486a48 --- /dev/null +++ b/scripts/build_dll.bat @@ -0,0 +1,4 @@ +@echo off + +if not exist build\ mkdir build +odin build app.odin -file -build-mode:dll -out:build/app.dll \ No newline at end of file diff --git a/scripts/build_release.bat b/scripts/build_release.bat new file mode 100644 index 0000000..a3af95a --- /dev/null +++ b/scripts/build_release.bat @@ -0,0 +1,3 @@ +@echo off + +odin build releaser -out:build/app.exe \ No newline at end of file diff --git a/scripts/build_reloader.bat b/scripts/build_reloader.bat new file mode 100644 index 0000000..c9ecd83 --- /dev/null +++ b/scripts/build_reloader.bat @@ -0,0 +1,3 @@ +@echo off + +odin build reloader -debug -out:build/app_reloader.exe \ No newline at end of file