くまのてサーバー

Harmonyを利用したmod作成

今夜のレシピは

gamestages.xmlのコメントに書かれている

gameStage = ( playerLevel + daysSurvived ) * difficultyBonus(標準で1.2)(daysSurvivedはplayerLevelが上限)

となっている設定を


LV50以上の時はそのままで50未満の時は

gameStage = playerLevel * 1.2

と変更することが目的としながら、Harmonyを使ってみようです。


7days to die で用意されている割り込みポイントは

ModEvents.GameStartDone.RegisterHandler
ModEvents.GameShutdown.RegisterHandler
ModEvents.SavePlayerData.RegisterHandler
ModEvents.PlayerSpawning.RegisterHandler
ModEvents.PlayerDisconnected.RegisterHandler
ModEvents.PlayerSpawnedInWorld.RegisterHandler
ModEvents.ChatMessage.RegisterHandler

などがあります。(詳しく最新版はclass ModEventsを覗いて見て)

これ以外の場所に割り込んでログを取ったり違う処理を走らせたい場合は0Harmony.dllを利用します。



必要素材

VisualStudio2022

7days to die dedicated server

目安時間

1時間(超てきとう)


下ごしらえ

新規プロジェクト作成

プロジェクト名
GameStageAdj

場所
C:\Users\test2\Desktop\7days\hjkw\hjkwPlus

ソリューション名
GameStageAdj

フレームワーク
.Net Framework 4.8

参照で前回指定したのに追加して7daysのManaged(前の『Mod作成(0から始める編)』参照)の中にある

0Harmony.dll

を追加で指定


Class1.csがまず出来ているのでこれをAPI.csに変更


using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace GameStageAdj
{
    public class API : IModApi
    {
        public void InitMod(Mod _modInstance)
        {
            ModEvents.GameStartDone.RegisterHandler(GameStartDone);

        }

        private static void GameStartDone()
        {
        }
    }
}

とりあえずゲームサーバー起動時にmodの機能を有効にさせる部分を設計。

GameStartDone()に機能させたいものを書き込んでいく。


調理開始

GameStageを弄るのでどこで設定されているかを探してみる。


VisualStudioのオブジェクトブラウザーを開いて「GameStage」で検索してみる。


まずはParty.GameStageをとりあえず見てみる。

APIの適当な場所にParty.GameStageを書いてカーソルを合わせて呼び出し


呼び出したら用済みなので今書いた分は消し

パーティメンバーのGameStageのリスト型データを作成している模様。

entityPlayer.gameStageでゲームステージの取り出しをやっているっぽい。


参照したいコードのところにカーソルを合わせて

entityPlayer.gameStageの場所にジャンプ


    public int gameStage
    {
        get
        {
            float num = Mathf.Clamp((long)(world.worldTime - gameStageBornAtWorldTime) / 24000L, 0f, Progression.Level);
            float @float = GameStats.GetFloat(EnumGameStats.GameDifficultyBonus);
            if (biomeStandingOn != null)
            {
                float num2 = 0f;
                float num3 = 0f;
                if (QuestJournal.ActiveQuest != null)
                {
                    num2 = QuestJournal.ActiveQuest.QuestClass.GameStageMod;
                    num3 = QuestJournal.ActiveQuest.QuestClass.GameStageBonus;
                }

                return Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)Progression.Level * (1f + biomeStandingOn.GameStageMod + num2) + num + biomeStandingOn.GameStageBonus + num3) * @float, this));
            }

            return Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)Progression.Level + num) * @float, this));
        }
    }

gameStage = ( playerLevel + daysSurvived ) * difficultyBonus(標準で1.2)(daysSurvivedはplayerLevelが上限)

の式と同じようなことをしているのでこれがGameStage決定箇所だと思われる。

なのでpublic int gameStage {}をAPI.cs内のprivate static void GameStartDone(){}の次の部分に丸っとコピーする。

EntityPlayerクラスの付属品なので単体では当然動かないので修正していく。


EntityPlayer __instanceを設定し、省略されているパラメーターに、親要素を書き足してやる。

thisは__instanceに書き直し、worldはallocさんなどを参考にGameManagerのinstanceから引っ張ってくる。



using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using UnityEngine;

namespace GameStageAdj
{
    public class API : IModApi
    {
        public void InitMod(Mod _modInstance)
        {
            ModEvents.GameStartDone.RegisterHandler(GameStartDone);

        }

