Build the older-gen sprite sets

rb, rg, y, c, rs, dp and bw have been served out of /smog2/sprites since
before this repo existed: the frozen output of the tup pipeline, uploaded
once and root-owned ever since. The sources are all here, so build them.

Each set is one generation's fronts under the name the smogdex, the forum
and chatot compose from a dex alias. None of the three reads a manifest, so
these ride the LINKS mirror the way xy/ does.

A collection is named for one game and sourced from several: `-g` says which
games a picture is the copy for, and the layers are claimed in preference
order, which is how rs/ gets Deoxys-Attack out of FireRed and dp/ the Rotom
appliances out of Platinum. That flag is Gigantamax everywhere the grammar
isn't naming a gen 1-4 game, so what a rule builds is named for what it
publishes, with the game already spent on picking the collection.

The first-source-wins loop the xy set carried inline is now firstWins(),
since every layered set wants it. Its output is unchanged.

Against what is being served today, this drops the names nothing in the
repo can draw -- jynx-old and four MissingNo in y/ -- and the legacy numeric
ones, 201-a and 498-water and the rest, which the same sprites now answer to
as unown-a and arceus-water. dp/arceus-* was 96x96 art that is not gen 4's
at all; the real one was hiding under 498-*.
This commit is contained in:
Christopher Monsanto
2026-08-23 16:32:26 -04:00
parent e3d3b1c7f0
commit d910d20b26
4 changed files with 164 additions and 20 deletions

View File

@@ -2,6 +2,8 @@ MODELS_OPTIPNG=-o7
MODELS_ADVPNG=-z4 -i10
MINISPRITE_OPTIPNG=-o7
MINISPRITE_ADVPNG=-z4 -i100
SPRITE_OPTIPNG=-o7
SPRITE_ADVPNG=-z4 -i100
#CONFIG_SPRITESHEET_OPTIPNG=-o7
#CONFIG_SPRITESHEET_ADVPNG=-z4 -i15
#CONFIG_SPRITESHEET_PNGQUANT=256 -s 11

74
rules/oldgen.ts Normal file
View File

@@ -0,0 +1,74 @@
import * as spritedata from '@smogon/sprite-data/index.ts';
import {type Artifact, type CmdSpec, rule} from '../tools/build/artifact.ts';
import {base, glob} from '../tools/build/helpers.ts';
// A source picked for an older-gen collection, and what the sprite built from
// it is called. The two differ because the game is spent on choosing the
// collection: `rb` and `y` are the same Zubat under two palettes, and neither
// output has a game left in its name. Dropping it is not cosmetic. `-g` is
// Gigantamax everywhere the grammar isn't naming a gen 1-4 game, so a name
// that kept it would reach publishedName() as `zubat-gmax`.
export type Front = {
file: string,
name: string,
};
// The gen 1-4 sets are each named for one game and sourced from several. `-g`
// spells the games a picture is the copy for, a letter apiece, and a
// collection takes them in preference order: the game it is named for first,
// then whatever drew the formes that game never did. That is how rs/ comes to
// hold Deoxys-Attack, which only FireRed drew, and dp/ the Rotom appliances
// and Giratina-Origin, which only Platinum did. Which layer wins a name is
// settled at copy time, by whoever claims it first.
//
// Gen 5 has no such axis -- there its `-g` really is Gigantamax -- so its sets
// pass no order, and keep every flag they came with.
export function fronts(pats: string | string[], games?: string): Front[] {
let ranked = [];
for (let file of glob(pats)) {
let sn = spritedata.parseFilename(base(file));
// Backs and shinies go unpublished: the legacy tree kept them in
// subdirectories of their own, under national dex numbers, and
// nothing on the site has ever read one.
if (sn.extra.has('b') || sn.extra.has('s')) {
continue;
}
let rank = 0;
if (games !== undefined) {
let code = sn.extra.get('g') ?? '';
rank = [...games].findIndex(g => code.includes(g));
// Another generation's game, or no game at all: not this set's.
if (rank < 0) {
continue;
}
sn.extra.delete('g');
}
ranked.push({file, name: spritedata.formatFilename(sn), rank});
}
// Stable, so within one game the glob's own order survives.
return ranked.sort((a, b) => a.rank - b.rank).map(({file, name}) => ({file, name}));
}
// One rule per front, its output named for what it publishes. Several sources
// can land on one name -- Abra is the same drawing in Ruby and in Emerald --
// which is fine, since a nominal name is provenance and not identity.
export function forEachFront(files: Front[], spec: CmdSpec, ext: string): Artifact[] {
return files.map(f => rule(f.file, spec, `${f.name}.${ext}`));
}
// The CAPs that were playable while Diamond and Pearl were. They were only
// ever drawn in gen 5's style, so dp/ carries them from the gen 5 sources the
// way the legacy set did -- and has to name them, since nothing in a filename
// says a sprite is a CAP.
export let GEN4_CAPS = [
'arghonaut', 'colossoil', 'cyclohm', 'fidgit', 'kitsunoh', 'krilowatt',
'pyroak', 'revenankh', 'stratagem', 'syclant', 'voodoom',
];
// Every front of the given species, formes and female slots included.
export function named(files: Front[], names: readonly string[]): Front[] {
let want = new Set(names);
return files.filter(f => want.has(spritedata.parseFilename(f.name).name));
}

View File

