// 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; } /** * Surface markings on flat ground. * * The first pass at this failed because it looked at every column: a ramp's * body is full of the shade colour and its crest is full of the highlight, so * the detector fired on every ramp and produced a mud pit under each one. The * fix is to mask out the columns a ramp stands on and only read what is left, * which is genuinely flat track. * * The two markings then separate by colour, the way they do on screen: cool * zones are chevrons painted in the course's highlight, mud is churned ground * in its shade. */ function findMarkings(img, t, ramps) { const covered = new Uint8Array(t.length); for (const r of ramps) { for (let i = Math.max(0, r.x - 2); i < Math.min(t.length, r.x + r.width + 2); i += 1) { covered[i] = 1; } } const rank = [...t.material]; const shade = rank.find((c) => c !== t.surface) ?? t.surface; const highlight = rank.filter((c) => c !== t.surface && c !== shade).pop() ?? null; const scan = (colour, minDensity) => { 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) { if (covered[i]) { flags[i] = false; continue; } let k = 0; for (let y = y0; y < y0 + LANE_H; y += 1) { if (pixelAt(img, t.first + i, y) === colour) k += 1; } // The lane divider contributes about a row on its own; only something // clearly denser than that is a marking. flags[i] = k >= minDensity; } perLane.push(runsOf(flags, 12)); } return perLane; }; return { mud: scan(shade, 4), cool: highlight ? scan(highlight, 3) : [[], [], [], []], }; } // --------------------------------------------------------------------------- // 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' }; /** Fold per-lane runs that overlap in x into single multi-lane features. */ function mergeLaneRuns(perLane) { const events = []; for (let lane = 0; lane < LANE_COUNT; lane += 1) { for (const [a, b] of perLane[lane]) events.push({ a, b, lane }); } events.sort((p, q) => p.a - q.a); const out = []; for (const ev of events) { const open = out.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 { out.push({ a: ev.a, b: ev.b, lanes: new Set([ev.lane]) }); } } return out; } 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 marks = findMarkings(img, t, ramps); // 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 mud = mergeLaneRuns(marks.mud); const cool = mergeLaneRuns(marks.cool); return { n, img, t, ramps, gaps, mud, cool, 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}` + ` mud ${r.mud.length} cool zones ${r.cool.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)'); const fmt = (rows) => rows.slice(0, 10).map((g) => `${g.a}+${g.b - g.a + 1}[${[...g.lanes].sort().join('')}]`).join(' ') || '(none)'; console.log(' mud: ', fmt(r.mud)); console.log(' cool: ', fmt(r.cool)); } // --------------------------------------------------------------------------- // 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}`); }