48 Commits
Author SHA1 Message Date
sanspie 824aa2e9a0 tests: the agent panel and key spacing checks allow a busy runner's noise
ci-platforms / darwin arm64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 11m19s
ci / checks (push) Successful in 9m7s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 9m50s
2026-10-26 22:20:54 +03:00
sanspie c2736c158f ci: the release job takes AEGO_UPDATE_KEY from a variable or a secret
ci-platforms / darwin amd64 smoke (push) Skipped
ci / checks (push) Successful in 6m58s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 6m7s
2026-10-26 21:46:40 +03:00
sanspie d4b5b88be1 Release 0.11.0 2026-10-26 21:09:36 +03:00
sanspie 78e86970d7 scene_run: a run with screenshots steps a fixed frame time, so it moves the game like a run without them 2026-10-26 21:09:27 +03:00
sanspie a6ebf841d0 Stage 11b8: agents draw their art (D363)
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 9m10s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m25s
ci / release (push) Failing after 4m59s
asset.pixels: pixel art as a character grid and a palette; frames of one size become an atlas with a sprite per frame and a flipbook clip; a preview image in the answer.
asset.svg: an SVG subset drawn by an in-tree rasterizer (format/svg) for tiles, UI and icons.
art module: project.aego art { palette pixelsPerUnit }, the default palette and scale for drawings; Project Health HLT009 for pixel art outside the palette.
The agent prompts say to draw instead of building sprites from rectangles.
Agent runs open the asset index shared without a warning; prefab.create on a scene open in the editor goes through the prefab service, so unsaved scenes work.
scene_run with shot names xvfb when there is no display; local make ci lists untracked files outside .gitignore.
2026-10-26 19:36:15 +03:00
sanspie f5944907cc launcher: new projects go to AegoProjects, without a space 2026-10-26 19:36:15 +03:00
sanspie d436311f69 Stage 11i4: signed self-updates for Aego and exported games (D362)
update core: signed release.json (Ed25519) per channel, ETag check, resumable download with sha256, Auto/Ask/Off policy, appliers for the install.sh layout, AppImage, NSIS installer (keeps the replaced setup.exe for Restore) and .app, trial run with rollback.
Editor and launcher: banner, status bar, Help commands, Task Center download, install on restart.
CLI: aego update, aego release keygen|manifest, aego export --release-json.
Games: modules/update with update { url publicKey }, settings and the aego/update script API.
release.sh refuses to publish without AEGO_RELEASE_KEY and AEGO_UPDATE_KEY; install.sh lays out versions; make test-update runs a real v1 -> v2 update.
2026-10-26 19:36:15 +03:00
sanspie 6d7adba259 agent: scene_run screenshots keep the run on one OS thread
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci / checks (push) Successful in 7m51s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m18s
ci / release (push) Successful in 12m21s
A run with screenshots opens a hidden window with a GL context; the goroutine running it could move to another OS thread, and the shaders then failed to compile now and then. The run is locked to its thread while it lasts.
2026-10-26 16:31:31 +03:00
sanspie 1e28291320 repo: no build artifacts in the root
Builds and test binaries belong in .cache/bin. The root .gitignore catches stray builds (aego-*, i18n, profiles, data dumps), make affected builds into .cache/bin/affected, make clean empties .cache/bin, and make ci ends with tools/ci-clean-root.sh: it fails when the root holds an untracked executable or a *.test file, and in CI when the run left changes git sees. .claude/CLAUDE.md tells agents to build only into .cache/bin and to run make ci before every push.
2026-10-26 15:53:47 +03:00
sanspie 49c9e97203 Polish 11: translator notes
The notes of the Russian translation are worked off. GMP001 names the component as the editor does, Rigid Body 2D. The editor guide points at Play > Never Show Play Changes, which is where the list is turned off. The AI preset of Debug Draw has a translated name. The Coroutines section of the language reference has its Waiting heading back before the yield table and says call into a script instead of callback. The catalog key of a method named like a field is api.<id>.method instead of carrying parentheses. The 21 easing presets say how each one moves and what it suits. Data types such as Audio Event get catalog entries for their names and fields, so the Inspector and the menus show them translated, and Audio Ranges has a translated name.

