Sav 파일

메모리 카드나 하드 드라이브와 같은 저장 매체와 함께 사용하도록 개발된 데이터 파일 형식

언리얼은 윈도우 기준으로 Saved/SavedGames 폴더에 .sav 파일로 저장된다.

이 파일을 사용하려면 USaveGame 클래스를 상속받아야 한다.

 

 

Serialize(직렬화)

객체를 바이트 스트림 형태로 연속(일직선)적인 데이터로 변환하는 것

현재 오브젝트의 상태를 보관하고, 다른 컴퓨터 환경에서 동일한 오브젝트를 만들어주는 기법?

 

어떠한 언어든 데이터의 메모리 구조는 크게 다음 2가지로 나뉜다.

값 형식 데이터 오브젝트(레퍼런스) 형식 데이터
integer, float(single), charactor(또는 char 의 집합인 string) 등 메모리 번지(주소, Address)값 --> 주소값을 최종적으로 따라가면 값 형식 데이터를 참조 하게 됨.

이 중에서 저장 / 전송 가능한 데이터는 값 형식 데이터

-> 서로 물리적으로 사용중인 메모리 공간(OS의 가상메모리 포함) 은 일치하지 않기 때문

-> 같은 컴퓨터에서 같은 코드를 동작시켜도 인스턴스들의 주소값은 항상 바뀌기 때문

 

해당 인스턴스의 오브젝트 형식 데이터를 -> 값 형식 데이터로 변환하는 작업을 Serialization(직렬화) 이라한다.

이렇게 직렬화 된 데이터 형식은 텍스트 / 바이너리 등의 형식을 띄게된다.

 

컴퓨터 메모리 설계상 큰 데이터 덩어리를 순차적으로 읽어오는 것이 가장 빠르기 때문에 직력화된 데이터는 RDBMS 구조랑 다르게, 대게 일직선의 연속적인 값들의 집합인 형태를 띄게 됨.

 

이렇게 직렬화(분해)한 데이터를 다른 컴퓨터 또는 환경에 전송/저장 하여 역직렬화하여 같은 오브젝트를 만들어 줄 수 있다.

 

언리얼에서 마찬가지로 UObject (포인터 이므로) 에 대한 정보를 직렬화하여 저장하려고 할 때, 오브젝트 형식 데이터를 저장 할 수 없으므로 해당 인스턴스의 멤버변수들의 값 들을 저장해야한다.

 

 

 

아카이브 (Archive)

언리얼에서 Serialize에 대한 모든 동작은 FArchive를 상속받아서 구현

다른 플랫폼이나 디스크 메모리 등 다양한 매체에 맞게 직렬화 / 역직렬화 할 수 있게 멀티플랫폼에서 동작하는 매체의 규약의 Base Class

(바이너리 / 압축 / 버퍼 등 모두 해당 클래스를 상속받아서 동작한다.)

/*
* Base class for archives that can be used for loading, saving, and garbage
* collecting in a byte order neutral way.
*/
class FArchive : private FArchiveState

// 로드, 저장, 가비지 등에 사용할 수 있는 아카이브의 기본 클래스
// 중립적인 방식으로 바이트 순서로 수집합니다.

UObject 를 Serialize 하기 위해선 해당 구조체를 이용해야 한다.

/**
 * Implements a proxy archive that serializes UObjects and FNames as string data.
 *
 * Expected use is:
 *    FArchive* SomeAr = CreateAnAr();
 *    FObjectAndNameAsStringProxyArchive Ar(*SomeAr);
 *    SomeObject->Serialize(Ar);
 *    FinalizeAr(SomeAr);
 * 
 * @param InInnerArchive The actual FArchive object to serialize normal data types (FStrings, INTs, etc)
 */
struct FObjectAndNameAsStringProxyArchive : public FNameAsStringProxyArchive
{
    /**
     * Creates and initializes a new instance.
     *
     * @param InInnerArchive - The inner archive to proxy.
     * @param bInLoadIfFindFails - Indicates whether to try and load a ref'd object if we don't find it
     */
    FObjectAndNameAsStringProxyArchive(FArchive& InInnerArchive, bool bInLoadIfFindFails)
        :	FNameAsStringProxyArchive(InInnerArchive)
        ,	bLoadIfFindFails(bInLoadIfFindFails)
    { }

