diff --git a/xbox/KIDLISP-NATIVE.md b/xbox/KIDLISP-NATIVE.md new file mode 100644 index 0000000000..bbadb2cf3a --- /dev/null +++ b/xbox/KIDLISP-NATIVE.md @@ -0,0 +1,64 @@ +# Native KidLisp on Xbox + +The Native BIOS can run a deliberately small KidLisp subset by compiling the +published source on the trusted host into the BIOS's existing sandboxed +QuickJS lifecycle. This avoids shipping a second interpreter and does not add +`fetch`, DOM, filesystem, process, Device Portal, or WinRT access to pieces. + +The first supported proof is the live `$obk` program by `@fifi`: + +```lisp +lightblue +stamp #j8t +blur 4 +``` + +Its complete external dependency closure is the painting `#j8t`. The host +fetches `$obk` only from +`https://aesthetic.computer/api/store-kidlisp?code=obk`. The Xbox resolves +`#j8t` only through +`https://aesthetic.computer/media/paintings/j8t.png`; redirects may reach AC's +media storage, but a piece cannot supply a URL. + +## Supported subset v1 + +- bare named-color wipes: `black`, `white`, `red`, `green`, `blue`, `lightblue` +- `wipe color` or `wipe r g b` +- `stamp #code [x y scale]`, with 1-8 alphanumeric painting codes +- `blur strength` from 0-48; the compiler applies KidLisp's divide-by-three + kernel scaling and the runtime caps the resulting radius at 16 + +Unsupported forms fail compilation rather than silently changing the artwork. +Published source is limited to 50 KB and 256 forms. The native image cache is +limited to eight entries, each compressed response to 8 MiB, and each decoded +image to a maximum 1024-pixel side. Native blur is a separable CPU box blur. + +The current renderer batches drawing by primitive type, so subset v1 supports +one final blur pass per frame. That is faithful to `$obk`; broader KidLisp +command ordering, transforms, animation/timing, nested `$code` composition, +audio, and the rest of the browser runtime remain future work. + +## Deploy over the wire + +Install Native BIOS revision 13 or newer, load the Device Portal environment +from the vault as usual, then run: + +```bash +node xbox/tools/live.mjs deploy-kidlisp '$obk' +``` + +The tool fetches and validates the current published source, emits a bounded JS +piece in memory, uploads it as `LocalState/live-piece.js`, launches the app, and +prints telemetry. A successful console run includes: + +```text +AC_NATIVE_JS KIDLISP_BOOT $obk forms=3 paintings=j8t +AC_NATIVE_PAINTING_READY key=#j8t ... +AC_NATIVE_CPU_FRAME ... images=1 blur=1 ... +``` + +Portable preflight: + +```bash +sh xbox/test-native.sh +``` diff --git a/xbox/native-bios/App.cpp b/xbox/native-bios/App.cpp index baea07b34b..1fa96f1250 100644 --- a/xbox/native-bios/App.cpp +++ b/xbox/native-bios/App.cpp @@ -1,5 +1,6 @@ #include "pch.h" #include "QuickJsEngine.hpp" +#include "../runtime/include/ac/image_effects.hpp" using Microsoft::WRL::ComPtr; using namespace Platform; @@ -141,6 +142,7 @@ class HostGraphics final : public Graphics { std::function on_system_write; std::function on_system_glyph; std::function on_image; + std::function on_blur; void wipe(Color color) override { if (on_wipe) on_wipe(color); } void box(const ac::xbox::Rect& rect) override { if (on_box) on_box(rect); } void line(const ac::xbox::Line& line) override { if (on_line) on_line(line); } @@ -152,6 +154,7 @@ class HostGraphics final : public Graphics { if (on_system_glyph) on_system_glyph(glyph); } void image(const ac::xbox::ImageDraw& draw) override { if (on_image) on_image(draw); } + void blur(unsigned radius) override { if (on_blur) on_blur(radius); } }; class HostSound final : public Sound { public: @@ -204,6 +207,10 @@ public: }; m_graphics->on_image = [this](const ac::xbox::ImageDraw& draw) { m_frameImages.push_back(draw); + RequestFrameImage(draw.source); + }; + m_graphics->on_blur = [this](unsigned radius) { + m_frameBlurRadius = (std::max)(m_frameBlurRadius, (std::min)(16u, radius)); }; m_sound->on_synth = [this](const SynthVoice& voice) { PlaySynth(voice); }; m_sound->on_stop = [this]() { if (m_voice) { m_voice->Stop(0); m_voice->FlushSourceBuffers(); } }; @@ -213,7 +220,7 @@ public: m_sound->on_oscillator_stop = [this]() { StopOscillator(); }; m_sound->get_rate = [this]() { return static_cast(m_sampleRate); }; m_api = std::make_unique(Api{{1920, 1080, 1}, {}, {}, {}, *m_graphics, *m_sound}); - m_api->system.version = "1.0.0.12"; + m_api->system.version = "1.0.0.13"; m_api->telemetry = [](std::string_view line) { std::string safe(line); for (auto& character : safe) if (character == '\n' || character == '\r') character = ' '; @@ -252,6 +259,7 @@ public: m_frameSystemTexts.clear(); m_frameSystemGlyphs.clear(); m_frameImages.clear(); + m_frameBlurRadius = 0; if (m_supervisor && m_supervisor->active()) { try { m_supervisor->active()->sim(*m_api); @@ -631,7 +639,7 @@ private: if (!m_acRequestInFlight.compare_exchange_strong(expected, true)) return; auto client = ref new HttpClient(); - client->DefaultRequestHeaders->UserAgent->ParseAdd("AC-Native-BIOS/1.0.0.12 Xbox"); + client->DefaultRequestHeaders->UserAgent->ParseAdd("AC-Native-BIOS/1.0.0.13 Xbox"); std::vector> requests; requests.push_back(create_task(client->GetStringAsync( ref new Uri(L"https://aesthetic.computer/api/mood/moods-of-the-day")))); @@ -677,7 +685,7 @@ private: snapshot->status = "ready"; std::atomic_store(&m_api->ac, std::static_pointer_cast(snapshot)); - DownloadPainting(snapshot->painting_url); + DownloadPainting("latest-painting", snapshot->painting_url); LogTelemetry("AC_NATIVE_AC_READY mood=" + snapshot->mood_handle + " clock=" + snapshot->clock_from + " painting=" + std::to_string(!snapshot->painting_url.empty())); @@ -696,13 +704,46 @@ private: }); } - void DownloadPainting(const std::string& url) { - if (url.empty()) return; - const auto current = std::atomic_load(&m_paintingImage); - if ((current && current->url == url) || m_paintingRequestInFlight.exchange(true)) return; + void RequestFrameImage(const std::string& source) { + if (source == "latest-painting") return; + if (source.size() < 2 || source.size() > 9 || source.front() != '#' || + !std::all_of(source.begin() + 1, source.end(), [](unsigned char character) { + return (character >= '0' && character <= '9') || + (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z'); + })) { + LogTelemetry("AC_NATIVE_IMAGE_REJECT reason=source"); + return; + } + DownloadPainting(source, "https://aesthetic.computer/media/paintings/" + + source.substr(1) + ".png"); + } + + std::shared_ptr FrameImage(const std::string& source) { + std::lock_guard lock(m_imageMutex); + const auto found = m_paintingImages.find(source); + return found == m_paintingImages.end() ? nullptr : found->second; + } + + void DownloadPainting(const std::string& key, const std::string& url) { + if (key.empty() || url.empty()) return; + { + std::lock_guard lock(m_imageMutex); + const auto current = m_paintingImages.find(key); + if (current != m_paintingImages.end() && current->second && + current->second->url == url) return; + if (m_paintingRequests.find(key) != m_paintingRequests.end()) return; + if (m_paintingImages.size() + m_paintingRequests.size() >= 8) { + LogTelemetry("AC_NATIVE_IMAGE_REJECT reason=cache-limit"); + return; + } + m_paintingRequests.insert(key); + } auto client = ref new HttpClient(); create_task(client->GetBufferAsync(ref new Uri(ref new String(Wide(url).c_str())))) .then([](IBuffer^ buffer) { + if (!buffer || buffer->Length == 0 || buffer->Length > 8 * 1024 * 1024) + throw std::runtime_error("painting payload exceeds 8 MiB limit"); auto stream = ref new InMemoryRandomAccessStream(); auto writer = ref new DataWriter(stream); writer->WriteBuffer(buffer); @@ -741,20 +782,23 @@ private: }); }); }) - .then([this, client, url](task> completed) { + .then([this, client, key, url](task> completed) { try { auto image = completed.get(); image->url = url; - std::atomic_store(&m_paintingImage, - std::static_pointer_cast(image)); - LogTelemetry("AC_NATIVE_PAINTING_READY " + std::to_string(image->width) + "x" + - std::to_string(image->height)); + { + std::lock_guard lock(m_imageMutex); + m_paintingImages[key] = std::static_pointer_cast(image); + } + LogTelemetry("AC_NATIVE_PAINTING_READY key=" + key + " " + + std::to_string(image->width) + "x" + std::to_string(image->height)); } catch (Exception^ error) { LogTelemetry("AC_NATIVE_PAINTING_ERROR " + Utf8(error->Message)); } catch (const std::exception& error) { LogTelemetry("AC_NATIVE_PAINTING_ERROR " + std::string(error.what())); } - m_paintingRequestInFlight = false; + std::lock_guard lock(m_imageMutex); + m_paintingRequests.erase(key); }); } @@ -799,10 +843,14 @@ private: " generation=" + std::to_string(m_supervisor->generation())); } + void ApplyBoxBlur(unsigned radius) { + BoxBlurBgra(m_cpuFrame, m_frameWidth, m_frameHeight, radius, m_blurScratch); + } + void Render(const std::array& color) { if (!m_target) return; if (!m_frameTexts.empty() || !m_frameRects.empty() || !m_frameLines.empty() || - !m_frameImages.empty() || + !m_frameImages.empty() || m_frameBlurRadius > 0 || !m_frameSystemTexts.empty() || !m_frameSystemGlyphs.empty()) { const auto byte = [](float value) { return static_cast(255.0f * (std::max)(0.0f, (std::min)(1.0f, value))); @@ -832,13 +880,17 @@ private: // Images sit above panel rectangles and below vector lines/type. This // preserves the piece's intended dashboard layering without requiring a // GPU resource per downloaded painting. - const auto painting = std::atomic_load(&m_paintingImage); - if (painting && painting->width > 0 && painting->height > 0) { - for (const auto& draw : m_frameImages) { - const int destLeft = static_cast(draw.x * scaleX); - const int destTop = static_cast(draw.y * scaleY); - const int destWidth = (std::max)(1, static_cast(draw.width * scaleX)); - const int destHeight = (std::max)(1, static_cast(draw.height * scaleY)); + for (const auto& draw : m_frameImages) { + const auto painting = FrameImage(draw.source); + if (painting && painting->width > 0 && painting->height > 0) { + const float requestedWidth = draw.width > 0 ? draw.width : painting->width * draw.scale; + const float requestedHeight = draw.height > 0 ? draw.height : painting->height * draw.scale; + const int destWidth = (std::max)(1, static_cast(std::abs(requestedWidth) * scaleX)); + const int destHeight = (std::max)(1, static_cast(std::abs(requestedHeight) * scaleY)); + const int centerOffsetX = draw.centered ? destWidth / 2 : 0; + const int centerOffsetY = draw.centered ? destHeight / 2 : 0; + const int destLeft = static_cast(draw.x * scaleX) - centerOffsetX; + const int destTop = static_cast(draw.y * scaleY) - centerOffsetY; const int left = (std::max)(0, destLeft); const int top = (std::max)(0, destTop); const int right = (std::min)(static_cast(m_frameWidth), destLeft + destWidth); @@ -868,6 +920,7 @@ private: } } } + ApplyBoxBlur(m_frameBlurRadius); for (const auto& line : m_frameLines) { const float x1 = line.x1 * scaleX, y1 = line.y1 * scaleY; const float x2 = line.x2 * scaleX, y2 = line.y2 * scaleY; @@ -941,6 +994,7 @@ private: " systemTexts=" + std::to_string(m_frameSystemTexts.size()) + " glyphs=" + std::to_string(m_frameSystemGlyphs.size()) + " images=" + std::to_string(m_frameImages.size()) + + " blur=" + std::to_string(m_frameBlurRadius) + " boxes=" + std::to_string(m_frameRects.size()) + " lines=" + std::to_string(m_frameLines.size()) + " surface=" + std::to_string(m_frameWidth) + "x" + std::to_string(m_frameHeight)); @@ -965,9 +1019,9 @@ private: unsigned long long m_nextCapabilityPollMs = 0; unsigned long long m_nextAcPollMs = 0; std::atomic m_acRequestInFlight{false}; - std::atomic m_paintingRequestInFlight{false}; unsigned m_frameWidth = 0; unsigned m_frameHeight = 0; + unsigned m_frameBlurRadius = 0; std::string m_lastCapabilityInventory; uint32_t m_sampleRate = 48000; std::array m_flashColor{1, 1, 1, 1}; @@ -978,7 +1032,9 @@ private: std::vector m_frameSystemTexts; std::vector m_frameSystemGlyphs; std::vector m_frameImages; - std::shared_ptr m_paintingImage; + std::mutex m_imageMutex; + std::unordered_map> m_paintingImages; + std::unordered_set m_paintingRequests; bool m_loggedTextFrame = false; ComPtr m_device; @@ -1000,6 +1056,7 @@ private: std::vector m_samples; std::vector m_oscSamples; std::vector m_cpuFrame; + std::vector m_blurScratch; XAUDIO2_BUFFER m_buffer{}; XAUDIO2_BUFFER m_oscBuffer{}; float m_oscillatorFrequency = 0; diff --git a/xbox/native-bios/Package.appxmanifest b/xbox/native-bios/Package.appxmanifest index 7a4aa7d7f2..93be0c3eb0 100644 --- a/xbox/native-bios/Package.appxmanifest +++ b/xbox/native-bios/Package.appxmanifest @@ -5,7 +5,7 @@ IgnorableNamespaces="uap mp"> + Version="1.0.0.13" /> diff --git a/xbox/native-bios/QuickJsEngine.cpp b/xbox/native-bios/QuickJsEngine.cpp index 21b6a4cc29..81798a3119 100644 --- a/xbox/native-bios/QuickJsEngine.cpp +++ b/xbox/native-bios/QuickJsEngine.cpp @@ -1,3 +1,5 @@ +#include +#include #include #include #include @@ -158,6 +160,41 @@ JSValue Painting(JSContext* context, JSValueConst, int argc, JSValueConst* argv) return JS_UNDEFINED; } +JSValue StampPainting(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + if (!scope || !scope->api || argc < 3) return JS_EXCEPTION; + const char* source = JS_ToCString(context, argv[0]); + if (!source) return JS_EXCEPTION; + std::string code(source); + JS_FreeCString(context, source); + const bool valid = code.size() >= 2 && code.size() <= 9 && code.front() == '#' && + std::all_of(code.begin() + 1, code.end(), [](unsigned char character) { + return (character >= '0' && character <= '9') || + (character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z'); + }); + if (!valid) return JS_ThrowTypeError(context, "painting code must match #[0-9A-Za-z]{1,8}"); + double x = 0, y = 0, scale = 1; + if (JS_ToFloat64(context, &x, argv[1]) || JS_ToFloat64(context, &y, argv[2]) || + (argc > 3 && JS_ToFloat64(context, &scale, argv[3]))) return JS_EXCEPTION; + if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(scale) || + std::abs(scale) > 8.0) return JS_ThrowRangeError(context, "invalid painting transform"); + scope->api->graphics.image({code, static_cast(x), static_cast(y), + 0, 0, static_cast(scale), true}); + return JS_UNDEFINED; +} + +JSValue Blur(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + int32_t radius = 0; + if (!scope || !scope->api || argc < 1 || JS_ToInt32(context, &radius, argv[0])) + return JS_EXCEPTION; + if (radius < 0 || radius > 16) + return JS_ThrowRangeError(context, "blur radius must be between 0 and 16"); + scope->api->graphics.blur(static_cast(radius)); + return JS_UNDEFINED; +} + JSValue Telemetry(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { auto* scope = static_cast(JS_GetContextOpaque(context)); if (!scope || !scope->api || !scope->api->telemetry || argc < 1) return JS_UNDEFINED; @@ -299,6 +336,8 @@ class QuickJsPiece final : public JsPiece { JS_SetPropertyStr(context_, global, "systemWrite", JS_NewCFunction(context_, SystemWrite, "systemWrite", 7)); JS_SetPropertyStr(context_, global, "systemGlyph", JS_NewCFunction(context_, SystemGlyph, "systemGlyph", 7)); JS_SetPropertyStr(context_, global, "painting", JS_NewCFunction(context_, Painting, "painting", 4)); + JS_SetPropertyStr(context_, global, "stampPainting", JS_NewCFunction(context_, StampPainting, "stampPainting", 4)); + JS_SetPropertyStr(context_, global, "blur", JS_NewCFunction(context_, Blur, "blur", 1)); JS_SetPropertyStr(context_, global, "telemetry", JS_NewCFunction(context_, Telemetry, "telemetry", 2)); JS_SetPropertyStr(context_, global, "runtime", JS_NewCFunction(context_, RuntimeInfo, "runtime", 0)); JS_SetPropertyStr(context_, global, "gamepad", JS_NewCFunction(context_, GamepadState, "gamepad", 0)); diff --git a/xbox/native-bios/README.md b/xbox/native-bios/README.md index c6d089670a..6208d80545 100644 --- a/xbox/native-bios/README.md +++ b/xbox/native-bios/README.md @@ -25,6 +25,10 @@ continuous `oscillator` / `oscillatorStop`, `controllers`, `gamepad`, clock-chat, and painting endpoints; sandboxed pieces do not receive a general HTTP primitive. Runtime failures roll back to the last known good piece. +Revision 13 also exposes bounded `stampPainting` and `blur` primitives to the +trusted host-side KidLisp compiler. See [`../KIDLISP-NATIVE.md`](../KIDLISP-NATIVE.md) +for the supported subset and `$obk` wire-deploy flow. + ## Live development from blueberry `xbox/tools/live.mjs` is the credential-safe control surface for agents and @@ -36,6 +40,7 @@ node xbox/tools/live.mjs status node xbox/tools/live.mjs install xbox/builds/1.0.0.10/NativeBios_1.0.0.10_x64.msix xbox/builds/1.0.0.10/Microsoft.VCLibs.x64.14.00.appx node xbox/tools/live.mjs deploy xbox/live/controller-probe.js node xbox/tools/live.mjs deploy xbox/live/native-showcase.js +node xbox/tools/live.mjs deploy-kidlisp '$obk' node xbox/tools/live.mjs logs 100 ``` diff --git a/xbox/native-bios/pch.h b/xbox/native-bios/pch.h index 13ad879445..4061c5c43d 100644 --- a/xbox/native-bios/pch.h +++ b/xbox/native-bios/pch.h @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include diff --git a/xbox/native-bios/tests/quickjs_engine_smoke.cpp b/xbox/native-bios/tests/quickjs_engine_smoke.cpp index fee7c02939..86496c88d0 100644 --- a/xbox/native-bios/tests/quickjs_engine_smoke.cpp +++ b/xbox/native-bios/tests/quickjs_engine_smoke.cpp @@ -2,7 +2,7 @@ #include using namespace ac::xbox; namespace { -class GraphicsProbe final : public Graphics { public: Color color{}; int boxes = 0; int lines = 0; int writes = 0; int systemWrites = 0; int glyphs = 0; int images = 0; void wipe(Color value) override { color = value; } void box(const Rect&) override { ++boxes; } void line(const Line&) override { ++lines; } void write(const Text&) override { ++writes; } void system_write(const SystemText&) override { ++systemWrites; } void system_glyph(const SystemGlyph&) override { ++glyphs; } void image(const ImageDraw&) override { ++images; } }; +class GraphicsProbe final : public Graphics { public: Color color{}; int boxes = 0; int lines = 0; int writes = 0; int systemWrites = 0; int glyphs = 0; int images = 0; int blurs = 0; ImageDraw lastImage{}; void wipe(Color value) override { color = value; } void box(const Rect&) override { ++boxes; } void line(const Line&) override { ++lines; } void write(const Text&) override { ++writes; } void system_write(const SystemText&) override { ++systemWrites; } void system_glyph(const SystemGlyph&) override { ++glyphs; } void image(const ImageDraw& draw) override { ++images; lastImage = draw; } void blur(unsigned) override { ++blurs; } }; class SoundProbe final : public Sound { public: int calls = 0; int oscillators = 0; int stops = 0; void synth(const SynthVoice&) override { ++calls; } void stop_all() override {} int sample_rate() const override { return 48000; } void oscillator(float, float) override { ++oscillators; } void oscillator_stop() override { ++stops; } }; } int main() { @@ -10,11 +10,13 @@ int main() { int telemetryCalls = 0; api.telemetry = [&](std::string_view) { ++telemetryCalls; }; QuickJsEngine engine; std::string error; - auto piece = engine.compile({"smoke", "test", "function boot(){telemetry('BOOT','OK');ac()} function sim(){gamepad();runtime();capabilities();controllers();oscillator(220,.1)} function paint(){wipe(1,2,3);box(1,2,3,4,5,6,7);line(1,2,3,4,2,5,6,7);write('OK',8,9,10,11,12,13);systemWrite('HI',20,30,40);systemGlyph('ButtonA',50,60,70);painting(80,90,100,110)} function act(b){if(b==='A')synth(440,.01);if(b==='B')oscillatorStop()}", "test"}, {}, error); + auto piece = engine.compile({"smoke", "test", "function boot(){telemetry('BOOT','OK');ac()} function sim(){gamepad();runtime();capabilities();controllers();oscillator(220,.1)} function paint(){wipe(1,2,3);box(1,2,3,4,5,6,7);line(1,2,3,4,2,5,6,7);write('OK',8,9,10,11,12,13);systemWrite('HI',20,30,40);systemGlyph('ButtonA',50,60,70);painting(80,90,100,110);stampPainting('#j8t',200,300,1);blur(4)} function act(b){if(b==='A')synth(440,.01);if(b==='B')oscillatorStop()}", "test"}, {}, error); assert(piece && error.empty()); piece->boot(api); piece->paint(api); assert(graphics.color.r == 1 && graphics.color.g == 2 && graphics.color.b == 3); assert(graphics.boxes == 1 && graphics.lines == 1 && graphics.writes == 1 && - graphics.systemWrites == 1 && graphics.glyphs == 1 && graphics.images == 1 && + graphics.systemWrites == 1 && graphics.glyphs == 1 && graphics.images == 2 && + graphics.blurs == 1 && graphics.lastImage.source == "#j8t" && + graphics.lastImage.centered && telemetryCalls == 1); piece->sim(api); assert(sound.oscillators == 1); piece->act(api, {"A"}); assert(sound.calls == 1); diff --git a/xbox/runtime/include/ac/image_effects.hpp b/xbox/runtime/include/ac/image_effects.hpp new file mode 100644 index 0000000000..bb143097e1 --- /dev/null +++ b/xbox/runtime/include/ac/image_effects.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include +#include +#include + +namespace ac::xbox { + +// In-place separable box blur for the host's packed ARGB/BGRA8 CPU frame. +// Runtime is O(width * height), independent of the bounded radius. +inline void BoxBlurBgra(std::vector& frame, unsigned width, + unsigned height, unsigned radius, + std::vector& scratch) { + if (radius == 0 || frame.empty() || width == 0 || height == 0 || + frame.size() != static_cast(width) * height) return; + radius = (std::min)(16u, radius); + const int frameWidth = static_cast(width); + const int frameHeight = static_cast(height); + const int diameter = static_cast(radius) * 2 + 1; + scratch.resize(frame.size()); + const auto channel = [](std::uint32_t pixel, unsigned shift) { + return (pixel >> shift) & 255u; + }; + const auto packAverage = [diameter](unsigned a, unsigned r, unsigned g, unsigned b) { + return ((a / diameter) << 24) | ((r / diameter) << 16) | + ((g / diameter) << 8) | (b / diameter); + }; + + for (int y = 0; y < frameHeight; ++y) { + unsigned a = 0, r = 0, g = 0, b = 0; + const auto sample = [&frame, y, frameWidth](int x) { + x = (std::max)(0, (std::min)(frameWidth - 1, x)); + return frame[static_cast(y) * frameWidth + x]; + }; + for (int offset = -static_cast(radius); offset <= static_cast(radius); ++offset) { + const auto pixel = sample(offset); + a += channel(pixel, 24); r += channel(pixel, 16); + g += channel(pixel, 8); b += channel(pixel, 0); + } + for (int x = 0; x < frameWidth; ++x) { + scratch[static_cast(y) * frameWidth + x] = packAverage(a, r, g, b); + const auto remove = sample(x - static_cast(radius)); + const auto add = sample(x + static_cast(radius) + 1); + a = a + channel(add, 24) - channel(remove, 24); + r = r + channel(add, 16) - channel(remove, 16); + g = g + channel(add, 8) - channel(remove, 8); + b = b + channel(add, 0) - channel(remove, 0); + } + } + + for (int x = 0; x < frameWidth; ++x) { + unsigned a = 0, r = 0, g = 0, b = 0; + const auto sample = [&scratch, x, frameHeight, frameWidth](int y) { + y = (std::max)(0, (std::min)(frameHeight - 1, y)); + return scratch[static_cast(y) * frameWidth + x]; + }; + for (int offset = -static_cast(radius); offset <= static_cast(radius); ++offset) { + const auto pixel = sample(offset); + a += channel(pixel, 24); r += channel(pixel, 16); + g += channel(pixel, 8); b += channel(pixel, 0); + } + for (int y = 0; y < frameHeight; ++y) { + frame[static_cast(y) * frameWidth + x] = packAverage(a, r, g, b); + const auto remove = sample(y - static_cast(radius)); + const auto add = sample(y + static_cast(radius) + 1); + a = a + channel(add, 24) - channel(remove, 24); + r = r + channel(add, 16) - channel(remove, 16); + g = g + channel(add, 8) - channel(remove, 8); + b = b + channel(add, 0) - channel(remove, 0); + } + } +} + +} // namespace ac::xbox diff --git a/xbox/runtime/include/ac/runtime.hpp b/xbox/runtime/include/ac/runtime.hpp index b152b8bc7a..71f9d9d167 100644 --- a/xbox/runtime/include/ac/runtime.hpp +++ b/xbox/runtime/include/ac/runtime.hpp @@ -61,6 +61,8 @@ struct SystemGlyph { struct ImageDraw { std::string source = "latest-painting"; float x = 0, y = 0, width = 0, height = 0; + float scale = 1; + bool centered = false; }; class Graphics { @@ -73,6 +75,7 @@ class Graphics { virtual void system_write(const SystemText&) {} virtual void system_glyph(const SystemGlyph&) {} virtual void image(const ImageDraw&) {} + virtual void blur(unsigned) {} }; struct SynthVoice { diff --git a/xbox/runtime/tests/image_effects_contract.cpp b/xbox/runtime/tests/image_effects_contract.cpp new file mode 100644 index 0000000000..b39ddac479 --- /dev/null +++ b/xbox/runtime/tests/image_effects_contract.cpp @@ -0,0 +1,23 @@ +#include "ac/image_effects.hpp" + +#include +#include +#include + +using ac::xbox::BoxBlurBgra; + +int main() { + std::vector scratch; + std::vector horizontal{0xff000000u, 0xffffffffu, 0xff000000u}; + BoxBlurBgra(horizontal, 3, 1, 1, scratch); + assert((horizontal == std::vector{ + 0xff555555u, 0xff555555u, 0xff555555u})); + + std::vector vertical{0xffff0000u, 0xff00ff00u, 0xff0000ffu}; + BoxBlurBgra(vertical, 1, 3, 1, scratch); + assert(vertical[1] == 0xff555555u); + + const auto unchanged = vertical; + BoxBlurBgra(vertical, 1, 3, 0, scratch); + assert(vertical == unchanged); +} diff --git a/xbox/test-native-windows.cmd b/xbox/test-native-windows.cmd index ac50f6cfd4..c7afc76ec6 100644 --- a/xbox/test-native-windows.cmd +++ b/xbox/test-native-windows.cmd @@ -11,7 +11,9 @@ if exist "%BUILD%" rmdir /s /q "%BUILD%" mkdir "%BUILD%" || exit /b 2 node --check "%ROOT%\xbox\live\controller-probe.js" || exit /b 1 +node --check "%ROOT%\xbox\live\native-showcase.js" || exit /b 1 node "%ROOT%\xbox\live\tests\controller-probe.test.mjs" || exit /b 1 +node "%ROOT%\xbox\tools\tests\kidlisp-native.test.mjs" || exit /b 1 cl /nologo /std:c++20 /EHsc /W4 ^ /I"%ROOT%\xbox\runtime\include" ^ @@ -19,6 +21,12 @@ cl /nologo /std:c++20 /EHsc /W4 ^ /Fe:"%BUILD%\runtime-contract.exe" || exit /b 1 "%BUILD%\runtime-contract.exe" || exit /b 1 +cl /nologo /std:c++20 /EHsc /W4 ^ + /I"%ROOT%\xbox\runtime\include" ^ + "%ROOT%\xbox\runtime\tests\image_effects_contract.cpp" ^ + /Fe:"%BUILD%\image-effects-contract.exe" || exit /b 1 +"%BUILD%\image-effects-contract.exe" || exit /b 1 + set "QJS=%ROOT%\xbox\native-bios\third_party\quickjs-ng" for %%f in (quickjs dtoa libregexp libunicode) do ( cl /nologo /c /O2 /TC /std:c17 /experimental:c11atomics ^ diff --git a/xbox/test-native.sh b/xbox/test-native.sh index 3e557a2f5c..8174a5505e 100644 --- a/xbox/test-native.sh +++ b/xbox/test-native.sh @@ -8,6 +8,7 @@ trap 'rm -rf "$build_dir"' EXIT INT TERM node --check "$repo_dir/xbox/live/controller-probe.js" node --check "$repo_dir/xbox/live/native-showcase.js" node "$repo_dir/xbox/live/tests/controller-probe.test.mjs" +node "$repo_dir/xbox/tools/tests/kidlisp-native.test.mjs" clang++ -std=c++20 -Wall -Wextra \ -I "$repo_dir/xbox/runtime/include" \ @@ -15,6 +16,12 @@ clang++ -std=c++20 -Wall -Wextra \ -o "$build_dir/runtime-contract" "$build_dir/runtime-contract" +clang++ -std=c++20 -Wall -Wextra \ + -I "$repo_dir/xbox/runtime/include" \ + "$repo_dir/xbox/runtime/tests/image_effects_contract.cpp" \ + -o "$build_dir/image-effects-contract" +"$build_dir/image-effects-contract" + qjs="$repo_dir/xbox/native-bios/third_party/quickjs-ng" for source_file in quickjs.c dtoa.c libregexp.c libunicode.c; do clang -std=c11 -O2 -DQUICKJS_NG_BUILD \ diff --git a/xbox/tools/kidlisp-native.mjs b/xbox/tools/kidlisp-native.mjs new file mode 100644 index 0000000000..ee74b25ec1 --- /dev/null +++ b/xbox/tools/kidlisp-native.mjs @@ -0,0 +1,162 @@ +// Bounded KidLisp-to-native-QuickJS bridge for the Xbox Native BIOS. +// +// This is intentionally a small compiler, not a second KidLisp runtime. It +// accepts only the drawing vocabulary the native host can faithfully execute +// and emits calls to the existing sandboxed QuickJS bindings. Published source +// is fetched from one fixed Aesthetic Computer endpoint; generated pieces gain +// no fetch, filesystem, DOM, process, or WinRT primitive. + +import { extractAst } from "../../system/backend/kidlisp-ast.mjs"; + +const DEFAULT_ORIGIN = "https://aesthetic.computer"; +const MAX_SOURCE_BYTES = 50_000; +const CODE_PATTERN = /^[0-9A-Za-z]{3,12}$/; +const PAINTING_PATTERN = /^#[0-9A-Za-z]{1,8}$/; + +// Start with the CSS/KidLisp colors needed by the native proof. Extending this +// table is safe and mechanical; unsupported colors fail closed. +const COLORS = Object.freeze({ + black: [0, 0, 0], + white: [255, 255, 255], + red: [255, 0, 0], + green: [0, 128, 0], + blue: [0, 0, 255], + lightblue: [173, 216, 230], +}); + +function normalizeCode(value) { + const code = String(value || "").trim().replace(/^\$/, ""); + if (!CODE_PATTERN.test(code)) throw new Error("KidLisp code must be 3-12 alphanumeric characters"); + return code; +} + +function tree(nodes) { + const byParent = new Map(); + for (const node of nodes) { + const key = node.parent ?? -1; + if (!byParent.has(key)) byParent.set(key, []); + byParent.get(key).push(node); + } + for (const children of byParent.values()) + children.sort((left, right) => left.position - right.position); + const build = (node) => ({ ...node, children: (byParent.get(node.id) || []).map(build) }); + return (byParent.get(-1) || []).map(build); +} + +function atom(node, label) { + if (!node || node.kind === "call" || typeof node.literal !== "string") + throw new Error(`${label} must be a literal`); + return node.literal; +} + +function number(node, label) { + const value = Number(atom(node, label)); + if (!Number.isFinite(value)) throw new Error(`${label} must be a finite number`); + return value; +} + +function color(name) { + const value = COLORS[String(name).toLowerCase()]; + if (!value) throw new Error(`unsupported native KidLisp color: ${name}`); + return value; +} + +function compileForm(form, paintings) { + if (form.kind !== "call" || !form.op) throw new Error("native KidLisp expects top-level calls"); + const op = form.op.toLowerCase(); + const args = form.children; + + if (Object.hasOwn(COLORS, op) && args.length === 0) { + const [r, g, b] = color(op); + return `wipe(${r},${g},${b});`; + } + + if (op === "wipe") { + if (args.length === 1) { + const [r, g, b] = color(atom(args[0], "wipe color")); + return `wipe(${r},${g},${b});`; + } + if (args.length === 3) { + const channels = args.map((item, index) => number(item, `wipe channel ${index + 1}`)); + if (channels.some((item) => item < 0 || item > 255)) + throw new Error("wipe channels must be between 0 and 255"); + return `wipe(${channels.map((item) => Math.round(item)).join(",")});`; + } + throw new Error("native wipe accepts a color or three RGB channels"); + } + + if (op === "stamp") { + if (args.length < 1 || args.length > 4) throw new Error("native stamp accepts #code [x y scale]"); + const painting = atom(args[0], "stamp painting"); + if (!PAINTING_PATTERN.test(painting)) + throw new Error("native stamp accepts only a short #painting code"); + paintings.add(painting.slice(1)); + const x = args[1] ? number(args[1], "stamp x") : null; + const y = args[2] ? number(args[2], "stamp y") : null; + const scale = args[3] ? number(args[3], "stamp scale") : 1; + if (Math.abs(scale) > 8) throw new Error("native stamp scale is limited to +/-8"); + // KidLisp chooses a fresh random center when x/y are omitted. + return `stampPainting(${JSON.stringify(painting)},${x ?? "Math.random()*runtime().width"},${y ?? "Math.random()*runtime().height"},${scale});`; + } + + if (op === "blur") { + if (args.length !== 1) throw new Error("native blur accepts one strength"); + const strength = number(args[0], "blur strength"); + if (strength < 0 || strength > 48) + throw new Error("native blur strength must be between 0 and 48"); + // Browser graph.blur scales KidLisp strength by three before choosing its + // separable kernel. Preserve that visible radius for the native box pass. + const radius = strength <= 0.1 ? 0 : Math.max(1, Math.floor(strength / 3)); + return `blur(${radius});`; + } + + throw new Error(`unsupported native KidLisp form: ${form.op}`); +} + +export function compileKidLispSource(source, { code = "local" } = {}) { + if (typeof source !== "string" || !source.trim()) throw new Error("KidLisp source is empty"); + if (Buffer.byteLength(source, "utf8") > MAX_SOURCE_BYTES) + throw new Error(`KidLisp source exceeds ${MAX_SOURCE_BYTES} bytes`); + const forms = tree(extractAst(source)); + if (forms.length === 0 || forms.length > 256) throw new Error("native KidLisp requires 1-256 forms"); + const paintings = new Set(); + const body = forms.map((form) => compileForm(form, paintings)); + const safeCode = String(code).replace(/[^0-9A-Za-z_-]/g, "").slice(0, 24) || "local"; + const generated = [ + `// Generated from KidLisp $${safeCode}; native subset v1.`, + `function boot(){telemetry("KIDLISP_BOOT",${JSON.stringify(`$${safeCode} forms=${forms.length} paintings=${[...paintings].join(",")}`)});}`, + "function sim(){}", + `function paint(){${body.join("")}}`, + "function act(button){telemetry(\"KIDLISP_BUTTON\",button);}", + "function leave(){telemetry(\"KIDLISP_LEAVE\",\"ok\");}", + "", + ].join("\n"); + return { code: safeCode, source, generated, formCount: forms.length, paintings: [...paintings] }; +} + +export async function fetchPublishedKidLisp(value, { fetchImpl = globalThis.fetch } = {}) { + if (typeof fetchImpl !== "function") throw new Error("fetch is unavailable"); + const code = normalizeCode(value); + const url = new URL("/api/store-kidlisp", DEFAULT_ORIGIN); + url.searchParams.set("code", code); + const response = await fetchImpl(url, { + headers: { accept: "application/json", "user-agent": "AC-Xbox-KidLisp-Bridge/1" }, + redirect: "error", + }); + if (!response.ok) throw new Error(`KidLisp $${code} fetch failed with HTTP ${response.status}`); + const declared = Number(response.headers.get("content-length") || 0); + if (declared > MAX_SOURCE_BYTES * 2) throw new Error("KidLisp response is too large"); + const text = await response.text(); + if (Buffer.byteLength(text, "utf8") > MAX_SOURCE_BYTES * 2) + throw new Error("KidLisp response is too large"); + const payload = JSON.parse(text); + if (!payload || typeof payload.source !== "string") throw new Error(`KidLisp $${code} has no source`); + return { code, source: payload.source, handle: payload.handle || "anon", when: payload.when || null }; +} + +export async function compilePublishedKidLisp(value, options = {}) { + const published = await fetchPublishedKidLisp(value, options); + return { ...published, ...compileKidLispSource(published.source, { code: published.code }) }; +} + +export const __testing = { COLORS, MAX_SOURCE_BYTES, normalizeCode }; diff --git a/xbox/tools/live.mjs b/xbox/tools/live.mjs index 3f98ac7235..b87986d437 100644 --- a/xbox/tools/live.mjs +++ b/xbox/tools/live.mjs @@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { basename, resolve } from "node:path"; +import { compilePublishedKidLisp } from "./kidlisp-native.mjs"; const defaultEnv = resolve(homedir(), "aesthetic-computer/aesthetic-computer-vault/xbox/device-portal.env"); @@ -41,9 +42,10 @@ const base = `https://${host}:${port}`; const auth = `${username}:${password}`; const autoAuth = `auto-${username}:${password}`; -function curl(args, { json = false } = {}) { +function curl(args, { json = false, input } = {}) { const result = spawnSync("curl", ["-k", "-sS", ...args], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024, + ...(input === undefined ? {} : { input }), }); if (result.status !== 0) throw new Error(result.stderr.trim() || `curl exited ${result.status}`); return json ? JSON.parse(result.stdout || "null") : result.stdout; @@ -139,13 +141,35 @@ function publish(sourcePath) { console.log(JSON.stringify({ published: absolute, package: item.PackageFullName })); } +function publishSource(source, label) { + if (typeof source !== "string" || source.length === 0 || + Buffer.byteLength(source, "utf8") > 2 * 1024 * 1024) + throw new Error("generated piece source is empty or exceeds 2 MiB"); + const item = installed(); + curl(["-u", autoAuth, "-X", "POST", "-F", + "file=@-;filename=live-piece.js;type=application/javascript", appFileUrl(item)], { input: source }); + console.log(JSON.stringify({ published: label, bytes: Buffer.byteLength(source, "utf8"), + package: item.PackageFullName })); +} + +async function deployKidLisp(code) { + if (!code) throw new Error("usage: xbox-live deploy-kidlisp <$code>"); + const compiled = await compilePublishedKidLisp(code); + publishSource(compiled.generated, `$${compiled.code} by ${compiled.handle}`); + console.log(JSON.stringify({ kidlisp: `$${compiled.code}`, handle: compiled.handle, + forms: compiled.formCount, paintings: compiled.paintings })); + launch(); + sleep(1200); + logs("30"); +} + function logs(tail = "80") { const count = Math.max(1, Math.min(5000, Number.parseInt(tail, 10) || 80)); const content = curl(["-u", autoAuth, appFileUrl(installed(), "ac-native-bios.log")]); console.log(content.trimEnd().split(/\r?\n/).slice(-count).join("\n")); } -function main() { +async function main() { const [command = "status", argument, ...rest] = process.argv.slice(2); if (command === "status") status(); else if (command === "install") install(argument, rest); @@ -154,7 +178,8 @@ function main() { else if (command === "publish") publish(argument); else if (command === "logs") logs(argument); else if (command === "deploy") { publish(argument); launch(); logs("20"); } - else throw new Error("commands: status | install [deps...] | prune | launch | publish | logs [lines] | deploy "); + else if (command === "deploy-kidlisp") await deployKidLisp(argument); + else throw new Error("commands: status | install [deps...] | prune | launch | publish | logs [lines] | deploy | deploy-kidlisp <$code>"); } -try { main(); } catch (error) { console.error(error.message); process.exit(1); } +try { await main(); } catch (error) { console.error(error.message); process.exit(1); } diff --git a/xbox/tools/tests/kidlisp-native.test.mjs b/xbox/tools/tests/kidlisp-native.test.mjs new file mode 100644 index 0000000000..98d1353021 --- /dev/null +++ b/xbox/tools/tests/kidlisp-native.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { compileKidLispSource, compilePublishedKidLisp } from "../kidlisp-native.mjs"; + +const obk = compileKidLispSource("lightblue\nstamp #j8t \nblur 4", { code: "obk" }); +assert.equal(obk.formCount, 3); +assert.deepEqual(obk.paintings, ["j8t"]); +assert.match(obk.generated, /wipe\(173,216,230\)/); +assert.match(obk.generated, /stampPainting\("#j8t",Math\.random\(\)\*runtime\(\)\.width,Math\.random\(\)\*runtime\(\)\.height,1\)/); +assert.match(obk.generated, /blur\(1\)/); + +assert.throws(() => compileKidLispSource("stamp https://example.com/a.png"), /only a short #painting code/); +assert.throws(() => compileKidLispSource("fetch https://example.com"), /unsupported native KidLisp form/); +assert.throws(() => compileKidLispSource("blur 100"), /between 0 and 48/); +assert.throws(() => compileKidLispSource("$other"), /unsupported native KidLisp form/); + +let requested; +const remote = await compilePublishedKidLisp("$obk", { + fetchImpl: async (url, options) => { + requested = { url: String(url), options }; + return new Response(JSON.stringify({ source: "lightblue\nstamp #j8t\nblur 4", handle: "@fifi" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }, +}); +assert.equal(requested.url, "https://aesthetic.computer/api/store-kidlisp?code=obk"); +assert.equal(requested.options.redirect, "error"); +assert.equal(remote.handle, "@fifi"); +assert.deepEqual(remote.paintings, ["j8t"]); + +console.log("xbox native KidLisp bridge: tests passed");