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/save/ops.js
/**
 * Read a Powder Toy save (OPS1, what the current game writes) far enough to
 * draw it: size, walls and every particle's type, position and decoration.
 *
 * Field layout from upstream's GameSave::readOPS / serialiseOPS (v100.1.400).
 * Everything here treats the save as hostile: sizes are checked before they
 * are trusted, and the decompressed size is capped.
 */
import { decompress } from './bzip2.js';
import { parseBson } from './bson.js';

export const CELL = 4;
export const XRES = 612, YRES = 384;
const CELLS_X = XRES / CELL, CELLS_Y = YRES / CELL;
export const MAX_DECOMPRESSED = 16 * 1024 * 1024;

/** Walls saved with the old numbering (O_WL_*), mapped as upstream maps them. */
const LEGACY_WALLS = new Map([
  [122, 1], [123, 2], [124, 3], [125, 4], [127, 5], [255, 5], [128, 6], [129, 7], [130, 0],
  [131, 8], [132, 9], [133, 10], [134, 11], [135, 12], [140, 13], [142, 14], [145, 15],
]);

export class SaveError extends Error {
  constructor(message) {
    super(message);
    this.name = 'SaveError';
  }
}

export function isOps(bytes) {
  return bytes.length >= 12 && bytes[0] === 0x4f && bytes[1] === 0x50 && bytes[2] === 0x53 && bytes[3] === 0x31;
}

/**
 * @param {Uint8Array} bytes the .cps file
 * @returns {{ version: number, width: number, height: number, blockWidth: number, blockHeight: number,
 *   walls: Uint8Array, forEachParticle: (visit: (x: number, y: number, type: number, dcolour: number) => void) => number }}
 */
export function readSave(bytes) {
  if (!(bytes instanceof Uint8Array)) throw new TypeError('save must be a Uint8Array');
  if (!isOps(bytes)) throw new SaveError('Not an OPS1 save');
  const version = bytes[4];
  if (bytes[5] !== CELL) throw new SaveError('Unexpected cell size');
  const blockWidth = bytes[6], blockHeight = bytes[7];
  if (!blockWidth || !blockHeight || blockWidth > CELLS_X || blockHeight > CELLS_Y) throw new SaveError('Save has an invalid size');
  const declared = (bytes[8] | (bytes[9] << 8) | (bytes[10] << 16) | (bytes[11] << 24)) >>> 0;
  if (!declared || declared > MAX_DECOMPRESSED) throw new SaveError('Save data is too large');
  const document = parseBson(decompress(bytes.subarray(12), { maxOutput: declared }));

  const width = blockWidth * CELL, height = blockHeight * CELL;
  const walls = new Uint8Array(blockWidth * blockHeight);
  if (document.wallMap instanceof Uint8Array) {
    if (document.wallMap.length < walls.length) throw new SaveError('Not enough wall data');
    for (let i = 0; i < walls.length; i++) {
      const wall = document.wallMap[i];
      walls[i] = LEGACY_WALLS.has(wall) ? LEGACY_WALLS.get(wall) : wall;
    }
  }

  const parts = document.parts instanceof Uint8Array ? document.parts : null;
  const positions = document.partsPos instanceof Uint8Array ? document.partsPos : null;

  function forEachParticle(visit) {
    if (!parts || !positions) return 0;
    if (positions.length < width * height * 3) throw new SaveError('Not enough particle position data');
    const length = parts.length;
    let i = 0, p = 0, count = 0;
    const take = (n) => {
      const at = i;
      i += n;
      if (i > length) throw new SaveError('Ran past the particle data');
      return at;
    };
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        const here = (positions[p] << 16) | (positions[p + 1] << 8) | positions[p + 2];
        p += 3;
        for (let n = 0; n < here; n++) {
          const head = take(3);
          let type = parts[head];
          let descriptor = parts[head + 1] | (parts[head + 2] << 8);
          if (descriptor & 0x4000) type |= parts[take(1)] << 8;
          take(descriptor & 0x01 ? 2 : 1); // temperature
          if (descriptor & 0x8000) descriptor |= parts[take(1)] << 16;
          if (descriptor & 0x02) take(descriptor & 0x04 ? 2 : 1); // life
          if (descriptor & 0x08) take(descriptor & 0x10 ? (descriptor & 0x1000 ? 4 : 2) : 1); // tmp
          if (descriptor & 0x20) take(descriptor & 0x200 ? 4 : 1); // ctype
          let dcolour = 0;
          if (descriptor & 0x40) {
            const at = take(4);
            dcolour = ((parts[at] << 24) | (parts[at + 1] << 16) | (parts[at + 2] << 8) | parts[at + 3]) >>> 0;
          }
          if (descriptor & 0x80) take(1); // vx
          if (descriptor & 0x100) take(1); // vy
          if (descriptor & 0x400) take(descriptor & 0x800 ? 2 : 1); // tmp2
          if (descriptor & 0x2000) take(descriptor & 0x10000 ? 8 : 4); // tmp3, tmp4
          visit(x, y, type, dcolour);
          count++;
        }
      }
    }
    return count;
  }

  return { version, width, height, blockWidth, blockHeight, walls, forEachParticle };
}