The Synacor Challenge is a programming challenge by Eric Wastl. I found it on the Advent of Code subreddit after completing the Advent of Code 2020.

There are eight codes hidden throughout the challenge. Some of them er easy to find - others take some head-scratching. After finding them, they can be entered into the challenge website. They’re individually generated for each participant so the codes I list here won’t work for anyone else. The solutions are the same, though, so beware of spoilers!

I completed most of the challenge using LabVIEW, but any programming language will do. For one part I also used a bit of Python.

Code 1: Reading the specs

After signing up you’re given a zip file with two files: arch-spec and challenge.bin. arch-spec gives the details needed to implement a virtual machine that can execute challenge.bin.

The first code is given directly in the hints section of the arch-spec document:

== hints ==
- Start with operations 0, 19, and 21.
- Here's a code for the challenge website: rUlMtCpeTmXE

Code 2: Getting started

Building the virtual machine is similar to the problem on day 8 of the 2020 AoC.

The VM consists of two loops: An execution loop that executes the program in challenge.bin, and an event handler loop that takes input from the user interface and passes it on to the execution loop.

User text input is passed through a shared queue. The event handler queues the input bytes, and the input operation dequeues them one at a time. The other user controls (Save, Load, Write) change the state of the execution loop. Eg. clicking the Save button puts the execution loop into a Save state which saves the current machine state (memory contents, registers, stack, execution pointer, output buffer) to a file before returning to the Execution state. The state change is passed via a Tag channel. Another Tag channel is used to stop the event handler when a Halt operation is encountered.

The spec document suggests starting with instructions 0 (halt), 19 (out) and 21 (noop). Implementing just these three and executing prints this on the display:

Code 3: Finishing the virtual machine

After that, it’s on to implement the rest of the instructions. The self-test routine will verify that all instructions work as intended. If there’s an error, it will tell you at which operation it failed. Very helpful for debugging.

The end of the self-test is a very long calculation that, for me, didn’t fail until the very end. My virtual machine isn’t the fastest which meant there was a bit of a wait before the failure. This lead me to implement functions for saving and loading the state of the VM. That also came in handy later on to skip over already solved puzzles.

The third code is printed after the self-test completes successfully:

Code 4: The Tablet

After booting up the machine, you’re presented with a text-based dungeon crawler game. The very first room contains a tablet. Picking it up and using it in-game yields the fourth code.

Code 5: The Twisting Passages

The first real puzzle is a maze. Each room has corridors leading to and from them, but the maze is The maze can be solved by navigating the rooms in a specific order. I could tell that some of the passages ran in loops, but I didn’t consider that going backwards might not send you back to where you came from. That turned out to be the key to navigate the maze without editing memory, but I didn’t realize it until after I’d completed the problem.

Instead I monitored the memory calls made while moving around to identify which addresses were involved in the maze logic. After monitoring those address’ contents, I found that addresses 2732 and 2733 contain a unique ID for each room, and that these IDs all have a numerical difference of 5.

The largest Room ID I found was 2407. Setting the value of address 2732 and 2733 to 2417 and entering the “look” command put me in a room with the fifth code and an oil can.

It’s also possible to reach the room with the code by moving west-south-north after going down the ladder. That would have been easier but I didn’t realize until I had found the code.

Code 6: The Ruins

Using the oil can and lighting the lamp let me go through the dark passage west of the ladder to the twisting passages. There’s a castle at the end with six coins scattered around. Each coin has a number on it, and the central room in the castle has an equation written on the wall:

  _ + _ * _^2 + _^3 - _ = 399

With six coins there are $6!=720$ possible arrangements. I wrote a quick program to run through the possible permutations of the coins to find the right combination:

    9  +  2  *   5^2  +   7^3   -     3    = 399
  blue   red   shiny    concave   corroded

Solving the puzzle opens the last door, giving access to a room with a teleporter. Activating the teleporter for the first time gives the sixth code before sending you to the Synacor HQ.

Code 7: The Teleporter

Reactivating the teleporter without doing anything else simply lands you back at Synacor HQ. There is a book here that tells you to change the eight register. Activating the teleporter with a nonzero value in the eight register starts a long calibration routine.

Before moving on, I had to program a disassembler to make the code human readable (slightly less unreadable than before). Monitoring the calls made by the program led me to the calibration routine itself:

  Teleporter calibration routine:
  
  6027 jt   reg0  6035                  // Jump to 6035 if reg0!=0
  6030 add  reg0  reg1     1
  6034 ret                              // Return
  
  6035 jt   reg1  6048                  // Jump to 6048 if reg1!=0
  6038 add  reg0  reg0 32767
  6042 set  reg1  reg7
  6045 call 6027                        // Recursion
  6047 ret                              // Return
  
  6048 push reg0
  6050 add  reg1  reg1 32767
  6054 call 6027                        // Recursion
  6056 set  reg1  reg0
  6059 pop  reg0
  6061 add  reg0  reg0 32767
  6065 call 6027                        // Recursion
  6067 ret                              // Return

