├── mod.json ├── Turbine.sln ├── LICENSE ├── Properties └── AssemblyInfo.cs ├── README.md ├── Turbine.csproj └── src ├── SafeGuards.cs ├── Mod.cs ├── Logger.cs ├── DataProcess.cs └── BattleMod.cs /mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "Turbine", 3 | "DLL": "Turbine.dll", 4 | 5 | "Version": "2.0.0", 6 | "Description": "Speed up game loading.", 7 | "Author": "Sheepy", 8 | "Website": "https://github.com/Sheep-y/BattleTech_Turbine/" 9 | } -------------------------------------------------------------------------------- /Turbine.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27703.2042 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Turbine", "Turbine.csproj", "{6AF80B81-1533-4352-8E44-266970CC84F1}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {6AF80B81-1533-4352-8E44-266970CC84F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {6AF80B81-1533-4352-8E44-266970CC84F1}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {6AF80B81-1533-4352-8E44-266970CC84F1}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {6AF80B81-1533-4352-8E44-266970CC84F1}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {81CDB540-DDA2-4AE2-BC9D-CF5CDFC60524} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("Turbine")] 9 | [assembly: AssemblyDescription("")] 10 | [assembly: AssemblyConfiguration("")] 11 | [assembly: AssemblyCompany("")] 12 | [assembly: AssemblyProduct( "BattleTechTurbine" )] 13 | [assembly: AssemblyCopyright( "Copyright © Yeung Ho Yiu 2018" )] 14 | [assembly: AssemblyTrademark("")] 15 | [assembly: AssemblyCulture("")] 16 | 17 | // Setting ComVisible to false makes the types in this assembly not visible 18 | // to COM components. If you need to access a type in this assembly from 19 | // COM, set the ComVisible attribute to true on that type. 20 | [assembly: ComVisible(false)] 21 | 22 | // The following GUID is for the ID of the typelib if this project is exposed to COM 23 | [assembly: Guid("6af80b81-1533-4352-8e44-266970cc84f1")] 24 | 25 | // Version information for an assembly consists of the following four values: 26 | // 27 | // Major Version 28 | // Minor Version 29 | // Build Number 30 | // Revision 31 | // 32 | // You can specify all the values or you can default the Build and Revision Numbers 33 | // by using the '*' as shown below: 34 | // [assembly: AssemblyVersion("1.0.*")] 35 | [assembly: AssemblyVersion( "2.0.0.11" )] 36 | [assembly: AssemblyFileVersion( "2.0.0.11" )] 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Turbine 2.0 # 2 | For BATTLETECH 1.3.0 3 | 4 | Turbine is a BattleTech mod that lighten and speed up the game's resource loading. 5 | 6 | This mod does not modify game data or save games. 7 | This mod does not fix memory leaks, either. Rest your eyes. 8 | 9 | * GitHub: https://github.com/Sheep-y/BattleTech_Turbine 10 | * Nexus Mods: https://www.nexusmods.com/battletech/mods/288 11 | 12 | # Installation 13 | 14 | 1. Install [BTML and ModTek](https://github.com/janxious/ModTek/wiki/The-Drop-Dead-Simple-Guide-to-Installing-BTML-&-ModTek-&-ModTek-mods). 15 | 2. [Download this mod](https://github.com/Sheep-y/BattleTech_Turbine/releases), extract in the mod folder. i.e. You should see `BATTLETECH\Mods\Turbine\Turbine.dll`. 16 | 3. Launch and play as normal. This mod has no settings. 17 | 4. If the game crash or hang up during a loading screen or blank screen, delete the mod and try again. 18 | 19 | Note: Turbine 1.x couldn't be loaded as ModTek, but it has been updated since 2.0. 20 | Please delete old Turbine and do not use both at the same time. 21 | 22 | 23 | # Compatibility 24 | 25 | Tested with janxious's BTML v0.6.4 and ModTek v0.4.2. 26 | 27 | It should otherwise work with all mods, including those that add new files for the game to load. 28 | 29 | 30 | # How It Works 31 | 32 | The mod has a few functional parts. 33 | Version 1.x is a major rewrite of BattleTech's DataManager, but its main ideas has since been implemented by the game. 34 | 35 | 1. The resource load loop has been rewritten to run faster. 36 | 2. Two lightweight duplicate resource filters are added, one check loading requests and the other checks complete notifications. 37 | 3. The json pre-processor has been rewritten to not use regular expression. 38 | 4. The csv line reader (for version manifest) has been optimised to use quick split if possible. 39 | 5. Data hashing code has been replaced with a multi-thread implementation. 40 | 6. VFX name list is cached instead of rebuilt every time. 41 | 7. Unparsed CombatConstants is cached in memory in compressed form. 42 | 43 | 44 | # Credits 45 | 46 | * Thanks [Denedan](https://github.com/Denadan) for finding the two original [performance](https://github.com/saltyhotdog/BattletechIssueTracker/issues/14) [issues](https://github.com/saltyhotdog/BattletechIssueTracker/issues/17) 47 | * Thanks [Matthew Spencer](https://github.com/m22spencer) for doing very detailed and amazing profiling so that I know where to start hacking, and suggested ways to speed the game up further. 48 | * Thanks LadyAlekto and many brave RogueTech users on the BATTLETECHGAME discord for testing the mod despite its high tendency to explode their games. 49 | * Thanks HBS the game developer for giving me a ComStar experience when working on this mod. Can't get any closer to maintaining Lostech. -------------------------------------------------------------------------------- /Turbine.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Debug 6 | AnyCPU 7 | {6AF80B81-1533-4352-8E44-266970CC84F1} 8 | Library 9 | Properties 10 | Turbine 11 | Turbine 12 | v3.5 13 | 512 14 | 15 | 16 | true 17 | full 18 | false 19 | bin\Debug\ 20 | DEBUG;TRACE 21 | prompt 22 | 4 23 | 7.1 24 | 25 | 26 | pdbonly 27 | true 28 | bin\Release\ 29 | TRACE 30 | prompt 31 | 4 32 | 7.1 33 | 34 | 35 | 36 | 37 | 38 | 39 | C:\Program Files (x86)\Steam\steamapps\common\BATTLETECH\BattleTech_Data\Managed\0Harmony.dll 40 | False 41 | 42 | 43 | C:\Program Files (x86)\Steam\steamapps\common\BATTLETECH\BattleTech_Data\Managed\Assembly-CSharp.dll 44 | False 45 | 46 | 47 | C:\Program Files (x86)\Steam\steamapps\common\BATTLETECH\BattleTech_Data\Managed\Newtonsoft.Json.dll 48 | False 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | C:\Program Files (x86)\Steam\steamapps\common\BATTLETECH\BattleTech_Data\Managed\UnityEngine.dll 58 | False 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/SafeGuards.cs: -------------------------------------------------------------------------------- 1 | using BattleTech; 2 | using System; 3 | using System.IO; 4 | using System.IO.Compression; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | 10 | namespace Sheepy.BattleTechMod.Turbine { 11 | using static Mod; 12 | using static System.Reflection.BindingFlags; 13 | 14 | public class SafeGuards : BattleModModule { 15 | 16 | // Cache some game properties to work around NPE of unknown cause. 17 | private const bool CacheVFXNames = true; 18 | private const bool CacheCombatConst = true; 19 | 20 | public override void ModStarts () { 21 | Verbo( "Some simple filters and safety shield first." ); 22 | // Fix VFXNames.AllNames NPE 23 | if ( CacheVFXNames ) 24 | Patch( typeof( VFXNamesDef ), "get_AllNames", nameof( Override_VFX_get_AllNames ), nameof( Cache_VFX_get_AllNames ) ); 25 | if ( CacheCombatConst ) { 26 | // CombatGameConstants can be loaded and reloaded many times. Cache it for reuse and fix an NPE. 27 | Patch( typeof( CombatGameConstants ), "LoadFromManifest", nameof( Override_CombatGameConstants_LoadFromManifest ), null ); 28 | Patch( typeof( CombatGameConstants ), "OnDataLoaded", nameof( Save_CombatGameConstants_Data ), null ); 29 | } 30 | } 31 | 32 | public static byte[] CombatConstantJSON; 33 | private static MethodInfo LoadMoraleResources, LoadMaintenanceResources; 34 | 35 | public static bool Override_CombatGameConstants_LoadFromManifest ( CombatGameConstants __instance ) { try { 36 | if ( CombatConstantJSON == null ) return true; 37 | __instance.FromJSON( UnzipStr( CombatConstantJSON ) ); 38 | LoadMoraleResources?.Invoke( __instance, null ); 39 | LoadMaintenanceResources?.Invoke( __instance, null ); 40 | return false; 41 | } catch ( Exception ex ) { return Error( ex ); } } 42 | 43 | public static void Save_CombatGameConstants_Data ( MessageCenterMessage message ) { 44 | if ( message is DataManagerRequestCompleteMessage msg && msg.Resource != null && msg.ResourceType == BattleTechResourceType.CombatGameConstants ) { try { 45 | string json = Regex.Replace( DataProcess.StripComments( msg.Resource ), @"(?<=\n)\s+", "" ); // 48K to 32K 46 | fastJSON.JSON.Parse( json ); 47 | CombatConstantJSON = ZipStr( json ); // 32K to 8K 48 | LoadMoraleResources = typeof( CombatGameConstants ).GetMethod( "LoadMoraleResources", NonPublic | Instance ); 49 | LoadMaintenanceResources = typeof( CombatGameConstants ).GetMethod( "LoadMaintenanceResources", NonPublic | Instance ); 50 | } catch ( Exception ex ) { 51 | CombatConstantJSON = null; 52 | Warn( ex ); 53 | } } 54 | } 55 | 56 | // https://stackoverflow.com/a/2118959/893578 57 | public static byte[] ZipStr ( String str ) { 58 | using ( MemoryStream output = new MemoryStream() ) { 59 | using ( DeflateStream gzip = new DeflateStream( output, CompressionMode.Compress ) ) { 60 | using ( StreamWriter writer = new StreamWriter( gzip, Encoding.UTF8 ) ) { 61 | writer.Write( str ); 62 | } 63 | } 64 | return output.ToArray(); 65 | } 66 | } 67 | 68 | // https://stackoverflow.com/a/2118959/893578 69 | public static string UnzipStr ( byte[] input ) { 70 | using ( MemoryStream inputStream = new MemoryStream( input ) ) { 71 | using ( DeflateStream gzip = new DeflateStream( inputStream, CompressionMode.Decompress ) ) { 72 | using ( StreamReader reader = new StreamReader( gzip, Encoding.UTF8 ) ) { 73 | return reader.ReadToEnd(); 74 | } 75 | } 76 | } 77 | } 78 | 79 | private static VFXNameDef[] nameCache; 80 | 81 | public static bool Override_VFX_get_AllNames ( ref VFXNameDef[] __result ) { 82 | if ( nameCache != null ) { 83 | __result = nameCache; 84 | return false; 85 | } 86 | // Will throw NPE if this.persistentDamage or this.persistentCrit is null. 87 | // No code change them, and NPE is reported to happens without Turbine. 88 | VFXNamesDef? def = BattleTechGame?.Combat?.Constants?.VFXNames; 89 | if ( def != null ) try { 90 | VFXNamesDef check = def.GetValueOrDefault(); 91 | if ( check.persistentCrit == null || check.persistentDamage == null ) { 92 | Warn( "VFXNamesDef.persistentCrit and/or VFXNamesDef.persistentDamage is null on first load, using hardcoded list." ); 93 | check.persistentDamage = "SmokeLrg_loop,SmokeSm_loop,ElectricalSm_loop,ElectricalLrg_loop,Sparks,ElectricalFailure_loop" 94 | .Split(',').Select( e => new VFXNameDef(){ name = $"vfxPrfPrtl_mechDmg{e}" } ).ToArray(); 95 | check.persistentCrit = "FireLrg_loop,FireSm_loop,SmokeSpark_loop" 96 | .Split(',').Select( e => new VFXNameDef(){ name = $"vfxPrfPrtl_mechDmg{e}" } ).ToArray(); 97 | typeof( CombatGameConstants ).GetProperty( "VFXNames" ).SetValue( BattleTechGame.Combat.Constants, check, null ); 98 | } 99 | } catch ( Exception ex ) { Error( ex ); } 100 | return true; 101 | } 102 | 103 | public static void Cache_VFX_get_AllNames ( VFXNameDef[] __result ) { 104 | if ( ! ReferenceEquals( __result, nameCache ) ) { 105 | Info( "Caching VFXNamesDef.AllNames ({0})", __result.Length ); 106 | nameCache = __result; 107 | } 108 | } 109 | } 110 | } -------------------------------------------------------------------------------- /src/Mod.cs: -------------------------------------------------------------------------------- 1 | using BattleTech; 2 | using BattleTech.Data; 3 | using Sheepy.Logging; 4 | using System; 5 | using System.Diagnostics; 6 | using System.Collections.Generic; 7 | 8 | namespace Sheepy.BattleTechMod.Turbine { 9 | 10 | public class Mod : BattleMod { 11 | 12 | // Block processing of empty and repeated DataManagerRequestCompleteMessages 13 | private const bool FilterNullAndRepeatedMessage = true; 14 | 15 | // A kill switch to press when any things go wrong during initialisation. 16 | // Default true and set to false after initial patch success. Also set to true after any exception. 17 | private static bool UnpatchManager = true; 18 | 19 | // Performance hit varies by machine spec. 20 | internal const bool DebugLog = false; 21 | 22 | public static void Init () { 23 | new Mod().Start( ref ModLog ); 24 | } 25 | 26 | public override void ModStarts () { 27 | Log.LogLevel = SourceLevels.Verbose; 28 | Add( new SafeGuards() ); 29 | 30 | Verbo( "Applying Turbine." ); 31 | Type dmType = typeof( DataManager ); 32 | logger = HBS.Logging.Logger.GetLogger( "Data.DataManager" ); 33 | Patch( dmType, "ProcessRequests", nameof( Override_ProcessRequests ), null ); 34 | Patch( dmType, "RequestResource_Internal", nameof( Prefix_RequestResource_Internal ), null ); 35 | if ( FilterNullAndRepeatedMessage ) 36 | Patch( typeof( DataManagerRequestCompleteMessage ).GetConstructors()[0], null, nameof( Skip_DuplicateRequestCompleteMessage ) ); 37 | UnpatchManager = false; 38 | Info( "Turbine initialised" ); 39 | 40 | Add( new DataProcess() ); 41 | 42 | if ( DebugLog ) Log.LogLevel = SourceLevels.Verbose | SourceLevels.ActivityTracing; 43 | } 44 | 45 | public override void GameStartsOnce () { 46 | if ( UnpatchManager ) return; 47 | Info( "Mods found: " + BattleMod.GetModList().Concat() ); 48 | } 49 | 50 | private static BattleTechResourceType lastCompleteType; 51 | private static string lastComplete; 52 | 53 | public static void Skip_DuplicateRequestCompleteMessage ( DataManagerRequestCompleteMessage __instance ) { 54 | if ( String.IsNullOrEmpty( __instance.ResourceId ) ) { 55 | __instance.hasBeenPublished = true; // Skip publishing empty id 56 | return; 57 | } 58 | if ( lastComplete == __instance.ResourceId && lastCompleteType == __instance.ResourceType ) { 59 | if ( DebugLog ) Verbo( "Skipping successive DataManagerRequestCompleteMessage {0} {1}", __instance.ResourceType, __instance.ResourceId ); 60 | __instance.hasBeenPublished = true; 61 | } else { 62 | lastComplete = __instance.ResourceId; 63 | lastCompleteType = __instance.ResourceType; 64 | } 65 | } 66 | 67 | // ============ ProcessRequest loop ============ 68 | 69 | // Cache or access to original manager states 70 | private static HBS.Logging.ILog logger; 71 | 72 | public static bool Override_ProcessRequests ( DataManager __instance, List ___foregroundRequestsList, uint ___foregroundRequestsCurrentAllowedWeight ) { try { 73 | if ( UnpatchManager ) return true; 74 | for ( int i = 0, len = ___foregroundRequestsList.Count ; i < len ; i++ ) { 75 | DataManager.DataManagerLoadRequest request = ___foregroundRequestsList[ i ]; 76 | if ( request.State != DataManager.DataManagerLoadRequest.RequestState.Requested ) continue; 77 | request.RequestWeight.SetAllowedWeight( ___foregroundRequestsCurrentAllowedWeight ); 78 | if ( request.IsMemoryRequest ) 79 | __instance.RemoveObjectOfType( request.ResourceId, request.ResourceType ); 80 | if ( ! request.ManifestEntryValid ) 81 | LogManifestEntryValid( request ); 82 | else if (!request.RequestWeight.RequestAllowed) 83 | request.NotifyLoadComplete(); 84 | else { 85 | if ( DebugLog ) Trace( "Loading {0} {1}", request.ResourceType, request.ResourceId ); 86 | request.Load(); 87 | } 88 | } 89 | return false; 90 | } catch ( Exception ex ) { return KillManagerPatch( ex ); } } 91 | 92 | private static void LogManifestEntryValid ( DataManager.DataManagerLoadRequest request ) { 93 | logger.LogError( string.Format("LoadRequest for {0} of type {1} has an invalid manifest entry. Any requests for this object will fail.", request.ResourceId, request.ResourceType ) ); 94 | request.NotifyLoadFailed(); 95 | } 96 | 97 | private static BattleTechResourceType lastResourceType; 98 | private static string lastIdentifier; 99 | 100 | public static bool Prefix_RequestResource_Internal ( DataManager __instance, BattleTechResourceType resourceType, string identifier ) { 101 | if ( string.IsNullOrEmpty( identifier ) || ( identifier == lastIdentifier && resourceType == lastResourceType ) ) { 102 | if ( DebugLog ) Verbo( "Skipping empty or dup resource {0} {1}", resourceType, identifier ); 103 | return false; 104 | } 105 | lastResourceType = resourceType; 106 | lastIdentifier = identifier; 107 | return true; 108 | } 109 | 110 | // ============ Safety System: Kill Switch and Logging ============ 111 | 112 | private static bool KillManagerPatch ( Exception err ) { try { 113 | Error( err ); 114 | Info( "Suicide due to exception." ); 115 | UnpatchManager = true; 116 | return true; 117 | } catch ( Exception ex ) { 118 | return Error( ex ); 119 | } } 120 | 121 | internal static Logger ModLog = BattleMod.BT_LOG; 122 | 123 | public static void Trace ( object message = null, params object[] args ) { ModLog.Trace( message, args ); } 124 | public static void Verbo ( object message = null, params object[] args ) { ModLog.Verbo( message, args ); } 125 | public static void Info ( object message = null, params object[] args ) { ModLog.Info ( message, args ); } 126 | public static void Warn ( object message = null, params object[] args ) { ModLog.Warn ( message, args ); } 127 | public static bool Error ( object message = null, params object[] args ) { ModLog.Error( message, args ); return true; } 128 | } 129 | } -------------------------------------------------------------------------------- /src/Logger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.IO; 5 | using System.Text; 6 | using System.Threading; 7 | 8 | namespace Sheepy.Logging { 9 | public class Logger : IDisposable { 10 | public Logger ( string file, string blockDelete ) : this( file, 1000, blockDelete ) { } 11 | public Logger ( string file, int writeDelay = 1000, string blockDelete = null ) { 12 | if ( string.IsNullOrEmpty( file ) ) throw new NullReferenceException(); 13 | BlockDeleteReason = blockDelete; 14 | LogFile = file.Trim(); 15 | if ( writeDelay < 0 ) return; 16 | this.writeDelay = writeDelay; 17 | queue = new List(); 18 | worker = new Thread( WorkerLoop ) { Name = "Logger " + LogFile, Priority = ThreadPriority.BelowNormal }; 19 | worker.Start(); 20 | } 21 | 22 | // ============ Self Prop ============ 23 | 24 | private readonly string BlockDeleteReason; // Non-null to block delete 25 | 26 | protected Func _LevelText = ( level ) => { //return level.ToString() + ": "; 27 | if ( level <= SourceLevels.Critical ) return "CRIT "; if ( level <= SourceLevels.Error ) return "ERR "; 28 | if ( level <= SourceLevels.Warning ) return "WARN "; if ( level <= SourceLevels.Information ) return "INFO "; 29 | if ( level <= SourceLevels.Verbose ) return "FINE "; return "TRAC "; 30 | }; 31 | protected string _TimeFormat = "hh:mm:ss.ffff ", _Prefix = null, _Postfix = null; 32 | protected List> _Filters = null; 33 | protected bool _IgnoreDuplicateExceptions = true; 34 | protected Action _OnError = ( ex ) => Console.Error.WriteLine( ex ); 35 | 36 | public class LogEntry { public DateTime time; public SourceLevels level; public object message; public object[] args; } 37 | 38 | // Worker states locked by queue which is private. 39 | private HashSet exceptions = new HashSet(); // Double as public get/set lock object 40 | private readonly List queue; 41 | private Thread worker; 42 | private int writeDelay; 43 | 44 | // ============ Public Prop ============ 45 | 46 | public static string Stacktrace { get { return new StackTrace( true ).ToString(); } } 47 | public string LogFile { get; private set; } 48 | 49 | public volatile SourceLevels LogLevel = SourceLevels.Information; 50 | // Time format, placed at the beginning of every line. 51 | public string TimeFormat { 52 | get { lock( exceptions ) { return _TimeFormat; } } 53 | set { lock( exceptions ) { _TimeFormat = value; } } } 54 | // Level format, placed between time and line. 55 | public Func LevelText { 56 | get { lock( exceptions ) { return _LevelText; } } 57 | set { lock( exceptions ) { _LevelText = value; } } } 58 | // String to add to the start of every line on write (not on log). 59 | public string Prefix { 60 | get { lock( exceptions ) { return _Prefix; } } 61 | set { lock( exceptions ) { _Prefix = value; } } } 62 | // String to add to the end of every line on write (not on log). 63 | public string Postfix { 64 | get { lock( exceptions ) { return _Postfix; } } 65 | set { lock( exceptions ) { _Postfix = value; } } } 66 | // Ignores duplicate exception logging (the exception must be the primary logged object, not as a parameter). 67 | public bool IgnoreDuplicateExceptions { 68 | get { lock( exceptions ) { return _IgnoreDuplicateExceptions; } } 69 | set { lock( exceptions ) { _IgnoreDuplicateExceptions = value; } } } 70 | // Handles "environmental" errors such as unable to write or delete log. Does not handle logical errors like log after dispose. 71 | public Action OnError { 72 | get { lock( exceptions ) { return _OnError; } } 73 | set { lock( exceptions ) { _OnError = value; } } } 74 | 75 | // ============ API ============ 76 | 77 | public virtual bool Exists () { return File.Exists( LogFile ); } 78 | 79 | public virtual void Delete () { 80 | if ( BlockDeleteReason != null ) { 81 | HandleError( new ApplicationException( "Cannot delete " + LogFile + ": " + BlockDeleteReason ) ); 82 | return; 83 | } 84 | try { 85 | File.Delete( LogFile ); 86 | } catch ( Exception e ) { HandleError( e ); } 87 | } 88 | 89 | public void Log ( SourceLevels level, object message, params object[] args ) { 90 | if ( ( level & LogLevel ) != level ) return; 91 | LogEntry entry = new LogEntry(){ time = DateTime.Now, level = level, message = message, args = args }; 92 | if ( queue != null ) lock ( queue ) { 93 | if ( worker == null ) throw new InvalidOperationException( "Logger already disposed." ); 94 | queue.Add( entry ); 95 | Monitor.Pulse( queue ); 96 | } else lock ( queue ) { 97 | OutputLog( _Filters, entry ); 98 | } 99 | } 100 | 101 | // Each filter may modify the line, and may return false to exclude an input line from logging. 102 | // First input is unformatted log line, second input is log entry. 103 | public bool AddFilter ( Func filter ) { lock( queue ) { 104 | if ( filter == null ) return false; 105 | if ( _Filters == null ) _Filters = new List>(); 106 | else if ( _Filters.Contains( filter ) ) return false; 107 | _Filters.Add( filter ); 108 | return true; 109 | } } 110 | 111 | public bool RemoveFilter ( Func filter ) { lock( queue ) { 112 | if ( filter == null || _Filters == null ) return false; 113 | bool result = _Filters.Remove( filter ); 114 | if ( result && _Filters.Count <= 0 ) _Filters = null; 115 | return result; 116 | } } 117 | 118 | public void Trace ( object message = null, params object[] args ) { Log( SourceLevels.ActivityTracing, message, args ); } 119 | public void Verbo ( object message = null, params object[] args ) { Log( SourceLevels.Verbose, message, args ); } 120 | public void Info ( object message = null, params object[] args ) { Log( SourceLevels.Information, message, args ); } 121 | public void Warn ( object message = null, params object[] args ) { Log( SourceLevels.Warning, message, args ); } 122 | public void Error ( object message = null, params object[] args ) { Log( SourceLevels.Error, message, args ); } 123 | 124 | // ============ Implementations ============ 125 | 126 | private void HandleError ( Exception ex ) { try { 127 | lock( exceptions ) { _OnError?.Invoke( ex ); } 128 | } catch ( Exception ) { } } 129 | 130 | private void WorkerLoop () { 131 | do { 132 | int delay = 0; 133 | lock ( queue ) { 134 | if ( worker == null ) return; 135 | try { 136 | if ( queue.Count <= 0 ) Monitor.Wait( queue ); 137 | } catch ( ThreadInterruptedException ) { } 138 | delay = writeDelay; 139 | } 140 | if ( delay > 0 ) 141 | Thread.Sleep( writeDelay ); 142 | Flush(); 143 | } while ( true ); 144 | } 145 | 146 | public bool? Flush () { 147 | Func[] filters; 148 | LogEntry[] entries; 149 | lock ( queue ) { 150 | filters = _Filters?.ToArray(); 151 | entries = queue.ToArray(); 152 | queue.Clear(); 153 | } 154 | return OutputLog( filters, entries ); 155 | } 156 | 157 | private bool? OutputLog ( IEnumerable> filters, params LogEntry[] entries ) { 158 | if ( entries.Length <= 0 ) return null; 159 | StringBuilder buf = new StringBuilder(); 160 | lock ( exceptions ) { // Not expecting settings to change frequently. Lock outside format loop for higher throughput. 161 | foreach ( LogEntry line in entries ) try { 162 | if ( filters != null ) foreach ( Func filter in filters ) try { 163 | if ( ! filter( line ) ) continue; 164 | } catch ( Exception ) { } 165 | string txt = line.message?.ToString(); 166 | if ( ! string.IsNullOrEmpty( txt ) ) { 167 | if ( ! AllowMessagePass( line, txt ) ) continue; 168 | FormatMessage( buf, line, txt ); 169 | } 170 | NewLine( buf, line ); 171 | } catch ( Exception ex ) { 172 | buf?.Append( Environment.NewLine ); // Clear error'ed line 173 | HandleError( ex ); 174 | } 175 | } 176 | return OutputLog( buf ); 177 | } 178 | 179 | // Override to control which message get logged. 180 | protected virtual bool AllowMessagePass ( LogEntry line, string txt ) { 181 | if ( line.message is Exception ex && IgnoreDuplicateExceptions ) { 182 | if ( exceptions.Contains( txt ) ) return false; 183 | exceptions.Add( txt ); 184 | } 185 | return true; 186 | } 187 | 188 | // Override to change line/entry format. 189 | protected virtual void FormatMessage ( StringBuilder buf, LogEntry line, string txt ) { 190 | if ( ! string.IsNullOrEmpty( _TimeFormat ) ) 191 | buf.Append( line.time.ToString( _TimeFormat ) ); 192 | if ( _LevelText != null ) 193 | buf.Append( _LevelText( line.level ) ); 194 | buf.Append( _Prefix ); 195 | if ( line.args != null && line.args.Length > 0 && txt != null ) try { 196 | txt = string.Format( txt, line.args ); 197 | } catch ( FormatException ) {} 198 | buf.Append( txt ).Append( _Postfix ); 199 | } 200 | 201 | // Called after every entry, even null or empty. 202 | protected virtual void NewLine ( StringBuilder buf, LogEntry line ) { 203 | buf.Append( Environment.NewLine ); 204 | } 205 | 206 | // Override to change log output, e.g. to console, system event log, or development environment. 207 | protected virtual bool? OutputLog ( StringBuilder buf ) { try { 208 | if ( buf.Length <= 0 ) return null; 209 | File.AppendAllText( LogFile, buf.ToString() ); 210 | return true; 211 | } catch ( Exception ex ) { HandleError( ex ); return false; } } 212 | 213 | public void Dispose () { 214 | if ( queue != null ) lock ( queue ) { 215 | worker = null; 216 | writeDelay = 0; // Flush log immediately 217 | Monitor.Pulse( queue ); 218 | } 219 | } 220 | } 221 | } -------------------------------------------------------------------------------- /src/DataProcess.cs: -------------------------------------------------------------------------------- 1 | using BattleTech; 2 | using BattleTech.Data; 3 | using Harmony; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.IO; 7 | using System.Linq; 8 | using System.Security.Cryptography; 9 | using System.Text; 10 | using System.Text.RegularExpressions; 11 | using System.Threading; 12 | 13 | namespace Sheepy.BattleTechMod.Turbine { 14 | using static Mod; 15 | 16 | public class DataProcess : BattleModModule { 17 | 18 | // Replace HBS's regex comment parser with a manually coded high performance parser 19 | private const bool StripJSON = true; 20 | 21 | // Calculate data hash in multi-thread 22 | private const bool MultiThreadHash = true; 23 | 24 | // Optimise CSVReader.ReadRow 25 | private const bool OptimiseCsvReadRow = true; 26 | 27 | //public static void LogStart() { Info( "Start" ); } 28 | //public static void LogEnd() { Info( "End" ); } 29 | 30 | public override void ModStarts () { 31 | if ( StripJSON ) 32 | Patch( typeof( HBS.Util.JSONSerializationUtility ), "StripHBSCommentsFromJSON", nameof( Override_StripComments ), null ); 33 | if ( MultiThreadHash ) 34 | Patch( typeof( DataManager ), "GetDataHash", "MultiThreadDataHash", null ); 35 | if ( OptimiseCsvReadRow ) { 36 | csvField = new Regex( "((?<=\\\")(?>[^\\\"]*)(?=\\\"(,|$)+)|(?<=,|^)(?>[^,\\\"]*)(?=,|$))", RegexOptions.Multiline | RegexOptions.Compiled ); 37 | Patch( typeof( CSVReader ), "ReadRow", new Type[]{}, nameof( Override_CSVReader_ReadRow ), null ); 38 | } 39 | } 40 | 41 | public override void GameStarts () { 42 | // Patch with authorization - https://github.com/Sheep-y/BattleTech_Turbine/issues/8 43 | if ( BattleMod.FoundMod( "BattletechPerformanceFix.Control" ) ) { 44 | Type DontStripComments = AppDomain.CurrentDomain.GetAssemblies().Select( e => e.GetType( "BattletechPerformanceFix.DontStripComments" ) ).FirstOrDefault( e => e != null ); 45 | if ( DontStripComments != null ) 46 | Patch( DontStripComments, "HBSStripCommentsMirror", "Override_StripComments", null ); 47 | } 48 | } 49 | 50 | public static string Unescape ( string value ) { 51 | if ( value.StartsWith( "\"" ) && value.EndsWith( "\"" ) ) { 52 | value = value.Substring( 1, value.Length - 2 ); 53 | if ( value.Contains( "\"\"" ) ) value = value.Replace( "\"\"", "\"" ); 54 | } 55 | return value; 56 | } 57 | 58 | // ============ Json Process ============ 59 | 60 | private static bool commentDetected; 61 | 62 | [ HarmonyPriority( Priority.LowerThanNormal ) ] 63 | public static bool Override_StripComments ( ref string __result, string json ) { try { 64 | commentDetected = false; 65 | __result = StripComments( json ); 66 | if ( commentDetected ) // Try parse stripped result to make sure it is good 67 | fastJSON.JSON.Parse( __result ); 68 | return false; 69 | } catch ( Exception ex ) { 70 | return Error( ex ); 71 | } } 72 | 73 | public static string StripComments ( string json ) { 74 | if ( json == null ) return null; 75 | int pos = 0; 76 | StringBuilder buf = new StringBuilder( json.Length ); 77 | do { 78 | Loop: 79 | for ( int i = pos, len = json.Length - 2 ; i < len ; i++ ) { 80 | char a = json[ i ]; 81 | if ( a == '/' ) { // Detect //* to */ 82 | char b = Peek( json, i+1 ); 83 | if ( b == '/' ) { 84 | if ( Peek( json, i+2 ) == '*' ) { // //* to */ 85 | if ( SkipWS( buf, json, ref pos, i, i+3, "*/" ) ) goto Loop; 86 | } /*else { // Single line comment // to \n, conflict with url string and requires string state tracking 87 | if ( Skip( buf, json, ref pos, i, i+2, "\n" ) ) { 88 | buf.Append( '\n' ); 89 | goto Loop; 90 | } 91 | }*/ 92 | } else if ( b == '*' ) { // /* to */ 93 | if ( SkipWS( buf, json, ref pos, i, i+2, "*/" ) ) goto Loop; 94 | } 95 | } else if ( a == '<' && Match( json, i+1, "!--" ) ) { // 96 | if ( SkipWS( buf, json, ref pos, i, i+4, "-->" ) ) goto Loop; 97 | } 98 | } 99 | // Nothing found, copy everything and break 100 | buf.Append( json.Substring( pos ) ); 101 | break; 102 | } while ( true ); 103 | return buf.ToString(); 104 | } 105 | 106 | private static bool Match ( string json, int pos, String txt ) { 107 | if ( json.Length <= pos + txt.Length ) return false; 108 | string sub = json.Substring( pos, txt.Length ); 109 | return sub == txt; 110 | } 111 | private static bool SkipWS ( StringBuilder buf, string json, ref int pos, int skipStart, int headEnd, string until ) { 112 | if ( ! Skip( buf, json, ref pos, skipStart, headEnd, until ) ) return false; 113 | int len = json.Length; 114 | while ( pos < len ) { 115 | switch ( json[ pos ] ) { 116 | case ' ': case '\t': case '\r': case '\n': 117 | pos++; 118 | break; 119 | default: 120 | return true; 121 | } 122 | } 123 | return true; 124 | } 125 | private static bool Skip ( StringBuilder buf, string json, ref int pos, int skipStart, int headEnd, string until ) { 126 | if ( json.Length <= headEnd ) return false; 127 | int tailStart = json.IndexOf( until, headEnd ); 128 | if ( tailStart < 0 ) return false; 129 | if ( skipStart > 0 ) 130 | buf.Append( json.Substring( pos, skipStart - pos ) ); 131 | pos = tailStart + until.Length; 132 | commentDetected = true; 133 | return true; 134 | } 135 | private static char Peek ( String json, int pos ) { 136 | if ( json.Length <= pos ) return '\u0000'; 137 | return json[ pos ]; 138 | } 139 | 140 | // ============ Data Hash ============ 141 | 142 | private static byte[] SecretKey; 143 | private const int JobPerLoop = 16, HashSize = 32; 144 | 145 | public static bool MultiThreadDataHash ( ref string __result, byte[] ___secret_key, params BattleTechResourceType[] typesToHash ) { try { 146 | if ( DebugLog ) Verbo( "Prepare to get data hash." ); 147 | SecretKey = ___secret_key; 148 | if ( SecretKey == null ) throw new NullReferenceException( "DataManager.secret_key is null" ); 149 | 150 | int manifestCounter = 0, pos = 0; 151 | // For me, over half the pre-Turbine time is spent on this new BattleTechResourceLocator. Post-Turbine it consume most time! 152 | BattleTechResourceLocator battleTechResourceLocator = new BattleTechResourceLocator(); 153 | Dictionary manifestMap = new Dictionary( 4000 ); // Vanilla has 900+. Mods may adds a lot more. 154 | foreach ( BattleTechResourceType type in typesToHash ) 155 | foreach ( VersionManifestEntry versionManifestEntry in battleTechResourceLocator.AllEntriesOfResource( type ) ) 156 | manifestMap.Add( manifestCounter++, versionManifestEntry ); 157 | battleTechResourceLocator = null; 158 | 159 | Dictionary hashMap = new Dictionary(); 160 | RunHashs( manifestMap, hashMap ); 161 | 162 | byte[] allHash = new byte[ HashSize * hashMap.Count ]; 163 | for ( int i = 0 ; i < manifestCounter ; i++ ) { 164 | if ( ! hashMap.TryGetValue( i, out byte[] hash ) ) continue; 165 | Buffer.BlockCopy( hash, 0, allHash, pos, HashSize ); 166 | pos += HashSize; 167 | } 168 | __result = Convert.ToBase64String( new HMACSHA256( SecretKey ).ComputeHash( allHash ) ); 169 | if ( DebugLog ) Verbo( "Hash = {0}", __result ); 170 | return false; 171 | } catch ( Exception ex ) { return Error( ex ); } } 172 | 173 | private static void RunHashs ( Dictionary manifestList, Dictionary hashSet ) { 174 | int workingThread = Math.Max( 2, Math.Min( Environment.ProcessorCount*2, 32 ) ); 175 | Info( "Calculating data hash with {0} threads.", workingThread ); 176 | for ( int i = 0 ; i < workingThread ; i++ ) { 177 | new Thread( () => { 178 | HMACSHA256 hasher = new HMACSHA256( SecretKey ); 179 | Dictionary taskList = new Dictionary( JobPerLoop ); 180 | Dictionary resultList = new Dictionary(); 181 | do { 182 | lock( manifestList ) { 183 | foreach ( var manifest in manifestList.Take( JobPerLoop ) ) taskList.Add( manifest.Key, manifest.Value ); 184 | foreach ( int id in taskList.Keys ) manifestList.Remove( id ); 185 | } 186 | if ( taskList.Count <= 0 ) break; 187 | foreach ( var task in taskList ) try { 188 | VersionManifestEntry versionManifestEntry = task.Value; 189 | if ( versionManifestEntry.IsAssetBundled || versionManifestEntry.IsResourcesAsset || ! File.Exists( versionManifestEntry.FilePath ) ) 190 | continue; 191 | using ( FileStream fileStream = new FileStream( versionManifestEntry.FilePath, FileMode.Open, FileAccess.Read ) ) { 192 | resultList.Add( task.Key, hasher.ComputeHash( fileStream ) ); 193 | } 194 | } catch ( Exception ex ) { 195 | Error( "Cannot hash {0}: {1}", task.Value.FilePath, ex ); 196 | } 197 | taskList.Clear(); 198 | } while ( true ); 199 | lock( hashSet ) { 200 | foreach ( var result in resultList ) hashSet.Add( result.Key, result.Value ); 201 | if ( --workingThread <= 0 ) 202 | Monitor.Pulse( hashSet ); 203 | } 204 | } ).Start(); 205 | } 206 | lock( hashSet ) { 207 | if ( workingThread > 0 ) 208 | Monitor.Wait( hashSet ); 209 | } 210 | } 211 | 212 | // ============ CSVReader ============ 213 | 214 | // Compiled and shared regex 215 | private static Regex csvField; 216 | 217 | public static bool Override_CSVReader_ReadRow ( ref List __result, ref int ___activeIdx, ref string[] ___rows ) { 218 | if ( ___activeIdx < 0 || ___activeIdx >= ___rows.Length ) { 219 | __result = null; 220 | return false; 221 | } 222 | string row = ___rows[ ___activeIdx++ ]; 223 | if ( row.Contains( '"' ) ) { 224 | __result = new List( 11 ); 225 | foreach ( object match in csvField.Matches( row ) ) 226 | __result.Add( Unescape( match.ToString() ) ); 227 | } else 228 | __result = row.Split( ',' ).ToList(); 229 | 230 | return false; 231 | } 232 | } 233 | } -------------------------------------------------------------------------------- /src/BattleMod.cs: -------------------------------------------------------------------------------- 1 | using BattleTech; 2 | using BattleTech.UI; 3 | using Harmony; 4 | using Localize; 5 | using Newtonsoft.Json; 6 | using Newtonsoft.Json.Linq; 7 | using Newtonsoft.Json.Serialization; 8 | using System; 9 | using System.Collections.Generic; 10 | using System.IO; 11 | using System.Linq; 12 | using System.Threading; 13 | using System.Reflection; 14 | using System.Text; 15 | using System.Text.RegularExpressions; 16 | using static System.Reflection.BindingFlags; 17 | 18 | namespace Sheepy.BattleTechMod { 19 | using Sheepy.Logging; 20 | 21 | public abstract class BattleMod : BattleModModule { 22 | 23 | public static readonly Logger BTML_LOG = new Logger( "Mods/BTModLoader.log", "BTML log should not be deleted." ); 24 | public static readonly Logger BT_LOG = new Logger( "BattleTech_Data/output_log.txt", "BattleTech game log should not be deleted." ); 25 | 26 | // Basic mod info for public access, will auto load from assembly then mod.json (if exists) 27 | public string Version { get; protected set; } = "Unknown"; 28 | 29 | protected BattleMod () { 30 | ReadBasicModInfo(); 31 | } 32 | 33 | public void Start () { Logger log = Log; Start( ref log ); } 34 | public void Start ( ref Logger log ) { 35 | CurrentMod = this; 36 | TryRun( Setup ); // May be overloaded 37 | if ( log != Log ) 38 | log = Log; 39 | log.AddFilter( FormatParameters ); 40 | Add( this ); 41 | PatchBattleMods(); 42 | CurrentMod = null; 43 | } 44 | 45 | public string BaseDir { get; protected set; } 46 | private string _LogDir; 47 | public string LogDir { 48 | get { return _LogDir; } 49 | protected set { 50 | _LogDir = value; 51 | Log = new Logger( GetLogFile() ); 52 | } 53 | } 54 | public HarmonyInstance ModHarmony { get; internal set; } 55 | 56 | // ============ Setup ============ 57 | 58 | internal static BattleMod CurrentMod; 59 | 60 | #pragma warning disable CS0649 // Disable "field never set" warnings since they are set by JsonConvert. 61 | private class ModInfo { public string Name; public string Version; } 62 | #pragma warning restore CS0649 63 | 64 | // Fill in blanks with Assembly values, then read from mod.json 65 | private void ReadBasicModInfo () { TryRun( Log, () => { 66 | Assembly file = GetType().Assembly; 67 | Id = GetType().Namespace; 68 | Name = file.GetName().Name; 69 | BaseDir = Path.GetDirectoryName( file.Location ) + "/"; 70 | string mod_info_file = BaseDir + "mod.json"; 71 | if ( File.Exists( mod_info_file ) ) TryRun( Log, () => { 72 | ModInfo info = JsonConvert.DeserializeObject( File.ReadAllText( mod_info_file ) ); 73 | if ( ! string.IsNullOrEmpty( info.Name ) ) 74 | Name = info.Name; 75 | if ( ! string.IsNullOrEmpty( info.Version ) ) 76 | Version = info.Version; 77 | } ); 78 | LogDir = BaseDir; // Create Logger after Name is read from mod.json 79 | } ); } 80 | 81 | // Override this method to override Namd, Id, or Logger. Remember to call this base method! 82 | protected virtual void Setup () { 83 | Log.Delete(); 84 | Log.Info( "{0:yyyy-MM-dd} Loading {1} Version {2} @ {3}", DateTime.Now, Name, Version, BaseDir ); 85 | Log.Info( "Game Version {0}, Harmony Version {1}" + Environment.NewLine, VersionInfo.ProductVersion, typeof(HarmonyInstance).Assembly.GetName().Version ); 86 | } 87 | 88 | public static string Idify ( string text ) { return new Regex( "\\W+" ).Split( text ).Concat( "", UppercaseFirst ); } 89 | 90 | protected virtual string GetLogFile () { 91 | return LogDir + "Log_" + Idify( Name ) + ".txt"; 92 | } 93 | 94 | // Load settings from settings.json, call SanitizeSettings, and create/overwrite it if the content is different. 95 | protected virtual void LoadSettings ( ref Settings settings, Action sanitise = null ) { 96 | string file = BaseDir + "settings.json", fileText = ""; 97 | Settings config = settings; 98 | if ( File.Exists( file ) ) TryRun( () => { 99 | fileText = File.ReadAllText( file ); 100 | if ( fileText.Contains( "\"Name\"" ) && fileText.Contains( "\"DLL\"" ) && fileText.Contains( "\"Settings\"" ) ) TryRun( Log, () => { 101 | JObject modInfo = JObject.Parse( fileText ); 102 | if ( modInfo.TryGetValue( "Settings", out JToken embedded ) ) 103 | fileText = embedded.ToString( Formatting.None ); 104 | } ); 105 | config = JsonConvert.DeserializeObject( fileText ); 106 | } ); 107 | if ( sanitise != null ) 108 | TryRun( () => sanitise( config ) ); 109 | 110 | ThreadPool.QueueUserWorkItem( ( obj ) => { 111 | string sanitised; 112 | sanitised = JsonConvert.SerializeObject( obj, Formatting.Indented, new JsonSerializerSettings { ContractResolver = new BattleJsonContract() } ); 113 | sanitised = Regex.Replace( sanitised, @"(?<=\d)\.0+(?=,\r?\n)", "" ); // Convert 1.0000000000 to 1 114 | sanitised = Regex.Replace( sanitised, @"(?<=\d\.\d+)0+(?=,\r?\n)", "" ); // Convert 1.20000000 to 1.2 115 | Log.Info( "WARNING: Do NOT change settings here. This is just a log." ); 116 | Log.Info( "Loaded Settings: " + sanitised ); 117 | Log.Info( "WARNING: Do NOT change settings here. This is just a log." ); // Yes. It is intentionally repeated. 118 | string commented = BattleJsonContract.FormatSettingJsonText( obj.GetType(), sanitised ); 119 | if ( commented != fileText ) { // Can be triggered by comment or field updates, not necessary sanitisation. 120 | Log.Info( "Background: Updating " + file ); 121 | SaveSettings( commented ); 122 | } 123 | }, typeof( object ).GetMethod( "MemberwiseClone", NonPublic | Instance ).Invoke( config, null ) ); 124 | settings = config; 125 | } 126 | 127 | protected void SaveSettings ( Settings settings_object ) { 128 | SaveSettings( JsonConvert.SerializeObject( settings_object, Formatting.Indented ) ); 129 | } 130 | 131 | private void SaveSettings ( string settings ) { 132 | TryRun( Log, () => File.WriteAllText( BaseDir + "settings.json", settings ) ); 133 | } 134 | 135 | // ============ Logging ============ 136 | 137 | private static bool FormatParameters ( Logger.LogEntry line ) { 138 | if ( line == null ) return true; 139 | object[] args = line?.args; 140 | // Convert Log( data ) to Log( "{0}", data ) so that it can be formatted 141 | if ( ! ( line.message is string ) && args.IsNullOrEmpty() && ! ( line.message is Exception ) ) { 142 | line.args = args = new object[]{ line.message }; 143 | line.message = "{0}"; 144 | } else if ( args == null ) 145 | return true; 146 | // Format parameters 147 | for ( int i = 0, len = args.Length ; i < len ; i++ ) 148 | args[ i ] = FormatParameter( args[i] ); 149 | return true; 150 | } 151 | 152 | public static object FormatParameter ( object arg ) { return FormatParameter( arg, 0 ); } 153 | public static object FormatParameter ( object arg, int level ) { 154 | if ( arg == null || arg is string || level > 10 ) return arg; 155 | if ( arg is UnityEngine.Color color ) 156 | return "#" + UnityEngine.ColorUtility.ToHtmlStringRGBA( color ); 157 | if ( arg is UnityEngine.Vector2 vet2 ) 158 | return "[x" + vet2.x + ",y" + vet2.y + "]"; 159 | if ( arg is UnityEngine.Vector3 vet3 ) 160 | return "[x" + vet3.x + ",y" + vet3.y + ",z" + vet3.z + "]"; 161 | if ( arg is ValueType ) return arg; 162 | if ( arg is Text text ) 163 | return text.ToString( true ); 164 | if ( arg is ICombatant unit ) 165 | return unit.DisplayName.ToString() + " (" + unit.GetPilot()?.Callsign + ( unit.team.IsLocalPlayer ? ",PC" : ",NPC" ) + ")"; 166 | if ( arg is MechComponent comp ) 167 | return comp.UIName.ToString() + ( string.IsNullOrEmpty( comp.uid ) ? "" : " (#" + comp.uid + ")" ); 168 | if ( arg is MechComponentDef def ) 169 | return def.Description.Id; 170 | if ( arg is System.Collections.IEnumerable list ) 171 | return "[" + list.Concat( ", ", e => FormatParameter( e, level + 1 )?.ToString() ) + "]"; 172 | return arg.ToString(); 173 | } 174 | 175 | // ============ Execution ============ 176 | 177 | private static Dictionary> modules = new Dictionary>(); 178 | 179 | public BattleMod Add ( BattleModModule module ) { 180 | if ( ! modules.TryGetValue( this, out List list ) ) 181 | modules.Add( this, list = new List() ); 182 | if ( ! list.Contains( module ) ) { 183 | if ( module != this ) 184 | if ( module.Id == Id ) module.Id += "." + Idify( module.Name ); 185 | list.Add( module ); 186 | TryRun( Log, module.ModStarts ); 187 | } 188 | return this; 189 | } 190 | 191 | private static bool GameStartPatched = false; 192 | 193 | public void PatchBattleMods () { 194 | if ( GameStartPatched ) return; 195 | Patch( typeof( UnityGameInstance ).GetMethod( "InitUserSettings", Instance | NonPublic ), null, typeof( BattleMod ).GetMethod( "RunGameStarts", Static | NonPublic ) ); 196 | Patch( typeof( SimGameState ).GetMethod( "Init" ), null, typeof( BattleMod ).GetMethod( "RunCampaignStarts", Static | NonPublic ) ); 197 | Patch( typeof( CombatHUD ).GetMethod( "Init", new Type[]{ typeof( CombatGameState ) } ), null, typeof( BattleMod ).GetMethod( "RunCombatStarts", Static | NonPublic ) ); 198 | Patch( typeof( CombatHUD ).GetMethod( "OnCombatGameDestroyed", new Type[]{} ), null, typeof( BattleMod ).GetMethod( "RunCombatEnds", Static | NonPublic ) ); 199 | GameStartPatched = true; 200 | } 201 | 202 | private static bool CalledGameStartsOnce = false; 203 | private static void RunGameStarts () { 204 | BattleTechGame = UnityGameInstance.BattleTechGame; 205 | if ( ! CalledGameStartsOnce ) { 206 | CallAllModules( module => module.GameStartsOnce() ); 207 | CalledGameStartsOnce = true; 208 | } 209 | CallAllModules( module => module.GameStarts() ); 210 | } 211 | 212 | private static bool CalledCampaignStartsOnce = false; 213 | private static void RunCampaignStarts () { 214 | Simulation = BattleTechGame?.Simulation; 215 | SimulationConstants = Simulation?.Constants; 216 | if ( ! CalledCampaignStartsOnce ) { 217 | CallAllModules( module => module.CampaignStartsOnce() ); 218 | CalledCampaignStartsOnce = true; 219 | } 220 | CallAllModules( module => module.CampaignStarts() ); 221 | } 222 | 223 | private static bool CalledCombatStartsOnce = false; 224 | private static void RunCombatStarts ( CombatHUD __instance ) { 225 | HUD = __instance; 226 | Combat = BattleTechGame?.Combat; 227 | CombatConstants = Combat?.Constants; 228 | if ( ! CalledCombatStartsOnce ) { 229 | CallAllModules( module => module.CombatStartsOnce() ); 230 | CalledCombatStartsOnce = true; 231 | } 232 | CallAllModules( module => module.CombatStarts() ); 233 | } 234 | 235 | private static void RunCombatEnds ( CombatHUD __instance ) { 236 | CallAllModules( module => module.CombatEnds() ); 237 | CombatConstants = null; 238 | Combat = null; 239 | HUD = null; 240 | } 241 | 242 | private static void CallAllModules ( Action task ) { 243 | foreach ( var mod in modules ) { 244 | foreach ( BattleModModule module in mod.Value ) try { 245 | task( module ); 246 | } catch ( Exception ex ) { 247 | mod.Key.Log.Error( ex ); 248 | } 249 | } 250 | } 251 | 252 | private static HashSet modList; 253 | public static string[] GetModList() { 254 | if ( modList == null ) { 255 | if ( BattleTechGame == null ) 256 | throw new InvalidOperationException( "Mod List is not known until GameStartsOnce." ); 257 | modList = new HashSet(); 258 | try { 259 | foreach ( MethodBase method in PatchProcessor.AllPatchedMethods() ) 260 | modList.UnionWith( PatchProcessor.GetPatchInfo( method ).Owners ); 261 | // Some mods may not leave a harmony trace and can only be parsed from log 262 | Regex regx = new Regex( " in type \"([^\"]+)\"", RegexOptions.Compiled ); 263 | foreach ( string line in File.ReadAllLines( "Mods/BTModLoader.log" ) ) { 264 | Match match = regx.Match( line ); 265 | if ( match.Success ) modList.Add( match.Groups[1].Value ); 266 | } 267 | } catch ( Exception ex ) { 268 | BattleMod.BTML_LOG.Error( ex ); 269 | } 270 | } 271 | return modList.ToArray(); 272 | } 273 | 274 | public static bool FoundMod ( params string[] mods ) { 275 | if ( modList == null ) GetModList(); 276 | for ( int i = 0, len = mods.Length ; i < len ; i++ ) 277 | if ( modList.Contains( mods[i] ) ) return true; 278 | return false; 279 | } 280 | } 281 | 282 | public abstract class BattleModModule { 283 | 284 | // Set on GameStarts 285 | public static GameInstance BattleTechGame { get; internal set; } 286 | // Set on CampaignStarts 287 | public static SimGameState Simulation { get; internal set; } 288 | public static SimGameConstants SimulationConstants { get; internal set; } 289 | // Set on CombatStarts 290 | public static CombatGameState Combat { get; internal set; } 291 | public static CombatGameConstants CombatConstants { get; internal set; } 292 | public static CombatHUD HUD { get; internal set; } 293 | public static SelectionState ActiveState { get => HUD?.SelectionHandler?.ActiveState; } 294 | public static UIManager uiManager { get => HBS.LazySingletonBehavior.Instance; } 295 | 296 | public virtual void ModStarts () {} 297 | public virtual void GameStartsOnce () { } 298 | public virtual void GameStarts () {} 299 | public virtual void CampaignStartsOnce () { } 300 | public virtual void CampaignStarts () {} 301 | public virtual void CombatStartsOnce () {} 302 | public virtual void CombatStarts () {} 303 | public virtual void CombatEnds () {} 304 | 305 | protected BattleMod Mod { get; private set; } 306 | 307 | // ============ Basic ============ 308 | 309 | public BattleModModule () { 310 | if ( this is BattleMod modbase ) 311 | Mod = modbase; 312 | else { 313 | Mod = BattleMod.CurrentMod; 314 | if ( Mod == null ) 315 | throw new ApplicationException( "Mod module should be created in BattleMod.ModStart()." ); 316 | Id = Mod.Id; 317 | Log = Mod.Log; 318 | } 319 | } 320 | 321 | public string Id { get; protected internal set; } = "org.example.mod.module"; 322 | public string Name { get; protected internal set; } = "Module"; 323 | 324 | // ============ Logging ============ 325 | 326 | private Logger _Logger; 327 | protected Logger Log { 328 | get { return _Logger ?? BattleMod.BTML_LOG; } 329 | set { _Logger = value; } 330 | } 331 | 332 | public void LogGuiTree ( UnityEngine.Component root ) { LogGuiTree( Log, root ); } 333 | public static void LogGuiTree ( Logger Log, UnityEngine.Component root ) { 334 | StringBuilder buf = new StringBuilder( "GUI Tree:\n" ); 335 | buf.EnsureCapacity( 1024 * 16 ); 336 | LogGuiTree( root as UnityEngine.Transform ?? root?.transform, buf, "" ); 337 | Log.Info( buf.ToString() ); 338 | } 339 | 340 | // Based on CptMoore's MechEngineer: https://github.com/CptMoore/MechEngineer/blob/v0.8.27/source/Features/MechLabSlots/GUILogUtils.cs#L99 341 | public static void LogGuiTree ( UnityEngine.Transform transform, StringBuilder buf, string indent = "" ) { 342 | if ( transform == null ) return; 343 | Func format = BattleMod.FormatParameter; 344 | buf.Append( indent ).AppendFormat( transform.name ); 345 | if ( transform.tag != "Untagged" ) buf.AppendFormat( " #{0}", transform.tag ); 346 | buf.AppendFormat( " world={0} local={1}", format( transform.position ), format( transform.localPosition ) ); 347 | if ( transform.GetComponent() is UnityEngine.RectTransform rect ) 348 | buf.AppendFormat( " rect={0} anchor={1}", rect.rect, format( rect.anchoredPosition ) ); 349 | if ( transform.GetComponent() is UnityEngine.MeshRenderer mesh ) 350 | buf.AppendFormat( " mesh={0} material={1} color={2}", mesh.name, mesh.material?.name, format( mesh.material?.color ) ); 351 | if ( transform.GetComponent() is TMPro.TextMeshProUGUI textComponent ) 352 | buf.AppendFormat( " font={0} color={1} text={2}", textComponent.font?.name, format( textComponent.color ), textComponent.text.Replace( "\n", "\\n" ) ); 353 | if ( indent.Length > 20 ) return; 354 | foreach ( UnityEngine.Transform child in transform ) 355 | LogGuiTree( child, buf.Append( '\n' ), indent + " " ); 356 | } 357 | 358 | // ============ Harmony ============ 359 | 360 | /* Find and create a HarmonyMethod from this class. method must be public and has unique name. */ 361 | protected HarmonyMethod MakePatch ( string method ) { 362 | if ( string.IsNullOrEmpty( method ) ) return null; 363 | MethodInfo mi = GetType().GetMethod( method, Static | Public | NonPublic ); 364 | if ( mi == null ) { 365 | Log.Error( "Cannot find patch method " + method ); 366 | return null; 367 | } 368 | return new HarmonyMethod( mi ); 369 | } 370 | 371 | public void Patch ( Type patchedClass, string patchedMethod, string prefix, string postfix, string transpiler = null ) { 372 | Patch( patchedClass, patchedMethod, (Type[]) null, prefix, postfix, transpiler ); 373 | } 374 | 375 | public void Patch ( Type patchedClass, string patchedMethod, Type parameterType, string prefix, string postfix, string transpiler = null ) { 376 | Patch( patchedClass, patchedMethod, new Type[]{ parameterType }, prefix, postfix, transpiler ); 377 | } 378 | 379 | public void Patch ( Type patchedClass, string patchedMethod, Type[] parameterTypes, string prefix, string postfix, string transpiler = null ) { 380 | BindingFlags flags = Public | NonPublic | Instance | Static; 381 | MethodInfo patched = null; 382 | Exception ex = null; 383 | try { 384 | if ( parameterTypes == null ) 385 | patched = patchedClass.GetMethod( patchedMethod, flags ); 386 | else 387 | patched = patchedClass.GetMethod( patchedMethod, flags, null, parameterTypes, null ); 388 | } catch ( Exception e ) { ex = e; } 389 | if ( patched == null ) { 390 | Log.Error( "Cannot find {0}.{1}(...) to patch {2}", patchedClass.Name, patchedMethod, ex ); 391 | return; 392 | } 393 | Patch( patched, prefix, postfix, transpiler ); 394 | } 395 | 396 | public void Patch ( MethodBase patched, string prefix, string postfix, string transpiler = null ) { 397 | HarmonyMethod pre = MakePatch( prefix ), post = MakePatch( postfix ), trans = MakePatch( transpiler ); 398 | if ( AllNull( pre, post, trans ) ) return; // MakePatch would have reported method not found 399 | Patch( patched, pre, post, trans ); 400 | } 401 | 402 | public void Patch ( MethodBase patched, MethodInfo prefix, MethodInfo postfix, MethodInfo transpiler = null ) { 403 | Patch( patched, new HarmonyMethod( prefix ), new HarmonyMethod( postfix ), new HarmonyMethod( transpiler ) ); 404 | } 405 | 406 | public void Patch ( MethodBase patched, HarmonyMethod prefix, HarmonyMethod postfix, HarmonyMethod transpiler = null ) { 407 | string pre = prefix?.method?.Name, post = postfix?.method?.Name, trans = transpiler?.method?.Name; 408 | if ( patched == null ) { 409 | Log.Error( "Method not found. Cannot patch [ {0} : {1} ]", pre, post ); 410 | return; 411 | } 412 | if ( Mod.ModHarmony == null ) { 413 | Mod.ModHarmony = HarmonyInstance.Create( Id ); 414 | Log.Info( "Harmony instance \"{0}\"", Id ); 415 | } 416 | Mod.ModHarmony.Patch( patched, prefix, postfix, transpiler ); 417 | Log.Verbo( "Patched: {0} {1} [ {2} : {3} : {4} ]", patched.DeclaringType, patched, pre, post, trans ); 418 | } 419 | 420 | public static IEnumerable LogIL ( IEnumerable input, Logger logger ) { 421 | List result = new List( 100 ); 422 | int index = 0; 423 | foreach ( CodeInstruction code in input ) { 424 | logger.Info( "{0,3} {1}", index++, code ); 425 | result.Add( code ); 426 | } 427 | return result; 428 | } 429 | 430 | public static IEnumerable ReplaceIL ( IEnumerable input, Func matcher, Func replacer, int limit = 0, string action = "anonymous transpiler", Logger logger = null ) { 431 | int found = 0; 432 | List result = new List( 100 ); 433 | foreach ( CodeInstruction code in input ) { 434 | if ( ( limit <= 0 || found < limit ) && matcher( code ) ) { 435 | result.Add( replacer( code ) ); 436 | ++found; 437 | } else 438 | result.Add( code ); 439 | } 440 | if ( found == 0 ) 441 | ( logger ?? BattleMod.BTML_LOG ).Warn( "Cannot found IL code to replace for {0}.", action ); 442 | return result; 443 | } 444 | 445 | // ============ UTILS ============ 446 | 447 | public static string Translate ( string s, params object[] augs ) { 448 | if ( augs == null ) augs = new object[0]; 449 | return new Text( s, augs ).ToString( true ); 450 | } 451 | 452 | public static string UppercaseFirst ( string s ) { 453 | if ( string.IsNullOrEmpty( s ) ) return ""; 454 | return char.ToUpper( s[ 0 ] ) + s.Substring( 1 ); 455 | } 456 | 457 | public static string ReplaceFirst ( string text, string search, string replace ) { 458 | int pos = text.IndexOf( search ); 459 | if ( pos < 0 ) return text; 460 | int tLen = text.Length, sLen = search.Length, sEnd = pos + sLen; 461 | return new StringBuilder( tLen - sLen + replace.Length ) 462 | .Append( text, 0, pos ).Append( replace ).Append( text, sEnd, tLen - sEnd ) 463 | .ToString(); 464 | } 465 | 466 | public static string NullIfEmpty ( ref string value ) { 467 | if ( value == null ) return null; 468 | if ( value.Trim().Length <= 0 ) return value = null; 469 | return value; 470 | } 471 | 472 | public static void TryRun ( Action action ) { TryRun( BattleMod.BTML_LOG, action ); } 473 | public static void TryRun ( Logger log, Action action ) { try { 474 | action.Invoke(); 475 | } catch ( Exception ex ) { log.Error( ex ); } } 476 | 477 | public static T TryGet ( T[] array, int index, T fallback = default, string errorArrayName = null ) { 478 | if ( array == null || array.Length <= index ) { 479 | if ( errorArrayName != null ) BattleMod.BTML_LOG.Warn( $"{errorArrayName}[{index}] not found, using default {fallback}." ); 480 | return fallback; 481 | } 482 | return array[ index ]; 483 | } 484 | 485 | public static T TryGet ( List list, int index, T fallback = default, string errorArrayName = null ) { 486 | if ( list == null || list.Count <= index ) { 487 | if ( errorArrayName != null ) BattleMod.BTML_LOG.Warn( $"{errorArrayName}[{index}] not found, using default {fallback}." ); 488 | return fallback; 489 | } 490 | return list[ index ]; 491 | } 492 | 493 | public static V TryGet ( Dictionary map, T key, V fallback = default, string errorDictName = null ) { 494 | if ( map == null || ! map.ContainsKey( key ) ) { 495 | if ( errorDictName != null ) BattleMod.BTML_LOG.Warn( $"{errorDictName}[{key}] not found, using default {fallback}." ); 496 | return fallback; 497 | } 498 | return map[ key ]; 499 | } 500 | 501 | public static bool AllNull ( params T[] objects ) { 502 | for ( int i = objects.Length - 1 ; i >= 0 ; i-- ) 503 | if ( objects[ i ] != null ) return false; 504 | return true; 505 | } 506 | 507 | public static bool AnyNull ( params T[] objects ) { 508 | for ( int i = objects.Length - 1 ; i >= 0 ; i-- ) 509 | if ( objects[ i ] == null ) return true; 510 | return false; 511 | } 512 | 513 | public static void ThrowAnyNull ( string message, params T[] objects ) { 514 | for ( int i = objects.Length - 1 ; i >= 0 ; i-- ) 515 | if ( objects[ i ] == null ) 516 | throw new NullReferenceException( string.Format( message, i ) ); 517 | } 518 | 519 | public static T ValueCheck ( ref T value, T fallback = default, Func validate = null ) { 520 | if ( value == null ) value = fallback; 521 | else if ( validate != null && ! validate( value ) ) value = fallback; 522 | return value; 523 | } 524 | 525 | public static int RangeCheck ( string name, ref int val, int min, int max ) { 526 | decimal v = val; 527 | RangeCheck( name, ref v, min, min, max, max ); 528 | return val = (int) Math.Round( v ); 529 | } 530 | 531 | public static decimal RangeCheck ( string name, ref decimal val, decimal min, decimal max ) { 532 | return RangeCheck( name, ref val, min, min, max, max ); 533 | } 534 | 535 | public static decimal RangeCheck ( string name, ref decimal val, decimal shownMin, decimal realMin, decimal realMax, decimal shownMax ) { 536 | if ( realMin > realMax || shownMin > shownMax ) 537 | BattleMod.BTML_LOG.Error( "Incorrect range check params on " + name ); 538 | decimal orig = val; 539 | if ( val < realMin ) 540 | val = realMin; 541 | else if ( val > realMax ) 542 | val = realMax; 543 | if ( orig < shownMin && orig > shownMax ) { 544 | string message = "Warning: " + name + " must be "; 545 | if ( shownMin > decimal.MinValue ) 546 | if ( shownMax < decimal.MaxValue ) 547 | message += " between " + shownMin + " and " + shownMax; 548 | else 549 | message += " >= " + shownMin; 550 | else 551 | message += " <= " + shownMin; 552 | BattleMod.BTML_LOG.Info( message + ". Setting to " + val ); 553 | } 554 | return val; 555 | } 556 | } 557 | 558 | public static class BattleModExtensions { 559 | 560 | public static bool IsNullOrEmpty ( this Array array ) { 561 | return array == null || array.Length <= 0; 562 | } 563 | 564 | public static bool IsNullOrEmpty ( this System.Collections.ICollection collection ) { 565 | return collection == null || collection.Count <= 0; 566 | } 567 | 568 | public static string Concat ( this System.Collections.IEnumerable list, string separator = ", ", Func formatter = null ) { 569 | if ( list == null ) return ""; 570 | StringBuilder result = new StringBuilder(); 571 | foreach ( object e in list ) { 572 | if ( result.Length > 0 ) result.Append( separator ); 573 | result.Append( formatter == null ? e?.ToString() : formatter( e ) ); 574 | } 575 | return result.ToString(); 576 | } 577 | 578 | public static string Concat ( this IEnumerable list, string separator = ", ", Func formatter = null ) { 579 | if ( list == null ) return ""; 580 | StringBuilder result = new StringBuilder(); 581 | foreach ( TSource e in list ) { 582 | if ( result.Length > 0 ) result.Append( separator ); 583 | result.Append( formatter == null ? e?.ToString() : formatter( e ) ); 584 | } 585 | return result.ToString(); 586 | } 587 | 588 | } 589 | 590 | // 591 | // JSON serialisation 592 | // 593 | 594 | [AttributeUsage( AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false ) ] 595 | public class JsonSection : Attribute { 596 | public string Section; 597 | public JsonSection ( string section ) { Section = section ?? ""; } 598 | } 599 | 600 | [ AttributeUsage( AttributeTargets.Field | AttributeTargets.Property, Inherited = true, AllowMultiple = false ) ] 601 | public class JsonComment : Attribute { 602 | public string[] Comments; 603 | public JsonComment ( string comment ) { Comments = comment?.Split( '\n' ) ?? new string[]{ "" }; } 604 | public JsonComment ( string[] comments ) { Comments = comments ?? new string[]{}; } 605 | } 606 | 607 | public class BattleJsonContract : DefaultContractResolver { 608 | protected override List GetSerializableMembers ( Type type ) { 609 | return base.GetSerializableMembers( type ).Where( ( member ) => 610 | member.GetCustomAttributes( typeof( ObsoleteAttribute ), true ).Length <= 0 611 | ).ToList(); 612 | } 613 | 614 | private static readonly string Indent = " "; 615 | public static string FormatSettingJsonText ( Type type, string text ) { 616 | string NewLine = text.Contains( "\r\n" ) ? "\r\n" : "\n"; 617 | string NewIndent = NewLine + Indent; 618 | foreach ( MemberInfo member in type.GetMembers() ) { 619 | if ( ( member.MemberType | MemberTypes.Field | MemberTypes.Property ) == 0 ) continue; 620 | object[] sections = member.GetCustomAttributes( typeof( JsonSection ), true ); 621 | object[] comments = member.GetCustomAttributes( typeof( JsonComment ), true ); 622 | if ( sections.Length <= 0 && comments.Length <= 0 ) continue; 623 | string propName = NewLine + Indent + JsonConvert.ToString( member.Name ); 624 | string injection = ""; 625 | if ( sections.Length > 0 ) 626 | injection += NewLine + 627 | NewIndent + "//" + 628 | NewIndent + "// " + ( sections[0] as JsonSection )?.Section + 629 | NewIndent + "//" + NewLine + 630 | NewLine; 631 | if ( comments.Length > 0 ) { 632 | string[] lines = ( comments[0] as JsonComment )?.Comments; 633 | // Insert blank line if not new section 634 | if ( sections.Length <= 0 ) 635 | injection += NewLine + NewLine; 636 | // Actual property comment 637 | if ( lines.Length > 1 ) { 638 | injection += Indent + "/* " + lines[0]; 639 | for ( int i = 1, len = lines.Length ; i < len ; i++ ) 640 | injection += NewIndent + " * " + lines[i]; 641 | injection += " */"; 642 | } else if ( lines.Length > 0 ) 643 | injection += Indent + "/* " + lines[0] + " */"; 644 | injection += NewLine; 645 | } 646 | text = BattleModModule.ReplaceFirst( text, propName, injection + propName ); 647 | } 648 | return text; 649 | } 650 | } 651 | } --------------------------------------------------------------------------------