From 0a630241497ead52ef83003eb9960996b006fd60 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 25 Jul 2026 19:58:33 -0700 Subject: [PATCH] Read and display Xbox photo discs --- xbox/live/photo-disc.js | 75 ++++ xbox/native-bios/App.cpp | 19 +- xbox/native-bios/NativeBios.vcxproj | 1 + xbox/native-bios/Package.appxmanifest | 20 +- xbox/native-bios/PhotoDiscService.cpp | 335 ++++++++++++++++++ xbox/native-bios/PhotoDiscService.hpp | 47 +++ xbox/native-bios/QuickJsEngine.cpp | 73 ++++ xbox/native-bios/README.md | 16 +- .../tests/quickjs_engine_smoke.cpp | 22 +- xbox/runtime/include/ac/runtime.hpp | 27 ++ xbox/test-native-windows.cmd | 1 + 11 files changed, 619 insertions(+), 17 deletions(-) create mode 100644 xbox/live/photo-disc.js create mode 100644 xbox/native-bios/PhotoDiscService.cpp create mode 100644 xbox/native-bios/PhotoDiscService.hpp diff --git a/xbox/live/photo-disc.js b/xbox/live/photo-disc.js new file mode 100644 index 000000000..43ebe53fd --- /dev/null +++ b/xbox/live/photo-disc.js @@ -0,0 +1,75 @@ +let previousDown = []; +let lastAdvance = 0; + +function boot() { + telemetry("PHOTO_DISC_BOOT", "scan"); + discScan(); +} + +function pressed(name, down) { + return down.includes(name) && !previousDown.includes(name); +} + +function sim() { + const state = disc(); + const down = gamepad().down; + if (pressed("Y", down)) discScan(); + if (pressed("X", down)) discCopy(); + if (state.count > 0 && state.status !== "scanning") { + if (pressed("ArrowLeft", down) || pressed("LeftShoulder", down)) { + discShow(state.index - 1); + lastAdvance = runtime().monotonicUs; + } else if (pressed("ArrowRight", down) || pressed("RightShoulder", down) || + pressed("A", down)) { + discShow(state.index + 1); + lastAdvance = runtime().monotonicUs; + } + } + const now = runtime().monotonicUs; + if (state.currentReady && state.count > 1 && + (lastAdvance === 0 || now - lastAdvance >= 7000000)) { + discShow(state.index + 1); + lastAdvance = now; + } + previousDown = down.slice(); +} + +function paint() { + const state = disc(); + wipe(5, 5, 8); + + if (state.currentReady && state.width > 0 && state.height > 0) { + const availableWidth = 1840; + const availableHeight = 930; + const scale = Math.min(availableWidth / state.width, availableHeight / state.height); + const width = Math.max(1, state.width * scale); + const height = Math.max(1, state.height * scale); + discPhoto((1920 - width) / 2, (990 - height) / 2, width, height); + } else { + systemGlyph("Pictures", 850, 330, 220, 80, 90, 115); + systemWrite(state.status === "scanning" ? "SEARCHING PHOTO CD" : + state.status === "empty" ? "NO PHOTOS FOUND" : + state.status.startsWith("error") ? "DISC NOT MOUNTED" : + state.status.startsWith("decode-error") ? "IMAGE COULD NOT DECODE" : + "LOADING PHOTO", 640, 590, 46, 235, 235, 242); + } + + box(0, 990, 1920, 90, 10, 11, 18); + const position = state.count > 0 ? (state.index + 1) + " / " + state.count : "0 / 0"; + systemWrite(position, 42, 1008, 30, 255, 225, 95); + systemWrite((state.name || state.status || "PHOTO DISC").slice(0, 72), + 205, 1008, 27, 238, 238, 245); + const copy = state.copyStatus === "copying" ? + "COPY " + state.copied + "/" + state.count : + state.copyStatus === "complete" ? "COPIED " + state.copied : + "A/NEXT DPAD/BROWSE X/COPY Y/RESCAN"; + write(copy, 1210, 1022, 12, 155, 180, 215); +} + +function act(button) { + telemetry("PHOTO_DISC_BUTTON", button); +} + +function leave() { + telemetry("PHOTO_DISC_LEAVE", "ok"); +} diff --git a/xbox/native-bios/App.cpp b/xbox/native-bios/App.cpp index 0eb57ad63..7b2fdae3b 100644 --- a/xbox/native-bios/App.cpp +++ b/xbox/native-bios/App.cpp @@ -1,5 +1,6 @@ #include "pch.h" #include "QuickJsEngine.hpp" +#include "PhotoDiscService.hpp" #include "../runtime/include/ac/image_effects.hpp" using Microsoft::WRL::ComPtr; @@ -27,12 +28,7 @@ namespace NativeBios { using namespace ac::xbox; -struct PaintingImage { - unsigned width = 0; - unsigned height = 0; - std::string url; - std::vector pixels; -}; +using PaintingImage = PhotoDiscImage; struct GpuTriangleVertex { float x, y, z; @@ -311,13 +307,18 @@ 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.24"; + m_api->system.version = "1.0.0.25"; m_api->telemetry = [](std::string_view line) { std::string safe(line); for (auto& character : safe) if (character == '\n' || character == '\r') character = ' '; if (safe.size() > 1024) safe.resize(1024); LogTelemetry("AC_NATIVE_" + safe); }; + m_photoDisc = std::make_unique(*m_api, + [this](std::shared_ptr image) { + std::lock_guard lock(m_imageMutex); + m_paintingImages["disc-photo"] = std::move(image); + }, [](const std::string& line) { LogTelemetry(line); }); InitializeMidi(); InitializeNetworkMidi(); RefreshCapabilities(true); @@ -333,6 +334,7 @@ public: OutputDebugStringA("AC_NATIVE_BIOS_READY engine=quickjs-ng piece=smoke\n"); LogTelemetry("AC_NATIVE_BIOS_READY engine=quickjs-ng piece=smoke"); } + m_photoDisc->scan(); } virtual void Load(String^) {} @@ -1486,7 +1488,7 @@ private: } void RequestFrameImage(const std::string& source) { - if (source == "latest-painting") return; + if (source == "latest-painting" || source == "disc-photo") 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') || @@ -2002,6 +2004,7 @@ private: std::unique_ptr m_api; std::unique_ptr m_engine; std::unique_ptr m_supervisor; + std::unique_ptr m_photoDisc; }; ref class AppSource sealed : public IFrameworkViewSource { diff --git a/xbox/native-bios/NativeBios.vcxproj b/xbox/native-bios/NativeBios.vcxproj index b88b7f280..0c4d3ab2c 100644 --- a/xbox/native-bios/NativeBios.vcxproj +++ b/xbox/native-bios/NativeBios.vcxproj @@ -44,6 +44,7 @@ Create + NotUsing diff --git a/xbox/native-bios/Package.appxmanifest b/xbox/native-bios/Package.appxmanifest index 5578e5fde..f5f8ac31e 100644 --- a/xbox/native-bios/Package.appxmanifest +++ b/xbox/native-bios/Package.appxmanifest @@ -5,7 +5,7 @@ IgnorableNamespaces="uap mp"> + Version="1.0.0.25" /> @@ -29,10 +29,28 @@ + + + + Photo disc images + + .jpg + .jpeg + .jpe + .png + .tif + .tiff + .pcd + + + + + + diff --git a/xbox/native-bios/PhotoDiscService.cpp b/xbox/native-bios/PhotoDiscService.cpp new file mode 100644 index 000000000..a72fcf22e --- /dev/null +++ b/xbox/native-bios/PhotoDiscService.cpp @@ -0,0 +1,335 @@ +#include "pch.h" +#include "PhotoDiscService.hpp" + +#include + +using namespace Platform; +using namespace Windows::Foundation; +using namespace Windows::Graphics::Imaging; +using namespace Windows::Storage; +using namespace Windows::Storage::Streams; +using namespace concurrency; + +namespace NativeBios { +namespace { + +constexpr std::size_t kMaxPhotoFiles = 4096; +constexpr unsigned kMaxFolderDepth = 32; +constexpr std::uint64_t kMaxEncodedBytes = 128ull * 1024 * 1024; +constexpr unsigned kMaxDecodedSide = 2048; + +std::string utf8(String^ value) { + if (!value || value->IsEmpty()) return {}; + const int size = WideCharToMultiByte(CP_UTF8, 0, value->Data(), value->Length(), + nullptr, 0, nullptr, nullptr); + std::string result(static_cast(size), '\0'); + WideCharToMultiByte(CP_UTF8, 0, value->Data(), value->Length(), result.data(), + size, nullptr, nullptr); + return result; +} + +bool is_photo_file(StorageFile^ file) { + if (!file) return false; + auto extension = utf8(file->FileType); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char value) { return static_cast(std::tolower(value)); }); + return extension == ".jpg" || extension == ".jpeg" || extension == ".jpe" || + extension == ".png" || extension == ".tif" || extension == ".tiff" || + extension == ".pcd"; +} + +std::string clean_error(String^ value) { + auto result = utf8(value); + for (auto& character : result) + if (character == '\r' || character == '\n') character = ' '; + if (result.size() > 512) result.resize(512); + return result; +} + +} // namespace + +PhotoDiscService::PhotoDiscService(ac::xbox::Api& api, ImageReady image_ready, + Logger logger) + : m_api(api), m_imageReady(std::move(image_ready)), m_log(std::move(logger)) { + m_api.disc.scan = [this]() { scan(); }; + m_api.disc.show = [this](std::int64_t index) { show(index); }; + m_api.disc.copy = [this]() { copy_all(); }; +} + +void PhotoDiscService::update( + const std::function& edit) { + const auto current = std::atomic_load(&m_api.disc.snapshot); + auto next = std::make_shared(); + if (current) *next = *current; + edit(*next); + std::atomic_store(&m_api.disc.snapshot, + std::static_pointer_cast(next)); +} + +task PhotoDiscService::collect(StorageFolder^ folder, + const std::shared_ptr>& output, unsigned depth) { + if (!folder || depth > kMaxFolderDepth || output->size() >= kMaxPhotoFiles) + return task_from_result(); + return create_task(folder->GetFilesAsync()).then( + [folder, output](IVectorView^ files) { + for (auto file : files) { + if (output->size() >= kMaxPhotoFiles) break; + if (is_photo_file(file)) output->push_back(file); + } + return create_task(folder->GetFoldersAsync()); + }).then([this, output, depth](IVectorView^ folders) { + task chain = task_from_result(); + for (auto child : folders) { + chain = chain.then([this, child, output, depth]() { + return collect(child, output, depth + 1); + }); + } + return chain; + }); +} + +void PhotoDiscService::fail_scan(const std::string& message) { + update([&message](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.status = "error: " + message; + snapshot.count = 0; + snapshot.current_ready = false; + }); + if (m_log) m_log("AC_NATIVE_DISC_ERROR " + message); +} + +void PhotoDiscService::scan() { + bool expected = false; + if (!m_scanInFlight.compare_exchange_strong(expected, true)) return; + ++m_loadGeneration; + { + std::lock_guard lock(m_filesMutex); + m_files.clear(); + m_volume.clear(); + } + update([](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot = {}; + snapshot.status = "scanning"; + }); + if (m_log) m_log("AC_NATIVE_DISC_SCAN begin=1"); + + auto photos = std::make_shared>(); + auto volumeNames = std::make_shared>(); + create_task(KnownFolders::RemovableDevices->GetFoldersAsync()).then( + [this, photos, volumeNames](IVectorView^ volumes) { + if (!volumes || volumes->Size == 0) + throw std::runtime_error("no mounted removable volume"); + task chain = task_from_result(); + for (auto volume : volumes) { + volumeNames->push_back(utf8(volume->Name)); + chain = chain.then([this, volume, photos]() { + return collect(volume, photos, 0); + }); + } + return chain; + }).then([this, photos, volumeNames](task completed) { + try { + completed.get(); + std::sort(photos->begin(), photos->end(), [](StorageFile^ left, StorageFile^ right) { + return utf8(left ? left->Path : nullptr) < utf8(right ? right->Path : nullptr); + }); + std::string volume; + for (const auto& name : *volumeNames) { + if (!volume.empty()) volume += ", "; + volume += name; + } + { + std::lock_guard lock(m_filesMutex); + m_files = *photos; + m_volume = volume; + } + update([photos, &volume](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.status = photos->empty() ? "empty" : "ready"; + snapshot.volume = volume; + snapshot.count = photos->size(); + snapshot.index = 0; + snapshot.current_ready = false; + }); + if (m_log) m_log("AC_NATIVE_DISC_READY volumes=" + + std::to_string(volumeNames->size()) + " photos=" + + std::to_string(photos->size()) + " formats=jpg,jpeg,jpe,png,tif,tiff,pcd"); + m_scanInFlight = false; + if (!photos->empty()) show(0); + return; + } catch (Exception^ error) { + fail_scan(clean_error(error->Message)); + } catch (const std::exception& error) { + fail_scan(error.what()); + } + m_scanInFlight = false; + }); +} + +void PhotoDiscService::show(std::int64_t requested_index) { + StorageFile^ file = nullptr; + std::size_t index = 0; + std::size_t count = 0; + { + std::lock_guard lock(m_filesMutex); + count = m_files.size(); + if (count == 0) return; + const auto modulus = static_cast(count); + index = static_cast((requested_index % modulus + modulus) % modulus); + file = m_files[index]; + } + if (!file) return; + const auto generation = ++m_loadGeneration; + const auto name = utf8(file->Name); + update([index, count, &name](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.status = "loading"; + snapshot.index = index; + snapshot.count = count; + snapshot.name = name; + snapshot.width = 0; + snapshot.height = 0; + snapshot.current_ready = false; + }); + if (m_log) m_log("AC_NATIVE_DISC_LOAD index=" + std::to_string(index) + + " name=" + name); + + create_task(file->OpenAsync(FileAccessMode::Read)).then( + [](IRandomAccessStreamWithContentType^ stream) { + if (!stream || stream->Size == 0 || stream->Size > kMaxEncodedBytes) + throw std::runtime_error("photo payload is empty or exceeds 128 MiB"); + return create_task(BitmapDecoder::CreateAsync(stream)).then( + [stream](BitmapDecoder^ decoder) { + if (!decoder || decoder->PixelWidth == 0 || decoder->PixelHeight == 0) + throw std::runtime_error("image decoder returned an empty frame"); + const double scale = (std::min)(1.0, kMaxDecodedSide / + static_cast((std::max)(decoder->PixelWidth, decoder->PixelHeight))); + const unsigned width = (std::max)(1u, + static_cast(decoder->PixelWidth * scale)); + const unsigned height = (std::max)(1u, + static_cast(decoder->PixelHeight * scale)); + auto transform = ref new BitmapTransform(); + transform->ScaledWidth = width; + transform->ScaledHeight = height; + return create_task(decoder->GetPixelDataAsync(BitmapPixelFormat::Bgra8, + BitmapAlphaMode::Straight, transform, ExifOrientationMode::RespectExifOrientation, + ColorManagementMode::ColorManageToSRgb)).then( + [stream, width, height](PixelDataProvider^ provider) { + const auto bytes = provider->DetachPixelData(); + const auto pixelCount = static_cast(width) * height; + if (!bytes || bytes->Length < pixelCount * 4) + throw std::runtime_error("image decoder returned a short pixel buffer"); + auto image = std::make_shared(); + image->width = width; + image->height = height; + image->pixels.resize(pixelCount); + for (std::size_t i = 0; i < pixelCount; ++i) { + const auto offset = i * 4; + image->pixels[i] = (static_cast(bytes[offset + 3]) << 24) | + (static_cast(bytes[offset + 2]) << 16) | + (static_cast(bytes[offset + 1]) << 8) | + static_cast(bytes[offset]); + } + return image; + }); + }); + }).then([this, generation, index, count, name]( + task> completed) { + if (generation != m_loadGeneration.load()) return; + try { + auto image = completed.get(); + if (m_imageReady) + m_imageReady(std::static_pointer_cast(image)); + update([index, count, &name, &image](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.status = "ready"; + snapshot.index = index; + snapshot.count = count; + snapshot.name = name; + snapshot.width = image->width; + snapshot.height = image->height; + snapshot.current_ready = true; + }); + if (m_log) m_log("AC_NATIVE_DISC_IMAGE_READY index=" + + std::to_string(index) + " size=" + std::to_string(image->width) + "x" + + std::to_string(image->height) + " name=" + name); + } catch (Exception^ error) { + const auto message = clean_error(error->Message); + update([&message](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.status = "decode-error: " + message; + snapshot.current_ready = false; + }); + if (m_log) m_log("AC_NATIVE_DISC_DECODE_ERROR " + message); + } catch (const std::exception& error) { + const std::string message(error.what()); + update([&message](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.status = "decode-error: " + message; + snapshot.current_ready = false; + }); + if (m_log) m_log("AC_NATIVE_DISC_DECODE_ERROR " + message); + } + }); +} + +void PhotoDiscService::copy_all() { + bool expected = false; + if (!m_copyInFlight.compare_exchange_strong(expected, true)) return; + auto files = std::make_shared>(); + { + std::lock_guard lock(m_filesMutex); + *files = m_files; + } + if (files->empty()) { + update([](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.copy_status = "no-photos"; + }); + m_copyInFlight = false; + return; + } + auto copied = std::make_shared(0); + auto failed = std::make_shared(0); + update([](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.copy_status = "copying"; + snapshot.copied = 0; + snapshot.copy_failed = 0; + }); + create_task(ApplicationData::Current->LocalFolder->CreateFolderAsync( + L"photo-cd", CreationCollisionOption::OpenIfExists)).then( + [this, files, copied, failed](StorageFolder^ destination) { + task chain = task_from_result(); + for (std::size_t index = 0; index < files->size(); ++index) { + auto file = (*files)[index]; + chain = chain.then([this, destination, file, index, copied, failed]() { + std::wstring name = file && file->Name ? file->Name->Data() : L"photo"; + if (name.size() > 220) name.resize(220); + wchar_t prefix[24]{}; + swprintf_s(prefix, L"%05llu-", static_cast(index + 1)); + name.insert(0, prefix); + return create_task(file->CopyAsync(destination, ref new String(name.c_str()), + NameCollisionOption::ReplaceExisting)).then( + [this, copied, failed](task completed) { + try { completed.get(); ++(*copied); } + catch (...) { ++(*failed); } + update([copied, failed](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.copied = copied->load(); + snapshot.copy_failed = failed->load(); + }); + }); + }); + } + return chain; + }).then([this, files, copied, failed](task completed) { + std::string error; + try { completed.get(); } + catch (Exception^ value) { error = clean_error(value->Message); } + catch (const std::exception& value) { error = value.what(); } + update([&error, copied, failed](ac::xbox::PhotoDiscSnapshot& snapshot) { + snapshot.copy_status = error.empty() ? "complete" : "error: " + error; + snapshot.copied = copied->load(); + snapshot.copy_failed = failed->load(); + }); + if (m_log) m_log("AC_NATIVE_DISC_COPY copied=" + + std::to_string(copied->load()) + " failed=" + + std::to_string(failed->load()) + " total=" + + std::to_string(files->size()) + (error.empty() ? "" : " error=" + error)); + m_copyInFlight = false; + }); +} + +} // namespace NativeBios diff --git a/xbox/native-bios/PhotoDiscService.hpp b/xbox/native-bios/PhotoDiscService.hpp new file mode 100644 index 000000000..150308d55 --- /dev/null +++ b/xbox/native-bios/PhotoDiscService.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "../runtime/include/ac/runtime.hpp" + +namespace NativeBios { + +struct PhotoDiscImage { + unsigned width = 0; + unsigned height = 0; + std::string url; + std::vector pixels; +}; + +// Privileged Xbox/UWP boundary for removable photo media. This service owns +// every StorageFile and decoder object; sandboxed JavaScript sees only the +// bounded callbacks and immutable snapshot installed on Api::disc. +class PhotoDiscService final { + public: + using ImageReady = std::function)>; + using Logger = std::function; + + PhotoDiscService(ac::xbox::Api& api, ImageReady image_ready, Logger logger); + + void scan(); + void show(std::int64_t requested_index); + void copy_all(); + + private: + concurrency::task collect( + Windows::Storage::StorageFolder^ folder, + const std::shared_ptr>& output, + unsigned depth); + void update(const std::function& edit); + void fail_scan(const std::string& message); + + ac::xbox::Api& m_api; + ImageReady m_imageReady; + Logger m_log; + std::mutex m_filesMutex; + std::vector m_files; + std::string m_volume; + std::atomic_bool m_scanInFlight{false}; + std::atomic_bool m_copyInFlight{false}; + std::atomic_uint64_t m_loadGeneration{0}; +}; + +} // namespace NativeBios diff --git a/xbox/native-bios/QuickJsEngine.cpp b/xbox/native-bios/QuickJsEngine.cpp index 78965fedd..abf6fb92c 100644 --- a/xbox/native-bios/QuickJsEngine.cpp +++ b/xbox/native-bios/QuickJsEngine.cpp @@ -545,6 +545,74 @@ JSValue AcData(JSContext* context, JSValueConst, int, JSValueConst*) { return result; } +JSValue DiscState(JSContext* context, JSValueConst, int, JSValueConst*) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + if (!scope || !scope->api) return JS_EXCEPTION; + const auto snapshot = std::atomic_load(&scope->api->disc.snapshot); + JSValue result = JS_NewObject(context); + if (!snapshot) return result; + JS_SetPropertyStr(context, result, "status", JS_NewString(context, snapshot->status.c_str())); + JS_SetPropertyStr(context, result, "volume", JS_NewString(context, snapshot->volume.c_str())); + JS_SetPropertyStr(context, result, "name", JS_NewString(context, snapshot->name.c_str())); + JS_SetPropertyStr(context, result, "count", JS_NewInt64(context, + static_cast(snapshot->count))); + JS_SetPropertyStr(context, result, "index", JS_NewInt64(context, + static_cast(snapshot->index))); + JS_SetPropertyStr(context, result, "width", JS_NewInt32(context, snapshot->width)); + JS_SetPropertyStr(context, result, "height", JS_NewInt32(context, snapshot->height)); + JS_SetPropertyStr(context, result, "currentReady", + JS_NewBool(context, snapshot->current_ready)); + JS_SetPropertyStr(context, result, "copyStatus", + JS_NewString(context, snapshot->copy_status.c_str())); + JS_SetPropertyStr(context, result, "copied", JS_NewInt64(context, + static_cast(snapshot->copied))); + JS_SetPropertyStr(context, result, "copyFailed", JS_NewInt64(context, + static_cast(snapshot->copy_failed))); + return result; +} + +JSValue DiscScan(JSContext* context, JSValueConst, int, JSValueConst*) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + if (!scope || !scope->api) return JS_EXCEPTION; + if (!scope->api->disc.scan) return JS_NewBool(context, false); + scope->api->disc.scan(); + return JS_NewBool(context, true); +} + +JSValue DiscShow(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + std::int64_t index = 0; + if (!scope || !scope->api || argc < 1 || JS_ToInt64(context, &index, argv[0])) + return JS_EXCEPTION; + if (!scope->api->disc.show) return JS_NewBool(context, false); + scope->api->disc.show(index); + return JS_NewBool(context, true); +} + +JSValue DiscCopy(JSContext* context, JSValueConst, int, JSValueConst*) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + if (!scope || !scope->api) return JS_EXCEPTION; + if (!scope->api->disc.copy) return JS_NewBool(context, false); + scope->api->disc.copy(); + return JS_NewBool(context, true); +} + +JSValue DiscPhoto(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + double x = 0, y = 0, width = 1920, height = 1080; + if (!scope || !scope->api) return JS_EXCEPTION; + if (argc > 0 && JS_ToFloat64(context, &x, argv[0])) return JS_EXCEPTION; + if (argc > 1 && JS_ToFloat64(context, &y, argv[1])) return JS_EXCEPTION; + if (argc > 2 && JS_ToFloat64(context, &width, argv[2])) return JS_EXCEPTION; + if (argc > 3 && JS_ToFloat64(context, &height, argv[3])) return JS_EXCEPTION; + if (!std::isfinite(x) || !std::isfinite(y) || !std::isfinite(width) || + !std::isfinite(height) || width <= 0 || height <= 0 || width > 3840 || + height > 2160) return JS_ThrowRangeError(context, "invalid disc photo rectangle"); + scope->api->graphics.image({"disc-photo", static_cast(x), + static_cast(y), static_cast(width), static_cast(height)}); + return JS_UNDEFINED; +} + class QuickJsPiece final : public JsPiece { public: QuickJsPiece(const PieceBundle& bundle, const JsLimits& limits, std::string& error) @@ -580,6 +648,11 @@ class QuickJsPiece final : public JsPiece { JS_SetPropertyStr(context_, global, "controllers", JS_NewCFunction(context_, Controllers, "controllers", 0)); JS_SetPropertyStr(context_, global, "capabilities", JS_NewCFunction(context_, Capabilities, "capabilities", 0)); JS_SetPropertyStr(context_, global, "ac", JS_NewCFunction(context_, AcData, "ac", 0)); + JS_SetPropertyStr(context_, global, "disc", JS_NewCFunction(context_, DiscState, "disc", 0)); + JS_SetPropertyStr(context_, global, "discScan", JS_NewCFunction(context_, DiscScan, "discScan", 0)); + JS_SetPropertyStr(context_, global, "discShow", JS_NewCFunction(context_, DiscShow, "discShow", 1)); + JS_SetPropertyStr(context_, global, "discCopy", JS_NewCFunction(context_, DiscCopy, "discCopy", 0)); + JS_SetPropertyStr(context_, global, "discPhoto", JS_NewCFunction(context_, DiscPhoto, "discPhoto", 4)); JS_FreeValue(context_, global); JSValue result = JS_Eval(context_, bundle.source.data(), bundle.source.size(), bundle.slug.c_str(), JS_EVAL_TYPE_GLOBAL); diff --git a/xbox/native-bios/README.md b/xbox/native-bios/README.md index b92550519..9a934a569 100644 --- a/xbox/native-bios/README.md +++ b/xbox/native-bios/README.md @@ -21,10 +21,23 @@ Current bindings are `wipe`, queued `box`, `line`, bitmap `write`, native `systemWrite`, Segoe MDL2 `systemGlyph`, latest AC `painting`, one-shot `synth`, continuous `oscillator` / `oscillatorStop`, `controllers`, `gamepad`, `capabilities`, `runtime`, the host-mediated `ac` feed, and bounded structured -`telemetry`. The `ac` snapshot polls only declared Aesthetic Computer mood, +`telemetry`. Revision 25 adds the host-mediated `disc`, `discScan`, `discShow`, +`discPhoto`, and `discCopy` photo-disc surface. The `ac` snapshot polls only declared Aesthetic Computer mood, clock-chat, and painting endpoints; sandboxed pieces do not receive a general HTTP primitive. Runtime failures roll back to the last known good piece. +The photo-disc service recursively searches mounted removable volumes for +`.jpg`, `.jpeg`, `.jpe`, `.png`, `.tif`, `.tiff`, and `.pcd`. It keeps WinRT +`StorageFile` objects and paths inside the native host, bounds discovery to +4,096 photos, bounds encoded input to 128 MiB, decodes through Windows Imaging +to an sRGB image no larger than 2,048 pixels per side, and publishes only an +immutable status snapshot to JavaScript. `discPhoto` draws the current decoded +image through the existing scene texture path. `discCopy` makes a flat, +numbered copy of the discovered photos under `LocalState/photo-cd`, where Xbox +Device Portal can retrieve them. `.pcd` is inventoried because Kodak Photo CDs +commonly use it; display still depends on the Xbox Windows image decoder having +a codec for that particular file. + Revision 22 turns the existing `Windows.Devices.Midi` probe into a hot-plug monophonic instrument. Note On/Off gates a native XAudio2 sine oscillator, 14-bit pitch bend shifts it continuously, CC1 is exposed for modulation, and @@ -60,6 +73,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 xbox/live/photo-disc.js node xbox/tools/live.mjs deploy-kidlisp '$obk' node xbox/tools/live.mjs logs 100 ``` diff --git a/xbox/native-bios/tests/quickjs_engine_smoke.cpp b/xbox/native-bios/tests/quickjs_engine_smoke.cpp index a62e57cb7..a2b0ffa94 100644 --- a/xbox/native-bios/tests/quickjs_engine_smoke.cpp +++ b/xbox/native-bios/tests/quickjs_engine_smoke.cpp @@ -8,20 +8,28 @@ class SoundProbe final : public Sound { public: int calls = 0; int oscillators = int main() { GraphicsProbe graphics; SoundProbe sound; Api api{{}, {}, {}, {}, graphics, sound, {}}; int telemetryCalls = 0; + int discScans = 0, discShows = 0, discCopies = 0; api.telemetry = [&](std::string_view) { ++telemetryCalls; }; + auto disc = std::make_shared(); + disc->status = "ready"; disc->volume = "D:"; disc->name = "PHOTO.JPG"; + disc->count = 3; disc->index = 1; disc->width = 1600; disc->height = 1200; + disc->current_ready = true; + api.disc.snapshot = std::static_pointer_cast(disc); + api.disc.scan = [&]() { ++discScans; }; + api.disc.show = [&](std::int64_t index) { assert(index == -1); ++discShows; }; + api.disc.copy = [&]() { ++discCopies; }; QuickJsEngine engine; std::string error; api.clock.network_synced = true; api.clock.network_offset_ms = 3; api.clock.network_rtt_ms = 21; api.audio.output_latency_ms = 11.5; api.audio.midi_status = "no-input"; api.audio.midi_gate = true; api.audio.midi_pitch_bend = 9000; - auto piece = engine.compile({"smoke", "test", "function boot(){telemetry('BOOT','OK');ac()} function sim(){gamepad();const r=runtime();if(!r.clockSynced||r.clockOffsetMs!==3||r.audioLatencyMs!==11.5||r.midiStatus!=='no-input'||!r.midiGate||r.midiPitchBend!==9000)throw Error('runtime telemetry');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);triangle(1,2,3,4,5,6,7,8,9);const batch=new Float32Array([1,2,.1,3,4,.1,5,6,.1,7,8,9,10,20,.2,30,40,.2,50,60,.2,70,80,90]);if(triangles3d(batch)!==2)throw Error('triangle batch');const textured=new Float32Array([1,2,.1,0,0,3,4,.1,1,0,5,6,.1,0,1,255,255,255]);if(texturedTriangles3d(textured,1)!==1)throw Error('texture batch');const sprites=new Float32Array([100,200,.3,16,255,80,90,1]);if(sprites3d(sprites,1)!==1)throw Error('sprite batch');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); + auto piece = engine.compile({"smoke", "test", "function boot(){telemetry('BOOT','OK');ac();if(!discScan())throw Error('disc scan')} function sim(){gamepad();const r=runtime();if(!r.clockSynced||r.clockOffsetMs!==3||r.audioLatencyMs!==11.5||r.midiStatus!=='no-input'||!r.midiGate||r.midiPitchBend!==9000)throw Error('runtime telemetry');const d=disc();if(d.status!=='ready'||d.volume!=='D:'||d.name!=='PHOTO.JPG'||d.count!==3||d.index!==1||d.width!==1600||d.height!==1200||!d.currentReady)throw Error('disc state');if(!discShow(-1))throw Error('disc show');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);triangle(1,2,3,4,5,6,7,8,9);const batch=new Float32Array([1,2,.1,3,4,.1,5,6,.1,7,8,9,10,20,.2,30,40,.2,50,60,.2,70,80,90]);if(triangles3d(batch)!==2)throw Error('triangle batch');const textured=new Float32Array([1,2,.1,0,0,3,4,.1,1,0,5,6,.1,0,1,255,255,255]);if(texturedTriangles3d(textured,1)!==1)throw Error('texture batch');const sprites=new Float32Array([100,200,.3,16,255,80,90,1]);if(sprites3d(sprites,1)!==1)throw Error('sprite batch');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);discPhoto(0,0,1920,1080);blur(4)} function act(b){if(b==='A')synth(440,.01);if(b==='B'){oscillatorStop();if(!discCopy())throw Error('disc copy')}}", "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.triangles == 3 && graphics.textured == 1 && graphics.sprites == 1 && graphics.writes == 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); + graphics.systemWrites == 1 && graphics.glyphs == 1 && graphics.images == 3 && + graphics.blurs == 1 && graphics.lastImage.source == "disc-photo" && + !graphics.lastImage.centered && telemetryCalls == 1 && discScans == 1); + piece->sim(api); assert(sound.oscillators == 1 && discShows == 1); piece->act(api, {"A"}); assert(sound.calls == 1); - piece->act(api, {"B"}); assert(sound.stops == 1); + piece->act(api, {"B"}); assert(sound.stops == 1 && discCopies == 1); } diff --git a/xbox/runtime/include/ac/runtime.hpp b/xbox/runtime/include/ac/runtime.hpp index 91c51d43f..bd0a2e2c7 100644 --- a/xbox/runtime/include/ac/runtime.hpp +++ b/xbox/runtime/include/ac/runtime.hpp @@ -163,6 +163,32 @@ struct AcSnapshot { std::int64_t refreshed_unix_ms = 0; }; +// Read-only view of a host-mediated photo disc. Pieces can request a scan, +// choose a discovered image, draw the decoded current image, or ask the host +// to copy the allowlisted photos into app-local storage. They never receive a +// path, StorageFile, raw sector handle, or general filesystem primitive. +struct PhotoDiscSnapshot { + std::string status = "idle"; + std::string volume; + std::string name; + std::string copy_status = "idle"; + std::size_t count = 0; + std::size_t index = 0; + std::size_t copied = 0; + std::size_t copy_failed = 0; + unsigned width = 0; + unsigned height = 0; + bool current_ready = false; +}; + +struct PhotoDisc { + std::shared_ptr snapshot = + std::make_shared(); + std::function scan = {}; + std::function show = {}; + std::function copy = {}; +}; + // The stable native lifecycle context. Names intentionally follow piece API // fields so portable engines need a thin adapter rather than a rewrite. struct Api { @@ -179,6 +205,7 @@ struct Api { // Host-polled, read-only snapshots from allowlisted aesthetic.computer // endpoints. Pieces never receive a general network primitive. std::shared_ptr ac = std::make_shared(); + PhotoDisc disc; // Sandboxed pieces can emit structured diagnostic lines without receiving // filesystem, process, Device Portal, or arbitrary WinRT access. std::function telemetry = {}; diff --git a/xbox/test-native-windows.cmd b/xbox/test-native-windows.cmd index c7afc76ec..30568431c 100644 --- a/xbox/test-native-windows.cmd +++ b/xbox/test-native-windows.cmd @@ -12,6 +12,7 @@ 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 --check "%ROOT%\xbox\live\photo-disc.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 -- 2.51.2