Skip to content

Instantly share code, notes, and snippets.

@swyxio
Last active July 25, 2026 20:56
Show Gist options
  • Select an option

  • Save swyxio/c24498dffecd22ea88697ff9771b5157 to your computer and use it in GitHub Desktop.

Select an option

Save swyxio/c24498dffecd22ea88697ff9771b5157 to your computer and use it in GitHub Desktop.
SmolForge character skin + regeneration contract v0.4 — schema, examples, and deterministic packaging

SmolForge character skin + regeneration contract

This Gist is the portable source of truth for creating or regenerating a SmolForge character skin. Feed the whole Gist at one pinned revision to the artist, model, or build agent. Do not fetch latest halfway through a run.

The contract separates three things:

  1. Identity and motion sources — the canonical reference image, prompts, motion references, and selected source video or frames.
  2. Runtime skin packageskin.json plus transparent sprite strips. This is the only part the SmolForge renderer loads.
  3. Scene sockets — app-owned placement, props, and route context. A skin can appear in a scene, but never owns the page layout.

Files in this Gist

File Purpose
smol forge checklist.md Human-readable creative, motion, packaging, and QA contract
skin.schema.json Machine-readable Draft 2020-12 manifest schema
skin.product.example.json Complete product-profile manifest example
scene-socket.example.json Example of how Forge places a skin into a UI scene
regenerate-skin-pack.mjs Dependency-free validator and deterministic packager
thunder-smith-vnext-apron-helmet.png Current Thunder Smith identity reference

The JSON Schema validates structure. The sample script also performs cross-field checks JSON Schema cannot express conveniently, including exact duration counts, cue bounds, action/clip ID agreement, unique IDs, safe paths, image headers, and strip dimensions.

Recommended regeneration loop

pin this Gist revision
        ↓
copy the reference + complete manifest brief into source/
        ↓
generate one action performance at a time
        ↓
select, align, cut out, and pack frames deterministically
        ↓
run the sample packager in strict validation mode
        ↓
inspect contact sheets and animated previews
        ↓
publish immutable dist/ plus its hashes and provenance receipt

Generation remains probabilistic. Reproducibility begins after a chosen source video or frame sequence is pinned by digest. The same pinned inputs and ordinary image tooling must then produce byte-identical runtime files.

Try the included code

The Gist intentionally does not contain the sprite strips, so its default run creates an honest incomplete plan:

node regenerate-skin-pack.mjs --dry-run
node regenerate-skin-pack.mjs --out ../thunder-smith-scaffold

After a generator has placed skin.json, reference.png, and all referenced strips in a source directory, validate and package them:

node regenerate-skin-pack.mjs \
  --source ./source \
  --manifest skin.json \
  --spec "smol forge checklist.md" \
  --reference reference.png \
  --mode validate \
  --out ./dist

The script never calls a model, downloads a URL, follows symlinks, installs a package, repairs art, or overwrites an output directory. It copies only files that exist and pass its checks, writes a normalized skin.json, and records SHA-256 hashes in integrity.json.

Minimum authored inputs

  • one canonical full-body identity reference;
  • a complete skin.product.example.json-shaped action brief;
  • a separate generated source performance for every action and direction;
  • license and human credit metadata;
  • the pinned Gist revision and generation-tool details for the provenance receipt.

The official example deliberately uses SPDX NOASSERTION because this Gist does not grant character-art rights. Replace it with the actual SPDX expression or packaged LicenseRef-* terms before public registry publication; the sample packager treats unresolved terms as non-publishable.

Do not bake backgrounds, route-specific props, shadows, labels, status badges, speech bubbles, or navigation into sprite strips. Those belong to Forge's scene socket and remain replaceable independently of the character.

Compatibility

New packages use schemaVersion: 1 plus a required semantic skinVersion. Forge may still load older schema-v1 packages by applying the legacy defaults documented in the spec, but a regeneration or registry submission must emit the current fields explicitly.

official, trust, and registry identity are distribution facts. A package cannot grant itself official status through manifest data.

