Erik's Blog - Advent of Code 2024 on a Nintendo 3DS - 2024-12-27

A Special Advent Calendar

For the past couple of years, my Decembers have been filled with a special kind of past time: writing little programs to help out at the North Pole when things go inevitably awry before Christmas. This is the main story of Advent of Code. Each day contains a puzzle that can be solved in any programming language.

This year, for example, it was all about finding the Chief Historian so that he could document the sleigh launch for posterity; since this is the tenth year of Advent of Code, the journey took you rather fittingly to various locations visited in previous years; even the image that is revealed bit by bit is made up of parts of the images of the previous years.

I’m not sure, how common Advent Calendars are in other parts of the world, but in Germany (where I am from) they are a staple of the pre-Christmas time in the same way that Christmas Markets are.

Usually, they contain a piece of chocolate each day to sweeten the waiting time until Christmas. In the case of Advent of Code, there is a puzzle each day to distract you from pre-Christmas obligations like holiday parties and buying presents.

For people interested in the origins and a bit of what goes on behind the scenes, I can recommend this talk by Eric Wastl, the creator of Advent of Code.

As someone who studied maths at university and did not go through a computer science curriculum, Advent of Code presents an opportunity to find gaps in my understanding of the field in which I now earn a living; concepts of which computer science students have at least a cursory understanding, whether they liked the subject or not - like machine architecture or data structures and algorithms -; these are things you can easily ignore in your day to day life in many programming jobs, especially if you do application development; however, it will hold you back significantly in the long run. It’s safe to say that I would not be doing what I am today without Advent of Code.

This kind of a-puzzle-a-day setting is also a good opportunity to try out a new programming language, so for the past years, I have tried to do a different one each year. If you’re interested, you can check out the repositories on my sourcehut. A word of advice, though: try to settle on a language and look into it a little bit before you start; I did not do that in 2023 and now have a repository with a mixture of Zig, Haskell, and Swift on my hands.

Anyway, I will be forever grateful to the colleague who introduced me to this a couple of years ago!

2024, the Year of Zig on a 3DS

Recently, I modded my old Nintendo 3DS and figured out how to write Homebrew applications in Zig. How could I resist trying to run Advent of Code on it?

User Interface

First things first, how do we even display anything? While there are ways of doing sophisticated GUI programming, e.g., using SDL, it seemed a little too involved for a first draft; instead, we can simply use the libctru type PrintConsole to have a normal console on the top and bottom screen of the 3DS which we can then printf to.

In this case we just put a heading on the top screen and display the individual days on the bottom screen via a counter that goes from 1 to 25 and is adjusted with the directional pad.

const std = @import("std");
const ds = @import("3ds.zig");
const aoc = @import("aoc");

const input01 = @embedFile("input/day01.txt");
const input02 = @embedFile("input/day02.txt");

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

    var top: ds.PrintConsole = undefined;
    var bottom: ds.PrintConsole = undefined;
    _ = ds.consoleInit(ds.GFX_TOP, &top);
    _ = ds.consoleInit(ds.GFX_BOTTOM, &bottom);

    _ = ds.consoleSelect(&top);
    _ = ds.printf("\x1b[16;15HAdvent of Code 2024\n");
    _ = ds.printf("\x1b[30;15HPress Start to exit.\n");

    // put everything else on the bottom screen
    _ = ds.consoleSelect(&bottom);

    var count: u8 = 1;
    while (ds.aptMainLoop()) {
        ds.hidScanInput();
        const kDown: u32 = ds.hidKeysDown();
        if (kDown & ds.KEY_START > 0) break;
        if (kDown & ds.KEY_DUP > 0) {
            if (count == 25) count = 0;
            count += 1;
        } else if (kDown & ds.KEY_DDOWN > 0) {
            if (count == 1) count = 26;
            count -= 1;
        } else if (kDown & ds.KEY_A > 0) {
            // clear the screen
            _ = ds.printf("\x1b[J");
            _ = ds.printf("\x1b[15;10HDay %d ", count);
            switch (count) {
                1 => {
                    const p1 = aoc.day01.part1(std.heap.c_allocator, input01) catch 0;
                    _ = ds.printf("\x1b[16;10HPart 1: %d", p1);
                    const p2 = aoc.day01.part2(std.heap.c_allocator, input01) catch 0;
                    _ = ds.printf("\x1b[17;10HPart 2: %d", p2);
                },
                2 => {
                    const p1 = aoc.day02.part1(input02) catch 0;
                    _ = ds.printf("\x1b[16;10HPart 1: %d", p1);
                    const p2 = aoc.day02.part2(std.heap.c_allocator, input02) catch 0;
                    _ = ds.printf("\x1b[17;10HPart 2: %d", p2);
                },
                else => {
                    _ = ds.printf("\x1b[16;10HNot implemented, yet!");
                },
            }
        }
        _ = ds.printf("\x1b[13;7HWhich day should be run? %d \n", count);
        ds.gfxFlushBuffers();
        ds.gfxSwapBuffers();
        ds.gspWaitForEvent(ds.GSPGPU_EVENT_VBlank0, true);
    }
}

