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}