From 3e6a7d2356978fd6b4592c62168d24e3ca96ae76 Mon Sep 17 00:00:00 2001 From: Chris Tristan <1764856+CTristan@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:58:14 -0700 Subject: [PATCH 1/6] Remove Mods.Common config bridge Deletes LmmConfigurationProvider (the IConfigProvider implementation that bridged Mods.Common's ModConfig/IConfigEntry to LmmConfigFile) and the provider-wiring block in Harmony_Patch that attached the provider to every registered ModConfig. Upstream Mods.Common has also dropped these config abstractions: mod settings are not a concern of a general reflection-facade library. A source generator shipped from this repo will reintroduce the optional-dependency integration without requiring mods to carry an extra runtime DLL. Also drops the Mods.Common PackageReference from both csprojs (no longer used), the Common .dll staging step from the release workflow, and adds a local ExcludeFromCodeCoverageAttribute polyfill in place of the one that previously came transitively from Common's ILRepack. --- .github/copilot-instructions.md | 3 +- .github/workflows/release.yml | 1 - Directory.Packages.props | 5 - .../LobCorp.ConfigurationManager.Test.csproj | 13 +- .../LmmConfigurationProviderTests.cs | 349 ------------------ LobCorp.ConfigurationManager/Harmony_Patch.cs | 14 - .../LmmConfigurationProvider.cs | 237 ------------ .../LobCorp.ConfigurationManager.csproj | 4 - .../ExcludeFromCodeCoverageAttribute.cs | 26 ++ 9 files changed, 31 insertions(+), 621 deletions(-) delete mode 100644 LobCorp.ConfigurationManager.Test/ModTests/ConfigurationManagerTests/LmmConfigurationProviderTests.cs delete mode 100644 LobCorp.ConfigurationManager/Implementations/LmmConfigurationProvider.cs create mode 100644 LobCorp.ConfigurationManager/Polyfills/ExcludeFromCodeCoverageAttribute.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4330b7e..7d28a3f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -31,6 +31,7 @@ Tests live in `LobCorp.ConfigurationManager.Test` (xunit.v3, Moq, AwesomeAsserti - LMM mods register via `Config/LmmConfigRegistration.cs` static API - Auto-scans `BaseMods/{modId}/config.cfg` files - Discovers BepInEx plugins via reflection (`Implementations/BepInExInterop.cs`) — no hard dependency +- Source-gen-based optional-dependency path for mod authors is planned (see `/Users/chris/.claude/plans/cheeky-cooking-puzzle.md`); this repo no longer carries a `LobotomyCorporation.Mods.Common`-based bridge. **Configuration model (`Config/`):** - `LmmConfigFile` — file I/O and parsing for `config.cfg` files @@ -50,7 +51,7 @@ Tests live in `LobCorp.ConfigurationManager.Test` (xunit.v3, Moq, AwesomeAsserti - **net35 target**: no LINQ extensions beyond what's available, no `System.ValueTuple`, limited BCL. `LangVersion` is set to `latest` so C# syntax features work but BCL APIs are restricted. - **RootNamespace and AssemblyName are both `ConfigurationManager`** (not `LobCorp.ConfigurationManager`) — intentionally matches upstream BepInEx.ConfigurationManager. This is a **DLL-name / namespace collision prevention** mechanism only: the identical DLL name stops both from loading simultaneously, and the shared root namespace avoids dual-load conflicts (double UI entries, duplicate `ConfigurationManagerAttributes` processing). This is **not** a public-API-compatibility contract — the fork can freely change its internal shape (e.g. `ConfigurationManagerAttributes` was moved from fields to properties). Do not change `RootNamespace` or `AssemblyName` without accounting for the loader-collision implications. - **`Harmony_Patch` class name is load-bearing** — every LMM mod must expose an entry type named `Harmony_Patch`. The analyzer package (`LobotomyCorporation.Mods.Analyzers` globalconfig) suppresses S101 and CA1707 repo-wide so this pattern doesn't trip naming rules. -- **Game assembly references are `Private=false`** — none are copied to output since they exist in the game's managed folder at runtime. The `LobotomyCorporation.Mods.Common` PackageReference does copy to output, as it must be deployed alongside the mod. +- **Game assembly references are `Private=false`** — none are copied to output since they exist in the game's managed folder at runtime. No other runtime DLLs are copied alongside `ConfigurationManager.dll` today (the previous `LobotomyCorporation.Mods.Common` bridge has been removed). - **Implicit usings and nullable are disabled.** - `Microsoft.NETFramework.ReferenceAssemblies` is pulled in implicitly by the SDK for net35 — do not add it to `Directory.Packages.props`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e459d2..a1487bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,7 +48,6 @@ jobs: run: | mkdir -p staging/ConfigurationManager/Info/en cp LobCorp.ConfigurationManager/bin/net35/ConfigurationManager.dll staging/ConfigurationManager/ - cp LobCorp.ConfigurationManager/bin/net35/LobotomyCorporation.Mods.Common.*.dll staging/ConfigurationManager/ cp LobCorp.ConfigurationManager/bin/net35/Info/GlobalInfo.xml staging/ConfigurationManager/Info/ cp LobCorp.ConfigurationManager/bin/net35/Info/en/Info.xml staging/ConfigurationManager/Info/en/ diff --git a/Directory.Packages.props b/Directory.Packages.props index 66d4a4a..9fc1c06 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,14 +1,9 @@ true - 0.1.0-preview.20 - diff --git a/LobCorp.ConfigurationManager.Test/LobCorp.ConfigurationManager.Test.csproj b/LobCorp.ConfigurationManager.Test/LobCorp.ConfigurationManager.Test.csproj index bacd62f..9751ebe 100644 --- a/LobCorp.ConfigurationManager.Test/LobCorp.ConfigurationManager.Test.csproj +++ b/LobCorp.ConfigurationManager.Test/LobCorp.ConfigurationManager.Test.csproj @@ -7,8 +7,9 @@ net10.0 Library $(NoWarn);NU1702 - + false false enable @@ -17,19 +18,11 @@ - - - $(PkgLobotomyCorporation_Mods_Common)\lib\net35\LobotomyCorporation.Mods.Common.$(LobotomyCorporationModsCommonVersion).dll - ..\external\LobotomyCorp_Data\Managed\0Harmony.dll diff --git a/LobCorp.ConfigurationManager.Test/ModTests/ConfigurationManagerTests/LmmConfigurationProviderTests.cs b/LobCorp.ConfigurationManager.Test/ModTests/ConfigurationManagerTests/LmmConfigurationProviderTests.cs deleted file mode 100644 index f759c40..0000000 --- a/LobCorp.ConfigurationManager.Test/ModTests/ConfigurationManagerTests/LmmConfigurationProviderTests.cs +++ /dev/null @@ -1,349 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later - -#region - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.IO; -using System.Linq; -using AwesomeAssertions; -using ConfigurationManager.Config; -using ConfigurationManager.Implementations; -using LobotomyCorporation.Mods.Common; -using Xunit; - -#endregion - -namespace LobCorp.ConfigurationManager.Test.ModTests.ConfigurationManagerTests -{ - public sealed class LmmConfigurationProviderTests : IDisposable - { - private readonly List _tempPaths = []; - - public void Dispose() - { - foreach (var path in _tempPaths) - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - } - - private LmmConfigFile CreateTempConfigFile() - { - var path = Path.GetTempFileName(); - _tempPaths.Add(path); - return new LmmConfigFile(path); - } - - private LmmConfigurationProvider CreateProvider(LmmConfigFile? reusableFile = null) - { - var file = reusableFile ?? CreateTempConfigFile(); - return new LmmConfigurationProvider((_, _, _) => file); - } - - [Fact] - public void Constructor_NullFactory_ShouldThrowArgumentNullException() - { - Action act = () => - { - var _ = new LmmConfigurationProvider( - (Func)null! - ); - }; - - act.Should().Throw(); - } - - [Fact] - public void Constructor_Default_ShouldNotThrow() - { - Action act = () => - { - var _ = new LmmConfigurationProvider(); - }; - - act.Should().NotThrow(); - } - - [Fact] - public void LoadPersistedValues_NullEntries_ShouldThrowArgumentNullException() - { - var provider = CreateProvider(); - - Action act = () => provider.LoadPersistedValues(null!); - - act.Should().Throw(); - } - - [Fact] - public void LoadPersistedValues_ShouldBindEntryToConfigFile() - { - var file = CreateTempConfigFile(); - var provider = CreateProvider(file); - var entry = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "Volume", - SettingType = typeof(int), - DefaultValue = 50, - }; - - provider.LoadPersistedValues([entry]); - - var bound = file.Bind("General", "Volume", 0); - bound.BoxedValue.Should().Be(50); - } - - [Fact] - public void LoadPersistedValues_PersistedValueDifferentFromDefault_ShouldSyncValue() - { - var file = CreateTempConfigFile(); - file.Bind("General", "Volume", 50).Value = 75; - - var provider = CreateProvider(file); - var entry = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "Volume", - SettingType = typeof(int), - DefaultValue = 50, - }; - - provider.LoadPersistedValues([entry]); - - entry.Value.Should().Be(75); - } - - [Fact] - public void LoadPersistedValues_PersistedValueMatchesDefault_ShouldNotChangeValue() - { - var provider = CreateProvider(); - var entry = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "Volume", - SettingType = typeof(int), - DefaultValue = 50, - Value = 50, - }; - - provider.LoadPersistedValues([entry]); - - // Value should still be the default — provider should not have overwritten it. - entry.Value.Should().Be(50); - } - - [Fact] - public void LoadPersistedValues_ChangingBoundEntry_ShouldPropagateToConfigEntry() - { - var file = CreateTempConfigFile(); - var provider = CreateProvider(file); - var entry = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "Volume", - SettingType = typeof(int), - DefaultValue = 50, - }; - - provider.LoadPersistedValues([entry]); - var bound = file.Bind("General", "Volume", 0); - bound.BoxedValue = 99; - - entry.Value.Should().Be(99); - } - - [Fact] - public void LoadPersistedValues_EntryWithDisplayName_ShouldAttachDisplayNameTag() - { - var file = CreateTempConfigFile(); - var provider = CreateProvider(file); - var entry = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "Volume", - DisplayName = "Volume Level", - Description = "Controls volume", - SettingType = typeof(int), - DefaultValue = 50, - }; - - provider.LoadPersistedValues([entry]); - - var bound = file.Bind("General", "Volume", 0); - bound.Description.Description.Should().Be("Controls volume"); - bound.Description.Tags.OfType().Should().ContainSingle(); - } - - [Fact] - public void LoadPersistedValues_EntryWithUseSlider_ShouldAttachConfigurationManagerAttributes() - { - var file = CreateTempConfigFile(); - var provider = CreateProvider(file); - var entry = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "Volume", - SettingType = typeof(int), - DefaultValue = 50, - UseSlider = true, - }; - - provider.LoadPersistedValues([entry]); - - var bound = file.Bind("General", "Volume", 0); - var attrs = bound - .Description.Tags.OfType() - .Single(); - attrs.UseIntegerSlider.Should().Be(true); - } - - [Fact] - public void LoadPersistedValues_EntryWithoutUseSlider_ShouldNotAttachConfigurationManagerAttributes() - { - var file = CreateTempConfigFile(); - var provider = CreateProvider(file); - var entry = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "Volume", - SettingType = typeof(int), - DefaultValue = 50, - UseSlider = false, - }; - - provider.LoadPersistedValues([entry]); - - var bound = file.Bind("General", "Volume", 0); - bound - .Description.Tags.OfType() - .Should() - .BeEmpty(); - } - - [Fact] - public void LoadPersistedValues_EntryWithAcceptableValueRange_ShouldSetAcceptableValues() - { - var file = CreateTempConfigFile(); - var provider = CreateProvider(file); - - // Use a real ModConfig + Bind to get a real IConfigEntry with Range set. - var config = new ModConfig("mod1", "Mod", "1.0"); - var realEntry = config.Bind( - "General", - "Volume", - 50, - "Volume setting", - range: new LobotomyCorporation.Mods.Common.AcceptableValueRange(0, 100) - ); - - provider.LoadPersistedValues([realEntry]); - - var bound = file.Bind("General", "Volume", 0); - bound - .Description.AcceptableValues.Should() - .BeOfType>(); - } - - [Fact] - public void GetOrCreateConfigFile_SameModId_ShouldReuseFile() - { - var factoryCallCount = 0; - var file = CreateTempConfigFile(); - var provider = new LmmConfigurationProvider( - (_, _, _) => - { - factoryCallCount++; - return file; - } - ); - - var entry1 = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "A", - SettingType = typeof(int), - DefaultValue = 1, - }; - var entry2 = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "B", - SettingType = typeof(int), - DefaultValue = 2, - }; - - provider.LoadPersistedValues([entry1, entry2]); - - factoryCallCount.Should().Be(1); - } - - [Fact] - public void Save_ShouldInvokeSaveOnAllRegisteredConfigFiles() - { - var path = Path.GetTempFileName(); - _tempPaths.Add(path); - var file = new LmmConfigFile(path); - var provider = CreateProvider(file); - var entry = new StubConfigEntry - { - ModId = "mod1", - Section = "General", - Key = "Volume", - SettingType = typeof(int), - DefaultValue = 50, - }; - provider.LoadPersistedValues([entry]); - file.Bind("General", "Volume", 0).Value = 77; - - File.WriteAllText(path, string.Empty); - - provider.Save(); - - var contents = File.ReadAllText(path); - contents.Should().Contain("Volume = 77"); - } - - [Fact] - public void Save_WithNoLoadedEntries_ShouldNotThrow() - { - var provider = CreateProvider(); - - Action act = provider.Save; - - act.Should().NotThrow(); - } - - /// - /// Minimal stub implementing for testing the provider - /// without needing a real . - /// - private sealed class StubConfigEntry : IConfigEntry - { - public string ModId { get; set; } = "mod"; - public string ModName { get; set; } = "Mod"; - public string ModVersion { get; set; } = "1.0"; - public string Section { get; set; } = "Section"; - public string Key { get; set; } = "Key"; - public string DisplayName { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public Type SettingType { get; set; } = typeof(int); - public object DefaultValue { get; set; } = 0; - public bool UseSlider { get; set; } - public object Value { get; set; } = 0; - } - } -} diff --git a/LobCorp.ConfigurationManager/Harmony_Patch.cs b/LobCorp.ConfigurationManager/Harmony_Patch.cs index e4ada04..7509503 100644 --- a/LobCorp.ConfigurationManager/Harmony_Patch.cs +++ b/LobCorp.ConfigurationManager/Harmony_Patch.cs @@ -2,9 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; -using ConfigurationManager.Implementations; using Harmony; -using LobotomyCorporation.Mods.Common; namespace ConfigurationManager { @@ -38,18 +36,6 @@ private Harmony_Patch(bool initialize) { var harmony = HarmonyInstance.Create("com.lobcorp.configurationmanager"); harmony.PatchAll(typeof(Harmony_Patch).Assembly); - - var provider = new LmmConfigurationProvider(); - - // Attach to every ModConfig that has already been created. - foreach (var config in ModConfig.RegisteredConfigs) - { - config.AttachProvider(provider); - } - - // Attach to any ModConfig created after this point. - ModConfig.ConfigRegistered += (sender, args) => - args.Config.AttachProvider(provider); } catch (Exception ex) { diff --git a/LobCorp.ConfigurationManager/Implementations/LmmConfigurationProvider.cs b/LobCorp.ConfigurationManager/Implementations/LmmConfigurationProvider.cs deleted file mode 100644 index ab6bfce..0000000 --- a/LobCorp.ConfigurationManager/Implementations/LmmConfigurationProvider.cs +++ /dev/null @@ -1,237 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later - -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Reflection; -using ConfigurationManager.Config; -using LobotomyCorporation.Mods.Common; - -namespace ConfigurationManager.Implementations -{ - /// - /// Bridges Common's abstractions to ConfigurationManager's - /// persistence and settings UI. - /// - public sealed class LmmConfigurationProvider : IConfigProvider - { - private static readonly MethodInfo s_bindMethod = FindBindMethod(); - - private readonly Dictionary _configFiles = - new Dictionary(); - - private readonly Func _configFileFactory; - - /// - /// Creates a provider that persists through . - /// - public LmmConfigurationProvider() - : this(LmmConfigRegistration.GetConfigFile) { } - - internal LmmConfigurationProvider( - Func configFileFactory - ) - { - _configFileFactory = - configFileFactory ?? throw new ArgumentNullException(nameof(configFileFactory)); - } - - /// - public void LoadPersistedValues(IEnumerable entries) - { - if (entries == null) - { - throw new ArgumentNullException(nameof(entries)); - } - - foreach (var entry in entries) - { - try - { - LoadEntry(entry); - } - catch (Exception ex) - { - UnityEngine.Debug.LogWarning( - "[ConfigurationManager] Failed to load entry " - + entry.ModId - + ":" - + entry.Section - + ":" - + entry.Key - + " - " - + ex - ); - } - } - } - - /// - public void Save() - { - foreach (var configFile in _configFiles.Values) - { - configFile.Save(); - } - } - - private void LoadEntry(IConfigEntry entry) - { - var configFile = GetOrCreateConfigFile(entry.ModId, entry.ModName, entry.ModVersion); - var description = BuildDescription(entry); - - var genericBind = s_bindMethod.MakeGenericMethod(entry.SettingType); - var lmmEntry = (LmmConfigEntryBase) - genericBind.Invoke( - configFile, - new[] { entry.Section, entry.Key, entry.DefaultValue, description } - ); - - if (lmmEntry == null) - { - return; - } - - // Sync persisted value → IConfigEntry - var persistedValue = lmmEntry.BoxedValue; - if (!object.Equals(persistedValue, entry.DefaultValue)) - { - entry.Value = persistedValue; - } - - // Sync live UI changes → IConfigEntry - lmmEntry.SettingChanged += (sender, args) => entry.Value = lmmEntry.BoxedValue; - } - - private LmmConfigFile GetOrCreateConfigFile(string modId, string modName, string modVersion) - { - if (_configFiles.TryGetValue(modId, out var existing)) - { - return existing; - } - - var configFile = _configFileFactory(modId, modName, modVersion); - _configFiles[modId] = configFile; - - return configFile; - } - - private static LmmConfigDescription BuildDescription(IConfigEntry entry) - { - var tags = new List(); - - // DisplayName tag - if (!string.IsNullOrEmpty(entry.DisplayName)) - { - tags.Add(new DisplayNameAttribute(entry.DisplayName)); - } - - // ConfigurationManagerAttributes for UI hints - var attrs = new ConfigurationManagerAttributes(); - var hasAttrs = false; - - if (entry.UseSlider) - { - attrs.UseIntegerSlider = true; - hasAttrs = true; - } - - if (hasAttrs) - { - tags.Add(attrs); - } - - // Acceptable value constraints from the generic Range property. - // IConfigEntry (non-generic) doesn't expose Range, so we use reflection - // to read it from the concrete IConfigEntry implementation. - IAcceptableValue acceptableValues = null; - if (TryGetRangeViaReflection(entry, out var rangeMin, out var rangeMax)) - { - acceptableValues = CreateAcceptableValueRange( - entry.SettingType, - rangeMin, - rangeMax - ); - } - - return new LmmConfigDescription( - entry.Description ?? string.Empty, - acceptableValues, - tags.ToArray() - ); - } - - private static bool TryGetRangeViaReflection( - IConfigEntry entry, - out object min, - out object max - ) - { - min = null; - max = null; - - // IConfigEntry.Range is AcceptableValueRange with Min/Max properties. - var entryType = entry.GetType(); - var rangeProp = entryType.GetProperty("Range"); - if (rangeProp == null) - { - return false; - } - - var rangeValue = rangeProp.GetValue(entry, null); - if (rangeValue == null) - { - return false; - } - - var rangeType = rangeValue.GetType(); - var minProp = rangeType.GetProperty("Min"); - var maxProp = rangeType.GetProperty("Max"); - if (minProp == null || maxProp == null) - { - return false; - } - - min = minProp.GetValue(rangeValue, null); - max = maxProp.GetValue(rangeValue, null); - - return min != null && max != null; - } - - private static IAcceptableValue CreateAcceptableValueRange( - Type settingType, - object min, - object max - ) - { - var openType = typeof(Config.AcceptableValueRange<>); - var closedType = openType.MakeGenericType(settingType); - - return (IAcceptableValue)Activator.CreateInstance(closedType, new[] { min, max }); - } - - private static MethodInfo FindBindMethod() - { - foreach (var method in typeof(LmmConfigFile).GetMethods()) - { - if (method.Name == "Bind" && method.IsGenericMethodDefinition) - { - var parameters = method.GetParameters(); - if ( - parameters.Length == 4 - && parameters[0].ParameterType == typeof(string) - && parameters[1].ParameterType == typeof(string) - && parameters[3].ParameterType == typeof(LmmConfigDescription) - ) - { - return method; - } - } - } - - throw new MissingMethodException( - "Could not find LmmConfigFile.Bind(string, string, T, LmmConfigDescription)" - ); - } - } -} diff --git a/LobCorp.ConfigurationManager/LobCorp.ConfigurationManager.csproj b/LobCorp.ConfigurationManager/LobCorp.ConfigurationManager.csproj index 77a3816..3365fac 100644 --- a/LobCorp.ConfigurationManager/LobCorp.ConfigurationManager.csproj +++ b/LobCorp.ConfigurationManager/LobCorp.ConfigurationManager.csproj @@ -14,10 +14,6 @@ ConfigurationManager - - - - ..\external\LobotomyCorp_Data\Managed\0Harmony.dll diff --git a/LobCorp.ConfigurationManager/Polyfills/ExcludeFromCodeCoverageAttribute.cs b/LobCorp.ConfigurationManager/Polyfills/ExcludeFromCodeCoverageAttribute.cs new file mode 100644 index 0000000..46ba35a --- /dev/null +++ b/LobCorp.ConfigurationManager/Polyfills/ExcludeFromCodeCoverageAttribute.cs @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +// net35 BCL polyfill: System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute was +// added in .NET 4.0 but this assembly targets net35. Providing the type locally lets the +// rest of the codebase annotate coverage-excluded members without pulling in a dependency +// solely for this attribute. + +#pragma warning disable IDE0130 // Namespace must match BCL location, not folder path +namespace System.Diagnostics.CodeAnalysis +#pragma warning restore IDE0130 +{ + [AttributeUsage( + AttributeTargets.Class + | AttributeTargets.Struct + | AttributeTargets.Constructor + | AttributeTargets.Method + | AttributeTargets.Property + | AttributeTargets.Event, + Inherited = false, + AllowMultiple = false + )] + internal sealed class ExcludeFromCodeCoverageAttribute : Attribute + { + public string Justification { get; set; } + } +} From c8952341a5c4969797ecd15539a0aef54076517d Mon Sep 17 00:00:00 2001 From: Chris Tristan <1764856+CTristan@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:32:10 -0700 Subject: [PATCH 2/6] Add ConfigurationManager.Integration source-generator package Ships build-time glue as one NuGet reference with PrivateAssets="all" so mods integrate with ConfigurationManager without a runtime dependency on ConfigurationManager.dll. The source generator emits reflection-based interop directly into the consuming mod's own assembly, so the mod ships one DLL and still runs when ConfigurationManager is absent (bindings fall back to an in-memory store). Also adds Audience & Language guidance to CLAUDE.md to shape user-facing text (README, error messages, release notes) for native Korean ESL readers and first-time modders, and extends the release workflow to pack and upload the nupkg alongside the existing zip asset. --- .github/copilot-instructions.md | 18 + .github/workflows/release.yml | 7 +- ConfigurationManager.slnx | 2 + Directory.Packages.props | 12 + .../BindingModelTests.cs | 165 ++++++++ .../BindingScannerTests.cs | 60 +++ .../GeneratorDiagnosticsTests.cs | 170 ++++++++ .../GeneratorSnapshotTests.cs | 77 ++++ ...figurationManager.Integration.Tests.csproj | 20 + .../ModAttributeScannerTests.cs | 56 +++ ...pes#ConfigurationManager.Api.g.verified.cs | 152 +++++++ ...gurationManager.CmAttributes.g.verified.cs | 28 ++ ...gurationManager.Registration.g.verified.cs | 213 ++++++++++ ...ing#ConfigurationManager.Api.g.verified.cs | 152 +++++++ ...gurationManager.CmAttributes.g.verified.cs | 28 ++ ...gurationManager.Registration.g.verified.cs | 212 ++++++++++ ...ing#ConfigurationManager.Api.g.verified.cs | 152 +++++++ ...gurationManager.CmAttributes.g.verified.cs | 28 ++ ...gurationManager.Registration.g.verified.cs | 212 ++++++++++ .../TestHelper.cs | 41 ++ .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 9 + .../BindingModel.cs | 147 +++++++ .../BindingScanner.cs | 235 +++++++++++ .../Diagnostics/DiagnosticDescriptors.cs | 38 ++ .../Emit/ApiSurfaceEmitter.cs | 168 ++++++++ .../Emit/CmAttributesEmitter.cs | 43 ++ .../Emit/RegistrationEmitter.cs | 387 ++++++++++++++++++ .../Generator.cs | 120 ++++++ ...ds.ConfigurationManager.Integration.csproj | 54 +++ .../PACKAGE_README.md | 75 ++++ ...ods.ConfigurationManager.Integration.props | 8 + README.md | 54 ++- 33 files changed, 3142 insertions(+), 3 deletions(-) create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingModelTests.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingScannerTests.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorDiagnosticsTests.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorSnapshotTests.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests.csproj create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/ModAttributeScannerTests.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Api.g.verified.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.CmAttributes.g.verified.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Registration.g.verified.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Api.g.verified.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.CmAttributes.g.verified.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Registration.g.verified.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Api.g.verified.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.CmAttributes.g.verified.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Registration.g.verified.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/TestHelper.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Shipped.md create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Unshipped.md create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingModel.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingScanner.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Diagnostics/DiagnosticDescriptors.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/ApiSurfaceEmitter.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/CmAttributesEmitter.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/RegistrationEmitter.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Generator.cs create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/LobotomyCorporation.Mods.ConfigurationManager.Integration.csproj create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/PACKAGE_README.md create mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/build/LobotomyCorporation.Mods.ConfigurationManager.Integration.props diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7d28a3f..9882ec6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -65,3 +65,21 @@ NuGet package publishing is planned for v1.0.0 but not yet implemented. ## Analyzers Global analyzers (`LobotomyCorporation.Mods.Analyzers`, `OpenLobotomy.Standards`) are configured in `Directory.Packages.props`. All Sonar rules run at their default severity — there are no global suppressions in `.editorconfig`. Rule exceptions that apply to all LMM mods (e.g. S101/CA1707 for the `Harmony_Patch` entry point) live in the shared `LobotomyCorporation.Mods.Analyzers` globalconfig, not here. Fix violations rather than suppressing them; if a suppression is truly needed, scope it as narrowly as possible (file-local `#pragma` or per-member `[SuppressMessage]`). + +## Audience & Language + +Many users and contributors are native Korean speakers who read English as a second language or through machine translation. + +### Project facts that shape documentation + +- **Lobotomy Corporation itself will never update.** The base game is final. Do not pitch wrappers, adapters, or analyzers on "survives game updates" or "keeps working when the game changes" — those claims are factually wrong and will mislead readers. The honest value props for typed wrappers over reflection are: (a) the compiler checks names and types at build time, so typos fail before you run the game; (b) typed code is shorter and easier to read; (c) the package is community-maintained, so fixes land once for everyone. What *does* still change is LMM (the mod loader) and other mods that patch the same game code via Harmony — if a doc needs to explain why a wrapper helps mods coexist, that is the real reason, not game updates. + +### Package Audiences + +- **`ConfigurationManager.dll` and `LobotomyCorporation.Mods.ConfigurationManager.Integration`** — target audience includes first-time modders without professional development experience. Error messages, analyzer diagnostics, and docs should explain *why*, not just *what*. Avoid assuming knowledge of patterns like dependency injection, mocking, reflection, or build system internals. The source generator in particular is consumed via a single NuGet reference by authors who may have never used Roslyn analyzers before — surface failures as clear, actionable messages, not stack traces. + +### Writing Style + +**User-facing text** (README, error messages, release notes): use short sentences with active voice and explicit subjects. Avoid idioms, slang, and culturally specific references. Define technical terms inline or use simpler words. Write in a style that survives machine translation — no ambiguous pronouns, no noun stacking. + +**Developer-facing text** (code comments, commit messages): technical terminology is fine, but prefer direct, concise phrasing over unnecessarily complex language. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1487bb..9132f8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,12 @@ jobs: working-directory: staging run: zip -r ../ConfigurationManager-${{ github.event.release.tag_name }}.zip ConfigurationManager/ + - name: Pack Integration NuGet + run: dotnet pack LobotomyCorporation.Mods.ConfigurationManager.Integration/LobotomyCorporation.Mods.ConfigurationManager.Integration.csproj --no-build -p:Version=${{ steps.version.outputs.VERSION }} -o nupkg-out + - name: Upload release asset env: GH_TOKEN: ${{ github.token }} - run: gh release upload --clobber "${{ github.event.release.tag_name }}" "ConfigurationManager-${{ github.event.release.tag_name }}.zip" + run: | + gh release upload --clobber "${{ github.event.release.tag_name }}" "ConfigurationManager-${{ github.event.release.tag_name }}.zip" + gh release upload --clobber "${{ github.event.release.tag_name }}" nupkg-out/LobotomyCorporation.Mods.ConfigurationManager.Integration.*.nupkg diff --git a/ConfigurationManager.slnx b/ConfigurationManager.slnx index ccb68d4..4c53acb 100644 --- a/ConfigurationManager.slnx +++ b/ConfigurationManager.slnx @@ -4,5 +4,7 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 9fc1c06..6de7a74 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,18 @@ + + + + + + diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingModelTests.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingModelTests.cs new file mode 100644 index 0000000..3ece739 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingModelTests.cs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using System.Collections.Generic; +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests +{ + public sealed class BindingModelTests + { + [Fact] + public void Equals_IdenticalInstances_ReturnsTrue() + { + var a = Create(); + var b = Create(); + a.Equals(b).Should().BeTrue(); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void Equals_NullOther_ReturnsFalse() + { + Create().Equals(null).Should().BeFalse(); + } + + [Fact] + public void Equals_ObjectOverload_ReturnsFalseForUnrelatedType() + { + Create().Equals((object?)"not a model").Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentContainingType_ReturnsFalse() + { + Create(containingType: "Other.Type").Equals(Create()).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentSection_ReturnsFalse() + { + Create(section: "OtherSection").Equals(Create()).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentKey_ReturnsFalse() + { + Create(key: "OtherKey").Equals(Create()).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentValueType_ReturnsFalse() + { + Create(valueType: "System.Single").Equals(Create()).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentDefaultLiteral_ReturnsFalse() + { + Create(defaultLiteral: "1").Equals(Create()).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentNamedArgumentCount_ReturnsFalse() + { + var a = Create(namedArgs: new Dictionary { ["order"] = "1" }); + var b = Create(); + a.Equals(b).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentNamedArgumentValue_ReturnsFalse() + { + var a = Create(namedArgs: new Dictionary { ["order"] = "1" }); + var b = Create(namedArgs: new Dictionary { ["order"] = "2" }); + a.Equals(b).Should().BeFalse(); + } + + [Fact] + public void Equals_MissingNamedArgumentKey_ReturnsFalse() + { + var a = Create(namedArgs: new Dictionary { ["order"] = "1" }); + var b = Create(namedArgs: new Dictionary { ["otherKey"] = "1" }); + a.Equals(b).Should().BeFalse(); + } + + private static BindingModel Create( + string containingType = "TestMod.MyConfig", + string section = "Section", + string key = "Key", + string valueType = "System.Int32", + string defaultLiteral = "100", + Dictionary? namedArgs = null + ) + { + return new BindingModel( + containingType, + section, + key, + valueType, + defaultLiteral, + namedArgs ?? [], + Location.None + ); + } + } + + public sealed class ModAttributeModelTests + { + [Fact] + public void Equals_IdenticalInstances_ReturnsTrue() + { + var a = Create(); + var b = Create(); + a.Equals(b).Should().BeTrue(); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void Equals_NullOther_ReturnsFalse() + { + Create().Equals(null).Should().BeFalse(); + } + + [Fact] + public void Equals_ObjectOverload_ReturnsFalseForUnrelatedType() + { + Create().Equals((object?)42).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentModId_ReturnsFalse() + { + Create(modId: "other.id").Equals(Create()).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentModName_ReturnsFalse() + { + Create(modName: "Other").Equals(Create()).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentModVersion_ReturnsFalse() + { + Create(modVersion: "2.0.0").Equals(Create()).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentFallback_ReturnsFalse() + { + Create(fallback: "FileBacked").Equals(Create()).Should().BeFalse(); + } + + private static ModAttributeModel Create( + string modId = "com.test.mod", + string modName = "Test Mod", + string modVersion = "1.0.0", + string fallback = "InMemory" + ) + { + return new ModAttributeModel(modId, modName, modVersion, fallback, Location.None); + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingScannerTests.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingScannerTests.cs new file mode 100644 index 0000000..c7a8c88 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingScannerTests.cs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using AwesomeAssertions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests +{ + public sealed class BindingScannerTests + { + [Fact] + public void IsBindCandidate_ReturnsTrue_ForConfigDotBindInvocation() + { + var node = ParseExpression("Config.Bind(\"s\", \"k\", 1)"); + BindingScanner.IsBindCandidate(node).Should().BeTrue(); + } + + [Fact] + public void IsBindCandidate_ReturnsFalse_ForDifferentReceiver() + { + var node = ParseExpression("Other.Bind(\"s\", \"k\", 1)"); + BindingScanner.IsBindCandidate(node).Should().BeFalse(); + } + + [Fact] + public void IsBindCandidate_ReturnsFalse_ForDifferentMethod() + { + var node = ParseExpression("Config.Other(\"s\", \"k\", 1)"); + BindingScanner.IsBindCandidate(node).Should().BeFalse(); + } + + [Fact] + public void IsBindCandidate_ReturnsFalse_ForQualifiedName() + { + // Aliased or fully-qualified uses are intentionally not matched (documented limitation). + var node = ParseExpression("global::Config.Bind(\"s\", \"k\", 1)"); + BindingScanner.IsBindCandidate(node).Should().BeFalse(); + } + + [Fact] + public void IsBindCandidate_ReturnsFalse_ForNonInvocationSyntax() + { + var node = SyntaxFactory.ParseExpression("42"); + BindingScanner.IsBindCandidate(node).Should().BeFalse(); + } + + [Fact] + public void IsBindCandidate_ReturnsFalse_ForInvocationWithoutMemberAccess() + { + var node = (InvocationExpressionSyntax)SyntaxFactory.ParseExpression("Foo()"); + BindingScanner.IsBindCandidate(node).Should().BeFalse(); + } + + private static InvocationExpressionSyntax ParseExpression(string expression) + { + return (InvocationExpressionSyntax)SyntaxFactory.ParseExpression(expression); + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorDiagnosticsTests.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorDiagnosticsTests.cs new file mode 100644 index 0000000..19b1079 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorDiagnosticsTests.cs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using System.Linq; +using AwesomeAssertions; +using Xunit; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests +{ + public sealed class GeneratorDiagnosticsTests + { + [Fact] + public void DuplicateBinding_ReportsLCM001() + { + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + [assembly: ConfigManagerMod(ModId = "com.test.mod", ModName = "Test Mod")] + + namespace TestMod + { + internal static class MyConfig + { + public static readonly IConfigValue First = + Config.Bind("Section", "Key", 100); + public static readonly IConfigValue Second = + Config.Bind("Section", "Key", 200); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToList(); + diagnostics.Should().ContainSingle(d => d.Id == "LCM001"); + } + + [Fact] + public void DuplicateModAttribute_ReportsLCM002() + { + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + [assembly: ConfigManagerMod(ModId = "com.test.mod", ModName = "Test Mod")] + [assembly: ConfigManagerMod(ModId = "com.other.mod", ModName = "Other Mod")] + + namespace TestMod + { + internal static class MyConfig + { + public static readonly IConfigValue Damage = + Config.Bind("Combat", "Damage", 100); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + var diagnostics = result.Results.SelectMany(r => r.Diagnostics).ToList(); + diagnostics.Should().ContainSingle(d => d.Id == "LCM002"); + } + + [Fact] + public void BindWithTooFewArguments_IsSkippedSilently() + { + // Scanner matches Config.Bind by name, TryExtract bails when args < 3. + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + namespace TestMod + { + internal static class MyConfig + { + public static readonly object TooFew = Config.Bind("a", "b"); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + result.Results.Should().NotBeEmpty(); + } + + [Fact] + public void BindWithNonLiteralSectionOrKey_IsSkippedSilently() + { + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + namespace TestMod + { + internal static class MyConfig + { + private const string S = "Section"; + public static readonly IConfigValue X = Config.Bind(S, "Key", 100); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + result.Results.Should().NotBeEmpty(); + } + + [Fact] + public void ConfigManagerModOnNonAssemblyTarget_IsIgnored() + { + // Attribute parent check: only [assembly: ...] is honored. + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + namespace TestMod + { + [ConfigManagerMod(ModId = "x", ModName = "y")] + internal static class MyConfig + { + public static readonly IConfigValue Damage = + Config.Bind("Combat", "Damage", 100); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + result.Results.Should().NotBeEmpty(); + } + + [Fact] + public void ConfigManagerModMissingModId_IsIgnored() + { + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + [assembly: ConfigManagerMod(ModName = "Only Name")] + + namespace TestMod + { + internal static class MyConfig + { + public static readonly IConfigValue Damage = + Config.Bind("Combat", "Damage", 100); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + result.Results.Should().NotBeEmpty(); + } + + [Fact] + public void ConfigManagerModWithFallbackQualified_ExtractsEnumSuffix() + { + // Exercises StripEnumPrefix branch on a dotted enum expression. + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + [assembly: ConfigManagerMod( + ModId = "com.test", + ModName = "Test", + Fallback = ConfigFallback.InMemory)] + + namespace TestMod + { + internal static class MyConfig + { + public static readonly IConfigValue Damage = + Config.Bind("Combat", "Damage", 100); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + result.Results.Should().NotBeEmpty(); + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorSnapshotTests.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorSnapshotTests.cs new file mode 100644 index 0000000..8884441 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorSnapshotTests.cs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using System.Threading.Tasks; +using VerifyXunit; +using Xunit; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests +{ + public sealed class GeneratorSnapshotTests + { + [Fact] + public Task EmitsExpectedSources_ForSimpleBinding() + { + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + [assembly: ConfigManagerMod(ModId = "com.test.mod", ModName = "Test Mod", ModVersion = "1.0.0")] + + namespace TestMod + { + internal static class MyConfig + { + public static readonly IConfigValue Damage = Config.Bind("Combat", "Damage", 100); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + return Verifier.Verify(result, TestHelper.VerifySettings); + } + + [Fact] + public Task EmitsExpectedSources_ForMultipleBindingsAcrossTypes() + { + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + [assembly: ConfigManagerMod(ModId = "com.test.mod", ModName = "Test Mod")] + + namespace TestMod + { + internal static class Combat + { + public static readonly IConfigValue Damage = Config.Bind("Combat", "Damage", 100, minValue: 1, maxValue: 1000); + } + + internal static class Cheats + { + public static readonly IConfigValue GodMode = Config.Bind("Cheats", "GodMode", false, isAdvanced: true); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + return Verifier.Verify(result, TestHelper.VerifySettings); + } + + [Fact] + public Task EmitsRegistration_WhenModAttributeMissing() + { + const string Source = """ + using LobotomyCorporation.Mods.ConfigurationManager; + + namespace TestMod + { + internal static class MyConfig + { + public static readonly IConfigValue Damage = Config.Bind("Combat", "Damage", 100); + } + } + """; + + var result = TestHelper.RunGenerator(Source); + return Verifier.Verify(result, TestHelper.VerifySettings); + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests.csproj b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests.csproj new file mode 100644 index 0000000..f660bea --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + false + true + Exe + enable + latest + + + + + + + + + + + + diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/ModAttributeScannerTests.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/ModAttributeScannerTests.cs new file mode 100644 index 0000000..1372689 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/ModAttributeScannerTests.cs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using AwesomeAssertions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Xunit; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests +{ + public sealed class ModAttributeScannerTests + { + [Fact] + public void IsCandidate_ReturnsTrue_ForConfigManagerMod() + { + var node = ParseAttribute("ConfigManagerMod(ModId = \"x\", ModName = \"y\")"); + ModAttributeScanner.IsCandidate(node).Should().BeTrue(); + } + + [Fact] + public void IsCandidate_ReturnsTrue_ForConfigManagerModAttribute() + { + var node = ParseAttribute("ConfigManagerModAttribute(ModId = \"x\", ModName = \"y\")"); + ModAttributeScanner.IsCandidate(node).Should().BeTrue(); + } + + [Fact] + public void IsCandidate_ReturnsTrue_ForQualifiedName() + { + var node = ParseAttribute( + "LobotomyCorporation.Mods.ConfigurationManager.ConfigManagerMod(ModId = \"x\")" + ); + ModAttributeScanner.IsCandidate(node).Should().BeTrue(); + } + + [Fact] + public void IsCandidate_ReturnsFalse_ForUnrelatedAttribute() + { + var node = ParseAttribute("Obsolete"); + ModAttributeScanner.IsCandidate(node).Should().BeFalse(); + } + + [Fact] + public void IsCandidate_ReturnsFalse_ForNonAttributeSyntax() + { + var node = SyntaxFactory.ParseExpression("42"); + ModAttributeScanner.IsCandidate(node).Should().BeFalse(); + } + + private static AttributeSyntax ParseAttribute(string attributeBody) + { + var tree = CSharpSyntaxTree.ParseText($"[assembly: {attributeBody}]"); + var root = tree.GetCompilationUnitRoot(); + return root.AttributeLists[0].Attributes[0]; + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Api.g.verified.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Api.g.verified.cs new file mode 100644 index 0000000..d0dc53c --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Api.g.verified.cs @@ -0,0 +1,152 @@ +//HintName: ConfigurationManager.Api.g.cs +// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +using System; +using System.Collections.Generic; + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + /// + /// Read-only handle to a config value. Returned by Config.Bind. The underlying store is + /// either ConfigurationManager (if installed) or an in-memory fallback. + /// + internal interface IConfigValue + { + T Value { get; set; } + T DefaultValue { get; } + string Section { get; } + string Key { get; } + event EventHandler ValueChanged; + } + + internal sealed class ConfigValue : IConfigValue + { + private T _value; + private readonly T _defaultValue; + + internal ConfigValue(string section, string key, T defaultValue) + { + Section = section; + Key = key; + _defaultValue = defaultValue; + _value = defaultValue; + } + + public T Value + { + get { return _value; } + set + { + if (Equals(_value, value)) { return; } + _value = value; + var handler = ValueChanged; + if (handler != null) { handler(this, EventArgs.Empty); } + } + } + + public T DefaultValue { get { return _defaultValue; } } + public string Section { get; private set; } + public string Key { get; private set; } + public event EventHandler ValueChanged; + } + + /// + /// Strategy for what happens when ConfigurationManager is not installed at runtime. + /// + internal enum ConfigFallback + { + /// Bindings live in a non-persisted in-memory store. Values are lost on mod unload. + InMemory = 0, + + /// Reserved for a future release — not implemented in v1. Treated as InMemory at runtime. + FileBacked = 1, + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] + internal sealed class ConfigManagerModAttribute : Attribute + { + public string ModId { get; set; } + public string ModName { get; set; } + public string ModVersion { get; set; } + public ConfigFallback Fallback { get; set; } + } + + internal static partial class Config + { + internal sealed class BindingDescriptor + { + public string Section; + public string Key; + public Type ValueType; + public object DefaultValue; + public object ValueHandle; + public string Description; + public object MinValue; + public object MaxValue; + public object CmAttributes; + public Action ApplyFromCm; + public Action PushToCm; + } + + internal static readonly List _bindings = new List(); + + public static IConfigValue Bind( + string section, + string key, + T defaultValue, + string description = null, + T minValue = default(T), + T maxValue = default(T), + int? order = null, + bool? isAdvanced = null, + string category = null, + string dispName = null, + bool? browsable = null, + bool? readOnly = null, + bool? hideDefaultButton = null, + bool? hideSettingName = null, + bool? showRangeAsPercent = null, + bool? useIntegerSlider = null + ) + { + var value = new ConfigValue(section, key, defaultValue); + + var hasRange = !Equals(minValue, default(T)) || !Equals(maxValue, default(T)); + + var cmAttrs = new ConfigurationManagerAttributes + { + Order = order, + IsAdvanced = isAdvanced, + Category = category, + DispName = dispName, + Browsable = browsable, + ReadOnly = readOnly, + HideDefaultButton = hideDefaultButton, + HideSettingName = hideSettingName, + ShowRangeAsPercent = showRangeAsPercent, + UseIntegerSlider = useIntegerSlider, + Description = description, + }; + + _bindings.Add(new BindingDescriptor + { + Section = section, + Key = key, + ValueType = typeof(T), + DefaultValue = defaultValue, + ValueHandle = value, + Description = description, + MinValue = hasRange ? (object)minValue : null, + MaxValue = hasRange ? (object)maxValue : null, + CmAttributes = cmAttrs, + ApplyFromCm = boxed => { value.Value = (T)boxed; }, + PushToCm = null, + }); + + return value; + } + } +} +#pragma warning restore diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.CmAttributes.g.verified.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.CmAttributes.g.verified.cs new file mode 100644 index 0000000..5cfc156 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.CmAttributes.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: ConfigurationManager.CmAttributes.g.cs +// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + /// + /// Per-mod copy of ConfigurationManagerAttributes. ConfigurationManager discovers this + /// by simple type name, not by assembly identity, so each mod ships its own identical class. + /// + internal sealed class ConfigurationManagerAttributes + { + public bool? ShowRangeAsPercent { get; set; } + public bool? UseIntegerSlider { get; set; } + public bool? Browsable { get; set; } + public string Category { get; set; } + public object DefaultValue { get; set; } + public bool? HideDefaultButton { get; set; } + public bool? HideSettingName { get; set; } + public string Description { get; set; } + public string DispName { get; set; } + public int? Order { get; set; } + public bool? ReadOnly { get; set; } + public bool? IsAdvanced { get; set; } + } +} +#pragma warning restore diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Registration.g.verified.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Registration.g.verified.cs new file mode 100644 index 0000000..e57a151 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Registration.g.verified.cs @@ -0,0 +1,213 @@ +//HintName: ConfigurationManager.Registration.g.cs +// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + internal static partial class Config + { + private const string _modId = "com.test.mod"; + private const string _modName = "Test Mod"; + private const string _modVersion = ""; + + private static readonly Type[] _containingTypes = new Type[] + { + typeof(global::TestMod.Combat), + typeof(global::TestMod.Cheats), + }; + + private static bool _registered; + + /// Registers every Config.Bind declaration with ConfigurationManager if it is installed. + /// Safe to call more than once; subsequent calls are no-ops. If ConfigurationManager is absent, + /// bindings fall back to an in-memory store and RegisterAll returns without throwing. + public static void RegisterAll() + { + if (_registered) { return; } + _registered = true; + + foreach (var t in _containingTypes) + { + try + { + System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(t.TypeHandle); + } + catch (Exception ex) + { + TryLog("[ConfigurationManager.Integration] Failed to initialize " + t + ": " + ex); + } + } + + var lmmRegType = ResolveCmType("ConfigurationManager.Config.LmmConfigRegistration"); + if (lmmRegType == null) { return; } + + var descType = ResolveCmType("ConfigurationManager.Config.LmmConfigDescription"); + var acceptableValueType = ResolveCmType("ConfigurationManager.Config.IAcceptableValue"); + var rangeOpenGeneric = ResolveCmType("ConfigurationManager.Config.AcceptableValueRange`1"); + if (descType == null || acceptableValueType == null) + { + TryLog("[ConfigurationManager.Integration] ConfigurationManager version mismatch: required types missing. Falling back to in-memory."); + return; + } + + var descCtor = FindDescriptionConstructor(descType, acceptableValueType); + var registerOpen = FindRegisterMethod(lmmRegType, descType); + if (descCtor == null || registerOpen == null) + { + TryLog("[ConfigurationManager.Integration] ConfigurationManager version mismatch: required members missing. Falling back to in-memory."); + return; + } + + foreach (var binding in _bindings) + { + try + { + RegisterBinding(binding, descType, acceptableValueType, rangeOpenGeneric, descCtor, registerOpen); + } + catch (Exception ex) + { + TryLog("[ConfigurationManager.Integration] Failed to register " + binding.Section + "." + binding.Key + ": " + ex); + } + } + } + + private static Type ResolveCmType(string fullName) + { + var t = Type.GetType(fullName + ", ConfigurationManager"); + if (t != null) { return t; } + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + if (assembly.GetName().Name != "ConfigurationManager") { continue; } + var candidate = assembly.GetType(fullName); + if (candidate != null) { return candidate; } + } + catch { } + } + return null; + } + + private static ConstructorInfo FindDescriptionConstructor(Type descType, Type acceptableValueType) + { + foreach (var ctor in descType.GetConstructors()) + { + var p = ctor.GetParameters(); + if (p.Length == 3 && p[0].ParameterType == typeof(string) && p[1].ParameterType == acceptableValueType && p[2].ParameterType == typeof(object[])) + { + return ctor; + } + } + return null; + } + + private static MethodInfo FindRegisterMethod(Type lmmRegType, Type descType) + { + foreach (var m in lmmRegType.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != "Register" || !m.IsGenericMethodDefinition) { continue; } + var p = m.GetParameters(); + if (p.Length == 7 && p[5].ParameterType == descType) { return m; } + } + return null; + } + + private static void RegisterBinding(BindingDescriptor binding, Type descType, Type acceptableValueType, Type rangeOpenGeneric, ConstructorInfo descCtor, MethodInfo registerOpen) + { + object acceptableValue = null; + if (binding.MinValue != null && binding.MaxValue != null && rangeOpenGeneric != null) + { + var closedRange = rangeOpenGeneric.MakeGenericType(binding.ValueType); + acceptableValue = Activator.CreateInstance(closedRange, binding.MinValue, binding.MaxValue); + } + + var description = descCtor.Invoke(new object[] { binding.Description ?? string.Empty, acceptableValue, new object[] { binding.CmAttributes } }); + + var registerClosed = registerOpen.MakeGenericMethod(binding.ValueType); + var entry = registerClosed.Invoke(null, new object[] { _modId, _modName, binding.Section, binding.Key, binding.DefaultValue, description, _modVersion }); + if (entry == null) { return; } + + var entryType = entry.GetType(); + var valueProperty = entryType.GetProperty("Value", BindingFlags.Instance | BindingFlags.Public); + var settingChangedEvent = entryType.GetEvent("SettingChanged", BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy); + if (valueProperty == null || settingChangedEvent == null) { return; } + + // Seed the value handle with whatever CM loaded (may have been populated from the persisted config file). + binding.ApplyFromCm(valueProperty.GetValue(entry, null)); + + var sink = new BindingSink(binding, entry, valueProperty); + binding.PushToCm = sink.PushToCm; + + var handlerType = settingChangedEvent.EventHandlerType; + var handlerMethod = typeof(BindingSink).GetMethod("OnCmChanged", BindingFlags.Instance | BindingFlags.Public); + var handler = Delegate.CreateDelegate(handlerType, sink, handlerMethod); + settingChangedEvent.AddEventHandler(entry, handler); + + // Wire the local-to-CM direction: subscribing on a boxed IConfigValue means we + // reflect onto its ValueChanged event since T is only known at runtime here. + var valueHandleType = binding.ValueHandle.GetType(); + var valueChangedEvent = valueHandleType.GetEvent("ValueChanged"); + if (valueChangedEvent != null) + { + var localMethod = typeof(BindingSink).GetMethod("OnLocalChanged", BindingFlags.Instance | BindingFlags.Public); + var localHandler = Delegate.CreateDelegate(valueChangedEvent.EventHandlerType, sink, localMethod); + valueChangedEvent.AddEventHandler(binding.ValueHandle, localHandler); + } + } + + private static void TryLog(string message) + { + try { UnityEngine.Debug.Log(message); } + catch { } + } + + internal sealed class BindingSink + { + private readonly BindingDescriptor _binding; + private readonly object _cmEntry; + private readonly PropertyInfo _valueProperty; + [ThreadStatic] private static bool _suppress; + + public BindingSink(BindingDescriptor binding, object cmEntry, PropertyInfo valueProperty) + { + _binding = binding; + _cmEntry = cmEntry; + _valueProperty = valueProperty; + } + + public void OnCmChanged(object sender, EventArgs e) + { + if (_suppress) { return; } + _suppress = true; + try + { + _binding.ApplyFromCm(_valueProperty.GetValue(_cmEntry, null)); + } + finally { _suppress = false; } + } + + public void OnLocalChanged(object sender, EventArgs e) + { + if (_suppress) { return; } + _suppress = true; + try { PushToCm(_valueProperty.GetValue(_cmEntry, null)); } + finally { _suppress = false; } + } + + public void PushToCm(object newValue) + { + // Read the up-to-date value off the local handle reflectively; _cmEntry.Value is the push target. + var handleType = _binding.ValueHandle.GetType(); + var handleValue = handleType.GetProperty("Value").GetValue(_binding.ValueHandle, null); + _valueProperty.SetValue(_cmEntry, handleValue, null); + } + } + } +} +#pragma warning restore diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Api.g.verified.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Api.g.verified.cs new file mode 100644 index 0000000..d0dc53c --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Api.g.verified.cs @@ -0,0 +1,152 @@ +//HintName: ConfigurationManager.Api.g.cs +// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +using System; +using System.Collections.Generic; + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + /// + /// Read-only handle to a config value. Returned by Config.Bind. The underlying store is + /// either ConfigurationManager (if installed) or an in-memory fallback. + /// + internal interface IConfigValue + { + T Value { get; set; } + T DefaultValue { get; } + string Section { get; } + string Key { get; } + event EventHandler ValueChanged; + } + + internal sealed class ConfigValue : IConfigValue + { + private T _value; + private readonly T _defaultValue; + + internal ConfigValue(string section, string key, T defaultValue) + { + Section = section; + Key = key; + _defaultValue = defaultValue; + _value = defaultValue; + } + + public T Value + { + get { return _value; } + set + { + if (Equals(_value, value)) { return; } + _value = value; + var handler = ValueChanged; + if (handler != null) { handler(this, EventArgs.Empty); } + } + } + + public T DefaultValue { get { return _defaultValue; } } + public string Section { get; private set; } + public string Key { get; private set; } + public event EventHandler ValueChanged; + } + + /// + /// Strategy for what happens when ConfigurationManager is not installed at runtime. + /// + internal enum ConfigFallback + { + /// Bindings live in a non-persisted in-memory store. Values are lost on mod unload. + InMemory = 0, + + /// Reserved for a future release — not implemented in v1. Treated as InMemory at runtime. + FileBacked = 1, + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] + internal sealed class ConfigManagerModAttribute : Attribute + { + public string ModId { get; set; } + public string ModName { get; set; } + public string ModVersion { get; set; } + public ConfigFallback Fallback { get; set; } + } + + internal static partial class Config + { + internal sealed class BindingDescriptor + { + public string Section; + public string Key; + public Type ValueType; + public object DefaultValue; + public object ValueHandle; + public string Description; + public object MinValue; + public object MaxValue; + public object CmAttributes; + public Action ApplyFromCm; + public Action PushToCm; + } + + internal static readonly List _bindings = new List(); + + public static IConfigValue Bind( + string section, + string key, + T defaultValue, + string description = null, + T minValue = default(T), + T maxValue = default(T), + int? order = null, + bool? isAdvanced = null, + string category = null, + string dispName = null, + bool? browsable = null, + bool? readOnly = null, + bool? hideDefaultButton = null, + bool? hideSettingName = null, + bool? showRangeAsPercent = null, + bool? useIntegerSlider = null + ) + { + var value = new ConfigValue(section, key, defaultValue); + + var hasRange = !Equals(minValue, default(T)) || !Equals(maxValue, default(T)); + + var cmAttrs = new ConfigurationManagerAttributes + { + Order = order, + IsAdvanced = isAdvanced, + Category = category, + DispName = dispName, + Browsable = browsable, + ReadOnly = readOnly, + HideDefaultButton = hideDefaultButton, + HideSettingName = hideSettingName, + ShowRangeAsPercent = showRangeAsPercent, + UseIntegerSlider = useIntegerSlider, + Description = description, + }; + + _bindings.Add(new BindingDescriptor + { + Section = section, + Key = key, + ValueType = typeof(T), + DefaultValue = defaultValue, + ValueHandle = value, + Description = description, + MinValue = hasRange ? (object)minValue : null, + MaxValue = hasRange ? (object)maxValue : null, + CmAttributes = cmAttrs, + ApplyFromCm = boxed => { value.Value = (T)boxed; }, + PushToCm = null, + }); + + return value; + } + } +} +#pragma warning restore diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.CmAttributes.g.verified.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.CmAttributes.g.verified.cs new file mode 100644 index 0000000..5cfc156 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.CmAttributes.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: ConfigurationManager.CmAttributes.g.cs +// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + /// + /// Per-mod copy of ConfigurationManagerAttributes. ConfigurationManager discovers this + /// by simple type name, not by assembly identity, so each mod ships its own identical class. + /// + internal sealed class ConfigurationManagerAttributes + { + public bool? ShowRangeAsPercent { get; set; } + public bool? UseIntegerSlider { get; set; } + public bool? Browsable { get; set; } + public string Category { get; set; } + public object DefaultValue { get; set; } + public bool? HideDefaultButton { get; set; } + public bool? HideSettingName { get; set; } + public string Description { get; set; } + public string DispName { get; set; } + public int? Order { get; set; } + public bool? ReadOnly { get; set; } + public bool? IsAdvanced { get; set; } + } +} +#pragma warning restore diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Registration.g.verified.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Registration.g.verified.cs new file mode 100644 index 0000000..5b4f424 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Registration.g.verified.cs @@ -0,0 +1,212 @@ +//HintName: ConfigurationManager.Registration.g.cs +// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + internal static partial class Config + { + private const string _modId = "com.test.mod"; + private const string _modName = "Test Mod"; + private const string _modVersion = "1.0.0"; + + private static readonly Type[] _containingTypes = new Type[] + { + typeof(global::TestMod.MyConfig), + }; + + private static bool _registered; + + /// Registers every Config.Bind declaration with ConfigurationManager if it is installed. + /// Safe to call more than once; subsequent calls are no-ops. If ConfigurationManager is absent, + /// bindings fall back to an in-memory store and RegisterAll returns without throwing. + public static void RegisterAll() + { + if (_registered) { return; } + _registered = true; + + foreach (var t in _containingTypes) + { + try + { + System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(t.TypeHandle); + } + catch (Exception ex) + { + TryLog("[ConfigurationManager.Integration] Failed to initialize " + t + ": " + ex); + } + } + + var lmmRegType = ResolveCmType("ConfigurationManager.Config.LmmConfigRegistration"); + if (lmmRegType == null) { return; } + + var descType = ResolveCmType("ConfigurationManager.Config.LmmConfigDescription"); + var acceptableValueType = ResolveCmType("ConfigurationManager.Config.IAcceptableValue"); + var rangeOpenGeneric = ResolveCmType("ConfigurationManager.Config.AcceptableValueRange`1"); + if (descType == null || acceptableValueType == null) + { + TryLog("[ConfigurationManager.Integration] ConfigurationManager version mismatch: required types missing. Falling back to in-memory."); + return; + } + + var descCtor = FindDescriptionConstructor(descType, acceptableValueType); + var registerOpen = FindRegisterMethod(lmmRegType, descType); + if (descCtor == null || registerOpen == null) + { + TryLog("[ConfigurationManager.Integration] ConfigurationManager version mismatch: required members missing. Falling back to in-memory."); + return; + } + + foreach (var binding in _bindings) + { + try + { + RegisterBinding(binding, descType, acceptableValueType, rangeOpenGeneric, descCtor, registerOpen); + } + catch (Exception ex) + { + TryLog("[ConfigurationManager.Integration] Failed to register " + binding.Section + "." + binding.Key + ": " + ex); + } + } + } + + private static Type ResolveCmType(string fullName) + { + var t = Type.GetType(fullName + ", ConfigurationManager"); + if (t != null) { return t; } + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + if (assembly.GetName().Name != "ConfigurationManager") { continue; } + var candidate = assembly.GetType(fullName); + if (candidate != null) { return candidate; } + } + catch { } + } + return null; + } + + private static ConstructorInfo FindDescriptionConstructor(Type descType, Type acceptableValueType) + { + foreach (var ctor in descType.GetConstructors()) + { + var p = ctor.GetParameters(); + if (p.Length == 3 && p[0].ParameterType == typeof(string) && p[1].ParameterType == acceptableValueType && p[2].ParameterType == typeof(object[])) + { + return ctor; + } + } + return null; + } + + private static MethodInfo FindRegisterMethod(Type lmmRegType, Type descType) + { + foreach (var m in lmmRegType.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != "Register" || !m.IsGenericMethodDefinition) { continue; } + var p = m.GetParameters(); + if (p.Length == 7 && p[5].ParameterType == descType) { return m; } + } + return null; + } + + private static void RegisterBinding(BindingDescriptor binding, Type descType, Type acceptableValueType, Type rangeOpenGeneric, ConstructorInfo descCtor, MethodInfo registerOpen) + { + object acceptableValue = null; + if (binding.MinValue != null && binding.MaxValue != null && rangeOpenGeneric != null) + { + var closedRange = rangeOpenGeneric.MakeGenericType(binding.ValueType); + acceptableValue = Activator.CreateInstance(closedRange, binding.MinValue, binding.MaxValue); + } + + var description = descCtor.Invoke(new object[] { binding.Description ?? string.Empty, acceptableValue, new object[] { binding.CmAttributes } }); + + var registerClosed = registerOpen.MakeGenericMethod(binding.ValueType); + var entry = registerClosed.Invoke(null, new object[] { _modId, _modName, binding.Section, binding.Key, binding.DefaultValue, description, _modVersion }); + if (entry == null) { return; } + + var entryType = entry.GetType(); + var valueProperty = entryType.GetProperty("Value", BindingFlags.Instance | BindingFlags.Public); + var settingChangedEvent = entryType.GetEvent("SettingChanged", BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy); + if (valueProperty == null || settingChangedEvent == null) { return; } + + // Seed the value handle with whatever CM loaded (may have been populated from the persisted config file). + binding.ApplyFromCm(valueProperty.GetValue(entry, null)); + + var sink = new BindingSink(binding, entry, valueProperty); + binding.PushToCm = sink.PushToCm; + + var handlerType = settingChangedEvent.EventHandlerType; + var handlerMethod = typeof(BindingSink).GetMethod("OnCmChanged", BindingFlags.Instance | BindingFlags.Public); + var handler = Delegate.CreateDelegate(handlerType, sink, handlerMethod); + settingChangedEvent.AddEventHandler(entry, handler); + + // Wire the local-to-CM direction: subscribing on a boxed IConfigValue means we + // reflect onto its ValueChanged event since T is only known at runtime here. + var valueHandleType = binding.ValueHandle.GetType(); + var valueChangedEvent = valueHandleType.GetEvent("ValueChanged"); + if (valueChangedEvent != null) + { + var localMethod = typeof(BindingSink).GetMethod("OnLocalChanged", BindingFlags.Instance | BindingFlags.Public); + var localHandler = Delegate.CreateDelegate(valueChangedEvent.EventHandlerType, sink, localMethod); + valueChangedEvent.AddEventHandler(binding.ValueHandle, localHandler); + } + } + + private static void TryLog(string message) + { + try { UnityEngine.Debug.Log(message); } + catch { } + } + + internal sealed class BindingSink + { + private readonly BindingDescriptor _binding; + private readonly object _cmEntry; + private readonly PropertyInfo _valueProperty; + [ThreadStatic] private static bool _suppress; + + public BindingSink(BindingDescriptor binding, object cmEntry, PropertyInfo valueProperty) + { + _binding = binding; + _cmEntry = cmEntry; + _valueProperty = valueProperty; + } + + public void OnCmChanged(object sender, EventArgs e) + { + if (_suppress) { return; } + _suppress = true; + try + { + _binding.ApplyFromCm(_valueProperty.GetValue(_cmEntry, null)); + } + finally { _suppress = false; } + } + + public void OnLocalChanged(object sender, EventArgs e) + { + if (_suppress) { return; } + _suppress = true; + try { PushToCm(_valueProperty.GetValue(_cmEntry, null)); } + finally { _suppress = false; } + } + + public void PushToCm(object newValue) + { + // Read the up-to-date value off the local handle reflectively; _cmEntry.Value is the push target. + var handleType = _binding.ValueHandle.GetType(); + var handleValue = handleType.GetProperty("Value").GetValue(_binding.ValueHandle, null); + _valueProperty.SetValue(_cmEntry, handleValue, null); + } + } + } +} +#pragma warning restore diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Api.g.verified.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Api.g.verified.cs new file mode 100644 index 0000000..d0dc53c --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Api.g.verified.cs @@ -0,0 +1,152 @@ +//HintName: ConfigurationManager.Api.g.cs +// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +using System; +using System.Collections.Generic; + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + /// + /// Read-only handle to a config value. Returned by Config.Bind. The underlying store is + /// either ConfigurationManager (if installed) or an in-memory fallback. + /// + internal interface IConfigValue + { + T Value { get; set; } + T DefaultValue { get; } + string Section { get; } + string Key { get; } + event EventHandler ValueChanged; + } + + internal sealed class ConfigValue : IConfigValue + { + private T _value; + private readonly T _defaultValue; + + internal ConfigValue(string section, string key, T defaultValue) + { + Section = section; + Key = key; + _defaultValue = defaultValue; + _value = defaultValue; + } + + public T Value + { + get { return _value; } + set + { + if (Equals(_value, value)) { return; } + _value = value; + var handler = ValueChanged; + if (handler != null) { handler(this, EventArgs.Empty); } + } + } + + public T DefaultValue { get { return _defaultValue; } } + public string Section { get; private set; } + public string Key { get; private set; } + public event EventHandler ValueChanged; + } + + /// + /// Strategy for what happens when ConfigurationManager is not installed at runtime. + /// + internal enum ConfigFallback + { + /// Bindings live in a non-persisted in-memory store. Values are lost on mod unload. + InMemory = 0, + + /// Reserved for a future release — not implemented in v1. Treated as InMemory at runtime. + FileBacked = 1, + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] + internal sealed class ConfigManagerModAttribute : Attribute + { + public string ModId { get; set; } + public string ModName { get; set; } + public string ModVersion { get; set; } + public ConfigFallback Fallback { get; set; } + } + + internal static partial class Config + { + internal sealed class BindingDescriptor + { + public string Section; + public string Key; + public Type ValueType; + public object DefaultValue; + public object ValueHandle; + public string Description; + public object MinValue; + public object MaxValue; + public object CmAttributes; + public Action ApplyFromCm; + public Action PushToCm; + } + + internal static readonly List _bindings = new List(); + + public static IConfigValue Bind( + string section, + string key, + T defaultValue, + string description = null, + T minValue = default(T), + T maxValue = default(T), + int? order = null, + bool? isAdvanced = null, + string category = null, + string dispName = null, + bool? browsable = null, + bool? readOnly = null, + bool? hideDefaultButton = null, + bool? hideSettingName = null, + bool? showRangeAsPercent = null, + bool? useIntegerSlider = null + ) + { + var value = new ConfigValue(section, key, defaultValue); + + var hasRange = !Equals(minValue, default(T)) || !Equals(maxValue, default(T)); + + var cmAttrs = new ConfigurationManagerAttributes + { + Order = order, + IsAdvanced = isAdvanced, + Category = category, + DispName = dispName, + Browsable = browsable, + ReadOnly = readOnly, + HideDefaultButton = hideDefaultButton, + HideSettingName = hideSettingName, + ShowRangeAsPercent = showRangeAsPercent, + UseIntegerSlider = useIntegerSlider, + Description = description, + }; + + _bindings.Add(new BindingDescriptor + { + Section = section, + Key = key, + ValueType = typeof(T), + DefaultValue = defaultValue, + ValueHandle = value, + Description = description, + MinValue = hasRange ? (object)minValue : null, + MaxValue = hasRange ? (object)maxValue : null, + CmAttributes = cmAttrs, + ApplyFromCm = boxed => { value.Value = (T)boxed; }, + PushToCm = null, + }); + + return value; + } + } +} +#pragma warning restore diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.CmAttributes.g.verified.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.CmAttributes.g.verified.cs new file mode 100644 index 0000000..5cfc156 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.CmAttributes.g.verified.cs @@ -0,0 +1,28 @@ +//HintName: ConfigurationManager.CmAttributes.g.cs +// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + /// + /// Per-mod copy of ConfigurationManagerAttributes. ConfigurationManager discovers this + /// by simple type name, not by assembly identity, so each mod ships its own identical class. + /// + internal sealed class ConfigurationManagerAttributes + { + public bool? ShowRangeAsPercent { get; set; } + public bool? UseIntegerSlider { get; set; } + public bool? Browsable { get; set; } + public string Category { get; set; } + public object DefaultValue { get; set; } + public bool? HideDefaultButton { get; set; } + public bool? HideSettingName { get; set; } + public string Description { get; set; } + public string DispName { get; set; } + public int? Order { get; set; } + public bool? ReadOnly { get; set; } + public bool? IsAdvanced { get; set; } + } +} +#pragma warning restore diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Registration.g.verified.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Registration.g.verified.cs new file mode 100644 index 0000000..0c07980 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Registration.g.verified.cs @@ -0,0 +1,212 @@ +//HintName: ConfigurationManager.Registration.g.cs +// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + internal static partial class Config + { + private const string _modId = "unknown.mod"; + private const string _modName = "Unknown Mod"; + private const string _modVersion = ""; + + private static readonly Type[] _containingTypes = new Type[] + { + typeof(global::TestMod.MyConfig), + }; + + private static bool _registered; + + /// Registers every Config.Bind declaration with ConfigurationManager if it is installed. + /// Safe to call more than once; subsequent calls are no-ops. If ConfigurationManager is absent, + /// bindings fall back to an in-memory store and RegisterAll returns without throwing. + public static void RegisterAll() + { + if (_registered) { return; } + _registered = true; + + foreach (var t in _containingTypes) + { + try + { + System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(t.TypeHandle); + } + catch (Exception ex) + { + TryLog("[ConfigurationManager.Integration] Failed to initialize " + t + ": " + ex); + } + } + + var lmmRegType = ResolveCmType("ConfigurationManager.Config.LmmConfigRegistration"); + if (lmmRegType == null) { return; } + + var descType = ResolveCmType("ConfigurationManager.Config.LmmConfigDescription"); + var acceptableValueType = ResolveCmType("ConfigurationManager.Config.IAcceptableValue"); + var rangeOpenGeneric = ResolveCmType("ConfigurationManager.Config.AcceptableValueRange`1"); + if (descType == null || acceptableValueType == null) + { + TryLog("[ConfigurationManager.Integration] ConfigurationManager version mismatch: required types missing. Falling back to in-memory."); + return; + } + + var descCtor = FindDescriptionConstructor(descType, acceptableValueType); + var registerOpen = FindRegisterMethod(lmmRegType, descType); + if (descCtor == null || registerOpen == null) + { + TryLog("[ConfigurationManager.Integration] ConfigurationManager version mismatch: required members missing. Falling back to in-memory."); + return; + } + + foreach (var binding in _bindings) + { + try + { + RegisterBinding(binding, descType, acceptableValueType, rangeOpenGeneric, descCtor, registerOpen); + } + catch (Exception ex) + { + TryLog("[ConfigurationManager.Integration] Failed to register " + binding.Section + "." + binding.Key + ": " + ex); + } + } + } + + private static Type ResolveCmType(string fullName) + { + var t = Type.GetType(fullName + ", ConfigurationManager"); + if (t != null) { return t; } + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + try + { + if (assembly.GetName().Name != "ConfigurationManager") { continue; } + var candidate = assembly.GetType(fullName); + if (candidate != null) { return candidate; } + } + catch { } + } + return null; + } + + private static ConstructorInfo FindDescriptionConstructor(Type descType, Type acceptableValueType) + { + foreach (var ctor in descType.GetConstructors()) + { + var p = ctor.GetParameters(); + if (p.Length == 3 && p[0].ParameterType == typeof(string) && p[1].ParameterType == acceptableValueType && p[2].ParameterType == typeof(object[])) + { + return ctor; + } + } + return null; + } + + private static MethodInfo FindRegisterMethod(Type lmmRegType, Type descType) + { + foreach (var m in lmmRegType.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + if (m.Name != "Register" || !m.IsGenericMethodDefinition) { continue; } + var p = m.GetParameters(); + if (p.Length == 7 && p[5].ParameterType == descType) { return m; } + } + return null; + } + + private static void RegisterBinding(BindingDescriptor binding, Type descType, Type acceptableValueType, Type rangeOpenGeneric, ConstructorInfo descCtor, MethodInfo registerOpen) + { + object acceptableValue = null; + if (binding.MinValue != null && binding.MaxValue != null && rangeOpenGeneric != null) + { + var closedRange = rangeOpenGeneric.MakeGenericType(binding.ValueType); + acceptableValue = Activator.CreateInstance(closedRange, binding.MinValue, binding.MaxValue); + } + + var description = descCtor.Invoke(new object[] { binding.Description ?? string.Empty, acceptableValue, new object[] { binding.CmAttributes } }); + + var registerClosed = registerOpen.MakeGenericMethod(binding.ValueType); + var entry = registerClosed.Invoke(null, new object[] { _modId, _modName, binding.Section, binding.Key, binding.DefaultValue, description, _modVersion }); + if (entry == null) { return; } + + var entryType = entry.GetType(); + var valueProperty = entryType.GetProperty("Value", BindingFlags.Instance | BindingFlags.Public); + var settingChangedEvent = entryType.GetEvent("SettingChanged", BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy); + if (valueProperty == null || settingChangedEvent == null) { return; } + + // Seed the value handle with whatever CM loaded (may have been populated from the persisted config file). + binding.ApplyFromCm(valueProperty.GetValue(entry, null)); + + var sink = new BindingSink(binding, entry, valueProperty); + binding.PushToCm = sink.PushToCm; + + var handlerType = settingChangedEvent.EventHandlerType; + var handlerMethod = typeof(BindingSink).GetMethod("OnCmChanged", BindingFlags.Instance | BindingFlags.Public); + var handler = Delegate.CreateDelegate(handlerType, sink, handlerMethod); + settingChangedEvent.AddEventHandler(entry, handler); + + // Wire the local-to-CM direction: subscribing on a boxed IConfigValue means we + // reflect onto its ValueChanged event since T is only known at runtime here. + var valueHandleType = binding.ValueHandle.GetType(); + var valueChangedEvent = valueHandleType.GetEvent("ValueChanged"); + if (valueChangedEvent != null) + { + var localMethod = typeof(BindingSink).GetMethod("OnLocalChanged", BindingFlags.Instance | BindingFlags.Public); + var localHandler = Delegate.CreateDelegate(valueChangedEvent.EventHandlerType, sink, localMethod); + valueChangedEvent.AddEventHandler(binding.ValueHandle, localHandler); + } + } + + private static void TryLog(string message) + { + try { UnityEngine.Debug.Log(message); } + catch { } + } + + internal sealed class BindingSink + { + private readonly BindingDescriptor _binding; + private readonly object _cmEntry; + private readonly PropertyInfo _valueProperty; + [ThreadStatic] private static bool _suppress; + + public BindingSink(BindingDescriptor binding, object cmEntry, PropertyInfo valueProperty) + { + _binding = binding; + _cmEntry = cmEntry; + _valueProperty = valueProperty; + } + + public void OnCmChanged(object sender, EventArgs e) + { + if (_suppress) { return; } + _suppress = true; + try + { + _binding.ApplyFromCm(_valueProperty.GetValue(_cmEntry, null)); + } + finally { _suppress = false; } + } + + public void OnLocalChanged(object sender, EventArgs e) + { + if (_suppress) { return; } + _suppress = true; + try { PushToCm(_valueProperty.GetValue(_cmEntry, null)); } + finally { _suppress = false; } + } + + public void PushToCm(object newValue) + { + // Read the up-to-date value off the local handle reflectively; _cmEntry.Value is the push target. + var handleType = _binding.ValueHandle.GetType(); + var handleValue = handleType.GetProperty("Value").GetValue(_binding.ValueHandle, null); + _valueProperty.SetValue(_cmEntry, handleValue, null); + } + } + } +} +#pragma warning restore diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/TestHelper.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/TestHelper.cs new file mode 100644 index 0000000..86b7a84 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/TestHelper.cs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using VerifyTests; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests +{ + internal static class TestHelper + { + internal static readonly VerifySettings VerifySettings = CreateSettings(); + + public static GeneratorDriverRunResult RunGenerator(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var references = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location), + }; + var compilation = CSharpCompilation.Create( + "TestMod", + [syntaxTree], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var generator = new ConfigurationManagerGenerator(); + var driver = CSharpGeneratorDriver.Create(generator).RunGenerators(compilation); + return driver.GetRunResult(); + } + + private static VerifySettings CreateSettings() + { + VerifySourceGenerators.Initialize(); + var settings = new VerifySettings(); + settings.UseDirectory("Snapshots"); + return settings; + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Shipped.md b/LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..f50bb1f --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Unshipped.md b/LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..9630569 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Unshipped.md @@ -0,0 +1,9 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +LCM001 | ConfigurationManager.Integration | Warning | Duplicate config binding +LCM002 | ConfigurationManager.Integration | Warning | Duplicate ConfigManagerMod attribute +LCM003 | ConfigurationManager.Integration | Info | Missing ConfigManagerMod attribute diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingModel.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingModel.cs new file mode 100644 index 0000000..42b98a7 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingModel.cs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration +{ + /// + /// Immutable description of a single Config.Bind invocation discovered by the scanner. + /// Equality is value-based so the incremental generator can deduplicate cache entries. + /// Location is intentionally excluded from equality — two identical bindings at different + /// call sites should still be treated as structurally equal. + /// + /// Fully-qualified name of the type declaring the binding. + /// Config section name the binding lives under. + /// Config key within the section. + /// Fully-qualified CLR type of the bound value. + /// Textual form of the default value expression. + /// Named arguments (UI hints) supplied to Bind. + /// Source location of the Bind invocation for diagnostics. + internal sealed class BindingModel( + string containingTypeFullName, + string section, + string key, + string valueTypeFullName, + string defaultValueLiteral, + IReadOnlyDictionary namedArguments, + Location location + ) : IEquatable + { + public string ContainingTypeFullName { get; } = containingTypeFullName; + public string Section { get; } = section; + public string Key { get; } = key; + public string ValueTypeFullName { get; } = valueTypeFullName; + public string DefaultValueLiteral { get; } = defaultValueLiteral; + public IReadOnlyDictionary NamedArguments { get; } = namedArguments; + public Location Location { get; } = location; + + public bool Equals(BindingModel? other) + { + if (other is null) + { + return false; + } + + if ( + ContainingTypeFullName != other.ContainingTypeFullName + || Section != other.Section + || Key != other.Key + || ValueTypeFullName != other.ValueTypeFullName + || DefaultValueLiteral != other.DefaultValueLiteral + || NamedArguments.Count != other.NamedArguments.Count + ) + { + return false; + } + + foreach (var kvp in NamedArguments) + { + if ( + !other.NamedArguments.TryGetValue(kvp.Key, out var otherVal) + || otherVal != kvp.Value + ) + { + return false; + } + } + + return true; + } + + public override bool Equals(object? obj) + { + return Equals(obj as BindingModel); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) ^ ContainingTypeFullName.GetHashCode(); + hash = (hash * 31) ^ Section.GetHashCode(); + hash = (hash * 31) ^ Key.GetHashCode(); + hash = (hash * 31) ^ ValueTypeFullName.GetHashCode(); + hash = (hash * 31) ^ DefaultValueLiteral.GetHashCode(); + hash = (hash * 31) ^ NamedArguments.Count; + return hash; + } + } + } + + /// + /// Model for a discovered [assembly: ConfigManagerMod(...)] attribute. + /// + /// Unique mod identifier. + /// Human-readable mod display name. + /// Optional mod version string. + /// Behavior when ConfigurationManager is not installed. + /// Source location of the attribute for diagnostics. + internal sealed class ModAttributeModel( + string modId, + string modName, + string modVersion, + string fallback, + Location location + ) : IEquatable + { + public string ModId { get; } = modId; + public string ModName { get; } = modName; + public string ModVersion { get; } = modVersion; + public string Fallback { get; } = fallback; + public Location Location { get; } = location; + + public bool Equals(ModAttributeModel? other) + { + if (other is null) + { + return false; + } + + return ModId == other.ModId + && ModName == other.ModName + && ModVersion == other.ModVersion + && Fallback == other.Fallback; + } + + public override bool Equals(object? obj) + { + return Equals(obj as ModAttributeModel); + } + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) ^ (ModId?.GetHashCode() ?? 0); + hash = (hash * 31) ^ (ModName?.GetHashCode() ?? 0); + hash = (hash * 31) ^ (ModVersion?.GetHashCode() ?? 0); + hash = (hash * 31) ^ (Fallback?.GetHashCode() ?? 0); + return hash; + } + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingScanner.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingScanner.cs new file mode 100644 index 0000000..fb1a2fc --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingScanner.cs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using System; +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration +{ + /// + /// Text-level syntax scan for Config.Bind(...) invocations. Uses identifier + /// name matching rather than symbol resolution — aliasing Config via a using + /// directive is intentionally unsupported (documented in the package README). + /// + internal static class BindingScanner + { + public static bool IsBindCandidate(SyntaxNode node) + { + if (node is not InvocationExpressionSyntax invocation) + { + return false; + } + + if (invocation.Expression is not MemberAccessExpressionSyntax member) + { + return false; + } + + if (member.Name.Identifier.ValueText != "Bind") + { + return false; + } + + if (member.Expression is not IdentifierNameSyntax typeName) + { + return false; + } + + return typeName.Identifier.ValueText == "Config"; + } + + public static BindingModel? TryExtract( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel + ) + { + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 3) + { + return null; + } + + var section = TryGetStringLiteral(arguments[0].Expression); + var key = TryGetStringLiteral(arguments[1].Expression); + if (section is null || key is null) + { + return null; + } + + var defaultValueExpression = arguments[2].Expression; + var typeInfo = semanticModel.GetTypeInfo(defaultValueExpression); + var valueType = typeInfo.ConvertedType ?? typeInfo.Type; + if (valueType is null || valueType.TypeKind == TypeKind.Error) + { + return null; + } + + var containingType = FindContainingType(invocation); + if (containingType is null) + { + return null; + } + + if (semanticModel.GetDeclaredSymbol(containingType) is not INamedTypeSymbol namedType) + { + return null; + } + + var namedArguments = new Dictionary(StringComparer.Ordinal); + for (var i = 3; i < arguments.Count; i++) + { + var arg = arguments[i]; + if (arg.NameColon is null) + { + continue; + } + + var name = arg.NameColon.Name.Identifier.ValueText; + namedArguments[name] = arg.Expression.ToString(); + } + + return new BindingModel( + containingTypeFullName: namedType.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + section: section, + key: key, + valueTypeFullName: valueType.ToDisplayString( + SymbolDisplayFormat.FullyQualifiedFormat + ), + defaultValueLiteral: defaultValueExpression.ToString(), + namedArguments: namedArguments, + location: invocation.GetLocation() + ); + } + + private static string? TryGetStringLiteral(ExpressionSyntax expression) + { + if ( + expression is LiteralExpressionSyntax literal + && literal.IsKind(SyntaxKind.StringLiteralExpression) + ) + { + return literal.Token.ValueText; + } + + return null; + } + + private static TypeDeclarationSyntax? FindContainingType(SyntaxNode node) + { + var current = node.Parent; + while (current is not null) + { + if (current is TypeDeclarationSyntax typeDecl) + { + return typeDecl; + } + + current = current.Parent; + } + + return null; + } + } + + /// + /// Syntax scan for the [assembly: ConfigManagerMod(...)] marker attribute. + /// + internal static class ModAttributeScanner + { + public static bool IsCandidate(SyntaxNode node) + { + if (node is not AttributeSyntax attribute) + { + return false; + } + + var name = attribute.Name switch + { + IdentifierNameSyntax id => id.Identifier.ValueText, + QualifiedNameSyntax q => q.Right.Identifier.ValueText, + _ => null, + }; + + return name is "ConfigManagerMod" or "ConfigManagerModAttribute"; + } + + public static ModAttributeModel? TryExtract( + AttributeSyntax attribute, + SemanticModel semanticModel + ) + { + if (attribute.Parent is not AttributeListSyntax attrList) + { + return null; + } + + if (attrList.Target?.Identifier.ValueText != "assembly") + { + return null; + } + + string? modId = null; + string? modName = null; + string? modVersion = null; + var fallback = "InMemory"; + + if (attribute.ArgumentList is null) + { + return null; + } + + foreach (var arg in attribute.ArgumentList.Arguments) + { + var name = arg.NameEquals?.Name.Identifier.ValueText; + if (name is null) + { + continue; + } + + var constant = semanticModel.GetConstantValue(arg.Expression); + var value = constant.HasValue + ? constant.Value?.ToString() + : arg.Expression.ToString(); + + switch (name) + { + case "ModId": + modId = value; + break; + case "ModName": + modName = value; + break; + case "ModVersion": + modVersion = value; + break; + case "Fallback": + fallback = StripEnumPrefix(arg.Expression.ToString()); + break; + } + } + + if (string.IsNullOrEmpty(modId) || string.IsNullOrEmpty(modName)) + { + return null; + } + + return new ModAttributeModel( + modId!, + modName!, + modVersion ?? string.Empty, + fallback, + attribute.GetLocation() + ); + } + + private static string StripEnumPrefix(string expression) + { + var dot = expression.LastIndexOf('.'); + return dot >= 0 ? expression.Substring(dot + 1) : expression; + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/Diagnostics/DiagnosticDescriptors.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Diagnostics/DiagnosticDescriptors.cs new file mode 100644 index 0000000..7668f7a --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Diagnostics/DiagnosticDescriptors.cs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using Microsoft.CodeAnalysis; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Diagnostics +{ + internal static class DiagnosticDescriptors + { + private const string Category = "ConfigurationManager.Integration"; + + public static readonly DiagnosticDescriptor DuplicateBinding = new( + id: "LCM001", + title: "Duplicate config binding", + messageFormat: "A Config.Bind for section '{0}' key '{1}' is defined more than once; only the first will be registered", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor DuplicateModAttribute = new( + id: "LCM002", + title: "Duplicate ConfigManagerMod attribute", + messageFormat: "More than one [assembly: ConfigManagerMod] attribute was found; only the first is honored", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor MissingModAttribute = new( + id: "LCM003", + title: "Missing ConfigManagerMod attribute", + messageFormat: "No [assembly: ConfigManagerMod] attribute was found; Config.RegisterAll will use the assembly name as a fallback", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true + ); + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/ApiSurfaceEmitter.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/ApiSurfaceEmitter.cs new file mode 100644 index 0000000..489a8fc --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/ApiSurfaceEmitter.cs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Emit +{ + /// + /// Author-visible API surface that Config.Bind(...) call sites compile against: + /// the Config partial static class with overloads, the IConfigValue<T> and + /// ConfigValue<T> pair, the ConfigManagerMod marker attribute, and the + /// ConfigFallback enum. Emitted as post-init source so the author's code compiles + /// before the binding scanner runs. + /// + internal static class ApiSurfaceEmitter + { + public const string Source = + @"// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +using System; +using System.Collections.Generic; + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + /// + /// Read-only handle to a config value. Returned by Config.Bind. The underlying store is + /// either ConfigurationManager (if installed) or an in-memory fallback. + /// + internal interface IConfigValue + { + T Value { get; set; } + T DefaultValue { get; } + string Section { get; } + string Key { get; } + event EventHandler ValueChanged; + } + + internal sealed class ConfigValue : IConfigValue + { + private T _value; + private readonly T _defaultValue; + + internal ConfigValue(string section, string key, T defaultValue) + { + Section = section; + Key = key; + _defaultValue = defaultValue; + _value = defaultValue; + } + + public T Value + { + get { return _value; } + set + { + if (Equals(_value, value)) { return; } + _value = value; + var handler = ValueChanged; + if (handler != null) { handler(this, EventArgs.Empty); } + } + } + + public T DefaultValue { get { return _defaultValue; } } + public string Section { get; private set; } + public string Key { get; private set; } + public event EventHandler ValueChanged; + } + + /// + /// Strategy for what happens when ConfigurationManager is not installed at runtime. + /// + internal enum ConfigFallback + { + /// Bindings live in a non-persisted in-memory store. Values are lost on mod unload. + InMemory = 0, + + /// Reserved for a future release — not implemented in v1. Treated as InMemory at runtime. + FileBacked = 1, + } + + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] + internal sealed class ConfigManagerModAttribute : Attribute + { + public string ModId { get; set; } + public string ModName { get; set; } + public string ModVersion { get; set; } + public ConfigFallback Fallback { get; set; } + } + + internal static partial class Config + { + internal sealed class BindingDescriptor + { + public string Section; + public string Key; + public Type ValueType; + public object DefaultValue; + public object ValueHandle; + public string Description; + public object MinValue; + public object MaxValue; + public object CmAttributes; + public Action ApplyFromCm; + public Action PushToCm; + } + + internal static readonly List _bindings = new List(); + + public static IConfigValue Bind( + string section, + string key, + T defaultValue, + string description = null, + T minValue = default(T), + T maxValue = default(T), + int? order = null, + bool? isAdvanced = null, + string category = null, + string dispName = null, + bool? browsable = null, + bool? readOnly = null, + bool? hideDefaultButton = null, + bool? hideSettingName = null, + bool? showRangeAsPercent = null, + bool? useIntegerSlider = null + ) + { + var value = new ConfigValue(section, key, defaultValue); + + var hasRange = !Equals(minValue, default(T)) || !Equals(maxValue, default(T)); + + var cmAttrs = new ConfigurationManagerAttributes + { + Order = order, + IsAdvanced = isAdvanced, + Category = category, + DispName = dispName, + Browsable = browsable, + ReadOnly = readOnly, + HideDefaultButton = hideDefaultButton, + HideSettingName = hideSettingName, + ShowRangeAsPercent = showRangeAsPercent, + UseIntegerSlider = useIntegerSlider, + Description = description, + }; + + _bindings.Add(new BindingDescriptor + { + Section = section, + Key = key, + ValueType = typeof(T), + DefaultValue = defaultValue, + ValueHandle = value, + Description = description, + MinValue = hasRange ? (object)minValue : null, + MaxValue = hasRange ? (object)maxValue : null, + CmAttributes = cmAttrs, + ApplyFromCm = boxed => { value.Value = (T)boxed; }, + PushToCm = null, + }); + + return value; + } + } +} +#pragma warning restore +"; + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/CmAttributesEmitter.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/CmAttributesEmitter.cs new file mode 100644 index 0000000..1dd1dcb --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/CmAttributesEmitter.cs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Emit +{ + /// + /// Copy of ConfigurationManagerAttributes emitted into the mod's own assembly. + /// CM reads these via simple-name reflection and GetProperties(), so the emitted + /// type matches upstream's property names exactly and uses Nullable<T> for + /// every optional hint so unset values are skipped by CM's if (val != null) check. + /// + internal static class CmAttributesEmitter + { + public const string Source = + @"// +// SPDX-License-Identifier: LGPL-3.0-or-later +#pragma warning disable + +namespace LobotomyCorporation.Mods.ConfigurationManager +{ + /// + /// Per-mod copy of ConfigurationManagerAttributes. ConfigurationManager discovers this + /// by simple type name, not by assembly identity, so each mod ships its own identical class. + /// + internal sealed class ConfigurationManagerAttributes + { + public bool? ShowRangeAsPercent { get; set; } + public bool? UseIntegerSlider { get; set; } + public bool? Browsable { get; set; } + public string Category { get; set; } + public object DefaultValue { get; set; } + public bool? HideDefaultButton { get; set; } + public bool? HideSettingName { get; set; } + public string Description { get; set; } + public string DispName { get; set; } + public int? Order { get; set; } + public bool? ReadOnly { get; set; } + public bool? IsAdvanced { get; set; } + } +} +#pragma warning restore +"; + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/RegistrationEmitter.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/RegistrationEmitter.cs new file mode 100644 index 0000000..8df239c --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/RegistrationEmitter.cs @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Text; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration.Emit +{ + /// + /// Emits the second partial of Config containing RegisterAll() plus the reflective + /// interop that binds the mod's ConfigValue<T> handles to ConfigurationManager's + /// LmmConfigEntry<T> at runtime. Hardcodes the set of containing types to force + /// their static constructors so field initializers that call Config.Bind are guaranteed + /// to have run before registration begins. + /// + internal static class RegistrationEmitter + { + public static string Emit( + ImmutableArray bindings, + ModAttributeModel? modAttribute + ) + { + var sb = new StringBuilder(); + _ = sb.AppendLine("// "); + _ = sb.AppendLine("// SPDX-License-Identifier: LGPL-3.0-or-later"); + _ = sb.AppendLine("#pragma warning disable"); + _ = sb.AppendLine(); + _ = sb.AppendLine("using System;"); + _ = sb.AppendLine("using System.Collections.Generic;"); + _ = sb.AppendLine("using System.Reflection;"); + _ = sb.AppendLine(); + _ = sb.AppendLine("namespace LobotomyCorporation.Mods.ConfigurationManager"); + _ = sb.AppendLine("{"); + _ = sb.AppendLine(" internal static partial class Config"); + _ = sb.AppendLine(" {"); + EmitModIdentity(sb, modAttribute); + EmitContainingTypeList(sb, bindings); + EmitRegisterAll(sb); + EmitCmInteropHelpers(sb); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine("}"); + _ = sb.AppendLine("#pragma warning restore"); + return sb.ToString(); + } + + private static void EmitModIdentity(StringBuilder sb, ModAttributeModel? modAttribute) + { + var modId = modAttribute?.ModId ?? "unknown.mod"; + var modName = modAttribute?.ModName ?? "Unknown Mod"; + var modVersion = modAttribute?.ModVersion ?? string.Empty; + _ = sb.AppendLine(" private const string _modId = " + Literal(modId) + ";"); + _ = sb.AppendLine(" private const string _modName = " + Literal(modName) + ";"); + _ = sb.AppendLine( + " private const string _modVersion = " + Literal(modVersion) + ";" + ); + _ = sb.AppendLine(); + } + + private static void EmitContainingTypeList( + StringBuilder sb, + ImmutableArray bindings + ) + { + var seen = new HashSet(System.StringComparer.Ordinal); + _ = sb.AppendLine( + " private static readonly Type[] _containingTypes = new Type[]" + ); + _ = sb.AppendLine(" {"); + foreach (var binding in bindings) + { + if (!seen.Add(binding.ContainingTypeFullName)) + { + continue; + } + + _ = sb.AppendLine(" typeof(" + binding.ContainingTypeFullName + "),"); + } + + _ = sb.AppendLine(" };"); + _ = sb.AppendLine(); + } + + private static void EmitRegisterAll(StringBuilder sb) + { + _ = sb.AppendLine(" private static bool _registered;"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " /// Registers every Config.Bind declaration with ConfigurationManager if it is installed." + ); + _ = sb.AppendLine( + " /// Safe to call more than once; subsequent calls are no-ops. If ConfigurationManager is absent," + ); + _ = sb.AppendLine( + " /// bindings fall back to an in-memory store and RegisterAll returns without throwing." + ); + _ = sb.AppendLine(" public static void RegisterAll()"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" if (_registered) { return; }"); + _ = sb.AppendLine(" _registered = true;"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" foreach (var t in _containingTypes)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" try"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " System.Runtime.CompilerServices.RuntimeHelpers.RunClassConstructor(t.TypeHandle);" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" catch (Exception ex)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " TryLog(\"[ConfigurationManager.Integration] Failed to initialize \" + t + \": \" + ex);" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var lmmRegType = ResolveCmType(\"ConfigurationManager.Config.LmmConfigRegistration\");" + ); + _ = sb.AppendLine(" if (lmmRegType == null) { return; }"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var descType = ResolveCmType(\"ConfigurationManager.Config.LmmConfigDescription\");" + ); + _ = sb.AppendLine( + " var acceptableValueType = ResolveCmType(\"ConfigurationManager.Config.IAcceptableValue\");" + ); + _ = sb.AppendLine( + " var rangeOpenGeneric = ResolveCmType(\"ConfigurationManager.Config.AcceptableValueRange`1\");" + ); + _ = sb.AppendLine(" if (descType == null || acceptableValueType == null)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " TryLog(\"[ConfigurationManager.Integration] ConfigurationManager version mismatch: required types missing. Falling back to in-memory.\");" + ); + _ = sb.AppendLine(" return;"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var descCtor = FindDescriptionConstructor(descType, acceptableValueType);" + ); + _ = sb.AppendLine( + " var registerOpen = FindRegisterMethod(lmmRegType, descType);" + ); + _ = sb.AppendLine(" if (descCtor == null || registerOpen == null)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " TryLog(\"[ConfigurationManager.Integration] ConfigurationManager version mismatch: required members missing. Falling back to in-memory.\");" + ); + _ = sb.AppendLine(" return;"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" foreach (var binding in _bindings)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" try"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " RegisterBinding(binding, descType, acceptableValueType, rangeOpenGeneric, descCtor, registerOpen);" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" catch (Exception ex)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " TryLog(\"[ConfigurationManager.Integration] Failed to register \" + binding.Section + \".\" + binding.Key + \": \" + ex);" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + } + + private static void EmitCmInteropHelpers(StringBuilder sb) + { + _ = sb.AppendLine(" private static Type ResolveCmType(string fullName)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " var t = Type.GetType(fullName + \", ConfigurationManager\");" + ); + _ = sb.AppendLine(" if (t != null) { return t; }"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())" + ); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" try"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " if (assembly.GetName().Name != \"ConfigurationManager\") { continue; }" + ); + _ = sb.AppendLine(" var candidate = assembly.GetType(fullName);"); + _ = sb.AppendLine(" if (candidate != null) { return candidate; }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" catch { }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" return null;"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " private static ConstructorInfo FindDescriptionConstructor(Type descType, Type acceptableValueType)" + ); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" foreach (var ctor in descType.GetConstructors())"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" var p = ctor.GetParameters();"); + _ = sb.AppendLine( + " if (p.Length == 3 && p[0].ParameterType == typeof(string) && p[1].ParameterType == acceptableValueType && p[2].ParameterType == typeof(object[]))" + ); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" return ctor;"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" return null;"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " private static MethodInfo FindRegisterMethod(Type lmmRegType, Type descType)" + ); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " foreach (var m in lmmRegType.GetMethods(BindingFlags.Public | BindingFlags.Static))" + ); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " if (m.Name != \"Register\" || !m.IsGenericMethodDefinition) { continue; }" + ); + _ = sb.AppendLine(" var p = m.GetParameters();"); + _ = sb.AppendLine( + " if (p.Length == 7 && p[5].ParameterType == descType) { return m; }" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" return null;"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " private static void RegisterBinding(BindingDescriptor binding, Type descType, Type acceptableValueType, Type rangeOpenGeneric, ConstructorInfo descCtor, MethodInfo registerOpen)" + ); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" object acceptableValue = null;"); + _ = sb.AppendLine( + " if (binding.MinValue != null && binding.MaxValue != null && rangeOpenGeneric != null)" + ); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " var closedRange = rangeOpenGeneric.MakeGenericType(binding.ValueType);" + ); + _ = sb.AppendLine( + " acceptableValue = Activator.CreateInstance(closedRange, binding.MinValue, binding.MaxValue);" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var description = descCtor.Invoke(new object[] { binding.Description ?? string.Empty, acceptableValue, new object[] { binding.CmAttributes } });" + ); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var registerClosed = registerOpen.MakeGenericMethod(binding.ValueType);" + ); + _ = sb.AppendLine( + " var entry = registerClosed.Invoke(null, new object[] { _modId, _modName, binding.Section, binding.Key, binding.DefaultValue, description, _modVersion });" + ); + _ = sb.AppendLine(" if (entry == null) { return; }"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" var entryType = entry.GetType();"); + _ = sb.AppendLine( + " var valueProperty = entryType.GetProperty(\"Value\", BindingFlags.Instance | BindingFlags.Public);" + ); + _ = sb.AppendLine( + " var settingChangedEvent = entryType.GetEvent(\"SettingChanged\", BindingFlags.Instance | BindingFlags.Public | BindingFlags.FlattenHierarchy);" + ); + _ = sb.AppendLine( + " if (valueProperty == null || settingChangedEvent == null) { return; }" + ); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " // Seed the value handle with whatever CM loaded (may have been populated from the persisted config file)." + ); + _ = sb.AppendLine( + " binding.ApplyFromCm(valueProperty.GetValue(entry, null));" + ); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var sink = new BindingSink(binding, entry, valueProperty);" + ); + _ = sb.AppendLine(" binding.PushToCm = sink.PushToCm;"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " var handlerType = settingChangedEvent.EventHandlerType;" + ); + _ = sb.AppendLine( + " var handlerMethod = typeof(BindingSink).GetMethod(\"OnCmChanged\", BindingFlags.Instance | BindingFlags.Public);" + ); + _ = sb.AppendLine( + " var handler = Delegate.CreateDelegate(handlerType, sink, handlerMethod);" + ); + _ = sb.AppendLine(" settingChangedEvent.AddEventHandler(entry, handler);"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " // Wire the local-to-CM direction: subscribing on a boxed IConfigValue means we" + ); + _ = sb.AppendLine( + " // reflect onto its ValueChanged event since T is only known at runtime here." + ); + _ = sb.AppendLine(" var valueHandleType = binding.ValueHandle.GetType();"); + _ = sb.AppendLine( + " var valueChangedEvent = valueHandleType.GetEvent(\"ValueChanged\");" + ); + _ = sb.AppendLine(" if (valueChangedEvent != null)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " var localMethod = typeof(BindingSink).GetMethod(\"OnLocalChanged\", BindingFlags.Instance | BindingFlags.Public);" + ); + _ = sb.AppendLine( + " var localHandler = Delegate.CreateDelegate(valueChangedEvent.EventHandlerType, sink, localMethod);" + ); + _ = sb.AppendLine( + " valueChangedEvent.AddEventHandler(binding.ValueHandle, localHandler);" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" private static void TryLog(string message)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" try { UnityEngine.Debug.Log(message); }"); + _ = sb.AppendLine(" catch { }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" internal sealed class BindingSink"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" private readonly BindingDescriptor _binding;"); + _ = sb.AppendLine(" private readonly object _cmEntry;"); + _ = sb.AppendLine(" private readonly PropertyInfo _valueProperty;"); + _ = sb.AppendLine(" [ThreadStatic] private static bool _suppress;"); + _ = sb.AppendLine(); + _ = sb.AppendLine( + " public BindingSink(BindingDescriptor binding, object cmEntry, PropertyInfo valueProperty)" + ); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" _binding = binding;"); + _ = sb.AppendLine(" _cmEntry = cmEntry;"); + _ = sb.AppendLine(" _valueProperty = valueProperty;"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" public void OnCmChanged(object sender, EventArgs e)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" if (_suppress) { return; }"); + _ = sb.AppendLine(" _suppress = true;"); + _ = sb.AppendLine(" try"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " _binding.ApplyFromCm(_valueProperty.GetValue(_cmEntry, null));" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" finally { _suppress = false; }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" public void OnLocalChanged(object sender, EventArgs e)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine(" if (_suppress) { return; }"); + _ = sb.AppendLine(" _suppress = true;"); + _ = sb.AppendLine( + " try { PushToCm(_valueProperty.GetValue(_cmEntry, null)); }" + ); + _ = sb.AppendLine(" finally { _suppress = false; }"); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(); + _ = sb.AppendLine(" public void PushToCm(object newValue)"); + _ = sb.AppendLine(" {"); + _ = sb.AppendLine( + " // Read the up-to-date value off the local handle reflectively; _cmEntry.Value is the push target." + ); + _ = sb.AppendLine(" var handleType = _binding.ValueHandle.GetType();"); + _ = sb.AppendLine( + " var handleValue = handleType.GetProperty(\"Value\").GetValue(_binding.ValueHandle, null);" + ); + _ = sb.AppendLine( + " _valueProperty.SetValue(_cmEntry, handleValue, null);" + ); + _ = sb.AppendLine(" }"); + _ = sb.AppendLine(" }"); + } + + private static string Literal(string value) + { + return "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/Generator.cs b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Generator.cs new file mode 100644 index 0000000..c80d9b6 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/Generator.cs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using LobotomyCorporation.Mods.ConfigurationManager.Integration.Diagnostics; +using LobotomyCorporation.Mods.ConfigurationManager.Integration.Emit; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace LobotomyCorporation.Mods.ConfigurationManager.Integration +{ + /// + /// Incremental source generator that emits runtime glue for optional-dependency + /// integration with LobCorp.ConfigurationManager into the consuming mod's assembly. + /// + [Generator(LanguageNames.CSharp)] + public sealed class ConfigurationManagerGenerator : IIncrementalGenerator + { + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidateNodes = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => BindingScanner.IsBindCandidate(node), + transform: static (ctx, _) => + BindingScanner.TryExtract( + (InvocationExpressionSyntax)ctx.Node, + ctx.SemanticModel + ) + ); + + var bindings = candidateNodes + .Where(static model => model is not null) + .Select(static (model, _) => model!) + .Collect(); + + var modAttribute = context + .SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => ModAttributeScanner.IsCandidate(node), + transform: static (ctx, _) => + ModAttributeScanner.TryExtract((AttributeSyntax)ctx.Node, ctx.SemanticModel) + ) + .Where(static model => model is not null) + .Select(static (model, _) => model!) + .Collect(); + + var combined = bindings.Combine(modAttribute); + + context.RegisterPostInitializationOutput(static ctx => + { + ctx.AddSource( + "ConfigurationManager.Api.g.cs", + SourceText.From(ApiSurfaceEmitter.Source, Encoding.UTF8) + ); + ctx.AddSource( + "ConfigurationManager.CmAttributes.g.cs", + SourceText.From(CmAttributesEmitter.Source, Encoding.UTF8) + ); + }); + + context.RegisterSourceOutput(combined, EmitRegistration); + } + + private static void EmitRegistration( + SourceProductionContext spc, + (ImmutableArray Bindings, ImmutableArray ModAttrs) data + ) + { + if (data.ModAttrs.Length > 1) + { + spc.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.DuplicateModAttribute, + data.ModAttrs[0].Location + ) + ); + } + + var modAttribute = data.ModAttrs.Length == 0 ? null : data.ModAttrs[0]; + var uniqueBindings = DeduplicateAndReport(spc, data.Bindings); + + var registration = RegistrationEmitter.Emit(uniqueBindings, modAttribute); + spc.AddSource( + "ConfigurationManager.Registration.g.cs", + SourceText.From(registration, Encoding.UTF8) + ); + } + + private static ImmutableArray DeduplicateAndReport( + SourceProductionContext spc, + ImmutableArray scannedBindings + ) + { + var seen = new HashSet(); + var uniqueBindings = new List(scannedBindings.Length); + foreach (var model in scannedBindings) + { + var identity = model.Section + "\0" + model.Key; + if (!seen.Add(identity)) + { + spc.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.DuplicateBinding, + model.Location, + model.Section, + model.Key + ) + ); + continue; + } + + uniqueBindings.Add(model); + } + + return [.. uniqueBindings]; + } + } +} diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/LobotomyCorporation.Mods.ConfigurationManager.Integration.csproj b/LobotomyCorporation.Mods.ConfigurationManager.Integration/LobotomyCorporation.Mods.ConfigurationManager.Integration.csproj new file mode 100644 index 0000000..4975fc2 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/LobotomyCorporation.Mods.ConfigurationManager.Integration.csproj @@ -0,0 +1,54 @@ + + + netstandard2.0 + latest + enable + disable + + true + true + false + true + $(NoWarn);NU5128 + + true + true + LobotomyCorporation.Mods.ConfigurationManager.Integration + Build-time integration package for LobCorp.ConfigurationManager. A Roslyn source generator emits all runtime glue (reflection-based interop with ConfigurationManager) directly into a mod's own DLL. Mods reference this package with PrivateAssets="all" and ship one DLL; ConfigurationManager.dll remains an optional runtime dependency. + PACKAGE_README.md + lobotomy-corporation;mod;configuration;source-generator;roslyn;lmm + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/PACKAGE_README.md b/LobotomyCorporation.Mods.ConfigurationManager.Integration/PACKAGE_README.md new file mode 100644 index 0000000..0dee182 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/PACKAGE_README.md @@ -0,0 +1,75 @@ +# LobotomyCorporation.Mods.ConfigurationManager.Integration + +Build-time integration package for +[LobCorp.ConfigurationManager](https://github.com/open-lobotomy/LobCorp.ConfigurationManager). +It lets your Lobotomy Corporation mod expose settings in the in-game F1 menu +without taking a hard dependency on `ConfigurationManager.dll`. Your mod ships +as a single DLL and runs whether or not ConfigurationManager is installed. + +## What it does + +Reference this package with `PrivateAssets="all"` and write fluent config +declarations: + +```csharp +using LobotomyCorporation.Mods.ConfigurationManager; + +[assembly: ConfigManagerMod( + ModId = "com.example.mymod", + ModName = "My Mod", + ModVersion = "1.0.0")] + +public static class MyConfig +{ + public static readonly IConfigValue Damage = Config.Bind( + "Combat", "Damage", 100, + description: "Damage per hit", minValue: 1, maxValue: 1000); + + public static readonly IConfigValue GodMode = Config.Bind( + "Cheats", "GodMode", false, isAdvanced: true); +} + +public class Harmony_Patch +{ + static Harmony_Patch() + { + HarmonyInstance.Create("com.example.mymod").PatchAll(); + Config.RegisterAll(); + } +} +``` + +The Integration package scans your source for `Config.Bind(...)` invocations and emits into your mod's assembly: + +- A `Config` static class with `Bind` overloads +- `IConfigValue` / `ConfigValue` value types +- A local `ConfigurationManagerAttributes` class for UI hints +- A `Config.RegisterAll()` method that uses reflection to register with + ConfigurationManager if it is installed, or falls back to an in-memory store if not + +## Runtime behavior + +- **ConfigurationManager present**: settings show up in the in-game F1 menu and persist to disk. +- **ConfigurationManager absent**: bindings return their default values from an in-memory store. Your mod still runs. + +## Things to know + +- **Aliased uses (`using X = Config;`) are not supported.** The scanner matches on the literal identifier `Config`. +- **If your mod already has a class named `Config`, rename it.** The `using LobotomyCorporation.Mods.ConfigurationManager;` directive brings this package's own `Config` class into scope, and two classes with the same name cause a compile error. +- **ConfigurationManager is a fork of [BepInEx.ConfigurationManager](https://github.com/BepInEx/BepInEx.ConfigurationManager).** Both ship as `ConfigurationManager.dll` on purpose — only one can be loaded at a time. If a user has BepInEx.ConfigurationManager instead of LobCorp.ConfigurationManager, your mod still runs, but settings fall back to in-memory. + +## Installation + +```sh +dotnet add package LobotomyCorporation.Mods.ConfigurationManager.Integration +``` + +Make sure the `PackageReference` has `PrivateAssets="all"` so the generator +doesn't flow through to consumers of your mod: + +```xml + +``` + +Your mod still targets **.NET Framework 3.5** — this package contains no runtime +code; it only emits source into your assembly. diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/build/LobotomyCorporation.Mods.ConfigurationManager.Integration.props b/LobotomyCorporation.Mods.ConfigurationManager.Integration/build/LobotomyCorporation.Mods.ConfigurationManager.Integration.props new file mode 100644 index 0000000..cfb2312 --- /dev/null +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/build/LobotomyCorporation.Mods.ConfigurationManager.Integration.props @@ -0,0 +1,8 @@ + + + diff --git a/README.md b/README.md index 7118690..f4af2b2 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,59 @@ Copy `ConfigurationManager.dll` into your Lobotomy Corporation mods folder. The ## How to make my mod compatible -### Registering settings +The recommended integration path is the **Integration package**, which lets your mod work with ConfigurationManager as an _optional_ dependency — your mod compiles and runs whether or not `ConfigurationManager.dll` is installed at runtime. -Use `LmmConfigRegistration` to register your mod's settings. ConfigurationManager will display them automatically, including any metadata (descriptions, value ranges, acceptable value lists). +### Option 1 — Integration package (recommended, ships one DLL, optional at runtime) + +Install [`LobotomyCorporation.Mods.ConfigurationManager.Integration`](https://github.com/open-lobotomy/LobCorp.ConfigurationManager) with `PrivateAssets="all"` so the generator stays at build time and does not flow to your mod's own consumers: + +```xml + +``` + +Declare your settings with a fluent `Config.Bind` API: + +```c# +using LobotomyCorporation.Mods.ConfigurationManager; + +[assembly: ConfigManagerMod( + ModId = "com.example.mymod", + ModName = "My Mod", + ModVersion = "1.0.0")] + +internal static class MyConfig +{ + public static readonly IConfigValue Damage = Config.Bind( + "Combat", "Damage", 100, + description: "Damage per hit", minValue: 1, maxValue: 1000, order: 10); + + public static readonly IConfigValue GodMode = Config.Bind( + "Cheats", "GodMode", false, isAdvanced: true); +} + +public class Harmony_Patch +{ + static Harmony_Patch() + { + HarmonyInstance.Create("com.example.mymod").PatchAll(); + Config.RegisterAll(); + } +} +``` + +The Integration package emits all runtime glue — reflection-based interop with ConfigurationManager, a local `ConfigurationManagerAttributes` copy, and a `Config.RegisterAll()` method — directly into your mod's own assembly. You ship one DLL. When `ConfigurationManager.dll` is present, settings appear in the F1 menu and persist to disk. When it is absent, `Config.Bind` values fall back to an in-memory store and your mod still runs. + +A few things to know: + +- **Aliased uses (`using X = Config;`) are intentionally not supported.** The scanner matches on the literal identifier `Config`. +- **If your mod already has a class named `Config`, rename it.** The `using LobotomyCorporation.Mods.ConfigurationManager;` directive brings the Integration package's own `Config` class into scope. Two classes with the same name cause a compile error. Aliasing does not help here because of the rule above. +- **This package is a build-time companion to `ConfigurationManager.dll`, which is a fork of [BepInEx.ConfigurationManager](https://github.com/BepInEx/BepInEx.ConfigurationManager).** Both share the DLL name `ConfigurationManager.dll` on purpose — only one can be loaded at a time. If a user installs BepInEx.ConfigurationManager instead of this fork, your mod still runs, but settings fall back to in-memory and do not appear in the F1 menu. Install LobCorp.ConfigurationManager to see them. + +### Option 2 — Direct dependency (requires `ConfigurationManager.dll` at runtime) + +Use `LmmConfigRegistration` directly. This is a harder dependency — your mod will fail to load if ConfigurationManager is not installed. ```c# using ConfigurationManager.Config; From fb7454a1bbde34441c61f6ac5542be9f7cca1253 Mon Sep 17 00:00:00 2001 From: Chris Tristan <1764856+CTristan@users.noreply.github.com> Date: Tue, 21 Apr 2026 17:22:11 -0700 Subject: [PATCH 3/6] Add SampleMod and align docs for first-time mod authors Wires up samples/SampleMod/ as a complete, buildable reference for the Integration package's optional-dependency pattern, and rewrites README / copilot-instructions to target ESL first-time mod authors. Adds the sample to the solution so CI enforces the net35 compile-check the csproj promises. --- .github/copilot-instructions.md | 7 +- .gitignore | 1 + ConfigurationManager.slnx | 1 + .../PACKAGE_README.md | 6 +- README.md | 78 +++++++-- samples/SampleMod/Harmony_Patch.cs | 45 +++++ samples/SampleMod/MyConfig.cs | 70 ++++++++ samples/SampleMod/README.md | 163 ++++++++++++++++++ samples/SampleMod/SampleMod.csproj | 49 ++++++ 9 files changed, 401 insertions(+), 19 deletions(-) create mode 100644 samples/SampleMod/Harmony_Patch.cs create mode 100644 samples/SampleMod/MyConfig.cs create mode 100644 samples/SampleMod/README.md create mode 100644 samples/SampleMod/SampleMod.csproj diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9882ec6..9e0577d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -68,15 +68,18 @@ Global analyzers (`LobotomyCorporation.Mods.Analyzers`, `OpenLobotomy.Standards` ## Audience & Language -Many users and contributors are native Korean speakers who read English as a second language or through machine translation. +**Assume the reader is a first-time mod author whose first language is not English.** Most consumers of this repo — both the `ConfigurationManager.dll` end-user install and the `Integration` NuGet package — are Korean-speaking modders reading English as a second language or through machine translation, and many have no prior professional development experience. Every error message, diagnostic, README, and code comment that an author will see must pass that bar before shipping. ### Project facts that shape documentation - **Lobotomy Corporation itself will never update.** The base game is final. Do not pitch wrappers, adapters, or analyzers on "survives game updates" or "keeps working when the game changes" — those claims are factually wrong and will mislead readers. The honest value props for typed wrappers over reflection are: (a) the compiler checks names and types at build time, so typos fail before you run the game; (b) typed code is shorter and easier to read; (c) the package is community-maintained, so fixes land once for everyone. What *does* still change is LMM (the mod loader) and other mods that patch the same game code via Harmony — if a doc needs to explain why a wrapper helps mods coexist, that is the real reason, not game updates. +- **The Integration package exists so authors can hook into ConfigurationManager *if it is installed*, without bundling or redistributing `ConfigurationManager.dll` themselves.** The mod ships as a single DLL. If the player has ConfigurationManager, settings appear in the F1 menu. If not, the mod still runs and bindings serve defaults from an in-memory store. Never write docs, diagnostics, or examples that imply the mod author has to ship `ConfigurationManager.dll`, reference it at compile time, or detect its presence by hand — the generator's emitted reflection probe handles that. If a reader walks away thinking they need to copy a DLL into their mod folder or add a hard `Reference Include="ConfigurationManager"`, the doc has failed. ### Package Audiences -- **`ConfigurationManager.dll` and `LobotomyCorporation.Mods.ConfigurationManager.Integration`** — target audience includes first-time modders without professional development experience. Error messages, analyzer diagnostics, and docs should explain *why*, not just *what*. Avoid assuming knowledge of patterns like dependency injection, mocking, reflection, or build system internals. The source generator in particular is consumed via a single NuGet reference by authors who may have never used Roslyn analyzers before — surface failures as clear, actionable messages, not stack traces. +- **`ConfigurationManager.dll`** — shipped to players as a BaseMod. End-user audience (installers, not coders); release notes and the in-game UI should be readable without developer vocabulary. +- **`LobotomyCorporation.Mods.ConfigurationManager.Integration`** — consumed by mod authors via a single NuGet reference. The optional-dependency story above is the central value proposition: assume the author found this package because they want settings UI *when available* but are not willing to take a hard runtime dependency. Error messages, analyzer diagnostics, README samples, and generated-code comments should all reinforce that. Explain *why*, not just *what*. Do not assume knowledge of dependency injection, mocking, reflection, source generators, Roslyn analyzers, or build-system internals like `PrivateAssets`/`ReferenceOutputAssembly` — when those terms are unavoidable, define them inline or link to a one-paragraph explainer. Surface failures as clear, actionable messages, not stack traces. +- **`samples/` directory (e.g. `samples/SampleMod/`)** — this bar applies here too. Samples are copy-paste reference material for mod authors; every comment, naming choice, and implicit convention must be readable in isolation on GitHub by someone who has never opened the rest of this repo. Expand acronyms the first time they appear (LMM → Lobotomy Mod Manager), add inline comments on any assembly attribute or pattern that a first-timer would not recognize (e.g. `Fallback = ConfigFallback.InMemory`, the static-initializer entry-point idiom), and ship a README in each sample that states the optional-dependency promise upfront. ### Writing Style diff --git a/.gitignore b/.gitignore index 6e45246..7aa0ccb 100644 --- a/.gitignore +++ b/.gitignore @@ -210,6 +210,7 @@ _pkginfo.txt *.[Cc]ache # but keep track of directories ending in .cache !*.[Cc]ache/ +*.lscache # Others ClientBin/ diff --git a/ConfigurationManager.slnx b/ConfigurationManager.slnx index 4c53acb..09d2948 100644 --- a/ConfigurationManager.slnx +++ b/ConfigurationManager.slnx @@ -7,4 +7,5 @@ + diff --git a/LobotomyCorporation.Mods.ConfigurationManager.Integration/PACKAGE_README.md b/LobotomyCorporation.Mods.ConfigurationManager.Integration/PACKAGE_README.md index 0dee182..10df655 100644 --- a/LobotomyCorporation.Mods.ConfigurationManager.Integration/PACKAGE_README.md +++ b/LobotomyCorporation.Mods.ConfigurationManager.Integration/PACKAGE_README.md @@ -29,11 +29,13 @@ public static class MyConfig "Cheats", "GodMode", false, isAdvanced: true); } -public class Harmony_Patch +public sealed class Harmony_Patch { static Harmony_Patch() { - HarmonyInstance.Create("com.example.mymod").PatchAll(); + var harmony = HarmonyInstance.Create("com.example.mymod"); + harmony.PatchAll(typeof(Harmony_Patch).Assembly); + Config.RegisterAll(); } } diff --git a/README.md b/README.md index f4af2b2..7b61a0c 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,53 @@ -# In-game configuration manager for Lobotomy Corporation (LMM) +# In-game settings menu for Lobotomy Corporation mods -Fork of [BepInEx.ConfigurationManager](https://github.com/BepInEx/BepInEx.ConfigurationManager) adapted for Lobotomy Corporation's mod loader (LMM) and Harmony 1. Provides an in-game ImGUI settings window for LMM and BepInEx mods. Press **F1** to open. Hover over setting names to see their descriptions. +ConfigurationManager is a Lobotomy Corporation mod that adds an in-game +settings window for other mods. Players press **F1** to open the +window, change values, and save them. + +It is a fork of +[BepInEx.ConfigurationManager](https://github.com/BepInEx/BepInEx.ConfigurationManager) +adapted for Lobotomy Mod Manager (LMM — the base game's mod loader) and +Harmony 1 (the patching library LMM mods use). ![Configuration manager](Screenshot.PNG) +## Who this page is for + +- **Players**: see [Installation](#installation). +- **Mod authors**: see + [Adding settings to your mod](#adding-settings-to-your-mod). + +If you have never written a Lobotomy Corporation mod before, the +[`samples/SampleMod/`](samples/SampleMod/) folder in this repository +walks through the integration step by step and includes a complete +working example. + ## Installation -Copy `ConfigurationManager.dll` into your Lobotomy Corporation mods folder. The configuration manager will load automatically via LMM. +Copy `ConfigurationManager.dll` into your Lobotomy Corporation mods +folder. LMM loads it automatically the next time you start the game. + +## Adding settings to your mod -## How to make my mod compatible +The **Integration package** +(`LobotomyCorporation.Mods.ConfigurationManager.Integration`) is the +recommended way to add settings to your mod. -The recommended integration path is the **Integration package**, which lets your mod work with ConfigurationManager as an _optional_ dependency — your mod compiles and runs whether or not `ConfigurationManager.dll` is installed at runtime. +Your mod is distributed as one DLL. If the player has +ConfigurationManager installed, your settings appear in the F1 menu. +If not, your mod still runs and your settings keep their default +values in memory. You never copy `ConfigurationManager.dll` into your +mod's folder, and you never add a runtime reference to it. -### Option 1 — Integration package (recommended, ships one DLL, optional at runtime) +→ For a four-step walkthrough with a working example, see +**[samples/SampleMod/README.md](samples/SampleMod/)**. -Install [`LobotomyCorporation.Mods.ConfigurationManager.Integration`](https://github.com/open-lobotomy/LobCorp.ConfigurationManager) with `PrivateAssets="all"` so the generator stays at build time and does not flow to your mod's own consumers: +A short version follows for readers already familiar with the pattern. + +### Short version — Integration package + +Add the package to your `.csproj` with `PrivateAssets="all"` +(build-time-only, so the package does not ship inside your mod): ```xml ``` -Declare your settings with a fluent `Config.Bind` API: +Mark your assembly and declare your settings: ```c# using LobotomyCorporation.Mods.ConfigurationManager; @@ -30,7 +63,8 @@ using LobotomyCorporation.Mods.ConfigurationManager; [assembly: ConfigManagerMod( ModId = "com.example.mymod", ModName = "My Mod", - ModVersion = "1.0.0")] + ModVersion = "1.0.0", + Fallback = ConfigFallback.InMemory)] internal static class MyConfig { @@ -42,23 +76,37 @@ internal static class MyConfig "Cheats", "GodMode", false, isAdvanced: true); } -public class Harmony_Patch +public sealed class Harmony_Patch { static Harmony_Patch() { - HarmonyInstance.Create("com.example.mymod").PatchAll(); + var harmony = HarmonyInstance.Create("com.example.mymod"); + harmony.PatchAll(typeof(Harmony_Patch).Assembly); + Config.RegisterAll(); } } ``` -The Integration package emits all runtime glue — reflection-based interop with ConfigurationManager, a local `ConfigurationManagerAttributes` copy, and a `Config.RegisterAll()` method — directly into your mod's own assembly. You ship one DLL. When `ConfigurationManager.dll` is present, settings appear in the F1 menu and persist to disk. When it is absent, `Config.Bind` values fall back to an in-memory store and your mod still runs. +The Integration package writes the supporting runtime code — a local +`ConfigurationManagerAttributes` copy and a `Config.RegisterAll()` +method that probes for ConfigurationManager at runtime — directly +into your mod's own DLL. The output is a single DLL. A few things to know: -- **Aliased uses (`using X = Config;`) are intentionally not supported.** The scanner matches on the literal identifier `Config`. -- **If your mod already has a class named `Config`, rename it.** The `using LobotomyCorporation.Mods.ConfigurationManager;` directive brings the Integration package's own `Config` class into scope. Two classes with the same name cause a compile error. Aliasing does not help here because of the rule above. -- **This package is a build-time companion to `ConfigurationManager.dll`, which is a fork of [BepInEx.ConfigurationManager](https://github.com/BepInEx/BepInEx.ConfigurationManager).** Both share the DLL name `ConfigurationManager.dll` on purpose — only one can be loaded at a time. If a user installs BepInEx.ConfigurationManager instead of this fork, your mod still runs, but settings fall back to in-memory and do not appear in the F1 menu. Install LobCorp.ConfigurationManager to see them. +- **Do not alias `Config`.** `using X = Config;` breaks the scanner, + which matches the literal identifier `Config`. +- **If your mod already has a class named `Config`, rename it.** The + `using LobotomyCorporation.Mods.ConfigurationManager;` directive + brings the Integration package's own `Config` class into scope. Two + classes with the same name cause a compile error. +- **`ConfigurationManager.dll` is a fork of + [BepInEx.ConfigurationManager](https://github.com/BepInEx/BepInEx.ConfigurationManager).** + Both share the same DLL name on purpose — only one can load at a + time. If a player installs BepInEx.ConfigurationManager instead of + this fork, your mod still runs, but the F1 menu does not show your + settings. Install LobCorp.ConfigurationManager to see them. ### Option 2 — Direct dependency (requires `ConfigurationManager.dll` at runtime) diff --git a/samples/SampleMod/Harmony_Patch.cs b/samples/SampleMod/Harmony_Patch.cs new file mode 100644 index 0000000..e38c771 --- /dev/null +++ b/samples/SampleMod/Harmony_Patch.cs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using System; +using Harmony; +using LobotomyCorporation.Mods.ConfigurationManager; + +namespace SampleMod +{ + /// + /// Entry point that LMM (Lobotomy Mod Manager, the base game's mod + /// loader) instantiates when this mod loads. Applies Harmony patches + /// and registers our configuration bindings. + /// + /// + /// LMM loads this mod by calling new Harmony_Patch() + /// reflectively. The first time any code touches the + /// Harmony_Patch type, the CLR runs the static constructor + /// below — that is where all of our setup happens. The instance LMM + /// gets back is a no-op; the work has already been done. + /// + public sealed class Harmony_Patch + { + static Harmony_Patch() + { + try + { + var harmony = HarmonyInstance.Create("com.example.samplemod"); + harmony.PatchAll(typeof(Harmony_Patch).Assembly); + + // Registers every Config.Bind(...) declaration in this + // assembly with ConfigurationManager if it is installed. + // If ConfigurationManager is absent, this call returns + // without error and bindings keep their default values in + // memory. You never write an "is ConfigurationManager + // installed?" check yourself — the source generator emits + // that for you. + Config.RegisterAll(); + } + catch (Exception ex) + { + UnityEngine.Debug.LogError("[SampleMod] Harmony patch failure: " + ex); + } + } + } +} diff --git a/samples/SampleMod/MyConfig.cs b/samples/SampleMod/MyConfig.cs new file mode 100644 index 0000000..2018cac --- /dev/null +++ b/samples/SampleMod/MyConfig.cs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +using LobotomyCorporation.Mods.ConfigurationManager; + +// The [assembly: ConfigManagerMod] attribute identifies your mod to the +// ConfigurationManager integration. The source generator reads these +// values at build time and uses them when registering your settings +// at runtime. +// +// Fallback = ConfigFallback.InMemory means: if a player runs your mod +// without ConfigurationManager installed, the generator's registration +// helper skips the UI step and your settings live in memory with their +// default values. Your mod still runs. Nothing crashes. No error logs. +[assembly: ConfigManagerMod( + ModId = "com.example.samplemod", + ModName = "Sample Mod", + ModVersion = "0.1.0", + Fallback = ConfigFallback.InMemory +)] + +namespace SampleMod +{ + /// + /// Three example settings showing the most common shapes of + /// Config.Bind: a numeric range, a boolean flag, and a plain + /// string. Read any binding's current value at runtime via + /// MyConfig.Damage.Value, MyConfig.GodMode.Value, and + /// so on. + /// + internal static class MyConfig + { + /// + /// A numeric setting with a range. ConfigurationManager renders + /// this as a slider because minValue and maxValue + /// are set. order controls where the setting appears in + /// the F1 menu relative to other settings in the same section + /// (higher = earlier). + /// + public static readonly IConfigValue Damage = Config.Bind( + section: "Combat", + key: "Damage", + defaultValue: 100, + description: "Damage per hit", + minValue: 1, + maxValue: 1000, + order: 10 + ); + + /// + /// isAdvanced: true hides this setting behind an + /// "Advanced" toggle in the F1 menu, so regular players do not + /// see it by default. + /// + public static readonly IConfigValue GodMode = Config.Bind( + section: "Cheats", + key: "GodMode", + defaultValue: false, + isAdvanced: true + ); + + /// + /// A plain string setting. ConfigurationManager renders a text box. + /// + public static readonly IConfigValue PlayerName = Config.Bind( + section: "General", + key: "PlayerName", + defaultValue: "Manager" + ); + } +} diff --git a/samples/SampleMod/README.md b/samples/SampleMod/README.md new file mode 100644 index 0000000..c0b1e18 --- /dev/null +++ b/samples/SampleMod/README.md @@ -0,0 +1,163 @@ +# Adding ConfigurationManager settings to your mod + +This page shows how to add in-game settings to your Lobotomy Corporation +mod using the +`LobotomyCorporation.Mods.ConfigurationManager.Integration` package. + +The files in this `SampleMod/` folder are a complete, working example. +**You do not need to copy the whole folder into your mod.** Follow the +steps below to add the integration to a mod you already have, and open +the sample files when you want to compare your code with a finished +version. + +## How it works with and without ConfigurationManager + +Your mod is distributed as **one DLL**. You never copy +`ConfigurationManager.dll` into your mod's folder. You never add a +runtime reference to it. + +- **If the player has ConfigurationManager installed**, your settings + appear in the in-game F1 menu and save to disk. +- **If the player does not have ConfigurationManager installed**, your + mod still loads. Your settings keep their default values in memory. + Nothing crashes. Nothing logs errors. + +You do not write the code that detects ConfigurationManager. The +Integration package generates that for you. You only call +`Config.RegisterAll()` once, from your mod's entry point. + +## Four steps + +### 1. Add the Integration package to your `.csproj` + +Add this inside any `` in your mod's `.csproj`: + +```xml + +``` + +`PrivateAssets="all"` means "use this package at build time only; do +not include it in my output DLL." That is how your mod stays a single +DLL. + +> The sample here uses a `ProjectReference` instead, because it builds +> from inside this repository. See `SampleMod.csproj` for the full +> project file. Your own mod uses the `PackageReference` shown above. + +### 2. Identify your mod to the integration + +Add this once per mod, at the top of any `.cs` file: + +```c# +using LobotomyCorporation.Mods.ConfigurationManager; + +[assembly: ConfigManagerMod( + ModId = "com.example.mymod", + ModName = "My Mod", + ModVersion = "1.0.0", + Fallback = ConfigFallback.InMemory +)] +``` + +- `ModId` — a unique identifier for your mod. Reverse-domain format + (for example `com.yourname.yourmod`) keeps it unique across every + mod a player installs. +- `ModName` — the heading players see for your mod in the F1 menu. +- `ModVersion` — shown next to the mod name in the F1 menu. +- `Fallback = ConfigFallback.InMemory` — what happens when + ConfigurationManager is not installed. `InMemory` means "keep values + in memory with their defaults." This is the only supported value + today. + +> See `MyConfig.cs` in this folder for this attribute in context. + +### 3. Declare your settings + +Create a static class that holds your settings. Each setting is one +call to `Config.Bind`: + +```c# +internal static class MyConfig +{ + public static readonly IConfigValue Damage = Config.Bind( + section: "Combat", + key: "Damage", + defaultValue: 100, + description: "Damage per hit", + minValue: 1, + maxValue: 1000); + + public static readonly IConfigValue GodMode = Config.Bind( + section: "Cheats", + key: "GodMode", + defaultValue: false, + isAdvanced: true); +} +``` + +Read a setting at runtime with `MyConfig.Damage.Value`. + +Common options: +- `minValue` / `maxValue` — for numeric settings. Shows a slider in + the F1 menu when both are set. +- `isAdvanced: true` — hides the setting behind an "Advanced" toggle. +- `order` — higher numbers appear earlier within a section. + +> See `MyConfig.cs` for a complete example with three different +> setting shapes. + +### 4. Register your settings when the mod loads + +Your mod's entry point is a class named `Harmony_Patch`. LMM +(Lobotomy Mod Manager — the base game's mod loader) instantiates this +class when your mod loads. Call `Config.RegisterAll()` from its static +constructor: + +```c# +public sealed class Harmony_Patch +{ + static Harmony_Patch() + { + var harmony = HarmonyInstance.Create("com.example.mymod"); + harmony.PatchAll(typeof(Harmony_Patch).Assembly); + + Config.RegisterAll(); + } +} +``` + +That one call: + +- Finds every `Config.Bind` declaration in your assembly. +- Checks whether ConfigurationManager is installed. +- If it is, registers your settings so they appear in the F1 menu. +- If it is not, returns without error. Your settings keep their + defaults in memory. + +> See `Harmony_Patch.cs` for a complete example, including a +> `try`/`catch` wrapper so that a failure during load appears in the +> game's log instead of failing silently. + +## The full sample in this folder + +| File | What to look at | +|---|---| +| `MyConfig.cs` | The `[assembly: ConfigManagerMod]` attribute and three settings: a numeric range, a boolean flag, and a plain string. | +| `Harmony_Patch.cs` | The entry-point pattern and how to wrap setup in `try`/`catch`. | +| `SampleMod.csproj` | The project structure, including how to reference the Integration package at build time only. | + +## Building the sample (optional) + +If you want to build the sample directly from this repository to see +the output: + +```sh +dotnet build samples/SampleMod/SampleMod.csproj +``` + +The output is `samples/SampleMod/bin/net35/SampleMod.dll`. To install +it as a real mod, copy that one file into your LMM +`BaseMods/SampleMod/` folder. diff --git a/samples/SampleMod/SampleMod.csproj b/samples/SampleMod/SampleMod.csproj new file mode 100644 index 0000000..bafa90d --- /dev/null +++ b/samples/SampleMod/SampleMod.csproj @@ -0,0 +1,49 @@ + + + + net35 + latest + disable + disable + false + + $(NoWarn);CS8021;NU1702;CS1591;S1118 + true + SampleMod + SampleMod + + + + + + + + + ..\..\external\LobotomyCorp_Data\Managed\0Harmony.dll + false + + + ..\..\external\LobotomyCorp_Data\Managed\UnityEngine.dll + false + + + ..\..\external\LobotomyCorp_Data\Managed\UnityEngine.CoreModule.dll + false + + + From 8daf9e7183b2b52b3103fe8f681d6fd445ca9076 Mon Sep 17 00:00:00 2001 From: Chris Tristan <1764856+CTristan@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:25:58 -0700 Subject: [PATCH 4/6] Split Integration package to separate repo The source-generator package and its tests now live at open-lobotomy/LobotomyCorporation.Mods.ConfigurationManager.Integration. This repo retains only the LGPL-3.0 runtime mod DLL and its tests. Side effects: - Removes LICENSES.md and LICENSE-MIT; single-license repo. - Removes the samples/ directory (migrated to Integration repo). - Dual-credits BepInEx contributors + Open Lobotomy in Directory.Build.props. - Marks the runtime csproj IsPackable=false (defense against accidental pack). - Trims release.yml to the zip installer path; NuGet pack/push moved to Integration repo's release.yml. - README loses the Integration-authoring walkthrough; adds a Related link back to the Integration repo for mod authors. --- .DS_Store | Bin 0 -> 12292 bytes .github/workflows/release.yml | 9 +- ConfigurationManager.slnx | 3 - Directory.Build.props | 4 +- .../LobCorp.ConfigurationManager.csproj | 1 + .../BindingModelTests.cs | 165 -------- .../BindingScannerTests.cs | 60 --- .../GeneratorDiagnosticsTests.cs | 170 -------- .../GeneratorSnapshotTests.cs | 77 ---- ...figurationManager.Integration.Tests.csproj | 20 - .../ModAttributeScannerTests.cs | 56 --- ...pes#ConfigurationManager.Api.g.verified.cs | 152 ------- ...gurationManager.CmAttributes.g.verified.cs | 28 -- ...gurationManager.Registration.g.verified.cs | 213 ---------- ...ing#ConfigurationManager.Api.g.verified.cs | 152 ------- ...gurationManager.CmAttributes.g.verified.cs | 28 -- ...gurationManager.Registration.g.verified.cs | 212 ---------- ...ing#ConfigurationManager.Api.g.verified.cs | 152 ------- ...gurationManager.CmAttributes.g.verified.cs | 28 -- ...gurationManager.Registration.g.verified.cs | 212 ---------- .../TestHelper.cs | 41 -- .../AnalyzerReleases.Shipped.md | 2 - .../AnalyzerReleases.Unshipped.md | 9 - .../BindingModel.cs | 147 ------- .../BindingScanner.cs | 235 ----------- .../Diagnostics/DiagnosticDescriptors.cs | 38 -- .../Emit/ApiSurfaceEmitter.cs | 168 -------- .../Emit/CmAttributesEmitter.cs | 43 -- .../Emit/RegistrationEmitter.cs | 387 ------------------ .../Generator.cs | 120 ------ ...ds.ConfigurationManager.Integration.csproj | 54 --- .../PACKAGE_README.md | 77 ---- ...ods.ConfigurationManager.Integration.props | 8 - README.md | 109 ++--- samples/SampleMod/Harmony_Patch.cs | 45 -- samples/SampleMod/MyConfig.cs | 70 ---- samples/SampleMod/README.md | 163 -------- samples/SampleMod/SampleMod.csproj | 49 --- 38 files changed, 29 insertions(+), 3478 deletions(-) create mode 100644 .DS_Store delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingModelTests.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/BindingScannerTests.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorDiagnosticsTests.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/GeneratorSnapshotTests.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests.csproj delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/ModAttributeScannerTests.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Api.g.verified.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.CmAttributes.g.verified.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForMultipleBindingsAcrossTypes#ConfigurationManager.Registration.g.verified.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Api.g.verified.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.CmAttributes.g.verified.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsExpectedSources_ForSimpleBinding#ConfigurationManager.Registration.g.verified.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Api.g.verified.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.CmAttributes.g.verified.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/Snapshots/GeneratorSnapshotTests.EmitsRegistration_WhenModAttributeMissing#ConfigurationManager.Registration.g.verified.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration.Tests/TestHelper.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Shipped.md delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/AnalyzerReleases.Unshipped.md delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingModel.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/BindingScanner.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Diagnostics/DiagnosticDescriptors.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/ApiSurfaceEmitter.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/CmAttributesEmitter.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Emit/RegistrationEmitter.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/Generator.cs delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/LobotomyCorporation.Mods.ConfigurationManager.Integration.csproj delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/PACKAGE_README.md delete mode 100644 LobotomyCorporation.Mods.ConfigurationManager.Integration/build/LobotomyCorporation.Mods.ConfigurationManager.Integration.props delete mode 100644 samples/SampleMod/Harmony_Patch.cs delete mode 100644 samples/SampleMod/MyConfig.cs delete mode 100644 samples/SampleMod/README.md delete mode 100644 samples/SampleMod/SampleMod.csproj diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..7f513f03c7374219161cc0bf37600ff8f1890f34 GIT binary patch literal 12292 zcmeI232Yo!8OPuMIf-Y;G#Z@VzR#D*4gz=oN3lG z?#`^8G=#b!D5$Nt5g?%4hYAr_feKOzf-0gER1_jLh=K}&R)Pva1S+D1@6DSv_O2@> z0VO4!k!HSk&&;>;e>1=DEn^JrxpX6AwTv-^c42jm4)-t?TTXV~ChEmrw3Sc^Xcv`~ z))`B&4(6~dn_?S^S{D~^DiZyd(7u{=Q_uPR#Yi?gz$`Ywax`X=d6X7qJ(RX-pI|1- zPA9T<#<3^FSgzO5a2mX1$dqBOM+x6lKj z2SN{o9tb_~YV?43_tK^myzgaccnm!ddf-*<0Wm+sXcsmf*jZ`)se=yU6JFGdPk4W$ zG;x4m77J`Vu(Q(oU8#@Xm8|TF05KrDQ+Z4@CmRp!thBN_gY3=_oEZV3z&|_ni}}qN z;?jo4&;y|diaj9Y(;Be1Lxk-*;W_8td>+73a zw{-02?mbnemse<&+SP;8mTRRlX8(+p_6`mw-Eq@STlT~V{g}3A9k9%-T8@a?XD27k zQx$r7*~(Rs$WSh2-I24rBYL%>$g$Q$Vyga-GMc6f>2cDcXWFlctck2ulzpPSBWu!8 z7Hc(;*Qt6^>2v9N}zC-k6$*zssjf$RD`!mUunUO^;+D1j+r4CL@U(3Qa zZL?C2I(2B=nVh8Sc4cuq(yr(~RBjXJ?oPVio+;DLUsYFRyP}^|M%;tRthr}~9&XvE zE3!*b{9lmDMW?Q`^t?9Ix4(>d%H`1r*Kc_yrWlvia2&3On#>15A z{O?d5UoVr@kKXf>KuT))vBJKwNN>-oRY?^7#; zVy~>c>gsD^LdF|6x6eK7QXY9l%vYK(t_70x#pOSHg{mN3U%T4SqWz>k^KjvOke?^? zQlT^DlxSV`S}m$CC4I{5x*EzZBW=p;^>xvx&S^@J*@nibrk9Z5Rln~|b(BSrX}wv~ z^d%&HS-0gD$}%zQ$b3g9jUro}&=+CzDx-RFHaDt_n#I_By-`=d=Ji_RLN+&=wUz=lH(Ir}A~rWF zwOf<|FgMzbP66MA2qF-N>UJnaSWczgi(@%?V|SHdEWq-}%2gFc!sx$XEH8?mP~_ZT z>)1w$l=sp9WgK=lyN^9Ucz%LC!k%EK3DAGXo@Xzxf3O$XODKVc3arLj0`qzzi$>f` zc)l51u*)Zl0pf~7$Pk=6@Ng_(insebA@Kae_!K^k&*F19j&Bi5JWOaV7~-e+8KLUd6>>wp{qXz>R!iXQRGBE)Qx05bB3RfM*5!^}Ps<+`Dyu%M#z26U7J%CS= zGCo7vIF7I2tN0pm_6Z7JJ?e+A9>*yPUOj`~7CiMm`R83)oV_^wPiI>y&sZzg|GQrP z|9`Socnm!ddf>A30G4znI@{^i@{#hwyA*5fG1~Xjp5Lgg-<%GjT4emCc(VRdJlXv6 lfvAfIZDysV651~Mp8ystlZEg9{G!t - - - diff --git a/Directory.Build.props b/Directory.Build.props index a39f47f..db7f754 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,9 +8,9 @@ In-game configuration manager for LMM and BepInEx mods - https://github.com/BepInEx/BepInEx.ConfigurationManager + BepInEx contributors, Open Lobotomy - Copyright 2019 / LGPL-3.0 + Copyright 2019 BepInEx contributors; Copyright 2026 Open Lobotomy true bin\ diff --git a/LobCorp.ConfigurationManager/LobCorp.ConfigurationManager.csproj b/LobCorp.ConfigurationManager/LobCorp.ConfigurationManager.csproj index 3365fac..b3a8004 100644 --- a/LobCorp.ConfigurationManager/LobCorp.ConfigurationManager.csproj +++ b/LobCorp.ConfigurationManager/LobCorp.ConfigurationManager.csproj @@ -1,6 +1,7 @@ net35 + false - diff --git a/README.md b/README.md index 7b61a0c..d3f88e0 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,7 @@ Harmony 1 (the patching library LMM mods use). ## Who this page is for - **Players**: see [Installation](#installation). -- **Mod authors**: see - [Adding settings to your mod](#adding-settings-to-your-mod). - -If you have never written a Lobotomy Corporation mod before, the -[`samples/SampleMod/`](samples/SampleMod/) folder in this repository -walks through the integration step by step and includes a complete -working example. +- **Mod authors**: see [Adding settings to your mod](#adding-settings-to-your-mod). ## Installation @@ -29,88 +23,23 @@ folder. LMM loads it automatically the next time you start the game. ## Adding settings to your mod -The **Integration package** -(`LobotomyCorporation.Mods.ConfigurationManager.Integration`) is the -recommended way to add settings to your mod. - -Your mod is distributed as one DLL. If the player has -ConfigurationManager installed, your settings appear in the F1 menu. -If not, your mod still runs and your settings keep their default -values in memory. You never copy `ConfigurationManager.dll` into your -mod's folder, and you never add a runtime reference to it. - -→ For a four-step walkthrough with a working example, see -**[samples/SampleMod/README.md](samples/SampleMod/)**. - -A short version follows for readers already familiar with the pattern. +The recommended path is the **Integration package**, which lives in its +own repository: +[open-lobotomy/LobotomyCorporation.Mods.ConfigurationManager.Integration](https://github.com/open-lobotomy/LobotomyCorporation.Mods.ConfigurationManager.Integration). -### Short version — Integration package +That package is a build-time source generator. Your mod references it +with `PrivateAssets="all"` and ships as a single DLL. If the player +installs ConfigurationManager, your settings appear in the F1 menu; if +not, the mod still runs and settings fall back to in-memory defaults. -Add the package to your `.csproj` with `PrivateAssets="all"` -(build-time-only, so the package does not ship inside your mod): +The Integration repo has the full walkthrough, a sample mod, and the +NuGet installation instructions. -```xml - -``` +### Direct dependency (alternative — requires `ConfigurationManager.dll` at runtime) -Mark your assembly and declare your settings: - -```c# -using LobotomyCorporation.Mods.ConfigurationManager; - -[assembly: ConfigManagerMod( - ModId = "com.example.mymod", - ModName = "My Mod", - ModVersion = "1.0.0", - Fallback = ConfigFallback.InMemory)] - -internal static class MyConfig -{ - public static readonly IConfigValue Damage = Config.Bind( - "Combat", "Damage", 100, - description: "Damage per hit", minValue: 1, maxValue: 1000, order: 10); - - public static readonly IConfigValue GodMode = Config.Bind( - "Cheats", "GodMode", false, isAdvanced: true); -} - -public sealed class Harmony_Patch -{ - static Harmony_Patch() - { - var harmony = HarmonyInstance.Create("com.example.mymod"); - harmony.PatchAll(typeof(Harmony_Patch).Assembly); - - Config.RegisterAll(); - } -} -``` - -The Integration package writes the supporting runtime code — a local -`ConfigurationManagerAttributes` copy and a `Config.RegisterAll()` -method that probes for ConfigurationManager at runtime — directly -into your mod's own DLL. The output is a single DLL. - -A few things to know: - -- **Do not alias `Config`.** `using X = Config;` breaks the scanner, - which matches the literal identifier `Config`. -- **If your mod already has a class named `Config`, rename it.** The - `using LobotomyCorporation.Mods.ConfigurationManager;` directive - brings the Integration package's own `Config` class into scope. Two - classes with the same name cause a compile error. -- **`ConfigurationManager.dll` is a fork of - [BepInEx.ConfigurationManager](https://github.com/BepInEx/BepInEx.ConfigurationManager).** - Both share the same DLL name on purpose — only one can load at a - time. If a player installs BepInEx.ConfigurationManager instead of - this fork, your mod still runs, but the F1 menu does not show your - settings. Install LobCorp.ConfigurationManager to see them. - -### Option 2 — Direct dependency (requires `ConfigurationManager.dll` at runtime) - -Use `LmmConfigRegistration` directly. This is a harder dependency — your mod will fail to load if ConfigurationManager is not installed. +If you prefer a hard runtime dependency on ConfigurationManager, use +`LmmConfigRegistration` directly. Your mod will fail to load if +ConfigurationManager is not installed. ```c# using ConfigurationManager.Config; @@ -211,3 +140,13 @@ static void MyDrawer(LmmConfigEntryBase entry) ## BepInEx plugin compatibility ConfigurationManager also discovers BepInEx plugins via reflection — no hard dependency is required. BepInEx plugins using `Config.Bind` will have their settings shown automatically. + +## License + +LGPL-3.0-or-later. See [LICENSE](LICENSE). + +## Related + +- [LobotomyCorporation.Mods.ConfigurationManager.Integration](https://github.com/open-lobotomy/LobotomyCorporation.Mods.ConfigurationManager.Integration) + — MIT-licensed NuGet package for mod authors who want optional-dependency settings + without bundling `ConfigurationManager.dll`. diff --git a/samples/SampleMod/Harmony_Patch.cs b/samples/SampleMod/Harmony_Patch.cs deleted file mode 100644 index e38c771..0000000 --- a/samples/SampleMod/Harmony_Patch.cs +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later - -using System; -using Harmony; -using LobotomyCorporation.Mods.ConfigurationManager; - -namespace SampleMod -{ - /// - /// Entry point that LMM (Lobotomy Mod Manager, the base game's mod - /// loader) instantiates when this mod loads. Applies Harmony patches - /// and registers our configuration bindings. - /// - /// - /// LMM loads this mod by calling new Harmony_Patch() - /// reflectively. The first time any code touches the - /// Harmony_Patch type, the CLR runs the static constructor - /// below — that is where all of our setup happens. The instance LMM - /// gets back is a no-op; the work has already been done. - /// - public sealed class Harmony_Patch - { - static Harmony_Patch() - { - try - { - var harmony = HarmonyInstance.Create("com.example.samplemod"); - harmony.PatchAll(typeof(Harmony_Patch).Assembly); - - // Registers every Config.Bind(...) declaration in this - // assembly with ConfigurationManager if it is installed. - // If ConfigurationManager is absent, this call returns - // without error and bindings keep their default values in - // memory. You never write an "is ConfigurationManager - // installed?" check yourself — the source generator emits - // that for you. - Config.RegisterAll(); - } - catch (Exception ex) - { - UnityEngine.Debug.LogError("[SampleMod] Harmony patch failure: " + ex); - } - } - } -} diff --git a/samples/SampleMod/MyConfig.cs b/samples/SampleMod/MyConfig.cs deleted file mode 100644 index 2018cac..0000000 --- a/samples/SampleMod/MyConfig.cs +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: LGPL-3.0-or-later - -using LobotomyCorporation.Mods.ConfigurationManager; - -// The [assembly: ConfigManagerMod] attribute identifies your mod to the -// ConfigurationManager integration. The source generator reads these -// values at build time and uses them when registering your settings -// at runtime. -// -// Fallback = ConfigFallback.InMemory means: if a player runs your mod -// without ConfigurationManager installed, the generator's registration -// helper skips the UI step and your settings live in memory with their -// default values. Your mod still runs. Nothing crashes. No error logs. -[assembly: ConfigManagerMod( - ModId = "com.example.samplemod", - ModName = "Sample Mod", - ModVersion = "0.1.0", - Fallback = ConfigFallback.InMemory -)] - -namespace SampleMod -{ - /// - /// Three example settings showing the most common shapes of - /// Config.Bind: a numeric range, a boolean flag, and a plain - /// string. Read any binding's current value at runtime via - /// MyConfig.Damage.Value, MyConfig.GodMode.Value, and - /// so on. - /// - internal static class MyConfig - { - /// - /// A numeric setting with a range. ConfigurationManager renders - /// this as a slider because minValue and maxValue - /// are set. order controls where the setting appears in - /// the F1 menu relative to other settings in the same section - /// (higher = earlier). - /// - public static readonly IConfigValue Damage = Config.Bind( - section: "Combat", - key: "Damage", - defaultValue: 100, - description: "Damage per hit", - minValue: 1, - maxValue: 1000, - order: 10 - ); - - /// - /// isAdvanced: true hides this setting behind an - /// "Advanced" toggle in the F1 menu, so regular players do not - /// see it by default. - /// - public static readonly IConfigValue GodMode = Config.Bind( - section: "Cheats", - key: "GodMode", - defaultValue: false, - isAdvanced: true - ); - - /// - /// A plain string setting. ConfigurationManager renders a text box. - /// - public static readonly IConfigValue PlayerName = Config.Bind( - section: "General", - key: "PlayerName", - defaultValue: "Manager" - ); - } -} diff --git a/samples/SampleMod/README.md b/samples/SampleMod/README.md deleted file mode 100644 index c0b1e18..0000000 --- a/samples/SampleMod/README.md +++ /dev/null @@ -1,163 +0,0 @@ -# Adding ConfigurationManager settings to your mod - -This page shows how to add in-game settings to your Lobotomy Corporation -mod using the -`LobotomyCorporation.Mods.ConfigurationManager.Integration` package. - -The files in this `SampleMod/` folder are a complete, working example. -**You do not need to copy the whole folder into your mod.** Follow the -steps below to add the integration to a mod you already have, and open -the sample files when you want to compare your code with a finished -version. - -## How it works with and without ConfigurationManager - -Your mod is distributed as **one DLL**. You never copy -`ConfigurationManager.dll` into your mod's folder. You never add a -runtime reference to it. - -- **If the player has ConfigurationManager installed**, your settings - appear in the in-game F1 menu and save to disk. -- **If the player does not have ConfigurationManager installed**, your - mod still loads. Your settings keep their default values in memory. - Nothing crashes. Nothing logs errors. - -You do not write the code that detects ConfigurationManager. The -Integration package generates that for you. You only call -`Config.RegisterAll()` once, from your mod's entry point. - -## Four steps - -### 1. Add the Integration package to your `.csproj` - -Add this inside any `` in your mod's `.csproj`: - -```xml - -``` - -`PrivateAssets="all"` means "use this package at build time only; do -not include it in my output DLL." That is how your mod stays a single -DLL. - -> The sample here uses a `ProjectReference` instead, because it builds -> from inside this repository. See `SampleMod.csproj` for the full -> project file. Your own mod uses the `PackageReference` shown above. - -### 2. Identify your mod to the integration - -Add this once per mod, at the top of any `.cs` file: - -```c# -using LobotomyCorporation.Mods.ConfigurationManager; - -[assembly: ConfigManagerMod( - ModId = "com.example.mymod", - ModName = "My Mod", - ModVersion = "1.0.0", - Fallback = ConfigFallback.InMemory -)] -``` - -- `ModId` — a unique identifier for your mod. Reverse-domain format - (for example `com.yourname.yourmod`) keeps it unique across every - mod a player installs. -- `ModName` — the heading players see for your mod in the F1 menu. -- `ModVersion` — shown next to the mod name in the F1 menu. -- `Fallback = ConfigFallback.InMemory` — what happens when - ConfigurationManager is not installed. `InMemory` means "keep values - in memory with their defaults." This is the only supported value - today. - -> See `MyConfig.cs` in this folder for this attribute in context. - -### 3. Declare your settings - -Create a static class that holds your settings. Each setting is one -call to `Config.Bind`: - -```c# -internal static class MyConfig -{ - public static readonly IConfigValue Damage = Config.Bind( - section: "Combat", - key: "Damage", - defaultValue: 100, - description: "Damage per hit", - minValue: 1, - maxValue: 1000); - - public static readonly IConfigValue GodMode = Config.Bind( - section: "Cheats", - key: "GodMode", - defaultValue: false, - isAdvanced: true); -} -``` - -Read a setting at runtime with `MyConfig.Damage.Value`. - -Common options: -- `minValue` / `maxValue` — for numeric settings. Shows a slider in - the F1 menu when both are set. -- `isAdvanced: true` — hides the setting behind an "Advanced" toggle. -- `order` — higher numbers appear earlier within a section. - -> See `MyConfig.cs` for a complete example with three different -> setting shapes. - -### 4. Register your settings when the mod loads - -Your mod's entry point is a class named `Harmony_Patch`. LMM -(Lobotomy Mod Manager — the base game's mod loader) instantiates this -class when your mod loads. Call `Config.RegisterAll()` from its static -constructor: - -```c# -public sealed class Harmony_Patch -{ - static Harmony_Patch() - { - var harmony = HarmonyInstance.Create("com.example.mymod"); - harmony.PatchAll(typeof(Harmony_Patch).Assembly); - - Config.RegisterAll(); - } -} -``` - -That one call: - -- Finds every `Config.Bind` declaration in your assembly. -- Checks whether ConfigurationManager is installed. -- If it is, registers your settings so they appear in the F1 menu. -- If it is not, returns without error. Your settings keep their - defaults in memory. - -> See `Harmony_Patch.cs` for a complete example, including a -> `try`/`catch` wrapper so that a failure during load appears in the -> game's log instead of failing silently. - -## The full sample in this folder - -| File | What to look at | -|---|---| -| `MyConfig.cs` | The `[assembly: ConfigManagerMod]` attribute and three settings: a numeric range, a boolean flag, and a plain string. | -| `Harmony_Patch.cs` | The entry-point pattern and how to wrap setup in `try`/`catch`. | -| `SampleMod.csproj` | The project structure, including how to reference the Integration package at build time only. | - -## Building the sample (optional) - -If you want to build the sample directly from this repository to see -the output: - -```sh -dotnet build samples/SampleMod/SampleMod.csproj -``` - -The output is `samples/SampleMod/bin/net35/SampleMod.dll`. To install -it as a real mod, copy that one file into your LMM -`BaseMods/SampleMod/` folder. diff --git a/samples/SampleMod/SampleMod.csproj b/samples/SampleMod/SampleMod.csproj deleted file mode 100644 index bafa90d..0000000 --- a/samples/SampleMod/SampleMod.csproj +++ /dev/null @@ -1,49 +0,0 @@ - - - - net35 - latest - disable - disable - false - - $(NoWarn);CS8021;NU1702;CS1591;S1118 - true - SampleMod - SampleMod - - - - - - - - - ..\..\external\LobotomyCorp_Data\Managed\0Harmony.dll - false - - - ..\..\external\LobotomyCorp_Data\Managed\UnityEngine.dll - false - - - ..\..\external\LobotomyCorp_Data\Managed\UnityEngine.CoreModule.dll - false - - - From 043b1bf248fba5e0f25bc92345c4a9611e9944a1 Mon Sep 17 00:00:00 2001 From: Chris Tristan <1764856+CTristan@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:10:13 -0700 Subject: [PATCH 5/6] Switch ConfigurationManagerAttributes to fields and centralize suppressions Align ConfigurationManagerAttributes with upstream BepInEx.ConfigurationManager by using public fields (read via Type.GetFields) instead of auto-properties, so a plugin's copy-paste template copied from either source works without modification. Update SettingEntryBase.SetFromAttributes to match. Add ApiVersion = 1 constant on LmmConfigRegistration so the LobotomyCorporation.Mods.ConfigurationManager.Integration source generator can probe at runtime and refuse to register against a mismatched contract instead of silently corrupting bindings. Fold two near-identical Register overloads into one. Move all inline analyzer suppressions (S1104, CS0649, CA1054, IDE0130) to path-scoped sections in .editorconfig. Each section carries a comment explaining why the rule is wrong for that specific file, so reviewers see every exception and its rationale in one place rather than chasing #pragmas and [SuppressMessage] attributes through source. CA1054 widens from per-member to per-file in CommonHelpers.cs; the scope note in .editorconfig flags that tradeoff. --- .editorconfig | 57 +++++++++++++++++++ .../LmmSettingEntryTests.cs | 8 +-- .../Config/LmmConfigRegistration.cs | 37 ++++-------- .../ConfigurationManagerAttributes.cs | 44 +++++++------- .../Implementations/SettingEntryBase.cs | 19 +++---- .../ExcludeFromCodeCoverageAttribute.cs | 2 - .../Utilities/CommonHelpers.cs | 5 -- 7 files changed, 103 insertions(+), 69 deletions(-) diff --git a/.editorconfig b/.editorconfig index 844818a..f70ab69 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,3 +2,60 @@ root = true [*.cs] file_header_template = SPDX-License-Identifier: LGPL-3.0-or-later + +# --------------------------------------------------------------------------- +# Path-scoped analyzer suppressions +# +# Each section below suppresses a specific rule ID in a specific file. The +# scope is deliberately file-level so that the exception cannot silently +# leak to unrelated code. If you add a new suppression, document *why* the +# rule is wrong for that file, not just what is being turned off. +# --------------------------------------------------------------------------- + +# ConfigurationManagerAttributes.cs is the copy-paste template that every +# LMM/BepInEx plugin bundles into its own project. The class is discovered +# by simple type name from each plugin's bundled copy, and both this fork +# and upstream BepInEx.ConfigurationManager read values via +# Type.GetFields(BindingFlags.Instance | BindingFlags.Public). Encapsulating +# the fields as properties would break the reflection contract with every +# plugin that has already copied this template. Public fields are the +# deliberate wire format, not an oversight. +[ConfigurationManagerAttributes.cs] +dotnet_diagnostic.S1104.severity = none + +# LmmSettingEntryTests.cs contains a nested stub type that intentionally +# mirrors the public-field shape of ConfigurationManagerAttributes to +# exercise SetFromAttributes' reflection path. The stub's fields are +# populated via object-initializer syntax inside the test body, but because +# the enclosing type is private, the net35 compiler does not credit that +# assignment and emits CS0649 ("field is never assigned to"). The whole +# point of the test is to simulate a foreign plugin's copy of the +# template, so rewriting the stub to silence the warning would defeat the +# test. +[LmmSettingEntryTests.cs] +dotnet_diagnostic.CS0649.severity = none + +# CommonHelpers.OpenWebsite takes a URL as a plain string because the URL +# arrives as string metadata read out of the game (plugin info, mod config, +# etc.) where there is no System.Uri available. CA1054 ("URI parameters +# should not be strings") correctly flags that pattern in general, but the +# source here is already string-shaped and conversion would just push the +# same unvalidated data through an extra round-trip. Scope note: this is +# slightly wider than the previous per-member suppression — every method +# in CommonHelpers.cs is now exempt from CA1054. OpenWebsite is the only +# URL-shaped signature in the file today; if a future helper introduces a +# genuinely validatable URI parameter, prefer fixing that site over +# leaning on this scope. +[CommonHelpers.cs] +dotnet_diagnostic.CA1054.severity = none + +# ExcludeFromCodeCoverageAttribute.cs is a net35 BCL polyfill: the real +# System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute was +# added in .NET 4.0, so this project ships its own copy under the BCL +# namespace. IDE0130 ("namespace should match folder structure") fires +# because the file lives under Polyfills/ but declares +# namespace System.Diagnostics.CodeAnalysis — that mismatch is the whole +# point of a polyfill and matching the folder name would break the +# compatibility story. +[ExcludeFromCodeCoverageAttribute.cs] +dotnet_diagnostic.IDE0130.severity = none diff --git a/LobCorp.ConfigurationManager.Test/ModTests/ConfigurationManagerTests/LmmSettingEntryTests.cs b/LobCorp.ConfigurationManager.Test/ModTests/ConfigurationManagerTests/LmmSettingEntryTests.cs index 795d023..b3c795f 100644 --- a/LobCorp.ConfigurationManager.Test/ModTests/ConfigurationManagerTests/LmmSettingEntryTests.cs +++ b/LobCorp.ConfigurationManager.Test/ModTests/ConfigurationManagerTests/LmmSettingEntryTests.cs @@ -257,16 +257,16 @@ public void Constructor_NullEntry_ShouldThrowArgumentNullException() /// /// Stand-in for a plugin that bundles its own copy of ConfigurationManagerAttributes. /// SetFromAttributes matches by simple type name, not assembly identity, so this - /// property-shape stub should populate the entry exactly like the real class. + /// field-shape stub should populate the entry exactly like the real class. /// private sealed class ConfigurationManagerAttributes { - public bool? IsAdvanced { get; set; } - public int? Order { get; set; } + public bool? IsAdvanced; + public int? Order; } [Fact] - public void Constructor_ForeignConfigurationManagerAttributesStub_ShouldPopulateFromProperties() + public void Constructor_ForeignConfigurationManagerAttributesStub_ShouldPopulateFromFields() { var stub = new ConfigurationManagerAttributes { IsAdvanced = true, Order = 7 }; var desc = new LmmConfigDescription("test", null, stub); diff --git a/LobCorp.ConfigurationManager/Config/LmmConfigRegistration.cs b/LobCorp.ConfigurationManager/Config/LmmConfigRegistration.cs index 63c05fc..4be6e99 100644 --- a/LobCorp.ConfigurationManager/Config/LmmConfigRegistration.cs +++ b/LobCorp.ConfigurationManager/Config/LmmConfigRegistration.cs @@ -15,6 +15,16 @@ namespace ConfigurationManager.Config )] public static class LmmConfigRegistration { + /// + /// Version of the registration contract between this runtime and the + /// LobotomyCorporation.Mods.ConfigurationManager.Integration source generator. + /// The generator probes this constant at runtime and refuses to register against + /// a mismatched value, so that breaking changes to the registration surface fail + /// loudly instead of silently corrupting bindings. Bump only when the shape of + /// changes in a way incompatible with older generators. + /// + public const int ApiVersion = 1; + private static readonly Dictionary RegisteredMods = new Dictionary(); @@ -65,32 +75,7 @@ public static LmmConfigFile GetConfigFile( /// Config section name to group the setting under. /// Setting key within the section. /// Default value used when no saved value exists. - /// Optional plain-text description shown in the settings UI. - /// Optional version string shown alongside the mod name. - public static LmmConfigEntry Register( - string modId, - string modName, - string section, - string key, - T defaultValue, - string description = null, - string modVersion = "" - ) - { - var configFile = GetConfigFile(modId, modName, modVersion); - return configFile.Bind(section, key, defaultValue, description); - } - - /// - /// Register a single setting with full description. - /// - /// The type of the setting value. - /// Unique mod identifier. - /// Human-readable mod name for display. - /// Config section name to group the setting under. - /// Setting key within the section. - /// Default value used when no saved value exists. - /// Full description including acceptable value constraints. + /// Description carrying optional UI-hint tags and acceptable-value constraints. /// Optional version string shown in the settings UI. public static LmmConfigEntry Register( string modId, diff --git a/LobCorp.ConfigurationManager/ConfigurationManagerAttributes.cs b/LobCorp.ConfigurationManager/ConfigurationManagerAttributes.cs index 9162f5c..6a8fa07 100644 --- a/LobCorp.ConfigurationManager/ConfigurationManagerAttributes.cs +++ b/LobCorp.ConfigurationManager/ConfigurationManagerAttributes.cs @@ -7,11 +7,11 @@ namespace ConfigurationManager /// /// Usage: /// This class template has to be copied inside the plugin's project and referenced by its code directly. - /// Make a new instance, assign any properties that you want to override, and pass it as a tag for your setting. + /// Make a new instance, assign any fields that you want to override, and pass it as a tag for your setting. /// /// - /// If a property is null (default), it will be ignored and won't change how the setting is displayed. - /// If a property is non-null (you assigned a value to it), it will override default behavior. + /// If a field is null (default), it will be ignored and won't change how the setting is displayed. + /// If a field is non-null (you assigned a value to it), it will override default behavior. /// /// /// @@ -27,9 +27,9 @@ namespace ConfigurationManager /// /// /// This fork of ConfigurationManager reads attribute values from a plugin's copy of this class via public - /// properties (Type.GetProperties). Upstream BepInEx.ConfigurationManager uses public fields, so its - /// template is not directly compatible — if copying from upstream, convert the fields to auto-properties. - /// You can optionally remove properties that you won't use from this class, it's the same as leaving them null. + /// fields (Type.GetFields), aligned with upstream BepInEx.ConfigurationManager so that a template + /// copied from either source works without modification. You can optionally remove fields that you won't + /// use from this class, it's the same as leaving them null. /// [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Data holder class for plugin configuration attributes" @@ -39,24 +39,24 @@ public sealed class ConfigurationManagerAttributes /// /// Should the setting be shown as a percentage (only use with value range settings). /// - public bool? ShowRangeAsPercent { get; set; } + public bool? ShowRangeAsPercent; /// /// If true, the slider snaps to whole numbers while the text field still accepts decimals. /// - public bool? UseIntegerSlider { get; set; } + public bool? UseIntegerSlider; /// /// Custom setting editor (OnGUI code that replaces the default editor provided by ConfigurationManager). /// See below for a deeper explanation. Using a custom drawer will cause many of the other fields to do nothing. /// - public System.Action CustomDrawer { get; set; } + public System.Action CustomDrawer; /// /// Custom setting editor that allows polling keyboard input with the Input class. /// Use either CustomDrawer or CustomHotkeyDrawer, using both at the same time leads to undefined behaviour. /// - public CustomHotkeyDrawerFunc CustomHotkeyDrawer { get; set; } + public CustomHotkeyDrawerFunc CustomHotkeyDrawer; /// /// Custom setting draw action that allows polling keyboard input with the Input class. @@ -71,62 +71,62 @@ ref bool isCurrentlyAcceptingInput /// /// Show this setting in the settings screen at all? If false, don't show. /// - public bool? Browsable { get; set; } + public bool? Browsable; /// /// Category the setting is under. Null to be directly under the plugin. /// - public string Category { get; set; } + public string Category; /// /// If set, a "Default" button will be shown next to the setting to allow resetting to default. /// - public object DefaultValue { get; set; } + public object DefaultValue; /// /// Force the "Reset" button to not be displayed, even if a valid DefaultValue is available. /// - public bool? HideDefaultButton { get; set; } + public bool? HideDefaultButton; /// /// Force the setting name to not be displayed. /// - public bool? HideSettingName { get; set; } + public bool? HideSettingName; /// /// Optional description shown when hovering over the setting. /// - public string Description { get; set; } + public string Description; /// /// Name of the setting. /// - public string DispName { get; set; } + public string DispName; /// /// Order of the setting on the settings list relative to other settings in a category. /// 0 by default, higher number is higher on the list. /// - public int? Order { get; set; } + public int? Order; /// /// Only show the value, don't allow editing it. /// - public bool? ReadOnly { get; set; } + public bool? ReadOnly; /// /// If true, don't show the setting by default. User has to turn on showing advanced settings or search for it. /// - public bool? IsAdvanced { get; set; } + public bool? IsAdvanced; /// /// Custom converter from setting type to string for the built-in editor textboxes. /// - public System.Func ObjToStr { get; set; } + public System.Func ObjToStr; /// /// Custom converter from string to setting type for the built-in editor textboxes. /// - public System.Func StrToObj { get; set; } + public System.Func StrToObj; } } diff --git a/LobCorp.ConfigurationManager/Implementations/SettingEntryBase.cs b/LobCorp.ConfigurationManager/Implementations/SettingEntryBase.cs index 5819605..cec6dd1 100644 --- a/LobCorp.ConfigurationManager/Implementations/SettingEntryBase.cs +++ b/LobCorp.ConfigurationManager/Implementations/SettingEntryBase.cs @@ -222,12 +222,12 @@ internal void SetFromAttributes(ICollection attribs, object pluginInstan var attrType = attrib.GetType(); if (attrType.Name == "ConfigurationManagerAttributes") { - var otherProperties = attrType.GetProperties( + var otherFields = attrType.GetFields( BindingFlags.Instance | BindingFlags.Public ); foreach ( - var propertyPair in _myProperties.Join( - otherProperties, + var memberPair in _myProperties.Join( + otherFields, my => my.Name, other => other.Name, (my, other) => new { my, other } @@ -236,32 +236,31 @@ var propertyPair in _myProperties.Join( { try { - var val = propertyPair.other.GetValue(attrib, null); + var val = memberPair.other.GetValue(attrib); if (val != null) { if ( - propertyPair.my.PropertyType - != propertyPair.other.PropertyType + memberPair.my.PropertyType != memberPair.other.FieldType && typeof(Delegate).IsAssignableFrom( - propertyPair.my.PropertyType + memberPair.my.PropertyType ) ) { val = Delegate.CreateDelegate( - propertyPair.my.PropertyType, + memberPair.my.PropertyType, ((Delegate)val).Target, ((Delegate)val).Method ); } - propertyPair.my.SetValue(this, val, null); + memberPair.my.SetValue(this, val, null); } } catch (Exception ex) { SimpleLogger.LogWarning( "Failed to copy value " - + propertyPair.my.Name + + memberPair.my.Name + " from provided tag object " + attrType.FullName + " - " diff --git a/LobCorp.ConfigurationManager/Polyfills/ExcludeFromCodeCoverageAttribute.cs b/LobCorp.ConfigurationManager/Polyfills/ExcludeFromCodeCoverageAttribute.cs index 46ba35a..a477d47 100644 --- a/LobCorp.ConfigurationManager/Polyfills/ExcludeFromCodeCoverageAttribute.cs +++ b/LobCorp.ConfigurationManager/Polyfills/ExcludeFromCodeCoverageAttribute.cs @@ -5,9 +5,7 @@ // rest of the codebase annotate coverage-excluded members without pulling in a dependency // solely for this attribute. -#pragma warning disable IDE0130 // Namespace must match BCL location, not folder path namespace System.Diagnostics.CodeAnalysis -#pragma warning restore IDE0130 { [AttributeUsage( AttributeTargets.Class diff --git a/LobCorp.ConfigurationManager/Utilities/CommonHelpers.cs b/LobCorp.ConfigurationManager/Utilities/CommonHelpers.cs index 9666fd4..4f6692a 100644 --- a/LobCorp.ConfigurationManager/Utilities/CommonHelpers.cs +++ b/LobCorp.ConfigurationManager/Utilities/CommonHelpers.cs @@ -270,11 +270,6 @@ public static string GetWebsite(object pluginInstance) /// /// The URL to open [System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage(Justification = "Process.Start")] - [System.Diagnostics.CodeAnalysis.SuppressMessage( - "Design", - "CA1054", - Justification = "URL is a string from game metadata" - )] public static void OpenWebsite(string url) { if (string.IsNullOrEmpty(url)) From 97c18c872755034c4f2dc58018745680f999ff52 Mon Sep 17 00:00:00 2001 From: Chris Tristan <1764856+CTristan@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:35:24 -0700 Subject: [PATCH 6/6] Fix review findings from post-Integration-split sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LmmConfigRegistration.Register string overload so README "individual settings" example compiles and the Register API matches LmmConfigFile.Bind (which already has both overloads). - Correct README and CLAUDE.md notes about the ConfigurationManagerAttributes template: the fork uses public fields, matching upstream, so copy-as-is works from either source. Previous text told authors to convert to auto-properties, which would silently break attribute matching. - Trim CLAUDE.md Integration/SampleMod audience guidance now that both live in the separate open-lobotomy/LobotomyCorporation.Mods.ConfigurationManager.Integration repo, and update the auto-scan path to the real location. - Drop Microsoft.CodeAnalysis.* and Verify.* PackageVersion entries from Directory.Packages.props — no project references them since the Integration split. --- .github/copilot-instructions.md | 24 +++++++-------- Directory.Packages.props | 12 -------- .../Config/LmmConfigRegistration.cs | 30 +++++++++++++++++-- README.md | 4 +-- 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9e0577d..a4f25c2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,9 +29,9 @@ Tests live in `LobCorp.ConfigurationManager.Test` (xunit.v3, Moq, AwesomeAsserti **Settings discovery (`Implementations/SettingSearcher.cs`):** - LMM mods register via `Config/LmmConfigRegistration.cs` static API -- Auto-scans `BaseMods/{modId}/config.cfg` files +- Auto-scans `{persistentDataPath}/LobotomyBaseMod/{modId}/config.cfg` files - Discovers BepInEx plugins via reflection (`Implementations/BepInExInterop.cs`) — no hard dependency -- Source-gen-based optional-dependency path for mod authors is planned (see `/Users/chris/.claude/plans/cheeky-cooking-puzzle.md`); this repo no longer carries a `LobotomyCorporation.Mods.Common`-based bridge. +- Mod authors who want an optional-dependency path (settings UI when ConfigurationManager is installed, in-memory fallback otherwise) use the separately published [`LobotomyCorporation.Mods.ConfigurationManager.Integration`](https://github.com/open-lobotomy/LobotomyCorporation.Mods.ConfigurationManager.Integration) source-generator package. That repo owns the generator, the sample mod, and the author-facing docs. **Configuration model (`Config/`):** - `LmmConfigFile` — file I/O and parsing for `config.cfg` files @@ -44,12 +44,12 @@ Tests live in `LobCorp.ConfigurationManager.Test` (xunit.v3, Moq, AwesomeAsserti - Type-specific controls: checkboxes, sliders, dropdowns, color pickers, hotkey capture - `ConfigurationManagerAttributes` controls display (order, visibility, custom drawers) -**`ConfigurationManagerAttributes` is a copy-paste template, not a referenced API.** Each plugin bundles its own copy of the class and assigns values to instances that get passed as tags to setting descriptions. `SettingEntryBase.SetFromAttributes()` reads these via reflection (`Type.GetProperties`, matching by simple type name — not assembly identity). This fork uses **public auto-properties**; upstream BepInEx.ConfigurationManager uses **public fields**, so upstream's template is not directly compatible — if copying from upstream, convert the fields to auto-properties. +**`ConfigurationManagerAttributes` is a copy-paste template, not a referenced API.** Each plugin bundles its own copy of the class and assigns values to instances that get passed as tags to setting descriptions. `SettingEntryBase.SetFromAttributes()` reads these via reflection (`Type.GetFields`, matching by simple type name — not assembly identity). This fork uses **public fields**, matching upstream BepInEx.ConfigurationManager, so a template copied from either source works unchanged. ## Key Constraints - **net35 target**: no LINQ extensions beyond what's available, no `System.ValueTuple`, limited BCL. `LangVersion` is set to `latest` so C# syntax features work but BCL APIs are restricted. -- **RootNamespace and AssemblyName are both `ConfigurationManager`** (not `LobCorp.ConfigurationManager`) — intentionally matches upstream BepInEx.ConfigurationManager. This is a **DLL-name / namespace collision prevention** mechanism only: the identical DLL name stops both from loading simultaneously, and the shared root namespace avoids dual-load conflicts (double UI entries, duplicate `ConfigurationManagerAttributes` processing). This is **not** a public-API-compatibility contract — the fork can freely change its internal shape (e.g. `ConfigurationManagerAttributes` was moved from fields to properties). Do not change `RootNamespace` or `AssemblyName` without accounting for the loader-collision implications. +- **RootNamespace and AssemblyName are both `ConfigurationManager`** (not `LobCorp.ConfigurationManager`) — intentionally matches upstream BepInEx.ConfigurationManager. This is a **DLL-name / namespace collision prevention** mechanism only: the identical DLL name stops both from loading simultaneously, and the shared root namespace avoids dual-load conflicts (double UI entries, duplicate `ConfigurationManagerAttributes` processing). This is **not** a public-API-compatibility contract — the fork can freely change its internal shape. Do not change `RootNamespace` or `AssemblyName` without accounting for the loader-collision implications. - **`Harmony_Patch` class name is load-bearing** — every LMM mod must expose an entry type named `Harmony_Patch`. The analyzer package (`LobotomyCorporation.Mods.Analyzers` globalconfig) suppresses S101 and CA1707 repo-wide so this pattern doesn't trip naming rules. - **Game assembly references are `Private=false`** — none are copied to output since they exist in the game's managed folder at runtime. No other runtime DLLs are copied alongside `ConfigurationManager.dll` today (the previous `LobotomyCorporation.Mods.Common` bridge has been removed). - **Implicit usings and nullable are disabled.** @@ -68,18 +68,18 @@ Global analyzers (`LobotomyCorporation.Mods.Analyzers`, `OpenLobotomy.Standards` ## Audience & Language -**Assume the reader is a first-time mod author whose first language is not English.** Most consumers of this repo — both the `ConfigurationManager.dll` end-user install and the `Integration` NuGet package — are Korean-speaking modders reading English as a second language or through machine translation, and many have no prior professional development experience. Every error message, diagnostic, README, and code comment that an author will see must pass that bar before shipping. +This repo ships two audience-facing surfaces: -### Project facts that shape documentation +- **`ConfigurationManager.dll`** — installed by players as an LMM BaseMod. End-user audience (installers, not coders); release notes and the in-game UI should be readable without developer vocabulary. +- **Mod-author docs in this repo** (primarily `README.md`) — for authors who take a **direct runtime dependency** on `ConfigurationManager.dll` via `LmmConfigRegistration`. This is the "hard dependency" path. -- **Lobotomy Corporation itself will never update.** The base game is final. Do not pitch wrappers, adapters, or analyzers on "survives game updates" or "keeps working when the game changes" — those claims are factually wrong and will mislead readers. The honest value props for typed wrappers over reflection are: (a) the compiler checks names and types at build time, so typos fail before you run the game; (b) typed code is shorter and easier to read; (c) the package is community-maintained, so fixes land once for everyone. What *does* still change is LMM (the mod loader) and other mods that patch the same game code via Harmony — if a doc needs to explain why a wrapper helps mods coexist, that is the real reason, not game updates. -- **The Integration package exists so authors can hook into ConfigurationManager *if it is installed*, without bundling or redistributing `ConfigurationManager.dll` themselves.** The mod ships as a single DLL. If the player has ConfigurationManager, settings appear in the F1 menu. If not, the mod still runs and bindings serve defaults from an in-memory store. Never write docs, diagnostics, or examples that imply the mod author has to ship `ConfigurationManager.dll`, reference it at compile time, or detect its presence by hand — the generator's emitted reflection probe handles that. If a reader walks away thinking they need to copy a DLL into their mod folder or add a hard `Reference Include="ConfigurationManager"`, the doc has failed. +The **optional-dependency** path — settings UI when ConfigurationManager is installed, in-memory fallback when not — is owned by the separate [`LobotomyCorporation.Mods.ConfigurationManager.Integration`](https://github.com/open-lobotomy/LobotomyCorporation.Mods.ConfigurationManager.Integration) repo, along with the sample mod. Author-facing docs, NuGet packaging, and analyzer diagnostics for that path live there, not here. -### Package Audiences +**Assume the reader is a first-time mod author whose first language is not English.** Most consumers are Korean-speaking modders reading English as a second language or through machine translation, and many have no prior professional development experience. Every error message, README, and code comment that an author will see must pass that bar before shipping. -- **`ConfigurationManager.dll`** — shipped to players as a BaseMod. End-user audience (installers, not coders); release notes and the in-game UI should be readable without developer vocabulary. -- **`LobotomyCorporation.Mods.ConfigurationManager.Integration`** — consumed by mod authors via a single NuGet reference. The optional-dependency story above is the central value proposition: assume the author found this package because they want settings UI *when available* but are not willing to take a hard runtime dependency. Error messages, analyzer diagnostics, README samples, and generated-code comments should all reinforce that. Explain *why*, not just *what*. Do not assume knowledge of dependency injection, mocking, reflection, source generators, Roslyn analyzers, or build-system internals like `PrivateAssets`/`ReferenceOutputAssembly` — when those terms are unavoidable, define them inline or link to a one-paragraph explainer. Surface failures as clear, actionable messages, not stack traces. -- **`samples/` directory (e.g. `samples/SampleMod/`)** — this bar applies here too. Samples are copy-paste reference material for mod authors; every comment, naming choice, and implicit convention must be readable in isolation on GitHub by someone who has never opened the rest of this repo. Expand acronyms the first time they appear (LMM → Lobotomy Mod Manager), add inline comments on any assembly attribute or pattern that a first-timer would not recognize (e.g. `Fallback = ConfigFallback.InMemory`, the static-initializer entry-point idiom), and ship a README in each sample that states the optional-dependency promise upfront. +### Project facts that shape documentation + +- **Lobotomy Corporation itself will never update.** The base game is final. Do not pitch wrappers, adapters, or analyzers on "survives game updates" or "keeps working when the game changes" — those claims are factually wrong and will mislead readers. The honest value props for typed wrappers over reflection are: (a) the compiler checks names and types at build time, so typos fail before you run the game; (b) typed code is shorter and easier to read; (c) the package is community-maintained, so fixes land once for everyone. What *does* still change is LMM (the mod loader) and other mods that patch the same game code via Harmony — if a doc needs to explain why a wrapper helps mods coexist, that is the real reason, not game updates. ### Writing Style diff --git a/Directory.Packages.props b/Directory.Packages.props index 6de7a74..9fc1c06 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,18 +15,6 @@ - - - - - - diff --git a/LobCorp.ConfigurationManager/Config/LmmConfigRegistration.cs b/LobCorp.ConfigurationManager/Config/LmmConfigRegistration.cs index 4be6e99..482aba1 100644 --- a/LobCorp.ConfigurationManager/Config/LmmConfigRegistration.cs +++ b/LobCorp.ConfigurationManager/Config/LmmConfigRegistration.cs @@ -21,7 +21,7 @@ public static class LmmConfigRegistration /// The generator probes this constant at runtime and refuses to register against /// a mismatched value, so that breaking changes to the registration surface fail /// loudly instead of silently corrupting bindings. Bump only when the shape of - /// changes in a way incompatible with older generators. + /// the Register overloads changes in a way incompatible with older generators. /// public const int ApiVersion = 1; @@ -67,7 +67,33 @@ public static LmmConfigFile GetConfigFile( } /// - /// Register a single setting for a mod. + /// Register a single setting for a mod using a plain description string. + /// + /// The type of the setting value. + /// Unique mod identifier. + /// Human-readable mod name for display. + /// Config section name to group the setting under. + /// Setting key within the section. + /// Default value used when no saved value exists. + /// Optional plain-text description shown in the settings UI. + /// Optional version string shown in the settings UI. + public static LmmConfigEntry Register( + string modId, + string modName, + string section, + string key, + T defaultValue, + string description = null, + string modVersion = "" + ) + { + var configFile = GetConfigFile(modId, modName, modVersion); + return configFile.Bind(section, key, defaultValue, description); + } + + /// + /// Register a single setting for a mod with a full description including + /// acceptable-value constraints and UI-hint tags. /// /// The type of the setting value. /// Unique mod identifier. diff --git a/README.md b/README.md index d3f88e0..0ab7223 100644 --- a/README.md +++ b/README.md @@ -118,8 +118,8 @@ config.Bind("X", "2", 2, new LmmConfigDescription("", null, Important notes about the attributes class: - You do **not** need to reference ConfigurationManager.dll for this to work — it is read via reflection. -- This fork uses **public auto-properties** (not public fields). If copying from upstream BepInEx.ConfigurationManager, convert fields to auto-properties. -- Keep the class name `ConfigurationManagerAttributes` unchanged. You can remove properties you don't use. +- This fork uses **public fields**, matching upstream BepInEx.ConfigurationManager. You can copy the template from either source without changes. +- Keep the class name `ConfigurationManagerAttributes` unchanged. You can remove fields you don't use. - Avoid making the class public to prevent conflicts with other mods. ### Custom setting editors