My SpawnableObjectInfo class has a problem. It has no error handling. This means that attempting to load a missing or invalid XML file will crash my entire game. Even worse, if I’m running the game with the Play In Editor function, it will crash the entire Unreal Engine editor!
I need to fix this. I’m not a C++ developer though, so I don’t know what the “idiomatic” method of handling errors in C++ is. I’m a Rust and Go developer so I’ll pull from that experience.
In Go, we use multiple returns and the error interface. This interface is defined as
type error interface {
Error() string
}
Which means that any Go object that implements an Error() function that returns a String is considered a valid error. Idiomatic error handling involves this interface as well as the fact that Go functions can return multiple values. Here is a contrived example of Go error handling:
package main
import "fmt"
type DivideByZeroError struct{}
func (e *DivideByZeroError) Error() string {
return "divide by zero error!"
}
func divide(a float64, b float64) (float64, error) {
if b == 0 {
return 0, &DivideByZeroError{}
}
return a / b, nil
}
func main() {
good, err := divide(10, 5)
if err != nil {
fmt.Printf("division error: %v\n", err)
return
}
fmt.Printf("10 / 5 = %v\n", good)
bad, err := divide(10, 0)
if err != nil {
fmt.Printf("division error: %v\n", err)
return
}
fmt.Printf("10 / 0 = %v\n", bad)
}
Executing this program will result in the output
10 / 5 = 2
division error: divide by zero error!
That’s pretty neat. However, C++ does not support multiple returns. I could approximate the functionality by returning a Struct with two variables: Value and Error but that seems a little hacky to do it myself. So let’s look at Rust next.
Rust uses the built in std::Result<T, E> type. It’s an enum with two variants, Ok(T), representing the happy value, and Err(e), representing an error. After that, we use pattern matching to unpack the value or the error from the enum.
use std::fmt;
#[derive(Debug, Clone)]
struct DivideByZeroError {}
impl fmt::Display for DivideByZeroError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "divide by zero error!")
}
}
fn divide(a: f64, b: f64) -> Result<f64, DivideByZeroError> {
if b == 0f64 {
return Err(DivideByZeroError{})
}
return Ok(a / b)
}
fn main() {
let good = divide(10.0, 5.0);
match good {
Ok(v) => println!("10 / 5 = {}", v),
Err(e) => println!("division error: {}", e)
}
let bad = divide(10.0, 0.0);
match bad {
Ok(v) => println!("10 / 0 = {}", v),
Err(e) => println!("division error: {}", e)
}
}
Executing this program will result in the output
10 / 5 = 2
division error: divide by zero error!
Something cool about Rust’s result type is that it’s an enum, so the compiler will force you to implement handling for both possible enum variants. Neat!
As it turns out, C++23 introduced a new class template, std::expected which provides similar functionality. Unfortunately, instead of being an enum, std::expected is just a class containing a val member and an unex member but at least it’s in the standard library. I’ve decided to use std::expected for my error handling.
I’ve moved my XML handling to a new class, WorldObjectManager. Here’s the implementation of the XML loading and parsing:
#include "WorldObjectManager.h"
#include "XmlFile.h"
DEFINE_LOG_CATEGORY(LogWorldObjectManager);
enum class WorldObjectManagerError {
FileNotFound,
FileReadError,
XMLParseError
};
std::expected<WorldObjectInfo, WorldObjectManagerError> WorldObjectManager::LoadWorldObjectFromXML(FString xmlName) {
WorldObjectInfo objectInfo;
FString xmlPath = this->BaseDirectory + xmlName;
IPlatformFile& PlatformFile = FPlatformFileManager::Get().GetPlatformFile();
if (!PlatformFile.FileExists(*xmlPath)) {
UE_LOG(LogWorldObjectManager, Error, TEXT("XML definition file not found: %s"), *xmlPath);
return std::unexpected(WorldObjectManagerError::FileNotFound);
}
FString xmlData;
bool isXmlDataLoaded = FFileHelper::LoadFileToString(xmlData, &PlatformFile, *xmlPath, FFileHelper::EHashOptions::None, 0);
if (!isXmlDataLoaded) {
UE_LOG(LogWorldObjectManager, Error, TEXT("Failed to load XML file; isXmlDataLoaded == false: %s"), *xmlPath);
return std::unexpected(WorldObjectManagerError::FileReadError);
}
FXmlFile* xmlDocument = new FXmlFile(xmlData, EConstructMethod::ConstructFromBuffer);
if (!xmlDocument->IsValid()) {
FString XmlError = xmlDocument->GetLastError();
UE_LOG(LogWorldObjectManager, Error, TEXT("Failed to parse XML file [%s]: %s"), *xmlPath, *XmlError);
return std::unexpected(WorldObjectManagerError::XMLParseError);
}
FXmlNode* rootNode = xmlDocument->GetRootNode();
// TODO: add modelSrc existence check
// hard to do because FXmlNode does not provide a HasAttribute function :(
FString modelSrc = rootNode->GetAttribute(TEXT("src"));
FString modelID = rootNode->GetAttribute(TEXT("id"));
objectInfo.MeshSource = modelSrc;
objectInfo.ID = modelID;
FXmlNode* options = rootNode->FindChildNode(TEXT("options"));
if (options) {
FXmlNode* scaleNode = options->FindChildNode(TEXT("scale"));
if (scaleNode) {
double meshScale = FCString::Atod(*scaleNode->GetContent());
objectInfo.MeshScale = FVector(meshScale);
}
else {
UE_LOG(LogWorldObjectManager, Warning, TEXT("XML Definition File [%s] did not have scale node"), *xmlPath);
}
FXmlNode* voxelSizeNode = options->FindChildNode(TEXT("voxelSize"));
if (voxelSizeNode) {
double voxelSize = FCString::Atod(*voxelSizeNode->GetContent());
objectInfo.VoxelSize = voxelSize;
}
else {
UE_LOG(LogWorldObjectManager, Warning, TEXT("XML Definition File [%s] did not have voxelSize node"), *xmlPath);
}
FXmlNode* rotationNode = options->FindChildNode(TEXT("rotation"));
if (rotationNode) {
double rotX = FCString::Atod(*rotationNode->GetAttribute(TEXT("x")));
double rotY = FCString::Atod(*rotationNode->GetAttribute(TEXT("y")));
double rotZ = FCString::Atod(*rotationNode->GetAttribute(TEXT("z")));
// This order of rotations is to match the order in the unreal editor inspect objects pane.
objectInfo.MeshRotation = FRotator(rotY, rotZ, rotX);
}
else {
UE_LOG(LogWorldObjectManager, Warning, TEXT("XML Definition File [%s] did not have rotation node"), *xmlPath);
}
}
// This is a required node
FXmlNode* modelSizeNode = rootNode->FindChildNode(TEXT("modelSize"));
if (!modelSizeNode) {
UE_LOG(LogWorldObjectManager, Error, TEXT("XML Definition File [%s] missing required node: modelSize"), *xmlPath);
return std::unexpected(WorldObjectManagerError::XMLParseError);
}
double modelSizeX = FCString::Atod(*modelSizeNode->GetAttribute(TEXT("x")));
double modelSizeY = FCString::Atod(*modelSizeNode->GetAttribute(TEXT("y")));
double modelSizeZ = FCString::Atod(*modelSizeNode->GetAttribute(TEXT("z")));
objectInfo.MeshSize = FVector(modelSizeX, modelSizeY, modelSizeZ);
FXmlNode* spawnpointsNode = rootNode->FindChildNode(TEXT("spawnpoints"));
if (spawnpointsNode) {
TArray<FXmlNode*> children = spawnpointsNode->GetChildrenNodes();
for (FXmlNode* child : children) {
double spawnX = FCString::Atod(*child->GetAttribute(TEXT("x")));
double spawnY = FCString::Atod(*child->GetAttribute(TEXT("y")));
double spawnZ = FCString::Atod(*child->GetAttribute(TEXT("z")));
TArray<FString> spTags;
TArray<FXmlNode*> spawnpointChildren = child->GetChildrenNodes();
for (FXmlNode* spawnpointTag : spawnpointChildren) {
if (spawnpointTag->GetTag() == "tag") {
spTags.Add(spawnpointTag->GetContent());
}
}
SpawnpointInfo spInfo;
spInfo.Tags = spTags;
spInfo.Position = FVector(spawnX, spawnY, spawnZ);
objectInfo.Spawnpoints.Add(spInfo);
}
}
else {
UE_LOG(LogWorldObjectManager, Verbose, TEXT("XML Definition File [%s] missing <spawnpoints>"), *xmlPath);
}
FXmlNode* tagsNode = rootNode->FindChildNode(TEXT("tags"));
if (!tagsNode) {
UE_LOG(LogWorldObjectManager, Error, TEXT("XML Definition File [%s] missing required node: tags"), *xmlPath);
return std::unexpected(WorldObjectManagerError::XMLParseError);
}
TArray<FXmlNode*> tagsChildNodes = tagsNode->GetChildrenNodes();
for (FXmlNode* tagChild : tagsChildNodes) {
FString tagValue = tagChild->GetContent();
objectInfo.Tags.Add(tagValue);
TArray<FString> tagMap = TagToModelIds.FindOrAdd(tagValue);
tagMap.Add(modelID);
TagToModelIds[tagValue] = tagMap;
}
ModelIdToInfo.Add(modelID, objectInfo);
return objectInfo;
}
And here is some code using the new loading function:
WorldObjectManager* objectManager = new WorldObjectManager();
auto deskInfo = objectManager->LoadWorldObjectFromXML("desk.xml");
if (!deskInfo) {
switch (deskInfo.error()) {
case WorldObjectManagerError::XMLParseError:
UE_LOG(LogGameMode, Error, TEXT("unable to load desk xml file: xml parse error"));
break;
case WorldObjectManagerError::FileReadError:
UE_LOG(LogGameMode, Error, TEXT("unable to load desk xml file: file read error"));
break;
}
return;
}
FVector DeskPosition = FVector::ZeroVector;
FRotator DeskRotation = deskInfo->MeshRotation;
auto DeskActor = ASpawnableObject::CreateSpawnableObject(GetWorld(), &DeskPosition, &DeskRotation, SpawnParams);
DeskActor->SetMesh(*deskInfo->MeshSource);
DeskActor->SetScale(deskInfo->MeshScale);
That’s pretty neat. The error translation from enum to string can probably be done smarter. Or I can learn how to use try-catch.
Anyways, in the above code you might notice that I’ve added tags to my XML loader. This allows me to choose what types of items can be spawned on my furniture.
<model id="desk" src="/Game/Models/desk.desk">
<options>
<voxelSize>10</voxelSize>
<rotation x="0" y="0" z="0" />
<scale>1</scale>
</options>
<modelSize x="32" y="11" z="23" />
<spawnpoints>
<spawnpoint x="1" y="1" z="23">
<tag>notebook</tag>
</spawnpoint>
<spawnpoint x="5" y="1" z="23">
<tag>cup</tag>
</spawnpoint>
</spawnpoints>
<tags>
<tag>furniture</tag>
<tag>medium</tag>
</tags>
</model>
I also made a desk