Skip to main content

rhdl_dsig_scalable_core/
packer.rs

1//! Pack 18-byte chain nodes into 64-byte words.
2//!
3//! # The problem
4//!
5//! Hash chains produce 18-byte nodes ([`ELEMENT_BYTES`]), but the rest of the
6//! hardware moves 64-byte words: a BLAKE3 block is 64 bytes and an HBM beat
7//! (one transfer on the memory bus) is 64 bytes. Somebody has to glue a
8//! stream of 18-byte pieces into a stream of 64-byte words, with no gaps.
9//!
10//! Two places in a slice turn a stream of 18-byte nodes into 64-byte words:
11//!
12//! | Stream | Prefix | Then | Total |
13//! |---|---|---|---|
14//! | Endpoint commitment (BLAKE3 blocks) | `pk_nonce`, 16 B | 68 chain ends | 1,240 B |
15//! | Signature frame tail (HBM beats) | last 34 header bytes | 68 revealed nodes | 1,258 B |
16//!
17//! The first is used by the [key factory](crate::slice_factory) to hash the
18//! 68 chain ends into `pk_hash` (the key's public commitment). The second is
19//! used by the [signer](crate::slice_signer) to lay the 68 revealed nodes
20//! into the signature frame right after the header. Both are the same circuit,
21//! [`ElementPacker`], with a different prefix length `P`.
22//!
23//! # Why it is tricky
24//!
25//! 18 does not divide 64, so each node lands at a different byte offset. The
26//! sequence of offsets repeats every 32 nodes (`32 × 18 = 9 × 64`).
27//!
28//! ```text
29//!   word 0: [ prefix (P bytes) | node 0 | node 1 | node 2 | node 3 (part) ]
30//!   word 1: [ node 3 (rest) | node 4 | node 5 | node 6 (part) ]   …
31//! ```
32//!
33//! For the signature stream (`P = 34`) the first words look like this:
34//!
35//! ```text
36//!   byte:    0          34        52     64
37//!   word 0:  [ header tail | node 0 | node 1: 12 B ]
38//!   word 1:  [ node 1: 6 B | node 2 | node 3 | node 4 | node 5: 4 B ] ...
39//!   last:    [ ... node 67 ends at byte 42 | zero padding ]   (1,258 = 19 × 64 + 42)
40//! ```
41//!
42//! The packer keeps a *fill* counter (bytes already used in the current
43//! word). Each new node is placed at offset `fill`. If it fits, `fill` grows
44//! by 18. If it crosses the 64-byte edge, the full word is emitted and the
45//! 0 to 17 overflow bytes (the *remainder*) start the next word.
46//!
47//! [`ElementPacker`] accepts at most one node per cycle and emits at most one
48//! word per cycle. Both streams have the property that the 68th node never
49//! completes a word *and* leaves a remainder, so a single output suffices. A
50//! compile-time assertion checks this for each prefix length.
51//!
52//! # Why the kernel looks unusual
53//!
54//! RHDL lowers every array-element assignment into a copy of the whole array,
55//! so "for each offset, for each byte, maybe write it" would emit thousands
56//! of 512-bit copies. Instead each output byte is one OR of ANDs over a
57//! zero-padded copy of the node ([`place_kernel`]). Every index is a
58//! compile-time constant in range, and synthesis folds the padding zeros away,
59//! leaving an 18-way select per byte.
60//!
61//! # Timing
62//!
63//! One register stage: a node presented with `valid` in cycle `t` produces
64//! its word (if any) on the output in cycle `t + 1`.
65
66use crate::{CHAINS, ELEMENT_BYTES};
67use rhdl::prelude::*;
68use rhdl_fpga::core::dff::DFF;
69
70/// Prefix length of the endpoint-commitment stream (`pk_nonce`).
71pub const COMMITMENT_PREFIX: usize = 16;
72/// Prefix length of the signature-frame tail: the 1,642-byte wire format puts
73/// 418 header bytes before the nodes, and `418 = 6 × 64 + 34`.
74///
75/// The first 384 header bytes fill six whole beats that the signer writes
76/// itself; the last 34 share beat 6 with node 0, so they go through the
77/// packer as its prefix.
78pub const SIGNATURE_PREFIX: usize = 34;
79
80/// Whether `prefix` keeps the 68th node from overflowing into a second word.
81///
82/// `before_last` is the byte offset where node 67 starts inside its word. If
83/// node 67 fits completely in that word, the stream ends with exactly one
84/// partial word and the single output register is enough. The prefix must
85/// also be even and shorter than one word (assumptions the packer relies on).
86#[must_use]
87pub const fn single_flush(prefix: usize) -> bool {
88    let before_last = (prefix + (CHAINS - 1) * ELEMENT_BYTES) % 64;
89    before_last + ELEMENT_BYTES < 64 && prefix % 2 == 0 && prefix < 64
90}
91
92// Both real prefixes satisfy the one-output rule; checked at compile time.
93const _: () = assert!(single_flush(COMMITMENT_PREFIX));
94const _: () = assert!(single_flush(SIGNATURE_PREFIX));
95
96/// Packer input; `start` must accompany node 0 of each stream.
97#[derive(Clone, Copy, Debug, PartialEq, Digital)]
98pub struct PackerInput {
99    /// Prefix bytes for this stream; only the first `P` bytes are used.
100    pub prefix: [b8; 64],
101    /// This node is node 0: discard state and begin with the prefix.
102    pub start: bool,
103    /// The 18-byte node.
104    pub node: [b8; ELEMENT_BYTES],
105    /// A node is present this cycle.
106    pub valid: bool,
107}
108
109impl Default for PackerInput {
110    /// No node, no start, all-zero prefix and node.
111    fn default() -> Self {
112        Self {
113            prefix: [b8(0); 64],
114            start: false,
115            node: [b8(0); ELEMENT_BYTES],
116            valid: false,
117        }
118    }
119}
120
121/// One output word; bytes at and after `bytes` are zero.
122#[derive(Clone, Copy, Debug, PartialEq, Digital)]
123pub struct PackerOutput {
124    /// The word, byte 0 first in stream order.
125    pub word: [b8; 64],
126    /// Meaningful bytes: 64, or fewer for the final word.
127    pub bytes: b7,
128    /// This is the stream's final word.
129    pub last: bool,
130    /// A word is presented this cycle.
131    pub valid: bool,
132}
133
134/// The registered part of [`PackerOutput`] (everything except `valid`,
135/// which lives in its own resettable register). Internal.
136#[derive(Clone, Copy, Debug, PartialEq, Digital)]
137#[doc(hidden)]
138pub struct PackerOut {
139    /// The word being presented.
140    word: [b8; 64],
141    /// Meaningful bytes in `word` (64, or fewer for the final word).
142    bytes: b7,
143    /// `word` is the stream's final word.
144    last: bool,
145}
146
147/// Registered packer for a stream with a `P`-byte prefix and 68 nodes.
148///
149/// `P` is a const generic so each stream gets its own copy with the prefix
150/// length baked in (16 for the commitment, 34 for the signature).
151#[derive(Clone, Debug, Synchronous, SynchronousDQ)]
152pub struct ElementPacker<const P: usize> {
153    /// The partly filled word: bytes `0..fill` are valid.
154    accumulator: DFF<[b8; 64]>,
155    /// Bytes already used in `accumulator` (0..64).
156    fill: DFF<b7>,
157    /// Nodes accepted so far in this stream (0..68); 67 marks the last node.
158    count: DFF<b7>,
159    /// The word presented on the output.
160    out: DFF<PackerOut>,
161    /// `out` holds a new word this cycle. The only register with a reset.
162    out_valid: DFF<bool>,
163}
164
165impl<const P: usize> Default for ElementPacker<P> {
166    /// Checks `P` at construction (panics for a prefix that would need two
167    /// flush words) and clears only `out_valid`; `start` initializes the rest.
168    fn default() -> Self {
169        assert!(single_flush(P), "prefix {P} needs a two-word flush");
170        Self {
171            accumulator: DFF::resetless(),
172            fill: DFF::resetless(),
173            count: DFF::resetless(),
174            out: DFF::resetless(),
175            out_valid: DFF::new(false),
176        }
177    }
178}
179
180/// Input: at most one node per cycle. Output: at most one word per cycle,
181/// one cycle later.
182impl<const P: usize> SynchronousIO for ElementPacker<P> {
183    type I = PackerInput;
184    type O = PackerOutput;
185    type Kernel = element_packer_kernel<P>;
186}
187
188/// Bytes of `node` shifted to start at offset `fill`, over 82 positions.
189///
190/// Position `p` (0..82) holds `node[p - fill]` when that index is in range,
191/// else zero. `onehot[f]` is true exactly for `f == fill`.
192///
193/// 82 = 64 + 18: positions 0..64 are the current word, 64..82 the overflow
194/// into the next word. Pure combinational logic, no registers.
195#[allow(clippy::needless_range_loop)] // Static byte wiring.
196#[kernel]
197pub fn place_kernel(node: [b8; ELEMENT_BYTES], onehot: [bool; 64]) -> [b8; 82] {
198    // padded[64 + k] = node[k]; all other entries are zero.
199    // The 64 zeros in front let `64 + p - f` stay in range for every p, f,
200    // so all indices below are constants and no bounds logic is generated.
201    let mut padded = [b8(0); 146];
202    for k in 0..ELEMENT_BYTES {
203        padded[64 + k] = node[k];
204    }
205    let mut placed = [b8(0); 82];
206    for p in 0..82 {
207        // OR together the candidate byte for every possible fill; exactly
208        // one `onehot[f]` is true, so this is a select, not a merge.
209        let mut value = b8(0);
210        for f in 0..64 {
211            if onehot[f] {
212                value = value | padded[64 + p - f];
213            }
214        }
215        placed[p] = value;
216    }
217    placed
218}
219
220/// Wiring for [`ElementPacker`].
221///
222/// * `input.start` (with node 0): load the prefix and reset `fill` to `P`.
223/// * `input.valid`: place the node at `fill`. If it crosses the word edge,
224///   emit the full word and keep the remainder; on node 67, emit the final
225///   partial word.
226/// * Output: the registered word from the previous cycle; `valid` is forced
227///   low during reset.
228#[allow(clippy::needless_range_loop)] // Static byte wiring.
229#[kernel]
230pub fn element_packer_kernel<const P: usize>(
231    clock_reset: ClockReset,
232    input: PackerInput,
233    q: ElementPackerQ<P>,
234) -> (PackerOutput, ElementPackerD<P>) {
235    let resetting = clock_reset.reset.any();
236    // `base`, `fill`, `count`: the state this node builds on. Normally the
237    // registers; on `start`, a fresh word holding only the prefix.
238    let mut base = q.accumulator;
239    let mut fill = q.fill;
240    let mut count = q.count;
241    if input.start {
242        base = [b8(0); 64];
243        for byte in 0..P {
244            base[byte] = input.prefix[byte];
245        }
246        fill = b7(P as u128);
247        count = b7(0);
248    }
249    // One-hot form of `fill` for `place_kernel`'s select.
250    let mut onehot = [false; 64];
251    for f in 0..64 {
252        onehot[f] = fill == b7(f as u128);
253    }
254    // The node shifted to byte offset `fill` (82 positions, rest zero).
255    let placed = place_kernel(input.node, onehot);
256    // Keep accumulator bytes below `fill`; take placed bytes from `fill` on.
257    let mut word = [b8(0); 64];
258    for p in 0..64 {
259        word[p] = if b7(p as u128) < fill {
260            base[p]
261        } else {
262            placed[p]
263        };
264    }
265    // Bytes that spilled past byte 63; they start the next word.
266    let mut remainder = [b8(0); 64];
267    for p in 0..ELEMENT_BYTES {
268        remainder[p] = placed[64 + p];
269    }
270    // Bytes used after adding this node, and whether that fills the word.
271    let total = fill + b7(ELEMENT_BYTES as u128);
272    let crosses = total >= b7(64);
273    // This node is node 67, the last of the stream.
274    let last = count == b7((CHAINS - 1) as u128);
275    // Default: hold state, present nothing next cycle.
276    let mut d = ElementPackerD::<P> {
277        accumulator: q.accumulator,
278        fill: q.fill,
279        count: q.count,
280        out: q.out,
281        out_valid: false,
282    };
283    if input.valid {
284        d.count = count + b7(1);
285        if crosses {
286            // Word full: carry the overflow bytes into a new word.
287            d.accumulator = remainder;
288            d.fill = total - b7(64);
289        } else {
290            d.accumulator = word;
291            d.fill = total;
292        }
293        // Emit on a full word, or on the last node (partial final word).
294        // `single_flush` guarantees these never both need a word at once.
295        if crosses || last {
296            d.out = PackerOut {
297                word,
298                bytes: if crosses { b7(64) } else { total },
299                last,
300            };
301            d.out_valid = true;
302        }
303    }
304    (
305        PackerOutput {
306            word: q.out.word,
307            bytes: q.out.bytes,
308            last: q.out.last,
309            valid: q.out_valid && !resetting,
310        },
311        d,
312    )
313}