diff --git a/.eslintrc.json b/.eslintrc.json
index d7849a77..84e58b0c 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -42,6 +42,7 @@
"PULL_THRESHOLD_PX": "readonly",
"PacketFilter": "readonly",
"PathInspector": "readonly",
+ "PrefixReserved": "readonly",
"QRCode": "readonly",
"ROLE_COLORS": "readonly",
"ROLE_EMOJI": "readonly",
diff --git a/public/analytics.js b/public/analytics.js
index a198a6bb..cdef5317 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -1615,6 +1615,14 @@
html += hashMatrixLegendHtml(legendLabels);
el.innerHTML = html;
initMatrixTooltip(el);
+ // #1473 — Grey out cells whose first byte the MeshCore firmware keygen
+ // routine avoids (pub_key[0] in {0x00, 0xFF}). This is a keygen
+ // CONVENTION, not a protocol-level rejection — see firmware
+ // examples/simple_repeater/main.cpp:83 (HEAD 8ede7641). Must run BEFORE
+ // we wire click handlers so .hash-active is stripped first.
+ if (typeof PrefixReserved !== 'undefined' && PrefixReserved && typeof PrefixReserved.markReservedCells === 'function') {
+ PrefixReserved.markReservedCells(el);
+ }
el.querySelectorAll('.hash-active').forEach(td => {
td.addEventListener('click', () => {
clickHandlerFn(td);
@@ -2992,6 +3000,12 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
Generate Available Prefix
Find a prefix with zero current collisions.
+
+ 🚫
+ 0x00 and 0xFF excluded as a first byte — the MeshCore firmware keygen routine re-rolls identities whose pub_key[0] is 00 or FF, so by convention you should not see those prefixes on real nodes (see
+ simple_repeater/main.cpp:83 ).
+
1-byte
@@ -3052,6 +3066,19 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
: [{ b: input.length / 2, prefix: input }];
let html = '';
+ // #1473 — Warn when the user pastes a prefix or full pubkey whose
+ // first byte is one the MeshCore firmware keygen routine avoids
+ // (pub_key[0] in {0x00, 0xFF}). Firmware keygen CONVENTION, not a
+ // protocol-level rejection — see simple_repeater/main.cpp:83.
+ if (typeof PrefixReserved !== 'undefined' && PrefixReserved &&
+ PrefixReserved.isReservedPrefix(input)) {
+ html += `
+
⚠️ Firmware avoids this first byte
+
+ ${input.slice(0,2)} as the first byte of a node pubkey is avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose pub_key[0] is 00 or FF). You generally shouldn't see this on real nodes.
+
+
`;
+ }
if (isFullKey) {
const inNetwork = nodes.some(n => n.public_key.toUpperCase() === input);
html += `Derived prefixes: ${input.slice(0,2)} / ${input.slice(0,4)} / ${input.slice(0,6)}${!inNetwork ? ' — this node is not yet in the network ' : ''}
`;
@@ -3085,34 +3112,55 @@ function destroy() { _stopRolesRefresh(); _stopScopesRefresh(); _analyticsData =
const b = sizeInput ? parseInt(sizeInput.value) : 2;
const hexLen = b * 2;
const totalSpace = spaceSizes[b];
- const available = totalSpace - idx[b].size;
+ // #1473 — Reserved prefixes (first byte 0x00 / 0xFF) are dropped from
+ // the candidate pool because the MeshCore firmware keygen routine
+ // re-rolls identities whose pub_key[0] is 0x00 or 0xFF — a keygen
+ // CONVENTION (not a protocol rejection). See firmware
+ // examples/simple_repeater/main.cpp:83 (HEAD 8ede7641).
+ // Available = space - used - reserved.
+ const reservedTotal = (typeof PrefixReserved !== 'undefined' && PrefixReserved)
+ ? PrefixReserved.reservedCount(b)
+ : 0;
+ // Count reserved prefixes that are ALREADY used so we don't subtract them twice.
+ let reservedUsed = 0;
+ if (typeof PrefixReserved !== 'undefined' && PrefixReserved) {
+ for (const p of idx[b].keys()) {
+ if (PrefixReserved.isReservedPrefix(p)) reservedUsed++;
+ }
+ }
+ const available = totalSpace - idx[b].size - (reservedTotal - reservedUsed);
- if (available === 0) {
+ if (available <= 0) {
const next = b < 3 ? (b + 1) + '-byte' : 'a different size';
genResultEl.innerHTML = `No collision-free ${b}-byte prefixes available. Try ${next}.
`;
return;
}
+ const isReserved = (p) =>
+ (typeof PrefixReserved !== 'undefined' && PrefixReserved)
+ ? PrefixReserved.isReservedPrefix(p)
+ : false;
+
let prefix;
if (b === 1) {
- // Enumerate all 256 options
+ // Enumerate all 256 options, skipping used + reserved.
const free = [];
for (let i = 0; i < totalSpace; i++) {
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
- if (!idx[b].has(p)) free.push(p);
+ if (!idx[b].has(p) && !isReserved(p)) free.push(p);
}
prefix = free[Math.floor(Math.random() * free.length)];
} else {
- // Random sampling — with 2K used / 65K space, hit rate >96%
+ // Random sampling — with 2K used / 65K space, hit rate >96%.
let attempts = 0;
do {
prefix = Math.floor(Math.random() * totalSpace).toString(16).toUpperCase().padStart(hexLen, '0');
- } while (idx[b].has(prefix) && ++attempts < 500);
- // Fallback to enumeration if sampling kept hitting used prefixes
- if (idx[b].has(prefix)) {
+ } while ((idx[b].has(prefix) || isReserved(prefix)) && ++attempts < 500);
+ // Fallback to enumeration if sampling kept hitting used/reserved prefixes.
+ if (idx[b].has(prefix) || isReserved(prefix)) {
for (let i = 0; i < totalSpace; i++) {
const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
- if (!idx[b].has(p)) { prefix = p; break; }
+ if (!idx[b].has(p) && !isReserved(p)) { prefix = p; break; }
}
}
}
diff --git a/public/index.html b/public/index.html
index a1ed0d89..8694464f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -141,6 +141,7 @@
+
diff --git a/public/prefix-reserved.js b/public/prefix-reserved.js
new file mode 100644
index 00000000..9b860d84
--- /dev/null
+++ b/public/prefix-reserved.js
@@ -0,0 +1,113 @@
+/* === CoreScope — prefix-reserved.js =====================================
+ *
+ * Issue #1473 — Flag prefixes that the MeshCore firmware keygen routine
+ * avoids by convention.
+ *
+ * Scope (narrow, per meshcore-protocol-expert review):
+ * - This is a FIRMWARE KEYGEN CONVENTION, not a protocol-level rule.
+ * The standard repeater example re-rolls any new identity whose
+ * public-key FIRST BYTE is 0x00 or 0xFF, so in practice you should
+ * never see a node prefix of 00 or FF in the wild.
+ * - We only check the FIRST byte. Other bytes 00/FF inside a pubkey are
+ * perfectly normal (~96% of pubkeys contain a 00 or FF byte somewhere).
+ * - There is NO protocol rejection of such pubkeys and NO routing-level
+ * wildcard semantics tied to dest_hash == 0xFF.
+ *
+ * Firmware citation (HEAD 8ede7641, examples/simple_repeater/main.cpp:83):
+ *
+ * while (count < 10 && (the_mesh.self_id.pub_key[0] == 0x00
+ * || the_mesh.self_id.pub_key[0] == 0xFF)) {
+ * // reserved id hashes
+ * the_mesh.self_id = radio_new_identity(); count++;
+ * }
+ *
+ * https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83
+ *
+ * Surfaces that consume this helper:
+ * - Prefix matrix (analytics.js → renderHashMatrixFromServer, 1-byte view):
+ * grey 00 / FF cells and disable click, tooltip explains the convention.
+ * - Prefix generator (analytics.js → renderPrefixTool.doGenerate):
+ * never suggest a prefix whose first byte is 00 / FF; visible note.
+ *
+ * Reporter: @halo779 (community).
+ * ========================================================================= */
+'use strict';
+
+(function (root) {
+ // First-byte reservations as uppercase 2-char hex strings.
+ var RESERVED_FIRST_BYTES = ['00', 'FF'];
+ var RESERVED_CLASS = 'prefix-reserved';
+ var RESERVED_NOTE = '0x00 and 0xFF excluded — the MeshCore firmware keygen routine avoids these as the first byte of a node pubkey.';
+ var RESERVED_TITLE =
+ '0x00 and 0xFF as a first byte are avoided by the MeshCore firmware keygen convention (the standard repeater re-rolls identities whose pub_key[0] is 0x00 or 0xFF), so you should not pick them as a node prefix.';
+
+ function isReservedPrefix(prefix) {
+ if (prefix == null) return false;
+ var s = String(prefix);
+ if (s.length < 2) return false;
+ var head = s.slice(0, 2).toUpperCase();
+ for (var i = 0; i < RESERVED_FIRST_BYTES.length; i++) {
+ if (head === RESERVED_FIRST_BYTES[i]) return true;
+ }
+ return false;
+ }
+
+ function filterReserved(prefixes) {
+ var out = [];
+ for (var i = 0; i < prefixes.length; i++) {
+ if (!isReservedPrefix(prefixes[i])) out.push(prefixes[i]);
+ }
+ return out;
+ }
+
+ // How many prefixes of `bytes` length the reservation removes from the
+ // total space of 256^bytes. (For each reserved first byte the entire
+ // 256^(bytes-1) tail is reserved.)
+ function reservedCount(bytes) {
+ var b = Number(bytes) || 1;
+ if (b < 1) return 0;
+ return RESERVED_FIRST_BYTES.length * Math.pow(256, b - 1);
+ }
+
+ // Given a DOM root (or any object exposing querySelectorAll), find
+ // hash-matrix cells whose data-hex first byte is reserved, mark them
+ // .prefix-reserved + aria-disabled, strip .hash-active so the matrix's
+ // click wiring skips them, and set a tooltip explaining why.
+ // Returns the count of cells marked.
+ function markReservedCells(root) {
+ if (!root || typeof root.querySelectorAll !== 'function') return 0;
+ var cells = root.querySelectorAll('[data-hex]');
+ var n = 0;
+ for (var i = 0; i < cells.length; i++) {
+ var td = cells[i];
+ var hex = (typeof td.getAttribute === 'function')
+ ? td.getAttribute('data-hex')
+ : (td.dataset && td.dataset.hex);
+ if (!isReservedPrefix(hex)) continue;
+ if (td.classList && typeof td.classList.add === 'function') {
+ td.classList.add(RESERVED_CLASS);
+ td.classList.remove('hash-active');
+ }
+ if (typeof td.setAttribute === 'function') {
+ td.setAttribute('aria-disabled', 'true');
+ td.setAttribute('title', RESERVED_TITLE);
+ }
+ n++;
+ }
+ return n;
+ }
+
+ var api = {
+ RESERVED_FIRST_BYTES: RESERVED_FIRST_BYTES.slice(),
+ RESERVED_CLASS: RESERVED_CLASS,
+ RESERVED_NOTE: RESERVED_NOTE,
+ RESERVED_TITLE: RESERVED_TITLE,
+ isReservedPrefix: isReservedPrefix,
+ filterReserved: filterReserved,
+ reservedCount: reservedCount,
+ markReservedCells: markReservedCells,
+ };
+
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ if (root) root.PrefixReserved = api;
+})(typeof window !== 'undefined' ? window : globalThis);
diff --git a/public/style.css b/public/style.css
index ae53e7f9..2199275c 100644
--- a/public/style.css
+++ b/public/style.css
@@ -2407,6 +2407,26 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.hash-cell-taken { background: var(--status-green); color: #fff; }
.hash-cell-possible { background: var(--status-yellow); color: #fff; }
.hash-cell-collision { color: #fff; }
+/* #1473 — First-byte 0x00 / 0xFF pubkeys are avoided by the MeshCore
+ firmware keygen convention (NOT a protocol-level rejection):
+ https://github.com/meshcore-dev/MeshCore/blob/8ede7641/examples/simple_repeater/main.cpp#L83
+ Visually grey + strike, kill pointer events so the matrix click handler
+ skips them. Applied by public/prefix-reserved.js → markReservedCells(). */
+.hash-cell.prefix-reserved,
+.prefix-reserved {
+ background: repeating-linear-gradient(
+ 45deg,
+ var(--card-bg),
+ var(--card-bg) 4px,
+ var(--border) 4px,
+ var(--border) 8px
+ ) !important;
+ color: var(--text-muted) !important;
+ text-decoration: line-through;
+ opacity: 0.6;
+ cursor: not-allowed !important;
+ pointer-events: none;
+}
.hash-matrix-tooltip {
position: fixed; z-index: var(--z-tooltip); background: var(--surface-1); border: 1px solid var(--border);
border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); padding: 8px 12px;
diff --git a/test-all.sh b/test-all.sh
index cb82537d..ccc0be4d 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -48,6 +48,8 @@ node test-issue-1456-score-labels.js
# #1461 mobile UX overhaul + #1470 node-detail tile helper (#1468 covered by E2E)
node test-issue-1461-mobile-page-actions.js
node test-issue-1470-node-tile-helper.js
+node test-issue-1473-reserved-prefixes.js
+node test-issue-1473-prefix-generator.js
echo ""
echo "═══════════════════════════════════════"
diff --git a/test-issue-1473-prefix-generator.js b/test-issue-1473-prefix-generator.js
new file mode 100644
index 00000000..1c055756
--- /dev/null
+++ b/test-issue-1473-prefix-generator.js
@@ -0,0 +1,110 @@
+/**
+ * Issue #1473 — Prefix generator (#/analytics?tab=prefix-tool) must NOT
+ * suggest any prefix whose FIRST byte is 0x00 or 0xFF, since the MeshCore
+ * firmware keygen routine re-rolls such identities by convention (HEAD
+ * 8ede7641, examples/simple_repeater/main.cpp:83).
+ *
+ * Reporter: @halo779 (community).
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const assert = require('assert');
+
+const src = fs.readFileSync(path.join(__dirname, 'public', 'prefix-reserved.js'), 'utf8');
+const sandbox = { module: { exports: {} }, exports: {}, window: {} };
+vm.createContext(sandbox);
+vm.runInContext(src, sandbox);
+const PR = (sandbox.module.exports && sandbox.module.exports.isReservedPrefix)
+ ? sandbox.module.exports
+ : sandbox.window.PrefixReserved;
+
+let passed = 0, failed = 0;
+function test(name, fn) {
+ try { fn(); passed++; console.log(' \u2713 ' + name); }
+ catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
+}
+
+console.log('\n=== #1473: filterReserved() drops 00 and FF from 1-byte space ===');
+const all1byte = [];
+for (let i = 0; i < 256; i++) all1byte.push(i.toString(16).toUpperCase().padStart(2, '0'));
+const filtered = PR.filterReserved(all1byte);
+
+test('filtered length is 254 (256 - 2 reserved)', () => assert.strictEqual(filtered.length, 254));
+test('"00" not in filtered output', () => assert.strictEqual(filtered.indexOf('00'), -1));
+test('"FF" not in filtered output', () => assert.strictEqual(filtered.indexOf('FF'), -1));
+test('"01" still present', () => assert.ok(filtered.indexOf('01') >= 0));
+test('"FE" still present', () => assert.ok(filtered.indexOf('FE') >= 0));
+test('"A3" still present', () => assert.ok(filtered.indexOf('A3') >= 0));
+
+console.log('\n=== #1473: reservedCount() per byte length ===');
+test('1-byte reserved count = 2', () => assert.strictEqual(PR.reservedCount(1), 2));
+test('2-byte reserved count = 512', () => assert.strictEqual(PR.reservedCount(2), 512));
+test('3-byte reserved count = 131072', () => assert.strictEqual(PR.reservedCount(3), 131072));
+
+console.log('\n=== #1473: simulated generator never returns a reserved prefix ===');
+// Mirrors the production generator loop: random sampling + reserved filter.
+// We pre-seed the RNG to return the reserved hex values up front; the loop
+// MUST iterate past them and end on a non-reserved value.
+function generateOne(bytes, usedSet, rng) {
+ const totalSpace = Math.pow(256, bytes);
+ const hexLen = bytes * 2;
+ let attempts = 0, prefix;
+ do {
+ prefix = Math.floor(rng() * totalSpace).toString(16).toUpperCase().padStart(hexLen, '0');
+ } while ((usedSet.has(prefix) || PR.isReservedPrefix(prefix)) && ++attempts < 2000);
+ return prefix;
+}
+
+// Sequence that biases towards reserved first, then a valid prefix.
+const seq = [0, 255, 0, 255, 0, 0x42, 0x77, 0x10];
+let rngIdx = 0;
+const biasedRng = () => seq[rngIdx++ % seq.length] / 256;
+for (let k = 0; k < 20; k++) {
+ const out = generateOne(1, new Set(), biasedRng);
+ test('1-byte gen run #' + k + ' (' + out + ') is not reserved',
+ () => assert.strictEqual(PR.isReservedPrefix(out), false));
+}
+
+// Enumeration-style generator (fallback path in production) — first available
+// non-reserved.
+function enumerateFirstFree(bytes, usedSet) {
+ const totalSpace = Math.pow(256, bytes);
+ const hexLen = bytes * 2;
+ for (let i = 0; i < totalSpace; i++) {
+ const p = i.toString(16).toUpperCase().padStart(hexLen, '0');
+ if (!usedSet.has(p) && !PR.isReservedPrefix(p)) return p;
+ }
+ return null;
+}
+test('enumerate-first-free (1-byte, empty used) returns "01" (skips 00)',
+ () => assert.strictEqual(enumerateFirstFree(1, new Set()), '01'));
+
+// Used = everything except 00, FE, FF → must return FE, NOT 00 or FF.
+const usedAllBut3 = new Set();
+for (let i = 0; i < 256; i++) {
+ if (i !== 0x00 && i !== 0xFE && i !== 0xFF) {
+ usedAllBut3.add(i.toString(16).toUpperCase().padStart(2, '0'));
+ }
+}
+test('with only {00, FE, FF} free, generator returns "FE" (NOT 00 or FF)',
+ () => assert.strictEqual(enumerateFirstFree(1, usedAllBut3), 'FE'));
+
+console.log('\n=== #1473: analytics.js wires reserved filter into the generator ===');
+const analyticsSrc = fs.readFileSync(path.join(__dirname, 'public', 'analytics.js'), 'utf8');
+test('analytics.js references PrefixReserved (generator wiring)',
+ () => assert.ok(/PrefixReserved/.test(analyticsSrc)));
+test('analytics.js mentions the reserved-excluded note in the generator card',
+ () => assert.ok(/0x00 and 0xFF[\s\S]{0,200}excluded/i.test(analyticsSrc)));
+test('analytics.js loads prefix-reserved before analytics in index.html',
+ () => {
+ const html = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
+ const pri = html.indexOf('prefix-reserved.js');
+ const ani = html.indexOf('analytics.js');
+ assert.ok(pri > 0 && ani > 0 && pri < ani, 'prefix-reserved.js must load before analytics.js');
+ });
+
+console.log('\n' + passed + ' passed, ' + failed + ' failed');
+if (failed) process.exit(1);
diff --git a/test-issue-1473-reserved-prefixes.js b/test-issue-1473-reserved-prefixes.js
new file mode 100644
index 00000000..943792a8
--- /dev/null
+++ b/test-issue-1473-reserved-prefixes.js
@@ -0,0 +1,106 @@
+/**
+ * Issue #1473 — The MeshCore firmware keygen routine re-rolls any new
+ * identity whose public-key first byte is 0x00 or 0xFF — a KEYGEN
+ * CONVENTION (not a protocol-level rule). Firmware citation (HEAD
+ * 8ede7641, examples/simple_repeater/main.cpp:83):
+ *
+ * while (... && (pub_key[0] == 0x00 || pub_key[0] == 0xFF)) { ... }
+ *
+ * Only the FIRST byte is checked — internal 00 / FF bytes are normal.
+ *
+ * This test pins:
+ * - isReservedPrefix() semantics (case-insensitive, first byte only)
+ * - markReservedCells() applies .prefix-reserved + disables click on matrix
+ *
+ * Reporter: @halo779 (community).
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const assert = require('assert');
+
+// Load prefix-reserved.js as a CommonJS module under vm.
+const src = fs.readFileSync(path.join(__dirname, 'public', 'prefix-reserved.js'), 'utf8');
+const sandbox = { module: { exports: {} }, exports: {}, window: {} };
+vm.createContext(sandbox);
+vm.runInContext(src, sandbox);
+const PR = (sandbox.module.exports && sandbox.module.exports.isReservedPrefix)
+ ? sandbox.module.exports
+ : sandbox.window.PrefixReserved;
+
+let passed = 0, failed = 0;
+function test(name, fn) {
+ try { fn(); passed++; console.log(' \u2713 ' + name); }
+ catch (e) { failed++; console.error(' \u2717 ' + name + ': ' + e.message); }
+}
+
+console.log('\n=== #1473: isReservedPrefix() ===');
+test('"00" is reserved', () => assert.strictEqual(PR.isReservedPrefix('00'), true));
+test('"FF" is reserved', () => assert.strictEqual(PR.isReservedPrefix('FF'), true));
+test('lowercase "ff" is reserved', () => assert.strictEqual(PR.isReservedPrefix('ff'), true));
+test('lowercase "00" is reserved', () => assert.strictEqual(PR.isReservedPrefix('00'), true));
+test('"01" is NOT reserved', () => assert.strictEqual(PR.isReservedPrefix('01'), false));
+test('"FE" is NOT reserved', () => assert.strictEqual(PR.isReservedPrefix('FE'), false));
+test('"A3" is NOT reserved', () => assert.strictEqual(PR.isReservedPrefix('A3'), false));
+test('2-byte "0042" reserved (first byte = 00)', () => assert.strictEqual(PR.isReservedPrefix('0042'), true));
+test('2-byte "FF01" reserved (first byte = FF)', () => assert.strictEqual(PR.isReservedPrefix('FF01'), true));
+test('2-byte "01FF" NOT reserved (first byte = 01)', () => assert.strictEqual(PR.isReservedPrefix('01FF'), false));
+test('3-byte "001234" reserved', () => assert.strictEqual(PR.isReservedPrefix('001234'), true));
+test('3-byte "FFAABB" reserved', () => assert.strictEqual(PR.isReservedPrefix('FFAABB'), true));
+test('empty string NOT reserved', () => assert.strictEqual(PR.isReservedPrefix(''), false));
+
+console.log('\n=== #1473: markReservedCells() on a mock 1-byte matrix ===');
+function mkCell(hex) {
+ const classes = new Set(['hash-cell']);
+ const attrs = { 'data-hex': hex };
+ return {
+ getAttribute: k => attrs[k],
+ setAttribute: (k, v) => { attrs[k] = String(v); },
+ classList: {
+ add: c => classes.add(c),
+ remove: c => classes.delete(c),
+ contains: c => classes.has(c),
+ },
+ _attrs: attrs,
+ _classes: classes,
+ };
+}
+
+const cells = [];
+for (let i = 0; i < 256; i++) {
+ const hex = i.toString(16).toUpperCase().padStart(2, '0');
+ cells.push(mkCell(hex));
+}
+cells[0].classList.add('hash-active');
+cells[255].classList.add('hash-active');
+cells[10].classList.add('hash-active');
+
+const root = { querySelectorAll: () => cells };
+const marked = PR.markReservedCells(root);
+
+test('markReservedCells returned 2 (00 and FF)', () => assert.strictEqual(marked, 2));
+test('cell 00 has .prefix-reserved', () => assert.strictEqual(cells[0]._classes.has('prefix-reserved'), true));
+test('cell FF has .prefix-reserved', () => assert.strictEqual(cells[255]._classes.has('prefix-reserved'), true));
+test('cell 01 does NOT have .prefix-reserved', () => assert.strictEqual(cells[1]._classes.has('prefix-reserved'), false));
+test('cell A3 does NOT have .prefix-reserved', () => assert.strictEqual(cells[0xA3]._classes.has('prefix-reserved'), false));
+test('cell FE does NOT have .prefix-reserved', () => assert.strictEqual(cells[0xFE]._classes.has('prefix-reserved'), false));
+test('cell 00 had .hash-active removed (no click handler attached)', () => assert.strictEqual(cells[0]._classes.has('hash-active'), false));
+test('cell FF had .hash-active removed', () => assert.strictEqual(cells[255]._classes.has('hash-active'), false));
+test('cell 10 still has .hash-active (untouched)', () => assert.strictEqual(cells[10]._classes.has('hash-active'), true));
+test('cell 00 has aria-disabled=true', () => assert.strictEqual(cells[0]._attrs['aria-disabled'], 'true'));
+test('cell FF has aria-disabled=true', () => assert.strictEqual(cells[255]._attrs['aria-disabled'], 'true'));
+test('cell 00 title cites MeshCore firmware keygen', () => assert.ok(/MeshCore firmware keygen/i.test(cells[0]._attrs.title)));
+test('cell FF title cites MeshCore firmware keygen', () => assert.ok(/MeshCore firmware keygen/i.test(cells[255]._attrs.title)));
+
+console.log('\n=== #1473: prefix-reserved.js loaded by index.html ===');
+const indexHtml = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8');
+test('index.html includes prefix-reserved.js script', () => assert.ok(/prefix-reserved\.js/.test(indexHtml)));
+const styleCss = fs.readFileSync(path.join(__dirname, 'public', 'style.css'), 'utf8');
+test('style.css defines .prefix-reserved', () => assert.ok(/\.prefix-reserved\b/.test(styleCss)));
+test('style.css disables pointer events on reserved cell',
+ () => assert.ok(/\.prefix-reserved[\s\S]{0,400}pointer-events:\s*none/i.test(styleCss)));
+
+console.log('\n' + passed + ' passed, ' + failed + ' failed');
+if (failed) process.exit(1);