The Skylark's Garden

Failing To Do 3DS Homebrew with Zig 0.16.0

2026-07-28

I had a 3DS homebrew project I wanted to make. I didn't want to use C, because I would generally prefer to avoid writing C when I can. I didn't want to use Rust, because the community's libctru bindings don't appear to be very comprehensive yet and I didn't want to spend the whole project writing my own safe bindings. So I decided to go with Zig.

I then wrote up this post while I went, assuming I'd succeed. I did not. Let this be a cautionary tale, though I'm not really sure what the lesson is supposed to be.

The issue is that while there are guides out there for 3DS homebrew with Zig, Zig seems to have had numerous breaking changes since they were written.

So, just in case any other stubborn motherfuckers out there get themselves into this weird position by refusing to just use C or C++, I thought I'd document my own efforts to get it working. I'm basically doing exactly what Erik Wastaken did in his blog post on the topic, but with the current version of Zig, 0.16.0. This post owes a lot to his writeup!

So we are going to:

  1. Start with a C "hello world" program.
  2. Convert the makefile into a build.zig.
  3. Alter the build.zig to use Zig itself as the C compiler.
  4. Alter the build.zig and source code to use Zig instead of C.

See how I'm switching to first-person plural? That's how you know I'm personable and writing a guide and not just documenting the random bullshit I tried.

devkitpro Hello World

Trivial.

