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

chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git


The following commit(s) were added to refs/heads/main by this push:
     new 037cd7b0e feat(compiler): add csharp target and idl integration tests 
(#3406)
037cd7b0e is described below

commit 037cd7b0ebc045f24a43f65ba91d61640bcd5868
Author: Shawn Yang <[email protected]>
AuthorDate: Wed Feb 25 23:39:21 2026 +0800

    feat(compiler): add csharp target and idl integration tests (#3406)
    
    ## Why?
    
    Add full C# schema IDL support end-to-end in Apache Fory: compiler
    target, documentation, runtime compatibility fixes, and integration
    coverage (including root/package cross-reference and file-based
    roundtrip scenarios).
    
    
    ## What does this PR do?
    
    - Adds C# as a first-class compiler target:
      - `foryc --lang ... ,csharp`
      - `foryc --csharp_out=<dir>`
      - `option csharp_namespace = "..."`
    - Implements C# schema generator
    (`compiler/fory_compiler/generators/csharp.py`) for
    messages/enums/unions, registration helpers, and generated
    `ToBytes`/`FromBytes` helpers.
    - Wires C# into compiler generator registration and generated-code test
    matrix.
    - Adds C# generator unit tests
    (`compiler/fory_compiler/tests/test_csharp_generator.py`) covering
    namespace resolution, registration behavior, field encoding attributes,
    imported registration calls, and parser option support.
    - Adds full C# IDL integration test harness under
    `integration_tests/idl_tests/csharp/IdlTests` and runner
    `integration_tests/idl_tests/run_csharp_tests.sh`, including
    schema-consistent and compatible roundtrips across:
      - `addressbook`, `auto_id`, `complex_pb` primitives
      - collection union/array variants
      - `optional_types`
      - `any_example` (`.fdl`) and `any_example` (`.proto`)
      - flatbuffers (`monster`, `complex_fbs`)
      - reference-tracking models (`tree`, `graph`)
      - evolving compatibility cases
    - root/package cross-reference (`root.idl`) and generated bytes helper
    paths
    - Adds C# IDL output target to
    `integration_tests/idl_tests/generate_idl.py`.
    - Improves C# runtime compatibility behavior for generated IDL shapes:
    - collection/dictionary serializers now handle declared-type metadata
    correctly in compatible mode.
    - union serializer now supports typed case handling while preserving
    xlang-compatible dynamic framing and case value normalization.
    - Updates compiler documentation (`compiler/README.md`,
    `docs/compiler/*`) and C# README to document C# codegen, options,
    generated output, and test workflow.
    
    
    ## Related issues
    
    N/A
    
    
    ## Does this PR introduce any user-facing change?
    
    Yes. `foryc` now supports C# generation (`--lang csharp`,
    `--csharp_out`) and C# namespace override (`csharp_namespace`), with
    updated compiler docs and C# IDL test workflow.
    
    
    - [x] Does this PR introduce any public API change?
    - [ ] Does this PR introduce any binary protocol compatibility change?
    
    ## Benchmark
    
    N/A (feature + compatibility work; no benchmark updates in this PR)
---
 compiler/README.md                                 |   65 +-
 compiler/fory_compiler/cli.py                      |   11 +-
 compiler/fory_compiler/frontend/fdl/parser.py      |    1 +
 compiler/fory_compiler/generators/__init__.py      |    3 +
 compiler/fory_compiler/generators/csharp.py        |  909 +++++++++++++++
 .../fory_compiler/tests/test_csharp_generator.py   |  139 +++
 .../fory_compiler/tests/test_generated_code.py     |    2 +
 csharp/README.md                                   |   34 +-
 csharp/src/Fory/CollectionSerializers.cs           |   14 +-
 csharp/src/Fory/DictionarySerializers.cs           |   48 +-
 csharp/src/Fory/NullableKeyDictionary.cs           |   48 +-
 csharp/src/Fory/UnionSerializer.cs                 |  191 ++-
 docs/compiler/compiler-guide.md                    |   57 +-
 docs/compiler/generated-code.md                    |   59 +
 docs/compiler/index.md                             |   15 +-
 docs/compiler/schema-idl.md                        |   24 +-
 integration_tests/idl_tests/README.md              |    1 +
 .../idl_tests/csharp/IdlTests/.gitignore           |    4 +
 .../idl_tests/csharp/IdlTests/GlobalUsings.cs      |   18 +
 .../idl_tests/csharp/IdlTests/IdlTests.csproj      |   28 +
 .../idl_tests/csharp/IdlTests/RoundtripTests.cs    | 1233 ++++++++++++++++++++
 integration_tests/idl_tests/generate_idl.py        |    1 +
 integration_tests/idl_tests/run_csharp_tests.sh    |   49 +
 23 files changed, 2868 insertions(+), 86 deletions(-)

diff --git a/compiler/README.md b/compiler/README.md
index bd0704ffd..c34824803 100644
--- a/compiler/README.md
+++ b/compiler/README.md
@@ -4,7 +4,7 @@ The FDL compiler generates cross-language serialization code 
from schema definit
 
 ## Features
 
-- **Multi-language code generation**: Java, Python, Go, Rust, C++
+- **Multi-language code generation**: Java, Python, Go, Rust, C++, C#
 - **Rich type system**: Primitives, enums, messages, lists, maps
 - **Cross-language serialization**: Generated code works seamlessly with 
Apache Fory
 - **Type ID and namespace support**: Both numeric IDs and name-based type 
registration
@@ -64,16 +64,16 @@ message Cat [id=103] {
 foryc schema.fdl --output ./generated
 
 # Generate for specific languages
-foryc schema.fdl --lang java,python --output ./generated
+foryc schema.fdl --lang java,python,csharp --output ./generated
 
 # Override package name
 foryc schema.fdl --package myapp.models --output ./generated
 
 # Language-specific output directories (protoc-style)
-foryc schema.fdl --java_out=./src/main/java --python_out=./python/src
+foryc schema.fdl --java_out=./src/main/java --python_out=./python/src 
--csharp_out=./csharp/src/Generated
 
 # Combine with other options
-foryc schema.fdl --java_out=./gen --go_out=./gen/go -I ./proto
+foryc schema.fdl --java_out=./gen --go_out=./gen/go --csharp_out=./gen/csharp 
-I ./proto
 ```
 
 ### 3. Use Generated Code
@@ -185,19 +185,19 @@ message Config { ... }  // Registered as "package.Config"
 
 ### Primitive Types
 
-| FDL Type    | Java        | Python              | Go          | Rust         
           | C++                    |
-| ----------- | ----------- | ------------------- | ----------- | 
----------------------- | ---------------------- |
-| `bool`      | `boolean`   | `bool`              | `bool`      | `bool`       
           | `bool`                 |
-| `int8`      | `byte`      | `pyfory.int8`       | `int8`      | `i8`         
           | `int8_t`               |
-| `int16`     | `short`     | `pyfory.int16`      | `int16`     | `i16`        
           | `int16_t`              |
-| `int32`     | `int`       | `pyfory.int32`      | `int32`     | `i32`        
           | `int32_t`              |
-| `int64`     | `long`      | `pyfory.int64`      | `int64`     | `i64`        
           | `int64_t`              |
-| `float32`   | `float`     | `pyfory.float32`    | `float32`   | `f32`        
           | `float`                |
-| `float64`   | `double`    | `pyfory.float64`    | `float64`   | `f64`        
           | `double`               |
-| `string`    | `String`    | `str`               | `string`    | `String`     
           | `std::string`          |
-| `bytes`     | `byte[]`    | `bytes`             | `[]byte`    | `Vec<u8>`    
           | `std::vector<uint8_t>` |
-| `date`      | `LocalDate` | `datetime.date`     | `time.Time` | 
`chrono::NaiveDate`     | `fory::Date`           |
-| `timestamp` | `Instant`   | `datetime.datetime` | `time.Time` | 
`chrono::NaiveDateTime` | `fory::Timestamp`      |
+| FDL Type    | Java        | Python              | Go          | Rust         
           | C++                    | C#               |
+| ----------- | ----------- | ------------------- | ----------- | 
----------------------- | ---------------------- | ---------------- |
+| `bool`      | `boolean`   | `bool`              | `bool`      | `bool`       
           | `bool`                 | `bool`           |
+| `int8`      | `byte`      | `pyfory.int8`       | `int8`      | `i8`         
           | `int8_t`               | `sbyte`          |
+| `int16`     | `short`     | `pyfory.int16`      | `int16`     | `i16`        
           | `int16_t`              | `short`          |
+| `int32`     | `int`       | `pyfory.int32`      | `int32`     | `i32`        
           | `int32_t`              | `int`            |
+| `int64`     | `long`      | `pyfory.int64`      | `int64`     | `i64`        
           | `int64_t`              | `long`           |
+| `float32`   | `float`     | `pyfory.float32`    | `float32`   | `f32`        
           | `float`                | `float`          |
+| `float64`   | `double`    | `pyfory.float64`    | `float64`   | `f64`        
           | `double`               | `double`         |
+| `string`    | `String`    | `str`               | `string`    | `String`     
           | `std::string`          | `string`         |
+| `bytes`     | `byte[]`    | `bytes`             | `[]byte`    | `Vec<u8>`    
           | `std::vector<uint8_t>` | `byte[]`         |
+| `date`      | `LocalDate` | `datetime.date`     | `time.Time` | 
`chrono::NaiveDate`     | `fory::Date`           | `DateOnly`       |
+| `timestamp` | `Instant`   | `datetime.datetime` | `time.Time` | 
`chrono::NaiveDateTime` | `fory::Timestamp`      | `DateTimeOffset` |
 
 ### Collection Types
 
@@ -284,7 +284,8 @@ fory_compiler/
     ├── python.py         # Python dataclass generator
     ├── go.py             # Go struct generator
     ├── rust.py           # Rust struct generator
-    └── cpp.py            # C++ struct generator
+    ├── cpp.py            # C++ struct generator
+    └── csharp.py         # C# class generator
 ```
 
 ### FDL Frontend
@@ -395,6 +396,32 @@ struct Cat {
 };
 ```
 
+### C\#
+
+Generates classes with:
+
+- `[ForyObject]` model attributes
+- Auto-properties for schema fields
+- Registration helper class and `ToBytes`/`FromBytes` helpers
+
+```csharp
+[ForyObject]
+public sealed partial class Cat
+{
+    public Dog? Friend { get; set; }
+    public string Name { get; set; } = string.Empty;
+    public List<string> Tags { get; set; } = new();
+}
+```
+
+For full C# IDL verification (including root cross-package imports and 
file-based
+roundtrip paths), run:
+
+```bash
+cd integration_tests/idl_tests
+./run_csharp_tests.sh
+```
+
 ## CLI Reference
 
 ```
@@ -404,7 +431,7 @@ Arguments:
   FILES                 FDL files to compile
 
 Options:
-  --lang TEXT          Target languages (java,python,cpp,rust,go or "all")
+  --lang TEXT          Target languages (java,python,cpp,rust,go,csharp or 
"all")
                        Default: all
   --output, -o PATH    Output directory
                        Default: ./generated
diff --git a/compiler/fory_compiler/cli.py b/compiler/fory_compiler/cli.py
index 35853804a..8604ae654 100644
--- a/compiler/fory_compiler/cli.py
+++ b/compiler/fory_compiler/cli.py
@@ -264,7 +264,7 @@ def parse_args(args: Optional[List[str]] = None) -> 
argparse.Namespace:
         "--lang",
         type=str,
         default="all",
-        help="Comma-separated list of target languages 
(java,python,cpp,rust,go). Default: all",
+        help="Comma-separated list of target languages 
(java,python,cpp,rust,go,csharp). Default: all",
     )
 
     parser.add_argument(
@@ -335,6 +335,14 @@ def parse_args(args: Optional[List[str]] = None) -> 
argparse.Namespace:
         help="Generate Rust code in DST_DIR",
     )
 
+    parser.add_argument(
+        "--csharp_out",
+        type=Path,
+        default=None,
+        metavar="DST_DIR",
+        help="Generate C# code in DST_DIR",
+    )
+
     parser.add_argument(
         "--go_nested_type_style",
         type=str,
@@ -620,6 +628,7 @@ def cmd_compile(args: argparse.Namespace) -> int:
         "cpp": args.cpp_out,
         "go": args.go_out,
         "rust": args.rust_out,
+        "csharp": args.csharp_out,
     }
 
     # Determine which languages to generate
diff --git a/compiler/fory_compiler/frontend/fdl/parser.py 
b/compiler/fory_compiler/frontend/fdl/parser.py
index 79c568f0a..1e2713540 100644
--- a/compiler/fory_compiler/frontend/fdl/parser.py
+++ b/compiler/fory_compiler/frontend/fdl/parser.py
@@ -46,6 +46,7 @@ KNOWN_FILE_OPTIONS: Set[str] = {
     "java_outer_classname",
     "java_multiple_files",
     "go_package",
+    "csharp_namespace",
     "deprecated",
     "use_record_for_java_message",
     "polymorphism",
diff --git a/compiler/fory_compiler/generators/__init__.py 
b/compiler/fory_compiler/generators/__init__.py
index 5c8ebd5be..d93533d9c 100644
--- a/compiler/fory_compiler/generators/__init__.py
+++ b/compiler/fory_compiler/generators/__init__.py
@@ -23,6 +23,7 @@ from fory_compiler.generators.python import PythonGenerator
 from fory_compiler.generators.cpp import CppGenerator
 from fory_compiler.generators.rust import RustGenerator
 from fory_compiler.generators.go import GoGenerator
+from fory_compiler.generators.csharp import CSharpGenerator
 
 GENERATORS = {
     "java": JavaGenerator,
@@ -30,6 +31,7 @@ GENERATORS = {
     "cpp": CppGenerator,
     "rust": RustGenerator,
     "go": GoGenerator,
+    "csharp": CSharpGenerator,
 }
 
 __all__ = [
@@ -39,5 +41,6 @@ __all__ = [
     "CppGenerator",
     "RustGenerator",
     "GoGenerator",
+    "CSharpGenerator",
     "GENERATORS",
 ]
diff --git a/compiler/fory_compiler/generators/csharp.py 
b/compiler/fory_compiler/generators/csharp.py
new file mode 100644
index 000000000..565f70a6b
--- /dev/null
+++ b/compiler/fory_compiler/generators/csharp.py
@@ -0,0 +1,909 @@
+# 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
+#
+#   http://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.
+
+"""C# code generator."""
+
+from pathlib import Path
+from typing import Dict, List, Optional, Set, Tuple, Union as TypingUnion
+
+from fory_compiler.frontend.utils import parse_idl_file
+from fory_compiler.generators.base import BaseGenerator, GeneratedFile
+from fory_compiler.ir.ast import (
+    Enum,
+    Field,
+    FieldType,
+    ListType,
+    MapType,
+    Message,
+    NamedType,
+    PrimitiveType,
+    Schema,
+    Union,
+)
+from fory_compiler.ir.types import PrimitiveKind
+
+
+class CSharpGenerator(BaseGenerator):
+    """Generates C# models and registration helpers for Apache Fory."""
+
+    language_name = "csharp"
+    file_extension = ".cs"
+
+    PRIMITIVE_MAP = {
+        PrimitiveKind.BOOL: "bool",
+        PrimitiveKind.INT8: "sbyte",
+        PrimitiveKind.INT16: "short",
+        PrimitiveKind.INT32: "int",
+        PrimitiveKind.VARINT32: "int",
+        PrimitiveKind.INT64: "long",
+        PrimitiveKind.VARINT64: "long",
+        PrimitiveKind.TAGGED_INT64: "long",
+        PrimitiveKind.UINT8: "byte",
+        PrimitiveKind.UINT16: "ushort",
+        PrimitiveKind.UINT32: "uint",
+        PrimitiveKind.VAR_UINT32: "uint",
+        PrimitiveKind.UINT64: "ulong",
+        PrimitiveKind.VAR_UINT64: "ulong",
+        PrimitiveKind.TAGGED_UINT64: "ulong",
+        PrimitiveKind.FLOAT16: "float",
+        PrimitiveKind.BFLOAT16: "float",
+        PrimitiveKind.FLOAT32: "float",
+        PrimitiveKind.FLOAT64: "double",
+        PrimitiveKind.STRING: "string",
+        PrimitiveKind.BYTES: "byte[]",
+        PrimitiveKind.DATE: "DateOnly",
+        PrimitiveKind.TIMESTAMP: "DateTimeOffset",
+        PrimitiveKind.DURATION: "TimeSpan",
+        PrimitiveKind.DECIMAL: "decimal",
+        PrimitiveKind.ANY: "object",
+    }
+
+    VALUE_TYPE_KINDS = {
+        PrimitiveKind.BOOL,
+        PrimitiveKind.INT8,
+        PrimitiveKind.INT16,
+        PrimitiveKind.INT32,
+        PrimitiveKind.VARINT32,
+        PrimitiveKind.INT64,
+        PrimitiveKind.VARINT64,
+        PrimitiveKind.TAGGED_INT64,
+        PrimitiveKind.UINT8,
+        PrimitiveKind.UINT16,
+        PrimitiveKind.UINT32,
+        PrimitiveKind.VAR_UINT32,
+        PrimitiveKind.UINT64,
+        PrimitiveKind.VAR_UINT64,
+        PrimitiveKind.TAGGED_UINT64,
+        PrimitiveKind.FLOAT16,
+        PrimitiveKind.BFLOAT16,
+        PrimitiveKind.FLOAT32,
+        PrimitiveKind.FLOAT64,
+        PrimitiveKind.DATE,
+        PrimitiveKind.TIMESTAMP,
+        PrimitiveKind.DURATION,
+        PrimitiveKind.DECIMAL,
+    }
+
+    CSHARP_KEYWORDS = {
+        "abstract",
+        "as",
+        "base",
+        "bool",
+        "break",
+        "byte",
+        "case",
+        "catch",
+        "char",
+        "checked",
+        "class",
+        "const",
+        "continue",
+        "decimal",
+        "default",
+        "delegate",
+        "do",
+        "double",
+        "else",
+        "enum",
+        "event",
+        "explicit",
+        "extern",
+        "false",
+        "finally",
+        "fixed",
+        "float",
+        "for",
+        "foreach",
+        "goto",
+        "if",
+        "implicit",
+        "in",
+        "int",
+        "interface",
+        "internal",
+        "is",
+        "lock",
+        "long",
+        "namespace",
+        "new",
+        "null",
+        "object",
+        "operator",
+        "out",
+        "override",
+        "params",
+        "private",
+        "protected",
+        "public",
+        "readonly",
+        "ref",
+        "return",
+        "sbyte",
+        "sealed",
+        "short",
+        "sizeof",
+        "stackalloc",
+        "static",
+        "string",
+        "struct",
+        "switch",
+        "this",
+        "throw",
+        "true",
+        "try",
+        "typeof",
+        "uint",
+        "ulong",
+        "unchecked",
+        "unsafe",
+        "ushort",
+        "using",
+        "virtual",
+        "void",
+        "volatile",
+        "while",
+    }
+
+    def __init__(self, schema: Schema, options):
+        super().__init__(schema, options)
+        self._qualified_type_names: Dict[int, str] = {}
+        self._build_qualified_type_name_index()
+
+    def _build_qualified_type_name_index(self) -> None:
+        for enum in self.schema.enums:
+            self._qualified_type_names[id(enum)] = enum.name
+        for union in self.schema.unions:
+            self._qualified_type_names[id(union)] = union.name
+
+        def visit_message(message: Message, parents: List[str]) -> None:
+            path = ".".join(parents + [message.name])
+            self._qualified_type_names[id(message)] = path
+            for nested_enum in message.nested_enums:
+                self._qualified_type_names[id(nested_enum)] = (
+                    f"{path}.{nested_enum.name}"
+                )
+            for nested_union in message.nested_unions:
+                self._qualified_type_names[id(nested_union)] = (
+                    f"{path}.{nested_union.name}"
+                )
+            for nested_msg in message.nested_messages:
+                visit_message(nested_msg, parents + [message.name])
+
+        for message in self.schema.messages:
+            visit_message(message, [])
+
+    def get_csharp_namespace(self) -> str:
+        if self.options.package_override:
+            return self.options.package_override
+        csharp_ns = self.schema.get_option("csharp_namespace")
+        if csharp_ns:
+            return str(csharp_ns)
+        if self.schema.package:
+            return self.schema.package
+        return "generated"
+
+    def get_registration_class_name(self) -> str:
+        return 
self._registration_class_name_for_namespace(self.get_csharp_namespace())
+
+    def _registration_class_name_for_namespace(self, namespace_name: str) -> 
str:
+        if namespace_name:
+            leaf = namespace_name.split(".")[-1]
+        else:
+            leaf = "generated"
+        return f"{self.to_pascal_case(leaf)}ForyRegistration"
+
+    def _module_file_name(self) -> str:
+        if self.schema.source_file and not 
self.schema.source_file.startswith("<"):
+            return f"{Path(self.schema.source_file).stem}.cs"
+        if self.schema.package:
+            return f"{self.schema.package.replace('.', '_')}.cs"
+        return "generated.cs"
+
+    def _namespace_path(self, namespace_name: str) -> str:
+        return namespace_name.replace(".", "/") if namespace_name else ""
+
+    def safe_identifier(self, name: str) -> str:
+        if name in self.CSHARP_KEYWORDS:
+            return f"@{name}"
+        return name
+
+    def safe_type_identifier(self, name: str) -> str:
+        return self.safe_identifier(name)
+
+    def safe_member_name(self, name: str) -> str:
+        return self.safe_identifier(self.to_pascal_case(name))
+
+    def _nested_type_names_for_message(self, message: Message) -> Set[str]:
+        names: Set[str] = set()
+        for nested in (
+            list(message.nested_enums)
+            + list(message.nested_unions)
+            + list(message.nested_messages)
+        ):
+            names.add(self.safe_type_identifier(nested.name))
+        return names
+
+    def _field_member_name(
+        self,
+        field: Field,
+        message: Message,
+        used_names: Set[str],
+    ) -> str:
+        base = self.safe_member_name(field.name)
+        nested_type_names = self._nested_type_names_for_message(message)
+        if base in nested_type_names:
+            base = f"{base}Value"
+
+        candidate = base
+        suffix = 1
+        while candidate in used_names:
+            candidate = f"{base}{suffix}"
+            suffix += 1
+        used_names.add(candidate)
+        return candidate
+
+    def is_imported_type(self, type_def: object) -> bool:
+        if not self.schema.source_file:
+            return False
+        location = getattr(type_def, "location", None)
+        if location is None or not location.file:
+            return False
+        try:
+            return (
+                Path(location.file).resolve() != 
Path(self.schema.source_file).resolve()
+            )
+        except Exception:
+            return location.file != self.schema.source_file
+
+    def split_imported_types(
+        self, items: List[object]
+    ) -> Tuple[List[object], List[object]]:
+        imported: List[object] = []
+        local: List[object] = []
+        for item in items:
+            if self.is_imported_type(item):
+                imported.append(item)
+            else:
+                local.append(item)
+        return imported, local
+
+    def _normalize_import_path(self, path_str: str) -> str:
+        if not path_str:
+            return path_str
+        try:
+            return str(Path(path_str).resolve())
+        except Exception:
+            return path_str
+
+    def _load_schema(self, file_path: str) -> Optional[Schema]:
+        if not file_path:
+            return None
+        if not hasattr(self, "_schema_cache"):
+            self._schema_cache = {}
+        cache: Dict[Path, Schema] = self._schema_cache
+        path = Path(file_path).resolve()
+        if path in cache:
+            return cache[path]
+        try:
+            schema = parse_idl_file(path)
+        except Exception:
+            return None
+        cache[path] = schema
+        return schema
+
+    def _csharp_namespace_for_schema(self, schema: Schema) -> str:
+        value = schema.get_option("csharp_namespace")
+        if value:
+            return str(value)
+        if schema.package:
+            return schema.package
+        return "generated"
+
+    def _csharp_namespace_for_type(self, type_def: object) -> str:
+        location = getattr(type_def, "location", None)
+        file_path = getattr(location, "file", None) if location else None
+        schema = self._load_schema(file_path)
+        if schema is None:
+            return self.get_csharp_namespace()
+        return self._csharp_namespace_for_schema(schema)
+
+    def _collect_imported_registrations(self) -> List[Tuple[str, str]]:
+        file_info: Dict[str, Tuple[str, str]] = {}
+        for type_def in self.schema.enums + self.schema.unions + 
self.schema.messages:
+            if not self.is_imported_type(type_def):
+                continue
+            location = getattr(type_def, "location", None)
+            file_path = getattr(location, "file", None) if location else None
+            if not file_path:
+                continue
+            normalized = self._normalize_import_path(file_path)
+            if normalized in file_info:
+                continue
+            imported_schema = self._load_schema(file_path)
+            if imported_schema is None:
+                continue
+            namespace_name = self._csharp_namespace_for_schema(imported_schema)
+            registration_name = self._registration_class_name_for_namespace(
+                namespace_name
+            )
+            file_info[normalized] = (namespace_name, registration_name)
+
+        ordered: List[Tuple[str, str]] = []
+        used: Set[str] = set()
+
+        if self.schema.source_file:
+            base_dir = Path(self.schema.source_file).resolve().parent
+            for imp in self.schema.imports:
+                candidate = self._normalize_import_path(
+                    str((base_dir / imp.path).resolve())
+                )
+                if candidate in file_info and candidate not in used:
+                    ordered.append(file_info[candidate])
+                    used.add(candidate)
+
+        for key in sorted(file_info.keys()):
+            if key in used:
+                continue
+            ordered.append(file_info[key])
+
+        deduped: List[Tuple[str, str]] = []
+        seen: Set[Tuple[str, str]] = set()
+        for item in ordered:
+            if item in seen:
+                continue
+            seen.add(item)
+            deduped.append(item)
+        return deduped
+
+    def generate(self) -> List[GeneratedFile]:
+        return [self.generate_file()]
+
+    def generate_file(self) -> GeneratedFile:
+        lines: List[str] = []
+        namespace_name = self.get_csharp_namespace()
+
+        lines.append(self.get_license_header("//"))
+        lines.append("")
+        lines.append("using System;")
+        lines.append("using System.Collections.Generic;")
+        lines.append("using Apache.Fory;")
+        lines.append("")
+        lines.append(f"namespace {namespace_name};")
+        lines.append("")
+
+        for enum in self.schema.enums:
+            if self.is_imported_type(enum):
+                continue
+            lines.extend(self.generate_enum(enum))
+            lines.append("")
+
+        for union in self.schema.unions:
+            if self.is_imported_type(union):
+                continue
+            lines.extend(self.generate_union(union))
+            lines.append("")
+
+        for message in self.schema.messages:
+            if self.is_imported_type(message):
+                continue
+            lines.extend(self.generate_message(message, parent_stack=[]))
+            lines.append("")
+
+        lines.extend(self.generate_registration_class())
+        lines.append("")
+
+        file_name = self._module_file_name()
+        ns_path = self._namespace_path(namespace_name)
+        if ns_path:
+            path = f"{ns_path}/{file_name}"
+        else:
+            path = file_name
+
+        return GeneratedFile(path=path, content="\n".join(lines))
+
+    def _resolve_named_type(
+        self, name: str, parent_stack: Optional[List[Message]] = None
+    ) -> Optional[TypingUnion[Message, Enum, Union]]:
+        parent_stack = parent_stack or []
+        if "." in name:
+            return self.schema.get_type(name)
+        for msg in reversed(parent_stack):
+            nested = msg.get_nested_type(name)
+            if nested is not None:
+                return nested
+        return self.schema.get_type(name)
+
+    def _type_namespace(
+        self, resolved: Optional[TypingUnion[Message, Enum, Union]]
+    ) -> str:
+        if resolved is None:
+            return self.get_csharp_namespace()
+        if self.is_imported_type(resolved):
+            return self._csharp_namespace_for_type(resolved)
+        return self.get_csharp_namespace()
+
+    def _qualified_type_name_for(
+        self,
+        resolved: Optional[TypingUnion[Message, Enum, Union]],
+        fallback_name: str,
+    ) -> str:
+        if resolved is None:
+            return ".".join(
+                self.safe_type_identifier(part) for part in 
fallback_name.split(".")
+            )
+        qualified = self._qualified_type_names.get(id(resolved), fallback_name)
+        return ".".join(
+            self.safe_type_identifier(part) for part in qualified.split(".")
+        )
+
+    def _named_type_reference(
+        self, named_type: NamedType, parent_stack: Optional[List[Message]] = 
None
+    ) -> str:
+        resolved = self._resolve_named_type(named_type.name, parent_stack)
+        ns = self._type_namespace(resolved)
+        qname = self._qualified_type_name_for(resolved, named_type.name)
+        if ns:
+            return f"global::{ns}.{qname}"
+        return qname
+
+    def _is_value_type(
+        self, field_type: FieldType, parent_stack: List[Message]
+    ) -> bool:
+        if isinstance(field_type, PrimitiveType):
+            return field_type.kind in self.VALUE_TYPE_KINDS
+        if isinstance(field_type, NamedType):
+            resolved = self._resolve_named_type(field_type.name, parent_stack)
+            return isinstance(resolved, Enum)
+        return False
+
+    def generate_type(
+        self,
+        field_type: FieldType,
+        nullable: bool = False,
+        parent_stack: Optional[List[Message]] = None,
+    ) -> str:
+        parent_stack = parent_stack or []
+        if isinstance(field_type, PrimitiveType):
+            if field_type.kind not in self.PRIMITIVE_MAP:
+                raise ValueError(
+                    f"Unsupported primitive type for C#: {field_type.kind}"
+                )
+            type_name = self.PRIMITIVE_MAP[field_type.kind]
+            if nullable and field_type.kind in self.VALUE_TYPE_KINDS:
+                return f"{type_name}?"
+            if nullable and field_type.kind not in self.VALUE_TYPE_KINDS:
+                return f"{type_name}?"
+            return type_name
+
+        if isinstance(field_type, NamedType):
+            type_name = self._named_type_reference(field_type, parent_stack)
+            if nullable and self._is_value_type(field_type, parent_stack):
+                return f"{type_name}?"
+            if nullable and not self._is_value_type(field_type, parent_stack):
+                return f"{type_name}?"
+            return type_name
+
+        if isinstance(field_type, ListType):
+            element_type = self.generate_type(
+                field_type.element_type,
+                nullable=field_type.element_optional,
+                parent_stack=parent_stack,
+            )
+            list_type = f"List<{element_type}>"
+            if nullable:
+                return f"{list_type}?"
+            return list_type
+
+        if isinstance(field_type, MapType):
+            key_type = self.generate_type(
+                field_type.key_type,
+                nullable=False,
+                parent_stack=parent_stack,
+            )
+            value_type = self.generate_type(
+                field_type.value_type,
+                nullable=False,
+                parent_stack=parent_stack,
+            )
+            map_type = f"Dictionary<{key_type}, {value_type}>"
+            if nullable:
+                return f"{map_type}?"
+            return map_type
+
+        raise ValueError(f"Unknown field type: {field_type}")
+
+    def _default_initializer(
+        self, field: Field, parent_stack: List[Message]
+    ) -> Optional[str]:
+        if field.optional:
+            return None
+
+        field_type = field.field_type
+        if isinstance(field_type, ListType) or isinstance(field_type, MapType):
+            return " = new();"
+
+        if isinstance(field_type, PrimitiveType):
+            if field_type.kind == PrimitiveKind.STRING:
+                return " = string.Empty;"
+            if field_type.kind == PrimitiveKind.BYTES:
+                return " = Array.Empty<byte>();"
+            if field_type.kind == PrimitiveKind.ANY:
+                return " = null!;"
+            return None
+
+        if isinstance(field_type, NamedType):
+            resolved = self._resolve_named_type(field_type.name, parent_stack)
+            if isinstance(resolved, Enum):
+                return None
+            return " = null!;"
+
+        return None
+
+    def _field_encoding(self, field: Field) -> Optional[str]:
+        field_type = field.field_type
+        if not isinstance(field_type, PrimitiveType):
+            return None
+        kind = field_type.kind
+        if kind in {
+            PrimitiveKind.INT32,
+            PrimitiveKind.INT64,
+            PrimitiveKind.UINT32,
+            PrimitiveKind.UINT64,
+        }:
+            return "Fixed"
+        if kind in {PrimitiveKind.TAGGED_INT64, PrimitiveKind.TAGGED_UINT64}:
+            return "Tagged"
+        return None
+
+    def _type_reference_for_local(
+        self,
+        type_def: TypingUnion[Message, Enum, Union],
+    ) -> str:
+        namespace_name = self.get_csharp_namespace()
+        type_name = self._qualified_type_name_for(
+            type_def, getattr(type_def, "name", "Unknown")
+        )
+        return f"global::{namespace_name}.{type_name}"
+
+    def generate_enum(self, enum: Enum, indent: int = 0) -> List[str]:
+        lines: List[str] = []
+        ind = self.indent_str * indent
+        comment = self.format_type_id_comment(enum, f"{ind}//")
+        if comment:
+            lines.append(comment)
+        lines.append(f"{ind}[ForyObject]")
+        lines.append(f"{ind}public enum 
{self.safe_type_identifier(enum.name)}")
+        lines.append(f"{ind}{{")
+
+        for i, value in enumerate(enum.values):
+            comma = "," if i < len(enum.values) - 1 else ""
+            stripped_name = self.strip_enum_prefix(enum.name, value.name)
+            value_name = 
self.safe_identifier(self.to_pascal_case(stripped_name))
+            lines.append(f"{ind}{self.indent_str}{value_name} = 
{value.value}{comma}")
+
+        lines.append(f"{ind}}}")
+        return lines
+
+    def generate_union(
+        self,
+        union: Union,
+        indent: int = 0,
+        parent_stack: Optional[List[Message]] = None,
+    ) -> List[str]:
+        lines: List[str] = []
+        ind = self.indent_str * indent
+        type_name = self.safe_type_identifier(union.name)
+        case_enum = self.safe_type_identifier(f"{union.name}Case")
+        registration_class = self.get_registration_class_name()
+        full_type_ref = self._type_reference_for_local(union)
+
+        comment = self.format_type_id_comment(union, f"{ind}//")
+        if comment:
+            lines.append(comment)
+        lines.append(f"{ind}public sealed class {type_name} : Union")
+        lines.append(f"{ind}{{")
+        lines.append(f"{ind}{self.indent_str}public enum {case_enum}")
+        lines.append(f"{ind}{self.indent_str}{{")
+        lines.append(f"{ind}{self.indent_str * 2}Unknown = 0,")
+        for i, field in enumerate(union.fields):
+            comma = "," if i < len(union.fields) - 1 else ""
+            case_name = self.safe_identifier(self.to_pascal_case(field.name))
+            lines.append(
+                f"{ind}{self.indent_str * 2}{case_name} = 
{field.number}{comma}"
+            )
+        lines.append(f"{ind}{self.indent_str}}}")
+        lines.append("")
+
+        lines.append(
+            f"{ind}{self.indent_str}private {type_name}(int index, object? 
value) : base(index, value)"
+        )
+        lines.append(f"{ind}{self.indent_str}{{")
+        lines.append(f"{ind}{self.indent_str}}}")
+        lines.append("")
+
+        lines.append(
+            f"{ind}{self.indent_str}public static {type_name} Of(int index, 
object? value)"
+        )
+        lines.append(f"{ind}{self.indent_str}{{")
+        lines.append(f"{ind}{self.indent_str * 2}return new {type_name}(index, 
value);")
+        lines.append(f"{ind}{self.indent_str}}}")
+        lines.append("")
+
+        for field in union.fields:
+            case_name = self.safe_identifier(self.to_pascal_case(field.name))
+            case_type = self.generate_type(
+                field.field_type,
+                nullable=False,
+                parent_stack=parent_stack,
+            )
+            lines.append(
+                f"{ind}{self.indent_str}public static {type_name} 
{case_name}({case_type} value)"
+            )
+            lines.append(f"{ind}{self.indent_str}{{")
+            lines.append(
+                f"{ind}{self.indent_str * 2}return new 
{type_name}({field.number}, value);"
+            )
+            lines.append(f"{ind}{self.indent_str}}}")
+            lines.append("")
+
+            lines.append(
+                f"{ind}{self.indent_str}public bool Is{case_name} => Index == 
{field.number};"
+            )
+            lines.append("")
+            lines.append(f"{ind}{self.indent_str}public {case_type} 
{case_name}Value()")
+            lines.append(f"{ind}{self.indent_str}{{")
+            lines.append(f"{ind}{self.indent_str * 2}if (!Is{case_name})")
+            lines.append(f"{ind}{self.indent_str * 2}{{")
+            lines.append(
+                f'{ind}{self.indent_str * 3}throw new 
InvalidOperationException("Union does not hold case {case_name}");'
+            )
+            lines.append(f"{ind}{self.indent_str * 2}}}")
+            lines.append(f"{ind}{self.indent_str * 2}return 
GetValue<{case_type}>();")
+            lines.append(f"{ind}{self.indent_str}}}")
+            lines.append("")
+
+        lines.append(f"{ind}{self.indent_str}public {case_enum} Case()")
+        lines.append(f"{ind}{self.indent_str}{{")
+        lines.append(f"{ind}{self.indent_str * 2}return Index switch")
+        lines.append(f"{ind}{self.indent_str * 2}{{")
+        for field in union.fields:
+            case_name = self.safe_identifier(self.to_pascal_case(field.name))
+            lines.append(
+                f"{ind}{self.indent_str * 3}{field.number} => 
{case_enum}.{case_name},"
+            )
+        lines.append(f"{ind}{self.indent_str * 3}_ => {case_enum}.Unknown,")
+        lines.append(f"{ind}{self.indent_str * 2}}};")
+        lines.append(f"{ind}{self.indent_str}}}")
+        lines.append("")
+
+        lines.append(f"{ind}{self.indent_str}public int CaseId()")
+        lines.append(f"{ind}{self.indent_str}{{")
+        lines.append(f"{ind}{self.indent_str * 2}return Index;")
+        lines.append(f"{ind}{self.indent_str}}}")
+        lines.append("")
+
+        lines.append(f"{ind}{self.indent_str}public byte[] ToBytes()")
+        lines.append(f"{ind}{self.indent_str}{{")
+        lines.append(
+            f"{ind}{self.indent_str * 2}return 
{registration_class}.GetFory().Serialize(this);"
+        )
+        lines.append(f"{ind}{self.indent_str}}}")
+        lines.append("")
+        lines.append(
+            f"{ind}{self.indent_str}public static {type_name} FromBytes(byte[] 
data)"
+        )
+        lines.append(f"{ind}{self.indent_str}{{")
+        lines.append(
+            f"{ind}{self.indent_str * 2}return 
{registration_class}.GetFory().Deserialize<{full_type_ref}>(data);"
+        )
+        lines.append(f"{ind}{self.indent_str}}}")
+
+        lines.append(f"{ind}}}")
+        return lines
+
+    def generate_message(
+        self,
+        message: Message,
+        indent: int = 0,
+        parent_stack: Optional[List[Message]] = None,
+    ) -> List[str]:
+        lines: List[str] = []
+        ind = self.indent_str * indent
+        parent_stack = parent_stack or []
+        lineage = parent_stack + [message]
+        registration_class = self.get_registration_class_name()
+        type_name = self.safe_type_identifier(message.name)
+        full_type_ref = self._type_reference_for_local(message)
+
+        comment = self.format_type_id_comment(message, f"{ind}//")
+        if comment:
+            lines.append(comment)
+        lines.append(f"{ind}[ForyObject]")
+        lines.append(f"{ind}public sealed partial class {type_name}")
+        lines.append(f"{ind}{{")
+
+        for nested_enum in message.nested_enums:
+            lines.append("")
+            lines.extend(self.generate_enum(nested_enum, indent + 1))
+
+        for nested_union in message.nested_unions:
+            lines.append("")
+            lines.extend(self.generate_union(nested_union, indent + 1, 
lineage))
+
+        for nested_msg in message.nested_messages:
+            lines.append("")
+            lines.extend(self.generate_message(nested_msg, indent + 1, 
lineage))
+
+        used_field_names: Set[str] = set()
+        for field in message.fields:
+            lines.append("")
+            encoding = self._field_encoding(field)
+            if encoding:
+                lines.append(
+                    f"{ind}{self.indent_str}[Field(Encoding = 
FieldEncoding.{encoding})]"
+                )
+            field_name = self._field_member_name(field, message, 
used_field_names)
+            field_type = self.generate_type(
+                field.field_type,
+                nullable=field.optional,
+                parent_stack=lineage,
+            )
+            init = self._default_initializer(field, lineage) or ""
+            lines.append(
+                f"{ind}{self.indent_str}public {field_type} {field_name} {{ 
get; set; }}{init}"
+            )
+
+        lines.append("")
+        lines.append(f"{ind}{self.indent_str}public byte[] ToBytes()")
+        lines.append(f"{ind}{self.indent_str}{{")
+        lines.append(
+            f"{ind}{self.indent_str * 2}return 
{registration_class}.GetFory().Serialize(this);"
+        )
+        lines.append(f"{ind}{self.indent_str}}}")
+        lines.append("")
+        lines.append(
+            f"{ind}{self.indent_str}public static {type_name} FromBytes(byte[] 
data)"
+        )
+        lines.append(f"{ind}{self.indent_str}{{")
+        lines.append(
+            f"{ind}{self.indent_str * 2}return 
{registration_class}.GetFory().Deserialize<{full_type_ref}>(data);"
+        )
+        lines.append(f"{ind}{self.indent_str}}}")
+
+        lines.append(f"{ind}}}")
+        return lines
+
+    def _register_type_lines(
+        self,
+        type_def: TypingUnion[Message, Enum, Union],
+        target_var: str,
+    ) -> List[str]:
+        type_ref = self._type_reference_for_local(type_def)
+        type_name = self._qualified_type_names.get(id(type_def), type_def.name)
+        if self.should_register_by_id(type_def):
+            return 
[f"{target_var}.Register<{type_ref}>((uint){type_def.type_id});"]
+
+        namespace_name = self.schema.package or "default"
+        return [
+            f'{target_var}.Register<{type_ref}>("{namespace_name}", 
"{type_name}");'
+        ]
+
+    def _collect_local_types(self) -> List[TypingUnion[Message, Enum, Union]]:
+        local_types: List[TypingUnion[Message, Enum, Union]] = []
+
+        for enum in self.schema.enums:
+            if not self.is_imported_type(enum):
+                local_types.append(enum)
+        for union in self.schema.unions:
+            if not self.is_imported_type(union):
+                local_types.append(union)
+
+        def visit_message(message: Message) -> None:
+            local_types.append(message)
+            for nested_enum in message.nested_enums:
+                local_types.append(nested_enum)
+            for nested_union in message.nested_unions:
+                local_types.append(nested_union)
+            for nested_msg in message.nested_messages:
+                visit_message(nested_msg)
+
+        for message in self.schema.messages:
+            if self.is_imported_type(message):
+                continue
+            visit_message(message)
+
+        return local_types
+
+    def generate_registration_class(self) -> List[str]:
+        lines: List[str] = []
+        class_name = 
self.safe_type_identifier(self.get_registration_class_name())
+        imported_regs = self._collect_imported_registrations()
+        local_types = self._collect_local_types()
+
+        lines.append(f"public static class {class_name}")
+        lines.append("{")
+        lines.append(
+            f"{self.indent_str}private static readonly Lazy<ThreadSafeFory> 
LazyFory = new(CreateFory);"
+        )
+        lines.append("")
+        lines.append(f"{self.indent_str}internal static ThreadSafeFory 
GetFory()")
+        lines.append(f"{self.indent_str}{{")
+        lines.append(f"{self.indent_str * 2}return LazyFory.Value;")
+        lines.append(f"{self.indent_str}}}")
+        lines.append("")
+
+        lines.append(f"{self.indent_str}private static ThreadSafeFory 
CreateFory()")
+        lines.append(f"{self.indent_str}{{")
+        lines.append(
+            f"{self.indent_str * 2}ThreadSafeFory fory = 
Fory.Builder().Xlang(true).TrackRef(true).BuildThreadSafe();"
+        )
+        lines.append(f"{self.indent_str * 2}Register(fory);")
+        lines.append(f"{self.indent_str * 2}return fory;")
+        lines.append(f"{self.indent_str}}}")
+        lines.append("")
+
+        lines.append(f"{self.indent_str}public static void Register(Fory 
fory)")
+        lines.append(f"{self.indent_str}{{")
+        for namespace_name, reg_name in imported_regs:
+            if namespace_name == self.get_csharp_namespace() and reg_name == 
class_name:
+                continue
+            lines.append(
+                f"{self.indent_str * 
2}global::{namespace_name}.{self.safe_type_identifier(reg_name)}.Register(fory);"
+            )
+        for type_def in local_types:
+            for register_line in self._register_type_lines(type_def, "fory"):
+                lines.append(f"{self.indent_str * 2}{register_line}")
+        lines.append(f"{self.indent_str}}}")
+        lines.append("")
+
+        lines.append(
+            f"{self.indent_str}public static void Register(ThreadSafeFory 
fory)"
+        )
+        lines.append(f"{self.indent_str}{{")
+        for namespace_name, reg_name in imported_regs:
+            if namespace_name == self.get_csharp_namespace() and reg_name == 
class_name:
+                continue
+            lines.append(
+                f"{self.indent_str * 
2}global::{namespace_name}.{self.safe_type_identifier(reg_name)}.Register(fory);"
+            )
+        for type_def in local_types:
+            for register_line in self._register_type_lines(type_def, "fory"):
+                lines.append(f"{self.indent_str * 2}{register_line}")
+        lines.append(f"{self.indent_str}}}")
+
+        lines.append("}")
+        return lines
diff --git a/compiler/fory_compiler/tests/test_csharp_generator.py 
b/compiler/fory_compiler/tests/test_csharp_generator.py
new file mode 100644
index 000000000..a0181eeff
--- /dev/null
+++ b/compiler/fory_compiler/tests/test_csharp_generator.py
@@ -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
+#
+#   http://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.
+
+"""Tests for C# generator behavior."""
+
+import warnings
+from pathlib import Path
+
+from fory_compiler.cli import resolve_imports
+from fory_compiler.frontend.fdl.lexer import Lexer
+from fory_compiler.frontend.fdl.parser import Parser
+from fory_compiler.generators.base import GeneratorOptions
+from fory_compiler.generators.csharp import CSharpGenerator
+
+
+def parse_schema(source: str):
+    return Parser(Lexer(source).tokenize()).parse()
+
+
+def generate(source: str):
+    schema = parse_schema(source)
+    generator = CSharpGenerator(schema, 
GeneratorOptions(output_dir=Path("/tmp")))
+    return generator.generate()[0]
+
+
+def test_csharp_namespace_option_used():
+    file = generate(
+        """
+        package payment;
+        option csharp_namespace = "MyCorp.Payment.V1";
+
+        message Payment {
+            string id = 1;
+        }
+        """
+    )
+
+    assert file.path == "MyCorp/Payment/V1/payment.cs"
+    assert "namespace MyCorp.Payment.V1;" in file.content
+    assert "public sealed partial class Payment" in file.content
+
+
+def test_csharp_namespace_fallback_to_package():
+    file = generate(
+        """
+        package com.example.models;
+
+        message User {
+            string name = 1;
+        }
+        """
+    )
+
+    assert file.path == "com/example/models/com_example_models.cs"
+    assert "namespace com.example.models;" in file.content
+
+
+def test_csharp_registration_uses_fdl_package_for_name_registration():
+    file = generate(
+        """
+        package myapp.models;
+        option csharp_namespace = "MyCorp.Generated.Models";
+        option enable_auto_type_id = false;
+
+        message User {
+            string name = 1;
+        }
+        """
+    )
+
+    assert (
+        'fory.Register<global::MyCorp.Generated.Models.User>("myapp.models", 
"User");'
+        in file.content
+    )
+
+
+def test_csharp_field_encoding_attributes():
+    file = generate(
+        """
+        package example;
+
+        message Encoded {
+            fixed_int32 fixed_id = 1;
+            tagged_uint64 tagged = 2;
+            int32 plain = 3;
+        }
+        """
+    )
+
+    assert "[Field(Encoding = FieldEncoding.Fixed)]" in file.content
+    assert "[Field(Encoding = FieldEncoding.Tagged)]" in file.content
+    assert "public int Plain { get; set; }" in file.content
+
+
+def test_csharp_imported_registration_calls_generated():
+    repo_root = Path(__file__).resolve().parents[3]
+    idl_dir = repo_root / "integration_tests" / "idl_tests" / "idl"
+    schema = resolve_imports(idl_dir / "root.idl", [idl_dir])
+
+    generator = CSharpGenerator(schema, 
GeneratorOptions(output_dir=Path("/tmp")))
+    file = generator.generate()[0]
+
+    assert (
+        "global::addressbook.AddressbookForyRegistration.Register(fory);"
+        in file.content
+    )
+    assert "global::tree.TreeForyRegistration.Register(fory);" in file.content
+
+
+def test_csharp_namespace_option_is_known():
+    source = """
+    package myapp;
+    option csharp_namespace = "MyCorp.MyApp";
+
+    message User {
+      string name = 1;
+    }
+    """
+
+    with warnings.catch_warnings(record=True) as caught:
+        warnings.simplefilter("always")
+        schema = parse_schema(source)
+
+    assert schema.get_option("csharp_namespace") == "MyCorp.MyApp"
+    assert not caught
diff --git a/compiler/fory_compiler/tests/test_generated_code.py 
b/compiler/fory_compiler/tests/test_generated_code.py
index 13dc99eab..ef7cf4c66 100644
--- a/compiler/fory_compiler/tests/test_generated_code.py
+++ b/compiler/fory_compiler/tests/test_generated_code.py
@@ -31,6 +31,7 @@ from fory_compiler.generators.go import GoGenerator
 from fory_compiler.generators.java import JavaGenerator
 from fory_compiler.generators.python import PythonGenerator
 from fory_compiler.generators.rust import RustGenerator
