From 95035c6f34399d82e86a257ae1b6001efc478422 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 10 Oct 2026 21:20:41 -0600 Subject: [PATCH 1/3] Add experimental Godot terminal export target and runtime --- .github/actions/artifact-check/action.yml | 8 + .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 3 + .gitignore | 3 + Makefile | 10 +- README.md | 47 ++++ cli.go | 16 ++ engineering-profile.json | 4 + examples/godot/paddle/badge.png | Bin 0 -> 85 bytes examples/godot/paddle/game.gd | 16 ++ examples/godot/paddle/main.tscn | 26 ++ examples/godot/paddle/project.godot | 13 + godot.go | 313 ++++++++++++++++++++++ godot_test.go | 40 +++ internal/godot/addon/platform.gd | 75 ++++++ internal/godot/addon/plugin.cfg | 6 + internal/godot/addon/plugin.gd | 13 + internal/godot/addon/renderer.gd | 146 ++++++++++ internal/godot/addon/runtime.gd | 107 ++++++++ internal/godot/addon/setup.gd | 38 +++ internal/godot/integration_test.go | 206 ++++++++++++++ internal/godot/project.go | 237 ++++++++++++++++ internal/godot/runtime.go | 232 ++++++++++++++++ internal/godot/runtime_test.go | 87 ++++++ internal/godot/testdata/renderer_test.gd | 62 +++++ scripts/install-godot.py | 61 +++++ scripts/test-godot-cli.py | 116 ++++++++ 27 files changed, 1885 insertions(+), 3 deletions(-) create mode 100644 .github/actions/artifact-check/action.yml create mode 100644 examples/godot/paddle/badge.png create mode 100644 examples/godot/paddle/game.gd create mode 100644 examples/godot/paddle/main.tscn create mode 100644 examples/godot/paddle/project.godot create mode 100644 godot.go create mode 100644 godot_test.go create mode 100644 internal/godot/addon/platform.gd create mode 100644 internal/godot/addon/plugin.cfg create mode 100644 internal/godot/addon/plugin.gd create mode 100644 internal/godot/addon/renderer.gd create mode 100644 internal/godot/addon/runtime.gd create mode 100644 internal/godot/addon/setup.gd create mode 100644 internal/godot/integration_test.go create mode 100644 internal/godot/project.go create mode 100644 internal/godot/runtime.go create mode 100644 internal/godot/runtime_test.go create mode 100644 internal/godot/testdata/renderer_test.gd create mode 100644 scripts/install-godot.py create mode 100644 scripts/test-godot-cli.py 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/workflows/ci.yml b/.github/workflows/ci.yml index b7a1daa..df9fee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,6 @@ jobs: - name: Test uses: ./.github/actions/test + + - name: Artifact Check + uses: ./.github/actions/artifact-check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df05655..08972fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,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..cc84c8c 100644 --- a/Makefile +++ b/Makefile @@ -3,14 +3,16 @@ 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: 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 +21,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..8790cf4 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,48 @@ 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. The first target supports solid `Polygon2D`, `Line2D`, +`ColorRect`, imported `Sprite2D` textures, transforms, visibility and z ordering. +Godot runs the scene's GDScript and physics; a software renderer produces the +framebuffer for Termcade's existing quadrant, sextant, half-block or ASCII renderer. +There is no browser, graphical window or GPU readback in this target. + +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. The release +archives do not bundle Godot. + +```sh +termcade godot export examples/godot/paddle build/paddle.tgd +termcade godot play --trusted build/paddle.tgd +termcade godot capture --trusted --json --frames 120 build/paddle.tgd frame.png +``` + +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. + +In the player, **Ctrl+P** pauses and **Esc** or **Ctrl+C** exits. 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. + +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 scene. Output is a PNG of the framebuffer before +terminal character conversion. Determinism depends on the game's code and inputs. + +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. General Godot compatibility, +native terminal text nodes, audio, mouse/controller input and terminal image +protocols are not implemented. Unsupported visual nodes, cameras, materials, +autoloads and native extensions are rejected. A hung frame terminates its engine +process rather than blocking the terminal indefinitely. diff --git a/cli.go b/cli.go index 36df49c..1e9d50e 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-2d-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 0000000000000000000000000000000000000000..3d941348e984e065fbb59cec121575e2846684c6 GIT binary patch literal 85 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqGM+AuAr*6yId%kp{O^3K>Hm%l iwwg%_vl1g_Ffa)CY&xiDU+@U1j=|H_&t;ucLK6U1*ccE1 literal 0 HcmV?d00001 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..1f92b16 --- /dev/null +++ b/godot.go @@ -0,0 +1,313 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "image" + "image/color" + "image/png" + "io" + "os" + "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 | capture --trusted [--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") + 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" { + return fmt.Errorf("%s", godotUsage) + } + if (args[0] == "play" || args[0] == "capture") && !*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": + want := 1 + if args[0] == "capture" { + want = 2 + } + if len(positional) != want || *frames < 1 || *frames > 3600 { + return fmt.Errorf("%s", godotUsage) + } + runtime, err := godot.Start(ctx, bin, positional[0]) + if err != nil { + return err + } + defer runtime.Close() + canvas := sdk.NewCanvas(72, 40, sdk.Black, pixelShape()) + 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]) + } + 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-2d-v1", "godot": godot.EngineVersion}) + } + fmt.Println("Godot terminal", operation+":", artifact) + return nil +} + +type replayEvent struct { + Frame int `json:"frame"` + godot.KeyEvent +} + +func captureGodot(runtime *godot.Runtime, canvas *sdk.Canvas, frames int, input, destination string) error { + var events []replayEvent + if input != "" { + file, err := os.Open(input) + if err != nil { + return 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 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 fmt.Errorf("input replay event is out of range") + } + } + } + for frame := 1; frame <= frames; frame++ { + var keys []godot.KeyEvent + for _, event := range events { + if event.Frame == frame { + keys = append(keys, event.KeyEvent) + } + } + pixels, err := runtime.Step(keys) + 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 { + 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() } + +func godotNextTick() tea.Cmd { + return tea.Tick(time.Second/60, func(time.Time) tea.Msg { return godotTick{} }) +} + +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", "esc": + 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) + } + } + } + keys := m.keys + m.keys = nil + m.busy = true + return m, func() tea.Msg { + pixels, err := m.runtime.Step(keys) + return godotFrame{pixels: pixels, err: err} + } + } + return m, godotNextTick() + case godotFrame: + m.busy = false + m.err = msg.err + if msg.err == nil { + copy(m.canvas.Pix(), msg.pixels) + } + return m, godotNextTick() + } + 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 · Esc 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 "backspace": + return special + 4 + case "space", " ": + return 32 + } + runes := []rune(key) + if len(runes) == 1 { + return int(unicode.ToUpper(runes[0])) + } + return 0 +} diff --git a/godot_test.go b/godot_test.go new file mode 100644 index 0000000..50fd8ee --- /dev/null +++ b/godot_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "strings" + "testing" + + 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") + } +} diff --git a/internal/godot/addon/platform.gd b/internal/godot/addon/platform.gd new file mode 100644 index 0000000..ecd329f --- /dev/null +++ b/internal/godot/addon/platform.gd @@ -0,0 +1,75 @@ +@tool +extends EditorExportPlatformExtension + +const Renderer = preload("renderer.gd") + +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_2d", "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": "2d-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: + # Validate saved scene resources without running their _ready/process callbacks. + # This remains developer tooling: GDScript constructors can execute on load. + var failures: PackedStringArray = [] + _validate_scenes("res://", failures) + for property in ProjectSettings.get_property_list(): + if str(property.name).begins_with("autoload/"): + failures.append("Autoloads are not supported by the first terminal target: " + str(property.name)) + if not failures.is_empty(): + for failure in failures: + add_message(EXPORT_MESSAGE_ERROR, "Termcade compatibility", failure) + printerr("TERMCADE EXPORT ERROR: " + failure) + return ERR_UNAVAILABLE + 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 + +func _validate_scenes(directory: String, failures: PackedStringArray) -> void: + for file in DirAccess.get_files_at(directory): + if file.ends_with(".tscn") or file.ends_with(".scn"): + var packed := load(directory.path_join(file)) as PackedScene + if packed == null: + failures.append("Cannot load " + directory.path_join(file)) + continue + var scene := packed.instantiate() + failures.append_array(Renderer.validate(scene, directory.path_join(file))) + scene.free() + for child in DirAccess.get_directories_at(directory): + if not child.begins_with(".") and child != "addons" and child != "build": + _validate_scenes(directory.path_join(child), failures) 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/renderer.gd b/internal/godot/addon/renderer.gd new file mode 100644 index 0000000..4912e70 --- /dev/null +++ b/internal/godot/addon/renderer.gd @@ -0,0 +1,146 @@ +extends RefCounted + +# A software scene renderer. No GPU, viewport readback, browser or graphical +# window participates in this target. Native Godot simulation owns the scene. +const MAX_NODES := 1024 +const MAX_POINTS := 256 +const MAX_TEXTURE_EDGE := 1024 + +var image: Image +var viewport: Vector2 +var scale_to_pixels: Transform2D +var textures: Dictionary = {} + +static func validate(root: Node, context: String = "scene") -> PackedStringArray: + var failures: PackedStringArray = [] + var pending: Array[Node] = [root] + var count := 0 + while not pending.is_empty(): + var node := pending.pop_back() as Node + count += 1 + if count > MAX_NODES: + failures.append(context + ": scene exceeds 1024 nodes") + break + var where := context + "/" + str(root.get_path_to(node)) + if node is Node3D or node is Viewport or node is CanvasLayer or node is Camera2D or node is AudioStreamPlayer: + failures.append(where + ": unsupported " + node.get_class()) + elif node is CanvasItem: + if not (node.get_class() == "Node2D" or node is CollisionObject2D or node is CollisionShape2D or node is CollisionPolygon2D or node is Sprite2D or node is Polygon2D or node is Line2D or node is ColorRect): + failures.append(where + ": unsupported visual node " + node.get_class()) + if node.material != null or node.clip_children != CanvasItem.CLIP_CHILDREN_DISABLED: + failures.append(where + ": materials and canvas clipping are unsupported") + if node is Node2D and node.y_sort_enabled: + failures.append(where + ": y sorting is unsupported; use z_index") + if node.get_script() != null: + for method in node.get_script().get_script_method_list(): + if method.name == "_draw": + failures.append(where + ": custom _draw requires a terminal renderer adapter") + if node is Sprite2D: + if node.texture == null: + failures.append(where + ": Sprite2D requires a texture") + elif node.texture.get_width() > MAX_TEXTURE_EDGE or node.texture.get_height() > MAX_TEXTURE_EDGE: + failures.append(where + ": texture exceeds 1024 pixels per edge") + elif node is Polygon2D: + if node.texture != null or not node.vertex_colors.is_empty() or not node.polygons.is_empty() or not node.skeleton.is_empty() or node.invert_enabled: + failures.append(where + ": only solid unskinned polygons are supported") + if node.polygon.size() > MAX_POINTS: + failures.append(where + ": polygon exceeds 256 points") + elif node is Line2D: + if node.texture != null or node.gradient != null or node.width_curve != null: + failures.append(where + ": only solid constant-width lines are supported") + if node.points.size() > MAX_POINTS: + failures.append(where + ": line exceeds 256 points") + pending.append_array(node.get_children()) + return failures + +func render(root: Node, width: int, height: int, world_size: Vector2) -> Image: + image = Image.create(width, height, false, Image.FORMAT_RGB8) + image.fill(ProjectSettings.get_setting("rendering/environment/defaults/default_clear_color", Color.BLACK)) + viewport = world_size + scale_to_pixels = Transform2D(Vector2(width / world_size.x, 0), Vector2(0, height / world_size.y), Vector2.ZERO) + var items: Array[Dictionary] = [] + _collect(root, items, 0, Color.WHITE) + items.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.z < b.z) + for item in items: + _draw_item(item.node, item.color) + return image + +func _collect(node: Node, items: Array[Dictionary], parent_z: int, inherited: Color) -> void: + var z := parent_z + var tint := inherited + if node is CanvasItem: + if not node.is_visible_in_tree(): + return + z = parent_z + node.z_index if node.z_as_relative else node.z_index + tint *= node.modulate + if node is Sprite2D or node is Polygon2D or node is Line2D or node is ColorRect: + items.append({"node": node, "z": z, "color": tint * node.self_modulate}) + for child in node.get_children(): + _collect(child, items, z, tint) + +func _blend(x: int, y: int, color: Color) -> void: + if color.a > 0.0: + image.set_pixel(x, y, image.get_pixel(x, y).lerp(Color(color.r, color.g, color.b), color.a)) + +func _draw_item(node: CanvasItem, tint: Color) -> void: + var transform := scale_to_pixels * node.get_global_transform() + if is_zero_approx(transform.determinant()): + return + var inverse := transform.affine_inverse() + var polygon: PackedVector2Array = [] + var rect := Rect2() + var texture: Image + var source := Rect2() + if node is Sprite2D: + rect = node.get_rect() + var key: int = node.texture.get_instance_id() + if not textures.has(key): + textures[key] = node.texture.get_image() + texture = textures[key] + if texture == null or texture.is_empty(): + return + source = node.region_rect if node.region_enabled else Rect2(0, 0, texture.get_width(), texture.get_height()) + source.size /= Vector2(node.hframes, node.vframes) + source.position += Vector2(node.frame_coords) * source.size + elif node is ColorRect: + rect = Rect2(Vector2.ZERO, node.size) + elif node is Polygon2D: + for point in node.polygon: + polygon.append(point + node.offset) + elif node is Line2D: + polygon = node.points + if polygon.is_empty(): + polygon = PackedVector2Array([rect.position, rect.position + Vector2(rect.size.x, 0), rect.end, rect.position + Vector2(0, rect.size.y)]) + if polygon.is_empty(): + return + var bounds := Rect2(transform * polygon[0], Vector2.ZERO) + for point in polygon: + bounds = bounds.expand(transform * point) + if node is Line2D: + bounds = bounds.grow(node.width * maxf(transform.x.length(), transform.y.length())) + var x0 := clampi(int(floor(bounds.position.x)), 0, image.get_width()) + var y0 := clampi(int(floor(bounds.position.y)), 0, image.get_height()) + var x1 := clampi(int(ceil(bounds.end.x)), 0, image.get_width()) + var y1 := clampi(int(ceil(bounds.end.y)), 0, image.get_height()) + for y in range(y0, y1): + for x in range(x0, x1): + var local: Vector2 = inverse * Vector2(x + 0.5, y + 0.5) + if node is Sprite2D: + if not rect.has_point(local): + continue + var uv := (local - rect.position) / rect.size + if node.flip_h: + uv.x = 1.0 - uv.x + if node.flip_v: + uv.y = 1.0 - uv.y + var sample := source.position + uv * source.size + if sample.x >= 0 and sample.y >= 0 and sample.x < texture.get_width() and sample.y < texture.get_height(): + _blend(x, y, texture.get_pixel(int(sample.x), int(sample.y)) * tint) + elif node is Line2D: + var points: PackedVector2Array = node.points + for index in range(points.size() if node.closed else points.size() - 1): + if local.distance_to(Geometry2D.get_closest_point_to_segment(local, points[index], points[(index + 1) % points.size()])) <= node.width / 2.0: + _blend(x, y, node.default_color * tint) + break + elif Geometry2D.is_point_in_polygon(local, polygon): + _blend(x, y, node.color * tint) diff --git a/internal/godot/addon/runtime.gd b/internal/godot/addon/runtime.gd new file mode 100644 index 0000000..86e5c51 --- /dev/null +++ b/internal/godot/addon/runtime.gd @@ -0,0 +1,107 @@ +extends SceneTree + +const Renderer = preload("renderer.gd") +const PROTOCOL := 1 +const MAX_REQUEST := 65536 +var peer := StreamPeerTCP.new() +var renderer := Renderer.new() +var scene: Node +var width := 144 +var height := 40 +var input_bytes := PackedByteArray() +var world_size: Vector2 +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) + world_size = Vector2(ProjectSettings.get_setting("display/window/size/viewport_width", 640), ProjectSettings.get_setting("display/window/size/viewport_height", 360)) + _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 + if request.get("op") == "reset": + width = int(request.get("width", 144)) + height = int(request.get("height", 40)) + if width < 1 or width > 600 or height < 1 or height > 360: + _send({"error": "invalid framebuffer dimensions"}) + return true + 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 scene != null: + 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 + scene = packed.instantiate() + root.add_child(scene) + current_scene = scene + elif request.get("op") != "step" or scene == null: + _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: + var failures := Renderer.validate(scene) + if not failures.is_empty(): + _send({"error": "; ".join(failures)}) + quit(2) + return + var frame: Image = renderer.render(scene, width, height, world_size) + _send({"width": width, "height": height, "pixels": Marshalls.raw_to_base64(frame.get_data())}) + +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..eba91a3 --- /dev/null +++ b/internal/godot/addon/setup.gd @@ -0,0 +1,38 @@ +extends SceneTree + +func _initialize() -> void: + var config := ConfigFile.new() + if config.load("res://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("res://project.godot") != OK: + quit(2) + return + var presets := ConfigFile.new() + presets.load("res://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", "2d-v1") + if presets.save("res://export_presets.cfg") != OK: + quit(2) + return + print("TERMCADE SETUP OK") + quit() diff --git a/internal/godot/integration_test.go b/internal/godot/integration_test.go new file mode 100644 index 0000000..f230109 --- /dev/null +++ b/internal/godot/integration_test.go @@ -0,0 +1,206 @@ +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(), 60*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 without a GPU") + } + 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) + } + probe, _ := filepath.Abs("testdata/renderer_test.gd") + output, err := runEngine(ctx, bin, "--headless", "--path", project, "--script", probe) + if err != nil || strings.Count(output, "PASS termcade-godot renderer reachable=1") != 1 { + t.Fatalf("real renderer assertions did not complete: %v; %s", err, output) + } + // Unsupported visuals must fail before creating any destination package. + scene, _ := os.ReadFile(filepath.Join(project, "main.tscn")) + scene = append(scene, []byte("\n[node name=\"Unsupported\" type=\"Camera2D\" parent=\".\"]\n")...) + os.WriteFile(filepath.Join(project, "main.tscn"), scene, 0o644) + badPack := filepath.Join(t.TempDir(), "unsupported.tgd") + if err := Export(ctx, bin, project, badPack); err == nil || !strings.Contains(err.Error(), "Camera2D") { + t.Fatalf("unsupported camera did not fail explicitly: %v", err) + } + if _, err := os.Stat(badPack); !os.IsNotExist(err) { + t.Fatal("failed export left a shipping artifact") + } +} + +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)) < 3 && abs(int(g)-int(tg)) < 3 && abs(int(b)-int(tb)) < 3 { + 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) > 2*time.Second { + 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") + } +} diff --git a/internal/godot/project.go b/internal/godot/project.go new file mode 100644 index 0000000..85b7719 --- /dev/null +++ b/internal/godot/project.go @@ -0,0 +1,237 @@ +// 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 + } + log, err := runEngine(ctx, bin, "--headless", "--path", project, + "--script", "res://addons/termcade/setup.gd") + 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 + } + 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) { + 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 || 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..1bcf637 --- /dev/null +++ b/internal/godot/runtime.go @@ -0,0 +1,232 @@ +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"` +} + +// 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 + conn net.Conn + reader *bufio.Reader + log *engineLog + done chan struct{} + close sync.Once + temp string + 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, "--headless", "--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.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 != 1 || 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}, 5*time.Second) +} + +func (r *Runtime) Step(keys []KeyEvent) ([]sdk.Color, error) { + if len(keys) > 256 { + return nil, fmt.Errorf("too many input events in one frame") + } + if keys == nil { + keys = []KeyEvent{} + } + return r.exchange(map[string]any{"op": "step", "keys": keys}, 500*time.Millisecond) +} + +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 { + 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 + } + 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..77730f0 --- /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", "renderer.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/renderer_test.gd b/internal/godot/testdata/renderer_test.gd new file mode 100644 index 0000000..52a5628 --- /dev/null +++ b/internal/godot/testdata/renderer_test.gd @@ -0,0 +1,62 @@ +extends SceneTree + +const Renderer = preload("res://addons/termcade/renderer.gd") +var failures: PackedStringArray = [] + +func _initialize() -> void: + _test.call_deferred() + +func check(condition: bool, message: String) -> void: + if not condition: + failures.append(message) + +func _test() -> void: + var scene := Node2D.new() + root.add_child(scene) + var polygon := Polygon2D.new() + polygon.polygon = PackedVector2Array([Vector2(0, 0), Vector2(4, 0), Vector2(4, 4), Vector2(0, 4)]) + polygon.position = Vector2(4, 4) + polygon.color = Color.RED + scene.add_child(polygon) + var renderer := Renderer.new() + check(Renderer.validate(scene).is_empty(), "ordinary 2D scene was rejected") + var frame: Image = renderer.render(scene, 16, 16, Vector2(16, 16)) + check(frame.get_pixel(5, 5).r > 0.99, "translated polygon missing") + check(frame.get_pixel(1, 1) == Color.BLACK, "polygon leaked outside its bounds") + polygon.rotation = PI / 2.0 + frame = renderer.render(scene, 16, 16, Vector2(16, 16)) + check(frame.get_pixel(2, 6).r > 0.99 and frame.get_pixel(6, 6) == Color.BLACK, "rotation did not transform the polygon") + polygon.rotation = 0.0 + var overlay := Polygon2D.new() + overlay.polygon = polygon.polygon + overlay.position = polygon.position + overlay.color = Color(0, 0, 1, 0.5) + overlay.z_index = 1 + scene.add_child(overlay) + frame = renderer.render(scene, 16, 16, Vector2(16, 16)) + check(absf(frame.get_pixel(5, 5).r - 0.5) < 0.01 and absf(frame.get_pixel(5, 5).b - 0.5) < 0.01, "alpha or z ordering is wrong") + polygon.z_index = 2 + frame = renderer.render(scene, 16, 16, Vector2(16, 16)) + check(frame.get_pixel(5, 5).r > 0.99 and frame.get_pixel(5, 5).b < 0.01, "z ordering ignored") + scene.visible = false + frame = renderer.render(scene, 16, 16, Vector2(16, 16)) + check(frame.get_pixel(5, 5) == Color.BLACK, "hidden parent still rendered") + scene.visible = true + polygon.position = Vector2(-2, -2) + frame = renderer.render(scene, 8, 4, Vector2(16, 16)) + check(frame.get_width() == 8 and frame.get_height() == 4, "terminal pixel density not honored") + var unsupported := Camera2D.new() + scene.add_child(unsupported) + check(not Renderer.validate(scene).is_empty(), "unsupported camera accepted") + unsupported.free() + var material := ShaderMaterial.new() + polygon.material = material + check(not Renderer.validate(scene).is_empty(), "unsupported shader accepted") + scene.free() + if failures.is_empty(): + print("PASS termcade-godot renderer reachable=1") + quit() + else: + for failure in failures: + printerr("FAIL: " + failure) + quit(1) 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..598534f --- /dev/null +++ b/scripts/test-godot-cli.py @@ -0,0 +1,116 @@ +#!/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) + 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() From 1889d446836c097beaa79a5ac9efdabfaa763efb Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 10 Oct 2026 21:45:50 -0600 Subject: [PATCH 2/3] Render Godot viewports on a private display for terminal playback --- .github/actions/godot-display/action.yml | 10 ++ .github/workflows/ci.yml | 2 + .github/workflows/release-sdk.yml | 2 + .github/workflows/release.yml | 2 + Makefile | 1 + README.md | 57 ++++--- cli.go | 2 +- godot.go | 36 ++++- internal/godot/addon/platform.gd | 32 +--- internal/godot/addon/renderer.gd | 146 ------------------ internal/godot/addon/runtime.gd | 55 ++++--- internal/godot/addon/setup.gd | 15 +- internal/godot/display.go | 92 +++++++++++ internal/godot/integration_test.go | 96 ++++++++++-- internal/godot/project.go | 31 +++- internal/godot/runtime.go | 47 ++++-- internal/godot/runtime_test.go | 2 +- internal/godot/testdata/framebuffer/main.gd | 63 ++++++++ internal/godot/testdata/framebuffer/main.tscn | 4 + internal/godot/testdata/framebuffer/next.gd | 4 + internal/godot/testdata/framebuffer/next.tscn | 7 + .../godot/testdata/framebuffer/project.godot | 12 ++ internal/godot/testdata/framebuffer/state.gd | 2 + internal/godot/testdata/renderer_test.gd | 62 -------- 24 files changed, 456 insertions(+), 326 deletions(-) create mode 100644 .github/actions/godot-display/action.yml delete mode 100644 internal/godot/addon/renderer.gd create mode 100644 internal/godot/display.go create mode 100644 internal/godot/testdata/framebuffer/main.gd create mode 100644 internal/godot/testdata/framebuffer/main.tscn create mode 100644 internal/godot/testdata/framebuffer/next.gd create mode 100644 internal/godot/testdata/framebuffer/next.tscn create mode 100644 internal/godot/testdata/framebuffer/project.godot create mode 100644 internal/godot/testdata/framebuffer/state.gd delete mode 100644 internal/godot/testdata/renderer_test.gd 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 df9fee9..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 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 08972fe..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 diff --git a/Makefile b/Makefile index cc84c8c..56f8c0d 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ export PATH := $(CURDIR)/.artifacts/godot/bin:$(PATH) help: @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 diff --git a/README.md b/README.md index 8790cf4..2ffdeab 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # termcade @@ -299,21 +299,28 @@ go vet ./... ## Godot terminal target (experimental) Godot 4.7.2 projects can export to a custom **Termcade** target and play inside -terminal character cells. The first target supports solid `Polygon2D`, `Line2D`, -`ColorRect`, imported `Sprite2D` textures, transforms, visibility and z ordering. -Godot runs the scene's GDScript and physics; a software renderer produces the -framebuffer for Termcade's existing quadrant, sextant, half-block or ASCII renderer. -There is no browser, graphical window or GPU readback in this target. +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. The release -archives do not bundle Godot. +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 --frames 120 build/paddle.tgd frame.png +termcade godot capture --trusted --json --columns 160 --rows 45 --frames 120 build/paddle.tgd frame.png ``` For your own project, `termcade godot init /path/to/project` installs the addon @@ -321,22 +328,30 @@ 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. - -In the player, **Ctrl+P** pauses and **Esc** or **Ctrl+C** exits. 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. +The new framebuffer protocol requires newly exported packages; re-export packages +created by the earlier software-node prototype. + +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 scene. Output is a PNG of the framebuffer before -terminal character conversion. Determinism depends on the game's code and inputs. +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. 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. General Godot compatibility, -native terminal text nodes, audio, mouse/controller input and terminal image -protocols are not implemented. Unsupported visual nodes, cameras, materials, -autoloads and native extensions are rejected. A hung frame terminates its engine -process rather than blocking the terminal indefinitely. +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 1e9d50e..87b4a81 100644 --- a/cli.go +++ b/cli.go @@ -115,7 +115,7 @@ func runCommand(args []string) bool { } 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-2d-v1"}) + _ = 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) diff --git a/godot.go b/godot.go index 1f92b16..bdab1f4 100644 --- a/godot.go +++ b/godot.go @@ -20,7 +20,7 @@ import ( "github.com/aviorstudio/termcade/sdk" ) -const godotUsage = "usage: termcade godot init | export [--json] | play --trusted | capture --trusted [--frames N] [--input replay.json] " +const godotUsage = "usage: termcade godot init | export [--json] | play --trusted | capture --trusted [--columns N] [--rows N] [--frames N] [--input replay.json] " func cmdGodot(args []string) error { if len(args) == 0 { @@ -30,6 +30,8 @@ func cmdGodot(args []string) error { 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") 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 { @@ -77,7 +79,11 @@ func cmdGodot(args []string) error { return err } defer runtime.Close() - canvas := sdk.NewCanvas(72, 40, sdk.Black, pixelShape()) + 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 { @@ -100,7 +106,7 @@ func cmdGodot(args []string) error { 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-2d-v1", "godot": godot.EngineVersion}) + 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 @@ -144,7 +150,9 @@ func captureGodot(runtime *godot.Runtime, canvas *sdk.Canvas, frames int, input, keys = append(keys, event.KeyEvent) } } - pixels, err := runtime.Step(keys) + 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 } @@ -172,6 +180,7 @@ func captureGodot(runtime *godot.Runtime, canvas *sdk.Canvas, frames int, input, type godotTick struct{} type godotFrame struct { + canvas *sdk.Canvas pixels []sdk.Color err error } @@ -204,7 +213,7 @@ func (m godotPlayer) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.width, m.height = msg.Width, msg.Height case tea.KeyPressMsg: switch msg.String() { - case "ctrl+c", "esc": + case "ctrl+c": return m, tea.Quit case "ctrl+p": m.paused = !m.paused @@ -237,12 +246,20 @@ func (m godotPlayer) Update(message tea.Msg) (tea.Model, tea.Cmd) { } } } + 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 return m, func() tea.Msg { - pixels, err := m.runtime.Step(keys) - return godotFrame{pixels: pixels, err: err} + width, height := canvas.PixelSize() + pixels, err := m.runtime.StepCanvas(keys, width, height, float64(shape.Rows)/float64(2*shape.Cols)) + return godotFrame{canvas: canvas, pixels: pixels, err: err} } } return m, godotNextTick() @@ -250,6 +267,7 @@ func (m godotPlayer) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.busy = false m.err = msg.err if msg.err == nil { + m.canvas = msg.canvas copy(m.canvas.Pix(), msg.pixels) } return m, godotNextTick() @@ -262,7 +280,7 @@ func (m godotPlayer) View() tea.View { 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 · Esc exit" + content = m.canvas.Render() + "\n" + safeGodotText(m.runtime.Title) + " · Ctrl+P pause · Ctrl+C exit" if m.paused { content += " · PAUSED" } @@ -300,6 +318,8 @@ func godotKey(key string) int { return special + 5 case "tab": return special + 2 + case "esc": + return special + 1 case "backspace": return special + 4 case "space", " ": diff --git a/internal/godot/addon/platform.gd b/internal/godot/addon/platform.gd index ecd329f..dd7805d 100644 --- a/internal/godot/addon/platform.gd +++ b/internal/godot/addon/platform.gd @@ -1,8 +1,6 @@ @tool extends EditorExportPlatformExtension -const Renderer = preload("renderer.gd") - func _get_name() -> String: return "Termcade" @@ -18,13 +16,13 @@ func _get_binary_extensions(_preset: EditorExportPreset) -> PackedStringArray: return PackedStringArray(["tgd"]) func _get_platform_features() -> PackedStringArray: - return PackedStringArray(["termcade", "termcade_2d", "pc"]) + 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": "2d-v1"}] + 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) @@ -41,35 +39,9 @@ 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: - # Validate saved scene resources without running their _ready/process callbacks. - # This remains developer tooling: GDScript constructors can execute on load. - var failures: PackedStringArray = [] - _validate_scenes("res://", failures) - for property in ProjectSettings.get_property_list(): - if str(property.name).begins_with("autoload/"): - failures.append("Autoloads are not supported by the first terminal target: " + str(property.name)) - if not failures.is_empty(): - for failure in failures: - add_message(EXPORT_MESSAGE_ERROR, "Termcade compatibility", failure) - printerr("TERMCADE EXPORT ERROR: " + failure) - return ERR_UNAVAILABLE 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 - -func _validate_scenes(directory: String, failures: PackedStringArray) -> void: - for file in DirAccess.get_files_at(directory): - if file.ends_with(".tscn") or file.ends_with(".scn"): - var packed := load(directory.path_join(file)) as PackedScene - if packed == null: - failures.append("Cannot load " + directory.path_join(file)) - continue - var scene := packed.instantiate() - failures.append_array(Renderer.validate(scene, directory.path_join(file))) - scene.free() - for child in DirAccess.get_directories_at(directory): - if not child.begins_with(".") and child != "addons" and child != "build": - _validate_scenes(directory.path_join(child), failures) diff --git a/internal/godot/addon/renderer.gd b/internal/godot/addon/renderer.gd deleted file mode 100644 index 4912e70..0000000 --- a/internal/godot/addon/renderer.gd +++ /dev/null @@ -1,146 +0,0 @@ -extends RefCounted - -# A software scene renderer. No GPU, viewport readback, browser or graphical -# window participates in this target. Native Godot simulation owns the scene. -const MAX_NODES := 1024 -const MAX_POINTS := 256 -const MAX_TEXTURE_EDGE := 1024 - -var image: Image -var viewport: Vector2 -var scale_to_pixels: Transform2D -var textures: Dictionary = {} - -static func validate(root: Node, context: String = "scene") -> PackedStringArray: - var failures: PackedStringArray = [] - var pending: Array[Node] = [root] - var count := 0 - while not pending.is_empty(): - var node := pending.pop_back() as Node - count += 1 - if count > MAX_NODES: - failures.append(context + ": scene exceeds 1024 nodes") - break - var where := context + "/" + str(root.get_path_to(node)) - if node is Node3D or node is Viewport or node is CanvasLayer or node is Camera2D or node is AudioStreamPlayer: - failures.append(where + ": unsupported " + node.get_class()) - elif node is CanvasItem: - if not (node.get_class() == "Node2D" or node is CollisionObject2D or node is CollisionShape2D or node is CollisionPolygon2D or node is Sprite2D or node is Polygon2D or node is Line2D or node is ColorRect): - failures.append(where + ": unsupported visual node " + node.get_class()) - if node.material != null or node.clip_children != CanvasItem.CLIP_CHILDREN_DISABLED: - failures.append(where + ": materials and canvas clipping are unsupported") - if node is Node2D and node.y_sort_enabled: - failures.append(where + ": y sorting is unsupported; use z_index") - if node.get_script() != null: - for method in node.get_script().get_script_method_list(): - if method.name == "_draw": - failures.append(where + ": custom _draw requires a terminal renderer adapter") - if node is Sprite2D: - if node.texture == null: - failures.append(where + ": Sprite2D requires a texture") - elif node.texture.get_width() > MAX_TEXTURE_EDGE or node.texture.get_height() > MAX_TEXTURE_EDGE: - failures.append(where + ": texture exceeds 1024 pixels per edge") - elif node is Polygon2D: - if node.texture != null or not node.vertex_colors.is_empty() or not node.polygons.is_empty() or not node.skeleton.is_empty() or node.invert_enabled: - failures.append(where + ": only solid unskinned polygons are supported") - if node.polygon.size() > MAX_POINTS: - failures.append(where + ": polygon exceeds 256 points") - elif node is Line2D: - if node.texture != null or node.gradient != null or node.width_curve != null: - failures.append(where + ": only solid constant-width lines are supported") - if node.points.size() > MAX_POINTS: - failures.append(where + ": line exceeds 256 points") - pending.append_array(node.get_children()) - return failures - -func render(root: Node, width: int, height: int, world_size: Vector2) -> Image: - image = Image.create(width, height, false, Image.FORMAT_RGB8) - image.fill(ProjectSettings.get_setting("rendering/environment/defaults/default_clear_color", Color.BLACK)) - viewport = world_size - scale_to_pixels = Transform2D(Vector2(width / world_size.x, 0), Vector2(0, height / world_size.y), Vector2.ZERO) - var items: Array[Dictionary] = [] - _collect(root, items, 0, Color.WHITE) - items.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a.z < b.z) - for item in items: - _draw_item(item.node, item.color) - return image - -func _collect(node: Node, items: Array[Dictionary], parent_z: int, inherited: Color) -> void: - var z := parent_z - var tint := inherited - if node is CanvasItem: - if not node.is_visible_in_tree(): - return - z = parent_z + node.z_index if node.z_as_relative else node.z_index - tint *= node.modulate - if node is Sprite2D or node is Polygon2D or node is Line2D or node is ColorRect: - items.append({"node": node, "z": z, "color": tint * node.self_modulate}) - for child in node.get_children(): - _collect(child, items, z, tint) - -func _blend(x: int, y: int, color: Color) -> void: - if color.a > 0.0: - image.set_pixel(x, y, image.get_pixel(x, y).lerp(Color(color.r, color.g, color.b), color.a)) - -func _draw_item(node: CanvasItem, tint: Color) -> void: - var transform := scale_to_pixels * node.get_global_transform() - if is_zero_approx(transform.determinant()): - return - var inverse := transform.affine_inverse() - var polygon: PackedVector2Array = [] - var rect := Rect2() - var texture: Image - var source := Rect2() - if node is Sprite2D: - rect = node.get_rect() - var key: int = node.texture.get_instance_id() - if not textures.has(key): - textures[key] = node.texture.get_image() - texture = textures[key] - if texture == null or texture.is_empty(): - return - source = node.region_rect if node.region_enabled else Rect2(0, 0, texture.get_width(), texture.get_height()) - source.size /= Vector2(node.hframes, node.vframes) - source.position += Vector2(node.frame_coords) * source.size - elif node is ColorRect: - rect = Rect2(Vector2.ZERO, node.size) - elif node is Polygon2D: - for point in node.polygon: - polygon.append(point + node.offset) - elif node is Line2D: - polygon = node.points - if polygon.is_empty(): - polygon = PackedVector2Array([rect.position, rect.position + Vector2(rect.size.x, 0), rect.end, rect.position + Vector2(0, rect.size.y)]) - if polygon.is_empty(): - return - var bounds := Rect2(transform * polygon[0], Vector2.ZERO) - for point in polygon: - bounds = bounds.expand(transform * point) - if node is Line2D: - bounds = bounds.grow(node.width * maxf(transform.x.length(), transform.y.length())) - var x0 := clampi(int(floor(bounds.position.x)), 0, image.get_width()) - var y0 := clampi(int(floor(bounds.position.y)), 0, image.get_height()) - var x1 := clampi(int(ceil(bounds.end.x)), 0, image.get_width()) - var y1 := clampi(int(ceil(bounds.end.y)), 0, image.get_height()) - for y in range(y0, y1): - for x in range(x0, x1): - var local: Vector2 = inverse * Vector2(x + 0.5, y + 0.5) - if node is Sprite2D: - if not rect.has_point(local): - continue - var uv := (local - rect.position) / rect.size - if node.flip_h: - uv.x = 1.0 - uv.x - if node.flip_v: - uv.y = 1.0 - uv.y - var sample := source.position + uv * source.size - if sample.x >= 0 and sample.y >= 0 and sample.x < texture.get_width() and sample.y < texture.get_height(): - _blend(x, y, texture.get_pixel(int(sample.x), int(sample.y)) * tint) - elif node is Line2D: - var points: PackedVector2Array = node.points - for index in range(points.size() if node.closed else points.size() - 1): - if local.distance_to(Geometry2D.get_closest_point_to_segment(local, points[index], points[(index + 1) % points.size()])) <= node.width / 2.0: - _blend(x, y, node.default_color * tint) - break - elif Geometry2D.is_point_in_polygon(local, polygon): - _blend(x, y, node.color * tint) diff --git a/internal/godot/addon/runtime.gd b/internal/godot/addon/runtime.gd index 86e5c51..8f853c7 100644 --- a/internal/godot/addon/runtime.gd +++ b/internal/godot/addon/runtime.gd @@ -1,15 +1,13 @@ extends SceneTree -const Renderer = preload("renderer.gd") -const PROTOCOL := 1 +const PROTOCOL := 2 const MAX_REQUEST := 65536 var peer := StreamPeerTCP.new() -var renderer := Renderer.new() -var scene: Node +var started := false var width := 144 var height := 40 +var pixel_aspect := 0.5 var input_bytes := PackedByteArray() -var world_size: Vector2 var pressed_codes: Array[int] = [] func _initialize() -> void: @@ -25,7 +23,9 @@ func _initialize() -> void: while peer.get_status() == StreamPeerTCP.STATUS_CONNECTING: peer.poll() OS.delay_usec(1000) - world_size = Vector2(ProjectSettings.get_setting("display/window/size/viewport_width", 640), ProjectSettings.get_setting("display/window/size/viewport_height", 360)) + # Preserve the game's viewport and UI layout; only the captured image is resized. + 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: @@ -54,12 +54,13 @@ func _process(_delta: float) -> bool: 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 request.get("op") == "reset": - width = int(request.get("width", 144)) - height = int(request.get("height", 40)) - if width < 1 or width > 600 or height < 1 or height > 360: - _send({"error": "invalid framebuffer dimensions"}) - return true for code in pressed_codes: var release := InputEventKey.new() release.keycode = code @@ -67,16 +68,18 @@ func _request(request: Dictionary) -> bool: Input.parse_input_event(release) pressed_codes.clear() Input.flush_buffered_events() - if scene != null: - scene.free() + 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 - scene = packed.instantiate() + paused = false + var scene := packed.instantiate() root.add_child(scene) current_scene = scene - elif request.get("op") != "step" or scene == null: + started = true + elif request.get("op") != "step" or not started: _send({"error": "expected reset or step"}) return true for key in request.get("keys", []): @@ -95,12 +98,26 @@ func _request(request: Dictionary) -> bool: return false func _send_frame() -> void: - var failures := Renderer.validate(scene) - if not failures.is_empty(): - _send({"error": "; ".join(failures)}) + # Godot renders every node, including cameras, UI, shaders and 3D. The + # private display never appears on the desktop; no node emulation is used. + RenderingServer.force_draw(false, 1.0 / 60.0) + var frame := root.get_texture().get_image() + if frame == null or frame.is_empty(): + _send({"error": "Godot did not produce a rendered viewport"}) quit(2) return - var frame: Image = renderer.render(scene, width, height, world_size) + # 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_LANCZOS) + 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())}) func _send(value: Dictionary) -> void: diff --git a/internal/godot/addon/setup.gd b/internal/godot/addon/setup.gd index eba91a3..8dcf460 100644 --- a/internal/godot/addon/setup.gd +++ b/internal/godot/addon/setup.gd @@ -1,19 +1,24 @@ 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("res://project.godot") != OK: + 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("res://project.godot") != OK: + if config.save(project.path_join("project.godot")) != OK: quit(2) return var presets := ConfigFile.new() - presets.load("res://export_presets.cfg") + 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": @@ -30,8 +35,8 @@ func _initialize() -> void: 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", "2d-v1") - if presets.save("res://export_presets.cfg") != OK: + 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") 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 index f230109..d4694d4 100644 --- a/internal/godot/integration_test.go +++ b/internal/godot/integration_test.go @@ -14,7 +14,7 @@ import ( // 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(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() bin, err := Binary(ctx) if err != nil { @@ -55,7 +55,7 @@ func TestGodotExportAndTerminalRuntime(t *testing.T) { t.Fatal("scene polygons, lines and sprite were not rendered") } if first[4*144+132] == 0 { - t.Fatal("packed imported Sprite2D did not render without a GPU") + t.Fatal("packed imported Sprite2D did not render through Godot") } frame := first for i := range 20 { @@ -107,22 +107,19 @@ func TestGodotExportAndTerminalRuntime(t *testing.T) { if _, err := runEngine(ctx, bin, "--headless", "--quiet", "--editor", "--path", project, "--import"); err != nil { t.Fatal("editor could not load the installed platform:", err) } - probe, _ := filepath.Abs("testdata/renderer_test.gd") - output, err := runEngine(ctx, bin, "--headless", "--path", project, "--script", probe) - if err != nil || strings.Count(output, "PASS termcade-godot renderer reachable=1") != 1 { - t.Fatalf("real renderer assertions did not complete: %v; %s", err, output) - } - // Unsupported visuals must fail before creating any destination package. + // 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=\"Unsupported\" type=\"Camera2D\" parent=\".\"]\n")...) + scene = append(scene, []byte("\n[node name=\"Camera\" type=\"Camera2D\" parent=\".\"]\n")...) os.WriteFile(filepath.Join(project, "main.tscn"), scene, 0o644) - badPack := filepath.Join(t.TempDir(), "unsupported.tgd") - if err := Export(ctx, bin, project, badPack); err == nil || !strings.Contains(err.Error(), "Camera2D") { - t.Fatalf("unsupported camera did not fail explicitly: %v", err) + if err := Export(ctx, bin, project, filepath.Join(t.TempDir(), "camera.tgd")); err != nil { + t.Fatalf("ordinary camera was rejected: %v", err) } - if _, err := os.Stat(badPack); !os.IsNotExist(err) { - t.Fatal("failed export left a shipping artifact") + select { + case <-runtime.display.done: + default: + t.Fatal("owned virtual display survived cleanup") } + } func colored(pixels []sdk.Color) int { @@ -140,7 +137,7 @@ func centerOfColor(pixels []sdk.Color, target sdk.Color) float64 { tr, tg, tb := target.RGB() for index, pixel := range pixels { r, g, b := pixel.RGB() - if abs(int(r)-int(tr)) < 3 && abs(int(g)-int(tg)) < 3 && abs(int(b)-int(tb)) < 3 { + if abs(int(r)-int(tr)) < 40 && abs(int(g)-int(tg)) < 40 && abs(int(b)-int(tb)) < 40 { total += index % 144 count++ } @@ -192,7 +189,7 @@ func TestGodotHungGameIsKilledAndCleanedUp(t *testing.T) { if err == nil { // Physics sees input on the following fixed engine iteration. _, err = runtime.Step(nil) } - if err == nil || time.Since(start) > 2*time.Second { + if err == nil || time.Since(start) > 3500*time.Millisecond { t.Fatalf("hung game escaped its frame watchdog: %v", err) } select { @@ -204,3 +201,70 @@ func TestGodotHungGameIsKilledAndCleanedUp(t *testing.T) { 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) + } + } + // 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") + } +} diff --git a/internal/godot/project.go b/internal/godot/project.go index 85b7719..8a41097 100644 --- a/internal/godot/project.go +++ b/internal/godot/project.go @@ -49,8 +49,23 @@ func InstallAddon(ctx context.Context, bin, project string) error { if err := writeAddon(project); err != nil { return err } - log, err := runEngine(ctx, bin, "--headless", "--path", project, - "--script", "res://addons/termcade/setup.gd") + // 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 } @@ -116,6 +131,12 @@ func Export(ctx context.Context, bin, project, destination string) error { 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 } @@ -216,6 +237,10 @@ func (log *engineLog) snapshot() (string, bool) { } 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...) @@ -230,7 +255,7 @@ func runEngine(ctx context.Context, bin string, args ...string) (string, error) cmd.Stdout, cmd.Stderr = log, log err = cmd.Run() output, failed := log.snapshot() - if err != nil || failed { + 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 index 1bcf637..c8c66aa 100644 --- a/internal/godot/runtime.go +++ b/internal/godot/runtime.go @@ -38,16 +38,17 @@ type Frame struct { // 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 - conn net.Conn - reader *bufio.Reader - log *engineLog - done chan struct{} - close sync.Once - temp string - Title string - width int - height int + cmd *exec.Cmd + display *virtualDisplay + conn net.Conn + reader *bufio.Reader + log *engineLog + done chan struct{} + close sync.Once + temp string + Title string + width int + height int } func Start(ctx context.Context, bin, pack string) (_ *Runtime, err error) { @@ -87,7 +88,8 @@ func Start(ctx context.Context, bin, pack string) (_ *Runtime, err error) { r.Close() } }() - r.cmd = exec.CommandContext(ctx, bin, "--headless", "--quiet", "--fixed-fps", "60", + 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 @@ -98,6 +100,11 @@ func Start(ctx context.Context, bin, pack string) (_ *Runtime, err error) { 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") r.cmd.Stdout, r.cmd.Stderr = r.log, r.log if err = r.cmd.Start(); err != nil { close(r.done) @@ -123,7 +130,7 @@ func Start(ctx context.Context, bin, pack string) (_ *Runtime, err error) { Title string `json:"title"` } if readErr != nil || json.Unmarshal(line, &hello) != nil || - hello.Protocol != 1 || subtle.ConstantTimeCompare([]byte(hello.Token), []byte(token)) != 1 { + hello.Protocol != 2 || subtle.ConstantTimeCompare([]byte(hello.Token), []byte(token)) != 1 { conn.Close() continue } @@ -151,17 +158,26 @@ func (r *Runtime) Reset(width, height int) ([]sdk.Color, error) { 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}, 5*time.Second) + 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{} } - return r.exchange(map[string]any{"op": "step", "keys": keys}, 500*time.Millisecond) + 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) { @@ -226,6 +242,9 @@ func (r *Runtime) Close() error { _ = 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 index 77730f0..c5c37ee 100644 --- a/internal/godot/runtime_test.go +++ b/internal/godot/runtime_test.go @@ -73,7 +73,7 @@ func TestAddonDoesNotOverwriteAuthorChanges(t *testing.T) { if err := writeAddon(project); err != nil { t.Fatal(err) } - file := filepath.Join(project, "addons", "termcade", "renderer.gd") + file := filepath.Join(project, "addons", "termcade", "runtime.gd") if err := os.WriteFile(file, []byte("author edits"), 0o644); err != nil { t.Fatal(err) } diff --git a/internal/godot/testdata/framebuffer/main.gd b/internal/godot/testdata/framebuffer/main.gd new file mode 100644 index 0000000..12b68b2 --- /dev/null +++ b/internal/godot/testdata/framebuffer/main.gd @@ -0,0 +1,63 @@ +extends Node2D + +func _ready() -> void: + 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) 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/internal/godot/testdata/renderer_test.gd b/internal/godot/testdata/renderer_test.gd deleted file mode 100644 index 52a5628..0000000 --- a/internal/godot/testdata/renderer_test.gd +++ /dev/null @@ -1,62 +0,0 @@ -extends SceneTree - -const Renderer = preload("res://addons/termcade/renderer.gd") -var failures: PackedStringArray = [] - -func _initialize() -> void: - _test.call_deferred() - -func check(condition: bool, message: String) -> void: - if not condition: - failures.append(message) - -func _test() -> void: - var scene := Node2D.new() - root.add_child(scene) - var polygon := Polygon2D.new() - polygon.polygon = PackedVector2Array([Vector2(0, 0), Vector2(4, 0), Vector2(4, 4), Vector2(0, 4)]) - polygon.position = Vector2(4, 4) - polygon.color = Color.RED - scene.add_child(polygon) - var renderer := Renderer.new() - check(Renderer.validate(scene).is_empty(), "ordinary 2D scene was rejected") - var frame: Image = renderer.render(scene, 16, 16, Vector2(16, 16)) - check(frame.get_pixel(5, 5).r > 0.99, "translated polygon missing") - check(frame.get_pixel(1, 1) == Color.BLACK, "polygon leaked outside its bounds") - polygon.rotation = PI / 2.0 - frame = renderer.render(scene, 16, 16, Vector2(16, 16)) - check(frame.get_pixel(2, 6).r > 0.99 and frame.get_pixel(6, 6) == Color.BLACK, "rotation did not transform the polygon") - polygon.rotation = 0.0 - var overlay := Polygon2D.new() - overlay.polygon = polygon.polygon - overlay.position = polygon.position - overlay.color = Color(0, 0, 1, 0.5) - overlay.z_index = 1 - scene.add_child(overlay) - frame = renderer.render(scene, 16, 16, Vector2(16, 16)) - check(absf(frame.get_pixel(5, 5).r - 0.5) < 0.01 and absf(frame.get_pixel(5, 5).b - 0.5) < 0.01, "alpha or z ordering is wrong") - polygon.z_index = 2 - frame = renderer.render(scene, 16, 16, Vector2(16, 16)) - check(frame.get_pixel(5, 5).r > 0.99 and frame.get_pixel(5, 5).b < 0.01, "z ordering ignored") - scene.visible = false - frame = renderer.render(scene, 16, 16, Vector2(16, 16)) - check(frame.get_pixel(5, 5) == Color.BLACK, "hidden parent still rendered") - scene.visible = true - polygon.position = Vector2(-2, -2) - frame = renderer.render(scene, 8, 4, Vector2(16, 16)) - check(frame.get_width() == 8 and frame.get_height() == 4, "terminal pixel density not honored") - var unsupported := Camera2D.new() - scene.add_child(unsupported) - check(not Renderer.validate(scene).is_empty(), "unsupported camera accepted") - unsupported.free() - var material := ShaderMaterial.new() - polygon.material = material - check(not Renderer.validate(scene).is_empty(), "unsupported shader accepted") - scene.free() - if failures.is_empty(): - print("PASS termcade-godot renderer reachable=1") - quit() - else: - for failure in failures: - printerr("FAIL: " + failure) - quit(1) From 06f1b3feb74cdec9020281c578d91854b7d10001 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 10 Oct 2026 22:12:00 -0600 Subject: [PATCH 3/3] Reduce Godot frame work and include render time in player pacing --- README.md | 16 ++- godot.go | 108 ++++++++++++++++---- godot_test.go | 11 ++ internal/godot/addon/runtime.gd | 28 ++++- internal/godot/integration_test.go | 23 +++++ internal/godot/runtime.go | 40 +++++--- internal/godot/testdata/framebuffer/main.gd | 6 ++ scripts/test-godot-cli.py | 4 + 8 files changed, 199 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 2ffdeab..d764862 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # termcade @@ -321,6 +321,7 @@ archives do not bundle Godot, Xvfb or Mesa. 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 @@ -331,6 +332,13 @@ 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, @@ -347,6 +355,12 @@ 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 diff --git a/godot.go b/godot.go index bdab1f4..ebcc267 100644 --- a/godot.go +++ b/godot.go @@ -10,6 +10,7 @@ import ( "image/png" "io" "os" + "sort" "strings" "time" "unicode" @@ -20,7 +21,7 @@ import ( "github.com/aviorstudio/termcade/sdk" ) -const godotUsage = "usage: termcade godot init | export [--json] | play --trusted | capture --trusted [--columns N] [--rows N] [--frames N] [--input replay.json] " +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 { @@ -32,16 +33,17 @@ func cmdGodot(args []string) error { 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" { + 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") && !*trusted { + 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() @@ -66,12 +68,12 @@ func cmdGodot(args []string) error { return err } return godotResult(*machine, "export", positional[1]) - case "play", "capture": + case "play", "capture", "benchmark": want := 1 if args[0] == "capture" { want = 2 } - if len(positional) != want || *frames < 1 || *frames > 3600 { + 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]) @@ -96,6 +98,9 @@ func cmdGodot(args []string) error { } 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 @@ -117,12 +122,12 @@ type replayEvent struct { godot.KeyEvent } -func captureGodot(runtime *godot.Runtime, canvas *sdk.Canvas, frames int, input, destination string) error { +func loadGodotReplay(input string, frames int) ([]replayEvent, error) { var events []replayEvent if input != "" { file, err := os.Open(input) if err != nil { - return err + return nil, err } decoder := json.NewDecoder(io.LimitReader(file, 65537)) decoder.DisallowUnknownFields() @@ -135,14 +140,22 @@ func captureGodot(runtime *godot.Runtime, canvas *sdk.Canvas, frames int, input, } file.Close() if err != nil || len(events) > 256 { - return fmt.Errorf("invalid bounded input replay: %v", err) + 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 fmt.Errorf("input replay event is out of range") + 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 { @@ -180,9 +193,10 @@ func captureGodot(runtime *godot.Runtime, canvas *sdk.Canvas, frames int, input, type godotTick struct{} type godotFrame struct { - canvas *sdk.Canvas - pixels []sdk.Color - err error + started time.Time + canvas *sdk.Canvas + pixels []sdk.Color + err error } // The native engine exchange runs as a Tea command, keeping resize, pause and @@ -201,10 +215,16 @@ type godotPlayer struct { height int } -func (m godotPlayer) Init() tea.Cmd { return godotNextTick() } +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{} }) +} -func godotNextTick() tea.Cmd { - return tea.Tick(time.Second/60, 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) { @@ -256,13 +276,14 @@ func (m godotPlayer) Update(message tea.Msg) (tea.Model, tea.Cmd) { 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{canvas: canvas, pixels: pixels, err: err} + return godotFrame{started: started, canvas: canvas, pixels: pixels, err: err} } } - return m, godotNextTick() + return m, godotNextTick(time.Now()) case godotFrame: m.busy = false m.err = msg.err @@ -270,7 +291,7 @@ func (m godotPlayer) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.canvas = msg.canvas copy(m.canvas.Pix(), msg.pixels) } - return m, godotNextTick() + return m, godotNextTick(msg.started) } return m, nil } @@ -331,3 +352,54 @@ func godotKey(key string) int { } 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 index 50fd8ee..72f6acb 100644 --- a/godot_test.go +++ b/godot_test.go @@ -3,6 +3,7 @@ package main import ( "strings" "testing" + "time" tea "charm.land/bubbletea/v2" @@ -38,3 +39,13 @@ func TestGodotPlayerPauseQueuesReleaseAndResizeRetainsFrame(t *testing.T) { 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/runtime.gd b/internal/godot/addon/runtime.gd index 8f853c7..138f2ef 100644 --- a/internal/godot/addon/runtime.gd +++ b/internal/godot/addon/runtime.gd @@ -7,6 +7,8 @@ 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] = [] @@ -23,7 +25,18 @@ func _initialize() -> void: while peer.get_status() == StreamPeerTCP.STATUS_CONNECTING: peer.poll() OS.delay_usec(1000) - # Preserve the game's viewport and UI layout; only the captured image is resized. + 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")}) @@ -60,6 +73,11 @@ func _request(request: Dictionary) -> bool: 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() @@ -100,8 +118,12 @@ func _request(request: Dictionary) -> bool: 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) @@ -112,13 +134,13 @@ func _send_frame() -> void: 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_LANCZOS) + 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())}) + _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/integration_test.go b/internal/godot/integration_test.go index d4694d4..b439b60 100644 --- a/internal/godot/integration_test.go +++ b/internal/godot/integration_test.go @@ -242,6 +242,13 @@ func TestGodotNativeRenderingAndSceneTransitions(t *testing.T) { 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) @@ -267,4 +274,20 @@ func TestGodotNativeRenderingAndSceneTransitions(t *testing.T) { 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/runtime.go b/internal/godot/runtime.go index c8c66aa..ae0ea6a 100644 --- a/internal/godot/runtime.go +++ b/internal/godot/runtime.go @@ -29,26 +29,31 @@ type KeyEvent struct { } type Frame struct { - Width int `json:"width"` - Height int `json:"height"` - Pixels string `json:"pixels"` - Error string `json:"error"` + 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 - Title string - width int - height int + 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) { @@ -105,6 +110,9 @@ func Start(ctx context.Context, bin, pack string) (_ *Runtime, err error) { 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) @@ -193,6 +201,8 @@ func (r *Runtime) exchange(request any, budget time.Duration) ([]sdk.Color, erro 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 { diff --git a/internal/godot/testdata/framebuffer/main.gd b/internal/godot/testdata/framebuffer/main.gd index 12b68b2..8c36904 100644 --- a/internal/godot/testdata/framebuffer/main.gd +++ b/internal/godot/testdata/framebuffer/main.gd @@ -1,6 +1,8 @@ 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) @@ -61,3 +63,7 @@ func _ready() -> void: 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/scripts/test-godot-cli.py b/scripts/test-godot-cli.py index 598534f..a67d401 100644 --- a/scripts/test-godot-cli.py +++ b/scripts/test-godot-cli.py @@ -49,6 +49,10 @@ def command(*args, success=True): 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"