        private static void GameStartDone()
        {
        
        
        
        }

        public int gameStage(EntityPlayer __instance)
        {
            World world = GameManager.Instance.World;
            float num = Mathf.Clamp((long)(world.worldTime - __instance.gameStageBornAtWorldTime) / 24000L, 0f, __instance.Progression.Level);
            float @float = GameStats.GetFloat(EnumGameStats.GameDifficultyBonus);
            if (__instance.biomeStandingOn != null)
            {
                float num2 = 0f;
                float num3 = 0f;
                if (__instance.QuestJournal.ActiveQuest != null)
                {
                    num2 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageMod;
                    num3 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageBonus;
                }

                return Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level * (1f + __instance.biomeStandingOn.GameStageMod + num2) + num + __instance.biomeStandingOn.GameStageBonus + num3) * @float, __instance));
            }

            return Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level + num) * @float, __instance));
        }
    }
}

コンパイルは通るけど、何もまだしない状態。

次にHarmoyを使っての割り込みを行う。

今回は通常の処理が終わったあとにもう一度計算しなおしてgamestageの値を7dtdに返すようにする。

通常処理前でも出来る。通常処理を直接改変も出来るが中間言語がわかってないと難しい、てか自分は無理


Harmonyを追加


using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using UnityEngine;

namespace GameStageAdj
{
    public class API : IModApi
    {
        public void InitMod(Mod _modInstance)
        {
            ModEvents.GameStartDone.RegisterHandler(GameStartDone);

        }

        private static void GameStartDone()
        {
            Log.Out("[GameStageAdj] GameStageAdj patch start");

            Harmony harmony = new Harmony("GameStageAdj.patch");
            MethodInfo original = AccessTools.PropertyGetter(typeof(EntityPlayer), "gameStage");
            if (original == null)
            {
                Log.Out(string.Format("[GameStageAdj] patch error : EntityPlayer.gameStage Property was not found"));
                return;
            }
            else
            {
                MethodInfo postfix = typeof(API).GetMethod("gameStage_postfix");
                if (postfix == null)
                {
                    Log.Out(string.Format("[GameStageAdj] patch error: API.gameStage_postfix was not found"));
                    return;
                }
                harmony.Patch(original, null , new HarmonyMethod(postfix));
            }
            Log.Out("[GameStageAdj] GameStageAd patched");
        }

        public static void gameStage_postfix(ref int __result)
        {
            __result = 10;
            return;
        }

        public int gameStage(EntityPlayer __instance)
        {
            World world = GameManager.Instance.World;
            float num = Mathf.Clamp((long)(world.worldTime - __instance.gameStageBornAtWorldTime) / 24000L, 0f, __instance.Progression.Level);
            float @float = GameStats.GetFloat(EnumGameStats.GameDifficultyBonus);
            if (__instance.biomeStandingOn != null)
            {
                float num2 = 0f;
                float num3 = 0f;
                if (__instance.QuestJournal.ActiveQuest != null)
                {
                    num2 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageMod;
                    num3 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageBonus;
                }

                return Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level * (1f + __instance.biomeStandingOn.GameStageMod + num2) + num + __instance.biomeStandingOn.GameStageBonus + num3) * @float, __instance));
            }

            return Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level + num) * @float, __instance));
        }
    }
}

Harmony harmony = new Harmony("GameStageAdj.patch");でharmony準備。””の中身は適当でok


MethodInfo original = AccessTools.PropertyGetter(typeof(EntityPlayer), "gameStage");で割り込む位置の指定

今回はEntityPlayerのgameStageのgetプロパティへの割り込みなのでPropertyGetterを使用。

Methodへの割り込みの場合は

MethodInfo original = AccessTools.Method(typeof(EntityPlayer), "なにやら");

のようになる。

オーバーロードされ同名のメソッドが複数存在している場合に割り込みしたい場合は

MethodInfo original = AccessTools.Method(typeof(NetPackageTileEntity), "Setup", new Type[] { typeof(TileEntity), typeof(TileEntity.StreamModeWrite), typeof(byte) });

のように指定したいメソッドと同じパラメーターを指定してやればok


harmony.Patch(original, null , new HarmonyMethod(postfix));

でターゲットメソッド実行後に割り込みメソッドを実行させる。


ターゲットの前に実行させたい場合は

harmony.Patch(original, new HarmonyMethod(prefix));

のように前と後にそれぞれ実行させたい場合は

