From d92c8d691e174412f49681d2d10462efa7d9a131 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sat, 10 Oct 2026 21:20:41 -0600 Subject: [PATCH] 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()