@pixelvibing/cli 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,7 @@ import { promisify, parseArgs } from "node:util";
5
5
  import { randomBytes, createHash } from "node:crypto";
6
6
  import { createServer } from "node:http";
7
7
  import { stat, readFile, writeFile, mkdir, readdir, lstat, rm, rename, realpath as realpath$1 } from "node:fs/promises";
8
- import { deflateSync, inflateSync } from "node:zlib";
8
+ import { inflate, deflateSync, inflateSync } from "node:zlib";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { spawn } from "node:child_process";
11
11
  import { hostname, homedir } from "node:os";
@@ -1305,6 +1305,174 @@ const editor = {
1305
1305
  answer(context, { opened: local.root, app: found.command }, `Opening ${local.root} in PixelVibing.`);
1306
1306
  }
1307
1307
  };
1308
+ const SECTOR_SIDE = 128;
1309
+ const SECTOR_CELLS = SECTOR_SIDE * SECTOR_SIDE;
1310
+ const SECTOR_BYTES = 512 * 1024;
1311
+ const MAP_LEVELS = { first: 0, last: 14 };
1312
+ const LARGEST_MAP_SIDE = 1e4;
1313
+ const OUTLINE_ACROSS = Math.ceil(LARGEST_MAP_SIDE / SECTOR_SIDE);
1314
+ const SECTOR_FORMAT = 1;
1315
+ const MAGIC = [80, 86, 67];
1316
+ const PRESENT = 1;
1317
+ const AUDIO = 2;
1318
+ const INTERACT = 4;
1319
+ const EXTRA = 8;
1320
+ const NAMED = /* @__PURE__ */ new Set(["x", "y", "z", "layers", "blocking", "zone", "audioZone", "interact"]);
1321
+ class SectorError extends Error {
1322
+ constructor(message) {
1323
+ super(message);
1324
+ this.name = "SectorError";
1325
+ }
1326
+ }
1327
+ function decodeSector(bytes) {
1328
+ if (bytes.length < 4 || MAGIC.some((byte, index) => bytes[index] !== byte)) throw new SectorError("these bytes are not a map sector");
1329
+ if (bytes[3] !== SECTOR_FORMAT) throw new SectorError(`a map sector of format ${bytes[3]} is not one this version reads`);
1330
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1331
+ let at = 4;
1332
+ const need = (count) => {
1333
+ if (at + count > bytes.length) throw new SectorError("the sector's bytes end before its last cell");
1334
+ };
1335
+ const read8 = () => {
1336
+ need(1);
1337
+ return bytes[at++];
1338
+ };
1339
+ const read16 = () => {
1340
+ need(2);
1341
+ const value = bytes[at] | bytes[at + 1] << 8;
1342
+ at += 2;
1343
+ return value;
1344
+ };
1345
+ const cells = [];
1346
+ for (let index = 0; index < SECTOR_CELLS; index += 1) {
1347
+ const flags = read8();
1348
+ if ((flags & PRESENT) === 0) {
1349
+ cells.push(void 0);
1350
+ continue;
1351
+ }
1352
+ const count = read8();
1353
+ const layers = [];
1354
+ for (let layer = 0; layer < count; layer += 1) layers.push(read16());
1355
+ const blocking = read16();
1356
+ const zone = read16();
1357
+ const audioZone = (flags & AUDIO) === 0 ? void 0 : read16();
1358
+ const interact = (flags & INTERACT) === 0 ? void 0 : read16();
1359
+ let extra;
1360
+ if ((flags & EXTRA) !== 0) {
1361
+ const length = read16();
1362
+ need(length);
1363
+ let parsed2;
1364
+ try {
1365
+ parsed2 = JSON.parse(decoder.decode(bytes.subarray(at, at + length)));
1366
+ } catch {
1367
+ throw new SectorError("a tile's extra fields are not JSON");
1368
+ }
1369
+ if (typeof parsed2 !== "object" || parsed2 === null || Array.isArray(parsed2)) throw new SectorError("a tile's extra fields are not an object");
1370
+ const named = Object.keys(parsed2).find((key) => NAMED.has(key));
1371
+ if (named !== void 0) throw new SectorError(`a tile's extra fields name ${named}, which the sector holds itself`);
1372
+ extra = parsed2;
1373
+ at += length;
1374
+ }
1375
+ cells.push({
1376
+ layers,
1377
+ blocking,
1378
+ zone,
1379
+ ...audioZone === void 0 ? {} : { audioZone },
1380
+ ...interact === void 0 ? {} : { interact },
1381
+ ...extra === void 0 ? {} : { extra }
1382
+ });
1383
+ }
1384
+ if (at !== bytes.length) throw new SectorError("the sector's bytes go on past its last cell");
1385
+ return cells;
1386
+ }
1387
+ function tileOfCell(cell, x, y, z) {
1388
+ return {
1389
+ ...cell.extra ?? {},
1390
+ x,
1391
+ y,
1392
+ z,
1393
+ layers: [...cell.layers],
1394
+ blocking: cell.blocking,
1395
+ zone: cell.zone,
1396
+ ...cell.audioZone === void 0 ? {} : { audioZone: cell.audioZone },
1397
+ ...cell.interact === void 0 ? {} : { interact: cell.interact }
1398
+ };
1399
+ }
1400
+ const MIRROR_MAP_DIRECTORY = "map";
1401
+ const MIRROR_INDEX_FILE = `${MIRROR_MAP_DIRECTORY}/index.json`;
1402
+ const MIRROR_FORMAT = 1;
1403
+ class MirrorError extends Error {
1404
+ // Assigned in the body: Node's strip-only TypeScript mode rejects parameter
1405
+ // properties (docs/notes.md n-0023).
1406
+ constructor(message) {
1407
+ super(message);
1408
+ this.name = "MirrorError";
1409
+ }
1410
+ }
1411
+ function mirrorSectorPath(z, cx, cy) {
1412
+ if (!isPlace(z, cx, cy)) throw new MirrorError(`no sector of a map is at level ${z}, column ${cx}, row ${cy}`);
1413
+ return `${MIRROR_MAP_DIRECTORY}/${z}/${cx}-${cy}.bin`;
1414
+ }
1415
+ function isPlace(z, cx, cy) {
1416
+ const within = (value, last) => Number.isInteger(value) && value >= 0 && value <= last;
1417
+ return within(z, MAP_LEVELS.last) && z >= MAP_LEVELS.first && within(cx, OUTLINE_ACROSS - 1) && within(cy, OUTLINE_ACROSS - 1);
1418
+ }
1419
+ function isSize(value) {
1420
+ const side = (n) => Number.isInteger(n) && n >= 1 && n <= LARGEST_MAP_SIDE;
1421
+ return typeof value === "object" && value !== null && side(value.width) && side(value.height);
1422
+ }
1423
+ function parseMirrorIndex(text2) {
1424
+ let value;
1425
+ try {
1426
+ value = JSON.parse(text2);
1427
+ } catch {
1428
+ throw new MirrorError(`${MIRROR_INDEX_FILE} is not JSON`);
1429
+ }
1430
+ const index = value;
1431
+ if (typeof index !== "object" || index === null) throw new MirrorError(`${MIRROR_INDEX_FILE} is not an object`);
1432
+ if (index.format !== MIRROR_FORMAT)
1433
+ throw new MirrorError(`${MIRROR_INDEX_FILE} is in format ${String(index.format)}, and this reads format ${MIRROR_FORMAT}`);
1434
+ if (!Number.isInteger(index.revision) || index.revision < 0) throw new MirrorError(`${MIRROR_INDEX_FILE} names no revision`);
1435
+ if (!isSize(index.size) || !isSize(index.limit)) throw new MirrorError(`${MIRROR_INDEX_FILE} names no size or limit a map may have`);
1436
+ if (!Array.isArray(index.names) || !Array.isArray(index.sectors)) throw new MirrorError(`${MIRROR_INDEX_FILE} lists no levels or sectors`);
1437
+ for (const named of index.names) {
1438
+ if (typeof named?.name !== "string" || !isPlace(named.z, 0, 0)) throw new MirrorError(`${MIRROR_INDEX_FILE} names a level that is none`);
1439
+ }
1440
+ const seen = /* @__PURE__ */ new Set();
1441
+ for (const sector of index.sectors) {
1442
+ const [z, cx, cy, version2] = Array.isArray(sector) ? sector : [];
1443
+ if (!isPlace(z, cx, cy) || !Number.isInteger(version2) || version2 < 1) {
1444
+ throw new MirrorError(`${MIRROR_INDEX_FILE} lists a sector that is none: ${JSON.stringify(sector)}`);
1445
+ }
1446
+ const key = `${z}:${cx}:${cy}`;
1447
+ if (cx * SECTOR_SIDE >= index.size.width || cy * SECTOR_SIDE >= index.size.height) {
1448
+ throw new MirrorError(`${MIRROR_INDEX_FILE} lists the sector ${key}, outside the map's size`);
1449
+ }
1450
+ if (seen.has(key)) throw new MirrorError(`${MIRROR_INDEX_FILE} lists the sector ${key} twice`);
1451
+ seen.add(key);
1452
+ }
1453
+ return index;
1454
+ }
1455
+ async function readMirroredMap(index, read, inflate2) {
1456
+ const tiles = [];
1457
+ for (const [z, cx, cy] of index.sectors) {
1458
+ const path = mirrorSectorPath(z, cx, cy);
1459
+ let cells;
1460
+ try {
1461
+ cells = decodeSector(await inflate2(await read(path)));
1462
+ } catch (error) {
1463
+ throw new MirrorError(`${path} is not a sector the map can read: ${error instanceof Error ? error.message : String(error)}`);
1464
+ }
1465
+ for (const [at, cell] of cells.entries()) {
1466
+ if (cell === void 0) continue;
1467
+ const x = cx * SECTOR_SIDE + at % SECTOR_SIDE;
1468
+ const y = cy * SECTOR_SIDE + Math.floor(at / SECTOR_SIDE);
1469
+ if (x >= index.size.width || y >= index.size.height) continue;
1470
+ tiles.push(tileOfCell(cell, x, y, z));
1471
+ }
1472
+ }
1473
+ tiles.sort((a, b) => a.z - b.z || a.y - b.y || a.x - b.x);
1474
+ return { width: index.size.width, height: index.size.height, tiles };
1475
+ }
1308
1476
  const TILE_SIZE = 256;
