Some time ago, I announced that the Molecule Engine uses C++ as a scripting language. Today, I can share implementation details and a few additional tricks that were used to keep compilation times and executable sizes down.
Prerequisites
Why use C++ for scripting when there are many readily available scripting languages out there? Scripting languages boast a few advantages compared to compiled languages:
- They are mostly dynamically typed. Scripters don’t need to worry about whether something is a string, an integer, a boolean, or something entirely different.
- They are mostly interpreted, making it easier to reload changes on-the-fly.
- Scripters don’t have to deal with pointers, references, and other language details.
Scripting languages also exhibit some disadvantages compared to compiled languages:
- They are not as fast as native code, even when JIT-compilation is used. Additionally, JIT-code is not an option on several (console) platforms for security reasons.
- Proper debugging needs a 3rd-party debugger, or you have to roll your own debugging tools.
- Calling C++-code from script code (or vice versa) always needs some kind of wrapper/translation layer in between, which has to be implemented either manually or automatically (using e.g. SWIG). I find both methods tedious.
For scripting in Molecule, I wanted to have the best of both worlds:
- Scripting code should be as fast as native code.
- Scripters should never have to deal with low-level language details directly. They shouldn’t need to worry about pointers, references, ownership, memory management, etc.
- It should be possible to reload script code in a fraction of a second, while the engine is running. I love short iteration times.
- Debugging should be supported out-of-the-box, preferably by using the platform’s “native” debugger such as Visual Studio, gdb, SN Systems debugger on PS3, etc.
- The language should be statically typed. In my experience, dynamically typed languages are great for writing code initially, but absolutely suck when you have to find out why some code in a 50kb script file won’t work.
- Script code should have direct access to all engine code (if desired) without any translation layers in-between.
With a few tricks we can make C++ fulfill all of the above requirements.
How it works
Conceptually, the idea is simple: each script consists of a single C++ file that is compiled into its own dynamic library (e.g. .dll on Windows, .prx on PS3). Whenever the file contents change, we store the script state into memory, unload the library, compile the script, load the new library, and restore the script state from memory. Done.
On the engine side, a script is a class that implements an interface with a few virtual functions. The script system is responsible for updating all registered scripts each frame. The dynamic library exports two C functions responsible for creating and destroying a script.
The script interface is defined as follows:
class ME_NO_VTABLE_INIT ScriptBase
{
public:
void SetupSharedLibrary(void);
void SetEnvironment(const ScriptEnvironment& environment)
virtual void Startup(void) ME_ABSTRACT;
virtual void Shutdown(void) ME_ABSTRACT;
virtual void Serialize(ScriptSerializerBase* serializer) ME_ABSTRACT;
virtual void Update(float deltaTime) ME_ABSTRACT;
protected:
gameScripting::LogBase* m_log;
gameScripting::DebugDrawBase* m_debugDraw;
gameScripting::WorldBase* m_world;
};
Whenever a script is created, the creation function initializes the shared library (more on that later), and then the script system sets up the environment – this initializes the protected members which are used by each script to access exposed engine functionality.
This gives us a statically typed language, native debugging using our favorite debugger, the performance of native code, and fast iteration times if we manage to keep the compile times down. Additionally, we can directly access the full C++ codebase and all engine code by linking with the engine libraries.
As you may have guessed already though, there are quite a few things to watch out for in order to make the system work efficiently.
Avoiding low-level language details
Depending on how your engine is setup, this can either be trivial to accomplish, or result in a lot of work. Because Molecule only uses IDs and handles to identify components, entities, and the likes, I did not have to come up with another solution for hiding raw pointers from users. Scripts use the exact same types like regular C++ engine code does, e.g.:
graphics::MeshComponentId mesh = m_world->AddMeshComponent(m_meshEntity[i], math::MatrixIdentity(), "cube", "floortiles");
In case the engine code still identifies assets using raw pointers, I would highly recommend implementing a system that only ever returns opaque data to the user instead of pointers.
Compilation
Scripts are compiled using the platform’s native toolchain, e.g. on Windows I use cl.exe for compiling the C++ code. Molecule’s content pipeline uses a directory watcher to be notified whenever a file changes. As soon as a script modification has been detected, the content pipeline spawns cl.exe in a new process, and compiles the single .cpp file with the correct command-line options. Both the compiler to use as well as the options can be setup per script, similar to any asset consumed by the engine.
Instead of using the batch-file that ships with Visual Studio for setting up the compilation environment, I wrote my own that only does the minimal amount of setup work needed in order to use the compiler, vastly improving compile times. Additionally, each script includes only one file which makes all required engine parts available to the script. This include file uses a pre-compiled header, which further improves compile times.
A simple script such as the one used in the video now takes 0.3s to compile instead of >1 second, which includes spawning a new process, compiling the script, and reloading the script into the engine.
Exception handling
Scripters make mistakes. Programmers make mistakes. Therefore, it would be nice to have at least some kind of protection against mistakes that could bring the whole engine down. Molecule generally uses a custom exception filter that is used to catch things such as access violations, page faults, etc.
The idea is to install a custom exception filter when running the scripts, handle any exception inside the filter, and notify the system that the script “crashed” without having to close the main application. There is one caveat though: simply returning from the exception filter would take us back to the faulty script, which is not something we want to do. Instead, we throw a C++ exception inside the exception filter which is caught by the surrounding code in the script system, as in the following code:
const LPTOP_LEVEL_EXCEPTION_FILTER oldExceptionFilter = SetUnhandledExceptionFilter(&ScriptExceptionFilter);
bool success = true;
try
{
script->Update(deltaTime);
}
catch (const ScriptException&)
{
ME_ERROR("ScriptSupport", "An exception has been caught, forcing the script to be disabled. See the log output for more details.");
success = false;
}
catch (...)
{
ME_ERROR("ScriptSupport", "An unknown error occurred, forcing the script to be disabled.");
success = false;
}
SetUnhandledExceptionFilter(oldExceptionFilter);
The ScriptExceptionFilter captures the call stack and logs some info about the exception that occurred, and then simply executes throw ScriptException();.
Note that in order for this to work we need to compile this translation unit with C++ exceptions turned on. In shipping builds, we can simply call the script’s Update() function, not using any exception mechanisms at all.
Debugging
Capturing the call stack inside the exception filter only works when the correct symbols have been loaded, which can be achieved using SymLoadModuleEx. This works as long as we use StackWalk64 for capturing the call stack.
However, the debugger also needs to load the symbols which is done internally only when calling LoadLibrary. From what I gathered, LoadLibrary internally sends an event to the debugger, which is kind of an undocumented feature that could be simulated by calling RaiseException with the proper parameters.
In Molecule, I copy both the .dll and the .pdb to a temporary location, and load them using LoadLibrary in development builds. In shipping builds, the .dlls are embedded into the resource packages, and loaded from memory because the .pdbs are not needed.
Note that you need to compile the C++ scripts with the /PDBALTPATH option if you want LoadLibrary to find the correct .pdb file. Once everything is setup correctly and the debugger can find the .pdb file, you can set breakpoints and debug your scripts just like you would any other C++ code.
Global state
As you probably know, dynamic libraries can be a bit of a hassle if you have lots of global state, and things like singletons because those “live” inside the main application, as well as inside the dynamic library.
Fortunately, Molecule does not have even one singleton, and only a few global variables. Among those globals are things such as the head and tail pointers to the intrusive linked list of loggers, the string hash database, and the global D3D device. And that’s about it.
Nevertheless, we need to make sure that both the main application and all dynamic libraries access the same objects.
On Windows, we can use Named Shared Memory to store all global state from within the main application, which is later retrieved by each dynamic library upon startup in SetupSharedLibrary().
Optimizing script code size
During development, script code can access all exposed engine functionality via the interfaces stored as protected members. By using interfaces that are setup by the main application, the script does not need to link with all the engine code, but rather only with the C and C++ libraries. Calling a function from the interface is just a regular virtual function call, with the v-table being contained in the main executable already. Not having to link all libraries decreases the code size, and speeds up the compilation process.
But we can do better! Using dumpbin on the generated object file tells us that the script .dll still contains a lot of things we don’t need.
Earlier we said that each script .dll exports two C functions: one for creating the script, and one for destroying the script. We could use ordinary new and delete for that purpose, but that would pull in operator new and operator delete from the runtime libraries.
Instead, we can use a static buffer inside the .dll that is large enough to hold the script, and use placement new to initialize it. This gets rid of requiring an implementation for operator new and operator delete.
Additionally, Molecule scripts do not need static C++ instances, initterm(), atexit(), and a lot of other things supported by the C/C++ runtime. In fact, you do not need to link the C/C++ runtime at all if you are careful: by providing your own _DllMainCRTStartup function and some dummy symbols in order to keep the linker quiet, we only need to link with kernel32.lib – and nothing else.
After those optimizations, the .dll for the script shown in the video is about 3kb, which is less than the 6kb of script (C++ source) code.
Important to note is that you can still link with the engine libraries and use the engine directly as from within the rest of your code. This is especially useful when programmers need to help scripters or need to aid in debugging, and would like to use the engine’s visualization or debugging features. Simply add the C++ code, link the engine libraries (either by changing the compilation options or using #pragma comment(lib)), and presto: all the functionality is available without having to restart the application!
Remarks and conclusion
With the above optimizations employed, C++ can truly be used as a scripting language, offering all the benefits normally only available to scripting languages.
A student of mine has successfully implemented a similar system on the PS3 using .prx libraries (Thanks Niki!).
Filed under:
Asset pipeline,
C++,
Core