Lua程序逆向之Luac文件格式分析

作者:非蟲

預估稿費:600RMB

投稿方式:發送郵件至linwei#360.cn,或登陸網頁版在線投稿

簡介

Lua語言對於遊戲開發與相關逆向分析的人來說並不陌生。Lua語言憑藉其高效、簡潔與跨平台等多種特性,一直穩立於遊戲、移動APP等特定的開發領域中。

目前Lua主要有5.1、5.2、5.3共三個版本。5.1版本的Lua之所以目前仍然被廣泛使用的原因之一,是由於另一個流行的項目LuaJit採用了該版本Lua的內核。單純使用Lua來實現的項目中,5.2與5.3版本的Lua則更加流行。這裡主要以Lua版本5.2為例,通過分析它生成的Luac位元組碼文件,完成Lua程序的初步分析,為以後更深入的反彙編、位元組碼置換與重組等技能打下基礎。

Lua與Luac

Lua與Python一樣,可以被定義為腳本型的語言,與Python生成pyc位元組碼一樣,Lua程序也有自己的位元組碼格式luac。Lua程序在載入到內存中後,Lua虛擬機環境會將其編譯為Luac(下面文中Luac與luac含義相同)位元組碼,因此,載入本地的Luac位元組碼與Lua源程序一樣,在內存中都是編譯好的二進位結構。

為了探究Luac的內幕,我們需要找到合適的資料與工具來輔助分析Luac文件。最好的資料莫過於Lua的源碼,它包含了Lua相關知識的方方面面,閱讀並理解Luac的構造與Lua虛擬機載入位元組碼的過程,便可以通透的了解Luac的格式。但這裡並不打算這麼做,而採取閱讀第三方Lua反編譯工具的代碼。主要原因是:這類工具的代碼往往更具有針對性,代碼量也會少很多,分析與還原理解Luac位元組碼文件格式可以省掉不少的時間與精力。

luadecunlua是最流行的Luac反彙編與反編譯工具,前者使用C++語言開發,後者使用Java語言,這兩個工具都能很好的還原與解釋Luac文件,但考慮到Lua本身採用C語言開發,並且接下來打算編寫010 Editor編輯器的Luac.bt文件格式模板,010 Editor的模板語法類似於C語言,為了在編碼時更加順利,這裡分析時主要針對luadec

Luac文件格式

一個Luac文件包含兩部分:文件頭與函數體。文件頭格式定義如下:

