diff --git a/apps/browser-demos/pages/kandelo/panes/Display.tsx b/apps/browser-demos/pages/kandelo/panes/Display.tsx index 5872bb956..8cb8dbef6 100644 --- a/apps/browser-demos/pages/kandelo/panes/Display.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Display.tsx @@ -2,7 +2,10 @@ import * as React from "react"; import { useWebPreview } from "../kernel-host/react"; import { Framebuffer, type FramebufferProps } from "./Framebuffer"; import { Modeset } from "./Modeset"; -import type { PrimarySurface } from "../../../../../web-libs/kandelo-session/src/kernel-host"; +import type { + DemoPresentation, + PrimarySurface, +} from "../../../../../web-libs/kandelo-session/src/kernel-host"; export interface WordPressLoginOptions { username: string; @@ -23,13 +26,28 @@ export interface DisplayProps extends FramebufferProps { * framebuffer) for callers that don't yet pass a surface. */ surface?: PrimarySurface; + presentation?: DemoPresentation; onDockControlsChange?: (controls: React.ReactNode | null) => void; } -export const Display = React.forwardRef(({ surface, onDockControlsChange, ...props }, ref) => { +export const Display = React.forwardRef(({ + surface, + presentation, + onDockControlsChange, + ...props +}, ref) => { const preview = useWebPreview(); - if (surface === "kms") return ; + if (surface === "kms") { + return ( + + ); + } if (surface === "framebuffer") return ; if (surface === "web" && preview) { return ; diff --git a/apps/browser-demos/pages/kandelo/panes/Modeset.tsx b/apps/browser-demos/pages/kandelo/panes/Modeset.tsx index 042e0a647..ccdad48dd 100644 --- a/apps/browser-demos/pages/kandelo/panes/Modeset.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Modeset.tsx @@ -4,7 +4,11 @@ import * as React from "react"; import { useKernelHost, useStatus } from "../kernel-host/react"; -import type { KmsDisplayHandle } from "../../../../../web-libs/kandelo-session/src/kernel-host"; +import type { + KmsConnectorMode, + KmsDisplayHandle, + KmsSurfaceFit, +} from "../../../../../web-libs/kandelo-session/src/kernel-host"; import { attachLinuxMediumRawKeyboard, injectChunkedMouseMotion, @@ -13,15 +17,10 @@ import { import { DemoSurfaceDockControls } from "./Framebuffer"; import { useFittedCanvasStyle } from "./canvasFit"; -// modeset.c hardcodes 1920×1080 (CANVAS_W/CANVAS_H). The kernel-side -// auto-attach resizes the OffscreenCanvas drawing buffer to match the -// FB before `getContext("webgl2")`, but the placeholder HTMLCanvas in -// the main thread keeps whatever `width`/`height` we set BEFORE -// `transferControlToOffscreen()`. We need correct attribute dims here -// so the pointer scaling math (`canvas.width / rect.width`) matches -// the framebuffer the wasm program actually paints into. -const MODESET_FB_W = 1920; -const MODESET_FB_H = 1080; +// Initial connector mode before any process binds a KMS FB. Once a process +// calls MODE_SETCRTC/PAGE_FLIP, stats slots 2/3 become the authoritative +// scanout size and drive input scaling + CSS fitting. +const DEFAULT_KMS_CONNECTOR_MODE: KmsConnectorMode = { width: 1920, height: 1080 }; export interface ModesetProps { dragProps?: import("./PaneHead").PaneHeadDragProps; @@ -34,6 +33,10 @@ export interface ModesetProps { /** CRTC to bind the canvas to. Defaults to 1 (the single CRTC the * kernel currently advertises via MODE_GETRESOURCES). */ crtcId?: number; + /** Virtual mode advertised by the browser KMS connector before page flips. */ + connectorMode?: KmsConnectorMode; + /** Browser presentation fit for the scanout canvas. */ + fit?: KmsSurfaceFit; } interface KmsStats { @@ -50,9 +53,20 @@ const ZERO_STATS: KmsStats = { lastFrameUs: 0, }; -export const Modeset: React.FC = ({ autoFocus = false, focusToken = 0, crtcId = 1, onDockControlsChange }) => { +export const Modeset: React.FC = ({ + autoFocus = false, + focusToken = 0, + crtcId = 1, + connectorMode: configuredConnectorMode, + fit = "contain", + onDockControlsChange, +}) => { const host = useKernelHost(); const status = useStatus(); + const connectorMode = React.useMemo( + () => sanitizeConnectorMode(configuredConnectorMode), + [configuredConnectorMode?.height, configuredConnectorMode?.width], + ); const stageRef = React.useRef(null); const canvasRef = React.useRef(null); const handleRef = React.useRef(null); @@ -73,21 +87,15 @@ export const Modeset: React.FC = ({ autoFocus = false, focusToken if (!canvas) return; if (handleRef.current) return; - // Match the wasm program's framebuffer dims BEFORE - // `transferControlToOffscreen()`. The placeholder HTMLCanvas keeps - // these as its `.width`/`.height` attribute values after transfer; - // the OffscreenCanvas inherits them too. Both matter: - // - The pointer scaler reads `canvas.width / rect.width` to map - // CSS deltas to framebuffer pixels. Default 300/150 would mean - // the cursor crawls at ~1/6 speed and Pavel's splats clump. - // - The OffscreenCanvas drawing buffer must be 1920×1080 so - // `glViewport(0, 0, 1920, 1080)` covers the full surface. - if (canvas.width !== MODESET_FB_W) canvas.width = MODESET_FB_W; - if (canvas.height !== MODESET_FB_H) canvas.height = MODESET_FB_H; + // Seed the connector mode before transfer. The worker resizes the + // OffscreenCanvas to the kernel's current FB before WebGL2 attaches, + // and the stats SAB reports that real scanout size back to this pane. + if (canvas.width !== connectorMode.width) canvas.width = connectorMode.width; + if (canvas.height !== connectorMode.height) canvas.height = connectorMode.height; let offBound: (() => void) | null = null; try { - const handle = host.attachKmsDisplay(canvas, crtcId); + const handle = host.attachKmsDisplay(canvas, crtcId, { mode: "webgl2", connectorMode }); if (!handle) { setError("Kernel does not expose kmsAttachCanvas (older ABI?)"); return; @@ -106,7 +114,7 @@ export const Modeset: React.FC = ({ autoFocus = false, focusToken handleRef.current = null; setBoundPid(null); }; - }, [host, status, crtcId]); + }, [connectorMode, host, status, crtcId]); // Forward mouse motion + buttons into the kernel's `/dev/input/mice`. // The wasm side has no absolute-cursor input — it integrates int8 @@ -125,8 +133,8 @@ export const Modeset: React.FC = ({ autoFocus = false, focusToken const activeFrameSize = () => { const s = statsRef.current; - const width = s.width > 0 ? s.width : (canvas.width || MODESET_FB_W); - const height = s.height > 0 ? s.height : (canvas.height || MODESET_FB_H); + const width = s.width > 0 ? s.width : (canvas.width || connectorMode.width); + const height = s.height > 0 ? s.height : (canvas.height || connectorMode.height); return { width, height }; }; const initialFrame = activeFrameSize(); @@ -230,7 +238,7 @@ export const Modeset: React.FC = ({ autoFocus = false, focusToken canvas.removeEventListener("contextmenu", onContextMenu); doc.removeEventListener("mouseup", onMouseUp); }; - }, [boundPid, stats.height, stats.width, status]); + }, [boundPid, connectorMode.height, connectorMode.width, stats.height, stats.width, status]); React.useEffect(() => { if (status !== "running") return; @@ -292,7 +300,14 @@ export const Modeset: React.FC = ({ autoFocus = false, focusToken const captureLabel = focused ? boundPid !== null ? `captured · pid ${boundPid}` : "captured" : boundPid !== null ? "click to type" : "waiting for KMS process"; - const canvasStyle = useFittedCanvasStyle(stageRef, canvasRef, MODESET_FB_W / MODESET_FB_H); + const scanoutAspect = hasFrame ? stats.width / stats.height : undefined; + const canvasStyle = useFittedCanvasStyle( + stageRef, + canvasRef, + connectorMode.width / connectorMode.height, + scanoutAspect, + fit, + ); const statusLabel = hasFrame ? `${stats.width}×${stats.height} · ${stats.commitCount} flips · ${stats.lastFrameUs}µs · ${captureLabel}` : "waiting for PAGE_FLIP"; @@ -346,3 +361,19 @@ export const Modeset: React.FC = ({ autoFocus = false, focusToken ); }; + +function sanitizeConnectorMode(mode: KmsConnectorMode | undefined): KmsConnectorMode { + if ( + mode && + Number.isFinite(mode.width) && + Number.isFinite(mode.height) && + mode.width > 0 && + mode.height > 0 + ) { + return { + width: Math.floor(mode.width), + height: Math.floor(mode.height), + }; + } + return DEFAULT_KMS_CONNECTOR_MODE; +} diff --git a/apps/browser-demos/pages/kandelo/panes/canvasFit.ts b/apps/browser-demos/pages/kandelo/panes/canvasFit.ts index a7512a15f..35b1ed7d1 100644 --- a/apps/browser-demos/pages/kandelo/panes/canvasFit.ts +++ b/apps/browser-demos/pages/kandelo/panes/canvasFit.ts @@ -4,6 +4,8 @@ export function useFittedCanvasStyle( containerRef: React.RefObject, canvasRef: React.RefObject, fallbackAspect: number, + aspectOverride?: number, + fit: "contain" | "stretch" = "contain", ): React.CSSProperties { const [style, setStyle] = React.useState({}); @@ -15,7 +17,21 @@ export function useFittedCanvasStyle( const update = () => { const rect = container.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; - const aspect = canvasAspect(canvas, fallbackAspect); + if (fit === "stretch") { + const nextWidth = Math.max(1, Math.floor(rect.width)); + const nextHeight = Math.max(1, Math.floor(rect.height)); + setStyle((current) => { + if (current.width === `${nextWidth}px` && current.height === `${nextHeight}px`) { + return current; + } + return { + width: `${nextWidth}px`, + height: `${nextHeight}px`, + }; + }); + return; + } + const aspect = validAspect(aspectOverride) ? aspectOverride : canvasAspect(canvas, fallbackAspect); const containerAspect = rect.width / rect.height; const width = containerAspect > aspect ? rect.height * aspect : rect.width; const height = width / aspect; @@ -47,7 +63,7 @@ export function useFittedCanvasStyle( mutationObserver.disconnect(); window.removeEventListener("resize", update); }; - }, [canvasRef, containerRef, fallbackAspect]); + }, [aspectOverride, canvasRef, containerRef, fallbackAspect, fit]); return style; } @@ -60,3 +76,7 @@ function canvasAspect(canvas: HTMLCanvasElement, fallbackAspect: number): number } return fallbackAspect; } + +function validAspect(value: number | undefined): value is number { + return value !== undefined && Number.isFinite(value) && value > 0; +} diff --git a/apps/browser-demos/pages/kandelo/views/MachineView.tsx b/apps/browser-demos/pages/kandelo/views/MachineView.tsx index d526ed0ca..34b61890a 100644 --- a/apps/browser-demos/pages/kandelo/views/MachineView.tsx +++ b/apps/browser-demos/pages/kandelo/views/MachineView.tsx @@ -189,6 +189,7 @@ export const MachineView: React.FC = ({ const { activePrimary, demoSurface, + presentation, canUseTerminal, shouldMountDemoSurface, followDemoSurface, @@ -250,6 +251,7 @@ export const MachineView: React.FC = ({ autoFocus={activePrimary === demoSurface} focusToken={demoFocusToken} surface={demoSurface ?? undefined} + presentation={presentation} onDockControlsChange={onDemoDockControlsChange} /> diff --git a/apps/browser-demos/test/kandelo-kms-gles-rendering.spec.ts b/apps/browser-demos/test/kandelo-kms-gles-rendering.spec.ts new file mode 100644 index 000000000..852d48298 --- /dev/null +++ b/apps/browser-demos/test/kandelo-kms-gles-rendering.spec.ts @@ -0,0 +1,300 @@ +import { writeFileSync } from "node:fs"; +import { expect, test, type Locator, type Page, type TestInfo } from "@playwright/test"; + +interface KmsSnapshotMetrics { + backingStore: { width: number; height: number }; + canvasRect: { x: number; y: number; width: number; height: number }; + stageRect: { width: number; height: number }; + litPixels: number; + coloredPixels: number; + litBounds: { minX: number; minY: number; maxX: number; maxY: number; width: number; height: number } | null; + channelBoxes: Record; + widthFill: number; + heightFill: number; + scanlineAlternation: number; + adjacentRowContrast: number; + strongestChannelShift: { + rgShift: number; + rgScore: number; + rbShift: number; + rbScore: number; + }; +} + +const appUrl = (path: string): string => { + const baseUrl = process.env.KANDELO_TEST_BASE_URL; + return baseUrl ? new URL(path, baseUrl).href : path; +}; + +async function gotoOrSkip(page: Page, path: string) { + await page.goto(appUrl(path), { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(2_000); + if (await page.locator("vite-error-overlay").count()) { + test.skip(true, "Required binary not built - Vite import error"); + } +} + +async function captureKmsSnapshot( + page: Page, + canvas: Locator, + label: string, + testInfo: TestInfo, +): Promise { + const metrics = await page.evaluate(() => { + const canvas = document.querySelector("canvas.kmodeset-canvas"); + const stage = document.querySelector(".kmodeset-stage"); + if (!canvas || !stage) throw new Error("missing KMS canvas or stage"); + + const canvasRect = canvas.getBoundingClientRect(); + const stageRect = stage.getBoundingClientRect(); + const sampleCanvas = new OffscreenCanvas(canvas.width, canvas.height); + const ctx = sampleCanvas.getContext("2d"); + if (!ctx) throw new Error("2D sample context unavailable"); + ctx.drawImage(canvas, 0, 0); + const sample = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + let litPixels = 0; + let coloredPixels = 0; + let minX = canvas.width; + let minY = canvas.height; + let maxX = -1; + let maxY = -1; + const rowLuma = Array.from({ length: canvas.height }, () => 0); + const channels = { + red: { minX: canvas.width, minY: canvas.height, maxX: -1, maxY: -1, count: 0, sumX: 0 }, + green: { minX: canvas.width, minY: canvas.height, maxX: -1, maxY: -1, count: 0, sumX: 0 }, + blue: { minX: canvas.width, minY: canvas.height, maxX: -1, maxY: -1, count: 0, sumX: 0 }, + }; + const addChannel = (channel: keyof typeof channels, x: number, y: number) => { + const box = channels[channel]; + box.minX = Math.min(box.minX, x); + box.minY = Math.min(box.minY, y); + box.maxX = Math.max(box.maxX, x); + box.maxY = Math.max(box.maxY, y); + box.count++; + box.sumX += x; + }; + for (let i = 0; i < sample.length; i += 4) { + const r = sample[i]; + const g = sample[i + 1]; + const b = sample[i + 2]; + const pixelIndex = i / 4; + const x = pixelIndex % canvas.width; + const y = Math.floor(pixelIndex / canvas.width); + const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + rowLuma[y] += luma; + if (r > 20 || g > 20 || b > 20) { + litPixels++; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + if (Math.max(r, g, b) - Math.min(r, g, b) > 40) coloredPixels++; + if (r > 80 && r > g * 1.5 && r > b * 1.5) addChannel("red", x, y); + if (g > 80 && g > r * 1.5 && g > b * 1.5) addChannel("green", x, y); + if (b > 80 && b > r * 1.5 && b > g * 1.5) addChannel("blue", x, y); + } + const rowMean = rowLuma.map((sum) => sum / canvas.width); + const even = rowMean.filter((_, index) => index % 2 === 0); + const odd = rowMean.filter((_, index) => index % 2 === 1); + const mean = (values: number[]) => values.reduce((sum, value) => sum + value, 0) / Math.max(1, values.length); + const meanLuma = Math.max(1, mean(rowMean)); + const adjacentRowContrast = rowMean.slice(1) + .reduce((sum, value, index) => sum + Math.abs(value - rowMean[index]), 0) + / Math.max(1, rowMean.length - 1) + / meanLuma; + const scanlineAlternation = Math.abs(mean(even) - mean(odd)) / meanLuma; + + const channelShiftScore = (aOffset: 0 | 1 | 2, bOffset: 0 | 1 | 2, shift: number) => { + let overlap = 0; + let aTotal = 0; + let bTotal = 0; + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < canvas.width - shift; x++) { + const ai = (y * canvas.width + x) * 4 + aOffset; + const bi = (y * canvas.width + x + shift) * 4 + bOffset; + const a = sample[ai]; + const b = sample[bi]; + overlap += Math.min(a, b); + aTotal += a; + bTotal += b; + } + } + return overlap / Math.max(1, Math.sqrt(aTotal * bTotal)); + }; + let rgShift = 0; + let rgScore = 0; + let rbShift = 0; + let rbScore = 0; + for (let shift = 1; shift <= Math.min(80, canvas.width - 1); shift++) { + const rg = channelShiftScore(0, 1, shift); + if (rg > rgScore) { + rgScore = rg; + rgShift = shift; + } + const rb = channelShiftScore(0, 2, shift); + if (rb > rbScore) { + rbScore = rb; + rbShift = shift; + } + } + + const channelBoxes = Object.fromEntries(Object.entries(channels).map(([name, box]) => [ + name, + box.maxX >= 0 ? { + minX: box.minX, + minY: box.minY, + maxX: box.maxX, + maxY: box.maxY, + count: box.count, + meanX: box.sumX / box.count, + width: box.maxX - box.minX + 1, + height: box.maxY - box.minY + 1, + } : null, + ])); + return { + backingStore: { width: canvas.width, height: canvas.height }, + canvasRect: { + x: canvasRect.x, + y: canvasRect.y, + width: canvasRect.width, + height: canvasRect.height, + }, + stageRect: { + width: stageRect.width, + height: stageRect.height, + }, + litPixels, + coloredPixels, + litBounds: maxX >= 0 ? { + minX, + minY, + maxX, + maxY, + width: maxX - minX + 1, + height: maxY - minY + 1, + } : null, + channelBoxes, + widthFill: canvasRect.width / stageRect.width, + heightFill: canvasRect.height / stageRect.height, + scanlineAlternation, + adjacentRowContrast, + strongestChannelShift: { rgShift, rgScore, rbShift, rbScore }, + }; + }); + + const metricsJson = JSON.stringify(metrics, null, 2); + const canvasPng = Buffer.from( + await canvas.evaluate((node: HTMLCanvasElement) => node.toDataURL("image/png").split(",")[1]), + "base64", + ); + const pagePng = await page.screenshot({ fullPage: true }); + writeFileSync(testInfo.outputPath(`${label}-metrics.json`), metricsJson); + writeFileSync(testInfo.outputPath(`${label}-canvas.png`), canvasPng); + writeFileSync(testInfo.outputPath(`${label}-page.png`), pagePng); + + await test.info().attach(`${label}-metrics.json`, { + body: metricsJson, + contentType: "application/json", + }); + await test.info().attach(`${label}-canvas.png`, { + body: canvasPng, + contentType: "image/png", + }); + await test.info().attach(`${label}-page.png`, { + body: pagePng, + contentType: "image/png", + }); + + return metrics; +} + +test("BYTEPATH reaches gameplay on the native KMS/GLES renderer", async ({ browserName, page }, testInfo: TestInfo) => { + test.skip(browserName !== "chromium", "KMS WebGL2 OffscreenCanvas rendering is Chromium-only in CI"); + test.setTimeout(360_000); + + await page.setViewportSize({ width: 2048, height: 1152 }); + await gotoOrSkip(page, `/?demo=bytepath&verify=${Date.now()}`); + + const guideClose = page.getByRole("button", { name: "Close demo guide" }); + if (await guideClose.count()) { + await guideClose.first().click(); + } + + const canvas = page.locator("canvas.kmodeset-canvas").first(); + await expect(canvas).toBeVisible({ timeout: 180_000 }); + + await expect + .poll(() => canvas.evaluate((node: HTMLCanvasElement) => ({ + width: node.width, + height: node.height, + })), { timeout: 180_000, intervals: [1_000, 2_000, 5_000] }) + .toEqual({ width: 480, height: 270 }); + + const box = await canvas.boundingBox(); + expect(box, "KMS canvas bounding box").not.toBeNull(); + await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height / 2); + + // BYTEPATH starts with an in-game simulated boot sequence. Drive the actual + // player path instead of sending input during the boot text. + await page.waitForTimeout(35_000); + for (const key of ["s", "t", "a", "r", "t"]) { + await page.keyboard.down(key); + await page.waitForTimeout(80); + await page.keyboard.up(key); + await page.waitForTimeout(80); + } + await page.keyboard.press("Enter"); + await page.waitForTimeout(10_000); + const metrics10s = await captureKmsSnapshot(page, canvas, "bytepath-gameplay-10s", testInfo); + + await page.keyboard.down("w"); + await page.keyboard.down("d"); + await page.waitForTimeout(4_000); + await page.keyboard.up("w"); + await page.keyboard.up("d"); + await page.waitForTimeout(8_000); + const metrics22s = await captureKmsSnapshot(page, canvas, "bytepath-gameplay-22s", testInfo); + + const internalsButton = page.getByRole("button", { name: /internals|system internals/i }).first(); + if (await internalsButton.count()) { + await internalsButton.click(); + await page.waitForTimeout(250); + } + const syslog = (await page.locator(".ksys-line").allTextContents()).join("\n"); + writeFileSync(testInfo.outputPath("bytepath-syslog.txt"), syslog); + await test.info().attach("bytepath-syslog.txt", { + body: syslog, + contentType: "text/plain", + }); + + expect(metrics10s.litPixels, "BYTEPATH should render nonblank gameplay pixels").toBeGreaterThan(2_000); + expect(metrics10s.coloredPixels, "BYTEPATH should render colored gameplay/HUD pixels").toBeGreaterThan(500); + expect(metrics10s.litBounds?.width ?? 0, "BYTEPATH gameplay should span the game surface").toBeGreaterThan(300); + expect(metrics10s.litBounds?.height ?? 0, "BYTEPATH gameplay should span the game surface").toBeGreaterThan(180); + expect(metrics22s.litPixels, "BYTEPATH should still be rendering after sustained gameplay input").toBeGreaterThan(2_000); + + expect(metrics10s.widthFill, "BYTEPATH KMS canvas should fill the available pane width").toBeGreaterThan(0.98); + expect(metrics10s.heightFill, "BYTEPATH KMS canvas should fill the available pane height").toBeGreaterThan(0.98); + expect(metrics10s.canvasRect.width * metrics10s.canvasRect.height, "KMS canvas should not be CSS scale-capped") + .toBeGreaterThan(metrics10s.backingStore.width * metrics10s.backingStore.height * 8); + expect(metrics10s.adjacentRowContrast, "BYTEPATH final shader pass should leave visible row-level distortion/scanline structure") + .toBeGreaterThan(0.05); + expect(metrics10s.strongestChannelShift.rgShift, "BYTEPATH RGB shader map should not quantize neutral gray into a large red/green offset") + .toBeLessThanOrEqual(20); + expect(metrics10s.strongestChannelShift.rbShift, "BYTEPATH RGB shader map should not quantize neutral gray into a large red/blue offset") + .toBeLessThanOrEqual(40); + expect(metrics22s.strongestChannelShift.rgShift, "BYTEPATH sustained gameplay red/green offset should stay in the intended glitch range") + .toBeLessThanOrEqual(20); + expect(metrics22s.strongestChannelShift.rbShift, "BYTEPATH sustained gameplay red/blue offset should stay in the intended glitch range") + .toBeLessThanOrEqual(40); +}); diff --git a/docs/browser-support.md b/docs/browser-support.md index e0e0171e0..cbfb3fdf5 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -117,6 +117,10 @@ connection in any nginx worker. The standalone nginx image runs with - Keyboard input: the demo page maps focused browser `KeyboardEvent` values to Linux input keycodes, encodes them as MEDIUMRAW bytes, and feeds them through `appendStdinData(pid, …)`; fbDOOM-style software decodes those bytes from the tty. Ctrl+Shift+Esc is reserved as the host escape from keyboard capture. - Limitations: `fork` does not auto-bind the child; multi-buffering / vsync via `FBIOPAN_DISPLAY` is a no-op. +### KMS (`/dev/dri/card0`) +- KMS presentation follows the framebuffer object currently bound to the CRTC. The browser modeset pane reads the scanout width/height from the kernel stats SAB, uses those dimensions for input scaling, and upscales the canvas with CSS to fit the available Kandelo surface. +- The advertised connector mode is tracked separately from the current scanout framebuffer. Browser surfaces seed the connector from image-declared KMS presentation metadata, defaulting to 1920×1080 when no mode is declared, so user software sees stable `DRM_IOCTL_MODE_GETCONNECTOR` dimensions while `MODE_SETCRTC`/`PAGE_FLIP` remain authoritative for the active framebuffer size. + ### Mouse input (`/dev/input/mice`) - Demo pages attach `mousemove` / `mousedown` / `mouseup` listeners to the canvas and call `BrowserKernel.injectMouseEvent(dx, dy, buttons)`. The main thread posts a `mouse_inject` message to the kernel worker, which calls the kernel's `kernel_inject_mouse_event` export. The kernel encodes a 3-byte PS/2 frame and queues it on a global ring; user processes drain the queue via `read("/dev/input/mice", …)`. - **Pointer Lock recommended.** The DOOM demo calls `canvas.requestPointerLock()` on first click so the browser delivers unbounded relative motion (`MouseEvent.movementX/Y`). Without pointer lock, `clientX/Y` deltas clamp at the canvas edges and feel sluggish for first-person controls. Press `Esc` to release the lock. @@ -281,6 +285,16 @@ Kandelo app attaches the KMS canvas through the generic KMS surface plumbing, then runs the image-declared command. Do not add browser-loader branches that import or spawn a specific `modeset.wasm` file. +KMS profiles may also declare `presentation.kms.connectorMode` with integer +`width` and `height` fields. The browser host advertises that as the virtual +connector mode before the first KMS client binds a framebuffer, and the +Kandelo surface upscales the resulting scanout with CSS. This lets pixel-art +or fixed-resolution programs render to their intended logical display while +generic KMS demos keep the default 1920×1080 connector mode. By default the +CSS presentation preserves the scanout aspect ratio. Profiles that should fill +the whole Kandelo surface can set `presentation.kms.fit` to `"stretch"`; the +other accepted value is `"contain"`. + Images can also declare an optional `guide`. When `guide` is absent, Kandelo does not render a demo panel; this is the intended shape for demos where the primary surface is enough, such as WordPress and Doom. A guide can contain diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 27b533ef5..01aeabd3d 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -877,7 +877,10 @@ export class BrowserKernel { crtcId: number, canvas: OffscreenCanvas, stats?: SharedArrayBuffer, - opts?: { mode?: "auto" | "2d" | "webgl2" }, + opts?: { + mode?: "auto" | "2d" | "webgl2"; + connectorMode?: { width: number; height: number }; + }, ): void { this.sendToKernel( { type: "kms_attach_canvas", crtcId, canvas, stats, opts }, diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index 773cf3f0c..f78b9be31 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -318,7 +318,10 @@ export interface KmsAttachCanvasMessage { crtcId: number; canvas: OffscreenCanvas; stats?: SharedArrayBuffer; - opts?: { mode?: "auto" | "2d" | "webgl2" }; + opts?: { + mode?: "auto" | "2d" | "webgl2"; + connectorMode?: { width: number; height: number }; + }; } /** Register a stats SAB for a CRTC without binding a scanout canvas. The diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index ac4fc5b32..3f6e47a42 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -861,6 +861,9 @@ export class CentralizedKernelWorker { /** KMS presenter: OffscreenCanvas per CRTC for the vblank pump to blit * the bound framebuffer into. Populated via `attachKmsCanvas`. */ private kmsCanvases = new Map(); + /** Advertised connector mode per CRTC. This is separate from the canvas + * backing-store size, which tracks the current scanout framebuffer. */ + private kmsAdvertisedModes = new Map(); private kmsContexts = new Map(); /** Which context type each CRTC's canvas has been claimed for. Set * by `attachKmsCanvas` when the embedder declares the mode up-front @@ -898,6 +901,7 @@ export class CentralizedKernelWorker { // pid has no canvas bound yet; the kernel-worker's KMS registry // is the single source of truth for `crtc_id → OffscreenCanvas`. getKmsCanvas: (crtcId: number) => this.kmsCanvases.get(crtcId), + getKmsMode: (crtcId: number) => this.kmsAdvertisedModes.get(crtcId), markKmsCanvasGlOwned: (crtcId: number) => { this.kmsContextMode.set(crtcId, "webgl2"); }, @@ -8569,9 +8573,21 @@ export class CentralizedKernelWorker { crtc_id: number, canvas: OffscreenCanvas, statsSab?: SharedArrayBuffer, - opts?: { mode?: "auto" | "2d" | "webgl2" }, + opts?: { + mode?: "auto" | "2d" | "webgl2"; + connectorMode?: { width: number; height: number }; + }, ): void { this.kmsCanvases.set(crtc_id, canvas); + const connectorWidth = opts?.connectorMode?.width ?? canvas.width; + const connectorHeight = opts?.connectorMode?.height ?? canvas.height; + if (Number.isFinite(connectorWidth) && Number.isFinite(connectorHeight) && + connectorWidth > 0 && connectorHeight > 0) { + this.kmsAdvertisedModes.set(crtc_id, { + width: Math.trunc(connectorWidth), + height: Math.trunc(connectorHeight), + }); + } if (statsSab) this.kmsStatsViews.set(crtc_id, new Int32Array(statsSab)); const mode = opts?.mode ?? "auto"; if (mode === "2d") { diff --git a/host/src/kernel.ts b/host/src/kernel.ts index a0bff4122..c7210683c 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -158,6 +158,11 @@ const WASM_STATFS_SIZE = 72; /** Size of the WasmDirent struct: d_ino(u64) + d_type(u32) + d_namlen(u32). */ const WASM_DIRENT_SIZE = STRUCT_SIZE_WASM_DIRENT; +export type KmsModeDimensions = { + width: number; + height: number; +}; + export interface KernelCallbacks { onKill?: (pid: number, signal: number) => number; onExec?: (path: string) => number; @@ -190,6 +195,12 @@ export interface KernelCallbacks { * legacy "embedder must call attachCanvas manually" path alive. */ getKmsCanvas?: (crtcId: number) => OffscreenCanvas | HTMLCanvasElement | undefined; + /** + * Resolve the advertised connector mode for `crtcId`. This is the display + * mode reported through DRM GETCONNECTOR and should remain stable even when + * the scanout canvas backing store is resized to the current framebuffer. + */ + getKmsMode?: (crtcId: number) => KmsModeDimensions | undefined; /** * Notify the embedder that GL has claimed the canvas for `crtcId`. * The KMS vblank pump uses this to skip the CPU `putImageData` blit @@ -978,10 +989,11 @@ export class WasmPosixKernel { } }, host_kms_mode_info: (connector_id: number, out_ptr: bigint): void => { + const mode = this.callbacks.getKmsMode?.(connector_id); const canvas = this.callbacks.getKmsCanvas?.(connector_id); this.writeKernelBytes( Number(out_ptr), - kmsModeInfoBytes(canvas?.width, canvas?.height), + kmsModeInfoBytes(mode?.width ?? canvas?.width, mode?.height ?? canvas?.height), ); }, host_kms_addfb: ( @@ -999,6 +1011,12 @@ export class WasmPosixKernel { host_kms_rmfb: (_pid: number, fb_id: number): void => { this.kms.rmFb(fb_id); }, host_kms_set_fb: (_pid: number, crtc_id: number, fb_id: number): void => { this.kms.setFb(crtc_id, fb_id); + const fb = this.kms.currentFb(crtc_id); + const canvas = this.callbacks.getKmsCanvas?.(crtc_id); + if (fb && canvas && (canvas.width !== fb.width || canvas.height !== fb.height)) { + canvas.width = fb.width; + canvas.height = fb.height; + } }, }, }; diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index eb46ad487..6224d53e2 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -291,7 +291,10 @@ export class NodeKernelHost { crtcId: number, canvas: OffscreenCanvas, stats?: SharedArrayBuffer, - opts?: { mode?: "auto" | "2d" | "webgl2" }, + opts?: { + mode?: "auto" | "2d" | "webgl2"; + connectorMode?: { width: number; height: number }; + }, ): void { this.sendToWorker({ type: "kms_attach_canvas", crtcId, canvas, stats, opts }); } diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index 70dddde6a..32e1bda86 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -175,7 +175,10 @@ export interface KmsAttachCanvasMessage { crtcId: number; canvas: OffscreenCanvas; stats?: SharedArrayBuffer; - opts?: { mode?: "auto" | "2d" | "webgl2" }; + opts?: { + mode?: "auto" | "2d" | "webgl2"; + connectorMode?: { width: number; height: number }; + }; } /** Register a stats SAB for a CRTC without binding a scanout canvas. */ diff --git a/host/src/webgl/query.ts b/host/src/webgl/query.ts index e54ed3d1a..cb310b7ea 100644 --- a/host/src/webgl/query.ts +++ b/host/src/webgl/query.ts @@ -30,6 +30,68 @@ const GL_CURRENT_PROGRAM = 0x8B8D; const GL_ACTIVE_UNIFORMS = 0x8B86; const GL_ACTIVE_UNIFORM_MAX_LENGTH = 0x8B87; const GL_INFO_LOG_LENGTH = 0x8B84; +const KANDELO_GLES_EXTENSIONS = [ + // WebGL2 can render to RGBA8 textures. Expose the equivalent GLES2 + // extension so native renderers do not fall back to lower-precision canvases. + "GL_OES_rgb8_rgba8", +].join(" "); +const GL_UNSIGNED_SHORT = 0x1403; +const GL_UNSIGNED_INT = 0x1405; +const GL_FLOAT = 0x1406; +const GL_HALF_FLOAT = 0x140B; +const GL_UNSIGNED_SHORT_4_4_4_4 = 0x8033; +const GL_UNSIGNED_SHORT_5_5_5_1 = 0x8034; +const GL_UNSIGNED_SHORT_5_6_5 = 0x8363; +const GL_UNSIGNED_INT_2_10_10_10_REV = 0x8368; +const GL_UNSIGNED_INT_24_8 = 0x84FA; +const GL_UNSIGNED_INT_10F_11F_11F_REV = 0x8C3B; + +function alignedFloat32View(out: Uint8Array): Float32Array { + if (out.byteOffset % 4 === 0) { + return new Float32Array(out.buffer, out.byteOffset, (out.byteLength / 4) | 0); + } + return new Float32Array((out.byteLength / 4) | 0); +} + +function alignedUint32View(out: Uint8Array): Uint32Array { + if (out.byteOffset % 4 === 0) { + return new Uint32Array(out.buffer, out.byteOffset, (out.byteLength / 4) | 0); + } + return new Uint32Array((out.byteLength / 4) | 0); +} + +function alignedUint16View(out: Uint8Array): Uint16Array { + if (out.byteOffset % 2 === 0) { + return new Uint16Array(out.buffer, out.byteOffset, (out.byteLength / 2) | 0); + } + return new Uint16Array((out.byteLength / 2) | 0); +} + +function readPixelsViewForType(type: number, out: Uint8Array): ArrayBufferView { + switch (type) { + case GL_FLOAT: + return alignedFloat32View(out); + case GL_UNSIGNED_SHORT: + case GL_HALF_FLOAT: + case GL_UNSIGNED_SHORT_4_4_4_4: + case GL_UNSIGNED_SHORT_5_5_5_1: + case GL_UNSIGNED_SHORT_5_6_5: + return alignedUint16View(out); + case GL_UNSIGNED_INT: + case GL_UNSIGNED_INT_2_10_10_10_REV: + case GL_UNSIGNED_INT_24_8: + case GL_UNSIGNED_INT_10F_11F_11F_REV: + return alignedUint32View(out); + default: + return out; + } +} + +function copyReadPixelsResult(view: ArrayBufferView, out: Uint8Array): void { + if (view.buffer === out.buffer && view.byteOffset === out.byteOffset) return; + const bytes = new Uint8Array(view.buffer, view.byteOffset, Math.min(view.byteLength, out.byteLength)); + out.set(bytes); +} export function runGlQuery( b: GlBinding, @@ -68,7 +130,7 @@ export function runGlQuery( s = "OpenGL ES GLSL ES 1.00 Kandelo"; break; case GL_EXTENSIONS: - s = ""; + s = KANDELO_GLES_EXTENSIONS; break; default: s = (gl.getParameter(name) as string | null) ?? ""; @@ -226,14 +288,9 @@ export function runGlQuery( const format = inDv.getUint32(16, true); const type = inDv.getUint32(20, true); // WebGL2 readPixels requires the destination view to match `type`. - // 0x1406 = GL_FLOAT, 0x1401 = GL_UNSIGNED_BYTE, 0x140B = GL_HALF_FLOAT. - let view: ArrayBufferView = out; - if (type === 0x1406) { - view = new Float32Array(out.buffer, out.byteOffset, (out.byteLength / 4) | 0); - } else if (type === 0x140B) { - view = new Uint16Array(out.buffer, out.byteOffset, (out.byteLength / 2) | 0); - } + const view = readPixelsViewForType(type, out); gl.readPixels(x, y, w, h, format, type, view); + copyReadPixelsResult(view, out); // gl.readPixels writes into `out` directly. Bytes-written depends // on (format,type,w,h); the kernel cap (`MAX_QUERY_OUT_LEN`) is // the upper bound. Return the full out length — the C side knows diff --git a/host/test/dri-kms-stats-sab.test.ts b/host/test/dri-kms-stats-sab.test.ts index d1b34dd47..c4f70b471 100644 --- a/host/test/dri-kms-stats-sab.test.ts +++ b/host/test/dri-kms-stats-sab.test.ts @@ -96,6 +96,25 @@ describe("CentralizedKernelWorker KMS stats SAB", () => { expect(Atomics.load(view, 4)).toBe(0); }); + it("keeps the advertised connector mode separate from the canvas backing store", () => { + const kernel = makeKernel(); + const canvas = makeFakeCanvas(); + canvas.width = 1920; + canvas.height = 1080; + kernel.attachKmsCanvas(1, canvas, undefined, { + mode: "webgl2", + connectorMode: { width: 1920, height: 1080 }, + }); + + canvas.width = 480; + canvas.height = 270; + const inner = (kernel as unknown as { + kernel: { callbacks: { getKmsMode?: (crtcId: number) => { width: number; height: number } | undefined } }; + }).kernel; + + expect(inner.callbacks.getKmsMode?.(1)).toEqual({ width: 1920, height: 1080 }); + }); + it("tickVblank leaves slots 5/6 alone when the SAB is the legacy 5-slot size", () => { const kernel = makeKernel(); stubScanout(kernel, 16, 16); diff --git a/host/test/webgl-bridge.test.ts b/host/test/webgl-bridge.test.ts index 03c27fde8..d23fd14f7 100644 --- a/host/test/webgl-bridge.test.ts +++ b/host/test/webgl-bridge.test.ts @@ -141,7 +141,7 @@ class RecordingGl { getShaderInfoLog(_s: unknown) { return "shader log"; } getProgramParameter(_p: unknown, _q: number) { return 1; } getProgramInfoLog(_p: unknown) { return "program log"; } - readPixels(_x: number, _y: number, _w: number, _h: number, _f: number, _t: number, dst: Uint8Array) { + readPixels(_x: number, _y: number, _w: number, _h: number, _f: number, _t: number, dst: Uint8Array | Uint16Array | Uint32Array | Float32Array) { for (let i = 0; i < dst.length; i++) dst[i] = (i & 0xff); } checkFramebufferStatus(_t: number) { return 0x8CD5 /* GL_FRAMEBUFFER_COMPLETE */; } @@ -494,6 +494,20 @@ describe("query handler", () => { expect(o[0]).toBe(0); // RecordingGl.getError() returns 0 }); + it("QOP_GET_STRING exposes GLES renderer extensions", () => { + const { b } = setup(); + const pname = new Uint8Array(4); + new DataView(pname.buffer).setUint32(0, 0x1F03 /* GL_EXTENSIONS */, true); + const o = out(128); + const n = runGlQuery(b, O.QOP_GET_STRING, pname, o); + const len = new DataView(o.buffer).getUint32(0, true); + const extensions = new TextDecoder().decode(o.subarray(4, 4 + len)); + + expect(n).toBe(4 + len); + expect(extensions).toContain("GL_OES_rgb8_rgba8"); + expect(extensions).not.toContain("WEBGL_test"); + }); + it("QOP_GET_INTEGERV reads a u32 pname and writes an i32", () => { const { b } = setup(); const o = out(4); @@ -598,6 +612,23 @@ describe("query handler", () => { expect(new TextDecoder().decode(o.subarray(4, 4 + len))).toBe("shader log"); }); + it("QOP_READ_PIXELS copies packed 16-bit results into unaligned byte output", () => { + const { b } = setup(); + const inp = new Uint8Array(24); + const idv = new DataView(inp.buffer); + idv.setInt32(0, 0, true); + idv.setInt32(4, 0, true); + idv.setInt32(8, 2, true); + idv.setInt32(12, 1, true); + idv.setUint32(16, 0x1908 /* GL_RGBA */, true); + idv.setUint32(20, 0x8033 /* GL_UNSIGNED_SHORT_4_4_4_4 */, true); + const backing = new ArrayBuffer(5); + const o = new Uint8Array(backing, 1, 4); + + expect(runGlQuery(b, O.QOP_READ_PIXELS, inp, o)).toBe(4); + expect([...o]).toEqual([0, 0, 1, 0]); + }); + it("returns -EPERM when the binding has no live context", () => { const { b } = setup(); b.gl = null; diff --git a/images/vfs/scripts/build-shell-vfs-image.ts b/images/vfs/scripts/build-shell-vfs-image.ts index b3438983f..696b22be0 100644 --- a/images/vfs/scripts/build-shell-vfs-image.ts +++ b/images/vfs/scripts/build-shell-vfs-image.ts @@ -86,15 +86,15 @@ async function main() { presentation: kmsPresentation("/usr/local/bin/modeset"), }, love: { - presentation: kmsPresentation(LOVE_COMMAND), + presentation: kmsPresentation(LOVE_COMMAND, { connectorMode: { width: 960, height: 540 }, fit: "stretch" }), guide: loveGuide(), }, bytepath: { - presentation: kmsPresentation(BYTEPATH_COMMAND), + presentation: kmsPresentation(BYTEPATH_COMMAND, { connectorMode: { width: 480, height: 270 }, fit: "stretch" }), guide: bytepathGuide(), }, snkrx: { - presentation: kmsPresentation(SNKRX_COMMAND), + presentation: kmsPresentation(SNKRX_COMMAND, { connectorMode: { width: 960, height: 540 }, fit: "stretch" }), guide: snkrxGuide(), }, }, @@ -118,13 +118,21 @@ function populateModesetRuntime(fs: MemoryFileSystem): void { writeVfsBinary(fs, "/usr/local/bin/modeset", new Uint8Array(modesetBytes), 0o755); } -function kmsPresentation(autoCommand: string): DemoPresentationConfig { +function kmsPresentation( + autoCommand: string, + options: { connectorMode?: { width: number; height: number }; fit?: "contain" | "stretch" } = {}, +): DemoPresentationConfig { + const kms = Object.keys(options).length > 0 ? { + ...(options.connectorMode ? { connectorMode: options.connectorMode } : {}), + ...(options.fit ? { fit: options.fit } : {}), + } : undefined; return { bootPrimary: "syslog", runningPrimary: ["kms", "terminal", "syslog"], terminalAccess: "drawer", internalsAccess: "drawer", autoCommand, + ...(kms ? { kms } : {}), }; } diff --git a/libc/glue/libglesv2_stub.c b/libc/glue/libglesv2_stub.c index 5f978be39..34988b329 100644 --- a/libc/glue/libglesv2_stub.c +++ b/libc/glue/libglesv2_stub.c @@ -1009,11 +1009,8 @@ void glGenerateMipmap(GLenum target) { EMIT_END() } -static uint32_t pixel_data_len(GLsizei width, GLsizei height, - GLenum format, GLenum type) { - if (width <= 0 || height <= 0) return 0; +static uint32_t pixel_component_count(GLenum format) { uint32_t channels = 0; - uint32_t bytes_per_channel = 0; switch (format) { case GL_ALPHA: case GL_LUMINANCE: @@ -1031,24 +1028,64 @@ static uint32_t pixel_data_len(GLsizei width, GLsizei height, default: return 0; } + return channels; +} + +static uint32_t pixel_bytes_per_pixel(GLenum format, GLenum type) { + uint32_t channels = pixel_component_count(format); + uint32_t bytes_per_channel = 0; + if (channels == 0) return 0; switch (type) { case GL_UNSIGNED_BYTE: bytes_per_channel = 1; break; + case GL_UNSIGNED_SHORT: +#ifdef GL_HALF_FLOAT + case GL_HALF_FLOAT: +#endif + bytes_per_channel = 2; + break; case GL_UNSIGNED_SHORT_5_6_5: case GL_UNSIGNED_SHORT_4_4_4_4: case GL_UNSIGNED_SHORT_5_5_5_1: channels = 1; bytes_per_channel = 2; break; + case GL_UNSIGNED_INT: case GL_FLOAT: bytes_per_channel = 4; break; +#ifdef GL_UNSIGNED_INT_2_10_10_10_REV + case GL_UNSIGNED_INT_2_10_10_10_REV: + channels = 1; + bytes_per_channel = 4; + break; +#endif +#ifdef GL_UNSIGNED_INT_10F_11F_11F_REV + case GL_UNSIGNED_INT_10F_11F_11F_REV: + channels = 1; + bytes_per_channel = 4; + break; +#endif +#ifdef GL_UNSIGNED_INT_24_8 + case GL_UNSIGNED_INT_24_8: + channels = 1; + bytes_per_channel = 4; + break; +#endif default: return 0; } + return channels * bytes_per_channel; +} + +static uint32_t pixel_data_len(GLsizei width, GLsizei height, + GLenum format, GLenum type) { + if (width <= 0 || height <= 0) return 0; + uint32_t bpp = pixel_bytes_per_pixel(format, type); + if (bpp == 0) return 0; uint64_t len = (uint64_t)(uint32_t)width * (uint64_t)(uint32_t)height * - (uint64_t)channels * (uint64_t)bytes_per_channel; + (uint64_t)bpp; if (len > UINT32_MAX) return 0; return (uint32_t)len; } @@ -1475,24 +1512,58 @@ GLenum glCheckFramebufferStatus(GLenum target) { void glReadPixels(GLint x, GLint y, GLsizei width, GLsizei height, GLenum format, GLenum type, void *pixels) { if (!pixels || width <= 0 || height <= 0) return; - /* Bytes-per-pixel sizing for the combinations this stub supports. - * Extend when a demo needs another (format,type) pair. */ - uint32_t bpp = 4; - if (format == GL_RGB && type == GL_UNSIGNED_BYTE) bpp = 3; - if (format == GL_RGBA && type == GL_FLOAT) bpp = 16; - if (format == GL_RGB && type == GL_FLOAT) bpp = 12; - uint32_t out_len = (uint32_t)width * (uint32_t)height * bpp; - uint8_t in[24]; - int32_t xi = x, yi = y; - int32_t wi = width, hi = height; - uint32_t fmt = (uint32_t)format, t = (uint32_t)type; - memcpy(in, &xi, 4); - memcpy(in + 4, &yi, 4); - memcpy(in + 8, &wi, 4); - memcpy(in + 12, &hi, 4); - memcpy(in + 16, &fmt, 4); - memcpy(in + 20, &t, 4); - (void)_wpk_gl_query_into(QOP_READ_PIXELS, in, sizeof in, pixels, out_len); + uint32_t bpp = pixel_bytes_per_pixel(format, type); + if (bpp == 0) return; + const uint32_t max_query_out_len = 64u * 1024u; + uint32_t row_len = (uint32_t)width * bpp; + uint8_t *dst = (uint8_t *)pixels; + + for (int32_t yoff = 0; yoff < height;) { + uint32_t rows = row_len > 0 ? max_query_out_len / row_len : 0; + if (rows == 0) rows = 1; + if (rows > (uint32_t)(height - yoff)) rows = (uint32_t)(height - yoff); + + if (row_len <= max_query_out_len) { + uint8_t in[24]; + int32_t xi = x, yi = y + yoff; + int32_t wi = width, hi = (int32_t)rows; + uint32_t fmt = (uint32_t)format, t = (uint32_t)type; + uint32_t len = row_len * rows; + memcpy(in, &xi, 4); + memcpy(in + 4, &yi, 4); + memcpy(in + 8, &wi, 4); + memcpy(in + 12, &hi, 4); + memcpy(in + 16, &fmt, 4); + memcpy(in + 20, &t, 4); + if (_wpk_gl_query_into(QOP_READ_PIXELS, in, sizeof in, + dst + (uint32_t)yoff * row_len, len) != 0) + return; + yoff += (int32_t)rows; + continue; + } + + uint32_t max_cols = max_query_out_len / bpp; + if (max_cols == 0) return; + for (int32_t xoff = 0; xoff < width; xoff += (int32_t)max_cols) { + uint32_t cols = max_cols; + if (cols > (uint32_t)(width - xoff)) cols = (uint32_t)(width - xoff); + uint8_t in[24]; + int32_t xi = x + xoff, yi = y + yoff; + int32_t wi = (int32_t)cols, hi = 1; + uint32_t fmt = (uint32_t)format, t = (uint32_t)type; + uint32_t len = cols * bpp; + memcpy(in, &xi, 4); + memcpy(in + 4, &yi, 4); + memcpy(in + 8, &wi, 4); + memcpy(in + 12, &hi, 4); + memcpy(in + 16, &fmt, 4); + memcpy(in + 20, &t, 4); + if (_wpk_gl_query_into(QOP_READ_PIXELS, in, sizeof in, + dst + (uint32_t)yoff * row_len + (uint32_t)xoff * bpp, len) != 0) + return; + } + yoff++; + } } void glGetShaderiv(GLuint shader, GLenum pname, GLint *params) { diff --git a/packages/registry/love/README.md b/packages/registry/love/README.md index 8b971821b..424d60ef2 100644 --- a/packages/registry/love/README.md +++ b/packages/registry/love/README.md @@ -29,15 +29,23 @@ The upstream LÖVE 11.5 source is still pinned and fetched by the build script for the port baseline and bundled libraries such as `lodepng`, but the Kandelo backend replaces the upstream SDL presentation path with the kernel's direct-rendering surface. Lua is provided by the separate `lua` registry -package and linked as `liblua.a`. +package and linked as `liblua.a`. The native backend reads `love.conf` before +opening the KMS presenter, preserves the advertised connector mode for +fullscreen/window queries, allocates scanout buffers at the game-requested +window size, and leaves browser-side upscaling to the Kandelo KMS surface. +The browser shell image declares virtual KMS connector modes for the LOVE +profiles through `/etc/kandelo/demo.json`: the gallery and SNKRX use 960x540, +while BYTEPATH uses its original 480x270 target. The browser canvas scales +those scanouts to the available Kandelo UI size without changing the mode seen +by the game. The LOVE game profiles request stretched KMS presentation so the +raw target-resolution scanout fills the available Kandelo surface, matching +the games' fullscreen behavior. The BYTEPATH staging step pins upstream `a327ex/BYTEPATH` and keeps the MIT game code plus permissive Lua dependencies needed for gameplay. It omits the bundled Windows runtime, tutorial archive, GPL windfield dependency, and audio assets; Kandelo supplies small compatibility shims for the omitted runtime -pieces. The packaged demo also starts BYTEPATH's in-game `glitch` option below -the upstream maximum so the authored RGB-split effect remains readable on the -1080p KMS surface; the shaders and renderer path are otherwise unchanged. +pieces. The SNKRX staging step pins upstream `a327ex/SNKRX` and keeps the MIT game code. Upstream notes that assets have separate licenses, so Kandelo omits the diff --git a/packages/registry/love/build-love.sh b/packages/registry/love/build-love.sh index e130266c7..fddf7cf03 100755 --- a/packages/registry/love/build-love.sh +++ b/packages/registry/love/build-love.sh @@ -455,11 +455,6 @@ find "$BYTEPATH_SRC" -mindepth 1 -maxdepth 1 \ ! -name 'love' \ -exec cp -R {} "$BYTEPATH_BUILD"/ \; perl -0pi -e "s/^Steam = require 'libraries\\/steamworks'\nif type\(Steam\) == 'boolean' then Steam = nil end/Steam = nil/m" "$BYTEPATH_BUILD/main.lua" -for patch in "$HERE"/game-patches/bytepath/*.patch; do - [ -e "$patch" ] || continue - echo "==> Applying BYTEPATH demo patch $(basename "$patch")..." - (cd "$BYTEPATH_BUILD" && patch -p1 < "$patch") -done # SNKRX is also packaged from its upstream game tree with its real assets and # rendering code. Steamworks is an external native SDK boundary, so only that diff --git a/packages/registry/love/game-patches/bytepath/0001-kandelo-demo-effect-defaults.patch b/packages/registry/love/game-patches/bytepath/0001-kandelo-demo-effect-defaults.patch deleted file mode 100644 index 527211770..000000000 --- a/packages/registry/love/game-patches/bytepath/0001-kandelo-demo-effect-defaults.patch +++ /dev/null @@ -1,16 +0,0 @@ -diff --git a/globals.lua b/globals.lua -index 2a2e165..f0dce25 100644 ---- a/globals.lua -+++ b/globals.lua -@@ -66,7 +66,9 @@ function setDefaultGlobals(opts) - display = 1 - sx, sy = 2, 2 - screen_shake = 10 - distortion = 5 -- glitch = 10 -+ -- Keep BYTEPATH's authored RGB glitch visible without starting the -+ -- high-resolution Kandelo demo at the game's maximum effect strength. -+ glitch = 3 - achievements = {} - high_score = 0 - end diff --git a/packages/registry/love/patches/0004-kandelo-native-renderer-shader-compat.patch b/packages/registry/love/patches/0004-kandelo-native-renderer-shader-compat.patch index 63c8b2784..ee040dfae 100644 --- a/packages/registry/love/patches/0004-kandelo-native-renderer-shader-compat.patch +++ b/packages/registry/love/patches/0004-kandelo-native-renderer-shader-compat.patch @@ -462,19 +462,26 @@ diff --git a/src/modules/graphics/wrap_GraphicsShader.lua b/src/modules/graphics index daf11ea..615df0c 100644 --- a/src/modules/graphics/wrap_GraphicsShader.lua +++ b/src/modules/graphics/wrap_GraphicsShader.lua -@@ -315,6 +315,16 @@ local function getLanguageTarget(code) +@@ -241,3 +241,7 @@ GLSL.PIXEL = { + #ifdef GL_ES +- precision mediump float; ++#ifdef GL_FRAGMENT_PRECISION_HIGH ++ precision highp float; ++#else ++ precision mediump float; ++#endif + #endif +@@ -315,6 +315,14 @@ local function getLanguageTarget(code) end local function createShaderStageCode(stage, code, lang, gles, glsl1on3, gammacorrect, custom, multicanvas) -+ if gles then -+ code = code:gsub("([^;\n]*%f[%w_]extern%s+[^;\n]-)%s*=%s*([^;\n]*);", function(prefix, value) -+ local name = prefix:match("([%a_][%w_]*)%s*(%[[^%]]+%])?%s*$") -+ if name then -+ return "// LOVE_KANDELO_UNIFORM_INIT " .. name .. " " .. value .. "\n" .. prefix .. ";" -+ end -+ return prefix .. ";" -+ end) -+ end ++ code = code:gsub("([^;\n]*%f[%w_]extern%s+[^;\n]-)%s*=%s*([^;\n]*);", function(prefix, value) ++ local name = prefix:match("([%a_][%w_]*)%s*(%[[^%]]+%])?%s*$") ++ if name then ++ return "// LOVE_KANDELO_UNIFORM_INIT " .. name .. " " .. value .. "\n" .. prefix .. ";" ++ end ++ return prefix .. ";" ++ end) + stage = stage:upper() local lines = { diff --git a/packages/registry/love/src/kandelo_native.cpp b/packages/registry/love/src/kandelo_native.cpp index b8be8e3a4..61b56d91f 100644 --- a/packages/registry/love/src/kandelo_native.cpp +++ b/packages/registry/love/src/kandelo_native.cpp @@ -53,8 +53,10 @@ struct NativeState { std::string root; int width = 960; int height = 540; - int pixelWidth = 1920; - int pixelHeight = 1080; + int pixelWidth = 960; + int pixelHeight = 540; + int desktopWidth = 960; + int desktopHeight = 540; SwapCallback swap = nullptr; }; @@ -368,8 +370,10 @@ class KandeloFilesystem final : public love::filesystem::Filesystem { class KandeloWindow final : public love::window::Window { public: - KandeloWindow(int width, int height, int pixelWidth, int pixelHeight) - : width(width), height(height), pixelWidth(pixelWidth), pixelHeight(pixelHeight) { + KandeloWindow(int width, int height, int pixelWidth, int pixelHeight, + int desktopWidth, int desktopHeight) + : width(width), height(height), pixelWidth(pixelWidth), pixelHeight(pixelHeight), + desktopWidth(desktopWidth), desktopHeight(desktopHeight) { settings.refreshrate = 60.0; } @@ -380,6 +384,8 @@ class KandeloWindow final : public love::window::Window { bool setWindow(int width, int height, love::window::WindowSettings *settings) override { if (width > 0) this->width = width; if (height > 0) this->height = height; + pixelWidth = this->width; + pixelHeight = this->height; if (settings != nullptr) this->settings = *settings; if (this->settings.refreshrate <= 0.0) this->settings.refreshrate = 60.0; kandelo_love_set_native_window_size(this->width, this->height); @@ -413,6 +419,8 @@ class KandeloWindow final : public love::window::Window { bool onSizeChanged(int width, int height) override { this->width = width; this->height = height; + pixelWidth = this->width; + pixelHeight = this->height; kandelo_love_set_native_window_size(this->width, this->height); return true; } @@ -421,11 +429,11 @@ class KandeloWindow final : public love::window::Window { const char *getDisplayName(int) const override { return "Kandelo"; } DisplayOrientation getDisplayOrientation(int) const override { return ORIENTATION_LANDSCAPE; } std::vector getFullscreenSizes(int) const override { - return {{pixelWidth, pixelHeight}, {width, height}}; + return {{desktopWidth, desktopHeight}, {pixelWidth, pixelHeight}, {width, height}}; } void getDesktopDimensions(int, int &width, int &height) const override { - width = pixelWidth; - height = pixelHeight; + width = desktopWidth; + height = desktopHeight; } void setPosition(int x, int y, int displayindex) override { posX = x; @@ -514,8 +522,10 @@ class KandeloWindow final : public love::window::Window { love::graphics::Graphics *graphics = nullptr; int width = 960; int height = 540; - int pixelWidth = 1920; - int pixelHeight = 1080; + int pixelWidth = 960; + int pixelHeight = 540; + int desktopWidth = 960; + int desktopHeight = 540; int posX = 0; int posY = 0; int display = 0; @@ -586,7 +596,8 @@ bool installWindow() { if (love::Module::getInstance(love::Module::M_WINDOW) != nullptr) return true; love::Module::registerInstance(new KandeloWindow(gNative.width, gNative.height, - gNative.pixelWidth, gNative.pixelHeight)); + gNative.pixelWidth, gNative.pixelHeight, + gNative.desktopWidth, gNative.desktopHeight)); return true; } @@ -668,12 +679,15 @@ uint64 Channel::push(const Variant &) { extern "C" bool kandelo_love_register_native_renderer(lua_State *L, const char *root, int width, int height, int pixelWidth, int pixelHeight, + int desktopWidth, int desktopHeight, SwapCallback swap) { gNative.root = root ? root : "."; gNative.width = width > 0 ? width : 960; gNative.height = height > 0 ? height : 540; gNative.pixelWidth = pixelWidth > 0 ? pixelWidth : gNative.width; gNative.pixelHeight = pixelHeight > 0 ? pixelHeight : gNative.height; + gNative.desktopWidth = desktopWidth > 0 ? desktopWidth : gNative.pixelWidth; + gNative.desktopHeight = desktopHeight > 0 ? desktopHeight : gNative.pixelHeight; gNative.swap = swap; try { diff --git a/packages/registry/love/src/lovefb.cpp b/packages/registry/love/src/lovefb.cpp index 63ac19c1c..035b6373e 100644 --- a/packages/registry/love/src/lovefb.cpp +++ b/packages/registry/love/src/lovefb.cpp @@ -60,14 +60,13 @@ extern "C" { extern "C" bool kandelo_love_register_native_renderer(lua_State *L, const char *root, int width, int height, int pixelWidth, int pixelHeight, + int desktopWidth, int desktopHeight, bool (*swap)()); namespace { constexpr int kLogicalWidth = 960; constexpr int kLogicalHeight = 540; -constexpr int kScanoutWidth = 1920; -constexpr int kScanoutHeight = 1080; constexpr int kKmsBoCount = 2; constexpr double kPi = 3.14159265358979323846; @@ -236,6 +235,8 @@ struct Runtime { uint32_t kmsCrtcId = 0; uint32_t kmsConnId = 0; drmModeModeInfo kmsMode{}; + int desktopW = kLogicalWidth; + int desktopH = kLogicalHeight; int kmsCurrentFb = 0; EGLDisplay eglDisplay = EGL_NO_DISPLAY; EGLContext eglContext = EGL_NO_CONTEXT; @@ -296,6 +297,49 @@ struct Runtime { Runtime G; volatile sig_atomic_t gRunning = 1; +int scanoutWidth() { + return std::max(1, G.windowW); +} + +int scanoutHeight() { + return std::max(1, G.windowH); +} + +uint16_t clampModeU16(int value) { + return uint16_t(std::max(1, std::min(0xffff, value))); +} + +void setKmsModeDimensions(drmModeModeInfo &mode, int width, int height) { + const int w = std::max(1, width); + const int h = std::max(1, height); + mode.hdisplay = clampModeU16(w); + mode.hsync_start = clampModeU16(w + 16); + mode.hsync_end = clampModeU16(w + 48); + mode.htotal = clampModeU16(w + 160); + mode.hskew = 0; + mode.vdisplay = clampModeU16(h); + mode.vsync_start = clampModeU16(h + 3); + mode.vsync_end = clampModeU16(h + 8); + mode.vtotal = clampModeU16(h + 45); + mode.vscan = 0; + mode.vrefresh = mode.vrefresh > 0 ? mode.vrefresh : 60; + mode.clock = uint32_t(std::max(1.0, std::round(double(mode.htotal) * double(mode.vtotal) * + double(mode.vrefresh) / 1000.0))); + std::snprintf(mode.name, sizeof(mode.name), "%dx%d", w, h); +} + +void resizeSoftwareScreen(int width, int height) { + const int w = std::max(1, width); + const int h = std::max(1, height); + if (G.screen.w == w && G.screen.h == h) return; + G.screen = Surface{w, h, std::vector(size_t(w) * size_t(h), 0)}; + if (G.target == nullptr || G.target->bgra.empty()) G.target = &G.screen; + G.sw = w; + G.sh = h; + G.mouseX = std::max(0, std::min(G.mouseX, w - 1)); + G.mouseY = std::max(0, std::min(G.mouseY, h - 1)); +} + const char *MT_IMAGE = "lovefb.Image"; const char *MT_IMAGEDATA = "lovefb.ImageData"; const char *MT_CANVAS = "lovefb.Canvas"; @@ -1287,6 +1331,8 @@ bool buildPresenterProgram() { } int setupKmsGl() { + const int scanoutW = scanoutWidth(); + const int scanoutH = scanoutHeight(); G.drmFd = open("/dev/dri/card0", O_RDWR | O_NONBLOCK); if (G.drmFd < 0) { perror("open /dev/dri/card0"); @@ -1317,6 +1363,9 @@ int setupKmsGl() { return 1; } G.kmsMode = conn->modes[0]; + G.desktopW = std::max(1, int(G.kmsMode.hdisplay)); + G.desktopH = std::max(1, int(G.kmsMode.vdisplay)); + setKmsModeDimensions(G.kmsMode, scanoutW, scanoutH); drmModeFreeConnector(conn); drmModeFreeResources(res); @@ -1328,7 +1377,7 @@ int setupKmsGl() { } for (int i = 0; i < kKmsBoCount; ++i) { - G.kmsBos[i] = gbm_bo_create(G.gbm, kScanoutWidth, kScanoutHeight, + G.kmsBos[i] = gbm_bo_create(G.gbm, scanoutW, scanoutH, GBM_FORMAT_XRGB8888, GBM_BO_USE_SCANOUT); if (!G.kmsBos[i]) { perror("gbm_bo_create"); @@ -1341,7 +1390,7 @@ int setupKmsGl() { uint32_t handles[4] = {handle, 0, 0, 0}; uint32_t pitches[4] = {stride, 0, 0, 0}; uint32_t offsets[4] = {0, 0, 0, 0}; - if (drmModeAddFB2(G.drmFd, kScanoutWidth, kScanoutHeight, + if (drmModeAddFB2(G.drmFd, scanoutW, scanoutH, DRM_FORMAT_XRGB8888, handles, pitches, offsets, &G.kmsFbIds[i], 0) != 0) { perror("drmModeAddFB2"); @@ -1422,7 +1471,7 @@ int setupKmsGl() { glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); glPixelStorei(GL_UNPACK_ALIGNMENT, 4); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, kLogicalWidth, kLogicalHeight, 0, + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, G.screen.w, G.screen.h, 0, GL_RGBA, GL_UNSIGNED_BYTE, G.screen.bgra.data()); const GLfloat verts[] = { @@ -1434,7 +1483,7 @@ int setupKmsGl() { glGenBuffers(1, &G.glBuffer); glBindBuffer(GL_ARRAY_BUFFER, G.glBuffer); glBufferData(GL_ARRAY_BUFFER, sizeof(verts), verts, GL_STATIC_DRAW); - glViewport(0, 0, kScanoutWidth, kScanoutHeight); + glViewport(0, 0, scanoutW, scanoutH); glDisable(GL_DEPTH_TEST); glDisable(GL_STENCIL_TEST); glDisable(GL_BLEND); @@ -1466,13 +1515,74 @@ int kmsPageflipWait() { return 0; } +int resizeKmsScanout(int width, int height) { + if (G.presenter != Presenter::KmsGl || G.drmFd < 0 || G.gbm == nullptr) return 0; + + const int w = std::max(1, width); + const int h = std::max(1, height); + if (G.kmsMode.hdisplay == w && G.kmsMode.vdisplay == h) return 0; + + gbm_bo *newBos[kKmsBoCount]{}; + uint32_t newFbIds[kKmsBoCount]{}; + + for (int i = 0; i < kKmsBoCount; ++i) { + newBos[i] = gbm_bo_create(G.gbm, w, h, GBM_FORMAT_XRGB8888, GBM_BO_USE_SCANOUT); + if (!newBos[i]) { + perror("gbm_bo_create resize"); + for (int j = 0; j < i; ++j) { + if (newFbIds[j]) drmModeRmFB(G.drmFd, newFbIds[j]); + if (newBos[j]) gbm_bo_destroy(newBos[j]); + } + return 1; + } + + uint32_t handle = gbm_bo_get_handle(newBos[i]).u32; + uint32_t stride = gbm_bo_get_stride(newBos[i]); + uint32_t handles[4] = {handle, 0, 0, 0}; + uint32_t pitches[4] = {stride, 0, 0, 0}; + uint32_t offsets[4] = {0, 0, 0, 0}; + if (drmModeAddFB2(G.drmFd, w, h, DRM_FORMAT_XRGB8888, handles, pitches, offsets, + &newFbIds[i], 0) != 0) { + perror("drmModeAddFB2 resize"); + for (int j = 0; j <= i; ++j) { + if (newFbIds[j]) drmModeRmFB(G.drmFd, newFbIds[j]); + if (newBos[j]) gbm_bo_destroy(newBos[j]); + } + return 1; + } + } + + drmModeModeInfo newMode = G.kmsMode; + setKmsModeDimensions(newMode, w, h); + if (drmModeSetCrtc(G.drmFd, G.kmsCrtcId, newFbIds[0], 0, 0, &G.kmsConnId, 1, + &newMode) != 0) { + perror("drmModeSetCrtc resize"); + for (int i = 0; i < kKmsBoCount; ++i) { + if (newFbIds[i]) drmModeRmFB(G.drmFd, newFbIds[i]); + if (newBos[i]) gbm_bo_destroy(newBos[i]); + } + return 1; + } + + for (int i = 0; i < kKmsBoCount; ++i) { + if (G.kmsFbIds[i]) drmModeRmFB(G.drmFd, G.kmsFbIds[i]); + if (G.kmsBos[i]) gbm_bo_destroy(G.kmsBos[i]); + G.kmsBos[i] = newBos[i]; + G.kmsFbIds[i] = newFbIds[i]; + } + G.kmsMode = newMode; + G.kmsCurrentFb = 0; + glViewport(0, 0, w, h); + return 0; +} + void flushKmsGl() { if (G.presenter != Presenter::KmsGl) return; - glViewport(0, 0, kScanoutWidth, kScanoutHeight); + glViewport(0, 0, scanoutWidth(), scanoutHeight()); glUseProgram(G.glProgram); glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_2D, G.glTexture); - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, kLogicalWidth, kLogicalHeight, + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, G.screen.w, G.screen.h, GL_RGBA, GL_UNSIGNED_BYTE, G.screen.bgra.data()); glUniform1i(G.glSamplerLoc, 0); glBindBuffer(GL_ARRAY_BUFFER, G.glBuffer); @@ -1665,8 +1775,8 @@ void pollInput() { int dx = int(int8_t(pkt[1])); int dy = -int(int8_t(pkt[2])); if (G.presenter == Presenter::KmsGl) { - G.mouseRemainderX += double(dx) * double(G.windowW) / double(kScanoutWidth); - G.mouseRemainderY += double(dy) * double(G.windowH) / double(kScanoutHeight); + G.mouseRemainderX += double(dx) * double(G.windowW) / double(scanoutWidth()); + G.mouseRemainderY += double(dy) * double(G.windowH) / double(scanoutHeight()); dx = int(std::trunc(G.mouseRemainderX)); dy = int(std::trunc(G.mouseRemainderY)); G.mouseRemainderX -= double(dx); @@ -2587,7 +2697,7 @@ int l_timer_sleep(lua_State *L) { usleep(useconds_t(luaL_checknumber(L, 1) * 100 int l_timer_step(lua_State *L) { double t = nowSeconds(); if (G.last == 0.0) G.last = t; - G.dt = std::min(0.1, std::max(0.0, t - G.last)); + G.dt = std::max(0.0, t - G.last); G.last = t; G.fpsFrames++; if (G.fpsT0 == 0.0) G.fpsT0 = t; @@ -2607,7 +2717,7 @@ int l_window_setTitle(lua_State *L) { int l_window_getFullscreenModes(lua_State *L) { lua_newtable(L); - int modes[2][2] = {{G.windowW, G.windowH}, {kScanoutWidth, kScanoutHeight}}; + int modes[2][2] = {{G.windowW, G.windowH}, {scanoutWidth(), scanoutHeight()}}; for (int i = 0; i < 2; ++i) { lua_newtable(L); lua_pushinteger(L, modes[i][0]); lua_setfield(L, -2, "width"); @@ -2620,6 +2730,7 @@ int l_window_getFullscreenModes(lua_State *L) { int l_window_setMode(lua_State *L) { G.windowW = std::max(1, int(luaL_checkinteger(L, 1))); G.windowH = std::max(1, int(luaL_checkinteger(L, 2))); + resizeSoftwareScreen(G.windowW, G.windowH); G.mouseX = clampInt(G.mouseX, 0, G.windowW - 1); G.mouseY = clampInt(G.mouseY, 0, G.windowH - 1); return 0; @@ -2635,8 +2746,8 @@ int l_window_getMode(lua_State *L) { } int l_window_setIcon(lua_State *) { return 0; } int l_window_getDesktopDimensions(lua_State *L) { - lua_pushinteger(L, kScanoutWidth); - lua_pushinteger(L, kScanoutHeight); + lua_pushinteger(L, scanoutWidth()); + lua_pushinteger(L, scanoutHeight()); return 2; } int l_window_getDisplayCount(lua_State *L) { lua_pushinteger(L, 1); return 1; } @@ -3430,9 +3541,12 @@ int runLove(lua_State *L) { configureLuaPath(L); installLoveHandlers(L); maybeRunConf(L); + resizeSoftwareScreen(G.windowW, G.windowH); + if (openPresenter() != 0) return 1; if (G.presenter == Presenter::KmsGl) { G.nativeRenderer = kandelo_love_register_native_renderer( - L, G.root.c_str(), G.windowW, G.windowH, kScanoutWidth, kScanoutHeight, + L, G.root.c_str(), G.windowW, G.windowH, scanoutWidth(), scanoutHeight(), + G.desktopW, G.desktopH, nativeSwapBuffers); if (G.nativeRenderer) { fprintf(stderr, "love: using upstream LÖVE OpenGL renderer on Kandelo KMS/EGL\n"); @@ -3462,7 +3576,7 @@ int runLove(lua_State *L) { G.start = G.last = G.fpsT0 = nowSeconds(); while (gRunning) { double t = nowSeconds(); - G.dt = std::min(0.1, std::max(0.0, t - G.last)); + G.dt = std::max(0.0, t - G.last); G.last = t; G.fpsFrames++; if (t - G.fpsT0 >= 1.0) { @@ -3535,6 +3649,7 @@ extern "C" void kandelo_love_set_native_window_size(int width, int height) { } G.mouseX = clampInt(G.mouseX, 0, std::max(1, G.windowW) - 1); G.mouseY = clampInt(G.mouseY, 0, std::max(1, G.windowH) - 1); + if (resizeKmsScanout(G.windowW, G.windowH) != 0) gRunning = 0; } int main(int argc, char **argv) { @@ -3545,16 +3660,11 @@ int main(int argc, char **argv) { signal(SIGTERM, signalHandler); signal(SIGINT, signalHandler); - if (openPresenter() != 0) return 1; configureRawStdin(); G.savedStdinFlags = fcntl(STDIN_FILENO, F_GETFL, 0); if (G.savedStdinFlags >= 0) fcntl(STDIN_FILENO, F_SETFL, G.savedStdinFlags | O_NONBLOCK); G.miceFd = open("/dev/input/mice", O_RDONLY | O_NONBLOCK); - clearSurface(G.screen, Color{14, 18, 24, 255}); - drawText(G.screen, G.presenter == Presenter::KmsGl ? "LOVE KMS/EGL/GLES runtime" : "LOVE framebuffer runtime", 20, 20); - presentFrame(); - lua_State *L = luaL_newstate(); lua_atpanic(L, luaPanic); luaL_openlibs(L); diff --git a/web-libs/kandelo-session/src/demo-config.ts b/web-libs/kandelo-session/src/demo-config.ts index f6d7773a3..4218c0880 100644 --- a/web-libs/kandelo-session/src/demo-config.ts +++ b/web-libs/kandelo-session/src/demo-config.ts @@ -1,5 +1,7 @@ import type { DemoPresentation, + KmsPresentationOptions, + KmsSurfaceFit, PrimarySurface, } from "./kernel-host"; @@ -11,6 +13,7 @@ export interface DemoPresentationConfig { terminalAccess: DemoPresentation["terminalAccess"]; internalsAccess: DemoPresentation["internalsAccess"]; autoCommand?: string; + kms?: KmsPresentationOptions; } export interface DemoAssetConfig { @@ -115,11 +118,13 @@ const PRIMARY_SURFACES = new Set([ "kms", ]); const ACCESS_MODES = new Set(["primary", "drawer", "side"]); +const KMS_SURFACE_FITS = new Set(["contain", "stretch"]); const ACTION_KINDS = new Set([ "terminal.run", "terminal.write", "web.wordpressLogin", ]); +const MAX_KMS_CONNECTOR_MODE_DIMENSION = 16_384; export function parseKandeloDemoConfig(text: string): KandeloDemoConfig | null { const value: unknown = JSON.parse(text); @@ -194,9 +199,52 @@ function normalizePresentationConfig(config: unknown): DemoPresentation { terminalAccess: accessMode(config.terminalAccess, "terminalAccess"), internalsAccess: accessMode(config.internalsAccess, "internalsAccess"), ...(typeof config.autoCommand === "string" ? { autoCommand: config.autoCommand } : {}), + ...normalizeKmsPresentationOptions(config.kms), }; } +function normalizeKmsPresentationOptions(value: unknown): { kms?: KmsPresentationOptions } { + if (value === undefined) return {}; + if (!isRecord(value)) { + throw new Error("presentation.kms must be an object"); + } + const kms: KmsPresentationOptions = {}; + if (value.connectorMode !== undefined) { + if (!isRecord(value.connectorMode)) { + throw new Error("presentation.kms.connectorMode must be an object"); + } + kms.connectorMode = { + width: kmsModeDimension( + value.connectorMode.width, + "presentation.kms.connectorMode.width", + ), + height: kmsModeDimension( + value.connectorMode.height, + "presentation.kms.connectorMode.height", + ), + }; + } + if (value.fit !== undefined) { + if (typeof value.fit !== "string" || !KMS_SURFACE_FITS.has(value.fit as KmsSurfaceFit)) { + throw new Error("presentation.kms.fit must be one of: contain, stretch"); + } + kms.fit = value.fit as KmsSurfaceFit; + } + return { kms }; +} + +function kmsModeDimension(value: unknown, field: string): number { + if ( + typeof value !== "number" || + !Number.isInteger(value) || + value < 1 || + value > MAX_KMS_CONNECTOR_MODE_DIMENSION + ) { + throw new Error(`${field} must be an integer between 1 and ${MAX_KMS_CONNECTOR_MODE_DIMENSION}`); + } + return value; +} + function normalizeAssets(value: unknown, field: string): DemoAssetConfig[] { if (value === undefined) return []; if (!Array.isArray(value)) { diff --git a/web-libs/kandelo-session/src/demo-guides.ts b/web-libs/kandelo-session/src/demo-guides.ts index 06b6ebc0b..3009bbf68 100644 --- a/web-libs/kandelo-session/src/demo-guides.ts +++ b/web-libs/kandelo-session/src/demo-guides.ts @@ -100,16 +100,19 @@ export function builtinDemoPresentation(profileId: string): DemoPresentation | n return { ...genericDemoPresentation("kms"), autoCommand: LOVE_COMMAND, + kms: { connectorMode: { width: 960, height: 540 }, fit: "stretch" }, }; case "bytepath": return { ...genericDemoPresentation("kms"), autoCommand: BYTEPATH_COMMAND, + kms: { connectorMode: { width: 480, height: 270 }, fit: "stretch" }, }; case "snkrx": return { ...genericDemoPresentation("kms"), autoCommand: SNKRX_COMMAND, + kms: { connectorMode: { width: 960, height: 540 }, fit: "stretch" }, }; default: return null; diff --git a/web-libs/kandelo-session/src/kernel-host.ts b/web-libs/kandelo-session/src/kernel-host.ts index a1215a8fe..7eb93e971 100644 --- a/web-libs/kandelo-session/src/kernel-host.ts +++ b/web-libs/kandelo-session/src/kernel-host.ts @@ -136,7 +136,10 @@ export interface KernelLike { crtcId: number, canvas: OffscreenCanvas, stats?: SharedArrayBuffer, - opts?: { mode?: "auto" | "2d" | "webgl2" }, + opts?: { + mode?: "auto" | "2d" | "webgl2"; + connectorMode?: { width: number; height: number }; + }, ): void; /** * Register a stats SAB for `crtcId` without binding a scanout @@ -382,6 +385,18 @@ export type PrimarySurface = "syslog" | "terminal" | "framebuffer" | "web" | "km export type SurfaceAvailability = Record; +export interface KmsConnectorMode { + width: number; + height: number; +} + +export type KmsSurfaceFit = "contain" | "stretch"; + +export interface KmsPresentationOptions { + connectorMode?: KmsConnectorMode; + fit?: KmsSurfaceFit; +} + export interface DemoPresentation { /** * Surface that should dominate while the machine is booting. Most demos use @@ -402,6 +417,8 @@ export interface DemoPresentation { * framebuffer demos so exiting the app returns to the shell command. */ autoCommand?: string; + /** Presentation preferences for the browser KMS scanout surface. */ + kms?: KmsPresentationOptions; } // ── Process lifecycle events ────────────────────────────────────────────── @@ -582,7 +599,10 @@ export interface KernelHost { attachKmsDisplay( canvas: HTMLCanvasElement, crtcId?: number, - opts?: { mode?: "auto" | "2d" | "webgl2" }, + opts?: { + mode?: "auto" | "2d" | "webgl2"; + connectorMode?: { width: number; height: number }; + }, ): KmsDisplayHandle | null; // web preview — service demos can expose an HTTP bridge endpoint. @@ -1562,7 +1582,7 @@ export class LiveKernelHost implements KernelHost { */ private async findPtyRoutingPid(pid: number): Promise { if (this.shellPids.size === 0) return null; - if (this.shellPids.has(pid)) return null; + if (this.shellPids.has(pid)) return pid; try { const procs = await this.enumProcs(); const byPid = new Map(procs.map((p) => [p.pid, p.ppid])); @@ -1724,7 +1744,10 @@ export class LiveKernelHost implements KernelHost { attachKmsDisplay( canvas: HTMLCanvasElement, crtcId: number = 1, - opts: { mode?: "auto" | "2d" | "webgl2" } = { mode: "webgl2" }, + opts: { + mode?: "auto" | "2d" | "webgl2"; + connectorMode?: { width: number; height: number }; + } = { mode: "webgl2" }, ): KmsDisplayHandle | null { if (!this.kernel?.kmsAttachCanvas) return null; if (typeof canvas.transferControlToOffscreen !== "function") return null; @@ -1741,8 +1764,12 @@ export class LiveKernelHost implements KernelHost { // 7 i32 slots × 4 bytes = 28 bytes; align to 64 so atomics are happy. const statsSab = new SharedArrayBuffer(64); const stats = new Int32Array(statsSab); + const connectorMode = opts.connectorMode ?? { + width: canvas.width, + height: canvas.height, + }; const offscreen = canvas.transferControlToOffscreen(); - this.kernel.kmsAttachCanvas(crtcId, offscreen, statsSab, opts); + this.kernel.kmsAttachCanvas(crtcId, offscreen, statsSab, { ...opts, connectorMode }); const kernel = this.kernel; const boundPidListeners = new ListenerSet(); let closed = false; diff --git a/web-libs/kandelo-session/test/kandelo-session.test.ts b/web-libs/kandelo-session/test/kandelo-session.test.ts index 01dd7fef0..de8650075 100644 --- a/web-libs/kandelo-session/test/kandelo-session.test.ts +++ b/web-libs/kandelo-session/test/kandelo-session.test.ts @@ -594,6 +594,96 @@ describe("LiveKernelHost: shell command queue", () => { }); }); +describe("LiveKernelHost: KMS input routing", () => { + function fakeKmsCanvas() { + return { + width: 480, + height: 270, + transferControlToOffscreen() { + return { width: 480, height: 270 }; + }, + } as unknown as HTMLCanvasElement; + } + + it("routes KMS input through the PTY when the KMS master is the PTY session process", async () => { + const encoder = new TextEncoder(); + const ptyWrites: Array<{ pid: number; data: number[] }> = []; + const stdinWrites: Array<{ pid: number; data: number[] }> = []; + + const host = new LiveKernelHost({ + kernel: { + fs: makeFs({ "/etc/passwd": "" }), + nextPid: 100, + spawnFromVfs: async () => ({ pid: 100, exit: new Promise(() => {}) }), + enumProcs: async () => [ + { pid: 100, ppid: 1, uid: 1000, gid: 1000, vsizeBytes: 1024, state: "S", comm: "love", cmdline: "love bytepath" }, + ], + onPtyOutput(_pid: number, callback: (data: Uint8Array) => void) { + callback(encoder.encode("kandelo$ ")); + }, + ptyResize() {}, + ptyWrite(pid: number, data: Uint8Array) { + ptyWrites.push({ pid, data: Array.from(data) }); + }, + appendStdinData(pid: number, data: Uint8Array) { + stdinWrites.push({ pid, data: Array.from(data) }); + }, + kmsAttachCanvas() {}, + getKmsMasterPid: async () => 100, + } as any, + }); + host.setDefaultShell({ + programPath: "/bin/bash", + programBytes: new ArrayBuffer(0), + argv: ["bash", "-l", "-i"], + env: ["PS1=kandelo$ "], + cwd: "/home/user", + }); + + await host.attachPty("/dev/pts/0", { cols: 80, rows: 24 }); + const kms = host.attachKmsDisplay(fakeKmsCanvas(), 1); + expect(kms).not.toBeNull(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + kms!.sendInput(new Uint8Array([46])); + expect(ptyWrites).toEqual([{ pid: 100, data: [46] }]); + expect(stdinWrites).toEqual([]); + kms!.close(); + }); + + it("routes KMS input to direct stdin for standalone KMS processes", async () => { + const ptyWrites: Array<{ pid: number; data: number[] }> = []; + const stdinWrites: Array<{ pid: number; data: number[] }> = []; + + const host = new LiveKernelHost({ + kernel: { + fs: makeFs({ "/etc/passwd": "" }), + nextPid: 1, + appendStdinData(pid: number, data: Uint8Array) { + stdinWrites.push({ pid, data: Array.from(data) }); + }, + ptyWrite(pid: number, data: Uint8Array) { + ptyWrites.push({ pid, data: Array.from(data) }); + }, + kmsAttachCanvas() {}, + getKmsMasterPid: async () => 77, + } as any, + }); + + const kms = host.attachKmsDisplay(fakeKmsCanvas(), 1); + expect(kms).not.toBeNull(); + await Promise.resolve(); + await Promise.resolve(); + + kms!.sendInput(new Uint8Array([46])); + expect(stdinWrites).toEqual([{ pid: 77, data: [46] }]); + expect(ptyWrites).toEqual([]); + kms!.close(); + }); +}); + describe("LiveKernelHost: descriptor", () => { it("getBootDescriptor returns a deep clone — callers can't mutate internal state", () => { const host = new LiveKernelHost({ descriptor: DUMMY_DESCRIPTOR }); @@ -817,6 +907,78 @@ describe("Kandelo demo config", () => { expect(presentation.autoCommand).toContain("fbdoom"); }); + it("resolves KMS presentation connector modes", () => { + const config = parseKandeloDemoConfig(JSON.stringify({ + version: 1, + profiles: { + bytepath: { + presentation: { + bootPrimary: "syslog", + runningPrimary: ["kms", "terminal", "syslog"], + terminalAccess: "drawer", + internalsAccess: "drawer", + autoCommand: "/usr/local/bin/love /usr/local/share/love/BYTEPATH.love", + kms: { + connectorMode: { width: 480, height: 270 }, + fit: "stretch", + }, + }, + }, + }, + })); + expect(config).not.toBeNull(); + + expect(resolveDemoPresentation(config!, "bytepath")?.kms?.connectorMode).toEqual({ + width: 480, + height: 270, + }); + expect(resolveDemoPresentation(config!, "bytepath")?.kms?.fit).toBe("stretch"); + }); + + it("rejects invalid KMS connector mode metadata", () => { + const config = parseKandeloDemoConfig(JSON.stringify({ + version: 1, + profiles: { + bytepath: { + presentation: { + bootPrimary: "syslog", + runningPrimary: ["kms", "terminal", "syslog"], + terminalAccess: "drawer", + internalsAccess: "drawer", + kms: { + connectorMode: { width: 0, height: 270 }, + }, + }, + }, + }, + })); + expect(config).not.toBeNull(); + + expect(() => resolveDemoPresentation(config!, "bytepath")).toThrow("connectorMode.width"); + }); + + it("rejects invalid KMS fit metadata", () => { + const config = parseKandeloDemoConfig(JSON.stringify({ + version: 1, + profiles: { + bytepath: { + presentation: { + bootPrimary: "syslog", + runningPrimary: ["kms", "terminal", "syslog"], + terminalAccess: "drawer", + internalsAccess: "drawer", + kms: { + fit: "cover", + }, + }, + }, + }, + })); + expect(config).not.toBeNull(); + + expect(() => resolveDemoPresentation(config!, "bytepath")).toThrow("presentation.kms.fit"); + }); + it("throws when profile metadata is incomplete", () => { const config = parseKandeloDemoConfig(JSON.stringify({ version: 1, @@ -958,6 +1120,10 @@ describe("Kandelo demo config", () => { runningPrimary: ["framebuffer", "terminal", "syslog"], autoCommand: DOOM_COMMAND, }); + expect(builtinDemoPresentation("bytepath")).toMatchObject({ + runningPrimary: ["kms", "terminal", "syslog"], + kms: { connectorMode: { width: 480, height: 270 }, fit: "stretch" }, + }); expect(builtinDemoAssets("doom")).toEqual([ expect.objectContaining({ path: "/doom1.wad", devCorsProxy: true }),