Files
aego/ARCHITECTURE.md
T
sanspie b6dd46f523
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 14m14s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 16m1s
ci / release (push) Failing after 14m38s
Stage 10a follow-up: camera scene tests, rewind check, value Adjust
- camera.NewService and SetAccessibility; AttachWorld wires the accessibility settings
- tests/camera: priorities, blends, cuts, zones, follow, bounds, outputs, shake, zero allocations, BrainStep benchmark
- integration: rewind restores the camera state across a zone change
- camera2d.Adjuster passes the view by value (no per-frame allocation), D304 updated
- modules/camera/module.mk: test-camera in CI, bench-camera
2026-10-21 01:32:28 +03:00

212 KiB
Raw Permalink Blame History

Aego — Architecture

Status: active architecture specification Language: Go 1.27+ Repository model: monorepo, single root Go module aego Scope: 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:

  1. A real native engine, not a wrapper around another engine.
  2. A built-in editor with a Unity/Godot-like workflow.
  3. A custom gameplay language with editor integration and hot reload.
  4. Native Go extensions for projects that need to extend the engine itself.
  5. 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 with plugin.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. A captured Game View receives every key, Escape included; the command play.release_input (default Shift+Esc, rebindable in the keymap) or a click outside the view returns input to the editor, and full-screen Play exits the same way. Amended 2026-10-18 (Stage 9.2): Escape used to release the capture, which hid the standard pause and menu key from games; releasing is a rare editor action, so it moved to Shift+Esc. F1 stays with docs.
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 is the Design workspace (D278): Hierarchy over Assets on the left, Scene/Game center, Console/Profiler bottom, Inspector right. window.reset_layout resets the current workspace to its preset. Amended 2026-10-19 (Stage 9.0a): the reset used to delete .aego/user/layout.aetext; that file is now imported once into the Design workspace and removed.
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 moved to the input module (D234).
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 or strings from the machine’s pool (abi.EventParam); the host produces such strings with scriptevents.Strings.Intern and may pass only a bounded set (ids, names), since pooled strings are never collected; scriptevents.Broadcaster delivers an event to every node with a handler in pre-order. 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 lives in modules/scripting/stdlib, whose Install keeps the historic registration order; aego/input belongs to the input module (D234).
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} 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 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. Closed in 9.0d: a node added by overrides inside a nested instance applies to the nearest prefab, which is the prefab whose file stores the addition; removing such a node drops its addition; "Apply to " names that prefab. Still open: a prefab instance added inside a nested instance cannot be applied to the nearest prefab (apply it to the outer one); 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.

3.13 Stage 9.1 — Time and debug

ID Decision
D228 .meta reconcile. The pipeline runs in MetaEnsure (edit, run, import: a missing sidecar is written with a new id) or MetaReadOnly (a missing sidecar gets an id derived from its path in memory and is reported). Duplicate ids are never fixed silently: the path the index does not name keeps a derived id in memory. Reports go to engine/problems with owner assets: META001 missing, META002 orphan sidecar, META003 duplicate id, META004 index entry without a source. Repair writes or deletes sidecars and drops index entries, only after confirmation, directly in the pipeline; these are sidecar operations, not document mutations. Move and Rename move the sidecar and keep the id. .aego/cache/index.lock gives the index to one process; another process opens the pipeline shared, with read-only sidecars and no index writes. aego check audits without opening the cache; --ensure-meta writes missing sidecars.
D229 Time domains live in core/clock.Time, one per World: Real (platform time cut to MaxFrameDelta, cut time counted), Game (Real × Scale, 0..8, zero while paused), Fixed (constant step fed by Game, at most MaxFixedSteps per frame, extra steps dropped and counted, remainder below one step), Editor and Audio (equal to Real; Audio is the interface for the audio clock). Alpha is remainder / step after the fixed phases and is what physics.present reads. WorldID (process-unique), FrameID and FixedTickID (monotonic per world) come only from the clock; they are in engine.Context, in every trace frame (World, FrameID, FirstTick, LastTick), and later in recorder events and input snapshots. Defaults 0.1 s and 4 steps apply to every project; the core section engine {} overrides fixedHz, maxFrameDelta, maxFixedSteps. D11 is amended accordingly. The clock is in core/clock, next to the stepper, because it needs only math.
D230 Pause and step. Pause stops Game and Fixed and never accumulates time, so Resume cannot spiral. Step Fixed runs exactly one fixed tick and no variable phase; Step Frame runs one variable frame with the Game delta of the last unpaused frame and zero or one tick by the accumulator. SystemDesc.Pause (Normal, Always, WhenPaused) applies to FixedBegin..Animation; FrameBegin, input, rendering and FrameEnd always run. physics.present is Always, so a stepped Game View shows the new pose. Engine.TickWorld(world, scope, real) is the only way to advance a world; the main loop and the editor play controller both use it. aego/time adds Unscaled, UnscaledElapsed, Scale, SetScale, Tick, IsPaused, SetPaused.
D231 Debug draw. contracts/debugdraw.Draw is the world service debugdraw: lines, arrows, rays, circles, rectangles, polygons, crosses, points and text with color, thickness in logical pixels, drawn as one-pixel lines stepped along the minor axis (up to 8), circles as rings stepped by 1/√2 px (up to 12), lifetime (one frame, N frames, seconds of Game or Unscaled time, persistent), channel, world or screen space, layer and fade. modules/debug implements it with preallocated buffers (16384 primitives, 4096 per channel, extra ones dropped and counted, zero allocations per frame), expires primitives in FrameBegin (one-frame and frame-counted primitives survive a paused frame) and draws 2D views in debug.draw after engine.views_submit, so primitives show in the Game View and in aego run; the editor adds a Scene View overlay and the Debug Draw panel with channel toggles and presets. Channels are visible unless shown or hidden explicitly or matched by a default-hidden prefix. debug { enabled=false } turns every call into a no-op. Physics draws shapes, contacts, AABBs, joints, islands and the broadphase tree as persistent physics.* channels, cleared and redrawn every tick, hidden by default; the Physics menu sets their visibility. Scripts use aego/debug with a per-instance pen (Pen, PenUnscaled) for channel and lifetime. 3D views and edit-world drawing are not covered yet.
D232 Event recorder. engine/recorder is a ring of records {World, Frame, Tick, Kind, Owner, Subject, Name, Detail} with per-kind counters; World.Recorder is created for play worlds and the aego run main world, and the engine stamps it at frame start and before every fixed tick. Sources: signal emits, engine events (recorded for nodes with a handler or under the node filter, counted otherwise), script faults, spawn and despawn through frame.Record, scene changes; input and animation kinds are reserved for 9.2 and Stage 11. Names and details are static strings, formatting happens only when shown, recording and counting allocate nothing. World.Break is checked after every fixed tick and after the variable phases; a hit pauses the clock at once, so the rest of the frame does not run. The Event Monitor filters by kind, text, selected nodes and frame (the profiler strip navigates to it) and breaks on a kind and text; Watch reads Node.Component.Field[.lane] through schema access, script fields through ScriptHost.Watch, and time.*, and breaks on a numeric comparison, edge-triggered.
D233 Crash bundles. Protected boundaries (workspace panels, extension callbacks, native script bindings AES6002, the play world) recover, write .aego/crash/<time>/ and disable the culprit or stop Play; a panic in editor code outside panels or in a worker writes the bundle and is re-raised. A bundle holds info.aetext, go.stack, script.stack, trace.json, events.log, scene.aescene (engine.crashScene) and the last 500 log lines, with the home directory written as ~ and no environment. The recovery journal is flushed when a bundle is written. engine.spikeMs writes a spike bundle in the editor, at most once per 10 s. The profiler adds a Memory section (Go heap, goal, process, goroutines, GC, script heap), optional allocations by phase from runtime/metrics and a frame-budget notice naming the slowest zone. GPU resource and asset cache counters are not shown yet.
D234 Input actions live in the input module. platform/input keeps raw keyboard, mouse and up to four gamepads and knows nothing of actions. Each play world gets an input.service. input.frame (PhaseInput, PauseAlways) evaluates bindings through the context stack, with consuming contexts masking their controls for the ones below. It turns edges into a per-action queue that input.tick (PhaseFixedBegin) drains one event per fixed step, so a tap between steps gives one Pressed then one Released, and three steps in a frame give one Pressed and three Held. Recording stores normalized per-frame action samples; a replay replaces the device source before both views, which keeps Update and FixedUpdate deterministic. The project section input (version 2) migrates version 1 axes to axis1 actions with a warning. aego/input is owned by the module, raw keys included; Pressed/Released take an action name, raw edges are KeyPressed/KeyReleased. Rebinds are player settings (D237).
D235 Module provisions. module.Registry.Provide(kind, v) hands typed data from one module to another without an import; the kind and the value type live in a contract. InitContext.Provided(kind) and Manifest.Provided(kind) list them in module order with the owner. The input module reads contracts/inputactions.Set: actions and contexts the project does not declare are added, contexts marked Default join the start stack, and a project declaration with the same name wins. Project Settings shows only project actions. The editor feeds gamepads to the play world whether or not the Game View captures input; keyboard and mouse still need the capture. Inject with tick 0 applies on the next frame.

3.14 Stage 9.3 — Settings

