Initial version of single binary hot-reloading

This commit is contained in:
Riyyi
2026-10-12 11:11:51 +02:00
parent 089ee28682
commit 3549b0be95
12 changed files with 491 additions and 274 deletions
+34 -29
View File
@@ -32,8 +32,6 @@ PROJECT_NAME :: "sindri"
BUILD_DIRECTORY :: "out/" BUILD_DIRECTORY :: "out/"
GAME_SRC_DIR :: "game/" 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_DIR :: "src/"
ENGINE_OUTPUT :: BUILD_DIRECTORY + PROJECT_NAME ENGINE_OUTPUT :: BUILD_DIRECTORY + PROJECT_NAME
@@ -47,6 +45,7 @@ Options :: struct {
debug: bool `args:"optional" usage:"Build project in debug-mode."`, debug: bool `args:"optional" usage:"Build project in debug-mode."`,
release: bool `args:"optional" usage:"Build project in release-mode."`, release: bool `args:"optional" usage:"Build project in release-mode."`,
run: bool `args:"optional" usage:"Run project."`, 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_init: bool `args:"optional" usage:"Prepare wgpu-native."`,
wgpu_version: string `args:"optional" usage:"Override the wgpu release version to init."`, wgpu_version: string `args:"optional" usage:"Override the wgpu release version to init."`,
fmt: bool `args:"optional" usage:"Run odinfmt."`, fmt: bool `args:"optional" usage:"Run odinfmt."`,
@@ -77,6 +76,7 @@ main :: proc() {
if opts.debug do build_project(true, opts.overflow[:]) if opts.debug do build_project(true, opts.overflow[:])
if opts.release do build_project(false, opts.overflow[:]) if opts.release do build_project(false, opts.overflow[:])
if opts.run do run_project(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.wgpu_init do wgpu_init(opts.wgpu_version)
if opts.fmt do format_source() if opts.fmt do format_source()
if opts.pre_commit do pre_commit() if opts.pre_commit do pre_commit()
@@ -91,17 +91,12 @@ main :: proc() {
// Build project // Build project
// Build the game library ('game/') and the engine ('src/') // 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() version := project_version()
// Setup compiled wgpu binary // Setup compiled wgpu binary
wgpu_init() wgpu_init()
// ----------------------------------------
// Game compile
build_game(debug, version, odin_extra_args)
// ---------------------------------------- // ----------------------------------------
// Engine compile // Engine compile
@@ -111,7 +106,7 @@ build_project :: proc(debug: bool, odin_extra_args: []string) {
return return
} }
build_engine(debug, version, odin_extra_args) build_engine(debug, version, extra_args)
} }
// Shared builder between game/engine // Shared builder between game/engine
@@ -124,6 +119,7 @@ build_common :: proc(
b := brokkr.builder_make() b := brokkr.builder_make()
brokkr.build(&b, dir) brokkr.build(&b, dir)
brokkr.append(&b, "-show-timings") brokkr.append(&b, "-show-timings")
brokkr.collection(&b, "game", ".")
brokkr.collection(&b, PROJECT_NAME, "src") brokkr.collection(&b, PROJECT_NAME, "src")
brokkr.collection(&b, "gram", "vendor/gram/src") brokkr.collection(&b, "gram", "vendor/gram/src")
brokkr.collection(&b, "wgpu", "vendor") brokkr.collection(&b, "wgpu", "vendor")
@@ -144,24 +140,6 @@ build_common :: proc(
return b 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 the engine executable
build_engine :: proc(debug: bool, version: string, extra_args: []string) { build_engine :: proc(debug: bool, version: string, extra_args: []string) {
b := build_common(ENGINE_DIR, debug, version, extra_args) 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) { run_project :: proc(extra_args: []string) {
// Build project if it wasnt already // Build project if it wasnt already
if !os.exists(ENGINE_OUTPUT) || if !os.exists(output_path(ENGINE_OUTPUT)) do build_project(true, {})
!os.exists(GAME_LIB_PATH) { build_project(true, {}) }
cmd := []string{output_path(ENGINE_OUTPUT)} cmd := []string{output_path(ENGINE_OUTPUT)}
line := slice.concatenate( line := slice.concatenate(
@@ -206,6 +183,34 @@ run_project :: proc(extra_args: []string) {
os.exit(res.exit_code) 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 // wgpu setup
+47
View File
@@ -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)
```
+35 -30
View File
@@ -7,39 +7,42 @@ import "sindri:base"
import "sindri:core" import "sindri:core"
// ----------------------------------------- // -----------------------------------------
// Types
Game_Memory :: struct { Game_Memory :: struct {
should_close: bool, should_close: bool,
} }
g: ^Game_Memory Test_Memory :: struct {
asd: int,
qwe: int,
}
// ----------------------------------------- // -----------------------------------------
// Variables
@(export) g: Game_Memory
memory :: proc() -> rawptr { t: Test_Memory
return g
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() { // Public functions
free(g)
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 { settings :: proc() -> base.Settings {
return base.Settings { return base.Settings {
width = 960, width = 960,
@@ -51,36 +54,40 @@ settings :: proc() -> base.Settings {
} }
} }
@(export)
init_once :: proc() { init_once :: proc() {
fmt.println("init once") 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() { init :: proc() {
fmt.println("hello from .dll!") fmt.println("hello from .dll!")
g = new(Game_Memory) g.should_close = false
memory_set(g)
} }
@(export)
update :: proc(dt: f32) { update :: proc(dt: f32) {
fmt.println("dt:", dt) fmt.println("dt:", dt)
if core.key_state(.Key_Escape) == .Press { if core.key_state(.Key_Escape) == .Press {
g.should_close = true 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() { destroy :: proc() {
fmt.println("bye") fmt.println("bye")
fmt.println(t.asd, t.qwe)
} }
start := time.tick_now() start := time.tick_now()
@(export)
should_close :: proc() -> bool { should_close :: proc() -> bool {
elapsed := time.tick_since(start) elapsed := time.tick_since(start)
seconds := time.duration_seconds(elapsed) seconds := time.duration_seconds(elapsed)
@@ -89,12 +96,10 @@ should_close :: proc() -> bool {
return g.should_close return g.should_close
} }
@(export)
force_reload :: proc() -> bool { force_reload :: proc() -> bool {
return core.key_state(.Key_F5) == .Press return core.key_state(.Key_F5) == .Press
} }
@(export)
force_restart :: proc() -> bool { force_restart :: proc() -> bool {
return core.key_state(.Key_F6) == .Press return core.key_state(.Key_F6) == .Press
} }
+1
View File
@@ -1,5 +1,6 @@
{ {
"collections": [ "collections": [
{ "name": "game", "path": "." },
{ "name": "sindri", "path": "src" }, { "name": "sindri", "path": "src" },
{ "name": "gram", "path": "vendor/gram/src" }, { "name": "gram", "path": "vendor/gram/src" },
{ "name": "wgpu", "path": "vendor" } { "name": "wgpu", "path": "vendor" }
+3
View File
@@ -0,0 +1,3 @@
package base
should_close: bool
-2
View File
@@ -158,8 +158,6 @@ on_event :: proc(data: Event_Data) {
drain_event_queue :: proc() { drain_event_queue :: proc() {
assert(event_bus.queue != nil) assert(event_bus.queue != nil)
fmt.println("toby!")
for &event in event_bus.queue { for &event in event_bus.queue {
#partial switch e in event.data { #partial switch e in event.data {
+259 -188
View File
@@ -1,231 +1,302 @@
package base package base
import "core:c" import "base:runtime"
import "core:dynlib" import "core:crypto/hash"
import "core:fmt" import "core:fmt"
import "core:mem"
import "core:os" import "core:os"
import "core:strings"
import "core:time" import "core:time"
when ODIN_OS == .Windows { SNAPSHOT_NAME :: "snapshot"
LIB_EXT :: ".dll" SNAPSHOT_EXTENSION :: "sndr"
} else when ODIN_OS == .Darwin {
LIB_EXT :: ".dylib"
} else {
LIB_EXT :: ".so"
}
GAME_LIB_DIR :: "out/" MAGIC_STRING :: 0x72646E73 // "rdns" -> "sndr" in little endian
GAME_LIB_PATH :: GAME_LIB_DIR + "game" + LIB_EXT 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 { Hot_Reload :: struct {
loaded_libs: [dynamic]Game_API, allocator: runtime.Allocator,
} snapshot_path: string,
snapshot_header: Snapshot_Header,
Hot_Reload_Error :: enum u8 { region_headers: map[string]Region_Header, // hash of name:size
None = 0, regions: map[string]Memory_Region, // hash of name:size
Load_Game_Lib_Failed = 1, write_full_snapshot: bool,
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
} }
// ----------------------------------------- // -----------------------------------------
// Variables
hot_reload_init :: proc() -> (hr: Hot_Reload, error: Error) { @(private)
api: Game_API hr: Hot_Reload
api_ok := load_game_lib(&api) // -----------------------------------------
if !api_ok { // Constructor & destructor
fmt.println("error: failed to load Game API")
return {}, .Load_Game_Lib_Failed 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,
)
snapshot_path_cpy := strings.clone(snapshot_path, allocator)
// 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,
} }
hr.loaded_libs = make([dynamic]Game_API, 0, 0, context.allocator) return nil
append(&hr.loaded_libs, api)
return hr, nil
} }
hot_reload_destroy :: proc(state: ^Hot_Reload) { hot_reload_destroy :: proc() {
for &api in state.loaded_libs { assert(
unload_game_lib(&api) 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(state.loaded_libs) delete(hr.region_headers)
delete(hr.regions)
} }
// ----------------------------------------- // -----------------------------------------
// Public functions
active_lib :: proc(hr: ^Hot_Reload) -> ^Game_API { register_region :: proc(
return &hr.loaded_libs[len(hr.loaded_libs) - 1] 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")
should_close :: proc(hr: ^Hot_Reload) -> bool { hash := fmt.tprintf("%v:%d", name, data_size)
api := active_lib(hr) assert(!(hash in hr.regions), "[hot_reload] double registration")
if api.lib == nil do return true hash_cpy := strings.clone(hash, hr.allocator)
return api.should_close() hr.region_headers[hash_cpy] = Region_Header {
} name_size = cast(u32)len(name),
data_size = data_size,
copy_lib :: proc(to: string) -> bool { }
copy_err := os.copy_file(to, GAME_LIB_PATH) hr.regions[hash_cpy] = Memory_Region {
if copy_err != nil { ptr_location = ptr_location,
fmt.printfln(
"error: failed to copy {0} to {1}: {2:v}",
GAME_LIB_PATH,
to,
copy_err,
)
return false
} }
return true hr.snapshot_header.region_count += 1
hr.write_full_snapshot = true
return nil
} }
// Load game lib symbols read_snapshot :: proc() -> os.Error {
load_game_lib :: proc(api: ^Game_API) -> bool { assert(hr.region_headers != nil, "[hot-reload] uninitialized")
mod_time, mod_time_err := os.last_write_time_by_name(GAME_LIB_PATH) assert(hr.regions != nil, "[hot-reload] uninitialized")
if mod_time_err != os.ERROR_NONE {
fmt.printfln( if !os.exists(hr.snapshot_path) {
"error: failed getting last write time of {0}, error code: {1}", hr.write_full_snapshot = true
GAME_LIB_PATH, return nil
mod_time_err,
)
return false
} }
api.modification_time = mod_time
lib_name := fmt.tprintf( snapshot := os.open(hr.snapshot_path, {.Read}) or_return
GAME_LIB_DIR + "game_{0}" + LIB_EXT, defer os.close(snapshot)
api.api_version,
)
copy_lib(lib_name) or_return
// Match the names of the fields in Game_API to symbols in the game DLL offset: i64
_, ok := dynlib.initialize_symbols(api, lib_name, "", "lib")
if !ok { // Read snapshot header
fmt.printfln( snapshot_header: Snapshot_Header
"error: failed initializing symbols: {0}", header_bytes := mem.ptr_to_bytes(&snapshot_header)
dynlib.last_error(), 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(&region_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,
)
region := &hr.regions[key]
region.digest = digest
// 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
}
// Next write will be a full memory snapshot
set_write_full_snapshot :: proc() {
hr.write_full_snapshot = 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")
snapshot: ^os.File
if hr.write_full_snapshot || !os.exists(hr.snapshot_path) {
snapshot = os.create(hr.snapshot_path) or_return
} else { } else {
// Odin's shared libraries don't register a constructor, so the lib's snapshot = os.open(hr.snapshot_path, {.Read, .Write}) or_return
// runtime init (@init procs like os.stdout setup) never runs on load. }
// Call it explicitly. defer os.close(snapshot)
// Windows doesn't need this: its dynamic build exports DllMain, which
// the OS calls on load and which runs _startup_runtime itself. offset: i64
when ODIN_OS != .Windows {
Entry :: #type proc "c" () -> c.int // Write header
entry_ptr, entry_ok := dynlib.symbol_address( if hr.write_full_snapshot {
api.lib, header_bytes := mem.ptr_to_bytes(&hr.snapshot_header)
"_odin_entry_point", n := os.write_at(snapshot, header_bytes, offset) or_return
}
offset += size_of(Snapshot_Header)
for key, &region 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
}
offset += size_of(Region_Header)
// 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 entry_ok do (transmute(Entry)entry_ptr)() 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
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,
)
}
}
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
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
}
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)
} else { } else {
// This does a full reset. That's basically like opening and delete(digest, hr.allocator)
// 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.
api.memory_free()
for &g in hr.loaded_libs {
unload_game_lib(&g)
} }
clear(&hr.loaded_libs) offset += cast(i64)header.data_size
new_api.init()
} }
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
} }
+18
View File
@@ -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,
}
+34 -15
View File
@@ -3,6 +3,7 @@ package sindri
import "core:fmt" import "core:fmt"
import "core:time" import "core:time"
import "game:game"
import "sindri:base" import "sindri:base"
import "sindri:platform" import "sindri:platform"
@@ -14,8 +15,13 @@ main :: proc() {
fmt.println("hello world!", VERSION) fmt.println("hello world!", VERSION)
// Hot reload development functionality // Hot reload development functionality
hr, err := base.hot_reload_init() base.hot_reload_init(VERSION)
defer base.hot_reload_destroy(&hr) defer base.hot_reload_destroy()
// Load game API & settings
api := game.api()
defer api.destroy()
settings := api.settings()
// Event system // Event system
base.event_bus_init() base.event_bus_init()
@@ -23,8 +29,9 @@ main :: proc() {
base.event_subscribe(base.Key_Press_Event, test) base.event_subscribe(base.Key_Press_Event, test)
api := base.active_lib(&hr) // Listen for OS signals (SIGINT, SIGTERM)
settings := api.settings() platform.signals_init()
defer platform.signals_destroy()
// Initialize Window // Initialize Window
platform.os_init(settings) platform.os_init(settings)
@@ -36,29 +43,41 @@ main :: proc() {
defer platform.instance_destroy() defer platform.instance_destroy()
api.init_once() api.init_once()
base.read_snapshot()
api.init() api.init()
gt: f64 = 0 dt: f64
dt: f32 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() start := time.tick_now()
platform.os_poll_events() platform.os_poll_events()
base.drain_event_queue() 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))) dt = time.duration_seconds(time.tick_since(start))
gt += f64(dt) gt += dt
st += dt
// Hot reload if st > 5 {
api, err = base.reload_game_lib(&hr) st -= 5
if err != nil do break base.set_write_full_snapshot()
} }
api.destroy() if api.force_reload() do base.force_reload()
if api.force_restart() do base.force_restart()
}
} }
test :: proc(e: ^base.Key_Press_Event, _: rawptr) -> bool { test :: proc(e: ^base.Key_Press_Event, _: rawptr) -> bool {
+3 -1
View File
@@ -1,5 +1,6 @@
package platform package platform
import "base:intrinsics"
import "core:fmt" import "core:fmt"
import "core:strings" import "core:strings"
import "vendor:glfw" import "vendor:glfw"
@@ -113,7 +114,8 @@ os_set_callback_proc :: proc(window: rawptr) {
} }
os_should_close :: proc() -> bool { 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) { os_set_should_close :: proc(val: bool) {
+48
View File
@@ -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")
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ resize :: proc "c" () {
wgpu.SurfaceConfigure(state.surface, &state.config) wgpu.SurfaceConfigure(state.surface, &state.config)
} }
frame :: proc "c" (dt: f32) { frame :: proc "c" () {
context = state.ctx context = state.ctx
surface_texture := wgpu.SurfaceGetCurrentTexture(state.surface) surface_texture := wgpu.SurfaceGetCurrentTexture(state.surface)