Skip to content

Commit 4b60b68

Browse files
committed
feat: Implement viewport capture and input handling improvements
- Added Capture module for taking screenshots using CaptureService, ensuring it works during playtests and handles rendering checks. - Introduced RenderMonitor to track the rendering state of the Studio window, preventing silent failures in input processing and screenshot capturing. - Enhanced InputBridge to utilize UserInputService for synthetic input, replacing the deprecated VirtualInputManager. - Implemented batch operation handling in Commands, allowing multiple edits to be applied under a single undo waypoint. - Added new capabilities for multiplayer testing and screenshot functionality in the Commands module. - Created PNG encoding utility in the server package to handle image compression for screenshots.
1 parent 3faace6 commit 4b60b68

29 files changed

Lines changed: 2788 additions & 553 deletions

‎AGENTS.md‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,35 @@ connection for — and rides it up on the next sync. `SessionRegistry.applyPlace
9494
accepts a redescription only when the identity matches, so a session can never
9595
change which game it stands for without reconnecting.
9696

97+
## Playtesting, input, and capture
98+
99+
All three go through a structured Studio API, and each replaced something that
100+
either drove the user's desktop or silently did nothing. Do not reintroduce
101+
either kind.
102+
103+
**Playtests are `StudioTestService`.** `ExecutePlayModeAsync`,
104+
`ExecuteRunModeAsync`, `ExecuteMultiplayerTestAsync`, and `EndTest` are the whole
105+
surface. Two properties of them shape everything above:
106+
107+
- The `Execute*` calls **yield for the entire life of the session**. The thread
108+
that starts a playtest is parked until it ends, so `RunOps` spawns the call and
109+
the run state is what reports the outcome.
110+
- **Starting and stopping are different peers.** Only the edit DataModel can
111+
start a session; only the running one can end it. `RunControl` picks the peer
112+
per operation through `SessionRegistry.findPeer`, which is also why
113+
`sessions.send` takes a `peer`. A request aimed at a named peer never rebinds
114+
the chat, because a playtest's connection is transient and following a chat
115+
into one would strand it the moment the playtest ended.
116+
117+
**Input is `UserInputService:CreateVirtualInput()`.** It feeds the engine's real
118+
input pipeline, so the character walks with the game's own controls and clicks
119+
hit-test against the GUI. Capability is probed by *constructing* the object.
120+
121+
**Capture is `CaptureService` plus `EditableImage`**, read in the plugin and
122+
encoded to PNG by `core/png.ts`, because Luau has no deflate. The desktop paths
123+
in `native/screenshot.ts` remain for `source: "window"` and `"screen"` only, and
124+
nothing falls back to them silently — the two pictures are not the same picture.
125+
97126
## Adding an operation
98127

99128
Operations are defined once and flow outward.
@@ -233,6 +262,23 @@ These are all real, and all cost time when hit blind.
233262
exactly this reason. It must be a real executable: the SDK spawns it with no
234263
shell and, on Windows, no PATHEXT lookup, so a bare `claude` never resolves and
235264
an npm `claude.cmd` fails with `spawn EINVAL`. See `agents/claudeExecutable.ts`.
265+
- **`VirtualInputManager` cannot be called from a plugin, and fails in the worst
266+
possible way.** Every one of its `Send*Event` methods is RobloxScriptSecurity,
267+
so a plugin raises on each. The service itself is fetchable, which is what made
268+
this cost months: `isAvailable` asked whether `GetService` worked, said yes,
269+
and Luu Code advertised `input.virtual`, accepted every key and click, and
270+
delivered none of them. `UserInputService:CreateVirtualInput()` is the callable
271+
path. Probe any capability by doing the thing, never by naming the API.
272+
- **A minimized Studio window keeps running scripts but stops rendering *and*
273+
processing input.** Heartbeat carries on at full rate, so it is useless as a
274+
signal; `RenderStepped` is what stops. `RenderMonitor` watches frame freshness
275+
for exactly this, because without it synthetic input returns success having
276+
done nothing and `CaptureService:CaptureScreenshot` never calls its callback at
277+
all — it does not fail, it simply never answers.
278+
- **`AssetService:CreateEditableImageAsync` needs the place's Mesh/Image API
279+
permission.** Without it, viewport capture fails at the read step, after the
280+
screenshot has already been taken. The error names the Game Settings switch;
281+
keep it that way, because nothing else about the failure suggests a setting.
236282
- **`plugin:SetSetting` is one store for the whole machine.** Not per window, not
237283
per place. Every open Studio reads back the same value, so nothing persisted
238284
there can identify a window — that is why the install id is scoped by place

‎packages/app/src/main/main.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type { ChangeRecord, PermissionGroup, ServerEvent } from "@luumen/code-pr
1616
import { AgentManager } from "./agents/manager.js";
1717
import { generateTitle, titleProvider } from "./agents/title.js";
1818
import { PluginInstaller } from "./plugin.js";
19-
import { createElectronScreenshotProvider } from "./screenshot.js";
19+
import { createElectronDesktopCaptureProvider } from "./screenshot.js";
2020
import { SettingsStore } from "./settings.js";
2121
import { ThreadStore } from "./threads.js";
2222
import { fromAgentEvent, fromServerEvent, userEntry } from "./transcript.js";
@@ -432,7 +432,7 @@ async function bootstrap(): Promise<void> {
432432
if (requireSettings().current().plugin.autoInstall && plugin.needsInstall()) plugin.install();
433433

434434
server = await createLuuCodeServer({
435-
screenshotProvider: createElectronScreenshotProvider(),
435+
desktopCaptureProvider: createElectronDesktopCaptureProvider(),
436436
});
437437

438438
server.bus.subscribe((event: ServerEvent) => {

‎packages/app/src/main/screenshot.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
11
/**
2-
* Screenshot capture through Electron.
2+
* Desktop capture through Electron.
33
*
44
* This is one of the concrete advantages of owning the harness (spec section
55
* 22): desktopCapturer goes through the compositor, so the Roblox Studio window
66
* can be captured without bringing it to the front or stealing the user's
77
* focus. The server falls back to a platform helper when the app is not
88
* running.
9+
*
10+
* This is the fallback path. `view.screenshot` captures the viewport through
11+
* CaptureService inside Studio by default, which sees what the experience is
12+
* drawing rather than a window with Studio's interface around it; what is here
13+
* answers `source: "window"` and `source: "screen"`.
914
*/
1015
import { desktopCapturer, screen } from "electron";
11-
import type { ScreenshotProvider, ScreenshotRequest } from "@luumen/code-server";
16+
import type { DesktopCaptureProvider, DesktopCaptureRequest } from "@luumen/code-server";
1217
import { LuuCodeError } from "@luumen/code-protocol";
1318

1419
const STUDIO_WINDOW_PATTERN = /roblox studio/i;
1520

16-
export function createElectronScreenshotProvider(): ScreenshotProvider {
17-
return async (request: ScreenshotRequest) => {
21+
export function createElectronDesktopCaptureProvider(): DesktopCaptureProvider {
22+
return async (request: DesktopCaptureRequest) => {
1823
const scale = screen.getPrimaryDisplay().scaleFactor || 1;
1924
const bounds = screen.getPrimaryDisplay().bounds;
2025

@@ -26,20 +31,20 @@ export function createElectronScreenshotProvider(): ScreenshotProvider {
2631
};
2732

2833
const sources = await desktopCapturer.getSources({
29-
types: request.source === "studio" ? ["window", "screen"] : ["screen"],
34+
types: request.source === "window" ? ["window", "screen"] : ["screen"],
3035
thumbnailSize,
3136
fetchWindowIcons: false,
3237
});
3338

3439
const source =
35-
request.source === "studio"
40+
request.source === "window"
3641
? sources.find((entry) => STUDIO_WINDOW_PATTERN.test(entry.name)) ?? null
3742
: sources.find((entry) => entry.id.startsWith("screen")) ?? sources[0] ?? null;
3843

3944
if (!source) {
4045
throw new LuuCodeError(
4146
"SCREENSHOT_FAILED",
42-
request.source === "studio"
47+
request.source === "window"
4348
? "No Roblox Studio window was found to capture."
4449
: "No screen was available to capture.",
4550
{ hint: "Open the place in Studio, then capture again." },
@@ -61,15 +66,16 @@ export function createElectronScreenshotProvider(): ScreenshotProvider {
6166
}
6267

6368
const finalSize = image.getSize();
64-
const buffer = request.format === "jpeg" ? image.toJPEG(82) : image.toPNG();
6569

6670
return {
67-
data: buffer.toString("base64"),
68-
mimeType: request.format === "jpeg" ? "image/jpeg" : "image/png",
71+
data: image.toPNG().toString("base64"),
72+
mimeType: "image/png",
6973
width: finalSize.width,
7074
height: finalSize.height,
7175
capturedAt: Date.now(),
7276
source: request.source,
77+
// A desktop capture photographs a window, not a DataModel.
78+
realm: null,
7379
};
7480
};
7581
}

‎packages/app/src/renderer/components/CommandPalette.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export function CommandPalette({
8080
icon: SquareTerminal,
8181
group: "Actions",
8282
disabled: !place,
83-
run: close(() => void harness.run("view.screenshot", { source: "studio" })),
83+
run: close(() => void harness.run("view.screenshot", {})),
8484
},
8585
{
8686
id: "clear-output",

‎packages/app/src/renderer/components/RightDock.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,7 @@ function StudioDetails({
276276
variant="outline"
277277
size="sm"
278278
className="flex-1"
279-
onClick={() => void harness.run("view.screenshot", { source: "studio" })}
279+
onClick={() => void harness.run("view.screenshot", {})}
280280
>
281281
<Camera />
282282
Screenshot

‎packages/protocol/src/capabilities.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@ export const CAPABILITIES = [
1616
"edit.undo-history",
1717
"playtest.run",
1818
"playtest.play",
19+
"playtest.multiplayer",
1920
"output.capture",
2021
"runtime.inspect",
2122
"runtime.exec",
2223
"input.virtual",
23-
"input.native",
2424
"view.screenshot",
2525
] as const;
2626

@@ -31,7 +31,13 @@ export interface CapabilityState {
3131
available: boolean;
3232
/** Why it is unavailable, phrased for the agent. */
3333
reason?: string;
34-
/** Which layer provides it, useful when diagnosing a failure. */
34+
/**
35+
* Which layer provides it, useful when diagnosing a failure.
36+
*
37+
* `native` is the desktop layer, and only screenshots still use it. Nothing
38+
* here drives the user's keyboard or mouse: every input path is a structured
39+
* Studio API delivered inside the running experience.
40+
*/
3541
provider: "studio-plugin" | "native" | "server";
3642
}
3743

0 commit comments

Comments
 (0)