Author SHA1 Message Date
sanspie 208875264f Stage 9.5c3: docs runtime polish
ci-platforms / build (amd64, darwin, macos-15-intel) (push) Skipped
ci-platforms / build (amd64, windows, windows-2022) (push) Skipped
ci-platforms / build (arm64, darwin, macos-15) (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 12m43s
ci / checks (push) Successful in 16m57s
2026-10-20 02:33:48 +03:00
sanspie 8c21400bb1 Stage 9.5c2: docs session, Try it, sandbox (D293–D294) 2026-10-20 01:31:40 +03:00
sanspie e83521a34a Stage 9.5c1: web export (D295) 2026-10-20 00:59:39 +03:00
65 changed files with 3379 additions and 154 deletions
+9 -5
View File
@@ -506,6 +506,9 @@ The Stage 8 specification numbered these decisions D153, D155, D158, D159, D168
| D290 | WebGL2 backend. `gpu/webgl2` implements `gpu.Device` on `WebGL2RenderingContext`, reached through `webgl2.Surface` on the window. `engine/backends_js.go` selects it, `engine/backends_native.go` selects gl33; a window with neither gets the null backend. GL enums are numeric constants; JS objects live in a table indexed by `uint32`, so `gpu/internal/glstate` (moved from gl33) caches state for both backends. Context methods are bound once; every call goes through one counter (`Device.Calls()` = calls of the last frame). WebGL2 has no sRGB default framebuffer, so the window pass renders into an SRGB8_ALPHA8 texture with a depth-stencil renderbuffer and `EndFrame` draws it to the canvas with a fullscreen triangle that encodes linear to sRGB; orientation follows D17 (the backbuffer is the window: viewport and scissor flipped, offscreen targets GL-native). Shaders: the renderer loads `engine://shaders/es/` when `Capabilities.ESSL` is set; `render/shaderx` translates the GLSL 330 sources at build time (`make shaders`, part of `make res`), a test keeps the committed copies current and validates them with glslangValidator when installed; there is no translator at run time. Timer queries use `EXT_disjoint_timer_query_webgl2` when present (a disjoint result reads as 0), otherwise the GPU lane stays empty. Context loss is handled inside the device: it keeps descriptors, shader sources and CPU copies of static buffers and textures with data, and on `webglcontextrestored` recreates every object under the same table id, so handles held above stay valid and nothing above the device reloads; render-target contents and dynamic buffers are refilled by the next frame. Uploads copy through one growing `Uint8Array`. The default build never compiles `syscall/js`: depcheck requires a js-only build constraint or a `_js` file name on every importer. |
| D291 | Playground runtime. One page holds the code editor, a Game View (`canvas[data-aego]`) and Console/Problems. The catalog `app/assembly/playground` is core, input, render2d, scripting, physics and debug (physics joins the playground graph, superseding D205 on that point; `render3d` stays out). Console examples (docs snippets, the donut) run headless as before. Scene examples come from `app/playground/examples`, a regular project (module `game`) embedded in the binary and checked by `aego check` in `make check-all`: its `project.aego` gives the project sections once at start, and each `scenes/<name>.aescene` with `scripts/<name>/` is one example. Run compiles the edited package in the same workspace, links a fresh script runtime and loads the scene through `sceneio` with a loader over an empty index, so only builtin assets resolve. One engine per page is built on the first Run on `platform/web` and `gpu/webgl2` and stays on `requestAnimationFrame`; Run and Stop swap the scene, the runtime and the module attachment of the main world. The page polls `poll` every 250 ms for log lines, faults and frame statistics (frame time, draw calls, WebGL calls of the last frame). The game owns the keyboard and pads while the canvas has focus, Escape included; a click elsewhere releases it. `bench` draws N sprites straight through the renderer for measurements and the screenshot golden, `loseContext` drives `WEBGL_lose_context`. Budgets are targets: `aego.wasm` ≤ 8 MB gzip (the site build fails above it), cold start ≤ 2 s on a laptop. `make playground-size` reports code size per package from the wasm name section (`tools/wasmsize`). `make web-smoke` runs Playwright with the system Chromium on SwiftShader: start-up time, a golden screenshot of `bench` 64, the same picture after a context loss, every scene example for 2 s without faults, and the 1000-sprite bench written to `.cache/web-bench.json`. |
| D292 | Documentation IA. One registry feeds the site, the editor's docs panel and `aego docs export` (version 2, with groups). Sections and groups: Getting started; Guides (Editor, Aego Script, Scenes & Prefabs, Gameplay, Physics 2D, Rendering, Extending Aego, Tools & Agents); Reference (Script API, Components, Project settings, Diagnostics, CLI, Project). A root Markdown page declares `group:` (a guide group) and `order:`, children inherit the group of their root. The kernel lists only the module-independent groups; a module adds its own guide group from its root page with `group_title:` and `group_order:` (Physics 2D comes from `modules/physics`, so `engine/docs` never names a module and needs no literal waiver under D212). Reference groups are filled from the registry: script modules are roots of Script API ordered by the owning module's `DocsOrder`; components hang under one page per module, created only for modules with components and titled `module.Info.DisplayName` (the core module is "Core"); diagnostic codes under Diagnostics; documented project scripts under Project. Entry IDs are unchanged, so `ref:` links keep working. `aego docs check` fails on a root page without a guide group or an order and on a built-in module without a DisplayName. `module.DocSource.Dir`, taken from the module's package path (`Unwrap` sees through catalog wrappers), gives every page its repository path for "Edit this page"; `aego docs build` points those links at the checked-out branch (`master` when git is unavailable). gendocs renders sections with collapsible groups whose open state the browser remembers, the current page highlighted with its group and ancestors open, breadcrumbs (section, group, ancestors), an on-this-page list from the h2/h3 anchors, previous/next in navigation order, search grouped by section, a footer with the engine version, the commit and links to the source, the playground and the page's file, a landing page with three entry points and `docs/hero.png` when present, and `llms.txt`. A text outline of the navigation is the golden of the gendocs tests, for a fixture and for the real export. The playground and the site share one set of colour tokens. Amended in 9.5b3b: component pages come from the schema: a header with category, module, space, scene uniqueness, required components and the Create path; a field table with type, the default read from the registered default, range and unit, notes (editor or script visibility, read-only, Play only, saved, live, choice and bitset sources, enum values, gizmo) and the field doc; component methods (`manifest.CompMethods`) and signals as members (a method that shares its name with a field gets the ID and title `Name()`); the script events of the owning module, listed under Script API → Script events with owners from `module.Manifest.EventOwners`; related guides (pages linking to the component or its fields, and root guides of the same module); an empty Try it slot. Project settings has one page per project section with its fields and default block (`Section.DefaultValue`, `Section.DefaultBlock`; a block that encodes to nothing is left out, and the section page lists the owning module's guides as related) and a Player settings page built from the settings registry. CLI is the authored page `cli.md` in group `ref-cli`, the only reference group a page may declare; a test keeps it listing every command of `cmd/aego`. A script module `aego/<id>` belongs to module `<id>` when no owner is registered. The editor's docs panel shows the same sections and groups. |
| D293 | Docs session. `editor/core/docsession.Session` serves the real Inspector and Game View without the editor shell: one scene from bytes, its own `document.Manager` holding the edited document (the Inspector's `services.Documents`), a second manager with a document over the same scene for `play.Controller`, which swaps in its play proxy during Play, so the Inspector always edits authored values; a `selection.Service` keyed by that document; a minimal `context.Editor` (engine, renderer, loader, types, commands, problems, an in-memory clipboard) and `Unavailable` for asset and scene operations. History works as in the editor; a committed change saves the scene to text (`OnChange`) and restarts Play, keeping a pause; Reset reloads the original bytes. `app/docsview` hosts it: the sandbox catalog, `LoopScope` editor, one canvas with a toolbar (Play/Pause, Reset, Undo, Redo), Game View and Inspector side by side (stacked below 640 px), a script host that links a sandboxed runtime and attaches the modules to the play world, and a JSON bridge (`load`, `reset`, `set`, `undo`, `redo`, `state`, `log`). `cmd/aego-try` builds it as `try/aego-docs.wasm` (`js && aego_editor`). The graph excludes SDL, gl33, the shell, every panel but the two, and the script importer; `asset/pipeline` stays because `editor/core/context`, `assetops` and the preset importer import it, and the Inspector gets no pipeline. Module editor extensions (custom inspectors) are not loaded. |
| D294 | Try it and the sandbox. A module's `res/docs/try/<Name>.aescene` becomes the try scene of the component whose ID ends in `.<Name>` (preferring the module's own), a guide page can name one with `try:` and `try_select:`; the registry carries `Try` and `TrySelect` and the site renders a Try it block when the playground is built: the scene text, Copy scene, Reset, and a button that lazily loads the one docs runtime per site, moves its single canvas into the block and loads the scene there, selecting the documented component; Inspector edits rewrite the shown text. `aego docs check` runs every try scene on the sandbox catalog for 60 headless frames and fails on a load error, a warning, a fault or a scene from a module outside the sandbox. Sandbox (playground and docs runtime): the catalog is the allowlist of host modules (core, input, render2d, scripting, physics, debug; no save, settings, spawn or audio), user files are in memory or localStorage only, there is no file or network access; scripts run with 2M instructions per call and a 64 MB VM heap (`vm.Options.MaxHeap`, checked in the allocator while the machine runs and reported as `CodeBudget`, garbage included until the next collection); a scene above 10 000 nodes stops the game with an error. `aego-docs.wasm` is held to 10 MB gzip by `make site` (`make try-size` reports it per package). Try scenes with scripts and the playground's Inspect toggle come later. |
| D295 | Web export. `aego export --target web` (`js/wasm`, shown as `web`) builds `cmd/aego-runtime` with `GOOS=js GOARCH=wasm CGO_ENABLED=0` and the runtime tag, release with `-trimpath -s -w`, and packs `index.html`, `loader.js` (both embedded in `app/export/web`), the toolchain's `wasm_exec.js` with its license, `game.wasm` and `data/` with the 9.5a manifest and artifacts plus `files.json`. The page script loads the data, not Go: it fetches `files.json`, then the wasm, the manifest and the artifacts six at a time with one progress bar over the uncompressed sizes, and hands the bytes to the runtime in `globalThis.aegoData`; `runtime/player.Main` under js mounts them with `vfs/memfs` after checking every manifest entry and size, as `exportfs` does on desktop. Under js the player has no log or crash files and no signal handling (`hostFiles`); the profile and saves go through `platform.UserFiles()` (localStorage). URL parameters replace the command-line flags; `globalThis.aegoGame` exposes the frame count and profile file access for tests. Files that compress by more than 10% get `.gz` (and `.br` when `brotli` exists); `export.aetext` records `binaryGzip`. Web-specific checks: EXP010 (asset kind the web runtime cannot load, none yet), EXP011 (native module must build without cgo). `make site` exports the platformer to `site/demo/`, the landing page links it (`aego docs build --demo`), and fails when its `game.wasm` is over 8 MB gzip; `make web-smoke` runs it for 120 frames against a screenshot golden and checks that a profile file survives a reload. `make test-wasm` builds the web runtime and rejects native, pipeline and editor packages in its graph. |
## 4. Dependency rules
@@ -605,7 +608,7 @@ aego/
├── core/ handle mathx clock log errs stats ids rng strs bits (1–3) rewind (9.4d)
├── platform/ platform.go event.go ext.go input/ sdl/ headless/ (1) web/ (9.5b) android/ (14)
├── gpu/ device.go gpu.go gl33/ (gl/ generated) internal/glstate webgl2/ (9.5b) null/ (1) gles30/ shaderc/ (12–14)
├── vfs/ vfs.go osfs/ (1) exportfs/ (9.5a) pakfs/ assetfs/ (13–14)
├── vfs/ vfs.go osfs/ (1) exportfs/ (9.5a) memfs/ (9.5c) pakfs/ assetfs/ (13–14)
├── engine/ engine registry world service config loop jobs app (1)
│ res/ (2, engine://) module/ (6.5) base/ problems/ (8C) settings/ settings/settingsui/ (9.3)
├── render/ render pass viewframe pass3d viewframe3d view/ texture/ sprite/ culling/ debug/ text/
@@ -638,16 +641,16 @@ aego/
│ └── physics/ world/ components/ systems/ stdlib/ settings/ editor/ res/ module.mk (8A, 8B, 8C)
├── editor/
│ ├── core/ command context services document history props selection shape state session workspace
│ code clipboard templates gizmo vectors play fieldgizmo assetops assetfiles sceneops prefabs fuzzy (4–8D) dataedit (9.4c)
│ code clipboard templates gizmo vectors play fieldgizmo assetops assetfiles sceneops prefabs fuzzy (4–8D) dataedit (9.4c) docsession (9.5c)
│ ├── ext/ extension points for module editors and sdk; inspect/ (4, 8C)
│ ├── panels/ assets console docs gameview help hierarchy inspector problems profiler
│ projectsettings sceneview scripteditor search welcome (4–8C) datatable (9.4c) saves (9.4d)
│ └── shell/ editor application, menus, modals, panel catalog; launcher/ (4, 6.5, 8C)
├── app/ build.go importers.go run/ edit/ supervise/ bootstrap/ (3–4, 6.5 build; dev-only)
│ assembly/{builtin,playground,editors}/ module catalog and profiles (8C)
│ export/{icon,winres,macos}/ (9.5a)
│ assembly/{builtin,playground,editors}/ module catalog and profiles (8C) docsview/ (9.5c)
│ export/{icon,winres,macos,web}/ (9.5a, web 9.5c)
├── runtime/ bundle/ (manifest formats) player/ (exported game host) (9.5a)
├── cmd/ sandbox/ aego/ aego-editor/ (1, 3, 4) aego-runtime/ (9.5a)
├── cmd/ sandbox/ aego/ aego-editor/ (1, 3, 4) aego-runtime/ (9.5a) aego-try/ (9.5c)
├── audio/ (9) anim/ (11) net/ store/ (13) templates/ (13)
├── examples/ native-rotator native-editor platformer physics-sandbox scriptreload (3–5, 8B; sdk-only)
├── demos/ basic input resize headless sprites camera layers text offscreen stress
@@ -946,6 +949,7 @@ Packages are named by capability (`render/view`, `asset/loader`, `scene/sceneio`
| 9.4b–12 | Audio, pixel/sprite tools, animation, materials/shaders/lighting. Optional: in-context prefab editing — prefab mode draws the origin scene dimmed around the prefab, read-only, with the prefab subtree placed at the instance it was opened from. |
| 9.5a | Export v1: `aego export`, `runtime/player`, `runtime/bundle`, `vfs/exportfs`, Windows resources, macOS app bundles, pre-export checks, Build Report, editor Export panel, native macOS menu bar, `make editor-app` |
| 9.5b | Web runtime: `platform/web`, `gpu/webgl2`, ES shaders and `render/shaderx`, `platform.Driver`, `platform.UserFS`, playground with Game View, docs IA |
| 9.5c | Web export, docs session with Inspector and Game View, Try it, playground sandbox |
| 13+ | Export/pak, networking/store, Android |
---
+30 -3
View File
@@ -20,6 +20,14 @@ EXPORT_HOME = $(CURDIR)/.cache/export-home
CHROMIUM ?= $(shell command -v chromium || command -v chromium-browser || command -v google-chrome)
WEB_SMOKE = cd tools/websmoke && npm ci --no-audit --no-fund && CHROMIUM=$(CHROMIUM) node smoke.mjs --site=$(CURDIR)/site
WASM_BUDGET = 8388608
WEB_DEMO_PROJECT ?= examples/platformer
WEB_DEMO = cd tools/websmoke && CHROMIUM=$(CHROMIUM) node demo.mjs --site=$(CURDIR)/site
WEB_DEMO_BUDGET = @size=$$(stat -c %s site/demo/game.wasm.gz); echo "demo game.wasm.gz: $$size bytes"; \
if [ $$size -gt $(WASM_BUDGET) ]; then echo "the web demo is over the 8 MB gzip budget"; exit 1; fi
TRY_BUDGET = 10485760
WEB_TRY = cd tools/websmoke && CHROMIUM=$(CHROMIUM) node try.mjs --site=$(CURDIR)/site
TRY_SIZE_CHECK = gzip -9 -k -f site/try/aego-docs.wasm; size=$$(stat -c %s site/try/aego-docs.wasm.gz); echo "aego-docs.wasm.gz: $$size bytes"; \
if [ $$size -gt $(TRY_BUDGET) ]; then echo "the docs runtime is over the 10 MB gzip budget, see make try-size"; exit 1; fi
# Module targets, appended by modules/*/module.mk.
MODULE_CI :=
MODULE_BENCH_ALLOCS :=
@@ -30,7 +38,7 @@ BENCH_PHYSICS_PKGS :=
.DEFAULT_GOAL := bootstrap
-include modules/*/module.mk
.PHONY: 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
.PHONY: 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 try-size
XVFB := $(shell command -v xvfb-run 2>/dev/null)
GL_OVERRIDE = MESA_GL_VERSION_OVERRIDE=3.3 MESA_GLSL_VERSION_OVERRIDE=330
@@ -70,6 +78,10 @@ test-runtime: bootstrap
# Engine on gpu/null under Node, plus the js packages compile (D289).
test-wasm:
GOOS=js GOARCH=wasm go build ./cmd/aego-playground ./platform/web ./gpu/webgl2 ./engine
GOOS=js GOARCH=wasm go build -tags aego_runtime -o /dev/null ./cmd/aego-runtime
@if GOOS=js GOARCH=wasm go list -tags aego_runtime -deps ./cmd/aego-runtime | grep -E '^aego/(platform/sdl|gpu/gl33|asset/pipeline|asset/importers|editor)'; then echo "the web runtime pulls a native, pipeline or editor package"; exit 1; fi
GOOS=js GOARCH=wasm go build -tags aego_editor -o /dev/null ./cmd/aego-try
@if GOOS=js GOARCH=wasm go list -tags aego_editor -deps ./cmd/aego-try | grep -E '^aego/(platform/sdl|gpu/gl33|editor/shell|editor/panels/|asset/importers/script)' | grep -vE '^aego/editor/panels/(inspector|gameview)$$'; then echo "the docs runtime pulls a native, shell or extra panel package"; exit 1; fi
GOOS=js GOARCH=wasm go vet ./platform/web ./gpu/webgl2
GOOS=js GOARCH=wasm go test -exec=$(WASM_EXEC) $(WASM_TEST_PKGS)
@@ -190,10 +202,13 @@ bench-profile:
go tool pprof -top -nodecount 40 -diff_base $(BENCH_DIR)/base.prof $(BENCH_DIR)/head.prof
site: cli
.cache/bin/aego docs build --out site --repo $(DOCS_REPO)
.cache/bin/aego docs build --out site --repo $(DOCS_REPO) --demo
.cache/bin/aego export $(WEB_DEMO_PROJECT) --target web --release --out site/demo
gzip -9 -k -f site/play/aego.wasm
@size=$$(stat -c %s site/play/aego.wasm.gz); echo "aego.wasm.gz: $$size bytes"; \
if [ $$size -gt $(WASM_BUDGET) ]; then echo "playground is over the 8 MB gzip budget, see make playground-size"; exit 1; fi
$(WEB_DEMO_BUDGET)
@$(TRY_SIZE_CHECK)
playground:
@if GOOS=js GOARCH=wasm go list -deps ./cmd/aego-playground | grep -E '^aego/(platform/sdl|gpu/gl33|asset/pipeline|asset/importers|modules/render3d)'; then echo "playground graph pulls a native or pipeline package"; exit 1; fi
@@ -201,10 +216,13 @@ playground:
site-headless:
CGO_ENABLED=0 go build -tags aego_nonative -o .cache/bin/aego-docs ./cmd/aego
.cache/bin/aego-docs docs build --out site --engine $(CURDIR) --repo $(DOCS_REPO)
.cache/bin/aego-docs docs build --out site --engine $(CURDIR) --repo $(DOCS_REPO) --demo
.cache/bin/aego-docs export $(WEB_DEMO_PROJECT) --target web --release --engine $(CURDIR) --out site/demo
gzip -9 -k -f site/play/aego.wasm
@size=$$(stat -c %s site/play/aego.wasm.gz); echo "aego.wasm.gz: $$size bytes"; \
if [ $$size -gt $(WASM_BUDGET) ]; then echo "playground is over the 8 MB gzip budget, see make playground-size"; exit 1; fi
$(WEB_DEMO_BUDGET)
@$(TRY_SIZE_CHECK)
site-check: cli
.cache/bin/aego docs check
@@ -217,11 +235,20 @@ playground-size:
go run ./tools/wasmsize -top 40 .cache/aego-playground.wasm
go run ./tools/wasmsize -top 15 -depth 2 .cache/aego-playground.wasm
try-size:
mkdir -p .cache
GOOS=js GOARCH=wasm go build -tags aego_editor -trimpath -o .cache/aego-try.wasm ./cmd/aego-try
go run ./tools/wasmsize -top 40 .cache/aego-try.wasm
go run ./tools/wasmsize -top 15 -depth 2 .cache/aego-try.wasm
web-smoke: site
$(WEB_SMOKE) --report=$(CURDIR)/.cache/web-bench.json
$(WEB_DEMO)
$(WEB_TRY)
web-smoke-update: site
$(WEB_SMOKE) --update
$(WEB_DEMO) --update
cli: bootstrap
go build -o .cache/bin/aego ./cmd/aego
+11
View File
@@ -10,6 +10,7 @@ import (
"aego/modules/render2d"
"aego/modules/scripting"
"aego/script/stdlib"
"aego/script/vm"
)
func Catalog() assembly.Catalog {
@@ -24,3 +25,13 @@ func Catalog() assembly.Catalog {
{ID: debug.ID, New: func(assembly.Options) module.Module { return debug.New() }},
}
}
const (
SandboxBudget = 2_000_000
SandboxHeap = 64 << 20
SandboxNodes = 10_000
)
func SandboxVM() vm.Options {
return vm.Options{Budget: SandboxBudget, MaxHeap: SandboxHeap}
}
+79 -24
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
package docstool
import (
"fmt"
"io"
"strings"
"aego/app/playground"
"aego/engine/docs"
)
const tryFrames = 60
func CheckTry(reg *docs.Registry, w io.Writer) int {
pg, err := playground.New()
if err != nil {
fmt.Fprintln(w, "try: cannot start the docs runtime:", err)
return 1
}
pg.SetGame(playground.GameConfig{Headless: true, FrameTime: 1.0 / 60})
defer func() {
if g := pg.Game(); g != nil {
g.Close()
}
}()
sandbox := map[string]bool{}
for _, e := range pg.TryScenes() {
sandbox[e.ID] = true
}
bad := 0
for _, e := range reg.All() {
if e.Try == "" {
continue
}
var msgs []string
add := func(msg string) {
for _, m := range msgs {
if m == msg || strings.Contains(m, msg) || strings.Contains(msg, m) {
return
}
}
msgs = append(msgs, msg)
}
switch res := pg.PlayScene(e.ID, []byte(e.Try)); {
case !sandbox[e.ID]:
add("belongs to a module the docs runtime does not include")
case !res.Started:
add("does not load: " + res.Error)
default:
if err := pg.Game().Step(tryFrames); err != nil {
add("stops the engine: " + err.Error())
}
}
poll := pg.Poll()
for _, l := range poll.Log {
if l.Level != "INFO" && l.Level != "DEBUG" {
add(l.Text)
}
}
for _, f := range poll.Faults {
add(f.Code + " " + f.Message)
}
for _, m := range msgs {
fmt.Fprintf(w, "%s: error: try scene %s\n", e.ID, m)
}
bad += len(msgs)
}
pg.Stop()
return bad
}
+398
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
//go:build aego_editor
package docsview
import (
"encoding/json"
"strconv"
"aego/editor/core/docsession"
)
type request struct {
Scene string `json:"scene"`
Select string `json:"select"`
Component string `json:"component"`
Field string `json:"field"`
Value float64 `json:"value"`
}
type Reply struct {
Text string `json:"text,omitempty"`
Error string `json:"error,omitempty"`
State *docsession.State `json:"state,omitempty"`
Log []Line `json:"log,omitempty"`
}
func (a *App) Call(method string, payload []byte) Reply {
var req request
if len(payload) > 0 {
if err := json.Unmarshal(payload, &req); err != nil {
return Reply{Error: err.Error()}
}
}
s := a.session
var err error
switch method {
case "load":
err = s.Load([]byte(req.Scene), req.Select)
case "reset":
err = s.Reset()
case "text":
case "play":
s.TogglePause()
case "undo":
err = s.Undo()
case "redo":
err = s.Redo()
case "set":
err = s.Set(req.Component, req.Field, req.Value)
case "state":
st := s.State()
return Reply{State: &st}
case "log":
out := Reply{Log: a.lines}
a.lines = nil
return out
default:
return Reply{Error: "unknown method " + strconv.Quote(method)}
}
if err != nil {
return Reply{Error: err.Error()}
}
st := s.State()
return Reply{Text: string(s.Text()), State: &st}
}
func (a *App) Reply(method, payload string) string {
out, err := json.Marshal(a.Call(method, []byte(payload)))
if err != nil {
return `{"error":"cannot encode the reply"}`
}
return string(out)
}
+70
View File
@@ -0,0 +1,70 @@
//go:build aego_editor
package docsview
import (
playmods "aego/app/assembly/playground"
"aego/core/log"
"aego/editor/core/context"
"aego/engine"
"aego/engine/module"
"aego/scene"
"aego/script/bind"
"aego/script/bytecode"
scriptruntime "aego/script/runtime"
)
type scriptHost struct {
reg *bind.Registry
types *scene.Types
man *module.Manifest
log *log.Logger
mods []*bytecode.Module
}
func (h *scriptHost) Errors() int {
return 0
}
func (h *scriptHost) Attach(w *engine.World, s *scene.Scene) (func(), error) {
rt := scriptruntime.New(scriptruntime.Options{Bindings: h.reg, Schema: h.types.Schema(), Log: h.log, VM: playmods.SandboxVM()})
if err := rt.Link(h.mods); err != nil {
rt.Close()
return nil, err
}
w.SetService(scriptruntime.ServiceID, rt)
if err := rt.Instantiate(s); err != nil {
w.RemoveService(scriptruntime.ServiceID)
rt.Close()
return nil, err
}
detach, err := h.man.Attach(&module.AttachContext{World: w, Scene: s, Log: h.log, Editor: true, Play: true})
if err != nil {
w.RemoveService(scriptruntime.ServiceID)
rt.Close()
return nil, err
}
return func() {
detach()
w.RemoveService(scriptruntime.ServiceID)
rt.Close()
}, nil
}
func (h *scriptHost) Problems() ([]context.ScriptProblem, uint64) {
return nil, 0
}
func (h *scriptHost) Stats() (context.ScriptStats, bool) {
return context.ScriptStats{}, false
}
func (h *scriptHost) Status() (uint64, bool) {
return 0, false
}
func (h *scriptHost) Watch(*engine.World, scene.NodeHandle, string, string, int) (string, float64, bool, bool) {
return "", 0, false, false
}
var _ context.ScriptHost = (*scriptHost)(nil)
+17 -2
View File
@@ -67,6 +67,17 @@ func (j *job) toolchain() error {
if _, err := exec.LookPath("go"); err != nil {
return errs.New(errs.BuildFailed, "export needs the Go toolchain on this machine; the exported game does not, see docs/export.md")
}
if j.t.Web() {
root, err := goRoot()
if err != nil {
return err
}
if _, err := wasmExecPath(root); err != nil {
return err
}
j.goroot = root
return nil
}
cc := j.cc()
if !j.t.Host() && cc == "" {
return errs.New(errs.BuildFailed, "exporting to "+j.t.String()+" from "+runtime.GOOS+"/"+runtime.GOARCH+
@@ -164,8 +175,12 @@ func (j *job) build() error {
args = append(args, pkg)
cmd := exec.Command("go", args...)
cmd.Dir = root
cmd.Env = append(os.Environ(), "GOOS="+j.t.OS, "GOARCH="+j.t.Arch, "CGO_ENABLED=1")
if cc := j.cc(); cc != "" {
cgo := "CGO_ENABLED=1"
if j.t.Web() {
cgo = "CGO_ENABLED=0"
}
cmd.Env = append(os.Environ(), "GOOS="+j.t.OS, "GOARCH="+j.t.Arch, cgo)
if cc := j.cc(); cc != "" && !j.t.Web() {
cmd.Env = append(cmd.Env, "CC="+cc)
}
if j.t.OS == "darwin" {
+18 -9
View File
@@ -70,6 +70,7 @@ func (j *job) data() error {
var total int64
count := 0
var failed error
j.files = j.files[:0]
j.snap.idx.Each(func(e *asset.Entry) {
if failed != nil || e.Missing || e.Error != "" || exportSkip(e.Kind) {
return
@@ -98,6 +99,7 @@ func (j *job) data() error {
}
written[name] = true
names = append(names, name)
j.files = append(j.files, webFile{Path: bundle.ArtifactsDir + "/" + name, Size: int64(len(raw))})
s.Bytes += int64(len(raw))
total += int64(len(raw))
}
@@ -120,7 +122,9 @@ func (j *job) data() error {
if err := os.WriteFile(filepath.Join(j.l.data, bundle.ManifestFile), raw, 0o644); err != nil {
return errs.Wrap(err, errs.VFSIO, "cannot write the data manifest")
}
j.manifestSize = int64(len(raw))
slices.Sort(names)
slices.SortFunc(j.files, func(a, b webFile) int { return strings.Compare(a.Path, b.Path) })
h := sha256.New()
h.Write([]byte(strings.Join(names, "\n")))
h.Write(raw)
@@ -147,7 +151,7 @@ func (j *job) manifest() error {
p := j.p
x := &bundle.Export{
Engine: engine.Version, Game: p.Name, GameVersion: GameVersion(p), Module: p.Module,
Target: j.t.String(), Profile: j.profile, Binary: j.rep.Binary, BinarySize: j.rep.BinarySize,
Target: j.t.String(), Profile: j.profile, Binary: j.rep.Binary, BinarySize: j.rep.BinarySize, BinaryGzip: j.rep.BinaryGzip,
StartScene: p.MainScene, Modules: j.rep.Modules, Libs: j.libs(), Data: j.rep.Data,
DataSize: j.rep.DataSize, Artifacts: j.rep.Artifacts, Hash: j.rep.Hash,
BuildMillis: time.Since(j.start).Milliseconds(), Time: time.Now().UTC().Format(time.RFC3339),
@@ -169,19 +173,24 @@ func (j *job) sdlLicense() string {
return ""
}
func (j *job) libs() []bundle.Lib {
lib := bundle.Lib{Name: "SDL3", Link: "static"}
src := j.sdlLicense()
func (j *job) license(lib bundle.Lib, src, name string) bundle.Lib {
if src == "" {
return []bundle.Lib{lib}
return lib
}
data, err := os.ReadFile(src)
if err != nil {
return []bundle.Lib{lib}
return lib
}
dst := filepath.Join(j.out, "licenses", "SDL3.txt")
dst := filepath.Join(j.out, "licenses", name)
if os.MkdirAll(filepath.Dir(dst), 0o755) == nil && os.WriteFile(dst, data, 0o644) == nil {
lib.License = "licenses/SDL3.txt"
lib.License = "licenses/" + name
}
return []bundle.Lib{lib}
return lib
}
func (j *job) libs() []bundle.Lib {
if j.t.Web() {
return []bundle.Lib{j.license(bundle.Lib{Name: "Go wasm_exec.js", Link: "script", File: webExec}, filepath.Join(j.goroot, "LICENSE"), "Go.txt")}
}
return []bundle.Lib{j.license(bundle.Lib{Name: "SDL3", Link: "static"}, j.sdlLicense(), "SDL3.txt")}
}
+36 -15
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -21,6 +21,8 @@ func (j *job) pack() error {
return j.packLinux()
case "darwin":
return j.packDarwin()
case "js":
return j.packWeb()
}
return nil
}
+6 -1
View File
@@ -24,6 +24,7 @@ type Report struct {
Out string `json:"out"`
Binary string `json:"binary,omitempty"`
BinarySize int64 `json:"binarySize"`
BinaryGzip int64 `json:"binaryGzip,omitempty"`
Data []bundle.Size `json:"data"`
DataSize int64 `json:"dataSize"`
Artifacts int `json:"artifacts"`
@@ -70,7 +71,11 @@ func Bytes(n int64) string {
func (r *Report) Lines() [][2]string {
out := [][2]string{{"Target", r.Target}, {"Profile", r.Profile}, {"Output", r.Out}}
if r.Binary != "" {
out = append(out, [2]string{"Binary", r.Binary + " (" + Bytes(r.BinarySize) + ")"})
s := r.Binary + " (" + Bytes(r.BinarySize)
if r.BinaryGzip > 0 {
s += ", gzip " + Bytes(r.BinaryGzip)
}
out = append(out, [2]string{"Binary", s + ")"})
}
if r.Artifacts > 0 {
out = append(out, [2]string{"Data", Bytes(r.DataSize) + " in " + strconv.Itoa(r.Artifacts) + " artifacts"})
+208
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en" data-build="{{.Build}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
{{if .Icon}}<link rel="icon" href="favicon.png">{{else}}<link rel="icon" href="data:,">{{end}}
<style>
html, body { margin: 0; height: 100%; background: #0d0e10; color: #e4e3df; overflow: hidden; font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
canvas { display: block; width: 100%; height: 100%; outline: none; touch-action: none; }
.loader { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; background: #0d0e10; transition: opacity 0.25s; }
.loader.done { opacity: 0; pointer-events: none; }
.loader .title { font-size: 20px; font-weight: 600; }
.progress { width: min(360px, 80vw); height: 6px; border-radius: 3px; background: #2d3036; overflow: hidden; }
#bar { width: 0; height: 100%; background: #6e9bf5; transition: width 0.1s; }
#bar.indeterminate { width: 30%; animation: slide 1s ease-in-out infinite alternate; }
@keyframes slide { from { margin-left: 0; } to { margin-left: 70%; } }
#status { color: #9a9890; font-size: 13px; }
.loader.failed #bar { background: #f0776c; }
#error { max-width: min(640px, 90vw); max-height: 40vh; overflow: auto; margin: 0; padding: 10px 12px; white-space: pre-wrap; color: #f0776c; background: #1d1f23; border-radius: 6px; font: 12px/1.5 ui-monospace, Menlo, Consolas, monospace; }
#fullscreen { position: fixed; top: 10px; right: 10px; width: 36px; height: 36px; border: 0; border-radius: 6px; background: rgba(0, 0, 0, 0.45); color: #fff; font-size: 18px; cursor: pointer; opacity: 0.5; }
#fullscreen:hover { opacity: 1; }
</style>
</head>
<body>
<canvas id="game" data-aego tabindex="0" aria-label="{{.Title}}"></canvas>
<div id="loader" class="loader" role="status" aria-live="polite">
<div class="title">{{.Title}}</div>
<div class="progress"><div id="bar"></div></div>
<div id="status">Loading…</div>
<pre id="error" hidden></pre>
</div>
<button id="fullscreen" type="button" title="Fullscreen" aria-label="Fullscreen" hidden>⛶</button>
<script src="wasm_exec.js?v={{.Build}}"></script>
<script src="loader.js?v={{.Build}}"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
package export
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"aego/project"
"aego/runtime/bundle"
)
var webTarget = Target{OS: "js", Arch: "wasm"}
func TestParseWeb(t *testing.T) {
for _, s := range []string{"web", "WEB", "js/wasm", "wasm"} {
tg, err := ParseTarget(s)
if err != nil || !tg.Web() || tg.String() != "web" {
t.Fatalf("%s: %+v %v", s, tg, err)
}
}
}
func TestWebLayout(t *testing.T) {
p := &project.Project{Name: "My Game", Root: t.TempDir()}
l := layoutFor(webTarget, "/out", p)
if l.binary() != filepath.Join("/out", WebWasm) || l.data != filepath.Join("/out", bundle.DataDir) {
t.Fatalf("layout %+v", l)
}
if !strings.HasSuffix(DefaultOut(p, webTarget, bundle.Release), "web-"+bundle.Release) {
t.Fatal(DefaultOut(p, webTarget, bundle.Release))
}
}
func TestPackWeb(t *testing.T) {
root, err := goRoot()
if err != nil {
t.Skip(err)
}
out := t.TempDir()
wasm := bytes.Repeat([]byte("wasm"), 4096)
j := &job{
p: &project.Project{Name: `<Game & "Co">`, Module: "game"}, t: webTarget, out: out, goroot: root,
rep: &Report{Hash: strings.Repeat("ab", 32), BinarySize: int64(len(wasm))},
files: []webFile{{Path: "artifacts/a.tex", Size: 3}}, manifestSize: 8,
}
j.l = layoutFor(webTarget, out, j.p)
if err := os.MkdirAll(j.l.data, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(j.l.binary(), wasm, 0o644); err != nil {
t.Fatal(err)
}
if err := j.packWeb(); err != nil {
t.Fatal(err)
}
page, _ := os.ReadFile(filepath.Join(out, webIndex))
if !strings.Contains(string(page), "&lt;Game &amp;") || strings.Contains(string(page), "<Game") {
t.Fatal("the title is not escaped")
}
if !strings.Contains(string(page), `data-build="abababababab"`) {
t.Fatal("the build key is missing")
}
raw, err := os.ReadFile(filepath.Join(j.l.data, WebFiles))
if err != nil {
t.Fatal(err)
}
var list webList
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatal(err)
}
if list.Wasm != int64(len(wasm)) || list.Manifest.Size != 8 || len(list.Files) != 1 || list.Files[0].Path != "artifacts/a.tex" {
t.Fatalf("files.json %+v", list)
}
for _, f := range []string{webExec, webLoader, WebWasm + ".gz"} {
if _, err := os.Stat(filepath.Join(out, f)); err != nil {
t.Errorf("%s is missing", f)
}
}
if j.rep.BinaryGzip <= 0 || j.rep.BinaryGzip >= j.rep.BinarySize {
t.Fatalf("wasm gzip size %d of %d", j.rep.BinaryGzip, j.rep.BinarySize)
}
}
+14 -2
View File
@@ -1,8 +1,11 @@
package playground
import (
"fmt"
"strings"
playmods "aego/app/assembly/playground"
"aego/asset"
"aego/asset/loader"
"aego/core/log"
@@ -99,6 +102,7 @@ type Game struct {
bench bench
running bool
looping bool
overflow bool
}
type driver struct {
@@ -195,7 +199,7 @@ func (g *Game) Engine() *engine.Engine {
func (g *Game) Play(name string, src []byte, mods []*bytecode.Module) error {
g.Stop()
rt := scriptruntime.New(scriptruntime.Options{Bindings: g.reg, Schema: g.types.Schema(), Log: g.log})
rt := scriptruntime.New(scriptruntime.Options{Bindings: g.reg, Schema: g.types.Schema(), Log: g.log, VM: playmods.SandboxVM()})
if err := rt.Link(mods); err != nil {
rt.Close()
return err
@@ -225,7 +229,7 @@ func (g *Game) Play(name string, src []byte, mods []*bytecode.Module) error {
return err
}
g.detach = d
g.example, g.running, g.faults = name, true, 0
g.example, g.running, g.faults, g.overflow = name, true, 0, false
g.start()
return nil
}
@@ -272,6 +276,10 @@ func (g *Game) update(dt float64) {
g.frameMs += (ft - g.frameMs) * statsAlpha
}
g.bench.t += dt
if g.scene != nil && !g.overflow && g.scene.Stats().Alive > playmods.SandboxNodes {
g.overflow = true
g.clog.Error(fmt.Sprintf("the scene grew past %d nodes, the game was stopped", playmods.SandboxNodes))
}
}
func hue(i, n int) mathx.Color {
@@ -351,6 +359,10 @@ func (g *Game) newFaults() []Fault {
}
func (g *Game) Poll() Poll {
if g.overflow {
g.overflow = false
g.Stop()
}
p := Poll{Log: g.lines, Faults: g.newFaults()}
g.lines = nil
s := &p.Stats
+11
View File
@@ -109,3 +109,14 @@ func (p *Playground) Poll() Poll {
func (p *Playground) LoseContext(lose bool) bool {
return p.game != nil && p.game.LoseContext(lose)
}
func (p *Playground) PlayScene(name string, src []byte) PlayResult {
g, err := p.host()
if err != nil {
return PlayResult{Error: err.Error()}
}
if err := g.Play(name, src, nil); err != nil {
return PlayResult{Error: err.Error()}
}
return PlayResult{Started: true}
}
+11 -3
View File
@@ -31,7 +31,6 @@ import (
"aego/script/lexer"
"aego/script/runtime"
"aego/script/source"
"aego/script/vm"
"aego/script/workspace"
)
@@ -43,7 +42,6 @@ const (
ScriptsDir = "scripts"
DefaultFrames = 120
MaxFrames = 3600
Budget = 2_000_000
)
type Diagnostic struct {
@@ -284,7 +282,7 @@ func (p *Playground) Run(files map[string]string, frames int) Result {
res.Log = append(res.Log, record(&frame, r))
}))
lg.SetLevel(log.Debug)
rt := runtime.New(runtime.Options{Bindings: p.reg, Schema: p.types.Schema(), Log: lg, VM: vm.Options{Budget: Budget}})
rt := runtime.New(runtime.Options{Bindings: p.reg, Schema: p.types.Schema(), Log: lg, VM: playmods.SandboxVM()})
defer rt.Close()
if err := rt.Link(mods); err != nil {
res.Diagnostics = append(res.Diagnostics, failed(err)...)
@@ -495,3 +493,13 @@ func Format(res Result) []string {
}
return append(out, strconv.Itoa(res.Frames)+" frames")
}
func (p *Playground) TryScenes() []docs.Entry {
var out []docs.Entry
for _, e := range p.docs.All() {
if e.Try != "" {
out = append(out, e)
}
}
return out
}
+86
View File
@@ -0,0 +1,86 @@
package playground
import (
"strings"
"testing"
"aego/script/vm"
)
const loopKick = `package boxes
script Kick {
func Update(dt float) {
n := 0
for n >= 0 {
n++
}
}
}
`
const heapKick = `package boxes
script Kick {
func Update(dt float) {
s := "ab"
for s != "" {
s = s + s
}
}
}
`
const saveScript = `package demo
import "aego/save"
script S {
func Ready() {
save.Save("slot")
}
}
`
func faultOf(t *testing.T, p *Playground, code string) Fault {
t.Helper()
res := p.Play(ScenePrefix+"boxes", map[string]string{"main.aes": code})
if !res.Started {
t.Fatalf("did not start: %s %+v", res.Error, res.Diagnostics)
}
if err := p.Game().Step(3); err != nil {
t.Fatal(err)
}
for _, f := range p.Poll().Faults {
if f.Code == vm.CodeBudget {
return f
}
}
t.Fatal("the script was not stopped by the sandbox")
return Fault{}
}
func TestSandboxInstructionBudget(t *testing.T) {
p := newHeadless(t)
faultOf(t, p, loopKick)
if !p.Poll().Stats.Running {
t.Fatal("the game stopped with the script")
}
}
func TestSandboxHeapLimit(t *testing.T) {
p := newHeadless(t)
if f := faultOf(t, p, heapKick); !strings.Contains(f.Message, "heap") {
t.Fatalf("fault %+v", f)
}
}
func TestSandboxHasNoSaves(t *testing.T) {
p := newHeadless(t)
for _, d := range p.Compile(map[string]string{"main.aes": saveScript}) {
if d.Severity == "error" {
return
}
}
t.Fatal("aego/save compiled in the sandbox")
}
+40
View File
@@ -0,0 +1,40 @@
//go:build js && aego_editor
package main
import (
"syscall/js"
"aego/app/docsview"
)
func main() {
g := js.Global()
api := g.Get("Object").New()
app, err := docsview.New(docsview.Options{})
if err != nil {
api.Set("error", err.Error())
} else {
app.OnChange = func(text string) {
if f := g.Get("aegoDocsChanged"); f.Type() == js.TypeFunction {
f.Invoke(text)
}
}
api.Set("call", js.FuncOf(func(_ js.Value, args []js.Value) any {
method, payload := "", ""
if len(args) > 0 {
method = args[0].String()
}
if len(args) > 1 {
payload = args[1].String()
}
return app.Reply(method, payload)
}))
go app.Run()
}
g.Set("aegoDocs", api)
if ready := g.Get("aegoDocsReady"); ready.Type() == js.TypeFunction {
ready.Invoke()
}
select {}
}
+3 -1
View File
@@ -15,7 +15,7 @@ const docsUsage = `usage: aego docs <command> [flags]
export [--out file] write the documentation registry as JSON
check [--strict] broken links, empty summaries, undocumented bindings
build [--out site] generate the static site with the playground
build [--out site] [--demo] generate the static site with the playground
serve [--addr host:port] build, serve on localhost and rebuild on changes
flags: --project dir, --engine dir, --playground=false`
@@ -64,6 +64,7 @@ func cmdDocs(args []string) int {
play := fs.Bool("playground", true, "build the playground and Run buttons")
engineDir := fs.String("engine", "", "path to the aego source tree")
repo := fs.String("repo", "", "source repository linked from the site header")
demo := fs.Bool("demo", false, "link the web demo at demo/ from the landing page")
dir := fs.String("project", "", "project directory")
if err := fs.Parse(args[1:]); err != nil {
return 2
@@ -73,6 +74,7 @@ func cmdDocs(args []string) int {
return fail(err)
}
env.Repo = *repo
env.Demo = *demo
site := func(def string) string {
if *out != "" {
return *out
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const exportUsage = `usage: aego export [dir] [flags]
--target host|linux|windows|darwin[/amd64|/arm64] default host
--target host|linux|windows|darwin[/amd64|/arm64]|web default host
--development | --release default development
--out dir output directory, default <project>/exports/<os>-<arch>-<profile>
--cc compiler C compiler for a cross target, or CC_<OS>_<ARCH>
+22 -3
View File
@@ -3,7 +3,7 @@
`aego export` builds a game that runs on a machine without Go, without the engine sources and without the project.
```
aego export [dir] --target host|linux|windows|darwin[/amd64|/arm64] --development|--release --out dir
aego export [dir] --target host|linux|windows|darwin[/amd64|/arm64]|web --development|--release --out dir
aego export --check --release pre-export checks only
aego export --json report as JSON
```
@@ -77,13 +77,32 @@ The export needs Go and the SDL build for the target in `.cache/native/<os>_<arc
| host | `make bootstrap` |
| linux → windows | `CC=x86_64-w64-mingw32-gcc GOOS=windows make bootstrap`, then `aego export --target windows --cc x86_64-w64-mingw32-gcc`, or `zig cc -target x86_64-windows-gnu` |
| linux → darwin | osxcross: `CC=o64-clang GOOS=darwin make bootstrap`, `--cc o64-clang` |
| any → web | Stage 9.5c |
| any → web | nothing beyond Go: the build is GOOS=js GOARCH=wasm without cgo |
`CC_<OS>_<ARCH>` (for example `CC_WINDOWS_AMD64`) replaces `--cc`.
## Not in v1
No `.aepak`, no pruning of unreferenced assets (every imported asset except presets is exported), no stripping of embedded engine resources, no web target, no installers or notarization.
No `.aepak`, no pruning of unreferenced assets (every imported asset except presets is exported), no stripping of embedded engine resources, no installers or notarization.
## Web (D295)
`aego export --target web` builds the same runtime for `js/wasm` and writes a folder that any static server can host:
| File | Content |
|---|---|
| `index.html` | a full-window canvas, the loader with progress and errors, a fullscreen button |
| `loader.js` | fetches `data/files.json`, then `game.wasm`, the manifest and every artifact (six at a time), and starts the game |
| `wasm_exec.js` | the Go WebAssembly support script of the toolchain that built the game, its license in `licenses/Go.txt` |
| `game.wasm` | `cmd/aego-runtime` with `-tags aego_runtime`; release adds `-trimpath -s -w` |
| `data/` | `manifest.aetext`, `artifacts/` and `files.json` with every file and its size |
| `export.aetext` | as for desktop targets, plus `binaryGzip` |
Every file that compresses by more than 10% gets a `.gz` copy next to it, and a `.br` copy when `brotli` is in PATH; serve them with `gzip_static`/`brotli_static` or the equivalent, and serve `.wasm` as `application/wasm`. Artifacts are named by content hash and can be cached forever; `index.html`, `game.wasm`, the manifest and `files.json` carry the export hash in their query strings.
The game reads its data from memory: the loader hands the bytes to the runtime, which mounts them with `vfs/memfs` after checking every file against the manifest. Settings and saves go to `localStorage` through `platform.UserFiles()` (a few MB per site, writes past the quota fail with an error); there are no log files and no crash bundles, the log goes to the browser console. Fullscreen and pointer lock need a click or key press first. URL parameters `?scene=`, `?seed=`, `?profile=` and `?debug=1` replace the command-line flags.
Checks: EXP010 warns about an asset kind the web runtime cannot load (none yet), EXP011 warns that a native module must build without cgo. The target budget is 8 MB gzip for `game.wasm`; `make site` exports the platformer to `site/demo/` and fails above it.
## Editor app on macOS
+6
View File
@@ -162,3 +162,9 @@ func (q *questPanel) Draw(f *sdk.Frame, r mathx.Rect) {
| Settings page, profiler section, vector layer | `SettingsPage`, `Profiler`, `Vectors` |
A panel gets a fresh `Frame` every draw (`UI`, drag and drop, time, focus). Anything else it needs it captures at registration through `e.Editor()`. Built-in panels follow a stricter rule and never see the editor (D216). `PanelDesc.Slot` puts a panel into a column of the default layout; `Near` opens it next to a panel on demand; without `Order` extension panels follow the built-ins in the Window menu (D217).
## Try it scenes
A module can give any of its components a live example on the documentation site: put a scene in `res/docs/try/<Component>.aescene`, named after the last part of the component ID (`try/Body2D.aescene` for `physics.Body2D`). The page of that component gets a Try it block that runs the scene in the browser with the Inspector, selecting the first node that has the component; a guide page can show a scene with `try: try/name.aescene` and `try_select: module.Component` in its front matter.
Try scenes run on the sandbox catalog (core, input, render2d, scripting, physics, debug) with builtin assets only and no scripts. `aego docs check` loads each one, runs it for 60 frames and fails on a load error, a warning or a fault, and on a scene of a module outside the sandbox. Keep the scene to what the component needs, put the documented node first, and add a camera.
+4
View File
@@ -43,3 +43,7 @@ What the browser build (`GOOS=js GOARCH=wasm`, `platform/web` + `gpu/webgl2`) su
| Debug output, object labels | missing | `Options.Debug` checks `getError` after creation calls |
| Context loss | works | objects are recreated from CPU copies; textures cost twice the memory |
| Shaders | works | GLSL ES 3.00 copies generated by `make shaders` |
## Exported games
`aego export --target web` (D295) runs the same runtime as the desktop export on this platform and GPU backend. Differences from desktop: data is loaded into memory before the game starts, there are no log files or crash bundles, saves and settings share the site's `localStorage` quota.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -95,6 +95,7 @@ type Viewport struct {
OnRender func(func())
RenderWorld func(w *engine.World)
GameHUD []func(ui *core.Context, r mathx.Rect)
Bare bool
}
var (
+1 -1
View File
@@ -107,7 +107,7 @@ func (p *Panel) Draw(f *services.Frame, r mathx.Rect) {
origin := mathx.Vec2{X: r.X, Y: r.Y}
p.bar = mathx.Rect{}
p.tl.Clear()
if !p.play.Captured() {
if !p.play.Captured() && !p.vp.Bare {
p.toolbar(ui, r)
p.tl.Draw(ui, r)
}
+6
View File
@@ -22,6 +22,8 @@ type record struct {
ordered bool
keys []string
requires []string
try string
trySelect string
body string
line int
}
@@ -141,6 +143,10 @@ func (r *record) set(key, val string) error {
r.keys, err = list(val)
case "requires":
r.requires, err = list(val)
case "try":
r.try, err = scalar(val)
case "try_select":
r.trySelect, err = scalar(val)
default:
return fmt.Errorf("unknown front matter key %q", key)
}
+60
View File
@@ -48,6 +48,8 @@ const (
EngineDocsDir = "engine/res/docs"
scriptOwner = "script"
unknownOrder = 1000
TryDir = "try"
TryExt = ".aescene"
)
var kindNames = [...]string{"page", "module", "function", "type", "field", "method", "signal", "code", "const", "enum"}
@@ -102,6 +104,8 @@ type Entry struct {
Field *FieldInfo
Component *ComponentInfo
Related []string
Try string
TrySelect string
}
type Problem struct {
@@ -232,6 +236,7 @@ func Assemble(src Sources) *Registry {
r.scripts(src.Project, src.Scripts)
for _, d := range src.Docs {
r.pages(d)
r.tries(d)
}
r.index()
r.related()
@@ -505,6 +510,9 @@ func (r *Registry) pages(src module.DocSource) {
}
for _, rec := range recs {
r.page(src.Owner, f, source, rec)
if rec.try != "" {
r.pageTry(src, rec)
}
}
}
}
@@ -736,3 +744,55 @@ func (r *Registry) Check(strict bool) []Problem {
})
return out
}
func (r *Registry) pageTry(src module.DocSource, rec record) {
i, ok := r.byID[rec.id]
if !ok {
return
}
data, err := fs.ReadFile(src.FS, path.Clean(rec.try))
if err != nil {
r.problems = append(r.problems, Problem{ID: rec.id, Error: true, Message: "try scene " + rec.try + " cannot be read: " + err.Error()})
return
}
r.entries[i].Try, r.entries[i].TrySelect = string(data), rec.trySelect
}
func (r *Registry) componentNamed(short, owner string) (int, bool) {
found := -1
for i := range r.entries {
e := &r.entries[i]
if e.Component == nil || !strings.HasSuffix(e.ID, "."+short) {
continue
}
if e.Owner == owner {
return i, true
}
if found < 0 {
found = i
}
}
return found, found >= 0
}
func (r *Registry) tries(src module.DocSource) {
files, err := fs.Glob(src.FS, TryDir+"/*"+TryExt)
if err != nil {
return
}
for _, f := range files {
short := strings.TrimSuffix(path.Base(f), TryExt)
i, ok := r.componentNamed(short, src.Owner)
if !ok {
r.problems = append(r.problems, Problem{ID: src.Owner + ":" + f, Error: true, Message: "try scene names no component " + short})
continue
}
data, err := fs.ReadFile(src.FS, f)
if err != nil {
r.problems = append(r.problems, Problem{ID: src.Owner + ":" + f, Error: true, Message: err.Error()})
continue
}
e := &r.entries[i]
e.Try, e.TrySelect = string(data), e.ID
}
}
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
@@ -0,0 +1,58 @@
scene version=1 {
node "d3000000000000000000000000000001" name="Sprite" enabled=true layers=1 {
transform {
position=[0, 0]
rotation=0
scale=[1, 1]
}
component "engine.SpriteRenderer" version=1 {
sprite=asset("ae900000000000000000000000000010")
layer=0
order=1
tint=#38bdf8ff
origin=[0.5, 0.5]
size=[4, 3]
flipX=false
flipY=false
blend=premultiplied
filter=nearest
}
}
node "d3000000000000000000000000000002" name="Backdrop" enabled=true layers=1 {
transform {
position=[1.5, 1]
rotation=0
scale=[1, 1]
}
component "engine.SpriteRenderer" version=1 {
sprite=asset("ae900000000000000000000000000010")
layer=0
order=0
tint=#f43f5eff
origin=[0.5, 0.5]
size=[4, 3]
flipX=false
flipY=false
blend=premultiplied
filter=nearest
}
}
node "d3000000000000000000000000000003" name="Camera" enabled=true layers=1 {
transform {
position=[0, 0]
rotation=0
scale=[1, 1]
}
component "engine.Camera2D" version=1 {
zoom=48
viewport=[0, 0, 1, 1]
mask=-1
order=0
clear=true
clearColor=#111827ff
scale=fitVirtual
virtual=[960, 540]
active=true
}
}
}
+7 -1
View File
@@ -86,6 +86,7 @@ type Export struct {
Profile string
Binary string
BinarySize int64
BinaryGzip int64
StartScene ids.AssetID
Modules []string
Libs []Lib
@@ -251,6 +252,11 @@ func EncodeExport(x *Export) []byte {
text("profile", x.Profile),
text("binary", x.Binary),
integer("binarySize", x.BinarySize),
)
if x.BinaryGzip > 0 {
root.Fields = append(root.Fields, integer("binaryGzip", x.BinaryGzip))
}
root.Fields = append(root.Fields,
aetext.Field{Name: "startScene", Value: aetext.AssetRef(x.StartScene.String())},
strs("modules", x.Modules),
integer("dataSize", x.DataSize),
@@ -288,7 +294,7 @@ func DecodeExport(src []byte, file string) (*Export, error) {
x := &Export{
Engine: getStr(doc, "engine"), Game: getStr(doc, "game"), GameVersion: getStr(doc, "gameVersion"),
Module: getStr(doc, "module"), Target: getStr(doc, "target"), Profile: getStr(doc, "profile"),
Binary: getStr(doc, "binary"), BinarySize: getInt(doc, "binarySize"), Modules: getStrs(doc, "modules"),
Binary: getStr(doc, "binary"), BinarySize: getInt(doc, "binarySize"), BinaryGzip: getInt(doc, "binaryGzip"), Modules: getStrs(doc, "modules"),
DataSize: getInt(doc, "dataSize"), Artifacts: int(getInt(doc, "artifacts")), Hash: getStr(doc, "hash"),
BuildMillis: getInt(doc, "buildMillis"), Time: getStr(doc, "time"),
}
+2 -2
View File
@@ -39,14 +39,14 @@ func TestDataRoundTrip(t *testing.T) {
}
func TestExportRoundTrip(t *testing.T) {
in := &Export{Engine: "e", Game: "G", Target: "darwin/arm64", Profile: Development, Binary: "G.app/Contents/MacOS/g",
in := &Export{Engine: "e", Game: "G", Target: "darwin/arm64", Profile: Development, Binary: "G.app/Contents/MacOS/g", BinaryGzip: 7,
Modules: []string{"engine", "script"}, Libs: []Lib{{Name: "SDL3", Link: "static", License: "licenses/SDL3.txt"}},
Data: []Size{{Kind: "texture", Count: 2, Bytes: 10}}, Artifacts: 2, Hash: "ff"}
out, err := DecodeExport(EncodeExport(in), ExportFile)
if err != nil {
t.Fatal(err)
}
if out.Binary != in.Binary || len(out.Libs) != 1 || out.Libs[0].License != in.Libs[0].License || out.Data[0] != in.Data[0] || len(out.Modules) != 2 {
if out.Binary != in.Binary || out.BinaryGzip != 7 || len(out.Libs) != 1 || out.Libs[0].License != in.Libs[0].License || out.Data[0] != in.Data[0] || len(out.Modules) != 2 {
t.Fatalf("export manifest did not survive: %+v", out)
}
}
+9
View File
@@ -0,0 +1,9 @@
//go:build js
package player
const hostFiles = false
func (p *Player) watchInterrupt() func() {
return func() {}
}
+24
View File
@@ -0,0 +1,24 @@
//go:build !js
package player
import (
"os"
"os/signal"
)
const hostFiles = true
func (p *Player) watchInterrupt() func() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
go func() {
if _, ok := <-ch; ok {
p.Engine.Jobs().OnMain(p.Engine.Quit)
}
}()
return func() {
signal.Stop(ch)
close(ch)
}
}
+2
View File
@@ -1,3 +1,5 @@
//go:build !js
package player
import (
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
package player
import (
"strings"
"aego/asset"
"aego/core/errs"
"aego/core/log"
"aego/runtime/bundle"
"aego/vfs"
"aego/vfs/memfs"
)
type Bundle struct {
Manifest []byte
Files map[string][]byte
}
func MountBytes(b *Bundle) (*bundle.Data, *vfs.VFS, error) {
d, err := bundle.DecodeData(b.Manifest, bundle.ManifestFile)
if err != nil {
return nil, nil, err
}
prefix := bundle.Scheme + "://"
var bad error
d.Index.Each(func(e *asset.Entry) {
for _, a := range e.Artifacts {
rel, ok := strings.CutPrefix(a.Path, prefix)
if !ok || bad != nil {
continue
}
data, ok := b.Files[rel]
switch {
case !ok:
bad = errs.New(errs.AssetNotMounted, "the game data is missing a file", log.F("path", rel))
case int64(len(data)) != a.Size:
bad = errs.New(errs.VFSIO, "a game data file differs from the manifest", log.F("path", rel), log.F("size", len(data)), log.F("want", a.Size))
}
}
})
if bad != nil {
return nil, nil, bad
}
v, err := vfs.New(vfs.Mount{Scheme: bundle.Scheme, Source: memfs.New(b.Files), ReadOnly: true})
if err != nil {
return nil, nil, err
}
return d, v, nil
}
func (p *Player) mount() (*bundle.Data, *vfs.VFS, error) {
if p.cfg.Bundle != nil {
return MountBytes(p.cfg.Bundle)
}
return Mount(p.cfg.Data)
}
+29
View File
@@ -0,0 +1,29 @@
package player
import (
"testing"
"aego/asset"
"aego/runtime/bundle"
)
func TestMountBytes(t *testing.T) {
raw, err := bundle.EncodeData(&bundle.Data{Engine: "test", Profile: bundle.Release, Target: "web", Index: asset.NewIndex()})
if err != nil {
t.Fatal(err)
}
d, v, err := MountBytes(&Bundle{Manifest: raw, Files: map[string][]byte{"artifacts/x.bin": []byte("hi")}})
if err != nil {
t.Fatal(err)
}
if !d.Release() {
t.Fatalf("profile %q", d.Profile)
}
got, err := v.ReadFile(bundle.ArtifactPath("x.bin"))
if err != nil || string(got) != "hi" {
t.Fatalf("read %q %v", got, err)
}
if _, _, err := MountBytes(&Bundle{Manifest: []byte("not a manifest")}); err == nil {
t.Fatal("a broken manifest mounted")
}
}
+6 -13
View File
@@ -3,7 +3,6 @@ package player
import (
"io"
"os"
"os/signal"
"path/filepath"
"runtime/debug"
"strings"
@@ -64,6 +63,7 @@ type Config struct {
Seed uint64
NoProfile bool
Logger *log.Logger
Bundle *Bundle
}
type Player struct {
@@ -145,7 +145,7 @@ func Open(cfg Config, mods ...module.Module) (*Player, error) {
}
func (p *Player) build(mods []module.Module) error {
data, files, err := Mount(p.cfg.Data)
data, files, err := p.mount()
if err != nil {
return err
}
@@ -153,7 +153,7 @@ func (p *Player) build(mods []module.Module) error {
g := &data.Game
if !p.cfg.NoProfile {
p.profileDir, p.profileErr = platform.ProfileDir(g.Module, p.cfg.Profile)
if p.profileErr == nil {
if p.profileErr == nil && hostFiles {
p.crashDir = filepath.Join(p.profileDir, "crash")
}
}
@@ -243,11 +243,11 @@ func (p *Player) logger() error {
}
p.Log = log.New(log.FuncSink(func(log.Record) {}))
release := p.Data.Release()
if !release || p.cfg.Debug {
if !release || p.cfg.Debug || !hostFiles {
p.Log.AddSink(log.NewTextSink(os.Stderr))
}
path := p.cfg.LogFile
if path == "" && release && p.profileDir != "" {
if path == "" && release && p.profileDir != "" && hostFiles {
path = filepath.Join(p.profileDir, "logs", "game.log")
}
if path != "" {
@@ -471,14 +471,7 @@ func (p *Player) Run() error {
panic(rec)
}
}()
interrupts := make(chan os.Signal, 1)
signal.Notify(interrupts, os.Interrupt)
defer signal.Stop(interrupts)
go func() {
if _, ok := <-interrupts; ok {
p.Engine.Jobs().OnMain(p.Engine.Quit)
}
}()
defer p.watchInterrupt()()
d := driver{}
if p.cfg.Frames > 0 {
for i := 0; i < p.cfg.Frames; i++ {
+20
View File
@@ -1,5 +1,7 @@
package vm
import "strconv"
const staticBit = uint64(1) << 63
type object struct {
@@ -14,12 +16,28 @@ type object struct {
size int
}
type heapFull struct {
limit int
}
func (f heapFull) Error() string {
return "the script heap is over its " + strconv.Itoa(f.limit>>20) + " MB limit"
}
type Heap struct {
objs []object
free []uint32
live int
bytes int
since int
limit int
armed int
}
func (h *Heap) charge(size int) {
if h.armed > 0 && h.limit > 0 && h.bytes+size > h.limit {
panic(heapFull{limit: h.limit})
}
}
func heapHandle(idx, gen uint32) uint64 {
@@ -27,6 +45,7 @@ func heapHandle(idx, gen uint32) uint64 {
}
func (h *Heap) alloc(size int) (uint32, *object) {
h.charge(size)
var idx uint32
if n := len(h.free); n > 0 {
idx = h.free[n-1]
@@ -77,6 +96,7 @@ func (h *Heap) newSlice(width int, mask uint32, capacity int) Value {
}
func (h *Heap) grow(o *object, delta int) {
h.charge(delta)
o.size += delta
h.bytes += delta
h.since += delta
+16 -1
View File
@@ -59,6 +59,7 @@ type Options struct {
Budget uint64
InitialStack int
GCThreshold int
MaxHeap int
}
func (o Options) withDefaults() Options {
@@ -173,12 +174,14 @@ type Machine struct {
func New(opt Options) *Machine {
opt = opt.withDefaults()
return &Machine{
m := &Machine{
opt: opt,
interned: map[string]Value{},
stack: make([]Value, opt.InitialStack),
frames: make([]frame, 0, opt.MaxCallDepth),
}
m.heap.limit = opt.MaxHeap
return m
}
func (m *Machine) Options() Options {
@@ -390,15 +393,27 @@ func (m *Machine) trapErr(top, pc int, err error) {
}
func (m *Machine) exec(floor int) {
m.heap.armed++
defer m.disarm()
defer m.recoverHost()
m.run(floor)
}
func (m *Machine) disarm() {
m.heap.armed--
}
func (m *Machine) recoverHost() {
r := recover()
if r == nil {
return
}
if hf, ok := r.(heapFull); ok {
if m.fault == nil {
m.raise(CodeBudget, hf.Error())
}
return
}
if m.fault == nil {
m.raise(CodeNativePanic, fmt.Sprint("native binding panicked: ", r))
}
-3
View File
@@ -326,9 +326,6 @@ func TestExportCrossWithoutToolchain(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), "C compiler") {
t.Fatalf("want a toolchain error, got %v", err)
}
if _, err := export.ParseTarget("web"); err == nil {
t.Fatal("web export must be refused in 9.5a")
}
}
func TestExportWindowsUnderWine(t *testing.T) {

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