Next, how do we even read a file? What we would normally do is something like this:

var file = try std.fs.cwd().openFile("day01.txt", .{});
defer file.close();

var buf_reader = std.io.bufferedReader(file.reader());

var buf: [1024]u8 = undefined;
while (try buf_reader.reader().readUntilDelimiterOrEof(&buf, '\n')) |line| {
    std.debug.print("value: {s}\n", .{line});
}

As described in my previous post, we cannot do this in our 3DS environment. Instead, we can take one of two approaches.

@embed

We can simply @embed the input file as a string. This means that the program only knows this one specific input file and it needs to be available at compile time. This is fine for an Advent of Code application because you are unlikely to have to run the same day with different input files - unless one of your friends gets stuck in the fun situation of their program working for the example but not the real input; we’ve all been there.

This has the advantage that we can run the executable on an emulator on our development machine before pushing it to the actual 3DS. It also means that we don’t have to worry about the input file being anywhere on the 3DS, because the contents are already part of the executable.

Using libctru Facilities

pub usingnamespace @cImport({
    @cInclude("stdio.h");
    @cInclude("3ds.h");
});

const std = @import("std");
const ds = @This();

pub const AoCInput = struct {
    allocator: std.mem.Allocator,
    buffer: []u8,

    pub fn init(allocator: std.mem.Allocator, path: [*c]const u8) !AoCInput {
        const file: [*c]ds.FILE = ds.fopen(path, "r");
        if (file == null) {
            return error.FileNotFound;
        }
        defer _ = ds.fclose(file);

        // seek to end of file
        _ = ds.fseek(file, 0, ds.SEEK_END);

        // file pointer tells us the size
        const size = ds.ftell(file);
        if (size < 0) {
            return error.FileNotRead;
        }

        // seek back to start
        _ = ds.fseek(file, 0, ds.SEEK_SET);

        //allocate a buffer
        const buffer = try allocator.alloc(u8, @bitCast(size));
        errdefer allocator.free(buffer);

        //read contents !
        const bytesRead = ds.fread(@ptrCast(buffer.ptr), 1, @bitCast(size), file);

        if (size != bytesRead) {
            return error.FileNotRead;
        }
        return AoCInput{
            .buffer = buffer,
            .allocator = allocator,
        };
    }

    pub fn deinit(self: *AoCInput) void {
        self.allocator.free(self.buffer);
    }
};

This reads the contents of the file at the path on the 3DS’ SD card into an appropriately sized allocated []u8 buffer, which can then be passed to the functions for the individual advent days.

We do need to make sure to transfer the input file to the 3DS in addition to the executable. If you are wondering how to get the executable or the input files onto the 3DS, it’s done via FTP.

aoc Module and a Local CLI

Years of maintenance have ingrained in me the desire to separate UI code from application logic as much as possible. So, we encapsulate the actual implementations of the solutions to the individual days into a module, which we can import and use.

Our directory structure looks something like this:

