CyberVoid: The Deck - part 0.2

CyberVoid: The Deck - part 0.2

A Deep Dive into the RWInterpreter Alpha v0.1

·

7 min read

In our previous article, we introduced the Deck, a new gameplay mechanic in the CyberVoid universe that merges gaming and coding. In this article we’re going to take a closer look at the interpreter that makes it all possible: the RWInterpreter. This is still an ongoing coding and review. We are still missing some key operations.

The RWInterpreter is a smart contract that operates a stack-based bytecode language.

RW Overview

RWInterpreter Contract

The RWInterpreter contract is written in Solidity making it compatible with any EVM. Is heavily inspired by EVM itself.

Error Handling

The contract has several custom errors defined for robust error handling. These include ProgramOutOfBounds, StackUnderflow, DivisionByZero, NotEnoughElements, and InvalidOpcode, which helps pinpoint issues in the program flow.

OPCodes

The contract defines several operation codes (OPCodes) that represent different operations in the bytecode language. There are stack operations, arithmetic operations, bit operations, and branching operations. Each operation is assigned a unique byte value, making it "easy" to read and write programs.

The run Function

The run function is the heart of the RWInterpreter. It takes a byte array representing a program as input, and it returns the top value of the stack when the program finishes executing. Inside the function, the program is iterated over, and each opcode is identified and executed

Stack Operations

Stack operations manipulate the stack's state. The OP_PUSH operation pushes a value onto the stack, OP_POP removes the top value from the stack, OP_DUP duplicates the top value of the stack, and OP_SWP swaps the two top values on the stack.

Arithmetic Operations

Arithmetic operations perform mathematical operations on the two top values of the stack. The operations include addition, subtraction, multiplication, division, and modulo.

Bit Operations

Bit operations perform bitwise operations on the stack's top values. These operations include bitwise AND, OR, XOR, and NOT.

Branching Operations

Branching operations control the flow of the program based on certain conditions. The OP_JMP operation jumps to a specific address, OP_JZ jumps if the top of the stack is zero, and OP_JNZ jumps if the top of the stack is not zero.

Execution

The program iterates over each opcode and performs the appropriate action. If an invalid opcode is encountered, the contract reverts with an InvalidOpcode error. If the OP_STOP opcode is encountered, the execution breaks and the function returns the top value of the stack.

Examples of execution

Arithmetical

// compute (3+4)*2
function testCompleteProgram1() public {
    bytes memory program = hex"0103010414010216";
    uint256 topElement = interpreter.run(program);
    assertEq(topElement, 14, "top element should be 14");
}

Bit operation

function testOpCodeOr_ThreeWords() public {
    // PUSH 03 -> PUSH 02 -> PUSH 01 -> OR -> OR
    uint256 topElement = interpreter.run(hex"0103010301012A2A");
    assertEq(topElement, 3, "top element should be 3");
}

Branching Operation

function testOpCodeJMP_Forward() public {
    // OP_CODE JMP 0x0D
    uint256 topElement = interpreter.run(hex"0102");
    assertEq(topElement, 2, "top element should be 2");

    topElement = interpreter.run(hex"01020D0103");
    assertEq(topElement, 2, "top element should be 2");

    topElement = interpreter.run(hex"01020D01030104");
    assertEq(topElement, 4, "top element should be 4");
}

Let's break this example down

This test is verifying the correct operation of the OP_JMP opcode, which stands for "jump" in the RWInterpreter's bytecode language.

The OP_JMP opcode is used to jump forward in the bytecode by a specified number of steps. This is done by adding the operand's value to the program counter (pc). The operand in this case comes from the next byte in the program (right after the OP_JMP opcode).

Step by Step

  1. topElement = interpreter.run(hex"0102");: This part is testing the OP_PUSH opcode. It pushes the value 2 onto the stack (01 being the OP_PUSH opcode and 02 is the value to be pushed). The assertEq function checks if the top element of the stack is 2, which it should be as it's the value that was just pushed onto the stack.

  2. topElement = interpreter.run(hex"01020D0103");: This part is testing the OP_JMP opcode. It first pushes the value 2 onto the stack (with 0102), then it jumps forward one instruction (0D01), skipping the next OP_PUSH instruction which would have pushed 3 onto the stack. The assertEq function checks if the top element of the stack is 2, which it should be because OP_JMP operation didn't affect the stack and the push of the number 3 was skipped.

  3. topElement = interpreter.run(hex"01020D01030104");: This part extends the previous test. After the OP_JMP opcode, adds another OP_PUSH opcode which pushes 4 onto the stack. Since OP_JMP operation jumped one step, it skipped the push of the number 3, and the top element should be 4 after execution, which is checked with the assertEq function.

