#include "IronsHost.h" #include "Modules/ModuleManager.h" #include "Misc/Paths.h" #include "Misc/FileHelper.h" #include "Misc/CommandLine.h" #include "Misc/Parse.h" #include "Misc/ConfigCacheIni.h" #include "HAL/PlatformFileManager.h" #include "Serialization/JsonReader.h" #include "Serialization/JsonSerializer.h" #include "Windows/AllowWindowsPlatformTypes.h" #include #include "Windows/HideWindowsPlatformTypes.h" IMPLEMENT_MODULE(FDefaultModuleImpl, UEAdapter) namespace { std::string Utf8(const FString& S){FTCHARToUTF8 U(*S);return std::string(U.Get(),U.Length());} FString Wide(std::string_view S){return FString(UTF8_TO_TCHAR(std::string(S).c_str()));} bool ReadValidated(const FString& Path,std::string& Bytes){ TArray Data;if(!FFileHelper::LoadFileToArray(Data,*Path))return false; Bytes.assign(reinterpret_cast(Data.GetData()),Data.Num());gf::Profile P;return static_cast(gf::decode(Bytes,P)); } struct FDiskStorage : gf::IProfileStorage { FString Path; explicit FDiskStorage(FString P):Path(MoveTemp(P)){} gf::Result load(std::string& Bytes)override { auto& Files=FPlatformFileManager::Get().GetPlatformFile(); if(!Files.FileExists(*Path)&&!Files.FileExists(*(Path+TEXT(".bak"))))return gf::Result::fail(gf::Code::Missing); if(ReadValidated(Path,Bytes)||ReadValidated(Path+TEXT(".bak"),Bytes))return {}; return gf::Result::fail(gf::Code::Corrupt,"Both profile and backup are unreadable; files preserved"); } gf::Result commitAtomically(std::string_view Bytes)override { gf::Profile Candidate;if(!gf::decode(Bytes,Candidate))return gf::Result::fail(gf::Code::Corrupt); auto& Files=FPlatformFileManager::Get().GetPlatformFile();Files.CreateDirectoryTree(*FPaths::GetPath(Path)); std::string Old;bool Exists=Files.FileExists(*Path),Valid=Exists&&ReadValidated(Path,Old); if(Exists&&!Valid&&!ReadValidated(Path+TEXT(".bak"),Old))return gf::Result::fail(gf::Code::Corrupt,"Refusing to overwrite damaged progress"); FString Temp=Path+TEXT(".tmp"); HANDLE File=CreateFileW(*Temp,GENERIC_WRITE,0,nullptr,CREATE_ALWAYS,FILE_ATTRIBUTE_NORMAL|FILE_FLAG_WRITE_THROUGH,nullptr); if(File==INVALID_HANDLE_VALUE)return gf::Result::fail(gf::Code::SaveFailed,"Create temporary profile"); DWORD Written=0;bool Ok=WriteFile(File,Bytes.data(),static_cast(Bytes.size()),&Written,nullptr)&&Written==Bytes.size(); Ok=FlushFileBuffers(File)&&Ok;CloseHandle(File); if(Ok){ if(Valid)Ok=ReplaceFileW(*Path,*Temp,*(Path+TEXT(".bak")),0,nullptr,nullptr)!=0; else Ok=MoveFileExW(*Temp,*Path,MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH)!=0; } if(!Ok){Files.DeleteFile(*Temp);return gf::Result::fail(gf::Code::SaveFailed,"Atomic replacement failed");} return {}; } }; struct FGuidIds : gf::IIdGenerator {std::string next()override{return Utf8(FGuid::NewGuid().ToString(EGuidFormats::Digits));}}; void LoadTable(const FString& Path,TMap& Table){ FString Json;TSharedPtr Root; if(FFileHelper::LoadFileToString(Json,*Path)&&FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(Json),Root)&&Root){ const TArray>* Entries=nullptr;if(Root->TryGetArrayField(TEXT("entries"),Entries))for(auto& E:*Entries){auto O=E->AsObject();if(O)Table.Add(O->GetStringField(TEXT("key")),O->GetStringField(TEXT("value")));} } } } class FIronsSessionHost { public: FDiskStorage Storage;FGuidIds Ids;gf::Session Session; explicit FIronsSessionHost(const FString& Path):Storage(Path),Session(Storage,Ids){} }; void FIronsHostDeleter::operator()(FIronsSessionHost* Ptr)const{delete Ptr;} UIronsGameInstance::UIronsGameInstance()=default; UIronsGameInstance::~UIronsGameInstance()=default; void UIronsGameInstance::Init(){ Super::Init(); DataRoot=FPaths::ConvertRelativePathToFull(FPaths::ProjectSavedDir()/TEXT("IRONSPORE")); FString Override;if(FParse::Value(FCommandLine::Get(),TEXT("IronsporeDataRoot="),Override))DataRoot=FPaths::ConvertRelativePathToFull(Override);FPaths::NormalizeDirectoryName(DataRoot); Campaign.Reset(new FIronsSessionHost(DataRoot/TEXT("Campaign/profile-v1.json"))); bReady=Accept(Campaign->Session.load()); if(bReady&&Campaign->Session.state().run.destructionPending)bReady=Accept(Campaign->Session.finish(true)); FString Raw=FPaths::ProjectContentDir()/TEXT("IRONSPORE/Raw"); LoadTable(Raw/TEXT("Localization/zh-Hans.json"),Chinese);LoadTable(Raw/TEXT("Localization/en.json"),English); Keys={TEXT("W"),TEXT("S"),TEXT("A"),TEXT("D"),TEXT("SpaceBar"),TEXT("LeftShift"),TEXT("Q"),TEXT("R"),TEXT("V"),TEXT("E"),TEXT("Tab"),TEXT("G"),TEXT("F"),TEXT("LeftMouseButton"),TEXT("RightMouseButton")}; PreferencesIni=GGameUserSettingsIni; if(FParse::Param(FCommandLine::Get(),TEXT("IronsporeQA"))&&DataRoot.Contains(TEXT("/QA/"))){ PreferencesIni=DataRoot/TEXT("UserPreferences.ini");FConfigFile Preferences; if(FPlatformFileManager::Get().GetPlatformFile().FileExists(*PreferencesIni))Preferences.Read(PreferencesIni); // UE 5.8 does not create unknown files through GConfig::SetBool/SetInt. Preferences.NoSave=false;Preferences.bCanSaveAllSections=true;GConfig->Add(PreferencesIni,Preferences); } GConfig->GetBool(TEXT("IRONSPORE"),TEXT("English"),bEnglish,PreferencesIni); GConfig->GetFloat(TEXT("IRONSPORE"),TEXT("Sensitivity"),Sensitivity,PreferencesIni); GConfig->GetFloat(TEXT("IRONSPORE"),TEXT("AimSensitivity"),AimSensitivity,PreferencesIni); GConfig->GetFloat(TEXT("IRONSPORE"),TEXT("MasterVolume"),MasterVolume,PreferencesIni); GConfig->GetBool(TEXT("IRONSPORE"),TEXT("InvertY"),bInvertY,PreferencesIni); GConfig->GetBool(TEXT("IRONSPORE"),TEXT("ShowPerformance"),bShowPerformance,PreferencesIni); GConfig->GetInt(TEXT("IRONSPORE"),TEXT("PerformanceCorner"),PerformanceCorner,PreferencesIni);PerformanceCorner=FMath::Clamp(PerformanceCorner,0,3); TArray SavedKeys;GConfig->GetArray(TEXT("IRONSPORE"),TEXT("Keys"),SavedKeys,PreferencesIni);if(SavedKeys.Num()==Keys.Num())Keys=SavedKeys; } void UIronsGameInstance::Shutdown(){if(bReady)Campaign->Session.save();Training.Reset();Campaign.Reset();Super::Shutdown();} gf::Session& UIronsGameInstance::Core(){return bTraining?Training->Session:Campaign->Session;} const gf::Session& UIronsGameInstance::Core()const{return bTraining?Training->Session:Campaign->Session;} FString UIronsGameInstance::Label(const FString& Key)const {const auto& Table=bEnglish?English:Chinese;if(const FString* V=Table.Find(Key))return *V;return Key;} FText UIronsGameInstance::Text(const FString& Key)const{return FText::FromString(Label(Key));} FString UIronsGameInstance::ItemName(std::string_view Id)const { FString I=Wide(Id);FString Key=TEXT("item.")+I;FString R=Label(Key); if(R!=Key)return R;Key=TEXT("equipment.")+I+TEXT(".name");R=Label(Key);return R==Key?I:R; } void UIronsGameInstance::SetLanguage(bool EnglishLanguage){bEnglish=EnglishLanguage;SavePreferences();} void UIronsGameInstance::SavePreferences(){ GConfig->SetBool(TEXT("IRONSPORE"),TEXT("English"),bEnglish,PreferencesIni); GConfig->SetFloat(TEXT("IRONSPORE"),TEXT("Sensitivity"),Sensitivity,PreferencesIni); GConfig->SetFloat(TEXT("IRONSPORE"),TEXT("AimSensitivity"),AimSensitivity,PreferencesIni); GConfig->SetFloat(TEXT("IRONSPORE"),TEXT("MasterVolume"),MasterVolume,PreferencesIni); GConfig->SetBool(TEXT("IRONSPORE"),TEXT("InvertY"),bInvertY,PreferencesIni); GConfig->SetBool(TEXT("IRONSPORE"),TEXT("ShowPerformance"),bShowPerformance,PreferencesIni); GConfig->SetInt(TEXT("IRONSPORE"),TEXT("PerformanceCorner"),FMath::Clamp(PerformanceCorner,0,3),PreferencesIni); GConfig->SetArray(TEXT("IRONSPORE"),TEXT("Keys"),Keys,PreferencesIni); if(PreferencesIni==GGameUserSettingsIni)GConfig->Flush(false,PreferencesIni); else if(auto File=GConfig->FindConfigFile(PreferencesIni)){ FPlatformFileManager::Get().GetPlatformFile().CreateDirectoryTree(*FPaths::GetPath(PreferencesIni));File->Write(PreferencesIni); } } bool UIronsGameInstance::Accept(const gf::Result& R){ if(R){Notice(TEXT("ue.saved"));return true;} static const TCHAR* Errors[]={TEXT("ue.saved"),TEXT("ue.invalid"),TEXT("ue.missing"),TEXT("ue.locked"),TEXT("ue.budget"),TEXT("ue.funds"),TEXT("ue.duplicate"),TEXT("ue.saveFailed"),TEXT("ue.corrupt"),TEXT("ue.unsupported")}; // Codes include NoSpace before Locked; keep explicit mapping. switch(R.code){case gf::Code::NoSpace:Notice(TEXT("ue.noSpace"));break;case gf::Code::Locked:Notice(TEXT("ue.locked"));break;case gf::Code::Budget:Notice(TEXT("ue.budget"));break;case gf::Code::Funds:Notice(TEXT("ue.funds"));break;case gf::Code::Duplicate:Notice(TEXT("ue.duplicate"));break;case gf::Code::SaveFailed:Notice(TEXT("ue.saveFailed"));break;case gf::Code::Corrupt:Notice(TEXT("ue.corrupt"));break;case gf::Code::Unsupported:Notice(TEXT("ue.unsupported"));break;default:Notice(Errors[FMath::Min(static_cast(R.code),2)]);break;} return false; } void UIronsGameInstance::Notice(const FString& Key,const FString& Detail){StatusKey=Key;StatusDetail=Detail;StatusUntil=FPlatformTime::Seconds()+5;} bool UIronsGameInstance::BeginSortie(){if(!bReady)return false;if(Core().state().run.active)return true;Combat={};return Accept(Core().begin(FMath::Rand()));} bool UIronsGameInstance::FinishSortie(bool Destroyed){bool Ok=Accept(Core().finish(Destroyed));if(Ok)Notice(Destroyed?TEXT("ue.destroyed"):TEXT("ue.extracted"));return Ok;} void UIronsGameInstance::EnterTraining(){ if(bTraining)return;Training.Reset(new FIronsSessionHost(DataRoot/TEXT("Training/profile-v1.json"))); if(!Accept(Training->Session.load())){Training.Reset();return;} bTraining=true;if(!Core().state().run.active){Core().seedAcceptanceStock();Core().begin(7001,true);}Combat={}; } void UIronsGameInstance::LeaveTraining(){if(!bTraining)return;Core().finish(false);Training.Reset();bTraining=false;Combat={};} bool RunIronsHostAcceptance(const FString& Root,FString& Report){ if(!Root.Contains(TEXT("/QA/"))){Report=TEXT("Isolated QA root required");return false;} int Passed=0,Failed=0;auto Check=[&](bool Value,const TCHAR* Name){if(Value)Passed++;else Failed++;Report+=FString::Printf(TEXT("%s: %s\n"),Value?TEXT("PASS"):TEXT("FAIL"),Name);}; FString Folder=Root/FGuid::NewGuid().ToString(EGuidFormats::Digits);FIronsSessionHost A(Folder/TEXT("campaign-v1.json"));auto& S=A.Session; Check(static_cast(S.load()),TEXT("Create atomic V1 profile"));Check(S.state().version==1&&S.state().warehouse.size()==4,TEXT("Fresh owned baseline")); Check(static_cast(S.begin(20261113)),TEXT("Deploy via independent library"));Check(static_cast(S.addLoot("alloy",4,"ue-source")),TEXT("Loot commit")); auto Uid=S.state().run.cargo.front().uid;Check(static_cast(S.recoverPod({Uid})),TEXT("Pod banks once"));Check(!S.recoverPod({Uid}),TEXT("Pod replay rejected")); Check(static_cast(S.addLoot("cells",3,"ue-source2")),TEXT("Second loot commit"));auto SafeUid=S.state().run.cargo.front().uid;Check(static_cast(S.moveItem(SafeUid,gf::Hold::Cargo,gf::Hold::SafeCase)),TEXT("Secure case unique transfer")); Check(static_cast(S.finish(true)),TEXT("Two-phase death finalization"));FIronsSessionHost B(Folder/TEXT("campaign-v1.json"));Check(static_cast(B.Session.load()),TEXT("Reload real disk profile"));Check(gf::total(B.Session.state().warehouse,"alloy")==4&&gf::total(B.Session.state().warehouse,"cells")==3,TEXT("Recovered ownership survives restart")); FString Broken=TEXT("{broken-primary");FFileHelper::SaveStringToFile(Broken,*A.Storage.Path,FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM);FIronsSessionHost C(A.Storage.Path);Check(static_cast(C.Session.load()),TEXT("Recover valid atomic backup")); std::string Backup;Check(ReadValidated(A.Storage.Path+TEXT(".bak"),Backup),TEXT("Backup remains valid"));Check(static_cast(C.Session.state().run.destructionPending?C.Session.finish(true):C.Session.save()),TEXT("Finalize recovered pending death while preserving backup"));Check(ReadValidated(A.Storage.Path+TEXT(".bak"),Backup),TEXT("Repair did not replace backup with corrupt data"));Check(gf::total(C.Session.state().warehouse,"cells")==3,TEXT("Pending backup recovery banks secure cargo exactly once")); FFileHelper::SaveStringToFile(Broken,*A.Storage.Path,FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM);FFileHelper::SaveStringToFile(Broken,*(A.Storage.Path+TEXT(".bak")),FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM); FIronsSessionHost D(A.Storage.Path);Check(!D.Session.load(),TEXT("Both damaged files rejected"));auto Commit=D.Storage.commitAtomically(gf::encode(B.Session.state()));Check(!Commit,TEXT("Corrupt profile cannot be overwritten"));FString Preserved;FFileHelper::LoadFileToString(Preserved,*A.Storage.Path);Check(Preserved==Broken,TEXT("Corrupt original preserved")); FIronsSessionHost Trial(Folder/TEXT("Training/profile-v1.json"));Check(static_cast(Trial.Session.load()),TEXT("Training store independent"));Check(Trial.Session.state().sorties==0&&B.Session.state().sorties==1,TEXT("Training does not mutate campaign")); Report+=FString::Printf(TEXT("%d passed, %d failed\n"),Passed,Failed);FFileHelper::SaveStringToFile(Report,*(Folder/TEXT("host-results.txt")),FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM);return Failed==0; }