Source Generation, Serialization, and NativeAOT
Why Components Must Be Declared partial
Voltage uses a Roslyn source generator (ComponentDataGenerator) to emit serialization code at compile time. For every partial Component or partial SceneComponent subclass that does not already override Data, the generator emits:
- A nested
sealed class XxxGeneratedData : ComponentDatawith one public field per serializable field on the component. - An override of the
Dataproperty — a getter that snapshots the component's current fields into aXxxGeneratedDatainstance, and a setter that applies a loadedXxxGeneratedDataback to the component's fields. - A private static
Read_XxxGeneratedData(JsonTokenReader)method — a hand-unrolled JSON parser with no reflection, safe under NativeAOT trimming. - A
[ModuleInitializer]method__RegisterDeserializer_Xxxthat registers the reader withComponentDataAotDeserializerso the engine can deserialize the type by its fully-qualified name at runtime.
A second generator pipeline emits a shared [ModuleInitializer] in ComponentDataAotBootstrap.AutoRegister that registers every concrete Component and SceneComponent in ComponentAotFactory (so instances can be created by name without reflection).
What this means in practice:
- A component without
partialhas noDataoverride. Its fields serialize to nothing. In the editor this is immediately visible as "data not persisting on save/reload". In a published build the component type also cannot be instantiated by the factory, so it would not load from a scene file at all. - The generated code uses only
new T(), direct field assignment, andJsonTokenReader— noActivator.CreateInstance, noGetType().GetFields(), no LINQ. This is what makes the published build trim-safe.
Serializable Fields
The generator picks up every public non-static field on the component (and its non-Component base classes, up to but not including Component itself). Fields of these types are supported out of the box:
| Category | Types |
|---|---|
| Primitives | bool, int, uint, long, float, double, string |
| Enums | Any enum |
| Engine value types | Vector2, Color, RectangleF |
| User structs | Plain structs with only public fields (no explicit constructor — see gotchas) |
IComponentGroup | Classes implementing IComponentGroup with public fields — the generator emits both a Read_ and a Clone_ helper |
| Collections | List<T>, Dictionary<string, TValue>, T[] of any above |
| References | ComponentReference, EntityReference (resolved post-load) |
Fields marked [JsonExclude] are skipped entirely. Private fields are skipped unless marked [SerializedField] (Voltage.SerializedFieldAttribute).
Serialization Attributes
| Attribute | Where | What it does |
|---|---|---|
[JsonExclude] | Field or property | Excluded from serialization. Applied to Entity.Scene, Component.Entity, Component.Transform, and similar back-references to prevent cycles. |
[DecodeAlias("oldName")] | Field in a ComponentData class | When loading JSON, a key matching oldName is mapped to this field. Use this when a field is renamed to keep old save files loading correctly. |
[ComponentId("alias")] | Component / SceneComponent class | Stable, rename-proof identity — a human-readable alias (like a protobuf field number or an Orleans [Alias]). Scenes reference the component by this id instead of its type name, so renaming the class or moving its namespace no longer breaks the scenes that use it. The editor stamps this automatically on first compile (defaulting the id to the class's simple name), so you normally never write it by hand. The id is assigned once and frozen — renaming the class never changes it. The attribute is emitted by the source generator (not the engine DLL), so it always compiles even against a stale engine reference; the generator bakes the mapping into the NativeAOT build via ComponentIdRegistry. Don't change or reuse an id once a scene has referenced it. |
[DynamicallyAccessedMembers(...)] | Engine internals | Preserves reflection metadata for trim-sensitive code paths. You will rarely need this in game scripts. |
The Build System
GameBuilder.BuildGameAsync in the editor orchestrates the publish:
- Builds the engine DLLs without the
EDITORpreprocessor symbol. - Runs
dotnet publishon the game project with NativeAOT and trimming enabled. - Copies the
Content/folder and theData/folder (scenes, prefabs) into the build output. - Compiles custom effect shaders from
Effects/usingEffectsCompiler.
The output lands in Build/<Configuration>/<platform>/ inside the project folder. Each dotnet publish target platform gets its own subfolder.
The [ModuleInitializer] pattern guarantees that AOT registrations run before any deserialization is attempted — no runtime reflection needed.
Editor Hot-Reload vs Published Build
In the editor the DynamicScripts assembly is loaded via reflection. This is fine because the editor runs on full .NET with a JIT. When the editor recompiles scripts it sets Core.LatestScriptAssembly so type resolution finds the newest types.
In a published build there is no DynamicScripts assembly. All game code is statically compiled into the executable. The source generator's [ModuleInitializer] registrations replace the runtime reflection that the editor uses.