@@ -146,3 +146,25 @@ export async function itemspritecopy(manifest: Manifest, f: Sprite, dest: Dest):
await manifest.copy(f, dest, spritedata.smogon(alias));
}
}
// A copy that takes a published name only where nothing before it did. Every
// layered set is built this way: the later sources are backfills, and the
// claim is per published name rather than per filename because the mapping is
// one to many in both directions. One name can be spelled several ways, and
// gen 5 carries a sprite per forme slot, so its six Minior meteors and its two
// Zygarde Power Construct slots all want the name the layer above already
// published, which the manifest would refuse as a duplicate.
export function firstWins(manifest: Manifest, dest: Dest,
opts: NameOpts = {}): (f: Sprite) => Promise<void> {
let seen = new Set<string>();
return async (f: Sprite): Promise<void> => {
let names = publishedNames(f, opts);
if (names.some(n => seen.has(n))) {
return;
}
for (let name of names) {
seen.add(name);
await manifest.copy(f, dest, name);
}
};
}

View File

@@ -1,9 +1,10 @@
import {gen10Modelslike} from './rules/modelslike.ts';
import {Manifest, type Sprite, type Tree, itemspritecopy, publishedNames, spritecopy} from './rules/publish.ts';
import {forEachRule, rule} from './tools/build/artifact.ts';
import {GEN4_CAPS, forEachFront, fronts, named} from './rules/oldgen.ts';
import {Manifest, type Sprite, type Tree, firstWins, itemspritecopy, spritecopy} from './rules/publish.ts';
import {type CmdSpec, forEachRule, rule} from './tools/build/artifact.ts';
import {compresspng, pad, spriteglob, trimimg} from './tools/build/helpers.ts';
import {deploy} from './tools/deploy/api.ts';
import {type DeployCtx, deploy} from './tools/deploy/api.ts';
// The tar root maps onto the served tree: sprites/x is served at
// /__assets/sprites/x. The upload rejects a tar whose tree disagrees with the
@@ -56,23 +57,7 @@ let xyGen5 = forEachRule('src/sprites/gen5/*.png', [
deploy(async ctx => {
let manifest = new Manifest(ctx, TREE);
let seen = new Set<string>();
// First source wins per published name rather than per filename, because
// the later sources are backfills and one name can be spelled several
// ways. gen 5 carries a sprite per forme slot, so its six Minior meteors
// and its two Zygarde Power Construct slots all want the name the models
// already published, which the manifest would refuse as a duplicate.
let xycopy = async (f: Sprite) => {
let names = publishedNames(f);
if (names.some(n => seen.has(n))) {
return;
}
for (let name of names) {
seen.add(name);
await manifest.copy(f, {dir: 'xy'}, name);
}
};
let xycopy = firstWins(manifest, {dir: 'xy'});
for (let f of await ctx.list('src/models')) {
await xycopy(f);
}
@@ -113,6 +98,67 @@ deploy(async ctx => {
manifest.links(LINKS);
});
// sprites/rb, rg, y, c, rs, dp, bw: the older-gen full sprites, the front of
// each generation under the name the smogdex, the forum and chatot each
// compose from a dex alias. They read no manifest, which is why these ride
// the LINKS mirror the way xy/ does.
function trimmed(display: string): CmdSpec {
return {display, cmds: [trimimg(), compresspng({config: 'SPRITE'})]};
}
// Red and Blue and Japanese Red and Green are two palettes of one set of
// drawings; Yellow's are the Game Boy Color's, which is a directory of its
// own, the sprites beside it being the Super Game Boy ones.
let gen1 = trimmed('gen 1 sprite %f');
let rb = forEachFront(fronts('src/sprites/gen1/*.png', 'b'), gen1, 'png');
let rg = forEachFront(fronts('src/sprites/gen1/*.png', 'g'), gen1, 'png');
let y = forEachFront(fronts('src/sprites/gen1/gbc/*.png', 'y'), gen1, 'png');
// Crystal's animations come at the games' own 56x56 and are published on the
// 60x60 box the legacy set used: the smogdex scales a sprite to fill its
// frame, so trimmed, a Diglett would arrive the size of a Steelix.
let c = forEachFront(fronts('src/sprites/gen2/*.gif'), {
display: 'gen 2 animation %f',
cmds: [
'magick convert %f -coalesce -background none -gravity center -extent 60x60 %o',
'gifsicle -O3 -b %o',
],
}, 'gif');
let rs = forEachFront(fronts('src/sprites/gen3/*.png', 'rfle'), trimmed('gen 3 sprite %f'), 'png');
let dp = forEachFront([
...fronts('src/sprites/gen4/*.png', 'dph'),
...named(fronts('src/sprites/gen5/*.png'), GEN4_CAPS),
], trimmed('gen 4 sprite %f'), 'png');
function oldgen(dir: string, sources: (ctx: DeployCtx) => Promise<Sprite[]>): void {
deploy(async ctx => {
let manifest = new Manifest(ctx, TREE);
let copy = firstWins(manifest, {dir});
for (let f of await sources(ctx)) {
await copy(f);
}
manifest.write(`__meta/${dir}/manifest.json`);
manifest.links(LINKS);
});
}
oldgen('rb', async () => rb);
oldgen('rg', async () => rg);
oldgen('y', async () => y);
oldgen('c', async () => c);
oldgen('rs', async () => rs);
oldgen('dp', async () => dp);
// bw/ is gen 5's animations, backfilled from the gen 5-style stills for what
// the games never animated: the CAPs, and everything the Smogon Sprite
// Project has drawn since. The stills are the rule set xy/ already layers on.
oldgen('bw', async ctx => [
...(await ctx.list('src/sprites/gen5')).filter(f => f.ext === 'gif'),
...xyGen5,
]);
// Smogdex spritesheet. The sheet tool bakes the names parsed from the %f
// filenames into the css, hence nameSensitive. The png is declared only so
// cwebp has something to read; only the css and the webp are published.