Skip to main content

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}