Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ and dispatch state before falling back to the interpreter. Cached compiled
programs include the root and every code module; instantiation creates fresh
guest state.

Application hosts may pause through `ApplicationRuntime::set_paused(bool)`.
Updates do not execute while paused, execution-scoped monotonic clocks freeze,
and resume excludes paused wall time. Wall-clock imports remain real time.
Release held controls before pausing and suspend/clear the host audio device;
the runtime discards pending gameplay actions and audio while retaining input
releases and viewport state for the next update.

The browser endpoint accepts `{ type: "pause", paused: boolean }` and acknowledges
every valid request with `{ type: "pause-state", paused: boolean }`. Hosts combine
menu and background pause reasons before sending the effective state. Pause is
retained before and during asynchronous startup: initialization completes, but
the first update waits for resume. Resume queues at most one update, not missed
frames. Paused input cannot wake execution, and stopping during compilation
cannot bring the endpoint back to life. The worker and Wasm runtime must be
rebuilt together: the interpreter uses `polkavm_browser_pause_input` to enforce
the same queue boundary as the translated backend.

Browser and native render passes accept registered texture views as offscreen
color attachments; zero still selects the surface. Offscreen passes preserve
the surface and retain generation and resource-handle validation. These changes
Expand Down
15 changes: 15 additions & 0 deletions docs/runtime/polkavm-app-abi-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ input and audio. A Host call made outside its declared capability MUST fail
with that call's unavailable or invalid-state result. The Host MUST NOT
silently reinterpret a submission as another graphics profile.

`capabilities.deviceInput.controls` MAY contain display-only control help.
When present it MUST be an array of at most 32 strings; each string MUST be
nonempty, have no leading or trailing whitespace, and contain at most 160
UTF-8 bytes. Omission means no declared help. This field neither defines input
mappings nor adds required device features. Hosts MAY show it in their own
controls menu outside the guest presentation surface.

## Host imports

### Cooperative update scheduling
Expand All @@ -105,6 +112,14 @@ A guest that does not import this call retains Host-defined continuous
scheduling for compatibility. Scheduling does not weaken per-update gas or
Host-call budgets.

A Host may suspend execution for its menu or while backgrounded. It MUST
release held input before pausing, discard queued gameplay actions and audio,
and prevent new gameplay presses from accumulating during the pause. Releases
and viewport state may remain pending until the first resumed update. Paused
execution does not process updates or external-event wakes. Execution-scoped
monotonic time excludes the pause; wall time does not. Resume MUST NOT replay
missed update ticks or buffered audio.

### Framebuffer presentation

