解析情報 - yu-ituki/ElinMod GitHub Wiki

■解析の基本

ILSpyでElinのDLLを開いて覗こう。
あとは有志の作ったここを見たり、
https://elin-modding-resources.github.io/Elin.Docs/articles/3_Making%20a%20Mod%20-%20A%20generalist%20walkthrough/part_1_intro_outlining
公式DiscodeのModチャンネルを覗いたりしよう。
https://discord.com/channels/208391609778307075/1213048941650772018

他の人のModを開いて解析していくと多少は楽に解析できるかもしれない。

■解析添付ファイル

▼ダンプ情報群:

Source名 ダンプファイル
Thing things.xlsx
Recipe recipies.xlsx
Faction factions.xlsx
Element elements.xlsx
Category categories.xlsx
Chara charas.xlsx
Zone zones.xlsx
langGeneral langGeneral.xlsx
langGame langGame.xlsx
langWord langWord.xlsx
langList langList.xlsx
langNote langNote.xlsx

スプライトは下記
Elinのスプライトデータ群

■デバッグメモ

printfデバッグ的なものしか分からない…break置きたい……。
とりあえずSystem.IO.File.WriteAllText() でひたすらダンプするか、
BepInExのロギング機能でログを出そう。
どのみちprintfしかない。

BepInExのログは基本的にはBepInEx備え付けのコンソールで出力する。
コンソールを表示するにはコンフィグファイルの項目を一部設定する必要がある。
Elinインストールフォルダ(C:\Program Files (x86)\Steam\steamapps\common\Elin\ など) 以下の
BepInEx\config\BepInEx.cfg
の [Logging.Console] の Enabled を = true にすることでゲーム起動時にコンソールが表示される。

BepInExのログはBaseUnityPluginのLoggerメンバで出力可能だ。

// こんな感じ.
[BepInPlugin("hoge.mod", "hoge", "1.0.0.0")]
class Hoge : BaseUnityPlugin 
{
  private void Awake() 
  {
    this.Logger.LogInfo("hoge");
    this.Logger.LogError("fuga");
  }
}

何かしらの関数でエラーが発生していたら、とりあえずHarmonyPatchで関数を乗っ取って、 中のエラーに関係しそうな値を片っ端からダンプするのが手っ取り早い。

■ソース解析情報

▼基本

(2025/01/09追記)
ここに全部デコンパイル後のドキュメントが載ってる、すごい時代だ。
とりあえずここさえ見ておけばある程度は分かる。
https://elin-modding-resources.github.io/Elin-Decompiled/index.html
ソースで手元で直に見て解析したい場合は↑のILSpyで自分でデコンパイルしてVisualStudioなりなんなりで見よう。

▼Harmonyについて

元々定義されている関数群を乗っ取ったりバイパスしたり出来る。
適当なクラスにHarmonyPatch属性を付け、その中の関数でHarmonyPrefixやHarmonyPostfix属性をつけることで
大本の関数呼び出し前や呼び出し後に自動で自分の処理をフックすることが可能。
なお、Prefix/Postfix内で元の関数を呼び出すと簡単に無限ループになるため注意。
必要に応じてstatic変数等で制御しよう。

[HarmonyPatch]
class Hoge {
	// HarmonyPostfixは呼び出し直後のフック処理.
	// TraitToolRange の BestDist Getプロパティの呼び出し直後にここに入る.
	[HarmonyPatch(typeof(TraitToolRange), "get_BestDist")]
	[HarmonyPostfix]
	public static void Postfix(TraitToolRange __instance, ref int __result) {
		// __instance と ref __result は特殊な名前.
		// __instanceは呼び出し元の自身のインスタンスが入る.
		// __result は 元の関数の戻り値が入る.
		
		__result = 2; //< 戻り値を書き換えることも出来る.
	}

	// HarmonyPrefixは呼び出し直前のフック処理.
	// CardのDamageHP 呼び出し直前にフックされる.
	[HarmonyPatch(typeof(Card), "DamageHP", 
		// CardのDamageHP() は複数オーバーロードが存在する.
		// こういったメソッドは[ParmonyPatch]の第三引数にて引数Type[]を明示する形となる.
		// オーバーロードが存在しない場合は引数Type[]は省略して良い.
		new System.Type[] { typeof(int), typeof(int), typeof(int), typeof(AttackSource), typeof(Card), typeof(bool) })]
	[HarmonyPrefix]
	public static bool Prefix(Card __instance, int dmg, ref int ele, int eleP = 100, AttackSource attackSource = AttackSource.None, Card origin = null, bool showEffect = true) {
		// 引数にrefを付けると、その引数をフック時にバイパスして書き換えることが出来る.
		// 元の関数定義の引数がrefでなくてもPrefixではrefに出来る.

		// trueを返すとその元の関数が継続して呼び出される.
		// falseを返すと元の関数を呼び出さない.
		// つまりfalseにすると関数を完全に乗っ取ることが出来る.
		return true;
	}
		
}

