Tutorial 140: Parse and Format Math Values with Type Converters
What you’ll learn: How to link the opt-in CNA::Design module, ask TypeDescriptor for the converter of a math value type, load a settings file with Point, Vector3 and Color values, format for a culture, and handle the two failure modes.
Before you start — Tutorial 02 (toolchain and the sibling checkouts; sharp-runtime must be its next branch) and Tutorial 03 (a CMake project that pulls CNA in with add_subdirectory). This tutorial never opens a window or a graphics device, so it runs anywhere CNA configures. It uses Framework.Design, the opt-in CNA::Design module.
Sooner or later a game needs to turn text into a Vector3, a Color or a Point — a spawn position in a settings file, a tint in a mod manifest, a level-editor field — and back again. You can hand-roll sscanf, or you can use the same TypeConverter classes XNA’s own tools use. CNA implements them in Microsoft::Xna::Framework::Design; they parse and print in the right culture, reject malformed input with an exception, and describe how to rebuild a value. In this tutorial you build a small console program that reads a settings file with three kinds of value, prints it back, formats a value for a Czech user, handles bad input, and edits one component through a property descriptor.
The project and CMake
Lay the project out next to your clones, exactly as in Tutorial 03:
my-cna-workspace/
sharp-runtime/ # git clone -b next https://github.com/libcna/sharp-runtime.git
cna/ # git clone -b next https://github.com/libcna/cna.git
converter-demo/
CMakeLists.txt
main.cpp
The only new idea is the link line. CNA::Design is not part of the CNA umbrella target, so a tool that only needs converters links it directly; it pulls in CNA::Math and Sharp Runtime’s ComponentModel and nothing else (no renderer, no SDL window).
cmake_minimum_required(VERSION 3.20)
project(ConverterDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# CNA has no install/export package for the C++ framework: add_subdirectory is the way in.
# HEADLESS needs no easy-gl / meta-gl sibling and this program never creates a device.
set(CNA_GRAPHICS_RENDERER "HEADLESS" CACHE STRING "CNA graphics renderer")
set(CNA_BUILD_TESTS OFF CACHE BOOL "")
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../cna ${CMAKE_BINARY_DIR}/cna)
add_executable(converter_demo main.cpp)
target_link_libraries(converter_demo PRIVATE CNA::Design)
Configuring still processes the whole CNA tree, so keep the same build-directory habits as in the other tutorials and build only your target (--target converter_demo). The commands and code in this tutorial were checked against the CNA headers at snapshot 009d40f5 (the program compiles as C++23 against them); the site itself did not build and run it, so treat the output shown as what CNA’s own converter tests assert for the same formats.
Step 1: get a converter
Converters live in Sharp Runtime’s TypeDescriptor registry. Including any Framework.Design header registers the converter for each XNA value type (once, at start-up), so the only thing you do is ask for it. Values cross the interface as std::any, the C++ spelling of CLR object. Two tiny helpers turn that into a typed API:
// main.cpp - Tutorial 140: parse and format math values with Framework.Design converters
#include <any>
#include <iostream>
#include <sstream>
#include <string>
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Design.hpp" // the umbrella: every converter + registration
#include "Microsoft/Xna/Framework/Point.hpp"
#include "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/Vector3.hpp"
#include "System/ArgumentException.hpp"
#include "System/ComponentModel/Design/Serialization/InstanceDescriptor.hpp"
#include "System/ComponentModel/TypeDescriptor.hpp"
#include "System/Globalization/CultureInfo.hpp"
#include "System/NotSupportedException.hpp"
using namespace Microsoft::Xna::Framework;
using System::ComponentModel::TypeDescriptor;
using System::ComponentModel::Design::Serialization::InstanceDescriptor;
// Ask the type-descriptor registry for the converter that Framework.Design registered for T.
template <class T>
T ParseInvariant(const std::string& text)
{
const auto converter = TypeDescriptor::GetConverter(System::Type::From<T>());
return std::any_cast<T>(converter->ConvertFromInvariantString(text));
}
template <class T>
std::string FormatInvariant(const T& value)
{
const auto converter = TypeDescriptor::GetConverter(System::Type::From<T>());
return converter->ConvertToInvariantString(std::any(value));
}
Include Microsoft/Xna/Framework/Design.hpp (the umbrella) or the specific ...Design/Vector3Converter.hpp-style header: that inclusion is what triggers registration. ConvertFromInvariantString uses the invariant culture — a . decimal point and , as the list separator — which is what you want for files you read and write yourself.
Step 2: load a settings file
Each line is key = value; the converter does the parsing. Point parses two integers, Vector3 three floats, Color four bytes (R, G, B, A):
// A "key = value" settings file: one converter call per line.
struct Settings
{
Point window{1280, 720};
Vector3 spawn{};
Color tint = Color::White;
};
Settings LoadSettings(std::istream& in)
{
Settings s;
std::string line;
while (std::getline(in, line))
{
const auto eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string value = line.substr(eq + 1);
key.erase(key.find_last_not_of(" \t") + 1);
try
{
if (key == "window") s.window = ParseInvariant<Point>(value);
else if (key == "spawn") s.spawn = ParseInvariant<Vector3>(value);
else if (key == "tint") s.tint = ParseInvariant<Color>(value);
}
catch (const System::ArgumentException& e)
{
std::cerr << "bad value for '" << key << "': " << e.what() << '\n';
}
}
return s;
}
Leading and trailing whitespace around the list is trimmed by the converter, so spawn = 10, 0.5, -5 needs no manual clean-up beyond the key. Anything that does not parse throws System::ArgumentException; the loader reports it and keeps the default.
int main()
{
std::istringstream file(
"window = 1920, 1080\n"
"spawn = 10, 0.5, -5\n"
"tint = 255, 128, 0, 255\n");
const Settings s = LoadSettings(file);
std::cout << FormatInvariant(s.window) << '\n' // 1920, 1080
<< FormatInvariant(s.spawn) << '\n' // 10, 0.5, -5
<< FormatInvariant(s.tint) << '\n'; // 255, 128, 0, 255
Step 3: culture-aware text
The same converter can format for a person rather than a file. It splits and joins on the culture’s list separator, and formats each component with that culture’s number format, so a Czech user sees a decimal comma and a semicolon separator. Pass the culture explicitly (a null culture means the current one):
// Culture-aware: Czech uses a decimal comma, so the list separator becomes a semicolon.
const auto v3 = TypeDescriptor::GetConverter(System::Type::From<Vector3>());
const System::Globalization::CultureInfo czech("cs-CZ");
const Vector3 v(1.5F, 2.25F, -3.75F);
std::cout << v3->ConvertToString(nullptr, &czech, std::any(v)) << '\n'; // 1,5; 2,25; -3,75
const Vector3 back =
std::any_cast<Vector3>(v3->ConvertFromString(nullptr, &czech, " 1,5; 2,25; -3,75 "));
std::cout << (back == v ? "round trip ok\n" : "round trip FAILED\n");
The two cultures are not interchangeable: under cs-CZ, "1,5, 2,25, -3,75" is rejected with an ArgumentException, because the list separator is ;. Use the invariant functions for files and network data, and the culture overloads only for text a user types or reads.
Step 4: bad input and types without a string form
Two different failures are worth telling apart. Wrong or unparsable text raises System::ArgumentException. A type that XNA never allowed to parse from a string — Rectangle, Matrix, BoundingBox, BoundingSphere, Plane, Ray — raises System::NotSupportedException, and CanConvertFrom(Type::From<std::string>()) tells you beforehand:
// Types without a string form refuse string input.
try
{
(void)TypeDescriptor::GetConverter(System::Type::From<Rectangle>())
->ConvertFromInvariantString("1, 2, 3, 4");
}
catch (const System::NotSupportedException&)
{
std::cout << "Rectangle: string input is not supported (as in XNA)\n";
}
// Wrong component count -> ArgumentException.
try { (void)ParseInvariant<Vector3>("1, 2"); }
catch (const System::ArgumentException&) { std::cout << "\"1, 2\" is not a Vector3: rejected\n"; }
Those same types can be converted to a string (through the value’s ToString()); they just cannot be parsed back. If you need a round trip for a Rectangle, store its four Point-style integers yourself.
Step 5: edit a component and rebuild the value
XNA’s value types are immutable-feeling structs, so a property grid edits a boxed copy component by component and then recreates the value. The converter exposes exactly that machinery. GetProperties returns the descriptors in XNA order (X, Y, Z for a Vector3); SetValue writes one component of the boxed copy; converting to an InstanceDescriptor captures the constructor arguments, and Invoke() calls the constructor again:
// Edit one component through the property descriptors, then rebuild via an InstanceDescriptor.
std::any boxed = v;
const auto properties = v3->GetProperties(boxed);
properties.getItem("X")->SetValue(boxed, std::any(9.0F));
const std::any described = v3->ConvertTo(boxed, System::Type::From<InstanceDescriptor>());
const auto& descriptor = std::any_cast<const InstanceDescriptor&>(described);
const Vector3 rebuilt = std::any_cast<Vector3>(descriptor.Invoke());
std::cout << FormatInvariant(rebuilt) << '\n'; // 9, 2.25, -3.75
return 0;
}
An InstanceDescriptor is what a code generator would emit as “new Vector3(9f, 2.25f, -3.75f)”. In CNA there is no CodeDOM, but the descriptor is a faithful, executable record you can log, diff or store. CreateInstance(context, hashtable) is the other route: give it a System::Collections::Hashtable of property name to boxed component value and it returns the boxed value.
Build and run
cd converter-demo
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target converter_demo -j$(nproc)
./build/converter_demo
Expected output, line for line:
1920, 1080
10, 0.5, -5
255, 128, 0, 255
1,5; 2,25; -3,75
round trip ok
Rectangle: string input is not supported (as in XNA)
"1, 2" is not a Vector3: rejected
9, 2.25, -3.75
The Czech line depends on Sharp Runtime’s culture data for cs-CZ (CNA’s own test asserts exactly 1,5; 2,25; -3,75).
The complete main.cpp
// main.cpp - Tutorial 140: parse and format math values with Framework.Design converters
#include <any>
#include <iostream>
#include <sstream>
#include <string>
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Design.hpp" // the umbrella: every converter + registration
#include "Microsoft/Xna/Framework/Point.hpp"
#include "Microsoft/Xna/Framework/Rectangle.hpp"
#include "Microsoft/Xna/Framework/Vector3.hpp"
#include "System/ArgumentException.hpp"
#include "System/ComponentModel/Design/Serialization/InstanceDescriptor.hpp"
#include "System/ComponentModel/TypeDescriptor.hpp"
#include "System/Globalization/CultureInfo.hpp"
#include "System/NotSupportedException.hpp"
using namespace Microsoft::Xna::Framework;
using System::ComponentModel::TypeDescriptor;
using System::ComponentModel::Design::Serialization::InstanceDescriptor;
// Ask the type-descriptor registry for the converter that Framework.Design registered for T.
template <class T>
T ParseInvariant(const std::string& text)
{
const auto converter = TypeDescriptor::GetConverter(System::Type::From<T>());
return std::any_cast<T>(converter->ConvertFromInvariantString(text));
}
template <class T>
std::string FormatInvariant(const T& value)
{
const auto converter = TypeDescriptor::GetConverter(System::Type::From<T>());
return converter->ConvertToInvariantString(std::any(value));
}
// A "key = value" settings file: one converter call per line.
struct Settings
{
Point window{1280, 720};
Vector3 spawn{};
Color tint = Color::White;
};
Settings LoadSettings(std::istream& in)
{
Settings s;
std::string line;
while (std::getline(in, line))
{
const auto eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string value = line.substr(eq + 1);
key.erase(key.find_last_not_of(" \t") + 1);
try
{
if (key == "window") s.window = ParseInvariant<Point>(value);
else if (key == "spawn") s.spawn = ParseInvariant<Vector3>(value);
else if (key == "tint") s.tint = ParseInvariant<Color>(value);
}
catch (const System::ArgumentException& e)
{
std::cerr << "bad value for '" << key << "': " << e.what() << '\n';
}
}
return s;
}
int main()
{
std::istringstream file(
"window = 1920, 1080\n"
"spawn = 10, 0.5, -5\n"
"tint = 255, 128, 0, 255\n");
const Settings s = LoadSettings(file);
std::cout << FormatInvariant(s.window) << '\n' // 1920, 1080
<< FormatInvariant(s.spawn) << '\n' // 10, 0.5, -5
<< FormatInvariant(s.tint) << '\n'; // 255, 128, 0, 255
// Culture-aware: Czech uses a decimal comma, so the list separator becomes a semicolon.
const auto v3 = TypeDescriptor::GetConverter(System::Type::From<Vector3>());
const System::Globalization::CultureInfo czech("cs-CZ");
const Vector3 v(1.5F, 2.25F, -3.75F);
std::cout << v3->ConvertToString(nullptr, &czech, std::any(v)) << '\n'; // 1,5; 2,25; -3,75
const Vector3 back =
std::any_cast<Vector3>(v3->ConvertFromString(nullptr, &czech, " 1,5; 2,25; -3,75 "));
std::cout << (back == v ? "round trip ok\n" : "round trip FAILED\n");
// Types without a string form refuse string input.
try
{
(void)TypeDescriptor::GetConverter(System::Type::From<Rectangle>())
->ConvertFromInvariantString("1, 2, 3, 4");
}
catch (const System::NotSupportedException&)
{
std::cout << "Rectangle: string input is not supported (as in XNA)\n";
}
// Wrong component count -> ArgumentException.
try { (void)ParseInvariant<Vector3>("1, 2"); }
catch (const System::ArgumentException&) { std::cout << "\"1, 2\" is not a Vector3: rejected\n"; }
// Edit one component through the property descriptors, then rebuild via an InstanceDescriptor.
std::any boxed = v;
const auto properties = v3->GetProperties(boxed);
properties.getItem("X")->SetValue(boxed, std::any(9.0F));
const std::any described = v3->ConvertTo(boxed, System::Type::From<InstanceDescriptor>());
const auto& descriptor = std::any_cast<const InstanceDescriptor&>(described);
const Vector3 rebuilt = std::any_cast<Vector3>(descriptor.Invoke());
std::cout << FormatInvariant(rebuilt) << '\n'; // 9, 2.25, -3.75
return 0;
}
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Link error mentioning ComponentModel or Design symbols | You linked CNA only; CNA::Design is opt-in. | target_link_libraries(x PRIVATE CNA::Design). |
GetConverter does not return the XNA converter | No Framework.Design header was included in a translation unit of your program, so registration never ran. | Include Microsoft/Xna/Framework/Design.hpp. |
ArgumentException on a Color such as "0, 1, 2, 256" | Colour components are bytes; 256 and negatives are rejected. | Clamp to 0–255 before formatting your own text; use Color constructors for floats. |
ArgumentException on "1.5, 2.5" under cs-CZ | The culture expects 1,5; 2,5. | Read data files with ConvertFromInvariantString. |
std::bad_any_cast | You cast to the wrong type; a Point is ints, a Vector2 is floats. | Cast to the value type the converter is registered for. |
NotSupportedException parsing Matrix, Plane, … | Those converters never accept string input, as in XNA. | Store components yourself, or use CreateInstance with a Hashtable. |
Next steps
- Framework.Design reference — all thirteen types, their property order, the string-input table, tests and limits.
- Tutorial 41: Math vectors and Tutorial 07: Colors — the value types you just converted.
- XNA Compatibility — where Framework.Design sits in the namespace-by-namespace picture, and what 3,627/3,627 does and does not mean.