Repository navigation
NativeAOT Desktop Shipping
An experimental, opt-in way to ship a desktop O3DESharp game with no CoreCLR and no Coral dependency at all — just a compiled native image and the launcher's native host. This page is a task-focused companion to the README's "Shipping with NativeAOT" section and the M3/M4 design; it walks through what the option does, how to turn it on, and what its current limits are.
The default O3DESharp build hosts C# via Coral: CoreCLR, hostfxr, JIT, full reflection. That's great for iteration (hot reload, dynamic dispatch, debugging) but means every shipped game embeds a .NET runtime and pays JIT/reflection overhead.
NativeAOT compiles O3DE.Core — the managed scripting API, plus every ScriptComponent subclass reachable from it — straight to native code ahead of time. The result is a shared library (O3DE.Core.dll/.so/.dylib) with exactly one exported entry point. No interpreter, no JIT, no Assembly.Load.
The trade-off is the closed world. AOT compilation needs to know every call site at build time. Coral's dynamic dispatch (NativeReflection.BroadcastEBusEvent("SomeBus", "SomeEvent", ...) with a runtime-computed bus/event name) has nothing to compile against, so it's out of scope for this artifact by design — see Closed-world dispatch below.
cmake -S . -B build -DO3DESHARP_PUBLISH_NATIVEAOT=ON
cmake --build build --target O3DESharp.PublishNativeAot
# Re-run configure once more so the produced image gets queued for deploy
# (its file list isn't known until the publish above has actually run):
cmake -S . -B buildDefault is OFF — the Coral/CoreCLR path is unchanged and is what every existing project keeps using.
What happens:
-
O3DESharp.PublishNativeAotrunsdotnet publishonAssets/Scripts/O3DE.Core/O3DE.Core.csprojwith-c Release -r <rid> -p:PublishAot=true -p:NativeLib=Shared -p:O3DESharpHostMode=NativeAot, where<rid>matches the host platform (win-x64,linux-x64,osx-x64,osx-arm64). - The compiled image is staged into the build tree and deployed to
Bin/Scripts/aot/— deliberately notBin/Scripts/, where the managedO3DE.Core.dlllives. Both artifacts share a filename; a launcher that loaded the wrong one would fail in a way that reads as an unrelated bug, so they live in sibling directories and picking one is an explicit choice.
Windows command-line builds: ILCompiler's native link step shells out to vswhere.exe. Make sure C:\Program Files (x86)\Microsoft Visual Studio\Installer is on PATH, or the build fails with MSB3073 exit code 123. CMake's Visual Studio generator inherits a developer environment and isn't affected — this only bites bare command-line/CI builds.
Two host implementations sit behind a common IManagedHost C++ interface:
-
CoralHost(default) — wrapsCoralHostManager, talks to Coral/CoreCLR. -
NativeAotHost(this page) — no Coral, nohostfxr.dlopen/LoadLibraryAs the published image and resolves exactly one exported symbol,O3DESharp_GetManagedExports.
The whole ABI crosses that single call. A frozen C struct pair — NativeImports (host → image: logging, entity/transform/input/time/physics function pointers) and ManagedExports (image → host: lifecycle entry points) — is exchanged in one shot, each side stamped with an ABI version (HostAbiVersion) that both sides check and refuse to proceed past on mismatch.
On the managed side, a Roslyn incremental generator (HostExportsGenerator, under Code/Tools/SourceGenerators/) emits the [UnmanagedCallersOnly] thunk for O3DESharp_GetManagedExports plus a GeneratedScriptTypes registry that lets the image construct ScriptComponent subclasses without Activator.CreateInstance (which NativeAOT's trimmer can't see through). Under Coral, the received NativeImports struct is stored but never consumed — Coral already wired up InternalCalls' function pointers via AddInternalCall/UploadInternalCalls. Under NativeAOT there is no Coral to do that, so a hand-written NativeImportsWiring.Apply(...) (in Assets/Scripts/O3DE.Core/Interop/NativeImportsWiring.cs, compiled only under the NativeAOT symbol) is the only thing that ever assigns O3DE.InternalCalls' 47 function-pointer fields — cast field-for-field from the struct the host just handed over. Skipping this step means every native call from a script is a null function pointer.
Static (constant-name) EBus dispatch is generated at compile time and works normally. A runtime-computed bus or event name has no generated path:
// Fine under NativeAOT — constant names, resolved at compile time.
NativeReflection.BroadcastEBusEvent("TickBus", "OnTick", deltaTime);
// Flags O3DESHARP1001 at build time (a warning, not an error — the editor
// build handles this fine). Throws NotSupportedException if actually reached
// in a NativeAOT image.
NativeReflection.BroadcastEBusEvent(busNameFromSomewhere, eventNameFromSomewhere);DynamicDispatchAnalyzer (O3DESHARP1001, category O3DESharp.AOT) is the diagnostic — it's a warning by design, not an error, so a game that never ships a NativeAOT artifact is never blocked by it. If you hit it and need the call to work under NativeAOT:
- Constant-fold the name at the call site, or
- Regenerate
reflection_data.jsonif it's a bus/event the generator hasn't seen yet, or - Stay on the default Coral artifact for that code path.
Assets/Scripts/Examples/AotSampleComponent.cs is a real, minimal worked example of both halves — a normal per-frame broadcast using constant names (dispatches statically, silent) plus one deliberately dynamic call (DispatchByRuntimeName) that exists specifically to prove O3DESHARP1001 fires on real game code, not just a synthetic test fixture. That warning on the sample is intentional and must not be suppressed.
-
Managed-side pipeline verified end-to-end; the C++ side and a real engine run are not. The ABI seam, publish pipeline, closed-world diagnostic, and
NativeImports→O3DE.InternalCallswiring are all built and verified: a real NativeAOT publish produces an image whoseO3DESharp_GetManagedExportsentry point, when called, populates everyInternalCallsfunction pointer field-for-field (cross-checked againstInternalCalls.csby name and cast type, plus a realdotnet buildcompiling every cast). What hasn't been driven yet isNativeAotHost/CoralHoston the C++ side and an actual O3DE Editor/launcher run — there's no O3DE engine SDK available in the environment this was built in. -
No hot reload.
NativeAotHost.SupportsHotReload()returnsfalseunconditionally. AOT images are editor-only-workflow-excluded by design — use the default Coral path for iteration, and only publish NativeAOT for the shipping artifact. -
Per-game script registration is a known gap.
GeneratedScriptTypes.RegisterAll()only coversScriptComponentsubclasses that live inO3DE.Core's own compilation — a separate game assembly's script types (like theExamplessample project) are not automatically included in the registry today. The sample project exists specifically to make this gap visible rather than hiding it. -
Mutually exclusive per launcher, never a runtime switch, with the other M2 shipping option,
O3DESHARP_BUNDLE_DOTNET_RUNTIME(a private self-contained CoreCLR bundled next to the launcher — still Coral, just without requiring a machine-wide .NET install). Pick one artifact per launcher. -
Platform verification:
win-x64is verified;linux-x64is authored but not independently verified end-to-end. macOS and consoles are out of scope for this milestone.
The default (O3DESHARP_PUBLISH_NATIVEAOT=OFF) keeps today's Coral/CoreCLR behavior unchanged — this whole path is additive and opt-in.
-
README — "Shipping with NativeAOT (experimental)" for the condensed version and the sibling M2 (
O3DESHARP_BUNDLE_DOTNET_RUNTIME) option. - Generated Bindings Guide and Generating Bindings — the binding generator's output is plain C#/P-Invoke and works the same way regardless of which host artifact eventually runs it.