1 /*
2 zlib/libpng license
3 
4 Copyright (c) 2023-2025 Matheus Catarino França <matheus-catarino@hotmail.com>
5 
6 This software is provided 'as-is', without any express or implied warranty.
7 In no event will the authors be held liable for any damages arising from the
8 use of this software.
9 */
10 module build;
11 
12 import std;
13 
14 // Dependency versions
15 enum emsdk_version = "4.0.10";
16 enum imgui_version = "3b98e0a57fc17cc72fdda6934bd932426778a16e";
17 enum nuklear_version = "4.12.7";
18 
19 void main(string[] args) @safe
20 {
21     static if (__VERSION__ < 2111)
22     {
23         static assert(false, "This project requires DMD-frontend 2.111.0 or newer");
24     }
25 
26     // Command-line options
27     struct Options
28     {
29         bool help, verbose, downloadEmsdk, downloadShdc;
30         string compiler, target = defaultTarget, optimize = "debug", linkExample, runExample, linkage = "static";
31         SokolBackend backend;
32         bool useX11 = true, useWayland, useEgl, useLTO, withSokolImgui, withSokolNuklear;
33     }
34 
35     Options opts;
36     immutable sokolRoot = environment.get("SOKOL_ROOTPATH", getcwd);
37     immutable vendorPath = absolutePath(buildPath(sokolRoot, "vendor"));
38     immutable sokolSrcPath = absolutePath(buildPath(sokolRoot, "src", "sokol", "c"));
39 
40     // Parse arguments
41     foreach (arg; args[1 .. $])
42         with (opts) switch (arg)
43     {
44     case "--help":
45         help = true;
46         break;
47     case "--verbose":
48         verbose = true;
49         break;
50     case "--enable-wasm-lto":
51         useLTO = true;
52         break;
53     case "--download-emsdk":
54         downloadEmsdk = true;
55         break;
56     case "--download-sokol-tools":
57         downloadShdc = true;
58         break;
59     case "--with-sokol-imgui":
60         withSokolImgui = true;
61         break;
62     case "--with-sokol-nuklear":
63         withSokolNuklear = true;
64         break;
65     default:
66         if (arg.startsWith("--backend="))
67             backend = arg[10 .. $].to!SokolBackend;
68         else if (arg.startsWith("--toolchain="))
69             compiler = findProgram(arg[12 .. $]);
70         else if (arg.startsWith("--optimize="))
71             optimize = arg[11 .. $];
72         else if (arg.startsWith("--target="))
73             target = arg[9 .. $];
74         else if (arg.startsWith("--link="))
75             linkExample = arg[7 .. $];
76         else if (arg.startsWith("--run="))
77             runExample = arg[6 .. $];
78         else if (arg.startsWith("--linkage="))
79         {
80             linkage = arg[10 .. $];
81             if (!["static", "dynamic"].canFind(linkage))
82                 throw new Exception("Invalid linkage: use static or dynamic");
83         }
84         else
85             throw new Exception("Unknown argument: " ~ arg);
86         break;
87     }
88 
89     if (args.length < 2 || opts.help)
90     {
91         writeln("Usage: build [options]\nOptions:");
92         writeln("  --help                Show this help message");
93         writeln("  --verbose             Enable verbose output");
94         writeln("  --backend=<backend>   Select backend (d3d11, metal, glcore, gles3, wgpu)");
95         writeln("  --toolchain=<compiler> Select C toolchain (e.g., gcc, clang, emcc)");
96         writeln("  --optimize=<level>    Select optimization level (debug, release, small)");
97         writeln("  --target=<target>     Select target (native, wasm, android)");
98         writeln("  --enable-wasm-lto     Enable Emscripten LTO");
99         writeln(
100             "  --linkage=<type>      Specify library linkage (static or dynamic, default: static)");
101         writeln("  --download-emsdk      Download Emscripten SDK");
102         writeln("  --download-sokol-tools Download sokol-tools");
103         writeln("  --link=<example>      Link WASM example (e.g., triangle)");
104         writeln("  --run=<example>       Run WASM example (e.g., triangle)");
105         writeln("  --with-sokol-imgui    Enable sokol_imgui integration");
106         writeln("  --with-sokol-nuklear  Enable sokol_nuklear integration");
107         return;
108     }
109 
110     if (opts.backend == SokolBackend._auto)
111         opts.backend = resolveSokolBackend(opts.backend, opts.target);
112 
113     if (!opts.linkExample && !opts.runExample)
114     {
115         if (opts.target.canFind("wasm"))
116             opts.downloadEmsdk = true;
117         writeln("Configuration:");
118         writeln("  Target: ", opts.target, ", Optimize: ", opts.optimize, ", Backend: ", opts
119                 .backend);
120         writeln("  Linkage: ", opts.linkage);
121         writeln("  Download: Emscripten=", opts.downloadEmsdk, ", ImGui=", opts.withSokolImgui,
122             ", Nuklear=", opts.withSokolNuklear, ", Sokol-tools=", opts.downloadShdc);
123         writeln("  Verbose: ", opts.verbose);
124     }
125 
126     // Setup dependencies
127     if (opts.downloadEmsdk || opts.target.canFind("wasm"))
128         getEmSDK(vendorPath);
129     if (opts.withSokolImgui)
130         getIMGUI(vendorPath);
131     if (opts.withSokolNuklear)
132         getNuklear(vendorPath);
133 
134     // Execute build steps
135     if (opts.downloadShdc)
136         buildShaders(vendorPath);
137     else if (opts.linkExample)
138     {
139         EmLinkOptions linkOpts = {
140             target: "wasm",
141             optimize: opts.optimize,
142             lib_main: buildPath("build", "lib" ~ opts.linkExample ~ ".a"),
143             vendor: vendorPath,
144             backend: opts.backend,
145             use_emmalloc: true,
146             release_use_lto: opts.useLTO,
147             use_imgui: opts.withSokolImgui,
148             use_nuklear: opts.withSokolNuklear,
149             use_filesystem: false,
150             shell_file_path: absolutePath(buildPath(sokolRoot, "src", "sokol", "web", "shell.html")),
151             extra_args: [
152                 "-L" ~ absolutePath(buildPath(sokolRoot, "build")), "-lsokol"
153             ],
154             verbose: opts.verbose
155         };
156         emLinkStep(linkOpts);
157     }
158     else if (opts.runExample)
159     {
160         emRunStep(EmRunOptions(opts.runExample, vendorPath, opts.verbose));
161     }
162     else
163     {
164         LibSokolOptions libOpts = {
165             target: opts.target,
166             optimize: opts.optimize,
167             toolchain: opts.compiler,
168             vendor: vendorPath,
169             sokolSrcPath: sokolSrcPath,
170             backend: opts.backend,
171             use_x11: opts.useX11,
172             use_wayland: opts.useWayland,
173             use_egl: opts.useEgl,
174             with_sokol_imgui: opts.withSokolImgui,
175             with_sokol_nuklear: opts.withSokolNuklear,
176             linkageStatic: opts.target.canFind("wasm") ? true : opts.linkage == "static",
177             verbose: opts.verbose
178         };
179         if (opts.target.canFind("wasm"))
180             buildLibSokol(libOpts);
181     }
182 }
183 
184 // Dependency management
185 void getEmSDK(string vendor) @safe
186 {
187     downloadAndExtract("Emscripten SDK", vendor, "emsdk",
188         format("https://github.com/emscripten-core/emsdk/archive/refs/tags/%s.zip", emsdk_version),
189         (path) => emSdkSetupStep(path));
190 }
191 
192 void getIMGUI(string vendor) @safe
193 {
194     string url;
195     enum commitHashRegex = ctRegex!`^[0-9a-fA-F]{7,40}$`;
196     if (matchFirst(imgui_version, commitHashRegex))
197     {
198         url = format("https://github.com/floooh/dcimgui/archive/%s.zip", imgui_version);
199     }
200     else
201     {
202         url = format("https://github.com/floooh/dcimgui/archive/refs/tags/v%s.zip", imgui_version);
203     }
204     downloadAndExtract("ImGui", vendor, "imgui", url);
205 }
206 
207 void getNuklear(string vendor) @safe
208 {
209     writeln("Setting up Nuklear");
210     string path = absolutePath(buildPath(vendor, "nuklear"));
211     string file = "nuklear.h";
212 
213     if (!exists(path))
214     {
215         mkdirRecurse(path);
216         download(format("https://raw.githubusercontent.com/Immediate-Mode-UI/Nuklear/refs/tags/%s/nuklear.h", nuklear_version), file);
217         std.file.write(buildPath(path, "nuklear.h"), read(file));
218     }
219 }
220 
221 void buildShaders(string vendor) @safe
222 {
223     immutable shdcPath = getSHDC(vendor);
224     immutable shadersDir = "examples/shaders";
225     immutable shaders = [
226         "triangle", "bufferoffsets", "cube", "instancing", "mrt",
227         "noninterleaved", "offscreen", "quad", "shapes", "texcube", "blend"
228     ];
229 
230     version (OSX)
231         enum glsl = "glsl410";
232     else
233         enum glsl = "glsl430";
234     immutable slang = glsl ~ ":metal_macos:hlsl5:glsl300es:wgsl";
235 
236     version (Posix)
237         executeOrFail(["chmod", "+x", shdcPath], "Failed to set shader permissions", true);
238 
239     foreach (shader; shaders)
240         executeOrFail([
241         shdcPath, "-i", buildPath(shadersDir, shader ~ ".glsl"),
242         "-o", buildPath(shadersDir, shader ~ ".d"), "-l", slang, "-f", "sokol_d"
243     ], "Shader compilation failed for " ~ shader, true);
244 }
245 
246 // Download and extract utility
247 void downloadAndExtract(string name, string vendor, string dir, string url, void delegate(string) @safe postExtract = null) @safe
248 {
249     writeln("Setting up ", name);
250     string path = absolutePath(buildPath(vendor, dir));
251     string file = dir ~ ".zip";
252     scope (exit)
253         if (exists(file))
254             remove(file);
255 
256     if (!exists(path))
257     {
258         download(url, file);
259         extractZip(file, path);
260     }
261     if (postExtract)
262         postExtract(path);
263 }
264 
265 // Core build structures
266 enum SokolBackend
267 {
268     _auto,
269     d3d11,
270     metal,
271     glcore,
272     gles3,
273     wgpu
274 }
275 
276 struct LibSokolOptions
277 {
278     string target, optimize, toolchain, vendor, sokolSrcPath;
279     SokolBackend backend;
280     bool use_egl, use_x11 = true, use_wayland, with_sokol_imgui, with_sokol_nuklear, linkageStatic, verbose;
281 }
282 
283 struct EmLinkOptions
284 {
285     string target, optimize, lib_main, vendor, shell_file_path;
286     SokolBackend backend;
287     bool release_use_closure = true, release_use_lto, use_emmalloc, use_filesystem, use_imgui, use_nuklear, verbose;
288     string[] extra_args;
289 }
290 
291 struct EmRunOptions
292 {
293     string name, vendor;
294     bool verbose;
295 }
296 
297 struct EmbuilderOptions
298 {
299     string port_name, vendor;
300 }
301 
302 // Build Sokol, ImGui, and Nuklear libraries
303 void buildLibSokol(LibSokolOptions opts) @safe
304 {
305     immutable buildDir = absolutePath("build");
306     mkdirRecurse(buildDir);
307 
308     // Compiler setup
309     string compiler = opts.toolchain ? opts.toolchain : defaultCompiler(opts.target);
310     string[] cflags = [
311         "-DNDEBUG", "-DIMPL",
312         format("-DSOKOL_%s", resolveSokolBackend(opts.backend, opts.target).to!string.toUpper)
313     ];
314     string[] lflags;
315 
316     // Platform-specific flags
317     switch (opts.target)
318     {
319     case "darwin":
320         cflags ~= [
321             "-ObjC", "-Wall", "-Wextra", "-Wno-unused-function",
322             "-Wno-return-type-c-linkage"
323         ];
324         lflags ~= [
325             "-framework", "Cocoa", "-framework", "QuartzCore", "-framework",
326             "Foundation",
327             "-framework", "MetalKit", "-framework", "Metal", "-framework",
328             "AudioToolbox"
329         ];
330         break;
331     case "linux":
332         cflags ~= ["-Wall", "-Wextra", "-Wno-unused-function"];
333         if (opts.use_egl)
334             cflags ~= "-DSOKOL_FORCE_EGL";
335         if (!opts.use_x11)
336             cflags ~= "-DSOKOL_DISABLE_X11";
337         if (!opts.use_wayland)
338             cflags ~= "-DSOKOL_DISABLE_WAYLAND";
339         lflags ~= opts.use_wayland ? [
340             "-lwayland-client", "-lwayland-egl", "-lwayland-cursor", "-lxkbcommon"
341         ] : [];
342         lflags ~= ["-lX11", "-lGL", "-lXi", "-lXcursor", "-lasound"];
343         break;
344     case "windows":
345         cflags ~= ["/DNDEBUG", "/DIMPL", "/wd4190", "/O2"];
346         lflags ~= ["dxgi.lib", "d3d11.lib"];
347         break;
348     case "wasm":
349         cflags ~= ["-fPIE"];
350         if (opts.backend == SokolBackend.wgpu)
351         {
352             //dfmt off
353             EmbuilderOptions embopts = {
354                 port_name: "emdawnwebgpu",
355                 vendor: opts.vendor,
356             };
357             //dfmt on
358             embuilderStep(embopts);
359             cflags ~= format("-I%s", buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "cache", "ports", "emdawnwebgpu", "emdawnwebgpu_pkg", "webgpu", "include"));
360         }
361         compiler = buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "emcc") ~ (isWindows ? ".bat"
362                 : "");
363         break;
364     default:
365         break;
366     }
367 
368     // Optimization and dynamic library flags
369     cflags ~= opts.optimize == "debug" && !opts.target.canFind("windows") ? "-O0" : "-O2";
370     if (!opts.linkageStatic && !opts.target.canFind("wasm"))
371         cflags ~= "-fPIC";
372 
373     // Add Nuklear include path if enabled
374     if (opts.with_sokol_nuklear)
375     {
376         immutable nuklearRoot = absolutePath(buildPath(opts.vendor, "nuklear"));
377         cflags ~= format("-I%s", nuklearRoot);
378     }
379 
380     // Compile Sokol sources
381     immutable sokolSources = [
382         "sokol_log.c", "sokol_app.c", "sokol_gfx.c", "sokol_time.c",
383         "sokol_audio.c", "sokol_gl.c", "sokol_debugtext.c", "sokol_shape.c",
384         "sokol_glue.c", "sokol_fetch.c", "sokol_memtrack.c", "sokol_args.c",
385     ];
386     auto sokolObjs = compileSources(sokolSources, buildDir, opts.sokolSrcPath, compiler, cflags, "sokol_", opts
387             .verbose);
388 
389     // Create Sokol library
390     immutable sokolLib = buildPath(buildDir, opts.linkageStatic ? "libsokol.a" : (opts.target.canFind("darwin") ? "libsokol.dylib" : opts
391             .target.canFind("windows") ? "sokol.dll" : "libsokol.so"));
392     linkLibrary(sokolLib, sokolObjs, opts.target, opts.linkageStatic, opts.vendor, lflags, opts
393             .verbose);
394     sokolObjs.each!(obj => exists(obj) && remove(obj));
395 
396     // Handle ImGui
397     if (opts.with_sokol_imgui)
398     {
399         immutable imguiRoot = absolutePath(buildPath(opts.vendor, "imgui", "src"));
400         enforce(exists(imguiRoot), "ImGui source not found. Use --download-imgui.");
401 
402         immutable imguiSources = [
403             "cimgui.cpp", "imgui.cpp", "imgui_demo.cpp", "imgui_draw.cpp",
404             "imgui_tables.cpp", "imgui_widgets.cpp", "cimgui_internal.cpp"
405         ];
406         cflags ~= format("-I%s", imguiRoot);
407 
408         string imguiCompiler = opts.target.canFind("wasm") ? buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "em++") ~ (
409             isWindows ? ".bat" : "") : compiler.canFind("clang") ? findProgram(compiler ~ "++") : compiler.canFind(
410             "gcc") ? findProgram("g++") : compiler;
411 
412         // Compile ImGui sources
413         auto imguiObjs = compileSources(imguiSources, buildDir, imguiRoot, imguiCompiler, cflags ~ "-DNDEBUG", "imgui_", opts
414                 .verbose);
415 
416         // Compile sokol_imgui.c
417         immutable sokolImguiPath = buildPath(opts.sokolSrcPath, "sokol_imgui.c");
418         enforce(exists(sokolImguiPath), "sokol_imgui.c not found");
419         immutable sokolImguiObj = buildPath(buildDir, "sokol_imgui.o");
420         compileSource(sokolImguiPath, sokolImguiObj, compiler, cflags, opts.verbose);
421         imguiObjs ~= sokolImguiObj;
422 
423         // Create ImGui library
424         immutable imguiLib = buildPath(buildDir, opts.linkageStatic ? "libcimgui.a" : (opts.target.canFind("darwin") ? "libcimgui.dylib" : opts
425                 .target.canFind("windows") ? "cimgui.dll" : "libcimgui.so"));
426         linkLibrary(imguiLib, imguiObjs, opts.target, opts.linkageStatic, opts.vendor, lflags, opts
427                 .verbose);
428         imguiObjs.each!(obj => exists(obj) && remove(obj));
429     }
430 
431     // Handle Nuklear
432     if (opts.with_sokol_nuklear)
433     {
434         immutable nuklearRoot = absolutePath(buildPath(opts.vendor, "nuklear"));
435         enforce(exists(nuklearRoot), "Nuklear source not found. Ensure it is downloaded.");
436 
437         // Define Nuklear sources
438         string[] nuklearObjs;
439 
440         // Compile sokol_nuklear.c
441         immutable sokolNuklearPath = buildPath(opts.sokolSrcPath, "sokol_nuklear.c");
442         enforce(exists(sokolNuklearPath), "sokol_nuklear.c not found");
443         immutable sokolNuklearObj = buildPath(buildDir, "sokol_nuklear.o");
444         compileSource(sokolNuklearPath, sokolNuklearObj, compiler, cflags, opts
445                 .verbose);
446         nuklearObjs ~= sokolNuklearObj;
447 
448         // Compile nuklearc.c
449         immutable nuklearcPath = absolutePath(buildPath("src", "nuklear", "c", "nuklearc.c"));
450         enforce(exists(nuklearcPath), "nuklearc.c not found in src/nuklear/c");
451         immutable nuklearcObj = buildPath(buildDir, "nuklearc.o");
452         compileSource(nuklearcPath, nuklearcObj, compiler, cflags, opts
453                 .verbose);
454         nuklearObjs ~= nuklearcObj;
455 
456         // Create Nuklear library
457         immutable nuklearLib = buildPath(buildDir, opts.linkageStatic ? "libnuklear.a" : (opts.target.canFind("darwin") ? "libnuklear.dylib" : opts
458                 .target.canFind("windows") ? "nuklear.dll" : "libnuklear.so"));
459         linkLibrary(nuklearLib, nuklearObjs, opts.target, opts.linkageStatic, opts.vendor, lflags, opts
460                 .verbose);
461         nuklearObjs.each!(obj => exists(obj) && remove(obj));
462     }
463 }
464 
465 // Compile a single source file
466 void compileSource(string srcPath, string objPath, string compiler, string[] cflags, bool verbose) @safe
467 {
468     enforce(exists(srcPath), format("Source file %s does not exist", srcPath));
469     string[] cmd = [compiler] ~ cflags ~ ["-c", "-o", objPath, srcPath];
470     if (verbose)
471         writeln("Executing: ", cmd.join(" "));
472     auto result = executeShell(cmd.join(" "));
473     if (verbose && result.output.length)
474         writeln("Output:\n", result.output);
475     enforce(result.status == 0, format("Failed to compile %s: %s", srcPath, result.output));
476 }
477 
478 // Compile multiple sources
479 string[] compileSources(const(string[]) sources, string buildDir, string srcRoot, string compiler, string[] cflags, string prefix, bool verbose) @safe
480 {
481     string[] objFiles;
482     foreach (src; sources)
483     {
484         immutable srcPath = buildPath(srcRoot, src);
485         immutable objPath = buildPath(buildDir, prefix ~ src.baseName ~ ".o");
486         compileSource(srcPath, objPath, compiler, cflags, verbose);
487         objFiles ~= objPath;
488     }
489     return objFiles;
490 }
491 
492 // Link objects into a static or dynamic library
493 void linkLibrary(string libPath, string[] objFiles, string target, bool linkageStatic, string vendor, string[] lflags, bool verbose) @safe
494 {
495     string arCmd = target.canFind("wasm") ? buildPath(vendor, "emsdk", "upstream", "emscripten", "emar") ~ (
496         isWindows ? ".bat" : "") : isWindows ? "lib.exe" : "ar";
497     string[] cmd;
498 
499     if (!linkageStatic && !target.canFind("wasm"))
500     {
501         if (target.canFind("darwin"))
502         {
503             string linker = findProgram("clang");
504             cmd = [linker, "-dynamiclib", "-o", libPath] ~ objFiles ~ lflags;
505         }
506         else if (target.canFind("windows"))
507         {
508             string linker = findProgram("cl");
509             cmd = [linker, "/LD", format("/Fe:%s", libPath)] ~ objFiles ~ lflags;
510         }
511         else // Linux
512         {
513             string linker = findProgram("gcc");
514             cmd = [linker, "-shared", "-o", libPath] ~ objFiles ~ lflags;
515         }
516     }
517     else if (isWindows && !target.canFind("wasm"))
518     {
519         cmd = [arCmd, "/nologo", format("/OUT:%s", libPath)] ~ objFiles;
520     }
521     else
522     {
523         cmd = [arCmd, "rcs", libPath] ~ objFiles;
524     }
525 
526     if (verbose)
527         writeln("Executing: ", cmd.join(" "));
528     auto result = executeShell(cmd.join(" "));
529     if (verbose && result.output.length)
530         writeln("Output:\n", result.output);
531     enforce(result.status == 0, format("Failed to create %s: %s", libPath, result.output));
532 }
533 
534 // Link WASM executable
535 void emLinkStep(EmLinkOptions opts) @safe
536 {
537     string emcc = buildPath(opts.vendor, "emsdk", "upstream", "emscripten", opts.use_imgui ? "em++"
538             : "emcc") ~ (
539         isWindows ? ".bat" : "");
540     string[] cmd = [emcc];
541 
542     if (opts.use_imgui)
543         cmd ~= "-lcimgui";
544     if (opts.use_nuklear)
545         cmd ~= "-lnuklear";
546     if (opts.optimize == "debug")
547         cmd ~= ["-gsource-map", "-sSAFE_HEAP=1", "-sSTACK_OVERFLOW_CHECK=1"];
548     else
549     {
550         cmd ~= "-sASSERTIONS=0";
551         cmd ~= opts.optimize == "small" ? "-Oz" : "-O3";
552         if (opts.release_use_lto)
553             cmd ~= "-flto";
554         if (opts.release_use_closure)
555             cmd ~= ["--closure", "1"];
556     }
557 
558     if (opts.backend == SokolBackend.wgpu)
559         cmd ~= "--use-port=emdawnwebgpu";
560     if (opts.backend == SokolBackend.gles3)
561         cmd ~= "-sUSE_WEBGL2=1";
562     if (!opts.use_filesystem)
563         cmd ~= "-sNO_FILESYSTEM=1";
564     if (opts.use_emmalloc)
565         cmd ~= "-sMALLOC='emmalloc'";
566     if (opts.shell_file_path)
567         cmd ~= "--shell-file=" ~ opts.shell_file_path;
568 
569     cmd ~= ["-sSTACK_SIZE=512KB"] ~ opts.extra_args ~ opts.lib_main;
570     immutable baseName = opts.lib_main.baseName[3 .. $ - 2]; // Strip "lib" and ".a"
571     string outFile = buildPath("build", baseName ~ ".html");
572     cmd ~= ["-o", outFile];
573 
574     if (opts.verbose)
575         writeln("Executing: ", cmd.join(" "));
576     auto result = executeShell(cmd.join(" "));
577     if (opts.verbose && result.output.length)
578         writeln("Output:\n", result.output);
579     enforce(result.status == 0, format("emcc failed: %s: %s", outFile, result.output));
580 
581     string webDir = "web";
582     mkdirRecurse(webDir);
583     foreach (ext; [".html", ".wasm", ".js"])
584         copy(buildPath("build", baseName ~ ext), buildPath(webDir, baseName ~ ext));
585     rmdirRecurse(buildPath("build"));
586 }
587 
588 // Run WASM executable
589 void emRunStep(EmRunOptions opts) @safe
590 {
591     string emrun = buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "emrun") ~ (
592         isWindows ? ".bat" : "");
593     executeOrFail([emrun, buildPath("web", opts.name ~ ".html")], "emrun failed", opts.verbose);
594 }
595 
596 // Setup Emscripten SDK
597 void emSdkSetupStep(string emsdk) @safe
598 {
599     if (!exists(buildPath(emsdk, ".emscripten")))
600     {
601         immutable cmd = buildPath(emsdk, "emsdk") ~ (isWindows ? ".bat" : "");
602         executeOrFail([!isWindows ? "bash " ~ cmd: cmd, "install", "latest"], "emsdk install failed", true);
603         executeOrFail([!isWindows ? "bash " ~ cmd: cmd, "activate", "latest"], "emsdk activate failed", true);
604     }
605 }
606 
607 void embuilderStep(EmbuilderOptions opts) @safe
608 {
609     string embuilder = buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "embuilder") ~ (
610         isWindows ? ".bat" : "");
611     string[] bFlags = ["build", opts.port_name];
612     executeOrFail(embuilder ~ bFlags, "embuilder failed to build " ~ opts.port_name, true);
613 }
614 
615 // Utility functions
616 string findProgram(string programName) @safe
617 {
618     foreach (path; environment.get("PATH").split(pathSeparator))
619     {
620         string fullPath = buildPath(path, programName);
621         version (Windows)
622             fullPath ~= ".exe";
623         if (exists(fullPath) && isFile(fullPath))
624             return fullPath;
625     }
626     throw new Exception(format("Program '%s' not found in PATH", programName));
627 }
628 
629 string defaultCompiler(string target) @safe
630 {
631     if (target.canFind("wasm"))
632         return "";
633     version (linux)
634         return findProgram("gcc");
635     version (Windows)
636         return findProgram("cl");
637     version (OSX)
638         return findProgram("clang");
639     version (Android)
640         return findProgram("clang");
641     throw new Exception("Unsupported platform");
642 }
643 
644 SokolBackend resolveSokolBackend(SokolBackend backend, string target) @safe
645 {
646     if (target.canFind("linux"))
647         return SokolBackend.glcore;
648     if (target.canFind("darwin"))
649         return SokolBackend.metal;
650     if (target.canFind("windows"))
651         return SokolBackend.d3d11;
652     if (target.canFind("wasm"))
653         return backend == SokolBackend.wgpu ? backend : SokolBackend.gles3;
654     if (target.canFind("android"))
655         return SokolBackend.gles3;
656     version (linux)
657         return SokolBackend.glcore;
658     version (Windows)
659         return SokolBackend.d3d11;
660     version (OSX)
661         return SokolBackend.metal;
662     return backend;
663 }
664 
665 void executeOrFail(string[] cmd, string errorMsg, bool verbose) @safe
666 {
667     if (verbose)
668         writeln("Executing: ", cmd.join(" "));
669     auto result = executeShell(cmd.join(" "));
670     if (verbose && result.output.length)
671         writeln("Output:\n", result.output);
672     enforce(result.status == 0, format("%s: %s", errorMsg, result.output));
673 }
674 
675 bool isWindows() @safe
676 {
677     version (Windows)
678         return true;
679     return false;
680 }
681 
682 // Download and extract functions
683 void download(string url, string fileName) @trusted
684 {
685     auto buf = appender!(ubyte[])();
686     size_t contentLength;
687     auto http = HTTP(url);
688     http.onReceiveHeader((in k, in v) {
689         if (k == "content-length")
690             contentLength = to!size_t(v);
691     });
692 
693     int barWidth = 50;
694     http.onReceive((data) {
695         buf.put(data);
696         if (contentLength)
697         {
698             float progress = cast(float) buf.data.length / contentLength;
699             write("\r[", "=".replicate(cast(int)(barWidth * progress)), ">", " ".replicate(
700                 barWidth - cast(int)(barWidth * progress)), "] ",
701                 format("%d%%", cast(int)(progress * 100)));
702             stdout.flush();
703         }
704         return data.length;
705     });
706 
707     http.perform();
708     enforce(http.statusLine.code / 100 == 2 || http.statusLine.code == 302, format(
709             "HTTP request failed: %s", http.statusLine.code));
710     std.file.write(fileName, buf.data);
711     writeln();
712 }
713 
714 void extractZip(string zipFile, string destination) @trusted
715 {
716     ZipArchive archive = new ZipArchive(read(zipFile));
717     string prefix = archive.directory.keys.front[0 .. $ - archive.directory.keys.front.find("/")
718             .length + 1];
719 
720     if (exists(destination))
721         rmdirRecurse(destination);
722     mkdirRecurse(destination);
723 
724     foreach (name, am; archive.directory)
725     {
726         if (!am.expandedSize)
727             continue;
728         string path = buildPath(destination, chompPrefix(name, prefix));
729         mkdirRecurse(dirName(path));
730         std.file.write(path, archive.expand(am));
731     }
732 }
733 
734 string getSHDC(string vendor) @safe
735 {
736     string path = absolutePath(buildPath(vendor, "shdc"));
737     string file = "shdc.zip";
738     scope (exit)
739         if (exists(file))
740             remove(file);
741 
742     if (!exists(path))
743     {
744         download("https://github.com/floooh/sokol-tools-bin/archive/refs/heads/master.zip", file);
745         extractZip(file, path);
746     }
747 
748     version (Windows)
749         immutable shdc = buildPath("bin", "win32", "sokol-shdc.exe");
750     else version (linux)
751         immutable shdc = buildPath("bin", isAArch64 ? "linux_arm64" : "linux", "sokol-shdc");
752     else version (OSX)
753         immutable shdc = buildPath("bin", isAArch64 ? "osx_arm64" : "osx", "sokol-shdc");
754     else
755         throw new Exception("Unsupported platform for sokol-tools");
756 
757     return buildPath(path, shdc);
758 }
759 
760 bool isAArch64() @safe
761 {
762     version (AArch64)
763         return true;
764     return false;
765 }
766 
767 string defaultTarget() @safe
768 {
769     version (linux)
770         return "linux";
771     version (Windows)
772         return "windows";
773     version (OSX)
774         return "darwin";
775     version (Android)
776         return "android";
777     version (Emscripten)
778         return "wasm";
779     throw new Exception("Unsupported platform");
780 }