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
449 changed files with 4434 additions and 31840 deletions
+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
+39 -1
View File
@@ -1,3 +1,41 @@
# CI workflows
The workflows in this directory automate checks, builds, tests, and release publishing for Aego. Optional platform-runner jobs cover Windows and macOS alongside the main Linux checks.
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.
+1 -1
View File
@@ -21,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:
+5 -1
View File
@@ -20,6 +20,7 @@ env:
jobs:
checks:
if: github.server_url != 'https://github.com'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -44,6 +45,7 @@ jobs:
run: make nsis-check
build:
if: github.server_url != 'https://github.com'
strategy:
fail-fast: false
matrix:
@@ -121,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
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 -4
View File
@@ -47,7 +47,6 @@ MODULE_BENCH_COMPARE := ./tests/integration:RewindScrub
BENCH_PHYSICS_PKGS :=
.DEFAULT_GOAL := bootstrap
-include modules/*/module.mk
include tools/artstudio/release/release.mk
.PHONY: materials materials-check dmg nsis-check test-update clean-root appimage bootstrap build test test-nonative test-editor test-runtime test-gl vet lint depcheck smoke run genfont genicons icons icons-doc res genpreviews golden golden-update site-headless bench bench-baseline bench-scripts bench-build bench-compare bench-profile bench-scripts-run bench-allocs playground playground-size site site-check cli example example-platformer check-all ci clean fuzz runtime export-smoke editor-app shaders test-wasm web-smoke web-smoke-update web-bench web-bench-baseline try-size shots shots-check affected quick check-camera check-audio check-gameplay check-mutate check-editor check-web check-docs i18n-check pkgsize
@@ -103,8 +102,7 @@ test-nonative:
# Tests needing a real GL context (offscreen SDL + llvmpipe).
test-gl: bootstrap
$(GL_ENV) go test -tags aego_gltest ./tests/gl/
$(GL_ENV) go test -tags aego_gltest ./app/edit -count=1 -run '^Test((Material|Lighting|ImageAsset|ArtScene)AuthoringWorkflowNative|ArtWorkingAlphaShadowNative|PlatformerVignetteGameView|PostFXEditPreviewWithoutPlay|CameraInspectorShowsRenderedPostStatus|PostFXAndParticlePresetThumbnails|ColdPostMaterialPreviewAndThumbnail|DiagnosticComposerConsoleProfilerNative)$$'
$(GL_ENV) go test -tags aego_editor,aego_gltest -count=1 -run '^(Test(Mesh2D|SpriteRenderer)TryNativeVisibleOrderAndReset|TestComponentContractProbeRunsNativeAndRejectsUnrunWeb|TestBrowserProcessDeadlineFailsClosed)$$' ./app/docsview
$(GL_ENV) go test -tags aego_gltest ./app/edit -count=1 -run '^Test((Material|Lighting|ImageAsset)AuthoringWorkflowNative|PlatformerVignetteGameView|PostFXEditPreviewWithoutPlay|CameraInspectorShowsRenderedPostStatus|PostFXAndParticlePresetThumbnails|ColdPostMaterialPreviewAndThumbnail|DiagnosticComposerConsoleProfilerNative)$$'
$(GL_ENV) SDL_AUDIO_DRIVER=dummy AEGO_PLATFORMER_SHOTS=$(CURDIR)/.cache/stage12/lighting-route AEGO_PARTICLE_SHOTS=$(CURDIR)/.cache/stage12/particle-route AEGO_POSTFX_SHOTS=$(CURDIR)/.cache/stage12/postfx go test -tags aego_gltest -count=1 -run GL\|Screenshots ./tests/integration/
vet: bootstrap
@@ -385,7 +383,6 @@ web-visibility-lifecycle-test:
node --test tools/websmoke/visibility-browser.test.mjs
ci: web-warning-policy-test web-bench-baseline-test web-audio-policy-test web-visibility-lifecycle-test shader-validator materials-check build vet lint depcheck i18n-check test test-nonative test-editor test-runtime test-wasm runtime smoke fuzz golden shots-check test-gl cli playground web-smoke site-check example example-platformer check-all export-smoke test-update $(MODULE_CI) bench-allocs
ci: studio-web-test
@sh tools/ci-clean-root.sh
# test-update builds two versions of the CLI and updates one to the other through a release server on loopback (D362).
-97
View File
@@ -1,97 +0,0 @@
package agent
import (
"fmt"
"strconv"
"strings"
"aego/art"
)
// ArtHost is supplied only by the running editor. Offline commands must not
// reopen files and silently replace the user's dirty graphical documents.
// Like Host.ApplyScene, these calls run on the editor mutation thread.
type ArtHost interface {
ReadArt(file, section string, offset, limit int) (*Output, error)
CaptureArt(file string, frame art.ID, channel art.Channel) (*Output, error)
PrepareArt(file string, revision uint64, label string, ops []art.Operation) (*Output, error)
}
// ArtProposal is an opaque, prepared transaction carried by the existing agent
// proposal queue. Commit is invoked only after its normal approval decision.
// Keeping the prepared transaction also preserves IDs returned by its preview.
type ArtProposal interface {
Commit() (*Output, error)
RetainedBytes() int64
}
func artHost(s *Session) (ArtHost, error) {
if s != nil {
if h, ok := s.host.(ArtHost); ok {
return h, nil
}
}
return nil, fmt.Errorf("art: connect to the running Aego editor; offline writes to graphical documents are disabled")
}
func artCommands() []*Command {
fileArg := Arg{Name: "file", Type: "string", Doc: "Project-relative .aeart source, including unsaved editor content", Required: true, Pos: true}
return []*Command{
{Name: "art.read", Doc: "Read the current art document, not its last saved file. Lists are paginated; revision is a decimal string for art.apply.", Args: []Arg{
fileArg, {Name: "section", Type: "string", Doc: "summary, layers, frames, cels, palette, tags or slices"},
{Name: "offset", Type: "integer", Doc: "First item, default 0"}, {Name: "limit", Type: "integer", Doc: "1..1024 items; default 256"},
}, Run: func(s *Session, a Args) (*Output, error) {
h, err := artHost(s)
if err != nil {
return nil, err
}
offset, err := a.Int("offset", 0)
if err != nil {
return nil, err
}
limit, err := a.Int("limit", 256)
if err != nil {
return nil, err
}
if offset < 0 || limit < 1 || limit > 1024 {
return nil, fmt.Errorf("art: invalid page")
}
return h.ReadArt(a.Str("file"), a.Str("section"), offset, limit)
}},
{Name: "art.capture", Doc: "Capture unsaved CPU pixels with document/frame/channel identity and revision. MCP returns a PNG image; the CLI writes it only with --out to a new file.", Args: []Arg{
fileArg, {Name: "frame", Type: "string", Doc: "Stable frame ID; defaults to current frame"},
{Name: "channel", Type: "string", Doc: "albedo, normal, height or emission; defaults to current channel"},
}, Run: func(s *Session, a Args) (*Output, error) {
h, err := artHost(s)
if err != nil {
return nil, err
}
return h.CaptureArt(a.Str("file"), art.ID(a.Str("frame")), art.Channel(a.Str("channel")))
}},
{Name: "art.apply", Doc: "Propose one atomic graphics edit at the revision returned by art.read. Agent panel edits require approval; CLI and external agents follow the editor's confirmation setting. All use the document's common Undo. Name new entities with as and refer to them as $name later.", Write: true, Destroys: true, Args: []Arg{
fileArg, {Name: "revision", Type: "string", Doc: "Exact decimal revision returned by art.read", Required: true},
{Name: "label", Type: "string", Doc: "Human-readable operation label", Required: true},
{Name: "operations", Type: "array", Doc: "1..4096 typed art.Operation objects; no paths or shell commands", Required: true, Items: map[string]any{"type": "object"}},
}, Run: func(s *Session, a Args) (*Output, error) {
h, err := artHost(s)
if err != nil {
return nil, err
}
if !a.Bool("dry-run") {
return nil, fmt.Errorf("art: edits must pass through the editor proposal queue")
}
revision, err := strconv.ParseUint(a.Str("revision"), 10, 64)
if err != nil {
return nil, fmt.Errorf("art: revision must be an unsigned decimal integer")
}
label := strings.TrimSpace(a.Str("label"))
if label == "" || len(label) > 256 {
return nil, fmt.Errorf("art: label must have 1..256 bytes")
}
ops, err := art.DecodeOperations([]byte(a.Str("operations")))
if err != nil {
return nil, err
}
return h.PrepareArt(a.Str("file"), revision, label, ops)
}},
}
}
-44
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"aego/project"
@@ -15,7 +14,6 @@ type cliFlags struct {
json bool
quiet bool
strict bool
artOut string
}
func Usage(w io.Writer) {
@@ -103,22 +101,6 @@ func parseArgs(c *Command, toks []string) (Args, cliFlags, error) {
i++
}
continue
case "out":
if c.Name != "art.capture" {
break
}
if !has {
if i+1 >= len(toks) {
return nil, f, fmt.Errorf("--out needs a value")
}
i++
val = toks[i]
}
if val == "" || f.artOut != "" {
return nil, f, fmt.Errorf("--out requires one non-empty path")
}
f.artOut = val
continue
}
x, ok := byName[name]
if !ok {
@@ -147,9 +129,6 @@ func parseArgs(c *Command, toks []string) (Args, cliFlags, error) {
}
func report(stdout, stderr io.Writer, f cliFlags, out *Output, err error) int {
if err == nil && f.artOut != "" {
err = writeArtCapture(f.artOut, out)
}
switch {
case f.json:
var v any = out
@@ -172,26 +151,3 @@ func report(stdout, stderr io.Writer, f cliFlags, out *Output, err error) int {
}
return 0
}
// An explicit output path never overwrites a user's existing file.
func writeArtCapture(path string, out *Output) error {
if out == nil || len(out.Images) != 1 || out.Images[0].Mime != "image/png" {
return errors.New("art: editor did not return a PNG capture")
}
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return fmt.Errorf("art: cannot create capture: %w", err)
}
n, err := f.Write(out.Images[0].Data)
if err == nil && n != len(out.Images[0].Data) {
err = io.ErrShortWrite
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err != nil {
_ = os.Remove(path)
return fmt.Errorf("art: cannot write capture: %w", err)
}
return nil
}
+1 -6
View File
@@ -48,7 +48,6 @@ type Output struct {
// Images go to the model as image content, such as the screenshots of a run.
Images []Image `json:"-"`
Drawing *DrawingEdit `json:"-"`
Art ArtProposal `json:"-"`
}
// Image is a picture an answer carries.
@@ -74,7 +73,6 @@ func catalog() []*Command {
out = append(out, materialCommands()...)
out = append(out, postEffectCommands()...)
out = append(out, textureCommands()...)
out = append(out, artCommands()...)
out = append(out, schemaCommands()...)
out = append(out, projectCommands()...)
out = append(out, scriptCommands()...)
@@ -84,7 +82,7 @@ func catalog() []*Command {
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.") || strings.HasPrefix(c.Name, "art.")) {
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}
}
@@ -145,9 +143,6 @@ func (c *Command) Usage() string {
if c.Write {
parts = append(parts, "[--dry-run]")
}
if c.Name == "art.capture" {
parts = append(parts, "[--out path]")
}
return strings.Join(parts, " ")
}
-59
View File
@@ -1,59 +0,0 @@
package agent
import (
"fmt"
"testing"
"aego/art"
"aego/asset"
)
// Source-only scene validation must not treat a partial sprite sheet as a
// complete generic material texture when native runtime rejects that sampler.
func TestAgentSourceMaterialRejectsPartialArtSheet(t *testing.T) {
for _, multi := range []bool{false, true} {
t.Run(fmt.Sprintf("multi=%v", multi), func(t *testing.T) {
s := materialSession(t)
doc, err := art.New(4, 4, art.RGBA, art.DefaultLimits())
if err != nil {
t.Fatal(err)
}
defer doc.Close()
if multi {
edit, err := doc.Begin(doc.Revision(), "Add frame")
if err != nil {
t.Fatal(err)
}
if _, err := edit.AddFrame(doc.Info().Frames[0].ID, 100); err != nil {
t.Fatal(err)
}
change, err := edit.Prepare()
if err != nil {
t.Fatal(err)
}
if err := change.Apply(); err != nil {
t.Fatal(err)
}
}
data, err := doc.Encode()
if err != nil {
t.Fatal(err)
}
artID, err := s.create("assets/art.aeart", data)
if err != nil {
t.Fatal(err)
}
content := fmt.Sprintf("material version=1 shader=asset(%q) { params { Noise=asset(%q) } }", asset.BuiltinShaderDissolve.String(), artID.String())
materialID, err := s.create("assets/art.aemat", []byte(content))
if err != nil {
t.Fatal(err)
}
if info, ok := (sources{s.Pipeline}).TextureMetadata(artID); !ok || info.Region != multi || info.Width != 4 || info.Height != 4 {
t.Fatalf("source-only metadata lost first-frame bounds: %+v/%v", info, ok)
}
if _, ok := (sources{s.Pipeline}).Material(materialID); ok == multi {
t.Fatalf("source-only material resolver result=%v for multi=%v", ok, multi)
}
})
}
}
-17
View File
@@ -470,23 +470,6 @@ func (r sources) Material(id ids.AssetID) (*material.Material, bool) {
}
e, ok := r.pl.Entry(tid)
info, valid := r.pl.TextureSourceInfo(tid, nil)
if !valid && strings.EqualFold(path.Ext(e.Source), ".aeart") {
// The PNG source inspector cannot decode an art document. Use the
// same prepared frame semantics as the runtime instead of silently
// accepting its whole animation sheet as a generic sampler.
prepared, ready := r.TextureMetadata(tid)
if !ok || e.Missing || e.Error != "" || !ready {
return material.Tex{}, false
}
mode := texture.EmissionNone
switch prepared.Semantic {
case asset.TextureEmissionRGB:
mode = texture.EmissionRGB
case asset.TextureEmissionMask:
mode = texture.EmissionMask
}
return material.Tex{RegionOnly: prepared.Region, NormalMap: prepared.Semantic == asset.TextureNormal, EmissionMode: mode}, true
}
mode := texture.EmissionNone
switch info.Role {
case asset.RoleEmissionRGB:
+1 -1
View File
@@ -41,7 +41,7 @@ var versions = []string{"2025-06-18", "2025-03-26", "2024-11-05"}
var skipDirs = []string{".aego", ".git", "exports", "native"}
const instructions = `Tools for one Aego project. Paths are relative to the project root; a node is a path from the root (Player/Sprite) or #<id> from scene_tree; values use scene syntax: 2.5, [1, 2], #ff8800ff, Kinematic, "text", asset("<id>") or a project path. Read schema_show before setting fields of an unfamiliar component and docs_search / docs_read for how the engine works. Every write tool takes dry-run; scene_apply applies a whole transaction at once. For editable .aeart in a running editor, art_read and art_capture inspect the unsaved document; art_apply proposes an undoable batch at the returned revision. Check results with scene_validate, project_validate and scene_run. A sprite with no texture yet is drawn, not built from colored rectangles: asset_pixels takes a character grid and a palette (frames give an atlas and a flipbook), asset_svg smooth shapes for tiles, UI and icons; both answer with a preview to check. Draw in the style of project.aego's art section (project_get art): its palette, and without a palette argument 0-9 and a-z are its colors.`
const instructions = `Tools for one Aego project. Paths are relative to the project root; a node is a path from the root (Player/Sprite) or #<id> from scene_tree; values use scene syntax: 2.5, [1, 2], #ff8800ff, Kinematic, "text", asset("<id>") or a project path. Read schema_show before setting fields of an unfamiliar component and docs_search / docs_read for how the engine works. Every write tool takes dry-run; scene_apply applies a whole transaction at once. Check results with scene_validate, project_validate and scene_run. A sprite with no texture yet is drawn, not built from colored rectangles: asset_pixels takes a character grid and a palette (frames give an atlas and a flipbook), asset_svg smooth shapes for tiles, UI and icons; both answer with a preview to check. Draw in the style of project.aego's art section (project_get art): its palette, and without a palette argument 0-9 and a-z are its colors.`
type request struct {
JSONRPC string `json:"jsonrpc"`
+1 -17
View File
@@ -3,7 +3,6 @@ package agent
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -92,7 +91,7 @@ func proxyRun(addr, token string, c *Command, a Args) (*Output, error) {
var rep struct {
Result struct {
Content []struct {
Type, Text, Data, MimeType string
Type, Text string
} `json:"content"`
IsError bool `json:"isError"`
} `json:"result"`
@@ -123,21 +122,6 @@ func proxyRun(addr, token string, c *Command, a Args) (*Output, error) {
return &Output{OK: true, Text: text}, nil
}
o := out.Output
if c.Name == "art.capture" {
for _, part := range rep.Result.Content {
if part.Type != "image" || part.MimeType != "image/png" {
continue
}
if len(part.Data) > base64.StdEncoding.EncodedLen(16<<20) {
return nil, errors.New("art: PNG exceeds agent capture budget")
}
data, err := base64.StdEncoding.DecodeString(part.Data)
if err != nil {
return nil, fmt.Errorf("art: invalid PNG response: %w", err)
}
o.Images = append(o.Images, Image{Mime: part.MimeType, Data: data})
}
}
var s string
switch {
case c.Name == "scene.run":
+6 -4
View File
@@ -1,6 +1,7 @@
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"testing"
@@ -98,7 +99,8 @@ func TestKeyTracksNativeSources(t *testing.T) {
func TestLocalReplaces(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "go.mod"), `module game
absolute := filepath.VolumeName(root) + "/abs/c"
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf(`module game
go 1.27
@@ -107,9 +109,9 @@ replace aego => ../engine // local engine
replace (
example.com/a v1.2.3 => ./vendor/a
example.com/b => example.com/b v1.0.0
example.com/c => /abs/c
example.com/c => %s
)
`)
`, absolute))
got, err := LocalReplaces(root)
if err != nil {
t.Fatal(err)
@@ -117,7 +119,7 @@ replace (
want := map[string]bool{
filepath.Clean(filepath.Join(root, "../engine")): true,
filepath.Clean(filepath.Join(root, "vendor", "a")): true,
filepath.Clean("/abs/c"): true,
filepath.Clean(absolute): true,
}
if len(got) != len(want) {
t.Fatalf("replaces = %v", got)
-6
View File
@@ -45,9 +45,6 @@ type Config struct {
Platform platform.Platform
Profile string
NoProfile bool
// NoExternalImportTools prevents project assets from starting external
// converters and validators, including on later reimport.
NoExternalImportTools bool
// SharedIndex opens the asset index read-only without a warning when another process, the editor, holds it.
SharedIndex bool
}
@@ -137,9 +134,6 @@ func (h *Host) build(cfg Config, mods []module.Module) error {
if h.Importers, err = DevImporters(h.Workspace, h.Modules); err != nil {
return err
}
if cfg.NoExternalImportTools {
disableExternalImportTools(h.Importers)
}
if err = h.Types.Schema().Freeze(); err != nil {
return err
}
+13 -3
View File
@@ -78,11 +78,21 @@ type Bundle struct {
func sanitizer() func(string) string {
home, _ := os.UserHomeDir()
return func(s string) string {
if home != "" {
s = strings.ReplaceAll(s, home, "~")
}
return hideHome(s, home)
}
}
func hideHome(s, home string) string {
if home == "" {
return s
}
// Windows paths can appear with either separator in stack traces and logs.
for _, prefix := range [2]string{home, strings.ReplaceAll(home, `\`, "/")} {
s = strings.ReplaceAll(s, prefix+`\`, "~/")
s = strings.ReplaceAll(s, prefix+"/", "~/")
s = strings.ReplaceAll(s, prefix, "~")
}
return s
}
func Write(b Bundle) (string, error) {
+13
View File
@@ -3,6 +3,7 @@ package crash
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -10,6 +11,9 @@ import (
func TestBundleHidesHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if runtime.GOOS == "windows" {
t.Setenv("USERPROFILE", home)
}
root := filepath.Join(home, "game")
dir, err := Write(Bundle{Root: root, Kind: "panic", Where: "panel inspector", Panic: "boom in " + root, Stack: []byte(root + "/x.go:1")})
if err != nil {
@@ -25,3 +29,12 @@ func TestBundleHidesHome(t *testing.T) {
}
}
}
func TestHideHomeWithWindowsSeparators(t *testing.T) {
home := `C:\Users\Alice`
for _, input := range []string{`C:\Users\Alice\game\main.go`, `C:/Users/Alice/game/main.go`} {
if got := hideHome(input, home); got != `~/game/main.go` && got != `~/game\main.go` {
t.Fatalf("hideHome(%q) = %q", input, got)
}
}
}
File diff suppressed because it is too large Load Diff
-99
View File
@@ -1,99 +0,0 @@
package docstool
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
func TestStudioWebPackageVersionsEveryBridgeResource(t *testing.T) {
web := filepath.Join("..", "..", "tools", "artstudio", "web")
dir := t.TempDir()
wasm := filepath.Join(dir, "artstudio.wasm")
if err := os.WriteFile(wasm, []byte("wasm-build-one"), 0o644); err != nil {
t.Fatal(err)
}
support := []byte("versioned go wasm support")
check := func() string {
t.Helper()
if err := packageStudioWeb(web, dir, support); err != nil {
t.Fatal(err)
}
index, err := os.ReadFile(filepath.Join(dir, "index.html"))
if err != nil {
t.Fatal(err)
}
js, err := os.ReadFile(filepath.Join(dir, "studio.js"))
if err != nil {
t.Fatal(err)
}
match := regexp.MustCompile(`studio\.css\?v=([a-f0-9]{10})`).FindSubmatch(index)
if len(match) != 2 {
t.Fatalf("no versioned stylesheet: %s", index)
}
version := string(match[1])
for _, item := range []string{"wasm_exec.js", "recovery.js", "studio.js"} {
if !strings.Contains(string(index), item+"?v="+version) {
t.Fatalf("%s is not bound to %s", item, version)
}
}
if !strings.Contains(string(js), "fetch(window.AegoStudioRelease?.wasmURL || 'artstudio.wasm?v="+version+"')") {
t.Fatal("WASM fetch lost the release override or the versioned local fallback")
}
actual, err := os.ReadFile(filepath.Join(dir, "wasm_exec.js"))
if err != nil || string(actual) != string(support) {
t.Fatal("WASM support changed")
}
return version
}
first := check()
if second := check(); first != second {
t.Fatalf("same inputs are not deterministic: %s != %s", first, second)
}
if err := os.WriteFile(wasm, []byte("wasm-build-two"), 0o644); err != nil {
t.Fatal(err)
}
if next := check(); next == first {
t.Fatal("changed WASM reused the old bridge version")
}
}
func TestStudioWebPackageRejectsUnknownEntryPoints(t *testing.T) {
web, dir := t.TempDir(), t.TempDir()
for name, contents := range map[string]string{"index.html": "unexpected index", "studio.css": "css", "studio.js": "fetch('artstudio.wasm')", "recovery.js": "js"} {
if err := os.WriteFile(filepath.Join(web, name), []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(dir, "artstudio.wasm"), []byte("wasm"), 0o644); err != nil {
t.Fatal(err)
}
if err := packageStudioWeb(web, dir, []byte("support")); err == nil {
t.Fatal("unknown index was silently packaged")
}
if _, err := os.Stat(filepath.Join(dir, "index.html")); !os.IsNotExist(err) {
t.Fatalf("invalid index was published: %v", err)
}
}
func TestStudioWebPackageRejectsAmbiguousWASMFetch(t *testing.T) {
web, dir := t.TempDir(), t.TempDir()
index := `<link href="studio.css"><script src="wasm_exec.js"></script><script src="recovery.js"></script><script src="studio.js"></script>`
fetch := `fetch(window.AegoStudioRelease?.wasmURL || 'artstudio.wasm')`
for name, contents := range map[string]string{"index.html": index, "studio.css": "css", "studio.js": fetch + ";" + fetch, "recovery.js": "js"} {
if err := os.WriteFile(filepath.Join(web, name), []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(dir, "artstudio.wasm"), []byte("wasm"), 0o644); err != nil {
t.Fatal(err)
}
if err := packageStudioWeb(web, dir, []byte("support")); err == nil {
t.Fatal("ambiguous WASM fetch was silently packaged")
}
if _, err := os.Stat(filepath.Join(dir, "index.html")); !os.IsNotExist(err) {
t.Fatalf("ambiguous page was published: %v", err)
}
}
+18 -42
View File
@@ -3,9 +3,7 @@
package docsview
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
playmods "aego/app/assembly/playground"
@@ -17,29 +15,26 @@ import (
)
type request struct {
Scene string `json:"scene"`
Select string `json:"select"`
Code string `json:"code"`
Prefabs map[string]string `json:"prefabs"`
Artifacts []byte `json:"artifacts"`
Component string `json:"component"`
Field string `json:"field"`
Value json.RawMessage `json:"value"`
Fields []string `json:"fields"`
Dependencies []string `json:"dependencies"`
Node string `json:"node"`
Theme string `json:"theme"`
Lang string `json:"lang"`
Scene string `json:"scene"`
Select string `json:"select"`
Code string `json:"code"`
Prefabs map[string]string `json:"prefabs"`
Artifacts []byte `json:"artifacts"`
Component string `json:"component"`
Field string `json:"field"`
Value float64 `json:"value"`
Node string `json:"node"`
Theme string `json:"theme"`
Lang string `json:"lang"`
}
type Reply struct {
Text string `json:"text,omitempty"`
Values map[string]string `json:"values,omitempty"`
Error string `json:"error,omitempty"`
State *docsession.State `json:"state,omitempty"`
Log []Line `json:"log,omitempty"`
Pose []float64 `json:"pose,omitempty"`
Inspection *Inspection `json:"inspection,omitempty"`
Text string `json:"text,omitempty"`
Values map[string]string `json:"values,omitempty"`
Error string `json:"error,omitempty"`
State *docsession.State `json:"state,omitempty"`
Log []Line `json:"log,omitempty"`
Pose []float64 `json:"pose,omitempty"`
}
func (a *App) Call(method string, payload []byte) Reply {
@@ -82,20 +77,7 @@ func (a *App) Call(method string, payload []byte) Reply {
case "redo":
err = s.Redo()
case "set":
value := bytes.TrimSpace(req.Value)
if len(value) == 0 || bytes.Equal(value, []byte("null")) {
err = fmt.Errorf("set needs a number or string value")
} else if value[0] == '"' {
var text string
if err = json.Unmarshal(value, &text); err == nil {
err = s.SetString(req.Component, req.Field, text)
}
} else {
var number float64
if err = json.Unmarshal(value, &number); err == nil {
err = s.Set(req.Component, req.Field, number)
}
}
err = s.Set(req.Component, req.Field, req.Value)
case "state":
st := s.State()
return Reply{State: &st}
@@ -110,12 +92,6 @@ func (a *App) Call(method string, payload []byte) Reply {
}
p := sc.WorldPosition(h)
return Reply{Pose: []float64{float64(p.X), float64(p.Y)}}
case "inspect":
inspection, err := a.inspectTry(req.Component, req.Fields, req.Dependencies)
if err != nil {
return Reply{Error: err.Error()}
}
return Reply{Inspection: inspection}
case "log":
out := Reply{Log: a.lines}
a.lines = nil
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
//go:build aego_editor
package docsview
import (
"crypto/sha256"
"fmt"
"testing"
"aego/app/playground"
)
// Component-specific demonstrations must not silently replace the existing
// authored scenes and scripts used by the broader guides.
func TestComponentTryGuidesPreserveAuthoredScenes(t *testing.T) {
p, err := playground.New()
if err != nil {
t.Fatal(err)
}
for _, spec := range []struct{ guide, component, sum, script string }{
{"camera", "camera.CameraFollow2D", "0a7ed8d6fd9cce7ee144cd2a0ca91a6ff377220cc7b737ebe9feb93e10331f3a", ""},
{"camera/follow", "camera.CameraFollow2D", "0a7ed8d6fd9cce7ee144cd2a0ca91a6ff377220cc7b737ebe9feb93e10331f3a", ""},
{"camera/zones", "camera.CameraZone2D", "9f5b36a8de5584836d466b8437510c1990e3c719d41a4c65dfa591b2b7c634e8", ""},
{"camera/outputs", "camera.CameraBrain2D", "d475b935c8c41973653b44aa1ff75d4871263a0ad2cb9d56097f234818be1aa4", ""},
{"camera/virtual", "camera.VirtualCamera2D", "14e775168e8f7f5250d42970db4578d39219a9e3bb9d0dab839892fd92ae88a8", "af3febb779542a8605bd2899667e87d5cf81826ca4d15efcbab85a0c0efcffab"},
{"physics", "physics.Body2D", "87081fb3f66fb919ef02833f30d052457b456c841b2b6406c9930ad47884644c", ""},
} {
t.Run(spec.guide, func(t *testing.T) {
guide, ok := p.Docs().Get(spec.guide)
if !ok {
t.Fatal("guide not found")
}
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(guide.Try))); got != spec.sum || guide.TrySelect != spec.component {
t.Fatalf("authored guide scene or selection changed: sha=%s select=%s", got, guide.TrySelect)
}
if spec.script != "" {
if got := fmt.Sprintf("%x", sha256.Sum256([]byte(guide.TryScript))); got != spec.script {
t.Fatalf("authored guide script changed: sha=%s", got)
}
}
component, ok := p.Docs().Get(spec.component)
if !ok || component.Demo == nil || component.Try == guide.Try {
t.Fatalf("%s needs its distinct executable component demo", spec.component)
}
})
}
for _, problem := range p.Docs().Problems() {
if problem.Error {
t.Fatalf("documentation registry error: %+v", problem)
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-72
View File
@@ -1,72 +0,0 @@
//go:build aego_editor
package docsview
import (
"encoding/json"
"strings"
"testing"
"aego/app/playground"
)
func TestTryInspectionReadsSelectedRunningComponent(t *testing.T) {
p, err := playground.New()
if err != nil {
t.Fatal(err)
}
e, ok := p.Docs().Get("engine.SpriteRenderer")
if !ok || e.Try == "" {
t.Fatal("missing SpriteRenderer Try")
}
a := newHeadless(t)
load, _ := json.Marshal(map[string]any{"scene": e.Try, "select": e.TrySelect, "artifacts": e.TryArtifacts})
if r := a.Call("load", load); r.Error != "" {
t.Fatal(r.Error)
}
if err := a.Step(10); err != nil {
t.Fatal(err)
}
req := []byte(`{"component":"engine.SpriteRenderer","fields":["engine.SpriteRenderer.Order"],"dependencies":["ae900000000000000000000000000010"]}`)
inspect := func(want float64) {
t.Helper()
r := a.Call("inspect", req)
if r.Error != "" || r.Inspection == nil || !r.Inspection.RuntimeExecuting || r.Inspection.Observables["engine.SpriteRenderer.Order"] != int64(want) || len(r.Inspection.Dependencies) != 1 {
t.Fatalf("inspect: %+v", r)
}
wire, err := json.Marshal(r)
if err != nil || !strings.Contains(string(wire), `"engine.SpriteRenderer.Order":`) {
t.Fatalf("inspection is not accessible over the real bridge: %s, %v", wire, err)
}
}
inspect(1)
before := string(a.session.Text())
inspect(1)
if string(a.session.Text()) != before || a.session.State().CanUndo {
t.Fatal("read-only inspection changed the source document")
}
if r := a.Call("set", []byte(`{"component":"engine.SpriteRenderer","field":"order","value":-1}`)); r.Error != "" {
t.Fatal(r.Error)
}
if err := a.Step(4); err != nil {
t.Fatal(err)
}
inspect(-1)
if r := a.Call("reset", nil); r.Error != "" {
t.Fatal(r.Error)
}
if err := a.Step(4); err != nil {
t.Fatal(err)
}
inspect(1)
for _, invalid := range []string{
`{"component":"engine.SpriteRenderer","fields":["engine.SpriteRenderer.NoSuchField"]}`,
`{"component":"engine.SpriteRenderer","dependencies":["ffffffffffffffffffffffffffffffff"]}`,
`{"component":"engine.SpriteRenderer","dependencies":["ae900000000000000000000000000010","ae900000000000000000000000000010"]}`,
`{"component":"engine.Mesh2D","fields":["engine.Mesh2D.Order"]}`,
} {
if r := a.Call("inspect", []byte(invalid)); r.Error == "" {
t.Fatalf("accepted invalid inspection %s: %+v", invalid, r)
}
}
}
File diff suppressed because it is too large Load Diff
-84
View File
@@ -1,84 +0,0 @@
//go:build aego_editor
package docsview
import (
"encoding/json"
"testing"
"aego/app/playground"
"aego/asset"
"aego/engine/docs"
"aego/modules/render2d"
"aego/scene"
)
// This executes the real docs Try session with its existing loader, editor
// mutation and reset. A headless run does not prove GPU or browser rendering.
func TestMesh2DTryLoadsEditsAndResets(t *testing.T) {
p, err := playground.New()
if err != nil {
t.Fatal(err)
}
var entry docs.Entry
for _, candidate := range p.TryScenes() {
if candidate.ID == "engine.Mesh2D" {
entry = candidate
break
}
}
if entry.Try == "" || entry.TrySelect != "engine.Mesh2D" {
t.Fatal("the Mesh2D component does not own a selectable Try scene")
}
a := newHeadless(t)
request, err := json.Marshal(map[string]any{"scene": entry.Try, "select": entry.TrySelect, "artifacts": entry.TryArtifacts})
if err != nil {
t.Fatal(err)
}
if reply := a.Call("load", request); reply.Error != "" {
t.Fatal(reply.Error)
}
if err := a.Step(10); err != nil {
t.Fatal(err)
}
if _, ok := a.loader.Mesh(asset.BuiltinCube); !ok {
t.Fatal("the built-in mesh dependency is unavailable")
}
order := func() int32 {
t.Helper()
sc := a.session.Controller().Scene()
if sc == nil {
t.Fatal("Try runtime is not running")
}
node := sc.Find(sc.Root(), "Mesh 2D")
mesh, ok := scene.Get[render2d.Mesh2D](sc, node)
if !ok || mesh.Mesh.ID != asset.BuiltinCube {
t.Fatal("Try runtime lost the component or its mesh")
}
return mesh.Order
}
if got := order(); got != 2 {
t.Fatalf("initial mesh order = %d, want 2", got)
}
if err := a.session.Set("engine.Mesh2D", "order", 0); err != nil {
t.Fatal(err)
}
if err := a.Step(2); err != nil {
t.Fatal(err)
}
if got := order(); got != 0 {
t.Fatalf("edited mesh order = %d, want 0", got)
}
if err := a.session.Reset(); err != nil {
t.Fatal(err)
}
if err := a.Step(2); err != nil {
t.Fatal(err)
}
if got := order(); got != 2 {
t.Fatalf("reset mesh order = %d, want 2", got)
}
if errors := errorsOf(a); len(errors) > 0 {
t.Fatalf("Mesh2D Try faults: %v", errors)
}
}
-96
View File
@@ -1,96 +0,0 @@
//go:build aego_editor
package docsview
import (
"encoding/json"
"fmt"
"testing"
"aego/app/playground"
)
func TestStringTryBridgeHistoryAndValidation(t *testing.T) {
p, err := playground.New()
if err != nil {
t.Fatal(err)
}
e, ok := p.Docs().Get("camera.CameraBrain2D")
if !ok || e.Try == "" {
t.Fatal("missing CameraBrain2D Try")
}
a := newHeadless(t)
load, err := json.Marshal(map[string]any{"scene": e.Try, "select": e.TrySelect, "artifacts": e.TryArtifacts})
if err != nil {
t.Fatal(err)
}
if r := a.Call("load", load); r.Error != "" {
t.Fatal(r.Error)
}
step := func() {
t.Helper()
if err := a.Step(4); err != nil {
t.Fatal(err)
}
}
step()
inspect := func(want string) {
t.Helper()
r := a.Call("inspect", []byte(`{"component":"camera.CameraBrain2D","fields":["camera.CameraBrain2D.Output"]}`))
if r.Error != "" || r.Inspection == nil || r.Inspection.Observables["camera.CameraBrain2D.Output"] != want {
t.Fatalf("want output %q: %+v", want, r)
}
}
inspect("map")
for _, value := range []string{`null`, `true`, `{}`, `[]`, `123`} {
r := a.Call("set", []byte(fmt.Sprintf(`{"component":"camera.CameraBrain2D","field":"Output","value":%s}`, value)))
if r.Error == "" {
t.Fatalf("accepted %s for string field", value)
}
}
for _, invalid := range []string{
`{"component":"camera.CameraBrain2D","field":"Output"}`,
`{"component":"engine.Camera2D","field":"Zoom","value":"40"}`,
} {
if r := a.Call("set", []byte(invalid)); r.Error == "" {
t.Fatalf("accepted invalid set: %s", invalid)
}
}
inspect("map")
if a.session.State().CanUndo {
t.Fatal("rejected edits changed history")
}
set := func(value string) {
t.Helper()
data, err := json.Marshal(map[string]any{"component": "camera.CameraBrain2D", "field": "Output", "value": value})
if err != nil {
t.Fatal(err)
}
if r := a.Call("set", data); r.Error != "" {
t.Fatal(r.Error)
}
step()
}
set("")
inspect("")
if r := a.Call("undo", nil); r.Error != "" {
t.Fatal(r.Error)
}
step()
inspect("map")
if r := a.Call("redo", nil); r.Error != "" {
t.Fatal(r.Error)
}
step()
inspect("")
set("карта \"A\"")
inspect("карта \"A\"")
if r := a.Call("reset", nil); r.Error != "" {
t.Fatal(r.Error)
}
step()
inspect("map")
if a.session.State().CanUndo {
t.Fatal("reset retained history")
}
}
@@ -1,58 +0,0 @@
//go:build aego_editor && aego_gltest && linux && cgo && !aego_nonative
package docsview
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBrowserProcessDeadlineFailsClosed(t *testing.T) {
const runner = `
const readline = require('node:readline');
readline.createInterface({input: process.stdin}).on('line', line => {
const req = JSON.parse(line);
if (req.method === 'hang') return;
const id = req.method === 'wrong-id' ? req.id + 1 : req.id;
const error = req.method === 'error' ? 'deliberate failure' : undefined;
process.stdout.write(JSON.stringify({id, error, result: {ok: true}}) + '\n');
});`
file := filepath.Join(t.TempDir(), "runner.cjs")
if err := os.WriteFile(file, []byte(runner), 0o600); err != nil {
t.Fatal(err)
}
p, err := startBrowserProcess(context.Background(), t.TempDir(), file)
if err != nil {
t.Fatal(err)
}
defer p.close()
var reply struct {
OK bool `json:"ok"`
}
if err := p.call(context.Background(), "ok", nil, &reply); err != nil || !reply.OK {
t.Fatalf("valid browser reply failed: %+v, %v", reply, err)
}
if err := p.call(context.Background(), "wrong-id", nil, nil); err == nil || !strings.Contains(err.Error(), "ID mismatch") {
t.Fatalf("wrong response ID accepted: %v", err)
}
if err := p.call(context.Background(), "error", nil, nil); err == nil || !strings.Contains(err.Error(), "deliberate failure") {
t.Fatalf("browser error accepted: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
start := time.Now()
if err := p.call(ctx, "hang", nil, nil); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("hung browser must fail with context deadline, got %v", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("hung browser did not stop promptly: %v", elapsed)
}
if err := p.call(context.Background(), "ok", nil, &reply); err == nil {
t.Fatal("a killed runner accepted a later probe")
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -109,7 +109,7 @@ const GuideExample = `tx version=1 { label="Add Ledge" op "add-node" { name="Led
// Guide is the system prompt the editor adds for the model.
const Guide = "You work inside the Aego editor. Write tools wait until the user applies or rejects the change; a rejected call means the user said no, so ask before trying it again. Edits to open scenes become Undo steps in the editor and are saved by the user. " +
"Each write call is one card the user has to apply, so send related edits of one scene as one scene_apply transaction: a new node with its components and fields is one call, not a chain of add-node, add-comp and set. Give the transaction a short label; it names the Undo step. Example tx: " + GuideExample + ". Use the single-step tools for one small change. For an open document use editor_save_document, editor_undo or editor_redo with its project path; these ask the user and never act on a different document. " +
"Read the selected Console entry as evidence. Read .aeshader and .aemat buffers with editor_material_source by AssetID or project path. For an editable .aeart use art_read and art_capture to inspect the current unsaved document; use its exact revision with art_apply to propose one undoable batch. Read scripts with script_read; change one with script_edit for a few lines or script_write for the whole text, and add one with script_create. A script attaches to a node like a component: add-comp with its type name (EnemyAI or the full mygame.enemy.EnemyAI), and its exported fields are read and set like component fields (EnemyAI.PointA). Node reference fields take a node path such as Root/PatrolA1. " +
"Read the selected Console entry as evidence. Read .aeshader and .aemat buffers with editor_material_source by AssetID or project path. Read scripts with script_read; change one with script_edit for a few lines or script_write for the whole text, and add one with script_create. A script attaches to a node like a component: add-comp with its type name (EnemyAI or the full mygame.enemy.EnemyAI), and its exported fields are read and set like component fields (EnemyAI.PointA). Node reference fields take a node path such as Root/PatrolA1. " +
"Check behaviour with scene_run: it plays the scene as it is in the editor; input presses actions by frame (30 press Jump; 10-60 hold Right; 0-40 axis Move 1), assert checks values at a frame or at any frame (@120 Player.transform.position.y < 3; EnemyLeft.Health.hp == 0; exists Ledge) and fails the run when one does not hold, watch reports node positions, shot captures up to 3 frames as images. If a check fails, inspect its result and change the test or fix the cause; do not repeatedly run an unchanged failing test. A sprite with no texture yet is drawn, not built from colored rectangles: asset_pixels takes a character grid and a palette (frames give an atlas and a flipbook), asset_svg smooth shapes for tiles, UI and icons; both answer with a preview to check. Draw in the style of project.aego's art section (project_get art): its palette, and without a palette argument 0-9 and a-z are its colors. When the user has to choose between ways to do something, call ask_user with the options instead of listing them in your answer."
// Start opens the MCP server on loopback; tools run on the editor's main thread through OnMain.
+2 -31
View File
@@ -40,7 +40,6 @@ type undoRef struct {
depth int
script *scriptUndo
drawing *drawingCommand
art *artUndo
}
// Proposal is one write call from the model, previewed as a dry run and waiting for the user (D342).
@@ -146,14 +145,7 @@ func (b *Bridge) proposeVia(c *agent.Command, a agent.Args, via string, direct b
func (b *Bridge) preview(c *agent.Command, a agent.Args, via string, direct bool) (*Proposal, error) {
dry := maps.Clone(a)
dry["dry-run"] = true
// Prepared transactions retain their history label from preview through
// approval, so record the external origin before constructing them.
out, err := func() (*agent.Output, error) {
previous := b.via
b.via = via
defer func() { b.via = previous }()
return agent.Run(b.sess, c.Name, dry)
}()
out, err := agent.Run(b.sess, c.Name, dry)
if err != nil {
return nil, err
}
@@ -193,10 +185,7 @@ func (b *Bridge) apply(p *Proposal) (*agent.Output, error) {
defer func() { b.via = "" }()
var out *agent.Output
var err error
if p.Preview.Art != nil {
out, err = p.Preview.Art.Commit()
p.Preview.Art = nil
} else if p.Preview.Drawing != nil {
if p.Preview.Drawing != nil {
out, err = b.applyDrawing(p)
} else {
out, err = b.run(p.Command, p.Args)
@@ -222,9 +211,6 @@ func (b *Bridge) await(p *Proposal) (bool, error) {
if e := b.main(func() {
if p.State == Pending {
p.State, expired = Expired, true
if p.Preview != nil {
p.Preview.Art = nil
}
}
}); e != nil {
return false, e
@@ -268,8 +254,6 @@ func (b *Bridge) Decide(id int, apply bool) bool {
p.State = Rejected
if apply {
p.State = Approved
} else if p.Preview != nil {
p.Preview.Art = nil
}
p.decide <- apply
return true
@@ -286,9 +270,6 @@ func (b *Bridge) RejectAll() {
for _, p := range b.proposals {
if p.generation != 0 && p.State == Approved {
p.State, p.Err = Rejected, errCancelled.Error()
if p.Preview != nil {
p.Preview.Art = nil
}
}
}
b.dismissQuestions()
@@ -330,9 +311,6 @@ func (b *Bridge) CanUndo(id int) bool {
if p == nil || p.undo == nil || p.State != Applied {
return false
}
if p.undo.art != nil {
return b.canUndoArt(p.undo.art)
}
if p.undo.drawing != nil {
if p.undo.drawing.check(true) != nil {
return false
@@ -357,13 +335,6 @@ func (b *Bridge) Undo(id int) error {
return errors.New("this change is no longer the last step; undo it with Edit → Undo")
}
p := b.find(id)
if p.undo.art != nil {
if err := b.undoArt(p.undo.art); err != nil {
return err
}
p.State = Undone
return nil
}
if p.undo.drawing != nil && p.undo.doc == 0 {
if err := p.undo.drawing.Undo(nil); err != nil {
return 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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -15
View File
@@ -159,8 +159,8 @@ func TestDiagnosticComposerConsoleProfilerNative(t *testing.T) {
t.Fatal("SDL clipboard rejected window screenshot")
}
key(input.KeyV, true, false)
if panel.Input() != "draft line[Image 1]" {
t.Fatalf("screenshot paste lost draft or marker: %q", panel.Input())
if panel.Input() != "draft line" {
t.Fatalf("screenshot paste changed text: %q", panel.Input())
}
key(input.KeyA, true, false)
long := "line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nlast visible line"
@@ -169,19 +169,6 @@ func TestDiagnosticComposerConsoleProfilerNative(t *testing.T) {
if panel.Input() != long {
t.Fatalf("text not editable after clipboard image: %q", panel.Input())
}
// Replacing the marker removes its attachment. Reattach the screenshot so
// the remainder also exercises thumbnail layout and the unsent draft.
if thumbs := s.App.UIList().TestDrawnItems(screen, ed.UI.Font(), "", 512, height*512/width); len(thumbs) != 0 {
t.Fatalf("replacing image marker left an orphan attachment: %+v", thumbs)
}
if !ed.Clipboard.SetData(clip.MimePNG, original.Bytes(), "") {
t.Fatal("SDL clipboard rejected screenshot on reattach")
}
key(input.KeyV, true, false)
long += "[Image 1]"
if panel.Input() != long {
t.Fatalf("reattaching screenshot lost multiline draft or marker: %q", panel.Input())
}
last := text("last visible line")
if !inside(last) || last.Clip.H < small.H+3*ed.UI.LineHeight() {
t.Fatalf("multiline did not grow: one=%+v multiline=%+v", small, last)
+8 -20
View File
@@ -52,9 +52,6 @@ type Config struct {
OpenPath string
NativeNote string
LauncherTab string
// RemoteProject is set by the confirmed remote-example launcher. It is
// retained for the session, even if project.aego changes after startup.
RemoteProject bool
// AgentBackend overrides the saved provider for tests/replay; headless nil means no panel.
AgentBackend backend.Backend
}
@@ -135,9 +132,6 @@ func Run(p *project.Project, cfg Config, mods ...sdk.Module) error {
}
func Open(p *project.Project, cfg Config, mods ...sdk.Module) (*Session, error) {
if cfg.RemoteProject && p.Native {
return nil, errors.New(i18n.Tr("launcher.remote.native_blocked", "Remote examples cannot start a native Go project."))
}
platform.SetAppMetadata(platform.AppMetadata{Name: "Aego Editor", Version: engine.Version, Identifier: "ru.akarpov.aego.editor"})
if cfg.Width <= 0 {
cfg.Width = 1600
@@ -161,15 +155,14 @@ func Open(p *project.Project, cfg Config, mods ...sdk.Module) (*Session, error)
AppID: p.Module + ".editor",
Trace: true,
},
Logger: cfg.Logger,
Debug: cfg.Debug,
LoopScope: engine.ScopeFrame,
LocateBindings: true,
Docs: true,
Editor: true,
Catalog: cat,
Profile: cfg.Profile,
NoExternalImportTools: cfg.RemoteProject,
Logger: cfg.Logger,
Debug: cfg.Debug,
LoopScope: engine.ScopeFrame,
LocateBindings: true,
Docs: true,
Editor: true,
Catalog: cat,
Profile: cfg.Profile,
}, sdk.AdaptAll(mods)...)
if err != nil {
return nil, err
@@ -249,11 +242,6 @@ func Open(p *project.Project, cfg Config, mods ...sdk.Module) (*Session, error)
dirty = append(dirty, doc.Path)
}
}
for _, doc := range s.App.Context().Docs.AssetDocuments() {
if doc.Binding.Dirty != nil && doc.Binding.Dirty() {
dirty = append(dirty, doc.Path)
}
}
}
return dirty
}),
+1 -30
View File
@@ -16,7 +16,6 @@ import (
"aego/app/updates"
"aego/editor/core/session"
"aego/editor/shell/launcher"
"aego/engine/i18n"
"aego/platform"
"aego/project"
"aego/sdk"
@@ -154,15 +153,11 @@ func Start(e Entry) error {
dir, file := resolveArg(e.Arg)
if e.Launcher != "" || (dir == "" && !hasProject(".")) {
cfg.LauncherTab = e.Launcher
root, entry, remote, err := launchWithEntry(cfg)
root, err := Launch(cfg)
if err != nil || root == "" {
return err
}
dir, file = root, ""
if remote {
cfg.OpenPath = entry
cfg.RemoteProject = true
}
}
if dir == "" {
dir = "."
@@ -171,34 +166,10 @@ func Start(e Entry) error {
if err != nil {
return err
}
copy, managed, err := (launcherRemote{}).existingProject(root)
if err != nil {
return err
}
if managed {
cfg.RemoteProject = true
if file != "" {
file, err = filepath.EvalSymlinks(file)
if err != nil {
return err
}
}
root = copy.Directory
if file == "" && cfg.OpenPath == "" {
entry, ok := relInside(root, copy.Entry)
if !ok {
return errors.New("remote example entry is outside its project")
}
cfg.OpenPath = entry
}
}
p, err := project.Load(root)
if err != nil {
return err
}
if cfg.RemoteProject && p.Native {
return errors.New(i18n.Tr("launcher.remote.native_blocked", "Remote examples cannot start a native Go project."))
}
if file != "" {
if rel, ok := relInside(p.Root, file); ok {
cfg.OpenPath = rel
+1 -1
View File
@@ -60,7 +60,7 @@ func (x *exporter) Prepare() any {
if x.dirtySources != nil {
for _, rel := range x.dirtySources() {
switch strings.ToLower(filepath.Ext(rel)) {
case ".aemat", ".aeshader", ".glsl", ".vert", ".frag", ".meta", ".aepreset", ".aeart":
case ".aemat", ".aeshader", ".glsl", ".vert", ".frag", ".meta", ".aepreset":
snapshot.DirtySources = append(snapshot.DirtySources, rel)
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ func TestExportRejectsUnsavedMetadataWorkingOverlay(t *testing.T) {
}
defer pl.Close()
// The loader may display a private Undo generation while the pipeline retains the saved artifact; export must reject this even for .meta changes.
for _, path := range []string{"assets/platform_height.png.meta", "assets/effects/Dust.aepreset", "assets/hero.aeart"} {
for _, path := range []string{"assets/platform_height.png.meta", "assets/effects/Dust.aepreset"} {
t.Run(path, func(t *testing.T) {
x := &exporter{p: p, pl: pl, dirtySources: func() []string { return []string{path} }}
snapshot := x.Prepare().(*gameexport.Snapshot)
+4 -17
View File
@@ -242,7 +242,6 @@ func OpenLauncher(cfg Config) (*LaunchSession, error) {
RecentPath: cfg.Recent,
Home: projectsHome(),
Image: d.image,
Remote: launcherRemote{},
Theme: func() theme.Mode { return d.prefs.Theme },
SetTheme: d.setTheme,
Language: func() string { return d.prefs.Language },
@@ -286,36 +285,24 @@ func (ls *LaunchSession) Step() bool {
}
func (ls *LaunchSession) Close() {
if ls.UI != nil {
ls.UI.Close()
}
ls.Engine.Close()
}
func Launch(cfg Config) (string, error) {
root, _, remote, err := launchWithEntry(cfg)
if err == nil && remote {
return "", errors.New("remote examples require the entry-aware editor launch path")
}
return root, err
}
func launchWithEntry(cfg Config) (root, entry string, remote bool, err error) {
ls, err := OpenLauncher(cfg)
if err != nil {
return "", "", false, err
return "", err
}
if cfg.Frames > 0 {
for i := 0; i < cfg.Frames && ls.Step(); i++ {
}
} else if err := ls.Engine.Run(ls.driver); err != nil {
ls.Close()
return "", "", false, err
return "", err
}
ls.Close()
root, _ = ls.UI.Result()
entry, remote = ls.UI.ResultEntry()
return root, entry, remote, nil
root, _ := ls.UI.Result()
return root, nil
}
func touchRecent(cfg Config, root, name string, lg *log.ChannelLogger) {
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
//go:build aego_gltest
package edit
import (
"runtime"
"testing"
"aego/asset"
"aego/core/log"
"aego/core/mathx"
"aego/editor/core/document"
"aego/modules/render2d"
"aego/scene"
"aego/scene/frame"
)
func TestSceneView3DShows2DSpritePixels(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
p := newProject(t)
const path = "scenes/2d-in-3d.aescene"
writeScene(t, p, path, func(s *scene.Scene) {
h := s.Create(s.Root(), "Red sprite")
sr := scene.Add[render2d.SpriteRenderer](s, h)
*sr = render2d.DefaultSpriteRenderer()
sr.Sprite = asset.NewRef[asset.Texture](asset.BuiltinWhite)
sr.Tint = mathx.Color{R: 1, A: 1}
sr.Size = mathx.V2(4, 4)
})
s, err := Open(p, Config{Width: 800, Height: 600, Hidden: true, NoWatch: true, Logger: log.Nop()})
if err != nil {
t.Fatal(err)
}
defer s.Close()
s.Step()
ed := s.App.Context()
doc, err := ed.Docs.Open(path)
if err != nil {
t.Fatal(err)
}
doc.View.Mode = document.View3D
doc.View.Orbit = document.Orbit{Distance: 10, FOV: 60}
ed.Settings.Grid = false
for range 3 {
s.Step()
}
raw, ok := s.App.SceneView().LightingWorld().ServiceAny(frame.ServiceID)
if !ok {
t.Fatal("missing Scene View runner")
}
runner := raw.(*frame.Runner)
readRed := func() int {
t.Helper()
for range 2 {
s.Step()
}
target, w, h := runner.Target()
pixels := make([]byte, w*h*4)
if target.IsZero() {
t.Fatal("no Scene View target")
}
if err := ed.Renderer.Device().ReadPixels(target, 0, 0, w, h, pixels); err != nil {
t.Fatal(err)
}
count := 0
for i := 0; i+3 < len(pixels); i += 4 {
if pixels[i] > 160 && pixels[i+1] < 80 && pixels[i+2] < 80 {
count++
}
}
return count
}
visible := readRed()
if visible < 100 {
t.Fatalf("2D sprite produced only %d red pixels in 3D Scene View", visible)
}
sc := doc.Scene()
h := sc.Find(sc.Root(), "Red sprite")
sc.SetEnabled(h, false)
if hidden := readRed(); hidden >= visible/2 {
t.Fatalf("disabling the sprite left %d of %d red pixels", hidden, visible)
}
}

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