$ cp -r $DEVKITPRO/examples/3ds/templates/application/* .
$ make

We get a 3dsx file, which can be uploaded and run on a 3DS via 3dslink. This is the easy part where I just do things the way I probably ought to be doing it to begin with.

Convert the makefile

Then get the actual commands that were run, by rebuilding with V=1.

$ make clean
$ make V=1
arm-none-eabi-gcc -MMD -MP -MF /home/skylar/software/scurvytracker/build/main.d   -g -Wall -O2 -mword-relocations -ffunction-sections -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft -I/home/skylar/software/scurvytracker/include -I/opt/devkitpro/libctru/include -I/home/skylar/software/scurvytracker/build -D__3DS__ -c /home/skylar/software/scurvytracker/source/main.c -o main.o
arm-none-eabi-gcc -specs=3dsx.specs -g -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft -Wl,-Map,scurvytracker.map      main.o  -L/opt/devkitpro/libctru/lib -lctru -lm -o /home/skylar/software/scurvytracker/scurvytracker.elf
arm-none-eabi-gcc-nm -CSn /home/skylar/software/scurvytracker/scurvytracker.elf > scurvytracker.lst
smdhtool --create "scurvytracker" "Built with devkitARM & libctru" "Unspecified Author" /opt/devkitpro/libctru/default_icon.png /home/skylar/software/scurvytracker/scurvytracker.smdh
3dsxtool /home/skylar/software/scurvytracker/scurvytracker.elf /home/skylar/software/scurvytracker/scurvytracker.3dsx --smdh=/home/skylar/software/scurvytracker/scurvytracker.smdh

Wow! Horrible long commands. Truly now we are become dweeb, developer of software.

The first command builds an object file with devkitARM's C compiler. This is the step we're eventually going to replace with Zig's C compiler and then Zig itself.

The second command uses devkitARM's linker to produce an ELF binary. Erik Wastaken had trouble switching this to Zig's linker, hypothesizing there are some 3DS specific quirks that the devkitARM linker is able to account for but the Zig one cannot, so I am ✨ Not Going To Bother Trying To Do That ✨.

The third command generates a list of the symbols in our ELF file. Erik says this is optional and for my part I can't see where anything is actually using it, so he's probably right.

The fourth command generates an SMDH file, which is basically a 3DS application's metadata.

Then finally, the fifth command patches the ELF binary and the SMDH metadata into a 3dsx file that can be loaded into the Homebrew menu. There are also tools out there that can create CIA files to install directly on the home screen, specifically makerom, which is not packaged with devkitpro. I do want my application to have this in the end, both for the swag and also because you can embed a manual which is accessible via the home menu. But I won't be bothering with any of that yet, because 3dsx is way more convenient for prototyping.

const std = @import("std");
const builtin = @import("builtin");

const devkitpro = "/opt/devkitpro";
const devkitarm = "/opt/devkitpro/devkitARM";

pub fn build(b: *std.Build) void {
    // WriteFiles step which lets us do all of this in a cache directory
    const wf = b.addWriteFiles();
    const extension = if (builtin.target.os.tag == .windows) ".exe" else "";

    // Compile the hello world to an object file
    const obj = b.addSystemCommand(&.{devkitarm ++ "/bin/arm-none-eabi-gcc" ++ extension});
    obj.addArgs(&.{ "-MMD", "-MP", "-g", "-Wall", "-O2", "-mword-relocations", "-ffunction-sections", "-march=armv6k", "-mtune=mpcore", "-mfloat-abi=hard", "-mtp=soft", "-I/opt/devkitpro/libctru/include", "-D__3DS__", "-c", "/home/skylar/software/scurvytracker/source/main.c" });
    const obj_out = obj.addPrefixedOutputFileArg("-o", "main.o");

    // Link the object file to an ELF binary
    const elf = b.addSystemCommand(&.{devkitarm ++ "/bin/arm-none-eabi-gcc" ++ extension});
    elf.setCwd(wf.getDirectory());
    elf.addArgs(&.{ "-specs=3dsx.specs", "-g", "-march=armv6k", "-mtune=mpcore", "-mfloat-abi=hard", "-mtp=soft" });
    _ = elf.addPrefixedOutputFileArg("-Wl,-Map,", "scurvytracker.map");
    elf.addFileArg(obj_out);
    elf.addArgs(&.{ "-L/opt/devkitpro/libctru/lib", "-lctru", "-lm" });
    const out_elf = elf.addPrefixedOutputFileArg("-o", "scurvytracker.elf");

    // Generate SMDH metadata
    const smdh = b.addSystemCommand(&.{devkitpro ++ "/tools/bin/smdhtool" ++ extension});
    smdh.setCwd(wf.getDirectory());
    smdh.addArgs(&.{ "--create", "scurvytracker", "Built with Zig, devkitARM, and libctru.", "Void Sleeper", "/opt/devkitpro/libctru/default_icon.png" });
    const out_smdh = smdh.addOutputFileArg("scurvytracker.smdh");

    // Create the final 3DSX file
    const dsx = b.addSystemCommand(&.{devkitpro ++ "/tools/bin/3dsxtool" ++ extension});
    dsx.setCwd(wf.getDirectory());
    dsx.addFileArg(out_elf);
    const out_dsx = dsx.addOutputFileArg("scurvytracker.3dsx");
    dsx.addPrefixedFileArg("--smdh=", out_smdh);

    // Copy the 3DSX file to the install directory, leaving all of its precursors behind in the cache
    const install_3dsx = b.addInstallFileWithDir(out_dsx, .prefix, "scurvytracker.3dsx");
    b.getInstallStep().dependOn(&install_3dsx.step);
}

So far so basically identical to Erik's guide. Why am I writing this again?

Use Zig C compiler

-     const obj = b.addSystemCommand(&.{devkitarm ++ "/bin/arm-none-eabi-gcc" ++ extension});
-     obj.addArgs(&.{ "-MMD", "-MP", "-g", "-Wall", "-O2", "-mword-relocations", "-ffunction-sections", "-march=armv6k", "-mtune=mpcore", "-mfloat-abi=hard", "-mtp=soft", "-I/opt/devkitpro/libctru/include", "-D__3DS__", "-c", "/home/skylar/software/scurvytracker/source/main.c" });
-     const obj_out = obj.addPrefixedOutputFileArg("-o", "main.o");
---
+     const optimize = b.standardOptimizeOption(.{});
+     const target: std.Target.Query = .{
+         .cpu_arch = .arm,
+         .os_tag = .freestanding,
+         .abi = .eabihf,
+         .cpu_model = .{ .explicit = &std.Target.arm.cpu.mpcore },
+     };
+     const obj = b.addObject(.{
+         .name = "scurvytracker",
+         .root_module = b.createModule(.{
+             .optimize = optimize,
+             .target = b.resolveTargetQuery(target),
+         }),
+     });
+     obj.root_module.addCSourceFiles(.{
+         .root = b.path("source/"),
+         .files = &.{"main.c"},
+         .flags = &.{
+             "-MMD",
+             "-MP",
+             "-g",
+             "-Wall",
+             "-O2",
+             "-ffunction-sections",
+             "-march=armv6k",
+             "-mtune=mpcore",
+             "-mfloat-abi=hard",
+             "-mtp=soft",
+             "-D__3DS__",
+         },
+     });
+     obj.root_module.addIncludePath(.{ .src_path = .{ .owner = b, .sub_path = devkitpro ++ "/libctru/include" } });
+     obj.root_module.addIncludePath(.{ .src_path = .{ .owner = b, .sub_path = devkitarm ++ "/arm-none-eabi/include" } });
22c52
-     elf.addFileArg(obj_out);
---
+     elf.addArtifactArg(obj);

Ah, that's why. A lot of things are the same as Erik's guide but notably, Zig now requires you to package compiler options and things into a "module". Also, the horrible code there for adding the include path is because Zig really does not want you to use absolute paths anywhere in your build script for reasons which I cannot even begin to understand. The error message when I tried said something about a LazyPath.cwd_relative which handles it but it does not appear to exist.

This particular build script also gives the warnings:

/opt/devkitpro/devkitARM/arm-none-eabi/bin/ld: warning: ./../db02d4ec2abb95122acf5ee3ab40fee3/scurvytracker.o uses 32-bit enums yet the output is to use variable-size enums; use of enum values across objects may fail
/opt/devkitpro/devkitARM/arm-none-eabi/bin/ld: warning: /opt/devkitpro/devkitARM/lib/gcc/arm-none-eabi/16.1.0/armv6k/fpu/crtn.o: missing .note.GNU-stack section implies executable stack
/opt/devkitpro/devkitARM/arm-none-eabi/bin/ld: NOTE: This behaviour is deprecated and will be removed in a future version of the linker
failed command: cd .zig-cache/o/2fb5824d4233e9fe32370ab196a5b16d && /opt/devkitpro/devkitARM/bin/arm-none-eabi-gcc -specs=3dsx.specs -g -march=armv6k -mtune=mpcore -mfloat-abi=hard -mtp=soft -Wl,-Map,/home/skylar/software/scurvytracker/.zig-cache/o/5f7976b54257210ef9d2ed2712a55036/scurvytracker.map ./../db02d4ec2abb95122acf5ee3ab40fee3/scurvytracker.o -L/opt/devkitpro/libctru/lib -lctru -lm -o/home/skylar/software/scurvytracker/.zig-cache/o/5f7976b54257210ef9d2ed2712a55036/scurvytracker.elf

I have no idea what that's about but it is concerning. The 3DSX file does appear to build properly though, and runs when I send it to my 3DS, so. Problem for future Skylar.

Use Zig linker

I did not attempt to do this. I've seen it done in some other guides though so presumably it's possible, though I am unsure if there's any real reason to do it.

Use Zig source code

And this is where it all went wrong. It was fairly easy to translate the C hello world program into Zig. It was fairly easy to rewrite the build.zig to compile a Zig file instead of a C file. Except it won't compile properly:

source/main.zig:3:13: error: C import failed
const ctr = @cImport({
            ^~~~~~~~
source/main.zig:3:13: note: libc headers not available; compilation does not link against libc
referenced by:
    main: source/main.zig:9:5
    main: source/main.zig:8:1
    4 reference(s) hidden; use '-freference-trace=6' to see all references
error: translation failure
/opt/devkitpro/libctru/include/sys/select.h:1:15: error: 'sys/select.h' not found
#include_next <sys/select.h>
              ^

This is baffling because the directory which includes <sys/select.h> ($DEVKITARM/arm-none-eabi/include) is definitely in our include path. I thought maybe it's the lack of having linked libc, and that using the Zig linker was going to be mandatory after all. So I added a libc.txt and configured the module to link libc, referencing that libc.txt. Same error except the warning about not having libc headers available goes away.

For posterity, here are all the relevant files.

build.zig

const std = @import("std");
const builtin = @import("builtin");

const devkitpro = "/opt/devkitpro";
const devkitarm = "/opt/devkitpro/devkitARM";

pub fn build(b: *std.Build) void {
    // WriteFiles step which lets us do all of this in a cache directory
    const wf = b.addWriteFiles();
    const extension = if (builtin.target.os.tag == .windows) ".exe" else "";

    // Compile the hello world to an object file
    const optimize = b.standardOptimizeOption(.{});
    const target: std.Target.Query = .{
        .cpu_arch = .arm,
        .os_tag = .freestanding,
        .abi = .eabihf,
        .cpu_model = .{ .explicit = &std.Target.arm.cpu.mpcore },
    };
    const obj = b.addObject(.{
        .name = "scurvytracker",
        .root_module = b.createModule(.{
            .root_source_file = b.path("source/main.zig"),
            .optimize = optimize,
            .target = b.resolveTargetQuery(target),
            .link_libc = true,
        }),
    });
    obj.root_module.addIncludePath(.{ .src_path = .{ .owner = b, .sub_path = devkitpro ++ "/libctru/include" } });
    obj.root_module.addIncludePath(.{ .src_path = .{ .owner = b, .sub_path = devkitarm ++ "/arm-none-eabi/include" } });
    obj.setLibCFile(b.path("libc.txt"));

    // Link the object file to an ELF binary
    const elf = b.addSystemCommand(&.{devkitarm ++ "/bin/arm-none-eabi-gcc" ++ extension});
    elf.setCwd(wf.getDirectory());
    elf.addArgs(&.{ "-specs=3dsx.specs", "-g", "-march=armv6k", "-mtune=mpcore", "-mfloat-abi=hard", "-mtp=soft" });
    _ = elf.addPrefixedOutputFileArg("-Wl,-Map,", "scurvytracker.map");
    elf.addArtifactArg(obj);
    elf.addArgs(&.{ "-L/opt/devkitpro/libctru/lib", "-lctru", "-lm" });
    const out_elf = elf.addPrefixedOutputFileArg("-o", "scurvytracker.elf");

    // Generate SMDH metadata
    const smdh = b.addSystemCommand(&.{devkitpro ++ "/tools/bin/smdhtool" ++ extension});
    smdh.setCwd(wf.getDirectory());
    smdh.addArgs(&.{ "--create", "scurvytracker", "Built with Zig, devkitARM, and libctru.", "Void Sleeper", "/opt/devkitpro/libctru/default_icon.png" });
    const out_smdh = smdh.addOutputFileArg("scurvytracker.smdh");

    // Create the final 3DSX file
    const dsx = b.addSystemCommand(&.{devkitpro ++ "/tools/bin/3dsxtool" ++ extension});
    dsx.setCwd(wf.getDirectory());
    dsx.addFileArg(out_elf);
    const out_dsx = dsx.addOutputFileArg("scurvytracker.3dsx");
    dsx.addPrefixedFileArg("--smdh=", out_smdh);

    // Copy the 3DSX file to the install directory, leaving all of its precursors behind in the cache
    const install_3dsx = b.addInstallFileWithDir(out_dsx, .prefix, "scurvytracker.3dsx");
    b.getInstallStep().dependOn(&install_3dsx.step);
}

libc.txt

# The directory that contains `stdlib.h`.
# On POSIX-like systems, include directories be found with: `cc -E -Wp,-v -xc /dev/null`
include_dir=/opt/devkitpro/devkitARM/arm-none-eabi/include

# The system-specific include directory. May be the same as `include_dir`.
# On Windows it's the directory that includes `vcruntime.h`.
# On POSIX it's the directory that includes `sys/errno.h`.
sys_include_dir=/opt/devkitpro/devkitARM/arm-none-eabi/include

# The directory that contains `crt1.o` or `crt2.o`.
# On POSIX, can be found with `cc -print-file-name=crt1.o`.
# Not needed when targeting MacOS.
crt_dir=/opt/devkitpro/devkitARM/arm-none-eabi/lib/armv6k/fpu

# The directory that contains `vcruntime.lib`.
# Only needed when targeting MSVC on Windows.
msvc_lib_dir=

# The directory that contains `kernel32.lib`.
# Only needed when targeting MSVC on Windows.
kernel32_lib_dir=

# The directory that contains `crtbeginS.o` and `crtendS.o`
# Only needed when targeting Haiku.
gcc_dir=

main.zig

const std = @import("std");

const ctr = @cImport({
    @cInclude("stdio.h");
    @cInclude("3ds.h");
});

export fn main(_: c_int, _: [*]const [*:0]const u8) void {
    ctr.gfxInitDefault();
    defer ctr.gfxExit();

    _ = ctr.consoleInit(ctr.GFX_TOP, null);
    _ = ctr.printf("Hello, world!\n");

    while (ctr.aptMainLoop()) {
        ctr.gspWaitForVBlank();
        ctr.gfxSwapBuffers();
        ctr.hidScanInput();

        const kDown = ctr.hidKeysDown();
        if (kDown & ctr.KEY_START) break;
    }
}

In conclusion

Well I guess it probably won't kill me to write some C++. But this actually might kill me.