Scripting Host
Gameplay scripting is C#. The runtime embeds CoreCLR through hostfxr,
loads the LuminaSharp managed assembly, and compiles the project’s .cs files
in process with Roslyn.
For the scripting API itself see the manual’s Scripting section. This page is the host and interop layer.
DotNet::Initialize() runs from FEngine::Init, after the renderer and before
ProcessNewlyLoadedCObjects. It is non-fatal: if the bundled runtime is
missing, scripting is disabled with a log message and the engine continues.
The sequence:
- Locate
host/fxr/<version>/hostfxr.<ext>under the bundled runtime root (External/DotNet). Not finding it disables scripting. - Resolve
hostfxr_initialize_for_runtime_config,hostfxr_get_runtime_delegate, andhostfxr_close. - Initialize a runtime context from the config, then get the
load_assembly_and_get_function_pointerdelegate. - Load the
LuminaSharpbootstrap assembly and resolve twoUnmanagedCallersOnlyentry points:BootstrapandResolveManagedExport. - Run the managed handshake, which checks the ABI version.
DotNet::Tick() runs once per frame at FrameEnd. DotNet::Shutdown() runs
during engine shutdown, before the render manager is destroyed.
ABI versioning
Section titled “ABI versioning”The native to managed boundary carries an explicit version, bumped whenever the boundary changes. Its history is a useful record of what the boundary carries:
- v4,
LoadScriptstakes per-unit assembly buckets. - v5, native to managed exports resolved by name through
ResolveManagedExportrather than through a mirrored struct and hash. - v6, the managed system descriptor sink carries declared read and write component tokens, enabling parallel C# systems.
- v7, delegate properties replace hardcoded collision and perception dispatch,
adding
OnNativeDelegateDestroyed. - v8, a managed render scene bridge, so a C# type can be installed through
RenderSceneFactory.
A mismatch is reported at the handshake rather than crashing later.
Native to managed
Section titled “Native to managed”ResolveManagedExport(Name) returns a raw function pointer for a managed export,
or null.
The lifetime rule matters: engine exports are stable for the process, but
script and plugin exports are not. A [ManagedExport] in a plugin’s scripts
belongs to a script generation, and its pointer dangles once that generation
unloads. Any caller holding one must re-resolve when GetScriptGeneration()
changes. Game thread only.
Managed to native
Section titled “Managed to native”Two mechanisms, both resolved by name at runtime:
Hand-written exports use LUMINA_DOTNET_EXPORT
(Scripting/DotNet/DotNetExport.h):
LUMINA_DOTNET_EXPORT(FVector3, Physics_GetLinearVelocity)(uint64 World, uint32 Entity){ // ...}expands to extern "C" LUMINA_SCRIPT_API FVector3 LuminaSharp_Physics_GetLinearVelocity(...). The C# side binds it with
[NativeCall(Module = "Runtime", EntryPoint = "LuminaSharp_Physics_GetLinearVelocity")].
Conventions in that surface:
Worldis an opaqueCWorld*passed asuint64.Entityis anenttid passed asuint32.- Game thread only.
- MSVC warning C4190 (“UDT returned with C linkage”) is expected and harmless:
every export returning
FVector3,FQuat, or a wire struct does so deliberately, mirroring a blittable C# struct byte for byte.
Generated exports come from the Reflector:
SCRIPT_EXPORT(Class = "Namespace.Class") on a namespace-scope free function
emits both the native thunk and the C# binding.
LUMINA_SCRIPT_API is always dllexport, in every build configuration, because
C# resolves these by name through NativeLibrary.TryGetExport rather than
linking them. In monolithic Shipping they land in the executable’s export table;
in modular builds they land in their module’s DLL.
The interop implementation is split by area:
DotNetGameplay.cpp, DotNetAnimation.cpp, DotNetAudio.cpp,
DotNetPerception.cpp, DotNetProperty.cpp, DotNetRHI.cpp,
DotNetTask.cpp, DotNetTimer.cpp, DotNetView.cpp,
DotNetDynamicMesh.cpp.
Blittable layout checks
Section titled “Blittable layout checks”CSharpLayoutChecks.cpp and LayoutRegistry.cpp verify that every struct
mirrored across the boundary has matching size and field offsets on both sides.
A mismatch is caught at startup with a named error instead of producing silently
corrupted data.
Whenever you add or change a struct that crosses the boundary, add it to the layout registry. This is the single highest-value guard in the interop layer.
Compilation and hot reload
Section titled “Compilation and hot reload”In the editor, scripts are compiled in process with Roslyn. CoreCLR keeps
LuminaSharp.dll and the Roslyn assemblies the compiler pulls in loaded for the
life of the process; only the script assemblies are collectible.
Each script unit compiles into its own collectible AssemblyLoadContext,
emitted to <root>/Binaries/DotNet/<Name>.dll. FSourceAssembly buckets group
source files per unit, and each unit declares the sibling units it references,
which drives managed load order.
DotNet::ReloadScripts() unloads the current generation and loads a new one.
The ordering is delicate:
- Managed render scenes are torn down first, which flushes the render thread, so nothing dispatches into a dead load context.
- Script systems are removed from every world’s system set before the unload, so stale GC handle slots are never ticked.
- Every static holder of a user type or a GC handle must be cleared during unload. A single surviving reference pins the load context and the unload silently fails, which shows up later as two generations of a type coexisting.
GetScriptGeneration() returns the current generation number. Anything caching
managed pointers keys off it.
Cooked games
Section titled “Cooked games”A packaged game does not run Roslyn.
GatherScriptUnitsForPackagingrecompiles scripts so every unit’s DLL is freshly emitted, and returns the unit graph (FPackagedScriptUnit: name, DLL path, dependencies) for the packager.- The packager stages those DLLs under
<exeDir>/DotNet/Scripts/with ascripts.manifest.json. DotNet::LoadCookedScripts()loads the prebuilt assemblies from that manifest. It is a safe no-op when no manifest exists.
GenerateScriptProjects() writes the .csproj files used for IDE editing; it is
not part of the runtime path.
Script structs and scriptable objects
Section titled “Script structs and scriptable objects”CScriptStruct(Scripting/ScriptStruct.cpp) represents a C#-defined struct as a reflected type, so it can appear in the editor property grid and be serialized. ItsStructOpsis null; always null-checkGetStructOps()before using it, unlike a natively compiledCStruct.CScriptableObjectlets a C# type derive from aCObject, giving scripts access to assets and settings objects.ScriptValueBridgeandScriptValueStoremarshal reflected property values across the boundary.
Diagnostics
Section titled “Diagnostics”FScriptDiagnostics surfaces the managed runtime’s state to the editor:
| Field | Source |
|---|---|
ManagedHeapBytes | GC.GetTotalMemory(false) |
HeapSizeBytes, FragmentedBytes, CommittedBytes | GCMemoryInfo |
TotalAllocatedBytes | GC.GetTotalAllocatedBytes(), lifetime; drives the churn rate |
WorkingSetBytes | Environment.WorkingSet, whole process |
PauseTimePercentage, LastPauseMs | GCMemoryInfo |
PinnedObjects | GCMemoryInfo.PinnedObjectsCount |
Generation | Current script generation |
Allocation churn is the number to watch. Per-frame allocation in a script drives GC pause percentage up, and the pause lands on the game thread.
Common failure modes
Section titled “Common failure modes”| Symptom | Cause |
|---|---|
| ”C# scripting disabled” at startup | hostfxr not found under the bundled runtime, or missing expected exports. |
| ABI handshake failure | Native and managed built from different revisions. Rebuild both. |
| Crash after a script reload | A cached managed export pointer used across a generation change, or a static holder that pinned the old load context. |
| Reload appears to work but old code still runs | The load context did not actually unload. Something still references a user type. |
| Corrupt values across the boundary | A blittable struct changed on one side only. Add it to the layout registry. |
[NativeCall] throws at runtime | The export is not in the export table. Check that it uses LUMINA_SCRIPT_API, not a module API macro. |
| Frame hitches attributed to scripting | GC pauses from per-frame allocation. Check TotalAllocatedBytes churn. |