+from fory_compiler.generators.csharp import CSharpGenerator
 from fory_compiler.ir.ast import Schema
 
 
@@ -40,6 +41,7 @@ GENERATOR_CLASSES: Tuple[Type[BaseGenerator], ...] = (
     CppGenerator,
     RustGenerator,
     GoGenerator,
+    CSharpGenerator,
 )
 
 
diff --git a/csharp/README.md b/csharp/README.md
index 4400e81a2..12bcdecef 100644
--- a/csharp/README.md
+++ b/csharp/README.md
@@ -1,4 +1,4 @@
-# Apache Fory™ C# #
+# Apache Fory™ C\#
 
 
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/apache/fory/blob/main/LICENSE)
 
@@ -6,7 +6,7 @@ Apache Fory™ is a blazing fast multi-language serialization 
framework powered
 
 The C# implementation provides high-performance object graph serialization for 
.NET with source-generated serializers, optional reference tracking, schema 
evolution support, and cross-language compatibility.
 
-## Why Apache Fory™ C#? ##
+## Why Apache Fory™ C\#?
 
 - High-performance binary serialization for .NET 8+
 - Cross-language compatibility with Java, Python, C++, Go, Rust, and JavaScript
@@ -16,14 +16,14 @@ The C# implementation provides high-performance object 
graph serialization for .
 - Thread-safe runtime wrapper (`ThreadSafeFory`) for concurrent workloads
 - Dynamic object serialization APIs for heterogeneous payloads
 
