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/src/storage-fs.js
/**
 * /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) }; },
  };
}