PrimeWorldEditor/src/Core/GameProject/CGameExporter.cpp

774 lines
27 KiB
C++

#include "CGameExporter.h"
#include "CGameInfo.h"
#include "CResourceIterator.h"
#include "CResourceStore.h"
#include "Core/CompressionUtil.h"
#include "Core/Resource/CWorld.h"
#include "Core/Resource/Script/CGameTemplate.h"
#include <Common/Macros.h>
#include <Common/CScopedTimer.h>
#include <Common/FileIO.h>
#include <Common/FileUtil.h>
#include <Common/Serialization/CXMLWriter.h>
#include <nod/nod.hpp>
#include <nod/DiscBase.hpp>
#include <tinyxml2.h>
#define LOAD_PAKS 1
#define SAVE_PACKAGE_DEFINITIONS 1
#define USE_ASSET_NAME_MAP 1
#define EXPORT_COOKED 1
#if NOD_UCS2
#define TStringToNodString(string) ToWChar(string)
#else
#define TStringToNodString(string) *string
#endif
CGameExporter::CGameExporter(EDiscType DiscType, EGame Game, bool FrontEnd, ERegion Region, const TString& rkGameName, const TString& rkGameID, float BuildVersion)
: mGame(Game)
, mRegion(Region)
, mGameName(rkGameName)
, mGameID(rkGameID)
, mBuildVersion(BuildVersion)
, mDiscType(DiscType)
, mFrontEnd(FrontEnd)
, mpProgress(nullptr)
{
ASSERT(mGame != EGame::Invalid);
ASSERT(mRegion != ERegion::Unknown);
}
bool CGameExporter::Export(nod::DiscBase *pDisc, const TString& rkOutputDir, CAssetNameMap *pNameMap, CGameInfo *pGameInfo, IProgressNotifier *pProgress)
{
SCOPED_TIMER(ExportGame);
mpDisc = pDisc;
mpNameMap = pNameMap;
mpGameInfo = pGameInfo;
mExportDir = FileUtil::MakeAbsolute(rkOutputDir);
mDiscDir = "Disc/";
mWorldsDirName = "Worlds/";
// Export directory must be empty!
if (FileUtil::Exists(mExportDir) && !FileUtil::IsEmpty(mExportDir))
return false;
FileUtil::MakeDirectory(mExportDir);
// Init progress
mpProgress = pProgress;
mpProgress->SetNumTasks(eES_NumSteps);
// Extract disc
if (!ExtractDiscData())
return false;
// Create project
mpProject = CGameProject::CreateProjectForExport(
mExportDir,
mGame,
mRegion,
mGameID,
mBuildVersion);
mpProject->SetProjectName(mGameName);
mpStore = mpProject->ResourceStore();
mResourcesDir = mpStore->ResourcesDir();
CResourceStore *pOldStore = gpResourceStore;
gpResourceStore = mpStore;
// Export cooked data
LoadPaks();
ExportCookedResources();
// Export editor data
if (!mpProgress->ShouldCancel())
{
mpProject->AudioManager()->LoadAssets();
ExportResourceEditorData();
}
// Export finished!
mProjectPath = mpProject->ProjectPath();
delete mpProject;
if (pOldStore) gpResourceStore = pOldStore;
return !mpProgress->ShouldCancel();
}
void CGameExporter::LoadResource(const CAssetID& rkID, std::vector<uint8>& rBuffer)
{
SResourceInstance *pInst = FindResourceInstance(rkID);
if (pInst) LoadResource(*pInst, rBuffer);
}
bool CGameExporter::ShouldExportDiscNode(const nod::Node *pkNode, bool IsInRoot)
{
if (IsInRoot && mDiscType != EDiscType::Normal)
{
// Directories - exclude the filesystem for other games
if (pkNode->getKind() == nod::Node::Kind::Directory)
{
// Frontend is always included; this is for compatibility with Dolphin
if (pkNode->getName() == "fe")
return true;
else if (mFrontEnd)
return false;
switch (mGame)
{
case EGame::Prime:
return ( (mDiscType == EDiscType::WiiDeAsobu && pkNode->getName() == "MP1JPN") ||
(mDiscType == EDiscType::Trilogy && pkNode->getName() == "MP1") );
case EGame::Echoes:
return ( (mDiscType == EDiscType::WiiDeAsobu && pkNode->getName() == "MP2JPN") ||
(mDiscType == EDiscType::Trilogy && pkNode->getName() == "MP2") );
case EGame::Corruption:
return (mDiscType == EDiscType::Trilogy && pkNode->getName() == "MP3");
default:
return false;
}
}
// Files - exclude the DOLs for other games
else
{
// Again - always include frontend. Always include opening.bnr as well.
if (pkNode->getName() == "rs5fe_p.dol" || pkNode->getName() == "opening.bnr")
return true;
else if (mFrontEnd)
return false;
switch (mGame)
{
case EGame::Prime:
return ( (mDiscType == EDiscType::WiiDeAsobu && pkNode->getName() == "rs5mp1jpn_p.dol") ||
(mDiscType == EDiscType::Trilogy && pkNode->getName() == "rs5mp1_p.dol") );
case EGame::Echoes:
return ( (mDiscType == EDiscType::WiiDeAsobu && pkNode->getName() == "rs5mp2jpn_p.dol") ||
(mDiscType == EDiscType::Trilogy && pkNode->getName() == "rs5mp2_p.dol") );
case EGame::Corruption:
return (mDiscType == EDiscType::Trilogy && pkNode->getName() == "rs5mp3_p.dol");
default:
return false;
}
}
}
return true;
}
// ************ PROTECTED ************
bool CGameExporter::ExtractDiscData()
{
// todo: handle dol, apploader, multiple partitions, wii ticket blob
SCOPED_TIMER(ExtractDiscData);
// Init progress
mpProgress->SetTask(eES_ExtractDisc, "Extracting disc files");
// Create Disc output folder
TString AbsDiscDir = mExportDir + mDiscDir;
bool IsWii = (mBuildVersion >= 3.f);
if (IsWii) AbsDiscDir += "DATA/";
FileUtil::MakeDirectory(AbsDiscDir);
// Extract disc filesystem
nod::IPartition *pDataPartition = mpDisc->getDataPartition();
nod::ExtractionContext Context;
Context.force = false;
Context.progressCB = [&](const std::string_view rkDesc, float ProgressPercent) {
mpProgress->Report((int) (ProgressPercent * 10000), 10000, rkDesc.data());
};
TString FilesDir = AbsDiscDir + "files/";
FileUtil::MakeDirectory(FilesDir);
bool Success = ExtractDiscNodeRecursive(&pDataPartition->getFSTRoot(), FilesDir, true, Context);
if (!Success) return false;
if (!mpProgress->ShouldCancel())
{
Context.progressCB = nullptr;
if (IsWii)
{
// Extract crypto files
if (!pDataPartition->extractCryptoFiles(TStringToNodString(AbsDiscDir), Context))
return false;
// Extract disc header files
if (!mpDisc->extractDiscHeaderFiles(TStringToNodString(AbsDiscDir), Context))
return false;
}
// Extract system files
if (!pDataPartition->extractSysFiles(TStringToNodString(AbsDiscDir), Context))
return false;
return true;
}
else
return false;
}
bool CGameExporter::ExtractDiscNodeRecursive(const nod::Node *pkNode, const TString& rkDir, bool RootNode, const nod::ExtractionContext& rkContext)
{
for (nod::Node::DirectoryIterator Iter = pkNode->begin(); Iter != pkNode->end(); ++Iter)
{
if (!ShouldExportDiscNode(&*Iter, RootNode))
continue;
if (Iter->getKind() == nod::Node::Kind::File)
{
TString FilePath = rkDir + Iter->getName().data();
bool Success = Iter->extractToDirectory(TStringToNodString(rkDir), rkContext);
if (!Success) return false;
if (FilePath.GetFileExtension().CaseInsensitiveCompare("pak"))
{
// For multi-game Wii discs, don't track packages for frontend unless we're exporting frontend
if (mDiscType == EDiscType::Normal || mFrontEnd || pkNode->getName() != "fe")
mPaks.push_back(FilePath);
}
}
else
{
TString Subdir = rkDir + Iter->getName().data() + "/";
bool Success = FileUtil::MakeDirectory(Subdir);
if (!Success) return false;
Success = ExtractDiscNodeRecursive(&*Iter, Subdir, false, rkContext);
if (!Success) return false;
}
}
return true;
}
// ************ RESOURCE LOADING ************
void CGameExporter::LoadPaks()
{
#if LOAD_PAKS
SCOPED_TIMER(LoadPaks);
mPaks.sort([](const TString& rkLeft, const TString& rkRight) -> bool {
return rkLeft.ToUpper() < rkRight.ToUpper();
});
for (auto It = mPaks.begin(); It != mPaks.end(); It++)
{
TString PakPath = *It;
CFileInStream Pak(PakPath, EEndian::BigEndian);
if (!Pak.IsValid())
{
errorf("Couldn't open pak: %s", *PakPath);
continue;
}
TString RelPakPath = FileUtil::MakeRelative(PakPath.GetFileDirectory(), mpProject->DiscFilesystemRoot(false));
CPackage *pPackage = new CPackage(mpProject, PakPath.GetFileName(false), RelPakPath);
// MP1-MP3Proto
if (mGame < EGame::Corruption)
{
uint32 PakVersion = Pak.ReadLong();
Pak.Seek(0x4, SEEK_CUR);
ASSERT(PakVersion == 0x00030005);
// Echoes demo disc has a pak that ends right here.
if (!Pak.EoF())
{
uint32 NumNamedResources = Pak.ReadLong();
ASSERT(NumNamedResources > 0);
for (uint32 iName = 0; iName < NumNamedResources; iName++)
{
CFourCC ResType = Pak.ReadLong();
CAssetID ResID(Pak, mGame);
uint32 NameLen = Pak.ReadLong();
TString Name = Pak.ReadString(NameLen);
pPackage->AddResource(Name, ResID, ResType);
}
uint32 NumResources = Pak.ReadLong();
// Keep track of which areas have duplicate resources
std::set<CAssetID> PakResourceSet;
bool AreaHasDuplicates = true; // Default to true so that first area is always considered as having duplicates
for (uint32 iRes = 0; iRes < NumResources; iRes++)
{
bool Compressed = (Pak.ReadLong() == 1);
CFourCC ResType = Pak.ReadLong();
CAssetID ResID(Pak, mGame);
uint32 ResSize = Pak.ReadLong();
uint32 ResOffset = Pak.ReadLong();
if (mResourceMap.find(ResID) == mResourceMap.end())
mResourceMap[ResID] = SResourceInstance { PakPath, ResID, ResType, ResOffset, ResSize, Compressed, false };
// Check for duplicate resources
if (ResType == "MREA")
{
mAreaDuplicateMap[ResID] = AreaHasDuplicates;
AreaHasDuplicates = false;
}
else if (!AreaHasDuplicates && PakResourceSet.find(ResID) != PakResourceSet.end())
AreaHasDuplicates = true;
else
PakResourceSet.insert(ResID);
}
}
}
// MP3 + DKCR
else
{
uint32 PakVersion = Pak.ReadLong();
uint32 PakHeaderLen = Pak.ReadLong();
Pak.Seek(PakHeaderLen - 0x8, SEEK_CUR);
ASSERT(PakVersion == 2);
struct SPakSection {
CFourCC Type; uint32 Size;
};
std::vector<SPakSection> PakSections;
uint32 NumPakSections = Pak.ReadLong();
ASSERT(NumPakSections == 3);
for (uint32 iSec = 0; iSec < NumPakSections; iSec++)
{
CFourCC Type = Pak.ReadLong();
uint32 Size = Pak.ReadLong();
PakSections.push_back(SPakSection { Type, Size });
}
Pak.SeekToBoundary(64);
for (uint32 iSec = 0; iSec < NumPakSections; iSec++)
{
uint32 Next = Pak.Tell() + PakSections[iSec].Size;
// Named Resources
if (PakSections[iSec].Type == "STRG")
{
uint32 NumNamedResources = Pak.ReadLong();
for (uint32 iName = 0; iName < NumNamedResources; iName++)
{
TString Name = Pak.ReadString();
CFourCC ResType = Pak.ReadLong();
CAssetID ResID(Pak, mGame);
pPackage->AddResource(Name, ResID, ResType);
}
}
else if (PakSections[iSec].Type == "RSHD")
{
ASSERT(PakSections[iSec + 1].Type == "DATA");
uint32 DataStart = Next;
uint32 NumResources = Pak.ReadLong();
// Keep track of which areas have duplicate resources
std::set<CAssetID> PakResourceSet;
bool AreaHasDuplicates = true; // Default to true so that first area is always considered as having duplicates
for (uint32 iRes = 0; iRes < NumResources; iRes++)
{
bool Compressed = (Pak.ReadLong() == 1);
CFourCC Type = Pak.ReadLong();
CAssetID ResID(Pak, mGame);
uint32 Size = Pak.ReadLong();
uint32 Offset = DataStart + Pak.ReadLong();
if (mResourceMap.find(ResID) == mResourceMap.end())
mResourceMap[ResID] = SResourceInstance { PakPath, ResID, Type, Offset, Size, Compressed, false };
// Check for duplicate resources (unnecessary for DKCR)
if (mGame != EGame::DKCReturns)
{
if (Type == "MREA")
{
mAreaDuplicateMap[ResID] = AreaHasDuplicates;
AreaHasDuplicates = false;
}
else if (!AreaHasDuplicates && PakResourceSet.find(ResID) != PakResourceSet.end())
AreaHasDuplicates = true;
else
PakResourceSet.insert(ResID);
}
}
}
Pak.Seek(Next, SEEK_SET);
}
}
// Add package to project and save
mpProject->AddPackage(pPackage);
#if SAVE_PACKAGE_DEFINITIONS
bool SaveSuccess = pPackage->Save();
ASSERT(SaveSuccess);
#endif
}
#endif
}
void CGameExporter::LoadResource(const SResourceInstance& rkResource, std::vector<uint8>& rBuffer)
{
CFileInStream Pak(rkResource.PakFile, EEndian::BigEndian);
if (Pak.IsValid())
{
Pak.Seek(rkResource.PakOffset, SEEK_SET);
// Handle compression
if (rkResource.Compressed)
{
bool ZlibCompressed = (mGame <= EGame::EchoesDemo || mGame == EGame::DKCReturns);
if (mGame <= EGame::CorruptionProto)
{
std::vector<uint8> CompressedData(rkResource.PakSize);
uint32 UncompressedSize = Pak.ReadLong();
rBuffer.resize(UncompressedSize);
Pak.ReadBytes(CompressedData.data(), CompressedData.size());
if (ZlibCompressed)
{
uint32 TotalOut;
CompressionUtil::DecompressZlib(CompressedData.data(), CompressedData.size(), rBuffer.data(), rBuffer.size(), TotalOut);
}
else
{
CompressionUtil::DecompressSegmentedData(CompressedData.data(), CompressedData.size(), rBuffer.data(), rBuffer.size());
}
}
else
{
CFourCC Magic = Pak.ReadLong();
ASSERT(Magic == "CMPD");
uint32 NumBlocks = Pak.ReadLong();
struct SCompressedBlock {
uint32 CompressedSize; uint32 UncompressedSize;
};
std::vector<SCompressedBlock> CompressedBlocks;
uint32 TotalUncompressedSize = 0;
for (uint32 iBlock = 0; iBlock < NumBlocks; iBlock++)
{
uint32 CompressedSize = (Pak.ReadLong() & 0x00FFFFFF);
uint32 UncompressedSize = Pak.ReadLong();
TotalUncompressedSize += UncompressedSize;
CompressedBlocks.push_back( SCompressedBlock { CompressedSize, UncompressedSize } );
}
rBuffer.resize(TotalUncompressedSize);
uint32 Offset = 0;
for (uint32 iBlock = 0; iBlock < NumBlocks; iBlock++)
{
uint32 CompressedSize = CompressedBlocks[iBlock].CompressedSize;
uint32 UncompressedSize = CompressedBlocks[iBlock].UncompressedSize;
// Block is compressed
if (CompressedSize != UncompressedSize)
{
std::vector<uint8> CompressedData(CompressedBlocks[iBlock].CompressedSize);
Pak.ReadBytes(CompressedData.data(), CompressedData.size());
if (ZlibCompressed)
{
uint32 TotalOut;
CompressionUtil::DecompressZlib(CompressedData.data(), CompressedData.size(), rBuffer.data() + Offset, UncompressedSize, TotalOut);
}
else
{
CompressionUtil::DecompressSegmentedData(CompressedData.data(), CompressedData.size(), rBuffer.data() + Offset, UncompressedSize);
}
}
// Block is uncompressed
else
Pak.ReadBytes(rBuffer.data() + Offset, UncompressedSize);
Offset += UncompressedSize;
}
}
}
// Handle uncompressed
else
{
rBuffer.resize(rkResource.PakSize);
Pak.ReadBytes(rBuffer.data(), rBuffer.size());
}
}
}
void CGameExporter::ExportCookedResources()
{
SCOPED_TIMER(ExportCookedResources);
FileUtil::MakeDirectory(mResourcesDir);
mpProgress->SetTask(eES_ExportCooked, "Unpacking cooked assets");
int ResIndex = 0;
for (auto It = mResourceMap.begin(); It != mResourceMap.end() && !mpProgress->ShouldCancel(); It++, ResIndex++)
{
SResourceInstance& rRes = It->second;
// Update progress
if ((ResIndex & 0x3) == 0)
mpProgress->Report(ResIndex, mResourceMap.size(), TString::Format("Unpacking asset %d/%d", ResIndex, mResourceMap.size()) );
// Export resource
ExportResource(rRes);
}
}
void CGameExporter::ExportResourceEditorData()
{
{
// Save raw versions of resources + resource cache data files
// Note this has to be done after all cooked resources are exported
// because we have to load the resource to build its dependency tree and
// some resources will fail to load if their dependencies don't exist
SCOPED_TIMER(SaveRawResources);
mpProgress->SetTask(eES_GenerateRaw, "Generating editor data");
int ResIndex = 0;
// todo: we're wasting a ton of time loading the same resources over and over because most resources automatically
// load all their dependencies and then we just clear it out from memory even though we'll need it again later. we
// should really be doing this by dependency order instead of by ID order.
for (CResourceIterator It(mpStore); It && !mpProgress->ShouldCancel(); ++It, ++ResIndex)
{
// Update progress
if ((ResIndex & 0x3) == 0 || It->ResourceType() == EResourceType::Area)
mpProgress->Report(ResIndex, mpStore->NumTotalResources(), TString::Format("Processing asset %d/%d: %s",
ResIndex, mpStore->NumTotalResources(), *It->CookedAssetPath(true).GetFileName()) );
// Worlds need some info we can only get from the pak at export time; namely, which areas can
// have duplicates, as well as the world's internal name.
if (It->ResourceType() == EResourceType::World)
{
CWorld *pWorld = (CWorld*) It->Load();
// Set area duplicate flags
for (uint32 iArea = 0; iArea < pWorld->NumAreas(); iArea++)
{
CAssetID AreaID = pWorld->AreaResourceID(iArea);
auto Find = mAreaDuplicateMap.find(AreaID);
if (Find != mAreaDuplicateMap.end())
pWorld->SetAreaAllowsPakDuplicates(iArea, Find->second);
}
// Set world name
TString WorldName = MakeWorldName(pWorld->ID());
pWorld->SetName(WorldName);
}
// Save raw resource + generate dependencies
if (It->TypeInfo()->CanBeSerialized())
It->Save(true);
else
It->UpdateDependencies();
// Set flags, save metadata
It->SaveMetadata(true);
}
}
if (!mpProgress->ShouldCancel())
{
// All resources should have dependencies generated, so save the project files
SCOPED_TIMER(SaveResourceDatabase);
#if EXPORT_COOKED
bool ResDBSaveSuccess = mpStore->SaveDatabaseCache();
ASSERT(ResDBSaveSuccess);
#endif
bool ProjectSaveSuccess = mpProject->Save();
ASSERT(ProjectSaveSuccess);
}
}
void CGameExporter::ExportResource(SResourceInstance& rRes)
{
if (!rRes.Exported)
{
std::vector<uint8> ResourceData;
LoadResource(rRes, ResourceData);
// Register resource and write to file
TString Directory, Name;
bool AutoDir, AutoName;
#if USE_ASSET_NAME_MAP
mpNameMap->GetNameInfo(rRes.ResourceID, Directory, Name, AutoDir, AutoName);
#else
Directory = mpStore->DefaultAssetDirectoryPath(mpStore->Game());
Name = rRes.ResourceID.ToString();
#endif
CResourceEntry *pEntry = mpStore->CreateNewResource(rRes.ResourceID,
CResTypeInfo::TypeForCookedExtension(mGame, rRes.ResourceType)->Type(),
Directory, Name, true);
// Set flags
pEntry->SetFlag(EResEntryFlag::IsBaseGameResource);
pEntry->SetFlagEnabled(EResEntryFlag::AutoResDir, AutoDir);
pEntry->SetFlagEnabled(EResEntryFlag::AutoResName, AutoName);
#if EXPORT_COOKED
// Save cooked asset
TString OutCookedPath = pEntry->CookedAssetPath();
FileUtil::MakeDirectory(OutCookedPath.GetFileDirectory());
CFileOutStream Out(OutCookedPath, EEndian::BigEndian);
if (Out.IsValid())
Out.WriteBytes(ResourceData.data(), ResourceData.size());
ASSERT(pEntry->HasCookedVersion());
#endif
rRes.Exported = true;
}
}
TString CGameExporter::MakeWorldName(CAssetID WorldID)
{
CResourceEntry *pWorldEntry = mpStore->FindEntry(WorldID);
ASSERT(pWorldEntry && pWorldEntry->ResourceType() == EResourceType::World);
// Find the original world name in the package resource names
TString WorldName;
for (uint32 iPkg = 0; iPkg < mpProject->NumPackages(); iPkg++)
{
CPackage *pPkg = mpProject->PackageByIndex(iPkg);
for (uint32 iRes = 0; iRes < pPkg->NumNamedResources(); iRes++)
{
const SNamedResource& rkRes = pPkg->NamedResourceByIndex(iRes);
if (rkRes.ID == WorldID)
{
WorldName = rkRes.Name;
if (WorldName.EndsWith("_NODEPEND"))
WorldName = WorldName.ChopBack(9);
break;
}
}
if (!WorldName.IsEmpty()) break;
}
// Fix up the name; remove date/time, leading exclamation points, etc
if (!WorldName.IsEmpty())
{
// World names are basically formatted differently in every game...
// MP1 demo - Remove ! from the beginning
if (mGame == EGame::PrimeDemo)
{
if (WorldName.StartsWith('!'))
WorldName = WorldName.ChopFront(1);
}
// MP1 - Remove prefix characters and ending date
else if (mGame == EGame::Prime)
{
WorldName = WorldName.ChopFront(2);
bool StartedDate = false;
while (!WorldName.IsEmpty())
{
char Chr = WorldName.Back();
if (!StartedDate && Chr >= '0' && Chr <= '9')
StartedDate = true;
else if (StartedDate && Chr != '_' && (Chr < '0' || Chr > '9'))
break;
WorldName = WorldName.ChopBack(1);
}
}
// MP2 demo - Use text between the first and second underscores
else if (mGame == EGame::EchoesDemo)
{
uint32 UnderscoreA = WorldName.IndexOf('_');
uint32 UnderscoreB = WorldName.IndexOf('_', UnderscoreA + 1);
if (UnderscoreA != UnderscoreB && UnderscoreA != -1 && UnderscoreB != -1)
WorldName = WorldName.SubString(UnderscoreA + 1, UnderscoreB - UnderscoreA - 1);
}
// MP2 - Remove text before first underscore and after last underscore, strip remaining underscores (except multiplayer maps, which have one underscore)
else if (mGame == EGame::Echoes)
{
uint32 FirstUnderscore = WorldName.IndexOf('_');
uint32 LastUnderscore = WorldName.LastIndexOf('_');
if (FirstUnderscore != LastUnderscore && FirstUnderscore != -1 && LastUnderscore != -1)
{
WorldName = WorldName.ChopBack(WorldName.Size() - LastUnderscore);
WorldName = WorldName.ChopFront(FirstUnderscore + 1);
WorldName.Remove('_');
}
}
// MP3 proto - Remove ! from the beginning and all text after last underscore
else if (mGame == EGame::CorruptionProto)
{
if (WorldName.StartsWith('!'))
WorldName = WorldName.ChopFront(1);
uint32 LastUnderscore = WorldName.LastIndexOf('_');
WorldName = WorldName.ChopBack(WorldName.Size() - LastUnderscore);
}
// MP3 - Remove text after last underscore
else if (mGame == EGame::Corruption)
{
uint32 LastUnderscore = WorldName.LastIndexOf('_');
if (LastUnderscore != -1 && !WorldName.StartsWith("front_end_"))
WorldName = WorldName.ChopBack(WorldName.Size() - LastUnderscore);
}
// DKCR - Remove text prior to first underscore
else if (mGame == EGame::DKCReturns)
{
uint32 Underscore = WorldName.IndexOf('_');
WorldName = WorldName.ChopFront(Underscore + 1);
}
}
return WorldName;
}