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); +});