30 Commits
Author SHA1 Message Date
sanspie 208875264f Stage 9.5c3: docs runtime polish
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 / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m43s
ci / checks (push) Successful in 16m57s
2026-10-20 02:33:48 +03:00
sanspie 8c21400bb1 Stage 9.5c2: docs session, Try it, sandbox (D293–D294) 2026-10-20 01:31:40 +03:00
sanspie e83521a34a Stage 9.5c1: web export (D295) 2026-10-20 00:59:39 +03:00
sanspie 3f5584d062 Stage 9.5b3b: component, settings and CLI reference (D292) 2026-10-20 00:11:43 +03:00
sanspie 6ce72aa1ec Stage 9.5b3a: docs IA and site shell (D292) 2026-10-19 23:27:11 +03:00
sanspie 9ee2b0b05c Stage 9.5b2: playground with Game View (D291) 2026-10-19 23:10:55 +03:00
sanspie 99dacd7a75 Stage 9.5b1: web platform and WebGL2 backend (D289–D290) 2026-10-19 22:47:08 +03:00
sanspie 5447383f2b gitignore: aego-runtime binary and .syso resources 2026-10-19 20:39:03 +03:00
sanspie 6745d027fb Stage 9.5a: export v1 (D286–D288) 2026-10-19 20:27:58 +03:00
sanspie e7dd01371e script/abi: Parse accepts the untyped Handle<> the encoder writes 2026-10-19 20:27:40 +03:00
sanspie 68802195b6 Stage 9.0: editor workflow 2026-10-19 20:27:40 +03:00
sanspie 7cba364aed Stage 9.4d4: aego test --determinism 2026-10-19 02:18:58 +03:00
sanspie 35e9849cae Stage 9.4d3: keyframes 2026-10-19 02:15:10 +03:00
sanspie f21d455d68 Stage 9.4d3a: CloneInto, spawn pool 2026-10-19 01:51:39 +03:00
sanspie d1a16d8208 Stage 9.4d2: rewind 2026-10-19 01:47:39 +03:00
sanspie 2a1826f876 Stage 9.4d1: save games 2026-10-19 00:37:50 +03:00
sanspie 3af2d5d977 Stage 9.4c: data assets, validators 2026-10-18 22:58:02 +03:00
sanspie b61a1656ad Stage 9.4b: signals, tags, spawn 2026-10-18 22:58:02 +03:00
sanspie 0fbf22a090 Stage 9.4a: curves, tween, timers
core/curve with presets, keys, wraps; KindCurve in schema, scenes, prefabs and the inspector curve editor.
modules/tween: tweens and timers in per-world pools, game and unscaled domains, script field targets via contracts/scriptfields.
Host handle types (abi.Handle, bind.Handle, manifest v5), yield on awaitable handles via contracts/scriptwait.
Script modules aego/curve, aego/tween, aego/timer; platformer enemy uses a cooldown and a fade-out tween.
Docs: tween.md, curves.md, language reference; golden for the curve editor.
2026-10-18 21:32:39 +03:00
sanspie 5e763aa73c Stage 9.2–9.3: input and settings 2026-10-18 21:32:39 +03:00
sanspie 6093ca9d8b Stage 9.1: time and debug 2026-10-17 23:14:21 +03:00
sanspie 6cc0673082 CI on master, docs auto-deploy and repo link, macOS build fixes
ci / checks (push) Successful in 13m36s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 14m40s
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
2026-10-16 12:10:43 +03:00
sanspie ab9b003457 Stage 8: physics and module boundaries 2026-10-15 19:16:09 +03:00
sanspie 41d949717d deploy: push-to-deploy docs site and playground
make site-headless builds the CLI without cgo (aego_nonative) and the
static site with the wasm playground. deploy/ holds the image, the
container nginx config (gzip_static with Vary), compose and deploy.sh
run by the post-receive hook on mainserv.

.gitignore ignores only the root site/ output; tools/gendocs/site was
never committed because of it.

Docs site: index and section summaries render code, bold and italic
instead of raw markdown; search.json gets the plain text. Page summaries
drop a leading list or quote marker. The nav toggle is three CSS bars
centred in a 32px button.

Playground: a loading overlay in the editor pane shows a spinner, a
progress bar and the downloaded size while aego.wasm streams in, then
'Starting engine' until the runtime is ready; a failed start keeps the
error on the overlay. docstool writes the wasm size and a version hash
into index.html; play.js, play.css, wasm_exec.js and aego.wasm are
referenced with ?v=<hash> so a deploy never mixes cached old files with
new ones.

Instruction budget: default 1M per callback (was 100k), playground 2M;
the donut needs ~120k per frame. Playground: software 3D donut example,
multi-line log entries render as monospace blocks, a fault is reported
once instead of also as a "script stopped" log line.

The compose project is named aego explicitly; the default name came from
the deploy/ directory and collided with other stacks on the host.
2026-10-14 22:40:20 +03:00
sanspie 69d19e501f Stage 7: documentation, help, playground; templates, aego new/bootstrap/icons, launcher
Documentation: engine/docs assembles one registry from the script
bindings, the schema, module docs and module-owned script paths; problems
are reported as warnings at build time. Module registries record doc
sources and script module owners; diag.Codes lists every diagnostic code.

Project bootstrap: project.defaultSpace ("2d"/"3d"), EnsureDirs creates
the default folders when the editor opens a project (reported in the
console), prefabs/ is a pipeline root. Native builds drop -trimpath so
binding locations resolve inside native/.

Editor files: create scene/prefab/script from menus, move/rename/duplicate
/delete with undo, script package retargeting on move, open scenes and
script panels follow renames, Save As / Rename Scene prompts, scene
picker, startup scene. Scoped shortcuts fall back to the global command
when disabled; Save As is Ctrl+Shift+S, Save All Ctrl+Alt+S.