    /** If we fail to find an object during loading, try and load it. */
    bool bLoadIfFindFails;

    COREUOBJECT_API virtual FArchive& operator<<(UObject*& Obj) override;
    COREUOBJECT_API virtual FArchive& operator<<(FWeakObjectPtr& Obj) override;
    COREUOBJECT_API virtual FArchive& operator<<(FSoftObjectPtr& Value) override;
    COREUOBJECT_API virtual FArchive& operator<<(FSoftObjectPath& Value) override;
    COREUOBJECT_API virtual FArchive& operator<<(FObjectPtr& Obj) override;
};

해당 구조체를 보면 UObject 와 ObjectPtr에 대한 << 연산자가 정의되어있다. 

보통 C++의 경우 << (출력)  >> (입력) 으로 사용하는데 FArchive에는 << 연산자 밖에없음

 

 

구현

Struct -> (포인터를 사용하지않는)단순 구조체 값 직렬화 (컴파일)

UObject -> 인스턴스의 런타임에 필요한 메타데이터 필요 

 

Struct 의 경우 operator<< 을 전역함수로 구현하면되고, UObject의 경우 Serailize 함수를 가지고있어 오버라이드 해서 사용하면 된다.

USTRUCT()
struct FTestStruct
{
    GENERATED_BODY()

    UPROPERTY()
    int32 TestInt;

    UPROPERTY()
    FString TestString;

    // 구조체 Serialize
    friend FORCEINLINE FArchive& operator <<(FArchive& Ar, FTestStruct& TheStruct)
    {
        Ar << TheStruct.TestInt;
        Ar << TheStruct.TestString;
        return Ar;
    }
};

UCLASS()
class TEST_API UTestObject : public UObject
{
    GENERATED_BODY()
    
public:
    virtual void Serialize(FArchive& Ar) override
    {
        Super::Serialize(Ar);
        Ar << ItemCmsId;
        Ar << ItemDBId;
        Ar << ItemCount;
    }
    
    UPROPERTY()
    int32 A;
    UPROPERTY()
    int64 B;
    UPROPERTY()
    int32 C;
}

Serialize는 Archive 의 옵션에 따라 기록되는 데이터가 좀 다르기 때문에 필요하다면 FArchive인자를 받은 함수를 직접 정의해서 사용해도 상관 없다.

 

 

TestUObject 를 는 직렬화 과정을 하면 코드 순서대로 TestFloat / TestArray / TestStruct 가 기록된다.

이 멤버변수 값들을 기록할 FObjectRecord라는 구조체를 하나 정의해야한다.

USTRUCT()
struct FObjectRecord
{
    GENERATED_BODY()

    UPROPERTY()
    UClass* ObjectClass;

    UPROPERTY()
    FString ObjectName;

    UPROPERTY()
    TArray<uint8> ObjectData;
};

// UObject의 데이터 담는 구조체 반환
FObjectRecord UTestSaveGameManager::CreateObjectRecord(UObject* Element)
{
    FObjectRecord ObjectRecord;
    ObjectRecord.ObjectClass = Element->GetClass();
    ObjectRecord.ObjectName = Element->GetName();

    FMemoryWriter MemoryWriter(ObjectRecord.ObjectData, true);

    FObjectAndNameAsStringProxyArchive Ar(MemoryWriter, true);
    Element->Serialize(Ar);

    return ObjectRecord;
}

// ObjectRecord에 저장된 데이터 기반으로 UObject생성
UObject* UTestSaveGameManager::DeSerializeObjectRecord(const FObjectRecord& ObjectRecord)
{
    TArray<uint8> ObjectData = ObjectRecord.ObjectData;

    FMemoryReader MemReader(ObjectData);
    UObject* CreatedObject = NewObject<UObject>(GetTransientPackage(), ObjectRecord.ObjectClass);

    FObjectAndNameAsStringProxyArchive Ar(MemReader, true);
    CreatedObject->Serialize(Ar);

    return CreatedObject;
}

