Compare commits

...
Author SHA1 Message Date
Cursor Agentandcodegirl007 e702497996 Clarify Agentbox names and invariants
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-18 06:05:46 +00:00
Cursor Agentandcodegirl007 4d2fb4a733 Organize Agentbox runtime code
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-18 06:03:59 +00:00
Cursor Agentandcodegirl007 f2fee5d26b Explain Agentbox architecture in plain language
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-17 18:19:11 +00:00
Cursor Agentandcodegirl007 c4f8dbeaaf Verify deterministic movement from observations
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-17 16:49:37 +00:00
Cursor Agentandcodegirl007 3037dbc2dd Allow staged applications to execute from tmpfs
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-17 16:48:13 +00:00
Cursor Agentandcodegirl007 e9197798f9 Document Agentbox runtime and model workflow
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-17 16:47:22 +00:00
Cursor Agentandcodegirl007 94146fcae2 Add runtime abstraction and agent loop
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-17 16:47:22 +00:00
Cursor Agentandcodegirl007 54dc5658c6 Wait for window manager readiness
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-17 16:40:23 +00:00
Cursor Agentandcodegirl007 ad5a4b4114 Stream Phase 1 artifacts from container tmpfs
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-17 16:39:29 +00:00
Cursor Agentandcodegirl007 96dade334d Add headless Agentbox environment spike
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
2026-08-17 16:36:55 +00:00
codegirl007 98f56833a0 Add set_sprite_clip_def for pre-resolved clip switches 2026-08-14 23:19:30 -07:00
codegirl007 3cc4797a72 Test draw_sprite MAX_SPRITES enqueue capacity 2026-08-14 23:19:07 -07:00
codegirl007 f295aedf04 Merge master into draw-sprite-max-sprites-guard.
Resolve batching_test.odin by keeping master tests and the MAX_SPRITES guard test.
2026-08-14 23:17:48 -07:00
codegirl007 ba0b25980c Speed group_texture_runs for two-texture batches 2026-08-14 23:13:29 -07:00
codegirl007 71c76f180e Add crowd_batch, crowd_overdraw, and clip_thrash stress examples 2026-08-14 23:12:58 -07:00
codegirl007 00ba400d15 Add crowd_batch, crowd_overdraw, and clip_thrash stress examples. 2026-08-14 23:08:03 -07:00
codegirl007 c97a17f85b Speed group_texture_runs for two-texture batches. 2026-08-14 23:08:00 -07:00
codegirl007 a24f547709 Add set_sprite_clip_def for pre-resolved clip switches. 2026-08-14 23:07:58 -07:00
codegirl007 9df98ef73d Merge pull request #31 from Codegirl-Games/test-sprite-clip-cache
Test sprite clip_def cache on set_sprite_clip
2026-08-14 22:44:52 -07:00
codegirl007 f65b0354df Test that set_sprite_clip caches clip_def on sprites.
Locks spawn/switch cache fill and that a bad clip name leaves the prior cache intact.
2026-08-14 22:43:42 -07:00
codegirl007 cc196afd2e Cache sprite clip defs to cut per-frame map lookups 2026-08-14 22:42:15 -07:00
codegirl007 8d9faaaee0 Drop verbose comments from the clip-cache change. 2026-08-14 22:40:42 -07:00
codegirl007 41fb0a3d5a Cache clip defs on sprites to avoid per-frame map lookups.
Crowd stress (128 sprites) showed Clip_Def string map_get as the top engine cost; cache on set_sprite_clip and use it in update/draw.
2026-08-14 22:32:28 -07:00
codegirl007 dcf5ea29b0 Texture batching with prepare_draw_batches test coverage 2026-08-14 22:20:08 -07:00
codegirl007 70784b0f1b Test DXIL shader runtime preference without MSL 2026-08-14 22:19:16 -07:00
codegirl007 fb63ee21f4 Test draw_sprite refuses to exceed MAX_SPRITES.
Locks the enqueue capacity guard so a full draw_list cannot grow past the vertex buffer budget.
2026-08-14 22:18:42 -07:00
codegirl007 deef204bd3 Test DXIL preference when MSL is unavailable.
Locks the MSL > DXIL > SPIRV shader runtime order so Windows paths cannot silently fall through to Vulkan.
2026-08-14 22:16:58 -07:00
codegirl007 25dd3aa920 Add texture batching with TDD coverage at enqueue and prepare seams.
Opt-in batch_group regrouping cuts texture binds; prepare_draw_batches and draw_list tests lock the contract so end_frame cannot silently drop regroup.
2026-08-14 22:15:08 -07:00
48 changed files with 4178 additions and 48 deletions
+17 -2
View File
@@ -1,7 +1,7 @@
.PHONY: shaders-vulkan shaders-d3d12 shaders-metal shaders-all bake toad hello_sprite crowd camera_sandbox clips check test help
.PHONY: shaders-vulkan shaders-d3d12 shaders-metal shaders-all bake toad hello_sprite crowd crowd_batch crowd_overdraw clip_thrash camera_sandbox clips check test help
.PHONY: flame flame-build flame-record flame-svg flame-report flame-tools
# Flamegraph profiling (needs: pacman -S perf). Example: make flame or make flame FLAME_EXAMPLE=toad
# Flamegraph profiling (needs: pacman -S perf). Example: make flame or make flame FLAME_EXAMPLE=crowd_batch
FLAME_EXAMPLE ?= crowd
FLAME_BIN := $(FLAME_EXAMPLE)_perf
FLAMEGRAPH_DIR ?= tools/FlameGraph
@@ -24,6 +24,9 @@ help:
@echo " toad Run the toad example (full demo)"
@echo " hello_sprite Minimal load + draw"
@echo " crowd Many sprites, one Character_Data"
@echo " crowd_batch Multi-texture batching stress (two toad loads)"
@echo " crowd_overdraw Stacked sprites overdraw stress"
@echo " clip_thrash Per-frame idle/walk clip flip stress"
@echo " camera_sandbox Pan camera / Space toggles follow"
@echo " clips Keys 1/2 switch idle/walk"
@echo " flame Build+record+SVG+JPG+text report (FLAME_EXAMPLE=$(FLAME_EXAMPLE))"
@@ -54,6 +57,9 @@ check:
odin check examples/toad -collection:pkg=.
odin check examples/hello_sprite -collection:pkg=.
odin check examples/crowd -collection:pkg=.
odin check examples/crowd_batch -collection:pkg=.
odin check examples/crowd_overdraw -collection:pkg=.
odin check examples/clip_thrash -collection:pkg=.
odin check examples/camera_sandbox -collection:pkg=.
odin check examples/clips -collection:pkg=.
@@ -66,6 +72,15 @@ hello_sprite:
crowd:
odin run examples/crowd -collection:pkg=.
crowd_batch:
odin run examples/crowd_batch -collection:pkg=.
crowd_overdraw:
odin run examples/crowd_overdraw -collection:pkg=.
clip_thrash:
odin run examples/clip_thrash -collection:pkg=.
camera_sandbox:
odin run examples/camera_sandbox -collection:pkg=.
+3
View File
@@ -0,0 +1,3 @@
.agentbox/
/agentbox
examples/mover/mover
+16
View File
@@ -0,0 +1,16 @@
.PHONY: build demo demo-binary phase1 test
build:
go build -o agentbox ./cmd/agentbox
demo-binary:
./scripts/build_demo.sh
demo: build demo-binary
./agentbox run ./examples/mover --task "Launch the application, move the character to the right, and describe what happened."
phase1:
go run ./cmd/agentbox phase1
test:
go test ./...
+129
View File
@@ -0,0 +1,129 @@
# Agentbox
This is a local technical spike for a programmable graphical Linux
environment. It proves that a controller can launch an ordinary GUI process
headlessly, observe pixels and logs, inject keyboard and mouse input, drive the
application through a replaceable agent, record a trace, and shut the
environment down.
The default agent is deterministic and requires no API credentials. One
optional OpenAI Responses adapter demonstrates screenshot + text model control.
This is still a local spike, not a cloud control plane or production sandbox.
See [the architecture decision](docs/architecture.md) for the choices and
security boundary.
## Requirements
- Linux
- Go 1.22 or newer
- Docker Engine with a running daemon
- permission to use Docker without an interactive `sudo` prompt
No X server, window manager, or C compiler is required on the host; those
dependencies are built into the container image.
## One-command demo
From this directory:
```sh
make demo
```
That one command:
1. builds the `agentbox` CLI and mover executable;
2. creates a restricted Xvfb/Openbox environment;
3. stages and launches the executable;
4. runs the deterministic observe/press/wait/release/observe loop;
5. records screenshots, actions, decisions, logs, and final status;
6. stops and removes the environment, including on failure or interruption.
The equivalent explicit commands are:
```sh
make build demo-binary
./agentbox run ./examples/mover \
--task "Launch the application, move the character to the right, and describe what happened."
```
Flags can appear before or after the path.
## Run a private executable
Pass a prebuilt Linux executable:
```sh
./agentbox run ./path/to/application --task "Open the menu and click Settings."
```
For a directory, add `agentbox.json`:
```json
{
"command": "game",
"args": ["--windowed"],
"env": {"EXAMPLE": "value"},
"window_title": "My Game"
}
```
The command is resolved relative to the manifest. The current runtime is
Debian-based, so dynamically linked executables must have compatible libraries.
## Model agent
The model adapter is opt-in:
```sh
export OPENAI_API_KEY="..."
./agentbox run ./path/to/application \
--task "Move the character right and report the result." \
--agent openai \
--model gpt-5
```
Each request contains the task, current screenshot, previous actions, step
history, and recent application logs. Strict structured output allows one
backend-neutral input action or completion. Use `--max-steps` to bound a run.
The environment itself still has no network access.
## Runs and inspection
```sh
./agentbox runs
./agentbox inspect <run-id>
```
Each run writes:
```text
.agentbox/runs/<run-id>/
├── run.json
├── actions.jsonl
├── steps.jsonl
├── screenshots/
│ ├── 0001.png
│ └── ...
├── stdout.log
└── stderr.log
```
`run.json` has `schema_version: "1"` plus task, application, agent, timestamps,
status, and step count. `steps.jsonl` records each observation reference, agent
message, and action. `actions.jsonl` is a compact action-only stream.
## Original environment proof
The visual centroid test from Phase 1 remains available:
```sh
make phase1
```
## Development checks
```sh
make test
go vet ./...
```
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"path/filepath"
"agentbox/internal/trace"
)
func listRuns() error {
projectRoot, err := findProjectRoot()
if err != nil {
return err
}
runs, err := trace.List(projectRoot)
if err != nil {
return err
}
if len(runs) == 0 {
fmt.Println("No recorded runs.")
return nil
}
for _, run := range runs {
fmt.Printf("%s %-8s %-20s %s\n",
run.ID, run.Status, run.Agent, run.Task)
}
return nil
}
func inspect(args []string) error {
if len(args) != 1 {
return errors.New("inspect requires one run ID")
}
projectRoot, err := findProjectRoot()
if err != nil {
return err
}
runID := args[0]
run, err := trace.Read(projectRoot, runID)
if err != nil {
return err
}
steps, err := trace.ReadSteps(projectRoot, runID)
if err != nil {
return err
}
output := struct {
Run trace.Run `json:"run"`
Steps []trace.StepRecord `json:"steps"`
Directory string `json:"directory"`
}{
Run: run,
Steps: steps,
Directory: filepath.Join(projectRoot, ".agentbox", "runs", run.ID),
}
data, err := json.MarshalIndent(output, "", " ")
if err != nil {
return err
}
fmt.Println(string(data))
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"agentbox/internal/phase1"
)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "phase1":
err = phase1.Run(ctx)
case "run":
err = run(ctx, os.Args[2:])
case "runs":
err = listRuns()
case "inspect":
err = inspect(os.Args[2:])
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintf(os.Stderr, "agentbox: %v\n", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprintln(os.Stderr, `usage:
agentbox run <path> --task "<task>" [--agent deterministic|openai] [--model <model>]
agentbox runs
agentbox inspect <run-id>
agentbox phase1`)
}
+17
View File
@@ -0,0 +1,17 @@
package main
import "testing"
func TestParseRunOptionsAllowsFlagsAfterPath(t *testing.T) {
options, err := parseRunOptions([]string{
"./game", "--task", "move right", "--max-steps=7",
})
if err != nil {
t.Fatal(err)
}
if options.applicationPath != "./game" ||
options.task != "move right" ||
options.maxSteps != 7 {
t.Fatalf("options = %#v", options)
}
}
+28
View File
@@ -0,0 +1,28 @@
package main
import (
"bytes"
"errors"
"os"
"path/filepath"
)
// findProjectRoot lets commands work from any directory inside the Agentbox
// module. The module declaration is a stronger marker than a directory name.
func findProjectRoot() (string, error) {
currentDirectory, err := os.Getwd()
if err != nil {
return "", err
}
for {
goModule, readErr := os.ReadFile(filepath.Join(currentDirectory, "go.mod"))
if readErr == nil && bytes.Contains(goModule, []byte("module agentbox")) {
return currentDirectory, nil
}
parentDirectory := filepath.Dir(currentDirectory)
if parentDirectory == currentDirectory {
return "", errors.New("run agentbox from inside its module directory")
}
currentDirectory = parentDirectory
}
}
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
"agentbox/internal/agent"
"agentbox/internal/appspec"
"agentbox/internal/environment/dockerx11"
agentRuntime "agentbox/internal/runtime"
"agentbox/internal/trace"
)
type runOptions struct {
applicationPath string
task string
agentName string
modelName string
maxSteps int
}
func run(ctx context.Context, args []string) error {
options, err := parseRunOptions(args)
if err != nil {
return err
}
projectRoot, err := findProjectRoot()
if err != nil {
return err
}
applicationCommand, err := appspec.Resolve(options.applicationPath)
if err != nil {
return err
}
controller, err := newAgent(options)
if err != nil {
return err
}
traceStore, err := trace.New(
projectRoot,
options.task,
options.applicationPath,
controller.Name(),
)
if err != nil {
return err
}
desktopEnvironment := dockerx11.New(dockerx11.Config{
ProjectRoot: projectRoot,
RunID: traceStore.ID(),
Output: os.Stdout,
})
err = agentRuntime.Run(ctx, agentRuntime.Config{
Task: options.task,
Command: applicationCommand,
Controller: controller,
Environment: desktopEnvironment,
TraceStore: traceStore,
MaxSteps: options.maxSteps,
Output: os.Stdout,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Run artifacts: %s\n", traceStore.Directory())
return err
}
fmt.Printf("Run artifacts written to:\n%s\n", traceStore.Directory())
return nil
}
// parseRunOptions accepts flags before or after the application path so the CLI
// matches the natural "agentbox run ./app --task ..." form shown in the docs.
func parseRunOptions(args []string) (runOptions, error) {
options := runOptions{
agentName: "deterministic",
modelName: "gpt-5",
maxSteps: 20,
}
for index := 0; index < len(args); index++ {
argument := args[index]
if !strings.HasPrefix(argument, "--") {
if options.applicationPath != "" {
return options, errors.New("run accepts exactly one application path")
}
options.applicationPath = argument
continue
}
flagName, flagValue, _ := strings.Cut(strings.TrimPrefix(argument, "--"), "=")
if flagValue == "" {
index++
if index >= len(args) {
return options, fmt.Errorf("--%s requires a value", flagName)
}
flagValue = args[index]
}
switch flagName {
case "task":
options.task = flagValue
case "agent":
options.agentName = flagValue
case "model":
options.modelName = flagValue
case "max-steps":
maxSteps, err := strconv.Atoi(flagValue)
if err != nil || maxSteps < 1 {
return options, errors.New("--max-steps must be a positive integer")
}
options.maxSteps = maxSteps
default:
return options, fmt.Errorf("unknown flag --%s", flagName)
}
}
if options.applicationPath == "" {
return options, errors.New("run requires an application path")
}
if options.task == "" {
return options, errors.New("run requires --task")
}
return options, nil
}
func newAgent(options runOptions) (agent.Agent, error) {
switch options.agentName {
case "deterministic":
return &agent.Deterministic{}, nil
case "openai":
return agent.NewOpenAI(agent.OpenAIConfig{
APIKey: os.Getenv("OPENAI_API_KEY"),
Model: options.modelName,
})
default:
return nil, fmt.Errorf(
"unknown agent %q (want deterministic or openai)",
options.agentName,
)
}
}
+290
View File
@@ -0,0 +1,290 @@
# How Agentbox works
This document explains the prototype in simple terms. The exact technical names
are included in parentheses for readers who want to dig deeper.
## The big idea
Imagine giving a robot its own computer in a locked room.
The robot cannot look inside the program or use secret game controls. It can
only:
- look at the screen;
- press and release keyboard keys;
- move and click the mouse;
- read messages printed by the program.
Agentbox builds that room, starts the program, lets the robot interact with it,
and records everything that happened.
## The main parts
Each part has one job:
```text
You type a command
|
v
CLI: understands what you asked for
|
v
Runtime: runs the experiment step by step
/ \
v v
Environment Agent
"the computer" "the robot"
| |
+------v-------+
|
Trace
"the experiment notebook"
```
- **CLI:** The `agentbox` command you run in a terminal.
- **Runtime:** The referee. It asks the environment for a screenshot, gives it
to the agent, carries out the agent's next action, and repeats.
- **Environment:** The temporary Linux computer containing the application.
- **Application:** The game or other interactive program being tested.
- **Agent:** The decision-maker. It can be a fixed test script or an AI model.
- **Trace:** The saved screenshots, actions, logs, and final result.
The important rule is that these parts do not cheat by reaching into each
other. The agent does not know about Docker or X11. The environment does not
know whether the agent is OpenAI, another model, or a fixed script.
## What happens during one run
When you run:
```sh
agentbox run ./my-game --task "Move the character right."
```
Agentbox does this:
1. Creates a fresh temporary room.
2. Copies the executable into that room.
3. Starts a pretend monitor inside the room.
4. Launches the application on that monitor.
5. Takes a screenshot.
6. Gives the screenshot, task, recent logs, and earlier actions to the agent.
7. Receives one action, such as `key_down RIGHT`.
8. Sends that action to the temporary computer.
9. Repeats until the agent says it is finished or reaches the step limit.
10. Saves the experiment and destroys the temporary room.
Stopping on errors is important. Even if the application or agent fails,
Agentbox still tries to save the logs and remove the environment.
## How the pretend computer works
The temporary room is a **Docker container**. A container is like a lightweight
box around a group of programs. It keeps files and processes separate enough
for this experiment, but it is not strong enough to safely hold a determined
attacker. The security section explains that limitation.
There is no physical monitor in the container, so we use a pretend one:
- **Xvfb** is a screen that exists only in memory. The application thinks it is
drawing to a normal 640×480 monitor.
- **Openbox** acts like a tiny desktop. It puts windows in the right place and
decides which window receives keyboard input.
- **scrot** takes a picture of the pretend monitor and saves it as a PNG.
- **xdotool** sends ordinary-looking keyboard and mouse events.
The application is not modified to accept special Agentbox commands. From its
point of view, a person pressed a key or clicked the mouse.
## Why use these old-looking tools?
### Why X11 instead of Wayland?
Linux has two common ways to manage graphical windows: X11 and Wayland.
Wayland is newer and safer for everyday desktops. One of its safety features is
that programs cannot freely spy on the whole screen or pretend to be the
keyboard. Those are exactly the powers Agentbox needs, so a headless Wayland
version would need more complicated, compositor-specific plumbing.
X11 already has small, well-understood tools for this experiment. Because all
X11 details are hidden behind the `Environment` interface, we can replace this
backend later without changing the agent.
One X11 screen is used per environment. Programs sharing an X11 screen can
interfere with each other, so unrelated runs must never share one.
### Why no VNC or browser viewer?
VNC would let a human watch the desktop live. That sounds useful, but the agent
only needs screenshots and input for now. Adding VNC would mean more servers,
network ports, and video encoding without proving anything new. It can be added
later as a viewer without changing how the agent thinks.
### What about a graphics card?
This version draws with the CPU instead of a GPU. That is enough for the simple
demo and many desktop applications. A demanding 3D game may need a future
environment that supplies a virtual or real GPU.
### Why is the demo written with Xlib?
The demo uses a tiny C/Xlib program because it has very few dependencies. It
proves the screen and input path directly. Agentbox is not tied to Xlib: a
staged application may use Raylib, SDL, Qt, GTK, a browser, or another toolkit
that can display through X11.
## Why the pieces are kept separate
Think about a toy car with replaceable batteries. The car should not care which
brand of battery powers it, and the battery should not need to know where the
car is driving.
Agentbox follows the same idea:
- The **Environment interface** is a remote control for a computer:
`Start`, `Launch`, `Screenshot`, `SendInput`, `Logs`, and `Stop`.
- The **Agent interface** receives an observation and chooses the next action.
- The **Runtime** connects the two interfaces.
This lets us swap parts independently:
- Docker today could become a microVM or cloud machine later.
- X11 today could become a Wayland or GPU backend later.
- The fixed test agent could become OpenAI, Claude, Gemini, or a local model.
- The mover game could become a browser, drawing program, or other application.
Only `internal/environment/dockerx11` knows the current Linux tricks. Model
code never imports that package.
## The two current agents
### Deterministic agent
This is a fixed test script:
1. Look at the first screenshot.
2. Hold the RIGHT key.
3. Wait one second.
4. Check another screenshot.
5. Fail unless the green square moved at least 100 pixels.
6. Release the key and finish.
It is intentionally specific to the demo. Its purpose is to test the whole
system without paying for or depending on a model API.
### OpenAI adapter
This optional adapter sends the model:
- the user's task;
- the current screenshot;
- earlier actions;
- recent application logs;
- a short history of the run.
The model must return a small JSON object containing either one allowed action
or `done`. It cannot directly call Docker or run shell commands. The adapter is
one example, not a giant framework for every model company.
## How an application gets inside
`agentbox run` accepts a prebuilt Linux executable.
If the given path is a directory, an `agentbox.json` file says which executable
to run, what arguments to pass, which environment variables to set, and
optionally which window title to wait for.
Agentbox streams the executable into temporary memory inside the container. It
does not share the developer's whole folder with the container. The temporary
copy disappears when the run ends.
There is one practical limit: the executable and its libraries must work in the
Debian-based runtime image. Agentbox does not yet package missing libraries or
convert Windows and macOS programs.
## What gets recorded
The trace is an experiment notebook:
- `run.json` says what ran, which agent controlled it, and whether it finished;
- `steps.jsonl` records every observation, decision, and action in order;
- `actions.jsonl` is a smaller action-only list;
- `screenshots/` contains the pictures the agent saw;
- `stdout.log` and `stderr.log` contain application messages.
The trace format has a version number. Screenshots are separate files instead
of giant blobs inside JSON. This keeps traces easy to inspect and leaves room
for replay, comparisons, tests, or training data later.
## Is the container safe?
Not safe enough for strangers' programs.
Docker is more like a locked bedroom than a bank vault. It keeps ordinary
programs apart, but every container still shares the host's Linux kernel. A
serious kernel or Docker bug might let a hostile program break out.
This prototype adds useful guardrails:
- no network access inside the environment;
- a read-only main filesystem;
- a small, temporary writable area;
- an unprivileged user;
- no extra Linux capabilities;
- limits on CPU, memory, and process count;
- automatic destruction after the run.
These rules reduce accidents. They do not make Docker a trustworthy boundary
for arbitrary customer code. Do not use this prototype to run unknown binaries
on an important machine or a machine shared by multiple customers.
## What production would need
Before accepting untrusted uploads, the room needs walls built with hardware
virtualization. A small virtual machine, such as Firecracker or Kata
Containers, gives each run its own kernel. gVisor may be another option when it
supports the applications we need.
A real service would also need to:
- build uploaded programs away from runtime hosts;
- limit upload size, expanded file size, disk use, runtime, and network access;
- keep every customer's files, logs, secrets, and encryption keys separate;
- authenticate every control request;
- patch and replace base images regularly;
- monitor hosts and destroy every machine after its run.
## What this experiment proves
It proves the interaction model:
1. launch a normal graphical Linux application without a physical monitor;
2. observe it through screenshots and logs;
3. control it with keyboard and mouse actions;
4. keep the environment and agent replaceable;
5. save enough information to understand what happened;
6. shut everything down cleanly.
It does **not** prove production security, GPU game support, cloud scaling,
Windows/macOS support, billing, or authentication.
## Small glossary
- **Container:** A lightweight box around processes and files.
- **Backend:** One implementation hidden behind a common interface.
- **X11:** A Linux system for drawing windows and handling input.
- **Headless:** Running without a physical monitor.
- **Synthetic input:** Keyboard or mouse events created by software.
- **Trace:** The saved record of a run.
- **Kernel:** The deepest part of the operating system that controls hardware
and processes.
- **MicroVM:** A small virtual machine with its own kernel.
## Sources
- [X.Org Xvfb manual](https://www.x.org/releases/X11R7.6/doc/man/man1/Xvfb.1.xhtml)
- [xdotool project and XTEST behavior](https://github.com/jordansissel/xdotool)
- [Docker Engine security](https://docs.docker.com/engine/security/)
- [gVisor security model](https://gvisor.dev/docs/architecture_guide/security/)
- [Firecracker design](https://firecracker-microvm.github.io/)
+31
View File
@@ -0,0 +1,31 @@
FROM debian:bookworm-slim AS builder
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential \
libx11-dev \
&& rm -rf /var/lib/apt/lists/*
COPY examples/mover/main.c /src/main.c
RUN cc -std=c11 -O2 -Wall -Wextra -Werror /src/main.c -lX11 -o /mover
FROM debian:bookworm-slim
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
libx11-6 \
openbox \
scrot \
x11-utils \
xdotool \
xvfb \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --uid 10001 agentbox
COPY --from=builder /mover /opt/agentbox/mover
COPY environment/entrypoint.sh /opt/agentbox/entrypoint.sh
RUN chmod 0755 /opt/agentbox/entrypoint.sh /opt/agentbox/mover
USER agentbox
ENV DISPLAY=:99
ENTRYPOINT ["/opt/agentbox/entrypoint.sh"]
+63
View File
@@ -0,0 +1,63 @@
#!/bin/sh
set -eu
export HOME=/tmp/home
export XDG_RUNTIME_DIR=/tmp/runtime
mkdir -p "$HOME" "$XDG_RUNTIME_DIR"
chmod 0700 "$XDG_RUNTIME_DIR"
cleanup() {
rm -f /tmp/agentbox-ready
kill "${OPENBOX_PID:-}" "${XVFB_PID:-}" 2>/dev/null || true
wait "${OPENBOX_PID:-}" "${XVFB_PID:-}" 2>/dev/null || true
}
terminate() {
trap - EXIT INT TERM
cleanup
exit 0
}
trap cleanup EXIT
trap terminate INT TERM
Xvfb "$DISPLAY" -screen 0 640x480x24 -nolisten tcp -noreset &
XVFB_PID=$!
attempt=0
until xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; do
attempt=$((attempt + 1))
if [ "$attempt" -ge 100 ]; then
echo "Xvfb did not become ready" >&2
exit 1
fi
sleep 0.05
done
openbox --sm-disable >/tmp/openbox.log 2>&1 &
OPENBOX_PID=$!
attempt=0
while :; do
wm_info=$(xprop -root _NET_SUPPORTING_WM_CHECK 2>/dev/null || true)
case "$wm_info" in
*"window id #"*) break ;;
esac
if ! kill -0 "$OPENBOX_PID" 2>/dev/null; then
echo "Openbox exited before becoming ready" >&2
exit 1
fi
attempt=$((attempt + 1))
if [ "$attempt" -ge 100 ]; then
echo "Openbox did not become ready" >&2
exit 1
fi
sleep 0.05
done
touch /tmp/agentbox-ready
while :; do
sleep 3600 &
wait $!
done
+4
View File
@@ -0,0 +1,4 @@
{
"command": "mover",
"window_title": "Agentbox Mover"
}
+136
View File
@@ -0,0 +1,136 @@
#define _POSIX_C_SOURCE 199309L
#include <X11/Xlib.h>
#include <X11/keysym.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
enum {
WINDOW_WIDTH = 640,
WINDOW_HEIGHT = 480,
SQUARE_SIZE = 50,
};
static double monotonic_seconds(void) {
struct timespec now;
if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) {
perror("clock_gettime");
exit(1);
}
return (double)now.tv_sec + (double)now.tv_nsec / 1000000000.0;
}
static void set_key_state(KeySym key, bool down, bool *left, bool *right,
bool *up, bool *down_key) {
switch (key) {
case XK_Left:
*left = down;
break;
case XK_Right:
*right = down;
break;
case XK_Up:
*up = down;
break;
case XK_Down:
*down_key = down;
break;
default:
break;
}
}
int main(void) {
setvbuf(stdout, NULL, _IOLBF, 0);
Display *display = XOpenDisplay(NULL);
if (display == NULL) {
fputs("unable to open X display\n", stderr);
return 1;
}
int screen = DefaultScreen(display);
Window window = XCreateSimpleWindow(
display, RootWindow(display, screen), 0, 0, WINDOW_WIDTH, WINDOW_HEIGHT,
0, BlackPixel(display, screen), BlackPixel(display, screen));
XStoreName(display, window, "Agentbox Mover");
XSelectInput(display, window,
ExposureMask | KeyPressMask | KeyReleaseMask | ButtonPressMask);
XMapWindow(display, window);
GC gc = XCreateGC(display, window, 0, NULL);
Colormap colors = DefaultColormap(display, screen);
XColor green;
XColor red;
if (!XParseColor(display, colors, "#00ff66", &green) ||
!XAllocColor(display, colors, &green) ||
!XParseColor(display, colors, "#ff3355", &red) ||
!XAllocColor(display, colors, &red)) {
fputs("unable to allocate colors\n", stderr);
XCloseDisplay(display);
return 1;
}
double x = (WINDOW_WIDTH - SQUARE_SIZE) / 2.0;
double y = (WINDOW_HEIGHT - SQUARE_SIZE) / 2.0;
bool left = false;
bool right = false;
bool up = false;
bool down = false;
bool marker_visible = false;
int marker_x = 0;
int marker_y = 0;
double previous = monotonic_seconds();
printf("mover started at x=%.1f y=%.1f\n", x, y);
for (;;) {
while (XPending(display) > 0) {
XEvent event;
XNextEvent(display, &event);
if (event.type == KeyPress || event.type == KeyRelease) {
set_key_state(XLookupKeysym(&event.xkey, 0),
event.type == KeyPress, &left, &right, &up, &down);
} else if (event.type == ButtonPress) {
marker_visible = true;
marker_x = event.xbutton.x;
marker_y = event.xbutton.y;
printf("mouse click at x=%d y=%d\n", marker_x, marker_y);
}
}
double now = monotonic_seconds();
double delta = now - previous;
previous = now;
const double speed = 180.0;
x += ((right ? 1.0 : 0.0) - (left ? 1.0 : 0.0)) * speed * delta;
y += ((down ? 1.0 : 0.0) - (up ? 1.0 : 0.0)) * speed * delta;
if (x < 0.0) {
x = 0.0;
} else if (x > WINDOW_WIDTH - SQUARE_SIZE) {
x = WINDOW_WIDTH - SQUARE_SIZE;
}
if (y < 0.0) {
y = 0.0;
} else if (y > WINDOW_HEIGHT - SQUARE_SIZE) {
y = WINDOW_HEIGHT - SQUARE_SIZE;
}
XClearWindow(display, window);
XSetForeground(display, gc, green.pixel);
XFillRectangle(display, window, gc, (int)x, (int)y, SQUARE_SIZE,
SQUARE_SIZE);
if (marker_visible) {
XSetForeground(display, gc, red.pixel);
XFillRectangle(display, window, gc, marker_x - 4, marker_y - 4, 9,
9);
}
XFlush(display);
struct timespec frame = {.tv_sec = 0, .tv_nsec = 16000000};
nanosleep(&frame, NULL);
}
}
+3
View File
@@ -0,0 +1,3 @@
module agentbox
go 1.22
+42
View File
@@ -0,0 +1,42 @@
package agent
import (
"context"
"time"
"agentbox/internal/environment"
)
type Observation struct {
Screenshot []byte
Timestamp time.Time
Logs []environment.LogEntry
PreviousActions []environment.InputAction
}
// Step is the compact history passed back to an agent on its next decision.
type Step struct {
Number int
Timestamp time.Time
ScreenshotPath string
Message string
Action *environment.InputAction
}
// Decision contains either one action or Done. Returning neither is invalid.
type Decision struct {
Reason string
Action *environment.InputAction
Done bool
}
// Agent chooses one backend-neutral action from the latest observation.
type Agent interface {
Name() string
NextAction(
ctx context.Context,
task string,
history []Step,
observation Observation,
) (Decision, error)
}
+93
View File
@@ -0,0 +1,93 @@
package agent
import (
"bytes"
"context"
"fmt"
"image/png"
"agentbox/internal/environment"
)
type Deterministic struct {
nextStep int
initialSquareX float64
hasInitialPosition bool
}
func (deterministicAgent *Deterministic) Name() string {
return "deterministic-right"
}
func (deterministicAgent *Deterministic) NextAction(
_ context.Context,
_ string,
_ []Step,
observation Observation,
) (Decision, error) {
var decision Decision
switch deterministicAgent.nextStep {
case 0:
initialSquareX, err := greenCentroidX(observation.Screenshot)
if err != nil {
return Decision{}, fmt.Errorf("inspect initial screenshot: %w", err)
}
deterministicAgent.initialSquareX = initialSquareX
deterministicAgent.hasInitialPosition = true
action := environment.InputAction{Type: environment.KeyDown, Key: "RIGHT"}
decision = Decision{Reason: "Press RIGHT to start moving.", Action: &action}
case 1:
action := environment.InputAction{Type: environment.Wait, DurationMS: 1000}
decision = Decision{Reason: "Keep RIGHT held for one second.", Action: &action}
case 2:
currentSquareX, err := greenCentroidX(observation.Screenshot)
if err != nil {
return Decision{}, fmt.Errorf("inspect moved screenshot: %w", err)
}
distanceMoved := currentSquareX - deterministicAgent.initialSquareX
if !deterministicAgent.hasInitialPosition || distanceMoved < 100 {
return Decision{}, fmt.Errorf(
"visual verification failed: square moved %.1f pixels right, want at least 100",
distanceMoved,
)
}
action := environment.InputAction{Type: environment.KeyUp, Key: "RIGHT"}
decision = Decision{
Reason: fmt.Sprintf(
"The square moved %.1f pixels right; release RIGHT.",
distanceMoved,
),
Action: &action,
}
default:
decision = Decision{Reason: "The movement sequence is complete.", Done: true}
}
deterministicAgent.nextStep++
return decision, nil
}
func greenCentroidX(screenshot []byte) (float64, error) {
renderedImage, err := png.Decode(bytes.NewReader(screenshot))
if err != nil {
return 0, err
}
var xCoordinateSum, greenPixelCount uint64
bounds := renderedImage.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
red, green, blue, _ := renderedImage.At(x, y).RGBA()
// Match the mover's #00ff66 square. Requiring 1,000 matching pixels
// prevents a small green UI detail from passing verification.
if green > 0xc000 && red < 0x4000 && blue < 0x8000 {
xCoordinateSum += uint64(x)
greenPixelCount++
}
}
}
if greenPixelCount < 1000 {
return 0, fmt.Errorf("found only %d green square pixels", greenPixelCount)
}
return float64(xCoordinateSum) / float64(greenPixelCount), nil
}
var _ Agent = (*Deterministic)(nil)
@@ -0,0 +1,75 @@
package agent
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"testing"
"agentbox/internal/environment"
)
func TestDeterministicSequence(t *testing.T) {
controller := &Deterministic{}
want := []struct {
action environment.InputType
done bool
}{
{environment.KeyDown, false},
{environment.Wait, false},
{environment.KeyUp, false},
{"", true},
}
for index, expected := range want {
x := 20
if index >= 2 {
x = 140
}
decision, err := controller.NextAction(context.Background(), "", nil, Observation{
Screenshot: screenshotWithSquare(t, x),
})
if err != nil {
t.Fatalf("step %d: %v", index, err)
}
if decision.Done != expected.done {
t.Fatalf("step %d done = %v, want %v", index, decision.Done, expected.done)
}
if expected.action != "" && (decision.Action == nil || decision.Action.Type != expected.action) {
t.Fatalf("step %d action = %#v, want %s", index, decision.Action, expected.action)
}
}
}
func TestDeterministicRejectsMissingMovement(t *testing.T) {
controller := &Deterministic{}
for step := 0; step < 2; step++ {
if _, err := controller.NextAction(context.Background(), "", nil, Observation{
Screenshot: screenshotWithSquare(t, 20),
}); err != nil {
t.Fatal(err)
}
}
if _, err := controller.NextAction(context.Background(), "", nil, Observation{
Screenshot: screenshotWithSquare(t, 20),
}); err == nil {
t.Fatal("movement verification error = nil")
}
}
func screenshotWithSquare(t *testing.T, startX int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 240, 100))
green := color.RGBA{G: 255, B: 102, A: 255}
for y := 20; y < 60; y++ {
for x := startX; x < startX+40; x++ {
img.Set(x, y, green)
}
}
var output bytes.Buffer
if err := png.Encode(&output, img); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
+98
View File
@@ -0,0 +1,98 @@
package agent
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type OpenAIConfig struct {
APIKey string
Model string
BaseURL string
Client *http.Client
}
// OpenAI adapts the Responses API to Agentbox's provider-neutral Agent
// contract. HTTP and wire-format details do not leak into the runtime.
type OpenAI struct {
config OpenAIConfig
}
func NewOpenAI(config OpenAIConfig) (*OpenAI, error) {
if config.APIKey == "" {
return nil, errors.New("OPENAI_API_KEY is required for --agent openai")
}
if config.Model == "" {
config.Model = "gpt-5"
}
if config.BaseURL == "" {
config.BaseURL = "https://api.openai.com/v1"
}
if config.Client == nil {
config.Client = &http.Client{Timeout: 90 * time.Second}
}
return &OpenAI{config: config}, nil
}
func (openAI *OpenAI) Name() string {
return "openai:" + openAI.config.Model
}
func (openAI *OpenAI) NextAction(
ctx context.Context,
task string,
history []Step,
observation Observation,
) (Decision, error) {
requestBody, err := buildResponsesRequest(
openAI.config.Model,
task,
history,
observation,
)
if err != nil {
return Decision{}, err
}
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
strings.TrimRight(openAI.config.BaseURL, "/")+"/responses",
bytes.NewReader(requestBody),
)
if err != nil {
return Decision{}, err
}
request.Header.Set("Authorization", "Bearer "+openAI.config.APIKey)
request.Header.Set("Content-Type", "application/json")
httpResponse, err := openAI.config.Client.Do(request)
if err != nil {
return Decision{}, err
}
defer httpResponse.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(httpResponse.Body, 4<<20))
if err != nil {
return Decision{}, err
}
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
return Decision{}, fmt.Errorf(
"OpenAI response %s: %s",
httpResponse.Status,
strings.TrimSpace(string(responseBody)),
)
}
outputText, err := extractResponseText(responseBody)
if err != nil {
return Decision{}, err
}
return parseModelDecision(outputText)
}
var _ Agent = (*OpenAI)(nil)
+206
View File
@@ -0,0 +1,206 @@
package agent
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"agentbox/internal/environment"
)
type responsesRequest struct {
Model string `json:"model"`
Input []responsesInput `json:"input"`
Text responsesTextSettings `json:"text"`
}
type responsesInput struct {
Role string `json:"role"`
Content []responsesContent `json:"content"`
}
type responsesContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL string `json:"image_url,omitempty"`
}
type responsesTextSettings struct {
Format responsesFormat `json:"format"`
}
type responsesFormat struct {
Type string `json:"type"`
Name string `json:"name"`
Strict bool `json:"strict"`
Schema map[string]any `json:"schema"`
}
func buildResponsesRequest(
model string,
task string,
history []Step,
observation Observation,
) ([]byte, error) {
prompt, err := modelPrompt(task, history, observation)
if err != nil {
return nil, err
}
request := responsesRequest{
Model: model,
Input: []responsesInput{{
Role: "user",
Content: []responsesContent{
{Type: "input_text", Text: prompt},
{
Type: "input_image",
ImageURL: "data:image/png;base64," +
base64.StdEncoding.EncodeToString(observation.Screenshot),
},
},
}},
Text: responsesTextSettings{Format: responsesFormat{
Type: "json_schema",
Name: "agentbox_action",
Strict: true,
Schema: decisionSchema(),
}},
}
return json.Marshal(request)
}
func modelPrompt(task string, history []Step, observation Observation) (string, error) {
contextData := struct {
Task string `json:"task"`
History []Step `json:"history"`
RecentLogs []environment.LogEntry `json:"recent_logs"`
PreviousActions []environment.InputAction `json:"previous_actions"`
}{
Task: task,
History: history,
RecentLogs: observation.Logs,
PreviousActions: observation.PreviousActions,
}
data, err := json.Marshal(contextData)
if err != nil {
return "", err
}
return "You control an interactive Linux application from screenshots. " +
"Choose exactly one allowed input action, or mark done when the task is complete. " +
"Use logical key names such as RIGHT, ENTER, or ESCAPE. Keep waits under 5000 ms.\n" +
string(data), nil
}
func decisionSchema() map[string]any {
// Strict structured output requires every action property to be present,
// even when a particular action ignores most of them.
actionProperties := map[string]any{
"type": map[string]any{
"type": "string",
"enum": []string{
string(environment.KeyDown), string(environment.KeyUp),
string(environment.MouseMove), string(environment.MouseDown),
string(environment.MouseUp), string(environment.Wait),
},
},
"key": map[string]any{"type": "string"},
"x": map[string]any{"type": "integer"},
"y": map[string]any{"type": "integer"},
"button": map[string]any{"type": "integer"},
"duration_ms": map[string]any{"type": "integer"},
}
return map[string]any{
"type": "object",
"additionalProperties": false,
"properties": map[string]any{
"reason": map[string]any{"type": "string"},
"done": map[string]any{"type": "boolean"},
"action": map[string]any{
"anyOf": []any{
map[string]any{
"type": "object",
"additionalProperties": false,
"properties": actionProperties,
"required": []string{
"type", "key", "x", "y", "button", "duration_ms",
},
},
map[string]any{"type": "null"},
},
},
},
"required": []string{"reason", "done", "action"},
}
}
func extractResponseText(data []byte) (string, error) {
var response struct {
Output []struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
} `json:"output"`
}
if err := json.Unmarshal(data, &response); err != nil {
return "", fmt.Errorf("decode OpenAI response: %w", err)
}
for _, output := range response.Output {
for _, content := range output.Content {
if content.Type == "output_text" && content.Text != "" {
return content.Text, nil
}
}
}
return "", errors.New("OpenAI response contained no output_text")
}
type modelDecision struct {
Reason string `json:"reason"`
Done bool `json:"done"`
Action *modelAction `json:"action"`
}
type modelAction struct {
Type environment.InputType `json:"type"`
Key string `json:"key"`
X int `json:"x"`
Y int `json:"y"`
Button int `json:"button"`
DurationMS int `json:"duration_ms"`
}
func parseModelDecision(text string) (Decision, error) {
var modelOutput modelDecision
if err := json.Unmarshal([]byte(text), &modelOutput); err != nil {
return Decision{}, fmt.Errorf("decode model decision: %w", err)
}
if modelOutput.Reason == "" {
return Decision{}, errors.New("model decision requires reason")
}
if modelOutput.Done {
return Decision{Reason: modelOutput.Reason, Done: true}, nil
}
if modelOutput.Action == nil {
return Decision{}, errors.New("model decision requires action when not done")
}
if modelOutput.Action.DurationMS < 0 || modelOutput.Action.DurationMS > 5000 {
return Decision{}, errors.New("model wait must be between 0 and 5000 ms")
}
action := environment.InputAction{
Type: modelOutput.Action.Type,
Key: modelOutput.Action.Key,
X: modelOutput.Action.X,
Y: modelOutput.Action.Y,
Button: modelOutput.Action.Button,
DurationMS: modelOutput.Action.DurationMS,
}
switch action.Type {
case environment.KeyDown, environment.KeyUp, environment.MouseMove,
environment.MouseDown, environment.MouseUp, environment.Wait:
default:
return Decision{}, fmt.Errorf("model returned unsupported action %q", action.Type)
}
return Decision{Reason: modelOutput.Reason, Action: &action}, nil
}
+59
View File
@@ -0,0 +1,59 @@
package agent
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"agentbox/internal/environment"
)
func TestOpenAISendsScreenshotAndParsesDecision(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/responses" {
t.Errorf("path = %q, want /responses", request.URL.Path)
}
if request.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("missing bearer token")
}
body, err := io.ReadAll(request.Body)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "data:image/png;base64,AQID") {
t.Errorf("request does not contain screenshot data URL: %s", body)
}
response.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(response, `{
"output":[{"content":[{"type":"output_text","text":"{\"reason\":\"move right\",\"done\":false,\"action\":{\"type\":\"key_down\",\"key\":\"RIGHT\",\"x\":0,\"y\":0,\"button\":0,\"duration_ms\":0}}"}]}]
}`)
}))
defer server.Close()
controller, err := NewOpenAI(OpenAIConfig{
APIKey: "test-key", Model: "test-model", BaseURL: server.URL, Client: server.Client(),
})
if err != nil {
t.Fatal(err)
}
decision, err := controller.NextAction(context.Background(), "move", nil, Observation{
Screenshot: []byte{1, 2, 3},
})
if err != nil {
t.Fatal(err)
}
if decision.Action == nil || decision.Action.Type != environment.KeyDown ||
decision.Action.Key != "RIGHT" {
t.Fatalf("decision = %#v", decision)
}
}
func TestDecisionSchemaIsJSONSerializable(t *testing.T) {
if _, err := json.Marshal(decisionSchema()); err != nil {
t.Fatal(err)
}
}
+66
View File
@@ -0,0 +1,66 @@
package appspec
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"agentbox/internal/environment"
)
type manifest struct {
Command string `json:"command"`
Args []string `json:"args"`
EnvironmentVariables map[string]string `json:"env"`
WindowTitle string `json:"window_title"`
}
// Resolve turns either an executable path or a directory containing
// agentbox.json into the backend-neutral launch command.
func Resolve(path string) (environment.Command, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return environment.Command{}, fmt.Errorf("resolve application path: %w", err)
}
info, err := os.Stat(absolute)
if err != nil {
return environment.Command{}, fmt.Errorf("inspect application path: %w", err)
}
if !info.IsDir() {
if !info.Mode().IsRegular() {
return environment.Command{}, errors.New("application must be a regular executable file")
}
return environment.Command{Path: absolute}, nil
}
data, err := os.ReadFile(filepath.Join(absolute, "agentbox.json"))
if err != nil {
return environment.Command{}, fmt.Errorf("read directory manifest agentbox.json: %w", err)
}
var config manifest
if err := json.Unmarshal(data, &config); err != nil {
return environment.Command{}, fmt.Errorf("parse agentbox.json: %w", err)
}
if config.Command == "" {
return environment.Command{}, errors.New("agentbox.json requires command")
}
commandPath := config.Command
if !filepath.IsAbs(commandPath) {
commandPath = filepath.Join(absolute, commandPath)
}
commandInfo, err := os.Stat(commandPath)
if err != nil {
return environment.Command{}, fmt.Errorf("inspect manifest command: %w", err)
}
if !commandInfo.Mode().IsRegular() {
return environment.Command{}, errors.New("manifest command must be a regular executable file")
}
return environment.Command{
Path: commandPath,
Args: config.Args,
EnvironmentVariables: config.EnvironmentVariables,
WindowTitle: config.WindowTitle,
}, nil
}
+28
View File
@@ -0,0 +1,28 @@
package appspec
import (
"os"
"path/filepath"
"testing"
)
func TestResolveDirectoryManifest(t *testing.T) {
directory := t.TempDir()
executable := filepath.Join(directory, "game")
if err := os.WriteFile(executable, []byte("binary"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"command":"game","args":["--demo"],"window_title":"Demo"}`
if err := os.WriteFile(filepath.Join(directory, "agentbox.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
command, err := Resolve(directory)
if err != nil {
t.Fatal(err)
}
if command.Path != executable || command.WindowTitle != "Demo" ||
len(command.Args) != 1 || command.Args[0] != "--demo" {
t.Fatalf("command = %#v", command)
}
}
@@ -0,0 +1,175 @@
package dockerx11
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"agentbox/internal/environment"
)
const stagedApplicationPath = "/tmp/application"
func (environmentBackend *Environment) Launch(
ctx context.Context,
application environment.Command,
) error {
fmt.Fprintln(environmentBackend.config.Output, "Staging application...")
executable, err := os.Open(application.Path)
if err != nil {
return fmt.Errorf("open application: %w", err)
}
defer executable.Close()
// Stream instead of bind-mounting the developer's directory. The container
// sees only the requested executable, and the copy disappears at teardown.
stageCommand := exec.CommandContext(
ctx,
"docker",
"exec",
"-i",
environmentBackend.containerName,
"sh",
"-c",
"cat > "+stagedApplicationPath+" && chmod 0500 "+stagedApplicationPath,
)
stageCommand.Stdin = executable
if output, err := stageCommand.CombinedOutput(); err != nil {
return fmt.Errorf(
"stage application: %w: %s",
err,
strings.TrimSpace(string(output)),
)
}
fmt.Fprintln(environmentBackend.config.Output, "Launching application...")
dockerArguments := []string{"exec", "-d"}
for variableName, variableValue := range application.EnvironmentVariables {
dockerArguments = append(
dockerArguments,
"-e",
variableName+"="+variableValue,
)
}
dockerArguments = append(
dockerArguments,
environmentBackend.containerName,
"sh",
"-c",
)
shellCommand := []string{"exec", stagedApplicationPath}
for _, applicationArgument := range application.Args {
shellCommand = append(shellCommand, shellQuote(applicationArgument))
}
shellCommand = append(
shellCommand,
">/tmp/stdout.log",
"2>/tmp/stderr.log",
)
dockerArguments = append(dockerArguments, strings.Join(shellCommand, " "))
if _, err := environmentBackend.runDocker(ctx, dockerArguments...); err != nil {
return fmt.Errorf("launch application: %w", err)
}
return environmentBackend.focusApplicationWindow(ctx, application.WindowTitle)
}
func (environmentBackend *Environment) focusApplicationWindow(
ctx context.Context,
windowTitle string,
) error {
if windowTitle == "" {
// Applications without a title cannot be searched reliably. Give the
// process a brief startup window before the first screenshot.
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
return nil
}
}
var windowID string
if err := waitFor(ctx, 10*time.Second, func() bool {
output, searchErr := environmentBackend.runDocker(
ctx,
"exec",
environmentBackend.containerName,
"xdotool",
"search",
"--name",
windowTitle,
)
if searchErr != nil {
return false
}
windowID = strings.TrimSpace(strings.Split(string(output), "\n")[0])
return windowID != ""
}); err != nil {
return fmt.Errorf("wait for application window %q: %w", windowTitle, err)
}
if _, err := environmentBackend.runDocker(
ctx,
"exec",
environmentBackend.containerName,
"xdotool",
"windowactivate",
"--sync",
windowID,
); err != nil {
return fmt.Errorf("focus application window: %w", err)
}
return nil
}
func (environmentBackend *Environment) Screenshot(ctx context.Context) ([]byte, error) {
const screenshotPath = "/tmp/screenshot.png"
if _, err := environmentBackend.runDocker(
ctx,
"exec",
environmentBackend.containerName,
"scrot",
"-o",
screenshotPath,
); err != nil {
return nil, fmt.Errorf("capture screenshot: %w", err)
}
screenshot, err := environmentBackend.runDocker(
ctx,
"exec",
environmentBackend.containerName,
"cat",
screenshotPath,
)
if err != nil {
return nil, fmt.Errorf("extract screenshot: %w", err)
}
return screenshot, nil
}
func (environmentBackend *Environment) Logs(
ctx context.Context,
) ([]environment.LogEntry, error) {
var logEntries []environment.LogEntry
for _, streamName := range []string{"stdout", "stderr"} {
logContents, err := environmentBackend.runDocker(
ctx,
"exec",
environmentBackend.containerName,
"cat",
"/tmp/"+streamName+".log",
)
if err != nil || len(logContents) == 0 {
continue
}
logEntries = append(logEntries, environment.LogEntry{
Stream: streamName,
Message: string(logContents),
Time: time.Now().UTC(),
})
}
return logEntries, nil
}
@@ -0,0 +1,61 @@
package dockerx11
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
func (environmentBackend *Environment) runDocker(
ctx context.Context,
arguments ...string,
) ([]byte, error) {
command := exec.CommandContext(ctx, "docker", arguments...)
output, err := command.CombinedOutput()
if err != nil {
return output, fmt.Errorf(
"docker %s: %w: %s",
arguments[0],
err,
strings.TrimSpace(string(output)),
)
}
return output, nil
}
func (environmentBackend *Environment) streamDockerOutput(
ctx context.Context,
arguments ...string,
) error {
command := exec.CommandContext(ctx, "docker", arguments...)
command.Stdout = environmentBackend.config.Output
command.Stderr = environmentBackend.config.Output
return command.Run()
}
func waitFor(ctx context.Context, timeout time.Duration, ready func() bool) error {
timeoutTimer := time.NewTimer(timeout)
defer timeoutTimer.Stop()
retryTicker := time.NewTicker(100 * time.Millisecond)
defer retryTicker.Stop()
for {
if ready() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-timeoutTimer.C:
return errors.New("timed out")
case <-retryTicker.C:
}
}
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
@@ -0,0 +1,37 @@
package dockerx11
import (
"io"
"sync"
"agentbox/internal/environment"
)
const runtimeImageName = "agentbox-runtime:local"
// Config contains host-side values needed to create one isolated desktop.
type Config struct {
ProjectRoot string
RunID string
Output io.Writer
}
// Environment implements the backend-neutral environment contract with one
// Docker container, one X11 display, and one application process.
type Environment struct {
config Config
containerName string
stopMutex sync.Mutex
containerCreated bool
containerStopped bool
}
func New(config Config) *Environment {
return &Environment{
config: config,
containerName: "agentbox-run-" + config.RunID,
}
}
var _ environment.Environment = (*Environment)(nil)
@@ -0,0 +1,18 @@
package dockerx11
import "testing"
func TestX11KeyTranslatesLogicalNames(t *testing.T) {
tests := map[string]string{
"RIGHT": "Right",
"LEFT": "Left",
"ENTER": "Return",
"ESCAPE": "Escape",
"A": "a",
}
for input, want := range tests {
if got := toX11KeyName(input); got != want {
t.Errorf("toX11KeyName(%q) = %q, want %q", input, got, want)
}
}
}
@@ -0,0 +1,95 @@
package dockerx11
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"agentbox/internal/environment"
)
func (environmentBackend *Environment) SendInput(
ctx context.Context,
action environment.InputAction,
) error {
var xdotoolArguments []string
switch action.Type {
case environment.KeyDown:
if action.Key == "" {
return errors.New("key_down requires key")
}
xdotoolArguments = []string{"keydown", toX11KeyName(action.Key)}
case environment.KeyUp:
if action.Key == "" {
return errors.New("key_up requires key")
}
xdotoolArguments = []string{"keyup", toX11KeyName(action.Key)}
case environment.MouseMove:
xdotoolArguments = []string{
"mousemove",
strconv.Itoa(action.X),
strconv.Itoa(action.Y),
}
case environment.MouseDown:
xdotoolArguments = []string{"mousedown", strconv.Itoa(action.Button)}
case environment.MouseUp:
xdotoolArguments = []string{"mouseup", strconv.Itoa(action.Button)}
case environment.Wait:
if action.DurationMS < 0 {
return errors.New("wait duration cannot be negative")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(action.DurationMS) * time.Millisecond):
return nil
}
default:
return fmt.Errorf("unsupported input action %q", action.Type)
}
dockerArguments := []string{
"exec",
environmentBackend.containerName,
"xdotool",
}
dockerArguments = append(dockerArguments, xdotoolArguments...)
if _, err := environmentBackend.runDocker(ctx, dockerArguments...); err != nil {
return fmt.Errorf("send %s: %w", action.Type, err)
}
return nil
}
// toX11KeyName keeps X11 spellings out of the public action API. Agents can use
// logical names such as RIGHT even though xdotool expects Right.
func toX11KeyName(logicalName string) string {
switch strings.ToUpper(logicalName) {
case "LEFT":
return "Left"
case "RIGHT":
return "Right"
case "UP":
return "Up"
case "DOWN":
return "Down"
case "ENTER", "RETURN":
return "Return"
case "ESC", "ESCAPE":
return "Escape"
case "SPACE":
return "space"
case "TAB":
return "Tab"
case "BACKSPACE":
return "BackSpace"
case "DELETE":
return "Delete"
}
if len(logicalName) == 1 {
return strings.ToLower(logicalName)
}
return logicalName
}
@@ -0,0 +1,121 @@
package dockerx11
import (
"context"
"fmt"
"io"
"path/filepath"
"time"
)
func (environmentBackend *Environment) Start(ctx context.Context) error {
if environmentBackend.config.Output == nil {
environmentBackend.config.Output = io.Discard
}
if _, err := environmentBackend.runDocker(
ctx,
"version",
"--format",
"{{.Server.Version}}",
); err != nil {
return fmt.Errorf("Docker is required and the daemon must be accessible: %w", err)
}
fmt.Fprintln(environmentBackend.config.Output, "Creating environment...")
dockerfilePath := filepath.Join(
environmentBackend.config.ProjectRoot,
"environment",
"Dockerfile",
)
if err := environmentBackend.streamDockerOutput(
ctx,
"build",
"-q",
"-t",
runtimeImageName,
"-f",
dockerfilePath,
environmentBackend.config.ProjectRoot,
); err != nil {
return fmt.Errorf("build environment image: %w", err)
}
// These restrictions reduce accidental damage. They are defense in depth,
// not a safe boundary for hostile customer code; see docs/architecture.md.
_, err := environmentBackend.runDocker(
ctx,
"create",
"--name", environmentBackend.containerName,
"--init",
"--network=none",
"--read-only",
"--tmpfs=/tmp:rw,exec,nosuid,nodev,size=128m",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--pids-limit=128",
"--memory=512m",
"--cpus=1",
runtimeImageName,
)
if err != nil {
return fmt.Errorf("create environment: %w", err)
}
environmentBackend.containerCreated = true
if _, err := environmentBackend.runDocker(
ctx,
"start",
environmentBackend.containerName,
); err != nil {
return fmt.Errorf("start environment: %w", err)
}
// entrypoint.sh creates this file only after both Xvfb and Openbox accept
// requests. It is the readiness handshake between host and container.
if err := waitFor(ctx, 10*time.Second, func() bool {
_, readyErr := environmentBackend.runDocker(
ctx,
"exec",
environmentBackend.containerName,
"test",
"-f",
"/tmp/agentbox-ready",
)
return readyErr == nil
}); err != nil {
return fmt.Errorf("wait for graphical environment: %w", err)
}
return nil
}
// Stop is idempotent because both normal completion and deferred cleanup may
// try to tear down the same environment.
func (environmentBackend *Environment) Stop(ctx context.Context) error {
environmentBackend.stopMutex.Lock()
defer environmentBackend.stopMutex.Unlock()
if !environmentBackend.containerCreated || environmentBackend.containerStopped {
return nil
}
environmentBackend.containerStopped = true
fmt.Fprintln(environmentBackend.config.Output, "Shutting environment down...")
_, stopErr := environmentBackend.runDocker(
ctx,
"stop",
"--time=3",
environmentBackend.containerName,
)
_, removeErr := environmentBackend.runDocker(
ctx,
"rm",
"-f",
environmentBackend.containerName,
)
if stopErr != nil {
return fmt.Errorf("stop environment: %w", stopErr)
}
if removeErr != nil {
return fmt.Errorf("remove environment: %w", removeErr)
}
return nil
}
@@ -0,0 +1,56 @@
package environment
import (
"context"
"time"
)
// Command describes a host executable and how it should start in an
// environment. Environment implementations decide how to stage the file.
type Command struct {
Path string
Args []string
EnvironmentVariables map[string]string
WindowTitle string
}
// InputType identifies one backend-neutral keyboard, mouse, or timing action.
type InputType string
const (
KeyDown InputType = "key_down"
KeyUp InputType = "key_up"
MouseMove InputType = "mouse_move"
MouseDown InputType = "mouse_down"
MouseUp InputType = "mouse_up"
Wait InputType = "wait"
)
// InputAction contains only fields relevant to Type. DurationMS is used by Wait.
type InputAction struct {
Type InputType `json:"type"`
Key string `json:"key,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Button int `json:"button,omitempty"`
DurationMS int `json:"duration_ms,omitempty"`
}
// LogEntry is a cumulative snapshot of one application output stream. Time is
// when Agentbox captured the snapshot, not when the application emitted it.
type LogEntry struct {
Stream string `json:"stream"`
Message string `json:"message"`
Time time.Time `json:"time"`
}
// Environment is the programmable-computer boundary used by the runtime.
// Implementations may use containers, virtual machines, or remote hosts.
type Environment interface {
Start(context.Context) error
Launch(context.Context, Command) error
Screenshot(context.Context) ([]byte, error)
SendInput(context.Context, InputAction) error
Logs(context.Context) ([]LogEntry, error)
Stop(context.Context) error
}
+414
View File
@@ -0,0 +1,414 @@
package phase1
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image/png"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
const imageName = "agentbox-phase1:local"
type point struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type runRecord struct {
ID string `json:"id"`
Phase string `json:"phase"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
ContainerEngine string `json:"container_engine"`
Before *point `json:"square_before,omitempty"`
After *point `json:"square_after,omitempty"`
MovementPixels float64 `json:"movement_pixels,omitempty"`
Error string `json:"error,omitempty"`
}
type actionRecord struct {
Timestamp time.Time `json:"timestamp"`
Type string `json:"type"`
Key string `json:"key,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Button int `json:"button,omitempty"`
Duration string `json:"duration,omitempty"`
}
type runner struct {
root string
runDir string
containerName string
containerMade bool
actions *os.File
record runRecord
}
func Run(ctx context.Context) (runErr error) {
root, err := findRoot()
if err != nil {
return err
}
id, err := runID()
if err != nil {
return fmt.Errorf("create run ID: %w", err)
}
runDir := filepath.Join(root, ".agentbox", "runs", id)
if err := os.MkdirAll(filepath.Join(runDir, "screenshots"), 0o755); err != nil {
return fmt.Errorf("create run directory: %w", err)
}
actions, err := os.Create(filepath.Join(runDir, "actions.jsonl"))
if err != nil {
return fmt.Errorf("create action trace: %w", err)
}
r := &runner{
root: root,
runDir: runDir,
containerName: "agentbox-phase1-" + id,
actions: actions,
record: runRecord{
ID: id,
Phase: "environment-prototype",
Status: "running",
StartedAt: time.Now().UTC(),
},
}
defer func() {
cleanupErr := r.cleanup()
r.record.FinishedAt = time.Now().UTC()
if runErr == nil && cleanupErr != nil {
runErr = cleanupErr
}
if runErr != nil {
r.record.Status = "failed"
r.record.Error = runErr.Error()
} else {
r.record.Status = "passed"
}
if err := r.writeRecord(); err != nil && runErr == nil {
runErr = err
}
}()
fmt.Println("Checking container engine...")
version, err := r.docker(ctx, "version", "--format", "{{.Server.Version}}")
if err != nil {
return fmt.Errorf("Docker is required and the daemon must be accessible: %w", err)
}
r.record.ContainerEngine = "Docker " + strings.TrimSpace(string(version))
fmt.Println("Building Phase 1 environment...")
if err := r.dockerStream(ctx, "build", "-t", imageName, "-f",
filepath.Join(root, "environment", "Dockerfile"), root); err != nil {
return fmt.Errorf("build environment image: %w", err)
}
fmt.Println("Creating restricted graphical environment...")
_, err = r.docker(ctx,
"create",
"--name", r.containerName,
"--init",
"--network=none",
"--read-only",
"--tmpfs=/tmp:rw,nosuid,nodev,size=64m",
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--pids-limit=128",
"--memory=512m",
"--cpus=1",
imageName,
)
if err != nil {
return fmt.Errorf("create environment: %w", err)
}
r.containerMade = true
if _, err := r.docker(ctx, "start", r.containerName); err != nil {
return fmt.Errorf("start environment: %w", err)
}
if err := r.waitFor(ctx, 10*time.Second, func() bool {
_, readyErr := r.docker(ctx, "exec", r.containerName, "test", "-f", "/tmp/agentbox-ready")
return readyErr == nil
}); err != nil {
return fmt.Errorf("wait for graphical environment: %w", err)
}
fmt.Println("Launching graphical mover demo...")
if _, err := r.docker(ctx, "exec", "-d", r.containerName, "sh", "-c",
"exec /opt/agentbox/mover >/tmp/stdout.log 2>/tmp/stderr.log"); err != nil {
return fmt.Errorf("launch demo: %w", err)
}
var windowID string
if err := r.waitFor(ctx, 10*time.Second, func() bool {
output, searchErr := r.docker(ctx, "exec", r.containerName, "xdotool",
"search", "--name", "Agentbox Mover")
if searchErr != nil {
return false
}
windowID = strings.TrimSpace(strings.Split(string(output), "\n")[0])
return windowID != ""
}); err != nil {
return fmt.Errorf("wait for demo window: %w", err)
}
if _, err := r.docker(ctx, "exec", r.containerName, "xdotool",
"windowactivate", "--sync", windowID); err != nil {
return fmt.Errorf("focus demo window: %w", err)
}
beforePath := filepath.Join(r.runDir, "screenshots", "before.png")
fmt.Println("Capturing screenshot before input...")
if err := r.screenshot(ctx, "/tmp/before.png", beforePath); err != nil {
return err
}
before, err := squareCentroid(beforePath)
if err != nil {
return fmt.Errorf("inspect before screenshot: %w", err)
}
r.record.Before = &before
fmt.Println("Sending synthetic mouse input...")
if _, err := r.docker(ctx, "exec", r.containerName, "xdotool",
"mousemove", "--window", windowID, "100", "100", "mousedown", "1", "mouseup", "1"); err != nil {
return fmt.Errorf("send mouse input: %w", err)
}
if err := r.trace(actionRecord{
Timestamp: time.Now().UTC(), Type: "mouse_move", X: 100, Y: 100,
}); err != nil {
return err
}
if err := r.trace(actionRecord{
Timestamp: time.Now().UTC(), Type: "mouse_down", Button: 1,
}); err != nil {
return err
}
if err := r.trace(actionRecord{
Timestamp: time.Now().UTC(), Type: "mouse_up", Button: 1,
}); err != nil {
return err
}
fmt.Println("Holding RIGHT for one second...")
if _, err := r.docker(ctx, "exec", r.containerName, "xdotool", "keydown", "Right"); err != nil {
return fmt.Errorf("send key down: %w", err)
}
if err := r.trace(actionRecord{
Timestamp: time.Now().UTC(), Type: "key_down", Key: "RIGHT",
}); err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second):
}
if _, err := r.docker(ctx, "exec", r.containerName, "xdotool", "keyup", "Right"); err != nil {
return fmt.Errorf("send key up: %w", err)
}
if err := r.trace(actionRecord{
Timestamp: time.Now().UTC(), Type: "key_up", Key: "RIGHT", Duration: "1s",
}); err != nil {
return err
}
time.Sleep(100 * time.Millisecond)
afterPath := filepath.Join(r.runDir, "screenshots", "after.png")
fmt.Println("Capturing screenshot after input...")
if err := r.screenshot(ctx, "/tmp/after.png", afterPath); err != nil {
return err
}
after, err := squareCentroid(afterPath)
if err != nil {
return fmt.Errorf("inspect after screenshot: %w", err)
}
r.record.After = &after
r.record.MovementPixels = after.X - before.X
if r.record.MovementPixels < 100 {
return fmt.Errorf("visual verification failed: square moved %.1f pixels right, want at least 100", r.record.MovementPixels)
}
fmt.Printf("Verified: square moved %.1f pixels to the right.\n", r.record.MovementPixels)
fmt.Printf("Phase 1 passed. Artifacts: %s\n", runDir)
return nil
}
func findRoot() (string, error) {
current, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
for {
data, readErr := os.ReadFile(filepath.Join(current, "go.mod"))
if readErr == nil && bytes.Contains(data, []byte("module agentbox")) {
return current, nil
}
parent := filepath.Dir(current)
if parent == current {
return "", errors.New("run from the agentbox module directory")
}
current = parent
}
}
func runID() (string, error) {
random := make([]byte, 3)
if _, err := rand.Read(random); err != nil {
return "", err
}
return time.Now().UTC().Format("20060102T150405") + "-" + hex.EncodeToString(random), nil
}
func (r *runner) waitFor(ctx context.Context, timeout time.Duration, check func() bool) error {
timer := time.NewTimer(timeout)
defer timer.Stop()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
if check() {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return errors.New("timed out")
case <-ticker.C:
}
}
}
func (r *runner) screenshot(ctx context.Context, containerPath, hostPath string) error {
if _, err := r.docker(ctx, "exec", r.containerName, "scrot", "-o", containerPath); err != nil {
return fmt.Errorf("capture screenshot: %w", err)
}
if err := r.extract(ctx, containerPath, hostPath); err != nil {
return fmt.Errorf("extract screenshot: %w", err)
}
return nil
}
func (r *runner) extract(ctx context.Context, containerPath, hostPath string) error {
data, err := r.docker(ctx, "exec", r.containerName, "cat", containerPath)
if err != nil {
return err
}
if err := os.WriteFile(hostPath, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", hostPath, err)
}
return nil
}
func squareCentroid(path string) (point, error) {
file, err := os.Open(path)
if err != nil {
return point{}, err
}
defer file.Close()
img, err := png.Decode(file)
if err != nil {
return point{}, err
}
var sumX, sumY, count uint64
bounds := img.Bounds()
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
red, green, blue, _ := img.At(x, y).RGBA()
// Match the demo square's #00ff66 color with enough tolerance for
// image conversion while rejecting the black background.
if green > 0xc000 && red < 0x4000 && blue < 0x8000 {
sumX += uint64(x)
sumY += uint64(y)
count++
}
}
}
if count < 1000 {
return point{}, fmt.Errorf("found only %d green square pixels", count)
}
return point{
X: float64(sumX) / float64(count),
Y: float64(sumY) / float64(count),
}, nil
}
func (r *runner) trace(action actionRecord) error {
encoded, err := json.Marshal(action)
if err != nil {
return fmt.Errorf("encode action: %w", err)
}
if _, err := r.actions.Write(append(encoded, '\n')); err != nil {
return fmt.Errorf("write action trace: %w", err)
}
return nil
}
func (r *runner) cleanup() error {
if r.actions != nil {
_ = r.actions.Close()
}
if !r.containerMade {
return nil
}
fmt.Println("Stopping environment...")
stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, name := range []string{"stdout.log", "stderr.log"} {
_ = r.extract(stopCtx, "/tmp/"+name, filepath.Join(r.runDir, name))
}
_, stopErr := r.docker(stopCtx, "stop", "--time=3", r.containerName)
_, removeErr := r.docker(stopCtx, "rm", "-f", r.containerName)
if stopErr != nil {
return fmt.Errorf("stop environment: %w", stopErr)
}
if removeErr != nil {
return fmt.Errorf("remove environment: %w", removeErr)
}
fmt.Println("Environment stopped cleanly.")
return nil
}
func (r *runner) writeRecord() error {
data, err := json.MarshalIndent(r.record, "", " ")
if err != nil {
return fmt.Errorf("encode run record: %w", err)
}
data = append(data, '\n')
if err := os.WriteFile(filepath.Join(r.runDir, "run.json"), data, 0o644); err != nil {
return fmt.Errorf("write run record: %w", err)
}
return nil
}
func (r *runner) docker(ctx context.Context, args ...string) ([]byte, error) {
command := exec.CommandContext(ctx, "docker", args...)
output, err := command.CombinedOutput()
if err != nil {
return output, fmt.Errorf("docker %s: %w: %s", args[0], err, strings.TrimSpace(string(output)))
}
return output, nil
}
func (r *runner) dockerStream(ctx context.Context, args ...string) error {
command := exec.CommandContext(ctx, "docker", args...)
command.Stdout = os.Stdout
command.Stderr = os.Stderr
if err := command.Run(); err != nil {
return fmt.Errorf("docker %s: %w", strconv.Quote(strings.Join(args, " ")), err)
}
return nil
}
+54
View File
@@ -0,0 +1,54 @@
package phase1
import (
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
)
func TestSquareCentroid(t *testing.T) {
path := filepath.Join(t.TempDir(), "screenshot.png")
img := image.NewRGBA(image.Rect(0, 0, 200, 100))
green := color.RGBA{R: 0, G: 255, B: 102, A: 255}
for y := 20; y < 60; y++ {
for x := 80; x < 120; x++ {
img.Set(x, y, green)
}
}
writePNG(t, path, img)
got, err := squareCentroid(path)
if err != nil {
t.Fatalf("squareCentroid() error = %v", err)
}
if got.X != 99.5 || got.Y != 39.5 {
t.Fatalf("squareCentroid() = (%.1f, %.1f), want (99.5, 39.5)", got.X, got.Y)
}
}
func TestSquareCentroidRejectsMissingSquare(t *testing.T) {
path := filepath.Join(t.TempDir(), "empty.png")
writePNG(t, path, image.NewRGBA(image.Rect(0, 0, 200, 100)))
if _, err := squareCentroid(path); err == nil {
t.Fatal("squareCentroid() error = nil, want missing-square error")
}
}
func writePNG(t *testing.T, path string, img image.Image) {
t.Helper()
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := png.Encode(file, img); err != nil {
_ = file.Close()
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
}
+182
View File
@@ -0,0 +1,182 @@
package runtime
import (
"context"
"errors"
"fmt"
"io"
"time"
"agentbox/internal/agent"
"agentbox/internal/environment"
"agentbox/internal/trace"
)
// Config wires replaceable environment, agent, and trace implementations into
// one run. The runtime itself has no Docker, X11, or model-provider knowledge.
type Config struct {
Task string
Command environment.Command
Controller agent.Agent
Environment environment.Environment
TraceStore *trace.Store
MaxSteps int
Output io.Writer
}
func Run(ctx context.Context, config Config) (runErr error) {
if config.MaxSteps <= 0 {
config.MaxSteps = 20
}
if config.Output == nil {
config.Output = io.Discard
}
if config.Controller == nil || config.Environment == nil || config.TraceStore == nil {
return errors.New("runtime requires agent, environment, and trace")
}
defer func() {
runErr = finalizeRun(config, runErr)
}()
if err := config.Environment.Start(ctx); err != nil {
return err
}
if err := config.Environment.Launch(ctx, config.Command); err != nil {
return err
}
fmt.Fprintln(config.Output, "Agent loop started.")
var stepHistory []agent.Step
var actionHistory []environment.InputAction
runStartedAt := time.Now()
for stepNumber := 1; stepNumber <= config.MaxSteps; stepNumber++ {
screenshot, err := config.Environment.Screenshot(ctx)
if err != nil {
return err
}
screenshotPath, err := config.TraceStore.SaveScreenshot(stepNumber, screenshot)
if err != nil {
return err
}
applicationLogs, err := config.Environment.Logs(ctx)
if err != nil {
return err
}
observedAt := time.Now().UTC()
previousActions := append([]environment.InputAction(nil), actionHistory...)
observation := agent.Observation{
Screenshot: screenshot,
Timestamp: observedAt,
Logs: applicationLogs,
PreviousActions: previousActions,
}
fmt.Fprintf(config.Output, "[%s] screenshot captured\n", elapsed(runStartedAt))
decision, err := config.Controller.NextAction(
ctx,
config.Task,
stepHistory,
observation,
)
if err != nil {
return fmt.Errorf("agent next action: %w", err)
}
fmt.Fprintf(
config.Output,
"[%s] agent: %s\n",
elapsed(runStartedAt),
decision.Reason,
)
if err := config.TraceStore.Record(trace.StepRecord{
Step: stepNumber,
Timestamp: observedAt,
Observation: trace.ObservationRecord{
Screenshot: screenshotPath,
Logs: applicationLogs,
PreviousActions: previousActions,
},
Agent: trace.AgentRecord{Message: decision.Reason},
Action: decision.Action,
Done: decision.Done,
}); err != nil {
return err
}
stepHistory = append(stepHistory, agent.Step{
Number: stepNumber,
Timestamp: observedAt,
ScreenshotPath: screenshotPath,
Message: decision.Reason,
Action: decision.Action,
})
if decision.Done {
fmt.Fprintln(config.Output, "Task complete.")
return nil
}
if decision.Action == nil {
return errors.New("agent returned neither action nor completion")
}
fmt.Fprintf(
config.Output,
"[%s] action: %s%s\n",
elapsed(runStartedAt),
decision.Action.Type,
actionDetail(*decision.Action),
)
if err := config.Environment.SendInput(ctx, *decision.Action); err != nil {
return err
}
actionHistory = append(actionHistory, *decision.Action)
}
return fmt.Errorf("agent exceeded maximum of %d steps", config.MaxSteps)
}
// finalizeRun uses a fresh timeout because the caller's context may already be
// canceled. Preserving logs and removing the environment must still be tried.
func finalizeRun(
config Config,
runErr error,
) error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if logs, err := config.Environment.Logs(cleanupCtx); err == nil {
if logErr := config.TraceStore.WriteLogs(logs); runErr == nil && logErr != nil {
runErr = logErr
}
}
// Stop is idempotent, so always call it. Start may have created a container
// before returning an error while waiting for its graphical services.
if stopErr := config.Environment.Stop(cleanupCtx); runErr == nil && stopErr != nil {
runErr = stopErr
}
if finishErr := config.TraceStore.Finish(runErr); runErr == nil && finishErr != nil {
runErr = finishErr
}
return runErr
}
func elapsed(start time.Time) string {
duration := time.Since(start).Round(time.Second)
return fmt.Sprintf(
"%02d:%02d",
int(duration.Minutes()),
int(duration.Seconds())%60,
)
}
func actionDetail(action environment.InputAction) string {
switch action.Type {
case environment.KeyDown, environment.KeyUp:
return " " + action.Key
case environment.MouseMove:
return fmt.Sprintf(" %d,%d", action.X, action.Y)
case environment.MouseDown, environment.MouseUp:
return fmt.Sprintf(" %d", action.Button)
case environment.Wait:
return fmt.Sprintf(" %dms", action.DurationMS)
default:
return ""
}
}
+63
View File
@@ -0,0 +1,63 @@
package runtime
import (
"context"
"errors"
"io"
"testing"
"agentbox/internal/agent"
"agentbox/internal/environment"
"agentbox/internal/trace"
)
type failingStartEnvironment struct {
stopCalled bool
}
func (fake *failingStartEnvironment) Start(context.Context) error {
return errors.New("startup failed")
}
func (fake *failingStartEnvironment) Launch(context.Context, environment.Command) error {
return nil
}
func (fake *failingStartEnvironment) Screenshot(context.Context) ([]byte, error) {
return nil, nil
}
func (fake *failingStartEnvironment) SendInput(context.Context, environment.InputAction) error {
return nil
}
func (fake *failingStartEnvironment) Logs(context.Context) ([]environment.LogEntry, error) {
return nil, nil
}
func (fake *failingStartEnvironment) Stop(context.Context) error {
fake.stopCalled = true
return nil
}
func TestRunStopsEnvironmentWhenStartFails(t *testing.T) {
traceStore, err := trace.New(t.TempDir(), "task", "application", "test-agent")
if err != nil {
t.Fatal(err)
}
fakeEnvironment := &failingStartEnvironment{}
err = Run(context.Background(), Config{
Task: "task",
Controller: &agent.Deterministic{},
Environment: fakeEnvironment,
TraceStore: traceStore,
Output: io.Discard,
})
if err == nil {
t.Fatal("Run() error = nil, want startup error")
}
if !fakeEnvironment.stopCalled {
t.Fatal("Run() did not stop environment after startup failure")
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package trace stores durable run records. steps.jsonl contains complete
// observations and decisions, actions.jsonl is an action-only view, and PNG
// screenshots remain separate files referenced by relative paths.
package trace
+97
View File
@@ -0,0 +1,97 @@
package trace
import (
"bufio"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
)
func List(projectRoot string) ([]Run, error) {
runsDirectory := filepath.Join(projectRoot, ".agentbox", "runs")
directoryEntries, err := os.ReadDir(runsDirectory)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var runs []Run
for _, entry := range directoryEntries {
if !entry.IsDir() {
continue
}
run, err := Read(projectRoot, entry.Name())
// Old or malformed trace directories are intentionally hidden rather
// than making the entire run listing fail.
if err == nil && run.SchemaVersion == SchemaVersion {
runs = append(runs, run)
}
}
sort.Slice(runs, func(left, right int) bool {
return runs[left].StartedAt.After(runs[right].StartedAt)
})
return runs, nil
}
func Read(projectRoot, runID string) (Run, error) {
if err := validateRunID(runID); err != nil {
return Run{}, err
}
runPath := filepath.Join(
projectRoot,
".agentbox",
"runs",
runID,
"run.json",
)
data, err := os.ReadFile(runPath)
if err != nil {
return Run{}, err
}
var run Run
if err := json.Unmarshal(data, &run); err != nil {
return Run{}, err
}
return run, nil
}
func ReadSteps(projectRoot, runID string) ([]StepRecord, error) {
if err := validateRunID(runID); err != nil {
return nil, err
}
stepsPath := filepath.Join(
projectRoot,
".agentbox",
"runs",
runID,
"steps.jsonl",
)
stepsFile, err := os.Open(stepsPath)
if err != nil {
return nil, err
}
defer stepsFile.Close()
var steps []StepRecord
scanner := bufio.NewScanner(stepsFile)
for scanner.Scan() {
var step StepRecord
if err := json.Unmarshal(scanner.Bytes(), &step); err != nil {
return nil, err
}
steps = append(steps, step)
}
return steps, scanner.Err()
}
func validateRunID(runID string) error {
// Run IDs become path components, so reject separators and traversal.
if filepath.Base(runID) != runID {
return errors.New("invalid run ID")
}
return nil
}
+180
View File
@@ -0,0 +1,180 @@
package trace
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"agentbox/internal/environment"
)
// Store writes one run's metadata and append-only event streams.
type Store struct {
runDirectory string
run Run
stepsFile *os.File
actionsFile *os.File
}
func New(projectRoot, task, application, agentName string) (*Store, error) {
runID, err := newRunID()
if err != nil {
return nil, err
}
runDirectory := filepath.Join(projectRoot, ".agentbox", "runs", runID)
screenshotDirectory := filepath.Join(runDirectory, "screenshots")
if err := os.MkdirAll(screenshotDirectory, 0o755); err != nil {
return nil, fmt.Errorf("create run directory: %w", err)
}
stepsFile, err := os.Create(filepath.Join(runDirectory, "steps.jsonl"))
if err != nil {
return nil, fmt.Errorf("create step trace: %w", err)
}
actionsFile, err := os.Create(filepath.Join(runDirectory, "actions.jsonl"))
if err != nil {
_ = stepsFile.Close()
return nil, fmt.Errorf("create action trace: %w", err)
}
store := &Store{
runDirectory: runDirectory,
stepsFile: stepsFile,
actionsFile: actionsFile,
run: Run{
SchemaVersion: SchemaVersion,
ID: runID,
Task: task,
Application: application,
Agent: agentName,
Status: "running",
StartedAt: time.Now().UTC(),
},
}
if err := store.writeRunSummary(); err != nil {
_ = stepsFile.Close()
_ = actionsFile.Close()
return nil, err
}
return store, nil
}
func (store *Store) ID() string {
return store.run.ID
}
func (store *Store) Directory() string {
return store.runDirectory
}
func (store *Store) SaveScreenshot(stepNumber int, screenshot []byte) (string, error) {
relativePath := filepath.Join(
"screenshots",
fmt.Sprintf("%04d.png", stepNumber),
)
absolutePath := filepath.Join(store.runDirectory, relativePath)
if err := os.WriteFile(absolutePath, screenshot, 0o644); err != nil {
return "", fmt.Errorf("write screenshot: %w", err)
}
// Trace paths always use slash separators so traces are portable.
return filepath.ToSlash(relativePath), nil
}
func (store *Store) Record(step StepRecord) error {
if err := appendJSONLine(store.stepsFile, step); err != nil {
return fmt.Errorf("record step: %w", err)
}
if step.Action != nil {
action := actionRecord{
Step: step.Step,
Timestamp: step.Timestamp,
Action: *step.Action,
}
if err := appendJSONLine(store.actionsFile, action); err != nil {
return fmt.Errorf("record action: %w", err)
}
}
store.run.StepCount = step.Step
return store.writeRunSummary()
}
func (store *Store) WriteLogs(logEntries []environment.LogEntry) error {
var standardOutput, standardError string
for _, entry := range logEntries {
switch entry.Stream {
case "stdout":
standardOutput = entry.Message
case "stderr":
standardError = entry.Message
}
}
if err := os.WriteFile(
filepath.Join(store.runDirectory, "stdout.log"),
[]byte(standardOutput),
0o644,
); err != nil {
return err
}
return os.WriteFile(
filepath.Join(store.runDirectory, "stderr.log"),
[]byte(standardError),
0o644,
)
}
func (store *Store) Finish(runErr error) error {
store.closeEventFiles()
store.run.FinishedAt = time.Now().UTC()
if runErr != nil {
store.run.Status = "failed"
store.run.Error = runErr.Error()
} else {
store.run.Status = "complete"
}
return store.writeRunSummary()
}
func (store *Store) closeEventFiles() {
if store.stepsFile != nil {
_ = store.stepsFile.Close()
store.stepsFile = nil
}
if store.actionsFile != nil {
_ = store.actionsFile.Close()
store.actionsFile = nil
}
}
// Sync each JSONL record so an interrupted run retains its latest complete
// decision. Performance is secondary to debuggability in this local spike.
func appendJSONLine(file *os.File, value any) error {
data, err := json.Marshal(value)
if err != nil {
return err
}
if _, err := file.Write(append(data, '\n')); err != nil {
return err
}
return file.Sync()
}
func (store *Store) writeRunSummary() error {
data, err := json.MarshalIndent(store.run, "", " ")
if err != nil {
return err
}
runPath := filepath.Join(store.runDirectory, "run.json")
return os.WriteFile(runPath, append(data, '\n'), 0o644)
}
func newRunID() (string, error) {
randomSuffix := make([]byte, 3)
if _, err := rand.Read(randomSuffix); err != nil {
return "", err
}
timestamp := time.Now().UTC().Format("20060102T150405")
return timestamp + "-" + hex.EncodeToString(randomSuffix), nil
}
+50
View File
@@ -0,0 +1,50 @@
package trace
import (
"time"
"agentbox/internal/environment"
)
// SchemaVersion changes only when existing trace readers need new logic.
const SchemaVersion = "1"
// Run is the summary stored in run.json.
type Run struct {
SchemaVersion string `json:"schema_version"`
ID string `json:"id"`
Task string `json:"task"`
Application string `json:"application"`
Agent string `json:"agent"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
StepCount int `json:"step_count"`
Error string `json:"error,omitempty"`
}
type ObservationRecord struct {
Screenshot string `json:"screenshot"`
Logs []environment.LogEntry `json:"logs,omitempty"`
PreviousActions []environment.InputAction `json:"previous_actions,omitempty"`
}
type AgentRecord struct {
Message string `json:"message"`
}
// StepRecord is one append-only line in steps.jsonl.
type StepRecord struct {
Step int `json:"step"`
Timestamp time.Time `json:"timestamp"`
Observation ObservationRecord `json:"observation"`
Agent AgentRecord `json:"agent"`
Action *environment.InputAction `json:"action,omitempty"`
Done bool `json:"done,omitempty"`
}
type actionRecord struct {
Step int `json:"step"`
Timestamp time.Time `json:"timestamp"`
Action environment.InputAction `json:"action"`
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
image=agentbox-runtime:local
output="$root/examples/mover/mover"
temporary="$output.tmp"
cleanup() {
rm -f "$temporary"
}
trap cleanup EXIT INT TERM
docker build -q -t "$image" -f "$root/environment/Dockerfile" "$root" >/dev/null
docker run --rm --network=none --entrypoint cat "$image" /opt/agentbox/mover >"$temporary"
chmod 0755 "$temporary"
mv "$temporary" "$output"
+57
View File
@@ -118,6 +118,63 @@ set_sprite_clip_switch_and_guards :: proc(t: ^testing.T) {
testing.expect_value(t, sprite.time, f32(0.09))
}
@(test)
set_sprite_clip_caches_clip_def :: proc(t: ^testing.T) {
data := make_test_character()
defer destroy_test_character(&data)
sprite := spawn_sprite(&data, {}, "idle", 0)
testing.expect(t, sprite.has_clip, "spawn should cache a valid clip")
testing.expect_value(t, sprite.clip, "idle")
testing.expect(t, sprite.clip_def.loop, "idle clip loops")
testing.expect_value(t, sprite.clip_def.fps, f32(10))
testing.expect_value(t, len(sprite.clip_def.frames), 3)
set_sprite_clip(&sprite, "once")
testing.expect(t, sprite.has_clip, "switch should refresh cache")
testing.expect_value(t, sprite.clip, "once")
testing.expect(t, !sprite.clip_def.loop, "once clip does not loop")
testing.expect_value(t, sprite.clip_def.fps, f32(10))
testing.expect_value(t, len(sprite.clip_def.frames), 3)
set_sprite_clip(&sprite, "nope")
testing.expect(t, sprite.has_clip, "bad clip must leave cache intact")
testing.expect_value(t, sprite.clip, "once")
testing.expect(t, !sprite.clip_def.loop, "cached once clip preserved")
testing.expect_value(t, len(sprite.clip_def.frames), 3)
}
@(test)
set_sprite_clip_def_applies_and_guards :: proc(t: ^testing.T) {
data := make_test_character()
defer destroy_test_character(&data)
sprite := spawn_sprite(&data, {}, "idle", 0)
sprite.frame = 2
sprite.time = 0.05
once := data.def.clips["once"]
set_sprite_clip_def(&sprite, "once", once)
testing.expect_value(t, sprite.clip, "once")
testing.expect(t, sprite.has_clip, "def apply should mark clip present")
testing.expect_value(t, sprite.frame, 0)
testing.expect_value(t, sprite.time, f32(0))
testing.expect(t, !sprite.clip_def.loop, "once clip does not loop")
testing.expect_value(t, len(sprite.clip_def.frames), 3)
sprite.frame = 1
sprite.time = 0.09
set_sprite_clip_def(&sprite, "once", once)
testing.expect_value(t, sprite.frame, 1)
testing.expect_value(t, sprite.time, f32(0.09))
set_sprite_clip_def(&sprite, "empty", Clip_Def{loop = true, fps = 10, frames = nil})
testing.expect_value(t, sprite.clip, "once")
testing.expect_value(t, sprite.frame, 1)
set_sprite_clip_def(nil, "once", once)
}
@(test)
spawn_sprite_valid_and_invalid :: proc(t: ^testing.T) {
data := make_test_character()
+137 -12
View File
@@ -16,8 +16,15 @@ SPRITE_VERTS_SIZE :: SPRITE_VERT_COUNT * size_of(Vertex)
VERTEX_BUFFER_SIZE :: MAX_SPRITES * SPRITE_VERTS_SIZE
Queued_Sprite :: struct {
texture: ^sdl.GPUTexture,
verts: [SPRITE_VERT_COUNT]Vertex,
batch_group: u32, // 0 = strict order; nonzero = caller permits regrouping
}
Draw_Batch :: struct {
start: int,
count: int,
texture: ^sdl.GPUTexture,
verts: [SPRITE_VERT_COUNT]Vertex,
}
App :: struct {
@@ -65,7 +72,7 @@ init :: proc(app: ^App, title: cstring, width, height: i32) -> bool {
}
requested: sdl.GPUShaderFormat = {.SPIRV, .DXIL, .MSL}
app.device = sdl.CreateGPUDevice(requested, true, nil)
app.device = sdl.CreateGPUDevice(requested, false, nil)
if app.device == nil {
fmt.eprintfln("CreateGPUDevice failed: %s", sdl.GetError())
return false
@@ -76,6 +83,10 @@ init :: proc(app: ^App, title: cstring, width, height: i32) -> bool {
return false
}
if !sdl.SetGPUAllowedFramesInFlight(app.device, 3) {
fmt.eprintfln("SetGPUAllowedFramesInFlight failed: %s", sdl.GetError())
}
// Prefer uncapped present for profiling; fall back if unsupported.
present := sdl.GPUPresentMode.VSYNC
if sdl.WindowSupportsGPUPresentMode(app.device, app.window, .IMMEDIATE) {
@@ -242,7 +253,11 @@ end_frame :: proc(app: ^App) {
}
n := len(app.draw_list)
batches: [dynamic]Draw_Batch
defer delete(batches)
if n > 0 {
prepare_draw_batches(app.draw_list[:], &batches)
map_ptr := sdl.MapGPUTransferBuffer(app.device, app.transfer_buffer, false)
if map_ptr == nil {
fmt.eprintfln("MapGPUTransferBuffer failed: %s", sdl.GetError())
@@ -286,26 +301,20 @@ end_frame :: proc(app: ^App) {
app.render_pass = sdl.BeginGPURenderPass(cmd, &color_info, 1, nil)
sdl.BindGPUGraphicsPipeline(app.render_pass, app.pipeline)
i := 0
for i < n {
run := texture_run_len(app.draw_list[:], i)
q0 := app.draw_list[i]
for batch in batches {
sampler_binding := sdl.GPUTextureSamplerBinding {
texture = q0.texture,
texture = batch.texture,
sampler = app.sampler,
}
sdl.BindGPUFragmentSamplers(app.render_pass, 0, &sampler_binding, 1)
vb_binding := sdl.GPUBufferBinding {
buffer = app.vertex_buffer,
offset = u32(i * SPRITE_VERTS_SIZE),
offset = u32(batch.start * SPRITE_VERTS_SIZE),
}
sdl.BindGPUVertexBuffers(app.render_pass, 0, &vb_binding, 1)
sdl.DrawGPUPrimitives(app.render_pass, u32(run * SPRITE_VERT_COUNT), 1, 0, 0)
i += run
sdl.DrawGPUPrimitives(app.render_pass, u32(batch.count * SPRITE_VERT_COUNT), 1, 0, 0)
}
sdl.EndGPURenderPass(app.render_pass)
app.render_pass = nil
@@ -454,3 +463,119 @@ texture_run_len :: proc(list: []Queued_Sprite, start: int) -> int {
return n
}
texture_run_count :: proc(list: []Queued_Sprite) -> int {
if len(list) == 0 do return 0
count := 0
i := 0
for i < len(list) {
run := texture_run_len(list, i)
count += 1
i += run
}
return count
}
prepare_draw_batches :: proc(list: []Queued_Sprite, batches: ^[dynamic]Draw_Batch) {
clear(batches)
group_texture_runs(list)
i := 0
for i < len(list) {
run := texture_run_len(list, i)
append(batches, Draw_Batch{start = i, count = run, texture = list[i].texture})
i += run
}
}
// Within each contiguous nonzero batch_group, stably sort by texture so
// consecutive same-texture sprites become one draw. Group 0 and group
// boundaries are never crossed.
group_texture_runs :: proc(list: []Queued_Sprite) {
start := 0
for start < len(list) {
group := list[start].batch_group
if group == 0 {
start += 1
continue
}
end := start + 1
for end < len(list) && list[end].batch_group == group {
end += 1
}
stable_group_by_texture(list[start:end])
start = end
}
}
// Stably orders window by texture pointer. Fast path for 12 textures
// (common); insertion sort for 3+.
stable_group_by_texture :: proc(window: []Queued_Sprite) {
n := len(window)
if n <= 1 do return
already := true
for i in 1 ..< n {
if uintptr(window[i].texture) < uintptr(window[i - 1].texture) {
already = false
break
}
}
if already do return
first := uintptr(window[0].texture)
second: uintptr
has_second := false
third := false
for i in 1 ..< n {
t := uintptr(window[i].texture)
if t == first do continue
if !has_second {
second = t
has_second = true
continue
}
if t != second {
third = true
break
}
}
if !has_second do return
if third {
for i in 1 ..< n {
item := window[i]
j := i
for j > 0 {
if uintptr(window[j - 1].texture) <= uintptr(item.texture) {
break
}
window[j] = window[j - 1]
j -= 1
}
window[j] = item
}
return
}
lo, hi := first, second
if lo > hi do lo, hi = hi, lo
tmp: [MAX_SPRITES]Queued_Sprite
w := 0
for q in window {
if uintptr(q.texture) == lo {
tmp[w] = q
w += 1
}
}
for q in window {
if uintptr(q.texture) == hi {
tmp[w] = q
w += 1
}
}
copy(window, tmp[:w])
}
+365
View File
@@ -7,6 +7,18 @@ fake_tex :: proc(id: uintptr) -> ^sdl.GPUTexture {
return cast(^sdl.GPUTexture)id
}
queued :: proc(tex_id: uintptr, group: u32, marker: f32) -> Queued_Sprite {
q: Queued_Sprite
q.texture = fake_tex(tex_id)
q.batch_group = group
q.verts[0].pos = {marker, 0}
return q
}
marker_of :: proc(q: Queued_Sprite) -> f32 {
return q.verts[0].pos.x
}
@(test)
texture_run_len_empty_or_oob :: proc(t: ^testing.T) {
testing.expect_value(t, texture_run_len(nil, 0), 0)
@@ -62,3 +74,356 @@ texture_run_len_all_different :: proc(t: ^testing.T) {
testing.expect_value(t, texture_run_len(list, 1), 1)
testing.expect_value(t, texture_run_len(list, 2), 1)
}
@(test)
group_texture_runs_strict_order_unchanged :: proc(t: ^testing.T) {
// Group 0 alternates textures; sorting must not reorder (alpha order).
list := []Queued_Sprite {
queued(2, 0, 1),
queued(1, 0, 2),
queued(2, 0, 3),
queued(1, 0, 4),
}
before_runs := texture_run_count(list)
group_texture_runs(list)
testing.expect_value(t, texture_run_count(list), before_runs)
testing.expect_value(t, marker_of(list[0]), f32(1))
testing.expect_value(t, marker_of(list[1]), f32(2))
testing.expect_value(t, marker_of(list[2]), f32(3))
testing.expect_value(t, marker_of(list[3]), f32(4))
}
@(test)
group_texture_runs_reduces_runs_inside_group :: proc(t: ^testing.T) {
list := []Queued_Sprite {
queued(2, 1, 1),
queued(1, 1, 2),
queued(2, 1, 3),
queued(1, 1, 4),
}
testing.expect_value(t, texture_run_count(list), 4)
group_texture_runs(list)
testing.expect_value(t, texture_run_count(list), 2)
testing.expect(t, list[0].texture == fake_tex(1), "lower texture pointer first")
testing.expect(t, list[1].texture == fake_tex(1), "same texture run")
testing.expect(t, list[2].texture == fake_tex(2), "second texture run")
testing.expect(t, list[3].texture == fake_tex(2), "second texture run cont")
}
@(test)
group_texture_runs_stable_same_texture :: proc(t: ^testing.T) {
list := []Queued_Sprite {
queued(2, 1, 10),
queued(1, 1, 20),
queued(2, 1, 30),
queued(1, 1, 40),
}
group_texture_runs(list)
// Same-texture relative order preserved (stable sort).
testing.expect_value(t, marker_of(list[0]), f32(20))
testing.expect_value(t, marker_of(list[1]), f32(40))
testing.expect_value(t, marker_of(list[2]), f32(10))
testing.expect_value(t, marker_of(list[3]), f32(30))
}
@(test)
group_texture_runs_respects_group_boundaries :: proc(t: ^testing.T) {
list := []Queued_Sprite {
queued(2, 1, 1),
queued(1, 1, 2),
queued(2, 0, 3), // strict barrier
queued(1, 2, 4),
queued(2, 2, 5),
}
group_texture_runs(list)
testing.expect_value(t, marker_of(list[2]), f32(3)) // barrier stays put
testing.expect(t, list[0].texture == fake_tex(1))
testing.expect(t, list[1].texture == fake_tex(2))
testing.expect(t, list[3].texture == fake_tex(1))
testing.expect(t, list[4].texture == fake_tex(2))
testing.expect_value(t, list[0].batch_group, u32(1))
testing.expect_value(t, list[1].batch_group, u32(1))
testing.expect_value(t, list[2].batch_group, u32(0))
testing.expect_value(t, list[3].batch_group, u32(2))
testing.expect_value(t, list[4].batch_group, u32(2))
}
@(test)
group_texture_runs_does_not_merge_across_different_groups :: proc(t: ^testing.T) {
// Adjacent nonzero groups with different ids must not merge runs across.
list := []Queued_Sprite {
queued(1, 1, 1),
queued(2, 1, 2),
queued(1, 2, 3),
queued(2, 2, 4),
}
group_texture_runs(list)
testing.expect_value(t, texture_run_count(list), 4)
testing.expect_value(t, list[0].batch_group, u32(1))
testing.expect_value(t, list[1].batch_group, u32(1))
testing.expect_value(t, list[2].batch_group, u32(2))
testing.expect_value(t, list[3].batch_group, u32(2))
}
@(test)
group_texture_runs_empty :: proc(t: ^testing.T) {
list := []Queued_Sprite{}
testing.expect_value(t, texture_run_count(list), 0)
group_texture_runs(list)
testing.expect_value(t, texture_run_count(list), 0)
}
@(test)
group_texture_runs_already_optimal :: proc(t: ^testing.T) {
list := []Queued_Sprite {
queued(1, 1, 10),
queued(1, 1, 20),
queued(2, 1, 30),
queued(2, 1, 40),
}
testing.expect_value(t, texture_run_count(list), 2)
group_texture_runs(list)
testing.expect_value(t, texture_run_count(list), 2)
testing.expect_value(t, marker_of(list[0]), f32(10))
testing.expect_value(t, marker_of(list[1]), f32(20))
testing.expect_value(t, marker_of(list[2]), f32(30))
testing.expect_value(t, marker_of(list[3]), f32(40))
}
@(test)
group_texture_runs_alternating_many_stable :: proc(t: ^testing.T) {
// Characterization: large alternating group must collapse to two runs
// while preserving same-texture submission order (markers).
N :: 64
list := make([]Queued_Sprite, N)
defer delete(list)
for i in 0 ..< N {
tex: uintptr = 2 if (i % 2) == 0 else 1
list[i] = queued(tex, 1, f32(i + 1))
}
testing.expect_value(t, texture_run_count(list), N)
group_texture_runs(list)
testing.expect_value(t, texture_run_count(list), 2)
testing.expect(t, list[0].texture == fake_tex(1))
testing.expect(t, list[N / 2 - 1].texture == fake_tex(1))
testing.expect(t, list[N / 2].texture == fake_tex(2))
testing.expect(t, list[N - 1].texture == fake_tex(2))
for i in 0 ..< N / 2 {
testing.expect_value(t, marker_of(list[i]), f32(2 * i + 2)) // odd markers: 2,4,...,N
testing.expect_value(t, marker_of(list[N / 2 + i]), f32(2 * i + 1)) // even markers: 1,3,...,N-1
}
}
@(test)
group_texture_runs_three_textures_stable :: proc(t: ^testing.T) {
list := []Queued_Sprite {
queued(3, 1, 1),
queued(1, 1, 2),
queued(2, 1, 3),
queued(3, 1, 4),
queued(1, 1, 5),
queued(2, 1, 6),
}
testing.expect_value(t, texture_run_count(list), 6)
group_texture_runs(list)
testing.expect_value(t, texture_run_count(list), 3)
testing.expect_value(t, marker_of(list[0]), f32(2))
testing.expect_value(t, marker_of(list[1]), f32(5))
testing.expect_value(t, marker_of(list[2]), f32(3))
testing.expect_value(t, marker_of(list[3]), f32(6))
testing.expect_value(t, marker_of(list[4]), f32(1))
testing.expect_value(t, marker_of(list[5]), f32(4))
}
@(test)
group_texture_runs_noncontiguous_same_group_id :: proc(t: ^testing.T) {
// Same nonzero id split by group 0: each window regroups alone.
list := []Queued_Sprite {
queued(2, 1, 1),
queued(1, 1, 2),
queued(2, 0, 3),
queued(2, 1, 4),
queued(1, 1, 5),
}
group_texture_runs(list)
testing.expect_value(t, marker_of(list[2]), f32(3))
testing.expect(t, list[0].texture == fake_tex(1))
testing.expect(t, list[1].texture == fake_tex(2))
testing.expect_value(t, list[2].batch_group, u32(0))
testing.expect(t, list[3].texture == fake_tex(1))
testing.expect(t, list[4].texture == fake_tex(2))
testing.expect_value(t, list[0].batch_group, u32(1))
testing.expect_value(t, list[1].batch_group, u32(1))
testing.expect_value(t, list[3].batch_group, u32(1))
testing.expect_value(t, list[4].batch_group, u32(1))
}
make_test_draw_app :: proc() -> App {
app: App
app.cmd = cast(^sdl.GPUCommandBuffer)uintptr(1)
app.swapchain_texture = fake_tex(99)
app.swapchain_w = 800
app.swapchain_h = 600
app.camera = camera_default()
app.draw_list = make([dynamic]Queued_Sprite)
return app
}
destroy_test_draw_app :: proc(app: ^App) {
if app == nil do return
delete(app.draw_list)
app^ = {}
}
make_test_draw_character :: proc() -> Character_Data {
data: Character_Data
data.texture = fake_tex(42)
data.width = 100
data.height = 100
data.def.pivot = {0.5, 1.0}
data.def.clips = make(map[string]Clip_Def)
frames := make([]Frame_Def, 1)
frames[0] = Frame_Def {
rect = {0, 0, 10, 10},
source_size = {10, 10},
trim_offset = {0, 0},
}
data.def.clips["idle"] = Clip_Def {
loop = true,
fps = 10,
frames = frames,
}
return data
}
destroy_test_draw_character :: proc(data: ^Character_Data) {
if data == nil do return
keys := make([dynamic]string, context.temp_allocator)
for key, clip in data.def.clips {
delete(clip.frames)
append(&keys, key)
}
for key in keys {
delete_key(&data.def.clips, key)
}
delete(data.def.clips)
data^ = {}
}
@(test)
draw_sprite_stamps_batch_group_zero :: proc(t: ^testing.T) {
app := make_test_draw_app()
defer destroy_test_draw_app(&app)
data := make_test_draw_character()
defer destroy_test_draw_character(&data)
sprite := spawn_sprite(&data, {100, 200}, "idle", 0)
draw_sprite(&app, &sprite)
testing.expect_value(t, len(app.draw_list), 1)
testing.expect_value(t, app.draw_list[0].batch_group, u32(0))
testing.expect(t, app.draw_list[0].texture == data.texture)
}
@(test)
draw_sprite_at_max_sprites_does_not_append :: proc(t: ^testing.T) {
app := make_test_draw_app()
defer destroy_test_draw_app(&app)
data := make_test_draw_character()
defer destroy_test_draw_character(&data)
for _ in 0 ..< MAX_SPRITES {
append(&app.draw_list, Queued_Sprite{texture = data.texture})
}
testing.expect_value(t, len(app.draw_list), MAX_SPRITES)
sprite := spawn_sprite(&data, {100, 200}, "idle", 0)
draw_sprite(&app, &sprite)
testing.expect_value(t, len(app.draw_list), MAX_SPRITES)
}
@(test)
draw_sprite_batched_stamps_batch_group :: proc(t: ^testing.T) {
app := make_test_draw_app()
defer destroy_test_draw_app(&app)
data := make_test_draw_character()
defer destroy_test_draw_character(&data)
sprite := spawn_sprite(&data, {100, 200}, "idle", 0)
draw_sprite_batched(&app, &sprite, 7)
testing.expect_value(t, len(app.draw_list), 1)
testing.expect_value(t, app.draw_list[0].batch_group, u32(7))
testing.expect(t, app.draw_list[0].texture == data.texture)
}
@(test)
draw_sprite_batched_guards_leave_list_unchanged :: proc(t: ^testing.T) {
app := make_test_draw_app()
defer destroy_test_draw_app(&app)
data := make_test_draw_character()
defer destroy_test_draw_character(&data)
sprite := spawn_sprite(&data, {100, 200}, "idle", 0)
app.cmd = nil
draw_sprite_batched(&app, &sprite, 1)
testing.expect_value(t, len(app.draw_list), 0)
app.cmd = cast(^sdl.GPUCommandBuffer)uintptr(1)
app.swapchain_texture = nil
draw_sprite_batched(&app, &sprite, 1)
testing.expect_value(t, len(app.draw_list), 0)
app.swapchain_texture = fake_tex(99)
draw_sprite_batched(&app, nil, 1)
testing.expect_value(t, len(app.draw_list), 0)
no_data := sprite
no_data.data = nil
draw_sprite_batched(&app, &no_data, 1)
testing.expect_value(t, len(app.draw_list), 0)
no_tex := sprite
tex_data := data
tex_data.texture = nil
no_tex.data = &tex_data
draw_sprite_batched(&app, &no_tex, 1)
testing.expect_value(t, len(app.draw_list), 0)
}
@(test)
prepare_draw_batches_regroups_then_plans_runs :: proc(t: ^testing.T) {
list := []Queued_Sprite {
queued(2, 1, 1),
queued(1, 1, 2),
queued(2, 1, 3),
queued(1, 1, 4),
queued(3, 0, 5),
queued(1, 0, 6),
}
batches := make([dynamic]Draw_Batch)
defer delete(batches)
prepare_draw_batches(list, &batches)
testing.expect_value(t, len(batches), 4)
testing.expect_value(t, batches[0].start, 0)
testing.expect_value(t, batches[0].count, 2)
testing.expect(t, batches[0].texture == fake_tex(1))
testing.expect_value(t, batches[1].start, 2)
testing.expect_value(t, batches[1].count, 2)
testing.expect(t, batches[1].texture == fake_tex(2))
testing.expect_value(t, batches[2].start, 4)
testing.expect_value(t, batches[2].count, 1)
testing.expect(t, batches[2].texture == fake_tex(3))
testing.expect_value(t, batches[3].start, 5)
testing.expect_value(t, batches[3].count, 1)
testing.expect(t, batches[3].texture == fake_tex(1))
// Group 0 submission order preserved.
testing.expect_value(t, marker_of(list[4]), f32(5))
testing.expect_value(t, marker_of(list[5]), f32(6))
testing.expect_value(t, list[4].batch_group, u32(0))
testing.expect_value(t, list[5].batch_group, u32(0))
}
+9
View File
@@ -27,6 +27,15 @@ choose_shader_runtime_prefers_msl :: proc(t: ^testing.T) {
testing.expect_value(t, rt.format, sdl.GPUShaderFormat{.MSL})
}
@(test)
choose_shader_runtime_prefers_dxil_without_msl :: proc(t: ^testing.T) {
rt, ok := choose_shader_runtime_from_formats({.DXIL, .SPIRV})
testing.expect(t, ok, "should pick a runtime when DXIL is available")
testing.expect_value(t, rt.backend, Shader_Backend.DSD12_DXIL)
testing.expect_value(t, rt.shader_dir, "shaders/d3d12")
testing.expect_value(t, rt.format, sdl.GPUShaderFormat{.DXIL})
}
@(test)
choose_shader_runtime_spirv_only :: proc(t: ^testing.T) {
rt, ok := choose_shader_runtime_from_formats({.SPIRV})
+71 -28
View File
@@ -5,12 +5,14 @@ import sdl "vendor:sdl3"
Vec2 :: [2]f32
Sprite :: struct {
data: ^Character_Data,
position: Vec2,
clip: string,
frame: int,
time: f32,
flip_x: bool,
data: ^Character_Data,
position: Vec2,
clip: string,
clip_def: Clip_Def,
has_clip: bool,
frame: int,
time: f32,
flip_x: bool,
}
spawn_sprite :: proc(
@@ -24,16 +26,14 @@ spawn_sprite :: proc(
position = position,
}
set_sprite_clip(&sprite, clip)
if frame != 0 {
c, ok := character_clip(sprite.data, sprite.clip)
if ok {
if frame < 0 {
sprite.frame = 0
} else if frame >= len(c.frames) {
sprite.frame = len(c.frames) - 1
} else {
sprite.frame = frame
}
if frame != 0 && sprite.has_clip {
c := sprite.clip_def
if frame < 0 {
sprite.frame = 0
} else if frame >= len(c.frames) {
sprite.frame = len(c.frames) - 1
} else {
sprite.frame = frame
}
}
return sprite
@@ -42,9 +42,9 @@ spawn_sprite :: proc(
update_sprite :: proc(sprite: ^Sprite, dt: f32) {
if sprite == nil || sprite.data == nil do return
if dt <= 0 do return
if !sprite.has_clip do return
clip, ok := character_clip(sprite.data, sprite.clip)
if !ok do return
clip := sprite.clip_def
frame_count := len(clip.frames)
if frame_count <= 0 do return
@@ -82,7 +82,31 @@ to_clip :: proc(px, py, sw, sh: f32) -> [2]f32 {
}
}
// Axis-aligned quad: two unique x and y values, so scale once and reuse.
sprite_quad_to_clip :: proc(x0, y0, x1, y1, sw, sh: f32) -> [4]Vec2 {
sx := 2.0 / sw
sy := 2.0 / sh
left := x0 * sx - 1
right := x1 * sx - 1
top := 1 - y0 * sy
bottom := 1 - y1 * sy
return {
{left, top},
{right, top},
{right, bottom},
{left, bottom},
}
}
draw_sprite :: proc(app: ^App, sprite: ^Sprite) {
draw_sprite_batched(app, sprite, 0)
}
// Nonzero batch_group lets end_frame regroup consecutive same-group sprites by
// texture. Group 0 keeps exact submission order for correct alpha overlap.
draw_sprite_batched :: proc(app: ^App, sprite: ^Sprite, batch_group: u32) {
if app.cmd == nil || app.swapchain_texture == nil {
return
}
@@ -92,12 +116,13 @@ draw_sprite :: proc(app: ^App, sprite: ^Sprite) {
if len(app.draw_list) >= MAX_SPRITES {
return
}
frame, ok := character_frame(sprite.data, sprite.clip, sprite.frame)
if !ok {
if !sprite.has_clip do return
if sprite.frame < 0 || sprite.frame >= len(sprite.clip_def.frames) {
return
}
frame := sprite.clip_def.frames[sprite.frame]
src_w := f32(frame.source_size[0])
src_h := f32(frame.source_size[1])
if src_w <= 0 do src_w = f32(frame.rect[2])
@@ -124,10 +149,8 @@ draw_sprite :: proc(app: ^App, sprite: ^Sprite) {
sw := f32(app.swapchain_w)
sh := f32(app.swapchain_h)
p0 := to_clip(x0_px, y0_px, sw, sh)
p1 := to_clip(x1_px, y0_px, sw, sh)
p2 := to_clip(x1_px, y1_px, sw, sh)
p3 := to_clip(x0_px, y1_px, sw, sh)
points := sprite_quad_to_clip(x0_px, y0_px, x1_px, y1_px, sw, sh)
p0, p1, p2, p3 := points[0], points[1], points[2], points[3]
tex_w := f32(sprite.data.width)
tex_h := f32(sprite.data.height)
@@ -142,18 +165,38 @@ draw_sprite :: proc(app: ^App, sprite: ^Sprite) {
{pos = p3, uv = {u0, v1}},
}
append(&app.draw_list, Queued_Sprite{texture = sprite.data.texture, verts = verts})
append(
&app.draw_list,
Queued_Sprite {
texture = sprite.data.texture,
verts = verts,
batch_group = batch_group,
},
)
}
set_sprite_clip :: proc(sprite: ^Sprite, clip: string) {
if sprite == nil || sprite.data == nil do return
if sprite.clip == clip do return
if sprite.clip == clip && sprite.has_clip do return
_, ok := character_clip(sprite.data, clip)
def, ok := character_clip(sprite.data, clip)
if !ok do return
set_sprite_clip_def(sprite, clip, def)
}
// Applies a pre-resolved clip without looking up the character clip map.
// Use when callers already hold Clip_Def (e.g. thrashing between known clips).
set_sprite_clip_def :: proc(sprite: ^Sprite, clip: string, def: Clip_Def) {
if sprite == nil do return
if len(def.frames) == 0 do return
if sprite.clip == clip && sprite.has_clip do return
sprite.clip = clip
sprite.clip_def = def
sprite.has_clip = true
sprite.frame = 0
sprite.time = 0
}
+80
View File
@@ -0,0 +1,80 @@
package main
import "core:fmt"
import eng "pkg:engine"
// Clip thrash: flip every sprite between idle/walk each frame via set_sprite_clip_def.
COUNT :: eng.MAX_SPRITES
COLS :: 16
FRAME_LOG_EVERY :: 60
main :: proc() {
app: eng.App
if !eng.init(&app, "clip_thrash", 800, 600) do return
defer eng.shutdown(&app)
data, ok := eng.load_character_data(&app, "assets_baked/characters/toad/toad.char.json")
if !ok do return
defer eng.destroy_character_data(&app, &data)
idle_def, idle_ok := eng.character_clip(&data, "idle")
walk_def, walk_ok := eng.character_clip(&data, "walk")
if !idle_ok || !walk_ok do return
sprites: [COUNT]eng.Sprite
for i in 0 ..< COUNT {
col := i % COLS
row := i / COLS
pos := eng.Vec2 {
f32(40 + col * 48),
f32(80 + row * 60),
}
sprites[i] = eng.spawn_sprite(&data, pos, "idle", i % 5)
}
app.camera.position = {400, 400}
last := eng.now_seconds()
frame_i := 0
sum_ms: f64
peak_ms: f64
use_walk := false
for eng.events() {
now := eng.now_seconds()
dt := f32(now - last)
last = now
frame_ms := f64(dt) * 1000.0
sum_ms += frame_ms
if frame_ms > peak_ms do peak_ms = frame_ms
frame_i += 1
if frame_i % FRAME_LOG_EVERY == 0 {
avg := sum_ms / f64(FRAME_LOG_EVERY)
fps := 1000.0 / avg if avg > 0 else 0
fmt.printfln(
"clip_thrash frame: avg=%.2f ms (%.1f FPS) peak=%.2f ms over %d frames",
avg,
fps,
peak_ms,
FRAME_LOG_EVERY,
)
sum_ms = 0
peak_ms = 0
}
clip := "walk" if use_walk else "idle"
def := walk_def if use_walk else idle_def
use_walk = !use_walk
for &s in sprites {
eng.set_sprite_clip_def(&s, clip, def)
eng.update_sprite(&s, dt)
}
eng.begin_frame(&app)
for &s in sprites {
eng.draw_sprite(&app, &s)
}
eng.end_frame(&app)
}
}
+29 -6
View File
@@ -1,9 +1,12 @@
package main
import "core:fmt"
import eng "pkg:engine"
// Many sprites sharing one Character_Data (Flyweight) good batching demo.
COUNT :: 24
COUNT :: eng.MAX_SPRITES
COLS :: 16
FRAME_LOG_EVERY :: 60
main :: proc() {
app: eng.App
@@ -16,25 +19,45 @@ main :: proc() {
sprites: [COUNT]eng.Sprite
for i in 0 ..< COUNT {
col := i % 8
row := i / 8
col := i % COLS
row := i / COLS
pos := eng.Vec2 {
f32(120 + col * 80),
f32(280 + row * 120),
f32(40 + col * 48),
f32(80 + row * 60),
}
clip := "idle" if (i % 2) == 0 else "walk"
sprites[i] = eng.spawn_sprite(&data, pos, clip, i % 5)
}
// Look at the middle of the grid
app.camera.position = {400, 400}
last := eng.now_seconds()
frame_i := 0
sum_ms: f64
peak_ms: f64
for eng.events() {
now := eng.now_seconds()
dt := f32(now - last)
last = now
frame_ms := f64(dt) * 1000.0
sum_ms += frame_ms
if frame_ms > peak_ms do peak_ms = frame_ms
frame_i += 1
if frame_i % FRAME_LOG_EVERY == 0 {
avg := sum_ms / f64(FRAME_LOG_EVERY)
fps := 1000.0 / avg if avg > 0 else 0
fmt.printfln(
"crowd frame: avg=%.2f ms (%.1f FPS) peak=%.2f ms over %d frames",
avg,
fps,
peak_ms,
FRAME_LOG_EVERY,
)
sum_ms = 0
peak_ms = 0
}
for &s in sprites {
eng.update_sprite(&s, dt)
+79
View File
@@ -0,0 +1,79 @@
package main
import "core:fmt"
import eng "pkg:engine"
// Multi-texture batching stress: two Character_Data (two GPU textures), alternating sprites.
COUNT :: eng.MAX_SPRITES
COLS :: 16
FRAME_LOG_EVERY :: 60
BATCH_GROUP :: u32(1)
TOAD_JSON :: "assets_baked/characters/toad/toad.char.json"
main :: proc() {
app: eng.App
if !eng.init(&app, "crowd_batch", 800, 600) do return
defer eng.shutdown(&app)
data_a, ok_a := eng.load_character_data(&app, TOAD_JSON)
if !ok_a do return
defer eng.destroy_character_data(&app, &data_a)
data_b, ok_b := eng.load_character_data(&app, TOAD_JSON)
if !ok_b do return
defer eng.destroy_character_data(&app, &data_b)
sprites: [COUNT]eng.Sprite
for i in 0 ..< COUNT {
col := i % COLS
row := i / COLS
pos := eng.Vec2 {
f32(40 + col * 48),
f32(80 + row * 60),
}
clip := "idle" if (i % 2) == 0 else "walk"
data := &data_a if (i % 2) == 0 else &data_b
sprites[i] = eng.spawn_sprite(data, pos, clip, i % 5)
}
app.camera.position = {400, 400}
last := eng.now_seconds()
frame_i := 0
sum_ms: f64
peak_ms: f64
for eng.events() {
now := eng.now_seconds()
dt := f32(now - last)
last = now
frame_ms := f64(dt) * 1000.0
sum_ms += frame_ms
if frame_ms > peak_ms do peak_ms = frame_ms
frame_i += 1
if frame_i % FRAME_LOG_EVERY == 0 {
avg := sum_ms / f64(FRAME_LOG_EVERY)
fps := 1000.0 / avg if avg > 0 else 0
fmt.printfln(
"crowd_batch frame: avg=%.2f ms (%.1f FPS) peak=%.2f ms over %d frames",
avg,
fps,
peak_ms,
FRAME_LOG_EVERY,
)
sum_ms = 0
peak_ms = 0
}
for &s in sprites {
eng.update_sprite(&s, dt)
}
eng.begin_frame(&app)
for &s in sprites {
eng.draw_sprite_batched(&app, &s, BATCH_GROUP)
}
eng.end_frame(&app)
}
}
+70
View File
@@ -0,0 +1,70 @@
package main
import "core:fmt"
import eng "pkg:engine"
// GPU overdraw stress: MAX_SPRITES stacked near one world point.
COUNT :: eng.MAX_SPRITES
FRAME_LOG_EVERY :: 60
main :: proc() {
app: eng.App
if !eng.init(&app, "crowd_overdraw", 800, 600) do return
defer eng.shutdown(&app)
data, ok := eng.load_character_data(&app, "assets_baked/characters/toad/toad.char.json")
if !ok do return
defer eng.destroy_character_data(&app, &data)
center := eng.Vec2{400, 400}
sprites: [COUNT]eng.Sprite
for i in 0 ..< COUNT {
jitter := eng.Vec2 {
f32((i % 7) - 3),
f32((i % 5) - 2),
}
clip := "idle" if (i % 2) == 0 else "walk"
sprites[i] = eng.spawn_sprite(&data, center + jitter, clip, i % 5)
}
app.camera.position = {400, 400}
last := eng.now_seconds()
frame_i := 0
sum_ms: f64
peak_ms: f64
for eng.events() {
now := eng.now_seconds()
dt := f32(now - last)
last = now
frame_ms := f64(dt) * 1000.0
sum_ms += frame_ms
if frame_ms > peak_ms do peak_ms = frame_ms
frame_i += 1
if frame_i % FRAME_LOG_EVERY == 0 {
avg := sum_ms / f64(FRAME_LOG_EVERY)
fps := 1000.0 / avg if avg > 0 else 0
fmt.printfln(
"crowd_overdraw frame: avg=%.2f ms (%.1f FPS) peak=%.2f ms over %d frames",
avg,
fps,
peak_ms,
FRAME_LOG_EVERY,
)
sum_ms = 0
peak_ms = 0
}
for &s in sprites {
eng.update_sprite(&s, dt)
}
eng.begin_frame(&app)
for &s in sprites {
eng.draw_sprite(&app, &s)
}
eng.end_frame(&app)
}
}