Code Components and Editor Extensions: Swift-authored components and plugin menus, loaded and reloaded by the editor
Status: Revision 5.2 (2026-09-18). Implemented through stage 3d on feature branches in both repos; see Implementation status below. Decisions D1 to D8 are locked (§14).
Scope: UntoldEngine (new UntoldComponentKit product, generated-project spec) and UntoldEditor (loader, inspector, menu host, panel, app bundle). No change to the ECS core, the .untoldscene format, or any existing component.
Baseline: editor develop @ 4a95ce5 (v0.19.0); engine fork develop @ cd7ca92e (the revision the editor's Package.resolved pins); Xcode 27.0, Swift 6.4, macOS 27.0.
Repos: miolabs/UntoldEngine, miolabs/UntoldEditor. miolabs/UntoldGaussianTwins and the twin demo adopt it afterwards (stage 5): that work is on hold until this feature has been tested.
Working agreement: commit as work progresses and push branches to the miolabs fork; no pull request until the whole loop works end to end (§12 defines that bar).
Changes in revision 2: @Expose is now @UntoldAttribute; new EditorMenuPlugin with @UntoldMenu menu items (§5); plugin packages such as UntoldGaussianTwins bring their editor integration with them (§7.8); generated projects are XcodeGen projects, which corrects §9 and D5; spikes C and D added; the feature is tested on its own sample project first, and the twin demo is updated after that (§11, §12).
Revision 2.1: @UntoldMenu takes the root domain first, then the submenu path or just the title. Loaded code is tied to the editor's fixed root menus and cannot create new ones (§5.2).
Revision 4: loaded code can add kinds of entity to the editor's creation shelves (EntityPlugin, §5.6), with the three sorts a plugin needs: nothing to show, an editor-only representation, or a shape of its own that also exists in the game. The Inspector has one Add Component menu instead of two (§7.6).
Revision 4.1 (superseded by revision 5): a component could be locked to its kind of entity, so a torus's shape was never offered for a cube.
Revision 5: the base classes are named after what they are, and a kind of entity is a thing of its own. CodeComponent is ComponentPlugin, EntityTemplate is EntityPlugin, EditorExtension is EditorMenuPlugin (the engine already calls its extension points plugins: PhysicsBackendPlugin, RenderExtensionPlugin). An EntityPlugin stays bound to its entity and carries the entity's own properties, its geometry and its editor representation, which is now icons, points and polylines so a spline can show its control points (§5.6). The component lock of revision 4.1 is gone. ScenePlugin is the shared base of the two plugins bound to entities, ScenePluginSystem drives both, and ComponentAction is PluginAction. This document uses the new names throughout.
Revision 5.1: the two places plugins live are named apart. The project's own folder is Sources/<Project>Plugins (was <Project>Components, a leftover from when only components lived there), and a shared one is a plugin package, the term the engine's plugin docs already use: untold-package.json in its root, listed under pluginPackages in UntoldEditor.json. The sample package is SamplePluginPackage, its editor side the folder SampleEditorPluginPackage with the menu plugin SampleMenus; a package's editor-side library is named after its folder. The old folder name, keys and file name are still read and reported (§7.1).
Revision 5.2: control points can be dragged. .handles(properties:tint:) names SIMD3<Float> properties of an EntityPlugin; a click on one, with either button, puts the move gizmo on the point, and dragging an axis writes the property as an Inspector edit would, one undo step per drag (§5.6). Entities with only an editor representation get the gizmo too.
Branches, all pushed to the miolabs fork, no pull request opened:
| Repo | Branch | Holds |
|---|---|---|
| UntoldEngine | feature/component_kit |
the kit, its tests and guide, generated-project support, the example |
| UntoldEditor | feature/code_components_loader |
loader, inspector blocks and the one Add Component menu, menu host, panel, hot reload, packaged SDK, entity shelves and viewport markers |
| UntoldGaussianTwins | fork-engine-component-kit |
fork-engine with only the engine pin changed, so SwiftPM resolves one engine |
One branch per repo instead of the one per stage planned in §11: the editor and the twins pin branch both name the engine branch, so stacked branches would have meant re-pinning all three at every stage. Commits are small and topical and can be regrouped for review.
| Stage | State | Evidence |
|---|---|---|
| 0 Spikes | Closed, except one item | Real-module compile and load run as a test in both repos; compile takes 0.45 s per library; reloading 1,000 instances takes 26 ms. Not verified: rebuilding menus while one is open (needs a person at the UI) |
| 1 Kit | Done | 55 tests. Sources/UntoldComponentKit, docs/API/UsingCodeComponents.md |
| 1b Generated projects | Done | 10 tests. A generated project was built with xcodebuild and reports its component at startup |
| 2 Loader, 2b Inspector, 2c Menus, 3 Hot reload | Done | 64 new editor tests (709 in the suite, the one failure predates this work). Verified in the running editor, debug and packaged release |
| 3b Sample | Done | Examples/CodeComponents: opens in the editor (three libraries, three component plugins, four entity plugins, two menu plugins) and builds as a game |
| 3c, 3d Kinds of entity (§5.6) | Done | In the running editor, debug and packaged release: four kinds on the shelves (Torus on Primitives; Spline Path, Spawn Point and Game Rules on Entities); Add Component offers the three components and no kind; each entity carries its own properties; an Inspector-style edit reshapes the torus (1,029 to 441 vertices) and the spline (665 to 249, its midpoint following the moved handle); the spawn point's circle follows its radius; the icon, line and point drawing paths all run in the real render pass; after a scene round trip every kind comes back as itself with its edited values and its components. The game built with xcodebuild registers three component plugins and four entity plugins and creates one of each. Handles: the spline's four control points are drawn as handles in the running editor and follow a property write; picking, local-space writing under a rotated transform, selection validity and the single undo step are unit-tested; the click and drag themselves need a person |
| 4 Polish | Not started | Basic pickers and a color well exist; macro package, evolution SDK, stable entity IDs, plugin resources and CLI flags remain |
| 5 Twins adoption | On hold | Waits for the feature to be tested by hand |
Measured in the running editor on a sample project: launch to first library loaded 4.1 s including app start; save to new revision live 1.2 s; broken save to error with file and line 0.2 s, with the last good revision still running.
Each of these passed every unit test and failed only in a real build, which is why the plan insisted on running the packaged app and a generated game.
- Hidden symbols in release. A subclass in a loaded library copies its base class's dispatch table, entries for internal members included, and release builds hide internal symbols. The packaged editor failed to load any component until
entity,isAttachedandhasStartedbecamefinal.scripts/verify-component-sdk.shnow checks this at packaging time: it compiles a fixture against the bundled SDK and confirms the executable exports every symbol it needs (103 today, with an entity plugin that has its own properties, a generated mesh and an editor representation in the fixture). - Xcode's debug dylib. Xcode builds an app's code into
<App>.debug.dyliband leaves the executable as a stub, so scanning the main executable found nothing.discoverInApp()becamediscoverInApp(), which covers every image inside the app bundle. - Image paths. The loader records
/private/var/...for a library opened as/var/.... Discovery now matches a path against the loaded images instead of trusting its spelling. - Entities pending destruction.
scene.mask(for:)hides them, so the cleanup handler could not reach the instances to detach. The lookup uses the component bit instead. - The bundle script hardcoded a build path the newer SwiftPM does not use, and did not copy the editor's own resource bundle, so the packaged app crashed at launch outside the build tree. Both fixed.
- Marker colors. The first viewport icons came out washed: the pixels are sRGB and the texture was read as linear. Only looking at the running editor showed it; the texture is
rgba8Unorm_srgbnow.
- Components from code are drawn by the Inspector directly, like Splat Twin, instead of being registered through
addComponent_Editor: scene-composition mode whitelists registered components and would have hidden them, and the set of types changes whenever a library loads. They are added from the same Add Component menu as the engine's, under a "From Code" heading; the first version had a second button, which was one too many. - The editor gained
--open-project <folder>, and accepts a folder with aproject.ymland no generated Xcode project. - Each Swift module is a directory in the newer SwiftPM layout; the SDK packaging copies recursively.
- An unloaded type's saved values round-trip unchanged, not byte for byte: the scene encoder does not sort keys.
BuildSettings.includesCodeComponentsis opt-in, so projects generated without it are exactly what they were. The editor turns it on when it knows its own engine reference.
Nothing here can be driven from a script, because macOS refuses UI scripting without an accessibility grant:
- The Inspector renders every kind of field and edits are undoable.
- The one Add Component menu lists the engine's components, then the ones from code under "From Code".
- Rows for kinds of entity drag from the shelves into the viewport and the hierarchy; the Entities shelf appears and disappears with its contents.
- An entity of a kind shows its own block at the top of the Inspector, titled with the kind, and its fields edit it.
- How the editor representation looks on screen: the flag's tint, the spawn radius circle, and the spline's dots and control polygon staying visible over its tube.
- Clicking a spline control point puts the gizmo on it, dragging an axis reshapes the tube, one undo takes the drag back, and a left click on a dot does not drop the selection.
- The contributed menu items appear, toggle, and keep their checkmarks.
- Play, stop, and a rebuild that lands during play.
Examples/CodeComponents/README.md lists the steps.
CreateProjectViewTests.test_createProjectView_resultProjectPathIsNilByDefaultfails on the editor's pristinedevelop.- Generated projects keep
{{PROJECT_NAME}}inMain.storyboard: the variable pass skips storyboard files.
- Components are written in Swift, in Xcode, by subclassing a base class the engine ships. Code is the primary interface; the editor views and tunes.
- A property wrapper marks what the editor sees:
@UntoldAttributeproperties become inspector fields and are saved with the scene; actions become inspector buttons and script-callable functions. - When a project is opened, the editor finds the project's component sources, compiles them and loads them. A change made in Xcode is recompiled and shows up in the running editor without relaunching it.
- The same sources compile into the final game on macOS, iOS and visionOS with no editor involvement and no dynamic loading.
- Loaded code can also extend the editor itself.
@UntoldMenu(.debug, "Splat Twin/…")declares a menu item under one of the editor's fixed root menus; when the library loads, the item appears there. The twin gaussian work is the first real consumer: it is a plugin, and its debug and preview menus should arrive with the plugin instead of being hardcoded in the editor. That adoption happens after this feature has been tested; the twin session is on hold until then. - Everything editor-only (compiler, loader, inspector, menu host) stays on macOS. The kit that game code depends on is platform neutral.
| Capability | Where | Reuse |
|---|---|---|
Component protocol (only init()), class-based components, 128-slot component mask, lazily assigned IDs keyed by ObjectIdentifier |
ECS/ComponentPool.swift, ECS/Scenes.swift |
User components live behind one engine slot (§6.2), so reloads never consume mask slots |
Custom component serialization: encodeCustomComponent(type:merge:) writes EntityData.customComponents: [String: Data] and decodes by type name |
Systems/RegistrationSystem.swift:3152, Scenes/SceneSerializer.swift:822, :1745 |
The kit's storage component is Component & Codable and registers here. No serializer change |
Per-frame plugin lifecycle: EngineExtension (update every frame, fixedUpdate in game mode only) |
ECS/EngineExtensions.swift, Renderer/UntoldEngine.swift:592, :636 |
ScenePluginSystem is one EngineExtension |
USC scripting: ScriptComponent, USCSystem.startPlayMode/stopPlayMode, USCActionRegistry |
Scripting/, Systems/USCSystem.swift |
Component actions are registered as USC actions (§4.3) |
Inspector extension point ComponentOption_Editor + public addComponent_Editor(componentOption:) |
Editor/InspectorView.swift:16, :137 |
One "Code Components" section |
Menu bar built by hand in the app delegate; checkmarks synced in menuNeedsUpdate |
UntoldEditorApp.swift:96, :198 |
EditorMenuHost adds contributed items with the same sync pattern (§7.7) |
Twin plugin wiring: the editor imports UntoldGaussianTwins, owns GaussianTwinPreviewSettings, the Preview Splat Twins item and, on feature/gaussian_large_capture, a Splat Debug tree of toggles and radio submenus (+280 lines in the app delegate) |
Editor/GaussianTwinPreviewSettings.swift, UntoldEditorApp.swift |
This is the code that moves into the plugin's editor sources (§7.8) |
Project model: EditorAssetBasePath.basePath is <Root>/Sources/<Project>/GameData; open-project flow requires <Project>.xcodeproj |
Editor/EditorController.swift:33, Editor/EditorView.swift:1250 |
Component folder is derived the same way; the twin demos already match this layout |
Generated projects are XcodeGen projects: XcodeGenProjectSpec.generateYAML writes project.yml, and the app target compiles everything under Sources/ except GameData |
BuildSystem/XcodeGenProjectSpec.swift, BuildSystem/BuildSystem.swift:420 |
A components folder under Sources/ is compiled by the app target with no new target (§9) |
Play mode: setEditorPlayMode snapshots the scene, restores it on stop, starts/stops USC |
Editor/EditorView.swift:1751 |
Same hook starts/stops code components |
Background process with task UI (swift run under TaskCenter), Log Console, undo manager |
Systems/ScriptProjectManager.swift:139, Editor/EditorUndoManager.swift:283 |
Compile pipeline pattern; undoable edits |
| App bundle script copies executable, metallib, resource bundle, exporter scripts | UntoldEditor/create_app_bundle.sh |
Gains the Component SDK copy step (§7.2) |
Feature flags; EditorAuthoringMode.sceneCompositionOnly = true currently hides the Script Component |
Config/EditorFeatureFlags.swift |
New enableCodeComponents flag |
Not present today: any property metadata or reflection for components, any dynamic code loading (HotReloadingSystem.swift only swaps a hand-picked .metallib), any way for code outside the editor to add a menu item, a compile SDK in the app bundle.
Xcode ──edits──▶ project: <Root>/Sources/<Project>Plugins/*.swift ◀── same files, no editor ──┐
plugin: <Plugin>/Sources/<Plugin>Editor/*.swift (editor-only sources) │
│ │
editor: watch / "Build" ▼ ▼
/usr/bin/xcrun swiftc -emit-library Xcode / xcodebuild (static link)
against the Component SDK shipped in the .app │
│ ▼
▼ game binary: macOS / iOS / visionOS
dlopen <Module>_r<N>.dylib ──▶ discovery ──▶ registries rebind │
│ │ │
▼ ▼ ▼
Inspector: @UntoldAttribute Menu bar: @UntoldMenu ◀── UntoldComponentKit: base classes, wrappers,
fields, action buttons items by domain reflection, storage, system, registries
Three parts, in dependency order:
- A.
UntoldComponentKit(engine package, new library product and target, likeUntoldEngineAR). Platform neutral: Foundation, simd, ObjC runtime,UntoldEngine. HoldsComponentPlugin,@UntoldAttribute,EditorMenuPlugin,@UntoldMenu, reflection, the storage component, the per-frame system, registries and discovery, and the USC bridge. - B. Editor (macOS only): source discovery, compiler, loader, reload and state migration, inspector section, menu host, Plugins panel, Component SDK inside the app bundle.
- C. Build system: generated XcodeGen projects link the kit and call registration; the app bundle script ships the SDK.
import UntoldComponentKit
import UntoldEngine
import simd
final class PlayerController: ComponentPlugin {
@UntoldAttribute("Speed", range: 0...20) var speed: Float = 5
@UntoldAttribute var jumpHeight: Float = 1.5
@UntoldAttribute var lives: Int = 3
@UntoldAttribute var invincible = false
@UntoldAttribute var spawnOffset: SIMD3<Float> = [0, 1, 0]
@UntoldAttribute(.color) var tint: SIMD4<Float> = [1, 1, 1, 1]
@UntoldAttribute var target = EntityRef() // resolved by entity name
@UntoldAttribute var footstep = AssetRef(category: .animations)
@UntoldAttribute var stance: Stance = .idle // String enum + CaseIterable: picker
enum Stance: String, CaseIterable { case idle, walk, run }
override class var actions: [PluginAction] {
[
PluginAction("Jump") { ($0 as! PlayerController).jump() },
PluginAction("Reset") { ($0 as! PlayerController).reset() },
]
}
override func onStart() {
// play mode began, or the entity was spawned during play
}
override func onUpdate(deltaTime dt: Float) {
guard let transform else { return }
if InputSystem.shared.keyState.wPressed { transform.position.z -= speed * dt }
}
func jump() { /* ... */ }
func reset() { /* ... */ }
}Rules the kit enforces or documents:
- Subclass
ComponentPlugin.finalis recommended. Generic classes are not discovered. No initializer parameters: the kit constructs every instance withinit()and then applies saved values. - Only stored properties wrapped in
@UntoldAttributeare shown and saved. Everything else is runtime-only state. actionsis an explicit table in v1. An attribute on the function itself needs a Swift macro (§5.5, D6).- Lifecycle, in order:
onAttach()(instance bound to an entity, edit or play),onStart()(play begins),onUpdate(deltaTime:)(each frame in play),onFixedUpdate(deltaTime:)(fixed step, in play),onStop(),onDetach(), andonEditorChanged(property:)(the inspector wrote a value; edit mode only). - Access:
entity: EntityID,transform: LocalTransformComponent?, thesceneglobal and every public engine API.ComponentPluginRegistry.entities(with: PlayerController.self)andComponentPluginRegistry.component(PlayerController.self, on: entityId)replacequeryEntitiesfor kit types. - Isolation rule. Component sources may import only
UntoldEngine,UntoldComponentKit, plugin modules declared for the project, Foundation and simd. They must not reference types from the app target: the editor compiles this folder on its own, and its compile error is the guard.
| Swift type | Inspector control | Saved as | Hints |
|---|---|---|---|
Float |
number field or slider | number | range:, step: |
Int |
number field or slider | integer | range:, step: |
Bool |
toggle | bool | |
String |
text field | string | .multiline |
SIMD3<Float> |
X/Y/Z fields (existing TextInputVectorView) |
[x, y, z] |
|
SIMD4<Float> |
X/Y/Z/W fields, or a color well with .color |
[x, y, z, w] |
.color |
EntityRef |
picker over scene entities, stores the entity name | {"entity": "Enemy01"} |
see D7 |
AssetRef |
picker over the project's asset category, stores a project-relative path | {"asset": "Animations/run.untold"} |
category: |
RawRepresentable<String> & CaseIterable enum |
popup | raw value |
Optionals are not supported in v1. Adding a kind is one conformance to UntoldAttributeValueType in the kit plus one control in the editor.
Every PluginAction is registered in USCActionRegistry as "<TypeName>.<ActionName>" when its type registers, and removed when the type is replaced. A USC script attached to the same entity can call it with callAction("PlayerController.Jump"). The inspector shows one button per action, in edit mode and in play mode. Edit-mode actions are authoring helpers; their effects are not tracked by undo (the play-mode snapshot already protects the scene during play).
Components describe entities. An EditorMenuPlugin describes what a loaded library adds to the editor itself. It is discovered the same way as components, instantiated once when its library loads, and ignored entirely in a game.
This is the twins plugin's editor side, replacing GaussianTwinPreviewSettings and the hardcoded Splat Debug tree:
import UntoldComponentKit
import UntoldEngine
import UntoldGaussianTwins
final class GaussianTwinsEditor: EditorMenuPlugin {
@UntoldMenu(.view, "Preview Splat Twins",
tooltip: "Swap meshes linked to a .untoldgs twin for the splat as the scene camera approaches.")
var previewTwins = true
@UntoldMenu(.debug, "Splat Twin/Disable HZB Occlusion Cull") var disableHZBCull = false
@UntoldMenu(.debug, "Splat Twin/Disable Opaque Depth Test") var disableDepthTest = false
@UntoldMenu(.debug, "Splat Twin/Blend Cap") var blendCap: BlendCap = .c64 // enum: radio submenu
@UntoldMenu(.debug, "Splat Twin/Reset Link Adoption")
var resetAdoption = UntoldMenuAction { GaussianTwinSystem.shared.resetSceneLinkAdoption() }
enum BlendCap: String, CaseIterable { case c64 = "64", c128 = "128", unlimited = "Unlimited" }
override func menuDidChange(_ domain: UntoldMenuDomain, _ path: String) {
if previewTwins { GaussianTwinSystem.shared.install() } else { GaussianTwinSystem.shared.uninstall() }
GaussianDebugOptions.shared.disableHZBOcclusionCull = disableHZBCull
GaussianDebugOptions.shared.disableOpaqueDepthTest = disableDepthTest
}
override func onSceneReset() { GaussianTwinSystem.shared.resetSceneLinkAdoption() }
override func onUnload() { GaussianTwinSystem.shared.uninstall() }
}| Property type | Menu item | Example |
|---|---|---|
Bool |
toggle with a checkmark | @UntoldMenu(.debug, "Splat Twin/Preview") var preview = true |
RawRepresentable<String> & CaseIterable enum |
submenu with one radio item per case | Blend Cap ▸ 64 / 128 / Unlimited |
UntoldMenuAction |
command | Reset Link Adoption |
Options: key: (key equivalent), tooltip:, persist: (default true), enabled: (closure evaluated when the menu opens).
Domains. The first argument names one of the editor's root menus. The set is closed and owned by the kit and the editor, so loaded code can never create a root menu:
| Domain | Root menu | Notes |
|---|---|---|
.file |
File |
the editor's existing menu; contributed items follow a separator below the editor's own |
.view |
View |
same |
.debug |
Debug |
owned by the editor, shown only while it holds at least one item |
.tools |
Tools |
same; the home for plugin commands that are not diagnostics |
Path rules: the second argument is the item title, optionally preceded by submenu names separated by /, as in "Splat Twin/Blend Cap". It never names a root. Items are grouped per extension and keep declaration order; a second declaration of the same domain and path is rejected with an error in the Components panel. Built-in items cannot be overridden. Adding a domain means changing the kit and the editor together.
onLoad(), onUnload(), onSceneReset() (a scene was loaded or cleared, or the project switched: the three places the editor calls GaussianTwinPreviewSettings.sceneDidReset() today), onPlayModeChanged(_:), onEditorUpdate(deltaTime:) (edit mode, optional), menuWillOpen() (refresh wrapped values from outside state so checkmarks stay truthful), menuDidChange(_ domain:_ path:).
The wrapper owns the value. The editor persists it per project in UserDefaults under editor.menu.<project-hash>.<domain>.<path> and re-applies it on load and after every reload, then calls menuDidChange once per item. That reproduces what GaussianTwinPreviewSettings does by hand with editor.gaussianTwins.preview. persist: false opts out.
@UntoldMenu(.debug, "SplatTwin", .bool) func setSplatTwin(_ on: Bool) and @UntoldMenu(.debug, "Splat Twin/Reset") func reset() put the attribute on the function. Swift only allows custom attributes on functions through macros, so this form arrives with the macro package (D6): a member marker @UntoldMenu plus a type-level macro that synthesizes the same descriptors the wrappers produce. Semantics are identical: for .bool the editor owns and persists the state and calls the function on load and on every toggle. The property form above needs no macro and ships in v1.
A plugin's entity is not always a mesh. It can be a torus, but it can also be a spawn point, which the editor must show and the game must not; a spline, which is a tube in the game and a tube plus control points in the editor; or a rules object, which nothing shows. Loaded code adds such kinds with an EntityPlugin.
The plugin is the entity, not a recipe for one. An instance stays bound to each entity of the kind, is saved with the scene, and is bound again on load, in the editor and in the game. Everything that is part of the entity lives on it:
public final class SplinePathEntity: EntityPlugin {
@UntoldAttribute("Start") public var start: SIMD3<Float> = [-1.5, 0, 0] // its own properties
@UntoldAttribute("Start Handle") public var startHandle: SIMD3<Float> = [-0.5, 1, 1]
// ...
override public func onAttach() { rebuild() } // its geometry
override public func onEditorChanged(property _: String) { rebuild() }
override public var editorRepresentation: EditorRepresentation { // its editor representation
EditorRepresentation([
.polyline([start, startHandle, endHandle, end], closed: false),
.points([start, end], tint: [1.0, 0.75, 0.2]),
.points([startHandle, endHandle], tint: [0.35, 0.8, 1.0]),
])
}
}| Part of the entity | Mechanism | Saved |
|---|---|---|
| Its own properties | @UntoldAttribute on the plugin; the Inspector shows them in the entity's own block, titled with the kind, above its components, with no remove button |
yes, under "entity" next to "components" |
| Geometry | built in onAttach and onEditorChanged, handed over with setGeneratedMesh(_:name:) |
no: the properties that define it are |
| Editor representation | var editorRepresentation: .icon, .points, .polyline and .handles, in the entity's local space; asked every frame while editing, so it follows the properties |
no |
| Starting components | onCreate(), once, when the entity is first made from the kind |
the components are |
| Behaviour | the same lifecycle and actions as a component | n/a |
An entity can have geometry, an editor representation, both or neither: torus, spawn point, spline, game rules. Icons are hidden by geometry in front of them, like the editor's light markers; lines and points are drawn over everything, so a handle inside a mesh stays visible. All of it is drawn in the editor's light marker pass, so it is absent in play mode and cannot reach a game. Lines reuse the pipeline of the selection box and are white; points are small billboards.
Handles. .handles names SIMD3<Float> properties, and each is a draggable point. The property is the truth and the dot follows it, so nothing new exists in the hierarchy, the scene file or play mode (the alternative, editor-only child entities, would have needed meshes to be pickable, a serializer rule to skip them, and removal at play). A click near a dot, with either button and within 14 points of it on screen (each handle is projected with the camera's own matrices, which a test checks against the engine's click ray), selects the entity and puts the move gizmo on the point instead of on the entity; the gizmo's own axis drag then writes the property through ScenePluginSystem.setAttribute, so onEditorChanged rebuilds the geometry as the point moves, and the drag is registered as one undo step. The selected handle is drawn white and holds while its entity stays the active one; the rotate and scale gizmos go back to the entity. Entities whose only presence is an editor representation get the gizmo too, so a spawn point can be moved in the viewport.
Shelves are a closed set, for the reason the menu roots are (§5.2): .primitives and .lights put the row under the built-in ones, and .entities is a third shelf that the Content browser shows only while it holds something. Rows behave like the built-in rows: drag into the viewport or onto the hierarchy, or double-click. The payload carries the kind's type name under its own key, so the existing drop decoding tells it from an asset, a light or a primitive.
Component or entity. A ComponentPlugin is something any entity could have. What could only ever belong to one kind of entity is not a component: it is a property of that entity. Revision 4 had it the other way round (a one-shot EntityTemplate plus a TorusShape component, then, in 4.1, a way to lock that component to its kind). The lock was a symptom: a component that can only be tied to one entity is part of the entity, so it became the entity's own properties and the lock went away.
For shapes the engine does not ship there was no public way in: Mesh.makeMeshes is internal. BasicPrimitives.createMesh(from: MDLMesh) is the one engine addition. Generated geometry is recorded by the scene as a procedural mesh name the engine does not know, which it restores as a cube; the plugin then replaces it on attach, and setGeneratedMesh carries the old submesh's material over, so material edits survive both a rebuild and a reload. A mesh built this way is part of the entity, and the editor's remove policy keeps it.
Entity plugins are discovered and registered per library revision like the other plugins, unregister with their library, and are listed in the Plugins tab. ScenePluginSystem.discoverInApp() registers them in a game too, where EntityPluginRegistry.shared.instantiate(_:at:entityName:) makes the same entities in code.
UntoldAttribute<Value> and UntoldMenu<Value> are class-based property wrappers conforming to AnyUntoldAttribute / AnyUntoldMenu (label or path, kind, hints, getAny(), setAny(_:)). untoldAttributes() and untoldMenuItems() walk Mirror from the root superclass down, in declaration order, and strip the _ prefix from each wrapped property's label. Because the wrapper is a reference the instance owns, the inspector and the menu host write through the same object the code reads. No key paths, no code generation. Verified in spike B (§8).
ScenePluginsComponent: Component, Codable is the only engine component the kit adds. It holds [Slot] where a slot is typeName, payload: [String: UntoldAttributeValue] and instance: ComponentPlugin?.
Why not one engine component per user type:
- Engine component IDs are keyed by
ObjectIdentifierof the Swift type. Every reload produces new types, so each reload would burn one of the 128 slots per user type. - Scenes must load before the library is available (and in a fresh editor without one). A slot with
instance == nilkeeps its payload, round-trips it untouched on save, and is bound when the type appears. The inspector shows it as "not available in the loaded library".
Registered once with encodeCustomComponent(type: ScenePluginsComponent.self). The payload written under customComponents["ScenePluginsComponent"]:
{
"components": [
{
"type": "PlayerController",
"properties": {
"speed": 12.5,
"spawnOffset": [0, 1, 0],
"target": { "entity": "Enemy01" },
"stance": "walk"
}
}
]
}Serialization is reflection-driven, not synthesized Codable. Two reasons: spike B showed that a subclass of a Codable class does not get synthesized coding for its own properties, and reflection tolerates properties added or removed between reloads (missing: default kept; extra: dropped with a log line).
ComponentPluginRegistry and EditorMenuPluginRegistry map unqualified type names to types, with a revision stamp. discover(imagePath:) uses objc_copyClassNamesForImage and keeps classes whose superclass chain reaches ComponentPlugin or EditorMenuPlugin. No entry point and no hand-maintained list. Spike C proved it for a loaded dylib and for classes compiled into an executable with -O -whole-module-optimization -dead_strip, where nothing references them. The game calls discoverInApp(). Duplicate names across modules are rejected with an error. rebind(name:to:) is what a reload calls: same name, new type, old instances migrated (§7.5).
ScenePluginSystem: EngineExtension, id com.untoldengine.componentkit. update forwards to onUpdate only while gameMode is true; fixedUpdate forwards to onFixedUpdate (already gated by the engine). It ticks after input and scene-graph traversal and before LOD and batching, which is where EngineExtensionRegistry.updateExtensions sits in runFrame. startPlayMode() / stopPlayMode() mirror USCSystem.
ComponentRegistry.register(componentType: ScenePluginsComponent.self) calls onDetach on every live instance when an entity is destroyed. Callbacks run on the engine's simulation thread: main on macOS and iOS, the compositor render thread on visionOS (runFrame is driven from there in XR). Components must not touch SwiftUI or UIKit state directly. The kit builds in language mode 6; user sources compile in language mode 5 (same as the editor and generated games).
| Source | Location | Reloads |
|---|---|---|
| The project's plugins folder | <Root>/Sources/<Project>Plugins/, or the pluginsFolder path in <Root>/UntoldEditor.json |
on save |
| A plugin package listed by path (under development) | pluginPackages[].path in UntoldEditor.json; the package root holds untold-package.json |
on save |
| A plugin package the editor already links | its editor sources only; the runtime module comes from the SDK | on save |
Two places, named apart on purpose. The plugins folder belongs to the one game: part of the app like any source folder, no manifest, created by the editor. A plugin package is a Swift package of its own that several projects share (UntoldGaussianTwins), with a Package.swift for games and an untold-package.json for the editor; it is the "plugin package" of the engine's plugin architecture docs. Either can hold component, entity and editor menu plugins. BuildSystem.pluginsFolderName(forProject:) is the one place the folder name lives; the names from before the rename (<Project>Components, the components and plugins keys, untold-plugin.json) are still read, and each is reported in the Plugins tab and the console with what to rename it to, so projects made earlier keep working.
UntoldEditor.json (optional, project root):
{ "pluginsFolder": "Sources/SplatTwinPlugins",
"pluginPackages": [ { "path": "../../../Libs/UntoldGaussianTwins" } ] }untold-package.json (plugin package root):
{ "id": "com.miolabs.untold.gaussianTwins",
"module": "UntoldGaussianTwins",
"runtimeSources": "Sources/UntoldGaussianTwins",
"editorSources": "Sources/UntoldGaussianTwinsEditor" }If the plugins folder is missing, the panel offers Create plugins folder, mirroring ScriptProjectManager.initializeProject.
Compiling against the editor's own modules is what lets loaded code share the editor's engine (one scene, one registry). The bundle ships Contents/Resources/ComponentSDK/:
ComponentSDK/
├── Modules/
│ ├── UntoldEngine.swiftmodule (+ .swiftdoc)
│ ├── UntoldComponentKit.swiftmodule (+ .swiftdoc)
│ └── UntoldGaussianTwins.swiftmodule (every plugin the editor links; listed in sdk.json)
├── CShaderTypes/
│ ├── *.h (UntoldEngine imports this clang module)
│ └── module.modulemap (rewritten with a relative umbrella; SwiftPM's points at the checkout)
└── sdk.json { swiftCompilerVersion, engineRevision, target, languageMode, providedModules }
When the editor runs from source, the SDK resolves to the build directory next to the executable. Two SwiftPM layouts exist and the resolver probes both: .build/<triple>/<config>/Modules/ (classic build system, what the editor's current .build uses) and .build/out/Products/<Config>/ (the newer build system Swift 6.4 selected in the spikes). create_app_bundle.sh gains the copy step and writes sdk.json.
ComponentCompiler runs /usr/bin/xcrun swiftc by absolute path (the user's swift on PATH can be a swiftly shim; on the baseline machine it points at a missing toolchain):
/usr/bin/xcrun swiftc -emit-library -parse-as-library \
-o <cache>/<Module>_r<N>.dylib -module-name <Module>_r<N> \
-emit-module -emit-module-path <cache>/<Module>_r<N>.swiftmodule \
-module-alias <Plugin>=<Plugin>_r<N> (one per reloadable plugin this module imports)
-swift-version 5 -Onone -g -D UNTOLD_EDITOR \
-target arm64-apple-macosx14.0 -sdk "$(xcrun --sdk macosx --show-sdk-path)" \
-I <SDK>/Modules -I <cache> -Xcc -fmodule-map-file=<SDK>/CShaderTypes/module.modulemap \
-Xlinker -undefined -Xlinker dynamic_lookup \
<every .swift in the source folder>
<cache> is ~/Library/Caches/com.untoldengine.studio/Components/<project-hash>/. Build order: package runtime modules, then package editor modules, then the project module. Diagnostics go to the Log Console and the Components panel. The compile runs as a TaskCenter task with cancel. Pre-flight: swiftc --version must equal sdk.json.swiftCompilerVersion; otherwise one clear error names the required Xcode. -D UNTOLD_EDITOR lets sources guard editor-only code with #if UNTOLD_EDITOR.
ComponentLibraryLoader loads plugin runtime libraries with RTLD_NOW | RTLD_GLOBAL and everything else with RTLD_NOW | RTLD_LOCAL, then runs discovery on each image. Spike D showed why: a module that imports a reloadable plugin resolves the plugin's symbols through the flat namespace, which only sees global images. Libraries are never dlclosed: Swift runtime metadata cannot be unloaded safely. N increments per build and every module name is unique per build, which avoids the ObjC runtime's duplicate-class registration (spike A). -module-alias keeps import UntoldGaussianTwins valid in source while the real module is UntoldGaussianTwins_r<N> (spike D).
Runs on the main thread between frames (the draw loop is on main; a DispatchQueue.main block lands between two MTKView draws):
- If play mode is on, stop it. This restores the pre-play snapshot, so play-mode drift is never migrated.
- Snapshot: every slot's live instance is reflected into its payload. Extensions get
onUnload(). - Load revision
N+1of every rebuilt module, discover,rebindevery type by name. Types that disappeared keep their payload and lose their instance. - Re-instantiate components:
init(), apply payload (missing property: default; extra property: dropped, logged),onAttach(). Instantiate extensions, rebuild their menu items, re-apply persisted values, callonLoad()thenmenuDidChangeper item. - Refresh inspector and hierarchy. USC action registrations are replaced.
The old libraries stay mapped. Their memory cost is roughly the dylib size per reload and is shown in the panel.
- A
DispatchSourcewatcher on every reloadable source folder, debounced 300 ms, triggers compile + reload when Rebuild on save is on. Off by default on the first open of a project; the panel asks once. A manual Build button always exists. The editor only ever compiles the opened project's component folder and the plugins that project declares. - Components from code are rendered by
ScenePluginInspectorView, drawn by the Inspector directly like Splat Twin so scene-composition mode keeps them, and styled like the engine's components. Per attached instance: display name, remove button, the field form generated fromuntoldAttributes(), then action buttons. Unknown types render as a read-only row that keeps their payload. There is one Add Component menu (AddComponentMenu): the engine's components, then the registry's types under a "From Code" heading. Edits go throughEditorUndoManager.registerValueChange, mark the scene dirty, and callonEditorChanged(property:). - The Content browser's Primitives and Lights shelves list the entity kinds loaded code added under the built-in rows, and an Entities shelf appears while it has any (§5.6).
- A Plugins tab beside Log and Tasks: per-module status (
r<N>, built at, compiler), discovered components with instance counts, loaded extensions with their menu paths, entity kinds with their shelf, the error list (click opens the file at the line in Xcode viaxed --line), and the buttons Build, Rebuild on save, Open in Xcode, Reveal folder. - Everything sits behind
EditorFeatureFlags.enableCodeComponents.
EditorMenuHost owns every contributed NSMenuItem. It builds items from untoldMenuItems() of each loaded extension, maps each domain to its root menu (Debug and Tools are shown only while they hold items), tags items with their owner revision, and on reload removes the old revision's items before building the new ones. It is the NSMenuDelegate for contributed menus: menuNeedsUpdate calls the extension's menuWillOpen() and then syncs checkmarks and radio marks from the wrapped values, the same pattern the app delegate uses today for its own items. A click writes the wrapper, persists the value, and calls menuDidChange(domain, path).
A plugin package keeps its runtime where it is (Sources/UntoldGaussianTwins, linked by games through SwiftPM as today) and adds an editor-only folder, Sources/UntoldGaussianTwinsEditor, which no game target compiles.
Two loading modes:
- The editor links the plugin (the twins today). The SDK provides the runtime module, the loader compiles only the plugin's editor sources, and their symbols resolve into the running editor exactly as engine symbols do. There is one
GaussianTwinSystem. This is the mode to start with. - The editor does not link the plugin. The loader compiles the runtime sources as
<Module>_r<N>, loads it globally, and aliases it for everything that imports it. This works for Swift-only plugins without resources, which is what UntoldGaussianTwins is (three files, one dependency). Plugins with metallibs or C targets need a resource story first and are out of scope for v1.
A plugin must never be present both ways at once: a statically linked copy and a loaded copy would mean two singletons, with the inspector talking to one and the menu to the other. sdk.json.providedModules is what the loader checks.
Migration of the twins integration happens after the feature has been tested on the sample project (stage 5), in two steps:
- Menus and system lifecycle move to
GaussianTwinsEditor(§5.1):Preview Splat Twins, theSplat Debugtoggles and radio submenus, theWorking Setsubmenu, install/uninstall andsceneDidReset.GaussianTwinPreviewSettings.swiftand the menu code inUntoldEditorApp.swiftare deleted. The editor keeps linking the plugin. - The Splat Twin inspector section, align mode and link persistence stay in the editor for now. They are SwiftUI views over editor internals, and the declarative API here cannot express them. They move when an editor UI module exists for plugins to import (out of scope, listed in stage 4). After that the editor stops linking the plugin.
Spikes A and B ran on 2026-09-14, C and D on 2026-09-17. Appendix B has the commands.
| Check | Result |
|---|---|
dylib built with -I <build>/Modules and -Xlinker -undefined -Xlinker dynamic_lookup, no host library linked, loads with RTLD_NOW and its entry runs |
Pass |
| Globals are shared: the plugin incremented a host global; the host read the new value | Pass (counter 101 after one register + 100) |
A host function the host never references (@inline(never)) is still in the executable and callable from the plugin |
Pass (nm finds it; SwiftPM does not dead-strip) |
| A small unreferenced function absent from the executable | Still callable: the client emitted its own copy through cross-module optimization |
| Two versions of the plugin with the same module name loaded in one process | Both work, but the ObjC runtime warns that the class is implemented twice |
| Same, with a unique module name per version | Pass, no warning |
| Check | Result |
|---|---|
Class-based wrapper on Float, Int, Bool, SIMD3<Float>; Mirror lists the five wrapped properties in declaration order with label and range; an unwrapped property is skipped |
Pass |
Inspector-style write through the reflected wrapper changes the component's value; a later onUpdate mutates it further |
Pass (5 → 12.5 → 13.0) |
Synthesized Codable on a subclass of a Codable base class |
Produced {}: subclasses get no synthesis, hence §6.3 |
| Check | Result |
|---|---|
objc_copyClassNamesForImage on the main executable finds two ComponentPlugin subclasses nothing references, built with -O |
Pass |
Same with -O -Xlinker -dead_strip |
Pass |
Same with -O -whole-module-optimization -Xlinker -dead_strip (what an Xcode Release app target does) |
Pass |
Same API on a dlopened dylib with no entry point; non-component classes filtered out; instances created through init() |
Pass |
| Check | Result |
|---|---|
Plugin built as TwinLib_r1; project sources say import TwinLib and compile with -module-alias TwinLib=TwinLib_r1 |
Pass |
Plugin loaded RTLD_GLOBAL, project loaded RTLD_LOCAL; project code calls the plugin's singleton |
Pass |
| Revision 2 of both loaded into the same process after revision 1; each project revision sees its own plugin revision; no ObjC warnings | Pass |
Control: plugin loaded RTLD_LOCAL |
Fails as predicted: symbol not found in flat namespace |
- Compiling against the editor's real
UntoldEngine.swiftmodule(it importsCShaderTypesand Metal) with the SDK layout in §7.2, from a bundled app and from a source build, in both SwiftPM layouts. -targetand deployment-target agreement between the editor binary and the loaded dylibs.- Removing and rebuilding contributed
NSMenuItems across a reload while a menu is open. - Wall time of a full compile (expected 1 to 3 s for a handful of files) and of the reload protocol on a scene with a few hundred instances.
Generated projects and the twin demos are XcodeGen projects. Their app target's sources are Sources/ minus GameData, so Sources/<Project>Plugins/ is compiled into the app with no new target. Three changes:
XcodeGenProjectSpec.generateYAMLadds the kit to the app target's dependencies:
dependencies:
- package: UntoldEngine
product: UntoldComponentKit- The engine package reference comes from
BuildSettingsinstead of the hardcoded upstream URL andbranch: develop, and defaults to the revision the editor was built with (D8). While the kit exists only on the fork, projects that use it point athttps://github.com/miolabs/UntoldEngine.git. - The generated
GameSceneregisters before loading scenes:
private func configureEngineSystems() {
gameMode = true
AnimationSystem.shared.isEnabled = true
ComponentPluginRegistry.discoverInApp()
ScenePluginSystem.install() // registers the EngineExtension and the storage component's serializer
ScenePluginSystem.shared.startPlayMode()
/* ... */
}Scenes decode customComponents as they do today; slots bind to the statically linked types; onAttach and onStart run. iOS and visionOS use exactly this path. Nothing dynamic ships in a game. EditorMenuPlugin subclasses compile into the game only if their file is not guarded by #if UNTOLD_EDITOR; they are never instantiated there.
XcodeGen lists files at generation time, so the editor re-runs xcodegen generate after it creates the plugins folder or a new plugin file (BuildSystem already locates the tool). For an existing project such as SplatTwin or BedroomTwin, Create component package adds the dependency line to project.yml, regenerates, and prints the registration snippet to the Log Console.
SwiftPM-style projects (the legacy BuildTemplates.packageSwift path) get the same result with a <Project>Plugins library target that the executable depends on.
| Risk | Effect | Mitigation |
|---|---|---|
Binary .swiftmodule files are compiler-version specific |
Compiles must use the same swiftc that built the editor |
sdk.json check with a clear error naming the required Xcode; a source-built editor always matches. The release workflow must build with a current Xcode (it uses Swift 6.0 today). Later: .swiftinterface with library evolution (D3) |
| Swift dylibs cannot be unloaded | Memory grows by about one dylib per module per reload | Unique module names, old instances released, footprint shown in the panel |
| Reloaded types have new identity | Any engine-side reference to a user type goes stale | All references go through the kit registries by name; the engine never sees user types (§6.2) |
| A plugin present both statically and as a loaded copy | Two singletons; inspector and menu drive different systems | sdk.json.providedModules decides the mode per plugin; never both (§7.8) |
Plugin runtime loaded with RTLD_LOCAL |
Dependent modules fail to load | Loader rule in §7.4, covered by the integration test |
| Components share the app module in the game but compile alone in the editor | Code that references app types builds in Xcode and fails in the editor | Isolation rule (§4.1); the editor's error names the file and line |
| Editor engine revision differs from the project's pinned engine | Compiles against an API the game does not have, or vice versa | Warn from sdk.json.engineRevision vs the project's resolved pin; D8 removes the skew for new projects |
| User code runs in the editor process | A crash in a component or a menu action takes the editor down | Autosave the open scene before every play and every reload. Later: out-of-process play |
visionOS drives runFrame from the compositor render thread |
Component callbacks are off the main thread there | Documented in §6.6; no SwiftUI or UIKit access from callbacks |
| Two types with the same name, or two menu items with the same domain and path | Ambiguity | Registries and the menu host reject the second with an error in the panel |
| The editor README states the editor is not for gameplay logic or script attachment | This feature changes that stance | Behind enableCodeComponents; on in the fork, proposed upstream with the flag off (D4) |
| Loading code from a folder | Same trust level as running the project in Xcode | Only the opened project's folder and its declared plugins are compiled; rebuild-on-save is opt-in per project |
Branches are cut from the fork's develop. The editor feature branch points its engine dependency at the engine feature branch by name. Commits and pushes to the fork are fine at any time; no pull request until §12's end-to-end bar is met.
| Stage | Goal | Repo | Branch | Contents | Tests |
|---|---|---|---|---|---|
| 0 | Close the open spikes (§8) | scratch | none | Real-module compile from source and bundle; target agreement; menu rebuild across reload; timings | Notes appended to this document |
| 1 | Kit, static path | engine | feature/component_kit |
Sources/UntoldComponentKit/: ComponentPlugin, UntoldAttribute, values, EntityRef, AssetRef, PluginAction, EditorMenuPlugin, UntoldMenu, UntoldMenuAction, storage component, registries and discovery, system, USC bridge; package product; docs/API/UsingCodeComponents.md |
Reflection order and kinds, payload round-trip, unknown-type preservation, discovery in the test image, rebind migration, gating on gameMode, USC registration, menu descriptor reflection and path parsing |
| 1b | Generated projects | engine | feature/component_kit_projects |
XcodeGen spec dependency and configurable engine reference; GameScene registration; add-to-existing-project helper; SwiftPM template target |
Spec snapshot tests; a generated project builds in CI |
| 2 | Editor loads at project open | editor | feature/code_components_loader |
Source locator and manifests, SDK resolution and sdk.json, compiler (build graph, aliases), loader (global and local), Components panel, feature flag; bundle script SDK step |
Locator and manifest parsing; compiler argument builder (pure, snapshot-tested); SDK resolution in both layouts; integration test that compiles fixtures against the test build's modules and loads them, including a plugin runtime plus a dependent module |
| 2b | Inspector | editor | feature/code_components_inspector |
Inspector section, new EditorField kinds, Add Component submenu, undo and dirty-state wiring, play-mode wiring |
Form generation; undo of an edit; add and remove mark dirty; play start and stop reach the system |
| 2c | Menu host | editor | feature/code_components_menus |
EditorMenuHost, path resolution, persistence, menuWillOpen / menuDidChange, extension lifecycle calls at scene reset and play changes |
Path to menu tree; checkmark and radio sync; persisted value re-applied; duplicate path rejected; items removed on unload |
| 3 | Hot reload | editor | feature/code_components_hot_reload |
Reload protocol (§7.5), watcher and rebuild-on-save, autosave before reload, panel status | Migration with added and removed properties; reload during play stops play first; menu items rebuilt with values kept; watcher debounce |
| 3b | Sample project and sample plugin: the test vehicle for the whole loop | engine | feature/component_kit_sample |
Examples/CodeComponentsSample: a generated macOS project with two components and one EditorMenuPlugin that adds a Debug menu, plus SamplePlugin, a tiny Swift-only plugin package listed by path with its own editor sources |
The §12 end-to-end checklist is run against it by hand; its sources double as fixtures for the loader integration test |
| 4 | Polish | all | per item | Entity and asset pickers, enum popups, color well; macro package for @UntoldMenu and @Callable on functions (D6); editor UI module so plugins can ship inspector sections; library-evolution SDK (D3); stable entity IDs (D7); plugin resources |
Per item |
| 5 | Twins plugin and twin demo adopt it, after the end-to-end test passes; done together with the twin gaussian session, which is on hold until then | twins + editor + arcade | feature/editor_extension (twins), feature/twins_menus_from_plugin (editor), demo branch in UntoldArcade |
untold-plugin.json, Sources/UntoldGaussianTwinsEditor/GaussianTwinsEditor.swift; editor deletes GaussianTwinPreviewSettings and the hardcoded twin and splat debug menus; the demo's project.yml links the kit and gains a components folder |
SplatDebugMenuTests ported to the descriptor level; preview toggle persists across relaunch; demo builds for visionOS |
Order: 1 before 1b and 2; 2 before 2b, 2c and 3; 3b needs 1b and is what stages 2 to 3 are tested against; 5 starts only after the end-to-end test passes; 4 is independent items. Stage 2 alone is already useful: libraries load once at project open, and a relaunch picks up changes.
- Stage 1. A test target defines a
ComponentPluginsubclass with every supported kind;untoldAttributes()lists them in order; a scene saved with the kit's payload loads back with equal values; a scene saved with a type the test image does not contain round-trips its payload unchanged;onUpdateruns only in game mode; USCcallAction("Type.Action")invokes the action on the entity; anEditorMenuPluginsubclass reports its menu descriptors with parsed paths. - Stage 1b. A project created from the editor builds in Xcode with a components folder that contains the starter component, and the running game instantiates it from the saved scene.
- Stage 2. Opening a project with a plugins folder compiles it, the Plugins panel lists the types, and Add Component offers them. A compile error appears in the panel and the Log Console with file and line, and clicking it opens Xcode at that line. From a bundled app the compile uses only files inside
Contents/Resources/ComponentSDK. - Stage 2b. Every kind renders its control; edits are undoable and mark the scene dirty; values survive save and reload of the scene.
- Stage 2c. An extension's
@UntoldMenu(.debug, "Splat Twin/…")items appear when its library loads, under the editor'sDebugroot, which was hidden while empty; an item cannot name or create a root menu; toggles show checkmarks, enums show radio marks, values persist across relaunch, and the menu disappears when the project closes. - Stage 3. Editing and saving a component in Xcode while the editor is open, with rebuild-on-save on, updates the inspector within a few seconds; instance values survive the reload; a renamed property falls back to its default and logs; a removed type keeps its payload and shows as unavailable; menu items are rebuilt with their values kept.
- End to end (the bar for opening pull requests). Run on
Examples/CodeComponentsSample. The project opens in the editor. Its components compile, load, are editable and hot-reload. The sample extension'sDebugmenu and the sample plugin's menu appear on load, keep their values across reload and relaunch, and disappear when the project closes. The sample app builds in Xcode with the same component sources and behaves the same from the saved scene. - After the bar (stage 5). The twins plugin's menus come from the plugin's own editor sources, no twin menu code remains in the editor, and the twin demo builds for visionOS with a components folder.
Kit tests run on macOS in swift test and are platform neutral apart from discovery, which is tested on macOS only. Editor tests use @testable import UntoldEditor under swift test --filter UntoldEditorTests as today. The dynamic-loading integration test builds fixtures with the same compiler invocation the editor uses, against the test process's own build directory, and loads them: one standalone module, and one plugin runtime plus a dependent module through an alias. It is the automated form of spikes A, C and D and guards the same-compiler contract; it runs only when xcrun swiftc is available (skipped otherwise, never failed). Menu host tests work on descriptors and an NSMenu built in memory; no window is needed.
| # | Decision | Outcome |
|---|---|---|
| D1 | Where the kit lives | A target and product in the engine package, UntoldComponentKit |
| D2 | Storage | One engine slot with kit-managed instances |
| D3 | Compiler policy | Same compiler as the editor for v1; library evolution revisited later |
| D4 | Editor scope | Behind enableCodeComponents, on in the fork, proposed upstream with the flag off |
| D5 | Where component sources live | One folder inside the project. Corrected in revision 2: generated projects are XcodeGen projects, so the folder sits under Sources/ and is compiled by the app target; only SwiftPM-style projects get a separate target |
| D6 | Attributes on functions | Explicit tables and property wrappers for v1; macros in stage 4, in a separate UntoldComponentMacros package so the engine package keeps zero dependencies |
| D7 | Entity references | By name. Scene UUIDs are regenerated on every save (SceneSerializer.swift:509), so a stable ID is engine work outside this proposal |
| D8 | Engine pin in generated projects | The editor's engine revision, from sdk.json |
| Naming | Wrapper names | @UntoldAttribute for inspector properties, @UntoldMenu for menu items |
| Menu roots | How an item picks its root menu | The first argument is a domain from a closed set (.file, .view, .debug, .tools); the second is the submenu path or just the title. Loaded code cannot create root menus |
| Sequencing | What the feature is tested on | Its own sample project and sample plugin first. The twin gaussian session is on hold until that test passes; the twins plugin and the twin demo are updated afterwards (stage 5) |
| # | Question | Default if nothing is said |
|---|---|---|
| N1 | @UntoldMenu directly on a function needs the macro package. Pull D6 forward so the function form exists from the first version? |
No. v1 ships the property form in §5.1; the function form follows in stage 4 |
| N2 | Stage 5, first migration of the twins integration: menus and system lifecycle move to the plugin first; the Splat Twin inspector section, align mode and link persistence stay in the editor until plugins can ship UI | Yes, in that order (§7.8) |
Engine
Package.swift: productUntoldComponentKit, targetSources/UntoldComponentKit, test target.Sources/UntoldComponentKit/:ComponentPlugin.swift,UntoldAttribute.swift,UntoldAttributeValue.swift,EntityRef.swift,AssetRef.swift,PluginAction.swift,EditorMenuPlugin.swift,UntoldMenu.swift,ScenePluginsComponent.swift,ComponentPluginRegistry.swift,EditorMenuPluginRegistry.swift,ImageDiscovery.swift,ScenePlugin.swift,ScenePluginSystem.swift,USCBridge.swift,EntityPlugin.swift,EditorRepresentation.swift.Sources/UntoldEngine/Mesh/BasicPrimitives.swift:createMesh(from:).Sources/UntoldEngine/BuildSystem/XcodeGenProjectSpec.swift: kit dependency, configurable engine reference.Sources/UntoldEngine/BuildSystem/BuildTemplates.swift: registration call, starter component file, SwiftPM template target.Sources/UntoldEngine/BuildSystem/BuildSystem.swift: add-to-existing-project helper,xcodegen generatere-run.docs/API/UsingCodeComponents.md,docs/index.mdlink.
Editor
Sources/UntoldEditor/Components/:ComponentSourceLocator.swift,EditorProjectManifest.swift,PluginPackageManifest.swift,ComponentSDK.swift,ComponentCompiler.swift,ComponentLibraryLoader.swift,ComponentReloadCoordinator.swift,ComponentSourceWatcher.swift,ComponentsPanelView.swift,ScenePluginInspectorView.swift,EditorMenuHost.swift.Sources/UntoldEditor/UntoldEditorApp.swift: hand the menu bar toEditorMenuHost; in stage 3b, delete the twin and splat debug items.Sources/UntoldEditor/Editor/InspectorView.swift: the code component blocks and the oneAddComponentMenu.Sources/UntoldEditor/Components/: alsoAddComponentMenu.swift,EntityPluginPlacement.swift,EntityPluginShelfViews.swift,EditorRepresentationRenderer.swift.Sources/UntoldEditor/Editor/AssetPlacement.swift,AssetBrowserView.swift,AssetBrowserNavigationState.swift: the entity plugin row payload, the rows on the shelves, the Entities shelf.Sources/UntoldEditor/Renderer/EditorRenderPasses.swift: markers drawn in the light marker pass.Sources/UntoldEditor/Editor/ComponentEditorForm.swift: newEditorFieldkinds.Sources/UntoldEditor/Editor/EditorView.swift: discovery on project open, play-mode start/stop, scene-reset notifications to extensions, bottom-panel tab.Sources/UntoldEditor/Config/EditorFeatureFlags.swift:enableCodeComponents.create_app_bundle.sh: Component SDK copy andsdk.json.Tests/UntoldEditorTests/: one file per new type plusComponentLoaderIntegrationTests.swift.
UntoldGaussianTwins
untold-package.json,Sources/UntoldGaussianTwinsEditor/GaussianTwinsEditor.swift.
Spike A, host package: a library target exporting public var sharedCounter, a Registry class, a Plugin protocol, register(_:), and an @inline(never) function the executable never calls; an executable that dlopens each argument, dlsyms untold_plugin_entry, calls it, and prints the global. Build with /usr/bin/swift build -c release. Compile and run the plugin:
BIN=.build/release
/usr/bin/swiftc -emit-library -o libPlugin.1.dylib -module-name GameComponents_r1 \
-I "$BIN" -I "$BIN/Modules" Plugin.swift -Xlinker -undefined -Xlinker dynamic_lookup
"$BIN/HostApp" libPlugin.1.dylib
nm -m libPlugin.1.dylib | grep 'dynamically looked up'Spike B: a single file with the class wrapper, its protocol, a ComponentPlugin base with the reflection walk, and a PlayerController subclass; compile with /usr/bin/swiftc -O reflect.swift and run.
Spike C: a discoverComponents(imagePath:) function built on objc_copyClassNamesForImage and class_getSuperclass; two subclasses nothing references. Build the three-file executable with -O, with -O -Xlinker -dead_strip, and with -O -whole-module-optimization -Xlinker -dead_strip, and pass _dyld_get_image_name(0). For the dylib case the host dlopens the path and passes the same path to discovery.
Spike D:
swiftc -emit-library -parse-as-library -emit-module -emit-module-path out/TwinLib_r1.swiftmodule \
-module-name TwinLib_r1 -o out/TwinLib_r1.dylib -I "$BIN" TwinLib.swift -Xlinker -undefined -Xlinker dynamic_lookup
swiftc -emit-library -parse-as-library -module-name GameComponents_r1 -module-alias TwinLib=TwinLib_r1 \
-o out/GameComponents_r1.dylib -I "$BIN" -I out Project.swift -Xlinker -undefined -Xlinker dynamic_lookup
"$BIN/HostApp" G:out/TwinLib_r1.dylib out/GameComponents_r1.dylib # "G:" = RTLD_GLOBAL in the spike host