harmony.Patch(original, new HarmonyMethod(prefix),new HarmonyMethod(postfix));

とする。

prefix、postfixのメソッドともにviodかboolでなければいけない。

prefixをreturn false;で終わらせた場合は以後の処理がなされない。

詳しくはharmonyの文献を漁ってください。(7daysで探すよりRimWorldで探す方が参考になったりする。特に日本語文献)


割り込むメソッド

public static void gameStage_postfix(ref int __result){...}

を設定。

とりあえず仮で必ずgameStageを10で返すように書き換え。

voidの為にreturnで値を返すことができないがref int __resultを設定して__resultに値を入れることで本体に値を返すことが出来る。


この状態でビルドし、サーバーに適当に書いたModInfo.xmlを一緒に設置。

サーバーを起動して、レベル100越えサーバー日数140の22時にセットしてBMを起こしてみる。

2023-10-03T22:29:25 386.620 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS7 (count 1, numToSpawn 15, maxAlive 6), cnt 1, zombieMarlene, loot 0.02, at player 321, day/time 140 22:05
2023-10-03T22:29:26 387.649 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS7 (count 2, numToSpawn 15, maxAlive 6), cnt 2, zombieTomClark, loot 0.02, at player 321, day/time 140 22:05
2023-10-03T22:29:27 388.676 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS7 (count 3, numToSpawn 15, maxAlive 6), cnt 3, zombieJanitor, loot 0.02, at player 321, day/time 140 22:05

feralHordeStageGS7のゾンビが抽選されているのでgameStageの書き換えに成功はしてる模様。


gameStageをgameStage_postfixに融合してみる。


using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using UnityEngine;

namespace GameStageAdj
{
    public class API : IModApi
    {
        public void InitMod(Mod _modInstance)
        {
            ModEvents.GameStartDone.RegisterHandler(GameStartDone);

        }

        private static void GameStartDone()
        {
            Log.Out("[GameStageAdj] GameStageAdj patch start");

            Harmony harmony = new Harmony("GameStageAdj.patch");
            MethodInfo original = AccessTools.PropertyGetter(typeof(EntityPlayer), "gameStage");
            if (original == null)
            {
                Log.Out(string.Format("[GameStageAdj] patch error : EntityPlayer.gameStage Property was not found"));
                return;
            }
            else
            {
                MethodInfo postfix = typeof(API).GetMethod("gameStage_postfix");
                if (postfix == null)
                {
                    Log.Out(string.Format("[GameStageAdj] patch error: API.gameStage_postfix was not found"));
                    return;
                }
                harmony.Patch(original, null , new HarmonyMethod(postfix));
            }
            Log.Out("[GameStageAdj] GameStageAd patched");
        }

        public static void gameStage_postfix(EntityPlayer __instance , ref int __result)
        {

            World world = GameManager.Instance.World;
            float num = Mathf.Clamp((long)(world.worldTime - __instance.gameStageBornAtWorldTime) / 24000L, 0f, __instance.Progression.Level);
            float @float = GameStats.GetFloat(EnumGameStats.GameDifficultyBonus);
            if (__instance.biomeStandingOn != null)
            {
                float num2 = 0f;
                float num3 = 0f;
                if (__instance.QuestJournal.ActiveQuest != null)
                {
                    num2 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageMod;
                    num3 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageBonus;
                }

                __result = Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level * (1f + __instance.biomeStandingOn.GameStageMod + num2) + num + __instance.biomeStandingOn.GameStageBonus + num3) * @float, __instance));
                Log.Out("[test001] gamestage:" + __result);
                return;
            }

            __result = Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level + num) * @float, __instance));
            Log.Out("[test001a] gamestage:" + __result);
            return;
        }
    }
}

融合、ついでに割り込んでるかの確認の為にテストログを吐くようにする。


ビルドしなおしてサーバーへ更新・再起動

2023-10-03T23:03:49 264.913 INF [test001] gamestage:266
2023-10-03T23:03:49 264.915 INF Party of 1, game stage 266, enemy max 1074, bonus every 35
2023-10-03T23:03:49 264.916 INF Party members:
2023-10-03T23:03:49 264.917 INF [test001] gamestage:266
2023-10-03T23:03:49 264.917 INF Player id 321, gameStage 266
2023-10-03T23:03:49 265.022 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS247 (count 1, numToSpawn 358, maxAlive 81), cnt 1, zombiePartyGirl, loot 0.02, at player 321, day/time 140 22:00
2023-10-03T23:03:50 265.939 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS247 (count 2, numToSpawn 358, maxAlive 81), cnt 2, zombieBoeFeral, loot 0.03, at player 321, day/time 140 22:00

