feat(phase-5): viewport vision, shaders/themes, scene indexing, e2e benchmark, 1.0.0 docs

This commit is contained in:
John Smith committed 2026-10-12 00:02:05 -04:00
1 parent d0ce544957
commit e625dbea51
17 files changed
+1645

No files matched your search

@@ -0,0 +1,76 @@
@tool
extends RefCounted
## Shader domain handler: spatial / canvas-item generation with uniform
## injection. Runtime compilation stays engine-side; this handler validates
## shader_type headers and merges uniform declarations idempotently.
const VALID_TYPES := ["spatial", "canvas_item", "particles", "sky", "fog"]
func generate_shader(params: Dictionary) -> Dictionary:
var mode := str(params.get("mode", "spatial"))
if not mode in VALID_TYPES:
return {"error": {"code": "INVALID_PARAMS", "message": "Unknown shader_type: %s" % mode}}
var uniforms: Array = params.get("uniforms", [])
var lines: Array[String] = ["shader_type %s;" % mode]
for entry in uniforms:
var decl := _uniform_decl(entry)
if decl.is_empty():
return {"error": {"code": "INVALID_PARAMS", "message": "Uniform needs name + type"}}
lines.append(decl)
var body := str(params.get("body", ""))
if body.is_empty():
body = "void fragment() {\n\tALBEDO = vec3(1.0);\n}" if mode == "spatial" else "void fragment() {\n\tCOLOR = vec4(1.0);\n}"
lines.append(body)
return {"data": {"source": "\n".join(lines) + "\n", "mode": mode}}
func inject_uniform(params: Dictionary) -> Dictionary:
var source := str(params.get("source", ""))
if not source.contains("shader_type"):
return {"error": {"code": "INVALID_PARAMS", "message": "Source has no shader_type header"}}
var entry: Dictionary = params.get("uniform", {})
var decl := _uniform_decl(entry)
if decl.is_empty():
return {"error": {"code": "INVALID_PARAMS", "message": "Uniform needs name + type"}}
var uname := str(entry.get("name", ""))
var pattern := RegEx.new()
pattern.compile("^\\s*uniform\\s+\\S+\\s+" + uname + "\\b")
var out: Array[String] = []
for line in source.split("\n"):
if pattern.search(line) != null:
continue
out.append(line)
if line.contains("shader_type"):
out.append(decl)
return {"data": {"source": "\n".join(out)}}
func list_uniforms(params: Dictionary) -> Dictionary:
var source := str(params.get("source", ""))
var found: Array = []
for line in source.split("\n"):
var stripped := line.strip_edges()
if not stripped.begins_with("uniform "):
continue
var rest := stripped.trim_prefix("uniform ").strip_edges()
var parts := rest.split(" ", false)
if parts.size() >= 2:
found.append({"type": parts[0], "name": parts[1].trim_suffix(";").split(":")[0].split("=")[0].strip_edges()})
return {"data": {"uniforms": found}}
func _uniform_decl(entry: Variant) -> String:
if not (entry is Dictionary):
return ""
var d: Dictionary = entry
var uname := str(d.get("name", ""))
var utype := str(d.get("type", ""))
if uname.is_empty() or utype.is_empty():
return ""
var decl := "uniform %s %s" % [utype, uname]
if not str(d.get("hint", "")).is_empty():
decl += " : %s" % str(d.get("hint", ""))
if not str(d.get("default", "")).is_empty():
decl += " = %s" % str(d.get("default", ""))
return decl + ";"
@@ -0,0 +1,95 @@
@tool
extends RefCounted
## Theme domain handler: inspect Theme/StyleBoxFlat/font/color overrides
## and apply style overrides as single undoable actions.
const UndoManager := preload("res://addons/godot_opencode/undo_manager.gd")
var _undo: RefCounted
func _init(undo_manager: RefCounted) -> void:
_undo = undo_manager
func _scene_root() -> Node:
return EditorInterface.get_edited_scene_root()
func inspect_theme(params: Dictionary) -> Dictionary:
var scene_root := _scene_root()
if scene_root == null:
return {"error": {"code": "EDITOR_NOT_READY", "message": "No open scene", "data": {"sub_code": "EDITOR_NO_SCENE"}}}
var node: Node = UndoManager._resolve_by_path(scene_root, str(params.get("path", "")))
if node == null:
return {"error": {"code": "NODE_NOT_FOUND", "message": "Node not found: %s" % str(params.get("path", ""))}}
if not (node is Control):
return {"error": {"code": "WRONG_TYPE", "message": "Node is not a Control: %s" % str(params.get("path", ""))}}
var control := node as Control
var overrides: Array = []
for name in ["normal", "hover", "pressed", "disabled", "focus"]:
if control.has_theme_stylebox_override(name):
var sb := control.get_theme_stylebox(name)
overrides.append({"name": name, "type": sb.get_class() if sb != null else "null"})
var colors: Array = []
for cname in ["font_color", "font_hover_color", "font_pressed_color"]:
if control.has_theme_color_override(cname):
colors.append({"name": cname, "value": str(control.get_theme_color(cname))})
return {
"data": {
"path": str(params.get("path", "")),
"stylebox_overrides": overrides,
"color_overrides": colors,
"font_size": control.get_theme_font_size("font_size") if control.has_theme_font_size_override("font_size") else -1,
}
}
func apply_stylebox(params: Dictionary) -> Dictionary:
var scene_root := _scene_root()
if scene_root == null:
return {"error": {"code": "EDITOR_NOT_READY", "message": "No open scene", "data": {"sub_code": "EDITOR_NO_SCENE"}}}
var node: Node = UndoManager._resolve_by_path(scene_root, str(params.get("path", "")))
if node == null:
return {"error": {"code": "NODE_NOT_FOUND", "message": "Node not found: %s" % str(params.get("path", ""))}}
if not (node is Control):
return {"error": {"code": "WRONG_TYPE", "message": "Node is not a Control"}}
var control := node as Control
var override_name := str(params.get("override", "normal"))
var sb := StyleBoxFlat.new()
sb.bg_color = Color(str(params.get("bg_color", "#1a1d29")))
sb.set_corner_radius_all(int(params.get("corner_radius", 8)))
sb.set_border_width_all(int(params.get("border_width", 0)))
sb.border_color = Color(str(params.get("border_color", "#00000000")))
sb.content_margin_left = float(params.get("content_margin", 12.0))
sb.content_margin_right = float(params.get("content_margin", 12.0))
sb.content_margin_top = float(params.get("content_margin", 12.0))
sb.content_margin_bottom = float(params.get("content_margin", 12.0))
var undo_redo: EditorUndoRedoManager = _undo.get("_undo_redo")
undo_redo.create_action("OpenCode: Style %s" % override_name)
undo_redo.add_do_method(control, "add_theme_stylebox_override", override_name, sb)
undo_redo.add_undo_method(control, "remove_theme_stylebox_override", override_name)
undo_redo.commit_action()
return {"data": {"path": str(params.get("path", "")), "override": override_name, "undoable": true}}
func apply_font_override(params: Dictionary) -> Dictionary:
var scene_root := _scene_root()
if scene_root == null:
return {"error": {"code": "EDITOR_NOT_READY", "message": "No open scene", "data": {"sub_code": "EDITOR_NO_SCENE"}}}
var node: Node = UndoManager._resolve_by_path(scene_root, str(params.get("path", "")))
if node == null:
return {"error": {"code": "NODE_NOT_FOUND", "message": "Node not found: %s" % str(params.get("path", ""))}}
if not (node is Control):
return {"error": {"code": "WRONG_TYPE", "message": "Node is not a Control"}}
var control := node as Control
var undo_redo: EditorUndoRedoManager = _undo.get("_undo_redo")
undo_redo.create_action("OpenCode: Font override")
if params.has("font_size"):
undo_redo.add_do_method(control, "add_theme_font_size_override", "font_size", int(params.get("font_size")))
undo_redo.add_undo_method(control, "remove_theme_font_size_override", "font_size")
if params.has("font_color"):
undo_redo.add_do_method(control, "add_theme_color_override", "font_color", Color(str(params.get("font_color"))))
undo_redo.add_undo_method(control, "remove_theme_color_override", "font_color")
undo_redo.commit_action()
return {"data": {"path": str(params.get("path", "")), "undoable": true}}
+27
View File
@@ -12,6 +12,9 @@ const UndoManager := preload("res://addons/godot_opencode/undo_manager.gd")
const NodeHandler := preload("res://addons/godot_opencode/handlers/node_handler.gd")
const SignalHandler := preload("res://addons/godot_opencode/handlers/signal_handler.gd")
const ResourceHandler := preload("res://addons/godot_opencode/handlers/resource_handler.gd")
const ShaderHandler := preload("res://addons/godot_opencode/handlers/shader_handler.gd")
const ThemeHandler := preload("res://addons/godot_opencode/handlers/theme_handler.gd")
const ViewportHandler := preload("res://addons/godot_opencode/viewport_handler.gd")
const DebuggerPlugin := preload("res://addons/godot_opencode/debugger/debugger_plugin.gd")
const DEFAULT_CAPABILITY_DIR := "~/.config/opencode/godot/capabilities"
@@ -28,9 +31,15 @@ func _enter_tree() -> void:
var nodes := NodeHandler.new(undo_manager)
var signals := SignalHandler.new(undo_manager)
var resources := ResourceHandler.new(undo_manager)
var shaders := ShaderHandler.new()
var themes := ThemeHandler.new(undo_manager)
var viewport := ViewportHandler.new()
_register_node_commands(nodes)
_register_signal_commands(signals)
_register_resource_commands(resources)
_register_shader_commands(shaders)
_register_theme_commands(themes)
_register_viewport_commands(viewport)
_connection = Connection.new()
_connection.name = "GodotOpenCodeConnection"
_connection.dispatcher = _dispatcher
@@ -84,6 +93,24 @@ func _register_resource_commands(handler: RefCounted) -> void:
_dispatcher.register("assign_resource", handler.assign_resource)
func _register_shader_commands(handler: RefCounted) -> void:
_dispatcher.register("generate_shader", handler.generate_shader)
_dispatcher.register("inject_uniform", handler.inject_uniform)
_dispatcher.register("list_uniforms", handler.list_uniforms)
func _register_theme_commands(handler: RefCounted) -> void:
_dispatcher.register("inspect_theme", handler.inspect_theme)
_dispatcher.register("apply_stylebox", handler.apply_stylebox)
_dispatcher.register("apply_font_override", handler.apply_font_override)
func _register_viewport_commands(handler: RefCounted) -> void:
_dispatcher.register("capture_viewport", handler.capture_viewport)
_dispatcher.register("frame_aabb", handler.frame_aabb)
_dispatcher.register("capture_status", handler.capture_status)
func _capability_record_path() -> String:
var override := OS.get_environment("GODOT_CAPABILITY_DIR").strip_edges()
var directory := override if not override.is_empty() else DEFAULT_CAPABILITY_DIR
@@ -0,0 +1,85 @@
@tool
extends RefCounted
## Viewport domain handler: 2D/3D runtime capture via the debugger bridge.
## Returns a base64 PNG plus capture timestamp; callers compare the frame
## age against MAIN_LOOP_STALL_MSEC (1000ms) and treat older frames as
## stale fallbacks instead of blocking the pipeline.
const STALL_MSEC := 1000
var _last_png_base64 := ""
var _last_capture_msec := 0
func capture_viewport(params: Dictionary) -> Dictionary:
var mode := str(params.get("mode", "2d"))
var width := int(params.get("width", 1280))
var height := int(params.get("height", 720))
var viewport := EditorInterface.get_editor_viewport_2d() if mode == "2d" else EditorInterface.get_editor_viewport_3d()
if viewport == null:
return _stale_fallback("No editor viewport available", width, height)
var img := viewport.get_texture().get_image() if viewport.has_method("get_texture") else null
if img == null:
return _stale_fallback("Viewport texture unavailable", width, height)
if img.get_width() != width or img.get_height() != height:
img.resize(width, height)
var png := img.save_png_to_buffer()
if png == null or png.is_empty():
return _stale_fallback("PNG encode failed", width, height)
_last_png_base64 = Marshalls.raw_to_base64(png)
_last_capture_msec = Time.get_ticks_msec()
return {
"data": {
"png_base64": _last_png_base64,
"width": width,
"height": height,
"captured_at_msec": _last_capture_msec,
"stale": false,
"mode": mode,
}
}
func frame_aabb(params: Dictionary) -> Dictionary:
var min_v: Vector3 = params.get("min", Vector3.ZERO)
var max_v: Vector3 = params.get("max", Vector3.ONE)
var fov_deg := float(params.get("fov_deg", 70.0))
var padding := float(params.get("padding", 1.2))
var center := (min_v + max_v) * 0.5
var size := (max_v - min_v).abs()
var radius := size.length() * 0.5
var distance := radius * padding / maxf(tan(deg_to_rad(fov_deg) * 0.5), 0.000001)
return {
"data": {
"center": {"x": center.x, "y": center.y, "z": center.z},
"distance": distance,
"camera_pos": {"x": center.x, "y": center.y, "z": center.z + distance},
}
}
func capture_status(_params: Dictionary) -> Dictionary:
var age := Time.get_ticks_msec() - _last_capture_msec
return {
"data": {
"has_frame": not _last_png_base64.is_empty(),
"age_msec": age,
"stale": age >= STALL_MSEC or _last_png_base64.is_empty(),
"stall_msec": STALL_MSEC,
}
}
func _stale_fallback(reason: String, width: int, height: int) -> Dictionary:
var age := Time.get_ticks_msec() - _last_capture_msec
return {
"data": {
"png_base64": _last_png_base64,
"width": width,
"height": height,
"captured_at_msec": _last_capture_msec,
"stale": true,
"age_msec": age,
"reason": reason,
}
}
+39
View File
@@ -0,0 +1,39 @@
# API — godot-opencode 1.0.0
## Bridge tools (`godot_*`)
| Tool | Bridge method | Purpose |
|---|---|---|
| `godot_editor_state` | `editor_state` | Editor state snapshot |
| `godot_scene_get_hierarchy` | `scene_get_hierarchy` | Scene hierarchy |
| `godot_node_get_properties` | `node_get_properties` | Node properties |
| `godot_session_activate` | `session_activate` | Session switch |
| `godot_capture_viewport` | `capture_viewport` | Viewport PNG (base64), stale fallback past 1000 ms |
| `godot_shader_generate` | `generate_shader` | spatial/canvas-item shader + uniforms |
| `godot_theme_apply_style` | `apply_stylebox` | StyleBoxFlat / font / color overrides |
Companion dispatch adds: `frame_aabb`, `capture_status`,
`inject_uniform`, `list_uniforms`, `inspect_theme`,
`apply_font_override`, plus the Phase ≤4 node/signal/resource set.
## TypeScript modules
- `src/vision/viewport.ts` — `frameAABB`, `isStaleFrame`,
`buildCaptureParams`, `parseCaptureResult`, `executeCaptureViewport`,
`MAIN_LOOP_STALL_MSEC = 1000`.
- `src/studio/shaders.ts` — `generateShader`, `injectUniform`,
`listUniforms`, `buildStyleBoxFlat`, `applyStyleOverride`,
`buildFontOverride`.
- `src/indexer/project.ts` — `parseTscn`, `parseProjectGodot`,
`indexProject(fs)`, `renderArchitectureMap`.
- `src/verify/production.ts` — `runProductionPipeline` (mock-friendly
E2E orchestrator; stages: `devStory, scene, script, signal, hudStyle,
gut, headless, viewport`).
- `src/studio/commands.ts` — `/setup-godot /adr /dev-story /story-done
/gate-check`.
## Status model
`ok | error | unavailable | timeout`, plus pipeline-tolerated
`unavailable-tolerated` (no binary) and `stale-tolerated` (stalled main
loop). Tolerated statuses keep CI green without an engine.
+38
View File
@@ -0,0 +1,38 @@
# Architecture — godot-opencode 1.0.0
## Layout
```text
packages/plugin/src/
bindings/ tool definitions + JSON-RPC (transform-compatible adapters)
transport/ WebSocket bridge client, handshake, circuit breaker
guards/ safety middleware, write guards, pre-commit validation
session/ session registry, context packing
studio/ agents roster, director gates, slash commands, shaders/themes
verify/ headless checker, GUT runner, scene validator, production E2E
vision/ viewport framing math + stale-aware capture parsing
indexer/ .tscn/.tres + project.godot indexing, Markdown arch map
security/ capability handshake
effect.ts Effect-wrapped plugin entry
companion/addons/godot_opencode/
plugin.gd addon lifecycle, command registration
dispatcher.gd frame-budgeted FIFO command queue
connection.gd loopback JSON-RPC server
viewport_handler.gd 2D/3D capture, AABB framing, stale fallback
handlers/ node, signal, resource, shader, theme domains
debugger/ runtime capture bridge plugin
undo_manager.gd atomic undoable writes
```
## Key decisions
- **Zero-Python**: Bun shells out to the Godot CLI; every runner has a
mock-injectable `runFn` so tests pass without an engine.
- **Graceful degradation**: missing binary → `unavailable`; stalled main
loop (>1000 ms) → `stale` frame fallback. Vision/indexing tickets
never hard-require a viewport.
- **Undoable writes**: all companion mutations route through
`UndoManager` as single editor undo actions.
- **Indexer grammar**: VariantParser subset (`gd_scene, ext_resource,
node, connection`) + `project.godot [autoload]`; renders autoload,
hierarchy, signal-graph, and script-usage tables as Markdown.
+27
View File
@@ -0,0 +1,27 @@
# Install — godot-opencode 1.0.0
## Prerequisites
- Bun ≥ 1.2, Godot 4.x (editor + CLI on `PATH` as `godot`, or set `GODOT_BINARY`).
- OpenCode v2 with plugin support. No Python anywhere in the loop.
## Steps
1. `bun install` from the repo root.
2. Copy the companion addon into your game project:
`cp -r companion/addons/godot_opencode <game>/addons/`
then enable **Project → Plugins → Godot OpenCode**.
3. Point the plugin at the bridge (defaults: port `9500`,
capability dir `~/.config/opencode/godot/capabilities`,
override with `GODOT_CAPABILITY_DIR`).
4. Verify: `bun run check` (Biome + `tsc --noEmit`) and
`bun test packages/plugin/test/` (all green without a Godot binary;
engine-backed legs degrade to `unavailable`/`stale-tolerated`).
## No-engine CI
Without a Godot binary every runner degrades gracefully instead of
throwing: headless checks report `unavailable`, GUT reports
`unavailable`, viewport captures report `stale` past
`MAIN_LOOP_STALL_MSEC` (1000 ms). The E2E production benchmark
(`packages/plugin/test/e2e-production.test.ts`) asserts this.
+27
View File
@@ -0,0 +1,27 @@
# Release notes — 1.0.0 Full-Stack Production & Vision
## Highlights (Phase 5)
- **5.1 Viewport Vision & Screenshots** — 2D + 3D/AABB-framed capture
via the debugger bridge; base64 PNG with 1000 ms stale fallback;
`godot_capture_viewport` tool; framing math unit-tested.
- **5.2 Shaders & UI Themes** — spatial/canvas-item generation with
uniform injection; Theme/StyleBoxFlat/font/color inspect + apply;
uniform-injection and style-override round-trip tests.
- **5.3 Scene Graph Indexing** — recursive `.tscn`/`.tres` parse,
Markdown architecture map (hierarchies, autoloads, signal graphs,
script tables) over an injectable filesystem.
- **5.4 E2E Production Benchmark** — mocked concept→dev-story→scene→
script→signal→HUD→GUT→headless→viewport pipeline; green without an
engine (`unavailable`/`stale` tolerated, real failures still fail).
- **5.5 Docs & Release** — INSTALL / API / ARCHITECTURE guides.
## Verification
- `bun run check`: Biome + `tsc --noEmit` clean.
- `bun test packages/plugin/test/`: 123 pass; the single combined-run
`1 fail + 1 error` (a `review/opencode` module-resolution artifact)
pre-exists on Phase-4 HEAD — every file passes in isolation and all
28 new Phase-5 tests are green, no regressions.
- No Godot binary or viewport in CI: all engine-backed legs degrade
gracefully with mock harnesses.
+15
View File
@@ -62,6 +62,21 @@ export const GODOT_TOOLS: readonly GodotToolDefinition[] = [
"Activate a Godot session by id",
"session_activate",
),
makeTool(
"godot_capture_viewport",
"Capture viewport screenshot as base64 PNG (Resource form: godot://viewport/capture; stale fallback past 1000ms)",
"capture_viewport",
),
makeTool(
"godot_shader_generate",
"Generate spatial/canvas-item shader with uniforms",
"generate_shader",
),
makeTool(
"godot_theme_apply_style",
"Inspect Theme/StyleBox overrides and apply style (font/color/StyleBoxFlat)",
"apply_stylebox",
),
];
// Transform-compatible: pass directly to ctx.tool.transform(editor => ...).
+267
View File
@@ -0,0 +1,267 @@
// Scene-graph project indexer (no Godot binary required).
// Parses the VariantParser-grammar subset of .tscn/.tres text
// ([gd_scene], [ext_resource], [node], [connection]) plus project.godot
// [autoload], then renders a Markdown architecture map. The filesystem
// is injected so tests run against an in-memory fixture.
export interface TscnExtResource {
readonly type: string;
readonly path: string;
readonly id: string;
}
export interface TscnNode {
readonly name: string;
readonly type?: string;
readonly parent?: string;
readonly instanceOf?: string;
}
export interface TscnConnection {
readonly signal: string;
readonly from: string;
readonly to: string;
readonly method: string;
}
export interface ParsedTscn {
readonly nodes: readonly TscnNode[];
readonly extResources: readonly TscnExtResource[];
readonly connections: readonly TscnConnection[];
}
export interface AutoloadEntry {
readonly name: string;
readonly path: string;
}
export interface IndexedScene {
readonly path: string;
readonly nodes: readonly TscnNode[];
readonly extResources: readonly TscnExtResource[];
readonly connections: readonly TscnConnection[];
readonly scripts: readonly string[];
}
export interface SignalEdge {
readonly scene: string;
readonly signal: string;
readonly from: string;
readonly to: string;
readonly method: string;
}
export interface ScriptEntry {
readonly path: string;
readonly usedBy: readonly string[];
}
export interface ProjectIndex {
readonly scenes: readonly IndexedScene[];
readonly autoloads: readonly AutoloadEntry[];
readonly signalGraph: readonly SignalEdge[];
readonly scripts: readonly ScriptEntry[];
}
export interface ProjectFileSystem {
readonly listFiles: () => readonly string[] | Promise<readonly string[]>;
readonly readFile: (path: string) => string | Promise<string>;
}
const KV_RE = /(\w+)="([^"]*)"/g;
function parseKv(segment: string): Record<string, string> {
const out: Record<string, string> = {};
KV_RE.lastIndex = 0;
while (true) {
const m: RegExpExecArray | null = KV_RE.exec(segment);
if (m === null) break;
out[m[1] ?? ""] = m[2] ?? "";
}
return out;
}
function parseBare(segment: string, key: string): string | undefined {
const m = new RegExp(`${key}=([^\\s\\]]+)`).exec(segment);
return m?.[1]?.replace(/^"|"$/g, "");
}
function instanceTarget(segment: string): string | undefined {
const m = /instance=ExtResource\("([^"]+)"\)/.exec(segment);
return m?.[1];
}
export function parseTscn(text: string): ParsedTscn {
if (!/\[gd_scene[^\]]*\]/.test(text)) {
throw new Error("parseTscn: missing [gd_scene] header");
}
const extResources: TscnExtResource[] = [];
const nodes: TscnNode[] = [];
const connections: TscnConnection[] = [];
const sectionRe = /\[(ext_resource|node|connection)[^\]]*\]/g;
sectionRe.lastIndex = 0;
while (true) {
const m: RegExpExecArray | null = sectionRe.exec(text);
if (m === null) break;
const kind = m[1];
const seg = m[0] ?? "";
const kv = parseKv(seg);
if (kind === "ext_resource") {
extResources.push({
type: kv.type ?? "",
path: kv.path ?? "",
id: kv.id ?? "",
});
} else if (kind === "node") {
nodes.push({
name: kv.name ?? "Unnamed",
...(kv.type ? { type: kv.type } : {}),
...(kv.parent ? { parent: kv.parent } : {}),
...(instanceTarget(seg) ? { instanceOf: instanceTarget(seg) } : {}),
...(parseBare(seg, "instance") && !instanceTarget(seg)
? { instanceOf: parseBare(seg, "instance") }
: {}),
});
} else {
connections.push({
signal: kv.signal ?? "",
from: kv.from ?? "",
to: kv.to ?? "",
method: kv.method ?? "",
});
}
}
return { nodes, extResources, connections };
}
export function parseProjectGodot(text: string): AutoloadEntry[] {
const out: AutoloadEntry[] = [];
const lines = text.split(/\r?\n/);
let inAutoload = false;
for (const raw of lines) {
const line = raw.trim();
if (/^\[.*\]$/.test(line)) {
inAutoload = line.toLowerCase() === "[autoload]";
continue;
}
if (!inAutoload || !line || line.startsWith(";")) continue;
const m = /^([A-Za-z_][\w]*)\s*=\s*"(\*?)([^"]+)"\s*$/.exec(line);
if (m)
out.push({ name: m[1] ?? "", path: (m[3] ?? "").replace(/^\*/, "") });
}
return out;
}
function isSceneFile(p: string): boolean {
return p.endsWith(".tscn") || p.endsWith(".tres") || p.endsWith(".scn");
}
export async function indexProject(
fs: ProjectFileSystem,
): Promise<ProjectIndex> {
const files = await fs.listFiles();
const projectFile = files.find((f) => f.endsWith("project.godot"));
let autoloads: AutoloadEntry[] = [];
if (projectFile) {
try {
autoloads = parseProjectGodot(await fs.readFile(projectFile));
} catch {
autoloads = [];
}
}
const scenes: IndexedScene[] = [];
for (const path of files.filter(isSceneFile)) {
let text: string;
try {
text = await fs.readFile(path);
} catch {
continue;
}
let parsed: ParsedTscn;
try {
parsed = parseTscn(text);
} catch {
continue;
}
const idToPath = new Map(parsed.extResources.map((e) => [e.id, e.path]));
const scripts = parsed.extResources
.filter((e) => e.path.endsWith(".gd"))
.map((e) => e.path);
scenes.push({
path,
nodes: parsed.nodes.map((n) => {
if (n.instanceOf && idToPath.has(n.instanceOf)) {
return { ...n, instanceOf: idToPath.get(n.instanceOf) };
}
return n;
}),
extResources: parsed.extResources,
connections: parsed.connections,
scripts,
});
}
const signalGraph: SignalEdge[] = scenes.flatMap((s) =>
s.connections.map((c) => ({
scene: s.path,
signal: c.signal,
from: c.from,
to: c.to,
method: c.method,
})),
);
const byScript = new Map<string, Set<string>>();
for (const s of scenes) {
for (const e of s.extResources) {
if (!e.path.endsWith(".gd")) continue;
if (!byScript.has(e.path)) byScript.set(e.path, new Set());
byScript.get(e.path)?.add(s.path);
}
}
const scripts: ScriptEntry[] = [...byScript.entries()].map(([path, set]) => ({
path,
usedBy: [...set].sort(),
}));
return { scenes, autoloads, signalGraph, scripts };
}
function nodeLabel(n: TscnNode): string {
const type = n.type ? ` (${n.type})` : "";
const inst = n.instanceOf ? ` -> ${n.instanceOf}` : "";
const parent = n.parent ? ` [parent: ${n.parent}]` : "";
return `- ${n.name}${type}${inst}${parent}`;
}
export function renderArchitectureMap(index: ProjectIndex): string {
const lines: string[] = ["# Architecture Map", ""];
lines.push("## Autoloads", "");
if (index.autoloads.length === 0) lines.push("_none_");
for (const a of index.autoloads)
lines.push(`- **${a.name}** — \`${a.path}\``);
lines.push("", "## Scenes", "");
if (index.scenes.length === 0) lines.push("_none_");
for (const s of index.scenes) {
lines.push(`### \`${s.path}\``, "");
lines.push("#### Hierarchy", "");
if (s.nodes.length === 0) lines.push("_no nodes_");
for (const n of s.nodes) lines.push(nodeLabel(n));
lines.push("", "#### Ext Resources", "");
if (s.extResources.length === 0) lines.push("_none_");
for (const e of s.extResources)
lines.push(`- [${e.type}] \`${e.path}\` (id=${e.id})`);
lines.push("");
}
lines.push("## Signal Graph", "");
if (index.signalGraph.length === 0) lines.push("_no connections_");
for (const g of index.signalGraph)
lines.push(
`- \`${g.scene}\`: \`${g.signal}\` \`${g.from}\` → \`${g.to}\` :: \`${g.method}\``,
);
lines.push("", "## Scripts", "");
if (index.scripts.length === 0) lines.push("_none_");
for (const s of index.scripts)
lines.push(
`- \`${s.path}\` — used by: ${s.usedBy.map((u) => `\`${u}\``).join(", ")}`,
);
lines.push("");
return lines.join("\n");
}
+152
View File
@@ -0,0 +1,152 @@
// Shaders & UI themes (no Godot binary required).
// GDScript-side handlers (companion shader_handler.gd / theme_handler.gd)
// own runtime application; this module owns template generation, uniform
// injection, and style-override descriptors so tests run headless.
export type ShaderMode = "spatial" | "canvas_item";
export interface ShaderUniform {
readonly name: string;
readonly type: string;
readonly hint?: string;
readonly defaultValue?: string;
}
export interface GenerateShaderOptions {
readonly uniforms?: readonly ShaderUniform[];
readonly body?: string;
}
const FRAGMENT_DEFAULTS: Record<ShaderMode, string> = {
spatial: "void fragment() {\n\tALBEDO = albedo_color.rgb;\n}",
canvas_item: "void fragment() {\n\tCOLOR = tint * texture(TEXTURE, UV);\n}",
};
function uniformDecl(u: ShaderUniform): string {
const hint = u.hint ? ` : ${u.hint}` : "";
const def = u.defaultValue ? ` = ${u.defaultValue}` : "";
return `uniform ${u.type} ${u.name}${hint}${def};`;
}
export function generateShader(
mode: ShaderMode,
options: GenerateShaderOptions = {},
): string {
const uniforms = (options.uniforms ?? []).map(uniformDecl).join("\n");
const body = options.body ?? FRAGMENT_DEFAULTS[mode];
const defaults =
mode === "spatial"
? "uniform vec4 albedo_color : source_color = vec4(1.0);"
: "uniform vec4 tint : source_color = vec4(1.0);";
const uniformBlock =
options.uniforms === undefined
? defaults
: options.uniforms.length > 0
? uniforms
: "";
const parts = [`shader_type ${mode};`];
if (uniformBlock) parts.push(uniformBlock);
parts.push(body);
return `${parts.join("\n")}\n`;
}
const UNIFORM_RE =
/uniform\s+([A-Za-z_][\w]*)\s+([A-Za-z_][\w]*)(?:\s*:\s*([^=;]+))?(?:\s*=\s*([^;]+))?;/g;
export function listUniforms(src: string): ShaderUniform[] {
const out: ShaderUniform[] = [];
UNIFORM_RE.lastIndex = 0;
while (true) {
const m: RegExpExecArray | null = UNIFORM_RE.exec(src);
if (m === null) break;
const u: ShaderUniform = {
name: m[2] ?? "",
type: m[1] ?? "",
...(m[3]?.trim() ? { hint: m[3].trim() } : {}),
...(m[4]?.trim() ? { defaultValue: m[4].trim() } : {}),
};
if (u.name) out.push(u);
}
return out;
}
/** Insert (or replace) a uniform declaration after the shader_type line. */
export function injectUniform(src: string, uniform: ShaderUniform): string {
const decl = uniformDecl(uniform);
const lines = src.split("\n");
const filtered = lines.filter(
(l) => !new RegExp(`uniform\\s+\\S+\\s+${uniform.name}\\b`).test(l),
);
const anchor = filtered.findIndex((l) => l.includes("shader_type"));
if (anchor === -1) return [decl, ...filtered].join("\n");
filtered.splice(anchor + 1, 0, decl);
return filtered.join("\n");
}
export interface StyleBoxFlatDescriptor {
readonly bg_color: string;
readonly corner_radius: number;
readonly border_width: number;
readonly border_color: string;
readonly content_margin: number;
}
export interface StyleBoxInput {
readonly bgColor?: string;
readonly cornerRadius?: number;
readonly borderWidth?: number;
readonly borderColor?: string;
readonly contentMargin?: number;
}
export function buildStyleBoxFlat(
input: StyleBoxInput = {},
): StyleBoxFlatDescriptor {
return {
bg_color: input.bgColor ?? "#1a1d29",
corner_radius: input.cornerRadius ?? 8,
border_width: input.borderWidth ?? 0,
border_color: input.borderColor ?? "#00000000",
content_margin: input.contentMargin ?? 12,
};
}
export type StyleOverrideInput = Partial<StyleBoxInput> & {
readonly bg_color?: string;
readonly corner_radius?: number;
readonly border_width?: number;
readonly border_color?: string;
readonly content_margin?: number;
};
/** Immutable merge: snake_case GDScript keys or camelCase TS keys win. */
export function applyStyleOverride(
base: StyleBoxFlatDescriptor,
override: StyleOverrideInput,
): StyleBoxFlatDescriptor {
return {
bg_color: override.bg_color ?? override.bgColor ?? base.bg_color,
corner_radius:
override.corner_radius ?? override.cornerRadius ?? base.corner_radius,
border_width:
override.border_width ?? override.borderWidth ?? base.border_width,
border_color:
override.border_color ?? override.borderColor ?? base.border_color,
content_margin:
override.content_margin ?? override.contentMargin ?? base.content_margin,
};
}
export interface FontOverride {
readonly fontPath?: string;
readonly fontSize?: number;
readonly fontColor?: string;
}
export function buildFontOverride(input: FontOverride = {}): FontOverride {
return {
...(input.fontPath ? { fontPath: input.fontPath } : {}),
...(input.fontSize ? { fontSize: input.fontSize } : {}),
...(input.fontColor ? { fontColor: input.fontColor } : {}),
};
}
+216
View File
@@ -0,0 +1,216 @@
// E2E production benchmark orchestrator (no engine required).
// Chains the /dev-story pipeline (mocked) with scene validation,
// typed GDScript checks, signal wiring, HUD styling, GUT + headless
// verification, viewport capture, and the architecture map. Engine-
// absent legs degrade to tolerated statuses instead of failing.
import { indexProject, renderArchitectureMap } from "../indexer/project";
import { runDevStory } from "../studio/commands";
import {
applyStyleOverride,
buildStyleBoxFlat,
generateShader,
injectUniform,
} from "../studio/shaders";
import { parseCaptureResult } from "../vision/viewport";
import { GutRunner } from "./gut";
import { HeadlessRunner } from "./headless";
import { validateSceneText } from "./scene";
export type ProductionStageStatus =
| "pass"
| "fail"
| "unavailable-tolerated"
| "stale-tolerated";
export interface ProductionPipelineInput {
readonly brief: string;
readonly gutTAP: string;
readonly headlessAvailable: boolean;
readonly viewportStale: boolean;
readonly headlessErrorOutput?: string;
}
export interface ProductionPipelineResult {
readonly ok: boolean;
readonly stages: Record<string, ProductionStageStatus>;
readonly architectureMap: string;
readonly details: Record<string, unknown>;
}
const PLAYER_TSCN = `[gd_scene load_steps=3 format=3 uid="uid://bplayer"]
[ext_resource type="Script" path="res://scripts/player.gd" id="1"]
[ext_resource type="PackedScene" path="res://scenes/hud.tscn" id="2"]
[node name="Player" type="CharacterBody2D"]
script = ExtResource("1")
[node name="Sprite" type="Sprite2D" parent="."]
[node name="HUD" parent="." instance=ExtResource("2")]
[connection signal="health_changed" from="." to="HUD" method="_on_health_changed"]
`;
const PLAYER_GD = `extends CharacterBody2D
## Survivor player: typed movement + health signal.
signal health_changed(value: int)
@export var speed: float = 220.0
var health: int = 100
func _physics_process(delta: float) -> void:
\tvar dir := Input.get_vector("left", "right", "up", "down")
\tvelocity = dir * speed
\tmove_and_slide()
func take_damage(amount: int) -> void:
\thealth = maxi(health - amount, 0)
\thealth_changed.emit(health)
`;
function checkTypedScript(src: string): { ok: boolean; notes: string[] } {
const notes: string[] = [];
if (!/^extends\s+CharacterBody2D/m.test(src))
notes.push("missing extends CharacterBody2D");
if (!/signal\s+health_changed/.test(src))
notes.push("missing health_changed signal");
if (!/@export\s+var\s+speed:\s*float/.test(src))
notes.push("missing typed @export speed");
if (!/func\s+take_damage\(amount:\s*int\)\s*->\s*void/.test(src))
notes.push("missing typed take_damage");
return { ok: notes.length === 0, notes };
}
export async function runProductionPipeline(
input: ProductionPipelineInput,
): Promise<ProductionPipelineResult> {
const stages: Record<string, ProductionStageStatus> = {};
const details: Record<string, unknown> = {};
// 1. Concept brief → /dev-story pipeline (mocked deps, always green).
const story = await runDevStory(
{ storyId: "S-prod", prompt: input.brief },
{
loadStory: async (id: string) => ({ id, brief: input.brief }),
spawnSpecialist: async (s: unknown) => s,
runGut: async () => ({ passed: true }),
runHeadlessCheck: async () => ({ passed: true }),
commit: async () => ({ sha: "mock-prod" }),
},
);
stages.devStory = story.ok ? "pass" : "fail";
details.devStory = story.summary;
// 2. Character scene validates clean.
const sceneRes = validateSceneText(PLAYER_TSCN, {
knownMethods: ["_on_health_changed"],
});
stages.scene = sceneRes.status === "ok" ? "pass" : "fail";
details.scene = sceneRes.issues;
// 3. Typed GDScript static checks.
const scriptCheck = checkTypedScript(PLAYER_GD);
stages.script = scriptCheck.ok ? "pass" : "fail";
details.script = scriptCheck.notes;
// 4. Signal wiring present (health_changed → HUD).
const hasSignal = sceneRes.connections.some(
(c) => c.signal === "health_changed" && c.method === "_on_health_changed",
);
stages.signal = hasSignal ? "pass" : "fail";
// 5. HUD style: shader uniform injection + StyleBox round-trip.
let hudOk = false;
try {
const base = generateShader("canvas_item");
const injected = injectUniform(base, { name: "tint", type: "vec4" });
const styled = applyStyleOverride(buildStyleBoxFlat({}), {
cornerRadius: 12,
});
hudOk =
injected.includes("uniform vec4 tint") && styled.corner_radius === 12;
} catch {
hudOk = false;
}
stages.hudStyle = hudOk ? "pass" : "fail";
// 6. GUT suite (mocked TAP).
const gut = new GutRunner({
runFn: async () => ({ exitCode: 0, stdout: input.gutTAP, stderr: "" }),
});
const gutRes = await gut.run();
stages.gut = gutRes.status === "pass" ? "pass" : "fail";
details.gut = {
total: gutRes.total,
pass: gutRes.pass,
fail: gutRes.fail,
};
// 7. Headless verify: unavailable-tolerant without engine.
if (!input.headlessAvailable) {
const runner = new HeadlessRunner({
runFn: async () => {
const err = new Error("spawn godot ENOENT") as Error & {
code?: string;
};
err.code = "ENOENT";
throw err;
},
});
const res = await runner.checkScript("res://scripts/player.gd");
stages.headless =
res.status === "unavailable" ? "unavailable-tolerated" : "fail";
details.headless = res.status;
} else {
const out = input.headlessErrorOutput ?? "";
const runner = new HeadlessRunner({
runFn: async () => ({
exitCode: out ? 1 : 0,
stdout: out,
stderr: "",
}),
});
const res = await runner.checkScript("res://scripts/player.gd");
stages.headless = res.status === "ok" ? "pass" : "fail";
details.headless = res.status;
}
// 8. Viewport capture: stale-tolerant without a live main loop.
const now = 100_000;
const capture = parseCaptureResult(
{
pngBase64: "aGVsbG8=",
width: 1280,
height: 720,
capturedAtMs: input.viewportStale ? now - 5000 : now - 100,
},
now,
);
stages.viewport =
capture.status === "ok"
? "pass"
: capture.status === "stale"
? "stale-tolerated"
: "fail";
details.viewport = capture.status;
// 9. Architecture map over the benchmark fixture.
const idx = await indexProject({
listFiles: () => ["scenes/player.tscn", "scenes/hud.tscn"],
readFile: (p: string) =>
p === "scenes/player.tscn"
? PLAYER_TSCN
: `[gd_scene load_steps=1 format=3]\n\n[node name="HUD" type="CanvasLayer"]\n`,
});
const architectureMap = renderArchitectureMap(idx);
details.architectureMapBytes = architectureMap.length;
const ok = Object.values(stages).every(
(s) =>
s === "pass" || s === "unavailable-tolerated" || s === "stale-tolerated",
);
return { ok, stages, architectureMap, details };
}
+197
View File
@@ -0,0 +1,197 @@
// Viewport vision & screenshots (no Godot binary required).
// Pure framing math + stale-fallback detection. Runtime capture goes
// through the debugger bridge (companion viewport_handler.gd); when the
// main loop stalls, frames older than MAIN_LOOP_STALL_MSEC degrade to a
// `stale` result instead of blocking the pipeline.
export const MAIN_LOOP_STALL_MSEC = 1000;
export interface Vec3 {
readonly x: number;
readonly y: number;
readonly z: number;
}
export interface AABB {
readonly min: Vec3;
readonly max: Vec3;
}
export interface FramingResult {
readonly center: Vec3;
readonly size: Vec3;
readonly radius: number;
readonly distance: number;
readonly cameraPos: Vec3;
}
export type ViewportMode = "2d" | "3d";
export interface CaptureParams {
readonly mode: ViewportMode;
readonly width: number;
readonly height: number;
readonly framing?: FramingResult;
readonly aabb?: AABB;
}
export interface CaptureParamsInput {
readonly mode: ViewportMode;
readonly width?: number;
readonly height?: number;
readonly aabb?: AABB;
readonly fovDeg?: number;
readonly padding?: number;
}
export type CaptureStatus = "ok" | "stale" | "unavailable";
export interface RawCapture {
readonly pngBase64?: string;
readonly width?: number;
readonly height?: number;
readonly capturedAtMs?: number;
}
export interface CaptureResult {
readonly status: CaptureStatus;
readonly pngBase64: string;
readonly width: number;
readonly height: number;
readonly stale: boolean;
readonly capturedAtMs: number;
readonly reason?: string;
}
/** Half-diagonal framing: distance so the bounding sphere fits the FOV. */
export function frameAABB(
aabb: AABB,
fovDeg = 70,
padding = 1.2,
): FramingResult {
const center: Vec3 = {
x: (aabb.min.x + aabb.max.x) / 2,
y: (aabb.min.y + aabb.max.y) / 2,
z: (aabb.min.z + aabb.max.z) / 2,
};
const size: Vec3 = {
x: Math.abs(aabb.max.x - aabb.min.x),
y: Math.abs(aabb.max.y - aabb.min.y),
z: Math.abs(aabb.max.z - aabb.min.z),
};
const radius =
Math.sqrt(size.x * size.x + size.y * size.y + size.z * size.z) / 2;
const halfFovRad = ((fovDeg > 0 ? fovDeg : 70) * Math.PI) / 360;
const tan = Math.tan(halfFovRad);
const safeTan = tan > 1e-6 ? tan : 1e-6;
const distance = (radius * (padding > 0 ? padding : 1.0)) / safeTan;
return {
center,
size,
radius,
distance,
cameraPos: { x: center.x, y: center.y, z: center.z + distance },
};
}
export function isStaleFrame(
capturedAtMs: number,
nowMs: number,
stallMs: number = MAIN_LOOP_STALL_MSEC,
): boolean {
return nowMs - capturedAtMs >= stallMs;
}
export function buildCaptureParams(input: CaptureParamsInput): CaptureParams {
const width = input.width ?? 1280;
const height = input.height ?? 720;
if (input.mode === "3d" && input.aabb) {
return {
mode: "3d",
width,
height,
aabb: input.aabb,
framing: frameAABB(input.aabb, input.fovDeg ?? 70, input.padding ?? 1.2),
};
}
return { mode: input.mode, width, height };
}
export function parseCaptureResult(
raw: RawCapture,
nowMs: number,
): CaptureResult {
const capturedAtMs = raw.capturedAtMs ?? nowMs;
if (!raw.pngBase64 || raw.pngBase64.length === 0) {
return {
status: "unavailable",
pngBase64: "",
width: raw.width ?? 0,
height: raw.height ?? 0,
stale: true,
capturedAtMs,
reason: "viewport capture unavailable (no frame from debugger bridge)",
};
}
const stale = isStaleFrame(capturedAtMs, nowMs);
return {
status: stale ? "stale" : "ok",
pngBase64: raw.pngBase64,
width: raw.width ?? 0,
height: raw.height ?? 0,
stale,
capturedAtMs,
...(stale
? { reason: `stale frame: age exceeded ${MAIN_LOOP_STALL_MSEC}ms` }
: {}),
};
}
export interface ViewportToolParams {
readonly mode?: string;
readonly width?: number;
readonly height?: number;
}
/** Bridge adapter for the `godot_capture_viewport` tool. */
export async function executeCaptureViewport(
params: ViewportToolParams,
send: (method: string, p: unknown) => Promise<unknown>,
nowMs: number = Date.now(),
): Promise<CaptureResult> {
const mode: ViewportMode = params.mode === "3d" ? "3d" : "2d";
const built = buildCaptureParams({
mode,
width: params.width,
height: params.height,
});
let raw: RawCapture;
try {
const res = (await send("capture_viewport", {
mode: built.mode,
width: built.width,
height: built.height,
})) as Record<string, unknown>;
raw = {
...(typeof res.pngBase64 === "string"
? { pngBase64: res.pngBase64 }
: {}),
...(typeof res.width === "number" ? { width: res.width } : {}),
...(typeof res.height === "number" ? { height: res.height } : {}),
...(typeof res.capturedAtMs === "number"
? { capturedAtMs: res.capturedAtMs }
: {}),
};
} catch (err) {
return {
status: "unavailable",
pngBase64: "",
width: 0,
height: 0,
stale: true,
capturedAtMs: nowMs,
reason: err instanceof Error ? err.message : "capture bridge failed",
};
}
return parseCaptureResult(raw, nowMs);
}
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import { runProductionPipeline } from "../src/verify/production";
const TAP_PASS = `TAP version 13
ok 1 test_player_moves
ok 2 test_hud_updates
1..2
`;
describe("e2e production benchmark (mocked, no engine)", () => {
test("full pipeline green with mocks, engine-unavailable tolerant", async () => {
const result = await runProductionPipeline({
brief: "Top-down survivor: player CharacterBody2D + HUD health label",
gutTAP: TAP_PASS,
// No binary: headless degrades to unavailable, viewport to stale.
headlessAvailable: false,
viewportStale: true,
});
expect(result.stages.devStory).toBe("pass");
expect(result.stages.scene).toBe("pass");
expect(result.stages.script).toBe("pass");
expect(result.stages.signal).toBe("pass");
expect(result.stages.hudStyle).toBe("pass");
expect(result.stages.gut).toBe("pass");
// Degraded but tolerated — never fail the pipeline without an engine.
expect(result.stages.headless).toBe("unavailable-tolerated");
expect(result.stages.viewport).toBe("stale-tolerated");
expect(result.ok).toBe(true);
expect(result.architectureMap).toContain("# Architecture Map");
});
test("gut failure fails the pipeline", async () => {
const result = await runProductionPipeline({
brief: "concept",
gutTAP: "not ok 1 test_broken\n",
headlessAvailable: true,
viewportStale: false,
});
expect(result.ok).toBe(false);
expect(result.stages.gut).toBe("fail");
});
test("headless errors fail when engine present", async () => {
const result = await runProductionPipeline({
brief: "concept",
gutTAP: TAP_PASS,
headlessAvailable: true,
headlessErrorOutput:
"SCRIPT ERROR: Parse Error\nat: GDScript::reload (res://scripts/player.gd:3:1)",
viewportStale: false,
});
expect(result.stages.headless).toBe("fail");
expect(result.ok).toBe(false);
});
});
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test";
import {
indexProject,
parseProjectGodot,
parseTscn,
renderArchitectureMap,
} from "../src/indexer/project";
const PLAYER_TSCN = `[gd_scene load_steps=3 format=3 uid="uid://bplayer"]
[ext_resource type="Script" path="res://scripts/player.gd" id="1"]
[ext_resource type="PackedScene" path="res://scenes/hud.tscn" id="2"]
[node name="Player" type="CharacterBody2D"]
script = ExtResource("1")
[node name="Sprite" type="Sprite2D" parent="."]
texture_filter = 1
[node name="HUD" parent="." instance=ExtResource("2")]
[connection signal="health_changed" from="." to="HUD" method="_on_health_changed"]
[connection signal="died" from="." to="." method="_on_died"]
`;
const HUD_TSCN = `[gd_scene load_steps=2 format=3 uid="uid://bhud"]
[ext_resource type="Script" path="res://scripts/hud.gd" id="1"]
[node name="HUD" type="CanvasLayer"]
script = ExtResource("1")
[node name="Label" type="Label" parent="."]
`;
const PROJECT_GODOT = `; Engine configuration file.
config_version=5
[application]
config/name="Fixture Game"
[autoload]
GameState="*res://scripts/game_state.gd"
Audio="*res://scripts/audio.gd"
[rendering]
renderer/rendering_method="gl_compatibility"
`;
const HUD_GD = `extends CanvasLayer
func _on_health_changed(value: int) -> void:
\tpass
`;
describe("tscn parser (VariantParser-grammar subset)", () => {
test("parses nodes, ext_resources, connections", () => {
const parsed = parseTscn(PLAYER_TSCN);
expect(parsed.nodes.map((n) => n.name)).toEqual([
"Player",
"Sprite",
"HUD",
]);
expect(parsed.extResources.map((e) => e.path)).toContain(
"res://scripts/player.gd",
);
expect(parsed.connections).toHaveLength(2);
expect(parsed.connections[0]?.signal).toBe("health_changed");
});
test("malformed scene without header throws structured error", () => {
expect(() => parseTscn("garbage")).toThrow(/gd_scene/);
});
});
describe("project.godot autoload parsing", () => {
test("extracts autoload singletons", () => {
const loads = parseProjectGodot(PROJECT_GODOT);
expect(loads.map((a) => a.name)).toEqual(["GameState", "Audio"]);
expect(loads[0]?.path).toBe("res://scripts/game_state.gd");
});
});
describe("indexProject fixture", () => {
const files: Record<string, string> = {
"project.godot": PROJECT_GODOT,
"scenes/player.tscn": PLAYER_TSCN,
"scenes/hud.tscn": HUD_TSCN,
"scripts/hud.gd": HUD_GD,
};
const fs = {
listFiles: () => Object.keys(files),
readFile: (p: string) => {
const v = files[p];
if (v === undefined) throw new Error(`missing file: ${p}`);
return v;
},
};
test("indexes scenes, autoloads, signal graph, script table", async () => {
const idx = await indexProject(fs);
expect(idx.scenes.map((s) => s.path).sort()).toEqual([
"scenes/hud.tscn",
"scenes/player.tscn",
]);
expect(idx.autoloads.map((a) => a.name).sort()).toEqual([
"Audio",
"GameState",
]);
expect(idx.signalGraph.length).toBeGreaterThanOrEqual(2);
expect(idx.scripts.map((s) => s.path)).toContain("res://scripts/player.gd");
});
test("renders Markdown architecture map", async () => {
const idx = await indexProject(fs);
const md = renderArchitectureMap(idx);
expect(md).toContain("# Architecture Map");
expect(md).toContain("## Autoloads");
expect(md).toContain("## Scenes");
expect(md).toContain("## Signal Graph");
expect(md).toContain("## Scripts");
expect(md).toContain("Player");
expect(md).toContain("health_changed");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, test } from "bun:test";
import {
applyStyleOverride,
buildStyleBoxFlat,
generateShader,
injectUniform,
listUniforms,
} from "../src/studio/shaders";
describe("shader generation", () => {
test("spatial template has shader_type + uniforms", () => {
const src = generateShader("spatial", {
uniforms: [{ name: "albedo_color", type: "vec4" }],
});
expect(src).toContain("shader_type spatial");
expect(src).toContain("uniform vec4 albedo_color");
});
test("canvas_item template", () => {
const src = generateShader("canvas_item");
expect(src).toContain("shader_type canvas_item");
});
});
describe("uniform injection", () => {
test("injects uniform after shader_type line", () => {
const base =
"shader_type spatial;\nvoid fragment() { ALBEDO = vec3(1.0); }";
const out = injectUniform(base, { name: "pulse_speed", type: "float" });
expect(out).toContain("uniform float pulse_speed");
const lines = out.split("\n");
expect(lines[0]).toContain("shader_type");
expect(lines[1]).toContain("uniform float pulse_speed");
});
test("inject with hint and default", () => {
const base = "shader_type canvas_item;";
const out = injectUniform(base, {
name: "tint",
type: "vec4",
hint: "source_color",
defaultValue: "vec4(1.0)",
});
expect(out).toContain("uniform vec4 tint");
expect(out).toContain("source_color");
expect(out).toContain("vec4(1.0)");
});
test("duplicate uniform is replaced, not doubled", () => {
const base = "shader_type spatial;\nuniform float speed = 1.0;";
const out = injectUniform(base, {
name: "speed",
type: "float",
defaultValue: "2.0",
});
const count = out
.split("\n")
.filter((l) => l.includes("uniform float speed")).length;
expect(count).toBe(1);
expect(out).toContain("2.0");
});
test("listUniforms round-trips injected uniforms", () => {
const base = generateShader("spatial", {
uniforms: [
{ name: "a", type: "float" },
{ name: "b", type: "vec4" },
],
});
const names = listUniforms(base).map((u) => u.name);
expect(names).toContain("a");
expect(names).toContain("b");
});
});
describe("theme style overrides", () => {
test("buildStyleBoxFlat defaults", () => {
const sb = buildStyleBoxFlat({ bgColor: "#1a1d29" });
expect(sb.bg_color).toBe("#1a1d29");
expect(typeof sb.corner_radius).toBe("number");
});
test("style override round-trip merges", () => {
const base = buildStyleBoxFlat({ bgColor: "#111111", cornerRadius: 4 });
const merged = applyStyleOverride(base, { cornerRadius: 12 });
expect(merged.bg_color).toBe("#111111");
expect(merged.corner_radius).toBe(12);
// base untouched (immutable merge)
expect(base.corner_radius).toBe(4);
});
});
+113
View File
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test";
import {
MAIN_LOOP_STALL_MSEC,
buildCaptureParams,
frameAABB,
isStaleFrame,
parseCaptureResult,
} from "../src/vision/viewport";
describe("viewport framing math", () => {
test("frames unit cube centered at origin", () => {
const r = frameAABB(
{ min: { x: -1, y: -1, z: -1 }, max: { x: 1, y: 1, z: 1 } },
90,
1.0,
);
expect(r.center.x).toBeCloseTo(0, 9);
expect(r.center.y).toBeCloseTo(0, 9);
expect(r.center.z).toBeCloseTo(0, 9);
// half-extent 1, fov 90 -> tan(45)=1 -> distance ~= radius(√3≈1.732)
expect(r.distance).toBeGreaterThan(1.7);
expect(r.distance).toBeLessThan(1.8);
// camera looks down -z from +z
expect(r.cameraPos.z).toBeCloseTo(r.center.z + r.distance, 9);
});
test("offset box shifts center", () => {
const r = frameAABB(
{ min: { x: 10, y: 0, z: 0 }, max: { x: 12, y: 2, z: 2 } },
70,
1.0,
);
expect(r.center.x).toBeCloseTo(11, 9);
expect(r.center.y).toBeCloseTo(1, 9);
expect(r.center.z).toBeCloseTo(1, 9);
});
test("padding scales distance linearly", () => {
const aabb = {
min: { x: 0, y: 0, z: 0 },
max: { x: 2, y: 2, z: 2 },
};
const a = frameAABB(aabb, 60, 1.0);
const b = frameAABB(aabb, 60, 2.0);
expect(b.distance).toBeCloseTo(a.distance * 2, 9);
});
});
describe("stale frame detection", () => {
test("stall threshold constant is 1000ms", () => {
expect(MAIN_LOOP_STALL_MSEC).toBe(1000);
});
test("fresh frame is not stale", () => {
expect(isStaleFrame(9000, 9500)).toBe(false);
});
test("frame older than stall window is stale", () => {
expect(isStaleFrame(8000, 9500)).toBe(true);
});
test("boundary is stale (>=)", () => {
expect(isStaleFrame(8500, 9500)).toBe(true);
});
});
describe("capture result parsing", () => {
test("fresh base64 PNG parses ok, not stale", () => {
const r = parseCaptureResult(
{ pngBase64: "aGVsbG8=", width: 1280, height: 720, capturedAtMs: 9500 },
9600,
);
expect(r.stale).toBe(false);
expect(r.pngBase64).toBe("aGVsbG8=");
expect(r.status).toBe("ok");
});
test("stale fallback: old timestamp marks stale", () => {
const r = parseCaptureResult(
{ pngBase64: "aGVsbG8=", width: 640, height: 480, capturedAtMs: 1000 },
9500,
);
expect(r.stale).toBe(true);
expect(r.status).toBe("stale");
});
test("missing png degrades to unavailable, not throw", () => {
const r = parseCaptureResult({ capturedAtMs: 9500 }, 9600);
expect(r.status).toBe("unavailable");
expect(r.pngBase64).toBe("");
});
});
describe("capture params builder", () => {
test("2d defaults", () => {
const p = buildCaptureParams({ mode: "2d" });
expect(p.mode).toBe("2d");
expect(p.width).toBeGreaterThan(0);
});
test("3d with aabb includes framing", () => {
const p = buildCaptureParams({
mode: "3d",
aabb: {
min: { x: 0, y: 0, z: 0 },
max: { x: 1, y: 1, z: 1 },
},
});
expect(p.mode).toBe("3d");
expect(p.framing).toBeDefined();
expect(p.framing?.center.x).toBeCloseTo(0.5, 9);
});
});