// One-time asset/codegen fetcher for Star Control Super Melee.
//
// node tools/fetchStarControlAssets.js --ships # download + build ship rotation sheets
// node tools/fetchStarControlAssets.js --stats # print UQM stat blocks for manual review
//
// Downloads ship art and stats from the Ur-Quan Masters GitHub mirror
// (juj/sc2-uqm). UQM content is CC BY-NC-SA 2.5 — an attribution line ships in
// data/star-control-ships.json and on the game's setup screen. This script is
// never run by the site; it needs network access plus ImageMagick (`magick`).
//
// --ships: for each ship, the GitHub contents API lists
// sc2/content/base/ships/
/, the 16 `-big-000..015.png` rotation
// frames are downloaded, montaged into a 16x1 spritesheet at
// assets/images/starcontrol/sc-ship-.png, and the measured frame
// dimensions are written into that ship's `sprite` block in
// data/star-control-ships.json (only the sprite block is touched).
import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const REPO = 'juj/sc2-uqm';
const CONTENT = 'sc2/content/base/ships';
const SRC = 'sc2/src/uqm/ships';
const OUT_DIR = join(root, 'assets/images/starcontrol');
const JSON_PATH = join(root, 'data/star-control-ships.json');
// slug (our JSON key) -> UQM content directory. Frame prefixes are discovered,
// not guessed (e.g. human/cruiser-big-000.png).
const CONTENT_DIRS = {
androsynth: 'androsynth', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr',
druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'kohrah',
melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz',
pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandro', spathi: 'spathi',
supox: 'supox', syreen: 'syreen', thraddash: 'thraddash', umgah: 'umgah',
urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfotpik',
};
// slug -> the rotation-frame prefix that is the SHIP (several directories
// also hold 16-frame weapon animations like spathi's "butt-big-000..015").
const SHIP_BASE = {
androsynth: 'guardian', arilou: 'skiff', chenjesu: 'broodhome', chmmr: 'avatar',
druuge: 'mauler', earthling: 'cruiser', ilwrath: 'avenger', kohrah: 'marauder',
melnorme: 'trader', mmrnmhrm: 'xform', mycon: 'podship', orz: 'nemesis',
pkunk: 'fury', shofixti: 'scout', slylandro: 'probe', spathi: 'eluder',
supox: 'blade', syreen: 'penetrator', thraddash: 'torch', umgah: 'drone',
urquan: 'dreadnought', utwig: 'jugger', vux: 'intruder', yehat: 'terminator',
zoqfot: 'stinger',
};
// slug -> UQM source directory for --stats.
const SRC_DIRS = {
androsynth: 'androsyn', arilou: 'arilou', chenjesu: 'chenjesu', chmmr: 'chmmr',
druuge: 'druuge', earthling: 'human', ilwrath: 'ilwrath', kohrah: 'blackurq',
melnorme: 'melnorme', mmrnmhrm: 'mmrnmhrm', mycon: 'mycon', orz: 'orz',
pkunk: 'pkunk', shofixti: 'shofixti', slylandro: 'slylandr', spathi: 'spathi',
supox: 'supox', syreen: 'syreen', thraddash: 'thradd', umgah: 'umgah',
urquan: 'urquan', utwig: 'utwig', vux: 'vux', yehat: 'yehat', zoqfot: 'zoqfot',
};
async function ghJson(path) {
const res = await fetch(`https://api.github.com/repos/${REPO}/contents/${path}`, {
headers: { 'User-Agent': 'fertig-classic-games-asset-fetch' },
});
if (!res.ok) throw new Error(`GitHub API ${res.status} for ${path}`);
return res.json();
}
async function download(url, dest) {
const res = await fetch(url);
if (!res.ok) throw new Error(`download ${res.status} for ${url}`);
writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
}
// Finds the rotation-frame prefix: a group of exactly 16 files named
// `-big-000.png` .. `-big-015.png` (weapon/captain art doesn't
// match this shape; e.g. human's saturn-big has 23 frames and is skipped).
function findRotationGroup(listing, preferredBase) {
const groups = {};
for (const f of listing) {
const m = /^(.+)-big-(\d{3})\.png$/.exec(f.name);
if (!m) continue;
(groups[m[1]] ??= []).push({ n: Number(m[2]), url: f.download_url });
}
const candidates = Object.entries(groups)
.filter(([, frames]) => frames.length === 16
&& Math.min(...frames.map((fr) => fr.n)) === 0
&& Math.max(...frames.map((fr) => fr.n)) === 15);
if (candidates.length === 0) return null;
let picked = candidates.find(([base]) => base === preferredBase);
if (!picked) {
console.warn(` preferred base "${preferredBase}" not found; candidates: ${candidates.map(([k]) => k).join(', ')}`);
[picked] = candidates;
}
const [base, frames] = picked;
frames.sort((a, b) => a.n - b.n);
return { base, frames };
}
async function buildSheet(slug, group, suffix = '') {
const tmp = join(tmpdir(), `sc-fetch-${slug}${suffix}`);
rmSync(tmp, { recursive: true, force: true });
mkdirSync(tmp, { recursive: true });
const files = [];
for (const fr of group.frames) {
const dest = join(tmp, `f-${String(fr.n).padStart(3, '0')}.png`);
await download(fr.url, dest);
files.push(dest);
}
const sheetName = `sc-ship-${slug}${suffix}`;
const out = join(OUT_DIR, `${sheetName}.png`);
execFileSync('magick', ['montage', ...files, '-tile', '16x1', '-geometry', '+0+0', '-background', 'none', out]);
const dims = execFileSync('magick', ['identify', '-format', '%w %h', files[0]]).toString().trim().split(' ');
rmSync(tmp, { recursive: true, force: true });
return { sheet: sheetName, frames: 16, frameWidth: Number(dims[0]), frameHeight: Number(dims[1]) };
}
async function fetchShips() {
mkdirSync(OUT_DIR, { recursive: true });
const json = JSON.parse(readFileSync(JSON_PATH, 'utf8'));
const results = [];
for (const [slug, dir] of Object.entries(CONTENT_DIRS)) {
if (!json.ships[slug]) { console.warn(`skip ${slug}: not in ships JSON`); continue; }
process.stdout.write(`${slug} (${dir}) ... `);
try {
const listing = await ghJson(`${CONTENT}/${dir}`);
const group = findRotationGroup(listing, SHIP_BASE[slug]);
if (!group) { console.log('NO 16-frame rotation group found'); continue; }
json.ships[slug].sprite = await buildSheet(slug, group);
console.log(`ok (${group.base}, ${json.ships[slug].sprite.frameWidth}x${json.ships[slug].sprite.frameHeight})`);
// Mmrnmhrm's alternate Y-Wing form has its own rotation frames.
if (slug === 'mmrnmhrm' && json.ships[slug].forms?.[0]) {
const yGroup = findRotationGroup(listing, 'ywing');
if (yGroup?.base === 'ywing') {
json.ships[slug].forms[0].sprite = await buildSheet(slug, yGroup, '-y');
console.log(` + y-form sheet (${json.ships[slug].forms[0].sprite.frameWidth}x${json.ships[slug].forms[0].sprite.frameHeight})`);
}
}
results.push(slug);
} catch (e) {
console.log(`FAILED: ${e.message}`);
}
}
writeFileSync(JSON_PATH, `${JSON.stringify(json, null, 2)}\n`);
console.log(`\n${results.length}/${Object.keys(CONTENT_DIRS).length} sheets built; sprite blocks written to data/star-control-ships.json`);
}
const STAT_DEFINES = [
'MAX_CREW', 'MAX_ENERGY', 'ENERGY_REGENERATION', 'ENERGY_WAIT', 'MAX_THRUST',
'THRUST_INCREMENT', 'THRUST_WAIT', 'TURN_WAIT', 'SHIP_MASS',
'WEAPON_ENERGY_COST', 'WEAPON_WAIT', 'SPECIAL_ENERGY_COST', 'SPECIAL_WAIT',
'MISSILE_SPEED', 'MISSILE_LIFE', 'MISSILE_HITS', 'MISSILE_DAMAGE',
];
// Prints raw #define blocks for manual review/merge — NOT written to the JSON
// (several ships encode stats in code: Chmmr zapsats, Slylandro drive,
// Mmrnmhrm forms).
async function fetchStats() {
for (const [slug, dir] of Object.entries(SRC_DIRS)) {
const url = `https://raw.githubusercontent.com/${REPO}/master/${SRC}/${dir}/${dir}.c`;
const res = await fetch(url);
if (!res.ok) { console.log(`${slug}: fetch failed ${res.status}`); continue; }
const text = await res.text();
const out = {};
for (const name of STAT_DEFINES) {
const m = new RegExp(`^#define\\s+${name}\\s+(.+)$`, 'm').exec(text);
if (m) out[name] = m[1].replace(/\/\*.*?\*\//g, '').trim();
}
console.log(`\n=== ${slug} (${dir}.c)`);
console.log(JSON.stringify(out, null, 2));
}
}
const args = process.argv.slice(2);
if (args.includes('--stats')) await fetchStats();
else if (args.includes('--ships')) await fetchShips();
else console.log('usage: node tools/fetchStarControlAssets.js --ships | --stats');