// Transcribe the five original NES Excitebike courses from their track maps.
//
// node tools/readExcitebikeMaps.js
# report what it finds
// node tools/readExcitebikeMaps.js --write # rewrite tools/data/excitebikeCourses.js
//
// holds track1.png .. track5.png, the full-course rips from nesmaps.com.
// They are third-party images and are not committed; this tool exists so the
// transcription is reproducible and reviewable rather than a wall of numbers
// somebody has to take on trust.
//
// How the maps read, established by inspection:
// - the left of each image is a legend panel, not track; the course starts at
// the first column whose lane band is actually made of track material
// - the playfield is 192px tall: crowd 0-39, sky 40-63, infield 64-127,
// four 12px lanes 128-175, apron 176-191
// - each course has its own palette. The surface colour is whatever the lane
// band is mostly made of; the infield colour showing through a lane band
// means the track is missing there
// - ramps are solid masses of track material rising out of the lane band, so
// they are found by walking up from the lane floor rather than by colour
//
// Heights and widths come out of the image; the catalogue letter is then the
// closest hurdle in src/games/excitebike/ExcitebikeTrack.js. That mapping is
// approximate by construction — the game's hurdles are a fixed vocabulary and
// the original's ramps vary continuously — so it is printed for review.
import { readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { decodePng, pixelAt } from './lib/png.js';
import { HURDLES } from '../src/games/excitebike/ExcitebikeTrack.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const LANES_Y = 128;
const LANE_H = 12;
const LANE_COUNT = 4;
const LANES_BOTTOM = LANES_Y + LANE_H * LANE_COUNT; // 176
const INFIELD_Y = 100;
const args = process.argv.slice(2);
const DIR = args.find((a) => !a.startsWith('--'));
const WRITE = args.includes('--write');
if (!DIR) {
console.error('usage: node tools/readExcitebikeMaps.js [--write]');
process.exit(1);
}
// ---------------------------------------------------------------------------
// Palette and extent
// ---------------------------------------------------------------------------
function commonest(counts) {
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
}
function readTrack(img) {
// Surface: whatever the lane band is mostly made of, sampled clear of the
// legend panel on the left.
const laneHist = new Map();
for (let y = LANES_Y; y < LANES_BOTTOM; y += 1) {
for (let x = Math.floor(img.width * 0.3); x < img.width; x += 1) {
const c = pixelAt(img, x, y);
laneHist.set(c, (laneHist.get(c) ?? 0) + 1);
}
}
const laneRank = commonest(laneHist);
const surface = laneRank[0][0];
const infieldHist = new Map();
for (let x = Math.floor(img.width * 0.3); x < img.width; x += 1) {
const c = pixelAt(img, x, INFIELD_Y);
infieldHist.set(c, (infieldHist.get(c) ?? 0) + 1);
}
const infield = commonest(infieldHist)[0][0];
// Track material: the surface plus every other lane-band colour that is not
// the infield showing through a hole and not chrome from the legend panel.
//
// The threshold has to be generous. Each course has a highlight colour used
// only on the lit faces of ramps, so it is rare — but leaving it out makes
// small ramps invisible to the reader, and a missed ramp in front of a hole
// turns a jump into an unclearable wall.
const material = new Set([surface]);
for (const [c, n] of laneRank.slice(1, 6)) {
if (c === infield) continue;
if (n < img.width * 0.4) continue;
material.add(c);
}
const isMaterial = (c) => material.has(c);
const bandMaterial = (x) => {
let k = 0;
for (let y = LANES_Y; y < LANES_BOTTOM; y += 1) if (isMaterial(pixelAt(img, x, y))) k += 1;
return k;
};
let first = 0;
let last = img.width - 1;
while (first < img.width && bandMaterial(first) <= 30) first += 1;
while (last > first && bandMaterial(last) <= 30) last -= 1;
return { surface, infield, material, isMaterial, first, last, length: last - first + 1 };
}
// ---------------------------------------------------------------------------
// Features
// ---------------------------------------------------------------------------
/** Top of the solid mass of track material standing in this column. */
function surfaceTop(img, t, x) {
let best = LANES_BOTTOM;
let miss = 0;
for (let y = LANES_BOTTOM - 1; y >= 56; y -= 1) {
if (t.isMaterial(pixelAt(img, x, y))) { best = y; miss = 0; } else {
miss += 1;
if (miss > 3) break;
}
}
return best;
}
function runsOf(flags, minWidth) {
const out = [];
let s = -1;
for (let i = 0; i < flags.length; i += 1) {
if (flags[i] && s < 0) s = i;
if (!flags[i] && s >= 0) {
if (i - s >= minWidth) out.push([s, i - 1]);
s = -1;
}
}
if (s >= 0 && flags.length - s >= minWidth) out.push([s, flags.length - 1]);
return out;
}
/** Ramps and mounds: connected terrain standing proud of the lane band. */
function findRamps(img, t) {
const top = new Int16Array(t.length);
for (let i = 0; i < t.length; i += 1) top[i] = surfaceTop(img, t, t.first + i);
const raised = Array.from(top, (y) => y < LANES_Y - 1);
return runsOf(raised, 8).map(([a, b]) => {
let peak = 0;
for (let i = a; i <= b; i += 1) peak = Math.max(peak, LANES_Y - top[i]);
const entry = LANES_Y - top[a];
const exit = LANES_Y - top[b];
return { x: a, width: b - a + 1, height: peak, entry, exit };
});
}
/** Holes: the infield showing through where track should be. */
function findGaps(img, t) {
const perLane = [];
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
const y0 = LANES_Y + lane * LANE_H;
const flags = new Array(t.length);
for (let i = 0; i < t.length; i += 1) {
let k = 0;
for (let y = y0; y < y0 + LANE_H; y += 1) if (pixelAt(img, t.first + i, y) === t.infield) k += 1;
flags[i] = k >= LANE_H - 2;
}
perLane.push(runsOf(flags, 8));
}
return perLane;
}
/**
* Textured patches: stretches where the shade colour floods a lane far above
* its baseline. On these maps that is churned ground — mud and whoops.
*/
function findTextured(img, t) {
const shade = [...t.material].find((c) => c !== t.surface) ?? t.surface;
const perLane = [];
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
const y0 = LANES_Y + lane * LANE_H;
const flags = new Array(t.length);
for (let i = 0; i < t.length; i += 1) {
let k = 0;
for (let y = y0; y < y0 + LANE_H; y += 1) if (pixelAt(img, t.first + i, y) === shade) k += 1;
// The lane divider contributes one row; anything much denser is texture.
flags[i] = k >= 4;
}
perLane.push(runsOf(flags, 10));
}
return perLane;
}
// ---------------------------------------------------------------------------
// Mapping onto the hurdle catalogue
// ---------------------------------------------------------------------------
const RAMP_LETTERS = ['A', 'B', 'C', 'D', 'E', 'H', 'R', 'S'];
/** Closest catalogue hurdle to a measured ramp, by peak height then footprint. */
function classifyRamp(ramp) {
// A mound comes back down to the ground; a ramp ends on a launch edge. The
// test has to be relative, because a sine mound's last sample is close to
// zero rather than exactly zero.
const isMound = ramp.exit <= Math.max(3, ramp.height * 0.2);
let best = null;
let bestCost = Infinity;
for (const id of RAMP_LETTERS) {
const h = HURDLES[id];
let peak = 0;
for (let u = 0; u < h.profile.length; u += 1) peak = Math.max(peak, h.profile.at(u));
const endsFlat = h.profile.at(h.profile.length - 1) < peak * 0.2;
if (endsFlat !== isMound) continue;
const cost = Math.abs(peak - ramp.height) * 3 + Math.abs(h.profile.length - ramp.width) * 0.4;
if (cost < bestCost) { bestCost = cost; best = id; }
}
return best ?? 'B';
}
function gapLetter(lanes) {
if (lanes.length >= 4) return 'Q';
if (lanes.every((l) => l >= 2)) return 'O';
if (lanes.every((l) => l <= 1)) return 'P';
return 'Q';
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const THEMES = { 1: 'day', 2: 'night', 3: 'day', 4: 'desert', 5: 'snow' };
function transcribe(n) {
const img = decodePng(readFileSync(join(DIR, `track${n}.png`)));
const t = readTrack(img);
const ramps = findRamps(img, t);
const gapLanes = findGaps(img, t);
const texLanes = findTextured(img, t);
// Merge per-lane gap runs that overlap into one hurdle spanning those lanes.
const gapEvents = [];
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
for (const [a, b] of gapLanes[lane]) gapEvents.push({ a, b, lane });
}
gapEvents.sort((p, q) => p.a - q.a);
const gaps = [];
for (const ev of gapEvents) {
const open = gaps.find((g) => ev.a < g.b + 12 && g.a < ev.b + 12);
if (open) {
open.a = Math.min(open.a, ev.a);
open.b = Math.max(open.b, ev.b);
open.lanes.add(ev.lane);
} else {
gaps.push({ a: ev.a, b: ev.b, lanes: new Set([ev.lane]) });
}
}
const texEvents = [];
for (let lane = 0; lane < LANE_COUNT; lane += 1) {
for (const [a, b] of texLanes[lane]) texEvents.push({ a, b, lane });
}
texEvents.sort((p, q) => p.a - q.a);
const patches = [];
for (const ev of texEvents) {
const open = patches.find((g) => ev.a < g.b + 12 && g.a < ev.b + 12);
if (open) {
open.a = Math.min(open.a, ev.a);
open.b = Math.max(open.b, ev.b);
open.lanes.add(ev.lane);
} else {
patches.push({ a: ev.a, b: ev.b, lanes: new Set([ev.lane]) });
}
}
return { n, img, t, ramps, gaps, patches, theme: THEMES[n] };
}
const results = [];
for (let n = 1; n <= 5; n += 1) results.push(transcribe(n));
for (const r of results) {
console.log(`\n=== TRACK ${r.n} =============================================`);
console.log(` image ${r.img.width}x${r.img.height} course starts at x=${r.t.first} length ${r.t.length}`);
console.log(` surface #${r.t.surface.toString(16).padStart(6, '0')}`
+ ` infield #${r.t.infield.toString(16).padStart(6, '0')}`
+ ` material ${[...r.t.material].map((c) => `#${c.toString(16).padStart(6, '0')}`).join(' ')}`);
console.log(` ramps ${r.ramps.length} gaps ${r.gaps.length} textured patches ${r.patches.length}`);
const byHeight = new Map();
for (const ramp of r.ramps) {
const key = `h${ramp.height} w${ramp.width}`;
byHeight.set(key, (byHeight.get(key) ?? 0) + 1);
}
console.log(' ramp shapes:', [...byHeight.entries()].map(([k, v]) => `${k} x${v}`).join(', '));
console.log(' gaps:', r.gaps.map((g) => `${g.a}+${g.b - g.a + 1}[${[...g.lanes].sort().join('')}]`).join(' ') || '(none)');
console.log(' patches:', r.patches.slice(0, 12).map((g) => `${g.a}+${g.b - g.a + 1}[${[...g.lanes].sort().join('')}]`).join(' ') || '(none)');
}
// ---------------------------------------------------------------------------
// Emit
// ---------------------------------------------------------------------------
if (WRITE) {
const lines = [];
lines.push('// The five original NES courses, transcribed from the track maps by');
lines.push('// tools/readExcitebikeMaps.js. Do not hand-edit: re-run the tool.');
lines.push('//');
lines.push('// MEASURED off the images: course length, and the position, width and');
lines.push('// height of every ramp and every hole in the track. Hurdle letters are the');
lines.push('// nearest match in the catalogue, chosen by peak height and footprint, so');
lines.push('// the shapes are the original\'s intent expressed in the vocabulary DESIGN');
lines.push('// mode offers rather than a pixel copy of terrain the game cannot hold.');
lines.push('//');
lines.push('// NOT measured: cool zones and mud. Both are drawn as texture inside the');
lines.push('// lane band, and on these maps that texture is not separable from the');
lines.push('// shading on a ramp body — the detector fires on every ramp. Rather than');
lines.push('// emit hurdles the images do not actually support, cool zones are placed');
lines.push('// into the long clear stretches on a rule (see the tool), and mud is left');
lines.push('// out. This is the one part of the transcription that is not the');
lines.push('// original\'s, and it is worth revisiting if the maps can be read better.');
lines.push('');
lines.push('export const TRACKS = {');
for (const r of results) {
const hurdles = [];
for (const ramp of r.ramps) {
hurdles.push({ t: classifyRamp(ramp), x: ramp.x, w: ramp.width });
}
for (const g of r.gaps) {
hurdles.push({ t: gapLetter([...g.lanes]), x: g.a, w: g.b - g.a + 1 });
}
hurdles.sort((a, b) => a.x - b.x);
// Cool zones are not emitted from the maps — see the note in the header.
// They are placed into the long clear stretches between transcribed
// features, alternating halves, so heat is always manageable if you look
// for it. Everything else here is measured.
const withCool = [];
let lastEnd = 160;
let coolSide = 0;
for (const h of hurdles) {
const clear = h.x - lastEnd;
if (clear > 260) {
const at = Math.round(lastEnd + (clear - 64) / 2);
withCool.push({ t: coolSide % 2 === 0 ? 'M' : 'N', x: at });
coolSide += 1;
}
withCool.push({ t: h.t, x: h.x });
lastEnd = h.x + (HURDLES[h.t]?.profile.length ?? h.w ?? 0);
}
hurdles.length = 0;
hurdles.push(...withCool);
lines.push(` ${r.n}: {`);
lines.push(` n: ${r.n}, length: ${r.t.length}, theme: '${r.theme}', tier: ${r.n - 1},`);
lines.push(' hurdles: [');
for (let i = 0; i < hurdles.length; i += 4) {
lines.push(` ${hurdles.slice(i, i + 4).map((h) => `{ t: '${h.t}', x: ${h.x} }`).join(', ')},`);
}
lines.push(' ],');
lines.push(' rough: [],');
lines.push(' },');
}
lines.push('};');
lines.push('');
lines.push('export default TRACKS;');
const out = join(ROOT, 'tools', 'data', 'excitebikeCourses.js');
writeFileSync(out, `${lines.join('\n')}\n`);
console.log(`\nwrote ${out}`);
}