fix(#309): decode and encode path_len per firmware, not as a byte count
The firmware path-len byte is packed and self-describing (src/Packet.h:79-84): high 2 bits = hash size - 1, low 6 bits = hash COUNT (hops), byte length = count * size. The app treated the low 6 bits as a byte count and ignored the width, on the strength of a comment asserting the high bits were "not reliably populated". Both halves of that comment were wrong. Receive: Contact.fromFrame read `count` bytes where firmware meant `count` hops, so at 2-byte width it kept HALF of every path and discarded the rest. DAYTON VA's 2-hop route became a 2-byte fragment, which is why every repeater login in Bandit's capture timed out. Send: buildUpdateContactPathFrame wrote the count raw, leaving mode bits 00. That tells the radio "1-byte hashes" while handing it 2-byte hash data, so it routed to nodes never on the route. This is the likelier cause of the observed "routes via c6 and 5c separately" behaviour. Also fixes a third unit inconsistency: setContactPath wrote customPath.length (bytes) into pathLength (hops) after every path set. Contact now carries pathHashWidth per path, so a stored path can never be re-sliced at a width it was not captured at. realHopCount() is removed rather than adjusted: it divided an already-correct hop count, halving it at 2-byte width, and every call site was compensating for a bug that no longer exists. Migration (Ben's call, option A): records written before this change have no pathHashWidth and hold truncated bytes that cannot be recovered by reinterpretation, so their path is dropped and the contact reverts to flood until the radio re-supplies it. Logged, not silent. Tests asserted the old semantics (expect(realHopCount(2, 2), 1)) and were rewritten against firmware truth rather than adjusted to keep passing. Added send-side encoding coverage, which did not exist. 456 tests pass. Refs #240, #279, #299 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pull/317/head
parent
ac877ba904
commit
b28f3b36cb
@ -1,32 +1,91 @@
|
||||
// Path-hash width / hop-count decode (#112). The firmware path-length byte is a
|
||||
// BYTE count of the hop-hash array, not a hop count, so at a 2-byte hash width a
|
||||
// single 2-byte hop reports 2. realHopCount divides by the device width to get
|
||||
// true hops; the Packet Path screen slices and matches at the device width so a
|
||||
// 2-byte hash like 6A3D resolves to one repeater instead of two 1-byte hops.
|
||||
// Path-len byte encode/decode (#309, supersedes #112/#222).
|
||||
//
|
||||
// The firmware path-len byte is PACKED and self-describing:
|
||||
// high 2 bits = hash size - 1 (1..3 bytes per hop hash)
|
||||
// low 6 bits = hash COUNT, i.e. the number of HOPS
|
||||
// byte length = count * size
|
||||
//
|
||||
// Verified against firmware src/Packet.h:79-84:
|
||||
// getPathHashSize() == (path_len >> 6) + 1
|
||||
// getPathHashCount() == path_len & 63
|
||||
// getPathByteLen() == getPathHashCount() * getPathHashSize()
|
||||
// setPathHashSizeAndCount(sz, n) { path_len = ((sz - 1) << 6) | (n & 63); }
|
||||
//
|
||||
// This file previously asserted the opposite (low 6 bits = a BYTE count, and a
|
||||
// realHopCount() that divided by the device width). Those assertions encoded the
|
||||
// bug: the app read `count` bytes where firmware meant `count` hops, keeping
|
||||
// half of every path at 2-byte width, and halved every displayed hop count.
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
|
||||
void main() {
|
||||
group('realHopCount', () {
|
||||
test('divides path byte-length by hash width', () {
|
||||
// The bug: one 2-byte hop (6A3D) reported byteLen 2 -> shown as "2 hops".
|
||||
expect(realHopCount(2, 2), 1);
|
||||
expect(realHopCount(4, 2), 2);
|
||||
expect(realHopCount(6, 3), 2);
|
||||
group('path-len decode', () {
|
||||
test('low 6 bits are a HOP count, not a byte count', () {
|
||||
// 0x42 = 2-byte hashes, 2 hops. The old model called this "2 bytes".
|
||||
expect(pathHopCount(0x42), 2);
|
||||
expect(pathHashSizeBytes(0x42), 2);
|
||||
expect(pathByteLength(0x42), 4); // 2 hops * 2 bytes
|
||||
});
|
||||
|
||||
test('is a no-op at 1-byte width (no regression for legacy nets)', () {
|
||||
expect(realHopCount(3, 1), 3);
|
||||
test('one 2-byte hop is 1 hop occupying 2 bytes', () {
|
||||
// Bandit's C65C case: ONE hop through a 2-byte-prefixed repeater.
|
||||
expect(pathHopCount(0x41), 1);
|
||||
expect(pathHashSizeBytes(0x41), 2);
|
||||
expect(pathByteLength(0x41), 2);
|
||||
});
|
||||
|
||||
test('treats width < 1 as 1', () {
|
||||
expect(realHopCount(3, 0), 3);
|
||||
test('1-byte width is unchanged (legacy nets)', () {
|
||||
expect(pathHopCount(0x03), 3);
|
||||
expect(pathHashSizeBytes(0x03), 1);
|
||||
expect(pathByteLength(0x03), 3);
|
||||
});
|
||||
|
||||
test('passes null (unknown) and negative (flood) sentinels through', () {
|
||||
expect(realHopCount(null, 2), isNull);
|
||||
expect(realHopCount(-1, 2), -1);
|
||||
test('3-byte width', () {
|
||||
// 0x82 = size 3, 2 hops -> 6 bytes.
|
||||
expect(pathHashSizeBytes(0x82), 3);
|
||||
expect(pathHopCount(0x82), 2);
|
||||
expect(pathByteLength(0x82), 6);
|
||||
});
|
||||
|
||||
test('direct (zero-hop) at 2-byte mode carries no path bytes', () {
|
||||
// 0x40 = size 2, 0 hops. Reading the raw byte as a length would have
|
||||
// pulled 64 junk bytes into the path.
|
||||
expect(pathHopCount(0x40), 0);
|
||||
expect(pathByteLength(0x40), 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('encodePathLen', () {
|
||||
test('packs width and hop count the way firmware does', () {
|
||||
expect(encodePathLen(2, 2), 0x42);
|
||||
expect(encodePathLen(1, 2), 0x41);
|
||||
expect(encodePathLen(3, 1), 0x03);
|
||||
expect(encodePathLen(2, 3), 0x82);
|
||||
expect(encodePathLen(0, 2), 0x40);
|
||||
});
|
||||
|
||||
test('round-trips through the decoders', () {
|
||||
for (final width in [1, 2, 3]) {
|
||||
for (final hops in [0, 1, 5, 63]) {
|
||||
final encoded = encodePathLen(hops, width);
|
||||
expect(pathHopCount(encoded), hops, reason: 'hops w=$width');
|
||||
expect(pathHashSizeBytes(encoded), width, reason: 'width w=$width');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('clamps width to the 1..3 firmware range (mode 3 is reserved)', () {
|
||||
expect(pathHashSizeBytes(encodePathLen(1, 0)), 1);
|
||||
expect(pathHashSizeBytes(encodePathLen(1, 9)), 3);
|
||||
});
|
||||
|
||||
test('a bare hop count would mislabel the width as 1 byte', () {
|
||||
// The send-side bug: writing the count raw leaves mode bits 00, telling
|
||||
// the radio "1-byte hashes" while handing it 2-byte hash data.
|
||||
const rawCount = 2;
|
||||
expect(pathHashSizeBytes(rawCount), 1); // what the radio would have read
|
||||
expect(pathHashSizeBytes(encodePathLen(2, 2)), 2); // what it must read
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in new issue