#!/usr/bin/env node
/**
* Dependency-free SmolForge skin-pack normalizer/scaffolder.
*
* This script does not generate, resize, or repair image assets. It validates
* the supplied manifest and every asset that already exists, then emits a
* deterministic dist plan. In scaffold mode, missing strips remain explicit
* plan entries; in validate mode, any missing asset fails the run.
*/
import { createHash } from 'node:crypto';
import {
constants as fsConstants,
copyFile,
lstat,
mkdir,
readFile,
stat,
writeFile,
} from 'node:fs/promises';
import { basename, dirname, extname, isAbsolute, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const PRODUCT_ACTIONS = [
'idle', 'walk', 'work', 'review', 'wait', 'fail', 'wave', 'celebrate',
];
const GAME_ACTIONS = [
'interact', 'sleep', 'talk', 'dance', 'attack-short', 'attack-long',
'war-cry', 'alert-low', 'alert-high',
];
const PLAYBACK = new Set(['once', 'loop', 'ping-pong', 'hold']);
const DIRECTIONS = ['left', 'right', 'ne', 'nw', 'se', 'sw'];
const SEMVER = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const FRAME = {
width: 192,
height: 208,
anchor: { x: 96, y: 192 },
baseline: 192,
safeBounds: { left: 18, top: 16, right: 174, bottom: 192 },
canonicalFacing: 'three-quarter-front-right',
};
const LIMITS = {
files: 256,
manifestBytes: 128 * 1024,
assetBytes: 8 * 1024 * 1024,
totalBytes: 64 * 1024 * 1024,
frameDurationMs: 60_000,
};
const HELP = `Usage:
node regenerate-skin-pack.mjs --dry-run [options]
node regenerate-skin-pack.mjs --out <new-directory> [options]
Options:
--source <dir> Input root (default: directory containing this script)
--manifest <path> Manifest relative to source (default: skin.product.example.json)
--spec <path> Markdown spec relative to source (default: smol forge checklist.md)
--schema <path> JSON Schema relative to source (default: skin.schema.json)
--scene <path> App-owned scene example relative to source
(default: scene-socket.example.json)
--reference <path> Existing reference/scene image relative to source
(default: thunder-smith-vnext-apron-helmet.png)
--mode <mode> "scaffold" allows missing strips; "validate" requires all
referenced assets (default: scaffold)
--out <dir> New output directory; must not already exist
--dry-run Print the deterministic plan without writing
--help, -h Show this help
Examples:
node regenerate-skin-pack.mjs --dry-run
node regenerate-skin-pack.mjs --out ./thunder-smith-dist-scaffold
node regenerate-skin-pack.mjs --source ./complete-pack --manifest skin.json \\
--reference reference.png --mode validate --out ../validated-dist
The script never synthesizes images, follows remote URLs, installs packages,
or overwrites an existing output directory.`;
class PackError extends Error {}
function parseArgs(argv) {
const options = {
source: SCRIPT_DIR,
manifest: 'skin.product.example.json',
spec: 'smol forge checklist.md',
schema: 'skin.schema.json',
scene: 'scene-socket.example.json',
reference: 'thunder-smith-vnext-apron-helmet.png',
mode: 'scaffold',
out: null,
dryRun: false,
help: false,
};
const valueOptions = new Set([
'source', 'manifest', 'spec', 'schema', 'scene', 'reference', 'mode', 'out',
]);
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === '--dry-run') {
options.dryRun = true;
continue;
}
if (token === '--help' || token === '-h') {
options.help = true;
continue;
}
if (!token.startsWith('--')) throw new PackError(`Unexpected argument: ${token}`);
const [rawName, inlineValue] = token.slice(2).split('=', 2);
if (!valueOptions.has(rawName)) throw new PackError(`Unknown option: --${rawName}`);
const value = inlineValue ?? argv[index + 1];
if (!value || value.startsWith('--')) throw new PackError(`--${rawName} needs a value`);
if (inlineValue === undefined) index += 1;
options[rawName] = value;
}
if (!['scaffold', 'validate'].includes(options.mode)) {
throw new PackError('--mode must be "scaffold" or "validate"');
}
if (!options.help && !options.dryRun && !options.out) {
throw new PackError('--out is required unless --dry-run is used');
}
return options;
}
function stableJson(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function requireText(record, field, label, maxLength) {
const value = record[field];
if (typeof value !== 'string' || !value.trim() || value.length > maxLength) {
throw new PackError(`${label} must be between 1 and ${maxLength} characters`);
}
return value.trim();
}
function optionalHttps(value, label) {
if (value === undefined) return undefined;
if (typeof value !== 'string' || value.length > 2048 || !value.startsWith('https://')) {
throw new PackError(`${label} must be an HTTPS URL`);
}
return value;
}
function normalizeCredits(raw) {
if (raw === undefined) return [];
if (!Array.isArray(raw) || raw.length < 1 || raw.length > 32) {
throw new PackError('credits must contain 1 through 32 entries');
}
return raw.map((credit, index) => {
if (!isRecord(credit)) throw new PackError(`credits[${index}] must be an object`);
return {
name: requireText(credit, 'name', `credits[${index}].name`, 80),
...(credit.role ? {
role: requireText(credit, 'role', `credits[${index}].role`, 80),
} : {}),
...(credit.url ? { url: optionalHttps(credit.url, `credits[${index}].url`) } : {}),
};
});
}
function normalizeLicense(raw) {
if (raw === undefined) return undefined;
if (!isRecord(raw)) throw new PackError('license must be an object');
return {
spdx: requireText(raw, 'spdx', 'license.spdx', 160),
...(raw.name ? { name: requireText(raw, 'name', 'license.name', 80) } : {}),
...(raw.url ? { url: optionalHttps(raw.url, 'license.url') } : {}),
};
}
function normalizeProvenance(raw) {
if (raw === undefined) return undefined;
if (!isRecord(raw)) throw new PackError('provenance must be an object');
let generators;
if (raw.generators !== undefined) {
if (
!Array.isArray(raw.generators)
|| raw.generators.length > 32
|| raw.generators.some((value) => typeof value !== 'string' || !value.trim() || value.length > 160)
) {
throw new PackError('provenance.generators must contain up to 32 short names');
}
generators = raw.generators.map((value) => value.trim());
}
return {
...(raw.sourceUrl ? { sourceUrl: optionalHttps(raw.sourceUrl, 'provenance.sourceUrl') } : {}),
...(raw.sourceRevision ? {
sourceRevision: requireText(raw, 'sourceRevision', 'provenance.sourceRevision', 128),
} : {}),
...(generators ? { generators } : {}),
};
}
function normalizeExtensions(raw) {
if (raw === undefined) return undefined;
if (!isRecord(raw)) throw new PackError('extensions must be an object');
const keyPattern = /^[a-z0-9](?:[a-z0-9-]*\.)+[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$/;
for (const key of Object.keys(raw)) {
if (!keyPattern.test(key)) throw new PackError(`Invalid extension key: ${key}`);
}
return structuredClone(raw);
}
function safeRelativePath(value, label) {
if (typeof value !== 'string') throw new PackError(`${label} must be a path`);
const normalized = value.replaceAll('\\', '/').replace(/^\.\/+/, '');
const parts = normalized.split('/');
if (
!normalized
|| isAbsolute(normalized)
|| /^[a-z][a-z0-9+.-]*:/i.test(normalized)
|| parts.some((part) => !part || part === '.' || part === '..')
|| /[\0-\x1f\x7f]/.test(normalized)
) {
throw new PackError(`${label} is not a safe package-relative path: ${value}`);
}
return normalized;
}
function pathInside(root, relativePath, label) {
const target = resolve(root, relativePath);
const fromRoot = relative(root, target);
if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
if (!fromRoot) return target;
throw new PackError(`${label} leaves the source root`);
}
return target;
}
function normalizeFrame(raw) {
const valid = isRecord(raw)
&& raw.width === FRAME.width
&& raw.height === FRAME.height
&& isRecord(raw.anchor)
&& raw.anchor.x === FRAME.anchor.x
&& raw.anchor.y === FRAME.anchor.y
&& raw.baseline === FRAME.baseline
&& isRecord(raw.safeBounds)
&& raw.safeBounds.left === FRAME.safeBounds.left
&& raw.safeBounds.top === FRAME.safeBounds.top
&& raw.safeBounds.right === FRAME.safeBounds.right
&& raw.safeBounds.bottom === FRAME.safeBounds.bottom
&& raw.canonicalFacing === FRAME.canonicalFacing;
if (!valid) {
throw new PackError('frame must exactly match the SmolForge 192 x 208 placement contract');
}
return FRAME;
}
function normalizeClip(raw, family, profile) {
if (!isRecord(raw)) throw new PackError(`${family} contains an invalid clip`);
const frames = raw.frames;
if (!Number.isInteger(frames) || frames < 1 || frames > 8) {
throw new PackError(`${family} frames must be an integer from 1 through 8`);
}
if (
!Number.isInteger(raw.frameDurationMs)
|| raw.frameDurationMs <= 0
|| raw.frameDurationMs > LIMITS.frameDurationMs
) {
throw new PackError(`${family} has an invalid frameDurationMs`);
}
const familyPattern = family.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (typeof raw.id !== 'string' || !new RegExp(`^${familyPattern}\\.v(?:0[1-9]|10)$`).test(raw.id)) {
throw new PackError(`${family} clip IDs must use ${family}.v01 through ${family}.v10`);
}
if (!PLAYBACK.has(raw.playback)) throw new PackError(`${raw.id} has unsupported playback`);
if (
raw.frameDurationsMs !== undefined
&& (
!Array.isArray(raw.frameDurationsMs)
|| raw.frameDurationsMs.length !== frames
|| raw.frameDurationsMs.some((duration) => (
!Number.isInteger(duration) || duration <= 0 || duration > LIMITS.frameDurationMs
))
)
) {
throw new PackError(`${raw.id} needs one valid duration per frame`);
}
const clip = {
id: raw.id,
name: requireText(raw, 'name', `${raw.id} name`, 80),
description: requireText(raw, 'description', `${raw.id} description`, 500),
whenToUse: requireText(raw, 'whenToUse', `${raw.id} whenToUse`, 500),
frames,
frameDurationMs: raw.frameDurationMs,
...(raw.frameDurationsMs ? { frameDurationsMs: [...raw.frameDurationsMs] } : {}),
playback: raw.playback,
posterFrame: raw.posterFrame ?? 0,
};
if (!Number.isInteger(clip.posterFrame) || clip.posterFrame < 0 || clip.posterFrame >= frames) {
throw new PackError(`${raw.id} posterFrame must point to an existing frame`);
}
if (raw.cues !== undefined) {
if (!Array.isArray(raw.cues) || raw.cues.length > 32) {
throw new PackError(`${raw.id} cues must be an array of at most 32 entries`);
}
const cueIds = new Set();
clip.cues = raw.cues.map((cue, index) => {
if (!isRecord(cue)) throw new PackError(`${raw.id} cue ${index} must be an object`);
const id = requireText(cue, 'id', `${raw.id} cue ${index} id`, 64);
if (!/^[a-z][a-z0-9-]*$/.test(id)) throw new PackError(`${raw.id} has an invalid cue ID: ${id}`);
if (cueIds.has(id)) throw new PackError(`${raw.id} has duplicate cue ID: ${id}`);
cueIds.add(id);
if (!Number.isInteger(cue.frame) || cue.frame < 0 || cue.frame >= frames) {
throw new PackError(`${raw.id} cue ${id} must point to an existing frame`);
}
return {
id,
frame: cue.frame,
...(cue.description ? {
description: requireText(cue, 'description', `${raw.id} cue ${id} description`, 240),
} : {}),
};
});
}
if (raw.mirrorSafe !== undefined && typeof raw.mirrorSafe !== 'boolean') {
throw new PackError(`${raw.id} mirrorSafe must be boolean`);
}
clip.mirrorSafe = raw.mirrorSafe ?? false;
if (raw.strips !== undefined) {
if (!isRecord(raw.strips) || raw.strip !== undefined) {
throw new PackError(`${raw.id} must use exactly one of strip or strips`);
}
const keys = Object.keys(raw.strips);
if (!keys.length || keys.some((direction) => !DIRECTIONS.includes(direction))) {
throw new PackError(`${raw.id} has invalid directional strips`);
}
const requiredDirections = profile === 'game' ? ['ne', 'nw', 'se', 'sw'] : ['left', 'right'];
if (family === 'walk' && requiredDirections.some((direction) => !keys.includes(direction))) {
throw new PackError(`${raw.id} must provide: ${requiredDirections.join(', ')}`);
}
clip.strips = Object.fromEntries(
DIRECTIONS.filter((direction) => keys.includes(direction)).map((direction) => [
direction,
safeRelativePath(raw.strips[direction], `${raw.id}.${direction}`),
]),
);
} else {
if (family === 'walk') throw new PackError('walk clips must use directional strips');
clip.strip = safeRelativePath(raw.strip, `${raw.id}.strip`);
}
return clip;
}
function normalizeManifest(raw, spec) {
if (!isRecord(raw)) throw new PackError('Manifest must contain a JSON object');
if (raw.schemaVersion !== 1) throw new PackError('Only schemaVersion 1 is supported');
const profile = raw.profile ?? 'game';
if (!['product', 'game'].includes(profile)) throw new PackError('profile must be product or game');
const warnings = [];
const publicationIssues = [];
if (raw.profile === undefined) warnings.push('Legacy schema v1 manifest inferred as the complete game profile.');
const skinVersion = raw.skinVersion ?? '0.0.0-legacy';
if (!SEMVER.test(skinVersion)) throw new PackError('skinVersion must be valid semantic versioning');
if (raw.skinVersion === undefined) {
warnings.push('Missing skinVersion was normalized to 0.0.0-legacy; registry publication is not allowed.');
publicationIssues.push('skinVersion is required for a publishable package');
}
if (!Array.isArray(raw.credits) || raw.credits.length === 0) {
publicationIssues.push('at least one credit is required for a publishable package');
}
if (raw.license === undefined) {
publicationIssues.push('license metadata is required for a publishable package');
} else if (['NOASSERTION', 'NONE'].includes(raw.license?.spdx)) {
publicationIssues.push('license.spdx must be resolved before registry publication');
}
if (typeof raw.id !== 'string' || !/^[a-z0-9][a-z0-9-]{0,63}$/.test(raw.id)) {
throw new PackError('id must use lowercase letters, numbers, and dashes');
}
if (!isRecord(raw.actions)) throw new PackError('actions must contain an object');
const requiredActionOrder = profile === 'game'
? [...PRODUCT_ACTIONS, ...GAME_ACTIONS]
: PRODUCT_ACTIONS;
const actionOrder = profile === 'product'
? [...PRODUCT_ACTIONS, ...GAME_ACTIONS.filter((family) => raw.actions[family] !== undefined)]
: requiredActionOrder;
const actions = {};
for (const family of actionOrder) {
const variants = raw.actions[family];
if (!Array.isArray(variants) || variants.length < 1 || variants.length > 10) {
throw new PackError(`${profile} manifests need 1 through 10 ${family} variants`);
}
actions[family] = variants.map((clip) => normalizeClip(clip, family, profile));
if (!actions[family].some((clip) => clip.id === `${family}.v01`)) {
throw new PackError(`${family} must include ${family}.v01`);
}
if (new Set(actions[family].map((clip) => clip.id)).size !== actions[family].length) {
throw new PackError(`${family} contains duplicate clip IDs`);
}
}
const ignoredActions = Object.keys(raw.actions)
.filter((name) => ![...PRODUCT_ACTIONS, ...GAME_ACTIONS].includes(name));
if (ignoredActions.length) warnings.push(`Unrecognized actions were omitted: ${ignoredActions.sort().join(', ')}.`);
const knownTopLevel = new Set([
'schemaVersion', 'skinVersion', 'profile', 'id', 'name', 'description',
'reference', 'credits', 'license', 'provenance', 'frame', 'actions',
'customActions', 'extensions',
]);
const ignoredFields = Object.keys(raw).filter((field) => !knownTopLevel.has(field));
if (ignoredFields.length) warnings.push(`Unknown manifest fields were omitted: ${ignoredFields.sort().join(', ')}.`);
if (
spec.actions[profile].length !== requiredActionOrder.length
|| spec.actions[profile].some((action) => !requiredActionOrder.includes(action))
) {
throw new PackError(`The parsed ${profile} action table disagrees with this script's contract`);
}
let customActions = [];
if (raw.customActions !== undefined) {
if (!Array.isArray(raw.customActions) || raw.customActions.length > 4) {
throw new PackError('customActions must contain at most four families');
}
const ids = new Set();
customActions = raw.customActions.map((family, index) => {
if (!isRecord(family)) throw new PackError(`customActions[${index}] must be an object`);
const id = requireText(family, 'id', `customActions[${index}].id`, 16);
if (!/^custom-0[1-4]$/.test(id)) throw new PackError(`Invalid custom action ID: ${id}`);
if (ids.has(id)) throw new PackError(`Duplicate custom action ID: ${id}`);
ids.add(id);
if (!Array.isArray(family.variants) || family.variants.length < 1 || family.variants.length > 10) {
throw new PackError(`${id} must contain 1 through 10 variants`);
}
const variants = family.variants.map((variant) => normalizeClip(variant, id, profile));
if (!variants.some((variant) => variant.id === `${id}.v01`)) {
throw new PackError(`${id} must include ${id}.v01`);
}
if (new Set(variants.map((variant) => variant.id)).size !== variants.length) {
throw new PackError(`${id} contains duplicate clip IDs`);
}
return {
id,
name: requireText(family, 'name', `${id}.name`, 80),
description: requireText(family, 'description', `${id}.description`, 500),
whenToUse: requireText(family, 'whenToUse', `${id}.whenToUse`, 500),
variants,
};
});
}
return {
manifest: {
schemaVersion: 1,
skinVersion,
profile,
id: raw.id,
name: requireText(raw, 'name', 'name', 80),
description: requireText(raw, 'description', 'description', 500),
...(raw.reference ? { reference: safeRelativePath(raw.reference, 'reference') } : {}),
credits: normalizeCredits(raw.credits),
...(raw.license ? { license: normalizeLicense(raw.license) } : {}),
...(raw.provenance ? { provenance: normalizeProvenance(raw.provenance) } : {}),
frame: normalizeFrame(raw.frame),
actions,
customActions,
...(raw.extensions ? { extensions: normalizeExtensions(raw.extensions) } : {}),
},
warnings,
publicationIssues,
};
}
function parseSpec(markdown) {
const status = markdown.match(/^Status:\s*(.+)$/m)?.[1]?.trim();
if (!status) throw new PackError('Spec markdown is missing a Status line');
const version = status.match(/\bv(\d+\.\d+(?:\.\d+)?)\b/)?.[1];
if (!version) throw new PackError('Spec status does not contain a version');
const extractTableActions = (heading, nextHeading) => {
const start = markdown.indexOf(`### ${heading}`);
const end = nextHeading ? markdown.indexOf(`### ${nextHeading}`, start + 1) : markdown.length;
if (start < 0 || end < 0) throw new PackError(`Spec is missing the ${heading} action table`);
return [...markdown.slice(start, end).matchAll(/^\|\s*`([^`]+)`\s*\|/gm)]
.map((match) => match[1]);
};
const product = extractTableActions('Product', 'Game');
const gameOnly = extractTableActions('Game', null);
const image = markdown.match(/!\[([^\]]+)\]\((https?:\/\/[^)]+)\)/);
return {
status,
version,
actions: { product, game: [...product, ...gameOnly] },
documentedScene: image ? { alt: image[1], url: image[2] } : null,
};
}
function parseImageMetadata(bytes, filePath) {
const extension = extname(filePath).toLowerCase();
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
if (bytes.subarray(0, 8).equals(pngSignature)) {
if (extension !== '.png') throw new PackError(`${filePath} contains PNG bytes but has the wrong extension`);
if (bytes.length < 33 || bytes.toString('ascii', 12, 16) !== 'IHDR') {
throw new PackError(`${filePath} has an invalid PNG header`);
}
const colorType = bytes.readUInt8(25);
return {
format: 'png',
width: bytes.readUInt32BE(16),
height: bytes.readUInt32BE(20),
bitDepth: bytes.readUInt8(24),
colorType,
hasAlphaChannel: colorType === 4 || colorType === 6 || bytes.includes(Buffer.from('tRNS')),
};
}
if (
bytes.length >= 30
&& bytes.toString('ascii', 0, 4) === 'RIFF'
&& bytes.toString('ascii', 8, 12) === 'WEBP'
) {
if (extension !== '.webp') throw new PackError(`${filePath} contains WebP bytes but has the wrong extension`);
const chunk = bytes.toString('ascii', 12, 16);
let width;
let height;
if (chunk === 'VP8X') {
width = 1 + bytes.readUIntLE(24, 3);
height = 1 + bytes.readUIntLE(27, 3);
} else if (chunk === 'VP8L' && bytes.readUInt8(20) === 0x2f) {
const bits = bytes.readUInt32LE(21);
width = 1 + (bits & 0x3fff);
height = 1 + ((bits >>> 14) & 0x3fff);
} else if (chunk === 'VP8 ' && bytes.subarray(23, 26).equals(Buffer.from([0x9d, 0x01, 0x2a]))) {
width = bytes.readUInt16LE(26) & 0x3fff;
height = bytes.readUInt16LE(28) & 0x3fff;
}
if (!width || !height) throw new PackError(`${filePath} has an unsupported or invalid WebP header`);
const hasAlphaChannel = chunk === 'VP8X'
? (bytes.readUInt8(20) & 0x10) !== 0
: chunk === 'VP8L';
return { format: 'webp', width, height, hasAlphaChannel };
}
throw new PackError(`${filePath} is not a readable PNG or WebP image`);
}
async function readRegularFile(path, label) {
const info = await lstat(path).catch((error) => {
if (error.code === 'ENOENT') return null;
throw error;
});
if (!info) return null;
if (info.isSymbolicLink() || !info.isFile()) throw new PackError(`${label} must be a regular file`);
return { bytes: await readFile(path), size: info.size };
}
function assetReferences(manifest) {
const assets = [];
const families = [
...Object.entries(manifest.actions),
...manifest.customActions.map((family) => [family.id, family.variants]),
];
for (const [family, variants] of families) {
for (const clip of variants) {
if (clip.strip) {
assets.push({ family, clip: clip.id, distPath: clip.strip, frames: clip.frames });
}
for (const [direction, path] of Object.entries(clip.strips ?? {})) {
assets.push({ family, clip: clip.id, direction, distPath: path, frames: clip.frames });
}
}
}
return assets.sort((left, right) => left.distPath.localeCompare(right.distPath));
}
async function inspectAsset(sourceRoot, sourceRelative, distPath, details = {}) {
if (extname(sourceRelative).toLowerCase() !== extname(distPath).toLowerCase()) {
throw new PackError(`${sourceRelative} and ${distPath} must use the same image format`);
}
const sourcePath = pathInside(sourceRoot, sourceRelative, sourceRelative);
const file = await readRegularFile(sourcePath, sourceRelative);
if (!file) return { ...details, sourcePath: sourceRelative, distPath, exists: false };
if (file.size > LIMITS.assetBytes) throw new PackError(`${sourceRelative} is larger than 8 MiB`);
const image = parseImageMetadata(file.bytes, sourceRelative);
if (
details.frames
&& (image.width !== details.frames * FRAME.width || image.height !== FRAME.height)
) {
throw new PackError(
`${sourceRelative} must be ${details.frames * FRAME.width} x ${FRAME.height}, `
+ `not ${image.width} x ${image.height}`,
);
}
if (details.kind === 'strip' && !image.hasAlphaChannel) {
throw new PackError(`${sourceRelative} must contain an alpha channel`);
}
return {
...details,
sourcePath: sourceRelative,
distPath,
exists: true,
bytes: file.size,
sha256: sha256(file.bytes),
image,
};
}
async function assertFreshOutput(outputPath) {
const absolute = resolve(outputPath);
if (absolute === resolve('/') || basename(absolute) === '') throw new PackError('Refusing an unsafe output path');
const existing = await lstat(absolute).catch((error) => {
if (error.code === 'ENOENT') return null;
throw error;
});
if (existing) throw new PackError(`Output already exists; refusing to overwrite: ${absolute}`);
const parent = await stat(dirname(absolute)).catch(() => null);
if (!parent?.isDirectory()) throw new PackError('The output parent directory must already exist');
return absolute;
}
async function buildPlan(options) {
const sourceRoot = resolve(options.source);
const sourceInfo = await stat(sourceRoot).catch(() => null);
if (!sourceInfo?.isDirectory()) throw new PackError(`Source is not a directory: ${sourceRoot}`);
const manifestRelative = safeRelativePath(options.manifest, 'manifest');
const specRelative = safeRelativePath(options.spec, 'spec');
const schemaRelative = safeRelativePath(options.schema, 'schema');
const sceneRelative = safeRelativePath(options.scene, 'scene');
const referenceRelative = safeRelativePath(options.reference, 'reference source');
const manifestInput = await readRegularFile(pathInside(sourceRoot, manifestRelative, 'manifest'), 'manifest');
const specInput = await readRegularFile(pathInside(sourceRoot, specRelative, 'spec'), 'spec');
const schemaInput = await readRegularFile(pathInside(sourceRoot, schemaRelative, 'schema'), 'schema');
const sceneInput = await readRegularFile(pathInside(sourceRoot, sceneRelative, 'scene'), 'scene');
if (!manifestInput) throw new PackError(`Missing manifest: ${manifestRelative}`);
if (!specInput) throw new PackError(`Missing spec: ${specRelative}`);
if (!schemaInput) throw new PackError(`Missing schema: ${schemaRelative}`);
if (!sceneInput) throw new PackError(`Missing scene example: ${sceneRelative}`);
if (manifestInput.size > LIMITS.manifestBytes) throw new PackError('Manifest is larger than 128 KiB');
let rawManifest;
let rawSchema;
let rawScene;
try {
rawManifest = JSON.parse(manifestInput.bytes.toString('utf8'));
rawSchema = JSON.parse(schemaInput.bytes.toString('utf8'));
rawScene = JSON.parse(sceneInput.bytes.toString('utf8'));
} catch {
throw new PackError('Manifest, schema, and scene example must be valid JSON');
}
if (rawSchema.$id !== 'urn:smolforge:schema:character-skin:1') {
throw new PackError('Schema uses an unexpected canonical $id');
}
if (rawScene.schemaVersion !== 1 || typeof rawScene.id !== 'string') {
throw new PackError('Scene example uses an unexpected contract');
}
const spec = parseSpec(specInput.bytes.toString('utf8'));
const normalized = normalizeManifest(rawManifest, spec);
const normalizedBytes = Buffer.from(stableJson(normalized.manifest));
const files = [];
if (normalized.manifest.reference) {
files.push(await inspectAsset(
sourceRoot,
referenceRelative,
normalized.manifest.reference,
{ kind: 'reference' },
));
}
for (const asset of assetReferences(normalized.manifest)) {
const assetSourceRelative = relative(
sourceRoot,
resolve(sourceRoot, dirname(manifestRelative), asset.distPath),
);
files.push(await inspectAsset(
sourceRoot,
safeRelativePath(assetSourceRelative, `${asset.distPath} source`),
asset.distPath,
{ kind: 'strip', family: asset.family, clip: asset.clip, direction: asset.direction, frames: asset.frames },
));
}
files.sort((left, right) => left.distPath.localeCompare(right.distPath));
if (files.length + 1 > LIMITS.files) throw new PackError(`Package exceeds ${LIMITS.files} files`);
const totalExistingBytes = files.reduce((sum, file) => sum + (file.bytes ?? 0), normalizedBytes.length);
if (totalExistingBytes > LIMITS.totalBytes) throw new PackError('Package exceeds 64 MiB');
const missing = files.filter((file) => !file.exists).map((file) => file.distPath);
const publicationIssues = normalized.publicationIssues;
const reference = files.find((file) => file.kind === 'reference');
return {
planVersion: 1,
generator: 'regenerate-skin-pack.mjs',
generatorVersion: 1,
truthfulBoundary: 'Validates and copies existing files only; does not generate or transform images.',
mode: options.mode,
complete: missing.length === 0 && publicationIssues.length === 0,
contract: {
schemaVersion: normalized.manifest.schemaVersion,
specStatus: spec.status,
specVersion: spec.version,
profile: normalized.manifest.profile,
requiredActions: spec.actions[normalized.manifest.profile],
},
package: {
id: normalized.manifest.id,
name: normalized.manifest.name,
normalizedManifestSha256: sha256(normalizedBytes),
},
inputs: [
{ kind: 'manifest-sample', path: manifestRelative, bytes: manifestInput.size, sha256: sha256(manifestInput.bytes) },
{ kind: 'spec', path: specRelative, bytes: specInput.size, sha256: sha256(specInput.bytes) },
{ kind: 'schema', path: schemaRelative, bytes: schemaInput.size, sha256: sha256(schemaInput.bytes) },
{ kind: 'scene-example', path: sceneRelative, bytes: sceneInput.size, sha256: sha256(sceneInput.bytes) },
],
sceneMetadata: {
documentedScene: spec.documentedScene,
localReference: reference
? {
sourcePath: reference.sourcePath,
distPath: reference.distPath,
exists: reference.exists,
bytes: reference.bytes,
sha256: reference.sha256,
image: reference.image,
}
: null,
},
files: [
{
kind: 'manifest',
sourcePath: manifestRelative,
distPath: 'skin.json',
exists: true,
bytes: normalizedBytes.length,
sha256: sha256(normalizedBytes),
normalized: true,
},
...files,
],
missing,
publicationIssues,
warnings: [
...normalized.warnings,
...(missing.length
? [`${missing.length} referenced asset${missing.length === 1 ? ' is' : 's are'} missing; scaffold remains incomplete.`]
: []),
...publicationIssues,
],
normalizedManifest: normalized.manifest,
};
}
async function writeScaffold(outputPath, sourceRoot, plan) {
await mkdir(outputPath);
await writeFile(resolve(outputPath, 'skin.json'), stableJson(plan.normalizedManifest), { flag: 'wx' });
for (const file of plan.files) {
if (file.kind === 'manifest' || !file.exists) continue;
const destination = resolve(outputPath, file.distPath);
await mkdir(dirname(destination), { recursive: true });
await copyFile(resolve(sourceRoot, file.sourcePath), destination, fsConstants.COPYFILE_EXCL);
}
const planForDisk = { ...plan };
delete planForDisk.normalizedManifest;
await writeFile(resolve(outputPath, 'integrity.json'), stableJson(planForDisk), { flag: 'wx' });
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
console.log(HELP);
return;
}
options.source = resolve(options.source);
const plan = await buildPlan(options);
if (options.dryRun) {
console.log(stableJson(plan).trimEnd());
if (options.mode === 'validate' && !plan.complete) process.exitCode = 2;
return;
}
if (options.mode === 'validate' && !plan.complete) {
const reasons = [
...(plan.missing.length ? [`missing: ${plan.missing.join(', ')}`] : []),
...plan.publicationIssues,
];
throw new PackError(`Validation failed; ${reasons.join('; ')}`);
}
const outputPath = await assertFreshOutput(options.out);
await writeScaffold(outputPath, options.source, plan);
console.log(
`${plan.complete ? 'Validated dist' : 'Incomplete scaffold'} written to ${outputPath}\n`
+ `Manifest: ${plan.package.normalizedManifestSha256}\n`
+ `Missing assets: ${plan.missing.length}`,
);
}
main().catch((error) => {
console.error(`regenerate-skin-pack: ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
});
{
"schemaVersion": 1,
"id": "actions-empty-workbench",
"description": "App-owned scene composition for an empty Actions page. This is not part of the skin package.",
"socket": {
"name": "empty-state-companion",
"placement": "inline",
"widthPx": 320,
"heightPx": 220,
"anchor": "bottom-left",
"overflow": "visible"
},
"character": {
"action": "wait",
"variant": "wait.v01",
"direction": "right",
"reducedMotion": "posterFrame",
"ariaLabel": "Thunder Smith waits beside the idle workflow machine."
},
"appLayers": [
{
"id": "workflow-machine",
"kind": "decorative-prop",
"z": 10
},
{
"id": "character",
"kind": "skin",
"z": 20
},
{
"id": "status-copy",
"kind": "content",
"z": 30
}
],
"responsive": {
"compact": {
"widthPx": 176,
"hideDecorativeProps": true
}
},
"ownership": {
"skinOwns": [
"character pixels",
"clip timing",
"poster frame",
"semantic cues"
],
"appOwns": [
"route selection",
"placement",
"scale",
"props",
"copy",
"status",
"accessibility",
"responsive behavior"
]
}
}
{
"schemaVersion": 1,
"skinVersion": "1.0.0",
"profile": "product",
"id": "thunder-smith",
"name": "Thunder Smith",
"description": "SmolForge's tiny S-badged clay workshop familiar.",
"reference": "reference.png",
"credits": [
{
"name": "SmolForge",
"role": "Character direction and curation",
"url": "https://forge.smol.ai"
}
],
"license": {
"spdx": "NOASSERTION",
"name": "Resolve official character asset terms before registry publication"
},
"frame": {
"width": 192,
"height": 208,
"anchor": {
"x": 96,
"y": 192
},
"baseline": 192,
"safeBounds": {
"left": 18,
"top": 16,
"right": 174,
"bottom": 192
},
"canonicalFacing": "three-quarter-front-right"
},
"actions": {
"idle": [
{
"id": "idle.v01",
"name": "Workshop idle",
"description": "A calm breathing and blinking loop.",
"whenToUse": "Use whenever no more specific state applies.",
"frames": 6,
"frameDurationMs": 160,
"frameDurationsMs": [
280,
110,
110,
140,
140,
320
],
"playback": "loop",
"posterFrame": 0,
"cues": [
{
"id": "blink",
"frame": 2,
"description": "A safe moment for a subtle UI acknowledgement."
}
],
"strip": "strips/idle.v01.png"
}
],
"wave": [
{
"id": "wave.v01",
"name": "Workshop wave",
"description": "A friendly raised-hand greeting and return.",
"whenToUse": "Use for greetings or friendly attention.",
"frames": 4,
"frameDurationMs": 140,
"frameDurationsMs": [
140,
140,
140,
280
],
"playback": "once",
"posterFrame": 0,
"cues": [
{
"id": "greeting-apex",
"frame": 2
}
],
"strip": "strips/wave.v01.png"
}
],
"celebrate": [
{
"id": "celebrate.v01",
"name": "Clay jump",
"description": "An anticipation, lift, peak, descent, and settle sequence.",
"whenToUse": "Use after a success, merge, publish, or deploy.",
"frames": 5,
"frameDurationMs": 140,
"frameDurationsMs": [
140,
140,
140,
140,
280
],
"playback": "once",
"posterFrame": 4,
"cues": [
{
"id": "airborne-apex",
"frame": 2
},
{
"id": "landed",
"frame": 4
}
],
"strip": "strips/celebrate.v01.png"
}
],
"fail": [
{
"id": "fail.v01",
"name": "Forge failure",
"description": "A readable deflation after something has failed.",
"whenToUse": "Use after a build, action, or operation fails.",
"frames": 8,
"frameDurationMs": 140,
"frameDurationsMs": [
140,
140,
140,
140,
140,
140,
140,
240
],
"playback": "hold",
"posterFrame": 7,
"cues": [
{
"id": "reaction",
"frame": 1
},
{
"id": "settled",
"frame": 7
}
],
"strip": "strips/fail.v01.png"
}
],
"wait": [
{
"id": "wait.v01",
"name": "Pocket-feed wait",
"description": "He checks a tiny phone from his apron, scrolls the feed, then pockets it.",
"whenToUse": "Use while awaiting approval, help, or user input.",
"frames": 8,
"frameDurationMs": 220,
"frameDurationsMs": [
300,
160,
180,
220,
420,
420,
240,
460
],
"playback": "loop",
"posterFrame": 0,
"cues": [
{
"id": "phone-visible",
"frame": 2
},
{
"id": "phone-pocketed",
"frame": 7
}
],
"strip": "strips/wait.v01.png"
}
],
"work": [
{
"id": "work.v01",
"name": "Active work",
"description": "Focused processing and busy-hand motion without walking.",
"whenToUse": "Use while building, forging, processing, or operating.",
"frames": 6,
"frameDurationMs": 120,
"frameDurationsMs": [
120,
120,
120,
120,
120,
220
],
"playback": "loop",
"posterFrame": 0,
"cues": [
{
"id": "impact",
"frame": 2,
"description": "Cosmetic impact beat; never defines workflow or game timing."
}
],
"strip": "strips/work.v01.png"
}
],
"review": [
{
"id": "review.v01",
"name": "Attentive review",
"description": "Inspection through gaze, lean, blink, and head tilt.",
"whenToUse": "Use while reading, thinking, inspecting, or reviewing.",
"frames": 6,
"frameDurationMs": 150,
"frameDurationsMs": [
150,
150,
150,
150,
150,
280
],
"playback": "loop",
"posterFrame": 0,
"cues": [
{
"id": "inspection-focus",
"frame": 3
}
],
"strip": "strips/review.v01.png"
}
],
"walk": [
{
"id": "walk.v01",
"name": "Determined walk",
"description": "A compact, purposeful walk across product UI.",
"whenToUse": "Use whenever the character moves between interface regions.",
"frames": 8,
"frameDurationMs": 120,
"frameDurationsMs": [
120,
120,
120,
120,
120,
120,
120,
220
],
"playback": "loop",
"posterFrame": 0,
"mirrorSafe": false,
"cues": [
{
"id": "left-contact",
"frame": 0
},
{
"id": "right-contact",
"frame": 4
}
],
"strips": {
"left": "strips/walk.v01.left.png",
"right": "strips/walk.v01.right.png"
}
}
]
},
"customActions": []
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "urn:smolforge:schema:character-skin:1",
"title": "SmolForge Character Skin Manifest",
"description": "Schema for an additive SmolForge character skin manifest. Unknown core fields are rejected; opaque metadata uses one namespaced extensions map.",
"type": "object",
"required": [
"schemaVersion",
"skinVersion",
"profile",
"id",
"name",
"description",
"credits",
"license",
"frame",
"actions"
],
"properties": {
"schemaVersion": {
"const": 1,
"description": "Version of the SmolForge manifest schema."
},
"skinVersion": {
"$ref": "#/$defs/semver"
},
"profile": {
"enum": [
"product",
"game"
]
},
"id": {
"$ref": "#/$defs/identifier"
},
"name": {
"$ref": "#/$defs/name"
},
"description": {
"$ref": "#/$defs/description"
},
"reference": {
"$ref": "#/$defs/assetPath",
"description": "Optional canonical character reference image."
},
"credits": {
"type": "array",
"description": "People or organizations that should be credited for this skin.",
"minItems": 1,
"maxItems": 32,
"items": {
"$ref": "#/$defs/credit"
}
},
"license": {
"$ref": "#/$defs/license"
},
"provenance": {
"$ref": "#/$defs/provenance"
},
"frame": {
"$ref": "#/$defs/frame"
},
"actions": {
"$ref": "#/$defs/actions"
},
"customActions": {
"type": "array",
"maxItems": 4,
"items": {
"$ref": "#/$defs/customActionFamily"
}
},
"extensions": {
"$ref": "#/$defs/extensions"
}
},
"additionalProperties": false,
"allOf": [
{
"if": {
"properties": {
"profile": {
"const": "product"
}
},
"required": [
"profile"
]
},
"then": {
"properties": {
"actions": {
"type": "object",
"required": [
"idle",
"walk",
"work",
"review",
"wait",
"fail",
"wave",
"celebrate"
],
"properties": {
"walk": {
"$ref": "#/$defs/productWalkVariants"
}
}
}
}
}
},
{
"if": {
"properties": {
"profile": {
"const": "game"
}
},
"required": [
"profile"
]
},
"then": {
"properties": {
"actions": {
"type": "object",
"required": [
"idle",
"walk",
"work",
"review",
"wait",
"fail",
"wave",
"celebrate",
"interact",
"sleep",
"talk",
"dance",
"attack-short",
"attack-long",
"war-cry",
"alert-low",
"alert-high"
],
"properties": {
"walk": {
"$ref": "#/$defs/gameWalkVariants"
}
}
}
}
}
}
],
"$defs": {
"semver": {
"type": "string",
"description": "Semantic version of this skin's content.",
"pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-((?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$",
"examples": [
"1.0.0",
"1.2.0-beta.1",
"2.0.1+build.7"
]
},
"identifier": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$"
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"assetPath": {
"type": "string",
"description": "A package-relative PNG or WebP path. Absolute URLs and parent traversal are not allowed.",
"minLength": 5,
"maxLength": 512,
"pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?![A-Za-z][A-Za-z0-9+.-]*:)(?!.*\\\\)[^\\x00]+\\.(?:png|webp)$"
},
"extensionValue": {
"description": "Opaque vendor-defined JSON data. It cannot override core semantics or introduce runtime asset references.",
"anyOf": [
{
"type": "array"
},
{
"type": "boolean"
},
{
"type": "null"
},
{
"type": "number"
},
{
"type": "object"
},
{
"type": "string"
}
]
},
"extensions": {
"type": "object",
"description": "Namespaced, opaque metadata. Keys use a reverse-DNS owner plus a field name.",
"propertyNames": {
"pattern": "^[a-z0-9](?:[a-z0-9-]*\\.)+[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*$"
},
"additionalProperties": {
"$ref": "#/$defs/extensionValue"
}
},
"credit": {
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"$ref": "#/$defs/name"
},
"role": {
"type": "string",
"minLength": 1,
"maxLength": 80
},
"url": {
"type": "string",
"pattern": "^https://",
"maxLength": 2048
}
},
"additionalProperties": false
},
"license": {
"type": "object",
"description": "Declared usage rights. Registry trust and official status are verified outside the package.",
"required": [
"spdx"
],
"properties": {
"spdx": {
"type": "string",
"minLength": 3,
"maxLength": 160,
"description": "An SPDX identifier/expression or a LicenseRef-* identifier for custom terms."
},
"name": {
"$ref": "#/$defs/name"
},
"url": {
"type": "string",
"pattern": "^https://",
"maxLength": 2048
}
},
"additionalProperties": false
},
"provenance": {
"type": "object",
"description": "Authorship and generation history only; never a self-asserted trust signal.",
"properties": {
"sourceUrl": {
"type": "string",
"pattern": "^https://",
"maxLength": 2048
},
"sourceRevision": {
"type": "string",
"minLength": 7,
"maxLength": 128
},
"generators": {
"type": "array",
"maxItems": 32,
"items": {
"type": "string",
"minLength": 1,
"maxLength": 160
}
}
},
"additionalProperties": false
},
"point": {
"type": "object",
"required": [
"x",
"y"
],
"properties": {
"x": {
"const": 96
},
"y": {
"const": 192
}
},
"additionalProperties": false
},
"bounds": {
"type": "object",
"required": [
"left",
"top",
"right",
"bottom"
],
"properties": {
"left": {
"const": 18
},
"top": {
"const": 16
},
"right": {
"const": 174
},
"bottom": {
"const": 192
}
},
"additionalProperties": false
},
"frame": {
"type": "object",
"required": [
"width",
"height",
"anchor",
"baseline",
"safeBounds",
"canonicalFacing"
],
"properties": {
"width": {
"const": 192
},
"height": {
"const": 208
},
"anchor": {
"$ref": "#/$defs/point"
},
"baseline": {
"const": 192
},
"safeBounds": {
"$ref": "#/$defs/bounds"
},
"canonicalFacing": {
"const": "three-quarter-front-right"
}
},
"additionalProperties": false
},
"cue": {
"type": "object",
"description": "A semantic animation marker. Cues are advisory and never define game mechanics.",
"required": [
"id",
"frame"
],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": "^[a-z][a-z0-9-]*$"
},
"frame": {
"type": "integer",
"minimum": 0,
"maximum": 7,
"$comment": "The cue frame must also be less than the containing clip's frames value."
},
"description": {
"type": "string",
"minLength": 1,
"maxLength": 240
}
},
"additionalProperties": false
},
"directionalStrips": {
"type": "object",
"minProperties": 1,
"properties": {
"left": {
"$ref": "#/$defs/assetPath"
},
"right": {
"$ref": "#/$defs/assetPath"
},
"ne": {
"$ref": "#/$defs/assetPath"
},
"nw": {
"$ref": "#/$defs/assetPath"
},
"se": {
"$ref": "#/$defs/assetPath"
},
"sw": {
"$ref": "#/$defs/assetPath"
}
},
"additionalProperties": false
},
"clip": {
"type": "object",
"required": [
"id",
"name",
"description",
"whenToUse",
"frames",
"frameDurationMs",
"playback"
],
"properties": {
"id": {
"type": "string",
"minLength": 5,
"maxLength": 72,
"pattern": "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\\.v(?:0[1-9]|10)$"
},
"name": {
"$ref": "#/$defs/name"
},
"description": {
"$ref": "#/$defs/description"
},
"whenToUse": {
"$ref": "#/$defs/description"
},
"frames": {
"type": "integer",
"minimum": 1,
"maximum": 8
},
"frameDurationMs": {
"type": "integer",
"minimum": 1,
"maximum": 60000
},
"frameDurationsMs": {
"type": "array",
"minItems": 1,
"maxItems": 8,
"items": {
"type": "integer",
"minimum": 1,
"maximum": 60000
},
"$comment": "When present, this array must contain exactly one duration per frame."
},
"playback": {
"enum": [
"once",
"loop",
"ping-pong",
"hold"
]
},
"posterFrame": {
"type": "integer",
"minimum": 0,
"maximum": 7,
"default": 0,
"description": "Semantically meaningful still frame for reduced-motion and static rendering.",
"$comment": "posterFrame must also be less than the clip's frames value."
},
"cues": {
"type": "array",
"maxItems": 32,
"items": {
"$ref": "#/$defs/cue"
}
},
"strip": {
"$ref": "#/$defs/assetPath"
},
"strips": {
"$ref": "#/$defs/directionalStrips"
},
"mirrorSafe": {
"type": "boolean",
"default": false,
"description": "Whether a renderer may mirror this clip when a requested facing is missing."
}
},
"additionalProperties": false,
"oneOf": [
{
"required": [
"strip"
],
"not": {
"required": [
"strips"
]
}
},
{
"required": [
"strips"
],
"not": {
"required": [
"strip"
]
}
}
]
},
"actionVariants": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": {
"$ref": "#/$defs/clip"
}
},
"productWalkClip": {
"allOf": [
{
"$ref": "#/$defs/clip"
},
{
"type": "object",
"required": [
"strips"
],
"properties": {
"strips": {
"type": "object",
"required": [
"left",
"right"
]
}
}
}
]
},
"gameWalkClip": {
"allOf": [
{
"$ref": "#/$defs/clip"
},
{
"type": "object",
"required": [
"strips"
],
"properties": {
"strips": {
"type": "object",
"required": [
"ne",
"nw",
"se",
"sw"
]
}
}
}
]
},
"productWalkVariants": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": {
"$ref": "#/$defs/productWalkClip"
}
},
"gameWalkVariants": {
"type": "array",
"minItems": 1,
"maxItems": 10,
"items": {
"$ref": "#/$defs/gameWalkClip"
}
},
"actions": {
"type": "object",
"properties": {
"idle": {
"$ref": "#/$defs/actionVariants"
},
"walk": {
"$ref": "#/$defs/actionVariants"
},
"work": {
"$ref": "#/$defs/actionVariants"
},
"review": {
"$ref": "#/$defs/actionVariants"
},
"wait": {
"$ref": "#/$defs/actionVariants"
},
"fail": {
"$ref": "#/$defs/actionVariants"
},
"wave": {
"$ref": "#/$defs/actionVariants"
},
"celebrate": {
"$ref": "#/$defs/actionVariants"
},
"interact": {
"$ref": "#/$defs/actionVariants"
},
"sleep": {
"$ref": "#/$defs/actionVariants"
},
"talk": {
"$ref": "#/$defs/actionVariants"
},
"dance": {
"$ref": "#/$defs/actionVariants"
},
"attack-short": {
"$ref": "#/$defs/actionVariants"
},
"attack-long": {
"$ref": "#/$defs/actionVariants"
},
"war-cry": {
"$ref": "#/$defs/actionVariants"
},
"alert-low": {
"$ref": "#/$defs/actionVariants"
},
"alert-high": {
"$ref": "#/$defs/actionVariants"
}
},
"additionalProperties": false
},
"customActionFamily": {
"type": "object",
"required": [
"id",
"name",
"description",
"whenToUse",
"variants"
],
"properties": {
"id": {
"type": "string",
"pattern": "^custom-0[1-4]$"
},
"name": {
"$ref": "#/$defs/name"
},
"description": {
"$ref": "#/$defs/description"
},
"whenToUse": {
"$ref": "#/$defs/description"
},
"variants": {
"$ref": "#/$defs/actionVariants"
}
},
"additionalProperties": false
}
}
}