ID Decision
D236 Settings registry. engine/settings.Store holds every player and project option. A setting is <owner>.<path>, registered by its module through module.Registry.Setting in Register, or through InitContext.Setting in Init when it depends on project data (input bindings per action); the owner prefix is checked at build and at registration. Kinds: bool, int, float, string, enum, Vec2, Color, list of strings. Flags: User, Project, Restart, Transient, Accessibility, Confirm, Controls. The effective value is session, else user, else project, else default; reads go through a Key and an effective-value cache and do not allocate. Changes go through a transaction (Begin → Set → Apply/Revert): Apply runs the owners' Apply hooks in order, and when one fails it restores every touched layer, re-applies the old values to the hooks already run and reports CFG003. ApplyWithConfirm(s) reverts on its own after s seconds without Confirm(). Subscribers are called once per changed setting per commit. EnabledWhen, ChoicesWhen, ChoicesFrom(section, list) and Supports (platform check) are evaluated on every check. One enum setting may be the preset: choosing a preset sets every setting that declares Presets[name], changing one of them selects the matching preset or Custom. Session overrides are never saved.
D237 Storage. Project values live in the core block defaults { <owner> { <path>=value } } of project.aego (the block is not a project section; its head is not a module id because of the literal rule); Project Settings → Defaults edits only this layer and writes it through Store.ProjectNode and SetProject. The player profile is platform.ProfilePath(module, profile) = UserConfigDir/aego/<module>/profiles/<name>/settings.aetext, profile version=1 { <owner> version=N { … } }; platform reads and writes it (temp + rename), the store only sees bytes. Owners version their block with Registry.SettingsVersion(n, migrate); an older block is migrated on a clone, a newer one is kept verbatim and not overwritten, a newer profile makes the store read-only. Unknown owners and unknown fields survive load and save. A damaged profile gives CFG001, defaults and a .bad copy. The editor's Play and aego run share the profile; --profile name selects another. app.Build creates the store before Modules.Init, freezes it after, loads project then profile and calls Start, which applies every hook once and runs AfterLoad callbacks. Input rebinds moved from user://input.aetext into input.bind.<Action> settings; the old file is imported once when the profile has no binding.
D238 Built-in settings modules. display (window mode, monitor, resolution, vsync, frame limit, render scale, UI scale, quality preset in the Graphics category) applies to platform.Displayer only outside the editor; mode, monitor and resolution carry Confirm (15 s). audio is a stub that only holds bus volumes and mutes until Stage 10 (audio.Of(engine).Volume(bus)). accessibility holds reduced motion, screen shake, high contrast, colorblind palette and subtitles; modules that move the camera or emit particles must read reducedMotion and screenShake. The UI category of a setting is Category; the Accessibility tab also lists settings flagged Accessibility. settings owns the runtime settings screen (a play-world overlay drawn after engine.views_submit with ui/, opened by F10 through a provided input context with consume and priority 1000, read via contracts/inputactions.Reader; it pauses the world while open), aego/settings and autosave half a second after the last change. engine/settings/settingsui draws one setting and is shared by the screen and Project Settings. renderScale is applied by the runner: display.scale (RenderPrepare, before engine.transform) passes the value with Runner.SetRenderScale; engine.transform renders into a pooled target of window pixels × scale unless the host set its own target (SetTarget: Game View, Scene View), and multiplies the runner pixel scale so input mapping stays consistent. engine.present (RenderUI, before render.end) stretches the target over the backbuffer. Full-resolution overlays are ordered between two core ids, After engine.present and Before engine.overlays, and draw into Runner.Output(), the final surface (the backbuffer in aego run, the Game View target in the editor); the settings screen is the first such overlay. The play controller runs RenderUI for the play world, so overlays reach the Game View.

3.15 Stage 9.4a — Curves, tween, timers