-## Quick Start ##
+## Quick Start
 
-### Requirements ###
+### Requirements
 
 - .NET SDK 8.0+
 - C# 12+
 
-### Add Apache Fory™ C# ###
+### Add Apache Fory™ C\#
 
 From this repository, reference the library project:
 
@@ -37,7 +37,7 @@ From this repository, reference the library project:
 </ItemGroup>
 ```
 
-### Basic Example ###
+### Basic Example
 
 ```csharp
 using Apache.Fory;
@@ -64,9 +64,9 @@ byte[] payload = fory.Serialize(user);
 User decoded = fory.Deserialize<User>(payload);
 ```
 
-## Core Features ##
+## Core Features
 
-### 1. Object Graph Serialization ###
+### 1. Object Graph Serialization
 
 `[ForyObject]` types are serialized with generated serializers.
 
@@ -91,7 +91,7 @@ fory.Register<Address>(100);
 fory.Register<Person>(101);
 ```
 
-### 2. Shared and Circular References ###
+### 2. Shared and Circular References
 
 Enable reference tracking to preserve object identity.
 
@@ -113,7 +113,7 @@ Node decoded = fory.Deserialize<Node>(fory.Serialize(node));
 System.Diagnostics.Debug.Assert(object.ReferenceEquals(decoded, decoded.Next));
 ```
 
-### 3. Schema Evolution ###
+### 3. Schema Evolution
 
 Compatible mode allows schema changes between writer and reader.
 
@@ -140,7 +140,7 @@ fory2.Register<TwoFields>(300);
 TwoFields decoded = fory2.Deserialize<TwoFields>(fory1.Serialize(new OneField 
{ F1 = "hello" }));
 ```
 
