From ad1fcd8e8414af9aa258e69f31da4ac19f5776f8 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 4 Jul 2026 20:26:16 -0400 Subject: [PATCH 1/6] Render fixed-resolution KMS demos at native target resolution --- .../pages/kandelo/panes/Modeset.tsx | 42 +++++----- .../pages/kandelo/panes/canvasFit.ts | 9 ++- docs/browser-support.md | 4 + packages/registry/love/README.md | 8 +- packages/registry/love/build-love.sh | 5 -- .../0001-kandelo-demo-effect-defaults.patch | 16 ---- packages/registry/love/src/kandelo_native.cpp | 12 ++- packages/registry/love/src/lovefb.cpp | 80 ++++++++++++++----- 8 files changed, 103 insertions(+), 73 deletions(-) delete mode 100644 packages/registry/love/game-patches/bytepath/0001-kandelo-demo-effect-defaults.patch diff --git a/apps/browser-demos/pages/kandelo/panes/Modeset.tsx b/apps/browser-demos/pages/kandelo/panes/Modeset.tsx index 042e0a647..6e7fa10b9 100644 --- a/apps/browser-demos/pages/kandelo/panes/Modeset.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Modeset.tsx @@ -13,15 +13,11 @@ 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 FALLBACK_KMS_FB_W = 1920; +const FALLBACK_KMS_FB_H = 1080; export interface ModesetProps { dragProps?: import("./PaneHead").PaneHeadDragProps; @@ -73,17 +69,11 @@ 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 !== FALLBACK_KMS_FB_W) canvas.width = FALLBACK_KMS_FB_W; + if (canvas.height !== FALLBACK_KMS_FB_H) canvas.height = FALLBACK_KMS_FB_H; let offBound: (() => void) | null = null; try { @@ -125,8 +115,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 || FALLBACK_KMS_FB_W); + const height = s.height > 0 ? s.height : (canvas.height || FALLBACK_KMS_FB_H); return { width, height }; }; const initialFrame = activeFrameSize(); @@ -292,7 +282,13 @@ 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, + FALLBACK_KMS_FB_W / FALLBACK_KMS_FB_H, + scanoutAspect, + ); const statusLabel = hasFrame ? `${stats.width}×${stats.height} · ${stats.commitCount} flips · ${stats.lastFrameUs}µs · ${captureLabel}` : "waiting for PAGE_FLIP"; diff --git a/apps/browser-demos/pages/kandelo/panes/canvasFit.ts b/apps/browser-demos/pages/kandelo/panes/canvasFit.ts index a7512a15f..f73a49d40 100644 --- a/apps/browser-demos/pages/kandelo/panes/canvasFit.ts +++ b/apps/browser-demos/pages/kandelo/panes/canvasFit.ts @@ -4,6 +4,7 @@ export function useFittedCanvasStyle( containerRef: React.RefObject, canvasRef: React.RefObject, fallbackAspect: number, + aspectOverride?: number, ): React.CSSProperties { const [style, setStyle] = React.useState({}); @@ -15,7 +16,7 @@ export function useFittedCanvasStyle( const update = () => { const rect = container.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; - const aspect = canvasAspect(canvas, fallbackAspect); + 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 +48,7 @@ export function useFittedCanvasStyle( mutationObserver.disconnect(); window.removeEventListener("resize", update); }; - }, [canvasRef, containerRef, fallbackAspect]); + }, [aspectOverride, canvasRef, containerRef, fallbackAspect]); return style; } @@ -60,3 +61,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/docs/browser-support.md b/docs/browser-support.md index e0e0171e0..0939314f0 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 browser pane seeds an initial connector mode before a process binds a framebuffer, but the committed KMS FB is authoritative once `MODE_SETCRTC`/`PAGE_FLIP` has run. + ### 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. diff --git a/packages/registry/love/README.md b/packages/registry/love/README.md index 8b971821b..c31833f90 100644 --- a/packages/registry/love/README.md +++ b/packages/registry/love/README.md @@ -29,15 +29,15 @@ 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, allocates scanout buffers at the game-requested +window size, and leaves browser-side upscaling to the Kandelo KMS surface. 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/src/kandelo_native.cpp b/packages/registry/love/src/kandelo_native.cpp index b8be8e3a4..0be41c354 100644 --- a/packages/registry/love/src/kandelo_native.cpp +++ b/packages/registry/love/src/kandelo_native.cpp @@ -53,8 +53,8 @@ struct NativeState { std::string root; int width = 960; int height = 540; - int pixelWidth = 1920; - int pixelHeight = 1080; + int pixelWidth = 960; + int pixelHeight = 540; SwapCallback swap = nullptr; }; @@ -380,6 +380,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 +415,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; } @@ -514,8 +518,8 @@ 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 posX = 0; int posY = 0; int display = 0; diff --git a/packages/registry/love/src/lovefb.cpp b/packages/registry/love/src/lovefb.cpp index 63ac19c1c..31e3211c2 100644 --- a/packages/registry/love/src/lovefb.cpp +++ b/packages/registry/love/src/lovefb.cpp @@ -66,8 +66,6 @@ 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; @@ -296,6 +294,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 +1328,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 +1360,7 @@ int setupKmsGl() { return 1; } G.kmsMode = conn->modes[0]; + setKmsModeDimensions(G.kmsMode, scanoutW, scanoutH); drmModeFreeConnector(conn); drmModeFreeResources(res); @@ -1328,7 +1372,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 +1385,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 +1466,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 +1478,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); @@ -1468,11 +1512,11 @@ int kmsPageflipWait() { 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 +1709,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); @@ -2607,7 +2651,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 +2664,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 +2680,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 +3475,11 @@ 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(), nativeSwapBuffers); if (G.nativeRenderer) { fprintf(stderr, "love: using upstream LÖVE OpenGL renderer on Kandelo KMS/EGL\n"); @@ -3545,16 +3592,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); From f5254478aa181eed99111098ee5add036d7f1adc Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 4 Jul 2026 23:00:20 -0400 Subject: [PATCH 2/6] Preserve KMS connector mode for native fullscreen Keep the browser KMS connector mode separate from the active scanout framebuffer so DRM_GETCONNECTOR continues to report the display mode after the canvas backing store is resized. Resize the OffscreenCanvas to the committed FB on SETCRTC, pass the advertised mode through the host protocols, and have the native LOVE window backend return that mode for fullscreen/window queries. This restores BYTEPATH's native fullscreen sizing: the game can request the advertised 1920x1080 desktop mode while the browser still fits the active KMS surface with CSS. Add a host regression test and update the KMS/LOVE docs for the separate connector-mode and scanout-size contract. --- docs/browser-support.md | 2 +- host/src/browser-kernel-host.ts | 5 +- host/src/browser-kernel-protocol.ts | 5 +- host/src/kernel-worker.ts | 18 ++++- host/src/kernel.ts | 20 +++++- host/src/node-kernel-host.ts | 5 +- host/src/node-kernel-protocol.ts | 5 +- host/test/dri-kms-stats-sab.test.ts | 19 ++++++ packages/registry/love/README.md | 3 +- packages/registry/love/src/kandelo_native.cpp | 22 ++++-- packages/registry/love/src/lovefb.cpp | 68 +++++++++++++++++++ web-libs/kandelo-session/src/kernel-host.ts | 21 ++++-- 12 files changed, 175 insertions(+), 18 deletions(-) diff --git a/docs/browser-support.md b/docs/browser-support.md index 0939314f0..1069b227a 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -119,7 +119,7 @@ connection in any nginx worker. The standalone nginx image runs with ### 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 browser pane seeds an initial connector mode before a process binds a framebuffer, but the committed KMS FB is authoritative once `MODE_SETCRTC`/`PAGE_FLIP` has run. +- The advertised connector mode is tracked separately from the current scanout framebuffer. Browser surfaces seed a 1920×1080 connector mode before transfer 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", …)`. 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/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/packages/registry/love/README.md b/packages/registry/love/README.md index c31833f90..be8633a50 100644 --- a/packages/registry/love/README.md +++ b/packages/registry/love/README.md @@ -30,7 +30,8 @@ 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`. The native backend reads `love.conf` before -opening the KMS presenter, allocates scanout buffers at the game-requested +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 BYTEPATH staging step pins upstream `a327ex/BYTEPATH` and keeps the MIT diff --git a/packages/registry/love/src/kandelo_native.cpp b/packages/registry/love/src/kandelo_native.cpp index 0be41c354..61b56d91f 100644 --- a/packages/registry/love/src/kandelo_native.cpp +++ b/packages/registry/love/src/kandelo_native.cpp @@ -55,6 +55,8 @@ struct NativeState { int height = 540; 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; } @@ -425,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; @@ -520,6 +524,8 @@ class KandeloWindow final : public love::window::Window { int height = 540; int pixelWidth = 960; int pixelHeight = 540; + int desktopWidth = 960; + int desktopHeight = 540; int posX = 0; int posY = 0; int display = 0; @@ -590,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; } @@ -672,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 31e3211c2..a44221b4c 100644 --- a/packages/registry/love/src/lovefb.cpp +++ b/packages/registry/love/src/lovefb.cpp @@ -60,6 +60,7 @@ 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 { @@ -234,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; @@ -1360,6 +1363,8 @@ 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); @@ -1510,6 +1515,67 @@ 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, scanoutWidth(), scanoutHeight()); @@ -3480,6 +3546,7 @@ int runLove(lua_State *L) { if (G.presenter == Presenter::KmsGl) { G.nativeRenderer = kandelo_love_register_native_renderer( 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"); @@ -3582,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) { diff --git a/web-libs/kandelo-session/src/kernel-host.ts b/web-libs/kandelo-session/src/kernel-host.ts index a1215a8fe..6efb0c88e 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 @@ -582,7 +585,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. @@ -1724,7 +1730,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 +1750,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; From 3b8e6f68f16c2add4f674b61a7c4411c74ec4613 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 4 Jul 2026 23:24:28 -0400 Subject: [PATCH 3/6] Let KMS demos declare virtual connector modes --- .../pages/kandelo/panes/Display.tsx | 23 ++++++- .../pages/kandelo/panes/Modeset.tsx | 60 ++++++++++++++----- .../pages/kandelo/views/MachineView.tsx | 2 + docs/browser-support.md | 9 ++- images/vfs/scripts/build-shell-vfs-image.ts | 12 ++-- packages/registry/love/README.md | 5 ++ web-libs/kandelo-session/src/demo-config.ts | 40 +++++++++++++ web-libs/kandelo-session/src/kernel-host.ts | 11 ++++ .../test/kandelo-session.test.ts | 48 +++++++++++++++ 9 files changed, 187 insertions(+), 23 deletions(-) diff --git a/apps/browser-demos/pages/kandelo/panes/Display.tsx b/apps/browser-demos/pages/kandelo/panes/Display.tsx index 5872bb956..76c49b2f3 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,27 @@ 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 6e7fa10b9..dd1136ba1 100644 --- a/apps/browser-demos/pages/kandelo/panes/Modeset.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Modeset.tsx @@ -4,7 +4,10 @@ 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, +} from "../../../../../web-libs/kandelo-session/src/kernel-host"; import { attachLinuxMediumRawKeyboard, injectChunkedMouseMotion, @@ -13,11 +16,10 @@ import { import { DemoSurfaceDockControls } from "./Framebuffer"; import { useFittedCanvasStyle } from "./canvasFit"; -// 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 FALLBACK_KMS_FB_W = 1920; -const FALLBACK_KMS_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; @@ -30,6 +32,8 @@ 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; } interface KmsStats { @@ -46,9 +50,19 @@ 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, + 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); @@ -72,12 +86,12 @@ export const Modeset: React.FC = ({ autoFocus = false, focusToken // 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 !== FALLBACK_KMS_FB_W) canvas.width = FALLBACK_KMS_FB_W; - if (canvas.height !== FALLBACK_KMS_FB_H) canvas.height = FALLBACK_KMS_FB_H; + 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; @@ -96,7 +110,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 @@ -115,8 +129,8 @@ export const Modeset: React.FC = ({ autoFocus = false, focusToken const activeFrameSize = () => { const s = statsRef.current; - const width = s.width > 0 ? s.width : (canvas.width || FALLBACK_KMS_FB_W); - const height = s.height > 0 ? s.height : (canvas.height || FALLBACK_KMS_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(); @@ -220,7 +234,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; @@ -286,7 +300,7 @@ export const Modeset: React.FC = ({ autoFocus = false, focusToken const canvasStyle = useFittedCanvasStyle( stageRef, canvasRef, - FALLBACK_KMS_FB_W / FALLBACK_KMS_FB_H, + connectorMode.width / connectorMode.height, scanoutAspect, ); const statusLabel = hasFrame @@ -342,3 +356,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/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/docs/browser-support.md b/docs/browser-support.md index 1069b227a..4f3e09c7f 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -119,7 +119,7 @@ connection in any nginx worker. The standalone nginx image runs with ### 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 a 1920×1080 connector mode before transfer so user software sees stable `DRM_IOCTL_MODE_GETCONNECTOR` dimensions, while `MODE_SETCRTC`/`PAGE_FLIP` remain authoritative for the active framebuffer size. +- 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", …)`. @@ -285,6 +285,13 @@ 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. + 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/images/vfs/scripts/build-shell-vfs-image.ts b/images/vfs/scripts/build-shell-vfs-image.ts index b3438983f..25260b70b 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, { width: 960, height: 540 }), guide: loveGuide(), }, bytepath: { - presentation: kmsPresentation(BYTEPATH_COMMAND), + presentation: kmsPresentation(BYTEPATH_COMMAND, { width: 480, height: 270 }), guide: bytepathGuide(), }, snkrx: { - presentation: kmsPresentation(SNKRX_COMMAND), + presentation: kmsPresentation(SNKRX_COMMAND, { width: 960, height: 540 }), guide: snkrxGuide(), }, }, @@ -118,13 +118,17 @@ function populateModesetRuntime(fs: MemoryFileSystem): void { writeVfsBinary(fs, "/usr/local/bin/modeset", new Uint8Array(modesetBytes), 0o755); } -function kmsPresentation(autoCommand: string): DemoPresentationConfig { +function kmsPresentation( + autoCommand: string, + connectorMode?: { width: number; height: number }, +): DemoPresentationConfig { return { bootPrimary: "syslog", runningPrimary: ["kms", "terminal", "syslog"], terminalAccess: "drawer", internalsAccess: "drawer", autoCommand, + ...(connectorMode ? { kms: { connectorMode } } : {}), }; } diff --git a/packages/registry/love/README.md b/packages/registry/love/README.md index be8633a50..d74735170 100644 --- a/packages/registry/love/README.md +++ b/packages/registry/love/README.md @@ -33,6 +33,11 @@ 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 BYTEPATH staging step pins upstream `a327ex/BYTEPATH` and keeps the MIT game code plus permissive Lua dependencies needed for gameplay. It omits the diff --git a/web-libs/kandelo-session/src/demo-config.ts b/web-libs/kandelo-session/src/demo-config.ts index f6d7773a3..3c42a7895 100644 --- a/web-libs/kandelo-session/src/demo-config.ts +++ b/web-libs/kandelo-session/src/demo-config.ts @@ -1,5 +1,6 @@ import type { DemoPresentation, + KmsPresentationOptions, PrimarySurface, } from "./kernel-host"; @@ -11,6 +12,7 @@ export interface DemoPresentationConfig { terminalAccess: DemoPresentation["terminalAccess"]; internalsAccess: DemoPresentation["internalsAccess"]; autoCommand?: string; + kms?: KmsPresentationOptions; } export interface DemoAssetConfig { @@ -120,6 +122,7 @@ const ACTION_KINDS = new Set([ "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 +197,46 @@ 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", + ), + }; + } + 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/kernel-host.ts b/web-libs/kandelo-session/src/kernel-host.ts index 6efb0c88e..ef29242f2 100644 --- a/web-libs/kandelo-session/src/kernel-host.ts +++ b/web-libs/kandelo-session/src/kernel-host.ts @@ -385,6 +385,15 @@ export type PrimarySurface = "syslog" | "terminal" | "framebuffer" | "web" | "km export type SurfaceAvailability = Record; +export interface KmsConnectorMode { + width: number; + height: number; +} + +export interface KmsPresentationOptions { + connectorMode?: KmsConnectorMode; +} + export interface DemoPresentation { /** * Surface that should dominate while the machine is booting. Most demos use @@ -405,6 +414,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 ────────────────────────────────────────────── diff --git a/web-libs/kandelo-session/test/kandelo-session.test.ts b/web-libs/kandelo-session/test/kandelo-session.test.ts index 01dd7fef0..30928cc45 100644 --- a/web-libs/kandelo-session/test/kandelo-session.test.ts +++ b/web-libs/kandelo-session/test/kandelo-session.test.ts @@ -817,6 +817,54 @@ 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 }, + }, + }, + }, + }, + })); + expect(config).not.toBeNull(); + + expect(resolveDemoPresentation(config!, "bytepath")?.kms?.connectorMode).toEqual({ + width: 480, + height: 270, + }); + }); + + 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("throws when profile metadata is incomplete", () => { const config = parseKandeloDemoConfig(JSON.stringify({ version: 1, From b6cfdc3f8f57056990dd496aa6cba4976032c824 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sat, 4 Jul 2026 23:57:30 -0400 Subject: [PATCH 4/6] Constrain fixed-resolution KMS presentation scale --- .../pages/kandelo/panes/Display.tsx | 1 + .../pages/kandelo/panes/Modeset.tsx | 4 ++++ .../pages/kandelo/panes/canvasFit.ts | 14 +++++++++-- docs/browser-support.md | 4 ++++ images/vfs/scripts/build-shell-vfs-image.ts | 12 ++++++++-- packages/registry/love/README.md | 4 +++- web-libs/kandelo-session/src/demo-config.ts | 16 +++++++++++++ web-libs/kandelo-session/src/kernel-host.ts | 1 + .../test/kandelo-session.test.ts | 24 +++++++++++++++++++ 9 files changed, 75 insertions(+), 5 deletions(-) diff --git a/apps/browser-demos/pages/kandelo/panes/Display.tsx b/apps/browser-demos/pages/kandelo/panes/Display.tsx index 76c49b2f3..5cafcd0a6 100644 --- a/apps/browser-demos/pages/kandelo/panes/Display.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Display.tsx @@ -43,6 +43,7 @@ export const Display = React.forwardRef(({ ); diff --git a/apps/browser-demos/pages/kandelo/panes/Modeset.tsx b/apps/browser-demos/pages/kandelo/panes/Modeset.tsx index dd1136ba1..f2263f715 100644 --- a/apps/browser-demos/pages/kandelo/panes/Modeset.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Modeset.tsx @@ -34,6 +34,8 @@ export interface ModesetProps { crtcId?: number; /** Virtual mode advertised by the browser KMS connector before page flips. */ connectorMode?: KmsConnectorMode; + /** Maximum CSS pixels per backing-store pixel when fitting the KMS surface. */ + maxCssScale?: number; } interface KmsStats { @@ -55,6 +57,7 @@ export const Modeset: React.FC = ({ focusToken = 0, crtcId = 1, connectorMode: configuredConnectorMode, + maxCssScale, onDockControlsChange, }) => { const host = useKernelHost(); @@ -302,6 +305,7 @@ export const Modeset: React.FC = ({ canvasRef, connectorMode.width / connectorMode.height, scanoutAspect, + maxCssScale, ); const statusLabel = hasFrame ? `${stats.width}×${stats.height} · ${stats.commitCount} flips · ${stats.lastFrameUs}µs · ${captureLabel}` diff --git a/apps/browser-demos/pages/kandelo/panes/canvasFit.ts b/apps/browser-demos/pages/kandelo/panes/canvasFit.ts index f73a49d40..a02c7b476 100644 --- a/apps/browser-demos/pages/kandelo/panes/canvasFit.ts +++ b/apps/browser-demos/pages/kandelo/panes/canvasFit.ts @@ -5,6 +5,7 @@ export function useFittedCanvasStyle( canvasRef: React.RefObject, fallbackAspect: number, aspectOverride?: number, + maxScale?: number, ): React.CSSProperties { const [style, setStyle] = React.useState({}); @@ -18,7 +19,9 @@ export function useFittedCanvasStyle( if (rect.width <= 0 || rect.height <= 0) 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 fittedWidth = containerAspect > aspect ? rect.height * aspect : rect.width; + const cappedWidth = scaleCap(canvas, maxScale); + const width = cappedWidth === undefined ? fittedWidth : Math.min(fittedWidth, cappedWidth); const height = width / aspect; const nextWidth = Math.max(1, Math.floor(width)); const nextHeight = Math.max(1, Math.floor(height)); @@ -48,7 +51,7 @@ export function useFittedCanvasStyle( mutationObserver.disconnect(); window.removeEventListener("resize", update); }; - }, [aspectOverride, canvasRef, containerRef, fallbackAspect]); + }, [aspectOverride, canvasRef, containerRef, fallbackAspect, maxScale]); return style; } @@ -65,3 +68,10 @@ function canvasAspect(canvas: HTMLCanvasElement, fallbackAspect: number): number function validAspect(value: number | undefined): value is number { return value !== undefined && Number.isFinite(value) && value > 0; } + +function scaleCap(canvas: HTMLCanvasElement, maxScale: number | undefined): number | undefined { + if (!validAspect(maxScale)) return undefined; + const width = canvas.width; + if (width <= 0 || width === 300) return undefined; + return width * maxScale; +} diff --git a/docs/browser-support.md b/docs/browser-support.md index 4f3e09c7f..39f66fb01 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -291,6 +291,10 @@ 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. +Profiles can set `presentation.kms.maxCssScale` to cap how far the browser +presentation zooms that scanout. The cap does not change the KMS framebuffer +or the dimensions visible to user software; it only prevents low-resolution +shader effects from being magnified across an oversized browser pane. 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 diff --git a/images/vfs/scripts/build-shell-vfs-image.ts b/images/vfs/scripts/build-shell-vfs-image.ts index 25260b70b..cd63a8adc 100644 --- a/images/vfs/scripts/build-shell-vfs-image.ts +++ b/images/vfs/scripts/build-shell-vfs-image.ts @@ -90,7 +90,7 @@ async function main() { guide: loveGuide(), }, bytepath: { - presentation: kmsPresentation(BYTEPATH_COMMAND, { width: 480, height: 270 }), + presentation: kmsPresentation(BYTEPATH_COMMAND, { width: 480, height: 270 }, 2), guide: bytepathGuide(), }, snkrx: { @@ -121,6 +121,7 @@ function populateModesetRuntime(fs: MemoryFileSystem): void { function kmsPresentation( autoCommand: string, connectorMode?: { width: number; height: number }, + maxCssScale?: number, ): DemoPresentationConfig { return { bootPrimary: "syslog", @@ -128,7 +129,14 @@ function kmsPresentation( terminalAccess: "drawer", internalsAccess: "drawer", autoCommand, - ...(connectorMode ? { kms: { connectorMode } } : {}), + ...(connectorMode || maxCssScale + ? { + kms: { + ...(connectorMode ? { connectorMode } : {}), + ...(maxCssScale ? { maxCssScale } : {}), + }, + } + : {}), }; } diff --git a/packages/registry/love/README.md b/packages/registry/love/README.md index d74735170..602b17ce7 100644 --- a/packages/registry/love/README.md +++ b/packages/registry/love/README.md @@ -37,7 +37,9 @@ 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. +by the game. BYTEPATH also declares a 2x CSS presentation cap so its +low-resolution glitch shader offsets are not magnified across the full browser +pane. The BYTEPATH staging step pins upstream `a327ex/BYTEPATH` and keeps the MIT game code plus permissive Lua dependencies needed for gameplay. It omits the diff --git a/web-libs/kandelo-session/src/demo-config.ts b/web-libs/kandelo-session/src/demo-config.ts index 3c42a7895..d4f92c5e2 100644 --- a/web-libs/kandelo-session/src/demo-config.ts +++ b/web-libs/kandelo-session/src/demo-config.ts @@ -123,6 +123,7 @@ const ACTION_KINDS = new Set([ "web.wordpressLogin", ]); const MAX_KMS_CONNECTOR_MODE_DIMENSION = 16_384; +const MAX_KMS_CSS_SCALE = 32; export function parseKandeloDemoConfig(text: string): KandeloDemoConfig | null { const value: unknown = JSON.parse(text); @@ -222,6 +223,9 @@ function normalizeKmsPresentationOptions(value: unknown): { kms?: KmsPresentatio ), }; } + if (value.maxCssScale !== undefined) { + kms.maxCssScale = kmsCssScale(value.maxCssScale, "presentation.kms.maxCssScale"); + } return { kms }; } @@ -237,6 +241,18 @@ function kmsModeDimension(value: unknown, field: string): number { return value; } +function kmsCssScale(value: unknown, field: string): number { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value <= 0 || + value > MAX_KMS_CSS_SCALE + ) { + throw new Error(`${field} must be a finite number between 0 and ${MAX_KMS_CSS_SCALE}`); + } + 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/kernel-host.ts b/web-libs/kandelo-session/src/kernel-host.ts index ef29242f2..3023dba6a 100644 --- a/web-libs/kandelo-session/src/kernel-host.ts +++ b/web-libs/kandelo-session/src/kernel-host.ts @@ -392,6 +392,7 @@ export interface KmsConnectorMode { export interface KmsPresentationOptions { connectorMode?: KmsConnectorMode; + maxCssScale?: number; } export interface DemoPresentation { diff --git a/web-libs/kandelo-session/test/kandelo-session.test.ts b/web-libs/kandelo-session/test/kandelo-session.test.ts index 30928cc45..577648325 100644 --- a/web-libs/kandelo-session/test/kandelo-session.test.ts +++ b/web-libs/kandelo-session/test/kandelo-session.test.ts @@ -830,6 +830,7 @@ describe("Kandelo demo config", () => { autoCommand: "/usr/local/bin/love /usr/local/share/love/BYTEPATH.love", kms: { connectorMode: { width: 480, height: 270 }, + maxCssScale: 2, }, }, }, @@ -841,6 +842,7 @@ describe("Kandelo demo config", () => { width: 480, height: 270, }); + expect(resolveDemoPresentation(config!, "bytepath")?.kms?.maxCssScale).toBe(2); }); it("rejects invalid KMS connector mode metadata", () => { @@ -865,6 +867,28 @@ describe("Kandelo demo config", () => { expect(() => resolveDemoPresentation(config!, "bytepath")).toThrow("connectorMode.width"); }); + it("rejects invalid KMS CSS scale metadata", () => { + const config = parseKandeloDemoConfig(JSON.stringify({ + version: 1, + profiles: { + bytepath: { + presentation: { + bootPrimary: "syslog", + runningPrimary: ["kms", "terminal", "syslog"], + terminalAccess: "drawer", + internalsAccess: "drawer", + kms: { + maxCssScale: -1, + }, + }, + }, + }, + })); + expect(config).not.toBeNull(); + + expect(() => resolveDemoPresentation(config!, "bytepath")).toThrow("maxCssScale"); + }); + it("throws when profile metadata is incomplete", () => { const config = parseKandeloDemoConfig(JSON.stringify({ version: 1, From edd9f5f00c8d833b8d564ff21fd32eb4dae6f426 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 5 Jul 2026 03:40:47 -0400 Subject: [PATCH 5/6] Fix KMS/GLES rendering precision and browser scaling Expose the RGBA8 render-target capability through the GLES extension string so LOVE keeps RGBA8 canvases instead of falling back to RGBA4, which exaggerated BYTEPATH's RGB shader offsets. Add safer packed readback sizing/typing for glReadPixels. Add stretch-fit KMS presentation metadata for LOVE demos and a Chromium browser regression test that boots BYTEPATH gameplay, captures the raw KMS canvas, and asserts full-pane scaling plus bounded RGB channel separation. --- .../pages/kandelo/panes/Display.tsx | 2 +- .../pages/kandelo/panes/Modeset.tsx | 9 +- .../pages/kandelo/panes/canvasFit.ts | 29 +- .../test/kandelo-love-rendering.spec.ts | 300 ++++++++++++++++++ docs/browser-support.md | 9 +- host/src/webgl/query.ts | 73 ++++- host/test/webgl-bridge.test.ts | 33 +- images/vfs/scripts/build-shell-vfs-image.ts | 22 +- libc/glue/libglesv2_stub.c | 117 +++++-- packages/registry/love/README.md | 6 +- ...andelo-native-renderer-shader-compat.patch | 27 +- packages/registry/love/src/lovefb.cpp | 4 +- web-libs/kandelo-session/src/demo-config.ts | 22 +- web-libs/kandelo-session/src/demo-guides.ts | 3 + web-libs/kandelo-session/src/kernel-host.ts | 6 +- .../test/kandelo-session.test.ts | 104 +++++- 16 files changed, 662 insertions(+), 104 deletions(-) create mode 100644 apps/browser-demos/test/kandelo-love-rendering.spec.ts diff --git a/apps/browser-demos/pages/kandelo/panes/Display.tsx b/apps/browser-demos/pages/kandelo/panes/Display.tsx index 5cafcd0a6..8cb8dbef6 100644 --- a/apps/browser-demos/pages/kandelo/panes/Display.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Display.tsx @@ -43,7 +43,7 @@ export const Display = React.forwardRef(({ ); diff --git a/apps/browser-demos/pages/kandelo/panes/Modeset.tsx b/apps/browser-demos/pages/kandelo/panes/Modeset.tsx index f2263f715..ccdad48dd 100644 --- a/apps/browser-demos/pages/kandelo/panes/Modeset.tsx +++ b/apps/browser-demos/pages/kandelo/panes/Modeset.tsx @@ -7,6 +7,7 @@ import { useKernelHost, useStatus } from "../kernel-host/react"; import type { KmsConnectorMode, KmsDisplayHandle, + KmsSurfaceFit, } from "../../../../../web-libs/kandelo-session/src/kernel-host"; import { attachLinuxMediumRawKeyboard, @@ -34,8 +35,8 @@ export interface ModesetProps { crtcId?: number; /** Virtual mode advertised by the browser KMS connector before page flips. */ connectorMode?: KmsConnectorMode; - /** Maximum CSS pixels per backing-store pixel when fitting the KMS surface. */ - maxCssScale?: number; + /** Browser presentation fit for the scanout canvas. */ + fit?: KmsSurfaceFit; } interface KmsStats { @@ -57,7 +58,7 @@ export const Modeset: React.FC = ({ focusToken = 0, crtcId = 1, connectorMode: configuredConnectorMode, - maxCssScale, + fit = "contain", onDockControlsChange, }) => { const host = useKernelHost(); @@ -305,7 +306,7 @@ export const Modeset: React.FC = ({ canvasRef, connectorMode.width / connectorMode.height, scanoutAspect, - maxCssScale, + fit, ); const statusLabel = hasFrame ? `${stats.width}×${stats.height} · ${stats.commitCount} flips · ${stats.lastFrameUs}µs · ${captureLabel}` diff --git a/apps/browser-demos/pages/kandelo/panes/canvasFit.ts b/apps/browser-demos/pages/kandelo/panes/canvasFit.ts index a02c7b476..35b1ed7d1 100644 --- a/apps/browser-demos/pages/kandelo/panes/canvasFit.ts +++ b/apps/browser-demos/pages/kandelo/panes/canvasFit.ts @@ -5,7 +5,7 @@ export function useFittedCanvasStyle( canvasRef: React.RefObject, fallbackAspect: number, aspectOverride?: number, - maxScale?: number, + fit: "contain" | "stretch" = "contain", ): React.CSSProperties { const [style, setStyle] = React.useState({}); @@ -17,11 +17,23 @@ export function useFittedCanvasStyle( const update = () => { const rect = container.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return; + 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 fittedWidth = containerAspect > aspect ? rect.height * aspect : rect.width; - const cappedWidth = scaleCap(canvas, maxScale); - const width = cappedWidth === undefined ? fittedWidth : Math.min(fittedWidth, cappedWidth); + const width = containerAspect > aspect ? rect.height * aspect : rect.width; const height = width / aspect; const nextWidth = Math.max(1, Math.floor(width)); const nextHeight = Math.max(1, Math.floor(height)); @@ -51,7 +63,7 @@ export function useFittedCanvasStyle( mutationObserver.disconnect(); window.removeEventListener("resize", update); }; - }, [aspectOverride, canvasRef, containerRef, fallbackAspect, maxScale]); + }, [aspectOverride, canvasRef, containerRef, fallbackAspect, fit]); return style; } @@ -68,10 +80,3 @@ function canvasAspect(canvas: HTMLCanvasElement, fallbackAspect: number): number function validAspect(value: number | undefined): value is number { return value !== undefined && Number.isFinite(value) && value > 0; } - -function scaleCap(canvas: HTMLCanvasElement, maxScale: number | undefined): number | undefined { - if (!validAspect(maxScale)) return undefined; - const width = canvas.width; - if (width <= 0 || width === 300) return undefined; - return width * maxScale; -} diff --git a/apps/browser-demos/test/kandelo-love-rendering.spec.ts b/apps/browser-demos/test/kandelo-love-rendering.spec.ts new file mode 100644 index 000000000..0a865ecde --- /dev/null +++ b/apps/browser-demos/test/kandelo-love-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 LOVE KMS 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 39f66fb01..cbfb3fdf5 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -290,11 +290,10 @@ KMS profiles may also declare `presentation.kms.connectorMode` with integer 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. -Profiles can set `presentation.kms.maxCssScale` to cap how far the browser -presentation zooms that scanout. The cap does not change the KMS framebuffer -or the dimensions visible to user software; it only prevents low-resolution -shader effects from being magnified across an oversized browser pane. +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 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/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 cd63a8adc..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, { width: 960, height: 540 }), + presentation: kmsPresentation(LOVE_COMMAND, { connectorMode: { width: 960, height: 540 }, fit: "stretch" }), guide: loveGuide(), }, bytepath: { - presentation: kmsPresentation(BYTEPATH_COMMAND, { width: 480, height: 270 }, 2), + presentation: kmsPresentation(BYTEPATH_COMMAND, { connectorMode: { width: 480, height: 270 }, fit: "stretch" }), guide: bytepathGuide(), }, snkrx: { - presentation: kmsPresentation(SNKRX_COMMAND, { width: 960, height: 540 }), + presentation: kmsPresentation(SNKRX_COMMAND, { connectorMode: { width: 960, height: 540 }, fit: "stretch" }), guide: snkrxGuide(), }, }, @@ -120,23 +120,19 @@ function populateModesetRuntime(fs: MemoryFileSystem): void { function kmsPresentation( autoCommand: string, - connectorMode?: { width: number; height: number }, - maxCssScale?: number, + 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, - ...(connectorMode || maxCssScale - ? { - kms: { - ...(connectorMode ? { connectorMode } : {}), - ...(maxCssScale ? { maxCssScale } : {}), - }, - } - : {}), + ...(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 602b17ce7..424d60ef2 100644 --- a/packages/registry/love/README.md +++ b/packages/registry/love/README.md @@ -37,9 +37,9 @@ 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. BYTEPATH also declares a 2x CSS presentation cap so its -low-resolution glitch shader offsets are not magnified across the full browser -pane. +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 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/lovefb.cpp b/packages/registry/love/src/lovefb.cpp index a44221b4c..035b6373e 100644 --- a/packages/registry/love/src/lovefb.cpp +++ b/packages/registry/love/src/lovefb.cpp @@ -2697,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; @@ -3576,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) { diff --git a/web-libs/kandelo-session/src/demo-config.ts b/web-libs/kandelo-session/src/demo-config.ts index d4f92c5e2..4218c0880 100644 --- a/web-libs/kandelo-session/src/demo-config.ts +++ b/web-libs/kandelo-session/src/demo-config.ts @@ -1,6 +1,7 @@ import type { DemoPresentation, KmsPresentationOptions, + KmsSurfaceFit, PrimarySurface, } from "./kernel-host"; @@ -117,13 +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; -const MAX_KMS_CSS_SCALE = 32; export function parseKandeloDemoConfig(text: string): KandeloDemoConfig | null { const value: unknown = JSON.parse(text); @@ -223,8 +224,11 @@ function normalizeKmsPresentationOptions(value: unknown): { kms?: KmsPresentatio ), }; } - if (value.maxCssScale !== undefined) { - kms.maxCssScale = kmsCssScale(value.maxCssScale, "presentation.kms.maxCssScale"); + 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 }; } @@ -241,18 +245,6 @@ function kmsModeDimension(value: unknown, field: string): number { return value; } -function kmsCssScale(value: unknown, field: string): number { - if ( - typeof value !== "number" || - !Number.isFinite(value) || - value <= 0 || - value > MAX_KMS_CSS_SCALE - ) { - throw new Error(`${field} must be a finite number between 0 and ${MAX_KMS_CSS_SCALE}`); - } - 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 3023dba6a..7eb93e971 100644 --- a/web-libs/kandelo-session/src/kernel-host.ts +++ b/web-libs/kandelo-session/src/kernel-host.ts @@ -390,9 +390,11 @@ export interface KmsConnectorMode { height: number; } +export type KmsSurfaceFit = "contain" | "stretch"; + export interface KmsPresentationOptions { connectorMode?: KmsConnectorMode; - maxCssScale?: number; + fit?: KmsSurfaceFit; } export interface DemoPresentation { @@ -1580,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])); diff --git a/web-libs/kandelo-session/test/kandelo-session.test.ts b/web-libs/kandelo-session/test/kandelo-session.test.ts index 577648325..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 }); @@ -830,7 +920,7 @@ describe("Kandelo demo config", () => { autoCommand: "/usr/local/bin/love /usr/local/share/love/BYTEPATH.love", kms: { connectorMode: { width: 480, height: 270 }, - maxCssScale: 2, + fit: "stretch", }, }, }, @@ -842,7 +932,7 @@ describe("Kandelo demo config", () => { width: 480, height: 270, }); - expect(resolveDemoPresentation(config!, "bytepath")?.kms?.maxCssScale).toBe(2); + expect(resolveDemoPresentation(config!, "bytepath")?.kms?.fit).toBe("stretch"); }); it("rejects invalid KMS connector mode metadata", () => { @@ -867,7 +957,7 @@ describe("Kandelo demo config", () => { expect(() => resolveDemoPresentation(config!, "bytepath")).toThrow("connectorMode.width"); }); - it("rejects invalid KMS CSS scale metadata", () => { + it("rejects invalid KMS fit metadata", () => { const config = parseKandeloDemoConfig(JSON.stringify({ version: 1, profiles: { @@ -878,7 +968,7 @@ describe("Kandelo demo config", () => { terminalAccess: "drawer", internalsAccess: "drawer", kms: { - maxCssScale: -1, + fit: "cover", }, }, }, @@ -886,7 +976,7 @@ describe("Kandelo demo config", () => { })); expect(config).not.toBeNull(); - expect(() => resolveDemoPresentation(config!, "bytepath")).toThrow("maxCssScale"); + expect(() => resolveDemoPresentation(config!, "bytepath")).toThrow("presentation.kms.fit"); }); it("throws when profile metadata is incomplete", () => { @@ -1030,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 }), From 18a7de26ee1a7982791b655f42475714634f6f72 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Sun, 5 Jul 2026 10:20:22 -0400 Subject: [PATCH 6/6] Reframe browser proof as KMS/GLES rendering coverage --- ...ove-rendering.spec.ts => kandelo-kms-gles-rendering.spec.ts} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename apps/browser-demos/test/{kandelo-love-rendering.spec.ts => kandelo-kms-gles-rendering.spec.ts} (99%) diff --git a/apps/browser-demos/test/kandelo-love-rendering.spec.ts b/apps/browser-demos/test/kandelo-kms-gles-rendering.spec.ts similarity index 99% rename from apps/browser-demos/test/kandelo-love-rendering.spec.ts rename to apps/browser-demos/test/kandelo-kms-gles-rendering.spec.ts index 0a865ecde..852d48298 100644 --- a/apps/browser-demos/test/kandelo-love-rendering.spec.ts +++ b/apps/browser-demos/test/kandelo-kms-gles-rendering.spec.ts @@ -218,7 +218,7 @@ async function captureKmsSnapshot( return metrics; } -test("BYTEPATH reaches gameplay on the native LOVE KMS renderer", async ({ browserName, page }, testInfo: TestInfo) => { +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);