build.zig
src/
    input/
        day01.txt
        day02.txt
    aoc/
        lib.zig
        day01.zig
        day02.zig
    3ds.zig
    main_3ds.zig
    main_cli.zig

The aoc module is exposed from lib.zig and then imported where needed.

// src/aoc/lib.zig
pub const day01 = @import("day01.zig");
pub const day02 = @import("day02.zig");

This allows us to have a local CLI that uses the same code as the 3DS application for the individual days which is great when you’re working on a machine that may not have all of the dependencies to build 3DS homebrew. For example when you’re doing the puzzle during an extended coffee break on your work machine. Or if you want to know the answer to a puzzle that the 3DS cannot handle.

// src/main_cli.zig
const std = @import("std");
const aoc = @import("aoc");

pub fn main() !void {
    const input01 = @embedFile("input/day01.txt");
    const input02 = @embedFile("input/day02.txt");
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const ally = gpa.allocator();
    var args = std.process.args();
    while (args.next()) |arg| {
        const day = std.fmt.parseInt(i32, arg, 10) catch continue;
        switch (day) {
            1 => {
                std.debug.print("{d}\n", .{try aoc.day01.part1(ally, input01)});
                std.debug.print("{d}\n", .{try aoc.day01.part2(ally, input01)});
            },
            2 => {
                std.debug.print("{d}\n", .{try aoc.day02.part1(input02)});
                std.debug.print("{d}\n", .{try aoc.day02.part2(ally, input02)});
            },
            else => std.debug.print("Not implemented", .{}),
        }
    }
}

For this to work, we need to adjust our build.zig file:

// build.zig
pub fn build(b: *std.Build) !void {
//...
    const libaoc = b.createModule(.{
        .root_source_file = b.path("src/aoc/lib.zig"),
    });

    const cli = b.addExecutable(.{
        .name = "aoc24",
        .root_source_file = b.path("src/main_cli.zig"),
        .optimize = optimize,
        .target = local_target,
    });
    cli.root_module.addImport("aoc", libaoc);
    const cli_install = b.addInstallArtifact(cli, .{});
    const cli_step = b.step("cli", "Build CLI");
    cli_step.dependOn(&cli_install.step);

Limitations

Now, the 3DS is an old piece of hardware, so it has some limitations compared to my development machine. For starters, it only supports 32-bit integers out of the box. This means that the puzzles that have larger answers cannot run on the 3DS without extra effort (which at the moment I have not gone the extra mile for). You could arguably use a Ripple-Carry Adder, just make sure it’s well-configured; looking at you day 24.

It can also only handle SIMD @Vectors with a total size of 32 bits, i.e., for a 2D coordinate as is so often used during Advent of Code, we can only use 16-bit integers as the individual components. Mind you, that should usually be more than sufficient, but it may catch you off guard when your 3DS suddenly crashes, just because you used usize for the @Vector components.

If this got you interested in the 3DS’ hardware architecture, I highly recommend this article by Rodrigo Copetti. He has already analyzed a variety of game consoles and the write-ups are a very worthwhile read!

Final Thoughts

I very much enjoyed my time writing Zig this month! I will say though, that it is likely not the best language for something like Advent of Code if your main goal is just to solve the puzzles; there are better languages for this kind of one-and-done scenario where you want to be able to quickly write down your thoughts as an algorithm that does not necessarily need to be rock-solid or consider too many edge cases.

Zig feels like a language that has been designed to do things properly which is a great quality when you’re doing software engineering! Passing around allocators and being very conscious of which parts of your code need to allocate memory at run-time is a very neat feature. Having functions that need to allocate memory accept an allocator as one of their parameters allows the caller to be very specific about how the memory will be allocated, whether it be via a normal heap allocation or by using a buffer defined at compile-time with FixedBufferAllocator.

As for this year’s Advent of Code, I still have two more stars left this year, and due to time constraints I actually switched to Python starting on day 21. I’m looking forward to cleaning that up over the next few days. Maybe this will be the year that I actually finish an entire calendar!