SmolForge Character Skin Specification

Status: Draft v0.4

This is the smallest contract for a character that can move through SmolForge product UI today and, optionally, a future 2.5D multiplayer game.

The engine decides what action is happening. A skin decides how that action looks.

1. Start here

A skin package contains:

  • skin.json, describing the character and its animation clips;
  • one transparent horizontal sprite strip per clip;
  • an optional reference.png or reference.webp for future clip generation.

Newly authored packages also declare a semantic skinVersion, human credits, and license metadata. Generation history belongs in provenance and the packaging receipt; it is informative, never a trust signal.

Choose one validation profile:

{ "profile": "product" }

Use product for SmolForge web, repository, desktop-pet, and other non-game surfaces. Use game only when the character supplies the complete future-game action set.

The vNext official Thunder Smith wears a small horned smith helmet and an uppercase S on a work apron. Community characters do not need an S, an apron, horns, or any other SmolForge-specific body feature. The shared frame, actions, and compositing rules are what make a skin compatible.

The accompanying skin.product.example.json describes the current apron-and-horned-helmet Thunder Smith product set. The runtime strips themselves are intentionally not embedded in this Gist. Regeneration starts with the reference and action briefs here, then pins the selected source performances before deterministic extraction, alignment, cutout, packing, and validation.

