diff --git a/.github/actions/artifact-check/action.yml b/.github/actions/artifact-check/action.yml new file mode 100644 index 0000000..de741e3 --- /dev/null +++ b/.github/actions/artifact-check/action.yml @@ -0,0 +1,8 @@ +name: Artifact Check +description: Verify the shipping Linux archive's Godot export, replay and terminal player. +runs: + using: composite + steps: + - name: Real Godot terminal artifact check + shell: bash + run: make artifact-check diff --git a/.github/actions/godot-display/action.yml b/.github/actions/godot-display/action.yml new file mode 100644 index 0000000..1fd1d76 --- /dev/null +++ b/.github/actions/godot-display/action.yml @@ -0,0 +1,10 @@ +name: Godot Rendering Dependencies +description: Install the private X server and Mesa software renderer used by real viewport tests. +runs: + using: composite + steps: + - name: Install virtual display and software OpenGL + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends xvfb libgl1-mesa-dri libglx-mesa0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7a1daa..28fc63a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + - uses: ./.github/actions/godot-display + - name: Install pinned build and validation tools run: make install @@ -40,3 +42,6 @@ jobs: - name: Test uses: ./.github/actions/test + + - name: Artifact Check + uses: ./.github/actions/artifact-check diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 8db0bd7..71274d5 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -71,6 +71,8 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + - uses: ./.github/actions/godot-display + - name: Install pinned build and validation tools run: make install diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df05655..b31519d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -76,6 +76,8 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" - uses: jdx/mise-action@7a4e45a543138629540c9a1616d08632b893e492 # v5.0.1 + - uses: ./.github/actions/godot-display + - name: Install pinned build and validation tools run: make install @@ -93,6 +95,9 @@ jobs: - name: Test uses: ./.github/actions/test + - name: Artifact Check + uses: ./.github/actions/artifact-check + - name: Verify released binary version env: ARCHIVE: dist/termcade_Linux_x86_64.tar.gz diff --git a/.gitignore b/.gitignore index c90a82d..a6e16a3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ game.wasm .artifacts/ __pycache__/ +.godot/ +*.tgd +examples/godot/*/build/ diff --git a/Makefile b/Makefile index 62a0ca5..56f8c0d 100644 --- a/Makefile +++ b/Makefile @@ -3,14 +3,17 @@ SHELL := /bin/bash .DEFAULT_GOAL := help .NOTPARALLEL: VERSION ?= dev -.PHONY: help install lint test build check dev stop clean +export PATH := $(CURDIR)/.artifacts/godot/bin:$(PATH) +.PHONY: help install lint test build artifact-check check dev stop clean help: - @echo 'make check: complete five-platform archive build, lint and race/consumer checks' + @echo 'make check: five-platform archives, lint, race/consumer and real Godot terminal checks' install: + @command -v "$${TERMCADE_XVFB_BIN:-Xvfb}" >/dev/null || { echo "Install Xvfb and Mesa for Godot framebuffer previews (see README)."; exit 1; } mise trust .mise.toml mise install go python actionlint shellcheck mise exec -- go mod download cd sdk && mise exec -- go mod download + mise exec -- python3 scripts/install-godot.py lint: mise exec -- bash scripts/lint.sh mise exec -- actionlint @@ -19,7 +22,9 @@ test: mise exec -- bash scripts/test.sh build: mise exec -- env VERSION="$(VERSION)" bash scripts/build.sh -check: build lint test +artifact-check: + mise exec -- python3 scripts/test-godot-cli.py +check: build lint test artifact-check dev stop: @echo '$@: unsupported: interactive arcade sessions require caller-owned terminal and account configuration' clean: diff --git a/README.md b/README.md index 713b575..d764862 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ + + # termcade A terminal arcade. Classic games rendered at sub-cell resolution with block @@ -293,3 +295,77 @@ go test ./... # unit + shell tests go test ./internal/plugin/ # includes wasm end-to-end tests (builds guests) go vet ./... ``` + +## Godot terminal target (experimental) + +Godot 4.7.2 projects can export to a custom **Termcade** target and play inside +terminal character cells. Godot renders the game's actual viewport, including +cameras, animated sprites, UI, shaders, custom drawing, and 3D. Termcade resizes +that image into its quadrant, sextant, half-block or ASCII cell renderer. A +private virtual display keeps game windows off your desktop; play happens in +the terminal. + +This experimental framebuffer runtime currently requires **Linux**, **Xvfb** +and **Mesa OpenGL**. On Arch install `xorg-server-xvfb mesa`; on Ubuntu install +`xvfb libgl1-mesa-dri libglx-mesa0`. Xvfb must be on PATH, or set +`TERMCADE_XVFB_BIN` to its executable. The preview uses Godot's Compatibility +renderer with software OpenGL. Games requiring Forward+, compute shaders, C#, +native extensions, XR or additional windows are not supported by this runtime. + +Install Godot **4.7.2.stable.official.ed1daf0bf** and make `godot` available on +PATH, or set `GODOT_BIN` to that executable. From a source checkout, `make install` +installs the checksum-pinned Linux engine needed by the full gate. Release +archives do not bundle Godot, Xvfb or Mesa. + +```sh +termcade godot export examples/godot/paddle build/paddle.tgd +termcade godot play --trusted build/paddle.tgd +termcade godot capture --trusted --json --columns 160 --rows 45 --frames 120 build/paddle.tgd frame.png +termcade godot benchmark --trusted --json --columns 160 --rows 45 --frames 120 --warmup 60 build/paddle.tgd +``` + +For your own project, `termcade godot init /path/to/project` installs the addon +and adds a Termcade preset to Godot's Export dialog. The CLI exporter uses that +same platform in a disposable project copy, leaving your source settings and +import cache untouched. Output files must not already exist. Export and capture +support `--json` for success and failure results. Flags precede positional paths. +The new framebuffer protocol requires newly exported packages; re-export packages +created by the earlier software-node prototype. + +Godot renders near the terminal's pixel resolution while retaining the authored +logical coordinates and UI layout. Final conversion uses bilinear filtering. +For games with resolution-dependent shaders or viewport logic, set +`TERMCADE_GODOT_FULL_RES=1` to keep the original render target. Re-export older +packages to get this optimization. The player counts rendering and transfer time +inside its 60 Hz frame budget, avoiding an extra full-frame wait. + +The player uses the available terminal space, preserves the game's aspect ratio, +and resizes captures without restarting gameplay. **Ctrl+P** pauses and **Ctrl+C** +exits. Escape goes to the game so its menus work. Arrow keys, letters, space, tab, +backspace and enter are translated to Godot key events. Terminals with release +reporting supply exact held keys; other terminals use an auto-repeat timeout. +The preview needs at least 72 columns by 22 rows. A larger terminal and smaller +font give games with detailed UI more readable pixels. Game labels are rendered +pixels rather than selectable terminal text. + +Capture accepts `--input replay.json`, an array of `{ "frame": 1, "code": 4194321, +"down": true }` events using Godot key codes; `4194321` is the right arrow. +Each capture starts a fresh engine. Output is a PNG of the framebuffer before +terminal character conversion. Defaults are 72 columns and 20 rows. Capture +resolution is bounded to 600 × 360 pixels across all cell shapes. Determinism +depends on the game's code and inputs. + +`godot benchmark` runs an unpaced replay, excludes `--warmup` frames, and reports +frame-exchange mean/p95, terminal-cell conversion time, ANSI bytes and available +engine render/readback/resize timings. Input replay frame numbers include warmup. +It measures the pipeline, not terminal-emulator paint time or startup latency. +Benchmark and capture share the same bounded input format and resolution limits. + +This is a **local developer target**. `.tgd` packages execute native Godot code +with host access, which is why play and capture require `--trusted`; they are +not Wasm-sandboxed `.tcade` packages and cannot be published to the existing +marketplace. User data is temporary for each preview. Audio, mouse/controller +input, modifier combinations and terminal image protocols are not implemented. +A hung frame terminates its engine and private display rather than blocking the +terminal indefinitely. Ordinary rendering compatibility does not guarantee +that every game is readable or controllable at terminal resolution. diff --git a/cli.go b/cli.go index 36df49c..87b4a81 100644 --- a/cli.go +++ b/cli.go @@ -2,12 +2,14 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "regexp" + "slices" "strings" "github.com/aviorstudio/termcade/internal/plugin" @@ -46,6 +48,14 @@ usage: termcade dev build [dir] build a game directory into a .tcade package termcade dev install install a local .tcade while developing + termcade godot init install the Godot Termcade export target + termcade godot export + validate and export a Godot 2D game + termcade godot play --trusted + play a local Godot game inside the terminal + termcade godot capture --trusted + capture a terminal-resolution frame for agents + This arcade is also the whole dev kit: dev new, hack, dev build, dev install, play. See docs/sdk.md to get started. ` @@ -93,6 +103,8 @@ func runCommand(args []string) bool { default: err = fmt.Errorf("unknown dev subcommand; try: termcade dev new · termcade dev build [dir] · termcade dev install ") } + case "godot": + err = cmdGodot(args[1:]) case "version", "-v", "--version": fmt.Println("termcade", resolveVersion()) case "help", "-h", "--help": @@ -102,6 +114,10 @@ func runCommand(args []string) bool { os.Exit(2) } if err != nil { + if args[0] == "godot" && slices.Contains(args, "--json") { + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": false, "error": err.Error(), "runtime": "godot-terminal-framebuffer-v1"}) + os.Exit(1) + } fmt.Fprintln(os.Stderr, "termcade:", err) os.Exit(1) } diff --git a/engineering-profile.json b/engineering-profile.json index e97788c..cf5b044 100644 --- a/engineering-profile.json +++ b/engineering-profile.json @@ -22,6 +22,10 @@ "make", "check" ], + "artifact-check": [ + "make", + "artifact-check" + ], "clean": [ "make", "clean" diff --git a/examples/godot/paddle/badge.png b/examples/godot/paddle/badge.png new file mode 100644 index 0000000..3d94134 Binary files /dev/null and b/examples/godot/paddle/badge.png differ diff --git a/examples/godot/paddle/game.gd b/examples/godot/paddle/game.gd new file mode 100644 index 0000000..c9e13cb --- /dev/null +++ b/examples/godot/paddle/game.gd @@ -0,0 +1,16 @@ +extends Node2D + +var velocity := Vector2(24, 28) + +func _physics_process(delta: float) -> void: + $Paddle.position.x = clampf($Paddle.position.x + Input.get_axis("ui_left", "ui_right") * 80.0 * delta, 14, 130) + $Ball.position += velocity * delta + if $Ball.position.x < 3 or $Ball.position.x > 141: + velocity.x = -velocity.x + if $Ball.position.y < 3: + velocity.y = absf(velocity.y) + if velocity.y > 0 and $Ball.position.y >= 66 and $Ball.position.y <= 72 and absf($Ball.position.x - $Paddle.position.x) < 14: + velocity.y = -absf(velocity.y) + if $Ball.position.y > 82: + $Ball.position = Vector2(72, 40) + velocity = Vector2(24, 28) diff --git a/examples/godot/paddle/main.tscn b/examples/godot/paddle/main.tscn new file mode 100644 index 0000000..7407700 --- /dev/null +++ b/examples/godot/paddle/main.tscn @@ -0,0 +1,26 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://game.gd" id="1"] +[ext_resource type="Texture2D" path="res://badge.png" id="2"] + +[node name="PaddleGame" type="Node2D"] +script = ExtResource("1") + +[node name="Badge" type="Sprite2D" parent="."] +position = Vector2(132, 10) +texture = ExtResource("2") + +[node name="Paddle" type="Polygon2D" parent="."] +position = Vector2(72, 70) +color = Color(0.25, 0.77, 0.79, 1) +polygon = PackedVector2Array(-12, -2, 12, -2, 12, 2, -12, 2) + +[node name="Ball" type="Polygon2D" parent="."] +position = Vector2(72, 40) +color = Color(0.9, 0.79, 0.27, 1) +polygon = PackedVector2Array(-2, -2, 2, -2, 2, 2, -2, 2) + +[node name="Boundary" type="Line2D" parent="."] +points = PackedVector2Array(1, 79, 1, 1, 143, 1, 143, 79) +width = 1.0 +default_color = Color(0.25, 0.25, 0.25, 1) diff --git a/examples/godot/paddle/project.godot b/examples/godot/paddle/project.godot new file mode 100644 index 0000000..a9c30be --- /dev/null +++ b/examples/godot/paddle/project.godot @@ -0,0 +1,13 @@ +config_version=5 + +[application] +config/name="Godot Terminal Paddle" +run/main_scene="res://main.tscn" + +[display] +window/size/viewport_width=144 +window/size/viewport_height=80 + +[rendering] +renderer/rendering_method="gl_compatibility" +environment/defaults/default_clear_color=Color(0, 0, 0, 1) diff --git a/godot.go b/godot.go new file mode 100644 index 0000000..ebcc267 --- /dev/null +++ b/godot.go @@ -0,0 +1,405 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "image" + "image/color" + "image/png" + "io" + "os" + "sort" + "strings" + "time" + "unicode" + + tea "charm.land/bubbletea/v2" + + "github.com/aviorstudio/termcade/internal/godot" + "github.com/aviorstudio/termcade/sdk" +) + +const godotUsage = "usage: termcade godot init | export [--json] | play --trusted | benchmark --trusted [--frames N] [--warmup N] | capture --trusted [--columns N] [--rows N] [--frames N] [--input replay.json] " + +func cmdGodot(args []string) error { + if len(args) == 0 { + return fmt.Errorf("%s", godotUsage) + } + options := flag.NewFlagSet("godot "+args[0], flag.ContinueOnError) + options.SetOutput(io.Discard) + trusted := options.Bool("trusted", false, "execute a local project as native code") + machine := options.Bool("json", false, "emit structured results") + columns := options.Int("columns", 72, "capture width in terminal columns") + rows := options.Int("rows", 20, "capture height in terminal rows") + warmup := options.Int("warmup", 60, "benchmark warmup frames") + frames := options.Int("frames", 1, "number of simulation frames to capture") + input := options.String("input", "", "JSON replay: array of {frame,code,down}") + if err := options.Parse(args[1:]); err != nil { + return err + } + positional := options.Args() + if args[0] != "init" && args[0] != "export" && args[0] != "play" && args[0] != "capture" && args[0] != "benchmark" { + return fmt.Errorf("%s", godotUsage) + } + if (args[0] == "play" || args[0] == "capture" || args[0] == "benchmark") && !*trusted { + return fmt.Errorf("Godot .tgd projects execute native code; use --trusted for your local project (marketplace .tcade games retain their Wasm sandbox)") + } + ctx := context.Background() + bin, err := godot.Binary(ctx) + if err != nil { + return err + } + switch args[0] { + case "init": + if len(positional) != 1 { + return fmt.Errorf("%s", godotUsage) + } + if err := godot.InstallAddon(ctx, bin, positional[0]); err != nil { + return err + } + return godotResult(*machine, "init", positional[0]) + case "export": + if len(positional) != 2 { + return fmt.Errorf("%s", godotUsage) + } + if err := godot.Export(ctx, bin, positional[0], positional[1]); err != nil { + return err + } + return godotResult(*machine, "export", positional[1]) + case "play", "capture", "benchmark": + want := 1 + if args[0] == "capture" { + want = 2 + } + if len(positional) != want || *frames < 1 || *frames > 3600 || (args[0] == "benchmark" && (*warmup < 0 || *warmup > 3600-*frames)) { + return fmt.Errorf("%s", godotUsage) + } + runtime, err := godot.Start(ctx, bin, positional[0]) + if err != nil { + return err + } + defer runtime.Close() + shape := pixelShape() + if *columns < 1 || *columns > 600/shape.Cols || *rows < 1 || *rows > 360/shape.Rows { + return fmt.Errorf("capture dimensions exceed the framebuffer limit") + } + canvas := sdk.NewCanvas(*columns, *rows*2, sdk.Black, shape) + width, height := canvas.PixelSize() + pixels, err := runtime.Reset(width, height) + if err != nil { + return err + } + copy(canvas.Pix(), pixels) + if args[0] == "capture" { + if err := captureGodot(runtime, canvas, *frames, *input, positional[1]); err != nil { + return err + } + return godotResult(*machine, "capture", positional[1]) + } + if args[0] == "benchmark" { + return benchmarkGodot(runtime, canvas, *frames, *warmup, *input, *machine) + } + model := godotPlayer{runtime: runtime, canvas: canvas, held: map[int]int{}} + _, err = tea.NewProgram(model).Run() + return err + default: + return fmt.Errorf("%s", godotUsage) + } +} + +func godotResult(machine bool, operation, artifact string) error { + if machine { + return json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": true, "operation": operation, "artifact": artifact, "runtime": "godot-terminal-framebuffer-v1", "godot": godot.EngineVersion}) + } + fmt.Println("Godot terminal", operation+":", artifact) + return nil +} + +type replayEvent struct { + Frame int `json:"frame"` + godot.KeyEvent +} + +func loadGodotReplay(input string, frames int) ([]replayEvent, error) { + var events []replayEvent + if input != "" { + file, err := os.Open(input) + if err != nil { + return nil, err + } + decoder := json.NewDecoder(io.LimitReader(file, 65537)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&events) + if err == nil { + var extra any + if decoder.Decode(&extra) != io.EOF { + err = fmt.Errorf("replay must contain exactly one JSON array") + } + } + file.Close() + if err != nil || len(events) > 256 { + return nil, fmt.Errorf("invalid bounded input replay: %v", err) + } + for _, event := range events { + if event.Frame < 1 || event.Frame > frames || event.Code < 1 || event.Code > 1<<24 { + return nil, fmt.Errorf("input replay event is out of range") + } + } + } + return events, nil +} + +func captureGodot(runtime *godot.Runtime, canvas *sdk.Canvas, frames int, input, destination string) error { + events, err := loadGodotReplay(input, frames) + if err != nil { + return err + } + for frame := 1; frame <= frames; frame++ { + var keys []godot.KeyEvent + for _, event := range events { + if event.Frame == frame { + keys = append(keys, event.KeyEvent) + } + } + width, height := canvas.PixelSize() + shape := canvas.Shape() + pixels, err := runtime.StepCanvas(keys, width, height, float64(shape.Rows)/float64(2*shape.Cols)) + if err != nil { + return err + } + copy(canvas.Pix(), pixels) + } + width, height := canvas.PixelSize() + frame := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := range height { + for x := range width { + r, g, b := canvas.AtPixel(x, y).RGB() + frame.SetRGBA(x, y, color.RGBA{R: r, G: g, B: b, A: 255}) + } + } + file, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + return err + } + if err = png.Encode(file, frame); err != nil { + file.Close() + os.Remove(destination) + return err + } + return file.Close() +} + +type godotTick struct{} +type godotFrame struct { + started time.Time + canvas *sdk.Canvas + pixels []sdk.Color + err error +} + +// The native engine exchange runs as a Tea command, keeping resize, pause and +// exit responsive even while a game is hung. Only one exchange is in flight. +type godotPlayer struct { + runtime *godot.Runtime + canvas *sdk.Canvas + held map[int]int + keys []godot.KeyEvent + ticks int + exact bool + busy bool + paused bool + err error + width int + height int +} + +func (m godotPlayer) Init() tea.Cmd { return godotNextTick(time.Now()) } + +func godotNextTick(started time.Time) tea.Cmd { + return tea.Tick(godotFrameDelay(started, time.Now()), func(time.Time) tea.Msg { return godotTick{} }) +} + +// Rendering and transfer consume part of the frame budget. Waiting a full +// interval after they finish slows the game's fixed-step simulation down. +func godotFrameDelay(started, now time.Time) time.Duration { + return max(time.Duration(0), time.Second/60-now.Sub(started)) +} + +func (m godotPlayer) Update(message tea.Msg) (tea.Model, tea.Cmd) { + switch msg := message.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + case tea.KeyPressMsg: + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "ctrl+p": + m.paused = !m.paused + if m.paused { + for code := range m.held { + m.keys = append(m.keys, godot.KeyEvent{Code: code}) + } + clear(m.held) + } + return m, nil + } + if code := godotKey(msg.String()); code != 0 && !m.paused && len(m.keys) < 256 { + m.held[code] = m.ticks + m.keys = append(m.keys, godot.KeyEvent{Code: code, Down: true}) + } + case tea.KeyReleaseMsg: + m.exact = true + if code := godotKey(msg.String()); code != 0 && len(m.keys) < 256 { + delete(m.held, code) + m.keys = append(m.keys, godot.KeyEvent{Code: code}) + } + case godotTick: + if !m.paused && m.err == nil && !m.busy && m.width >= 72 && m.height >= 22 { + m.ticks++ + if !m.exact { + for code, last := range m.held { + if m.ticks-last >= sdk.DefaultHoldTicks { + m.keys = append(m.keys, godot.KeyEvent{Code: code}) + delete(m.held, code) + } + } + } + shape := m.canvas.Shape() + columns := min(m.width, 600/shape.Cols) + rows := min(m.height-2, 360/shape.Rows) + canvas := m.canvas + if canvas.W != columns || canvas.H != rows*2 { + canvas = sdk.NewCanvas(columns, rows*2, sdk.Black, shape) + } + keys := m.keys + m.keys = nil + m.busy = true + started := time.Now() + return m, func() tea.Msg { + width, height := canvas.PixelSize() + pixels, err := m.runtime.StepCanvas(keys, width, height, float64(shape.Rows)/float64(2*shape.Cols)) + return godotFrame{started: started, canvas: canvas, pixels: pixels, err: err} + } + } + return m, godotNextTick(time.Now()) + case godotFrame: + m.busy = false + m.err = msg.err + if msg.err == nil { + m.canvas = msg.canvas + copy(m.canvas.Pix(), msg.pixels) + } + return m, godotNextTick(msg.started) + } + return m, nil +} + +func (m godotPlayer) View() tea.View { + content := "" + if m.width < 72 || m.height < 22 { + content = "Godot terminal preview needs at least 72 columns × 22 rows." + } else { + content = m.canvas.Render() + "\n" + safeGodotText(m.runtime.Title) + " · Ctrl+P pause · Ctrl+C exit" + if m.paused { + content += " · PAUSED" + } + if m.err != nil { + content += "\n" + safeGodotText(m.err.Error()) + } + } + view := tea.NewView(content) + view.AltScreen = true + view.KeyboardEnhancements.ReportEventTypes = true + return view +} + +func safeGodotText(value string) string { + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, value) +} + +func godotKey(key string) int { + const special = 1 << 22 + switch key { + case "left": + return special + 15 + case "right": + return special + 17 + case "up": + return special + 16 + case "down": + return special + 18 + case "enter": + return special + 5 + case "tab": + return special + 2 + case "esc": + return special + 1 + case "backspace": + return special + 4 + case "space", " ": + return 32 + } + runes := []rune(key) + if len(runes) == 1 { + return int(unicode.ToUpper(runes[0])) + } + return 0 +} + +// Benchmark reports warmed engine exchange and cell conversion; it does not +// include the terminal emulator's painting or the player's 60 Hz pacing. +func benchmarkGodot(runtime *godot.Runtime, canvas *sdk.Canvas, frames, warmup int, input string, machine bool) error { + events, err := loadGodotReplay(input, frames+warmup) + if err != nil { + return err + } + width, height := canvas.PixelSize() + shape := canvas.Shape() + samples := make([]float64, 0, frames) + engine := map[string]float64{} + cellMS := 0.0 + ansiBytes := 0 + for frame := 1; frame <= frames+warmup; frame++ { + var keys []godot.KeyEvent + for _, event := range events { + if event.Frame == frame { + keys = append(keys, event.KeyEvent) + } + } + started := time.Now() + pixels, err := runtime.StepCanvas(keys, width, height, float64(shape.Rows)/float64(2*shape.Cols)) + if err != nil { + return err + } + elapsed := float64(time.Since(started).Microseconds()) / 1000 + if frame <= warmup { + continue + } + samples = append(samples, elapsed) + for key, value := range runtime.LastFrameTimes { + engine[strings.TrimSuffix(key, "_us")] += float64(value) / 1000 / float64(frames) + } + copy(canvas.Pix(), pixels) + started = time.Now() + rendered := canvas.Render() + cellMS += float64(time.Since(started).Microseconds()) / 1000 / float64(frames) + ansiBytes += len(rendered) + } + sort.Float64s(samples) + mean := 0.0 + for _, sample := range samples { + mean += sample / float64(frames) + } + if machine { + return json.NewEncoder(os.Stdout).Encode(map[string]any{"ok": true, "operation": "benchmark", "runtime": "godot-terminal-framebuffer-v1", "godot": godot.EngineVersion, "frames": frames, "warmup": warmup, "width": width, "height": height, "exchange_mean_ms": mean, "exchange_p95_ms": samples[(len(samples)-1)*95/100], "cell_render_mean_ms": cellMS, "ansi_bytes_per_frame": ansiBytes / frames, "engine_mean_ms": engine}) + } + fmt.Printf("Godot frame exchange %.2f ms mean, %.2f ms p95; terminal cells %.2f ms; %d ANSI bytes/frame\n", mean, samples[(len(samples)-1)*95/100], cellMS, ansiBytes/frames) + return nil +} diff --git a/godot_test.go b/godot_test.go new file mode 100644 index 0000000..72f6acb --- /dev/null +++ b/godot_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/aviorstudio/termcade/internal/godot" + "github.com/aviorstudio/termcade/sdk" +) + +func TestGodotTerminalKeysAndEscaping(t *testing.T) { + for key, expected := range map[string]int{"left": 4194319, "right": 4194321, "up": 4194320, "down": 4194322, "enter": 4194309, "a": 65, "space": 32, "unknown": 0} { + if got := godotKey(key); got != expected { + t.Errorf("%s: got %d, want %d", key, got, expected) + } + } + if text := safeGodotText("title\x1b[2J\n\x07"); strings.ContainsAny(text, "\x1b\n\x07") { + t.Fatal("game diagnostics can inject terminal controls") + } +} + +func TestGodotPlayerPauseQueuesReleaseAndResizeRetainsFrame(t *testing.T) { + m := godotPlayer{held: map[int]int{65: 0}, canvas: sdk.NewCanvas(72, 40, sdk.Black, sdk.Quadrant), runtime: &godot.Runtime{Title: "game"}} + model, _ := m.Update(tea.KeyPressMsg{Code: 'p', Mod: tea.ModCtrl}) + paused := model.(godotPlayer) + if !paused.paused || len(paused.held) != 0 || len(paused.keys) != 1 || paused.keys[0].Down { + t.Fatal("pause left a game key held") + } + model, _ = paused.Update(tea.WindowSizeMsg{Width: 50, Height: 15}) + resized := model.(godotPlayer) + if resized.canvas != paused.canvas || resized.width != 50 { + t.Fatal("resize discarded the scene framebuffer") + } + model, _ = resized.Update(tea.KeyReleaseMsg{Code: 'a'}) + if !model.(godotPlayer).exact { + t.Fatal("terminal release events did not activate exact input mode") + } +} + +func TestGodotFrameSchedulingIncludesRenderingTime(t *testing.T) { + start := time.Unix(100, 0) + if got := godotFrameDelay(start, start.Add(8*time.Millisecond)); got != time.Second/60-8*time.Millisecond { + t.Fatalf("render time was added to frame interval: %s", got) + } + if got := godotFrameDelay(start, start.Add(30*time.Millisecond)); got != 0 { + t.Fatalf("late frame incurred additional sleep: %s", got) + } +} diff --git a/internal/godot/addon/platform.gd b/internal/godot/addon/platform.gd new file mode 100644 index 0000000..dd7805d --- /dev/null +++ b/internal/godot/addon/platform.gd @@ -0,0 +1,47 @@ +@tool +extends EditorExportPlatformExtension + +func _get_name() -> String: + return "Termcade" + +func _get_os_name() -> String: + return "Termcade" + +func _get_logo() -> Texture2D: + var image := Image.create(32, 32, false, Image.FORMAT_RGBA8) + image.fill(Color("3fc4c9")) + return ImageTexture.create_from_image(image) + +func _get_binary_extensions(_preset: EditorExportPreset) -> PackedStringArray: + return PackedStringArray(["tgd"]) + +func _get_platform_features() -> PackedStringArray: + return PackedStringArray(["termcade", "termcade_framebuffer", "pc"]) + +func _get_preset_features(_preset: EditorExportPreset) -> PackedStringArray: + return PackedStringArray() + +func _get_export_options() -> Array[Dictionary]: + return [{"name": "terminal/profile", "type": TYPE_STRING, "default_value": "framebuffer-v1"}] + +func _has_valid_export_configuration(_preset: EditorExportPreset, _debug: bool) -> bool: + set_config_missing_templates(false) + return true + +func _has_valid_project_configuration(_preset: EditorExportPreset) -> bool: + var main: String = ProjectSettings.get_setting("application/run/main_scene", "") + if main.is_empty(): + set_config_error("Termcade requires a main scene.") + return false + return true + +func _can_export(preset: EditorExportPreset, debug: bool) -> bool: + return _has_valid_export_configuration(preset, debug) and _has_valid_project_configuration(preset) + +func _export_project(preset: EditorExportPreset, debug: bool, path: String, _flags: int) -> Error: + var result: Dictionary = save_pack(preset, debug, path) + if not result.get("so_files", []).is_empty(): + add_message(EXPORT_MESSAGE_ERROR, "Termcade compatibility", "Native extensions are unsupported.") + DirAccess.remove_absolute(path) + return ERR_UNAVAILABLE + return result.result diff --git a/internal/godot/addon/plugin.cfg b/internal/godot/addon/plugin.cfg new file mode 100644 index 0000000..e23726d --- /dev/null +++ b/internal/godot/addon/plugin.cfg @@ -0,0 +1,6 @@ +[plugin] +name="Termcade" +description="Export supported Godot 2D scenes to the Termcade terminal runtime." +author="Avior Studio" +version="0.1.0" +script="plugin.gd" diff --git a/internal/godot/addon/plugin.gd b/internal/godot/addon/plugin.gd new file mode 100644 index 0000000..8f8be4e --- /dev/null +++ b/internal/godot/addon/plugin.gd @@ -0,0 +1,13 @@ +@tool +extends EditorPlugin + +const Platform = preload("platform.gd") +var platform: EditorExportPlatformExtension + +func _enter_tree() -> void: + platform = Platform.new() + add_export_platform(platform) + +func _exit_tree() -> void: + remove_export_platform(platform) + platform = null diff --git a/internal/godot/addon/runtime.gd b/internal/godot/addon/runtime.gd new file mode 100644 index 0000000..138f2ef --- /dev/null +++ b/internal/godot/addon/runtime.gd @@ -0,0 +1,146 @@ +extends SceneTree + +const PROTOCOL := 2 +const MAX_REQUEST := 65536 +var peer := StreamPeerTCP.new() +var started := false +var width := 144 +var height := 40 +var pixel_aspect := 0.5 +var logical_size: Vector2i +var native_resolution := false +var input_bytes := PackedByteArray() +var pressed_codes: Array[int] = [] + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + if args.size() != 2: + printerr("Termcade runtime requires its host connection.") + quit(2) + return + var err := peer.connect_to_host("127.0.0.1", int(args[0])) + if err != OK: + quit(2) + return + while peer.get_status() == StreamPeerTCP.STATUS_CONNECTING: + peer.poll() + OS.delay_usec(1000) + peer.set_no_delay(true) + logical_size = Vector2i(ProjectSettings.get_setting("display/window/size/viewport_width", 0), ProjectSettings.get_setting("display/window/size/viewport_height", 0)) + if logical_size.x <= 0 or logical_size.y <= 0: + logical_size = root.size + native_resolution = OS.get_environment("TERMCADE_GODOT_FULL_RES") == "1" + # Keep authored coordinates and layout while rendering only the pixels + # the terminal can show. Native resolution remains available for games + # whose scripts or shaders depend on the original render target size. + if not native_resolution: + root.content_scale_size = logical_size + root.content_scale_mode = Window.CONTENT_SCALE_MODE_CANVAS_ITEMS + root.content_scale_aspect = Window.CONTENT_SCALE_ASPECT_KEEP + root.disable_3d = false + RenderingServer.render_loop_enabled = false + _send({"protocol": PROTOCOL, "token": args[1], "title": ProjectSettings.get_setting("application/config/name", "Godot game")}) + +func _process(_delta: float) -> bool: + # Host-driven fixed-FPS engine iterations. While the arcade is paused or + # waiting on input, Godot cannot run ahead and accumulate invisible frames. + while peer.get_status() == StreamPeerTCP.STATUS_CONNECTED: + peer.poll() + var available := peer.get_available_bytes() + if available > 0: + var result := peer.get_data(mini(available, MAX_REQUEST)) + if result[0] != OK: + return true + input_bytes.append_array(result[1]) + if input_bytes.size() > MAX_REQUEST: + return true + var newline := input_bytes.find(10) + if newline >= 0: + var request = JSON.parse_string(input_bytes.slice(0, newline).get_string_from_utf8()) + input_bytes = input_bytes.slice(newline + 1) + if not request is Dictionary: + return true + return _request(request) + OS.delay_usec(1000) + return true + +func _request(request: Dictionary) -> bool: + if request.get("op") == "close": + return true + width = int(request.get("width", width)) + height = int(request.get("height", height)) + pixel_aspect = float(request.get("pixel_aspect", pixel_aspect)) + if width < 1 or width > 600 or height < 1 or height > 360 or pixel_aspect <= 0 or pixel_aspect > 2: + _send({"error": "invalid framebuffer dimensions"}) + return true + if not native_resolution: + var scale := minf(1.0, minf(float(width) / logical_size.x, float(height) / pixel_aspect / logical_size.y)) + var render_size := Vector2i(maxi(1, roundi(logical_size.x * scale)), maxi(1, roundi(logical_size.y * scale))) + if root.size != render_size: + root.size = render_size + if request.get("op") == "reset": + for code in pressed_codes: + var release := InputEventKey.new() + release.keycode = code + release.physical_keycode = code + Input.parse_input_event(release) + pressed_codes.clear() + Input.flush_buffered_events() + if is_instance_valid(current_scene): + current_scene.free() + var packed := load(ProjectSettings.get_setting("application/run/main_scene", "")) as PackedScene + if packed == null: + _send({"error": "cannot load main scene"}) + return true + paused = false + var scene := packed.instantiate() + root.add_child(scene) + current_scene = scene + started = true + elif request.get("op") != "step" or not started: + _send({"error": "expected reset or step"}) + return true + for key in request.get("keys", []): + var event := InputEventKey.new() + event.keycode = int(key.code) + event.physical_keycode = event.keycode + event.pressed = bool(key.down) + event.echo = event.pressed and pressed_codes.has(event.keycode) + if event.pressed and not pressed_codes.has(event.keycode): + pressed_codes.append(event.keycode) + elif not event.pressed: + pressed_codes.erase(event.keycode) + Input.parse_input_event(event) + Input.flush_buffered_events() + _send_frame.call_deferred() + return false + +func _send_frame() -> void: + # Godot renders every node, including cameras, UI, shaders and 3D. The + # private display never appears on the desktop; no node emulation is used. + var render_start := Time.get_ticks_usec() + RenderingServer.force_draw(false, 1.0 / 60.0) + var read_start := Time.get_ticks_usec() + var frame := root.get_texture().get_image() + var resize_start := Time.get_ticks_usec() + var render_size := frame.get_size() + if frame == null or frame.is_empty(): + _send({"error": "Godot did not produce a rendered viewport"}) + quit(2) + return + # Character cells are taller than they are wide. Fit the game's aspect + # ratio into the available terminal space instead of stretching its UI. + var scale := minf(width * pixel_aspect / frame.get_width(), float(height) / frame.get_height()) + var fitted := Vector2i(maxi(1, roundi(frame.get_width() * scale / pixel_aspect)), maxi(1, roundi(frame.get_height() * scale))) + fitted.x = mini(fitted.x, width) + fitted.y = mini(fitted.y, height) + frame.resize(fitted.x, fitted.y, Image.INTERPOLATE_BILINEAR) + frame.convert(Image.FORMAT_RGB8) + var output := Image.create(width, height, false, Image.FORMAT_RGB8) + output.fill(Color.BLACK) + output.blit_rect(frame, Rect2i(Vector2i.ZERO, fitted), Vector2i((width - fitted.x) / 2, (height - fitted.y) / 2)) + frame = output + _send({"width": width, "height": height, "pixels": Marshalls.raw_to_base64(frame.get_data()), "render_width": render_size.x, "render_height": render_size.y, "timings": {"render_us": read_start - render_start, "readback_us": resize_start - read_start, "resize_us": Time.get_ticks_usec() - resize_start}}) + +func _send(value: Dictionary) -> void: + peer.put_data((JSON.stringify(value) + "\n").to_utf8_buffer()) diff --git a/internal/godot/addon/setup.gd b/internal/godot/addon/setup.gd new file mode 100644 index 0000000..8dcf460 --- /dev/null +++ b/internal/godot/addon/setup.gd @@ -0,0 +1,43 @@ +extends SceneTree + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + if args.size() != 1: + quit(2) + return + var project: String = args[0] + var config := ConfigFile.new() + if config.load(project.path_join("project.godot")) != OK: + quit(2) + return + var plugins: PackedStringArray = config.get_value("editor_plugins", "enabled", PackedStringArray()) + if not plugins.has("res://addons/termcade/plugin.cfg"): + plugins.append("res://addons/termcade/plugin.cfg") + config.set_value("editor_plugins", "enabled", plugins) + if config.save(project.path_join("project.godot")) != OK: + quit(2) + return + var presets := ConfigFile.new() + presets.load(project.path_join("export_presets.cfg")) + var index := 0 + while presets.has_section("preset.%d" % index): + if presets.get_value("preset.%d" % index, "name", "") == "Termcade": + print("TERMCADE SETUP OK") + quit() + return + index += 1 + var section := "preset.%d" % index + presets.set_value(section, "name", "Termcade") + presets.set_value(section, "platform", "Termcade") + presets.set_value(section, "runnable", false) + presets.set_value(section, "export_filter", "all_resources") + presets.set_value(section, "include_filter", "addons/termcade/*.gd") + presets.set_value(section, "exclude_filter", "") + presets.set_value(section, "script_export_mode", 1) + presets.set_value(section, "export_path", "build/game.tgd") + presets.set_value(section + ".options", "terminal/profile", "framebuffer-v1") + if presets.save(project.path_join("export_presets.cfg")) != OK: + quit(2) + return + print("TERMCADE SETUP OK") + quit() diff --git a/internal/godot/display.go b/internal/godot/display.go new file mode 100644 index 0000000..3cba2fe --- /dev/null +++ b/internal/godot/display.go @@ -0,0 +1,92 @@ +package godot + +import ( + "bufio" + "context" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +// A private X server keeps all game windows off the user's desktop. Its only +// listener is a local Unix socket, and the runtime owns and reaps the process. +type virtualDisplay struct { + cmd *exec.Cmd + done chan struct{} + once sync.Once + name string +} + +func startDisplay(ctx context.Context, environment []string) (_ *virtualDisplay, err error) { + if runtime.GOOS != "linux" { + return nil, fmt.Errorf("Godot framebuffer previews currently require Linux and Xvfb") + } + bin := os.Getenv("TERMCADE_XVFB_BIN") + if bin == "" { + bin = "Xvfb" + } + bin, err = exec.LookPath(bin) + if err != nil { + return nil, fmt.Errorf("Godot framebuffer previews require Xvfb (install xorg-server-xvfb on Arch or xvfb on Ubuntu): %w", err) + } + reader, writer, err := os.Pipe() + if err != nil { + return nil, err + } + defer reader.Close() + defer writer.Close() + d := &virtualDisplay{done: make(chan struct{})} + d.cmd = exec.CommandContext(ctx, bin, "-displayfd", "3", "-screen", "0", "4096x4096x24", "-nolisten", "tcp", "-noreset") + d.cmd.ExtraFiles = []*os.File{writer} + d.cmd.Env = environment + log := &engineLog{} + d.cmd.Stdout, d.cmd.Stderr = log, log + d.cmd.WaitDelay = time.Second + if err = d.cmd.Start(); err != nil { + return nil, err + } + writer.Close() + go func() { _ = d.cmd.Wait(); close(d.done) }() + defer func() { + if err != nil { + d.Close() + } + }() + ready := make(chan string, 1) + go func() { + line, _ := bufio.NewReaderSize(reader, 64).ReadSlice('\n') + ready <- strings.TrimSpace(string(line)) + }() + select { + case line := <-ready: + number, parseErr := strconv.Atoi(line) + if parseErr != nil || number < 0 || number > 65535 { + output, _ := log.snapshot() + return nil, fmt.Errorf("Xvfb did not announce a display: %s", output) + } + d.name = ":" + line + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(5 * time.Second): + return nil, fmt.Errorf("Xvfb display startup timed out") + } + return d, nil +} + +func (d *virtualDisplay) Close() { + d.once.Do(func() { + // SIGINT gives Xvfb time to remove its own socket and lock file. + _ = d.cmd.Process.Signal(os.Interrupt) + select { + case <-d.done: + case <-time.After(time.Second): + _ = d.cmd.Process.Kill() + <-d.done + } + }) +} diff --git a/internal/godot/integration_test.go b/internal/godot/integration_test.go new file mode 100644 index 0000000..b439b60 --- /dev/null +++ b/internal/godot/integration_test.go @@ -0,0 +1,293 @@ +package godot + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aviorstudio/termcade/sdk" +) + +// Required by make test and CI. No skip/fake engine path: this exercises the +// actual export platform, packed resources and engine/terminal boundary. +func TestGodotExportAndTerminalRuntime(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + bin, err := Binary(ctx) + if err != nil { + t.Fatal(err) + } + project := t.TempDir() + if err := copyProject("../../examples/godot/paddle", project); err != nil { + t.Fatal(err) + } + original, _ := os.ReadFile(filepath.Join(project, "project.godot")) + pack := filepath.Join(t.TempDir(), "paddle.tgd") + if err := Export(ctx, bin, project, pack); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(filepath.Join(project, "project.godot")) + if string(original) != string(after) { + t.Fatal("export modified source project settings") + } + if _, err := os.Stat(filepath.Join(project, ".godot")); !os.IsNotExist(err) { + t.Fatal("export created source import state") + } + if err := Export(ctx, bin, project, pack); err == nil { + t.Fatal("export overwrote an existing artifact") + } + runtime, err := Start(ctx, bin, pack) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + first, err := runtime.Reset(144, 40) + if err != nil { + t.Fatal(err) + } + if runtime.Title != "Godot Terminal Paddle" { + t.Fatalf("wrong project title %q", runtime.Title) + } + if colored(first) < 30 { + t.Fatal("scene polygons, lines and sprite were not rendered") + } + if first[4*144+132] == 0 { + t.Fatal("packed imported Sprite2D did not render through Godot") + } + frame := first + for i := range 20 { + var events []KeyEvent + if i == 0 { + events = []KeyEvent{{Code: 4194321, Down: true}} + } + frame, err = runtime.Step(events) + if err != nil { + t.Fatal(err) + } + } + if centerOfColor(frame, 0x40c4c9) <= centerOfColor(first, 0x40c4c9)+15 { + t.Fatalf("ordinary Godot Input and physics did not move the paddle: before %.2f, after %.2f, ball %.2f -> %.2f", centerOfColor(first, 0x40c4c9), centerOfColor(frame, 0x40c4c9), centerOfColor(first, 0xe6c945), centerOfColor(frame, 0xe6c945)) + } + if centerOfColor(frame, 0xe6c945) == centerOfColor(first, 0xe6c945) { + t.Fatal("Godot simulation did not advance") + } + canvas := sdk.NewCanvas(72, 40, sdk.Black, sdk.Quadrant) + copy(canvas.Pix(), frame) + if !strings.Contains(canvas.Render(), "\x1b[") { + t.Fatal("frame did not become terminal output") + } + reset, err := runtime.Reset(144, 40) + if err != nil { + t.Fatal(err) + } + if centerOfColor(reset, 0x40c4c9) > 75 { + t.Fatal("reset did not recreate the scene") + } + if err := runtime.Close(); err != nil { + t.Fatal(err) + } + select { + case <-runtime.done: + default: + t.Fatal("owned engine process survived cleanup") + } + if _, err := os.Stat(runtime.temp); !os.IsNotExist(err) { + t.Fatal("owned runtime state survived cleanup") + } + // Editor installation follows the same public API and preserves unrelated settings. + if err := InstallAddon(ctx, bin, project); err != nil { + t.Fatal(err) + } + if err := InstallAddon(ctx, bin, project); err != nil { + t.Fatal("addon setup is not idempotent:", err) + } + if _, err := runEngine(ctx, bin, "--headless", "--quiet", "--editor", "--path", project, "--import"); err != nil { + t.Fatal("editor could not load the installed platform:", err) + } + // Cameras are ordinary Godot nodes now, not exporter compatibility errors. + scene, _ := os.ReadFile(filepath.Join(project, "main.tscn")) + scene = append(scene, []byte("\n[node name=\"Camera\" type=\"Camera2D\" parent=\".\"]\n")...) + os.WriteFile(filepath.Join(project, "main.tscn"), scene, 0o644) + if err := Export(ctx, bin, project, filepath.Join(t.TempDir(), "camera.tgd")); err != nil { + t.Fatalf("ordinary camera was rejected: %v", err) + } + select { + case <-runtime.display.done: + default: + t.Fatal("owned virtual display survived cleanup") + } + +} + +func colored(pixels []sdk.Color) int { + n := 0 + for _, pixel := range pixels { + if pixel != 0 { + n++ + } + } + return n +} + +func centerOfColor(pixels []sdk.Color, target sdk.Color) float64 { + total, count := 0, 0 + tr, tg, tb := target.RGB() + for index, pixel := range pixels { + r, g, b := pixel.RGB() + if abs(int(r)-int(tr)) < 40 && abs(int(g)-int(tg)) < 40 && abs(int(b)-int(tb)) < 40 { + total += index % 144 + count++ + } + } + if count == 0 { + return -1 + } + return float64(total) / float64(count) +} + +func abs(value int) int { + if value < 0 { + return -value + } + return value +} + +func TestGodotHungGameIsKilledAndCleanedUp(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + bin, err := Binary(ctx) + if err != nil { + t.Fatal(err) + } + project := t.TempDir() + if err := copyProject("../../examples/godot/paddle", project); err != nil { + t.Fatal(err) + } + path := filepath.Join(project, "game.gd") + data, _ := os.ReadFile(path) + data = []byte(strings.Replace(string(data), "func _physics_process(delta: float) -> void:", "func _physics_process(delta: float) -> void:\n\tif Input.is_key_pressed(KEY_B):\n\t\twhile true:\n\t\t\tpass", 1)) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + pack := filepath.Join(t.TempDir(), "hung.tgd") + if err := Export(ctx, bin, project, pack); err != nil { + t.Fatal(err) + } + runtime, err := Start(ctx, bin, pack) + if err != nil { + t.Fatal(err) + } + defer runtime.Close() + if _, err := runtime.Reset(144, 40); err != nil { + t.Fatal(err) + } + start := time.Now() + _, err = runtime.Step([]KeyEvent{{Code: 66, Down: true}}) + if err == nil { // Physics sees input on the following fixed engine iteration. + _, err = runtime.Step(nil) + } + if err == nil || time.Since(start) > 3500*time.Millisecond { + t.Fatalf("hung game escaped its frame watchdog: %v", err) + } + select { + case <-runtime.done: + default: + t.Fatal("watchdog did not reap its own process") + } + if _, err := os.Stat(runtime.temp); !os.IsNotExist(err) { + t.Fatal("watchdog left owned runtime state") + } +} + +func TestGodotNativeRenderingAndSceneTransitions(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + bin, err := Binary(ctx) + if err != nil { + t.Fatal(err) + } + pack := filepath.Join(t.TempDir(), "framebuffer.tgd") + if err := Export(ctx, bin, "testdata/framebuffer", pack); err != nil { + t.Fatal(err) + } + r, err := Start(ctx, bin, pack) + if err != nil { + t.Fatal(err) + } + defer r.Close() + pixels, err := r.Reset(160, 80) + if err != nil { + t.Fatal(err) + } + // Use square pixels so test regions match the project's authored viewport. + for range 3 { + pixels, err = r.StepCanvas(nil, 160, 80, 1) + if err != nil { + t.Fatal(err) + } + } + for name, target := range map[string]sdk.Color{"custom draw": 0xff0000, "canvas shader": 0x00ff00, "animated sprite": 0x0000ff, "3D viewport": 0x00ffff, "UI text": 0xffffff} { + count := 0 + for _, pixel := range pixels { + pr, pg, pb := pixel.RGB() + tr, tg, tb := target.RGB() + if abs(int(pr)-int(tr)) < 20 && abs(int(pg)-int(tg)) < 20 && abs(int(pb)-int(tb)) < 20 { + count++ + } + } + if count < 10 { + t.Errorf("%s did not render: %d matching pixels", name, count) + } + } + // Low-resolution rendering preserves authored coordinates and gameplay. + if _, err = r.StepCanvas(nil, 80, 40, 1); err != nil { + t.Fatal(err) + } + if r.LastRenderSize[0] > 80 || r.LastRenderSize[1] > 40 || r.LastRenderSize[0] < 1 { + t.Fatalf("render target stayed oversized: %v", r.LastRenderSize) + } + // Capture resize preserves the scene, focused button and autoload state. + if _, err = r.StepCanvas(nil, 320, 160, 1); err != nil { + t.Fatal(err) + } + if _, err = r.Step([]KeyEvent{{Code: 4194309, Down: true}}); err != nil { + t.Fatal(err) + } + if _, err = r.Step([]KeyEvent{{Code: 4194309, Down: false}}); err != nil { + t.Fatal(err) + } + for range 4 { + pixels, err = r.Step(nil) + if err != nil { + t.Fatal(err) + } + } + magenta := 0 + for _, pixel := range pixels { + if pixel == 0xff00ff { + magenta++ + } + } + if magenta < len(pixels)/3 { + t.Fatal("focused UI, autoload state or scene transition failed") + } + // Games that depend on the original render target can opt out without + // changing their source or the terminal capture dimensions. + r.Close() + t.Setenv("TERMCADE_GODOT_FULL_RES", "1") + native, err := Start(ctx, bin, pack) + if err != nil { + t.Fatal(err) + } + defer native.Close() + if _, err = native.Reset(80, 40); err != nil { + t.Fatal(err) + } + if native.LastRenderSize != [2]int{160, 80} { + t.Fatalf("native-resolution override ignored: %v", native.LastRenderSize) + } + +} diff --git a/internal/godot/project.go b/internal/godot/project.go new file mode 100644 index 0000000..8a41097 --- /dev/null +++ b/internal/godot/project.go @@ -0,0 +1,262 @@ +// Package godot implements the developer-only Godot terminal target. Godot +// executes native project code, so these packages are deliberately separate +// from sandboxed .tcade installs and the public marketplace validator. +package godot + +import ( + "context" + "embed" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +const EngineVersion = "4.7.2.stable.official.ed1daf0bf" + +//go:embed addon/* +var addon embed.FS + +// Binary resolves an explicitly selected or PATH-provided pinned engine. +func Binary(ctx context.Context) (string, error) { + bin := os.Getenv("GODOT_BIN") + if bin == "" { + bin = "godot" + } + path, err := exec.LookPath(bin) + if err != nil { + return "", fmt.Errorf("install Godot %s or set GODOT_BIN: %w", EngineVersion, err) + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + output, err := exec.CommandContext(ctx, path, "--version").Output() + if err != nil || strings.TrimSpace(string(output)) != EngineVersion { + return "", fmt.Errorf("Godot must be %s; got %q (%v)", EngineVersion, strings.TrimSpace(string(output)), err) + } + return path, nil +} + +// InstallAddon exposes the same real export platform used by the CLI in a +// consuming Godot editor. Existing edits to the addon are never overwritten. +func InstallAddon(ctx context.Context, bin, project string) error { + if _, err := os.Stat(filepath.Join(project, "project.godot")); err != nil { + return fmt.Errorf("expected a Godot project: %w", err) + } + if err := writeAddon(project); err != nil { + return err + } + // Setup reads config files in an empty bootstrap project. Loading the + // caller's SceneTree before import would execute autoloads against missing + // class/texture caches, and starting an editor script races its scan thread. + project, err := filepath.Abs(project) + if err != nil { + return err + } + bootstrap, err := os.MkdirTemp("", "termcade-godot-setup-") + if err != nil { + return err + } + defer os.RemoveAll(bootstrap) + if err := os.WriteFile(filepath.Join(bootstrap, "project.godot"), []byte("config_version=5\n"), 0o644); err != nil { + return err + } + log, err := runEngine(ctx, bin, "--headless", "--path", bootstrap, + "--script", filepath.Join(project, "addons", "termcade", "setup.gd"), "--", project) + if err != nil { + return err + } + if !strings.Contains(log, "TERMCADE SETUP OK") { + return fmt.Errorf("Godot setup did not complete: %s", log) + } + return nil +} + +func writeAddon(project string) error { + entries, _ := fs.ReadDir(addon, "addon") + dir := filepath.Join(project, "addons", "termcade") + // Validate every destination before writing any of them. + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + data, _ := addon.ReadFile("addon/" + entry.Name()) + old, err := os.ReadFile(path) + if err == nil && string(old) != string(data) { + return fmt.Errorf("refusing to overwrite edited addon file %s", path) + } + if err != nil && !os.IsNotExist(err) { + return err + } + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + for _, entry := range entries { + data, _ := addon.ReadFile("addon/" + entry.Name()) + if err := os.WriteFile(filepath.Join(dir, entry.Name()), data, 0o644); err != nil { + return err + } + } + return nil +} + +// Export imports and exports a disposable project copy through Godot's real +// Termcade EditorExportPlatformExtension. No graphical export template is used. +// A failed validation leaves both the source project and destination untouched. +func Export(ctx context.Context, bin, project, destination string) error { + if filepath.Ext(destination) != ".tgd" { + return fmt.Errorf("Godot terminal packages use .tgd, not .tcade") + } + project, err := filepath.Abs(project) + if err != nil { + return err + } + destination, err = filepath.Abs(destination) + if err != nil { + return err + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + return fmt.Errorf("destination already exists or is inaccessible: %s", destination) + } + stage, err := os.MkdirTemp("", "termcade-godot-export-") + if err != nil { + return err + } + defer os.RemoveAll(stage) + if err := copyProject(project, stage); err != nil { + return err + } + if err := InstallAddon(ctx, bin, stage); err != nil { + return err + } + // A clean Godot import can log missing cached textures while loading a + // custom theme before the importer runs. Bootstrap once, then require a + // second clean pass; persistent script/resource errors still fail export. + if _, err := runEngineProcess(ctx, false, bin, "--headless", "--quiet", "--editor", "--path", stage, "--import"); err != nil { + return err + } + if _, err := runEngine(ctx, bin, "--headless", "--quiet", "--editor", "--path", stage, "--import"); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + temp, err := os.CreateTemp(filepath.Dir(destination), ".termcade-*.tgd") + if err != nil { + return err + } + temp.Close() + defer os.Remove(temp.Name()) + if _, err := runEngine(ctx, bin, "--headless", "--quiet", "--path", stage, + "--export-release", "Termcade", temp.Name()); err != nil { + return err + } + info, err := os.Stat(temp.Name()) + if err != nil || info.Size() == 0 || info.Size() > maxPackageBytes { + return fmt.Errorf("Godot did not produce a bounded terminal package") + } + // Link is atomic and refuses a destination created during the export. + // Both paths share a directory/filesystem; never replace someone else's file. + return os.Link(temp.Name(), destination) +} + +const maxPackageBytes = 256 << 20 + +func copyProject(source, destination string) error { + var total int64 + return filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(source, path) + if err != nil || relative == "." { + return err + } + if strings.HasPrefix(entry.Name(), ".") || (entry.IsDir() && (entry.Name() == "build" || entry.Name() == "dist")) { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("project symlinks are unsupported: %s", relative) + } + target := filepath.Join(destination, relative) + if entry.IsDir() { + return os.MkdirAll(target, 0o755) + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("project contains a non-regular file: %s", relative) + } + total += info.Size() + if total > maxPackageBytes { + return fmt.Errorf("project exceeds 256 MiB") + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o644) + }) +} + +// Godot may report a script error and still exit zero. Treat the actual error +// output as failure, as the fleet's shared Godot test runner does. +type engineLog struct { + mu sync.Mutex + text string + carry string + failed bool +} + +func (log *engineLog) Write(data []byte) (int, error) { + log.mu.Lock() + defer log.mu.Unlock() + combined := log.carry + string(data) + for _, marker := range []string{"SCRIPT ERROR:", "ERROR:", "TERMCADE EXPORT ERROR:"} { + if strings.Contains(combined, marker) { + log.failed = true + } + } + log.carry = combined[max(0, len(combined)-64):] + log.text += string(data) + log.text = log.text[max(0, len(log.text)-(32<<10)):] + return len(data), nil +} + +func (log *engineLog) snapshot() (string, bool) { + log.mu.Lock() + defer log.mu.Unlock() + return log.text, log.failed +} + +func runEngine(ctx context.Context, bin string, args ...string) (string, error) { + return runEngineProcess(ctx, true, bin, args...) +} + +func runEngineProcess(ctx context.Context, requireClean bool, bin string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + cmd.WaitDelay = time.Second + state, err := os.MkdirTemp("", "termcade-godot-tool-") + if err != nil { + return "", err + } + defer os.RemoveAll(state) + cmd.Env = append(os.Environ(), "XDG_DATA_HOME="+state, "XDG_CONFIG_HOME="+state, "XDG_CACHE_HOME="+state) + log := &engineLog{} + cmd.Stdout, cmd.Stderr = log, log + err = cmd.Run() + output, failed := log.snapshot() + if err != nil || (requireClean && failed) { + return output, fmt.Errorf("Godot command failed (%v): %s", err, output) + } + return output, nil +} diff --git a/internal/godot/runtime.go b/internal/godot/runtime.go new file mode 100644 index 0000000..ae0ea6a --- /dev/null +++ b/internal/godot/runtime.go @@ -0,0 +1,261 @@ +package godot + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/aviorstudio/termcade/sdk" +) + +const maxMessage = 1 << 20 + +type KeyEvent struct { + Code int `json:"code"` + Down bool `json:"down"` +} + +type Frame struct { + Width int `json:"width"` + Height int `json:"height"` + Pixels string `json:"pixels"` + Error string `json:"error"` + RenderWidth int `json:"render_width"` + RenderHeight int `json:"render_height"` + Timings map[string]int64 `json:"timings"` +} + +// Runtime owns one native engine child and its private loopback protocol. It +// is a developer runtime, not a security sandbox for marketplace downloads. +type Runtime struct { + cmd *exec.Cmd + display *virtualDisplay + conn net.Conn + reader *bufio.Reader + log *engineLog + done chan struct{} + close sync.Once + temp string + LastRenderSize [2]int + LastFrameTimes map[string]int64 + Title string + width int + height int +} + +func Start(ctx context.Context, bin, pack string) (_ *Runtime, err error) { + if filepath.Ext(pack) != ".tgd" { + return nil, fmt.Errorf("Godot terminal packages use .tgd") + } + info, err := os.Stat(pack) + if err != nil || !info.Mode().IsRegular() || info.Size() < 4 || info.Size() > maxPackageBytes { + return nil, fmt.Errorf("expected a regular .tgd package of at most 256 MiB") + } + pack, err = filepath.Abs(pack) + if err != nil { + return nil, err + } + listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + return nil, err + } + defer listener.Close() + deadline := time.Now().Add(10 * time.Second) + if callerDeadline, ok := ctx.Deadline(); ok && callerDeadline.Before(deadline) { + deadline = callerDeadline + } + listener.SetDeadline(deadline) + var secret [32]byte + if _, err := rand.Read(secret[:]); err != nil { + return nil, err + } + token := hex.EncodeToString(secret[:]) + temp, err := os.MkdirTemp("", "termcade-godot-runtime-") + if err != nil { + return nil, err + } + r := &Runtime{log: &engineLog{}, done: make(chan struct{}), temp: temp} + defer func() { + if err != nil { + r.Close() + } + }() + r.cmd = exec.CommandContext(ctx, bin, "--display-driver", "x11", "--rendering-method", "gl_compatibility", + "--audio-driver", "Dummy", "--disable-render-loop", "--quiet", "--fixed-fps", "60", + "--path", temp, "--main-pack", pack, "--script", "res://addons/termcade/runtime.gd", "--", + fmt.Sprint(listener.Addr().(*net.TCPAddr).Port), token) + r.cmd.WaitDelay = time.Second + // Caller credentials and environment are not game inputs. This is still + // ordinary native code: the project can access the host OS on its own. + r.cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + temp, "XDG_DATA_HOME=" + temp, "GODOT_SILENCE_ROOT_WARNING=1"} + r.cmd.Env = append(r.cmd.Env, "APPDATA="+temp, "LOCALAPPDATA="+temp, "USERPROFILE="+temp) + if systemRoot := os.Getenv("SystemRoot"); systemRoot != "" { + r.cmd.Env = append(r.cmd.Env, "SystemRoot="+systemRoot) + } + r.display, err = startDisplay(ctx, r.cmd.Env) + if err != nil { + return nil, err + } + r.cmd.Env = append(r.cmd.Env, "DISPLAY="+r.display.name, "LIBGL_ALWAYS_SOFTWARE=1") + if os.Getenv("TERMCADE_GODOT_FULL_RES") == "1" { + r.cmd.Env = append(r.cmd.Env, "TERMCADE_GODOT_FULL_RES=1") + } + r.cmd.Stdout, r.cmd.Stderr = r.log, r.log + if err = r.cmd.Start(); err != nil { + close(r.done) + return nil, err + } + go func() { + _ = r.cmd.Wait() + close(r.done) + listener.Close() // a child that fails before connecting must not cost 10s + }() + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + output, _ := r.log.snapshot() + return nil, fmt.Errorf("Godot terminal handshake failed: %w; %s", acceptErr, output) + } + conn.SetDeadline(deadline) + reader := bufio.NewReaderSize(conn, maxMessage) + line, readErr := boundedLine(reader) + var hello struct { + Protocol int `json:"protocol"` + Token string `json:"token"` + Title string `json:"title"` + } + if readErr != nil || json.Unmarshal(line, &hello) != nil || + hello.Protocol != 2 || subtle.ConstantTimeCompare([]byte(hello.Token), []byte(token)) != 1 { + conn.Close() + continue + } + if len(hello.Title) > 200 { + conn.Close() + return nil, fmt.Errorf("Godot title exceeds 200 bytes") + } + r.conn, r.reader, r.Title = conn, reader, hello.Title + break + } + return r, nil +} + +func boundedLine(reader *bufio.Reader) ([]byte, error) { + line, err := reader.ReadSlice('\n') + if err != nil { + return nil, err + } + return line, nil +} + +// Reset chooses the framebuffer dimensions; every following frame must match. +func (r *Runtime) Reset(width, height int) ([]sdk.Color, error) { + if width < 1 || width > 600 || height < 1 || height > 360 { + return nil, fmt.Errorf("invalid Godot framebuffer %dx%d", width, height) + } + r.width, r.height = width, height + return r.exchange(map[string]any{"op": "reset", "width": width, "height": height}, 30*time.Second) +} + +func (r *Runtime) Step(keys []KeyEvent) ([]sdk.Color, error) { + return r.StepCanvas(keys, r.width, r.height, 0.5) +} + +// StepCanvas adjusts only capture dimensions; resizing never restarts gameplay. +func (r *Runtime) StepCanvas(keys []KeyEvent, width, height int, pixelAspect float64) ([]sdk.Color, error) { + if width < 1 || width > 600 || height < 1 || height > 360 || pixelAspect <= 0 || pixelAspect > 2 { + return nil, fmt.Errorf("invalid Godot framebuffer dimensions") + } + if len(keys) > 256 { + return nil, fmt.Errorf("too many input events in one frame") + } + if keys == nil { + keys = []KeyEvent{} + } + r.width, r.height = width, height + return r.exchange(map[string]any{"op": "step", "keys": keys, "width": width, "height": height, "pixel_aspect": pixelAspect}, 2*time.Second) +} + +func (r *Runtime) exchange(request any, budget time.Duration) ([]sdk.Color, error) { + r.conn.SetDeadline(time.Now().Add(budget)) + data, err := json.Marshal(request) + if err != nil { + return nil, err + } + if _, err = io.Copy(r.conn, strings.NewReader(string(data)+"\n")); err == nil { + var line []byte + line, err = boundedLine(r.reader) + if err == nil { + var frame Frame + err = json.Unmarshal(line, &frame) + if err == nil { + r.LastFrameTimes = frame.Timings + r.LastRenderSize = [2]int{frame.RenderWidth, frame.RenderHeight} + pixels, decodeErr := decodeFrame(frame, r.width, r.height) + _, failed := r.log.snapshot() + if decodeErr == nil && !failed { + return pixels, nil + } + err = decodeErr + if failed { + err = fmt.Errorf("Godot reported a script/runtime error") + } + } + } + } + r.Close() + output, _ := r.log.snapshot() + return nil, fmt.Errorf("Godot terminal frame failed: %w; %s", err, output) +} + +func decodeFrame(frame Frame, width, height int) ([]sdk.Color, error) { + if frame.Error != "" { + return nil, fmt.Errorf("%s", frame.Error) + } + if width < 1 || height < 1 || width > 600 || height > 360 || frame.Width != width || frame.Height != height { + return nil, fmt.Errorf("Godot framebuffer dimensions changed") + } + want := width * height * 3 + if len(frame.Pixels) != base64.StdEncoding.EncodedLen(want) { + return nil, fmt.Errorf("Godot framebuffer length does not match dimensions") + } + data, err := base64.StdEncoding.DecodeString(frame.Pixels) + if err != nil || len(data) != want { + return nil, fmt.Errorf("invalid Godot RGB framebuffer") + } + pixels := make([]sdk.Color, width*height) + for index := range pixels { + i := index * 3 + pixels[index] = sdk.Color(uint32(data[i])<<16 | uint32(data[i+1])<<8 | uint32(data[i+2])) + } + return pixels, nil +} + +func (r *Runtime) Close() error { + r.close.Do(func() { + if r.conn != nil { + r.conn.Close() + } + if r.cmd != nil && r.cmd.Process != nil { + _ = r.cmd.Process.Kill() + <-r.done + } + if r.display != nil { + r.display.Close() + } + os.RemoveAll(r.temp) + }) + return nil +} diff --git a/internal/godot/runtime_test.go b/internal/godot/runtime_test.go new file mode 100644 index 0000000..c5c37ee --- /dev/null +++ b/internal/godot/runtime_test.go @@ -0,0 +1,87 @@ +package godot + +import ( + "bufio" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFrameValidation(t *testing.T) { + valid := Frame{Width: 2, Height: 1, Pixels: base64.StdEncoding.EncodeToString([]byte{255, 0, 0, 0, 255, 0})} + pixels, err := decodeFrame(valid, 2, 1) + if err != nil || len(pixels) != 2 || pixels[0] != 0xff0000 || pixels[1] != 0x00ff00 { + t.Fatalf("RGB frame: %v, %v", pixels, err) + } + for name, frame := range map[string]Frame{ + "error": {Error: "unsupported Camera2D"}, + "changed dimensions": {Width: 3, Height: 1, Pixels: valid.Pixels}, + "short pixels": {Width: 2, Height: 1, Pixels: "AAAA"}, + "bad base64": {Width: 2, Height: 1, Pixels: "!!!!!!!!"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := decodeFrame(frame, 2, 1); err == nil { + t.Fatal("accepted a malformed engine frame") + } + }) + } + if _, err := decodeFrame(valid, 1<<30, 1<<30); err == nil { + t.Fatal("unbounded allocation accepted") + } +} + +func TestBoundedProtocolAndErrorLogs(t *testing.T) { + reader := bufio.NewReaderSize(strings.NewReader(strings.Repeat("x", maxMessage)+"\n"), maxMessage) + if _, err := boundedLine(reader); err == nil { + t.Fatal("accepted oversized protocol message") + } + log := &engineLog{} + log.Write([]byte(strings.Repeat("a", 100000) + "SCRIPT ER")) + log.Write([]byte("ROR: game failed")) + text, failed := log.snapshot() + if !failed || len(text) > 32<<10 { + t.Fatal("split script error was lost or logs grew unbounded") + } +} + +func TestProjectCopyIsolatedAndSymlinksRejected(t *testing.T) { + source := t.TempDir() + destination := t.TempDir() + for name, content := range map[string]string{"project.godot": "config_version=5", ".env": "private", "scene.tscn": "scene"} { + if err := os.WriteFile(filepath.Join(source, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + if err := copyProject(source, destination); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(destination, ".env")); !os.IsNotExist(err) { + t.Fatal("hidden local state copied into export staging") + } + if err := os.Symlink(filepath.Join(source, "scene.tscn"), filepath.Join(source, "linked.tscn")); err != nil { + t.Fatal(err) + } + if err := copyProject(source, t.TempDir()); err == nil { + t.Fatal("symlink was followed") + } +} + +func TestAddonDoesNotOverwriteAuthorChanges(t *testing.T) { + project := t.TempDir() + if err := writeAddon(project); err != nil { + t.Fatal(err) + } + file := filepath.Join(project, "addons", "termcade", "runtime.gd") + if err := os.WriteFile(file, []byte("author edits"), 0o644); err != nil { + t.Fatal(err) + } + if err := writeAddon(project); err == nil { + t.Fatal("edited addon overwritten") + } + data, _ := os.ReadFile(file) + if string(data) != "author edits" { + t.Fatal("author edits lost") + } +} diff --git a/internal/godot/testdata/framebuffer/main.gd b/internal/godot/testdata/framebuffer/main.gd new file mode 100644 index 0000000..8c36904 --- /dev/null +++ b/internal/godot/testdata/framebuffer/main.gd @@ -0,0 +1,69 @@ +extends Node2D + +func _ready() -> void: + if get_viewport().get_visible_rect().size != Vector2(160, 80): + push_error("Terminal rendering changed authored viewport coordinates") + var camera := Camera2D.new() + camera.position = Vector2(80, 40) + add_child(camera) + var frames := SpriteFrames.new() + for color in [Color.BLUE, Color.YELLOW]: + var image := Image.create(24, 24, false, Image.FORMAT_RGBA8) + image.fill(color) + frames.add_frame("default", ImageTexture.create_from_image(image)) + var animated := AnimatedSprite2D.new() + animated.sprite_frames = frames + animated.position = Vector2(60, 20) + animated.play() + add_child(animated) + var rect := ColorRect.new() + rect.position = Vector2(80, 0) + rect.size = Vector2(40, 40) + var shader := Shader.new() + shader.code = "shader_type canvas_item; void fragment() { COLOR = vec4(0.0, 1.0, 0.0, 1.0); }" + var material := ShaderMaterial.new() + material.shader = shader + rect.material = material + add_child(rect) + var ui := CanvasLayer.new() + add_child(ui) + var label := Label.new() + label.text = "UI" + label.position = Vector2(120, 0) + ui.add_child(label) + var button := Button.new() + button.text = "Go" + button.position = Vector2(0, 40) + button.size = Vector2(40, 40) + button.pressed.connect(func() -> void: + State.activations += 1 + get_tree().change_scene_to_file("res://next.tscn")) + ui.add_child(button) + button.grab_focus.call_deferred() + var viewport := SubViewport.new() + viewport.size = Vector2i(40, 40) + viewport.own_world_3d = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + add_child(viewport) + var camera3d := Camera3D.new() + camera3d.position.z = 3 + viewport.add_child(camera3d) + var mesh := MeshInstance3D.new() + mesh.mesh = BoxMesh.new() + var unlit := StandardMaterial3D.new() + unlit.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + unlit.albedo_color = Color.CYAN + mesh.material_override = unlit + viewport.add_child(mesh) + var texture := TextureRect.new() + texture.position = Vector2(80, 40) + texture.size = Vector2(40, 40) + texture.texture = viewport.get_texture() + ui.add_child(texture) + +func _draw() -> void: + draw_rect(Rect2(0, 0, 40, 40), Color.RED) + +func _process(_delta: float) -> void: + if get_viewport().get_visible_rect().size != Vector2(160, 80): + push_error("Capture resizing changed authored viewport coordinates") diff --git a/internal/godot/testdata/framebuffer/main.tscn b/internal/godot/testdata/framebuffer/main.tscn new file mode 100644 index 0000000..2e4ac13 --- /dev/null +++ b/internal/godot/testdata/framebuffer/main.tscn @@ -0,0 +1,4 @@ +[gd_scene format=3] +[ext_resource type="Script" path="res://main.gd" id="1"] +[node name="Main" type="Node2D"] +script = ExtResource("1") diff --git a/internal/godot/testdata/framebuffer/next.gd b/internal/godot/testdata/framebuffer/next.gd new file mode 100644 index 0000000..6cff70d --- /dev/null +++ b/internal/godot/testdata/framebuffer/next.gd @@ -0,0 +1,4 @@ +extends ColorRect +func _ready() -> void: + if State.activations != 1: + push_error("Autoload state did not survive the scene transition") diff --git a/internal/godot/testdata/framebuffer/next.tscn b/internal/godot/testdata/framebuffer/next.tscn new file mode 100644 index 0000000..2bd3fd3 --- /dev/null +++ b/internal/godot/testdata/framebuffer/next.tscn @@ -0,0 +1,7 @@ +[gd_scene format=3] +[ext_resource type="Script" path="res://next.gd" id="1"] +[node name="Next" type="ColorRect"] +color = Color(1,0,1,1) +offset_right = 160.0 +offset_bottom = 80.0 +script = ExtResource("1") diff --git a/internal/godot/testdata/framebuffer/project.godot b/internal/godot/testdata/framebuffer/project.godot new file mode 100644 index 0000000..1f4494f --- /dev/null +++ b/internal/godot/testdata/framebuffer/project.godot @@ -0,0 +1,12 @@ +config_version=5 +[application] +config/name="Framebuffer acceptance" +run/main_scene="res://main.tscn" +[autoload] +State="*res://state.gd" +[display] +window/size/viewport_width=160 +window/size/viewport_height=80 +[rendering] +renderer/rendering_method="gl_compatibility" +environment/defaults/default_clear_color=Color(0,0,0,1) diff --git a/internal/godot/testdata/framebuffer/state.gd b/internal/godot/testdata/framebuffer/state.gd new file mode 100644 index 0000000..6ff8e2c --- /dev/null +++ b/internal/godot/testdata/framebuffer/state.gd @@ -0,0 +1,2 @@ +extends Node +var activations := 0 diff --git a/scripts/install-godot.py b/scripts/install-godot.py new file mode 100644 index 0000000..a5411c7 --- /dev/null +++ b/scripts/install-godot.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Install the fleet-pinned engine for Linux CI without graphical templates.""" + +import hashlib +import io +import os +from pathlib import Path +import platform +import shutil +import subprocess +import urllib.request +import zipfile + +VERSION = "4.7.2.stable.official.ed1daf0bf" +ARCHIVE = "Godot_v4.7.2-stable_linux.x86_64.zip" +SHA256 = "cadd3204e728a35d3f13adb7fd0d7902636b79f6b95c40c265eb73b6c35329e4" +BINARY_SHA256 = "8d106cbe6144c2dc7e881d61d2429c1a8a76e6b22ef48bd5e48dcf934953f71e" +DESTINATION = Path(".artifacts/godot/bin/godot") + + +def verify(path): + if subprocess.check_output([str(path.resolve()), "--version"], timeout=10).decode().strip() != VERSION: + raise SystemExit("Godot engine does not match the pinned version") + + +def main(): + # Explicit caller-owned engine selection also supports development on + # other operating systems. Version checking remains mandatory. + if selected := os.environ.get("GODOT_BIN"): + resolved = shutil.which(selected) + if not resolved: + raise SystemExit("GODOT_BIN does not resolve to an executable") + verify(Path(resolved)) + return + if platform.system() != "Linux" or platform.machine() not in ("x86_64", "AMD64"): + raise SystemExit("Install Godot 4.7.2 and set GODOT_BIN on this platform") + if DESTINATION.exists() and hashlib.sha256(DESTINATION.read_bytes()).hexdigest() == BINARY_SHA256: + verify(DESTINATION) + return + if cached := os.environ.get("GODOT_ARCHIVE"): + data = Path(cached).read_bytes() + else: + url = "https://github.com/godotengine/godot-builds/releases/download/4.7.2-stable/" + ARCHIVE + with urllib.request.urlopen(url, timeout=60) as response: + data = response.read((256 << 20) + 1) + if len(data) > 256 << 20 or hashlib.sha256(data).hexdigest() != SHA256: + raise SystemExit("Godot archive does not match the independently pinned SHA256") + with zipfile.ZipFile(io.BytesIO(data)) as archive: + binary = archive.read("Godot_v4.7.2-stable_linux.x86_64") + if hashlib.sha256(binary).hexdigest() != BINARY_SHA256: + raise SystemExit("Godot binary does not match the pinned SHA256") + DESTINATION.parent.mkdir(parents=True, exist_ok=True) + temporary = DESTINATION.with_suffix(".tmp") + temporary.write_bytes(binary) + temporary.chmod(0o755) + verify(temporary) + temporary.replace(DESTINATION) + + +if __name__ == "__main__": + main() diff --git a/scripts/test-godot-cli.py b/scripts/test-godot-cli.py new file mode 100644 index 0000000..a67d401 --- /dev/null +++ b/scripts/test-godot-cli.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Exercise the actual shipping Linux archive through exports, replay and a PTY.""" + +import fcntl +import hashlib +import json +import os +from pathlib import Path +import pty +import select +import signal +import shutil +import struct +import subprocess +import tarfile +import tempfile +import termios +import time + + +def main(): + archive = Path("dist/termcade_Linux_x86_64.tar.gz") + checksums = dict(line.split(maxsplit=1)[::-1] for line in Path("dist/checksums.txt").read_text().splitlines()) + checksums = {name.strip(): digest for name, digest in checksums.items()} + if hashlib.sha256(archive.read_bytes()).hexdigest() != checksums[archive.name]: + raise AssertionError("shipping archive checksum mismatch") + with tempfile.TemporaryDirectory(prefix="termcade-godot-artifact-") as directory: + work = Path(directory) + with tarfile.open(archive) as packaged: + members = packaged.getmembers() + if len(members) != 1 or members[0].name != "termcade" or not members[0].isfile(): + raise AssertionError("unexpected shipping archive contents") + binary = work / "termcade" + binary.write_bytes(packaged.extractfile(members[0]).read()) + binary.chmod(0o755) + project = work / "project" + shutil.copytree("examples/godot/paddle", project, ignore=shutil.ignore_patterns(".godot", "build")) + pack = work / "paddle.tgd" + environment = dict(os.environ, TERM="xterm-256color", COLORTERM="truecolor", TERMCADE_PIXELS="quad") + + def command(*args, success=True): + result = subprocess.run([str(binary), "godot", *map(str, args)], env=environment, capture_output=True, text=True, timeout=60) + if (result.returncode == 0) != success: + raise AssertionError(result.stdout + result.stderr) + data = json.loads(result.stdout) + if data["ok"] != success: + raise AssertionError(data) + return data + + command("export", "--json", project, pack) + command("play", "--json", pack, success=False) + command("benchmark", "--json", pack, success=False) + benchmark = command("benchmark", "--trusted", "--json", "--frames", 8, "--warmup", 2, pack) + if benchmark["exchange_mean_ms"] <= 0 or benchmark["frames"] != 8 or benchmark["engine_mean_ms"]["render"] <= 0: + raise AssertionError("benchmark did not measure real warmed frames") + initial = work / "initial.png" + moved = work / "moved.png" + repeated = work / "repeated.png" + replay = work / "input.json" + replay.write_text(json.dumps([{"frame": 1, "code": 4194321, "down": True}, {"frame": 20, "code": 4194321, "down": False}])) + command("capture", "--trusted", "--json", pack, initial) + command("capture", "--trusted", "--json", "--frames", 24, "--input", replay, pack, moved) + command("capture", "--trusted", "--json", "--frames", 24, "--input", replay, pack, repeated) + if initial.read_bytes() == moved.read_bytes() or moved.read_bytes() != repeated.read_bytes(): + raise AssertionError("replay did not produce a changed, reproducible frame") + if struct.unpack(">II", moved.read_bytes()[16:24]) != (144, 40): + raise AssertionError("capture does not match terminal pixel dimensions") + command("capture", "--trusted", "--json", pack, initial, success=False) + master, slave = pty.openpty() + + def resize(columns, rows): + fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, columns, 0, 0)) + + resize(96, 30) + process = subprocess.Popen([str(binary), "godot", "play", "--trusted", str(pack)], stdin=slave, stdout=slave, stderr=slave, env=environment, start_new_session=True) + os.close(slave) + captured = bytearray() + + def until(text, seconds=15): + deadline = time.monotonic() + seconds + start = len(captured) + while time.monotonic() < deadline: + readable, _, _ = select.select([master], [], [], 0.1) + if readable: + try: + captured.extend(os.read(master, 65536)) + except OSError: + break + if text in captured[start:]: + return + if process.poll() is not None: + break + raise AssertionError(f"terminal did not show {text!r}: {captured[-4000:]!r}") + + try: + until(b"Godot Terminal Paddle") + if b"\x1b[" not in captured or not any(block in captured for block in ("▀".encode(), "▄".encode(), "▘".encode(), "▝".encode(), "▖".encode(), "▗".encode(), "▚".encode(), "▞".encode())): + raise AssertionError("no terminal block framebuffer appeared") + os.write(master, b"\x1b[C") + os.write(master, b"\x10") # Ctrl+P + until(b"PAUSED") + resize(50, 15) + os.kill(process.pid, signal.SIGWINCH) + until(b"needs at least") + resize(96, 30) + os.kill(process.pid, signal.SIGWINCH) + until(b"Godot Terminal Paddle") + os.write(master, b"\x03") + if process.wait(timeout=5) != 0: + raise AssertionError("terminal player did not exit cleanly") + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + os.close(master) + print("PASS termcade-godot shipping archive, export, deterministic replay and PTY") + + +if __name__ == "__main__": + main()