文章里的代碼粘過來的時候格式有點問題,原因是一開始文章是在訂閱號上寫的(gamedev101,文末有二維碼),不知道為啥貼過來就沒了格式,還要手動刪行號,就沒搞了。
介紹下問題背景:
小說君正在參與的項目,服務端邏輯以C#為主。
之前的一篇文章,《公式計算機》也有提到,這個項目的服務端需要提供讓策划寫游戲業務的能力。
不過跟文章里的方案不同,最后策划用來寫業務的語言是C#。
實踐下來,策划寫的業務分為兩大類:
-
戰斗相關的流程性質的邏輯。例如技能結算的流程性邏輯。
-
各模塊中經常變動的運算邏輯。例如面板屬性的運算邏輯。

如圖,簡單直接,就是程序寫的Foo調用策划寫的Formula。
這些邏輯如果只放在服務端,那就什么問題也沒有。
第一類邏輯,由於游戲類型的原因(MMO),基本上只有服務端會用,服務端想怎么更新就怎么更新。
第二類邏輯,面板屬性運算,不僅服務端需要計算完推給客戶端做顯示用,客戶端自己也需要做屬性預覽。
要解決這個問題,一般的做法要么是客戶端每次問服務端計算下數據,顯示出來;要么是客戶端也維護一份相關邏輯的定義。
第一種做法,是項目實在改不動了才不得不用。
第二種做法,如果客戶端大部分面板邏輯跑在C#上,那還好辦,策划的邏輯打成Assembly,客戶端服務端兩邊共用。
但是現在Lua普及程度已經這么高了,很少還有面板主要靠C#的手游。
這樣,如標題所說,如果我們有個工具可以把C#轉成Lua,平時策划用C#寫業務,持續集成流程自動把客戶端服務端共用的業務邏輯轉成Lua,客戶端用自動生成的對應的Lua函數做一些面板預覽計算邏輯,就解決了上面說的所有問題。

-
策划用C#寫Formula,強類型,減少犯錯。
-
工具把C#版本的Formula轉成Formula.lua。
-
客戶端的xxx.lua直接require Formula,調用。
把C#翻譯成Lua的方法有很多種。
比如可以直接給Roslyn寫插件,集成在編譯流程里,取到C#的語法樹,然后做自動生成。
再比如可以讀C#的編譯后程序集,反編譯,拿到語法樹,然后做代碼生成。
兩種方法相比較,小說君更傾向於后者。原因也很簡單:
-
C#是一種多范式編程語言,語法特性多而雜。而且C#版本越新,語法糖越多,Lua很難覆蓋。前者拿到的就是源代碼對應的語法樹,要變換的東西太多。
-
C#編譯出的IL就簡單多了,由於抽象層次介於底層語言和高級語言之間,基本上不用做任何變換就可以用任一門高級語言完整表達。
最關鍵的是,對於IL來說,有強大的ILSpy工具,可以讀取IL,可以選擇性地做反編譯變換,方便生成適用於目標語言的語法樹。
接下來進一段背景知識,用過C#的同學都知道,C#源代碼會被編譯為IL Assembly。然后由具體的runtime加載Assembly,編譯為native code並執行,這也是現在幾乎所有虛擬機語言的執行流程。

.Net Core/Mono是兩個比較常見的加載執行Assembly的backend。既可以運行時JIT編譯為native code直接執行,也可以編譯期AOT。
IL2CPP與上面兩個稍微不同,但是本質屬於一種AOT。Assembly被翻譯成CPP代碼集合,與支持庫編譯、link為目標文件。
Assembly的信息除了一些元信息比如模塊、類定義之外,主要存的是每個方法的IL指令集合。
IL是一種基於操作棧的虛擬機語言,所有的IL指令要么是把參數或返回值push到操作棧,要么是從操作棧pop值。
圍繞IL稱呼的名詞比較多,不過由於這次的系列主題不會太深入,所以就簡單統稱為IL了。有興趣的同學可以查閱ECMA335深入學習下IL。
C#中的200+100,翻譯為IL后,就是依次push 200、push 100,然后調用add指令,從操作棧pop兩個值,相加把結果push回操作棧。