[test001]とgameStage_postfixに入れたロギングが働いているので、割り込んだ上で正常に動いている模様。


まだ、バニラと変わらない状態なのでやっと改変を入れて見る。

低LV(50未満)のうちはGameStageをLV*1.2を上限とするようにしてみる。

gameStage_postfixを改造したものがこちら


        public static void gameStage_postfix(EntityPlayer __instance , ref int __result)
        {

            World world = GameManager.Instance.World;
            float num = Mathf.Clamp((long)(world.worldTime - __instance.gameStageBornAtWorldTime) / 24000L, 0f, __instance.Progression.Level);
            float @float = GameStats.GetFloat(EnumGameStats.GameDifficultyBonus);
            if (__instance.biomeStandingOn != null)
            {
                float num2 = 0f;
                float num3 = 0f;
                if (__instance.QuestJournal.ActiveQuest != null)
                {
                    num2 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageMod;
                    num3 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageBonus;
                }

                __result = Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level * (1f + __instance.biomeStandingOn.GameStageMod + num2) + num + __instance.biomeStandingOn.GameStageBonus + num3) * @float, __instance));
 
                if (__instance.Progression.Level < 50)
                {
                    if (__result > Mathf.FloorToInt((float)__instance.Progression.Level * 1.2f))
                    {
                        __result = Mathf.FloorToInt((float)__instance.Progression.Level * 1.2f);
                    }
                }
                Log.Out("[test001] gamestage:" + __result);
                return;
            }

            __result = Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level + num) * @float, __instance));
            if (__instance.Progression.Level < 50)
            {
                if (__result > Mathf.FloorToInt((float)__instance.Progression.Level * 1.2f))
                {
                    __result = Mathf.FloorToInt((float)__instance.Progression.Level * 1.2f);
                }
            }
            Log.Out("[test001a] gamestage:" + __result);
            return;
        }

ビルドしなおしサーバー再起動

プレイヤーLevel34に調整しゲーム内時間140日22時に設定

2023-10-03T23:26:31 593.229 INF [test001] gamestage:40
2023-10-03T23:26:31 593.229 INF Player id 832, gameStage 40
2023-10-03T23:26:31 593.260 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS31 (count 1, numToSpawn 57, maxAlive 15), cnt 1, zombieBoe, loot 0.02, at player 832, day/time 14 22:00
2023-10-03T23:26:32 594.258 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS31 (count 2, numToSpawn 57, maxAlive 15), cnt 2, zombieJanitor, loot 0.02, at player 832, day/time 14 22:00
2023-10-03T23:26:33 595.347 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS31 (count 3, numToSpawn 57, maxAlive 15), cnt 3, zombiePartyGirl, loot 0.02, at player 832, day/time 14 22:00
2023-10-03T23:26:34 596.379 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS31 (count 4, numToSpawn 57, maxAlive 15), cnt 4, zombieMoe, loot 0.02, at player 832, day/time 14 22:00

LV34の1.2倍のゲームステージ40 [34*1.2=40.8 切り捨てで40] が適用されているのが確認


GameStageAdjModを抜いた状態

2023-10-03T23:32:44 234.214 INF BloodMoon starting for day 140
2023-10-03T23:32:44 234.220 INF Party of 1, game stage 81, enemy max 339, bonus every 12
2023-10-03T23:32:44 234.220 INF Party members:
2023-10-03T23:32:44 234.220 INF Player id 832, gameStage 81
2023-10-03T23:32:44 234.234 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS69 (count 1, numToSpawn 113, maxAlive 27), cnt 1, zombieMarlene, loot 0.02, at player 832, day/time 140 22:00
2023-10-03T23:32:45 235.233 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS69 (count 2, numToSpawn 113, maxAlive 27), cnt 2, zombieSteve, loot 0.02, at player 832, day/time 140 22:00
2023-10-03T23:32:46 236.242 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS69 (count 3, numToSpawn 113, maxAlive 27), cnt 3, zombieLumberjack, loot 0.02, at player 832, day/time 140 22:00
2023-10-03T23:32:47 237.290 INF BloodMoonParty: SpawnZombie grp 0 feralHordeStageGS69 (count 4, numToSpawn 113, maxAlive 27), cnt 4, zombieFatCop, loot 0.02, at player 832, day/time 140 22:00