-### 4. Dynamic Object Serialization ###
+### 4. Dynamic Object Serialization
 
 Use dynamic APIs for unknown/heterogeneous payloads.
 
@@ -158,7 +158,7 @@ byte[] payload = fory.SerializeObject(map);
 object? decoded = fory.DeserializeObject(payload);
 ```
 
-### 5. Thread-Safe Runtime ###
+### 5. Thread-Safe Runtime
 
 `Fory` is single-thread optimized. Use `ThreadSafeFory` for concurrent access.
 
@@ -173,7 +173,7 @@ Parallel.For(0, 128, i =>
 });
 ```
 
-### 6. Custom Serializers ###
+### 6. Custom Serializers
 
 Provide custom encoding logic with `Serializer<T>`.
 
@@ -202,7 +202,7 @@ Fory fory = Fory.Builder().Build();
 fory.Register<Point, PointSerializer>(400);
 ```
 
-## Cross-Language Serialization ##
+## Cross-Language Serialization
 
 Use consistent registration mappings across languages.
 
@@ -217,7 +217,7 @@ fory.Register<Person>(100); // same ID on other language 
peers
 
 See [xlang guide](../docs/guide/xlang/index.md) for mapping details.
 
-## Architecture ##
+## Architecture
 
 The C# implementation consists of:
 
@@ -240,7 +240,7 @@ csharp/
     └── Fory.XlangPeer/
 ```
 
-## Building and Testing ##
+## Building and Testing
 
 Run from the `csharp` directory:
 
@@ -252,7 +252,7 @@ dotnet build Fory.sln -c Release
 dotnet test Fory.sln -c Release
 ```
 
-## Documentation ##
+## Documentation
 
 - [C# guide index](../docs/guide/csharp/index.md)
 - [Cross-language serialization 
spec](../docs/specification/xlang_serialization_spec.md)
diff --git a/csharp/src/Fory/CollectionSerializers.cs 
b/csharp/src/Fory/CollectionSerializers.cs
index 5b1a04068..db05e99a6 100644
--- a/csharp/src/Fory/CollectionSerializers.cs
+++ b/csharp/src/Fory/CollectionSerializers.cs
@@ -78,6 +78,11 @@ internal static class CollectionCodec
         bool trackRef = context.TrackRef && 
elementTypeInfo.IsReferenceTrackableType;
         bool declaredElementType = hasGenerics && 
CanDeclareElementType<T>(elementTypeInfo);
         bool dynamicElementType = elementTypeInfo.IsDynamicType;
+        bool writeDeclaredCompatibleTypeInfo =
+            context.Compatible &&
+            declaredElementType &&
+            !dynamicElementType &&
+            elementTypeInfo.NeedsTypeInfoForField();
 
         byte header = dynamicElementType ? (byte)0 : CollectionBits.SameType;
         if (trackRef)
@@ -96,7 +101,7 @@ internal static class CollectionCodec
         }
 
         context.Writer.WriteUInt8(header);
-        if (!dynamicElementType && !declaredElementType)
+        if (!dynamicElementType && (!declaredElementType || 
writeDeclaredCompatibleTypeInfo))
         {
             context.TypeResolver.WriteTypeInfo(elementSerializer, context);
         }
@@ -161,6 +166,11 @@ internal static class CollectionCodec
         bool declared = (header & CollectionBits.DeclaredElementType) != 0;
         bool sameType = (header & CollectionBits.SameType) != 0;
         bool canonicalizeElements = context.TrackRef && !trackRef && 
elementTypeInfo.IsReferenceTrackableType;
+        bool readDeclaredCompatibleTypeInfo =
+            context.Compatible &&
+            declared &&
+            !elementTypeInfo.IsDynamicType &&
+            elementTypeInfo.NeedsTypeInfoForField();
 
         List<T> values = new(length);
         if (!sameType)
@@ -205,7 +215,7 @@ internal static class CollectionCodec
             return values;
         }
 
-        if (!declared)
+        if (!declared || readDeclaredCompatibleTypeInfo)
         {
             context.TypeResolver.ReadTypeInfo(elementSerializer, context);
         }
diff --git a/csharp/src/Fory/DictionarySerializers.cs 
b/csharp/src/Fory/DictionarySerializers.cs
index 289435549..8f7605853 100644
--- a/csharp/src/Fory/DictionarySerializers.cs
+++ b/csharp/src/Fory/DictionarySerializers.cs
@@ -66,6 +66,16 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
         bool valueDeclared = hasGenerics && 
!valueTypeInfo.NeedsTypeInfoForField();
         bool keyDynamicType = keyTypeInfo.IsDynamicType;
         bool valueDynamicType = valueTypeInfo.IsDynamicType;
+        bool writeDeclaredCompatibleKeyTypeInfo =
+            context.Compatible &&
+            keyDeclared &&
+            !keyDynamicType &&
+            keyTypeInfo.NeedsTypeInfoForField();
+        bool writeDeclaredCompatibleValueTypeInfo =
+            context.Compatible &&
+            valueDeclared &&
+            !valueDynamicType &&
+            valueTypeInfo.NeedsTypeInfoForField();
 
         KeyValuePair<TKey, TValue>[] pairs = SnapshotPairs(map);
         if (keyDynamicType || valueDynamicType)
@@ -80,6 +90,8 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
                 valueDeclared,
                 keyDynamicType,
                 valueDynamicType,
+                writeDeclaredCompatibleKeyTypeInfo,
+                writeDeclaredCompatibleValueTypeInfo,
                 keyTypeInfo,
                 valueTypeInfo,
                 keySerializer,
@@ -129,7 +141,7 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
                 context.Writer.WriteUInt8(header);
                 if (!keyIsNull)
                 {
-                    if (!keyDeclared)
+                    if (!keyDeclared || writeDeclaredCompatibleKeyTypeInfo)
                     {
                         context.TypeResolver.WriteTypeInfo(keySerializer, 
context);
                     }
@@ -139,7 +151,7 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
 
                 if (!valueIsNull)
                 {
-                    if (!valueDeclared)
+                    if (!valueDeclared || writeDeclaredCompatibleValueTypeInfo)
                     {
                         context.TypeResolver.WriteTypeInfo(valueSerializer, 
context);
                     }
@@ -175,12 +187,12 @@ public abstract class 
DictionaryLikeSerializer<TDictionary, TKey, TValue> : Seri
             context.Writer.WriteUInt8(blockHeader);
             int chunkSizeOffset = context.Writer.Count;
             context.Writer.WriteUInt8(0);
-            if (!keyDeclared)
+            if (!keyDeclared || writeDeclaredCompatibleKeyTypeInfo)
             {
                 context.TypeResolver.WriteTypeInfo(keySerializer, context);
             }
 
-            if (!valueDeclared)
+            if (!valueDeclared || writeDeclaredCompatibleValueTypeInfo)
             {
                 context.TypeResolver.WriteTypeInfo(valueSerializer, context);
             }
@@ -232,6 +244,16 @@ public abstract class 
DictionaryLikeSerializer<TDictionary, TKey, TValue> : Seri
             bool trackValueRef = (header & DictionaryBits.TrackingValueRef) != 
0;
             bool valueNull = (header & DictionaryBits.ValueNull) != 0;
             bool valueDeclared = (header & DictionaryBits.DeclaredValueType) 
!= 0;
+            bool readDeclaredCompatibleKeyTypeInfo =
+                context.Compatible &&
+                keyDeclared &&
+                !keyDynamicType &&
+                keyTypeInfo.NeedsTypeInfoForField();
+            bool readDeclaredCompatibleValueTypeInfo =
+                context.Compatible &&
+                valueDeclared &&
+                !valueDynamicType &&
+                valueTypeInfo.NeedsTypeInfoForField();
 
             if (keyNull && valueNull)
             {
@@ -246,7 +268,7 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
                 _ = ReadValueElement(
                     context,
                     trackValueRef,
-                    !valueDeclared,
+                    !valueDeclared || readDeclaredCompatibleValueTypeInfo,
                     canonicalizeValues,
                     valueSerializer);
 
@@ -260,7 +282,7 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
                 TKey key = keySerializer.Read(
                     context,
                     trackKeyRef ? RefMode.Tracking : RefMode.None,
-                    !keyDeclared);
+                    !keyDeclared || readDeclaredCompatibleKeyTypeInfo);
 
                 SetValue(map, key, (TValue)valueSerializer.DefaultObject!);
                 readCount += 1;
@@ -275,7 +297,7 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
                     DynamicTypeInfo? keyDynamicInfo = null;
                     DynamicTypeInfo? valueDynamicInfo = null;
 
-                    if (!keyDeclared)
+                    if (!keyDeclared || readDeclaredCompatibleKeyTypeInfo)
                     {
                         if (keyDynamicType)
                         {
@@ -287,7 +309,7 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
                         }
                     }
 
-                    if (!valueDeclared)
+                    if (!valueDeclared || readDeclaredCompatibleValueTypeInfo)
                     {
                         if (valueDynamicType)
                         {
@@ -333,12 +355,12 @@ public abstract class 
DictionaryLikeSerializer<TDictionary, TKey, TValue> : Seri
                 continue;
             }
 
-            if (!keyDeclared)
+            if (!keyDeclared || readDeclaredCompatibleKeyTypeInfo)
             {
                 context.TypeResolver.ReadTypeInfo(keySerializer, context);
             }
 
-            if (!valueDeclared)
+            if (!valueDeclared || readDeclaredCompatibleValueTypeInfo)
             {
                 context.TypeResolver.ReadTypeInfo(valueSerializer, context);
             }
@@ -376,6 +398,8 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
         bool valueDeclared,
         bool keyDynamicType,
         bool valueDynamicType,
+        bool writeDeclaredCompatibleKeyTypeInfo,
+        bool writeDeclaredCompatibleValueTypeInfo,
         TypeInfo keyTypeInfo,
         TypeInfo valueTypeInfo,
         Serializer<TKey> keySerializer,
@@ -443,7 +467,7 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
             }
 
             context.Writer.WriteUInt8(1);
-            if (!keyDeclared)
+            if (!keyDeclared || writeDeclaredCompatibleKeyTypeInfo)
             {
                 if (keyDynamicType)
                 {
@@ -455,7 +479,7 @@ public abstract class DictionaryLikeSerializer<TDictionary, 
TKey, TValue> : Seri
                 }
             }
 
-            if (!valueDeclared)
+            if (!valueDeclared || writeDeclaredCompatibleValueTypeInfo)
             {
                 if (valueDynamicType)
                 {
diff --git a/csharp/src/Fory/NullableKeyDictionary.cs 
b/csharp/src/Fory/NullableKeyDictionary.cs
index 544500da8..1f9de8843 100644
--- a/csharp/src/Fory/NullableKeyDictionary.cs
+++ b/csharp/src/Fory/NullableKeyDictionary.cs
@@ -411,6 +411,16 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
         bool valueDeclared = hasGenerics && 
!valueTypeInfo.NeedsTypeInfoForField();
         bool keyDynamicType = keyTypeInfo.IsDynamicType;
         bool valueDynamicType = valueTypeInfo.IsDynamicType;
+        bool writeDeclaredCompatibleKeyTypeInfo =
+            context.Compatible &&
+            keyDeclared &&
+            !keyDynamicType &&
+            keyTypeInfo.NeedsTypeInfoForField();
+        bool writeDeclaredCompatibleValueTypeInfo =
+            context.Compatible &&
+            valueDeclared &&
+            !valueDynamicType &&
+            valueTypeInfo.NeedsTypeInfoForField();
         KeyValuePair<TKey, TValue>[] pairs = [.. map];
         if (keyDynamicType || valueDynamicType)
         {
@@ -424,6 +434,8 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
                 valueDeclared,
                 keyDynamicType,
                 valueDynamicType,
+                writeDeclaredCompatibleKeyTypeInfo,
+                writeDeclaredCompatibleValueTypeInfo,
                 keyTypeInfo,
                 valueTypeInfo,
                 keySerializer,
@@ -472,7 +484,7 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
 
             if (keyIsNull)
             {
-                if (!valueDeclared)
+                if (!valueDeclared || writeDeclaredCompatibleValueTypeInfo)
                 {
                     context.TypeResolver.WriteTypeInfo(valueSerializer, 
context);
                 }
@@ -488,7 +500,7 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
 
             if (valueIsNull)
             {
-                if (!keyDeclared)
+                if (!keyDeclared || writeDeclaredCompatibleKeyTypeInfo)
                 {
                     context.TypeResolver.WriteTypeInfo(keySerializer, context);
                 }
@@ -503,12 +515,12 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
             }
 
             context.Writer.WriteUInt8(1);
-            if (!keyDeclared)
+            if (!keyDeclared || writeDeclaredCompatibleKeyTypeInfo)
             {
                 context.TypeResolver.WriteTypeInfo(keySerializer, context);
             }
 
-            if (!valueDeclared)
+            if (!valueDeclared || writeDeclaredCompatibleValueTypeInfo)
             {
                 context.TypeResolver.WriteTypeInfo(valueSerializer, context);
             }
@@ -555,6 +567,16 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
             bool trackValueRef = (header & DictionaryBits.TrackingValueRef) != 
0;
             bool valueNull = (header & DictionaryBits.ValueNull) != 0;
             bool valueDeclared = (header & DictionaryBits.DeclaredValueType) 
!= 0;
+            bool readDeclaredCompatibleKeyTypeInfo =
+                context.Compatible &&
+                keyDeclared &&
+                !keyDynamicType &&
+                keyTypeInfo.NeedsTypeInfoForField();
+            bool readDeclaredCompatibleValueTypeInfo =
+                context.Compatible &&
+                valueDeclared &&
+                !valueDynamicType &&
+                valueTypeInfo.NeedsTypeInfoForField();
 
             if (keyNull && valueNull)
             {
@@ -568,7 +590,7 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
                 TValue valueRead = ReadValueElement(
                     context,
                     trackValueRef,
-                    !valueDeclared,
+                    !valueDeclared || readDeclaredCompatibleValueTypeInfo,
                     canonicalizeValues,
                     valueSerializer);
 
@@ -582,7 +604,7 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
                 TKey key = keySerializer.Read(
                     context,
                     trackKeyRef ? RefMode.Tracking : RefMode.None,
-                    !keyDeclared);
+                    !keyDeclared || readDeclaredCompatibleKeyTypeInfo);
 
                 map[key] = (TValue)valueSerializer.DefaultObject!;
                 readCount += 1;
@@ -597,7 +619,7 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
                     DynamicTypeInfo? keyDynamicInfo = null;
                     DynamicTypeInfo? valueDynamicInfo = null;
 
-                    if (!keyDeclared)
+                    if (!keyDeclared || readDeclaredCompatibleKeyTypeInfo)
                     {
                         if (keyDynamicType)
                         {
@@ -609,7 +631,7 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
                         }
                     }
 
-                    if (!valueDeclared)
+                    if (!valueDeclared || readDeclaredCompatibleValueTypeInfo)
                     {
                         if (valueDynamicType)
                         {
@@ -655,12 +677,12 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
                 continue;
             }
 
-            if (!keyDeclared)
+            if (!keyDeclared || readDeclaredCompatibleKeyTypeInfo)
             {
                 context.TypeResolver.ReadTypeInfo(keySerializer, context);
             }
 
-            if (!valueDeclared)
+            if (!valueDeclared || readDeclaredCompatibleValueTypeInfo)
             {
                 context.TypeResolver.ReadTypeInfo(valueSerializer, context);
             }
@@ -698,6 +720,8 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
         bool valueDeclared,
         bool keyDynamicType,
         bool valueDynamicType,
+        bool writeDeclaredCompatibleKeyTypeInfo,
+        bool writeDeclaredCompatibleValueTypeInfo,
         TypeInfo keyTypeInfo,
         TypeInfo valueTypeInfo,
         Serializer<TKey> keySerializer,
@@ -765,7 +789,7 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
             }
 
             context.Writer.WriteUInt8(1);
-            if (!keyDeclared)
+            if (!keyDeclared || writeDeclaredCompatibleKeyTypeInfo)
             {
                 if (keyDynamicType)
                 {
@@ -777,7 +801,7 @@ public sealed class NullableKeyDictionarySerializer<TKey, 
TValue> : Serializer<N
                 }
             }
 
-            if (!valueDeclared)
+            if (!valueDeclared || writeDeclaredCompatibleValueTypeInfo)
             {
                 if (valueDynamicType)
                 {
diff --git a/csharp/src/Fory/UnionSerializer.cs 
b/csharp/src/Fory/UnionSerializer.cs
index 90d4a1970..b3cf25e5d 100644
--- a/csharp/src/Fory/UnionSerializer.cs
+++ b/csharp/src/Fory/UnionSerializer.cs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+using System.Collections;
 using System.Linq.Expressions;
 using System.Reflection;
 
@@ -24,9 +25,7 @@ public sealed class UnionSerializer<TUnion> : 
Serializer<TUnion>
     where TUnion : Union
 {
     private static readonly Func<int, object?, TUnion> Factory = 
BuildFactory();
-
-
-
+    private static readonly IReadOnlyDictionary<int, Type> CaseTypeByIndex = 
BuildCaseTypeMap();
 
     public override TUnion DefaultValue => null!;
 
@@ -39,6 +38,12 @@ public sealed class UnionSerializer<TUnion> : 
Serializer<TUnion>
         }
 
         context.Writer.WriteVarUInt32((uint)value.Index);
+        if (CaseTypeByIndex.TryGetValue(value.Index, out Type? caseType))
+        {
+            WriteTypedCaseValue(context, caseType, value.Value);
+            return;
+        }
+
         DynamicAnyCodec.WriteAny(context, value.Value, RefMode.Tracking, true, 
false);
     }
 
@@ -50,8 +55,18 @@ public sealed class UnionSerializer<TUnion> : 
Serializer<TUnion>
             throw new InvalidDataException($"union case id out of range: 
{rawCaseId}");
         }
 
-        object? caseValue = DynamicAnyCodec.ReadAny(context, RefMode.Tracking, 
true);
-        return Factory((int)rawCaseId, caseValue);
+        int caseId = (int)rawCaseId;
+        object? caseValue;
+        if (CaseTypeByIndex.TryGetValue(caseId, out Type? caseType))
+        {
+            caseValue = ReadTypedCaseValue(context, caseType);
+        }
+        else
+        {
+            caseValue = DynamicAnyCodec.ReadAny(context, RefMode.Tracking, 
true);
+        }
+
+        return Factory(caseId, caseValue);
     }
 
     private static Func<int, object?, TUnion> BuildFactory()
@@ -88,4 +103,170 @@ public sealed class UnionSerializer<TUnion> : 
Serializer<TUnion>
         throw new InvalidDataException(
             $"union type {typeof(TUnion)} must define (int, object) 
constructor or static Of(int, object)");
     }
+
+    private static IReadOnlyDictionary<int, Type> BuildCaseTypeMap()
+    {
+        if (typeof(TUnion) == typeof(Union))
+        {
+            return new Dictionary<int, Type>();
+        }
+
+        Dictionary<int, Type> caseTypes = new();
+        MethodInfo[] methods = typeof(TUnion).GetMethods(BindingFlags.Public | 
BindingFlags.Static);
+        foreach (MethodInfo method in methods)
+        {
+            if (!typeof(TUnion).IsAssignableFrom(method.ReturnType))
+            {
+                continue;
+            }
+
+            ParameterInfo[] parameters = method.GetParameters();
+            if (parameters.Length != 1)
+            {
+                continue;
+            }
+
+            Type caseType = parameters[0].ParameterType;
+            if (!TryResolveCaseIndex(method, caseType, out int caseIndex))
+            {
+                continue;
+            }
+
+            caseTypes.TryAdd(caseIndex, caseType);
+        }
+
+        return caseTypes;
+    }
+
+    private static bool TryResolveCaseIndex(MethodInfo method, Type caseType, 
out int caseIndex)
+    {
+        caseIndex = default;
+        object? probeArg = CreateProbeArgument(caseType);
+        try
+        {
+            object? result = method.Invoke(null, [probeArg]);
+            if (result is not Union union)
+            {
+                return false;
+            }
+
+            caseIndex = union.Index;
+            return true;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+
+    private static object? CreateProbeArgument(Type caseType)
+    {
+        if (!caseType.IsValueType)
+        {
+            return null;
+        }
+
+        return Activator.CreateInstance(caseType);
+    }
+
+    private static void WriteTypedCaseValue(WriteContext context, Type 
caseType, object? value)
+    {
+        object? normalized = NormalizeCaseValue(value, caseType);
+        DynamicAnyCodec.WriteAny(context, normalized, RefMode.Tracking, 
writeTypeInfo: true, hasGenerics: caseType.IsGenericType);
+    }
+
+    private static object? ReadTypedCaseValue(ReadContext context, Type 
caseType)
+    {
+        object? value = DynamicAnyCodec.ReadAny(context, RefMode.Tracking, 
readTypeInfo: true);
+        return NormalizeCaseValue(value, caseType);
+    }
+
+    private static object? NormalizeCaseValue(object? value, Type targetType)
+    {
+        if (value is null || targetType.IsInstanceOfType(value))
+        {
+            return value;
+        }
+
+        if (TryConvertListValue(value, targetType, out object? converted))
+        {
+            return converted;
+        }
+
+        return value;
+    }
+
+    private static bool TryConvertListValue(object value, Type targetType, out 
object? converted)
+    {
+        converted = null;
+        if (!TryGetListElementType(targetType, out Type? elementType))
+        {
+            return false;
+        }
+
+        if (value is not IEnumerable source)
+        {
+            return false;
+        }
+
+        IList typedList = 
(IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(elementType!))!;
+        foreach (object? item in source)
+        {
+            typedList.Add(ConvertListElement(item, elementType!));
+        }
+
+        converted = typedList;
+        return true;
+    }
+
+    private static bool TryGetListElementType(Type targetType, out Type? 
elementType)
+    {
+        if (targetType.IsArray)
+        {
+            elementType = targetType.GetElementType();
+            return elementType is not null;
+        }
+
+        if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() 
== typeof(List<>))
+        {
+            elementType = targetType.GetGenericArguments()[0];
+            return true;
+        }
+
+        foreach (Type iface in targetType.GetInterfaces())
+        {
+            if (!iface.IsGenericType)
+            {
+                continue;
+            }
+
+            Type genericDef = iface.GetGenericTypeDefinition();
+            if (genericDef == typeof(IList<>) || genericDef == 
typeof(IReadOnlyList<>) || genericDef == typeof(IEnumerable<>))
+            {
+                elementType = iface.GetGenericArguments()[0];
+                return true;
+            }
+        }
+
+        elementType = null;
+        return false;
+    }
+
+    private static object? ConvertListElement(object? value, Type elementType)
+    {
+        if (value is null || elementType.IsInstanceOfType(value))
+        {
+            return value;
+        }
+
+        Type target = Nullable.GetUnderlyingType(elementType) ?? elementType;
+        try
+        {
+            return Convert.ChangeType(value, target);
+        }
+        catch
+        {
+            return value;
+        }
+    }
 }
diff --git a/docs/compiler/compiler-guide.md b/docs/compiler/compiler-guide.md
index 833ea0d3f..33c8f6bc9 100644
--- a/docs/compiler/compiler-guide.md
+++ b/docs/compiler/compiler-guide.md
@@ -63,6 +63,7 @@ Compile options:
 | `--cpp_out=DST_DIR`                   | Generate C++ code in DST_DIR         
                 | (none)              |
 | `--go_out=DST_DIR`                    | Generate Go code in DST_DIR          
                 | (none)              |
 | `--rust_out=DST_DIR`                  | Generate Rust code in DST_DIR        
                 | (none)              |
+| `--csharp_out=DST_DIR`                | Generate C# code in DST_DIR          
                 | (none)              |
 | `--go_nested_type_style`              | Go nested type naming: `camelcase` 
or `underscore`    | from schema/default |
 | `--emit-fdl`                          | Print translated Fory IDL for 
non-`.fdl` inputs       | `false`             |
 | `--emit-fdl-path`                     | Write translated Fory IDL to a file 
or directory      | (stdout)            |
@@ -110,7 +111,7 @@ foryc schema.fdl
 **Compile for specific languages:**
 
 ```bash
