diff --git a/README.md b/README.md
index 3157244..e098c86 100644
--- a/README.md
+++ b/README.md
@@ -491,14 +491,26 @@ isolated worker processes. Your IMAP app password is stored encrypted at rest
```powershell
npm start # run the cat
-npm test # 52 tests: config, poses, petting, audio, site drift
+npm test # 81 tests: config, poses, petting, audio, art frames, site drift
npm run poses:cat # previews/cat-poses.png (every activity x every coat)
npm run poses:dog # the same for all 14 breeds
+npm run frames:import --
# import painted PNGs as baked poses
npm run demo:all # regenerate the README media (hero, gallery, carousel)
npm run hook -- cursor # print a path-filled agent hook config
npm run icon # regenerate the tray + app-tile icons
```
+**Painting a pose by hand.** Every pose is composed in code, which is why one
+`sit` covers 28 coats and a new coat costs nine hex values instead of an art pass.
+The trade is that changing how the pet looks means editing geometry.
+`npm run frames:import` is the escape hatch: paint a pose against a placeholder
+palette, import it, and it wins over the composer for exactly the coats you name
+while everything else keeps composing. Five held poses can be baked (`sit`,
+`type`, `loaf`, `rear`, `hunt`); the six raised-limb activities are parameterised
+rigs whose limbs sweep through quantised frames, so a still would freeze them.
+Palette, naming and the checks the importer runs are in
+[docs/frame-pack.md](docs/frame-pack.md).
+
**Visual QA.** The overlay is GPU-composited, so ordinary screenshots cannot
capture it. Poses are reviewed with a one-command contact sheet instead:
`npm run poses:cat` renders **every activity across every coat** into one image,
diff --git a/docs/frame-pack.md b/docs/frame-pack.md
new file mode 100644
index 0000000..5f0f4a1
--- /dev/null
+++ b/docs/frame-pack.md
@@ -0,0 +1,126 @@
+# Frame pack: painting poses by hand
+
+Every pose the pet holds is composed in code from grid primitives, which is why one
+`sit` covers 28 coats and why a new coat costs nine hex values instead of an art
+pass. The trade is that improving how the pet *looks* means editing geometry.
+
+This is the escape hatch. Paint a pose, import it, and it wins over the composer
+for exactly the coats you name. Everything else keeps composing, so a pack of one
+frame is a perfectly valid pack.
+
+## The model: roles, not colours
+
+A sprite is a grid of **role letters**, one per cell:
+
+| Letter | Role | Where it goes |
+|---|---|---|
+| `C` | coat | body, head, legs, the main mass |
+| `K` | mark | stripes, saddle, mask, ear backs |
+| `W` | white | bib, muzzle, toes, tail tip |
+| `X` | patch | tortie and calico blocks, dog brows |
+| `I` | inner | inner ear only |
+| `N` | nose | nose pad only, anchors the whiskers and the tongue |
+| `E` | eye | flat eye block, the engine paints the pupil into it |
+| `O` | outline | one cell silhouette band |
+| `H` | halo | **generated**, never authored |
+
+`drawCat` looks the colour up per coat at draw time and applies its own top lit
+shading. So the art you import must be **flat**: any shading painted in doubles up
+with the engine's and turns muddy.
+
+Three things are drawn live on top of the frame and must not appear in it: the
+**halo**, the **pupils** (they track the cursor), and the dog **tongue**.
+
+## Painting against the placeholder palette
+
+Paint with these nine colours and nothing in between. They are never seen; they
+exist only so a nearest-colour match can tell the roles apart.
+
+| Role | Hex | | Role | Hex |
+|---|---|---|---|---|
+| coat | `#D9C7A7` | | nose | `#B04A57` |
+| mark | `#33302E` | | eye | `#4FBF7A` |
+| white | `#FBFBF7` | | outline | `#5C534A` |
+| patch | `#D2762B` | | tongue | `#E8747F` (engine only) |
+| inner | `#EFA9B8` | | | |
+
+Transparent background. No anti aliasing, no gradients, no dithering. The importer
+snaps every pixel to the nearest of the eight paintable colours and takes an area
+majority vote per cell, so it survives art that does not land exactly on the grid,
+but it cannot rescue a soft edge: it will report the percentage of pixels that were
+not a palette colour and you should treat anything over 10 percent as a redraw.
+
+## What can be baked, and what cannot
+
+Five poses are **held**: the pet sits in them for whole seconds, and a still frame
+is the right shape for them.
+
+| Pose | Canvas | Notes |
+|---|---|---|
+| `sit` | 24x30 | the default, also the base for drag, pet, startle, greeting |
+| `type` | 24x24 | no forelegs: the kneading paws are drawn over the keycaps |
+| `loaf` | 24x30 | cat loaf, dog nose-to-tail curl (a side view, one eye) |
+| `rear` | 24x30 | cat rear-up, dog beg. No forelegs: they are drawn live |
+| `hunt` | 30x20 cat, 30x22 dog | cat crouch (front on), dog play bow (side view, one eye) |
+
+The other six activities are **animation rigs**, not stills. Their limbs move with
+a quantised parameter, so replacing one with a single frame would freeze it:
+
+| Activity | Rig | Distinct frames per coat |
+|---|---|---|
+| rope climb | `climbSpriteFor(i, hand, dir)` | 4 (two grips x two headings) |
+| groom, ponder, play | `pawSpriteFor(i, lift, out)` | up to 45 (`PAW_STEPS` 8 by 4 steps out) |
+| bat overhead | `batSpriteFor(i, up, ph)` | 14 (two sides x seven arc steps) |
+
+The importer refuses those poses on purpose. Generated art for them is still worth
+having as a **reference to redraw the composer against**, which is how the dog was
+built in the first place: iterate the geometry against a rendered contact sheet
+until the silhouette reads.
+
+## Importing
+
+Name each PNG for the pose it fills, and optionally the coat it belongs to:
+
+```
+cat-sit.png every cat coat
+cat-sit--Orange Tabby.png that one coat, by its PATTERNS name
+dog-hunt--retriever.png every breed built on the retriever body
+```
+
+Then:
+
+```
+npm run frames:import -- path/to/pngs # writes src/art-frames.js
+npm run frames:import -- path/to/pngs --dry # report only, write nothing
+```
+
+Lookup order is coat name, then build name, then `*`, then the composer. So you can
+ship one `*` frame and override a single awkward coat later.
+
+Every frame is checked before it is written, and a frame that fails is dropped with
+a reason rather than shipped:
+
+- the canvas matches the pose (a wrong size would break the layout maths built on it)
+- an eye block sits **either side of the seam `eyeBox()` splits on**, or exactly one
+ for the two profile poses. A face drawn off centre puts one eye in both boxes and
+ `drawCat` paints the pupil as a bar across the muzzle. This has bitten the project
+ twice, once on the cat and once on the dog.
+- a nose exists, because the whiskers and the panting tongue anchor to it
+- the bottom row is occupied, so the pose stands on the same floor line as the others
+
+`--force` writes anyway. It is there for experiments, not for shipping.
+
+## Verifying
+
+```
+npm test # includes a PNG round trip and the renderer hook
+npm run poses:cat # previews/cat-poses.png, every activity x every coat
+npm run poses:dog
+npm run test:boot
+```
+
+Then look at the sheets. Reading grid code cannot tell you whether a silhouette
+reads as an animal, and the two coats worth checking first are **Black** and
+**Slate**: they collapse coat, mark, white and patch into one colour, so anything
+that only reads because of a colour change disappears. If the pose survives those,
+the silhouette is doing the work.
diff --git a/eslint.config.js b/eslint.config.js
index 17d758a..3953dfc 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -18,6 +18,8 @@ const sharedOverlay = {
PET_SPECIES: 'readonly', SPECIES_IDS: 'readonly', speciesOf: 'readonly', coatsFor: 'readonly',
isSpecies: 'readonly', defaultCoatIndex: 'readonly', CAT_COATS: 'readonly', DOG_COATS: 'readonly',
BUILDS: 'readonly', TABBY: 'readonly', PATTERN_BUILD: 'readonly',
+ // art-frames.js provides the generated baked-pose table:
+ ART_FRAMES: 'readonly',
BODY: 'readonly', G: 'readonly', GC: 'readonly', GR: 'readonly', HALO: 'readonly',
rgbStr: 'readonly', toRgb: 'readonly', shadeStr: 'readonly', lerpHex: 'readonly',
fillPlaceholders: 'readonly', CLIMB_FRAMES: 'readonly',
@@ -49,7 +51,7 @@ module.exports = [
{
// Node / CommonJS: main process, workers, scripts, tests, configs, template.js
files: ['**/*.js'],
- ignores: [...CONSUMER_OVERLAY, 'src/cat-sprite.js', 'src/dog-sprite.js', 'src/patterns.js', 'src/pets.js', 'src/audio.js', 'src/effects.js', 'src/jam.js'],
+ ignores: [...CONSUMER_OVERLAY, 'src/cat-sprite.js', 'src/dog-sprite.js', 'src/patterns.js', 'src/pets.js', 'src/art-frames.js', 'src/audio.js', 'src/effects.js', 'src/jam.js'],
languageOptions: { sourceType: 'commonjs', ecmaVersion: 2023, globals: { ...globals.node } },
},
{
@@ -76,7 +78,7 @@ module.exports = [
{
// cat-sprite.js / patterns.js are dual-loaded: classic
+
diff --git a/src/renderer.js b/src/renderer.js
index 3188b13..65d756a 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -484,12 +484,14 @@ function climbSpriteFor(i, hand, dir) {
}
return sp;
}
-let spriteHunt = buildSprite(SPECIES_DEFS.huntCols, SPECIES_DEFS.huntRows, () => SPECIES_DEFS.hunt(buildFor(0, SPECIES_DEFS)));
+// Cats share ONE crouch across every coat, so its baked override is looked up at
+// index 0: name a coat and only that coat's key can ever match, so key it '*'.
+let spriteHunt = posed('hunt', 0, SPECIES_DEFS.huntCols, SPECIES_DEFS.huntRows, () => SPECIES_DEFS.hunt(buildFor(0, SPECIES_DEFS)));
let huntSprites = null; // dogs vary the bow by breed (markings); cats share one crouch
function huntSpriteFor(i) { return (huntSprites && huntSprites[i]) || spriteHunt; }
function buildHuntSprites(D) {
return D === null || !isDog() ? null
- : D.build.map((b, i) => buildSprite(D.huntCols, D.huntRows, () => D.hunt(buildFor(i, D))));
+ : D.build.map((b, i) => posed('hunt', i, D.huntCols, D.huntRows, () => D.hunt(buildFor(i, D))));
}
const TW = 24 * CELL, TH = 24 * CELL; // front-facing kneading-cat dims (per-coat sprites built below)
// Sit grid is always 24x30, so SW/SH and the mochi bands stay constant across the
@@ -568,14 +570,46 @@ const octx = oc.getContext('2d'); octx.imageSmoothingEnabled = false;
const HEAD_SRC = 14 * CELL, FEET_SRC = 7 * CELL, MID_SRC = SH - HEAD_SRC - FEET_SRC;
-const sprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => SPECIES_DEFS.sit(buildFor(i, SPECIES_DEFS))));
+// --- baked frames (src/art-frames.js) ---------------------------------------
+// A painted frame beats the composer for the pose and the coat it names, and
+// everything else keeps composing, so a half finished art pack still runs. Only
+// the five HELD poses can be baked: the raised-limb activities are parameterised
+// rigs (pawSpriteFor / batSpriteFor / climbSpriteFor quantise a limb angle into a
+// handful of frames) and one still would freeze them mid swing. A grid that does
+// not match the pose's canvas is ignored rather than trusted, because the layout
+// maths around it is built on those constants.
+function artGrid(pose, i, cols, rows) {
+ const all = typeof ART_FRAMES !== 'undefined' ? ART_FRAMES : null;
+ const byPose = all && all[species] && all[species][pose];
+ if (!byPose) return null;
+ const name = (PATTERNS[i] && PATTERNS[i].name) || '';
+ const g = byPose[name] || byPose[PATTERN_BUILD[i]] || byPose['*'] || null;
+ if (!g || g.COLS !== cols || g.ROWS !== rows || !Array.isArray(g.rows) || g.rows.length !== rows) return null;
+ return g;
+}
+
+// Stamp a baked grid THROUGH buildSprite so it picks up outlineHalo(), the eye
+// boxes and the muzzle anchor exactly as a composed pose does - the halo is never
+// painted by hand, and a patchy outline gets its gaps filled for free.
+function posed(pose, i, cols, rows, compose) {
+ const g = artGrid(pose, i, cols, rows);
+ if (!g) return buildSprite(cols, rows, compose);
+ return buildSprite(cols, rows, () => {
+ for (let r = 0; r < rows; r++) {
+ const row = g.rows[r];
+ for (let c = 0; c < cols && c < row.length; c++) if (row[c] !== '.') setCell(c, r, row[c]);
+ }
+ });
+}
+
+const sprites = PATTERN_BUILD.map((b, i) => posed('sit', i, 24, 30, () => SPECIES_DEFS.sit(buildFor(i, SPECIES_DEFS))));
// each coat also gets its own typing (kneading) body, so every breed types differently
// one shared front "kneading cat" shape, recoloured per coat (+ tabby stripes / fluffy tufts)
-const typeSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 24, () => SPECIES_DEFS.type(buildFor(i, SPECIES_DEFS))));
+const typeSprites = PATTERN_BUILD.map((b, i) => posed('type', i, 24, 24, () => SPECIES_DEFS.type(buildFor(i, SPECIES_DEFS))));
// and a dedicated loaf (resting) body per coat - same 24x30 size as the sit sprite
-const loafSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => SPECIES_DEFS.loaf(buildFor(i, SPECIES_DEFS))));
+const loafSprites = PATTERN_BUILD.map((b, i) => posed('loaf', i, 24, 30, () => SPECIES_DEFS.loaf(buildFor(i, SPECIES_DEFS))));
// and a rear-up "bat the butterfly" body per coat - same 24x30 size as the sit sprite
-const rearSprites = PATTERN_BUILD.map((b, i) => buildSprite(24, 30, () => SPECIES_DEFS.rear(buildFor(i, SPECIES_DEFS))));
+const rearSprites = PATTERN_BUILD.map((b, i) => posed('rear', i, 24, 30, () => SPECIES_DEFS.rear(buildFor(i, SPECIES_DEFS))));
huntSprites = buildHuntSprites(SPECIES_DEFS);
const DEFAULT_PATTERN = Math.max(0, PATTERNS.findIndex((p) => p.name === (species === 'dog' ? 'Golden Retriever' : 'Tuxedo')));
const coatKey = (sp) => (sp === 'dog' ? 'dogPattern' : 'pattern');
@@ -606,11 +640,12 @@ function applyThemes(list) {
PATTERN_BUILD.push(build);
TABBY.push(!!th.tabby);
const D = SPECIES_DEFS, tb = { ...(D.builds[build] || BUILDS[build] || {}), tabby: !!th.tabby };
- sprites.push(buildSprite(24, 30, () => D.sit(tb)));
- typeSprites.push(buildSprite(24, 24, () => D.type(tb)));
- loafSprites.push(buildSprite(24, 30, () => D.loaf(tb)));
- rearSprites.push(buildSprite(24, 30, () => D.rear(tb)));
- if (huntSprites) huntSprites.push(buildSprite(D.huntCols, D.huntRows, () => D.hunt(tb)));
+ const at = PATTERNS.length - 1; // this custom coat's index, for the baked-frame lookup
+ sprites.push(posed('sit', at, 24, 30, () => D.sit(tb)));
+ typeSprites.push(posed('type', at, 24, 24, () => D.type(tb)));
+ loafSprites.push(posed('loaf', at, 24, 30, () => D.loaf(tb)));
+ rearSprites.push(posed('rear', at, 24, 30, () => D.rear(tb)));
+ if (huntSprites) huntSprites.push(posed('hunt', at, D.huntCols, D.huntRows, () => D.hunt(tb)));
}
if (!(patternIndex >= 0 && patternIndex < PATTERNS.length)) patternIndex = DEFAULT_PATTERN;
if (forcedPattern) { const i = PATTERNS.findIndex((p) => p.name.toLowerCase().includes(forcedPattern.toLowerCase())); if (i >= 0) patternIndex = i; }
@@ -629,15 +664,15 @@ function setSpecies(next, coatIdx) {
installTables(SPECIES_DEFS);
BASE_PATTERNS = PATTERNS.length;
const D = SPECIES_DEFS;
- const rebuild = (arr, cols, rows, fn) => {
+ const rebuild = (pose, arr, cols, rows, fn) => {
arr.length = 0;
- for (let i = 0; i < D.build.length; i++) arr.push(buildSprite(cols, rows, () => fn(buildFor(i, D))));
+ for (let i = 0; i < D.build.length; i++) arr.push(posed(pose, i, cols, rows, () => fn(buildFor(i, D))));
};
- rebuild(sprites, 24, 30, D.sit);
- rebuild(typeSprites, 24, 24, D.type);
- rebuild(loafSprites, 24, 30, D.loaf);
- rebuild(rearSprites, 24, 30, D.rear);
- spriteHunt = buildSprite(D.huntCols, D.huntRows, () => D.hunt(buildFor(0, D)));
+ rebuild('sit', sprites, 24, 30, D.sit);
+ rebuild('type', typeSprites, 24, 24, D.type);
+ rebuild('loaf', loafSprites, 24, 30, D.loaf);
+ rebuild('rear', rearSprites, 24, 30, D.rear);
+ spriteHunt = posed('hunt', 0, D.huntCols, D.huntRows, () => D.hunt(buildFor(0, D)));
huntSprites = buildHuntSprites(D);
_palKey = -1; // force a cold-palette rebuild for the new coats
// climbImgs is deliberately NOT cleared: those frames decode once at startup and
diff --git a/tests/art-frames.test.js b/tests/art-frames.test.js
new file mode 100644
index 0000000..254b727
--- /dev/null
+++ b/tests/art-frames.test.js
@@ -0,0 +1,238 @@
+// Baked pose frames: the PNG -> role grid importer, and the renderer hook that
+// lets an imported frame win over the composer.
+//
+// The load-bearing test is the round trip. A composed pose is painted out to a PNG
+// with one flat placeholder colour per role, read back through the importer, and
+// compared cell for cell against the grid it came from. That exercises the whole
+// chain (PNG decode, area majority vote, colour snap) against real sprite shapes
+// rather than a hand written fixture, and it fails loudly if any step drifts.
+const test = require('node:test');
+const assert = require('node:assert');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const zlib = require('node:zlib');
+
+const imp = require('../scripts/import-frames.js');
+const { loadOverlay } = require('../scripts/overlay-vm.js');
+
+const ROLE_RGB = Object.fromEntries(imp.PALETTE);
+
+// --- a minimal PNG encoder, so the fixtures are real files ------------------
+function crc(b) { let c = ~0; for (let i = 0; i < b.length; i++) { c ^= b[i]; for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); } return ~c >>> 0; }
+function chunk(t, d) {
+ const l = Buffer.alloc(4); l.writeUInt32BE(d.length, 0);
+ const b = Buffer.concat([Buffer.from(t), d]);
+ const cc = Buffer.alloc(4); cc.writeUInt32BE(crc(b), 0);
+ return Buffer.concat([l, b, cc]);
+}
+function encodePng(rgba, w, h) {
+ const ih = Buffer.alloc(13); ih.writeUInt32BE(w, 0); ih.writeUInt32BE(h, 4); ih[8] = 8; ih[9] = 6;
+ const stride = w * 4, raw = Buffer.alloc(h * (stride + 1));
+ for (let y = 0; y < h; y++) for (let x = 0; x < stride; x++) raw[y * (stride + 1) + 1 + x] = rgba[y * stride + x];
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
+ chunk('IHDR', ih), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
+}
+
+// Paint a role grid at `scale` px per cell, flat, transparent where empty. The
+// halo is deliberately not painted: outlineHalo() regrows it on import.
+function paintGrid(rows, scale) {
+ const COLS = rows[0].length, ROWS = rows.length, W = COLS * scale, H = ROWS * scale;
+ const rgba = new Uint8ClampedArray(W * H * 4);
+ for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
+ const ch = rows[r][c];
+ const rgb = ROLE_RGB[ch];
+ if (!rgb) continue;
+ for (let dy = 0; dy < scale; dy++) for (let dx = 0; dx < scale; dx++) {
+ const o = ((r * scale + dy) * W + c * scale + dx) * 4;
+ rgba[o] = rgb[0]; rgba[o + 1] = rgb[1]; rgba[o + 2] = rgb[2]; rgba[o + 3] = 255;
+ }
+ }
+ return encodePng(rgba, W, H);
+}
+
+// A composed pose straight out of the overlay, as rows of role letters with the
+// halo dropped (that is what a painted frame would contain).
+// Array.from re-wraps as a HOST array: values crossing back from the vm carry the
+// context's prototypes, so deepStrictEqual fails its prototype check even when the
+// contents match exactly.
+function composedRows(h, expr) {
+ const sp = h.run(`(() => { const s = ${expr}; return { rows: s.grid.map((r) => r.join('')), COLS: s.COLS, ROWS: s.ROWS }; })()`);
+ return { rows: Array.from(sp.rows, (r) => r.replace(/H/g, '.')), COLS: sp.COLS, ROWS: sp.ROWS };
+}
+
+function tmpdir() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pixelpets-frames-'));
+}
+
+test('a painted pose round trips back to the grid it came from', () => {
+ const h = loadOverlay();
+ const cases = [['cat', 'sit', 'sprites[0]'], ['cat', 'loaf', 'loafSprites[0]'], ['dog', 'sit', 'sprites[0]']];
+ for (const [species, pose, expr] of cases) {
+ h.run(`setSpecies(${JSON.stringify(species)})`);
+ const want = composedRows(h, expr);
+ const dir = tmpdir();
+ try {
+ fs.writeFileSync(path.join(dir, `${species}-${pose}.png`), paintGrid(want.rows, 11));
+ const { data, ok, skipped } = imp.importDir(dir, {});
+ assert.strictEqual(skipped, 0, `${species}/${pose} was skipped`);
+ assert.strictEqual(ok, 1);
+ const got = data[species][pose]['*'];
+ assert.strictEqual(got.COLS, want.COLS);
+ assert.strictEqual(got.ROWS, want.ROWS);
+ assert.deepStrictEqual(got.rows, want.rows, `${species}/${pose} did not survive the round trip`);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ }
+});
+
+test('a non integer scale still round trips, because generated art never lands on the grid', () => {
+ const h = loadOverlay();
+ const want = composedRows(h, 'sprites[0]');
+ const dir = tmpdir();
+ try {
+ // 24x30 cells painted at 11px, then the canvas is what a generator would hand
+ // back: the same picture at an awkward size. The area vote has to cope.
+ const png = paintGrid(want.rows, 17);
+ fs.writeFileSync(path.join(dir, 'cat-sit.png'), png);
+ const { data } = imp.importDir(dir, {});
+ assert.deepStrictEqual(data.cat.sit['*'].rows, want.rows);
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test('the importer decodes indexed and greyscale PNGs, not just RGBA', () => {
+ // 2x2 indexed, 4 bit depth, with a transparent entry.
+ const plte = Buffer.from([0xD9, 0xC7, 0xA7, 0x33, 0x30, 0x2E]);
+ const trns = Buffer.from([255, 0]);
+ const raw = Buffer.from([0, 0x01, 0, 0x10]); // filter byte + one packed byte per row
+ const ih = Buffer.alloc(13); ih.writeUInt32BE(2, 0); ih.writeUInt32BE(2, 4); ih[8] = 4; ih[9] = 3;
+ const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
+ chunk('IHDR', ih), chunk('PLTE', plte), chunk('tRNS', trns),
+ chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
+ const img = imp.decodePng(png);
+ assert.strictEqual(img.w, 2);
+ assert.deepStrictEqual([...img.rgba.slice(0, 4)], [0xD9, 0xC7, 0xA7, 255]);
+ assert.strictEqual(img.rgba[7], 0, 'palette index 1 is transparent via tRNS');
+});
+
+test('every PNG filter type decodes', () => {
+ // Sub / Up / Average / Paeth all have to unfilter correctly or the art arrives
+ // sheared. Encode a gradient with a mixed filter per row and check it survives.
+ const w = 4, h = 5, stride = w * 4;
+ const flat = Buffer.alloc(h * stride);
+ for (let i = 0; i < flat.length; i += 4) { flat[i] = i & 255; flat[i + 1] = (i * 3) & 255; flat[i + 2] = (i * 7) & 255; flat[i + 3] = 255; }
+ const raw = Buffer.alloc(h * (stride + 1));
+ for (let y = 0; y < h; y++) {
+ raw[y * (stride + 1)] = y; // filter types 0..4, one per row
+ const cur = flat.subarray(y * stride, (y + 1) * stride);
+ const prev = y ? flat.subarray((y - 1) * stride, y * stride) : Buffer.alloc(stride);
+ for (let x = 0; x < stride; x++) {
+ const a = x >= 4 ? cur[x - 4] : 0, b = prev[x], c = x >= 4 ? prev[x - 4] : 0;
+ const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
+ const pae = (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c);
+ const sub = [0, a, b, (a + b) >> 1, pae][y];
+ raw[y * (stride + 1) + 1 + x] = (cur[x] - sub) & 255;
+ }
+ }
+ const ih = Buffer.alloc(13); ih.writeUInt32BE(w, 0); ih.writeUInt32BE(h, 4); ih[8] = 8; ih[9] = 6;
+ const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
+ chunk('IHDR', ih), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
+ assert.deepStrictEqual([...imp.decodePng(png).rgba], [...flat]);
+});
+
+test('a face drawn off the eyeBox seam is rejected, not shipped', () => {
+ // eyeBox() splits at COLS/2. Both eyes on one side means one eye lands in both
+ // boxes and drawCat paints the pupil as a bar across the muzzle.
+ const spec = imp.poseSpec('sit', 'cat');
+ const rows = Array.from({ length: spec.ROWS }, () => '.'.repeat(spec.COLS));
+ const put = (r, c, ch) => { rows[r] = rows[r].slice(0, c) + ch + rows[r].slice(c + 1); };
+ for (let r = 4; r < spec.ROWS; r++) for (let c = 4; c < 20; c++) put(r, c, 'C');
+ put(8, 5, 'E'); put(8, 8, 'E'); // both eyes left of the seam
+ put(11, 6, 'N');
+ const problems = imp.check(rows, spec);
+ assert.ok(problems.some((p) => p[0] === 'fail' && /either side of column/.test(p[1])),
+ `expected an eye-seam failure, got ${JSON.stringify(problems)}`);
+});
+
+test('a frame with no nose is rejected', () => {
+ const spec = imp.poseSpec('sit', 'cat');
+ const rows = Array.from({ length: spec.ROWS }, () => '.'.repeat(spec.COLS));
+ const put = (r, c, ch) => { rows[r] = rows[r].slice(0, c) + ch + rows[r].slice(c + 1); };
+ for (let r = 4; r < spec.ROWS; r++) for (let c = 4; c < 20; c++) put(r, c, 'C');
+ put(8, 8, 'E'); put(8, 15, 'E');
+ const problems = imp.check(rows, spec);
+ assert.ok(problems.some((p) => p[0] === 'fail' && /nose/.test(p[1])));
+});
+
+test('the dog play bow is checked as a profile pose, one eye only', () => {
+ assert.strictEqual(imp.poseSpec('hunt', 'dog').eyes, 1);
+ assert.strictEqual(imp.poseSpec('hunt', 'cat').eyes, 2);
+ assert.strictEqual(imp.poseSpec('hunt', 'dog').ROWS, 22);
+ assert.strictEqual(imp.poseSpec('hunt', 'cat').ROWS, 20);
+});
+
+test('only the held poses are importable, the animation rigs are refused', () => {
+ for (const pose of ['climb', 'groom', 'ponder', 'play', 'bat']) {
+ assert.strictEqual(imp.poseSpec(pose, 'cat'), null, `${pose} must not be bakeable`);
+ }
+ for (const pose of ['sit', 'type', 'loaf', 'rear', 'hunt']) {
+ assert.ok(imp.poseSpec(pose, 'cat'), `${pose} must be bakeable`);
+ }
+});
+
+test('file names map to a species, a pose and a coat key', () => {
+ assert.deepStrictEqual(imp.parseName('cat-sit.png'), { species: 'cat', pose: 'sit', key: '*' });
+ assert.deepStrictEqual(imp.parseName('cat-sit--Orange Tabby.png'), { species: 'cat', pose: 'sit', key: 'Orange Tabby' });
+ assert.deepStrictEqual(imp.parseName('dog-hunt--retriever.png'), { species: 'dog', pose: 'hunt', key: 'retriever' });
+ assert.strictEqual(imp.parseName('sit.png'), null);
+ assert.strictEqual(imp.parseName('rabbit-sit.png'), null);
+});
+
+test('the emitted module parses and round trips through the lookup shape', () => {
+ const src = imp.emit({ cat: { sit: { '*': { COLS: 2, ROWS: 2, rows: ['CC', 'WW'] } } }, dog: {} });
+ const mod = { exports: {} };
+ new Function('module', src)(mod);
+ assert.deepStrictEqual(mod.exports.ART_FRAMES.cat.sit['*'].rows, ['CC', 'WW']);
+ assert.deepStrictEqual(mod.exports.ART_FRAMES.dog, {});
+});
+
+test('a baked frame beats the composer, and an unbaked coat still composes', () => {
+ const h = loadOverlay();
+ const before = h.run('sprites[0].grid.map((r) => r.join(""))');
+ const spec = imp.poseSpec('sit', 'cat');
+
+ // A deliberately unmistakable body: a solid block with a face on the seam.
+ const rows = Array.from({ length: spec.ROWS }, () => '.'.repeat(spec.COLS));
+ const put = (r, c, ch) => { rows[r] = rows[r].slice(0, c) + ch + rows[r].slice(c + 1); };
+ for (let r = 6; r < spec.ROWS; r++) for (let c = 6; c < 18; c++) put(r, c, 'C');
+ put(9, 9, 'E'); put(9, 14, 'E'); put(12, 11, 'N');
+
+ h.run(`ART_FRAMES.cat.sit = { 'Orange Tabby': { COLS: ${spec.COLS}, ROWS: ${spec.ROWS}, rows: ${JSON.stringify(rows)} } }`);
+ h.run("setSpecies('dog'); setSpecies('cat')"); // forces both tables to rebuild
+
+ const baked = h.run('sprites[0].grid.map((r) => r.join(""))');
+ const other = h.run('sprites[1].grid.map((r) => r.join(""))');
+ assert.notDeepStrictEqual(baked, before, 'the baked frame did not replace the composed one');
+ // Every body cell we painted survives; the halo is regrown around it.
+ for (let r = 0; r < spec.ROWS; r++) for (let c = 0; c < spec.COLS; c++) {
+ if (rows[r][c] !== '.') assert.strictEqual(baked[r][c], rows[r][c], `cell ${c},${r} was not preserved`);
+ }
+ assert.ok(baked.join('').includes('H'), 'outlineHalo() should still have grown a halo');
+ assert.ok(baked.join('').includes('O'), 'outlineHalo() should still have drawn the outline');
+ assert.ok(other.join('') !== baked.join(''), 'a coat with no baked frame must keep composing');
+
+ // eyeBox still finds two eyes, which is what drawCat needs to place the pupils.
+ const eyes = h.run('sprites[0].eyes.map((e) => e.w)');
+ assert.ok(eyes[0] > 0 && eyes[1] > 0, 'both eye boxes must survive a baked frame');
+});
+
+test('a baked grid on the wrong canvas is ignored rather than trusted', () => {
+ const h = loadOverlay();
+ const before = h.run('sprites[0].grid.map((r) => r.join(""))');
+ h.run("ART_FRAMES.cat.sit = { '*': { COLS: 8, ROWS: 8, rows: ['CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC','CCCCCCCC'] } }");
+ h.run("setSpecies('dog'); setSpecies('cat')");
+ assert.deepStrictEqual(h.run('sprites[0].grid.map((r) => r.join(""))'), before);
+});