├── .editorconfig ├── .gitattributes ├── .gitignore ├── LICENSE.txt ├── README.md ├── ResoniteModLoader.sln ├── ResoniteModLoader ├── AssemblyFile.cs ├── AssemblyHider.cs ├── AssemblyLoader.cs ├── Attributes │ └── AutoRegisterConfigKeyAttribute.cs ├── ConfigurationChangedEvent.cs ├── DebugInfo.cs ├── DelegateExtensions.cs ├── ExecutionHook.cs ├── GlobalDirectives.cs ├── HarmonyWorker.cs ├── JsonConverters │ ├── EnumConverter.cs │ └── ResonitePrimitiveConverter.cs ├── LoadProgressIndicator.cs ├── Logger.cs ├── ModConfiguration.cs ├── ModConfigurationDefinitionBuilder.cs ├── ModConfigurationKey.cs ├── ModLoader.cs ├── ModLoaderConfiguration.cs ├── Properties │ └── AssemblyInfo.cs ├── ResoniteMod.cs ├── ResoniteModBase.cs ├── ResoniteModLoader.csproj ├── Util.cs └── Utility │ └── EnumerableInjector.cs └── doc ├── config.md ├── directories.md ├── example_log.log ├── faq.md ├── guidelines.md ├── img ├── steam_game_properties_1.png └── steam_game_properties_2.png ├── launch_options.md ├── linux.md ├── making_mods.md ├── modloader_config.md ├── problem_solving_techniques.md ├── start_resonite.bat └── troubleshooting.md /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://EditorConfig.org 2 | root = true 3 | 4 | [*] 5 | # Indentation and spacing 6 | indent_size = 4 7 | indent_style = tab 8 | tab_width = 4 9 | 10 | # New line preferences 11 | end_of_line = lf 12 | trim_trailing_whitespace = true 13 | insert_final_newline = true 14 | charset = utf-8 15 | csharp_indent_labels = one_less_than_current 16 | csharp_using_directive_placement = outside_namespace:silent 17 | csharp_prefer_simple_using_statement = false:suggestion 18 | csharp_prefer_braces = true:silent 19 | csharp_style_namespace_declarations = file_scoped:silent 20 | csharp_style_prefer_method_group_conversion = true:silent 21 | csharp_style_prefer_top_level_statements = true:silent 22 | csharp_style_expression_bodied_methods = false:silent 23 | csharp_style_expression_bodied_constructors = false:silent 24 | csharp_style_expression_bodied_operators = false:silent 25 | csharp_style_expression_bodied_properties = true:silent 26 | csharp_style_expression_bodied_indexers = true:silent 27 | csharp_style_expression_bodied_accessors = true:silent 28 | csharp_style_expression_bodied_lambdas = true:silent 29 | csharp_style_expression_bodied_local_functions = false:silent 30 | csharp_indent_block_contents = true 31 | csharp_space_around_binary_operators = before_and_after 32 | csharp_style_prefer_primary_constructors = true:suggestion 33 | csharp_new_line_before_open_brace = none 34 | 35 | [*.yml] 36 | indent_style = space 37 | indent_size = 2 38 | 39 | [*.md] 40 | trim_trailing_whitespace = false 41 | 42 | [*.{cs,vb}] 43 | #### Naming styles #### 44 | 45 | # Naming rules 46 | 47 | dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion 48 | dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface 49 | dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i 50 | 51 | dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion 52 | dotnet_naming_rule.types_should_be_pascal_case.symbols = types 53 | dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case 54 | 55 | dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion 56 | dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members 57 | dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case 58 | 59 | # Symbol specifications 60 | 61 | dotnet_naming_symbols.interface.applicable_kinds = interface 62 | dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 63 | dotnet_naming_symbols.interface.required_modifiers = 64 | 65 | dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum 66 | dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 67 | dotnet_naming_symbols.types.required_modifiers = 68 | 69 | dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method 70 | dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 71 | dotnet_naming_symbols.non_field_members.required_modifiers = 72 | 73 | # Naming styles 74 | 75 | dotnet_naming_style.begins_with_i.required_prefix = I 76 | dotnet_naming_style.begins_with_i.required_suffix = 77 | dotnet_naming_style.begins_with_i.word_separator = 78 | dotnet_naming_style.begins_with_i.capitalization = pascal_case 79 | 80 | dotnet_naming_style.pascal_case.required_prefix = 81 | dotnet_naming_style.pascal_case.required_suffix = 82 | dotnet_naming_style.pascal_case.word_separator = 83 | dotnet_naming_style.pascal_case.capitalization = pascal_case 84 | 85 | dotnet_naming_style.pascal_case.required_prefix = 86 | dotnet_naming_style.pascal_case.required_suffix = 87 | dotnet_naming_style.pascal_case.word_separator = 88 | dotnet_naming_style.pascal_case.capitalization = pascal_case 89 | dotnet_style_operator_placement_when_wrapping = beginning_of_line 90 | tab_width = 4 91 | dotnet_style_coalesce_expression = true:suggestion 92 | dotnet_style_null_propagation = true:suggestion 93 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion 94 | dotnet_style_prefer_auto_properties = true:silent 95 | dotnet_style_object_initializer = true:suggestion 96 | dotnet_style_collection_initializer = true:suggestion 97 | dotnet_style_prefer_simplified_boolean_expressions = true:suggestion 98 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 99 | dotnet_style_prefer_conditional_expression_over_return = true:silent 100 | dotnet_style_explicit_tuple_names = true:suggestion 101 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 102 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 103 | dotnet_style_prefer_compound_assignment = true:suggestion 104 | dotnet_style_prefer_simplified_interpolation = true:suggestion 105 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.cs text eol=lf 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | ResoniteHeadless/* 331 | 332 | # Cake - Uncomment if you are using it 333 | # tools/** 334 | # !tools/packages.config 335 | 336 | # Tabs Studio 337 | *.tss 338 | 339 | # Telerik's JustMock configuration file 340 | *.jmconfig 341 | 342 | # BizTalk build output 343 | *.btp.cs 344 | *.btm.cs 345 | *.odx.cs 346 | *.xsd.cs 347 | 348 | # OpenCover UI analysis results 349 | OpenCover/ 350 | 351 | # Azure Stream Analytics local run output 352 | ASALocalRun/ 353 | 354 | # MSBuild Binary and Structured Log 355 | *.binlog 356 | 357 | # NVidia Nsight GPU debugger configuration file 358 | *.nvuser 359 | 360 | # MFractors (Xamarin productivity tool) working folder 361 | .mfractor/ 362 | 363 | # Local History for Visual Studio 364 | .localhistory/ 365 | 366 | # Visual Studio History (VSHistory) files 367 | .vshistory/ 368 | 369 | # BeatPulse healthcheck temp database 370 | healthchecksdb 371 | 372 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 373 | MigrationBackup/ 374 | 375 | # Ionide (cross platform F# VS Code tools) working folder 376 | .ionide/ 377 | 378 | # Fody - auto-generated XML schema 379 | FodyWeavers.xsd 380 | 381 | # VS Code files for those working on multiple tools 382 | .vscode/* 383 | !.vscode/settings.json 384 | !.vscode/tasks.json 385 | !.vscode/launch.json 386 | !.vscode/extensions.json 387 | *.code-workspace 388 | 389 | # Local History for Visual Studio Code 390 | .history/ 391 | 392 | # Windows Installer files from build outputs 393 | *.cab 394 | *.msi 395 | *.msix 396 | *.msm 397 | *.msp 398 | 399 | # JetBrains Rider 400 | *.sln.iml 401 | # Specifically allow the example log 402 | !/doc/example_log.log 403 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ResoniteModLoader 2 | 3 | A mod loader for [Resonite](https://resonite.com/). Consider joining our community on [Discord][Resonite Modding Discord] for support, updates, and more. 4 | 5 | ## Installation 6 | 7 | 1. Download [ResoniteModLoader.dll](https://github.com/resonite-modding-group/ResoniteModLoader/releases/latest/download/ResoniteModLoader.dll) to Resonite's `Libraries` folder (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Libraries`). You may need to create this folder if it's missing. 8 | 2. Place [0Harmony.dll](https://github.com/resonite-modding-group/ResoniteModLoader/releases/latest/download/0Harmony.dll) into a `rml_libs` folder under your Resonite install directory (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\rml_libs`). You will need to create this folder. 9 | 3. Add the following to Resonite's [launch options](https://github.com/resonite-modding-group/ResoniteModLoader/wiki/Launch-Options): `-LoadAssembly Libraries/ResoniteModLoader.dll`. If you put `ResoniteModLoader.dll` somewhere else you will need to change the path. 10 | 4. Optionally add mod DLL files to a `rml_mods` folder under your Resonite install directory (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\rml_mods`). You can create the folder if it's missing, or launch Resonite once with ResoniteModLoader installed and it will be created automatically. 11 | 5. Start the game. If you want to verify that ResoniteModLoader is working you can check the Resonite logs. (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs`). The modloader adds some very obvious logs on startup, and if they're missing something has gone wrong. Here is an [example log file](https://github.com/resonite-modding-group/ResoniteModLoader/wiki/Example-Log) where everything worked correctly. 12 | 13 | If ResoniteModLoader isn't working after following these steps, take a look at our [troubleshooting page](doc/troubleshooting.md). 14 | 15 | ### Example Directory Structure 16 | 17 | Your Resonite directory should now look similar to the following. Files not related to modding are not shown. 18 | 19 | ``` 20 | 21 | │ Resonite.exe 22 | │ 23 | ├───Logs 24 | │ 25 | │ 26 | ├───rml_mods 27 | │ 28 | │ 29 | ├───rml_libs 30 | │ 0Harmony.dll 31 | │ 32 | │ 33 | ├───rml_config 34 | │ 35 | │ 36 | └───Libraries 37 | ResoniteModLoader.dll 38 | ``` 39 | 40 | Note that additional libraries (rml_libs) can also be in the root of the Resonite install directory if you prefer, but the loading of those happens outside of RML itself. 41 | 42 | ## Finding Mods 43 | 44 | For an easy way to find and manage mods, check out [Resolute](https://github.com/Gawdl3y/Resolute). It simplifies the installation and updating for verified mods from the [mod manifest](https://github.com/resonite-modding-group/resonite-mod-manifest). 45 | 46 | New mods and updates also are posted in [our Discord][Resonite Modding Discord]. 47 | 48 | ## Frequently Asked Questions 49 | 50 | Many questions about what RML is and how it works are answered on our [frequently asked questions page](doc/faq.md). 51 | 52 | ## Making a Mod 53 | 54 | Check out the [Mod Creation Guide](https://github.com/resonite-modding-group/ResoniteModLoader/wiki/Creating-Mods). 55 | 56 | ## Configuration 57 | 58 | ResoniteModLoader aims to have a reasonable default configuration, but certain things can be adjusted via an [optional config file](https://github.com/resonite-modding-group/ResoniteModLoader/wiki/Modloader-Config). 59 | 60 | ## Contributing 61 | 62 | Issues and PRs are welcome. Please read our [Contributing Guidelines](.github/CONTRIBUTING.md)! 63 | 64 | ## Licensing and Credits 65 | 66 | ResoniteModLoader is licensed under the GNU Lesser General Public License (LGPL). See [LICENSE.txt](LICENSE.txt) for the full license. 67 | 68 | Third-party libraries distributed alongside ResoniteModLoader: 69 | 70 | - [LibHarmony] ([MIT License](https://github.com/pardeike/Harmony/blob/v2.2.2.0/LICENSE)) 71 | 72 | Third-party libraries used in source: 73 | 74 | - [.NET](https://github.com/dotnet) (Various licenses) 75 | - [Resonite](https://resonite.com/) ([EULA](https://resonite.com/policies/EULA.html)) 76 | - [Json.NET](https://github.com/JamesNK/Newtonsoft.Json) ([MIT License](https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md)) 77 | 78 | 79 | [LibHarmony]: https://github.com/pardeike/Harmony 80 | [Resonite Modding Discord]: https://discord.gg/ZMRyQ8bryN 81 | -------------------------------------------------------------------------------- /ResoniteModLoader.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.33627.172 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ResoniteModLoader", "ResoniteModLoader\ResoniteModLoader.csproj", "{D4627C7F-8091-477A-ABDC-F1465D94D8D9}" 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 | {D4627C7F-8091-477A-ABDC-F1465D94D8D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {D4627C7F-8091-477A-ABDC-F1465D94D8D9}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {D4627C7F-8091-477A-ABDC-F1465D94D8D9}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {D4627C7F-8091-477A-ABDC-F1465D94D8D9}.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 = {757072E6-E985-4EC2-AB38-C4D1588F6A15} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /ResoniteModLoader/AssemblyFile.cs: -------------------------------------------------------------------------------- 1 | namespace ResoniteModLoader; 2 | 3 | internal sealed class AssemblyFile { 4 | internal string File { get; } 5 | internal Assembly Assembly { get; set; } 6 | internal AssemblyFile(string file, Assembly assembly) { 7 | File = file; 8 | Assembly = assembly; 9 | } 10 | internal string Name => Assembly.GetName().Name; 11 | internal string Version => Assembly.GetName().Version.ToString(); 12 | private string? sha256; 13 | internal string Sha256 { 14 | get { 15 | if (sha256 == null) { 16 | try { 17 | sha256 = Util.GenerateSHA256(File); 18 | } catch (Exception e) { 19 | Logger.ErrorInternal($"Exception calculating sha256 hash for {File}:\n{e}"); 20 | sha256 = "failed to generate hash"; 21 | } 22 | } 23 | return sha256; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ResoniteModLoader/AssemblyHider.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | using Elements.Core; 4 | 5 | using HarmonyLib; 6 | 7 | namespace ResoniteModLoader; 8 | 9 | internal static class AssemblyHider { 10 | /// 11 | /// Companies that indicate an assembly is part of .NET 12 | /// This list was found by debug logging the AssemblyCompany 13 | /// for all loaded assemblies. 14 | /// 15 | private readonly static HashSet knownDotNetCompanies = new List() { 16 | "Mono development team", // used by .NET stuff and Mono.Security 17 | }.Select(company => company.ToLowerInvariant()).ToHashSet(); 18 | 19 | /// 20 | /// Products that indicate an assembly is part of .NET. 21 | /// This list was found by debug logging the AssemblyProductAttribute for all loaded assemblies. 22 | /// 23 | private readonly static HashSet knownDotNetProducts = new List() { 24 | "Microsoft® .NET", // used by a few System.* assemblies 25 | "Microsoft® .NET Framework", // used by most of the System.* assemblies 26 | "Mono Common Language Infrastructure", // used by mscorlib stuff 27 | }.Select(product => product.ToLowerInvariant()).ToHashSet(); 28 | 29 | /// 30 | /// Assemblies that were already loaded when RML started up, minus a couple known non-assemblies. 31 | /// 32 | private static HashSet? resoniteAssemblies; 33 | 34 | /// 35 | /// Assemblies that 100% exist due to a mod 36 | /// 37 | private static HashSet? modAssemblies; 38 | 39 | /// 40 | /// .NET assembiles we want to ignore in some cases, like the callee check for the AppDomain.GetAssemblies() patch 41 | /// 42 | private static HashSet? dotNetAssemblies; 43 | 44 | /// 45 | /// Patch Resonite's type lookup code to not see mod-related types. This is needed, because users can pass 46 | /// arbitrary strings to TypeHelper.FindType(), which can be used to detect if someone is running mods. 47 | /// 48 | /// Our RML harmony instance 49 | /// Assemblies that were loaded when RML first started 50 | internal static void PatchResonite(Harmony harmony, HashSet initialAssemblies) { 51 | //if (ModLoaderConfiguration.Get().HideModTypes) { 52 | // initialize the static assembly sets that our patches will need later 53 | resoniteAssemblies = GetResoniteAssemblies(initialAssemblies); 54 | modAssemblies = GetModAssemblies(resoniteAssemblies); 55 | dotNetAssemblies = resoniteAssemblies.Where(LooksLikeDotNetAssembly).ToHashSet(); 56 | 57 | // TypeHelper.FindType explicitly does a type search 58 | MethodInfo findTypeTarget = AccessTools.DeclaredMethod(typeof(TypeHelper), nameof(TypeHelper.FindType), new Type[] { typeof(string) }); 59 | MethodInfo findTypePatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(FindTypePostfix)); 60 | harmony.Patch(findTypeTarget, postfix: new HarmonyMethod(findTypePatch)); 61 | 62 | // ReflectionExtensions.IsValidGenericType checks a type for validity, and if it returns `true` it reveals that the type exists 63 | MethodInfo isValidGenericTypeTarget = AccessTools.DeclaredMethod(typeof(ReflectionExtensions), nameof(ReflectionExtensions.IsValidGenericType), new Type[] { typeof(Type), typeof(bool) }); 64 | MethodInfo isValidGenericTypePatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(IsValidTypePostfix)); 65 | harmony.Patch(isValidGenericTypeTarget, postfix: new HarmonyMethod(isValidGenericTypePatch)); 66 | 67 | // FrooxEngine likes to enumerate all types in all assemblies, which is prone to issues (such as crashing FrooxCode if a type isn't loadable) 68 | MethodInfo getAssembliesTarget = AccessTools.DeclaredMethod(typeof(AppDomain), nameof(AppDomain.GetAssemblies), Array.Empty()); 69 | MethodInfo getAssembliesPatch = AccessTools.DeclaredMethod(typeof(AssemblyHider), nameof(GetAssembliesPostfix)); 70 | harmony.Patch(getAssembliesTarget, postfix: new HarmonyMethod(getAssembliesPatch)); 71 | //} 72 | } 73 | 74 | private static HashSet GetResoniteAssemblies(HashSet initialAssemblies) { 75 | // Remove RML itself, as its types should be hidden but it's guaranteed to be loaded. 76 | initialAssemblies.Remove(Assembly.GetExecutingAssembly()); 77 | 78 | // Remove Harmony, as users who aren't using rml_libs will already have it loaded. 79 | initialAssemblies.Remove(typeof(Harmony).Assembly); 80 | 81 | return initialAssemblies; 82 | } 83 | 84 | private static HashSet GetModAssemblies(HashSet resoniteAssemblies) { 85 | // start with ALL assemblies 86 | HashSet assemblies = AppDomain.CurrentDomain.GetAssemblies().ToHashSet(); 87 | 88 | // remove assemblies that we know to have come with Resonite 89 | assemblies.ExceptWith(resoniteAssemblies); 90 | 91 | // what's left are assemblies that magically appeared during the mod loading process. So mods and their dependencies. 92 | return assemblies; 93 | } 94 | 95 | /// 96 | /// Checks if an belongs to a mod or not. 97 | /// 98 | /// The to check. 99 | /// Type of root check being performed. Should be "type" or "assembly". Used in logging. 100 | /// Name of the root check being performed. Used in logging. 101 | /// If `true`, this will emit logs. If `false`, this function will not log. 102 | /// If `true`, then this function will always return `false` for late-loaded types 103 | /// `true` if this assembly belongs to a mod. 104 | private static bool IsModAssembly(Assembly assembly, string typeOrAssembly, string name, bool log, bool forceShowLate) { 105 | if (resoniteAssemblies!.Contains(assembly)) { 106 | return false; // The assembly belongs to Resonite and shouldn't be hidden 107 | } else { 108 | if (modAssemblies!.Contains(assembly)) { 109 | // The assembly belongs to a mod and should be hidden 110 | if (log) { 111 | Logger.DebugFuncInternal(() => $"Hid {typeOrAssembly} \"{name}\" from Resonite"); 112 | } 113 | return true; 114 | } else { 115 | // an assembly was in neither resoniteAssemblies nor modAssemblies 116 | // this implies someone late-loaded an assembly after RML, and it was later used in-game 117 | // this is super weird, and probably shouldn't ever happen... but if it does, I want to know about it. 118 | // since this is an edge case users may want to handle in different ways, the HideLateTypes rml config option allows them to choose. 119 | //bool hideLate = true;// ModLoaderConfiguration.Get().HideLateTypes; 120 | /*if (log) { 121 | Logger.WarnInternal($"The \"{name}\" {typeOrAssembly} does not appear to part of Resonite or a mod. It is unclear whether it should be hidden or not. Due to the HideLateTypes config option being {hideLate} it will be {(hideLate ? "Hidden" : "Shown")}"); 122 | }*/ 123 | // if forceShowLate == true, then this function will always return `false` for late-loaded types 124 | // if forceShowLate == false, then this function will return `true` when hideLate == true 125 | return !forceShowLate; 126 | } 127 | } 128 | } 129 | 130 | /// 131 | /// Checks if an belongs to a mod or not. 132 | /// 133 | /// The to check 134 | /// If true, then this function will always return false for late-loaded types. 135 | /// true if this belongs to a mod. 136 | private static bool IsModAssembly(Assembly assembly, bool forceShowLate = false) { 137 | // this generates a lot of logspam, as a single call to AppDomain.GetAssemblies() calls this many times 138 | return IsModAssembly(assembly, "assembly", assembly.ToString(), log: false, forceShowLate); 139 | } 140 | 141 | /// 142 | /// Checks if a belongs to a mod or not. 143 | /// 144 | /// The to check. 145 | /// true if this belongs to a mod. 146 | private static bool IsModType(Type type) { 147 | return IsModAssembly(type.Assembly, "type", type.ToString(), log: true, forceShowLate: false); 148 | } 149 | 150 | // postfix for a method that searches for a type, and returns a reference to it if found (TypeHelper.FindType and WorkerManager.GetType) 151 | private static void FindTypePostfix(ref Type? __result) { 152 | if (__result != null) { 153 | // we only need to think about types if the method actually returned a non-null result 154 | if (IsModType(__result)) { 155 | __result = null; 156 | } 157 | } 158 | } 159 | 160 | // postfix for a method that validates a type (ReflectionExtensions.IsValidGenericType) 161 | private static void IsValidTypePostfix(ref bool __result, Type type) { 162 | if (__result == true) { 163 | // we only need to think about types if the method actually returned a true result 164 | if (IsModType(type)) { 165 | __result = false; 166 | } 167 | } 168 | } 169 | 170 | private static void GetAssembliesPostfix(ref Assembly[] __result) { 171 | Assembly? callingAssembly = GetCallingAssembly(new(1)); 172 | if (callingAssembly != null && resoniteAssemblies!.Contains(callingAssembly)) { 173 | // if we're being called by Resonite code, then hide mod assemblies 174 | Logger.DebugFuncInternal(() => $"Intercepting call to AppDomain.GetAssemblies() from {callingAssembly}"); 175 | __result = __result 176 | .Where(assembly => !IsModAssembly(assembly, forceShowLate: true)) // it turns out Resonite itself late-loads a bunch of stuff, so we force-show late-loaded assemblies here 177 | .ToArray(); 178 | } 179 | } 180 | 181 | /// 182 | /// Get the calling using stack trace analysis, ignoring .NET assemblies. 183 | /// This implementation is SPECIFICALLY for the patch and may not be valid for other use-cases. 184 | /// 185 | /// The stack trace captured by the callee. 186 | /// The calling , or null if none was found. 187 | private static Assembly? GetCallingAssembly(StackTrace stackTrace) { 188 | for (int i = 0; i < stackTrace.FrameCount; i++) { 189 | Assembly? assembly = stackTrace.GetFrame(i)?.GetMethod()?.DeclaringType?.Assembly; 190 | // .NET calls AppDomain.GetAssemblies() a bunch internally, and we don't want to intercept those calls UNLESS they originated from Resonite code. 191 | if (assembly != null && !dotNetAssemblies!.Contains(assembly)) { 192 | return assembly; 193 | } 194 | } 195 | return null; 196 | } 197 | 198 | private static bool LooksLikeDotNetAssembly(Assembly assembly) { 199 | // check the assembly's company 200 | string? company = assembly.GetCustomAttribute()?.Company; 201 | if (company != null && knownDotNetCompanies.Contains(company.ToLowerInvariant())) { 202 | return true; 203 | } 204 | 205 | // check the assembly's product 206 | string? product = assembly.GetCustomAttribute()?.Product; 207 | if (product != null && knownDotNetProducts.Contains(product.ToLowerInvariant())) { 208 | return true; 209 | } 210 | 211 | // nothing matched, this is probably not part of .NET 212 | return false; 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /ResoniteModLoader/AssemblyLoader.cs: -------------------------------------------------------------------------------- 1 | namespace ResoniteModLoader; 2 | 3 | internal static class AssemblyLoader { 4 | private static string[]? GetAssemblyPathsFromDir(string dirName) { 5 | 6 | string assembliesDirectory = Path.Combine(Directory.GetCurrentDirectory(), dirName); 7 | 8 | Logger.MsgInternal($"Loading assemblies from {dirName}"); 9 | 10 | string[]? assembliesToLoad = null; 11 | try { 12 | // Directory.GetFiles and Directory.EnumerateFiles have a fucked up API: https://learn.microsoft.com/en-us/dotnet/api/system.io.directory.getfiles?view=netframework-4.6.2#system-io-directory-getfiles(system-string-system-string-system-io-searchoption) 13 | // Searching for "*.dll" would return results like "foo.dll_disabled" 14 | 15 | assembliesToLoad = Directory.EnumerateFiles(assembliesDirectory, "*.dll") 16 | .Where(file => file.EndsWith(".dll", StringComparison.InvariantCultureIgnoreCase)) 17 | .ToArray(); 18 | Array.Sort(assembliesToLoad, string.CompareOrdinal); 19 | } catch (DirectoryNotFoundException) { 20 | Logger.MsgInternal($"{dirName} directory not found, creating it now."); 21 | try { 22 | Directory.CreateDirectory(assembliesDirectory); 23 | } catch (Exception e2) { 24 | Logger.ErrorInternal($"Error creating ${dirName} directory:\n{e2}"); 25 | } 26 | } catch (Exception e) { 27 | Logger.ErrorInternal($"Error enumerating ${dirName} directory:\n{e}"); 28 | } 29 | return assembliesToLoad; 30 | } 31 | 32 | private static Assembly? LoadAssembly(string filepath) { 33 | string filename = Path.GetFileName(filepath); 34 | LoadProgressIndicator.SetCustom($"Loading file: {filename}"); 35 | Assembly assembly; 36 | try { 37 | Logger.DebugFuncInternal(() => $"Load assembly {filename}"); 38 | assembly = Assembly.LoadFrom(filepath); 39 | } catch (Exception e) { 40 | Logger.ErrorInternal($"Error loading assembly from {filepath}: {e}"); 41 | return null; 42 | } 43 | if (assembly == null) { 44 | Logger.ErrorInternal($"Unexpected null loading assembly from {filepath}"); 45 | return null; 46 | } 47 | return assembly; 48 | } 49 | 50 | internal static AssemblyFile[] LoadAssembliesFromDir(string dirName) { 51 | List assemblyFiles = new(); 52 | if (GetAssemblyPathsFromDir(dirName) is string[] assemblyPaths) { 53 | foreach (string assemblyFilepath in assemblyPaths) { 54 | try { 55 | if (LoadAssembly(assemblyFilepath) is Assembly assembly) { 56 | assemblyFiles.Add(new AssemblyFile(assemblyFilepath, assembly)); 57 | } 58 | } catch (Exception e) { 59 | Logger.ErrorInternal($"Unexpected exception loading assembly from {assemblyFilepath}:\n{e}"); 60 | } 61 | } 62 | } 63 | return assemblyFiles.ToArray(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /ResoniteModLoader/Attributes/AutoRegisterConfigKeyAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace ResoniteModLoader; 2 | /// 3 | /// Marks a field of type on a class 4 | /// deriving from to be automatically included in that mod's configuration. 5 | /// 6 | [AttributeUsage(AttributeTargets.Field)] 7 | public sealed class AutoRegisterConfigKeyAttribute : Attribute { } 8 | -------------------------------------------------------------------------------- /ResoniteModLoader/ConfigurationChangedEvent.cs: -------------------------------------------------------------------------------- 1 | namespace ResoniteModLoader; 2 | /// 3 | /// Represents the data for the and events. 4 | /// 5 | public class ConfigurationChangedEvent { 6 | /// 7 | /// The in which the change occured. 8 | /// 9 | public ModConfiguration Config { get; private set; } 10 | 11 | /// 12 | /// The specific who's value changed. 13 | /// 14 | public ModConfigurationKey Key { get; private set; } 15 | 16 | /// 17 | /// A custom label that may be set by whoever changed the configuration. 18 | /// 19 | public string? Label { get; private set; } 20 | 21 | internal ConfigurationChangedEvent(ModConfiguration config, ModConfigurationKey key, string? label) { 22 | Config = config; 23 | Key = key; 24 | Label = label; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ResoniteModLoader/DebugInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | using System.Runtime.Versioning; 3 | 4 | namespace ResoniteModLoader; 5 | 6 | internal static class DebugInfo { 7 | internal static void Log() { 8 | Logger.MsgInternal($"ResoniteModLoader v{ModLoader.VERSION} starting up!{(ModLoaderConfiguration.Get().Debug ? " Debug logs will be shown." : "")}"); 9 | Logger.DebugFuncInternal(() => $"Launched with args: {string.Join(" ", Environment.GetCommandLineArgs())}"); 10 | Logger.MsgInternal($"CLR v{Environment.Version}"); 11 | Logger.DebugFuncInternal(() => $"Using .NET Framework: \"{AppDomain.CurrentDomain.SetupInformation.TargetFrameworkName}\""); 12 | Logger.DebugFuncInternal(() => $"Using .NET Core: \"{Assembly.GetEntryAssembly()?.GetCustomAttribute()?.FrameworkName}\""); 13 | Logger.MsgInternal($".NET Runtime: {RuntimeInformation.FrameworkDescription}"); 14 | Logger.MsgInternal($"Using Harmony v{GetAssemblyVersion(typeof(HarmonyLib.Harmony))}"); 15 | Logger.MsgInternal($"Using Elements.Core v{GetAssemblyVersion(typeof(Elements.Core.floatQ))}"); 16 | Logger.MsgInternal($"Using FrooxEngine v{GetAssemblyVersion(typeof(FrooxEngine.IComponent))}"); 17 | Logger.MsgInternal($"Using Json.NET v{GetAssemblyVersion(typeof(Newtonsoft.Json.JsonSerializer))}"); 18 | } 19 | 20 | private static string? GetAssemblyVersion(Type typeFromAssembly) { 21 | return typeFromAssembly.Assembly.GetName()?.Version?.ToString(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ResoniteModLoader/DelegateExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace ResoniteModLoader; 2 | 3 | internal static class DelegateExtensions { 4 | internal static void SafeInvoke(this Delegate del, params object[] args) { 5 | var exceptions = new List(); 6 | 7 | foreach (var handler in del.GetInvocationList()) { 8 | try { 9 | handler.Method.Invoke(handler.Target, args); 10 | } catch (Exception ex) { 11 | exceptions.Add(ex); 12 | } 13 | } 14 | 15 | if (exceptions.Count != 0) { 16 | throw new AggregateException(exceptions); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /ResoniteModLoader/ExecutionHook.cs: -------------------------------------------------------------------------------- 1 | using FrooxEngine; 2 | 3 | namespace ResoniteModLoader; 4 | 5 | [ImplementableClass(true)] 6 | internal static class ExecutionHook { 7 | #pragma warning disable CS0169, IDE0051, CA1823, IDE0044 8 | // fields must exist due to reflective access 9 | private static Type? __connectorType; 10 | private static Type? __connectorTypes; 11 | 12 | // implementation not strictly required, but method must exist due to reflective access 13 | private static DummyConnector InstantiateConnector() { 14 | return new DummyConnector(); 15 | } 16 | #pragma warning restore CS0169, IDE0051, CA1823, IDE0044 17 | 18 | static ExecutionHook() { 19 | Logger.DebugInternal($"Start of ExecutionHook"); 20 | try { 21 | BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic; 22 | var byName = (Dictionary)typeof(GlobalTypeRegistry).GetField("_byName", flags).GetValue(null); 23 | 24 | var firstAsm = byName.FirstOrDefault(asm => asm.Value.Assembly == typeof(ExecutionHook).Assembly); 25 | if (firstAsm.Value != null && byName.ContainsKey(firstAsm.Key)) { 26 | Logger.DebugInternal($"Removing Assembly {firstAsm.Key} from global type registry"); 27 | byName.Remove(firstAsm.Key); 28 | } 29 | 30 | HashSet initialAssemblies = AppDomain.CurrentDomain.GetAssemblies().ToHashSet(); 31 | LoadProgressIndicator.SetCustom("Loading Libraries"); 32 | AssemblyFile[] loadedAssemblies = AssemblyLoader.LoadAssembliesFromDir("rml_libs"); 33 | // note that harmony may not be loaded until this point, so this class cannot directly import HarmonyLib. 34 | 35 | if (loadedAssemblies.Length != 0) { 36 | string loadedAssemblyList = string.Join("\n", loadedAssemblies.Select(a => a.Name + ", Version=" + a.Version + ", Sha256=" + a.Sha256)); 37 | Logger.MsgInternal($"Loaded libraries from rml_libs:\n{loadedAssemblyList}"); 38 | } 39 | LoadProgressIndicator.SetCustom("Initializing"); 40 | DebugInfo.Log(); 41 | HarmonyWorker.LoadModsAndHideModAssemblies(initialAssemblies); 42 | LoadProgressIndicator.SetCustom("Loaded"); 43 | } catch (Exception e) { 44 | // it's important that this doesn't send exceptions back to Resonite 45 | Logger.ErrorInternal($"Exception in execution hook!\n{e}"); 46 | } 47 | } 48 | 49 | 50 | // type must match return type of InstantiateConnector() 51 | private sealed class DummyConnector : IConnector { 52 | public IImplementable? Owner { get; private set; } 53 | public void ApplyChanges() { } 54 | public void AssignOwner(IImplementable owner) => Owner = owner; 55 | public void Destroy(bool destroyingWorld) { } 56 | public void Initialize() { } 57 | public void RemoveOwner() => Owner = null; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /ResoniteModLoader/GlobalDirectives.cs: -------------------------------------------------------------------------------- 1 | /* 2 | // auto-generated Implicit Using Directives 3 | global using global::System; 4 | global using global::System.Collections.Generic; 5 | global using global::System.IO; 6 | global using global::System.Linq; 7 | global using global::System.Threading; 8 | global using global::System.Threading.Tasks; 9 | 10 | //System.Net.Http is a default implicit. While we are still on 4.6.2, it needs to be manually removed via csproj `` 11 | */ 12 | global using System.Reflection; 13 | global using System.Text; 14 | -------------------------------------------------------------------------------- /ResoniteModLoader/HarmonyWorker.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | 3 | namespace ResoniteModLoader; 4 | // this class does all the harmony-related RML work. 5 | // this is needed to avoid importing harmony in ExecutionHook, where it may not be loaded yet. 6 | internal sealed class HarmonyWorker { 7 | internal static void LoadModsAndHideModAssemblies(HashSet initialAssemblies) { 8 | Harmony harmony = new("com.resonitemodloader.ResoniteModLoader"); 9 | ModLoader.LoadMods(); 10 | ModConfiguration.RegisterShutdownHook(harmony); 11 | AssemblyHider.PatchResonite(harmony, initialAssemblies); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /ResoniteModLoader/JsonConverters/EnumConverter.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace ResoniteModLoader.JsonConverters; 4 | 5 | // serializes and deserializes enums as strings 6 | internal sealed class EnumConverter : JsonConverter { 7 | public override bool CanConvert(Type objectType) { 8 | return objectType.IsEnum; 9 | } 10 | 11 | public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { 12 | // handle old behavior where enums were serialized as underlying type 13 | Type underlyingType = Enum.GetUnderlyingType(objectType); 14 | if (TryConvert(reader!.Value!, underlyingType, out object? deserialized)) { 15 | Logger.DebugFuncInternal(() => $"Deserializing a Core Element type: {objectType} from a {reader!.Value!.GetType()}"); 16 | return deserialized!; 17 | } 18 | 19 | // handle new behavior where enums are serialized as strings 20 | if (reader.Value is string serialized) { 21 | return Enum.Parse(objectType, serialized); 22 | } 23 | 24 | throw new ArgumentException($"Could not deserialize a Core Element type: {objectType} from a {reader?.Value?.GetType()}. Expected underlying type was {underlyingType}"); 25 | } 26 | 27 | public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { 28 | string serialized = Enum.GetName(value!.GetType(), value); 29 | writer.WriteValue(serialized); 30 | } 31 | 32 | private static bool TryConvert(object value, Type newType, out object? converted) { 33 | try { 34 | converted = Convert.ChangeType(value, newType); 35 | return true; 36 | } catch { 37 | converted = null; 38 | return false; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /ResoniteModLoader/JsonConverters/ResonitePrimitiveConverter.cs: -------------------------------------------------------------------------------- 1 | using Elements.Core; 2 | 3 | using Newtonsoft.Json; 4 | 5 | namespace ResoniteModLoader.JsonConverters; 6 | 7 | internal sealed class ResonitePrimitiveConverter : JsonConverter { 8 | private static readonly Assembly ElementsCore = typeof(floatQ).Assembly; 9 | 10 | public override bool CanConvert(Type objectType) { 11 | // handle all non-enum Resonite Primitives in the Elements.Core assembly 12 | return !objectType.IsEnum && ElementsCore.Equals(objectType.Assembly) && Coder.IsEnginePrimitive(objectType); 13 | } 14 | 15 | public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { 16 | if (reader.Value is string serialized) { 17 | // use Resonite's built-in decoding if the value was serialized as a string 18 | return typeof(Coder<>).MakeGenericType(objectType).GetMethod("DecodeFromString").Invoke(null, new object[] { serialized }); 19 | } 20 | 21 | throw new ArgumentException($"Could not deserialize a Core Element type: {objectType} from a {reader?.Value?.GetType()}"); 22 | } 23 | 24 | public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { 25 | string serialized = (string)typeof(Coder<>).MakeGenericType(value!.GetType()).GetMethod("EncodeToString").Invoke(null, new object[] { value }); 26 | writer.WriteValue(serialized); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /ResoniteModLoader/LoadProgressIndicator.cs: -------------------------------------------------------------------------------- 1 | using FrooxEngine; 2 | 3 | namespace ResoniteModLoader; 4 | 5 | // Custom LoadProgressIndicator logic failing shouldn't stop the rest of the modloader. 6 | internal static class LoadProgressIndicator { 7 | private static bool failed; 8 | 9 | private static FieldInfo? _showSubphase; 10 | private static FieldInfo? ShowSubphase { 11 | get { 12 | if (_showSubphase is null) { 13 | try { 14 | _showSubphase = typeof(EngineLoadProgress).GetField("_showSubphase", BindingFlags.NonPublic | BindingFlags.Instance); 15 | } catch (Exception ex) { 16 | if (!failed) { 17 | Logger.WarnInternal("_showSubphase not found: " + ex.ToString()); 18 | } 19 | failed = true; 20 | } 21 | } 22 | return _showSubphase; 23 | } 24 | } 25 | 26 | // Returned true means success, false means something went wrong. 27 | internal static bool SetCustom(string text) { 28 | if (ModLoaderConfiguration.Get().HideVisuals) { return true; } 29 | if (!ModLoader.IsHeadless) { 30 | ShowSubphase?.SetValue(Engine.Current.InitProgress, text); 31 | return true; 32 | } 33 | return false; 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /ResoniteModLoader/Logger.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | using Elements.Core; 4 | 5 | namespace ResoniteModLoader; 6 | 7 | internal sealed class Logger { 8 | // logged for null objects 9 | internal const string NULL_STRING = "null"; 10 | 11 | internal static bool IsDebugEnabled() { 12 | #if DEBUG 13 | return true; 14 | #endif 15 | return ModLoaderConfiguration.Get().Debug; 16 | } 17 | 18 | internal static void DebugFuncInternal(Func messageProducer) { 19 | if (IsDebugEnabled()) { 20 | LogInternal(LogType.DEBUG, messageProducer()); 21 | } 22 | } 23 | 24 | internal static void DebugFuncExternal(Func messageProducer) { 25 | if (IsDebugEnabled()) { 26 | LogInternal(LogType.DEBUG, messageProducer(), SourceFromStackTrace(new(1))); 27 | } 28 | } 29 | 30 | internal static void DebugInternal(string message) { 31 | if (IsDebugEnabled()) { 32 | LogInternal(LogType.DEBUG, message); 33 | } 34 | } 35 | 36 | internal static void DebugExternal(object message) { 37 | if (IsDebugEnabled()) { 38 | LogInternal(LogType.DEBUG, message, SourceFromStackTrace(new(1))); 39 | } 40 | } 41 | 42 | internal static void DebugListExternal(object[] messages) { 43 | if (IsDebugEnabled()) { 44 | LogListInternal(LogType.DEBUG, messages, SourceFromStackTrace(new(1))); 45 | } 46 | } 47 | 48 | internal static void MsgInternal(string message) => LogInternal(LogType.INFO, message); 49 | internal static void MsgExternal(object message) => LogInternal(LogType.INFO, message, SourceFromStackTrace(new(1))); 50 | internal static void MsgListExternal(object[] messages) => LogListInternal(LogType.INFO, messages, SourceFromStackTrace(new(1))); 51 | internal static void WarnInternal(string message) => LogInternal(LogType.WARN, message); 52 | internal static void WarnExternal(object message) => LogInternal(LogType.WARN, message, SourceFromStackTrace(new(1))); 53 | internal static void WarnListExternal(object[] messages) => LogListInternal(LogType.WARN, messages, SourceFromStackTrace(new(1))); 54 | internal static void ErrorInternal(string message) => LogInternal(LogType.ERROR, message); 55 | internal static void ErrorExternal(object message) => LogInternal(LogType.ERROR, message, SourceFromStackTrace(new(1))); 56 | internal static void ErrorListExternal(object[] messages) => LogListInternal(LogType.ERROR, messages, SourceFromStackTrace(new(1))); 57 | 58 | private static void LogInternal(string logTypePrefix, object message, string? source = null) { 59 | message ??= NULL_STRING; 60 | if (source == null) { 61 | UniLog.Log($"{logTypePrefix}[ResoniteModLoader] {message}"); 62 | } else { 63 | UniLog.Log($"{logTypePrefix}[ResoniteModLoader/{source}] {message}"); 64 | } 65 | } 66 | 67 | private static void LogListInternal(string logTypePrefix, object[] messages, string? source) { 68 | if (messages == null) { 69 | LogInternal(logTypePrefix, NULL_STRING, source); 70 | } else { 71 | foreach (object element in messages) { 72 | LogInternal(logTypePrefix, element.ToString(), source); 73 | } 74 | } 75 | } 76 | 77 | private static string? SourceFromStackTrace(StackTrace stackTrace) { 78 | // MsgExternal() and Msg() are above us in the stack 79 | return Util.ExecutingMod(stackTrace)?.Name; 80 | } 81 | 82 | private static class LogType { 83 | internal const string DEBUG = "[DEBUG]"; 84 | internal const string INFO = "[INFO] "; 85 | internal const string WARN = "[WARN] "; 86 | internal const string ERROR = "[ERROR]"; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /ResoniteModLoader/ModConfiguration.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | using FrooxEngine; 4 | 5 | using HarmonyLib; 6 | 7 | using Newtonsoft.Json; 8 | using Newtonsoft.Json.Linq; 9 | 10 | using ResoniteModLoader.JsonConverters; 11 | 12 | namespace ResoniteModLoader; 13 | 14 | /// 15 | /// Represents an interface for mod configurations. 16 | /// 17 | public interface IModConfigurationDefinition { 18 | /// 19 | /// Gets the mod that owns this configuration definition. 20 | /// 21 | ResoniteModBase Owner { get; } 22 | 23 | /// 24 | /// Gets the semantic version for this configuration definition. This is used to check if the defined and saved configs are compatible. 25 | /// 26 | Version Version { get; } 27 | 28 | /// 29 | /// Gets the set of configuration keys defined in this configuration definition. 30 | /// 31 | ISet ConfigurationItemDefinitions { get; } 32 | } 33 | 34 | /// 35 | /// Defines a mod configuration. This should be defined by a using the method. 36 | /// 37 | public class ModConfigurationDefinition : IModConfigurationDefinition { 38 | /// 39 | public ResoniteModBase Owner { get; private set; } 40 | 41 | /// 42 | public Version Version { get; private set; } 43 | 44 | internal bool AutoSave; 45 | 46 | // this is a ridiculous hack because HashSet.TryGetValue doesn't exist in .NET 4.6.2 47 | private Dictionary configurationItemDefinitionsSelfMap; 48 | 49 | /// 50 | public ISet ConfigurationItemDefinitions { 51 | // clone the collection because I don't trust giving public API users shallow copies one bit 52 | get => new HashSet(configurationItemDefinitionsSelfMap.Keys); 53 | } 54 | 55 | internal bool TryGetDefiningKey(ModConfigurationKey key, out ModConfigurationKey? definingKey) { 56 | if (key.DefiningKey != null) { 57 | // we've already cached the defining key 58 | definingKey = key.DefiningKey; 59 | return true; 60 | } 61 | 62 | // first time we've seen this key instance: we need to hit the map 63 | if (configurationItemDefinitionsSelfMap.TryGetValue(key, out definingKey)) { 64 | // initialize the cache for this key 65 | key.DefiningKey = definingKey; 66 | return true; 67 | } else { 68 | // not a real key 69 | definingKey = null; 70 | return false; 71 | } 72 | 73 | } 74 | 75 | internal ModConfigurationDefinition(ResoniteModBase owner, Version version, HashSet configurationItemDefinitions, bool autoSave) { 76 | Owner = owner; 77 | Version = version; 78 | AutoSave = autoSave; 79 | 80 | configurationItemDefinitionsSelfMap = new Dictionary(configurationItemDefinitions.Count); 81 | foreach (ModConfigurationKey key in configurationItemDefinitions) { 82 | key.DefiningKey = key; // early init this property for the defining key itself 83 | configurationItemDefinitionsSelfMap.Add(key, key); 84 | } 85 | } 86 | } 87 | 88 | /// 89 | /// The configuration for a mod. Each mod has zero or one configuration. The configuration object will never be reassigned once initialized. 90 | /// 91 | public class ModConfiguration : IModConfigurationDefinition { 92 | private readonly ModConfigurationDefinition Definition; 93 | 94 | private static readonly string ConfigDirectory = Path.Combine(Directory.GetCurrentDirectory(), "rml_config"); 95 | private const string VERSION_JSON_KEY = "version"; 96 | private const string VALUES_JSON_KEY = "values"; 97 | 98 | /// 99 | public ResoniteModBase Owner => Definition.Owner; 100 | 101 | /// 102 | public Version Version => Definition.Version; 103 | 104 | /// 105 | public ISet ConfigurationItemDefinitions => Definition.ConfigurationItemDefinitions; 106 | 107 | private bool AutoSave => Definition.AutoSave; 108 | 109 | /// 110 | /// The delegate that is called for configuration change events. 111 | /// 112 | /// The event containing details about the configuration change 113 | public delegate void ConfigurationChangedHandler(ConfigurationChangedEvent configurationChangedEvent); 114 | 115 | /// 116 | /// Called if any config value for any mod changed. 117 | /// 118 | public static event ConfigurationChangedHandler? OnAnyConfigurationChanged; 119 | 120 | /// 121 | /// Called if one of the values in this mod's config changed. 122 | /// 123 | public event ConfigurationChangedHandler? OnThisConfigurationChanged; 124 | 125 | // used to track how frequenly Save() is being called 126 | private Stopwatch saveTimer = new(); 127 | 128 | // time that save must not be called for a save to actually go through 129 | private int debounceMilliseconds = 3000; 130 | 131 | // used to keep track of mods that spam Save(): 132 | // any mod that calls Save() for the ModConfiguration within debounceMilliseconds of the previous call to the same ModConfiguration 133 | // will be put into Ultimate Punishment Mode, and ALL their Save() calls, regardless of ModConfiguration, will be debounced. 134 | // The naughty list is global, while the actual debouncing is per-configuration. 135 | private static HashSet naughtySavers = new HashSet(); 136 | 137 | // used to keep track of the debouncers for this configuration. 138 | private Dictionary> saveActionForCallee = new(); 139 | 140 | private static readonly JsonSerializer jsonSerializer = CreateJsonSerializer(); 141 | 142 | private static JsonSerializer CreateJsonSerializer() { 143 | JsonSerializerSettings settings = new() { 144 | MaxDepth = 32, 145 | ReferenceLoopHandling = ReferenceLoopHandling.Error, 146 | DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate, 147 | Formatting = Formatting.Indented 148 | }; 149 | List converters = new(); 150 | IList defaultConverters = settings.Converters; 151 | if (defaultConverters != null && defaultConverters.Count != 0) { 152 | Logger.DebugFuncInternal(() => $"Using {defaultConverters.Count} default json converters"); 153 | converters.AddRange(defaultConverters); 154 | } 155 | converters.Add(new EnumConverter()); 156 | converters.Add(new ResonitePrimitiveConverter()); 157 | settings.Converters = converters; 158 | return JsonSerializer.Create(settings); 159 | } 160 | 161 | private ModConfiguration(ModConfigurationDefinition definition) { 162 | Definition = definition; 163 | } 164 | 165 | internal static void EnsureDirectoryExists() { 166 | Directory.CreateDirectory(ConfigDirectory); 167 | } 168 | 169 | private static string GetModConfigPath(ResoniteModBase mod) { 170 | if (mod.ModAssembly is null) throw new ArgumentException("Cannot get the config path of a mod that has not been fully loaded"); 171 | 172 | string filename = Path.ChangeExtension(Path.GetFileName(mod.ModAssembly.File), ".json"); 173 | return Path.Combine(ConfigDirectory, filename); 174 | } 175 | 176 | private static bool AreVersionsCompatible(Version serializedVersion, Version currentVersion) { 177 | if (serializedVersion.Major != currentVersion.Major) { 178 | // major version differences are hard incompatible 179 | return false; 180 | } 181 | 182 | if (serializedVersion.Minor > currentVersion.Minor) { 183 | // if serialized config has a newer minor version than us 184 | // in other words, someone downgraded the mod but not the config 185 | // then we cannot load the config 186 | return false; 187 | } 188 | 189 | // none of the checks failed! 190 | return true; 191 | } 192 | 193 | /// 194 | /// Checks if the given key is defined in this config. 195 | /// 196 | /// The key to check. 197 | /// true if the key is defined. 198 | public bool IsKeyDefined(ModConfigurationKey key) { 199 | // if a key has a non-null defining key it's guaranteed a real key. Lets check for that. 200 | ModConfigurationKey? definingKey = key.DefiningKey; 201 | if (definingKey != null) { 202 | return true; 203 | } 204 | 205 | // The defining key was null, so lets try to get the defining key from the hashtable instead 206 | if (Definition.TryGetDefiningKey(key, out definingKey)) { 207 | // we might as well set this now that we have the real defining key 208 | key.DefiningKey = definingKey; 209 | return true; 210 | } 211 | 212 | // there was no definition 213 | return false; 214 | } 215 | 216 | /// 217 | /// Checks if the given key is the defining key. 218 | /// 219 | /// The key to check. 220 | /// true if the key is the defining key. 221 | internal static bool IsKeyDefiningKey(ModConfigurationKey key) { 222 | // a key is the defining key if and only if its DefiningKey property references itself 223 | return ReferenceEquals(key, key.DefiningKey); // this is safe because we'll throw a NRE if key is null 224 | } 225 | 226 | /// 227 | /// Get a value, throwing a if the key is not found. 228 | /// 229 | /// The key to get the value for. 230 | /// The value for the key. 231 | /// The given key does not exist in the configuration. 232 | public object GetValue(ModConfigurationKey key) { 233 | if (TryGetValue(key, out object? value)) { 234 | return value!; 235 | } else { 236 | throw new KeyNotFoundException($"{key.Name} not found in {Owner.Name} configuration"); 237 | } 238 | } 239 | 240 | /// 241 | /// Get a value, throwing a if the key is not found. 242 | /// 243 | /// The type of the key's value. 244 | /// The key to get the value for. 245 | /// The value for the key. 246 | /// The given key does not exist in the configuration. 247 | public T? GetValue(ModConfigurationKey key) { 248 | if (TryGetValue(key, out T? value)) { 249 | return value; 250 | } else { 251 | throw new KeyNotFoundException($"{key.Name} not found in {Owner.Name} configuration"); 252 | } 253 | } 254 | 255 | /// 256 | /// Tries to get a value, returning default if the key is not found. 257 | /// 258 | /// The key to get the value for. 259 | /// The value if the return value is true, or default if false. 260 | /// true if the value was read successfully. 261 | public bool TryGetValue(ModConfigurationKey key, out object? value) { 262 | if (!Definition.TryGetDefiningKey(key, out ModConfigurationKey? definingKey)) { 263 | // not in definition 264 | value = null; 265 | return false; 266 | } 267 | 268 | if (definingKey!.TryGetValue(out object? valueObject)) { 269 | value = valueObject; 270 | return true; 271 | } else if (definingKey.TryComputeDefault(out value)) { 272 | return true; 273 | } else { 274 | value = null; 275 | return false; 276 | } 277 | } 278 | 279 | 280 | /// 281 | /// Tries to get a value, returning default() if the key is not found. 282 | /// 283 | /// The key to get the value for. 284 | /// The value if the return value is true, or default if false. 285 | /// true if the value was read successfully. 286 | public bool TryGetValue(ModConfigurationKey key, out T? value) { 287 | if (TryGetValue(key, out object? valueObject)) { 288 | value = (T)valueObject!; 289 | return true; 290 | } else { 291 | value = default; 292 | return false; 293 | } 294 | } 295 | 296 | /// 297 | /// Sets a configuration value for the given key, throwing a if the key is not found 298 | /// or an if the value is not valid for it. 299 | /// 300 | /// The key to get the value for. 301 | /// The new value to set. 302 | /// A custom label you may assign to this change event. 303 | /// The given key does not exist in the configuration. 304 | /// The new value is not valid for the given key. 305 | public void Set(ModConfigurationKey key, object? value, string? eventLabel = null) { 306 | if (!Definition.TryGetDefiningKey(key, out ModConfigurationKey? definingKey)) { 307 | throw new KeyNotFoundException($"{key.Name} is not defined in the config definition for {Owner.Name}"); 308 | } 309 | 310 | if (value == null) { 311 | if (Util.CannotBeNull(definingKey!.ValueType())) { 312 | throw new ArgumentException($"null cannot be assigned to {definingKey.ValueType()}"); 313 | } 314 | } else if (!definingKey!.ValueType().IsAssignableFrom(value.GetType())) { 315 | throw new ArgumentException($"{value.GetType()} cannot be assigned to {definingKey.ValueType()}"); 316 | } 317 | 318 | if (!definingKey!.Validate(value)) { 319 | throw new ArgumentException($"\"{value}\" is not a valid value for \"{Owner.Name}{definingKey.Name}\""); 320 | } 321 | 322 | definingKey.Set(value); 323 | FireConfigurationChangedEvent(definingKey, eventLabel); 324 | } 325 | 326 | /// 327 | /// Sets a configuration value for the given key, throwing a if the key is not found 328 | /// or an if the value is not valid for it. 329 | /// 330 | /// The type of the key's value. 331 | /// The key to get the value for. 332 | /// The new value to set. 333 | /// A custom label you may assign to this change event. 334 | /// The given key does not exist in the configuration. 335 | /// The new value is not valid for the given key. 336 | public void Set(ModConfigurationKey key, T value, string? eventLabel = null) { 337 | // the reason we don't fall back to untyped Set() here is so we can skip the type check 338 | 339 | if (!Definition.TryGetDefiningKey(key, out ModConfigurationKey? definingKey)) { 340 | throw new KeyNotFoundException($"{key.Name} is not defined in the config definition for {Owner.Name}"); 341 | } 342 | 343 | if (!definingKey!.Validate(value)) { 344 | throw new ArgumentException($"\"{value}\" is not a valid value for \"{Owner.Name}{definingKey.Name}\""); 345 | } 346 | definingKey.Set(value); 347 | FireConfigurationChangedEvent(definingKey, eventLabel); 348 | } 349 | 350 | /// 351 | /// Removes a configuration value, throwing a if the key is not found. 352 | /// 353 | /// The key to remove the value for. 354 | /// true if a value was successfully found and removed, false if there was no value to remove. 355 | /// The given key does not exist in the configuration. 356 | public bool Unset(ModConfigurationKey key) { 357 | if (Definition.TryGetDefiningKey(key, out ModConfigurationKey? definingKey)) { 358 | return definingKey!.Unset(); 359 | } else { 360 | throw new KeyNotFoundException($"{key.Name} is not defined in the config definition for {Owner.Name}"); 361 | } 362 | } 363 | 364 | private bool AnyValuesSet() { 365 | return ConfigurationItemDefinitions 366 | .Where(key => key.HasValue) 367 | .Any(); 368 | } 369 | 370 | internal static ModConfiguration? LoadConfigForMod(ResoniteMod mod) { 371 | ModConfigurationDefinition? definition = mod.BuildConfigurationDefinition(); 372 | if (definition == null) { 373 | // if there's no definition, then there's nothing for us to do here 374 | return null; 375 | } 376 | 377 | string configFile = GetModConfigPath(mod); 378 | 379 | try { 380 | using StreamReader file = File.OpenText(configFile); 381 | using JsonTextReader reader = new(file); 382 | JObject json = JObject.Load(reader); 383 | Version version = new(json[VERSION_JSON_KEY]!.ToObject(jsonSerializer)); 384 | if (!AreVersionsCompatible(version, definition.Version)) { 385 | var handlingMode = mod.HandleIncompatibleConfigurationVersions(definition.Version, version); 386 | switch (handlingMode) { 387 | case IncompatibleConfigurationHandlingOption.CLOBBER: 388 | Logger.WarnInternal($"{mod.Name} saved config version is {version} which is incompatible with mod's definition version {definition.Version}. Clobbering old config and starting fresh."); 389 | return new ModConfiguration(definition); 390 | case IncompatibleConfigurationHandlingOption.FORCELOAD: 391 | break; 392 | case IncompatibleConfigurationHandlingOption.ERROR: // fall through to default 393 | default: 394 | mod.AllowSavingConfiguration = false; 395 | throw new ModConfigurationException($"{mod.Name} saved config version is {version} which is incompatible with mod's definition version {definition.Version}"); 396 | } 397 | } 398 | foreach (ModConfigurationKey key in definition.ConfigurationItemDefinitions) { 399 | string keyName = key.Name; 400 | try { 401 | JToken? token = json[VALUES_JSON_KEY]?[keyName]; 402 | if (token != null) { 403 | object? value = token.ToObject(key.ValueType(), jsonSerializer); 404 | key.Set(value); 405 | } 406 | } catch (Exception e) { 407 | // I know not what exceptions the JSON library will throw, but they must be contained 408 | mod.AllowSavingConfiguration = false; 409 | throw new ModConfigurationException($"Error loading {key.ValueType()} config key \"{keyName}\" for {mod.Name}", e); 410 | } 411 | } 412 | } catch (FileNotFoundException) { 413 | // return early and create a new config 414 | return new ModConfiguration(definition); 415 | } catch (Exception e) { 416 | // I know not what exceptions the JSON library will throw, but they must be contained 417 | mod.AllowSavingConfiguration = true; 418 | var backupPath = configFile + "." + Convert.ToBase64String(Encoding.UTF8.GetBytes(((int)DateTimeOffset.Now.TimeOfDay.TotalSeconds).ToString("X"))) + ".bak"; //ExampleMod.json.40A4.bak, unlikely to already exist 419 | Logger.ErrorExternal($"Error loading config for {mod.Name}, creating new config file (old file can be found at {backupPath}). Exception:\n{e}"); 420 | File.Move(configFile, backupPath); 421 | } 422 | 423 | return new ModConfiguration(definition); 424 | } 425 | 426 | /// 427 | /// Persist this configuration to disk.
428 | /// This method is not called automatically. 429 | ///
430 | /// If true, default values will also be persisted. 431 | /// 432 | /// Saving too often may result in save calls being debounced, with only the latest save call being used after a delay. 433 | /// 434 | public void Save(bool saveDefaultValues = false) { 435 | SaveQueue(saveDefaultValues, false); 436 | } 437 | 438 | /// 439 | /// Asynchronously persists this configuration to disk. 440 | /// 441 | /// If true, default values will also be persisted. 442 | /// If true, skip the debouncing and save immediately. 443 | /// 444 | /// immediate isn't used anywhere nor exposed outside of internal, mods shouldn't be bypassing debounce. 445 | /// 446 | internal void SaveQueue(bool saveDefaultValues = false, bool immediate = false) { 447 | Thread thread = Thread.CurrentThread; 448 | ResoniteMod? callee = Util.ExecutingMod(new(1)); 449 | Action? saveAction = null; 450 | 451 | // get saved state for this callee 452 | if (callee != null && naughtySavers.Contains(callee.Name) && !saveActionForCallee.TryGetValue(callee.Name, out saveAction)) { 453 | // handle case where the callee was marked as naughty from a different ModConfiguration being spammed 454 | saveAction = Util.Debounce(SaveInternal, debounceMilliseconds); 455 | saveActionForCallee.Add(callee.Name, saveAction); 456 | } 457 | 458 | if (saveTimer.IsRunning) { 459 | float elapsedMillis = saveTimer.ElapsedMilliseconds; 460 | saveTimer.Restart(); 461 | if (elapsedMillis < debounceMilliseconds) { 462 | Logger.WarnInternal($"ModConfiguration.Save({saveDefaultValues}) called for \"{Owner.Name}\" by \"{callee?.Name}\" from thread with id=\"{thread.ManagedThreadId}\", name=\"{thread.Name}\", bg=\"{thread.IsBackground}\", pool=\"{thread.IsThreadPoolThread}\". Last called {elapsedMillis / 1000f}s ago. This is very recent! Do not spam calls to ModConfiguration.Save()! All Save() calls by this mod are now subject to a {debounceMilliseconds}ms debouncing delay."); 463 | if (saveAction == null && callee != null) { 464 | // congrats, you've switched into Ultimate Punishment Mode where now I don't trust you and your Save() calls get debounced 465 | saveAction = Util.Debounce(SaveInternal, debounceMilliseconds); 466 | saveActionForCallee.Add(callee.Name, saveAction); 467 | naughtySavers.Add(callee.Name); 468 | } 469 | } else { 470 | Logger.DebugFuncInternal(() => $"ModConfiguration.Save({saveDefaultValues}) called for \"{Owner.Name}\" by \"{callee?.Name}\" from thread with id=\"{thread.ManagedThreadId}\", name=\"{thread.Name}\", bg=\"{thread.IsBackground}\", pool=\"{thread.IsThreadPoolThread}\". Last called {elapsedMillis / 1000f}s ago."); 471 | } 472 | } else { 473 | saveTimer.Start(); 474 | Logger.DebugFuncInternal(() => $"ModConfiguration.Save({saveDefaultValues}) called for \"{Owner.Name}\" by \"{callee?.Name}\" from thread with id=\"{thread.ManagedThreadId}\", name=\"{thread.Name}\", bg=\"{thread.IsBackground}\", pool=\"{thread.IsThreadPoolThread}\""); 475 | } 476 | 477 | // prevent saving if we've determined something is amiss with the configuration 478 | if (!Owner.AllowSavingConfiguration) { 479 | Logger.WarnInternal($"ModConfiguration for {Owner.Name} will NOT be saved due to a safety check failing. This is probably due to you downgrading a mod."); 480 | return; 481 | } 482 | 483 | if (immediate || saveAction == null) { 484 | // infrequent callers get to save immediately 485 | Task.Run(() => SaveInternal(saveDefaultValues)); 486 | } else { 487 | // bad callers get debounced 488 | saveAction(saveDefaultValues); 489 | } 490 | } 491 | 492 | /// 493 | /// Performs the actual, synchronous save 494 | /// 495 | /// If true, default values will also be persisted 496 | private void SaveInternal(bool saveDefaultValues = false) { 497 | Stopwatch stopwatch = Stopwatch.StartNew(); 498 | JObject json = new() { 499 | [VERSION_JSON_KEY] = JToken.FromObject(Definition.Version.ToString(), jsonSerializer) 500 | }; 501 | 502 | JObject valueMap = new(); 503 | foreach (ModConfigurationKey key in ConfigurationItemDefinitions) { 504 | if (key.TryGetValue(out object? value)) { 505 | valueMap[key.Name] = value == null ? null : JToken.FromObject(value, jsonSerializer); 506 | } else if (saveDefaultValues && key.TryComputeDefault(out object? defaultValue)) { 507 | valueMap[key.Name] = defaultValue == null ? null : JToken.FromObject(defaultValue, jsonSerializer); 508 | } 509 | } 510 | 511 | json[VALUES_JSON_KEY] = valueMap; 512 | 513 | string configFile = GetModConfigPath(Owner); 514 | 515 | File.WriteAllText(configFile, json.ToString()); 516 | 517 | Logger.DebugFuncInternal(() => $"Saved ModConfiguration for \"{Owner.Name}\" in {stopwatch.ElapsedMilliseconds}ms"); 518 | } 519 | 520 | private void FireConfigurationChangedEvent(ModConfigurationKey key, string? label) { 521 | try { 522 | OnAnyConfigurationChanged?.SafeInvoke(new ConfigurationChangedEvent(this, key, label)); 523 | } catch (Exception e) { 524 | Logger.ErrorInternal($"An OnAnyConfigurationChanged event subscriber threw an exception:\n{e}"); 525 | } 526 | 527 | try { 528 | OnThisConfigurationChanged?.SafeInvoke(new ConfigurationChangedEvent(this, key, label)); 529 | } catch (Exception e) { 530 | Logger.ErrorInternal($"An OnThisConfigurationChanged event subscriber threw an exception:\n{e}"); 531 | } 532 | } 533 | 534 | internal static void RegisterShutdownHook(Harmony harmony) { 535 | try { 536 | MethodInfo shutdown = AccessTools.DeclaredMethod(typeof(Engine), nameof(Engine.RequestShutdown)); 537 | if (shutdown == null) { 538 | Logger.ErrorInternal("Could not find method Engine.RequestShutdown(). Will not be able to autosave configs on close!"); 539 | return; 540 | } 541 | MethodInfo patch = AccessTools.DeclaredMethod(typeof(ModConfiguration), nameof(ShutdownHook)); 542 | if (patch == null) { 543 | Logger.ErrorInternal("Could not find method ModConfiguration.ShutdownHook(). Will not be able to autosave configs on close!"); 544 | return; 545 | } 546 | harmony.Patch(shutdown, prefix: new HarmonyMethod(patch)); 547 | } catch (Exception e) { 548 | Logger.ErrorInternal($"Unexpected exception applying shutdown hook!\n{e}"); 549 | } 550 | } 551 | 552 | private static void ShutdownHook() { 553 | int count = 0; 554 | ModLoader.Mods() 555 | .Select(mod => mod.GetConfiguration()) 556 | .Where(config => config != null) 557 | .Where(config => config!.AutoSave) 558 | .Where(config => config!.AnyValuesSet()) 559 | .Do(config => { 560 | try { 561 | // synchronously save the config 562 | config!.SaveInternal(); 563 | } catch (Exception e) { 564 | Logger.ErrorInternal($"Error saving configuration for {config!.Owner.Name}:\n{e}"); 565 | } 566 | count += 1; 567 | }); 568 | Logger.MsgInternal($"Configs saved for {count} mods."); 569 | } 570 | } 571 | 572 | /// 573 | /// Represents an encountered while loading a mod's configuration file. 574 | /// 575 | public class ModConfigurationException : Exception { 576 | internal ModConfigurationException(string message) : base(message) { } 577 | 578 | internal ModConfigurationException(string message, Exception innerException) : base(message, innerException) { } 579 | } 580 | 581 | /// 582 | /// Defines options for the handling of incompatible configuration versions. 583 | /// 584 | public enum IncompatibleConfigurationHandlingOption { 585 | /// 586 | /// Fail to read the config, and block saving over the config on disk. 587 | /// 588 | ERROR, 589 | 590 | /// 591 | /// Destroy the saved config and start over from scratch. 592 | /// 593 | CLOBBER, 594 | 595 | /// 596 | /// Ignore the version number and attempt to load the config from disk. 597 | /// 598 | FORCELOAD, 599 | } 600 | -------------------------------------------------------------------------------- /ResoniteModLoader/ModConfigurationDefinitionBuilder.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | 3 | namespace ResoniteModLoader; 4 | /// 5 | /// Represents a fluent configuration interface to define mod configurations. 6 | /// 7 | public class ModConfigurationDefinitionBuilder { 8 | private readonly ResoniteModBase Owner; 9 | private Version ConfigVersion = new(1, 0, 0); 10 | private readonly HashSet Keys = new(); 11 | private bool AutoSaveConfig = true; 12 | 13 | internal ModConfigurationDefinitionBuilder(ResoniteModBase owner) { 14 | Owner = owner; 15 | } 16 | 17 | /// 18 | /// Sets the semantic version of this configuration definition. Default is 1.0.0. 19 | /// 20 | /// The config's semantic version. 21 | /// This builder. 22 | public ModConfigurationDefinitionBuilder Version(Version version) { 23 | ConfigVersion = version; 24 | return this; 25 | } 26 | 27 | /// 28 | /// Sets the semantic version of this configuration definition. Default is 1.0.0. 29 | /// 30 | /// The config's semantic version, as a string. 31 | /// This builder. 32 | public ModConfigurationDefinitionBuilder Version(string version) { 33 | ConfigVersion = new Version(version); 34 | return this; 35 | } 36 | 37 | /// 38 | /// Adds a new key to this configuration definition. 39 | /// 40 | /// A configuration key. 41 | /// This builder. 42 | public ModConfigurationDefinitionBuilder Key(ModConfigurationKey key) { 43 | Keys.Add(key); 44 | return this; 45 | } 46 | 47 | /// 48 | /// Sets the AutoSave property of this configuration definition. Default is true. 49 | /// 50 | /// If false, the config will not be autosaved on Resonite close. 51 | /// This builder. 52 | public ModConfigurationDefinitionBuilder AutoSave(bool autoSave) { 53 | AutoSaveConfig = autoSave; 54 | return this; 55 | } 56 | 57 | internal void ProcessAttributes() { 58 | AccessTools.GetDeclaredFields(Owner.GetType()) 59 | .Where(field => field.GetCustomAttribute() is not null) 60 | .Do(ProcessField); 61 | } 62 | 63 | private void ProcessField(FieldInfo field) { 64 | if (!typeof(ModConfigurationKey).IsAssignableFrom(field.FieldType)) { 65 | // wrong type 66 | Logger.WarnInternal($"{Owner.Name} had an [AutoRegisterConfigKey] field of the wrong type: {field}"); 67 | return; 68 | } 69 | 70 | ModConfigurationKey fieldValue = (ModConfigurationKey)field.GetValue(field.IsStatic ? null : Owner); 71 | Keys.Add(fieldValue); 72 | } 73 | 74 | internal ModConfigurationDefinition? Build() { 75 | if (Keys.Count > 0) { 76 | return new ModConfigurationDefinition(Owner, ConfigVersion, Keys, AutoSaveConfig); 77 | } 78 | return null; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /ResoniteModLoader/ModConfigurationKey.cs: -------------------------------------------------------------------------------- 1 | namespace ResoniteModLoader; 2 | 3 | /// 4 | /// Represents an untyped mod configuration key. 5 | /// 6 | public abstract class ModConfigurationKey { 7 | internal ModConfigurationKey(string name, string? description, bool internalAccessOnly) { 8 | Name = name ?? throw new ArgumentNullException(nameof(name)); 9 | Description = description; 10 | InternalAccessOnly = internalAccessOnly; 11 | } 12 | 13 | /// 14 | /// Gets the mod-unique name of this config item. Must be present. 15 | /// 16 | public string Name { get; private set; } 17 | 18 | /// 19 | /// Gets the human-readable description of this config item. Should be specified by the defining mod. 20 | /// 21 | public string? Description { get; private set; } 22 | 23 | /// 24 | /// Gets whether only the owning mod should have access to this config item. 25 | /// 26 | public bool InternalAccessOnly { get; private set; } 27 | 28 | /// 29 | /// Delegate for handling configuration changes. 30 | /// 31 | /// The new value of the . 32 | public delegate void OnChangedHandler(object? newValue); 33 | 34 | /// 35 | /// Called if this changed. 36 | /// 37 | public event OnChangedHandler? OnChanged; 38 | 39 | /// 40 | /// Get the of this key's value. 41 | /// 42 | /// The of this key's value. 43 | public abstract Type ValueType(); 44 | 45 | /// 46 | /// Checks if a value is valid for this configuration item. 47 | /// 48 | /// The value to check. 49 | /// true if the value is valid. 50 | public abstract bool Validate(object? value); 51 | 52 | /// 53 | /// Tries to compute the default value for this key, if a default provider was set. 54 | /// 55 | /// The computed default value if the return value is true. Otherwise default. 56 | /// true if the default value was successfully computed. 57 | public abstract bool TryComputeDefault(out object? defaultValue); 58 | 59 | /// 60 | /// We only care about key name for non-defining keys.
61 | /// For defining keys all of the other properties (default, validator, etc.) also matter. 62 | ///
63 | /// The other object to compare against. 64 | /// true if the other object is equal to this. 65 | public override bool Equals(object obj) { 66 | return obj is ModConfigurationKey key && 67 | Name == key.Name; 68 | } 69 | 70 | /// 71 | public override string ToString() { 72 | return $"ConfigKey Name: {Name}, Description: {Description}, InternalAccessOnly: {InternalAccessOnly}, Type: {ValueType()}, Value: {Value}"; 73 | } 74 | 75 | /// 76 | public override int GetHashCode() { 77 | return 539060726 + EqualityComparer.Default.GetHashCode(Name); 78 | } 79 | 80 | private object? Value; 81 | internal bool HasValue; 82 | 83 | /// 84 | /// Each configuration item has exactly ONE defining key, and that is the key defined by the mod. 85 | /// Duplicate keys can be created (they only need to share the same Name) and they'll still work 86 | /// for reading configs. 87 | /// 88 | /// This is a non-null self-reference for the defining key itself as soon as the definition is done initializing. 89 | /// 90 | internal ModConfigurationKey? DefiningKey; 91 | 92 | internal bool TryGetValue(out object? value) { 93 | if (HasValue) { 94 | value = Value; 95 | return true; 96 | } else { 97 | value = null; 98 | return false; 99 | } 100 | } 101 | 102 | internal void Set(object? value) { 103 | Value = value; 104 | HasValue = true; 105 | try { 106 | OnChanged?.SafeInvoke(value); 107 | } catch (Exception e) { 108 | Logger.ErrorInternal($"An OnChanged event subscriber for {Name} threw an exception:\n{e}"); 109 | } 110 | } 111 | 112 | internal bool Unset() { 113 | bool hadValue = HasValue; 114 | HasValue = false; 115 | try { 116 | OnChanged?.SafeInvoke(); 117 | } catch (Exception e) { 118 | Logger.ErrorInternal($"An OnChanged event subscriber for {Name} threw an exception:\n{e}"); 119 | } 120 | return hadValue; 121 | } 122 | } 123 | 124 | /// 125 | /// Represents a typed mod configuration key. 126 | /// 127 | /// The type of this key's value. 128 | public class ModConfigurationKey : ModConfigurationKey { 129 | /// 130 | /// Creates a new instance of the class with the given parameters. 131 | /// 132 | /// The mod-unique name of this config item. 133 | /// The human-readable description of this config item. 134 | /// The function that computes a default value for this key. Otherwise default() will be used. 135 | /// If true, only the owning mod should have access to this config item. 136 | /// The function that checks if the given value is valid for this configuration item. Otherwise everything will be accepted. 137 | public ModConfigurationKey(string name, string? description = null, Func? computeDefault = null, bool internalAccessOnly = false, Predicate? valueValidator = null) : base(name, description, internalAccessOnly) { 138 | ComputeDefault = computeDefault; 139 | IsValueValid = valueValidator; 140 | } 141 | 142 | private readonly Func? ComputeDefault; 143 | private readonly Predicate? IsValueValid; 144 | 145 | /// 146 | public override Type ValueType() => typeof(T); 147 | 148 | /// 149 | public override bool Validate(object? value) { 150 | if (value is T typedValue) { 151 | // value is of the correct type 152 | return ValidateTyped(typedValue); 153 | } else if (value == null) { 154 | if (Util.CanBeNull(ValueType())) { 155 | // null is valid for T 156 | return ValidateTyped((T?)value); 157 | } else { 158 | // null is not valid for T 159 | return false; 160 | } 161 | } else { 162 | // value is of the wrong type 163 | return false; 164 | } 165 | } 166 | 167 | /// 168 | /// Checks if a value is valid for this configuration item. 169 | /// 170 | /// The value to check. 171 | /// true if the value is valid. 172 | public bool ValidateTyped(T? value) { 173 | if (IsValueValid == null) { 174 | return true; 175 | } else { 176 | return IsValueValid(value); 177 | } 178 | } 179 | 180 | /// 181 | public override bool TryComputeDefault(out object? defaultValue) { 182 | if (TryComputeDefaultTyped(out T? defaultTypedValue)) { 183 | defaultValue = defaultTypedValue; 184 | return true; 185 | } else { 186 | defaultValue = null; 187 | return false; 188 | } 189 | } 190 | 191 | /// 192 | /// Tries to compute the default value for this key, if a default provider was set. 193 | /// 194 | /// The computed default value if the return value is true. Otherwise default(T). 195 | /// true if the default value was successfully computed. 196 | public bool TryComputeDefaultTyped(out T? defaultValue) { 197 | if (ComputeDefault == null) { 198 | defaultValue = default; 199 | return false; 200 | } else { 201 | defaultValue = ComputeDefault(); 202 | return true; 203 | } 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /ResoniteModLoader/ModLoader.cs: -------------------------------------------------------------------------------- 1 | using HarmonyLib; 2 | 3 | namespace ResoniteModLoader; 4 | 5 | /// 6 | /// Contains the actual mod loader. 7 | /// 8 | public sealed class ModLoader { 9 | internal const string VERSION_CONSTANT = "3.1.0"; 10 | /// 11 | /// ResoniteModLoader's version 12 | /// 13 | public static readonly string VERSION = VERSION_CONSTANT; 14 | private static readonly Type RESONITE_MOD_TYPE = typeof(ResoniteMod); 15 | private static readonly List LoadedMods = new(); // used for mod enumeration 16 | internal static readonly Dictionary AssemblyLookupMap = new(); // used for logging 17 | private static readonly Dictionary ModNameLookupMap = new(); // used for duplicate mod checking 18 | 19 | 20 | /// 21 | /// Returns true if ResoniteModLoader was loaded by a headless 22 | /// 23 | public static bool IsHeadless { // Extremely thorough, but doesn't rely on any specific class to check for headless presence 24 | get { 25 | return _isHeadless ??= AppDomain.CurrentDomain.GetAssemblies().Any(a => { 26 | IEnumerable types; 27 | try { 28 | types = a.GetTypes(); 29 | } catch (ReflectionTypeLoadException e) { 30 | types = e.Types; 31 | } 32 | return types.Any(t => t != null && t.Namespace == "FrooxEngine.Headless"); 33 | }); 34 | } 35 | } 36 | 37 | 38 | private static bool? _isHeadless; 39 | 40 | /// 41 | /// Allows reading metadata for all loaded mods 42 | /// 43 | /// A new list containing each loaded mod 44 | public static IEnumerable Mods() { 45 | return LoadedMods.ToList(); 46 | } 47 | 48 | internal static void LoadMods() { 49 | ModLoaderConfiguration config = ModLoaderConfiguration.Get(); 50 | if (config.NoMods) { 51 | Logger.DebugInternal("Mods will not be loaded due to configuration file"); 52 | return; 53 | } 54 | LoadProgressIndicator.SetCustom("Gathering mods"); 55 | // generate list of assemblies to load 56 | AssemblyFile[] modsToLoad; 57 | if (AssemblyLoader.LoadAssembliesFromDir("rml_mods") is AssemblyFile[] arr) { 58 | modsToLoad = arr; 59 | } else { 60 | return; 61 | } 62 | 63 | ModConfiguration.EnsureDirectoryExists(); 64 | 65 | // Call InitializeMod() for each mod 66 | foreach (AssemblyFile mod in modsToLoad) { 67 | try { 68 | ResoniteMod? loaded = InitializeMod(mod); 69 | if (loaded != null) { 70 | // if loading succeeded, then we need to register the mod 71 | RegisterMod(loaded); 72 | } 73 | } catch (ReflectionTypeLoadException reflectionTypeLoadException) { 74 | // this exception type has some inner exceptions we must also log to gain any insight into what went wrong 75 | StringBuilder sb = new(); 76 | sb.AppendLine(reflectionTypeLoadException.ToString()); 77 | foreach (Exception loaderException in reflectionTypeLoadException.LoaderExceptions) { 78 | sb.AppendLine($"Loader Exception: {loaderException.Message}"); 79 | if (loaderException is FileNotFoundException fileNotFoundException) { 80 | if (!string.IsNullOrEmpty(fileNotFoundException.FusionLog)) { 81 | sb.Append(" Fusion Log:\n "); 82 | sb.AppendLine(fileNotFoundException.FusionLog); 83 | } 84 | } 85 | } 86 | Logger.ErrorInternal($"ReflectionTypeLoadException initializing mod from {mod.File}:\n{sb}"); 87 | } catch (Exception e) { 88 | Logger.ErrorInternal($"Unexpected exception initializing mod from {mod.File}:\n{e}"); 89 | } 90 | } 91 | 92 | foreach (ResoniteMod mod in LoadedMods) { 93 | try { 94 | HookMod(mod); 95 | } catch (Exception e) { 96 | Logger.ErrorInternal($"Unexpected exception in OnEngineInit() for mod {mod.Name} from {mod.ModAssembly?.File ?? "Unknown Assembly"}:\n{e}"); 97 | } 98 | } 99 | 100 | // Log potential conflicts 101 | if (config.LogConflicts) { 102 | LoadProgressIndicator.SetCustom("Looking for conflicts"); 103 | IEnumerable patchedMethods = Harmony.GetAllPatchedMethods(); 104 | foreach (MethodBase patchedMethod in patchedMethods) { 105 | Patches patches = Harmony.GetPatchInfo(patchedMethod); 106 | HashSet owners = new(patches.Owners); 107 | if (owners.Count > 1) { 108 | Logger.WarnInternal($"Method \"{patchedMethod.FullDescription()}\" has been patched by the following:"); 109 | foreach (string owner in owners) { 110 | Logger.WarnInternal($" \"{owner}\" ({TypesForOwner(patches, owner)})"); 111 | } 112 | } else if (config.Debug) { 113 | string owner = owners.FirstOrDefault(); 114 | Logger.DebugFuncInternal(() => $"Method \"{patchedMethod.FullDescription()}\" has been patched by \"{owner}\""); 115 | } 116 | } 117 | } 118 | Logger.DebugInternal("Mod loading finished"); 119 | } 120 | 121 | /// 122 | /// Registers a successfully loaded mod, adding it to various lookup maps. 123 | /// 124 | /// The successfully loaded mod to register 125 | private static void RegisterMod(ResoniteMod mod) { 126 | if (mod.ModAssembly is null) throw new ArgumentException("Cannot register a mod before it's properly initialized."); 127 | 128 | try { 129 | ModNameLookupMap.Add(mod.Name, mod); 130 | } catch (ArgumentException) { 131 | ResoniteModBase existing = ModNameLookupMap[mod.Name]; 132 | Logger.ErrorInternal($"{mod.ModAssembly?.File} declares duplicate mod {mod.Name} already declared in {existing.ModAssembly?.File ?? "Unknown Assembly"}. The new mod will be ignored."); 133 | return; 134 | } 135 | 136 | LoadedMods.Add(mod); 137 | AssemblyLookupMap.Add(mod.ModAssembly.Assembly, mod); 138 | mod.FinishedLoading = true; // used to signal that the mod is truly loaded 139 | } 140 | 141 | private static string TypesForOwner(Patches patches, string owner) { 142 | bool ownerEquals(Patch patch) => Equals(patch.owner, owner); 143 | int prefixCount = patches.Prefixes.Where(ownerEquals).Count(); 144 | int postfixCount = patches.Postfixes.Where(ownerEquals).Count(); 145 | int transpilerCount = patches.Transpilers.Where(ownerEquals).Count(); 146 | int finalizerCount = patches.Finalizers.Where(ownerEquals).Count(); 147 | return $"prefix={prefixCount}; postfix={postfixCount}; transpiler={transpilerCount}; finalizer={finalizerCount}"; 148 | } 149 | 150 | /// 151 | /// Load the mod class and mod config for a given mod. 152 | /// 153 | /// The for an unloaded mod 154 | private static ResoniteMod? InitializeMod(AssemblyFile mod) { 155 | if (mod.Assembly == null) { 156 | return null; 157 | } 158 | 159 | Type[] modClasses = mod.Assembly.GetLoadableTypes(t => t.IsClass && !t.IsAbstract && RESONITE_MOD_TYPE.IsAssignableFrom(t)).ToArray(); 160 | if (modClasses.Length == 0) { 161 | Logger.ErrorInternal($"No loadable mod found in {mod.File}"); 162 | return null; 163 | } else if (modClasses.Length != 1) { 164 | Logger.ErrorInternal($"More than one mod found in {mod.File}. File will not be loaded."); 165 | return null; 166 | } else { 167 | Type modClass = modClasses[0]; 168 | ResoniteMod? resoniteMod = null; 169 | try { 170 | resoniteMod = (ResoniteMod)AccessTools.CreateInstance(modClass); 171 | } catch (Exception e) { 172 | Logger.ErrorInternal($"Error instantiating mod {modClass.FullName} from {mod.File}:\n{e}"); 173 | return null; 174 | } 175 | if (resoniteMod == null) { 176 | Logger.ErrorInternal($"Unexpected null instantiating mod {modClass.FullName} from {mod.File}"); 177 | return null; 178 | } 179 | 180 | LoadProgressIndicator.SetCustom($"Loading configuration for [{resoniteMod.Name}/{resoniteMod.Version}]"); 181 | resoniteMod.ModAssembly = mod; 182 | Logger.MsgInternal($"Loaded mod [{resoniteMod.Name}/{resoniteMod.Version}] ({Path.GetFileName(mod.File)}) by {resoniteMod.Author} with Sha256: {mod.Sha256}"); 183 | resoniteMod.ModConfiguration = ModConfiguration.LoadConfigForMod(resoniteMod); 184 | return resoniteMod; 185 | } 186 | } 187 | 188 | private static void HookMod(ResoniteMod mod) { 189 | LoadProgressIndicator.SetCustom($"Starting mod [{mod.Name}/{mod.Version}]"); 190 | Logger.DebugFuncInternal(() => $"calling OnEngineInit() for [{mod.Name}/{mod.Version}]"); 191 | try { 192 | mod.OnEngineInit(); 193 | } catch (Exception e) { 194 | Logger.ErrorInternal($"Mod {mod.Name} from {mod.ModAssembly?.File ?? "Unknown Assembly"} threw error from OnEngineInit():\n{e}"); 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /ResoniteModLoader/ModLoaderConfiguration.cs: -------------------------------------------------------------------------------- 1 | namespace ResoniteModLoader; 2 | 3 | internal sealed class ModLoaderConfiguration { 4 | private const string CONFIG_FILENAME = "ResoniteModLoader.config"; 5 | 6 | private static ModLoaderConfiguration? _configuration; 7 | 8 | internal static ModLoaderConfiguration Get() { 9 | if (_configuration == null) { 10 | // the config file can just sit next to the dll. Simple. 11 | string path = Path.Combine(GetAssemblyDirectory(), CONFIG_FILENAME); 12 | _configuration = new ModLoaderConfiguration(); 13 | 14 | Dictionary> keyActions = new() { 15 | //{ "unsafe", (value) => _configuration.Unsafe = bool.Parse(value) }, 16 | { "debug", (value) => _configuration.Debug = bool.Parse(value) }, 17 | { "hidevisuals", (value) => _configuration.HideVisuals = bool.Parse(value) }, 18 | { "nomods", (value) => _configuration.NoMods = bool.Parse(value) }, 19 | { "advertiseversion", (value) => _configuration.AdvertiseVersion = bool.Parse(value) }, 20 | { "logconflicts", (value) => _configuration.LogConflicts = bool.Parse(value) }, 21 | //{ "hidemodtypes", (value) => _configuration.HideModTypes = bool.Parse(value) }, 22 | //{ "hidelatetypes", (value) => _configuration.HideLateTypes = bool.Parse(value) } 23 | }; 24 | 25 | // .NET's ConfigurationManager is some hot trash to the point where I'm just done with it. 26 | // Time to reinvent the wheel. This parses simple key=value style properties from a text file. 27 | try { 28 | var lines = File.ReadAllLines(path); 29 | foreach (var line in lines) { 30 | int splitIdx = line.IndexOf('='); 31 | if (splitIdx != -1) { 32 | string key = line.Substring(0, splitIdx); 33 | string value = line.Substring(splitIdx + 1); 34 | 35 | if (keyActions.TryGetValue(key, out Action action)) { 36 | try { 37 | action(value); 38 | } catch (Exception) { 39 | Logger.WarnInternal($"Unable to parse value: '{value}' for config key: '{key}', this key will be ignored"); 40 | } 41 | } 42 | } 43 | } 44 | } catch (Exception e) { 45 | if (e is FileNotFoundException) { 46 | Logger.MsgInternal($"No modloader config found at {path}, using defaults. This is probably fine."); 47 | } else if (e is DirectoryNotFoundException || e is IOException || e is UnauthorizedAccessException) { 48 | Logger.WarnInternal(e.ToString()); 49 | } else { 50 | throw; 51 | } 52 | } 53 | } 54 | return _configuration; 55 | } 56 | 57 | private static string GetAssemblyDirectory() { 58 | string codeBase = Assembly.GetExecutingAssembly().CodeBase; 59 | UriBuilder uri = new(codeBase); 60 | string path = Uri.UnescapeDataString(uri.Path); 61 | return Path.GetDirectoryName(path); 62 | } 63 | 64 | #pragma warning disable CA1805 65 | //public bool Unsafe { get; private set; } = false; 66 | public bool Debug { get; private set; } = false; 67 | public bool NoMods { get; private set; } = false; 68 | public bool HideVisuals { get; private set; } = false; 69 | public bool AdvertiseVersion { get; private set; } = false; 70 | public bool LogConflicts { get; private set; } = true; 71 | //public bool HideModTypes { get; private set; } = true; 72 | //public bool HideLateTypes { get; private set; } = true; 73 | #pragma warning restore CA1805 74 | } 75 | -------------------------------------------------------------------------------- /ResoniteModLoader/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | using Elements.Core; 6 | 7 | [assembly: AssemblyTitle("ResoniteModLoader")] 8 | [assembly: AssemblyProduct("ResoniteModLoader")] 9 | [assembly: AssemblyDescription("A modloader for Resonite")] 10 | [assembly: AssemblyCopyright("Copyright © 2025")] 11 | [assembly: AssemblyVersion(ResoniteModLoader.ModLoader.VERSION_CONSTANT)] 12 | [assembly: AssemblyFileVersion(ResoniteModLoader.ModLoader.VERSION_CONSTANT)] 13 | 14 | [assembly: ComVisible(false)] 15 | 16 | // Prevent FrooxEngine.Weaver from modifying this assembly, as it doesn't need anything done to it 17 | // This keeps Weaver from overwriting AssemblyVersionAttribute 18 | [module: Description("FROOXENGINE_WEAVED")] 19 | 20 | //Mark as DataModelAssembly for the Plugin loading system to load this assembly 21 | [assembly: DataModelAssembly(DataModelAssemblyType.Optional)] 22 | -------------------------------------------------------------------------------- /ResoniteModLoader/ResoniteMod.cs: -------------------------------------------------------------------------------- 1 | namespace ResoniteModLoader; 2 | 3 | /// 4 | /// Contains members that only the or the Mod itself are intended to access. 5 | /// 6 | public abstract class ResoniteMod : ResoniteModBase { 7 | /// 8 | /// Gets whether debug logging is enabled. 9 | /// 10 | /// true if debug logging is enabled. 11 | public static bool IsDebugEnabled() => Logger.IsDebugEnabled(); 12 | 13 | /// 14 | /// Logs an object as a line in the log based on the value produced by the given function if debug logging is enabled.. 15 | /// 16 | /// This is more efficient than passing an or a directly, 17 | /// as it won't be generated if debug logging is disabled. 18 | /// 19 | /// The function generating the object to log. 20 | public static void DebugFunc(Func messageProducer) => Logger.DebugFuncExternal(messageProducer); 21 | 22 | /// 23 | /// Logs the given object as a line in the log if debug logging is enabled. 24 | /// 25 | /// The object to log. 26 | public static void Debug(object message) => Logger.DebugExternal(message); 27 | 28 | /// 29 | /// Logs the given objects as lines in the log if debug logging is enabled. 30 | /// 31 | /// The objects to log. 32 | public static void Debug(params object[] messages) => Logger.DebugListExternal(messages); 33 | 34 | /// 35 | /// Logs the given object as a regular line in the log. 36 | /// 37 | /// The object to log. 38 | public static void Msg(object message) => Logger.MsgExternal(message); 39 | 40 | /// 41 | /// Logs the given objects as regular lines in the log. 42 | /// 43 | /// The objects to log. 44 | public static void Msg(params object[] messages) => Logger.MsgListExternal(messages); 45 | 46 | /// 47 | /// Logs the given object as a warning line in the log. 48 | /// 49 | /// The object to log. 50 | public static void Warn(object message) => Logger.WarnExternal(message); 51 | 52 | /// 53 | /// Logs the given objects as warning lines in the log. 54 | /// 55 | /// The objects to log. 56 | public static void Warn(params object[] messages) => Logger.WarnListExternal(messages); 57 | 58 | /// 59 | /// Logs the given object as an error line in the log. 60 | /// 61 | /// The object to log. 62 | public static void Error(object message) => Logger.ErrorExternal(message); 63 | 64 | /// 65 | /// Logs the given objects as error lines in the log. 66 | /// 67 | /// The objects to log. 68 | public static void Error(params object[] messages) => Logger.ErrorListExternal(messages); 69 | 70 | /// 71 | /// Called once immediately after ResoniteModLoader begins execution 72 | /// 73 | public virtual void OnEngineInit() { } 74 | 75 | /// 76 | /// Build the defined configuration for this mod. 77 | /// 78 | /// This mod's configuration definition. 79 | internal ModConfigurationDefinition? BuildConfigurationDefinition() { 80 | ModConfigurationDefinitionBuilder builder = new(this); 81 | builder.ProcessAttributes(); 82 | DefineConfiguration(builder); 83 | return builder.Build(); 84 | } 85 | 86 | /// 87 | /// Define this mod's configuration via a builder 88 | /// 89 | /// A builder you can use to define the mod's configuration 90 | public virtual void DefineConfiguration(ModConfigurationDefinitionBuilder builder) { 91 | } 92 | 93 | /// 94 | /// Defines handling of incompatible configuration versions 95 | /// 96 | /// Configuration version read from the config file 97 | /// Configuration version defined in the mod code 98 | /// 99 | public virtual IncompatibleConfigurationHandlingOption HandleIncompatibleConfigurationVersions(Version serializedVersion, Version definedVersion) { 100 | return IncompatibleConfigurationHandlingOption.ERROR; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /ResoniteModLoader/ResoniteModBase.cs: -------------------------------------------------------------------------------- 1 | namespace ResoniteModLoader; 2 | 3 | /// 4 | /// Contains public metadata about a mod. 5 | /// 6 | public abstract class ResoniteModBase { 7 | /// 8 | /// Gets the mod's name. This must be unique. 9 | /// 10 | public abstract string Name { get; } 11 | 12 | /// 13 | /// Gets the mod's author. 14 | /// 15 | public abstract string Author { get; } 16 | 17 | /// 18 | /// Gets the mod's semantic version. 19 | /// 20 | public abstract string Version { get; } 21 | 22 | /// 23 | /// Gets an optional hyperlink to the mod's repo or homepage. 24 | /// 25 | public virtual string? Link { get; } 26 | 27 | /// 28 | /// A reference to the AssemblyFile that this mod was loaded from. 29 | /// The reference is set once the mod is successfully loaded, and is null before that. 30 | /// 31 | internal AssemblyFile? ModAssembly { get; set; } 32 | 33 | internal ModConfiguration? ModConfiguration { get; set; } 34 | internal bool AllowSavingConfiguration = true; 35 | 36 | /// 37 | /// Gets this mod's current . 38 | /// 39 | /// This will always be the same instance. 40 | /// 41 | /// This mod's current configuration. 42 | public ModConfiguration? GetConfiguration() { 43 | if (!FinishedLoading) { 44 | throw new ModConfigurationException($"GetConfiguration() was called before {Name} was done initializing. Consider calling GetConfiguration() from within OnEngineInit()"); 45 | } 46 | return ModConfiguration; 47 | } 48 | 49 | internal bool FinishedLoading { get; set; } 50 | } 51 | -------------------------------------------------------------------------------- /ResoniteModLoader/ResoniteModLoader.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | ResoniteModLoader 4 | ResoniteModLoader 5 | false 6 | net472 7 | 512 8 | 11.0 9 | enable 10 | true 11 | True 12 | enable 13 | True 14 | 7.0-Recommended 15 | 16 | false 17 | embedded 18 | 19 | 20 | 21 | 22 | $(MSBuildThisFileDirectory)Resonite/ 23 | C:\Program Files (x86)\Steam\steamapps\common\Resonite\ 24 | $(HOME)/.steam/steam/steamapps/common/Resonite/ 25 | 26 | 27 | 28 | 29 | 30 | $(ResonitePath)Resonite_Data\Managed\Elements.Core.dll 31 | False 32 | 33 | 34 | $(ResonitePath)Resonite_Data\Managed\FrooxEngine.dll 35 | False 36 | 37 | 38 | $(ResonitePath)Resonite_Data\Managed\Newtonsoft.Json.dll 39 | False 40 | 41 | 42 | 43 | $(ResonitePath)Resonite_Data\Managed\Assembly-CSharp.dll 44 | False 45 | 46 | 47 | 48 | $(ResonitePath)Resonite_Data\Managed\UnityEngine.CoreModule.dll 49 | False 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /ResoniteModLoader/Util.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Security.Cryptography; 3 | 4 | namespace ResoniteModLoader; 5 | 6 | internal static class Util { 7 | /// 8 | /// Get the executing mod by stack trace analysis. 9 | /// You may skip extra frames if you know your callers are guaranteed to be RML code. 10 | /// 11 | /// A stack trace captured by the callee 12 | /// The executing mod, or null if none found 13 | internal static ResoniteMod? ExecutingMod(StackTrace stackTrace) { 14 | for (int i = 0; i < stackTrace.FrameCount; i++) { 15 | Assembly? assembly = stackTrace.GetFrame(i)?.GetMethod()?.DeclaringType?.Assembly; 16 | if (assembly != null && ModLoader.AssemblyLookupMap.TryGetValue(assembly, out ResoniteMod mod)) { 17 | return mod; 18 | } 19 | } 20 | return null; 21 | } 22 | 23 | /// 24 | /// Used to debounce calls to a given method. The given method will be called after there have been no additional calls 25 | /// for the given number of milliseconds. 26 | /// 27 | /// The returned by this method has internal state used for debouncing, 28 | /// so you will need to store and reuse the Action for each call. 29 | /// 30 | /// The type of the debounced method's input. 31 | /// The method to be debounced. 32 | /// How long to wait before a call to the debounced method gets passed through. 33 | /// A debouncing wrapper for the given method. 34 | // credit: https://stackoverflow.com/questions/28472205/c-sharp-event-debounce 35 | internal static Action Debounce(this Action func, int milliseconds) { 36 | // this variable gets embedded in the returned Action via the magic of closures 37 | CancellationTokenSource? cancelTokenSource = null; 38 | 39 | return arg => { 40 | // if there's already a scheduled call, then cancel it 41 | cancelTokenSource?.Cancel(); 42 | cancelTokenSource = new CancellationTokenSource(); 43 | 44 | // schedule a new call 45 | Task.Delay(milliseconds, cancelTokenSource.Token) 46 | .ContinueWith(t => { 47 | if (t.IsCompletedSuccessfully()) { 48 | Task.Run(() => func(arg)); 49 | } 50 | }, TaskScheduler.Default); 51 | }; 52 | } 53 | 54 | // Shim because this doesn't exist in .NET 4.6 55 | private static bool IsCompletedSuccessfully(this Task t) { 56 | return t.IsCompleted && !t.IsFaulted && !t.IsCanceled; 57 | } 58 | 59 | // credit to Delta for this method https://github.com/XDelta/ 60 | internal static string GenerateSHA256(string filepath) { 61 | using var hasher = SHA256.Create(); 62 | using var stream = File.OpenRead(filepath); 63 | var hash = hasher.ComputeHash(stream); 64 | return BitConverter.ToString(hash).Replace("-", ""); 65 | } 66 | 67 | internal static HashSet ToHashSet(this IEnumerable source, IEqualityComparer? comparer = null) { 68 | return new HashSet(source, comparer); 69 | } 70 | 71 | // Check if a type cannot possibly have null assigned 72 | internal static bool CannotBeNull(Type t) { 73 | return t.IsValueType && Nullable.GetUnderlyingType(t) == null; 74 | } 75 | 76 | // Check if a type is allowed to have null assigned 77 | internal static bool CanBeNull(Type t) { 78 | return !CannotBeNull(t); 79 | } 80 | 81 | internal static IEnumerable GetLoadableTypes(this Assembly assembly, Predicate predicate) { 82 | try { 83 | return assembly.GetTypes().Where(type => CheckType(type, predicate)); 84 | } catch (ReflectionTypeLoadException e) { 85 | return e.Types.Where(type => CheckType(type, predicate)); 86 | } 87 | } 88 | 89 | // Check a potentially unloadable type to see if it is (A) loadable and (B) satsifies a predicate without throwing an exception 90 | // this does a series of increasingly aggressive checks to see if the type is unsafe to touch 91 | private static bool CheckType(Type type, Predicate predicate) { 92 | if (type == null) { 93 | return false; 94 | } 95 | 96 | try { 97 | string _name = type.Name; 98 | } catch (Exception e) { 99 | Logger.DebugFuncInternal(() => $"Could not read the name for a type: {e}"); 100 | return false; 101 | } 102 | 103 | try { 104 | return predicate(type); 105 | } catch (Exception e) { 106 | Logger.DebugFuncInternal(() => $"Could not load type \"{type}\": {e}"); 107 | return false; 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /ResoniteModLoader/Utility/EnumerableInjector.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | 3 | namespace ResoniteModLoader.Utility; 4 | 5 | /// 6 | /// Provides the ability to inject actions into the execution of an enumeration while transforming it.

