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.
/**
* /powder on napplet storage: the game's saves, stamps, settings and scripts
* survive closing the napplet.
*
* Upstream mounts IDBFS at /powder, which needs IndexedDB, and a sandboxed
* frame has none. The glue patch in scripts/upstream.mjs mounts what
* `Module.nappletFS(FS, MEMFS)` returns instead: this filesystem type. It is
* MEMFS with a `syncfs`, exactly the shape IDBFS has, so upstream's own flow
* drives it unchanged:
*
* - before main() the game calls FS.syncfs(true): every entry is read from
* storage into memory (populate);
* - whenever a file under /powder changes, the game calls FS.syncfs(false)
* on its next frame: what changed is written, what vanished is removed.
*
* One storage key per file or directory, `fs:<path>`. The value is JSON with
* the path, mode, mtime and, for files, the bytes as base64. Hosts differ in
* what they keep: the Hangar takes up to 8 MB per value (nappelin.com
* apps/hangar/src/web-storage.ts), napplet.soy drops
* any message over 360 000 characters and keeps 1 MiB and 256 keys in all.
* So the base64 of a file larger than CHUNK characters goes into extra keys,
* `fsc:<version>:<n>:<path>`, written before the entry that names them, so a
* write that stops half way leaves the previous version whole. A file the
* host will not take stays in memory for the session, is reported once, and
* does not stop the other files from being kept.
*
* Safety: nothing is ever removed from storage unless the populate that
* preceded it read every entry. A storage that failed to answer at start
* cannot turn into an empty folder that then deletes the player's saves.
*/
import { base64ToBytes, bytesToBase64 } from './base64.js';
export const KEY_PREFIX = 'fs:';
export const CHUNK_PREFIX = 'fsc:';
const MAX_KEY = 200;
/** Base64 characters per storage value; with the message around it, well under 360 000. */
export const CHUNK = 256 * 1024;
/** The largest file kept at all (base64 characters). */
const MAX_FILE = 8 * 1024 * 1024;
function hashPath(path) {
// Two independent 32-bit FNV-1a runs: a readable key even for paths too long to be one.
let a = 0x811c9dc5, b = 0x01000193 ^ path.length;
for (let i = 0; i < path.length; i++) {
const c = path.charCodeAt(i);
a = Math.imul(a ^ c, 0x01000193) >>> 0;
b = Math.imul(b ^ (c + i), 0x01000193) >>> 0;
}
return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0');
}
export function keyFor(path) {
const key = KEY_PREFIX + path;
return key.length <= MAX_KEY ? key : `${KEY_PREFIX}#${hashPath(path)}`;
}
/** The key of one extra piece of a file's base64; `version` keeps old and new pieces apart. */
export function chunkKey(path, version, index) {
const key = `${CHUNK_PREFIX}${version}:${index}:${path}`;
return key.length <= MAX_KEY ? key : `${CHUNK_PREFIX}${version}:${index}:#${hashPath(path)}`;
}
const isUnder = (path, root) => path === root || path.startsWith(root.endsWith('/') ? root : `${root}/`);
/**
* @param {{
* FS: any, MEMFS: any, store: { get, set, remove, keys },
* log?: (message: string) => void,
* onTrouble?: (path: string, reason: string) => void,
* }} options
* `onTrouble` hears that a file could not be kept, once until that file changes.
*/
export function createNappletFS({ FS, MEMFS, store, log = (message) => console.warn(`[powder-toy] ${message}`), onTrouble = () => {} }) {
/** What storage holds, as far as we know: path -> { key, dir, mtime, size, chunks: string[] }. */
const remote = new Map();
/** Files the host refused, as they were then: path -> { mtime, size }. Not retried until they change. */
const refused = new Map();
let complete = false;
let queue = Promise.resolve();
let versions = 0;
function walk(root) {
const entries = new Map();
const pending = [root];
while (pending.length) {
const dir = pending.pop();
for (const name of FS.readdir(dir)) {
if (name === '.' || name === '..') continue;
const path = dir === '/' ? `/${name}` : `${dir}/${name}`;
const stat = FS.stat(path);
const isDir = FS.isDir(stat.mode);
if (!isDir && !FS.isFile(stat.mode)) continue;
entries.set(path, { dir: isDir, mode: stat.mode, mtime: stat.mtime.getTime(), size: isDir ? 0 : stat.size });
if (isDir) pending.push(path);
}
}
return entries;
}
async function readData(entry) {
let data = typeof entry.data === 'string' ? entry.data : '';
for (const key of entry.chunks ?? []) {
const piece = await store.get(key);
if (typeof piece !== 'string') throw new Error(`a piece of it is missing (${key})`);
data += piece;
}
return data;
}
async function populate(root) {
const all = (await store.keys()).filter((key) => typeof key === 'string');
const keys = all.filter((key) => key.startsWith(KEY_PREFIX));
const loaded = [];
for (const key of keys) {
let value;
try {
value = JSON.parse(await store.get(key));
} catch {
log(`skipped an unreadable entry (${key})`);
continue;
}
if (!value || typeof value.path !== 'string' || !isUnder(value.path, root) || value.path === root) continue;
if (value.chunks !== undefined && !(Array.isArray(value.chunks) && value.chunks.every((item) => typeof item === 'string'))) continue;
loaded.push({ key, ...value });
}
// Parents before children.
loaded.sort((a, b) => a.path.split('/').length - b.path.split('/').length);
let whole = true;
for (const entry of loaded) {
try {
if (entry.dir) {
FS.mkdirTree(entry.path);
} else {
const parent = entry.path.slice(0, entry.path.lastIndexOf('/'));
if (parent && parent !== root) FS.mkdirTree(parent);
FS.writeFile(entry.path, base64ToBytes(await readData(entry)), { canOwn: true });
}
if (typeof entry.mode === 'number') FS.chmod(entry.path, entry.mode);
const mtime = Number(entry.mtime) || Date.now();
FS.utime(entry.path, mtime, mtime);
const stat = FS.stat(entry.path);
remote.set(entry.path, {
key: entry.key, dir: Boolean(entry.dir), mtime: stat.mtime.getTime(), size: entry.dir ? 0 : stat.size, chunks: entry.chunks ?? [],
});
} catch (error) {
whole = false;
log(`could not restore ${entry.path}: ${error?.message ?? error}`);
}
}
// Pieces no entry names are left over from a write that stopped half way.
if (whole) {
const named = new Set([...remote.values()].flatMap((known) => known.chunks));
for (const key of all) {
if (!key.startsWith(CHUNK_PREFIX) || named.has(key)) continue;
try { await store.remove(key); } catch { /* tried again next session */ }
}
}
complete = true;
}
function refuse(path, entry, reason) {
const before = refused.get(path);
refused.set(path, { mtime: entry.mtime, size: entry.size });
if (before) return;
log(`${path} is not kept: ${reason}`);
onTrouble(path, reason);
}
async function write(path, entry) {
const key = keyFor(path);
const value = { path, dir: entry.dir, mode: entry.mode, mtime: entry.mtime };
const known = remote.get(path);
let chunks = [];
if (!entry.dir) {
const data = bytesToBase64(FS.readFile(path));
if (data.length > MAX_FILE) {
refuse(path, entry, `it is too large (${entry.size} bytes)`);
return;
}
value.data = data.slice(0, CHUNK);
const version = `${Date.now().toString(36)}${(versions++).toString(36)}`;
for (let at = CHUNK; at < data.length; at += CHUNK) chunks.push(chunkKey(path, version, chunks.length + 1));
if (chunks.length) value.chunks = chunks;
try {
for (let i = 0; i < chunks.length; i++) await store.set(chunks[i], data.slice((i + 1) * CHUNK, (i + 2) * CHUNK));
await store.set(key, JSON.stringify(value));
} catch (error) {
// The entry still names the old pieces, if any; the new ones go.
for (const piece of chunks) {
try { await store.remove(piece); } catch { /* removed at the next populate */ }
}
refuse(path, entry, error?.message ?? String(error));
return;
}
} else {
try {
await store.set(key, JSON.stringify(value));
} catch (error) {
refuse(path, entry, error?.message ?? String(error));
return;
}
}
refused.delete(path);
remote.set(path, { key, dir: entry.dir, mtime: entry.mtime, size: entry.size, chunks });
for (const piece of known?.chunks ?? []) {
try { await store.remove(piece); } catch { /* removed at the next populate */ }
}
}
async function persist(root) {
const local = walk(root);
for (const [path, entry] of local) {
const known = remote.get(path);
// A directory only has to exist; its mtime moves whenever a child is written.
if (known && entry.dir && known.dir) continue;
if (known && !entry.dir && !known.dir && known.mtime === entry.mtime && known.size === entry.size) continue;
const before = refused.get(path);
if (before && before.mtime === entry.mtime && before.size === entry.size) continue;
await write(path, entry);
}
for (const path of [...refused.keys()]) if (!local.has(path)) refused.delete(path);
if (!complete) return;
for (const [path, known] of [...remote]) {
if (local.has(path)) continue;
await store.remove(known.key);
remote.delete(path);
for (const piece of known.chunks) {
try { await store.remove(piece); } catch { /* removed at the next populate */ }
}
}
}
return {
mount: (mount) => MEMFS.mount(mount),
syncfs(mount, populateNow, callback) {
const run = () => (populateNow ? populate(mount.mountpoint) : persist(mount.mountpoint));
queue = queue.then(run).then(
() => callback(null),
(error) => {
log(`${populateNow ? 'loading' : 'keeping'} saves failed: ${error?.message ?? error}`);
// The game must start even when storage does not answer; a failed persist is retried on the next change.
callback(populateNow ? null : error);
},
);
},
/** For tests and diagnostics. */
get state() { return { complete, remote: new Map(remote), refused: new Map(refused) }; },
};
}