ObjectData를 보면 TArray<uint8>로 되어있는데 이는 바이트 단위로 처리하기위해 적절하기 때문

int 나 char은  플랫폼마다 크기나 부호가 다를 수 있지만, uInt8은 어느 플랫폼이나 같기 떄문

 

이렇게 하면 UObject를 보내주게되면 ObjectRecord에 Element의 정보들이

해당 실제 클래스 / 인스턴스 이름 / ObjectData (TestUObject 경우 TestFloat / TestArray / TestStruct) 가 차례대로 Archive에 쓰여진다.

FMemoryWriter TArray<uint8> Bytes 멤버변수를 가지고 있고, 바이트 단위로 기록된다.

예를들어 다음과 같은 ObjectData가 기록되어있다 할 때

 

1Byte = 8bit = 4bit + 4bit = 16진수 + 16진수

 UTestObject
10진수
16진수
Little Endian
(하위 바이트부터 먼저 저장)
저장된 바이트
A
104000001
0x0632EA01
[0x01, 0xEA, 0x32, 0x06]
[1, 234, 50, 6]
B
4,673
0x1241
[0x41, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
[65, 18, 0, 0, 0, 0, 0, 0]
C
99
0x63
[0x63, 0x00, 0x00, 0x00]
[99, 0, 0, 0]
 

Bytes 에는 << 연산자 순서대로 기록되기 때문에 코드 순서를 바꾸거나 데이터 값을 바꾸는데 유의해야 한다.

FMemoryReader 마찬가지로 데이터를 읽은 후 TArray<uint8> Bytes 에서 << 연산자로 값을 가져올 수 있다.

 

 

이제 쓰여진 Archive를 .sav 파일로 만들어야한다.

UCLASS(BlueprintType)
class TEST_API UTestSaveData : public USaveGame
{
	GENERATED_BODY()

public:
    // All object data in one array
    UPROPERTY()
    TArray<FObjectRecord> SavedObjects;
};

void UTestSaveGameManager::SaveGameData(const FString& SlotName, TArray<UObject*>& GameData)
{
    UTestSaveData* CreatedTestSaveData = Cast<UTestSaveData>(UGameplayStatics::CreateSaveGameObject(UTestSaveData::StaticClass()));

    for (UObject* Element : GameData)
    {
        const FObjectRecord& ObjectRecord = CreateObjectRecord(Element);
        CreatedTestSaveData->SavedObjects.Add(ObjectRecord);
    }

    UGameplayStatics::AsyncSaveGameToSlot(CreatedTestSaveData, SlotName, staticUserIndex, OnCompletedSaveGameDataDelegate);
}

 

UGameplayStatics 클래스 내부에 동기 저장 / 비동기 저장 방식이 따로 있고, SlotName 과 UserIndex 파라미터를 같이 넘겨주어 저장할 수 있다. 

 

UserIndex 사용 안함

 

언리얼 공식문서와는 달리 디버깅 해보니까 UserIndex는 쓰지도 않음

그래서 하나의 SlotName 파일에 덮어 씌우고 로드 한다.

(어떤 사이트에선 H5 (Html5) 에서만 사용한다고)

 

여기까지하면 Saved/SaveGames 폴더에 .sav 파일이 저장되는것을 확인할 수 있다.

 

대충 .sav 파일 리더로 확인해보면

위에서 인스턴스 클래스 / 인스턴스 이름 / 멤버변수 값 차례대로 저장한 그대로 쓰여진다. 

 

 

UObject를 상속받은 인스턴스들을 저장하거나 불러올 때는 위처럼 FObjectRecord 에 담아서 그대로 생성하고 값을 세팅해주면되고, 그 외의 TMap / TArray  (int / FString / float 같으 기본 자료형일 때) 등의 언리얼에서 사용하는 것들은 그대로 저장 / 로드가 가능하다.

'언리얼' 카테고리의 다른 글

모바일 EnableGestureRecognizer 관련  (0) 2025.01.02
AsyncTask 간단 예제  (0) 2024.12.16
SceneCaptureComponent2D  (0) 2024.06.26
ChunkDownloader  (0) 2024.05.02
소프트 포인터  (1) 2024.02.19

+ Recent posts