110 KiB
Aego — Architecture
Status: active architecture specification Language: Go 1.27+ Repository model: monorepo, single root Go module
aegoScope: 2D-first engine with a 3D foundation, native desktop targets (Android later), built-in editor, custom scripting language, compile-time native Go extensions.
1. Purpose
Aego is a game engine written in Go. It is built around five long-term goals:
- A real native engine, not a wrapper around another engine.
- A built-in editor with a Unity/Godot-like workflow.
- A custom gameplay language with editor integration and hot reload.
- Native Go extensions for projects that need to extend the engine itself.
- A portable runtime where renderer, scene, editor and gameplay code never depend on a specific OS or graphics API.
Aego uses a small number of low-level external pieces where reimplementing platform plumbing adds no engine value: SDL3 for OS integration, generated OpenGL bindings, and build-time tools (font and icon generators in nested modules). Everything else — scene model, rendering, batching, UI, assets, scripting language and VM, editor, physics, audio mixing, networking, persistence, animation, materials, export — is owned by the engine. The root go.mod has no external Go dependencies.
2. Architectural principles
2.1 Core rules
- No global engine singletons; construction is
Builder → Engine. - Low-level packages never import high-level packages. The import graph is a DAG enforced by
tools/depcheck. - Runtime code never depends on editor or dev-only packages.
- Persistent identity is never a runtime handle; runtime handles are generational and never serialized.
- Source assets are not runtime assets; source file paths are not asset identity.
- Source files are canonical text; export and cache artifacts are binary.
- The renderer does not know about scenes; the scene does not know about the editor.
- User Go code imports only
aego/sdk; native extensions are compiled into the project executable, never loaded withplugin.Open. - Every stage ends in a runnable, testable result; stages close on acceptance criteria, not calendar dates.
2.2 Layers
flowchart BT
core["core"]
pinput["platform/input"]
platform["platform"]
gpu["gpu"]
vfs["vfs"]
schema["schema"]
format["format/aetext"]
engine["engine"]
render["render"]
ui["ui"]
loader["asset/loader"]
scene["scene · scene/frame · scene/scriptcomp · scene/sceneio"]
scriptrt["script runtime: abi · bytecode · verify · vm · bind · runtime · stdlib · modules/scripting"]
gameplay["physics · audio · anim · net · store"]
sdk["aego/sdk"]
editor["editor"]
scriptdev["script dev: lexer · parser · checker · compiler · workspace · langsvc"]
project["project"]
pipeline["asset/pipeline"]
app["app · app/run · app/edit · app/supervise"]
entry["cmd · demos · examples · tools"]
core --> pinput --> platform --> engine
core --> gpu --> engine
core --> vfs --> engine
core --> schema
core --> format
gpu --> render
pinput --> ui
render --> ui
format --> ui
vfs --> loader
schema --> scene
loader --> scene
format --> scene
engine --> scriptrt
scene --> scriptrt
engine --> gameplay
scene --> gameplay
scene --> sdk
scriptrt --> sdk
sdk --> editor
schema -. dev-only .-> scriptdev
format -. dev-only .-> scriptdev
vfs -. dev-only .-> project
project -. dev-only .-> pipeline
scriptdev -. dev-only .-> pipeline
pipeline -. dev-only .-> app
editor -. editor-only .-> app
app --> entry
| Layer | Responsibility |
|---|---|
| Foundation | core: handles, math, IDs, clock, logging, errors, stats, RNG, interning. |
| Platform contracts | platform/input, platform, gpu, vfs: OS/GPU/filesystem boundaries and backends. |
| Metadata contracts | schema, format/aetext: type metadata and source serialization. Independent of scene and editor. |
| Orchestration | engine: builder, worlds, phases, systems, services, jobs, frame loop. |
| Presentation | render, ui: scene-agnostic rendering and immediate-mode UI. |
| Runtime content | asset/loader, scene: runtime assets and the Node + Components world. |
| Gameplay systems | script runtime, physics, audio, anim, net, store. |
| Extension surface | aego/sdk: the only import for user native/ code; a facade, not a storage layer. |
| Applications | editor, runtime. |
| Development-only | app (Build), project, asset/pipeline, the script compiler and workspace, app/*, CLI and tools. Never part of exported runtime graphs. |
3. Decision registry
Decision IDs are stable references for reviews, migrations and ADR-style changes. Superseded decisions are rewritten in place and noted.
3.1 Stage 1 — Foundation
| ID | Decision |
|---|---|
| D01 | Go 1.27+; the toolchain is pinned in go.mod. Generic methods on concrete types are used in registry APIs. |
| D02 | SDL3 is pinned and used only for windowing, GL context, events, timing, text input, clipboard, drag/drop, lifecycle and audio device output. SDL_Renderer and SDL_GPU are not used. The cgo bridge is hand-written and limited to the calls the engine needs. |
| D03 | OpenGL 3.3 Core is the first GPU backend; public gpu APIs are backend-neutral. GL bindings are generated (tools/gen-gl.sh) into gpu/gl33/gl. |
| D04 | No global singletons. Construction is Builder → Engine. |
| D05 | All runtime resources use generational handles; public APIs never expose long-lived pointers into internal storage. |
| D06 | The main thread owns platform, GPU and authoritative game/editor state. Workers compute and return results through Jobs.OnMain. |
| D07 | Headless is a first-class engine configuration (Config.Headless), distinct from the aego_nonative build tag. |
| D08 | 2D world: +X right, +Y down, radians internally, positive rotation clockwise, UV (0,0) top-left. |
| D09 | One world unit is not one pixel; camera scale controls world-to-screen size. |
| D10 | Shader math is linear; color textures and the backbuffer use sRGB semantics. |
| D11 | Fixed timestep with accumulator and interpolation, explicit MaxFrameDelta and MaxSteps. |
| D12 | Logs are structured and channel-based; errors are typed (errs.Error); panic is reserved for violated programming invariants. |
| D13 | File-format versions are independent from the engine version. |
| D14 | Dependency direction is strictly bottom-up. |
| D15 | Stages close by acceptance criteria, never by duration. |
3.2 Stage 2 — Renderer
| ID | Decision |
|---|---|
| D16 | Projection matrices in mathx use GL clip space (z ∈ [-1,1]). Backend differences are neutralized through gpu.Capabilities.ClipZeroToOne and a fixup in render/view, never in mathx. |
| D17 | Offscreen targets render in native backend orientation. For GL the window viewport/scissor is flipped at the backend boundary; offscreen targets stay GL-native and render/view compensates Y. Render-target textures are exposed top-down with UV (0,0) top-left. |
| D18 | Texture color is premultiplied once at upload; shaders do not multiply alpha again. Standard blending is ONE / ONE_MINUS_SRC_ALPHA. |
| D19 | Transparent visual order is painter order layer → order → z → submission. Batching merges only adjacent compatible draws and never reorders by texture, sampler, blend or clip. |
| D20 | Sprites are drawn as an instanced unit quad generated from gl_VertexID; this is private to render/sprite. Sprite z exists only in sort metadata in the pure 2D path. |
| D21 | GL 3.3 buffer streaming uses orphaning; persistent mapping is not part of the baseline. |
| D22 | SamplerDesc is independent from Texture; the renderer caches samplers by descriptor. |
| D23 | TextureRegion is the drawable texture unit: whole texture, spritesheet frame, atlas cell or glyph. |
| D24 | The renderer is an ordered list of Pass objects (PassDesc{ID, Before, After}), topologically resolved and frozen when rendering starts. |
| D25 | Camera2D.Zoom means pixels per world unit. |
| D26 | Screen/UI coordinates are Y-down logical pixels. PushClip is expressed in logical target pixels and never rotates with the camera. |
| D27 | The built-in bitmap debug font is mounted from engine://; the editor font layer arrives in Stage 4. |
| D28 | Golden rendering tests use exact comparison for pixel-stable scenes and tolerance comparison otherwise; the Linux reference is Mesa llvmpipe with SDL offscreen video. |
3.3 Stage 3 — Scene, schema, assets, native extensions
| ID | Decision |
|---|---|
| D29 | The scene model is Node + Components. Transform, hierarchy, name, enabled state and visibility layers are built into the node; everything else is a component. Typed node names (Sprite2D, Camera2D) are editor creation templates, never serialized types. |
| D30 | Components use an independent sparse-set store per type. Aego does not use archetype ECS storage. |
| D31 | A node holds at most one component of a given type. |
| D32 | A *T from ComponentStore[T] is valid only within the current phase. Adds go to a pending chunked buffer and are visible through direct lookup immediately but not through iteration until flush; removes become invisible immediately and are compacted at flush. |
| D33 | Structural mutations are deferred to the phase flush. Destroy is logically immediate and idempotent; physical removal is deferred. |
| D34 | NodeID is 128-bit persistent identity; NodeHandle is runtime-only and never serialized. |
| D35 | Files store full type and field names; runtime uses hashes. FormerName / FormerField support renames. Hash collisions are Freeze() errors. |
| D36 | Source formats (project.aego, .aescene, .aeprefab, .aemat, .aeanim, .meta) are canonical aetext without comments. Export and cache artifacts are binary. Chunked containers exist only in .aepak and caches. |
| D37 | Unknown components are preserved as parsed semantic blocks (engine.Unknown) and survive load/save when the defining extension is missing. |
| D38 | Unresolved NodeRef / AssetRef keep their persistent IDs and produce diagnostics; they never silently become null. |
| D39 | Nodes store LocalEnabled; EffectiveEnabled is derived in the hierarchy pass. |
| D40 | Node names are unique among siblings, interned, and indexed by (parent, name) in the scene. |
| D41 | Visibility uses Node.VisibilityLayers and Camera.Mask. |
| D42 | Assets are split into runtime asset/loader and dev-only asset/pipeline. Scene depends only on asset.Resolver. |
| D43 | Sidecars are <file>.<ext>.meta; moving source and sidecar together preserves identity. |
| D44 | The import cache is content-addressed by source bytes, settings, importer version, pipeline version and dependency artifact hashes. |
| D45 | Asset hot reload is transactional: import → validate → GPU preparation → atomic swap. Failure keeps the previous resource. |
| D46 | aego/sdk is the only supported import for project-native Go; tools/depcheck enforces it for examples/**. |
| D47 | Native project builds use generated bootstrap code under .aego/generated; cache keys include engine/SDK/Go versions, target and native source state. |
| D48 | Built-in scene systems have stable IDs: engine.transform (flush, hierarchy, transforms, effective enabled), engine.sprite_extract, engine.mesh_extract. View construction belongs to RenderPrepare. Extract IDs superseded by D145–D146. |
| D49 | FieldDescriptor offset fast paths apply only to POD-like kinds; reference and GC-sensitive fields use typed accessors. |
| D50 | Scenes are assets; project.aego references the main scene by AssetID. |
| D51 | Engine services are typed registrations keyed by stable string IDs (engine.Service[T]), so upper layers attach Scene, Renderer, Loader and editor services without reverse imports. |
| D52 | The asset watcher is stdlib polling over (path, size, mtime) behind a Watcher interface. A same-size rewrite inside filesystem mtime granularity may be missed. |
| D53 | Group importers (atlas, script) finalize per-group artifacts; Produced.Kind is set by the importer, the pipeline is kind-agnostic. |
| D54 | Raw OS filesystem access is allowed only in vfs, platform, tools, cmd, project, asset/pipeline, app and app/* (including app/bootstrap), script/workspace, the editor packages that own files on disk (editor/core/{assetops,document,session,state}, editor/panels/{assets,scripteditor,welcome}, editor/shell/launcher) and demos/asset-hot-reload. Runtime packages use VFS so the same code runs over .aepak. The list is fsAllowed in tools/depcheck. |
| D55 | aego run is a supervisor. Native source changes trigger go build; compile errors keep the current process alive; successful builds use a restart protocol where the child serializes state and exits. |
| D56 | Builtin assets use reserved AssetIDs with Hi = 0xae90… (asset.BuiltinCube, BuiltinWhite, …), generated by providers in asset/builtin and resolved by the loader without a pipeline. |
| D57 | engine.World is a service container. Systems declare scope {Play, Editor, Frame}; Engine.RunPhase(world, phase, scope) runs a phase for one world. |
3.4 Stage 4 — Editor
| ID | Decision |
|---|---|
| D58 | Play mode clones the edit scene into a separate play world through sceneio. The edit scene is immutable during Play; Stop destroys the play world. |
| D59 | Editor UI fonts are pre-baked by tools/genfont (nested module, OFL font). render/text.Font is the common UTF-8 + kerning API; BitmapFont.Font() adapts the debug font; FontSet.Closest(px, scale) selects by DPI. Runtime TTF rasterization is not required. |
| D60 | One OS window; docking lives inside it. DockTree is serializable and does not know about native windows. |
| D61 | The editor is a UI over scene/schema/assets/render. Nothing below editor/ imports it; runtime and app/run never link it. |
| D62 | All editor mutations go through history commands; a drag gesture is one transaction; delete stores a serialized fragment preserving NodeIDs. |
| D63 | Document dirty state is derived from History position vs saved position. Scene.Revision exists only for cache invalidation. |
| D64 | Selection stores persistent NodeID, never NodeHandle. |
| D65 | The scene editor camera is editor state, not a scene node. |
| D66 | Scene View and Game View render into pooled render targets bucketed to 64 px. |
| D67 | Reparent preserves world transform by default; a non-invertible parent yields SceneBadReparent. Shear from non-uniform parent scale is not preserved. |
| D68 | Editor-only project Go code is compiled with the aego_editor build tag. |
| D69 | Local editor state lives in .aego/user, .aego/session, .aego/recovery and .aego/editor.lock; none is committed. |
| D70 | Input routing: modal → focused widget → hovered-panel shortcuts → global shortcuts → Scene View → Game View. Active widgets and gizmos own pointer capture. |
| D71 | Text entry uses SDL text input/editing events; text input starts on focus and stops on blur. |
| D72 | Editor extension callbacks are protected by recover; a panicking extension is disabled and reported. |
| D73 | Editor UI is not allocation-free. Idle budget ≤ 64 KiB/frame, target 60 FPS with a 10k-node Hierarchy. ui/draw.List has base/popup/modal/top streams. |
| D74 | aego edit with native/: the supervisor builds the editor from .aego/build/editor/main.go with -tags aego_editor, watches native/*.go, go.mod, go.sum, and exchanges build.status, restart, handoff.aetext + handoff.ready through .aego/session/. The editor exits with code 3 on restart; a new binary that crashes within 10 s is replaced by the previous one. |
| D75 | During Play the editor switches entirely to the play world: Docs.Active() returns the play view of the document (same ID/path, play scene, play history, play props). Save/Reload/recovery use the real documents. |
| D76 | Play-mode edits go to a separate history in ModePlay (never dirty, labels prefixed [Play]), destroyed on Stop. |
| D77 | The Inspector re-reads values every frame; a field being edited shows its cached value until released. |
| D78 | Inspector components are grouped by category with DisplayName and a module badge; order within a category is attachment order. |
| D79 | Extension creation templates live under Create → <module>; built-ins at the root. |
| D80 | Default layout: Hierarchy over Assets on the left, Scene/Game center, Console/Profiler bottom, Inspector right. window.reset_layout removes .aego/user/layout.aetext. |
| D81 | The top of the editor is a fixed-height MenuBar and Toolbar built from command.Registry; the document name and dirty marker live in the tab and window title; the native build indicator reads build.status. |
| D82 | Icons are an atlas in engine/res/icons generated by tools/genicons; ui.Icon(id) is the only way to draw one; missing IDs render a placeholder. |
3.5 Stage 4.5 — 3D foundation
| ID | Decision |
|---|---|
| D83 | Node.Space ∈ {Space2D, Space3D}. A new node inherits its parent's space; children of the root get Scene.DefaultSpace; CreateIn sets space explicitly; SetSpace is allowed only on a leaf without space-locked components. |
| D84 | Mixed trees: 2D under 3D is allowed (world4 = nearest3D.World4 × Mat4Lift(2D chain)); 3D under 2D is rejected (SceneSpaceMismatch). The transform pass is one pre-order traversal with three formulas. |
| D85 | 3D conventions: right-handed, Y up, camera looks down −Z. Lift maps 2D (x, y) to (x, −y, 0). QuatEuler(x, y, z) = Rz·Ry·Rx; Euler angles exist only in UI; aetext stores Quat as [x, y, z, w]. |
| D86 | gpu is dimension-agnostic. PassDesc.DepthTarget; FBOs are cached by (color, depth); rt.Pool.Acquire(w, h, depth) never mixes depth and non-depth targets. |
| D87 | 2D sprites with a 3D ancestor use a separate sprites3d pipeline (per-instance position/basis, depth test without write, back-to-front). The pure 2D path is unchanged. |
| D88 | Meshes are AEMS artifacts (header, counts, AABB3, vertices, indices); .obj import; builtin primitives Cube/Plane/Sphere/Cylinder per D56. Mesh instancing is deferred. |
| D89 | schema.Space(k) on TypeDescriptor: SpaceAny/Space2D/Space3D. AddComponent and the Inspector check compatibility. Camera is renamed Camera2D with FormerName("engine.Camera"). |
| D90 | Mat3.Inverse returns (Mat3, bool); silent identity fallbacks are removed. Extension gizmos work only in 2D until the 3D gizmo API is designed. |
3.6 Stage 5 — Aego Script Core
| ID | Decision |
|---|---|
| D91 | Aego Script is a statically typed, Go-like language compiled to register bytecode. Files are *.aes; a package is a directory under scripts/ (overlaid into res://); package path = project.Module + "/" + relative directory. Host modules and script packages share one namespace (conflict → AES4xxx); aego/ is reserved for stdlib; package name must equal its directory name. Module paths contain no dots. |
| D92 | No pointers, unsafe, goroutines, channels, defer, exceptions, closures, first-class functions, inheritance, interfaces, general generics, maps or operator overloading. Type arguments are parsed everywhere but resolved only for registered intrinsics (node.Get<T>(), Has<T>()). |
| D93 | int = int64, float = float32, bool, string (immutable, literals interned, len in bytes). Engine value types: Vec2 Vec3 Color Quat Rect Transform2D Transform3D, references NodeRef AssetRef<T> ComponentRef<T>. User types: struct (value), enum, []T (heap). Vector values are constructed with composite literals only. A short component name in Get<T> resolves if unique, otherwise AES2xxx with candidates. |
| D94 | No mutable package globals; only const. State lives in script instances and components. |
| D95 | script is a stateful attachable type. One ScriptComponent (engine.Script) per node holds attachments, at most one per script type. Attachments are stored as script "<FullName>" enabled=… { … } blocks keyed by full type name and property name, never by runtime index; authoring values are a schema.PropertyBag; unknown properties and types are preserved. |
| D96 | Script types form a dynamic layer inside schema.Registry: the native layer is frozen by Freeze(), SetDynamic atomically replaces only the dynamic layer and rejects name/hash collisions with the native layer. ByID/ByName/Field see both layers; Components() returns native types, Scripts() dynamic ones. scene.Types.Overlay routes Get/SetProperty for script types into ScriptComponent; scene never imports script. |
| D97 | vm.Value{A, B uint64} is exactly 16 bytes with no kind tag. int/bool/enum in A; float in the low 32 bits of A; Vec2 A=(x,y); Vec3 A=(x,y) B=(z,0); Color/Quat/Rect A=(x,y) B=(z,w); strings and slices are heap handles in A; NodeRef A=NodeHandle; AssetRef A,B=AssetID; ComponentRef A=NodeHandle, B=link-time component index. Unused bits are always zero (equality compares both words). Structs are N consecutive Values with a static layout (nested structs flattened); copies are MOVE. The heap holds only []T and string; GC is mark-sweep at safe points with no active frames; roots are instance fields and heap-object element masks. Instance slot 0 is node; script fields start at slot 1. Host methods of NodeRef/AssetRef are imported from pseudo-modules #NodeRef/#AssetRef with the receiver first; intrinsics are #NodeRef.Get<full component name> and Has<…>. Call windows are at the top of the function's register range. Cross-script access goes through vm.Scripts (Find(node, script) → ScriptRef value A=NodeHandle, B=InstanceID; Instance(ref, script) → self slice) and vm.Signals (Emit/Connect/Disconnect/Connected), both implemented by script/runtime. Bytecode v3 adds four tables resolved by name in vm.Link: ScriptRefs (script full names), XFields (ref, name, type, slots), XMethods (ref, name, arity), Connects (source, signal, target, handler); a stale layout or a lifecycle/coroutine target is a link error. SELFREF builds the ScriptRef of the running instance. |
| D98 | Native bindings resolve once at link; bytecode stores symbolic host imports with signatures; bind.Registry.ABIHash is part of the compile cache key. No reflection in the VM hot path; adapters are generated once from Go function types. Host signatures may not contain structs or slices. |
| D99 | Bytecode is verified before execution: bounds, jump targets, register counts, call windows, host import signatures, type references, field layout, name uniqueness, code length ≤ 32768. Corrupt artifacts are rejected. Runtime-only checks (slice element width, component field index, extern arity) are documented in bytecode/doc.go. |
| D100 | Faults are isolated per script instance (Running/Faulted/Disabled). Instruction budget per callback (default 1M, playground 2M), MaxCallDepth 256. Native binding panics become AES6002. float→int conversion is saturating and NaN-safe for cross-platform determinism. |
| D101 | Lifecycle v1: Ready, FixedUpdate(dt), Update(dt). Ready runs after all instances exist and references resolve, in scene pre-order. Update/FixedUpdate run in instance creation order. Systems script.fixed (PhasePrePhysics) and script.update (PhaseUpdate) are ScopePlay. Instances are created only from the runtime flush, never during a VM call. |
| D102 | Authoring state (@export in .aescene) and runtime state (instance fields) are separate; Play never writes to the edit scene. |
| D103 | Compilation is transactional per package: a failure keeps the last-good artifact and marks the source Invalid; Play is blocked while any package is invalid. |
| D104 | The compiler (source token lexer diag ast parser types resolver checker asm compiler workspace langsvc harness) and the runtime (abi bytecode verify bind vm runtime stdlib module) are separate subgraphs; exported runtimes link only the runtime side. script/abi is the shared leaf (Signature, TypeRef, Surface); the checker consumes abi.Surface. There is no IR: v1 compiles AST → bytecode directly; script/asm is an assembler. |
| D105 | Language version, bytecode version and binding ABI version are independent and all recorded in artifacts. |
| D106 | await select go chan defer interface map are reserved (AES1004). yield signal coroutine are keywords from language version 2; start is a contextual keyword before Name(; wait frames fixed are special only after yield. |
| D107 | Visibility follows the Go rule (uppercase = exported); @export is scene/Inspector exposure and unrelated to visibility. |
| D108 | @former on script types and fields mirrors FormerName/FormerField; unknown script properties are preserved and re-saved with a warning. Input axes are input.AxisConfig in platform/input; project parses the input {} block and app/run/app/edit pass it to stdlib. |
| D109 | The builtin texture engine/textures/white (AssetID ae90…0010) is resolved by the loader without a pipeline; prototype sprites use tint and size. |
| D110 | Script packages are imported as a single scripts group so the checker sees the whole project; the compile cache is per package via compiler.CacheKey; the group ID is deterministic so last-good artifacts survive restarts. The importer publishes a structured diagnostics snapshot with a revision that the editor shows in Console. |
3.7 Stage 6 — Script Development Loop
The Stage 6 specification numbered its decisions D109–D128; D109 and D110 were already taken by Stage 5, so they are recorded here as D111–D130.
| ID | Decision |
|---|---|
| D111 | script/workspace is the single project model for the language service, aego check/test/dump, the script importer and hot reload; no other compiler path exists. |
| D112 | Unsaved overlays take part only in Snapshot; Build and committed generations use saved files only. A FileID is stable per path for the lifetime of the workspace. |
| D113 | CLI native bindings come from a manifest (script/bind/manifest) written by a helper generated in .aego/generated/manifest, built with aego_nonative, that only calls Register; no engine, SDL or GPU is created. Cache: .aego/cache/script-bindings/<key>.aetext, key = bootstrap.CacheKey (native sources, go.mod/go.sum, local replaces, engine version). |
| D114 | Register is declarative: types, systems, bindings, importers, metadata. Resources, windows, network and scenes belong to Init. |
| D115 | aego check is strict: broken native code is exit 2, script errors exit 1. Only the editor keeps a last-good API (the previous build's binary) and shows "Native API is stale". |
| D116 | A committed workspace.Generation (ID, modules, build hashes) is the unit of hot reload; hosts convert it to a reload.Program, so modules/scripting and runtime binaries never depend on the compiler. |
| D117 | The swap runs in script.swap (PhaseFrameBegin, ScopePlay) through Runtime.Defer, when the VM has no active frames; play.Controller.Tick runs FrameBegin for the play world even while paused. |
| D118 | Apply is a transaction: BeginSwap → LoadModules → Migrate → Commit → CallReloaded. All modules of the new program load into the same vm.Machine; Commit calls SetDynamic first. Any failure aborts and the previous generation keeps running. |
| D119 | Instance fields migrate by name or @former, never by slot: same type kept, new field default, removed field dropped, incompatible type reset with a warning, enums by member name, structs field by field, slices element by element, references kept. |
| D120 | Instances keep InstanceID, node and heap objects across a swap. Faulted instances of changed scripts resume; instances of removed scripts stop. |
| D121 | func Reloaded() runs after the swap for every instance whose script or a dependency's interface changed, after signal rebinding; Ready does not run again. |
| D122 | Coroutines of changed scripts are cancelled by a reload; coroutines of unchanged scripts are untouched. A Coroutine field keeps the old, no longer running handle. |
| D123 | Signal connections are rebound by signal name, parameter types and handler name; a connection whose signal or handler is gone or changed its signature is removed. |
| D124 | A script is changed when its package build hash differs: sources, interface hashes of its transitive dependencies, ABI hash. |
| D125 | A save that does not compile never produces a generation; the package is marked invalid and Play cannot start while any package is invalid (D103). |
| D126 | Each reload prints one Console line Hot reload N → M: X migrated, Y reset, Z coroutines canceled, W handlers disconnected, T ms; the counters are the values the runtime acted on. |
| D127 | Changes in native/ are never swapped in-process; the supervisor rebuilds and restarts (D55, D74). |
| D128 | Language service requests carry a generation token (Invalidate); results of stale requests never reach the UI. |
| D129 | The built-in editor calls the language service directly; no LSP is introduced. |
| D130 | editor/core/code is a separate subsystem (piece table, line index, line-state lexer), not an extension of ui/textedit. |
| D131 | Coroutines: func Name(params) coroutine on script methods without a result; lifecycle methods cannot be coroutines (AES2056). start Name(args) runs to the first yield and returns a Coroutine. Forms: yield, yield wait(s), yield frames(n), yield fixed(n), yield c. |
| D132 | A coroutine is a VM frame suspended at YIELD with its registers kept in vm.Machine; Resume(h, self) continues it against the owning instance's current self slice, so arena moves during a reload cannot break it. Each resume has its own instruction budget. A coroutine is owned by an instance and cancelled when the instance stops, faults, is disabled or its node is destroyed. |
| D133 | script/coroutine.Scheduler keeps indexed min-heaps for frame counts, game time and fixed steps, plus await lists with a reverse index. A due coroutine is found without scanning sleeping ones; Cancel removes the entry at once. Due coroutines resume in start order in script.coroutines, after script.update; fixed waits resume after script.fixed. Paused Play does not advance game time. |
| D134 | Signals: signal Name(params) inside a script. Emit is allowed only in the declaring script (AES2055); handlers run synchronously in connection order. Connections added or removed during an Emit take effect after it returns. A nested emit counts towards call depth (AES6003). |
| D135 | Connect returns a generational Connection. When the source instance stops, all its connections are removed; when a handler's owner stops, its connections are skipped and removed. Connected() turns false in both cases. |
| D136 | ScriptRef<T> (node.GetScript<T>()) is a value A=NodeHandle, B=InstanceID, revalidated on every access (AES6004). It reaches fields, plain methods and signals of T; lifecycle methods and coroutines are not reachable (AES2059). |
| D137 | Bytecode v3 adds four tables resolved by name in vm.Link: ScriptRefs, XFields, XMethods, Connects. A stale field layout, or a lifecycle or coroutine target, is a link error. GC roots of struct fields are checked locally by the verifier and across modules in vm.Link. |
| D138 | CONNECT a bx uses the window R[a] result, R[a+1] source ScriptRef, R[a+2] target ScriptRef; SELFREF builds the running instance's ref; EMIT a bx passes the signal's slots starting at R[a]. |
| D139 | Cross-script access goes through vm.Scripts (Find, Instance) and vm.Signals (Emit, Connect, Disconnect, Connected), both implemented by script/runtime; the VM never sees scenes. |
| D140 | Language version 2, bytecode version 3. Artifacts of other versions are rejected at decode and reimported. |
| D141 | aego test, aego dump script and aego check run on script/workspace; script/build is removed. *_test.aes files compile only in test builds. aego test supports -run -v -budget -seed; a test that exceeds its budget fails with AES6001 and the next test runs. |
| D142 | Fault codes: a host binding that returns *vm.Fault keeps its code; Ctx.Fail and plain Go errors are AES6010. Nil or dead references (nil NodeRef in intrinsics, a ComponentRef to a destroyed node or removed component, a dead ScriptRef) are AES6004. Runtime codes live in script/diag next to the compiler codes; vm re-exports them. |
| D147 | Script autosave (File → Autosave Scripts, on by default): a dirty script is saved one second after the last edit, when the window loses focus and before Play starts. Read-only and conflicting documents are skipped. Recorded after Stage 6.5. |
| D148 | Project templates are embedded files under engine/res/templates/<id>/ (empty 2d 2.5d 3d) with the placeholders {{module}} {{template}} {{white}} {{cube}} {{plane}}. app/projects.Create is the only creation path: aego new, the launcher and Getting Started call it. Scene sidecars and mainScene are generated at creation; the project name comes from the directory. --example copies examples/<name> with the module renamed and replace aego pointing at the engine tree. A new project is verified with the same document and script checks as aego check. |
| D149 | aego edit without a project opens editor/launcher. Recent projects live in os.UserConfigDir()/aego/recent (aetext, 20 entries) and are updated whenever the editor opens a project. The launcher runs on its own engine and hands the chosen root back to aego edit in the same process. |
| D150 | view.Ortho3D is a 3D projection beside Perspective3D; code that branches on 3D asks View.Is3D(). Camera3D.Orthographic selects it with OrthoSize as half of the vertical extent. The 2.5d template is a 3D root with an orthographic camera and 2D sprites under it (D84, D87). The Scene View orbit camera has its own ortho toggle, stored per scene in editor state, with size = distance·tan(fov/2) so switching keeps the framing. |
| D151 | Inspector reference fields derive one state per value (None, alive, engine builtin, Missing, no T) through nodeState/assetState; widgets draw by state, never by label text. ids.*.String() stays the canonical hex used by serialization; Short() is the display form and returns nil for the zero ID. |
| D152 | sceneio keeps a table from file node IDs to created nodes. Values containing node references (fields, lists, structs, script properties) are applied after all nodes exist and are rewritten through that table, so prefabs and other fragments loaded without KeepIDs point at their own instances; references outside the fragment keep their ID and are reported as unresolved when they do not exist. |
| D153 | An exported ComponentRef<T> is a KindNodeRef property with FieldDescriptor.Component = T; it is serialized as node(…). At instantiation the runtime builds the reference from the node and the linked index of T; a node without T starts the field as nil with a warning. The Inspector shows a node without T as name · no T and filters the picker to nodes that have T. |
Build incrementality: a package key is its sources, the InterfaceHash of every package in its transitive dependency closure and the ABI hash. A generation never mixes a package with a new interface and a dependent compiled against the old one: a package that compiled is held back with AES2030 when a broken package with a committed module depends on it, or when it depends on that broken package, and the interface changed.
Hot reload (D116–D127): script/reload.Plan is built from two reload.Programs (package → module, package → build hash). Apply = Runtime.BeginSwap → LoadModules (all modules of the new program into the same vm.Machine, schema.CheckDynamic) → Migrate (new arenas, conversion by names via bytecode Structs/Enums) → Commit (SetDynamic first, then the instance, arena and schedule switch) → cancel coroutines of changed types → rebind signals → CallReloaded. Bytecode v2 added Module.Structs and ScriptDef.Reloaded.
Editor code (D129–D130): editor/core/code (piece table, line index, document with grouped history, search, line-local lexical highlight) has no UI and no script dependency other than script/token. editor/panels/scripteditor is the only editor package allowed to use script/langsvc and script/workspace. Panels are registered per document (script:<path>) and docked next to the Scene view; open and close are queued to the end of the frame, so the dock tree never changes while drawing.
3.8 Stage 6.5 — Module system
| ID | Decision |
|---|---|
| D143 | engine/module.Registry is the one registration path for engine subsystems and project native code. sdk.Registry is a struct over it; user modules keep Register(*sdk.Registry) error and are adapted with sdk.Adapt. engine never imports engine/module: Manifest.Engine() adapts collected systems and wrapped engine.Modules into one engine.Module. Init runs once per engine after assets mount. Attach runs per scene: once for the main scene of aego run, once per open scene document in the editor, once per Play world; detach runs in reverse order. Acceptance form: a component is added with one file in the module plus one line in its Register, with no edits outside the module. app.Build is the single construction path for aego run and the editor. |
| D144 | Everything about a component is declared through ComponentBuilder[T] and stored in schema.TypeDescriptor: display name, category, icon, owner, builtin flag, default, required components, scene uniqueness, creation template, script fields and methods, custom inspector. Types the descriptor cannot name without a cycle are held as any and checked by their consumer (editor/core/templates, editor/ext/inspect, modules/scripting), with the owning module in the error. ScriptFields == nil exposes every FlagScript field. Template("", setup) names the template after the type. Component script methods are typed adapters (Method0..2, Proc0..2) called on the component reference (D196). ScriptMethod(any) is a development-only reflection fallback: it logs a warning and fails Build under aego_runtime. Ref fields whose type is a single ID at offset 0 are read and written by offset; other layouts go through Get/Set. At most 255 script fields per component. |
| D145 | A subsystem is a package tree with Module(): render2d, render3d, script/engine now; physics, audio, anim, particles follow the same shape. scene, scene/frame, scene/scriptcomp, schema, render, engine are core. Subsystems never import each other; shared render buckets live in scene/frame. Dev-only registration (importers) is a dev hook compiled under !aego_runtime; editor parts of built-in modules live in <module>/editor and are attached by editor/builtins, project editor code uses sdk.EditorHook under aego_editor. Built-in modules hide the Inspector badge. Package paths and the editor/builtins attachment are superseded by D201 and D205. |
| D146 | engine/builtin.Modules() lists built-in modules in dependency order; Build resolves Info.Requires topologically (ties keep Add order) and fails on missing or cyclic requirements, system ids without the module prefix, reserved phases for non-builtin modules, unknown icons, unknown required components, duplicate template ids and bad template setups. Create-menu order is module order, then registration order. Module icons are PNG files <id>@<size>x<scale>.png; built-in SVG sources are listed by builtin.IconSources() for tools/genicons. module.Audit runs the same registration and icon checks without failing on icon problems and lists every difference (missing, unused, badly named, empty source); aego icons prints it and exits 1 on any entry, building a helper under aego_nonative for native projects. engine/builtin is superseded by the app/assembly catalog (D205). |
3.9 Stage 8A — Physics core
The Stage 8 specification numbered these decisions D153, D155, D158, D159, D168–D173 and D179; D153 was already taken by Stage 6, so the whole Stage 8 range is shifted by one.
| ID | Decision |
|---|---|
| D154 | Host structs: bind.Struct[T] registers a Go struct whose fields are bools, integers, floats, Vec2/Vec3/Color/Rect/Quat, NodeHandle, asset.Ref[*], registered enums or other host structs; strings and slices are rejected so a host struct never holds VM heap values. At most 32 value slots. abi.Table keeps StructDefs, its hash covers every field, and the manifest (version 3) stores them. The compiler copies every host struct a module touches into bytecode.Module.Structs, so the verifier, call windows (Module.Window) and hot-reload migration use one layout source. Structs cross the host boundary by value through typed field encoders, zero allocations per call. Bytecode version 4. D98 is amended: host signatures may contain host structs and []T of plain elements as parameters. |
| D156 | bind.Out[T] is the only slice a host function sees: it writes into the existing elements of a script []T, Cap() is the slice length, a nil slice has Cap() == 0. The host returns how many elements it wrote. Scripts size the buffer with append once. |
| D159 | Engine script events: a module declares module.Registry.ScriptEvent(Event) in Register and gets a stable EventID. script/engine adds them to the bindings in a finalizer before the registry freezes, so aego check sees them. A script method with an event name is an event handler: exact signature (AES2060), never called directly (AES2061), never a coroutine (AES2056), unreachable through ScriptRef and Connect. Event names are exported, unique and never lifecycle names; parameters are plain values. ScriptDef.Events maps names to functions. |
| D160 | contracts/* are interface packages between subsystems and scripts: contracts/scriptevents (Sink, EventID, service script.events attached per world) and contracts/physics2d (Queries, QueryFilter, RayHit over nodes). Contracts import only core, scene and the script value layer; nothing in contracts imports a subsystem. |
| D169 | physics/world depends only on core. It uses its own handles, UserData uint64 for owners, float32 world units and validated definitions; every Create/Add/Set/Apply returns an error instead of accepting NaN, infinities or degenerate geometry. Removed bodies and shapes keep their slots for one step so event handles stay meaningful. |
| D170 | Broadphase: a dynamic AABB tree with fat boxes (margin 2·ContactOffset), bullet boxes swept along their velocity, a move buffer, candidate pairs sorted by (shape, child) and an open-addressing pair table. No Go map is iterated during a step. Pairs pass when both bodies are not static/kinematic-only, layers and masks accept each other and no joint forbids them; static-static and trigger-trigger never pair. |
| D171 | Narrowphase follows Box2D v3: every convex shape is a polygon with a radius (circle 1 vertex, capsule and chain segment 2), SAT plus reference-face clipping with byte feature ids, manifold normal from A to B. Parallel reference and incident edges that overlap by more than the slop are always clipped, never reduced to a vertex contact. Chains are always shape A; ghost vertices drop or snap vertex contacts at flat and concave joints; one-sided chains ignore shapes behind them. Manifolds persist per pair and warm-start by feature id. |
| D172 | Solver: one collision pass per step, then Substeps solver substeps (Box2D v3 soft step, 4 by default). Each substep integrates velocities, warm starts, runs VelocityIterations soft passes (contact spring of min(ContactHertz, substep rate/4) Hz with ContactDampingRatio, twice as stiff against static bodies, push-out capped at ContactPushVelocity), integrates positions with the current separation rebuilt from body deltas and contact anchors, runs RelaxIterations relax passes without bias and one joint position pass. Restitution is a separate pass after the substeps. Defaults are Box2D v3's 30 Hz / 4 substeps / 1 velocity / 1 relax pass: at the cap itself (60 Hz at a 60 Hz step) one pass per substep cannot damp the spring and large stacks never come to rest. The stiff profile 60 Hz / 8 substeps holds tall free columns at about 1.8× the step cost. Substeps and both iteration counts are 1..64; zero takes the default, as for every Config field, so a relax pass can never be switched off. Islands are found by depth-first search from a sorted list of awake bodies; narrowphase visits only contacts of awake bodies; a sleeping world costs no per-body work. Islands sleep after SleepTime below SleepLinear/SleepAngular. |
| D173 | Contact events are counted per shape pair and buffered per step: End during collision and removal, Begin after the solver so it carries the first impulse. Event normals point from B to A. Removal between steps reports End in the next step. There is no stay event; callers ask Contacts. Event NormalImpulse is the impulse summed over the substeps of the step. |
| D174 | Joints: DistanceJoint (rigid when Min == Max, soft spring with Stiffness/Damping, optional limits) and RevoluteJoint (limit, motor with MaxTorque), Box2D v2.4 formulation. Destroying a body destroys its joints. |
| D180 | Determinism profile: one binary, one CPU, the same call order give bit-identical Snapshot() bytes. Snapshot/Restore copy pools with generations and free lists, the tree, the pair tables, manifolds with impulses, joints and sleep timers. |
3.10 Stage 8B — Physics integration
| ID | Decision |
|---|---|
| D181 | physics is a built-in module without requirements. It creates a systems.Sim only for worlds attached with AttachContext.Play (aego run, editor Play); edit worlds get no simulation, the editor checks geometry with systems.Checker. A play world carries the services physics.sim and physics2d.queries. Settings reach the module through the engine service physics.settings and apply at the next Play. |
| D182 | Physics works in world units and never reads render.pixelsPerUnit; that value only sets the default size of imported sprites, the default Camera2D.Zoom and Snap to Sprite. |
| D183 | A collider belongs to the nearest node at or above it with Body2D; without one it gets an implicit static body on its own node. Its pose relative to the body (translation, rotation, uniform scale; non-uniform scale for boxes and polygons) is baked into the shape at sync. Non-uniform scale on circles and capsules is PHY001, nodes under a 3D parent are excluded (PHY005), reparenting a moving body recreates it with world pose and velocity (PHY006). |
| D184 | Presentation pose: scene keeps a sparse presentation local per node (SetPresentationLocal, PresentationLocal, ClearPresentation). UpdateTransforms computes RenderWorld in the same pass; nodes without a presented ancestor share World without a copy. Render extract reads RenderWorld, everything else reads World. physics.present runs in PhaseRenderPrepare before engine.transform and interpolates with Context.Alpha. |
| D185 | Schedule: physics.sync, physics.step, physics.writeback, physics.events in PhasePhysics, so the events reach the sink before script.events_fixed in PhasePostPhysics without a cross-module ordering reference; physics.present per D184. Writeback remembers the local it wrote; sync treats any other value of a moving body's Transform2D as a teleport that keeps the velocity. A difference first seen by present was written in Update: PHY007 once per body, applied at the next step. Static and kinematic bodies follow their node every step. |
| D186 | Layers: project.aego physics { layers=[…] collide { A=[…] } }, at most 32 names. The matrix is symmetric, a missing collide means all pairs, an unknown layer is a load error. Body2D.Layer stores the index, Mask the accepted layers; a pair collides when both masks and the matrix allow it. |
| D187 | Destroy, disable, component changes, reparent, hot reload, native rebuild and Stop follow the lifecycle table in the physics documentation (physics/lifecycle). Exit events are generated for everything except Stop. |
| D188 | Script events OnCollisionEnter/Stay/Exit(other NodeRef, c physics.Contact) and OnTriggerEnter/Exit(other NodeRef) reach both participants, on the body node and on the collider node (once when they are the same). Contact.Normal points from the other body into the receiver. Stay is emitted only when scriptevents.Listener reports a running handler on either side. Removals inside handlers are applied after dispatch. |
| D189 | Script API: Body2D methods (Apply*, Set*Velocity, MoveTo, Teleport, WakeUp, Sleep, Mass) and aego/physics functions. Queries ignore the caller's body and skip triggers; filtering by layer goes through Linecast's mask and Layer/LayerMask. Scripts have no QueryFilter; Go keeps it in contracts/physics2d. Runtime body state is read through the read-only fields Velocity, AngularVelocity, Awake. |
| D190 | Project Settings is the panel editor/panels/projectsettings with built-in pages (Project, Render, Input) and pages from extension.SettingsPage. It writes project.aego after confirmation, outside History. The physics page validates through world.Config.Validate; zero means default and shows the default greyed out. |
| D191 | Overlays are read-only renderers on shape.Registry. They are scene-only unless registered with extension.GameOverlay. OverlayCtx carries Scene, World and Game, so no overlay looks worlds up through documents. The Game View draws game overlays after the game's own views, in one ViewFrame per active 2D play camera with that camera's view, never inside the game's frame; when its toggle is off no overlay is called. |
| D192 | Vectors overlay: layers from extension.Vectors; Fields from schema gizmo tags (vector, point, radius; local or world) and @export(gizmo=…); drags go through History. Force recording (world.Config.DebugVectors) is on only while the Forces layer is visible. Off means zero calls and zero allocations. |
| D193 | Profiler: Frame is always shown; Game and Scripts only while Play runs; extension sections declare whether they are play-only. Sections reach the play world through Editor.PlayWorld(), never through the active document. |
| D194 | Physics diagnostics PHY001–PHY007 appear in Problems under the physics category with node locations; PHY002 (invalid geometry) blocks Play. |
| D195 | examples/platformer runs entirely on physics; examples/physics-sandbox is the integration test of the module; demos/physics2d stays on the bare world. |
| D196 | Component script methods are called on the component reference: body := node.Get<Body2D>(); body.SetVelocity(v). They live in the pseudo-module #<FullName> with ComponentRef<T> as the receiver, are kept in abi.Table as component methods (AddComponentMethod, part of the ABI hash) and in the bindings manifest as cmethod blocks (manifest version 4). They are not a module and cannot be imported. The checker resolves ComponentRef<T>.Name(...) through Surface.ComponentMethod; the compiler emits CALLH with the reference as the first argument; the typed adapter faults with AES6004 on a nil reference, a destroyed node or a removed component. Stubs, hover, go-to-definition and parameter hints list the methods inside the component. D144 is amended accordingly. |
| D197 | A field initializer is a constant when it is a constant scalar or a composite literal of Vec2, Vec3, Color, Quat or Rect whose components are numeric constants. It folds into types.CLanes and is stored as bytecode.LanesK (lanes 0–1 in A, 2–3 in B); @export publishes it as the schema default, so the Inspector shows it and an omitted scene property starts with it. Any other initializer is AES3006. |
| D198 | Loading a component starts from its registered Default() and overlays the fields present in the file; AddComponent does the same. A field missing from a hand-written scene therefore never falls back to the Go zero value. Script properties already fall back to the script initializer. |
| D199 | ComponentBuilder.OnAdd(func(AddContext, *T)) runs when the editor adds the component through the Inspector, Paste or Create, never on load or at runtime. AddContext.Visual reports the node's visual rectangle in node space from the editor shape registry, so a module can fit itself to a sprite without importing render2d. The first Do of the add command runs the hook and records every field, so undo and redo replay the same values. Box, circle and capsule colliders fit the sprite; without one they keep their defaults. |
| D200 | Performance checks compare with a baseline of the machine they run on (make bench-baseline, files under .cache/bench, ignored by git); the gate itself is D213. tests/bench/baseline.json holds reference numbers of the reference machine and is not a pass criterion. Wall-clock limits under -race are scaled by 4. Dev-only packages such as engine/docs are excluded from aego_runtime builds by build tag. |
3.11 Stage 8C — Module layout
| ID | Decision |
|---|---|
| D201 | Built-in modules live in modules/<name> (render2d, render3d, scripting, physics) with their editor side in modules/<name>/editor. Core packages never import modules/...; sdk is a composition facade above the modules and may import them, modules never import sdk. render/host (former render/module) is installed by the host before the modules and is not a catalog entry. engine/base holds the core module engine and NewTypes. |
| D202 | The editor is split by role: editor/core (state, documents, history, shared helpers such as assetops and fuzzy), editor/ext (extension points; ext/inspect for custom inspectors), editor/panels/<name> (panels never import each other), editor/shell (application, dock, menus, launcher). |
| D203 | The script library is split by imports. script/stdlib keeps math, log, rand, time, testing; they reach the engine only through the bind.Ctx fields (Random, Log, Dt, Fixed, Elapsed, Frame) that script/runtime fills per call, which is the runtime contract. aego/scene and aego/input import the scene and the engine and live in modules/scripting/stdlib, whose Install keeps the historic registration order. |
| D204 | Modules depend on each other only through Info.Requires; ordering constraints never name systems of another module. physics.events runs in PhasePhysics after physics.writeback. physics and scripting each build an engine with the core module alone (TestBuildWithoutScripting, TestBuildWithoutPhysics). |
| D205 | The built-in module catalog lives in app/assembly. An Entry is {ID, Requires, New(Options), Editor}; Requires must equal the module's Info().Requires. Options{Seed, Testing, Input} are constructor parameters of entries, so stdlib.Options no longer leaks into the catalog API. Profiles are packages, not files, because the playground graph must not see render3d or physics: app/assembly/builtin (Default, Headless), app/assembly/playground. Editor hooks of built-in modules are attached by app/assembly/editors.With, imported only by app/edit, so app/run never links the editor; the hook is registered through module.Registry.Editor inside the module's Register. Order is topological by Requires, ties keep catalog order; a cycle error names the chain. Systems registered by nested engine modules (Registry.Use) must carry the owning module's prefix. engine/builtin, engine/builtin/playground, editor/builtins and app/modules.go are removed. |
| D206 | Module lifecycle is Register → Freeze → Init → AttachWorld → DetachWorld → Shutdown. Shutdown runs in reverse Init order; a failing Init shuts down the modules initialized before it. Manifest.Attach calls DetachWorld on every module whose AttachWorld was called, including the failing one, so DetachWorld must tolerate a partial attach. Per-world module state lives only in world services. |
| D207 | Project settings sections: module.Registry.ProjectSection(SectionDesc[T]) with Decode, Encode, Validate, Migrate(from, to), Version and Former heads. project keeps every block except window and input as AST; after Build, Manifest.LoadSections decodes registered sections (defaults, migration on a clone, validation) and leaves unknown blocks untouched. A section whose type is registered with module.Struct gets a schema codec and an automatic Project Settings page. InitContext.Section(id) gives the live value; editors work on drafts (Sections.Draft) and commit with Sections.Set after project.aego is written. physics {} and render2d {} (former render {}) are sections; project.PhysicsSettings, RenderSettings and modules/physics/dev are removed. |
| D208 | Inspector hints live in the schema: ChoicesFrom(field, section, list) and Bitset(field, section, list) draw an integer field as a choice or a mask over a named list that the project section returns from SectionDesc.Names; a mask with every listed bit set is stored as -1. Runtime(fields...) shows a field only during Play and makes it read-only. The Inspector orders component groups by module order from the manifest and never switches on module or component names. |
| D209 | Component hooks: OnAdd, OnRemoving (old value, before the store frees it, from the editor remove and undo-of-add paths), OnChanged(*ChangeSet) after every committed transaction, Undo and Redo (fields deduplicated, add and remove in one batch cancel), and Validate(ctx) []problems.Diagnostic. Hooks make no structural changes outside pending. Runtime keeps the D185 comparison; there is no MarkDirty. |
| D210 | engine/problems is the diagnostics service: Diagnostic{Owner, Code, Severity, Message, Subject(Node, Field, Asset, Setting, File), BlocksPlay} with a stable key; sources carry a revision and the service rebuilds only on change. The editor validates open edit scenes through module.ValidateScene; modules publish runtime diagnostics from AttachWorld. The Problems panel only displays them with the owner as category; the Play controller asks the service for blocking problems. |
| D211 | Resources and SDK: module icons are named <owner>.* or cat.<owner> and a duplicate ID is a build error; icon sources come from IconDir() of the catalog modules. Built-in editor hooks come from the catalog (app/assembly/editors), project hooks from module.Registry.Editor under aego_editor; vector layer commands are created when the layer is registered; module.mk files add module targets to the Makefile. The SDK exposes project sections, diagnostics and the hook contexts; a component changed after the schema freezes reports EngineFrozen; module IDs are dotted lowercase and engine is reserved. 8C.4 is one commit instead of one per item: the items share files (modules/physics/module.go carries items 2, 3, 6 and 7) and the intermediate trees do not build; Appendix A maps every backlog row to the substage and files. |
| D212 | tools/depcheck/exceptions.txt is the only place where a dependency or literal rule is waived: from -> to, variant, reason, one edge per line, file-level from allowed (sdk/editor.go). An empty reason, an unknown variant or an exception that matches nothing fails depcheck, so the file only shrinks. Entries that 8C.4 removes say so. |
| D213 | Benchmarks. CI (make bench-allocs, part of make ci) checks allocations only: allocs/op and allocations per frame must equal the saved baseline of the machine, with count=1, since allocation counts do not depend on the CPU clock; modules add their checks through MODULE_BENCH_ALLOCS in modules/*/module.mk. Allocations do not depend on the renderer: without a baseline of the same `demo |
| D214 | Fake module proof (8C.5): tests/modulecheck/fakephys is a second module that uses every boundary-bearing extension point: project section with names, inspector hints (ChoicesFrom, Bitset, Runtime), system, template, script event, component hooks (OnAdd, OnRemoving, OnChanged, Validate), world attach, and in the editor a settings page, panel, custom inspector, gizmo, bounds, scene and game overlays, a vector layer, a profiler section and a command. Icons, docs and asset drops are resources registered by owner and are covered by engine/module tests. The editor runs with the catalog "built-in minus physics plus fakephys" (assembly.Catalog.Without, edit.Config.Catalog), and no file outside tests/modulecheck names it. The editor frame on physics-sandbox (Edit, Play, overlays and vectors on) is a permanent bench-compare scenario, added by modules/physics/module.mk through MODULE_BENCH_COMPARE, not part of bench-allocs. |
| D215 | Profiler 2.0 (8C.6): core/trace records hierarchical zones of the main thread into a ring of preallocated frames (default 240 frames × 1024 events, allocated on first enable, zero allocations per frame, nesting up to 32, overflow counted as Dropped). The engine opens zones for phases, systems (including play worlds via RunPhase), polling and swap; the renderer for passes; the workspace for panels; modules get them for free and open sub-zones through sdk.TracerOf. GC cycles per frame come from runtime/metrics. The Profiler panel shows a frame strip with the 16.6 ms budget and a flame graph of the selected frame; Export writes Chrome trace JSON to .aego/user/traces (editor, tracing always on) or aego run --trace file (runtime, the last 239 frames on exit). Go pprof stays separate (--cpuprofile). |
| D216 | Narrow editor services (8C.7b). A panel implements workspace.Panel: Draw(f *services.Frame, r mathx.Rect); Frame carries UI, DnD, Now and Focused. Code under editor/panels/* never holds or receives *context.Editor; tests/modulecheck/panels_test.go enforces this. Services live in editor/core/services: the interfaces Notices, Navigation, Problems, Selection, Documents, SceneOps, AssetOps and the dependency struct Viewport. *context.Editor implements the interfaces and stays the shell's composition root; it is not passed to panels. Every panel takes a Deps struct in its constructor that lists exactly the services it uses; the shell builds these structs, and there is no service locator. Navigation is one call, Open(Location): Location.Target (Docs, Problems, Settings, Asset) and Location.Symbol replace direct calls to ShowDocs, ShowProblems, ShowSettings, RevealPath and OpenScript, which remain on Editor only as shell wiring. editor/core/sceneops.Ops and editor/core/assetfiles.Files are facades: core types bound to Editor that expose operations without ctx. This is an accepted form of a service; narrowing editor/core/assetops itself to a non-Editor environment is out of scope for Stage 8. clipboard.Env narrows component and value clipboard operations to Clip, Types, Res and OnAdd; the ctx wrappers remain for the shell and tests. Module extension callbacks (custom inspectors, settings pages, profiler sections, gizmos, overlays, vector layers) keep their *context.Editor API; editor/ext binds Editor at registration (inspect.Registry.SetEditor, closures in ext), so panels see only bound functions. workspace.Panel has a single Draw(f *services.Frame, r); the transitional LegacyPanel path was removed at the end of 8C.7b. Extension panels capture what they need at registration through EditorRegistry.Editor(). |
| D217 | Panel descriptors (8C.7c). A panel is registered with workspace.Desc: Panel, Slot (column of the default layout: Left, Browser, Center, Bottom, Right, or None), Near (the panel it opens next to when it has no slot), Order (default-layout order inside a slot and Window menu order) and Hidden (not in the Window menu; script file panels). The default layout, the Window menu and Workspace.Show all derive from the registered descriptors; ShowNear is gone. The built-in catalog is the explicit list editor/shell/panels.go; extensions register descriptors through EditorRegistry.Panel (sdk.PanelDesc), ordered after the built-ins unless they set Order. The descriptor holds the panel instance, not a factory: panels are built by the shell with their Deps (D216), which already state the services they use. Panel IDs stay in editor/core/workspace as the shared vocabulary of Near and Show; moving them into panel packages would make panels import panels, which depcheck forbids (panel-imports-panel, D212). |
3.12 Stage 8D — Prefabs
| ID | Decision |
|---|---|
| D218 | scene/prefab (model, Expand, Diff, Normalize inputs) is kernel; scene/prefab/author (Normalize, Rebase, conflicts) is dev-only and outside the runtime graph. Diff stays in the kernel because sceneio.Save recomputes overrides. Stage 13 exports expanded scenes into .aepak and removes both from the runtime graph. |
| D219 | Identity inside an instance is a Key: the chain of local ids of nested instance nodes plus the node's own local id (a/b/c). The root of a nested instance takes the key of the instance node. Key.ID() is the local id for one segment and a sha256-derived id otherwise; node references inside the model are stored in key-id space. The instance keeps a table Key → SceneNodeID; missing entries get fresh ids and are written on the next save. |
| D220 | Overrides are normalized: properties by key and Owner.Field (node.*, transform.*, component full name, script/<full>.Field, #enabled), added and removed components, added nodes as model subtrees, removed nodes, child order. The transform of the instance root is always an override and is never applied to the prefab. Values compare by kind; float32 compares as float32 and colors by their 8-bit sRGB form, so a round trip never creates an override. Entries whose target is gone are orphans for properties, added components and added nodes: kept, flagged orphan=true, never applied. Entries for unknown types are kept as written. |
| D221 | Instance format: node "<scene id>" prefab=asset(…) { id "<key>" node=node(…) {} · override "<key>" prop="Owner.Field" value=… {} · addComponent "<key>" { component … | script … } · removeComponent "<key>" owner="…" {} · addNode "<parent key>" { node … } · remove "<key>" {} · order "<key>" children=[…] {} }. A variant is an .aeprefab whose root is an instance. Prefab documents are written with the head prefab (SavePrefab); the importer also accepts scene heads from 7.0 files. There is nothing else to migrate from 7.0: no earlier code path wrote linked instances. |
| D222 | The hidden component engine.PrefabInstance links scene nodes to prefabs: the owner of the overrides (root of an authoring instance: keys, orphans, raw entries, original block), nested roots and runtime copies (instanceOf=asset(…) in files). A missing prefab or a cycle loads an empty placeholder that saves its block unchanged and reports PFB001; unused overrides report PFB002. |
| D223 | The runtime template is the expanded prefab written as a flat fragment with key ids, compiled on first use and cached in the loader through asset.Decoder; the cache is cleared on Swap, Drop and Mount. scene.Instantiate loads it with fresh ids, and internal references follow the copy (D152). Scripts see IsPrefabInstance and PrefabRoot only. The template becomes an .aepak artifact at Stage 13. |
| D224 | editor/core/prefabs is the prefab service. Apply bakes the selected overrides into the prefab model: a key inside a nested instance goes either into the nested prefab file (nearest) or into the outer prefab as an override of that nested instance (root), with references re-keyed. A change to prefab files is one transaction: write with temp+rename, synchronous reimport, rebuild every affected outermost instance in every open document from its current overrides; the source instance uses before/after snapshots. One shared command sits in the history of each affected document, the first Do/Undo does the work and the others are no-ops, so undo from any of them restores the files and all documents. A failure restores files and rebuilt instances. Saving a prefab document is the same transaction. |
| D225 | Inside an instance, nodes owned by the prefab keep their parent; order can change. Revert, Unpack and Create Prefab are single-document Replace commands over the instance subtree. Unpack promotes direct nested instances to owners; Unpack Completely flattens everything. |
| D226 | Known limits: applying to the nearest prefab a node that was added by overrides inside a nested instance is refused; unpacking a variant drops its link to the base; undo of an Apply whose source instance lies inside the added part of another affected instance rebuilds the outer one from its current overrides. |
| D227 | Prefab mode: a prefab document opened from a scene context (Open Prefab, Inspector Open, a nested prefab) records its origin; one opened from Assets records only the previously active document for Back. Breadcrumbs follow the origin chain. Play in prefab mode runs the scene at the start of the chain and returns to the prefab afterwards; without an origin Play is unavailable with a hint. Back (button, Esc in a focused Scene View with nothing selected) closes a clean prefab and keeps a dirty one open; while a dirty prefab points back to the active scene, the crumb bar stays visible there with the dirty dot. Closing a document re-points origin and back links to its own target. |
4. Dependency rules
The import graph is acyclic. The rules below are enforced by tools/depcheck.
4.1 Groups
Every package belongs to one group by its path prefix (tools/depcheck/groups.go); a new package is covered by the rules of its group without a new entry. A path with no group prefix is a violation.
| Group | Prefixes |
|---|---|
| low | core platform gpu format vfs |
| kernel | schema scene render asset engine script ui project contracts |
| module X | modules/X/... except modules/X/editor/... |
| module editor X | modules/X/editor/... |
| editor | editor/core/..., editor/ext/..., editor/panels/<P>/... (panel P), editor/shell/... |
| sdk, app, cmd | sdk, app/..., cmd/... |
| dev-only | asset/pipeline, asset/importers/..., engine/docs, project, the script compiler (script/{token,lexer,ast,parser,types,resolver,checker,asm,compiler,harness,workspace,langsvc}), app/..., cmd/..., tools/..., demos/..., tests/..., examples/..., modules/*/editor/..., modules/*/dev/... |
4.2 Group rules
- low, kernel ✗ modules, editor,
sdk,app. This includescontracts/.... - module X ✗ module Y and module editor Y (X ≠ Y); module X ✗ module editor X; module editor X → module X is allowed.
- module X ✗ editor; module editor X →
editor/core,editor/extonly; modules ✗sdk,app. editor/panels/P✗editor/panels/Q(P ≠ Q), ✗editor/shell;editor/ext✗ panels, shell;editor/core✗ panels,editor/ext, shell.- editor ✗ modules, except
editor/shell(composition). sdk→ modules is allowed (composition facade, D201);sdk✗ module editors;sdk✗ editor except the filesdk/editor.go.- Runtime graph (
aego_runtime, every package minus dev-only): no package reaches a dev-only package, transitively. scene/prefab/authoris dev-only; the runtime graph keepsscene/prefabuntil Stage 13 (D218).- Playground graph (
js/wasm,cmd/aego-playground): only low and kernel packages withoutgpu/gl33,platform/sdl,asset/pipeline,asset/importers, plusmodules/render2d,modules/scripting/...,app/assembly,app/assembly/playground,app/playground. - Literals: kernel, editor (
core,ext,panels),sdkandprojectcontain no string literal that names a built-in module: its ID,<ID>.*, a component or script module it owns, or a diagnostic code prefix it declares (PHY001). The names come from the module catalog. - Test imports (
TestImports,XTestImports) follow the same group rules as a separate graph per build variant.
Exceptions live only in tools/depcheck/exceptions.txt (D212).
4.3 Layer rules
Inside low and kernel the fine-grained rules still hold:
core/...uses only the standard library.platform/inputis a leaf input model;platformimports it, never the reverse.platform.ApplyInputconverts events to state.render/...never importsplatform,engine,ui,sceneoreditor;render/hostis the only engine-integration package.ui/...may importrender,platform/inputandformat/aetext, not the platform backend orengine.schema/...depends only oncore; references are recognized through schema-facing contracts.scene/...never importseditor,asset/pipelineorscript; it resolves assets only throughasset.Resolver.scene/sceneioowns source serialization;scenedoes not import it.asset/loaderis runtime-safe and never importsasset/pipeline.script/...may useschema,sceneand engine services, nevereditor. The editor reaches the script toolchain only fromeditor/panels/scripteditor(script/{langsvc,workspace,source,diag}, D129) andeditor/core/code(script/token); everything else works throughcontext.ScriptHostimplemented inapp/edit.- User
native/code imports onlyaego/sdk. core/statsis a leaf; renderer counters stay inrender.Stats.- cgo and raw OS access are restricted to approved packages (D54;
import "C"only inplatform/sdlandgpu/gl33/gl). engine/moduleimportsengine,schema,scene,script/bind, nevereditor,appor dev-only packages;scene/...andschemanever importengine/module.appis development-only: it ownsBuild, which wires types, bindings, modules, importers, pipeline and loader foraego runand the editor.modules/physics/worldimports onlycore/...;modules/physics/componentsonlycore,schema,scene;modules/physics/systemsaddsworld,components,contracts,engine;modules/physics/stdlibaddssystemsandscript/{abi,bind,vm},engine/module.contracts/...importcore,scene,script/abi,script/vm.
4.4 Script subgraphs
script/{abi,diag,source,token} → core, schema
script/{lexer,ast,parser,types,resolver,checker,asm,compiler,harness}
→ core, schema, format/aetext, script dev-layer (dev-only)
script/{bytecode,verify,vm} → core, schema, script/{abi,diag,source}
script/bind → script/{abi,vm}, scene, engine, asset, platform/input, core
script/stdlib → script/bind, core, platform/input (math, log, rand, time, testing)
script/runtime → script/{abi,bytecode,verify,bind,vm}, scene, asset, engine, platform/input
modules/scripting/systems → script/{bind,runtime,stdlib}, modules/scripting/stdlib, engine, scene
modules/scripting/stdlib → script/{bind,stdlib}, scene, engine, asset, platform/input
asset/importers/script → script dev-layer, asset, asset/pipeline
script/{bytecode,verify,vm,bind,stdlib,runtime}, modules/scripting/... ✗ script/{lexer,parser,checker,asm,compiler,harness,workspace,langsvc}
Stage 6:
script/bind/manifest→ the runtime layer ofscript/*,format/aetext;app/assembly/builtin→script/bind/manifest.script/workspace→ the dev layer ofscript/*,script/bind/manifest;script/langsvc→script/workspace.asset/importers/script→script/workspace(no compilation of its own).script/{bytecode,verify,vm,bind,stdlib,runtime},modules/scripting/...✗script/{workspace,langsvc}.
4.5 Enforcement
make depcheck runs tools/depcheck and its tests. depcheck lists the graph with go list -deps for the variants default, aego_editor, aego_nonative, aego_runtime (the runtime package set) and js/wasm (cmd/aego-playground), plus the test imports of the default and editor builds; a go list error fails depcheck. On every graph it applies the group rules, the layer rules, the runtime and playground reachability rules, then the literal pass, then the source checks (os.* outside approved packages, import "C" outside native bridges, mutable global service singletons). Violations name the rule, the file and the variants. The negative tests in tools/depcheck/testdata reproduce each rule on a small tree, so a rule that stops firing fails the tests.
The runtime package set is not listed by hand: go run -tags aego_nonative ./tools/depcheck -runtime-pkgs . prints every package minus the dev-only ones, and make test-runtime builds and tests exactly that set. depcheck runs with aego_nonative because it reads the module catalog (app/assembly/builtin) and must not need the native libraries.
5. Repository layout
Numbers are the stage in which the package becomes real. Placeholder packages are not created.
aego/
├── go.mod Makefile ARCHITECTURE.md docs/
├── core/ handle mathx clock log errs stats ids rng strs bits (1–3)
├── platform/ platform.go event.go ext.go input/ sdl/ headless/ (1) android/ (14)
├── gpu/ device.go gpu.go gl33/ (gl/ generated, internal/glstate) null/ (1) gles30/ shaderc/ (12–14)
├── vfs/ vfs.go osfs/ (1) pakfs/ assetfs/ (13–14)
├── engine/ engine registry world service config loop jobs app (1)
│ res/ (2, engine://) module/ (6.5) base/ problems/ (8C)
├── render/ render pass viewframe pass3d viewframe3d view/ texture/ sprite/ culling/ debug/ text/
│ gpuprof/ rt/ mesh/ icons/ host/ (2, 4.5) material/ postfx/ light/ (12)
├── ui/ core/ draw/ widgets/ overlay/ layout/ textedit/ dock/ theme/ dnd/ icon.go (2–4) scenedebug/ (dev)
├── schema/ registry descriptor dynamic bag access refs kind options space tag hash methods (3, 5)
├── format/aetext/ lex parse write value (3)
├── project/ project.aego model, roots, native build inputs (3, dev-only)
├── asset/ artifact id index resolver builtin loader/ pipeline/
│ importers/{texture,atlas,obj,scenefile,script} (3, 4.5, 5)
├── scene/ scene node store query names space transform events overlay register
│ sceneio/ (3, 4.5, 5) frame/ scriptcomp/ (6.5) prefab/ prefab/author/ (8D)
├── script/ abi source token lexer diag ast parser types resolver checker asm compiler harness
│ bytecode verify bind vm runtime stdlib (5)
│ workspace langsvc reload coroutine signal (6) docgen (7)
├── sdk/ sdk.go dev.go editor.go input.go script.go spatial.go (3+, 6.5)
├── contracts/ scriptevents physics2d (8A)
├── modules/ built-in modules, one package tree each (8C)
│ ├── render2d/ module components systems editor/ (6.5)
│ ├── render3d/ module components systems dev editor/ (6.5)
│ ├── scripting/ module events methods systems/ stdlib/ (scene, input) (6.5, 8C)
│ └── physics/ world/ components/ systems/ stdlib/ settings/ editor/ res/ module.mk (8A, 8B, 8C)
├── editor/
│ ├── core/ command context services document history props selection shape state session workspace
│ code clipboard templates gizmo vectors play fieldgizmo assetops assetfiles sceneops prefabs fuzzy (4–8D)
│ ├── ext/ extension points for module editors and sdk; inspect/ (4, 8C)
│ ├── panels/ assets console docs gameview help hierarchy inspector problems profiler
│ projectsettings sceneview scripteditor search welcome (4–8C)
│ └── shell/ editor application, menus, modals, panel catalog; launcher/ (4, 6.5, 8C)
├── app/ build.go importers.go run/ edit/ supervise/ bootstrap/ (3–4, 6.5 build; dev-only)
│ assembly/{builtin,playground,editors}/ module catalog and profiles (8C)
├── cmd/ sandbox/ aego/ aego-editor/ (1, 3, 4) aego-runtime/ (13)
├── audio/ (9) anim/ (11) net/ store/ (13) runtime/ (13) templates/ (13)
├── examples/ native-rotator native-editor platformer physics-sandbox scriptreload (3–5, 8B; sdk-only)
├── demos/ basic input resize headless sprites camera layers text offscreen stress
│ scene-load asset-hot-reload hierarchy stress-scene mesh mixed stress-mesh (1–4.5)
│ physics2d (8A)
├── tests/ integration/ gl/ golden/ bench/ scenes/ scripts/
├── tools/ bootstrap-native.sh gen-gl.sh depcheck/ bench/ genfont/ genicons/ (nested modules)
└── third_party/ sdl/version.txt fonts/
6. User project layout
Created by aego new MyGame [--template empty|2d|2.5d|3d] [--module name] [--native] [--example name].
MyGame/
├── project.aego name, module, mainScene (AssetID), defaultSpace, window, input axes, native flag
├── assets/ mounted into res://
├── scenes/ mounted into res://; scenes are assets with .meta
├── scripts/ mounted into res://; one Aego Script package per directory
├── native/ optional Go extensions; imports only aego/sdk
│ ├── module.go
│ └── editor_*.go //go:build aego_editor
├── go.mod only when native/ exists
├── .aego/ ignored: cache/ generated/ build/ user/ session/ recovery/ editor.lock
└── exports/
The engine treats res:// as project content, never as a direct OS path. Project templates (2d, 3d, 2.5d, empty) are starting contents, not editor modes: the editor is one binary for all of them.
7. File formats
| Extension | Purpose | Representation |
|---|---|---|
project.aego |
Project metadata | canonical aetext |
.aescene / .aeprefab |
Scene / prefab source; instances are prefab= blocks with overrides (D221) |
canonical aetext |
.aemat / .aeanim |
Material / animation source (12, 11) | canonical aetext |
.meta |
Source asset sidecar | canonical aetext |
.aes |
Aego Script source | text, UTF-8, comments allowed |
.aeshader |
User shader (12) | text |
AETX / AEMS / AESB |
Texture / mesh / script bytecode artifacts | binary |
.aepak |
Export package (13) | binary |
Every format owns its own version (scene_format, bytecode_version, lang_version, pak_format, …); they are independent from the engine version.
8. Handles and lifetime
type Handle[T any] struct {
Index uint32
Gen uint32
}
Generation starts at 1; the zero handle is invalid; reusing an index increments the generation. Persistent data never stores a handle. A pointer returned by an arena or store is a temporary view valid until the next operation that may grow or compact the storage; callers that need stability keep the handle. GPU destruction is deferred to EndFrame; backend caches key resources by {Index, Gen}.
9. Scene model
Every node owns: NodeID, interned Name, LocalEnabled and derived EffectiveEnabled, VisibilityLayers, Space, a local transform (Transform2D with Mat3 world, or Transform3D with Mat4 world), and Parent/FirstChild/NextSibling/PrevSibling links. Component stores are sparse sets (denseNodes, denseData, sparse) with pending/tombstone semantics per D32–D33. The scene keeps a derived pre-order index rebuilt only on structural change; the transform pass is one linear traversal with parents before children, handling 2D×2D, 3D×3D and 3D-parent×2D-child per D84.
Destroy(h) marks the node dead immediately (IsAlive false, queries skip it); physical removal, child-name index updates and pre-order rebuild happen at flush.
10. Schema
schema is the single metadata source for serialization, Inspector, undo, script bindings, native extension discovery, validation, references and future replication. TypeDescriptor{ID, FullName, Former, Version, Kind, Space, Fields, Hints} and FieldDescriptor{ID, Name, Former, Kind, Flags, Offset, Size, Fast, Get, Set, Min/Max, Hints} are built once from struct tags (aego:"name,min=,max=,former=,asset=,transient,readonly") via reflection at registration. After Freeze() lookups use IDs; POD fields may use offset fast paths; reference fields use typed accessors. Script types are a dynamic layer replaced atomically (D96) whose fields operate on a PropertyBag.
11. References
NodeRef serializes NodeID; AssetRef[T] serializes AssetID. Runtime handles are derived caches. A missing target keeps its ID, yields an invalid handle and a diagnostic, and is saved back unchanged.
12. Assets
asset/loader (editor, dev run and exported games): AssetID → artifact index → runtime resource → residency/hot swap; builtin providers for reserved IDs. It knows nothing about sources, sidecars, watchers or importers.
asset/pipeline (dev only): source scan, sidecars, AssetID mapping, importers and group importers, dependency graph, content-addressed cache (D44), polling watcher with coalesced batches, transactional reimport feeding Loader.Swap.
13. VFS
Schemes res://, engine://, user://, cache://. res:// and engine:// support overlay mounts (latest wins reads); user:// and cache:// have exactly one writable mount. Runtime code never assumes res:// is a directory; .aepak mounts into the same surface.
14. Renderer
scene/frame (views, buckets) ←extract— render2d · render3d ; scene/frame —submit→ ViewFrame → render → gpu → backend
The renderer never receives scene or node types. Pass order per View: mesh opaque → mesh blend → sprites → sprites3d → debug → UI. Sprite visual order is painter order (D19); batching is a post-order optimization. Textures are premultiplied at upload (D18). Window and offscreen targets share one abstraction; ReadPixels returns RGBA8 with no padding, rows in memory order; render targets are stored top-down (D17), so row 0 is the top of anything drawn through the renderer, on every backend. Editor views render into pooled targets (D66).
Render extract (render2d, render3d) reads RenderWorld, which differs from World only under a presentation pose (D184).
15. GPU contracts
- UBO blocks
ub0..ub15, samplerstex0..tex14; texture unit 15 is reserved for uploads. Stage 12 shader tooling emits these automatically. - Data passed through cgo must live in pointer-free allocations.
- One active GPU timer query per device; nested queries are forbidden;
EndQueryin the same pass. PassDesc.DepthTarget; FBOs cached by(color, depth).
16. Engine construction, worlds, services
Builder.Use(modules) → Build(): platform, VFS, workers, GPU → Freeze registries → Module.Init(*Engine) → Engine
Services are typed registrations keyed by stable string IDs on the engine or on a World. A World is a service container plus scene state; system scope {Play, Editor, Frame} selects which systems run per world (Engine.RunPhase). The editor runs RenderPrepare for the edit world with ScopeEditor while the play world runs all phases with ScopePlay.
17. Frame lifecycle
FrameBegin drain OnMain, stats.begin, (Stage 6) apply pending script generation
PollEvents platform events, input.Apply, metrics
Input
FixedBegin × fixed steps: PrePhysics (script.fixed) · Physics (app.fixed, physics.sync, physics.step, physics.writeback, physics.events) · PostPhysics (script.events_fixed)
FixedEnd
PreUpdate · Update (app.update, script.update, script.coroutines) · PostUpdate
Animation
RenderPrepare structural flush, physics.present, engine.transform, render2d.cameras, render3d.cameras
Render render2d.extract, render3d.extract, engine.views_submit, app.render, passes
RenderUI UI / editor overlays
gpu.EndFrame, swap
FrameEnd stats.end
Reserved system IDs: app.fixed app.update app.render engine.transform engine.views_submit script.fixed script.events_fixed script.update script.coroutines script.events script.swap; every module system id starts with its module id. Extensions order themselves with Before/After by ID.
18. Input
Separate concepts: physical scancode, layout key code, Unicode text input, pointer state, recorded InputFrame. Focus loss clears held state. Text fields use platform text/IME events. Axis mappings are input.AxisConfig from project.aego.
19. Editor
The editor is a client of the engine: it composes scene, schema, asset/pipeline, asset/loader, render, ui, history and the module catalog. Open files are documents (edit scene, history, selection, view state, path/AssetID, dirty). Play clones the edit scene into a play world (D58); during Play the editor operates on the play world with a throwaway history (D75–D76). Mutations are history commands (D62); selection is persistent IDs; docking is inside one window. aego edit is supervised like aego run with session handoff (D74).
The editor is split by role (D202):
editor/core: documents, history, selection, commands, play, shapes, gizmos, the code model (core/code), the services of D216 (core/services:Frame,Notices,Navigation,Problems,Selection,Documents,SceneOps,AssetOps,Viewport) and the facadessceneopsandassetfiles.context.Editorimplements the services and is the composition root; only the shell,editor/extand core helpers hold it.editor/ext: the extension registry for module editors and project code (panels, inspectors, commands, gizmos, bounds, overlays, asset drops, settings pages, profiler sections, vector layers). Every callback is panic-isolated (D72) and getsEditorbound at registration.editor/panels/<name>: one package per panel. A panel draws throughDraw(f *services.Frame, r), takes its services in aDepsconstructor, and never imports another panel, the shell orcontext.Editor(D216, depcheckpanel-imports-panel,tests/modulecheck/panels_test.go).editor/shell: application loop, menus, toolbar, modals, the panel catalogpanels.goand the wiring of everyDeps. Panels are registered with descriptors; the default layout, the Window menu andShowderive from them (D217).
Scripts are edited in editor/panels/scripteditor over editor/core/code; it is the only editor package that uses script/langsvc and script/workspace (D129–D130).
20. Native Go extensions
A project's native/module.go implements sdk.Module (Register(*sdk.Registry)). It declares components with sdk.Component[T] (category, icon, defaults, script exposure and methods, templates), systems, script modules, templates and importers; editor tools are attached with sdk.EditorHook from files tagged aego_editor. Engine subsystems register through the same registry (D143–D146). Generated bootstrap main.go under .aego/generated links the module statically for run, editor, runtime and manifest (builtin.Manifest). Rebuilds are supervised; compile errors never kill the running process.
21. UI
Immediate-mode with retained state keyed by stable IDs: focus, text buffers, scroll, active item, pointer capture, dock tree, popups. Input routing per D70. Draw streams base/popup/modal/top with independent clip balance. Editor fonts are pre-baked atlases (D59); icons are an atlas (D82).
22. Scripting
Language. Go-like syntax, static typing, no implicit numeric conversion, structs by value, enums, slices, script types with @export fields and lifecycle methods, node as an implicit NodeRef, composite-literal constructors for engine value types. Coroutines (coroutine, start, yield) and signals (signal, Emit, Connect) are part of language version 2 (D131–D135).
Compiler. lexer → parser (error recovery, Bad* nodes) → resolver (package graph, cycles) → checker (types, attributes, exhaustiveness, Analysis with symbols and references) → compiler (register allocation, typed opcodes, constant folding, assembler with labels and line tables). Diagnostics carry codes AES1xxx–AES6xxx, spans and notes. Golden tests cover AST, diagnostics and disassembly.
Bytecode and VM. Register machine (op a b c / bx / sbx in 32 bits), typed arithmetic and comparisons, CALL/CALLX/CALLH, instance fields GETSF/SETSF, component fields GETCF/SETCF, slices NEWSLICE/ENSURE/INDEX/SETINDEX/APPEND/LEN, vector lanes VEC2/3/4 GETL SETL VADD…. The verifier rejects malformed modules; the interpreter enforces budget, call depth and runtime bounds; the heap is generational with mark-sweep at safe points.
Bindings. abi describes signatures symbolically; bind builds adapters once per Go function; stdlib (math log rand time input scene testing) and project modules register the same way; components are exposed from schema with field tables; intrinsics Get<T>/Has<T> resolve by name at load.
Runtime. Instances live in per-type arenas (slot 0 = node), schedules contain only types that define a callback, references resolve at instantiate, faults stop one instance, PropertyBag authoring values convert at spawn. Integration is modules/scripting (systems + services); the editor never imports the script runtime.
Coroutines and signals. A coroutine is a VM frame suspended at YIELD, kept with its registers in vm.Machine and resumed by script/coroutine.Scheduler (frames/fixed counters, a heap keyed by game time, await lists); it is owned by an instance and resumed against the instance's current self slice, so arena moves during a reload cannot break it. A due coroutine is found without scanning sleeping ones. Signals are a per-runtime registry of connections keyed by source instance and signal index; Emit delivers synchronously through Machine.Invoke, connection changes during emit apply after it, and a reload rebinds connections by signal name, signature and handler name before Reloaded runs.
Tooling. aego test (headless script tests from a test build of the workspace), aego dump script (disassembly of the committed generation with source lines), aego check (whole-project diagnostics against the bindings manifest).
23. Logging and errors
Structured channel logs (engine platform gpu render asset scene script physics audio network editor game), no logging in frame paths. errs.Error with codes; fatal only when the platform/graphics backend cannot initialize. Shader, import, extension, scene-load and script errors are recoverable diagnostics.
24. Performance policy
Runtime hot paths: zero steady-state allocations per frame, no frame-path logging, preallocated buffers with overflow counters instead of panics, VM execution without Go allocations unless the script allocates. Editor: ≤ 64 KiB/frame idle, 60 FPS with 10k nodes. Allocations are gated in CI against the machine's own baseline; time is gated only by the interleaved bench-compare (D213). tests/bench/baseline.json holds reference numbers, not a pass criterion.
25. Build modes and native dependencies
- Normal native build: SDL3 + GL backend.
aego_nonative: excludes SDL/GL packages for pure-Go CI, tests and tools; the whole script stack builds under it.Config.Headless: a native binary without window/GPU (null backend).WindowDesc.Hidden: GPU-capable hidden window for golden and benchmark runs.
SDL is pinned in third_party/sdl/version.txt and built by tools/bootstrap-native.sh into the cache; it is never built as a side effect of go build. GL bindings in gpu/gl33/gl are generated and excluded from manual edits and lint.
26. Testing and CI
Unit (handles, math, stepper, sparse sets, hierarchy, schema freeze, aetext canonical form, VFS, dependency graph, history, UI state, lexer/parser/checker goldens, VM conformance, verifier), fuzz (lexer, bytecode decode, verifier), contract (real GL vs null backend), golden rendering and golden editor screenshots (llvmpipe, pinned fonts), scene/format round-trips and migrations, performance baselines, depcheck. CI runs go test ./..., go vet, staticcheck, depcheck, aego_nonative and native desktop builds, GL contract and golden tests, bench regression.
Module golden files (tests/integration/testdata/create_menu.txt, inspector.txt, bindings.aetext) are the reference as of commit 7bcf79f. bindings.aetext was generated from the pre-module code of 08f0b15 and matches the module build byte for byte (apart from source locations); the Create menu and Inspector goldens were checked against the template and inspector registration code of 08f0b15, which has no module system to run the same test against.
Open before Stage 7 adds load to the same paths:
- Script runtime benchmarks are +18% (
Update2k) and +29% (Schedule10k) against 08f0b15; the cost arrived with Stage 6 (the 6.5 verification work left them unchanged and madeCoroutines2k18% faster). This is known and not yet accepted as a baseline. - Rendering golden references (
tests/golden/testdata) were first written after Stage 6.5, once the harness rendered on the thread that owns the GL context andReadPixelsstopped flipping rows (D17 already stores render targets top-down). They were checked by eye once; there is no older reference to compare against. - The manual editor checklist for 6.5 (Create → module templates, Inspector sections and badges, Add Component requirements, save and reload, component script methods during Play, the one-file component check) has not been run.
Physics (Stage 8A, make bench-physics, defaults 30 Hz / 4 substeps / 1 / 1), reference numbers. i7-1260P: 1k active boxes (100 stacks of 10) + 2k static = 1.79 ms/step, 0 allocs/step after warmup; raycast through the stacks 3.0 µs, 0 allocs; 10k sleeping = 0.36 µs/step, 0 allocs. Second development machine: 2.45–2.57 ms/step for the same scene, the same on the commit before Stage 8B. Pass criterion: the benchmark gate (D213). demos/physics2d (bonded 513-brick wall, 15-row pyramid): the wall sleeps after 5 s, the pyramid after 10 s, nothing moves out of place.
Open after Stage 8A:
- Tall unbonded columns: soft contacts are springs of at most
min(ContactHertz, substep rate/4)Hz. On the defaults a free column of one-unit boxes stands up to 13 boxes, leans at 14 and falls from 15 (TestTallColumnStandschecks 12). The stiff profileContactHertz60 +Substeps8 holds 20 (TestTallColumnStandsStiff) at 2.9 ms/step for 1k active boxes. Games that need tall free columns use the stiff profile, bond the layers like bricks, or build the column as one body. - Not blocking: Box2D v3 at 30 Hz usually holds columns taller than 13. The difference is in the relax pass, in warm starting across substeps or in the manifold anchor points. Compare both solvers on an identical scene during the physics performance pass; if the cause is found the default stays at 30 Hz and the column limit rises for free.
Stage 8B (make bench-physics): the platformer scene runs at 0 Go allocations per steady frame (TestPlatformerSteadyFrameAllocs); BenchmarkPlatformerFrame = 0.026 ms/frame, 0 allocs. examples/physics-sandbox settles within 600 frames with every dynamic body asleep. Editor: with Game View overlays on, 300 frames of Play keep body positions bit-identical to a run without them (TestGameOverlayKeepsSimulation); goldens editor_profiler_play.png and gameview_physics_overlay.png.
27. Naming
Packages are named by capability (render/view, asset/loader, scene/sceneio), never utils/common/manager. Engine-owned IDs are namespace-qualified (engine.transform, render.sprites, script.update, editor.save); project extensions use their own namespace. Dimension-dependent types carry a suffix (Camera2D/Camera3D, AABB2/AABB3, SpriteRenderer/MeshRenderer).
28. Architecture invariants checklist
- Does runtime remain independent from editor/dev-only packages?
- Is persistent identity separated from runtime handles?
- Does the code path work when
res://comes from.aepak? - Is backend-specific behavior confined to backend/view boundaries?
- Does render remain independent from scene, and scene from script?
- Are structural scene writes safe during iteration?
- Are unknown components and properties preserved?
- Do native extensions use only
aego/sdk? - Does a failed hot reload (asset or script) keep the previous working state?
- Is every source representation canonical and versioned?
- Does every edit participate in history?
- Can the feature run headless and under
aego_nonative? - Does the VM hot path stay free of reflection and Go allocations?
29. Stage ownership
| Stage | Ownership |
|---|---|
| 1 | Core, platform, SDL bridge, input, VFS, Builder/Engine, phases, handles, headless, GPU/RHI, logging, worker boundary |
| 2 | 2D renderer, views, batching, culling, targets, debug draw, GPU profiling, UI foundation, golden rendering |
| 3 | Schema, Node + Components, sparse sets, project, asset loader/pipeline, aetext, scene IO, native Go extensions, aego run/build |
| 4 / 4.1 | Editor v1, docking, hierarchy, inspector, views, gizmos, undo, assets, play world, extensions, supervised aego edit, play-mode editing |
| 4.5 | 3D foundation: spaces, Transform3D, meshes, depth, sprites3d, 3D scene view and gizmos |
| 5 | Aego Script core: language, compiler, VM, bindings, runtime, scene/editor integration, CLI |
| 6 | Script development loop: manifest and aego check, workspace, language service, code editor, hot reload, coroutines, signals |
| 6.5 | Module system: engine/module, built-in modules (render2d, render3d, script/engine), sdk.Registry, app.Build, editor extensions, icon sources |
| 7 | Documentation, help, playground; project templates, aego new/bootstrap/icons, launcher (7 E) |
| 8A | Physics core: physics/world, host structs and Out in bindings, script events contract, physics contracts, demos/physics2d |
| 8B | Physics integration: physics module, components, sync/step/writeback/events/present, presentation pose, layers and matrix, script events and aego/physics, collider and joint handles, debug and Vectors overlays in Scene and Game View, Project Settings, Problems and Profiler sections, platformer on physics, examples/physics-sandbox |
| 8C | Module boundaries: modules/ layout, app/assembly catalog and profiles, module API v2 (lifecycle, project sections, diagnostics, component hooks, inspector hints, resources), depcheck groups, literal pass and exceptions, fake module proof, benchmark gate, Profiler 2.0, editor services and panel descriptors |
| 8D | Prefabs: scene/prefab model and author, instance format, engine.PrefabInstance, missing and orphaned overrides, runtime templates and scene.Instantiate, IsPrefabInstance/PrefabRoot, editor prefab service with multi-document Apply/Revert/Unpack/Variant, Hierarchy/Inspector/Assets integration, platformer Enemy and FastEnemy, sandbox crate |
| 9–12 | Audio, pixel/sprite tools, animation, materials/shaders/lighting. Optional: in-context prefab editing — prefab mode draws the origin scene dimmed around the prefab, read-only, with the prefab subtree placed at the instance it was opened from. |
| 13+ | Export/pak, networking/store, Android |
30. Binaries and CLI
aego CLI:
new <dir> [--template empty|2d|2.5d|3d] [--module m] [--example name] [--native]creates a project (D148) and verifies it;edit [dir]opens the editor, or the launcher when no project is found (D149);run build check test dump importas before;test [dir]runs script tests;make check-allruns it forexamples/platformerandexamples/physics-sandbox;bootstrap [dir]regenerates.aego/generatedfor the native module without building;icons [dir]checks module icon files against the icons the module declares, exit 1 on any difference;docs export|check|build|serveserves the documentation registry and playground.
Later: export bench. Native SDL is bootstrapped with make bootstrap. Binaries: aego-editor, sandbox, later aego-runtime.
31. Final rule
When choosing between a locally convenient shortcut and preserving a boundary that later stages depend on, preserve the boundary. Platform/GPU can change without rewriting render; render without rewriting scene; scene without rewriting editor data ownership; the editor can evolve without entering runtime; the language can evolve without touching the scene; user projects can extend the engine without forking it.
Appendix A. 8C.4 backlog audit
Rows of the 8B backlog (8C.3 inventory, §6). "keep" rows were already extension points or kernel features; the stage column names the substage that closed a row; git log -S or git blame on the files finds the commit.
| # | Closed by | Stage | Files |
|---|---|---|---|
| 1 | project section instead of the physics.settings service; InitContext.Section |
8C.4 | engine/module/sections.go, modules/physics/settings/, app/build.go |
| 2 | physics editor hook attached by the catalog, not editor/builtins |
8C.4 | app/assembly/editors/editors.go, app/assembly/hooked.go |
| 3 | physics.New() in the catalog |
8C.4 | app/assembly/builtin/builtin.go |
| 4 | keep; the missing-requirement check moved with the catalog | 8C.4 | app/assembly/assembly_test.go, engine/module/registry_test.go |
| 5 | keep: app/edit is the composition layer and may import modules (D205) |
8C.4 | app/edit/*_test.go |
| 6–7 | physics block moved to the module; project keeps unknown blocks as AST; Project Settings checks the draft before writing |
8C.4 | project/project.go, modules/physics/settings/settings.go, editor/panels/projectsettings/panel.go |
| 8 | aetext.Node.Clone, deep clone of Extra; physics text removed |
8C.4 | format/aetext/value.go, editor/panels/projectsettings/panel.go |
| 9 | Render page built from the render2d.Settings schema |
8C.4 | modules/render2d/settings.go, editor/panels/projectsettings/auto.go, pages.go |
| 10 | engine/problems; ProblemPhysics removed; physics publishes a source |
8C.4 | engine/problems/, editor/core/context/editor.go, modules/physics/module.go |
| 11 | play hint built from registered profiler sections | 8C.4 | editor/panels/profiler/profiler.go |
| 12 | overlay tooltip without module names | 8C.4 | editor/panels/gameview/gameview.go |
| 13 | depcheck catalog from app/assembly/builtin; editor-builtins group removed |
8C.4 | tools/depcheck/catalog.go, groups.go, layers.go |
| 14 | modules/physics/module.mk through MODULE_CI, MODULE_BENCH_ALLOCS, MODULE_BENCH_BASELINE |
8C.4 | Makefile, modules/physics/module.mk |
| 15 | keep: fixtures at the composition level | — | tests/golden, tests/integration |
| 16 | keep; Render.PixelsPerUnit is the render2d section |
8C.4 | modules/render2d/settings.go, modules/render2d/editor/editor.go |
| 17 | SettingsPage(section, draw) on drafts (D207) |
8C.4 | editor/ext/extras.go, editor/core/context/extras.go |
| 18 | replaced by engine/problems (D210) |
8C.4 | editor/ext/extras.go, editor/panels/problems/ |
| 19–20 | keep (Profiler, GameOverlay, Overlay) |
— | editor/ext/extras.go |
| 21 | vector layer command created when the layer is registered | 8C.4 | editor/ext/extras.go, editor/core/vectors/vectors.go |
| 22 | guard tests with fakephys; frame phase names allowed |
8C.4 | tests/modulecheck/ |
| 23 | keep OnAdd; added OnRemoving, OnChanged, Validate (D209) |
8C.4 | engine/module/onadd.go, changes.go, validate.go, editor/core/history/ |
| 24–26 | keep | — | — |
| 27 | Play asks Problems.Blocking |
8C.4 | editor/core/play/play.go |
| 28 | AttachWorld/DetachWorld with the Play flag; script.events only in play worlds (D206) |
8C.4 | engine/module/module.go, manifest.go, modules/scripting/module.go |
| 29–32 | keep | — | — |
| 33–34, 40–41 | keep; docs integration tests moved to tests/docs |
8C.4 | engine/docs/docs_test.go, tests/docs/ |
| 35 | keep: frame phases are kernel | — | engine/registry.go |
| 36 | keep: contract (D160) | — | contracts/physics2d |
| 37 | cat.physics.svg in the module; genicons skips placeholders found in any source |
8C.4 | modules/physics/res/icons/src/, engine/module/icons.go, tools/genicons/main.go |
| 38 | SDK facades for sections, diagnostics, hooks, hints, VectorSink; examples/native-settings |
8C.4 | sdk/settings.go, sdk/editor.go, sdk/sdk.go, examples/native-settings/ |
| 39 | script events tested in the module; checker fixture renamed to aego/probe |
8C.4 | modules/scripting/events_test.go, script/checker/*_test.go |
| 42 | physics.events in PhasePhysics after physics.writeback (D204) |
8C.1 | modules/physics/module.go |