First attempt: Just skip the calculation

The calibration routine can be bypassed by overwriting the first operation (6027) with a ret operation. The calculation stops, but the teleportation is aborted.

Second attempt: Skip the return value check

After the calibration routine returns, there is a check for the correct value in reg0:

  5489 call 6027                        // Call check code
  5491 eq   reg1  reg0     6            // Set reg1=1 if reg0=6
  5495 jf   reg1  5579                  // Jump to 5579 if reg1=0 (ie. if reg0 != 6). This happens if reg7 is wrong. 
  5498 push reg0                        // Save reg0, 1 and 2
  5500 push reg1      
  5502 push reg2      
  5504 set  reg0 29014                  // Set new values
  5507 set  reg1  1531
  5510 add  reg2 16218  5646
  5514 call 1458                        // This is the one we want to call!

The value check can by bypassed by either setting reg0=6 manually, or by simply replacing the whole check section with noops:

  5489 noop
  ...
  5497 noop

This finally activates the teleporter and puts you on a tropical beach with a new code written in the sand. Unfortunately, the code is invalid 😥

Third attempt: Finding the correct value

The book on teleportation actually gives instructions on how to solve this thing. Looks like there’s no quick bypass. - reimplement the code on more powerful hardware and optimize it - determine the correct value for reg7 - set the register to that value and activate the teleporter - bypass the confirmation mechanism

First implementation

Here’s a direct implementation of the calibration code in Python. The memoization decorator is from the Python Decorator Library.

@memoized
def f(r0,r1,r7):
    if r0 == 0:
        return (r1 + 1) % 32768, r1, r7
    
    if r1 == 0:
        return f(r0-1, r7, r7)
    
    r1,_,_ = f(r0, r1-1, r7)
    return f(r0-1, r1, r7)

The above code does give correct results for small values of $r_0$ and $r_1$, but it quickly runs out of room for recursion.

Optimization

This was by far the most difficult part of the challenge.

The calibration routing is called with arguments $r_0 = 4$ and $r_1 = 1$, and only $r_0$ is used to return a result. The routing must return a value of 6 to pass the value check that follows. Unfortunately, these arguments lead to too many levels of recursion to calculate easily.

I tried looking into the Ackermann function, of which this seems to be a variant, but with no luck. Eventually I had to go and look for hints online. Dmitry Panin’s writeup had the answer. It turns out the above algorithm can be reduced algebraically:

\begin{aligned}
    f(r_0,r_1,r_7) &= \begin{cases} 
                          (r_1+1) \bmod 2^{15}             & \text{if } r_0 = 0 \\
                          f(r_0-1, r_7, r_7)               & \text{if } r_1 = 1 \\
                          f(r_0-1, f(r_0,r_1-1,r_7), r_7)  & \text{otherwise}
                      \end{cases}
\end{aligned}

\begin{aligned}
    &f(0,r_1,r_7) = r_1+1 \\
    \\
    &f(1,0,r_7)   = f(0,r_7,r_7)          = r_7+1 \\
    &f(1,1,r_7)   = f(0, f(1,0,r_7), r_7) = f(0, r_7+1, r_7) = (r_7+1)+1\\
    &f(1,2,r_7)   = f(0, f(1,1,r_7), r_7) = f(0, r_7+2, r_7) = (r_7+1)+2 \\
    &f(1,r_1,r_7) = f(0, f(1,r_1-1,r_7), r_7) = (r_7+1)+r_1 \\
    \\
    &f(2,0,r_7)   = f(1, r_7, r_7) = 2 \, r_7 + 1 \\
    &f(2,1,r_7)   = f(1, f(2,0,r_7), r_7) = f(1, 2 \, r_7+1, r_7) = (r_7+1) + (2 \, r_7+1) \\
    &f(2,2,r_7)   = f(1, f(2,1,r_7), r_7) = f(1, (r_7+1) + (2 \, r_7+1), r_7) = (r_7+1)+(r_7+1) + (2 \, r_7+1) \\
    &f(2,r_1,r_7) = r_1 \cdot (r_7+1) + (2 \, r_7+1) \\
    \\
    &f(3,0,r_7)   = f(2, r_7, r_7) = r_7 \cdot (r_7+1) + (2 \, r_7+1) \\
    &f(3,1,r_7)   = f(2, f(3, 0, r_7), r_7) = f(3, 0, r_7) \cdot (r_7+1) + (2 \, r_7+1) \\
    &f(3,r_1,r_7)   = f(2, f(3, r_1-1, r_7), r_7) = f(3, r_1-1, r_7) \cdot (r_7+1) + (2 \, r_7+1) \\
\end{aligned}

