Tutorial 140: Parse and Format Math Values with Type Converters

CNA Tutorials  ·  CNA snapshot 009d40f5

ℹ

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

SymptomCauseFix
Link error mentioning ComponentModel or Design symbolsYou linked CNA only; CNA::Design is opt-in.target_link_libraries(x PRIVATE CNA::Design).
GetConverter does not return the XNA converterNo 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-CZThe culture expects 1,5; 2,5.Read data files with ConvertFromInvariantString.
std::bad_any_castYou 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