Thunder Smith vNext apron and horned-helmet identity study

2. Profiles

Product

A product skin must provide:

Family Meaning
idle Neutral standing
walk Left and right movement across product UI
work Build, forge, process, type, or operate
review Inspect, read, think, or examine
wait Await approval, help, or user input
fail A completed negative reaction after failure
wave Greeting or friendly attention
celebrate Success, merge, publish, deploy, or victory

interact is recommended but optional. When it is absent, product UI may use work.

Game

A game skin must provide every product family plus:

Family Meaning
interact Pick up, place, open, press, or operate
sleep AFK, away, disconnected, or long idle
talk Conversational body motion
dance Dance or rhythmic celebration
attack-short Close-range attack
attack-long Ranged attack or projectile release
war-cry Rally, intimidate, or power up
alert-low Caution, warning, or minor error
alert-high Danger, panic, or major alarm

Game walking uses four isometric facings: ne, nw, se, and sw.

Combat animation remains cosmetic. Damage, range, hit timing, projectiles, and other mechanics belong to the game engine.

3. Package structure

my-skin-project/
├── source/
│   ├── reference.png
│   ├── motion/
│   ├── selected-frames/
│   └── generation-receipt.json
└── dist/
    ├── skin.json
    ├── reference.png
    ├── LICENSE.txt       # required when SPDX uses LicenseRef-*
    ├── NOTICE.txt        # optional attribution details
    ├── integrity.json
    └── strips/
        ├── idle.v01.png
        ├── walk.v01.left.png
        ├── walk.v01.right.png
        ├── work.v01.png
        ├── review.v01.png
        ├── wait.v01.png
        ├── fail.v01.png
        ├── wave.v01.png
        └── celebrate.v01.png

