Author SHA1 Message Date
Riyyi 3549b0be95 Initial version of single binary hot-reloading 2026-10-12 11:11:51 +02:00
Riyyi 089ee28682 Add naming conventions to docs 2026-09-28 20:50:59 +02:00
Riyyi 9ead998a3b Change build scripts to Odin via brokkr 2026-09-28 20:50:59 +02:00
Riyyi bf668d6177 Add nobuild dependency 2026-09-27 10:58:53 +02:00
Riyyi f85ad5e98f Fix loop 2026-09-20 18:33:41 +02:00
Riyyi cbc320b7f5 Add event listener subscribe/unsubsribe system 2026-09-20 18:26:09 +02:00
Riyyi 22be96ee45 Refactor modules architecture 2026-09-17 22:09:05 +02:00
Riyyi eb495f67c2 Random stuff 2026-09-17 21:26:19 +02:00
Riyyi 5cc4f9c7f5 Fix Linux dynamic library init 2026-09-17 21:25:16 +02:00
Riyyi b436c4dff2 Add Windows build script 2026-09-17 21:24:23 +02:00
Riyyi 43bd34a505 Add odin.md doc 2026-09-17 21:21:00 +02:00
28 changed files with 1615 additions and 642 deletions
+7 -2
View File
@@ -2,7 +2,12 @@
.DS_Store
build
build.exe
build.exe.old
build.old
# Directories
build
vendor/wgpu
out/
vendor/wgpu/
+4 -1
View File
@@ -1,3 +1,6 @@
[submodule "sindri/vendor/gram"]
[submodule "vendor/brokkr"]
path = vendor/brokkr
url = https://codeberg.org/chivvii/brokkr.git
[submodule "vendor/gram"]
path = vendor/gram
url = https://github.com/riyyi/gram.git
+8 -4
View File
@@ -10,14 +10,18 @@ Game engine.
### Dependencies
- `git`
- `odin`
- `sh` a POSIX compatible shell
- `unzip` on macOS or Linux
### Building
```sh
./build.sh
./build.sh debug
./build.sh # debug game and engine (default)
./build.sh -debug # debug game and engine
./build.sh -release # build game and engine
./build.sh -run # run game
./build.sh -fmt # format sources
```
## Contributing
@@ -35,7 +39,7 @@ vendor
- miniaudio
- microui (temp)
- box3d?
- build.sh -> build.odin
- build.sh -> bootstrap shim for `compile/build.odin`
## Hot Reload
+12
View File
@@ -0,0 +1,12 @@
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot
$exe = Join-Path $PSScriptRoot "build.exe"
if (-not (Test-Path $exe)) {
& odin build compile/build.odin -file ("-out:" + $exe)
}
& $exe @args
Set-Location -
+5 -50
View File
@@ -2,57 +2,12 @@
set -eu
PROJECT="sindri"
VERSION="dev-$(date -u '+%Y-%m-%d')-$(git rev-parse --short HEAD)"
cd -- "$(dirname -- "$0")"
OPTION="${1:-}"
shift
# ------------------------------------------
# Setup compiled wgpu binary
./scripts/wgpu-init.sh
# ------------------------------------------
# Game compile
mkdir -p build
# Game.dll
odin build game/ -show-timings \
-collection:sindri=src \
-build-mode:dynamic \
-out:build/game_tmp -microarch:native -define:VERSION="$VERSION-debug" -debug "$@"
# Need to use a temp file on Linux/macOS because it first writes an empty file,
# which the engine will load before it is actually fully written.
[ "$(uname -s)" = "Darwin" ] && LIB_EXT="dylib" || LIB_EXT="so"
[ -f "build/game_tmp.$LIB_EXT" ] || {
echo "error: build produced no game lib.$LIB_EXT" >&2
exit 1
}
mv -f "build/game_tmp.$LIB_EXT" "build/game.$LIB_EXT"
# ------------------------------------------
# Engine compile
# If the executable is already running, then don't try to build and start it.
if pgrep -x $PROJECT >/dev/null; then
echo "Hot reloading..."
exit 0
if [ ! -x ./build ]; then
odin build compile/build.odin -file -out:build
fi
if [ "$OPTION" = "debug" ]; then
odin build src/ -show-timings \
-collection:sindri=src \
-collection:gram=vendor/gram/src \
-collection:wgpu=vendor \
-out:build/$PROJECT -microarch:native -use-separate-modules -define:VERSION="$VERSION-debug" -debug "$@"
exit 0
fi
./build "$@"
odin build src/ -show-timings \
-collection:sindri=src \
-collection:gram=vendor/gram/src \
-collection:wgpu=vendor \
-out:build/$PROJECT -microarch:native -o:speed -define:VERSION="$VERSION" "$@"
cd - > /dev/null
+635
View File
@@ -0,0 +1,635 @@
package build
import "core:flags"
import "core:fmt"
import "core:log"
import "core:os"
import "core:path/filepath"
import "core:slice"
import "core:strings"
import "core:time"
import "../vendor/brokkr"
when ODIN_OS == .Windows {
LIB_EXT :: ".dll"
OS_NAME :: "windows"
} else when ODIN_OS == .Darwin {
LIB_EXT :: ".dylib"
OS_NAME :: "macos"
} else {
LIB_EXT :: ".so"
OS_NAME :: "linux"
}
when ODIN_ARCH == .amd64 {
ARCH_NAME :: "x86_64"
} else when ODIN_ARCH == .arm64 {
ARCH_NAME :: "aarch64"
}
PROJECT_NAME :: "sindri"
BUILD_DIRECTORY :: "out/"
GAME_SRC_DIR :: "game/"
ENGINE_DIR :: "src/"
ENGINE_OUTPUT :: BUILD_DIRECTORY + PROJECT_NAME
BUILD_INPUT_FILES :: []string{"compile/build.odin"}
// -----------------------------------------
// Types
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."`,
pre_commit: bool `args:"optional" usage:"Run git pre-commit hook."`,
overflow: [dynamic]string `args:"manifold" usage:"Extra arguments passed through to the odin build calls."`,
}
// -----------------------------------------
// Main
main :: proc() {
// Create logger
context.logger = log.create_console_logger(
.Warning,
{.Level, .Terminal_Color},
"build",
)
defer log.destroy_console_logger(context.logger)
// Change working directory
chdir_project_root()
brokkr.auto_rebuild(..BUILD_INPUT_FILES)
opts: Options
flags.parse_or_exit(&opts, os.args, .Unix)
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()
// Fallback to building project, if no option provided
if len(os.args) - 1 == len(opts.overflow) {
build_project(true, opts.overflow[:])
}
}
// -----------------------------------------
// Build project
// Build the game library ('game/') and the engine ('src/')
build_project :: proc(debug: bool, extra_args: []string) {
version := project_version()
// Setup compiled wgpu binary
wgpu_init()
// ----------------------------------------
// Engine compile
// If the executable is already running, don't try to build and start it
if engine_is_running() {
fmt.println("Hot reloading...")
return
}
build_engine(debug, version, extra_args)
}
// Shared builder between game/engine
build_common :: proc(
dir: string,
debug: bool,
version: string,
extra_args: []string,
) -> brokkr.Builder {
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")
brokkr.append(&b, "-microarch:native")
brokkr.opt(&b, debug ? .Minimal : .Speed)
brokkr.define(
&b,
"VERSION",
debug ? fmt.tprintf("%v-debug", version) : version,
)
if debug {
brokkr.debug(&b)
brokkr.append(&b, "-use-separate-modules")
}
for arg in extra_args {
brokkr.append(&b, arg)
}
return b
}
// Build the engine executable
build_engine :: proc(debug: bool, version: string, extra_args: []string) {
b := build_common(ENGINE_DIR, debug, version, extra_args)
defer brokkr.builder_destroy(&b)
brokkr.out(&b, output_path(ENGINE_OUTPUT))
if err := brokkr.execute(&b); err != nil {
os.exit(1)
}
}
run_project :: proc(extra_args: []string) {
// Build project if it wasnt already
if !os.exists(output_path(ENGINE_OUTPUT)) do build_project(true, {})
cmd := []string{output_path(ENGINE_OUTPUT)}
line := slice.concatenate(
[][]string{cmd, extra_args},
context.temp_allocator,
)
process, s_err := os.process_start(
{
command = line,
stdin = os.stdin,
stdout = os.stdout,
stderr = os.stderr,
},
)
if s_err != nil {
die("failed to start project process: %v", os.error_string(s_err))
}
res, w_err := os.process_wait(process)
if w_err != nil {
die("failed to wait for project process: %v", os.error_string(w_err))
}
if !res.success {
die("project process failed with exit code: %v", 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
// Prepare wgpu for usage: download, unzip, copy vendor code
wgpu_init :: proc(version_override := "") {
when ODIN_OS == .Windows {
target_name :=
"wgpu-" + OS_NAME + "-" + ARCH_NAME + "-msvc" + "-release"
} else {
target_name := "wgpu-" + OS_NAME + "-" + ARCH_NAME + "-release"
}
target := join_path({"vendor", "wgpu", "lib", target_name})
// ----------------------------------------
// wgpu version detection
// Detect the wgpu-native version the bundled bindings expect
odin_root, ok, _ := exec([]string{"odin", "root"})
if !ok do os.exit(1)
defer delete(odin_root)
wgpu_version := version_override
if len(wgpu_version) == 0 {
v, found := bindings_version_from_file(
join_path({odin_root, "vendor", "wgpu", "wgpu_native_types.odin"}),
)
if !found {
die("wgpu setup: could not detect the wgpu-native version")
}
wgpu_version = fmt.tprintf("v%v", v)
}
// Detect the version of the vendored wgpu bindings
current := ""
if wgpu_types_path := join_path(
{"vendor", "wgpu", "wgpu_native_types.odin"},
); os.is_file(wgpu_types_path) {
if ver, found := bindings_version_from_file(wgpu_types_path); found {
current = fmt.tprintf("v%v", ver)
}
}
// Check if the vendored wgpu is already up to date
if current == wgpu_version && os.is_dir(target) {
return
}
// ----------------------------------------
// Copy vendor/wgpu
// Remove any stale copy.
os.remove_all("vendor/wgpu")
// Copy wgpu from the odin installation.
os.copy_directory_all(
"vendor/wgpu",
join_path({odin_root, "vendor", "wgpu"}),
)
chmod_recursively("vendor/wgpu")
// Point imports at the copied vendor wgpu package.
rewrite_imports("vendor/wgpu", `"vendor:wgpu"`, `"wgpu:wgpu"`)
// ----------------------------------------
// Download
if !os.is_dir(target) {
url := fmt.tprintf(
"https://github.com/gfx-rs/wgpu-native/releases/download/%v/%v.zip",
wgpu_version,
target_name,
)
fmt.println("wgpu setup: downloading", url)
tmp_dir, err := os.make_directory_temp(
"",
"out-tmp-*",
context.temp_allocator,
)
if err != nil {
die(
"wgpu setup: failed to create a temporary directory: %v",
os.error_string(err),
)
}
defer os.remove_all(tmp_dir)
zip_path := join_path({tmp_dir, "wgpu.zip"})
when ODIN_OS == .Windows {
if dl_err := brokkr.execute_command(
[]string {
"powershell",
"-NoProfile",
"-Command",
fmt.tprintf(
"$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -UseBasicParsing -Uri %q -OutFile %q",
url,
zip_path,
),
},
); dl_err != nil {
die("wgpu setup: failed to download wgpu-native release")
}
} else {
if dl_err := brokkr.execute_command(
[]string{"curl", "-fL", "--retry", "3", "-o", zip_path, url},
); dl_err != nil {
die("wgpu setup: failed to download wgpu-native release")
}
}
when ODIN_OS == .Windows {
// Uses the PowerShell cmdlet the same way scripts/wgpu-init.ps1 did.
if ex_err := brokkr.execute_command(
[]string {
"powershell",
"-NoProfile",
"-Command",
fmt.tprintf(
"Expand-Archive -Force -Path %q -DestinationPath %q",
zip_path,
target,
),
},
); ex_err != nil {
die("wgpu setup: failed to extract wgpu-native release")
}
} else {
// Matches scripts/wgpu-init.sh.
if ex_err := brokkr.execute_command(
[]string{"unzip", "-o", "-q", zip_path, "-d", target},
); ex_err != nil {
die("wgpu setup: failed to extract wgpu-native release")
}
}
}
}
// Extracts the wgpu-native version from BINDINGS_VERSION_STRING
bindings_version_from_file :: proc(
path: string,
) -> (
version: string,
found: bool,
) {
if !os.is_file(path) do return
data, err := os.read_entire_file(path, context.temp_allocator)
if err != nil do return
s := string(data)
id := strings.index(s, "BINDINGS_VERSION_STRING")
if id == -1 do return
quote_start := strings.index(s[id:], `"`)
if quote_start == -1 do return
quote_start += id
quote_end := strings.index(s[quote_start + 1:], `"`)
if quote_end == -1 do return
quote_end += quote_start + 1
version = s[quote_start + 1:quote_end]
found = len(version) != 0
return
}
// 'chmod -R +w' equivalent: makes everything under 'path' writable,
// keeping every other permission bit
chmod_recursively :: proc(path: string) {
when ODIN_OS == .Windows {
return
} else {
info, err := os.stat(path, context.temp_allocator)
if err != nil {
die(
"wgpu setup: failed to stat %q: %v",
path,
os.error_string(err),
)
}
// Add writable (+w)
if err := os.change_mode(path, info.mode + os.Permissions_Write_All);
err != nil {
die("wgpu setup: chmod %q failed: %v", path, os.error_string(err))
}
// Recurse into directories
if info.type == .Directory {
entries, read_err := os.read_directory_by_path(
path,
-1,
context.temp_allocator,
)
if read_err != nil {
die(
"wgpu setup: failed to read %q: %v",
path,
os.error_string(read_err),
)
}
for entry in entries {
if entry.name == "." || entry.name == ".." do continue
chmod_recursively(join_path({path, entry.name}))
}
}
}
}
// Replaces 'old_import' with 'new_import' in every '.odin' file under 'path'
rewrite_imports :: proc(path: string, old_import, new_import: string) {
info, err := os.stat(path, context.temp_allocator)
if err != nil {
die("wgpu setup: failed to stat %q: %v", path, os.error_string(err))
}
// Recurse into directories
if info.type == .Directory {
entries, read_err := os.read_directory_by_path(
path,
-1,
context.temp_allocator,
)
if read_err != nil {
die(
"wgpu setup: failed to read %q: %v",
path,
os.error_string(read_err),
)
}
for entry in entries {
if entry.name == "." || entry.name == ".." do continue
rewrite_imports(
join_path({path, entry.name}),
old_import,
new_import,
)
}
return
}
if info.type != .Regular || !strings.ends_with(info.name, ".odin") do return
data, read_err := os.read_entire_file(path, context.temp_allocator)
if read_err != nil {
die(
"wgpu setup: failed to read %q: %v",
path,
os.error_string(read_err),
)
}
replacement, was_allocation := strings.replace_all(
string(data),
old_import,
new_import,
context.temp_allocator,
)
if was_allocation {
if write_err := os.write_entire_file(path, replacement);
write_err != nil {
die(
"wgpu setup: failed to write %q: %v",
path,
os.error_string(write_err),
)
}
}
}
// -----------------------------------------
// Formatting
// Run `odinfmt -w` on source directory
format_source :: proc() {
for dir in ([]string{GAME_SRC_DIR, ENGINE_DIR}) {
err := brokkr.execute_command([]string{"odinfmt", "-w", dir})
if err != nil do os.exit(1)
}
}
// -----------------------------------------
// Git pre-commit hook
pre_commit :: proc() {
err := brokkr.execute_command(
[]string{"git", "stash", "push", "--keep-index", "--quiet"},
)
if err != nil do os.exit(1)
format_source()
err = brokkr.execute_command([]string{"git", "diff", "--quiet"})
if err != nil {
log.error("formatting changed staged files; commit aborted")
_ = brokkr.execute_command([]string{"git", "checkout", "--", "."})
_ = brokkr.execute_command([]string{"git", "stash", "pop", "--quiet"})
os.exit(1)
}
err = brokkr.execute_command([]string{"git", "stash", "pop", "--quiet"})
if err != nil do os.exit(1)
}
// -----------------------------------------
// Helpers
// Log an error and exit(1)
die :: proc(fmt_str: string, args: ..any, loc := #caller_location) -> ! {
log.errorf(fmt_str, ..args, location = loc)
os.exit(1)
}
// Change working directory to project root
chdir_project_root :: proc() {
// Get full path
binary_path, abs_err := filepath.abs(os.args[0])
if abs_err != nil {
die(
"failed to get absolute binary path %q: %v",
os.args[0],
os.error_string(abs_err),
)
}
// Set working directory to project root
project_root_dir := filepath.dir(binary_path)
if err := os.set_working_directory(project_root_dir); err != nil {
die(
"failed to change working directory to %q: %v",
project_root_dir,
os.error_string(err),
)
}
// Set program argument to the full path of the binary
os.args[0] = binary_path
}
// Check if engine (executable named `Project_Name`) is already running
engine_is_running :: proc() -> bool {
when ODIN_OS == .Windows {
out, ok := exec(
[]string {
"tasklist",
"/FI",
("IMAGENAME eq " + PROJECT_NAME + ".exe"),
"/NH",
},
)
if !ok do return false
return strings.contains(string(out), PROJECT_NAME + ".exe")
} else {
_, ok, code := exec([]string{"pgrep", "-x", PROJECT_NAME}, false)
if !ok do return false
return code == 0
}
}
// Runs a command. Use this over brokkr.execute_command when you need to
// capture the contents of stdout, or the return code
exec :: proc(
cmd: []string,
print_error := true,
) -> (
out: string,
ok: bool,
code: int,
) {
state, stdout, stderr, err := os.process_exec(
{command = cmd},
context.temp_allocator,
)
defer delete(stdout, context.temp_allocator)
defer delete(stderr, context.temp_allocator)
if err != nil {
log.errorf("exec failed %v: %v", cmd, os.error_string(err))
return "", false, 1
}
if state.exit_code != 0 {
if print_error {
log.errorf("exec failed %v: %s", cmd, stderr)
}
return "", false, state.exit_code
}
str, a_err := strings.clone_from_bytes(stdout)
if a_err != nil {
return "", false, 1
}
return strings.trim_space(str), true, 0
}
// Joins segments into a full path, ex: {"a", "b", "c"} -> a/b/c
join_path :: proc(elems: []string) -> string {
joined, err := filepath.join(elems, context.temp_allocator)
if err != nil {
die("failed to join path %v: %v", elems, os.error_string(err))
}
return joined
}
output_path :: proc(binary_path: string) -> string {
when ODIN_OS == .Windows {
return binary_path + ".exe"
}
return binary_path
}
// Get project version, example: `dev-2026-09-23-a4084b3`
project_version :: proc() -> string {
commit, ok, _ := exec([]string{"git", "rev-parse", "--short", "HEAD"})
if !ok do os.exit(1)
defer delete(commit)
buf: [time.MIN_YYYY_DATE_LEN]u8
date := time.to_string_yyyy_mm_dd(time.now(), buf[:])
return fmt.tprintf("dev-%v-%v", date, commit)
}
+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)
```
+93
View File
@@ -0,0 +1,93 @@
# Odin
This document contains specifics about the workings of the Odin programming
language, dynamic linking and state management design patterns. These are
tested and validated findings, for continued reference by me.
## Compiler
Odin's compiler is demand-driver: a procedure's body is only semantically
analyzed when the proc is actually referenced. They are syntactically checked
always.
## Dynamic Library Memory
When a program loads in a dynamic library, what happens with package globals?
Findings (macOS):
- Package globals are shared between modules, but only indirectly
- This is due to the dynamic linker merging symbols into a (single) flat namespace
- Practically this means the lib's unset package globals get shadowed by the hosts'
- After hot reloading the lib, the memory of the packge is not refreshed,
it keeps pointing to the package global of the host
Findings (Windows):
- Package globals are not shared between modules, they have their own copy
Findings (Linux):
- Package globals are not shared between modules, they have their own copy
Conclusion: avoid package globals, or pass pointers to the dynamic library so
it can match its package globals with the host module.
## Dependency Patterns
1. Strict layering, only call downwards. If two modules need each other, one of
the modules is in a wrong layer. Move it, or extract the shared part into a
lower layer.
2. Dependency inversion: if required, define an interface in the lower layer,
upper layer registers itself.
3. Dependency Injection: Prevent dependencies via data, over includes. Pass what
a function needs as arguments instead of reaching another module's state.
4. Events instead of callbacks both ways If A and B would call each other,
have A push events into a queue and B consume them. Neither module includes
the other; they only share the event type (which lives in a lower layer).
5. Merge or split as a last resort, if two modules genuinely can't be untangled.
Sindri Engine:
```
+---------------------+ Game:
| Game | - Game
+---------------------+
|
v
+---------------------+ +---------------------+ Core:
| Core |<----+----| main | - Input
+---------------------+ | +---------------------+
| |
v |
+---------------------+ | Platform:
| Platform |<----+ - GLFW
+---------------------+ | - wgpu
| |
v |
+---------------------+ | Base:
| Base |<----+ - Hot Reload
+---------------------+ - Settings
- Event
- Keycodes
```
## Logging strategy
From the docs:
> While it is ok for simple apps to use the core:fmt package, libraries and
> complex apps should prefer the core:log package. By using the implicit logger
> library and application authors allow the caller to decide how to process log
> messages.
## Naming Conventions
From core.odin:
> In general, Ada_Case for types and snake_case for values
>
> - Package Name: snake_case (but prefer single word)
> - Import Name: snake_case (but prefer single word)
> - Types: Ada_Case
> - Enum Values: Ada_Case
> - Procedures: snake_case
> - Local Variables: snake_case
> - Constant Variables: SCREAMING_SNAKE_CASE
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
set -e
odinfmt -w src/ > /dev/null
# odinfmt -w tests/ > /dev/null
+44 -40
View File
@@ -1,85 +1,93 @@
package game
import "sindri:input"
import "core:time"
import "core:fmt"
import "core:time"
import "sindri:base"
import "sindri:core"
// -----------------------------------------
// Types
Game_Memory :: struct {
should_close: bool
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() -> core.Settings {
return core.Settings {
settings :: proc() -> base.Settings {
return base.Settings {
width = 960,
height = 540,
title = "WGPU Native Triangle",
mode = core.WindowMode.Windowed,
mode = base.WindowMode.Windowed,
refresh = 60,
vsync = true,
}
}
@(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)
g.should_close = false
}
@(export)
update :: proc(dt: f32) {
fmt.println("dt:", dt)
if input.key_state(.Key_Escape) == .Press {
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)
@@ -88,14 +96,10 @@ should_close :: proc() -> bool {
return g.should_close
}
@(export)
force_reload :: proc() -> bool {
// TODO: read keypress
return false
return core.key_state(.Key_F5) == .Press
}
@(export)
force_restart :: proc() -> bool {
// TODO: read keypress
return false
return core.key_state(.Key_F6) == .Press
}
+2 -1
View File
@@ -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" }
]
}
+3 -14
View File
@@ -1,18 +1,7 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
git stash push --keep-index --quiet
"$SCRIPT_DIR/../fmt.sh"
if ! git diff --quiet; then
echo "Formatting changed staged files; commit aborted."
git checkout -- "$SCRIPT_DIR/.."
git stash pop --quiet
exit 1
fi
# "$SCRIPT_DIR/../test.sh"
git stash pop --quiet
exec "$SCRIPT_DIR/../build.sh" -pre-commit
-100
View File
@@ -1,100 +0,0 @@
#!/bin/sh
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
VENDOR_DIR="$SCRIPT_DIR/../vendor"
# ------------------------------------------
# Platform detection
# OS detection
case "$(uname -s)" in
Darwin)
os="macos"
;;
Linux)
os="linux"
;;
MINGW* | MSYS* | CYGWIN*)
os="windows"
extra="-msvc"
;;
*)
echo "wgpu setup: unsupported OS '$(uname -s)'" >&2
exit 1
;;
esac
# Arch detection
case "$(uname -m)" in
arm64 | aarch64)
arch="aarch64"
;;
x86_64 | amd64)
arch="x86_64"
;;
*)
echo "wgpu setup: unsupported arch '$(uname -m)'" >&2
exit 1
;;
esac
# Download filename
name="wgpu-${os}-${arch}${extra:-}-release"
# Compiler filename: lib/wgpu-macos-aarch64-release/lib/libwgpu_native.a
target="$VENDOR_DIR/wgpu/lib/$name"
# ------------------------------------------
# WGPU version detection
# Detect the wgpu-native version the bundled bindings expect, can be overridden
if [ "$1" != "" ]; then
WGPU_VERSION="$1"
else
WGPU_VERSION="v$(sed -n 's/.*BINDINGS_VERSION_STRING :: "\([^"]*\)".*/\1/p' "$(odin root)/vendor/wgpu/wgpu_native_types.odin")"
fi
if [ "$WGPU_VERSION" = "v" ]; then
echo "wgpu setup: could not detect the wgpu-native version" >&2
exit 1
fi
# Detect the version of the vendored wgpu bindings
current="v$(sed -n 's/.*BINDINGS_VERSION_STRING :: "\([^"]*\)".*/\1/p' "$VENDOR_DIR/wgpu/wgpu_native_types.odin" 2> /dev/null)"
# Check if the vendored wgpu is already up to date
if [ "$current" = "$WGPU_VERSION" ] && [ -d "$target" ]; then
exit 0
fi
# ------------------------------------------
# Copy vendor/wgpu
# Remove any stale copy
rm -rf "$VENDOR_DIR/wgpu"
# Copy wgpu
wgpu_path="$(odin root)/vendor/wgpu"
cp -r "$wgpu_path" "$VENDOR_DIR"
chmod -R +w "$VENDOR_DIR/wgpu"
# Point imports at the copied vendor wgpu package
grep -rl '"vendor:wgpu"' "$VENDOR_DIR/wgpu" --include='*.odin' |
while IFS= read -r file; do
sed 's|"vendor:wgpu"|"wgpu:wgpu"|g' "$file" >"$file.tmp" && mv "$file.tmp" "$file"
done
# ------------------------------------------
# Download
ASSET_BASE="https://github.com/gfx-rs/wgpu-native/releases/download/${WGPU_VERSION}"
# Download
if [ ! -d "$target" ]; then
url="${ASSET_BASE}/${name}.zip"
echo "wgpu setup: downloading $url"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
curl -fL --retry 3 -o "$tmp/wgpu.zip" "$url"
unzip -o -q "$tmp/wgpu.zip" -d "$target"
fi
+3
View File
@@ -0,0 +1,3 @@
package base
should_close: bool
+266
View File
@@ -0,0 +1,266 @@
package base
import "base:intrinsics"
import "base:runtime"
import "core:fmt"
// -----------------------------------------
// Types
// event category, bitfield (?)
Event_Data :: union {
Window_Close_Event,
Window_Resize_Event,
Joystick_Connect_Event,
Joystick_Disconnect_Event,
Key_Press_Event,
Key_Release_Event,
Key_Repeat_Event,
Mouse_Button_Press_Event,
Mouse_Button_Release_Event,
Mouse_Position_Event,
Mouse_Scroll_Event,
}
Event :: struct {
data: Event_Data,
handled: bool,
}
Window_Close_Event :: struct {}
Window_Resize_Event :: struct {
width: i32,
height: i32,
}
Key_Press_Event :: struct {
key: Key,
mods: Mod_Set,
}
Key_Release_Event :: struct {
key: Key,
mods: Mod_Set,
}
Key_Repeat_Event :: struct {
key: Key,
mods: Mod_Set,
}
Mouse_Button_Press_Event :: struct {
button: Mouse_Button,
mods: Mod_Set,
}
Mouse_Button_Release_Event :: struct {
button: Mouse_Button,
mods: Mod_Set,
}
Mouse_Position_Event :: struct {
x_pos: f32,
y_pos: f32,
}
Mouse_Scroll_Event :: struct {
x_offset: f32,
y_offset: f32,
}
Joystick_Connect_Event :: struct {
id: i32,
}
Joystick_Disconnect_Event :: struct {
id: i32,
}
// Message Receiver
@(private)
Listener :: struct {
callback: proc(event: ^Event_Data, user_ptr: rawptr) -> bool, // return true = don't propagate
user_ptr: rawptr,
// TODO: test if hot_reload keeps the same function pointers and the listeners remain working.
// if they dont add a flag "engine_listener: bool", which engine calls should set true,
// then clear all game listeners
}
// Transfers messages between Sender and Receiver
Event_Bus :: struct {
queue: [dynamic]Event,
listeners: map[typeid][dynamic]Listener,
allocator: runtime.Allocator,
}
// -----------------------------------------
// Variables
event_bus: Event_Bus
// -----------------------------------------
// Constructor/destructor
event_bus_init :: proc(allocator := context.allocator) {
// make() doesnt allocate until there's capacity, so set it > 0
queue := make([dynamic]Event, 0, 4, allocator)
listeners := make(map[typeid][dynamic]Listener, 4, allocator)
event_bus = {
queue = queue,
listeners = listeners,
allocator = allocator,
}
}
event_bus_destroy :: proc() {
assert(event_bus.queue != nil)
assert(event_bus.listeners != nil)
for _, ls in event_bus.listeners do delete(ls)
delete(event_bus.queue)
delete(event_bus.listeners)
event_bus.queue = nil
event_bus.listeners = nil
}
// -----------------------------------------
// Public functions
event_subscribe :: proc(
$T: typeid,
callback: proc(event: ^T, user_ptr: rawptr) -> bool,
user_ptr: rawptr = nil,
) where intrinsics.type_is_variant_of(Event_Data, T) {
cb := cast(proc(_: ^Event_Data, _: rawptr) -> bool)callback // upcast
event_subscribe_raw(T, cb, user_ptr)
}
event_unsubscribe :: proc(
$T: typeid,
callback: proc(event: ^T, user_ptr: rawptr) -> bool,
user_ptr: rawptr = nil,
) where intrinsics.type_is_variant_of(Event_Data, T) {
cb := cast(proc(_: ^Event_Data, _: rawptr) -> bool)callback // upcast
event_unsubscribe_raw(T, cb, user_ptr)
}
on_event :: proc(data: Event_Data) {
assert(event_bus.queue != nil)
append(&event_bus.queue, Event{data = data})
}
// Drain all the events that have been queued up, in enqueue order
drain_event_queue :: proc() {
assert(event_bus.queue != nil)
for &event in event_bus.queue {
#partial switch e in event.data {
case Window_Close_Event:
event_dispatch(Window_Close_Event, &event)
fmt.println("Close")
case Window_Resize_Event:
event_dispatch(Window_Resize_Event, &event)
fmt.println("Resize:", e.width, e.height)
case Key_Press_Event:
event_dispatch(Key_Press_Event, &event)
fmt.println("Key press", e.key, e.mods)
case Key_Release_Event:
event_dispatch(Key_Release_Event, &event)
fmt.println("Key release", e.key, e.mods)
case Key_Repeat_Event:
event_dispatch(Key_Repeat_Event, &event)
fmt.println("Key repeat", e.key, e.mods)
case Mouse_Button_Press_Event:
event_dispatch(Mouse_Button_Press_Event, &event)
fmt.println("Mouse press", e.button, e.mods)
case Mouse_Button_Release_Event:
event_dispatch(Mouse_Button_Release_Event, &event)
fmt.println("Mouse release", e.button, e.mods)
case Mouse_Position_Event:
event_dispatch(Mouse_Position_Event, &event)
fmt.printfln("Mouse pos: {}x{}", e.x_pos, e.y_pos)
case Mouse_Scroll_Event:
event_dispatch(Mouse_Scroll_Event, &event)
fmt.printfln("Mouse scroll: {}x{}", e.x_offset, e.y_offset)
case Joystick_Connect_Event:
event_dispatch(Joystick_Connect_Event, &event)
fmt.println("Joy con:", e.id)
case Joystick_Disconnect_Event:
event_dispatch(Joystick_Disconnect_Event, &event)
fmt.println("Joy dis:", e.id)
case:
panic("[event] unimplemented event")
}
}
clear(&event_bus.queue)
}
// Dispatch to the listeners registered for T, in reverse subscription order.
// First listener to return true claims the event and stops propagation
event_dispatch :: proc(
$T: typeid,
event: ^Event,
) -> bool where intrinsics.type_is_variant_of(Event_Data, T) {
assert(event_bus.listeners != nil)
ls, ok := event_bus.listeners[T]
if !ok do return false
#reverse for &l in ls {
callback := cast(proc(_: ^T, _: rawptr) -> bool)l.callback // downcast
if callback(&event.data.(T), l.user_ptr) {
event.handled = true
return true
}
}
return false
}
// -----------------------------------------
// Private functions
@(private)
event_subscribe_raw :: proc(
$T: typeid,
callback: proc(event: ^Event_Data, user_ptr: rawptr) -> bool,
user_ptr: rawptr = nil,
) where intrinsics.type_is_variant_of(Event_Data, T) {
assert(event_bus.listeners != nil)
if _, ok := event_bus.listeners[T]; !ok {
event_bus.listeners[T] = make([dynamic]Listener, event_bus.allocator)
}
append(&event_bus.listeners[T], Listener{callback, user_ptr})
}
@(private)
event_unsubscribe_raw :: proc(
$T: typeid,
callback: proc(event: ^Event_Data, user_ptr: rawptr) -> bool,
user_ptr: rawptr = nil,
) where intrinsics.type_is_variant_of(Event_Data, T) {
assert(event_bus.listeners != nil)
ls, ok := event_bus.listeners[T]
if !ok do return
for l, i in ls {
if l.callback == callback && l.user_ptr == user_ptr {
ordered_remove(&event_bus.listeners[T], i) // preserve subscription order
return
}
}
}
+302
View File
@@ -0,0 +1,302 @@
package base
import "base:runtime"
import "core:crypto/hash"
import "core:fmt"
import "core:mem"
import "core:os"
import "core:strings"
import "core:time"
SNAPSHOT_NAME :: "snapshot"
SNAPSHOT_EXTENSION :: "sndr"
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 {
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
@(private)
hr: Hot_Reload
// -----------------------------------------
// Constructor & destructor
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,
}
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,
)
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 {
snapshot = os.open(hr.snapshot_path, {.Read, .Write}) or_return
}
defer os.close(snapshot)
offset: i64
// 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, &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 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
}
hr.write_full_snapshot = false
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
}
@@ -1,4 +1,4 @@
package input
package base
// -----------------------------------------
@@ -1,4 +1,4 @@
package core
package base
// -----------------------------------------
+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,
}
+25
View File
@@ -0,0 +1,25 @@
package core
import "sindri:base"
import "sindri:platform"
// -----------------------------------------
// Public functions
// Returns the state of the keyboard key
key_state :: proc(key: base.Key) -> base.Action {
return platform.os_key_state(key)
}
// Returns the state of the mouse button
mouse_button_state :: proc(button: base.Mouse_Button) -> base.Action {
return platform.os_mouse_button_state(button)
}
// Returns the position of the mouse cursor
mouse_position :: proc() -> (x_pos: f32, y_pos: f32) {
return platform.os_mouse_position()
}
// -----------------------------------------
// Private functions
-118
View File
@@ -1,118 +0,0 @@
package event
import "core:fmt"
import "sindri:input"
// -----------------------------------------
// event category, bitfield (?)
Event :: union {
Window_Close_Event,
Window_Resize_Event,
Joystick_Connect_Event,
Joystick_Disconnect_Event,
Key_Press_Event,
Key_Release_Event,
Key_Repeat_Event,
Mouse_Button_Press_Event,
Mouse_Button_Release_Event,
Mouse_Position_Event,
Mouse_Scroll_Event,
}
Window_Close_Event :: struct {
handled: bool,
}
Window_Resize_Event :: struct {
handled: bool,
width: i32,
height: i32,
}
Joystick_Connect_Event :: struct {
handled: bool,
id: i32,
}
Joystick_Disconnect_Event :: struct {
handled: bool,
id: i32,
}
Key_Press_Event :: struct {
handled: bool,
key: input.Key,
mods: input.Mod_Set,
}
Key_Release_Event :: struct {
handled: bool,
key: input.Key,
mods: input.Mod_Set,
}
Key_Repeat_Event :: struct {
handled: bool,
key: input.Key,
mods: input.Mod_Set,
}
Mouse_Button_Press_Event :: struct {
handled: bool,
button: input.Mouse_Button,
mods: input.Mod_Set,
}
Mouse_Button_Release_Event :: struct {
handled: bool,
button: input.Mouse_Button,
mods: input.Mod_Set,
}
Mouse_Position_Event :: struct {
handled: bool,
x_pos: f32,
y_pos: f32,
}
Mouse_Scroll_Event :: struct {
handled: bool,
x_offset: f32,
y_offset: f32,
}
// -----------------------------------------
// Dispatcher(Event) -> forwards to the right function
on_event :: proc(event: Event) {
fmt.println("toby!")
#partial switch e in event {
case Window_Close_Event:
fmt.println("Close")
case Window_Resize_Event:
fmt.println("Resize:", e.width, e.height)
case Key_Press_Event:
fmt.println("Key press", e.key, e.mods)
case Key_Release_Event:
fmt.println("Key release", e.key, e.mods)
case Key_Repeat_Event:
fmt.println("Key repeat", e.key, e.mods)
case Mouse_Button_Press_Event:
fmt.println("Mouse press", e.button, e.mods)
case Mouse_Button_Release_Event:
fmt.println("Mouse release", e.button, e.mods)
case Mouse_Position_Event:
fmt.printfln("Mouse pos: {}x{}", e.x_pos, e.y_pos)
case Joystick_Connect_Event:
fmt.println("Joy con:", e.id)
case Joystick_Disconnect_Event:
fmt.println("Joy dis:", e.id)
}
}
-218
View File
@@ -1,218 +0,0 @@
package hot_reload
import "core:dynlib"
import "core:fmt"
import "core:os"
import "core:time"
import "sindri:core"
when ODIN_OS == .Windows {
LIB_EXT :: ".dll"
} else when ODIN_OS == .Darwin {
LIB_EXT :: ".dylib"
} else {
LIB_EXT :: ".so"
}
GAME_LIB_DIR :: "build/"
GAME_LIB_PATH :: GAME_LIB_DIR + "game" + LIB_EXT
// -----------------------------------------
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() -> core.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
}
// -----------------------------------------
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)
}
// -----------------------------------------
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,
)
copy_lib(lib_name) or_return
// 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(),
)
}
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 {
// 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.
api.memory_free()
for &g in hr.loaded_libs {
unload_game_lib(&g)
}
clear(&hr.loaded_libs)
new_api.init()
}
append(&hr.loaded_libs, new_api)
return active_lib(hr), nil
}
-19
View File
@@ -1,19 +0,0 @@
package input
// -----------------------------------------
// Public functions
// Returns the state of the keyboard key
// implementation in platform package, to prevent cyclic dependency
key_state: proc(key: Key) -> Action
// Returns the state of the mouse button
// implementation in platform package, to prevent cyclic dependency
mouse_button_state: proc(button: Mouse_Button) -> Action
// Returns the position of the mouse cursor
// implementation in platform package, to prevent cyclic dependency
mouse_position: proc() -> (x_pos: f32, y_pos: f32)
// -----------------------------------------
// Private functions
+48 -17
View File
@@ -3,7 +3,8 @@ package sindri
import "core:fmt"
import "core:time"
import "sindri:hot_reload"
import "game:game"
import "sindri:base"
import "sindri:platform"
VERSION :: #config(VERSION, "dev")
@@ -11,15 +12,27 @@ VERSION :: #config(VERSION, "dev")
// -----------------------------------------
main :: proc() {
fmt.println("hello world!")
fmt.println("hello world!", VERSION)
// Hot reload development functionality
hr, err := hot_reload.hot_reload_init()
defer hot_reload.hot_reload_destroy(&hr)
base.hot_reload_init(VERSION)
defer base.hot_reload_destroy()
api := hot_reload.active_lib(&hr)
// Load game API & settings
api := game.api()
defer api.destroy()
settings := api.settings()
// Event system
base.event_bus_init()
defer base.event_bus_destroy()
base.event_subscribe(base.Key_Press_Event, test)
// Listen for OS signals (SIGINT, SIGTERM)
platform.signals_init()
defer platform.signals_destroy()
// Initialize Window
platform.os_init(settings)
platform.os_set_monitor(settings)
@@ -30,26 +43,44 @@ 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() && !hot_reload.should_close(&hr) {
start := time.tick_now()
platform.os_poll_events()
api.update(dt)
base.drain_event_queue()
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 = hot_reload.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 {
fmt.println("main: @@@@@@@@@@@", e)
return true
}
+39 -47
View File
@@ -1,5 +1,6 @@
package platform
import "base:intrinsics"
import "core:fmt"
import "core:strings"
import "vendor:glfw"
@@ -7,9 +8,7 @@ import "vendor:glfw"
import "wgpu:wgpu"
import "wgpu:wgpu/glfwglue"
import "sindri:core"
import "sindri:event"
import "sindri:input"
import "sindri:base"
// -----------------------------------------
// Types
@@ -21,7 +20,7 @@ OS :: struct {
// -----------------------------------------
// Constructor / destructor
os_init :: proc(settings: core.Settings) {
os_init :: proc(settings: base.Settings) {
if !glfw.Init() {
panic("[glfw] init failure")
}
@@ -48,11 +47,6 @@ os_init :: proc(settings: core.Settings) {
glfw.SetCursorPosCallback(state.os.window, cursor_pos_callback)
glfw.SetScrollCallback(state.os.window, scroll_callback)
glfw.SetJoystickCallback(joystick_callback)
// Register input functions
input.key_state = key_state
input.mouse_button_state = mouse_button_state
input.mouse_position = mouse_position
}
os_destroy :: proc() {
@@ -63,7 +57,7 @@ os_destroy :: proc() {
// -----------------------------------------
// Public functions
os_set_monitor :: proc(settings: core.Settings) {
os_set_monitor :: proc(settings: base.Settings) {
monitor := glfw.GetPrimaryMonitor()
x_pos: i32
y_pos: i32
@@ -120,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) {
@@ -140,47 +135,44 @@ os_get_surface :: proc(instance: wgpu.Instance) -> wgpu.Surface {
return glfwglue.GetSurface(instance, state.os.window)
}
// -----------------------------------------
// Private functions
@(private = "file")
key_state :: proc(key: input.Key) -> input.Action {
os_key_state :: proc(key: base.Key) -> base.Action {
return input_action(glfw.GetKey(state.os.window, i32(key)))
}
@(private = "file")
mouse_button_state :: proc(button: input.Mouse_Button) -> input.Action {
os_mouse_button_state :: proc(button: base.Mouse_Button) -> base.Action {
return input_action(glfw.GetMouseButton(state.os.window, i32(button)))
}
@(private = "file")
mouse_position :: proc() -> (x_pos: f32, y_pos: f32) {
os_mouse_position :: proc() -> (x_pos: f32, y_pos: f32) {
x_pos_f64, y_pos_64 := glfw.GetCursorPos(state.os.window)
return f32(x_pos_f64), f32(y_pos_64)
}
// -----------------------------------------
// Private functions
@(private = "file")
input_action :: proc(action: i32) -> input.Action {
if action == glfw.RELEASE do return input.Action.Release
else if action == glfw.PRESS do return input.Action.Press
else if action == glfw.REPEAT do return input.Action.Repeat
input_action :: proc(action: i32) -> base.Action {
if action == glfw.RELEASE do return base.Action.Release
else if action == glfw.PRESS do return base.Action.Press
else if action == glfw.REPEAT do return base.Action.Repeat
when ODIN_DEBUG do panic("[glfw] unknown action")
return .None
}
@(private = "file")
input_key :: proc(key: i32) -> input.Key {
return input.Key(key) // values match
input_key :: proc(key: i32) -> base.Key {
return base.Key(key) // values match
}
@(private = "file")
input_mod_set :: proc(mods: i32) -> (set: input.Mod_Set) {
return transmute(input.Mod_Set)i8(mods & 0x3f) // values match bit position
input_mod_set :: proc(mods: i32) -> (set: base.Mod_Set) {
return transmute(base.Mod_Set)i8(mods & 0x3f) // values match bit position
}
@(private = "file")
input_mouse_button :: proc(button: i32) -> input.Mouse_Button {
return input.Mouse_Button(button) // values match
input_mouse_button :: proc(button: i32) -> base.Mouse_Button {
return base.Mouse_Button(button) // values match
}
// Error callback
@@ -194,7 +186,7 @@ error_callback :: proc "c" (error: i32, description: cstring) {
@(private = "file")
window_close_callback :: proc "c" (window: glfw.WindowHandle) {
context = state.ctx
event.on_event(event.Window_Close_Event{})
base.on_event(base.Window_Close_Event{})
} // GLFWwindowclosefun
// Window resize callback
@@ -211,24 +203,24 @@ key_callback :: proc "c" (
) {
context = state.ctx
if action == glfw.PRESS {
event.on_event(
event.Key_Press_Event {
base.on_event(
base.Key_Press_Event {
key = input_key(key),
mods = input_mod_set(mods),
},
)
}
if action == glfw.RELEASE {
event.on_event(
event.Key_Release_Event {
base.on_event(
base.Key_Release_Event {
key = input_key(key),
mods = input_mod_set(mods),
},
)
}
if action == glfw.REPEAT {
event.on_event(
event.Key_Repeat_Event {
base.on_event(
base.Key_Repeat_Event {
key = input_key(key),
mods = input_mod_set(mods),
},
@@ -244,16 +236,16 @@ mouse_button_callback :: proc "c" (
) {
context = state.ctx
if action == glfw.PRESS {
event.on_event(
event.Mouse_Button_Press_Event {
base.on_event(
base.Mouse_Button_Press_Event {
button = input_mouse_button(button),
mods = input_mod_set(mods),
},
)
}
if action == glfw.RELEASE {
event.on_event(
event.Mouse_Button_Release_Event {
base.on_event(
base.Mouse_Button_Release_Event {
button = input_mouse_button(button),
mods = input_mod_set(mods),
},
@@ -268,8 +260,8 @@ cursor_pos_callback :: proc "c" (
x_pos, y_pos: f64,
) {
context = state.ctx
event.on_event(
event.Mouse_Position_Event{x_pos = f32(x_pos), y_pos = f32(y_pos)},
base.on_event(
base.Mouse_Position_Event{x_pos = f32(x_pos), y_pos = f32(y_pos)},
)
} // GLFWcursorposfun
@@ -280,8 +272,8 @@ scroll_callback :: proc "c" (
x_offset, y_offset: f64,
) {
context = state.ctx
event.on_event(
event.Mouse_Scroll_Event {
base.on_event(
base.Mouse_Scroll_Event {
x_offset = f32(x_offset),
y_offset = f32(y_offset),
},
@@ -293,9 +285,9 @@ scroll_callback :: proc "c" (
joystick_callback :: proc "c" (id, connected: i32) {
context = state.ctx
if connected == glfw.CONNECTED {
event.on_event(event.Joystick_Connect_Event{id = id})
base.on_event(base.Joystick_Connect_Event{id = id})
} else {
event.on_event(event.Joystick_Disconnect_Event{id = id})
base.on_event(base.Joystick_Disconnect_Event{id = id})
}
} // GLFWjoystickfun
+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")
}
}
+3 -3
View File
@@ -6,7 +6,7 @@ import "core:slice"
import "wgpu:wgpu"
import "sindri:core"
import "sindri:base"
// -----------------------------------------
// Variables
@@ -33,7 +33,7 @@ state: struct {
// -----------------------------------------
// Constructor/destructor
instance_init :: proc(settings: core.Settings) {
instance_init :: proc(settings: base.Settings) {
state.ctx = context
wgpu.SetLogCallback(log_callback, nil)
@@ -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)
Vendored Submodule
+1
Submodule vendor/brokkr added at 154ced730e