Lv34+経過日数(LVを上限)の1.2倍の81 [ (34 + 34 ) * 1.2 = 81.6 切り捨てで81]が適用されている。


これでおおよそ完成

これでざっくりですがサーバー日数が経過したサーバーにLV1で入ってすぐにフェラルゾンビに追い掛け回されるのは減るかと思います。

LV50未満にしたのは大体LV40台までには何かしらの武器と弾が安定して手に入るようになる。

GameStageを48で制限掛けたのはGameStage50からフェラルの抽選が始まるため。


この先としては

Log.Out("[test00うんにゃらはいらないので削除または念のための記録に整形して残してもいいかもしれない。

外部ファイル(Configファイルみたいなもの)参照で取り扱いやすくしたり、もっとLVによる段階を増やすなどの改造が考えられます。

ログ出力を少し手直しした版

using HarmonyLib;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using UnityEngine;

namespace GameStageAdj
{
    public class API : IModApi
    {
        public void InitMod(Mod _modInstance)
        {
            ModEvents.GameStartDone.RegisterHandler(GameStartDone);

        }

        private static void GameStartDone()
        {
            Log.Out("[GameStageAdj] GameStageAdj patch start");

            Harmony harmony = new Harmony("GameStageAdj.patch");
            MethodInfo original = AccessTools.PropertyGetter(typeof(EntityPlayer), "gameStage");
            if (original == null)
            {
                Log.Out(string.Format("[GameStageAdj] patch error : EntityPlayer.gameStage Property was not found"));
                return;
            }
            else
            {
                MethodInfo postfix = typeof(API).GetMethod("gameStage_postfix");
                if (postfix == null)
                {
                    Log.Out(string.Format("[GameStageAdj] patch error: API.gameStage_postfix was not found"));
                    return;
                }
                harmony.Patch(original, null , new HarmonyMethod(postfix));
            }
            Log.Out("[GameStageAdj] GameStageAd patched");
        }

        public static void gameStage_postfix(EntityPlayer __instance , ref int __result)
        {

            World world = GameManager.Instance.World;
            float num = Mathf.Clamp((long)(world.worldTime - __instance.gameStageBornAtWorldTime) / 24000L, 0f, __instance.Progression.Level);
            float @float = GameStats.GetFloat(EnumGameStats.GameDifficultyBonus);
            if (__instance.biomeStandingOn != null)
            {
                float num2 = 0f;
                float num3 = 0f;
                if (__instance.QuestJournal.ActiveQuest != null)
                {
                    num2 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageMod;
                    num3 = __instance.QuestJournal.ActiveQuest.QuestClass.GameStageBonus;
                }

                __result = Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level * (1f + __instance.biomeStandingOn.GameStageMod + num2) + num + __instance.biomeStandingOn.GameStageBonus + num3) * @float, __instance));
 
                if (__instance.Progression.Level < 50)
                {
                    if (__result > Mathf.FloorToInt((float)__instance.Progression.Level * 1.2f))
                    {
                        __result = Mathf.FloorToInt((float)__instance.Progression.Level * 1.2f);
                    }
                }
                Log.Out("[GameStageAdj] gamestage:" + __result);
                return;
            }

            __result = Mathf.FloorToInt(EffectManager.GetValue(PassiveEffects.GameStage, null, ((float)__instance.Progression.Level + num) * @float, __instance));
            if (__instance.Progression.Level < 50)
            {
                if (__result > Mathf.FloorToInt((float)__instance.Progression.Level * 1.2f))
                {
                    __result = Mathf.FloorToInt((float)__instance.Progression.Level * 1.2f);
                }
            }
            Log.Out("[GameStageAdj] gamestage:" + __result);
            return;
        }
    }
}

今回はgameStageの値を作っている関数がすぐに見つかったので楽でしたが

なかなか見つからず手を焼く事があります。

(chunkAgeでのリセット時にkeystoneとbedを除外している場所はどこですか…)


C# Harmonyを使えるようになったら

Discordチャンネル Guppy's Unofficial 7DtD Modding Serverなんかおすすめ

α22はもうこれでいいんじゃね?ってのが個人で発表されてたりする。




はじめの1歩がどこに踏み出せばいいのか分からない人が多いかと思い書いてみた。

7dtdのmod製作者が一人でも増えることを願って 2023/10/4