Only dist/ is installed by Forge. It contains no prompts, source video, scripts, arbitrary CSS, audio, remote URLs, or executable code. A game package adds its extra action strips and replaces the product walk pair with walk.v01.ne.png, walk.v01.nw.png, walk.v01.se.png, and walk.v01.sw.png.

integrity.json records the byte size and SHA-256 digest of every runtime file. generation-receipt.json records the pinned contract revision, reference digest, generator names and versions, prompt/action brief digests, selected source-performance digests, and deterministic extraction settings. Never put secrets, provider tokens, private prompts, or personal metadata in either file.

A registry identifies an immutable release by (publisher, id, skinVersion, integrity digest). The manifest may describe provenance, but the registry independently verifies publisher identity, ownership, moderation state, and any official designation.

Backgrounds, UI, speech bubbles, projectiles, status indicators, cast shadows, and official SmolForge props are separate layers. Do not bake them into a character strip.

4. Clips and variants

Each required family must provide at least v01 and may provide up to ten variants:

idle.v01
idle.v02
war-cry.v01
war-cry.v02

Every variant supplies:

  • id: <family>.v01 through <family>.v10;
  • name: a short human-readable name;
  • description: what the character visibly does;
  • whenToUse: freeform direction for an animation selector;
  • frames: an integer from 1 through 8;
  • frameDurationMs: one positive fallback duration;
  • optional frameDurationsMs: exact timing for each frame;
  • playback: once, loop, ping-pong, or hold;
  • posterFrame: a meaningful zero-based still for reduced-motion and static UI;
  • optional cues: named, zero-based cosmetic synchronization markers;
  • strip, or directional strips for any action that needs facing-specific art;
  • mirrorSafe: whether the renderer may synthesize a missing facing by mirroring this clip; it defaults to false.

