Skip to content

Runtime Lifecycle

TL;DR: A WasmVMAnchor on a root GameObject holds the compiled WasmModuleAsset. At load, WasmVM.Setup creates a Store, instantiates the module, wires up event exports, then for each behaviour calls _createInstance and _deserializeInstance. Afterwards the host calls typed scripting_call_* exports on Unity/physics/game events.

  1. Build timeBuild Pipeline compiles author C# into Assets/WasmModule.wasm, in-place replaces WasmBehaviour with WasmRuntimeBehaviour, and writes WasmVMAnchor metadata (behaviourInfos, moduleAsset, definedGameEvents).
  2. Asset load — When the world/avatar/prop loads, Unity instantiates the prefab. WasmVMAnchor.Setup (and/or WasmRuntimeInitializeOnLoadMethod) kicks off VM construction.
  3. VM setupWasmVM.Setup creates the Store, instantiates, calls init exports, then calls per-behaviour _createInstance + _deserializeInstance.
  4. Event dispatch — The Unity main loop (or engine events) calls into the VM, which invokes the matching guest export.
  5. ShutdownOnDestroy disposes the Store; WasmManager.OnDestroy tears down the Engine/Linker at quit.

From CVR-GameFiles/WasmScripting/WasmVM.cs (Setup, called on the main thread by MTJobManager.RunOnMainThread("InitializeVM", ...)):

_store = new Store(WasmManager.Engine);
WasmManager.Linker.FillNonLinkedWithEmptyStubs(_store, _module);
_instance = WasmManager.Linker.Instantiate(_store, _module);
WasmManager.StartEpochTimer(_store);
_store.SetEpochDeadline(100000uL); // one-time startup deadline: ~1 s
_instance.GetAction("_initialize")?.Invoke(); // WASI init (if any)
_instance.GetAction("scripting_initialize")?.Invoke(); // CVR runtime init
_createInstance = _instance.GetAction<long, int>("scripting_create_instance");
_deserializeInstance = _instance.GetAction<long, int>("scripting_deserialize_instance");
InitializeEvents();
WasmNetworkManager.RegisterVM(this);
_store.SetData(new StoreData(rootTransform, _instance, this, NetworkId));
foreach (var b in behaviours) CreateInstance(b);
foreach (var b in behaviours) DeserializeInstance(b);
WasmManager.StopEpochTimer();

StoreData (CVR-GameFiles/WasmScripting/StoreData.cs) resolves the guest-side hooks scripting_raise_exception(code, msgPtr, msgLen) and scripting_alloc(size) -> ptr at this point and stashes the memory export. These are what the host uses to marshal strings and arrays and to surface exceptions.

The module is cached on disk by FNV-1a hash (WasmVM.LoadCachedWasmModule); a matching hash skips recompilation via Module.DeserializeFile (or Module.Deserialize under Wine/Proton, because the deserialize-file path is memory-mapped and that interacts badly with Wine’s loader).

For each WasmRuntimeBehaviour on the root:

// 1) Create:
long behaviourHandle = storeData.AccessManager.ToWrapped(
behaviour, overrideScope: true, CVRScriptScopeContext.Self).Handle;
_createInstance(behaviourHandle, info.behaviourId);
// 2) Deserialize:
int stateArg = behaviour.serializationData.MarshalSerializedObject(storeData);
_deserializeInstance(behaviourHandle, stateArg);

info.behaviourId is an 8-bit index derived from the type name hash by WasmBuildUtility.GetHash. The guest uses it to dispatch to the right subclass.

WasmVM.InitializeEvents looks up these guest exports and caches delegates. All names come from CVR-GameFiles/WasmScripting/WasmVM.cs (search GetAction/GetFunction):

Single dispatcher: all Awake / Start / OnEnable / OnDisable / OnDestroy / render events go through scripting_call_event(handle, scriptEventId), where scriptEventId is the integer value from WasmScripting.Enums.ScriptEvent. The runtime calls _callEvent in WasmVM.CallScriptEvent. Awake and Start are therefore not separate exports.

The per-phase Update family uses its own exports, because they fan out to every active behaviour in a single host call:

Guest exportParametersDispatched from
scripting_call_update(behavioursPtr, count)pointer to long[count] handlesWasmVM.Update
scripting_call_post_update(...)sameLateEventsManager.OnPostUpdate
scripting_call_late_update(...)sameWasmVM.LateUpdate
scripting_call_post_late_update(...)sameLateEventsManager.OnPostLateUpdate
scripting_call_fixed_update(...)sameWasmVM.FixedUpdate
scripting_call_post_fixed_update(...)sameLateEventsManager.OnPostFixedUpdate

Legacy scripting_call_awake(behavioursPtr, count) and scripting_call_start(behavioursPtr, count) are still looked up but the current runtime dispatches per-behaviour Awake / Start via the single scripting_call_event path.

Guest exportMeaning
scripting_call_on_collision_enter(handle, collisionHandle)OnCollisionEnter
scripting_call_on_collision_stay(handle, collisionHandle)OnCollisionStay
scripting_call_on_collision_exit(handle, collisionHandle)OnCollisionExit
scripting_call_on_trigger_enter(handle, colliderHandle, colliderTypeId)OnTriggerEnter
scripting_call_on_trigger_stay(handle, colliderHandle, colliderTypeId)OnTriggerStay
scripting_call_on_trigger_exit(handle, colliderHandle, colliderTypeId)OnTriggerExit

