From 3549b0be95b6f5db9129c3bfa960a5ce1b7c5c42 Mon Sep 17 00:00:00 2001 From: Riyyi Date: Sun, 11 Oct 2026 23:04:16 +0200 Subject: [PATCH] Initial version of single binary hot-reloading --- compile/build.odin | 63 +++--- docs/hot-reloading.md | 47 ++++ game/game.odin | 65 +++--- ols.json | 3 +- src/base/base.odin | 3 + src/base/event.odin | 2 - src/base/hot_reload.odin | 459 ++++++++++++++++++++++---------------- src/core/api.odin | 18 ++ src/main.odin | 51 +++-- src/platform/glfw.odin | 4 +- src/platform/signals.odin | 48 ++++ src/platform/wgpu.odin | 2 +- 12 files changed, 491 insertions(+), 274 deletions(-) create mode 100644 docs/hot-reloading.md create mode 100644 src/base/base.odin create mode 100644 src/core/api.odin create mode 100644 src/platform/signals.odin diff --git a/compile/build.odin b/compile/build.odin index f21321b..ad29e8f 100644 --- a/compile/build.odin +++ b/compile/build.odin @@ -32,8 +32,6 @@ PROJECT_NAME :: "sindri" BUILD_DIRECTORY :: "out/" GAME_SRC_DIR :: "game/" -GAME_LIB_PATH :: BUILD_DIRECTORY + "game" + LIB_EXT -GAME_LIB_TMP_PATH :: BUILD_DIRECTORY + "game_tmp" + LIB_EXT ENGINE_DIR :: "src/" ENGINE_OUTPUT :: BUILD_DIRECTORY + PROJECT_NAME @@ -47,6 +45,7 @@ Options :: struct { debug: bool `args:"optional" usage:"Build project in debug-mode."`, release: bool `args:"optional" usage:"Build project in release-mode."`, run: bool `args:"optional" usage:"Run project."`, + hot_reload: bool `args:"optional" usage:"Hot-reload project."`, wgpu_init: bool `args:"optional" usage:"Prepare wgpu-native."`, wgpu_version: string `args:"optional" usage:"Override the wgpu release version to init."`, fmt: bool `args:"optional" usage:"Run odinfmt."`, @@ -77,6 +76,7 @@ main :: proc() { if opts.debug do build_project(true, opts.overflow[:]) if opts.release do build_project(false, opts.overflow[:]) if opts.run do run_project(opts.overflow[:]) + if opts.hot_reload do hot_reload(!opts.release, opts.overflow[:]) if opts.wgpu_init do wgpu_init(opts.wgpu_version) if opts.fmt do format_source() if opts.pre_commit do pre_commit() @@ -91,17 +91,12 @@ main :: proc() { // Build project // Build the game library ('game/') and the engine ('src/') -build_project :: proc(debug: bool, odin_extra_args: []string) { +build_project :: proc(debug: bool, extra_args: []string) { version := project_version() // Setup compiled wgpu binary wgpu_init() - // ---------------------------------------- - // Game compile - - build_game(debug, version, odin_extra_args) - // ---------------------------------------- // Engine compile @@ -111,7 +106,7 @@ build_project :: proc(debug: bool, odin_extra_args: []string) { return } - build_engine(debug, version, odin_extra_args) + build_engine(debug, version, extra_args) } // Shared builder between game/engine @@ -124,6 +119,7 @@ build_common :: proc( b := brokkr.builder_make() brokkr.build(&b, dir) brokkr.append(&b, "-show-timings") + brokkr.collection(&b, "game", ".") brokkr.collection(&b, PROJECT_NAME, "src") brokkr.collection(&b, "gram", "vendor/gram/src") brokkr.collection(&b, "wgpu", "vendor") @@ -144,24 +140,6 @@ build_common :: proc( return b } -// Build the game as a dynamic library loaded by the engine -build_game :: proc(debug: bool, version: string, extra_args: []string) { - b := build_common(GAME_SRC_DIR, debug, version, extra_args) - defer brokkr.builder_destroy(&b) - brokkr.mode(&b, .Dynamic) - // Use a temp file because it first writes an empty file, which the engine - // would load before it is actually fully written - brokkr.out(&b, GAME_LIB_TMP_PATH) - if err := brokkr.execute(&b); err != nil { - os.exit(1) - } - - if !os.is_file(GAME_LIB_TMP_PATH) { - die("build produced no game lib %q", GAME_LIB_TMP_PATH) - } - os.rename(GAME_LIB_TMP_PATH, GAME_LIB_PATH) -} - // Build the engine executable build_engine :: proc(debug: bool, version: string, extra_args: []string) { b := build_common(ENGINE_DIR, debug, version, extra_args) @@ -174,8 +152,7 @@ build_engine :: proc(debug: bool, version: string, extra_args: []string) { run_project :: proc(extra_args: []string) { // Build project if it wasnt already - if !os.exists(ENGINE_OUTPUT) || - !os.exists(GAME_LIB_PATH) { build_project(true, {}) } + if !os.exists(output_path(ENGINE_OUTPUT)) do build_project(true, {}) cmd := []string{output_path(ENGINE_OUTPUT)} line := slice.concatenate( @@ -206,6 +183,34 @@ run_project :: proc(extra_args: []string) { os.exit(res.exit_code) } +hot_reload :: proc(debug: bool, extra_args: []string) { + if engine_is_running() { + process := output_path(PROJECT_NAME) + when ODIN_OS == .Windows { + // Ask process to shut down gracefully (equivalent to SIGTERM) + _, ok, _ := exec([]string{"taskkill", "/IM", process + ".exe"}) + if !ok { + die("hot reload: failed to terminate running %s", process) + } + } else { + _, ok, _ := exec([]string{"pkill", "-TERM", "-x", process}) + if !ok { + die( + "hot reload: failed to send SIGTERM to running %s", + process, + ) + } + } + + // Wait for process to terminate + for engine_is_running() { + time.sleep(time.Millisecond * 20) + } + } + + run_project(extra_args) +} + // ----------------------------------------- // wgpu setup diff --git a/docs/hot-reloading.md b/docs/hot-reloading.md new file mode 100644 index 0000000..f40d358 --- /dev/null +++ b/docs/hot-reloading.md @@ -0,0 +1,47 @@ +# Hot Reloading + +Instead of having the engine as an exe and game as a dll, and having to jump +through hoops to share libraries internal state between them we do it +differently. The engine and game live in the same binary, compiled fully each +time. We rely mostly on static memory, and register these memory regions. Then +when we want to hot reload: we send a close signal to the running instance, it +writes a snapshot of the active memory to disk, instance closes, recompile, +restart the application, it restores the memory from the snapshot. + +## Byte Layout On Disk + +```odin +Snapshot_Header :: struct #packed #all_or_none { + magic_string: u32, + version: u16, // spec of the snapshot + app_version: [4 + time.MIN_YYYY_DATE_LEN + 8]u8, // example: `dev-2026-09-23-a4084b3` + region_count: u32, +} + +Region_Header :: struct #packed #all_or_none { + name_size: u32, + data_size: u32, +} +``` + +[ Snapshot_Header ] +[ Region_Header ] +[ name bytes ] +[ data bytes ] +[ Region_Header ] +[ name bytes ] +[ data bytes ] +[ etc.. ] + +## Diff Checking + +Use hashing per memory segment to do a sanity diff-check. + +```odin +import "core:crypto/hash" + +data: []byte = ... + +digest := hash.hash_bytes(hash.Algorithm.SHA256, data) +defer delete(digest) +``` diff --git a/game/game.odin b/game/game.odin index 629d0f7..cd9bb15 100644 --- a/game/game.odin +++ b/game/game.odin @@ -7,39 +7,42 @@ import "sindri:base" import "sindri:core" // ----------------------------------------- +// Types Game_Memory :: struct { should_close: bool, } -g: ^Game_Memory +Test_Memory :: struct { + asd: int, + qwe: int, +} // ----------------------------------------- +// Variables -@(export) -memory :: proc() -> rawptr { - return g +g: Game_Memory +t: Test_Memory + +game_api := core.Game_API { + settings = settings, + init_once = init_once, + init = init, + update = update, + render = render, + destroy = destroy, + should_close = should_close, + force_reload = force_reload, + force_restart = force_restart, } -@(export) -memory_free :: proc() { - free(g) +// ----------------------------------------- +// Public functions + +api :: proc() -> ^core.Game_API { + return &game_api } -@(export) -memory_size :: proc() -> int { - return size_of(Game_Memory) -} - -@(export) -memory_set :: proc(mem: rawptr) { - g = (^Game_Memory)(mem) - - // Here you can also set your own global variables. A good idea is to make - // your global variables into pointers that point to something inside `g`. -} - -@(export) settings :: proc() -> base.Settings { return base.Settings { width = 960, @@ -51,36 +54,40 @@ settings :: proc() -> base.Settings { } } -@(export) init_once :: proc() { fmt.println("init once") + + base.register_region("game_memory", &g, size_of(Game_Memory)) + base.register_region("test_memory", &t, size_of(Test_Memory)) } -@(export) init :: proc() { fmt.println("hello from .dll!") - g = new(Game_Memory) - memory_set(g) + g.should_close = false } -@(export) update :: proc(dt: f32) { fmt.println("dt:", dt) if core.key_state(.Key_Escape) == .Press { g.should_close = true } + + if core.key_state(.Key_F5) == .Press do t.asd = 555 + if core.key_state(.Key_F6) == .Press do t.asd = 666 } -@(export) +render :: proc() { } + destroy :: proc() { fmt.println("bye") + + fmt.println(t.asd, t.qwe) } start := time.tick_now() -@(export) should_close :: proc() -> bool { elapsed := time.tick_since(start) seconds := time.duration_seconds(elapsed) @@ -89,12 +96,10 @@ should_close :: proc() -> bool { return g.should_close } -@(export) force_reload :: proc() -> bool { return core.key_state(.Key_F5) == .Press } -@(export) force_restart :: proc() -> bool { return core.key_state(.Key_F6) == .Press } diff --git a/ols.json b/ols.json index f14c8a0..b50c466 100644 --- a/ols.json +++ b/ols.json @@ -1,7 +1,8 @@ { "collections": [ + { "name": "game", "path": "." }, { "name": "sindri", "path": "src" }, { "name": "gram", "path": "vendor/gram/src" }, - { "name": "wgpu", "path": "vendor" } + { "name": "wgpu", "path": "vendor" } ] } diff --git a/src/base/base.odin b/src/base/base.odin new file mode 100644 index 0000000..f04d0ef --- /dev/null +++ b/src/base/base.odin @@ -0,0 +1,3 @@ +package base + +should_close: bool diff --git a/src/base/event.odin b/src/base/event.odin index 24d5acb..6b5e616 100644 --- a/src/base/event.odin +++ b/src/base/event.odin @@ -158,8 +158,6 @@ on_event :: proc(data: Event_Data) { drain_event_queue :: proc() { assert(event_bus.queue != nil) - fmt.println("toby!") - for &event in event_bus.queue { #partial switch e in event.data { diff --git a/src/base/hot_reload.odin b/src/base/hot_reload.odin index 4d33440..f3512ec 100644 --- a/src/base/hot_reload.odin +++ b/src/base/hot_reload.odin @@ -1,231 +1,302 @@ package base -import "core:c" -import "core:dynlib" +import "base:runtime" +import "core:crypto/hash" import "core:fmt" +import "core:mem" import "core:os" +import "core:strings" import "core:time" -when ODIN_OS == .Windows { - LIB_EXT :: ".dll" -} else when ODIN_OS == .Darwin { - LIB_EXT :: ".dylib" -} else { - LIB_EXT :: ".so" -} +SNAPSHOT_NAME :: "snapshot" +SNAPSHOT_EXTENSION :: "sndr" -GAME_LIB_DIR :: "out/" -GAME_LIB_PATH :: GAME_LIB_DIR + "game" + LIB_EXT +MAGIC_STRING :: 0x72646E73 // "rdns" -> "sndr" in little endian +HOT_RELOAD_VERSION :: 1 +APP_VERSION_SIZE :: 4 + time.MIN_YYYY_DATE_LEN + 8 + 6 // example: `dev-2026-09-23-a4084b3-debug` // ----------------------------------------- +// Types +@(private) +Snapshot_Header :: struct #packed #all_or_none { + magic_string: u32, + version: u16, // spec of the snapshot + app_version: [APP_VERSION_SIZE]u8, + region_count: u32, +} + +@(private) +Region_Header :: struct #packed #all_or_none { + name_size: u32, + data_size: u32, +} + +@(private) +Memory_Region :: struct { + ptr_location: rawptr, + // TODO: How do we do hash compare without having to hash current memory every frame? + digest: []u8, // hash of this region in last snapshot +} + +@(private) Hot_Reload :: struct { - loaded_libs: [dynamic]Game_API, -} - -Hot_Reload_Error :: enum u8 { - None = 0, - Load_Game_Lib_Failed = 1, - Unimplemented = 127, - Okay = None, -} - -Error :: union #shared_nil { - Hot_Reload_Error, -} - -Game_API :: struct { - lib: dynlib.Library, - memory: proc() -> rawptr, - memory_free: proc(), - memory_size: proc() -> int, - memory_set: proc(mem: rawptr), - settings: proc() -> Settings, - init_once: proc(), - init: proc(), - update: proc(_: f32), - render: proc(), - destroy: proc(), - should_close: proc() -> bool, - force_reload: proc() -> bool, - force_restart: proc() -> bool, - modification_time: time.Time, - api_version: int, // iteration of the game lib + allocator: runtime.Allocator, + snapshot_path: string, + snapshot_header: Snapshot_Header, + region_headers: map[string]Region_Header, // hash of name:size + regions: map[string]Memory_Region, // hash of name:size + write_full_snapshot: bool, } // ----------------------------------------- +// Variables -hot_reload_init :: proc() -> (hr: Hot_Reload, error: Error) { - api: Game_API - - api_ok := load_game_lib(&api) - if !api_ok { - fmt.println("error: failed to load Game API") - return {}, .Load_Game_Lib_Failed - } - - hr.loaded_libs = make([dynamic]Game_API, 0, 0, context.allocator) - append(&hr.loaded_libs, api) - - return hr, nil -} - -hot_reload_destroy :: proc(state: ^Hot_Reload) { - for &api in state.loaded_libs { - unload_game_lib(&api) - } - - delete(state.loaded_libs) -} +@(private) +hr: Hot_Reload // ----------------------------------------- +// Constructor & destructor -active_lib :: proc(hr: ^Hot_Reload) -> ^Game_API { - return &hr.loaded_libs[len(hr.loaded_libs) - 1] -} - -should_close :: proc(hr: ^Hot_Reload) -> bool { - api := active_lib(hr) - if api.lib == nil do return true - return api.should_close() -} - -copy_lib :: proc(to: string) -> bool { - copy_err := os.copy_file(to, GAME_LIB_PATH) - if copy_err != nil { - fmt.printfln( - "error: failed to copy {0} to {1}: {2:v}", - GAME_LIB_PATH, - to, - copy_err, - ) - return false - } - - return true -} - -// Load game lib symbols -load_game_lib :: proc(api: ^Game_API) -> bool { - mod_time, mod_time_err := os.last_write_time_by_name(GAME_LIB_PATH) - if mod_time_err != os.ERROR_NONE { - fmt.printfln( - "error: failed getting last write time of {0}, error code: {1}", - GAME_LIB_PATH, - mod_time_err, - ) - return false - } - api.modification_time = mod_time - - lib_name := fmt.tprintf( - GAME_LIB_DIR + "game_{0}" + LIB_EXT, - api.api_version, +hot_reload_init :: proc( + app_version: string, + allocator := context.allocator, +) -> os.Error { + // Get snapshot path + out := os.get_executable_directory(allocator) or_return + defer delete(out, allocator) + snapshot_path := fmt.tprintf( + "%v/%v.%v", + out, + SNAPSHOT_NAME, + SNAPSHOT_EXTENSION, ) - copy_lib(lib_name) or_return + snapshot_path_cpy := strings.clone(snapshot_path, allocator) - // Match the names of the fields in Game_API to symbols in the game DLL - _, ok := dynlib.initialize_symbols(api, lib_name, "", "lib") - if !ok { - fmt.printfln( - "error: failed initializing symbols: {0}", - dynlib.last_error(), + // Copy version string + version: [APP_VERSION_SIZE]u8 + n := min(len(app_version), APP_VERSION_SIZE) + copy(version[:n], app_version[:n]) + + hr = { + allocator = allocator, + snapshot_path = snapshot_path_cpy, + snapshot_header = { + magic_string = MAGIC_STRING, + version = HOT_RELOAD_VERSION, + app_version = version, + region_count = 0, + }, + region_headers = make(map[string]Region_Header, 1, allocator), // initial capacity for nil checks + regions = make(map[string]Memory_Region, 1, allocator), // initial capacity for nil checks + write_full_snapshot = true, + } + + return nil +} + +hot_reload_destroy :: proc() { + assert( + hr.region_headers != nil, + "[hot-reload] destroying uninitialized data", + ) + + assert(hr.regions != nil, "[hot-reload] destroying uninitialized data") + + delete(hr.snapshot_path, hr.allocator) + + for key, region in hr.region_headers do delete(key, hr.allocator) + for key, region in hr.regions { + if region.digest != nil do delete(region.digest, hr.allocator) + } + + delete(hr.region_headers) + delete(hr.regions) +} + +// ----------------------------------------- +// Public functions + +register_region :: proc( + name: string, + ptr_location: rawptr, + data_size: u32, +) -> os.Error { + assert(hr.region_headers != nil, "[hot-reload] uninitialized") + assert(hr.regions != nil, "[hot-reload] uninitialized") + assert(ptr_location != nil, "[hot_reload] invalid location") + assert(data_size > 0, "[hot_reload] invalid type size") + + hash := fmt.tprintf("%v:%d", name, data_size) + assert(!(hash in hr.regions), "[hot_reload] double registration") + hash_cpy := strings.clone(hash, hr.allocator) + hr.region_headers[hash_cpy] = Region_Header { + name_size = cast(u32)len(name), + data_size = data_size, + } + hr.regions[hash_cpy] = Memory_Region { + ptr_location = ptr_location, + } + + hr.snapshot_header.region_count += 1 + + hr.write_full_snapshot = true + + return nil +} + +read_snapshot :: proc() -> os.Error { + assert(hr.region_headers != nil, "[hot-reload] uninitialized") + assert(hr.regions != nil, "[hot-reload] uninitialized") + + if !os.exists(hr.snapshot_path) { + hr.write_full_snapshot = true + return nil + } + + snapshot := os.open(hr.snapshot_path, {.Read}) or_return + defer os.close(snapshot) + + offset: i64 + + // Read snapshot header + snapshot_header: Snapshot_Header + header_bytes := mem.ptr_to_bytes(&snapshot_header) + os.read_at(snapshot, header_bytes, offset) or_return + offset += size_of(Snapshot_Header) + assert( + snapshot_header.magic_string == MAGIC_STRING, + "[hot-reload] unknown filetype", + ) + assert( + snapshot_header.version == HOT_RELOAD_VERSION, + "[hot-reload] incompatible snapshot version", + ) + + region_header: Region_Header + for i in 0 ..< snapshot_header.region_count { + + // Read region header + header_bytes := mem.ptr_to_bytes(®ion_header) + os.read_at(snapshot, header_bytes, offset) or_return + offset += size_of(Region_Header) + + // Read region name + name_bytes := make([]byte, region_header.name_size, hr.allocator) + defer delete(name_bytes, hr.allocator) + os.read_at(snapshot, name_bytes, offset) or_return + offset += cast(i64)region_header.name_size + name := transmute(string)name_bytes[:] + + // Read region data + data_bytes := make([]byte, region_header.data_size, hr.allocator) + defer delete(data_bytes, hr.allocator) + os.read_at(snapshot, data_bytes, offset) or_return + offset += cast(i64)region_header.data_size + + // Compare currently registered region with the loaded region + key := fmt.tprintf("%v:%d", name, region_header.data_size) + new_region_header, ok := hr.region_headers[key] + if !ok || region_header.data_size != new_region_header.data_size { + hr.write_full_snapshot = true + fmt.println("[hot-reload] bail loading region:", name) + continue + } + + // Create digest and store in registered regions + digest := hash.hash_bytes( + hash.Algorithm.SHA256, + data_bytes, + hr.allocator, ) - } else { - // Odin's shared libraries don't register a constructor, so the lib's - // runtime init (@init procs like os.stdout setup) never runs on load. - // Call it explicitly. - // Windows doesn't need this: its dynamic build exports DllMain, which - // the OS calls on load and which runs _startup_runtime itself. - when ODIN_OS != .Windows { - Entry :: #type proc "c" () -> c.int - entry_ptr, entry_ok := dynlib.symbol_address( - api.lib, - "_odin_entry_point", - ) - if entry_ok do (transmute(Entry)entry_ptr)() - } - } + region := &hr.regions[key] + region.digest = digest - return ok -} - -// Unload game lib, used during full restarts -unload_game_lib :: proc(api: ^Game_API) { - if api.lib != nil { - if !dynlib.unload_library(api.lib) { - fmt.eprintfln( - "error: failed unloading lib: {0}", - dynlib.last_error(), - ) - } - } - - if os.remove( - fmt.tprintf(GAME_LIB_DIR + "game_{0}" + LIB_EXT, api.api_version), - ) != - nil { - fmt.printfln( - "error: failed to remove {0}game_{1}{2} copy", - GAME_LIB_DIR, - api.api_version, - LIB_EXT, + // Write snapshot memory to the memory region + dest := mem.byte_slice( + region.ptr_location, + cast(int)region_header.data_size, ) + copy(dest, data_bytes) } + + return nil } -reload_game_lib :: proc(hr: ^Hot_Reload) -> (lib: ^Game_API, err: Error) { - api := active_lib(hr) - force_reload := api.force_reload() - force_restart := api.force_restart() - reload := force_reload || force_restart +// Next write will be a full memory snapshot +set_write_full_snapshot :: proc() { + hr.write_full_snapshot = true +} - lib_mod, lib_mod_err := os.last_write_time_by_name(GAME_LIB_PATH) - // TODO: Handle error - if lib_mod_err == os.ERROR_NONE && api.modification_time != lib_mod { - reload = true - } +// Write memory snapshot, save on writes by diff-ing +write_snapshot :: proc() -> os.Error { + assert(hr.region_headers != nil, "[hot-reload] uninitialized") + assert(hr.regions != nil, "[hot-reload] uninitialized") - if !reload do return api, nil - - new_api: Game_API - new_api.api_version = api.api_version + 1 - new_api_ok := load_game_lib(&new_api) - if !new_api_ok { - fmt.println("error: failed to load Game API") - return nil, .Load_Game_Lib_Failed - } - - force_restart = force_restart || api.memory_size() != new_api.memory_size() - - if !force_restart { - // This does the normal hot reload - - // Note that we don't unload the old game APIs because that - // would unload the lib. The lib can contain stored info - // such as string literals. The old libs are only unloaded - // on a full reset or on shutdown. - memory := api.memory() - new_api.memory_set(memory) + snapshot: ^os.File + if hr.write_full_snapshot || !os.exists(hr.snapshot_path) { + snapshot = os.create(hr.snapshot_path) or_return } else { - // This does a full reset. That's basically like opening and - // closing the game, without having to restart the executable. - // - // You end up in here if the game requests a full reset OR - // if the size of the game memory has changed. That would - // probably lead to a crash anyways. + snapshot = os.open(hr.snapshot_path, {.Read, .Write}) or_return + } + defer os.close(snapshot) - api.memory_free() + offset: i64 - for &g in hr.loaded_libs { - unload_game_lib(&g) + // Write header + if hr.write_full_snapshot { + header_bytes := mem.ptr_to_bytes(&hr.snapshot_header) + n := os.write_at(snapshot, header_bytes, offset) or_return + } + offset += size_of(Snapshot_Header) + + for key, ®ion in hr.regions { + header := hr.region_headers[key] + + // Write region header + if hr.write_full_snapshot { + header_bytes := mem.ptr_to_bytes(&header) + os.write_at(snapshot, header_bytes, offset) or_return } - clear(&hr.loaded_libs) + offset += size_of(Region_Header) - new_api.init() + // Write region name + if hr.write_full_snapshot { + name := key[:header.name_size] // get name from first part of the hash + name_bytes := transmute([]byte)name + os.write_at(snapshot, name_bytes, offset) or_return + } + offset += cast(i64)header.name_size + + // Write region data (if bytes differ) + data_bytes := mem.byte_slice(region.ptr_location, header.data_size) + digest := hash.hash_bytes( + hash.Algorithm.SHA256, + data_bytes, + hr.allocator, + ) + if hr.write_full_snapshot || mem.compare(digest, region.digest) != 0 { + delete(region.digest, hr.allocator) + region.digest = digest + os.write_at(snapshot, data_bytes, offset) or_return + } else { + delete(digest, hr.allocator) + } + offset += cast(i64)header.data_size } - append(&hr.loaded_libs, new_api) + hr.write_full_snapshot = false - return active_lib(hr), nil + return nil +} + +force_reload :: proc() { + should_close = true +} + +force_restart :: proc() -> os.Error { + should_close = true + os.remove(hr.snapshot_path) or_return + + return nil } diff --git a/src/core/api.odin b/src/core/api.odin new file mode 100644 index 0000000..e35c40c --- /dev/null +++ b/src/core/api.odin @@ -0,0 +1,18 @@ +package core + +import "sindri:base" + +// ----------------------------------------- +// Types + +Game_API :: struct { + settings: proc() -> base.Settings, + init_once: proc(), + init: proc(), + update: proc(dt: f32), + render: proc(), + destroy: proc(), + should_close: proc() -> bool, + force_reload: proc() -> bool, + force_restart: proc() -> bool, +} diff --git a/src/main.odin b/src/main.odin index 44e37e6..854a002 100644 --- a/src/main.odin +++ b/src/main.odin @@ -3,6 +3,7 @@ package sindri import "core:fmt" import "core:time" +import "game:game" import "sindri:base" import "sindri:platform" @@ -14,8 +15,13 @@ main :: proc() { fmt.println("hello world!", VERSION) // Hot reload development functionality - hr, err := base.hot_reload_init() - defer base.hot_reload_destroy(&hr) + base.hot_reload_init(VERSION) + defer base.hot_reload_destroy() + + // Load game API & settings + api := game.api() + defer api.destroy() + settings := api.settings() // Event system base.event_bus_init() @@ -23,8 +29,9 @@ main :: proc() { base.event_subscribe(base.Key_Press_Event, test) - api := base.active_lib(&hr) - settings := api.settings() + // Listen for OS signals (SIGINT, SIGTERM) + platform.signals_init() + defer platform.signals_destroy() // Initialize Window platform.os_init(settings) @@ -36,29 +43,41 @@ main :: proc() { defer platform.instance_destroy() api.init_once() + + base.read_snapshot() + api.init() - gt: f64 = 0 - dt: f32 + dt: f64 + gt: f64 + st: f64 + + for !api.should_close() && + !platform.os_should_close() && + !base.should_close { - for !platform.os_should_close() && !base.should_close(&hr) { start := time.tick_now() platform.os_poll_events() base.drain_event_queue() - api.update(dt) + api.update(cast(f32)dt) + base.write_snapshot() - platform.frame(dt) + platform.frame() + api.render() - dt = f32(time.duration_seconds(time.tick_since(start))) - gt += f64(dt) + dt = time.duration_seconds(time.tick_since(start)) + gt += dt + st += dt - // Hot reload - api, err = base.reload_game_lib(&hr) - if err != nil do break + if st > 5 { + st -= 5 + base.set_write_full_snapshot() + } + + if api.force_reload() do base.force_reload() + if api.force_restart() do base.force_restart() } - - api.destroy() } test :: proc(e: ^base.Key_Press_Event, _: rawptr) -> bool { diff --git a/src/platform/glfw.odin b/src/platform/glfw.odin index 23af9ce..0b1947b 100644 --- a/src/platform/glfw.odin +++ b/src/platform/glfw.odin @@ -1,5 +1,6 @@ package platform +import "base:intrinsics" import "core:fmt" import "core:strings" import "vendor:glfw" @@ -113,7 +114,8 @@ os_set_callback_proc :: proc(window: rawptr) { } os_should_close :: proc() -> bool { - return bool(glfw.WindowShouldClose(state.os.window)) + window_close := bool(glfw.WindowShouldClose(state.os.window)) + return window_close || intrinsics.atomic_load(&quit_signal_received) } os_set_should_close :: proc(val: bool) { diff --git a/src/platform/signals.odin b/src/platform/signals.odin new file mode 100644 index 0000000..43e62bb --- /dev/null +++ b/src/platform/signals.odin @@ -0,0 +1,48 @@ +package platform + +import "base:intrinsics" +import "core:c/libc" +import "core:fmt" + +// ----------------------------------------- +// Variables + +// Read with atomic_load, safe across threads / interrupt contexts +quit_signal_received: bool + +// ----------------------------------------- +// Public functions + +// Register signal handlers +signals_init :: proc() { + // SIGINT = Interrupt, typically Ctrl+C + if libc.signal(libc.SIGINT, signal_handler) == + cast(proc "c" (_: i32))libc.SIG_ERR { + fmt.eprintln("error: failed to register SIGINT handler") + } + + // SIGTERM = Normal termination + if libc.signal(libc.SIGTERM, signal_handler) == + cast(proc "c" (_: i32))libc.SIG_ERR { + fmt.eprintln("error: failed to register SIGTERM handler") + } +} + +// Restore default signal handlers +signals_destroy :: proc() { + libc.signal(libc.SIGINT, cast(proc "c" (_: i32))libc.SIG_DFL) + libc.signal(libc.SIGTERM, cast(proc "c" (_: i32))libc.SIG_DFL) +} + +@(private = "file") +signal_handler :: proc "c" (sig: i32) { + switch sig { + case libc.SIGINT: + fallthrough + case libc.SIGTERM: + intrinsics.atomic_store(&quit_signal_received, true) + case: + context = state.ctx + panic("[signal] unhandled signal type") + } +} diff --git a/src/platform/wgpu.odin b/src/platform/wgpu.odin index 54b4b97..2834d30 100644 --- a/src/platform/wgpu.odin +++ b/src/platform/wgpu.odin @@ -96,7 +96,7 @@ resize :: proc "c" () { wgpu.SurfaceConfigure(state.surface, &state.config) } -frame :: proc "c" (dt: f32) { +frame :: proc "c" () { context = state.ctx surface_texture := wgpu.SurfaceGetCurrentTexture(state.surface)