diff --git a/dist/datamodel/parser/DictionaryXmlFast.d.ts b/dist/datamodel/parser/DictionaryXmlFast.d.ts
new file mode 100644
index 00000000..73f9274d
--- /dev/null
+++ b/dist/datamodel/parser/DictionaryXmlFast.d.ts
@@ -0,0 +1,17 @@
+/**
+ * Read a dictionary XML part, or decline.
+ *
+ * `null` means "I did not read this" — never "this is broken". The caller must fall back to the
+ * general engine, which owns every judgement about damaged input.
+ *
+ * HOW THE STATE IS HELD, and why it is seven arrays instead of a stack of objects.
+ *
+ * The obvious shape is a `Frame` object per open element, pushed and popped. It works and it
+ * was the first version, and it cost one object plus one attribute array for every element in
+ * the file — 13.8 M allocations on the corpus's largest dictionary, all of them dying
+ * immediately. Dictionaries nest shallowly (single digits), so the arrays below are indexed by
+ * DEPTH and reused: a `
` at depth 3 writes over the state of the previous `
` at depth 3.
+ * The element's own object (`kids[d]`) is of course fresh each time, because it is the output.
+ */
+export declare function readDictionaryXmlFast(input: string): Record | null;
+//# sourceMappingURL=DictionaryXmlFast.d.ts.map
\ No newline at end of file
diff --git a/dist/datamodel/parser/DictionaryXmlFast.d.ts.map b/dist/datamodel/parser/DictionaryXmlFast.d.ts.map
new file mode 100644
index 00000000..bda96aa1
--- /dev/null
+++ b/dist/datamodel/parser/DictionaryXmlFast.d.ts.map
@@ -0,0 +1 @@
+{"version":3,"file":"DictionaryXmlFast.d.ts","sourceRoot":"","sources":["../../../src/datamodel/parser/DictionaryXmlFast.ts"],"names":[],"mappings":"AA0bA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAgNnF"}
\ No newline at end of file
diff --git a/dist/datamodel/parser/DictionaryXmlFast.js b/dist/datamodel/parser/DictionaryXmlFast.js
new file mode 100644
index 00000000..c6aeeb4d
--- /dev/null
+++ b/dist/datamodel/parser/DictionaryXmlFast.js
@@ -0,0 +1,682 @@
+// src/datamodel/parser/DictionaryXmlFast.ts
+// Copyright 2026 The MathWorks, Inc.
+//
+// A reader for ONE XML vocabulary: the `data/chunk0.xml` part of a binary `.sldd`.
+//
+// WHY THIS EXISTS
+//
+// A zipped dictionary is small on disk and enormous once inflated — the corpus's
+// `sldd-zip-entries` is 2.75 MB of zip and 74.7 MB of XML — and reading that XML was
+// 94% of the whole ingest:
+//
+// unzip (native zlib under the Inflate seam) ~140 ms
+// utf8 decode 74.7 MB -> JS string 26 ms
+// readDictionaryXml (fast-xml-parser) 3358 ms <- 94%
+// everything else, data model included ~80 ms
+//
+// 22 MB/s, where a bare `charCodeAt` pass over the same string is 149 ms (478 MB/s). The cost
+// was the general-purpose engine, not the XML: it must cope with namespaces, DOCTYPE, CDATA,
+// stop nodes, path matching and entity-expansion limits, and it pays for all of that on every
+// one of the 6.9 M elements a dictionary holds.
+//
+// This file pays for none of it, because a dictionary's XML is a closed vocabulary. Over all
+// 32 zipped dictionaries available (469.4 MB of XML, `.scratch/probe-schema.mjs`):
+//
+// elements
6,022,235 624,550
', {});
+ });
+
+ it('declines a second root element', () => {
+ expectDeclined('', { P: ['', ''] });
+ });
+
+ it('declines non-whitespace text outside the root', () => {
+ expectDeclined('x', { DataSource: '' });
+ });
+
+ it('declines input with no markup — the shape a truncated write takes', () => {
+ // The engine's empty object here is load-bearing: every caller reports it as a loss.
+ // This reader must not invent that answer, because `null` from it means "not read".
+ expectDeclined('hello', {});
+ expectDeclined('', {});
+ });
+ });
+
+ describe('attribute syntax it will not guess at', () => {
+ it('declines an attribute with no value', () => {
+ // fast-xml-parser's own regex tolerates it and drops it. Imitating that would mean
+ // owning a judgement about damaged input, which is the thing this reader defers.
+ expectDeclined('', { P: [''] });
+ });
+
+ it('declines an unquoted value', () => {
+ expectDeclined('
', { P: [''] });
+ });
+
+ it('declines a name split by whitespace only JAVASCRIPT counts as whitespace', () => {
+ // The one way a stricter reader can be WRONG rather than narrower. `isSpace` is XML's four
+ // characters, which is correct; the engine's regexes split on `\s`, which also includes
+ // `\f`, `\v`, NBSP and the Unicode spaces. So the engine sees TWO attributes where this
+ // scanner sees one name with a form feed inside it — and when the halves repeat a name,
+ // the engine folds the duplicate and keeps the last, so even the key COUNT differs.
+ expectDeclined('
', { P: [{ '@_Value': 'b' }] });
+ expectDeclined('', { P: [{ '@_Value': 'b' }] });
+ expectDeclined('', { P: [{ '@_Value': '1' }] });
+ expectDeclined('', { P: [{ '@_Value': '1' }] });
+ expectDeclined('', { P: [{ '@_Value': '1' }] });
+ expectDeclined('', { P: [{ '@_Value': '1' }] });
+ // The same hole on the declaration's attributes, which is where it was actually found: the
+ // element path happened to decline already, because the tag name then holds the character.
+ expectDeclined('', {
+ '?xml': { '@_version': '1.0' },
+ DataSource: '',
+ });
+ });
+
+ it('still reads an attribute name that is merely UNKNOWN', () => {
+ // The control for the test above, and the one that matters for real files: the decline is
+ // keyed on the alien whitespace, not on the name being absent from `ATTR_KEYS`. MATLAB
+ // writes `Format="decimal"` for a char value XML cannot hold plainly, and that attribute
+ // appears in none of the 32 corpus dictionaries — it must still be copied faithfully.
+ expectRead('
9 10 13 7 1
', {
+ P: [
+ {
+ '#text': '9 10 13 7 1',
+ '@_Name': 'Ctrl',
+ '@_Class': 'char',
+ '@_Dimension': '1*5',
+ '@_Format': 'decimal',
+ },
+ ],
+ });
+ });
+
+ it('declines a `>` inside an attribute value', () => {
+ // A known, accepted limitation rather than a bug: finding the tag's end needs the
+ // attributes parsed and parsing them needs the tag's end. No dictionary attribute
+ // (`Class`, `Name`, `Dimension`, `Source`, ...) has ever held one, and the cost of
+ // being wrong is a fallback.
+ expectDeclined('', { P: [{ '@_Name': 'a>b' }] });
+ });
+ });
+
+ describe('the engine’s nesting limit, past which it throws rather than answers', () => {
+ // `maxNestedTags` defaults to 100 and `XmlReader` does not override it. A reader that keeps
+ // going past the point where the engine raises is the mirror image of a caller reading `null`
+ // as "empty document": a loud failure becomes quiet data. Dictionaries nest in single digits,
+ // so nothing here is about real files — it is about the contract holding at the edge.
+ const deep = (d: number, inner = 'x'): string =>
+ '
'.repeat(d) + inner + '
'.repeat(d);
+
+ it('reads the deepest document the engine reads', () => {
+ // 101 opens, not 100: the engine checks its stack BEFORE pushing, so the limit admits one
+ // more level than the number suggests. Compared against the live engine rather than a
+ // literal, which at this depth would be 101 lines of nesting and would pin the shape
+ // instead of the boundary — the shape is pinned by every other case in this file.
+ for (const d of [99, 100, 101]) {
+ const fast = readDictionaryXmlFast(deep(d));
+ expect(fast, `depth ${d} should still be read`).not.toBeNull();
+ expect(fast, `depth ${d}`).toEqual(readDictionaryXmlGeneric(deep(d)));
+ }
+ });
+
+ it('declines one level past it, where the engine throws', () => {
+ for (const d of [102, 103, 150]) {
+ expect(readDictionaryXmlFast(deep(d)), `depth ${d}`).toBeNull();
+ expect(() => readDictionaryXmlGeneric(deep(d))).toThrow(/Maximum nested tags/);
+ expect(() => readDictionaryXml(deep(d))).toThrow(/Maximum nested tags/);
+ }
+ });
+
+ it('does not count a self-closing tag, because the engine pushes no frame for one', () => {
+ // The off-by-one that a `depth > limit` check placed one line earlier would introduce: at
+ // the limit exactly, `` is still fine and `` is not. Declining here would cost
+ // nothing in practice and would still be a divergence from "identical or null" in the only
+ // direction that is allowed — but it would mean the boundary was copied, not understood.
+ const edge = '
'.repeat(101) + '
'.repeat(5) + '
'.repeat(101);
+ const fast = readDictionaryXmlFast(edge);
+ expect(fast).not.toBeNull();
+ expect(fast).toEqual(readDictionaryXmlGeneric(edge));
+ });
+ });
+
+ it('declines an unterminated tag, where the engine THROWS', () => {
+ // The one decline whose fallback does not return a value. Pinned because the seam's
+ // behaviour here is "throws", and a caller that treated the fast reader's `null` as
+ // "empty document" would turn a thrown error into silent data loss.
+ expect(readDictionaryXmlFast('
readDictionaryXmlGeneric('
readDictionaryXml('
);
+ const kb = Object.keys(b as Record);
+ if (ka.length !== kb.length) return `${path}: keys [${ka}] vs [${kb}]`;
+ for (let i = 0; i < ka.length; i++) {
+ // Positional, not by lookup: this is where a reader that emitted the right keys in the
+ // wrong sequence gets caught.
+ if (ka[i] !== kb[i]) return `${path}: key ${i} is '${ka[i]}' vs '${kb[i]}'`;
+ }
+ for (const k of ka) {
+ const d = diff(
+ (a as Record)[k],
+ (b as Record)[k],
+ `${path}.${k}`,
+ );
+ if (d !== null) return d;
+ }
+ return null;
+}
+
+/**
+ * The property, for one document. Returns whether the reader took it, so a caller can tell a
+ * generator that produces nothing but declines from one that is actually exercising the reader.
+ */
+function assertEquivalent(xml: string, label: string): boolean {
+ const fast = readDictionaryXmlFast(xml);
+ if (fast === null) return false;
+ let generic: unknown;
+ try {
+ generic = readDictionaryXmlGeneric(xml);
+ } catch (e) {
+ // Accepted where the engine throws: the seam would stop throwing, which is a behaviour
+ // change and not a speedup.
+ expect.fail(
+ `${label}: the fast reader ACCEPTED a document the engine throws on ` +
+ `(${(e as Error).message})\n xml: ${JSON.stringify(xml)}`,
+ );
+ }
+ const d = diff(fast, generic);
+ if (d !== null) {
+ expect.fail(`${label}: diverged at ${d}\n xml: ${JSON.stringify(xml)}`);
+ }
+ return true;
+}
+
+// ---------------------------------------------------------------------------------------
+// Part 1 — every binary `.sldd` fixture in the repo
+// ---------------------------------------------------------------------------------------
+
+const TEST_DIR = fileURLToPath(new URL('.', import.meta.url));
+
+/** Every `.sldd` under `test/`, recursively. */
+function findSldd(dir: string, out: string[] = []): string[] {
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) findSldd(full, out);
+ else if (entry.endsWith('.sldd')) out.push(full);
+ }
+ return out;
+}
+
+/** The XML part of a compressed-binary dictionary, or `null` if the file is the JSON format. */
+function chunkXml(path: string): string | null {
+ const bytes = readFileSync(path);
+ // A text `.sldd` is JSON and holds no zip; the local-file-header magic is the cheap test.
+ if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4b) return null;
+ const part = unzipSync(new Uint8Array(bytes))['data/chunk0.xml'];
+ return part === undefined ? null : new TextDecoder().decode(part);
+}
+
+describe('every binary dictionary fixture reads identically through both readers', () => {
+ const binary = findSldd(TEST_DIR)
+ .map((p) => [p, chunkXml(p)] as const)
+ .filter((e): e is readonly [string, string] => e[1] !== null);
+
+ // A guard on the guard: if the fixtures move or the zip detection breaks, this suite would
+ // pass by testing nothing. The number only has to be a floor.
+ it('finds the fixtures at all', () => {
+ expect(binary.length).toBeGreaterThanOrEqual(15);
+ });
+
+ for (const [path, xml] of binary) {
+ const name = path.slice(TEST_DIR.length);
+ it(`${name} (${(xml.length / 1024).toFixed(0)} KB of XML)`, () => {
+ // Every fixture in the repo is a document MATLAB wrote, so the reader is expected to
+ // take all of them — a decline here is not a correctness failure but it is a silent
+ // loss of the whole point, so it fails.
+ expect(assertEquivalent(xml, name), `${name} was DECLINED by the fast reader`).toBe(true);
+ });
+ }
+});
+
+// ---------------------------------------------------------------------------------------
+// Part 2 — generated documents
+// ---------------------------------------------------------------------------------------
+
+/** mulberry32: four lines, deterministic, and nobody has to install anything. */
+function rng(seed: number): () => number {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+const TAGS = ['P', 'Element', 'Object', 'Field', 'DataSource'];
+
+/** The real vocabulary, plus names outside it so the bail path is generated too. */
+const ATTR_NAMES = [
+ 'Class',
+ 'Name',
+ 'Dimension',
+ 'Source',
+ 'PropertyType',
+ 'EnumerationName',
+ 'EnumerationType',
+ 'IsEnum',
+ 'IsComplex',
+ 'Encoding',
+ 'EncodedLength',
+ 'FormatVersion',
+ 'MinRelease',
+ 'Arch',
+ 'Unlisted',
+ 'x:ns',
+];
+
+/**
+ * Text chunks chosen so each decides something: the coercion spellings, the padding rule, the
+ * entity table and its edges, the layout-whitespace hook, and the values that only LOOK
+ * coercible.
+ */
+const TEXTS = [
+ '',
+ ' ',
+ '\n ',
+ // Whitespace `trim()` strips but the reader's four-character ASCII pre-scan does not — the
+ // only route into `processText`'s own drop branch. Mutation testing found that branch
+ // unreachable without these.
+ '\f',
+ '\v',
+ ' ',
+ ' ',
+ '7',
+ '007',
+ '-0',
+ '+0',
+ '0.0',
+ '1.0',
+ '0x1F',
+ '1e3',
+ '.5',
+ '9007199254740993',
+ '12345678901234567890123',
+ 'true',
+ 'false',
+ 'True',
+ 'e3',
+ 'abc',
+ 'a b',
+ ' 7 ',
+ ' true ',
+ 'a>b',
+ '&',
+ '<x>',
+ '"'',
+ '&',
+ // Digit-leading AND entity-bearing: the only shape that reaches the coercion branch with
+ // something to decode, so the only shape that can tell `toNumber(decoded)` from
+ // `toNumber(raw)`. Mutation testing found that pair indistinguishable without these.
+ '7&8',
+ '1<2',
+ '-1>0',
+ '.5&',
+ // Numeric character references, which the engine leaves standing — so the reader reads them as
+ // text and these are not bails. The prohibited codepoints next to them ARE bails, and both
+ // kinds belong here: the generator is the only check that the two are told apart in
+ // combinations nobody wrote down, including a value carrying one of each.
+ 'A',
+ '
',
+ 'a
b',
+ '70',
+ 'A',
+ '',
+ 'ZZ;',
+ '',
+ ' ',
+ ' ',
+ '',
+ '',
+ '', // deleted by the engine, so a bail
+ '',
+ '', // capital X, the spelling mutation testing caught no test reaching
+ '',
+ ' a',
+ ' ',
+ 'a & b',
+ // Carriage returns, which XML normalizes (`\r\n` and a lone `\r` both become `\n`) and a reader
+ // slicing the raw string does not. These are not bails — they must come back normalized — and
+ // they belong in a generator because the interesting part is the COMBINATION: a CR beside a
+ // reference, a CR that turns a coercible value into a padded one, a CR in layout position.
+ '\r',
+ '\r\n ',
+ 'a\r\nb',
+ 'a\rb',
+ 'a\r\r\nb',
+ '1\r',
+ '
\r',
+ 'Simulink.Parameter',
+ '[1 2]',
+];
+
+/**
+ * Whitespace JavaScript's `\s` counts and XML's four do not.
+ *
+ * Every one of these is a separator to the engine's regexes and part of a name to this reader's
+ * scanner, so wherever one can end a name the reader must decline. Kept out of `ATTR_NAMES` and
+ * injected at low probability instead: at list frequency almost every document would decline and
+ * the stream would stop reaching anything else.
+ */
+const ALIEN_WS = ['\f', '\v', ' ', ' ', ''];
+
+const ATTR_VALUES = [
+ '',
+ 'v',
+ '7',
+ ' 7 ',
+ 'true',
+ 'Simulink.Signal',
+ '&',
+ 'A',
+ 'a
b',
+ '', // deleted inside an attribute value too
+ "a'b",
+ 'a"b',
+ 'a>b',
+ 'a\nb',
+ 'a\r\nb',
+ 'a\r',
+];
+
+/** One generated document. */
+function generate(rand: () => number): string {
+ const pick = (xs: readonly T[]): T => xs[Math.floor(rand() * xs.length)];
+
+ const attrs = (): string => {
+ let s = '';
+ for (let n = Math.floor(rand() * 4); n > 0; n--) {
+ const value = pick(ATTR_VALUES);
+ // Quote style has to follow the content, or the generator produces nothing but
+ // malformations past the first `"` and stops exercising anything else.
+ const q = value.includes('"') ? "'" : rand() < 0.15 ? "'" : '"';
+ const name = pick(ATTR_NAMES);
+ // An `ALIEN_WS` in either place a name can end. The engine splits there and this reader
+ // does not, so the engine sees one more attribute than the scanner does.
+ const lead = rand() < 0.04 ? pick(ALIEN_WS) : pick([' ', ' ', '\t', '\n ']);
+ const sep = rand() < 0.04 ? `${pick(ALIEN_WS)}=` : rand() < 0.1 ? ' = ' : '=';
+ s += `${lead}${name}${sep}${q}${value}${q}`;
+ // The same name twice. Where the split above lands inside a repeated name, the engine folds
+ // the duplicate and keeps the last — so the divergence is a key COUNT rather than a key
+ // value, which is the form it was first caught in.
+ if (rand() < 0.03) s += `${rand() < 0.3 ? pick(ALIEN_WS) : ' '}${name}="dup"`;
+ }
+ return s;
+ };
+
+ const element = (depth: number): string => {
+ const tag = pick(TAGS);
+ const a = attrs();
+ if (depth >= 4 || rand() < 0.25) {
+ return rand() < 0.35 ? `<${tag}${a}${rand() < 0.5 ? ' ' : ''}/>` : `<${tag}${a}>${tag}>`;
+ }
+ let body = '';
+ const kids = Math.floor(rand() * 4);
+ if (kids === 0) {
+ body = pick(TEXTS);
+ } else {
+ // Mixed content on purpose: no real dictionary has it, so only a generator will.
+ // A CRLF indent now and then, so whole documents arrive line-ended the way a Windows editor
+ // leaves them rather than only carrying a CR inside one value.
+ const indent =
+ rand() < 0.5 ? `${rand() < 0.15 ? '\r\n' : '\n'}${' '.repeat(depth + 1)}` : '';
+ if (rand() < 0.2) body += pick(TEXTS);
+ for (let k = 0; k < kids; k++) body += indent + element(depth + 1);
+ if (rand() < 0.2) body += pick(TEXTS);
+ if (indent !== '') body += `\n${' '.repeat(depth)}`;
+ }
+ const close = rand() < 0.1 ? `${tag}\n >` : `${tag}>`;
+ return `<${tag}${a}>${body}${close}`;
+ };
+
+ let doc = '';
+ if (rand() < 0.3) doc += ``;
+ // Hostile injections, each one a documented bail. They are generated rather than listed so
+ // they land in positions a list would not think to put them in.
+ if (rand() < 0.08) doc += pick(['', '', '']);
+ // Processing instructions whose name merely STARTS with `xml`, and a repeat of the declaration.
+ // A prefix test read all of these as the declaration; the engine keys the name it actually read,
+ // and arrays a repeated key rather than overwriting it. The last one is only a repeat when the
+ // line above fired, which is the point of generating it instead of listing it.
+ if (rand() < 0.06) {
+ doc += pick([
+ '',
+ '',
+ '',
+ '',
+ ]);
+ }
+ doc += element(0);
+ if (rand() < 0.05) doc += pick(['', 'trailing', '
', '']);
+ return doc;
+}
+
+describe('generated documents are read identically or declined', () => {
+ // Three seeds rather than one: a single stream can get unlucky about which branches it
+ // reaches, and three is still instant.
+ for (const seed of [1, 20260930, 0x5eed]) {
+ it(`seed ${seed}: 2000 documents`, () => {
+ const rand = rng(seed);
+ let accepted = 0;
+ for (let i = 0; i < 2000; i++) {
+ if (assertEquivalent(generate(rand), `seed ${seed} case ${i}`)) accepted++;
+ }
+ // The generator has to actually reach the reader. Without this the suite would pass
+ // gloriously on a generator that emitted nothing but DOCTYPEs.
+ expect(accepted, 'documents the fast reader accepted').toBeGreaterThan(400);
+ });
+ }
+
+ it('also reads a document shaped like a real dictionary entry', () => {
+ // The generator above is adversarial and produces shapes MATLAB never writes. This one is
+ // the shape it always writes, so the common path is covered by something other than luck —
+ // and it must be ACCEPTED, which is why its values are drawn from a list with none of
+ // `TEXTS`'s deliberate bails in it.
+ const rand = rng(99);
+ const pick = (xs: readonly T[]): T => xs[Math.floor(rand() * xs.length)];
+ const values = ['', '0', '7', '1.5', '-1', 'true', 'false', 'double', 'auto', 'inherit', ' pad '];
+ let xml = '\n';
+ for (let e = 0; e < 300; e++) {
+ xml += `\n `;
+ xml += `\n `;
+ for (const prop of ['Value', 'Min', 'Max', 'DataType', 'Description', 'Unit']) {
+ xml += `\n
${pick(values)}
`;
+ }
+ xml += '\n \n ';
+ }
+ xml += '\n';
+ expect(assertEquivalent(xml, 'dictionary-shaped'), 'was declined').toBe(true);
+ });
+});
diff --git a/test/fixtures/bus_types_binary.sldd b/test/fixtures/bus_types_binary.sldd
new file mode 100644
index 00000000..6a3b95e1
Binary files /dev/null and b/test/fixtures/bus_types_binary.sldd differ
diff --git a/test/fixtures/bus_types_text.sldd b/test/fixtures/bus_types_text.sldd
new file mode 100644
index 00000000..b4d1438e
--- /dev/null
+++ b/test/fixtures/bus_types_text.sldd
@@ -0,0 +1,246 @@
+{
+ "__MW_TEXT_COREPROPERTIES__": {
+ "release": "R2027a"
+ },
+ "__MW_TEXT_PARTS__": {
+ "__MW_TEXT_PART__/data/chunk0": {
+ "__MW_TEXT_content": {
+ "entries": [
+ {
+ "name": "btAlias",
+ "metadata": {
+ "uuid": "7e329469-bb37-4927-bcaa-d50ebaa75075",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.666878",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "Simulink.AliasType",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "BaseType": "uint8",
+ "DataScope": "Auto",
+ "Description": "alias of uint8",
+ "HeaderFile": ""
+ }
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ },
+ {
+ "name": "btBus",
+ "metadata": {
+ "uuid": "32d4cca6-d185-41d0-8076-16b5f999697b",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.667733",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "Simulink.Bus",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "DataScope": "Auto",
+ "Description": "four elements, four cases",
+ "Elements_internal": {
+ "_array_class": "Simulink.BusElement",
+ "_dimensions": [
+ 4,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "2",
+ "_properties": {
+ "Complexity": "real",
+ "Dimensions": 1,
+ "DimensionsMode": "Fixed",
+ "DocUnits": "",
+ "Name": "plain"
+ }
+ },
+ {
+ "_id": "3",
+ "_properties": {
+ "Complexity": "real",
+ "DataType_internal": "btAlias",
+ "Dimensions": 1,
+ "DimensionsMode": "Fixed",
+ "DocUnits": "",
+ "Name": "aliased"
+ }
+ },
+ {
+ "_id": "4",
+ "_properties": {
+ "Complexity": "real",
+ "DataType_internal": "Enum: GearMode",
+ "Dimensions": 1,
+ "DimensionsMode": "Fixed",
+ "DocUnits": "",
+ "Name": "geared"
+ }
+ },
+ {
+ "_id": "5",
+ "_properties": {
+ "Complexity": "complex",
+ "Dimensions": [
+ 2,
+ 3
+ ],
+ "DimensionsMode": "Fixed",
+ "DocUnits": "",
+ "Name": "matrix"
+ }
+ }
+ ]
+ },
+ "HeaderFile": "",
+ "PreserveElementDimensions": false
+ }
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ },
+ {
+ "name": "btEnumDef",
+ "metadata": {
+ "uuid": "09759b9f-a1eb-4703-b45b-f4c1e38aa50c",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.668533",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "Simulink.data.dictionary.EnumTypeDefinition",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "DefaultValue": "OFF",
+ "Description": "a dictionary-defined enum",
+ "Enumerals": {
+ "_array_type": "Struct",
+ "_dimensions": [
+ 1,
+ 2
+ ],
+ "_elements": [
+ {
+ "Description": "off state",
+ "Name": "OFF",
+ "Value": "0"
+ },
+ {
+ "Description": "on state",
+ "Name": "ON",
+ "Value": "1"
+ }
+ ],
+ "_fields": [
+ "Name",
+ "Value",
+ "Description"
+ ]
+ },
+ "StorageType": "int32"
+ }
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ },
+ {
+ "name": "btNumeric",
+ "metadata": {
+ "uuid": "b0b3099d-7150-4d4e-9a2b-624ca90ddd8f",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.667311",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "Simulink.NumericType",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "Bias": 0,
+ "DataScope": "Auto",
+ "DataTypeMode": "Fixed-point: binary point scaling",
+ "DataTypeOverride": "Inherit",
+ "Description": "fixdt(1,16,3)",
+ "FixedExponent": -3,
+ "HeaderFile": "",
+ "IsAlias": false,
+ "SignednessBool": true,
+ "SlopeAdjustmentFactor": 1,
+ "WordLength": 16
+ }
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ },
+ {
+ "name": "btValueType",
+ "metadata": {
+ "uuid": "f8dfcb42-c723-4b9a-a5f7-b0fca67f83d2",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.668177",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "Simulink.ValueType",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "DataType": "btAlias",
+ "Description": "a value type over an alias",
+ "Dimensions": [
+ 1,
+ 4
+ ]
+ }
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ }
+ ],
+ "Dictionary References": [],
+ "AllowAccessBWS": false
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/fixtures/custom_object_binary.sldd b/test/fixtures/custom_object_binary.sldd
new file mode 100644
index 00000000..d1568b70
Binary files /dev/null and b/test/fixtures/custom_object_binary.sldd differ
diff --git a/test/fixtures/custom_object_text.sldd b/test/fixtures/custom_object_text.sldd
new file mode 100644
index 00000000..763f420c
--- /dev/null
+++ b/test/fixtures/custom_object_text.sldd
@@ -0,0 +1,322 @@
+{
+ "__MW_TEXT_COREPROPERTIES__": {
+ "release": "R2027a"
+ },
+ "__MW_TEXT_PARTS__": {
+ "__MW_TEXT_PART__/data/chunk0": {
+ "__MW_TEXT_content": {
+ "entries": [
+ {
+ "name": "gearRow",
+ "metadata": {
+ "uuid": "e54c9100-9075-418f-a157-e6ed26ce0bab",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.612641",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "GearMode",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_enum_value": {
+ "_type": "int32",
+ "_value": "[0, 1, -1]"
+ },
+ "_id": "1"
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ },
+ {
+ "name": "gearValue",
+ "metadata": {
+ "uuid": "e1766a72-9481-4d20-bd8d-0f64922e0e62",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.611431",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "GearMode",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_enum_value": {
+ "_type": "int32",
+ "_value": "-1"
+ },
+ "_id": "1"
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ },
+ {
+ "name": "partCell",
+ "metadata": {
+ "uuid": "e5e357cb-4c55-4417-ade6-c5ce128681e3",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.611249",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_type": "Cell",
+ "_dimensions": [
+ 2,
+ 2
+ ],
+ "_elements": [
+ {
+ "_array_class": "WidgetPart",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "Label": "cell1",
+ "PartId": 101
+ }
+ }
+ ]
+ },
+ {
+ "_array_class": "WidgetPart",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "2",
+ "_properties": {
+ "Label": "cell2",
+ "PartId": 102
+ }
+ }
+ ]
+ },
+ {
+ "_array_class": "WidgetPart",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "3",
+ "_properties": {
+ "Label": "cell3",
+ "PartId": 103
+ }
+ }
+ ]
+ },
+ {
+ "_array_class": "WidgetPart",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "4",
+ "_properties": {
+ "Label": "cell4",
+ "PartId": 104
+ }
+ }
+ ]
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ },
+ {
+ "name": "widget",
+ "metadata": {
+ "uuid": "44b5f868-559a-4d3f-840b-db5d94b7a8f7",
+ "namespace": "42516768-0ace-4981-8ac7-0a9b32cba471",
+ "lastmod": "20261001T033014.610979",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "Widget",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "Cells": {
+ "_array_type": "Cell",
+ "_dimensions": [
+ 1,
+ 3
+ ],
+ "_elements": [
+ 1,
+ "two",
+ true
+ ]
+ },
+ "Cplx": {
+ "_type": "cdata",
+ "_value": " %)30 . 2 8 ( !@@ % \" $ ! 0 ) \" A \"0 @ 00 \u0000\u0000\u0000"
+ },
+ "Cr": "ab",
+ "Ctrl": "\t\n\u0007\u0001",
+ "EmptyStruct": {
+ "_array_type": "Struct",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {}
+ ]
+ },
+ "Flag": true,
+ "Hostile": "amp & lt < gt > quot \" apos ' end",
+ "Lf": "a\nb",
+ "Matrix": {
+ "_type": "double",
+ "_value": "Matrix(2,3)\n[[1.0, 2.0, 3.0]; [4.0, 5.0, 6.0]]"
+ },
+ "Mode": {
+ "_enum_value": {
+ "_type": "int32",
+ "_value": "-1"
+ },
+ "_id": "2",
+ "_object_class": "GearMode"
+ },
+ "Nested": {
+ "_array_type": "Struct",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "inner": {
+ "_array_type": "Struct",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "leaf": 7
+ }
+ ]
+ }
+ }
+ ]
+ },
+ "Nothing": [],
+ "Padded": " pad me ",
+ "Part": {
+ "_id": "3",
+ "_object_class": "WidgetPart",
+ "_properties": {
+ "Label": "lead",
+ "PartId": 1
+ }
+ },
+ "Parts": {
+ "_array_class": "WidgetPart",
+ "_dimensions": [
+ 1,
+ 3
+ ],
+ "_elements": [
+ {
+ "_id": "4",
+ "_properties": {
+ "Label": "part1",
+ "PartId": 10
+ }
+ },
+ {
+ "_id": "5",
+ "_properties": {
+ "Label": "part2",
+ "PartId": 20
+ }
+ },
+ {
+ "_id": "6",
+ "_properties": {
+ "Label": "part3",
+ "PartId": 30
+ }
+ }
+ ]
+ },
+ "Scalar": 1.5,
+ "Str": [
+ "a string"
+ ],
+ "Tab": "a\tb",
+ "Tuning": {
+ "_id": "7",
+ "_object_class": "Simulink.Parameter",
+ "_properties": {
+ "CoderInfo": {
+ "_id": "8",
+ "_object_class": "Simulink.CoderInfo",
+ "_properties": {
+ "CSCPackageName": "Simulink",
+ "CustomAttributes": {
+ "_id": "9",
+ "_object_class": "SimulinkCSC.AttribClass_Simulink_Default",
+ "_properties": {}
+ },
+ "CustomStorageClass": "Default",
+ "ParameterOrSignal": "Parameter",
+ "StorageClass": "Auto"
+ }
+ },
+ "Complexity": "real",
+ "DataType": "single",
+ "Description": "nested MathWorks object",
+ "Dimensions": [
+ 1,
+ 1
+ ],
+ "Value": 2.5
+ }
+ },
+ "ZeroStruct": {
+ "_type": "struct",
+ "_value": "[]"
+ }
+ }
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ }
+ ],
+ "Dictionary References": [],
+ "AllowAccessBWS": false
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/fixtures/derived_class_binary.sldd b/test/fixtures/derived_class_binary.sldd
new file mode 100644
index 00000000..aef2b9a0
Binary files /dev/null and b/test/fixtures/derived_class_binary.sldd differ
diff --git a/test/fixtures/derived_class_text.sldd b/test/fixtures/derived_class_text.sldd
new file mode 100644
index 00000000..8df2e057
--- /dev/null
+++ b/test/fixtures/derived_class_text.sldd
@@ -0,0 +1,209 @@
+{
+ "__MW_TEXT_COREPROPERTIES__": {
+ "release": "R2027a"
+ },
+ "__MW_TEXT_PARTS__": {
+ "__MW_TEXT_PART__/data/chunk0": {
+ "__MW_TEXT_content": {
+ "entries": [
+ {
+ "name": "dpGain",
+ "metadata": {
+ "uuid": "7a8f51eb-e176-481d-8c8f-b578b979872e",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.428555",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "dexdata.DerivedParam",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "Aliases": {
+ "_array_type": "Cell",
+ "_dimensions": [
+ 1,
+ 3
+ ],
+ "_elements": [
+ "alpha",
+ "beta",
+ "gamma"
+ ]
+ },
+ "CalibLevel": 42,
+ "CalibTag": "high",
+ "CoderInfo": {
+ "_id": "3",
+ "_object_class": "Simulink.CoderInfo",
+ "_properties": {
+ "CSCPackageName": "Simulink",
+ "CustomAttributes": {
+ "_id": "4",
+ "_object_class": "SimulinkCSC.AttribClass_Simulink_Default",
+ "_properties": {}
+ },
+ "CustomStorageClass": "Default",
+ "ParameterOrSignal": "Parameter",
+ "StorageClass": "Auto"
+ }
+ },
+ "Complexity": "real",
+ "DataType": "int32",
+ "Description": "a derived parameter",
+ "Dimensions": [
+ 1,
+ 1
+ ],
+ "DocUnits": "m/s",
+ "IsLocked": true,
+ "Limits": {
+ "_array_type": "Struct",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "hi": 9,
+ "lo": -1
+ }
+ ],
+ "_fields": [
+ "lo",
+ "hi"
+ ]
+ },
+ "Max": 100,
+ "Min": -10,
+ "Mode": {
+ "_enum_value": {
+ "_type": "int32",
+ "_value": "-1"
+ },
+ "_id": "2",
+ "_object_class": "GearMode"
+ },
+ "Value": {
+ "_type": "int32",
+ "_value": "7"
+ }
+ }
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ },
+ {
+ "name": "dpPlain",
+ "metadata": {
+ "uuid": "6422e816-d4ad-4678-8a63-73c5cd7e396e",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.431172",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "dexdata.DerivedParam",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "CoderInfo": {
+ "_id": "2",
+ "_object_class": "Simulink.CoderInfo",
+ "_properties": {
+ "CSCPackageName": "Simulink",
+ "CustomAttributes": {
+ "_id": "3",
+ "_object_class": "SimulinkCSC.AttribClass_Simulink_Default",
+ "_properties": {}
+ },
+ "CustomStorageClass": "Default",
+ "ParameterOrSignal": "Parameter",
+ "StorageClass": "Auto"
+ }
+ },
+ "Complexity": "real",
+ "Dimensions": [
+ 0,
+ 0
+ ]
+ }
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ },
+ {
+ "name": "dsFlow",
+ "metadata": {
+ "uuid": "a156ac1e-6f1c-41c4-a1f1-852c4447e667",
+ "namespace": "dacaf35e-55a5-454d-a7c1-93db038a210e",
+ "lastmod": "20261001T033014.432643",
+ "modifiedby": "weiwang",
+ "isderived": "0"
+ },
+ "value": {
+ "_array_class": "dexdata.DerivedSignal",
+ "_dimensions": [
+ 1,
+ 1
+ ],
+ "_elements": [
+ {
+ "_id": "1",
+ "_properties": {
+ "CoderInfo": {
+ "_id": "3",
+ "_object_class": "Simulink.CoderInfo",
+ "_properties": {
+ "CSCPackageName": "Simulink",
+ "CustomAttributes": {
+ "_id": "4",
+ "_object_class": "SimulinkCSC.AttribClass_Simulink_Default",
+ "_properties": {}
+ },
+ "CustomStorageClass": "Default",
+ "ParameterOrSignal": "Signal",
+ "StorageClass": "Auto"
+ }
+ },
+ "DataType": "single",
+ "Description": "a derived signal",
+ "DocUnits": "V",
+ "LoggingInfo": {
+ "_id": "2",
+ "_object_class": "Simulink.LoggingInfo",
+ "_properties": {}
+ },
+ "Max": 5,
+ "Min": -5,
+ "Priority": {
+ "_type": "int32",
+ "_value": "9"
+ },
+ "Routing": "external"
+ }
+ }
+ ],
+ "_mw_element_type": "MATLABArray"
+ }
+ }
+ ],
+ "Dictionary References": [],
+ "AllowAccessBWS": false
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/test/fixtures/mcos/+dexdata/@DerivedParam/DerivedParam.m b/test/fixtures/mcos/+dexdata/@DerivedParam/DerivedParam.m
new file mode 100644
index 00000000..994a56ec
--- /dev/null
+++ b/test/fixtures/mcos/+dexdata/@DerivedParam/DerivedParam.m
@@ -0,0 +1,36 @@
+classdef DerivedParam < Simulink.Parameter
+ % A SUBCLASS of a MathWorks data class — the shape derived_class_{binary,text}.sldd
+ % exists for, and the one the corpus had no instance of at all. Everything here is a
+ % Simulink.Parameter plus six extra properties, which is how customers extend the
+ % class (the documented "define data classes" workflow).
+ %
+ % WHY IT LIVES IN +dexdata AND NOT BESIDE Widget.m. MATLAB R2027a refuses outright:
+ % Class 'DerivedParam' is a subclass of 'Simulink.DataObject' and must be defined
+ % inside of a package, but not in a nested package
+ % So the on-disk class name a customer's dictionary carries for an extended data class
+ % is ALWAYS dotted — `dexdata.DerivedParam` here — and never bare. That is a fact about
+ % every such entry, not about this fixture: any rule that splits a Class attribute on
+ % '.' to find a MathWorks namespace sees a user package in exactly the same shape as
+ % `Simulink.Parameter`. A plain custom class (Widget) has the opposite constraint — it
+ % cannot be in a nested package either, but it needs no package at all.
+ %
+ % Why it is worth a fixture: an entry's `Class` attribute becomes DerivedParam, not
+ % Simulink.Parameter, so every class-gated rule in the data model sees a name it has
+ % never heard of while the VALUE still carries Simulink.Parameter's own properties
+ % (Value, DataType, Min, Max, Unit, CoderInfo...). A reader that keys off the exact
+ % class name degrades this to a generic object and silently loses the parameter
+ % columns; a reader that keys off "is-a" keeps them. The fixture is what makes that
+ % choice visible either way.
+ %
+ % The six extras are one per value kind (double, char, logical, struct, cell, custom
+ % enum) so that a derived class's own properties are serialized through the same
+ % value paths as a plain custom class's, and any divergence lands on a named property.
+ properties
+ CalibLevel = 3
+ CalibTag = 'cal'
+ IsLocked = false
+ Limits = struct('lo', 0, 'hi', 1)
+ Aliases = {'a', 'b'}
+ Mode = GearMode.Drive
+ end
+end
diff --git a/test/fixtures/mcos/+dexdata/@DerivedSignal/DerivedSignal.m b/test/fixtures/mcos/+dexdata/@DerivedSignal/DerivedSignal.m
new file mode 100644
index 00000000..4f693116
--- /dev/null
+++ b/test/fixtures/mcos/+dexdata/@DerivedSignal/DerivedSignal.m
@@ -0,0 +1,15 @@
+classdef DerivedSignal < Simulink.Signal
+ % The Signal half of the derived-class pair (see DerivedParam for the argument, and
+ % for why both are in the +dexdata package rather than beside Widget.m).
+ % Separate from DerivedParam because Simulink.Parameter and Simulink.Signal are
+ % different classes with different property sets and different data-model nodes, and
+ % a fixture that only subclassed one would leave the other's path unmeasured.
+ %
+ % Priority is int32 on purpose: a typed integer property is written with
+ % Class="int32", so the derived class also pins that an extra property keeps its
+ % MATLAB type rather than widening to double on the way through XML.
+ properties
+ Routing = 'bus'
+ Priority = int32(5)
+ end
+end
diff --git a/test/fixtures/mcos/GearMode.m b/test/fixtures/mcos/GearMode.m
new file mode 100644
index 00000000..896925d6
--- /dev/null
+++ b/test/fixtures/mcos/GearMode.m
@@ -0,0 +1,19 @@
+classdef GearMode < Simulink.IntEnumType
+ % A CUSTOM enumeration, used three ways by make_class_fixtures.m: as a property
+ % value on a custom class, as a property value on a Simulink.Parameter subclass,
+ % and as a dictionary entry value in its own right.
+ %
+ % The corpus had no custom enum before this. It matters because an enum is the one
+ % value whose serialization is not inferable from its class name — MATLAB writes it
+ % with the IsEnum / EnumerationName / EnumerationType attributes, three of the
+ % fourteen attributes DictionaryXmlFast interns that no fixture here exercised.
+ %
+ % Reverse is deliberately NEGATIVE: the underlying storage is int32, and a minus
+ % sign is the one character in an enum's serialized text that a scanner reading
+ % digits could get wrong.
+ enumeration
+ Park(0)
+ Drive(1)
+ Reverse(-1)
+ end
+end
diff --git a/test/fixtures/mcos/Widget.m b/test/fixtures/mcos/Widget.m
new file mode 100644
index 00000000..40c428ce
--- /dev/null
+++ b/test/fixtures/mcos/Widget.m
@@ -0,0 +1,101 @@
+classdef Widget
+ % The authoring class for custom_object_{binary,text}.sldd: ONE plain MCOS value class
+ % (no superclass) whose properties cover every value shape a dictionary can hold, so a
+ % reader change that breaks one of them breaks a NAMED property rather than "the
+ % fixture".
+ %
+ % WHY EVERY DEFAULT IS THE SENTINEL 0, AND WHY THE VALUES LIVE IN THE GENERATOR.
+ % The two dictionary formats disagree about defaults, measured on the first cut of
+ % these fixtures (R2027a):
+ %
+ % compressed-binary writes EVERY property of an object, touched or not
+ % JSON text writes only properties away from the CLASSDEF DEFAULT, and
+ % reconstructs the rest from the class at load time
+ %
+ % The first cut put the interesting values in the defaults here and set only the four
+ % object-valued properties in the generator. MATLAB read both twins back perfectly — and
+ % custom_object_text.sldd contained four properties out of twenty. The literal string
+ % `pad me` was not in the file at all. A twin pair like that is worse than no twin: it
+ % looks like a control and is not one, because the text member's values live in a .m file
+ % the reader under test has never seen.
+ %
+ % So nothing here is a real value. `0` is a sentinel no property in the fixture uses, and
+ % make_class_fixtures.m assigns all twenty — which makes all twenty non-default and so
+ % present in BOTH formats. The fixture's content is in the generator, in one place, where
+ % the next person looking for it will be.
+ %
+ % WHAT EACH PROPERTY IS FOR (values in make_class_fixtures.m/custom_items):
+ %
+ % Padded ' pad me ' with LEADING AND TRAILING SPACES. The single most load-bearing
+ % value here. DictionaryXmlFast coerces a text body ONLY IF IT EQUALS ITS OWN
+ % TRIM, precisely so a padded char array survives; a reader that trims turns
+ % this into 'pad me' and nothing else in the corpus notices.
+ % Hostile all five characters XML must escape (& < > " ') in one char array. MATLAB
+ % escapes only the first three — `"` and `'` are written RAW, because they
+ % need escaping in an attribute value and not in element text — so this also
+ % pins which of the five entities a dictionary ever actually contains.
+ % Ctrl / the CONTROL-CHARACTER cases, and the reason this class is worth more than
+ % Cr / its property count. Measured one character at a time in
+ % Tab / Lf .scratch/probe_char_format.m, MATLAB has THREE encodings for a char array
+ % and picks by content:
+ %
+ % printable, incl. tab(9) and LF(10) written literally
+ % CR(13) written as
+ % NUL(0), BEL(7), DEL(127), or any the WHOLE array becomes
+ % non-BMP char (surrogate pair) Format="decimal" and the body is
+ % the char CODES, space separated
+ %
+ % Both of the last two are outside what the readers were built for: `Format`
+ % is a FIFTEENTH attribute (DictionaryXmlFast interns fourteen, and the
+ % 32-dictionary corpus census found no other), and `
` is a numeric
+ % character reference, which that census found zero of. One property each:
+ % Ctrl -> Format="decimal">9 10 13 7 1 (one bad character decimalizes all)
+ % Cr -> a
b
+ % Tab -> ab literal tab in element text
+ % Lf -> ab literal newline in element text
+ % Tab and Lf are not filler: a reader that strips layout whitespace from
+ % element text — which DictionaryXmlFast deliberately does — has to not strip
+ % these, and nothing in the corpus made it prove that.
+ %
+ % AND CR IS WHERE THE TWO FORMATS STOP AGREEING. With these values finally in
+ % the files rather than in this classdef, MATLAB round-trips char 13 through
+ % compressed-binary and LOSES it through JSON text: Ctrl comes back
+ % [9 10 7 1] and Cr comes back 'ab'. custom_object_text.sldd holds zero raw
+ % CR bytes and zero \r escapes, while \t, \n, \u0007 and \u0001 all survive in
+ % the same strings — so the character is dropped on write, not mis-escaped.
+ % verify_class_fixtures.m asserts both behaviours by format on purpose.
+ % Cplx complex scalar; MATLAB writes IsComplex="1" and a `3.0+4.0i` body.
+ % Matrix 2x3, so a column-major flattening is visible as a transpose rather than as
+ % the same six numbers in a different order.
+ % Nothing / ZeroStruct / EmptyStruct — the three distinct EMPTIES, which are not
+ % interchangeable: [] is 0x0 double, struct([]) is a 0x0 struct, struct() is a
+ % 1x1 struct with no fields. They serialize three different ways.
+ % Tuning a Simulink.Parameter INSIDE a custom class. The nesting direction the corpus
+ % lacked: object_props and rt_bin both have custom-in-custom, nothing had
+ % MathWorks-in-custom.
+ % Part / a scalar custom object and a 1x3 object ARRAY. An object array cannot be an
+ % Parts entry value at all (see make_class_fixtures.m), so a property is the only
+ % place real MATLAB output puts one.
+ properties
+ Scalar = 0
+ Matrix = 0
+ Cplx = 0
+ Padded = 0
+ Str = 0
+ Flag = 0
+ Nothing = 0
+ EmptyStruct = 0
+ ZeroStruct = 0
+ Nested = 0
+ Cells = 0
+ Hostile = 0
+ Ctrl = 0
+ Cr = 0
+ Tab = 0
+ Lf = 0
+ Mode = 0
+ Part = 0
+ Parts = 0
+ Tuning = 0
+ end
+end
diff --git a/test/fixtures/mcos/WidgetPart.m b/test/fixtures/mcos/WidgetPart.m
new file mode 100644
index 00000000..6f61bd56
--- /dev/null
+++ b/test/fixtures/mcos/WidgetPart.m
@@ -0,0 +1,14 @@
+classdef WidgetPart
+ % The leaf custom class behind Widget's two object-valued properties: one scalar
+ % (Widget.Part) and one ARRAY (Widget.Parts). Kept deliberately tiny — its job is to
+ % be countable, not to hold interesting values, because what is under test is the
+ % `
` + one classed `` per element shape,
+ % and a fat leaf only makes that shape harder to read in the XML.
+ %
+ % Label carries the index so a mis-ordered or truncated element list is visible by
+ % reading, not by counting.
+ properties
+ PartId = 0
+ Label = 'part'
+ end
+end
diff --git a/test/fixtures/mcos/make_class_fixtures.m b/test/fixtures/mcos/make_class_fixtures.m
new file mode 100644
index 00000000..eb188fe0
--- /dev/null
+++ b/test/fixtures/mcos/make_class_fixtures.m
@@ -0,0 +1,344 @@
+function make_class_fixtures()
+ % Copyright 2026 The MathWorks, Inc.
+ % Authors the derived-class / custom-class dictionary fixtures, each as a TWIN PAIR:
+ % one compressed-binary (zip whose data/chunk0.xml is the XML we read) and one JSON
+ % text, from the SAME in-memory values. Run:
+ %
+ % mw -using Bmain matlab -batch "cd('/test/fixtures/mcos'); make_class_fixtures"
+ %
+ % (A MULTI-LINE -batch argument silently runs nothing and exits 0, so the whole call has
+ % to be one line. The `cd` is what puts this folder — and so the classdefs and the
+ % +dexdata package — on the path before the function resolves. Verify by the WROTE
+ % lines and the trailing MAKE_CLASS_FIXTURES_DONE, not by the exit code.)
+ %
+ % Writes six files into test/fixtures/:
+ % derived_class_{binary,text}.sldd dexdata.DerivedParam / dexdata.DerivedSignal —
+ % subclasses of Simulink.Parameter and .Signal
+ % custom_object_{binary,text}.sldd Widget — a plain MCOS value class covering every
+ % value shape, plus bare enum entries (scalar and
+ % 1x3) and a 2x2 CELL of custom objects, which is
+ % as close to an object-array entry as a dictionary
+ % allows (see custom_items)
+ % bus_types_{binary,text}.sldd the type-defining MathWorks classes as MATLAB
+ % actually writes them in BINARY (arch_binary.sldd
+ % is hand-authored and typeLink.sldd is text-only,
+ % so neither is evidence of MATLAB's output)
+ %
+ % WHY TWINS. The same dictionary written both ways is the control that separates FORMAT
+ % from CONTENT: any difference the two readers produce is a reader bug, because the
+ % values went in from one variable. Both members of a pair therefore come from one
+ % `items` list, built once below — a second hand-written list is a second thing to get
+ % wrong, which is exactly how rt_text/rt_bin were already done here.
+ %
+ % BUT A TWIN PAIR IS NOT TWO COPIES OF THE SAME PROPERTY SET, and that is the most
+ % surprising thing these fixtures measured (R2027a):
+ %
+ % compressed-binary writes EVERY property of an object
+ % JSON text writes only properties away from the CLASSDEF DEFAULT, and
+ % reconstructs the rest from the class on load
+ %
+ % Measured on the entries below: dpGain (all fifteen touched) is 15 properties in both
+ % formats, but dpPlain (nothing touched) is 15 in the binary and THREE in the text, and
+ % btValueType is 9 against 3. So "same entries, same properties" is the wrong assertion
+ % across a twin pair; "same entries, and every property PRESENT IN BOTH agrees" is the
+ % right one. It also means a text dictionary is not self-contained for a custom class —
+ % its defaults live in a .m file — which is why Widget's defaults are a sentinel and
+ % custom_items() assigns all twenty properties explicitly.
+ %
+ % WHY THE SECTION IS PROBED, NOT DECLARED. Design Data accepts only values Simulink
+ % recognises as design data and rejects the rest; which side of that line a
+ % Simulink.Parameter SUBCLASS or a bare enum falls on is not documented anywhere we
+ % trust, so resolve_sections() finds out once, against a throwaway dictionary, and both
+ % twins then use the answer. Guessing would mean a silent twin divergence if the guess
+ % were wrong for one format only.
+ here = fileparts(mfilename('fullpath'));
+ fixtures = fileparts(here);
+ addpath(here);
+ Simulink.data.dictionary.closeAll('-discard');
+
+ derived = resolve_sections('derived', derived_items());
+ custom = resolve_sections('custom', custom_items());
+ types = resolve_sections('types', type_items());
+
+ write_twins(fullfile(fixtures, 'derived_class'), derived);
+ write_twins(fullfile(fixtures, 'custom_object'), custom);
+ write_twins(fullfile(fixtures, 'bus_types'), types);
+ fprintf('MAKE_CLASS_FIXTURES_DONE\n');
+end
+
+% ---------------------------------------------------------------------------
+% The three item lists. An item is {name, preferredSection, value}; the section
+% is a PREFERENCE, narrowed by resolve_sections.
+% ---------------------------------------------------------------------------
+
+function items = derived_items()
+ % A fully populated subclass instance: every INHERITED property set away from its
+ % default too, so each one can be checked by value rather than taken on trust.
+ % dexdata.* and not DerivedParam: MATLAB refuses to DEFINE a Simulink.DataObject
+ % subclass outside a package ("must be defined inside of a package, but not in a
+ % nested package"), so the dotted name is forced, not stylistic. See
+ % +dexdata/DerivedParam.m.
+ p = dexdata.DerivedParam;
+ p.Value = int32(7);
+ p.DataType = 'int32';
+ p.Min = -10;
+ p.Max = 100;
+ p.Unit = 'm/s';
+ p.Description = 'a derived parameter';
+ p.CalibLevel = 42;
+ p.CalibTag = 'high';
+ p.IsLocked = true;
+ p.Limits = struct('lo', -1, 'hi', 9);
+ p.Aliases = {'alpha', 'beta', 'gamma'};
+ p.Mode = GearMode.Reverse;
+
+ % The same class at ITS DEFAULTS, as the control. The measured answer (R2027a) is that
+ % MATLAB omits NOTHING: dpPlain writes all six extra properties and all of
+ % Simulink.Parameter's, CoderInfo tree included, exactly as dpGain does. So a derived
+ % class costs the same bytes whether it was touched or not, and no reader may infer
+ % "unset" from "absent" here.
+ pd = dexdata.DerivedParam;
+
+ s = dexdata.DerivedSignal;
+ s.DataType = 'single';
+ s.Min = -5;
+ s.Max = 5;
+ s.Unit = 'V';
+ s.Description = 'a derived signal';
+ s.Routing = 'external';
+ s.Priority = int32(9);
+
+ items = { ...
+ {'dpGain', 'Design Data', p}, ...
+ {'dpPlain', 'Design Data', pd}, ...
+ {'dsFlow', 'Design Data', s}, ...
+ };
+end
+
+function items = custom_items()
+ % EVERY Widget property is assigned, none left at its classdef default — see Widget.m
+ % for the measurement, but the short version is that the JSON text format writes only
+ % non-default properties, so a value left in the classdef lands in the binary twin and
+ % NOT in the text one. Widget's defaults are therefore the sentinel 0 and this function
+ % is the fixture's content. Adding a property to Widget means adding a line here.
+ w = Widget;
+ w.Scalar = 1.5;
+ w.Matrix = [1 2 3; 4 5 6];
+ w.Cplx = 3 + 4i;
+ w.Padded = ' pad me ';
+ w.Str = "a string";
+ w.Flag = true;
+ w.Nothing = [];
+ w.EmptyStruct = struct();
+ w.ZeroStruct = struct([]);
+ w.Nested = struct('inner', struct('leaf', 7));
+ w.Cells = {1, 'two', true};
+ w.Hostile = 'amp & lt < gt > quot " apos '' end';
+ % The four control-character cases, one encoding each. char() and not an escape, because
+ % MATLAB single quotes have no escapes and '\t' would be a literal backslash-t.
+ w.Ctrl = char([9 10 13 7 1]);
+ w.Cr = ['a' char(13) 'b'];
+ w.Tab = ['a' char(9) 'b'];
+ w.Lf = ['a' char(10) 'b'];
+ w.Mode = GearMode.Reverse;
+
+ part = WidgetPart;
+ part.PartId = 1;
+ part.Label = 'lead';
+ w.Part = part;
+
+ % 1x3 object ARRAY as a PROPERTY. Built by growing from .empty rather than by
+ % repmat, so each element carries a distinct PartId/Label and a dropped or
+ % reordered element is legible in the XML instead of being invisible.
+ parts = WidgetPart.empty;
+ for k = 1:3
+ q = WidgetPart;
+ q.PartId = k * 10;
+ q.Label = sprintf('part%d', k);
+ parts(k) = q;
+ end
+ w.Parts = parts;
+
+ % A Simulink.Parameter nested inside a custom class: MathWorks-in-custom, the
+ % nesting direction the corpus had no instance of.
+ t = Simulink.Parameter;
+ t.Value = 2.5;
+ t.DataType = 'single';
+ t.Description = 'nested MathWorks object';
+ w.Tuning = t;
+
+ % A 2x2 CELL of objects, which is as close as a dictionary gets to an object array at
+ % entry level. An MCOS object ARRAY cannot be an entry value AT ALL — measured in
+ % .scratch/probe_class_refusals.m, R2027a, both sections refuse every one:
+ %
+ % WidgetPart 1x3 / 2x2 "Values of class 'WidgetPart' are not supported in the
+ % 'Other_Data' section of the dictionary." (scalar: accepted)
+ % Widget 1x2 same, and the scalar is accepted
+ % Simulink.Parameter "Arrays of class 'Simulink.Parameter' are not supported in
+ % 1x3 / 2x2 the dictionary." — a DIFFERENT message, i.e. a deliberate
+ % array check, not a class-support check
+ % Simulink.BusElement refused in both sections, scalar included
+ %
+ % That is worth writing down twice over, because object_array_binary.sldd hand-authors
+ % exactly this shape (3x1 Simulink.Parameter and 2x1 Simulink.VariableUsage as entry
+ % VALUES) and MATLAB will not write it. The reader should keep coping with it, but the
+ % fixture is synthetic and no round-trip can ever confirm it. Object arrays reach real
+ % XML only as a PROPERTY — Widget.Parts above, a Bus's Elements_internal — which is
+ % where this corpus now covers them.
+ %
+ % The cell, by contrast, is accepted in DESIGN DATA as well as Other Data, so custom
+ % MCOS objects do reach the design-data section after all, just wrapped. 2x2 and not
+ % 1x4 so the column-major order of the list is observable.
+ grid = cell(2, 2);
+ for k = 1:4
+ q = WidgetPart;
+ q.PartId = 100 + k;
+ q.Label = sprintf('cell%d', k);
+ grid{k} = q;
+ end
+
+ % gearRow: an enum ARRAY is accepted where an object array is not, so this is the one
+ % non-scalar object-ish entry value a dictionary will hold. Mixed signs on purpose.
+ items = { ...
+ {'widget', 'Other Data', w}, ...
+ {'partCell', 'Design Data', grid}, ...
+ {'gearValue', 'Design Data', GearMode.Reverse}, ...
+ {'gearRow', 'Design Data', [GearMode.Park GearMode.Drive GearMode.Reverse]}, ...
+ };
+end
+
+function items = type_items()
+ a = Simulink.AliasType;
+ a.BaseType = 'uint8';
+ a.Description = 'alias of uint8';
+
+ n = Simulink.NumericType;
+ n.DataTypeMode = 'Fixed-point: binary point scaling';
+ n.Signedness = 'Signed';
+ n.WordLength = 16;
+ n.FractionLength = 3;
+ n.Description = 'fixdt(1,16,3)';
+
+ % A bus whose four elements each differ in ONE property from the others, so the
+ % element list is a table of cases rather than three copies: a plain double, one typed
+ % by the alias entry above (the Data Type link's binary-format case), one typed by the
+ % CUSTOM enum class, and one that is both 2x3 and complex.
+ e1 = Simulink.BusElement; e1.Name = 'plain'; e1.DataType = 'double';
+ e2 = Simulink.BusElement; e2.Name = 'aliased'; e2.DataType = 'btAlias';
+ e3 = Simulink.BusElement; e3.Name = 'geared'; e3.DataType = 'Enum: GearMode';
+ e4 = Simulink.BusElement; e4.Name = 'matrix'; e4.DataType = 'double';
+ e4.Dimensions = [2 3];
+ e4.Complexity = 'complex';
+ b = Simulink.Bus;
+ b.Elements = [e1 e2 e3 e4];
+ b.Description = 'four elements, four cases';
+
+ items = { ...
+ {'btAlias', 'Design Data', a}, ...
+ {'btNumeric', 'Design Data', n}, ...
+ {'btBus', 'Design Data', b}, ...
+ };
+
+ % Simulink.ValueType and EnumTypeDefinition are both newer and both optional to the
+ % point of this fixture; a release that lacks either must still produce the bus.
+ try
+ v = Simulink.ValueType;
+ v.DataType = 'btAlias';
+ v.Dimensions = [1 4];
+ v.Description = 'a value type over an alias';
+ items{end + 1} = {'btValueType', 'Design Data', v};
+ catch err
+ fprintf('SKIP btValueType: %s\n', err.message);
+ end
+ try
+ % appendEnumeral, not addEnumeral: R2027a's EnumTypeDefinition has no addEnumeral at
+ % all (`methods` lists appendEnumeral / removeEnumeral), so the obvious spelling fails
+ % as an "Unrecognized method" rather than as a bad argument.
+ ed = Simulink.data.dictionary.EnumTypeDefinition;
+ ed.appendEnumeral('OFF', 0, 'off state');
+ ed.appendEnumeral('ON', 1, 'on state');
+ % A fresh EnumTypeDefinition is NOT empty — it ships one enumeral named `enum1`
+ % with value 0, which the two appends land behind. Removed so the fixture holds the
+ % two enumerals it says it does, and so OFF is not the second zero in the list.
+ % By INDEX, not by name: removeEnumeral('enum1') fails with "Invalid argument at
+ % position 1. Value must be a scalar."
+ ed.removeEnumeral(1);
+ ed.DefaultValue = 'OFF';
+ ed.StorageType = 'int32';
+ ed.Description = 'a dictionary-defined enum';
+ items{end + 1} = {'btEnumDef', 'Design Data', ed};
+ catch err
+ fprintf('SKIP btEnumDef: %s\n', err.message);
+ end
+end
+
+% ---------------------------------------------------------------------------
+% Writing
+% ---------------------------------------------------------------------------
+
+function items = resolve_sections(tag, items)
+ % Narrow each item's section to one Simulink will actually accept, and DROP any item
+ % no section takes — reported loudly, because a silently missing entry is the failure
+ % mode that makes a fixture lie. Runs against a throwaway dictionary in tempdir so the
+ % real files are only ever written from a list already known to work.
+ probe = [tempname '_' tag '.sldd'];
+ cleanup = onCleanup(@() cleanup_file(probe));
+ dd = Simulink.data.dictionary.create(probe);
+ keep = {};
+ for k = 1:numel(items)
+ it = items{k};
+ sections = unique({it{2}, 'Other Data'}, 'stable');
+ placed = '';
+ for s = sections
+ try
+ addEntry(getSection(dd, s{1}), it{1}, it{3});
+ placed = s{1};
+ break
+ catch err
+ fprintf(' %s/%s refused by %s: %s\n', tag, it{1}, s{1}, err.message);
+ end
+ end
+ if isempty(placed)
+ fprintf('SKIP %s/%s: no section accepted it\n', tag, it{1});
+ else
+ if ~strcmp(placed, it{2})
+ fprintf(' %s/%s moved to %s\n', tag, it{1}, placed);
+ end
+ keep{end + 1} = {it{1}, placed, it{3}}; %#ok
+ end
+ end
+ Simulink.data.dictionary.closeAll('-discard');
+ items = keep;
+end
+
+function write_twins(stem, items)
+ write_dict([stem '_binary.sldd'], items, 'compressed-binary');
+ write_dict([stem '_text.sldd'], items, 'text');
+end
+
+function write_dict(path, items, fmt)
+ cleanup_file(path);
+ dd = Simulink.data.dictionary.create(path);
+ for k = 1:numel(items)
+ it = items{k};
+ addEntry(getSection(dd, it{2}), it{1}, it{3});
+ end
+ % FileFormat must be set BEFORE saveChanges; without it the write is JSON text,
+ % which is R2027a's default and is NOT what the XML reader is being measured on.
+ if strcmp(fmt, 'compressed-binary')
+ dd.FileFormat = 'compressed-binary';
+ end
+ saveChanges(dd);
+ Simulink.data.dictionary.closeAll('-discard');
+ fprintf('WROTE %s bytes=%d entries=%d\n', path, dir(path).bytes, numel(items));
+end
+
+function cleanup_file(path)
+ % A dictionary left open holds a lock, and `create` on an existing path errors, so a
+ % re-run has to clear both. closeAll first: deleting a file still open leaves the
+ % in-memory dictionary pointing at nothing and the NEXT create fails instead.
+ Simulink.data.dictionary.closeAll('-discard');
+ if isfile(path)
+ delete(path);
+ end
+end
diff --git a/test/fixtures/mcos/verify_class_fixtures.m b/test/fixtures/mcos/verify_class_fixtures.m
new file mode 100644
index 00000000..7af23dab
--- /dev/null
+++ b/test/fixtures/mcos/verify_class_fixtures.m
@@ -0,0 +1,219 @@
+function verify_class_fixtures()
+ % Copyright 2026 The MathWorks, Inc.
+ % Reopens the six fixtures make_class_fixtures.m wrote and asserts the values came back
+ % — so a fixture that MATLAB *wrote* but cannot *read* is caught here rather than
+ % becoming a test that pins a MATLAB accident. Run:
+ %
+ % mw -using Bmain matlab -batch "cd('/test/fixtures/mcos'); verify_class_fixtures"
+ %
+ % Every twin is checked SEPARATELY and against the same expectations, which is the whole
+ % point of a twin: a value that only survives one of the two formats is a finding.
+ %
+ % The checks are deliberately uneven. Most properties get one equality; the ones that
+ % have a plausible way to go quietly wrong get a stricter check:
+ % Padded compared with strcmp against a value WITH its spaces, because a trimming
+ % bug produces a string that still looks right in a printout.
+ % Cr/Tab/Lf/Ctrl compared by DOUBLE(char), because these characters are invisible and
+ % an equality on the text would read as passing either way.
+ % Matrix compared whole, so a column-major/row-major transpose fails rather than
+ % comparing equal element-by-element in the wrong order.
+ here = fileparts(mfilename('fullpath'));
+ fixtures = fileparts(here);
+ addpath(here);
+ Simulink.data.dictionary.closeAll('-discard');
+
+ state.pass = 0;
+ state.fail = 0;
+ for fmt = {'binary', 'text'}
+ state = verify_derived(state, fullfile(fixtures, ['derived_class_' fmt{1} '.sldd']));
+ state = verify_custom(state, fullfile(fixtures, ['custom_object_' fmt{1} '.sldd']), fmt{1});
+ state = verify_types(state, fullfile(fixtures, ['bus_types_' fmt{1} '.sldd']));
+ end
+ fprintf('\nTOTAL pass=%d fail=%d\n', state.pass, state.fail);
+ if state.fail > 0
+ fprintf('VERIFY_CLASS_FIXTURES_FAILED\n');
+ else
+ fprintf('VERIFY_CLASS_FIXTURES_OK\n');
+ end
+end
+
+function state = verify_derived(state, path)
+ fprintf('\n=== %s ===\n', path);
+ [vals, state] = load_entries(state, path, {'dpGain', 'dpPlain', 'dsFlow'});
+ if isempty(vals), return, end
+ p = vals.dpGain;
+ state = chk(state, 'dpGain class', class(p), 'dexdata.DerivedParam');
+ state = chk(state, 'dpGain Value', p.Value, int32(7));
+ state = chk(state, 'dpGain DataType', p.DataType, 'int32');
+ state = chk(state, 'dpGain Min/Max', [p.Min p.Max], [-10 100]);
+ state = chk(state, 'dpGain Unit', p.Unit, 'm/s');
+ state = chk(state, 'dpGain CalibLevel', p.CalibLevel, 42);
+ state = chk(state, 'dpGain CalibTag', p.CalibTag, 'high');
+ state = chk(state, 'dpGain IsLocked', p.IsLocked, true);
+ state = chk(state, 'dpGain Limits', [p.Limits.lo p.Limits.hi], [-1 9]);
+ state = chk(state, 'dpGain Aliases', p.Aliases, {'alpha', 'beta', 'gamma'});
+ state = chk(state, 'dpGain Mode', p.Mode, GearMode.Reverse);
+
+ pd = vals.dpPlain;
+ state = chk(state, 'dpPlain class', class(pd), 'dexdata.DerivedParam');
+ state = chk(state, 'dpPlain CalibLevel (default)', pd.CalibLevel, 3);
+ state = chk(state, 'dpPlain Mode (default)', pd.Mode, GearMode.Drive);
+
+ s = vals.dsFlow;
+ state = chk(state, 'dsFlow class', class(s), 'dexdata.DerivedSignal');
+ state = chk(state, 'dsFlow DataType', s.DataType, 'single');
+ state = chk(state, 'dsFlow Routing', s.Routing, 'external');
+ state = chk(state, 'dsFlow Priority', s.Priority, int32(9));
+ state = chk(state, 'dsFlow Priority stays int32', class(s.Priority), 'int32');
+end
+
+function state = verify_custom(state, path, fmt)
+ fprintf('\n=== %s ===\n', path);
+ [vals, state] = load_entries(state, path, {'widget', 'partCell', 'gearValue', 'gearRow'});
+ if isempty(vals), return, end
+ w = vals.widget;
+ state = chk(state, 'widget class', class(w), 'Widget');
+ state = chk(state, 'widget Scalar', w.Scalar, 1.5);
+ state = chk(state, 'widget Matrix', w.Matrix, [1 2 3; 4 5 6]);
+ state = chk(state, 'widget Cplx', w.Cplx, 3 + 4i);
+ state = chk(state, 'widget Padded KEEPS SPACES', w.Padded, ' pad me ');
+ state = chk(state, 'widget Str', w.Str, "a string");
+ state = chk(state, 'widget Flag', w.Flag, true);
+ state = chk(state, 'widget Nothing is 0x0', size(w.Nothing), [0 0]);
+ state = chk(state, 'widget EmptyStruct is 1x1 no fields', ...
+ [numel(w.EmptyStruct) numel(fieldnames(w.EmptyStruct))], [1 0]);
+ state = chk(state, 'widget ZeroStruct is 0x0', size(w.ZeroStruct), [0 0]);
+ state = chk(state, 'widget Nested.inner.leaf', w.Nested.inner.leaf, 7);
+ state = chk(state, 'widget Cells', w.Cells, {1, 'two', true});
+ state = chk(state, 'widget Hostile all five', w.Hostile, 'amp & lt < gt > quot " apos '' end');
+ % By code point: these four are invisible, so an equality on the text would pass on a
+ % value that silently lost a character — which is exactly what one format does.
+ %
+ % THE ONE PLACE THE TWINS DISAGREE ON A VALUE, and the reason these checks are
+ % format-aware rather than shared. Measured R2027a, after moving the values out of
+ % Widget.m's defaults so that both files actually carry them:
+ %
+ % char code 13 (CR) SURVIVES a compressed-binary round-trip and is LOST by JSON text.
+ %
+ % Ctrl = char([9 10 13 7 1]) binary -> [9 10 13 7 1] text -> [9 10 7 1]
+ % Cr = ['a' char(13) 'b'] binary -> [97 13 98] text -> [97 98]
+ %
+ % It is not a JSON escaping problem: custom_object_text.sldd contains zero raw CR bytes
+ % AND zero \r escapes, while tab, LF, BEL(7) and SOH(1) all come through the same string
+ % as \t, \n, \u0007 and \u0001. The CR never reaches the file at all. The binary
+ % format writes it as the reference
and reads it back intact.
+ %
+ % So a reader may NOT assume a text dictionary and its binary twin hold equal char data,
+ % and must not "fix" a CR-less text value by inferring one. Asserting the measured
+ % behaviour per format, rather than skipping these two, is deliberate: if a later release
+ % makes text preserve CR, this fails and someone re-reads this comment.
+ if strcmp(fmt, 'text')
+ state = chk(state, 'widget Ctrl codes (text LOSES CR 13)', double(w.Ctrl), [9 10 7 1]);
+ state = chk(state, 'widget Cr codes (text LOSES CR 13)', double(w.Cr), [97 98]);
+ else
+ state = chk(state, 'widget Ctrl codes', double(w.Ctrl), [9 10 13 7 1]);
+ state = chk(state, 'widget Cr codes (
survives)', double(w.Cr), [97 13 98]);
+ end
+ state = chk(state, 'widget Tab codes', double(w.Tab), [97 9 98]);
+ state = chk(state, 'widget Lf codes', double(w.Lf), [97 10 98]);
+ state = chk(state, 'widget Mode', w.Mode, GearMode.Reverse);
+ state = chk(state, 'widget Part', [w.Part.PartId double(string(w.Part.Label) == "lead")], [1 1]);
+ state = chk(state, 'widget Parts size', size(w.Parts), [1 3]);
+ state = chk(state, 'widget Parts ids in order', [w.Parts.PartId], [10 20 30]);
+ state = chk(state, 'widget Tuning class', class(w.Tuning), 'Simulink.Parameter');
+ state = chk(state, 'widget Tuning Value', w.Tuning.Value, 2.5);
+
+ g = vals.partCell;
+ state = chk(state, 'partCell size', size(g), [2 2]);
+ state = chk(state, 'partCell column-major ids', cellfun(@(c) c.PartId, g(:))', [101 102 103 104]);
+ state = chk(state, 'partCell element class', class(g{1}), 'WidgetPart');
+
+ state = chk(state, 'gearValue', vals.gearValue, GearMode.Reverse);
+ state = chk(state, 'gearRow', vals.gearRow, [GearMode.Park GearMode.Drive GearMode.Reverse]);
+ state = chk(state, 'gearRow underlying int32', int32(vals.gearRow), int32([0 1 -1]));
+end
+
+function state = verify_types(state, path)
+ fprintf('\n=== %s ===\n', path);
+ [vals, state] = load_entries(state, path, {'btAlias', 'btNumeric', 'btBus', 'btValueType', 'btEnumDef'});
+ if isempty(vals), return, end
+ state = chk(state, 'btAlias BaseType', vals.btAlias.BaseType, 'uint8');
+ state = chk(state, 'btNumeric WordLength', vals.btNumeric.WordLength, 16);
+ state = chk(state, 'btNumeric FractionLength', vals.btNumeric.FractionLength, 3);
+ b = vals.btBus;
+ state = chk(state, 'btBus element count', numel(b.Elements), 4);
+ state = chk(state, 'btBus element names', {b.Elements.Name}, {'plain', 'aliased', 'geared', 'matrix'});
+ state = chk(state, 'btBus aliased type', b.Elements(2).DataType, 'btAlias');
+ state = chk(state, 'btBus enum type', b.Elements(3).DataType, 'Enum: GearMode');
+ state = chk(state, 'btBus matrix dims', b.Elements(4).Dimensions, [2 3]);
+ state = chk(state, 'btBus matrix complexity', b.Elements(4).Complexity, 'complex');
+ if isfield(vals, 'btValueType')
+ state = chk(state, 'btValueType DataType', vals.btValueType.DataType, 'btAlias');
+ state = chk(state, 'btValueType Dimensions', vals.btValueType.Dimensions, [1 4]);
+ end
+ if isfield(vals, 'btEnumDef')
+ e = vals.btEnumDef;
+ state = chk(state, 'btEnumDef enumerals', {e.Enumerals.Name}, {'OFF', 'ON'});
+ state = chk(state, 'btEnumDef DefaultValue', e.DefaultValue, 'OFF');
+ end
+end
+
+% ---------------------------------------------------------------------------
+
+function [vals, state] = load_entries(state, path, names)
+ % Read every named entry out in one open/close, searching BOTH sections: which section
+ % an entry landed in is make_class_fixtures's probe's business, not this file's, and
+ % hard-coding it here would make a correct relocation look like a missing entry.
+ vals = struct();
+ if ~isfile(path)
+ state = fail(state, sprintf('%s does not exist', path));
+ vals = [];
+ return
+ end
+ d = Simulink.data.dictionary.open(path);
+ for k = 1:numel(names)
+ found = false;
+ for sec = {'Design Data', 'Other Data'}
+ try
+ vals.(names{k}) = getValue(getEntry(getSection(d, sec{1}), names{k}));
+ found = true;
+ break
+ catch
+ % next section
+ end
+ end
+ if ~found
+ state = fail(state, sprintf('entry %s missing from %s', names{k}, path));
+ end
+ end
+ Simulink.data.dictionary.closeAll('-discard');
+end
+
+function state = chk(state, name, actual, expected)
+ if isequal(actual, expected)
+ state.pass = state.pass + 1;
+ fprintf('PASS %s\n', name);
+ else
+ state = fail(state, sprintf('%s: got %s expected %s', name, brief(actual), brief(expected)));
+ end
+end
+
+function state = fail(state, msg)
+ state.fail = state.fail + 1;
+ fprintf('FAIL %s\n', msg);
+end
+
+function s = brief(v)
+ % mat2str throws on a cell/object/enum, and a verifier that errors while REPORTING a
+ % failure hides the failure it was reporting.
+ try
+ s = mat2str(v);
+ catch
+ try
+ s = formattedDisplayText(v);
+ s = strtrim(regexprep(char(s), '\s+', ' '));
+ catch
+ s = sprintf('<%s %s>', class(v), mat2str(size(v)));
+ end
+ end
+end