The Russian table of agent commands in cli.ru.md is generated from the catalog like the English one, so a new or changed command shows up as a missing translation instead of a stale row.
2026-10-26 15:52:53 +03:00
sanspie a1eb467600 Stage 11b6: QA tools for the agent and the editor's proxy (D341 amended)
scene_run plays the game the way a player would and checks the result in the same run. input presses actions by frame: press, hold over a range, release, and axis values. assert reads values with the paths scene_set takes, at a frame (@120) or at any frame of the run, with ~= comparing numbers within 0.01, and exists or gone for nodes; the report gives ok or failed with the value and the frame, a failed check fails the run, and the CLI exits with 2. shot captures up to three frames from a run with a hidden window: the answer carries them as images scaled to 640 wide (scale changes it) and the full frames stay under .aego/cache/runs.

While the editor is open, the CLI and aego mcp go through it. The editor serves the agent commands on a second loopback MCP server over the same bridge and writes its address in .aego/editor.lock. A change becomes an Undo step named after its origin, via CLI or via agent with the client's name, and asset creation works because the editor does it. Window > Confirm Changes from Outside Agents (editor.proxy.confirm) makes these changes wait in the Agent panel instead; follow mode shows outside calls like the panel's agent's. When the editor does not answer, the CLI works on the files as before.

The native Windows and macOS jobs of ci-platforms are named smoke: the Windows build itself is cross-compiled on the Linux runner.
2026-10-26 15:39:35 +03:00
sanspie a25db02077 Stage 11b5c: context meter, read budgets, new sessions from a summary (D343 amended)
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 7m55s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m8s
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / release (push) Successful in 8m55s
The Agent panel counts the session's context. A gauge in the header shows how full the model's window is, from the CLI's usage on each answer and the window it reports; it turns yellow past 80 % and near compaction. Its popup lays the window out as one bar and a row per part: messages, scene, code, docs, project and schema, tool results, the editor's instructions and tool definitions, the CLI's own prompt, the compaction buffer and free room. The parts are the editor's estimate of what it sent, scaled to the usage; on a recorded session the estimate stays within 15 % of the CLI's count.

A part opens to its contributions with Drop and Pin. The CLI cannot edit its history, so Drop starts the next message in a new session with a shortened history: messages in full, results as summaries, pinned ones in full, dropped ones left out. New Session with Summary asks the agent for a summary and sends it, with the names of the changed nodes, with the next message. The open scene and the selection can go with every message. A compaction shows in the transcript.

Read tools keep large reads small: scene_tree up to a number of nodes and levels with the rest counted and a node argument for a part, docs_search up to a number of results, script_read in parts for a long script. The limits are in the popup and in prefs.aetext. MCP results are compact JSON. scene_run through the editor answers ok when the game ran.
2026-10-26 15:11:53 +03:00
sanspie 239a449fe2 agent: scripts on nodes, scene_run in the editor, following returns to the scene
ci / checks (push) Successful in 5m14s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m39s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / release (push) Successful in 5m44s
The agent attaches and removes scripts with add-comp and remove-comp by the script's type name, reads and sets their exported fields like component fields with short names (EnemyAI.PointA), and node reference fields take a node path. scene_get lists a node's script fields.

scene_run works inside the editor: the game runs headless in a child process of the editor's own binary (--tool agent-run), with the open scene as the editor has it, unsaved changes included; scripts with unsaved changes are named in the answer. watch samples the world positions of nodes through the run, so the agent can check that something moves.

Following brings the Scene View back in front of the code editor on a scene step and shows the Inspector, and a scene_apply leads to the node its transaction names.
2026-10-26 02:16:45 +03:00
sanspie b6cc16531d tests: the setting-changed test picks a volume that differs from the saved one, so it passes when run again in the same process
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 6m34s
ci / release (push) Successful in 5m9s
ci / checks (push) Successful in 4m47s
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
2026-10-26 01:42:55 +03:00
sanspie 70be14646a Stage 11b5b: follow mode and agent presence (D343 amended)
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 9m1s
ci / release (push) Successful in 11m0s
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 6m39s
Follow mode leads the editor after the agent: each tool call opens the scene, selects the node in a second selection that Delete and Copy never read, frames it smoothly and shows it in the Inspector at the component; scripts open at the line without taking the keyboard, with the lines of a waiting card as ghost lines and changed lines marked in the gutter; runs and checks bring the Console or Problems forward with new entries lit. Changed fields light for two seconds, new nodes and components light whole, removed nodes fade where they were.