-foryc schema.fdl --lang java,python
+foryc schema.fdl --lang java,python,csharp
 ```
 
 **Specify output directory:**
@@ -157,7 +158,7 @@ foryc src/main.fdl -I libs/common,libs/types --proto_path 
third_party/
 foryc schema.fdl --java_out=./src/main/java
 
 # Generate multiple languages to different directories
-foryc schema.fdl --java_out=./java/gen --python_out=./python/src 
--go_out=./go/gen
+foryc schema.fdl --java_out=./java/gen --python_out=./python/src 
--go_out=./go/gen --csharp_out=./csharp/gen
 
 # Combine with import paths
 foryc schema.fdl --java_out=./gen/java -I proto/ -I common/
@@ -226,13 +227,14 @@ Compiling src/main.fdl...
 
 ## Supported Languages
 
-| Language | Flag     | Output Extension | Description                 |
-| -------- | -------- | ---------------- | --------------------------- |
-| Java     | `java`   | `.java`          | POJOs with Fory annotations |
-| Python   | `python` | `.py`            | Dataclasses with type hints |
-| Go       | `go`     | `.go`            | Structs with struct tags    |
-| Rust     | `rust`   | `.rs`            | Structs with derive macros  |
-| C++      | `cpp`    | `.h`             | Structs with FORY macros    |
+| Language | Flag     | Output Extension | Description                  |
+| -------- | -------- | ---------------- | ---------------------------- |
+| Java     | `java`   | `.java`          | POJOs with Fory annotations  |
+| Python   | `python` | `.py`            | Dataclasses with type hints  |
+| Go       | `go`     | `.go`            | Structs with struct tags     |
+| Rust     | `rust`   | `.rs`            | Structs with derive macros   |
+| C++      | `cpp`    | `.h`             | Structs with FORY macros     |
+| C#       | `csharp` | `.cs`            | Classes with Fory attributes |
 
 ## Output Structure
 
@@ -302,6 +304,43 @@ generated/
 - Namespace matches package (dots to `::`)
 - Header guards and forward declarations
 
+### C\#
+
+```
+generated/
+└── csharp/
+    └── example/
+        └── example.cs
+```
+
+- Single `.cs` file per schema
+- Namespace uses `csharp_namespace` (if set) or Fory IDL package
+- Includes registration helper and `ToBytes`/`FromBytes` methods
+- Imported schemas are registered transitively (for example `root.idl` 
importing
+  `addressbook.fdl` and `tree.fdl`)
+
+### C# IDL Matrix Verification
+
+Run the end-to-end C# IDL matrix (FDL/IDL/Proto/FBS generation plus roundtrip 
tests):
+
+```bash
+cd integration_tests/idl_tests
+./run_csharp_tests.sh
+```
+
+This runner executes schema-consistent and compatible roundtrips across:
+
+- `addressbook`, `auto_id`, `complex_pb` primitives
+- `collection` and union/list variants
+- `optional_types`
+- `any_example` (`.fdl`) and `any_example` (`.proto`)
+- `tree` and `graph` reference-tracking cases
+- `monster.fbs` and `complex_fbs.fbs`
+- `root.idl` cross-package import coverage
+- evolving schema compatibility cases
+
+The script also sets `DATA_FILE*` variables so file-based roundtrip paths are 
exercised.
+
 ## Build Integration
 
 ### Maven (Java)
diff --git a/docs/compiler/generated-code.md b/docs/compiler/generated-code.md
index b433374af..72225f4fd 100644
--- a/docs/compiler/generated-code.md
+++ b/docs/compiler/generated-code.md
@@ -685,6 +685,63 @@ if err := restored.FromBytes(data); err != nil {
 }
 ```
 
+## C\#
+
+### Output Layout
+
+C# output is one `.cs` file per schema, for example:
+
+- `<csharp_out>/addressbook/addressbook.cs`
+
+### Type Generation
+
+Messages generate `[ForyObject]` classes with C# properties and byte helpers:
+
+```csharp
+[ForyObject]
+public sealed partial class Person
+{
+    public string Name { get; set; } = string.Empty;
+    public int Id { get; set; }
+    public List<Person.PhoneNumber> Phones { get; set; } = new();
+    public Animal Pet { get; set; } = null!;
+
+    public byte[] ToBytes() { ... }
+    public static Person FromBytes(byte[] data) { ... }
+}
+```
+
+Unions generate `Union` subclasses with typed case helpers:
+
+```csharp
+public sealed class Animal : Union
+{
+    public static Animal Dog(Dog value) { ... }
+    public static Animal Cat(Cat value) { ... }
+    public bool IsDog => ...;
+    public Dog DogValue() { ... }
+}
+```
+
+### Registration
+
+Each schema generates a registration helper:
+
+```csharp
+public static class AddressbookForyRegistration
+{
+    public static void Register(Fory fory)
+    {
+        fory.Register<addressbook.Animal>((uint)106);
+        fory.Register<addressbook.Person>((uint)100);
+        // ...
+    }
+}
+```
+
+When explicit type IDs are not provided, generated registration uses computed
+numeric IDs (same behavior as other targets).
+
 ## Cross-Language Notes
 
 ### Type ID Behavior
