From d007cb58e43b5ce6a5f6e21aadfc393e0fb6831f Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 4 Aug 2026 11:15:32 -0700 Subject: [PATCH] Add two-player Xbox stick fight --- xbox/live/hello.js | 241 +++++++++++++++++- xbox/native-bios/App.cpp | 150 ++++++++++- xbox/native-bios/Package.appxmanifest | 2 +- xbox/native-bios/QuickJsEngine.cpp | 46 +++- xbox/native-bios/README.md | 3 +- .../tests/quickjs_engine_smoke.cpp | 9 +- xbox/runtime/include/ac/runtime.hpp | 9 +- xbox/test-native-windows.cmd | 1 + xbox/test-native.sh | 1 + 9 files changed, 430 insertions(+), 32 deletions(-) diff --git a/xbox/live/hello.js b/xbox/live/hello.js index be1a135adb..00f6485cad 100644 --- a/xbox/live/hello.js +++ b/xbox/live/hello.js @@ -1,10 +1,237 @@ -function boot() {} -function sim() {} -function paint() { - wipe(30, 35, 90); - write("HELLO XBOX", 120, 120, 96); +const floorY = 780; +const arenaLeft = 115; +const arenaRight = 1805; +const players = [ + { name: "OSKIE", pad: 0, x: 520, y: floorY, vx: 0, vy: 0, facing: 1, + grounded: true, ducking: false, previous: [], lastButton: "NONE", + lastButtonAt: -10000000, color: [255, 232, 92], hit: 0 }, + { name: "JEFFREY", pad: 1, x: 1400, y: floorY, vx: 0, vy: 0, facing: -1, + grounded: true, ducking: false, previous: [], lastButton: "NONE", + lastButtonAt: -10000000, color: [255, 105, 190], hit: 0 }, +]; +const bullets = []; +let startedAt = 0; +let lastSimAt = 0; + +const buttonLabel = (button) => ({ + ArrowUp: "UP", ArrowDown: "DOWN", ArrowLeft: "LEFT", ArrowRight: "RIGHT", + LeftShoulder: "LB", RightShoulder: "RB", LeftStick: "LEFT STICK", + RightStick: "RIGHT STICK", View: "VIEW", Menu: "MENU", +}[button] || String(button).toUpperCase()); + +function boot() { + startedAt = runtime().monotonicUs; + lastSimAt = startedAt; +} + +function quantizedInput(pad) { + const held = pad.down; + let horizontal = (held.includes("ArrowRight") ? 1 : 0) - + (held.includes("ArrowLeft") ? 1 : 0); + let vertical = (held.includes("ArrowUp") ? 1 : 0) - + (held.includes("ArrowDown") ? 1 : 0); + if (!horizontal && Math.abs(pad.leftX) >= 0.48) horizontal = pad.leftX > 0 ? 1 : -1; + if (!vertical && Math.abs(pad.leftY) >= 0.48) vertical = pad.leftY > 0 ? 1 : -1; + return { horizontal, vertical }; +} + +function remember(player, button) { + player.lastButton = buttonLabel(button); + player.lastButtonAt = runtime().monotonicUs; + telemetry("FIGHT_BUTTON", player.name + " " + player.lastButton); +} + +function playButtonDrum(button, player) { + const pan = player.pad === 0 ? -0.4 : 0.4; + if (button === "A") drum("kick", 1.05, pan); + else if (button === "B") drum("snare", 0.95, pan); + else if (button === "X") drum("hat", 0.9, pan); + else if (button === "Y") drum("clap", 0.95, pan); + else if (!button.startsWith("Arrow")) drum("block", 0.75, pan); +} + +function fire(player) { + bullets.push({ + x: player.x + player.facing * 38, + y: player.y - (player.ducking ? 45 : 78), + vx: player.facing * 1050, + owner: player.pad, + life: 1.6, + }); + while (bullets.length > 24) bullets.shift(); +} + +function updatePlayer(player, pad, dt) { + const input = quantizedInput(pad); + const upPressed = input.vertical > 0 && !player.previous.includes("MOVE_UP"); + player.ducking = input.vertical < 0 && player.grounded; + + if (input.horizontal) player.facing = input.horizontal; + if (player.ducking) { + player.vx *= Math.exp(-12 * dt); + } else { + player.vx += input.horizontal * 1900 * dt; + if (!input.horizontal) player.vx *= Math.exp(-7.5 * dt); + } + player.vx = Math.max(-560, Math.min(560, player.vx)); + + if (upPressed && player.grounded) { + player.vy = -850; + player.grounded = false; + player.ducking = false; + drum("block", 0.72, player.pad === 0 ? -0.4 : 0.4); + } + + for (const button of pad.down) { + if (!player.previous.includes(button)) { + remember(player, button); + playButtonDrum(button, player); + if (button === "A") fire(player); + } + } + + player.vy += 1900 * dt; + player.x += player.vx * dt; + player.y += player.vy * dt; + if (player.x < arenaLeft + 20) { + player.x = arenaLeft + 20; + player.vx = Math.abs(player.vx) * 0.28; + } else if (player.x > arenaRight - 20) { + player.x = arenaRight - 20; + player.vx = -Math.abs(player.vx) * 0.28; + } + if (player.y >= floorY) { + player.y = floorY; + player.vy = 0; + player.grounded = true; + } + player.hit = Math.max(0, player.hit - dt * 4); + player.previous = pad.down.slice(); + if (input.vertical > 0) player.previous.push("MOVE_UP"); } -function act(button) { - synth(button === "Y" ? 880 : 550, 0.05); + +function sim() { + const now = runtime().monotonicUs; + const dt = Math.min(0.04, Math.max(0.001, (now - lastSimAt) / 1000000)); + lastSimAt = now; + updatePlayer(players[0], gamepad(0), dt); + updatePlayer(players[1], gamepad(1), dt); + + for (const bullet of bullets) { + bullet.x += bullet.vx * dt; + bullet.life -= dt; + const target = players[bullet.owner === 0 ? 1 : 0]; + const targetTop = target.y - (target.ducking ? 70 : 140); + if (bullet.life > 0 && Math.abs(bullet.x - target.x) < 28 && + bullet.y > targetTop && bullet.y < target.y + 8) { + bullet.life = 0; + target.vx += Math.sign(bullet.vx) * 360; + target.vy = -230; + target.grounded = false; + target.hit = 1; + drum("snare", 1.15, target.pad === 0 ? -0.4 : 0.4); + } + if (bullet.x < arenaLeft || bullet.x > arenaRight) bullet.life = 0; + } + while (bullets.length && bullets[0].life <= 0) bullets.shift(); +} + +function circle(x, y, radius, width, color) { + let lastX = x + radius; + let lastY = y; + for (let i = 1; i <= 12; i++) { + const angle = i * Math.PI * 2 / 12; + const nextX = x + Math.cos(angle) * radius; + const nextY = y + Math.sin(angle) * radius; + line(lastX, lastY, nextX, nextY, width, ...color); + lastX = nextX; + lastY = nextY; + } +} + +function drawRunner(player, t) { + const speed = Math.min(1, Math.abs(player.vx) / 360); + const stride = Math.sin(t * (7 + speed * 9) + player.pad * Math.PI) * 25 * speed; + const height = player.ducking ? 76 : 132; + const lean = player.facing * speed * 11; + const x = player.x; + const feet = player.y; + const hipY = feet - (player.ducking ? 28 : 43); + const neckX = x + lean; + const neckY = feet - height + 40; + const headY = feet - height + 16; + const color = player.hit > 0 ? [255, 255, 255] : player.color; + + circle(neckX + lean * .25, headY, 17, 7, color); + line(neckX, neckY, x, hipY, 8, ...color); + if (player.ducking) { + line(x, hipY, x - 29, feet - 17, 8, ...color); + line(x - 29, feet - 17, x - 4, feet, 8, ...color); + line(x, hipY, x + 29, feet - 14, 8, ...color); + line(x + 29, feet - 14, x + 46, feet, 8, ...color); + } else if (player.grounded) { + line(x, hipY, x - 10 + stride * .45, feet - 20, 8, ...color); + line(x - 10 + stride * .45, feet - 20, x + stride, feet, 8, ...color); + line(x, hipY, x + 10 - stride * .45, feet - 20, 8, ...color); + line(x + 10 - stride * .45, feet - 20, x - stride, feet, 8, ...color); + } else { + line(x, hipY, x - 25, feet - 24, 8, ...color); + line(x - 25, feet - 24, x - 5, feet - 8, 8, ...color); + line(x, hipY, x + 24, feet - 32, 8, ...color); + line(x + 24, feet - 32, x + 38, feet - 14, 8, ...color); + } + const arm = player.grounded ? -stride * .72 : 22; + line(neckX, neckY + 8, x - 19 + arm, feet - 67, 7, ...color); + line(x - 19 + arm, feet - 67, x - 7 + arm, feet - 47, 7, ...color); + line(neckX, neckY + 8, x + 19 - arm, feet - 67, 7, ...color); + line(x + 19 - arm, feet - 67, x + 7 - arm, feet - 47, 7, ...color); + + const labelX = x - (player.name === "JEFFREY" ? 72 : 54); + systemWrite(player.name, labelX, headY - 62, 34, ...color); +} + +function drawPlayerHud(player, x, pad) { + const age = (runtime().monotonicUs - player.lastButtonAt) / 1000000; + const pulse = Math.max(0, 1 - age * 2.2); + const color = player.color; + box(x, 842, 790, 176, 14 + Math.round(pulse * 20), 18, 45 + Math.round(pulse * 35)); + systemWrite(player.name, x + 36, 862, 34, ...color); + write(player.lastButton, x + 36, 920, 54 + Math.round(pulse * 10), ...color); + const held = pad.down.length ? pad.down.map(buttonLabel).join(" ") : "NONE"; + write("HELD " + held, x + 360, 876, 24, 195, 210, 230); + write(pad.connected ? "CONTROLLER " + (player.pad + 1) : "CONNECT CONTROLLER " + (player.pad + 1), + x + 360, 936, 20, pad.connected ? 115 : 255, pad.connected ? 225 : 105, + pad.connected ? 165 : 105); +} + +function paint() { + const run = runtime(); + const t = (run.monotonicUs - startedAt) / 1000000; + const titleX = 500 + Math.sin(t * 1.05) * 135; + const titleY = 60 + Math.sin(t * 2.1) * 12; + wipe(7, 8, 28); + for (let i = 0; i < 8; i++) { + const x = ((i * 310 + t * (38 + i * 3)) % 2300) - 190; + box(x, 0, 5, 1080, 20 + i * 3, 32 + i * 4, 72 + i * 6); + } + box(titleX - 28, titleY - 24, 890, 144, 22, 28, 104); + write("HELLO OSKIE", titleX, titleY, 96, 245, 248, 255); + + box(95, 238, 1730, 562, 10, 13, 30); + box(arenaLeft, floorY, arenaRight - arenaLeft, 20, 72, 90, 125); + box(arenaLeft, 300, 20, floorY - 300, 72, 90, 125); + box(arenaRight - 20, 300, 20, floorY - 300, 72, 90, 125); + for (const bullet of bullets) { + if (bullet.life <= 0) continue; + const color = players[bullet.owner].color; + line(bullet.x - Math.sign(bullet.vx) * 34, bullet.y, bullet.x, bullet.y, 8, ...color); + box(bullet.x - 7, bullet.y - 7, 14, 14, ...color); + } + drawRunner(players[0], t); + drawRunner(players[1], t); + drawPlayerHud(players[0], 120, gamepad(0)); + drawPlayerHud(players[1], 1010, gamepad(1)); } + +function act() {} function leave() {} diff --git a/xbox/native-bios/App.cpp b/xbox/native-bios/App.cpp index 130a4c74c2..e900fc5e02 100644 --- a/xbox/native-bios/App.cpp +++ b/xbox/native-bios/App.cpp @@ -232,6 +232,7 @@ class HostSound final : public Sound { std::function on_stop; std::function on_oscillator; std::function on_oscillator_stop; + std::function on_drum; std::function get_rate; void synth(const SynthVoice& voice) override { if (on_synth) on_synth(voice); } void stop_all() override { if (on_stop) on_stop(); } @@ -240,6 +241,9 @@ class HostSound final : public Sound { if (on_oscillator) on_oscillator(frequency, volume); } void oscillator_stop() override { if (on_oscillator_stop) on_oscillator_stop(); } + void drum(std::string_view name, float velocity, float pan) override { + if (on_drum) on_drum(name, velocity, pan); + } }; static void Check(HRESULT hr) { @@ -305,9 +309,12 @@ public: SetOscillator(frequency, volume); }; m_sound->on_oscillator_stop = [this]() { StopOscillator(); }; + m_sound->on_drum = [this](std::string_view name, float velocity, float pan) { + PlayDrum(name, velocity, pan); + }; 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.27"; + m_api->system.version = "1.0.0.28"; m_api->telemetry = [](std::string_view line) { std::string safe(line); for (auto& character : safe) if (character == '\n' || character == '\r') character = ' '; @@ -919,6 +926,107 @@ private: TriggerAudio(0); } + void PlayDrum(std::string_view name, float velocity, float pan) { + if (!m_voice || m_sampleRate == 0) return; + (void)pan; // The current game-effects voice is mono; preserve the API for a stereo pool. + enum class Wave { Sine, Triangle, Square, Noise }; + struct Layer { + Wave wave; + double frequency, duration, volume, attack, decay; + }; + std::vector layers; + if (name == "kick") { + layers = { + {Wave::Noise, 2500, .0025, .50, .0002, .0022}, + {Wave::Sine, 200, .012, 1.10, .0005, .011}, + {Wave::Sine, 150, .045, 1.30, .001, .044}, + {Wave::Sine, 90, .080, .85, .002, .078}, + {Wave::Sine, 55, .35, 1.00, .003, .345}, + }; + } else if (name == "snare") { + layers = { + {Wave::Noise, 3500, .004, .95, .0001, .004}, + {Wave::Sine, 238, .030, .35, .0003, .029}, + {Wave::Sine, 476, .030, .28, .0003, .029}, + {Wave::Noise, 3500, .11, .85, .0005, .108}, + {Wave::Noise, 1800, .07, .38, .0008, .068}, + {Wave::Triangle, 180, .025, .22, .001, .024}, + }; + } else if (name == "clap") { + layers = { + {Wave::Noise, 1000, .025, .90, .005, .020}, + {Wave::Noise, 1100, .035, .95, .015, .020}, + {Wave::Noise, 900, .045, .85, .025, .020}, + {Wave::Noise, 3000, .008, .55, .001, .007}, + {Wave::Noise, 1000, .14, .85, .045, .095}, + }; + } else if (name == "hat") { + layers = { + {Wave::Square, 800, .008, .18, .0005, .0075}, + {Wave::Square, 540, .008, .18, .0005, .0075}, + {Wave::Square, 522.7, .008, .18, .0005, .0075}, + {Wave::Square, 369.6, .008, .18, .0005, .0075}, + {Wave::Noise, 8000, .040, .38, .0005, .038}, + }; + } else { + layers = { + {Wave::Noise, 5000, .002, .35, .0001, .0018}, + {Wave::Triangle, 2500, .050, .52, .0003, .048}, + {Wave::Triangle, 1250, .050, .18, .0005, .048}, + }; + } + + const double hit = (std::max)(.1, (std::min)(1.5, static_cast(velocity))); + double duration = 0; + for (const auto& layer : layers) duration = (std::max)(duration, layer.duration); + const auto frames = static_cast(m_sampleRate * duration); + std::vector mixed(frames, 0.0); + constexpr double tau = 6.2831853071795864769; + uint32_t seed = static_cast(GetTickCount64()) | 1u; + for (const auto& layer : layers) { + double phase = 0, filteredNoise = 0; + const double phaseStep = layer.frequency / m_sampleRate; + const double noiseAlpha = 1.0 - std::exp(-tau * + (std::min)(layer.frequency, m_sampleRate * .45) / m_sampleRate); + const auto layerFrames = static_cast(m_sampleRate * layer.duration); + for (uint32_t index = 0; index < layerFrames && index < frames; ++index) { + const double time = static_cast(index) / m_sampleRate; + double envelope = 1.0; + if (layer.attack > 0 && time < layer.attack) envelope = time / layer.attack; + const double decayStart = (std::max)(0.0, layer.duration - layer.decay); + if (layer.decay > 0 && time > decayStart) + envelope *= 1.0 - (time - decayStart) / layer.decay; + double sample = 0; + if (layer.wave == Wave::Noise) { + seed ^= seed << 13; seed ^= seed >> 17; seed ^= seed << 5; + const double noise = static_cast(seed) / UINT32_MAX * 2.0 - 1.0; + filteredNoise += noiseAlpha * (noise - filteredNoise); + sample = filteredNoise; + } else { + phase += phaseStep; + phase -= std::floor(phase); + if (layer.wave == Wave::Sine) sample = std::sin(tau * phase); + else if (layer.wave == Wave::Square) sample = phase < .5 ? 1.0 : -1.0; + else sample = 1.0 - 4.0 * std::abs(phase - .5); + } + mixed[index] += sample * envelope * layer.volume; + } + } + + m_voice->Stop(0); + m_voice->FlushSourceBuffers(); + m_samples.resize(frames); + for (uint32_t index = 0; index < frames; ++index) { + const double sample = (std::max)(-1.0, (std::min)(1.0, mixed[index] * hit * .28)); + m_samples[index] = static_cast(sample * 32767.0); + } + m_buffer = {}; + m_buffer.AudioBytes = static_cast(m_samples.size() * sizeof(int16_t)); + m_buffer.pAudioData = reinterpret_cast(m_samples.data()); + m_buffer.Flags = XAUDIO2_END_OF_STREAM; + TriggerAudio(0); + } + void SetOscillator(float frequency, float volume) { if (!m_oscVoice) return; frequency = (std::max)(20.0f, (std::min)(5000.0f, frequency)); @@ -1220,21 +1328,15 @@ private: void PollController() { const auto pads = Gamepad::Gamepads; m_api->gamepad.down.clear(); + m_api->gamepad.pads.clear(); if (pads->Size == 0) { m_previousButtons = 0; + m_api->gamepad.connected = false; m_api->gamepad.left_x = m_api->gamepad.left_y = 0; m_api->gamepad.right_x = m_api->gamepad.right_y = 0; m_api->gamepad.left_trigger = m_api->gamepad.right_trigger = 0; return; } - const auto reading = pads->GetAt(0)->GetCurrentReading(); - const unsigned buttons = static_cast(reading.Buttons); - m_api->gamepad.left_x = static_cast(reading.LeftThumbstickX); - m_api->gamepad.left_y = static_cast(reading.LeftThumbstickY); - m_api->gamepad.right_x = static_cast(reading.RightThumbstickX); - m_api->gamepad.right_y = static_cast(reading.RightThumbstickY); - m_api->gamepad.left_trigger = static_cast(reading.LeftTrigger); - m_api->gamepad.right_trigger = static_cast(reading.RightTrigger); struct ButtonName { GamepadButtons bit; const char* name; }; static constexpr ButtonName names[] = { {GamepadButtons::A, "A"}, {GamepadButtons::B, "B"}, @@ -1247,8 +1349,34 @@ private: {GamepadButtons::LeftThumbstick, "LeftStick"}, {GamepadButtons::RightThumbstick, "RightStick"} }; - for (const auto& named : names) - if (buttons & static_cast(named.bit)) m_api->gamepad.down.insert(named.name); + + unsigned buttons = 0; + for (unsigned index = 0; index < pads->Size; ++index) { + const auto reading = pads->GetAt(index)->GetCurrentReading(); + PadState state; + state.connected = true; + state.left_x = static_cast(reading.LeftThumbstickX); + state.left_y = static_cast(reading.LeftThumbstickY); + state.right_x = static_cast(reading.RightThumbstickX); + state.right_y = static_cast(reading.RightThumbstickY); + state.left_trigger = static_cast(reading.LeftTrigger); + state.right_trigger = static_cast(reading.RightTrigger); + const unsigned padButtons = static_cast(reading.Buttons); + for (const auto& named : names) + if (padButtons & static_cast(named.bit)) state.down.insert(named.name); + if (index == 0) buttons = padButtons; + m_api->gamepad.pads.push_back(std::move(state)); + } + + const auto& first = m_api->gamepad.pads.front(); + m_api->gamepad.connected = true; + m_api->gamepad.left_x = first.left_x; + m_api->gamepad.left_y = first.left_y; + m_api->gamepad.right_x = first.right_x; + m_api->gamepad.right_y = first.right_y; + m_api->gamepad.left_trigger = first.left_trigger; + m_api->gamepad.right_trigger = first.right_trigger; + m_api->gamepad.down = first.down; const unsigned pressed = buttons & ~m_previousButtons; m_previousButtons = buttons; if (!pressed) return; diff --git a/xbox/native-bios/Package.appxmanifest b/xbox/native-bios/Package.appxmanifest index bac230494c..7abb69eb02 100644 --- a/xbox/native-bios/Package.appxmanifest +++ b/xbox/native-bios/Package.appxmanifest @@ -5,7 +5,7 @@ IgnorableNamespaces="uap mp"> + Version="1.0.0.28" /> diff --git a/xbox/native-bios/QuickJsEngine.cpp b/xbox/native-bios/QuickJsEngine.cpp index abf6fb92c9..53db535e49 100644 --- a/xbox/native-bios/QuickJsEngine.cpp +++ b/xbox/native-bios/QuickJsEngine.cpp @@ -39,6 +39,27 @@ JSValue Synth(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { return JS_UNDEFINED; } +JSValue Drum(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + if (!scope || !scope->api || argc < 1) return JS_EXCEPTION; + const char* name = JS_ToCString(context, argv[0]); + if (!name) return JS_EXCEPTION; + double velocity = 1.0, pan = 0.0; + if (argc > 1 && JS_ToFloat64(context, &velocity, argv[1])) { + JS_FreeCString(context, name); return JS_EXCEPTION; + } + if (argc > 2 && JS_ToFloat64(context, &pan, argv[2])) { + JS_FreeCString(context, name); return JS_EXCEPTION; + } + const std::string drum(name); + JS_FreeCString(context, name); + static constexpr std::string_view allowed[] = {"kick", "snare", "clap", "hat", "block"}; + if (std::find(std::begin(allowed), std::end(allowed), drum) == std::end(allowed)) + return JS_ThrowRangeError(context, "unknown drum"); + scope->api->sound.drum(drum, static_cast(velocity), static_cast(pan)); + return JS_UNDEFINED; +} + JSValue Oscillator(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { auto* scope = static_cast(JS_GetContextOpaque(context)); double frequency = 220.0, volume = .12; @@ -455,24 +476,34 @@ JSValue RuntimeInfo(JSContext* context, JSValueConst, int, JSValueConst*) { return result; } -JSValue GamepadState(JSContext* context, JSValueConst, int, JSValueConst*) { - auto* scope = static_cast(JS_GetContextOpaque(context)); - if (!scope || !scope->api) return JS_EXCEPTION; - const auto& pad = scope->api->gamepad; +JSValue PadObject(JSContext* context, const PadState& pad, int index) { JSValue result = JS_NewObject(context); + JS_SetPropertyStr(context, result, "index", JS_NewInt32(context, index)); + JS_SetPropertyStr(context, result, "connected", JS_NewBool(context, pad.connected)); JS_SetPropertyStr(context, result, "leftX", JS_NewFloat64(context, pad.left_x)); JS_SetPropertyStr(context, result, "leftY", JS_NewFloat64(context, pad.left_y)); JS_SetPropertyStr(context, result, "rightX", JS_NewFloat64(context, pad.right_x)); JS_SetPropertyStr(context, result, "rightY", JS_NewFloat64(context, pad.right_y)); JS_SetPropertyStr(context, result, "leftTrigger", JS_NewFloat64(context, pad.left_trigger)); JS_SetPropertyStr(context, result, "rightTrigger", JS_NewFloat64(context, pad.right_trigger)); - JSValue down = JS_NewArray(context); uint32_t index = 0; + JSValue down = JS_NewArray(context); uint32_t downIndex = 0; for (const auto& button : pad.down) - JS_SetPropertyUint32(context, down, index++, JS_NewString(context, button.c_str())); + JS_SetPropertyUint32(context, down, downIndex++, JS_NewString(context, button.c_str())); JS_SetPropertyStr(context, result, "down", down); return result; } +JSValue GamepadState(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + if (!scope || !scope->api) return JS_EXCEPTION; + if (argc < 1) return PadObject(context, scope->api->gamepad, 0); + int32_t index = 0; + if (JS_ToInt32(context, &index, argv[0])) return JS_EXCEPTION; + if (index >= 0 && static_cast(index) < scope->api->gamepad.pads.size()) + return PadObject(context, scope->api->gamepad.pads[static_cast(index)], index); + return PadObject(context, PadState{}, index); +} + JSValue Controllers(JSContext* context, JSValueConst, int, JSValueConst*) { auto* scope = static_cast(JS_GetContextOpaque(context)); if (!scope || !scope->api) return JS_EXCEPTION; @@ -627,6 +658,7 @@ class QuickJsPiece final : public JsPiece { JSValue global = JS_GetGlobalObject(context_); JS_SetPropertyStr(context_, global, "wipe", JS_NewCFunction(context_, Wipe, "wipe", 3)); JS_SetPropertyStr(context_, global, "synth", JS_NewCFunction(context_, Synth, "synth", 2)); + JS_SetPropertyStr(context_, global, "drum", JS_NewCFunction(context_, Drum, "drum", 3)); JS_SetPropertyStr(context_, global, "oscillator", JS_NewCFunction(context_, Oscillator, "oscillator", 2)); JS_SetPropertyStr(context_, global, "oscillatorStop", JS_NewCFunction(context_, OscillatorStop, "oscillatorStop", 0)); JS_SetPropertyStr(context_, global, "write", JS_NewCFunction(context_, Write, "write", 7)); @@ -644,7 +676,7 @@ class QuickJsPiece final : public JsPiece { 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)); + JS_SetPropertyStr(context_, global, "gamepad", JS_NewCFunction(context_, GamepadState, "gamepad", 1)); 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)); diff --git a/xbox/native-bios/README.md b/xbox/native-bios/README.md index 9a934a5693..24b5a4e1bb 100644 --- a/xbox/native-bios/README.md +++ b/xbox/native-bios/README.md @@ -19,7 +19,8 @@ globals. 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`, +MenuBand-derived `drum`, continuous `oscillator` / `oscillatorStop`, `controllers`, +indexed `gamepad(0)` / `gamepad(1)` snapshots (plain `gamepad()` remains Player 1), `capabilities`, `runtime`, the host-mediated `ac` feed, and bounded structured `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, diff --git a/xbox/native-bios/tests/quickjs_engine_smoke.cpp b/xbox/native-bios/tests/quickjs_engine_smoke.cpp index a2b0ffa943..3782ecf15c 100644 --- a/xbox/native-bios/tests/quickjs_engine_smoke.cpp +++ b/xbox/native-bios/tests/quickjs_engine_smoke.cpp @@ -3,7 +3,7 @@ using namespace ac::xbox; namespace { class GraphicsProbe final : public Graphics { public: Color color{}; int boxes = 0; int lines = 0; int triangles = 0; int textured = 0; int sprites = 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 triangle(const Triangle&) override { ++triangles; } void textured_triangle(const TexturedTriangle&) override { ++textured; } void sprite(const Sprite&) override { ++sprites; } 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; } }; +class SoundProbe final : public Sound { public: int calls = 0; int oscillators = 0; int stops = 0; int drums = 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; } void drum(std::string_view, float, float) override { ++drums; } }; } int main() { GraphicsProbe graphics; SoundProbe sound; Api api{{}, {}, {}, {}, graphics, sound, {}}; @@ -19,17 +19,20 @@ int main() { api.disc.show = [&](std::int64_t index) { assert(index == -1); ++discShows; }; api.disc.copy = [&]() { ++discCopies; }; QuickJsEngine engine; std::string error; + PadState firstPad; firstPad.connected = true; firstPad.down.insert("A"); + PadState secondPad; secondPad.connected = true; secondPad.left_x = -1; + api.gamepad.connected = true; api.gamepad.pads = {firstPad, secondPad}; 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();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); + auto piece = engine.compile({"smoke", "test", "function boot(){telemetry('BOOT','OK');ac();if(!discScan())throw Error('disc scan')} function sim(){if(!gamepad().connected||!gamepad(0).connected||!gamepad(1).connected||gamepad(2).connected||gamepad(1).leftX!==-1)throw Error('indexed gamepads');drum('kick',1,0);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 == 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->sim(api); assert(sound.oscillators == 1 && sound.drums == 1 && discShows == 1); piece->act(api, {"A"}); assert(sound.calls == 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 bd0a2e2c7a..6b034d781d 100644 --- a/xbox/runtime/include/ac/runtime.hpp +++ b/xbox/runtime/include/ac/runtime.hpp @@ -33,15 +33,19 @@ struct ControllerInfo { std::uint16_t switches = 0; bool gamepad = false; }; -struct GamepadState { +struct PadState { float left_x = 0, left_y = 0, right_x = 0, right_y = 0; float left_trigger = 0, right_trigger = 0; std::unordered_set down; - std::vector controllers; + bool connected = false; [[nodiscard]] bool pressed(std::string_view button) const { return down.find(std::string(button)) != down.end(); } }; +struct GamepadState : PadState { + std::vector pads; + std::vector controllers; +}; struct Color { std::uint8_t r, g, b, a = 255; }; struct Rect { float x, y, width, height; Color color; }; @@ -107,6 +111,7 @@ class Sound { virtual int sample_rate() const = 0; virtual void oscillator(float, float) {} virtual void oscillator_stop() {} + virtual void drum(std::string_view, float = 1.0f, float = 0.0f) {} }; struct Clock { diff --git a/xbox/test-native-windows.cmd b/xbox/test-native-windows.cmd index 30568431c7..31c282075b 100644 --- a/xbox/test-native-windows.cmd +++ b/xbox/test-native-windows.cmd @@ -11,6 +11,7 @@ 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\hello.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 diff --git a/xbox/test-native.sh b/xbox/test-native.sh index 8174a5505e..f579f9fcdd 100644 --- a/xbox/test-native.sh +++ b/xbox/test-native.sh @@ -6,6 +6,7 @@ build_dir=$(mktemp -d "${TMPDIR:-/tmp}/ac-xbox-tests.XXXXXX") trap 'rm -rf "$build_dir"' EXIT INT TERM node --check "$repo_dir/xbox/live/controller-probe.js" +node --check "$repo_dir/xbox/live/hello.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" -- 2.51.2