Script editor: Go to Implementation (stub → native/ source), host
definitions always resolve to stubs, bracket match/mismatch states,
rainbow brackets (persisted), mono code font (ui-mono atlases, per-command
fonts in draw.List, PushFont/PopFont restored by Mark/Restore).

Tests: langsvc definition/implementation, bracket states, dispatch
fallback, script renames, font stack, editor file-op integration, golden
mono glyph and bracket references; depcheck rule for engine/docs.

Docs pipeline: engine, render2d and script/engine ship their own res/docs;
keyword hover text comes from the registry through manifest.Keywords;
project script symbols join the registry; aego docs export|check|build|
serve, --tool docs in native binaries, aego check --explain; app/docstool
and the tools/gendocs module (goldmark) build the static site.

Playground: app/playground + cmd/aego-playground (js/wasm, 3.3 MB gzip)
compile, highlight and run scripts in the browser; run blocks in the
language docs become examples. engine/builtin/headless is the module set
without render3d; workspace.Config.FS; StubFor/StubPaths; backends and the
script importer drop out of js builds.

VM: one frame pointer in the interpreter loop, direct host calls, panic
recovery once per run instead of per host call (Update2k -19%,
Schedule10k -24% against the previous head). bench-build/compare/profile
run pre-built, pinned, interleaved binaries.

Editor: structural bracket levels and current-block highlight, indent
guides, occurrence highlighting with write marks, multi-format clipboard
(copy/cut/paste assets, paste images as sprites or textures). gl33 probes
a renderable depth format and reports framebuffer failures by stage.

7 E:
- project templates empty/2d/2.5d/3d, app/projects as the single creation path (D148)
- launcher and recent projects (D149)
- orthographic Camera3D and Scene View (D150)
- reference states in the Inspector, ids.Short (D151)
- sceneio remaps node references when loading without KeepIDs (D152)
- exported ComponentRef<T> (D153)
- aego icons via module.Audit (D146)
2026-10-13 01:08:56 +03:00
sanspie cf68c71f05 Stage 6: coroutines, signals, ScriptRef, workspace CLI, editor tooling
Language: yield/signal/coroutine become keywords, start is contextual.
Coroutine methods (func Name() coroutine) with start and
yield / wait / frames / fixed / await; signals with Emit/Connect and
Connection handles; ScriptRef<T> and node.GetScript<T>() for fields,
plain methods and signals of other scripts. Diagnostics AES2050-AES2059.

Bytecode v3: ScriptRefs, XFields, XMethods and Connects tables resolved by
name in vm.Link, per-script signals, coroutine flag on functions; new
opcodes START/YIELD/EMIT/CONNECT/DISCONNECT/CONNECTED/COSTOP/CORUN/
SELFREF/GETSCRIPT/GETXF/SETXF/CALLR, checked by verify.

Runtime: script/coroutine scheduler (frames/fixed counters, time heap,
await lists; only due coroutines are visited) and script/signal registry
(snapshot per Emit, deferred removal, no allocations in steady state).
Hot reload cancels coroutines of changed scripts, rebinds connections by
name and signature, then runs Reloaded.

Tooling: script/build removed, aego check/test/dump run on the workspace
(test builds include _test.aes); langsvc completion, hover, signatures and
semantic tokens for the new syntax; editor highlighting, snippets with
placeholders, coroutine/signal profiler graphs and stats.

Docs (script.md sections 16-19, editor.md, ARCHITECTURE), test fixtures and
golden files, platformer enemies wired to the player, scriptreload demo,
and run.Config.FrameTime for deterministic headless fixed steps.

Modules (6.5): engine/module registry and ComponentBuilder are the one
registration path for engine subsystems and native code; render2d,
render3d and script/engine are modules over scene/frame, the script
component moved to scene/scriptcomp, and scene/components and
scene/systems are gone. Editor inspector, templates, picking shapes and
extension hooks are driven by descriptors and editor/builtins; sdk gains
Component/EditorHook; depcheck checks the module graph and the
aego_runtime graph.

Also: stop tracking built tool binaries; gl33 activates the scratch unit
before binding for texture updates.

Verification: typed component method adapters (Method0..2, Proc0..2)
with direct field access and no allocations; app.Build as the single
construction path for run and the editor; modules attach per scene
document; depcheck aego_editor pass; diagnostic codes in one place and
every code covered by a test; indexed coroutine heaps with eager
cancel; dead component references and nil intrinsics fault with
AES6004, host faults keep their code; GC roots of struct fields checked
in verify and vm.Link; 255-field limit; AES4010 for script packages
shadowing host modules; architecture registry D111-D147.

Golden: the harness renders on the thread that owns the GL context,
ReadPixels returns rows in memory order (render targets are top-down per
D17), and the first rendering references are committed.
2026-10-12 14:22:08 +03:00
sanspie 08f0b15e1e docs: update architecture spec and script reference 2026-10-08 13:31:21 +03:00
sanspie 17cb5e4291 Stage 2-5: assets, scene/ECS, editor, 3D, and Aego Script
Stage 2: render pipeline and UI toolkit over gpu/platform.
Stage 3: schema registry, asset pipeline + runtime loader, scene graph and ECS.
Stage 4: editor — docking, inspector, scene/game views, play mode, asset browser, console, profiler; 4.5 adds the 3D foundation (mixed 2D/3D spaces, meshes, depth).
Stage 5: Aego Script — compiler (lexer/parser/types/resolver/checker/asm/compiler), bytecode with verifier and register VM, instance runtime, stdlib, native bindings, and editor/CLI integration (aego run/edit/check/test/dump).
2026-10-08 13:17:28 +03:00
sanspie eb9ac1e589 Initial commit 2026-09-26 16:32:40 +03:00
sanspie dae4bc19e1 Stage 1: engine core, platform, gpu, vfs
Foundation for the aego engine.

core/    errs, log, handle arena, mathx, clock, stats
platform SDL3 backend plus a headless one, input state, events
gpu      device abstraction with gl33 and null backends
vfs      scheme-based virtual filesystem with overlay mounts
engine   builder, module registry, phase-ordered system loop, jobs
demos    basic, resize, input, headless; sandbox driver

