Author SHA1 Message Date
Riyyi 3549b0be95 Initial version of single binary hot-reloading 2026-10-12 11:11:51 +02:00
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
} }
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"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 {
+265 -194
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 {
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)
}
// ----------------------------------------- // -----------------------------------------
// Constructor & destructor
active_lib :: proc(hr: ^Hot_Reload) -> ^Game_API { hot_reload_init :: proc(
return &hr.loaded_libs[len(hr.loaded_libs) - 1] app_version: string,
} allocator := context.allocator,
) -> os.Error {
should_close :: proc(hr: ^Hot_Reload) -> bool { // Get snapshot path
api := active_lib(hr) out := os.get_executable_directory(allocator) or_return
if api.lib == nil do return true defer delete(out, allocator)
return api.should_close() snapshot_path := fmt.tprintf(
} "%v/%v.%v",
out,
copy_lib :: proc(to: string) -> bool { SNAPSHOT_NAME,
copy_err := os.copy_file(to, GAME_LIB_PATH) SNAPSHOT_EXTENSION,
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,
) )
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 // Copy version string
_, ok := dynlib.initialize_symbols(api, lib_name, "", "lib") version: [APP_VERSION_SIZE]u8
if !ok { n := min(len(app_version), APP_VERSION_SIZE)
fmt.printfln( copy(version[:n], app_version[:n])
"error: failed initializing symbols: {0}",
dynlib.last_error(), 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(&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,
) )
} else { region := &hr.regions[key]
// Odin's shared libraries don't register a constructor, so the lib's region.digest = digest
// 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)()
}
}
return ok // Write snapshot memory to the memory region
} dest := mem.byte_slice(
region.ptr_location,
// Unload game lib, used during full restarts cast(int)region_header.data_size,
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,
) )
copy(dest, data_bytes)
} }
return nil
} }
reload_game_lib :: proc(hr: ^Hot_Reload) -> (lib: ^Game_API, err: Error) { // Next write will be a full memory snapshot
api := active_lib(hr) set_write_full_snapshot :: proc() {
force_reload := api.force_reload() hr.write_full_snapshot = true
force_restart := api.force_restart() }
reload := force_reload || force_restart
lib_mod, lib_mod_err := os.last_write_time_by_name(GAME_LIB_PATH) // Write memory snapshot, save on writes by diff-ing
// TODO: Handle error write_snapshot :: proc() -> os.Error {
if lib_mod_err == os.ERROR_NONE && api.modification_time != lib_mod { assert(hr.region_headers != nil, "[hot-reload] uninitialized")
reload = true assert(hr.regions != nil, "[hot-reload] uninitialized")
}
if !reload do return api, nil snapshot: ^os.File
if hr.write_full_snapshot || !os.exists(hr.snapshot_path) {
new_api: Game_API snapshot = os.create(hr.snapshot_path) or_return
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 snapshot = os.open(hr.snapshot_path, {.Read, .Write}) or_return
// closing the game, without having to restart the executable. }
// defer os.close(snapshot)
// 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() offset: i64
for &g in hr.loaded_libs { // Write header
unload_game_lib(&g) 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, &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
} }
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
} }
+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,
}
+35 -16
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()
}
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 { 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)