8 Commits
Author SHA1 Message Date
sanspie a6822156cd Finish Stage 12 editor fixes and refreshed platformer
ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
2026-11-05 17:36:42 +03:00
sanspie 5eb1832907 Complete Stage 12 for v0.12.0
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
2026-11-05 11:46:53 +03:00
sanspie ad23798da5 docs: the project description says what Aego is: a 2D game engine built in Go
ci / checks (push) Successful in 7m58s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m1s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / release (push) Successful in 8m21s
2026-10-28 02:55:13 +03:00
sanspie 132ddeff70 Stage 12a1: shader ABI, shader import and material runtime (D367, D368, D380, D381, D393)
ci / checks (push) Successful in 8m39s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m19s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / release (push) Successful in 8m54s
Shader ABI 1: ub0 frame (16 B), ub1 view (112 B), ub2 material, ub3 mesh
draw; tex0 main, tex1 reserved, tex2-tex7 user; @instance lanes in
vertex slot 1 at locations 4-7, 64 bytes a sprite. The engine sprite,
sprite3d, debug and mesh shaders move to it, and engine includes
sprite, color, noise and lighting ship under engine/res/shaders/include.

.aeshader files import into an AESH artifact with every variant in both
GLSL dialects and a line map; errors are SHD001-SHD009 mapped back to the
source file and line, also for driver compile logs. A failed reimport
keeps the last good shader. .aemat materials (MAT001-MAT004) reference a
shader and textures and are reimported when their shader changes. The
material cache in render/material binds a material with no allocations,
retires replaced shaders, moves materials to a reimported shader in
place (D393) and re-uploads every material after a WebGL context
restore. make ci requires glslangValidator (D367).

web-bench, baseline retaken on nodea in both GPU modes, the same numbers
in swiftshader and hardware, Chromium and Firefox:
- the frame UBO is uploaded once a frame: buffer uploads 2 -> 3 a frame;
- the view UBO is 112 bytes instead of 64: buffer KB 0.372 -> 0.434
  a frame (+16 B frame, +48 B view);
- fewer SetUniformBuffer calls a batch: the sprite pass no longer binds
  the view per batch, frame and view are bound once per view; the GL
  state cache already deduped that bind, so WebGL calls go 50.49 ->
  52.49 a frame with the two new frame-UBO calls, draws 3.31 unchanged;
- buffer KB from lanes is unchanged: the demo has no @instance material.
Frame time and Go bytes in the engine frame (357 -> 342) do not regress.