The agent's avatar marks where it works in the Hierarchy, the Scene View and the Inspector; the Agent panel says where it is, with a timeline of its steps to go back to them or undo to one. Follow is the eye in the header, Window → Follow the Agent or Ctrl+Alt+F, kept in prefs.aetext; the user's own input pauses it until Resume or 30 idle seconds. A user edit of a field a waiting card changes is made again after the card. During Play the agent edits the running game's scene, so its changes show in Play changes.

editor/core/presence holds who works where; ui/widgets/presence draws it. backend.Replay plays a recorded stream-json session against the editor's MCP server for the tests.
2026-10-26 00:52:59 +03:00
sanspie 70534c271c Stage 11b5a: Agent panel UI (D343 amended)
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 5m42s
ci / checks (push) Successful in 5m48s
ci / release (push) Skipped
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
Steps instead of a raw log: every tool declares an icon, a title from its arguments and a summary of its result; read-only steps fold into one row, a step opens to its result laid out as search results, a node tree, fields, components, assets or a log, with links to what it names. Cards show changes by node with Inspector labels and names instead of ids, with Apply, Dry-run and Skip, then Undo and Select; Apply All sits over the first of several waiting cards; a failed step has Retry and Ask Agent to Fix. The agent asks with ask_user, a card of options that waits for the answer. A header with the session and what the agent does, a menu with New Session, History, Plan Only and Stop, @ chips, Stop in place of Send, the cost and time of the last answer, an empty state with examples.

The transcript is virtual: blocks are measured once per width and only those in view are laid out and drawn; text is laid out once per message. 500 messages stay within the frame budget and an idle panel allocates nothing.

