From 5ebc5ab21612fb1123eb82a10c7a60c2978e7d02 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 10 Oct 2026 21:20:41 -0600 Subject: [PATCH 1/5] 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 f6eaef604b6011029bdff61e7f78d9369cda5592 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 10 Oct 2026 21:45:50 -0600 Subject: [PATCH 2/5] 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 c8118aef379b1dd4a9e656d71ee2ba8bb671e2e2 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 10 Oct 2026 22:12:00 -0600 Subject: [PATCH 3/5] 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" From bbd2831ab173f581a6fa2af6fb505b302ed2e009 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 10 Oct 2026 22:27:40 -0600 Subject: [PATCH 4/5] Overlay native terminal text for Godot labels and buttons --- README.md | 12 ++- godot.go | 11 ++- godot_text.go | 144 +++++++++++++++++++++++++++++ godot_text_test.go | 42 +++++++++ internal/godot/addon/runtime.gd | 119 +++++++++++++++++++++++- internal/godot/integration_test.go | 16 +++- internal/godot/runtime.go | 33 +++++++ internal/godot/runtime_test.go | 20 ++++ scripts/test-godot-cli.py | 4 +- 9 files changed, 390 insertions(+), 11 deletions(-) create mode 100644 godot_text.go create mode 100644 godot_text_test.go diff --git a/README.md b/README.md index d764862..7b35a4d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # termcade @@ -345,13 +345,17 @@ exits. Escape goes to the game so its menus work. Arrow keys, letters, space, ta 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. +font give detailed interfaces more room. Standard, axis-aligned Godot `Label` +and `Button` text is drawn as real terminal characters over the game background, +with alignment, wrapping and parent clipping. Re-export existing packages to +include this feature. Rich text, rotated text, subviewport text, custom drawing +and lettering baked into textures retain raster rendering. Terminal fonts replace +the game's font, and crowded layouts can truncate text. Capture accepts `--input replay.json`, an array of `{ "frame": 1, "code": 4194321, "down": true }` events using Godot key codes; `4194321` is the right arrow. Each capture starts a fresh engine. Output is a PNG of the framebuffer before -terminal character conversion. Defaults are 72 columns and 20 rows. Capture +terminal character conversion; native text overlays are omitted from PNGs. 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. diff --git a/godot.go b/godot.go index ebcc267..623dd7c 100644 --- a/godot.go +++ b/godot.go @@ -101,7 +101,7 @@ func cmdGodot(args []string) error { if args[0] == "benchmark" { return benchmarkGodot(runtime, canvas, *frames, *warmup, *input, *machine) } - model := godotPlayer{runtime: runtime, canvas: canvas, held: map[int]int{}} + model := godotPlayer{runtime: runtime, canvas: canvas, text: append([]godot.TextRun(nil), runtime.LastText...), held: map[int]int{}} _, err = tea.NewProgram(model).Run() return err default: @@ -196,6 +196,7 @@ type godotFrame struct { started time.Time canvas *sdk.Canvas pixels []sdk.Color + text []godot.TextRun err error } @@ -204,6 +205,7 @@ type godotFrame struct { type godotPlayer struct { runtime *godot.Runtime canvas *sdk.Canvas + text []godot.TextRun held map[int]int keys []godot.KeyEvent ticks int @@ -280,7 +282,7 @@ func (m godotPlayer) Update(message tea.Msg) (tea.Model, tea.Cmd) { return m, func() tea.Msg { width, height := canvas.PixelSize() pixels, err := m.runtime.StepCanvas(keys, width, height, float64(shape.Rows)/float64(2*shape.Cols)) - return godotFrame{started: started, canvas: canvas, pixels: pixels, err: err} + return godotFrame{started: started, canvas: canvas, pixels: pixels, text: append([]godot.TextRun(nil), m.runtime.LastText...), err: err} } } return m, godotNextTick(time.Now()) @@ -289,6 +291,7 @@ func (m godotPlayer) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err if msg.err == nil { m.canvas = msg.canvas + m.text = msg.text copy(m.canvas.Pix(), msg.pixels) } return m, godotNextTick(msg.started) @@ -301,7 +304,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 · Ctrl+C exit" + content = renderGodotText(m.canvas, m.text) + "\n" + safeGodotText(m.runtime.Title) + " · Ctrl+P pause · Ctrl+C exit" if m.paused { content += " · PAUSED" } @@ -388,7 +391,7 @@ func benchmarkGodot(runtime *godot.Runtime, canvas *sdk.Canvas, frames, warmup i } copy(canvas.Pix(), pixels) started = time.Now() - rendered := canvas.Render() + rendered := renderGodotText(canvas, runtime.LastText) cellMS += float64(time.Since(started).Microseconds()) / 1000 / float64(frames) ansiBytes += len(rendered) } diff --git a/godot_text.go b/godot_text.go new file mode 100644 index 0000000..6257b71 --- /dev/null +++ b/godot_text.go @@ -0,0 +1,144 @@ +package main + +import ( + "fmt" + "math" + "strings" + + "github.com/aviorstudio/termcade/internal/godot" + "github.com/aviorstudio/termcade/sdk" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" +) + +type godotTextCell struct { + value string + color [3]uint8 + span int +} + +// Text replaces whole terminal cells, keeping the sampled game background. +// Cursor movement never enters Bubble Tea's view; all output has fixed width. +func renderGodotText(canvas *sdk.Canvas, runs []godot.TextRun) string { + base := canvas.Render() + if len(runs) == 0 { + return base + } + lines := strings.Split(base, "\n") + shape := canvas.Shape() + width, height := canvas.PixelSize() + cols, rows := width/shape.Cols, height/shape.Rows + cells := make(map[int]godotTextCell) + for _, run := range runs { + left := int(math.Round(run.X / float64(shape.Cols))) + top := int(math.Round(run.Y / float64(shape.Rows))) + right := int(math.Round((run.X + run.W) / float64(shape.Cols))) + bottom := max(top+1, int(math.Round((run.Y+run.H)/float64(shape.Rows)))) + if run.Block { + for y := max(0, top); y < min(rows, bottom); y++ { + for x := max(0, left); x < min(cols, right); x++ { + eraseGodotTextCell(cells, y*cols+x, cols) + } + } + continue + } + available := min(cols, right) - max(0, left) + if available <= 0 || bottom <= top { + continue + } + // Wrap at terminal character boundaries, independent of Godot font metrics. + var wrapped []string + for _, line := range strings.Split(run.Value, "\n") { + line = safeGodotText(line) + for ansi.StringWidth(line) > available { + part := ansi.Truncate(line, available, "") + if part == "" { + break + } + wrapped = append(wrapped, part) + line = line[len(part):] + } + wrapped = append(wrapped, line) + } + if len(wrapped) > bottom-top { + wrapped = wrapped[:bottom-top] + } + y := top + if run.Vertical == 1 { + y += (bottom - top - len(wrapped)) / 2 + } else if run.Vertical == 2 { + y += bottom - top - len(wrapped) + } + for _, line := range wrapped { + x := left + if run.Align == 1 { + x += (right - left - ansi.StringWidth(line)) / 2 + } else if run.Align == 2 { + x += right - left - ansi.StringWidth(line) + } + graphemes := uniseg.NewGraphemes(line) + for graphemes.Next() { + value := graphemes.Str() + span := ansi.StringWidth(value) + if span == 0 { + continue + } + inside := x >= 0 && x+span <= cols && y >= 0 && y < rows && float64(x*shape.Cols) >= run.Clip[0]-0.5 && float64((x+span)*shape.Cols) <= run.Clip[2]+0.5 && float64(y*shape.Rows) >= run.Clip[1]-0.5 && float64((y+1)*shape.Rows) <= run.Clip[3]+0.5 + if inside { + for i := range span { + eraseGodotTextCell(cells, y*cols+x+i, cols) + } + cells[y*cols+x] = godotTextCell{value, run.Color, span} + for i := 1; i < span; i++ { + cells[y*cols+x+i] = godotTextCell{span: -1} + } + } + x += span + } + y++ + } + } + var output strings.Builder + pixels := canvas.Pix() + for y, line := range lines { + if y > 0 { + output.WriteByte('\n') + } + for x := 0; x < cols; { + cell, ok := cells[y*cols+x] + if !ok || cell.span < 1 { + end := x + 1 + for end < cols { + if next, found := cells[y*cols+end]; found && next.span > 0 { + break + } + end++ + } + output.WriteString(ansi.Cut(line, x, end)) + x = end + continue + } + bg := pixels[(y*shape.Rows+shape.Rows/2)*width+x*shape.Cols+shape.Cols/2] + r, g, b := bg.RGB() + fmt.Fprintf(&output, "\x1b[38;2;%d;%d;%d;48;2;%d;%d;%dm%s\x1b[0m", cell.color[0], cell.color[1], cell.color[2], r, g, b, cell.value) + x += cell.span + } + output.WriteString("\x1b[0m") + } + return output.String() +} + +func eraseGodotTextCell(cells map[int]godotTextCell, index, columns int) { + start := index + for start > index-index%columns && cells[start].span < 0 { + start-- + } + span := cells[start].span + if span > 0 { + for i := range span { + delete(cells, start+i) + } + } else { + delete(cells, index) + } +} diff --git a/godot_text_test.go b/godot_text_test.go new file mode 100644 index 0000000..67ef83b --- /dev/null +++ b/godot_text_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "strings" + "testing" + + "github.com/aviorstudio/termcade/internal/godot" + "github.com/aviorstudio/termcade/sdk" + "github.com/charmbracelet/x/ansi" +) + +func TestGodotTextOverlay(t *testing.T) { + canvas := sdk.NewCanvas(20, 8, sdk.Color(0x123456), sdk.Quadrant) + run := godot.TextRun{X: 4, Y: 2, W: 20, H: 4, Value: "Hello 世界", Align: 1, Color: [3]uint8{255, 240, 220}, Clip: [4]float64{0, 0, 40, 8}} + rendered := renderGodotText(canvas, []godot.TextRun{run}) + plain := ansi.Strip(rendered) + if !strings.Contains(plain, "Hello 世界") { + t.Fatalf("text was not preserved: %q", plain) + } + for _, line := range strings.Split(plain, "\n") { + if ansi.StringWidth(line) != 20 { + t.Fatalf("terminal row width changed: %q", line) + } + } + if !strings.Contains(rendered, "48;2;18;52;86m") { + t.Fatal("game background was lost") + } + // A later panel covers earlier text, but a caption within it remains visible. + panel := godot.TextRun{X: 0, Y: 0, W: 40, H: 8, Block: true} + caption := run + caption.Value = "Top" + plain = ansi.Strip(renderGodotText(canvas, []godot.TextRun{run, panel, caption})) + if strings.Contains(plain, "Hello") || !strings.Contains(plain, "Top") { + t.Fatalf("panel ordering failed: %q", plain) + } + run.Value = "\x1b[2Junsafe\a" + run.Clip = [4]float64{0, 0, 2, 2} + rendered = renderGodotText(canvas, []godot.TextRun{run}) + if strings.Contains(rendered, "\x1b[2J") || strings.Contains(rendered, "unsafe") { + t.Fatal("clipped or terminal control text escaped") + } +} diff --git a/internal/godot/addon/runtime.gd b/internal/godot/addon/runtime.gd index 138f2ef..dcb8cf6 100644 --- a/internal/godot/addon/runtime.gd +++ b/internal/godot/addon/runtime.gd @@ -118,11 +118,18 @@ 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 text_state := _collect_text(root) + # Theme changes queue CanvasItem redraws. Capture only after those deferred + # redraws have updated the rendering server's commands. + _capture_frame.call_deferred(text_state) + +func _capture_frame(text_state: Dictionary) -> void: 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() + _restore_text(text_state.restore) var render_size := frame.get_size() if frame == null or frame.is_empty(): _send({"error": "Godot did not produce a rendered viewport"}) @@ -140,7 +147,117 @@ func _send_frame() -> void: output.fill(Color.BLACK) output.blit_rect(frame, Rect2i(Vector2i.ZERO, fitted), Vector2i((width - fitted.x) / 2, (height - fitted.y) / 2)) frame = output - _send({"width": width, "height": height, "pixels": Marshalls.raw_to_base64(frame.get_data()), "render_width": render_size.x, "render_height": render_size.y, "timings": {"render_us": read_start - render_start, "readback_us": resize_start - read_start, "resize_us": Time.get_ticks_usec() - resize_start}}) + _send({"width": width, "height": height, "pixels": Marshalls.raw_to_base64(frame.get_data()), "text": _fit_text(text_state.items, render_size, fitted), "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()) + +# Standard axis-aligned UI text becomes semantic terminal text. Custom drawing, +# rich text, rotated controls and subviewports retain Godot's raster rendering. +func _collect_text(node: Node) -> Dictionary: + var state := {"items": [], "restore": [], "bytes": 0, "order": 0} + _collect_text_nodes(node, state) + state.items.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if a.layer != b.layer: + return a.layer < b.layer + if a.z != b.z: + return a.z < b.z + if a.order != b.order: + return a.order < b.order + return a.get("block", false) and not b.get("block", false)) + return state + +func _control_rect(node: Control) -> Rect2: + return (root.get_final_transform() * node.get_global_transform_with_canvas()) * Rect2(Vector2.ZERO, node.size) + +func _collect_text_nodes(node: Node, state: Dictionary) -> void: + state.order += 1 + if node is SubViewport: + return + if node is Control and node.is_visible_in_tree() and node.get_viewport() == root: + var transform: Transform2D = root.get_final_transform() * node.get_global_transform_with_canvas() + if absf(transform.x.y) < 0.001 and absf(transform.y.x) < 0.001 and transform.x.x > 0 and transform.y.y > 0: + var rect := _control_rect(node) + var clip := Rect2(Vector2.ZERO, Vector2(root.size)) + var ancestor := node.get_parent() + var opacity: float = node.self_modulate.a + var layer := 0 + var z: int = node.z_index + var relative: bool = node.z_as_relative + while ancestor != null: + if ancestor is CanvasLayer: + layer = ancestor.layer + if ancestor is CanvasItem: + if relative: z += ancestor.z_index + relative = relative and ancestor.z_as_relative + opacity *= ancestor.modulate.a + if ancestor is Control and ancestor.clip_contents: + clip = clip.intersection(_control_rect(ancestor)) + ancestor = ancestor.get_parent() + opacity *= node.modulate.a + if opacity > 0.01 and rect.intersects(clip): + if _text_occluder(node) and state.items.size() < 512: + state.items.append({"rect": rect.intersection(clip), "block": true, "layer": layer, "z": z, "order": state.order}) + if (node is Label or node is Button) and not node.text.is_empty() and state.items.size() < 512 and state.bytes < 65536: + var color_name := "font_color" + if node is Button: + if node.disabled: + color_name = "font_disabled_color" + elif node.is_pressed(): + color_name = "font_pressed_color" + var color: Color = node.get_theme_color(color_name) + if color.a <= 0.01: + for child in node.get_children(): + _collect_text_nodes(child, state) + return + var align: int = node.horizontal_alignment if node is Label else node.alignment + var vertical: int = node.vertical_alignment if node is Label else VERTICAL_ALIGNMENT_CENTER + var content: String = node.tr(node.text).substr(0, mini(4096, (65536 - state.bytes) / 4)) + state.bytes += content.to_utf8_buffer().size() + if node is Label and node.visible_characters >= 0: + content = content.substr(0, node.visible_characters) + state.items.append({"rect": rect, "clip": clip, "value": content, "align": align, "vertical": vertical, "color": [color.r8, color.g8, color.b8], "layer": layer, "z": z, "order": state.order}) + for key in ["font_color", "font_shadow_color", "font_outline_color", "font_focus_color", "font_hover_color", "font_pressed_color", "font_hover_pressed_color", "font_disabled_color"]: + state.restore.append([node, key, node.has_theme_color_override(key), node.get_theme_color(key)]) + node.add_theme_color_override(key, Color.TRANSPARENT) + for child in node.get_children(): + _collect_text_nodes(child, state) + +func _restore_text(changes: Array) -> void: + for change in changes: + if change[2]: + change[0].add_theme_color_override(change[1], change[3]) + else: + change[0].remove_theme_color_override(change[1]) + +func _fit_text(items: Array, render_size: Vector2i, fitted: Vector2i) -> Array: + var result: Array = [] + var ratio := Vector2(fitted) / Vector2(render_size) + var offset := Vector2(Vector2i((width - fitted.x) / 2, (height - fitted.y) / 2)) + for item in items: + var rect: Rect2 = item.rect + item["x"] = rect.position.x * ratio.x + offset.x + item["y"] = rect.position.y * ratio.y + offset.y + item["w"] = rect.size.x * ratio.x + item["h"] = rect.size.y * ratio.y + item.erase("rect") + item.erase("layer") + item.erase("z") + item.erase("order") + if item.has("clip"): + var clip: Rect2 = item.clip + item["clip"] = [clip.position.x * ratio.x + offset.x, clip.position.y * ratio.y + offset.y, clip.end.x * ratio.x + offset.x, clip.end.y * ratio.y + offset.y] + result.append(item) + return result + +func _text_occluder(node: Control) -> bool: + var style: StyleBox + if node is Panel or node is PanelContainer: + style = node.get_theme_stylebox("panel") + elif node is Button: + style = node.get_theme_stylebox("normal") + else: + return false + if style is StyleBoxFlat: + return style.bg_color.a > 0.95 + return style is StyleBoxTexture and style.draw_center diff --git a/internal/godot/integration_test.go b/internal/godot/integration_test.go index b439b60..6301f94 100644 --- a/internal/godot/integration_test.go +++ b/internal/godot/integration_test.go @@ -229,7 +229,7 @@ func TestGodotNativeRenderingAndSceneTransitions(t *testing.T) { 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} { + for name, target := range map[string]sdk.Color{"custom draw": 0xff0000, "canvas shader": 0x00ff00, "animated sprite": 0x0000ff, "3D viewport": 0x00ffff} { count := 0 for _, pixel := range pixels { pr, pg, pb := pixel.RGB() @@ -242,6 +242,20 @@ func TestGodotNativeRenderingAndSceneTransitions(t *testing.T) { t.Errorf("%s did not render: %d matching pixels", name, count) } } + found := map[string]bool{} + for _, text := range r.LastText { + found[text.Value] = true + } + for y := 0; y < 24; y++ { + for x := 120; x < 160; x++ { + if pixels[y*160+x] == 0xffffff { + t.Fatal("semantic label was also rasterized") + } + } + } + if !found["UI"] || !found["Go"] { + t.Fatalf("native UI text not extracted: %+v", r.LastText) + } // Low-resolution rendering preserves authored coordinates and gameplay. if _, err = r.StepCanvas(nil, 80, 40, 1); err != nil { t.Fatal(err) diff --git a/internal/godot/runtime.go b/internal/godot/runtime.go index ae0ea6a..a52d0e9 100644 --- a/internal/godot/runtime.go +++ b/internal/godot/runtime.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "math" "net" "os" "os/exec" @@ -28,7 +29,19 @@ type KeyEvent struct { Down bool `json:"down"` } +// TextRun describes an axis-aligned UI control in framebuffer pixel coordinates. +type TextRun struct { + X, Y, W, H float64 + Value string `json:"value"` + Align int `json:"align"` + Vertical int `json:"vertical"` + Color [3]uint8 `json:"color"` + Clip [4]float64 `json:"clip"` + Block bool `json:"block"` +} + type Frame struct { + Text []TextRun `json:"text"` Width int `json:"width"` Height int `json:"height"` Pixels string `json:"pixels"` @@ -49,6 +62,7 @@ type Runtime struct { done chan struct{} close sync.Once temp string + LastText []TextRun LastRenderSize [2]int LastFrameTimes map[string]int64 Title string @@ -203,6 +217,7 @@ func (r *Runtime) exchange(request any, budget time.Duration) ([]sdk.Color, erro if err == nil { r.LastFrameTimes = frame.Timings r.LastRenderSize = [2]int{frame.RenderWidth, frame.RenderHeight} + r.LastText = frame.Text pixels, decodeErr := decodeFrame(frame, r.width, r.height) _, failed := r.log.snapshot() if decodeErr == nil && !failed { @@ -227,6 +242,24 @@ func decodeFrame(frame Frame, width, height int) ([]sdk.Color, error) { if width < 1 || height < 1 || width > 600 || height > 360 || frame.Width != width || frame.Height != height { return nil, fmt.Errorf("Godot framebuffer dimensions changed") } + if len(frame.Text) > 512 { + return nil, fmt.Errorf("too many Godot text controls") + } + textBytes := 0 + for _, text := range frame.Text { + textBytes += len(text.Value) + if textBytes > 65536 || text.Align < 0 || text.Align > 3 || text.Vertical < 0 || text.Vertical > 3 { + return nil, fmt.Errorf("invalid Godot text metadata") + } + for _, value := range []float64{text.X, text.Y, text.W, text.H, text.Clip[0], text.Clip[1], text.Clip[2], text.Clip[3]} { + if math.IsNaN(value) || math.IsInf(value, 0) || math.Abs(value) > 100000 { + return nil, fmt.Errorf("invalid Godot text coordinates") + } + } + if len(text.Value) > 16384 || text.W < 0 || text.H < 0 || text.W > 100000 || text.H > 100000 || text.X < -100000 || text.X > 100000 || text.Y < -100000 || text.Y > 100000 { + return nil, fmt.Errorf("invalid Godot text control") + } + } want := width * height * 3 if len(frame.Pixels) != base64.StdEncoding.EncodedLen(want) { return nil, fmt.Errorf("Godot framebuffer length does not match dimensions") diff --git a/internal/godot/runtime_test.go b/internal/godot/runtime_test.go index c5c37ee..b5a9180 100644 --- a/internal/godot/runtime_test.go +++ b/internal/godot/runtime_test.go @@ -3,6 +3,7 @@ package godot import ( "bufio" "encoding/base64" + "math" "os" "path/filepath" "strings" @@ -85,3 +86,22 @@ func TestAddonDoesNotOverwriteAuthorChanges(t *testing.T) { t.Fatal("author edits lost") } } + +func TestTextMetadataBounds(t *testing.T) { + valid := Frame{Width: 1, Height: 1, Pixels: "AAAA"} + cases := [][]TextRun{ + make([]TextRun, 513), + {{Value: strings.Repeat("x", 16385)}}, + {{X: math.NaN()}}, + {{Clip: [4]float64{0, 0, math.Inf(1), 1}}}, + {{W: -1}}, + {{Align: 20}}, + } + for _, texts := range cases { + frame := valid + frame.Text = texts + if _, err := decodeFrame(frame, 1, 1); err == nil { + t.Fatal("accepted unbounded text metadata") + } + } +} diff --git a/scripts/test-godot-cli.py b/scripts/test-godot-cli.py index a67d401..84c5cad 100644 --- a/scripts/test-godot-cli.py +++ b/scripts/test-godot-cli.py @@ -35,6 +35,8 @@ def main(): binary.chmod(0o755) project = work / "project" shutil.copytree("examples/godot/paddle", project, ignore=shutil.ignore_patterns(".godot", "build")) + scene = project / "main.tscn" + scene.write_text(scene.read_text() + '\n[node name="Caption" type="Label" parent="."]\noffset_left = 10.0\noffset_top = 10.0\noffset_right = 134.0\noffset_bottom = 34.0\ntext = "Readable caption"\n') pack = work / "paddle.tgd" environment = dict(os.environ, TERM="xterm-256color", COLORTERM="truecolor", TERMCADE_PIXELS="quad") @@ -93,7 +95,7 @@ def until(text, seconds=15): raise AssertionError(f"terminal did not show {text!r}: {captured[-4000:]!r}") try: - until(b"Godot Terminal Paddle") + until(b"Readable caption") 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") From b6a7aefe44a5981e74117664c5da36f81d3e874b Mon Sep 17 00:00:00 2001 From: nicodes Date: Sun, 11 Oct 2026 01:40:35 -0600 Subject: [PATCH 5/5] Supersample Godot backgrounds to reduce camera-pan shimmer --- README.md | 10 ++- internal/godot/addon/runtime.gd | 9 ++- internal/godot/integration_test.go | 65 ++++++++++++++++++- internal/godot/runtime.go | 5 ++ internal/godot/testdata/sampling/main.gd | 19 ++++++ internal/godot/testdata/sampling/main.tscn | 4 ++ .../godot/testdata/sampling/project.godot | 10 +++ 7 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 internal/godot/testdata/sampling/main.gd create mode 100644 internal/godot/testdata/sampling/main.tscn create mode 100644 internal/godot/testdata/sampling/project.godot diff --git a/README.md b/README.md index 7b35a4d..7870186 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + # termcade @@ -332,8 +332,12 @@ 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. +Godot renders at up to twice the terminal pixel resolution while retaining the +authored logical coordinates and UI layout. Mipmap-based downsampling averages +fine background detail to reduce shimmer while the camera moves, especially in +small terminals. Set `TERMCADE_GODOT_SAMPLING=fast` for the earlier lower-cost +bilinear rendering, or `smooth` for the default. Re-export existing packages to +include the sampling change. 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 diff --git a/internal/godot/addon/runtime.gd b/internal/godot/addon/runtime.gd index dcb8cf6..317a6e8 100644 --- a/internal/godot/addon/runtime.gd +++ b/internal/godot/addon/runtime.gd @@ -9,6 +9,7 @@ var height := 40 var pixel_aspect := 0.5 var logical_size: Vector2i var native_resolution := false +var smooth_sampling := true var input_bytes := PackedByteArray() var pressed_codes: Array[int] = [] @@ -30,6 +31,7 @@ func _initialize() -> void: if logical_size.x <= 0 or logical_size.y <= 0: logical_size = root.size native_resolution = OS.get_environment("TERMCADE_GODOT_FULL_RES") == "1" + smooth_sampling = OS.get_environment("TERMCADE_GODOT_SAMPLING") != "fast" # 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. @@ -74,7 +76,7 @@ func _request(request: Dictionary) -> bool: _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 scale := minf(1.0, minf(float(width) / logical_size.x, float(height) / pixel_aspect / logical_size.y) * (2.0 if smooth_sampling else 1.0)) 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 @@ -141,7 +143,10 @@ func _capture_frame(text_state: Dictionary) -> 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_BILINEAR) + # Render two samples across each capture pixel and average the footprint. + # Bilinear alone samples too little of a downscaled moving background, + # causing fine edges and textures to flicker between terminal cells. + frame.resize(fitted.x, fitted.y, Image.INTERPOLATE_TRILINEAR if smooth_sampling else Image.INTERPOLATE_BILINEAR) frame.convert(Image.FORMAT_RGB8) var output := Image.create(width, height, false, Image.FORMAT_RGB8) output.fill(Color.BLACK) diff --git a/internal/godot/integration_test.go b/internal/godot/integration_test.go index 6301f94..0f0130e 100644 --- a/internal/godot/integration_test.go +++ b/internal/godot/integration_test.go @@ -260,8 +260,8 @@ func TestGodotNativeRenderingAndSceneTransitions(t *testing.T) { 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) + if r.LastRenderSize[0] > 160 || r.LastRenderSize[1] > 80 || r.LastRenderSize[0] < 1 { + t.Fatalf("supersampled render target exceeded its bound: %v", r.LastRenderSize) } // Capture resize preserves the scene, focused button and autoload state. if _, err = r.StepCanvas(nil, 320, 160, 1); err != nil { @@ -305,3 +305,64 @@ func TestGodotNativeRenderingAndSceneTransitions(t *testing.T) { } } + +// A diagonal camera pan over subpixel detail should average that detail instead +// of blinking the entire sampled background between black and white. +func TestGodotDiagonalBackgroundSampling(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(), "sampling.tgd") + if err := Export(ctx, bin, "testdata/sampling", pack); err != nil { + t.Fatal(err) + } + measure := func(mode string) float64 { + t.Setenv("TERMCADE_GODOT_SAMPLING", mode) + r, err := Start(ctx, bin, pack) + if err != nil { + t.Fatal(err) + } + defer r.Close() + previous, err := r.Reset(64, 32) + if err != nil { + t.Fatal(err) + } + // Settle the square-pixel capture before measuring only camera motion. + for range 4 { + previous, err = r.StepCanvas(nil, 64, 32, 1) + if err != nil { + t.Fatal(err) + } + } + energy := 0.0 + for range 24 { + frame, err := r.StepCanvas(nil, 64, 32, 1) + if err != nil { + t.Fatal(err) + } + for i, pixel := range frame { + pr, _, _ := pixel.RGB() + before, _, _ := previous[i].RGB() + energy += float64(abs(int(pr) - int(before))) + } + previous = frame + } + expected := [2]int{64, 32} + if mode == "smooth" { + expected = [2]int{128, 64} + } + if r.LastRenderSize != expected { + t.Fatalf("%s render size %v, expected %v", mode, r.LastRenderSize, expected) + } + return energy / float64(24*64*32) + } + fast := measure("fast") + smooth := measure("smooth") + t.Logf("diagonal background mean per-pixel flicker: fast %.2f, smooth %.2f", fast, smooth) + if fast < 20 || smooth >= fast*0.25 { + t.Fatalf("moving background aliasing was not reduced: fast %.2f, smooth %.2f", fast, smooth) + } +} diff --git a/internal/godot/runtime.go b/internal/godot/runtime.go index a52d0e9..098cba4 100644 --- a/internal/godot/runtime.go +++ b/internal/godot/runtime.go @@ -71,6 +71,10 @@ type Runtime struct { } func Start(ctx context.Context, bin, pack string) (_ *Runtime, err error) { + sampling := os.Getenv("TERMCADE_GODOT_SAMPLING") + if sampling != "" && sampling != "smooth" && sampling != "fast" { + return nil, fmt.Errorf("TERMCADE_GODOT_SAMPLING must be smooth or fast") + } if filepath.Ext(pack) != ".tgd" { return nil, fmt.Errorf("Godot terminal packages use .tgd") } @@ -124,6 +128,7 @@ 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") + r.cmd.Env = append(r.cmd.Env, "TERMCADE_GODOT_SAMPLING="+sampling) if os.Getenv("TERMCADE_GODOT_FULL_RES") == "1" { r.cmd.Env = append(r.cmd.Env, "TERMCADE_GODOT_FULL_RES=1") } diff --git a/internal/godot/testdata/sampling/main.gd b/internal/godot/testdata/sampling/main.gd new file mode 100644 index 0000000..d6c397c --- /dev/null +++ b/internal/godot/testdata/sampling/main.gd @@ -0,0 +1,19 @@ +extends Node2D +var camera: Camera2D + +func _ready() -> void: + var image := Image.create(1024, 512, false, Image.FORMAT_RGB8) + for y in 512: + for x in 1024: + image.set_pixel(x, y, Color.WHITE if ((x >> 1) + (y >> 2)) % 2 == 0 else Color.BLACK) + var sprite := Sprite2D.new() + sprite.texture = ImageTexture.create_from_image(image) + sprite.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + sprite.centered = false + add_child(sprite) + camera = Camera2D.new() + camera.position = Vector2(256, 128) + add_child(camera) + +func _physics_process(_delta: float) -> void: + camera.position += Vector2(1, 1) diff --git a/internal/godot/testdata/sampling/main.tscn b/internal/godot/testdata/sampling/main.tscn new file mode 100644 index 0000000..3985fdc --- /dev/null +++ b/internal/godot/testdata/sampling/main.tscn @@ -0,0 +1,4 @@ +[gd_scene format=3] +[ext_resource type="Script" path="res://main.gd" id="1"] +[node name="Sampling" type="Node2D"] +script = ExtResource("1") diff --git a/internal/godot/testdata/sampling/project.godot b/internal/godot/testdata/sampling/project.godot new file mode 100644 index 0000000..41edb2f --- /dev/null +++ b/internal/godot/testdata/sampling/project.godot @@ -0,0 +1,10 @@ +config_version=5 +[application] +config/name="Terminal sampling fixture" +run/main_scene="res://main.tscn" +[display] +window/size/viewport_width=512 +window/size/viewport_height=256 +[rendering] +renderer/rendering_method="gl_compatibility" +textures/canvas_textures/default_texture_filter=0