1309
1477
  const EAI_MAGIC = 1229018181;
1310
1478
  const EAI_VERSION = 1;
@@ -2206,6 +2374,23 @@ async function readProjectMap(boundary, artDirectory) {
2206
2374
  await sized(boundary, path, MAX_TEXT_BYTES, shown(artDirectory, "map.json"));
2207
2375
  return boundary.readText(path);
2208
2376
  }
2377
+ const MAX_MIRRORED_SECTOR_BYTES = 256 * 1024;
2378
+ const inflated = promisify(inflate);
2379
+ async function readMirroredProjectMap(boundary, artDirectory) {
2380
+ const indexPath = join(artDirectory, MIRROR_INDEX_FILE);
2381
+ if (!await boundary.exists(indexPath)) return null;
2382
+ await sized(boundary, indexPath, MAX_TEXT_BYTES, shown(artDirectory, MIRROR_INDEX_FILE));
2383
+ const index = parseMirrorIndex(await boundary.readText(indexPath));
2384
+ return readMirroredMap(
2385
+ index,
2386
+ async (relative2) => {
2387
+ const path = join(artDirectory, relative2);
2388
+ await sized(boundary, path, MAX_MIRRORED_SECTOR_BYTES, shown(artDirectory, relative2));
2389
+ return boundary.readBytes(path);
2390
+ },
2391
+ async (bytes) => new Uint8Array(await inflated(bytes, { maxOutputLength: SECTOR_BYTES }))
2392
+ );
2393
+ }
2209
2394
  async function sealProject(boundary, artDirectory) {
2210
2395
  try {
2211
2396
  const sealed = sealProjectArt(await readProjectArt(boundary, artDirectory));
@@ -2740,14 +2925,14 @@ const paeth = (a, b, c) => {
2740
2925
  const pc = Math.abs(p - c);
2741
2926
  return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
2742
2927
  };
2743
- function unfilter(inflated, header) {
2928
+ function unfilter(inflated2, header) {
2744
2929
  const bpp = CHANNELS[header.colorType] * (header.depth / 8);
2745
2930
  const stride = header.width * bpp;
2746
2931
  const out = new Uint8Array(header.height * stride);
2747
2932
  for (let y = 0; y < header.height; y += 1) {
2748
- const filter = inflated[y * (stride + 1)];
2933
+ const filter = inflated2[y * (stride + 1)];
2749
2934
  if (filter > 4) throw new PngError(`a row has filter type ${filter}, which PNG does not define`);
2750
- const row = inflated.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
2935
+ const row = inflated2.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
2751
2936
  const at = y * stride;
2752
2937
  for (let x = 0; x < stride; x += 1) {
2753
2938
  const a = x >= bpp ? out[at + x - bpp] : 0;
@@ -2792,14 +2977,14 @@ function decodeWith(zlib, bytes) {
2792
2977
  if (header === null) throw new PngError("the PNG has no IHDR chunk");
2793
2978
  const bpp = CHANNELS[header.colorType] * (header.depth / 8);
2794
2979
  const expected = header.height * (1 + header.width * bpp);
2795
- let inflated;
2980
+ let inflated2;
2796
2981
  try {
2797
- inflated = zlib.inflate(concat(data), expected);
2982
+ inflated2 = zlib.inflate(concat(data), expected);
2798
2983
  } catch {
2799
2984
  throw new PngError("the PNG's image data could not be inflated to the size its header promises");
2800
2985
  }
2801
- if (inflated.length !== expected) throw new PngError("the PNG's image data is not the size its header promises");
2802
- return toRgba(unfilter(inflated, header), header, palette, transparency);
2986
+ if (inflated2.length !== expected) throw new PngError("the PNG's image data is not the size its header promises");
2987
+ return toRgba(unfilter(inflated2, header), header, palette, transparency);
2803
2988
  }
2804
2989
  function toRgba(samples, header, palette, transparency) {
2805
2990
  const { width, height, depth, colorType } = header;
@@ -2949,7 +3134,8 @@ const exportCommand = {
2949
3134
  try {
2950
3135
  const boundary = new Boundary([art]);
2951
3136
  const read = await readProjectArt(boundary, art);
2952
- const mapText = await readProjectMap(boundary, art);
3137
+ const mirrored2 = await readMirroredProjectMap(boundary, art);
3138
+ const mapText = mirrored2 === null ? await readProjectMap(boundary, art) : null;
2953
3139
  checkExportable(
2954
3140
  read.pictures.map((picture) => {
2955
3141
  if (!isPng(picture.bytes)) throw new CliError(`the picture ${picture.name} cannot be read: it is not a PNG`);
@@ -2967,7 +3153,7 @@ const exportCommand = {
2967
3153
  {
2968
3154
  pictures,
2969
3155
  entries: read.entries,
2970
- map: mapText === null ? null : parseMapText(mapText, "pixelvibing/map.json").document,
3156
+ map: mirrored2 ?? (mapText === null ? null : parseMapText(mapText, "pixelvibing/map.json").document),
2971
3157
  idMap: read.idMap === null ? null : parseIdMap(JSON.parse(read.idMap))
2972
3158
  },
2973
3159
  engine,
@@ -2975,7 +3161,7 @@ const exportCommand = {
2975
3161
  godotRoot === void 0 ? {} : { godotRoot }
2976
3162
  );
2977
3163
  } catch (error) {
2978
- const refusals = [ProjectError, PackError, PngError, MapTextError, IdMapError, SyntaxError, CliError];
3164
+ const refusals = [ProjectError, PackError, PngError, MapTextError, MirrorError, IdMapError, SyntaxError, CliError];
2979
3165
  if (refusals.some((kind) => error instanceof kind)) throw new CliError(`nothing was exported: ${error.message}`);
2980
3166
  throw error;
2981
3167
  }
@@ -3762,7 +3948,7 @@ const seal = {
3762
3948
  answer(context, { sealed: true, output: result.output }, result.output);
3763
3949
  }
3764
3950
  };
3765
- const version = "0.9.0";
3951
+ const version = "0.10.0";
3766
3952
  const manifest = {
3767
3953
  version
3768
3954
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixelvibing/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "PixelVibing: pixel art game assets from the command line, and the Skills that teach coding assistants to make them.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://pixelvibing.com",