AoC 2024 Day 17: Virtual Machininator

Source: Day 17: Chronospatial Computer

Full solution for today (spoilers!).

Part 1

Implement a virtual machine. The machine will have 3 unbounded signed registers, 8 opcodes (see below), a variable parameter scheme (see below that). You will be given the initial values of the 3 registers and a program. Find the final output.

Instructions

OpcodeInstructionDescriptionNotes
0adv reg/valA = A >> OP
1bxl valB = B ^ OP
2bst reg/valB = OP & 0b111
3jnz valIf a =/= 0, jump to LIT
4bxc ignoreB = B ^ CStill takes param, but ignores it
5out reg/valOutput bOnly outputs lowest 3 bits
6bdv reg/valB = A >> OPSame as adv but writes to b
7cdv reg/valC = A >> OPSame as adv but writes to c

Parameter specification

For instructions that can take reg/val, 0 to 3 (inclusive) are treated as literal values, 4 is register A, 5 is B, 6, is C, and 7 is an error (should never happen).

For instructions that only take val, it’s always a literal value in the range 0 to 7 (inclusive).

I do love virtual machines.

Here’s a representation of the instructions, parameters, and the virtual machine itself:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Instruction {
    Adv,
    Bxl,
    Bst,
    Jnz,
    Bxc,
    Out,
    Bdv,
    Cdv,
}

impl From<u8> for Instruction {
    fn from(value: u8) -> Self {
        match value {
            0 => Self::Adv,
            1 => Self::Bxl,
            2 => Self::Bst,
            3 => Self::Jnz,
            4 => Self::Bxc,
            5 => Self::Out,
            6 => Self::Bdv,
            7 => Self::Cdv,
            _ => panic!("Invalid instruction"),
        }
    }
}

impl fmt::Display for Instruction {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{:?}", self)
    }
}

