{"owner":"hajimehoshi","repo":"ebiten","hasSkills":true,"totalSkillsCount":1,"totalTokensCount":4718,"categories":["anthropic-skill"],"hasMcp":false,"mcpConfig":null,"found":["skills/run-ebitengine-app-headless/SKILL.md"],"skills":{"skills/run-ebitengine-app-headless/SKILL.md":"---\nname: run-ebitengine-app-headless\ndescription: >\n  Use this skill to run an Ebitengine app (anything implementing\n  ebiten.Game) headlessly and programmatically — no visible window and\n  no changes to the app's source — to test, debug, screenshot, or\n  otherwise exercise it. It launches the app as a vmhost guest, drives it\n  through its ticks faster than real time, injects keyboard / mouse /\n  touch / gamepad / text input, reads the rendered frame back as pixels\n  to assert on or dump as a PNG, and observes the audio the app plays.\n  Use when you need input injection, deterministic multi-tick\n  runs, golden-image checks, or audio assertions — for example to\n  reproduce an input-dependent bug, verify behavior after a sequence of\n  clicks or key presses, check that the right sound plays, capture a single\n  rendered frame, or let an AI agent drive an app end-to-end.\nallowed-tools: Read, Edit, Write, Bash\n---\n\n# run-ebitengine-app-headless skill\n\n*Targets ebiten commit `7de1780bd` (2026-09-27), verified against it.\n`exp/vmhost` is experimental, so if its API has moved since, the driver and\nthe snippets here may need updating.*\n\nDrive an Ebitengine app from a small **host** program that runs it as a\n**guest** of the `exp/vmhost` package. The host controls the guest's\nticks however it likes — one at a time, many per host frame, or\nfree-running — injects input, reads back rendered frames, and observes the\naudio the app plays, all from a hidden window. The app under test is\nunmodified.\n\n## How it works (mental model)\n\nOnly the host touches a GPU or display; the guest is fully headless.\n\n- **Guest** = the app under test. Its `ebiten.RunGame` connects to the host\n  over a socket *instead of opening a window*, forwarding its graphics\n  commands rather than rendering.\n- **Host** = the small driver you run. It is itself an `ebiten.Game`\n  with `ebiten.SetWindowVisible(false)`, so it has no visible window. It\n  replays the guest's draw commands on the real GPU, and exposes a\n  `vmhost.GuestSession` to step the guest (`AdvanceTicks`/`AdvanceFrame`/\n  `WaitFrame`/`CompositeFrame`), inject input, read pixels back, and observe\n  the audio it plays.\n\n## The endpoint\n\nThe guest reaches the host through an **endpoint** — the host listener's\naddress as a URL, e.g. `unix:///path/to/socket` or `tcp://127.0.0.1:PORT`\n(the driver builds one from its listener with `vmhost.EndpointURLFromAddr`).\nThe endpoint reaches the guest one of two ways:\n\n- Built with `-tags ebitenginevmguest`, the guest reads the\n  `EBITENGINE_VM_ENDPOINT` environment variable (no source change — this is\n  what the driver uses).\n- With no build tag, the app's code sets `RunGameOptions.VMGuestEndpoint`.\n\n## The driver\n\nA host driver lives at\n[_driver/main.go](skills/run-ebitengine-app-headless/_driver/main.go). Treat it\nas a **starting-point template**, not a finished tool: copy it and adapt the\ninput script, assertions, and screen size to your case. It runs as-is only for\nthe trivial screenshot (recipe step 1); it is a skill asset, not a stable\ncommand, so don't depend on its flags.\n\n## When to use\n\n- You need to **inject input** (keys, mouse, wheel, runes, touches,\n  gamepads) and observe how the app responds — bugs that only reproduce\n  after a click/drag/keystroke sequence.\n- You need to **fast-forward to a state and assert it** — advance N ticks\n  deterministically (compressed, so a long run is near-instant, not\n  real-time), then check the resulting frame. Interleave to step frame by\n  frame when you need every one.\n- You want a **golden-image / pixel assertion** without a human running\n  the app and pasting a screenshot.\n- You just want a **screenshot** of the app — run the driver with no input\n  script and dump the frame after N ticks (recipe step 1).\n- You want to **check audio** — verify the app plays the expected sound, at\n  the expected sample rate and volume, as raw PCM per player (un-mixed),\n  without playing anything.\n\n## When NOT to use\n\n- You're testing pure logic with no rendering — write an ordinary Go\n  test.\n- Target platform is android / ios / js — `vmhost` is desktop-only.\n\n## Recipe\n\nEverything here is built on the driver at\n[_driver/main.go](skills/run-ebitengine-app-headless/_driver/main.go): step 1\nruns it as-is, steps 2–3 copy and adapt it. The Go snippets below are excerpts\nof its `Update`, so `d.guest` (the `*vmhost.GuestSession`), `d.screen`, and\n`*ticks` (the `-ticks` flag value) are the driver's own identifiers.\n\nRun these examples from the **ebiten repo root** — the `-pkg ./examples/...`\npaths below are repo-relative. To test your own app in another module, copy\nor invoke this driver from that module's root and point `-pkg` at the guest\npackage there. The driver imports `github.com/hajimehoshi/ebiten/v2/exp/vmhost`,\nso the host and guest must resolve to the same Ebitengine version; use your\nmodule's `go.mod` or `replace` directives to keep them aligned.\n\n### 1. No input — just capture a frame\n\nRun the shipped driver directly against any guest package:\n\n```bash\ngo run ./skills/run-ebitengine-app-headless/_driver \\\n  -pkg ./examples/rotate -ticks 60 -out /tmp/frame.png\n```\n\nFlags: `-pkg` (guest package), `-ticks` (how many ticks to run before\ndumping + exiting), `-out` (PNG path), `-w`/`-h` (logical screen size,\ndefault 320×240). In a restricted sandbox, `go run` may need a writable\n`GOCACHE` or approval to use the normal Go build cache.\n\n### 2. With input — script the ticks\n\nCopy the driver to a scratch dir, edit the `INPUT SCRIPT` block in\n`Update`, then run the copy (a `.`-prefixed dir stays out of\n`go build ./...`; remove it when you are done):\n\n```bash\nmkdir -p .vmdriver && cp skills/run-ebitengine-app-headless/_driver/main.go .vmdriver/\n# edit .vmdriver/main.go — the INPUT SCRIPT block in Update\ngo run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png\n```\n\nThe injectors on `*vmhost.GuestSession`. Injected state is observed at the\n**next** `AdvanceTicks` and persists until changed, so timing comes from\n*where* you inject in the run — split the run into `AdvanceTicks` segments and\ninject between them:\n\n```go\n// keyboard\nd.guest.PressKey(ebiten.KeyArrowRight)\nd.guest.ReleaseKey(ebiten.KeyArrowRight)\nd.guest.TypeRune('a')                 // a typed character (input chars)\n// mouse — coordinates are outside-screen device-independent pixels\nd.guest.MoveCursor(160, 120)\nd.guest.PressMouseButton(ebiten.MouseButtonLeft)\nd.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\nd.guest.ScrollWheel(0, -1)\n// touch — id, then device-independent pixels\nd.guest.PressTouch(0, 100, 100)\nd.guest.MoveTouch(0, 120, 100)\nd.guest.ReleaseTouch(0)\n// gamepads — full snapshot each call; see vmhost.GamepadState\nd.guest.UpdateGamepads([]vmhost.GamepadState{ /* ... */ })\n// gamepad touchpad — TouchSurfaces lists each surface's current touches,\n// positions in [0, 1] with (0, 0) at the top left\nd.guest.UpdateGamepads([]vmhost.GamepadState{{\n\tID:   0,\n\tName: \"Pad\",\n\tTouchSurfaces: [][]vmhost.GamepadTouchState{\n\t\t{{ID: 1, X: 0.25, Y: 0.5}},\n\t},\n}})\n```\n\nA gamepad touch is identified by its `ID` across `UpdateGamepads` calls: a\ntouch the previous call reported on the same surface with the same ID is the\nsame touch (it keeps its `ebiten.GamepadTouchID` in the guest), and any other\nis a new touch. To lift a finger, send a snapshot without it; to put down a\nnew finger, use an ID the previous snapshot did not have. Every call is a\nsnapshot the guest tracks, so leaving a touch out of one call ends it even if\nno tick runs before the next call. A surface tracks at most 16 touches at\nonce.\n\nTick counts are in the guest's own time units — see\n[Ticks and TPS](#ticks-and-tps) to convert seconds of app time into\n`AdvanceTicks` counts.\n\nExample — use `-ticks` as the total run length, settle 30 ticks, hold a\nclick for one tick, then run the remaining ticks — as the `INPUT SCRIPT`\nblock. For shorter total runs, reduce the settle count first.\n\n```go\nd.guest.AdvanceTicks(30) // let the app settle\nd.guest.MoveCursor(160, 120)\nd.guest.PressMouseButton(ebiten.MouseButtonLeft)\nd.guest.AdvanceTicks(1)  // one tick with the button held\nd.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\nif rest := *ticks - 31; rest > 0 {\n\td.guest.AdvanceTicks(rest)\n}\n```\n\n### 3. Many states — snapshot each one\n\nThe driver's `snapshot(i)` method renders the guest's *current* state and\nwrites it to a numbered PNG derived from `-out` (`frame.png` →\n`frame_00.png`, `frame_01.png`, …). Call it between input segments in the\n`INPUT SCRIPT` block to walk the app through N states and capture every\none — in practice the highest-value pattern (for example, clicking through\neach page of a multi-page app and golden-comparing all of them):\n\n```go\nd.guest.AdvanceTicks(30) // let the app settle\nfor page := 0; page < pageCount; page++ {\n\tif page > 0 {\n\t\td.guest.MoveCursor(nextButtonX, nextButtonY) // click \"next page\"\n\t\td.guest.PressMouseButton(ebiten.MouseButtonLeft)\n\t\td.guest.AdvanceTicks(1)\n\t\td.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\n\t\td.guest.AdvanceTicks(30)\n\t}\n\tif err := d.snapshot(page); err != nil {\n\t\treturn err\n\t}\n}\n```\n\nEach `snapshot` runs the `AdvanceFrame` / `WaitFrame` / `CompositeFrame`\nsequence itself, so the numbered PNGs are exact frames; the final\n`-out` frame at the end of `Update` is still written as usual.\n\n### 4. Inspect the output\n\nInspect the PNG with the available image-viewing tool. For a programmatic\nassertion, read pixels in `Update` after `CompositeFrame` and compare —\n`d.screen.ReadPixels(buf)` gives premultiplied-alpha RGBA, 4 bytes per\npixel. Input coordinates and `-w`/`-h` are logical device-independent pixels,\nbut `d.screen` and the PNG are physical pixels (`logical × DeviceScaleFactor`;\nfor example, a 320×240 logical screen can produce a 640×480 PNG on a 2x\ndisplay). Use `b := d.screen.Bounds()` for assertion dimensions; the center\npixel of that physical `w*h` image is at `4*((h/2)*w + w/2)`. For a golden\ncheck, dump once, eyeball it, then compare bytes against the saved PNG on\nlater runs — see [Verifying a run](#verifying-a-run) for the pitfalls.\n\n## Verifying a run\n\nJudge success by the **presence of the output PNG** (or the driver's final\n`wrote frame` log line) — never by the exit status observed through a\npipe. Don't pipe the driver's output through `tail -1` or send it to\n`/dev/null`: when the driver fails, its actual one-line error (the\n`fmt.Fprintln` in `main`) is easily discarded, leaving only `go run`'s\ngeneric `exit status 1` with nothing to diagnose. Save the full output to\na log file and read it on failure:\n\n```bash\ngo run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png \\\n  > /tmp/run.log 2>&1 || cat /tmp/run.log\n```\n\nWhen byte-comparing captures with `cmp -s`, check that both files exist\nfirst: a missing file (from a capture that silently failed) also reads as\na difference, masquerading as a pixel regression.\n\n### Golden baselines across a code change\n\nTo golden-compare before/after a code change, capture the baseline from\nthe committed state via `git show <commit>:<file>` extraction or a\ntemporary commit — **not** `git stash` round-trips: the stash list is\nrepo-global and shared across worktrees, so a stray `git stash pop` can\ngrab an unrelated stash entry belonging to the user.\n\n## How the driver drives the guest\n\nThe whole run happens in one host `Update`:\n\n```go\nd.guest.AdvanceTicks(*ticks) // run every tick back-to-back, no real-time pacing\nd.guest.AdvanceFrame()       // request the final frame\nd.guest.WaitFrame()          // block until all queued ticks have run and it is rendered\nd.guest.CompositeFrame()     // composite it into the host-owned screen image; check the bool in assertions\n```\n\n`AdvanceTicks(*ticks)` queues every tick at once; `WaitFrame` then blocks\nuntil they have all run and the final frame is rendered, so one wait covers\nthe lot. That order keeps capture deterministic — the screen reflects exactly\nthe run's end. Only the one frame is rendered, because a tick forwards no\nrendering; only a frame does.\n\n**This compresses wall-clock time** — the point of the VM. The guest is not\npaced to the host's ~60 TPS, so a 1000-tick run finishes near-instantly\ninstead of taking ~16 real seconds. To capture intermediate frames or pace\ninput by hand, interleave instead: `AdvanceTicks(n)`, `AdvanceFrame`,\n`WaitFrame`, `CompositeFrame`, repeat. `PendingTicks` and `WaitTicks` track the\nbacklog; ticks coalesce, so queuing many is cheap.\n\n`guest.Err()` becomes non-nil when the guest terminates, crashes, or times\nout; a panicking guest prints its stack to stderr and the driver stops. The\ndriver also puts a deadline on accepting the guest connection so a guest that\nnever dials the host fails instead of hanging indefinitely.\n\n## Ticks and TPS\n\nA tick is one guest `Update` call, and `AdvanceTicks(n)` runs exactly n of\nthem. The guest's TPS never throttles or scales this — how many ticks to run,\nand at what real-time rate if any, is entirely the host's choice.\n\nTPS matters as a unit conversion. The guest's game logic is written assuming\n`Update` runs TPS times per second of real time, so its timers, animations,\nand cooldowns count in those units. `d.guest.RequestedTPS()` reports what the\ngame requested via `ebiten.SetTPS` (the standard 60 until changed). To\nsimulate a duration of app time, multiply seconds by that value: one second\nin a 30-TPS game is `AdvanceTicks(30)`, not 60.\n\n`RequestedTPS` can also return `ebiten.SyncWithFPS` (-1), meaning the game\nties its ticks to rendered frames rather than a fixed rate. There is no\nseconds-to-ticks conversion then; treat a tick as a frame and choose counts\nby observing the app.\n\n## Observing audio\n\nThe guest plays no local audio; instead each audio stream it starts is\nhanded to the host through the `OnAudioStream` handler registered when the\nsession is created, as a separate, **un-mixed** `*vmhost.GuestAudioStream`.\nThe driver already wires this up: `d.onAudioStream` collects the streams and\n`d.appendAudioStreams(nil)` hands back the still-open ones (dropping any the\nguest has closed). The audio methods are\ngoroutine-safe and not frame-bound (unlike `CompositeFrame`/`Close`), so\ninspect them anywhere; sample data is pulled from the guest on demand. Drop\nthis into `Update` once the ticks have run (e.g. after `WaitFrame`, so the\nstreams the run started have been delivered):\n\n```go\nrate := d.guest.AudioSampleRate()             // per-channel samples/sec; 0 until audio plays\nfor i, s := range d.appendAudioStreams(nil) { // one entry per still-open guest player\n    slog.Info(\"guest audio player\", \"i\", i,\n        \"playing\", s.IsPlaying(), \"volume\", s.Volume(), \"position\", s.Position())\n    // Raw PCM: 32-bit little-endian floats, two interleaved channels, at `rate`.\n    // Read pulls on demand; volume is reported but NOT applied to these samples.\n    buf := make([]byte, 4096)\n    n, _ := s.Read(buf) // 0 bytes while paused; io.EOF once the guest closes/ends the source\n    slog.Info(\"read PCM\", \"bytes\", n, \"rate\", rate)\n}\n```\n\n`OnAudioStream` fires once per stream, during `AdvanceTicks` and `WaitTicks`\non the goroutine calling them, when the guest starts it. The driver's handler\nonly stashes the handle; read the samples from `Update`, as above. A stream\nstays valid after `io.EOF` (a seek-and-replay yields more samples); once the\nguest closes its player, `IsClosed()` returns true and `Read` reports\n`io.EOF` for good, so `appendAudioStreams` drops it — returning only the\nguest's still-open streams. To actually *hear* it rather than just inspect it,\nfeed the stream to a host `audio.Player` as its source — see\n[examples/vm/main.go](examples/vm/main.go).\n\n## Gotchas\n\n- **Host and guest must be the same ebiten version.** The host/guest\n  protocol is version-locked. The driver builds the guest in your current\n  module, so they match automatically — but a guest built against a\n  different ebiten version (such as a prebuilt binary) fails the handshake.\n- **The host still needs a display/graphics context.** It is *windowless*,\n  not *displayless*: on macOS it works from a normal shell; on bare\n  headless Linux CI you still need Xvfb or an EGL setup to create the\n  hidden window. The guest needs neither.\n- **`SetOutsideScreen` before the first `AdvanceTicks`**, and the image is\n  in **physical** pixels (logical size × `DeviceScaleFactor`). The driver\n  handles this; keep it if you change the screen size.\n- **Call `GuestSession.Close` from the host frame.** The driver closes the\n  guest inside `Update`, after dumping the frame. Keep that shape if you copy\n  the template; `cmd.Wait` can run after `ebiten.RunGame` returns. Losing the\n  host ends the closed guest's `RunGame` without an error, so it exits 0 on\n  its own — the driver waits for it rather than killing it, and treats a\n  non-zero exit as a genuine guest crash.\n- **Launches are occasionally flaky.** Back-to-back runs sometimes fail on\n  the first attempt while an identical rerun succeeds (root cause not\n  identified; possibly socket/handshake timing). Retry once before\n  debugging.\n- **Wall-clock isn't deterministic.** Tick *count* is exact, but code\n  using `time.Now()` still sees real time between ticks.\n- **`exp/vmhost` is experimental** — its API may change.\n- **Unix socket path length is limited** (~104 bytes on macOS), so the\n  driver keeps its socket in a short temp dir. Unix sockets work on every\n  desktop target, Windows included; a `tcp` loopback listener\n  (`127.0.0.1:0`) is an equivalent alternative if a filesystem socket is\n  inconvenient.\n\n## In-repo alternative (Go test)\n\nTo exercise an app as a guest from a `go test` instead of a standalone\ndriver, mirror the package's own end-to-end tests:\n[exp/vmhost/guest_test.go](exp/vmhost/guest_test.go) (the `startGuest` /\n`tickAndFrame` helpers) and\n[exp/vmhost/readpixels_test.go](exp/vmhost/readpixels_test.go). These use\n`internal/testing`'s `MainWithRunLoop`, which is importable only inside\nthe ebiten module.\n"},"files":{"skills/run-ebitengine-app-headless/SKILL.md":"---\nname: run-ebitengine-app-headless\ndescription: >\n  Use this skill to run an Ebitengine app (anything implementing\n  ebiten.Game) headlessly and programmatically — no visible window and\n  no changes to the app's source — to test, debug, screenshot, or\n  otherwise exercise it. It launches the app as a vmhost guest, drives it\n  through its ticks faster than real time, injects keyboard / mouse /\n  touch / gamepad / text input, reads the rendered frame back as pixels\n  to assert on or dump as a PNG, and observes the audio the app plays.\n  Use when you need input injection, deterministic multi-tick\n  runs, golden-image checks, or audio assertions — for example to\n  reproduce an input-dependent bug, verify behavior after a sequence of\n  clicks or key presses, check that the right sound plays, capture a single\n  rendered frame, or let an AI agent drive an app end-to-end.\nallowed-tools: Read, Edit, Write, Bash\n---\n\n# run-ebitengine-app-headless skill\n\n*Targets ebiten commit `7de1780bd` (2026-09-27), verified against it.\n`exp/vmhost` is experimental, so if its API has moved since, the driver and\nthe snippets here may need updating.*\n\nDrive an Ebitengine app from a small **host** program that runs it as a\n**guest** of the `exp/vmhost` package. The host controls the guest's\nticks however it likes — one at a time, many per host frame, or\nfree-running — injects input, reads back rendered frames, and observes the\naudio the app plays, all from a hidden window. The app under test is\nunmodified.\n\n## How it works (mental model)\n\nOnly the host touches a GPU or display; the guest is fully headless.\n\n- **Guest** = the app under test. Its `ebiten.RunGame` connects to the host\n  over a socket *instead of opening a window*, forwarding its graphics\n  commands rather than rendering.\n- **Host** = the small driver you run. It is itself an `ebiten.Game`\n  with `ebiten.SetWindowVisible(false)`, so it has no visible window. It\n  replays the guest's draw commands on the real GPU, and exposes a\n  `vmhost.GuestSession` to step the guest (`AdvanceTicks`/`AdvanceFrame`/\n  `WaitFrame`/`CompositeFrame`), inject input, read pixels back, and observe\n  the audio it plays.\n\n## The endpoint\n\nThe guest reaches the host through an **endpoint** — the host listener's\naddress as a URL, e.g. `unix:///path/to/socket` or `tcp://127.0.0.1:PORT`\n(the driver builds one from its listener with `vmhost.EndpointURLFromAddr`).\nThe endpoint reaches the guest one of two ways:\n\n- Built with `-tags ebitenginevmguest`, the guest reads the\n  `EBITENGINE_VM_ENDPOINT` environment variable (no source change — this is\n  what the driver uses).\n- With no build tag, the app's code sets `RunGameOptions.VMGuestEndpoint`.\n\n## The driver\n\nA host driver lives at\n[_driver/main.go](skills/run-ebitengine-app-headless/_driver/main.go). Treat it\nas a **starting-point template**, not a finished tool: copy it and adapt the\ninput script, assertions, and screen size to your case. It runs as-is only for\nthe trivial screenshot (recipe step 1); it is a skill asset, not a stable\ncommand, so don't depend on its flags.\n\n## When to use\n\n- You need to **inject input** (keys, mouse, wheel, runes, touches,\n  gamepads) and observe how the app responds — bugs that only reproduce\n  after a click/drag/keystroke sequence.\n- You need to **fast-forward to a state and assert it** — advance N ticks\n  deterministically (compressed, so a long run is near-instant, not\n  real-time), then check the resulting frame. Interleave to step frame by\n  frame when you need every one.\n- You want a **golden-image / pixel assertion** without a human running\n  the app and pasting a screenshot.\n- You just want a **screenshot** of the app — run the driver with no input\n  script and dump the frame after N ticks (recipe step 1).\n- You want to **check audio** — verify the app plays the expected sound, at\n  the expected sample rate and volume, as raw PCM per player (un-mixed),\n  without playing anything.\n\n## When NOT to use\n\n- You're testing pure logic with no rendering — write an ordinary Go\n  test.\n- Target platform is android / ios / js — `vmhost` is desktop-only.\n\n## Recipe\n\nEverything here is built on the driver at\n[_driver/main.go](skills/run-ebitengine-app-headless/_driver/main.go): step 1\nruns it as-is, steps 2–3 copy and adapt it. The Go snippets below are excerpts\nof its `Update`, so `d.guest` (the `*vmhost.GuestSession`), `d.screen`, and\n`*ticks` (the `-ticks` flag value) are the driver's own identifiers.\n\nRun these examples from the **ebiten repo root** — the `-pkg ./examples/...`\npaths below are repo-relative. To test your own app in another module, copy\nor invoke this driver from that module's root and point `-pkg` at the guest\npackage there. The driver imports `github.com/hajimehoshi/ebiten/v2/exp/vmhost`,\nso the host and guest must resolve to the same Ebitengine version; use your\nmodule's `go.mod` or `replace` directives to keep them aligned.\n\n### 1. No input — just capture a frame\n\nRun the shipped driver directly against any guest package:\n\n```bash\ngo run ./skills/run-ebitengine-app-headless/_driver \\\n  -pkg ./examples/rotate -ticks 60 -out /tmp/frame.png\n```\n\nFlags: `-pkg` (guest package), `-ticks` (how many ticks to run before\ndumping + exiting), `-out` (PNG path), `-w`/`-h` (logical screen size,\ndefault 320×240). In a restricted sandbox, `go run` may need a writable\n`GOCACHE` or approval to use the normal Go build cache.\n\n### 2. With input — script the ticks\n\nCopy the driver to a scratch dir, edit the `INPUT SCRIPT` block in\n`Update`, then run the copy (a `.`-prefixed dir stays out of\n`go build ./...`; remove it when you are done):\n\n```bash\nmkdir -p .vmdriver && cp skills/run-ebitengine-app-headless/_driver/main.go .vmdriver/\n# edit .vmdriver/main.go — the INPUT SCRIPT block in Update\ngo run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png\n```\n\nThe injectors on `*vmhost.GuestSession`. Injected state is observed at the\n**next** `AdvanceTicks` and persists until changed, so timing comes from\n*where* you inject in the run — split the run into `AdvanceTicks` segments and\ninject between them:\n\n```go\n// keyboard\nd.guest.PressKey(ebiten.KeyArrowRight)\nd.guest.ReleaseKey(ebiten.KeyArrowRight)\nd.guest.TypeRune('a')                 // a typed character (input chars)\n// mouse — coordinates are outside-screen device-independent pixels\nd.guest.MoveCursor(160, 120)\nd.guest.PressMouseButton(ebiten.MouseButtonLeft)\nd.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\nd.guest.ScrollWheel(0, -1)\n// touch — id, then device-independent pixels\nd.guest.PressTouch(0, 100, 100)\nd.guest.MoveTouch(0, 120, 100)\nd.guest.ReleaseTouch(0)\n// gamepads — full snapshot each call; see vmhost.GamepadState\nd.guest.UpdateGamepads([]vmhost.GamepadState{ /* ... */ })\n// gamepad touchpad — TouchSurfaces lists each surface's current touches,\n// positions in [0, 1] with (0, 0) at the top left\nd.guest.UpdateGamepads([]vmhost.GamepadState{{\n\tID:   0,\n\tName: \"Pad\",\n\tTouchSurfaces: [][]vmhost.GamepadTouchState{\n\t\t{{ID: 1, X: 0.25, Y: 0.5}},\n\t},\n}})\n```\n\nA gamepad touch is identified by its `ID` across `UpdateGamepads` calls: a\ntouch the previous call reported on the same surface with the same ID is the\nsame touch (it keeps its `ebiten.GamepadTouchID` in the guest), and any other\nis a new touch. To lift a finger, send a snapshot without it; to put down a\nnew finger, use an ID the previous snapshot did not have. Every call is a\nsnapshot the guest tracks, so leaving a touch out of one call ends it even if\nno tick runs before the next call. A surface tracks at most 16 touches at\nonce.\n\nTick counts are in the guest's own time units — see\n[Ticks and TPS](#ticks-and-tps) to convert seconds of app time into\n`AdvanceTicks` counts.\n\nExample — use `-ticks` as the total run length, settle 30 ticks, hold a\nclick for one tick, then run the remaining ticks — as the `INPUT SCRIPT`\nblock. For shorter total runs, reduce the settle count first.\n\n```go\nd.guest.AdvanceTicks(30) // let the app settle\nd.guest.MoveCursor(160, 120)\nd.guest.PressMouseButton(ebiten.MouseButtonLeft)\nd.guest.AdvanceTicks(1)  // one tick with the button held\nd.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\nif rest := *ticks - 31; rest > 0 {\n\td.guest.AdvanceTicks(rest)\n}\n```\n\n### 3. Many states — snapshot each one\n\nThe driver's `snapshot(i)` method renders the guest's *current* state and\nwrites it to a numbered PNG derived from `-out` (`frame.png` →\n`frame_00.png`, `frame_01.png`, …). Call it between input segments in the\n`INPUT SCRIPT` block to walk the app through N states and capture every\none — in practice the highest-value pattern (for example, clicking through\neach page of a multi-page app and golden-comparing all of them):\n\n```go\nd.guest.AdvanceTicks(30) // let the app settle\nfor page := 0; page < pageCount; page++ {\n\tif page > 0 {\n\t\td.guest.MoveCursor(nextButtonX, nextButtonY) // click \"next page\"\n\t\td.guest.PressMouseButton(ebiten.MouseButtonLeft)\n\t\td.guest.AdvanceTicks(1)\n\t\td.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\n\t\td.guest.AdvanceTicks(30)\n\t}\n\tif err := d.snapshot(page); err != nil {\n\t\treturn err\n\t}\n}\n```\n\nEach `snapshot` runs the `AdvanceFrame` / `WaitFrame` / `CompositeFrame`\nsequence itself, so the numbered PNGs are exact frames; the final\n`-out` frame at the end of `Update` is still written as usual.\n\n### 4. Inspect the output\n\nInspect the PNG with the available image-viewing tool. For a programmatic\nassertion, read pixels in `Update` after `CompositeFrame` and compare —\n`d.screen.ReadPixels(buf)` gives premultiplied-alpha RGBA, 4 bytes per\npixel. Input coordinates and `-w`/`-h` are logical device-independent pixels,\nbut `d.screen` and the PNG are physical pixels (`logical × DeviceScaleFactor`;\nfor example, a 320×240 logical screen can produce a 640×480 PNG on a 2x\ndisplay). Use `b := d.screen.Bounds()` for assertion dimensions; the center\npixel of that physical `w*h` image is at `4*((h/2)*w + w/2)`. For a golden\ncheck, dump once, eyeball it, then compare bytes against the saved PNG on\nlater runs — see [Verifying a run](#verifying-a-run) for the pitfalls.\n\n## Verifying a run\n\nJudge success by the **presence of the output PNG** (or the driver's final\n`wrote frame` log line) — never by the exit status observed through a\npipe. Don't pipe the driver's output through `tail -1` or send it to\n`/dev/null`: when the driver fails, its actual one-line error (the\n`fmt.Fprintln` in `main`) is easily discarded, leaving only `go run`'s\ngeneric `exit status 1` with nothing to diagnose. Save the full output to\na log file and read it on failure:\n\n```bash\ngo run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png \\\n  > /tmp/run.log 2>&1 || cat /tmp/run.log\n```\n\nWhen byte-comparing captures with `cmp -s`, check that both files exist\nfirst: a missing file (from a capture that silently failed) also reads as\na difference, masquerading as a pixel regression.\n\n### Golden baselines across a code change\n\nTo golden-compare before/after a code change, capture the baseline from\nthe committed state via `git show <commit>:<file>` extraction or a\ntemporary commit — **not** `git stash` round-trips: the stash list is\nrepo-global and shared across worktrees, so a stray `git stash pop` can\ngrab an unrelated stash entry belonging to the user.\n\n## How the driver drives the guest\n\nThe whole run happens in one host `Update`:\n\n```go\nd.guest.AdvanceTicks(*ticks) // run every tick back-to-back, no real-time pacing\nd.guest.AdvanceFrame()       // request the final frame\nd.guest.WaitFrame()          // block until all queued ticks have run and it is rendered\nd.guest.CompositeFrame()     // composite it into the host-owned screen image; check the bool in assertions\n```\n\n`AdvanceTicks(*ticks)` queues every tick at once; `WaitFrame` then blocks\nuntil they have all run and the final frame is rendered, so one wait covers\nthe lot. That order keeps capture deterministic — the screen reflects exactly\nthe run's end. Only the one frame is rendered, because a tick forwards no\nrendering; only a frame does.\n\n**This compresses wall-clock time** — the point of the VM. The guest is not\npaced to the host's ~60 TPS, so a 1000-tick run finishes near-instantly\ninstead of taking ~16 real seconds. To capture intermediate frames or pace\ninput by hand, interleave instead: `AdvanceTicks(n)`, `AdvanceFrame`,\n`WaitFrame`, `CompositeFrame`, repeat. `PendingTicks` and `WaitTicks` track the\nbacklog; ticks coalesce, so queuing many is cheap.\n\n`guest.Err()` becomes non-nil when the guest terminates, crashes, or times\nout; a panicking guest prints its stack to stderr and the driver stops. The\ndriver also puts a deadline on accepting the guest connection so a guest that\nnever dials the host fails instead of hanging indefinitely.\n\n## Ticks and TPS\n\nA tick is one guest `Update` call, and `AdvanceTicks(n)` runs exactly n of\nthem. The guest's TPS never throttles or scales this — how many ticks to run,\nand at what real-time rate if any, is entirely the host's choice.\n\nTPS matters as a unit conversion. The guest's game logic is written assuming\n`Update` runs TPS times per second of real time, so its timers, animations,\nand cooldowns count in those units. `d.guest.RequestedTPS()` reports what the\ngame requested via `ebiten.SetTPS` (the standard 60 until changed). To\nsimulate a duration of app time, multiply seconds by that value: one second\nin a 30-TPS game is `AdvanceTicks(30)`, not 60.\n\n`RequestedTPS` can also return `ebiten.SyncWithFPS` (-1), meaning the game\nties its ticks to rendered frames rather than a fixed rate. There is no\nseconds-to-ticks conversion then; treat a tick as a frame and choose counts\nby observing the app.\n\n## Observing audio\n\nThe guest plays no local audio; instead each audio stream it starts is\nhanded to the host through the `OnAudioStream` handler registered when the\nsession is created, as a separate, **un-mixed** `*vmhost.GuestAudioStream`.\nThe driver already wires this up: `d.onAudioStream` collects the streams and\n`d.appendAudioStreams(nil)` hands back the still-open ones (dropping any the\nguest has closed). The audio methods are\ngoroutine-safe and not frame-bound (unlike `CompositeFrame`/`Close`), so\ninspect them anywhere; sample data is pulled from the guest on demand. Drop\nthis into `Update` once the ticks have run (e.g. after `WaitFrame`, so the\nstreams the run started have been delivered):\n\n```go\nrate := d.guest.AudioSampleRate()             // per-channel samples/sec; 0 until audio plays\nfor i, s := range d.appendAudioStreams(nil) { // one entry per still-open guest player\n    slog.Info(\"guest audio player\", \"i\", i,\n        \"playing\", s.IsPlaying(), \"volume\", s.Volume(), \"position\", s.Position())\n    // Raw PCM: 32-bit little-endian floats, two interleaved channels, at `rate`.\n    // Read pulls on demand; volume is reported but NOT applied to these samples.\n    buf := make([]byte, 4096)\n    n, _ := s.Read(buf) // 0 bytes while paused; io.EOF once the guest closes/ends the source\n    slog.Info(\"read PCM\", \"bytes\", n, \"rate\", rate)\n}\n```\n\n`OnAudioStream` fires once per stream, during `AdvanceTicks` and `WaitTicks`\non the goroutine calling them, when the guest starts it. The driver's handler\nonly stashes the handle; read the samples from `Update`, as above. A stream\nstays valid after `io.EOF` (a seek-and-replay yields more samples); once the\nguest closes its player, `IsClosed()` returns true and `Read` reports\n`io.EOF` for good, so `appendAudioStreams` drops it — returning only the\nguest's still-open streams. To actually *hear* it rather than just inspect it,\nfeed the stream to a host `audio.Player` as its source — see\n[examples/vm/main.go](examples/vm/main.go).\n\n## Gotchas\n\n- **Host and guest must be the same ebiten version.** The host/guest\n  protocol is version-locked. The driver builds the guest in your current\n  module, so they match automatically — but a guest built against a\n  different ebiten version (such as a prebuilt binary) fails the handshake.\n- **The host still needs a display/graphics context.** It is *windowless*,\n  not *displayless*: on macOS it works from a normal shell; on bare\n  headless Linux CI you still need Xvfb or an EGL setup to create the\n  hidden window. The guest needs neither.\n- **`SetOutsideScreen` before the first `AdvanceTicks`**, and the image is\n  in **physical** pixels (logical size × `DeviceScaleFactor`). The driver\n  handles this; keep it if you change the screen size.\n- **Call `GuestSession.Close` from the host frame.** The driver closes the\n  guest inside `Update`, after dumping the frame. Keep that shape if you copy\n  the template; `cmd.Wait` can run after `ebiten.RunGame` returns. Losing the\n  host ends the closed guest's `RunGame` without an error, so it exits 0 on\n  its own — the driver waits for it rather than killing it, and treats a\n  non-zero exit as a genuine guest crash.\n- **Launches are occasionally flaky.** Back-to-back runs sometimes fail on\n  the first attempt while an identical rerun succeeds (root cause not\n  identified; possibly socket/handshake timing). Retry once before\n  debugging.\n- **Wall-clock isn't deterministic.** Tick *count* is exact, but code\n  using `time.Now()` still sees real time between ticks.\n- **`exp/vmhost` is experimental** — its API may change.\n- **Unix socket path length is limited** (~104 bytes on macOS), so the\n  driver keeps its socket in a short temp dir. Unix sockets work on every\n  desktop target, Windows included; a `tcp` loopback listener\n  (`127.0.0.1:0`) is an equivalent alternative if a filesystem socket is\n  inconvenient.\n\n## In-repo alternative (Go test)\n\nTo exercise an app as a guest from a `go test` instead of a standalone\ndriver, mirror the package's own end-to-end tests:\n[exp/vmhost/guest_test.go](exp/vmhost/guest_test.go) (the `startGuest` /\n`tickAndFrame` helpers) and\n[exp/vmhost/readpixels_test.go](exp/vmhost/readpixels_test.go). These use\n`internal/testing`'s `MainWithRunLoop`, which is importable only inside\nthe ebiten module.\n"},"items":[{"name":"SKILL.md","path":"skills/run-ebitengine-app-headless/SKILL.md","rawUrl":"https://raw.githubusercontent.com/hajimehoshi/ebiten/HEAD/skills/run-ebitengine-app-headless/SKILL.md","title":"run-ebitengine-app-headless","category":"anthropic-skill","format":"markdown","content":"---\nname: run-ebitengine-app-headless\ndescription: >\n  Use this skill to run an Ebitengine app (anything implementing\n  ebiten.Game) headlessly and programmatically — no visible window and\n  no changes to the app's source — to test, debug, screenshot, or\n  otherwise exercise it. It launches the app as a vmhost guest, drives it\n  through its ticks faster than real time, injects keyboard / mouse /\n  touch / gamepad / text input, reads the rendered frame back as pixels\n  to assert on or dump as a PNG, and observes the audio the app plays.\n  Use when you need input injection, deterministic multi-tick\n  runs, golden-image checks, or audio assertions — for example to\n  reproduce an input-dependent bug, verify behavior after a sequence of\n  clicks or key presses, check that the right sound plays, capture a single\n  rendered frame, or let an AI agent drive an app end-to-end.\nallowed-tools: Read, Edit, Write, Bash\n---\n\n# run-ebitengine-app-headless skill\n\n*Targets ebiten commit `7de1780bd` (2026-09-27), verified against it.\n`exp/vmhost` is experimental, so if its API has moved since, the driver and\nthe snippets here may need updating.*\n\nDrive an Ebitengine app from a small **host** program that runs it as a\n**guest** of the `exp/vmhost` package. The host controls the guest's\nticks however it likes — one at a time, many per host frame, or\nfree-running — injects input, reads back rendered frames, and observes the\naudio the app plays, all from a hidden window. The app under test is\nunmodified.\n\n## How it works (mental model)\n\nOnly the host touches a GPU or display; the guest is fully headless.\n\n- **Guest** = the app under test. Its `ebiten.RunGame` connects to the host\n  over a socket *instead of opening a window*, forwarding its graphics\n  commands rather than rendering.\n- **Host** = the small driver you run. It is itself an `ebiten.Game`\n  with `ebiten.SetWindowVisible(false)`, so it has no visible window. It\n  replays the guest's draw commands on the real GPU, and exposes a\n  `vmhost.GuestSession` to step the guest (`AdvanceTicks`/`AdvanceFrame`/\n  `WaitFrame`/`CompositeFrame`), inject input, read pixels back, and observe\n  the audio it plays.\n\n## The endpoint\n\nThe guest reaches the host through an **endpoint** — the host listener's\naddress as a URL, e.g. `unix:///path/to/socket` or `tcp://127.0.0.1:PORT`\n(the driver builds one from its listener with `vmhost.EndpointURLFromAddr`).\nThe endpoint reaches the guest one of two ways:\n\n- Built with `-tags ebitenginevmguest`, the guest reads the\n  `EBITENGINE_VM_ENDPOINT` environment variable (no source change — this is\n  what the driver uses).\n- With no build tag, the app's code sets `RunGameOptions.VMGuestEndpoint`.\n\n## The driver\n\nA host driver lives at\n[_driver/main.go](skills/run-ebitengine-app-headless/_driver/main.go). Treat it\nas a **starting-point template**, not a finished tool: copy it and adapt the\ninput script, assertions, and screen size to your case. It runs as-is only for\nthe trivial screenshot (recipe step 1); it is a skill asset, not a stable\ncommand, so don't depend on its flags.\n\n## When to use\n\n- You need to **inject input** (keys, mouse, wheel, runes, touches,\n  gamepads) and observe how the app responds — bugs that only reproduce\n  after a click/drag/keystroke sequence.\n- You need to **fast-forward to a state and assert it** — advance N ticks\n  deterministically (compressed, so a long run is near-instant, not\n  real-time), then check the resulting frame. Interleave to step frame by\n  frame when you need every one.\n- You want a **golden-image / pixel assertion** without a human running\n  the app and pasting a screenshot.\n- You just want a **screenshot** of the app — run the driver with no input\n  script and dump the frame after N ticks (recipe step 1).\n- You want to **check audio** — verify the app plays the expected sound, at\n  the expected sample rate and volume, as raw PCM per player (un-mixed),\n  without playing anything.\n\n## When NOT to use\n\n- You're testing pure logic with no rendering — write an ordinary Go\n  test.\n- Target platform is android / ios / js — `vmhost` is desktop-only.\n\n## Recipe\n\nEverything here is built on the driver at\n[_driver/main.go](skills/run-ebitengine-app-headless/_driver/main.go): step 1\nruns it as-is, steps 2–3 copy and adapt it. The Go snippets below are excerpts\nof its `Update`, so `d.guest` (the `*vmhost.GuestSession`), `d.screen`, and\n`*ticks` (the `-ticks` flag value) are the driver's own identifiers.\n\nRun these examples from the **ebiten repo root** — the `-pkg ./examples/...`\npaths below are repo-relative. To test your own app in another module, copy\nor invoke this driver from that module's root and point `-pkg` at the guest\npackage there. The driver imports `github.com/hajimehoshi/ebiten/v2/exp/vmhost`,\nso the host and guest must resolve to the same Ebitengine version; use your\nmodule's `go.mod` or `replace` directives to keep them aligned.\n\n### 1. No input — just capture a frame\n\nRun the shipped driver directly against any guest package:\n\n```bash\ngo run ./skills/run-ebitengine-app-headless/_driver \\\n  -pkg ./examples/rotate -ticks 60 -out /tmp/frame.png\n```\n\nFlags: `-pkg` (guest package), `-ticks` (how many ticks to run before\ndumping + exiting), `-out` (PNG path), `-w`/`-h` (logical screen size,\ndefault 320×240). In a restricted sandbox, `go run` may need a writable\n`GOCACHE` or approval to use the normal Go build cache.\n\n### 2. With input — script the ticks\n\nCopy the driver to a scratch dir, edit the `INPUT SCRIPT` block in\n`Update`, then run the copy (a `.`-prefixed dir stays out of\n`go build ./...`; remove it when you are done):\n\n```bash\nmkdir -p .vmdriver && cp skills/run-ebitengine-app-headless/_driver/main.go .vmdriver/\n# edit .vmdriver/main.go — the INPUT SCRIPT block in Update\ngo run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png\n```\n\nThe injectors on `*vmhost.GuestSession`. Injected state is observed at the\n**next** `AdvanceTicks` and persists until changed, so timing comes from\n*where* you inject in the run — split the run into `AdvanceTicks` segments and\ninject between them:\n\n```go\n// keyboard\nd.guest.PressKey(ebiten.KeyArrowRight)\nd.guest.ReleaseKey(ebiten.KeyArrowRight)\nd.guest.TypeRune('a')                 // a typed character (input chars)\n// mouse — coordinates are outside-screen device-independent pixels\nd.guest.MoveCursor(160, 120)\nd.guest.PressMouseButton(ebiten.MouseButtonLeft)\nd.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\nd.guest.ScrollWheel(0, -1)\n// touch — id, then device-independent pixels\nd.guest.PressTouch(0, 100, 100)\nd.guest.MoveTouch(0, 120, 100)\nd.guest.ReleaseTouch(0)\n// gamepads — full snapshot each call; see vmhost.GamepadState\nd.guest.UpdateGamepads([]vmhost.GamepadState{ /* ... */ })\n// gamepad touchpad — TouchSurfaces lists each surface's current touches,\n// positions in [0, 1] with (0, 0) at the top left\nd.guest.UpdateGamepads([]vmhost.GamepadState{{\n\tID:   0,\n\tName: \"Pad\",\n\tTouchSurfaces: [][]vmhost.GamepadTouchState{\n\t\t{{ID: 1, X: 0.25, Y: 0.5}},\n\t},\n}})\n```\n\nA gamepad touch is identified by its `ID` across `UpdateGamepads` calls: a\ntouch the previous call reported on the same surface with the same ID is the\nsame touch (it keeps its `ebiten.GamepadTouchID` in the guest), and any other\nis a new touch. To lift a finger, send a snapshot without it; to put down a\nnew finger, use an ID the previous snapshot did not have. Every call is a\nsnapshot the guest tracks, so leaving a touch out of one call ends it even if\nno tick runs before the next call. A surface tracks at most 16 touches at\nonce.\n\nTick counts are in the guest's own time units — see\n[Ticks and TPS](#ticks-and-tps) to convert seconds of app time into\n`AdvanceTicks` counts.\n\nExample — use `-ticks` as the total run length, settle 30 ticks, hold a\nclick for one tick, then run the remaining ticks — as the `INPUT SCRIPT`\nblock. For shorter total runs, reduce the settle count first.\n\n```go\nd.guest.AdvanceTicks(30) // let the app settle\nd.guest.MoveCursor(160, 120)\nd.guest.PressMouseButton(ebiten.MouseButtonLeft)\nd.guest.AdvanceTicks(1)  // one tick with the button held\nd.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\nif rest := *ticks - 31; rest > 0 {\n\td.guest.AdvanceTicks(rest)\n}\n```\n\n### 3. Many states — snapshot each one\n\nThe driver's `snapshot(i)` method renders the guest's *current* state and\nwrites it to a numbered PNG derived from `-out` (`frame.png` →\n`frame_00.png`, `frame_01.png`, …). Call it between input segments in the\n`INPUT SCRIPT` block to walk the app through N states and capture every\none — in practice the highest-value pattern (for example, clicking through\neach page of a multi-page app and golden-comparing all of them):\n\n```go\nd.guest.AdvanceTicks(30) // let the app settle\nfor page := 0; page < pageCount; page++ {\n\tif page > 0 {\n\t\td.guest.MoveCursor(nextButtonX, nextButtonY) // click \"next page\"\n\t\td.guest.PressMouseButton(ebiten.MouseButtonLeft)\n\t\td.guest.AdvanceTicks(1)\n\t\td.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\n\t\td.guest.AdvanceTicks(30)\n\t}\n\tif err := d.snapshot(page); err != nil {\n\t\treturn err\n\t}\n}\n```\n\nEach `snapshot` runs the `AdvanceFrame` / `WaitFrame` / `CompositeFrame`\nsequence itself, so the numbered PNGs are exact frames; the final\n`-out` frame at the end of `Update` is still written as usual.\n\n### 4. Inspect the output\n\nInspect the PNG with the available image-viewing tool. For a programmatic\nassertion, read pixels in `Update` after `CompositeFrame` and compare —\n`d.screen.ReadPixels(buf)` gives premultiplied-alpha RGBA, 4 bytes per\npixel. Input coordinates and `-w`/`-h` are logical device-independent pixels,\nbut `d.screen` and the PNG are physical pixels (`logical × DeviceScaleFactor`;\nfor example, a 320×240 logical screen can produce a 640×480 PNG on a 2x\ndisplay). Use `b := d.screen.Bounds()` for assertion dimensions; the center\npixel of that physical `w*h` image is at `4*((h/2)*w + w/2)`. For a golden\ncheck, dump once, eyeball it, then compare bytes against the saved PNG on\nlater runs — see [Verifying a run](#verifying-a-run) for the pitfalls.\n\n## Verifying a run\n\nJudge success by the **presence of the output PNG** (or the driver's final\n`wrote frame` log line) — never by the exit status observed through a\npipe. Don't pipe the driver's output through `tail -1` or send it to\n`/dev/null`: when the driver fails, its actual one-line error (the\n`fmt.Fprintln` in `main`) is easily discarded, leaving only `go run`'s\ngeneric `exit status 1` with nothing to diagnose. Save the full output to\na log file and read it on failure:\n\n```bash\ngo run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png \\\n  > /tmp/run.log 2>&1 || cat /tmp/run.log\n```\n\nWhen byte-comparing captures with `cmp -s`, check that both files exist\nfirst: a missing file (from a capture that silently failed) also reads as\na difference, masquerading as a pixel regression.\n\n### Golden baselines across a code change\n\nTo golden-compare before/after a code change, capture the baseline from\nthe committed state via `git show <commit>:<file>` extraction or a\ntemporary commit — **not** `git stash` round-trips: the stash list is\nrepo-global and shared across worktrees, so a stray `git stash pop` can\ngrab an unrelated stash entry belonging to the user.\n\n## How the driver drives the guest\n\nThe whole run happens in one host `Update`:\n\n```go\nd.guest.AdvanceTicks(*ticks) // run every tick back-to-back, no real-time pacing\nd.guest.AdvanceFrame()       // request the final frame\nd.guest.WaitFrame()          // block until all queued ticks have run and it is rendered\nd.guest.CompositeFrame()     // composite it into the host-owned screen image; check the bool in assertions\n```\n\n`AdvanceTicks(*ticks)` queues every tick at once; `WaitFrame` then blocks\nuntil they have all run and the final frame is rendered, so one wait covers\nthe lot. That order keeps capture deterministic — the screen reflects exactly\nthe run's end. Only the one frame is rendered, because a tick forwards no\nrendering; only a frame does.\n\n**This compresses wall-clock time** — the point of the VM. The guest is not\npaced to the host's ~60 TPS, so a 1000-tick run finishes near-instantly\ninstead of taking ~16 real seconds. To capture intermediate frames or pace\ninput by hand, interleave instead: `AdvanceTicks(n)`, `AdvanceFrame`,\n`WaitFrame`, `CompositeFrame`, repeat. `PendingTicks` and `WaitTicks` track the\nbacklog; ticks coalesce, so queuing many is cheap.\n\n`guest.Err()` becomes non-nil when the guest terminates, crashes, or times\nout; a panicking guest prints its stack to stderr and the driver stops. The\ndriver also puts a deadline on accepting the guest connection so a guest that\nnever dials the host fails instead of hanging indefinitely.\n\n## Ticks and TPS\n\nA tick is one guest `Update` call, and `AdvanceTicks(n)` runs exactly n of\nthem. The guest's TPS never throttles or scales this — how many ticks to run,\nand at what real-time rate if any, is entirely the host's choice.\n\nTPS matters as a unit conversion. The guest's game logic is written assuming\n`Update` runs TPS times per second of real time, so its timers, animations,\nand cooldowns count in those units. `d.guest.RequestedTPS()` reports what the\ngame requested via `ebiten.SetTPS` (the standard 60 until changed). To\nsimulate a duration of app time, multiply seconds by that value: one second\nin a 30-TPS game is `AdvanceTicks(30)`, not 60.\n\n`RequestedTPS` can also return `ebiten.SyncWithFPS` (-1), meaning the game\nties its ticks to rendered frames rather than a fixed rate. There is no\nseconds-to-ticks conversion then; treat a tick as a frame and choose counts\nby observing the app.\n\n## Observing audio\n\nThe guest plays no local audio; instead each audio stream it starts is\nhanded to the host through the `OnAudioStream` handler registered when the\nsession is created, as a separate, **un-mixed** `*vmhost.GuestAudioStream`.\nThe driver already wires this up: `d.onAudioStream` collects the streams and\n`d.appendAudioStreams(nil)` hands back the still-open ones (dropping any the\nguest has closed). The audio methods are\ngoroutine-safe and not frame-bound (unlike `CompositeFrame`/`Close`), so\ninspect them anywhere; sample data is pulled from the guest on demand. Drop\nthis into `Update` once the ticks have run (e.g. after `WaitFrame`, so the\nstreams the run started have been delivered):\n\n```go\nrate := d.guest.AudioSampleRate()             // per-channel samples/sec; 0 until audio plays\nfor i, s := range d.appendAudioStreams(nil) { // one entry per still-open guest player\n    slog.Info(\"guest audio player\", \"i\", i,\n        \"playing\", s.IsPlaying(), \"volume\", s.Volume(), \"position\", s.Position())\n    // Raw PCM: 32-bit little-endian floats, two interleaved channels, at `rate`.\n    // Read pulls on demand; volume is reported but NOT applied to these samples.\n    buf := make([]byte, 4096)\n    n, _ := s.Read(buf) // 0 bytes while paused; io.EOF once the guest closes/ends the source\n    slog.Info(\"read PCM\", \"bytes\", n, \"rate\", rate)\n}\n```\n\n`OnAudioStream` fires once per stream, during `AdvanceTicks` and `WaitTicks`\non the goroutine calling them, when the guest starts it. The driver's handler\nonly stashes the handle; read the samples from `Update`, as above. A stream\nstays valid after `io.EOF` (a seek-and-replay yields more samples); once the\nguest closes its player, `IsClosed()` returns true and `Read` reports\n`io.EOF` for good, so `appendAudioStreams` drops it — returning only the\nguest's still-open streams. To actually *hear* it rather than just inspect it,\nfeed the stream to a host `audio.Player` as its source — see\n[examples/vm/main.go](examples/vm/main.go).\n\n## Gotchas\n\n- **Host and guest must be the same ebiten version.** The host/guest\n  protocol is version-locked. The driver builds the guest in your current\n  module, so they match automatically — but a guest built against a\n  different ebiten version (such as a prebuilt binary) fails the handshake.\n- **The host still needs a display/graphics context.** It is *windowless*,\n  not *displayless*: on macOS it works from a normal shell; on bare\n  headless Linux CI you still need Xvfb or an EGL setup to create the\n  hidden window. The guest needs neither.\n- **`SetOutsideScreen` before the first `AdvanceTicks`**, and the image is\n  in **physical** pixels (logical size × `DeviceScaleFactor`). The driver\n  handles this; keep it if you change the screen size.\n- **Call `GuestSession.Close` from the host frame.** The driver closes the\n  guest inside `Update`, after dumping the frame. Keep that shape if you copy\n  the template; `cmd.Wait` can run after `ebiten.RunGame` returns. Losing the\n  host ends the closed guest's `RunGame` without an error, so it exits 0 on\n  its own — the driver waits for it rather than killing it, and treats a\n  non-zero exit as a genuine guest crash.\n- **Launches are occasionally flaky.** Back-to-back runs sometimes fail on\n  the first attempt while an identical rerun succeeds (root cause not\n  identified; possibly socket/handshake timing). Retry once before\n  debugging.\n- **Wall-clock isn't deterministic.** Tick *count* is exact, but code\n  using `time.Now()` still sees real time between ticks.\n- **`exp/vmhost` is experimental** — its API may change.\n- **Unix socket path length is limited** (~104 bytes on macOS), so the\n  driver keeps its socket in a short temp dir. Unix sockets work on every\n  desktop target, Windows included; a `tcp` loopback listener\n  (`127.0.0.1:0`) is an equivalent alternative if a filesystem socket is\n  inconvenient.\n\n## In-repo alternative (Go test)\n\nTo exercise an app as a guest from a `go test` instead of a standalone\ndriver, mirror the package's own end-to-end tests:\n[exp/vmhost/guest_test.go](exp/vmhost/guest_test.go) (the `startGuest` /\n`tickAndFrame` helpers) and\n[exp/vmhost/readpixels_test.go](exp/vmhost/readpixels_test.go). These use\n`internal/testing`'s `MainWithRunLoop`, which is importable only inside\nthe ebiten module.\n","frontmatter":{"name":"run-ebitengine-app-headless","description":"Use this skill to run an Ebitengine app (anything implementing ebiten.Game) headlessly and programmatically — no visible window and no changes to the app's source — to test, debug, screenshot, or otherwise exercise it. It launches the app as a vmhost guest, drives it through its ticks faster than real time, injects keyboard / mouse / touch / gamepad / text input, reads the rendered frame back as pixels to assert on or dump as a PNG, and observes the audio the app plays. Use when you need input injection, deterministic multi-tick runs, golden-image checks, or audio assertions — for example to reproduce an input-dependent bug, verify behavior after a sequence of clicks or key presses, check that the right sound plays, capture a single rendered frame, or let an AI agent drive an app end-to-end.\n","allowed-tools":"Read, Edit, Write, Bash","allowedTools":["Read,","Edit,","Write,","Bash"]},"allowedTools":["Read,","Edit,","Write,","Bash"],"isInternal":false,"tokens":4718,"sizeBytes":18064}],"systemPromptSnippet":"<yakaai_skills repo=\"hajimehoshi/ebiten\">\n<!-- File: skills/run-ebitengine-app-headless/SKILL.md (Tokens: ~4718 | Category: anthropic-skill) -->\n---\nname: run-ebitengine-app-headless\ndescription: >\n  Use this skill to run an Ebitengine app (anything implementing\n  ebiten.Game) headlessly and programmatically — no visible window and\n  no changes to the app's source — to test, debug, screenshot, or\n  otherwise exercise it. It launches the app as a vmhost guest, drives it\n  through its ticks faster than real time, injects keyboard / mouse /\n  touch / gamepad / text input, reads the rendered frame back as pixels\n  to assert on or dump as a PNG, and observes the audio the app plays.\n  Use when you need input injection, deterministic multi-tick\n  runs, golden-image checks, or audio assertions — for example to\n  reproduce an input-dependent bug, verify behavior after a sequence of\n  clicks or key presses, check that the right sound plays, capture a single\n  rendered frame, or let an AI agent drive an app end-to-end.\nallowed-tools: Read, Edit, Write, Bash\n---\n\n# run-ebitengine-app-headless skill\n\n*Targets ebiten commit `7de1780bd` (2026-09-27), verified against it.\n`exp/vmhost` is experimental, so if its API has moved since, the driver and\nthe snippets here may need updating.*\n\nDrive an Ebitengine app from a small **host** program that runs it as a\n**guest** of the `exp/vmhost` package. The host controls the guest's\nticks however it likes — one at a time, many per host frame, or\nfree-running — injects input, reads back rendered frames, and observes the\naudio the app plays, all from a hidden window. The app under test is\nunmodified.\n\n## How it works (mental model)\n\nOnly the host touches a GPU or display; the guest is fully headless.\n\n- **Guest** = the app under test. Its `ebiten.RunGame` connects to the host\n  over a socket *instead of opening a window*, forwarding its graphics\n  commands rather than rendering.\n- **Host** = the small driver you run. It is itself an `ebiten.Game`\n  with `ebiten.SetWindowVisible(false)`, so it has no visible window. It\n  replays the guest's draw commands on the real GPU, and exposes a\n  `vmhost.GuestSession` to step the guest (`AdvanceTicks`/`AdvanceFrame`/\n  `WaitFrame`/`CompositeFrame`), inject input, read pixels back, and observe\n  the audio it plays.\n\n## The endpoint\n\nThe guest reaches the host through an **endpoint** — the host listener's\naddress as a URL, e.g. `unix:///path/to/socket` or `tcp://127.0.0.1:PORT`\n(the driver builds one from its listener with `vmhost.EndpointURLFromAddr`).\nThe endpoint reaches the guest one of two ways:\n\n- Built with `-tags ebitenginevmguest`, the guest reads the\n  `EBITENGINE_VM_ENDPOINT` environment variable (no source change — this is\n  what the driver uses).\n- With no build tag, the app's code sets `RunGameOptions.VMGuestEndpoint`.\n\n## The driver\n\nA host driver lives at\n[_driver/main.go](skills/run-ebitengine-app-headless/_driver/main.go). Treat it\nas a **starting-point template**, not a finished tool: copy it and adapt the\ninput script, assertions, and screen size to your case. It runs as-is only for\nthe trivial screenshot (recipe step 1); it is a skill asset, not a stable\ncommand, so don't depend on its flags.\n\n## When to use\n\n- You need to **inject input** (keys, mouse, wheel, runes, touches,\n  gamepads) and observe how the app responds — bugs that only reproduce\n  after a click/drag/keystroke sequence.\n- You need to **fast-forward to a state and assert it** — advance N ticks\n  deterministically (compressed, so a long run is near-instant, not\n  real-time), then check the resulting frame. Interleave to step frame by\n  frame when you need every one.\n- You want a **golden-image / pixel assertion** without a human running\n  the app and pasting a screenshot.\n- You just want a **screenshot** of the app — run the driver with no input\n  script and dump the frame after N ticks (recipe step 1).\n- You want to **check audio** — verify the app plays the expected sound, at\n  the expected sample rate and volume, as raw PCM per player (un-mixed),\n  without playing anything.\n\n## When NOT to use\n\n- You're testing pure logic with no rendering — write an ordinary Go\n  test.\n- Target platform is android / ios / js — `vmhost` is desktop-only.\n\n## Recipe\n\nEverything here is built on the driver at\n[_driver/main.go](skills/run-ebitengine-app-headless/_driver/main.go): step 1\nruns it as-is, steps 2–3 copy and adapt it. The Go snippets below are excerpts\nof its `Update`, so `d.guest` (the `*vmhost.GuestSession`), `d.screen`, and\n`*ticks` (the `-ticks` flag value) are the driver's own identifiers.\n\nRun these examples from the **ebiten repo root** — the `-pkg ./examples/...`\npaths below are repo-relative. To test your own app in another module, copy\nor invoke this driver from that module's root and point `-pkg` at the guest\npackage there. The driver imports `github.com/hajimehoshi/ebiten/v2/exp/vmhost`,\nso the host and guest must resolve to the same Ebitengine version; use your\nmodule's `go.mod` or `replace` directives to keep them aligned.\n\n### 1. No input — just capture a frame\n\nRun the shipped driver directly against any guest package:\n\n```bash\ngo run ./skills/run-ebitengine-app-headless/_driver \\\n  -pkg ./examples/rotate -ticks 60 -out /tmp/frame.png\n```\n\nFlags: `-pkg` (guest package), `-ticks` (how many ticks to run before\ndumping + exiting), `-out` (PNG path), `-w`/`-h` (logical screen size,\ndefault 320×240). In a restricted sandbox, `go run` may need a writable\n`GOCACHE` or approval to use the normal Go build cache.\n\n### 2. With input — script the ticks\n\nCopy the driver to a scratch dir, edit the `INPUT SCRIPT` block in\n`Update`, then run the copy (a `.`-prefixed dir stays out of\n`go build ./...`; remove it when you are done):\n\n```bash\nmkdir -p .vmdriver && cp skills/run-ebitengine-app-headless/_driver/main.go .vmdriver/\n# edit .vmdriver/main.go — the INPUT SCRIPT block in Update\ngo run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png\n```\n\nThe injectors on `*vmhost.GuestSession`. Injected state is observed at the\n**next** `AdvanceTicks` and persists until changed, so timing comes from\n*where* you inject in the run — split the run into `AdvanceTicks` segments and\ninject between them:\n\n```go\n// keyboard\nd.guest.PressKey(ebiten.KeyArrowRight)\nd.guest.ReleaseKey(ebiten.KeyArrowRight)\nd.guest.TypeRune('a')                 // a typed character (input chars)\n// mouse — coordinates are outside-screen device-independent pixels\nd.guest.MoveCursor(160, 120)\nd.guest.PressMouseButton(ebiten.MouseButtonLeft)\nd.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\nd.guest.ScrollWheel(0, -1)\n// touch — id, then device-independent pixels\nd.guest.PressTouch(0, 100, 100)\nd.guest.MoveTouch(0, 120, 100)\nd.guest.ReleaseTouch(0)\n// gamepads — full snapshot each call; see vmhost.GamepadState\nd.guest.UpdateGamepads([]vmhost.GamepadState{ /* ... */ })\n// gamepad touchpad — TouchSurfaces lists each surface's current touches,\n// positions in [0, 1] with (0, 0) at the top left\nd.guest.UpdateGamepads([]vmhost.GamepadState{{\n\tID:   0,\n\tName: \"Pad\",\n\tTouchSurfaces: [][]vmhost.GamepadTouchState{\n\t\t{{ID: 1, X: 0.25, Y: 0.5}},\n\t},\n}})\n```\n\nA gamepad touch is identified by its `ID` across `UpdateGamepads` calls: a\ntouch the previous call reported on the same surface with the same ID is the\nsame touch (it keeps its `ebiten.GamepadTouchID` in the guest), and any other\nis a new touch. To lift a finger, send a snapshot without it; to put down a\nnew finger, use an ID the previous snapshot did not have. Every call is a\nsnapshot the guest tracks, so leaving a touch out of one call ends it even if\nno tick runs before the next call. A surface tracks at most 16 touches at\nonce.\n\nTick counts are in the guest's own time units — see\n[Ticks and TPS](#ticks-and-tps) to convert seconds of app time into\n`AdvanceTicks` counts.\n\nExample — use `-ticks` as the total run length, settle 30 ticks, hold a\nclick for one tick, then run the remaining ticks — as the `INPUT SCRIPT`\nblock. For shorter total runs, reduce the settle count first.\n\n```go\nd.guest.AdvanceTicks(30) // let the app settle\nd.guest.MoveCursor(160, 120)\nd.guest.PressMouseButton(ebiten.MouseButtonLeft)\nd.guest.AdvanceTicks(1)  // one tick with the button held\nd.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\nif rest := *ticks - 31; rest > 0 {\n\td.guest.AdvanceTicks(rest)\n}\n```\n\n### 3. Many states — snapshot each one\n\nThe driver's `snapshot(i)` method renders the guest's *current* state and\nwrites it to a numbered PNG derived from `-out` (`frame.png` →\n`frame_00.png`, `frame_01.png`, …). Call it between input segments in the\n`INPUT SCRIPT` block to walk the app through N states and capture every\none — in practice the highest-value pattern (for example, clicking through\neach page of a multi-page app and golden-comparing all of them):\n\n```go\nd.guest.AdvanceTicks(30) // let the app settle\nfor page := 0; page < pageCount; page++ {\n\tif page > 0 {\n\t\td.guest.MoveCursor(nextButtonX, nextButtonY) // click \"next page\"\n\t\td.guest.PressMouseButton(ebiten.MouseButtonLeft)\n\t\td.guest.AdvanceTicks(1)\n\t\td.guest.ReleaseMouseButton(ebiten.MouseButtonLeft)\n\t\td.guest.AdvanceTicks(30)\n\t}\n\tif err := d.snapshot(page); err != nil {\n\t\treturn err\n\t}\n}\n```\n\nEach `snapshot` runs the `AdvanceFrame` / `WaitFrame` / `CompositeFrame`\nsequence itself, so the numbered PNGs are exact frames; the final\n`-out` frame at the end of `Update` is still written as usual.\n\n### 4. Inspect the output\n\nInspect the PNG with the available image-viewing tool. For a programmatic\nassertion, read pixels in `Update` after `CompositeFrame` and compare —\n`d.screen.ReadPixels(buf)` gives premultiplied-alpha RGBA, 4 bytes per\npixel. Input coordinates and `-w`/`-h` are logical device-independent pixels,\nbut `d.screen` and the PNG are physical pixels (`logical × DeviceScaleFactor`;\nfor example, a 320×240 logical screen can produce a 640×480 PNG on a 2x\ndisplay). Use `b := d.screen.Bounds()` for assertion dimensions; the center\npixel of that physical `w*h` image is at `4*((h/2)*w + w/2)`. For a golden\ncheck, dump once, eyeball it, then compare bytes against the saved PNG on\nlater runs — see [Verifying a run](#verifying-a-run) for the pitfalls.\n\n## Verifying a run\n\nJudge success by the **presence of the output PNG** (or the driver's final\n`wrote frame` log line) — never by the exit status observed through a\npipe. Don't pipe the driver's output through `tail -1` or send it to\n`/dev/null`: when the driver fails, its actual one-line error (the\n`fmt.Fprintln` in `main`) is easily discarded, leaving only `go run`'s\ngeneric `exit status 1` with nothing to diagnose. Save the full output to\na log file and read it on failure:\n\n```bash\ngo run ./.vmdriver -pkg ./examples/paint -ticks 120 -out /tmp/frame.png \\\n  > /tmp/run.log 2>&1 || cat /tmp/run.log\n```\n\nWhen byte-comparing captures with `cmp -s`, check that both files exist\nfirst: a missing file (from a capture that silently failed) also reads as\na difference, masquerading as a pixel regression.\n\n### Golden baselines across a code change\n\nTo golden-compare before/after a code change, capture the baseline from\nthe committed state via `git show <commit>:<file>` extraction or a\ntemporary commit — **not** `git stash` round-trips: the stash list is\nrepo-global and shared across worktrees, so a stray `git stash pop` can\ngrab an unrelated stash entry belonging to the user.\n\n## How the driver drives the guest\n\nThe whole run happens in one host `Update`:\n\n```go\nd.guest.AdvanceTicks(*ticks) // run every tick back-to-back, no real-time pacing\nd.guest.AdvanceFrame()       // request the final frame\nd.guest.WaitFrame()          // block until all queued ticks have run and it is rendered\nd.guest.CompositeFrame()     // composite it into the host-owned screen image; check the bool in assertions\n```\n\n`AdvanceTicks(*ticks)` queues every tick at once; `WaitFrame` then blocks\nuntil they have all run and the final frame is rendered, so one wait covers\nthe lot. That order keeps capture deterministic — the screen reflects exactly\nthe run's end. Only the one frame is rendered, because a tick forwards no\nrendering; only a frame does.\n\n**This compresses wall-clock time** — the point of the VM. The guest is not\npaced to the host's ~60 TPS, so a 1000-tick run finishes near-instantly\ninstead of taking ~16 real seconds. To capture intermediate frames or pace\ninput by hand, interleave instead: `AdvanceTicks(n)`, `AdvanceFrame`,\n`WaitFrame`, `CompositeFrame`, repeat. `PendingTicks` and `WaitTicks` track the\nbacklog; ticks coalesce, so queuing many is cheap.\n\n`guest.Err()` becomes non-nil when the guest terminates, crashes, or times\nout; a panicking guest prints its stack to stderr and the driver stops. The\ndriver also puts a deadline on accepting the guest connection so a guest that\nnever dials the host fails instead of hanging indefinitely.\n\n## Ticks and TPS\n\nA tick is one guest `Update` call, and `AdvanceTicks(n)` runs exactly n of\nthem. The guest's TPS never throttles or scales this — how many ticks to run,\nand at what real-time rate if any, is entirely the host's choice.\n\nTPS matters as a unit conversion. The guest's game logic is written assuming\n`Update` runs TPS times per second of real time, so its timers, animations,\nand cooldowns count in those units. `d.guest.RequestedTPS()` reports what the\ngame requested via `ebiten.SetTPS` (the standard 60 until changed). To\nsimulate a duration of app time, multiply seconds by that value: one second\nin a 30-TPS game is `AdvanceTicks(30)`, not 60.\n\n`RequestedTPS` can also return `ebiten.SyncWithFPS` (-1), meaning the game\nties its ticks to rendered frames rather than a fixed rate. There is no\nseconds-to-ticks conversion then; treat a tick as a frame and choose counts\nby observing the app.\n\n## Observing audio\n\nThe guest plays no local audio; instead each audio stream it starts is\nhanded to the host through the `OnAudioStream` handler registered when the\nsession is created, as a separate, **un-mixed** `*vmhost.GuestAudioStream`.\nThe driver already wires this up: `d.onAudioStream` collects the streams and\n`d.appendAudioStreams(nil)` hands back the still-open ones (dropping any the\nguest has closed). The audio methods are\ngoroutine-safe and not frame-bound (unlike `CompositeFrame`/`Close`), so\ninspect them anywhere; sample data is pulled from the guest on demand. Drop\nthis into `Update` once the ticks have run (e.g. after `WaitFrame`, so the\nstreams the run started have been delivered):\n\n```go\nrate := d.guest.AudioSampleRate()             // per-channel samples/sec; 0 until audio plays\nfor i, s := range d.appendAudioStreams(nil) { // one entry per still-open guest player\n    slog.Info(\"guest audio player\", \"i\", i,\n        \"playing\", s.IsPlaying(), \"volume\", s.Volume(), \"position\", s.Position())\n    // Raw PCM: 32-bit little-endian floats, two interleaved channels, at `rate`.\n    // Read pulls on demand; volume is reported but NOT applied to these samples.\n    buf := make([]byte, 4096)\n    n, _ := s.Read(buf) // 0 bytes while paused; io.EOF once the guest closes/ends the source\n    slog.Info(\"read PCM\", \"bytes\", n, \"rate\", rate)\n}\n```\n\n`OnAudioStream` fires once per stream, during `AdvanceTicks` and `WaitTicks`\non the goroutine calling them, when the guest starts it. The driver's handler\nonly stashes the handle; read the samples from `Update`, as above. A stream\nstays valid after `io.EOF` (a seek-and-replay yields more samples); once the\nguest closes its player, `IsClosed()` returns true and `Read` reports\n`io.EOF` for good, so `appendAudioStreams` drops it — returning only the\nguest's still-open streams. To actually *hear* it rather than just inspect it,\nfeed the stream to a host `audio.Player` as its source — see\n[examples/vm/main.go](examples/vm/main.go).\n\n## Gotchas\n\n- **Host and guest must be the same ebiten version.** The host/guest\n  protocol is version-locked. The driver builds the guest in your current\n  module, so they match automatically — but a guest built against a\n  different ebiten version (such as a prebuilt binary) fails the handshake.\n- **The host still needs a display/graphics context.** It is *windowless*,\n  not *displayless*: on macOS it works from a normal shell; on bare\n  headless Linux CI you still need Xvfb or an EGL setup to create the\n  hidden window. The guest needs neither.\n- **`SetOutsideScreen` before the first `AdvanceTicks`**, and the image is\n  in **physical** pixels (logical size × `DeviceScaleFactor`). The driver\n  handles this; keep it if you change the screen size.\n- **Call `GuestSession.Close` from the host frame.** The driver closes the\n  guest inside `Update`, after dumping the frame. Keep that shape if you copy\n  the template; `cmd.Wait` can run after `ebiten.RunGame` returns. Losing the\n  host ends the closed guest's `RunGame` without an error, so it exits 0 on\n  its own — the driver waits for it rather than killing it, and treats a\n  non-zero exit as a genuine guest crash.\n- **Launches are occasionally flaky.** Back-to-back runs sometimes fail on\n  the first attempt while an identical rerun succeeds (root cause not\n  identified; possibly socket/handshake timing). Retry once before\n  debugging.\n- **Wall-clock isn't deterministic.** Tick *count* is exact, but code\n  using `time.Now()` still sees real time between ticks.\n- **`exp/vmhost` is experimental** — its API may change.\n- **Unix socket path length is limited** (~104 bytes on macOS), so the\n  driver keeps its socket in a short temp dir. Unix sockets work on every\n  desktop target, Windows included; a `tcp` loopback listener\n  (`127.0.0.1:0`) is an equivalent alternative if a filesystem socket is\n  inconvenient.\n\n## In-repo alternative (Go test)\n\nTo exercise an app as a guest from a `go test` instead of a standalone\ndriver, mirror the package's own end-to-end tests:\n[exp/vmhost/guest_test.go](exp/vmhost/guest_test.go) (the `startGuest` /\n`tickAndFrame` helpers) and\n[exp/vmhost/readpixels_test.go](exp/vmhost/readpixels_test.go). These use\n`internal/testing`'s `MainWithRunLoop`, which is importable only inside\nthe ebiten module.\n</yakaai_skills>"}