When frameDurationsMs is present, it must contain exactly frames positive integers and takes precedence over frameDurationMs.

The text fields help choose an animation. They are never executed and cannot change product or game rules.

Playback has renderer semantics:

  • once plays one cycle, then returns completion to its caller;
  • loop repeats until its owning UI state changes;
  • ping-pong alternates forward and backward without duplicating endpoints;
  • hold plays once and remains on its final frame.

Every cue frame and the poster frame must be less than frames. Cue IDs must be unique inside a clip. Cues can align decorative dust, a UI glint, or a sound owned by the host app; they never define damage, completion, billing, workflow state, or other mechanics.

5. Custom behaviors

A skin may define up to four custom action families. Each may contain up to ten variants:

{
  "id": "custom-01",
  "name": "Plant care",
  "description": "Small rituals involving the workshop plant.",
  "whenToUse": "Use during quiet periods or after maintenance work.",
  "variants": [
    {
      "id": "custom-01.v01",
      "name": "Water the plant",
      "description": "The character carefully waters a tiny plant.",
      "whenToUse": "Use while idle near a plant.",
      "frames": 6,
      "frameDurationMs": 150,
      "playback": "once",
      "strip": "strips/custom-01.v01.png"
    }
  ]
}

Custom behaviors are cosmetic unless the server explicitly binds one to a mechanic.

