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.
/**
* 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');
}