```text
Expand Down
127 changes: 123 additions & 4 deletions js/packages/polkavm-browser-runtime/src/polkavm-runtime-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
let backend = "interpreter";
let running = false;
let disposed = false;
let starting = false;
let paused = false;
let pausedAt = 0;
let demandDriven = false;
let tickPending = false;
let motionAvailability = 0;
Expand All @@ -73,6 +76,7 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
let updateCount = 0;
const updateSamples = [];
const activeMediatedInputHandles = new Set();
const heldInputs = new Map();
const tickChannel = new MessageChannel();
tickChannel.port1.onmessage = () => {
tickPending = false;
Expand All @@ -89,6 +93,7 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
}
activeMediatedInputHandles.clear();
running = false;
heldInputs.clear();
clearTimeout(timer);
translated?.stop();
pvm?.polkavm_browser_reset?.();
Expand All @@ -98,6 +103,10 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
}

function postRuntimeOutput(output, transfers = []) {
// The Host also clears already delivered audio when it requests a pause.
if (paused && output?.type === "audio") {
return;
}
if (output?.type === "mediated-input-request") {
activeMediatedInputHandles.add(output.handle);
} else if (output?.type === "mediated-input-cancel") {
Expand Down Expand Up @@ -272,6 +281,9 @@ globalThis.createPolkaVmRuntime = (endpoint) => {

function drainAudio() {
while (pvm.polkavm_browser_take_audio()) {
if (paused) {
continue;
}
const sampleRate = pvm.polkavm_browser_audio_sample_rate();
const channels = pvm.polkavm_browser_audio_channels();
const length = pvm.polkavm_browser_audio_length() * 2;
Expand Down Expand Up @@ -328,7 +340,7 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
}

function scheduleTick(delayMs) {
if (!running) {
if (!running || paused) {
return;
}
clearTimeout(timer);
Expand All @@ -345,7 +357,7 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
}
timer = setTimeout(() => {
timer = undefined;
if (!running || tickPending) {
if (!running || paused || tickPending) {
return;
}
tickPending = true;
Expand All @@ -359,6 +371,49 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
}
}

function pauseInput() {
if (translated) {
translated.pauseInput();
} else {
check(pvm.polkavm_browser_pause_input(), "discard paused PolkaVM browser input");
}
// Releases survive the pause boundary; presses and movement never do.
// A conforming Host has already sent these, but also release controls if
// focus disappeared before the Host received their physical key-up.
for (const bytes of heldInputs.values()) {
bytes[0] = bytes[0] === 18 ? 21 : bytes[0] + 1;
sendInput(bytes);
}
heldInputs.clear();
pendingMotionSample = null;
}

function setPaused(next) {
if (typeof next !== "boolean") {
throw new Error("invalid PolkaVM browser pause state");
}
const changed = paused !== next;
if (changed) {
const now = performance.now();
if (next) {
clearTimeout(timer);
timer = undefined;
pendingMotionSample = null;
if (running) {
pauseInput();
pausedAt = now;
}
} else if (running) {
startedAt += now - pausedAt;
}
paused = next;
}
postMessage({ type: "pause-state", paused });
if (changed && !paused) {
scheduleTick(0);
}
}

function requestedUpdateDelay() {
if (!demandDriven) {
return LEGACY_FRAME_INTERVAL_MS;
Expand All @@ -370,7 +425,7 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
}

function tick() {
if (!running) {
if (!running || paused) {
return;
}
const firstUpdate = updateCount === 0;
Expand Down Expand Up @@ -560,10 +615,11 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
if (disposed) {
throw new Error("PolkaVM browser worker is stopped");
}
if (pvm || running) {
if (starting || pvm || running) {
throw new Error("PolkaVM browser worker is already started");
}
const program = validateStartMessage(message);
starting = true;
motionAvailability = message.motionAvailability ?? 0;
pointerCaptureSupported = message.pointerCaptureSupported === true;
pendingGpuCapabilities =
Expand Down Expand Up @@ -608,6 +664,10 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
pvm = (
await WebAssembly.instantiate(message.runtime, runtimeImports)
).instance.exports;
if (disposed) {
pvm.polkavm_browser_reset?.();
return;
}
if (pvm.polkavm_browser_abi_version() !== 2) {
throw new Error("PolkaVM browser runtime has an incompatible ABI");
}
Expand Down Expand Up @@ -651,8 +711,14 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
compiledProgram =
await globalThis.TranslatedPolkaVmRuntime.compile(bytes);
compilationMs = performance.now() - compilationStarted;
if (disposed) {
return;
}
} catch (error) {
compilationMs = performance.now() - compilationStarted;
if (disposed) {
return;
}
// Invalid Wasm cannot be repaired by changing compilation-unit sizes.
if (error instanceof WebAssembly.CompileError) {
throw error;
Expand All @@ -664,6 +730,10 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
pvm = (
await WebAssembly.instantiate(message.runtime, runtimeImports)
).instance.exports;
if (disposed) {
pvm.polkavm_browser_reset?.();
return;
}
stage(program);
const translationStarted = performance.now();
check(
Expand All @@ -688,6 +758,9 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
compilationMs += performance.now() - partsStarted;
}
}
if (disposed) {
return;
}
if (generatedTranslation) {
const persistent = bytes.slice();
postMessage(
Expand Down Expand Up @@ -733,6 +806,9 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
backend = "compiler";
} catch (error) {
translated = null;
if (disposed) {
return;
}
pendingOutputs.length = 0;
if (error !== FORCE_INTERPRETER) {
compilerFallbackReason =
Expand All @@ -746,6 +822,10 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
pvm = (
await WebAssembly.instantiate(message.runtime, runtimeImports)
).instance.exports;
if (disposed) {
pvm.polkavm_browser_reset?.();
return;
}
}
let presentation = 0;
if (message.graphicsProfile === "tri2d") {
Expand Down Expand Up @@ -840,6 +920,11 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
drainHostFrameRequests();
drainLogs();
}
if (disposed) {
translated?.stop();
pvm?.polkavm_browser_reset?.();
return;
}
const usesMotion = translated
? translated.usesMotion()
: pvm.polkavm_browser_uses_motion() === 1;
Expand All @@ -851,6 +936,11 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
: typeof pvm.polkavm_browser_uses_update_scheduling === "function" &&
pvm.polkavm_browser_uses_update_scheduling() === 1;
startedAt = performance.now();
starting = false;
if (paused) {
pausedAt = startedAt;
pauseInput();
}
running = true;
postMessage({
type: "ready",
Expand Down Expand Up @@ -887,6 +977,21 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
if (!running) {
return;
}
const type = bytes[0];
if (paused && !(type === 2 || type === 4 || type === 7 || type === 12 ||
type === 20 || type === 21 || ((type === 13 || type === 15) && bytes[1] === 0))) {
return;
}
if (type === 1 || type === 3 || type === 18) {
const key = type * 256 + bytes[1];
if (!heldInputs.has(key)) {
heldInputs.set(key, bytes.slice());
}
} else if (type === 2 || type === 4) {
heldInputs.delete((type - 1) * 256 + bytes[1]);
} else if (type === 20 || type === 21) {
heldInputs.delete(18 * 256 + bytes[1]);
}
if (translated) {
translated.sendInput(bytes);
return;
Expand Down Expand Up @@ -1003,6 +1108,9 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
if (bytes.byteLength !== MOTION_SAMPLE_BYTES) {
throw new Error("invalid PolkaVM browser motion sample");
}
if (paused) {
return;
}
if (!running) {
pendingMotionSample = bytes.slice();
motionAvailability = 1;
Expand Down Expand Up @@ -1097,10 +1205,21 @@ globalThis.createPolkaVmRuntime = (endpoint) => {
const message = event.data;
if (message?.type === "start") {
void start(message).catch((error) => {
if (disposed) {
return;
}
stopRuntime();
postMessage({ type: "error", message: error.message });
postMessage({ type: "terminated" });
});
} else if (message?.type === "pause") {
try {
setPaused(message.paused);
} catch (error) {
stopRuntime();
postMessage({ type: "error", message: error.message });
postMessage({ type: "terminated" });
}
} else if (message?.type === "input") {
try {
sendInput(new Uint8Array(message.bytes));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,21 @@
this.#run(update, false);
}

pauseInput() {
const survivesPause = (record) =>
record[0] === 2 || record[0] === 4 || record[0] === 7 ||
record[0] === 12 || record[0] === 16 || record[0] === 17 ||
record[0] === 20 || record[0] === 21 ||
((record[0] === 13 || record[0] === 15) && record[1] === 0);
this.input = this.input.filter(survivesPause);
this.epocaInput = this.epocaInput.filter(survivesPause);
this.coreInput = this.coreInput.filter(
([key, value]) => value === 0 && key !== 0xa3 && key !== 0xa4,
);
this.pointer = null;
this.motionSample = null;
}

sendInput(bytes) {
if (this.stopped || bytes.byteLength !== INPUT_EVENT_BYTES) {
return;
Expand Down Expand Up @@ -1145,6 +1160,7 @@
this.stopped = true;
this.input.length = 0;
this.coreInput.length = 0;
this.epocaInput.length = 0;
this.hostFrameRequests = 0;
this.hostFrameRequestBytes = 0;
this.gpuEvents.length = 0;
Expand Down
Loading
Loading