ftz QuakeC Virtual Machine  2018.11.06
Main page

A QuakeC virtual machine written in modern C++. Useful where there is still some QuakeC code left but everything else is up to date. The goals of this implementation are portability and ease of use. There is absolutely no global state and all non-portable bit-twiddling is isolated into a single file. A library of 45 builtin functions and a standalone executable is provided for testing.

This library was based on a code from the Daemon port of Xonotic but almost all of it was rewritten.

Prerequisites

Dependencies

Compiler support

  • G++ 8 or newer with libstdc++

Setting up

You need to use Conan to install and use this library. To install Conan on APT-based distros you would typically do:

    # apt install python-pip
    $ pip install conan

Then add official ftz repository:

    $ conan remote add ftz https://conan.ftz.lyberta.net

Then you need to follow a Conan tutorial to declare that your project depends on this library, for example, using conanfile.txt:

    [requires]
    ftzQCVM/Latest@Lyberta/Latest

Or using conanfile.py:

    build_requires = "ftzQCVM/Latest@Lyberta/Latest"

The rest depends on your build system. In CMake you would do:

    # Adding Conan dependencies.
    include(${CMAKE_BINARY_DIR}/conanbuildinfo.cmake)
    conan_basic_setup(TARGETS)

    # Adding example executable target.
    add_executable(Example ...)

    # Specifying libraries to link executable to.
    target_link_libraries(Example PRIVATE CONAN_PKG::ftzQCVM)

After that in your source code you include files like this:

    #include <ftz/QuakeC/SomeHeader.h>

To build your code, remember to execute conan install before calling conan build or cmake so your dependencies will be set correctly.

Library overview

Once the library has been installed, you should be able to include header files like this:

#include <ftz/QuakeC/someheader.h>

Usually each class of the library is contained in its own header, however, there are some exceptions. All declarations are located in the ftz::QuakeC namespace. The main class is VirtualMachine. It stores the full state of a single virtual machine. Normally, you would create instances of this class and and use CallFunction member function template to call functions of the virtual machine. Virtual machine will use builtin functions and global variables to communicate back with your program.

There is no way to read and write data from the virtual machine into arbitrary structs since binary layout of structs is implementation defined. To work around that, there is a BinaryLayout class. It is used for portable and type safe way to define a binary layout of global variables an entity fields. Therefore, two instances of such objects are needed for a VirtualMachine constructor.

BuiltinManager is used to keep track of builtin functions. The instance of this class can be shared between several virtual machines.

Examples

Hello world

This example demonstates a program that can only print text. A QuakeC code for such program can be this:

void print(string message) = #1;
void main()
{
print("Hello world!");
}

A full C++ implementation can be this:

#include <iostream>
#include <fstream>
// Reads a binary data of the QuakeC program from file.
std::vector<std::byte> ReadProgram(const std::string& filename)
{
std::ifstream stream{filename, std::ios_base::ate};
if (!stream.is_open())
{
throw std::runtime_error{"ReadProgram: Unable to open input file."};
}
std::size_t size = stream.tellg();
stream.seekg(0);
std::vector<std::byte> buffer(size);
stream.read(reinterpret_cast<char*>(buffer.data()), size);
return buffer;
}
// An implementation of the only builtin function.
void Print(std::string_view message)
{
std::cout << message << '\n';
}
int main(int argc, char* argv[])
try
{
if (argc < 2)
{
throw std::runtime_error{"No input file specified."};
}
builtinmanager.AddBuiltin(1, Print);
ftz::QuakeC::VirtualMachine vm{{}, {}, builtinmanager,
ReadProgram(argv[1])};
vm.CallFunction("main");
}
catch (std::exception& e)
{
std::cerr << "Exception caught: " << e.what() << '\n';
}
catch (...)
{
std::cerr << "Unknown exception.\n";
}

Defining builtin functions

Suppose the QuakeC declaration of builtin is this:

float sum(float a, float b) = #1;

The C++ implementation of such builtin can be this:

float sum(float a, float b)
{
return a + b;
}

Then it can be added to BuiltinManager like this:

builtinmanager.AddBuiltin(1, sum);

The library will take care of properly marshalling data between C++ code and virtual machine. The following types can be used as function arguments:

The following types can be used as function return types:

You can use functions from the standard library but you may need to manually specify overload:

builtinmanager.AddBuiltin<float, float>(42, std::sin);

You can make functions that will accept variable number of arguments. Suppose the QuakeC declaration is this:

string strcat(string s1, string s2, ...) = #31;

The C++ implementation can be this:

std::string ConcatenateStrings(ftz::QuakeC::Arguments arguments)
{
auto numargs = arguments.GetNumberOfArguments();
if (numargs < 2)
{
throw std::runtime_error{"ConcatenateStrings: "
"Not enough arguments were given."};
}
std::string result;
for (int i = 0; i < numargs; ++i)
{
result += arguments.GetString(i);
}
return result;
}

Calling functions

You can call arbitrary functions of the virtual machine by their name using CallFunction template:

vm.CallFunction("main", 42, {1.0f, -2.0f, 3.5}, "hello"s);

The following types can be passed to CallFunction template:

You can read the return value of called function by reading it from the global memory from the ReturnAddress:

vm.CallFunction("main");
float returnvalue = vm.GetGlobalMemory().ReadFloat(

You can read and write arguments directly from and to global memory using GetParameterAddress:

auto& memory = vm.GetGlobalMemory();
memory.WriteVector(ftz::QuakeC::GlobalMemory::GetParameterAddress(1), {0, 0, 0});
vm.CallFunction("main");

Defining fixed layout of global variables

Usually there are a few global variables that are defined by the game engine and their layout in global memory is fixed. They are usually defined before the special end_sys_globals variable. The original code of Quake used a C struct for such variables and allowed the virtual machine to write into arbitrary memory of this struct. Of course, this is undefined behavior and this implementation avoids it by providing a BinaryLayout class. It uses array semantics that are guaranteed not to have padding between the members and are therefore portable.

Suppose the QuakeC code for global variables is this:

float global1;
string global2;
void end_sys_globals;

The code to initialize such layout would be this:

// These are used internally for function return values and arguments.
globallayout.AddPadding(28);

The code for reading from and writing to such global variables would be this:

ftz::QuakeC::VirtualMachine vm{globallayout, ... };
auto layout = vm.GetGlobalMemory().GetLayoutIO();
float global1 = layout.ReadFloat("global1");
layout.WriteString("global2", 0); // 0 is null string usually.

Defining fixed layout of entity fields

Like global variables, there are usually some entity fields that are too defined by the game engine and their layout is fixed. They are usually defined before the special end_sys_fields variable. Here they are also implemented using the BinaryLayout class.

Suppose the QuakeC code for entity fields is this:

.float field1;
.vector field2;
void end_sys_fields;

The code to initialize such layout would be this:

The code for reading from and writing to such fields would be this:

ftz::QuakeC::VirtualMachine vm{globallayout, entitylayout, ... };
auto& manager = vm.GetEntityManager();
ftz::QuakeC::Entity::Handle handle = manager.SpawnEntity();
ftz::QuakeC::Entity entity = manager.GetEntity();
auto layout = entity.GetLayout();
float field1 = layout.ReadFloat("field1");
layout.WriteVector("field2", {0, 1, 2});