Back to Powder Tool V600Billion
SOURCE / PINNED RELEASE

Made of little things.

Powder Tool V600Billion

Release
142767edcab8…
Author-recorded commit
6d92971effd0…
License
LICENSE
Author’s source reference
nostr://npub1fllw8kw0thjj55wds0uugcnp5kej2nfxd36eruq39d56wwz8r44q5q78wj/wss%3A%2F%2Fgit.napplet.soy%2F/powder-toy

Archive hash verified: ed7d6a8ea7083197…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

source/scripts/upstream.mjs
/**
 * The game as this napplet uses it: the two files scripts/build-wasm.sh
 * builds into vendor/ from upstream's pinned source, checked against the
 * hashes in upstream.json, and the glue with the patches the sandbox needs.
 *
 * Shared by vite.config.js (which puts both into index.html) and the tests.
 */
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { gzipSync } from 'fflate';
import { fileURLToPath } from 'node:url';

export const PACKAGE_DIR = fileURLToPath(new URL('../', import.meta.url));
export const VENDOR_DIR = `${PACKAGE_DIR}vendor/`;
export const upstream = JSON.parse(readFileSync(`${PACKAGE_DIR}upstream.json`, 'utf8'));

const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');

/** What the removed loaders throw if anything ever reached them. */
const NO_URL_LOADS = "(() => { throw new Error('a napplet loads nothing by URL: the game is in the page'); })()";

/**
 * Text patches on upstream's Emscripten glue. Each must match exactly `count`
 * times (once unless it says otherwise), or the build stops: a changed glue is
 * the signal to read upstream's changes before shipping them. Every `replace`
 * is text of its own, so the tests can undo each patch and check the result
 * against the pinned glue.
 *
 * Besides mounting /powder on napplet storage they take every piece of browser
 * authority out of the glue (the napplet sandbox contract, checked by
 * napplet-conformance): the game's own requests, WebSockets and links go to
 * the page through Module hooks (src/main.js), and Emscripten's loaders by URL,
 * which a page that carries its wasm never calls, and IDBFS, which is never
 * mounted, can no longer reach fetch, XMLHttpRequest or IndexedDB.
 */
export const GLUE_PATCHES = [
  {
    why: 'A sandboxed frame has no IndexedDB, so /powder is mounted on the filesystem '
      + 'src/storage-fs.js hands in (napplet storage), or plain MEMFS without a host.',
    find: 'FS.mount(IDBFS, {}, ddir);',
    replace: 'FS.mount(typeof Module["nappletFS"] == "function" ? Module["nappletFS"](FS, MEMFS) : MEMFS, {}, ddir);',
  },
  {
    why: 'The game\'s HTTP requests (powdertoy.co.uk) go to the page\'s own server (src/server/), '
      + 'which answers them from Nostr through the host, instead of the global fetch.',
    find: 'fetch(request.fetchResource, {',
    replace: 'Module["nappletFetch"](request.fetchResource, {',
  },
  {
    why: 'The game\'s WebSockets fail the way an unreachable server does: a napplet has no network.',
    find: 'new WebSocket(UTF8ToString($1), websocket.protocols)',
    replace: 'Module["nappletWebSocket"](UTF8ToString($1), websocket.protocols)',
  },
  {
    why: 'The game\'s links open through the host (link.open, which asks the player) instead of window.open.',
    find: '($0) => { open(UTF8ToString($0)); }',
    replace: '($0) => { Module["nappletOpen"](UTF8ToString($0)); }',
  },
  {
    why: 'Emscripten\'s async loader by URL; the page carries everything and loads nothing.',
    find: 'return fetch(url, { credentials: \'same-origin\' })',
    replace: 'return Promise.reject(new Error(\'a napplet loads nothing by URL: \' + url))',
  },
  {
    why: 'Emscripten\'s streaming compile from a URL; the wasm is handed over as wasmBinary.',
    find: 'return fetch(binaryFile, { credentials: \'same-origin\' })',
    replace: 'return Promise.reject(new Error(\'the wasm is in the page, not at \' + binaryFile))',
  },
  {
    why: 'A comment that names fetch().',
    find: 'it does not have a full fetch()',
    replace: 'it does not have a full fetch',
  },
  {
    why: 'Emscripten\'s synchronous loaders by URL (worker readBinary, file:// readAsync, lazy files).',
    find: 'new XMLHttpRequest()',
    replace: NO_URL_LOADS,
    count: 4,
  },
  {
    why: 'IDBFS is linked by upstream but never mounted here; it no longer reaches for IndexedDB.',
    find: "indexedDB:() => {\n        if (typeof indexedDB != 'undefined') return indexedDB;\n        var ret = null;\n        if (typeof window == 'object') ret = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;\n        assert(ret, 'IDBFS used, but indexedDB not supported');\n        return ret;\n      },",
    replace: "idbUnavailable:() => {\n        throw new Error('IDBFS is not available in a napplet: /powder lives in napplet storage');\n      },",
  },
  {
    why: 'IDBFS\'s one call of that accessor.',
    find: 'req = IDBFS.indexedDB().open(name, IDBFS.DB_VERSION);',
    replace: 'req = IDBFS.idbUnavailable().open(name, IDBFS.DB_VERSION);',
  },
];

export function readVendor(file) {
  const expected = upstream.build.files[file];
  if (!expected) throw new Error(`${file} is not part of the pinned build`);
  let bytes;
  try {
    bytes = readFileSync(VENDOR_DIR + file);
  } catch {
    throw new Error(`vendor/${file} is missing. Run: scripts/build-wasm.sh`);
  }
  const actual = sha256(bytes);
  if (actual !== expected) {
    throw new Error(`vendor/${file} hashes to ${actual}, upstream.json pins ${expected}. Run: scripts/build-wasm.sh`);
  }
  return bytes;
}

export function patchedGlue() {
  let glue = readVendor('powder.js').toString('utf8');
  for (const patch of GLUE_PATCHES) {
    const expected = patch.count ?? 1;
    const count = glue.split(patch.find).length - 1;
    if (count !== expected) throw new Error(`glue patch expected ${expected} time(s), found ${count}: ${patch.find}`);
    glue = glue.split(patch.find).join(patch.replace);
  }
  if (!glue.includes('var create_powder')) throw new Error('powder.js does not define create_powder; upstream changed EXPORT_NAME');
  if (/<\/script/i.test(glue)) throw new Error('powder.js contains "</script" and can no longer be inlined');
  return glue;
}

/**
 * The wasm as the page carries it: gzip then base64, unpacked in the page
 * with DecompressionStream. fflate rather than node:zlib, whose output
 * depends on the zlib build and the CPU; this way the same wasm gives the
 * same page everywhere. No file name and time 0 in the gzip header.
 */
export function wasmGzipBase64() {
  return Buffer.from(gzipSync(readVendor('powder.wasm'), { level: 9, mem: 12, mtime: 0 })).toString('base64');
}