7 | /// This example shows how to apply the when patching a function.
8 | /// Of course you typically wouldn't patch with a generic method, that's just for illustrating the Type usage. 9 | /// 10 | /// private static void Postfix<Original, Transformed>(ref IEnumerable<Original> __result) where Transformed : Original 11 | /// { 12 | /// __result = new EnumerableInjector<Original, Transformed>(__result, 13 | /// item => { Msg("Change what the item is exactly"); return new Transformed(item); }) 14 | /// { 15 | /// Prefix = () => Msg("Before the first item is returned"), 16 | /// PreItem = item => { Msg("Decide if an item gets returned"); return true; }, 17 | /// PostItem = (original, transformed, returned) => Msg("After control would come back to the generator after a yield return"), 18 | /// Postfix = () => Msg("When the generator stopped returning items") 19 | /// }; 20 | /// } 21 | /// 22 | ///
23 | /// The type of the original enumeration's items. 24 | /// The type of the transformed enumeration's items.
Must be assignable to TOriginal for compatibility.
25 | public class EnumerableInjector : IEnumerable 26 | where TTransformed : TOriginal { 27 | /// 28 | /// Internal enumerator for iteration. 29 | /// 30 | private readonly IEnumerator enumerator; 31 | 32 | private Action postfix = () => { }; 33 | private Action postItem = (original, transformed, returned) => { }; 34 | private Action prefix = () => { }; 35 | private Func preItem = item => true; 36 | private Func transformItem = item => throw new NotImplementedException("You're supposed to insert your own transformation function here!"); 37 | 38 | /// 39 | /// Gets called when the wrapped enumeration returned the last item. 40 | /// 41 | public Action Postfix { 42 | get => postfix; 43 | set => postfix = value ?? throw new ArgumentNullException(nameof(value), "Postfix can't be null!"); 44 | } 45 | 46 | /// 47 | /// Gets called for each item, with the transformed item, and whether it was passed through. 48 | /// First thing to be called after execution returns to the enumerator after a yield return. 49 | /// 50 | public Action PostItem { 51 | get => postItem; 52 | set => postItem = value ?? throw new ArgumentNullException(nameof(value), "PostItem can't be null!"); 53 | } 54 | 55 | /// 56 | /// Gets called before the enumeration returns the first item. 57 | /// 58 | public Action Prefix { 59 | get => prefix; 60 | set => prefix = value ?? throw new ArgumentNullException(nameof(value), "Prefix can't be null!"); 61 | } 62 | 63 | /// 64 | /// Gets called for each item to determine whether it should be passed through. 65 | /// 66 | public Func PreItem { 67 | get => preItem; 68 | set => preItem = value ?? throw new ArgumentNullException(nameof(value), "PreItem can't be null!"); 69 | } 70 | 71 | /// 72 | /// Gets called for each item to transform it, even if it won't be passed through. 73 | /// 74 | public Func TransformItem { 75 | get => transformItem; 76 | set => transformItem = value ?? throw new ArgumentNullException(nameof(value), "TransformItem can't be null!"); 77 | } 78 | 79 | /// 80 | /// Creates a new instance of the class using the supplied input and transform function. 81 | /// 82 | /// The enumerable to inject into and transform. 83 | /// The transformation function. 84 | public EnumerableInjector(IEnumerable enumerable, Func transformItem) 85 | : this(enumerable.GetEnumerator(), transformItem) { } 86 | 87 | /// 88 | /// Creates a new instance of the class using the supplied input and transform function. 89 | /// 90 | /// The enumerator to inject into and transform. 91 | /// The transformation function. 92 | public EnumerableInjector(IEnumerator enumerator, Func transformItem) { 93 | this.enumerator = enumerator; 94 | TransformItem = transformItem; 95 | } 96 | 97 | /// 98 | /// Injects into and transforms the input enumeration. 99 | /// 100 | /// The injected and transformed enumeration. 101 | public IEnumerator GetEnumerator() { 102 | Prefix(); 103 | 104 | while (enumerator.MoveNext()) { 105 | var item = enumerator.Current; 106 | var returnItem = PreItem(item); 107 | var transformedItem = TransformItem(item); 108 | 109 | if (returnItem) 110 | yield return transformedItem; 111 | 112 | PostItem(item, transformedItem, returnItem); 113 | } 114 | 115 | Postfix(); 116 | } 117 | 118 | /// 119 | /// Injects into and transforms the input enumeration without a generic type. 120 | /// 121 | /// The injected and transformed enumeration without a generic type. 122 | IEnumerator IEnumerable.GetEnumerator() { 123 | return GetEnumerator(); 124 | } 125 | } 126 | 127 | /// 128 | /// Provides the ability to inject actions into the execution of an enumeration without transforming it.

129 | /// This example shows how to apply the when patching a function.
130 | /// Of course you typically wouldn't patch with a generic method, that's just for illustrating the Type usage. 131 | /// 132 | /// static void Postfix<T>(ref IEnumerable<T> __result) 133 | /// { 134 | /// __result = new EnumerableInjector<T>(__result) 135 | /// { 136 | /// Prefix = () => Msg("Before the first item is returned"), 137 | /// PreItem = item => { Msg("Decide if an item gets returned"); return true; }, 138 | /// TransformItem = item => { Msg("Change what the item is exactly"); return item; }, 139 | /// PostItem = (original, transformed, returned) => Msg("After control would come back to the generator after a yield return"), 140 | /// Postfix = () => Msg("When the generator stopped returning items") 141 | /// }; 142 | /// } 143 | /// 144 | ///
145 | /// The type of the enumeration's items. 146 | public class EnumerableInjector : EnumerableInjector { 147 | /// 148 | /// Creates a new instance of the class using the supplied input . 149 | /// 150 | /// The enumerable to inject into. 151 | public EnumerableInjector(IEnumerable enumerable) 152 | : this(enumerable.GetEnumerator()) { } 153 | 154 | /// 155 | /// Creates a new instance of the class using the supplied input . 156 | /// 157 | /// The enumerator to inject into. 158 | public EnumerableInjector(IEnumerator enumerator) 159 | : base(enumerator, item => item) { } 160 | } 161 | -------------------------------------------------------------------------------- /doc/config.md: -------------------------------------------------------------------------------- 1 | # ResoniteModLoader Configuration System 2 | 3 | ResoniteModLoader provides a built-in configuration system that can be used to persist configuration values for mods. 4 | Operations provided: 5 | 6 | - Reading value of a config key 7 | - Writing value to a config key 8 | - Enumerating config keys for a mod 9 | - Enumerating mods 10 | - Saving a config to disk 11 | 12 | Behind the scenes, configs are saved to a `rml_config` folder in the Resonite install directory. The `rml_config` folder contains JSON files, named after each mod dll that defines a config. End users and mod developers do not need to interact with this JSON directly. Mod developers should use the API exposed by ResoniteModLoader. End users should use interfaces exposed by configuration management mods. 13 | 14 | ## Overview 15 | 16 | - Mods may define a configuration 17 | - Configuration items must be declared alongside the mod itself. You cannot change your configuration schema at runtime. 18 | - Configuration items may be of any type, however, there are considerations: 19 | - Json.NET is used to serialize the configuration, so the type must be JSON-compatible (e.g. no circular references). Lists, Sets, and Dictionary will work fine. 20 | - Using complex types will make it more difficult for configuration manager UIs to interface with your mod. For best compatibility keep things simple (primitive types and basic collections) 21 | - Reading/writing configuration values is done in-memory and is extremely cheap. 22 | - Saving configuration to disk is more expensive but is done infrequently 23 | 24 | ## Working With Your Mod's Configuration 25 | 26 | A simple example is below: 27 | 28 | ```csharp 29 | using HarmonyLib; // HarmonyLib comes included with a ResoniteModLoader install 30 | using ResoniteModLoader; 31 | using System; 32 | using System.Reflection; 33 | 34 | namespace ConfigurationExampleMod; 35 | 36 | public class ConfigurationExampleMod : ResoniteMod { 37 | public override string Name => "ConfigurationExampleMod"; 38 | public override string Author => "YourNameHere"; 39 | public override string Version => "1.0.0"; //Version of the mod, should match the AssemblyVersion 40 | public override string Link => "https://github.com/YourNameHere/ConfigurationExampleMod"; // Optional link to a repo where this mod would be located 41 | 42 | [AutoRegisterConfigKey] 43 | private static readonly ModConfigurationKey KEY_COUNT = new ModConfigurationKey("count", "Example counter", internalAccessOnly: true); //Mod config for an int 44 | 45 | private static ModConfiguration Config; //This holds your mods' ModConfiguration 46 | 47 | public override void OnEngineInit() { 48 | Config = GetConfiguration(); //Get this mods' current ModConfiguration 49 | int countValue = default(int); 50 | if (Config.TryGetValue(KEY_COUNT, out countValue)) { 51 | int oldValue = countValue++; 52 | Msg($"Incrementing count from {oldValue} to {countValue}"); 53 | } else { 54 | Msg($"Initializing count to {countValue}"); 55 | } 56 | 57 | Config.Set(KEY_COUNT, countValue); 58 | } 59 | } 60 | ``` 61 | 62 | ### Defining a Configuration 63 | 64 | To define a configuration simply have at least one `ModConfigurationKey` field with the `[AutoRegisterConfigKey]` attribute applied. 65 | 66 | If you need more options, implement the optional `DefineConfiguration` method in your mod. Here's an example: 67 | 68 | ```csharp 69 | // this override lets us change optional settings in our configuration definition 70 | public override void DefineConfiguration(ModConfigurationDefinitionBuilder builder) { 71 | builder 72 | .Version(new Version(1, 0, 0)) // manually set config version (default is 1.0.0) 73 | .AutoSave(false); // don't autosave on shutdown (default is true) 74 | } 75 | ``` 76 | 77 | This `ModConfigurationDefinitionBuilder` allows you to change the default version and autosave values. [Version](#configuration-version) and [AutoSave](#saving-the-configuration) will be discussed in separate sections.. 78 | 79 | #### Configuration Version 80 | 81 | You may optionally specify a version for your configuration. This is separate from your mod's version. By default, the version will be 1.0.0. The version should be a [semantic version][semver]—in summary the major version should be bumped for hard breaking changes, and the minor version should be bumped if you break backwards compatibility. ResoniteModLoader uses this version number to check the saved configuration against your definition and ensure they are compatible. 82 | 83 | #### Configuration Keys 84 | 85 | Configuration keys define the values your mod's config can store. The relevant class is `ModConfigurationKey`, which has the following constructor: 86 | 87 | ```csharp 88 | public ModConfigurationKey(string name, string description, Func computeDefault = null, bool internalAccessOnly = false, Predicate valueValidator = null) 89 | ``` 90 | 91 | |Parameter | Description | Default | 92 | | -------- | ----------- | ------- | 93 | | name | Unique name of this config item | *required* | 94 | | description | Human-readable description of this config item | *required* | 95 | | computeDefault | Function that, if present, computes a default value for this key | `null` | 96 | | internalAccessOnly | If true, only the owning mod should have access to this config item. Note that this is *not* enforced by ResoniteModLoader itself. | `false` | 97 | | valueValidator | A custom function that (if present) checks if a value is valid for this configuration item | `null` | 98 | 99 | ### Saving the Configuration 100 | 101 | Configurations should be saved to disk by calling the `ModConfiguration.Save()` method. If you don't call `ModConfiguration.Save()`, your changes will still be available in memory. This allows multiple changes to be batched before you write them all to disk at once. Saving to disk is a relatively expensive operation and should not be performed at high frequency. 102 | 103 | ResoniteModLoader will automatically call `Save()` for you when the game is shutting down. This will not occur if Resonite crashes, so to avoid data loss you should manually call `Save()` when appropriate. If you'd like to opt out of this autosave-on-shutdown functionality, use the `ModConfigurationDefinitionBuilder` discussed in the [Defining a Configuration](#defining-a-configuration) section. 104 | 105 | ### Getting the Configuration 106 | 107 | To get the configuration, call `ResoniteModBase.GetConfiguration()`. Some notes: 108 | 109 | - This will return `null` if the mod does not have a configuration. 110 | - You must not call `ResoniteModBase.GetConfiguration()` before OnEngineInit() is called, as the mod may still be initializing. 111 | - The returned `ModConfiguration` instance is guaranteed to be the same reference for all calls to `ResoniteModBase.GetConfiguration()`. Therefore, it is safe to save a reference to your `ModConfiguration`. 112 | - Other mods may modify the `ModConfiguration` instance you are working with. 113 | - A `ModConfiguration.TryGetValue()` call will always return the current value for that config item. If you need notice that someone else has changed one of your configs, there are events you can subscribe to. However, the `ModConfiguration.GetValue()` and `TryGetValue()` API is very inexpensive so it is fine to poll. 114 | 115 | ### Events 116 | 117 | The `ModConfiguration` class provides two events you can subscribe to: 118 | 119 | - The static event `OnAnyConfigurationChanged` is called if any config value for any mod changed. 120 | - The instance event `OnThisConfigurationChanged` is called if one of the values in this mod's config changed. 121 | 122 | Both of these events use the following delegate: 123 | 124 | ```csharp 125 | public delegate void ConfigurationChangedHandler(ConfigurationChangedEvent configurationChangedEvent); 126 | ``` 127 | 128 | A `ConfigurationChangedEvent` has the following properties: 129 | 130 | - `ModConfiguration Config` is the configuration the change occurred in 131 | - `ModConfigurationKey Key` is the specific key who's value changed 132 | - `string Label` is a custom label that may be set by whoever changed the configuration. This may be `null`. 133 | 134 | To subscribe to either of these events, add 135 | ```csharp 136 | public override void OnEngineInit() { 137 | Config = GetConfiguration(); 138 | 139 | ModConfiguration.OnAnyConfigurationChanged += OnConfigurationChanged; //Subscribe to any mod configuration changing 140 | Config.OnThisConfigurationChanged += OnThisConfigurationChanged; //Subscribe to when any key in this mod has changed 141 | } 142 | ``` 143 | 144 | For individual `ModConfigurationKey`s there is also an `OnChanged` Event for when that specific key has changed 145 | ```csharp 146 | ExampleConfigKey.OnChanged += (value) => { Msg($"Key set to {value}"); } 147 | ``` 148 | ### Handling Incompatible Configuration Versions 149 | 150 | You may optionally override a `HandleIncompatibleConfigurationVersions()` function in your ResoniteMod to define how incompatible versions are handled. You have two options: 151 | 152 | - `IncompatibleConfigurationHandlingOption.ERROR`: Fail to read the config, and block saving over the config on disk. 153 | - `IncompatibleConfigurationHandlingOption.CLOBBER`: Destroy the saved config and start over from scratch. 154 | - `IncompatibleConfigurationHandlingOption.FORCELOAD`: Ignore the version number and load the config anyways. This may throw exceptions and break your mod. 155 | 156 | If you do not override `HandleIncompatibleConfigurationVersions()`, the default is to return `ERROR` on all incompatibilities. `HandleIncompatibleConfigurationVersions()` is only called for configs that are detected to be incompatible under [semantic versioning][semver]. 157 | 158 | Here's an example implementation that can detect mod downgrades and conditionally avoid clobbering your new config: 159 | 160 | ```csharp 161 | public override IncompatibleConfigurationHandlingOption HandleIncompatibleConfigurationVersions(Version serializedVersion, Version definedVersion) { 162 | if (serializedVersion > definedVersion) { 163 | // someone has dared to downgrade my mod 164 | // this will break the old version instead of nuking my config 165 | return IncompatibleConfigurationHandlingOption.ERROR; 166 | } else { 167 | // there's an old incompatible config version on disk 168 | // lets just nuke it instead of breaking 169 | return IncompatibleConfigurationHandlingOption.CLOBBER; 170 | } 171 | } 172 | ``` 173 | 174 | ### Breaking Changes in Configuration Definition 175 | 176 | There are two cases to consider: 177 | 178 | - **Forwards Compatible**: Can mod v2 load config v1? 179 | - **Backwards Compatible**: Can mod v1 load config v2? 180 | 181 | | Action | Forwards Compatible | Backwards Compatible | 182 | | ------ | ------------------- | ---------------------| 183 | | Adding a brand-new key | Yes | Yes | 184 | | Removing an existing key | Yes | Yes | 185 | | Adding, altering, or removing a key's default value | Yes | Maybe* | 186 | | Restricting a key's validator | Yes** | Yes | 187 | | Relaxing a key's validator | Yes | Maybe* | 188 | | Changing `internalAccessOnly` to `false` | Yes | Maybe* | 189 | | Changing `internalAccessOnly` to `true` | Yes** |Yes | 190 | | Altering a key's type (removing and re-adding later counts!) | **No** | **No** | 191 | 192 | \* ResoniteModLoader is compatible, but the old version of your mod's code may not be 193 | \*\* Assuming the new version of your mod properly accounts for reading old configs 194 | 195 | ## Working With Other Mods' Configurations 196 | 197 | An example of enumerating all configs: 198 | 199 | ```csharp 200 | void EnumerateConfigs() { 201 | IEnumerable mods = ModLoader.Mods(); 202 | foreach (ResoniteModBase mod in mods) { 203 | ModConfiguration config = mod.GetConfiguration(); 204 | if (config != null) { 205 | foreach (ModConfigurationKey key in config.ConfigurationItemDefinitions) { 206 | // while we COULD read internal configs, we shouldn't. 207 | if (!key.InternalAccessOnly) { 208 | if (config.TryGetValue(key, out object value)) { 209 | Msg($"{mod.Name} has configuration {key.Name} with type {key.ValueType()} and value {value}"); 210 | } else { 211 | Msg($"{mod.Name} has configuration {key.Name} with type {key.ValueType()} and no value"); 212 | } 213 | } 214 | } 215 | } 216 | } 217 | } 218 | ``` 219 | 220 | Worth noting here is that this API works with raw untyped objects, because as an external mod you lack the compile-time type information. The API performs its own type checking behind the scenes to prevent incorrect types from being written. 221 | 222 | [semver]: https://semver.org/ 223 | -------------------------------------------------------------------------------- /doc/directories.md: -------------------------------------------------------------------------------- 1 | # Resonite Directories 2 | 3 | If you've installed to a non-default location then finding the path is up to you. 4 | 5 | | Directory | Description | 6 | | --------- |------------ | 7 | | Resonite Install Directory | Contains the game install itself, the `Logs` directory, and the `Libraries` directory | 8 | | Log Directory | A `Logs` directory within the Resonite Install Directory. Contains the main game logs. | 9 | | Libraries Directory | A `Libraries` directory within the Resonite Install Directory. Plugins dlls go here. 10 | | Data Directory | Contains the local db, Unity's player.log, and local assets directory. Location can be changed with `-DataPath ` argument. | 11 | | Temporary Directory | Contains crash logs and the cache | 12 | | Cache Directory | Contains cached remote assets. Located inside the Temporary Directory by default. Location can be changed with `-CachePath ` argument. | 13 | 14 | ## Windows 15 | 16 | | Description | Typical Path | 17 | | ----------- | ------------ | 18 | | Resonite Install Directory (Steam) | `C:\Program Files (x86)\Steam\steamapps\common\Resonite` | 19 | | Data Directory | `%userprofile%\AppData\LocalLow\Yellow Dog Man Studios\Resonite` | 20 | | Temporary Directory | `%temp%\Yellow Dog Man Studios\Resonite` | 21 | | Cache Directory | `%temp%\Yellow Dog Man Studios\Resonite\Cache` | 22 | 23 | ## Linux Native- 24 | 25 | | Description | Typical Path | 26 | | ----------- | ------------ | 27 | | Resonite Install Directory (Steam) | `$HOME/Steam/steamapps/common/Resonite` | 28 | | Resonite Install Directory (Steam) Alt | `$HOME/.local/share/Steam/steamapps/common/Resonite` | 29 | | Data Directory | `$HOME/.config/unity3d/Yellow Dog Man Studios/Resonite` | 30 | | Temporary Directory | `/tmp/Yellow Dog Man Studios/Resonite` | 31 | | Cache Directory | `/tmp/Yellow Dog Man Studios/Resonite/Cache` | 32 | 33 | ## Linux Proton/WINE 34 | 35 | | Description | Typical Path | 36 | | ----------- | ------------ | 37 | | Resonite Install Directory (Steam) | `$HOME/Steam/steamapps/common/Resonite` | 38 | | Resonite Install Directory (Steam) Alt | `$HOME/.local/share/Steam/steamapps/common/Resonite` | 39 | | Data Directory | `$HOME/.local/share/Steam/steamapps/compatdata/2519830/pfx/drive_c/users/steamuser/AppData/LocalLow/Yellow Dog Man Studios/Resonite` | 40 | | Temporary Directory | `$HOME/.local/share/Steam/steamapps/compatdata/2519830/pfx/drive_c/users/steamuser/Temp/Yellow Dog Man Studios/Resonite` | 41 | | Cache Directory | `$HOME/.local/share/Steam/steamapps/compatdata/2519830/pfx/drive_c/users/steamuser/Temp/Yellow Dog Man Studios/Resonite/Cache` | 42 | 43 | ## Drive Notes 44 | 45 | - The actual Resonite install should be less than 1GB, but the log files can in certain cases be very large. 46 | - The cache can get very large, upwards of 30GB so make sure the drive you save cache to has plenty of space. Resonite will benefit by having this on a faster drive such as a SSD. The cache directory can be deleted whenever you need without breaking Resonite. The cache directory can be changed with the `-CachePath ` launch option. 47 | - The data directory contains your localDB as well as locally saved assets. This can get to be around 10GB, or more if you store a lot in your local home. The data directory can be changed with the `-DataPath ` launch option. Deleting this will: 48 | - Reset any non-cloud-synced Resonite settings. This will, for example, send you back to the tutorial (unless you use `-SkipIntroTutorial`) 49 | - Reset your local home and nuke anything that was stored in it. 50 | - Regenerate your machine ID 51 | -------------------------------------------------------------------------------- /doc/example_log.log: -------------------------------------------------------------------------------- 1 | 2:12:17 AM.319 ( -1 FPS) Detecting output device 2 | 2:12:17 AM.457 ( -1 FPS) Creating FrooxEngineRunner 3 | 2:12:17 AM.633 ( -1 FPS) Initializing Engine Runner 4 | 2:12:17 AM.633 ( -1 FPS) Microphone permission authorized 5 | 2:12:17 AM.633 ( -1 FPS) External storage read permission authorized 6 | 2:12:17 AM.633 ( -1 FPS) External storage write permission authorized 7 | 2:12:17 AM.634 ( -1 FPS) DeviceName: Screen 8 | 2:12:22 AM.734 ( -1 FPS) AppPath: R:\SteamLibrary\steamapps\common\Resonite 9 | DataPath: U:\Resonite 10 | CachePath: U:\Resonite 11 | 2:12:22 AM.768 ( -1 FPS) Initializing App: Beta 2023.9.26.304 12 | Platform: Windows 13 | HeadDevice: Screen 14 | IsAOT: False 15 | OS: Windows 10 (10.0.19045) 64bit 16 | CPU: Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz 17 | GPU: NVIDIA GeForce RTX 3080 18 | PhysicalCores: 19 | MemoryBytes: 63.93 GB 20 | VRAMBytes: 11.82 GB 21 | MaxTextureSize: 16384 22 | IsGPUTexturePOTByteAligned: True 23 | UsingLinearSpace: True 24 | XR Device Name: 25 | XR Device Model: 26 | StereoRenderingMode: MultiPass 27 | Max GC Generation: 0, IsLittleEndian: True 28 | System.Numerics.Vectors HW accelerated: False, Vector.Count: 4 29 | Brotli native encoding/decoding supported: True 30 | 2:12:22 AM.769 ( -1 FPS) Supported Texture Formats: Alpha8, RGB24, ARGB32, RGBA32, BGRA32, RGBAHalf, RGBAFloat, BC1, BC3, BC4, BC5, BC6H, BC7 31 | 2:12:22 AM.770 ( -1 FPS) Processing startup commands... 32 | 2:12:22 AM.860 ( -1 FPS) Scanning locales... 33 | 2:12:22 AM.862 ( -1 FPS) Available locales: cs, de, en, en-gb, eo, es, et, fi, fr, is, ja, ko, nl, no, pl, qps-ploc, ru, sv, tr, zh-cn, zh-tw 34 | 2:12:22 AM.869 ( -1 FPS) Loading Config.json... 35 | 2:12:22 AM.869 ( -1 FPS) Computing compatibility hash... 36 | 2:12:22 AM.872 ( -1 FPS) Loaded Extra Assembly: Libraries/ResoniteModLoader.dll 37 | 2:12:22 AM.874 ( -1 FPS) Compatibility Hash: pkJrcwZuU9k9oRlXT/DANw== 38 | 2:12:22 AM.874 ( -1 FPS) Initializing FrooxEngine... 39 | 2:12:23 AM.933 ( -1 FPS) FreeImage Version: 3.18.0 40 | 2:12:23 AM.933 ( -1 FPS) BepuPhysics Version: 2.4.0-frooxengine 41 | 2:12:23 AM.942 ( -1 FPS) FreeType Version: 2.10.4 42 | 2:12:23 AM.942 ( -1 FPS) Opus Version: libopus 1.3.1-138-g07376903 43 | 2:12:23 AM.967 ( -1 FPS) Supported 3D model formats: meshx, 3d, 3ds, 3mf, a3d, ac, ac3d, acc, amf, ase, ask, assbin, b3d, blend, bsp, bvh, cob, csm, dae, dxf, enff, fbx, glb, gltf, hmp, ifc, ifczip, irr, irrmesh, lwo, lws, lxo, m3d, md2, md3, md5anim, md5camera, md5mesh, mdc, mdl, mesh, mesh.xml, mot, ms3d, ndo, nff, obj, off, ogex, pk3, ply, pmx, prj, q3o, q3s, raw, scn, sib, smd, step, stl, stp, ter, uc, vta, x, xgl, xml, zae, zgl 44 | 2:12:23 AM.967 ( -1 FPS) Supported point cloud formats: pts, las, laz 45 | 2:12:23 AM.967 ( -1 FPS) Supported image formats: bmp, ico, jpg, jif, jpeg, jpe, jng, koa, iff, lbm, mng, pbm, pcd, pcx, pgm, png, ppm, ras, tga, targa, tif, tiff, wap, wbmp, wbm, psd, psb, cut, xbm, xpm, dds, gif, hdr, g3, sgi, rgb, rgba, bw, exr, j2k, j2c, jp2, pfm, pct, pict, pic, 3fr, arw, bay, bmq, cap, cine, cr2, crw, cs1, dc2, dcr, drf, dsc, dng, erf, fff, ia, iiq, k25, kc2, kdc, mdc, mef, mos, mrw, nef, nrw, orf, pef, ptx, pxn, qtk, raf, raw, rdc, rw2, rwl, rwz, sr2, srf, srw, sti, x3f, webp, jxr, wdp, hdp 46 | 2:12:23 AM.967 ( -1 FPS) Supported audio formats: wav, wave, flac, fla, ogg, aiff, aif, aifc 47 | 2:12:23 AM.967 ( -1 FPS) Supported video formats: mp4, mpeg, avi, mov, mpg, mkv, flv, webm, mts, 3gp, bik, m2v, m2s, wmv, m3u8, m3u, pls, m4a, mp3, mpeg3, aac, ac3, aif, aiff, ape, au, it, mka, mod, mp1, mp2, opus, s3m, sid, w64, wma, xm, nsf, nsfe, gbs, vgm, vgz, spc, gym 48 | 2:12:23 AM.967 ( -1 FPS) Supported font formats: ttf, otf, ttc, otc, woff 49 | 2:12:23 AM.969 ( -1 FPS) Supported subtitle formats: srt, sub, sub, ssa, ttml, vtt 50 | 2:12:25 AM.746 ( -1 FPS) [INFO] [ResoniteModLoader] Loading assemblies from rml_libs 51 | 2:12:25 AM.777 ( -1 FPS) [INFO] [ResoniteModLoader] Loaded libraries from rml_libs: 52 | 0Harmony, Version=2.2.2.0, Culture=neutral, PublicKeyToken=null Sha256=6928F117D52C0683C44683C0656CD5A345D21AA45720B11C64DC72A35771CAC5 53 | 2:12:25 AM.778 ( -1 FPS) [INFO] [ResoniteModLoader] ResoniteModLoader v2.4.0 starting up! 54 | 2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] CLR v4.0.30319.42000 55 | 2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] Using Harmony v2.2.2.0 56 | 2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] Using Elements.Core v1.0.0.0 57 | 2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] Using FrooxEngine v2023.9.26.304 58 | 2:12:25 AM.779 ( -1 FPS) [INFO] [ResoniteModLoader] Using Json.NET v13.0.0.0 59 | 2:12:26 AM.043 ( -1 FPS) [INFO] [ResoniteModLoader] Compatibility hash spoofing succeeded 60 | 2:12:26 AM.044 ( -1 FPS) [INFO] [ResoniteModLoader] Loading assemblies from rml_mods -------------------------------------------------------------------------------- /doc/faq.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## Something is broken! Where can I get help? 4 | 5 | Please take a look at our [troubleshooting page](troubleshooting.md). 6 | 7 | ## Do you have a Discord server? 8 | 9 | Yes. [Here it is.][Resonite Modding Discord] 10 | 11 | ## What is a mod? 12 | 13 | Mods are .dll files loaded by ResoniteModLoader that change the behavior of your Resonite client in some way. Unlike plugins, mods are specifically designed to work in multiplayer. 14 | 15 | ## What does ResoniteModLoader do? 16 | 17 | ResoniteModLoader is a Resonite Plugin that does a lot of the boilerplate necessary to get mods working in a reasonable way. In summary, it: 18 | 19 | 1. Initializes earlier than a normal plugin 20 | 2. Ensures that Resonite's compatibility check doesn't prevent you from joining other players. For safety reasons this will only work if ResoniteModLoader is the only plugin. 21 | 3. Loads mod .dll files and calls their `OnEngineInit()` function so the mods can begin executing 22 | 23 | ## Is using ResoniteModLoader allowed? 24 | 25 | Yes, so long as Resonite's Guidelines are followed. 26 | 27 | ## Will people know I'm using mods? 28 | 29 | - By default, ResoniteModLoader does not do anything identifiable over the network. You will appear to be running the vanilla Resonite version to any component that shows your version strings or compatibility hash. 30 | - If you are running other plugins, they will alter your version strings and compatibility hash. 31 | - ResoniteModLoader logs to the same log file Resonite uses. If you send your logs to anyone, it will be obvious that you are using a plugin. This is intended. 32 | - ResoniteModLoader mods may have effects visible to other users, depending on the mod. 33 | - If you wish to opt-in to using your real version string you can set `advertiseversion=true` in the [Modloader Config](modloader_config.md). 34 | - If ResoniteModLoader breaks due to a bad install or a Resonite update, it will be unable to hide its own existence and your real version string will be shown. 35 | 36 | ## Are mods safe? 37 | 38 | Mods are not sandboxed in any way. In other words, they run with the same level of privilege as Resonite itself. A poorly written mod could cause performance or stability issues. A maliciously designed mod could give a malicious actor a dangerous level of control over your computer. **Make sure you only use mods from sources you trust.** 39 | 40 | We'll be setting up a list of mods that have been manually audited to ensure they aren't malicious or evil. While this process isn't 100% foolproof, the mods that will be on this list are significantly more trustworthy than an unvetted DLL. 41 | 42 | If you aren't sure if you can trust a mod and you have some level of ability to read code, you can look at its source code. If the source code is unavailable or you suspect it may differ from the contents of the .dll file, you can inspect the mod with a [C# decompiler](https://www.google.com/search?q=c%23+decompiler). Things to be particularly wary of include: 43 | 44 | - Obfuscated code 45 | - Sending or receiving data over the internet 46 | - Interacting with the file system (reading, writing, or executing files from disk) 47 | 48 | ## Where does ResoniteModLoader log to? 49 | 50 | The regular Resonite logs: `C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs`. 51 | 52 | If you are experiencing issues, check our [troubleshooting page](doc/troubleshooting.md). 53 | 54 | 55 | ## Is ResoniteModLoader compatible with other mod loaders? 56 | 57 | Yes, **however** other mod loaders are likely to come with LibHarmony, and you need to ensure you only have one. Therefore you may need to remove `0Harmony.dll` from your Resonite install directory or your `rml_libs` folder. If another mod loader's LibHarmony version is significantly different from the standard Harmony 2 library, then it will not be compatible with ResoniteModLoader at all. 58 | 59 | ## Why are you using a custom mod loader for Resonite? 60 | 61 | 1. Resonite Plugins are the officially supported means to extend functionality of the game, we can expect them to continue working with relatively little change, if any, to the modloader itself even through major engine changes, for example if Resonite ever switches to a non-Unity engine. 62 | 2. Some other mod loaders may do unnecessary additional steps to load mods while we already have an intended 'entry point' from the plugin system. 63 | 3. Other mod loaders may be designed for specifically Unity games. While Resonite uses Unity, it isn't your typical Unity game. 64 | 4. It affords us some extra flexibility to change the modloader directly as we see fit instead of needing an additional layer on another modloader to change what it is doing. 65 | 66 | ## As a content creator, when is a mod the right solution? 67 | 68 | Check out our documentation describing various [Problem Solving Techniques](problem_solving_techniques.md) to determine if a mod may be the solution. 69 | 70 | ## As a mod developer, why should I use ResoniteModLoader over a Resonite Plugin? 71 | 72 | If you are just trying to make a new Component or ProtoFlux node, you should use a plugin. The plugin system is specifically designed for that and is the supported method of extending the functionality of the engine directly. 73 | 74 | If you are trying to modify Resonite's existing behavior without adding any new components, ResoniteModLoader offers the following: 75 | 76 | - [LibHarmony] is a dependency of ResoniteModLoader, so as a mod developer you don't need to worry about making sure it's installed 77 | - Resonite Plugins normally break multiplayer compatibility. The ResoniteModLoader plugin has been specifically designed to remain compatible. This feature will only work if ResoniteModLoader.dll is the *only* plugin you are using. 78 | - Resonite Plugins can normally execute when Local Home loads at the earliest. ResoniteModLoader begins executing significantly earlier, giving you more room to alter Resonite's behavior before it finishes initializing. 79 | - Steam has a relatively small character limit on launch options, and every Resonite plugin you install pushes you closer to that limit. Having more than a handful plugins will therefore prevent you from using Steam to launch the game, and mods using ResoniteModLoader are unaffected by this issue as you only need one launch option 80 | 81 | ## Can mods depend on other mods? 82 | 83 | Yes. All mod assemblies are loaded before any mod hooks are called, so no special setup is needed if your mod provides public methods. 84 | 85 | Mod hooks are called alphabetically by the mod filename, so you can purposefully alter your filename (`0_mod.dll`) to make sure your hooks run first. Please only do this after trying other avenues of resolving mod dependancies. 86 | 87 | ## Can ResoniteModLoader load Resonite plugins? 88 | 89 | No. You need to use `-LoadAssembly ` to load plugins. There is important plugin handling code that does not run for ResoniteModLoader mods. 90 | 91 | ## Are ResoniteModLoader mods plugins? 92 | 93 | No. ResoniteModLoader mods will not work if used as a Resonite plugin. 94 | 95 | 96 | [LibHarmony]: https://github.com/pardeike/Harmony 97 | [Mod & Plugin Policy]: https://resonite.com/policies/index.html 98 | [Resonite Discord]: https://discord.gg/resonite 99 | [Resonite Modding Discord]: https://discord.gg/ZMRyQ8bryN 100 | -------------------------------------------------------------------------------- /doc/guidelines.md: -------------------------------------------------------------------------------- 1 | # Modding related guidelines 2 | 3 | ## Is Modding Resonite Allowed? 4 | 5 | Yes probably, pending an official [Mod & Plugin Policy] 6 | 7 | ## Is Modding Good for Resonite? 8 | 9 | Modding is generally healthy for games as it allows users to try out features and tweaks on a deeper more integrated level before and enhance their experience before a feature could be officially integrated. This also allows players to refine feature requests and test out implementations ahead of time. 10 | 11 | ### The Pros 12 | 13 | - Quality of life improvements before they have an official implementation. 14 | - Niche tweaks that may only make sense for a small handful of users and may be unlikely to be implemented. 15 | - Hardware support for both new and obscure devices 16 | - Lets power users opt-in to 'warranty-voiding' tools or unsupported functionality. Sometimes a desired feature risks breaking in the future. But at the same time it can be very useful in the meanwhile. 17 | - The classic "if you outlaw guns only outlaws will have guns" argument. 18 | 19 | 20 | ### The Cons 21 | 22 | - Risk of abuse. The more control users have over the game the more damage they can potentially do. 23 | - Risk of breakage. Users *should* be aware that mods void the warranty, but some people might complain in the wrong channels when mods break. And if things go very wrong there's potential for a mod to break an unmodified user over the network. 24 | - Misdirection of effort. If mods create a problem the Resonite team has to step in and solve that's less time spent improving the game. 25 | 26 | ## Afterword 27 | 28 | The [EULA], [Guidelines], [Privacy Policy] and [Mod & Plugin Policy] are subject to change. This document was last updated on **2023-08-10**, and may be out of date. 29 | 30 | [EULA]: https://resonite.com/policies/EULA.html 31 | [Guidelines]: https://resonite.com/policies/Guidelines.html 32 | [Mod & Plugin Policy]: https://resonite.com/policies/index.html 33 | [Privacy Policy]: https://resonite.com/policies/PrivacyPolicy.html 34 | -------------------------------------------------------------------------------- /doc/img/steam_game_properties_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resonite-modding-group/ResoniteModLoader/b6b513b38ba8a198c163d94c5da7ce4fcf454935/doc/img/steam_game_properties_1.png -------------------------------------------------------------------------------- /doc/img/steam_game_properties_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/resonite-modding-group/ResoniteModLoader/b6b513b38ba8a198c163d94c5da7ce4fcf454935/doc/img/steam_game_properties_2.png -------------------------------------------------------------------------------- /doc/launch_options.md: -------------------------------------------------------------------------------- 1 | # Launch Options 2 | 3 | Adding to your games launch options 4 | 5 | 1. Go to Steam and find Resonite in your library 6 | 2. Right click on Resonite, and go to "Properties" 7 | ![right click properties screenshot](img/steam_game_properties_1.png) 8 | 3. At the bottom of the General tab, you will have a field where you can enter Launch Options. 9 | ![launch option field screenshot](img/steam_game_properties_2.png) 10 | 4. Enter in `-LoadAssembly Libraries/ResoniteModLoader.dll` 11 | - Surrounding the path here with quotation marks `" "` is optional if the relative path is used and doesn't contain spaces 12 | - If the mod loader is in another location you will need to use an absolute path surrounded by quotation marks 13 | 14 | If ResoniteModLoader doesn't appear to be picking up your launch arguments after following those steps, take a look at our [troubleshooting page](doc/troubleshooting.md). -------------------------------------------------------------------------------- /doc/linux.md: -------------------------------------------------------------------------------- 1 | # Linux Notes 2 | 3 | ### Note, I have not checked if these specific fixes are still required or if there are any new fixes that are needed. 4 | 5 | ResoniteModLoader works on Linux, but in addition to the [normal install steps](../README.md#installation) there are some extra steps you may need to take. 6 | 7 | The log directory on Linux may be in `$HOME/Steam/steamapps/common/Resonite/Logs` or `$HOME/.local/share/Steam/steamapps/common/Resonite/Logs` 8 | 9 | If your log contains the following or similar, you will need to set up a workaround for the issue. 10 | 11 | ```log 12 | System.IO.DirectoryNotFoundException: Could not find a part of the path "/home/myusername/Steam/steamapps/common/Resonite/Resonite_Data\Managed/FrooxEngine.dll". 13 | ``` 14 | 15 | To set up the workaround, run the following commands in your terminal: 16 | 17 | ```bash 18 | cd "$HOME/Steam/steamapps/common/Resonite" 19 | ln -s Resonite_Data/Managed 'Resonite_Data\Managed' 20 | ``` 21 | -------------------------------------------------------------------------------- /doc/making_mods.md: -------------------------------------------------------------------------------- 1 | # Mod Creation Guide 2 | 3 | If you have some level of familiarity with C#, getting started making mods should not be too difficult. 4 | 5 | ## Basic Visual Studio setup 6 | 7 | 1. Make a new .NET library against .NET version 4.7.2. 8 | 2. Add ResoniteModLoader.dll as a reference and optionally HarmonyLib (0harmony.dll) 9 | 3. Add references to Resonite libraries as needed (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Resonite_Data\Managed`) 10 | 4. Remove the reference to `System.Net.Http` if it was added automatically as it will make the compiler angry 11 | 12 | ## Decompilers 13 | 14 | You'll likely want to grab a decompiler if you don't have one already to take a look at existing code. Here are a few popular options: 15 | 16 | [DnSpyEx](https://github.com/dnSpyEx/dnSpy), 17 | [dotPeek](https://www.jetbrains.com/decompiler/), 18 | [ILSpy](https://github.com/icsharpcode/ILSpy) 19 | 20 | ## Hooks 21 | 22 | ### `OnEngineInit()` 23 | 24 | Called once per mod during FrooxEngine initialization. This is where you will set up and apply any harmony patches or setup anything your mod will need. 25 | 26 | Happens **before** `OnEngineInit()` 27 | 28 | - Load Locales 29 | - Configs 30 | - Plugin initialization 31 | 32 | Happens **after** `OnEngineInit()` 33 | 34 | - Input/Head device setup 35 | - Local DB initialization 36 | - Networking initialization 37 | - Audio initialization 38 | - SkyFrost Interface 39 | - RunPostInit 40 | - Worlds loading, including Local home and Userspace 41 | 42 | 43 | ### RunPostInit 44 | 45 | Add something to be run after init can be done with `Engine.Current.RunPostInit` added in your `OnEngineInit()`, here are 2 examples. 46 | 47 | ```csharp 48 | Engine.Current.RunPostInit(FunctionToCall); 49 | ``` 50 | OR 51 | ```csharp 52 | Engine.Current.RunPostInit(() => { 53 | //Code to call after Initialization 54 | FunctionToCall(); 55 | AnotherFunctionToCall(); 56 | }); 57 | ``` 58 | 59 | ## Mod Configuration 60 | 61 | ResoniteModLoader provides a built-in configuration system that can be used to persist configuration values for mods. More information is available in the [configuration system documentation](config.md). 62 | 63 | ## Example Mod 64 | 65 | ```csharp 66 | using HarmonyLib; // HarmonyLib comes included with a ResoniteModLoader install 67 | using ResoniteModLoader; 68 | using System; 69 | using System.Reflection; 70 | 71 | namespace ExampleMod; 72 | 73 | public class ExampleMod : ResoniteMod { 74 | public override string Name => "ExampleMod"; 75 | public override string Author => "YourNameHere"; 76 | public override string Version => "1.0.0"; //Version of the mod, should match the AssemblyVersion 77 | public override string Link => "https://github.com/YourNameHere/ExampleMod"; // Optional link to a repo where this mod would be located 78 | 79 | [AutoRegisterConfigKey] 80 | private static readonly ModConfigurationKey enabled = new ModConfigurationKey("enabled", "Should the mod be enabled", () => true); //Optional config settings 81 | 82 | private static ModConfiguration Config;//If you use config settings, this will be where you interface with them 83 | 84 | public override void OnEngineInit() { 85 | Config = GetConfiguration(); //Get this mods' current ModConfiguration 86 | Config.Save(true); //If you'd like to save the default config values to file 87 | Harmony harmony = new Harmony("com.example.ExampleMod"); //typically a reverse domain name is used here (https://en.wikipedia.org/wiki/Reverse_domain_name_notation) 88 | harmony.PatchAll(); // do whatever LibHarmony patching you need, this will patch all [HarmonyPatch()] instances 89 | 90 | //Various log methods provided by the mod loader, below is an example of how they will look 91 | //3:14:42 AM.069 ( -1 FPS) [INFO] [ResoniteModLoader/ExampleMod] a regular log 92 | Debug("a debug log"); 93 | Msg("a regular log"); 94 | Warn("a warn log"); 95 | Error("an error log"); 96 | } 97 | 98 | //Example of how a HarmonyPatch can be formatted, Note that the following isn't a real patch and will not compile. 99 | [HarmonyPatch(typeof(ClassName), "MethodName")] 100 | class ClassName_MethodName_Patch { 101 | //Postfix() here will be automatically applied as a PostFix Patch 102 | static void Postfix(ClassName __instance) { 103 | if(!Config.GetValue(enabled)) {//Use Config.GetValue() to use the ModConfigurationKey defined earlier 104 | return; //In this example if the mod is not enabled, we'll just return before doing anything 105 | } 106 | //Do stuff after everything in the original MethodName has run. 107 | } 108 | } 109 | } 110 | 111 | ``` 112 | 113 | ## Additional Resources 114 | 115 | - [Quick C# Refresher](https://learnxinyminutes.com/docs/csharp/) 116 | - [LibHarmony Documentation](https://harmony.pardeike.net/) 117 | - [Unity API Documentation](https://docs.unity3d.com/ScriptReference/index.html) 118 | -------------------------------------------------------------------------------- /doc/modloader_config.md: -------------------------------------------------------------------------------- 1 | # Modloader Configuration 2 | 3 | ResoniteModLoader aims to have a reasonable default configuration, but certain things can be adjusted via an optional config file. The config file isn't generated automatically, but you can create it manually by adding a `ResoniteModLoader.config` file in the same directory as `ResoniteModLoader.dll`. `ResoniteModLoader.config` is a simple text file that supports keys and values in the following format: 4 | 5 | ```ini 6 | debug=true 7 | nomods=false 8 | ``` 9 | 10 | All config keys are optional to include, missing keys will use the defaults outlined below: 11 | 12 | | Configuration | Default | Description | 13 | | ------------------ | ------- | ----------- | 14 | | `debug` | `false` | If `true`, ResoniteMod.Debug() logs will appear in your log file. Otherwise, they are hidden. | 15 | | `hidevisuals` | `false` | If `true`, RML won't show the LoadProgressIndicator on the splash screen. | 16 | | `nomods` | `false` | If `true`, mods will not be loaded from `rml_mods`. | 17 | | `advertiseversion` | `false` | If `false`, your version will be spoofed and will resemble `2024.4.25.1389`. If `true`, your version will be left unaltered and will resemble `2024.4.25.1389+ResoniteModLoader.dll`. This version string is visible to other players under certain circumstances. | 18 | | `unsafe` | `false` | If `true`, the version spoofing safety check is disabled and it will still work even if you have other Resonite plugins. DO NOT load plugin components in multiplayer sessions, as it will break things and cause crashes. Plugin components should only be used in your local home or userspace. | 19 | | `logconflicts` | `true` | If `false`, conflict logging will be disabled. If `true`, potential mod conflicts will be logged. If `debug` is also `true` this will be more verbose. | 20 | | `hidemodtypes` | `true` | If `true`, mod-related types will be hidden in-game. If `false`, no types will be hidden, which makes RML detectable in-game. | 21 | | `hidelatetypes` | `true` | If `true` and `hidemodtypes` is `true`, late loaded types will be hidden in-game. If `false`, late loaded types will be shown | -------------------------------------------------------------------------------- /doc/problem_solving_techniques.md: -------------------------------------------------------------------------------- 1 | # Problem Solving Techniques 2 | 3 | Resonite has many different ways to solve problems including Components, ProtoFlux, HTTP connections to an external server, refhacking, plugins, and mods. Some of these methods are supported, while others are not. We'll do a quick rundown of the methods and where they're applicable. 4 | 5 | ## Components and ProtoFlux 6 | 7 | Using Components and/or ProtoFlux to address problems is generally the preferred approach, as more advanced techniques are likely to be overkill. 8 | 9 | ## HTTP Connections 10 | 11 | Resonite provides ProtoFlux nodes to communicate with an external server via HTTP GET, POST, and websockets. This is great for: 12 | 13 | - Heavy data processing that ProtoFlux isn't well suited for 14 | - Advanced data persistence (for simple things, consider using Cloud Variables) 15 | - Connecting ProtoFlux across multiple sessions 16 | 17 | ## Plugins 18 | 19 | Plugins allow you to add new components and ProtoFlux nodes, but at the cost of breaking multiplayer compatibility. If you like multiplayer, they aren't going to give you much quality-of-life because they require everyone in a session to use the exact same set of plugins. Plugins are great for automating menial tasks that you do very infrequently, for example monopacking is nightmarish to do by hand but can be done with a single button via a plugin. 20 | 21 | ## Mods 22 | 23 | Mods do **not** let you add new components and ProtoFlux nodes, but they do work in multiplayer. They are limited in what they can do without breaking multiplayer compatibility. You can imagine them as a "controlled desync". They are well-suited for minor quality-of-life tweaks, for example preventing your client from rendering motion blur. Making a larger feature with a mod isn't a great option, as you cannot rely on other clients also having the mod. 24 | 25 | ## RefHacking 26 | 27 | RefHacking is a method that at considerable performance cost can get you an extremely sketchy and fragile but working component access. Refhacking is not supported and will break in the future, but sometimes it is the only way to do certain things without waiting for real component access. 28 | 29 | Consider keeping your component access ideas as a to-do list and create them when there is proper support in place. This approach avoids potential disruptions caused by the breakdown of your creations. -------------------------------------------------------------------------------- /doc/start_resonite.bat: -------------------------------------------------------------------------------- 1 | @start Resonite.exe -LoadAssembly "Libraries/ResoniteModLoader.dll" 2 | -------------------------------------------------------------------------------- /doc/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting ResoniteModLoader 2 | 3 | Below we will go over some common problems and their solutions. 4 | 5 | ## ResoniteModLoader Isn't Being Loaded 6 | 7 | **Symptoms:** 8 | 9 | - After starting the game nothing has changed, and it appears completely unmodified. 10 | - Logs don't say anything about "ResoniteModLoader" 11 | 12 | **Fix:** 13 | 14 | If the problem is the `-LoadAssembly` setup: 15 | 16 | 1. Check the logs (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs`). If you search the log for "ResoniteModLoader" you should find a section that looks like this: 17 | 18 | ```log 19 | 2:12:22 AM.869 ( -1 FPS) Computing compatibility hash... 20 | 2:12:22 AM.872 ( -1 FPS) Loaded Extra Assembly: Libraries/ResoniteModLoader.dll 21 | 2:12:22 AM.874 ( -1 FPS) Compatibility Hash: pkJrcwZuU9k9oRlXT/DANw 22 | ``` 23 | 24 | If those logs are absent it indicates you are not passing the `-LoadAssembly Libraries/ResoniteModLoader.dll` argument to Resonite correctly. 25 | 2. Double check your shortcut to Resonite. 26 | 3. If you have `ResoniteModLoader.dll` in a different folder than Libraries, you will likely need to use the absolute path like `-LoadAssembly "C:\Program Files (x86)\Steam\steamapps\common\Resonite\Libraries\ResoniteModLoader.dll"` 27 | - Absolute paths need to be surrounded with quotation marks if they include any spaces `""` 28 | 4. Check a known-working shortcut. 29 | 1. Navigate to the Resonite install directory. (`C:\Program Files (x86)\Steam\steamapps\common\Resonite`) 30 | 2. Create a new text file named `start_resonite.bat` in your Resonite install directory. Make sure the file extension is `.bat` and not `.txt`. 31 | 3. Copy the contents of the example [start_resonite.bat](start_resonite.bat) into yours. 32 | 4. Run your `start_resonite.bat` by double-clicking it in your file explorer. 33 | 5. Resonite should start and load ResoniteModLoader as expected. 34 | 35 | If the problem is the FrooxEngine.dll path on Linux: 36 | 37 | 1. If you are on Linux, make sure you've followed the [extra Linux instructions](linux.md). 38 | 39 | Windows may be blocking the DLL files from being loaded: 40 | 41 | (Provided example from windows Headless) 42 | ```log 43 | [ERROR][ResoniteModLoader] Error loading assembly from C:\Program Files (x86)\Steam\steamapps\common\Resonite\Headless\rml_libs\0Harmony.dll: System.IO.FileLoadException: Could not load file or assembly 'file:///C:\Program Files (x86)\Steam\steamapps\common\Resonite\Headless\rml_libs\0Harmony.dll' or one of its dependencies. Operation is not supported. (Exception from HRESULT: 0x80131515) 44 | File name: 'file:///C:\Program Files (x86)\Steam\steamapps\common\Resonite\Headless\rml_libs\0Harmony.dll' ---> System.NotSupportedException: An attempt was made to load an assembly from a network location which would have caused the assembly to be sandboxed in previous versions of the .NET Framework. This release of the .NET Framework does not enable CAS policy by default, so this load may be dangerous. If this load is not intended to sandbox the assembly, please enable the loadFromRemoteSources switch. See http://go.microsoft.com/fwlink/?LinkId=155569 for more information. 45 | ``` 46 | 47 | 1. Right click on the ResoniteModLoader.dll file and open the properties. 48 | 2. Check the unblock checkbox, and hit OK. 49 | ![unblock dll on windows](img/windows_unblock.png) 50 | 3. Repeat this process for 0Harmony.dll. 51 | 52 | If the problem is your antivirus: 53 | 54 | 1. Make sure your antivirus has not quarantined or deleted ResoniteModLoader.dll or 0Harmony.dll. 55 | 2. Add an exception to your antivirus. If you're uncomfortable adding an exception, you have options: 56 | - Don't run ResoniteModLoader. 57 | - Change to an antivirus that has fewer false positives. 58 | - Build ResoniteModLoader and/or Harmony yourself from source code. 59 | 60 | ## ResoniteModLoader Loads, but Errors Out 61 | 62 | **Symptoms:** 63 | 64 | - Mods are not loading 65 | - All of your contacts appear to be on the same version as you but showing the current versions such as `On Version 2023.9.26.304` 66 | - All of your contacts appear to be using an incompatible version 67 | 68 | **Fix:** 69 | 70 | 1. Verify that the [installation instructions](../README.md#installation) were followed correctly 71 | 2. If you are using [Linux](linux.md) builds, make sure you've followed the extra steps. 72 | 3. Check the logs (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs`). There are a few things you are likely to find: 73 | 74 | Possibility 1: Harmony is not installed correctly. 75 | 76 | 1. Your log contains the following: 77 | 78 | ```log 79 | 2:04:54 AM.013 ( -1 FPS) [ERROR][ResoniteModLoader] Exception in execution hook! 80 | System.IO.FileNotFoundException: Could not load file or assembly '0Harmony, Version=2.2.2.0, Culture=neutral, PublicKeyToken=null' or one of its dependencies. 81 | File name: '0Harmony, Version=2.2.2.0, Culture=neutral, PublicKeyToken=null' 82 | at ResoniteModLoader.ExecutionHook..cctor () [0x00050] in <86a5d715b5ea4079ac09deb2b6184e56>:0 83 | ``` 84 | 85 | 2. Go back to the [installation instructions](../README.md#installation) and install Harmony to the correct location. 86 | 87 | Possibility 2: You are using an old version of ResoniteModLoader. 88 | 89 | 1. Check your log for a line like this: 90 | 91 | ```log 92 | 3:45:43 AM.373 ( -1 FPS) [INFO] [ResoniteModLoader] ResoniteModLoader v2.4.0 starting up! 93 | ``` 94 | 95 | 2. Verify your ResoniteModLoader version matches [the latest release](https://github.com/resonite-modding-group/ResoniteModLoader/releases/latest). 96 | 97 | Possibility 3: ResoniteModLoader itself is broken, even on the latest version. This can happen in rare circumstances when Resonite updates. 98 | 99 | 1. Please report the issue on [our Discord][Resonite Modding Discord] or in [a GitHub issue](https://github.com/resonite-modding-group/ResoniteModLoader/issues). 100 | 2. Wait for a fix. 101 | 102 | ## Multiplayer Compatibility is Broken, but Everything Else Works 103 | 104 | **Symptoms:** 105 | 106 | - Mods are loading 107 | - All of your contacts appear to be on the same version as you but showing the current versions such as `On Version 2023.9.26.304` 108 | - All of your contacts appear to be using an incompatible version 109 | 110 | **Fix:** 111 | 112 | 1. Make sure you are not running more than one plugin. For safety reasons, ResoniteModLoader will only spoof your version if it is the only plugin running. 113 | 2. If you absolutely need your other plugin and understand the risks there is a [configuration](modloader_config.md) available to force version spoofing. 114 | 115 | ## A Mod is Breaking Resonite 116 | 117 | **Symptoms:** 118 | 119 | - Modded Resonite is broken or crashing unexpectedly 120 | - Unmodified Resonite is working 121 | 122 | **Fix:** 123 | 124 | Remove the offending mod, and contact its developer so they can fix the bug. 125 | 126 | If you are not sure which mod is broken, follow the below steps: 127 | 128 | 1. Check the logs (`C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs`). They should indicate which mod is failing. If the logs don't help, then continue with the following steps. 129 | 2. Disable ResoniteModLoader by removing the `-LoadAssembly Libraries/ResoniteModLoader.dll` launch option. If Resonite is still having problems while completely unmodified, you can get support on the [Resonite Discord]. **You should not ask the Resonite Discord for help with mods.** 130 | 3. If you only experience the problem while modded, try uninstalling all of your mods and re-installing them one by one. Once you find the problematic mod reach out it its developers. 131 | - Alternatively you can re-add mods half at a time until the problem starts occuring then investigate within the smaller set of mods. 132 | 4. If the issue appears to be with ResoniteModLoader itself, please open [an issue](https://github.com/resonite-modding-group/ResoniteModLoader/issues). 133 | 134 | ## I Need More Help 135 | 136 | If you are having trouble diagnosing the issue yourself, we have a #help-and-support channel in the [Resonite Modding Discord]. The first thing we're likely to ask for is your log, so please have that handy. You can find logs here: `C:\Program Files (x86)\Steam\steamapps\common\Resonite\Logs` 137 | 138 | 139 | [Resonite Modding Discord]: https://discord.gg/ZMRyQ8bryN 140 | [Resonite Discord]: https://discord.gg/resonite 141 | --------------------------------------------------------------------------------