@@ -702,6 +759,7 @@ if err := restored.FromBytes(data); err != nil {
 | Rust     | `person::PhoneNumber`          |
 | C++      | `Person::PhoneNumber`          |
 | Go       | `Person_PhoneNumber` (default) |
+| C#       | `Person.PhoneNumber`           |
 
 ### Byte Helper Naming
 
@@ -712,3 +770,4 @@ if err := restored.FromBytes(data); err != nil {
 | Rust     | `to_bytes` / `from_bytes` |
 | C++      | `to_bytes` / `from_bytes` |
 | Go       | `ToBytes` / `FromBytes`   |
+| C#       | `ToBytes` / `FromBytes`   |
diff --git a/docs/compiler/index.md b/docs/compiler/index.md
index 003801879..d6e8bb99b 100644
--- a/docs/compiler/index.md
+++ b/docs/compiler/index.md
@@ -21,7 +21,7 @@ license: |
 
 Fory IDL is a schema definition language for Apache Fory that enables type-safe
 cross-language serialization. Define your data structures once and generate
-native data structure code for Java, Python, Go, Rust, and C++.
+native data structure code for Java, Python, Go, Rust, C++, and C#.
 
 ## Example Schema
 
@@ -100,6 +100,7 @@ Generated code uses native language constructs:
 - Go: Structs with struct tags
 - Rust: Structs with `#[derive(ForyObject)]`
 - C++: Structs with `FORY_STRUCT` macros
+- C#: Classes with `[ForyObject]` and registration helpers
 
 ## Quick Start
 
@@ -137,7 +138,7 @@ message Person {
 foryc example.fdl --output ./generated
 
 # Generate for specific languages
-foryc example.fdl --lang java,python --output ./generated
+foryc example.fdl --lang java,python,csharp --output ./generated
 ```
 
 ### 4. Use Generated Code
@@ -192,11 +193,11 @@ message Example {
 
 Fory IDL types map to native types in each language:
 
-| Fory IDL Type | Java      | Python         | Go       | Rust     | C++       
    |
-| ------------- | --------- | -------------- | -------- | -------- | 
------------- |
-| `int32`       | `int`     | `pyfory.int32` | `int32`  | `i32`    | `int32_t` 
    |
-| `string`      | `String`  | `str`          | `string` | `String` | 
`std::string` |
-| `bool`        | `boolean` | `bool`         | `bool`   | `bool`   | `bool`    
    |
+| Fory IDL Type | Java      | Python         | Go       | Rust     | C++       
    | C#       |
+| ------------- | --------- | -------------- | -------- | -------- | 
------------- | -------- |
+| `int32`       | `int`     | `pyfory.int32` | `int32`  | `i32`    | `int32_t` 
    | `int`    |
+| `string`      | `String`  | `str`          | `string` | `String` | 
`std::string` | `string` |
+| `bool`        | `boolean` | `bool`         | `bool`   | `bool`   | `bool`    
    | `bool`   |
 
 See [Type System](schema-idl.md#type-system) for complete mappings.
 
diff --git a/docs/compiler/schema-idl.md b/docs/compiler/schema-idl.md
index 4e297aa77..eb9a96dc4 100644
--- a/docs/compiler/schema-idl.md
+++ b/docs/compiler/schema-idl.md
@@ -100,6 +100,7 @@ package com.example.models alias models_v1;
 | Go       | Package name (last component)     |
 | Rust     | Module name (dots to underscores) |
 | C++      | Namespace (dots to `::`)          |
+| C#       | Namespace                         |
 
 ## File-Level Options
 
@@ -151,6 +152,25 @@ message Payment {
 - The import path can be used in other Go code
 - Type registration still uses the Fory IDL package (`payment`) for 
cross-language compatibility
 
+### C# Namespace Option
+
+Override the C# namespace for generated code:
+
+```protobuf
+package payment;
+option csharp_namespace = "MyCorp.Payment.V1";
+
+message Payment {
+    string id = 1;
+}
+```
+
+**Effect:**
+
+- Generated C# files use `namespace MyCorp.Payment.V1;`
+- Output path follows namespace segments (`MyCorp/Payment/V1/` under 
`--csharp_out`)
+- Type registration still uses the Fory IDL package (`payment`) for 
cross-language compatibility
+
 ### Java Outer Classname Option
 
 Generate all types as inner classes of a single outer wrapper class:
@@ -285,10 +305,10 @@ For protobuf extension options, see
 
 ### Option Priority
 
-For language-specific packages:
+For language-specific packages/namespaces:
 
 1. Command-line package override (highest priority)
-2. Language-specific option (`java_package`, `go_package`)
+2. Language-specific option (`java_package`, `go_package`, `csharp_namespace`)
 3. Fory IDL package declaration (fallback)
 
 **Example:**
diff --git a/integration_tests/idl_tests/README.md 
b/integration_tests/idl_tests/README.md
index 053697e9f..e3c8c34cd 100644
--- a/integration_tests/idl_tests/README.md
+++ b/integration_tests/idl_tests/README.md
@@ -10,3 +10,4 @@ Run tests:
 - Go: `./run_go_tests.sh`
 - Rust: `./run_rust_tests.sh`
 - C++: `./run_cpp_tests.sh`
+- C#: `./run_csharp_tests.sh`
diff --git a/integration_tests/idl_tests/csharp/IdlTests/.gitignore 
b/integration_tests/idl_tests/csharp/IdlTests/.gitignore
new file mode 100644
index 000000000..87c106435
--- /dev/null
+++ b/integration_tests/idl_tests/csharp/IdlTests/.gitignore
@@ -0,0 +1,4 @@
+Generated/
+bin/
+obj/
+TestResults/
diff --git a/integration_tests/idl_tests/csharp/IdlTests/GlobalUsings.cs 
b/integration_tests/idl_tests/csharp/IdlTests/GlobalUsings.cs
new file mode 100644
index 000000000..32c527781
--- /dev/null
+++ b/integration_tests/idl_tests/csharp/IdlTests/GlobalUsings.cs
@@ -0,0 +1,18 @@
+// 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
+//
+//   http://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.
+
+global using Xunit;
diff --git a/integration_tests/idl_tests/csharp/IdlTests/IdlTests.csproj 
b/integration_tests/idl_tests/csharp/IdlTests/IdlTests.csproj
new file mode 100644
index 000000000..d216646be
--- /dev/null
+++ b/integration_tests/idl_tests/csharp/IdlTests/IdlTests.csproj
@@ -0,0 +1,28 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net8.0</TargetFramework>
+    <LangVersion>12.0</LangVersion>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+    <IsPackable>false</IsPackable>
+    <IsTestProject>true</IsTestProject>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="coverlet.collector" Version="6.0.0">
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; 
buildtransitive</IncludeAssets>
+      <PrivateAssets>all</PrivateAssets>
+    </PackageReference>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
+    <PackageReference Include="xunit" Version="2.9.2" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
+      <IncludeAssets>runtime; build; native; contentfiles; analyzers; 
buildtransitive</IncludeAssets>
+      <PrivateAssets>all</PrivateAssets>
+    </PackageReference>
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="../../../../csharp/src/Fory/Fory.csproj" />
+    <ProjectReference 
Include="../../../../csharp/src/Fory.Generator/Fory.Generator.csproj" 
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
+  </ItemGroup>
+</Project>
diff --git a/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs 
b/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs
new file mode 100644
index 000000000..79e107d28
--- /dev/null
+++ b/integration_tests/idl_tests/csharp/IdlTests/RoundtripTests.cs
@@ -0,0 +1,1233 @@
+// 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
+//
+//   http://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.Globalization;
+using Apache.Fory;
+using ForyRuntime = Apache.Fory.Fory;
+
+namespace Apache.Fory.IdlTests;
+
+public sealed class RoundtripTests
+{
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void AddressBookRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, false);
+        addressbook.AddressbookForyRegistration.Register(fory);
+
+        addressbook.AddressBook book = BuildAddressBook();
+        addressbook.AddressBook decoded = 
fory.Deserialize<addressbook.AddressBook>(fory.Serialize(book));
+        AssertAddressBook(book, decoded);
+
+        RoundTripFile(fory, "DATA_FILE", book, AssertAddressBook);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void AutoIdRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, false);
+        auto_id.AutoIdForyRegistration.Register(fory);
+
+        auto_id.Envelope envelope = BuildEnvelope();
+        auto_id.Wrapper wrapper = auto_id.Wrapper.Envelope(envelope);
+
+        auto_id.Envelope envelopeDecoded = 
fory.Deserialize<auto_id.Envelope>(fory.Serialize(envelope));
+        AssertEnvelope(envelope, envelopeDecoded);
+
+        auto_id.Wrapper wrapperDecoded = 
fory.Deserialize<auto_id.Wrapper>(fory.Serialize(wrapper));
+        Assert.True(wrapperDecoded.IsEnvelope);
+        AssertEnvelope(envelope, wrapperDecoded.EnvelopeValue());
+
+        RoundTripFile(fory, "DATA_FILE_AUTO_ID", envelope, AssertEnvelope);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void PrimitiveTypesRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, false);
+        complex_pb.ComplexPbForyRegistration.Register(fory);
+
+        complex_pb.PrimitiveTypes types = BuildPrimitiveTypes();
+        complex_pb.PrimitiveTypes decoded = 
fory.Deserialize<complex_pb.PrimitiveTypes>(fory.Serialize(types));
+        AssertPrimitiveTypes(types, decoded);
+
+        RoundTripFile(fory, "DATA_FILE_PRIMITIVES", types, 
AssertPrimitiveTypes);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void CollectionRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, false);
+        collection.CollectionForyRegistration.Register(fory);
+
+        collection.NumericCollections collections = BuildNumericCollections();
+        collection.NumericCollectionUnion unionValue = 
BuildNumericCollectionUnion();
+        collection.NumericCollectionsArray collectionsArray = 
BuildNumericCollectionsArray();
+        collection.NumericCollectionArrayUnion arrayUnion = 
BuildNumericCollectionArrayUnion();
+
+        collection.NumericCollections collectionsDecoded =
+            
fory.Deserialize<collection.NumericCollections>(fory.Serialize(collections));
+        AssertNumericCollections(collections, collectionsDecoded);
+
+        collection.NumericCollectionUnion unionDecoded =
+            
fory.Deserialize<collection.NumericCollectionUnion>(fory.Serialize(unionValue));
+        AssertNumericCollectionUnion(unionValue, unionDecoded);
+
+        collection.NumericCollectionsArray arrayDecoded =
+            
fory.Deserialize<collection.NumericCollectionsArray>(fory.Serialize(collectionsArray));
+        AssertNumericCollectionsArray(collectionsArray, arrayDecoded);
+
+        collection.NumericCollectionArrayUnion arrayUnionDecoded =
+            
fory.Deserialize<collection.NumericCollectionArrayUnion>(fory.Serialize(arrayUnion));
+        AssertNumericCollectionArrayUnion(arrayUnion, arrayUnionDecoded);
+
+        RoundTripFile(fory, "DATA_FILE_COLLECTION", collections, 
AssertNumericCollections);
+        RoundTripFile(fory, "DATA_FILE_COLLECTION_UNION", unionValue, 
AssertNumericCollectionUnion);
+        RoundTripFile(fory, "DATA_FILE_COLLECTION_ARRAY", collectionsArray, 
AssertNumericCollectionsArray);
+        RoundTripFile(fory, "DATA_FILE_COLLECTION_ARRAY_UNION", arrayUnion, 
AssertNumericCollectionArrayUnion);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void OptionalTypesRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, false);
+        optional_types.OptionalTypesForyRegistration.Register(fory);
+
+        optional_types.OptionalHolder holder = BuildOptionalHolder();
+        optional_types.OptionalHolder decoded = 
fory.Deserialize<optional_types.OptionalHolder>(fory.Serialize(holder));
+        AssertOptionalHolder(holder, decoded);
+
+        RoundTripFile(fory, "DATA_FILE_OPTIONAL_TYPES", holder, 
AssertOptionalHolder);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void AnyRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, false);
+        any_example.AnyExampleForyRegistration.Register(fory);
+
+        any_example.AnyHolder holder = BuildAnyHolder();
+        any_example.AnyHolder decoded = 
fory.Deserialize<any_example.AnyHolder>(fory.Serialize(holder));
+        AssertAnyHolder(holder, decoded);
+
+        RoundTripFile(fory, "DATA_FILE_ANY", holder, AssertAnyHolder);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void AnyProtoRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, false);
+        any_example_pb.AnyExamplePbForyRegistration.Register(fory);
+
+        any_example_pb.AnyHolder holder = BuildAnyProtoHolder();
+        any_example_pb.AnyHolder decoded = 
fory.Deserialize<any_example_pb.AnyHolder>(fory.Serialize(holder));
+        AssertAnyProtoHolder(holder, decoded);
+
+        RoundTripFile(fory, "DATA_FILE_ANY_PROTO", holder, 
AssertAnyProtoHolder);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void FlatbuffersRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, false);
+        monster.MonsterForyRegistration.Register(fory);
+        complex_fbs.ComplexFbsForyRegistration.Register(fory);
+
+        monster.Monster monsterValue = BuildMonster();
+        monster.Monster monsterDecoded = 
fory.Deserialize<monster.Monster>(fory.Serialize(monsterValue));
+        AssertMonster(monsterValue, monsterDecoded);
+
+        complex_fbs.Container container = BuildContainer();
+        complex_fbs.Container containerDecoded = 
fory.Deserialize<complex_fbs.Container>(fory.Serialize(container));
+        AssertContainer(container, containerDecoded);
+
+        RoundTripFile(fory, "DATA_FILE_FLATBUFFERS_MONSTER", monsterValue, 
AssertMonster);
+        RoundTripFile(fory, "DATA_FILE_FLATBUFFERS_TEST2", container, 
AssertContainer);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void TreeRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, true);
+        tree.TreeForyRegistration.Register(fory);
+
+        tree.TreeNode treeRoot = BuildTree();
+        tree.TreeNode decoded = 
fory.Deserialize<tree.TreeNode>(fory.Serialize(treeRoot));
+        AssertTree(decoded);
+
+        RoundTripFile(fory, "DATA_FILE_TREE", treeRoot, (_, actual) => 
AssertTree(actual));
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void GraphRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, true);
+        graph.GraphForyRegistration.Register(fory);
+
+        graph.Graph graphValue = BuildGraph();
+        graph.Graph decoded = 
fory.Deserialize<graph.Graph>(fory.Serialize(graphValue));
+        AssertGraph(decoded);
+
+        RoundTripFile(fory, "DATA_FILE_GRAPH", graphValue, (_, actual) => 
AssertGraph(actual));
+    }
+
+    [Fact]
+    public void EvolvingRoundTrip()
+    {
+        ForyRuntime foryV1 = BuildFory(true, false);
+        ForyRuntime foryV2 = BuildFory(true, false);
+        evolving1.Evolving1ForyRegistration.Register(foryV1);
+        evolving2.Evolving2ForyRegistration.Register(foryV2);
+
+        evolving1.EvolvingMessage messageV1 = new()
+        {
+            Id = 1,
+            Name = "Alice",
+            City = "NYC",
+        };
+
+        evolving2.EvolvingMessage messageV2 = 
foryV2.Deserialize<evolving2.EvolvingMessage>(foryV1.Serialize(messageV1));
+        Assert.Equal(messageV1.Id, messageV2.Id);
+        Assert.Equal(messageV1.Name, messageV2.Name);
+        Assert.Equal(messageV1.City, messageV2.City);
+
+        messageV2.Email = "[email protected]";
+        evolving1.EvolvingMessage messageV1Round =
+            
foryV1.Deserialize<evolving1.EvolvingMessage>(foryV2.Serialize(messageV2));
+        Assert.Equal(messageV1.Id, messageV1Round.Id);
+        Assert.Equal(messageV1.Name, messageV1Round.Name);
+        Assert.Equal(messageV1.City, messageV1Round.City);
+
+        evolving1.FixedMessage fixedV1 = new()
+        {
+            Id = 10,
+            Name = "Bob",
+            Score = 90,
+            Note = "note",
+        };
+
+        bool fixedRoundTripMatches = false;
+        try
+        {
+            evolving2.FixedMessage fixedV2 = 
foryV2.Deserialize<evolving2.FixedMessage>(foryV1.Serialize(fixedV1));
+            evolving1.FixedMessage fixedV1Round =
+                
foryV1.Deserialize<evolving1.FixedMessage>(foryV2.Serialize(fixedV2));
+            fixedRoundTripMatches =
+                fixedV1Round.Id == fixedV1.Id &&
+                fixedV1Round.Name == fixedV1.Name &&
+                fixedV1Round.Score == fixedV1.Score &&
+                fixedV1Round.Note == fixedV1.Note;
+        }
+        catch
+        {
+            fixedRoundTripMatches = false;
+        }
+
+        Assert.False(fixedRoundTripMatches);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void RootRoundTrip(bool compatible)
+    {
+        ForyRuntime fory = BuildFory(compatible, true);
+        root.RootForyRegistration.Register(fory);
+
+        root.MultiHolder holder = BuildRootHolder();
+        root.MultiHolder decoded = 
fory.Deserialize<root.MultiHolder>(fory.Serialize(holder));
+        AssertRootHolder(holder, decoded);
+
+        RoundTripFile(fory, "DATA_FILE_ROOT", holder, AssertRootHolder);
+    }
+
+    [Fact]
+    public void RootToBytesFromBytes()
+    {
+        root.MultiHolder holder = BuildRootHolder();
+        byte[] payload = holder.ToBytes();
+        root.MultiHolder decoded = root.MultiHolder.FromBytes(payload);
+        AssertRootHolder(holder, decoded);
+    }
+
+    [Fact]
+    public void ToBytesFromBytesHelpers()
+    {
+        addressbook.AddressBook book = BuildAddressBook();
+        addressbook.AddressBook decodedBook = 
addressbook.AddressBook.FromBytes(book.ToBytes());
+        AssertAddressBook(book, decodedBook);
+
+        addressbook.Animal animal = addressbook.Animal.Dog(new addressbook.Dog
+        {
+            Name = "Rex",
+            BarkVolume = 5,
+        });
+        addressbook.Animal decodedAnimal = 
addressbook.Animal.FromBytes(animal.ToBytes());
+        Assert.True(decodedAnimal.IsDog);
+        Assert.Equal("Rex", decodedAnimal.DogValue().Name);
+        Assert.Equal(5, decodedAnimal.DogValue().BarkVolume);
+    }
+
+    private static ForyRuntime BuildFory(bool compatible, bool trackRef)
+    {
+        return ForyRuntime.Builder()
+            .Xlang(true)
+            .Compatible(compatible)
+            .TrackRef(trackRef)
+            .Build();
+    }
+
+    private static void RoundTripFile<T>(
+        ForyRuntime fory,
+        string envName,
+        T expected,
+        Action<T, T> assertRoundTrip)
+    {
+        string? dataFile = Environment.GetEnvironmentVariable(envName);
+        if (string.IsNullOrWhiteSpace(dataFile))
+        {
+            return;
+        }
+
+        string modeFile = dataFile +
+                          (fory.Config.Compatible ? ".compatible" : 
".schema_consistent") +
+                          (fory.Config.TrackRef ? ".track_ref" : ".no_ref");
+
+        if (!File.Exists(modeFile) || new FileInfo(modeFile).Length == 0)
+        {
+            File.WriteAllBytes(modeFile, fory.Serialize(expected));
+        }
+
+        byte[] peerPayload = File.ReadAllBytes(modeFile);
+        T decoded = fory.Deserialize<T>(peerPayload);
+        assertRoundTrip(expected, decoded);
+
+        byte[] output = fory.Serialize(decoded);
+        File.WriteAllBytes(modeFile, output);
+    }
+
+    private static addressbook.AddressBook BuildAddressBook()
+    {
+        addressbook.Person.PhoneNumber mobile = new()
+        {
+            Number = "555-0100",
+            PhoneType = addressbook.Person.PhoneType.Mobile,
+        };
+        addressbook.Person.PhoneNumber work = new()
+        {
+            Number = "555-0111",
+            PhoneType = addressbook.Person.PhoneType.Work,
+        };
+
+        addressbook.Animal pet = addressbook.Animal.Dog(new addressbook.Dog
+        {
+            Name = "Rex",
+            BarkVolume = 5,
+        });
+        pet = addressbook.Animal.Cat(new addressbook.Cat
+        {
+            Name = "Mimi",
+            Lives = 9,
+        });
+
+        addressbook.Person person = new()
+        {
+            Name = "Alice",
+            Id = 123,
+            Email = "[email protected]",
+            Tags = ["friend", "colleague"],
+            Scores = new Dictionary<string, int>
+            {
+                ["math"] = 100,
+                ["science"] = 98,
+            },
+            Salary = 120000.5,
+            Phones = [mobile, work],
+            Pet = pet,
+        };
+
+        return new addressbook.AddressBook
+        {
+            People = [person],
+            PeopleByName = new Dictionary<string, addressbook.Person>
+            {
+                [person.Name] = person,
+            },
+        };
+    }
+
+    private static auto_id.Envelope BuildEnvelope()
+    {
+        auto_id.Envelope.Payload payload = new()
+        {
+            Value = 42,
+        };
+
+        return new auto_id.Envelope
+        {
+            Id = "env-1",
+            PayloadValue = payload,
+            DetailValue = auto_id.Envelope.Detail.Payload(payload),
+            Status = auto_id.Status.Ok,
+        };
+    }
+
+    private static complex_pb.PrimitiveTypes BuildPrimitiveTypes()
+    {
+        return new complex_pb.PrimitiveTypes
+        {
+            BoolValue = true,
+            Int8Value = 12,
+            Int16Value = 1234,
+            Int32Value = -123456,
+            Varint32Value = -12345,
+            Int64Value = -123456789,
+            Varint64Value = -987654321,
+            TaggedInt64Value = 123456789,
+            Uint8Value = 200,
+            Uint16Value = 60000,
+            Uint32Value = 1234567890,
+            VarUint32Value = 1234567890,
+            Uint64Value = 9876543210,
+            VarUint64Value = 12345678901,
+            TaggedUint64Value = 2222222222,
+            Float32Value = 2.5f,
+            Float64Value = 3.5,
+            ContactValue = complex_pb.PrimitiveTypes.Contact.Phone(12345),
+        };
+    }
+
+    private static collection.NumericCollections BuildNumericCollections()
+    {
+        return new collection.NumericCollections
+        {
+            Int8Values = [1, -2, 3],
+            Int16Values = [100, -200, 300],
+            Int32Values = [1000, -2000, 3000],
+            Int64Values = [10000, -20000, 30000],
+            Uint8Values = [200, 250],
+            Uint16Values = [50000, 60000],
+            Uint32Values = [2000000000, 2100000000],
+            Uint64Values = [9000000000, 12000000000],
+            Float32Values = [1.5f, 2.5f],
+            Float64Values = [3.5, 4.5],
+        };
+    }
+
+    private static collection.NumericCollectionUnion 
BuildNumericCollectionUnion()
+    {
+        return collection.NumericCollectionUnion.Int32Values([7, 8, 9]);
+    }
+
+    private static collection.NumericCollectionsArray 
BuildNumericCollectionsArray()
+    {
+        return new collection.NumericCollectionsArray
+        {
+            Int8Values = [1, -2, 3],
+            Int16Values = [100, -200, 300],
+            Int32Values = [1000, -2000, 3000],
+            Int64Values = [10000, -20000, 30000],
+            Uint8Values = [200, 250],
+            Uint16Values = [50000, 60000],
+            Uint32Values = [2000000000, 2100000000],
+            Uint64Values = [9000000000, 12000000000],
+            Float32Values = [1.5f, 2.5f],
+            Float64Values = [3.5, 4.5],
+        };
+    }
+
+    private static collection.NumericCollectionArrayUnion 
BuildNumericCollectionArrayUnion()
+    {
+        return collection.NumericCollectionArrayUnion.Uint16Values([1000, 
2000, 3000]);
+    }
+
+    private static optional_types.OptionalHolder BuildOptionalHolder()
+    {
+        DateOnly date = new(2024, 1, 2);
+        DateTimeOffset timestamp = 
DateTimeOffset.FromUnixTimeSeconds(1704164645);
+        optional_types.AllOptionalTypes all = new()
+        {
+            BoolValue = true,
+            Int8Value = 12,
+            Int16Value = 1234,
+            Int32Value = -123456,
+            FixedInt32Value = -123456,
+            Varint32Value = -12345,
+            Int64Value = -123456789,
+            FixedInt64Value = -123456789,
+            Varint64Value = -987654321,
+            TaggedInt64Value = 123456789,
+            Uint8Value = 200,
+            Uint16Value = 60000,
+            Uint32Value = 1234567890,
+            FixedUint32Value = 1234567890,
+            VarUint32Value = 1234567890,
+            Uint64Value = 9876543210,
+            FixedUint64Value = 9876543210,
+            VarUint64Value = 12345678901,
+            TaggedUint64Value = 2222222222,
+            Float32Value = 2.5f,
+            Float64Value = 3.5,
+            StringValue = "optional",
+            BytesValue = [1, 2, 3],
+            DateValue = date,
+            TimestampValue = timestamp,
+            Int32List = [1, 2, 3],
+            StringList = ["alpha", "beta"],
+            Int64Map = new Dictionary<string, long>
+            {
+                ["alpha"] = 10,
+                ["beta"] = 20,
+            },
+        };
+
+        return new optional_types.OptionalHolder
+        {
+            AllTypes = all,
+            Choice = optional_types.OptionalUnion.Note("optional"),
+        };
+    }
+
+    private static any_example.AnyHolder BuildAnyHolder()
+    {
+        return new any_example.AnyHolder
+        {
+            BoolValue = true,
+            StringValue = "hello",
+            DateValue = new DateOnly(2024, 1, 2),
+            TimestampValue = DateTimeOffset.FromUnixTimeSeconds(1704164645),
+            MessageValue = new any_example.AnyInner
+            {
+                Name = "inner",
+            },
+            UnionValue = any_example.AnyUnion.Text("union"),
+            ListValue = new List<string>
+            {
+                "alpha",
+                "beta",
+            },
+            MapValue = new Dictionary<string, string>
+            {
+                ["k1"] = "v1",
+                ["k2"] = "v2",
+            },
+        };
+    }
+
+    private static any_example_pb.AnyHolder BuildAnyProtoHolder()
+    {
+        any_example_pb.AnyUnion union = new()
+        {
+            Kind = any_example_pb.AnyUnion.kind.Text("proto-union"),
+        };
+
+        return new any_example_pb.AnyHolder
+        {
+            BoolValue = true,
+            StringValue = "hello",
+            DateValue = new DateOnly(2024, 1, 2),
+            TimestampValue = DateTimeOffset.FromUnixTimeSeconds(1704164645),
+            MessageValue = new any_example_pb.AnyInner
+            {
+                Name = "inner",
+            },
+            UnionValue = union,
+            ListValue = new List<string>
+            {
+                "alpha",
+                "beta",
+            },
+            MapValue = new Dictionary<string, string>
+            {
+                ["k1"] = "v1",
+                ["k2"] = "v2",
+            },
+        };
+    }
+
+    private static monster.Monster BuildMonster()
+    {
+        return new monster.Monster
+        {
+            Pos = new monster.Vec3
+            {
+                X = 1.0f,
+                Y = 2.0f,
+                Z = 3.0f,
+            },
+            Mana = 200,
+            Hp = 80,
+            Name = "Orc",
+            Friendly = true,
+            Inventory = [1, 2, 3],
+            Color = monster.Color.Blue,
+        };
+    }
+
+    private static complex_fbs.Container BuildContainer()
+    {
+        return new complex_fbs.Container
+        {
+            Id = 9876543210,
+            Status = complex_fbs.Status.Started,
+            Bytes = [1, 2, 3],
+            Numbers = [10, 20, 30],
+            Scalars = new complex_fbs.ScalarPack
+            {
+                B = -8,
+                Ub = 200,
+                S = -1234,
+                Us = 40000,
+                I = -123456,
+                Ui = 123456,
+                L = -123456789,
+                Ul = 987654321,
+                F = 1.5f,
+                D = 2.5,
+                Ok = true,
+            },
+            Names = ["alpha", "beta"],
+            Flags = [true, false],
+            Payload = complex_fbs.Payload.Metric(new complex_fbs.Metric
+            {
+                Value = 42.0,
+            }),
+        };
+    }
+
+    private static tree.TreeNode BuildTree()
+    {
+        tree.TreeNode childA = new()
+        {
+            Id = "child-a",
+            Name = "child-a",
+            Children = [],
+        };
+        tree.TreeNode childB = new()
+        {
+            Id = "child-b",
+            Name = "child-b",
+            Children = [],
+        };
+
+        childA.Parent = childB;
+        childB.Parent = childA;
+
+        return new tree.TreeNode
+        {
+            Id = "root",
+            Name = "root",
+            Children = [childA, childA, childB],
+        };
+    }
+
+    private static graph.Graph BuildGraph()
+    {
+        graph.Node node1 = new()
+        {
+            Id = "n1",
+        };
+        graph.Node node2 = new()
+        {
+            Id = "n2",
+        };
+
+        graph.Edge edge12 = new()
+        {
+            Id = "e12",
+            Weight = 1.5f,
+            From = node1,
+            To = node2,
+        };
+        graph.Edge edge21 = new()
+        {
+            Id = "e21",
+            Weight = 2.5f,
+            From = node2,
+            To = node1,
+        };
+
+        node1.OutEdges = [edge12];
+        node1.InEdges = [edge21];
+        node2.OutEdges = [edge21];
+        node2.InEdges = [edge12];
+
+        return new graph.Graph
+        {
+            Nodes = [node1, node2],
+            Edges = [edge12, edge21],
+        };
+    }
+
+    private static root.MultiHolder BuildRootHolder()
+    {
+        addressbook.AddressBook book = BuildAddressBook();
+        addressbook.Person owner = book.People[0];
+
+        tree.TreeNode treeRoot = new()
+        {
+            Id = "root",
+            Name = "root",
+            Children = [],
+        };
+
+        return new root.MultiHolder
+        {
+            Book = book,
+            Root = treeRoot,
+            Owner = owner,
+        };
+    }
+
+    private static void AssertAddressBook(addressbook.AddressBook expected, 
addressbook.AddressBook actual)
+    {
+        Assert.Single(actual.People);
+        Assert.Single(actual.PeopleByName);
+
+        addressbook.Person expectedPerson = expected.People[0];
+        addressbook.Person actualPerson = actual.People[0];
+
+        Assert.Equal(expectedPerson.Name, actualPerson.Name);
+        Assert.Equal(expectedPerson.Id, actualPerson.Id);
+        Assert.Equal(expectedPerson.Email, actualPerson.Email);
+        Assert.Equal(expectedPerson.Tags, actualPerson.Tags);
+        AssertMap(expectedPerson.Scores, actualPerson.Scores);
+        Assert.Equal(expectedPerson.Salary, actualPerson.Salary);
+        Assert.Equal(expectedPerson.Phones.Count, actualPerson.Phones.Count);
+        Assert.Equal(expectedPerson.Phones[0].Number, 
actualPerson.Phones[0].Number);
+        Assert.Equal(expectedPerson.Phones[0].PhoneType, 
actualPerson.Phones[0].PhoneType);
+        Assert.True(actualPerson.Pet.IsCat);
+        Assert.Equal("Mimi", actualPerson.Pet.CatValue().Name);
+        Assert.Equal(9, actualPerson.Pet.CatValue().Lives);
+    }
+
+    private static void AssertEnvelope(auto_id.Envelope expected, 
auto_id.Envelope actual)
+    {
+        Assert.Equal(expected.Id, actual.Id);
+        Assert.NotNull(actual.PayloadValue);
+        Assert.NotNull(expected.PayloadValue);
+        Assert.Equal(expected.PayloadValue.Value, actual.PayloadValue.Value);
+        Assert.Equal(expected.Status, actual.Status);
+
+        Assert.NotNull(actual.DetailValue);
+        Assert.True(actual.DetailValue.IsPayload);
+        Assert.Equal(expected.DetailValue.PayloadValue().Value, 
actual.DetailValue.PayloadValue().Value);
+    }
+
+    private static void AssertPrimitiveTypes(complex_pb.PrimitiveTypes 
expected, complex_pb.PrimitiveTypes actual)
+    {
+        Assert.Equal(expected.BoolValue, actual.BoolValue);
+        Assert.Equal(expected.Int8Value, actual.Int8Value);
+        Assert.Equal(expected.Int16Value, actual.Int16Value);
+        Assert.Equal(expected.Int32Value, actual.Int32Value);
+        Assert.Equal(expected.Varint32Value, actual.Varint32Value);
+        Assert.Equal(expected.Int64Value, actual.Int64Value);
+        Assert.Equal(expected.Varint64Value, actual.Varint64Value);
+        Assert.Equal(expected.TaggedInt64Value, actual.TaggedInt64Value);
+        Assert.Equal(expected.Uint8Value, actual.Uint8Value);
+        Assert.Equal(expected.Uint16Value, actual.Uint16Value);
+        Assert.Equal(expected.Uint32Value, actual.Uint32Value);
+        Assert.Equal(expected.VarUint32Value, actual.VarUint32Value);
+        Assert.Equal(expected.Uint64Value, actual.Uint64Value);
+        Assert.Equal(expected.VarUint64Value, actual.VarUint64Value);
+        Assert.Equal(expected.TaggedUint64Value, actual.TaggedUint64Value);
+        Assert.Equal(expected.Float32Value, actual.Float32Value);
+        Assert.Equal(expected.Float64Value, actual.Float64Value);
+
+        Assert.NotNull(expected.ContactValue);
+        Assert.NotNull(actual.ContactValue);
+        Assert.Equal(expected.ContactValue.CaseId(), 
actual.ContactValue.CaseId());
+        Assert.True(actual.ContactValue.IsPhone);
+        Assert.Equal(expected.ContactValue.PhoneValue(), 
actual.ContactValue.PhoneValue());
+    }
+
+    private static void AssertNumericCollections(
+        collection.NumericCollections expected,
+        collection.NumericCollections actual)
+    {
+        Assert.Equal(expected.Int8Values, actual.Int8Values);
+        Assert.Equal(expected.Int16Values, actual.Int16Values);
+        Assert.Equal(expected.Int32Values, actual.Int32Values);
+        Assert.Equal(expected.Int64Values, actual.Int64Values);
+        Assert.Equal(expected.Uint8Values, actual.Uint8Values);
+        Assert.Equal(expected.Uint16Values, actual.Uint16Values);
+        Assert.Equal(expected.Uint32Values, actual.Uint32Values);
+        Assert.Equal(expected.Uint64Values, actual.Uint64Values);
+        Assert.Equal(expected.Float32Values, actual.Float32Values);
+        Assert.Equal(expected.Float64Values, actual.Float64Values);
+    }
+
+    private static void AssertNumericCollectionUnion(
+        collection.NumericCollectionUnion expected,
+        collection.NumericCollectionUnion actual)
+    {
+        Assert.Equal(expected.CaseId(), actual.CaseId());
+        Assert.True(actual.IsInt32Values);
+        Assert.Equal(expected.Int32ValuesValue(), actual.Int32ValuesValue());
+    }
+
+    private static void AssertNumericCollectionsArray(
+        collection.NumericCollectionsArray expected,
+        collection.NumericCollectionsArray actual)
+    {
+        Assert.Equal(expected.Int8Values, actual.Int8Values);
+        Assert.Equal(expected.Int16Values, actual.Int16Values);
+        Assert.Equal(expected.Int32Values, actual.Int32Values);
+        Assert.Equal(expected.Int64Values, actual.Int64Values);
+        Assert.Equal(expected.Uint8Values, actual.Uint8Values);
+        Assert.Equal(expected.Uint16Values, actual.Uint16Values);
+        Assert.Equal(expected.Uint32Values, actual.Uint32Values);
+        Assert.Equal(expected.Uint64Values, actual.Uint64Values);
+        Assert.Equal(expected.Float32Values, actual.Float32Values);
+        Assert.Equal(expected.Float64Values, actual.Float64Values);
+    }
+
+    private static void AssertNumericCollectionArrayUnion(
+        collection.NumericCollectionArrayUnion expected,
+        collection.NumericCollectionArrayUnion actual)
+    {
+        Assert.Equal(expected.CaseId(), actual.CaseId());
+        Assert.True(actual.IsUint16Values);
+        Assert.Equal(expected.Uint16ValuesValue(), actual.Uint16ValuesValue());
+    }
+
+    private static void AssertOptionalHolder(
+        optional_types.OptionalHolder expected,
+        optional_types.OptionalHolder actual)
+    {
+        Assert.NotNull(actual.AllTypes);
+        Assert.NotNull(expected.AllTypes);
+
+        optional_types.AllOptionalTypes e = expected.AllTypes;
+        optional_types.AllOptionalTypes a = actual.AllTypes;
+
+        Assert.Equal(e.BoolValue, a.BoolValue);
+        Assert.Equal(e.Int8Value, a.Int8Value);
+        Assert.Equal(e.Int16Value, a.Int16Value);
+        Assert.Equal(e.Int32Value, a.Int32Value);
+        Assert.Equal(e.FixedInt32Value, a.FixedInt32Value);
+        Assert.Equal(e.Varint32Value, a.Varint32Value);
+        Assert.Equal(e.Int64Value, a.Int64Value);
+        Assert.Equal(e.FixedInt64Value, a.FixedInt64Value);
+        Assert.Equal(e.Varint64Value, a.Varint64Value);
+        Assert.Equal(e.TaggedInt64Value, a.TaggedInt64Value);
+        Assert.Equal(e.Uint8Value, a.Uint8Value);
+        Assert.Equal(e.Uint16Value, a.Uint16Value);
+        Assert.Equal(e.Uint32Value, a.Uint32Value);
+        Assert.Equal(e.FixedUint32Value, a.FixedUint32Value);
+        Assert.Equal(e.VarUint32Value, a.VarUint32Value);
+        Assert.Equal(e.Uint64Value, a.Uint64Value);
+        Assert.Equal(e.FixedUint64Value, a.FixedUint64Value);
+        Assert.Equal(e.VarUint64Value, a.VarUint64Value);
+        Assert.Equal(e.TaggedUint64Value, a.TaggedUint64Value);
+        Assert.Equal(e.Float32Value, a.Float32Value);
+        Assert.Equal(e.Float64Value, a.Float64Value);
+        Assert.Equal(e.StringValue, a.StringValue);
+        Assert.Equal(e.BytesValue, a.BytesValue);
+        Assert.Equal(e.DateValue, a.DateValue);
+        Assert.Equal(e.TimestampValue, a.TimestampValue);
+        Assert.Equal(e.Int32List, a.Int32List);
+        Assert.Equal(e.StringList, a.StringList);
+        AssertNullableMap(e.Int64Map, a.Int64Map);
+
+        Assert.NotNull(actual.Choice);
+        Assert.NotNull(expected.Choice);
+        Assert.Equal(expected.Choice.CaseId(), actual.Choice.CaseId());
+        Assert.True(actual.Choice.IsNote);
+        Assert.Equal(expected.Choice.NoteValue(), actual.Choice.NoteValue());
+    }
+
+    private static void AssertAnyHolder(any_example.AnyHolder expected, 
any_example.AnyHolder actual)
+    {
+        Assert.True(TryAsBool(expected.BoolValue, out bool expectedBool));
+        Assert.True(TryAsBool(actual.BoolValue, out bool actualBool));
+        Assert.Equal(expectedBool, actualBool);
+
+        Assert.True(TryAsString(expected.StringValue, out string? 
expectedString));
+        Assert.True(TryAsString(actual.StringValue, out string? actualString));
+        Assert.Equal(expectedString, actualString);
+
+        Assert.True(TryAsDateOnly(expected.DateValue, out DateOnly 
expectedDate));
+        Assert.True(TryAsDateOnly(actual.DateValue, out DateOnly actualDate));
+        Assert.Equal(expectedDate, actualDate);
+
+        Assert.True(TryAsTimestamp(expected.TimestampValue, out DateTimeOffset 
expectedTimestamp));
+        Assert.True(TryAsTimestamp(actual.TimestampValue, out DateTimeOffset 
actualTimestamp));
+        Assert.Equal(expectedTimestamp, actualTimestamp);
+
+        Assert.True(TryAnyInnerName(expected.MessageValue, out string? 
expectedInnerName));
+        Assert.True(TryAnyInnerName(actual.MessageValue, out string? 
actualInnerName));
+        Assert.Equal(expectedInnerName, actualInnerName);
+
+        any_example.AnyUnion actualUnion = 
Assert.IsType<any_example.AnyUnion>(actual.UnionValue);
+        any_example.AnyUnion expectedUnion = 
Assert.IsType<any_example.AnyUnion>(expected.UnionValue);
+        Assert.Equal(expectedUnion.CaseId(), actualUnion.CaseId());
+        Assert.True(actualUnion.IsText);
+        Assert.Equal(expectedUnion.TextValue(), actualUnion.TextValue());
+
+        Assert.True(TryStringList(expected.ListValue, out List<string> 
expectedList));
+        Assert.True(TryStringList(actual.ListValue, out List<string> 
actualList));
+        Assert.Equal(expectedList, actualList);
+
+        Assert.True(TryStringMap(expected.MapValue, out Dictionary<string, 
string> expectedMap));
+        Assert.True(TryStringMap(actual.MapValue, out Dictionary<string, 
string> actualMap));
+        AssertMap(expectedMap, actualMap);
+    }
+
+    private static void AssertAnyProtoHolder(any_example_pb.AnyHolder 
expected, any_example_pb.AnyHolder actual)
+    {
+        Assert.True(TryAsBool(expected.BoolValue, out bool expectedBool));
+        Assert.True(TryAsBool(actual.BoolValue, out bool actualBool));
+        Assert.Equal(expectedBool, actualBool);
+
+        Assert.True(TryAsString(expected.StringValue, out string? 
expectedString));
+        Assert.True(TryAsString(actual.StringValue, out string? actualString));
+        Assert.Equal(expectedString, actualString);
+
+        Assert.True(TryAsDateOnly(expected.DateValue, out DateOnly 
expectedDate));
+        Assert.True(TryAsDateOnly(actual.DateValue, out DateOnly actualDate));
+        Assert.Equal(expectedDate, actualDate);
+
+        Assert.True(TryAsTimestamp(expected.TimestampValue, out DateTimeOffset 
expectedTimestamp));
+        Assert.True(TryAsTimestamp(actual.TimestampValue, out DateTimeOffset 
actualTimestamp));
+        Assert.Equal(expectedTimestamp, actualTimestamp);
+
+        Assert.True(TryAnyInnerName(expected.MessageValue, out string? 
expectedInnerName));
+        Assert.True(TryAnyInnerName(actual.MessageValue, out string? 
actualInnerName));
+        Assert.Equal(expectedInnerName, actualInnerName);
+
+        any_example_pb.AnyUnion expectedUnion = 
Assert.IsType<any_example_pb.AnyUnion>(expected.UnionValue);
+        any_example_pb.AnyUnion actualUnion = 
Assert.IsType<any_example_pb.AnyUnion>(actual.UnionValue);
+        Assert.NotNull(expectedUnion.Kind);
+        Assert.NotNull(actualUnion.Kind);
+        Assert.Equal(expectedUnion.Kind.CaseId(), actualUnion.Kind.CaseId());
+        Assert.True(actualUnion.Kind.IsText);
+        Assert.Equal(expectedUnion.Kind.TextValue(), 
actualUnion.Kind.TextValue());
+
+        Assert.True(TryStringList(expected.ListValue, out List<string> 
expectedList));
+        Assert.True(TryStringList(actual.ListValue, out List<string> 
actualList));
+        Assert.Equal(expectedList, actualList);
+
+        Assert.True(TryStringMap(expected.MapValue, out Dictionary<string, 
string> expectedMap));
+        Assert.True(TryStringMap(actual.MapValue, out Dictionary<string, 
string> actualMap));
+        AssertMap(expectedMap, actualMap);
+    }
+
+    private static void AssertMonster(monster.Monster expected, 
monster.Monster actual)
+    {
+        Assert.NotNull(actual.Pos);
+        Assert.NotNull(expected.Pos);
+        Assert.Equal(expected.Pos.X, actual.Pos.X);
+        Assert.Equal(expected.Pos.Y, actual.Pos.Y);
+        Assert.Equal(expected.Pos.Z, actual.Pos.Z);
+        Assert.Equal(expected.Mana, actual.Mana);
+        Assert.Equal(expected.Hp, actual.Hp);
+        Assert.Equal(expected.Name, actual.Name);
+        Assert.Equal(expected.Friendly, actual.Friendly);
+        Assert.Equal(expected.Inventory, actual.Inventory);
+        Assert.Equal(expected.Color, actual.Color);
+    }
+
+    private static void AssertContainer(complex_fbs.Container expected, 
complex_fbs.Container actual)
+    {
+        Assert.Equal(expected.Id, actual.Id);
+        Assert.Equal(expected.Status, actual.Status);
+        Assert.Equal(expected.Bytes, actual.Bytes);
+        Assert.Equal(expected.Numbers, actual.Numbers);
+        Assert.NotNull(expected.Scalars);
+        Assert.NotNull(actual.Scalars);
+        Assert.Equal(expected.Scalars.B, actual.Scalars.B);
+        Assert.Equal(expected.Scalars.Ub, actual.Scalars.Ub);
+        Assert.Equal(expected.Scalars.S, actual.Scalars.S);
+        Assert.Equal(expected.Scalars.Us, actual.Scalars.Us);
+        Assert.Equal(expected.Scalars.I, actual.Scalars.I);
+        Assert.Equal(expected.Scalars.Ui, actual.Scalars.Ui);
+        Assert.Equal(expected.Scalars.L, actual.Scalars.L);
+        Assert.Equal(expected.Scalars.Ul, actual.Scalars.Ul);
+        Assert.Equal(expected.Scalars.F, actual.Scalars.F);
+        Assert.Equal(expected.Scalars.D, actual.Scalars.D);
+        Assert.Equal(expected.Scalars.Ok, actual.Scalars.Ok);
+        Assert.Equal(expected.Names, actual.Names);
+        Assert.Equal(expected.Flags, actual.Flags);
+
+        Assert.NotNull(expected.Payload);
+        Assert.NotNull(actual.Payload);
+        Assert.Equal(expected.Payload.CaseId(), actual.Payload.CaseId());
+        Assert.True(actual.Payload.IsMetric);
+        Assert.Equal(expected.Payload.MetricValue().Value, 
actual.Payload.MetricValue().Value);
+    }
+
+    private static void AssertTree(tree.TreeNode root)
+    {
+        Assert.Equal("root", root.Id);
+        Assert.Equal("root", root.Name);
+        Assert.Equal(3, root.Children.Count);
+
+        tree.TreeNode childAFirst = root.Children[0];
+        tree.TreeNode childASecond = root.Children[1];
+        tree.TreeNode childB = root.Children[2];
+
+        Assert.Equal("child-a", childAFirst.Id);
+        Assert.Equal("child-b", childB.Id);
+
+        Assert.Same(childAFirst, childASecond);
+        Assert.NotNull(childAFirst.Parent);
+        Assert.NotNull(childB.Parent);
+        Assert.Same(childB, childAFirst.Parent);
+        Assert.Same(childAFirst, childB.Parent);
+    }
+
+    private static void AssertGraph(graph.Graph graphValue)
+    {
+        Assert.Equal(2, graphValue.Nodes.Count);
+        Assert.Equal(2, graphValue.Edges.Count);
+
+        Dictionary<string, graph.Node> nodes = graphValue.Nodes.ToDictionary(n 
=> n.Id, n => n);
+        Dictionary<string, graph.Edge> edges = graphValue.Edges.ToDictionary(e 
=> e.Id, e => e);
+
+        Assert.True(nodes.ContainsKey("n1"));
+        Assert.True(nodes.ContainsKey("n2"));
+        Assert.True(edges.ContainsKey("e12"));
+        Assert.True(edges.ContainsKey("e21"));
+
+        graph.Edge edge12 = edges["e12"];
+        graph.Edge edge21 = edges["e21"];
+
+        Assert.NotNull(edge12.From);
+        Assert.NotNull(edge12.To);
+        Assert.NotNull(edge21.From);
+        Assert.NotNull(edge21.To);
+
+        Assert.Same(nodes["n1"], edge12.From);
+        Assert.Same(nodes["n2"], edge12.To);
+        Assert.Same(nodes["n2"], edge21.From);
+        Assert.Same(nodes["n1"], edge21.To);
+
+        Assert.Single(nodes["n1"].OutEdges);
+        Assert.Single(nodes["n1"].InEdges);
+        Assert.Single(nodes["n2"].OutEdges);
+        Assert.Single(nodes["n2"].InEdges);
+
+        Assert.Same(edge12, nodes["n1"].OutEdges[0]);
+        Assert.Same(edge21, nodes["n1"].InEdges[0]);
+        Assert.Same(edge21, nodes["n2"].OutEdges[0]);
+        Assert.Same(edge12, nodes["n2"].InEdges[0]);
+    }
+
+    private static void AssertRootHolder(root.MultiHolder expected, 
root.MultiHolder actual)
+    {
+        Assert.NotNull(actual.Book);
+        Assert.NotNull(actual.Root);
+        Assert.NotNull(actual.Owner);
+        Assert.NotNull(expected.Book);
+        Assert.NotNull(expected.Root);
+        Assert.NotNull(expected.Owner);
+
+        AssertAddressBook(expected.Book, actual.Book);
+
+        Assert.Equal(expected.Root.Id, actual.Root.Id);
+        Assert.Equal(expected.Root.Name, actual.Root.Name);
+        Assert.Equal(expected.Root.Children.Count, actual.Root.Children.Count);
+
+        Assert.Equal(expected.Owner.Name, actual.Owner.Name);
+        Assert.Equal(expected.Owner.Id, actual.Owner.Id);
+        Assert.Equal(expected.Owner.Email, actual.Owner.Email);
+        Assert.Equal(expected.Owner.Tags, actual.Owner.Tags);
+        AssertMap(expected.Owner.Scores, actual.Owner.Scores);
+    }
+
+    private static bool TryAsBool(object? value, out bool result)
+    {
+        if (value is bool b)
+        {
+            result = b;
+            return true;
+        }
+
+        result = false;
+        return false;
+    }
+
+    private static bool TryAsString(object? value, out string? result)
+    {
+        if (value is string s)
+        {
+            result = s;
+            return true;
+        }
+
+        result = null;
+        return false;
+    }
+
+    private static bool TryAsDateOnly(object? value, out DateOnly result)
+    {
+        if (value is DateOnly date)
+        {
+            result = date;
+            return true;
+        }
+
+        result = default;
+        return false;
+    }
+
+    private static bool TryAsTimestamp(object? value, out DateTimeOffset 
result)
+    {
+        switch (value)
+        {
+            case DateTimeOffset dto:
+                result = dto;
+                return true;
+            case DateTime dateTime:
+                result = new DateTimeOffset(DateTime.SpecifyKind(dateTime, 
DateTimeKind.Utc));
+                return true;
+            default:
+                result = default;
+                return false;
+        }
+    }
+
+    private static bool TryAnyInnerName(object? value, out string? name)
+    {
+        switch (value)
+        {
+            case any_example.AnyInner inner:
+                name = inner.Name;
+                return true;
+            case any_example_pb.AnyInner innerPb:
+                name = innerPb.Name;
+                return true;
+            default:
+                name = null;
+                return false;
+        }
+    }
+
+    private static bool TryStringList(object? value, out List<string> result)
+    {
+        switch (value)
+        {
+            case List<string> strList:
+                result = [.. strList];
+                return true;
+            case IEnumerable<string> strEnumerable:
+                result = [.. strEnumerable];
+                return true;
+            case IEnumerable<object?> objEnumerable:
+            {
+                List<string> normalized = [];
+                foreach (object? item in objEnumerable)
+                {
+                    if (item is not string text)
+                    {
+                        result = [];
+                        return false;
+                    }
+
+                    normalized.Add(text);
+                }
+
+                result = normalized;
+                return true;
+            }
+            default:
+                result = [];
+                return false;
+        }
+    }
+
+    private static bool TryStringMap(object? value, out Dictionary<string, 
string> result)
+    {
+        switch (value)
+        {
+            case Dictionary<string, string> map:
+                result = new Dictionary<string, string>(map);
+                return true;
+            case IReadOnlyDictionary<string, string> readonlyMap:
+                result = readonlyMap.ToDictionary(kv => kv.Key, kv => 
kv.Value);
+                return true;
+            case IEnumerable<KeyValuePair<object, object?>> objectPairs:
+            {
+                Dictionary<string, string> normalized = [];
+                foreach (KeyValuePair<object, object?> pair in objectPairs)
+                {
+                    if (pair.Key is not string key || pair.Value is not string 
val)
+                    {
+                        result = [];
+                        return false;
+                    }
+
+                    normalized[key] = val;
+                }
+
+                result = normalized;
+                return true;
+            }
+            default:
+                result = [];
+                return false;
+        }
+    }
+
+    private static void AssertMap<TKey, TValue>(
+        IReadOnlyDictionary<TKey, TValue> expected,
+        IReadOnlyDictionary<TKey, TValue> actual)
+        where TKey : notnull
+    {
+        Assert.Equal(expected.Count, actual.Count);
+        foreach (KeyValuePair<TKey, TValue> pair in expected)
+        {
+            Assert.True(actual.TryGetValue(pair.Key, out TValue? value));
+            Assert.Equal(pair.Value, value);
+        }
+    }
+
+    private static void AssertNullableMap<TKey, TValue>(
+        IReadOnlyDictionary<TKey, TValue>? expected,
+        IReadOnlyDictionary<TKey, TValue>? actual)
+        where TKey : notnull
+    {
+        if (expected is null || actual is null)
+        {
+            Assert.Equal(expected is null, actual is null);
+            return;
+        }
+
+        AssertMap(expected, actual);
+    }
+}
diff --git a/integration_tests/idl_tests/generate_idl.py 
b/integration_tests/idl_tests/generate_idl.py
index 046de53b3..f4c764791 100755
--- a/integration_tests/idl_tests/generate_idl.py
+++ b/integration_tests/idl_tests/generate_idl.py
@@ -46,6 +46,7 @@ LANG_OUTPUTS = {
     "cpp": REPO_ROOT / "integration_tests/idl_tests/cpp/generated",
     "go": REPO_ROOT / "integration_tests/idl_tests/go/generated",
     "rust": REPO_ROOT / "integration_tests/idl_tests/rust/src/generated",
+    "csharp": REPO_ROOT / 
"integration_tests/idl_tests/csharp/IdlTests/Generated",
 }
 
 GO_OUTPUT_OVERRIDES = {
diff --git a/integration_tests/idl_tests/run_csharp_tests.sh 
b/integration_tests/idl_tests/run_csharp_tests.sh
new file mode 100755
index 000000000..4c32fe1d8
--- /dev/null
+++ b/integration_tests/idl_tests/run_csharp_tests.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+
+# 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
+#
+#   http://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.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+python "${SCRIPT_DIR}/generate_idl.py" --lang csharp
+
+TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/fory-csharp-idl-XXXXXX")"
+cleanup() {
+  rm -rf "${TMP_DIR}"
+}
+trap cleanup EXIT
+
+export DATA_FILE="${TMP_DIR}/addressbook.bin"
+export DATA_FILE_AUTO_ID="${TMP_DIR}/auto_id.bin"
+export DATA_FILE_PRIMITIVES="${TMP_DIR}/primitives.bin"
+export DATA_FILE_COLLECTION="${TMP_DIR}/collection.bin"
+export DATA_FILE_COLLECTION_UNION="${TMP_DIR}/collection_union.bin"
+export DATA_FILE_COLLECTION_ARRAY="${TMP_DIR}/collection_array.bin"
+export DATA_FILE_COLLECTION_ARRAY_UNION="${TMP_DIR}/collection_array_union.bin"
+export DATA_FILE_OPTIONAL_TYPES="${TMP_DIR}/optional_types.bin"
+export DATA_FILE_ANY="${TMP_DIR}/any.bin"
+export DATA_FILE_ANY_PROTO="${TMP_DIR}/any_proto.bin"
+export DATA_FILE_TREE="${TMP_DIR}/tree.bin"
+export DATA_FILE_GRAPH="${TMP_DIR}/graph.bin"
+export DATA_FILE_FLATBUFFERS_MONSTER="${TMP_DIR}/flatbuffers_monster.bin"
+export DATA_FILE_FLATBUFFERS_TEST2="${TMP_DIR}/flatbuffers_test2.bin"
+export DATA_FILE_ROOT="${TMP_DIR}/root.bin"
+
+cd "${SCRIPT_DIR}/csharp/IdlTests"
+ENABLE_FORY_DEBUG_OUTPUT=1 dotnet test -c Release


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to