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/" GAME_LIB_PATH :: BUILD_DIRECTORY + "game" + LIB_EXT GAME_LIB_TMP_PATH :: BUILD_DIRECTORY + "game_tmp" + LIB_EXT ENGINE_DIR :: "src/" ENGINE_OUTPUT :: BUILD_DIRECTORY + PROJECT_NAME 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."`, 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.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, odin_extra_args: []string) { version := project_version() // Setup compiled wgpu binary wgpu_init() // ---------------------------------------- // Game compile build_game(debug, version, odin_extra_args) // ---------------------------------------- // 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, odin_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, 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 game as a dynamic library loaded by the engine build_game :: proc(debug: bool, version: string, extra_args: []string) { b := build_common(GAME_SRC_DIR, debug, version, extra_args) defer brokkr.builder_destroy(&b) brokkr.mode(&b, .Dynamic) // Use a temp file because it first writes an empty file, which the engine // would load before it is actually fully written brokkr.out(&b, GAME_LIB_TMP_PATH) if err := brokkr.execute(&b); err != nil { os.exit(1) } if !os.is_file(GAME_LIB_TMP_PATH) { die("build produced no game lib %q", GAME_LIB_TMP_PATH) } os.rename(GAME_LIB_TMP_PATH, GAME_LIB_PATH) } // Build the engine executable build_engine :: proc(debug: bool, version: string, extra_args: []string) { b := build_common(ENGINE_DIR, debug, version, extra_args) 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(ENGINE_OUTPUT) || !os.exists(GAME_LIB_PATH) { 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) } // ----------------------------------------- // 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) }