wasm size, gzip: playground 6.46 -> 6.50 MB, try 6.98 -> 7.01 MB, web
runtime 6.05 -> 6.07 MB (total 27.27 -> 27.46, 29.54 -> 29.67,
25.56 -> 25.65 MB).
2026-10-28 01:23:11 +03:00
sanspie c8d45b6ff5 docs: the Animator demo jumps and hits; the web demo caption names J to attack
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m59s
ci / release (push) Successful in 9m16s
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 8m18s
ci-platforms / darwin amd64 smoke (push) Skipped
The built-in platformer controller plays engine/anim/jump in Jump and engine/anim/hit in Hit; before, both states had no clip and nothing showed.
The Animator try scene jumps with Space (Grounded false in the air) instead of toggling the parameter.
2026-10-27 23:09:31 +03:00
sanspie 962c479933 web: the browser build drops the self-update core (D362 amended)
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 10m24s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m13s
ci / release (push) Successful in 8m57s
modules/update links aego/update (net/http, TLS, x509, archives) only off js; the web build gets a browser backend with the same section, settings and aego/update API, where Check and Apply fail and State stays idle. A test compares both builds' registrations.
depcheck: wasm-runtime and wasm-try variants, update in wasmDenied, wasmDeniedStd (net/http, crypto/tls, crypto/x509, archive/*) with the wasm-reaches-std rule.
wasmsize -prev: playground-size and try-size print the change since their last run.
Loaders show the compressed download size: wasmGzip in an export's files.json, aego-*-gzip and data-wasm-gzip on the site; docstool writes the .wasm.gz copies.
Demo game.wasm 33.9 -> 26.0 MB, gzip 8.07 -> 6.25 MB.
2026-10-27 20:06:16 +03:00
sanspie 72d9a21782 Stage 12w: web runtime performance (D365, D366, D390, D391)
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 8m33s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m38s
ci / release (push) Successful in 9m25s
make web-bench measures the exported platformer in Chromium and Firefox
in lockstep, with exact WebGL counters against a per-mode baseline, time
compared with Mann-Whitney and a profile pass that gates allocations in
the engine frame to zero, except listed first-decode and growth sites.

On the web the frame loop fills the audio output, the stream feeder
waits for ring space, per-event allocations are gone and Go allocations
per frame drop from 7.2 to 3.8 in Chromium. The pixel ratio is capped at
1.5 by default (display.maxPixelRatio) and Frame Limit works on the web.
The game loop stays on the main thread; a Worker is not built (D391).
2026-10-27 16:29:41 +03:00
sanspie cc2a428a7d engine: version 0.12.0-dev until the 0.12.0 release
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 8m13s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m21s
ci / release (push) Successful in 9m18s
2026-10-26 22:33:39 +03:00
1452 changed files with 108629 additions and 3772 deletions
+3
View File
@@ -0,0 +1,3 @@
# Claude configuration
This directory contains project-specific guidance and local configuration for Claude Code. The guidance records repository hygiene and verification expectations for agents working on Aego.
+3
View File
@@ -0,0 +1,3 @@
# CI workflows
The workflows in this directory automate checks, builds, tests, and release publishing for Aego. Optional platform-runner jobs cover Windows and macOS alongside the main Linux checks.
+3 -2
View File
@@ -9,8 +9,9 @@ permissions:
contents: read
env:
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
LINUX_PKGS: >-
build-essential cmake ninja-build pkg-config
build-essential cmake ninja-build pkg-config openssl
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
libwayland-dev wayland-protocols libdecor-0-dev
@@ -41,7 +42,7 @@ jobs:
go-version-file: go.mod
- name: linux packages
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- name: msys2
if: runner.os == 'Windows'
id: msys2
+5 -4
View File
@@ -10,8 +10,9 @@ permissions:
contents: read
env:
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
LINUX_PKGS: >-
build-essential cmake ninja-build pkg-config
build-essential cmake ninja-build pkg-config openssl
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
libwayland-dev wayland-protocols libdecor-0-dev
@@ -26,7 +27,7 @@ jobs:
with:
go-version-file: go.mod
- name: system packages
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- uses: actions/cache@v4
continue-on-error: true
with:
@@ -61,7 +62,7 @@ jobs:
go-version-file: go.mod
- name: linux packages
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- name: msys2
if: runner.os == 'Windows'
id: msys2
@@ -137,7 +138,7 @@ jobs:
with:
go-version-file: go.mod
- name: linux packages
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- uses: actions/cache@v4
continue-on-error: true
with:
+1
View File
@@ -17,6 +17,7 @@ tools/genicons/genicons
/tests/golden/testdata/actual-*
tools/websmoke/node_modules/
/tools/websmoke/__pycache__/
tools/websmoke/golden/actual-*.png
# build output belongs in .cache/bin; these catch stray builds in the root
+108 -8
View File
File diff suppressed because it is too large Load Diff
+175 -19
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,8 +1,8 @@
# Aego
# Aego — A 2D game engine built in Go.
Make 2D games in Go. One editor, one project, desktop and web.
Build games visually, write gameplay logic in Aego Script, and extend the engine with native Go.
Build scenes in the editor, script them in a small Go-like language, extend the engine with Go when you need to, and ship the same project to Linux, Windows, macOS and the browser. Deterministic simulation, rewind, physics, animation, audio and cameras come built in.
A complete development environment with a visual editor, hot reload, deterministic simulation, frame-by-frame rewind, and cross-platform export. From your first scene to a playable game on Windows, Linux, macOS, and the web.
- Documentation and the playground: <https://aego.akarpov.ru>
- Installing and building from source: [engine/res/docs/install.md](engine/res/docs/install.md)
+3
View File
@@ -0,0 +1,3 @@
# Animation
`anim` provides the shared animation kernel for clips, keyframe tracks, events, and controller graphs. It samples tracks, evaluates state transitions and blending, and encodes clips and controllers in text and binary forms for use by the engine and editor.
+3
View File
@@ -0,0 +1,3 @@
# app
The `app` packages assemble the engine, modules, asset pipeline, and scripting workspace into development hosts. They coordinate workflows such as project creation, editing, running, and exporting outside the engine core.
+32 -90
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -18,6 +18,7 @@ type Arg struct {
Doc string
Required bool
Pos bool
Items map[string]any
}
type Command struct {
@@ -46,6 +47,7 @@ type Output struct {
Text string `json:"-"`
// Images go to the model as image content, such as the screenshots of a run.
Images []Image `json:"-"`
Drawing *DrawingEdit `json:"-"`
}
// Image is a picture an answer carries.
@@ -67,12 +69,23 @@ func catalog() []*Command {
out = append(out, ctrlCommands()...)
out = append(out, refsCommands()...)
out = append(out, assetCommands()...)
out = append(out, variantCommand())
out = append(out, materialCommands()...)
out = append(out, postEffectCommands()...)
out = append(out, textureCommands()...)
out = append(out, schemaCommands()...)
out = append(out, projectCommands()...)
out = append(out, scriptCommands()...)
ls := looks()
for _, c := range out {
c.Look = ls[c.Name]
if c.Name == "asset.import-variant" {
c.Look = ls["asset.pixels"]
}
if c.Look.Title == nil && (strings.HasPrefix(c.Name, "postfx.") || strings.HasPrefix(c.Name, "material.") || strings.HasPrefix(c.Name, "shader.") || strings.HasPrefix(c.Name, "texture.")) {
name := strings.ReplaceAll(c.Name, ".", " ")
c.Look = Look{Icon: "comp.sprite", Title: func(a Args) string { return name + " " + file(a) }, Summary: changes}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
@@ -137,7 +150,11 @@ func (c *Command) Schema() map[string]any {
props := map[string]any{}
var req []string
for _, x := range c.AllArgs() {
props[x.Name] = map[string]any{"type": x.Type, "description": x.Doc}
prop := map[string]any{"type": x.Type, "description": x.Doc}
if x.Items != nil {
prop["items"] = x.Items
}
props[x.Name] = prop
if x.Required {
req = append(req, x.Name)
}
@@ -175,7 +192,7 @@ func Run(s *Session, name string, a Args) (*Output, error) {
}
out, err := c.Run(s, a)
if out != nil {
out.OK = err == nil
out.OK = err == nil && !out.Errors()
}
return out, err
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
"sync/atomic"
"time"
)
@@ -16,7 +17,7 @@ const maxBody = 8 << 20
// HTTP serves MCP as JSON-RPC over POST on loopback, for an agent the editor runs (D341).
type HTTP struct {
URL string
Token string
token atomic.Pointer[string]
srv *http.Server
s *Server
}
@@ -27,12 +28,8 @@ func ListenHTTP(opt Options) (*HTTP, error) {
if err != nil {
return nil, err
}
var key [32]byte
if _, err := rand.Read(key[:]); err != nil {
ln.Close()
return nil, err
}
h := &HTTP{URL: "http://" + ln.Addr().String() + "/mcp", Token: hex.EncodeToString(key[:]), s: newServer(opt)}
h := &HTTP{URL: "http://" + ln.Addr().String() + "/mcp", s: newServer(opt)}
h.RotateToken()
h.s.ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("/mcp", h.serve)
@@ -41,9 +38,18 @@ func ListenHTTP(opt Options) (*HTTP, error) {
return h, nil
}
func (h *HTTP) Token() string { return *h.token.Load() }
func (h *HTTP) RotateToken() {
var key [32]byte
rand.Read(key[:])
token := hex.EncodeToString(key[:])
h.token.Store(&token)
}
func (h *HTTP) authorized(r *http.Request) bool {
got := r.Header.Get("Authorization")
return subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+h.Token)) == 1
return subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+h.Token())) == 1
}
// local refuses a request whose Host is not loopback, so a web page cannot reach the server by DNS rebinding.
+8 -1
View File
@@ -335,6 +335,13 @@ func (s *Server) call(params json.RawMessage) (any, *rpcError) {
}
for _, t := range s.opt.Tools {
if t.Name == p.Name {
if t.Output != nil {
out, err := t.Output(p.Arguments)
if err != nil {
return result(err.Error(), true), nil
}
return result(out, out != nil && out.Errors()), nil
}
text, failed := t.Run(p.Arguments)
return result(text, failed), nil
}
@@ -364,7 +371,7 @@ func (s *Server) call(params json.RawMessage) (any, *rpcError) {
if err != nil {
return result(err.Error(), true), nil
}
return result(out, false), nil
return result(out, out != nil && out.Errors()), nil
}
func (s *Server) registry() (*docs.Registry, error) {
+1
View File
@@ -30,6 +30,7 @@ type Tool struct {
ReadOnly bool
// Run answers the call; it may block while the user decides.
Run func(args map[string]any) (text string, failed bool)
Output func(map[string]any) (*agent.Output, error)
}
type kitSource struct {
+221
View File
File diff suppressed because it is too large Load Diff
+101
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
package agent
import (
"os"
"path/filepath"
"testing"
"aego/modules/render2d"
"aego/scene/mutate"
)
type postCameraHost struct {
Host
doc *mutate.Document
edits int
}
func (h *postCameraHost) OpenScene(string) (*mutate.Document, bool) { return h.doc, true }
func (h *postCameraHost) ApplyScene(_ string, _ string, tx mutate.Tx) (*mutate.Result, error) {
r, err := h.doc.Apply(tx)
if err == nil {
h.edits++
}
return r, err
}
func TestPostEffectCommandTargetsDirtyCameraByStableID(t *testing.T) {
s := materialSession(t)
const file = "scenes/test.aescene"
disk := []byte(`scene version=1 {
node "43980000000000000000000000000001" name="Camera" {
component "engine.Camera2D" version=1 {}
component "render2d.PostProcess" version=1 { enabled=true effects=[{typeID="postfx.bloom" instanceID="first" enabled=true version=1 parameters=[]}] }
}
}`)
full := filepath.Join(s.Project.Root, file)
if err := os.WriteFile(full, disk, 0644); err != nil {
t.Fatal(err)
}
_, _, doc, err := s.sceneDisk(file)
if err != nil {
t.Fatal(err)
}
if _, err = doc.Apply(mutate.Tx{Label: "Unsaved rename", Ops: []mutate.Op{mutate.Rename("Camera", "UnsavedCamera")}}); err != nil {
t.Fatal(err)
}
h := &postCameraHost{doc: doc}
s.host = h
a := Args{"file": file, "node": "UnsavedCamera", "instance": "first", "field": "Intensity", "value": "0.37", "dry-run": true}
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 0 {
t.Fatalf("dry run: %v / %d", err, h.edits)
}
delete(a, "dry-run")
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 1 {
t.Fatalf("apply: %v / %d", err, h.edits)
}
o, err := Run(s, "postfx.get", Args{"file": file, "node": "UnsavedCamera"})
if err != nil {
t.Fatal(err)
}
stack := o.Value.(render2d.PostProcess)
e, ok := stack.Effect("first")
if !ok || len(e.Parameters) != 1 || e.Parameters[0].Value.X != 0.37 {
t.Fatal(stack)
}
for _, bad := range []string{"-1", "true", "[1,2]"} {
a["value"] = bad
if _, err := Run(s, "postfx.set", a); err == nil || h.edits != 1 {
t.Fatalf("invalid %q wrote scene", bad)
}
}
a["instance"] = "deleted"
if _, err := Run(s, "postfx.set", a); err == nil {
t.Fatal("missing instance accepted")
}
if b, err := os.ReadFile(full); err != nil || string(b) != string(disk) {
t.Fatal("saved document changed")
}
}
+9
View File
@@ -228,6 +228,9 @@ func looks() map[string]Look {
"schema.show": {"cat.engine", func(a Args) string {
return i18n.Tr("editor.agent.step.show_type", "Read the fields of {type}", i18n.Str("type", a.Str("type")))
}, ok},
"asset.paint": {"file.texture", func(a Args) string {
return i18n.Tr("editor.agent.step.draw_pixels", "Draw {file} in pixels", i18n.Str("file", path.Base(a.Str("path"))))
}, ok},
"asset.pixels": {"file.texture", func(a Args) string {
return i18n.Tr("editor.agent.step.draw_pixels", "Draw {file} in pixels", i18n.Str("file", path.Base(a.Str("path"))))
}, ok},
@@ -255,6 +258,12 @@ func looks() map[string]Look {
"refs.fix": {"panel.references", func(a Args) string {
return i18n.Tr("editor.agent.step.fix_refs", "Point references at {to}", i18n.Str("to", path.Base(a.Str("to"))))
}, changes},
"project.main-scene": {"file.scene", func(a Args) string {
return i18n.Tr("editor.agent.step.set_project", "Set {target}", i18n.Str("target", "mainScene › "+a.Str("file")))
}, changes},
"project.input-action": {"doc.project", func(a Args) string {
return i18n.Tr("editor.agent.step.set_project", "Set {target}", i18n.Str("target", "input › "+a.Str("name")))
}, changes},
"project.get": {"doc.project", func(Args) string {
return i18n.Tr("editor.agent.step.read_project", "Read project settings")
}, ok},
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,6 +15,8 @@ import (
func projectCommands() []*Command {
return []*Command{
{Name: "project.main-scene", Doc: "Set an imported scene as the startup scene by project-relative path.", Args: []Arg{argFile}, Write: true, Run: (*Session).mainScene},
{Name: "project.input-action", Doc: "Create/update one action and add it to a context. Binding strings use the project input syntax. Only an empty initial stack is initialized.", Write: true, Args: []Arg{{Name: "name", Type: "string", Required: true, Pos: true}, {Name: "type", Type: "string", Required: true}, {Name: "bind", Type: "array", Items: map[string]any{"type": "string"}, Required: true}, {Name: "context", Type: "string", Doc: "Gameplay by default"}, {Name: "active", Type: "boolean", Doc: "Add the context to the initial stack if absent"}}, Run: (*Session).inputAction},
{Name: "project.validate", Doc: "missing assets and nodes, import errors, prefabs and scenes that do not load", Run: (*Session).projectValidate},
{Name: "project.get", Doc: "module sections of project.aego (physics, …) with their current values",
Args: []Arg{{Name: "section", Type: "string", Doc: "only this section", Pos: true}}, Run: (*Session).projectGet},
File diff suppressed because it is too large Load Diff
+74 -2
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -42,6 +42,7 @@ const (
)
type RunRequest struct {
Camera string `json:"camera,omitempty"`
Scene string `json:"scene,omitempty"`
Frames int `json:"frames"`
Seed uint64 `json:"seed"`
@@ -67,6 +68,8 @@ type Check struct {
// Shot is a captured frame: the full image under .aego/cache/runs, and a scaled PNG for the answer.
type Shot struct {
ViewSignature string `json:"viewSignature,omitempty"`
CaptureID string `json:"captureID,omitempty"`
Frame int `json:"frame"`
Path string `json:"path,omitempty"`
Width int `json:"width,omitempty"`
@@ -356,6 +359,14 @@ func (r sources) Texture(ids.AssetID) (texture.Region, bool) {
return texture.Region{}, false
}
// Disk scene validation reads the same prepared data/colour and atlas metadata as Play.
func (r sources) TextureMetadata(id ids.AssetID) (asset.TextureMetadata, bool) {
return asset.ReadTextureMetadata(id, func(id ids.AssetID) (*asset.Entry, bool) {
e, ok := r.pl.Entry(id)
return &e, ok && !e.Missing && e.Error == ""
}, func(a asset.Artifact) ([]byte, error) { return r.pl.Artifacts().ReadFile(a.Path) })
}
func (r sources) Mesh(ids.AssetID) (*mesh.Mesh, bool) {
return nil, false
}
@@ -414,6 +425,11 @@ func (s *Session) asset(text string) (ids.AssetID, error) {
if id, err := ids.ParseAssetID(text); err == nil {
return id, nil
}
for _, builtin := range asset.Builtins() {
if strings.EqualFold(text, builtin.Name) {
return builtin.ID, nil
}
}
rel, err := s.rel(text)
if err != nil {
return ids.AssetID{}, err
@@ -425,6 +441,9 @@ func (s *Session) asset(text string) (ids.AssetID, error) {
}
func (s *Session) label(id ids.AssetID) string {
if builtin, ok := asset.LookupBuiltin(id); ok {
return builtin.Name
}
if rel, ok := s.Pipeline.PathOf(id); ok {
return rel
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
package agent
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
"aego/asset"
"aego/format/aetext"
)
func textureCommandFixture(t *testing.T) *Session {
t.Helper()
s := materialSession(t)
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: byte(x * 60), A: 255})
}
}
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.Project.Root, "assets/height.png"), b.Bytes(), 0644); err != nil {
t.Fatal(err)
}
if _, _, err := s.Pipeline.Scan(); err != nil {
t.Fatal(err)
}
if batch, err := s.Pipeline.ImportAll(); err != nil || len(batch.Failed) != 0 {
t.Fatalf("fixture import %v %v", err, batch)
}
return s
}
func TestTextureCommandsShareAtomicMetadataMutationsAndImageRevision(t *testing.T) {
s := textureCommandFixture(t)
file := "assets/height.png"
get := materialCommand(t, s, "texture.get", Args{"file": file}).Value.(map[string]any)
hash := get["sourceHash"].(string)
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "role", "value": "height", "expected": hash})
before, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if _, err := Run(s, "texture.set", Args{"file": file, "setting": "height_strength", "value": "2", "expected": hash}); err == nil {
t.Fatal("stale metadata hash accepted")
}
if _, err := Run(s, "texture.apply", Args{"file": file, "tx": `tx version=1 {op "texture-setting" {path="generate_normal" value=true} op "texture-setting" {path="height_strength" value="bad"}}`}); err == nil {
t.Fatal("invalid typed batch accepted")
}
after, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if !bytes.Equal(before, after) {
t.Fatal("failed batch partially wrote metadata")
}
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true", "dry-run": true})
after, _ = os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if !bytes.Equal(before, after) {
t.Fatal("dry run wrote metadata")
}
hash = materialCommand(t, s, "texture.get", Args{"file": file}).Value.(map[string]any)["sourceHash"].(string)
image, _ := os.ReadFile(filepath.Join(s.Project.Root, file))
image = append(image, 0)
if err := os.WriteFile(filepath.Join(s.Project.Root, file), image, 0644); err != nil {
t.Fatal(err)
}
if _, err := Run(s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true", "expected": hash}); err == nil {
t.Fatal("source image change was not in optimistic revision")
}
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true"})
out := materialCommand(t, s, "texture.validate", Args{"file": file})
if out.Value.(map[string]any)["importChecked"] != true || out.Value.(map[string]any)["gpuLinked"] != false {
t.Fatal("validation claimed GPU link or did not run importer")
}
}
func TestTextureCommandsUseDirtySharedMetadataDocument(t *testing.T) {
s := textureCommandFixture(t)
file := "assets/height.png"
before, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
m, err := asset.ParseSourceMeta(before, file+".meta")
if err != nil {
t.Fatal(err)
}
m.Settings["role"], m.Settings["generate_normal"] = aetext.Ident("height"), aetext.Bool(true)
host := &auditMaterialHost{text: map[string]string{file + ".meta": string(asset.MarshalSourceMeta(m))}}
s.host = host
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "height_strength", "value": "4"})
edited, err := asset.ParseSourceMeta([]byte(host.text[file+".meta"]), file+".meta")
if err != nil || edited.Settings.Float("height_strength", 1) != 4 || !edited.Settings.Bool("generate_normal", false) {
t.Fatal("mutation ignored dirty metadata", err)
}
after, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if !bytes.Equal(before, after) {
t.Fatal("editor-session texture mutation wrote disk behind history")
}
}
+30
View File
@@ -0,0 +1,30 @@
package agent
import (
"bytes"
"errors"
"image/png"
)
func variantCommand() *Command {
return &Command{Name: "asset.import-variant", Write: true, Doc: "Import an inspected generated PNG variant from this editor conversation as a stable project source asset", Args: []Arg{{Name: "variant", Type: "string", Required: true}, {Name: "path", Type: "string", Required: true}, {Name: "replace", Type: "boolean"}}, Look: Look{}, Run: func(s *Session, a Args) (*Output, error) {
host, ok := s.host.(interface{ GeneratedImage(string) ([]byte, bool) })
if !ok {
return nil, errors.New("requires an active editor generator session")
}
data, ok := host.GeneratedImage(a.Str("variant"))
if !ok {
return nil, errors.New("variant is missing or belongs to a previous conversation")
}
path, err := pngPath(a.Str("path"))
if err != nil {
return nil, err
}
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
return s.drawn(a, path, img, img, nil, false, nil, nil)
}}
}
+9 -1
View File
@@ -1,4 +1,4 @@
// Package backend runs a model for the Agent panel behind one interface: the Claude Code CLI, or a scripted fake for tests (D341).
// Package backend runs Claude Code or Codex for the Agent panel behind one interface.
package backend
import (
@@ -24,6 +24,7 @@ const (
const ToolPrefix = "mcp__aego__"
type Event struct {
Images [][]byte
Kind EventKind
Text string
Tool string
@@ -32,6 +33,9 @@ type Event struct {
Result string
Failed bool
Err error
// Delta appends Text verbatim to MessageID instead of starting a complete text block.
Delta bool
MessageID string
// Cost in US dollars and Millis of the answer, on EvTurnEnd.
Cost float64
Millis int
@@ -42,11 +46,15 @@ type Event struct {
}
type Config struct {
Binding string
Dir string
Command string
MCPURL string
Token string
System string
// Empty Model and Effort inherit the selected CLI's configuration.
Model string
Effort string
// Record gets every line the CLI writes, for recording sessions the tests replay.
Record io.Writer
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
package backend
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestClaudeProcessFixture(t *testing.T) {
if os.Getenv("AEGO_CLAUDE_PROCESS_FIXTURE") != "1" {
return
}
for i := 0; i < 200; i++ {
fmt.Fprintln(os.Stdout, `{"type":"assistant","message":{"content":[{"type":"text","text":"stream"}]}}`)
}
_, _ = io.Copy(io.Discard, os.Stdin)
os.Exit(0)
}
func TestClaudeCloseReapsProcessWithUndrainedEvents(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestClaudeProcessFixture$")
cmd.Env = append(os.Environ(), "AEGO_CLAUDE_PROCESS_FIXTURE=1")
conf := filepath.Join(t.TempDir(), "mcp.json")
if err := os.WriteFile(conf, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
c := &claudeConv{cmd: cmd, conf: conf, events: make(chan Event, 64), done: make(chan struct{})}
var err error
c.in, err = cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
out, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = &c.stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
go c.read(out)
t.Cleanup(func() { _ = c.Close() })
deadline := time.Now().Add(5 * time.Second)
for len(c.events) < cap(c.events) && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if len(c.events) != cap(c.events) {
t.Fatal("fixture did not reach a blocked event delivery")
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
select {
case _, ok := <-c.Events():
if !ok {
if cmd.ProcessState == nil {
t.Fatal("child was not reaped")
}
if _, err := os.Stat(conf); !os.IsNotExist(err) {
t.Fatalf("temporary MCP credentials still exist: %v", err)
}
return
}
case <-timeout.C:
t.Fatal("closing a replaced Claude conversation leaked its process or reader")
}
}
}
+7 -1
View File
@@ -2,6 +2,7 @@ package backend
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -57,6 +58,11 @@ func (e rpcErr) Error() string {
// CallRaw is the result of a tool call as the server sent it, images included.
func (c *Client) CallRaw(tool string, args map[string]any) (json.RawMessage, error) {
return c.CallRawContext(context.Background(), tool, args)
}
// CallRawContext cancels an outstanding question/tool when its turn is stopped.
func (c *Client) CallRawContext(ctx context.Context, tool string, args map[string]any) (json.RawMessage, error) {
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": c.next.Add(1), "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args},
@@ -64,7 +70,7 @@ func (c *Client) CallRaw(tool string, args map[string]any) (json.RawMessage, err
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, c.URL, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body))
if err != nil {
return nil, err
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
package backend
import (
"encoding/json"
"strings"
"testing"
)
func TestEditorCatalogRejectsInvalidOrMismatchedTools(t *testing.T) {
for _, tc := range []struct {
name string
raw string
ok bool
}{
{"named", `{"name":"scene_tree"}`, true},
{"key-only", `{}`, true},
{"null", `null`, false},
{"mismatch", `{"name":"different"}`, false},
{"invalid-type", `"scene_tree"`, false},
} {
t.Run(tc.name, func(t *testing.T) {
err := verifyEditorCatalog(map[string]json.RawMessage{"scene_tree": json.RawMessage(tc.raw)}, []string{"scene_tree"})
if (err == nil) != tc.ok || err != nil && !strings.Contains(err.Error(), "scene_tree") {
t.Fatalf("catalog verification: %v", err)
}
})
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
//go:build !windows
package backend
import "os/exec"
func hideCatalogProcess(*exec.Cmd) {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
package backend
import (
"os/exec"
"syscall"
)
func hideCatalogProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
package backend
import (
"strings"
"testing"
)
func TestClaudeDoesNotConfuseTextSessionWithSceneAccess(t *testing.T) {
c := &claudeConv{}
c.status([]byte(`{"type":"system","subtype":"init","session_id":"one","model":"fixture","mcp_servers":[{"name":"aego","status":"connected"}],"tools":["mcp__aego__scene_tree"]}`))
if d := c.Connection(); d.Tools != 1 || d.State != "missing tools" || d.Thread != "one" {
t.Fatal(d)
}
c.status([]byte(`{"type":"control_response","response":{"request_id":"aego-status","subtype":"success","response":{"mcpServers":[{"name":"aego","status":"connected","tools":[{"name":"editor_scene_context"},{"name":"scene_tree"},{"name":"script_read"}]}]}}}`))
if d := c.Connection(); d.State != "tools listed" || d.Tools != 3 {
t.Fatal(d)
}
}
func TestCodexRefusesMissingToolAndStaleEditorBinding(t *testing.T) {
for _, tt := range []struct{ mode, state, reason string }{{"missing-scene-tool", "missing tools", "scene_tree"}, {"missing-context", "missing tools", "editor_scene_context"}, {"old-editor", "call rejected", "different editor session"}} {
t.Run(tt.mode, func(t *testing.T) {
c := startCodexFixture(t, tt.mode, func(cfg *Config) { cfg.Binding = "new-editor" })
evs := codexEventUntil(t, c, func(e Event) bool { return e.Kind == EvError })
err := evs[len(evs)-1].Err
if err == nil || !strings.Contains(err.Error(), tt.reason) || c.Connection().State != tt.state {
t.Fatalf("%+v: %v", c.Connection(), err)
}
})
}
}
+56
View File
@@ -0,0 +1,56 @@
package backend
import (
"bytes"
"encoding/base64"
"encoding/json"
"image/png"
)
func toolImages(raw json.RawMessage) [][]byte {
// Claude nests the base64 source; Codex reports MCP content directly.
var b []struct {
Type, MimeType, Data string
Source struct {
Type string
Mime string `json:"media_type"`
Data string
}
}
var r struct{ Content json.RawMessage }
if len(raw) > 0 && raw[0] == '{' {
if json.Unmarshal(raw, &r) != nil {
return nil
}
raw = r.Content
}
if json.Unmarshal(raw, &b) != nil {
return nil
}
var out [][]byte
for _, v := range b {
if v.Type != "image" {
continue
}
data, mime := v.Data, v.MimeType
if v.Source.Type == "base64" {
data, mime = v.Source.Data, v.Source.Mime
}
if mime != "image/png" || len(data) > 7<<20 {
continue
}
decoded, err := base64.StdEncoding.DecodeString(data)
if err != nil || len(decoded) > 5<<20 {
continue
}
c, err := png.DecodeConfig(bytes.NewReader(decoded))
if err != nil || c.Width < 1 || c.Height < 1 || c.Width > 16384 || c.Height > (16<<20)/c.Width {
continue
}
out = append(out, decoded)
if len(out) == 3 {
break
}
}
return out
}
+28
View File
@@ -0,0 +1,28 @@
package backend
import (
"bytes"
"encoding/base64"
"encoding/json"
"image"
"image/png"
"testing"
)
func TestToolImagesDecodeProviderShapes(t *testing.T) {
var b bytes.Buffer
if err := png.Encode(&b, image.NewNRGBA(image.Rect(0, 0, 7, 3))); err != nil {
t.Fatal(err)
}
data := base64.StdEncoding.EncodeToString(b.Bytes())
for _, raw := range []any{map[string]any{"content": []any{map[string]any{"type": "image", "mimeType": "image/png", "data": data}}}, []any{map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": "image/png", "data": data}}}} {
s, _ := json.Marshal(raw)
g := toolImages(s)
if len(g) != 1 || !bytes.Equal(g[0], b.Bytes()) {
t.Fatal("lost provider image")
}
}
if len(toolImages(json.RawMessage(`[{"type":"text","text":"data:image/png;base64,test"}]`))) != 0 {
t.Fatal("text is not an image block")
}
}
+15
View File
@@ -0,0 +1,15 @@
package backend
import "fmt"
// ForProvider selects a CLI without starting a process or reading its credentials.
func ForProvider(name string) (Backend, error) {
switch name {
case "", "claude":
return Claude{}, nil
case "codex":
return Codex{}, nil
default:
return nil, fmt.Errorf("unknown agent provider %q", name)
}
}
+6 -1
View File
@@ -14,6 +14,7 @@ import (
"aego/modules/display"
"aego/modules/gameplay"
inputmod "aego/modules/input"
"aego/modules/particles"
"aego/modules/physics"
"aego/modules/render2d"
"aego/modules/render3d"
@@ -38,6 +39,10 @@ func render2dEntry() assembly.Entry {
return assembly.Entry{ID: render2d.ID, New: func(assembly.Options) module.Module { return render2d.New() }}
}
func particlesEntry() assembly.Entry {
return assembly.Entry{ID: particles.ID, Requires: []string{render2d.ID}, New: func(assembly.Options) module.Module { return particles.New() }}
}
func render3dEntry() assembly.Entry {
return assembly.Entry{ID: render3d.ID, New: func(assembly.Options) module.Module { return render3d.New() }}
}
@@ -110,5 +115,5 @@ func updateEntry() assembly.Entry {
func Default() assembly.Catalog {
return assembly.Catalog{core(), dataEntry(), inputEntry(), render2dEntry(), render3dEntry(), scriptEntry(), physicsEntry(), debugEntry(),
displayEntry(), audioEntry(), accessibilityEntry(), settingsEntry(), tweenEntry(), animationEntry(), cameraEntry(), gameplayEntry(), spawnEntry(), savegameEntry(), updateEntry(), artEntry()}
displayEntry(), audioEntry(), accessibilityEntry(), settingsEntry(), tweenEntry(), animationEntry(), cameraEntry(), gameplayEntry(), spawnEntry(), savegameEntry(), updateEntry(), artEntry(), particlesEntry()}
}
+3
View File
@@ -12,6 +12,8 @@ import (
gameplayeditor "aego/modules/gameplay/editor"
inputmod "aego/modules/input"
inputeditor "aego/modules/input/editor"
"aego/modules/particles"
particleseditor "aego/modules/particles/editor"
"aego/modules/physics"
physicseditor "aego/modules/physics/editor"
"aego/modules/render2d"
@@ -23,6 +25,7 @@ import (
func With(c assembly.Catalog) assembly.Catalog {
return c.
With(inputmod.ID, inputeditor.Hook).
With(particles.ID, particleseditor.Hook).
With(render2d.ID, render2deditor.Hook).
With(render3d.ID, render3deditor.Hook).
With(physics.ID, physicseditor.Hook).

Some files were not shown because too many files have changed in this diff Show More