ID Decision
D239 core/curve depends only on core/mathx. A Curve is either a preset (Ease != None, domain [0,1], evaluated analytically) or keys (T, V, In, Out, Interp, Tangent). Segments interpolate by the interp of their left key: step, linear, bezier (cubic Hermite, equal to a Bezier with handles at a third of the segment). Tangent modes: auto (average of the adjacent secants, zero at an extremum, clamped to 3× the smaller secant, so a segment never overshoots its keys), linear (the secants), free (stored In/Out, may differ). End keys of auto/linear take the one-sided secant. Pre/Post wrap: clamp, loop, pingPong. Fix sorts keys, drops NaN keys and keeps the last of equal times; Normalize maps time to [0,1] and scales slopes; Bake turns a preset into free keys for editing. Math is float32; determinism follows D180.
D240 schema.KindCurve holds a curve.Curve by value in components and data; Value.Cv carries it, Set stores a fixed clone, comparison is Curve.Equal, so prefab overrides of curves never appear from a round trip. aetext form: a struct value { ease=… pre=… post=… keys=[[t, v, in, out, interp, tangent], …] }, defaults omitted, a preset curve writes no keys, an unknown field is a load error. The Inspector draws the curve in the field; a click opens the curve editor (preset, graph with keys and tangent handles, double-click adds a key, right-click removes one, drag moves, Primary snaps to a 1/20 grid, Shift breaks tangents, segment and tangent mode of the selected key, wraps). The view range is frozen while dragging.
D241 Script view of a curve: abi.Curve, one slot, A = owner NodeHandle, B = component link index in the low 32 bits and field index in the high 32. It is produced by reading a curve field of a ComponentRef (the field is always read-only) and is resolved by the runtime through bind.CurveSource; Go bindings take it as bind.Curve and call Resolve. aego/curve has Eval(c, t), EvalClamped(c, t), Duration(c), Ease(e, t), the enum Easing and one typed constant per preset (curve.OutQuad). A nil view, a destroyed owner or a removed component is AES6004. No curve literals and no exported script curve fields in 9.4.
D242 Host handle types: bind.Handle[T](module, name, awaitable, of, key) registers abi.Handle{module.name}; the codec is keyed by Go type, so every handle type has its own Go type (the timer is the script-only timerRef over the same pool handle); a value is one slot with the key in A and abi.HandleID(name) (FNV-1a) in B, the zero value is nil. Methods live in Registry.HandleMethods(name) (pseudo-module #module.name), are kept in abi.Table as handle methods (part of the ABI hash) and in the manifest as handle and hmethod blocks (manifest version 5). An awaitable type must have Done() bool; yield h compiles to Done() and a branch: done resumes on the next frame like yield c on a finished coroutine, otherwise YIELD with WaitHandle parks the coroutine under (B, A). The owning service calls contracts/scriptwait.Waker.Complete(typ, id) (world service script.waker) when the object ends, fires or is cancelled; cancellation wakes with the same result and the coroutine checks Done() itself. Woken handle waits resume with game-time coroutines on the next frame, so they wait while the game is paused.
D243 modules/tween is a built-in module with a per-play-world service tween.service. A tween targets Component.Field[.lane] (full or unique short component name, field name as in schema or with an upper-case first letter), transform.position|rotation|scale or script/<name>.Field[.lane], resolved once at creation; component fields are written through their offset, script fields through contracts/scriptfields implemented by the script runtime, so tween never imports scripting. Values: float, int (rounded), Vec2, Vec3, Color/Rect/Vec4, Quat (normalized lerp on the short arc). Options: easing or curve, Delay, Legs (1 by default, −1 forever), PingPong (each leg reverses), domain Game or Unscaled. The start value is read when the delay ends. When the last leg ends the field is written with that leg's exact end value (to, or the start for an even ping-pong). Stop leaves the current value and never jumps to to. A new tween on the same property of the same node stops the running one. A tween whose node or component is gone stops on its next update. Systems tween.game (PostUpdate, PauseNormal) and tween.unscaled (PostUpdate, PauseAlways). Tweening the transform of a dynamic body is a teleport (D185). Acceptance: the exact end value, no overshoot on Linear, Unscaled runs while paused, Stop keeps the value of a Legs=-1 tween, 1000 tweens step with zero allocations. Script API: tween.ToFloat|ToVec2|ToVec3|ToColor|ToQuat(node, path, to, duration, ease) and tween.Position(node, to, duration, ease) (no overloading in the language), easing as aego/curve constants (curve.OutQuad); Go API sdk.TweensOf(ctx). The module requires script, since ease is the script enum aego/curve.Easing.
D244 Timers live in the same module: StartTimer(s, Repeat, Unscaled, Keep, Owner), Pause/Resume/Restart/Cancel, Remaining, Count (fire count; the script method, Service.Fired in Go), Done; every fire and the end call the waker. A timer without Keep frees its slot when it ends, so its handle reads as done and Restart on it does nothing; a Keep timer stays until Cancel or until its owner node dies. Cooldown(s) is a Keep timer that starts done, with Ready()/Use(). Tweens and timers are slots in pools with free lists that grow and never shrink, so a stable count allocates nothing per frame. Handles are generational; a stale handle reads as done. OnComplete/Fired signals and sequences come with 9.4b. Script API: timer.Start(s) (one-shot, frees itself), timer.New(s) (kept), timer.Cooldown(s); every script timer is owned by the node of the script that created it. The bindings cache key carries the manifest version (engine.Version+"+v5" in app/projects/check.go), since app/bootstrap may import only core and project.

3.16 Stage 9.4b — Signals, tags, spawn

ID Decision
D245 One connection registry for every signal source. script/signal keys a source by Source{Kind, A, B}: a script instance (A = InstanceID), a component (A = node, B = TypeID), a handle (A = key, B = handle type id). A target is a script method or a Go handler. Semantics are the same for all: handlers run synchronously in connection order before the emit returns; connections added or removed during an emit apply after it; the source's death (node destroyed, component removed, handle ended, instance stopped) removes its connections; a script target's stop removes its connections; hot reload rebinds script handlers by name, and rebinds Go handlers of script sources by signal name and parameters; endless re-emitting ends with AES6003 on the emitting script, and is logged and dropped for a Go source. A Go handler that panics is logged and disconnected and goes to the crash bundle through the protected-boundary rule of 9.1 (runtime.Options.OnPanic); it never stops a script. The play world service signal.hub (contracts/signals) is implemented by the scripting module over the runtime, so modules emit and connect without importing scripting. Emit arguments come from the hub's stack (Args), an emit without subscribers stops at Wants, and neither allocates. Script code connects through the intrinsics #signal.ConnectComponent and #signal.ConnectHandle; the bytecode format is unchanged.
D246 Component signals: ComponentBuilder.Signal0..Signal3[A..](name, doc, names...) returns an emitter value created at registration; TypeDescriptor.Signals records name, parameter types, names and doc, and the component's ABI entry carries them (part of the hash and the manifest, csignal blocks, manifest version 6). Parameters are plain types (no strings or slices). Scripts connect through a ComponentRef: hp.Died.Connect(OnDied), checked like a script signal; Emit on it is AES2055. Only Go emits: died.Emit(world, node, args), zero allocations, a no-op without subscribers. Go connects with sdk.Connect, sdk.Connect0, sdk.Connect1[A] to "Health.Died" (component) or "script/Enemy.Died" (script signal) and gets a Connection usable from Go and scripts.
D247 Handle signals: bind.Signal(handleMethods, name, params...), hsignal blocks in the manifest. A tween has Completed(finished bool): emitted once when it ends, finished is true at its natural end and false after Stop, a replacement by a new tween on the same property, or the death of its node or target. A timer has Fired(count int) on every fire. After the last emit the handle's connections end. Go: Tweens.OnCompleted, Tweens.OnFired. yield h stays for coroutines. A handle signal and a handle method never share a name (abi.Table rejects it); state reads are nouns (Count, Remaining), signals are events (Fired, Completed).
D248 Tags: core component engine.Tags (Tags []string) registered by the engine module, category Engine. A tag is dot-separated segments of [A-Za-z0-9_] (Enemy.Flying). The scene keeps an index tag → nodes that also files every prefix (Enemy.Flying is found by Enemy); tag strings are interned in the scene interner. The index is built on first use and kept up to date by dirty marks: adding, removing or editing a Tags component (schema writes, the Inspector), destroying a node, AddTag/RemoveTag; a Go change to the slice itself calls scene.TagsChanged(h). Dirty nodes are reindexed before the next query, so a query with nothing dirty allocates nothing. The validator reports malformed tags (TAG001) and duplicates (TAG002) as warnings; malformed tags are not indexed. Scripts: node.HasTag(t), AddTag(t), RemoveTag(t). The Inspector edits tags as a string list; completion from a project tag list comes with the editor pass.
D249 Queries fill a caller slice and return the count: WithTag(tag, out), WithComponent(name, out) (full or unique short component name), InArea(rect, mask, out), ByID(id). They skip disabled nodes as of the last transform pass. WithTag returns nodes in the order they got the tag, WithComponent in component-store order; both are deterministic for the same sequence of operations. InArea asks physics2d.Queries for bodies touching the box on the layers in mask when physics runs; otherwise every enabled 2D node whose world position lies in the rectangle (a node is a point, the mask is ignored), because the core does not know object bounds. Go: Scene.WithTag, WithTagAll, WithComponent, InRect.
D250 Spawn and pools live in the built-in module spawn (play-world service). Spawn(prefab, pos), SpawnAt(prefab, parent, pos, rot), Despawn(node), Pool(prefab, n). A pooled root keeps a snapshot of its subtree taken right after instantiation: component values (hidden components and script attachments excepted), the enabled flags and the local poses of the children. Despawn runs OnDespawned() on the subtree's scripts, disables the root at once and parks it in the spawn.park system (Animation, always): the scripts' coroutines and connections end and the instances stop (Disabled), the root moves under a disabled Pool node. Spawn from the pool restores the snapshot, sets the root's 2D pose, re-enables and reparents it, resets every script instance to its declared defaults and its scene values (Ready does not run again; a faulted instance runs again), then runs OnSpawned(). A new instance runs Ready and then OnSpawned. Pool instantiates and parks at once; Ready runs for these instances before they park. Structure added at runtime (components, children) is not undone. The physics body keeps its velocity across a respawn, so set it in OnSpawned. Despawning a node that did not come from a pool destroys it. The script runtime takes part through contracts/scriptpool.
D251 Actions compose tweens and timers: action.Sequence() and action.Parallel() return the awaitable handle action.Action with Then(tween), ThenTimer(timer), ThenAction(action), Wait(s), Stop(), Done(), Running() and the signal Completed(finished bool). Steps are added before the action starts (at the next tween update); a tween or timer must not have advanced yet and belongs to one action at most, otherwise AES6010. A step that ends early (stopped, its node gone) counts as done and the sequence goes on. Stop stops every unfinished step, held ones included, and completes the action with finished=false. An action made by a script is stopped when that script's node dies. Coroutines are not steps, since a Coroutine does not cross the host boundary; a coroutine waits for an action with yield a.

3.17 Stage 9.4c — Data Assets, validators

ID Decision
D252 Data types: module.DataType[T](r, opts…) (sdk.DataType) registers a Go struct as schema.TypeAsset with Default, Validate, Display, Category; node references are rejected, since data lives outside scenes. A data asset is an .aedata file data version=1 type="game.EnemyStats" base=asset("…") { field=value … } with the scene value syntax; the importer data checks the head and the type attribute and records base and every asset value as dependencies, so a base change reimports its variants. Artifacts data/table are blobs in the loader, read through the optional asset.DataSource. Go fields data.Ref[T] (asset kind data) and data.TableRef[T] (kind table) are direct asset references; the Inspector picker filters by kind.
D253 Base and overrides: an asset starts from the type default, copies its base record (resolved recursively, same type required, depth ≤ 16, a cycle is an error), then applies its own fields; a variant file holds only the fields it changes (data.Fields(…, base) writes the difference using the prefab value equality). Unknown fields and values that do not convert are kept in the file, skipped at load and reported. data.Library resolves records and tables and caches them through asset.Decoder when the source is the loader, so a hot reload of any data asset rebuilds them on next access.
D254 Tables: .aetable table version=1 type="…" { row "id" { field=value … } … } with unique row ids; data.Row[T](lib, table, id). CSV export/import covers bool, int, float, string, enum (by name) and asset fields; other fields are not columns and an import keeps their values from the existing table; an empty cell means the type default. data.Weighted[T] (Items []T, Weights []float32, a missing weight counts as 1) is registered per element type with data.RegisterWeighted[T](reg, name) and Pick(rng) is deterministic for a seed.
D255 Script access to data. Every type registered with sdk.DataType[T] is published as the host struct aego/data.<Short> (exported Go fields whose types have a script form; others are hidden from scripts with a warning) and as the asset kinds data:<Short> and table:<Short>; short names are unique across modules. Scripts write AssetRef<EnemyStats> and TableRef<EnemyStats>. ref.Field and ref.Copy() compile to CALLH on the intrinsic #AssetRef.Copy<aego/data.Short>, which resolves the record through data.Library over the scene's asset source and copies it into the call window; a field read takes the field's slots from that copy, so the bytecode format and the VM are unchanged. Tables: Row(id) T, Has(id) bool, Len() int (#AssetRef.Row<…>, Has<…>, Len<…>). Fields are read-only through a reference (AES2007); Copy() is the Snapshot policy. A nil reference or a missing row is AES6004, a broken asset AES6010. data.Ref[T] and data.TableRef[T] cross the host boundary as those asset kinds. Field policy: Immutable by default; sdk.Live[T](r, fields…) sets schema.FlagLive (LiveReference): only the editor changes live fields at run time, in place, and every holder (scripts, Go pointers) sees the change on its next read.
D256 Validators: module.Registry.Validator(kind, fn) for an artifact kind; the manifest runs them over an asset store (Revision, Index, Artifacts): the loader in the editor and the game feeds Problems (re-run when the loader generation changes), aego check imports the project and prints them. A diagnostic defaults to the asset subject with the source path. Built in for data and tables: unreadable file, unknown type, missing or cyclic base, base of another type (DAT001/DAT004, errors that block Play), unknown or unconvertible fields (DAT005), values outside the field range (DAT002), references to missing assets (DAT003), plus the type's own Validate. For a native project aego check skips types it cannot see.
D257 Editing data. editor/core/dataedit keeps one document per .aedata (base and own fields, composed through data.Library.Compose with cycle and type checks; a broken base is ignored with an issue so the asset stays editable) and one per .aetable (rows as live values, unknown fields kept). Both have snapshot undo (200 steps, a drag merges into one step) and save by writing the source file and reimporting it. Selecting a .aedata in Assets shows it in the Inspector until a node is selected: fields the asset sets are marked and revert to the base from the context menu, the base is an asset picker filtered by type. Asset pickers for data: and table: kinds list only assets of that type. Double-clicking a .aetable opens the Table panel: sort by column, filter, inline editing of bool, number, string and enum cells, an edit on a selected row applies to all selected rows, add, duplicate, delete, rename by double-click, CSV export and import next to the table. New data assets and tables are created from Assets → New.
D258 Live tuning. During Play the Inspector shows a data asset from its running record; only live fields are editable, base, save and tables are locked. An edit patches the record in place and every record based on it that does not set the field, recording the value before the first change. When Play stops, the editor asks whether to keep the changes: Keep restores the records, writes the values into the assets and saves them; Discard restores the records.
D259 Persistence model. A save holds only marked state: component fields with schema.Persist (FlagPersist), script fields with @persist (bytecode 7, FieldDef.Persist; bool, int, float, string, vectors, enums, NodeRef, AssetRef, otherwise AES3007), the transform and enabled flag of nodes with the save.Persist marker, and values scripts write in OnSave. The scene part lists destroyed scene nodes and every live spawned object. Loading reloads the save's scene through persistence.Host and applies the save on top in AttachWorld of the save module: destroy, spawn, Scripts.Prepare, fields and poses, then OnLoad is queued, so scripts run Ready before OnLoad and see restored fields in Ready. Modules keep their own state through module.Registry.SaveSection(id, Section) with Version, Save, Load, Migrate; sections of modules that are not loaded are kept and written back. contracts/persistence carries *engine.World for sections.
D260 Persistent identity. Scene nodes are saved by NodeID (scenes load with KeepIDs). Every Spawn gets save.PersistentID(n) from the spawn service's counter (a fixed high word, the counter in the low word); a failed spawn uses no number, despawn and destroy release the id, and the counter is saved and restored, so the next id after a load is the same as after the save. References are written as node("id") for scene nodes and {spawn="<pid>" path="A/B"} for nodes inside spawned objects; a reference to a node created any other way is not saved.
D261 Script side: events OnSave() and OnLoad(); save.Put* work only while OnSave runs (the service emits OnSave to listening nodes and flushes the queue synchronously at the end of the frame), save.Get* read what the node wrote and return the default elsewhere; keys belong to the node. Game-wide progress is save.SetGlobal*/save.Global*, kept by the module across scene loads and written to every save. save.Save, Load, Checkpoint, Autosave(seconds), Exists, Delete.
D262 Slots and files. save.Store keeps <slot>.aesave and an optional <slot>.png in saves/ next to the player profile, writes atomically and lists slots with their meta: name, game modules and versions, engine version, scene, date, playtime, object count, spawn counter. A save is captured on the main thread in the save.step system (Animation, PauseAlways), encoded and written by a worker, and reported back on the main thread. Loads go through OnMain. The editor's Saves panel lists slots, shows what a save holds and saves, loads and deletes during Play. The format is aetext save version=1.
D263 Rewind is deterministic replay. Play records the scene bytes and the world seed at start, and per editor frame the real step, time scale, pause, a pending step, which Break call of the frame paused it, and the game's input.State after capture (consecutive equal states are stored once). Seeking back restarts the play world from the recorded bytes with the same seed (engine.World.Random, used by begin and RunPhase when set) and runs TickWorld over the recorded frames without rendering, firing recorded breaks on the same call; seeking forward runs further recorded frames. While the editor holds the game paused (the Pause button, a break or a seek) the world does not tick and nothing is recorded until Step or Play, but queued saves and loads run at once (persistence.Flusher) and a load keeps the pause; a replayed frame never loads; a pause the game sets itself keeps ticking, since pause-mode systems and scripts run in it. While the cursor is in the past and the game is paused the world does not tick; playing or stepping from there truncates the recording and starts a new timeline. A play-scene edit or the memory limit (256 MB by default, keys included) ends the usable history at that frame and earlier frames stay reachable; an asset or script reload, live data tuning or a settings change drops the whole recording, because a restart would replay it with the new values. A module with module.Info.NonDeterministic turns rewind off. A step back restores the nearest keyframe and replays from there (D264–D277); keyframes change none of these rules. Tests replay platformer input in a fresh app (physics snapshots, scene bytes and script fields at frames 200 and 400) and rewind the editor's play controller across seeks, branches, cuts and a break inside a frame.
D264 core/rewind: Keyframer { Capture(reuse Key) Key; Restore(k Key); Hash(h *Hasher) }, Key { Size() int }, optional AfterRestorer and Provider { Keyframer() Keyframer } for owners whose method names are taken. The contract lives in core because scene, handle and clock implement it. A Key is immutable after Capture; reuse is an evicted key of the same service, so steady capture allocates nothing per element for flat component types and types without a Clone hook; Clone hooks (scripts, prefab instances, Unknown) allocate on every capture. Restore never reads other services; derived state is rebuilt in AfterRestore.
D265 Services split into owners and views. World.SetService registers an owner, World.SetView an adapter over another owner: script event sink, fields, waker, hub, lifecycle and persist; physics2d queries and the physics problems source; the inputactions reader; the settings view; the frame runner and the persistence host of the play controller. Every owner in a play world implements Keyframer or Provider; an owner without either turns keyframes off for that world, the timeline shows "keyframes off: ", and seeking falls back to replay from the start (9.4d2). fakephys implements Keyframer for its state.
D266 World time and randomness are part of every key, before any service: clock.Time.Capture/Restore(*TimeState), rng.Source.State/SetState. Capture and Restore then follow owner registration order: the scene first, then the script runtime and module services as they attach.
D267 A key is taken at a frame boundary in the play controller, after TickWorld returns and the scene is flushed: the first boundary where Tick ≥ last key tick + K (default K = 300). Replay runs whole recorded frames, so a mid-frame state could not be resumed. Key 0 is taken right after Start. A seek to frame n restores the latest key before n (key 0 for n = 0), unless the current state is already between that key and n, and replays the frames in between; replaying at least one frame redraws per-frame debug primitives with the current overlay settings.
D268 Scene keys keep the node arena exact: handle.Arena.CopyTo/RestoreFrom copy items, generations, alive flags and the free list, so every NodeHandle, ComponentRef and ScriptRef in captured state stays valid. Stores copy dense data, node lists and sparse indices with CloneInto (D270); stores created after a key are emptied on Restore. Presentation entries are kept and render slots recomputed. The interner and the VM statics are append-only and never shrink on Restore; hashes use names and handle values, not the tables. Revision and structure revision stay monotonic on Restore and are not hashed, so editor-side NodeRef caches never hit a stale handle. The tag index is rebuilt lazily; Restore emits HierarchyChanged.
D269 The play controller flushes structural scene changes after every TickWorld, in live play and in replay alike, so a frame boundary never has pending operations. RenderPrepare keeps its Flush; it then applies only edits made from the editor, and every such edit ends the history (D277).
D270 TypeDescriptor.CloneInto(dst, src) is derived from the Go type at registration: slices, arrays and structs that reach slices are copied deep, pointers, maps and interfaces in unexported fields are shared; a type without slices is marked Flat and copied by value. A field skipped with aego:"-" that reaches a slice, map, pointer, interface, func or chan needs schema.Clone(fn), or registration fails naming the type and the field. ScriptComponent, engine.Unknown and engine.PrefabInstance register one. Spawn templates, prefab expansion and keys use CloneInto, which also stops pooled spawns from sharing slices with their templates.
D271 World.Replaying() is true during Restore and replay. Services with effects outside the world check it themselves: savegame runs the capture (OnSave included) but writes nothing, audio will skip playback, file and network writers skip I/O. The recorder keeps writing during replay, marks records Replayed and does not arm its break trigger; the Event Monitor hides replayed records unless "Show replayed" is on.
D272 Script runtime key: instance arena, per-type value arenas and free lists, byNode, schedule lists, ready and spawn queues, event queue, VM heap (object elements packed into one Value buffer, lengths and capacities kept so allocation accounting replays exactly) and coroutines (registers packed the same way), scheduler records, heaps, await and handle tables and counters, signal registry entries and source index. Go handlers and Conn strings are shared by reference. The GC allocation counter is in the key and the VM options are hashed; options come from the project section and apply at the next Play, so they never change within a history. A key from before a script reload is refused.
D273 Physics key: world.SnapshotInto reuses the evicted snapshot's buffers and records the awake list, whose order drives the solver when bodies wake between steps; Restore keeps that order. Sim records are copied by value into the key; Restore reuses the record pointers and rebuilds the index maps, so physics behaviour and benchmarks are unchanged. Diagnostics are captured, not hashed.
D274 Module keys: tween, timer and action pools (curve keys and action steps packed into one buffer each); spawn service (members by value, templates and their component snapshots shared as immutable); input service (action states, context stack, devices, injections, replay cursor; an active input recording ends on a seek); savegame through Provider (service state plus the playtime, global progress, autosave interval and unknown sections its module holds per world); debug draw (primitives, points, channel counts, handle counter, pens), since scripts may keep draw handles. Channel visibility and the physics DebugVectors flag are editor view state: Restore keeps their current values, channels missing from the key are carried over, and hashes name channels instead of indexing them.
D275 The history budget defaults to 256 MB and counts frame records, input states and key sizes. Over budget, the oldest keys are dropped and the timeline starts at the oldest kept key (Floor); frames before it stay for the reboot fallback. The status shows the key count, their size, and the size and capture time of the last key.
D276 rewind.Hasher is a streaming SHA-256 over canonical game state written by the time, the random source and each Keyframer (no stats, caches, revisions, diagnostics or string tables). Two states are compared paused, since the pause flag is part of the time hash. Acceptance: a seek 10 000 ticks back with K = 300 replays at most 300 ticks and matches the hash of a linear replay; one capture on the platformer scene takes at most 1 ms (§26). aego test --determinism runs a scene twice and compares hashes. It covers the time, the random source and every owner with a Keyframer; owners without one are listed with -v, not failed. Runs use a fixed seed and frame step; the report names the first checkpoint where they differ. The scene root's NodeID is generated per scene and is not hashed.
D277 An edit during Play ends the history at the cursor; keys after it are dropped. An edit at frame 0 rebases instead: after the next flush the play scene becomes the new start, frames and keys are cleared and key 0 is taken again. Invalidation (code, assets, settings) drops every key; the next seek reboots with the current code, and a history ended at frame 0 starts recording again.

3.18 Stage 9.0 — Editor workflow

ID Decision
D278 Workspaces: a workspace is a named preset (panel slots over the registered descriptors, toolbar visibility) plus the user's own dock tree. Built-in Design, Programming, Art and Audio live in editor/shell; modules add theirs through ext.Registry.Workspace, prefixed with the module ID, ordered after the built-ins and removed when the module is disabled. Every workspace keeps its tree in .aego/user/workspaces.aetext; entries of modules that did not load survive a save. The current workspace is the workspace field of editor.aetext and travels in the supervisor handoff. Switching rebuilds only the dock tree: documents, selection and play state are shared. Ctrl+1…4 switch from any panel, a focused text field included, because digits with the primary modifier type nothing. Users save, rename and delete their own workspaces; built-in ones only reset.
D279 Maximize: Shift+Space (outside text input), a double click on a tab or the tab menu fills the window with one panel; the dock tree is untouched and comes back as it was. Showing another panel, closing the maximized one or switching the workspace restores. Full-screen Play: F11 starts or continues Play, puts the window in full screen, draws only the Game View and captures input; releasing the capture (D70, Shift+Esc or a click outside) or Stop leaves full screen and keeps Play running in the first case. Zen (code.zen) maximizes the active code panel and hides its path bar, breadcrumbs and minimap; it ends with the maximize. The Undo History panel lists the steps of the active document, one row per transaction, and moves there with a run of undo or redo; it refuses while a transaction is open or the history is locked.
D280 Previews: editor/core/preview renders scenes and prefabs offscreen on the editor GPU context in two render-lane steps of the task queue (draw, then read back on the next frame), decodes textures on a worker and keeps script heads and data type names for schematic tiles. The view is the scene's own active camera, else a fit-to-content 2D or 3D view; prefabs get a transparent background. The cache is .aego/cache/thumbs/<key>.png, the key being the artifact hash plus the hashes of the dependencies of scenes and prefabs; the old thumbnails/ cache is removed. Without a GPU scenes fall back to a schematic tile. The startup scene's preview is copied to .aego/cache/preview.png for the launcher. editor/core/tasks is the queue underneath: worker jobs, main and render steps within a frame budget, priorities and cancellation; Task Center (D285) builds on it. Template and example previews are preview.png files rendered by tools/genpreviews through make res under xvfb + llvmpipe; the tool refuses other rasterizers, writes a file only when its bytes change, and the files are never copied into new projects.
D281 Icon manifest: icon uses come from module registrations (components, templates, recorded by module.AuditUses) and from editor/core/iconuse.Editor, which a test keeps complete by parsing the editor, ui and module-editor sources for icon arguments. aego icons list [--json] [--out] prints the manifest; docs/icons.md is generated from it and a test keeps it current. aego icons check fails on used-but-missing and provided-but-unused icons; engine icons count as unused only in the full check with the editor list, so the native project helper keeps its module-only scope. Tabs show the panel icon (workspace.Iconed or Desc.Icon), the name without extension, the parent folder when two open files share a name, and a dot for unsaved changes (workspace.Dirtier) instead of a title suffix.
D282 Multi-object editing, component sets and presets. The Inspector shows the components every selected node has. Fields that differ show "—"; their tooltip lists the distinct values with counts (first 64 nodes, 6 values). Every edit goes to all selected nodes in one History transaction. Discrete edits (a click, a typed value, a menu item, a paste, a preset) on more than 10 nodes ask first and name the nodes; drags and gizmo moves never ask. Add, Remove and Reset Component act on all selected nodes. Add skips nodes that already have the component. Clipboard: one component (values or the whole component) and a set of components (application/x-aego-components). Pasting onto a node without the component adds it; onto a node with it, overwrites the editable fields. Presets are .aepreset assets in assets/presets/, kind preset, importer preset. The file is a one-node prefab fragment holding exactly one component or one script attachment, so asset references stay dependencies. "Save as Preset…" and "Apply Preset" sit in the component and script menus, filtered by type. Applying is one History step on all selected nodes; a script preset attaches the script where it is missing. Presets of scripts that do not compile cannot be applied. Reset per field: "Reset to Default" (schema default, then inspector defaults, then zero), "Reset to Prefab Value" on prefab overrides, "Reset to Base Value" on data assets with a base. Lock Inspector keeps the locked nodes and their document across selection and document switches. The lock clears itself when the document closes or all nodes are gone. Favorites (.aego/user/favorites.aetext) hold assets (by ID), nodes (scene path + NodeID) and commands. The Favorites panel, the asset menu, the Hierarchy menu and Ctrl+D in the command palette add them. Favorites lead the "All" tab of the palette. Selection history: every explicit Set/Add/Remove/Toggle is a step, at most 64. Prune and DropDoc (undo, Stop, closing) are not steps; empty selections are not steps. Alt+←/→ walks it, activating the step's document and skipping steps whose document is closed.
D283 Play Changes Diff, isolation, viewport capture. On Stop the editor compares the Play scene with its edit scene, for nodes present in both: the transform (2D or 3D, as a whole) and every editable field of every component and script both sides have. Fields that are ReadOnly, Runtime or hidden are never listed. Nothing is checked by default, except the keys changed through the Inspector or gizmos during Play: SetProperty, SetTransform and SetTransform3D commands found in the Play history. Nodes created or removed during Play are reported and never transferred. "Apply to scene" is one History step on the edit document. The dialog shows when there are changes (default), always, or never; the choice is stored in editor.aetext as play_diff. Isolation (Shift+H) hides every node outside the isolated subtrees in the Scene View only: the frame runner skips them in extraction (Runner.SetHidden), picking and box selection skip them (shape.Set.Hide). It is per-document view state, never serialized, and does not touch Game View or Play. Viewport capture (Shift+F12) reads the Scene View render target back and writes it to .aego/captures/<scene>-<time>.png. It holds what the Scene View renders into its target; gizmos and selection outlines are drawn by the UI on top and are not part of the image.
D284 Asset references. The reverse map is built on demand from asset.Entry.Deps, which every importer of a document writes (scene, prefab, data, table, preset); project.aego adds itself as a referrer of mainScene, and dirty open documents add themselves by scanning live values. Places are found lazily: open documents through schema access on the live scene, closed files by walking aetext values. Replacing a reference edits open documents in one History transaction each (prefab instances only where the field is overridden) and rewrites closed files by replacing the asset("<id>") token, after copying each original to .aego/recovery/refs-<time>/<path>; changed files are reimported and the work shows as one Task Center entry listing the files; closed files have no Undo. Safe Delete lists the referrers and offers Show References (replace or clear there, then delete), Delete Anyway or Cancel. The pipeline keeps a graveyard (.aego/cache/graveyard.aetext, at most 2000 entries: id, last path, kind, time) written whenever scan loses a source, so Safe Delete, outside deletions and index repair all record it; Missing References groups unknown ids with their referrers, suggests an asset with the same file name and kind, and replaces or clears; missing node references inside scenes and prefabs can be selected or cleared. Batch Rename plans names from a pattern ({name}, {n}, {n:03}, {parent}, {ext}), literal or regex find/replace and case, refuses empty, invalid and colliding names (case-insensitive for assets), renames assets through assetops.Ops (two-phase through temporary names when the batch renames into its own names) and nodes in one transaction. References from scripts by string path are not detected.
D285 Background work and project health. editor/core/tasks.Queue runs worker jobs and budgeted main and render steps; tasks carry title, kind, detail, progress, state and error, and the last 50 finished ones (successful previews excluded) stay listed. Task Center shows them with Cancel; the status bar shows a spinner and the count of non-preview tasks, or failed ones, and opens Task Center. Project Health runs on demand: import errors, problems by owner, missing references, unused assets (roots: the startup scene, every scene and prefab, every script, assets/presets and excluded paths; reached through Deps), scenes nothing references, names that differ only in case, then on a worker textures larger than 4096 px and scenes whose artifact holds no camera component (a camera that comes from a prefab is not seen, so this is informational). Opening a scene records its load time and node count; the panel shows them with the scene's asset and missing counts. editor/core/vcs.Provider (Root, Show, Status, Log) has one git implementation through os/exec, inside the project's repository only. Structural Diff compares the working copy with HEAD or one of the last 50 commits of the file: scenes and prefabs as trees matched by NodeID (added, removed, moved, reordered, renamed nodes, node attributes, components, fields, nested script and override blocks), other documents field by field; unsaved changes of an open document are not part of it.

3.19 Stage 9.5a — Export

ID Decision
D286 Export. aego export (app/export, dev-only) builds the runtime binary and copies game data into a distributable directory. The binary is cmd/aego-runtime, or for a native project a main generated in .aego/generated/runtime, built with -tags aego_runtime on the developer machine; the result needs neither Go nor the project. The runtime host is runtime/player, a new group runtime beside app: it composes app/assembly/builtin.Runtime(), the loader, settings, scripts linked from loader.Scripts() and the module lifecycle without pipeline, workspace, compiler or docs. Data is data/manifest.aetext (runtime/bundle: engine, profile, target, the game block with name, module, version, start scene, window, defaults and project sections, and the asset index) and data/artifacts/<sha>.<kind>, content-addressed, so no file looks like a source. Presets are not exported. vfs/exportfs mounts the scheme data:// and opens only files the manifest lists with their size. export.aetext beside the game records engine and game versions, target, profile, modules, binary and size, libraries (SDL3 static, license copied to licenses/), start scene, data sizes by kind, content hash and build time; the same numbers are the Build Report of the CLI (--json) and the editor. Profiles: development keeps stderr logs and debug draw; release builds with -trimpath -s -w (-H=windowsgui on Windows), writes debug { enabled=false } into the exported sections and logs to the profile. Player data stays in platform.ProfileDir(module, profile): settings, saves, logs/, crash/ (crash.Bundle.Dir). aego run is unchanged.
D287 Targets. host always; linux, windows and darwin when Go, a C compiler for the target (--cc, CC_<OS>_<ARCH>) and SDL for the target in .cache/native/<os>_<arch> exist, otherwise the export stops before building with the command to run; web is 9.5c. Windows: app/export/winres writes a COFF .rsrc object (rsrc_windows_<arch>.syso) with PNG icon frames from project.aego icon=, version info from gameVersion and a PerMonitorV2 manifest. macOS: <Name>.app with Info.plist (bundle id dev.aego.<module>), PkgInfo, icon.icns and the data in Contents/Resources/data; ad-hoc signed when codesign exists. Linux: binary, data/, .desktop and icon.png; SDL is static, so no run.sh. The editor on macOS mirrors its command menus into the system menu bar (platform.MenuBar, implemented by platform/sdl on darwin with an NSMenu subclass that never consumes key equivalents, so shortcuts keep their editor scopes); make editor-app bundles it with tools/appbundle.
D288 Pre-export checks run before any build on a pipeline snapshot (export.Take in the editor, export.Fresh from the CLI): script errors (EXP002), import failures (EXP003), duplicate .meta ids (EXP006), a missing or non-scene start scene (EXP001), dependencies that resolve neither to an exported asset nor to a builtin (EXP004, an error in release, a warning in development), an icon that is not a PNG (EXP005), asset validators of the runtime catalog, and loading every scene through sceneio (EXP007, unknown components EXP008 unless the project is native). Diagnostics are engine/problems with owner export; any error blocks the export, the CLI exits with 2. Project Health lists release blockers.

3.20 Stage 9.5b — Web runtime

ID Decision
D289 Web platform. platform/web (js/wasm, syscall/js) implements platform.Platform over one <canvas>: the one in web.Options, else canvas[data-aego], else a new canvas appended to the body. The logical size is the canvas CSS box; the pixel size is CSS × devicePixelRatio, written back to canvas.width/height; a ResizeObserver (window resize as fallback) and a changed devicePixelRatio emit EvWindowResized; PixelDensity and DisplayScale are devicePixelRatio. Input: KeyboardEvent.code → scancode, key → keycode and text (no IME composition), pointer events on the canvas with pointer capture, primary pointer only (one-finger touch acts as the left button; multi-touch is Stage 18), wheel by deltaMode (100 px = one line, a page = 3 lines; a pixel delta that is not a whole line is precise, 10 px per unit, so smooth wheels and touchpads scroll the distance the page would. Amended 2026-10-20: it was divided by 100 too, which made such scrolling ten times slower than the browser's), focus and blur reset held state (9.2), gamepads polled through navigator.getGamepads() in the standard mapping while one is connected. The canvas takes keyboard focus on click; while focused it prevents the default of every key except browser shortcuts (Ctrl/Cmd combinations, F5, F11, F12). Pointer lock and fullscreen, which browsers grant only inside a user gesture, are queued and issued on the next pointer or key press; leaving them is immediate. Time is performance.now() and stops while the page is hidden. The loop is platform.Driver: Engine.Run hands Step to requestAnimationFrame, so VSync is always on and there is no frame limiter. Clipboard writes go to navigator.clipboard.writeText and an in-memory copy; reads return the copy, refreshed by paste events. Files go through platform.UserFiles() (localStorage), so profiles and saves work unchanged; the window is not a platform.Displayer, so display settings have nothing to apply. Translation code (scancodes, wheel, pads) lives in untagged files and is tested natively.
D290 WebGL2 backend. gpu/webgl2 implements gpu.Device on WebGL2RenderingContext, reached through webgl2.Surface on the window. engine/backends_js.go selects it, engine/backends_native.go selects gl33; a window with neither gets the null backend. GL enums are numeric constants; JS objects live in a table indexed by uint32, so gpu/internal/glstate (moved from gl33) caches state for both backends. Context methods are bound once; every call goes through one counter (Device.Calls() = calls of the last frame). WebGL2 has no sRGB default framebuffer, so the window pass renders into an SRGB8_ALPHA8 texture with a depth-stencil renderbuffer and EndFrame draws it to the canvas with a fullscreen triangle that encodes linear to sRGB; orientation follows D17 (the backbuffer is the window: viewport and scissor flipped, offscreen targets GL-native). Shaders: the renderer loads engine://shaders/es/ when Capabilities.ESSL is set; render/shaderx translates the GLSL 330 sources at build time (make shaders, part of make res), a test keeps the committed copies current and validates them with glslangValidator when installed; there is no translator at run time. Timer queries use EXT_disjoint_timer_query_webgl2 when present (a disjoint result reads as 0), otherwise the GPU lane stays empty. Context loss is handled inside the device: it keeps descriptors, shader sources and CPU copies of static buffers and textures with data, and on webglcontextrestored recreates every object under the same table id, so handles held above stay valid and nothing above the device reloads; render-target contents and dynamic buffers are refilled by the next frame. Uploads copy through one growing Uint8Array. The default build never compiles syscall/js: depcheck requires a js-only build constraint or a _js file name on every importer.
D291 Playground runtime. One page holds the code editor, a Game View (canvas[data-aego]) and Console/Problems. The catalog app/assembly/playground is core, input, render2d, scripting, physics and debug (physics joins the playground graph, superseding D205 on that point; render3d stays out). Console examples (docs snippets, the donut) run headless as before. Scene examples come from app/playground/examples, a regular project (module game) embedded in the binary and checked by aego check in make check-all: its project.aego gives the project sections once at start, and each scenes/<name>.aescene with scripts/<name>/ is one example. Run compiles the edited package in the same workspace, links a fresh script runtime and loads the scene through sceneio with a loader over an empty index, so only builtin assets resolve. One engine per page is built on the first Run on platform/web and gpu/webgl2 and stays on requestAnimationFrame; Run and Stop swap the scene, the runtime and the module attachment of the main world. The page polls poll every 250 ms for log lines, faults and frame statistics (frame time, draw calls, WebGL calls of the last frame). The game owns the keyboard and pads while the canvas has focus, Escape included; a click elsewhere releases it. bench draws N sprites straight through the renderer for measurements and the screenshot golden, loseContext drives WEBGL_lose_context. Budgets are targets: aego.wasm ≤ 8 MB gzip (the site build fails above it), cold start ≤ 2 s on a laptop. make playground-size reports code size per package from the wasm name section (tools/wasmsize). make web-smoke runs Playwright with the system Chromium on SwiftShader: start-up time, a golden screenshot of bench 64, the same picture after a context loss, every scene example for 2 s without faults, and the 1000-sprite bench written to .cache/web-bench.json.
D292 Documentation IA. One registry feeds the site, the editor's docs panel and aego docs export (version 2, with groups). Sections and groups: Getting started; Guides (Editor, Aego Script, Scenes & Prefabs, Gameplay, Physics 2D, Rendering, Extending Aego, Tools & Agents); Reference (Script API, Components, Project settings, Diagnostics, CLI, Project). A root Markdown page declares group: (a guide group) and order:, children inherit the group of their root. The kernel lists only the module-independent groups; a module adds its own guide group from its root page with group_title: and group_order: (Physics 2D comes from modules/physics, so engine/docs never names a module and needs no literal waiver under D212). Reference groups are filled from the registry: script modules are roots of Script API ordered by the owning module's DocsOrder; components hang under one page per module, created only for modules with components and titled module.Info.DisplayName (the core module is "Core"); diagnostic codes under Diagnostics; documented project scripts under Project. Entry IDs are unchanged, so ref: links keep working. aego docs check fails on a root page without a guide group or an order and on a built-in module without a DisplayName. module.DocSource.Dir, taken from the module's package path (Unwrap sees through catalog wrappers), gives every page its repository path for "Edit this page"; aego docs build points those links at the checked-out branch (master when git is unavailable). gendocs renders sections with collapsible groups whose open state the browser remembers, the current page highlighted with its group and ancestors open, breadcrumbs (section, group, ancestors), an on-this-page list from the h2/h3 anchors, previous/next in navigation order, search grouped by section, a footer with the engine version, the commit and links to the source, the playground and the page's file, a landing page with three entry points and docs/hero.png when present, and llms.txt. A text outline of the navigation is the golden of the gendocs tests, for a fixture and for the real export. The playground and the site share one set of colour tokens. Amended in 9.5b3b: component pages come from the schema: a header with category, module, space, scene uniqueness, required components and the Create path; a field table with type, the default read from the registered default, range and unit, notes (editor or script visibility, read-only, Play only, saved, live, choice and bitset sources, enum values, gizmo) and the field doc; component methods (manifest.CompMethods) and signals as members (a method that shares its name with a field gets the ID and title Name()); the script events of the owning module, listed under Script API → Script events with owners from module.Manifest.EventOwners; related guides (pages linking to the component or its fields, and root guides of the same module); an empty Try it slot. Project settings has one page per project section with its fields and default block (Section.DefaultValue, Section.DefaultBlock; a block that encodes to nothing is left out, and the section page lists the owning module's guides as related) and a Player settings page built from the settings registry. CLI is the authored page cli.md in group ref-cli, the only reference group a page may declare; a test keeps it listing every command of cmd/aego. A script module aego/<id> belongs to module <id> when no owner is registered. The editor's docs panel shows the same sections and groups. Amended 2026-10-20 (9.7): entries and groups carry an editor icon name (Entry.Icon, Group.Icon, module.Info.DocsIcon, group_icon:), and gendocs builds one SVG sprite from the editor icon sources and fails on an unknown name; the site uses the editor theme, and code is highlighted at build time with the editor tokenizer and palette, identifiers linked to their entries. Short names resolve everywhere: Registry.Aliases maps a last segment that names one entry (Body2D, ApplyForce, timer.Timer) to it, used by Registry.Link in the docs panel and exported for gendocs. Getting started is the install page; the editor tour moved to the Editor group with its IDs. module.Info.DocsGroup documents a module's components in a guide group instead of under Components (the Script component is in Aego Script). Script API module pages list their functions with signatures above the sections, with a filter; a function without its own example links to the module's runnable example. A ```aes check block is compiled but not run: component pages end with "From a script" examples compiled against the full builtin catalog (playground.NewWith) by the playground tests.
D293 Docs session. editor/core/docsession.Session serves the real Inspector and Game View without the editor shell: one scene from bytes, its own document.Manager holding the edited document (the Inspector's services.Documents), a second manager with a document over the same scene for play.Controller, which swaps in its play proxy during Play, so the Inspector always edits authored values; a selection.Service keyed by that document; a minimal context.Editor (engine, renderer, loader, types, commands, problems, an in-memory clipboard) and Unavailable for asset and scene operations. History works as in the editor; a committed change saves the scene to text (OnChange) and restarts Play, keeping a pause; Reset reloads the original bytes. app/docsview hosts it: the sandbox catalog, LoopScope editor, one canvas with a toolbar (Play/Pause, Reset, Undo, Redo), Game View and Inspector side by side (stacked below 640 px), a script host that links a sandboxed runtime and attaches the modules to the play world, and a JSON bridge (load, reset, set, undo, redo, state, log). cmd/aego-try builds it as try/aego-docs.wasm (js && aego_editor). The graph excludes SDL, gl33, the shell, every panel but the two, and the script importer; asset/pipeline stays because editor/core/context, assetops and the preset importer import it, and the Inspector gets no pipeline. Module editor extensions (custom inspectors) are not loaded.
D294 Try it and the sandbox. A module's res/docs/try/<Name>.aescene becomes the try scene of the component whose ID ends in .<Name> (preferring the module's own), a guide page can name one with try: and try_select:; the registry carries Try and TrySelect and the site renders a Try it block when the playground is built: the scene text, Copy scene, Reset, and a button that lazily loads the one docs runtime per site, moves its single canvas into the block and loads the scene there, selecting the documented component; Inspector edits rewrite the shown text. aego docs check runs every try scene on the sandbox catalog for 60 headless frames and fails on a load error, a warning, a fault or a scene from a module outside the sandbox. Sandbox (playground and docs runtime): the catalog is the allowlist of host modules (core, input, render2d, scripting, physics, debug and, since 9.7, tween and render3d with the built-in meshes, whose OBJ importer is left out of web builds; no save, settings, spawn or audio), user files are in memory or localStorage only, there is no file or network access; scripts run with 2M instructions per call and a 64 MB VM heap (vm.Options.MaxHeap, checked in the allocator while the machine runs and reported as CodeBudget, garbage included until the next collection); a scene above 10 000 nodes stops the game with an error. aego-docs.wasm is held to 10 MB gzip by make site (make try-size reports it per package). Try scenes with scripts and the playground's Inspect toggle come later. Amended 2026-10-20 (9.7): a console run (aes run, the playground Run) attaches the sandbox modules to its world as Play does and steps it with TickWorld on a windowless engine, so tweens, timers, actions, handles in yield and physics behave as in Play; before, scripts were stepped by hand and module calls failed with AES6010.
D295 Web export. aego export --target web (js/wasm, shown as web) builds cmd/aego-runtime with GOOS=js GOARCH=wasm CGO_ENABLED=0 and the runtime tag, release with -trimpath -s -w, and packs index.html, loader.js (both embedded in app/export/web), the toolchain's wasm_exec.js with its license, game.wasm and data/ with the 9.5a manifest and artifacts plus files.json. The page script loads the data, not Go: it fetches files.json, then the wasm, the manifest and the artifacts six at a time with one progress bar over the uncompressed sizes, and hands the bytes to the runtime in globalThis.aegoData; runtime/player.Main under js mounts them with vfs/memfs after checking every manifest entry and size, as exportfs does on desktop. Under js the player has no log or crash files and no signal handling (hostFiles); the profile and saves go through platform.UserFiles() (localStorage). URL parameters replace the command-line flags; globalThis.aegoGame exposes the frame count and profile file access for tests. Files that compress by more than 10% get .gz (and .br when brotli exists); export.aetext records binaryGzip. Web-specific checks: EXP010 (asset kind the web runtime cannot load, none yet), EXP011 (native module must build without cgo). make site exports the platformer to site/demo/, the landing page links it (aego docs build --demo), and fails when its game.wasm is over 8 MB gzip; make web-smoke runs it for 120 frames against a screenshot golden and checks that a profile file survives a reload. make test-wasm builds the web runtime and rejects native, pipeline and editor packages in its graph.

3.21 Stage 9.6 — Agent tooling

ID Decision
D296 Mutation service. scene/mutate edits a scene or prefab document held in memory without the editor: Load from bytes through sceneio, Bytes writes through the same codec. An operation is data (Op: kind, node, parent, name, component, path, value as aetext.Value or text, index, fragment, values block) applied by Document.Apply(Tx); each operation returns its inverse addressed by node ID (#<id>, the root as Root because its ID is not saved), so Result.Inverse is exact even when the transaction renamed or moved nodes and still applies after the file is reloaded, and Apply(tx) then Apply(Inverse) gives the original bytes (tested on every operation). A failing operation rolls back the ones before it, so a failed Apply leaves the document unchanged; DryRun applies and rolls back and returns the same changes and problems. Addressing: a node by path from the root (Root/Player/Sprite, Player/Sprite) or #<id>; a component by full name, unique suffix (Body2D) or display name, an ambiguous suffix is an error listing the candidates; a field path Comp.field, `Comp.field.x
D297 External changes in the editor. Scenes and prefabs open in the editor are checked once a second and when the window gets focus (document.Manager.CheckExternal): a document without unsaved changes reloads, one with unsaved changes raises the Reload / Keep mine conflict. Data assets and tables reread their file when the asset revision changes; with unsaved changes they keep the user's version and warn once per disk version, Save overwrites and Revert loads the disk. A prefab file changed outside the editor refreshes its instances in open scenes: ApplyBatch asks prefabs.Service.External for the instance blocks under the old library before the loader swaps and replaces the instances after it, keeping node IDs, overrides, history and the clean state; the editor's own Apply and prefab save skip this (they reimport under writing), and instances in a scene being played follow on the next change. This is what lets aego mcp, the CLI and the editor work on one project at the same time.
D298 Project refactorings without the editor. project/refs finds what references an asset from the pipeline index (Entry.Deps) and mainScene, locates the places in a file from its text (node path, owner, field, list index), lists missing assets (with the graveyard entry and a same-name suggestion of the same kind) and node references to nodes a scene does not define; it reads through refs.Source, which *pipeline.Pipeline satisfies. An ID counts as known when it is a builtin, has a file, or is in the index without a grave, so the page artifact of a multi-file script package is not reported as missing. project/refactor plans and applies: PlanReplace collects the referring files, Edits computes their new bytes by replacing the asset("<id>") token (overrides, data assets, tables and presets included, which is why it is not a mutate.Tx), Write backs the originals up under .aego/recovery/refs-<time> and writes atomically, ApplyProject moves mainScene, Reimport reimports what changed; Users lists referrers outside a deleted set (safe delete), Delete deletes through the pipeline, whose scan buries the IDs in the graveyard; Move moves an asset with its .meta and rewrites the package line of a script that changes package; PlanRename, RenameOps (with the temporary-name pass when names swap) and ApplyOps (rolled back on failure) carry batch rename. The rename rules live in project/rename. project.WriteAtomic is the one atomic writer (project.Save and document.WriteAtomic use it; files are written 0644). The editor's References, Missing References, Batch Rename and asset move are adapters: open documents are still edited through their history, files on disk go through project/refactor. Prefab Apply and Create From Node follow in 9.6c2. Amended in 9.6c2: prefab Apply and Create From Node work without the editor. refactor.PlanApply locates the instance (Locate), splits its overrides with prefab.Pick, bakes the selection into the prefab models (the baker moved from the editor; Nearest routes an apply to the nearest nested prefab) and returns the new models, the instance block before and after; ApplyPlan.Edits encodes the changed prefab files. ApplyPrefab sets the baked models into the document's library and replaces the instance root and every other prefab instance of the document through mutate, so the other instances pick up the new values and keep their overrides; Result.Inverse carries the blocks taken before the apply and is valid against the old prefab files. WriteAll writes the files and rolls back on failure. mutate.Document.Bytes saves through Env.Library, so overrides on instances are written. CheckCreate, LinkBlock and CreatePrefab turn a node into a prefab file (written, scanned, imported) and link the node to it keeping every node ID; a failure removes the file and its .meta. PrefabLibrary builds a library from the prefab sources, so tools need no loader. The editor's Apply and Create Prefab call the same functions and keep their history across documents. The data asset and table editors (editor/core/dataedit) edit a mutate.DataDocument: every change is a data-set, data-reset, data-base (new: the base asset, by ID or path), row-add, row-remove, row-restore or row-rename with exact inverses, consecutive edits of one field merge, a value equal to the inherited one (the base for a data asset, the type default for a table row) resets the field, and saving writes the document's own bytes, so untouched fields keep their text. Gizmo property drags go through commands.SetField after props.Normalize clamps them to the field range; commands.SetProperty is gone.
D299 Agent layer and CLI. app/agent is one catalog of commands (Command: name group.verb, typed arguments with positional order, write flag, Run(*Session, Args) (*Output, error)) that the CLI and the MCP server share; a command's MCP tool name and JSON schema come from the same definition. A Session loads types, modules, importers and the pipeline like app.Build without the engine, renderer or loader, publishes the compiled script types, builds the prefab library from sources (refactor.PrefabLibrary) and resolves scenes and prefabs from files; it edits through scene/mutate and project/refactor and writes atomically, then reimports and reloads the library. Commands: `scene tree
D300 MCP server. aego mcp (app/agent/mcp) speaks MCP over stdio, one JSON-RPC message per line, without a dependency: initialize (protocol 2025-06-18, older 2025-03-26 and 2024-11-05 echoed), ping, `tools/list
D301 Release builds. The release job of ci.yml runs after checks and build on every push: master publishes the rolling pre-release nightly (the release and its tag are recreated at the new commit), a v* tag publishes that release. `tools/release/release.sh [--dry-run] <vX.Y.Z
D302 Documentation screenshots. make shots opens a copy of examples/platformer in the real editor under a clean user profile with AEGO_SHOTS=<dir> (app/edit/shots.go: a fixed sequence of editor states, each after a fixed number of frames) and writes docs/shots/*.png, which are committed; make shots-check (part of make ci) regenerates them and tools/shotdiff fails when more than 3% of the pixels of any image moved by more than 24/255. Frames are read with Engine.CaptureFrame, a one-shot hook between EndFrame and the swap that reads the default framebuffer through the optional gpu.BackbufferReader (implemented by GL 3.3 only). Pages use them as ![alt](shots/name.png); gendocs copies the directory to assets/shots/, the editor's docs panel skips images, and docs/shots/demo.png is the poster of the landing page demo.
D303 Docs delivery. The landing page embeds the web demo as a click-to-play iframe over its poster and starts it at once for a visitor who has played it before (a localStorage mark). The Try it loader compiles aego-docs.wasm straight from the network response, so the browser keeps both the file and its compiled code in the HTTP cache, and counts a copy of the first download for a progress bar against the uncompressed size written into the page; a localStorage mark of the versioned URL tells later pages the runtime was started before, and they start the first Try it block that scrolls into view without waiting for a click (the Cache API was tried and dropped: responses rebuilt from it lose the compiled-code cache, and probing the HTTP cache with only-if-cached holds its lock on the same URL); the block shows the scene as a tree (sceneio.Outline, node → components → typed fields linked to their docs, values updated from the runtime and changed ones marked) with Open in editor (downloads the .aescene) and Copy as .aescene. The docs runtime shows the physics.shapes and physics.joints debug draw channels, so colliders and joints are visible without art. nginx serves every .wasm with a brotli or gzip copy made at image build, ?v= URLs as immutable for a year, HTML and JSON for 60 seconds (no-cache kept the edge proxy in front of the site from storing new pages, so it served stale ones), and install.sh as text; the web export preloads its game.wasm. Amended 2026-10-20: the site sits behind the edge proxy at 185.170.196.188, whose route to mainserv is slow (~0.2 MB/s) and drops, so the edge serves aego from its own copy (deploy/edge): the image carries __edge/site.tar.gz and __edge/version, the edge timer aego-wasm-sync fetches a new site archive with resumable downloads and switches /var/www/aego-site/current atomically, and copies each wasm into /var/www/aego-wasm/<version>/; anything not there yet goes to mainserv through the proxy, so a new ?v= never gets old bytes. Pages show the commit in the header, and styles, scripts and the icon sprite are loaded as ?v=<hash of their contents>, so a new revision reaches every browser within the 60 s HTML lifetime.
D304 Camera adjustment is a pull contract. contracts/camera2d declares Adjuster under camera2d.adjust. render2d.cameras builds the view of every active Camera 2D from its interpolated pose, then passes it through view = Adjust(node, view) when the play world has the service; the value form keeps the per-frame path free of allocations. Rendering never names the camera module and the camera module never names render systems (D204); the contract may import render/view (a depcheck exception). An output is evaluated once per camera.update tick; a second Camera 2D on the same output reuses the pose. The edit world has no adjuster, fixed views are never adjusted. The module ID camera collides with the scene view state field "camera", which is excepted in depcheck.
D305 Virtual cameras, priorities, blends and zones. Each output picks the enabled virtual camera with the highest priority; ties go to the lower node handle index, then zones over plain cameras, the later zone over the earlier, and BlendTo/Cut over everything until Release. Zones are derived each tick from the exact world position of the target (not the interpolated one), so entering is deterministic; they never write component fields. A switch blends from the current logical pose, zoom in log space; a replaced blend completes with finished=false. Blends are awaitable handles aego/camera.Blend. Outputs are chosen per Camera 2D by camera.CameraBrain2D; without it the camera shows the main output.
D306 Follow, shake, accessibility and time. Follow reads the interpolated target pose and runs in game time with exponential, rate-independent damping, a deadzone in view fractions, lookahead in seconds of velocity and snapping on teleports. Shake is trauma^exponent times deterministic gradient noise seeded by the output name; it never touches node transforms or the world RNG. Accessibility provides camera2d.accessibility with its setting IDs: Screen Shake scales shake, Reduce Motion disables shake and lookahead. Blends and shake follow the VC time domain (unscaled by default). The rewind hash covers active cameras, overrides, zones and game-domain blends and trauma; follow is captured but not hashed, unscaled blends finish on restore.

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/...
runtime runtime/...
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/... except app/assembly, app/assembly/builtin and app/crash, cmd/... except cmd/aego-runtime, tools/..., demos/..., tests/..., examples/..., modules/*/editor/..., modules/*/dev/...

4.2 Group rules

  • low, kernel ✗ modules, editor, sdk, app. This includes contracts/....
  • 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/ext only; 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 file sdk/editor.go.
  • low, kernel, modules, editor, sdk ✗ runtime; runtime ✗ editor and module editors.
  • Runtime graph (aego_runtime, every package minus dev-only): no package reaches a dev-only package, transitively.
  • scene/prefab/author is dev-only; the runtime graph keeps scene/prefab until Stage 13 (D218).
  • Playground graph (js/wasm, cmd/aego-playground): only low and kernel packages without gpu/gl33, platform/sdl, asset/pipeline, asset/importers, plus modules/render2d, modules/scripting/..., modules/input, modules/physics/..., modules/debug, platform/web, gpu/webgl2, app/assembly, app/assembly/playground, app/playground.
  • Literals: kernel, editor (core, ext, panels), sdk and project contain 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/input is a leaf input model; platform imports it, never the reverse. platform.ApplyInput converts events to state.
  • render/... never imports platform, engine, ui, scene or editor; render/host is the only engine-integration package.
  • ui/... may import render, platform/input and format/aetext, not the platform backend or engine.
  • schema/... depends only on core; references are recognized through schema-facing contracts.
  • scene/... never imports editor, asset/pipeline or script; it resolves assets only through asset.Resolver. scene/sceneio owns source serialization; scene does not import it.
  • asset/loader is runtime-safe and never imports asset/pipeline.
  • script/... may use schema, scene and engine services, never editor. The editor reaches the script toolchain only from editor/panels/scripteditor (script/{langsvc,workspace,source,diag}, D129) and editor/core/code (script/token); everything else works through context.ScriptHost implemented in app/edit.
  • User native/ code imports only aego/sdk.
  • core/stats is a leaf; renderer counters stay in render.Stats.
  • cgo and raw OS access are restricted to approved packages (D54; import "C" only in platform/sdl and gpu/gl33/gl).
  • engine/module imports engine, schema, scene, script/bind, never editor, app or dev-only packages; scene/... and schema never import engine/module.
  • app is development-only: it owns Build, which wires types, bindings, modules, importers, pipeline and loader for aego run and the editor.
  • modules/physics/world imports only core/...; modules/physics/components only core, schema, scene; modules/physics/systems adds world, components, contracts, engine; modules/physics/stdlib adds systems and script/{abi,bind,vm}, engine/module.
  • contracts/... import core, scene, script/abi, script/vm.
  • platform/web imports only core, platform, platform/input; gpu/webgl2 only core, gpu, gpu/internal/.... syscall/js is imported only from files that build for js alone.

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 of script/*, format/aetext; app/assembly/builtin → script/bind/manifest.
  • script/workspace → the dev layer of script/*, 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). The wasm graph is checked with the same group and layer rules as the native variants. 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)   rewind (9.4d)
├── platform/        platform.go event.go ext.go  input/ sdl/ headless/          (1)   web/ (9.5b)   android/ (14)
├── gpu/             device.go gpu.go  gl33/ (gl/ generated)  internal/glstate  webgl2/ (9.5b)  null/ (1)   gles30/ shaderc/ (12–14)
├── vfs/             vfs.go  osfs/                                                (1)   exportfs/ (9.5a)   memfs/ (9.5c)   pakfs/ assetfs/ (13–14)
├── engine/          engine registry world service config loop jobs app          (1)
│                    res/ (2, engine://)  module/ (6.5)  base/ problems/ (8C)  settings/ settings/settingsui/ (9.3)
├── render/          render pass viewframe pass3d viewframe3d  view/ texture/ sprite/ culling/ debug/ text/
│                    gpuprof/ rt/ mesh/ icons/ host/                              (2, 4.5)   shaderx/ (9.5b)   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)
├── data/            data types, records, tables, CSV, weighted, library           (9.4c)
├── save/            save format, slots, persistent ids                           (9.4d)
├── 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)   importers/datafile (9.4c)
├── scene/           scene node store query names space transform events overlay register
│                    sceneio/ (3, 4.5, 5)  frame/ scriptcomp/ (6.5)  prefab/ prefab/author/ (8D)   mutate/ (9.6)
├── 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 debugdraw inputactions  scriptwait scriptfields signals scriptpool persistence  (8A, 9.1, 9.2, 9.4)
├── modules/         built-in modules, one package tree each                      (8C)
│   ├── input/       module config section service record script editor/ res/      (9.2)
│   ├── display/ audio/ accessibility/ settings/                                   (9.3)
│   ├── tween/       module service tweens timers actions                          (9.4)
│   ├── spawn/       module service pools script                                   (9.4b)
│   ├── dataasset/   module dev validate                                           (9.4c)
│   ├── savegame/    module service capture apply refs script                      (9.4d)
│   ├── render2d/    module components systems  editor/                           (6.5)
│   ├── render3d/    module components systems dev  editor/                       (6.5)
│   ├── scripting/   module events methods  systems/ stdlib/ (scene)               (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)   dataedit (9.4c)   docsession (9.5c)
│   ├── 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)   datatable (9.4c)   saves (9.4d)
│   └── 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)   docsview/ (9.5c)
│                    export/{icon,winres,macos,web}/                              (9.5a, web 9.5c)
├── runtime/         bundle/ (manifest formats) player/ (exported game host)      (9.5a)
├── cmd/             sandbox/ aego/ aego-editor/                                  (1, 3, 4)   aego-runtime/ (9.5a)   aego-try/ (9.5c)
├── audio/ (9)  anim/ (11)  net/ store/ (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)   appbundle/ (9.5a)   genshaders/ (9.5b)
└── 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
data/manifest.aetext Exported game data: game block, sections, asset index canonical aetext
export.aetext Export Manifest beside the exported game canonical aetext
.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, samplers tex0..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; EndQuery in 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      world clock Begin (Real, Game, Fixed, FrameID), recorder stamp, drain OnMain, debug.expire, script.swap
PollEvents      platform events, input.Apply, metrics
Input
FixedBegin      × fixed steps (≤ maxFixedSteps; FixedTickID, recorder stamp; World.Break after each): PrePhysics (script.fixed) · Physics (app.fixed, physics.sync, physics.step, physics.writeback, physics.events) · PostPhysics (script.events_fixed, physics.debug)
FixedEnd
PreUpdate · Update (app.update, script.update, script.coroutines) · PostUpdate (tween.game, tween.unscaled)
Animation       spawn.park (always)
RenderPrepare   structural flush, physics.present, display.scale, engine.transform, render2d.cameras, render3d.cameras
Render          render2d.extract, render3d.extract, engine.views_submit, debug.draw, app.render, passes
RenderUI        engine.present, overlays (settings.draw), engine.overlays, UI / editor overlays, render.end
gpu.EndFrame, swap
FrameEnd        stats.end

Reserved system IDs: app.fixed app.update app.render engine.transform engine.views_submit engine.present engine.overlays 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, gamepads (up to four, raw buttons and axes in platform/input). Focus loss and a removed gamepad release held state. Text fields use platform text/IME events. Games read actions from the input module (D234): bindings, composites, interactions, contexts, players, buffers, recording and replay; modules contribute actions through provisions (D235).


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 facades sceneops and assetfiles. context.Editor implements the services and is the composition root; only the shell, editor/ext and 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 gets Editor bound at registration.
  • editor/panels/<name>: one package per panel. A panel draws through Draw(f *services.Frame, r), takes its services in a Deps constructor, and never imports another panel, the shell or context.Editor (D216, depcheck panel-imports-panel, tests/modulecheck/panels_test.go).
  • editor/shell: application loop, menus, toolbar, modals, the panel catalog panels.go and the wiring of every Deps. Panels are registered with descriptors; the default layout, the Window menu and Show derive 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.
  • js/wasm: platform/web + gpu/webgl2 (D289–D290); aego_nonative still links no backend.

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, make test-wasm (engine on the null backend under Node).

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 made Coroutines2k 18% 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 and ReadPixels stopped 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 (TestTallColumnStands checks 12). The stiff profile ContactHertz 60 + Substeps 8 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.

Stage 9.4d3 (keyframes, D264–D277): on the platformer a key is about 65 KB (8 owners: scene, scripts, input, physics, debug draw, tween, spawn, save) and one capture takes a median of 0.05–0.07 ms (TestKeyframeCaptureTime, Timeline().KeyTime over 100 captures, limit 1 ms). With K=300 a seek replays at most 300 frames and lands on the state of the linear run (TestKeyframeSeekMatchesLinearRun, 10 000 frames).


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.1 Time and debug: .meta reconcile, world clocks with time domains, pause, step and scale, frame and tick ids, debug draw service and module, event recorder, Event Monitor and Watch, crash bundles, memory and resource profiler sections
9.2 Input: gamepads in the SDL bridge, modules/input (actions, contexts, players, buffer, interactions, injection, record and replay, rebinding, migration of input.axes), Project Settings → Input, Input Devices with a virtual gamepad, Input Focus, module provisions
9.3 Settings: engine/settings registry, layers, transactions, confirm, presets, profile and project codecs, platform profiles and displays, frame limit, modules display, audio (stub), accessibility, settings (screen, aego/settings), input rebinds in the profile, Project Settings Defaults and Registry
9.4a Curves, tween, timers (core/curve, modules/tween, handles and yield h, aego/curve|tween|timer); done
9.4b–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.
9.5a Export v1: aego export, runtime/player, runtime/bundle, vfs/exportfs, Windows resources, macOS app bundles, pre-export checks, Build Report, editor Export panel, native macOS menu bar, make editor-app
9.5b Web runtime: platform/web, gpu/webgl2, ES shaders and render/shaderx, platform.Driver, platform.UserFS, playground with Game View, docs IA
9.5c Web export, docs session with Inspector and Game View, Try it, playground sandbox
9.6 Agent tooling: scene/mutate (transactions with exact inverses and redo, data documents), project/refs, project/refactor, project/rename, editor structure edits through mutate, app/agent command catalog and aego <group> <verb> CLI, aego mcp, external edits in the editor
9.7 Docs and release: editor theme and icons on the site, build-time highlighting with links, short-name references, Try it progress, cache and scene tree, Try scenes for every 2D physics component, script examples on component pages, Script API index and module examples, diagnostics with checked examples, install page, release builds and install script, editor screenshots; done
13+ Export/pak, networking/store, Android

Stage 9 complete

ID Decision Stage
D227 Prefab mode 8D
D228 .meta reconcile 9.1
D229 Time domains live in core/clock.Time, one per World 9.1
D230 Pause and step 9.1
D231 Debug draw 9.1
D232 Event recorder 9.1
D233 Crash bundles 9.1
D234 Input actions live in the input module 9.2–9.3
D235 Module provisions 9.2–9.3
D236 Settings registry 9.2–9.3
D237 Storage 9.2–9.3
D238 Built-in settings modules 9.2–9.3
D239 core/curve depends only on core/mathx 9.4a
D240 schema.KindCurve holds a curve.Curve by value in components and data 9.4a
D241 Script view of a curve 9.4a
D242 Host handle types 9.4a
D243 modules/tween is a built-in module with a per-play-world service tween.service 9.4a
D244 Timers live in the same module 9.4a
D245 One connection registry for every signal source 9.4b
D246 Component signals 9.4b
D247 Handle signals 9.4b
D248 Tags 9.4b
D249 Queries fill a caller slice and return the count 9.4b
D250 Spawn and pools live in the built-in module spawn (play-world service) 9.4b
D251 Actions compose tweens and timers 9.4b
D252 Data types 9.4c
D253 Base and overrides 9.4c
D254 Tables 9.4c
D255 Script access to data 9.4c
D256 Validators 9.4c
D257 Editing data 9.4c
D258 Live tuning 9.4c
D259 Persistence model 9.4d1
D260 Persistent identity 9.4d1
D261 Script side 9.4d1
D262 Slots and files 9.4d1
D263 Rewind is deterministic replay 9.4d2
D264 core/rewind 9.4d3
D265 Services split into owners and views 9.4d3
D266 World time and randomness are part of every key, before any service 9.4d3
D267 A key is taken at a frame boundary in the play controller 9.4d3
D268 Scene keys keep the node arena exact 9.4d3
D269 The play controller flushes structural scene changes after every TickWorld 9.4d3
D270 TypeDescriptor.CloneInto(dst, src) is derived from the Go type at registration 9.4d3
D271 World.Replaying() is true during Restore and replay 9.4d3
D272 Script runtime key 9.4d3
D273 Physics key 9.4d3
D274 Module keys 9.4d3
D275 The history budget defaults to 256 MB and counts frame records, input states and key sizes 9.4d3
D276 rewind.Hasher is a streaming SHA-256 over canonical game state written by the time 9.4d3
D277 An edit during Play ends the history at the cursor; keys after it are dropped 9.4d3
D278 Workspaces 9.0
D279 Maximize 9.0
D280 Previews 9.0
D281 Icon manifest 9.0
D282 Multi-object editing, component sets and presets 9.0
D283 Play Changes Diff, isolation, viewport capture 9.0
D284 Asset references 9.0
D285 Background work and project health 9.0
D286 Export 9.5a
D287 Targets 9.5a
D288 Pre-export checks run before any build on a pipeline snapshot 9.5a
D289 Web platform 9.5b1
D290 WebGL2 backend 9.5b1
D291 Playground runtime 9.5b2
D292 Documentation IA 9.5b3a
D293 Docs session 9.5c2
D294 Try it and the sandbox 9.5c2
D295 Web export 9.5c1
D296 Mutation service 9.6a
D297 External changes in the editor 9.6d2
D298 Project refactorings without the editor 9.6c1
D299 Agent layer and CLI 9.6d1
D300 MCP server 9.6d2
D301 Release builds 9.7
D302 Documentation screenshots 9.7
D303 Docs delivery 9.7
D304 Camera adjustment contract 10a
D305 Virtual cameras, blends and zones 10a
D306 Follow, shake, accessibility and time 10a

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 import as before;
  • test [dir] runs script tests; make check-all runs it for examples/platformer and examples/physics-sandbox;
  • bootstrap [dir] regenerates .aego/generated for 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|serve serves the documentation registry and playground;
  • export [dir] [--target …] [--release|--development] [--out dir] [--check] [--json] builds a distributable game (D286–D288).

Later: bench. Native SDL is bootstrapped with make bootstrap. Binaries: aego-editor, aego-runtime, sandbox.


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