Author SHA1 Message Date
sanspie 2f3b06af54 docs: exercise camera outputs and box collider Try 2026-11-07 13:05:26 +03:00
sanspie 788dae1bf7 docs: add camera and rigid body Try contracts 2026-11-07 12:22:07 +03:00
sanspie 3812145c74 test: run pinned native and browser art parity 2026-11-07 11:28:49 +03:00
sanspie bfc69d5598 docs: add executable VirtualCamera2D Try contract 2026-11-07 11:28:28 +03:00
sanspie a946785c7b docs: link component references to Studio lessons 2026-11-07 11:28:13 +03:00
sanspie 2104d1d54c test: preserve screenshot markers in native composer workflow 2026-11-07 11:27:52 +03:00
sanspie 6c1d19704e fix: satisfy release HTTP lint checks 2026-11-07 10:38:41 +03:00
sanspie 579751f54a Integrate pinned Asset Studio web release 2026-11-07 10:36:17 +03:00
sanspie 87e3181a2a fix: align art source frames and add workspace goldens 2026-11-07 09:25:36 +03:00
sanspie 199d39b347 docs: follow Go error message conventions 2026-11-07 08:26:54 +03:00
sanspie 43cbb6bb7f art: require consent before lossy web imports
Do not open an Aseprite document with omitted metadata until the browser user reviews and accepts diagnostics; exercise cancel and confirmation through the real UI.
2026-11-07 08:16:51 +03:00
sanspie 8cfb81d071 docs: verify PostProcess output Try
Exercise the real prepared Bloom scene, resolve its dependencies and check runtime and image Reset in native and Chromium.
2026-11-07 08:10:24 +03:00
sanspie 2687450c2d docs: publish Art Studio as a lazy site entry
Build the existing Go/WASM studio with version-bound browser resources and link it from English and Russian site navigation without loading it on docs pages.
2026-11-07 08:10:07 +03:00
sanspie 796fe5b52f scene: detach nested prefab override values
Clone nested lists, structs and curves in orphan properties and added components so repeated snapshot restores cannot alias live edits.
2026-11-07 08:09:44 +03:00
sanspie 4924bb12e8 docs: bind shared Try contracts only to components
Allow guide pages to reuse the same scene without claiming its behavioral sidecar; continue rejecting wrong or duplicate component contracts.
2026-11-07 08:03:41 +03:00
sanspie 54c1af7edf scene: clone frequent snapshot components directly
Detach mutable prefab, particle, occluder and edge data with typed copies; test repeated restore, snapshot reuse and mutable-field coverage.
2026-11-07 07:25:51 +03:00
sanspie 113892ab67 vm: skip slice traversal for string-only snapshots
Track live slice objects through allocation, collection and restore, while keeping independent heap snapshots and the existing slice-capacity semantics.
2026-11-07 07:22:55 +03:00
sanspie 5e55c2dbb9 docs: verify particle emission Try
Use a prewarmed emitter and observe the real live-particle count and Game View before and after Reset on native and Chromium.
2026-11-07 07:20:51 +03:00
sanspie c64c199e70 art: preserve transparent surface channel data
Keep authored normal, height and emission RGB when the composited pixel is transparent, without covering visible pixels. Check source round trips, partial updates, undo and prepared runner artifacts.
2026-11-07 07:06:12 +03:00
sanspie aa1da698f4 docs: verify MeshRenderer double-sided Try
Exercise back-face culling and Reset in native and Chromium without relaxing the full component inventory gate.
2026-11-07 07:02:04 +03:00
sanspie 25a731f6c4 docs: verify light and ambient Try contracts
Use deterministic light scenes and assert real visual changes and Reset across native and Chromium; overall component coverage remains incomplete.
2026-11-07 06:16:08 +03:00
sanspie 5a2745987f feat(art): import Aseprite through native editor
Create guarded editable sources with existing prepared frame and clip subassets; require confirmation for reported metadata loss and preserve the original file.
2026-11-07 06:07:41 +03:00
sanspie 8a364bee5e test(art): validate real Aseprite files against LibreSprite
Pin MIT-licensed upstream fixtures and independent reference frames; verify linked cels, undo, metadata round-trip, and explicit unsupported-data diagnostics.
2026-11-07 05:43:47 +03:00
sanspie 3d2cf44608 test: require actual null-DACL rejection on Windows
Protect the fixture from inherited ACEs and assert the rejection reason; fix the editor-tagged staticcheck error.
2026-11-07 05:34:15 +03:00
sanspie eb92b49531 test: create Windows null-DACL fixture without elevation
Keep the fail-closed assertion and fix Go vet's keyed color literals.
2026-11-07 05:30:38 +03:00
sanspie 09d10625c3 fix(examples): recognize Windows system volume owner
Trust only the exact Windows TrustedInstaller SID and add an ancestor DELETE_CHILD rejection test; native validation remains in progress.
2026-11-07 05:27:16 +03:00
sanspie 05aa019738 WIP: validate Windows remote store ACLs and reparse points
Fail closed for untrusted writes, null or unknown ACLs, and recheck managed trees. Native Windows validation is still required.
2026-11-07 05:23:49 +03:00
sanspie d965aeb886 fix: portable Stage paths and deduplicate Try sidecars
Allow only macOS system temp aliases while rejecting nested links, and preserve duplicate-contract detection across distinct Try sources.
2026-11-07 05:17:54 +03:00
sanspie 32f65c3ddc feat(examples): open checked remote copies in the launcher
Confirm the HTTPS origin, preserve entry scenes and offline edits, and disable external import tools in managed remote projects. Native Windows store support and full remote-example acceptance remain incomplete.
2026-11-07 05:06:50 +03:00
sanspie 8fd0483b42 art: add native ellipse and lasso selections 2026-11-07 04:36:18 +03:00
sanspie 029f6d7cb4 docs: add executable light occluder contract 2026-11-07 04:35:48 +03:00
sanspie 07a6fa1e55 art: add native eyedropper and preserve chosen color bytes 2026-11-07 04:23:45 +03:00
sanspie 6b52dd790b docs: run component contracts in native and browser runtimes
Verify real Game View changes and reset in both languages; add Camera2D and Camera3D demos. The strict gate remains intentionally failing until all public components are covered.
2026-11-07 04:22:46 +03:00
sanspie 3c015e9266 WIP: integrate Stage 12.5 art authoring and examples
Partial implementation; native studio, cross-platform examples, Try coverage, and acceptance remain incomplete.
2026-11-07 04:01:11 +03:00
449 changed files with 31839 additions and 4433 deletions
-5
View File
@@ -1,5 +0,0 @@
# 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
+1 -39
View File
@@ -1,41 +1,3 @@
# 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.
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.
+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: github.server_url != 'https://github.com' && vars.PLATFORM_RUNNERS == 'true'
if: vars.PLATFORM_RUNNERS == 'true'
strategy:
fail-fast: false
matrix:
+1 -5
View File
@@ -20,7 +20,6 @@ env:
jobs:
checks:
if: github.server_url != 'https://github.com'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
@@ -45,7 +44,6 @@ jobs:
run: make nsis-check
build:
if: github.server_url != 'https://github.com'
strategy:
fail-fast: false
matrix:
@@ -123,9 +121,7 @@ jobs:
release:
needs: [checks, build]
# Pause all automatic releases until the reviewed nightly/stable pipeline
# enforces native gates and preserves existing tags and release assets.
if: ${{ false }}
if: github.event_name == 'push'
runs-on: ubuntu-24.04
permissions:
contents: write
File diff suppressed because it is too large Load Diff
@@ -1,37 +0,0 @@
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
+4 -1
View File
@@ -47,6 +47,7 @@ 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
@@ -102,7 +103,8 @@ 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)AuthoringWorkflowNative|PlatformerVignetteGameView|PostFXEditPreviewWithoutPlay|CameraInspectorShowsRenderedPostStatus|PostFXAndParticlePresetThumbnails|ColdPostMaterialPreviewAndThumbnail|DiagnosticComposerConsoleProfilerNative)$$'
$(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) 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
@@ -383,6 +385,7 @@ 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
@@ -0,0 +1,97 @@
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,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"os"
"strings"
"aego/project"
@@ -14,6 +15,7 @@ type cliFlags struct {
json bool
quiet bool
strict bool
artOut string
}
func Usage(w io.Writer) {
@@ -101,6 +103,22 @@ 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 {
@@ -129,6 +147,9 @@ 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
@@ -151,3 +172,26 @@ 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
}
+6 -1
View File
@@ -48,6 +48,7 @@ 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.
@@ -73,6 +74,7 @@ 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()...)
@@ -82,7 +84,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.")) {
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.")) {
name := strings.ReplaceAll(c.Name, ".", " ")
c.Look = Look{Icon: "comp.sprite", Title: func(a Args) string { return name + " " + file(a) }, Summary: changes}
}
@@ -143,6 +145,9 @@ 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
@@ -0,0 +1,59 @@
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,6 +470,23 @@ 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. 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. 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.`
type request struct {
JSONRPC string `json:"jsonrpc"`
+17 -1
View File
@@ -3,6 +3,7 @@ package agent
import (
"bufio"
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -91,7 +92,7 @@ func proxyRun(addr, token string, c *Command, a Args) (*Output, error) {
var rep struct {
Result struct {
Content []struct {
Type, Text string
Type, Text, Data, MimeType string
} `json:"content"`
IsError bool `json:"isError"`
} `json:"result"`
@@ -122,6 +123,21 @@ 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":
+4 -6
View File
@@ -1,7 +1,6 @@
package bootstrap
import (
"fmt"
"os"
"path/filepath"
"testing"
@@ -99,8 +98,7 @@ func TestKeyTracksNativeSources(t *testing.T) {
func TestLocalReplaces(t *testing.T) {
root := t.TempDir()
absolute := filepath.VolumeName(root) + "/abs/c"
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf(`module game
writeFile(t, filepath.Join(root, "go.mod"), `module game
go 1.27
@@ -109,9 +107,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 => %s
example.com/c => /abs/c
)
`, absolute))
`)
got, err := LocalReplaces(root)
if err != nil {
t.Fatal(err)
@@ -119,7 +117,7 @@ replace (
want := map[string]bool{
filepath.Clean(filepath.Join(root, "../engine")): true,
filepath.Clean(filepath.Join(root, "vendor", "a")): true,
filepath.Clean(absolute): true,
filepath.Clean("/abs/c"): true,
}
if len(got) != len(want) {
t.Fatalf("replaces = %v", got)
+6
View File
@@ -45,6 +45,9 @@ 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
}
@@ -134,6 +137,9 @@ 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
}
+3 -13
View File
@@ -78,21 +78,11 @@ type Bundle struct {
func sanitizer() func(string) string {
home, _ := os.UserHomeDir()
return func(s string) string {
return hideHome(s, home)
}
}
func hideHome(s, home string) string {
if home == "" {
if home != "" {
s = strings.ReplaceAll(s, 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,7 +3,6 @@ package crash
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -11,9 +10,6 @@ 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 {
@@ -29,12 +25,3 @@ 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
@@ -0,0 +1,99 @@
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)
}
}
+42 -18
View File
@@ -3,7 +3,9 @@
package docsview
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
playmods "aego/app/assembly/playground"
@@ -15,26 +17,29 @@ 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 float64 `json:"value"`
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 json.RawMessage `json:"value"`
Fields []string `json:"fields"`
Dependencies []string `json:"dependencies"`
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"`
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"`
}
func (a *App) Call(method string, payload []byte) Reply {
@@ -77,7 +82,20 @@ func (a *App) Call(method string, payload []byte) Reply {
case "redo":
err = s.Redo()
case "set":
err = s.Set(req.Component, req.Field, req.Value)
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)
}
}
case "state":
st := s.State()
return Reply{State: &st}
@@ -92,6 +110,12 @@ 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
@@ -0,0 +1,52 @@
//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
@@ -0,0 +1,72 @@
//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
@@ -0,0 +1,84 @@
//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
@@ -0,0 +1,96 @@
//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")
}
}
@@ -0,0 +1,58 @@
//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. 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. 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. " +
"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.
+31 -2
View File
@@ -40,6 +40,7 @@ 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).
@@ -145,7 +146,14 @@ 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
out, err := agent.Run(b.sess, c.Name, dry)
// 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)
}()
if err != nil {
return nil, err
}
@@ -185,7 +193,10 @@ func (b *Bridge) apply(p *Proposal) (*agent.Output, error) {
defer func() { b.via = "" }()
var out *agent.Output
var err error
if p.Preview.Drawing != nil {
if p.Preview.Art != nil {
out, err = p.Preview.Art.Commit()
p.Preview.Art = nil
} else if p.Preview.Drawing != nil {
out, err = b.applyDrawing(p)
} else {
out, err = b.run(p.Command, p.Args)
@@ -211,6 +222,9 @@ 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
@@ -254,6 +268,8 @@ 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
@@ -270,6 +286,9 @@ 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()
@@ -311,6 +330,9 @@ 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
@@ -335,6 +357,13 @@ 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
+15 -2
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" {
t.Fatalf("screenshot paste changed text: %q", panel.Input())
if panel.Input() != "draft line[Image 1]" {
t.Fatalf("screenshot paste lost draft or marker: %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,6 +169,19 @@ 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)
+20 -8
View File
@@ -52,6 +52,9 @@ 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
}
@@ -132,6 +135,9 @@ 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
@@ -155,14 +161,15 @@ 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,
Logger: cfg.Logger,
Debug: cfg.Debug,
LoopScope: engine.ScopeFrame,
LocateBindings: true,
Docs: true,
Editor: true,
Catalog: cat,
Profile: cfg.Profile,
NoExternalImportTools: cfg.RemoteProject,
}, sdk.AdaptAll(mods)...)
if err != nil {
return nil, err
@@ -242,6 +249,11 @@ 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
}),
+30 -1
View File
@@ -16,6 +16,7 @@ import (
"aego/app/updates"
"aego/editor/core/session"
"aego/editor/shell/launcher"
"aego/engine/i18n"
"aego/platform"
"aego/project"
"aego/sdk"
@@ -153,11 +154,15 @@ func Start(e Entry) error {
dir, file := resolveArg(e.Arg)
if e.Launcher != "" || (dir == "" && !hasProject(".")) {
cfg.LauncherTab = e.Launcher
root, err := Launch(cfg)
root, entry, remote, err := launchWithEntry(cfg)
if err != nil || root == "" {
return err
}
dir, file = root, ""
if remote {
cfg.OpenPath = entry
cfg.RemoteProject = true
}
}
if dir == "" {
dir = "."
@@ -166,10 +171,34 @@ 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":
case ".aemat", ".aeshader", ".glsl", ".vert", ".frag", ".meta", ".aepreset", ".aeart":
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"} {
for _, path := range []string{"assets/platform_height.png.meta", "assets/effects/Dust.aepreset", "assets/hero.aeart"} {
t.Run(path, func(t *testing.T) {
x := &exporter{p: p, pl: pl, dirtySources: func() []string { return []string{path} }}
snapshot := x.Prepare().(*gameexport.Snapshot)
+17 -4
View File
@@ -242,6 +242,7 @@ 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 },
@@ -285,24 +286,36 @@ 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 "", err
return "", "", false, 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 "", err
return "", "", false, err
}
ls.Close()
root, _ := ls.UI.Result()
return root, nil
root, _ = ls.UI.Result()
entry, remote = ls.UI.ResultEntry()
return root, entry, remote, 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
@@ -1,84 +0,0 @@
//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