Files

150 lines
13 KiB
C++

#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 <Windows.h>
#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<uint8> Data;if(!FFileHelper::LoadFileToArray(Data,*Path))return false;
Bytes.assign(reinterpret_cast<const char*>(Data.GetData()),Data.Num());gf::Profile P;return static_cast<bool>(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<DWORD>(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<FString,FString>& Table){
FString Json;TSharedPtr<FJsonObject> Root;
if(FFileHelper::LoadFileToString(Json,*Path)&&FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(Json),Root)&&Root){
const TArray<TSharedPtr<FJsonValue>>* 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<FString> 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<int>(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<bool>(S.load()),TEXT("Create atomic V1 profile"));Check(S.state().version==1&&S.state().warehouse.size()==4,TEXT("Fresh owned baseline"));
Check(static_cast<bool>(S.begin(20261113)),TEXT("Deploy via independent library"));Check(static_cast<bool>(S.addLoot("alloy",4,"ue-source")),TEXT("Loot commit"));
auto Uid=S.state().run.cargo.front().uid;Check(static_cast<bool>(S.recoverPod({Uid})),TEXT("Pod banks once"));Check(!S.recoverPod({Uid}),TEXT("Pod replay rejected"));
Check(static_cast<bool>(S.addLoot("cells",3,"ue-source2")),TEXT("Second loot commit"));auto SafeUid=S.state().run.cargo.front().uid;Check(static_cast<bool>(S.moveItem(SafeUid,gf::Hold::Cargo,gf::Hold::SafeCase)),TEXT("Secure case unique transfer"));
Check(static_cast<bool>(S.finish(true)),TEXT("Two-phase death finalization"));FIronsSessionHost B(Folder/TEXT("campaign-v1.json"));Check(static_cast<bool>(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<bool>(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<bool>(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<bool>(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;
}