#!/usr/bin/env python3 """ Compile Plover JSON dictionaries into Javelin's JSC4 binary format. Produces a StenoDictionaryCollection binary that can be embedded in ZMK firmware at a known flash address. Usage: python compile_dict.py main.json -o steno_dict.bin --base-addr 0 python compile_dict.py main.json extra.json -o steno_dict.bin The --base-addr flag sets the XIP base address for pointer resolution. When embedded via .incbin in .rodata, set to 0 and the linker resolves the _javelin_dict_start symbol. Javelin dereferences pointers relative to the binary's load address. IMPORTANT: This compiler uses position-independent pointers (offsets from the start of the binary). The engine_init code adjusts pointers at load time if needed, or the binary is placed at a fixed address. """ import argparse import json import struct import sys import zlib from collections import defaultdict from typing import Dict, List, Optional, Tuple # --- Stroke parsing --- STENO_KEYS = { 'S-': 0x00000001, 'T-': 0x00000002, 'K-': 0x00000004, 'P-': 0x00000008, 'W-': 0x00000010, 'H-': 0x00000020, 'R-': 0x00000040, 'A-': 0x00000080, 'O-': 0x00000100, '*': 0x00000200, '-E': 0x00000400, '-U': 0x00000800, '-F': 0x00001000, '-R': 0x00002000, '-P': 0x00004000, '-B': 0x00008000, '-L': 0x00010000, '-G': 0x00020000, '-T': 0x00040000, '-S': 0x00080000, '-D': 0x00100000, '-Z': 0x00200000, '#': 0x00400000, } IMPLICIT_HYPHEN = set('AOEU*') LEFT_BANK = 'STKPWHR' RIGHT_BANK = 'FRPBLGTSDZ' VOWELS = 'AOEU' def parse_stroke(s: str) -> int: result = 0 if '#' in s: result |= STENO_KEYS['#'] s = s.replace('#', '') if '-' in s: left, right = s.split('-', 1) for c in left: if c == '*': result |= STENO_KEYS['*'] elif c in 'STKPWHR': result |= STENO_KEYS[c + '-'] elif c in VOWELS: result |= STENO_KEYS[{ 'A': 'A-', 'O': 'O-', 'E': '-E', 'U': '-U' }[c]] for c in right: key = '-' + c if key in STENO_KEYS: result |= STENO_KEYS[key] return result # No explicit hyphen — determine split from steno order has_vowel_or_star = any(c in IMPLICIT_HYPHEN for c in s) if has_vowel_or_star: phase = 'left' for c in s: if phase == 'left': if c in IMPLICIT_HYPHEN: phase = 'vowel' if c == '*': result |= STENO_KEYS['*'] else: result |= STENO_KEYS[{'A': 'A-', 'O': 'O-', 'E': '-E', 'U': '-U'}[c]] elif c in LEFT_BANK: result |= STENO_KEYS[c + '-'] elif phase == 'vowel': if c in IMPLICIT_HYPHEN: if c == '*': result |= STENO_KEYS['*'] else: result |= STENO_KEYS[{'A': 'A-', 'O': 'O-', 'E': '-E', 'U': '-U'}[c]] else: phase = 'right' result |= STENO_KEYS['-' + c] else: result |= STENO_KEYS['-' + c] else: for c in s: if c in LEFT_BANK: result |= STENO_KEYS[c + '-'] return result def parse_outline(outline: str) -> Tuple[int, ...]: return tuple(parse_stroke(s) for s in outline.split('/')) # --- CRC32 (matching Javelin's implementation) --- def crc32_hash(data: bytes) -> int: return zlib.crc32(data) & 0xFFFFFFFF def stroke_hash(strokes: Tuple[int, ...]) -> int: data = b''.join(struct.pack(' bytes: return struct.pack(' int: return (v + align - 1) & ~(align - 1) def popcount32(v: int) -> int: return bin(v & 0xFFFFFFFF).count('1') class CompactMapBuilder: """Build a compact map dictionary for a set of entries with the same stroke count.""" def __init__(self, stroke_length: int, entries: Dict[Tuple[int, ...], int]): """entries maps stroke tuples to text block offsets.""" self.stroke_length = stroke_length self.entries = entries def build(self) -> Tuple[bytes, bytes, int]: """Returns (data_block, hash_blocks, hashMapMask).""" if not self.entries: return b'', b'', 0 # Determine hash map size: next power of 2 * 128, at least 2x entries n = len(self.entries) num_blocks = max(1, (n * 2 + 127) // 128) # Round up to power of 2 p = 1 while p < num_blocks: p *= 2 num_blocks = p total_slots = num_blocks * 128 hash_map_mask = total_slots - 1 # Place entries using open addressing (linear probing) slots = [None] * total_slots for strokes, text_offset in self.entries.items(): h = stroke_hash(strokes) & hash_map_mask while slots[h] is not None: h = (h + 1) & hash_map_mask slots[h] = (strokes, text_offset) # Build data block (entries in slot order) and hash map blocks. # Data entry = textOffset(3 bytes) + strokes(3 bytes each) entry_size = 3 + 3 * self.stroke_length data_block = bytearray() hash_blocks = bytearray() for block_idx in range(num_blocks): masks = [0, 0, 0, 0] block_entries = [] for bit in range(128): slot_idx = block_idx * 128 + bit if slots[slot_idx] is not None: masks[bit // 32] |= 1 << (bit % 32) block_entries.append(slots[slot_idx]) # baseOffset: count of all entries in previous blocks, # MINUS popcount of current block's masks (Javelin convention). # From compact_map_dictionary.cc GetOffset(): # result = PopCount(mask << (31 - bitIndex)) + baseOffset # + PopCount of masks[0..maskIndex-1] # The builder sets baseOffset = running_total - PopCount(all masks in block) running_count = len(data_block) // entry_size block_popcount = sum(popcount32(m) for m in masks) base_offset = running_count - block_popcount for m in masks: hash_blocks.extend(struct.pack(' bytes: # Parse all entries, group by stroke count per dictionary parsed_dicts = [] for dict_name, raw_dict in dict_entries: by_length = defaultdict(dict) max_outline_len = 0 for outline, translation in raw_dict.items(): try: strokes = parse_outline(outline) except (KeyError, ValueError): continue stroke_count = len(strokes) by_length[stroke_count][strokes] = translation max_outline_len = max(max_outline_len, stroke_count) parsed_dicts.append((dict_name, by_length, max_outline_len)) # Build shared text block (deduplicated) all_texts = set() for _, by_length, _ in parsed_dicts: for entries in by_length.values(): all_texts.update(entries.values()) text_list = sorted(all_texts) text_to_offset = {} text_block = bytearray() for text in text_list: text_to_offset[text] = len(text_block) encoded = text.encode('utf-8') text_block.extend(encoded) text_block.append(0) # Replace translation strings with text offsets for _, by_length, _ in parsed_dicts: for stroke_len in by_length: entries = by_length[stroke_len] by_length[stroke_len] = { strokes: text_to_offset[text] for strokes, text in entries.items() } # Build compact map data for each (dict, stroke_length) # Each dict has a StrokesDefinition array indexed by stroke_length dict_stroke_data = [] for dict_name, by_length, max_outline_len in parsed_dicts: stroke_defs = [] for length in range(1, max_outline_len + 1): entries = by_length.get(length, {}) builder = CompactMapBuilder(length, entries) data_block, hash_blocks, hash_map_mask = builder.build() stroke_defs.append((length, data_block, hash_blocks, hash_map_mask)) dict_stroke_data.append((dict_name, stroke_defs, max_outline_len)) # Now lay out the binary. We do two passes: # 1. Calculate sizes to determine offsets # 2. Write the actual data with correct pointers timestamp = 0x12345678 # Header: StenoDictionaryCollection # uint32_t magic # uint16_t dictionaryCount # bool hasReverseLookup # uint8_t _padding # SizedList textBlock (count + ptr = 4 + ptr_size) # SizedList prefixes # SizedList suffixes # uint32_t timestamp # XipPointer dictionaries[] # On ARM32 (nRF52840): pointers are 4 bytes PTR = 4 # SizedList = { size_t count; T* data; } = 4 + PTR SIZED_LIST = 4 + PTR header_size = ( 4 + # magic 2 + # dictionaryCount 1 + # hasReverseLookup 1 + # padding SIZED_LIST + # textBlock SIZED_LIST + # prefixes SIZED_LIST + # suffixes 4 # timestamp ) dict_ptrs_size = len(parsed_dicts) * PTR # StenoCompactMapDictionaryDefinition: # uint8_t defaultEnabled, maximumOutlineLength, type, options (= 4) # XipPointer name (= PTR) # const uint8_t* textBlock (= PTR) # const StrokesDefinition* strokes (= PTR) COMPACT_DEF_SIZE = 4 + PTR + PTR + PTR # StenoCompactMapDictionaryStrokesDefinition: # size_t hashMapMask (= 4) # const uint8_t* data (= PTR) # const Block* offsets (= PTR) STROKES_DEF_SIZE = 4 + PTR + PTR # Layout plan offset = header_size + dict_ptrs_size # Align offset = round_up(offset, 4) # Dictionary definitions dict_def_offsets = [] for i in range(len(parsed_dicts)): dict_def_offsets.append(offset) offset += COMPACT_DEF_SIZE offset = round_up(offset, 4) # Dictionary names (null-terminated strings) dict_name_offsets = [] for dict_name, _, _ in dict_stroke_data: dict_name_offsets.append(offset) offset += len(dict_name.encode('utf-8')) + 1 offset = round_up(offset, 4) # Strokes definitions arrays strokes_def_offsets = [] # [(dict_idx, [(length, offset)])] for di, (_, stroke_defs, max_len) in enumerate(dict_stroke_data): arr_offset = offset arr = [] for si in range(len(stroke_defs)): arr.append(offset) offset += STROKES_DEF_SIZE strokes_def_offsets.append((arr_offset, arr)) offset = round_up(offset, 4) # Data blocks (per dict, per stroke length) data_offsets = [] # [[(length, offset)]] for di, (_, stroke_defs, _) in enumerate(dict_stroke_data): doffs = [] for si, (length, data_block, hash_blocks, _) in enumerate(stroke_defs): doffs.append(offset) offset += len(data_block) data_offsets.append(doffs) offset = round_up(offset, 4) # Hash map blocks (per dict, per stroke length) hash_offsets = [] for di, (_, stroke_defs, _) in enumerate(dict_stroke_data): hoffs = [] for si, (length, data_block, hash_blocks, _) in enumerate(stroke_defs): hoffs.append(offset) offset += len(hash_blocks) hash_offsets.append(hoffs) offset = round_up(offset, 4) # Text block text_block_offset = offset offset += len(text_block) # Timestamp at end of text block timestamp_end_offset = offset offset += 4 total_size = offset # --- Pass 2: write binary --- buf = bytearray(total_size) base = self.base_addr def write_u8(off, v): buf[off] = v & 0xFF def write_u16(off, v): struct.pack_into(' 0: items = list(raw.items())[:args.max_entries] raw = dict(items) name = args.name[i] if args.name and i < len(args.name) else input_path.rsplit('/', 1)[-1].rsplit('.', 1)[0] dict_entries.append((name, raw)) print(f"Loaded {input_path}: {len(raw)} entries as '{name}'", file=sys.stderr) builder = DictionaryCollectionBuilder(base_addr=args.base_addr) result = builder.build(dict_entries) with open(args.output, 'wb') as f: f.write(result) print(f"Wrote {len(result)} bytes ({len(result)/1024:.1f} KB) to {args.output}", file=sys.stderr) if __name__ == '__main__': main()