impl Instruction {
    // True if the operand is always a literal value, false if it's a combo operand (below)
    fn is_literally_literal(&self) -> bool {
        match self {
            Self::Adv => false,
            Self::Bxl => true,
            Self::Bst => false,
            Self::Jnz => true,
            Self::Bxc => true, // Takes one but ignores it
            Self::Out => false,
            Self::Bdv => false,
            Self::Cdv => false,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Operand {
    Literal(u8),
    A,
    B,
    C,
}

impl From<u8> for Operand {
    fn from(value: u8) -> Self {
        match value {
            0..=3 => Self::Literal(value),
            4 => Self::A,
            5 => Self::B,
            6 => Self::C,
            _ => panic!("Invalid combo operand"),
        }
    }
}

impl fmt::Display for Operand {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Self::Literal(value) => write!(f, "{}", value),
            Self::A => write!(f, "A"),
            Self::B => write!(f, "B"),
            Self::C => write!(f, "C"),
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct Machine {
    pub a: u128,
    pub b: u128,
    pub c: u128,
    pub ip: usize,
    pub ram: Vec<u8>,
    pub halted: bool,
    pub output: Vec<u8>,
}

impl Machine {
    pub fn decompile(&self) -> String {
        let mut output = String::new();

        for (i, &byte) in self.ram.iter().enumerate().step_by(2) {
            let instruction = Instruction::from(byte);
            let operand = if instruction.is_literally_literal() {
                Operand::Literal(self.ram[i + 1])
            } else {
                Operand::from(self.ram[i + 1])
            };

            output.push_str(&format!("{instruction} {operand}\n"));
        }

        output
    }
}

Most of that should be a direct translation of the specification, although I do have a few extra fields on the machine:

  • ip is the instruction pointer; the current instruction to decode
  • ram is the program to run; technically I suppose it should be ROM since you can never actually write to it
  • halted means the machine has stopped running, this happens whenever ip runs past the ram
  • output is a vector of values that have been written by the out instructions

To load a machine, we have this wonderfully ugly bit of code:

#[aoc_generator(day17)]
pub fn parse(input: &str) -> Machine {
    let mut lines = input.lines();
    let a = lines.next().unwrap().rsplit_once(" ").unwrap().1.parse().unwrap();
    let b = lines.next().unwrap().rsplit_once(" ").unwrap().1.parse().unwrap();
    let c = lines.next().unwrap().rsplit_once(" ").unwrap().1.parse().unwrap();

    lines.next(); // Skip the empty line

    let ram = lines
        .next()
        .unwrap()
        .rsplit_once(" ")
        .unwrap()
        .1
        .split(",")
        .map(|s| s.parse().unwrap())
        .collect();

    Machine {
        a,
        b,
        c,
        ip: 0,
        ram,
        halted: false,
        output: Vec::new(),
    }
}

… I probably should have written that with nom or the like. It’s functional though.

And finally, the actual interpreter itself!

impl Machine {
    fn value_of(&self, operand: Operand) -> u128 {
        match operand {
            Operand::Literal(value) => value as u128,
            Operand::A => self.a,
            Operand::B => self.b,
            Operand::C => self.c,
        }
    }

    pub fn run(&mut self) {
        while !self.halted {
            self.step();
        }
    }

    pub fn step(&mut self) {
        if self.halted {
            return;
        }

        // Always read an instruction + operand, out of bounds is an error
        if self.ip >= self.ram.len() - 1 {
            self.halted = true;
            return;
        }

        let instruction = Instruction::from(self.ram[self.ip]);
        let operand = if instruction.is_literally_literal() {
            Operand::Literal(self.ram[self.ip + 1])
        } else {
            Operand::from(self.ram[self.ip + 1])
        };

        match instruction {
            // Division (actually a right shift)
            Instruction::Adv => {
                self.a >>= self.value_of(operand);
            }
            // Bitwise XOR
            Instruction::Bxl => {
                self.b ^= self.value_of(operand);
            }
            // Bitwise set
            Instruction::Bst => {
                self.b = self.value_of(operand) & 0b111;
            }
            // Jump (if not zero)
            Instruction::Jnz => {
                if self.a != 0 {
                    self.ip = self.value_of(operand) as usize;
                    return; // Don't increment the IP
                }
            }
            // Bitwise XOR between b and c (ignores operand)
            Instruction::Bxc => {
                self.b ^= self.c;
            }
            // Output
            Instruction::Out => {
                self.output.push((self.value_of(operand) as u8) & 0b111);
            }
            // Division (actually a right shift) to b, still reads from a
            Instruction::Bdv => {
                self.b = self.a >> self.value_of(operand);
            }
            // Division (actually a right shift) to c, still reads from a
            Instruction::Cdv => {
                self.c = self.a >> self.value_of(operand);
            }
        }

        self.ip += 2;
    }
}

value_of takes either a literal and returns it or a register and returns the value of the register, so any instruction, it will be the operand. Choosing if it’s treated as a literal or combo type operand is handled in step.

step will advance the machine exactly one step. run will run the machine until it halts. And hopefully doesn’t catch fire.

And that’s it!

#[aoc(day17, part1, v1)]
fn part1_v1(input: &Machine) -> String {
    let mut machine = input.clone();

    machine.run();

    machine
        .output
        .iter()
        .map(|b| b.to_string())
        .collect::<Vec<_>>()
        .join(",")
}

… it took me longer than I care to admit to realize that I needed to include the , in the output this time around.

Unit tests

I actually did write some tests for each operation (based on comments in the Advent of Code specification):

#[cfg(test)]
mod tests {
    // If register C contains 9, the program 2,6 would set register B to 1.
    #[test]
    fn test_instruction_1() {
        let mut machine = Machine::default();
        machine.c = 9;
        machine.ram = vec![2, 6];

        machine.step();

        assert_eq!(machine.b, 1);
    }

    // If register A contains 10, the program 5,0,5,1,5,4 would output 0,1,2.
    #[test]
    fn test_instruction_2() {
        let mut machine = Machine::default();
        machine.a = 10;
        machine.ram = vec![5, 0, 5, 1, 5, 4];

        machine.step();
        machine.step();
        machine.step();

        assert_eq!(machine.output, vec![0, 1, 2]);
    }

    // If register A contains 2024, the program 0,1,5,4,3,0 would output 4,2,5,6,7,7,7,7,3,1,0 and leave 0 in register A.
    #[test]
    fn test_instruction_3() {
        let mut machine = Machine::default();
        machine.a = 2024;
        machine.ram = vec![0, 1, 5, 4, 3, 0];

        while !machine.halted {
            machine.step();
        }

        assert_eq!(machine.output, vec![4, 2, 5, 6, 7, 7, 7, 7, 3, 1, 0]);
        assert_eq!(machine.a, 0);
    }

    // If register B contains 29, the program 1,7 would set register B to 26.
    #[test]
    fn test_instruction_4() {
        let mut machine = Machine::default();
        machine.b = 29;
        machine.ram = vec![1, 7];

        machine.step();

        assert_eq!(machine.b, 26);
    }

    // If register B contains 2024 and register C contains 43690, the program 4,0 would set register B to 44354.
    #[test]
    fn test_instruction_5() {
        let mut machine = Machine::default();
        machine.b = 2024;
        machine.c = 43690;
        machine.ram = vec![4, 0];

        machine.step();

        assert_eq!(machine.b, 44354);
    }
}

I’ll admit, I threw an LLM at those. It did perfectly at turning the plain text specification into test cases after I wrote the first one. And the one it did mess up… that was actually a problem they found in my implementation!

Part 2

Find the initial value of a so that your program outputs it’s own code.

Make a quine!

Let’s brute force it:

#[aoc(day17, part2, bruteforce)]
fn part2_bruteforce(input: &Machine) -> u128 {
    for a in (8 ^ 15).. {
        let mut machine = input.clone();
        machine.a = a;
        machine.run();
        if machine.output == machine.ram {
            return a;
        }
    }

    panic!("No solution found");
}

… …

What’s that even outputting?

$ cargo run --bin day17-iterator | head -n 1000

Input A    Octal      Output
0          0          [2]
1          1          [3]
2          2          [0]
3          3          [1]
4          4          [7]
5          5          [7]
6          6          [2]
7          7          [6]
8          10         [2, 3]
9          11         [3, 3]
10         12         [0, 3]
11         13         [1, 3]
12         14         [5, 3]
13         15         [6, 3]
14         16         [2, 3]
15         17         [2, 3]
16         20         [2, 0]
17         21         [3, 0]
18         22         [1, 0]
19         23         [1, 0]
# ...
62         76         [2, 6]
63         77         [2, 6]
64         100        [3, 2, 3]
65         101        [3, 2, 3]
66         102        [4, 2, 3]
67         103        [3, 2, 3]
68         104        [7, 2, 3]
# ...
510        776        [2, 2, 6]
511        777        [2, 2, 6]
512        1000       [2, 3, 2, 3]
513        1001       [7, 3, 2, 3]
514        1002       [0, 3, 2, 3]
515        1003       [1, 3, 2, 3]
# ...

Okay, so if we treat the input a as an octal value (which makes sense, since everything is working with 3 bits), then each time we add another octet to the input… we get another octet of output. So if we need all 16 octets of output, our input needs to be… 16 octets = 48 bits long. That’s… 281 trillion! Even for a really fast computer… that might take a while.

I did some quick timing and showed that without much more optimization, we’re running at ~4 MHz (4 million values per second). Since the value we’re looking for is ~ $8^16$, we’d need… about 800 days to find the answer. Even if we start at $8^15$ instead of $1$… that only cuts off ~100 days.

It’s like the saying says: what’s the difference between $1 million and $1 billion? About $1 billion.

Anyways.

Let’s be smarter about this.

So what is our program actually doing?

I already wrote a decompile method, let’s see what the program is doing:

Bst A
Bxl 6
Cdv B
Bxc 6
Bxl 4
Out B
Adv 3
Jnz 0

Well that’s helpful. 😄

To translate a bit more, we have:

Bst A   B0 = A{N}           Truncated to 3 bits
Bxl 6   B1 = A{N} ^ 110
Cdv B   C0 = A{N} >> B1     C0 = A{N + B1}
Bxc 6   B2 = C0 ^ B1
Bxl 4   B = B ^ [0 1 1]
Out B   output B            Truncated to 3 bits
Adv 3   A = A{N+3}
Jnz 0   loop

I’m using A{N} to represent the bits of A starting from the Nth least significant bit. I’m using B0/B1/B2 to put this in static single-assignment form (and so it’s easier to tell which B is which).

Psuedo-code hash

So essentially, we have code that does this:

while A > 0 {
    output A{N} ^ A{N + ?} ^ some_literal_bytes
    A = A >>> 3
}

Basically it’s a hashing function. Although not a great one…

The interesting part is that A{N + ?}. Because B1 is based on B0 which is truncated, we know that the ? is at most 7. So at most, each output value is based on the three bits directly above it + up to 7 more.

This is probably interesting. 😄

What’s actually changing?

As an aside, I did do a quick visualization of this, starting with the program (converted as octal) as the input to A then I tried changing each tribble (3 bit nibble?) and showing which octets of the output changed:

fn decimalize(ram: &[u8]) -> u128 {
    let mut a = 0;
    for nibble in ram.iter() {
        a = (a << 3) | (*nibble as u128);
    }
    a
}

let mut a = input.ram.clone();
let mut change_map = Grid::new(input.ram.len(), input.ram.len());

// Which bytes change which bytes
for index in 0..input.ram.len() {
    let mut machine = input.clone();
    machine.a = decimalize(&a);
    machine.run();
    let output_1 = machine.output.clone();

    for new_value in 0..8 {
        let mut machine = input.clone();
        a[index] = new_value;
        machine.a = decimalize(&a);
        machine.run();
        let output_2 = machine.output.clone();

        output_1
            .iter()
            .zip(output_2.iter())
            .enumerate()
            .filter(|(_, (a, b))| a != b)
            .for_each(|(i, _)| {
                change_map.set((index, i), true);
            });
    }
}

println!(
    "{}",
    change_map.to_string(&|b| (if *b { 'X' } else { '.' }).to_string())
);

This outputs this fun looking chart:

.............X.X
............XXX.
...........XXX..
..........X.X...
..........XX....
.........XX.....
........XX......
.....XX.X.......
.......X........
.....XX.........
....XX..........
...XX...........
...X............
X.X.............
XX..............
X...............

Which shows pretty much exactly what I expected: each octet is based on up to a set of 4 above it.

Zero guarantees

So what else do we know?

Well, we know that in the final loop, we have A{N} = 0. And from there, for each octet, we should be able to try each different octet (0 to 7) as input. Some subset of those should output the next correct value–and if we continue down this line, only some of those should still be valid. Eventually, we run out of characters to generate in our quine.

That sounds an awful lot like recursion.

The actual answer

#[aoc(day17, part2, backtrack)]
fn part2_backtrack(input: &Machine) -> u128 {
    fn recur(original_machine: &Machine, a: u128, index: usize) -> Option<u128> {
        for tribble in 0..8 {
            let mut machine = original_machine.clone();
            let next_a = (a << 3) | tribble;
            machine.a = next_a;
            machine.run();

            if machine.output[0] == machine.ram[index] {
                // Recursive base case
                if index == 0 {
                    return Some(next_a);
                }

                if let Some(a) = recur(original_machine, next_a, index - 1) {
                    return Some(a);
                }
            }
        }

        None
    }

    recur(input, 0, input.ram.len() - 1).unwrap()
}

At each recursive step, we assume we have a that is correct up until index of the target string. Try each next tribble (octet), recurring down on the next index for each. If any of those returns a value, that’s also the final answer. If none do, this branch doesn’t have the answer, so backtrack (by the power of recursion!) and try another value somewhere earlier in the program.

That’s… amazingly simple.

$ cargo aoc --day 17 --part 1

AOC 2024
Day 17 - Part 2 - backtrack : 90938893795561
	generator: 709ns,
	runner: 45.041µs

And $\frac{log{(90938893795561)}}{log{(8)}} \approx 15.45$, so exactly the 16 octets long we were expecting!

In case you were wondering, you can print out the values of a and index to see how many different branches we actually end up needing to try:

recur 0 15
recur 2 14
recur 17 13
recur 20 13
recur 165 12
recur 1323 11
recur 10586 10
recur 84690 9
recur 84693 9
recur 677547 8
recur 5420377 7
recur 43363017 6
recur 346904138 5
recur 2775233106 4
recur 2775233109 4
recur 43363021 6
recur 5420380 7
recur 43363041 6
recur 43363042 6
recur 43363043 6
recur 346904349 5
recur 2775234796 4
recur 22201878368 3
recur 177615026944 2
recur 1420920215555 1
recur 11367361724445 0

26! (No, not that 26!).

That’s it.

And while it doesn’t generalize to any possible program, since we can only right shift and xor, most programs should be similarly impacted.

Benchmarks

$ cargo aoc bench --day 17

Day17 - Part1/v1        time:   [465.51 ns 469.71 ns 474.07 ns]
Day17 - Part2/backtrack time:   [43.640 µs 43.798 µs 43.951 µs]