Non-core metadata belongs under one top-level extensions object. Keys use a reverse-DNS owner plus a field, for example "ai.smol.forge:palette-notes". Extension values are opaque data: they cannot override core fields, grant trust, change mechanics, execute code, or smuggle additional runtime asset references.

6. Sprite strips and placement

Every animation is a horizontal strip:

  • transparent PNG or WebP;
  • 192 × 208 px per frame;
  • between 1 and 8 frames;
  • width equals frames × 192;
  • height is 208;
  • frames run left to right;
  • no clipping or overlap.

All skins share this default placement contract:

{
  "frame": {
    "width": 192,
    "height": 208,
    "anchor": { "x": 96, "y": 192 },
    "baseline": 192,
    "safeBounds": {
      "left": 18,
      "top": 16,
      "right": 174,
      "bottom": 192
    },
    "canonicalFacing": "three-quarter-front-right"
  }
}

Grounded frames place the midpoint between the feet, wheels, or lowest body contact at the anchor. Airborne motion moves the character without changing apparent scale. Keep the camera, material, proportions, and identifying marks stable across every strip.

Do not assume a skin can be safely mirrored. Mirroring may reverse letters, swap hands, or break asymmetric accessories.

7. skin.json

Minimal product-profile example:

{
  "schemaVersion": 1,
  "skinVersion": "1.0.0",
  "profile": "product",
  "id": "thunder-smith",
  "name": "Thunder Smith",
  "description": "A tiny clay workshop familiar with an S-marked apron.",
  "reference": "reference.png",
  "credits": [
    {
      "name": "SmolForge",
      "role": "Character direction and curation"
    }
  ],
  "license": {
    "spdx": "NOASSERTION",
    "name": "Resolve official character asset terms before registry publication"
  },
  "frame": {
    "width": 192,
    "height": 208,
    "anchor": { "x": 96, "y": 192 },
    "baseline": 192,
    "safeBounds": {
      "left": 18,
      "top": 16,
      "right": 174,
      "bottom": 192
    },
    "canonicalFacing": "three-quarter-front-right"
  },
  "actions": {
    "idle": [
      {
        "id": "idle.v01",
        "name": "Workshop idle",
        "description": "A calm breathing and blinking loop.",
        "whenToUse": "Use whenever no more specific state applies.",
        "frames": 6,
        "frameDurationMs": 160,
        "frameDurationsMs": [280, 110, 110, 140, 140, 320],
        "playback": "loop",
        "posterFrame": 0,
        "cues": [{ "id": "blink", "frame": 2 }],
        "strip": "strips/idle.v01.png"
      }
    ],
    "walk": [
      {
        "id": "walk.v01",
        "name": "Purposeful walk",
        "description": "A compact steady gait across product UI.",
        "whenToUse": "Use when moving between interface regions.",
        "frames": 8,
        "frameDurationMs": 120,
        "playback": "loop",
        "posterFrame": 0,
        "mirrorSafe": false,
        "strips": {
          "left": "strips/walk.v01.left.png",
          "right": "strips/walk.v01.right.png"
        }
      }
    ],
    "work": [],
    "review": [],
    "wait": [],
    "fail": [],
    "wave": [],
    "celebrate": []
  },
  "customActions": []
}