\begin{aligned}
    \boxed{f(3,r_1,r_7) = f(3, r_1-1, r_7) \cdot (r_7+1) + (2 \, r_7+1)}
\end{aligned}

The operations are all $\bmod 2^{15}$, but I only wrote it out in the function definition. (And yes, a lot of those parenthesis are redundant - they help me keep track of the terms.)

To check for mistakes along the way, I cross checked the formulas against the recursive function. They checked out until I could no longer run the recursion to check.

The last line can be rewritten to include a geometric series:

\begin{aligned}
    a_n :=& f(3,n,r_7) \\
    a_0 =& {r_7}^2 + 3\,r_7 + 1 \\
    a_1 =& a_0\, k + j \qquad , \qquad k=r_7+1, j=2\,r_7+1 \\
    a_2 =& a_1\, k + j = a_0\,k^2 + k\,j + j \\
    a_3 =& a_2\, k + j = a_0\,k^3 + k^2\,j + k\,j + j \\
    a_n =& a_0 \, k^n + \sum_{i=0}^{n-1} j\,k^i
\end{aligned}

I tried reducing it further, but it didn’t work. Instead I added a loop to calculate $f(3, r_1, r_7)$ for a given value of $r_1$. Adding the loop puts a limit on the recursion depth since $r_0 \leq 4$ for all calls, and the function will no longer recurse for $r_0 \leq 3$.

The finished code now looks like this:

@memoized
def f(r0,r1,r7):
    # Python code for 6027 routine

    # Optimized function for f(3,r1,r7)
    if r0 == 3:
        last = (r7 * (r7+1) + 2*r7 + 1)%32768
        for i in range(1,r1+1):
            last = ( last*(r7+1) + 2*r7 + 1 )%32768
        return last,0,0

    # f(0, r1, r7) -> r0 = r1+1
    if r0 == 0:
        return (r1+1)%32768, r1, r7

    # f(r0, 0, r7) -> f(r0-1, r7, r7)
    if r1 == 0:
        return f(r0-1, r7, r7)

    # f(r0, r1, r7) -> f(r0-1, f(r0, r1-1, r7), r7)
    r1,_,_ = f(r0, r1-1, r7)
    return f(r0-1, r1, r7)

Calculating the correct value

Running the function for every possible value of $r_7$ took 1 minute 58 seconds and produced one solution:

f(4,1,25734) = 6

So register 8 has to be 25734 for the teleporter to work.

Finished at last

To set the registers correctly and skip the calibration, I made these changes to the program in memory:

   Addr      Val
  --------------
  32775    25734    // Change reg7 to 25734
   6027        1    // set reg0 6
   6028    32768
   6029        6
   6030       18    // ret

This changes the eight register to the correct value and overwrites part of the calibration code to simply return $r_0=6$.

Using the teleporter afterwards lands us back on the beach with a new code in the sand. A working one, this time!

Code 8: The Orb

This one was a breeze after the teleporter.

The path leads to the final puzzle. The first room contains an orb that changes weight as you move around with it. Half of the rooms have an assigned number while the others have signs (+, - and *). Going from one numbered room to another changes the orb’s weight according to the path taken:

The goal is to reach the vault via a path that leaves the orb with a weight of 30. The path must be as short as possible and cannot return to the starting room (the orb evaporates if we return). Since we’re looking for the shortest/fastest path, a Breadth-first search works well.

Here’s the search algorithm in pseudo-code:

Loop while a solution is not found
  For each search branch
    For each possible next move
      if weight>0 AND next!=22 AND next is not a repeat:
        Generate a new search branch

The actual implementation is included in the LabVIEW project.

The solution is $22 + 4b - 11 * 4b - 18 - 11 - 1$. Note that while this path does go through the same room more than once, the orb’s weight is different each time and it is not considered a repeat.

The vault opens and there’s a mirror inside. I have a code written on my forehead but it’s mirrored. Reversing the order and replacing q with p gives me the last code.

Closing thoughts

There were a lot of elements to this challenge, but implementing the VM was the most fun. It’s similar to the Intcode puzzles in Advent of Code 2019, but where those puzzles had you use the VM as a tool to solve problems, the problems in this challenge were inside the program itself. It was a nice surprise to find a functioning dungeon crawler game once the machine was running.

I’m dissappointed that I had to go and look for a hint for the teleporter portion. I did all the math and programming on my own, but it would have felt better if I got through without any help at all.

Nonetheless it was a great challenge and I had a lot of fun with it.

My codes

These are my codes. They won’t work for anyone else.

  • Code 1: rUlMtCpeTmXE
  • Code 2: OelXfeAkndFI
  • Code 3: lYYmNpvGaQPL
  • Code 4: CbYTAdYJexqf
  • Code 5: JziacjPZcQSp
  • Code 6: DolCjQdinctm
  • Code 7: cuuxffSUoLBZ
  • Code 8: WxHpoIOAMOV8