Skip to content

NativeAOT Desktop Shipping

Mikael K. Aboagye edited this page Aug 25, 2026 · 1 revision

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.

Why this exists

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.

Enabling it

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 build

Default is OFF — the Coral/CoreCLR path is unchanged and is what every existing project keeps using.

What happens:

  1. O3DESharp.PublishNativeAot runs dotnet publish on Assets/Scripts/O3DE.Core/O3DE.Core.csproj with -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).
  2. The compiled image is staged into the build tree and deployed to Bin/Scripts/aot/ — deliberately not Bin/Scripts/, where the managed O3DE.Core.dll lives. 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.

How the ABI seam works

Two host implementations sit behind a common IManagedHost C++ interface:

  • CoralHost (default) — wraps CoralHostManager, talks to Coral/CoreCLR.
  • NativeAotHost (this page) — no Coral, no hostfxr. 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.

Closed-world dispatch only

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.json if 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.

Current status and limitations

  • 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.InternalCalls wiring are all built and verified: a real NativeAOT publish produces an image whose O3DESharp_GetManagedExports entry point, when called, populates every InternalCalls function pointer field-for-field (cross-checked against InternalCalls.cs by name and cast type, plus a real dotnet build compiling every cast). What hasn't been driven yet is NativeAotHost/CoralHost on 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() returns false unconditionally. 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 covers ScriptComponent subclasses that live in O3DE.Core's own compilation — a separate game assembly's script types (like the Examples sample 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-x64 is verified; linux-x64 is 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.

See also

  • 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.

Clone this wiki locally