RWInterpreter Specification Alpha v0.1

Properties

Stack and Reverse Polish Notation (RPN): The RWInterpreter uses a stack data structure to process bytecode programs. The stack operates using Reverse Polish Notation (RPN).

The stack has 256 elements for v0.1.

Words are 8 bits long for v0.1.

OPCodes: The bytecode language used in the RWInterpreter defines a range of operation codes (OPCodes) that specify different operations that can be executed. These include:

  • Stack operations (8bits):

    • OP_PUSH (0x01): push value to stack

    • OP_POP (0x02): pop value from stack

    • OP_DUP (0x03): duplicate top of stack

    • OP_SWP (0x04): swap top two values on stack

  • Arithmetic operations (8bits):

    • OP_ADD (0x14): add top two values on stack

    • OP_SUB (0x15): subtract top two values on stack

    • OP_MUL (0x16): multiply top two values on stack

    • OP_DIV (0x17): divide top two values on stack

    • OP_MOD (0x18): modulo top two values on stack

  • Bitwise operations (8bits):

    • OP_AND (0x29): bitwise and top two values on stack

    • OP_OR (0x2A): bitwise or top two values on stack

    • OP_XOR (0x2B): bitwise xor top two values on stack

    • OP_NOT (0x2C): bitwise not top value on stack

  • Branching operations (8bits):

    • OP_JMP (0x0D): jump to address

    • OP_JZ (0x0E): jump to address if top of stack is zero

    • OP_JNZ (0x0F): jump to address if top of stack is not zero

  • Stop operation: OP_STOP (0xFF): stop execution

Each opcode has a unique byte value and programs are written and interpreted as byte arrays.

The opcode values are spaced out in hexadecimal values, allowing for easy expansion of the interpreter. The gaps between opcode ranges (0x01-0x04, 0x14-0x18, 0x29-0x2C, 0x0D-0x0F, and 0xFF) leave room for future additions in each category, while keeping related opcodes grouped.

Error Handling: The RWInterpreter defines several custom error types for managing exceptions that occur during program execution:

  • ProgramOutOfBounds

  • StackUnderflow

  • DivisionByZero

  • NotEnoughElements

  • InvalidOpcode

These errors help identify and debug issues while making the interpreter safer and more reliable.

Functionalities

run: The core of the RWInterpreter is the run function. This function accepts a byte array, representing a program, as an argument and returns the top value of the stack after the program has finished execution.

The run function carries out several operations:

  • It sets up a memory stack of 256 elements, Stack Pointer and Program Counter

  • It iterates over each byte in the input program, identifying the opcode and carrying out the corresponding operation.

  • It handles different types of operations, including stack operations, arithmetic operations, bitwise operations, and branching operations.

  • If an invalid opcode is encountered, it reverts with an InvalidOpcode error. If OP_STOP opcode is encountered, it halts execution and returns the top value of the stack.

Wrapping Up

Throughout this blog post, we've explored the workings of the RWInterpreter, a key component of CyberVoid's Deck gameplay mechanic. From stack operations to arithmetic and branching operations, we've uncovered the inner work of its stack-based bytecode language. We've also delved into its error handling and the run function, both integral to the contract's functionality.

Still, this is just the beginning of our journey. We are continuously improving and enhancing the RWInterpreter and have plans to implement new opcodes like OP_EQ for equality checks and OP_STORE and OP_LOAD for memory operations.

Introducing memory operations will allow programs to store and retrieve values from memory during their execution. This significant step forward will enable more complex and dynamic programs, making your Deck even more powerful and versatile.

In parallel with these developments, we're working on a tool named RWLang, a high-level language interpreter written in JavaScript. Our goal with RWLang is to make it easier for players to write and understand programs for their Decks.

The RWInterpreter is a reflection of our commitment to creating an immersive, strategic and intellectually stimulating gameplay environment. Your Deck isn't just a tool; it's an evolving manifestation of your strategy.

Until next time, keep exploring the void.