Author SHA1 Message Date
sanspie 9612140432 ci: add opt-in Stage web diagnostics 2026-11-07 10:36:17 +03:00
sanspie 31f2da9f79 ci: make native timing and installer smoke independent
Use a deterministic headless frame clock and check the actual 2D and 3D rotation. Create a guarded fresh project with the installed CLI even when preceding tests failed.
2026-11-07 06:43:53 +03:00
sanspie a10633ec8d ci: add opt-in cross-platform Aseprite diagnostics
Run real fixture and headless editor-import tests for an exact Stage commit without changing ordinary native CI.
2026-11-07 06:07:41 +03:00
sanspie ee267286a3 ci: include ancestor ACLs in Windows diagnostics 2026-11-07 05:19:37 +03:00
sanspie e3d7b8c8a5 ci: add isolated native remote store diagnostics
Only an explicit workflow_dispatch with an exact Stage commit runs these tests; ordinary native CI remains unchanged.
2026-11-07 05:08:47 +03:00
sanspie 1ffd388350 ci: isolate native integration tests and retain installer diagnostics 2026-11-07 03:56:53 +03:00
sanspie 747b6bc3b9 fix(audio): measure Windows mix time with performance counter 2026-11-07 03:12:17 +03:00
sanspie 047132e05d fix(windows): preserve user PATH during install 2026-11-07 02:38:43 +03:00
sanspie ee235bbf14 ci: mirror verified Gitea releases to GitHub
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 6m1s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m35s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: success
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
2026-11-07 01:12:53 +03:00
sanspie 216bdbb19d test: measure agent allocations after previews settle 2026-11-07 01:11:28 +03:00
sanspie a54ce4902a fix: make native tests portable across Windows
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Successful in 6m2s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m11s
ci / release (push) Skipped
2026-11-07 00:54:36 +03:00
sanspie 2f768d4166 fix: preserve native export and updater checks
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 7m12s
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 14m42s
ci / release (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: failure
GitHub / darwin arm64 native GitHub-hosted native CI: failure
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
2026-11-06 21:04:10 +03:00
sanspie a0107b622a ci: report native GitHub results to Gitea
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m49s
ci-platforms / windows amd64 smoke (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci / checks (push) Successful in 6m36s
ci / release (push) Skipped
2026-11-06 20:43:11 +03:00
sanspie eb937b4470 ci: route mirror jobs safely and build pinned validator
ci / checks (push) Successful in 5m35s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
ci / release (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m0s
2026-11-06 20:33:00 +03:00
sanspie 1d7cee7cc7 test: update light scene preview goldens
ci-platforms / windows amd64 smoke (push) Skipped
github-native / linux amd64 native (push) Skipped
ci / checks (push) Successful in 6m34s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m53s
ci / release (push) Skipped
github-native / darwin amd64 native (push) Canceled after 0s
github-native / windows amd64 native (push) Canceled after 0s
github-native / darwin arm64 native (push) Canceled after 0s
2026-11-06 17:11:45 +03:00
sanspie 85d9efc67b ci: make repository paths portable to Windows 2026-11-06 17:11:18 +03:00
sanspie ab6ea7ef58 editor: improve scene tools and prepare native CI
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
github-native / linux amd64 native (push) Skipped
ci / checks (push) Successful in 6m33s
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m26s
ci / release (push) Skipped
github-native / darwin amd64 native (push) Canceled after 0s
github-native / windows amd64 native (push) Canceled after 0s
github-native / darwin arm64 native (push) Canceled after 0s
2026-11-06 16:49:11 +03:00
sanspie 9c02975882 engine: cache frame pacer capability
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 5m59s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 8m7s
ci / release (push) Skipped
2026-11-06 15:47:34 +03:00
sanspie 6a148e63e0 ci: pause releases and fix profiler lint
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 11m27s
ci / release (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci / checks (push) Successful in 5m41s
2026-11-06 15:20:17 +03:00
sanspie 48d1eabef2 profiler: report actual FPS and idle waits
ci / checks (push) Failing after 5m21s
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 7m49s
ci / release (push) Skipped
2026-11-06 12:27:31 +03:00
sanspie 107b09e506 tests: fix golden references for the Design workspace
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m50s
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 4m56s
ci / release (push) Successful in 7m22s
Keep the material inspector fixture full height and refresh the reviewed Russian palette reference for current icons and layout.
2026-11-05 22:53:42 +03:00
sanspie 1f47880e8a docs: update editor guidance for 0.12.1
Document the default Design layout, agent mentions and follow controls, curve preset editing, and Scene View lighting toggles in English and Russian.
2026-11-05 22:41:19 +03:00
sanspie 4920c2a11c release: prepare 0.12.1 and fix CI checks
Prepare the stable editor update and fix CI checks for module icons, diagnostics, updater retries and integration snapshots.
2026-11-05 22:28:27 +03:00
sanspie 5dd9cc91bb editor: refine agent, scene controls and curves
Improve mentions and the default Design workspace, clarify scene lighting controls, and polish editor workflows for 0.12.5-dev.
2026-11-05 20:14:53 +03:00
sanspie a6822156cd Finish Stage 12 editor fixes and refreshed platformer
ci-platforms / windows amd64 smoke (push) Skipped
ci / release (push) Successful in 8m58s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 4m46s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 13m28s
2026-11-05 17:36:42 +03:00
sanspie 5eb1832907 Complete Stage 12 for v0.12.0
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
2026-11-05 11:46:53 +03:00
sanspie ad23798da5 docs: the project description says what Aego is: a 2D game engine built in Go
ci / checks (push) Successful in 7m58s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m1s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / release (push) Successful in 8m21s
2026-10-28 02:55:13 +03:00
sanspie 132ddeff70 Stage 12a1: shader ABI, shader import and material runtime (D367, D368, D380, D381, D393)
ci / checks (push) Successful in 8m39s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 12m19s
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / release (push) Successful in 8m54s
Shader ABI 1: ub0 frame (16 B), ub1 view (112 B), ub2 material, ub3 mesh
draw; tex0 main, tex1 reserved, tex2-tex7 user; @instance lanes in
vertex slot 1 at locations 4-7, 64 bytes a sprite. The engine sprite,
sprite3d, debug and mesh shaders move to it, and engine includes
sprite, color, noise and lighting ship under engine/res/shaders/include.

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

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

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

On the web the frame loop fills the audio output, the stream feeder
waits for ring space, per-event allocations are gone and Go allocations
per frame drop from 7.2 to 3.8 in Chromium. The pixel ratio is capped at
1.5 by default (display.maxPixelRatio) and Frame Limit works on the web.
The game loop stays on the main thread; a Worker is not built (D391).
2026-10-27 16:29:41 +03:00
sanspie cc2a428a7d engine: version 0.12.0-dev until the 0.12.0 release
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 8m13s
ci / build (amd64, linux, ubuntu-24.04) (push) Successful in 11m21s
ci / release (push) Successful in 9m18s
2026-10-26 22:33:39 +03:00
1566 changed files with 114800 additions and 4148 deletions
+3
View File
@@ -0,0 +1,3 @@
# Claude configuration
This directory contains project-specific guidance and local configuration for Claude Code. The guidance records repository hygiene and verification expectations for agents working on Aego.
+5
View File
@@ -0,0 +1,5 @@
# Golden files and project sources are byte-stable across platforms.
* text=auto eol=lf
# Preserve the upstream font license exactly as committed (it uses CRLF).
tools/gendocs/site/static/fonts/IBM-Plex-OFL.txt -text
+41
View File
@@ -0,0 +1,41 @@
# CI workflows
Gitea is the source of truth. The Linux workflow runs there; optional Gitea
platform runners remain controlled by `PLATFORM_RUNNERS`.
`github-native.yml` is for the GitHub mirror. It builds and tests on native
Windows, macOS (arm64 and amd64), and Linux runners, and uploads unsigned CI
artifacts and diagnostic logs. The Windows installer smoke is restricted to an
ephemeral GitHub-hosted runner. macOS bundles are ad-hoc signed, not notarized.
These jobs are not evidence of platform support until they actually pass on
their respective runners.
On Gitea, the GitHub-only matrix routes to the Linux runner before being
skipped. This prevents Windows and macOS entries from waiting indefinitely for
runners that Gitea does not have. On GitHub, the actual native labels are used.
macOS and Windows build the pinned GLSL reference validator from verified
source, rather than depending on a changing package-manager version.
To show native GitHub results on the Gitea commit, create the GitHub Actions
secret `GITEA_STATUS_TOKEN` containing a Gitea API token with repository write
permission. Prefer a non-admin bot that has access only to `sanspie/aego`:
Gitea's repository-write token is broader than commit statuses. The reporter
publishes pending and final statuses with links to the real GitHub jobs only
for trusted `master` pushes or manual runs, never for pull requests. If the
secret is missing, CI still runs but no Gitea statuses are published.
Keep GitHub Actions disabled through the first mirror sync: historical tags
can contain older workflows. Before enabling Actions, verify mirrored refs and
restrict the allowed workflows so historical release jobs cannot run. Use a
separate repository-scoped credential for the mirror; do not put it in a URL.
Gitea's build-and-publish job remains paused. `github-release-mirror.yml`
copies already-published Gitea releases and assets to GitHub after the current
master passes Gitea Linux CI and all four real GitHub native jobs. It also
checks periodically and can be dispatched manually from master. Versioned
stable releases are published normally, with the highest stable version marked
Latest; nightly, beta versions, and the stable-channel pointer are prereleases.
The mirror verifies matching git refs and asset sizes/checksums, stages new
releases as drafts, and never moves tags or replaces existing assets. If a
mutable nightly or another mirrored snapshot changes, the mirror stops rather
than overwriting it; a separate reviewed update is needed in that case. Signed
update manifests are copied unchanged and retain their original download URLs.
+4 -3
View File
@@ -9,8 +9,9 @@ permissions:
contents: read
env:
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
LINUX_PKGS: >-
build-essential cmake ninja-build pkg-config
build-essential cmake ninja-build pkg-config openssl
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
libwayland-dev wayland-protocols libdecor-0-dev
@@ -20,7 +21,7 @@ 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
if: vars.PLATFORM_RUNNERS == 'true'
if: github.server_url != 'https://github.com' && vars.PLATFORM_RUNNERS == 'true'
strategy:
fail-fast: false
matrix:
@@ -41,7 +42,7 @@ jobs:
go-version-file: go.mod
- name: linux packages
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- name: msys2
if: runner.os == 'Windows'
id: msys2
+10 -5
View File
@@ -10,8 +10,9 @@ permissions:
contents: read
env:
GLSLANG_APT_VERSION: 15.1.0-2~ubuntu0.24.04.2
LINUX_PKGS: >-
build-essential cmake ninja-build pkg-config
build-essential cmake ninja-build pkg-config openssl
libgl1-mesa-dev libegl1-mesa-dev libgles2-mesa-dev
libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxtst-dev libxkbcommon-dev
libwayland-dev wayland-protocols libdecor-0-dev
@@ -19,6 +20,7 @@ env:
jobs:
checks:
if: github.server_url != 'https://github.com'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -26,7 +28,7 @@ jobs:
with:
go-version-file: go.mod
- name: system packages
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- uses: actions/cache@v4
continue-on-error: true
with:
@@ -43,6 +45,7 @@ jobs:
run: make nsis-check
build:
if: github.server_url != 'https://github.com'
strategy:
fail-fast: false
matrix:
@@ -61,7 +64,7 @@ jobs:
go-version-file: go.mod
- name: linux packages
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- name: msys2
if: runner.os == 'Windows'
id: msys2
@@ -120,7 +123,9 @@ jobs:
release:
needs: [checks, build]
if: github.event_name == 'push'
# Pause all automatic releases until the reviewed nightly/stable pipeline
# enforces native gates and preserves existing tags and release assets.
if: ${{ false }}
runs-on: ubuntu-24.04
permissions:
contents: write
@@ -137,7 +142,7 @@ jobs:
with:
go-version-file: go.mod
- name: linux packages
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS
run: sudo apt-get update && sudo apt-get install -y $LINUX_PKGS "glslang-tools=$GLSLANG_APT_VERSION" && bash tools/check-glslang.sh
- uses: actions/cache@v4
continue-on-error: true
with:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,37 @@
name: github-release-mirror
on:
workflow_run:
workflows: [github-native]
types: [completed]
branches: [master]
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: github-release-mirror
cancel-in-progress: false
jobs:
mirror:
if: github.server_url == 'https://github.com' && github.repository == 'Alexander-D-Karpov/aego' && github.ref == 'refs/heads/master'
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: write
actions: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: refs/heads/master
persist-credentials: false
- name: Test release mirror
run: python3 -m unittest discover -s tools/ci -p mirror_releases_test.py -v
- name: Mirror published Gitea releases
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python3 tools/ci/mirror_releases.py --apply
+1
View File
@@ -17,6 +17,7 @@ tools/genicons/genicons
/tests/golden/testdata/actual-*
tools/websmoke/node_modules/
/tools/websmoke/__pycache__/
tools/websmoke/golden/actual-*.png
# build output belongs in .cache/bin; these catch stray builds in the root
+108 -8
View File
File diff suppressed because it is too large Load Diff
+175 -19
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,8 +1,8 @@
# Aego
# Aego — A 2D game engine built in Go.
Make 2D games in Go. One editor, one project, desktop and web.
Build games visually, write gameplay logic in Aego Script, and extend the engine with native Go.
Build scenes in the editor, script them in a small Go-like language, extend the engine with Go when you need to, and ship the same project to Linux, Windows, macOS and the browser. Deterministic simulation, rewind, physics, animation, audio and cameras come built in.
A complete development environment with a visual editor, hot reload, deterministic simulation, frame-by-frame rewind, and cross-platform export. From your first scene to a playable game on Windows, Linux, macOS, and the web.
- Documentation and the playground: <https://aego.akarpov.ru>
- Installing and building from source: [engine/res/docs/install.md](engine/res/docs/install.md)
+3
View File
@@ -0,0 +1,3 @@
# Animation
`anim` provides the shared animation kernel for clips, keyframe tracks, events, and controller graphs. It samples tracks, evaluates state transitions and blending, and encodes clips and controllers in text and binary forms for use by the engine and editor.
+3
View File
@@ -0,0 +1,3 @@
# app
The `app` packages assemble the engine, modules, asset pipeline, and scripting workspace into development hosts. They coordinate workflows such as project creation, editing, running, and exporting outside the engine core.
+32 -90
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -18,6 +18,7 @@ type Arg struct {
Doc string
Required bool
Pos bool
Items map[string]any
}
type Command struct {
@@ -45,7 +46,8 @@ type Output struct {
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:"-"`
Images []Image `json:"-"`
Drawing *DrawingEdit `json:"-"`
}
// Image is a picture an answer carries.
@@ -67,12 +69,23 @@ func catalog() []*Command {
out = append(out, ctrlCommands()...)
out = append(out, refsCommands()...)
out = append(out, assetCommands()...)
out = append(out, variantCommand())
out = append(out, materialCommands()...)
out = append(out, postEffectCommands()...)
out = append(out, textureCommands()...)
out = append(out, schemaCommands()...)
out = append(out, projectCommands()...)
out = append(out, scriptCommands()...)
ls := looks()
for _, c := range out {
c.Look = ls[c.Name]
if c.Name == "asset.import-variant" {
c.Look = ls["asset.pixels"]
}
if c.Look.Title == nil && (strings.HasPrefix(c.Name, "postfx.") || strings.HasPrefix(c.Name, "material.") || strings.HasPrefix(c.Name, "shader.") || strings.HasPrefix(c.Name, "texture.")) {
name := strings.ReplaceAll(c.Name, ".", " ")
c.Look = Look{Icon: "comp.sprite", Title: func(a Args) string { return name + " " + file(a) }, Summary: changes}
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
@@ -137,7 +150,11 @@ func (c *Command) Schema() map[string]any {
props := map[string]any{}
var req []string
for _, x := range c.AllArgs() {
props[x.Name] = map[string]any{"type": x.Type, "description": x.Doc}
prop := map[string]any{"type": x.Type, "description": x.Doc}
if x.Items != nil {
prop["items"] = x.Items
}
props[x.Name] = prop
if x.Required {
req = append(req, x.Name)
}
@@ -175,7 +192,7 @@ func Run(s *Session, name string, a Args) (*Output, error) {
}
out, err := c.Run(s, a)
if out != nil {
out.OK = err == nil
out.OK = err == nil && !out.Errors()
}
return out, err
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
"sync/atomic"
"time"
)
@@ -16,7 +17,7 @@ const maxBody = 8 << 20
// HTTP serves MCP as JSON-RPC over POST on loopback, for an agent the editor runs (D341).
type HTTP struct {
URL string
Token string
token atomic.Pointer[string]
srv *http.Server
s *Server
}
@@ -27,12 +28,8 @@ func ListenHTTP(opt Options) (*HTTP, error) {
if err != nil {
return nil, err
}
var key [32]byte
if _, err := rand.Read(key[:]); err != nil {
ln.Close()
return nil, err
}
h := &HTTP{URL: "http://" + ln.Addr().String() + "/mcp", Token: hex.EncodeToString(key[:]), s: newServer(opt)}
h := &HTTP{URL: "http://" + ln.Addr().String() + "/mcp", s: newServer(opt)}
h.RotateToken()
h.s.ready.Store(true)
mux := http.NewServeMux()
mux.HandleFunc("/mcp", h.serve)
@@ -41,9 +38,18 @@ func ListenHTTP(opt Options) (*HTTP, error) {
return h, nil
}
func (h *HTTP) Token() string { return *h.token.Load() }
func (h *HTTP) RotateToken() {
var key [32]byte
rand.Read(key[:])
token := hex.EncodeToString(key[:])
h.token.Store(&token)
}
func (h *HTTP) authorized(r *http.Request) bool {
got := r.Header.Get("Authorization")
return subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+h.Token)) == 1
return subtle.ConstantTimeCompare([]byte(got), []byte("Bearer "+h.Token())) == 1
}
// local refuses a request whose Host is not loopback, so a web page cannot reach the server by DNS rebinding.
+8 -1
View File
@@ -335,6 +335,13 @@ func (s *Server) call(params json.RawMessage) (any, *rpcError) {
}
for _, t := range s.opt.Tools {
if t.Name == p.Name {
if t.Output != nil {
out, err := t.Output(p.Arguments)
if err != nil {
return result(err.Error(), true), nil
}
return result(out, out != nil && out.Errors()), nil
}
text, failed := t.Run(p.Arguments)
return result(text, failed), nil
}
@@ -364,7 +371,7 @@ func (s *Server) call(params json.RawMessage) (any, *rpcError) {
if err != nil {
return result(err.Error(), true), nil
}
return result(out, false), nil
return result(out, out != nil && out.Errors()), nil
}
func (s *Server) registry() (*docs.Registry, error) {
+2 -1
View File
@@ -29,7 +29,8 @@ type Tool struct {
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)
Run func(args map[string]any) (text string, failed bool)
Output func(map[string]any) (*agent.Output, error)
}
type kitSource struct {
+221
View File
File diff suppressed because it is too large Load Diff
+101
View File
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
package agent
import (
"os"
"path/filepath"
"testing"
"aego/modules/render2d"
"aego/scene/mutate"
)
type postCameraHost struct {
Host
doc *mutate.Document
edits int
}
func (h *postCameraHost) OpenScene(string) (*mutate.Document, bool) { return h.doc, true }
func (h *postCameraHost) ApplyScene(_ string, _ string, tx mutate.Tx) (*mutate.Result, error) {
r, err := h.doc.Apply(tx)
if err == nil {
h.edits++
}
return r, err
}
func TestPostEffectCommandTargetsDirtyCameraByStableID(t *testing.T) {
s := materialSession(t)
const file = "scenes/test.aescene"
disk := []byte(`scene version=1 {
node "43980000000000000000000000000001" name="Camera" {
component "engine.Camera2D" version=1 {}
component "render2d.PostProcess" version=1 { enabled=true effects=[{typeID="postfx.bloom" instanceID="first" enabled=true version=1 parameters=[]}] }
}
}`)
full := filepath.Join(s.Project.Root, file)
if err := os.WriteFile(full, disk, 0644); err != nil {
t.Fatal(err)
}
_, _, doc, err := s.sceneDisk(file)
if err != nil {
t.Fatal(err)
}
if _, err = doc.Apply(mutate.Tx{Label: "Unsaved rename", Ops: []mutate.Op{mutate.Rename("Camera", "UnsavedCamera")}}); err != nil {
t.Fatal(err)
}
h := &postCameraHost{doc: doc}
s.host = h
a := Args{"file": file, "node": "UnsavedCamera", "instance": "first", "field": "Intensity", "value": "0.37", "dry-run": true}
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 0 {
t.Fatalf("dry run: %v / %d", err, h.edits)
}
delete(a, "dry-run")
if _, err := Run(s, "postfx.set", a); err != nil || h.edits != 1 {
t.Fatalf("apply: %v / %d", err, h.edits)
}
o, err := Run(s, "postfx.get", Args{"file": file, "node": "UnsavedCamera"})
if err != nil {
t.Fatal(err)
}
stack := o.Value.(render2d.PostProcess)
e, ok := stack.Effect("first")
if !ok || len(e.Parameters) != 1 || e.Parameters[0].Value.X != 0.37 {
t.Fatal(stack)
}
for _, bad := range []string{"-1", "true", "[1,2]"} {
a["value"] = bad
if _, err := Run(s, "postfx.set", a); err == nil || h.edits != 1 {
t.Fatalf("invalid %q wrote scene", bad)
}
}
a["instance"] = "deleted"
if _, err := Run(s, "postfx.set", a); err == nil {
t.Fatal("missing instance accepted")
}
if b, err := os.ReadFile(full); err != nil || string(b) != string(disk) {
t.Fatal("saved document changed")
}
}
+9
View File
@@ -228,6 +228,9 @@ func looks() map[string]Look {
"schema.show": {"cat.engine", func(a Args) string {
return i18n.Tr("editor.agent.step.show_type", "Read the fields of {type}", i18n.Str("type", a.Str("type")))
}, ok},
"asset.paint": {"file.texture", func(a Args) string {
return i18n.Tr("editor.agent.step.draw_pixels", "Draw {file} in pixels", i18n.Str("file", path.Base(a.Str("path"))))
}, ok},
"asset.pixels": {"file.texture", func(a Args) string {
return i18n.Tr("editor.agent.step.draw_pixels", "Draw {file} in pixels", i18n.Str("file", path.Base(a.Str("path"))))
}, ok},
@@ -255,6 +258,12 @@ func looks() map[string]Look {
"refs.fix": {"panel.references", func(a Args) string {
return i18n.Tr("editor.agent.step.fix_refs", "Point references at {to}", i18n.Str("to", path.Base(a.Str("to"))))
}, changes},
"project.main-scene": {"file.scene", func(a Args) string {
return i18n.Tr("editor.agent.step.set_project", "Set {target}", i18n.Str("target", "mainScene › "+a.Str("file")))
}, changes},
"project.input-action": {"doc.project", func(a Args) string {
return i18n.Tr("editor.agent.step.set_project", "Set {target}", i18n.Str("target", "input › "+a.Str("name")))
}, changes},
"project.get": {"doc.project", func(Args) string {
return i18n.Tr("editor.agent.step.read_project", "Read project settings")
}, ok},
File diff suppressed because it is too large Load Diff
+2
View File
@@ -15,6 +15,8 @@ import (
func projectCommands() []*Command {
return []*Command{
{Name: "project.main-scene", Doc: "Set an imported scene as the startup scene by project-relative path.", Args: []Arg{argFile}, Write: true, Run: (*Session).mainScene},
{Name: "project.input-action", Doc: "Create/update one action and add it to a context. Binding strings use the project input syntax. Only an empty initial stack is initialized.", Write: true, Args: []Arg{{Name: "name", Type: "string", Required: true, Pos: true}, {Name: "type", Type: "string", Required: true}, {Name: "bind", Type: "array", Items: map[string]any{"type": "string"}, Required: true}, {Name: "context", Type: "string", Doc: "Gameplay by default"}, {Name: "active", Type: "boolean", Doc: "Add the context to the initial stack if absent"}}, Run: (*Session).inputAction},
{Name: "project.validate", Doc: "missing assets and nodes, import errors, prefabs and scenes that do not load", Run: (*Session).projectValidate},
{Name: "project.get", Doc: "module sections of project.aego (physics, …) with their current values",
Args: []Arg{{Name: "section", Type: "string", Doc: "only this section", Pos: true}}, Run: (*Session).projectGet},
File diff suppressed because it is too large Load Diff
+74 -2
View File
File diff suppressed because it is too large Load Diff
+25 -6
View File
@@ -42,6 +42,7 @@ const (
)
type RunRequest struct {
Camera string `json:"camera,omitempty"`
Scene string `json:"scene,omitempty"`
Frames int `json:"frames"`
Seed uint64 `json:"seed"`
@@ -67,12 +68,14 @@ type Check struct {
// Shot is a captured frame: the full image under .aego/cache/runs, and a scaled PNG for the answer.
type Shot struct {
Frame int `json:"frame"`
Path string `json:"path,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
PNG []byte `json:"png,omitempty"`
Error string `json:"error,omitempty"`
ViewSignature string `json:"viewSignature,omitempty"`
CaptureID string `json:"captureID,omitempty"`
Frame int `json:"frame"`
Path string `json:"path,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
PNG []byte `json:"png,omitempty"`
Error string `json:"error,omitempty"`
}
// Track is where a watched node was: at the start, at each quarter of the run and at the end.
@@ -356,6 +359,14 @@ func (r sources) Texture(ids.AssetID) (texture.Region, bool) {
return texture.Region{}, false
}
// Disk scene validation reads the same prepared data/colour and atlas metadata as Play.
func (r sources) TextureMetadata(id ids.AssetID) (asset.TextureMetadata, bool) {
return asset.ReadTextureMetadata(id, func(id ids.AssetID) (*asset.Entry, bool) {
e, ok := r.pl.Entry(id)
return &e, ok && !e.Missing && e.Error == ""
}, func(a asset.Artifact) ([]byte, error) { return r.pl.Artifacts().ReadFile(a.Path) })
}
func (r sources) Mesh(ids.AssetID) (*mesh.Mesh, bool) {
return nil, false
}
@@ -414,6 +425,11 @@ func (s *Session) asset(text string) (ids.AssetID, error) {
if id, err := ids.ParseAssetID(text); err == nil {
return id, nil
}
for _, builtin := range asset.Builtins() {
if strings.EqualFold(text, builtin.Name) {
return builtin.ID, nil
}
}
rel, err := s.rel(text)
if err != nil {
return ids.AssetID{}, err
@@ -425,6 +441,9 @@ func (s *Session) asset(text string) (ids.AssetID, error) {
}
func (s *Session) label(id ids.AssetID) string {
if builtin, ok := asset.LookupBuiltin(id); ok {
return builtin.Name
}
if rel, ok := s.Pipeline.PathOf(id); ok {
return rel
}
File diff suppressed because it is too large Load Diff
+97
View File
@@ -0,0 +1,97 @@
package agent
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
"aego/asset"
"aego/format/aetext"
)
func textureCommandFixture(t *testing.T) *Session {
t.Helper()
s := materialSession(t)
img := image.NewNRGBA(image.Rect(0, 0, 4, 4))
for y := 0; y < 4; y++ {
for x := 0; x < 4; x++ {
img.SetNRGBA(x, y, color.NRGBA{R: byte(x * 60), A: 255})
}
}
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.Project.Root, "assets/height.png"), b.Bytes(), 0644); err != nil {
t.Fatal(err)
}
if _, _, err := s.Pipeline.Scan(); err != nil {
t.Fatal(err)
}
if batch, err := s.Pipeline.ImportAll(); err != nil || len(batch.Failed) != 0 {
t.Fatalf("fixture import %v %v", err, batch)
}
return s
}
func TestTextureCommandsShareAtomicMetadataMutationsAndImageRevision(t *testing.T) {
s := textureCommandFixture(t)
file := "assets/height.png"
get := materialCommand(t, s, "texture.get", Args{"file": file}).Value.(map[string]any)
hash := get["sourceHash"].(string)
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "role", "value": "height", "expected": hash})
before, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if _, err := Run(s, "texture.set", Args{"file": file, "setting": "height_strength", "value": "2", "expected": hash}); err == nil {
t.Fatal("stale metadata hash accepted")
}
if _, err := Run(s, "texture.apply", Args{"file": file, "tx": `tx version=1 {op "texture-setting" {path="generate_normal" value=true} op "texture-setting" {path="height_strength" value="bad"}}`}); err == nil {
t.Fatal("invalid typed batch accepted")
}
after, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if !bytes.Equal(before, after) {
t.Fatal("failed batch partially wrote metadata")
}
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true", "dry-run": true})
after, _ = os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if !bytes.Equal(before, after) {
t.Fatal("dry run wrote metadata")
}
hash = materialCommand(t, s, "texture.get", Args{"file": file}).Value.(map[string]any)["sourceHash"].(string)
image, _ := os.ReadFile(filepath.Join(s.Project.Root, file))
image = append(image, 0)
if err := os.WriteFile(filepath.Join(s.Project.Root, file), image, 0644); err != nil {
t.Fatal(err)
}
if _, err := Run(s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true", "expected": hash}); err == nil {
t.Fatal("source image change was not in optimistic revision")
}
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "generate_normal", "value": "true"})
out := materialCommand(t, s, "texture.validate", Args{"file": file})
if out.Value.(map[string]any)["importChecked"] != true || out.Value.(map[string]any)["gpuLinked"] != false {
t.Fatal("validation claimed GPU link or did not run importer")
}
}
func TestTextureCommandsUseDirtySharedMetadataDocument(t *testing.T) {
s := textureCommandFixture(t)
file := "assets/height.png"
before, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
m, err := asset.ParseSourceMeta(before, file+".meta")
if err != nil {
t.Fatal(err)
}
m.Settings["role"], m.Settings["generate_normal"] = aetext.Ident("height"), aetext.Bool(true)
host := &auditMaterialHost{text: map[string]string{file + ".meta": string(asset.MarshalSourceMeta(m))}}
s.host = host
materialCommand(t, s, "texture.set", Args{"file": file, "setting": "height_strength", "value": "4"})
edited, err := asset.ParseSourceMeta([]byte(host.text[file+".meta"]), file+".meta")
if err != nil || edited.Settings.Float("height_strength", 1) != 4 || !edited.Settings.Bool("generate_normal", false) {
t.Fatal("mutation ignored dirty metadata", err)
}
after, _ := os.ReadFile(filepath.Join(s.Project.Root, file+".meta"))
if !bytes.Equal(before, after) {
t.Fatal("editor-session texture mutation wrote disk behind history")
}
}
+30
View File
@@ -0,0 +1,30 @@
package agent
import (
"bytes"
"errors"
"image/png"
)
func variantCommand() *Command {
return &Command{Name: "asset.import-variant", Write: true, Doc: "Import an inspected generated PNG variant from this editor conversation as a stable project source asset", Args: []Arg{{Name: "variant", Type: "string", Required: true}, {Name: "path", Type: "string", Required: true}, {Name: "replace", Type: "boolean"}}, Look: Look{}, Run: func(s *Session, a Args) (*Output, error) {
host, ok := s.host.(interface{ GeneratedImage(string) ([]byte, bool) })
if !ok {
return nil, errors.New("requires an active editor generator session")
}
data, ok := host.GeneratedImage(a.Str("variant"))
if !ok {
return nil, errors.New("variant is missing or belongs to a previous conversation")
}
path, err := pngPath(a.Str("path"))
if err != nil {
return nil, err
}
img, err := png.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
return s.drawn(a, path, img, img, nil, false, nil, nil)
}}
}
+9 -1
View File
@@ -1,4 +1,4 @@
// Package backend runs a model for the Agent panel behind one interface: the Claude Code CLI, or a scripted fake for tests (D341).
// Package backend runs Claude Code or Codex for the Agent panel behind one interface.
package backend
import (
@@ -24,6 +24,7 @@ const (
const ToolPrefix = "mcp__aego__"
type Event struct {
Images [][]byte
Kind EventKind
Text string
Tool string
@@ -32,6 +33,9 @@ type Event struct {
Result string
Failed bool
Err error
// Delta appends Text verbatim to MessageID instead of starting a complete text block.
Delta bool
MessageID string
// Cost in US dollars and Millis of the answer, on EvTurnEnd.
Cost float64
Millis int
@@ -42,11 +46,15 @@ type Event struct {
}
type Config struct {
Binding string
Dir string
Command string
MCPURL string
Token string
System string
// Empty Model and Effort inherit the selected CLI's configuration.
Model string
Effort string
// Record gets every line the CLI writes, for recording sessions the tests replay.
Record io.Writer
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
package backend
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func TestClaudeProcessFixture(t *testing.T) {
if os.Getenv("AEGO_CLAUDE_PROCESS_FIXTURE") != "1" {
return
}
for i := 0; i < 200; i++ {
fmt.Fprintln(os.Stdout, `{"type":"assistant","message":{"content":[{"type":"text","text":"stream"}]}}`)
}
_, _ = io.Copy(io.Discard, os.Stdin)
os.Exit(0)
}
func TestClaudeCloseReapsProcessWithUndrainedEvents(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestClaudeProcessFixture$")
cmd.Env = append(os.Environ(), "AEGO_CLAUDE_PROCESS_FIXTURE=1")
conf := filepath.Join(t.TempDir(), "mcp.json")
if err := os.WriteFile(conf, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
c := &claudeConv{cmd: cmd, conf: conf, events: make(chan Event, 64), done: make(chan struct{})}
var err error
c.in, err = cmd.StdinPipe()
if err != nil {
t.Fatal(err)
}
out, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = &c.stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
go c.read(out)
t.Cleanup(func() { _ = c.Close() })
deadline := time.Now().Add(5 * time.Second)
for len(c.events) < cap(c.events) && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if len(c.events) != cap(c.events) {
t.Fatal("fixture did not reach a blocked event delivery")
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
timeout := time.NewTimer(5 * time.Second)
defer timeout.Stop()
for {
select {
case _, ok := <-c.Events():
if !ok {
if cmd.ProcessState == nil {
t.Fatal("child was not reaped")
}
if _, err := os.Stat(conf); !os.IsNotExist(err) {
t.Fatalf("temporary MCP credentials still exist: %v", err)
}
return
}
case <-timeout.C:
t.Fatal("closing a replaced Claude conversation leaked its process or reader")
}
}
}
+7 -1
View File
@@ -2,6 +2,7 @@ package backend
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
@@ -57,6 +58,11 @@ func (e rpcErr) Error() string {
// CallRaw is the result of a tool call as the server sent it, images included.
func (c *Client) CallRaw(tool string, args map[string]any) (json.RawMessage, error) {
return c.CallRawContext(context.Background(), tool, args)
}
// CallRawContext cancels an outstanding question/tool when its turn is stopped.
func (c *Client) CallRawContext(ctx context.Context, tool string, args map[string]any) (json.RawMessage, error) {
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": c.next.Add(1), "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args},
@@ -64,7 +70,7 @@ func (c *Client) CallRaw(tool string, args map[string]any) (json.RawMessage, err
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, c.URL, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(body))
if err != nil {
return nil, err
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
package backend
import (
"encoding/json"
"strings"
"testing"
)
func TestEditorCatalogRejectsInvalidOrMismatchedTools(t *testing.T) {
for _, tc := range []struct {
name string
raw string
ok bool
}{
{"named", `{"name":"scene_tree"}`, true},
{"key-only", `{}`, true},
{"null", `null`, false},
{"mismatch", `{"name":"different"}`, false},
{"invalid-type", `"scene_tree"`, false},
} {
t.Run(tc.name, func(t *testing.T) {
err := verifyEditorCatalog(map[string]json.RawMessage{"scene_tree": json.RawMessage(tc.raw)}, []string{"scene_tree"})
if (err == nil) != tc.ok || err != nil && !strings.Contains(err.Error(), "scene_tree") {
t.Fatalf("catalog verification: %v", err)
}
})
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
//go:build !windows
package backend
import "os/exec"
func hideCatalogProcess(*exec.Cmd) {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
package backend
import (
"os/exec"
"syscall"
)
func hideCatalogProcess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
package backend
import (
"strings"
"testing"
)
func TestClaudeDoesNotConfuseTextSessionWithSceneAccess(t *testing.T) {
c := &claudeConv{}
c.status([]byte(`{"type":"system","subtype":"init","session_id":"one","model":"fixture","mcp_servers":[{"name":"aego","status":"connected"}],"tools":["mcp__aego__scene_tree"]}`))
if d := c.Connection(); d.Tools != 1 || d.State != "missing tools" || d.Thread != "one" {
t.Fatal(d)
}
c.status([]byte(`{"type":"control_response","response":{"request_id":"aego-status","subtype":"success","response":{"mcpServers":[{"name":"aego","status":"connected","tools":[{"name":"editor_scene_context"},{"name":"scene_tree"},{"name":"script_read"}]}]}}}`))
if d := c.Connection(); d.State != "tools listed" || d.Tools != 3 {
t.Fatal(d)
}
}
func TestCodexRefusesMissingToolAndStaleEditorBinding(t *testing.T) {
for _, tt := range []struct{ mode, state, reason string }{{"missing-scene-tool", "missing tools", "scene_tree"}, {"missing-context", "missing tools", "editor_scene_context"}, {"old-editor", "call rejected", "different editor session"}} {
t.Run(tt.mode, func(t *testing.T) {
c := startCodexFixture(t, tt.mode, func(cfg *Config) { cfg.Binding = "new-editor" })
evs := codexEventUntil(t, c, func(e Event) bool { return e.Kind == EvError })
err := evs[len(evs)-1].Err
if err == nil || !strings.Contains(err.Error(), tt.reason) || c.Connection().State != tt.state {
t.Fatalf("%+v: %v", c.Connection(), err)
}
})
}
}
+56
View File
@@ -0,0 +1,56 @@
package backend
import (
"bytes"
"encoding/base64"
"encoding/json"
"image/png"
)
func toolImages(raw json.RawMessage) [][]byte {
// Claude nests the base64 source; Codex reports MCP content directly.
var b []struct {
Type, MimeType, Data string
Source struct {
Type string
Mime string `json:"media_type"`
Data string
}
}
var r struct{ Content json.RawMessage }
if len(raw) > 0 && raw[0] == '{' {
if json.Unmarshal(raw, &r) != nil {
return nil
}
raw = r.Content
}
if json.Unmarshal(raw, &b) != nil {
return nil
}
var out [][]byte
for _, v := range b {
if v.Type != "image" {
continue
}
data, mime := v.Data, v.MimeType
if v.Source.Type == "base64" {
data, mime = v.Source.Data, v.Source.Mime
}
if mime != "image/png" || len(data) > 7<<20 {
continue
}
decoded, err := base64.StdEncoding.DecodeString(data)
if err != nil || len(decoded) > 5<<20 {
continue
}
c, err := png.DecodeConfig(bytes.NewReader(decoded))
if err != nil || c.Width < 1 || c.Height < 1 || c.Width > 16384 || c.Height > (16<<20)/c.Width {
continue
}
out = append(out, decoded)
if len(out) == 3 {
break
}
}
return out
}
+28
View File
@@ -0,0 +1,28 @@
package backend
import (
"bytes"
"encoding/base64"
"encoding/json"
"image"
"image/png"
"testing"
)
func TestToolImagesDecodeProviderShapes(t *testing.T) {
var b bytes.Buffer
if err := png.Encode(&b, image.NewNRGBA(image.Rect(0, 0, 7, 3))); err != nil {
t.Fatal(err)
}
data := base64.StdEncoding.EncodeToString(b.Bytes())
for _, raw := range []any{map[string]any{"content": []any{map[string]any{"type": "image", "mimeType": "image/png", "data": data}}}, []any{map[string]any{"type": "image", "source": map[string]any{"type": "base64", "media_type": "image/png", "data": data}}}} {
s, _ := json.Marshal(raw)
g := toolImages(s)
if len(g) != 1 || !bytes.Equal(g[0], b.Bytes()) {
t.Fatal("lost provider image")
}
}
if len(toolImages(json.RawMessage(`[{"type":"text","text":"data:image/png;base64,test"}]`))) != 0 {
t.Fatal("text is not an image block")
}
}

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