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.16";
16 enum imgui_version = "1.92.5";
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, opts.backend);
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, ref SokolBackend opts) @safe
222 {
223     immutable shdcPath = getSHDC(vendor);
224     immutable shadersDir = "examples/shaders";
225     immutable shaders = [
226         "triangle", "bufferoffsets", "cube", "instancing", "instancingcompute",
227         "mrt", "noninterleaved", "offscreen", "quad", "shapes", "texcube", "blend",
228         "vertexpull"
229     ];
230 
231     version (OSX)
232         enum glsl = "glsl410";
233     else
234         enum glsl = "glsl430";
235 
236     immutable slangTemplate = opts == SokolBackend.vulkan ? glsl ~ ":metal_macos:hlsl5:%s:wgsl:spirv_vk" : glsl ~ ":metal_macos:hlsl5:%s:wgsl";
237 
238     version (Posix)
239         executeOrFail(["chmod", "+x", shdcPath], "Failed to set shader permissions", true);
240 
241     foreach (shader; shaders)
242     {
243         immutable essl = (shader == "instancingcompute" || shader == "vertexpull")
244             ? "glsl310es" : "glsl300es";
245         immutable slang = slangTemplate.format(essl);
246         executeOrFail([
247             shdcPath, "-i", buildPath(shadersDir, shader ~ ".glsl"),
248             "-o", buildPath(shadersDir, shader ~ ".d"), "-l", slang, "-f",
249             "sokol_d"
250         ], "Shader compilation failed for " ~ shader, true);
251     }
252 }
253 
254 // Download and extract utility
255 void downloadAndExtract(string name, string vendor, string dir, string url, void delegate(string) @safe postExtract = null) @safe
256 {
257     writeln("Setting up ", name);
258     string path = absolutePath(buildPath(vendor, dir));
259     string file = dir ~ ".zip";
260     scope (exit)
261         if (exists(file))
262             remove(file);
263 
264     if (!exists(path))
265     {
266         download(url, file);
267         extractZip(file, path);
268     }
269     if (postExtract)
270         postExtract(path);
271 }
272 
273 // Core build structures
274 enum SokolBackend
275 {
276     _auto,
277     d3d11,
278     metal,
279     glcore,
280     gles3,
281     wgpu,
282     vulkan
283 }
284 
285 struct LibSokolOptions
286 {
287     string target, optimize, toolchain, vendor, sokolSrcPath;
288     SokolBackend backend;
289     bool use_egl, use_x11 = true, use_wayland, with_sokol_imgui, with_sokol_nuklear, linkageStatic, verbose;
290 }
291 
292 struct EmLinkOptions
293 {
294     string target, optimize, lib_main, vendor, shell_file_path;
295     SokolBackend backend;
296     bool release_use_closure = true, release_use_lto, use_emmalloc, use_filesystem, use_imgui, use_nuklear, verbose;
297     string[] extra_args;
298 }
299 
300 struct EmRunOptions
301 {
302     string name, vendor;
303     bool verbose;
304 }
305 
306 struct EmbuilderOptions
307 {
308     string port_name, vendor;
309 }
310 
311 // Build Sokol, ImGui, and Nuklear libraries
312 void buildLibSokol(LibSokolOptions opts) @safe
313 {
314     immutable buildDir = absolutePath("build");
315     mkdirRecurse(buildDir);
316 
317     // Compiler setup
318     string compiler = opts.toolchain ? opts.toolchain : defaultCompiler(opts.target);
319     string[] cflags = [
320         "-DNDEBUG", "-DIMPL",
321         format("-DSOKOL_%s", resolveSokolBackend(opts.backend, opts.target).to!string.toUpper)
322     ];
323     string[] lflags;
324 
325     // Platform-specific flags
326     switch (opts.target)
327     {
328     case "darwin":
329         cflags ~= [
330             "-ObjC", "-Wall", "-Wextra", "-Wno-unused-function",
331             "-Wno-return-type-c-linkage"
332         ];
333         lflags ~= [
334             "-framework", "Cocoa", "-framework", "QuartzCore", "-framework",
335             "Foundation",
336             "-framework", "MetalKit", "-framework", "Metal", "-framework",
337             "AudioToolbox"
338         ];
339         break;
340     case "linux":
341         cflags ~= ["-Wall", "-Wextra", "-Wno-unused-function"];
342         if (opts.use_egl)
343             cflags ~= "-DSOKOL_FORCE_EGL";
344         if (!opts.use_x11)
345             cflags ~= "-DSOKOL_DISABLE_X11";
346         if (!opts.use_wayland)
347             cflags ~= "-DSOKOL_DISABLE_WAYLAND";
348         lflags ~= opts.use_wayland ? [
349             "-lwayland-client", "-lwayland-egl", "-lwayland-cursor", "-lxkbcommon"
350         ] : [];
351         lflags ~= opts.backend == SokolBackend.vulkan ? ["-lvulkan"] : ["-lGL"];
352         lflags ~= ["-lX11", "-lXi", "-lXcursor", "-lasound"];
353         break;
354     case "windows":
355         cflags ~= ["/DNDEBUG", "/DIMPL", "/wd4190", "/O2"];
356         lflags ~= ["dxgi.lib", "d3d11.lib"];
357         break;
358     case "wasm":
359         cflags ~= ["-fPIE"];
360         if (opts.backend == SokolBackend.wgpu)
361         {
362             //dfmt off
363             EmbuilderOptions embopts = {
364                 port_name: "emdawnwebgpu",
365                 vendor: opts.vendor,
366             };
367             //dfmt on
368             embuilderStep(embopts);
369             cflags ~= format("-I%s", buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "cache", "ports", "emdawnwebgpu", "emdawnwebgpu_pkg", "webgpu", "include"));
370         }
371         compiler = buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "emcc") ~ (isWindows ? ".bat"
372                 : "");
373         break;
374     default:
375         break;
376     }
377 
378     // Optimization and dynamic library flags
379     cflags ~= opts.optimize == "debug" && !opts.target.canFind("windows") ? "-O0" : "-O2";
380     if (!opts.linkageStatic && !opts.target.canFind("wasm"))
381         cflags ~= "-fPIC";
382 
383     // Add Nuklear include path if enabled
384     if (opts.with_sokol_nuklear)
385     {
386         immutable nuklearRoot = absolutePath(buildPath(opts.vendor, "nuklear"));
387         cflags ~= format("-I%s", nuklearRoot);
388     }
389 
390     // Compile Sokol sources
391     immutable sokolSources = [
392         "sokol_log.c", "sokol_app.c", "sokol_gfx.c", "sokol_time.c",
393         "sokol_audio.c", "sokol_gl.c", "sokol_debugtext.c", "sokol_shape.c",
394         "sokol_glue.c", "sokol_fetch.c", "sokol_memtrack.c", "sokol_args.c",
395     ];
396     auto sokolObjs = compileSources(sokolSources, buildDir, opts.sokolSrcPath, compiler, cflags, "sokol_", opts
397             .verbose);
398 
399     // Create Sokol library
400     immutable sokolLib = buildPath(buildDir, opts.linkageStatic ? "libsokol.a" : (opts.target.canFind("darwin") ? "libsokol.dylib" : opts
401             .target.canFind("windows") ? "sokol.dll" : "libsokol.so"));
402     linkLibrary(sokolLib, sokolObjs, opts.target, opts.linkageStatic, opts.vendor, lflags, opts
403             .verbose);
404     sokolObjs.each!(obj => exists(obj) && remove(obj));
405 
406     // Handle ImGui
407     if (opts.with_sokol_imgui)
408     {
409         immutable imguiRoot = absolutePath(buildPath(opts.vendor, "imgui", "src"));
410         enforce(exists(imguiRoot), "ImGui source not found. Use --download-imgui.");
411 
412         immutable imguiSources = [
413             "cimgui.cpp", "imgui.cpp", "imgui_demo.cpp", "imgui_draw.cpp",
414             "imgui_tables.cpp", "imgui_widgets.cpp", "cimgui_internal.cpp"
415         ];
416         cflags ~= format("-I%s", imguiRoot);
417 
418         string imguiCompiler = opts.target.canFind("wasm") ? buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "em++") ~ (
419             isWindows ? ".bat" : "") : compiler.canFind("clang") ? findProgram(compiler ~ "++") : compiler.canFind(
420             "gcc") ? findProgram("g++") : compiler;
421 
422         // Compile ImGui sources
423         auto imguiObjs = compileSources(imguiSources, buildDir, imguiRoot, imguiCompiler, cflags ~ "-DNDEBUG", "imgui_", opts
424                 .verbose);
425 
426         // Compile sokol_imgui.c
427         immutable sokolImguiPath = buildPath(opts.sokolSrcPath, "sokol_imgui.c");
428         enforce(exists(sokolImguiPath), "sokol_imgui.c not found");
429         immutable sokolImguiObj = buildPath(buildDir, "sokol_imgui.o");
430         compileSource(sokolImguiPath, sokolImguiObj, compiler, cflags, opts.verbose);
431         imguiObjs ~= sokolImguiObj;
432         // Compile sokol_gfx_imgui.c
433         immutable sokolGfxImguiPath = buildPath(opts.sokolSrcPath, "sokol_gfx_imgui.c");
434         enforce(exists(sokolGfxImguiPath), "sokol_gfx_imgui.c not found");
435         immutable sokolGfxImguiObj = buildPath(buildDir, "sokol_gfx_imgui.o");
436         compileSource(sokolGfxImguiPath, sokolGfxImguiObj, compiler, cflags, opts.verbose);
437         imguiObjs ~= sokolGfxImguiObj;
438 
439         // Create ImGui library
440         immutable imguiLib = buildPath(buildDir, opts.linkageStatic ? "libcimgui.a" : (opts.target.canFind("darwin") ? "libcimgui.dylib" : opts
441                 .target.canFind("windows") ? "cimgui.dll" : "libcimgui.so"));
442         linkLibrary(imguiLib, imguiObjs, opts.target, opts.linkageStatic, opts.vendor, lflags, opts
443                 .verbose);
444         imguiObjs.each!(obj => exists(obj) && remove(obj));
445     }
446 
447     // Handle Nuklear
448     if (opts.with_sokol_nuklear)
449     {
450         immutable nuklearRoot = absolutePath(buildPath(opts.vendor, "nuklear"));
451         enforce(exists(nuklearRoot), "Nuklear source not found. Ensure it is downloaded.");
452 
453         // Define Nuklear sources
454         string[] nuklearObjs;
455 
456         // Compile sokol_nuklear.c
457         immutable sokolNuklearPath = buildPath(opts.sokolSrcPath, "sokol_nuklear.c");
458         enforce(exists(sokolNuklearPath), "sokol_nuklear.c not found");
459         immutable sokolNuklearObj = buildPath(buildDir, "sokol_nuklear.o");
460         compileSource(sokolNuklearPath, sokolNuklearObj, compiler, cflags, opts
461                 .verbose);
462         nuklearObjs ~= sokolNuklearObj;
463 
464         // Compile nuklearc.c
465         immutable nuklearcPath = absolutePath(buildPath("src", "nuklear", "c", "nuklearc.c"));
466         enforce(exists(nuklearcPath), "nuklearc.c not found in src/nuklear/c");
467         immutable nuklearcObj = buildPath(buildDir, "nuklearc.o");
468         compileSource(nuklearcPath, nuklearcObj, compiler, cflags, opts
469                 .verbose);
470         nuklearObjs ~= nuklearcObj;
471 
472         // Create Nuklear library
473         immutable nuklearLib = buildPath(buildDir, opts.linkageStatic ? "libnuklear.a" : (opts.target.canFind("darwin") ? "libnuklear.dylib" : opts
474                 .target.canFind("windows") ? "nuklear.dll" : "libnuklear.so"));
475         linkLibrary(nuklearLib, nuklearObjs, opts.target, opts.linkageStatic, opts.vendor, lflags, opts
476                 .verbose);
477         nuklearObjs.each!(obj => exists(obj) && remove(obj));
478     }
479 }
480 
481 // Compile a single source file
482 void compileSource(string srcPath, string objPath, string compiler, string[] cflags, bool verbose) @safe
483 {
484     enforce(exists(srcPath), format("Source file %s does not exist", srcPath));
485     string[] cmd = [compiler] ~ cflags ~ ["-c", "-o", objPath, srcPath];
486     if (verbose)
487         writeln("Executing: ", cmd.join(" "));
488     auto result = executeShell(cmd.join(" "));
489     if (verbose && result.output.length)
490         writeln("Output:\n", result.output);
491     enforce(result.status == 0, format("Failed to compile %s: %s", srcPath, result.output));
492 }
493 
494 // Compile multiple sources
495 string[] compileSources(const(string[]) sources, string buildDir, string srcRoot, string compiler, string[] cflags, string prefix, bool verbose) @safe
496 {
497     string[] objFiles;
498     foreach (src; sources)
499     {
500         immutable srcPath = buildPath(srcRoot, src);
501         immutable objPath = buildPath(buildDir, prefix ~ src.baseName ~ ".o");
502         compileSource(srcPath, objPath, compiler, cflags, verbose);
503         objFiles ~= objPath;
504     }
505     return objFiles;
506 }
507 
508 // Link objects into a static or dynamic library
509 void linkLibrary(string libPath, string[] objFiles, string target, bool linkageStatic, string vendor, string[] lflags, bool verbose) @safe
510 {
511     string arCmd = target.canFind("wasm") ? buildPath(vendor, "emsdk", "upstream", "emscripten", "emar") ~ (
512         isWindows ? ".bat" : "") : isWindows ? "lib.exe" : "ar";
513     string[] cmd;
514 
515     if (!linkageStatic && !target.canFind("wasm"))
516     {
517         if (target.canFind("darwin"))
518         {
519             string linker = findProgram("clang");
520             cmd = [linker, "-dynamiclib", "-o", libPath] ~ objFiles ~ lflags;
521         }
522         else if (target.canFind("windows"))
523         {
524             string linker = findProgram("cl");
525             cmd = [linker, "/LD", format("/Fe:%s", libPath)] ~ objFiles ~ lflags;
526         }
527         else // Linux
528         {
529             string linker = findProgram("gcc");
530             cmd = [linker, "-shared", "-o", libPath] ~ objFiles ~ lflags;
531         }
532     }
533     else if (isWindows && !target.canFind("wasm"))
534     {
535         cmd = [arCmd, "/nologo", format("/OUT:%s", libPath)] ~ objFiles;
536     }
537     else
538     {
539         cmd = [arCmd, "rcs", libPath] ~ objFiles;
540     }
541 
542     if (verbose)
543         writeln("Executing: ", cmd.join(" "));
544     auto result = executeShell(cmd.join(" "));
545     if (verbose && result.output.length)
546         writeln("Output:\n", result.output);
547     enforce(result.status == 0, format("Failed to create %s: %s", libPath, result.output));
548 }
549 
550 // Link WASM executable
551 void emLinkStep(EmLinkOptions opts) @safe
552 {
553     string emcc = buildPath(opts.vendor, "emsdk", "upstream", "emscripten", opts.use_imgui ? "em++"
554             : "emcc") ~ (
555         isWindows ? ".bat" : "");
556     string[] cmd = [emcc];
557 
558     if (opts.use_imgui)
559         cmd ~= "-lcimgui";
560     if (opts.use_nuklear)
561         cmd ~= "-lnuklear";
562     if (opts.optimize == "debug")
563         cmd ~= ["-Og", "-sSAFE_HEAP=1", "-sSTACK_OVERFLOW_CHECK=1"];
564     else
565     {
566         cmd ~= "-sASSERTIONS=0";
567         cmd ~= opts.optimize == "small" ? "-Oz" : "-O3";
568         if (opts.release_use_lto)
569             cmd ~= "-flto";
570         if (opts.release_use_closure)
571             cmd ~= ["--closure", "1"];
572     }
573 
574     if (opts.backend == SokolBackend.wgpu)
575         cmd ~= "--use-port=emdawnwebgpu";
576     if (opts.backend == SokolBackend.gles3)
577         cmd ~= "-sUSE_WEBGL2=1";
578     if (!opts.use_filesystem)
579         cmd ~= "-sNO_FILESYSTEM=1";
580     if (opts.use_emmalloc)
581         cmd ~= "-sMALLOC='emmalloc'";
582     if (opts.shell_file_path)
583         cmd ~= "--shell-file=" ~ opts.shell_file_path;
584 
585     cmd ~= ["-sSTACK_SIZE=512KB"] ~ opts.extra_args ~ opts.lib_main;
586     immutable baseName = opts.lib_main.baseName[3 .. $ - 2]; // Strip "lib" and ".a"
587     string outFile = buildPath("build", baseName ~ ".html");
588     cmd ~= ["-o", outFile];
589 
590     if (opts.verbose)
591         writeln("Executing: ", cmd.join(" "));
592     auto result = executeShell(cmd.join(" "));
593     if (opts.verbose && result.output.length)
594         writeln("Output:\n", result.output);
595     enforce(result.status == 0, format("emcc failed: %s: %s", outFile, result.output));
596 
597     string webDir = "web";
598     mkdirRecurse(webDir);
599     foreach (ext; [".html", ".wasm", ".js"])
600         copy(buildPath("build", baseName ~ ext), buildPath(webDir, baseName ~ ext));
601     rmdirRecurse(buildPath("build"));
602 }
603 
604 // Run WASM executable
605 void emRunStep(EmRunOptions opts) @safe
606 {
607     string emrun = buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "emrun") ~ (
608         isWindows ? ".bat" : "");
609     executeOrFail([emrun, buildPath("web", opts.name ~ ".html")], "emrun failed", opts.verbose);
610 }
611 
612 // Setup Emscripten SDK
613 void emSdkSetupStep(string emsdk) @safe
614 {
615     if (!exists(buildPath(emsdk, ".emscripten")))
616     {
617         immutable cmd = buildPath(emsdk, "emsdk") ~ (isWindows ? ".bat" : "");
618         executeOrFail([!isWindows ? "bash " ~ cmd: cmd, "install", "latest"], "emsdk install failed", true);
619         executeOrFail([!isWindows ? "bash " ~ cmd: cmd, "activate", "latest"], "emsdk activate failed", true);
620     }
621 }
622 
623 void embuilderStep(EmbuilderOptions opts) @safe
624 {
625     string embuilder = buildPath(opts.vendor, "emsdk", "upstream", "emscripten", "embuilder") ~ (
626         isWindows ? ".bat" : "");
627     string[] bFlags = ["build", opts.port_name];
628     executeOrFail(embuilder ~ bFlags, "embuilder failed to build " ~ opts.port_name, true);
629 }
630 
631 // Utility functions
632 string findProgram(string programName) @safe
633 {
634     foreach (path; environment.get("PATH").split(pathSeparator))
635     {
636         string fullPath = buildPath(path, programName);
637         version (Windows)
638             fullPath ~= ".exe";
639         if (exists(fullPath) && isFile(fullPath))
640             return fullPath;
641     }
642     throw new Exception(format("Program '%s' not found in PATH", programName));
643 }
644 
645 string defaultCompiler(string target) @safe
646 {
647     if (target.canFind("wasm"))
648         return "";
649     version (linux)
650         return findProgram("gcc");
651     version (Windows)
652         return findProgram("cl");
653     version (OSX)
654         return findProgram("clang");
655     version (Android)
656         return findProgram("clang");
657     throw new Exception("Unsupported platform");
658 }
659 
660 SokolBackend resolveSokolBackend(SokolBackend backend, string target) @safe
661 {
662     if (target.canFind("linux"))
663         return SokolBackend.glcore;
664     if (target.canFind("darwin"))
665         return SokolBackend.metal;
666     if (target.canFind("windows"))
667         return SokolBackend.d3d11;
668     if (target.canFind("wasm"))
669         return backend == SokolBackend.wgpu ? backend : SokolBackend.gles3;
670     if (target.canFind("android"))
671         return SokolBackend.gles3;
672     version (linux)
673         return SokolBackend.glcore;
674     version (Windows)
675         return SokolBackend.d3d11;
676     version (OSX)
677         return SokolBackend.metal;
678     return backend;
679 }
680 
681 void executeOrFail(string[] cmd, string errorMsg, bool verbose) @safe
682 {
683     if (verbose)
684         writeln("Executing: ", cmd.join(" "));
685     auto result = executeShell(cmd.join(" "));
686     if (verbose && result.output.length)
687         writeln("Output:\n", result.output);
688     enforce(result.status == 0, format("%s: %s", errorMsg, result.output));
689 }
690 
691 bool isWindows() @safe
692 {
693     version (Windows)
694         return true;
695     return false;
696 }
697 
698 // Download and extract functions
699 void download(string url, string fileName) @trusted
700 {
701     auto buf = appender!(ubyte[])();
702     size_t contentLength;
703     auto http = HTTP(url);
704     http.onReceiveHeader((in k, in v) {
705         if (k == "content-length")
706             contentLength = to!size_t(v);
707     });
708 
709     int barWidth = 50;
710     http.onReceive((data) {
711         buf.put(data);
712         if (contentLength)
713         {
714             float progress = cast(float) buf.data.length / contentLength;
715             write("\r[", "=".replicate(cast(int)(barWidth * progress)), ">", " ".replicate(
716                 barWidth - cast(int)(barWidth * progress)), "] ",
717                 format("%d%%", cast(int)(progress * 100)));
718             stdout.flush();
719         }
720         return data.length;
721     });
722 
723     http.perform();
724     enforce(http.statusLine.code / 100 == 2 || http.statusLine.code == 302, format(
725             "HTTP request failed: %s", http.statusLine.code));
726     std.file.write(fileName, buf.data);
727     writeln();
728 }
729 
730 void extractZip(string zipFile, string destination) @trusted
731 {
732     ZipArchive archive = new ZipArchive(read(zipFile));
733     string prefix = archive.directory.keys.front[0 .. $ - archive.directory.keys.front.find("/")
734             .length + 1];
735 
736     if (exists(destination))
737         rmdirRecurse(destination);
738     mkdirRecurse(destination);
739 
740     foreach (name, am; archive.directory)
741     {
742         if (!am.expandedSize)
743             continue;
744         string path = buildPath(destination, chompPrefix(name, prefix));
745         mkdirRecurse(dirName(path));
746         std.file.write(path, archive.expand(am));
747     }
748 }
749 
750 string getSHDC(string vendor) @safe
751 {
752     string path = absolutePath(buildPath(vendor, "shdc"));
753     string file = "shdc.zip";
754     scope (exit)
755         if (exists(file))
756             remove(file);
757 
758     if (!exists(path))
759     {
760         download("https://github.com/floooh/sokol-tools-bin/archive/refs/heads/master.zip", file);
761         extractZip(file, path);
762     }
763 
764     version (Windows)
765         immutable shdc = buildPath("bin", "win32", "sokol-shdc.exe");
766     else version (linux)
767         immutable shdc = buildPath("bin", isAArch64 ? "linux_arm64" : "linux", "sokol-shdc");
768     else version (OSX)
769         immutable shdc = buildPath("bin", isAArch64 ? "osx_arm64" : "osx", "sokol-shdc");
770     else
771         throw new Exception("Unsupported platform for sokol-tools");
772 
773     return buildPath(path, shdc);
774 }
775 
776 bool isAArch64() @safe
777 {
778     version (AArch64)
779         return true;
780     return false;
781 }
782 
783 string defaultTarget() @safe
784 {
785     version (linux)
786         return "linux";
787     version (Windows)
788         return "windows";
789     version (OSX)
790         return "darwin";
791     version (Android)
792         return "android";
793     version (Emscripten)
794         return "wasm";
795     throw new Exception("Unsupported platform");
796 }