typedef struct {char signature[4]; //".lua"uchar version;uchar format;uchar endian;uchar size_int;uchar size_size_t;uchar size_Instruction;uchar size_lua_Number;uchar lua_num_valid;uchar luac_tail[0x6];} GlobalHeader;

第一個欄位signature在lua.h頭文件中有定義,它是LUA_SIGNATURE,取值為「33Lua",其中,33表示按鍵<esc>LUA_SIGNATURE作為Luac文件開頭的4位元組,它是Luac的Magic Number,用來標識它為Luac位元組碼文件。Magic Number在各種二進位文件格式中比較常見,通過是特定文件的前幾個位元組,用來表示一種特定的文件格式。

version欄位表示Luac文件的格式版本,它的值對應於Lua編譯的版本,對於5.2版本的Lua生成的Luac文件,它的值為0x52。

format欄位是文件的格式標識,取值0代表official,表示它是官方定義的文件格式。這個欄位的值不為0,表示這是一份經過修改的Luac文件格式,可能無法被官方的Lua虛擬機正常載入。

endian表示Luac使用的位元組序。現在主流的計算機的位元組序主要有小端序`LittleEndian`與大端序`BigEndian`。這個欄位的取值為1的話表示為`LittleEndian`,為0則表示使用`BigEndian`。

size_int欄位表示int類型所佔的位元組大小。size_size_t欄位表示size_t類型所佔的位元組大小。這兩個欄位的存在,是為了兼容各種PC機與移動設備的處理器,以及它們的32位與64位版本,因為在特定的處理器上,這兩個數據類型所佔的位元組大小是不同的。

size_Instruction欄位表示Luac位元組碼的代碼塊中,一條指令的大小。目前,指令Instruction所佔用的大小為固定的4位元組,也就表示Luac使用等長的指令格式,這顯然為存儲與反編譯Luac指令帶來了便利。

size_lua_Number欄位標識lua_Number類型的數據大小。lua_Number表示Lua中的Number類型,它可以存放整型與浮點型。在Lua代碼中,它使用LUA_NUMBER表示,它的大小取值大小取決於Lua中使用的浮點數據類型與大小,對於單精度浮點來說,LUA_NUMBER被定義為float,即32位大小,對於雙精度浮點來說,它被定義為double,表示64位長度。目前,在macOS系統上編譯的Lua,它的大小為64位長度。

lua_num_valid欄位通常為0,用來確定lua_Number類型能否正常的工作。

luac_tail欄位用來捕捉轉換錯誤的數據。在Lua中它使用`LUAC_TAIL`表示,這是一段固定的字元串內容:"x19x93
x1a
"。

在文件頭後面,緊接著的是函數體部分。一個Luac文件中,位於最上面的是一個頂層的函數體,函數體中可以包含多個子函數,子函數可以是嵌套函數、也可以是閉包,它們由常量、代碼指令、Upvalue、行號、局部變數等信息組成。

在Lua中,函數體使用`Proto`結構體表示,它的聲明如下:

typedef struct {//headerProtoHeader header;//codeCode code;// constantsConstants constants;// functionsProtos protos;// upvaluesUpvaldescs upvaldescs;// stringSourceName src_name;// linesLines lines; // localsLocVars loc_vars; // upvalue namesUpValueNames names;} Proto;

ProtoHeaderProto的頭部分。它的定義如下:

typedef struct {uint32 linedefined;uint32 lastlinedefined;uchar numparams;uchar is_vararg;uchar maxstacksize;} ProtoHeader;

ProtoHeader在Lua中使用lua_Debug表示,lua_Debug的作用是調試時提供函數的行號,函數與變數名等信息,只是它部分欄位的信息在生成Luac位元組碼時,最終沒有寫入Luac文件中。`linedefined`與`lastlinedefined`是定義的兩個行信息。`numparams`表示函數有幾個參數。`is_vararg`表示參數是否為可變參數列表,例如這個函數聲明:

function f1(a1, a2, ...)......end

這點與C語言類似,三個點「...」表示這是一個可變參數的函數。`f1()`在這裡的`numparams`為2,並且`is_vararg`的值為1。

`maxstacksize`欄位指明當前函數的Lua棧大小。值為2的冪。

在`ProtoHeader`下面是函數的代碼部分,這裡使用`Code`表示。`Code`存放了一條條的Luac機器指令,每條指令是一個32位的整型大小。`Code`定義如下:

struct Code {uint32 sizecode;uint32 inst[];} code;

`sizecode`欄位標識了接下來的指令條數。`inst`則存放了當前函數所有的指令,在Lua中,指令採用`Instruction`表示,它的定義如下:

#define LUAI_UINT32unsigned inttypedef LUAI_UINT32 lu_int32;typedef lu_int32 Instruction;

當`LUAI_BITSINT`定義的長度大於等於32時,`LUAI_UINT32`被定義為unsigned int,否則定義為unsigned long,本質上,也就是要求`lu_int32`的長度為32位。

接下來是`Constants`,它存放了函數中所有的常量信息。定義如下:

typedef struct {uint32 sizek;Constant constant[];} Constants;

`sizek`欄位標識了接下來`Constant`的個數。`constant`則是`Constant`常量列表,存放了一個個的常量信息。的定義如下:

typedef struct {LUA_DATATYPE const_type;TValue val;} Constant;

`LUA_DATATYPE`是Lua支持的各種數據類型結構。如`LUA_TBOOLEAN`表示bool類型,使用`lua_Val`表示;`LUA_TNUMBER`表示數值型,它可以是整型,使用`lua_Integer`表示,也可以是浮點型,使用`lua_Number`表示;`LUA_TSTRING`表示字元串。這些所有的類型信息使用`const_type`欄位表示,大小為1位元組。

`TValue`用於存放具體的數據內容。它的定義如下:

typedef struct {union Value {//GCObject *gc; /* collectable objects *///void *p; /* light userdata */lua_Val val; /* booleans *///lua_CFunction f; /* light C functions */lua_Integer i; /* integer numbers */lua_Number n; /* float numbers */} value_;} TValue;

對於`LUA_TBOOLEAN`,它存放的值可以通過Lua中提供的宏`bvalue`來計算它的值。

對於`LUA_TNUMBER`,它存放的可能是整型,也可能是浮點型,可以直接通過`nvalue`宏自動進行類型判斷,然後獲取它格式化後的字元串值。對於Lua的5.3版本,對`nvalue`宏進行了改進,可以使用`ivalue`宏獲取它的整型值,使用`fltvalue`宏來獲取它的浮點值。

對於`LUA_TSTRING`,它存放的是字元串信息。可以使用`rawtsvalue`宏獲取它的字元串信息。而寫入Luac之後,這裡的信息實則是64位的值存放了字元串的大小,並且緊跟著後面是字元串的內容。

接下來是`Protos`,它表示當前函數包含的子函數信息。定義如下:

typedef struct(string level) {uint32 sizep;Proto proto[];} Protos

`sizep`欄位表示當前函數包含的子函數的數目。所謂子函數,指的是一個函數中包含的嵌套函數與閉包。如下面的代碼:

function Create(n) local function foo1() print(n) endlocal function foo2() n = n + 10 endreturn foo1,foo2end

`Create()`函數包含了`foo1()`與`foo2()`兩個子函數,因此,這裡`sizep`的值為2。`proto`表示子函數信息,它與父函數使用一樣的結構體信息。因此,可見Lua的函數部分使用了一種樹式的數據結構進行數據存儲。

`Upvaldescs`與`UpValueNames`共同描述了Lua中的UpValue信息。當函數中包含子函數或團包,並且訪問了函數的參數或局部變數時,就會產生UpValue。如上面的`Create()`函數,`foo1()`與`foo2()`兩個子函數都訪問了參數`n`,因此,這裡會產生一個UpValue,它的名稱為「n」。

`Upvaldesc`的定義如下:

typedef struct {uchar instack;uchar idx;} Upvaldesc;

`instack`欄位表示UpValue是否在棧上創建的,是的話取值為1,反之為0。`idx`欄位表示UpValue在UpValue數據列表中的索引,取值從0開始。

`UpValueNames`存放了當前函數中所有UpValue的名稱信息,它的定義如下:

typedef struct {uint32 size_upvalue_names;UpValueName upvalue_name[];} UpValueNames;

`size_upvalue_names`欄位表示`UpValueName`條目的數目,每一條`UpValueName`存放了一個UpValue的名稱,它的定義如下:

typedef struct {uint64 name_size;char var_str[];} UpValueName;

`name_size`欄位是符號串的長度,`var_str`為具體的字元串內容。

`SourceName`存放了當前Luac編譯前存放的完整文件名路徑。它的定義如下:

typedef struct {uint64 src_string_size;char str[];} SourceName

`SourceName`的定義與`UpValueName`一樣,兩個欄位分別存放了字元串的長度與內容。

`Lines`存放了所有的行號信息。它的定義如下:

typedef struct {uint32 sizelineinfo;uint32 line[];} Lines;

`sizelineinfo`欄位表示當前函數所有的行總數目。`line`欄位存放了具體的行號。

`LocVars`存放了當前函數所有的局部變數信息,它的定義如下:

typedef struct {uint32 sizelocvars;LocVar local_var[];} LocVars;

`sizelocvars`欄位表示局部變數的個數。`local_var`欄位是一個個的局部變數,它的類型`LocVar`定義如下:

typedef struct {uint64 varname_size;char varname[];uint32 startpc;uint32 endpc;} LocVar;

`varname_size`欄位是變數的名稱長度大小。`varname`欄位存放了變數的名稱字元串內容。`startpc`與`endpc`是兩個指針指,存儲了局部變數的作用域信息,即它的起始與結束的地方。

到此,一個Luac的文件格式就講完了。

010 Editor模板語法

為了方便分析與修改Luac二進位文件,有時候使用`010 Editor`編輯器配合它的文件模板,可以達到很直觀的查看與修改效果,但`010 Editor`官方並沒有提供Luac的格式模板,因此,決定自己動手編寫一個模板文件。

`010 Editor`支持模板與腳本功能,兩者使用的語法與C語言幾乎一樣,只是有著細微的差別與限制,我們看看如何編寫`010 Editor`模板文件。

點擊`010 Editor`菜單Templates->New Template,新建一個模板,會自動生成如下內容:

//------------------------------------------------//--- 010 Editor v8.0 Binary Template//// File: // Authors: // Version: // Purpose: // Category: // File Mask: // ID Bytes: // History: //------------------------------------------------

`File`是文件名,`010 Editor`使用.bt作為模柏樹的後綴,這裡取名為luac.bt即可。

`Authors`是作者信息。

`Version`是當前模板的版本,如果將最終的模板文件上傳到`010 Editor`的官方模板倉庫,`010 Editor`會以此欄位來判斷模板文件的版本信息。

`Purpose`是編寫本模板的意圖,內容上可以留空。

`Category`是模板的分類,`010 Editor`中自帶了一些內置的分類,這裡選擇`Programming`分類。

`File Mask`是文件擴展名掩碼,表示當前模板支持處理哪種文件類型的數據,支持通配符,如果支持多種文件格式,可以將所有的文件擴展名寫在一行,中間使用逗號分開,這裡設置它的值為「*.luac, *.lua」。

`ID Bytes`是文件開頭的Magic Number,用來通過文件的開頭來判斷是否為支持處理的文件,這裡的取值為「1B 4c 75 61」。

`History`中可以留空,也可以編寫模板的更新歷史信息。

最終,Luac.bt的開頭內容如下:

//------------------------------------------------//--- 010 Editor v8.0 Binary Template//// File: luac.bt// Authors: fei_cong(346345565@qq.com)// Version: 1.0// Purpose: // Category: Programming// File Mask: *.luac, *.lua// ID Bytes: 1B 4c 75 61// History: // 1.0 fei_cong: Initial version, support lua 5.2.//// License: This file is released into the public domain. People may // use it for any purpose, commercial or otherwise. //------------------------------------------------

`010 Editor`模板與C語言一樣,支持C語言的宏、數據類型、變數、函數、代碼語句、控制流程等,還支持調用常見的C語言函數。

數據類型上,支持的非常豐富,官方列出BS的支持的數據類型如下:

- 8-Bit Signed Integer - char, byte, CHAR, BYTE- 8-Bit Unsigned Integer - uchar, ubyte, UCHAR, UBYTE- 16-Bit Signed Integer - short, int16, SHORT, INT16- 16-Bit Unsigned Integer - ushort, uint16, USHORT, UINT16, WORD- 32-Bit Signed Integer - int, int32, long, INT, INT32, LONG- 32-Bit Unsigned Integer - uint, uint32, ulong, UINT, UINT32, ULONG, DWORD- 64-Bit Signed Integer - int64, quad, QUAD, INT64, __int64- 64-Bit Unsigned Integer - uint64, uquad, UQUAD, UINT64, QWORD, __uint64- 32-Bit Floating Point Number - float, FLOAT - 64-Bit Floating Point Number - double, DOUBLE - 16-Bit Floating Point Number - hfloat, HFLOAT - Date Types - DOSDATE, DOSTIME, FILETIME, OLETIME, time_t (for more information on date types see Using the Inspector)

在編寫模板時,同一數據類型中列出的類型,使用上是一樣,如下面的代碼片斷:

local int a;local int32 a;local long a;

表示的都是一個32位的整型變數,這三種聲明方式表達的含義是相同的。聲明變數時,需要在前面跟上`local`關鍵字,如果沒有跟上`local`,則表明是在聲明一個佔位的數據欄位。所謂佔位的數據欄位,指的`010 Editor`在解析模板中的變數時,會對佔位的數據部分使用指定的數據類型進行解析,如下面的代碼:

typedef struct {GlobalHeader header;Proto proto;} Luac;Luac luac;

`010 Editor`在解析這段代碼時,會按照`Luac`中所有的佔位數據欄位信息解析當前的二進位文件。`GlobalHeader`與`Proto`的聲明也中如此,沒有加上`local`的數據欄位,都會被`010 Editor`解析並顯示。

除了支持基本的C語言格式結構體struct外,`010 Editor`模板語法還加入了一些特性,比如欄位注釋與格式、結構體壓縮與處理函數。看如下的結構體信息:

typedef struct {uint64 varname_size <format=hex>;char varname[varname_size];uint32 startpc <format=hex, comment="first point where variable is active">;uint32 endpc <format=hex, comment="first point where variable is dead">;} LocVar <read = LocVarRead, optimize = false>;

這是按照前面介紹的`LocVar`結構體信息,按照`010 Editor`模板語法處理過後的效果。為欄位後添加`format`可以指定它的輸出格式為十六進位hex,默認是10進位;為欄位後添加`comment`可以指定它的注釋信息,這兩個欄位可以同時存在,在中間加入一個逗號即可;可以為結構體指定`read`來指定它的類型讀取函數,也可以指定`write`來指定它的類型寫入函數,`read`與`write`有著自己的格式,如下所示:

string LocVarRead(LocVar &val) {return val.varname;}

所有的`read`與`write`返回值必須為string,參數必須為要處理的結構體類型的引用。注意:`010 Editor`模板語法不支持指針,但支持引用類型,但引用類型不能作為變數與函數的返回值,只能作為參數進行傳遞,在編寫模板代碼時需要注意。

除了以上的基礎類型外,`010 Editor`模板還支持字元串類型`string`,這在C語言中是不存在的!它與`char[]`代表的含義是相同的,而且它支持的操作比較多,如以下字元串相加等操作:

local string str = "world";local string str2 = "hello " + str + "!
";

`010 Editor`模板中的宏有限制,並不能解析那些需要展開後替換符號的宏,只支持那些能夠直接計算的宏。如下面的`BITRK`與`ISK`宏:

#define SIZE_B9#define BITRK(1 << (SIZE_B - 1))#define ISK(x)((x) & BITRK)

前者可以直接解析並計算出來,`010 Editor`模板就支持它,而對於`ISK`宏,並不能在展開時計算出它的值,因此,`010 Editor`模板並不支持它。

`010 Editor`模板支持enum枚舉,與C語言中的枚舉的差別是,在定義枚舉時可以指定它的數據類型,這樣的好處是可以在`010 Editor`模板中聲明佔位的枚舉數據。如下所示是Luac.bt中用到的`LUA_DATATYPE`類型:

enum <uchar> LUA_DATATYPE {LUA_TNIL= 0,LUA_TBOOLEAN= 1,LUA_TLIGHTUSERDATA = 2,LUA_TNUMBER= 3,LUA_TSTRING= 4,LUA_TTABLE= 5,LUA_TFUNCTION= 6,LUA_TUSERDATA= 7,LUA_TTHREAD= 8,LUA_NUMTAGS = 9,};

`010 Editor`模板中支持調用常見的C語言庫函數,如`strlen()`、`strcat()`、`print()`、`sprintf()`、`strstr()`,不同的是,函數名上有些差別,這些可調用的函數在`010 Editor`模板中首字母是大寫的,因此,在調用時,它們分別是`Strlen()`、`Strcat()`、`Print()`、`Sprintf()`、`Strstr()`。更多支持的字元串操作的函數可以查看`010 Editor`的幫助文檔「String Functions」小節,除了「String Functions」外,還有「I/O Functions」、「Math Functions」、「Tool Functions」、「Interface Functions」等函數可供模板代碼使用。

接下來看下代碼結構部分,`010 Editor`模板支持C語言中的for/while/dowhile等循環語句,這些語句可以用來組成到`010 Editor`模板的函數與代碼塊中。一點細微的差別是`010 Editor`模板的返回類型只能是上面介紹過的基礎類型,不支持自定義類型與數組結構,這就給實際編寫代碼帶來了一些麻煩,遇到這種函數場景時,就需要考慮更改代碼的結構了。

編寫luac.bt文件格式模板

了解了`010 Editor`模板語法後,就可以開始編寫Luac.bt模板文件了。編寫模板前,需要找好一個Luac文件,然後邊寫邊測試,生成一個Luac文件很簡單,可以編寫好hello.lua後,執行下面的命令生成hello.luac:

$ luac -o ./hello.luac ./hello.lua

生成好Luac文件後,就是編寫一個個結構體進行測試,這是純體力活了。`luadec`提供了一個ChunkSpy52.lua,可以使用它列印Luac的文件格式內容,可以參考它的輸出進行Luac.bt的編寫工作,實際上我也是這麼做的。

首先是`GlobalHeader`,它的定義可以這樣寫:

typedef struct {uint32 signature <format=hex>; //".lua"uchar version <format=hex>;uchar format <comment = "format (0=official)">;uchar endian <comment = "1 == LittleEndian; 0 == BigEndian">;uchar size_int <comment = "sizeof(int)">;uchar size_size_t <comment = "sizeof(size_t)">;uchar size_Instruction <comment = "sizeof(Instruction)">;uchar size_lua_Number <comment = "sizeof(lua_Number)">;uchar lua_num_valid <comment = "Determine lua_Number whether it works or not, Its usually 0">;if (version == 0x52) {uchar luac_tail[0x6] <format=hex, comment = "data to catch conversion errors">;}} GlobalHeader;

這種定義的方式與前面介紹的`LocVar`一樣,具體就不展開討論了。下面主要討論編寫過程中遇到的問題與難點。

首先是輸出與ChunkSpy52.lua一樣的function level,也就是函數的嵌套級別,定義結構體時可以傳遞參數,這一點是C語言不具備的,但這個功能非常實用,可以用來傳遞定義結構時的信息,如這裡的function level就用到了該特性。這是`Protos`的定義:

typedef struct(string level) {uint32 sizep <format=hex>;local uint32 sz = sizep;local uint32 i = 0;local string s_level;while (sz-- > 0) {SPrintf(s_level, "%s_%d", level, i++);Proto proto(s_level);};} Protos <optimize=false>;

為結構體加上一個`string`類型的`level`參數,初始時傳值「0」,然後往下傳遞時,為傳遞的值累加一,這樣就做到了function level的輸出。

然後是`Constant`常量信息的獲取,由於`TValue`支持多種數據的類型,因此在處理上需要分別進行處理,這裡參考了`luadec`的實現,不過在細節上還是比較麻煩。`luadec`使用`DecompileConstant()`方法實現,它的代碼片斷如下:

···char* DecompileConstant(const Proto* f, int i) {const TValue* o = &f->k[i];switch (ttype(o)) {case LUA_TBOOLEAN:return strdup(bvalue(o)?"true":"false");case LUA_TNIL:return strdup("nil");#if LUA_VERSION_NUM == 501 || LUA_VERSION_NUM == 502case LUA_TNUMBER:{char* ret = (char*)calloc(128, sizeof(char));sprintf(ret, LUA_NUMBER_FMT, nvalue(o));return ret;}case LUA_TSTRING:return DecompileString(o);default:return strdup("Unknown_Type_Error");}}···

`bvalue`與`nvalue`是Lua提供的兩個宏,這在編寫模板時不能直接使用,需要自己實現,由於宏的嵌套較多,實際測試時編寫了C語言代碼展開它的實現,如`nvalue`展開後的實現為:

((((((o))->tt_) == ((3 | (1 << 4)))) ? ((lua_Number)(((((o)->value_).i)))) : (((o)->value_).n))));

於是編寫替換代碼`number2str`函數,實現如下:

string number2str(TValue &o) {local string ret;local string fmt;if (get_inst_sz() == 4) {fmt = "(=%.7g)";} else if (get_inst_sz() == 8) {fmt = "(=%.14g)";} else {Warning("error inst size.
");}local int tt = o.value_.val.tt_;//Printf("tt:%x
", tt);local lua_Integer i = o.value_.i;local lua_Number n = o.value_.n;SPrintf(ret, "%.14g", ((tt == (3 | (1 << 4))) ? i : n));return ret;}

然後為`Constant`編寫`read`方法`ConstantRead`,代碼片斷如下:

string ConstantRead(Constant& constant) {local string str;switch (constant.const_type) {case LUA_TBOOLEAN:{SPrintf(str, "%s", constant.bool_val ? "true" : "false");return str;}case LUA_TNIL:{return "nil";}case LUA_TNUMBER:{return number2str(constant.num_val);}case LUA_TSTRING:{return "(="" + constant.str_val + "")";}......default:return "";}}

`DecompileConstant`中調用的`DecompileString`方法,原實現比較麻煩,處理了非列印字元,這裡簡單的獲取解析的字元串內容,然後直接返回了。

最後,所有的代碼編寫完成後,效果如圖所示:

luac.bt的完整實現可以在這裡找到:github.com/feicong/lua_


推薦閱讀:

用LuaStudio調試Unity中SLua里的Lua5.3代碼
維基百科中模板和模塊有什麼區別?
公式計算機的另一種實現思路

TAG:Lua | 软件逆向工程 | 游戏开发 |