유니티 게임의 한글화와 패치 제작 – 유니티 에셋의 Serialization

유니티 엔진이 프로젝트를 빌드하면 에셋들은 일정한 규칙대로 assets 파일들에 합쳐진다. 이때 각 에셋들은 유니티 엔진에 기술된 방법대로 Serialized 된다. 에셋의 Dump를 추출할 수 있고, 이를 수정하는 것이 편리하나 특별한 이유에 의해 에셋의 Raw 파일이 필요할 때가 있다. 에셋의 Raw 형태에서 특정한 변수의 값을 확인, 수정하기 위해서는 유니티 엔진의 Serialization에 대해 알아보도록 한다.

유니티 게임 우리말화 도구 베타 버전을 공개하였습니다. 이에 관한 설명은 GitHub의 wiki에서 서술합니다. https://github.com/dmc31a42/UnityL10nTool/wiki

유니티 게임의 한글화와 패치 제작 – 서론 으로 돌아가기

본 강좌는 70~80% 완성되면 바로 공개되며, 잘못된 부분이 있으면 그때 그때 수정하는 것으로 하겠습니다. 아래의 강좌 내용은 언제든지 수정될 수 있습니다. 질문은 댓글로 달아주시거나 GitHub(https://github.com/dmc31a42/UnityGameL10nTutorial/)의 issue를 활용하여주시면 감사하겠습니다.

C#의 Serialization

에셋이 어떻게 Binary로 저장되는지 이해하기 위하여 C#의 Serialization 과정을 먼저 살펴보고 유니티 에셋의 Serialization을 살펴보도록 한다.

https://docs.microsoft.com/ko-kr/dotnet/csharp/programming-guide/concepts/serialization/walkthrough-persisting-an-object-in-visual-studio 에 기술되어있는 방법대로 따라해도 아래의 설명을 따라할 수 있고, 클래스의 인스턴스에 저장된 값을 보기 편하게 수정되어있는 https://github.com/dmc31a42/UnityGameL10nTutorial 의 UnitySerialization 프로젝트를 실행해서 따라해도 좋다. 아래의 설명은 GitHub에 있는 예제를 기준으로 설명한다.

프로젝트를 컴파일 한 후 (Form에 나타나있는 값을 일부 수정한 다음) Form의 ‘X’ 버튼을 통하여 창을 닫으면(Visual Studio에 있는 디버그 중지버튼을 누르면 안된다.) 프로젝트 파일(.csproj)이 있는 위치에 ‘SavedLoan.bin’ 파일이 생성된다.

해당 파일을 Hex Editor로 열어두고, 예제 프로그램을 다시 실행한 모습은 아래와 같다.

Form of program and serialization result. In left of form, '10000', '0.075', '36', 'Neil Black', and '1 2 3 4 5 6 7' values are written. In right of form, binary result of right textbox.

Form의 좌측편에 나타난 값을 바꾸고 ‘Save’ 버튼을 눌러(마이크로소프트에서 제공하는 예제는 창을 닫아야 저장이된다. 이 때문에 예제에서는 ‘Save’ 버튼을 만들었다.) Serialization된 결과 파일이 어떻게 바뀌는지 살펴본다. 위의 에제에 나와있는 것 처럼 입력된 값이 ‘10000’, ‘0.075’, ’36’, ‘Neil Black’, ‘1 2 3 4 5 6 7’일 때 ‘SavedLoan.bin’에 저장된 값은 다음과 같다.

offset value description
000000E0 000000000088C340 Bytes of 10000 as Double
000000E8 333333333333B33F Bytes of 0.075 as Double
000000F0 24000000 Bytes of 36 as Int32
000000F4 0603 Unknown
000000F6 0000000A Length of string(below) == 10
000000FA 4E65696C20426C61 Bytes of ‘Neil Bla’ as string(ASCII)
00000102 636B (continue from above) ‘ck’
00000104 09040000000F04 Unknown
0000010B 00000007 Length of Int array(below)
0000010F 00000008 Unknown
00000113 01000000 Bytes of 1 as Int
00000117 02000000 Bytes of 2 as Int
0000011B 03000000 Bytes of 3 as Int
0000011F 04000000 Bytes of 4 as Int
00000123 05000000 Bytes of 5 as Int
00000127 06000000 Bytes of 6 as Int
0000012B 07000000 Bytes of 7 as Int
0000012F 0B Unknwon

Form에 있는 Double, Int, String, Int[](Array)에 있는 값들을 바꿔보면서 ‘SavedLoan.bin’ 파일에 저장된 데이터의 변화를 직접 확인해보도록 한다. 이렇게 클래스의 인스턴스를 바이너리, xml, json, 네트워크 전송 등을 통하여 저장, 전달하는 것과 저장, 전달하는 법에 일정한 규칙을 부여한 것이 Serialization 이다.

Unity Engine Serialization

유니티 엔진에서 에셋을 Serialize할 때 c#과 비교하여 다음과 같은 특징을 가진다.
(주의: 아래의 비교는 검증되지 않은 주관적인 부분이 많이 포함되어 있으므로 이 경고 문구가 제거되기 전까지는 걸러들을 것은 걸러 들어야 한다.)

  • C#에서는 Big, Little-Endian이 혼용된 것과 달리. 유니티에서는 Binary 파일에 기록된 모든 value는 Little-Endian로 기록된다.
  • C#에서는 클래스가 변경되었을 경우를 대비하여 멤버의 Type과 변수명이 Binary 파일에 기록되어있지만, 유니티에서는 Binary파일에 멤버의 Type과 변수명을 나타내는 어떠한 힌트도 기록되어있지 않고 value만 기록되어있다. (클래스의 버전은 유니티 엔진의 버전으로 관리한다.)
  • 위에서 Unknwon으로 표기된 부분은 사실 [MS-NRBF]-170316 스펙을 보면 Type에 관한 Spec이다. 각 Array에는 Type 검증용으로 추정되는 측정 바이트가 기록되어있지만, 유니티의 Array의 경우 배열은 배열의 크기 + 원소의 바이트 변환이 연속적으로 기록되어있을 뿐이다.

Type

Serialize될 때 멤버의 type에 따라 변환되는 데이터의 형태는 다음과 같다. 해당 데이터의 구분은 UABE를 소개할 때도 알려주었다. 상세한 사항은 https://docs.microsoft.com/ko-kr/dotnet/csharp/language-reference/keywords/built-in-types-table 에서 확인할 것.

  • Type이 System 변수인 경우
    : UInt, Int, float(single), double, string 등
    필드에 type과 Field이름, 값이 함께 표시되어있으며, 자식 Field는 존재하지 않는다.
    C#에서 사용하는 System type은 수와 문자열, TypelessData(byte[]), Boolean가 있다.

    • 정수
      • 부호가 없는 정수
        부호가 없는 정수는 00 00(UInt16기준)이 0을 의미하고, FF FF가 65535가 된다. FF FF에서 1을 더하면 00 00 01이 되고 UInt16은 2 byte type이므로 뒤의 01은 무시된다. 따라서 00 00이 된다. (이를 Overflow라 부른다.)

        .Net
        형식
        C#
        형식
        범위 크기
        byte
        Byte byte 0 ~ 255 1
        UInt16 ushort 0 ~ 65,535 2
        UInt32 uint  0 ~ 4,294,967,295 4
        UInt64 ulong  0 ~ 18,446,744,073,709,551,615 8
      • 부호가 있는 정수
        부호가 있는 정수는 Int16을 기준으로 00 00이 0을 의미하고, FF 7F(유니티의 Serialization은 항상 Big-Endian으로 저장된다)이 32,767을 의미, 00 80은 -32,768, FF FF는 -1을 의미하고, 여기에 1을 더하면 00 00 01이 되지만 원래 Int16은 2byte type이므로 뒤의 01은 무시된다.(이를 Overflow라 부른다.)

        .Net
        형식
        C#
        형식
        범위 크기
        byte
        SByte sbyte -128 ~ 127 1
        Int16 short –32,768 ~ 32,767 2
        Int32 int –2,147,483,648 ~ 2,147,483,647 4
        Int64 long –9,223,372,036,854,775,808 ~9,223,372,036,854,775,807 8
    • 부동 소수점
      .Net
      형식
      C#
      형식
      근사 범위 유효숫자 크기
      byte
      Single float ±1.5e−45 ~ ±3.4e38 7
      자리
      4
      Double double ±5.0e−324 ~ ±1.7e308 15
      -16
      8
      Decimal decimal (-7.9x10e28 ~ 7.9x10e28)
      / (10e0 ~ 10e28)
      28
      -29
      16
    • 문자열과 TypelessData
      .Net
      형식
      C#
      형식
      내용물 크기
      byte
      String string 문자열의 길이(Int32) + 문자열 나열(UTF-8) 4+문자열의 길이
      TypelessData
      (유니티 전용)
      Raw의 크기(Int32)+이미지나 음악같은 파일의 Raw데이터(Byte) 4+Raw 파일의 크기
    • Boolean
      .Net
      형식
      C#
      형식
      범위 크기
      byte
      Boolean bool true or false 1
  • Base Type이 UnityEngine.Object 인 경우
    : Type 이 System의 변수가 아닌 객체 중 단독으로 Asset으로 존재하는 경우
    Type은 PPtr으로 표시되고 Field 이름이 존재하고, 자식 Field에 해당 Asset의 Assets 파일 위치(type: int, Field 이름: m_FieldID)와 그 Assets 파일에서의 Asset위치(type: SInt64, Field 이름: m_PathID)이 존재한다. Asset Data에서는 가리키는 Asset을 ‘[view asset]’으로 바로 확인할 수 있다.{PPtr<Object>의 구조}

    Int32(4byte) Int64(8byte) 크기(byte)
    m_FileID m_PathID 12
  • Base Type이 System.Object 인 경우
    : 위의 두 조건을 만족하지 않는 나머지 type
    필드는 type과 이름으로 구성되어있고, 자식 필드는 해당 필드의 멤버 변수로 구성되어있다. 별도의 에셋으로 저장되지 않은 클래스나 struct의 인스턴스, Array의 경우 본 항목에 해당한다.
    별도의 에셋으로 저장되지 않은 클래스나 struct의 인스턴스의 경우 해당 인스턴스의 Serialization결과가 그대로 저장되고, 크기는 그 인스턴스의 크기가 된다.
    Array의 경우 array에 들어있는 항목의 ‘갯수’가 Int32로 저장되고(배열이 Serialized 되었을 때의 전체 크기가 아니다), 저장되는 type의 인스턴스가 Serialized 되었을 때 결과를 순서대로 이어서 저장된다. Array 멤버의 크기는 ‘4byte+저장되는 type의 인스턴스가 Serialized되었을 때 byte 크기*항목의 갯수’가 된다.
  • 그리고 마지막으로 유니티 엔진의 Serialization에서 type 크기가 4 byte의 배수단위인 멤버는 메모리 또는 파일에서 값을 빠르게 읽어오기 위하여 주소가 4 byte 단위로 정렬되어 저장되어야한다. 1, 2 byte 크기의 멤버가 연속해서 배열로 저장될 때는 관계없으나 1, 2 byte 멤버 이후에 4 byte 멤버가 기록된다면 4 byte멤버의 주소를 4 byte의 배수로 만들기 위하여 1, 2 byte멤버와 4 byte멤버 사이에 패딩(NULL; 00h로 채워짐)이 추가된다.

이해를 돕기 위해, 위의 상황을 모두 갖춘 에셋을 하나 만들어보도록 한다. 여기서 중요한 조건은 다음과 같다.

  • 위에서 언급된 멤버들이 한번씩 나와야 한다.
  • Type이 System 변수인 경우, Base Type이 UnityEngine.Object 인 경우, Base Type이 System.Object 인 경우(인스턴스, Array 모두)가 모두 나와야 한다.
  • 1, 2 byte 멤버 뒤에 4 byte 멤버가 있어서서 4 byte 멤버가 시작되어야 하는 지점의 offset이 4 byte의 배수가 아닌 경우가 발생하여 Padding이 있어야 한다.
  • 1, 2 byte 멤버를 항목으로 가지는 배열이 있어야 한다.

유니티 에디터를 실행하여 새 스크립트를 만든다. 스크립트의 이름은 관계 없다. 만든 스크립트에는 위의 조건을 만족하는 멤버 변수를 만든다. 아래는 예시로 만든 클래스의 구현체이다.

|using System.Collections;
|using System.Collections.Generic;
|using UnityEngine;

|[System.Serializable]
|public class SerializeSubClass
|{
|    public byte byte1;
|    public int int1;
|    public string string1;
|}

|public class UnitySerializationExam : MonoBehaviour {

|    public byte byte1;
|    public byte byte2;
|    public ushort ushort1;
|    public uint uint1;
|    public ulong ulong1;
|    public sbyte sbyte1;
|    public short short1;
|    public int int1;
|    public long long1;
|    public float float1;
|    public double double1;
|    public decimal decimal1;
|    public string string1;
|   //
|    public bool bool1;
|    public UnitySerializationExam UnitySerializationExam1;
|    public SerializeSubClass SerializeSubClass1;
|    public byte[] bytes1;
|    public int[] ints1;
|    public SerializeSubClass[] SerializeSubClasses1;


|    // Use this for initialization
|    void Start () {
|    }

| // Update is called once per frame
|   void Update () {

|    }
|}

GameObject로 만들고 이렇게 만든 스크립트를 컴포넌트로 추가한다. 그리고, 추가한 MonoBehaviour의 각 멤버에 값을 임의의 값으로 수정한 결과는 다음과 같다. ‘Base Type이 UnityEngine.Object 인 경우’로 추가한 ‘UnitySerializationExam1’ 멤버의 값을 수정하려는 경우 GameObject를 복제하여 복제된 MonoBehaviour를 선택하면 된다.

Make example instance of serializable class(UnitySerializationExam) in 'GameObject' in Scene0, and properties are set.

만들어진 유니티 프로젝트는 GitHub의 UnitySerialization\UnityProject에서 확인할 수 있다. 이 상태로 Build를 하고, level0 파일을 UABE를 통해 열고, 본인이 작성한 class 이름을 갖는 MonoBehaviour를 ‘Export Raw’를 통해 추출하고 추출한 파일을 Hex Editor로 열어놓고 UABE에서는 ‘View Data’를 통해 열어 두 파일을 비교해보자.

Serialization result of UnitySerializationExam instance is seen in UABE and Hex Editor.

UnitySerializationExam의 구현체에 기록한 멤버 순서대로 저장이 되었다. 이제 raw 파일에 어떤식으로 저장되었는지 확인해보도록한다. 기본적으로 4바이트씩 끊어서 적어놓았고, 8바이트 변수는 8바이트를 붙여서 적었다. ()로 되어있는 부분은 padding 이고, __ 로 표시되어있는 부분은 4바이트를 1바이트씩 쪼개서 표시했을 때 위치를 맞추기 위해 넣은 표기이다(워드프레스에서는 그냥 공백을 가만히 놔두지 않고 지워버린다……)

Offset Value Description
00000000 00 00 00 00 PPtr<GameObject> m_GameObject -> int m_FileID = 0
00000004 04 00 00 00 00 00 00 00 PPtr<GameObject> m_GameObject -> SInt64 m_PathID = 4
0000000C 01 (00 00 00) UInt8 m_Enabled = 1
00000010 01 00 00 00 PPtr<MonoScript> m_Script -> int m_FileID = 1
00000014 43 00 00 00 00 00 00 00 PPtr<MonoScript> m_Script -> SInt64 m_PathID = 67
0000001C 10 00 00 00 Length of string m_Name (=16)
00000020 53 65 72 69 61 6C 69 7A part of string m_Name (=”Serializ”)
00000028 65 45 78 61 6D 70 6C 65 part of string m_Name (=”eExample”)
00000030 31 00 00 00 part of string m_Name (=”1″)
00000034 32 (00 00 00) UInt8 byte1 = 50
00000038 1B (00 00 00) UInt8 byte2 = 27
0000003C 16 0B (00 00) UInt16 ushort1 = 2838
00000040 69 42 01 00 unsigned int uint1 = 82537
00000044 14 5E 24 00 00 00 00 00 UInt64 ulong1 = 2383380
0000004C 7F (00 00 00) SInt8 sbyte1 = 127
00000050 FF 7F (00 00) SInt16 short1 = 32767
00000054 30 D3 08 00 int int1 = 578352
00000058 14 54 CB FC FF FF FF FF SInt64 long1 = -53783532
00000060 2E 50 6A 42 float float1 = 58.578300
00000064 06 D8 47 A7 AE C8 46 40 double double1 = 45.567830
0000006C 2B 00 00 00 Length of string string1 (=43)
00000070 54 68 69 73 20 69 73 20 part of string string1 (=”This is “)
00000078 65 78 61 6D 70 6C 65 2E part of string string1 (=”example.”)
00000080 20 EC 9D B4 EA B2 83 EC part of string string1 (=” 이것”(은))
00000088 9D 80 20 EC 98 88 EC A0 part of string string1 (=(은)” 예”(제))
00000090 9C EC 9E 85 EB 8B 88 EB part of string string1 (=(제)”입니”(다))
00000098 8B A4 2E (00) part of string string1 (=(다)”.”)
0000009C 01 (00 00 00) UInt8 bool1 = 1(원래는 bool이었음)
000000A0 00 00 00 00 PPtr<$UnitySerializationExam> UnitySerializationExam1 -> int m_FileID = 0
000000A4 13 00 00 00 00 00 00 00 PPtr<$UnitySerializationExam> UnitySerializationExam1 -> SInt64 m_PathID = 19
000000AC 78 (00 00 00) SerializeSubClass SerializeSubClass1 -> UInt8 byte1 = 120
000000B0/th> 93 E2 01 00 SerializeSubClass SerializeSubClass1 -> int int1 = 123539
000000B4 19 00 00 00 Length of SerializeSubClass SerializeSubClass1 -> string string1 (=25)
000000B8 53 65 72 69 61 6C 69 7A part of SerializeSubClass SerializeSubClass1 -> string string1 (=”Serializ”)
000000C0 65 53 75 62 43 6C 61 73 part of SerializeSubClass SerializeSubClass1 -> string string1 (=”eSubClas”)
000000C8 73 31 53 74 72 69 6E 67 part of SerializeSubClass SerializeSubClass1 -> string string1 (=”s1String”)
000000D0 31 (00 00 00) part of SerializeSubClass SerializeSubClass1 -> string string1 (=”1″)
000000D4 05 00 00 00 A number of items in Array bytes1(byte[]) = 5
000000D8 78 __ __ __ (UInt8 data) first item of Array bytes1 = 120
000000D9 __ 71 __ __ (UInt8 data) second item of Array bytes1 = 113
000000DA __ __ 32 __ (UInt8 data) third item of Array bytes1 = 50
000000DB __ __ __ 5C (UInt8 data) fourth item of Array bytes1 = 92
000000DC 29 (00 00 00) (UInt8 data) fifth item of Array bytes1 = 120
000000E0 04 00 00 00 A number of items in Array ints1(int[]) = 4
000000E4 BA B1 90 00 (int data) first item of Array ints1 = 9482682
000000E8 70 94 03 00 (int data) second item of Array ints1 = 234608
000000EC 64 62 FF FF (int data) third item of Array ints1 = -40348
000000F0 FC 05 07 00 (int data) fourth item of Array ints1 = 460284
000000F4 02 00 00 00 A number of items in Array SerializeSubClasses1(SerializeSubClass[]) = 4
000000F8 77 (00 00 00) (SerializeSubClass data -> UInt8 byte1) first item of Array SerializeSubClasses1 = 119
000000FC 0E 8A FF FF (SerializeSubClass data -> int int1) first item of Array SerializeSubClasses1 = -30194
00000100 19 00 00 00 (Length of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = 25
00000104 53 65 72 69 61 6C 69 7A (Part of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = “Serializ”
0000010C 65 53 75 62 43 6C 61 73 (Part of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = “eSubClas”
00000114 73 65 73 41 72 72 61 79 (Part of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = “sesArray”
0000011C 31 (00 00 00) (Part of SerializeSubClass data -> string string1) first item of Array SerializeSubClasses1 = “1”
00000120 00 (00 00 00) (SerializeSubClass data -> UInt8 byte1) second item of Array SerializeSubClasses1 = 0
00000124 85 0A 14 00 (SerializeSubClass data -> int int1) second item of Array SerializeSubClasses1 = 1313413
00000128 19 00 00 00 (Length of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = 25
0000012C 53 65 72 69 61 6C 69 7A (Part of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = “Serializ”
00000134 65 53 75 62 43 6C 61 73 (Part of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = “eSubClas”
0000013C 73 65 73 41 72 72 61 79 (Part of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = “sesArray”
00000144 32 (00 00 00) (Part of SerializeSubClass data -> string string1) second item of Array SerializeSubClasses1 = “2”

Raw파일을 UABE의 ‘View Data’를 보면서 하나하나 해석하는 실습을 한번은 해보기를 권장한다. UABE 2.2beta 버전이 나오면서 MonoBehaviour도 Dump와 ‘View Data’가 정상적으로 되지만, 모드 적용을 위해서 raw를 직접적으로 다룰 일이 있을 것이다. UnitySerializationExam 클래스의 멤버를 순서도 바꿔보고 type도 바꿔보면서 asset의 raw 파일을 분석하다보면 unity serialization에 대해 잘 알게될 것이다.

위의 Type을 설명하면서 부동 소숫점 type의 value-binary 변환식을 알려주지는 않았지만, CSharpSerialization 소스파일에서 float, double 형식의 value에서 binary로 바꿀 수 있는 코드가 있으니 이를 참고하여 예제를 따라가면된다(나중에 계산방법을 적어둘것이다)

광고

유니티 게임의 한글화와 패치 제작 – 유니티 에셋의 Serialization”의 3개의 생각

    1. 직접 Deserializer를 구현하셨다니 대단하네요. 위 글은 손으로 직접 Deserialize를 해보면서 원리를 이해하기 위해 보여드린것이고, UABE 2.2beta 버전부터 MonoBehaviour의 Deserialize를 구현해주고 있고, 제가 만든 UnityL10nTool 또한 MonoBehaviour로 제작된 폰트의 교체를 GUI로 지원해주고 있습니다.

      좋아요

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중