You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by sr...@apache.org on 2026/09/30 07:47:53 UTC

(plc4x) branch develop updated: feat(plc4net): add the MSpec C# generator (#2785)

This is an automated email from the ASF dual-hosted git repository.

sruehl pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git


The following commit(s) were added to refs/heads/develop by this push:
     new b878affa2d feat(plc4net): add the MSpec C# generator (#2785)
b878affa2d is described below

commit b878affa2d7b6234cc26f49084751b98f7bb4a15
Author: TomNewChao <ch...@gmail.com>
AuthorDate: Wed Sep 30 15:47:47 2026 +0800

    feat(plc4net): add the MSpec C# generator (#2785)
    
    * fix(plc4net): preserve expression literal semantics
    
    * ci(plc4net): verify generated parser artifacts
    
    * feat(plc4net): render expressions as C#
    
    * feat(plc4net): add the MSpec C# generator
    
    * docs(plc4net): clarify code generator usage
    
    * Fix generated expression helpers and enum accessors
    
    * Harden plc4net code generation boundaries
    
    * Fix generated parser validation and virtual fields
---
 .github/workflows/dotnet-platform.yml              |   33 +-
 .../code-gen-test/CSharpExpressionRendererTests.cs |   85 +
 plc4net/test/code-gen-test/ExpressionTests.cs      |  197 ++
 plc4net/test/code-gen-test/MSpecLexerTests.cs      |   78 +
 plc4net/test/code-gen-test/MspecParserTests.cs     |   93 +
 plc4net/test/code-gen-test/ParserSmokeTests.cs     |   15 +-
 plc4net/test/code-gen-test/PipelineTests.cs        |  612 +++++
 plc4net/test/code-gen-test/RepoPaths.cs            |   53 +
 plc4net/test/code-gen-test/TestsuiteRunnerTests.cs |   94 +
 plc4net/tools/code-gen/MspecExpressionParser.cs    |    7 +-
 plc4net/tools/code-gen/MspecModelBuilder.cs        |  571 +++++
 plc4net/tools/code-gen/MspecReader.cs              |  125 +
 .../code-gen/ParserSerializerTestsuiteRunner.cs    |  148 ++
 plc4net/tools/code-gen/Program.cs                  |  139 ++
 plc4net/tools/code-gen/README.md                   |  101 +
 plc4net/tools/code-gen/code-gen.csproj             |    2 +-
 plc4net/tools/code-gen/model/Definitions.cs        |  199 ++
 plc4net/tools/code-gen/model/Fields.cs             |  184 ++
 plc4net/tools/code-gen/model/Protocol.cs           |   66 +
 plc4net/tools/code-gen/model/TypeReference.cs      |  107 +
 plc4net/tools/code-gen/model/terms/Term.cs         |   13 +-
 .../code-gen/output/CSharpExpressionRenderer.cs    |  211 ++
 plc4net/tools/code-gen/output/CSharpGenerator.cs   | 2435 ++++++++++++++++++++
 plc4net/tools/code-gen/output/CSharpTypeMapper.cs  |  192 ++
 plc4net/tools/code-gen/output/CodeWriter.cs        |   46 +
 plc4net/tools/code-gen/output/FieldScope.cs        |  198 ++
 26 files changed, 5992 insertions(+), 12 deletions(-)

diff --git a/.github/workflows/dotnet-platform.yml b/.github/workflows/dotnet-platform.yml
index 42b6dca861..c7b56e9880 100644
--- a/.github/workflows/dotnet-platform.yml
+++ b/.github/workflows/dotnet-platform.yml
@@ -21,16 +21,45 @@ name: .NET Platform Compatibility
 on:
   push:
     branches: [develop, plc4net]