colliderTypeId comes from WasmScripting.TypeMap.GetId(collider.GetType()) — lets the guest dispatcher pick the concrete subclass (BoxCollider, CapsuleCollider, etc.) without a follow-up reflection call.

Script events (from rewired UnityEvents or direct guest calls)

Section titled “Script events (from rewired UnityEvents or direct guest calls)”
Guest exportMeaning
scripting_call_trigger_script_event(handle, namePtr, nameLen)void method
scripting_call_trigger_script_event_string(handle, namePtr, nameLen, argPtr, argLen)string method
scripting_call_trigger_script_event_int(handle, namePtr, nameLen, intArg)int method
scripting_call_trigger_script_event_float(handle, namePtr, nameLen, floatArg)float method
scripting_call_trigger_script_event_bool(handle, namePtr, nameLen, boolArg)bool method (as i32 0/1)
scripting_call_trigger_script_event_object(handle, namePtr, nameLen, objHandle, objTypeId)object method

The object variant also passes a TypeMap.GetId result so the guest can cast the wrapped handle back to the right concrete type. Method-name strings are written to guest memory as UTF-16 and sized in char count, not byte count.

Game events (fired once per WasmVM, fanned out inside guest)

Section titled “Game events (fired once per WasmVM, fanned out inside guest)”
Guest exportSignatureMeaning
CVR_WasmBehaviour_OnPlayerEvent(behavioursPtr, count, playerHandle, scriptEventId)int, int, long, intOnPlayerJoined/Left/Respawned + single-behaviour dispatch for OnPlayerTrigger* / OnPlayerCollision* variants
CVR_WasmBehaviour_OnPropEvent(behavioursPtr, count, propHandle, scriptEventId)int, int, long, intOnPropSpawned/Despawned
CVR_WasmBehaviour_OnPropTriggerEvent(handle, propHandle, colliderHandle, scriptEventId)long, long, long, intper-behaviour prop trigger hits
CVR_WasmBehaviour_OnPropCollisionEvent(handle, propHandle, collisionHandle, scriptEventId)long, long, long, intper-behaviour prop collision hits
CVR_WasmBehaviour_OnPortalEvent(behavioursPtr, count, portalHandle, scriptEventId)int, int, long, intOnPortalCreated/Destroyed
CVR_WasmBehaviour_OnInstanceOwnerChanged(behavioursPtr, count, playerHandle)int, int, longOnInstanceOwnerChange. playerHandle == 0 if the new owner hasn’t resolved yet.
CVR_WasmBehaviour_OnInputReady(behavioursPtr, count)int, intOnInputReady
CVR_Networking_OnReceiveMessage(senderHandle, msgPtr, msgLen)long, int, intincoming network payload, routed to the Networking.OnReceiveMessage delegate
CVR_WorldPermissions_OnWorldPermissionsChanged(marshalStructPtr)intworld VMs only; guest fans out to every behaviour that defined the method
CVR_Input_GetInputPointer() -> i32() -> intone-time lookup at VM init for the guest-owned CVRInputStruct slot the host writes each tick

For any event dispatch, the host:

  1. Calls WasmManager.StartEpochTimer(_store) — sets the deadline and wakes the timer.
  2. Invokes the cached delegate (a scripting_call_* export).
  3. If the call throws TrapException (epoch overrun, WASM trap, or guest exception) the host catches it, sets IsCrashed = true, and stops dispatching further events on this VM.
  4. Calls WasmManager.StopEpochTimer().

Host bindings wrap every delegate body in try / catch:

try {
// ... access check + real work ...
} catch (Exception exception) {
WasmBindingUtils.RaiseException(data, exception);
}

RaiseException allocates the exception message in guest memory and calls the guest export scripting_raise_exception(code, msgPtr, msgLen) (resolved once at StoreData construction). WasmBindingUtils.ExceptionToId maps 74 .NET + Unity exception types (Argument, InvalidOperation, NullReference, FileNotFound, UnityException, WasmAccessDeniedException, …) to integer codes so the guest can rebuild idiomatic exceptions rather than carry a string-only error.

WasmRuntimeBehaviour.OnDestroy (engine side) — calls VM.CallScriptEvent(this, ScriptEvent.OnDestroy) iff the behaviour defined OnDestroy.

WasmVM.OnDestroy — calls UnregisterEvents (unhooks every WasmGameEvents / LateEventsManager subscription + the optional WorldPermissionsManager.OnPermissionsChanged handler on world VMs), disposes the Store (frees guest memory), disposes the Module, and calls WasmNetworkManager.UnregisterVM.

WasmManager.OnDestroy — disposes Linker, Engine, Config on scene/application teardown. Also stops the epoch timer thread.

  • CVR-GameFiles/WasmScripting/WasmManager.cs — shared Engine/Linker, epoch timer.
  • CVR-GameFiles/WasmScripting/WasmVM.cs — per-root store, instance, event dispatch.
  • CVR-GameFiles/WasmScripting/WasmRuntimeBehaviour.cs — engine-side MonoBehaviour that bridges Unity callbacks to the VM.
  • CVR-GameFiles/WasmScripting/StoreData.cs — guest-memory accessor + scripting_raise_exception / scripting_alloc lookups.
  • CVR-GameFiles/ABI_RC.Systems.WasmScripting/WasmScriptingBridge.cs — content-load plumbing that drops the WasmVM component on an avatar/prop/world root when it arrives in the scene.
  • Events — full list of dispatchable events from the author’s perspective.
  • Architecture — the static wiring diagram.
  • Permissions — what CheckAccess can throw during any binding call.