zerodds_ast_layout/key.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! Layout keys. Spec §7.
4
5use zerodds_ast::Extensibility;
6
7/// Which encoding a plan is computed for.
8///
9/// XCDR1 and XCDR2 differ in their framing: XCDR1 has no delimiter
10/// header, and `@mutable` uses PID headers there and EMHEADERs here.
11/// Primitive alignment is the same in both. OMG XTypes 1.3 §7.4.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
13pub enum Encoding {
14 /// XCDR1 without a parameter list. XTypes 1.3 §7.4.1.
15 PlainCdr1,
16 /// XCDR1 with a parameter list, for `@mutable`. XTypes 1.3 §7.4.1.2.
17 PlCdr1,
18 /// XCDR2 without a header. XTypes 1.3 §7.4.2.
19 PlainCdr2,
20 /// XCDR2 with a delimiter header, for `@appendable`.
21 DelimitedCdr,
22 /// XCDR2 with a parameter list, for `@mutable`.
23 PlCdr2,
24}
25
26impl Encoding {
27 /// Every encoding the layout planner covers.
28 pub const ALL: [Self; 5] = [
29 Self::PlainCdr1,
30 Self::PlCdr1,
31 Self::PlainCdr2,
32 Self::DelimitedCdr,
33 Self::PlCdr2,
34 ];
35
36 /// Whether this is an XCDR version 1 encoding.
37 #[must_use]
38 pub const fn is_v1(self) -> bool {
39 matches!(self, Self::PlainCdr1 | Self::PlCdr1)
40 }
41
42 /// Whether this is an XCDR version 2 encoding.
43 #[must_use]
44 pub const fn is_v2(self) -> bool {
45 !self.is_v1()
46 }
47
48 /// The largest alignment any primitive takes.
49 ///
50 /// XTypes 1.3 §7.4.1, the XCDR stream operations table, defines it
51 /// outright:
52 ///
53 /// > `MAXALIGN( VERSION2) = 4`
54 /// > `MAXALIGN( VERSION1) = 8`
55 /// > `MAXALIGN( VERSION_NONE) = 8`
56 ///
57 /// and `ALIGN(N)` aligns to `MIN(N, XCDR.maxalign)`. §7.6.8 repeats
58 /// it for the key holder: "the maximum alignment in PLAIN_CDR2
59 /// (i.e., 4)".
60 ///
61 /// # Why this reads as a correction
62 ///
63 /// It was briefly eight for both versions, on the authority of a
64 /// wire vector V-3 in `crates/cdr/tests/xcdr2_wire_vectors.rs` at
65 /// `e1f3af895`. That vector is not an OMG artefact — its header
66 /// cites §7.4.2 but its byte table was computed by hand, and it pads
67 /// the trailing double to align(8) for a total of 48 bytes. Three
68 /// independent sources say 44:
69 ///
70 /// - the spec text above,
71 /// - Cyclone DDS, `src/core/cdr/src/dds_cdrstream.c:239` —
72 /// `xcdr_version == DDSI_RTPS_CDR_ENC_VERSION_2 ? MK_ALIGN(4) :
73 /// MK_ALIGN(8)`, and `:2435` `.alignmask = (… ? 3 : 7)`,
74 /// - Fast CDR, `src/cpp/Cdr.cpp:238` — `align64_ = 4` on reading an
75 /// XCDRv2 encapsulation, 8 otherwise.
76 ///
77 /// Measured, not read: `@final struct Point { long x; double y; }`
78 /// with `x=1, y=2.0` comes off Cyclone's and RTI Connext 7.7's own
79 /// serialisers as twelve payload bytes,
80 /// `010000000000000000000040`, with the double at offset 4.
81 /// `tests/fixtures/vendor_bytes.md` records the runs.
82 #[must_use]
83 pub const fn max_alignment(self) -> u8 {
84 if self.is_v1() { 8 } else { 4 }
85 }
86}
87
88/// Byte order.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
90pub enum Endianness {
91 /// Little-endian.
92 Little,
93 /// Big-endian.
94 Big,
95}
96
97impl Endianness {
98 /// Both orders.
99 pub const ALL: [Self; 2] = [Self::Little, Self::Big];
100}
101
102/// What the plan is for.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
104pub enum Purpose {
105 /// The full sample.
106 Data,
107 /// The key holder only. DDSI-RTPS 2.5 §9.6.3.8.
108 Key,
109}
110
111impl Purpose {
112 /// Both purposes.
113 pub const ALL: [Self; 2] = [Self::Data, Self::Key];
114}
115
116/// Which length code a `@mutable` member's EMHEADER gets.
117///
118/// XTypes 1.3 §7.4.3.4.2 gives eight codes and constrains almost
119/// nothing about which one a writer picks. LC0 to LC3 state a length of
120/// 1, 2, 4 or 8 in the header itself; LC4 states it in a following
121/// NEXTINT; LC5 to LC7 say the member's own leading word *is* the
122/// NEXTINT, times one, four or eight. Several are legal for the same
123/// member and they are not the same bytes, so which one is a
124/// configuration point.
125///
126/// # The three implementations take three positions
127///
128/// Measured 2026-08-09 through `wire-oracle/gate`, cases
129/// `mut_lengthcodes` and `mut_lc_nested`, XCDR2 little endian. The
130/// EMHEADER each member of a `@mutable` struct gets:
131///
132/// | member | Cyclone | RTI 7.7 | Fast DDS |
133/// |---|---|---|---|
134/// | `string s = "abc"` | `00000050` LC5 | LC5 | LC5 |
135/// | `sequence<octet>` of 3 | `01000050` LC5 | LC5 | LC5 |
136/// | `sequence<short>` of 2 | `02000040` LC4 | LC4 | `02000030` **LC3** |
137/// | `sequence<long>` of 2 | `03000060` LC6 | LC6 | LC6 |
138/// | `sequence<double>` of 1 | `04000070` LC7 | LC7 | LC7 |
139/// | `sequence<Cell>` of 1 | `05000050` LC5 | `05000040` LC4 | LC5 |
140/// | `Cell arr[2]` | `06000050` LC5 | LC4 | LC5 |
141/// | `long ap[2]` | `07000040` LC4 | LC4 | `07000030` **LC3** |
142/// | `Cell nested` | `08000040` LC4 | LC4 | `08000030` **LC3** |
143/// | `long tail` | `09000020` LC2 | LC2 | LC2 |
144/// | `ACell app` (`@appendable`) | `00000040` LC4 | `00000050` **LC5** | **LC5** |
145/// | `MCell mnest` (`@mutable`) | `01000040` LC4 | **LC5** | **LC5** |
146///
147/// The `sequence<Cell>` and `Cell arr[2]` rows are the collection
148/// delimiter — [`CollectionDelimiterPolicy`] — seen from the length-code
149/// side: RTI writes no collection DHEADER, so there is no leading word
150/// to reuse and LC4 is the only code left. What is left over after that
151/// is two independent choices, and each implementation makes a different
152/// pair of them:
153///
154/// 1. **Does a nested aggregate's own DHEADER count as a reusable
155/// leading word?** An `@appendable` or `@mutable` member begins with
156/// a DHEADER, which is exactly the byte length LC5 would state. RTI
157/// and Fast DDS reuse it. Cyclone does not, and says why in
158/// `dds_cdrstream.c`: `case DDS_OP_VAL_UNI: case DDS_OP_VAL_EXT:
159/// return LENGTH_CODE_NEXTINT; /* FIXME: may be optimized for
160/// specific cases, e.g. when EXT type is appendable */`.
161/// 2. **Does a member with no reusable word take a short code when its
162/// serialized size happens to be 1, 2, 4 or 8?** Fast DDS does.
163/// Cyclone and RTI take a short code only where the member's *kind*
164/// fixes the width — a primitive, an enumeration, a bitmask — and
165/// write LC4 for an eight-byte array or an eight-byte nested
166/// structure alike. Cyclone's `get_length_code` switches on the op
167/// kind and never on a size.
168///
169/// The two nest: Cyclone makes neither choice, RTI the first, Fast DDS
170/// both. So one enum in that order rather than two booleans, which
171/// would have four combinations and three witnesses.
172///
173/// # The runtime half of Fast DDS's rule
174///
175/// Fast DDS's `sequence<short>` of two elements is eight bytes and takes
176/// LC3. Three elements would be ten and take LC4. A layout plan cannot
177/// make that choice — the count is in the value, not in the type — so
178/// [`Self::ShortWhereItFits`] is answered in two places: the plan
179/// answers for every member whose size the *type* fixes, and the writer
180/// answers for the rest by patching the header once the value's length
181/// is known. `Writer::finish_emheader` does the patching, which is the
182/// same thing it already does to the NEXTINT and is safe for the same
183/// reason: under XCDR2 `MAXALIGN` is four, the value starts four-aligned
184/// with the NEXTINT and four-aligned without it, so removing the
185/// placeholder moves the value without changing a byte of it.
186///
187/// # Every code decodes
188///
189/// A reader that implements §7.4.3.4.2 handles all eight, because the
190/// code is in the header. This is a question about which bytes go out,
191/// not about which can be read — unlike [`CollectionDelimiterPolicy`],
192/// where nothing in the stream says whether the four bytes in front are
193/// a delimiter or the first element.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
195pub enum LengthCodePolicy {
196 /// LC5, LC6 and LC7 where a string's or a sequence's own leading
197 /// word can be reused, LC0 to LC3 where the member's *kind* fixes
198 /// its width, and LC4 everywhere else.
199 ///
200 /// The default, and what Cyclone DDS writes. A nested aggregate's
201 /// DHEADER is not reused and an eight-byte array is not shortened,
202 /// which is where Cyclone parts from the other two.
203 #[default]
204 Compact,
205 /// [`Self::Compact`], and a nested aggregate that begins with its
206 /// own DHEADER reuses it as the NEXTINT.
207 ///
208 /// What RTI Connext 7.7 writes. Four bytes shorter than
209 /// [`Self::Compact`] for every `@appendable` or `@mutable` member of
210 /// a `@mutable` type.
211 ReuseNestedDelimiter,
212 /// [`Self::ReuseNestedDelimiter`], and any member with no reusable
213 /// leading word takes LC0 to LC3 whenever its serialized size turns
214 /// out to be 1, 2, 4 or 8 bytes.
215 ///
216 /// What Fast DDS writes. The reuse is tried first: Fast DDS frames
217 /// an eight-byte `string` as LC5 and not LC3, so the leading word
218 /// wins where there is one.
219 ///
220 /// Half of this is a writer-time decision — see the type's
221 /// documentation.
222 ShortWhereItFits,
223 /// LC4 with an explicit NEXTINT throughout, whatever the member's
224 /// size or shape. Four bytes more per member than any of the three
225 /// above, and the form the pre-rebuild ZeroDDS emitters produced.
226 ///
227 /// No measured implementation writes it. Reach for it when a peer
228 /// expects that form — the OMG wire vector V-10 at `e1f3af895` is
229 /// pinned in exactly that shape — or when reproducing a recorded
230 /// capture byte for byte.
231 UniversalNextInt,
232 /// LC0 to LC3 wherever the member's **serialized** length is exactly
233 /// 1, 2, 4 or 8 octets, LC4 everywhere else, and never a reused
234 /// leading word.
235 ///
236 /// What OpenDDS writes, and the fourth of the four positions: it
237 /// answers the second question of the pair above yes and the first
238 /// no, which is the combination none of the three others is.
239 /// Measured on `struct-mutable-length-codes`, XCDR2, against this
240 /// project's [`Self::Compact`]:
241 ///
242 /// ```text
243 /// member ours OpenDDS
244 /// string s = "abc" LC5 LC3
245 /// sequence<octet> of 3 LC5 LC4, NEXTINT 7
246 /// sequence<short> of 2 LC4, NEXTINT 8 LC3
247 /// sequence<long> of 2 LC6 LC4, NEXTINT 12
248 /// sequence<double> of 1 LC7 LC4, NEXTINT 12
249 /// sequence<Cell> of 1 LC5 LC4
250 /// Cell pair[2] LC5 LC4
251 /// long plain[2] LC4 LC3
252 /// Cell nested LC4 LC3
253 /// ```
254 ///
255 /// Every code on both sides is legal for the member it is on —
256 /// §7.4.3.4.2 permits several for the same member — so this is a
257 /// reading of the clause and not a defect, and it is the one of the
258 /// four that never spends a code on a word the member already
259 /// carries.
260 ///
261 /// Half of it is a writer-time decision, for the same reason
262 /// [`Self::ShortWhereItFits`]'s is: `string s` is eight octets
263 /// because the sample says `"abc"`, and no plan knows that. The plan
264 /// answers where the *type* fixes the size — `long plain[2]` and
265 /// `Cell nested` — and `Writer::finish_emheader` patches the rest.
266 SizedOrNextInt,
267}
268
269impl LengthCodePolicy {
270 /// Every policy.
271 pub const ALL: [Self; 5] = [
272 Self::Compact,
273 Self::ReuseNestedDelimiter,
274 Self::ShortWhereItFits,
275 Self::UniversalNextInt,
276 Self::SizedOrNextInt,
277 ];
278
279 /// Whether this is the default.
280 #[must_use]
281 pub const fn is_default(self) -> bool {
282 matches!(self, Self::Compact)
283 }
284
285 /// Short name, for configuration and diagnostics.
286 #[must_use]
287 pub const fn as_str(self) -> &'static str {
288 match self {
289 Self::Compact => "compact",
290 Self::ReuseNestedDelimiter => "reuse-nested-delimiter",
291 Self::ShortWhereItFits => "short-where-it-fits",
292 Self::UniversalNextInt => "universal-nextint",
293 Self::SizedOrNextInt => "sized-or-nextint",
294 }
295 }
296
297 /// Whether a member's own leading word — a string's length, a
298 /// sequence's count, a nested aggregate's DHEADER — is ever spent as
299 /// the NEXTINT.
300 ///
301 /// Three of the five do it in some form; [`Self::UniversalNextInt`]
302 /// and [`Self::SizedOrNextInt`] never do, which is what leaves them
303 /// with LC0 to LC4 only.
304 #[must_use]
305 pub const fn reuses_a_leading_word(self) -> bool {
306 matches!(
307 self,
308 Self::Compact | Self::ReuseNestedDelimiter | Self::ShortWhereItFits
309 )
310 }
311
312 /// Whether a nested aggregate's own DHEADER is reused as the
313 /// NEXTINT, which is LC5 over the member rather than LC4 in front
314 /// of it.
315 #[must_use]
316 pub const fn reuses_nested_delimiter(self) -> bool {
317 matches!(self, Self::ReuseNestedDelimiter | Self::ShortWhereItFits)
318 }
319
320 /// Whether a member with no reusable leading word takes LC0 to LC3
321 /// when its serialized size is 1, 2, 4 or 8.
322 ///
323 /// The plan applies it where the type fixes the size; the writer
324 /// applies it to the rest.
325 #[must_use]
326 pub const fn short_code_where_it_fits(self) -> bool {
327 matches!(self, Self::ShortWhereItFits | Self::SizedOrNextInt)
328 }
329
330 /// Parse a configuration value.
331 ///
332 /// Accepts the names [`LengthCodePolicy::as_str`] produces. An
333 /// unknown value returns `None` rather than falling back to the
334 /// default: a typo in a wire-affecting setting must be loud.
335 #[must_use]
336 pub fn parse(value: &str) -> Option<Self> {
337 match value {
338 "compact" => Some(Self::Compact),
339 "reuse-nested-delimiter" => Some(Self::ReuseNestedDelimiter),
340 "short-where-it-fits" => Some(Self::ShortWhereItFits),
341 "universal-nextint" => Some(Self::UniversalNextInt),
342 "sized-or-nextint" => Some(Self::SizedOrNextInt),
343 _ => None,
344 }
345 }
346}
347
348/// Which collections carry a `DHEADER` under XCDR2.
349///
350/// XTypes 1.3 §7.4.3.5 rules (8) through (13): a collection of a
351/// primitive element type serialises identically under both versions,
352/// and every other collection is delimited under XCDR2. §7.2.2.2 lists
353/// the primitive types; an enumeration is a constructed type under
354/// §7.2.2.4.1 and is therefore not one.
355///
356/// # The three implementations disagree
357///
358/// Measured on 2026-08-09, XCDR2 little-endian, payload bytes:
359///
360/// | element type | Cyclone | RTI 7.7 | Fast DDS |
361/// |---|---|---|---|
362/// | `@final struct Cell`, `Cell items[2]` | 20 | 16 | 20 |
363/// | `@final struct Cell`, `sequence<Cell>` | 24 | 20 | 24 |
364/// | `@appendable struct`, `sequence<>` | 32 | 28 | 32 |
365/// | `enum Colour`, `Colour cs[2]` | 12 | 8 | 8 |
366/// | `enum Colour`, `sequence<Colour>` | 16 | 12 | 12 |
367///
368/// The runs are in `tests/fixtures/vendor_bytes.md`. Only Cyclone
369/// matches the specification on every row.
370///
371/// Changing this changes the bytes on the wire. Unlike the length-code
372/// policy, a conforming reader cannot absorb the difference: there is
373/// no flag in the stream saying whether the four bytes it is looking at
374/// are a delimiter or the first element.
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
376pub enum CollectionDelimiterPolicy {
377 /// Delimit every collection whose element type is not primitive,
378 /// enumerations included.
379 ///
380 /// The default, because it is what §7.4.3.5 says and what Cyclone
381 /// DDS emits.
382 #[default]
383 Specification,
384 /// Treat an enumeration as if it were primitive, and delimit the
385 /// rest.
386 ///
387 /// What Fast DDS emits. Select it to interoperate with a Fast DDS
388 /// peer over a topic whose type has an enum collection.
389 EnumIsPrimitive,
390 /// Never delimit a collection, whatever its element type.
391 ///
392 /// What RTI Connext 7.7 emits. Element-level delimiters an
393 /// `@appendable` element type calls for are still written; this
394 /// policy is only about the collection's own.
395 Never,
396}
397
398impl CollectionDelimiterPolicy {
399 /// Every policy.
400 pub const ALL: [Self; 3] = [Self::Specification, Self::EnumIsPrimitive, Self::Never];
401
402 /// Whether this is the default.
403 #[must_use]
404 pub const fn is_default(self) -> bool {
405 matches!(self, Self::Specification)
406 }
407
408 /// Short name, for configuration and diagnostics.
409 #[must_use]
410 pub const fn as_str(self) -> &'static str {
411 match self {
412 Self::Specification => "specification",
413 Self::EnumIsPrimitive => "enum-is-primitive",
414 Self::Never => "never",
415 }
416 }
417
418 /// Parse a configuration value.
419 ///
420 /// An unknown value returns `None` rather than falling back to the
421 /// default: a typo in a wire-affecting setting must be loud.
422 #[must_use]
423 pub fn parse(value: &str) -> Option<Self> {
424 match value {
425 "specification" => Some(Self::Specification),
426 "enum-is-primitive" => Some(Self::EnumIsPrimitive),
427 "never" => Some(Self::Never),
428 _ => None,
429 }
430 }
431}
432
433/// Which of a `map`'s two halves decides whether the map carries a
434/// delimiter under XCDR2.
435///
436/// Separate from [`CollectionDelimiterPolicy`] because it is a separate
437/// question and one measured implementation answers the two
438/// differently. That policy says *which element types* exempt a
439/// collection; this says which of a map's two element types is asked.
440///
441/// # The measurement
442///
443/// `tests/interop-results/sources/vendor-corpus.tsv`, Fast DDS 3.6.1,
444/// encoding version 2, both byte orders. Little endian, the leading
445/// word of each payload:
446///
447/// | case | the map | Fast DDS writes |
448/// |---|---|---|
449/// | `map-key-long` | `map<long, long>` | `03000000` — the entry count |
450/// | `map-key-string` | `map<string, long>` | `03000000` — the entry count |
451/// | `map-empty` | `map<string, long>` | `00000000` — the entry count |
452/// | `map-value-string` | `map<long, string>` | `22000000` — a DHEADER |
453/// | `map-value-struct` | `map<long, Cell>` | `22000000` — a DHEADER |
454/// | `map-value-sequence` | `map<long, sequence<long>>` | `1c000000` — a DHEADER |
455/// | `map-nested` | `map<long, map<long, long>>` | `1c000000` — a DHEADER |
456///
457/// A `string` **key** exempts nothing and a `string` **value** does not,
458/// so the question Fast DDS asks is the value's alone. Twelve recorded
459/// rows separate the two readings — `map-empty`, `map-key-string` and
460/// `typedef-of-map` in both orders under both of their halves — and
461/// they were the last twelve rows of the Fast DDS column that no single
462/// setting reached.
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
464pub enum MapDelimiter {
465 /// Either half. Rule (14) of XTypes 1.3 §7.4.3.5.3 exempts a map
466 /// only where the key *and* the value are primitive, so a
467 /// `map<string, long>` is delimited by rule (15).
468 ///
469 /// The default, and the clause's reading.
470 #[default]
471 EitherHalf,
472 /// The value alone, whatever the key is. Fast CDR 2.3.5's.
473 ValueOnly,
474}
475
476impl MapDelimiter {
477 /// Both readings.
478 pub const ALL: [Self; 2] = [Self::EitherHalf, Self::ValueOnly];
479
480 /// Whether this is the default.
481 #[must_use]
482 pub const fn is_default(self) -> bool {
483 matches!(self, Self::EitherHalf)
484 }
485
486 /// Short name, for configuration and diagnostics.
487 #[must_use]
488 pub const fn as_str(self) -> &'static str {
489 match self {
490 Self::EitherHalf => "either-half",
491 Self::ValueOnly => "value-only",
492 }
493 }
494
495 /// Parse a configuration value.
496 ///
497 /// An unknown value returns `None` rather than falling back to the
498 /// default: a typo in a wire-affecting setting must be loud.
499 #[must_use]
500 pub fn parse(value: &str) -> Option<Self> {
501 match value {
502 "either-half" => Some(Self::EitherHalf),
503 "value-only" => Some(Self::ValueOnly),
504 _ => None,
505 }
506 }
507}
508
509/// How a `wstring`'s length is counted, and how wide one character is.
510///
511/// XTypes 1.3 is not ambiguous here. §7.4.1.1.2: "The serialized length
512/// of an object of type `String<Char16>` shall be the number of bytes in
513/// the CDR buffer taken by the `String<Char16>` characters. This is twice
514/// the number of characters in the string", and "The representation
515/// shall also not include any terminating NUL character(s)."
516/// §7.4.3.5.3 rule (4) writes `O.ssize` as a `UInt32` and then the
517/// `Char16` units, annotated "No NUL". The three implementations write
518/// three different things anyway.
519///
520/// # The three implementations disagree
521///
522/// Measured on 2026-08-09 from `@final struct WStr { wstring w; octet
523/// tail; }`, little endian, the `w` member's bytes only:
524///
525/// | | `w = "ab"`, XCDR2 | `w = "ab"`, XCDR1 |
526/// |---|---|---|
527/// | Cyclone | `04000000 61006200` | `04000000 61006200` |
528/// | RTI 7.7 | `04000000 61006200` | `03000000 61000000 62000000 00000000` |
529/// | Fast DDS | `02000000 61006200` | `02000000 61006200` |
530///
531/// | | `w = "𝄞"` (`U+1D11E`), XCDR2 | XCDR1 |
532/// |---|---|---|
533/// | Cyclone | `04000000 34d81edd` | `04000000 34d81edd` |
534/// | RTI 7.7 | `04000000 34d81edd` | `03000000 34d80000 1edd0000 00000000` |
535/// | Fast DDS | `01000000 1ed1` | `01000000 1ed1` |
536///
537/// The runs are in `tests/fixtures/vendor_bytes.md`. Fast DDS's shape
538/// is in its own source rather than in the harness: Fast CDR 2.3.5
539/// `src/cpp/Cdr.cpp:884` computes `wstrlen * 2` for the capacity check
540/// and then serialises `wstrlen`, and writes each `wchar_t` as two
541/// bytes. `wchar_t` is four bytes wide on Linux, so `U+1D11E` leaves as
542/// its low sixteen bits, `d11e`. Past the BMP that stops being a length
543/// convention and becomes a lost character.
544///
545/// `U+D11E` is a Hangul syllable and a perfectly well-formed code
546/// point. Nothing downstream can see that it is the wrong one: the
547/// truncation happened in the sender and left no trace in the bytes.
548/// Only where the low sixteen bits land in `D800..=DFFF` does the
549/// result fail to be UTF-16 and get refused, and that is luck rather
550/// than a check.
551///
552/// # The reader takes the configured form and never infers one
553///
554/// The forms are not separable from the bytes. Fast DDS's encoding of
555/// `@final struct { wstring w; long n; }` with `w = "ab", n = 7` is
556/// `02000000 61006200 07000000`; the specification's encoding of the
557/// same type with `w = "a", n = 7` is `02000000 6100`, two bytes of
558/// padding, `07000000`. Twelve bytes each, differing only in the
559/// content of padding that no clause fixes and that RTI is measured
560/// leaving uninitialised. A reader choosing between those two samples
561/// would not be resolving an ambiguity, it would be inventing an
562/// answer, and it would be taking it from the length field — the field
563/// a hostile peer controls, and the one a reader must never let decide
564/// how much it reads. So this is a setting, not a detection.
565///
566/// What the reader owes in exchange is that every mismatch it *can* see
567/// is an error and not a shorter string:
568///
569/// - a length the configured form cannot have produced is refused, and
570/// the error names the form the length does fit and the setting that
571/// selects it. RTI's XCDR1 count of 3 and Fast DDS's odd character
572/// counts are both odd byte counts under [`WstringLengthPolicy::SPECIFICATION`];
573/// - code units that are not well-formed UTF-16 are refused rather
574/// than replaced. Substituting U+FFFD would hand the caller a
575/// character the sender never had;
576/// - whatever is left at the end of the frame has to be padding that
577/// alignment accounts for, so a short read that shifts every later
578/// member ends as an error rather than as a plausible sample.
579///
580/// Two cases survive all three. An even character count of BMP text
581/// from Fast DDS, where the next member's alignment absorbs the bytes
582/// the short read left — the twelve bytes above are that case. And a
583/// non-BMP code point Fast DDS truncated into a valid BMP one, which
584/// arrives as a well-formed string of the right length holding the
585/// wrong character. Both are undecidable from the stream, and they are
586/// the reason this is a setting at all.
587///
588/// # The width reaches a scalar `wchar` as well
589///
590/// [`WstringLengthPolicy::character_width`] is not a `wstring` detail. A `Char16` is
591/// one type and each implementation writes it at one width wherever it
592/// appears. Measured 2026-08-09, case `wchar_member`,
593/// `@final struct WCh { wchar c; octet tail; }` with `c = U+0061`:
594///
595/// | | XCDR1 LE | XCDR1 BE | XCDR2 LE | XCDR2 BE |
596/// |---|---|---|---|---|
597/// | Cyclone | `6100 ab` | `0061 ab` | `6100 ab` | `0061 ab` |
598/// | RTI 7.7 | `61000000 ab` | `00000061 ab` | `6100 ab` | `0061 ab` |
599/// | Fast DDS | `6100 ab` | `0061 ab` | `6100 ab` | `0061 ab` |
600///
601/// RTI's four-byte XCDR1 `wchar` is the same four-byte character its
602/// XCDR1 `wstring` writes, and its two-byte XCDR2 `wchar` is the same
603/// two-byte character its XCDR2 `wstring` writes. So the scalar takes
604/// [`WstringLengthPolicy::character_width`] and there is no second setting: a policy
605/// that said "four-byte `wstring` characters, two-byte `wchar`" would
606/// name no implementation.
607///
608/// This is why [`LayoutKey::offset_key`] carries the policy. A
609/// `wstring` plans as [`crate::Extent::Unbounded`] under every variant
610/// and moves no offset, but a `wchar` is a sized primitive: at four
611/// bytes it aligns to four and shifts every member after it. A cache
612/// that served a two-byte plan to a four-byte request would put them
613/// all in the wrong place.
614///
615/// A code point above the BMP has no `wchar` in any variant
616/// (§7.2.2.2.1.2.5) — the four-byte form zero-extends a code *unit*,
617/// it does not carry a code point.
618/// **C1** — what a `wstring`'s length prefix counts.
619///
620/// One of the three questions [`WstringLengthPolicy`] used to answer at
621/// once. Separated because implementations answer them independently:
622/// see that type's measured table, where two implementations agree on
623/// C1 and part on C3.
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
625pub enum WstringLengthCounts {
626 /// The number of octets the code units occupy.
627 ///
628 /// XTypes 1.3 §7.4.1.1.2, and CORBA 3.3 part 2 §9.3.1.6's GIOP 1.2
629 /// `wstring`. Cyclone, OpenDDS, omniORB, TAO and JacORB.
630 #[default]
631 Octets,
632 /// The number of code units, whatever each one weighs.
633 ///
634 /// CORBA §9.3.1.6's GIOP 1.1 `wstring`, and Fast CDR's `wcslen`.
635 /// RTI under XCDR1, Fast DDS, and both ROS type supports.
636 Characters,
637}
638
639/// **C2** — whether a terminating NUL code unit is written.
640///
641/// It is written *and* counted or neither: no measured implementation
642/// writes a terminator the length does not cover, which is why this is
643/// one question and not two.
644#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
645pub enum WstringTerminator {
646 /// No terminator. Eight of the nine measured implementations.
647 #[default]
648 Absent,
649 /// A NUL code unit at the end, inside the length.
650 ///
651 /// CORBA §9.3.1.6's GIOP 1.1 rule. RTI under XCDR1 alone among the
652 /// nine.
653 Present,
654}
655
656/// **C3** — how wide one UTF-16 code unit is on the wire.
657///
658/// The transmission code set's width. No GIOP version fixes it and
659/// XTypes Table 31 fixes it at two, so it varies independently of C1
660/// and C2 — which is the whole reason these are three types.
661#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
662pub enum WstringUnitWidth {
663 /// Two octets. XTypes Table 31, Cyclone, OpenDDS, Fast DDS,
664 /// omniORB, JacORB.
665 #[default]
666 Two,
667 /// Four octets, a code unit zero-extended — **not** a code point,
668 /// so `U+1D11E` stays the surrogate pair `34d80000 1edd0000`.
669 ///
670 /// RTI under XCDR1, TAO, and both ROS type supports.
671 Four,
672}
673
674impl WstringUnitWidth {
675 /// The width in octets.
676 #[must_use]
677 pub const fn octets(self) -> u8 {
678 match self {
679 Self::Two => 2,
680 Self::Four => 4,
681 }
682 }
683}
684
685/// How a `wstring`'s length is written and how wide its code units are
686/// — **C1 × C2 × C3**, a product space and not a list.
687///
688/// # Why this is a struct and no longer an enum
689///
690/// It was an enum of five named forms, and the enum was the defect. The
691/// five names mixed three independent questions — what the length
692/// counts, whether a terminator is included, how wide a code unit is —
693/// and a sixth question, byte order, which had no business here at all
694/// and has moved to [`WstringUnitOrder`].
695///
696/// A list of named forms can only hold the combinations somebody has
697/// already written down. Measured 2026-08-12 by reconstruction, over
698/// `wstring-unbounded-utf8`, `wstring-bounded-utf8`,
699/// `wstring-unbounded-empty` and `array-1d-wstring`: every combination
700/// of (C1, C2, C3, C4) was encoded and compared against the recorded
701/// octets, and exactly one reproduces each row.
702///
703/// | implementation | C1 counts | C2 terminator | C3 unit | C4 order |
704/// |---|---|---|--:|---|
705/// | Cyclone | octets | absent | 2 | host, unswapped |
706/// | OpenDDS | octets | absent | 2 | stream |
707/// | Fast DDS | characters | absent | 2 | stream |
708/// | RTI, XCDR1 | characters | **present** | 4 | stream |
709/// | ros-cyclone | characters | absent | **4** | stream |
710/// | ros-fastrtps | characters | absent | **4** | host, unswapped |
711/// | omniORB | octets, mark counted | absent | 2 | byte-order mark |
712/// | TAO | octets | absent | 4 | stream |
713/// | JacORB | octets | absent | 2 | big-endian always |
714///
715/// The row that forced the rebuild is either ROS one: **characters,
716/// no terminator, four-octet units**. The old enum could not reach it.
717/// `CharacterCount` was that length with two-octet units and
718/// `SpecificationWideUnits` was four-octet units under the other
719/// length; the cell between them had no name, so two implementations
720/// wrote a form this project had no way to express. That is what an
721/// enumeration standing in for a product space costs, and
722/// [`Self::ROS_TYPE_SUPPORT`] is the cell.
723///
724/// `tests::every_cell_of_the_product_is_reachable` fails if any of
725/// the eight cells stops being constructible or stops round-tripping
726/// through [`Self::parse`].
727///
728/// # Position
729///
730/// Every method here takes a [`WirePosition`], for the reason
731/// [`WcharFraming`]'s methods do: a caller must not be able to reach a
732/// member's answer for an element by not asking. On this axis the two
733/// positions are **measured to agree** — all nine implementations
734/// answer the member and the collection element identically, checked
735/// 2026-08-12 and guarded by
736/// `wstring_length_policy.rs::the_two_positions_still_agree`. That is a
737/// checked non-existence, not an assumption, and the argument is here so
738/// that the day an implementation parts them there is a place to put it
739/// rather than a signature to change.
740///
741/// # Width, offsets, and the scalar `wchar`
742///
743/// RTI's four-octet XCDR1 `wchar` is the same four-octet character its
744/// XCDR1 `wstring` writes, and its two-octet XCDR2 `wchar` the same as
745/// its XCDR2 `wstring`. So the scalar takes C3 and there is no second
746/// setting: a policy saying "four-octet `wstring` characters, two-octet
747/// `wchar`" would name no implementation.
748///
749/// This is why [`LayoutKey::offset_key`] carries it. A `wstring` plans
750/// as [`crate::Extent::Unbounded`] under every cell and moves no
751/// offset, but a `wchar` is a sized primitive: at four octets it aligns
752/// to four and shifts every member behind it. A cache that served a
753/// two-octet plan to a four-octet request would put them all in the
754/// wrong place.
755///
756/// A code point above the BMP has no `wchar` in any cell
757/// (§7.2.2.2.1.2.5) — the four-octet form zero-extends a code *unit*,
758/// it does not carry a code point.
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
760pub struct WstringLengthPolicy {
761 /// C1 — what the length prefix counts.
762 pub counts: WstringLengthCounts,
763 /// C2 — whether a terminating NUL is written and counted.
764 pub terminator: WstringTerminator,
765 /// C3 — how wide one code unit is.
766 pub unit: WstringUnitWidth,
767}
768
769impl WstringLengthPolicy {
770 /// Build a cell of the product directly.
771 ///
772 /// Public and `const` so that a cell nobody has named is as easy to
773 /// reach as one that is named. The named constants below are
774 /// conveniences for the cells an implementation was measured in;
775 /// they are not the set of legal values.
776 #[must_use]
777 pub const fn new(
778 counts: WstringLengthCounts,
779 terminator: WstringTerminator,
780 unit: WstringUnitWidth,
781 ) -> Self {
782 Self {
783 counts,
784 terminator,
785 unit,
786 }
787 }
788
789 /// Octets, no terminator, two-octet units.
790 ///
791 /// XTypes 1.3 §7.4.1.1.2 and CORBA's GIOP 1.2 `wstring` over a
792 /// two-octet code set. The default. Cyclone and OpenDDS under both
793 /// versions, RTI under XCDR2, omniORB and JacORB.
794 pub const SPECIFICATION: Self = Self::new(
795 WstringLengthCounts::Octets,
796 WstringTerminator::Absent,
797 WstringUnitWidth::Two,
798 );
799
800 /// Characters, no terminator, two-octet units.
801 ///
802 /// What Fast DDS writes under both versions. The writer refuses a
803 /// code point outside the BMP in this cell rather than truncating
804 /// it the way Fast CDR does: the truncation is undetectable at the
805 /// far end, so emitting it would be this project putting a
806 /// different character on the wire and calling it interoperability.
807 pub const CHARACTER_COUNT: Self = Self::new(
808 WstringLengthCounts::Characters,
809 WstringTerminator::Absent,
810 WstringUnitWidth::Two,
811 );
812
813 /// Characters including the terminator, four-octet units.
814 ///
815 /// CORBA 3.3 part 2 §9.3.1.6's GIOP **1.1** `wstring` over a
816 /// four-octet code set, and what RTI Connext 7.7 writes under
817 /// XCDR1 — measured 2026-08-12 at both the member and the
818 /// collection-element position, byte for byte, with no
819 /// RTI-specific rule anywhere. Its XCDR2 output is
820 /// [`Self::SPECIFICATION`].
821 pub const CHARACTER_COUNT_TERMINATED: Self = Self::new(
822 WstringLengthCounts::Characters,
823 WstringTerminator::Present,
824 WstringUnitWidth::Four,
825 );
826
827 /// Octets, no terminator, four-octet units.
828 ///
829 /// CORBA §9.3.1.6's GIOP 1.2 `wstring` over a four-octet
830 /// transmission code set — TAO 2.5.24.
831 pub const SPECIFICATION_WIDE_UNITS: Self = Self::new(
832 WstringLengthCounts::Octets,
833 WstringTerminator::Absent,
834 WstringUnitWidth::Four,
835 );
836
837 /// Characters, no terminator, four-octet units.
838 ///
839 /// **The cell the old enumeration could not reach.** Both ROS 2
840 /// type supports write it: `rosidl_typesupport_fastrtps` counts
841 /// characters like Fast CDR and hands each one over as a
842 /// four-octet `wchar_t`, and `rmw_cyclonedds_cpp` walking the
843 /// introspection type support arrives at the same octets.
844 ///
845 /// Measured 2026-08-12 from the recorded corpus. `wstring-unbounded-utf8`,
846 /// `plain-cdr1` little endian, `"äöü ሴ Z"`:
847 ///
848 /// ```text
849 /// ros-fastrtps 07000000 e4000000 f6000000 fc000000 20000000 34120000 20000000 5a000000
850 /// ros-cyclone 07000000 e4000000 f6000000 fc000000 20000000 34120000 20000000 5a000000
851 /// ```
852 ///
853 /// Seven, for seven characters, over four-octet units and no
854 /// terminator. The two are byte-identical here, and they part only
855 /// on C4 in a big-endian stream.
856 pub const ROS_TYPE_SUPPORT: Self = Self::new(
857 WstringLengthCounts::Characters,
858 WstringTerminator::Absent,
859 WstringUnitWidth::Four,
860 );
861
862 /// The cells an implementation has been measured in, for a caller
863 /// that wants to sweep the named forms.
864 ///
865 /// **Not the set of legal values** — that is the eight-cell product,
866 /// and [`Self::ALL_CELLS`] enumerates it. A caller that sweeps this
867 /// list is sweeping what has been seen, which is the right set for a
868 /// vendor comparison and the wrong one for a completeness check.
869 pub const MEASURED: [Self; 5] = [
870 Self::SPECIFICATION,
871 Self::CHARACTER_COUNT,
872 Self::CHARACTER_COUNT_TERMINATED,
873 Self::SPECIFICATION_WIDE_UNITS,
874 Self::ROS_TYPE_SUPPORT,
875 ];
876
877 /// Every cell of C1 × C2 × C3, measured or not.
878 ///
879 /// Eight, and the three that no implementation writes are as
880 /// constructible and as nameable as the five that are. A
881 /// completeness check runs over this; a vendor sweep runs over
882 /// [`Self::MEASURED`].
883 pub const ALL_CELLS: [Self; 8] = [
884 Self::new(
885 WstringLengthCounts::Octets,
886 WstringTerminator::Absent,
887 WstringUnitWidth::Two,
888 ),
889 Self::new(
890 WstringLengthCounts::Octets,
891 WstringTerminator::Absent,
892 WstringUnitWidth::Four,
893 ),
894 Self::new(
895 WstringLengthCounts::Octets,
896 WstringTerminator::Present,
897 WstringUnitWidth::Two,
898 ),
899 Self::new(
900 WstringLengthCounts::Octets,
901 WstringTerminator::Present,
902 WstringUnitWidth::Four,
903 ),
904 Self::new(
905 WstringLengthCounts::Characters,
906 WstringTerminator::Absent,
907 WstringUnitWidth::Two,
908 ),
909 Self::new(
910 WstringLengthCounts::Characters,
911 WstringTerminator::Absent,
912 WstringUnitWidth::Four,
913 ),
914 Self::new(
915 WstringLengthCounts::Characters,
916 WstringTerminator::Present,
917 WstringUnitWidth::Two,
918 ),
919 Self::new(
920 WstringLengthCounts::Characters,
921 WstringTerminator::Present,
922 WstringUnitWidth::Four,
923 ),
924 ];
925
926 /// Whether this is the default.
927 #[must_use]
928 pub const fn is_default(self) -> bool {
929 matches!(
930 (self.counts, self.terminator, self.unit),
931 (
932 WstringLengthCounts::Octets,
933 WstringTerminator::Absent,
934 WstringUnitWidth::Two
935 )
936 )
937 }
938
939 /// The name a configuration uses.
940 ///
941 /// Every one of the eight cells has one, and every one round-trips
942 /// through [`Self::parse`]. A cell an implementation was measured in
943 /// renders under the name it shipped with; the three nobody writes
944 /// render as their coordinates, `counts/terminator/width`. That a
945 /// cell nobody has named is still writable on a command line and
946 /// still readable in a diagnostic is the property the enumeration
947 /// did not have, and it is why the ROS form was unreachable for as
948 /// long as the names were the values.
949 #[must_use]
950 pub const fn as_str(self) -> &'static str {
951 match (self.counts, self.terminator, self.unit) {
952 (WstringLengthCounts::Octets, WstringTerminator::Absent, WstringUnitWidth::Two) => {
953 "specification"
954 }
955 (WstringLengthCounts::Octets, WstringTerminator::Absent, WstringUnitWidth::Four) => {
956 "specification-wide-units"
957 }
958 (WstringLengthCounts::Characters, WstringTerminator::Absent, WstringUnitWidth::Two) => {
959 "character-count"
960 }
961 (
962 WstringLengthCounts::Characters,
963 WstringTerminator::Absent,
964 WstringUnitWidth::Four,
965 ) => "ros-type-support",
966 (
967 WstringLengthCounts::Characters,
968 WstringTerminator::Present,
969 WstringUnitWidth::Four,
970 ) => "character-count-terminated",
971 // The three cells no measured implementation writes. Named
972 // by coordinate rather than left unnamed: a value that
973 // cannot be printed cannot be reported in a diagnostic, and
974 // a value that cannot be parsed cannot be reached from a
975 // command line to be measured against.
976 (WstringLengthCounts::Octets, WstringTerminator::Present, WstringUnitWidth::Two) => {
977 "octets/terminated/2"
978 }
979 (WstringLengthCounts::Octets, WstringTerminator::Present, WstringUnitWidth::Four) => {
980 "octets/terminated/4"
981 }
982 (
983 WstringLengthCounts::Characters,
984 WstringTerminator::Present,
985 WstringUnitWidth::Two,
986 ) => "characters/terminated/2",
987 }
988 }
989
990 /// The coordinate form's grammar, for a help text next to
991 /// [`Self::MEASURED`]'s five named cells.
992 ///
993 /// Kept here, beside [`Self::parse`], because a caller building a
994 /// help text out of the named cells alone would leave the three
995 /// unnamed ones unreachable from anything it printed — the defect
996 /// [`Self::ALL_CELLS`]'s own documentation describes for the old
997 /// enum. One constant next to the grammar it describes, rather than
998 /// a copy at each call site that could drift from what
999 /// [`Self::parse`] actually accepts.
1000 pub const COORDINATE_FORM_HELP: &'static str =
1001 "octets|characters / unterminated|terminated / 2|4";
1002
1003 /// Parse a configuration value.
1004 ///
1005 /// A named cell, or the three coordinates separated by `/`. Every
1006 /// value [`Self::as_str`] produces parses back to the same cell, and
1007 /// the coordinate form reaches all eight whether or not they carry a
1008 /// name. An unknown value returns `None` rather than falling back to
1009 /// the default: a typo in a wire-affecting setting must be loud.
1010 #[must_use]
1011 pub fn parse(value: &str) -> Option<Self> {
1012 match value {
1013 "specification" => return Some(Self::SPECIFICATION),
1014 "character-count" => return Some(Self::CHARACTER_COUNT),
1015 "character-count-terminated" => return Some(Self::CHARACTER_COUNT_TERMINATED),
1016 "specification-wide-units" => return Some(Self::SPECIFICATION_WIDE_UNITS),
1017 "ros-type-support" => return Some(Self::ROS_TYPE_SUPPORT),
1018 _ => {}
1019 }
1020 let mut parts = value.split('/');
1021 let counts = match parts.next()? {
1022 "octets" => WstringLengthCounts::Octets,
1023 "characters" => WstringLengthCounts::Characters,
1024 _ => return None,
1025 };
1026 let terminator = match parts.next()? {
1027 "unterminated" => WstringTerminator::Absent,
1028 "terminated" => WstringTerminator::Present,
1029 _ => return None,
1030 };
1031 let unit = match parts.next()? {
1032 "2" => WstringUnitWidth::Two,
1033 "4" => WstringUnitWidth::Four,
1034 _ => return None,
1035 };
1036 if parts.next().is_some() {
1037 return None;
1038 }
1039 Some(Self::new(counts, terminator, unit))
1040 }
1041 /// **C1** at a position — what the length prefix counts.
1042 ///
1043 /// An accessor and not the field, for the reason the type's
1044 /// documentation gives: a caller that reads `policy.counts`
1045 /// directly has answered for every position by not asking about
1046 /// any, which is exactly the shape that let a member's `wchar`
1047 /// framing stand in for an element's through five emitters. The
1048 /// nine implementations answer both positions alike, measured
1049 /// 2026-08-12, so the argument is unused — and it is the argument
1050 /// that keeps that a measurement rather than an assumption.
1051 #[must_use]
1052 pub const fn counts_at(self, _position: WirePosition) -> WstringLengthCounts {
1053 self.counts
1054 }
1055
1056 /// **C2** at a position — whether a terminating NUL is written.
1057 #[must_use]
1058 pub const fn terminator_at(self, _position: WirePosition) -> WstringTerminator {
1059 self.terminator
1060 }
1061
1062 /// **C3** at a position — how wide one code unit is.
1063 #[must_use]
1064 pub const fn unit_at(self, _position: WirePosition) -> WstringUnitWidth {
1065 self.unit
1066 }
1067
1068 /// How many octets one `Char16` takes, in a `wstring` and as a
1069 /// scalar `wchar` alike.
1070 ///
1071 /// Takes the position for the reason the type's documentation
1072 /// gives. The two positions are measured to agree, so the argument
1073 /// is unused in the body and named `_position` rather than dropped
1074 /// from the signature: a caller still cannot reach this without
1075 /// saying where it is asking from.
1076 #[must_use]
1077 pub const fn character_width(self, _position: WirePosition) -> usize {
1078 self.unit.octets() as usize
1079 }
1080
1081 /// The same width as a byte count, for the one caller that needs it
1082 /// narrow: [`WcharFraming::scalar_layout`], whose alignment answer
1083 /// has to be a `u8` and must not be produced by a fallible
1084 /// conversion.
1085 #[must_use]
1086 pub const fn char16_octets(self, _position: WirePosition) -> u8 {
1087 self.unit.octets()
1088 }
1089
1090 /// The same width as the planner wants it.
1091 ///
1092 /// A separate signature and not a cast at the call site: a plan that
1093 /// narrowed a `usize` into a `u32` with a fallback would answer 2
1094 /// for a width it did not recognise, and a wrong width is a wrong
1095 /// offset for every member behind the member.
1096 #[must_use]
1097 pub const fn char16_size(self, _position: WirePosition) -> u32 {
1098 self.unit.octets() as u32
1099 }
1100}
1101
1102/// How a scalar `wchar` is framed.
1103///
1104/// A `wstring` is delimited as a whole and a `wchar` is not, so the two
1105/// have never had to be one setting — until an ORB was asked. CORBA 3.3
1106/// part 2 §9.3.1.6 gives the scalar its own framing, and it is the one
1107/// place where the GIOP edition changes the *shape* of a member and not
1108/// only its length field.
1109///
1110/// # The three forms
1111///
1112/// Measured 2026-08-10 on the Linux test host,
1113/// `@final struct Sample { wchar a; wchar b; }` with `a = U+00E4` and
1114/// `b = U+1234`, both byte orders:
1115///
1116/// ```text
1117/// little endian big endian
1118/// zerodds e400 3412 00e4 1234
1119/// omniorb 02 00e4 02 1234 02 00e4 02 1234
1120/// tao 04 e4000000 04 34120000 (host order only)
1121/// jacorb (big endian only) 02 00e4 02 1234
1122/// ```
1123///
1124/// - [`Self::Fixed`] is XTypes 1.3 Table 31 and CORBA's GIOP **1.1**
1125/// `wchar`: a code unit of [`WstringLengthPolicy::character_width`]
1126/// octets, aligned to its own width, in the stream's byte order.
1127/// - [`Self::GiopLengthPrefixed`] is CORBA's GIOP **1.2** `wchar`: an
1128/// octet holding how many octets follow, then that many, aligned to
1129/// **one**. omniORB and JacORB write it wherever a `wchar` appears.
1130/// - [`Self::GiopLengthPrefixedMemberOnly`] is TAO's: the length octet
1131/// for a `wchar` reached as a **member**, and a bare fixed-width unit
1132/// for one reached as a collection **element**.
1133///
1134/// # The framing is per position, not per type
1135///
1136/// The third variant exists because `tao_idl`'s generated code takes
1137/// two different paths to the same IDL type, and the measurement is
1138/// what says so. Re-measured 2026-08-11 from the recorded corpus,
1139/// `plain-cdr1` little endian:
1140///
1141/// ```text
1142/// case tao omniorb
1143/// primitive-wchar 04 e4000000 04 34120000 02 00e4 02 1234
1144/// array-1d-primitives 00 e4000000 34120000 5a000000 02 00e4 02 1234 02 005a
1145/// sequence-primitives-… 03000000 e4000000 34120000 … (no wchar sequence run)
1146/// ```
1147///
1148/// In `array-1d-primitives` TAO writes one pad octet from offset 15 to
1149/// the four-octet alignment and then three bare units; omniORB writes
1150/// no padding and keeps the count octet on every element. So the two
1151/// ORBs part on the element and agree on the member, and a single
1152/// per-type answer reproduces at most one of them. [`WcharPosition`] is
1153/// the axis, and every method here takes it so that a caller cannot
1154/// reach the wrong one by omission.
1155///
1156/// # It is not a width, it is an alignment and a size
1157///
1158/// A `wchar` under [`Self::Fixed`] is a primitive of two or four octets
1159/// that aligns to two or four. Under [`Self::GiopLengthPrefixed`] it is
1160/// `1 + width` octets that align to **one**, so `@final struct { octet
1161/// head; wchar c; }` puts `c` at offset 1 and not at offset 2. Every
1162/// member behind it moves. That is why this policy is in
1163/// [`LayoutKey::offset_key`] and why it could not be modelled as another
1164/// value of the width.
1165///
1166/// The octet count is fixed per implementation rather than per value:
1167/// omniORB writes `02` for both U+00E4 and U+1234, TAO `04` for both.
1168/// It is the transmission code set's width, which is
1169/// [`WstringLengthPolicy::character_width`] again, so the plan stays a
1170/// fixed size and the two settings do not have to agree by hand.
1171///
1172/// # What it does not reach
1173///
1174/// A `wchar` inside a `wstring`. CORBA does not length-prefix the units
1175/// of a `wstring` — measured above, omniORB's `wstring` is a BOM and
1176/// then bare units — so this setting is the scalar's alone, and
1177/// [`WstringLengthPolicy`] stays the `wstring`'s.
1178///
1179/// A `wchar` member of a struct that is *itself* a collection element
1180/// is a member: the position is where the `wchar` is reached from and
1181/// not how deep the walk got there. See [`WcharPosition::inside`].
1182#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
1183pub enum WcharFraming {
1184 /// A fixed-width code unit, aligned to its own width.
1185 ///
1186 /// XTypes 1.3 Table 31, and CORBA GIOP 1.1's `wchar`. The default,
1187 /// and what all five DDS implementations write.
1188 #[default]
1189 Fixed,
1190 /// A one-octet length, then that many octets, aligned to one.
1191 ///
1192 /// CORBA 3.3 part 2 §9.3.1.6's GIOP 1.2 `wchar`, which XTypes
1193 /// Table 31 replaced. What omniORB 4.3.3 and JacORB 3.9 write in
1194 /// every position, and the reason a topic with a scalar `wchar` in
1195 /// it could not be exchanged with an ORB in either direction before
1196 /// this policy existed.
1197 ///
1198 /// The failure it replaces was loud rather than silent — omniORB
1199 /// answers a bare two-octet `wchar` with `CORBA::MARSHAL`, because
1200 /// the first octet reads as a count of `0xE4` octets and runs off
1201 /// the end. Loud, and still a topic nobody could carry.
1202 GiopLengthPrefixed,
1203 /// The length octet for a member, a bare fixed-width unit for a
1204 /// collection element. TAO 2.5.24.
1205 ///
1206 /// Not a softening of [`Self::GiopLengthPrefixed`] and not a
1207 /// tolerance: it is the second of the two paths `tao_idl` generates,
1208 /// measured in the corpus rows named on the type. An element under
1209 /// this framing is byte for byte [`Self::Fixed`] at the same
1210 /// code-unit width — which is why `--dialect rti`, whose XCDR1
1211 /// `wchar` is four octets fixed, already reproduced TAO's arrays
1212 /// while `--dialect tao` did not.
1213 GiopLengthPrefixedMemberOnly,
1214}
1215
1216impl WcharFraming {
1217 /// Every framing.
1218 pub const ALL: [Self; 3] = [
1219 Self::Fixed,
1220 Self::GiopLengthPrefixed,
1221 Self::GiopLengthPrefixedMemberOnly,
1222 ];
1223
1224 /// Whether this is the default.
1225 #[must_use]
1226 pub const fn is_default(self) -> bool {
1227 matches!(self, Self::Fixed)
1228 }
1229
1230 /// Short name, for configuration and diagnostics.
1231 #[must_use]
1232 pub const fn as_str(self) -> &'static str {
1233 match self {
1234 Self::Fixed => "fixed",
1235 Self::GiopLengthPrefixed => "giop-length-prefixed",
1236 Self::GiopLengthPrefixedMemberOnly => "giop-length-prefixed-member-only",
1237 }
1238 }
1239
1240 /// Whether a `wchar` reached at `position` carries an octet count of
1241 /// its own.
1242 ///
1243 /// The position is an argument and not a default because the two
1244 /// answers differ for one measured implementation, and a caller that
1245 /// could omit it would silently take the member's answer for an
1246 /// element — which is exactly the defect this argument closed.
1247 #[must_use]
1248 pub const fn has_length_octet(self, position: WcharPosition) -> bool {
1249 match self {
1250 Self::Fixed => false,
1251 Self::GiopLengthPrefixed => true,
1252 Self::GiopLengthPrefixedMemberOnly => position.is_member(),
1253 }
1254 }
1255
1256 /// The size and alignment of a `wchar` at a given code-unit width,
1257 /// in a given position.
1258 ///
1259 /// Returned as one pair rather than as two methods because the two
1260 /// answers are not independent: the length-prefixed form is both one
1261 /// octet longer *and* aligned to one, and a caller that took the
1262 /// size from here and the alignment from the width would put every
1263 /// following member in the wrong place.
1264 ///
1265 /// The alignment is a `u8` and the width a `u8` for the same reason
1266 /// [`WstringLengthPolicy::char16_size`] has its own signature: a
1267 /// caller that narrowed a `u32` with a fallback would answer some
1268 /// plausible alignment for a width it did not recognise, and a wrong
1269 /// alignment is a wrong offset for every member behind the member.
1270 /// Both matches here are exhaustive, so a variant added without an
1271 /// answer does not compile.
1272 #[must_use]
1273 pub const fn scalar_layout(self, position: WcharPosition, character_width: u8) -> (u8, u32) {
1274 if self.has_length_octet(position) {
1275 (1, 1 + character_width as u32)
1276 } else {
1277 (character_width, character_width as u32)
1278 }
1279 }
1280
1281 /// Parse a configuration value.
1282 ///
1283 /// An unknown value returns `None` rather than falling back to the
1284 /// default: a typo in a wire-affecting setting must be loud.
1285 #[must_use]
1286 pub fn parse(value: &str) -> Option<Self> {
1287 match value {
1288 "fixed" => Some(Self::Fixed),
1289 "giop-length-prefixed" => Some(Self::GiopLengthPrefixed),
1290 "giop-length-prefixed-member-only" => Some(Self::GiopLengthPrefixedMemberOnly),
1291 _ => None,
1292 }
1293 }
1294}
1295
1296/// How wide a scalar `wchar` is on the wire.
1297///
1298/// # Why this is not [`WstringLengthPolicy`]'s C3
1299///
1300/// It was, and the coupling was wrong. A scalar `wchar` and a
1301/// `wstring`'s code units both take a width, the two agree in eight of
1302/// the nine measured implementations, and the ninth is why this axis
1303/// exists: **`rmw_cyclonedds_cpp` 1.3.4 writes a two-octet scalar
1304/// `wchar` and four-octet `wstring` code units.** Measured against
1305/// `tests/interop-results/sources/ros-corpus.tsv`, encoding version 1,
1306/// little endian, which is the only order that type support writes:
1307///
1308/// ```text
1309/// primitive-wchar e400 3412
1310/// wstring-unbounded-utf8 07000000 e4000000 f6000000 fc000000 20000000
1311/// 34120000 20000000 5a000000
1312/// ```
1313///
1314/// `ä` and `ሴ` take two octets each as members; the same `ä` takes four
1315/// inside the `wstring`, whose length `07000000` is a count of the
1316/// seven characters. `rosidl_typesupport_fastrtps_c` 2.2.2 writes four
1317/// in both positions — `e400000034120000` for the same pair — so the two
1318/// ROS 2 type supports part here and nowhere else in the wide-character
1319/// family except the unit order.
1320///
1321/// Before this axis existed the two questions were one, so the
1322/// `ros-cyclone` column's `wstring` rows and its `wchar` rows were
1323/// reproduced by two **different** settings and by no single one. That
1324/// is what a dialect for it could not be built out of.
1325///
1326/// # What it does not decide
1327///
1328/// The width of a `wstring`'s code units, which is
1329/// [`WstringUnitWidth`]; whether a count octet precedes the value,
1330/// which is [`WcharFraming`]; and the byte order, which is
1331/// [`WstringUnitOrder`] for a `wstring` and the stream's for a scalar
1332/// under every DDS implementation measured.
1333#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
1334pub enum WcharWidth {
1335 /// Two octets. XTypes 1.3 Table 31, and seven of the nine measured
1336 /// implementations: Cyclone, OpenDDS, Fast DDS, RTI under XCDR2,
1337 /// omniORB, JacORB and `rmw_cyclonedds_cpp`.
1338 #[default]
1339 Two,
1340 /// Four octets, a code unit zero-extended — **not** a code point,
1341 /// so a value above the BMP is still refused.
1342 ///
1343 /// RTI under XCDR1, TAO, and `rosidl_typesupport_fastrtps_c`.
1344 Four,
1345}
1346
1347impl WcharWidth {
1348 /// Both widths.
1349 pub const ALL: [Self; 2] = [Self::Two, Self::Four];
1350
1351 /// Whether this is the default.
1352 #[must_use]
1353 pub const fn is_default(self) -> bool {
1354 matches!(self, Self::Two)
1355 }
1356
1357 /// The width in octets.
1358 #[must_use]
1359 pub const fn octets(self) -> u8 {
1360 match self {
1361 Self::Two => 2,
1362 Self::Four => 4,
1363 }
1364 }
1365
1366 /// Short name, for configuration and diagnostics.
1367 #[must_use]
1368 pub const fn as_str(self) -> &'static str {
1369 match self {
1370 Self::Two => "2",
1371 Self::Four => "4",
1372 }
1373 }
1374
1375 /// Parse a configuration value.
1376 ///
1377 /// An unknown value returns `None` rather than falling back to the
1378 /// default: a typo in a wire-affecting setting must be loud.
1379 #[must_use]
1380 pub fn parse(value: &str) -> Option<Self> {
1381 match value {
1382 "2" => Some(Self::Two),
1383 "4" => Some(Self::Four),
1384 _ => None,
1385 }
1386 }
1387}
1388
1389/// Where a `wchar` is reached from.
1390///
1391/// The one axis [`WcharFraming`] varies along, and the reason it exists
1392/// is a measurement rather than a symmetry: `tao_idl` writes GIOP 1.2's
1393/// length octet for a `wchar` member and no length octet for a `wchar`
1394/// inside an array or a sequence. Nothing else in this crate's layout
1395/// varies with position, so this enum is deliberately small and
1396/// deliberately not a general traversal context.
1397#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
1398pub enum WirePosition {
1399 /// Reached as a member of a structure, a union branch, or as the
1400 /// whole of a top-level value. The default, because it is the
1401 /// position every value was in before collections were measured.
1402 ///
1403 /// **Measured** on [`WcharFraming`] — `tao_idl` writes GIOP 1.2's
1404 /// length octet here and not at [`Self::Element`] — and on
1405 /// [`WstringLengthPolicy`], where it is measured to *agree* with
1406 /// [`Self::Element`] in all nine implementations.
1407 #[default]
1408 Member,
1409 /// Reached as an element of an array or a sequence.
1410 ///
1411 /// A `wstring`'s code units are **not** this: they are not `wchar`s
1412 /// on the wire at all and are laid out by [`WstringLengthPolicy`].
1413 ///
1414 /// **Measured** on [`WcharFraming`] and on
1415 /// [`WstringLengthPolicy`]; see [`Self::Member`].
1416 Element,
1417 /// The discriminator of a union.
1418 ///
1419 /// **Prospective.** No axis has been measured to answer this
1420 /// differently from [`Self::Member`]. It is here because a
1421 /// discriminator is reached by a path of its own in every emitter,
1422 /// so an axis that starts varying with it has somewhere to say so
1423 /// without a signature change.
1424 Discriminator,
1425 /// A key holder's copy of a member.
1426 ///
1427 /// **Prospective.** [`KeyHolderExtensibility`] and
1428 /// [`KeyHolderOrder`] are today whole-holder settings rather than
1429 /// per-position ones; if a per-member difference is ever measured
1430 /// this is where it lands.
1431 KeyHolderMember,
1432 /// The sentinel that closes an XCDR1 parameter list.
1433 ///
1434 /// **Measured** on [`PidFlagPolicy`], which is the axis that reads
1435 /// this variant: [`PidFlagPolicy::must_understand_at`] and
1436 /// [`PidFlagPolicy::impl_extension_at`] both take a position, the
1437 /// writer asks them at [`Self::Member`] for a member id and here for
1438 /// `PID_LIST_END`, and the four implementations' answers differ
1439 /// between the two positions. The measurement is on
1440 /// [`PidFlagPolicy`].
1441 Sentinel,
1442 /// The `PID_EXTENDED` marker that opens an XCDR1 parameter's
1443 /// extended header.
1444 ///
1445 /// **Measured** on [`PidFlagPolicy`]. The flag was
1446 /// `PlCdr1HeaderPolicy::marker_must_understand`, on the argument
1447 /// that the marker exists only under
1448 /// [`PlCdr1HeaderPolicy::AlwaysExtended`] and its flag is therefore
1449 /// a property of the form. Eight recorded rows contradict that:
1450 /// `xcdr.rs::needs_extended_pid` writes the marker under
1451 /// [`PlCdr1HeaderPolicy::Specification`] too — for a member id above
1452 /// `PID_SHORT_MAX_ID` or a value the short length cannot hold — and
1453 /// the four implementations answer it as follows, on
1454 /// `annotation-hashid-implicit` and `annotation-id-sparse`:
1455 ///
1456 /// ```text
1457 /// implementation framing form the marker
1458 /// Cyclone AlwaysExtended 7f01
1459 /// RTI Specification 7f01
1460 /// Fast DDS ShortTrueLength 3f01
1461 /// OpenDDS ShortTrueLength ff01 — with FLAG_IMPL_EXTENSION
1462 /// ```
1463 ///
1464 /// Two forms answer alike and two forms answer differently, which is
1465 /// judgement 3 of `docs/wire-axes-followup.md`: a field two forms
1466 /// answer differently and two forms answer alike is not a field of
1467 /// the form. The three [`PidFlagPolicy`] values answer all four
1468 /// implementations here with no fourth value.
1469 ExtendedMarker,
1470}
1471
1472impl WirePosition {
1473 /// Every position.
1474 pub const ALL: [Self; 6] = [
1475 Self::Member,
1476 Self::Element,
1477 Self::Discriminator,
1478 Self::KeyHolderMember,
1479 Self::Sentinel,
1480 Self::ExtendedMarker,
1481 ];
1482
1483 /// The positions on which some axis has a **measured** difference or
1484 /// a **measured** agreement, as against those carried prospectively.
1485 ///
1486 /// Kept apart because measured and provided-for must not look alike:
1487 /// a reader who cannot tell them apart reads the next measurement as
1488 /// a confirmation of something that was never checked.
1489 /// [`Self::Member`] and [`Self::Element`] are measured on
1490 /// [`WcharFraming`] (they differ) and on [`WstringLengthPolicy`]
1491 /// (they agree, in nine implementations); [`Self::Sentinel`] and
1492 /// [`Self::ExtendedMarker`] are measured on [`PidFlagPolicy`], which
1493 /// reads both. The other two are prospective.
1494 pub const MEASURED: [Self; 4] = [
1495 Self::Member,
1496 Self::Element,
1497 Self::Sentinel,
1498 Self::ExtendedMarker,
1499 ];
1500
1501 /// Whether some axis has been measured at this position.
1502 #[must_use]
1503 pub const fn is_measured(self) -> bool {
1504 matches!(
1505 self,
1506 Self::Member | Self::Element | Self::Sentinel | Self::ExtendedMarker
1507 )
1508 }
1509
1510 /// Whether this is the member position.
1511 #[must_use]
1512 pub const fn is_member(self) -> bool {
1513 matches!(self, Self::Member)
1514 }
1515
1516 /// Short name, for configuration and diagnostics.
1517 #[must_use]
1518 pub const fn as_str(self) -> &'static str {
1519 match self {
1520 Self::Member => "member",
1521 Self::Element => "element",
1522 Self::Discriminator => "discriminator",
1523 Self::KeyHolderMember => "key-holder-member",
1524 Self::Sentinel => "sentinel",
1525 Self::ExtendedMarker => "extended-marker",
1526 }
1527 }
1528
1529 /// The position an element of a collection is reached in.
1530 ///
1531 /// A named constructor rather than a literal at the call sites so
1532 /// that "descending into a collection" is one decision written once:
1533 /// a sequence of arrays of `wchar` reaches the same answer as an
1534 /// array of `wchar`, and neither has to say so itself.
1535 #[must_use]
1536 pub const fn inside_collection() -> Self {
1537 Self::Element
1538 }
1539
1540 /// The position the members of an aggregate are reached in.
1541 ///
1542 /// An aggregate resets the position: a `wchar` member of a struct
1543 /// that is itself an array element takes the member's framing,
1544 /// because `tao_idl` reaches it through the struct's own marshaller.
1545 /// Unmeasured against TAO — the corpus has no such case — and it is
1546 /// the reading the generated code's shape gives, recorded here so
1547 /// that the day a case is added there is something to contradict.
1548 #[must_use]
1549 pub const fn inside(self) -> Self {
1550 Self::Member
1551 }
1552}
1553
1554/// The name the position axis shipped under, when it reached only
1555/// `wchar`.
1556///
1557/// Judgement 1 of `docs/wire-axes-followup.md` makes the position first
1558/// class on every axis, so the type is [`WirePosition`] now. The alias
1559/// stays because the argument for the rename is in the rename, not in
1560/// breaking every caller in one commit.
1561pub type WcharPosition = WirePosition;
1562
1563/// In what byte order a `wstring`'s code units go out, and whether a
1564/// byte-order mark travels with them.
1565///
1566/// One axis and one decision, because the two halves are one: an
1567/// implementation that writes a mark reads the units by it, and one that
1568/// writes no mark has to have a rule for a stream that carries none.
1569///
1570/// # What was measured
1571///
1572/// The write direction, 2026-08-10 on the Linux test host,
1573/// `@final struct Sample { wstring w; octet tail; }` with `w = "a𝄞b"`:
1574///
1575/// ```text
1576/// little-endian stream
1577/// zerodds 08000000 6100 34d8 1edd 6200 ab
1578/// omniorb 0a000000 fffe 6100 34d8 1edd 6200 ab
1579/// big-endian stream
1580/// zerodds 00000008 0061 d834 dd1e 0062 ab
1581/// omniorb 00000010 fffe e400 … ("äöü ሴ Z", the long case)
1582/// ```
1583///
1584/// omniORB swaps the **length** word into the stream's order and leaves
1585/// the mark and the content alone, always little-endian on this x86-64
1586/// host. In a big-endian stream its mark therefore reads as `U+FFFE`,
1587/// the byte-swapped mark, which is precisely the signal that the units
1588/// that follow are opposite to the stream — so omniORB's output is
1589/// self-describing where Cyclone's identically-ordered output
1590/// (`WstringLengthPolicy::UnswappedUnits`) is not.
1591///
1592/// The read direction, from `docs/corba-column.md`'s `array-1d-wstring`
1593/// run over a little-endian stream carrying no mark:
1594///
1595/// ```text
1596/// zerodds {"items":["","äZ","ሴ"]}
1597/// omniorb {"items":["","娀","㐒"]}
1598/// jacorb {"items":["","娀","㐒"]}
1599/// ```
1600///
1601/// Both ORBs decode the units as UTF-16 **big-endian** whatever the
1602/// stream says. That is not a defect in either: XTypes §7.4.1.1.2 makes
1603/// the units the stream's through `ESWAP` (row A10 of the inventory),
1604/// and the OMG code-set rule for a GIOP 1.2 `wstring` makes them
1605/// UTF-16's own — big-endian in the absence of a mark. Each side is
1606/// self-consistent and they cannot talk, and the failure is the silent
1607/// kind: a `wstring` comes back well formed, of the right length, and
1608/// holding different characters.
1609///
1610/// # Why this is a second setting and not a fifth `WstringLengthPolicy`
1611///
1612/// Because it crosses the length convention rather than extending it.
1613/// omniORB's mark rides on the octet count of
1614/// [`WstringLengthPolicy::SPECIFICATION`]; nothing stops a GIOP 1.1
1615/// count from carrying one too. Folding the order into the length enum
1616/// is what produced `WstringLengthPolicy::UnswappedUnits`, which is
1617/// this axis's little-endian point wearing a length policy's name, and
1618/// doing it again would give eight variants for two questions.
1619///
1620/// `WstringLengthPolicy::UnswappedUnits` therefore stays where it
1621/// shipped, and the pair `(UnswappedUnits, anything but
1622/// [`Self::Stream`])` is **refused** rather than resolved — see
1623/// `WstringUnitOrder::conflicts_with_length`, which is retired. Two settings that both
1624/// decide one byte order, with a precedence rule between them, is the
1625/// shape that produces a wrong character and no error.
1626#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
1627pub enum WstringUnitOrder {
1628 /// The stream's own byte order, no mark.
1629 ///
1630 /// XTypes 1.3 §7.4.1.1.2 through `ESWAP`, and what Cyclone, RTI and
1631 /// Fast DDS write in little endian and RTI and Fast DDS write in
1632 /// big. The default.
1633 #[default]
1634 Stream,
1635 /// UTF-16 big-endian whatever the stream says, no mark.
1636 ///
1637 /// What omniORB and JacORB **decode**, and the CORBA rule for a
1638 /// GIOP 1.2 `wstring` that carries no mark. Reachable as
1639 /// `--wstring-units big-endian-always` and selected by
1640 /// [`Dialect::JacOrb`].
1641 ///
1642 /// JacORB's write direction cannot tell this apart from
1643 /// [`Self::Stream`]: `org.jacorb.orb.CDROutputStream` has no
1644 /// `setLittleEndian`, so every JacORB byte string measured here is
1645 /// big-endian and the two forms coincide in it. What is measured is
1646 /// JacORB's *reader*, which takes the units big-endian out of a
1647 /// little-endian stream — so this is the form a JacORB peer reads
1648 /// correctly, which is the half that decides what a writer should
1649 /// do. Recorded as an extrapolation from the read direction, in the
1650 /// place a reader of [`Dialect::JacOrb`] will look for it.
1651 BigEndianAlways,
1652 /// A byte-order mark, then the units, both little-endian.
1653 ///
1654 /// What omniORB 4.3.3 writes. The mark is `U+FEFF`, its two octets
1655 /// are counted in the length, and it is not a character of the
1656 /// string.
1657 ///
1658 /// On **read** the mark decides: `FEFF` in the stream's order means
1659 /// the units follow in the stream's order, `FFFE` means they are
1660 /// opposite, and no mark at all means big-endian — the CORBA rule,
1661 /// and the one that makes this variant read a [`Self::BigEndianAlways`]
1662 /// peer as well as an omniORB one.
1663 ///
1664 /// On **write** the mark and the units are little-endian, because
1665 /// that is what was measured, on x86-64, in both stream orders.
1666 /// "Little-endian" and not "the host's order" for the reason
1667 /// `WstringLengthPolicy::UnswappedUnits` gives at more length: a
1668 /// big-endian host running omniORB would presumably write big-endian
1669 /// units and a plain `FEFF`, and that is not something a peer on a
1670 /// little-endian host can produce or this project can measure.
1671 ///
1672 /// XTypes §7.4.1.1.2 forbids the mark outright — "The representation
1673 /// shall also not include any … byte-order mark" — so no dialect of a
1674 /// DDS implementation selects this and the default never will.
1675 ByteOrderMark,
1676 /// The units always little-endian, whatever the stream says, and no
1677 /// mark.
1678 ///
1679 /// **This point reproduces a peer misreading its own output**, and
1680 /// it is on this axis because it is a byte order. It arrived as
1681 /// `WstringLengthPolicy::UnswappedUnits` — a byte order wearing a
1682 /// length policy's name — and judgement 2 of
1683 /// `docs/wire-axes-followup.md` moved it here, which also retired
1684 /// the `(UnswappedUnits, non-Stream)` conflict rule: two settings
1685 /// that both decide one byte order, with a precedence between them,
1686 /// is the shape that produces a wrong character and no error. One
1687 /// axis cannot conflict with itself.
1688 ///
1689 /// Cyclone swaps the length word for a big-endian stream and does
1690 /// not swap the content, so its big-endian `"ab"` leaves as
1691 /// `00000004 61006200` where RTI and Fast DDS write
1692 /// `00000004 00610062`. Read as the big-endian stream the
1693 /// encapsulation identifier says it is, Cyclone's `"ab"` is U+6100
1694 /// U+6200 — two different characters, well-formed UTF-16, the right
1695 /// length, and nothing in the frame saying so. Cyclone reads its own
1696 /// bytes back correctly because it unswaps them the same way; every
1697 /// conforming peer gets the other string.
1698 ///
1699 /// `rosidl_typesupport_fastrtps` does the same, measured
1700 /// 2026-08-12: `wstring-unbounded-utf8` big endian is
1701 /// `00000007 e4000000 …`, a big-endian length over little-endian
1702 /// four-octet units. Two implementations, and the second one is
1703 /// what makes this a point on the axis rather than one product's
1704 /// defect.
1705 ///
1706 /// In little endian it is byte for byte [`Self::Stream`], which is
1707 /// why the divergence took a big-endian run to find.
1708 ///
1709 /// "Little-endian" and not "the host's order" because that is what
1710 /// was measured: Cyclone `memcpy`s its native UTF-16 buffer, and
1711 /// every run is on x86-64. A big-endian host would presumably write
1712 /// big-endian units and be byte-identical to [`Self::Stream`] there
1713 /// — unmeasured, and not something a peer on a little-endian host
1714 /// can produce.
1715 ///
1716 /// No [`Dialect`] takes it as a default, including
1717 /// [`Dialect::Cyclone`]: a default has to be the form a conforming
1718 /// reader gets right. It exists because refusing to model it means
1719 /// nobody can exchange a `wstring` with Cyclone over big endian at
1720 /// all, and "we decline to talk to that peer" is not a wire question
1721 /// the project gets to leave unanswered.
1722 HostUnswapped,
1723}
1724
1725impl WstringUnitOrder {
1726 /// Every order.
1727 pub const ALL: [Self; 4] = [
1728 Self::Stream,
1729 Self::BigEndianAlways,
1730 Self::ByteOrderMark,
1731 Self::HostUnswapped,
1732 ];
1733
1734 /// Whether this is the default.
1735 #[must_use]
1736 pub const fn is_default(self) -> bool {
1737 matches!(self, Self::Stream)
1738 }
1739
1740 /// Short name, for configuration and diagnostics.
1741 #[must_use]
1742 pub const fn as_str(self) -> &'static str {
1743 match self {
1744 Self::Stream => "stream",
1745 Self::BigEndianAlways => "big-endian-always",
1746 Self::ByteOrderMark => "byte-order-mark",
1747 Self::HostUnswapped => "host-unswapped",
1748 }
1749 }
1750
1751 /// Whether a mark is written, and expected on read.
1752 #[must_use]
1753 pub const fn has_mark(self) -> bool {
1754 matches!(self, Self::ByteOrderMark)
1755 }
1756
1757 /// In what byte order one code unit goes out.
1758 ///
1759 /// Takes the position for the reason [`WstringLengthPolicy`]'s
1760 /// methods do. Measured to agree at the member and the collection
1761 /// element in all nine implementations.
1762 #[must_use]
1763 pub const fn writes_little_endian_units(self, _position: WirePosition) -> bool {
1764 matches!(self, Self::ByteOrderMark | Self::HostUnswapped)
1765 }
1766
1767 /// Parse a configuration value.
1768 ///
1769 /// An unknown value returns `None` rather than falling back to the
1770 /// default: a typo in a wire-affecting setting must be loud.
1771 #[must_use]
1772 pub fn parse(value: &str) -> Option<Self> {
1773 match value {
1774 "stream" => Some(Self::Stream),
1775 "big-endian-always" => Some(Self::BigEndianAlways),
1776 "byte-order-mark" => Some(Self::ByteOrderMark),
1777 "host-unswapped" => Some(Self::HostUnswapped),
1778 _ => None,
1779 }
1780 }
1781}
1782
1783/// How a PL_CDR1 parameter is framed, and how the list ends.
1784///
1785/// XCDR1 frames a `@mutable` member as `{ PID, length }` — the short
1786/// header of XTypes 1.3 §7.4.1.2.2 — or as `PID_EXTENDED` followed by a
1787/// 32-bit id and a 32-bit length, §7.4.1.2.3, which the short form's
1788/// sixteen bits cannot express. The clause says when the extended form
1789/// is *required*; it does not forbid using it when it is not, and one
1790/// implementation uses nothing else.
1791///
1792/// # The three implementations disagree
1793///
1794/// Measured on 2026-08-09 from `@mutable struct Ext { long a; double b;
1795/// short c; }` with `a=7, b=1.5, c=-3`, XCDR1 little endian:
1796///
1797/// ```text
1798/// cyclone 017f0800 00000000 04000000 07000000
1799/// 017f0800 01000000 08000000 0000000000 00f83f
1800/// 017f0800 02000000 04000000 fdff0000
1801/// 027f0000 (56)
1802///
1803/// rti 00000400 07000000
1804/// 01000800 0000000000 00f83f
1805/// 02000400 fdff0000
1806/// 027f0000 (32)
1807///
1808/// fastdds 00000400 07000000
1809/// 01000800 0000000000 00f83f
1810/// 02000200 fdff 0000
1811/// 023f0000 (32)
1812/// ```
1813///
1814/// The run is in `tests/fixtures/vendor_bytes.md`. Three things vary,
1815/// and no two of them vary independently, which is why this is one
1816/// policy and not three:
1817///
1818/// - **which header form.** Cyclone writes `PID_EXTENDED` for every
1819/// member and never the short one. The other two use the short form
1820/// wherever it fits — including for a `string` or a `sequence`, whose
1821/// length the plan cannot bound. `needs_extended_pid` therefore plans
1822/// the short form for an unbounded member and `Writer::finish_pid`
1823/// rewrites the header on the sample that actually overruns sixteen
1824/// bits, which is the only way to write RTI's 24-byte `mut_string`
1825/// and still be correct on a 70 KiB one.
1826/// - **what the length field counts.** `short c` is two bytes followed
1827/// by two of padding. Cyclone and RTI both declare `4`; Fast DDS
1828/// declares `2` and leaves the padding outside. XTypes 1.3 §7.4.1.2
1829/// gives the exact length, and that is the default
1830/// ([`PlCdr1HeaderPolicy::Specification`]). DDSI-RTPS 2.5 §9.4.2.11
1831/// says the length is always a multiple of four — this project's own
1832/// RTPS decoder refuses a `parameterLength` that is not one
1833/// (`crates/rtps/src/parameter_list.rs`, "ParameterList length not
1834/// 4-byte aligned") — and that reading is a named setting,
1835/// [`PlCdr1HeaderPolicy::ShortRoundedLength`]. A reader realigns after
1836/// the value either way; whether it accepts the other convention's
1837/// length is [`PlCdr1Reading`].
1838///
1839/// So this policy answers two things and not three. The **sentinel's**
1840/// flag was a third field of it and the **`PID_EXTENDED` marker's** was
1841/// a fourth; both are [`PidFlagPolicy`] now, asked at
1842/// [`WirePosition::Sentinel`] and [`WirePosition::ExtendedMarker`].
1843///
1844/// The sentinel moved because it is not a framing question: OpenDDS
1845/// writes Fast DDS's framing — the short header, the true length — with
1846/// a member id no framing here produces, and the sentinel has to be
1847/// answerable separately from the form to say so.
1848///
1849/// The marker moved for the same reason, and against a claim this
1850/// comment used to make: "the marker exists only under
1851/// [`Self::AlwaysExtended`], which is why its flag is a property of the
1852/// form". It does not. `needs_extended_pid` writes the marker under
1853/// [`Self::Specification`] as well, for a member id above
1854/// `PID_SHORT_MAX_ID` or a value the short length cannot hold, and RTI
1855/// — which is [`Self::Specification`] — sets the bit there. Eight
1856/// recorded rows differ in exactly that octet. Cyclone and RTI write
1857/// different forms and the same marker; RTI and Fast DDS both produce
1858/// the marker without being [`Self::AlwaysExtended`] and write
1859/// different markers. [`WirePosition::ExtendedMarker`] carries the
1860/// table.
1861///
1862/// A *member's* own must-understand bit is not one of these three, and
1863/// it used to be. It is not a PL_CDR1 question: Fast DDS omits the flag
1864/// under both encoding versions and Cyclone and RTI write it under
1865/// both, so [`MustUnderstandPolicy`] carries it and this policy does
1866/// not.
1867///
1868/// Under XCDR2 the framing disagreement disappears — all three produce
1869/// the same thirty bytes for `ext_mutable`, because the EMHEADER has no
1870/// short form to choose. The must-understand disagreement does not.
1871#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
1872pub enum PlCdr1HeaderPolicy {
1873 /// The short header wherever §7.4.1.2.2 allows it and the declared
1874 /// length exactly the length of the serialized member.
1875 ///
1876 /// The default, as XTypes 1.3 §7.4.1.2 has it: "the value of the
1877 /// parameter length is the exact length of the serialized member. It
1878 /// does not account for any padding bytes that may follow the
1879 /// serialized member". The next parameter starts on a four-byte
1880 /// boundary. DDSI-RTPS 2.5 §9.4.2.11 says the length is always a
1881 /// multiple of four; that reading is [`Self::ShortRoundedLength`].
1882 #[default]
1883 Specification,
1884 /// The short header wherever §7.4.1.2.2 allows it and the declared
1885 /// length rounded up to four.
1886 ///
1887 /// DDSI-RTPS 2.5 §9.4.2.11 ("the length is always a multiple of
1888 /// four"), and what RTI Connext 7.7 writes for a sample whose type
1889 /// stays within the short form's length — see
1890 /// [`Self::ExtendedForConstructedInLargeSamples`]. A ParameterList
1891 /// reader that checks the multiple-of-four rule accepts it.
1892 ShortRoundedLength,
1893 /// `PID_EXTENDED` for every member, with the must-understand bit on
1894 /// the marker, and the same rounded length.
1895 ///
1896 /// What Cyclone DDS writes. Twelve bytes of header per member
1897 /// instead of four, and legal under §7.4.1.2.3 — the clause makes
1898 /// the extended form mandatory above the short form's limits, not
1899 /// exclusive to them.
1900 AlwaysExtended,
1901 /// The short header and the value's own length, with the padding
1902 /// outside it.
1903 ///
1904 /// What Fast DDS writes, and what a ParameterList reader that
1905 /// checks the multiple-of-four rule refuses. Select it to
1906 /// reproduce a Fast DDS peer's XCDR1 bytes.
1907 ShortTrueLength,
1908 /// `PID_EXTENDED` for every member whose type is not a primitive or
1909 /// an enumeration, the short header for those; lengths as
1910 /// [`Self::ShortRoundedLength`].
1911 ///
1912 /// What RTI Connext 7.7 writes for a sample past the step
1913 /// [`Self::ExtendedForConstructedInLargeSamples`] describes. A plan
1914 /// is given it by [`LayoutKey::for_sample`], not by a dialect.
1915 ExtendedForConstructed,
1916 /// [`Self::ShortRoundedLength`] for a sample whose type stays within the
1917 /// short form's length, [`Self::ExtendedForConstructed`] for one that
1918 /// can exceed it; [`LayoutKey::for_sample`] makes the choice for the
1919 /// sample's type, and every type nested in it is framed by that
1920 /// choice.
1921 ///
1922 /// What RTI Connext 7.7 writes. Measured on rtiddsgen's type support
1923 /// through `PRESTypePlugin_interpretedSerialize`, XCDR1 little
1924 /// endian: `@mutable struct T { long id; sequence<long, 100> small;
1925 /// sequence<octet, N> mid; }` frames every member short up to
1926 /// N = 65068 and `small` and `mid` with `PID_EXTENDED` from
1927 /// N = 65069, `id` short throughout; without `small` the step is at
1928 /// 65485. A `@mutable struct Inner { long a; sequence<long, 100> s;
1929 /// }`, small on its own, writes `s` extended as a member of a sample
1930 /// past the step. `boolean`, `octet`, `char`, `wchar`, `int8`,
1931 /// `uint8`, the integers, `float`, `double`, an `enum` and a typedef
1932 /// of `long` keep the short header there; a `string`, a sequence, a
1933 /// `@final` struct of two `long`, `long[2]` and a union arm take the
1934 /// extended one. The same in a `@mutable` union and for the
1935 /// `@optional` members of an `@appendable` struct. Where the step
1936 /// lies is `crate::large_sample`'s count.
1937 ExtendedForConstructedInLargeSamples,
1938}
1939
1940impl PlCdr1HeaderPolicy {
1941 /// Every policy.
1942 pub const ALL: [Self; 6] = [
1943 Self::Specification,
1944 Self::ShortRoundedLength,
1945 Self::AlwaysExtended,
1946 Self::ShortTrueLength,
1947 Self::ExtendedForConstructed,
1948 Self::ExtendedForConstructedInLargeSamples,
1949 ];
1950
1951 /// Whether this is the default.
1952 #[must_use]
1953 pub const fn is_default(self) -> bool {
1954 matches!(self, Self::Specification)
1955 }
1956
1957 /// Short name, for configuration and diagnostics.
1958 #[must_use]
1959 pub const fn as_str(self) -> &'static str {
1960 match self {
1961 Self::Specification => "specification",
1962 Self::ShortRoundedLength => "short-rounded-length",
1963 Self::AlwaysExtended => "always-extended",
1964 Self::ShortTrueLength => "short-true-length",
1965 Self::ExtendedForConstructed => "extended-for-constructed",
1966 Self::ExtendedForConstructedInLargeSamples => {
1967 "extended-for-constructed-in-large-samples"
1968 }
1969 }
1970 }
1971
1972 /// Whether the parameter that just ended writes its own trailing
1973 /// padding to the next four-byte boundary.
1974 ///
1975 /// Every header writer aligns itself before writing, so *between*
1976 /// two parameters the two readings produce the same octets. They
1977 /// part at the end of the stream: RTI writes the trailing pad
1978 /// after a final `@optional` parameter and Fast DDS does not —
1979 /// measured 2026-08-24 on `annotation-must-understand-unannotated`
1980 /// under XCDR1, 24 octets against 22 for the same values.
1981 /// [`Self::ShortTrueLength`] is Fast DDS's spelling of PL_CDR1 and
1982 /// carries this answer with the true length it is named for.
1983 #[must_use]
1984 pub const fn pads_after_value(self) -> bool {
1985 !matches!(self, Self::ShortTrueLength)
1986 }
1987
1988 /// Whether every member takes the extended header, whatever its id
1989 /// and length.
1990 #[must_use]
1991 pub const fn always_extended(self) -> bool {
1992 matches!(self, Self::AlwaysExtended)
1993 }
1994
1995 /// Whether a member whose type is not a primitive or an enumeration
1996 /// takes the extended header, whatever its id and length.
1997 #[must_use]
1998 pub const fn extends_constructed(self) -> bool {
1999 matches!(self, Self::ExtendedForConstructed)
2000 }
2001
2002 /// Whether the form depends on the sample's type, and
2003 /// [`LayoutKey::for_sample`] has a choice to make.
2004 #[must_use]
2005 pub const fn decided_per_sample(self) -> bool {
2006 matches!(self, Self::ExtendedForConstructedInLargeSamples)
2007 }
2008
2009 /// Whether a `@mutable` member's declared length covers the value's
2010 /// trailing padding.
2011 #[must_use]
2012 pub const fn pads_declared_length(self) -> bool {
2013 matches!(
2014 self,
2015 Self::ShortRoundedLength
2016 | Self::AlwaysExtended
2017 | Self::ExtendedForConstructed
2018 | Self::ExtendedForConstructedInLargeSamples
2019 )
2020 }
2021
2022 /// The same question for an `@optional` member of a type that is not
2023 /// `@mutable`, which one implementation answers the other way.
2024 ///
2025 /// Measured 2026-08-09 with `@final struct Sample { long a;
2026 /// @optional octet b; long c; };`, `b = 0xAB`, XCDR1 little endian:
2027 /// Cyclone declares `1` for the one-byte value where the same
2028 /// Cyclone declares `4` for the two-byte `short c` of `ext_mutable`.
2029 /// RTI declares `4` in both places and Fast DDS the true length in
2030 /// both. So the padded reading is not a property of the
2031 /// implementation but of the role the parameter is in, and
2032 /// [`PlCdr1HeaderPolicy::pads_declared_length`] alone made
2033 /// `Dialect::Cyclone` wrong about one of the two whichever way it
2034 /// was set. `crates/ast-layout/tests/fixtures/vendor_bytes.md` §1.
2035 #[must_use]
2036 pub const fn pads_declared_optional_length(self) -> bool {
2037 matches!(
2038 self,
2039 Self::ShortRoundedLength
2040 | Self::ExtendedForConstructed
2041 | Self::ExtendedForConstructedInLargeSamples
2042 )
2043 }
2044
2045 /// Parse a configuration value.
2046 ///
2047 /// An unknown value returns `None` rather than falling back to the
2048 /// default: a typo in a wire-affecting setting must be loud.
2049 #[must_use]
2050 pub fn parse(value: &str) -> Option<Self> {
2051 match value {
2052 "specification" => Some(Self::Specification),
2053 "short-rounded-length" => Some(Self::ShortRoundedLength),
2054 "always-extended" => Some(Self::AlwaysExtended),
2055 "short-true-length" => Some(Self::ShortTrueLength),
2056 "extended-for-constructed" => Some(Self::ExtendedForConstructed),
2057 "extended-for-constructed-in-large-samples" => {
2058 Some(Self::ExtendedForConstructedInLargeSamples)
2059 }
2060 _ => None,
2061 }
2062 }
2063}
2064
2065/// How tolerant a PL_CDR1 reader is of a stream another implementation
2066/// framed.
2067///
2068/// XTypes 1.3 §7.4.1.2.1 asks a reader to be "robust to any setting of the
2069/// FLAG_MUST_UNDERSTAND bit and accept the parameter nevertheless" and
2070/// §7.4.1.2.2 calls the short and the extended header of one parameter
2071/// "totally equivalent". Implementations differ in exactly those places:
2072/// the flags on `PID_EXTENDED` and on `PID_LIST_END` (Cyclone and RTI
2073/// `0x7f..`, Fast DDS `0x3f..`, OpenDDS `0xff01`/`0x3f02`), the header form
2074/// (Cyclone writes the extended header for every member) and whether a
2075/// parameter's length counts the padding after its value. A reader built
2076/// for one framing ([`PlCdr1HeaderPolicy`], [`PidFlagPolicy`]) refuses a
2077/// stream in another with `WireError::WrongFraming`, naming the setting
2078/// that would read it. This setting is the other answer.
2079///
2080/// The default is [`Self::Strict`]: the reader takes the framing it was
2081/// configured for and reports a stream in any other. A stream that is
2082/// malformed (a length that fits neither convention, a value that overruns
2083/// its parameter) is refused under both.
2084#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
2085pub enum PlCdr1Reading {
2086 /// The reader accepts the framing it is configured for and reports
2087 /// the flags, the header form and the length convention of another.
2088 #[default]
2089 Strict,
2090 /// The reader accepts any flags on `PID_EXTENDED` and `PID_LIST_END`,
2091 /// the short and the extended header of a parameter whichever form it
2092 /// is configured for, and a declared length that is either the
2093 /// value's own length or that length rounded up to four.
2094 Tolerant,
2095}
2096
2097impl PlCdr1Reading {
2098 /// Every reading.
2099 pub const ALL: [Self; 2] = [Self::Strict, Self::Tolerant];
2100
2101 /// Whether this is the default.
2102 #[must_use]
2103 pub const fn is_default(self) -> bool {
2104 matches!(self, Self::Strict)
2105 }
2106
2107 /// Whether the reader takes a stream in another framing.
2108 #[must_use]
2109 pub const fn tolerant(self) -> bool {
2110 matches!(self, Self::Tolerant)
2111 }
2112
2113 /// Short name, for configuration and diagnostics.
2114 #[must_use]
2115 pub const fn as_str(self) -> &'static str {
2116 match self {
2117 Self::Strict => "strict",
2118 Self::Tolerant => "tolerant",
2119 }
2120 }
2121
2122 /// Parse a configuration value.
2123 ///
2124 /// An unknown value returns `None` rather than falling back to the
2125 /// default.
2126 #[must_use]
2127 pub fn parse(value: &str) -> Option<Self> {
2128 match value {
2129 "strict" => Some(Self::Strict),
2130 "tolerant" => Some(Self::Tolerant),
2131 _ => None,
2132 }
2133 }
2134}
2135
2136/// What flags a PL_CDR1 parameter id carries, asked per
2137/// [`WirePosition`].
2138///
2139/// XTypes 1.3 §7.4.1.2 splits the sixteen-bit field: the low fourteen
2140/// bits are the member id, bit 14 is `FLAG_MUST_UNDERSTAND` and bit 15
2141/// is `FLAG_IMPL_EXTENSION`. Two ids in the reserved range are not
2142/// members — `PID_EXTENDED` (`0x3F01`) and `PID_LIST_END` (`0x3F02`) —
2143/// and the clause says nothing about what flags either of them carries.
2144///
2145/// # The axis, and why the sentinel is a position on it
2146///
2147/// Measured from the recorded corpus, `struct-mutable`
2148/// (`@mutable struct { long a; double b; short c; }`) and
2149/// `evolution-mutable-mustunderstand`
2150/// (`@id(1) long a; @id(2) long b; @id(3) @must_understand long c;`),
2151/// XCDR1 big endian:
2152///
2153/// ```text
2154/// implementation a member's PID the sentinel
2155/// Cyclone extended form; must-understand as declared 7f02
2156/// RTI 0001, 0002, and 4003 for the declared one 7f02
2157/// Fast DDS 0001, 0002, 0003 — never the flag 3f02
2158/// OpenDDS c001, c002, c003 — both flags, always 3f02
2159/// ```
2160///
2161/// Cyclone and RTI write the same sentinel octets while answering the
2162/// member as declared, and OpenDDS writes a bare sentinel while setting
2163/// every member's flags unconditionally. The sentinel's answer therefore
2164/// cannot be read off the member's: OpenDDS differs from Cyclone at both
2165/// positions, from Fast DDS at the member only, and agrees with Fast DDS
2166/// at the sentinel. Two independent implementations agreeing at the
2167/// sentinel while differing at the member is judgement 3 of
2168/// `docs/wire-axes-followup.md`, and it is what makes
2169/// [`WirePosition::Sentinel`] a measured position rather than a
2170/// property of the list.
2171///
2172/// # What is not on this axis
2173///
2174/// - **A member's own `must_understand`.** That is
2175/// [`MustUnderstandPolicy`], because it is answered the same way under
2176/// both encoding versions and this axis exists only under version 1.
2177/// The two compose: [`Self::ImplExtensionOnMembers`] sets the bit
2178/// whatever the member declared, and a member that also declares it
2179/// gets the same octets.
2180/// - **The `eMemberHeader`'s own flags in the extended form.**
2181/// [`Self::ImplExtensionOnMembers`] reaches the sixteen-bit parameter
2182/// id and not the 28-bit member id behind a `PID_EXTENDED` marker.
2183/// Measured, and a checked non-existence rather than an omission:
2184/// OpenDDS's `annotation-hashid-implicit` writes
2185/// `ff010008 0343172c 00000004` — both flags on the marker, and a
2186/// **bare** `eMemberHeader` for a member that declares nothing. That
2187/// is what this policy's writers do.
2188///
2189/// # The third position
2190///
2191/// [`WirePosition::ExtendedMarker`] is answered here too, and it moved
2192/// off [`PlCdr1HeaderPolicy`] for the reason the sentinel did. All four
2193/// implementations, on `annotation-hashid-implicit` and
2194/// `annotation-id-sparse`:
2195///
2196/// ```text
2197/// implementation framing form the marker
2198/// Cyclone AlwaysExtended 7f01
2199/// RTI Specification 7f01
2200/// Fast DDS ShortTrueLength 3f01
2201/// OpenDDS ShortTrueLength ff01
2202/// ```
2203///
2204/// Two framing forms answer alike and two answer differently, so the
2205/// marker's flags are not a property of the form. The three values here
2206/// answer all four with no fourth value.
2207#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
2208pub enum PidFlagPolicy {
2209 /// Nothing on a member id, `FLAG_MUST_UNDERSTAND` on the sentinel.
2210 ///
2211 /// The default, and what Cyclone DDS and RTI Connext 7.7 both write.
2212 #[default]
2213 SentinelMustUnderstand,
2214 /// Nothing anywhere: a bare member id and a bare `0x3f02`.
2215 ///
2216 /// What Fast DDS writes.
2217 Bare,
2218 /// `FLAG_IMPL_EXTENSION | FLAG_MUST_UNDERSTAND` on every member id,
2219 /// whatever the member declared, and a bare sentinel.
2220 ///
2221 /// What OpenDDS writes — `c0000004` where Fast DDS writes
2222 /// `00000004`, with the same short header, the same true length and
2223 /// the same `3f02`. No dialect selects it, because OpenDDS is a
2224 /// recorded peer rather than a preset here; it is reached by
2225 /// `--pid-flags impl-extension-members`.
2226 ImplExtensionOnMembers,
2227}
2228
2229impl PidFlagPolicy {
2230 /// Every policy.
2231 pub const ALL: [Self; 3] = [
2232 Self::SentinelMustUnderstand,
2233 Self::Bare,
2234 Self::ImplExtensionOnMembers,
2235 ];
2236
2237 /// Whether this is the default.
2238 #[must_use]
2239 pub const fn is_default(self) -> bool {
2240 matches!(self, Self::SentinelMustUnderstand)
2241 }
2242
2243 /// Short name, for configuration and diagnostics.
2244 #[must_use]
2245 pub const fn as_str(self) -> &'static str {
2246 match self {
2247 Self::SentinelMustUnderstand => "sentinel-must-understand",
2248 Self::Bare => "bare",
2249 Self::ImplExtensionOnMembers => "impl-extension-members",
2250 }
2251 }
2252
2253 /// Whether a parameter id at `position` carries
2254 /// `FLAG_MUST_UNDERSTAND` whatever the member declared.
2255 ///
2256 /// The member's own declaration is [`MustUnderstandPolicy`] and is
2257 /// OR-ed on top of this by the writer; this answers the part that
2258 /// does not depend on the declaration.
2259 ///
2260 /// Only [`WirePosition::Member`], [`WirePosition::Sentinel`] and
2261 /// [`WirePosition::ExtendedMarker`] have a parameter id at all. The
2262 /// other two answer `false` because there is nothing there to carry
2263 /// a flag, not because a policy decided so.
2264 #[must_use]
2265 pub const fn must_understand_at(self, position: WirePosition) -> bool {
2266 matches!(
2267 (self, position),
2268 (
2269 Self::SentinelMustUnderstand,
2270 WirePosition::Sentinel | WirePosition::ExtendedMarker
2271 ) | (
2272 Self::ImplExtensionOnMembers,
2273 WirePosition::Member | WirePosition::ExtendedMarker
2274 )
2275 )
2276 }
2277
2278 /// Whether a parameter id at `position` carries
2279 /// `FLAG_IMPL_EXTENSION`.
2280 #[must_use]
2281 pub const fn impl_extension_at(self, position: WirePosition) -> bool {
2282 matches!(
2283 (self, position),
2284 (
2285 Self::ImplExtensionOnMembers,
2286 WirePosition::Member | WirePosition::ExtendedMarker
2287 )
2288 )
2289 }
2290
2291 /// Parse a configuration value.
2292 ///
2293 /// An unknown value returns `None` rather than falling back to the
2294 /// default: a typo in a wire-affecting setting must be loud.
2295 #[must_use]
2296 pub fn parse(value: &str) -> Option<Self> {
2297 match value {
2298 "sentinel-must-understand" => Some(Self::SentinelMustUnderstand),
2299 "bare" => Some(Self::Bare),
2300 "impl-extension-members" => Some(Self::ImplExtensionOnMembers),
2301 _ => None,
2302 }
2303 }
2304}
2305
2306/// Whether a member's `must_understand` reaches the wire.
2307///
2308/// XTypes 1.3 says it does, in both encoding versions and without a
2309/// condition. §7.4.1.2.2 for the XCDR1 parameter header:
2310/// `FLAG_MUST_UNDERSTAND` "shall be set if and only if the
2311/// must_understand property of the member is set to true"; §7.4.3.4.2
2312/// for the XCDR2 EMHEADER's `M_FLAG`, which is the same property in the
2313/// same place in the header's top bit.
2314///
2315/// # One implementation never writes it, in either version
2316///
2317/// Measured 2026-08-09, case `mut_mustunderstand`,
2318/// `@mutable struct M { @id(1) @must_understand long a; @id(2) long b; };`
2319/// with `a = 7, b = -2`, little endian:
2320///
2321/// ```text
2322/// XCDR1 XCDR2
2323/// cyclone 017f0800 010000|40| 04000000 10000000 0100 00a0 07000000
2324/// rti 01|40|0400 07000000 10000000 0100 00a0 07000000
2325/// fastdds 01000400 07000000 10000000 0100 0020 07000000
2326/// ```
2327///
2328/// The bit is in a different place in each form — bit 14 of the short
2329/// parameter id, `FLAG_2` of the extended `eMemberHeader`, bit 31 of the
2330/// EMHEADER — and Fast DDS omits it in all three. So this is one
2331/// property of one implementation and not a property of a framing, which
2332/// is why it is its own policy and not a fourth axis of
2333/// [`PlCdr1HeaderPolicy`]. Carrying it there made `Dialect::FastDds`
2334/// right about the XCDR1 half and wrong about the XCDR2 half, which is
2335/// the inventory's I11.
2336///
2337/// The default writes the bit. A member declared `@must_understand`
2338/// that goes out looking skippable is a member a peer will skip, which
2339/// is the failure this project ranks above all others.
2340///
2341/// # The clause implies the bit on a `@key` member, and three
2342/// implementations do not write it
2343///
2344/// §7.2.2.4.4.4.8, verbatim: "Key members shall never be optional, and
2345/// they shall always have their 'must understand' attribute set to
2346/// true." That is the type system's clause and not the data
2347/// representation's, so it reaches every encoding version through
2348/// whichever bit the framing puts the attribute in.
2349///
2350/// Measured on `evolution-mutable-key`
2351/// (`@mutable struct { @id(1) @key long a; @id(2) long b; @id(3) @key
2352/// long k; }`), XCDR2 big endian:
2353///
2354/// ```text
2355/// cyclone 00000018 20000001 00000007 20000002 00000009 20000003 0000000b
2356/// rti 00000018 20000001 … 20000002 … 20000003 …
2357/// fastdds 00000018 20000001 … 20000002 … 20000003 …
2358/// opendds 00000018 a0000001 … 20000002 … a0000003 …
2359/// ```
2360///
2361/// OpenDDS sets `M_FLAG` on the two `@key` members and on neither of the
2362/// others; Cyclone, RTI and Fast DDS set it on none. Three
2363/// implementations against a clause with no second reading, and one with
2364/// it. The clause is not silent here — it is the one place in this
2365/// project's measured set where the departure is **this tree's** and not
2366/// a peer's, so it is a value of this axis and
2367/// [`Dialect::Specification`] takes it.
2368///
2369/// Under XCDR1 the question does not arise in the corpus: OpenDDS writes
2370/// [`PidFlagPolicy::ImplExtensionOnMembers`], which puts
2371/// `FLAG_MUST_UNDERSTAND` on **every** parameter id whatever the member
2372/// declared, so its `@key` members are indistinguishable from the rest.
2373/// The two rows this closes are XCDR2's.
2374#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
2375pub enum MustUnderstandPolicy {
2376 /// The flag is set wherever the member declares it, in whichever of
2377 /// the three places the framing puts it.
2378 ///
2379 /// The default, §7.4.1.2.2's and §7.4.3.4.2's reading of the
2380 /// attribute, and what Cyclone DDS, RTI Connext 7.7 and Fast DDS all
2381 /// write for a `@key` member — that is, without §7.2.2.4.4.4.8's
2382 /// implication.
2383 #[default]
2384 Declared,
2385 /// The flag is never written, in either encoding version.
2386 ///
2387 /// What Fast DDS writes. Select it to reproduce a Fast DDS peer's
2388 /// bytes; it does not change what this project's *reader* does with
2389 /// a flag it finds, which is §7.2.2.4.4.4.6's discard either way.
2390 Omitted,
2391 /// The flag is set wherever the member declares it **and** wherever
2392 /// §7.2.2.4.4.4.8 implies it: on every `@key` member.
2393 ///
2394 /// The clause read whole, and what OpenDDS writes.
2395 /// [`Dialect::Specification`] selects it, because the specification
2396 /// dialect is what the clause-by-clause column means; no vendor
2397 /// dialect does, because three vendors were measured not writing it.
2398 DeclaredAndKey,
2399 /// [`Self::DeclaredAndKey`], and the discriminator of a `@mutable`
2400 /// union carries the flag as well.
2401 ///
2402 /// §7.2.2.4.4.4.6: "In a union type, the discriminator member shall
2403 /// always have the 'must understand' attribute set to true." The
2404 /// discriminator has a member header in a `@mutable` union (rule (27)
2405 /// under XCDR2, rule (28) under XCDR1), and that header's `M_FLAG`
2406 /// (§7.4.3.4.2) or `FLAG_MUST_UNDERSTAND` (§7.4.1.2.1) is the
2407 /// attribute. RTI Connext 7.7 and OpenDDS were measured writing
2408 /// `union-mutable` and `evolution-union-unselected` with the bit
2409 /// clear on the discriminator (`20000000 00000001` under XCDR2); the
2410 /// other measured toolchains refuse a `@mutable` union. So this is the
2411 /// clause read whole, and [`Dialect::Specification`] takes it; the
2412 /// vendors' reading is [`Self::DeclaredAndKey`].
2413 DeclaredKeyAndDiscriminator,
2414}
2415
2416impl MustUnderstandPolicy {
2417 /// Every policy.
2418 pub const ALL: [Self; 4] = [
2419 Self::Declared,
2420 Self::Omitted,
2421 Self::DeclaredAndKey,
2422 Self::DeclaredKeyAndDiscriminator,
2423 ];
2424
2425 /// Whether this is the default.
2426 #[must_use]
2427 pub const fn is_default(self) -> bool {
2428 matches!(self, Self::Declared)
2429 }
2430
2431 /// Short name, for configuration and diagnostics.
2432 #[must_use]
2433 pub const fn as_str(self) -> &'static str {
2434 match self {
2435 Self::Declared => "declared",
2436 Self::Omitted => "omitted",
2437 Self::DeclaredAndKey => "declared-and-key",
2438 Self::DeclaredKeyAndDiscriminator => "declared-key-and-discriminator",
2439 }
2440 }
2441
2442 /// Whether a member declaring `must_understand` writes the flag.
2443 #[must_use]
2444 pub const fn writes_flag(self) -> bool {
2445 !matches!(self, Self::Omitted)
2446 }
2447
2448 /// Whether `@key` implies the attribute, per §7.2.2.4.4.4.8.
2449 #[must_use]
2450 pub const fn key_implies_the_flag(self) -> bool {
2451 matches!(
2452 self,
2453 Self::DeclaredAndKey | Self::DeclaredKeyAndDiscriminator
2454 )
2455 }
2456
2457 /// Whether the discriminator of a `@mutable` union carries the flag,
2458 /// per §7.2.2.4.4.4.6.
2459 #[must_use]
2460 pub const fn discriminator_is_understood(self) -> bool {
2461 matches!(self, Self::DeclaredKeyAndDiscriminator)
2462 }
2463
2464 /// Parse a configuration value.
2465 ///
2466 /// An unknown value returns `None` rather than falling back to the
2467 /// default: a typo in a wire-affecting setting must be loud.
2468 #[must_use]
2469 pub fn parse(value: &str) -> Option<Self> {
2470 match value {
2471 "declared" => Some(Self::Declared),
2472 "omitted" => Some(Self::Omitted),
2473 "declared-and-key" => Some(Self::DeclaredAndKey),
2474 "declared-key-and-discriminator" => Some(Self::DeclaredKeyAndDiscriminator),
2475 _ => None,
2476 }
2477 }
2478}
2479
2480/// How wide an enumeration's holder is on the wire.
2481///
2482/// **This axis has one specified answer and one defect.** XTypes 1.3
2483/// §7.3.1.2.1.9 gives `@bit_bound` on an enumeration and Table 32 (§7.4.1.1.3) the
2484/// serialised form: the holder is the width the bound calls for. There
2485/// is no second reading of the clause, and
2486/// [`Self::BitBoundDroppedByFrontEnd`] is not one — it reproduces two
2487/// products whose IDL front ends never carry the annotation as far as a
2488/// serialiser.
2489///
2490/// # The measurement, which is about the front ends and not the octets
2491///
2492/// M1 of `docs/wire-axes-followup.md`, taken over
2493/// `@bit_bound(8) enum Colour` and its 16-bit twin:
2494///
2495/// | front end | what it does with the annotation |
2496/// |---|---|
2497/// | `rtiddsgen` 4.7.0 | refuses at parse: `Only 32 bit bounds are accepted for enumerations.` No files generated |
2498/// | `fastddsgen` 4.3.0 | accepts, emits `enum class Colour : int32_t` and `BitBound bit_bound_Colour = 32;` for **both** bounds |
2499/// | `opendds_idl` | `error in lookup of symbol: bit_bound`; the serialiser is `strm << static_cast<ACE_CDR::Long>(enumval)` |
2500///
2501/// Cyclone DDS honours the bound — `enum-bitbound-8-plain` under XCDR1
2502/// big endian is `0200 ab`, one octet a value, and the 16-bit case is
2503/// `0002 0000 ab`. So Fast DDS's and OpenDDS's agreement on four octets
2504/// is the agreement of two front ends that dropped the same annotation,
2505/// which is the addendum to judgement 3: an implementation's octets are
2506/// evidence about an axis only where the implementation was in a
2507/// position to answer differently.
2508///
2509/// # Why it is a setting all the same
2510///
2511/// A compatibility flag has to be able to reproduce a peer's defect,
2512/// precisely because it is a defect. A peer whose front end drops
2513/// `@bit_bound` still puts four octets on the wire; a reader of this
2514/// project's one octet is three octets out of step from there on, and
2515/// every member behind the enumeration lands somewhere else. "Not an
2516/// axis" answers the model question and is not a reason to stop counting
2517/// the row.
2518///
2519/// # It reaches an enumeration and not a bitmask
2520///
2521/// M1 measured enumerations. `@bit_bound` on a `bitmask` is a different
2522/// construct in a different clause (§7.4.1.1.3.2, Table 33) and no
2523/// measurement here says what those front ends do with it, so the arms
2524/// that used to read `Enumeration | Bitmask` together are split and this
2525/// policy is applied to the enumeration half only.
2526///
2527/// # What it does not reach
2528///
2529/// OpenDDS's `enum-bitbound-{8,16}-value` rows carry a second front-end
2530/// answer on top of this one: `opendds_idl` numbers enumerators by
2531/// ordinal and drops `@value`, so `@value(200)` goes out as `00000002`.
2532/// The holder is the same four octets its `-plain` rows write; the
2533/// contents are not. That is the inventory's E1 and no width setting
2534/// reaches it —
2535/// `crates/wire-probe/tests/enum_holder_width.rs::opendds_closes_on_the_holder_and_not_on_the_numbering`
2536/// asserts the non-reproduction rather than leaving those rows out.
2537///
2538/// # No dialect selects the second value
2539///
2540/// Fast DDS and OpenDDS are recorded peers here rather than presets, for
2541/// the same reason [`PidFlagPolicy::ImplExtensionOnMembers`] is reached
2542/// only by name. `--enum-holder bit-bound-dropped` reaches it, and so
2543/// does the configuration of that name.
2544#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
2545pub enum EnumHolderWidth {
2546 /// The `@bit_bound` decides: 1, 2, 4 or 8 octets.
2547 ///
2548 /// The default, the clause, and what Cyclone DDS writes.
2549 #[default]
2550 Declared,
2551 /// Four octets whatever the annotation says.
2552 ///
2553 /// What Fast DDS and OpenDDS write, because neither front end
2554 /// carries the bound to a serialiser. Named for the cause so that a
2555 /// reader does not take it for a reading of the specification.
2556 BitBoundDroppedByFrontEnd,
2557}
2558
2559impl EnumHolderWidth {
2560 /// Both.
2561 pub const ALL: [Self; 2] = [Self::Declared, Self::BitBoundDroppedByFrontEnd];
2562
2563 /// The width a front end that drops the annotation is left with —
2564 /// the default `enum`'s, XTypes 1.3 §7.3.1.2.1.9.
2565 pub const DROPPED_BOUND: u16 = 32;
2566
2567 /// Whether this is the default.
2568 #[must_use]
2569 pub const fn is_default(self) -> bool {
2570 matches!(self, Self::Declared)
2571 }
2572
2573 /// Short name, for configuration and diagnostics.
2574 #[must_use]
2575 pub const fn as_str(self) -> &'static str {
2576 match self {
2577 Self::Declared => "declared",
2578 Self::BitBoundDroppedByFrontEnd => "bit-bound-dropped",
2579 }
2580 }
2581
2582 /// The bound an enumeration's holder is built from, given what the
2583 /// IDL declared.
2584 ///
2585 /// The one place the declared bound crosses into a wire width.
2586 /// [`crate::enumeration_bit_bound`] is what call sites use; this is
2587 /// the arithmetic behind it, separated so that the crossing can be
2588 /// tested without an `Ast`.
2589 #[must_use]
2590 pub const fn holder_bound(self, declared: u16) -> u16 {
2591 match self {
2592 Self::Declared => declared,
2593 Self::BitBoundDroppedByFrontEnd => Self::DROPPED_BOUND,
2594 }
2595 }
2596
2597 /// Parse a configuration value.
2598 ///
2599 /// An unknown value returns `None` rather than falling back to the
2600 /// default: a typo in a wire-affecting setting must be loud.
2601 #[must_use]
2602 pub fn parse(value: &str) -> Option<Self> {
2603 match value {
2604 "declared" => Some(Self::Declared),
2605 "bit-bound-dropped" => Some(Self::BitBoundDroppedByFrontEnd),
2606 _ => None,
2607 }
2608 }
2609}
2610
2611/// Whether a case label of a narrow signed discriminator can be
2612/// negative.
2613///
2614/// IDL 4.2 §7.4.1.4.4.2.4 makes a case label a value **of the
2615/// discriminator's type**, and XTypes 1.3 §7.4.3.5.3 rule (26) writes
2616/// the discriminator and then the member the labels select. Nothing in
2617/// either clause is width-dependent: `case -13:` on an `int8` selects
2618/// its branch for `_d = -13` exactly as `case -1234567:` on a `long`
2619/// does.
2620///
2621/// # What one implementation does instead
2622///
2623/// Cyclone DDS 11.0.1 compares the label against the discriminator's
2624/// octets read **unsigned**, so `f3` is 243 and not `-13`, and `cfc7` is
2625/// 53191 and not `-12345`. No label matches, and what happens next is
2626/// whether the union declared a `default:`:
2627///
2628/// | the union | what Cyclone writes | measured on |
2629/// |---|---|---|
2630/// | no `default:` | the discriminator and **no branch at all** | `union-discriminator-{int8,short}`, four octets shorter |
2631/// | a `default:` | the discriminator and the union's **storage read through the default branch** | `combined-everything`, four and eight octets longer |
2632///
2633/// It is the label's width and its sign together. `union-discriminator-long`
2634/// carries `case -1234567:` and agrees byte for byte;
2635/// `union-discriminator-{octet,uint8,ushort}` carry unsigned narrow
2636/// labels and agree byte for byte. Only the pair diverges.
2637///
2638/// # The storage, and why it is not the member zero-padded
2639///
2640/// `combined-everything-unannotated`, `case -1: long whole; default:
2641/// double fraction;`, `whole = 0x7fffffff`, XCDR1:
2642///
2643/// ```text
2644/// le cyclone ffff 0000 00000000 ffffff7f 00000000
2645/// be cyclone ffff 0000 00000000 00000000 7fffffff
2646/// ```
2647///
2648/// The discriminator, padding to the `double`'s eight-alignment, and
2649/// then eight octets. The big-endian row is the same eight octets
2650/// reversed **whole**: a member zero-padded to the branch's width would
2651/// give `7fffffff 00000000` there. So what is written is the union's
2652/// storage — the selected member's octets in the host's order at offset
2653/// zero, zeros above them — emitted as one value of the *default*
2654/// branch's width in the stream's byte order.
2655///
2656/// The zero octets are guaranteed rather than lucky:
2657/// `wireprobe-zerodds-c-gen` declares the sample as
2658/// `static {root_type} zd_sample;`, and §6.7.9 of the C standard
2659/// zero-initialises a static object.
2660///
2661/// This is a **host-storage** dependence, the same class as
2662/// [`WstringUnitOrder::HostUnswapped`], and it is recorded here for the
2663/// same reason: the corpus was measured on x86-64 and "little-endian"
2664/// is what was measured, not "the host's order". The C column reaches
2665/// it through a real C union and would follow a big-endian host; the
2666/// Rust and Python columns synthesise the little-endian octets. The two
2667/// coincide on every host this project measures on and would part on a
2668/// big-endian one — unmeasured, and stated rather than left to be
2669/// discovered.
2670///
2671/// # No dialect selects the second value
2672///
2673/// Not even [`Dialect::Cyclone`], which is this project's default. A
2674/// default has to be the form a conforming reader gets right, and this
2675/// one drops the selected branch off the wire: a peer reading
2676/// `union-discriminator-short` takes the member *after* the union for
2677/// the branch. That is a silent wrong value, and shipping it to every
2678/// caller who wrote `--dialect cyclone` would be shipping the defect.
2679/// `--union-labels negative-unmatched-below-32` reaches it, and so does
2680/// the configuration of that name — the same construction
2681/// [`EnumHolderWidth::BitBoundDroppedByFrontEnd`] and
2682/// [`PidFlagPolicy::ImplExtensionOnMembers`] are reached by.
2683///
2684/// # What it reaches, and what it does not
2685///
2686/// [`crate::union_negative_matches_nothing`] is the one crossing, and it
2687/// answers two questions.
2688///
2689/// The **discriminator** has to be a signed integer primitive narrower
2690/// than 32 bits — `int8` and `short`. Not `octet`, `char` or `boolean`,
2691/// none of which can carry a negative label; not `long` or `long long`,
2692/// which were measured to agree; and **not an enumeration**
2693/// discriminator: `union-discriminator-enum` declares no negative
2694/// `@value`, so nothing measured says what Cyclone does with one and
2695/// answering would be a claim about octets nobody has.
2696///
2697/// Every **branch** has to be a primitive of one, two, four or eight
2698/// octets, because the storage above is what a `default:` branch is read
2699/// through and a `string` or `sequence` branch is read as a *pointer*.
2700/// `union-member-sequence` is
2701/// `switch (short) { case -1: sequence<long> ids; default: string text; }`,
2702/// its `-1` is the union's only label, and
2703/// `tests/interop-results/sources/vendor-corpus.tsv` records Cyclone on
2704/// it and on its twin — both encoding versions, both byte orders — as
2705/// `refused — the probe was killed: Segmentation fault`. There are no
2706/// octets to reproduce, so the setting does not reach the shape and this
2707/// project writes the clause's frame there: a complete frame a
2708/// conforming reader can use.
2709///
2710/// It is **not** a planner setting. A union's extent is the
2711/// discriminator plus the widest branch under both values — with a
2712/// `default:` Cyclone writes the widest branch's storage, and without
2713/// one the extent still bounds what any value of the discriminator could
2714/// produce. Nothing here reaches an offset, so it stays out of
2715/// [`LayoutKey::offset_key`], and that is a claim rather than an
2716/// omission.
2717#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
2718pub enum UnionLabelMatch {
2719 /// A case label is compared against the discriminator's own signed
2720 /// value.
2721 ///
2722 /// The clause, and what RTI, Fast DDS and this project write.
2723 #[default]
2724 SignExtended,
2725 /// A negative label on a signed discriminator narrower than 32 bits
2726 /// matches nothing.
2727 ///
2728 /// What Cyclone DDS writes. Named for the behaviour and not for the
2729 /// vendor, so that a reader does not take it for a reading of
2730 /// §7.4.1.4.4.2.4.
2731 NegativeLabelUnmatchedBelow32,
2732}
2733
2734impl UnionLabelMatch {
2735 /// Both.
2736 pub const ALL: [Self; 2] = [Self::SignExtended, Self::NegativeLabelUnmatchedBelow32];
2737
2738 /// The width at or above which a signed label still matches.
2739 ///
2740 /// Thirty-two: `union-discriminator-long`'s `case -1234567:` was
2741 /// measured to match and `union-discriminator-short`'s `case
2742 /// -12345:` was measured not to.
2743 pub const MATCHING_WIDTH: u16 = 32;
2744
2745 /// Whether this is the default.
2746 #[must_use]
2747 pub const fn is_default(self) -> bool {
2748 matches!(self, Self::SignExtended)
2749 }
2750
2751 /// Short name, for configuration and diagnostics.
2752 #[must_use]
2753 pub const fn as_str(self) -> &'static str {
2754 match self {
2755 Self::SignExtended => "sign-extended",
2756 Self::NegativeLabelUnmatchedBelow32 => "negative-unmatched-below-32",
2757 }
2758 }
2759
2760 /// Whether a label of the given value, on a discriminator of the
2761 /// given signedness and width, selects its branch.
2762 ///
2763 /// The arithmetic behind [`crate::union_negative_matches_nothing`],
2764 /// separated so that the crossing can be tested without an `Ast`.
2765 #[must_use]
2766 pub const fn label_matches(self, label: i128, signed: bool, bits: u16) -> bool {
2767 match self {
2768 Self::SignExtended => true,
2769 Self::NegativeLabelUnmatchedBelow32 => {
2770 !(signed && bits < Self::MATCHING_WIDTH && label < 0)
2771 }
2772 }
2773 }
2774
2775 /// Parse a configuration value.
2776 ///
2777 /// An unknown value returns `None` rather than falling back to the
2778 /// default: a typo in a wire-affecting setting must be loud.
2779 #[must_use]
2780 pub fn parse(value: &str) -> Option<Self> {
2781 match value {
2782 "sign-extended" => Some(Self::SignExtended),
2783 "negative-unmatched-below-32" => Some(Self::NegativeLabelUnmatchedBelow32),
2784 _ => None,
2785 }
2786 }
2787}
2788
2789/// In what order a `map`'s entries go on the wire.
2790///
2791/// XTypes 1.3 §7.4.3.5.3 rules (14) and (15) give `MAP_TYPE` as a length
2792/// and a run of key-value pairs and say **nothing** about the order of
2793/// the pairs. Rule (16) is the same for encoding version 1. The order is
2794/// therefore not a reading of the clause in either direction; it is
2795/// whatever the binding's container does.
2796///
2797/// # What one implementation does
2798///
2799/// `fastddsgen` 4.3.0 maps an IDL `map<K,V>` to `std::map<K,V>`, which
2800/// is ordered by key, so Fast DDS serialises entries in ascending key
2801/// order whatever order the sample declared them in. Measured on
2802/// `map-key-long`, entries `[(-1234567, 1), (2147483647, -2), (1, 3)]`,
2803/// `plain-cdr1` big endian:
2804///
2805/// ```text
2806/// ours 00000003 ffed2979 00000001 7fffffff fffffffe 00000001 00000003 ffffffff
2807/// theirs 00000003 ffed2979 00000001 00000001 00000003 7fffffff fffffffe ffffffff
2808/// ```
2809///
2810/// A permutation, and nothing else: the count is the same, the length is
2811/// the same and every entry is present.
2812///
2813/// The evidence is complete rather than illustrative. **Exactly the four
2814/// `map-key-*` cases whose sample is not already in ascending key order
2815/// diverge, and every map case whose sample already is agrees.**
2816/// `map-key-boolean` is `[true, false]`, `map-key-char` is
2817/// `['a', 'Z', '~']`, `map-key-long` is `[-1234567, 2147483647, 1]` and
2818/// `map-key-ulonglong` is `[16045690984833335998, 7]`; `map-bounded`,
2819/// `map-nested`, `map-value-sequence`, `map-value-string`,
2820/// `map-value-struct`, `sequence-map`, `array-1d-map`,
2821/// `evolution-mutable-map` and `map-key-string` are all ascending
2822/// already and all agree byte for byte.
2823///
2824/// # The order is the key's own, and the corpus separates three of them
2825///
2826/// Not the encoded octets and not the canonical text — the key type's
2827/// own value order, which is what `std::map`'s default `std::less`
2828/// gives:
2829///
2830/// - `map-key-long` puts `-1234567` first, so a **signed** integer key
2831/// is compared signed. An unsigned reading would put it last.
2832/// - `map-key-ulonglong` puts `7` before `16045690984833335998`, so an
2833/// **unsigned** integer key is compared unsigned. A signed reading of
2834/// `0xdeadbeefdeadc2be` is negative and would put it first.
2835/// - `map-key-string`'s `["", "alpha", "üሴ"]` stays as declared, so a
2836/// `string` key is compared by **unsigned** octets — `std::string`'s
2837/// `char_traits<char>::compare`, which is `memcmp` semantics. A signed
2838/// octet comparison reads `üሴ`'s leading `c3` as negative and would
2839/// put that entry first, which Fast DDS does not.
2840///
2841/// # What the corpus does not separate, stated rather than assumed
2842///
2843/// `map-key-char`'s keys are `'a'`, `'Z'` and `'~'` — all below `0x80`,
2844/// so nothing here says whether a `char` key is compared signed or
2845/// unsigned. An IDL `char` is a C++ `char`, whose signedness is
2846/// implementation-defined and which is **signed** on x86-64 Linux, where
2847/// the corpus was recorded. That is what is written, in all three
2848/// columns and explicitly rather than by following each column's
2849/// compiler, so that the answer is the same on a host whose `char` is
2850/// unsigned. The same class as [`WstringUnitOrder::HostUnswapped`] and
2851/// recorded for the same reason.
2852///
2853/// `map-key-enum` says nothing either: `wireprobe-fastdds` records it as
2854/// `refused — fastddsgen failed` in every slot, because its key is a
2855/// `@bit_bound(16)` enumeration. An enumeration key is ordered by its
2856/// **declared value** here — `enum class Colour : int32_t` under
2857/// `std::less` is the underlying integer — and no recorded octets reach
2858/// that arm.
2859///
2860/// # Two dialects select the second value
2861///
2862/// [`Dialect::FastDds`] and [`Dialect::OpenDds`]. Fast DDS was the only
2863/// implementation in the corpus that produced `map` octets when this
2864/// axis was built; the Cyclone and RTI probes still refuse a `map`,
2865/// because the IDL-to-C mapping §7.5.1 has no map type, and OpenDDS's
2866/// column joined on 2026-08-24. Both write ascending keys —
2867/// `map-key-long` and `map-key-ulonglong` under OpenDDS reorder the
2868/// sample's declared entries in all four encodings — so judgement 3's
2869/// threshold of two implementations is met on the *position*.
2870///
2871/// It stays a compatibility setting all the same, and that is what
2872/// judgement 3's addendum in `docs/wire-axes-followup.md` says: the
2873/// order is a property of the ordered container each code generator
2874/// chose and not of a serialisation rule either product could have
2875/// written differently. §7.4.3.5.3 rules (14) to (16) give a map as a
2876/// length and a run of pairs and say nothing about their order.
2877/// `--map-entries ascending-key` reaches it, and so does the
2878/// configuration of that name.
2879///
2880/// # It is not a planner setting, and not a reader setting
2881///
2882/// A permutation changes no length and moves no offset, so it stays out
2883/// of [`LayoutKey::offset_key`] for the same reason [`UnionLabelMatch`]
2884/// does. And a reader is unaffected: `decode::read_map` and both other
2885/// columns sort a map's entries into the canonical order before they
2886/// hand it back, because a map is a mapping and not a list. All 32 rows
2887/// decode to the case's own sample under either value — they are the one
2888/// cause in this corpus where a reader sees no difference and the wire
2889/// decision is real all the same.
2890#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
2891pub enum MapEntryOrder {
2892 /// The order the sample declares its entries in.
2893 ///
2894 /// The default, and what this project has always written: the clause
2895 /// gives no order, so the one the caller handed over is kept.
2896 #[default]
2897 Declaration,
2898 /// Ascending by key, under the key type's own value order.
2899 ///
2900 /// What a binding that holds a `map` in an ordered container writes.
2901 /// Named for the behaviour and not for the vendor, so that a reader
2902 /// does not take it for a reading of §7.4.3.5.3.
2903 AscendingKey,
2904}
2905
2906/// Whether an array's element is flattened through aliases and inner
2907/// arrays before the delimiter question ([`crate::array_is_delimited`]).
2908///
2909/// XTypes models a multi-dimensional array as one ARRAY_TYPE over the
2910/// base element, so the specification's reading — and Cyclone DDS's and
2911/// Fast DDS's, both measured byte-identical on
2912/// `resolve-array-of-typedef-array` — sees `typedef long Row[3];
2913/// Row grid[2];` as an array of `long` and writes no DHEADER. OpenDDS
2914/// stops at the alias: its generated serialiser treats the `Row`
2915/// element as a constructed type and writes the inner DHEADER —
2916/// measured 2026-08-24, 32 octets against 28 for the same values under
2917/// XCDR2. Orthogonal to [`CollectionDelimiterPolicy`]: OpenDDS and
2918/// Cyclone share `Specification` there and part here.
2919#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
2920pub enum ArrayElementFlattening {
2921 /// Through aliases and inner arrays to the base element — the
2922 /// specification's reading.
2923 #[default]
2924 ThroughAliasesAndArrays,
2925 /// The element is taken as declared: an alias of an array is a
2926 /// constructed type and the array is delimited. OpenDDS.
2927 StopsAtAlias,
2928}
2929
2930impl ArrayElementFlattening {
2931 /// Both readings.
2932 pub const ALL: [Self; 2] = [Self::ThroughAliasesAndArrays, Self::StopsAtAlias];
2933
2934 /// Whether this is the default.
2935 #[must_use]
2936 pub const fn is_default(self) -> bool {
2937 matches!(self, Self::ThroughAliasesAndArrays)
2938 }
2939
2940 /// Short name, for configuration and diagnostics.
2941 #[must_use]
2942 pub const fn as_str(self) -> &'static str {
2943 match self {
2944 Self::ThroughAliasesAndArrays => "through-aliases-and-arrays",
2945 Self::StopsAtAlias => "stops-at-alias",
2946 }
2947 }
2948
2949 /// The named reading, or `None` for anything else.
2950 #[must_use]
2951 pub fn parse(text: &str) -> Option<Self> {
2952 match text {
2953 "through-aliases-and-arrays" => Some(Self::ThroughAliasesAndArrays),
2954 "stops-at-alias" => Some(Self::StopsAtAlias),
2955 _ => None,
2956 }
2957 }
2958}
2959
2960/// Which integer an enumerator serialises as.
2961///
2962/// XTypes 1.3 §7.4.3.5.3 rule (5) is
2963/// `XCDR << {O : ENUM_TYPE} = XCDR << { O.value : O.holder_type }`, and
2964/// IDL 4.2 §7.4.13 gives `O.value` as the `@value(n)` the enumerator
2965/// declares, else the previous enumerator's value plus one, starting at
2966/// zero. So the clause has an answer, and it is [`Self::Declared`].
2967///
2968/// # What is measured
2969///
2970/// Two front ends drop `@value` and number the enumerators by position,
2971/// and 40 recorded rows differ by exactly that. `enum-negative-value`,
2972/// `enum-sparse-values`, `enum-bitbound-{8,16,32}-value` and their
2973/// unannotated twins, OpenDDS, both encoding versions and both byte
2974/// orders — the same length in every row, and the first differing octet
2975/// is octet 0:
2976///
2977/// ```text
2978/// enum-negative-value @value(-1) MINUS, @value(0) ZERO, @value(2147483647) MAX
2979/// ours ffffffff 7fffffff
2980/// theirs 00000000 00000002
2981///
2982/// enum-sparse-values @value(1) OK, @value(100) WARN, @value(65535) FAIL
2983/// ours 0000ffff 00000001 00000064
2984/// theirs 00000002 00000000 00000001
2985///
2986/// enum-bitbound-8-value @value(3) RED, @value(9) GREEN, @value(200) BLUE
2987/// ours 000000c8 00000009 ab
2988/// theirs 00000002 00000001 ab
2989/// ```
2990///
2991/// `opendds_idl` answers `error in lookup of symbol: bit_bound` and the
2992/// same for `value`; `tao_idl` drops the annotation the same way —
2993/// `docs/wire-feature-inventory.md` row E1, and
2994/// `warnings.rs::CLASSIFICATION` already carries the warning with
2995/// `carried_into: "E1"`. Cyclone, RTI and Fast DDS all carry `@value`.
2996/// Two front ends dropping the annotation and three carrying it is the
2997/// same shape as [`EnumHolderWidth`], and it takes the same answer: not
2998/// an axis by judgement 3, and a **setting** all the same, because a
2999/// peer that writes the ordinal writes the ordinal whatever the reason
3000/// and a reader of the declared value is reading a different
3001/// enumerator.
3002///
3003/// # It is not the holder's width, and the corpus separates them
3004///
3005/// The two questions look like one on `enum-bitbound-8-value`, where
3006/// OpenDDS's octets differ from this project's in both. They are not:
3007/// `enum-negative-value` declares no `@bit_bound` at all and still
3008/// differs, and `enum-bitbound-8-plain` declares `@bit_bound(8)` with no
3009/// `@value` and is closed by [`EnumHolderWidth`] alone. The 16 rows of
3010/// `enum-bitbound-{8,16}-value` need **both** — the four-octet holder
3011/// and the ordinal — which is why `EnumHolderWidth` closed 76 rows and
3012/// not the 92 an estimate gave, and why those 16 were the difference
3013/// between the causes named for OpenDDS and the rows measured open.
3014///
3015/// # It is not a planner setting
3016///
3017/// The holder's width does not change, so no offset moves and no
3018/// EMHEADER length code changes. It stays out of [`LayoutKey::offset_key`]
3019/// for the same reason [`MapEntryOrder`] and [`UnionLabelMatch`] do.
3020///
3021/// # It is a reader setting
3022///
3023/// Unlike [`MapEntryOrder`]. All 40 rows reach a reader: a peer's
3024/// `00000002` is `BLUE`'s ordinal and this project's `MAX`'s declared
3025/// value, and a decoder that took the pattern for a declared value would
3026/// name a different enumerator or none at all.
3027///
3028/// # No dialect selects the second value
3029///
3030/// For the reason [`EnumHolderWidth::BitBoundDroppedByFrontEnd`] is not
3031/// selected either: OpenDDS and TAO are recorded peers here rather than
3032/// presets, and a front-end defect is not a reading of §7.4.3.5.3.
3033/// `--enumerator-value ordinal` reaches it.
3034#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
3035pub enum EnumeratorValue {
3036 /// The value IDL 4.2 §7.4.13 gives: `@value(n)`, else the previous
3037 /// enumerator's value plus one, from zero.
3038 ///
3039 /// The default, and what Cyclone, RTI and Fast DDS write.
3040 #[default]
3041 Declared,
3042 /// The enumerator's position in its declaration, from zero,
3043 /// whatever `@value` says.
3044 ///
3045 /// What a front end that does not know the annotation writes.
3046 /// Identical to [`Self::Declared`] for an enumeration that declares
3047 /// no `@value` — which is every enumeration in the corpus but five —
3048 /// so the setting is invisible except where the annotation is.
3049 Ordinal,
3050}
3051
3052impl EnumeratorValue {
3053 /// Both.
3054 pub const ALL: [Self; 2] = [Self::Declared, Self::Ordinal];
3055
3056 /// Whether this is the default.
3057 #[must_use]
3058 pub const fn is_default(self) -> bool {
3059 matches!(self, Self::Declared)
3060 }
3061
3062 /// Short name, for configuration and diagnostics.
3063 #[must_use]
3064 pub const fn as_str(self) -> &'static str {
3065 match self {
3066 Self::Declared => "declared",
3067 Self::Ordinal => "ordinal",
3068 }
3069 }
3070
3071 /// Parse a configuration value.
3072 ///
3073 /// An unknown value returns `None` rather than falling back to the
3074 /// default: a typo in a wire-affecting setting must be loud.
3075 #[must_use]
3076 pub fn parse(value: &str) -> Option<Self> {
3077 match value {
3078 "declared" => Some(Self::Declared),
3079 "ordinal" => Some(Self::Ordinal),
3080 _ => None,
3081 }
3082 }
3083}
3084
3085/// How a `@mutable` union numbers its branches, and whether its XCDR1
3086/// parameter list is terminated.
3087///
3088/// # The clause
3089///
3090/// §7.3.1.2.1.1 numbers the members of an aggregated type sequentially
3091/// from zero, and a union's discriminator is one of its members
3092/// (§7.2.2.4.4.4). So the discriminator takes 0 and the branches run on
3093/// from 1 — the only numbering under which the discriminator cannot
3094/// collide with an implicitly numbered branch. Rule (28) of §7.4.3.5.3
3095/// then closes an XCDR1 `MUNION_TYPE` with `PID_SENTINEL` and a length
3096/// of zero, in the same line as it does for a struct.
3097///
3098/// # What is measured
3099///
3100/// One implementation departs from both, and in the same generated
3101/// code. `union-mutable`
3102/// (`@mutable union Choice switch (long) { case 1: long whole; default:
3103/// double fraction; }`, `_d = 1`), XCDR2 big endian:
3104///
3105/// ```text
3106/// rti 00000010 20000000 00000001 20000001 ffed2979
3107/// opendds 00000010 20000000 00000001 20000000 ffed2979
3108/// ```
3109///
3110/// and the same union under XCDR1, where the sentinel goes too:
3111///
3112/// ```text
3113/// ours 00000004 00000001 00010004 ffed2979 7f020000
3114/// opendds c0000004 00000001 c0000004 ffed2979
3115/// ```
3116///
3117/// The branch takes member id **0**, which the discriminator already
3118/// has, and the list ends without `PID_LIST_END`. In
3119/// `evolution-union-unselected` the union is nested inside a struct and
3120/// what follows the missing sentinel is the enclosing `long tail`: a
3121/// reader walking the parameter list takes `0000002a` for another
3122/// parameter header. RTI and Fast DDS both number the branch 1 and both
3123/// write the sentinel, so two implementations are with the clause and
3124/// one against it.
3125///
3126/// # One axis and not two, and the corpus says why
3127///
3128/// The two halves never occur apart in anything measured, so a second
3129/// axis would have a value no recorded row reaches. They are still told
3130/// apart by the rows: the four XCDR2 rows carry the **numbering** alone,
3131/// because version 2 has no sentinel to suppress, and the four XCDR1
3132/// rows carry both. A mutation of either half is red on one of the two
3133/// groups, which is what a merged axis has to be able to say before it
3134/// is allowed to merge.
3135///
3136/// # It is a planner setting
3137///
3138/// Both halves reach `TypeLayout` — the branch's `member_id` and
3139/// `needs_sentinel` — so it is in [`LayoutKey::offset_key`]. A cache
3140/// that left it out would serve a plan numbered from one to a caller
3141/// that asked for zero.
3142///
3143/// # No dialect selects the second value
3144///
3145/// It reproduces a defect: a parameter list with two parameters sharing
3146/// member id 0 and no terminator is not a frame a conforming reader can
3147/// walk. `--union-framing branches-from-zero` reaches it.
3148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
3149pub enum MutableUnionFraming {
3150 /// The discriminator takes member id 0, the branches run on from 1,
3151 /// and the XCDR1 parameter list closes with `PID_SENTINEL`.
3152 ///
3153 /// The default, the clause, and what RTI Connext 7.7 and Fast DDS
3154 /// both write.
3155 #[default]
3156 Specification,
3157 /// The branches are numbered from 0, sharing member id 0 with the
3158 /// discriminator, and the XCDR1 parameter list closes without a
3159 /// sentinel.
3160 ///
3161 /// What OpenDDS writes. Select it to reproduce an OpenDDS peer's
3162 /// `@mutable` union; it produces a frame no conforming reader can
3163 /// walk, which is why no dialect selects it.
3164 BranchesFromZero,
3165}
3166
3167impl MutableUnionFraming {
3168 /// Both.
3169 pub const ALL: [Self; 2] = [Self::Specification, Self::BranchesFromZero];
3170
3171 /// Whether this is the default.
3172 #[must_use]
3173 pub const fn is_default(self) -> bool {
3174 matches!(self, Self::Specification)
3175 }
3176
3177 /// Short name, for configuration and diagnostics.
3178 #[must_use]
3179 pub const fn as_str(self) -> &'static str {
3180 match self {
3181 Self::Specification => "specification",
3182 Self::BranchesFromZero => "branches-from-zero",
3183 }
3184 }
3185
3186 /// The member id the first branch of a `@mutable` union takes.
3187 #[must_use]
3188 pub const fn first_branch_id(self) -> u32 {
3189 match self {
3190 Self::Specification => 1,
3191 Self::BranchesFromZero => 0,
3192 }
3193 }
3194
3195 /// Whether a `@mutable` union's XCDR1 parameter list is terminated.
3196 #[must_use]
3197 pub const fn writes_the_sentinel(self) -> bool {
3198 matches!(self, Self::Specification)
3199 }
3200
3201 /// Parse a configuration value.
3202 ///
3203 /// An unknown value returns `None` rather than falling back to the
3204 /// default: a typo in a wire-affecting setting must be loud.
3205 #[must_use]
3206 pub fn parse(value: &str) -> Option<Self> {
3207 match value {
3208 "specification" => Some(Self::Specification),
3209 "branches-from-zero" => Some(Self::BranchesFromZero),
3210 _ => None,
3211 }
3212 }
3213}
3214
3215/// Which member ids fit a PL_CDR1 **short** parameter header.
3216///
3217/// §7.4.1.2.2, verbatim: "Member IDs from 0 to 16,128 (0x3F00)
3218/// inclusive shall be represented exactly in the lower 14 bits of the
3219/// parameter ID. All other member IDs must be expressed using the
3220/// extended parameter header format." The range stops one below
3221/// `PID_EXTENDED` (`0x3F01`) because `0x3F01` upwards is the reserved
3222/// range that makes `PID_EXTENDED` and `PID_LIST_END` unambiguous.
3223///
3224/// # What is measured
3225///
3226/// `annotation-id-sparse` declares `@id(16383)`, which is `0x3FFF` — in
3227/// the reserved range and out of the short form's. XCDR1 big endian:
3228///
3229/// ```text
3230/// ours 3f010008 00003fff 00000004 fffffffe (12 octets of header)
3231/// opendds ffff0004 fffffffe (4)
3232/// ```
3233///
3234/// `0xFFFF` masked to fourteen bits is `0x3FFF`, so OpenDDS uses the
3235/// whole low fourteen bits and lets the reserved range be a reserved
3236/// range only for the two ids that are actually parameters. Cyclone,
3237/// RTI and Fast DDS all write the extended header there — measured on
3238/// the same case — so this project has the clause and two other
3239/// implementations with it.
3240///
3241/// # It is a planner setting
3242///
3243/// A short header is four octets and an extended one twelve, so the
3244/// choice moves every member behind it. It is in
3245/// [`LayoutKey::offset_key`] for that reason.
3246///
3247/// # No dialect selects the second value
3248///
3249/// It writes an id in a range the clause reserves, which a reader that
3250/// implements §7.4.1.2.2 will take for `PID_IGNORE` or worse.
3251/// `--short-pid-range all-fourteen-bits` reaches it.
3252#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
3253pub enum ShortPidRange {
3254 /// `0` to `0x3F00` inclusive, and the extended header above it.
3255 ///
3256 /// The default, the clause, and what Cyclone, RTI and Fast DDS all
3257 /// write.
3258 #[default]
3259 UpToReserved,
3260 /// The whole low fourteen bits, `0` to `0x3FFF`, reserved ids
3261 /// included.
3262 ///
3263 /// What OpenDDS writes.
3264 AllFourteenBits,
3265}
3266
3267impl ShortPidRange {
3268 /// Both.
3269 pub const ALL: [Self; 2] = [Self::UpToReserved, Self::AllFourteenBits];
3270
3271 /// Whether this is the default.
3272 #[must_use]
3273 pub const fn is_default(self) -> bool {
3274 matches!(self, Self::UpToReserved)
3275 }
3276
3277 /// Short name, for configuration and diagnostics.
3278 #[must_use]
3279 pub const fn as_str(self) -> &'static str {
3280 match self {
3281 Self::UpToReserved => "up-to-reserved",
3282 Self::AllFourteenBits => "all-fourteen-bits",
3283 }
3284 }
3285
3286 /// The highest member id the short header carries.
3287 ///
3288 /// `u16` and not a wider integer, because the short header's id
3289 /// field is fourteen bits and every value this can answer fits one.
3290 /// It was `u32`, and the one caller that needs the wire's own width
3291 /// — `PlCdr1Framing::highest_short_id` — narrowed it with
3292 /// `u16::try_from(..).unwrap_or(u16::MAX)`. That fallback is a
3293 /// silent clamp on the threshold a reader uses to decide that a
3294 /// parameter is reserved: `Reader::pl_cdr1` reports every id above
3295 /// it as reserved and drops the member, so a variant added here
3296 /// with a value above `0xffff` would have moved the threshold to
3297 /// `0xffff` and dropped members without saying so. Answering in the
3298 /// width the field has deletes the conversion instead of guarding
3299 /// it.
3300 #[must_use]
3301 pub const fn highest_short_id(self) -> u16 {
3302 match self {
3303 Self::UpToReserved => 0x3f00,
3304 Self::AllFourteenBits => 0x3fff,
3305 }
3306 }
3307
3308 /// Parse a configuration value.
3309 ///
3310 /// An unknown value returns `None` rather than falling back to the
3311 /// default: a typo in a wire-affecting setting must be loud.
3312 #[must_use]
3313 pub fn parse(value: &str) -> Option<Self> {
3314 match value {
3315 "up-to-reserved" => Some(Self::UpToReserved),
3316 "all-fourteen-bits" => Some(Self::AllFourteenBits),
3317 _ => None,
3318 }
3319 }
3320}
3321
3322impl MapEntryOrder {
3323 /// Both.
3324 pub const ALL: [Self; 2] = [Self::Declaration, Self::AscendingKey];
3325
3326 /// Whether this is the default.
3327 #[must_use]
3328 pub const fn is_default(self) -> bool {
3329 matches!(self, Self::Declaration)
3330 }
3331
3332 /// Short name, for configuration and diagnostics.
3333 #[must_use]
3334 pub const fn as_str(self) -> &'static str {
3335 match self {
3336 Self::Declaration => "declaration",
3337 Self::AscendingKey => "ascending-key",
3338 }
3339 }
3340
3341 /// Parse a configuration value.
3342 ///
3343 /// An unknown value returns `None` rather than falling back to the
3344 /// default: a typo in a wire-affecting setting must be loud.
3345 #[must_use]
3346 pub fn parse(value: &str) -> Option<Self> {
3347 match value {
3348 "declaration" => Some(Self::Declaration),
3349 "ascending-key" => Some(Self::AscendingKey),
3350 _ => None,
3351 }
3352 }
3353}
3354
3355/// Whether a `fixed<P,S>`'s digit count comes from the declaration or
3356/// from the value.
3357///
3358/// CORBA 3.3 part 2 §9.3.2.8 defines the wire form and nothing else:
3359/// packed binary-coded decimal, two digits to the octet, most
3360/// significant first, the low nibble of the last octet the sign — `0xC`
3361/// for positive or zero and `0xD` for negative — with a leading zero
3362/// nibble prepended when the nibble count is odd. IDL 4.2
3363/// §7.4.1.4.4.3.4 rule (42) gives the syntax and caps `P` at 31. XTypes
3364/// 1.3 has no fixed-point type at all, which is why the rule is borrowed
3365/// by name. **What neither clause says is how many digits go on the
3366/// wire**, and the four implementations that write one split two and
3367/// two.
3368///
3369/// # Two on each side, which is what makes it an axis
3370///
3371/// | | the digit count | measured on |
3372/// |---|---|---|
3373/// | omniORB 4.3.3 | the **declared** `P` | `fixed-basic`, `fixed-scales`, `fixed-scale-equals-digits`, `array-1d-fixed`, `sequence-fixed`, both orders |
3374/// | this project | the declared `P` | the same rows, byte for byte with omniORB |
3375/// | JacORB | the **value's** precision | the same rows; `docs/corba-column.md` §3 traced it to `BigDecimal.precision()` |
3376/// | ACE/TAO 2.5.24 | the digits of the string the value was parsed from | `ACE_CDR::Fixed`'s own CDR operator; `tao_idl` refuses the corpus's `fixed` IDL so TAO has no recorded row, and its runtime was measured directly in `unspecified_constructs.md` §3 |
3377/// | OpenDDS 3.34 | the declared `P` | its serializer writes `OpenDDS::FaceTypes::Fixed_T<P,S>`, `(P + 2) / 2` octets, not through `ACE_CDR::Fixed`'s operator: `0.00` under `fixed<5,2>` is `00000c`, `7` is `00700c` (measured 2026-10-07 through the FACE mapping) |
3378///
3379/// Judgement 3's threshold, met: two independent implementations on each
3380/// side, and both were in a position to answer differently — a front end
3381/// that carried `fixed<9,3>` as far as a serialiser and then wrote the
3382/// value's digits chose to.
3383///
3384/// # The octets
3385///
3386/// `fixed-basic`, `fixed<9,3>`, `amount = 123456.789` and
3387/// `debt = -000000.001`, `plain-cdr1` big endian:
3388///
3389/// ```text
3390/// ours 123456789c 00000000 1d ab
3391/// theirs 123456789c 1d ab
3392/// ```
3393///
3394/// Only a value with leading zeros differs. `fixed-scales` and
3395/// `fixed-scale-equals-digits` are byte-identical across omniORB, JacORB
3396/// and this project, which is what makes the cause exact rather than
3397/// inferred: `fixed<31,10>` holding thirty significant digits pads to
3398/// sixteen octets under **both** readings, and `fixed<4,4>` holding four
3399/// pads to three under both.
3400///
3401/// # The value's precision is `BigDecimal.precision()`
3402///
3403/// The digit string with its leading zeros removed, and **at least one
3404/// digit**: `new BigDecimal("0.00").precision()` is 1 in Java, so a zero
3405/// goes out as the single nibble `0` and the sign, one octet `0c`. That
3406/// is the same "at least one" the C++ and Rust sides need, and it is why
3407/// the rule is stated as `max(significant, 1)` rather than as "strip the
3408/// leading zeros".
3409///
3410/// # It is decodable, and the estimate said it was not
3411///
3412/// `docs/open-rows-breakdown.md` cause K states that "the declared count
3413/// is the only decodable half: a reader must know the octet count from
3414/// the type before it reads the value". That is wrong, and the clause
3415/// says why: a digit nibble is `0` to `9` and a sign nibble is `0xA` to
3416/// `0xF`, so the field **ends at the first octet whose low nibble is at
3417/// least `0xA`**. The count of nibbles is even by construction, so the
3418/// sign is always a low nibble and never a high one. A reader of this
3419/// form scans; all three columns do, and
3420/// `crates/wire-probe/tests/fixed_digit_count.rs::the_value_precision_form_reads_back`
3421/// is that as a test over the recorded JacORB rows.
3422///
3423/// # It **is** a planner setting
3424///
3425/// Unlike [`MapEntryOrder`] and [`UnionLabelMatch`]. A `fixed`'s extent
3426/// is `ceil((P + 1) / 2)` octets under [`Self::Declared`] and depends on
3427/// the value under [`Self::ValuePrecision`] and [`Self::ValueStringDigits`],
3428/// so the type stops being fixed-size and every member behind it moves.
3429/// It is in [`LayoutKey::offset_key`] for that reason, and
3430/// `the_extent_is_unbounded_under_the_value` is the claim as a test.
3431///
3432/// # Three readings exist
3433///
3434/// 1. **Declared** — `P` digits, padded to even nibble count. What
3435/// omniORB 4.3.3 and this project's default write.
3436/// 2. **ValuePrecision** — the value's precision with leading and
3437/// trailing zeros removed, minimum one digit. What JacORB 3.9 writes
3438/// (`BigDecimal.precision()`).
3439/// 3. **ValueStringDigits** — the integer digits of the decimal as
3440/// written, leading zeros included, plus the `S` fraction digits of
3441/// the declaration, padded to an odd digit count by adding one
3442/// leading zero if even. What ACE/TAO 2.5.24's `ACE_CDR::Fixed`
3443/// writes for a decimal with `S` fraction digits — it counts the
3444/// digits of the string it was parsed from — which is what OpenDDS's
3445/// generated `write_fixed` calls. The fraction is counted at `S`
3446/// and not as written: `"7"` under `fixed<5,2>` is `700c`, and
3447/// counting one digit would have kept only its last nibble, `0c`.
3448///
3449/// The three differ on values with fewer digits than the type declares:
3450///
3451/// | declared | value string | digit count | Declared | ValuePrecision | ValueStringDigits |
3452/// |---|---|---|---|---|---|
3453/// | `fixed<5,5>` | `"0.12345"` | 6 (even) | 5 | 5 | **7** (`0012345`) |
3454/// | `fixed<5,2>` | `"0.00"` | 3 (odd) | 5 | 1 | **3** (`000`) |
3455/// | `fixed<4,2>` | `"12.34"` | 4 (even) | 4 | 4 | **5** (`01234`) |
3456/// | `fixed<5,2>` | `"123.45"` | 5 (odd) | 5 | 5 | **5** (`12345`) |
3457/// | `fixed<1,0>` | `"7"` | 1 (odd) | 1 | 1 | **1** (`7`) |
3458/// | `fixed<5,2>` | `"7"` | 1 + 2 (odd) | 5 | 3 | **3** (`700`) |
3459///
3460/// The `ValueStringDigits` column is pinned by the recorded ACE values
3461/// in `unspecified_constructs.md` §3:
3462/// - `fixed<5,5>` `"0.12345"` → `0012345c` (7 digits) pins the even→odd padding.
3463/// - `fixed<5,2>` `"0.00"` → `000c` (3 digits) pins the odd count without padding.
3464/// - `fixed<4,2>` `"12.34"` → `01234c` (5 digits) pins the even→odd padding.
3465/// - `fixed<5,2>` `"123.45"` → `12345c` (5 digits) pins the odd count without padding.
3466/// - `fixed<1,0>` `"7"` → `7c` (1 digit) pins the minimum one digit.
3467/// - The `fixed<31,0>` row (31 nines) agrees across all three and does not
3468/// separate them.
3469#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
3470pub enum FixedDigitCount {
3471 /// `P` digits, whatever the value is — the declaration decides.
3472 ///
3473 /// The default, and what omniORB 4.3.3 writes.
3474 #[default]
3475 Declared,
3476 /// As many digits as the value has, and never fewer than one.
3477 ///
3478 /// What JacORB 3.9 writes. Named for the rule and not for a
3479 /// vendor, because two implementations reached it independently.
3480 ValuePrecision,
3481 /// The integer digits of the decimal as written, leading zeros
3482 /// included, plus the `S` fraction digits, padded to an odd digit
3483 /// count by adding one leading zero if the count is even.
3484 ///
3485 /// What ACE/TAO 2.5.24's `ACE_CDR::Fixed` writes for a decimal with
3486 /// `S` fraction digits, through its own CDR operator. Measured in
3487 /// `unspecified_constructs.md` §3. OpenDDS does not write it: its
3488 /// serializer writes the declared count (see [`Dialect::fixed_digits`]).
3489 ValueStringDigits,
3490}
3491
3492impl FixedDigitCount {
3493 /// All three.
3494 pub const ALL: [Self; 3] = [
3495 Self::Declared,
3496 Self::ValuePrecision,
3497 Self::ValueStringDigits,
3498 ];
3499
3500 /// The lowest nibble value that is a sign and not a digit.
3501 ///
3502 /// CORBA 3.3 part 2 §9.3.2.8: a digit is `0` to `9`, and every
3503 /// nibble at or above this is a sign. It is what makes the
3504 /// value-precision form self-delimiting, and it is a constant here
3505 /// so the three runtimes cannot each pick their own.
3506 pub const FIRST_SIGN_NIBBLE: u8 = 0x0a;
3507
3508 /// Whether this is the default.
3509 #[must_use]
3510 pub const fn is_default(self) -> bool {
3511 matches!(self, Self::Declared)
3512 }
3513
3514 /// Short name, for configuration and diagnostics.
3515 #[must_use]
3516 pub const fn as_str(self) -> &'static str {
3517 match self {
3518 Self::Declared => "declared",
3519 Self::ValuePrecision => "value-precision",
3520 Self::ValueStringDigits => "value-string-digits",
3521 }
3522 }
3523
3524 /// How many digit nibbles a value goes out with.
3525 ///
3526 /// `declared` is `P`; `significant` is the value's digit count with
3527 /// its leading zeros removed. The one place the two cross into a
3528 /// wire width. [`crate::fixed_wire_digits`] is what call sites use;
3529 /// this is the arithmetic behind it, separated so that the crossing
3530 /// can be tested without a value.
3531 ///
3532 /// Never zero under any value: a field of no digits could not carry
3533 /// the sign nibble's pair.
3534 #[must_use]
3535 pub const fn wire_digits(self, declared: u8, significant: u8, string_digits: u8) -> u8 {
3536 match self {
3537 Self::Declared => declared,
3538 Self::ValuePrecision => {
3539 if significant == 0 {
3540 1
3541 } else {
3542 significant
3543 }
3544 }
3545 Self::ValueStringDigits => {
3546 // ACE rule: if the string's digit count is even, add one
3547 // leading zero to make it odd. The sign nibble then makes
3548 // the total nibble count even (whole octets).
3549 if string_digits == 0 {
3550 1
3551 } else if string_digits % 2 == 0 {
3552 string_digits + 1
3553 } else {
3554 string_digits
3555 }
3556 }
3557 }
3558 }
3559
3560 /// Parse a configuration value.
3561 ///
3562 /// An unknown value returns `None` rather than falling back to the
3563 /// default: a typo in a wire-affecting setting must be loud.
3564 #[must_use]
3565 pub fn parse(value: &str) -> Option<Self> {
3566 match value {
3567 "declared" => Some(Self::Declared),
3568 "value-precision" => Some(Self::ValuePrecision),
3569 "value-string-digits" => Some(Self::ValueStringDigits),
3570 _ => None,
3571 }
3572 }
3573}
3574
3575/// How a `map` key's values are compared when
3576/// [`MapEntryOrder::AscendingKey`] puts the entries in order.
3577///
3578/// The answer [`crate::map_key_ordering`] gives, and the one thing the
3579/// three language columns have to agree on beyond "sorted": a signed and
3580/// an unsigned reading of the same octets give different orders, and the
3581/// corpus separates them — see [`MapEntryOrder`].
3582///
3583/// It is deliberately **not** "compare the encoded octets". A negative
3584/// `long` key encodes as `ffed2979`, which sorts last among the octets
3585/// and first among the values, and Fast DDS writes it first.
3586#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
3587pub enum MapKeyOrdering {
3588 /// By signed integer value — `int8`, `short`, `long`, `long long`,
3589 /// and an IDL `char`, whose C++ counterpart is signed on the host
3590 /// the corpus was recorded on.
3591 SignedInteger,
3592 /// By unsigned integer value — `octet`, `uint8`, `unsigned short`,
3593 /// `unsigned long`, `unsigned long long`, `boolean` (`false` before
3594 /// `true`) and `wchar` (by code point).
3595 UnsignedInteger,
3596 /// By the enumerator's declared value, signed.
3597 ///
3598 /// `enum class E : int32_t` under `std::less` is the underlying
3599 /// integer, and `@value` gives it. Unmeasured against recorded
3600 /// octets — see [`MapEntryOrder`].
3601 EnumeratorValue,
3602 /// By the text's code units, compared unsigned, shorter first on a
3603 /// prefix.
3604 ///
3605 /// For a `string` this is `std::string::operator<`, which is
3606 /// `char_traits<char>::compare` and which C++ defines over
3607 /// `unsigned char` whatever `char`'s own signedness is.
3608 /// `map-key-string` measures it: `["", "alpha", "üሴ"]` stays as
3609 /// declared, and a signed reading of `üሴ`'s leading `c3` would put
3610 /// that entry first.
3611 ///
3612 /// For a `wstring` this is `std::wstring::operator<`, which compares
3613 /// code points. **One variant and not two**, because the two orders
3614 /// are the same order: every column here holds a `wstring` as UTF-8
3615 /// — `zd_wstring` is a `const char *`, the Python column holds a
3616 /// `str` and the Rust probe a `String` — and UTF-8 is order
3617 /// preserving, so the unsigned octet order of a UTF-8 encoding is
3618 /// the code-point order of what it encodes. Splitting the variant
3619 /// would give a coordinate no value could separate, which is a
3620 /// distinction a mutation cannot make red. No corpus case declares a
3621 /// `wstring` map key.
3622 CodeUnits,
3623}
3624
3625impl MapKeyOrdering {
3626 /// Every ordering.
3627 pub const ALL: [Self; 4] = [
3628 Self::SignedInteger,
3629 Self::UnsignedInteger,
3630 Self::EnumeratorValue,
3631 Self::CodeUnits,
3632 ];
3633
3634 /// Short name, for diagnostics and for the emitters that name it in
3635 /// generated code.
3636 #[must_use]
3637 pub const fn as_str(self) -> &'static str {
3638 match self {
3639 Self::SignedInteger => "signed-integer",
3640 Self::UnsignedInteger => "unsigned-integer",
3641 Self::EnumeratorValue => "enumerator-value",
3642 Self::CodeUnits => "code-units",
3643 }
3644 }
3645}
3646
3647/// Whether a key holder keeps the type's extensibility or is forced to
3648/// `@final`.
3649///
3650/// DDSI-RTPS 2.5 §9.6.4.8 Step 1 and XTypes 1.3 §7.6.8 Step 1 both
3651/// build `FooKeyHolder` from `Foo` and then say it "shall have
3652/// extensibility kind 'FINAL'". A holder that kept the original's kind
3653/// carries the original's framing: a DHEADER for an appendable one,
3654/// PID headers and a sentinel for a mutable one under XCDR1 — and that
3655/// pushes the holder over sixteen bytes, which sends Step 5 down the
3656/// MD5 branch. Two peers reading the clause differently compute
3657/// different instance handles for one instance.
3658///
3659/// # One implementation departs, in one place
3660///
3661/// Measured with `@final struct Foo { @key long id; long x; long y; }`
3662/// and its `@appendable` and `@mutable` variants, `id = 0x12345678`:
3663///
3664/// | | XCDR1 | XCDR2 |
3665/// |---|---|---|
3666/// | Cyclone, all three kinds | holder `12345678`, hash `12345678` + zeros | the same |
3667/// | Fast DDS, all three | the same | the same |
3668/// | RTI, `@final` / `@appendable` | the same | the same |
3669/// | RTI, `@mutable` | holder `00000004 12345678 7f020000`, hash `0482916deda7e1d2459197a74346627f` | `12345678` + zeros |
3670///
3671/// Cyclone's key buffer still carries the framing and
3672/// `dds_stream_extract_keyBE_from_key(…, DDS_CDR_KEY_SERIALIZATION_KEYHASH,
3673/// …)` strips it. Fast DDS hard-codes `PLAIN_CDR2` in the generated
3674/// holder whatever the type says. RTI keeps the parameter list under
3675/// XCDR1 only: `maxSizeSerializedKey V1 = 28`, past sixteen, so the MD5
3676/// branch is taken over the framing rather than over the key.
3677#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
3678pub enum KeyHolderExtensibility {
3679 /// `@final`, whatever the type declares. The clause, and what four
3680 /// of the five measured combinations do.
3681 #[default]
3682 ForcedFinal,
3683 /// The type's own kind, framing and all.
3684 ///
3685 /// RTI Connext 7.7 under XCDR1. Select it to compute the instance
3686 /// handle an RTI peer computes for a `@mutable` keyed type there;
3687 /// under XCDR2 RTI forces final like everyone else, which is why
3688 /// [`Dialect::key_holder_extensibility`] takes the encoding.
3689 AsDeclared,
3690}
3691
3692impl KeyHolderExtensibility {
3693 /// Both.
3694 pub const ALL: [Self; 2] = [Self::ForcedFinal, Self::AsDeclared];
3695
3696 /// Whether this is the default.
3697 #[must_use]
3698 pub const fn is_default(self) -> bool {
3699 matches!(self, Self::ForcedFinal)
3700 }
3701
3702 /// Short name, for configuration and diagnostics.
3703 #[must_use]
3704 pub const fn as_str(self) -> &'static str {
3705 match self {
3706 Self::ForcedFinal => "forced-final",
3707 Self::AsDeclared => "as-declared",
3708 }
3709 }
3710
3711 /// Parse a configuration value.
3712 ///
3713 /// An unknown value returns `None` rather than falling back to the
3714 /// default: a typo here changes every instance handle.
3715 #[must_use]
3716 pub fn parse(value: &str) -> Option<Self> {
3717 match value {
3718 "forced-final" => Some(Self::ForcedFinal),
3719 "as-declared" => Some(Self::AsDeclared),
3720 _ => None,
3721 }
3722 }
3723}
3724
3725/// How far Step 1's member removal reaches into a key holder.
3726///
3727/// XTypes 1.3 §7.6.8 Step 1 keeps the key members of the holder —
3728///
3729/// > If there are any key members, then remove the non-key members from
3730/// > FooKeyHolder. Otherwise do not remove any members.
3731///
3732/// — and Step 3 says to "apply steps 1 and 2 recursively to the members
3733/// of FooKeyHolder if they are themselves AggregatedTypes". `FooKeyHolder`
3734/// at that point is whatever Step 1 left, so for a keyless aggregate it
3735/// is every member, and Step 3 applies to all of them. The recursion has
3736/// no depth limit written into it.
3737///
3738/// # What the vendors do
3739///
3740/// Measured on 2026-08-09 through `keyhash-probe/` — Cyclone DDS
3741/// 11.0.1, RTI Connext 7.7.0, Fast DDS from source. Three shapes:
3742///
3743/// ```idl
3744/// @final struct NestedNoKey { long a; long b; };
3745/// @final struct OuterKeylessNested { @key NestedNoKey inner; long tail; };
3746///
3747/// @final struct DeepLeaf { @key long p; long q; };
3748/// @final struct DeepMid { DeepLeaf leaf; long r; }; // no key of its own
3749/// @final struct DeepOuter { @key DeepMid mid; long tail; };
3750///
3751/// @final struct ChainLeaf { @key long p; long q; };
3752/// @final struct ChainMid { @key ChainLeaf inner; long r; };
3753/// @final struct ChainOuter { @key ChainMid mid; long tail; };
3754/// ```
3755///
3756/// All three vendors, under every encoding, give `OuterKeylessNested`
3757/// both of `NestedNoKey`'s members and `ChainOuter` only `p` — so the
3758/// "otherwise do not remove any members" clause holds, and the recursion
3759/// through *keyed* levels is unlimited everywhere.
3760///
3761/// They part over `DeepOuter`, where a keyless level sits above a keyed
3762/// one:
3763///
3764/// | | `DeepOuter` holder |
3765/// |---|---|
3766/// | Cyclone, XCDR1 and XCDR2 | `0d0d0d0d 0f0f0f0f` — `p` and `r` |
3767/// | Fast DDS | the same |
3768/// | RTI, XCDR2 | the same |
3769/// | RTI, XCDR1 | `0d0d0d0d 0e0e0e0e 0f0f0f0f` — `p`, `q` and `r` |
3770///
3771/// RTI's XCDR1 holder stops filtering at the first keyless aggregate and
3772/// takes what is under it whole: `maxSizeSerializedKey V1 / V2` reports
3773/// `12 / 8` for that type, so the two holders are genuinely different
3774/// types and not one type serialized twice.
3775#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
3776pub enum KeyHolderNesting {
3777 /// Step 1 applies at every level, however deep. The clause, and what
3778 /// four of the five measured combinations do.
3779 #[default]
3780 Recursive,
3781 /// The first keyless aggregate is taken whole; nothing below it is
3782 /// filtered.
3783 ///
3784 /// RTI Connext 7.7 under XCDR1. Under XCDR2 RTI recurses like
3785 /// everyone else, which is why [`Dialect::key_holder_nesting`] takes
3786 /// the encoding.
3787 KeylessNestedWhole,
3788}
3789
3790impl KeyHolderNesting {
3791 /// Both.
3792 pub const ALL: [Self; 2] = [Self::Recursive, Self::KeylessNestedWhole];
3793
3794 /// Whether this is the default.
3795 #[must_use]
3796 pub const fn is_default(self) -> bool {
3797 matches!(self, Self::Recursive)
3798 }
3799
3800 /// Short name, for configuration and diagnostics.
3801 #[must_use]
3802 pub const fn as_str(self) -> &'static str {
3803 match self {
3804 Self::Recursive => "recursive",
3805 Self::KeylessNestedWhole => "keyless-nested-whole",
3806 }
3807 }
3808
3809 /// Parse a configuration value.
3810 ///
3811 /// An unknown value returns `None` rather than falling back to the
3812 /// default: a typo here changes every instance handle.
3813 #[must_use]
3814 pub fn parse(value: &str) -> Option<Self> {
3815 match value {
3816 "recursive" => Some(Self::Recursive),
3817 "keyless-nested-whole" => Some(Self::KeylessNestedWhole),
3818 _ => None,
3819 }
3820 }
3821}
3822
3823/// In what order the members of a key holder are serialized.
3824///
3825/// XTypes 1.3 §7.6.8 orders the holder's members by member id, and
3826/// DDSI-RTPS 2.5 §9.6.3.8 hashes whatever that produces. Two peers that
3827/// disagree about the order compute two different KeyHashes for one
3828/// sample, which is the same instance arriving under two handles — a
3829/// wrong value with nothing on the wire to say so.
3830///
3831/// # What the implementations do
3832///
3833/// Measured 2026-08-10 through `n3-keyholder/` on the Linux test
3834/// host — Cyclone DDS 11.0.1 via `dds_stream_extract_keyBE_from_key(…,
3835/// DDS_CDR_KEY_SERIALIZATION_KEYHASH, …)`, RTI Connext 7.7.0 via
3836/// `PRESTypePlugin_interpretedSerializeKeyForKeyhash` under all five
3837/// encapsulations, Fast DDS via the generated
3838/// `KeyOrderPubSubType::compute_key`. Two types, each declared in the
3839/// reverse of its id order, so the KeyHash says which order won:
3840///
3841/// ```idl
3842/// @final struct KeyOrder { @id(5) @key long b; @id(2) @key long a; }; // b = 2, a = 1
3843/// @final struct KeyMixed { @id(5) @key octet b; @id(2) @key long a; }; // b = 2, a = 1
3844/// ```
3845///
3846/// | | `KeyOrder` KeyHash | `KeyMixed` KeyHash |
3847/// |---|---|---|
3848/// | Cyclone, XCDR1 and XCDR2 | `00000001 00000002` | `00000001 02` |
3849/// | RTI, XCDR2 (`CDR2_BE`, `D_CDR2_BE`, `PL_CDR2_BE`) | `00000001 00000002` | `00000001 02` |
3850/// | Fast DDS, both | `00000001 00000002` | `00000001 02` |
3851/// | RTI, XCDR1 (`CDR_BE`, `PL_CDR_BE`) | `00000002 00000001` | `02000000 00000001` |
3852///
3853/// — all sixteen bytes wide, zero-extended. So the clause's order is
3854/// what three of the four combinations write, and RTI under encoding
3855/// version 1 is the one that keeps declaration order: the same version
3856/// split [`KeyHolderExtensibility`] and [`KeyHolderNesting`] already
3857/// carry for RTI, on a third axis.
3858///
3859/// `KeyMixed` is the case that shows it is an ordering and not a
3860/// coincidence of equal-width members: RTI's XCDR1 holder is eight
3861/// octets, with the `octet` first and three of padding behind it, and
3862/// everyone else's is five with no padding at all.
3863///
3864/// Cyclone's *serdata* key buffer — `dds_stream_extract_key_from_data`,
3865/// which is not the KeyHash — is in declaration order in both versions.
3866/// It is an intermediate: `dds_stream_extract_keyBE_from_key` reorders
3867/// it on the way to the hash, and reading the intermediate as the answer
3868/// gives Cyclone a divergence it does not have.
3869#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
3870pub enum KeyHolderOrder {
3871 /// Ascending effective member id. The clause, and what three of the
3872 /// four measured combinations write.
3873 #[default]
3874 AscendingMemberId,
3875 /// The order the members are declared in, which is the order the
3876 /// sample carries them in.
3877 ///
3878 /// RTI Connext 7.7 under XCDR1. Under XCDR2 RTI orders by member id
3879 /// like everyone else, which is why [`Dialect::key_holder_order`]
3880 /// takes the encoding.
3881 Declaration,
3882}
3883
3884impl KeyHolderOrder {
3885 /// Both.
3886 pub const ALL: [Self; 2] = [Self::AscendingMemberId, Self::Declaration];
3887
3888 /// Whether this is the default.
3889 #[must_use]
3890 pub const fn is_default(self) -> bool {
3891 matches!(self, Self::AscendingMemberId)
3892 }
3893
3894 /// Short name, for configuration and diagnostics.
3895 #[must_use]
3896 pub const fn as_str(self) -> &'static str {
3897 match self {
3898 Self::AscendingMemberId => "ascending-member-id",
3899 Self::Declaration => "declaration",
3900 }
3901 }
3902
3903 /// Parse a configuration value.
3904 ///
3905 /// An unknown value returns `None` rather than falling back to the
3906 /// default: a typo here changes every instance handle.
3907 #[must_use]
3908 pub fn parse(value: &str) -> Option<Self> {
3909 match value {
3910 "ascending-member-id" => Some(Self::AscendingMemberId),
3911 "declaration" => Some(Self::Declaration),
3912 _ => None,
3913 }
3914 }
3915}
3916
3917/// How the members of a key holder are aligned, which decides the KeyHash
3918/// of every type with keys of different widths.
3919///
3920/// DDS-XTypes 1.3 §7.6.8 step 4 serialises the holder as PLAIN_CDR2
3921/// big-endian, so a member is aligned to its size capped at four, as in any
3922/// XCDR2 body. Measured on the Linux test host with `tools/keyhash-probe` (types
3923/// `PadKey`, `WideKey`, `CharKey`, `StrKey`, `PathKey`):
3924///
3925/// - Fast DDS 3.6.1, and Cyclone 11.0.1 and RTI Connext 7.7.0 under XCDR2,
3926/// align as the clause says.
3927/// - Cyclone and RTI under XCDR1 align to the XCDR1 `MAXALIGN` of eight:
3928/// `@key short s; @key long long ll` hashes as `0102 000000000000
3929/// 0304…` where the clause gives `0102 0000 0304…`. RTI sends this in
3930/// `PID_KEY_HASH` (plain and PL_CDR); Cyclone sends no `PID_KEY_HASH`.
3931/// - OpenSplice 6.9 writes the key fields back to back, measured on the
3932/// wire: `@key octet a; @key long b` with `a=0x11, b=0x22334455` goes out
3933/// as `11223344 55000000 …` where the clause gives `11000000 22334455 …`,
3934/// and a holder over sixteen octets is hashed without the padding too.
3935/// Its DDSI2 source says so — `KEYHASH_HAS_PADDING 0`, "my current guess"
3936/// (`src/services/ddsi2e/code/q_osplser.c`).
3937#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
3938pub enum KeyHolderAlignment {
3939 /// Each member aligned to its size, capped at PLAIN_CDR2's four. The
3940 /// clause.
3941 #[default]
3942 Aligned,
3943 /// Each member aligned to its size, capped at the `MAXALIGN` of the
3944 /// encoding the data goes out in: eight under XCDR1, four under XCDR2.
3945 /// Cyclone and RTI under XCDR1.
3946 DataEncoding,
3947 /// No padding between members: every member at the next free octet.
3948 /// OpenSplice 6.9.
3949 Packed,
3950}
3951
3952impl KeyHolderAlignment {
3953 /// All three.
3954 pub const ALL: [Self; 3] = [Self::Aligned, Self::DataEncoding, Self::Packed];
3955
3956 /// Whether this is the default.
3957 #[must_use]
3958 pub const fn is_default(self) -> bool {
3959 matches!(self, Self::Aligned)
3960 }
3961
3962 /// The largest alignment a key holder member takes when the data goes
3963 /// out in `encoding`.
3964 #[must_use]
3965 pub const fn max_alignment(self, encoding: Encoding) -> u8 {
3966 match self {
3967 Self::Aligned => Encoding::PlainCdr2.max_alignment(),
3968 Self::DataEncoding => encoding.max_alignment(),
3969 Self::Packed => 1,
3970 }
3971 }
3972
3973 /// Short name, for configuration and diagnostics.
3974 #[must_use]
3975 pub const fn as_str(self) -> &'static str {
3976 match self {
3977 Self::Aligned => "aligned",
3978 Self::DataEncoding => "data-encoding",
3979 Self::Packed => "packed",
3980 }
3981 }
3982
3983 /// Parse a configuration value.
3984 ///
3985 /// An unknown value returns `None` rather than falling back to the
3986 /// default: a typo here changes every instance handle.
3987 #[must_use]
3988 pub fn parse(value: &str) -> Option<Self> {
3989 match value {
3990 "aligned" => Some(Self::Aligned),
3991 "data-encoding" => Some(Self::DataEncoding),
3992 "packed" => Some(Self::Packed),
3993 _ => None,
3994 }
3995 }
3996}
3997
3998/// What becomes of a union's member when `@try_construct(use_default)`
3999/// defaulted its discriminator.
4000///
4001/// The question arises on one path only: `union U switch
4002/// (@try_construct(use_default) K)` — §7.3.1.2.1.12.5's own shape — a
4003/// stream whose discriminator value `K` cannot represent, and Table 19's
4004/// `UNION_TYPE` row deciding what the reader hands back for the member.
4005///
4006/// # The clause
4007///
4008/// §7.2.4.4.8, Table 19, the `UNION_TYPE` row:
4009///
4010/// > If the discriminator value selects a member m1 in O1, then m1 is
4011/// > set to its default value according to its type.
4012///
4013/// It does not take the writer's member, and §7.2.4.1 says why it
4014/// cannot: the whole of type consistency holds
4015///
4016/// > without any inspection of a data producer's type definition
4017///
4018/// and with no producer type there is nothing that says the octets
4019/// behind the discriminator belong to a member of the type the reader
4020/// now selects. So [`Self::TypeDefault`] is the clause read straight,
4021/// and it is the default here.
4022///
4023/// # What the implementations do
4024///
4025/// | | writer sends `VAL3`, reader's discriminator defaults to `VAL1` |
4026/// |---|---|
4027/// | RTI Connext 7.7.0 | `{"x1":0}` — the member defaulted. Measured. |
4028/// | OpenDDS 3.34.0 | `d=0 v=0` for §7.3.1.2.1.12.5's own example, not the `42` the writer put in `e3_value`. Measured. |
4029/// | Toc CoreDX 6.14 | keeps the writer's member — read off its exclusion list, **not** measured here. |
4030/// | eProsima Fast DDS 3.6.1 | cannot express the case: it refuses annotations on a union discriminator outright. |
4031///
4032/// RTI reaches its outcome without the annotation at all — `rtiddsgen`
4033/// reports `try_construct` as ignored, and the shipped Extensible Types
4034/// guide lists it among the annotations the middleware ignores — which
4035/// makes it a witness for §7.2.4.1's default behaviour rather than for a
4036/// reading of the annotation.
4037///
4038/// # Why this is an axis and not a verdict
4039///
4040/// The OMG `dds-xtypes` interoperability suite expects the other answer.
4041/// `tryc_union_enum_disc_2` has a writer send `union_disc_enum_1` with
4042/// discriminator `VAL3` and `x3 = 3`, a reader declare
4043/// `union_disc_enum_2_default` whose `E2` carries
4044/// `defaultLiteral="true"` on `VAL1`, and the sample the reader compares
4045/// against is `data/json/tryconstruct/union_x1.json` — `{"x1":3}`, the
4046/// writer's member value under the reader's member name. That is
4047/// [`Self::StreamValue`], and nothing else produces it.
4048///
4049/// A suite that disagrees with the implementations is a disagreement
4050/// like any other, and a disagreement gets an axis here. What it does
4051/// not get is the default: a reader that took the streamed octets would
4052/// hand back `{"x1":3}` where RTI and OpenDDS both hand back
4053/// `{"x1":0}`, so [`Self::StreamValue`] as the default would buy one
4054/// suite case at the price of two measured peers.
4055///
4056/// # Where it applies
4057///
4058/// The reflective codec, `zerodds-wire-codec`, which is the only reader
4059/// in the tree that implements Table 19's member step at all — the
4060/// emitted language columns carry `@try_construct` on the discriminator
4061/// itself and stop there, so they have no second answer to give and no
4062/// axis value to carry.
4063#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
4064pub enum DefaultedDiscriminatorMember {
4065 /// The member the defaulted discriminator selects takes its own
4066 /// default value, and the octets the writer's member occupied are
4067 /// stepped over. Table 19's `UNION_TYPE` row, and **the default**.
4068 #[default]
4069 TypeDefault,
4070 /// The octets the writer's member occupied are read as the member
4071 /// the defaulted discriminator selects.
4072 ///
4073 /// Reading them at all is the claim about the writer's type that
4074 /// §7.2.4.1 says a reader cannot make, so it is only ever a
4075 /// deliberate choice. Two consequences follow from it and are worth
4076 /// naming, because neither is a defect of the implementation:
4077 ///
4078 /// - the octets are read as *this* type's case, so where that case
4079 /// is wider than the writer's the read runs into whatever
4080 /// follows, and where it is narrower the union's own framing is
4081 /// what puts the reader back;
4082 /// - under a `@mutable` union the member the stream framed carries
4083 /// the **writer's** member id, so the id is taken as read rather
4084 /// than checked. There is no id to check it against: the reader's
4085 /// discriminator no longer names the writer's case.
4086 ///
4087 /// Two shapes fall back to [`Self::TypeDefault`] rather than
4088 /// guessing:
4089 ///
4090 /// - a defaulted discriminator that selects no case at all — there
4091 /// is no member to read the octets into, so the writer's member is
4092 /// stepped over;
4093 /// - an unbounded case in a union that states no extent of its own.
4094 /// An undelimited `@final` or `@appendable` union under XCDR1 has
4095 /// neither a delimiter nor a sentinel, so nothing catches a read
4096 /// that runs long, and a `string`, `sequence` or `map` case would
4097 /// take its length prefix out of octets that were never a length.
4098 /// A fixed-extent case has no such prefix — it takes what its own
4099 /// type takes — and is read; an unbounded one is stepped over, and
4100 /// the step-over is the refusal [`Self::TypeDefault`] makes for
4101 /// the same shape.
4102 StreamValue,
4103}
4104
4105/// Whether the elements of a collection carry alignment padding.
4106///
4107/// XTypes 1.3 §7.4.1 defines the padding: `ALIGN(N)` is
4108/// `MIN(N, XCDR.version.maxalign)`, and a structure's members each
4109/// begin on a multiple of their own alignment counted **from the
4110/// encapsulation**. An element of an array or a sequence is a value like
4111/// any other, so §7.4.1 pads in front of one exactly as it does in
4112/// front of a member — which is what makes a `Cell { long a; short b; }`
4113/// array take a stride of eight rather than of six.
4114///
4115/// # The measured departure
4116///
4117/// `tests/interop-results/array-1d-struct-final/hdds/plain-cdr1.le.bin`
4118/// is `01000000 0200 03000000 0400 77` for
4119/// `@final struct Cell { long a; short b; }` in `Cell items[2];` with
4120/// `octet tail`: the second element's `long a` starts at offset six,
4121/// where §7.4.1's `ALIGN(4)` puts it at eight. Every recorded row of
4122/// `sequence-struct-final` says the same — `many`'s second `Cell` puts
4123/// `a` at offset 26 — and so does every array and sequence row of that
4124/// column.
4125///
4126/// **Struct members are still padded**, which is why this is about
4127/// elements and not about alignment in general.
4128/// `tests/interop-results/struct-final/hdds/plain-cdr1.le.bin` is
4129/// `79 29 ed ff 00000000 18 2d 44 54 fb 21 09 40 fe ff`: four octets
4130/// of fill ahead of the `double`, because XCDR1's `maxalign` is eight.
4131/// `annotation-must-understand-unannotated` pads the same way after an
4132/// `@optional` flag. So the axis has two values and not one, and
4133/// [`Self::Packed`] means "no padding **between the elements**".
4134///
4135/// # What `Packed` means for the octets inside an element
4136///
4137/// A fresh alignment origin at the element's own first octet. Every
4138/// recorded row that reaches the question agrees with the plainer
4139/// reading — "no alignment at all inside an element" — because no
4140/// recorded row has an element whose own members need padding between
4141/// them: the corpus's collection elements are `long`/`short` pairs,
4142/// `string`s and `wstring`s, and the first of those is already packed.
4143/// The origin form is the one that keeps a member's offset a function of
4144/// its own type, and it is what [`crate::plan()`] and the writer both
4145/// implement.
4146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
4147pub enum ElementPadding {
4148 /// XTypes 1.3 §7.4.1: an element begins at the next multiple of its
4149 /// alignment, counted from the encapsulation. The default.
4150 #[default]
4151 Aligned,
4152 /// Every element begins at the octet after the previous element
4153 /// ends, and alignment inside an element is counted from there.
4154 Packed,
4155}
4156
4157impl ElementPadding {
4158 /// Both.
4159 pub const ALL: [Self; 2] = [Self::Aligned, Self::Packed];
4160
4161 /// Whether this is the default.
4162 #[must_use]
4163 pub const fn is_default(self) -> bool {
4164 matches!(self, Self::Aligned)
4165 }
4166
4167 /// Short name, for configuration and diagnostics.
4168 #[must_use]
4169 pub const fn as_str(self) -> &'static str {
4170 match self {
4171 Self::Aligned => "aligned",
4172 Self::Packed => "packed",
4173 }
4174 }
4175
4176 /// Parse a configuration value.
4177 ///
4178 /// An unknown value returns `None` rather than falling back to the
4179 /// default: a typo in a wire-affecting setting must be loud.
4180 #[must_use]
4181 pub fn parse(value: &str) -> Option<Self> {
4182 match value {
4183 "aligned" => Some(Self::Aligned),
4184 "packed" => Some(Self::Packed),
4185 _ => None,
4186 }
4187 }
4188
4189 /// Whether a value of `size` octets is followed by padding up to the
4190 /// next `align`, under this policy.
4191 ///
4192 /// One expression for both the planner's array stride and the
4193 /// writer's origin, so the two cannot answer differently about the
4194 /// same element.
4195 #[must_use]
4196 pub const fn pads_after(self, _size: u32, _align: u8) -> bool {
4197 matches!(self, Self::Aligned)
4198 }
4199}
4200
4201/// Whether an aggregate's declared extensibility reaches the wire.
4202///
4203/// XTypes 1.3 §7.4.3.5.3 rule (2) puts a `DHEADER` in front of an
4204/// `@appendable` or `@mutable` aggregate under XCDR2 and nowhere under
4205/// XCDR1, which §7.4.3.4.1 gives no delimiter at all; rules (21) to
4206/// (25) frame a `@mutable` type's members with an EMHEADER under XCDR2
4207/// and a parameter header under XCDR1, and rule (30) terminates the
4208/// parameter list with `PID_LIST_END`. All of that is
4209/// [`Self::Specification`].
4210///
4211/// # The measured departure
4212///
4213/// [`Self::Never`] is one product's generated serialiser writing every
4214/// aggregate as `@final`, and it is measurable at three of the four
4215/// positions an aggregate can be reached in:
4216///
4217/// - **the sample itself.** `tests/interop-results/struct-appendable/hdds/plain-cdr2.le.bin`
4218/// is `79 29 ed ff 18 2d 44 54 fb 21 09 40 fe ff`, fourteen octets.
4219/// §7.4.3.5.3 rule (2) adds `0e000000` in front of them.
4220/// - **a struct member.**
4221/// `tests/interop-results/struct-appendable-in-final/hdds/plain-cdr2.le.bin`
4222/// is `79 29 ed ff fe ff 00 00 ff ff ff ff`; the `@appendable Inner`
4223/// takes no delimiter.
4224/// - **a `@mutable` type.** `tests/interop-results/annotation-must-understand-unannotated/hdds/plain-cdr1.le.bin`
4225/// is `79 29 ed ff ff ff ff 7f 01 00 00 00 06 00 00 00 c3 bc 62 65 72 00`:
4226/// two `long`s, a one-octet `@optional` flag and a `string`, with no
4227/// parameter header and no `PID_LIST_END` anywhere, which is
4228/// rule (18) and rule (20) — the `@final` form.
4229///
4230/// The **fourth** position answers differently and has its own axis:
4231/// an element of a `sequence` does carry one, under both encoding
4232/// versions. That is [`SequenceElementDelimiter`], and this axis
4233/// deliberately says nothing about it — a reader who took `Never` to
4234/// mean "no delimiter anywhere" would be right about three positions
4235/// and wrong about the fourth.
4236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
4237pub enum AggregateDelimiter {
4238 /// XTypes 1.3 §7.4.3.5.3 rules (2) and (21) to (25): an
4239 /// `@appendable` or `@mutable` aggregate carries a `DHEADER` under
4240 /// XCDR2 and a `@mutable` type's members carry their headers under
4241 /// both versions. The default.
4242 #[default]
4243 Specification,
4244 /// Neither: every aggregate is written as `@final`, wherever it is
4245 /// reached. An `@appendable` or `@mutable` type takes no
4246 /// `DHEADER`, a `@mutable` type's members take no EMHEADER, no
4247 /// parameter header and no sentinel, and its `@optional` member
4248 /// takes rule (20)'s one-octet flag.
4249 Never,
4250}
4251
4252impl AggregateDelimiter {
4253 /// Both.
4254 pub const ALL: [Self; 2] = [Self::Specification, Self::Never];
4255
4256 /// Whether this is the default.
4257 #[must_use]
4258 pub const fn is_default(self) -> bool {
4259 matches!(self, Self::Specification)
4260 }
4261
4262 /// Whether an `@appendable` or `@mutable` aggregate writes a
4263 /// `DHEADER` at all.
4264 #[must_use]
4265 pub const fn writes_dheader(self) -> bool {
4266 matches!(self, Self::Specification)
4267 }
4268
4269 /// Whether a `@mutable` type's members are framed as
4270 /// `MMEMBER`s — an EMHEADER, a parameter header and a sentinel.
4271 #[must_use]
4272 pub const fn frames_mutable_members(self) -> bool {
4273 matches!(self, Self::Specification)
4274 }
4275
4276 /// Short name, for configuration and diagnostics.
4277 #[must_use]
4278 pub const fn as_str(self) -> &'static str {
4279 match self {
4280 Self::Specification => "specification",
4281 Self::Never => "never",
4282 }
4283 }
4284
4285 /// Parse a configuration value.
4286 ///
4287 /// An unknown value returns `None` rather than falling back to the
4288 /// default: a typo in a wire-affecting setting must be loud.
4289 #[must_use]
4290 pub fn parse(value: &str) -> Option<Self> {
4291 match value {
4292 "specification" => Some(Self::Specification),
4293 "never" => Some(Self::Never),
4294 _ => None,
4295 }
4296 }
4297}
4298
4299/// Whether an element of a `sequence` is preceded by its own octet
4300/// length.
4301///
4302/// A fourth position, and the only one on which
4303/// [`AggregateDelimiter::Never`] is not the answer: a product whose
4304/// serialiser frames every aggregate as `@final` writes a four-octet
4305/// length in front of each element of a `sequence` whose element is not
4306/// `@final`, and writes it under XCDR1 as well.
4307///
4308/// # The measurement
4309///
4310/// `tests/interop-results/sequence-struct-appendable/hdds/plain-cdr1.le.bin`
4311/// is
4312/// `00000000 01000000 06000000 79 29 ed ff fe ff 0000 03000000 06000000 01000000 0200 …`
4313/// for `@appendable struct Cell { long a; short b; }` in three
4314/// `sequence<Cell>` members. The `06 00 00 00` is the element's own six
4315/// octets, and the file is byte-identical under XCDR2, where
4316/// §7.4.3.4.1 gives no delimiter at all.
4317///
4318/// Three things narrow it, and each is a recorded row:
4319///
4320/// - the element type's extensibility. `sequence-struct-final` is the
4321/// same type declared `@final` and carries no such word: 44 octets
4322/// with no `06 00 00 00` anywhere.
4323/// - the collection. `array-1d-struct-appendable` is the same
4324/// `@appendable Cell` in `Cell items[2]` and writes
4325/// `01 00 00 00 02 00 03 00 00 00 04 00 77`, thirteen octets and no
4326/// word in front of either element.
4327/// - the encoding version. The two files are the same octets, so
4328/// whatever writes it does not consult `Encoding`.
4329///
4330/// The collection delimiter itself is a different question and already
4331/// has one: the same rows carry none under XCDR2 either, which is
4332/// [`CollectionDelimiterPolicy::Never`].
4333#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
4334pub enum SequenceElementDelimiter {
4335 /// XTypes 1.3 §7.4.3.4.1 and §7.4.3.5.3: no length of its own in
4336 /// front of an element. The default, and what every measured
4337 /// implementation except the one named above writes.
4338 #[default]
4339 Never,
4340 /// A four-octet `UInt32` — the element's own octet count — in
4341 /// front of every element of a `sequence` whose element type is not
4342 /// `@final`, under both encoding versions.
4343 BeforeEachNonFinalElement,
4344}
4345
4346impl SequenceElementDelimiter {
4347 /// Both.
4348 pub const ALL: [Self; 2] = [Self::Never, Self::BeforeEachNonFinalElement];
4349
4350 /// Whether this is the default.
4351 #[must_use]
4352 pub const fn is_default(self) -> bool {
4353 matches!(self, Self::Never)
4354 }
4355
4356 /// Whether an element of a `sequence` is preceded by its own octet
4357 /// length when its type is not `@final`.
4358 #[must_use]
4359 pub const fn writes_element_length(self) -> bool {
4360 matches!(self, Self::BeforeEachNonFinalElement)
4361 }
4362
4363 /// Short name, for configuration and diagnostics.
4364 #[must_use]
4365 pub const fn as_str(self) -> &'static str {
4366 match self {
4367 Self::Never => "never",
4368 Self::BeforeEachNonFinalElement => "before-each-non-final-element",
4369 }
4370 }
4371
4372 /// Parse a configuration value.
4373 ///
4374 /// An unknown value returns `None` rather than falling back to the
4375 /// default: a typo in a wire-affecting setting must be loud.
4376 #[must_use]
4377 pub fn parse(value: &str) -> Option<Self> {
4378 match value {
4379 "never" => Some(Self::Never),
4380 "before-each-non-final-element" => Some(Self::BeforeEachNonFinalElement),
4381 _ => None,
4382 }
4383 }
4384}
4385
4386/// Which octets stand for a `wstring`'s characters.
4387///
4388/// XTypes 1.3 Table 31 gives `Char16` for a `wstring`'s code unit and
4389/// §7.4.1.1.2 fixes the two things that follow from it: the length is
4390/// "twice the number of characters in the string", and the
4391/// representation "shall not include a terminating NUL character(s)".
4392/// [`WstringLengthPolicy`] answers how the length is counted and
4393/// whether there is a terminator; this answers what the characters
4394/// **are**, and the two are separate because they part.
4395///
4396/// # The measurement
4397///
4398/// `tests/interop-results/wstring-member/hdds/plain-cdr1.le.bin` is
4399/// `04 00 00 00 61 62 63 00` for `@appendable struct S { wstring w; }`
4400/// with `w = "abc"`: three octets of UTF-8 and a NUL, with the NUL
4401/// inside the four the length declares — which is exactly what
4402/// §7.4.1.1.1 gives a narrow `string`, and nothing §7.4.1.1.2 admits
4403/// for a `wstring`.
4404///
4405/// `wstring-unbounded-utf8` is the row that pins the encoding rather
4406/// than the length. `"äöü ሴ Z"` is seven characters; UTF-8 puts them
4407/// in twelve octets and the recorded file declares
4408/// `0d 00 00 00` — thirteen, the twelve and the NUL — where
4409/// §7.4.1.1.2's rule and Table 31 give fourteen. The next four octets
4410/// are `c3 a4 c3 b6 c3 bc 20 e1 88 b4 20 5a 00`, the twelve in UTF-8
4411/// and the terminator.
4412///
4413/// # What it does not reach
4414///
4415/// The scalar `wchar` is [`WcharWidth`]'s and not this: a peer whose
4416/// `wstring` is UTF-8 can still write a four-octet scalar `wchar`, and
4417/// one that writes a UTF-8 `wstring` does not thereby say what a
4418/// `wchar` is. `rmw_cyclonedds_cpp` already parts the two that way.
4419///
4420/// And it makes [`WstringLengthPolicy`]'s C1 and C3 vacuous: with no
4421/// fixed-width code unit there is nothing for a character count to
4422/// count and no width for an octet count to multiply by, so the writer
4423/// declares the UTF-8 octet count and only C2 — the terminator — still
4424/// reaches the wire.
4425#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
4426pub enum WstringCodeUnits {
4427 /// XTypes 1.3 Table 31's `Char16`, two octets per code unit, and
4428 /// [`WstringLengthPolicy`] decides the rest. The default.
4429 #[default]
4430 Utf16,
4431 /// The characters' own UTF-8 octets, one to four per code point,
4432 /// with [`WstringTerminator`]'s answer still reached.
4433 Utf8,
4434}
4435
4436impl WstringCodeUnits {
4437 /// Both.
4438 pub const ALL: [Self; 2] = [Self::Utf16, Self::Utf8];
4439
4440 /// Whether this is the default.
4441 #[must_use]
4442 pub const fn is_default(self) -> bool {
4443 matches!(self, Self::Utf16)
4444 }
4445
4446 /// Whether the length prefix counts octets rather than code units,
4447 /// whichever of the two answers [`WstringLengthCounts`] gives.
4448 ///
4449 /// Under [`Self::Utf8`] there is no code unit to count and no width
4450 /// to multiply an octet count by, so the octets are what the length
4451 /// names whatever C1 says. One predicate, so a reader of the writer
4452 /// cannot mistake one for the other.
4453 #[must_use]
4454 pub const fn counts_octets(self) -> bool {
4455 matches!(self, Self::Utf8)
4456 }
4457
4458 /// Short name, for configuration and diagnostics.
4459 #[must_use]
4460 pub const fn as_str(self) -> &'static str {
4461 match self {
4462 Self::Utf16 => "utf16",
4463 Self::Utf8 => "utf8",
4464 }
4465 }
4466
4467 /// Parse a configuration value.
4468 ///
4469 /// An unknown value returns `None` rather than falling back to the
4470 /// default: a typo in a wire-affecting setting must be loud.
4471 #[must_use]
4472 pub fn parse(value: &str) -> Option<Self> {
4473 match value {
4474 "utf16" => Some(Self::Utf16),
4475 "utf8" => Some(Self::Utf8),
4476 _ => None,
4477 }
4478 }
4479}
4480
4481impl DefaultedDiscriminatorMember {
4482 /// Both.
4483 pub const ALL: [Self; 2] = [Self::TypeDefault, Self::StreamValue];
4484
4485 /// Whether this is the default.
4486 #[must_use]
4487 pub const fn is_default(self) -> bool {
4488 matches!(self, Self::TypeDefault)
4489 }
4490
4491 /// Short name, for configuration and diagnostics.
4492 #[must_use]
4493 pub const fn as_str(self) -> &'static str {
4494 match self {
4495 Self::TypeDefault => "type-default",
4496 Self::StreamValue => "stream-value",
4497 }
4498 }
4499
4500 /// Parse a configuration value.
4501 ///
4502 /// An unknown value returns `None` rather than falling back to the
4503 /// default: a typo here changes what a reader hands its application.
4504 #[must_use]
4505 pub fn parse(value: &str) -> Option<Self> {
4506 match value {
4507 "type-default" => Some(Self::TypeDefault),
4508 "stream-value" => Some(Self::StreamValue),
4509 _ => None,
4510 }
4511 }
4512}
4513
4514/// Which implementation's wire behaviour a plan reproduces.
4515///
4516/// Every setting below is separately selectable, and a dialect is a
4517/// name for one whole set of them. It exists because a peer is not a
4518/// collection of independent quirks: talking to RTI Connext means
4519/// taking RTI's answer to every question at once, and assembling that
4520/// from five flags is five chances to get one wrong.
4521///
4522/// # A dialect name is a promise
4523///
4524/// `--dialect opendds`, **on its own and with no second flag**,
4525/// reproduces every OpenDDS byte string this project's record holds.
4526/// The same for the other eight measured systems.
4527/// `tools/interop-results/tests/dialect_completeness.rs` is that claim
4528/// as a gate, row by row, and it is the reason several of the settings
4529/// below moved into a dialect: a name that reaches a product only if
4530/// the caller also knows which three further switches to set is a name
4531/// that has moved the question rather than answered it.
4532///
4533/// **The consequence, stated once and applying throughout.** Where a
4534/// product's behaviour departs from the clause, the dialect named for
4535/// that product takes the product's behaviour, including where that
4536/// behaviour is a defect. Fast DDS's front end drops `@bit_bound` and
4537/// writes a four-octet enumeration holder; Cyclone matches no negative
4538/// case label on a discriminator narrower than 32 bits and writes no
4539/// branch at all; OpenDDS numbers a `@mutable` union's branches from 0
4540/// and terminates nothing. Each of those is what the peer puts on the
4541/// wire, and a preset that named the peer and wrote something else
4542/// could not talk to it. Interoperability decides here;
4543/// [`Dialect::Specification`] is where the clause is, and it takes no
4544/// vendor's departure.
4545///
4546/// Each such setting names, at its own method below, the measurement it
4547/// rests on and the condition under which it leaves the dialect again —
4548/// which is always the same condition: the product stops doing it, and
4549/// then the entry goes with a version saying from when.
4550///
4551/// # The default is the specification, not a product
4552///
4553/// Where the implementations disagree, this project writes what the
4554/// clause says unless told otherwise. [`Self::Specification`] is
4555/// [`Dialect::default`] and [`Self::is_default`] answers for it.
4556/// `docs/specs/zerodds-interop-suite-1.0.md` §7 states it and §6 lists
4557/// it among what is settled.
4558///
4559/// **This reverses an earlier decision, and the reversal is the point.**
4560/// The default was [`Self::Cyclone`], on the argument that a default has
4561/// to be *some* implementation's behaviour to interoperate out of the
4562/// box. What that argument left out is which behaviour it hands to a
4563/// caller who names nothing: once the products' defects moved into the
4564/// dialects that name those products — the pass
4565/// `docs/wire-axes-followup.md` records — the default started writing
4566/// **Cyclone's defects**. A `wstring`'s code units in the host's order
4567/// rather than the stream's, and no branch at all for a negative case
4568/// label on a narrow signed discriminator.
4569/// [`Self::wstring_unit_order`] and [`Self::union_label`] carry the
4570/// runs. A caller who named no dialect got those two, and had nothing
4571/// in hand saying so.
4572///
4573/// A dialect is a deliberate choice to talk to one peer, and it may
4574/// carry that peer's defects — that is what it is for. It must not be
4575/// what somebody gets who chose nothing. `--dialect cyclone` is a
4576/// dialect like the other eight now, complete in what it reproduces and
4577/// preferred in nothing.
4578///
4579/// **[`Self::Specification`] is not what any measured implementation
4580/// produces in full**, and that is the cost of the choice rather than an
4581/// argument against it. Held against each system's own record it reaches
4582/// most of what each writes and departs where that system departs from
4583/// the text; the two result tables carry the per-system count and
4584/// `tools/interop-results/tests/dialect_completeness.rs` holds the claim
4585/// that each *named* dialect is complete for its own system.
4586///
4587/// **Where it stands alone is measured and counted.**
4588/// `dialect_completeness.rs::the_default_writes_octets_no_measured_system_wrote`
4589/// holds the default's octets against every system's in each of the 1332
4590/// recorded (case, version, order) slots: **84** of them have no witness
4591/// — no recorded system wrote what the default writes there.
4592/// `no_single_axis_separates_the_default_from_every_measured_system` says
4593/// the other half, and it is the one that matters for reading this
4594/// number: **no axis** is alone. Every value the default holds is held
4595/// by some product; what nobody was measured writing is the
4596/// *combination*. The largest family is `map` — Fast DDS is the only
4597/// system that produces a `map`'s octets and it orders the entries
4598/// ascending by key, so the clause's declaration order has no witness in
4599/// 46 slots. The next is an unannotated aggregate taken as
4600/// `@appendable` (RTI's, Fast DDS's and OpenDDS's answer) beside a
4601/// holder in the declared `@bit_bound` (Cyclone's and RTI's) — each half
4602/// somebody's, the pair nobody's.
4603///
4604/// The same sweep over [`Self::Cyclone`] gives **147**, and that
4605/// comparison is asserted rather than recalled: the default this one
4606/// replaced stood alone on more of the record, not less.
4607///
4608/// # What each dialect holds, and where the evidence is
4609///
4610/// Every cell is derived from the recorded octets rather than written
4611/// down: `interop-results --dialects` searches the axis product against
4612/// each system's own rows and reports, per axis, the value that
4613/// reproduces them and whether any row **pins** it. A cell no row pins
4614/// is a question that system's record does not ask, and it is named as
4615/// such at the method that answers it rather than left to look
4616/// measured.
4617///
4618/// The last column is the default. It is last because the table is read
4619/// left to right against the products; nothing else follows from the
4620/// order.
4621///
4622/// | setting | Cyclone | RTI 7.7 | Fast DDS | OpenDDS | Specification |
4623/// |---|---|---|---|---|---|
4624/// | collection delimiters | specification | never | enum-is-primitive | specification | specification |
4625/// | a `map`'s delimiter | either half | either half | **value only** | either half | either half |
4626/// | PL_CDR1 headers | always-extended | extended-for-constructed-in-large-samples | short-true-length | short-true-length | specification |
4627/// | PL_CDR1 pid flags | sentinel-must-understand | sentinel-must-understand | bare | **impl-extension-members** | sentinel-must-understand |
4628/// | default extensibility | final | appendable | appendable | appendable | appendable |
4629/// | `wstring` length, XCDR2 | octets/unterminated/2 | octets/unterminated/2 | characters/unterminated/2 | octets/unterminated/2 | octets/unterminated/2 |
4630/// | `wstring` length, XCDR1 | octets/unterminated/2 | characters/**terminated**/4 | characters/unterminated/2 | octets/unterminated/2 | octets/unterminated/2 |
4631/// | `wstring` unit order | **host-unswapped** | stream | stream | stream | stream |
4632/// | scalar `wchar` width | 2 | 4 under XCDR1, 2 under XCDR2 | 2 | 2 | 2 |
4633/// | length codes | compact | reuse-nested-delimiter | short-where-it-fits | **sized-or-nextint** | compact |
4634/// | member must-understand | declared | declared | omitted | declared-and-key | declared-and-key |
4635/// | enumeration holder | declared | declared | **bit-bound-dropped** | **bit-bound-dropped** | declared |
4636/// | enumerator value | declared | declared | declared | **ordinal** | declared |
4637/// | union labels | **negative-unmatched-below-32** | sign-extended | sign-extended | sign-extended | sign-extended |
4638/// | `@mutable` union framing | specification | specification | specification | **branches-from-zero** | specification |
4639/// | short pid range | up-to-reserved | up-to-reserved | up-to-reserved | **all-fourteen-bits** | up-to-reserved |
4640/// | `map` entry order | declaration | declaration | **ascending-key** | **ascending-key** | declaration |
4641/// | `@hashid` counter | advances | skipped | advances | skipped | advances |
4642/// | key holder, XCDR2 | forced-final | forced-final | forced-final | forced-final | forced-final |
4643/// | key holder, XCDR1 | forced-final | as-declared | forced-final | forced-final | forced-final |
4644///
4645/// The three CORBA presets and the two ROS presets are in their own
4646/// sections below, because most of this table is unreachable under
4647/// them.
4648///
4649/// **Fast DDS's default extensibility is measured after all.** It was
4650/// carried here as an unknown, on the ground that `fastddsgen` refuses
4651/// the `common::Robot` of GitHub issues #41 and #42. The interop
4652/// corpus answers it without that case and always has: every
4653/// `-unannotated` twin is a struct with no annotation, and Fast DDS
4654/// writes `30000000` in front of `primitive-mixed-unannotated` under
4655/// XCDR2 where Cyclone writes nothing. That is `@appendable`, from a
4656/// recorded row, and the footnote that called it a guess was reading
4657/// past its own corpus.
4658///
4659/// # Where a preset does not reproduce its vendor
4660///
4661/// **Every recorded row of every measured system is reproduced by that
4662/// system's own dialect**, and the gate above is what says so. What is
4663/// left in this section is the two places where that is true of the
4664/// bytes and not of every value they can carry, plus the questions no
4665/// dialect can settle.
4666///
4667/// ## Closed, and what each one was
4668///
4669/// Kept as a list rather than deleted, because "a preset reproduces its
4670/// vendor" is a claim that has to be re-checkable against the run that
4671/// settled it.
4672///
4673/// | what the preset wrote | what the vendor writes | where |
4674/// |---|---|---|
4675/// | `Dialect::Rti`, a two-byte `wchar` under XCDR1 | four bytes, `61000000` | `wchar_member`, [`WcharWidth`] |
4676/// | `Dialect::FastDds`, `M_FLAG` set in the EMHEADER | never set, `01000020` | `mut_mustunderstand`, [`MustUnderstandPolicy`] |
4677/// | `Dialect::FastDds`, LC4 for an eight-byte `sequence<short>` | LC3, `02000030` | `mut_lengthcodes`, [`LengthCodePolicy`] |
4678/// | `Dialect::Cyclone`, LC3 for `long ap[2]` and for an eight-byte nested `Cell` | LC4, `07000040` and `08000040` | `mut_lengthcodes` |
4679/// | `Dialect::Rti`, the same two | LC4, the same two | `mut_lengthcodes` |
4680/// | `Dialect::Rti`, LC4 over an `@appendable` nested member | LC5 over its own DHEADER, `00000050` | `mut_lc_nested` |
4681/// | `Dialect::FastDds`, a DHEADER on `sequence<bitmask>` | none, `01000030` | `mut_lc_enum`, [`CollectionDelimiterPolicy`] |
4682/// | `Dialect::Cyclone`, a `wstring`'s units in the stream's order | the host's, `61006200` big endian | [`WstringUnitOrder`], 12 rows |
4683/// | `Dialect::Cyclone`, a negative narrow case label matching | no branch written | [`UnionLabelMatch`], 24 rows |
4684/// | `Dialect::FastDds`, an enumeration in its declared bound | four octets whatever the bound | [`EnumHolderWidth`], 52 rows |
4685/// | `Dialect::FastDds`, a `map` in declaration order | ascending by key | [`MapEntryOrder`], 32 rows |
4686/// | `Dialect::FastDds`, a DHEADER on `map<string, long>` | none | [`MapDelimiter`], 12 rows |
4687/// | `Dialect::JacOrb`, a `fixed<P,S>`'s declared digit count | the value's precision | [`FixedDigitCount`], 6 rows |
4688/// | no dialect at all for OpenDDS | six settings between them | [`Self::OpenDds`], 936 rows |
4689/// | no dialect at all for either ROS type support | two settings and a coupling defect | [`Self::RosFastRtps`], [`Self::RosCyclone`], 369 rows |
4690///
4691/// The last three lines are this pass. The ones above them were closed
4692/// earlier and are kept for the same reason.
4693///
4694/// ## `Dialect::FastDds` does not reproduce Fast DDS's lost characters
4695///
4696/// One layer under the framing, and it is a **value** question rather
4697/// than a byte one. Fast CDR counts characters and writes each
4698/// four-byte `wchar_t` as two bytes, so a code point outside the BMP
4699/// goes out as its low sixteen bits: `U+1D11E` becomes `d11e`, which is
4700/// `U+D11E`, a Hangul syllable and a perfectly well-formed code point.
4701/// The peer reads a different character and has nothing to object to.
4702///
4703/// [`WstringLengthPolicy::CHARACTER_COUNT`] reproduces the length
4704/// convention and **refuses** the value, rather than truncating it.
4705/// Reproducing a vendor's framing is what a dialect is for; reproducing
4706/// a data loss that leaves no trace in the bytes is this project putting
4707/// a different character on the wire and calling it interoperability.
4708/// So `--dialect fastdds` writes Fast DDS's bytes for every `wstring`
4709/// Fast DDS does not corrupt, and errors on the ones it does. No
4710/// recorded row carries such a value, so this costs no row.
4711///
4712/// ## `Dialect::OpenDds` takes the declared `fixed<P,S>` count, as OpenDDS writes it
4713///
4714/// OpenDDS 3.34's serializer writes a `fixed` as
4715/// `OpenDDS::FaceTypes::Fixed_T<P,S>`, `(P + 2) / 2` octets: the
4716/// declared count, measured 2026-10-07 through the FACE mapping
4717/// (`0.00` under `fixed<5,2>` is `00000c`, `7` is `00700c`). Its
4718/// `write_fixed(to_ace_fixed())` is the ValueWriter path, and
4719/// `ACE_CDR::Fixed`'s own CDR operator — the string's digits,
4720/// [`FixedDigitCount::ValueStringDigits`], `unspecified_constructs.md`
4721/// §3 — is not on its wire. No recorded row of the OpenDDS column has a
4722/// `fixed`: the probe builds the classic mapping, where `tao_idl`
4723/// refuses the type and the struct comes out without the member.
4724///
4725/// ## No preset reproduces any vendor's `@hashid` member ids
4726///
4727/// The one place where naming a dialect cannot help, because the three
4728/// implementations disagree with each other as well as with this
4729/// project. XTypes 1.3 §7.3.1.2.1.1 defines the hash; which *name* goes
4730/// into it is where they part. `vendor_roundtrip.md`, "`@hashid` member
4731/// ids are not agreed by anyone", and the inventory's I22. A preset that
4732/// picked one would be reproducing one vendor and diverging from the
4733/// clause and from the other two, with nothing to say which.
4734///
4735/// ## Two extrapolations inside `Dialect::Rti` and `Dialect::FastDds`
4736///
4737/// Both in [`LengthCodePolicy`], both from the same measurement, both
4738/// written down here rather than left in a code comment:
4739///
4740/// - **a union inside a `@mutable` type.** Cyclone's source puts a union
4741/// and a nested aggregate in one arm and returns LC4 for both. RTI and
4742/// Fast DDS were measured reusing a nested aggregate's DHEADER, and
4743/// neither was measured on a union — no vendor row has one inside a
4744/// `@mutable` type. The two are treated alike, on the reading that one
4745/// clause and one leading word cannot have two answers.
4746/// - **a delimited nested aggregate of exactly one, two, four or eight
4747/// bytes.** [`LengthCodePolicy::ShortWhereItFits`] tries the reuse
4748/// first, because Fast DDS frames an eight-byte `string` as LC5 and
4749/// not LC3. Whether it would do the same for an eight-byte delimited
4750/// aggregate is untested; the shape does not arise in the measured
4751/// cases, where every delimited member is twelve bytes or more.
4752///
4753/// ## `Dialect::Rti` reproduces RTI, including where RTI departs
4754///
4755/// Not a gap, and here because it reads like one. RTI keeps a `@mutable`
4756/// type's parameter list in the key holder under XCDR1, so its KeyHash
4757/// is the MD5 of the framing and not the specification's key
4758/// zero-padded. [`KeyHolderExtensibility`] carries the run and
4759/// `Dialect::Rti` selects it, under XCDR1 only. Two peers that disagree
4760/// here compute different handles for one instance, which is not a
4761/// formatting difference — so unlike the byte order above, the departure
4762/// is modelled rather than recorded.
4763///
4764/// # What a dialect deliberately does not set
4765///
4766/// A `map`'s entry **count**, which is `MapLengthPolicy` and is not a
4767/// `Dialect` method at all. XTypes 1.3 §7.4.1.1.4 and rule (15) of
4768/// §7.4.3.5.3 contradict each other; Fast DDS is the one implementation
4769/// in the corpus that produces a `map`'s octets, and its rows pin the
4770/// equivalence's reading — the count is there under a delimiter as well
4771/// as without one. OpenDDS's generated `operator<<` reads rule (15) the
4772/// other way, calling `write_delimiter` under version 2 and the
4773/// `<< length` in the `else` branch under version 1 — and since
4774/// 2026-08-24 its own rows measure exactly that: `map-key-enum` and
4775/// `map-value-struct` under XCDR2 carry the DHEADER and no count,
4776/// `map-key-long` (primitive on both halves, undelimited) carries the
4777/// count. The `opendds` matrix configuration passes
4778/// `--map-length rule-15-omits`; the policy stays a probe flag rather
4779/// than a `Dialect` method because the two readings now have one
4780/// measured implementation each.
4781///
4782/// Which of a map's two halves decides whether it is *delimited* is a
4783/// different question, is measured, and is [`MapDelimiter`].
4784///
4785/// # The three ORB dialects
4786///
4787/// XCDR1 *is* CORBA CDR — CORBA 3.3 part 2 §9.3 — so an ORB is a
4788/// reference for the whole shared base and [`Self::OmniOrb`],
4789/// [`Self::Tao`] and [`Self::JacOrb`] are presets like the other three.
4790/// They differ from them in reach rather than in kind: an ORB has no
4791/// encoding version 2, no `@mutable`, no DHEADER and no EMHEADER, so
4792/// most of the settings above are unreachable under them and take the
4793/// clause's value. What they do reach is the wide-character family,
4794/// where CORBA and XTypes genuinely part.
4795///
4796/// | | omniORB 4.3.3 | TAO 2.5.24 | JacORB 3.9 |
4797/// |---|---|---|---|
4798/// | `wchar` framing | giop-length-prefixed | giop-length-prefixed-member-only | giop-length-prefixed |
4799/// | scalar `wchar` width | 2 | 4 | 2 |
4800/// | `wstring` unit width | 2 | 4 | 2 |
4801/// | `wstring` length | octets/unterminated | octets/unterminated | octets/unterminated |
4802/// | `wstring` unit order | byte-order-mark | stream | big-endian-always |
4803/// | `fixed<P,S>` digits | declared | declared, and unmeasured | **value-precision** |
4804/// | default extensibility | final | final | final |
4805///
4806/// JacORB's digit count is the one CORBA-only setting a dialect took in
4807/// this pass: [`FixedDigitCount`] carries the six rows.
4808/// [`Self::Tao`]'s cell is not a measurement — `tao_idl` refuses
4809/// `fixed` outright, so TAO has no recorded row — and the section above
4810/// says what ACE's runtime does instead.
4811///
4812/// # The two ROS 2 dialects
4813///
4814/// Neither is its vendor. `rosidl_typesupport_fastrtps_c` generates its
4815/// own serialisation rather than calling `fastddsgen`'s output, and
4816/// `rmw_cyclonedds_cpp` walks the introspection type support rather
4817/// than using Cyclone's `idlc`; both parse `@final`, `@key`,
4818/// `@optional` and `@id` and drop them, so every ROS message is a
4819/// `@final` struct in encoding version 1 whatever its `.idl` says.
4820/// Almost every setting above is therefore unreachable under them and
4821/// takes the clause's value.
4822///
4823/// What they do reach is the wide-character family, and there they
4824/// part:
4825///
4826/// | | `ros-fastrtps` 2.2.2 | `ros-cyclone` 1.3.4 |
4827/// |---|---|---|
4828/// | `wstring` length | characters/unterminated/4 | characters/unterminated/4 |
4829/// | `wstring` unit order | host-unswapped | stream |
4830/// | scalar `wchar` width | **4** | **2** |
4831///
4832/// The width row is the one that made [`WcharWidth`] an axis: it was
4833/// read off [`WstringLengthPolicy`] until `rmw_cyclonedds_cpp` was
4834/// fitted, and one setting cannot answer `e4003412` for a member and
4835/// four-octet units inside a `wstring` at once. The unit-order row is
4836/// measured for `ros-fastrtps` — it writes both byte orders and its
4837/// big-endian rows keep the units little-endian — and is **not**
4838/// separable for `ros-cyclone`, which writes the host's order and takes
4839/// no argument for it, so its record has one order and the two answers
4840/// coincide in it. [`Self::wstring_unit_order`] says which of the two
4841/// that is and why.
4842///
4843/// The extensibility row is not a measurement and does not need to be:
4844/// CORBA IDL has no extensibility annotation, an ORB writes the members
4845/// in declaration order with no framing, and under XCDR1 — the only
4846/// encoding an ORB has — `@appendable` is byte-for-byte `@final`
4847/// (rule 29, the inventory's H2). `@final` is what an ORB's bytes are;
4848/// naming it `Appendable` would be true of the bytes and false of the
4849/// type.
4850///
4851/// Two cells are extrapolations and both are named where a reader will
4852/// look: JacORB's unit order comes from its **read** direction, because
4853/// `org.jacorb.orb.CDROutputStream` has no `setLittleEndian` and every
4854/// JacORB byte string is big-endian, where the two candidate orders
4855/// coincide; and TAO's unit order is unmeasured for the same reason in
4856/// reverse, because libACE here was built without
4857/// `ACE_ENABLE_SWAP_ON_WRITE` and TAO writes host order only.
4858/// [`WstringUnitOrder::BigEndianAlways`] carries the first and
4859/// [`Self::Tao`] the second.
4860///
4861/// # Why the wide-character policies are not named for their GIOP versions
4862///
4863/// The measurement makes the case for it and then takes it back. CORBA
4864/// §9.3.1.6 has exactly two wide-character encodings, one per GIOP
4865/// version, and the DDS implementations split along that line:
4866/// [`WstringLengthPolicy::SPECIFICATION`] is GIOP 1.2's `wstring` and
4867/// [`WstringLengthPolicy::CHARACTER_COUNT_TERMINATED`] is GIOP 1.1's, and
4868/// TAO reproduces each by changing only the GIOP minor version. Naming
4869/// them `Giop11` and `Giop12` would put the citation in the name.
4870///
4871/// Four things say not to.
4872///
4873/// 1. **One of the forms belongs to no edition.**
4874/// [`WstringLengthPolicy::CHARACTER_COUNT`] — Fast DDS's — is a
4875/// character count with no terminator, which is neither §9.3.1.6
4876/// edition. A naming scheme that covers three of five variants and
4877/// leaves the other two mechanism-named is worse than five mechanism
4878/// names, because it reads as if the two were unclassified rather
4879/// than as if they were unclassifiable.
4880/// 2. **The GIOP version does not fix the whole form.** It fixes the
4881/// length convention; the code-unit width is the transmission code
4882/// set, which it does not fix. omniORB and TAO are both GIOP 1.2 and
4883/// write two octets and four. That is why
4884/// [`WstringLengthPolicy::SPECIFICATION_WIDE_UNITS`] exists at all, and
4885/// a variant called `Giop12` sitting beside another called `Giop12`
4886/// is not a naming scheme.
4887/// 3. **The default would be named for the wrong specification.** This
4888/// project implements XTypes; §7.4.1.1.2 is what
4889/// [`WstringLengthPolicy::SPECIFICATION`] cites. That the clause
4890/// coincides with GIOP 1.2's `wstring` is a derivation worth
4891/// recording, not an identity — and it is not an identity across the
4892/// pair, because XTypes' `Char16` is fixed-width, which is GIOP
4893/// **1.1**'s `wchar`. XTypes' own combination is GIOP 1.2's `wstring`
4894/// with GIOP 1.1's `wchar`, a pairing no edition produces. A variant
4895/// named `Giop12` that selected it would be wrong in its own name.
4896/// 4. **A name should say what the bytes are.** Somebody holding a
4897/// length field of 3 where 4 was expected needs "character count,
4898/// terminated"; "GIOP 1.1" sends them to a document. The citation
4899/// belongs in the doc comment and in the table above, where it now
4900/// is, and the equivalence is stated in both directions so it can be
4901/// followed either way.
4902///
4903/// What the GIOP reading *did* change is the shape of the model rather
4904/// than the names in it: the length convention and the unit width are
4905/// two axes because §9.3.1.6 makes them two, and the byte order is a
4906/// third because the code-set rule makes it one.
4907/// [`WcharFraming`] and [`WstringUnitOrder`] are that reading, and
4908/// without it the three ORB presets would each have been a fourth
4909/// special case in one overloaded enum.
4910#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
4911pub enum Dialect {
4912 /// Eclipse Cyclone DDS.
4913 Cyclone,
4914 /// RTI Connext DDS 7.7.
4915 Rti,
4916 /// eProsima Fast DDS with Fast CDR 2.3.5.
4917 FastDds,
4918 /// The clause-by-clause reading of XTypes 1.3, and **the default**.
4919 ///
4920 /// No measured implementation produces it in full, which is what
4921 /// makes it the default rather than what argues against it: the
4922 /// default is what the specifications say, and a dialect is a
4923 /// deliberate choice to talk to one peer and to carry that peer's
4924 /// defects while doing it. [`Self::is_default`] and the
4925 /// "Why the specification is the default" section of this type's
4926 /// documentation carry the reasoning.
4927 #[default]
4928 Specification,
4929 /// omniORB 4.3.3, over CORBA CDR — which is XCDR1.
4930 ///
4931 /// A two-octet transmission code set, GIOP 1.2's length-prefixed
4932 /// `wchar`, and a byte-order mark inside every `wstring`.
4933 OmniOrb,
4934 /// TAO 2.5.24, over CORBA CDR.
4935 ///
4936 /// A **four**-octet transmission code set and GIOP 1.2's
4937 /// length-prefixed `wchar`, which makes its `wstring` the one form
4938 /// no DDS implementation writes: an octet count over wide units.
4939 Tao,
4940 /// JacORB 3.9, over CORBA CDR.
4941 ///
4942 /// A two-octet code set like omniORB's and GIOP 1.2's `wchar`, with
4943 /// no byte-order mark: it reads the units big-endian whatever the
4944 /// stream says.
4945 JacOrb,
4946 /// OpenDDS 3.34.0 over `OpenDDS::DCPS::Serializer`.
4947 ///
4948 /// Fast DDS's XCDR1 `@mutable` framing with a parameter id no other
4949 /// implementation writes — `PID_IMPL_EXTENSION` and
4950 /// `PID_MUST_UNDERSTAND` set on every member — an XCDR2 length code
4951 /// that is short where the serialised length fits and never reuses
4952 /// a nested delimiter, `@key` implying must-understand as
4953 /// §7.2.2.4.4.4.8 requires, and a front end that carries neither
4954 /// `@value` nor `@bit_bound` as far as a serialiser.
4955 OpenDds,
4956 /// `rosidl_typesupport_fastrtps_c` 2.2.2, ROS 2 Humble.
4957 ///
4958 /// Not Fast DDS: it generates its own serialisation. Encoding
4959 /// version 1 only, every message `@final`, a `wstring` counted in
4960 /// characters over four-octet units whose octets stay in the host's
4961 /// order while the length swaps, and a four-octet scalar `wchar`
4962 /// that does swap.
4963 RosFastRtps,
4964 /// `rmw_cyclonedds_cpp` 1.3.4, ROS 2 Humble.
4965 ///
4966 /// Not Cyclone: it walks the introspection type support. The same
4967 /// `wstring` cell as [`Self::RosFastRtps`] over units in the
4968 /// stream's order, and a **two**-octet scalar `wchar` beside those
4969 /// four-octet units — the one implementation of the nine where the
4970 /// two widths part, and the reason [`WcharWidth`] is an axis.
4971 RosCyclone,
4972 /// OpenSplice Community Edition 6.9, over
4973 /// `DDS::OpenSplice::CdrTypeSupport`.
4974 ///
4975 /// `idlpp -S -l cpp` generates the standalone C++ mapping and
4976 /// `CdrTypeSupport::serialize` writes it: XCDR1 in the host's byte
4977 /// order, and nothing else — no XTypes, so no encoding version 2
4978 /// and no `@mutable`. `tools/wireprobe/opensplice-idl.py` rewrites a
4979 /// corpus type into the IDL 3.5 `idlpp` reads only where every
4980 /// construct means the same XCDR1 bytes there, and refuses the rest
4981 /// by name: `@mutable`, `@optional`, `@external`,
4982 /// `@must_understand`, an enumerator's `@value`, an enum's
4983 /// `@bit_bound` below 32, `bitmask`, `bitset`, `map`, a keyed union
4984 /// discriminator and a union as the topic type, and `idlpp` itself
4985 /// refuses `wchar` and `wstring` outright.
4986 ///
4987 /// One axis is measured: OpenSplice's 2 rows of
4988 /// `union-discriminator-short` and its unannotated twin — the one
4989 /// byte order and encoding version it writes — put it beside RTI,
4990 /// Fast DDS and OpenDDS on [`UnionLabelMatch::SignExtended`] — the
4991 /// clause's answer, and [`Self::Specification`]'s. Every other axis is
4992 /// unpinned for this dialect: `wchar`/`wstring`, `@mutable` framing,
4993 /// `map`, `@bit_bound` below 32, `@value` and `fixed` are exactly
4994 /// the constructs the front end above cannot express, so no recorded
4995 /// row asks any of them a question, and this preset takes the
4996 /// clause's answer on every one rather than guess at a form nobody
4997 /// measured.
4998 OpenSplice,
4999 /// HDDS, over the serialiser `hddsgen gen rust` writes into
5000 /// `encode_xcdr1_le` / `encode_xcdr2_le`.
5001 ///
5002 /// **Opt-in, and the column measures its generated code and not its
5003 /// runtime** — see `docs/adding-a-column.md` step 3 and
5004 /// `tools/wireprobe/hdds-shim`. `hddsgen` emits no big-endian
5005 /// encoder at all, so half of every cell does not exist; this preset
5006 /// carries no endianness of its own and the gate that sweeps it
5007 /// reaches only the little-endian rows.
5008 ///
5009 /// Four departures carry the column, and each has its own axis:
5010 ///
5011 /// | departure | axis | rows |
5012 /// |---|---|---|
5013 /// | a collection's elements follow one another with no padding between them, and align from their own first octet | [`ElementPadding::Packed`] | `array-1d-struct-final`, `sequence-struct-final` |
5014 /// | every aggregate is written as `@final`: no `DHEADER`, no EMHEADER, no parameter header | [`AggregateDelimiter::Never`] | `struct-appendable`, `annotation-must-understand-unannotated` |
5015 /// | …except in front of a `sequence` element of a non-`@final` type, under both versions | [`SequenceElementDelimiter::BeforeEachNonFinalElement`] | `sequence-struct-appendable`, `empty-struct-in-sequence` |
5016 /// | a `wstring` is UTF-8 with a NUL the length counts | [`WstringCodeUnits::Utf8`] | `wstring-member`, `wstring-unbounded-utf8` |
5017 ///
5018 /// Three more answers come off axes that already existed:
5019 /// [`WcharWidth::Four`] (`char-and-wchar`), [`EnumHolderWidth::BitBoundDroppedByFrontEnd`]
5020 /// (`combined-keyed-topic`) and [`CollectionDelimiterPolicy::Never`]
5021 /// (`sequence-struct-final`). What is left is unpinned and takes the
5022 /// clause's answer, each arm of each method saying which.
5023 Hdds,
5024}
5025
5026impl Dialect {
5027 /// Every dialect.
5028 pub const ALL: [Self; 12] = [
5029 Self::Cyclone,
5030 Self::Rti,
5031 Self::FastDds,
5032 Self::Specification,
5033 Self::OmniOrb,
5034 Self::Tao,
5035 Self::JacOrb,
5036 Self::OpenDds,
5037 Self::RosFastRtps,
5038 Self::RosCyclone,
5039 Self::OpenSplice,
5040 Self::Hdds,
5041 ];
5042
5043 /// Every dialect that is a CORBA ORB rather than a DDS
5044 /// implementation.
5045 ///
5046 /// Named because the difference is not cosmetic: an ORB has no
5047 /// encoding version 2 and no `@mutable`, so a test that sweeps every
5048 /// dialect through every encoding has to know which of the two lists
5049 /// it is in. [`Self::is_corba`] is the same fact per value.
5050 pub const CORBA: [Self; 3] = [Self::OmniOrb, Self::Tao, Self::JacOrb];
5051
5052 /// Whether this dialect is a CORBA ORB.
5053 ///
5054 /// An ORB implements CORBA CDR, which is XCDR1 and nothing else:
5055 /// CORBA 3.3 part 2 §9.3 has no delimiter header, no EMHEADER and no
5056 /// encoding version field.
5057 ///
5058 /// Asking one of these presets for an XCDR2 plan is **not** refused,
5059 /// and the reason is worth stating because a refusal was the first
5060 /// instinct. Every setting the preset carries has an answer under
5061 /// both encoding versions — the wide-character three because a
5062 /// `wchar` and a `wstring` are the same under either, and the rest
5063 /// because they take the clause's value — so the plan that comes out
5064 /// is well defined and the three columns are held to it by the
5065 /// three-column gate, which runs these presets over every encoding.
5066 /// What such a plan is *not* is a reproduction of any ORB: there is
5067 /// no XCDR2 byte string from omniORB, TAO or JacORB to be right or
5068 /// wrong about. Nothing is dropped and nothing is misplaced; the
5069 /// combination simply has no foreign side, which is what this method
5070 /// is for saying.
5071 #[must_use]
5072 pub const fn is_corba(self) -> bool {
5073 matches!(self, Self::OmniOrb | Self::Tao | Self::JacOrb)
5074 }
5075
5076 /// Whether this is the default.
5077 ///
5078 /// [`Self::Specification`], and the one place the answer is written
5079 /// as a predicate rather than read off `#[default]`. A caller that
5080 /// wants to know whether a plan is the shipped one asks here; a
5081 /// caller that wants the plan itself takes [`Dialect::default`].
5082 #[must_use]
5083 pub const fn is_default(self) -> bool {
5084 matches!(self, Self::Specification)
5085 }
5086
5087 /// Short name, for configuration and diagnostics.
5088 #[must_use]
5089 pub const fn as_str(self) -> &'static str {
5090 match self {
5091 Self::Cyclone => "cyclone",
5092 Self::Rti => "rti",
5093 Self::FastDds => "fastdds",
5094 Self::Specification => "specification",
5095 Self::OmniOrb => "omniorb",
5096 Self::Tao => "tao",
5097 Self::JacOrb => "jacorb",
5098 Self::OpenDds => "opendds",
5099 Self::RosFastRtps => "ros-fastrtps",
5100 Self::RosCyclone => "ros-cyclone",
5101 Self::OpenSplice => "opensplice",
5102 Self::Hdds => "hdds",
5103 }
5104 }
5105
5106 /// Parse a configuration value.
5107 ///
5108 /// An unknown value returns `None` rather than falling back to the
5109 /// default: a typo in a wire-affecting setting must be loud.
5110 #[must_use]
5111 pub fn parse(value: &str) -> Option<Self> {
5112 match value {
5113 "cyclone" => Some(Self::Cyclone),
5114 "rti" => Some(Self::Rti),
5115 "fastdds" => Some(Self::FastDds),
5116 "specification" => Some(Self::Specification),
5117 "omniorb" => Some(Self::OmniOrb),
5118 "tao" => Some(Self::Tao),
5119 "jacorb" => Some(Self::JacOrb),
5120 "opendds" => Some(Self::OpenDds),
5121 "ros-fastrtps" => Some(Self::RosFastRtps),
5122 "ros-cyclone" => Some(Self::RosCyclone),
5123 "opensplice" => Some(Self::OpenSplice),
5124 "hdds" => Some(Self::Hdds),
5125 _ => None,
5126 }
5127 }
5128
5129 /// Which collections carry a delimiter under XCDR2.
5130 #[must_use]
5131 pub const fn collection_delimiters(self) -> CollectionDelimiterPolicy {
5132 match self {
5133 // The three ORBs take the clause's value because they
5134 // cannot reach the question: a collection delimiter is an
5135 // XCDR2 header (rules 9 and 12) and CORBA CDR has no
5136 // encoding version 2. Written out rather than folded into
5137 // the `Specification` arm so that adding an eighth dialect
5138 // does not silently inherit an answer nobody measured.
5139 Self::Cyclone | Self::Specification | Self::OmniOrb | Self::Tao | Self::JacOrb => {
5140 CollectionDelimiterPolicy::Specification
5141 }
5142 Self::Rti => CollectionDelimiterPolicy::Never,
5143 Self::FastDds => CollectionDelimiterPolicy::EnumIsPrimitive,
5144 // Pinned by OpenDDS's own XCDR2 rows: it delimits exactly
5145 // the collections rules 9 and 12 name.
5146 Self::OpenDds => CollectionDelimiterPolicy::Specification,
5147 // Unreachable in either ROS column: both write encoding
5148 // version 1 only, where no collection carries a delimiter
5149 // at all. The clause's value, and nothing measured.
5150 Self::RosFastRtps | Self::RosCyclone => CollectionDelimiterPolicy::Specification,
5151 // Unreachable: a collection delimiter is an XCDR2 header
5152 // and OpenSplice 6.9 has no encoding version 2. The
5153 // clause's value, and nothing measured.
5154 Self::OpenSplice => CollectionDelimiterPolicy::Specification,
5155 // Measured: `sequence-struct-final` and `array-1d-string`
5156 // are byte-identical under the two versions, so the XCDR2
5157 // delimiter rules 9 and 12 name is not written.
5158 Self::Hdds => CollectionDelimiterPolicy::Never,
5159 }
5160 }
5161
5162 /// Which length code a `@mutable` member's EMHEADER gets.
5163 ///
5164 /// The three take three positions and [`LengthCodePolicy`] carries
5165 /// the run they were measured in. This used to answer `Compact` for
5166 /// all four, which made `Dialect::Rti` four bytes long on every
5167 /// delimited nested member and `Dialect::FastDds` four bytes long on
5168 /// every eight-byte one.
5169 #[must_use]
5170 pub const fn length_codes(self) -> LengthCodePolicy {
5171 match self {
5172 // §7.4.3.4.2 permits every one of them, so the
5173 // clause-by-clause dialect takes the majority form rather
5174 // than a form of its own.
5175 Self::Cyclone | Self::Specification => LengthCodePolicy::Compact,
5176 Self::Rti => LengthCodePolicy::ReuseNestedDelimiter,
5177 Self::FastDds => LengthCodePolicy::ShortWhereItFits,
5178 // Unreachable under an ORB: a length code lives in an
5179 // EMHEADER, which is XCDR2's `@mutable` framing.
5180 Self::OmniOrb | Self::Tao | Self::JacOrb => LengthCodePolicy::Compact,
5181 // Pinned by OpenDDS's XCDR2 `@mutable` rows: short where
5182 // the serialised length fits a code, LC4 otherwise, and
5183 // never a reused leading word.
5184 Self::OpenDds => LengthCodePolicy::SizedOrNextInt,
5185 // Unreachable in either ROS column, as the delimiter is.
5186 Self::RosFastRtps | Self::RosCyclone => LengthCodePolicy::Compact,
5187 // Unreachable: a length code lives in an EMHEADER, which is
5188 // XCDR2's `@mutable` framing, and OpenSplice has neither.
5189 Self::OpenSplice => LengthCodePolicy::Compact,
5190 // Unpinned: `hddsgen` generates no parameter-list encoder
5191 // at all, so no recorded row of that column writes an
5192 // EMHEADER. The clause's answer.
5193 Self::Hdds => LengthCodePolicy::Compact,
5194 }
5195 }
5196
5197 /// How a `@mutable` member is framed under XCDR1.
5198 #[must_use]
5199 pub const fn pl_cdr1_headers(self) -> PlCdr1HeaderPolicy {
5200 match self {
5201 Self::Cyclone => PlCdr1HeaderPolicy::AlwaysExtended,
5202 Self::Specification => PlCdr1HeaderPolicy::Specification,
5203 Self::Rti => PlCdr1HeaderPolicy::ExtendedForConstructedInLargeSamples,
5204 Self::FastDds => PlCdr1HeaderPolicy::ShortTrueLength,
5205 // Unreachable under an ORB: PL_CDR1 frames a `@mutable`
5206 // type and CORBA IDL has no extensibility annotation, so
5207 // `wireprobe-corba-idl` refuses such a type by name before
5208 // any ORB sees it.
5209 Self::OmniOrb | Self::Tao | Self::JacOrb => PlCdr1HeaderPolicy::Specification,
5210 // Fast DDS's framing exactly — the short header and the
5211 // member's true length — with a different parameter id.
5212 // `struct-mutable` under XCDR1: `c0000004` where Fast DDS
5213 // writes `00000004`, and `c0020002` where RTI rounds the
5214 // `short`'s length to `0004`.
5215 Self::OpenDds => PlCdr1HeaderPolicy::ShortTrueLength,
5216 // Unreachable: every ROS message is a `@final` XCDR1
5217 // struct whatever its `.idl` says, so no parameter list is
5218 // ever written.
5219 Self::RosFastRtps | Self::RosCyclone => PlCdr1HeaderPolicy::Specification,
5220 // Unreachable: PL_CDR1 frames a `@mutable` type and
5221 // `tools/wireprobe/opensplice-idl.py` refuses `@mutable` by
5222 // name before OpenSplice ever sees the file.
5223 Self::OpenSplice => PlCdr1HeaderPolicy::Specification,
5224 // Unreachable the same way: `wireprobe-hdds` refuses
5225 // `--encoding pl-cdr1` because `hddsgen` emits no
5226 // parameter-list encoder, so no recorded row of that column
5227 // has one. The clause's answer.
5228 Self::Hdds => PlCdr1HeaderPolicy::Specification,
5229 }
5230 }
5231
5232 /// What flags a PL_CDR1 parameter id carries, at a member and at the
5233 /// sentinel.
5234 ///
5235 /// [`PidFlagPolicy::ImplExtensionOnMembers`] is **`Dialect::OpenDds`'s**,
5236 /// and it is in that dialect because it is what OpenDDS puts on the
5237 /// wire. Measured on `struct-mutable` and
5238 /// `evolution-mutable-mustunderstand` under XCDR1, both byte
5239 /// orders: `c0010004 c0020004 c0030004`, `PID_IMPL_EXTENSION` and
5240 /// `PID_MUST_UNDERSTAND` set on the declared member and on the two
5241 /// undeclared ones alike, and a bare `3f02` sentinel after them.
5242 /// It is reached by `--pid-flags impl-extension-members` as well,
5243 /// for a peer that wants the point without the rest of the preset.
5244 ///
5245 /// It leaves this dialect the day OpenDDS stops setting the two
5246 /// bits unconditionally, with the version it stopped in.
5247 #[must_use]
5248 pub const fn pid_flags(self) -> PidFlagPolicy {
5249 match self {
5250 Self::Cyclone | Self::Rti | Self::Specification => {
5251 PidFlagPolicy::SentinelMustUnderstand
5252 }
5253 Self::FastDds => PidFlagPolicy::Bare,
5254 Self::OpenDds => PidFlagPolicy::ImplExtensionOnMembers,
5255 // Unreachable: a ROS message is `@final` and writes no
5256 // parameter list.
5257 Self::RosFastRtps | Self::RosCyclone => PidFlagPolicy::SentinelMustUnderstand,
5258 // Unreachable under an ORB: a parameter list frames a
5259 // `@mutable` type and CORBA IDL has no extensibility
5260 // annotation, so `wireprobe-corba-idl` refuses such a type
5261 // by name before any ORB sees it. Written out rather than
5262 // folded into the arm above so that an eighth dialect does
5263 // not silently inherit an answer nobody measured.
5264 Self::OmniOrb | Self::Tao | Self::JacOrb => PidFlagPolicy::SentinelMustUnderstand,
5265 // Unreachable: a PL_CDR1 parameter id frames a `@mutable`
5266 // member and OpenSplice has none.
5267 Self::OpenSplice => PidFlagPolicy::SentinelMustUnderstand,
5268 // Unreachable for the same reason as `length_codes`: no
5269 // recorded row of that column carries a parameter id.
5270 Self::Hdds => PidFlagPolicy::SentinelMustUnderstand,
5271 }
5272 }
5273
5274 /// How wide an enumeration's holder is.
5275 ///
5276 /// [`EnumHolderWidth::BitBoundDroppedByFrontEnd`] is **Fast DDS's
5277 /// and OpenDDS's**, and it is in both of their dialects. It is a
5278 /// front-end defect and not a reading of §7.4.3.5.3 — M1 of
5279 /// `docs/wire-axes-followup.md` has `fastddsgen` 4.3.0 emitting
5280 /// `enum class Colour : int32_t` and `BitBound = 32` for an
5281 /// `@bit_bound(8)` enumeration, and `opendds_idl` not knowing the
5282 /// symbol at all — and that is exactly why a dialect has to carry
5283 /// it: a peer that writes four octets writes them whatever the
5284 /// reason, and a reader of one octet is three octets out of step
5285 /// from there on. Naming the product is asking to talk to the
5286 /// product.
5287 ///
5288 /// Pinned by 76 recorded rows, both encoding versions and both byte
5289 /// orders: `enum-bitbound-{8,16}-{plain,value}` and their
5290 /// unannotated twins, `array-3d-enum`, `combined-everything` and
5291 /// `combined-keyed-topic`. It leaves those two dialects the day
5292 /// either front end carries the annotation as far as a serialiser,
5293 /// with the version it started in.
5294 ///
5295 /// `--enum-holder bit-bound-dropped` reaches it as a single point.
5296 ///
5297 /// Written out for all ten rather than a catch-all arm, so that an
5298 /// eleventh dialect does not silently inherit an answer nobody
5299 /// measured for it.
5300 #[must_use]
5301 pub const fn enum_holder(self) -> EnumHolderWidth {
5302 match self {
5303 Self::FastDds | Self::OpenDds => EnumHolderWidth::BitBoundDroppedByFrontEnd,
5304 // Measured: `combined-keyed-topic` puts `@bit_bound(8) enum
5305 // Mode { IDLE, ACTIVE, FAULT }`'s `FAULT` out as
5306 // `02 00 00 00`, four octets, where the clause's bound says
5307 // one.
5308 Self::Hdds => EnumHolderWidth::BitBoundDroppedByFrontEnd,
5309 // Cyclone honours the bound: `enum-bitbound-8-plain` under
5310 // XCDR1 big endian is `0200ab`, one octet a value.
5311 Self::Cyclone
5312 | Self::Rti
5313 | Self::Specification
5314 // Unreachable in either ROS column: no recorded row of
5315 // either carries a narrow-bound enumeration.
5316 | Self::RosFastRtps
5317 | Self::RosCyclone
5318 // CORBA has no `@bit_bound` at all: an enumeration is an
5319 // unsigned long, CORBA 3.3 part 2 §9.3.2.5, and
5320 // `wireprobe-corba-idl` refuses a type that carries the
5321 // annotation before any ORB sees it.
5322 | Self::OmniOrb
5323 | Self::Tao
5324 | Self::JacOrb
5325 // `opensplice-idl.py` refuses `@bit_bound` below 32 by name
5326 // and drops it at 32, the width every OpenSplice enum has;
5327 // no recorded row separates a declared holder from one the
5328 // front end dropped.
5329 | Self::OpenSplice => EnumHolderWidth::Declared,
5330 }
5331 }
5332
5333 /// Whether a case label of a narrow signed discriminator can be
5334 /// negative.
5335 ///
5336 /// [`UnionLabelMatch::NegativeLabelUnmatchedBelow32`] is
5337 /// **[`Self::Cyclone`]'s**, and it is in that dialect because it is
5338 /// what Cyclone puts on the wire. It drops the selected branch, so
5339 /// a conforming reader of the frame takes the member *after* the
5340 /// union for the branch — which is precisely what a peer talking to
5341 /// Cyclone has to expect, and what this dialect is a name for.
5342 ///
5343 /// Pinned by 24 recorded rows, both encoding versions and both byte
5344 /// orders. `union-discriminator-short`, XCDR1: Cyclone writes
5345 /// `c7cf0000 ffffffff` where RTI, Fast DDS and OpenDDS write
5346 /// `c7cf0000 7929edff ffffffff`. `f3` read as 243 is not `-13` and
5347 /// `cfc7` read as 53191 is not `-12345`; a negative label on a
5348 /// 32-bit discriminator matches and an unsigned narrow one matches,
5349 /// only the pair does not.
5350 /// `tests/interop-corpus/union-discriminator-short/README.md` and
5351 /// `combined-everything/README.md` carry the runs.
5352 ///
5353 /// **[`Self::Cyclone`] is not [`Dialect::default`]**, so naming the
5354 /// dialect is what it takes to get this. It was the default once,
5355 /// and this setting is one of the two that made the reversal:
5356 /// a caller who named nothing got a frame with no branch in it. The
5357 /// default is [`Self::Specification`], which is
5358 /// [`UnionLabelMatch::SignExtended`], the clause. This setting
5359 /// leaves this dialect the day Cyclone matches the label, with the
5360 /// version it started in.
5361 ///
5362 /// `--union-labels negative-unmatched-below-32` reaches it as a
5363 /// single point.
5364 ///
5365 /// Written out for all ten rather than a catch-all arm, so that an
5366 /// eleventh dialect does not silently inherit an answer nobody
5367 /// measured for it.
5368 #[must_use]
5369 pub const fn union_label(self) -> UnionLabelMatch {
5370 match self {
5371 Self::Cyclone => UnionLabelMatch::NegativeLabelUnmatchedBelow32,
5372 Self::Rti
5373 | Self::FastDds
5374 | Self::OpenDds
5375 | Self::Specification
5376 // Unreachable in either ROS column: no recorded row of
5377 // either carries a union at all.
5378 | Self::RosFastRtps
5379 | Self::RosCyclone
5380 // CORBA 3.3 part 2 §9.3.2.11 writes the discriminant and
5381 // then the selected member by the same rule, and no ORB
5382 // measured here departs from it.
5383 | Self::OmniOrb
5384 | Self::Tao
5385 | Self::JacOrb
5386 // Measured, not carried over: OpenSplice's own 2 rows of
5387 // `union-discriminator-short` and its unannotated twin —
5388 // the one byte order and encoding version it writes —
5389 // carry the branch, `c7cf 7929edff ffffffff`, the same
5390 // shape RTI, Fast DDS and OpenDDS write.
5391 | Self::OpenSplice => UnionLabelMatch::SignExtended,
5392 // Unpinned: `hddsgen` refuses a union outright, so no
5393 // recorded row of that column carries a case label. The
5394 // clause's answer.
5395 | Self::Hdds => UnionLabelMatch::SignExtended,
5396 }
5397 }
5398
5399 /// Whether a delimited `map` omits its entry count — rule (15) of
5400 /// §7.4.3.5.3 as written, against §7.4.1.1.4's sequence
5401 /// equivalence.
5402 ///
5403 /// Measured on 2026-08-24, one implementation on each side:
5404 /// OpenDDS's rows carry the DHEADER and no count (`map-key-enum`,
5405 /// `map-value-struct` under XCDR2) and the count where the map is
5406 /// undelimited (`map-key-long`, primitive on both halves); Fast
5407 /// DDS's rows carry both. A `bool` for the reason
5408 /// `hashed_id_advances_the_counter` is one: `MapLengthPolicy`
5409 /// lives with the codec, not here, and `ProbeRequest::in_dialect`
5410 /// maps the fact onto it.
5411 #[must_use]
5412 pub const fn map_count_omitted_when_delimited(self) -> bool {
5413 match self {
5414 Self::OpenDds => true,
5415 Self::FastDds
5416 | Self::Cyclone
5417 | Self::Rti
5418 | Self::Specification
5419 | Self::RosFastRtps
5420 | Self::RosCyclone
5421 | Self::OmniOrb
5422 | Self::Tao
5423 | Self::JacOrb
5424 // `opensplice-idl.py` refuses `map<K, V>` by name; no
5425 // recorded row of this dialect reaches a `map` at all.
5426 | Self::OpenSplice => false,
5427 // `hddsgen` refuses a `map` too, and for the same reason
5428 // there is nothing to measure here.
5429 | Self::Hdds => false,
5430 }
5431 }
5432
5433 /// Whether an array's element is flattened before the delimiter
5434 /// question. See [`ArrayElementFlattening`]; only OpenDDS stops at
5435 /// the alias, measured on `resolve-array-of-typedef-array`.
5436 #[must_use]
5437 pub const fn array_element_flattening(self) -> ArrayElementFlattening {
5438 match self {
5439 Self::OpenDds => ArrayElementFlattening::StopsAtAlias,
5440 Self::Cyclone
5441 | Self::FastDds
5442 | Self::Rti
5443 | Self::Specification
5444 | Self::OmniOrb
5445 | Self::Tao
5446 | Self::JacOrb
5447 | Self::RosFastRtps
5448 | Self::RosCyclone
5449 | Self::OpenSplice
5450 // Unpinned: the one recorded row of that column that
5451 // reaches an alias at all, `recursive-typedef-sequence`,
5452 // is a `sequence`, and it separates the two values on the
5453 // row `resolve-array-of-typedef-array` names — which
5454 // `hddsgen` does not compile. The clause's answer.
5455 | Self::Hdds => ArrayElementFlattening::ThroughAliasesAndArrays,
5456 }
5457 }
5458
5459 /// In what order a `map`'s entries go on the wire.
5460 ///
5461 /// [`MapEntryOrder::AscendingKey`] is **[`Self::FastDds`]'s**, and
5462 /// it is in that dialect because it is what Fast DDS puts on the
5463 /// wire. Judgement 3's threshold — two independent implementations
5464 /// make a *position on an axis* — is about whether the order is a
5465 /// reading of the clause, and it is not: §7.4.3.5.3 rules (14) to
5466 /// (16) give a map as a length and a run of pairs and say nothing
5467 /// about their order, so this is the property of the `std::map`
5468 /// `fastddsgen` chose. That makes it a compatibility setting, which
5469 /// is exactly what belongs in the dialect of the product that
5470 /// writes it.
5471 ///
5472 /// Pinned by 32 recorded rows: `map-key-long`, `map-key-char`,
5473 /// `map-key-ulonglong`, `map-key-boolean` and `map-key-string`,
5474 /// their unannotated twins, both byte orders, both versions.
5475 ///
5476 /// **[`Self::OpenDds`] joined on 2026-08-24**: its column now
5477 /// records `map` octets, and they are ascending by key under the
5478 /// key type's own value order — `map-key-long` puts −1234567 ahead
5479 /// of 1 ahead of 2147483647 (signed), `map-key-ulonglong` puts 7
5480 /// ahead of 16045690984833335998 (unsigned; a signed reading would
5481 /// reverse them). The property of the ordered container both code
5482 /// generators chose, two products independently — still a
5483 /// compatibility setting and not a reading of the clause, which
5484 /// says nothing about order. The remaining dialects take the
5485 /// declaration order and nothing measured says otherwise.
5486 ///
5487 /// `--map-entries ascending-key` reaches it as a single point.
5488 ///
5489 /// Written out for all ten rather than a catch-all arm, so that an
5490 /// eleventh dialect does not silently inherit an answer nobody
5491 /// measured for it.
5492 #[must_use]
5493 pub const fn map_entries(self) -> MapEntryOrder {
5494 match self {
5495 Self::FastDds | Self::OpenDds => MapEntryOrder::AscendingKey,
5496 Self::Cyclone
5497 | Self::Rti
5498 | Self::Specification
5499 // Unreachable in either ROS column: no ROS message has a
5500 // `map`.
5501 | Self::RosFastRtps
5502 | Self::RosCyclone
5503 // CORBA has no `map` at all: the IDL-to-C and IDL-to-Java
5504 // mappings this project's CORBA probes go through have no
5505 // such type, and `wireprobe-corba-idl` refuses one before
5506 // any ORB sees it.
5507 | Self::OmniOrb
5508 | Self::Tao
5509 | Self::JacOrb
5510 // `opensplice-idl.py` refuses `map<K, V>` by name.
5511 | Self::OpenSplice
5512 // `hddsgen` refuses a `map` as well, and no recorded row of
5513 // that column reaches one.
5514 | Self::Hdds => MapEntryOrder::Declaration,
5515 }
5516 }
5517
5518 /// Which integer an enumerator serialises as.
5519 ///
5520 /// [`EnumeratorValue::Ordinal`] is **OpenDDS's**, and it is in
5521 /// [`Self::OpenDds`] for the reason
5522 /// [`EnumHolderWidth::BitBoundDroppedByFrontEnd`] is in it: a front
5523 /// end that drops `@value` still puts the ordinal on the wire, and
5524 /// naming the product is asking to talk to the product.
5525 ///
5526 /// Pinned by 40 recorded rows. `enum-bitbound-8-value` under
5527 /// OpenDDS, XCDR1 big endian, once the holder width is out of the
5528 /// way: `00000002 00000001 ab` where `@value(200)` and `@value(9)`
5529 /// were declared — the ordinals 2 and 1.
5530 ///
5531 /// **`tao_idl` drops `@value` too and [`Self::Tao`] does not take
5532 /// this**, which is a difference between a measurement and a
5533 /// reading: all three ORB columns record every `@value` case as
5534 /// `NOFORM` — `an enumerator with an explicit @value, where CORBA's
5535 /// is its ordinal position` —
5536 /// so no ORB row separates the two values. A dialect that took it
5537 /// there would be reproducing a front end nobody measured through
5538 /// this corpus.
5539 ///
5540 /// `--enumerator-value ordinal` reaches it as a single point.
5541 ///
5542 /// Written out for all ten rather than a catch-all arm, so that an
5543 /// eleventh dialect does not silently inherit an answer nobody
5544 /// measured for it.
5545 #[must_use]
5546 pub const fn enumerator_value(self) -> EnumeratorValue {
5547 match self {
5548 Self::OpenDds => EnumeratorValue::Ordinal,
5549 Self::Cyclone
5550 | Self::Rti
5551 | Self::FastDds
5552 | Self::Specification
5553 // Unreachable in either ROS column: `rosidl` has no
5554 // enumeration with an explicit value in the corpus.
5555 | Self::RosFastRtps
5556 | Self::RosCyclone
5557 // CORBA 3.3 part 2 §9.3.2.5 serialises an enumeration as an
5558 // `unsigned long` of the enumerator's own value, and
5559 // omniORB and JacORB both carry `@value` — `tao_idl` does
5560 // not, and TAO is reached by the setting rather than by its
5561 // dialect for the same reason OpenDDS is.
5562 | Self::OmniOrb
5563 | Self::Tao
5564 | Self::JacOrb
5565 // `opensplice-idl.py` refuses an enumerator's `@value` by
5566 // name; every OpenSplice enum is its ordinal in four
5567 // octets, which is `Declared`'s own answer where no
5568 // `@value` was given.
5569 | Self::OpenSplice
5570 // Unpinned for that column: `hddsgen` emits
5571 // `enum-value-gaps` as an error rather than as octets, so
5572 // no recorded row separates the two answers.
5573 | Self::Hdds => EnumeratorValue::Declared,
5574 }
5575 }
5576
5577 /// How a `@mutable` union numbers its branches, and whether its
5578 /// XCDR1 parameter list is terminated.
5579 ///
5580 /// [`MutableUnionFraming::BranchesFromZero`] is **OpenDDS's**, and
5581 /// it is in [`Self::OpenDds`] although it reproduces a frame in
5582 /// which two parameters share member id 0 and nothing terminates
5583 /// the list — a frame no conforming reader can walk. That was the
5584 /// argument for keeping it out of every dialect, and it is the
5585 /// argument for putting it in this one: the frame is what an
5586 /// OpenDDS peer sends, and a preset that named the product and
5587 /// wrote something else would be a preset that cannot talk to it.
5588 ///
5589 /// Pinned by 8 recorded rows, `union-mutable` and its unannotated
5590 /// twin in both versions and both orders.
5591 ///
5592 /// `--union-framing branches-from-zero` reaches it as a single
5593 /// point.
5594 ///
5595 /// Written out for all ten rather than a catch-all arm, so that an
5596 /// eleventh dialect does not silently inherit an answer nobody
5597 /// measured for it.
5598 #[must_use]
5599 pub const fn union_framing(self) -> MutableUnionFraming {
5600 match self {
5601 Self::OpenDds => MutableUnionFraming::BranchesFromZero,
5602 Self::Cyclone
5603 | Self::Rti
5604 | Self::FastDds
5605 | Self::Specification
5606 // Unreachable: a ROS message is `@final`.
5607 | Self::RosFastRtps
5608 | Self::RosCyclone
5609 // CORBA 3.3 part 2 §9.3.2.11 has one union framing and no
5610 // member ids at all: an ORB writes the discriminant and the
5611 // selected member and nothing else.
5612 | Self::OmniOrb
5613 | Self::Tao
5614 | Self::JacOrb
5615 // Unreachable: `opensplice-idl.py` refuses `@mutable` by
5616 // name, so no OpenSplice union is ever framed as one.
5617 | Self::OpenSplice
5618 // Unreachable for that column too: every recorded row of
5619 // it writes a union as `@final`.
5620 | Self::Hdds => MutableUnionFraming::Specification,
5621 }
5622 }
5623
5624 /// Which member ids fit a PL_CDR1 short parameter header.
5625 ///
5626 /// [`ShortPidRange::AllFourteenBits`] is **OpenDDS's**: it writes
5627 /// an id in the range §7.4.1.2.2 reserves in the short header,
5628 /// where Cyclone, RTI and Fast DDS were all measured writing the
5629 /// extended one. Pinned by 2 recorded rows under XCDR1.
5630 ///
5631 /// `--short-pid-range all-fourteen-bits` reaches it as a single
5632 /// point.
5633 ///
5634 /// Written out for all ten rather than a catch-all arm, so that an
5635 /// eleventh dialect does not silently inherit an answer nobody
5636 /// measured for it.
5637 #[must_use]
5638 pub const fn short_pid_range(self) -> ShortPidRange {
5639 match self {
5640 Self::OpenDds => ShortPidRange::AllFourteenBits,
5641 Self::Cyclone
5642 | Self::Rti
5643 | Self::FastDds
5644 | Self::Specification
5645 // Unreachable: a ROS message writes no parameter list.
5646 | Self::RosFastRtps
5647 | Self::RosCyclone
5648 // A CORBA ORB writes no parameter list at all.
5649 | Self::OmniOrb
5650 | Self::Tao
5651 | Self::JacOrb
5652 // Unreachable: OpenSplice writes no parameter list either,
5653 // for the same reason `pl_cdr1_headers` gives.
5654 | Self::OpenSplice
5655 // Unreachable for the same reason: no recorded row of that
5656 // column carries a parameter id.
5657 | Self::Hdds => ShortPidRange::UpToReserved,
5658 }
5659 }
5660
5661 /// Whether a `fixed<P,S>`'s digit count comes from the declaration
5662 /// or from the value.
5663 ///
5664 /// [`FixedDigitCount::ValuePrecision`] is **[`Self::JacOrb`]'s**,
5665 /// and it is in that dialect because it is what JacORB puts on the
5666 /// wire: `BigDecimal.precision()` digits and never fewer than one.
5667 /// Pinned by 6 recorded rows — `fixed-basic`, `sequence-fixed`,
5668 /// `array-1d-fixed` and their twins, where JacORB writes
5669 /// `123456789c1dab` against omniORB's and this project's
5670 /// `123456789c000000001dab` — and they were the whole of JacORB's
5671 /// unmatched column.
5672 ///
5673 /// [`FixedDigitCount::ValueStringDigits`] is no dialect's. It is what
5674 /// ACE/TAO 2.5.24's `ACE_CDR::Fixed` writes through its own CDR
5675 /// operator (the 7 rows of `unspecified_constructs.md` §3), and no
5676 /// DDS product's serializer goes through that operator: OpenDDS 3.34
5677 /// writes `OpenDDS::FaceTypes::Fixed_T<P,S>`, `(P + 2) / 2` octets,
5678 /// the declared count — measured 2026-10-07 through the FACE mapping,
5679 /// `0.00` under `fixed<5,2>` as `00000c`, `7` as `00700c`, `0.12345`
5680 /// under `fixed<6,5>` as `0012345c`. Its `write_fixed(to_ace_fixed())`
5681 /// is the ValueWriter path (JSON and dynamic data), not the wire.
5682 ///
5683 /// `--fixed-digits value-precision` reaches JacORB's as a single
5684 /// point; `--fixed-digits value-string-digits` reaches ACE's.
5685 ///
5686 /// Written out for all ten rather than a catch-all arm, so that an
5687 /// eleventh dialect does not silently inherit an answer nobody
5688 /// measured for it.
5689 #[must_use]
5690 pub const fn fixed_digits(self) -> FixedDigitCount {
5691 match self {
5692 Self::JacOrb => FixedDigitCount::ValuePrecision,
5693 // `Fixed_T<P,S>::operator<<`, `(P + 2) / 2` octets; see the doc.
5694 Self::OpenDds => FixedDigitCount::Declared,
5695 // A `fixed` is CORBA's and no DDS implementation writes one:
5696 // XTypes 1.3 has no fixed-point type, and the DDS probes
5697 // refuse the construct. `tao_idl` refuses it too.
5698 Self::Cyclone
5699 | Self::Rti
5700 | Self::FastDds
5701 | Self::Specification
5702 | Self::RosFastRtps
5703 | Self::RosCyclone
5704 | Self::OmniOrb
5705 | Self::Tao
5706 // `idlpp` itself refuses `fixed` outright.
5707 | Self::OpenSplice
5708 // XTypes 1.3 has no fixed-point type, and `hddsgen` refuses
5709 // the construct as well, so no recorded row of that column
5710 // asks the question.
5711 | Self::Hdds => FixedDigitCount::Declared,
5712 }
5713 }
5714
5715 /// Whether a member's `must_understand` reaches the wire, in either
5716 /// encoding version.
5717 ///
5718 /// [`Dialect::Specification`] takes
5719 /// [`MustUnderstandPolicy::DeclaredAndKey`] and the three vendor
5720 /// dialects do not, which is the one place in this table where the
5721 /// specification's answer and every measured vendor's part company:
5722 /// §7.2.2.4.4.4.8 says a `@key` member always has the attribute, and
5723 /// Cyclone, RTI and Fast DDS were all measured writing
5724 /// `evolution-mutable-key`'s two key members with `M_FLAG` clear.
5725 /// OpenDDS writes them with it set.
5726 #[must_use]
5727 pub const fn must_understand(self) -> MustUnderstandPolicy {
5728 match self {
5729 Self::FastDds => MustUnderstandPolicy::Omitted,
5730 // OpenDDS is the one measured implementation that agrees
5731 // with the clause here, and it is pinned by its own XCDR2
5732 // EMHEADERs: `evolution-mutable-key`'s two key members come
5733 // out with `M_FLAG` set.
5734 Self::OpenDds => MustUnderstandPolicy::DeclaredAndKey,
5735 // The clause read whole: a `@key` member and the
5736 // discriminator of a `@mutable` union (§7.2.2.4.4.4.6 and
5737 // §7.2.2.4.4.4.8).
5738 Self::Specification => MustUnderstandPolicy::DeclaredKeyAndDiscriminator,
5739 Self::Cyclone | Self::Rti => MustUnderstandPolicy::Declared,
5740 // Unreachable under an ORB: the flag is a bit of an
5741 // EMHEADER or of a parameter id, both `@mutable` framing.
5742 // Unreachable in a ROS column for the same reason: every
5743 // message is `@final`.
5744 Self::OmniOrb | Self::Tao | Self::JacOrb | Self::RosFastRtps | Self::RosCyclone => {
5745 MustUnderstandPolicy::Declared
5746 }
5747 // Unreachable for the same reason: the flag lives in an
5748 // EMHEADER or a PL_CDR1 parameter id, and OpenSplice writes
5749 // neither — no encoding version 2, `@mutable` refused by
5750 // name. Takes the clause's value, [`Self::Specification`]'s,
5751 // rather than the ORBs' and the ROS columns' — none of
5752 // those measured this system, so nothing here argues for
5753 // one unreached answer over the other.
5754 Self::OpenSplice => MustUnderstandPolicy::DeclaredAndKey,
5755 // Unreachable and inert: `hddsgen` writes every aggregate as
5756 // `@final`, so no member header of any kind reaches the wire
5757 // and the flag has nowhere to be written. Takes the clause's
5758 // value.
5759 Self::Hdds => MustUnderstandPolicy::DeclaredAndKey,
5760 }
5761 }
5762
5763 /// What an aggregate whose source said nothing is taken to be.
5764 #[must_use]
5765 pub const fn default_extensibility(self) -> Extensibility {
5766 match self {
5767 Self::Cyclone => Extensibility::Final,
5768 Self::Rti | Self::FastDds | Self::OpenDds | Self::Specification => {
5769 Extensibility::Appendable
5770 }
5771 // `opensplice-idl.py` drops `@final` and `@appendable`
5772 // alike — neither changes OpenSplice's XCDR1 bytes, which
5773 // carry no DHEADER whatever the extensibility says (rule
5774 // 29) — so no recorded row distinguishes this from `Final`.
5775 // Takes the clause's value.
5776 Self::OpenSplice => Extensibility::Appendable,
5777 // Every ROS message is a `@final` XCDR1 struct whatever its
5778 // `.idl` says: `rosidl` parses the annotation and drops it.
5779 // Under XCDR1 `@appendable` is byte-for-byte `@final`, so
5780 // no recorded row separates the two; `Final` is what the
5781 // type *is* to that front end, which is the reason the
5782 // three ORB presets take it too.
5783 Self::RosFastRtps | Self::RosCyclone => Extensibility::Final,
5784 // CORBA IDL has no extensibility annotation at all, so
5785 // every CORBA structure is an aggregate whose source said
5786 // nothing, and what an ORB writes for it is the members in
5787 // declaration order with no framing — the `@final` form.
5788 // Under XCDR1, which is the only encoding an ORB has,
5789 // `@appendable` is byte-for-byte `@final` (rule 29), so the
5790 // two would give the same bytes; `Final` is chosen because
5791 // it is what the type *is* and not only what the bytes are,
5792 // and because a plan that called it appendable would grow a
5793 // DHEADER the moment somebody asked one of these presets
5794 // for an XCDR2 plan it has no business answering.
5795 Self::OmniOrb | Self::Tao | Self::JacOrb => Extensibility::Final,
5796 // Measured, and it is the one place this answer is a
5797 // measurement rather than a reading: `hddsgen` emits
5798 // `struct_flags: IS_FINAL` for an unannotated structure,
5799 // where §7.3.1.2.1.8 makes it `@appendable`. The rows that
5800 // need it are the `-unannotated` twins of every
5801 // `@appendable` case — `struct-unannotated`,
5802 // `sequence-struct-unannotated`, `string-adjacent-widths-unannotated`.
5803 Self::Hdds => Extensibility::Final,
5804 }
5805 }
5806
5807 /// Whether a `@hashid` member advances the sequential counter the
5808 /// next unannotated member is numbered from.
5809 ///
5810 /// DDS-XTypes 1.3 §7.3.1.2.1.1 says it twice and not the same way.
5811 /// One sentence numbers a member from "the most-recently specified
5812 /// ID (using the @id annotation …)", which leaves a hashed id out of
5813 /// the count; the next numbers it from "the member ID of the
5814 /// previous member of the same Aggregated Type plus one", which does
5815 /// not.
5816 ///
5817 /// Measured 2026-08-09,
5818 /// `crates/ast-layout/tests/fixtures/vendor_bytes.md` §7. For
5819 /// `@mutable struct Sample { @hashid long alpha; long b; }` Cyclone
5820 /// and Fast DDS give `b` the id `0343172D`, the hash plus one; RTI
5821 /// gives it `00000000`. With an `@id(10)` before the hashed member
5822 /// RTI gives `0000000B`, so its counter is not merely reset — a
5823 /// hashed id never advances it at all.
5824 ///
5825 /// Three of the four take the second reading, which is why it is the
5826 /// default. It changes the member id on the wire, and a peer that
5827 /// numbers differently reads the wrong member or none, so RTI's
5828 /// answer is a dialect and not a footnote.
5829 ///
5830 /// # Why this one is a bool and not a policy enum
5831 ///
5832 /// It is consumed by the **lowering** pass, in
5833 /// `zerodds_idl_lower::complete::member_ids`, not by the planner:
5834 /// member ids are fixed before a `LayoutKey` is ever built. A shared
5835 /// policy type would have to live in a crate both `zerodds-ast-layout`
5836 /// and `zerodds-idl-lower` can see, and neither depends on the other.
5837 /// The *fact* still lives here, once, beside the other seven, and
5838 /// `zerodds_idl_lower::Options` carries the boolean.
5839 #[must_use]
5840 pub const fn hashed_id_advances_the_counter(self) -> bool {
5841 match self {
5842 // OpenDDS numbers the next member from the last *declared*
5843 // id, the way RTI does. Pinned by its own `@hashid` rows.
5844 Self::Rti | Self::OpenDds => false,
5845 Self::Cyclone | Self::FastDds | Self::Specification => true,
5846 // Unreachable under an ORB: `@hashid` is an XTypes
5847 // annotation and CORBA IDL has no member ids at all.
5848 // Unreachable in a ROS column: `rosidl` drops the
5849 // annotation with the rest of them.
5850 Self::OmniOrb | Self::Tao | Self::JacOrb | Self::RosFastRtps | Self::RosCyclone => true,
5851 // Unreachable: `idlpp` never sees `@hashid` at all —
5852 // `opensplice-idl.py` strips it, along with `@id` and
5853 // `@autoid`, before the file reaches the front end, because
5854 // none of the three changes OpenSplice's XCDR1 bytes (its
5855 // members are positional).
5856 Self::OpenSplice => true,
5857 // Unpinned: every recorded row of that column writes its
5858 // members in declaration order, which both readings give.
5859 Self::Hdds => true,
5860 }
5861 }
5862
5863 /// Whether a declaration may carry the name of the scope it is
5864 /// written in — `module common { struct common { … }; }`.
5865 ///
5866 /// IDL 4.2 §7.5.2 does not admit it: "The name of a module,
5867 /// structure, union, map, interface, value type, event type,
5868 /// exception or home may not be redefined within the immediate
5869 /// scope of" that same construct. The products split on it, and the
5870 /// split is measured by the corpus case
5871 /// `resolve-module-named-as-type` in
5872 /// `tests/interop-results/ledger.tsv`, each with its own words:
5873 ///
5874 /// * Cyclone DDS — `Declaration 'common' collides with an earlier
5875 /// declaration of 'common'`
5876 /// * OpenDDS — `Error - tao_idl: illegal redefinition "common" and
5877 /// "common"`, and TAO, omniORB and JacORB refuse it likewise
5878 /// * Fast DDS and RTI Connext take it and write octets
5879 /// * `rosidl` takes it too, under either middleware, because it
5880 /// runs its own front end rather than the vendor's
5881 ///
5882 /// A `bool` for the reason the two above are: the lowering pass
5883 /// consumes it, before any `LayoutKey` exists.
5884 /// `zerodds_idl_lower::NameOfTheEnclosingScope` carries the policy.
5885 ///
5886 /// A reader that refuses the file can never reproduce the octets the
5887 /// other four wrote — which is what
5888 /// `tools/interop-results/tests/dialect_completeness.rs` measures,
5889 /// and what this axis answers.
5890 #[must_use]
5891 pub const fn admits_the_name_of_the_enclosing_scope(self) -> bool {
5892 match self {
5893 // Measured: `resolve-module-named-as-type` has an HDDS row
5894 // in both encoding versions — `01000000` — so `hddsgen`
5895 // accepts the file where §7.5.2 refuses it.
5896 Self::Rti | Self::FastDds | Self::RosFastRtps | Self::RosCyclone | Self::Hdds => true,
5897 Self::Cyclone
5898 | Self::OpenDds
5899 | Self::Specification
5900 | Self::OmniOrb
5901 | Self::Tao
5902 | Self::JacOrb => false,
5903 // Takes the clause's value: this project's own front end
5904 // refuses `resolve-module-named-as-type` for OpenSplice
5905 // before `idlpp` ever sees the file, so the corpus row is
5906 // this axis's answer and not an independent reading of
5907 // `idlpp`'s grammar.
5908 Self::OpenSplice => false,
5909 }
5910 }
5911
5912 /// Whether a member-id hash reads an escaped name as written —
5913 /// `_long` with its underscore — instead of the identifier the
5914 /// escape spells.
5915 ///
5916 /// IDL 4.2 §7.2.3.2 makes the underscore "a purely lexical
5917 /// convention" that is not part of the name, so the specification
5918 /// hashes `long`. RTI hashes the spelling, keyword or not: measured
5919 /// with rtiddsgen 7.7.0 on `@hashid long _long;` and
5920 /// `@hashid long _foo;`, whose TypeCode member ids are
5921 /// 79659193 = hash("_long") and 230782087 = hash("_foo") where the
5922 /// clause gives 56906255 and 186170796 — rtiddsgen's generated C
5923 /// even names the fields `_long` and `_foo`. It changes the member
5924 /// id on the wire, so it is a dialect and not a footnote.
5925 ///
5926 /// A `bool` for the reason `hashed_id_advances_the_counter` is one:
5927 /// consumed by the lowering pass, before any `LayoutKey` exists.
5928 /// `zerodds_idl_lower::HashedNameSpelling` carries the policy.
5929 #[must_use]
5930 pub const fn hashid_reads_the_escaped_spelling(self) -> bool {
5931 match self {
5932 Self::Rti => true,
5933 // Measured stripped for Cyclone and Fast DDS by the
5934 // `identifier-escaped-keyword` cells; OpenDDS numbers as
5935 // the clause reads. Unreachable under an ORB or a ROS
5936 // column, which drop the annotation.
5937 Self::Cyclone
5938 | Self::FastDds
5939 | Self::OpenDds
5940 | Self::Specification
5941 | Self::OmniOrb
5942 | Self::Tao
5943 | Self::JacOrb
5944 | Self::RosFastRtps
5945 | Self::RosCyclone
5946 // Unreachable: member ids do not exist on an OpenSplice
5947 // wire at all.
5948 | Self::OpenSplice
5949 // Unpinned: `identifier-escaped-keyword` is not one of that
5950 // column's rows, so nothing separates the two readings.
5951 | Self::Hdds => false,
5952 }
5953 }
5954
5955 /// Whether a key holder is forced to `@final`, which for RTI
5956 /// depends on the encoding version.
5957 #[must_use]
5958 pub const fn key_holder_extensibility(self, encoding: Encoding) -> KeyHolderExtensibility {
5959 match self {
5960 Self::Rti if encoding.is_v1() => KeyHolderExtensibility::AsDeclared,
5961 // Unreachable under an ORB: a KeyHash is DDSI-RTPS §9.6.3.8
5962 // and CORBA has no instances, no keys and no discovery.
5963 Self::Cyclone
5964 | Self::Rti
5965 | Self::FastDds
5966 | Self::OpenDds
5967 | Self::Specification
5968 | Self::RosFastRtps
5969 | Self::RosCyclone
5970 | Self::OmniOrb
5971 | Self::Tao
5972 | Self::JacOrb
5973 // Not measured — OpenSplice is a real DDS system with keys
5974 // and discovery, unlike the three ORBs, but no recorded row
5975 // reaches a key holder. Takes the clause's value.
5976 | Self::OpenSplice
5977 // Not measured either: every recorded row of that column is
5978 // a payload, and a key holder is reached only by a
5979 // KeyHash.
5980 | Self::Hdds => KeyHolderExtensibility::ForcedFinal,
5981 }
5982 }
5983
5984 /// How deep Step 1's member removal reaches, which for RTI depends
5985 /// on the encoding version.
5986 #[must_use]
5987 pub const fn key_holder_nesting(self, encoding: Encoding) -> KeyHolderNesting {
5988 match self {
5989 Self::Rti if encoding.is_v1() => KeyHolderNesting::KeylessNestedWhole,
5990 // Unreachable under an ORB, as `key_holder_extensibility`.
5991 Self::Cyclone
5992 | Self::Rti
5993 | Self::FastDds
5994 | Self::OpenDds
5995 | Self::Specification
5996 | Self::RosFastRtps
5997 | Self::RosCyclone
5998 | Self::OmniOrb
5999 | Self::Tao
6000 | Self::JacOrb
6001 // Not measured, as `key_holder_extensibility`.
6002 | Self::OpenSplice
6003 // Not measured, as `key_holder_extensibility`.
6004 | Self::Hdds => KeyHolderNesting::Recursive,
6005 }
6006 }
6007
6008 /// In what order a key holder's members are serialized, which for
6009 /// RTI depends on the encoding version.
6010 #[must_use]
6011 pub const fn key_holder_order(self, encoding: Encoding) -> KeyHolderOrder {
6012 match self {
6013 Self::Rti if encoding.is_v1() => KeyHolderOrder::Declaration,
6014 // Unreachable under an ORB, as `key_holder_extensibility`.
6015 Self::Cyclone
6016 | Self::Rti
6017 | Self::FastDds
6018 | Self::OpenDds
6019 | Self::Specification
6020 | Self::RosFastRtps
6021 | Self::RosCyclone
6022 | Self::OmniOrb
6023 | Self::Tao
6024 | Self::JacOrb
6025 // Not measured, as `key_holder_extensibility`.
6026 | Self::OpenSplice
6027 // Not measured, as `key_holder_extensibility`.
6028 | Self::Hdds => KeyHolderOrder::AscendingMemberId,
6029 }
6030 }
6031
6032 /// How a key holder's members are aligned, which for Cyclone and RTI
6033 /// depends on the encoding version. See [`KeyHolderAlignment`] for the
6034 /// measurement.
6035 #[must_use]
6036 pub const fn key_holder_alignment(self, encoding: Encoding) -> KeyHolderAlignment {
6037 match self {
6038 Self::OpenSplice => KeyHolderAlignment::Packed,
6039 Self::Cyclone | Self::Rti if encoding.is_v1() => KeyHolderAlignment::DataEncoding,
6040 // Fast DDS measured as the clause; OpenDDS not measured and
6041 // takes the clause's value. The ORBs and the ROS type supports
6042 // never reach a key holder.
6043 Self::Cyclone
6044 | Self::Rti
6045 | Self::FastDds
6046 | Self::OpenDds
6047 | Self::Specification
6048 | Self::RosFastRtps
6049 | Self::RosCyclone
6050 | Self::OmniOrb
6051 | Self::Tao
6052 | Self::JacOrb
6053 // Not measured, as `key_holder_extensibility`.
6054 | Self::Hdds => KeyHolderAlignment::Aligned,
6055 }
6056 }
6057
6058 /// What becomes of a union's member when `@try_construct(use_default)`
6059 /// defaulted its discriminator.
6060 ///
6061 /// Every dialect answers [`DefaultedDiscriminatorMember::TypeDefault`],
6062 /// and the twelve arms are written out rather than folded together
6063 /// so that a thirteenth does not inherit an answer nobody looked
6064 /// for.
6065 ///
6066 /// - `Rti` and `OpenDds` are measurements: each hands back the
6067 /// member's type default, recorded in
6068 /// [`DefaultedDiscriminatorMember`]'s table.
6069 /// - `FastDds` cannot reach the question — it refuses an annotation
6070 /// on a union discriminator — so it takes the clause's value and
6071 /// claims no measurement.
6072 /// - `Cyclone` and the two ROS type supports have no recorded run of
6073 /// this shape either; the clause's value, and nothing measured.
6074 /// - The three ORBs have no `@try_construct` at all: CORBA CDR
6075 /// knows no such annotation, and a union whose discriminator
6076 /// value has no case is an `INV_OBJREF` there, not an evolution.
6077 /// - `OpenSplice` has neither: `opensplice-idl.py` refuses
6078 /// `@try_construct` by name. The clause's value.
6079 /// - `Hdds` has not been asked: `annotation-try-construct` is one of
6080 /// the cases whose generated code does not compile. The clause's
6081 /// value.
6082 #[must_use]
6083 pub const fn defaulted_discriminator_member(self) -> DefaultedDiscriminatorMember {
6084 match self {
6085 Self::Cyclone
6086 | Self::Rti
6087 | Self::FastDds
6088 | Self::OpenDds
6089 | Self::Specification
6090 | Self::RosFastRtps
6091 | Self::RosCyclone
6092 | Self::OmniOrb
6093 | Self::Tao
6094 | Self::JacOrb
6095 | Self::OpenSplice
6096 | Self::Hdds => DefaultedDiscriminatorMember::TypeDefault,
6097 }
6098 }
6099
6100 /// Whether this dialect writes the RTI Connext/CoreDX minimal
6101 /// `TypeObject` camp — one trailing octet inside each aggregated
6102 /// member's `DHEADER`, counted into the byte stream the minimal
6103 /// `EquivalenceHash` of XTypes 1.3 §7.3.4.5 is taken over.
6104 ///
6105 /// `docs/omg-xtypes-issues.md` §C2 measures the split. Two camps
6106 /// ship and neither stream is malformed; the clause does not decide
6107 /// which one the hash covers, so this is a dialect choice and not a
6108 /// defect in either sender.
6109 ///
6110 /// - `Rti` and `CoreDx` (CoreDX rides the `Rti` arm; the interop
6111 /// matrix records the two computing the same minimal hash) write
6112 /// the octet — measured `Cyclone\u{2194}RTI` and `ZeroDDS\u{2194}RTI`
6113 /// on `struct_key_enum_1`/`struct_key_struct_1`.
6114 /// - `Cyclone`, `FastDds` and `Specification` do not: Cyclone and
6115 /// Fast DDS agree with each other and are the default here, and
6116 /// `crates/types/tests/typeobject_vendor_dheader.rs` records their
6117 /// octets.
6118 /// - The ORBs and ROS type supports have no minimal `TypeObject`
6119 /// (CORBA CDR and the ROS introspection paths carry none), so
6120 /// they take the default `false` and claim no measurement.
6121 #[must_use]
6122 pub const fn minimal_member_trailing_octet(self) -> bool {
6123 match self {
6124 Self::Rti => true,
6125 Self::Cyclone
6126 | Self::FastDds
6127 | Self::OpenDds
6128 | Self::Specification
6129 | Self::RosFastRtps
6130 | Self::RosCyclone
6131 | Self::OmniOrb
6132 | Self::Tao
6133 | Self::JacOrb
6134 // OpenSplice has no minimal `TypeObject` either — it has no
6135 // XTypes at all. The default `false`, and no measurement.
6136 | Self::OpenSplice
6137 // `tools/wireprobe-hdds` compiles `hddsgen`'s own output
6138 // verbatim and never reaches a `TypeObject`, so nothing in
6139 // that column measures this.
6140 | Self::Hdds => false,
6141 }
6142 }
6143
6144 /// How a `wstring`'s length is counted, which for RTI depends on
6145 /// the encoding version.
6146 #[must_use]
6147 pub const fn wstring_length(self, encoding: Encoding) -> WstringLengthPolicy {
6148 match self {
6149 Self::Cyclone | Self::Specification => WstringLengthPolicy::SPECIFICATION,
6150 Self::FastDds => WstringLengthPolicy::CHARACTER_COUNT,
6151 // Measured on every `wstring` row OpenDDS wrote: octets, no
6152 // terminator, two-octet units, which is §7.4.1.1.2's own
6153 // form.
6154 Self::OpenDds => WstringLengthPolicy::SPECIFICATION,
6155 // The cell no name in the old five-value enumeration could
6156 // reach: a count of **characters** over **four**-octet
6157 // units. `wstring-unbounded-utf8` under either type
6158 // support is `07000000` and then seven four-octet units.
6159 Self::RosFastRtps | Self::RosCyclone => WstringLengthPolicy::ROS_TYPE_SUPPORT,
6160 // Measured 2026-08-12 at the member and the collection
6161 // element alike: RTI's XCDR1 `wstring` is CORBA §9.3.1.6's
6162 // GIOP 1.1 form byte for byte, and needs no RTI-specific
6163 // rule anywhere.
6164 Self::Rti if encoding.is_v1() => WstringLengthPolicy::CHARACTER_COUNT_TERMINATED,
6165 Self::Rti => WstringLengthPolicy::SPECIFICATION,
6166 // GIOP 1.2's octet count in all three, over a two-octet
6167 // transmission code set in omniORB and JacORB and a
6168 // four-octet one in TAO. The width rides on this setting
6169 // because it rides on it for the scalar `wchar` too.
6170 Self::OmniOrb | Self::JacOrb => WstringLengthPolicy::SPECIFICATION,
6171 Self::Tao => WstringLengthPolicy::SPECIFICATION_WIDE_UNITS,
6172 // Unreachable: `idlpp` itself refuses `wstring` outright.
6173 Self::OpenSplice => WstringLengthPolicy::SPECIFICATION,
6174 // Measured, and only the cell's third coordinate is live.
6175 // `wstring-member` declares `04 00 00 00` for `w = "abc"`
6176 // and then three UTF-8 octets and a NUL, so the count is of
6177 // octets and the terminator is inside it — the cell
6178 // §7.4.3.5.3 rule (3) gives a narrow `string` and rule (4)
6179 // forbids for a `wstring`. The unit width is not read at
6180 // all under [`WstringCodeUnits::Utf8`], which is the axis
6181 // that has the octets.
6182 Self::Hdds => WstringLengthPolicy::new(
6183 WstringLengthCounts::Octets,
6184 WstringTerminator::Present,
6185 WstringUnitWidth::Two,
6186 ),
6187 }
6188 }
6189
6190 /// How a `wchar` is framed.
6191 ///
6192 /// The setting that splits the five DDS implementations from the
6193 /// three ORBs: every DDS implementation writes XTypes Table 31's
6194 /// fixed-width code unit and every ORB writes CORBA §9.3.1.6's
6195 /// GIOP 1.2 length-prefixed one for a *member*. The three ORBs then
6196 /// part on the collection element — omniORB and JacORB keep the
6197 /// count octet, TAO drops it — which is why TAO's answer is
6198 /// [`WcharFraming::GiopLengthPrefixedMemberOnly`] and not the same
6199 /// value as the other two. [`WcharFraming`] has the runs.
6200 #[must_use]
6201 pub const fn wchar_framing(self) -> WcharFraming {
6202 match self {
6203 Self::Cyclone
6204 | Self::Rti
6205 | Self::FastDds
6206 | Self::OpenDds
6207 | Self::Specification
6208 | Self::RosFastRtps
6209 | Self::RosCyclone
6210 // Unreachable: `idlpp` itself refuses `wchar` outright.
6211 | Self::OpenSplice
6212 // Unpinned: no recorded row of that column has a scalar
6213 // `wchar` of its own — `char-and-wchar` is the one, and
6214 // `hddsgen` maps IDL `char` and `wchar` alike to a Rust
6215 // `char`, four octets either way. Table 31's answer.
6216 | Self::Hdds => WcharFraming::Fixed,
6217 Self::OmniOrb | Self::JacOrb => WcharFraming::GiopLengthPrefixed,
6218 Self::Tao => WcharFraming::GiopLengthPrefixedMemberOnly,
6219 }
6220 }
6221
6222 /// How wide a scalar `wchar` is.
6223 ///
6224 /// Eight of the nine give the same answer here as they give for a
6225 /// `wstring`'s code units, which is why this was one setting until
6226 /// the ninth was fitted: `rmw_cyclonedds_cpp` writes `e400 3412`
6227 /// for `primitive-wchar` and four-octet units inside a `wstring`.
6228 /// [`WcharWidth`] carries the octets.
6229 ///
6230 /// RTI is the reason the encoding is an argument: `61000000` under
6231 /// XCDR1 and `6100` under XCDR2, the same split its `wstring` takes.
6232 #[must_use]
6233 pub const fn wchar_width(self, encoding: Encoding) -> WcharWidth {
6234 match self {
6235 Self::Rti if encoding.is_v1() => WcharWidth::Four,
6236 // Table 31's two octets, and the value every DDS
6237 // implementation but RTI-under-XCDR1 was measured writing.
6238 // Unreachable: `idlpp` itself refuses `wchar` outright.
6239 Self::Cyclone | Self::Rti | Self::FastDds | Self::Specification | Self::OpenSplice => {
6240 WcharWidth::Two
6241 }
6242 // Measured, and the pair the axis exists for.
6243 // `primitive-wchar` under XCDR1 little endian:
6244 // `rosidl_typesupport_fastrtps_c` writes `e400000034120000`
6245 // and `rmw_cyclonedds_cpp` writes `e4003412`, while both
6246 // put four-octet units inside a `wstring`.
6247 Self::RosFastRtps => WcharWidth::Four,
6248 // Measured: `char-and-wchar` is
6249 // `61 00 00 00 7a 00 00 00` in **both** encoding versions —
6250 // `char c = 'a'` and then `wchar w = 'z'` in four octets
6251 // each. `hddsgen` renders both IDL types as a Rust `char`,
6252 // which is a four-octet scalar; see
6253 // `docs/edge-case-map.md`, "What the first comparison
6254 // showed".
6255 Self::Hdds => WcharWidth::Four,
6256 Self::OpenDds | Self::RosCyclone => WcharWidth::Two,
6257 // The transmission code set's width, which the ORBs also
6258 // put in the GIOP 1.2 count octet: omniORB and JacORB write
6259 // `02 00e4`, TAO `04 e4000000`.
6260 Self::OmniOrb | Self::JacOrb => WcharWidth::Two,
6261 Self::Tao => WcharWidth::Four,
6262 }
6263 }
6264
6265 /// Which of a `map`'s halves decides whether the map is delimited.
6266 ///
6267 /// [`MapDelimiter`] carries the twelve Fast DDS rows that separate
6268 /// the two readings. Every other measured implementation refuses a
6269 /// `map` before it reaches a wire, so the clause's reading is what
6270 /// they take.
6271 #[must_use]
6272 pub const fn map_delimiter(self) -> MapDelimiter {
6273 match self {
6274 Self::FastDds => MapDelimiter::ValueOnly,
6275 // Rule (14) read as written. None of these produces a
6276 // `map`'s octets at all — `docs/refused-constructs.md`,
6277 // "`map` in the C emitters" — so this is the clause's
6278 // answer and not a measurement, and it is written out for
6279 // each rather than folded into one arm so that a dialect
6280 // added later does not inherit an answer nobody took.
6281 Self::Cyclone
6282 | Self::Rti
6283 | Self::OpenDds
6284 | Self::Specification
6285 | Self::RosFastRtps
6286 | Self::RosCyclone
6287 | Self::OmniOrb
6288 | Self::Tao
6289 | Self::JacOrb
6290 // `opensplice-idl.py` refuses `map<K, V>` by name.
6291 | Self::OpenSplice
6292 // `hddsgen` refuses a `map` as well.
6293 | Self::Hdds => MapDelimiter::EitherHalf,
6294 }
6295 }
6296
6297 /// In what byte order a `wstring`'s code units go out.
6298 ///
6299 /// [`WstringUnitOrder`] has the runs, including which of the three
6300 /// ORB answers is measured in the write direction and which is
6301 /// carried over from the read direction.
6302 #[must_use]
6303 pub const fn wstring_unit_order(self) -> WstringUnitOrder {
6304 match self {
6305 // Cyclone's big-endian units are unswapped, and this
6306 // preset reproduces that because it is what Cyclone puts on
6307 // the wire. `"ab"` big endian leaves Cyclone as
6308 // `00000004 61006200` where RTI and Fast DDS write
6309 // `00000004 00610062`: the length word swaps and the
6310 // content does not, so read as the big-endian stream it is
6311 // labelled, Cyclone's `"ab"` is U+6100 U+6200.
6312 // `tests/fixtures/vendor_bytes.md` §4 has the run, and 12
6313 // recorded rows pin it.
6314 //
6315 // This preset used to write the stream's order here, on the
6316 // reading that a default must not be a form a conforming
6317 // reader mis-decodes — which was true of the reading and
6318 // false of the place it was applied: it made
6319 // `--dialect cyclone` unable to exchange a big-endian
6320 // `wstring` with Cyclone at all. The preset takes the
6321 // product's order; the *default* is what carries the
6322 // reading, and `Self::Specification` is `Dialect::default`
6323 // now, which is `WstringUnitOrder::Stream`. Naming the
6324 // product is asking to talk to the product. It leaves this
6325 // dialect the day Cyclone swaps the units, with the version
6326 // it started in.
6327 Self::RosFastRtps => WstringUnitOrder::HostUnswapped,
6328 Self::Cyclone => WstringUnitOrder::HostUnswapped,
6329 // Unreachable: `idlpp` itself refuses `wstring` outright.
6330 // `Hdds` is unreachable here for the same axis rather than
6331 // for the front end: its `wstring` is UTF-8 octets, which
6332 // `WstringCodeUnits` carries and this order does not reach.
6333 Self::Rti | Self::FastDds | Self::OpenDds | Self::Specification | Self::OpenSplice => {
6334 WstringUnitOrder::Stream
6335 }
6336 // Not separable from `HostUnswapped` by any recorded row:
6337 // `rmw_cyclonedds_cpp` writes the host's byte order and
6338 // takes no argument for it, so every row it has is little
6339 // endian on a little-endian host and the two answers
6340 // coincide. `Stream` is the reading of the two that matches
6341 // what the type support does — on a big-endian host it
6342 // would produce a big-endian stream with big-endian units —
6343 // and it is written down here rather than left to look
6344 // measured.
6345 Self::RosCyclone => WstringUnitOrder::Stream,
6346 Self::OmniOrb => WstringUnitOrder::ByteOrderMark,
6347 // TAO's write direction is host-order only here, so `Stream`
6348 // and a fixed order cannot be told apart in it. `Stream` is
6349 // the safe reading of the two: it is what the clause says,
6350 // it is byte-identical to what TAO was measured writing on
6351 // this little-endian host, and it is the one that does not
6352 // claim a big-endian measurement nobody has.
6353 Self::Tao => WstringUnitOrder::Stream,
6354 Self::JacOrb => WstringUnitOrder::BigEndianAlways,
6355 // Unpinned, and inert: under [`WstringCodeUnits::Utf8`] the
6356 // content is UTF-8, which has no byte order of its own, so
6357 // the writer and the reader never consult it.
6358 Self::Hdds => WstringUnitOrder::Stream,
6359 }
6360 }
6361
6362 /// Whether the elements of a collection carry alignment padding
6363 /// between them.
6364 ///
6365 /// [`ElementPadding::Packed`] is **[`Self::Hdds`]'s** and is
6366 /// measured on `array-1d-struct-final` and `sequence-struct-final`,
6367 /// which name the departure in their own octets. Every other dialect
6368 /// answers the clause, and nothing else in this table has been
6369 /// measured to differ.
6370 #[must_use]
6371 pub const fn element_padding(self) -> ElementPadding {
6372 match self {
6373 Self::Hdds => ElementPadding::Packed,
6374 Self::Cyclone
6375 | Self::Rti
6376 | Self::FastDds
6377 | Self::Specification
6378 | Self::OmniOrb
6379 | Self::Tao
6380 | Self::JacOrb
6381 | Self::OpenDds
6382 | Self::RosFastRtps
6383 | Self::RosCyclone
6384 | Self::OpenSplice => ElementPadding::Aligned,
6385 }
6386 }
6387
6388 /// Whether an aggregate's declared extensibility reaches the wire.
6389 ///
6390 /// [`AggregateDelimiter::Never`] is **[`Self::Hdds`]'s** and is
6391 /// measured at three of the four positions an aggregate can be
6392 /// reached in — the sample itself, a struct member, and a `@mutable`
6393 /// type's member framing. The fourth, a `sequence` element, is
6394 /// [`Self::sequence_element_delimiter`]'s and answers the opposite,
6395 /// which is why it is a separate axis and not a third value here.
6396 ///
6397 /// **Under the plain and delimited encodings only.** Every recorded
6398 /// HDDS row is `plain-cdr1` or `plain-cdr2`; no HDDS bytes exist
6399 /// under PL_CDR1 or PL_CDR2, where the encapsulation identifier states
6400 /// a parameter list and a reader takes the framing from it. There the
6401 /// dialect writes and reads the specification's framing: measured on
6402 /// the Linux test host, `Never` under PL_CDR1 refused Cyclone's `@mutable` stream
6403 /// in `dialect_roundtrip.rs` and wrote a parameter list with no
6404 /// parameter headers in `silent_drop.rs`.
6405 #[must_use]
6406 pub const fn aggregate_delimiter(self, encoding: Encoding) -> AggregateDelimiter {
6407 match self {
6408 Self::Hdds if matches!(encoding, Encoding::PlCdr1 | Encoding::PlCdr2) => {
6409 AggregateDelimiter::Specification
6410 }
6411 Self::Hdds => AggregateDelimiter::Never,
6412 Self::Cyclone
6413 | Self::Rti
6414 | Self::FastDds
6415 | Self::Specification
6416 | Self::OmniOrb
6417 | Self::Tao
6418 | Self::JacOrb
6419 | Self::OpenDds
6420 | Self::RosFastRtps
6421 | Self::RosCyclone
6422 | Self::OpenSplice => AggregateDelimiter::Specification,
6423 }
6424 }
6425
6426 /// Whether a `sequence` element of a non-`@final` type is preceded
6427 /// by its own octet length.
6428 ///
6429 /// [`SequenceElementDelimiter::BeforeEachNonFinalElement`] is
6430 /// **[`Self::Hdds`]'s**, measured on `sequence-struct-appendable`,
6431 /// `empty-struct-in-sequence`, `resolve-forward-declared`,
6432 /// `recursive-mutual` and `recursive-self-sequence`.
6433 #[must_use]
6434 pub const fn sequence_element_delimiter(self) -> SequenceElementDelimiter {
6435 match self {
6436 Self::Hdds => SequenceElementDelimiter::BeforeEachNonFinalElement,
6437 Self::Cyclone
6438 | Self::Rti
6439 | Self::FastDds
6440 | Self::Specification
6441 | Self::OmniOrb
6442 | Self::Tao
6443 | Self::JacOrb
6444 | Self::OpenDds
6445 | Self::RosFastRtps
6446 | Self::RosCyclone
6447 | Self::OpenSplice => SequenceElementDelimiter::Never,
6448 }
6449 }
6450
6451 /// Which octets stand for a `wstring`'s characters.
6452 ///
6453 /// [`WstringCodeUnits::Utf8`] is **[`Self::Hdds`]'s** and is
6454 /// measured on `wstring-member` and `wstring-unbounded-utf8`. It is
6455 /// not the same question as [`Self::wstring_length`]: that one says
6456 /// what the length counts, this one says what the content is made
6457 /// of, and the two part because a run of UTF-8 octets has no
6458 /// fixed-width code unit for the first to count.
6459 #[must_use]
6460 pub const fn wstring_code_units(self) -> WstringCodeUnits {
6461 match self {
6462 Self::Hdds => WstringCodeUnits::Utf8,
6463 Self::Cyclone
6464 | Self::Rti
6465 | Self::FastDds
6466 | Self::Specification
6467 | Self::OmniOrb
6468 | Self::Tao
6469 | Self::JacOrb
6470 | Self::OpenDds
6471 | Self::RosFastRtps
6472 | Self::RosCyclone
6473 | Self::OpenSplice => WstringCodeUnits::Utf16,
6474 }
6475 }
6476}
6477
6478/// What a layout is computed for.
6479///
6480/// Member offsets are invariant under endianness — byte order changes
6481/// the bytes, not where they sit — so the cache shares offset
6482/// computation between the two and consults the endianness only where it
6483/// genuinely matters: the big-endian key holder and per-primitive byte
6484/// order. Spec §7.
6485#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
6486pub struct LayoutKey {
6487 /// Which encoding.
6488 pub encoding: Encoding,
6489 /// Which byte order.
6490 pub endianness: Endianness,
6491 /// Data or key holder.
6492 pub purpose: Purpose,
6493 /// How variable-length mutable members are framed.
6494 pub length_codes: LengthCodePolicy,
6495 /// Which collections carry a delimiter under XCDR2.
6496 pub collection_delimiters: CollectionDelimiterPolicy,
6497 /// Which of a `map`'s two halves decides whether the map is
6498 /// delimited.
6499 pub map_delimiter: MapDelimiter,
6500 /// How a `wstring`'s length is counted.
6501 pub wstring_length: WstringLengthPolicy,
6502 /// How a scalar `wchar` is framed.
6503 pub wchar_framing: WcharFraming,
6504 /// How wide a scalar `wchar` is, which is not the width of a
6505 /// `wstring`'s code units: `rmw_cyclonedds_cpp` writes two here and
6506 /// four there.
6507 pub wchar_width: WcharWidth,
6508 /// In what byte order a `wstring`'s code units go out.
6509 pub wstring_unit_order: WstringUnitOrder,
6510 /// How a `@mutable` member is framed under XCDR1.
6511 pub pl_cdr1_headers: PlCdr1HeaderPolicy,
6512 /// What flags a PL_CDR1 parameter id carries, per position.
6513 pub pid_flags: PidFlagPolicy,
6514 /// How tolerant a PL_CDR1 reader is of another framing.
6515 pub pl_cdr1_reading: PlCdr1Reading,
6516 /// Whether a member's `must_understand` reaches the wire.
6517 pub must_understand: MustUnderstandPolicy,
6518 /// How wide an enumeration's holder is.
6519 pub enum_holder: EnumHolderWidth,
6520 /// Whether a negative case label of a narrow signed discriminator
6521 /// selects its branch.
6522 pub union_label: UnionLabelMatch,
6523 /// In what order a `map`'s entries go on the wire.
6524 pub map_entries: MapEntryOrder,
6525 /// Whether an array's element is flattened before the delimiter
6526 /// question. See [`ArrayElementFlattening`].
6527 pub array_element_flattening: ArrayElementFlattening,
6528 /// Which integer an enumerator serialises as.
6529 pub enumerator_value: EnumeratorValue,
6530 /// How a `@mutable` union numbers its branches, and whether its
6531 /// XCDR1 parameter list is terminated.
6532 pub union_framing: MutableUnionFraming,
6533 /// Which member ids fit a PL_CDR1 short parameter header.
6534 pub short_pid_range: ShortPidRange,
6535 /// Whether a `fixed<P,S>` carries its declared digit count or the
6536 /// value's.
6537 pub fixed_digits: FixedDigitCount,
6538 /// What an aggregate whose source said nothing is taken to be.
6539 pub default_extensibility: Extensibility,
6540 /// Whether a key holder keeps the type's extensibility.
6541 pub key_holder: KeyHolderExtensibility,
6542 /// How deep a key holder's member removal reaches.
6543 pub key_holder_nesting: KeyHolderNesting,
6544 /// In what order a key holder's members are serialized.
6545 pub key_holder_order: KeyHolderOrder,
6546 /// Whether a key holder's members are aligned.
6547 pub key_holder_alignment: KeyHolderAlignment,
6548 /// What becomes of a union's member when `@try_construct(use_default)`
6549 /// defaulted its discriminator.
6550 pub defaulted_discriminator_member: DefaultedDiscriminatorMember,
6551 /// Whether the elements of a collection carry alignment padding.
6552 pub element_padding: ElementPadding,
6553 /// Whether an aggregate's declared extensibility reaches the wire.
6554 pub aggregate_delimiter: AggregateDelimiter,
6555 /// Whether a `sequence` element of a non-`@final` type is preceded
6556 /// by its own octet length.
6557 pub sequence_element_delimiter: SequenceElementDelimiter,
6558 /// Which octets stand for a `wstring`'s characters.
6559 pub wstring_code_units: WstringCodeUnits,
6560}
6561
6562/// How a PL_CDR1 parameter list is framed beyond the header form the plan
6563/// carries: the fields of `zerodds_wire::PlCdr1Framing`, which this crate
6564/// does not depend on and so cannot name.
6565///
6566/// [`LayoutKey::pl_cdr1_framing`] is the one place they are computed from a
6567/// key. `zerodds-wire-codec` sets its reader and writer from it, and the
6568/// `DdsType` glue `zerodds-build` generates spells it into the reader and
6569/// writer it constructs. That glue used to construct both with their
6570/// defaults whatever dialect the plan was built in, so a codec built for
6571/// Fast DDS still refused the bare `0x3f02` sentinel Fast DDS writes.
6572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6573pub struct PlCdr1FramingValues {
6574 /// The flags on member ids, the sentinel and the extended marker.
6575 pub pid_flags: PidFlagPolicy,
6576 /// The highest member id the short header carries.
6577 pub highest_short_id: u16,
6578 /// Whether a parameter's declared length is rounded up to four.
6579 pub pad_declared_length: bool,
6580 /// The same for an `@optional` member's parameter.
6581 pub pad_declared_optional_length: bool,
6582 /// Whether a member that declares must-understand sets the flag.
6583 pub member_must_understand: bool,
6584 /// Whether every parameter uses the extended header.
6585 pub require_extended: bool,
6586 /// Whether padding follows a value inside its parameter.
6587 pub pad_after_value: bool,
6588}
6589
6590impl LayoutKey {
6591 /// The PL_CDR1 framing this key's writer and reader use.
6592 ///
6593 /// The header *form* is in the plan, because it changes a member's
6594 /// extent; these values do not, so they ride on the writer and the
6595 /// reader, and both take them from here.
6596 #[must_use]
6597 pub const fn pl_cdr1_framing(&self) -> PlCdr1FramingValues {
6598 PlCdr1FramingValues {
6599 pid_flags: self.pid_flags,
6600 highest_short_id: self.short_pid_range.highest_short_id(),
6601 pad_declared_length: self.pl_cdr1_headers.pads_declared_length(),
6602 pad_declared_optional_length: self.pl_cdr1_headers.pads_declared_optional_length(),
6603 member_must_understand: self.must_understand.writes_flag()
6604 && !matches!(self.pid_flags, PidFlagPolicy::ImplExtensionOnMembers),
6605 require_extended: self.pl_cdr1_headers.always_extended(),
6606 pad_after_value: self.pl_cdr1_headers.pads_after_value(),
6607 }
6608 }
6609
6610 /// A data plan in the given encoding and byte order, in the default
6611 /// dialect.
6612 ///
6613 /// The dialect is [`Dialect::Specification`], so every wire question
6614 /// the implementations answer differently is answered the way
6615 /// XTypes 1.3 answers it. [`LayoutKey::with_dialect`] selects
6616 /// another; each `with_*` method below overrides one setting of
6617 /// whichever dialect is in force, and overrides it whether or not a
6618 /// dialect was named.
6619 ///
6620 /// The variant is named rather than taken from [`Dialect::default`]
6621 /// because this is a `const fn` and `Default::default` is not one.
6622 /// `zerodds_ast_layout::key::the_default_dialect_is_one_fact` holds
6623 /// the three spellings — `#[default]`, [`Dialect::is_default`] and
6624 /// this call — to one value, so a change to one of them that misses
6625 /// the others fails rather than diverging quietly.
6626 #[must_use]
6627 pub const fn data(encoding: Encoding, endianness: Endianness) -> Self {
6628 Self::in_dialect(Dialect::Specification, encoding, endianness, Purpose::Data)
6629 }
6630
6631 /// Every policy of one dialect, for one encoding, byte order and
6632 /// purpose.
6633 ///
6634 /// The encoding is an argument because one setting depends on it:
6635 /// RTI counts a `wstring`'s length differently under the two
6636 /// versions.
6637 #[must_use]
6638 pub const fn in_dialect(
6639 dialect: Dialect,
6640 encoding: Encoding,
6641 endianness: Endianness,
6642 purpose: Purpose,
6643 ) -> Self {
6644 Self {
6645 encoding,
6646 endianness,
6647 purpose,
6648 length_codes: dialect.length_codes(),
6649 collection_delimiters: dialect.collection_delimiters(),
6650 wstring_length: dialect.wstring_length(encoding),
6651 wchar_framing: dialect.wchar_framing(),
6652 wchar_width: dialect.wchar_width(encoding),
6653 map_delimiter: dialect.map_delimiter(),
6654 wstring_unit_order: dialect.wstring_unit_order(),
6655 pl_cdr1_headers: dialect.pl_cdr1_headers(),
6656 pid_flags: dialect.pid_flags(),
6657 pl_cdr1_reading: PlCdr1Reading::Strict,
6658 must_understand: dialect.must_understand(),
6659 enum_holder: dialect.enum_holder(),
6660 union_label: dialect.union_label(),
6661 map_entries: dialect.map_entries(),
6662 array_element_flattening: dialect.array_element_flattening(),
6663 enumerator_value: dialect.enumerator_value(),
6664 union_framing: dialect.union_framing(),
6665 short_pid_range: dialect.short_pid_range(),
6666 fixed_digits: dialect.fixed_digits(),
6667 default_extensibility: dialect.default_extensibility(),
6668 key_holder: dialect.key_holder_extensibility(encoding),
6669 key_holder_nesting: dialect.key_holder_nesting(encoding),
6670 key_holder_order: dialect.key_holder_order(encoding),
6671 key_holder_alignment: dialect.key_holder_alignment(encoding),
6672 defaulted_discriminator_member: dialect.defaulted_discriminator_member(),
6673 element_padding: dialect.element_padding(),
6674 aggregate_delimiter: dialect.aggregate_delimiter(encoding),
6675 sequence_element_delimiter: dialect.sequence_element_delimiter(),
6676 wstring_code_units: dialect.wstring_code_units(),
6677 }
6678 }
6679
6680 /// The same plan with every setting taken from another dialect.
6681 ///
6682 /// This resets every one of them: it is a preset and not a patch,
6683 /// so applying it after a `with_*` call discards that call. The
6684 /// command-line probes apply the dialect first for that reason.
6685 #[must_use]
6686 pub const fn with_dialect(self, dialect: Dialect) -> Self {
6687 Self::in_dialect(dialect, self.encoding, self.endianness, self.purpose)
6688 }
6689
6690 /// The same plan with a different length-code policy.
6691 #[must_use]
6692 pub const fn with_length_codes(mut self, policy: LengthCodePolicy) -> Self {
6693 self.length_codes = policy;
6694 self
6695 }
6696
6697 /// The same plan with a different collection-delimiter policy.
6698 #[must_use]
6699 pub const fn with_collection_delimiters(mut self, policy: CollectionDelimiterPolicy) -> Self {
6700 self.collection_delimiters = policy;
6701 self
6702 }
6703
6704 /// The same plan with the elements of a collection packed against
6705 /// one another or aligned as §7.4.1 says.
6706 #[must_use]
6707 pub const fn with_element_padding(mut self, padding: ElementPadding) -> Self {
6708 self.element_padding = padding;
6709 self
6710 }
6711
6712 /// The same plan with an aggregate's declared extensibility reaching
6713 /// the wire or not.
6714 #[must_use]
6715 pub const fn with_aggregate_delimiter(mut self, delimiter: AggregateDelimiter) -> Self {
6716 self.aggregate_delimiter = delimiter;
6717 self
6718 }
6719
6720 /// The same plan with a per-element octet length in front of a
6721 /// `sequence` element, or without one.
6722 #[must_use]
6723 pub const fn with_sequence_element_delimiter(
6724 mut self,
6725 delimiter: SequenceElementDelimiter,
6726 ) -> Self {
6727 self.sequence_element_delimiter = delimiter;
6728 self
6729 }
6730
6731 /// The same plan with a `wstring`'s characters in UTF-16 code units
6732 /// or in UTF-8 octets.
6733 #[must_use]
6734 pub const fn with_wstring_code_units(mut self, units: WstringCodeUnits) -> Self {
6735 self.wstring_code_units = units;
6736 self
6737 }
6738
6739 /// The same plan with a different `wstring` length policy.
6740 #[must_use]
6741 pub const fn with_wstring_length(mut self, policy: WstringLengthPolicy) -> Self {
6742 self.wstring_length = policy;
6743 self
6744 }
6745
6746 /// The same plan framing a scalar `wchar` another way.
6747 #[must_use]
6748 pub const fn with_wchar_framing(mut self, framing: WcharFraming) -> Self {
6749 self.wchar_framing = framing;
6750 self
6751 }
6752
6753 /// The same plan at a different scalar `wchar` width.
6754 #[must_use]
6755 pub const fn with_wchar_width(mut self, width: WcharWidth) -> Self {
6756 self.wchar_width = width;
6757 self
6758 }
6759
6760 /// The same plan asking a different half of a `map` whether the map
6761 /// is delimited.
6762 #[must_use]
6763 pub const fn with_map_delimiter(mut self, policy: MapDelimiter) -> Self {
6764 self.map_delimiter = policy;
6765 self
6766 }
6767
6768 /// The same plan ordering a `wstring`'s code units another way.
6769 #[must_use]
6770 pub const fn with_wstring_unit_order(mut self, order: WstringUnitOrder) -> Self {
6771 self.wstring_unit_order = order;
6772 self
6773 }
6774
6775 /// Whether two settings of this plan both decide the byte order of a
6776 /// `wstring`'s code units.
6777 ///
6778 /// **Always false now, and kept as a signature rather than deleted.**
6779 /// It answered the pair `(WstringLengthPolicy::UnswappedUnits,
6780 /// non-Stream)`, which existed only because a byte order was living
6781 /// in the length enumeration under a length's name. Judgement 2 of
6782 /// `docs/wire-axes-followup.md` moved it to
6783 /// [`WstringUnitOrder::HostUnswapped`], and one axis cannot conflict
6784 /// with itself: the four orders are four points of one setting and
6785 /// any of them can be combined with any cell of C1 × C2 × C3.
6786 ///
6787 /// The method stays because a caller refusing an impossible pair is
6788 /// cheap and a caller that stops refusing a possible one is not. If
6789 /// a second setting ever claims the same byte order, this is where
6790 /// it is caught.
6791 #[must_use]
6792 pub const fn wstring_order_conflicts(self) -> bool {
6793 false
6794 }
6795
6796 /// The same plan with a different PL_CDR1 header policy.
6797 #[must_use]
6798 pub const fn with_pl_cdr1_headers(mut self, policy: PlCdr1HeaderPolicy) -> Self {
6799 self.pl_cdr1_headers = policy;
6800 self
6801 }
6802
6803 /// The plan for a sample of `root`: a PL_CDR1 header policy that is
6804 /// [decided per sample](PlCdr1HeaderPolicy::decided_per_sample)
6805 /// becomes [`PlCdr1HeaderPolicy::ExtendedForConstructed`] when the
6806 /// sample's type can exceed the short form's length and
6807 /// [`PlCdr1HeaderPolicy::ShortRoundedLength`] when it cannot. Any other
6808 /// key is returned as it is.
6809 ///
6810 /// A writer and a reader of a whole sample call this once, with the
6811 /// sample's type, and frame every type nested in it with the key it
6812 /// returns: RTI decides the form for the sample, and a nested type
6813 /// that is small on its own is framed by its sample's choice.
6814 ///
6815 /// # Errors
6816 /// [`crate::plan::LayoutError`] when the sample's type cannot be sized: an
6817 /// unresolved reference, an unevaluable bound, a nesting past the
6818 /// planner's bound.
6819 pub fn for_sample(
6820 self,
6821 ast: &zerodds_ast::Ast,
6822 root: zerodds_ast_core::Id<zerodds_ast::TypeNode>,
6823 ) -> Result<Self, crate::plan::LayoutError> {
6824 if !self.pl_cdr1_headers.decided_per_sample() || !self.encoding.is_v1() {
6825 return Ok(self);
6826 }
6827 Ok(self.with_pl_cdr1_headers(
6828 if crate::large_sample::exceeds_short_length(ast, root, self)? {
6829 PlCdr1HeaderPolicy::ExtendedForConstructed
6830 } else {
6831 PlCdr1HeaderPolicy::ShortRoundedLength
6832 },
6833 ))
6834 }
6835
6836 /// The same plan with a reader that takes another framing's PL_CDR1
6837 /// streams, or does not.
6838 #[must_use]
6839 pub const fn with_pl_cdr1_reading(mut self, reading: PlCdr1Reading) -> Self {
6840 self.pl_cdr1_reading = reading;
6841 self
6842 }
6843
6844 /// The same plan putting different flags on a parameter id.
6845 #[must_use]
6846 pub const fn with_pid_flags(mut self, policy: PidFlagPolicy) -> Self {
6847 self.pid_flags = policy;
6848 self
6849 }
6850
6851 /// The same plan writing or omitting a member's must-understand
6852 /// flag.
6853 #[must_use]
6854 pub const fn with_must_understand(mut self, policy: MustUnderstandPolicy) -> Self {
6855 self.must_understand = policy;
6856 self
6857 }
6858
6859 /// The same plan giving an enumeration a holder of another width.
6860 #[must_use]
6861 pub const fn with_enum_holder(mut self, policy: EnumHolderWidth) -> Self {
6862 self.enum_holder = policy;
6863 self
6864 }
6865
6866 /// The same plan matching a negative case label another way.
6867 #[must_use]
6868 pub const fn with_union_label(mut self, policy: UnionLabelMatch) -> Self {
6869 self.union_label = policy;
6870 self
6871 }
6872
6873 /// The same key with another array-element flattening.
6874 #[must_use]
6875 pub const fn with_array_element_flattening(mut self, policy: ArrayElementFlattening) -> Self {
6876 self.array_element_flattening = policy;
6877 self
6878 }
6879
6880 /// The same plan ordering a `map`'s entries another way.
6881 #[must_use]
6882 pub const fn with_map_entries(mut self, policy: MapEntryOrder) -> Self {
6883 self.map_entries = policy;
6884 self
6885 }
6886
6887 /// The same plan with a different enumerator-value policy.
6888 #[must_use]
6889 pub const fn with_enumerator_value(mut self, policy: EnumeratorValue) -> Self {
6890 self.enumerator_value = policy;
6891 self
6892 }
6893
6894 /// The same plan with a different `@mutable` union framing.
6895 #[must_use]
6896 pub const fn with_union_framing(mut self, policy: MutableUnionFraming) -> Self {
6897 self.union_framing = policy;
6898 self
6899 }
6900
6901 /// The same plan with a different short-PID range.
6902 #[must_use]
6903 pub const fn with_short_pid_range(mut self, policy: ShortPidRange) -> Self {
6904 self.short_pid_range = policy;
6905 self
6906 }
6907
6908 /// The same plan counting a `fixed`'s digits another way.
6909 #[must_use]
6910 pub const fn with_fixed_digits(mut self, policy: FixedDigitCount) -> Self {
6911 self.fixed_digits = policy;
6912 self
6913 }
6914
6915 /// The same plan with a different key-holder rule.
6916 #[must_use]
6917 pub const fn with_key_holder(mut self, policy: KeyHolderExtensibility) -> Self {
6918 self.key_holder = policy;
6919 self
6920 }
6921
6922 /// The same plan with a different key-holder nesting rule.
6923 #[must_use]
6924 pub const fn with_key_holder_nesting(mut self, policy: KeyHolderNesting) -> Self {
6925 self.key_holder_nesting = policy;
6926 self
6927 }
6928
6929 /// The same plan ordering a key holder's members differently.
6930 #[must_use]
6931 pub const fn with_key_holder_order(mut self, policy: KeyHolderOrder) -> Self {
6932 self.key_holder_order = policy;
6933 self
6934 }
6935
6936 /// The same plan aligning a key holder's members differently.
6937 #[must_use]
6938 pub const fn with_key_holder_alignment(mut self, policy: KeyHolderAlignment) -> Self {
6939 self.key_holder_alignment = policy;
6940 self
6941 }
6942
6943 /// The same plan giving a union's member another answer when
6944 /// `@try_construct(use_default)` defaulted its discriminator.
6945 #[must_use]
6946 pub const fn with_defaulted_discriminator_member(
6947 mut self,
6948 policy: DefaultedDiscriminatorMember,
6949 ) -> Self {
6950 self.defaulted_discriminator_member = policy;
6951 self
6952 }
6953
6954 /// The same plan reading an unannotated aggregate as something else.
6955 ///
6956 /// XTypes 1.3 §7.3.1.2.1.8 sanctions the knob outright:
6957 ///
6958 /// > In the event that the representation of a given type does not
6959 /// > indicate the type's extensibility kind, the type shall be
6960 /// > considered appendable. Implementations may provide a mechanism
6961 /// > to override this default behavior; for example, IDL compilers
6962 /// > may provide configuration options to allow users to specify
6963 /// > whether types of unspecified extensibility are to be considered
6964 /// > final, appendable, or mutable.
6965 #[must_use]
6966 pub const fn with_default_extensibility(mut self, extensibility: Extensibility) -> Self {
6967 self.default_extensibility = extensibility;
6968 self
6969 }
6970
6971 /// The key-holder plan, which is always big-endian.
6972 /// DDSI-RTPS 2.5 §9.6.3.8.
6973 ///
6974 /// In [`Dialect::Specification`], the default, for the reason
6975 /// [`Self::data`] is: the dialect reaches the holder — whether an
6976 /// unannotated keyed aggregate carries a DHEADER depends on what
6977 /// unannotated means — and [`crate::keyhash::key_holder_plan_with`]
6978 /// is the way to plan a holder under some other dialect.
6979 #[must_use]
6980 pub const fn key(encoding: Encoding) -> Self {
6981 Self::in_dialect(
6982 Dialect::Specification,
6983 encoding,
6984 Endianness::Big,
6985 Purpose::Key,
6986 )
6987 }
6988
6989 /// Whether a member's own parameter id carries its
6990 /// must-understand bit — the `M` in a short PID's 16-bit flags and
6991 /// `FLAG_2` in `PID_EXTENDED`'s eMemberHeader (§7.4.1.2.3).
6992 ///
6993 /// Two policies cross here. [`MustUnderstandPolicy::writes_flag`]
6994 /// says whether the member's declaration reaches any flag at all.
6995 /// [`PidFlagPolicy::ImplExtensionOnMembers`] — OpenDDS — sets
6996 /// `FLAG_IMPL_EXTENSION | FLAG_MUST_UNDERSTAND` on every 16-bit
6997 /// member id *as fixed framing*, and writes nothing
6998 /// declaration-dependent anywhere in a PID: measured 2026-08-24 on
6999 /// `identifier-keyword-member-ids` under XCDR1, where the `@key`
7000 /// member's eMemberHeader is `0x0FEDF2A2` — the bare hash, no
7001 /// `FLAG_2` — while the same member's XCDR2 EMHEADER carries
7002 /// `M_FLAG` (which is what put `DeclaredAndKey` in the OpenDDS
7003 /// dialect). §7.4.1.2.3 as written wants the bit in the
7004 /// eMemberHeader, so the specification dialect keeps it.
7005 #[must_use]
7006 pub const fn member_pid_carries_must_understand(self) -> bool {
7007 self.must_understand.writes_flag()
7008 && !matches!(self.pid_flags, PidFlagPolicy::ImplExtensionOnMembers)
7009 }
7010
7011 /// The part of the key that actually affects offsets.
7012 ///
7013 /// Two keys sharing this share their offset computation, which is
7014 /// what lets one cache entry serve both byte orders.
7015 ///
7016 /// [`Endianness`] is not in it: byte order changes the bytes, not
7017 /// where they sit.
7018 ///
7019 /// Everything else is. [`CollectionDelimiterPolicy`] was left out
7020 /// until this cache served an RTI-dialect plan, with no delimiter,
7021 /// to a Cyclone-dialect request that needed one: two keys that
7022 /// differ in a four-byte header do not share an offset computation.
7023 /// [`WstringLengthPolicy`] was left out for the reason that a
7024 /// `wstring` plans as `Extent::Unbounded` under every variant, which
7025 /// is true and was not the whole question: the policy also fixes the
7026 /// width of a scalar `wchar`, and a four-byte one aligns to four and
7027 /// moves every member behind it. [`WcharFraming`] is in for the
7028 /// second half of the same reason — GIOP 1.2's `wchar` is one octet
7029 /// longer than its code unit *and* aligns to one, so a plan built
7030 /// for it puts every following member somewhere a fixed-width plan
7031 /// does not.
7032 ///
7033 /// [`WstringUnitOrder`] is **not** in it, and that is a claim rather
7034 /// than an omission: a `wstring` plans as `Extent::Unbounded` under
7035 /// all three orders, and the byte-order mark makes it two octets
7036 /// longer without making it any less unbounded. Nothing an order
7037 /// changes reaches an offset.
7038 ///
7039 /// [`EnumHolderWidth`] **is** in it, and it is the plainest case of
7040 /// all: an `@bit_bound(8)` enumeration is one octet under one value
7041 /// and four under the other, so it changes the type's own extent, its
7042 /// alignment and the offset of every member behind it. A cache that
7043 /// left it out would hand a one-octet plan to a caller that asked for
7044 /// the four-octet holder.
7045 ///
7046 /// [`UnionLabelMatch`] is **not** in it, and that is a claim rather
7047 /// than an omission. A union's extent is the discriminator plus the
7048 /// widest branch under both values: with no `default:` the
7049 /// suppressed match writes less than the extent, which the extent
7050 /// already bounds, and with one it writes the *default* branch,
7051 /// which the extent already covers. Nothing it changes reaches an
7052 /// offset —
7053 /// `crates/wire-probe/tests/union_label_match.rs::the_extent_is_the_same_under_both_values`
7054 /// is that claim as a test rather than as this sentence.
7055 ///
7056 /// [`MapEntryOrder`] is **not** in it, and that is a claim rather
7057 /// than an omission for the plainest reason of the three: a
7058 /// permutation of a map's entries writes the same entries, so the
7059 /// count, the byte extent and every offset behind it are the same
7060 /// under both values.
7061 /// `crates/wire-probe/tests/map_entry_order.rs::the_extent_is_the_same_under_both_values`
7062 /// is that claim as a test.
7063 ///
7064 /// [`FixedDigitCount`] **is** in it, and it is the second plainest
7065 /// case after [`EnumHolderWidth`]: a `fixed<9,3>` is five octets
7066 /// under one value and as many as the value needs under the other,
7067 /// so the type stops being fixed-size and every member behind it
7068 /// moves. A cache that left it out would hand a five-octet plan to a
7069 /// caller that asked for the value's digits.
7070 ///
7071 /// [`EnumeratorValue`] is **not** in it, and it is the sharpest
7072 /// contrast with [`EnumHolderWidth`] beside it: the two axes reach
7073 /// the same construct and only one of them reaches an offset. The
7074 /// holder's *width* is a length; *which integer* goes into it is a
7075 /// value, and `@value(200)` and the ordinal `2` occupy the same
7076 /// holder.
7077 /// `crates/wire-probe/tests/enumerator_value.rs::the_extent_is_the_same_under_both_values`
7078 /// is that claim as a test.
7079 #[must_use]
7080 pub const fn offset_key(self) -> OffsetKey {
7081 OffsetKey {
7082 encoding: self.encoding,
7083 purpose: self.purpose,
7084 length_codes: self.length_codes,
7085 collection_delimiters: self.collection_delimiters,
7086 map_delimiter: self.map_delimiter,
7087 wstring_length: self.wstring_length,
7088 wchar_framing: self.wchar_framing,
7089 wchar_width: self.wchar_width,
7090 pl_cdr1_headers: self.pl_cdr1_headers,
7091 default_extensibility: self.default_extensibility,
7092 key_holder: self.key_holder,
7093 key_holder_nesting: self.key_holder_nesting,
7094 key_holder_alignment: self.key_holder_alignment,
7095 enum_holder: self.enum_holder,
7096 fixed_digits: self.fixed_digits,
7097 must_understand: self.must_understand,
7098 union_framing: self.union_framing,
7099 short_pid_range: self.short_pid_range,
7100 element_padding: self.element_padding,
7101 aggregate_delimiter: self.aggregate_delimiter,
7102 }
7103 }
7104}
7105
7106/// The part of a [`LayoutKey`] the plan cache keys on.
7107///
7108/// A struct and not a tuple, and that is a correction rather than a
7109/// style: the tuple stood at twelve, which is where Rust's derived
7110/// `Ord`, `Debug` and `Clone` impls stop, so the next field that had to
7111/// go in would not have compiled. A cache key that cannot grow is a
7112/// cache key that gets things left out of it, which is the defect
7113/// [`Self::must_understand`] below records.
7114///
7115/// **A field that reaches `TypeLayout` and is not here is a silent
7116/// wrong plan**: the cache would serve one caller's entry to another
7117/// whose answer differs. That is the whole of the rule, and every field
7118/// is here because it was checked against it rather than because it
7119/// looked relevant.
7120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
7121pub struct OffsetKey {
7122 /// XCDR1 or XCDR2, and which framing the extensibility picks.
7123 pub encoding: Encoding,
7124 /// Data or key holder.
7125 pub purpose: Purpose,
7126 /// Which length code a `@mutable` member's EMHEADER gets, which is
7127 /// four octets of header either way it goes.
7128 pub length_codes: LengthCodePolicy,
7129 /// Which collections carry a delimiter, which is four octets in
7130 /// front of the elements.
7131 pub collection_delimiters: CollectionDelimiterPolicy,
7132 /// Which of a `map`'s halves decides whether the map is delimited,
7133 /// which is the same four octets in front of the entries.
7134 pub map_delimiter: MapDelimiter,
7135 /// How a `wstring`'s length is counted, which decides the extent of
7136 /// every `wstring` and so the offset of every member behind one.
7137 pub wstring_length: WstringLengthPolicy,
7138 /// How a scalar `wchar` is framed: GIOP 1.2's count octet is one
7139 /// octet longer than the unit *and* aligns to one.
7140 pub wchar_framing: WcharFraming,
7141 /// How wide a scalar `wchar` is, which is its alignment as well as
7142 /// its extent.
7143 pub wchar_width: WcharWidth,
7144 /// Which header form a `@mutable` member takes under XCDR1: four
7145 /// octets against twelve.
7146 pub pl_cdr1_headers: PlCdr1HeaderPolicy,
7147 /// What an aggregate whose source said nothing is taken to be,
7148 /// which decides whether it carries a delimiter at all.
7149 pub default_extensibility: Extensibility,
7150 /// Whether a key holder keeps the type's extensibility.
7151 pub key_holder: KeyHolderExtensibility,
7152 /// How deep a key holder's member removal reaches.
7153 pub key_holder_nesting: KeyHolderNesting,
7154 /// Whether a key holder's members are aligned, which moves every
7155 /// offset after the first narrower key.
7156 pub key_holder_alignment: KeyHolderAlignment,
7157 /// How wide an enumeration's holder is: one octet against four.
7158 pub enum_holder: EnumHolderWidth,
7159 /// Whether a `fixed<P,S>` is `P` digits wide or as wide as the
7160 /// value, which is the difference between a sized type and an
7161 /// unbounded one.
7162 pub fixed_digits: FixedDigitCount,
7163 /// Whether a member's `must_understand` attribute is set, which the
7164 /// plan carries in every `MemberHeader` it builds.
7165 ///
7166 /// **This was missing, and it was a silent wrong plan.** The
7167 /// attribute is not a length — no offset moves with it — but the
7168 /// plan is what carries it to all three columns, so a cache that
7169 /// left it out served a plan built under `Declared` to a caller that
7170 /// asked for `Omitted` and the flag went out anyway. Nothing at run
7171 /// time would have said so: the bytes would simply have been the
7172 /// other setting's. The field was added when
7173 /// [`MustUnderstandPolicy::DeclaredAndKey`] made the gap a third
7174 /// value wide instead of a second.
7175 pub must_understand: MustUnderstandPolicy,
7176 /// How a `@mutable` union numbers its branches and whether its XCDR1
7177 /// parameter list is terminated — both of which the plan carries,
7178 /// the first in every `MemberPlan::member_id` and the second in
7179 /// `TypeLayout::needs_sentinel`.
7180 pub union_framing: MutableUnionFraming,
7181 /// Which member ids fit a short parameter header, which is four
7182 /// octets of header against twelve.
7183 pub short_pid_range: ShortPidRange,
7184 /// Whether a collection's elements carry padding between them,
7185 /// which is the array's stride and so every element's offset.
7186 pub element_padding: ElementPadding,
7187 /// Whether an aggregate writes a `DHEADER` and a `@mutable` type's
7188 /// members carry their headers — both of which the plan carries, the
7189 /// first in `TypeLayout::needs_dheader` and `Extent::Sized`, the
7190 /// second in every `MemberHeader` and in `TypeLayout::needs_sentinel`.
7191 pub aggregate_delimiter: AggregateDelimiter,
7192}
7193
7194#[cfg(test)]
7195mod tests {
7196 use super::*;
7197
7198 /// The default dialect is one fact spelled in three places, and
7199 /// this holds the three to one value.
7200 ///
7201 /// `#[default]` on the variant, [`Dialect::is_default`] as a
7202 /// predicate, and [`LayoutKey::data`]'s `const` call which cannot go
7203 /// through `Default::default`. Each of the three is read by
7204 /// different code — the CLI parsers take the first, the result
7205 /// tables and `wire-probe`'s argument test take the second, the
7206 /// planner takes the third — so a change that moves one and misses
7207 /// another would leave two defaults in one tree, and neither would
7208 /// look wrong on its own.
7209 ///
7210 /// The value is named here as well as compared, so that moving the
7211 /// default is a change to this line and not something a reader has
7212 /// to derive.
7213 #[test]
7214 fn the_default_dialect_is_one_fact() {
7215 assert_eq!(Dialect::default(), Dialect::Specification);
7216 assert!(Dialect::default().is_default());
7217 for dialect in Dialect::ALL {
7218 assert_eq!(
7219 dialect.is_default(),
7220 dialect == Dialect::default(),
7221 "`{}` disagrees with `#[default]` about being the default",
7222 dialect.as_str()
7223 );
7224 }
7225 let named = LayoutKey::in_dialect(
7226 Dialect::default(),
7227 Encoding::PlainCdr2,
7228 Endianness::Little,
7229 Purpose::Data,
7230 );
7231 assert_eq!(
7232 LayoutKey::data(Encoding::PlainCdr2, Endianness::Little),
7233 named,
7234 "`LayoutKey::data` names a dialect that is not `Dialect::default`"
7235 );
7236 assert_eq!(
7237 LayoutKey::data(Encoding::PlainCdr1, Endianness::Big),
7238 LayoutKey::in_dialect(
7239 Dialect::default(),
7240 Encoding::PlainCdr1,
7241 Endianness::Big,
7242 Purpose::Data
7243 ),
7244 "the two spellings part under encoding version 1"
7245 );
7246 for encoding in Encoding::ALL {
7247 assert_eq!(
7248 LayoutKey::key(encoding),
7249 LayoutKey::in_dialect(Dialect::default(), encoding, Endianness::Big, Purpose::Key),
7250 "the key holder's plan names a dialect that is not `Dialect::default`"
7251 );
7252 }
7253 }
7254
7255 /// The two settings that made the default move are the clause's
7256 /// under the default, and the product's under the product's name.
7257 ///
7258 /// Named rather than implied: these are the two axes on which
7259 /// `Dialect::Cyclone` departs from XTypes 1.3, and a caller who
7260 /// names no dialect used to get both. A regression that put the
7261 /// default back on a product would leave this red with the two axes
7262 /// in the message rather than leaving a table to be re-read.
7263 #[test]
7264 fn the_default_takes_no_product_departure_on_the_two_axes() {
7265 assert_eq!(
7266 Dialect::default().union_label(),
7267 UnionLabelMatch::SignExtended,
7268 "the default drops a branch a conforming reader expects"
7269 );
7270 assert_eq!(
7271 Dialect::default().wstring_unit_order(),
7272 WstringUnitOrder::Stream,
7273 "the default writes a `wstring`'s code units in the host's order"
7274 );
7275 assert_eq!(
7276 Dialect::Cyclone.union_label(),
7277 UnionLabelMatch::NegativeLabelUnmatchedBelow32,
7278 "`--dialect cyclone` stopped reproducing Cyclone"
7279 );
7280 assert_eq!(
7281 Dialect::Cyclone.wstring_unit_order(),
7282 WstringUnitOrder::HostUnswapped,
7283 "`--dialect cyclone` stopped reproducing Cyclone"
7284 );
7285 }
7286
7287 /// Every cell of C1 × C2 × C3 is constructible, nameable and
7288 /// parseable — and there are eight of them.
7289 ///
7290 /// This is the test the rebuild exists for. The axis was an
7291 /// enumeration of five named forms, which meant the reachable
7292 /// values were the ones somebody had already written down; two
7293 /// implementations were writing a sixth and the project had no way
7294 /// to say it. A product cannot have that failure, and this fails if
7295 /// anything puts the names back in charge of the values.
7296 #[test]
7297 fn every_cell_of_the_product_is_reachable() {
7298 assert_eq!(WstringLengthPolicy::ALL_CELLS.len(), 8);
7299 let mut seen = alloc::collections::BTreeSet::new();
7300 for cell in WstringLengthPolicy::ALL_CELLS {
7301 assert!(seen.insert(cell), "{} appears twice", cell.as_str());
7302 assert_eq!(
7303 WstringLengthPolicy::parse(cell.as_str()),
7304 Some(cell),
7305 "{} does not parse back to itself",
7306 cell.as_str()
7307 );
7308 assert_eq!(
7309 WstringLengthPolicy::parse(&coordinates(cell)),
7310 Some(cell),
7311 "{} is not reachable by coordinate",
7312 cell.as_str()
7313 );
7314 }
7315 assert_eq!(seen.len(), 8);
7316 }
7317
7318 /// The cell both ROS type supports write is one of the eight, and
7319 /// it is not any of the four the enumeration had.
7320 ///
7321 /// Characters, no terminator, four-octet units. Measured 2026-08-12
7322 /// from the recorded corpus: `wstring-unbounded-utf8` little endian
7323 /// is `07000000 e4000000 f6000000 fc000000 20000000 34120000
7324 /// 20000000 5a000000` from `ros-fastrtps` and from `ros-cyclone`
7325 /// alike. `CHARACTER_COUNT` is that length over two-octet units and
7326 /// `SPECIFICATION_WIDE_UNITS` is four-octet units under the other
7327 /// length, so neither reaches it and the cell between them had no
7328 /// name.
7329 #[test]
7330 fn the_ros_cell_is_reachable_and_is_none_of_the_four() {
7331 let ros = WstringLengthPolicy::ROS_TYPE_SUPPORT;
7332 assert_eq!(ros.counts, WstringLengthCounts::Characters);
7333 assert_eq!(ros.terminator, WstringTerminator::Absent);
7334 assert_eq!(ros.unit, WstringUnitWidth::Four);
7335 for other in [
7336 WstringLengthPolicy::SPECIFICATION,
7337 WstringLengthPolicy::CHARACTER_COUNT,
7338 WstringLengthPolicy::CHARACTER_COUNT_TERMINATED,
7339 WstringLengthPolicy::SPECIFICATION_WIDE_UNITS,
7340 ] {
7341 assert_ne!(
7342 ros,
7343 other,
7344 "the ROS cell collided with {}, so the split did not separate the questions",
7345 other.as_str()
7346 );
7347 }
7348 assert!(WstringLengthPolicy::ALL_CELLS.contains(&ros));
7349 assert_eq!(
7350 WstringLengthPolicy::parse("characters/unterminated/4"),
7351 Some(ros)
7352 );
7353 }
7354
7355 /// **Counter-check.** RTI under XCDR1 is CORBA §9.3.1.6's GIOP 1.1
7356 /// `wstring` and needs no RTI-specific line.
7357 ///
7358 /// Judgement 2 of `docs/wire-axes-followup.md` required this to be
7359 /// checked explicitly after the split: if RTI's XCDR1 form were not
7360 /// exactly a cell of the product, an axis would still be missing.
7361 /// It is the cell — characters, terminated, four-octet units — and
7362 /// `Dialect::Rti` reaches it by naming the cell rather than by
7363 /// naming itself.
7364 #[test]
7365 fn rti_under_xcdr1_is_giop_1_1_with_no_rti_specific_line() {
7366 let giop_1_1 = WstringLengthPolicy::new(
7367 WstringLengthCounts::Characters,
7368 WstringTerminator::Present,
7369 WstringUnitWidth::Four,
7370 );
7371 assert_eq!(
7372 Dialect::Rti.wstring_length(Encoding::PlainCdr1),
7373 giop_1_1,
7374 "RTI's XCDR1 wstring is no longer GIOP 1.1's, so an axis is missing"
7375 );
7376 assert_eq!(Dialect::Rti.wstring_length(Encoding::PlCdr1), giop_1_1);
7377 // And under version 2 it is the specification's, which is what
7378 // makes the XCDR1 answer a version question rather than a
7379 // vendor one.
7380 assert_eq!(
7381 Dialect::Rti.wstring_length(Encoding::PlainCdr2),
7382 WstringLengthPolicy::SPECIFICATION
7383 );
7384 }
7385
7386 /// **Counter-check.** Fast DDS's answer is a cell of the product,
7387 /// not a special rule.
7388 ///
7389 /// Characters, no terminator, two-octet units — the crossing of
7390 /// three coordinates each of which some other implementation also
7391 /// takes. Nothing about it is Fast DDS's alone, which is the test:
7392 /// a form that could only be described by naming its vendor would
7393 /// mean the coordinates were not the right ones.
7394 #[test]
7395 fn fast_dds_is_a_cell_and_not_a_rule() {
7396 let fastdds = Dialect::FastDds.wstring_length(Encoding::PlainCdr1);
7397 assert_eq!(fastdds, WstringLengthPolicy::CHARACTER_COUNT);
7398 assert!(WstringLengthPolicy::ALL_CELLS.contains(&fastdds));
7399 // Each coordinate is shared with an implementation that is not
7400 // Fast DDS: C1 with RTI and both ROS type supports, C2 with
7401 // seven of the nine, C3 with Cyclone, OpenDDS, omniORB, JacORB.
7402 assert_eq!(
7403 fastdds.counts,
7404 Dialect::Rti.wstring_length(Encoding::PlainCdr1).counts
7405 );
7406 assert_eq!(
7407 fastdds.terminator,
7408 Dialect::Cyclone
7409 .wstring_length(Encoding::PlainCdr1)
7410 .terminator
7411 );
7412 assert_eq!(
7413 fastdds.unit,
7414 Dialect::OmniOrb.wstring_length(Encoding::PlainCdr1).unit
7415 );
7416 }
7417
7418 /// Every position renders and every position says whether it is
7419 /// measured.
7420 ///
7421 /// Measured and prospective must not look alike: a reader who
7422 /// cannot tell them apart reads the next measurement as a
7423 /// confirmation of something that was never checked.
7424 #[test]
7425 fn the_measured_positions_are_told_apart_from_the_prospective_ones() {
7426 for position in WirePosition::ALL {
7427 assert!(!position.as_str().is_empty());
7428 }
7429 assert_eq!(WirePosition::MEASURED.len(), 4);
7430 for position in WirePosition::MEASURED {
7431 assert!(position.is_measured(), "{}", position.as_str());
7432 }
7433 for position in WirePosition::ALL {
7434 assert_eq!(
7435 position.is_measured(),
7436 WirePosition::MEASURED.contains(&position),
7437 "{} disagrees with the measured list",
7438 position.as_str()
7439 );
7440 }
7441 // The two that are carried and not measured, named so that a
7442 // future measurement moves a name rather than adds one.
7443 assert!(!WirePosition::Discriminator.is_measured());
7444 assert!(!WirePosition::KeyHolderMember.is_measured());
7445 }
7446
7447 /// The coordinate spelling of a cell, for the round-trip above.
7448 fn coordinates(cell: WstringLengthPolicy) -> alloc::string::String {
7449 let counts = match cell.counts {
7450 WstringLengthCounts::Octets => "octets",
7451 WstringLengthCounts::Characters => "characters",
7452 };
7453 let terminator = match cell.terminator {
7454 WstringTerminator::Absent => "unterminated",
7455 WstringTerminator::Present => "terminated",
7456 };
7457 alloc::format!("{counts}/{terminator}/{}", cell.unit.octets())
7458 }
7459}