Design converter internals

CNA snapshot 009d40f5  ·  Development › Module internals  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the TARGET sources, tests and CMake files and, for the component-model substrate, a sibling Sharp Runtime checkout (next at 41b918c9, 2026-09-20; not pinned by TARGET). No build or test was executed and no automated oracle exists for this module. Behaviour in a consuming editor or property grid, cross-thread use, lookup from another translation unit's static initialiser and Windows hosts remain unverified.

modules/design/ is not the Game component scheduler. It is CNA's opt-in implementation of the XNA Microsoft::Xna::Framework::Design namespace: twelve type converters that let Sharp Runtime's System::ComponentModel describe, format, parse and rebuild the math value types. A maintainer who changes a math type's fields, property names or constructors has to treat this module as a second public surface of math. The user-level guide, with the converter table and a worked example, is Framework.Design; this page explains the machinery behind it. Everything below was checked by reading the TARGET snapshot and, for the component-model substrate, a sibling Sharp Runtime checkout; nothing was built or run for it.

Boundary and build graph

design/CMakeLists.txt builds cna_design (alias CNA::Design) through cna_add_module from a CONFIGURE_DEPENDS glob of src/*.cpp: Converters.cpp, MathTypeConverter.cpp and Registration.cpp. It links Sharp Runtime's ComponentModel component and cna_math, both PUBLIC, and nothing else. It is deliberately outside the CNA umbrella: the comment above the umbrella in modules/CMakeLists.txt says tooling-only CNA::Design stays opt-in so converter registration and ComponentModel code do not enter games that never use them, and there is no option to disable the subdirectory, so a consumer opts in by linking the target. The aggregate test executable CnaTests names cna_design explicitly in UnitTests.cmake. The module defines no window, device or service owner and does no per-frame work.

The closure is a checked contract: ModuleProbes.cmake registers probe_design with a link-closure check whose forbidden pattern rejects any CNA archive other than design and math and any SDL3, enet, FFmpeg or renderer library. The public headers are under modules/design/include/Microsoft/Xna/Framework/Design/ (one per converter plus MathTypeConverter.hpp) and the umbrella Design.hpp; two internal headers under CNA/Internal/Design/ hold the boxed-property adapters (PropertyDescriptors.hpp) and the registration trigger (Registration.hpp). The dependency direction matters: design observes and reconstructs math values, never the reverse. A change to a math field, property name or constructor can require a converter change; a design-time tweak must not change numeric math or a renderer. The converters, at TARGET:

Converter (value type)Descriptors, in order (boxed type)String inputConstructor arguments of the InstanceDescriptor
PointConverter (Point)X, Y (fields, intcs)yesx, y
RectangleConverter (Rectangle)X, Y, Width, Height (fields, intcs)nox, y, width, height
Vector2Converter (Vector2)X, Y (fields, float)yesx, y
Vector3Converter (Vector3)X, Y, Zyesx, y, z
Vector4Converter (Vector4), QuaternionConverter (Quaternion)X, Y, Z, Wyesx, y, z, w
MatrixConverter (Matrix)Translation (accessor, Vector3), then M11 to M44 (fields, float), seventeen in allnothe sixteen elements m11 to m44
BoundingBoxConverter (BoundingBox)Min, Max (Vector3)nomin, max
BoundingSphereConverter (BoundingSphere)Center (Vector3), Radius (float)nocenter, radius
PlaneConverter (Plane)Normal (Vector3), D (float)nonormal, d
RayConverter (Ray)Position, Direction (Vector3)noposition, direction
ColorConverter (Color)R, G, B, A (getter and setter pairs, bytecs)yesr, g, b, alpha (bytes)

Most collections are built and then reordered with PropertyDescriptorCollection::Sort using an explicit name list; the Rectangle and Matrix collections are simply declared in the order shown. The tests assert that order, and it is part of the compatibility contract: a property grid shows properties in it. The probe, the build and the tests together are described under what the tests prove.

Registration is process-global, not a Game lifecycle step

consumer includes a Design/*Converter.hpp (or Design.hpp)
  -> it includes CNA/Internal/Design/Registration.hpp
  -> inline const bool FrameworkDesignConvertersRegistered = [] { Ensure...(); return true; }();
  -> EnsureFrameworkDesignConvertersRegistered()
  -> std::call_once(once, register twelve mappings)
  -> TypeDescriptor::RegisterType<TValue>({TypeConverterAttribute::Of<TConverter>()})   (per type)
consumer, later
  -> TypeDescriptor::GetConverter(Type::From<TValue>())   creates the converter on first use, then caches it

Every concrete converter header includes the internal registration header, whose inline variable runs the initialiser during static initialisation of any program that links a translation unit including a converter header (Registration.cpp includes Design.hpp itself). EnsureFrameworkDesignConvertersRegistered is idempotent through a function-local std::once_flag, and registers all twelve mappings together with the private template registerConverter<TValue, TConverter>. Three properties of this design follow from reading it:

  • Both an include and a link are needed. The initialiser is what references the registration function, and that reference is what makes the linker keep Registration.cpp (and, through it, Converters.cpp) out of the static archive. A program that links CNA::Design but includes no Design header in any linked translation unit registers nothing, and a lookup then quietly returns the default converter.
  • Only the converter is registered, not the property descriptors. Each registration passes one TypeConverterAttribute and no property collection, so Sharp Runtime's TypeDescriptor::GetProperties(Type) has nothing registered for these types; the descriptors are reachable through the converter's own GetProperties. A consumer that expects the type-level query to list them will see an empty collection. No CNA test makes that query.
  • Nothing orders this initialiser against other static initialisers, and nothing unregisters at shutdown. A lookup issued from another translation unit's static initialiser is not guaranteed to see the registration. The language leaves the relative order of dynamic initialisation across translation units unspecified for this kind of variable, and no test covers the case.

The registry itself belongs to Sharp Runtime, and CNA's source does not define its lifetime. In the sibling checkout read alongside this snapshot (next at 41b918c9 of 2026-09-20, not pinned by TARGET) the registry is a function-local static behind a mutex, so registering from any translation unit's static initialiser is safe regardless of link order; converters are created lazily by the attribute's factory on the first GetConverter and then cached, so all callers share one converter instance per type; registering a type again replaces its cached converter; and asking for a type nothing registered returns a plain default TypeConverter rather than throwing. That last point is why the module's tests find out whether registration worked with dynamic_cast on the result. Do not promise more than that: the exact storage and locking of a newer Sharp Runtime is outside CNA's source.

How a boxed value is read, edited and recreated

MathTypeConverter.hpp derives from ExpandableObjectConverter. It declares the two documented XNA protected fields, propertyDescriptions (the ordered descriptor collection) and supportStringConvert (default true), spelled as XNA spells them because a subclass outside CNA fills them in its constructor. There is one copy of that state, not an alias pair. Its virtual surface is small: GetPropertiesSupported and GetCreateInstanceSupported always answer true; GetProperties returns a copy of propertyDescriptions whatever value or attributes it is given; CanConvertFrom is true for a string source (std::string, const char*, char*, std::string_view) only while supportStringConvert is set, and otherwise defers to the base, which accepts an InstanceDescriptor; CanConvertTo is true for the string type while the flag is set and otherwise defers to the base, which is also true for a string, and each concrete converter adds InstanceDescriptor to its destinations.

The descriptors are two small templates in PropertyDescriptors.hpp, created with MakeFieldProperty and MakeAccessorProperty. FieldPropertyDescriptor<TComponent, TValue> wraps a pointer-to-member and AccessorPropertyDescriptor wraps a getter and an optional setter (read-only when the setter is null). Both work on a boxed value in a std::any: GetValue reads through std::any_cast<const TComponent&>, and SetValue casts the box to TComponent&, writes the member in place and then calls OnValueChanged. Four consequences are easy to miss:

  • Types are exact. A component boxed as anything but TComponent, or a new value boxed as anything but TValue (float for the vector fields, intcs for Point and Rectangle, bytecs for Color), throws std::bad_any_cast. Passing a double or an int for a float field fails; it is not converted.
  • An edit changes a boxed copy. SetValue mutates the value inside that std::any, so other copies of the math value are untouched. The property-grid workflow for a value type recreates the whole value from the edited components through CreateInstance.
  • No change notification is delivered. The descriptors are final and do not override OnValueChanged; in the sibling Sharp Runtime checkout the base implementation of that protected hook does nothing and the class declares no handler list, because a std::any has no identity to key handlers on. The call is therefore a no-op at that revision, whatever a consumer registers.
  • Descriptors are otherwise inert. Every descriptor answers that it has no reset value (CanResetValue is false and ResetValue does nothing) and that it should be serialised. Field descriptors are never read-only; an accessor descriptor is read-only only when built without a setter, which no in-tree descriptor is.

CreateInstance(context, hashtable) reads each named entry with Hashtable::at and std::any_cast, then returns a newly boxed value; an absent name throws at the lookup and a wrongly boxed entry throws bad_any_cast. MatrixConverter exposes Translation as an accessor pair over M41 to M43, but CreateInstance and its constructor descriptor use only the sixteen elements in row-major field order; Translation is an alternate, editable view, not a seventeenth constructor parameter. Vector3Converter exposes X, Y and Z float descriptors, formats and parses through the selected CultureInfo, and emits an InstanceDescriptor carrying the three constructor arguments.

The shared string parser is ConvertToValues<T> in the base header. It takes the text of a string-typed value (any other value type returns nothing and falls through to the base ConvertFrom, which invokes an InstanceDescriptor or throws NotSupportedException), uses the current culture when the culture is null, trims whitespace at both ends of the whole string only, splits on the culture's list separator, and converts each part with the converter TypeDescriptor::GetConverter returns for the component type (the built-in float, 32-bit integer and byte converters). Any failure while converting a part is caught and rethrown as System::ArgumentException carrying the original exception and a message naming the value and its components; a correct parse with the wrong number of parts throws a separate ArgumentException stating the expected count. ConvertFromValues formats with the culture's list separator followed by one space. A Czech locale uses a decimal comma and a semicolon separator, so the same Vector3 formats as 1,5; 2,25; -3,75 and comma-separated input is rejected there; the tests cover the invariant, en-US and cs-CZ cultures.

tryInstanceDescriptor in Converters.cpp builds the InstanceDescriptor: it returns nothing unless the destination is InstanceDescriptor and the value really holds the expected type, and otherwise binds a ConstructorInfo::Of<T, Args...> (created once per instantiation as a function-local static) with the parameter names in the table above and the boxed argument values. The base TypeConverter can then rebuild a value from the descriptor. ConvertTo with an empty System::Type throws ArgumentNullException, and an unsupported destination (for example int) reaches the base and throws NotSupportedException. The module owns no native resource: descriptors and converters are shared_ptr-owned, and the only dynamic type contract is the exact std::any types and property names above (see the ownership and lifetime master map for how that sits among CNA's other owners).

Compatibility asymmetries to preserve

supportStringConvert describes string input. String output is always available, because the base converter's CanConvertTo accepts a string regardless of the flag, and the tests assert that all twelve converters can convert to a string. Six converters clear the flag in their constructors: Rectangle, Matrix, BoundingBox, BoundingSphere, Plane and Ray. Their ConvertTo still returns the value's own ToString(), and there is no string parser for them. BoundingBox, BoundingSphere and Ray additionally declare a ConvertFrom override that forwards to the base, as the Microsoft implementation does, so they accept an InstanceDescriptor and refuse a string; Rectangle, Matrix and Plane declare no override and behave the same through inheritance. Do not add symmetric parsing just because the output is printable.

ℹ

A stale comment. The comment on supportStringConvert in MathTypeConverter.hpp says that only BoundingBox, BoundingSphere and Ray clear the flag and that the matrix and rectangle converters leave it set. The constructors in Converters.cpp say otherwise and the tests follow the constructors: Rectangle, Matrix and Plane clear it too.

ColorConverter uses byte components R, G, B, A rather than the packed integer, and parses each component with the byte converter, so "0, 1, 2, 256" and "0, 1, -1, 2" throw ArgumentException (ColorConverterTests.RejectsComponentsOutsideTheByteRange); PointConverter parses integers. These choices are compatibility behaviour checked by the module tests and are not to be harmonised casually.

The module adds no lock around a converter's protected descriptor collection or around a boxed value being edited. Registration uses std::call_once, which says nothing about later component-model operations. In the sibling Sharp Runtime checkout registry lookups take a mutex and converters are shared and cached, and GetProperties hands out copies of the collection, but no CNA test uses a converter from two threads, so concurrent conversion is not established here. If a binding or editor calls into the converters from a foreign thread, check the Sharp Runtime revision it links and see the thread and callback map.

A human change: add or alter a converter

  1. Decide what changes. Is the request about the math value itself or only its design-time representation? If only the latter, keep math's numeric and ABI contract untouched.
  2. Read the closest converter end to end: header, constructor, ConvertFrom, ConvertTo and CreateInstance. Choose a field descriptor or an accessor descriptor deliberately (Color and Matrix::Translation use accessors because the C++ type has no such public field). Keep property names, order and boxed types stable unless a compatibility change is intended.
  3. A new converter needs its public header (including Registration.hpp), an entry in Design.hpp, its implementation in Converters.cpp or a new source under src/ (the glob is CONFIGURE_DEPENDS, so a reconfigure picks it up), and a registerConverter line in Registration.cpp. Verify the registration from a separate consumer with TypeDescriptor::GetConverter and a dynamic_cast; constructing the converter directly can pass while the type-level lookup still returns the default converter.
  4. Add tests for the boxed property shape, descriptor read and write, CreateInstance, the InstanceDescriptor round trip, culture-specific parsing, unsupported destinations, and missing or wrongly boxed inputs. The shared helper expectCommonBehavior in FrameworkDesignTests.cpp already checks registration, string capability flags, property shape, recreation, descriptor arguments and the two exception types for a new type.
  5. Run the focused target and then the aggregate. The CMake test-group mechanism produces the focused executable CnaDesignTests from modules/design/tests/ (EXCLUDE_FROM_ALL, not a separate CTest registration); no build preset names it, so it is built by target name after configuring with tests on, and the same sources are part of CnaTests. Run the module link probe when a public header or a dependency changed. Command shape, shown for reference and not executed here: cmake --preset unit, cmake --build cmake-build-unit --target CnaDesignTests, then run cmake-build-unit/CnaDesignTests.

Self-review: is the new converter discoverable through TypeDescriptor, not only by direct construction? Does a property-grid edit reconstruct the whole value? Does the culture's list separator still keep decimal commas distinct? Is string input advertised accurately by supportStringConvert? Did a math field, property name or C ABI representation change unintentionally? Does registration depend on static-initialisation order that a consuming binary exercises? See also I need to add a regression test and Test architecture.

What the tests prove, and what they do not

The module has two test files with 25 static definitions. FrameworkDesignTests.cpp (17) has one case per converter (ImplementsXnaConversionDescriptorAndCreationBehavior) built on expectCommonBehavior, expectInvariantStringRoundTrip and expectStringInputDisabled: registration through TypeDescriptor::GetConverter, string capability in both directions, property names and boxed types in order, recreation from a hashtable, InstanceDescriptor completeness, argument count and Invoke round trip, and the two exception types. Extra cases cover the base class alone (MathTypeConverterTests.ImplementsTheExpandableXnaBaseContract), the Czech decimal comma and semicolon (Vector3ConverterTests.UsesCultureDecimalAndListSeparators), Matrix element order and the Translation descriptor (MatrixConverterTests.CreateInstanceUsesEveryElementInRowMajorOrder, TranslationDescriptorEditsTheTranslationRowButIsNotAConstructorArgument) and byte-range rejection for Color. MathTypeConverterProtectedSurfaceTests.cpp (8) pins the protected surface: a subclass reaching supportStringConvert and propertyDescriptions by their XNA names (a rename or an alias pair would not compile), that the flag is one source of truth for CanConvertFrom, that the three forwarding ConvertFrom overrides forward and refuse a string, and that they are reached through the base interface.

Scope of the evidence: these are source-verified unit tests that were not executed for this page. CNA's design notes (docs/framework-design.md) record that the rules were recovered from the genuine Microsoft assembly, and there is no automated oracle test for this module. No workflow names CnaDesignTests; the automatic general-tests-ci workflow builds the default targets with tests on and runs an unfiltered ctest, which would discover these cases through CnaTests (definition read, not its runs). Still unproved: behaviour in a consuming editor or property grid, use from a second thread, lookup from another translation unit's static initialiser, other cultures, and Windows hosts. Whether Sharp Runtime's TypeDescriptor keeps the properties described above at a newer revision is likewise unverified.

Curated source and evidence route

  1. design/CMakeLists.txt, then Design.hpp: the opt-in target and the public converter set.
  2. Registration.hpp and Registration.cpp: header-triggered, once-only mapping of all twelve value types; check each mapping against the converter list.
  3. MathTypeConverter.hpp, MathTypeConverter.cpp and PropertyDescriptors.hpp: culture parsing, the protected fields and how boxed values are read and written.
  4. Converters.cpp: compare Vector3, Matrix and BoundingBox rather than assuming every converter handles strings alike.
  5. FrameworkDesignTests.cpp and MathTypeConverterProtectedSurfaceTests.cpp: what is proved (discovery, descriptor shape, culture, negative paths, protected surface) and what still needs a consuming-editor or cross-thread test.
  6. docs/framework-design.md: CNA's own statement of the namespace inventory, the reference audit and the compatibility boundary.

For where the module sits among the others see the module index; for task routes, the Maintainer Handbook.

The same subsystem is explained at four altitudes. These are the neighbouring pages at each one.