▼CardとThingとChara

落ちている物体は全てThing.cs がベースとなっている。
また、行動するキャラクタはChara.cs がベースである。
これをCardが吸収して運用している。
CardはThingとCharaの両方を同一のレイヤーで取り合えるように合流させたクラス。

全カードの情報を知りたければとりあえずcsvにでも適当にダンプしてやろう。
ダンプコードは[このテンプレート] (https://github.com/yu-ituki/ElinMod/tree/main/Elin_ModTemplate)で
Debug_AnalyzeElinクラスのDump_~関数を使おう。

// こんなかんじ.
void Update() {
	if (UnityEngine.Input.GetKeyDown(KeyCode.F10)) {
		Debug_AnalyzeElin.Dump_ElinFactionAll("D:\\faction.tsv");
		Debug_AnalyzeElin.Dump_ElinElementAll("D:\\elements.tsv");
		Debug_AnalyzeElin.Dump_ElinRecipeAll("D:\\recipe.tsv");
		Debug_AnalyzeElin.Dump_ElinThingAll("D:\\things.tsv");
		Debug_AnalyzeElin.Dump_ElinLangGeneral("D:\\langGeneral.tsv");
		Debug_AnalyzeElin.Dump_ElinLangList("D:\\langList.tsv");
		Debug_AnalyzeElin.Dump_ElinLangWord("D:\\langWord.tsv");
		Debug_AnalyzeElin.Dump_ElinLangGame("D:\\langGame.tsv");
		Debug_AnalyzeElin.Dump_ElinLangNote("D:\\langNote.tsv");
	}
}

▼ThingのカテゴリとSourceCategoryについて

  • thingに設定されている「category」という文字列はイコール SourceCategory の IDと紐づく。
    • レシピなどで「#ranged」などと指定すると、ID単品ではなくカテゴリ指定になる。
  • SourceCategoryは親子関係を持ち、「_parent」のIDが親である。
    • 例えばgunとbowの親はrangedである
    • 親子全てをひっくるめた参照はSourceCategory.Row.IsChildOf( id ) で出来る。

▼テーブルデータ群

起動時に下記に全て読み込まれる仕組みになっているようだ。
これらは全てstaticになっており、いつでもアクセス可能となっている。

EClass.sources

Core.Instance.sources と EClass.sources は同一の物と思われる。
追加された、または既存のデータにアクセスするには下記のようにする。

// カードIDからCardを検索取得してくる.
// mapは内部Dictionary.
CardRow s = EClass.sources.cards.map.TryGetValue("カードID");

各クラスのテーブルデータがもとになったクラスにはSource~という名前がついている事が多い。
ただしテーブルデータをそのままパースしただけのクラスと、それを加工して取り回しやすくした物などが混在している、カオス。
例えばSourceThing を取り回しやすく加工したSourceThingVなど。

▼テーブルデータのインポート方法とそのタイミングについて

インポート自体は下記で出来る。

	ModUtil.ImportExcel("エクセルのパス", "シート名", SourceDataを継承したインポート先);

インポートするエクセルのフォーマットはダンプして調べる。
余談だがダンプ結果はこのページにも載せていて、とりあえずコピペして4行目から全部消して追記していけばフォーマットはそのまま使える。
だが、テーブルデータのインポートはかなりカオスな状態になっている。
原因として

  • Thing と Chara を束ねる Card の存在
  • CardのInitタイミングの問題
  • 韓国語などを含む各ローカライズModのロードタイミングや処理の問題
  • テーブルに直書きされている各種表示用文字列群
  • ModUtil::ImportExcel() で呼び出される SourceData::Reset() の仕様の問題。 の仕様すべてが悪い方向に絡み合っており、各データそれぞれを適切なタイミングで適切にインポートしないとゲーム側のデータが簡単に壊れてしまう。
    マジ舐めんなこれもうバグだろ早よ直せや。

◯ThingとChara

これはBaseUnityPluginのOnStartCore()以外ではインポートしてはいけない。
この直後にCard.Init()などが走り、Cardにデータが統合されるため、このタイミング以外ではゲーム処理が破綻する作りになっている。
EClass.sources.charas なりにそのまま入れれば動く。

	ModUtil.ImportExcel("hoge.xlsx", "fuga", EClass.sources.charas);

◯他のテーブル

これはOnStartCore()でインポートするとローカライズModと干渉し、データが上書きされる状態になっている。
そのためGameのOnBeforeInstantiate()などのタイミングで読み込むのが良い。
各種セットアップが終わりこれから各種生成を行う直前、というタイミングなためである。
これを使う場合はHarmonyPatchでフックしよう。

(2025/01/31追記)
もしかしたら上記のローカライズModの干渉問題は治ったかもしれない。
「1/30 2025 EA 23.83 Nightly Patch 1」にて、
「name_Lなどの言語フィールドを(本体側のデータで)シリアライズしないように。」
というのが来ていた。
そのため、テーブルインポートは全部OnStartCore()でいけるようになった気がする。

◯自前の追加データのみインポートしたい

インポートした際は内部のSourceData.Reset()機構により既存のデータも一緒に展開される作りになってしまっているため、
「自身の読み込んだテーブルのデータのみ参照したい」という場合は注意されたし。
その際は読み込みたいデータクラスを継承し、Reset()のみoverrideで潰すことで実現が可能。
Elin_Lib以下に用意してあるSourceNoReset.cs はこのインタフェースを用意している。

◯その他エクセルインポートの注意点

  • データは4行目から読み込み開始される。
  • NPOIの特性上、空白列はスキップされてしまう。 一行目にでもなんか入れとけ。
  • 配列のセパレータは「,」
  • 特定のデータは改行(\n)も使用される。
    • 例えばCore.ParseElements() で使用される値、Thingsのelementsなど.
    • 内部では更に「/」でID / レベル と解析している
  • たまにパースのindexが連続していない時がある。多分元ソースではコメントアウトしていてILSpyで取れなかったとかだと思う。
    • 例えばSourceElementのfoodEffectとlangActの間とか。
    • 変にインポートしたデータの列がズレる時があったらこれを疑っておこう。

▼ゲームのライフサイクルについて

ゲーム起動時以降(EClass.core.IsGameStarted == true)でないと、ThingGenなどは使用できない。
EClass.game が存在しないため。
ThingGenなどが必要な処理を書く場合は、IsGameStarted==true以降に書くべし。
SceneクラスのInit(Mode.StartGame) を HarmonyPatchでフックしてもOK。

PluginのOnStartCore() はまだリソースが読み込まれる直前なため注意が必要。
EClass.core.IsGameStarted == true 以降であれば読み込まれている。
プレイヤーキャラなどもここ以降は生成されているため、なにかキャラなどを使って初期化したい場合はここを使うと良い。

▼プレイヤー

下記に入っている。

ELayer.pc

下記のように使える。

// プレイヤー座標取得.
Point pos = ELayer.pc.pos;

▼マップ情報

下記に大体入っている。

ELayer._map
ELayer._zone

下記のように使える。

// プレイヤーに属する拠点か否か?.
if ( ELayer._zone.IsPlayerFaction ) {
}

▼テキストボックスへのメッセージ表示

Msg.SayRaw("ほげほげ");

ちなみに改行コードが入っていると表示が崩れる模様。注意。

▼Traitの生成される仕組みと、Cardとの紐づけ

Card.cs ApplyTrait() でTraitが生成される
そのときのクラスは「"Trait" + card.trait[0]」
でリフレクション的に生成される。
--> ClassCache.Create("Trait" + sourceCard.trait[0], "Elin");

▼recipeについて

Recipeが設備と施設と選択アイテムを決定している
SourceRecipeのfactoryと一致している施設が選ばれる

▼UIまわり

Layer = ページ、Canvas的な立ち位置の物。
EClass.ui 以下に各レイヤーが入っている。 最前面の表示物はEClass.ui.layerFloatに入っている。
GetLayer などで取得可能。

▼インベントリUIまわり

  • InvOwnerMod --> 改造パーツインベントリカード管理
  • InvOwnerDraglet --> ドラッグ可能なイベントリのカード1つ1つの管理
  • InvOwner --> インベントリのカードUI基底
  • UIDragGridInfo --> ドラッグ選択UI管理。レシピ選出など?
  • LayerDragGrid --> ドラッグで選択するタイプの生成施設UI

▼生成施設でアイテムを選ぶ方法

  1. 新規に施設TraitCrafterを継承したものを作り、Craft()をoverrideして使用する  >ここで選択後のアイテムを使用した処理を書く
  2. 新規にSourceRecipeに新たなレシピデータを入れ込む
  3. TraitCrafterを継承したクラスに TrySetAct() し、LayerDragGrid.CreateCraft(trait)
  4. TrySetAct()で生成されたactをEClass.pc.SetAIImmediate()

3と4が分かりづらいので、下記のサンプルコードを記載しておく。

// 自前のTraitを生成する.
T _CreateTraitCrafter<T>() where T : TraitCrafter, new() 
{
	// ダミーのオーナーを設定しておく.
	// こうしないと使用時にどこかしらでNULLエラーを吐くため.
	// hogeは何かしらの生成設備のthingのIDを流用するか、新規に用意する.
	var dmyOwner = ThingGen.Create("hoge");	
	var ret = new T();
	dmyOwner.trait = ret;
	ret.SetOwner(dmyOwner); 

	return ret;
}

// 強制的にTraitCrafterを使用する.
public static void UseForceTraitCrafter( TraitCrafter trait ) 
{
	var actPlan = new ActPlan();

	actPlan.TrySetAct(trait.CrafterTitle, delegate {
		LayerDragGrid.CreateCraft(trait);
		return false;
	}, trait.owner);

	if (actPlan.list.Count > 0) {
		var act = actPlan.list[0].act;
		EClass.pc.SetAIImmediate(
			new DynamicAIAct(act.GetText(), () => act.Perform())
		) ;
	}
}

▼Zoneについて

EClass._zone で現在のゾーンが取れる。
各マップは愚直にZoneを継承したクラスになっている。
ワールドマップはRegionというクラス。
荒野はZone_Fieldというクラス。

Region中は座標がRegionPointというクラスに変換されて扱われる。

テーブルはSourceZone。
こいつがあれこれ処理されたのち、実体が EClass.game.spatials に入っている。
EClass.game.spatials.Find(id) で取得可能。

Zone.map に各ゾーン内の様々な情報が入っている。
例えば置かれているアイテムなどは map.things に入っている。

▼宝の地図について

TraitScrollMapTreasure が本体。
宝の地図UIはLayerTreasureMapとなっている。

▼エンチャントについて

EClass.sources.elements に入っている物がそれ。 alias文字列で引っ掛けて検索する。 カテゴリはtagで調べる。 各エンチャントには最低要求オブジェクトレベルが存在し、 生成時のオブジェクトレベルがそれ以下だった場合はエンチャントは付与されない仕組み。

装備品のエンチャント抽選はelementsテーブルの「chance」数値が大きい順に行われる。
全部合算して割合抽選する良くある形で抽選されてる。
Thing.cs GetEnchant() で抽選している。

▼遠距離ダメージ計算

ActRanged -> AttackProcess.Current.Prepare() で基礎計算
その後numFire分AtatckProcess.Current.Perform() する

内部では各種スキルの補正などを計算する。
武器、キャラ、弾などに付いたelementsのうち、categorySubがeleAttackのものが
属性ダメージとして計算される。
属性ダメージは1回攻撃の1elementにつき25%で与えられる。

アビリティ追加効果がそのあと処理される。
各ElementがAbilityであるかどうかを調べ、Abilityであれば ActEffect.ProcAtに飛ばす。
ProcAt内で効果ごとの細かな処理をやっている。
例えば魔法「ボール」の効果判定でターゲットの判定からダメージ計算、エフェクト表示など...

  • Act.TC : TargetChara?Card?
  • Act.TP : TargetPosition
  • Act.CC : CurrentChara?CurrentCurd?

▼インベントリ表示更新

thingをいじったらインベントリUIを更新しておこう。 下記でダーティフラグが立てられるようだ。

LayerInventory.SetDirty(thing);

▼乱数

// 0~100が降ってくる EClass.rnd(100)

▼コンソールコマンド追加方法

ReflexCLIを導入しているらしく、

[ConsoleCommand("")]	

でそのメソッド名のコマンドを増やせる。 引数を入れておくと自動でその引数を使えるようにしてくれる。 戻り値はログへの表示内容。

[ConsoleCommand("")]
public static string Hoge( int fuga )
{
	Debug.Log( fuga );
	reutnr "Hoge";
}

▼お金(通貨)支払い

// 払えるかどうかのチェック&「足りない」のログメッセージ付き.
EClass.pc.TryPay( 数, コストthingのid );
// TryPay内部で呼んでる直支払い処理.
EClass.pc.ModCurrency( 数, コストthingのid );

コストのthing-idはthings.xlsx を見てね。
たとえばプラチナコインが"plat"、金塊が"money2" とかだよ。

▼サウンド再生

Plugin.Sound.dll をインポートして↓。

// SE再生.
SE.Play("SEのID"); 
// ビープ音とかはこれ.
SE.Beep();

▼古文書まわり

thing の id は "book_ancient"
Trait は TraitAncientbook(TraitBaseSpellbook が基底)
Type BookType => Type.Ancient と指定している。
thingなどのcategory は "ancientbook"

読む処理は AI_Idle -> AI_Read -> TraitBaseSpellbook.OnRead()
OnRead() でタイプ判定し、解読処理をしてる。

古文書の種別はCard.refValに入っている

既に読んだかどうかは Card.isOn
(Traitからだったらowner.isOn)

古文書の名前取得は
var list = Lang.GetList("ancientbook");
var title = list[book.refVal];
var dispName = "_titled".lang(title);
みたいな感じ。

▼技術書まわり

thing の id は "book_skill"
TraitBookSkill が本体
スキル経験値書と各ゾーン用ポリシー書は両方この「技術書」にカテゴライズされている。
Cardのcategoryで判別していて、 category == "policy" だったらポリシー書となる。
Card.refVal に付与ElementのIDが設定されており、TraitBookSkill では idEle で取得している。
TraitBookSkill.OnRead() で読んだ後の経験値付与処理が走っている。

// TraitBookSkill.cs でこんな感じにしてる.
public override void OnRead()
{
	EClass.Branch.elements.Learn(idEle);
}

▼ショップ

TraitMerchant を継承した専用のTraitを用意。これがそのままショップとなる。
charaテーブルのtrait列に用意したTraitの名前を入れる。
enum ShopTypeに各店のタイプがある。

Trait.cs の OnBarter() と CreateStock() で商品を追加。

商品切り替えはCard.c_dateStockExpireで時間判定しているらしい。
こんな感じ

if (EClass.world.date.IsExpired(owner.c_dateStockExpire)) {
	// 期限過ぎてますよ.
}

Trait.cs AllowSellプロパティで売却可能か判定している。

値段はCard.cs の GetPrice() でShopTypeやらなんやらを加味してぐちゃぐちゃやって決めてる。

CurrencyTypeには異国コインは無い。

Trait.ShopLv が投資額等を加味した現在店舗レベル。

Mod側で作成したTraitは Card.cs での自動生成機構(Card.ApplyTrait())がそのままだと使えない。
なぜならElin.dllアセンブリ以外のクラスはClassCacheに登録されていないからである。
やりたい場合はしゃーないのでCard.ApplyTrait()をHarmonyPatchしよう。

▼ガチャ

TraitGacha が本体。
InvOwnerGacha.cs の _OnProcess() から PlayGacha() をコールしている。

▼エコポ

thing-idは"ecopo"
エコポの排出(リサイクルボックス)は InvOwnerRecycle.cs でやってる。
普通に_OnProcessでThingGenしてる。

EClass.pc.Pick(ThingGen.Create("ecopo").SetNum(a / 10 + 1));  

▼セーブデータ

EClass.player
に[Serializable]で入ってる。
多分json持ち?見てない。

▼キャラ生成と座標設定など

// 生成.
var chara = CharaGen.Create( "id" );
// 座標セット(グローバル指定).
chara.SetGlobal( ゾーン, x, z );
// 

▼プレイヤーワープ

Game.player.zoneをセットした後に
pc.global.transition に ZoneTransitionを設定すればOK。

Player obj = Game.player;
Chara chara = EClass.pc;
Zone zone2 = (EClass.pc.homeZone = EClass.game.spatials.Find("Zone ID"));
Zone zone4 = (chara.currentZone = zone2);
obj.zone = zone4;
EClass.pc.global.transition = new ZoneTransition
{
	state = ZoneTransition.EnterState.Exact,
	x = EClass.game.Prologue.startX,
	z = EClass.game.Prologue.startZ
};

▼ホットバーショートカット

WidgetHotbar.SetShortcutMenu() にて各HotActionクラスをAddButtonしている。
HotActionを継承してやれば自前のショートカットを追加可能。
Nameは表示名。pathSpriteはスプライト名となる。
スプライト名とスプライトの対応はDebug_AnalyzeElin.csのDump_ElinSprites() でダンプすれば一発で分かる。

⚠️ **GitHub.com Fallback** ⚠️