script.read, script.edit, script.write and script.create change scripts through the open buffer or on disk, with Undo on the card and the compiler's AES diagnostics in the answer. docs_search matches several words against an entry and its parent, so a component and its signal find the signal.
2026-10-26 00:19:21 +03:00
sanspie a15d2a1fe3 render: the editor's sprite batch grows instead of dropping; the profiler skips graphs out of view and merges sub-pixel flame events
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / checks (push) Successful in 5m5s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m25s
ci / release (push) Successful in 5m14s
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
A busy frame (the profiler's 30 s graphs and a long agent transcript) passed the 65536 sprites of the editor's batch, and everything drawn after it, the right column and the menus, was dropped. The editor and the launcher now let the batch double up to the submission limit; games keep MaxSprites as their budget.
2026-10-25 23:47:58 +03:00
sanspie 6e8ebcd90f docs: the scenes docs check plays after each step stay out of the pages
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / checks (push) Successful in 5m39s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m49s
ci / release (push) Successful in 7m23s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
2026-10-25 18:13:09 +03:00
sanspie adcdd5fbad engine: version 0.11.0-dev until the 0.11.0 release 2026-10-25 18:11:12 +03:00
sanspie 0ec28ad86b web: fullscreen keeps the touch gamepad; the gamepad hides while the settings screen is open; settings tabs wrap and buttons shrink on a narrow screen
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / release (push) Successful in 4m43s
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci / checks (push) Successful in 4m59s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m26s
2026-10-25 17:49:02 +03:00
sanspie ba9d35443f smoke: a one-off runtime allocation in the measured frames gets a second window; the alloc profile rate is set before goroutines start
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 5m59s
ci / release (push) Successful in 4m44s
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 3m53s
2026-10-25 17:16:09 +03:00
sanspie 0fa39a3e17 11g3: one Russian label for a diagnostic's fix, Исправление
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci / checks (push) Successful in 4m33s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 6m51s
ci / release (push) Skipped
2026-10-25 17:06:09 +03:00
sanspie fa6f37b7f5 cli: aego version prints the engine version and the commit it was built from
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 4m14s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 6m11s
ci / release (push) Successful in 5m10s
2026-10-25 16:31:41 +03:00
sanspie 6151233e05 11g3: the Try it toolbar and widget texts in Russian
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 3m53s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 6m34s
ci / release (push) Successful in 4m47s
2026-10-25 16:17:32 +03:00
sanspie 53ed8c6476 site: new tagline and lead; agents only in their card and the llms.txt footer link
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / checks (push) Successful in 3m45s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 3m53s
ci / release (push) Skipped
2026-10-25 16:10:37 +03:00
sanspie ff896506f5 11g3: Try it, the download block and kind labels in Russian; the Try it view follows the page language
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 6m21s
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / checks (push) Successful in 4m45s
ci / release (push) Successful in 5m28s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
2026-10-25 16:00:13 +03:00
sanspie 8a842b19a6 11g3: the rest of the Russian site: llms.txt lead, module pages, project sections, handle types, field notes, diagnostic phases and notes
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / release (push) Successful in 8m36s
ci / checks (push) Successful in 3m54s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 5m59s
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
2026-10-25 15:51:54 +03:00
sanspie f22242e5aa Stage 11g2–11g3: registry texts, CLI and the site in Russian (D348)
ci / release (push) Successful in 5m5s
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / checks (push) Successful in 4m41s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 7m14s
The remaining editor strings and undo step names; component, field, enum, setting, diagnostic and Script API texts under schema.*, enum.*, setting.*, diag.* and api.*; the launcher, the in-game Settings View with a Language setting, the CLI and aego schema show --lang. Every documentation page has a .ru.md translation; the site has a full Russian tree with hreflang, a language switch and ru/llms.txt; aego docs check --lang ru. New doc.tutorial and doc.recipe icons. Fix: an animator restored by a rewind prepares its controller before the replayed step.
2026-10-25 15:31:22 +03:00
sanspie 2e10968d48 Stage 11f3: landing, Getting started, guides; demo fixes (D347, D289)
Landing page and Install Aego / Your first project in English and Russian; the 2D template matches them. Platformer from scratch and Recipes with every step scene run by docs check. Builtin data and handle methods in the reference, Inspector names in field tables, Markdown copies and llms-full.txt, strict docs check in CI, engine/docs/README.md.
Fixes: a body cast from a touching contact uses the face normal, so characters no longer stick after a respawn or against walls; Respawn.resetVelocity works; a touch gamepad on exported web pages.
2026-10-25 15:31:22 +03:00
sanspie 2fcbaa6c12 Stage 11g1: the editor in Russian (D348)
engine/i18n with catalogs, plural forms and module catalogs; tools/i18n extract and check; the editor UI, menus, panels and notices in Russian with the author's terms; overflow test and tooltip wrapping; the English log.
2026-10-25 15:31:22 +03:00
sanspie 67e2d77823 Stage 11f1–11f2: documentation style and rewrite (D347)
docs/style.md, docs/glossary.md, style checks DOC010–DOC023 and docs report; the site in a light theme; the Editor, Aego Script, Gameplay, Physics, Scenes, Rendering, Tools and Reference sections rewritten.
2026-10-25 15:31:22 +03:00
sanspie 55871f666b Stage 11e: rewind keyframes (D346)
Adaptive keyframes with age thinning, a scrub cache, allocation-free replay, idle pacing in paused Play; job workers start before the engine returns.
2026-10-25 15:31:22 +03:00
sanspie ef59ae297f Stage 11d: light theme (D345)
Theme tokens for every editor color with a literal check, System/Dark/Light preference that follows the OS, light goldens, Scene View overlays that keep viewport colors.
2026-10-25 15:31:22 +03:00
sanspie 7b83e82c6e Stage 11b: AI agent in the editor (D341–D343)
MCP over loopback HTTP and an editor session over open documents; proposals with apply, reject, auto-apply and card undo; the Agent panel with Markdown answers, @ mentions, Ask Agent from Hierarchy and Problems, session logs.
2026-10-25 15:31:22 +03:00
sanspie 515f4ca1b3 Stage 11a: animation (D333–D340, D344, D354–D357)
Clips (.aeanim) and controllers (.aectrl) with importers, AnimationPlayer and Animator with crossfades, root motion and events on fixed steps, rewind support. Editor: Animation panel (dope sheet, curves, record, preview, trajectory), Animator panel, sprite sheet slicer and flipbooks, Programming workspace, export panel redesign. Motion 2D ledge climb. 11c: the component and Create palette.
2026-10-25 15:31:22 +03:00
sanspie 3d8e3b2fd3 Web export: the page build key covers the runtime and scripts, so a new engine gets new immutable URLs
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / release (push) Successful in 5m0s
ci / checks (push) Successful in 3m50s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 4m21s
2026-10-22 19:26:12 +03:00
sanspie c6a24ba393 Try it: scene keys in the hint, J label on Health and Hurtbox, faster Respawn, sound starts with the Try click; lighter web audio pump; release notes from tools/release/notes
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / release (push) Successful in 7m34s
ci / checks (push) Successful in 2m53s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 5m2s
2026-10-22 18:55:33 +03:00
sanspie 7dca06e4b1 Icons in the engine style for gameplay, shake, clips, curves, dice, debug draw and install; web loader retries a stalled download
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) Successful in 17m5s
ci / release (push) Successful in 9m30s
ci / checks (push) Successful in 2m25s
2026-10-22 17:38:02 +03:00
sanspie 6dfdb824e2 Stage 10d3b: physics debug layers follow Game View overlays, PHY diagnostics pages
ci / checks (push) Successful in 3m4s
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) Successful in 3m53s
ci / release (push) Successful in 5m37s
2026-10-22 16:25:06 +03:00
sanspie de49a53172 Stage 10d3: docs, site demo, check levels, boundary audit (D316)
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / checks (push) Successful in 21m50s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 6m46s
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci / release (push) Successful in 17m42s
2026-10-22 16:00:33 +03:00
sanspie 1030dc9a43 Stage 10d2: audio fast path, shutdown order, profiler and monitor sections
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 4m58s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 27m47s
ci / release (push) Successful in 11m57s
2026-10-22 14:41:24 +03:00
sanspie 3d25734dfa Stage 10d1: add-component requirements, play diff from history, node labels; one sound button on the web
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 1s
ci / checks (push) Failing after 1s
ci / release (push) Skipped
2026-10-22 13:29:11 +03:00
sanspie 12f628c469 Stage 10c3: platformer and top-down examples on the gameplay kit, new music 2026-10-22 12:16:37 +03:00
sanspie 1529213434 Stage 10c2: health, hitboxes, zones, respawn, pickups, lifetime, templates, StateMachine, gameplay editor tools (D313, D330, D332) 2026-10-22 10:53:54 +03:00
sanspie f8bb865920 Stage 10c1: shape casts and Motion2D (D312, D328, D329) 2026-10-22 03:01:46 +03:00
sanspie f5fb32565e Stage 10b3: desktop onboarding, app icon, clip inspector, asset grid audio cards, web audio and docs fixes (D327) 2026-10-22 03:01:46 +03:00
sanspie f1a8f538d3 Stage 10b: audio mixer, codecs, clips, events, sources, zones and mixer UI (D307–D311, D321–D326) 2026-10-21 20:33:25 +03:00
sanspie 963b01a011 Docs: Try it for every component, quieter design, self-hosted fonts, OS downloads, page meta 2026-10-21 19:20:52 +03:00
1393 changed files with 121348 additions and 4939 deletions
+4
View File
@@ -0,0 +1,4 @@
# 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.
+3 -1
View File
@@ -17,7 +17,9 @@ env:
libasound2-dev libpulse-dev libdrm-dev libgbm-dev libudev-dev libdbus-1-dev libibus-1.0-dev
jobs:
build:
# 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
if: vars.PLATFORM_RUNNERS == 'true'
strategy:
fail-fast: false
+7
View File
@@ -37,6 +37,10 @@ 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:
@@ -123,6 +127,9 @@ jobs:
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
+8
View File
@@ -18,3 +18,11 @@ 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
+174 -29
View File
File diff suppressed because one or more lines are too long
+91 -21
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
# 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
@@ -0,0 +1,98 @@
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
@@ -0,0 +1,87 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,59 @@
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
@@ -0,0 +1,73 @@
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
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,87 @@
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
@@ -0,0 +1,61 @@
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])
}
}
+7
View File
@@ -2,6 +2,7 @@ package agent
import (
"encoding/json"
"errors"
"fmt"
"io"
"strings"
@@ -47,6 +48,12 @@ func Main(p *project.Project, args []string, stdout, stderr io.Writer, opt Optio
fmt.Fprintln(stderr, "usage: aego "+c.Usage())
return 2
}
if addr, token, ok := EditorProxy(p.Root); ok {
out, err := proxyRun(addr, token, c, a)
if !errors.As(err, new(errProxyDown)) {
return report(stdout, stderr, f, out, err)
}
}
s, err := Open(p, opt)
if err != nil {
return report(stdout, stderr, f, nil, err)
+33 -3
View File
@@ -25,7 +25,11 @@ type Command struct {
Doc string
Args []Arg
Write bool
Run func(s *Session, a Args) (*Output, error)
// Destroys marks commands that remove or move things; the editor never applies them without asking (D342).
Destroys bool
// Look is how the editor's Agent panel shows a call; every command has one.
Look Look
Run func(s *Session, a Args) (*Output, error)
}
type Args map[string]any
@@ -40,6 +44,14 @@ type Output struct {
Notes []string `json:"notes,omitempty"`
Value any `json:"value,omitempty"`
Text string `json:"-"`
// Images go to the model as image content, such as the screenshots of a run.
Images []Image `json:"-"`
}
// Image is a picture an answer carries.
type Image struct {
Mime string
Data []byte
}
var dryRun = Arg{Name: "dry-run", Type: "boolean", Doc: "report the changes without writing anything"}
@@ -51,9 +63,17 @@ func catalog() []*Command {
out = append(out, sceneCommands()...)
out = append(out, prefabCommands()...)
out = append(out, dataCommands()...)
out = append(out, animCommands()...)
out = append(out, ctrlCommands()...)
out = append(out, refsCommands()...)
out = append(out, assetCommands()...)
out = append(out, schemaCommands()...)
out = append(out, projectCommands()...)
out = append(out, scriptCommands()...)
ls := looks()
for _, c := range out {
c.Look = ls[c.Name]
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
@@ -254,9 +274,19 @@ func (o *Output) Print(w io.Writer) {
}
func Markdown(w io.Writer) {
fmt.Fprintln(w, "| Command | Meaning |")
MarkdownIn(w, "Command", "Meaning", func(c *Command) string { return c.Doc })
}
// MarkdownIn is the command table of the CLI reference with its headings and descriptions in another language.
func MarkdownIn(w io.Writer, command, meaning string, doc func(c *Command) string) {
fmt.Fprintln(w, "| "+command+" | "+meaning+" |")
fmt.Fprintln(w, "|---|---|")
for _, c := range all {
fmt.Fprintf(w, "| `aego %s` | %s |\n", strings.ReplaceAll(c.Usage(), "|", `\|`), c.Doc)
fmt.Fprintf(w, "| `aego %s` | %s |\n", strings.ReplaceAll(c.Usage(), "|", `\|`), doc(c))
}
}
// DocKey is the catalog key of a command's description, for the translated CLI reference.
func DocKey(c *Command) string {
return "cli.agent." + c.Name
}
+94
View File
@@ -0,0 +1,94 @@
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
@@ -0,0 +1,60 @@
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()
}
+94 -35
View File
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
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)
}
+49 -4
View File
@@ -22,8 +22,8 @@ func prefabCommands() []*Command {
{Name: "parent", Type: "string", Doc: "parent node, the root by default"}, {Name: "name", Type: "string", Doc: "name of the instance"}, argIndex}, Run: (*Session).prefabInstantiate},
{Name: "prefab.apply", Doc: "write instance overrides into the prefab file", Write: true,
Args: []Arg{argFile, argNode, level, owner, field, {Name: "nearest", Type: "boolean", Doc: "apply to the nearest nested prefab"}}, Run: (*Session).prefabApply},
{Name: "prefab.revert", Doc: "drop instance overrides", Write: true, Args: []Arg{argFile, argNode, level, owner, field}, Run: (*Session).prefabRevert},
{Name: "prefab.unpack", Doc: "turn an instance into plain nodes", Write: true,
{Name: "prefab.revert", Doc: "drop instance overrides", Write: true, Destroys: true, Args: []Arg{argFile, argNode, level, owner, field}, Run: (*Session).prefabRevert},
{Name: "prefab.unpack", Doc: "turn an instance into plain nodes", Write: true, Destroys: true,
Args: []Arg{argFile, argNode, {Name: "deep", Type: "boolean", Doc: "unpack nested prefabs too"}}, Run: func(s *Session, a Args) (*Output, error) {
return s.sceneTx(a, "Unpack Prefab", mutate.Unpack(a.Str("node"), a.Bool("deep")))
}},
@@ -44,7 +44,10 @@ func levelOf(a Args, def string) (prefab.Level, error) {
}
func (s *Session) prefabCreate(a Args) (*Output, error) {
rel, before, doc, err := s.scene(a)
if out, ok, err := s.prefabCreateOpen(a); ok {
return out, err
}
rel, before, doc, err := s.sceneFile(a)
if err != nil {
return nil, err
}
@@ -81,6 +84,48 @@ func (s *Session) prefabCreate(a Args) (*Output, error) {
return &Output{Changes: res.Changes, Problems: res.Problems, Created: []string{id.String()}, Files: append([]string{out}, files...)}, err
}
// prefabCreateOpen makes the prefab through the editor when the scene is open there, so unsaved edits count; false when there is no such editor or scene.
func (s *Session) prefabCreateOpen(a Args) (*Output, bool, error) {
ph, ok := s.host.(PrefabHost)
if !ok {
return nil, false, nil
}
rel, err := s.rel(a.Str("file"))
if err != nil {
return nil, true, err
}
doc, open := s.host.OpenScene(rel)
if !open {
return nil, false, nil
}
out, err := s.rel(a.Str("out"))
if err != nil {
return nil, true, err
}
if path.Ext(out) != PrefabExt {
out += PrefabExt
}
if s.exists(out) {
return nil, true, errors.New(out + " already exists")
}
h, err := doc.Node(a.Str("node"))
if err != nil {
return nil, true, err
}
if err := refactor.CheckCreate(doc.Scene, h); err != nil {
return nil, true, err
}
if a.Bool("dry-run") {
return &Output{DryRun: true, Notes: []string{"would create " + out, "would link the node in " + rel + " as an Undo step"}}, true, nil
}
id, err := ph.CreatePrefab(rel, a.Str("node"), out)
if err != nil {
return nil, true, err
}
s.reload()
return &Output{OK: true, Created: []string{id.String()}, Files: []string{out}}, true, nil
}
func (s *Session) prefabInstantiate(a Args) (*Output, error) {
op := mutate.Instantiate(a.Str("parent"), a.Str("prefab"))
op.Name = a.Str("name")
@@ -95,7 +140,7 @@ func (s *Session) prefabInstantiate(a Args) (*Output, error) {
}
func (s *Session) prefabApply(a Args) (*Output, error) {
rel, before, doc, err := s.scene(a)
rel, before, doc, err := s.sceneFile(a)
if err != nil {
return nil, err
}
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
+4 -4
View File
@@ -45,15 +45,15 @@ func refsCommands() []*Command {
return []*Command{
{Name: "refs.find", Doc: "files and fields that reference an asset", Args: []Arg{asset}, Run: (*Session).refsFind},
{Name: "refs.missing", Doc: "references to assets and nodes that do not exist", Run: (*Session).refsMissing},
{Name: "refs.fix", Doc: "point every reference to one asset at another (none clears them); originals go to .aego/recovery", Write: true,
{Name: "refs.fix", Doc: "point every reference to one asset at another (none clears them); originals go to .aego/recovery", Write: true, Destroys: true,
Args: []Arg{{Name: "from", Type: "string", Doc: "asset id or path being replaced", Required: true, Pos: true},
{Name: "to", Type: "string", Doc: "replacement id or path, empty clears", Pos: true}}, Run: (*Session).refsFix},
{Name: "asset.list", Doc: "assets with their ids and kinds", Args: []Arg{{Name: "kind", Type: "string", Doc: "only this kind: scene, prefab, texture, data, table, script…"}}, Run: (*Session).assetList},
{Name: "asset.move", Doc: "move an asset with its .meta; the id and every reference stay", Write: true,
{Name: "asset.move", Doc: "move an asset with its .meta; the id and every reference stay", Write: true, Destroys: true,
Args: []Arg{asset, {Name: "to", Type: "string", Doc: "new path", Required: true, Pos: true}}, Run: (*Session).assetMove},
{Name: "asset.rename", Doc: "rename an asset in its folder", Write: true,
{Name: "asset.rename", Doc: "rename an asset in its folder", Write: true, Destroys: true,
Args: []Arg{asset, {Name: "name", Type: "string", Doc: "new name without the extension", Required: true, Pos: true}}, Run: (*Session).assetRename},
{Name: "asset.delete", Doc: "delete an asset; refuses while it is referenced unless --force", Write: true,
{Name: "asset.delete", Doc: "delete an asset; refuses while it is referenced unless --force", Write: true, Destroys: true,
Args: []Arg{asset, {Name: "force", Type: "boolean", Doc: "delete even when referenced; the references become missing"}}, Run: (*Session).assetDelete},
}
}
+274 -26
View File
File diff suppressed because it is too large Load Diff
+82 -6
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
+162 -8
View File
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
// 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
+94
View File
@@ -0,0 +1,94 @@
package backend
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync/atomic"
"time"
)
// Client calls tools of the editor's MCP server, for the fake backend and tests.
type Client struct {
URL string
Token string
next atomic.Int64
http http.Client
}
func NewClient(url, token string) *Client {
return &Client{URL: url, Token: token, http: http.Client{Timeout: 15 * time.Minute}}
}
func (c *Client) Call(tool string, args map[string]any) (string, bool, error) {
raw, err := c.CallRaw(tool, args)
if err != nil {
var rpc rpcErr
if errors.As(err, &rpc) {
return string(rpc), true, nil
}
return "", true, err
}
var res struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if err := json.Unmarshal(raw, &res); err != nil {
return "", true, err
}
var b strings.Builder
for _, x := range res.Content {
b.WriteString(x.Text)
}
return b.String(), res.IsError, nil
}
// rpcErr is an error the server answered instead of a result.
type rpcErr string
func (e rpcErr) Error() string {
return string(e)
}
// 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) {
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},
})
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, c.URL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("mcp: %s", resp.Status)
}
var rep struct {
Result json.RawMessage `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&rep); err != nil {
return nil, err
}
if rep.Error != nil {
return nil, rpcErr(rep.Error.Message)
}
return rep.Result, nil
}
+21
View File
@@ -0,0 +1,21 @@
package backend
import "testing"
func TestDecodeStreamLines(t *testing.T) {
text := Decode([]byte(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"},{"type":"tool_use","id":"t1","name":"mcp__aego__scene_tree","input":{"file":"a"}}]}}`))
if len(text) != 2 || text[0].Text != "hi" || text[1].Tool != "scene_tree" || text[1].CallID != "t1" {
t.Fatalf("assistant line %+v", text)
}
res := Decode([]byte(`{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"ok"}]}]}}`))
if len(res) != 1 || res[0].Result != "ok" || res[0].CallID != "t1" {
t.Fatalf("tool result %+v", res)
}
end := Decode([]byte(`{"type":"result","is_error":true,"result":"boom"}`))
if len(end) != 2 || end[0].Kind != EvError || end[1].Kind != EvTurnEnd {
t.Fatalf("result line %+v", end)
}
if Decode([]byte(`not json`)) != nil {
t.Fatal("garbage gave events")
}
}
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