8.4.19. C++ Integration: Class Adapters

This tutorial shows how to let daslang classes derive from a C++ abstract base class and have C++ call their virtual methods seamlessly. This is the key pattern for game object systems where scripts define behavior but C++ owns the update loop.

Topics covered:

  • The 3-layer adapter pattern (base → generated adapter → bridge)

  • Pre-generated class adapter via daslib/cpp_bind

  • compileBuiltinModule with XDD-embedded .das.inc files

  • RTTI (StructInfo *) for adapter construction

  • getDasClassMethod / das_invoke_function for virtual dispatch

  • Virtual dispatch across the C++/daslang boundary

8.4.19.1. Architecture

The pattern uses three layers:

  1. C++ base class (BaseClass) — pure virtual interface that C++ code programs against.

  2. Generated adapter (TutorialBaseClass) — generated by daslib/cpp_bind’s log_cpp_class_adapter. For each virtual method, it provides get_<method> (checks if the daslang class overrides the method) and invoke_<method> (calls it via das_invoke_function).

  3. Dual-inheritance bridge (BaseClassAdapter) — inherits both the real C++ class and the generated adapter. When C++ calls obj->update(dt) through the vtable, the bridge checks for a daslang override and invokes it.

8.4.19.2. Prerequisites

8.4.19.3. Layer 1: C++ base class

A simple abstract interface that C++ code iterates over:

class BaseClass {
public:
    virtual ~BaseClass() = default;
    virtual void update(float dt) = 0;
    virtual float3 getPosition() = 0;
};

C++ maintains a list of shared_ptr<BaseClass> objects and calls update / getPosition polymorphically.

8.4.19.4. Layer 2: Generated adapter

The adapter is generated by daslib/cpp_bind (see log_cpp_class_adapter). It lives in a .inc file that you #include in your C++ source:

#include "19_class_adapters_gen.inc"

The generated code provides:

  • TutorialBaseClass — stores StructInfo and method lookup data

  • get_update(classPtr) — returns the Func if the daslang class overrides update, or nullptr if it does not

  • invoke_update(ctx, fn, classPtr, dt) — calls the override via das_invoke_function<void>::invoke

8.4.19.5. Layer 3: Bridge class

The bridge inherits both BaseClass (for the vtable) and TutorialBaseClass (for daslang method lookup):

class BaseClassAdapter : public BaseClass,
                         public TutorialBaseClass {
public:
    BaseClassAdapter(char * pClass,
                     const StructInfo * info,
                     Context * ctx)
        : TutorialBaseClass(info),
          classPtr(pClass), context(ctx) {}

    void update(float dt) override {
        if (auto fn = get_update(classPtr)) {
            invoke_update(context, fn, classPtr, dt);
        }
    }

    float3 getPosition() override {
        if (auto fn = get_get_position(classPtr)) {
            return invoke_get_position(context, fn, classPtr);
        }
        return float3(0.0f);
    }

protected:
    void *    classPtr;
    Context * context;
};

8.4.19.6. Module with XDD-embedded daslang

The abstract base class definition lives in a .das file that is embedded into C++ using XDD (a CMake macro that converts files to byte arrays). The module loads it with compileBuiltinModule:

#include "class_adapters_module.das.inc"

class Module_Tutorial19 : public Module {
public:
    Module_Tutorial19() : Module("tutorial_19") {
        ModuleLibrary lib(this);
        lib.addBuiltInModule();
        addBuiltinDependency(lib, Module::require("rtti_core"));

        addExtern<DAS_BIND_FUN(addObject)>(*this, lib, "add_object",
            SideEffects::modifyExternal, "addObject");

        compileBuiltinModule(this, "class_adapters_module.das",
            class_adapters_module_das,
            sizeof(class_adapters_module_das));
    }
};

REGISTER_MODULE(Module_Tutorial19);

8.4.19.7. The daslang side

Scripts derive from the exposed abstract class and use def override to provide implementations:

options gen2
require tutorial_19
require daslib/rtti

class ExampleObject : TutorialBaseClass {
    position : float3
    velocity : float3
    gravity  : float = 9.8f
    name     : string

    def override update(dt : float) : void {
        print("  [das] {name}.update({dt})\n")
        position += velocity * dt
        velocity.y -= gravity * dt
    }

    def override get_position : float3 {
        print("  [das] {name}.get_position => {position}\n")
        return position
    }
}

// Helper: registers the object with C++ via the Context* injected argument.
// The Context* parameter is injected automatically — do not pass it explicitly.
def add_new_object(classPtr) {
    add_object(classPtr, class_info(*classPtr))
}

[export]
def test {
    print("tick(0.0) = {tick(0.0)}\n\n")          // no objects yet
    add_new_object(new ExampleObject(name = "A"))
    for (t in range(3)) {
        print("tick(0.1) = {tick(0.1)}\n\n")
    }
    add_new_object(new ExampleObject(name = "B", velocity = float3(1.0, 0.0, 0.0)))
    for (t in range(3)) {
        print("tick(0.1) = {tick(0.1)}\n\n")
    }
}

8.4.19.8. Build & run

cmake --build build --config Release --target integration_cpp_19
bin/Release/integration_cpp_19

C++ drives each object’s overridden update / get_position through the adapter every tick, so the run is interleaved with per-object traces (abridged):

tick(0.0) = 0,0,0

  [das] A.update(0.1)
  [das] A.get_position => 0,0,0
tick(0.1) = 0,0,0
...
  [das] A.update(0.1)
  [das] A.get_position => 0,-0.58800006,0
  [das] B.update(0.1)
  [das] B.get_position => 0.1,0,0
tick(0.1) = 0.05,-0.29400003,0