OpenGL bindings are generated into gpu/gl33/gl by tools/gen-gl.sh,
so the module has no external dependencies.
2026-09-18 20:51:54 +03:00
1546 changed files with 6242 additions and 144763 deletions
-4
View File
@@ -1,4 +0,0 @@
# Agents working on Aego
- Binaries go to `.cache/bin` only. Never run `go build` without `-o` in the repository root, and build test binaries only with `go test -c -o .cache/bin/<name>.test`. `make clean` empties `.cache/bin`; `make ci` fails when the root holds a binary or a `*.test` file (`tools/ci-clean-root.sh`).
- Run the full `make ci` before every push. Between pushes, run the checks that cover the change: the tests of the changed packages, `make lint`, `make depcheck`, the docs check and the golden tests when the UI changed.
-1
View File
@@ -5,4 +5,3 @@ site
exports
**/.aego
*.test
.claude
+1 -3
View File
@@ -17,9 +17,7 @@ env:
libasound2-dev libpulse-dev libdrm-dev libgbm-dev libudev-dev libdbus-1-dev libibus-1.0-dev
jobs:
# Native runs on Windows and macOS machines; the Windows build itself is cross-compiled on Linux in ci.yml (D301).
smoke:
name: ${{ matrix.goos }} ${{ matrix.goarch }} smoke
build:
if: vars.PLATFORM_RUNNERS == 'true'
strategy:
fail-fast: false
+1 -44
View File
@@ -3,7 +3,6 @@ name: ci
on:
push:
branches: [master]
tags: ["v*"]
pull_request:
permissions:
@@ -37,10 +36,6 @@ jobs:
- run: go vet ./platform/sdl
- run: make lint
- run: make depcheck
- name: packaging tools
run: sudo apt-get install -y nsis squashfs-tools
- name: installer dry run
run: make nsis-check
build:
strategy:
@@ -116,42 +111,4 @@ jobs:
- name: example project
run: |
/tmp/aego check examples/native-rotator
/tmp/aego run examples/native-rotator --headless --frames 60
release:
needs: [checks, build]
if: github.event_name == 'push'
runs-on: ubuntu-24.04
permissions:
contents: write
env:
CGO_ENABLED: "1"
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the key that signs release.json and the public half the binaries trust; without them no release.json is published (D362)
AEGO_RELEASE_KEY: ${{ secrets.AEGO_RELEASE_KEY }}
AEGO_UPDATE_KEY: ${{ vars.AEGO_UPDATE_KEY || secrets.AEGO_UPDATE_KEY }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: linux packages
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
- uses: actions/cache@v4
continue-on-error: true
with:
path: .cache/native
key: native-linux_amd64-${{ hashFiles('third_party/sdl/version.txt', 'tools/bootstrap-native.sh') }}
- run: bash tools/bootstrap-native.sh
- name: windows tools
run: sudo apt-get install -y mingw-w64 nsis zip
- name: publish
env:
REQUIRE_WINDOWS: "1"
GITEA_RESOLVE: git.akarpov.ru:443:79.142.92.250
run: |
case "$GITHUB_REF" in
refs/tags/v*) sh tools/release/release.sh "$GITHUB_REF_NAME" ;;
refs/heads/master) sh tools/release/release.sh nightly ;;
esac
/tmp/aego run examples/native-rotator --headless --frames 60
-8
View File
@@ -18,11 +18,3 @@ tools/genicons/genicons
/tests/golden/testdata/actual-*
tools/websmoke/node_modules/
tools/websmoke/golden/actual-*.png
# build output belongs in .cache/bin; these catch stray builds in the root
/aego-*
/i18n
*.prof
*.pprof
/data*.txt
*.key
+27 -275
View File
File diff suppressed because one or more lines are too long
+20 -113
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,10 +0,0 @@
# Aego
Make 2D games in Go. One editor, one project, desktop and web.
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.
- Documentation and the playground: <https://aego.akarpov.ru>
- Installing and building from source: [engine/res/docs/install.md](engine/res/docs/install.md)
- Contributing, check levels and rules for engine code: [docs/contributing.md](docs/contributing.md)
- Architecture and decisions: [ARCHITECTURE.md](ARCHITECTURE.md)
-238
View File
File diff suppressed because it is too large Load Diff
-234
View File
File diff suppressed because it is too large Load Diff
-138
View File
File diff suppressed because it is too large Load Diff
-149
View File
File diff suppressed because it is too large Load Diff
-146
View File
File diff suppressed because it is too large Load Diff
-340
View File
File diff suppressed because it is too large Load Diff
-109
View File
File diff suppressed because it is too large Load Diff
-98
View File
@@ -1,98 +0,0 @@
package anim
import (
"slices"
"testing"
)
func clip(loop LoopMode, ts ...float32) *Clip {
c := &Clip{Length: 1, Loop: loop}
for _, t := range ts {
c.Events = append(c.Events, Event{T: t, Name: "e"})
}
c.Fix()
return c
}
func evs(cs []Crossing) []int32 {
out := make([]int32, len(cs))
for i, c := range cs {
out[i] = c.Event
}
return out
}
func cycles(cs []Crossing) []int64 {
out := make([]int64, len(cs))
for i, c := range cs {
out[i] = c.Cycle
}
return out
}
func expect[T comparable](t *testing.T, name string, got, want []T) {
t.Helper()
if !slices.Equal(got, want) {
t.Fatalf("%s: got %v, want %v", name, got, want)
}
}
func TestOnce(t *testing.T) {
c := clip(LoopOnce, 0, 0.5, 1)
expect(t, "start", evs(Crossings(c, LoopOnce, 0, 0.6, true, nil)), []int32{0, 1})
expect(t, "end", evs(Crossings(c, LoopOnce, 0.6, 1.5, false, nil)), []int32{2})
expect(t, "after", evs(Crossings(c, LoopOnce, 1, 2, false, nil)), []int32{})
}
func TestZeroKeyEveryCycle(t *testing.T) {
c := clip(LoopRepeat, 0)
expect(t, "start", cycles(Crossings(c, LoopRepeat, 0, 0.5, true, nil)), []int64{0})
expect(t, "next", cycles(Crossings(c, LoopRepeat, 0.5, 1.5, false, nil)), []int64{1})
expect(t, "two", cycles(Crossings(c, LoopRepeat, 1.5, 3.2, false, nil)), []int64{2, 3})
}
func TestLengthKeyOncePerCycle(t *testing.T) {
c := clip(LoopRepeat, 1)
expect(t, "start", evs(Crossings(c, LoopRepeat, 0, 0.5, true, nil)), []int32{})
expect(t, "first", cycles(Crossings(c, LoopRepeat, 0.5, 1, false, nil)), []int64{0})
expect(t, "second", cycles(Crossings(c, LoopRepeat, 1, 2, false, nil)), []int64{1})
}
func TestSameMoment(t *testing.T) {
c := clip(LoopOnce, 0.5, 0.5)
expect(t, "forward", evs(Crossings(c, LoopOnce, 0, 1, false, nil)), []int32{0, 1})
expect(t, "backward", evs(Crossings(c, LoopOnce, 1, 0, false, nil)), []int32{0, 1})
}
func TestStillAndBackward(t *testing.T) {
c := clip(LoopRepeat, 0.2, 0.8)
expect(t, "speed 0", evs(Crossings(c, LoopRepeat, 0.3, 0.3, false, nil)), []int32{})
got := Crossings(c, LoopRepeat, 1.5, 0.1, false, nil)
expect(t, "backward events", evs(got), []int32{0, 1, 0})
expect(t, "backward cycles", cycles(got), []int64{1, 0, 0})
}
func TestManyCycles(t *testing.T) {
c := clip(LoopRepeat, 0.5)
expect(t, "three", cycles(Crossings(c, LoopRepeat, 0, 3, true, nil)), []int64{0, 1, 2})
}
func TestPingPongTurns(t *testing.T) {
c := clip(LoopPingPong, 0, 1)
got := Crossings(c, LoopPingPong, 0, 4, true, nil)
expect(t, "events", evs(got), []int32{0, 1, 0, 1, 0})
expect(t, "cycles", cycles(got), []int64{0, 0, 2, 2, 4})
mid := clip(LoopPingPong, 0.25)
got = Crossings(mid, LoopPingPong, 0, 2, true, nil)
if len(got) != 2 || got[0].T != 0.25 || got[1].T != 1.75 {
t.Fatalf("mid %+v", got)
}
}
func TestCrossingsNoAllocs(t *testing.T) {
c := clip(LoopRepeat, 0, 0.25, 0.5, 0.75)
buf := make([]Crossing, 0, 64)
if n := testing.AllocsPerRun(100, func() { buf = Crossings(c, LoopRepeat, 0.1, 3.9, false, buf) }); n != 0 {
t.Fatalf("Crossings allocates %v", n)
}
}
-332
View File
File diff suppressed because it is too large Load Diff
-221
View File
File diff suppressed because it is too large Load Diff
-87
View File
@@ -1,87 +0,0 @@
package anim
import (
"testing"
"aego/format/aetext"
)
const layoutSrc = `controller version=1 {
entry="Idle"
param "Speed" kind=float {}
state "Idle" {}
state "Run" {}
state "Jump" pos=[500, -40] {}
state "Lost" {}
transition "Idle" to="Run" duration=0.1 {
cond "Speed" op=gt value=0.1 {}
}
transition "Run" to="Idle" duration=0.1 {
cond "Speed" op=le value=0.1 {}
}
transition "any" to="Jump" {
cond "Speed" op=lt value=0 {}
}
}
`
func layoutGraph(t *testing.T) *Graph {
t.Helper()
doc, err := aetext.Parse([]byte(layoutSrc), "l.aectrl")
if err != nil {
t.Fatal(err)
}
g, probs, err := DecodeGraph(doc)
if err != nil || len(probs) > 0 {
t.Fatalf("%v %+v", err, probs)
}
return g
}
func TestLayoutPlacesEveryStateApart(t *testing.T) {
g := layoutGraph(t)
g.Layout()
seen := map[[2]float32]string{}
for _, s := range g.States {
if !s.HasPos {
t.Fatalf("%s has no position", s.Name)
}
if o, dup := seen[s.Pos]; dup {
t.Fatalf("%s and %s share %v", s.Name, o, s.Pos)
}
seen[s.Pos] = s.Name
}
idle, run, jump := g.States[0].Pos, g.States[1].Pos, g.States[2].Pos
if idle[0] != 0 || run[0] != LayoutColumn {
t.Fatalf("entry %v, next %v: the entry is the first column", idle, run)
}
if jump != [2]float32{500, -40} {
t.Fatalf("a written position moved to %v", jump)
}
}
func TestPosRoundTrips(t *testing.T) {
g := layoutGraph(t)
g.Layout()
text := aetext.Write(EncodeGraph(g))
doc, err := aetext.Parse(text, "l.aectrl")
if err != nil {
t.Fatal(err)
}
back, _, err := DecodeGraph(doc)
if err != nil {
t.Fatal(err)
}
bin, err := UnmarshalGraph(MarshalGraph(back))
if err != nil {
t.Fatal(err)
}
for i := range g.States {
if back.States[i].Pos != g.States[i].Pos || bin.States[i].Pos != g.States[i].Pos {
t.Fatalf("%s: text %v binary %v, want %v", g.States[i].Name, back.States[i].Pos, bin.States[i].Pos, g.States[i].Pos)
}
}
if _, err := UnmarshalGraph(MarshalGraph(back)[:len(MarshalGraph(back))-4]); err == nil {
t.Fatal("a truncated state was accepted")
}
}
-185
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
package anim
// RootDelta is the change of a root track between two clip times: a looping clip adds its end minus its start for every cycle crossed, a ping-pong clip only moves back and forth.
func RootDelta(c *Clip, s *Sampler, track int, loop LoopMode, t0, t1 float64) [2]float32 {
if !(c.Length > 0) || t0 == t1 || track < 0 || track >= len(c.Tracks) {
return [2]float32{}
}
u0, k0 := c.Local(loop, t0)
u1, k1 := c.Local(loop, t1)
a := s.Track(c, track, u0).L
b := s.Track(c, track, u1).L
d := [2]float32{b[0] - a[0], b[1] - a[1]}
if loop == LoopRepeat && k1 != k0 {
start := s.Track(c, track, 0).L
end := s.Track(c, track, c.Length).L
n := float32(k1 - k0)
d[0] += n * (end[0] - start[0])
d[1] += n * (end[1] - start[1])
}
return d
}
-59
View File
@@ -1,59 +0,0 @@
package anim
import (
"testing"
"aego/core/curve"
"aego/schema"
)
func walkClip(loop LoopMode) *Clip {
c := &Clip{Length: 1, Loop: loop, Tracks: []Track{{
Property: RootProp, Kind: schema.KindVec2, Root: true,
Keys: []Key{{T: 0, V: Lanes([4]float32{0, 0}), Interp: curve.InterpLinear}, {T: 1, V: Lanes([4]float32{1, 0.5})}},
}}}
c.Fix()
return c
}
func TestRootDelta(t *testing.T) {
var s Sampler
loop := walkClip(LoopRepeat)
for _, tc := range []struct {
t0, t1 float64
want [2]float32
}{
{0.25, 0.75, [2]float32{0.5, 0.25}},
{0.5, 1.5, [2]float32{1, 0.5}},
{0.25, 2.25, [2]float32{2, 1}},
{1.25, 0.25, [2]float32{-1, -0.5}},
{0.75, 1, [2]float32{0.25, 0.125}},
} {
d := RootDelta(loop, &s, 0, LoopRepeat, tc.t0, tc.t1)
if !near(d[0], tc.want[0]) || !near(d[1], tc.want[1]) {
t.Fatalf("loop %v..%v: %v, want %v", tc.t0, tc.t1, d, tc.want)
}
}
ping := walkClip(LoopPingPong)
if d := RootDelta(ping, &s, 0, LoopPingPong, 0.5, 1.5); !near(d[0], 0) || !near(d[1], 0) {
t.Fatalf("ping-pong turns back: %v", d)
}
once := walkClip(LoopOnce)
if d := RootDelta(once, &s, 0, LoopOnce, 0.75, 3); !near(d[0], 0.25) {
t.Fatalf("once stops at the end: %v", d)
}
if d := RootDelta(loop, &s, 0, LoopRepeat, 0.5, 0.5); d != ([2]float32{}) {
t.Fatalf("no time, no motion: %v", d)
}
}
func TestRootCheck(t *testing.T) {
c := walkClip(LoopRepeat)
if ps := c.Check(nil); len(ps) != 0 {
t.Fatalf("a good root track reports %+v", ps)
}
c.Tracks[0].Target = "Body"
if ps := c.Check(nil); len(ps) != 1 || ps[0].Kind != ProblemRoot {
t.Fatalf("a root track on a child: %+v", ps)
}
}
-146
View File
File diff suppressed because it is too large Load Diff
-142
View File
File diff suppressed because it is too large Load Diff
-73
View File
@@ -1,73 +0,0 @@
package anim
import (
"slices"
"strconv"
"strings"
"aego/format/aetext"
)
// shape lists the attributes, fields and child blocks a block of a clip or controller may hold.
type shape struct {
attrs []string
fields []string
blocks []string
}
var (
clipShape = shape{attrs: []string{"version", "length", "fps", "loop"}, blocks: []string{"track", "frames", "event"}}
trackShape = shape{attrs: []string{"prop", "kind", "numeric", "root", "authority", "onEnd"}, blocks: []string{"key"}}
keyShape = shape{attrs: []string{"t", "v", "interp", "tangent", "in", "out"}}
framesShape = shape{attrs: []string{"start", "fps", "prop", "flipProp", "authority"}, fields: []string{"sprites", "durations", "flipX"}}
eventShape = shape{attrs: []string{"t", "name", "payload", "mode"}}
ctrlShape = shape{attrs: []string{"version", "ease", "entryPos", "anyPos"}, fields: []string{"entry"}, blocks: []string{"param", "state", "transition"}}
paramShape = shape{attrs: []string{"kind", "default"}}
stateShape = shape{attrs: []string{"clip", "speed", "loop", "pos", "events"}}
transShape = shape{attrs: []string{"to", "duration", "exitTime", "interrupt"}, blocks: []string{"cond"}}
condShape = shape{attrs: []string{"op", "value"}}
)
// opening is how the block's first line reads with the attribute moved into it.
func opening(n *aetext.Node, f aetext.Field) string {
var b strings.Builder
b.WriteString(n.Head)
if n.Label != "" || n.Head == "frames" || n.Head == "track" {
b.WriteString(" " + strconv.Quote(n.Label))
}
b.WriteString(" " + f.Name + "=" + aetext.FormatValue(f.Value) + " {")
return b.String()
}
// strict refuses names a block does not know, so a misspelt or misplaced setting is never dropped silently.
func strict(n *aetext.Node, s shape) error {
for _, f := range n.Attrs {
switch {
case slices.Contains(s.attrs, f.Name):
case slices.Contains(s.fields, f.Name):
return textErr(n, f.Name+" is a field of "+n.Head+": write "+f.Name+"="+aetext.FormatValue(f.Value)+" inside the block")
default:
return textErr(n, "unknown attribute "+f.Name+" of "+n.Head+"; it takes "+strings.Join(s.attrs, ", "))
}
}
for _, f := range n.Fields {
switch {
case slices.Contains(s.fields, f.Name):
case slices.Contains(s.attrs, f.Name):
return textErr(n, f.Name+" is an attribute of "+n.Head+": write `"+opening(n, f)+"`")
case len(s.fields) == 0:
return textErr(n, "unknown field "+f.Name+": "+n.Head+" takes no fields")
default:
return textErr(n, "unknown field "+f.Name+" of "+n.Head+"; it takes "+strings.Join(s.fields, ", "))
}
}
for _, b := range n.Blocks {
if !slices.Contains(s.blocks, b.Head) {
if len(s.blocks) == 0 {
return textErr(b, n.Head+" holds no blocks, found "+b.Head)
}
return textErr(b, "unknown block "+b.Head+" in "+n.Head+"; it holds "+strings.Join(s.blocks, ", "))
}
}
return nil
}
-60
View File
@@ -1,60 +0,0 @@
package anim
import (
"strings"
"testing"
"aego/format/aetext"
)
func decodeText(t *testing.T, src string, ctrl bool) error {
t.Helper()
n, err := aetext.Parse([]byte(src), "t")
if err != nil {
t.Fatal(err)
}
if ctrl {
_, _, err = DecodeGraph(n)
} else {
_, err = Decode(n)
}
return err
}
// Every block of a clip refuses names it does not know instead of dropping them.
func TestClipRefusesUnknownNames(t *testing.T) {
cases := []struct{ name, src, want string }{
{"root", `anim version=1 length=1 speed=2 {}`, "unknown attribute speed of anim"},
{"root block", "anim version=1 length=1 {\n curve \"\" {}\n}\n", "unknown block curve"},
{"track", "anim version=1 length=1 {\n track \"\" prop=\"SpriteRenderer.size\" kind=vec2 loop=true {}\n}\n", "unknown attribute loop of track"},
{"key", "anim version=1 length=1 {\n track \"\" prop=\"SpriteRenderer.size\" kind=vec2 {\n key t=0 v=[1, 1] ease=linear {}\n }\n}\n", "unknown attribute ease of key"},
{"frames", "anim version=1 length=1 {\n frames \"Slash\" {\n sprites=[asset(\"ae900000000000000000000000000b01\")]\n start=0.08\n }\n}\n", "start is an attribute of frames: write `frames \"Slash\" start=0.08 {`"},
{"frames field", "anim version=1 length=1 {\n frames \"\" {\n sprites=[asset(\"ae900000000000000000000000000b01\")]\n duration=[0.1]\n }\n}\n", "unknown field duration of frames"},
{"event", "anim version=1 length=1 {\n event t=0.1 name=\"Hit\" when=1 {}\n}\n", "unknown attribute when of event"},
{"event field", "anim version=1 length=1 {\n event t=0.1 name=\"Hit\" {\n payload=1\n }\n}\n", "payload is an attribute of event"},
}
for _, c := range cases {
err := decodeText(t, c.src, false)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: got %v, want %q", c.name, err, c.want)
}
}
}
// Every block of a controller refuses names it does not know instead of dropping them.
func TestControllerRefusesUnknownNames(t *testing.T) {
cases := []struct{ name, src, want string }{
{"root", "controller version=1 start=\"Idle\" {\n state \"Idle\" {}\n}\n", "unknown attribute start of controller"},
{"root field", "controller version=1 {\n ease=linear\n state \"Idle\" {}\n}\n", "ease is an attribute of controller"},
{"param", "controller version=1 {\n param \"Speed\" kind=float min=0 {}\n}\n", "unknown attribute min of param"},
{"state", "controller version=1 {\n state \"Idle\" clipp=asset(\"a0a10000000000000000000000000005\") {}\n}\n", "unknown attribute clipp of state"},
{"transition", "controller version=1 {\n state \"A\" {}\n state \"B\" {}\n transition \"A\" to=\"B\" exit=1 {}\n}\n", "unknown attribute exit of transition"},
{"cond", "controller version=1 {\n param \"Speed\" kind=float {}\n state \"A\" {}\n state \"B\" {}\n transition \"A\" to=\"B\" {\n cond \"Speed\" op=gt val=1 {}\n }\n}\n", "unknown attribute val of cond"},
}
for _, c := range cases {
err := decodeText(t, c.src, true)
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: got %v, want %q", c.name, err, c.want)
}
}
}
-521
View File
File diff suppressed because it is too large Load Diff
-87
View File
@@ -1,87 +0,0 @@
package anim
import (
"aego/core/ids"
"aego/format/aetext"
"aego/schema"
)
type Value struct {
L [4]float32
I int64
A ids.AssetID
}
func Float(f float32) Value { return Value{L: [4]float32{f}} }
func Lanes(l [4]float32) Value { return Value{L: l} }
func Int(i int64) Value { return Value{I: i} }
func Asset(id ids.AssetID) Value { return Value{A: id} }
func (v Value) Truth() bool { return v.I != 0 }
func Bool(b bool) Value {
if b {
return Value{I: 1}
}
return Value{}
}
type class uint8
const (
classNone class = iota
classLanes
classInt
classDiscrete
classQuat
classAsset
)
func classOf(k schema.Kind) class {
switch k {
case schema.KindFloat32, schema.KindFloat64, schema.KindVec2, schema.KindVec3, schema.KindVec4, schema.KindColor, schema.KindRect:
return classLanes
case schema.KindInt32, schema.KindInt64:
return classInt
case schema.KindBool, schema.KindEnum:
return classDiscrete
case schema.KindQuat:
return classQuat
case schema.KindAssetRef:
return classAsset
}
return classNone
}
func lanesOf(k schema.Kind) int {
switch k {
case schema.KindFloat32, schema.KindFloat64:
return 1
case schema.KindVec2:
return 2
case schema.KindVec3:
return 3
}
return 4
}
func Supported(k schema.Kind) bool {
return classOf(k) != classNone
}
func Interpolates(k schema.Kind) bool {
c := classOf(k)
return c == classLanes || c == classQuat
}
// LaneCount is the number of float lanes of a lane kind, 0 for discrete kinds.
func LaneCount(k schema.Kind) int {
if classOf(k) != classLanes && classOf(k) != classQuat {
return 0
}
return lanesOf(k)
}
// ValueText writes v the way a key stores it in .aeanim.
func ValueText(kind schema.Kind, v Value) aetext.Value {
return writeValue(kind, v)
}
-549
View File
File diff suppressed because it is too large Load Diff
-265
View File
File diff suppressed because it is too large Load Diff
-61
View File
@@ -1,61 +0,0 @@
package agent
import (
"strconv"
"strings"
"testing"
)
func tree(depth, fan int) NodeView {
v := NodeView{Name: "n"}
if depth > 0 {
for i := 0; i < fan; i++ {
v.Children = append(v.Children, tree(depth-1, fan))
}
}
return v
}
func TestCapTreeKeepsTheFirstNodes(t *testing.T) {
v := tree(4, 4)
all := nodeCount(&v)
left := capTree(&v, 50, 0)
if kept := nodeCount(&v); kept != 50 || kept+left != all {
t.Fatalf("kept %d and left %d of %d", kept, left, all)
}
more := 0
var walk func(n *NodeView)
walk = func(n *NodeView) {
more += n.More
for i := range n.Children {
walk(&n.Children[i])
}
}
walk(&v)
if more != left {
t.Fatalf("the more counts add up to %d, %d were left out", more, left)
}
v = tree(4, 2)
if left := capTree(&v, 0, 2); nodeCount(&v) != 7 || left != 31-7 {
t.Fatalf("depth 2 kept %d nodes", nodeCount(&v))
}
}
func TestScriptWindow(t *testing.T) {
var b strings.Builder
for i := 1; i <= 1000; i++ {
b.WriteString("line " + strconv.Itoa(i) + "\n")
}
text := b.String()
if got, note := window(text, 0, 0, 2000); got != text || note != "" {
t.Fatal("a short script is not read whole")
}
got, note := window(text, 0, 0, 400)
if !strings.HasPrefix(got, "line 1\n") || !strings.HasSuffix(got, "line 400\n") || !strings.Contains(note, "1-400 of 1001") {
t.Fatalf("the first window is wrong: %q", note)
}
got, _ = window(text, 600, 620, 400)
if !strings.HasPrefix(got, "line 600\n") || !strings.HasSuffix(got, "line 620\n") {
t.Fatalf("the asked range is wrong: %q", got[:20])
}
}
-153
View File
File diff suppressed because it is too large Load Diff
-85
View File
@@ -1,85 +0,0 @@
package agent
import (
"bytes"
"encoding/json"
"testing"
)
func mustFind(t *testing.T, name string) *Command {
t.Helper()
c, ok := Find(name)
if !ok {
t.Fatalf("no command %s", name)
}
return c
}
func TestParseArgs(t *testing.T) {
set := mustFind(t, "scene.set")
a, f, err := parseArgs(set, []string{"s.aescene", "--json", "Player", "--dry-run", "Body2D.mass", "-3"})
if err != nil || !f.json || a.Str("value") != "-3" || a.Str("node") != "Player" || !a.Bool("dry-run") {
t.Fatalf("flags after positionals: %v %+v %v", a, f, err)
}
add := mustFind(t, "scene.add-node")
for _, toks := range [][]string{{"s", "N", "--index=1"}, {"s", "N", "--index", "1"}} {
a, _, err := parseArgs(add, toks)
if n, _ := a.Int("index", -1); err != nil || n != 1 {
t.Fatalf("%v: %v %v", toks, a, err)
}
}
a, _, err = parseArgs(set, []string{"s", "--", "Player", "Body2D.mass", "--odd"})
if err != nil || a.Str("value") != "--odd" {
t.Fatalf("after --: %v %v", a, err)
}
if _, _, err := parseArgs(set, []string{"s", "--nope"}); err == nil {
t.Fatal("an unknown flag was accepted")
}
if _, _, err := parseArgs(set, []string{"s", "a", "b", "c", "d"}); err == nil {
t.Fatal("an extra positional was accepted")
}
var out, errw bytes.Buffer
if code := Main(nil, []string{"scene", "set", "s", "--nope"}, &out, &errw, Options{}); code != 2 {
t.Fatalf("unknown flag exit %d", code)
}
if code := Main(nil, []string{"scene", "set", "a", "b", "c", "d", "e"}, &out, &errw, Options{}); code != 2 {
t.Fatalf("extra positional exit %d", code)
}
if code := Main(nil, []string{"scene", "help"}, &out, &errw, Options{}); code != 0 {
t.Fatalf("help exit %d", code)
}
}
func TestSchemasAndToolNames(t *testing.T) {
seen := map[string]bool{}
for _, c := range Commands() {
if seen[c.Tool()] {
t.Fatalf("tool name %s is used twice", c.Tool())
}
seen[c.Tool()] = true
b, err := json.Marshal(c.Schema())
if err != nil {
t.Fatal(err)
}
var sch struct {
Type string `json:"type"`
Properties map[string]map[string]any `json:"properties"`
Required []string `json:"required"`
}
if err := json.Unmarshal(b, &sch); err != nil || sch.Type != "object" {
t.Fatalf("%s: %s", c.Name, b)
}
var want []string
for _, x := range c.AllArgs() {
if x.Required {
want = append(want, x.Name)
}
if _, ok := sch.Properties[x.Name]; !ok {
t.Fatalf("%s: no property %s", c.Name, x.Name)
}
}
if len(want) != len(sch.Required) {
t.Fatalf("%s: required %v, want %v", c.Name, sch.Required, want)
}
}
}
File diff suppressed because it is too large Load Diff
-194
View File
File diff suppressed because it is too large Load Diff
-94
View File
@@ -1,94 +0,0 @@
package mcp
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"time"
)
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
srv *http.Server
s *Server
}
// ListenHTTP serves on 127.0.0.1 at a free port with a fresh bearer token.
func ListenHTTP(opt Options) (*HTTP, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
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.s.ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("/mcp", h.serve)
h.srv = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second}
go h.srv.Serve(ln)
return h, nil
}
func (h *HTTP) authorized(r *http.Request) bool {
got := r.Header.Get("Authorization")
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.
func local(r *http.Request) bool {
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
host = r.Host
}
return host == "127.0.0.1" || host == "localhost"
}
func (h *HTTP) serve(w http.ResponseWriter, r *http.Request) {
switch {
case !local(r):
http.Error(w, "forbidden", http.StatusForbidden)
return
case !h.authorized(r):
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
case r.Method != http.MethodPost:
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
rep, ok := h.s.respond(raw)
if !ok {
w.WriteHeader(http.StatusAccepted)
return
}
w.Header().Set("Content-Type", "application/json")
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.Encode(rep)
}
func (h *HTTP) Close() error {
return h.srv.Close()
}
// Definitions is what the model reads about this server before the first message.
func (h *HTTP) Definitions() string {
return h.s.Definitions()
}
-60
View File
@@ -1,60 +0,0 @@
package mcp
import (
"bufio"
"encoding/json"
"io"
"aego/app/agent"
)
// Relay passes an outside agent's MCP session to the open editor, so its changes become the editor's Undo steps, named after the client.
func Relay(in io.Reader, out io.Writer, addr, token string) error {
client := "agent"
sc := bufio.NewScanner(in)
sc.Buffer(make([]byte, 0, 1<<20), maxBody)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var msg map[string]any
if err := json.Unmarshal(line, &msg); err != nil {
continue
}
params, _ := msg["params"].(map[string]any)
switch msg["method"] {
case "initialize":
if info, ok := params["clientInfo"].(map[string]any); ok {
if name, ok := info["name"].(string); ok && name != "" {
client = name
}
}
case "tools/call":
if params != nil {
args, _ := params["arguments"].(map[string]any)
if args == nil {
args = map[string]any{}
}
args[agent.ViaArg] = "via agent: " + client
params["arguments"] = args
}
}
body, _ := json.Marshal(msg)
rep, err := agent.ProxyPost(addr, token, body)
if err != nil {
id, ok := msg["id"]
if !ok {
continue
}
rep, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32000, "message": "the editor closed; start the MCP server again: " + err.Error()}})
}
if len(rep) == 0 {
continue
}
if _, err := out.Write(append(rep, '\n')); err != nil {
return err
}
}
return sc.Err()
}
File diff suppressed because it is too large Load Diff
-67
View File
@@ -1,67 +0,0 @@
package mcp
import (
"aego/app/agent"
"aego/project"
)
// Source runs the tool catalog: the CLI opens a session per call, the editor runs on its own documents (D341).
type Source interface {
Run(name string, a agent.Args) (*agent.Output, error)
Paths() ([]string, error)
Project() *project.Project
Rel(p string) (string, error)
}
// Extra is a resource a Source adds to the list, such as the editor's selection.
type Extra struct {
URI string
Name string
Description string
MimeType string
Read func() (string, error)
}
// Tool is a tool the host adds next to the agent commands, such as the editor's ask_user.
type Tool struct {
Name string
Description string
Schema map[string]any
ReadOnly bool
// Run answers the call; it may block while the user decides.
Run func(args map[string]any) (text string, failed bool)
}
type kitSource struct {
kit *agent.Kit
}
func (k kitSource) Run(name string, a agent.Args) (*agent.Output, error) {
sess, err := k.kit.Open()
if err != nil {
return nil, err
}
defer sess.Close()
return agent.Run(sess, name, a)
}
func (k kitSource) Paths() ([]string, error) {
sess, err := k.kit.Open()
if err != nil {
return nil, err
}
defer sess.Close()
var out []string
for _, a := range sess.Pipeline.Assets() {
out = append(out, a.Path)
}
return out, nil
}
func (k kitSource) Project() *project.Project {
return k.kit.Project
}
func (k kitSource) Rel(p string) (string, error) {
return k.kit.Rel(p)
}
-225
View File
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
-149
View File
File diff suppressed because it is too large Load Diff
-237
View File
File diff suppressed because it is too large Load Diff
-641
View File
File diff suppressed because it is too large Load Diff
-242
View File
File diff suppressed because it is too large Load Diff
-244
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-65
View File
@@ -1,65 +0,0 @@
// 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
import (
"encoding/json"
"io"
)
type EventKind uint8
const (
EvText EventKind = iota
EvToolStart
EvToolResult
EvTurnEnd
EvError
// EvUsage reports Tokens, the context the model read for its last answer, and Model.
EvUsage
// EvCompact reports that the CLI compacted the history; Tokens is the context before it.
EvCompact
)
// ToolPrefix is how Claude Code names the tools of the aego MCP server.
const ToolPrefix = "mcp__aego__"
type Event struct {
Kind EventKind
Text string
Tool string
CallID string
Args json.RawMessage
Result string
Failed bool
Err error
// Cost in US dollars and Millis of the answer, on EvTurnEnd.
Cost float64
Millis int
// Tokens on EvUsage and EvCompact; Window is the model's context size, on EvTurnEnd when the CLI reports it.
Tokens int
Window int
Model string
}
type Config struct {
Dir string
Command string
MCPURL string
Token string
System string
// Record gets every line the CLI writes, for recording sessions the tests replay.
Record io.Writer
}
type Conversation interface {
Send(text string) error
Events() <-chan Event
Cancel()
Close() error
}
type Backend interface {
Name() string
Available(cfg Config) (string, error)
Start(cfg Config) (Conversation, error)
}
File diff suppressed because it is too large Load Diff

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