介紹完IL,我們繼續看把IL翻譯成Lua的方案。
先看下參考方案,Unity的IL2CPP。
Unity在4.x開始引入了IL2CPP,用來在一些平台上替代mono這個邏輯腳本的backend。
IL2CPP整套工具鏈除了支持工具以外,主要分為兩塊:
-
把CIL Assembly翻譯成CPP的工具集。
-
支撐翻譯后的CPP正常運行在各個目標平台上的Native庫。
總的來說,IL2CPP做的事情就是把IL Assembly翻譯成C++文件集合,然后提供一些庫函數,保證原來的IL能怎么在Mono上跑起來,現在的so就也能直接跑起來。
相比之下,由於我們的需求比較簡單,所以ILToLua要做的事情就簡單很多了。比如IL2CPP需要提供gc相關的庫支持,lua就不用考慮這個問題。
再比如IL2CPP需要自己搞一套異常處理機制在C++中支持IL中的try-catch-finally語義,我們就可以有限支持。
先訂個小目標:我們實現一個工具,可以解析IL Assembly,將其中特定類型的定義轉為一個Lua module。
比如這樣一個簡單的類定義:
1public class Test 2{ 3 private Random r = new Random(); 4 5 public void Foo(Custom a, Custom b, Context ctx) 6 { 7 if ((a.Count - b.Count) > 0) 8 { 9 b.Rate = Modify(b.Rate, 0.003f * (b.Count - a.Count) * (b.Count - a.Count)); 10 11 var t = Math.Min(1 - b.Rate, a.Rate); 12 13 a.Rate = Modify(a.Rate, t - a.Rate); 14 } 15 } 16 17 private float Modify(float old, float diff) 18 { 19 var newVal = old + diff; 20 21 if (newVal < 0f) 22 { 23 newVal = 0f; 24 } 25 return newVal; 26 } 27}
里面的邏輯也比較簡單,剛入門的策划寫起來完全沒問題。
我們需要的大概的翻譯效果:
1local Prelude = require("LX6/Base/Prelude") 2local Math = require("LX6/Base/Math") 3 4local Random = System.Random 5 6local Formula = {} 7 8Formula.r = Random.New() 9 10function Formula:Foo(a, b, ctx) 11 if a.Count - b.Count > 0 then 12 b.Rate = self:Modify(b.Rate, 0.003 * (b.Count - a.Count) * (b.Count - a.Count)) 13 local t = Math.Min(1 - b.Rate, a.Rate) 14 a.Rate = self:Modify(a.Rate, t - a.Rate) 15 end 16end 17 18function Formula:Modify(old, diff) 19 local newVal = old + diff 20 if newVal < 0 then 21 newVal = 0 22 end 23 return newVal 24end 25 26return Formula
把這個類翻譯為Lua中的一個table。
簡化起見,這里就略去了table的構造函數。
兩個特點:
-
只翻譯一個類型。
-
由於lua本身的特性,函數用到的所有復雜參數都是鴨子類型(具體為table或udata)。
這兩點跟IL2CPP很不一樣,我們只需要把一個類型翻譯成Lua,不需要遞歸地去翻譯這個類型引用的其他類型。比如例子中的Custom和Context。
外面想調用的時候傳一個有Count和Rate成員的table也可以,傳一個真的符合類型的udata也可以。
接下來就開始進入正題了。不過由於這次文章的主題關聯的內容比較多,小說君打算分成幾篇短文來寫。每篇聚焦的內容稍微少一點。
大概的安排是:
-
本篇剩下的篇幅介紹下Mono.Cecil,然后初步認識下ILSpy。
-
接下來介紹ILSpy的一些原理性質的東西,以及相應的實現細節。
-
然后開始進入ILToLua的主題,跟大家分享下實現細節。
IL2CPP把IL Assembly翻譯成CPP的部分,就是靠Mono.Cecil做的。
Mono.Cecil,官方解釋
Cecil is a library written by Jb Evain to generate and inspect programs and libraries in the ECMA CIL format.
簡單來說,就是Mono.Cecil是符合ECMA335規范的。我們借助這個庫,可以結構化地讀Assembly,用起來跟.Net帶的反射庫差不多,只不過Mono.Cecil有自己的類型定義。可以修改Assembly。可以運行時Emit代碼。
Mono.Cecil可以用來寫編譯器,寫反編譯器,以及各種東西。
Unity用到的大量工具集都用了這個庫,比如用來裁剪未引用的字節碼的工具,用來在Editor熱更新腳本的工具等等。

