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
362 changed files with 31742 additions and 495 deletions
+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":
+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
}
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
+55
View File
@@ -0,0 +1,55 @@
package export
import (
"testing"
"aego/asset"
"aego/core/ids"
"aego/gpu"
"aego/vfs"
"aego/vfs/memfs"
)
// Export must validate the same source frame that runtime renders, rather than
// compare its 4x4 normal with the dimensions of the entire 8x4 sheet.
func TestExportSourceFirstFrameNormalAssociation(t *testing.T) {
for _, tc := range []struct {
name string
normalW int
semantic asset.TextureSemantic
wantBlocks bool
}{
{"matching", 4, asset.TextureNormal, false},
{"wrong-size", 3, asset.TextureNormal, true},
{"wrong-semantic", 4, asset.TextureColor, true},
} {
t.Run(tc.name, func(t *testing.T) {
root, normal, normalPage := ids.NewAssetID(), ids.NewAssetID(), ids.NewAssetID()
texture := func(semantic asset.TextureSemantic) []byte {
return asset.EncodeTexture(&asset.TextureArtifact{Width: 8, Height: 4, Format: gpu.TexRGBA8, Semantic: semantic, Pixels: make([]byte, 8*4*4)})
}
files := map[string][]byte{
"sheet": texture(asset.TextureColor),
"source-frame": asset.EncodeRegion(&asset.RegionArtifact{Page: root, W: 4, H: 4, PageW: 8, PageH: 4, Normal: normal}),
"normal-sheet": texture(tc.semantic),
"normal-frame": asset.EncodeRegion(&asset.RegionArtifact{Page: normalPage, W: tc.normalW, H: 4, PageW: 8, PageH: 4}),
}
fs, err := vfs.New(vfs.Mount{Scheme: "cache", Source: memfs.New(files)})
if err != nil {
t.Fatal(err)
}
idx := asset.NewIndex()
idx.Put(asset.Entry{ID: root, Kind: asset.ArtTexture, Artifacts: []asset.Artifact{{Kind: asset.ArtTexture, Path: "cache://sheet"}, {Kind: asset.ArtSpriteRegion, Path: "cache://source-frame"}}})
idx.Put(asset.Entry{ID: normalPage, Kind: asset.ArtTexture, Artifacts: []asset.Artifact{{Kind: asset.ArtTexture, Path: "cache://normal-sheet"}}})
idx.Put(asset.Entry{ID: normal, Kind: asset.ArtSpriteRegion, Artifacts: []asset.Artifact{{Kind: asset.ArtSpriteRegion, Path: "cache://normal-frame"}}})
snap := &Snapshot{idx: idx, files: fs}
if got, ok := (&resolver{s: snap}).textureInfo(root); !ok || !got.region || got.w != 4 || got.h != 4 {
t.Fatalf("export source metadata differs from its first frame: %+v/%v", got, ok)
}
problems := checkNormalAssociations(snap)
if (Blocking(problems) != 0) != tc.wantBlocks {
t.Fatalf("normal validation: got %+v; want blocking=%v", problems, tc.wantBlocks)
}
})
}
}
+6 -41
View File
@@ -27,49 +27,14 @@ func (r *resolver) textureInfo(id ids.AssetID) (preparedTexture, bool) {
}
return preparedTexture{w: tex.Width, h: tex.Height, semantic: semantic}, true
}
e, ok := r.s.idx.Lookup(id)
if !ok || e.Missing || e.Error != "" || r.s.files == nil {
if r.s.files == nil {
return preparedTexture{}, false
}
page := e
var region *asset.RegionArtifact
if e.Kind == asset.ArtSpriteRegion {
data, ok := r.blob(id, e.Kind)
if !ok {
return preparedTexture{}, false
}
var err error
region, err = asset.DecodeRegion(data)
if err != nil || region.Page == id {
return preparedTexture{}, false
}
page, ok = r.s.idx.Lookup(region.Page)
if !ok || page.Missing || page.Error != "" {
return preparedTexture{}, false
}
}
if page.Kind != asset.ArtTexture && page.Kind != asset.ArtAtlasPage {
return preparedTexture{}, false
}
data, ok := r.blob(page.ID, page.Kind)
if !ok {
return preparedTexture{}, false
}
tex, err := asset.DecodeTexture(data)
if err != nil {
return preparedTexture{}, false
}
info := preparedTexture{semantic: tex.Semantic, w: tex.Width, h: tex.Height}
if region != nil {
if region.PageW != tex.Width || region.PageH != tex.Height {
return preparedTexture{}, false
}
info.region, info.w, info.h = true, region.W, region.H
if region.SourceW != 0 {
info.w, info.h = region.SourceW, region.SourceH
}
}
return info, true
info, ok := asset.ReadTextureMetadata(id, func(id ids.AssetID) (*asset.Entry, bool) {
e, ok := r.s.idx.Lookup(id)
return e, ok && !e.Missing && e.Error == ""
}, func(a asset.Artifact) ([]byte, error) { return r.s.files.ReadFile(a.Path) })
return preparedTexture{semantic: info.Semantic, region: info.Region, w: info.Width, h: info.Height}, ok
}
func (r *resolver) TextureMetadata(id ids.AssetID) (asset.TextureMetadata, bool) {
+18
View File
@@ -4,7 +4,9 @@ import (
"aego/asset/importers/preset"
"sort"
"aego/asset/importers/artsource"
"aego/asset/importers/atlas"
audioImporter "aego/asset/importers/audio"
matImporter "aego/asset/importers/material"
"aego/asset/importers/scenefile"
scriptImporter "aego/asset/importers/script"
@@ -21,6 +23,7 @@ func CoreImporters() (*pipeline.ImporterRegistry, error) {
reg := pipeline.NewImporterRegistry()
for _, imp := range []pipeline.Importer{
atlas.Wrap(texImporter.New()),
artsource.New(),
scenefile.NewScene(),
scenefile.NewPrefab(),
preset.New(),
@@ -53,6 +56,21 @@ func DevImporters(ws *workspace.Workspace, man *module.Manifest) (*pipeline.Impo
return imps, nil
}
// disableExternalImportTools affects the existing importers for the entire
// editor session, including later reimports, not only the initial scan.
func disableExternalImportTools(imps *pipeline.ImporterRegistry) {
if imp, ok := imps.ByID("shader"); ok {
if shader, ok := imp.(*shaderImporter.Importer); ok {
shader.DisableExternal = true
}
}
if imp, ok := imps.ByID("audio"); ok {
if audio, ok := imp.(*audioImporter.Importer); ok {
audio.DisableExternal = true
}
}
}
func ScriptErrors(pl *pipeline.Pipeline) []string {
seen := map[string]bool{}
var out []string
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+753
View File
File diff suppressed because it is too large Load Diff
+602
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+103
View File
File diff suppressed because it is too large Load Diff
+106
View File
File diff suppressed because it is too large Load Diff

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