-    paths: [plc4net/**, .github/workflows/dotnet-platform.yml]
+    paths:
+      - plc4net/**
+      - code-generation/protocol-base-mspec/src/main/antlr4/org/apache/plc4x/plugins/codegenerator/language/mspec/**
+      - .github/workflows/dotnet-platform.yml
   pull_request:
     branches: [develop, plc4net]
-    paths: [plc4net/**, .github/workflows/dotnet-platform.yml]
+    paths:
+      - plc4net/**
+      - code-generation/protocol-base-mspec/src/main/antlr4/org/apache/plc4x/plugins/codegenerator/language/mspec/**
+      - .github/workflows/dotnet-platform.yml
   workflow_dispatch:
 
 permissions:
   contents: read
 
 jobs:
+  parser-generation:
+    name: Generated parser artifacts
+    runs-on: ubuntu-latest
+    timeout-minutes: 10
+    steps:
+      - uses: actions/checkout@v7
+      - name: Set up Python
+        uses: actions/setup-python@v7
+        with:
+          python-version: '3.12'
+      - name: Set up Java
+        uses: actions/setup-java@v6
+        with:
+          distribution: 'temurin'
+          java-version: 21
+      - name: Cache ANTLR
+        uses: actions/cache@v6
+        with:
+          path: ~/.cache/plc4x/antlr
+          key: ${{ runner.os }}-antlr-4.13.2
+      - name: Verify generated parser artifacts
+        run: python plc4net/tools/code-gen/generate_parsers.py --check
+
   test:
     name: Build and test (${{ matrix.os }})
     strategy:
diff --git a/plc4net/test/code-gen-test/CSharpExpressionRendererTests.cs b/plc4net/test/code-gen-test/CSharpExpressionRendererTests.cs
new file mode 100644
index 0000000000..436b44fff7
--- /dev/null
+++ b/plc4net/test/code-gen-test/CSharpExpressionRendererTests.cs
@@ -0,0 +1,85 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using org.apache.plc4net.tools.codegen;
+using org.apache.plc4net.tools.codegen.output;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen;
+
+public class CSharpExpressionRendererTests
+{
+    [Theory]
+    [InlineData("0xFFFFFFFFFFFFFFFF", "0xFFFFFFFFFFFFFFFF")]
+    [InlineData("pdu.lengthInBytes + 1", "(pdu.GetLengthInBytes() + 1)")]
+    [InlineData("COUNT(value)", "value.Count")]
+    [InlineData("2 ^ exponent", "System.Math.Pow(2, exponent)")]
+    [InlineData("ARRAY_SIZE_IN_BYTES(items)", "StaticHelper.ArraySizeInBytes(items)")]
+    [InlineData("STATIC_CALL(\"rtuCrcCheck\", address, pdu)", "StaticHelper.RtuCrcCheck(address, pdu)")]
+    [InlineData("CAST(parameter, \"S7Parameter\").items[0]", "((S7Parameter) parameter).Items[0]")]
+    [InlineData("CAST(parameter, S7Parameter).items[0]", "((S7Parameter) parameter).Items[0]")]
+    [InlineData("(-value)[0]", "(-value)[0]")]
+    [InlineData("(!flag)[0]", "(!flag)[0]")]
+    public void RendersExpressionAsCSharp(string expression, string expected)
+    {
+        Assert.Equal(expected, Render(expression));
+    }
+
+    [Fact]
+    public void UsesScopeForReferencesCountsAndMembers()
+    {
+        var renderer = new CSharpExpressionRenderer(new TestScope());
+
+        Assert.Equal("(_value.Start + _value.Size)",
+            renderer.Render(MspecExpressionParser.Parse("start + size")));
+        Assert.Equal("_value.Bytes.Length",
+            renderer.Render(MspecExpressionParser.Parse("COUNT(bytes)")));
+        Assert.Equal("_value.Value.GetCode()",
+            renderer.Render(MspecExpressionParser.Parse("value.code")));
+    }
+
+    [Fact]
+    public void SupportsQualifiedUnquotedCastTypesWithoutResolvingThemAsValues()
+    {
+        var renderer = new CSharpExpressionRenderer(new TestScope());
+
+        Assert.Equal("((Protocol.Message) _value.Value)",
+            renderer.Render(MspecExpressionParser.Parse("CAST(value, Protocol.Message)")));
+    }
+
+    [Fact]
+    public void RendersTheMspecEmptyStringCompatibilitySentinel()
+    {
+        Assert.Equal("\"\"", Render(MspecReader.EmptyStringSentinel));
+    }
+
+    private static string Render(string expression) =>
+        new CSharpExpressionRenderer().Render(MspecExpressionParser.Parse(expression));
+
+    private sealed class TestScope : IExpressionScope
+    {
+        public string ResolveReference(string name) => $"_value.{char.ToUpperInvariant(name[0])}{name[1..]}";
+
+        public string? ResolveCount(string name) =>
+            name == "bytes" ? "_value.Bytes.Length" : null;
+
+        public string? ResolveMember(string name) =>
+            name == "code" ? "GetCode()" : null;
+    }
+}
diff --git a/plc4net/test/code-gen-test/ExpressionTests.cs b/plc4net/test/code-gen-test/ExpressionTests.cs
new file mode 100644
index 0000000000..6beb22a3f5
--- /dev/null
+++ b/plc4net/test/code-gen-test/ExpressionTests.cs
@@ -0,0 +1,197 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using org.apache.plc4net.tools.codegen;
+using org.apache.plc4net.tools.codegen.model.terms;
+using org.apache.plc4net.tools.codegen.output;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+    /// <summary>
+    /// The mspec expression engine: <see cref="MspecExpressionParser"/> (text
+    /// -&gt; <see cref="Term"/> tree, via the <c>Expression.g4</c> grammar) and
+    /// <see cref="CSharpExpressionRenderer"/> (tree -&gt; C#). Every field that
+    /// carries a formula - array <c>count</c> / <c>length</c>, <c>implicit</c>,
+    /// <c>virtual</c>, <c>const</c>, discriminator values - depends on both.
+    /// </summary>
+    public class ExpressionTests
+    {
+        // ── literals ─────────────────────────────────────────────
+
+        [Theory]
+        [InlineData("42", "42")]
+        [InlineData("0", "0")]
+        [InlineData("0x0E", "0x0E")]
+        [InlineData("0xFF", "0xFF")]
+        [InlineData("true", "true")]
+        [InlineData("false", "false")]
+        [InlineData("null", "null")]
+        [InlineData("1.5", "1.5")]
+        public void Literals_round_trip(string mspec, string expected)
+        {
+            Assert.Equal(expected, MspecExpressionParser.Parse(mspec).ToString());
+        }
+
+        [Fact]
+        public void String_literal_drops_the_quotes()
+        {
+            var term = Assert.IsType<StringLiteral>(MspecExpressionParser.Parse("\"UTF8\""));
+            Assert.Equal("UTF8", term.Value);
+        }
+
+        // ── operators and precedence ─────────────────────────────
+
+        [Theory]
+        [InlineData("1 + 2", "(1 + 2)")]
+        [InlineData("1 + 2 * 3", "(1 + (2 * 3))")]
+        [InlineData("(1 + 2) * 3", "((1 + 2) * 3)")]
+        [InlineData("byteCount - 6", "(byteCount - 6)")]
+        [InlineData("numberOfValues * 8", "(numberOfValues * 8)")]
+        [InlineData("(COUNT(fifoValue) * 2) + 2", "((COUNT(fifoValue) * 2) + 2)")]
+        [InlineData("a >= b && c != d", "((a >= b) && (c != d))")]
+        [InlineData("!flag", "!flag")]
+        [InlineData("-1", "-1")]
+        [InlineData("a ? b : c", "(a ? b : c)")]
+        public void Operators_parse_with_the_right_shape(string mspec, string expectedTree)
+        {
+            Assert.Equal(expectedTree, MspecExpressionParser.Parse(mspec).ToString());
+        }
+
+        // ── identifier segments: name (args)? [index]? (. child)? ─
+
+        [Fact]
+        public void Dotted_path_becomes_a_child_chain()
+        {
+            var v = Assert.IsType<VariableLiteral>(MspecExpressionParser.Parse("pdu.lengthInBytes"));
+            Assert.Equal("pdu", v.Name);
+            Assert.Null(v.Args);
+            Assert.NotNull(v.Child);
+            Assert.Equal("lengthInBytes", v.Child.Name);
+        }
+
+        [Fact]
+        public void Call_captures_arguments()
+        {
+            var v = Assert.IsType<VariableLiteral>(MspecExpressionParser.Parse("COUNT(value)"));
+            Assert.Equal("COUNT", v.Name);
+            Assert.NotNull(v.Args);
+            Assert.Single(v.Args);
+            Assert.Equal("value", v.Args[0].ToString());
+        }
+
+        [Fact]
+        public void Static_call_keeps_the_string_target_and_the_rest()
+        {
+            var v = Assert.IsType<VariableLiteral>(
+                MspecExpressionParser.Parse("STATIC_CALL(\"rtuCrcCheck\", address, pdu)"));
+            Assert.Equal("STATIC_CALL", v.Name);
+            Assert.NotNull(v.Args);
+            Assert.Equal(3, v.Args.Count);
+            Assert.IsType<StringLiteral>(v.Args[0]);
+        }
+
+        [Fact]
+        public void Indexing_is_captured()
+        {
+            var v = Assert.IsType<VariableLiteral>(MspecExpressionParser.Parse("items[0].fileNumber"));
+            Assert.Single(v.Index);
+            Assert.Equal("0", v.Index[0].ToString());
+            Assert.NotNull(v.Child);
+            Assert.Equal("fileNumber", v.Child.Name);
+        }
+
+        // ── C# rendering ─────────────────────────────────────────
+
+        [Theory]
+        [InlineData("pdu.lengthInBytes + 1", "(pdu.GetLengthInBytes() + 1)")]
+        [InlineData("COUNT(value)", "value.Count")]
+        [InlineData("COUNT(value) + 6", "(value.Count + 6)")]
+        [InlineData("byteCount - 6", "(byteCount - 6)")]
+        [InlineData("(COUNT(fifoValue) * 2) / 2", "((fifoValue.Count * 2) / 2)")]
+        [InlineData("ARRAY_SIZE_IN_BYTES(items)", "StaticHelper.ArraySizeInBytes(items)")]
+        [InlineData("STATIC_CALL(\"rtuCrcCheck\", address, pdu)", "StaticHelper.RtuCrcCheck(address, pdu)")]
+        [InlineData("numberOfValues * 8", "(numberOfValues * 8)")]
+        [InlineData("errorFlag ? 1 : 0", "(errorFlag ? 1 : 0)")]
+        public void Renders_to_C_sharp(string mspec, string expectedCSharp)
+        {
+            var term = MspecExpressionParser.Parse(mspec);
+            Assert.Equal(expectedCSharp, new CSharpExpressionRenderer().Render(term));
+        }
+
+        [Fact]
+        public void The_scope_rewrites_bare_references()
+        {
+            var term = MspecExpressionParser.Parse("startingAddress + quantity");
+            var renderer = new CSharpExpressionRenderer(new PrefixScope("_value."));
+            Assert.Equal("(_value.startingAddress + _value.quantity)", renderer.Render(term));
+        }
+
+        [Fact]
+        public void Power_operator_maps_to_Math_Pow_not_xor()
+        {
+            var term = MspecExpressionParser.Parse("2 ^ exponent");
+            Assert.Equal("System.Math.Pow(2, exponent)", new CSharpExpressionRenderer().Render(term));
+        }
+
+        [Fact]
+        public void Every_modbus_field_expression_parses()
+        {
+            // The formulas that appear verbatim in modbus.mspec.
+            string[] expressions =
+            {
+                "pdu.lengthInBytes + 1",
+                "COUNT(value)",
+                "COUNT(events) + 6",
+                "byteCount - 6",
+                "(COUNT(fifoValue) * 2) + 2",
+                "(COUNT(fifoValue) * 2) / 2",
+                "ARRAY_SIZE_IN_BYTES(items)",
+                "COUNT(data) + 1",
+                "dataLength - 1",
+                "COUNT(recordData) / 2",
+                "recordLength * 2",
+                "COUNT(objects)",
+                "STATIC_CALL(\"rtuCrcCheck\", address, pdu)",
+                "STATIC_CALL(\"asciiLrcCheck\", address, pdu)",
+            };
+
+            var renderer = new CSharpExpressionRenderer();
+            foreach (var expr in expressions)
+            {
+                var term = MspecExpressionParser.Parse(expr);
+                Assert.False(string.IsNullOrEmpty(renderer.Render(term)), expr);
+            }
+        }
+
+        [Fact]
+        public void A_broken_expression_reports_where()
+        {
+            var ex = Assert.Throws<MspecParseException>(() => MspecExpressionParser.Parse("1 +"));
+            Assert.Contains("'1 +'", ex.Message);
+        }
+
+        private sealed class PrefixScope : IExpressionScope
+        {
+            private readonly string _prefix;
+            public PrefixScope(string prefix) => _prefix = prefix;
+            public string ResolveReference(string name) => _prefix + name;
+        }
+    }
+}
diff --git a/plc4net/test/code-gen-test/MSpecLexerTests.cs b/plc4net/test/code-gen-test/MSpecLexerTests.cs
new file mode 100644
index 0000000000..224554ffc4
--- /dev/null
+++ b/plc4net/test/code-gen-test/MSpecLexerTests.cs
@@ -0,0 +1,78 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Linq;
+using Antlr4.Runtime;
+using org.apache.plc4net.tools.codegen.grammar;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+    /// <summary>
+    /// Pins the column-zero predicate of the mspec lexer's <c>EmptyLine</c> rule.
+    /// The grammar is the upstream one and spells the predicate the way the ANTLR
+    /// Java runtime does, <c>getCharPositionInLine()</c>; <c>MSpecLexer.Predicates.cs</c>
+    /// supplies that method for the C# lexer. A line break that starts a line must
+    /// lex as <c>EmptyLine</c>, one that ends a line of text as <c>NEWLINE</c>.
+    /// </summary>
+    public class MSpecLexerTests
+    {
+        private static int[] LineBreakTokenTypes(string text)
+        {
+            var lexer = new MSpecLexer(CharStreams.fromString(text));
+            return lexer.GetAllTokens()
+                .Select(token => token.Type)
+                .Where(type => type == MSpecLexer.EmptyLine || type == MSpecLexer.NEWLINE)
+                .ToArray();
+        }
+
+        [Fact]
+        public void Line_break_at_column_zero_is_an_EmptyLine()
+        {
+            Assert.Equal(new[] { MSpecLexer.EmptyLine }, LineBreakTokenTypes("\n"));
+        }
+
+        [Fact]
+        public void Whitespace_only_line_is_an_EmptyLine()
+        {
+            Assert.Equal(new[] { MSpecLexer.EmptyLine }, LineBreakTokenTypes("  \t\n"));
+        }
+
+        [Fact]
+        public void Line_break_after_text_is_a_NEWLINE()
+        {
+            Assert.Equal(new[] { MSpecLexer.NEWLINE }, LineBreakTokenTypes("x\n"));
+        }
+
+        [Fact]
+        public void Crlf_line_breaks_follow_the_same_rule()
+        {
+            Assert.Equal(new[] { MSpecLexer.EmptyLine, MSpecLexer.NEWLINE }, LineBreakTokenTypes("\r\nx\r\n"));
+        }
+
+        [Fact]
+        public void Blank_line_between_two_lines_of_text()
+        {
+            // "a\n" ends a line of text; the second "\n" starts, and ends, a line of its own.
+            Assert.Equal(
+                new[] { MSpecLexer.NEWLINE, MSpecLexer.EmptyLine, MSpecLexer.NEWLINE },
+                LineBreakTokenTypes("a\n\nb\n"));
+        }
+    }
+}
\ No newline at end of file
diff --git a/plc4net/test/code-gen-test/MspecParserTests.cs b/plc4net/test/code-gen-test/MspecParserTests.cs
new file mode 100644
index 0000000000..bd5939e4c3
--- /dev/null
+++ b/plc4net/test/code-gen-test/MspecParserTests.cs
@@ -0,0 +1,93 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.IO;
+using org.apache.plc4net.tools.codegen;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+    /// <summary>
+    /// Verifies that the pure-.NET mspec parser can parse the Modbus protocol
+    /// description, which is the simplest of the 24 protocol descriptions and
+    /// therefore the first target for the generator pipeline.
+    /// </summary>
+    public class MspecParserTests
+    {
+        private static string? GetModbusMspecPath()
+        {
+            // The mspec file lives in the repo root under protocols/modbus/...
+            // FindRepoRoot walks up from the test output directory; null means
+            // the tests are not running inside a checkout.
+            var repoRoot = RepoPaths.FindRepoRoot();
+            if (repoRoot == null) return null;
+            return Path.Combine(repoRoot,
+                "protocols", "modbus", "src", "main", "resources",
+                "protocols", "modbus", "modbus.mspec");
+        }
+
+        [Fact]
+        public void Modbus_mspec_parses_without_errors()
+        {
+            var path = GetModbusMspecPath();
+            if (path == null)
+            {
+                // Fail loud rather than passing vacuously outside the repo layout.
+                Assert.Fail("repo root not found; real-protocol coverage would be vacuous");
+            }
+
+            // Deliberately no File.Exists guard here: inside a checkout the
+            // file must exist, and a missing one is a real error worth failing
+            // on, not a reason to pass vacuously.
+            var tree = MspecReader.ReadFile(path);
+
+            Assert.NotNull(tree);
+        }
+
+        [Fact]
+        public void A_minimal_mspec_parses()
+        {
+            var mspec = @"
+[type Byte
+    [simple uint 8 value]
+]
+";
+            var tree = MspecReader.Read(mspec);
+            Assert.NotNull(tree);
+        }
+
+        [Fact]
+        public void Malformed_mspec_throws()
+        {
+            var ex = Assert.Throws<MspecParseException>(
+                () => MspecReader.Read("[type Broken <<<"));
+
+            Assert.Contains("Failed to parse", ex.Message);
+        }
+
+        [Fact]
+        public void A_lexer_error_is_reported_by_strict_parsing()
+        {
+            var ex = Assert.Throws<MspecParseException>(
+                () => MspecReader.Read("[type Packet [simple uint 8 value] @]"));
+
+            Assert.Contains("token recognition error", ex.Message);
+        }
+    }
+}
diff --git a/plc4net/test/code-gen-test/ParserSmokeTests.cs b/plc4net/test/code-gen-test/ParserSmokeTests.cs
index 18d99e2750..4def8408e6 100644
--- a/plc4net/test/code-gen-test/ParserSmokeTests.cs
+++ b/plc4net/test/code-gen-test/ParserSmokeTests.cs
@@ -112,6 +112,17 @@ public class ParserSmokeTests
         Assert.Equal(expected, MspecExpressionParser.Parse(input).ToString());
     }
 
+    [Theory]
+    [InlineData("0x0E")]
+    [InlineData("0xFFFFFFFFFFFFFFFF")]
+    [InlineData("0x10000000000000000")]
+    public void PreservesHexadecimalLiteralTextWithoutSignedConversion(string input)
+    {
+        var literal = Assert.IsType<HexadecimalLiteral>(MspecExpressionParser.Parse(input));
+
+        Assert.Equal(input, literal.Text);
+    }
+
     [Fact]
     public void BuildsCallsIndexesAndMemberChains()
     {
@@ -151,6 +162,8 @@ public class ParserSmokeTests
     [Theory]
     [InlineData("\"abc\"[0]", "\"abc\"[0]")]
     [InlineData("(left + right)[offset]", "(left + right)[offset]")]
+    [InlineData("(-value)[0]", "(-value)[0]")]
+    [InlineData("(!flag)[0]", "(!flag)[0]")]
     public void PreservesIndexesOnAnyExpression(string input, string expected)
     {
         Assert.Equal(expected, MspecExpressionParser.Parse(input).ToString());
@@ -226,4 +239,4 @@ public class ParserSmokeTests
             Errors.Add($"{line}:{charPositionInLine}: {msg}");
         }
     }
-}
\ No newline at end of file
+}
diff --git a/plc4net/test/code-gen-test/PipelineTests.cs b/plc4net/test/code-gen-test/PipelineTests.cs
new file mode 100644
index 0000000000..ed68e659ff
--- /dev/null
+++ b/plc4net/test/code-gen-test/PipelineTests.cs
@@ -0,0 +1,612 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Security;
+using org.apache.plc4net.tools.codegen;
+using org.apache.plc4net.tools.codegen.model;
+using org.apache.plc4net.tools.codegen.model.fields;
+using org.apache.plc4net.tools.codegen.output;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+    /// <summary>
+    /// End-to-end for the pure-.NET mspec toolchain: text → type-model IR
+    /// (<see cref="MspecModelBuilder"/>) → C# (<see cref="CSharpGenerator"/>).
+    /// These tests cover the IR and emitted C# shapes without requiring a
+    /// generated protocol model to be checked in with the tool itself.
+    /// </summary>
+    public class PipelineTests
+    {
+        // ── IR ──────────────────────────────────────────────────
+
+        [Fact]
+        public void A_plain_type_has_its_fields_in_order()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type Item
+    [simple uint 8  referenceType]
+    [simple uint 16 fileNumber]
+]
+");
+            var item = Assert.Single(model.Types);
+            Assert.Equal("Item", item.Name);
+            Assert.Equal(new[] { "referenceType", "fileNumber" }, item.Fields.Select(f => f.Name));
+            var f0 = Assert.IsType<SimpleField>(item.Fields[0]);
+            var t0 = Assert.IsType<SimpleTypeReference>(f0.Type);
+            Assert.Equal(8, t0.SizeInBits);
+        }
+
+        [Fact]
+        public void A_discriminated_type_lifts_its_cases_to_top_level_children()
+        {
+            var model = MspecModelBuilder.Build(@"
+[discriminatedType Pdu(bit response)
+    [discriminator uint 8 code]
+    [typeSwitch code
+        ['0x01' PduA [simple uint 16 a]]
+        ['0x02' PduB [simple uint 16 b]]
+    ]
+]
+");
+            var parent = model.FindType("Pdu");
+            Assert.NotNull(parent);
+            Assert.True(parent.IsDiscriminatedParent);
+
+            var a = model.FindType("PduA");
+            Assert.NotNull(a);
+            Assert.Equal("Pdu", a.ParentName);
+            Assert.True(a.IsDiscriminatedChild);
+            Assert.NotNull(a.DiscriminatorValues);
+            Assert.Single(a.DiscriminatorValues);
+
+            Assert.NotNull(parent.TypeSwitch);
+            Assert.Equal(new[] { "PduA", "PduB" }, parent.TypeSwitch.CaseNames);
+        }
+
+        [Fact]
+        public void An_enum_carries_its_base_type_and_values()
+        {
+            var model = MspecModelBuilder.Build("[enum uint 8 E ['1' A] ['2' B]]");
+            var e = Assert.Single(model.Enums);
+            Assert.NotNull(e.BaseType);
+            Assert.Equal(SimpleTypeReference.Base.UInt, e.BaseType.BaseType);
+            Assert.Equal(8, e.BaseType.SizeInBits);
+            Assert.Equal(new[] { "A", "B" }, e.Values.Select(v => v.Name));
+        }
+
+        [Fact]
+        public void An_enum_typed_field_resolves_to_an_enum_reference()
+        {
+            var model = MspecModelBuilder.Build(@"
+[enum uint 8 ErrorCode ['1' BAD]]
+[type Err [simple ErrorCode code]]
+");
+            var errType = model.FindType("Err");
+            Assert.NotNull(errType);
+            Assert.IsType<EnumTypeReference>(errType.Fields[0].Type);
+        }
+
+        // ── generation ──────────────────────────────────────────
+
+        [Fact]
+        public void Generates_a_file_per_type_and_enum()
+        {
+            var model = MspecModelBuilder.Build(@"
+[enum uint 8 E ['1' A]]
+[type T [simple uint 8 x]]
+");
+            var files = new CSharpGenerator(model, "demo", "demo.readwrite").Generate();
+            Assert.Contains("model/T.cs", files.Keys);
+            Assert.Contains("model/E.cs", files.Keys);
+        }
+
+        [Fact]
+        public void A_generated_type_has_parse_serialize_and_length()
+        {
+            var model = MspecModelBuilder.Build("[type T [simple uint 16 startingAddress]]");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+            Assert.Contains("public static T StaticParse(ReadBuffer readBuffer)", code);
+            Assert.Contains("readBuffer.ReadUshort(\"startingAddress\", 16)", code);
+            Assert.Contains("public void Serialize(WriteBuffer writeBuffer)", code);
+            Assert.Contains("writeBuffer.WriteUshort(\"startingAddress\", 16, StartingAddress)", code);
+            Assert.Contains("public int GetLengthInBits()", code);
+        }
+
+        [Fact]
+        public void An_implicit_field_is_read_and_discarded_but_written_from_its_formula()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type T
+    [implicit uint 8 byteCount 'COUNT(value)']
+    [array    byte   value count 'byteCount']
+]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+            // parse: byteCount read into a local, then used as the array length
+            Assert.Contains("var byteCount = readBuffer.ReadByte(\"byteCount\", 8);", code);
+            Assert.Contains("readBuffer.ReadByteArray(\"value\", (int) (byteCount) * 8)", code);
+            // serialize: byteCount computed from the array, not stored
+            Assert.Contains("writeBuffer.WriteByte(\"byteCount\", 8, (byte) (Value.Length));", code);
+            Assert.DoesNotContain("public byte ByteCount", code);
+        }
+
+        [Fact]
+        public void A_checksum_field_is_validated_after_it_is_read()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type T
+    [simple uint 8 payload]
+    [checksum uint 8 crc 'payload']
+]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+            Assert.Contains("var expectedCrc = (byte) (payload);", code);
+            Assert.Contains("if (!Equals(crc, expectedCrc))", code);
+            Assert.Contains("throw new ParseException", code);
+        }
+
+        [Fact]
+        public void A_virtual_field_is_available_to_later_parse_fields_and_as_a_computed_property()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type T
+    [simple uint 8 payload]
+    [virtual uint 8 commandType 'payload + 1']
+    [optional uint 8 detail 'commandType == 2']
+]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+            Assert.Contains("public byte CommandType =>", code);
+            Assert.Contains("Payload + 1", code);
+            Assert.Contains("var commandType =", code);
+            Assert.Contains("payload + 1", code);
+            Assert.Contains("if ((commandType == 2))", code);
+            Assert.DoesNotContain("byte commandType)", code);
+        }
+
+        [Fact]
+        public void A_virtual_field_can_drive_a_type_switch()
+        {
+            var model = MspecModelBuilder.Build(@"
+[discriminatedType Parent
+    [simple uint 8 payload]
+    [virtual uint 8 commandType 'payload + 1']
+    [typeSwitch commandType
+        ['2' Child
+            [simple uint 8 detail]
+        ]
+    ]
+]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/Parent.cs"];
+
+            Assert.Contains("public byte CommandType =>", code);
+            Assert.Contains("var commandType =", code);
+            Assert.Contains("if (Equals(commandType, (byte) (2)))", code);
+        }
+
+        [Fact]
+        public void Enum_accessors_deduplicate_duplicate_underlying_values()
+        {
+            var model = MspecModelBuilder.Build(@"
+[enum uint 8 TransportSize(uint 8 code)
+    ['0x1C' COUNTER ['1']]
+    ['0x1C' DATE_AND_TIME ['2']]
+]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/TransportSize.cs"];
+
+            Assert.Single(System.Text.RegularExpressions.Regex.Matches(
+                code, "TransportSize\\.(COUNTER|DATE_AND_TIME) =>").Cast<System.Text.RegularExpressions.Match>());
+        }
+
+        [Fact]
+        public void An_optional_field_is_nullable_and_conditional()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type T
+    [simple   uint 16 dataLength]
+    [optional uint 8  extra 'dataLength >= 12']
+]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+            Assert.Contains("public byte? Extra { get; }", code);
+            // parse: only read when the condition holds
+            Assert.Contains("byte? extra = null;", code);
+            Assert.Contains("if ((dataLength >= 12))", code);
+            // serialize / length: guarded by the null check
+            Assert.Contains("if (Extra != null)", code);
+            Assert.Contains("(Extra != null ? 8 : 0)", code);
+        }
+
+        [Fact]
+        public void A_padding_field_loops_the_repeat_count()
+        {
+            var model = MspecModelBuilder.Build(
+                "[type T [simple uint 8 n] [padding uint 8 pad '0x00' 'n % 2']]");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+            Assert.Contains("var _timesPadding = (int) ((n % 2));", code);
+            Assert.Contains("while (_timesPadding-- > 0)", code);
+        }
+
+        [Fact]
+        public void A_vstring_field_uses_its_run_time_length_expression()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type T
+    [simple uint 16 len]
+    [simple vstring 'len * 8' text stringEncoding='""UTF8""']
+]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/T.cs"];
+
+            Assert.Contains("public string Text { get; }", code);
+            Assert.Contains("readBuffer.ReadString(\"text\", (int) ((len * 8)), System.Text.Encoding.UTF8)", code);
+            Assert.Contains("writeBuffer.WriteString(\"text\", (int) ((Len * 8)), \"UTF8\", Text)", code);
+            Assert.Contains("lengthInBits += ((Len * 8));", code);
+        }
+
+        [Fact]
+        public void The_real_modbus_mspec_generates_without_throwing()
+        {
+            // Fail loud rather than passing vacuously outside the repo layout.
+            var repoRoot = RepoPaths.FindRepoRoot();
+            Assert.NotNull(repoRoot);
+            var mspec = Path.Combine(repoRoot!,
+                "protocols", "modbus", "src", "main", "resources",
+                "protocols", "modbus", "modbus.mspec");
+
+            var model = MspecModelBuilder.BuildFile(mspec);
+            var files = new CSharpGenerator(
+                model, "modbus", "org.apache.plc4net.drivers.modbus.readwrite").Generate();
+
+            Assert.True(model.Types.Count > 40);
+            Assert.Contains("model/ModbusADU.cs", files.Keys);
+            Assert.Contains("model/ModbusPDU.cs", files.Keys);
+            Assert.All(files.Values, src => Assert.Contains("DO NOT EDIT", src));
+        }
+
+        // ── dataIo struct cases (KnxDatapoint / KnxProperty) ────
+
+        [Fact]
+        public void A_dataIo_struct_case_bundles_its_fields_into_a_PlcStruct()
+        {
+            var model = MspecModelBuilder.Build(@"
+[dataIo Composite(vstring kind)
+    [typeSwitch kind
+        ['SWITCH' Struct
+            [reserved uint 6 '0x00']
+            [simple   bit    control]
+            [simple   bit    onOff  ]
+        ]
+    ]
+]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/Composite.cs"];
+
+            Assert.DoesNotContain("NotImplementedException", code);
+            Assert.Contains("var _map = new System.Collections.Generic.Dictionary<string, IPlcValue>();", code);
+            Assert.Contains("_map[\"control\"] = new PlcBOOL((bool) control);", code);
+            Assert.Contains("_map[\"onOff\"] = new PlcBOOL((bool) onOff);", code);
+            Assert.Contains("return new PlcStruct(_map);", code);
+            // serialize reads each field back off the struct
+            Assert.Contains("_value.GetValue(\"control\")!.GetBool()", code);
+        }
+
+        [Fact]
+        public void A_dataIo_struct_case_with_a_byte_array_uses_PlcRawByteArray()
+        {
+            var model = MspecModelBuilder.Build(@"
+[dataIo Composite(vstring kind)
+    [typeSwitch kind
+        ['POLL' Struct
+            [array    byte   groupAddress count '2']
+            [simple   bit    disable          ]
+            [reserved uint 3 '0x00'            ]
+            [simple   uint 4 pollingSoftNr     ]
+        ]
+    ]
+]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/Composite.cs"];
+
+            Assert.DoesNotContain("NotImplementedException", code);
+            Assert.Contains("readBuffer.ReadByteArray(\"groupAddress\", (int) (2) * 8)", code);
+            Assert.Contains("_map[\"groupAddress\"] = new PlcRawByteArray(groupAddress);", code);
+            Assert.Contains("writeBuffer.WriteByteArray(\"groupAddress\", _value.GetValue(\"groupAddress\")!.GetRaw())", code);
+            Assert.Contains("lengthInBits += ((2) * 8);", code);
+        }
+
+        [Fact]
+        public void A_dataIo_case_with_no_discriminator_is_the_else_branch()
+        {
+            // KnxProperty ends with a `[* … List]` catch-all; emitting it as
+            // `else if (true)` left an unreachable fallback return (CS0162).
+            var model = MspecModelBuilder.Build(@"
+[dataIo Prop(KnxT kind, uint 8 len)
+    [typeSwitch kind
+        ['A' USINT
+            [simple uint 8 value]
+        ]
+        [       List
+            [array byte value count 'len']
+        ]
+    ]
+]
+[enum uint 8 KnxT ['1' A]]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/Prop.cs"];
+
+            Assert.Contains("else\n", code.Replace("\r\n", "\n"));
+            Assert.DoesNotContain("else if (true)", code);
+            // the fallback return is only for a chain that can fall through
+            var parseBody = code.Split("StaticSerialize")[0];
+            Assert.DoesNotContain("return new PlcNULL();", parseBody);
+        }
+
+        [Fact]
+        public void An_external_enum_is_left_to_the_SPI()
+        {
+            var model = MspecModelBuilder.Build(@"
+[enum PlcValueType external='true']
+[enum uint 8 E(PlcValueType t) ['1' A ['BOOL']]]
+");
+            var files = new CSharpGenerator(model, "demo", "demo.readwrite").Generate();
+
+            Assert.DoesNotContain("model/PlcValueType.cs", files.Keys);
+            // and no accessor that would return the type we cannot see
+            Assert.DoesNotContain("GetT(this E value)", files["model/E.cs"]);
+        }
+
+        [Fact]
+        public void A_hyphenated_enum_id_round_trips_as_a_string_literal()
+        {
+            // 'DPST-1-1' lexes as `DPST - 1 - 1`; a string-typed parameter can
+            // only mean the literal text.
+            var model = MspecModelBuilder.Build(@"
+[enum uint 16 DptId(vstring id, string 8 code) ['1' FIRST ['DPST-1-1', '409']]]
+");
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite").Generate()["model/DptId.cs"];
+
+            Assert.Contains("\"DPST-1-1\"", code);
+            Assert.DoesNotContain("DPST - 1", code);
+            // a bare number for a string parameter is the literal too
+            Assert.Contains("\"409\"", code);
+        }
+
+        [Fact]
+        public void The_real_knx_mspec_fails_loudly_for_an_unsupported_dataIo_shape()
+        {
+            // Fail loud rather than passing vacuously outside the repo layout.
+            var repoRoot = RepoPaths.FindRepoRoot();
+            Assert.NotNull(repoRoot);
+            var knxDir = Path.Combine(repoRoot!,
+                "protocols", "knxnetip", "src", "main");
+            var model = MspecModelBuilder.BuildFiles(
+                Directory.GetFiles(Path.Combine(knxDir, "resources", "protocols", "knxnetip"), "*.mspec")
+                    .Concat(Directory.GetFiles(Path.Combine(knxDir, "generated", "protocols", "knxnetip"), "*.mspec"))
+                    .ToArray());
+            var ex = Assert.Throws<System.NotSupportedException>(() => new CSharpGenerator(
+                model, "knxnetip", "org.apache.plc4net.drivers.knxnetip.readwrite").Generate());
+
+            Assert.Contains("KnxDatapoint", ex.Message);
+            Assert.Contains("TIME", ex.Message);
+        }
+
+        [Fact]
+        public void The_real_s7_mspec_fails_loudly_when_a_static_helper_is_missing()
+        {
+            // s7.mspec's TransportSize has COUNTER and DATE_AND_TIME both at
+            // 0x1C. Both enum members remain declared, while the generated
+            // accessor keeps only the first arm because C# rejects duplicate
+            // switch patterns.
+            var repoRoot = RepoPaths.FindRepoRoot();
+            Assert.NotNull(repoRoot);
+            var model = MspecModelBuilder.BuildFiles(Path.Combine(repoRoot!,
+                "protocols", "s7", "src", "main", "resources", "protocols", "s7", "s7.mspec"));
+            var ex = Assert.Throws<System.NotSupportedException>(() => new CSharpGenerator(
+                model, "s7", "org.apache.plc4net.drivers.s7.readwrite").Generate());
+
+            Assert.Contains("STATIC_CALL", ex.Message);
+            Assert.Contains("EventItemLength", ex.Message);
+        }
+
+        [Fact]
+        public void A_little_endian_byte_order_is_rejected_not_generated_wrong()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type LePacket byteOrder='""LITTLE_ENDIAN""'
+    [simple uint 16 a]
+]
+");
+            var ex = Assert.Throws<System.NotSupportedException>(
+                () => new CSharpGenerator(model, "le", "le.readwrite").Generate());
+            Assert.Contains("LePacket", ex.Message);
+        }
+
+        [Fact]
+        public void A_terminated_array_is_rejected_instead_of_being_emitted_as_a_count_array()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type Packet
+    [array byte payload terminated 'false']
+]
+");
+
+            var ex = Assert.Throws<System.NotSupportedException>(
+                () => new CSharpGenerator(model, "demo", "demo.readwrite").Generate());
+
+            Assert.Contains("terminated array", ex.Message);
+            Assert.Contains("payload", ex.Message);
+        }
+
+        [Fact]
+        public void A_byte_array_with_a_byte_length_uses_the_buffer_bulk_read()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type Packet
+    [simple uint 8 length]
+    [array byte payload length 'length']
+]
+");
+
+            var code = new CSharpGenerator(model, "demo", "demo.readwrite")
+                .Generate()["model/Packet.cs"];
+
+            Assert.Contains("readBuffer.ReadByteArray(\"payload\", (int) (length) * 8)", code);
+            Assert.DoesNotContain("new byte[]()", code);
+            Assert.DoesNotContain("payload.Add", code);
+        }
+
+        [Fact]
+        public void An_unknown_mspec_field_is_rejected_before_source_is_emitted()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type Packet
+    [peek uint 8 marker]
+]
+");
+
+            var ex = Assert.Throws<System.NotSupportedException>(
+                () => new CSharpGenerator(model, "demo", "demo.readwrite").Generate());
+
+            Assert.Contains("unsupported mspec field", ex.Message);
+            Assert.Contains("peek", ex.Message);
+        }
+
+        [Fact]
+        public void Modbus_crc_and_lrc_helpers_are_generated_as_real_implementations()
+        {
+            var repoRoot = RepoPaths.FindRepoRoot();
+            Assert.NotNull(repoRoot);
+            var mspec = Path.Combine(repoRoot!, "protocols", "modbus", "src", "main", "resources",
+                "protocols", "modbus", "modbus.mspec");
+
+            var files = new CSharpGenerator(MspecModelBuilder.BuildFile(mspec), "modbus",
+                "org.apache.plc4net.drivers.modbus.readwrite").Generate();
+            var helper = files["model/ModbusStaticHelper.cs"];
+
+            Assert.Contains("public static ushort RtuCrcCheck(byte address, IMessage pdu)", helper);
+            Assert.Contains("public static ushort AsciiLrcCheck(byte address, IMessage pdu)", helper);
+            Assert.DoesNotContain("NotImplementedException", helper);
+        }
+
+        [Fact]
+        public async System.Threading.Tasks.Task A_generated_modbus_model_compiles_against_the_plc4net_spi()
+        {
+            var repoRoot = RepoPaths.FindRepoRoot();
+            Assert.NotNull(repoRoot);
+            var mspec = Path.Combine(repoRoot!, "protocols", "modbus", "src", "main", "resources",
+                "protocols", "modbus", "modbus.mspec");
+            var files = new CSharpGenerator(MspecModelBuilder.BuildFile(mspec), "modbus",
+                "org.apache.plc4net.drivers.modbus.readwrite").Generate();
+            var temp = Path.Combine(Path.GetTempPath(), "plc4net-codegen-" + Guid.NewGuid().ToString("N"));
+
+            try
+            {
+                foreach (var (relativePath, source) in files)
+                {
+                    var target = Path.Combine(temp, relativePath);
+                    Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+                    File.WriteAllText(target, source);
+                }
+                var spiProject = Path.Combine(repoRoot!, "plc4net", "spi", "spi.csproj");
+                File.WriteAllText(Path.Combine(temp, "generated.csproj"),
+                    "<Project Sdk=\"Microsoft.NET.Sdk\"><PropertyGroup><TargetFramework>net8.0</TargetFramework>"
+                    + "<ImplicitUsings>disable</ImplicitUsings><Nullable>enable</Nullable></PropertyGroup><ItemGroup>"
+                    + $"<ProjectReference Include=\"{SecurityElement.Escape(spiProject)}\" />"
+                    + "</ItemGroup></Project>");
+
+                var start = new ProcessStartInfo("dotnet", "build generated.csproj --ignore-failed-sources --nologo")
+                {
+                    WorkingDirectory = temp,
+                    UseShellExecute = false,
+                };
+                using var process = Process.Start(start)!;
+                var exit = process.WaitForExitAsync();
+                if (await System.Threading.Tasks.Task.WhenAny(exit, System.Threading.Tasks.Task.Delay(60000)) != exit)
+                {
+                    process.Kill(entireProcessTree: true);
+                    await exit;
+                    Assert.Fail("Timed out compiling the generated Modbus model.");
+                }
+                Assert.True(process.ExitCode == 0, "Compiling the generated Modbus model failed.");
+            }
+            finally
+            {
+                if (Directory.Exists(temp))
+                {
+                    Directory.Delete(temp, recursive: true);
+                }
+            }
+        }
+
+        [Fact]
+        public void An_enum_field_keyed_on_a_missing_accessor_is_rejected()
+        {
+            var model = MspecModelBuilder.Build(@"
+[enum uint 8 E (uint 8 code)
+    ['1' A ['1']]
+]
+[type T
+    [enum E f missingKey]
+]
+");
+            var ex = Assert.Throws<System.InvalidOperationException>(
+                () => new CSharpGenerator(model, "t", "t.readwrite").Generate());
+            Assert.Contains("missingKey", ex.Message);
+        }
+
+        [Fact]
+        public void Duplicate_type_names_fail_instead_of_silently_overwriting()
+        {
+            var ex = Assert.Throws<System.InvalidOperationException>(() => MspecModelBuilder.Build(@"
+[type Item
+    [simple uint 8 a]
+]
+[type Item
+    [simple uint 16 b]
+]
+"));
+            Assert.Contains("Item", ex.Message);
+        }
+
+        [Fact]
+        public void An_unparseable_expression_is_reported_in_diagnostics()
+        {
+            var model = MspecModelBuilder.Build(@"
+[type T
+    [array byte data count '10x']
+]
+");
+            Assert.Contains(model.Diagnostics, d => d.Contains("10x"));
+        }
+    }
+}
diff --git a/plc4net/test/code-gen-test/RepoPaths.cs b/plc4net/test/code-gen-test/RepoPaths.cs
new file mode 100644
index 0000000000..bc972f4c3d
--- /dev/null
+++ b/plc4net/test/code-gen-test/RepoPaths.cs
@@ -0,0 +1,53 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.IO;
+
+namespace org.apache.plc4net.test.codegen
+{
+    /// <summary>
+    /// Resolves files in the repository checkout that integration-style tests
+    /// need — the real modbus.mspec, the KNX test-suite XML. The test host's
+    /// working directory is the test output directory, several levels below
+    /// the checkout root, so a hard-coded walk-up depth silently misses the
+    /// root when the layout changes. Walking up until a directory containing
+    /// pom.xml and plc4net/ marks the root is robust to that depth.
+    /// </summary>
+    internal static class RepoPaths
+    {
+        /// <summary>
+        /// Root of the PLC4X checkout, or null when the tests run somewhere
+        /// that is not inside a checkout (e.g. a decompiled source tree).
+        /// </summary>
+        public static string? FindRepoRoot()
+        {
+            var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
+            while (dir != null)
+            {
+                if (File.Exists(Path.Combine(dir.FullName, "pom.xml")) &&
+                    Directory.Exists(Path.Combine(dir.FullName, "plc4net")))
+                {
+                    return dir.FullName;
+                }
+                dir = dir.Parent;
+            }
+            return null;
+        }
+    }
+}
diff --git a/plc4net/test/code-gen-test/TestsuiteRunnerTests.cs b/plc4net/test/code-gen-test/TestsuiteRunnerTests.cs
new file mode 100644
index 0000000000..487c7755a5
--- /dev/null
+++ b/plc4net/test/code-gen-test/TestsuiteRunnerTests.cs
@@ -0,0 +1,94 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.IO;
+using System.Linq;
+using org.apache.plc4net.tools.codegen;
+using Xunit;
+
+namespace org.apache.plc4net.test.codegen
+{
+    /// <summary>
+    /// Verifies that the .NET ParserSerializerTestsuite runner can load and
+    /// decode the same XML test data that plc4j and plc4go consume.
+    /// </summary>
+    public class TestsuiteRunnerTests
+    {
+        [Fact]
+        public void Can_decode_hex_bytes()
+        {
+            var testCase = new ParserSerializerTestcase
+            {
+                Name = "test",
+                RawHex = "010203ff"
+            };
+
+            var bytes = testCase.GetRawBytes();
+
+            Assert.Equal(new byte[] { 0x01, 0x02, 0x03, 0xFF }, bytes);
+        }
+
+        [Fact]
+        public void Whitespace_in_hex_is_ignored()
+        {
+            var testCase = new ParserSerializerTestcase
+            {
+                Name = "test",
+                RawHex = "01 02\n03\r\n04"
+            };
+
+            var bytes = testCase.GetRawBytes();
+
+            Assert.Equal(new byte[] { 0x01, 0x02, 0x03, 0x04 }, bytes);
+        }
+
+        [Fact]
+        public void Empty_raw_produces_empty_bytes()
+        {
+            var empty = new ParserSerializerTestcase { Name = "test", RawHex = "" };
+            var nullHex = new ParserSerializerTestcase { Name = "test", RawHex = null };
+
+            Assert.Empty(empty.GetRawBytes());
+            Assert.Empty(nullHex.GetRawBytes());
+        }
+
+        [Fact]
+        public void Can_load_modbus_testsuite()
+        {
+            // Same test data consumed by plc4j and plc4go. Modbus, not KNX: the
+            // generator tool has no driver-specific test resources of its own,
+            // so this reads the one shared testsuite XML that already exists
+            // on develop independently of any plc4net driver PR.
+            // Fail loud rather than passing vacuously outside the repo layout.
+            var repoRoot = RepoPaths.FindRepoRoot();
+            Assert.NotNull(repoRoot);
+
+            var path = Path.Combine(repoRoot!,
+                "protocols", "modbus", "src", "test", "resources",
+                "protocols", "modbus", "tcp", "ParserSerializerTestsuite.xml");
+
+            var suite = ParserSerializerTestsuiteRunner.Load(path);
+
+            Assert.Equal("Modbus", suite.Name);
+            Assert.Equal("modbus", suite.ProtocolName);
+            Assert.Equal("BIG_ENDIAN", suite.ByteOrder);
+            Assert.True(suite.TestCases.Any());
+        }
+    }
+}
diff --git a/plc4net/tools/code-gen/MspecExpressionParser.cs b/plc4net/tools/code-gen/MspecExpressionParser.cs
index fafd3cc7f3..f2674fa17a 100644
--- a/plc4net/tools/code-gen/MspecExpressionParser.cs
+++ b/plc4net/tools/code-gen/MspecExpressionParser.cs
@@ -65,9 +65,8 @@ public static class MspecExpressionParser
     private static Term Convert(ExpressionParser.ExpressionContext context) => context switch
     {
         ExpressionParser.NumberExpressionContext value => Number(value.Number().GetText()),
-        ExpressionParser.HexExpressionContext value => new IntegerLiteral(
-            long.Parse(value.HexExpression().GetText()[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture),
-            value.HexExpression().GetText()),
+        ExpressionParser.HexExpressionContext value =>
+            new HexadecimalLiteral(value.HexExpression().GetText()),
         ExpressionParser.BoolExpressionContext value =>
             new BooleanLiteral(bool.Parse(value.Bool().GetText())),
         ExpressionParser.NullExpressionContext => new NullLiteral(),
@@ -168,4 +167,4 @@ public sealed class MspecParseException : Exception
     public MspecParseException(string message, Exception innerException) : base(message, innerException)
     {
     }
-}
\ No newline at end of file
+}
diff --git a/plc4net/tools/code-gen/MspecModelBuilder.cs b/plc4net/tools/code-gen/MspecModelBuilder.cs
new file mode 100644
index 0000000000..945784abec
--- /dev/null
+++ b/plc4net/tools/code-gen/MspecModelBuilder.cs
@@ -0,0 +1,571 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using Antlr4.Runtime;
+using Antlr4.Runtime.Misc;
+using org.apache.plc4net.tools.codegen.grammar;
+using org.apache.plc4net.tools.codegen.model;
+using org.apache.plc4net.tools.codegen.model.fields;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen
+{
+    /// <summary>
+    /// Turns a parsed mspec file into the type-model IR
+    /// (<see cref="Protocol"/>). Walks the ANTLR parse tree with the generated
+    /// typed context classes; expressions inside ticks are handed to
+    /// <see cref="MspecExpressionParser"/>.
+    /// </summary>
+    public sealed class MspecModelBuilder
+    {
+        private readonly Protocol _protocol = new Protocol();
+
+        /// <summary>Set by <c>BuildFiles</c> so diagnostics and errors can
+        /// name the mspec file they come from.</summary>
+        private string? _currentFile;
+
+        public static Protocol Build(string mspecContent)
+            => new MspecModelBuilder().Run(MspecReader.Read(mspecContent));
+
+        public static Protocol BuildFile(string path)
+            => BuildFiles(path);
+
+        /// <summary>
+        /// Builds one model from several mspec files - a protocol whose types
+        /// are split across files (knxnetip.mspec references
+        /// <c>KnxPropertyDataType</c> from device-info.mspec) is compiled the
+        /// way the Java plugin does it: every <c>*.mspec</c> in the directory
+        /// as one unit.
+        /// </summary>
+        public static Protocol BuildFiles(params string[] paths) => BuildFiles(true, paths);
+
+        public static Protocol BuildFiles(bool strict, params string[] paths)
+        {
+            var builder = new MspecModelBuilder();
+            foreach (var path in paths)
+            {
+                builder._currentFile = path;
+                builder.RunInto(MspecReader.ReadFile(path, strict));
+            }
+            builder.ResolveEnumReferences();
+            return builder._protocol;
+        }
+
+        private Protocol Run(MSpecParser.FileContext file)
+        {
+            RunInto(file);
+            ResolveEnumReferences();
+            return _protocol;
+        }
+
+        private void RunInto(MSpecParser.FileContext file)
+        {
+            var constants = file.contantsDefinition();
+            if (constants != null)
+            {
+                foreach (var cf in constants.constField())
+                {
+                    _protocol.Constants.Add(new ConstantDeclaration
+                    {
+                        Name = cf.name.GetText(),
+                        // A const's type token is mandatory in the grammar.
+                        Type = BuildTypeReference(cf.type)!,
+                        Value = ParseValueLiteral(cf.expected),
+                    });
+                }
+            }
+
+            foreach (var def in file.complexTypeDefinition())
+            {
+                BuildComplexTypeDefinition(def.complexType());
+            }
+        }
+
+        // ── type definitions ─────────────────────────────────────
+
+        private void BuildComplexTypeDefinition(MSpecParser.ComplexTypeContext ctx)
+        {
+            if (ctx.ENUM() != null)
+            {
+                BuildEnum(ctx);
+                return;
+            }
+            if (ctx.DATAIO() != null)
+            {
+                BuildDataIo(ctx);
+                return;
+            }
+
+            var type = new ComplexTypeDefinition { Name = ctx.name.GetText() };
+            FillArguments(ctx.argumentList(), type.Arguments);
+            FillAttributes(ctx.attributeList(), type.Attributes);
+            foreach (var fd in ctx.fieldDefinition())
+            {
+                AddField(type, fd);
+            }
+            AddTypeDefinition(type);
+        }
+
+        private void BuildEnum(MSpecParser.ComplexTypeContext ctx)
+        {
+            var e = new EnumTypeDefinition
+            {
+                Name = ctx.name.GetText(),
+                BaseType = ctx.dataType() != null ? BuildSimpleType(ctx.dataType()) : null,
+            };
+            FillArguments(ctx.argumentList(), e.Arguments);
+            FillAttributes(ctx.attributeList(), e.Attributes);
+
+            foreach (var v in ctx.enumValueDefinition())
+            {
+                e.Values.Add(new EnumValue
+                {
+                    Name = v.name.Text,
+                    Value = v.valueExpression != null ? ParseExpression(v.valueExpression) : null,
+                    // Every element here comes from a real expression node in
+                    // the constant-value list, never from an absent optional.
+                    ConstantValues = v.constantValueExpressions != null
+                        ? v.constantValueExpressions.expression().Select(e => ParseExpression(e)!).ToList()
+                        : (IReadOnlyList<Term>)System.Array.Empty<Term>(),
+                });
+            }
+            AddTypeDefinition(e);
+        }
+
+        private void BuildDataIo(MSpecParser.ComplexTypeContext ctx)
+        {
+            var dio = new DataIoTypeDefinition { Name = ctx.name.GetText() };
+            FillArguments(ctx.argumentList(), dio.Arguments);
+            FillAttributes(ctx.attributeList(), dio.Attributes);
+
+            var tsCtx = ctx.dataIoDefinition()?.typeSwitchField();
+            if (tsCtx != null)
+            {
+                // dataIo case sub-types are not lifted to top-level types: the
+                // names repeat (CHAR / STRING / TIME each appear twice) and a
+                // dataIo parses to an IPlcValue, not a generated class. They are
+                // kept on the DataIoTypeDefinition for the emitter.
+                dio.TypeSwitch = BuildTypeSwitch(tsCtx, dio.Name, dio.Cases);
+            }
+            AddTypeDefinition(dio);
+        }
+
+        // ── fields ───────────────────────────────────────────────
+
+        private void AddField(ComplexTypeDefinition owner, MSpecParser.FieldDefinitionContext fd)
+        {
+            var f = fd.field();
+            Field field = f switch
+            {
+                _ when f.simpleField() is { } s => new SimpleField
+                {
+                    Name = s.name.GetText(),
+                    Type = BuildTypeReference(s.type),
+                },
+                _ when f.constField() is { } c => new ConstField
+                {
+                    Name = c.name.GetText(),
+                    Type = BuildTypeReference(c.type),
+                    ReferenceValue = ParseValueLiteral(c.expected),
+                },
+                _ when f.implicitField() is { } im => new ImplicitField
+                {
+                    Name = im.name.GetText(),
+                    Type = BuildSimpleType(im.type),
+                    // The serialize expression is mandatory in the grammar.
+                    SerializeExpression = ParseExpression(im.serializeExpression)!,
+                },
+                _ when f.reservedField() is { } r => new ReservedField
+                {
+                    Name = "reserved",
+                    Type = BuildSimpleType(r.type),
+                    // The reserved value literal is mandatory in the grammar.
+                    ReferenceValue = ParseExpression(r.expected)!,
+                },
+                _ when f.discriminatorField() is { } d => new DiscriminatorField
+                {
+                    Name = d.name.GetText(),
+                    Type = BuildTypeReference(d.type),
+                },
+                _ when f.enumField() is { } en => new EnumField
+                {
+                    Name = en.name.GetText(),
+                    Type = BuildTypeReference(en.type),
+                    KeyAccessor = en.fieldName?.GetText(),
+                },
+                _ when f.arrayField() is { } a => new ArrayField
+                {
+                    Name = a.name.GetText(),
+                    Type = BuildTypeReference(a.type),
+                    LoopType = a.loopType.Text.Trim('\'') switch
+                    {
+                        "length" => ArrayField.Loop.Length,
+                        "terminated" => ArrayField.Loop.Terminated,
+                        _ => ArrayField.Loop.Count,
+                    },
+                    // The loop (count / length / terminator) expression is
+                    // mandatory in the grammar.
+                    LoopExpression = ParseExpression(a.loopExpression)!,
+                },
+                _ when f.checksumField() is { } ck => new ChecksumField
+                {
+                    Name = ck.name.GetText(),
+                    Type = BuildSimpleType(ck.type),
+                    ChecksumExpression = ParseExpression(ck.checksumExpression)!,
+                },
+                _ when f.virtualField() is { } vf => new VirtualField
+                {
+                    Name = vf.name.GetText(),
+                    Type = BuildTypeReference(vf.type),
+                    ValueExpression = ParseExpression(vf.valueExpression)!,
+                },
+                _ when f.optionalField() is { } of => new OptionalField
+                {
+                    Name = of.name.GetText(),
+                    Type = BuildTypeReference(of.type),
+                    Condition = of.condition != null ? ParseExpression(of.condition) : null,
+                },
+                // All three manual expressions are mandatory in the grammar.
+                _ when f.manualField() is { } mf => new ManualField
+                {
+                    Name = mf.name.GetText(),
+                    Type = BuildTypeReference(mf.type),
+                    ParseExpression = ParseExpression(mf.parseExpression)!,
+                    SerializeExpression = ParseExpression(mf.serializeExpression)!,
+                    LengthExpression = ParseExpression(mf.lengthExpression)!,
+                },
+                // Both padding expressions are mandatory in the grammar.
+                _ when f.paddingField() is { } pf => new PaddingField
+                {
+                    Name = pf.name.GetText(),
+                    Type = BuildSimpleType(pf.type),
+                    PaddingValue = ParseExpression(pf.paddingValue)!,
+                    TimesPadding = ParseExpression(pf.timesPadding)!,
+                },
+                _ when f.typeSwitchField() is { } ts => BuildTypeSwitch(ts, owner.Name, _protocol.Types),
+                // The field context always has at least the keyword token as
+                // its first child.
+                _ => new UnsupportedField
+                {
+                    Name = "unsupported",
+                    MspecKeyword = f.GetChild(0)?.GetText() ?? f.GetType().Name,
+                    RawText = SourceText(f),
+                },
+            };
+
+            FillAttributes(fd.attributeList(), field.Attributes);
+            ApplyStringEncoding(field);
+            owner.Fields.Add(field);
+        }
+
+        private TypeSwitchField BuildTypeSwitch(
+            MSpecParser.TypeSwitchFieldContext ts, string parentName,
+            List<ComplexTypeDefinition> childSink)
+        {
+            var field = new TypeSwitchField
+            {
+                Name = "typeSwitch",
+                Discriminators = ts.multipleVariableLiterals().variableLiteral()
+                    .Select(v =>
+                    {
+                        try { return MspecExpressionParser.Parse(SourceText(v)); }
+                        catch (MspecParseException) { return new VariableLiteral(SourceText(v)); }
+                    })
+                    .ToList(),
+            };
+
+            foreach (var cs in ts.caseStatement())
+            {
+                var child = new ComplexTypeDefinition
+                {
+                    Name = cs.name.Text,
+                    ParentName = parentName,
+                    DiscriminatorValues = cs.discriminatorValues != null
+                        ? cs.discriminatorValues.expression().Select(ParseExpression).ToList()
+                        : (IReadOnlyList<Term?>)System.Array.Empty<Term>(),
+                };
+                FillArguments(cs.argumentList(), child.Arguments);
+                foreach (var fd in cs.fieldDefinition())
+                {
+                    AddField(child, fd);
+                }
+
+                field.CaseNames.Add(child.Name);
+                if (ReferenceEquals(childSink, _protocol.Types))
+                {
+                    // A lifted child becomes its own output file; a name
+                    // shared with any other type would silently collide.
+                    AddTypeDefinition(child);
+                }
+                else
+                {
+                    // dataIo cases stay local and may repeat names.
+                    childSink.Add(child);
+                }
+            }
+
+            return field;
+        }
+
+        // ── type references ──────────────────────────────────────
+
+        private TypeReference? BuildTypeReference(MSpecParser.TypeReferenceContext ctx)
+        {
+            if (ctx == null)
+            {
+                return null;
+            }
+            if (ctx.simpleTypeReference != null)
+            {
+                return BuildSimpleType(ctx.simpleTypeReference);
+            }
+
+            return new ComplexTypeReference
+            {
+                Name = ctx.complexTypeReference.Text,
+                // Every element here is a real parser-argument expression,
+                // never from an absent optional.
+                Arguments = ctx.@params != null
+                    ? ctx.@params.expression().Select(e => ParseExpression(e)!).ToList()
+                    : (IReadOnlyList<Term>)System.Array.Empty<Term>(),
+            };
+        }
+
+        /// <summary>Registers a type / enum / dataIo, rejecting a name that
+        /// already exists anywhere in the model: output files are keyed by
+        /// type name, so a second definition would silently win the file.</summary>
+        private void AddTypeDefinition(TypeDefinition definition)
+        {
+            var clash = _protocol.Types.FirstOrDefault(t => t.Name == definition.Name)
+                ?? (TypeDefinition?)_protocol.Enums.FirstOrDefault(e => e.Name == definition.Name)
+                ?? _protocol.DataIos.FirstOrDefault(d => d.Name == definition.Name);
+            if (clash != null)
+            {
+                throw new System.InvalidOperationException(
+                    $"duplicate type name '{definition.Name}' in {_currentFile ?? "<unknown file>"}; "
+                    + "output is keyed by type name, so the second definition would overwrite the first");
+            }
+            switch (definition)
+            {
+                case EnumTypeDefinition e:
+                    _protocol.Enums.Add(e);
+                    break;
+                case DataIoTypeDefinition d:
+                    _protocol.DataIos.Add(d);
+                    break;
+                default:
+                    _protocol.Types.Add((ComplexTypeDefinition)definition);
+                    break;
+            }
+        }
+
+        private SimpleTypeReference BuildSimpleType(MSpecParser.DataTypeContext dt)
+        {
+            var baseText = dt.@base.Text;
+            var size = dt.size != null
+                ? int.Parse(dt.size.Text, System.Globalization.CultureInfo.InvariantCulture)
+                : 0;
+
+            var (baseType, bits) = baseText switch
+            {
+                "bit" => (SimpleTypeReference.Base.Bit, 1),
+                "byte" => (SimpleTypeReference.Base.Byte, 8),
+                "uint" => (SimpleTypeReference.Base.UInt, size),
+                "int" => (SimpleTypeReference.Base.Int, size),
+                "float" => (SimpleTypeReference.Base.Float, size),
+                "ufloat" => (SimpleTypeReference.Base.UFloat, size),
+                "string" => (SimpleTypeReference.Base.String, size),
+                "vstring" => (SimpleTypeReference.Base.VString, size),
+                "time" => (SimpleTypeReference.Base.Time, 32),
+                "date" => (SimpleTypeReference.Base.Date, 32),
+                "dateTime" => (SimpleTypeReference.Base.DateTime, 64),
+                _ => throw new System.NotSupportedException(
+                    $"base type '{baseText}' is not modelled (vint/vuint have no C# mapping yet)"),
+            };
+
+            return new SimpleTypeReference
+            {
+                BaseType = baseType,
+                SizeInBits = bits,
+                // `vstring 'expr'` - the length in bits is a run-time expression
+                // (ADS: `vstring 'stringLength * 8' value`).
+                LengthExpression = baseType == SimpleTypeReference.Base.VString && dt.length != null
+                    ? ParseExpression(dt.length)
+                    : null,
+            };
+        }
+
+        /// <summary>Promotes a <see cref="ComplexTypeReference"/> to an
+        /// <see cref="EnumTypeReference"/> once every enum name is known.</summary>
+        private void ResolveEnumReferences()
+        {
+            var enums = _protocol.Enums.ToDictionary(e => e.Name);
+
+            EnumTypeReference? Promote(ComplexTypeReference c) =>
+                enums.TryGetValue(c.Name, out var e)
+                    ? new EnumTypeReference
+                    {
+                        Name = e.Name,
+                        BaseType = e.BaseType
+                            ?? new SimpleTypeReference { BaseType = SimpleTypeReference.Base.UInt, SizeInBits = 8 },
+                    }
+                    : null;
+
+            void Fix(System.Func<TypeReference> get, System.Action<TypeReference> set)
+            {
+                if (get() is ComplexTypeReference c && Promote(c) is { } er)
+                {
+                    set(er);
+                }
+            }
+
+            foreach (var t in _protocol.Types)
+            {
+                foreach (var a in t.Arguments)
+                {
+                    Fix(() => a.Type, v => a.Type = v);
+                }
+                // Field.Type is nullable (null for typeSwitch), unlike
+                // Argument.Type above, so it is handled separately rather
+                // than widening Fix's own delegate types for one caller.
+                foreach (var f in t.Fields)
+                {
+                    if (f.Type is ComplexTypeReference fc && Promote(fc) is { } fer)
+                    {
+                        f.Type = fer;
+                    }
+                }
+            }
+            foreach (var en in _protocol.Enums)
+            {
+                foreach (var a in en.Arguments)
+                {
+                    Fix(() => a.Type, v => a.Type = v);
+                }
+            }
+            foreach (var dio in _protocol.DataIos)
+            {
+                foreach (var a in dio.Arguments)
+                {
+                    Fix(() => a.Type, v => a.Type = v);
+                }
+            }
+            foreach (var c in _protocol.Constants)
+            {
+                Fix(() => c.Type, v => c.Type = v);
+            }
+        }
+
+        // ── expressions, attributes, arguments ───────────────────
+
+        private Term? ParseExpression(MSpecParser.ExpressionContext exprCtx)
+        {
+            if (exprCtx == null)
+            {
+                return null;
+            }
+
+            var raw = SourceText(exprCtx).Trim();
+            if (raw == "*")
+            {
+                return new VariableLiteral("*");
+            }
+
+            var text = raw.Length >= 2 && raw[0] == '\'' && raw[^1] == '\''
+                ? raw.Substring(1, raw.Length - 2)
+                : raw;
+
+            try
+            {
+                return MspecExpressionParser.Parse(text);
+            }
+            catch (MspecParseException)
+            {
+                // Expression positions (count/length/serialize/...) never
+                // legitimately hold prose, so a failure here is a broken
+                // mspec; record it rather than silently emitting a string
+                // literal that breaks generated code far from the cause.
+                _protocol.Diagnostics.Add(
+                    $"{_currentFile ?? "<model>"}: expression '{text}' did not parse; kept as a string literal");
+                return new StringLiteral(text);
+            }
+        }
+
+        private Term ParseValueLiteral(MSpecParser.ValueLiteralContext ctx)
+        {
+            var text = SourceText(ctx).Trim();
+            try
+            {
+                return MspecExpressionParser.Parse(text.Trim('\''));
+            }
+            catch (MspecParseException)
+            {
+                // Deliberate: value-table cells legitimately hold quoted
+                // strings that are not expressions (KNX's 'DPST-1-1').
+                return new StringLiteral(text);
+            }
+        }
+
+        private void FillArguments(MSpecParser.ArgumentListContext list, List<Argument> target)
+        {
+            if (list == null)
+            {
+                return;
+            }
+            foreach (var a in list.argument())
+            {
+                target.Add(new Argument
+                {
+                    Name = a.name.GetText(),
+                    // A parser argument's type token is mandatory in the grammar.
+                    Type = BuildTypeReference(a.type)!,
+                });
+            }
+        }
+
+        private void FillAttributes(
+            MSpecParser.AttributeListContext list, Dictionary<string, Term> target)
+        {
+            if (list == null)
+            {
+                return;
+            }
+            foreach (var attr in list.attribute())
+            {
+                // An attribute's value is mandatory in the grammar (name=value).
+                target[attr.name.Text] = ParseExpression(attr.value)!;
+            }
+        }
+
+        private static void ApplyStringEncoding(Field field)
+        {
+            if (field.Type is SimpleTypeReference s
+                && field.Attributes.TryGetValue("stringEncoding", out var enc)
+                && enc is StringLiteral sl)
+            {
+                s.Encoding = sl.Value;
+            }
+        }
+
+        private static string SourceText(ParserRuleContext ctx) =>
+            ctx.Start.InputStream.GetText(Interval.Of(ctx.Start.StartIndex, ctx.Stop.StopIndex));
+    }
+}
diff --git a/plc4net/tools/code-gen/MspecReader.cs b/plc4net/tools/code-gen/MspecReader.cs
new file mode 100644
index 0000000000..344f6a7aae
--- /dev/null
+++ b/plc4net/tools/code-gen/MspecReader.cs
@@ -0,0 +1,125 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.IO;
+using System.Linq;
+using Antlr4.Runtime;
+using org.apache.plc4net.tools.codegen.grammar;
+
+namespace org.apache.plc4net.tools.codegen
+{
+    /// <summary>
+    /// Parses an mspec protocol description file into an ANTLR parse tree.
+    /// This is the first stage of the pure-.NET mspec toolchain: the grammar
+    /// layer that turns text into a parse tree. The type model layer (the
+    /// next stage) walks the tree with a listener.
+    /// </summary>
+    public static class MspecReader
+    {
+        /// <summary>
+        /// Parses the content of an mspec file and returns the parse tree root.
+        /// </summary>
+        /// <summary>
+        /// The empty-string literal <c>''</c> - which the shared expression
+        /// grammar's <c>TICK innerExpression TICK</c> rule cannot match (the
+        /// inner is not optional) - rewritten to a sentinel the model builder
+        /// maps back to <c>""</c>. It appears only in knx-master-data.mspec's
+        /// <c>KnxDatapointType</c> table, for the IEC-type rows with no DPST id.
+        /// </summary>
+        public const string EmptyStringSentinel = "__plc4net_empty_string__";
+
+        public static MSpecParser.FileContext Read(string mspecContent, bool strict = true)
+        {
+            mspecContent = mspecContent.Replace("''", "'" + EmptyStringSentinel + "'");
+            var inputStream = new AntlrInputStream(mspecContent);
+            var lexer = new MSpecLexer(inputStream);
+            var lexerErrors = new LexerErrorListener();
+            lexer.RemoveErrorListeners();
+            lexer.AddErrorListener(lexerErrors);
+            var tokenStream = new CommonTokenStream(lexer);
+            var parser = new MSpecParser(tokenStream);
+
+            // Collect errors rather than printing to stderr.
+            var errorListener = new CollectingErrorListener();
+            parser.RemoveErrorListeners();
+            parser.AddErrorListener(errorListener);
+
+            var tree = parser.file();
+
+            var errors = lexerErrors.Errors.Concat(errorListener.Errors).ToArray();
+            if (errors.Length != 0 && strict)
+            {
+                throw new MspecParseException(
+                    $"Failed to parse mspec: {string.Join("; ", errors)}");
+            }
+
+            // Non-strict: ANTLR's error recovery has already skipped the bad
+            // tokens and produced a best-effort tree. The Java MessageFormatParser
+            // behaves the same on knx-master-data.mspec (it logs the same
+            // "mismatched input" lines to stderr and carries on).
+            LastErrors = errors;
+            return tree;
+        }
+
+        /// <summary>Syntax errors from the most recent non-strict
+        /// <see cref="Read(string, bool)"/>, for a caller that wants to report them.</summary>
+        public static System.Collections.Generic.IReadOnlyList<string> LastErrors { get; private set; }
+            = System.Array.Empty<string>();
+
+        /// <summary>
+        /// Parses an mspec file at the given path.
+        /// </summary>
+        public static MSpecParser.FileContext ReadFile(string filePath, bool strict = true)
+        {
+            return Read(File.ReadAllText(filePath), strict);
+        }
+    }
+
+    /// <summary>
+    /// Collects syntax errors rather than printing them.
+    /// </summary>
+    internal class CollectingErrorListener : BaseErrorListener
+    {
+        private readonly System.Collections.Generic.List<string> _errors
+            = new System.Collections.Generic.List<string>();
+
+        public System.Collections.Generic.IReadOnlyList<string> Errors => _errors;
+
+        public bool HasErrors => _errors.Count > 0;
+
+        public override void SyntaxError(TextWriter output, IRecognizer recognizer,
+            IToken offendingSymbol, int line, int charPositionInLine,
+            string msg, RecognitionException e)
+        {
+            _errors.Add($"line {line}:{charPositionInLine} {msg}");
+        }
+    }
+
+    internal sealed class LexerErrorListener : IAntlrErrorListener<int>
+    {
+        public System.Collections.Generic.List<string> Errors { get; } = new System.Collections.Generic.List<string>();
+
+        public void SyntaxError(TextWriter output, IRecognizer recognizer, int offendingSymbol,
+            int line, int charPositionInLine, string msg, RecognitionException e)
+        {
+            Errors.Add($"line {line}:{charPositionInLine} {msg}");
+        }
+    }
+
+}
diff --git a/plc4net/tools/code-gen/ParserSerializerTestsuiteRunner.cs b/plc4net/tools/code-gen/ParserSerializerTestsuiteRunner.cs
new file mode 100644
index 0000000000..07fa84ee77
--- /dev/null
+++ b/plc4net/tools/code-gen/ParserSerializerTestsuiteRunner.cs
@@ -0,0 +1,148 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Xml.Linq;
+
+namespace org.apache.plc4net.tools.codegen
+{
+    /// <summary>
+    /// .NET executor for PLC4X ParserSerializerTestsuite XML files.
+    /// Mirrors the Java <c>ParserSerializerTestsuiteRunner</c> and Go
+    /// <c>ParserSerializerTestRunner</c>: each reads the same XML test
+    /// data and runs it against their own language's generated code.
+    /// </summary>
+    /// <remarks>
+    /// The XML schema lives at
+    /// https://plc4x.apache.org/schemas/parser-serializer-testsuite.xsd
+    /// and is shared across all languages. This runner loads the XML,
+    /// decodes the hex test vectors, and exposes them as a typed model
+    /// so that a protocol-specific test harness can feed them into the
+    /// generated parse/serialize code.
+    /// </remarks>
+    public class ParserSerializerTestsuiteRunner
+    {
+        /// <summary>
+        /// Loads a testsuite XML file.
+        /// </summary>
+        public static ParserSerializerTestsuite Load(string filePath)
+        {
+            var doc = XDocument.Load(filePath);
+            var ns = doc.Root?.GetDefaultNamespace() ?? XNamespace.None;
+            var testNamespace = ns.NamespaceName.Contains("parser-serializer")
+                ? ns
+                : XNamespace.Get("https://plc4x.apache.org/schemas/parser-serializer-testsuite.xsd");
+
+            var root = doc.Root;
+            if (root == null)
+            {
+                throw new InvalidDataException("Empty testsuite file.");
+            }
+
+            var suite = new ParserSerializerTestsuite
+            {
+                Name = root.Element("name")?.Value,
+                ProtocolName = root.Element("protocol-name")?.Value,
+                OutputFlavor = root.Element("output-flavor")?.Value,
+                ByteOrder = root.Attribute("byteOrder")?.Value ?? "BIG_ENDIAN",
+            };
+
+            foreach (var caseEl in root.Elements("testcase"))
+            {
+                var testCase = new ParserSerializerTestcase
+                {
+                    Name = caseEl.Element("name")?.Value ?? "unnamed",
+                    RawHex = caseEl.Element("raw")?.Value,
+                    RootType = caseEl.Element("root-type")?.Value,
+                    // The expected parsed tree. Leaf elements carry dataType /
+                    // bitLength attributes and the value as text; container
+                    // elements name the (possibly discriminated) type.
+                    ExpectedXml = caseEl.Element("xml")?.Elements().FirstOrDefault(),
+                };
+
+                // Collect expected values from the <parser-arguments> element.
+                var args = caseEl.Element("parser-arguments");
+                if (args != null)
+                {
+                    foreach (var arg in args.Elements())
+                    {
+                        testCase.ParserArguments[arg.Name.LocalName] = arg.Value;
+                    }
+                }
+
+                suite.TestCases.Add(testCase);
+            }
+
+            return suite;
+        }
+    }
+
+    /// <summary>
+    /// The typed content of a ParserSerializerTestsuite.xml.
+    /// </summary>
+    public class ParserSerializerTestsuite
+    {
+        public string? Name { get; set; }
+        public string? ProtocolName { get; set; }
+        public string? OutputFlavor { get; set; }
+        public required string ByteOrder { get; set; }
+
+        public List<ParserSerializerTestcase> TestCases { get; set; }
+            = new List<ParserSerializerTestcase>();
+    }
+
+    /// <summary>
+    /// A single test case: raw hex bytes and the expected parsed field values.
+    /// </summary>
+    public class ParserSerializerTestcase
+    {
+        public required string Name { get; set; }
+        public string? RawHex { get; set; }
+        public string? RootType { get; set; }
+
+        /// <summary>The <c>&lt;xml&gt;</c> body's single child - the expected
+        /// parsed tree.</summary>
+        public XElement? ExpectedXml { get; set; }
+
+        /// <summary>Parser argument name → value.</summary>
+        public Dictionary<string, string> ParserArguments { get; set; }
+            = new Dictionary<string, string>();
+
+        /// <summary>Decodes <see cref="RawHex"/> into bytes.</summary>
+        public byte[] GetRawBytes()
+        {
+            if (string.IsNullOrEmpty(RawHex))
+            {
+                return System.Array.Empty<byte>();
+            }
+            var hex = RawHex.Replace(" ", "").Replace("\n", "").Replace("\r", "");
+            if (hex.Length % 2 != 0)
+                throw new System.FormatException(
+                    $"Hex string has odd length ({hex.Length}); must be even. Raw: '{RawHex}'");
+            var bytes = new byte[hex.Length / 2];
+            for (var i = 0; i < bytes.Length; i++)
+            {
+                bytes[i] = System.Convert.ToByte(hex.Substring(i * 2, 2), 16);
+            }
+            return bytes;
+        }
+    }
+}
diff --git a/plc4net/tools/code-gen/Program.cs b/plc4net/tools/code-gen/Program.cs
new file mode 100644
index 0000000000..80f9622986
--- /dev/null
+++ b/plc4net/tools/code-gen/Program.cs
@@ -0,0 +1,139 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using org.apache.plc4net.tools.codegen;
+using org.apache.plc4net.tools.codegen.output;
+
+// The pure-.NET mspec -> C# generator. Replaces the `update-generated-code`
+// Maven profile: no JDK, no freemarker.
+//
+//   dotnet run --project plc4net/tools/code-gen -- \
+//     <protocol> <mspec-source[;mspec-source...]> <output-dir> [namespace]
+//
+// <output-dir> receives model/*.cs. It is wiped of *.cs under model/ first so
+// a removed mspec type does not leave a stale file behind.
+
+if (args.Length < 3)
+{
+    Console.Error.WriteLine(
+        "usage: plc4net-code-gen <protocol> <mspec-source[;mspec-source...]> <output-dir> [namespace]");
+    return 2;
+}
+
+var protocol = args[0];
+var mspecPath = args[1];
+var outDir = args[2];
+var ns = args.Length > 3
+    ? args[3]
+    : $"org.apache.plc4net.drivers.{protocol.Replace("-", "")}.readwrite";
+
+// A protocol's types can be split across files and directories (knxnetip
+// spans knxnetip.mspec, device-info.mspec and the generated
+// knx-master-data.mspec). The source argument is one or more ';'-separated
+// paths, each a .mspec file or a directory of them; every *.mspec found is
+// compiled as one unit, the way the Java plugin does.
+var mspecFiles = new List<string>();
+foreach (var part in mspecPath.Split(';', StringSplitOptions.RemoveEmptyEntries))
+{
+    if (Directory.Exists(part))
+    {
+        mspecFiles.AddRange(Directory.GetFiles(part, "*.mspec"));
+    }
+    else if (File.Exists(part))
+    {
+        // An explicit file means exactly that file - the siblings are
+        // independent protocols that merely share a directory
+        // (ads.mspec / ads-discovery.mspec both define AmsNetId).
+        mspecFiles.Add(Path.GetFullPath(part));
+    }
+    else
+    {
+        Console.Error.WriteLine($"mspec source not found: {part}");
+        return 2;
+    }
+}
+
+var sorted = mspecFiles.Distinct().OrderBy(p => p, StringComparer.Ordinal).ToArray();
+if (sorted.Length == 0)
+{
+    Console.Error.WriteLine($"no .mspec files found for '{mspecPath}'");
+    return 2;
+}
+var model = MspecModelBuilder.BuildFiles(sorted);
+foreach (var diagnostic in model.Diagnostics)
+{
+    Console.Error.WriteLine($"warning: {diagnostic}");
+}
+var files = new CSharpGenerator(model, protocol, ns).Generate();
+
+// Stage every source file before replacing the current model directory. This
+// keeps a previously generated driver intact if a disk/write failure occurs.
+Directory.CreateDirectory(outDir);
+var stageRoot = Path.Combine(outDir, $".plc4net-code-gen-{Guid.NewGuid():N}");
+var stageModelDir = Path.Combine(stageRoot, "model");
+var modelDir = Path.Combine(outDir, "model");
+var backupDir = Path.Combine(outDir, $".plc4net-code-gen-backup-{Guid.NewGuid():N}");
+
+try
+{
+    foreach (var (relativePath, source) in files)
+    {
+        var target = Path.Combine(stageRoot, relativePath);
+        Directory.CreateDirectory(Path.GetDirectoryName(target)!);
+        File.WriteAllText(target, source);
+    }
+
+    if (Directory.Exists(modelDir))
+    {
+        Directory.Move(modelDir, backupDir);
+    }
+    try
+    {
+        Directory.Move(stageModelDir, modelDir);
+    }
+    catch
+    {
+        if (Directory.Exists(backupDir) && !Directory.Exists(modelDir))
+        {
+            Directory.Move(backupDir, modelDir);
+        }
+        throw;
+    }
+
+    if (Directory.Exists(backupDir))
+    {
+        Directory.Delete(backupDir, recursive: true);
+    }
+}
+finally
+{
+    if (Directory.Exists(stageRoot))
+    {
+        Directory.Delete(stageRoot, recursive: true);
+    }
+}
+
+Console.WriteLine(
+    $"{protocol}: {model.Types.Count} types, {model.Enums.Count} enums, " +
+    $"{model.DataIos.Count} dataIo, {files.Count} files -> {outDir}");
+return 0;
diff --git a/plc4net/tools/code-gen/README.md b/plc4net/tools/code-gen/README.md
new file mode 100644
index 0000000000..05be862473
--- /dev/null
+++ b/plc4net/tools/code-gen/README.md
@@ -0,0 +1,101 @@
+<!--
+  Licensed to the Apache Software Foundation (ASF) under one
+  or more contributor license agreements.  See the NOTICE file
+  distributed with this work for additional information
+  regarding copyright ownership.  The ASF licenses this file
+  to you under the Apache License, Version 2.0 (the
+  "License"); you may not use this file except in compliance
+  with the License.  You may obtain a copy of the License at
+
+      https://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing,
+  software distributed under the License is distributed on an
+  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  KIND, either express or implied.  See the License for the
+  specific language governing permissions and limitations
+  under the License.
+  -->
+# plc4net-code-gen
+
+The pure-.NET `.mspec` → C# generator. Parses `.mspec` with the checked-in
+ANTLR-for-C# lexer/parser, walks the tree into a type-model IR, and emits the
+model class, `StaticParse`, `Serialize` and `GetLengthInBits` per type — the
+replacement for the Java freemarker `model-template` / `io-template` (whose
+C# `io-template` was never migrated, so it only ever produced data classes).
+
+## Run
+
+```bash
+dotnet run --project plc4net/tools/code-gen -c Release -- \
+  <protocol> <mspec-source[;mspec-source...]> <output-dir> [namespace]
+```
+
+Each source can be one `.mspec` file or a directory containing `.mspec`
+files. Separate multiple sources with semicolons.
+
+Generation is staged and then replaces `<output-dir>/model`, so a failed
+write leaves the previous generated model intact. Regenerating these protocol model files is an explicit
+driver-maintenance step; the driver slices that consume them commit the
+result. This is separate from the checked-in ANTLR parser artifacts below,
+whose reproducibility is enforced by CI.
+
+## Supported subset and fail-fast behavior
+
+The generator only emits a protocol when every field has a known C# wire
+mapping. It deliberately fails before writing output for unsupported field
+keywords, terminated arrays, unsupported data-IO shapes, unsupported temporal
+primitives, non-big-endian byte order, and unimplemented `STATIC_CALL`
+targets. This prevents a successful generation from silently changing a wire
+layout or deferring a missing implementation to production.
+
+Counted and byte-length arrays are supported; byte arrays with a byte-length
+use the buffer bulk-read path. Modbus `rtuCrcCheck` and `asciiLrcCheck` are
+generated as concrete helpers. Other protocol-specific static helpers remain
+an explicit generator gap and cause a diagnostic failure until implemented.
+
+## Grammars and the checked-in parsers
+
+`src/generated/` holds the ANTLR 4.13.2 output (lexer, parser, listener and
+`.tokens` side-cars) for the two mspec grammars in
+`code-generation/protocol-base-mspec/src/main/antlr4/org/apache/plc4x/plugins/codegenerator/language/mspec/`:
+`MSpec.g4` and `expression/Expression.g4`. plc4net keeps no copy of them; they
+are used exactly as they are upstream, the same files the Java toolchain
+compiles. The output is **checked in**, not generated at build time —
+day-to-day work needs no JDK and no ANTLR tool.
+
+ANTLR pastes the text of a semantic predicate into the generated code
+unchanged, and the lexer's `EmptyLine` rule spells its predicate the way the
+Java runtime does, `{getCharPositionInLine() == 0}?`. The generated
+`MSpecLexer` is a `partial` class, so `src/MSpecLexer.Predicates.cs` supplies
+that method (it returns the C# runtime's `Column`) and the grammar needs no
+C# port; without that file the generated lexer does not compile.
+`MSpecLexerTests` pins the behaviour.
+
+### Regenerating the parsers
+
+This requires Python 3 and a Java runtime. The script downloads the pinned
+ANTLR archive; it does not require a manually installed ANTLR command.
+
+```bash
+python plc4net/tools/code-gen/generate_parsers.py
+python plc4net/tools/code-gen/generate_parsers.py --check
+```
+
+The script downloads the pinned ANTLR archive into the user cache, verifies
+its SHA-256 checksum, applies the ASF header and repository line-ending rules,
+and omits ANTLR's debug-only `.interp` files. The `--check` mode regenerates in
+a temporary directory and fails when the checked-in artifacts drift. The .NET
+workflow runs that check whenever PLC4NET or either shared grammar changes.
+
+## Layout
+
+| path                         | role                                             |
+|------------------------------|--------------------------------------------------|
+| `MspecReader.cs`             | text → ANTLR parse tree                           |
+| `MspecModelBuilder.cs`       | parse tree → `model/` IR                          |
+| `MspecExpressionParser.cs`   | a quoted mspec expression → `model/terms/` tree   |
+| `model/`                     | the type-model IR                                 |
+| `output/CSharpGenerator.cs`  | IR → C# (class + parse / serialize / length)      |
+| `output/CSharpExpressionRenderer.cs` | `Term` → a C# expression                  |
+| `Program.cs`                 | the CLI                                           |
diff --git a/plc4net/tools/code-gen/code-gen.csproj b/plc4net/tools/code-gen/code-gen.csproj
index c72ab2768b..7aad9c679e 100644
--- a/plc4net/tools/code-gen/code-gen.csproj
+++ b/plc4net/tools/code-gen/code-gen.csproj
@@ -20,7 +20,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <OutputType>Library</OutputType>
+    <OutputType>Exe</OutputType>
     <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
     <AssemblyName>plc4net-code-gen</AssemblyName>
     <Title>PLC4Net: Code Generation</Title>
diff --git a/plc4net/tools/code-gen/model/Definitions.cs b/plc4net/tools/code-gen/model/Definitions.cs
new file mode 100644
index 0000000000..0ec990074f
--- /dev/null
+++ b/plc4net/tools/code-gen/model/Definitions.cs
@@ -0,0 +1,199 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.model
+{
+    /// <summary>A top-level named type in an mspec file.</summary>
+    public abstract class TypeDefinition
+    {
+        public required string Name { get; set; }
+
+        /// <summary>Attributes on the type header, e.g.
+        /// <c>byteOrder='"BIG_ENDIAN"'</c>.</summary>
+        public Dictionary<string, Term> Attributes { get; } = new Dictionary<string, Term>();
+
+        public Term? GetAttribute(string name) =>
+            Attributes.TryGetValue(name, out var v) ? v : null;
+    }
+
+    /// <summary>A parser argument in a type header: <c>(bit response)</c>.</summary>
+    public sealed class Argument
+    {
+        public required string Name { get; set; }
+        public required TypeReference Type { get; set; }
+    }
+
+    /// <summary>
+    /// A structured type - a <c>[type]</c> or a <c>[discriminatedType]</c>.
+    /// A discriminated parent is the one whose fields include a
+    /// <see cref="fields.TypeSwitchField"/>; its children are also
+    /// <see cref="ComplexTypeDefinition"/> instances, linked by
+    /// <see cref="ParentName"/> / <see cref="DiscriminatorValues"/>.
+    /// </summary>
+    public sealed class ComplexTypeDefinition : TypeDefinition
+    {
+        public List<Argument> Arguments { get; } = new List<Argument>();
+
+        public List<fields.Field> Fields { get; } = new List<fields.Field>();
+
+        // ── discriminated-type wiring ────────────────────────────
+
+        /// <summary>Set on a child type: the parent it extends.</summary>
+        public string? ParentName { get; set; }
+
+        /// <summary>Set on a child type: the case values, positionally matched
+        /// against the parent's <see cref="fields.TypeSwitchField.Discriminators"/>.
+        /// A null entry is a wildcard (the case listed fewer values).</summary>
+        public IReadOnlyList<Term?>? DiscriminatorValues { get; set; }
+
+        public bool IsDiscriminatedParent =>
+            Fields.Any(f => f is fields.TypeSwitchField);
+
+        public bool IsDiscriminatedChild => ParentName != null;
+
+        public fields.TypeSwitchField? TypeSwitch =>
+            Fields.OfType<fields.TypeSwitchField>().FirstOrDefault();
+
+        /// <summary>The fields that hold a value and therefore get a property
+        /// and a constructor parameter (everything except const / implicit /
+        /// reserved / virtual / typeSwitch and the abstract markers).</summary>
+        public IEnumerable<fields.Field> PropertyFields =>
+            Fields.Where(f => f.IsProperty);
+
+        /// <summary>
+        /// On a discriminated parent: the property fields declared before the
+        /// <c>typeSwitch</c>. A child inherits these - the parent reads and
+        /// writes them, and passes them to the child's <c>StaticParse</c> and
+        /// on to <c>base(...)</c>.
+        /// </summary>
+        public IEnumerable<fields.Field> PrefixPropertyFields =>
+            Fields.TakeWhile(f => f is not fields.TypeSwitchField).Where(f => f.IsProperty);
+
+        /// <summary>
+        /// On a discriminated parent: the property fields declared after the
+        /// <c>typeSwitch</c> - a shared trailer (S7's COTPPacket parameters /
+        /// payload). The child reads and writes these, after its own body.
+        /// </summary>
+        public IEnumerable<fields.Field> SuffixPropertyFields =>
+            Fields.SkipWhile(f => f is not fields.TypeSwitchField).Skip(1).Where(f => f.IsProperty);
+
+        /// <summary>
+        /// On a discriminated parent: the pre-<c>typeSwitch</c> fields that
+        /// carry a value a child might reference in an expression - property
+        /// fields plus discriminators, implicit and const fields. The parent
+        /// reads them and passes them to the child's <c>StaticParse</c>.
+        /// </summary>
+        public IEnumerable<fields.Field> PrefixContextFields =>
+            Fields.TakeWhile(f => f is not fields.TypeSwitchField)
+                  .Where(f => f.Name != null
+                      && f is not fields.ReservedField);
+
+        /// <summary>Pre-<c>typeSwitch</c> implicit fields - a child recomputes
+        /// these at the top of its serialize / length.</summary>
+        public IEnumerable<fields.ImplicitField> PrefixImplicitFields =>
+            Fields.TakeWhile(f => f is not fields.TypeSwitchField).OfType<fields.ImplicitField>();
+
+        /// <summary>All fields up to and including the <c>typeSwitch</c> - what
+        /// a discriminated parent's own <c>StaticParse</c> / <c>Serialize</c>
+        /// covers.</summary>
+        public IEnumerable<fields.Field> FieldsThroughSwitch
+        {
+            get
+            {
+                foreach (var f in Fields)
+                {
+                    yield return f;
+                    if (f is fields.TypeSwitchField)
+                    {
+                        yield break;
+                    }
+                }
+            }
+        }
+
+        /// <summary>The fields after the <c>typeSwitch</c> in declaration
+        /// order (a superset of <see cref="SuffixPropertyFields"/> - includes
+        /// reserved / const trailers too).</summary>
+        public IEnumerable<fields.Field> FieldsAfterSwitch =>
+            Fields.SkipWhile(f => f is not fields.TypeSwitchField).Skip(1);
+
+        public IEnumerable<fields.ConstField> ConstFields => Fields.OfType<fields.ConstField>();
+    }
+
+    /// <summary>An <c>[enum]</c> type.</summary>
+    public sealed class EnumTypeDefinition : TypeDefinition
+    {
+        /// <summary>The wire type: <c>uint 8</c> for
+        /// <c>[enum uint 8 ModbusErrorCode ...]</c>. Null for a bare
+        /// <c>[enum Name ...]</c> (rare; treated as an int).</summary>
+        public SimpleTypeReference? BaseType { get; set; }
+
+        /// <summary>Per-constant parameters: <c>(uint 8 dataTypeSize)</c>.</summary>
+        public List<Argument> Arguments { get; } = new List<Argument>();
+
+        public List<EnumValue> Values { get; } = new List<EnumValue>();
+
+        /// <summary><c>[enum PlcValueType external='true']</c>: the type lives
+        /// outside the mspec (hand-written in the SPI). The generator emits no
+        /// file for it and skips any accessor that would return it.</summary>
+        public bool IsExternal =>
+            GetAttribute("external") is terms.BooleanLiteral { Value: true }
+            || GetAttribute("external") is terms.StringLiteral { Value: "true" };
+    }
+
+    /// <summary>One constant in an enum.</summary>
+    public sealed class EnumValue
+    {
+        public required string Name { get; set; }
+
+        /// <summary>The wire value, e.g. <c>0x02</c>. Null when the mspec
+        /// left it out (the constant then just names a slot).</summary>
+        public Term? Value { get; set; }
+
+        /// <summary>Values for the enum's <see cref="EnumTypeDefinition.Arguments"/>,
+        /// positionally: <c>['1' BOOL ['2']]</c> gives <c>dataTypeSize = 2</c>.</summary>
+        public IReadOnlyList<Term> ConstantValues { get; set; } = System.Array.Empty<Term>();
+    }
+
+    /// <summary>
+    /// A <c>[dataIo]</c> type - parses to / serializes from an
+    /// <c>IPlcValue</c> rather than a generated class. The <c>typeSwitch</c>
+    /// picks the wire layout from the parser arguments (S7's <c>DataItem</c>
+    /// keys on <c>dataProtocolId</c>); each case yields one <c>PlcValue</c>.
+    /// </summary>
+    public sealed class DataIoTypeDefinition : TypeDefinition
+    {
+        public List<Argument> Arguments { get; } = new List<Argument>();
+
+        /// <summary>Set only when the dataIo actually has a <c>typeSwitch</c>
+        /// (always true in practice, but the builder sets this after
+        /// construction, so it is not <c>required</c>).</summary>
+        public fields.TypeSwitchField? TypeSwitch { get; set; }
+
+        /// <summary>The <c>typeSwitch</c> cases, in declaration order. Unlike a
+        /// discriminated type's children these are not lifted to top-level
+        /// types - the case names repeat (<c>CHAR</c>, <c>STRING</c>, <c>TIME</c>
+        /// each appear twice) and a case is a value shape, not a class.</summary>
+        public List<ComplexTypeDefinition> Cases { get; } = new List<ComplexTypeDefinition>();
+    }
+}
diff --git a/plc4net/tools/code-gen/model/Fields.cs b/plc4net/tools/code-gen/model/Fields.cs
new file mode 100644
index 0000000000..12aee9bffb
--- /dev/null
+++ b/plc4net/tools/code-gen/model/Fields.cs
@@ -0,0 +1,184 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.model.fields
+{
+    /// <summary>Base for the mspec field keywords.</summary>
+    public abstract class Field
+    {
+        public required string Name { get; set; }
+
+        /// <summary>The field's declared type. Null for <c>typeSwitch</c>.</summary>
+        public TypeReference? Type { get; set; }
+
+        public Dictionary<string, Term> Attributes { get; } = new Dictionary<string, Term>();
+
+        /// <summary>Whether the field holds a value the message carries (a
+        /// property + constructor parameter). False for the fields that exist
+        /// only for the wire format.</summary>
+        public virtual bool IsProperty => false;
+
+        /// <summary>The mspec keyword, for diagnostics and switch templates.</summary>
+        public string Keyword => GetType().Name.Replace("Field", "").ToLowerInvariant();
+    }
+
+    /// <summary><c>[simple uint 16 startingAddress]</c></summary>
+    public sealed class SimpleField : Field
+    {
+        public override bool IsProperty => true;
+    }
+
+    /// <summary><c>[const uint 16 protocolIdentifier 0x0000]</c> - written on
+    /// serialize, checked on parse, exposed as a C# <c>const</c>.</summary>
+    public sealed class ConstField : Field
+    {
+        public required Term ReferenceValue { get; set; }
+    }
+
+    /// <summary><c>[implicit uint 16 length 'pdu.lengthInBytes + 1']</c> -
+    /// computed on serialize, read-and-kept-as-a-local on parse.</summary>
+    public sealed class ImplicitField : Field
+    {
+        public required Term SerializeExpression { get; set; }
+    }
+
+    /// <summary><c>[reserved uint 15 '0x0000']</c> - no name, no property.</summary>
+    public sealed class ReservedField : Field
+    {
+        public required Term ReferenceValue { get; set; }
+    }
+
+    /// <summary><c>[virtual uint 16 foo 'a + b']</c> - never on the wire, a
+    /// computed accessor.</summary>
+    public sealed class VirtualField : Field
+    {
+        public required Term ValueExpression { get; set; }
+        public override bool IsProperty => false;
+    }
+
+    /// <summary>
+    /// <c>[discriminator uint 7 functionFlag]</c> - read first on the parent so
+    /// the type switch can pick the child. Exposed as an (abstract on the
+    /// parent, override on each child) accessor, not a stored property.
+    /// </summary>
+    public sealed class DiscriminatorField : Field
+    {
+    }
+
+    /// <summary><c>[enum TransportSize transportSize code]</c> - an enum-typed
+    /// field whose wire value is keyed on one of the enum's attributes rather
+    /// than the enum constant itself. <see cref="Field.Type"/> is an
+    /// <see cref="EnumTypeReference"/>.</summary>
+    public sealed class EnumField : Field
+    {
+        /// <summary>The enum attribute the wire value maps through - <c>code</c>
+        /// in <c>[enum TransportSize transportSize code]</c>. Null for the bare
+        /// two-token form, where the wire value is the enum constant itself.</summary>
+        public string? KeyAccessor { get; set; }
+
+        public override bool IsProperty => true;
+    }
+
+    /// <summary><c>[array byte value count 'byteCount']</c></summary>
+    public sealed class ArrayField : Field
+    {
+        public enum Loop { Count, Length, Terminated }
+
+        public Loop LoopType { get; set; }
+
+        /// <summary>The count / byte-length / terminator expression.</summary>
+        public required Term LoopExpression { get; set; }
+
+        public override bool IsProperty => true;
+    }
+
+    /// <summary><c>[checksum uint 16 crc 'STATIC_CALL("rtuCrcCheck", ...)']</c></summary>
+    public sealed class ChecksumField : Field
+    {
+        public required Term ChecksumExpression { get; set; }
+    }
+
+    /// <summary>
+    /// <c>[typeSwitch driverType, response ['MODBUS_TCP' ModbusTcpADU [...]]]</c>
+    /// - the discriminated dispatch. The case sub-types are lifted to top-level
+    /// <see cref="ComplexTypeDefinition"/>s by the builder; the names are kept
+    /// here in declaration order for the parse/serialize switch.
+    /// </summary>
+    public sealed class TypeSwitchField : Field
+    {
+        /// <summary>The discriminator expressions to switch on, e.g.
+        /// <c>errorFlag</c>, <c>functionFlag</c>, <c>response</c>.</summary>
+        public IReadOnlyList<Term> Discriminators { get; set; } = System.Array.Empty<Term>();
+
+        /// <summary>Child type names in declaration order.</summary>
+        public List<string> CaseNames { get; } = new List<string>();
+    }
+
+    /// <summary>
+    /// <c>[optional uint 16 errorCode 'condition']</c> - present on the wire
+    /// only when the condition holds. The property is nullable.
+    /// </summary>
+    public sealed class OptionalField : Field
+    {
+        /// <summary>The presence condition, or null (always present - rare).</summary>
+        public Term? Condition { get; set; }
+
+        public override bool IsProperty => true;
+    }
+
+    /// <summary>
+    /// <c>[manual Type name 'parse' 'serialize' 'length']</c> - the codec is
+    /// three hand-supplied expressions (usually <c>STATIC_CALL</c>s that take
+    /// <c>readBuffer</c> / <c>writeBuffer</c> / <c>_value</c>).
+    /// </summary>
+    public sealed class ManualField : Field
+    {
+        public required Term ParseExpression { get; set; }
+        public required Term SerializeExpression { get; set; }
+        public required Term LengthExpression { get; set; }
+
+        public override bool IsProperty => true;
+    }
+
+    /// <summary>
+    /// <c>[padding uint 8 pad '0x00' 'timesPadding']</c> - writes the padding
+    /// value <c>timesPadding</c> times; on parse, reads and drops that many.
+    /// </summary>
+    public sealed class PaddingField : Field
+    {
+        public required Term PaddingValue { get; set; }
+        public required Term TimesPadding { get; set; }
+    }
+
+    /// <summary>
+    /// A field keyword the generator does not handle yet
+    /// (<c>abstract</c>, <c>assert</c>, <c>manualArray</c>, <c>peek</c>,
+    /// <c>unknown</c>, <c>validation</c>). Kept in the model so a type that
+    /// uses one still round-trips through the builder and the gap is visible
+    /// in the output.
+    /// </summary>
+    public sealed class UnsupportedField : Field
+    {
+        public required string MspecKeyword { get; set; }
+        public required string RawText { get; set; }
+    }
+}
diff --git a/plc4net/tools/code-gen/model/Protocol.cs b/plc4net/tools/code-gen/model/Protocol.cs
new file mode 100644
index 0000000000..4137cbed06
--- /dev/null
+++ b/plc4net/tools/code-gen/model/Protocol.cs
@@ -0,0 +1,66 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.model
+{
+    /// <summary>
+    /// The whole type model for one mspec file: the complex types (including
+    /// the children lifted out of every <c>typeSwitch</c>), the enums, the
+    /// <c>dataIo</c> types, and the top-level <c>constants</c> block.
+    /// </summary>
+    public sealed class Protocol
+    {
+        public List<ComplexTypeDefinition> Types { get; } = new List<ComplexTypeDefinition>();
+
+        public List<EnumTypeDefinition> Enums { get; } = new List<EnumTypeDefinition>();
+
+        public List<DataIoTypeDefinition> DataIos { get; } = new List<DataIoTypeDefinition>();
+
+        public List<ConstantDeclaration> Constants { get; } = new List<ConstantDeclaration>();
+
+        /// <summary>Non-fatal notes collected while building - e.g. attribute
+        /// expressions that failed to parse and were kept as string literals.
+        /// The CLI prints these to stderr so the silent fallbacks stay
+        /// visible instead of surfacing as far-away generated-code errors.</summary>
+        public List<string> Diagnostics { get; } = new List<string>();
+
+        public ComplexTypeDefinition? FindType(string? name)
+            => Types.FirstOrDefault(t => t.Name == name);
+
+        public EnumTypeDefinition? FindEnum(string? name)
+            => Enums.FirstOrDefault(e => e.Name == name);
+
+        /// <summary>The concrete children of a discriminated parent, in
+        /// declaration order.</summary>
+        public IEnumerable<ComplexTypeDefinition> ChildrenOf(string parentName)
+            => Types.Where(t => t.ParentName == parentName);
+    }
+
+    /// <summary>One entry in the top-level <c>[constants]</c> block.</summary>
+    public sealed class ConstantDeclaration
+    {
+        public required string Name { get; set; }
+        public required TypeReference Type { get; set; }
+        public required Term Value { get; set; }
+    }
+}
diff --git a/plc4net/tools/code-gen/model/TypeReference.cs b/plc4net/tools/code-gen/model/TypeReference.cs
new file mode 100644
index 0000000000..d6b1561f9b
--- /dev/null
+++ b/plc4net/tools/code-gen/model/TypeReference.cs
@@ -0,0 +1,107 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.model
+{
+    /// <summary>
+    /// The type of a field or argument as written in the mspec: either a
+    /// <c>dataType</c> (<c>uint 16</c>, <c>bit</c>, <c>string 8</c>) or a
+    /// reference to another named type, optionally with parser arguments
+    /// (<c>ModbusPDU('response')</c>).
+    /// </summary>
+    public abstract class TypeReference
+    {
+    }
+
+    /// <summary>An mspec <c>dataType</c> - a primitive on the wire.</summary>
+    public sealed class SimpleTypeReference : TypeReference
+    {
+        public enum Base
+        {
+            Bit,
+            Byte,
+            UInt,
+            Int,
+            Float,
+            UFloat,
+            String,
+            VString,
+            Time,
+            Date,
+            DateTime,
+        }
+
+        public Base BaseType { get; set; }
+
+        /// <summary>Width in bits. 1 for <c>bit</c>, 8 for <c>byte</c>, the
+        /// declared size for <c>uint N</c> etc. Zero for a <c>vstring</c> whose
+        /// length is an expression (see <see cref="LengthExpression"/>).</summary>
+        public int SizeInBits { get; set; }
+
+        /// <summary>For <c>vstring</c>: the run-time length expression.</summary>
+        public Term? LengthExpression { get; set; }
+
+        /// <summary>The <c>stringEncoding</c> attribute on the field, if any
+        /// (e.g. <c>"UTF8"</c>, <c>"UTF16BE"</c>).</summary>
+        public string? Encoding { get; set; }
+
+        public bool IsIntegerLike =>
+            BaseType is Base.Bit or Base.Byte or Base.UInt or Base.Int;
+
+        public bool IsSigned => BaseType == Base.Int;
+
+        public bool IsByteBased => BaseType == Base.Byte && SizeInBits == 8;
+
+        public override string ToString() =>
+            BaseType == Base.Bit ? "bit"
+            : BaseType == Base.Byte ? "byte"
+            : $"{BaseType.ToString().ToLowerInvariant()} {SizeInBits}";
+    }
+
+    /// <summary>A reference to another <c>[type]</c> / <c>[discriminatedType]</c>.</summary>
+    public sealed class ComplexTypeReference : TypeReference
+    {
+        public required string Name { get; set; }
+
+        /// <summary>Parser arguments passed to the referenced type, e.g. the
+        /// <c>'response'</c> in <c>ModbusPDU('response')</c>.</summary>
+        public IReadOnlyList<Term> Arguments { get; set; } = System.Array.Empty<Term>();
+
+        public override string ToString() =>
+            Arguments.Count == 0 ? Name : $"{Name}({string.Join(", ", Arguments)})";
+    }
+
+    /// <summary>
+    /// A reference resolved to an <c>[enum]</c> type. mspec does not spell
+    /// these differently from a complex reference - the builder promotes a
+    /// <see cref="ComplexTypeReference"/> to this once the enum types are known.
+    /// </summary>
+    public sealed class EnumTypeReference : TypeReference
+    {
+        public required string Name { get; set; }
+
+        /// <summary>The enum's underlying wire type (<c>uint 8</c> etc.).</summary>
+        public required SimpleTypeReference BaseType { get; set; }
+
+        public override string ToString() => Name;
+    }
+}
diff --git a/plc4net/tools/code-gen/model/terms/Term.cs b/plc4net/tools/code-gen/model/terms/Term.cs
index 5bc7eeccdc..ae83001175 100644
--- a/plc4net/tools/code-gen/model/terms/Term.cs
+++ b/plc4net/tools/code-gen/model/terms/Term.cs
@@ -17,7 +17,6 @@
 // under the License.
 //
 
-using System;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
@@ -33,7 +32,12 @@ public sealed class IntegerLiteral(long value, string? text = null) : Term
 {
     public long Value { get; } = value;
     public string Text { get; } = text ?? value.ToString(CultureInfo.InvariantCulture);
-    public bool IsHex => Text.StartsWith("0x", StringComparison.OrdinalIgnoreCase);
+    public override string ToString() => Text;
+}
+
+public sealed class HexadecimalLiteral(string text) : Term
+{
+    public string Text { get; } = text;
     public override string ToString() => Text;
 }
 
@@ -105,7 +109,8 @@ public sealed class IndexExpression(Term target, IReadOnlyList<Term> indexes) :
     public Term Target { get; } = target;
     public IReadOnlyList<Term> Indexes { get; } = indexes;
     public override string ToString() =>
-        Target + string.Concat(Indexes.Select(item => $"[{item}]"));
+        (Target is UnaryExpression ? $"({Target})" : Target.ToString()) +
+        string.Concat(Indexes.Select(item => $"[{item}]"));
 }
 
 public sealed class TernaryExpression(Term condition, Term whenTrue, Term whenFalse) : Term
@@ -114,4 +119,4 @@ public sealed class TernaryExpression(Term condition, Term whenTrue, Term whenFa
     public Term WhenTrue { get; } = whenTrue;
     public Term WhenFalse { get; } = whenFalse;
     public override string ToString() => $"({Condition} ? {WhenTrue} : {WhenFalse})";
-}
\ No newline at end of file
+}
diff --git a/plc4net/tools/code-gen/output/CSharpExpressionRenderer.cs b/plc4net/tools/code-gen/output/CSharpExpressionRenderer.cs
new file mode 100644
index 0000000000..e45010d3c4
--- /dev/null
+++ b/plc4net/tools/code-gen/output/CSharpExpressionRenderer.cs
@@ -0,0 +1,211 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.output;
+
+/// <summary>Resolves MSpec names in the context of generated C# code.</summary>
+public interface IExpressionScope
+{
+    string? ResolveReference(string name);
+
+    string? ResolveCount(string name) => null;
+
+    string? ResolveMember(string name) => null;
+}
+
+/// <summary>Renders an MSpec expression tree as a C# expression.</summary>
+public sealed class CSharpExpressionRenderer(IExpressionScope? scope = null)
+{
+    private static readonly IReadOnlyDictionary<string, string> MemberAccessors =
+        new Dictionary<string, string>
+        {
+            ["lengthInBytes"] = "GetLengthInBytes()",
+            ["lengthInBits"] = "GetLengthInBits()"
+        };
+
+    public string StaticHelperClass { get; set; } = "StaticHelper";
+
+    public string Render(Term? term) => term switch
+    {
+        null => "null",
+        IntegerLiteral literal => literal.Text,
+        HexadecimalLiteral literal => literal.Text,
+        FloatLiteral literal => RenderFloat(literal),
+        BooleanLiteral literal => literal.Value ? "true" : "false",
+        NullLiteral => "null",
+        StringLiteral literal => RenderString(literal.Value),
+        UnaryExpression expression => RenderUnary(expression),
+        IndexExpression expression => RenderIndex(expression),
+        TernaryExpression expression =>
+            $"({Render(expression.Condition)} ? {Render(expression.WhenTrue)} : {Render(expression.WhenFalse)})",
+        BinaryExpression expression => RenderBinary(expression),
+        VariableLiteral variable => RenderVariable(variable),
+        _ => throw new NotSupportedException($"Cannot render term {term.GetType().Name}")
+    };
+
+    private static string RenderFloat(FloatLiteral literal)
+    {
+        string text = literal.Value.ToString("R", CultureInfo.InvariantCulture);
+        return text.IndexOfAny(['.', 'e', 'E']) < 0 ? text + ".0" : text;
+    }
+
+    private static string RenderString(string value) =>
+        $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
+
+    private string RenderUnary(UnaryExpression expression)
+    {
+        string operand = Render(expression.Operand);
+        return expression.Operator +
+               (expression.Operand is UnaryExpression ? $"({operand})" : operand);
+    }
+
+    private string RenderIndex(IndexExpression expression)
+    {
+        string target = Render(expression.Target);
+        if (expression.Target is UnaryExpression)
+        {
+            target = $"({target})";
+        }
+        return target + string.Concat(expression.Indexes.Select(index => $"[{Render(index)}]"));
+    }
+
+    private string RenderBinary(BinaryExpression expression) =>
+        expression.Operator == "^"
+            ? $"System.Math.Pow({Render(expression.Left)}, {Render(expression.Right)})"
+            : $"({Render(expression.Left)} {expression.Operator} {Render(expression.Right)})";
+
+    private string RenderVariable(VariableLiteral variable)
+    {
+        if (variable.Name == MspecReader.EmptyStringSentinel &&
+            !variable.IsCall && variable.Index.Count == 0 && variable.Child == null)
+        {
+            return "\"\"";
+        }
+
+        string head;
+        if (variable.IsCall && RenderBuiltin(variable) is { } builtin)
+        {
+            head = builtin;
+        }
+        else if (variable.IsCall)
+        {
+            head = (Resolve(variable.Name) ?? variable.Name) +
+                   $"({RenderArguments(variable.Args)})";
+        }
+        else
+        {
+            head = Resolve(variable.Name) ?? variable.Name;
+        }
+
+        head += RenderIndexes(variable.Index);
+        for (VariableLiteral? child = variable.Child; child != null; child = child.Child)
+        {
+            head += "." + RenderMember(child);
+        }
+        return head;
+    }
+
+    private string RenderMember(VariableLiteral member)
+    {
+        string value;
+        if (member.IsCall)
+        {
+            value = $"{Pascal(member.Name)}({RenderArguments(member.Args)})";
+        }
+        else if (MemberAccessors.TryGetValue(member.Name, out string? accessor))
+        {
+            value = accessor;
+        }
+        else
+        {
+            value = scope?.ResolveMember(member.Name) ?? Pascal(member.Name);
+        }
+        return value + RenderIndexes(member.Index);
+    }
+
+    private string RenderIndexes(IReadOnlyList<Term> indexes) =>
+        string.Concat(indexes.Select(index => $"[{Render(index)}]"));
+
+    private string RenderArguments(IReadOnlyList<Term>? arguments) =>
+        string.Join(", ", (arguments ?? []).Select(Render));
+
+    private string? RenderBuiltin(VariableLiteral variable)
+    {
+        if (variable.Args is not { } arguments)
+        {
+            return null;
+        }
+        return variable.Name switch
+        {
+            "COUNT" when arguments.Count == 1 => RenderCount(arguments[0]),
+            "CEIL" when arguments.Count == 1 =>
+                $"(int) System.Math.Ceiling((double) ({Render(arguments[0])}))",
+            "ARRAY_SIZE_IN_BYTES" when arguments.Count == 1 =>
+                $"{StaticHelperClass}.ArraySizeInBytes({Render(arguments[0])})",
+            "STATIC_CALL" when arguments.Count >= 1 && arguments[0] is StringLiteral function =>
+                $"{StaticHelperClass}.{Pascal(function.Value)}({string.Join(", ", arguments.Skip(1).Select(Render))})",
+            "CAST" when arguments.Count == 2 && RenderCastType(arguments[1]) is { } type =>
+                $"(({type}) {Render(arguments[0])})",
+            _ => null
+        };
+    }
+
+    private string RenderCount(Term argument)
+    {
+        if (argument is VariableLiteral { Child: null, Args: null } variable &&
+            scope?.ResolveCount(variable.Name) is { } count)
+        {
+            return count;
+        }
+        return Render(argument) + ".Count";
+    }
+
+    private static string? RenderCastType(Term term) => term switch
+    {
+        StringLiteral literal => literal.Value,
+        VariableLiteral variable => RenderTypeName(variable),
+        _ => null
+    };
+
+    private static string? RenderTypeName(VariableLiteral variable)
+    {
+        var parts = new List<string>();
+        for (VariableLiteral? part = variable; part != null; part = part.Child)
+        {
+            if (part.IsCall || part.Index.Count != 0)
+            {
+                return null;
+            }
+            parts.Add(part.Name);
+        }
+        return string.Join(".", parts);
+    }
+
+    private string? Resolve(string name) => scope?.ResolveReference(name);
+
+    private static string Pascal(string name) => string.IsNullOrEmpty(name)
+        ? name
+        : char.ToUpperInvariant(name[0]) + name[1..];
+}
diff --git a/plc4net/tools/code-gen/output/CSharpGenerator.cs b/plc4net/tools/code-gen/output/CSharpGenerator.cs
new file mode 100644
index 0000000000..c26affe287
--- /dev/null
+++ b/plc4net/tools/code-gen/output/CSharpGenerator.cs
@@ -0,0 +1,2435 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model;
+using org.apache.plc4net.tools.codegen.model.fields;
+using org.apache.plc4net.tools.codegen.model.terms;
+
+namespace org.apache.plc4net.tools.codegen.output
+{
+    /// <summary>
+    /// Emits C# for an mspec <see cref="Protocol"/>: one file per complex type
+    /// (class + <c>StaticParse</c> + <c>Serialize</c> + length) and per enum,
+    /// plus a constants class. This is the pure-.NET replacement for the Java
+    /// freemarker <c>model-template</c> / <c>io-template</c>.
+    /// </summary>
+    public sealed class CSharpGenerator
+    {
+        private readonly Protocol _protocol;
+        private readonly string _namespace;
+        private readonly string _protocolName;
+
+        public CSharpGenerator(Protocol protocol, string protocolName, string namespaceName)
+        {
+            _protocol = protocol;
+            _protocolName = protocolName;
+            _namespace = namespaceName;
+        }
+
+        /// <summary>Generates every file; key is a relative path.</summary>
+        /// <summary>Fails generation on model states that would silently
+        /// produce wrong or non-compiling drivers: a non-big-endian
+        /// <c>byteOrder</c> (no C# read/write path emits the swaps yet) and
+        /// enum fields keyed on an accessor the enum does not expose as a
+        /// non-external argument (the reverse lookup would never be
+        /// emitted, and generated code would not compile).</summary>
+        private void ValidateModel()
+        {
+            foreach (var t in _protocol.Types.Cast<TypeDefinition>()
+                         .Concat<TypeDefinition>(_protocol.Enums)
+                         .Concat(_protocol.DataIos))
+            {
+                if (t.Attributes.TryGetValue("byteOrder", out var order)
+                    && order is StringLiteral o && o.Value != "BIG_ENDIAN")
+                {
+                    throw new NotSupportedException(
+                        $"type '{t.Name}' declares byteOrder '{o.Value}'; "
+                        + "little-endian generation is not implemented yet");
+                }
+            }
+
+            foreach (var t in _protocol.Types)
+            {
+                foreach (var f in t.Fields)
+                {
+                    if (f is UnsupportedField unsupported)
+                    {
+                        throw new NotSupportedException(
+                            $"type '{t.Name}' contains unsupported mspec field "
+                            + $"'{unsupported.MspecKeyword}': {unsupported.RawText}");
+                    }
+                    if (f is ArrayField { LoopType: ArrayField.Loop.Terminated })
+                    {
+                        throw new NotSupportedException(
+                            $"type '{t.Name}' contains a terminated array '{f.Name}'; "
+                            + "terminated arrays are not implemented and cannot be emitted as count arrays");
+                    }
+                    if (f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.Time
+                        or SimpleTypeReference.Base.Date or SimpleTypeReference.Base.DateTime })
+                    {
+                        throw new NotSupportedException(
+                            $"type '{t.Name}.{f.Name}' uses '{f.Type}', which has no C# buffer mapping yet");
+                    }
+                    if (f is not EnumField ef || ef.KeyAccessor == null
+                        || ef.Type is not EnumTypeReference et)
+                    {
+                        continue;
+                    }
+                    var e = _protocol.FindEnum(et.Name);
+                    var ki = e?.Arguments.FindIndex(a => a.Name == ef.KeyAccessor) ?? -1;
+                    if (ki < 0 || IsExternalEnum(e!.Arguments[ki].Type))
+                    {
+                        throw new InvalidOperationException(
+                            $"enum field '{t.Name}.{ef.Name}' keys on '{ef.KeyAccessor}', which '{et.Name}' "
+                            + "does not declare as a non-external argument; "
+                            + "the generated reverse lookup would not exist");
+                    }
+                }
+            }
+
+            foreach (var dio in _protocol.DataIos)
+            {
+                foreach (var cs in dio.Cases)
+                {
+                    foreach (var f in cs.Fields)
+                    {
+                        if (f is UnsupportedField unsupported)
+                        {
+                            throw new NotSupportedException(
+                                $"dataIo '{dio.Name}.{cs.Name}' contains unsupported mspec field "
+                                + $"'{unsupported.MspecKeyword}': {unsupported.RawText}");
+                        }
+                        if (f is ArrayField { LoopType: ArrayField.Loop.Terminated })
+                        {
+                            throw new NotSupportedException(
+                                $"dataIo '{dio.Name}.{cs.Name}' contains a terminated array '{f.Name}'; "
+                                + "terminated arrays are not implemented");
+                        }
+                    }
+                    if (!IsSupportedDataIoCase(dio, cs))
+                    {
+                        throw new NotSupportedException(
+                            $"dataIo '{dio.Name}' case '{cs.Name}' is not a supported C# value shape");
+                    }
+                }
+            }
+
+            var staticCalls = new SortedDictionary<string, string>(StringComparer.Ordinal);
+            CollectStaticCalls(staticCalls);
+            foreach (var name in staticCalls.Keys)
+            {
+                if (!IsGeneratedStaticCall(name))
+                {
+                    throw new NotSupportedException(
+                        $"{_protocolName}.mspec uses STATIC_CALL '{name}', but no C# implementation is available");
+                }
+            }
+        }
+
+        public Dictionary<string, string> Generate()
+        {
+            ValidateModel();
+
+            var files = new Dictionary<string, string>();
+
+            foreach (var e in _protocol.Enums)
+            {
+                // An external enum (PlcValueType) is hand-written in the SPI;
+                // emitting a stub here would shadow it with an empty type.
+                if (e.IsExternal)
+                {
+                    continue;
+                }
+                files[$"model/{e.Name}.cs"] = EmitEnum(e);
+            }
+            foreach (var t in _protocol.Types)
+            {
+                files[$"model/{t.Name}.cs"] = EmitComplexType(t);
+            }
+            foreach (var dio in _protocol.DataIos)
+            {
+                files[$"model/{dio.Name}.cs"] = EmitDataIo(dio);
+            }
+            if (_protocol.Constants.Count > 0)
+            {
+                files[$"model/{Pascal(_protocolName)}Constants.cs"] = EmitConstants();
+            }
+
+            files[$"model/{Pascal(_protocolName)}StaticHelper.cs"] = EmitStaticHelper();
+            return files;
+        }
+
+        // ── complex types ───────────────────────────────────────
+
+        private string EmitComplexType(ComplexTypeDefinition type)
+        {
+            var scope = new FieldScope(_protocol, type);
+            var render = new CSharpExpressionRenderer(scope)
+            {
+                StaticHelperClass = $"{Pascal(_protocolName)}StaticHelper",
+            };
+
+            var c = new CodeWriter();
+            Header(c);
+            c.Line("using System;");
+            c.Line("using System.Linq;");
+            c.Line("using org.apache.plc4net.spi.drivers;");
+            c.Line("using org.apache.plc4net.spi.generation;");
+            c.Line();
+            c.Line($"namespace {_namespace}.model");
+            c.Line("{");
+            c.Indent();
+
+            var parent = type.IsDiscriminatedChild ? $" : {type.ParentName}" : " : IMessage";
+            var mods = type.IsDiscriminatedParent ? "abstract partial" : "partial";
+            c.Line($"public {mods} class {type.Name}{parent}");
+            c.Line("{");
+            c.Indent();
+
+            EmitConstFields(c, type, render);
+            EmitDiscriminatorAccessors(c, type, render);
+            EmitProperties(c, type, render);
+            EmitConstructor(c, type);
+            EmitStaticParse(c, type, render, scope);
+            EmitSerialize(c, type, render, scope);
+            EmitLength(c, type, render, scope);
+
+            c.Outdent();
+            c.Line("}");
+            c.Outdent();
+            c.Line("}");
+            return c.ToString();
+        }
+
+        private void EmitConstFields(CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r)
+        {
+            foreach (var f in type.ConstFields)
+            {
+                c.Line($"public const {CSharpTypeMapper.CSharpType(f.Type)} {Pascal(f.Name)} = {r.Render(f.ReferenceValue)};");
+            }
+            if (type.ConstFields.Any())
+            {
+                c.Line();
+            }
+        }
+
+        private void EmitDiscriminatorAccessors(CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r)
+        {
+            if (type.IsDiscriminatedParent)
+            {
+                foreach (var d in type.Fields.OfType<DiscriminatorField>())
+                {
+                    c.Line($"public abstract {CSharpTypeMapper.CSharpType(d.Type)} {Pascal(d.Name)} {{ get; }}");
+                }
+                // A parser-arg discriminator with no matching field becomes an
+                // abstract accessor too, so children can pin their value.
+                foreach (var name in ParserArgDiscriminatorNames(type))
+                {
+                    var arg = type.Arguments.First(a => a.Name == name);
+                    c.Line($"public abstract {CSharpTypeMapper.CSharpType(arg.Type)} {Pascal(name)} {{ get; }}");
+                }
+                c.Line();
+            }
+
+            if (type.IsDiscriminatedChild && _protocol.FindType(type.ParentName) is { } p)
+            {
+                var values = type.DiscriminatorValues ?? Array.Empty<Term>();
+                var discriminators = DiscriminatorList(p, r);
+                var emitted = false;
+                for (var i = 0; i < discriminators.Count; i++)
+                {
+                    var d = discriminators[i];
+                    if (d.AccessorName == null)
+                    {
+                        continue; // property field or dotted path - no accessor
+                    }
+                    var pinned = values.Count > i && values[i] != null;
+                    // An unpinned discriminator (the case listed fewer values,
+                    // or a null slot) takes the type's default, matching plc4j:
+                    // ModbusPDUError leaves functionFlag / response unspecified
+                    // and its accessors return 0 / false.
+                    var rhs = pinned
+                        ? RenderDiscriminatorValue(values[i], d.Type, r)
+                        : $"default({CSharpTypeMapper.CSharpType(d.Type)})";
+                    c.Line($"public override {CSharpTypeMapper.CSharpType(d.Type)} {Pascal(d.AccessorName)} => {rhs};");
+                    emitted = true;
+                }
+                if (emitted)
+                {
+                    c.Line();
+                }
+            }
+        }
+
+        private void EmitProperties(CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer parseRender)
+        {
+            foreach (var f in type.PropertyFields)
+            {
+                c.Line($"public {PropertyType(f)} {Pascal(f.Name)} {{ get; }}");
+            }
+            var serializeRender = new CSharpExpressionRenderer(new FieldScope(_protocol, type, serialize: true))
+            {
+                StaticHelperClass = parseRender.StaticHelperClass,
+            };
+            foreach (var f in type.Fields.OfType<VirtualField>())
+            {
+                c.Line($"public {CSharpTypeMapper.CSharpType(f.Type)} {Pascal(f.Name)} => {WithType(serializeRender.Render(f.ValueExpression), f.Type)};");
+            }
+            if (type.PropertyFields.Any() || type.Fields.OfType<VirtualField>().Any())
+            {
+                c.Line();
+            }
+        }
+
+        private void EmitConstructor(CodeWriter c, ComplexTypeDefinition type)
+        {
+            var all = AllValueFields(type);
+            if (all.Count == 0)
+            {
+                return;
+            }
+
+            var own = type.PropertyFields.ToList();
+            var inherited = ParentPrefixFields(type).Concat(ParentSuffixFields(type)).ToList();
+
+            var pars = string.Join(", ", all.Select(f => $"{PropertyType(f)} {Camel(f.Name)}"));
+            var mod = type.IsDiscriminatedParent ? "protected" : "public";
+            var baseCall = inherited.Count > 0
+                ? " : base(" + string.Join(", ", inherited.Select(f => Camel(f.Name))) + ")"
+                : "";
+
+            c.Line($"{mod} {type.Name}({pars}){baseCall}");
+            c.Line("{");
+            c.Indent();
+            foreach (var f in own)
+            {
+                c.Line($"{Pascal(f.Name)} = {Camel(f.Name)};");
+            }
+            c.Outdent();
+            c.Line("}");
+            c.Line();
+        }
+
+        // ── StaticParse ─────────────────────────────────────────
+
+        private void EmitStaticParse(
+            CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r, FieldScope scope)
+        {
+            var args = ParseSignatureArgs(type);
+            // `new` only when the child's StaticParse has the same signature as
+            // the base's (no extra context parameters) - otherwise it neither
+            // hides anything (CS0109) nor needs to.
+            var hide = type.IsDiscriminatedChild && ParentContextFields(type).Count == 0
+                ? "new "
+                : "";
+            c.Line($"public static {hide}{type.Name} StaticParse(ReadBuffer readBuffer{Comma(args)})");
+            c.Line("{");
+            c.Indent();
+
+            // `curPos` is "bytes since this type's parse began". A child that
+            // needs it receives _startPos from the parent (the byte count is
+            // measured from where the parent started, before the discriminators);
+            // everyone else captures it here.
+            if (NeedsStartPos(type) && !(type.IsDiscriminatedChild && UsesCurPos(type)))
+            {
+                c.Line("var _startPos = readBuffer.GetPos();");
+            }
+
+            foreach (var f in FieldsToEmit(type))
+            {
+                EmitFieldParse(c, type, f, r, scope);
+            }
+
+            if (!type.IsDiscriminatedParent)
+            {
+                // prefix (passed in), own + suffix (read above), in wire order.
+                var ctorArgs = string.Join(", ", AllValueFields(type).Select(f => Camel(f.Name)));
+                c.Line($"return new {type.Name}({ctorArgs});");
+            }
+
+            c.Outdent();
+            c.Line("}");
+            c.Line();
+        }
+
+        private void EmitFieldParse(
+            CodeWriter c, ComplexTypeDefinition type, Field f, CSharpExpressionRenderer r, FieldScope scope)
+        {
+            switch (f)
+            {
+                case DiscriminatorField d:
+                    c.Line($"var {Camel(d.Name)} = {CSharpTypeMapper.ReadCall(d.Type, d.Name)};");
+                    break;
+
+                case SimpleField:
+                    c.Line($"var {Camel(f.Name)} = {ReadFieldValue(f, r)};");
+                    break;
+
+                case EnumField ef:
+                    c.Line($"var {Camel(ef.Name)} = {EnumFieldRead(ef, r)};");
+                    break;
+
+                case ConstField cf:
+                    c.Line($"var {Camel(cf.Name)} = {CSharpTypeMapper.ReadCall(cf.Type, cf.Name)};");
+                    c.Line($"if (!Equals({Camel(cf.Name)}, {WithType(r.Render(cf.ReferenceValue), cf.Type)}))");
+                    c.Indent();
+                    c.Line($"throw new ParseException($\"Expected constant {{{Pascal(cf.Name)}}} for '{cf.Name}' but got {{{Camel(cf.Name)}}}\");");
+                    c.Outdent();
+                    break;
+
+                case ImplicitField imf:
+                    c.Line($"var {Camel(imf.Name)} = {CSharpTypeMapper.ReadCall(imf.Type, imf.Name)};");
+                    break;
+
+                case ReservedField rf:
+                    c.Line("{");
+                    c.Indent();
+                    c.Line($"var reserved = {CSharpTypeMapper.ReadCall(rf.Type, "reserved")};");
+                    c.Line($"if (!Equals(reserved, {WithType(r.Render(rf.ReferenceValue), rf.Type)})) {{ /* mspec reserved: value differs from the spec default */ }}");
+                    c.Outdent();
+                    c.Line("}");
+                    break;
+
+                case ArrayField af:
+                    EmitArrayParse(c, af, r, scope);
+                    break;
+
+                case ChecksumField ck:
+                    c.Line($"var {Camel(ck.Name)} = {CSharpTypeMapper.ReadCall(ck.Type, ck.Name)};");
+                    c.Line($"var expected{Pascal(ck.Name)} = {WithType(r.Render(ck.ChecksumExpression), ck.Type)};");
+                    c.Line($"if (!Equals({Camel(ck.Name)}, expected{Pascal(ck.Name)}))");
+                    c.Indent();
+                    c.Line($"throw new ParseException($\"Checksum '{ck.Name}' does not match: expected {{expected{Pascal(ck.Name)}}} but got {{{Camel(ck.Name)}}}\");");
+                    c.Outdent();
+                    break;
+
+                case OptionalField opt:
+                    c.Line($"{PropertyType(opt)} {Camel(opt.Name)} = null;");
+                    if (opt.Condition != null)
+                    {
+                        c.Line($"if ({r.Render(opt.Condition)})");
+                        c.Line("{");
+                        c.Indent();
+                    }
+                    c.Line($"{Camel(opt.Name)} = {ReadFieldValue(opt, r)};");
+                    if (opt.Condition != null)
+                    {
+                        c.Outdent();
+                        c.Line("}");
+                    }
+                    break;
+
+                case ManualField mf:
+                    c.Line($"var {Camel(mf.Name)} = ({CSharpTypeMapper.CSharpType(mf.Type)}) ({r.Render(mf.ParseExpression)});");
+                    break;
+
+                case PaddingField pad:
+                    c.Line("{");
+                    c.Indent();
+                    c.Line($"var _timesPadding = (int) ({r.Render(pad.TimesPadding)});");
+                    c.Line("while (_timesPadding-- > 0)");
+                    c.Line("{");
+                    c.Indent();
+                    c.Line($"{CSharpTypeMapper.ReadCall(pad.Type, "padding")};");
+                    c.Outdent();
+                    c.Line("}");
+                    c.Outdent();
+                    c.Line("}");
+                    break;
+
+                case TypeSwitchField ts:
+                    EmitTypeSwitchParse(c, type, ts, r);
+                    break;
+
+                case VirtualField vf:
+                    c.Line($"var {Camel(vf.Name)} = {WithType(r.Render(vf.ValueExpression), vf.Type)};");
+                    break;
+
+                case UnsupportedField uf:
+                    c.Line($"// TODO unsupported mspec field '{uf.MspecKeyword}': {uf.RawText}");
+                    break;
+            }
+        }
+
+        private void EmitArrayParse(CodeWriter c, ArrayField af, CSharpExpressionRenderer r, FieldScope scope)
+        {
+            var element = ElementType(af);
+            var (elemType, isByteArray) = CSharpTypeMapper.ArrayType(element);
+            var name = Camel(af.Name);
+
+            if (isByteArray && af.LoopType is ArrayField.Loop.Count or ArrayField.Loop.Length)
+            {
+                c.Line($"var {name} = readBuffer.ReadByteArray(\"{af.Name}\", (int) ({r.Render(af.LoopExpression)}) * 8);");
+                return;
+            }
+
+            c.Line($"var {name} = new {elemType}();");
+            // Underscore-prefixed locals: mspec field names never start with one,
+            // so a loop's bookkeeping cannot collide with an implicit / simple
+            // field of the same base name (e.g. an `itemsCount` implicit beside
+            // an `items count 'itemsCount'` array).
+            // `_lastItem` is only knowable ahead of time for a counted loop.
+            var lastItem = af.LoopType == ArrayField.Loop.Count && ElementUsesLastItem(af)
+                ? $"_{name}I == _{name}Cnt - 1"
+                : null;
+            if (af.LoopType == ArrayField.Loop.Length)
+            {
+                c.Line($"var _{name}End = readBuffer.GetPos() + (int) ({r.Render(af.LoopExpression)}) * 8;");
+                c.Line($"while (readBuffer.GetPos() < _{name}End)");
+            }
+            else
+            {
+                c.Line($"var _{name}Cnt = (int) ({r.Render(af.LoopExpression)});");
+                c.Line($"for (var _{name}I = 0; _{name}I < _{name}Cnt; _{name}I++)");
+            }
+            c.Line("{");
+            c.Indent();
+            var read = CSharpTypeMapper.ReadCall(element, af.Name, ElementArgs(element, r, lastItem));
+            c.Line($"{name}.Add({read});");
+            c.Outdent();
+            c.Line("}");
+        }
+
+        private void EmitTypeSwitchParse(
+            CodeWriter c, ComplexTypeDefinition parent, TypeSwitchField ts, CSharpExpressionRenderer r)
+        {
+            var discriminators = DiscriminatorList(parent, r);
+
+            foreach (var childName in ts.CaseNames)
+            {
+                // Every case name in ts.CaseNames was added to _protocol.Types
+                // by BuildTypeSwitch in the same pass that recorded the name.
+                var child = _protocol.FindType(childName)
+                    ?? throw new InvalidOperationException($"Type switch case '{childName}' has no matching type definition.");
+                var values = child.DiscriminatorValues ?? Array.Empty<Term>();
+                var conds = new List<string>();
+                for (var i = 0; i < discriminators.Count && i < values.Count; i++)
+                {
+                    if (values[i] == null)
+                    {
+                        continue;
+                    }
+                    var d = discriminators[i];
+                    conds.Add($"Equals({d.DispatchExpr}, {RenderDiscriminatorValue(values[i], d.Type, r)})");
+                }
+                var test = conds.Count == 0 ? "true" : string.Join(" && ", conds);
+                var childArgs = ChildParseArgNames(child)
+                    .Concat(ParentContextFields(child).Select(f => Camel(f.Name)));
+                if (UsesCurPos(child))
+                {
+                    childArgs = childArgs.Append("_startPos");
+                }
+                c.Line($"if ({test})");
+                c.Line("{");
+                c.Indent();
+                c.Line($"return {childName}.StaticParse(readBuffer{Comma(childArgs)});");
+                c.Outdent();
+                c.Line("}");
+            }
+            c.Line($"throw new ParseException(\"No matching subtype found for {parent.Name}\");");
+        }
+
+        // ── Serialize ───────────────────────────────────────────
+
+        private void EmitSerialize(
+            CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r, FieldScope scope)
+        {
+            if (type.IsDiscriminatedChild)
+            {
+                c.Line($"protected override void SerializeChild(WriteBuffer writeBuffer)");
+            }
+            else if (UsesLastItem(type))
+            {
+                c.Line("public void Serialize(WriteBuffer writeBuffer) => Serialize(writeBuffer, false);");
+                c.Line();
+                c.Line("public void Serialize(WriteBuffer writeBuffer, bool _lastItem)");
+            }
+            else
+            {
+                c.Line("public void Serialize(WriteBuffer writeBuffer)");
+            }
+            c.Line("{");
+            c.Indent();
+
+            var serScope = scope.ForSerialize();
+            var serRender = new CSharpExpressionRenderer(serScope) { StaticHelperClass = r.StaticHelperClass };
+
+            HoistParentImplicits(c, type, serRender, serScope);
+
+            foreach (var f in FieldsToEmit(type))
+            {
+                // `_value` inside a manual field's serialize expression is that
+                // field's own value.
+                serScope.CurrentValue = f is ManualField ? Pascal(f.Name) : null;
+                EmitFieldSerialize(c, type, f, serRender);
+            }
+            serScope.CurrentValue = null;
+            serScope.ClearLocals();
+            if (type.IsDiscriminatedParent)
+            {
+                c.Line("SerializeChild(writeBuffer);");
+            }
+
+            c.Outdent();
+            c.Line("}");
+            c.Line();
+
+            if (type.IsDiscriminatedParent)
+            {
+                c.Line("protected abstract void SerializeChild(WriteBuffer writeBuffer);");
+                c.Line();
+            }
+        }
+
+        private void EmitFieldSerialize(
+            CodeWriter c, ComplexTypeDefinition type, Field f, CSharpExpressionRenderer r)
+        {
+            switch (f)
+            {
+                case DiscriminatorField d:
+                    c.Line($"{CSharpTypeMapper.WriteCall(d.Type, d.Name, Pascal(d.Name))};");
+                    break;
+
+                case SimpleField when f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } len }:
+                    c.Line($"writeBuffer.WriteString(\"{f.Name}\", (int) ({r.Render(len)}), \"{VStringEncoding(f)}\", {Pascal(f.Name)});");
+                    break;
+
+                case SimpleField:
+                    c.Line($"{CSharpTypeMapper.WriteCall(f.Type, f.Name, Pascal(f.Name))};");
+                    break;
+
+                case EnumField ef:
+                    c.Line($"{EnumFieldWrite(ef)};");
+                    break;
+
+                case ConstField cf:
+                    c.Line($"{CSharpTypeMapper.WriteCall(cf.Type, cf.Name, Pascal(cf.Name))};");
+                    break;
+
+                case ImplicitField imf:
+                    var val = $"({CSharpTypeMapper.CSharpType(imf.Type)}) ({r.Render(imf.SerializeExpression)})";
+                    c.Line($"{CSharpTypeMapper.WriteCall(imf.Type, imf.Name, val)};");
+                    break;
+
+                case ReservedField rf:
+                    c.Line($"{CSharpTypeMapper.WriteCall(rf.Type, "reserved", WithType(r.Render(rf.ReferenceValue), rf.Type))};");
+                    break;
+
+                case ArrayField af:
+                    EmitArraySerialize(c, af, r);
+                    break;
+
+                case ChecksumField ck:
+                    var cv = $"({CSharpTypeMapper.CSharpType(ck.Type)}) ({r.Render(ck.ChecksumExpression)})";
+                    c.Line($"{CSharpTypeMapper.WriteCall(ck.Type, ck.Name, cv)};");
+                    break;
+
+                case OptionalField opt:
+                    // .Value for a nullable value type; the reference itself
+                    // for a nullable class.
+                    var optRef = opt.Type is ComplexTypeReference
+                        ? Pascal(opt.Name)
+                        : $"{Pascal(opt.Name)}.Value";
+                    c.Line($"if ({Pascal(opt.Name)} != null)");
+                    c.Line("{");
+                    c.Indent();
+                    c.Line($"{CSharpTypeMapper.WriteCall(opt.Type, opt.Name, optRef)};");
+                    c.Outdent();
+                    c.Line("}");
+                    break;
+
+                case ManualField mf:
+                    c.Line($"{r.Render(mf.SerializeExpression)};");
+                    break;
+
+                case PaddingField pad:
+                    c.Line("{");
+                    c.Indent();
+                    c.Line($"var _timesPadding = (int) ({r.Render(pad.TimesPadding)});");
+                    c.Line("while (_timesPadding-- > 0)");
+                    c.Line("{");
+                    c.Indent();
+                    c.Line($"{CSharpTypeMapper.WriteCall(pad.Type, "padding", WithType(r.Render(pad.PaddingValue), pad.Type))};");
+                    c.Outdent();
+                    c.Line("}");
+                    c.Outdent();
+                    c.Line("}");
+                    break;
+
+                case TypeSwitchField:
+                case VirtualField:
+                case UnsupportedField:
+                    break;
+            }
+        }
+
+        private void EmitArraySerialize(CodeWriter c, ArrayField af, CSharpExpressionRenderer r)
+        {
+            var element = ElementType(af);
+            var (_, isByteArray) = CSharpTypeMapper.ArrayType(element);
+            var prop = Pascal(af.Name);
+
+            if (isByteArray)
+            {
+                c.Line($"writeBuffer.WriteByteArray(\"{af.Name}\", {prop});");
+                return;
+            }
+
+            if (ElementUsesLastItem(af))
+            {
+                c.Line($"for (var _i = 0; _i < {prop}.Count; _i++)");
+                c.Line("{");
+                c.Indent();
+                c.Line($"{prop}[_i].Serialize(writeBuffer, _i == {prop}.Count - 1);");
+                c.Outdent();
+                c.Line("}");
+                return;
+            }
+
+            c.Line($"foreach (var _e in {prop})");
+            c.Line("{");
+            c.Indent();
+            c.Line($"{CSharpTypeMapper.WriteCall(element, af.Name, "_e")};");
+            c.Outdent();
+            c.Line("}");
+        }
+
+        // ── length ──────────────────────────────────────────────
+
+        private void EmitLength(
+            CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r, FieldScope scope)
+        {
+            var serScope = scope.ForSerialize();
+            var serRender = new CSharpExpressionRenderer(serScope) { StaticHelperClass = r.StaticHelperClass };
+
+            var childMethod = type.IsDiscriminatedChild;
+            var lastItem = !childMethod && UsesLastItem(type);
+            if (lastItem)
+            {
+                c.Line("public int GetLengthInBits() => GetLengthInBits(false);");
+                c.Line();
+                c.Line("public int GetLengthInBits(bool _lastItem)");
+            }
+            else
+            {
+                c.Line(childMethod
+                    ? "protected override int GetLengthInBitsChild()"
+                    : "public int GetLengthInBits()");
+            }
+            c.Line("{");
+            c.Indent();
+            c.Line("var lengthInBits = 0;");
+            HoistParentImplicits(c, type, serRender, serScope);
+            foreach (var f in FieldsToEmit(type))
+            {
+                var term = LengthContribution(f, serRender);
+                if (term != null)
+                {
+                    c.Line($"lengthInBits += {term};");
+                }
+            }
+            serScope.ClearLocals();
+            if (type.IsDiscriminatedParent)
+            {
+                c.Line("lengthInBits += GetLengthInBitsChild();");
+            }
+            c.Line("return lengthInBits;");
+            c.Outdent();
+            c.Line("}");
+            c.Line();
+
+            if (type.IsDiscriminatedParent)
+            {
+                c.Line("protected abstract int GetLengthInBitsChild();");
+                c.Line();
+            }
+            if (!childMethod)
+            {
+                // Round up: a type that is not byte-aligned still occupies a
+                // whole trailing byte on the wire (plc4j and the dataIo path
+                // both ceil).
+                c.Line($"public int GetLengthInBytes() => (GetLengthInBits({(lastItem ? "false" : "")}) + 7) / 8;");
+            }
+        }
+
+        /// <summary>
+        /// A discriminated child's serialize / length can reference the
+        /// parent's pre-typeSwitch implicit fields, which the parent (not the
+        /// child) writes. Recompute the ones it names as locals at the top so
+        /// the expressions resolve.
+        /// </summary>
+        private void HoistParentImplicits(
+            CodeWriter c, ComplexTypeDefinition type, CSharpExpressionRenderer r, FieldScope scope)
+        {
+            if (!type.IsDiscriminatedChild || _protocol.FindType(type.ParentName) is not { } p)
+            {
+                return;
+            }
+            var referenced = new HashSet<string>();
+            foreach (var f in FieldsToEmit(type))
+            {
+                foreach (var t in SerializeTerms(f))
+                {
+                    CollectNames(t, referenced);
+                }
+            }
+            foreach (var imf in p.PrefixImplicitFields.Where(i => referenced.Contains(i.Name)))
+            {
+                scope.MarkLocal(imf.Name);
+                c.Line($"var {Camel(imf.Name)} = ({CSharpTypeMapper.CSharpType(imf.Type)}) ({r.Render(imf.SerializeExpression)});");
+            }
+        }
+
+        /// <summary>The expression trees a field contributes to
+        /// <c>Serialize</c> / <c>GetLengthInBits</c>. Excludes the parse-only
+        /// ones: an optional's condition, an array's loop expression, a manual
+        /// field's parse expression, and a complex field's constructor
+        /// arguments (those are passed to <c>StaticParse</c>, never re-evaluated
+        /// on the way out).</summary>
+        private static IEnumerable<Term> SerializeTerms(Field f)
+        {
+            switch (f)
+            {
+                case ImplicitField i: yield return i.SerializeExpression; break;
+                case ChecksumField ck: yield return ck.ChecksumExpression; break;
+                case ReservedField r: yield return r.ReferenceValue; break;
+                case VirtualField v: yield return v.ValueExpression; break;
+                case ManualField m:
+                    yield return m.SerializeExpression;
+                    yield return m.LengthExpression;
+                    break;
+                case PaddingField p:
+                    yield return p.PaddingValue;
+                    yield return p.TimesPadding;
+                    break;
+            }
+        }
+
+        private string? LengthContribution(Field f, CSharpExpressionRenderer r)
+        {
+            switch (f)
+            {
+                case SimpleField or ConstField or DiscriminatorField or ImplicitField
+                    or ReservedField or ChecksumField or EnumField:
+                    var bits = CSharpTypeMapper.FixedBitLength(f.Type);
+                    if (bits >= 0)
+                    {
+                        return bits.ToString();
+                    }
+                    if (f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } vlen })
+                    {
+                        return $"({r.Render(vlen)})";
+                    }
+                    if (f.Type is ComplexTypeReference)
+                    {
+                        return $"{Pascal(f.Name)}.GetLengthInBits()";
+                    }
+                    return null; // vstring without a length / unmodelled - not computed
+
+                case VirtualField:
+                    return null;
+
+                case ArrayField af:
+                    var element = ElementType(af);
+                    var (_, isByteArray) = CSharpTypeMapper.ArrayType(element);
+                    var prop = Pascal(af.Name);
+                    if (isByteArray)
+                    {
+                        return $"({prop}.Length * 8)";
+                    }
+                    var eb = CSharpTypeMapper.FixedBitLength(element);
+                    if (eb >= 0)
+                    {
+                        return $"({prop}.Count * {eb})";
+                    }
+                    return ElementUsesLastItem(af)
+                        ? $"System.Linq.Enumerable.Range(0, {prop}.Count).Sum(_i => {prop}[_i].GetLengthInBits(_i == {prop}.Count - 1))"
+                        : $"{prop}.Sum(_e => _e.GetLengthInBits())";
+
+                case OptionalField opt:
+                    var name = Pascal(opt.Name);
+                    var ob = CSharpTypeMapper.FixedBitLength(opt.Type);
+                    if (ob >= 0)
+                    {
+                        return $"({name} != null ? {ob} : 0)";
+                    }
+                    return opt.Type is ComplexTypeReference
+                        ? $"({name}?.GetLengthInBits() ?? 0)"
+                        : null;
+
+                case ManualField mf:
+                    return $"(({r.Render(mf.LengthExpression)}) * 8)";
+
+                case PaddingField pad:
+                    var pb = CSharpTypeMapper.FixedBitLength(pad.Type);
+                    return $"(({r.Render(pad.TimesPadding)}) * {(pb > 0 ? pb : 8)})";
+
+                default:
+                    return null;
+            }
+        }
+
+        // ── enums ───────────────────────────────────────────────
+
+        private string EmitEnum(EnumTypeDefinition e)
+        {
+            var r = new CSharpExpressionRenderer(FieldScope.EnumConstantsOnly(_protocol));
+            var baseType = e.BaseType != null
+                ? CSharpTypeMapper.SimpleCSharpType(e.BaseType)
+                : "int";
+
+            var c = new CodeWriter();
+            Header(c);
+            c.Line($"namespace {_namespace}.model");
+            c.Line("{");
+            c.Indent();
+            c.Line($"public enum {e.Name} : {baseType}");
+            c.Line("{");
+            c.Indent();
+            foreach (var v in e.Values)
+            {
+                var val = v.Value != null ? r.Render(v.Value) : null;
+                c.Line(val != null ? $"{v.Name} = {val}," : $"{v.Name},");
+            }
+            c.Outdent();
+            c.Line("}");
+
+            if (e.Arguments.Count > 0)
+            {
+                c.Line();
+                c.Line($"public static class {e.Name}Extensions");
+                c.Line("{");
+                c.Indent();
+                for (var ai = 0; ai < e.Arguments.Count; ai++)
+                {
+                    var arg = e.Arguments[ai];
+                    // The accessor would return an external enum whose members
+                    // the generator cannot see; nothing consumes it, so skip it.
+                    if (IsExternalEnum(arg.Type))
+                    {
+                        continue;
+                    }
+                    var rows = e.Values.Where(v => v.ConstantValues.Count > ai).ToList();
+                    var nullable = IsValueType(arg.Type)
+                        && rows.Any(v => v.ConstantValues[ai] is NullLiteral);
+                    var retType = CSharpTypeMapper.CSharpType(arg.Type) + (nullable ? "?" : "");
+                    // `Get` prefix: keeps the method name off the enum type name
+                    // it may return (`DataTransportSize`), and matches the
+                    // `x.attr` -> `x.GetAttr()` the expression renderer emits.
+                    c.Line($"public static {retType} Get{Pascal(arg.Name)}(this {e.Name} value) => value switch");
+                    c.Line("{");
+                    c.Indent();
+                    // C# rejects duplicate switch patterns when two enum
+                    // members share the same underlying value (S7's
+                    // TransportSize has COUNTER and DATE_AND_TIME at 0x1C).
+                    // Keep every enum member in the declaration, but make the
+                    // accessor deterministic by keeping the first member for
+                    // each underlying enum value.
+                    EmitEnumArms(
+                        c,
+                        rows,
+                        v => RenderEnumUnderlyingValue(v, r),
+                        v => $"{e.Name}.{v.Name}",
+                        v => RenderEnumParamValue(v.ConstantValues[ai], arg.Type, r));
+                    c.Line("_ => default!,");
+                    c.Outdent();
+                    c.Line("};");
+                }
+                foreach (var key in EnumFieldKeys(e.Name))
+                {
+                    var ki = e.Arguments.FindIndex(a => a.Name == key);
+                    if (ki < 0)
+                    {
+                        continue;
+                    }
+                    var keyType = e.Arguments[ki].Type;
+                    var rows = e.Values.Where(v => v.ConstantValues.Count > ki).ToList();
+                    c.Line($"public static {e.Name}? FirstEnumForField{Pascal(key)}({CSharpTypeMapper.CSharpType(keyType)} {Camel(key)}) => {Camel(key)} switch");
+                    c.Line("{");
+                    c.Indent();
+                    // "first" lookup: the earliest constant wins each key value.
+                    string PatternFor(EnumValue v) => RenderEnumParamValue(v.ConstantValues[ki], keyType, r);
+                    EmitEnumArms(c, rows, PatternFor, PatternFor, v => $"{e.Name}.{v.Name}");
+                    // An unmapped key is null, not a bogus `(T) 0` — the caller
+                    // (a parse path) then fails loudly with a real value in hand.
+                    c.Line("_ => null,");
+                    c.Outdent();
+                    c.Line("};");
+                }
+                c.Outdent();
+                c.Line("}");
+            }
+
+            c.Outdent();
+            c.Line("}");
+            return c.ToString();
+        }
+
+        /// <summary>Emits <c>pattern =&gt; result,</c> switch arms, skipping any
+        /// whose <paramref name="dedupKey"/> repeats an earlier arm - two enum
+        /// constants can share an underlying value, and an enum can map several
+        /// constants to the same attribute value.</summary>
+        private static void EmitEnumArms(
+            CodeWriter c, IEnumerable<EnumValue> rows,
+            System.Func<EnumValue, string> dedupKey,
+            System.Func<EnumValue, string> pattern,
+            System.Func<EnumValue, string> result)
+        {
+            var seen = new HashSet<string>();
+            foreach (var v in rows)
+            {
+                if (seen.Add(dedupKey(v)))
+                {
+                    c.Line($"{pattern(v)} => {result(v)},");
+                }
+            }
+        }
+
+        /// <summary>Renders one entry of an enum's parameter table, coerced to
+        /// the parameter's declared type: a bare word is a string literal for a
+        /// <c>vstring</c> parameter, a member of the target enum for an
+        /// enum-typed one, and a char literal for a one-character integer.</summary>
+        private string RenderEnumParamValue(Term term, TypeReference target, CSharpExpressionRenderer r)
+        {
+            switch (target)
+            {
+                case EnumTypeReference et:
+                    if (term is NullLiteral)
+                    {
+                        return "null";
+                    }
+                    if (term is VariableLiteral { Child: null, IsCall: false } ev)
+                    {
+                        return $"{et.Name}.{ev.Name}";
+                    }
+                    return r.Render(term);
+
+                case SimpleTypeReference { BaseType: SimpleTypeReference.Base.String
+                    or SimpleTypeReference.Base.VString }:
+                    if (term is NullLiteral)
+                    {
+                        return "null";
+                    }
+                    if (term is VariableLiteral { Child: null, IsCall: false } sv)
+                    {
+                        return sv.Name == MspecReader.EmptyStringSentinel
+                            ? "\"\""
+                            : "\"" + sv.Name + "\"";
+                    }
+                    // A string-typed parameter table entry is always a literal, but
+                    // an unquoted one is parsed as an expression: a bare number
+                    // ('409') as an IntegerLiteral, a hyphenated id ('DPST-1-1') as
+                    // a '-' chain. Fold both back to the string they spell.
+                    if (term is StringLiteral strLit)
+                    {
+                        return "\"" + strLit.Value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
+                    }
+                    if (term is IntegerLiteral or FloatLiteral or BooleanLiteral)
+                    {
+                        return "\"" + term + "\"";
+                    }
+                    if (TryFlattenHyphenatedId(term, out var id))
+                    {
+                        return "\"" + id + "\"";
+                    }
+                    return r.Render(term);
+
+                case SimpleTypeReference { IsIntegerLike: true } it:
+                    if (term is VariableLiteral { Child: null, IsCall: false, Name.Length: 1 } cv)
+                    {
+                        return $"({CSharpTypeMapper.SimpleCSharpType(it)}) '{cv.Name}'";
+                    }
+                    return r.Render(term);
+
+                default:
+                    return r.Render(term);
+            }
+        }
+
+        /// <summary>An mspec enum id like <c>'DPST-1-1'</c> lexes as
+        /// <c>DPST - 1 - 1</c>: the grammar has no hyphen in an identifier and no
+        /// bare string. Rebuild the original text from a subtraction chain whose
+        /// leaves are all bare names or integers - the only way a string-typed
+        /// parameter can carry a <c>-</c>.</summary>
+        private static bool TryFlattenHyphenatedId(Term term, out string? id)
+        {
+            var parts = new List<string>();
+            if (!Collect(term) || parts.Count < 2)
+            {
+                id = null;
+                return false;
+            }
+            id = string.Join("-", parts);
+            return true;
+
+            bool Collect(Term t)
+            {
+                switch (t)
+                {
+                    case BinaryExpression { Operator: "-" } b:
+                        return Collect(b.Left) && Collect(b.Right);
+                    case VariableLiteral { Child: null, IsCall: false, Index.Count: 0 } v:
+                        parts.Add(v.Name);
+                        return true;
+                    case IntegerLiteral i:
+                        parts.Add(i.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
+                        return true;
+                    default:
+                        return false;
+                }
+            }
+        }
+
+        private bool IsExternalEnum(TypeReference t) =>
+            t is EnumTypeReference et
+            && _protocol.Enums.Any(e => e.Name == et.Name && e.IsExternal);
+
+        private static bool IsValueType(TypeReference t) =>
+            t is EnumTypeReference
+            || t is SimpleTypeReference s
+               && s.BaseType is not (SimpleTypeReference.Base.String or SimpleTypeReference.Base.VString);
+
+        /// <summary>The distinct <c>KeyAccessor</c>s of every <c>enum</c> field
+        /// in the protocol that is typed as <paramref name="enumName"/> - each
+        /// needs a <c>FirstEnumForField…</c> reverse lookup.</summary>
+        private IEnumerable<string> EnumFieldKeys(string enumName) =>
+            _protocol.Types
+                .SelectMany(t => t.Fields)
+                .OfType<EnumField>()
+                .Where(f => f.KeyAccessor != null
+                            && (f.Type as EnumTypeReference)?.Name == enumName)
+                .Select(f => f.KeyAccessor!)
+                .Distinct();
+
+        private string EmitConstants()
+        {
+            var r = new CSharpExpressionRenderer(FieldScope.EnumConstantsOnly(_protocol));
+            var c = new CodeWriter();
+            Header(c);
+            c.Line($"namespace {_namespace}.model");
+            c.Line("{");
+            c.Indent();
+            c.Line($"public static class {Pascal(_protocolName)}Constants");
+            c.Line("{");
+            c.Indent();
+            foreach (var k in _protocol.Constants)
+            {
+                c.Line($"public const {CSharpTypeMapper.CSharpType(k.Type)} {Pascal(k.Name)} = {r.Render(k.Value)};");
+            }
+            c.Outdent();
+            c.Line("}");
+            c.Outdent();
+            c.Line("}");
+            return c.ToString();
+        }
+
+        // ── dataIo (parses to / from an IPlcValue) ──────────────
+
+        /// <summary>IEC value wrappers whose plc4net type is exactly
+        /// <c>Plc&lt;caseName&gt;</c> and which round-trip through a single
+        /// scalar accessor.</summary>
+        private static readonly HashSet<string> ScalarPlcValueCases = new HashSet<string>
+        {
+            "BOOL", "BYTE", "WORD", "DWORD", "LWORD",
+            "SINT", "USINT", "INT", "UINT", "DINT", "UDINT", "LINT", "ULINT",
+            "REAL", "LREAL", "CHAR", "WCHAR", "STRING", "WSTRING",
+        };
+
+        /// <summary>The TIA date / time <c>DataItem</c> cases - each maps to a
+        /// <c>Plc{caseName}</c> built through a factory / segment call, and the
+        /// S7 date helpers (<c>parseS5Time</c>, <c>parseTiaDate</c>,
+        /// <c>parseSiemensYear</c>, BCD). Only the multi-value
+        /// <c>numberOfValues &gt; 1</c> list cases stay a throwing stub now.</summary>
+        private static readonly HashSet<string> TemporalPlcValueCases = new HashSet<string>
+        {
+            "TIME", "LTIME", "DATE", "TIME_OF_DAY", "LTIME_OF_DAY",
+            "DATE_AND_TIME", "DATE_AND_LTIME",
+        };
+
+        private string EmitDataIo(DataIoTypeDefinition dio)
+        {
+            var helper = $"{Pascal(_protocolName)}StaticHelper";
+
+            var c = new CodeWriter();
+            Header(c);
+            c.Line("using System;");
+            c.Line("using org.apache.plc4net.api.value;");
+            c.Line("using org.apache.plc4net.spi.generation;");
+            c.Line("using org.apache.plc4net.spi.model.values;");
+            c.Line();
+            c.Line($"namespace {_namespace}.model");
+            c.Line("{");
+            c.Indent();
+            c.Line("/// <summary>");
+            c.Line($"/// mspec <c>[dataIo {dio.Name}]</c> - reads and writes one");
+            c.Line("/// <see cref=\"IPlcValue\"/> whose wire layout the parser arguments pick.");
+            c.Line("/// </summary>");
+            c.Line($"public static class {dio.Name}");
+            c.Line("{");
+            c.Indent();
+
+            var argDecls = dio.Arguments
+                .Select(a => $"{CSharpTypeMapper.CSharpType(a.Type)} {Camel(a.Name)}")
+                .ToList();
+            var argNames = dio.Arguments.Select(a => Camel(a.Name)).ToList();
+
+            // A dataIo case with no discriminator values (KNX's KnxProperty) is
+            // the catch-all: it compiles to `else { … }`, and the trailing
+            // fallback `return` after it would be unreachable.
+            var caughtAll = dio.Cases.Count > 0 && IsWildcardCase(dio.Cases[^1]);
+
+            c.Line($"public static IPlcValue StaticParse(ReadBuffer readBuffer{Comma(argDecls)})");
+            c.Line("{");
+            c.Indent();
+            for (var i = 0; i < dio.Cases.Count; i++)
+            {
+                EmitDataIoCase(c, dio, dio.Cases[i], helper, DataIoMode.Parse, i == 0, i == dio.Cases.Count - 1);
+            }
+            if (!caughtAll)
+            {
+                c.Line("return new PlcNULL();");
+            }
+            c.Outdent();
+            c.Line("}");
+            c.Line();
+
+            c.Line($"public static void StaticSerialize(WriteBuffer writeBuffer, IPlcValue _value{Comma(argDecls)})");
+            c.Line("{");
+            c.Indent();
+            for (var i = 0; i < dio.Cases.Count; i++)
+            {
+                EmitDataIoCase(c, dio, dio.Cases[i], helper, DataIoMode.Serialize, i == 0, i == dio.Cases.Count - 1);
+            }
+            c.Outdent();
+            c.Line("}");
+            c.Line();
+
+            c.Line($"public static int GetLengthInBytes(IPlcValue _value{Comma(argDecls)}) =>");
+            c.Indent();
+            c.Line($"(GetLengthInBits(_value{Comma(argNames)}) + 7) / 8;");
+            c.Outdent();
+            c.Line();
+            c.Line($"public static int GetLengthInBits(IPlcValue _value{Comma(argDecls)})");
+            c.Line("{");
+            c.Indent();
+            c.Line("var lengthInBits = 0;");
+            for (var i = 0; i < dio.Cases.Count; i++)
+            {
+                EmitDataIoCase(c, dio, dio.Cases[i], helper, DataIoMode.Length, i == 0, i == dio.Cases.Count - 1);
+            }
+            c.Line("return lengthInBits;");
+            c.Outdent();
+            c.Line("}");
+
+            c.Outdent();
+            c.Line("}");
+            c.Outdent();
+            c.Line("}");
+            return c.ToString();
+        }
+
+        private enum DataIoMode { Parse, Serialize, Length }
+
+        /// <summary>A dataIo case that lists no discriminator values - the
+        /// <c>[* …]</c> catch-all. KNX's <c>KnxProperty</c> ends with one.</summary>
+        private static bool IsWildcardCase(ComplexTypeDefinition cs) =>
+            cs.DiscriminatorValues == null
+            || cs.DiscriminatorValues.Count == 0
+            || cs.DiscriminatorValues.All(v => v == null);
+
+        private void EmitDataIoCase(
+            CodeWriter c, DataIoTypeDefinition dio, ComplexTypeDefinition cs, string helper, DataIoMode mode, bool first, bool last = false)
+        {
+            var serialize = mode != DataIoMode.Parse;
+            var synthetic = new ComplexTypeDefinition { Name = dio.Name };
+            synthetic.Arguments.AddRange(dio.Arguments);
+            synthetic.Fields.AddRange(cs.Fields);
+            var scope = new FieldScope(_protocol, synthetic, serialize);
+            // A dataIo's parser arguments are method parameters in every method,
+            // never properties - keep them camelCase even while serializing.
+            foreach (var a in dio.Arguments)
+            {
+                scope.MarkLocal(a.Name);
+            }
+            var r = new CSharpExpressionRenderer(scope) { StaticHelperClass = helper };
+
+            // The catch-all case compiles to `else { … }` (or just `{ … }` when
+            // it is the only case) so the compiler sees no unreachable fallback.
+            var test = DataIoCaseTest(dio, cs, r);
+            if (last && test == "true" && !first)
+            {
+                c.Line("else");
+            }
+            else if (last && test == "true")
+            {
+                // sole unconditional case: no wrapper needed
+            }
+            else
+            {
+                c.Line($"{(first ? "if" : "else if")} ({test})");
+            }
+            c.Line("{");
+            c.Indent();
+            c.Line($"// {cs.Name}");
+
+            var props = cs.PropertyFields.ToList();
+            var valueField = props.Count == 1 ? props[0] : null;
+            var listField = valueField as ArrayField;
+            // The temporal bodies are a transcription of plc4j's S7 DataItem date
+            // logic - they only fit S7's field layout, so gate them on S7's
+            // discriminator. KNX's KnxDatapoint has DATE / TIME cases too, with a
+            // completely different (segmented, struct-valued) shape.
+            var temporal = TemporalPlcValueCases.Contains(cs.Name)
+                           && dio.Arguments.Any(a => a.Name == "dataProtocolId")
+                           && props.All(f => f is not ArrayField);
+            var scalar = valueField is not (null or ArrayField) && ScalarPlcValueCases.Contains(cs.Name);
+            // A KnxDatapoint / KnxProperty composite case ([… Struct …]): each
+            // field is a plain simple field or a fixed-count byte array, so it
+            // maps to a PlcStruct keyed by name.
+            var structCase = cs.Name == "Struct"
+                             && props.Count > 0
+                             && props.All(f => f is SimpleField { Type: SimpleTypeReference }
+                                 || f is ArrayField
+                                 {
+                                     Type: SimpleTypeReference { BaseType: SimpleTypeReference.Base.Byte },
+                                     LoopType: ArrayField.Loop.Count,
+                                 });
+
+            switch (mode)
+            {
+                case DataIoMode.Parse when temporal:
+                    EmitDataIoTemporalParse(c, cs, r);
+                    break;
+                case DataIoMode.Parse when structCase:
+                    EmitDataIoStructParse(c, cs, r);
+                    break;
+                case DataIoMode.Parse when listField != null:
+                    EmitDataIoListParse(c, listField, r);
+                    break;
+                case DataIoMode.Parse:
+                    foreach (var f in cs.Fields)
+                    {
+                        EmitDataIoFieldParse(c, f, r);
+                    }
+                    c.Line($"return {DataIoReturnValue(cs, valueField)};");
+                    break;
+
+                case DataIoMode.Serialize when temporal:
+                    EmitDataIoTemporalSerialize(c, cs, helper);
+                    break;
+                case DataIoMode.Serialize when structCase:
+                    EmitDataIoStructSerialize(c, cs, r);
+                    break;
+                case DataIoMode.Serialize when listField != null:
+                    EmitDataIoListSerialize(c, listField);
+                    break;
+                case DataIoMode.Serialize:
+                    foreach (var f in cs.Fields)
+                    {
+                        EmitDataIoFieldSerialize(c, cs, f, valueField, r);
+                    }
+                    break;
+
+                case DataIoMode.Length when listField != null:
+                    c.Line($"lengthInBits += _value.GetLength() * {CSharpTypeMapper.FixedBitLength(listField.Type)};");
+                    break;
+                case DataIoMode.Length:
+                    foreach (var f in cs.Fields)
+                    {
+                        var bits = DataIoFieldLength(f, r);
+                        if (bits != null)
+                        {
+                            c.Line($"lengthInBits += {bits};");
+                        }
+                    }
+                    break;
+            }
+
+            c.Outdent();
+            c.Line("}");
+        }
+
+        /// <summary>Parses one TIA date / time case to its <c>Plc…</c> wrapper.
+        /// Mirrors plc4j's <c>DataItem</c>: a factory / segment call, with the S7
+        /// helpers doing S5TIME, the Siemens epoch and BCD.</summary>
+        private void EmitDataIoTemporalParse(CodeWriter c, ComplexTypeDefinition cs, CSharpExpressionRenderer r)
+        {
+            var field = cs.PropertyFields.FirstOrDefault();
+            switch (cs.Name)
+            {
+                case "TIME":
+                    c.Line(field is ManualField mf
+                        ? $"var milliseconds = {r.Render(RewriteTypeEncoding(mf.ParseExpression, "UTF8"))};"
+                        : "var milliseconds = readBuffer.ReadUint(\"milliseconds\", 32);");
+                    c.Line("return PlcTIME.OfMilliseconds(milliseconds);");
+                    break;
+                case "LTIME":
+                    c.Line("var nanoseconds = readBuffer.ReadUlong(\"nanoseconds\", 64);");
+                    c.Line("return PlcLTIME.OfNanoseconds(nanoseconds);");
+                    break;
+                case "DATE":
+                    // Unlike TIME above, DATE has no non-ManualField fallback:
+                    // every mspec DATE dataIo case is modeled as manual.
+                    c.Line($"var daysSinceEpoch = {r.Render(RewriteTypeEncoding(((ManualField) field!).ParseExpression, "UTF8"))};");
+                    c.Line("return PlcDATE.OfDaysSinceEpoch(daysSinceEpoch);");
+                    break;
+                case "TIME_OF_DAY":
+                    c.Line("var millisecondsSinceMidnight = readBuffer.ReadUint(\"millisecondsSinceMidnight\", 32);");
+                    c.Line("return PlcTIME_OF_DAY.OfMillisecondsSinceMidnight(millisecondsSinceMidnight);");
+                    break;
+                case "LTIME_OF_DAY":
+                    c.Line("var nanosecondsSinceMidnight = readBuffer.ReadUlong(\"nanosecondsSinceMidnight\", 64);");
+                    c.Line("return PlcLTIME_OF_DAY.OfNanosecondsSinceMidnight(nanosecondsSinceMidnight);");
+                    break;
+                case "DATE_AND_TIME":
+                    c.Line($"var year = {r.StaticHelperClass}.ParseSiemensYear(readBuffer);");
+                    foreach (var f in new[] { "month", "day", "hour", "minutes", "seconds" })
+                    {
+                        c.Line($"var {f} = {r.StaticHelperClass}.BcdToBin(readBuffer.ReadByte(\"{f}\", 8));");
+                    }
+                    c.Line($"var millisecondsOfSecond = {r.StaticHelperClass}.BcdToBin12(readBuffer.ReadUshort(\"millisecondsOfSecond\", 12));");
+                    c.Line("readBuffer.ReadByte(\"dayOfWeek\", 4);");
+                    c.Line("return PlcDATE_AND_TIME.OfSegments(year, month, day, hour, minutes, seconds, millisecondsOfSecond * 1000000);");
+                    break;
+                case "DATE_AND_LTIME" when cs.PropertyFields.Count() == 1:
+                    c.Line("var nanosecondsSinceEpoch = readBuffer.ReadUlong(\"nanosecondsSinceEpoch\", 64);");
+                    c.Line("return PlcDATE_AND_LTIME.OfNanosecondsSinceEpoch(nanosecondsSinceEpoch);");
+                    break;
+                case "DATE_AND_LTIME": // DTL - plain-binary segments, widths from the mspec
+                    foreach (var f in cs.Fields.OfType<SimpleField>())
+                    {
+                        var read = DataIoSimpleRead(f, r);
+                        // dayOfWeek is recomputed from the DateTime on serialize.
+                        c.Line(f.Name == "dayOfWeek" ? $"{read};" : $"var {Camel(f.Name)} = {read};");
+                    }
+                    c.Line("return PlcDATE_AND_LTIME.OfSegments(year, month, day, hour, minutes, seconds, nanosecondsOfSecond);");
+                    break;
+            }
+        }
+
+        private void EmitDataIoTemporalSerialize(CodeWriter c, ComplexTypeDefinition cs, string helper)
+        {
+            var field = cs.PropertyFields.FirstOrDefault();
+            // S7 DT / DTL number the week Sunday=1 .. Saturday=7 (per s7.mspec's
+            // own DTL comment and the Siemens DTL doc). This is a DELIBERATE
+            // departure from plc4j, whose DataItem writes ISO-8601
+            // Monday=1 .. Sunday=7 (getDayOfWeek().getValue()) - the two never
+            // agree on any weekday. Both discard the field on parse (the
+            // DateTime carries its own weekday), so it only shows up when a
+            // device consumes the serialized frame, or when diffing plc4net vs
+            // plc4j serializer output.
+            const string dow = "(byte) ((int) _dt.DayOfWeek + 1)";
+            switch (cs.Name)
+            {
+                case "TIME" when field is ManualField:
+                    c.Line($"{helper}.SerializeS5Time(writeBuffer, _value);");
+                    break;
+                case "TIME":
+                    c.Line("writeBuffer.WriteUint(\"milliseconds\", 32, (uint) _value.GetDuration().TotalMilliseconds);");
+                    break;
+                case "LTIME":
+                    c.Line("writeBuffer.WriteUlong(\"nanoseconds\", 64, ((PlcLTIME) _value).GetNanoseconds());");
+                    break;
+                case "DATE":
+                    c.Line($"{helper}.SerializeTiaDate(writeBuffer, _value);");
+                    break;
+                case "TIME_OF_DAY":
+                    c.Line("writeBuffer.WriteUint(\"millisecondsSinceMidnight\", 32, (uint) _value.GetTime().ToTimeSpan().TotalMilliseconds);");
+                    break;
+                case "LTIME_OF_DAY":
+                    c.Line("writeBuffer.WriteUlong(\"nanosecondsSinceMidnight\", 64, ((PlcLTIME_OF_DAY) _value).GetNanosecondsSinceMidnight());");
+                    break;
+                case "DATE_AND_TIME":
+                    c.Line("var _dt = _value.GetDateTime();");
+                    c.Line($"{helper}.SerializeSiemensYear(writeBuffer, _value);");
+                    c.Line($"writeBuffer.WriteByte(\"month\", 8, {helper}.BinToBcd(_dt.Month));");
+                    c.Line($"writeBuffer.WriteByte(\"day\", 8, {helper}.BinToBcd(_dt.Day));");
+                    c.Line($"writeBuffer.WriteByte(\"hour\", 8, {helper}.BinToBcd(_dt.Hour));");
+                    c.Line($"writeBuffer.WriteByte(\"minutes\", 8, {helper}.BinToBcd(_dt.Minute));");
+                    c.Line($"writeBuffer.WriteByte(\"seconds\", 8, {helper}.BinToBcd(_dt.Second));");
+                    c.Line($"writeBuffer.WriteUshort(\"millisecondsOfSecond\", 12, {helper}.BinToBcd12(_dt.Millisecond));");
+                    c.Line($"writeBuffer.WriteByte(\"dayOfWeek\", 4, {dow});");
+                    break;
+                case "DATE_AND_LTIME" when cs.PropertyFields.Count() == 1:
+                    c.Line("writeBuffer.WriteUlong(\"nanosecondsSinceEpoch\", 64, ((PlcDATE_AND_LTIME) _value).GetNanosecondsSinceEpoch());");
+                    break;
+                case "DATE_AND_LTIME":
+                    c.Line("var _dtl = (PlcDATE_AND_LTIME) _value;");
+                    c.Line("var _dt = _dtl.GetDateTime();");
+                    c.Line("writeBuffer.WriteUshort(\"year\", 16, (ushort) _dt.Year);");
+                    c.Line("writeBuffer.WriteByte(\"month\", 8, (byte) _dt.Month);");
+                    c.Line("writeBuffer.WriteByte(\"day\", 8, (byte) _dt.Day);");
+                    c.Line($"writeBuffer.WriteByte(\"dayOfWeek\", 8, {dow});");
+                    c.Line("writeBuffer.WriteByte(\"hour\", 8, (byte) _dt.Hour);");
+                    c.Line("writeBuffer.WriteByte(\"minutes\", 8, (byte) _dt.Minute);");
+                    c.Line("writeBuffer.WriteByte(\"seconds\", 8, (byte) _dt.Second);");
+                    c.Line("writeBuffer.WriteUint(\"nanosecondsOfSecond\", 32, _dtl.GetNanosecondsOfSecond());");
+                    break;
+            }
+        }
+
+        /// <summary>A <c>numberOfValues &gt; 1</c> dataIo case (Modbus's
+        /// <c>DataItem</c> List cases) - a counted array of one primitive,
+        /// wrapped element-by-element into a <see cref="PlcList"/>.</summary>
+        private void EmitDataIoListParse(CodeWriter c, ArrayField af, CSharpExpressionRenderer r)
+        {
+            // An array field's element type is always given in the grammar.
+            var element = (SimpleTypeReference) af.Type!;
+            var wrap = DataIoPlcWrapper(element);
+            c.Line("var value = new System.Collections.Generic.List<IPlcValue>();");
+            c.Line($"var _valueCnt = (int) ({r.Render(af.LoopExpression)});");
+            c.Line("for (var _i = 0; _i < _valueCnt; _i++)");
+            c.Line("{");
+            c.Indent();
+            if (element.BaseType is SimpleTypeReference.Base.String)
+            {
+                c.Line($"var _e = readBuffer.ReadString(\"value\", {element.SizeInBits}, {CSharpTypeMapper.EncodingExpr(element.Encoding)});");
+                c.Line($"value.Add(new {wrap}(_e.Length > 0 ? _e[0] : '\\0'));");
+            }
+            else
+            {
+                c.Line($"value.Add(new {wrap}({CSharpTypeMapper.ReadCall(element, "value")}));");
+            }
+
+            c.Outdent();
+            c.Line("}");
+            c.Line("return new PlcList(value);");
+        }
+
+        private void EmitDataIoListSerialize(CodeWriter c, ArrayField af)
+        {
+            // An array field's element type is always given in the grammar.
+            var element = (SimpleTypeReference) af.Type!;
+            c.Line("foreach (var _e in _value.GetList())");
+            c.Line("{");
+            c.Indent();
+            var item = element.BaseType is SimpleTypeReference.Base.String
+                ? "_e.GetString()"
+                : $"({CSharpTypeMapper.SimpleCSharpType(element)}) _e.{PlcValueGetter(element)}";
+            c.Line(element.BaseType is SimpleTypeReference.Base.String
+                ? $"writeBuffer.WriteString(\"value\", {element.SizeInBits}, \"{element.Encoding ?? "UTF8"}\", {item});"
+                : $"{CSharpTypeMapper.WriteCall(element, "value", item)};");
+            c.Outdent();
+            c.Line("}");
+        }
+
+        // ── dataIo struct cases (KnxDatapoint composites) ───────
+
+        /// <summary>Parses a <c>[… Struct …]</c> case: read each field, then
+        /// bundle the simple ones into a <c>PlcStruct</c> keyed by field name.
+        /// Mirrors plc4j's KNX <c>KnxDatapoint</c> struct branch.</summary>
+        private void EmitDataIoStructParse(CodeWriter c, ComplexTypeDefinition cs, CSharpExpressionRenderer r)
+        {
+            foreach (var f in cs.Fields)
+            {
+                switch (f)
+                {
+                    case ArrayField af:
+                        c.Line($"var {Camel(af.Name)} = readBuffer.ReadByteArray(\"{af.Name}\", (int) ({r.Render(af.LoopExpression)}) * 8);");
+                        break;
+                    default:
+                        EmitDataIoFieldParse(c, f, r);
+                        break;
+                }
+            }
+            c.Line("var _map = new System.Collections.Generic.Dictionary<string, IPlcValue>();");
+            foreach (var f in cs.Fields)
+            {
+                switch (f)
+                {
+                    case ArrayField af:
+                        c.Line($"_map[\"{af.Name}\"] = new PlcRawByteArray({Camel(af.Name)});");
+                        break;
+                    case SimpleField sf when sf.Type is SimpleTypeReference st:
+                        c.Line($"_map[\"{sf.Name}\"] = new {DataIoPlcWrapper(st)}(({CSharpTypeMapper.SimpleCSharpType(st)}) {Camel(sf.Name)});");
+                        break;
+                }
+            }
+            c.Line("return new PlcStruct(_map);");
+        }
+
+        private void EmitDataIoStructSerialize(CodeWriter c, ComplexTypeDefinition cs, CSharpExpressionRenderer r)
+        {
+            foreach (var f in cs.Fields)
+            {
+                switch (f)
+                {
+                    case ReservedField rf:
+                        c.Line($"{CSharpTypeMapper.WriteCall(rf.Type, "reserved", WithType(r.Render(rf.ReferenceValue), rf.Type))};");
+                        break;
+                    case ArrayField af:
+                        c.Line($"writeBuffer.WriteByteArray(\"{af.Name}\", _value.GetValue(\"{af.Name}\")!.GetRaw());");
+                        break;
+                    case SimpleField sf when sf.Type is SimpleTypeReference st:
+                        var v = $"({CSharpTypeMapper.SimpleCSharpType(st)}) _value.GetValue(\"{sf.Name}\")!.{PlcValueGetter(st)}";
+                        c.Line($"{CSharpTypeMapper.WriteCall(sf.Type, sf.Name, v)};");
+                        break;
+                }
+            }
+        }
+
+        /// <summary>The <c>Plc…</c> wrapper for one element of a dataIo list,
+        /// keyed on the wire type (<c>bit</c> → <c>PlcBOOL</c>, <c>int 16</c> →
+        /// <c>PlcINT</c>, <c>string 16</c> → <c>PlcWCHAR</c>, …).</summary>
+        private static string DataIoPlcWrapper(SimpleTypeReference s) => s.BaseType switch
+        {
+            SimpleTypeReference.Base.Bit => "PlcBOOL",
+            SimpleTypeReference.Base.Byte => "PlcUSINT",
+            SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? "PlcUSINT"
+                : s.SizeInBits <= 16 ? "PlcUINT"
+                : s.SizeInBits <= 32 ? "PlcUDINT" : "PlcULINT",
+            SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? "PlcSINT"
+                : s.SizeInBits <= 16 ? "PlcINT"
+                : s.SizeInBits <= 32 ? "PlcDINT" : "PlcLINT",
+            SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+                s.SizeInBits <= 32 ? "PlcREAL" : "PlcLREAL",
+            SimpleTypeReference.Base.String or SimpleTypeReference.Base.VString =>
+                s.SizeInBits <= 8 ? "PlcCHAR" : "PlcWCHAR",
+            _ => "PlcNULL",
+        };
+
+        private string DataIoCaseTest(
+            DataIoTypeDefinition dio, ComplexTypeDefinition cs, CSharpExpressionRenderer r)
+        {
+            // Every dataIo carries a typeSwitch; see DataIoTypeDefinition.TypeSwitch.
+            var discs = dio.TypeSwitch!.Discriminators.OfType<VariableLiteral>().ToList();
+            var values = cs.DiscriminatorValues ?? Array.Empty<Term>();
+            var conds = new List<string>();
+            for (var i = 0; i < discs.Count && i < values.Count; i++)
+            {
+                if (values[i] == null)
+                {
+                    continue;
+                }
+                var argType = dio.Arguments.FirstOrDefault(a => a.Name == discs[i].Name)?.Type;
+                conds.Add($"Equals({Camel(discs[i].Name)}, {RenderDiscriminatorValue(values[i], argType, r)})");
+            }
+            return conds.Count == 0 ? "true" : string.Join(" && ", conds);
+        }
+
+        private void EmitDataIoFieldParse(CodeWriter c, Field f, CSharpExpressionRenderer r)
+        {
+            switch (f)
+            {
+                case ReservedField rf:
+                    c.Line("{");
+                    c.Indent();
+                    c.Line($"var reserved = {CSharpTypeMapper.ReadCall(rf.Type, "reserved")};");
+                    c.Line($"if (!Equals(reserved, {WithType(r.Render(rf.ReferenceValue), rf.Type)})) {{ /* mspec reserved: value differs from the spec default */ }}");
+                    c.Outdent();
+                    c.Line("}");
+                    break;
+
+                case ManualField mf:
+                    var parse = RewriteTypeEncoding(mf.ParseExpression, FieldEncoding(mf));
+                    c.Line($"var {Camel(mf.Name)} = ({CSharpTypeMapper.CSharpType(mf.Type)}) ({r.Render(parse)});");
+                    break;
+
+                case SimpleField sf:
+                    c.Line($"var {Camel(sf.Name)} = {DataIoSimpleRead(sf, r)};");
+                    break;
+            }
+        }
+
+        private void EmitDataIoFieldSerialize(
+            CodeWriter c, ComplexTypeDefinition cs, Field f, Field? valueField, CSharpExpressionRenderer r)
+        {
+            switch (f)
+            {
+                case ReservedField rf:
+                    c.Line($"{CSharpTypeMapper.WriteCall(rf.Type, "reserved", WithType(r.Render(rf.ReferenceValue), rf.Type))};");
+                    break;
+
+                case ManualField mf:
+                    c.Line($"{r.Render(RewriteTypeEncoding(mf.SerializeExpression, FieldEncoding(mf)))};");
+                    break;
+
+                case SimpleField sf when sf.Type is SimpleTypeReference st:
+                    var getter = st.BaseType is SimpleTypeReference.Base.String or SimpleTypeReference.Base.VString
+                        ? "_value.GetString()"
+                        : $"({CSharpTypeMapper.SimpleCSharpType(st)}) _value.{PlcValueGetter(st)}";
+                    if (st.BaseType is SimpleTypeReference.Base.String)
+                    {
+                        c.Line($"writeBuffer.WriteString(\"{sf.Name}\", {st.SizeInBits}, \"{FieldEncoding(sf)}\", {getter});");
+                    }
+                    else if (st.BaseType is SimpleTypeReference.Base.VString && st.LengthExpression is { } vlen)
+                    {
+                        c.Line($"writeBuffer.WriteString(\"{sf.Name}\", (int) ({r.Render(RewriteTypeEncoding(vlen, FieldEncoding(sf)))}), \"{FieldEncoding(sf)}\", {getter});");
+                    }
+                    else
+                    {
+                        c.Line($"{CSharpTypeMapper.WriteCall(sf.Type, sf.Name, getter)};");
+                    }
+                    break;
+            }
+        }
+
+        private string? DataIoFieldLength(Field f, CSharpExpressionRenderer r)
+        {
+            switch (f)
+            {
+                case SimpleField when f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } vlen }:
+                    // vstring: the length expression is already in bits (`stringLength * 8`).
+                    return $"({r.Render(RewriteTypeEncoding(vlen, FieldEncoding((SimpleField) f)))})";
+                case ReservedField or SimpleField:
+                    var bits = CSharpTypeMapper.FixedBitLength(f.Type);
+                    return bits >= 0 ? bits.ToString() : null;
+                case ArrayField { LoopType: ArrayField.Loop.Count } af:
+                    // a fixed-count byte array in a struct case
+                    return $"(({r.Render(af.LoopExpression)}) * 8)";
+                case ManualField mf:
+                    // A dataIo manual length expression is already in bits
+                    // (plc4j's DataItem: `(stringLength * 8) + 16`).
+                    return $"({r.Render(RewriteTypeEncoding(mf.LengthExpression, FieldEncoding(mf)))})";
+                default:
+                    return null;
+            }
+        }
+
+        /// <summary>The <c>return new Plc…(value)</c> for a scalar
+        /// <c>DataItem</c> case. The case name is the plc4net value type.</summary>
+        private string DataIoReturnValue(ComplexTypeDefinition cs, Field? valueField)
+        {
+            // Only reached for the scalar branch, gated on valueField being
+            // exactly one non-array property field (see the `scalar` check
+            // in EmitDataIoCase).
+            if (valueField == null)
+            {
+                throw new InvalidOperationException($"DataIo case '{cs.Name}' has no scalar value field.");
+            }
+            var v = Camel(valueField.Name);
+            switch (cs.Name)
+            {
+                case "CHAR":
+                case "WCHAR":
+                    // read as a 1- or 2-byte string; Plc(W)CHAR wraps a char.
+                    return $"new Plc{cs.Name}({v}.Length > 0 ? {v}[0] : '\\0')";
+                default:
+                    // BOOL / BYTE / … / STRING / WSTRING - Plc<caseName>(value).
+                    return $"new Plc{cs.Name}({v})";
+            }
+        }
+
+        private string DataIoSimpleRead(SimpleField sf, CSharpExpressionRenderer r)
+        {
+            switch (sf.Type)
+            {
+                case SimpleTypeReference { BaseType: SimpleTypeReference.Base.String } s:
+                    return $"readBuffer.ReadString(\"{sf.Name}\", {s.SizeInBits}, {CSharpTypeMapper.EncodingExpr(FieldEncoding(sf))})";
+                // vstring: the bit length is a run-time expression (modbus DataItem's
+                // `vstring 'stringLength * 8'`), already in bits.
+                case SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } len }:
+                    return $"readBuffer.ReadString(\"{sf.Name}\", (int) ({r.Render(RewriteTypeEncoding(len, FieldEncoding(sf)))}), {CSharpTypeMapper.EncodingExpr(FieldEncoding(sf))})";
+                default:
+                    return CSharpTypeMapper.ReadCall(sf.Type, sf.Name);
+            }
+        }
+
+        /// <summary>The <c>IPlcValue</c> accessor that yields a value of this
+        /// wire type, for a dataIo serialize.</summary>
+        private static string PlcValueGetter(SimpleTypeReference s) => s.BaseType switch
+        {
+            SimpleTypeReference.Base.Bit => "GetBool()",
+            SimpleTypeReference.Base.Byte => "GetByte()",
+            SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? "GetByte()"
+                : s.SizeInBits <= 16 ? "GetUshort()"
+                : s.SizeInBits <= 32 ? "GetUint()" : "GetUlong()",
+            SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? "GetSbyte()"
+                : s.SizeInBits <= 16 ? "GetShort()"
+                : s.SizeInBits <= 32 ? "GetInt()" : "GetLong()",
+            SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+                s.SizeInBits <= 32 ? "GetFloat()" : "GetDouble()",
+            _ => "GetString()",
+        };
+
+        /// <summary>The string encoding for a dataIo value field: the
+        /// <c>encoding</c> / <c>stringEncoding</c> attribute (s7.mspec spells it
+        /// the first way, modbus.mspec the second), or the type's own resolved
+        /// encoding, defaulting to UTF-8.</summary>
+        private static string FieldEncoding(Field f)
+        {
+            if (f.Type is SimpleTypeReference { Encoding: { } typeEncoding })
+            {
+                return typeEncoding;
+            }
+            foreach (var key in new[] { "encoding", "stringEncoding" })
+            {
+                if (f.Attributes.TryGetValue(key, out var t) && t is StringLiteral s)
+                {
+                    return s.Value;
+                }
+            }
+            return "UTF8";
+        }
+
+        /// <summary>Inlines <c>_type.encoding</c> (a dataIo case's own encoding
+        /// attribute) as a string literal, the way plc4j's <c>DataItem</c> does -
+        /// a dataIo has no instance for <c>_type</c> to resolve against.</summary>
+        private static Term RewriteTypeEncoding(Term t, string encoding)
+        {
+            switch (t)
+            {
+                case VariableLiteral v when v.Name == "_type"
+                        && v.Child is { Name: "encoding", Child: null }:
+                    return new StringLiteral(encoding);
+                case VariableLiteral v:
+                    return new VariableLiteral(
+                        v.Name,
+                        v.Args?.Select(a => RewriteTypeEncoding(a, encoding)).ToList(),
+                        v.Index?.Select(i => RewriteTypeEncoding(i, encoding)).ToList(),
+                        v.Child);
+                case BinaryExpression b:
+                    return new BinaryExpression(
+                        RewriteTypeEncoding(b.Left, encoding), b.Operator, RewriteTypeEncoding(b.Right, encoding));
+                case UnaryExpression u:
+                    return new UnaryExpression(u.Operator, RewriteTypeEncoding(u.Operand, encoding));
+                case TernaryExpression x:
+                    return new TernaryExpression(
+                        RewriteTypeEncoding(x.Condition, encoding),
+                        RewriteTypeEncoding(x.WhenTrue, encoding),
+                        RewriteTypeEncoding(x.WhenFalse, encoding));
+                default:
+                    return t;
+            }
+        }
+
+        // ── static helper (STATIC_CALL targets + array sizing) ──
+
+        private string EmitStaticHelper()
+        {
+            // name -> the checksum/field C# type the call has to return
+            var staticCalls = new SortedDictionary<string, string>(StringComparer.Ordinal);
+            CollectStaticCalls(staticCalls);
+
+            var cls = $"{Pascal(_protocolName)}StaticHelper";
+            var c = new CodeWriter();
+            Header(c);
+            c.Line("using System;");
+            c.Line("using System.Collections.Generic;");
+            c.Line("using System.Linq;");
+            c.Line("using org.apache.plc4net.spi.drivers;");
+            c.Line("using org.apache.plc4net.spi.generation;");
+            c.Line();
+            c.Line($"namespace {_namespace}.model");
+            c.Line("{");
+            c.Indent();
+            c.Line("/// <summary>");
+            c.Line("/// Shared helpers required by the generated protocol model.");
+            c.Line("/// </summary>");
+            c.Line($"public static partial class {cls}");
+            c.Line("{");
+            c.Indent();
+            c.Line("/// <summary>Serialized byte length of a sequence of messages.</summary>");
+            c.Line("public static int ArraySizeInBytes<T>(IEnumerable<T> items) where T : IMessage");
+            c.Indent();
+            c.Line("=> items?.Sum(i => i.GetLengthInBytes()) ?? 0;");
+            c.Outdent();
+            c.Line();
+            c.Line("public static byte BcdToBin(byte value) => (byte) ((value >> 4) * 10 + (value & 0x0F));");
+            c.Line("public static ushort BcdToBin12(ushort value) => (ushort) (((value >> 8) & 0x0F) * 100 + ((value >> 4) & 0x0F) * 10 + (value & 0x0F));");
+            c.Line("public static byte BinToBcd(int value) => (byte) ((value / 10) * 16 + (value % 10));");
+            c.Line("public static ushort BinToBcd12(int value) => (ushort) ((value / 100) * 256 + ((value / 10) % 10) * 16 + (value % 10));");
+            foreach (var (name, retType) in staticCalls)
+            {
+                c.Line();
+                EmitStaticCall(c, name, retType);
+            }
+            c.Outdent();
+            c.Line("}");
+            c.Outdent();
+            c.Line("}");
+            return c.ToString();
+        }
+
+        private static string RenderEnumUnderlyingValue(EnumValue value, CSharpExpressionRenderer r) =>
+            value.Value == null ? value.Name : r.Render(value.Value);
+
+        private void EmitStaticCall(CodeWriter c, string name, string retType)
+        {
+            if (_protocolName.Equals("modbus", StringComparison.OrdinalIgnoreCase)
+                && name == "AsciiLrcCheck")
+            {
+                c.Line("public static ushort AsciiLrcCheck(byte address, IMessage pdu)");
+                c.Line("{");
+                c.Indent();
+                c.Line("var buffer = new WriteBuffer();");
+                c.Line("buffer.WriteByte(\"address\", 8, address);");
+                c.Line("pdu.Serialize(buffer);");
+                c.Line("var sum = 0;");
+                c.Line("foreach (var b in buffer.GetBytes()) sum = (sum + b) & 0xFF;");
+                c.Line("return (ushort) ((-sum) & 0xFF);");
+                c.Outdent();
+                c.Line("}");
+                return;
+            }
+            if (_protocolName.Equals("modbus", StringComparison.OrdinalIgnoreCase)
+                && name == "RtuCrcCheck")
+            {
+                c.Line("public static ushort RtuCrcCheck(byte address, IMessage pdu)");
+                c.Line("{");
+                c.Indent();
+                c.Line("var buffer = new WriteBuffer();");
+                c.Line("buffer.WriteByte(\"address\", 8, address);");
+                c.Line("pdu.Serialize(buffer);");
+                c.Line("ushort crc = 0xFFFF;");
+                c.Line("foreach (var b in buffer.GetBytes())");
+                c.Line("{");
+                c.Indent();
+                c.Line("crc ^= b;");
+                c.Line("for (var bit = 0; bit < 8; bit++) crc = (crc & 1) != 0 ? (ushort) ((crc >> 1) ^ 0xA001) : (ushort) (crc >> 1);");
+                c.Outdent();
+                c.Line("}");
+                // ReadBuffer/WriteBuffer are MSB-first. Modbus RTU transports
+                // the low CRC byte first, so return the swapped 16-bit value.
+                c.Line("return (ushort) ((crc << 8) | (crc >> 8));");
+                c.Outdent();
+                c.Line("}");
+                return;
+            }
+            throw new InvalidOperationException($"No C# implementation is registered for STATIC_CALL '{name}' ({retType}).");
+        }
+
+        private void CollectStaticCalls(IDictionary<string, string> sink)
+        {
+            foreach (var type in _protocol.Types)
+            {
+                foreach (var f in type.Fields)
+                {
+                    var retType = f.Type != null ? CSharpTypeMapper.CSharpType(f.Type) : "object";
+                    foreach (var term in FieldTerms(f))
+                    {
+                        WalkStaticCalls(term, retType, sink);
+                    }
+                }
+            }
+            foreach (var dio in _protocol.DataIos)
+            {
+                foreach (var c in dio.Cases)
+                {
+                    foreach (var f in c.Fields)
+                    {
+                        var retType = f.Type != null ? CSharpTypeMapper.CSharpType(f.Type) : "object";
+                        foreach (var term in FieldTerms(f))
+                        {
+                            WalkStaticCalls(term, retType, sink);
+                        }
+                    }
+                }
+            }
+
+        }
+
+        private bool IsGeneratedStaticCall(string name) =>
+            _protocolName.Equals("modbus", StringComparison.OrdinalIgnoreCase)
+            && (name == "RtuCrcCheck" || name == "AsciiLrcCheck");
+
+        private static bool IsSupportedDataIoCase(DataIoTypeDefinition dio, ComplexTypeDefinition cs)
+        {
+            var props = cs.PropertyFields.ToList();
+            var valueField = props.Count == 1 ? props[0] : null;
+            var listField = valueField as ArrayField;
+            var temporal = TemporalPlcValueCases.Contains(cs.Name)
+                           && dio.Arguments.Any(a => a.Name == "dataProtocolId")
+                           && props.All(f => f is not ArrayField);
+            var scalar = valueField is not (null or ArrayField) && ScalarPlcValueCases.Contains(cs.Name);
+            var structCase = cs.Name == "Struct"
+                             && props.Count > 0
+                             && props.All(f => f is SimpleField { Type: SimpleTypeReference }
+                                 || f is ArrayField
+                                 {
+                                     Type: SimpleTypeReference { BaseType: SimpleTypeReference.Base.Byte },
+                                     LoopType: ArrayField.Loop.Count,
+                                 });
+            return temporal || scalar || structCase
+                || listField?.Type is SimpleTypeReference && listField.LoopType == ArrayField.Loop.Count;
+        }
+
+        private static void WalkStaticCalls(Term t, string retType, IDictionary<string, string> sink)
+        {
+            switch (t)
+            {
+                case VariableLiteral { Name: "STATIC_CALL" } v
+                    when v.Args?.Count >= 1 && v.Args[0] is StringLiteral s:
+                    sink[Pascal(s.Value)] = retType;
+                    break;
+                case VariableLiteral v:
+                    foreach (var a in v.Args ?? Enumerable.Empty<Term>())
+                    {
+                        WalkStaticCalls(a, retType, sink);
+                    }
+                    if (v.Child != null)
+                    {
+                        WalkStaticCalls(v.Child, retType, sink);
+                    }
+                    break;
+                case BinaryExpression b:
+                    WalkStaticCalls(b.Left, retType, sink);
+                    WalkStaticCalls(b.Right, retType, sink);
+                    break;
+                case UnaryExpression u:
+                    WalkStaticCalls(u.Operand, retType, sink);
+                    break;
+                case TernaryExpression tt:
+                    WalkStaticCalls(tt.Condition, retType, sink);
+                    WalkStaticCalls(tt.WhenTrue, retType, sink);
+                    WalkStaticCalls(tt.WhenFalse, retType, sink);
+                    break;
+            }
+        }
+
+        private static IEnumerable<Term> FieldTerms(Field f)
+        {
+            switch (f)
+            {
+                case ImplicitField i: yield return i.SerializeExpression; break;
+                case ChecksumField ck: yield return ck.ChecksumExpression; break;
+                case ArrayField a: yield return a.LoopExpression; break;
+                case VirtualField v: yield return v.ValueExpression; break;
+                case ConstField c2: yield return c2.ReferenceValue; break;
+                case ReservedField r: yield return r.ReferenceValue; break;
+                case OptionalField o when o.Condition != null: yield return o.Condition; break;
+                case ManualField m:
+                    yield return m.ParseExpression;
+                    yield return m.SerializeExpression;
+                    yield return m.LengthExpression;
+                    break;
+                case PaddingField p:
+                    yield return p.PaddingValue;
+                    yield return p.TimesPadding;
+                    break;
+            }
+        }
+
+        // ── helpers ─────────────────────────────────────────────
+
+        private string ReadFieldValue(Field f, CSharpExpressionRenderer r) =>
+            f.Type is SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString, LengthExpression: { } len }
+                ? $"readBuffer.ReadString(\"{f.Name}\", (int) ({r.Render(len)}), {CSharpTypeMapper.EncodingExpr(VStringEncoding(f))})"
+                : CSharpTypeMapper.ReadCall(f.Type, f.Name, ComplexArgs(f.Type, r));
+
+        private static string VStringEncoding(Field f) =>
+            (f.Type as SimpleTypeReference)?.Encoding
+            ?? (f.Attributes.TryGetValue("encoding", out var e) && e is StringLiteral s ? s.Value : "UTF8");
+
+        /// <summary>Reads an <c>enum</c> field. With a key accessor
+        /// (<c>[enum TransportSize transportSize code]</c>) the wire value is
+        /// the key, mapped back through the generated reverse lookup.</summary>
+        private string EnumFieldRead(EnumField ef, CSharpExpressionRenderer r)
+        {
+            if (ef.Type is EnumTypeReference et && ef.KeyAccessor != null)
+            {
+                var baseRead = CSharpTypeMapper.ReadCall(et.BaseType, ef.Name);
+                return $"({et.Name}Extensions.FirstEnumForField{Pascal(ef.KeyAccessor)}({baseRead}) "
+                       + $"?? throw new ParseException(\"unrecognised {et.Name} {ef.KeyAccessor} on the wire\"))";
+            }
+            return ReadFieldValue(ef, r);
+        }
+
+        private string EnumFieldWrite(EnumField ef)
+        {
+            if (ef.Type is EnumTypeReference et && ef.KeyAccessor != null)
+            {
+                var baseCs = CSharpTypeMapper.SimpleCSharpType(et.BaseType);
+                var val = $"({baseCs}) {Pascal(ef.Name)}.Get{Pascal(ef.KeyAccessor)}()";
+                return CSharpTypeMapper.WriteCall(et.BaseType, ef.Name, val);
+            }
+            return CSharpTypeMapper.WriteCall(ef.Type, ef.Name, Pascal(ef.Name));
+        }
+
+        /// <summary><see cref="ComplexArgs"/> plus the <c>_lastItem</c> flag when
+        /// the array loop knows it.</summary>
+        private string ElementArgs(TypeReference type, CSharpExpressionRenderer r, string? lastItem)
+        {
+            var args = ComplexArgs(type, r);
+            if (lastItem == null)
+            {
+                return args;
+            }
+            return args.Length == 0 ? lastItem : $"{args}, {lastItem}";
+        }
+
+        private string ComplexArgs(TypeReference? type, CSharpExpressionRenderer r)
+        {
+            if (type is not ComplexTypeReference c)
+            {
+                return "";
+            }
+            // Cast each argument to the callee's declared parameter type: an
+            // mspec count / position expression is `int`, but a `uint 8`
+            // parameter is `byte`.
+            var callee = _protocol.FindType(c.Name);
+            return string.Join(", ", c.Arguments.Select((a, i) =>
+            {
+                var rendered = r.Render(a);
+                var pType = callee != null && i < callee.Arguments.Count
+                    ? callee.Arguments[i].Type
+                    : null;
+                return pType != null ? WithType(rendered, pType) : rendered;
+            }));
+        }
+
+        // An array field's element type is always given in the grammar.
+        private static TypeReference ElementType(ArrayField af) => af.Type!;
+
+        private string PropertyType(Field f)
+        {
+            if (f is ArrayField af)
+            {
+                var (t, _) = CSharpTypeMapper.ArrayType(af.Type);
+                return t;
+            }
+            var t2 = CSharpTypeMapper.CSharpType(f.Type);
+            // An optional field is absent when its condition is false.
+            return f is OptionalField ? t2 + "?" : t2;
+        }
+
+        /// <summary>One switch discriminator, resolved against the parent.</summary>
+        private sealed class Discriminator
+        {
+            /// <summary>The C# accessor name a child would override, or null
+            /// when the value already lives in a property or is a dotted path
+            /// (both cases: no accessor is generated).</summary>
+            public string? AccessorName { get; init; }
+
+            public required TypeReference Type { get; init; }
+
+            /// <summary>The C# that reads the value during dispatch - a local
+            /// (a discriminator or simple field the parent already read), a
+            /// parser-argument name, or a rendered dotted path.</summary>
+            public required string DispatchExpr { get; init; }
+        }
+
+        private List<Discriminator> DiscriminatorList(
+            ComplexTypeDefinition parent, CSharpExpressionRenderer? render = null)
+        {
+            var ts = parent.TypeSwitch;
+            var result = new List<Discriminator>();
+            if (ts == null)
+            {
+                return result;
+            }
+
+            foreach (var term in ts.Discriminators.OfType<VariableLiteral>())
+            {
+                if (term.Child != null || term.IsCall)
+                {
+                    // e.g. parameter.parameterType - used for dispatch, never an accessor.
+                    var r = render ?? new CSharpExpressionRenderer(new FieldScope(_protocol, parent));
+                    result.Add(new Discriminator
+                    {
+                        AccessorName = null,
+                        Type = new SimpleTypeReference { BaseType = SimpleTypeReference.Base.UInt, SizeInBits = 8 },
+                        DispatchExpr = r.Render(term),
+                    });
+                    continue;
+                }
+
+                var name = term.Name;
+                var field = parent.Fields.FirstOrDefault(x => x.Name == name);
+                var arg = parent.Arguments.FirstOrDefault(a => a.Name == name);
+                var type = field?.Type ?? arg?.Type
+                    ?? new SimpleTypeReference { BaseType = SimpleTypeReference.Base.UInt, SizeInBits = 8 };
+
+                result.Add(new Discriminator
+                {
+                    // A property field carries the value itself; only a bare
+                    // `discriminator` / parser-arg gets an accessor.
+                    AccessorName = field is { IsProperty: true } or VirtualField ? null : name,
+                    Type = type,
+                    DispatchExpr = Camel(name),
+                });
+            }
+            return result;
+        }
+
+        private IEnumerable<string> ParserArgDiscriminatorNames(ComplexTypeDefinition parent) =>
+            DiscriminatorList(parent)
+                .Where(d => d.AccessorName != null
+                            && parent.Arguments.Any(a => a.Name == d.AccessorName)
+                            && parent.Fields.All(f => f.Name != d.AccessorName))
+                .Select(d => d.AccessorName!);
+
+        private IReadOnlyList<Field> ParentPrefixFields(ComplexTypeDefinition child) =>
+            child.ParentName != null && _protocol.FindType(child.ParentName) is { } p
+                ? p.PrefixPropertyFields.ToList()
+                : Array.Empty<Field>();
+
+        private IReadOnlyList<Field> ParentSuffixFields(ComplexTypeDefinition child) =>
+            child.ParentName != null && _protocol.FindType(child.ParentName) is { } p
+                ? p.SuffixPropertyFields.ToList()
+                : Array.Empty<Field>();
+
+        /// <summary>The parent's pre-typeSwitch value-carrying fields that this
+        /// child actually names in one of its own or trailing-field
+        /// expressions - the child's <c>StaticParse</c> takes these as extra
+        /// parameters so those expressions resolve. Filtered to what is
+        /// referenced so an unrelated protocol (Modbus) gets no spurious
+        /// parameters.</summary>
+        private IReadOnlyList<Field> ParentContextFields(ComplexTypeDefinition child)
+        {
+            if (child.ParentName == null || _protocol.FindType(child.ParentName) is not { } p)
+            {
+                return Array.Empty<Field>();
+            }
+            var referenced = new HashSet<string>();
+            foreach (var f in child.Fields.Concat(p.FieldsAfterSwitch))
+            {
+                foreach (var t in AllFieldTerms(f))
+                {
+                    CollectNames(t, referenced);
+                }
+            }
+            // Prefix property fields are always needed - the child's constructor
+            // takes them (see AllValueFields). Prefix discriminators / implicits
+            // / consts come along only when an expression names them.
+            return p.PrefixContextFields
+                .Where(f => f.IsProperty || referenced.Contains(f.Name))
+                .ToList();
+        }
+
+        private static IEnumerable<Term> AllFieldTerms(Field f)
+        {
+            foreach (var t in FieldTerms(f))
+            {
+                yield return t;
+            }
+            if (f.Type is ComplexTypeReference c)
+            {
+                foreach (var a in c.Arguments)
+                {
+                    yield return a;
+                }
+            }
+        }
+
+        private static void CollectNames(Term? t, HashSet<string> sink)
+        {
+            switch (t)
+            {
+                case VariableLiteral v:
+                    if (v.Name != null)
+                    {
+                        sink.Add(v.Name);
+                    }
+                    foreach (var a in v.Args ?? Enumerable.Empty<Term>())
+                    {
+                        CollectNames(a, sink);
+                    }
+                    foreach (var ix in v.Index ?? Enumerable.Empty<Term>())
+                    {
+                        CollectNames(ix, sink);
+                    }
+                    CollectNames(v.Child, sink);
+                    break;
+                case BinaryExpression b:
+                    CollectNames(b.Left, sink);
+                    CollectNames(b.Right, sink);
+                    break;
+                case UnaryExpression u:
+                    CollectNames(u.Operand, sink);
+                    break;
+                case TernaryExpression tt:
+                    CollectNames(tt.Condition, sink);
+                    CollectNames(tt.WhenTrue, sink);
+                    CollectNames(tt.WhenFalse, sink);
+                    break;
+            }
+        }
+
+        /// <summary>Constructor parameters and property fields for a type, in
+        /// wire order: inherited prefix, own, inherited suffix.</summary>
+        private List<Field> AllValueFields(ComplexTypeDefinition type) =>
+            ParentPrefixFields(type)
+                .Concat(type.PropertyFields)
+                .Concat(ParentSuffixFields(type))
+                .ToList();
+
+        /// <summary>The fields this type's own parse / serialize / length
+        /// covers. A discriminated parent stops at the typeSwitch; a child
+        /// picks up the parent's trailing fields after its own body.</summary>
+        private IReadOnlyList<Field> FieldsToEmit(ComplexTypeDefinition type)
+        {
+            if (type.IsDiscriminatedParent)
+            {
+                return type.FieldsThroughSwitch.ToList();
+            }
+            if (type.IsDiscriminatedChild && _protocol.FindType(type.ParentName) is { } p)
+            {
+                return type.Fields.Concat(p.FieldsAfterSwitch).ToList();
+            }
+            return type.Fields;
+        }
+
+        private string RenderDiscriminatorValue(Term? value, TypeReference? dType, CSharpExpressionRenderer r)
+        {
+            if (dType is EnumTypeReference e && value is VariableLiteral v && v.Child == null && !v.IsCall)
+            {
+                return $"{e.Name}.{v.Name}";
+            }
+            return WithType(r.Render(value), dType);
+        }
+
+        private static string WithType(string rendered, TypeReference? type)
+        {
+            if (type is SimpleTypeReference s && s.IsIntegerLike && s.BaseType != SimpleTypeReference.Base.Bit)
+            {
+                return $"({CSharpTypeMapper.SimpleCSharpType(s)}) ({rendered})";
+            }
+            return rendered;
+        }
+
+        private List<string> ParseSignatureArgs(ComplexTypeDefinition type)
+        {
+            // A child inherits the parent's parser arguments, and also takes
+            // the parent's prefix fields (read by the parent, passed down so
+            // the child can build a complete object).
+            // A discriminated child's own ParentName always resolves - the
+            // builder only sets it while lifting the child out of its parent's
+            // typeSwitch.
+            var owner = type.IsDiscriminatedChild
+                ? _protocol.FindType(type.ParentName)
+                    ?? throw new InvalidOperationException($"Type '{type.ParentName}' (parent of '{type.Name}') was not found.")
+                : type;
+            var args = owner.Arguments
+                .Select(a => $"{CSharpTypeMapper.CSharpType(a.Type)} {Camel(a.Name)}")
+                .ToList();
+            args.AddRange(ParentContextFields(type)
+                .Select(f => $"{PropertyType(f)} {Camel(f.Name)}"));
+            if (type.IsDiscriminatedChild && UsesCurPos(type))
+            {
+                args.Add("int _startPos");
+            }
+            if (UsesLastItem(type))
+            {
+                args.Add("bool _lastItem = false");
+            }
+            return args;
+        }
+
+        /// <summary>Whether an expression in this type reads <c>_lastItem</c> -
+        /// true only for a type used as an array element whose last element is
+        /// laid out differently (S7's <c>S7VarPayloadDataItem</c> padding).</summary>
+        private bool UsesLastItem(ComplexTypeDefinition type)
+        {
+            var names = new HashSet<string>();
+            foreach (var f in FieldsToEmit(type))
+            {
+                foreach (var t in AllFieldTerms(f))
+                {
+                    CollectNames(t, names);
+                }
+            }
+            return names.Contains("_lastItem");
+        }
+
+        /// <summary>Whether <paramref name="af"/>'s element type threads
+        /// <c>_lastItem</c> - the loop then has to pass the per-iteration flag
+        /// and use an index-aware serialize / length.</summary>
+        private bool ElementUsesLastItem(ArrayField af) =>
+            ElementType(af) is ComplexTypeReference c
+            && _protocol.FindType(c.Name) is { } et
+            && UsesLastItem(et);
+
+        /// <summary>Whether this type's own parse emits a field whose expression
+        /// reads <c>curPos</c> - it then needs <c>_startPos</c> in scope.</summary>
+        private bool UsesCurPos(ComplexTypeDefinition type)
+        {
+            var names = new HashSet<string>();
+            foreach (var f in FieldsToEmit(type))
+            {
+                foreach (var t in AllFieldTerms(f))
+                {
+                    CollectNames(t, names);
+                }
+            }
+            return names.Contains("curPos") || names.Contains("_curPos");
+        }
+
+        /// <summary>A discriminated parent that itself does not read
+        /// <c>curPos</c> still captures <c>_startPos</c> when a child needs it,
+        /// to pass down.</summary>
+        private bool NeedsStartPos(ComplexTypeDefinition type) =>
+            UsesCurPos(type)
+            || type.IsDiscriminatedParent
+               && type.TypeSwitch is { } ts
+               && ts.CaseNames
+                   .Select(_protocol.FindType)
+                   .OfType<ComplexTypeDefinition>()
+                   .Any(UsesCurPos);
+
+        private IEnumerable<string> ChildParseArgNames(ComplexTypeDefinition child)
+        {
+            // Every discriminated child's ParentName resolves - see the
+            // comment in ParseSignatureArgs.
+            var parent = _protocol.FindType(child.ParentName)
+                ?? throw new InvalidOperationException($"Type '{child.ParentName}' (parent of '{child.Name}') was not found.");
+            return parent.Arguments.Select(a => Camel(a.Name));
+        }
+
+        private static string Comma(IEnumerable<string> parts)
+        {
+            var list = parts.ToList();
+            return list.Count == 0 ? "" : ", " + string.Join(", ", list);
+        }
+
+        private static string Comma(string s) => string.IsNullOrEmpty(s) ? "" : ", " + s;
+
+        private void Header(CodeWriter c)
+        {
+            foreach (var line in LicenseHeader)
+            {
+                c.Line(line);
+            }
+            c.Line();
+            c.Line("// Code generated from the mspec by plc4net-code-gen. DO NOT EDIT.");
+            c.Line();
+        }
+
+        private static readonly string[] LicenseHeader =
+        {
+            "//",
+            "// Licensed to the Apache Software Foundation (ASF) under one",
+            "// or more contributor license agreements.  See the NOTICE file",
+            "// distributed with this work for additional information",
+            "// regarding copyright ownership.  The ASF licenses this file",
+            "// to you under the Apache License, Version 2.0 (the",
+            "// \"License\"); you may not use this file except in compliance",
+            "// with the License.  You may obtain a copy of the License at",
+            "//",
+            "//      https://www.apache.org/licenses/LICENSE-2.0",
+            "//",
+            "// Unless required by applicable law or agreed to in writing,",
+            "// software distributed under the License is distributed on an",
+            "// \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY",
+            "// KIND, either express or implied.  See the License for the",
+            "// specific language governing permissions and limitations",
+            "// under the License.",
+            "//",
+        };
+
+        internal static string Pascal(string s) =>
+            string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s.Substring(1);
+
+        internal static string Camel(string s) =>
+            string.IsNullOrEmpty(s) ? s : char.ToLowerInvariant(s[0]) + s.Substring(1);
+    }
+}
diff --git a/plc4net/tools/code-gen/output/CSharpTypeMapper.cs b/plc4net/tools/code-gen/output/CSharpTypeMapper.cs
new file mode 100644
index 0000000000..c5761be75a
--- /dev/null
+++ b/plc4net/tools/code-gen/output/CSharpTypeMapper.cs
@@ -0,0 +1,192 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System;
+using org.apache.plc4net.tools.codegen.model;
+
+namespace org.apache.plc4net.tools.codegen.output
+{
+    /// <summary>
+    /// Maps an mspec <see cref="TypeReference"/> to the C# type, and to the
+    /// <c>ReadBuffer</c> / <c>WriteBuffer</c> call that reads or writes it.
+    /// </summary>
+    /// <remarks>
+    /// Targets plc4net's hand-written buffer API: <c>ReadUshort("name", 16)</c>
+    /// with the width passed explicitly, no context stack and no byte-order
+    /// argument (the buffers are MSB-first / big-endian, which is what every
+    /// mspec-described protocol here uses).
+    /// </remarks>
+    public static class CSharpTypeMapper
+    {
+        /// <summary>The C# type used for a field or property of this reference.
+        /// Null (a typeSwitch pseudo-field) maps to <c>object</c>, same as any
+        /// other unrecognised shape - callers never actually render this for
+        /// a typeSwitch, since typeSwitch has no property/parse/serialize of
+        /// its own.</summary>
+        public static string CSharpType(TypeReference? type) => type switch
+        {
+            EnumTypeReference e => e.Name,
+            ComplexTypeReference c => c.Name,
+            SimpleTypeReference s => SimpleCSharpType(s),
+            _ => "object",
+        };
+
+        public static string SimpleCSharpType(SimpleTypeReference s) => s.BaseType switch
+        {
+            SimpleTypeReference.Base.Bit => "bool",
+            SimpleTypeReference.Base.Byte => "byte",
+            SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? "byte"
+                : s.SizeInBits <= 16 ? "ushort"
+                : s.SizeInBits <= 32 ? "uint" : "ulong",
+            SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? "sbyte"
+                : s.SizeInBits <= 16 ? "short"
+                : s.SizeInBits <= 32 ? "int" : "long",
+            SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+                s.SizeInBits <= 32 ? "float" : "double",
+            SimpleTypeReference.Base.String or SimpleTypeReference.Base.VString => "string",
+            SimpleTypeReference.Base.Time or SimpleTypeReference.Base.Date
+                or SimpleTypeReference.Base.DateTime => "long",
+            _ => "object",
+        };
+
+        /// <summary>
+        /// The C# element type of an array of <paramref name="element"/>, and
+        /// whether the array is <c>byte[]</c> (vs <c>List&lt;T&gt;</c>).
+        /// </summary>
+        public static (string Type, bool IsByteArray) ArrayType(TypeReference? element)
+        {
+            if (element is SimpleTypeReference { IsByteBased: true })
+            {
+                return ("byte[]", true);
+            }
+            return ($"System.Collections.Generic.List<{CSharpType(element)}>", false);
+        }
+
+        /// <summary>Bit width of a fixed-size simple type; -1 if run-time sized
+        /// or the type is null (typeSwitch has none).</summary>
+        public static int FixedBitLength(TypeReference? type) => type switch
+        {
+            SimpleTypeReference { BaseType: SimpleTypeReference.Base.VString } => -1,
+            SimpleTypeReference s => s.SizeInBits,
+            EnumTypeReference e => e.BaseType.SizeInBits,
+            _ => -1,
+        };
+
+        /// <summary>
+        /// A <c>ReadBuffer</c> call that reads one value of
+        /// <paramref name="type"/>. <paramref name="logicalName"/> is the field
+        /// name for diagnostics; <paramref name="complexArgs"/> is the rendered
+        /// argument list for a complex type's <c>StaticParse</c>.
+        /// </summary>
+        public static string ReadCall(
+            TypeReference? type, string logicalName, string complexArgs = "")
+        {
+            switch (type)
+            {
+                case EnumTypeReference e:
+                    return $"({e.Name}) {ReadCall(e.BaseType, logicalName)}";
+                case ComplexTypeReference c:
+                    return $"{c.Name}.StaticParse(readBuffer{Prefix(complexArgs)})";
+                case SimpleTypeReference s:
+                    return SimpleReadCall(s, logicalName);
+                default:
+                    throw new NotSupportedException($"No read for {type}");
+            }
+        }
+
+        private static string SimpleReadCall(SimpleTypeReference s, string n)
+        {
+            var q = $"\"{n}\"";
+            return s.BaseType switch
+            {
+                SimpleTypeReference.Base.Bit => $"readBuffer.ReadBit({q})",
+                SimpleTypeReference.Base.Byte => $"readBuffer.ReadByte({q}, 8)",
+                SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? $"readBuffer.ReadByte({q}, {s.SizeInBits})"
+                    : s.SizeInBits <= 16 ? $"readBuffer.ReadUshort({q}, {s.SizeInBits})"
+                    : s.SizeInBits <= 32 ? $"readBuffer.ReadUint({q}, {s.SizeInBits})"
+                    : $"readBuffer.ReadUlong({q}, {s.SizeInBits})",
+                SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? $"readBuffer.ReadSbyte({q}, {s.SizeInBits})"
+                    : s.SizeInBits <= 16 ? $"readBuffer.ReadShort({q}, {s.SizeInBits})"
+                    : s.SizeInBits <= 32 ? $"readBuffer.ReadInt({q}, {s.SizeInBits})"
+                    : $"readBuffer.ReadLong({q}, {s.SizeInBits})",
+                SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+                    s.SizeInBits <= 16 ? $"readBuffer.ReadFloat({q}, 16)"
+                        : s.SizeInBits <= 32 ? $"readBuffer.ReadFloat({q}, 32)"
+                        : $"readBuffer.ReadDouble({q}, 64)",
+                SimpleTypeReference.Base.String =>
+                    $"readBuffer.ReadString({q}, {s.SizeInBits}, {EncodingExpr(s.Encoding)})",
+                _ => throw new NotSupportedException($"No read for {s}"),
+            };
+        }
+
+        /// <summary>
+        /// A <c>WriteBuffer</c> statement that writes <paramref name="valueExpr"/>
+        /// as <paramref name="type"/>.
+        /// </summary>
+        public static string WriteCall(TypeReference? type, string logicalName, string valueExpr)
+        {
+            var q = $"\"{logicalName}\"";
+            switch (type)
+            {
+                case EnumTypeReference e:
+                    return WriteCall(e.BaseType, logicalName,
+                        $"({SimpleCSharpType(e.BaseType)}) {valueExpr}");
+                case ComplexTypeReference _:
+                    return $"{valueExpr}.Serialize(writeBuffer)";
+                case SimpleTypeReference s:
+                    return s.BaseType switch
+                    {
+                        SimpleTypeReference.Base.Bit => $"writeBuffer.WriteBit({q}, {valueExpr})",
+                        SimpleTypeReference.Base.Byte => $"writeBuffer.WriteByte({q}, 8, {valueExpr})",
+                        SimpleTypeReference.Base.UInt => s.SizeInBits <= 8 ? $"writeBuffer.WriteByte({q}, {s.SizeInBits}, {valueExpr})"
+                            : s.SizeInBits <= 16 ? $"writeBuffer.WriteUshort({q}, {s.SizeInBits}, {valueExpr})"
+                            : s.SizeInBits <= 32 ? $"writeBuffer.WriteUint({q}, {s.SizeInBits}, {valueExpr})"
+                            : $"writeBuffer.WriteUlong({q}, {s.SizeInBits}, {valueExpr})",
+                        SimpleTypeReference.Base.Int => s.SizeInBits <= 8 ? $"writeBuffer.WriteSbyte({q}, {s.SizeInBits}, {valueExpr})"
+                            : s.SizeInBits <= 16 ? $"writeBuffer.WriteShort({q}, {s.SizeInBits}, {valueExpr})"
+                            : s.SizeInBits <= 32 ? $"writeBuffer.WriteInt({q}, {s.SizeInBits}, {valueExpr})"
+                            : $"writeBuffer.WriteLong({q}, {s.SizeInBits}, {valueExpr})",
+                        SimpleTypeReference.Base.Float or SimpleTypeReference.Base.UFloat =>
+                            s.SizeInBits <= 16 ? $"writeBuffer.WriteFloat({q}, 16, {valueExpr})"
+                                : s.SizeInBits <= 32 ? $"writeBuffer.WriteFloat({q}, 32, {valueExpr})"
+                                : $"writeBuffer.WriteDouble({q}, 64, {valueExpr})",
+                        SimpleTypeReference.Base.String =>
+                            $"writeBuffer.WriteString({q}, {s.SizeInBits}, \"{s.Encoding ?? "UTF8"}\", {valueExpr})",
+                        _ => throw new NotSupportedException($"No write for {s}"),
+                    };
+                default:
+                    throw new NotSupportedException($"No write for {type}");
+            }
+        }
+
+        /// <summary>The <c>System.Text.Encoding</c> expression for an mspec
+        /// string encoding name (<c>"UTF8"</c>, <c>"UTF16BE"</c>, …).</summary>
+        public static string EncodingExpr(string? encoding) =>
+            (encoding ?? "UTF8").Replace("-", "").ToUpperInvariant() switch
+            {
+                "UTF16" or "UTF16LE" => "System.Text.Encoding.Unicode",
+                "UTF16BE" => "System.Text.Encoding.BigEndianUnicode",
+                "ASCII" => "System.Text.Encoding.ASCII",
+                _ => "System.Text.Encoding.UTF8",
+            };
+
+        private static string Prefix(string args) =>
+            string.IsNullOrEmpty(args) ? "" : ", " + args;
+    }
+}
diff --git a/plc4net/tools/code-gen/output/CodeWriter.cs b/plc4net/tools/code-gen/output/CodeWriter.cs
new file mode 100644
index 0000000000..fcb1a02cad
--- /dev/null
+++ b/plc4net/tools/code-gen/output/CodeWriter.cs
@@ -0,0 +1,46 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Text;
+
+namespace org.apache.plc4net.tools.codegen.output
+{
+    /// <summary>A minimal indent-aware source writer.</summary>
+    internal sealed class CodeWriter
+    {
+        private readonly StringBuilder _sb = new StringBuilder();
+        private int _depth;
+
+        public void Indent() => _depth++;
+
+        public void Outdent() => _depth = _depth > 0 ? _depth - 1 : 0;
+
+        public void Line(string text = "")
+        {
+            if (text.Length == 0)
+            {
+                _sb.Append('\n');
+                return;
+            }
+            _sb.Append(new string(' ', _depth * 4)).Append(text).Append('\n');
+        }
+
+        public override string ToString() => _sb.ToString();
+    }
+}
diff --git a/plc4net/tools/code-gen/output/FieldScope.cs b/plc4net/tools/code-gen/output/FieldScope.cs
new file mode 100644
index 0000000000..97bac253ea
--- /dev/null
+++ b/plc4net/tools/code-gen/output/FieldScope.cs
@@ -0,0 +1,198 @@
+//
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//      https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+//
+
+using System.Collections.Generic;
+using System.Linq;
+using org.apache.plc4net.tools.codegen.model;
+using org.apache.plc4net.tools.codegen.model.fields;
+
+namespace org.apache.plc4net.tools.codegen.output
+{
+    /// <summary>
+    /// Resolves the names in an mspec expression against one complex type's
+    /// fields and parser arguments. During parse, a field name is a local
+    /// holding the value just read; during serialize, it is the property. An
+    /// enum member is <c>Enum.Member</c>; an unknown name is left alone.
+    /// </summary>
+    internal sealed class FieldScope : IExpressionScope
+    {
+        private readonly Dictionary<string, TypeReference?> _byName = new Dictionary<string, TypeReference?>();
+        private readonly HashSet<string> _enumNames;
+        // constant name -> "EnumName.CONSTANT" (first enum wins on a clash)
+        private readonly Dictionary<string, string> _enumConstants;
+        // every enum attribute-accessor name, e.g. `sizeInBits`, `code` - a
+        // member access on one of these is a Get… extension-method call.
+        private readonly HashSet<string> _enumAccessors;
+        private readonly bool _serialize;
+
+        public FieldScope(Protocol protocol, ComplexTypeDefinition type, bool serialize = false)
+        {
+            _serialize = serialize;
+            _hoistedLocals = new HashSet<string>();
+            _enumNames = new HashSet<string>(protocol.Enums.Select(e => e.Name));
+            _enumAccessors = new HashSet<string>(
+                protocol.Enums.SelectMany(e => e.Arguments.Select(a => a.Name)));
+            _enumConstants = new Dictionary<string, string>();
+            foreach (var e in protocol.Enums)
+            {
+                foreach (var v in e.Values)
+                {
+                    _enumConstants.TryAdd(v.Name, $"{e.Name}.{v.Name}");
+                }
+            }
+
+            var owner = type.ParentName != null ? protocol.FindType(type.ParentName) : type;
+            if (owner != null)
+            {
+                foreach (var a in owner.Arguments)
+                {
+                    _byName[a.Name] = a.Type;
+                }
+                foreach (var f in owner.Fields.Where(f => f.Name != null))
+                {
+                    _byName[f.Name] = f.Type;
+                }
+            }
+            foreach (var f in type.Fields.Where(f => f.Name != null))
+            {
+                _byName[f.Name] = f.Type;
+            }
+        }
+
+        private FieldScope(FieldScope src, bool serialize)
+        {
+            _serialize = serialize;
+            _enumNames = src._enumNames;
+            _enumAccessors = src._enumAccessors;
+            _enumConstants = src._enumConstants;
+            _byName = src._byName;
+            _hoistedLocals = src._hoistedLocals;
+        }
+
+        public FieldScope ForSerialize() => new FieldScope(this, serialize: true);
+
+        /// <summary>The C# for <c>_value</c> - the field's own value, set by the
+        /// generator around a <c>manual</c> field's serialize expression.</summary>
+        public string? CurrentValue { get; set; }
+
+        /// <summary>Names the generator has hoisted as a plain local (a
+        /// recomputed implicit field in a child's serialize / length) - these
+        /// resolve to the camelCase local, not a Pascal property. Shared with
+        /// the serialize view so a mark on either is seen by both.</summary>
+        private readonly HashSet<string> _hoistedLocals;
+
+        public void MarkLocal(string name) => _hoistedLocals.Add(name);
+
+        public void ClearLocals() => _hoistedLocals.Clear();
+
+        public string? ResolveReference(string name)
+        {
+            switch (name)
+            {
+                // mspec context variables available inside manual / expression fields
+                case "readBuffer":
+                case "writeBuffer":
+                    return name;
+                case "_lastItem":
+                    // the generator gives a type that needs it a `_lastItem`
+                    // parameter, threaded from the enclosing array loop.
+                    return "_lastItem";
+                case "curPos":
+                case "_curPos":
+                    // mspec curPos is bytes consumed since this type's parse
+                    // began; the generator captures _startPos (bits) at entry.
+                    return _serialize
+                        ? "((writeBuffer.GetPos() - _startPos) / 8)"
+                        : "((readBuffer.GetPos() - _startPos) / 8)";
+                case "_value":
+                    return CurrentValue ?? "_value";
+                case "_type":
+                    return "this";
+                // the length of the type being defined, used bare inside an
+                // implicit / virtual field's expression
+                case "lengthInBytes":
+                    return "GetLengthInBytes()";
+                case "lengthInBits":
+                    return "GetLengthInBits()";
+            }
+
+            if (_enumNames.Contains(name))
+            {
+                return name;
+            }
+            if (_hoistedLocals.Contains(name))
+            {
+                return CSharpGenerator.Camel(name);
+            }
+            if (_byName.ContainsKey(name))
+            {
+                return _serialize ? CSharpGenerator.Pascal(name) : CSharpGenerator.Camel(name);
+            }
+            // A bare enum constant used in a discriminator / expression, e.g.
+            // `BYTE_WORD_DWORD` for a `DataTransportSize`-typed const field.
+            if (_enumConstants.TryGetValue(name, out var qualified))
+            {
+                return qualified;
+            }
+            return null;
+        }
+
+        /// <summary>Resolves only bare enum-constant names - for rendering an
+        /// enum's own value expressions and its parameter tables, where there
+        /// are no fields in scope.</summary>
+        public static IExpressionScope EnumConstantsOnly(Protocol protocol)
+            => new EnumScope(protocol);
+
+        private sealed class EnumScope : IExpressionScope
+        {
+            private readonly Dictionary<string, string> _constants = new Dictionary<string, string>();
+
+            public EnumScope(Protocol protocol)
+            {
+                foreach (var e in protocol.Enums)
+                {
+                    foreach (var v in e.Values)
+                    {
+                        _constants.TryAdd(v.Name, $"{e.Name}.{v.Name}");
+                    }
+                }
+            }
+
+            public string? ResolveReference(string name)
+                => _constants.TryGetValue(name, out var q) ? q : null;
+        }
+
+        public string? ResolveMember(string name)
+            => _enumAccessors.Contains(name)
+                ? $"Get{CSharpGenerator.Pascal(name)}()"
+                : null;
+
+        public string ResolveCount(string name)
+        {
+            var reference = ResolveReference(name) ?? name;
+            if (_byName.TryGetValue(name, out var type)
+                && type is SimpleTypeReference { IsByteBased: true })
+            {
+                return reference + ".Length";
+            }
+            // A byte-based array field is byte[]; anything else is List<T>.
+            return reference + ".Count";
+        }
+    }
+}