Mono.Cecil wiki上介紹了現在用到這個庫的一些工具。基本上編譯、反編譯、混淆、AOP相關的工具都有用到。
IL本身是一種抽象層次比較高的語言,用Mono.Cecil可以比較容易地拿到Assembly中定義的全部類型,以及每個類型包含方法的IL集合。
還是之前的代碼示例,摳出來一個簡單函數:
1private float Modify(float old, float diff) 2{ 3 var newVal = old + diff; 4 5 if (newVal < 0f) 6 { 7 newVal = 0f; 8 } 9 return newVal; 10}
用ILSpy看到的IL是這樣的:
1.method private hidebysig 2 instance float32 Modify ( 3 float32 old, 4 float32 diff 5 ) cil managed 6{ 7 // Method begins at RVA 0x2110 8 // Code size 20 (0x14) 9 .maxstack 2 10 .locals init ( 11 [0] float32 12 ) 13 14 // float newVal = old + diff; 15 IL_0000: ldarg.1 16 IL_0001: ldarg.2 17 IL_0002: add 18 IL_0003: stloc.0 19 // if (newVal < 0f) 20 IL_0004: ldloc.0 21 IL_0005: ldc.r4 0.0 22 IL_000a: bge.un.s IL_0012 23 24 // newVal = 0f; 25 IL_000c: ldc.r4 0.0 26 IL_0011: stloc.0 27 28 // return newVal; 29 IL_0012: ldloc.0 30 // (no C# code) 31 IL_0013: ret 32} // end of method Test::Modify 33
IL2CPP翻譯成這樣:
1// System.Single ConsoleApplication13.Test::Modify(System.Single,System.Single) 2extern "C" float Test_Modify_m3633460209 (Test_t2103423000 * __this, float ___old0, float ___diff1, const RuntimeMethod* method) 3{ 4 float V_0 = 0.0f; 5 { 6 float L_0 = ___old0; 7 float L_1 = ___diff1; 8 V_0 = ((float)((float)L_0+(float)L_1)); 9 float L_2 = V_0; 10 if ((!(((float)L_2) < ((float)(0.0f))))) 11 { 12 goto IL_0012; 13 } 14 } 15 { 16 V_0 = (0.0f); 17 } 18 19IL_0012: 20 { 21 float L_3 = V_0; 22 return L_3; 23 } 24}
比較直接。只做了比較簡單的塊划分,和數據流分析,沒做Inlining,也沒做控制流分析。
我們在ILSpy中看到的信息,如果不反編譯的話,大部分都是借助Mono.Cecil讀出來的。比如Assembly依賴的其他Assembly,Assembly里面的命名空間和類型定義,具體到每個類型定義的Method、Field、Property等定義,以及最關鍵的,每個Method的IL Instruction。
Mono.Cecil拿到的Assembly元信息層次關系圖:

然后是BCL反射庫拿到的:

除了叫法有區別,其他能拿到的信息都是差不多的。
最大的區別就是Mono.Cecil可以直接拿到帶類型的IL Instruction,比較方便。當然,修改,回寫的接口就不用說了,BCL反射庫是沒有的。
ILSpy反編譯的流程,就是根據Mono.Cecil,拿到具體類型,拿到類型定義的方法,以及各自的MethodBody。
然后對MethodBody中的IL Instructions做數據流分析,控制流分析,最后轉為AST,再輸出為C#代碼。
這篇就到這里。
下篇小說君重點介紹下ILSpy的數據流分析和控制流分析過程和具體實現細節。
個人訂閱號:gamedev101「說給開發游戲的你」,聊聊服務端,聊聊游戲開發。

