From 4deff5b687c090bcb279234fa7dedbc6a557c2df Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 23 Oct 2026 09:32:02 -0700 Subject: [PATCH] Pack RAK Full help text at compile time without changing output --- .../companion_radio/CompanionHelpWriter.h | 113 ++++++++++++++++++ examples/companion_radio/MyMesh.cpp | 16 ++- test/test_companion_help_writer.py | 38 +++++- 3 files changed, 162 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/CompanionHelpWriter.h b/examples/companion_radio/CompanionHelpWriter.h index bb26d1412..5cbe73767 100644 --- a/examples/companion_radio/CompanionHelpWriter.h +++ b/examples/companion_radio/CompanionHelpWriter.h @@ -22,4 +22,117 @@ size_t writeHelpChunks(Output& output, const char* text, size_t length) { return written; } +namespace help_packing { + +// Help is ASCII. Bytes 0x80..0x8f encode these common fragments; the original +// text exists only during constant evaluation, never alongside the packed data. +constexpr size_t TokenCount = 16; +constexpr char Dictionary[] = + " get/set \0" " get \0" " set \0" "get/set \0" + "bluetooth.\0" "pwrmgt.\0" "radio.\0" "default\0" + "preamble\0" "on|off\0" " (saved; \0" " \0" + "get \0" "set \0" "\r\n\0" " / \0"; + +struct TokenOffsets { uint8_t value[TokenCount + 1] = {}; }; +constexpr TokenOffsets tokenOffsets() { + TokenOffsets result{}; + size_t offset = 0; + for (size_t token = 0; token < TokenCount; ++token) { + result.value[token] = static_cast(offset); + while (Dictionary[offset]) ++offset; + ++offset; + } + result.value[TokenCount] = static_cast(offset); + return result; +} +static_assert(sizeof(Dictionary) < 256, "Help dictionary offsets need one byte"); +constexpr TokenOffsets Offsets = tokenOffsets(); +constexpr size_t tokenLength(size_t token) { + return Offsets.value[token + 1] - Offsets.value[token] - 1; +} +constexpr size_t matchToken(const char* text, size_t remaining) { + for (size_t token = 0; token < TokenCount; ++token) { + const char* fragment = Dictionary + Offsets.value[token]; + const size_t length = tokenLength(token); + if (length > remaining || *text != *fragment) continue; + size_t i = 1; + while (i < length && text[i] == fragment[i]) ++i; + if (i == length) return token; + } + return TokenCount; +} + +} // namespace help_packing + +template +constexpr bool validHelpText(const char (&text)[N]) { + if (text[N - 1] != 0) return false; + for (size_t i = 0; i + 1 < N; ++i) { + if (text[i] == 0 || static_cast(text[i]) >= 0x80) return false; + } + return true; +} + +template +constexpr size_t packedHelpSize(const char (&text)[N]) { + size_t packed = 0; + for (size_t i = 0; i + 1 < N; ++packed) { + const size_t token = help_packing::matchToken(text + i, N - i - 1); + i += token < help_packing::TokenCount ? help_packing::tokenLength(token) : 1; + } + return packed; +} + +template struct PackedHelp { + static_assert(N > 0, "A help image must contain text"); + uint8_t bytes[N] = {}; +}; + +template +constexpr PackedHelp packHelp(const char (&text)[N]) { + PackedHelp result{}; + size_t packed = 0; + for (size_t i = 0; i + 1 < N;) { + const size_t token = help_packing::matchToken(text + i, N - i - 1); + if (token < help_packing::TokenCount) { + result.bytes[packed++] = static_cast(0x80 + token); + i += help_packing::tokenLength(token); + } else { + result.bytes[packed++] = static_cast(text[i++]); + } + } + return result; +} + +template +size_t writePackedHelp(Output& output, const uint8_t* packed, size_t length) { + if (!packed) return 0; + size_t position = 0, written = 0; + const char* expansion = nullptr; + while (position < length || expansion) { + char chunk[64]; + size_t used = 0; + while (used < sizeof(chunk) && (position < length || expansion)) { + if (expansion) { + const char next = *expansion++; + if (next) chunk[used++] = next; + else expansion = nullptr; + } else { + const uint8_t next = packed[position++]; + if (next >= 0x80) { + const size_t token = next - 0x80; + if (token >= help_packing::TokenCount) return written; + expansion = help_packing::Dictionary + help_packing::Offsets.value[token]; + } else { + chunk[used++] = static_cast(next); + } + } + } + const size_t accepted = writeHelpChunks(output, chunk, used); + written += accepted; + if (accepted != used) break; + } + return written; +} + } } // namespace mesh::companion diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index dd9140086..87e6104ea 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -10405,8 +10405,10 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, } else if (strcmp(command, "help") == 0) { auto& output = terminalOutput(); #if defined(RAK_4631) && defined(COMPANION_RADIO_FULL) - // Keep every help byte and feature gate while sharing the printing code. - static const char help_text[] = + // Constant evaluation retains only the exact-sized packed help image. + // The source fragments and every feature gate remain shared below. + static constexpr auto packed_help = []() constexpr { + constexpr char help_text[] = #define MESH_COMPANION_HELP_BEGIN #define MESH_COMPANION_HELP_END #define MESH_COMPANION_HELP_LINE_BEGIN @@ -10416,8 +10418,14 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, #undef MESH_COMPANION_HELP_END #undef MESH_COMPANION_HELP_LINE_BEGIN #undef MESH_COMPANION_HELP_LINE_END - ; - mesh::companion::writeHelpChunks(output, help_text, sizeof(help_text) - 1); + ; + static_assert(mesh::companion::validHelpText(help_text), + "Packed help requires ASCII without embedded NUL bytes"); + return mesh::companion::packHelp< + mesh::companion::packedHelpSize(help_text)>(help_text); + }(); + mesh::companion::writePackedHelp(output, packed_help.bytes, + sizeof(packed_help.bytes)); #else #define MESH_COMPANION_HELP_BEGIN output.print( #define MESH_COMPANION_HELP_END ); diff --git a/test/test_companion_help_writer.py b/test/test_companion_help_writer.py index f8bfb70bf..d021372b4 100644 --- a/test/test_companion_help_writer.py +++ b/test/test_companion_help_writer.py @@ -97,6 +97,39 @@ int main() { Output empty; assert(mesh::companion::writeHelpChunks(empty,nullptr,1)==0); assert(mesh::companion::writeHelpChunks(empty,"",0)==0 && empty.writes.empty()); + + constexpr char literal[] = + " get/set radio.preamble on|off\r\n" + " get bluetooth.name / get pwrmgt.source\r\n" + " set radio.cad on (saved; default on)\r\n"; + static_assert(mesh::companion::validHelpText(literal), "ASCII accepted"); + static_assert(!mesh::companion::validHelpText("bad\x80"), "Token bytes reserved"); + static_assert(!mesh::companion::validHelpText("a\0b"), "Embedded NUL rejected"); + static constexpr auto packed = mesh::companion::packHelp< + mesh::companion::packedHelpSize(literal)>(literal); + static_assert(sizeof(packed) == mesh::companion::packedHelpSize(literal), + "No raw-size padding retained"); + static_assert(sizeof(packed) + 40 < sizeof(literal), "Fragments compress"); + Output decoded; + assert(mesh::companion::writePackedHelp(decoded,packed.bytes,sizeof(packed))==sizeof(literal)-1); + assert(decoded.text==literal); + Output slow;slow.throttle=3; + assert(mesh::companion::writePackedHelp(slow,packed.bytes,sizeof(packed))==sizeof(literal)-1); + assert(slow.text==literal && slow.writes.size()<=sizeof(literal)-1); + for (size_t credit : {size_t(0),size_t(1),size_t(13),size_t(63),size_t(64),size_t(65)}) { + Output blocked;blocked.credit=credit; + assert(mesh::companion::writePackedHelp(blocked,packed.bytes,sizeof(packed))==credit); + assert(blocked.text==std::string(literal,credit)); + assert(blocked.writes.size()<=credit+1); + } + Output bad;bad.overreport=true; + assert(mesh::companion::writePackedHelp(bad,packed.bytes,sizeof(packed))==0); + assert(bad.writes.size()==1 && bad.text.empty()); + Output unused; + assert(mesh::companion::writePackedHelp(unused,nullptr,1)==0); + assert(mesh::companion::writePackedHelp(unused,packed.bytes,0)==0 && unused.writes.empty()); + const uint8_t invalid_token[]={0xff}; + assert(mesh::companion::writePackedHelp(unused,invalid_token,1)==0 && unused.writes.empty()); } ''' @@ -116,7 +149,7 @@ class CompanionHelpWriterTests(unittest.TestCase): work = Path(folder);(work / 'test.cpp').write_text(harness) sanitizer = ['-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie'] if sys.platform.startswith('linux') else [] - result = subprocess.run([compiler, '-std=c++17', '-Wall', '-Wextra', '-Werror', + result = subprocess.run([compiler, '-std=c++17', '-Os', '-Wall', '-Wextra', '-Werror', *sanitizer, *['-D'+value for value in defines], '-I'+str(ROOT/'examples/companion_radio'), str(work/'test.cpp'), '-o', str(work/'test')], capture_output=True, text=True) @@ -126,6 +159,9 @@ class CompanionHelpWriterTests(unittest.TestCase): self.assertEqual(hashlib.sha256(result.stdout).hexdigest(),GOLDEN[name]['text']) if name != 'rak_full': self.assertEqual(hashlib.sha256(result.stderr).hexdigest(),GOLDEN[name]['calls']) + else: + image = (work / 'test').read_bytes() + self.assertNotIn(b'Commands:\r\n stats-core / stats-radio / stats-radio-diag',image) if __name__ == '__main__':