diff --git a/public/packets.js b/public/packets.js
index 4c8e6fca..2b91f497 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -2244,7 +2244,12 @@
const groupTypeClass = payloadTypeColor(p.payload_type);
const groupSize = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const _grpPlOff = getPathLenOffset(p.route_type);
- const groupHashBytes = ((parseInt(p.raw_hex?.slice(_grpPlOff * 2, _grpPlOff * 2 + 2), 16) || 0) >> 6) + 1;
+ // #1849: TRACE (payload_type=9) header path bytes are per-hop SNR readings,
+ // not truncated hop hashes (see internal/packetpath/route.go PathBytesAreHops).
+ // The (>>6)+1 derivation is meaningless for TRACE — render — with a tooltip.
+ const _grpIsTrace = p.payload_type === 9;
+ const groupHashBytes = _grpIsTrace ? '—' : (((parseInt(p.raw_hex?.slice(_grpPlOff * 2, _grpPlOff * 2 + 2), 16) || 0) >> 6) + 1);
+ const _grpHashSizeTitle = _grpIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : '';
const isSingle = p.count <= 1;
// Channel color highlighting (#271)
const _grpDecoded = getParsedDecoded(p) || {};
@@ -2257,7 +2262,7 @@
${renderTimestampCell(p.latest)} |
${truncate(p.hash || '—', 8)} |
${groupSize ? groupSize + 'B' : '—'} |
- ${groupHashBytes} |
+ ${groupHashBytes} |
${p.payload_type != null ? `${groupTypeName}${transportBadge(p.route_type)}` : '—'} |
${isSingle ? escapeHtml(truncate(obsNameOnly(headerObserverId), 16)) + obsIataBadge(p) : escapeHtml(truncate(obsNameOnly(headerObserverId), 10)) + groupedObserverIataBadgesHtml(p)} |
${groupPathStr} |
@@ -2275,9 +2280,14 @@
const size = c.raw_hex ? Math.floor(c.raw_hex.length / 2) : 0;
const childPath = getParsedPath(c);
const _cPlOff = getPathLenOffset(p.route_type);
- const childHashBytes = c.raw_hex
- ? (((parseInt(c.raw_hex.slice(_cPlOff * 2, _cPlOff * 2 + 2), 16) || 0) >> 6) + 1)
- : (childPath.length > 0 ? childPath[0].length / 2 : 0);
+ // #1849: TRACE header path bytes are SNR readings, not hop hashes.
+ const _cIsTrace = c.payload_type === 9;
+ const childHashBytes = _cIsTrace
+ ? '—'
+ : (c.raw_hex
+ ? (((parseInt(c.raw_hex.slice(_cPlOff * 2, _cPlOff * 2 + 2), 16) || 0) >> 6) + 1)
+ : (childPath.length > 0 ? childPath[0].length / 2 : 0));
+ const _cHashSizeTitle = _cIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : '';
const childRegion = c.observer_id ? (observerMap.get(c.observer_id)?.iata || '') : '';
const childPathStr = renderPath(childPath, c.observer_id);
const _childHashStripe = _hashStripeStyle(c.hash || p.hash);
@@ -2286,7 +2296,7 @@
${renderTimestampCell(c.timestamp)} |
${truncate(c.hash || '', 8)} |
${size}B |
- ${childHashBytes} |
+ ${childHashBytes} |
${typeName}${transportBadge(c.route_type)} |
${escapeHtml(truncate(obsNameOnly(c.observer_id), 16))}${obsIataBadge(c)} |
${childPathStr} |
@@ -2309,7 +2319,10 @@
const _chanStyle = window.ChannelColors ? window.ChannelColors.getRowStyle(decoded.type || typeName, decoded.channel) : '';
const size = p.raw_hex ? Math.floor(p.raw_hex.length / 2) : 0;
const _flatPlOff = getPathLenOffset(p.route_type);
- const hashBytes = ((parseInt(p.raw_hex?.slice(_flatPlOff * 2, _flatPlOff * 2 + 2), 16) || 0) >> 6) + 1;
+ // #1849: TRACE path bytes = SNR readings, not hop hashes.
+ const _flatIsTrace = p.payload_type === 9;
+ const hashBytes = _flatIsTrace ? '—' : (((parseInt(p.raw_hex?.slice(_flatPlOff * 2, _flatPlOff * 2 + 2), 16) || 0) >> 6) + 1);
+ const _flatHashSizeTitle = _flatIsTrace ? ' title="TRACE path bytes are SNR readings, not hash prefixes — see sidebar decoder for actual hop count"' : '';
const pathStr = renderPath(pathHops, p.observer_id);
const detail = getDetailPreview(decoded);
const _flatHashStripe = _hashStripeStyle(p.hash);
@@ -2319,7 +2332,7 @@
${renderTimestampCell(p.timestamp)} |
${truncate(p.hash || String(p.id), 8)} |
${size}B |
- ${hashBytes} |
+ ${hashBytes} |
${typeName}${transportBadge(p.route_type)} |
${escapeHtml(truncate(obsNameOnly(p.observer_id), 16))}${obsIataBadge(p)} |
${pathStr} |
diff --git a/test-all.sh b/test-all.sh
index a80e4c13..914fcee0 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -65,6 +65,7 @@ node test-naive-banner-tone.js
node test-issue-1473-reserved-prefixes.js
node test-issue-1473-prefix-generator.js
node test-issue-1770-mobile-row-clamp.js
+node test-issue-1849-trace-hashbytes.js
echo ""
echo "═══════════════════════════════════════"
diff --git a/test-issue-1849-trace-hashbytes.js b/test-issue-1849-trace-hashbytes.js
new file mode 100644
index 00000000..85307d5a
--- /dev/null
+++ b/test-issue-1849-trace-hashbytes.js
@@ -0,0 +1,151 @@
+/**
+ * #1849 — "Hop Bytes" (col-hashsize) column shows misleading `1` for TRACE packets.
+ *
+ * TRACE packets (payload_type=9) use header path bytes as per-hop SNR readings, not
+ * truncated hop hashes (see internal/packetpath/route.go PathBytesAreHops(TRACE)=false).
+ * The high-2-bit "hash_size" derivation is meaningless for TRACE.
+ *
+ * Fix: at the 3 render sites in public/packets.js (buildGroupRowHtml header, its child
+ * rows, buildFlatRowHtml), when payload_type === 9 render `—` in col-hashsize with an
+ * explanatory title tooltip. Non-TRACE behavior unchanged.
+ */
+'use strict';
+const vm = require('vm');
+const fs = require('fs');
+const assert = require('assert');
+
+let passed = 0, failed = 0;
+function test(name, fn) {
+ try { fn(); passed++; console.log(' ✅ ' + name); }
+ catch (e) { failed++; console.log(' ❌ ' + name + ': ' + e.message); }
+}
+
+// Reuse the sandbox construction from test-packets.js (kept in sync intentionally).
+function makeSandbox() {
+ const registeredPages = {};
+ const ctx = {
+ window: {
+ addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => {},
+ innerWidth: 1200, PacketFilter: null,
+ },
+ document: {
+ readyState: 'complete',
+ createElement: () => ({ id: '', textContent: '', innerHTML: '', className: '', style: {},
+ appendChild: () => {}, setAttribute: () => {}, addEventListener: () => {},
+ querySelectorAll: () => [], querySelector: () => null,
+ classList: { add: () => {}, remove: () => {}, contains: () => false } }),
+ head: { appendChild: () => {} }, getElementById: () => null,
+ addEventListener: () => {}, removeEventListener: () => {},
+ querySelectorAll: () => [], querySelector: () => null, body: { appendChild: () => {} },
+ },
+ console, Date, Infinity, Math, Array, Object, String, Number, JSON, RegExp,
+ Error, TypeError, RangeError, parseInt, parseFloat, isNaN, isFinite,
+ encodeURIComponent, decodeURIComponent,
+ setTimeout: () => {}, clearTimeout: () => {}, setInterval: () => {}, clearInterval: () => {},
+ fetch: () => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }),
+ performance: { now: () => Date.now() },
+ localStorage: (() => { const s = {}; return {
+ getItem: k => s[k] || null, setItem: (k, v) => { s[k] = String(v); }, removeItem: k => { delete s[k]; },
+ }; })(),
+ location: { hash: '' }, history: { replaceState: () => {} },
+ CustomEvent: class CustomEvent {}, Map, Set, Promise, URLSearchParams,
+ addEventListener: () => {}, removeEventListener: () => {}, dispatchEvent: () => {},
+ requestAnimationFrame: (cb) => setTimeout(cb, 0),
+ registerPage: (name, handler) => { registeredPages[name] = handler; },
+ };
+ vm.createContext(ctx);
+ return ctx;
+}
+
+function loadInCtx(ctx, file) {
+ vm.runInContext(fs.readFileSync(file, 'utf8'), ctx, { filename: file });
+ for (const k of Object.keys(ctx.window)) { ctx[k] = ctx.window[k]; }
+}
+
+function loadPacketsSandbox() {
+ const ctx = makeSandbox();
+ loadInCtx(ctx, 'public/payload-labels.js');
+ loadInCtx(ctx, 'public/roles.js');
+ loadInCtx(ctx, 'public/app.js');
+ loadInCtx(ctx, 'public/packet-helpers.js');
+ vm.runInContext(`
+ window.HopDisplay = {
+ renderHop: function(h, entry, opts) { return '' + h + ''; },
+ _showFromBtn: function() {}
+ };
+ `, ctx);
+ loadInCtx(ctx, 'public/packets.js');
+ return ctx;
+}
+
+console.log('\n=== #1849: TRACE col-hashsize renders — not misleading integer ===');
+{
+ const ctx = loadPacketsSandbox();
+ const api = ctx._packetsTestAPI;
+ assert(api, '_packetsTestAPI must be exposed');
+
+ test('buildFlatRowHtml: TRACE (payload_type=9) renders — in col-hashsize with title tooltip', () => {
+ const p = {
+ id: 100, hash: 'trace1', timestamp: '', observer_id: null,
+ raw_hex: '00090000000000000000000102030405', payload_type: 9,
+ route_type: 0, decoded_json: '{}', path_json: '[]'
+ };
+ const html = api.buildFlatRowHtml(p);
+ const m = html.match(/]*)>([^<]*)<\/td>/);
+ assert(m, 'col-hashsize cell must be present. Got: ' + html.slice(0, 500));
+ assert.strictEqual(m[2].trim(), '—',
+ 'TRACE col-hashsize must be — (em-dash), got: ' + JSON.stringify(m[2]));
+ assert(/title="[^"]*TRACE[^"]*"/.test(m[1]),
+ 'col-hashsize | must carry a title attribute mentioning TRACE. Got attrs: ' + m[1]);
+ });
+
+ test('buildFlatRowHtml: non-TRACE (payload_type=0) still renders numeric hashBytes', () => {
+ const p = {
+ id: 101, hash: 'adv1', timestamp: '', observer_id: null,
+ raw_hex: '00000000000000000000C100', payload_type: 0,
+ route_type: 0, decoded_json: '{}', path_json: '[]'
+ };
+ const html = api.buildFlatRowHtml(p);
+ const m = html.match(/ | ]*>([^<]*)<\/td>/);
+ assert(m, 'col-hashsize cell must be present');
+ assert(/^\d+$/.test(m[1].trim()),
+ 'non-TRACE col-hashsize must be numeric, got: ' + JSON.stringify(m[1]));
+ });
+
+ test('buildGroupRowHtml: TRACE header row (payload_type=9) renders — in col-hashsize with title', () => {
+ const p = {
+ hash: 'trg1', count: 1, latest: '',
+ observer_id: null, raw_hex: '00090000000000000000000102030405',
+ payload_type: 9, route_type: 0, decoded_json: '{}', path_json: '[]',
+ observation_count: 1, observer_count: 1,
+ };
+ const html = api.buildGroupRowHtml(p);
+ const m = html.match(/ | ]*)>([^<]*)<\/td>/);
+ assert(m, 'col-hashsize cell must be present');
+ assert.strictEqual(m[2].trim(), '—',
+ 'TRACE group header col-hashsize must be —, got: ' + JSON.stringify(m[2]));
+ assert(/title="[^"]*TRACE[^"]*"/.test(m[1]),
+ 'col-hashsize | must carry title mentioning TRACE');
+ });
+
+ test('buildGroupRowHtml: non-TRACE header row still numeric', () => {
+ const p = {
+ hash: 'grp1', count: 1, latest: '',
+ observer_id: null, raw_hex: '00000000000000000000C100',
+ payload_type: 0, route_type: 0, decoded_json: '{}', path_json: '[]',
+ observation_count: 1, observer_count: 1,
+ };
+ const html = api.buildGroupRowHtml(p);
+ const m = html.match(/ | ]*>([^<]*)<\/td>/);
+ assert(m, 'col-hashsize cell must be present');
+ assert(/^\d+$/.test(m[1].trim()),
+ 'non-TRACE group col-hashsize must be numeric, got: ' + JSON.stringify(m[1]));
+ });
+}
+
+console.log('');
+if (failed > 0) {
+ console.error(`❌ ${failed} test(s) failed, ${passed} passed`);
+ process.exit(1);
+}
+console.log(`✅ All ${passed} tests passed`);
|