The empty arrays show the required keys compactly. A submitted package must put at least v01 in each required family.

Do not add "official": true. Official status comes from a trusted SmolForge registry or distribution channel, never from a self-asserted upload field.

8. Direction rules

Product walk variants must contain:

{
  "strips": {
    "left": "strips/walk.v01.left.png",
    "right": "strips/walk.v01.right.png"
  }
}

Game walk variants contain:

{
  "strips": {
    "ne": "strips/walk.v01.ne.png",
    "nw": "strips/walk.v01.nw.png",
    "se": "strips/walk.v01.se.png",
    "sw": "strips/walk.v01.sw.png"
  }
}

In the fixed isometric camera, ne and nw travel away from the viewer; se and sw travel toward the viewer. A product renderer may use sw as left and se as right when loading a game skin.

Other action families may also use directional strips when choreography, handed props, letters, or asymmetry make one neutral strip insufficient. Published dist/ packages materialize every direction required by the selected profile. A generator may derive an opposite facing during source preparation only when mirrorSafe: true; the derived result is still written as its own runtime strip and inspected before publication. Thunder Smith's S, apron, hammer hand, and asymmetric details make conservative mirrorSafe: false the normal choice.

9. Validation

A package is valid when:

  • skin.json parses and uses schemaVersion: 1;
  • skinVersion is valid semantic versioning and changes whenever runtime content changes;
  • profile is product or game;
  • credits and license metadata are present;
  • every family required by that profile contains v01;
  • action-family names agree with clip ID prefixes and IDs are unique;
  • no family contains more than ten variants;
  • no more than four custom families exist;
  • every referenced file exists and stays inside the package;
  • walk directions match the chosen profile;
  • every strip uses 192 × 208 px frames;
  • dimensions match the declared frame count;
  • frameDurationsMs, when present, matches the frame count;
  • posterFrame and every cue point to an existing zero-based frame;
  • cue IDs are unique inside their clip;
  • every file is a regular file rather than a symlink;
  • image magic bytes agree with the extension;
  • each asset is at most 8 MiB and the complete package is at most 64 MiB;
  • the background is transparent;
  • frames do not overlap or clip;
  • anchor, baseline, scale, camera, and identity remain consistent;
  • each animation matches its declared action;
  • loops are clean;
  • alert-low, alert-high, and fail remain visibly distinct;
  • short- and long-range attacks remain distinguishable.

Before submission, render a contact sheet and animated preview of every clip. Repair identity drift, wrong facing, clipping, transparency artifacts, scale popping, baseline jumping, reversed marks, or misleading action semantics.

JSON Schema covers the portable object shape. An installer or packager must also enforce cross-field rules, file-system safety, image dimensions, alpha, byte quotas, and digests. A schema default is documentation; it does not mutate an old manifest.

10. Legacy schema-v1 packages

A schemaVersion: 1 manifest without profile is a legacy v0.2 package. Validators should continue to require all seventeen families and four isometric walk directions for that package.

A v0.4 loader may accept older packages by applying these in-memory defaults:

  • missing skinVersion becomes an internal legacy sentinel and is not eligible for new registry publication;
  • missing posterFrame becomes 0;
  • missing cues, credits, and extensions become empty collections;
  • missing mirrorSafe becomes false;
  • missing license remains visibly unknown and must not be inferred.

A v0.4 validator:

  • accepts legacy v0.2 without silently weakening it;
  • requires profile on newly authored packages;
  • permits recognized extra families in either profile;
  • rejects unknown core fields while retaining only explicitly namespaced extensions;
  • strictly validates the types of known fields;
  • never rejects a product skin for omitting game-only actions;
  • never treats a character-supplied official field as trusted;
  • never requires an S badge from a community skin.

11. Generating animation with image and video models

Do not ask an image model to one-shot a sprite sheet. In practice, that produces attractive poses but weak animation: limbs do not alternate reliably, the character jumps inside the cell, scale and camera drift, props change hands, and frame order does not describe one continuous performance.

The workflow that produced the official non-directional Thunder Smith actions uses a video model for temporal coherence and deterministic tooling for the sprite contract:

Model output is not deterministic. A reproducible build begins only after the chosen reference, prompt, motion reference, and source performance have been pinned by digest. Extraction and packaging after that boundary must be deterministic and must record their settings and output hashes.

  1. Lock one canonical identity image. Use a neutral full-body reference showing all identity-critical details and asymmetries.
  2. Block the action before rendering it. Define its ordered motion extrema: anticipation, contact, apex, recoil, settle, and so on. A simple 3D rig or proxy animation is useful when exact limb identity or foot contact matters.
  3. Generate one action video per call. Give a reference-conditioned image-to-video or video-to-video model the canonical character image as the appearance reference. When available, also provide the proxy animation as the motion reference. The official set was tested with Seedance through Fal, but the contract is model- and provider-independent.
  4. Capture a clean plate. Use a locked orthographic or long-lens camera, fixed character scale and screen position, flat removable background, neutral lighting, and no scenery or detached effects.
  5. Select frames from the downloaded video. Probe its real frame rate. Choose the clearest motion extrema in chronological order; do not blindly sample equal time intervals.
  6. Stabilize and cut out deterministically. Align slow camera drift using a stable body landmark such as helmet center and width, while preserving intentional body bob and airborne movement. Remove the background, fit each pose into 192 × 208, and place grounded contact at (96, 192).
  7. Pack and validate with ordinary image tooling. Assemble the horizontal strip, write exact frameDurationsMs, render a contact sheet and preview, and reject identity drift, baseline jumping, scale popping, reversed marks, prop-side swaps, broken foot contacts, or misleading action semantics.

The model creates a continuous performance. Code creates the sprite sheet.

Reusable video-generation prompt

Use the following as a shared prefix, then append one action-specific description:

Single continuous locked-camera character-animation shot.

Use the attached canonical image as the exact subject identity. Preserve the character's body proportions, material, palette, camera, clothing, accessories, identifying marks, and asymmetry for the entire video.

If a motion-reference video is attached, use it as the exact body-motion, gesture-order, timing, root-height, screen-position, and limb-identity reference. Replace only the proxy character. Do not mirror, reverse, reinterpret, or replace the motion.

Visible action: [DESCRIPTION]

Required ordered beats: [MOTION_EXTREMA]

The character remains centered at a stable apparent scale. The same hand holds each attached prop for the entire shot. Any letter or emblem remains readable and unreversed. Keep every limb and foot visible.

Flat solid chroma background with no floor, horizon, texture, gradient, shadow, scenery, particles, labels, speech bubbles, detached effects, extra objects, or extra characters. Fixed neutral lighting. No camera movement, zoom, shake, reframing, costume change, morphing, duplicated limbs, missing limbs, or moving background.

Examples of useful ordered beats:

  • wave: neutral, empty hand rises, clear wave, hand returns, neutral;
  • celebrate: anticipation crouch, lift, airborne apex, descent, planted landing, settle;
  • fail: news lands, shoulders sink, deep slump, tiny attempted recovery, final held slump;
  • work: hammer raises, downward strike, compressed impact pose, follow through, reset;
  • walk: contact, down, passing, up, opposite contact, down, passing, up.

For walking, the motion reference should visibly preserve anatomical left/right leg identity and planted-foot contact. Generate each screen direction separately. Never create the opposite direction by mechanically mirroring a character with letters, handed props, or asymmetric accessories.

Video generation is still probabilistic. A strong prompt is not proof that a clip is valid. If the hammer changes hands, the badge reverses, the legs fail to alternate, or the character drifts, reject or regenerate the source clip before extraction rather than hiding the error during strip assembly.

12. Scene sockets

Forge scenes are composed by the app, not shipped inside a skin. A scene socket may request an action, variant, direction, size, placement, and reduced-motion poster frame. The app continues to own route selection, layout, props, copy, status, z-order, responsive behavior, interaction, and accessibility.

See scene-socket.example.json for the portable boundary. It is illustrative host data, not another file to place in dist/. This separation lets the same skin inhabit an Actions empty state, Sites deployment bench, profile workshop, or future screen without regenerating route-specific art.

Full scene illustrations may remain useful as references or marketing assets. They are not sprite strips, should not be declared by skin.json, and must not be used as a substitute for transparent character animation.

13. Intentionally unspecified

SmolForge does not prescribe:

  • species, body shape, palette, material, or art style;
  • a universal logo, apron, helmet, horn, or character marking;
  • exact choreography;
  • frame count within the 1–8 limit;
  • game hitboxes, damage, or timing;
  • projectile and effect design;
  • atlas packing;
  • how an image model creates source art.

Those details can evolve without invalidating a compatible skin.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment