diff --git a/1.6/Assemblies/FactionLoadout.dll b/1.6/Assemblies/FactionLoadout.dll index 794dc58..00f8606 100644 Binary files a/1.6/Assemblies/FactionLoadout.dll and b/1.6/Assemblies/FactionLoadout.dll differ diff --git a/1.6/Source/FactionEdit.cs b/1.6/Source/FactionEdit.cs index 7d8d5a0..9ee4d1f 100644 --- a/1.6/Source/FactionEdit.cs +++ b/1.6/Source/FactionEdit.cs @@ -15,6 +15,16 @@ public class FactionEdit : IExposable { private static readonly Dictionary originalFactionDefs = new(); private static Dictionary<(FactionDef, PawnKindDef), PawnKindDef> factionSpecificPawnKindReplacements = new(); + + /// FactionDef defName → the FactionEdit applied to it by the active preset(s). Rebuilt on preset apply. + public static readonly Dictionary ActiveFactionEdits = new(); + + /// Returns the active for a runtime faction's def, or null. + public static FactionEdit GetActiveEditFor(FactionDef def) => + def == null ? null + : ActiveFactionEdits.TryGetValue(def.defName, out FactionEdit edit) ? edit + : null; + public bool Active = true; public ThingFilter ApparelStuffFilter; public TechLevel? TechLevel = null; @@ -24,6 +34,17 @@ public class FactionEdit : IExposable private Dictionary preservedFactionModuleXml; public DefRef Faction = new(); + + /// + /// Portable forced PRIMARY ideology reference for this faction (a .rid filename or an + /// defName; null = don't force). Each real faction instance + /// realises this once per save via and has it set as its + /// primary ideology, so the ideology listing matches what pawns actually believe. Pawnkind + /// edits referencing the same source+key resolve to the same realised instance. + /// + public string ForcedPrimaryIdeoKey = null; + public ForcedIdeoSource ForcedPrimaryIdeoSourceKind = ForcedIdeoSource.SavedFile; + public List KindEdits = []; public List PawnGroupMakerEdits = null; public Dictionary xenotypeChances = []; @@ -45,6 +66,8 @@ public void ExposeData() Scribe_Deep.Look(ref ApparelStuffFilter, "apparelStuffFilter"); Scribe_Deep.Look(ref Faction, "faction"); Scribe_Values.Look(ref TechLevel, "techLevel"); + Scribe_Values.Look(ref ForcedPrimaryIdeoKey, "forcedPrimaryIdeoKey"); + Scribe_Values.Look(ref ForcedPrimaryIdeoSourceKind, "forcedPrimaryIdeoSourceKind", ForcedIdeoSource.SavedFile); Scribe_Collections.Look(ref KindEdits, "kindEdits", LookMode.Deep); Scribe_Collections.Look(ref xenotypeChances, "xenotypeChances", LookMode.Value, LookMode.Value); if (Scribe.mode == LoadSaveMode.Saving) @@ -56,6 +79,7 @@ public void ExposeData() if (Scribe.mode != LoadSaveMode.PostLoadInit) return; + xenotypeChances ??= []; MaterializeXenotypeChances(); if (!(xenotypeChances.NullOrEmpty() && xenotypeChancesByDef.NullOrEmpty())) OverrideFactionXenotypes = true; @@ -268,6 +292,7 @@ public static void ClearState() { originalFactionDefs.Clear(); factionSpecificPawnKindReplacements.Clear(); + ActiveFactionEdits.Clear(); } public static FactionDef TryGetOriginal(string factionDefName) @@ -371,6 +396,7 @@ public void Apply(FactionDef def, bool updateDefDatabase = true) // def.apparelStuffFilter = ApparelStuffFilter; def = EnsureOriginal(def); + ActiveFactionEdits[def.defName] = this; // Apply group edits before discovering pawnkinds so that newly added // pawnkinds are visible to the rest of the Apply() pipeline. @@ -464,6 +490,8 @@ private void ReplaceKind(FactionDef faction, PawnKindDef original, PawnKindDef r public void CopyFrom(FactionEdit source) { TechLevel = source.TechLevel; + ForcedPrimaryIdeoKey = source.ForcedPrimaryIdeoKey; + ForcedPrimaryIdeoSourceKind = source.ForcedPrimaryIdeoSourceKind; OverrideFactionXenotypes = source.OverrideFactionXenotypes; xenotypeChances = source.xenotypeChances != null ? new Dictionary(source.xenotypeChances) : []; xenotypeChancesByDef = source.xenotypeChancesByDef != null ? new Dictionary(source.xenotypeChancesByDef) : []; diff --git a/1.6/Source/FactionEditUI.cs b/1.6/Source/FactionEditUI.cs index c15181d..4352a05 100644 --- a/1.6/Source/FactionEditUI.cs +++ b/1.6/Source/FactionEditUI.cs @@ -157,6 +157,38 @@ public override void DoWindowContents(Rect inRect) ); } + // Because this is faction level and these aren't real factions skip + if ( + ModsConfig.IdeologyActive + && Current.Faction is { IsMissing: false } + && Current.Faction?.Def != Preset.SpecialWildManFaction + && Current.Faction?.Def != Preset.SpecialCreepjoinerFaction + && Current.Faction?.Def != Preset.SpecialFactionlessPawnsFaction + ) + { + inner.GapLine(); + string primaryLabel = + ForcedIdeoRefUI.DisabledByClassicMode ? "FactionLoadout_General_IdeoClassicDisabled".Translate().ToString() + : string.IsNullOrEmpty(Current.ForcedPrimaryIdeoKey) ? "FactionLoadout_Faction_PrimaryIdeoNotOverridden".Translate().ToString() + : ForcedIdeoRefUI.DisplayName(Current.ForcedPrimaryIdeoSourceKind, Current.ForcedPrimaryIdeoKey); + if ( + inner.ButtonTextLabeled("FactionLoadout_Faction_PrimaryIdeo".Translate(), primaryLabel, tooltip: "FactionLoadout_Faction_PrimaryIdeoTooltip".Translate()) + && !ForcedIdeoRefUI.DisabledByClassicMode + ) + { + ForcedIdeoRefUI.OpenPicker( + includeFactionPrimary: false, + (source, key) => + { + Current.ForcedPrimaryIdeoSourceKind = source; + Current.ForcedPrimaryIdeoKey = key; + }, + onClear: () => Current.ForcedPrimaryIdeoKey = null, + clearLabel: "FactionLoadout_Faction_PrimaryIdeoNotOverridden".Translate().ToString() + ); + } + } + if ( ModsConfig.BiotechActive && Current.Faction is { IsMissing: false } diff --git a/1.6/Source/ForcedIdeoGameComponent.cs b/1.6/Source/ForcedIdeoGameComponent.cs new file mode 100644 index 0000000..9709526 --- /dev/null +++ b/1.6/Source/ForcedIdeoGameComponent.cs @@ -0,0 +1,448 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using RimWorld; +using UnityEngine; +using Verse; + +namespace FactionLoadout; + +/// Where a forced ideology reference resolves from. +public enum ForcedIdeoSource +{ + /// A user-saved ideology file (.rid) in RimWorld's Ideos folder; key = filename (no extension). + SavedFile, + + /// An shipped by the base game or a mod; key = defName. + Preset, + + /// The pawn's faction's actual primary ideology in the current game; key is unused. + FactionPrimary, +} + +/// +/// Per-save binding between a portable forced-ideology reference (source + key, stored in a +/// ) and the concrete instance realised into THIS game. +/// +/// Presets stay portable, they carry the source and key. The link to a game-specific +/// lives here because it must survive save/load and must not produce duplicate +/// ideos on reload. is NOT serialised by vanilla, so it can't be the +/// key; is serialised, so we bind by id. +/// +/// Realised ideos are registered as minor ideoligions of every faction that uses them, so the +/// ideology listing, vanilla's 4:1 pawn-belief weighting, and GC protection all agree with what +/// raiders actually believe. +/// +/// +public class ForcedIdeoGameComponent : GameComponent +{ + /// Persistent composite reference key -> the realised in this game. + public Dictionary refToIdeoId = new(); + + /// Bucket used in place of a faction for faction-less pawns (wild men, creepjoiners). + public const int NoFactionBucket = -1; + + /// + /// Session-only set of references that fail to realise — a missing .rid file, or a def/file + /// present but unloadable — so we don't retry (and re-log) on every generated pawn. + /// + [Unsaved(false)] + public HashSet failedRefs = []; + + [Unsaved(false)] + public Dictionary<(int loadId, int defIndex, ForcedIdeoSource source, string key), Ideo> resolvedCache = new(); + + public static bool AnyIdeologyEditsActive; + + public ForcedIdeoGameComponent(Game game) { } + + public static ForcedIdeoGameComponent Current => Verse.Current.Game?.GetComponent(); + + public static bool ClassicMode => Find.IdeoManager?.classicMode ?? false; + + public static void RecomputeAnyEditsActive() + { + foreach (FactionEdit edit in FactionEdit.ActiveFactionEdits.Values) + { + if (!string.IsNullOrEmpty(edit.ForcedPrimaryIdeoKey)) + { + AnyIdeologyEditsActive = true; + return; + } + foreach (PawnKindEdit kindEdit in edit.KindEdits) + { + if (!string.IsNullOrEmpty(kindEdit.ForcedIdeoKey)) + { + AnyIdeologyEditsActive = true; + return; + } + } + } + AnyIdeologyEditsActive = false; + } + + /// + /// Runs on new game and on load: refreshes the fast-exit flag, prunes orphaned bindings, + /// applies faction-level forced primaries, pre-realises kind-level references (so the + /// generation cost lands on the loading screen, not mid-raid) + /// + public override void FinalizeInit() + { + if (!ModsConfig.IdeologyActive || ClassicMode) + return; + + RecomputeAnyEditsActive(); + CleanupOrphanedBindings(); + if (!AnyIdeologyEditsActive) + return; + + WarnMissingSavedFiles(); + + foreach (Faction faction in Find.FactionManager.AllFactionsListForReading) + EnsurePrimaryIdeo(faction); + + PreRealizeKindRefs(); + } + + /// + /// If the active preset forces a primary ideology for 's def, + /// realise it and make it the faction's primary. Idempotent. Called from FinalizeInit and + /// from the FactionManager.Add hook (mid-game factions) — NOT from the pawn-generation path. + /// The player's faction is never touched. + /// + public void EnsurePrimaryIdeo(Faction faction) + { + if (faction?.ideos == null || faction.IsPlayer || ClassicMode) + return; + + FactionEdit edit = FactionEdit.GetActiveEditFor(faction.def); + if (edit == null || string.IsNullOrEmpty(edit.ForcedPrimaryIdeoKey) || edit.ForcedPrimaryIdeoSourceKind == ForcedIdeoSource.FactionPrimary) + return; + + Ideo ideo = GetOrInjectIdeo(faction, edit.ForcedPrimaryIdeoSourceKind, edit.ForcedPrimaryIdeoKey); + if (ideo == null) + { + ModCore.Debug($"Forced primary ideology for faction '{faction.Name}' did not resolve ({edit.ForcedPrimaryIdeoSourceKind} '{edit.ForcedPrimaryIdeoKey}')."); + return; + } + if (faction.ideos.IsPrimary(ideo)) + return; + + Ideo oldPrimary = faction.ideos.PrimaryIdeo; + faction.ideos.IdeosMinorListForReading.Remove(ideo); + faction.ideos.SetPrimary(ideo); + + // Keep the faction's face coherent: if the leader was generated on the old primary + // (leaders are often created before the forced primary lands), migrate them. + if (faction.leader?.ideo != null && oldPrimary != null && faction.leader.Ideo == oldPrimary) + faction.leader.ideo.SetIdeo(ideo); + + ModCore.Log($"Set forced primary ideology '{ideo.name}' on faction '{faction.Name}'."); + } + + /// + /// Returns the ideo for the given reference, realising it into the game (and registering it + /// with ) if it isn't already present. Returns null if Ideology is + /// inactive, classic mode is on, the reference can't be resolved, or it's unsafe to touch the + /// loader right now. + /// + public Ideo GetOrInjectIdeo(Faction faction, ForcedIdeoSource source, string key) + { + if (!ModsConfig.IdeologyActive || ClassicMode) + return null; + if (source == ForcedIdeoSource.FactionPrimary) + return faction?.ideos?.PrimaryIdeo; + if (string.IsNullOrEmpty(key)) + return null; + + // Three stages, cheapest first: tuple cache -> persistent id binding -> realise from disk/def. + (int, int, ForcedIdeoSource, string) cacheKey = CacheKeyFor(faction, source, key); + Ideo cached = ResolveFromCache(cacheKey, faction); + if (cached != null) + return cached; + + string refKey = RefKeyFor(faction, source, key); + Ideo bound = ResolveFromBinding(refKey, cacheKey, faction); + if (bound != null) + return bound; + + return failedRefs.Contains(refKey) ? null : RealiseNewIdeo(faction, source, key, refKey, cacheKey); + } + + /// + /// Returns the cached ideo, or null if there's no usable entry; a stale entry whose ideo has left the game is evicted so the caller re-resolves. + /// + public Ideo ResolveFromCache((int, int, ForcedIdeoSource, string) cacheKey, Faction faction) + { + if (!resolvedCache.TryGetValue(cacheKey, out Ideo cached)) + return null; + if (cached != null && Find.IdeoManager.IdeosListForReading.Contains(cached)) + { + EnsureRegisteredWith(faction, cached); + return cached; + } + resolvedCache.Remove(cacheKey); // ideo was removed from the game; re-resolve + return null; + } + + /// + /// Returns the bound ideo and warms the cache, or null if unbound using the id binding that survives save/load. + /// + public Ideo ResolveFromBinding(string refKey, (int, int, ForcedIdeoSource, string) cacheKey, Faction faction) + { + if (!refToIdeoId.TryGetValue(refKey, out int existingId)) + return null; + Ideo existing = FindById(existingId); + if (existing != null) + { + resolvedCache[cacheKey] = existing; + EnsureRegisteredWith(faction, existing); + return existing; + } + refToIdeoId.Remove(refKey); // garbage-collected (no holders left); re-realise + return null; + } + + /// + /// Realise a brand-new ideo from disk/def, register it, and bind it for this save. + /// Deferred while a Scribe pass is active because using the disk-based loader then + /// would corrupt the in-progress save/load. + /// + public Ideo RealiseNewIdeo(Faction faction, ForcedIdeoSource source, string key, string refKey, (int, int, ForcedIdeoSource, string) cacheKey) + { + if (Scribe.mode != LoadSaveMode.Inactive) + { + ModCore.Debug($"Deferring forced ideology realisation for '{refKey}': Scribe is {Scribe.mode}."); + return null; + } + + // Isolate the realisation's RNG so we don't mess with the generating pawn's own sequence. + Rand.PushState(); + try + { + Ideo ideo = source switch + { + ForcedIdeoSource.SavedFile => LoadFromFile(key, refKey), + ForcedIdeoSource.Preset => GenerateFromPreset(key, refKey, faction?.def), + _ => null, + }; + if (ideo == null) + return null; + + Find.IdeoManager.Add(ideo); + refToIdeoId[refKey] = ideo.id; + resolvedCache[cacheKey] = ideo; + EnsureRegisteredWith(faction, ideo); + + ModCore.Log($"Realised forced ideology '{ideo.name}' from {source} '{key}' for faction '{faction?.Name ?? ""}' (id {ideo.id})."); + return ideo; + } + finally + { + Rand.PopState(); + } + } + + /// + /// Registers as a minor ideoligion of so + /// the ideology listing shows it under the faction, vanilla's own pawn-gen weighting can use + /// it, and faction membership blocks GC. Needed on cache hits too: SavedFile refs are shared + /// across instances of one faction def, and each instance registers on first use. + /// + public static void EnsureRegisteredWith(Faction faction, Ideo ideo) + { + if (faction?.ideos == null || faction.ideos.Has(ideo)) + return; + faction.ideos.IdeosMinorListForReading.Add(ideo); + } + + public static (int, int, ForcedIdeoSource, string) CacheKeyFor(Faction faction, ForcedIdeoSource source, string key) => + source == ForcedIdeoSource.SavedFile ? (NoFactionBucket, faction?.def?.index ?? -1, source, key) : (faction?.loadID ?? NoFactionBucket, -1, source, key); + + public static string RefKeyFor(Faction faction, ForcedIdeoSource source, string key) + { + if (source == ForcedIdeoSource.SavedFile) + return "def:" + (faction?.def?.defName ?? "none") + ":" + source + ":" + key; + return (faction?.loadID ?? NoFactionBucket) + ":" + source + ":" + key; + } + + /// Loads an ideology from a .rid file. + public Ideo LoadFromFile(string fileName, string refKey) + { + string path = GenFilePaths.AbsPathForIdeo(fileName); + if (!File.Exists(path)) + { + // Missing on this machine, mark the ref failed so we don't retry for every generated pawn. + failedRefs.Add(refKey); + return null; + } + + if (!GameDataSaveLoader.TryLoadIdeo(path, out Ideo ideo) || ideo == null) + { + failedRefs.Add(refKey); + ModCore.Warn($"Forced ideology file '{fileName}' exists but could not be loaded (invalid .rid file)."); + return null; + } + + IdeoGenerator.InitLoadedIdeo(ideo); + return ideo; + } + + /// + /// Generates an ideology from an (base game / mod). The preset is a + /// generator, not a fixed ideology, so the result is randomised — but we realise it once per + /// faction instance per save and reuse it, so it stays stable within a save. + /// + public Ideo GenerateFromPreset(string defName, string refKey, FactionDef forFaction) + { + IdeoPresetDef preset = DefDatabase.GetNamedSilentFail(defName); + if (preset == null) + { + failedRefs.Add(refKey); + ModCore.Warn($"Forced ideology preset def '{defName}' not found (mod removed?)."); + return null; + } + + FactionDef fac = forFaction ?? Faction.OfPlayerSilentFail?.def ?? DefDatabase.AllDefsListForReading.FirstOrDefault(); + + // We don't let faction meme restrictions block generation, log any conflicts and move on + foreach (MemeDef meme in preset.memes) + { + if (!IdeoUtility.IsMemeAllowedFor(meme, fac)) + ModCore.Warn($"Forced ideology preset '{defName}' contains meme '{meme.defName}' which faction def '{fac?.defName}' does not normally allow."); + } + + // Mirror Page_ChooseIdeoPreset.DoPreset: ensure a structure meme is present before generating. + List memes = preset.memes.ToList(); + if ( + !memes.Any(m => m.category == MemeCategory.Structure) + && DefDatabase + .AllDefsListForReading.Where(m => m.category == MemeCategory.Structure && IdeoUtility.IsMemeAllowedFor(m, fac)) + .TryRandomElement(out MemeDef structure) + ) + { + memes.Add(structure); + } + + return IdeoGenerator.GenerateIdeo(new IdeoGenerationParms(fac, forcedMemes: memes, classicExtra: preset.classicPlus, forceNoWeaponPreference: true)); + } + + public static Ideo FindById(int id) => Enumerable.FirstOrDefault(Find.IdeoManager.IdeosListForReading, t => t.id == id); + + // ==================== Init-time passes ==================== + + /// All refKeys the active preset can currently produce, across live factions. + public HashSet BuildValidRefKeys() + { + HashSet valid = []; + List factions = Find.FactionManager.AllFactionsListForReading; + foreach (KeyValuePair pair in FactionEdit.ActiveFactionEdits) + { + List instances = factions.Where(f => f.def.defName == pair.Key).ToList(); + bool synthetic = instances.Count == 0; // special editor-only factions (wild men etc.) + + AddRef(pair.Value.ForcedPrimaryIdeoSourceKind, pair.Value.ForcedPrimaryIdeoKey); + foreach (PawnKindEdit kindEdit in pair.Value.KindEdits) + AddRef(kindEdit.ForcedIdeoSourceKind, kindEdit.ForcedIdeoKey); + continue; + + void AddRef(ForcedIdeoSource source, string key) + { + if (string.IsNullOrEmpty(key) || source == ForcedIdeoSource.FactionPrimary) + return; + if (synthetic) + { + valid.Add(RefKeyFor(null, source, key)); + return; + } + foreach (Faction f in instances) + valid.Add(RefKeyFor(f, source, key)); + } + } + return valid; + } + + public void CleanupOrphanedBindings() + { + if (refToIdeoId.Count == 0) + return; + + HashSet valid = BuildValidRefKeys(); + List orphaned = refToIdeoId.Keys.Where(k => !valid.Contains(k)).ToList(); + foreach (string refKey in orphaned) + { + Ideo ideo = FindById(refToIdeoId[refKey]); + refToIdeoId.Remove(refKey); + if (ideo == null) + continue; + foreach (Faction faction in Find.FactionManager.AllFactionsListForReading) + { + if (faction.ideos != null && !faction.ideos.IsPrimary(ideo)) + faction.ideos.IdeosMinorListForReading.Remove(ideo); + } + ModCore.Log($"Unbound orphaned forced ideology '{ideo.name}' (ref '{refKey}' no longer in active preset)."); + } + if (orphaned.Count > 0) + resolvedCache.Clear(); + } + + /// One consolidated warning for SavedFile references whose .rid is absent here. + public void WarnMissingSavedFiles() + { + List missing = []; + foreach (FactionEdit edit in FactionEdit.ActiveFactionEdits.Values) + { + Check(edit.ForcedPrimaryIdeoSourceKind, edit.ForcedPrimaryIdeoKey); + foreach (PawnKindEdit kindEdit in edit.KindEdits) + Check(kindEdit.ForcedIdeoSourceKind, kindEdit.ForcedIdeoKey); + continue; + + void Check(ForcedIdeoSource source, string key) + { + if (source != ForcedIdeoSource.SavedFile || string.IsNullOrEmpty(key)) + return; + if (File.Exists(GenFilePaths.AbsPathForIdeo(key))) + return; + if (!missing.Contains(key)) + missing.Add(key); + } + } + + if (missing.NullOrEmpty()) + ModCore.Warn( + $"Forced ideology saved file(s) not found on this machine: {string.Join(", ", missing)}. Affected pawns keep their faction's ideology until the file(s) exist in the Ideos folder." + ); + } + + /// + /// Realises every kind-level reference for existing factions up front, so ideology generation happens on the loading screen instead of mid-raid. + /// Faction primaries are already realised by EnsurePrimaryIdeo. + /// + public void PreRealizeKindRefs() + { + List factions = Find.FactionManager.AllFactionsListForReading; + foreach (KeyValuePair pair in FactionEdit.ActiveFactionEdits) + { + List instances = factions.Where(f => f.def.defName == pair.Key).ToList(); + foreach (PawnKindEdit kindEdit in pair.Value.KindEdits) + { + if (string.IsNullOrEmpty(kindEdit.ForcedIdeoKey) || kindEdit.ForcedIdeoSourceKind == ForcedIdeoSource.FactionPrimary) + continue; + if (instances.Count == 0) + { + GetOrInjectIdeo(null, kindEdit.ForcedIdeoSourceKind, kindEdit.ForcedIdeoKey); + continue; + } + foreach (Faction faction in instances) + GetOrInjectIdeo(faction, kindEdit.ForcedIdeoSourceKind, kindEdit.ForcedIdeoKey); + } + } + } + + public override void ExposeData() + { + Scribe_Collections.Look(ref refToIdeoId, "refToIdeoId", LookMode.Value, LookMode.Value); + if (Scribe.mode == LoadSaveMode.PostLoadInit) + refToIdeoId ??= new Dictionary(); + } +} diff --git a/1.6/Source/ModCore.cs b/1.6/Source/ModCore.cs index 25a9e18..ac0b530 100644 --- a/1.6/Source/ModCore.cs +++ b/1.6/Source/ModCore.cs @@ -109,6 +109,18 @@ private void LoadLate() AccessTools.Method(typeof(PawnGenerator), "GenerateNewPawnInternal"), postfix: new HarmonyMethod(AccessTools.Method(typeof(PawnGenPatchCore), nameof(PawnGenPatchCore.Postfix))) ); + // Forced-ideology hooks are meaningless without the DLC + if (ModsConfig.IdeologyActive) + { + harmony.Patch( + AccessTools.Method(typeof(PawnGenerator), "GenerateNewPawnInternal"), + prefix: new HarmonyMethod(AccessTools.Method(typeof(PawnGenPatchIdeo), nameof(PawnGenPatchIdeo.Prefix))) + ); + harmony.Patch( + AccessTools.Method(typeof(FactionManager), nameof(FactionManager.Add)), + postfix: new HarmonyMethod(AccessTools.Method(typeof(FactionAddIdeoPatch), nameof(FactionAddIdeoPatch.Postfix))) + ); + } harmony.Patch( AccessTools.Method(typeof(PawnGenerator), nameof(PawnGenerator.GenerateRandomAge)), prefix: new HarmonyMethod(AccessTools.Method(typeof(PawnGenAgePatchCore), nameof(PawnGenAgePatchCore.Prefix))) @@ -143,6 +155,7 @@ private void LoadLate() Preset.AddMissingSpecialFactionsIfNeeded(); RewarmVEFactionCache(); + ForcedIdeoGameComponent.RecomputeAnyEditsActive(); Log($"Game comp finalized init, applied {count} presets that affected {edits} factions."); } @@ -178,6 +191,7 @@ public static void ReapplyAfterHotReload() Preset.AddMissingSpecialFactionsIfNeeded(); RewarmVEFactionCache(); + ForcedIdeoGameComponent.RecomputeAnyEditsActive(); Log($"Reapply after hot reload complete: applied {count} presets affecting {edits} factions."); } diff --git a/1.6/Source/MySettings.cs b/1.6/Source/MySettings.cs index c0f5f22..0f07413 100644 --- a/1.6/Source/MySettings.cs +++ b/1.6/Source/MySettings.cs @@ -9,6 +9,7 @@ public class MySettings : ModSettings public static bool VerboseLogging = false; public static bool PatchKindInRequests = false; public static bool IgnorePriceLimits = false; + public static bool OverrideForcedIdeos = false; public override void ExposeData() { @@ -19,5 +20,6 @@ public override void ExposeData() Scribe_Values.Look(ref VerboseLogging, "verboseLogging", false); Scribe_Values.Look(ref PatchKindInRequests, "patchKindInRequests", false); Scribe_Values.Look(ref IgnorePriceLimits, "ignorePriceLimits", false); + Scribe_Values.Look(ref OverrideForcedIdeos, "overrideForcedIdeos", false); } } diff --git a/1.6/Source/Patches/ApparelGenPatch.cs b/1.6/Source/Patches/ApparelGenPatch.cs index a6f66b0..b273658 100644 --- a/1.6/Source/Patches/ApparelGenPatch.cs +++ b/1.6/Source/Patches/ApparelGenPatch.cs @@ -328,7 +328,8 @@ private static void HandleApparelPriceLimit(Pawn pawn) ); } - if (MySettings.IgnorePriceLimits) WearFallbackApparel(pawn, pair); + if (MySettings.IgnorePriceLimits) + WearFallbackApparel(pawn, pair); } /// @@ -338,9 +339,7 @@ private static void HandleApparelPriceLimit(Pawn pawn) private static bool BodyHasTorso(Pawn pawn) => Enumerable.Any(pawn.RaceProps?.body?.AllParts ?? [], t => t.IsInGroup(BodyPartGroupDefOf.Torso)); private static bool CoversTorso(Pawn pawn) => - pawn.apparel?.WornApparel - ?.Select(t => t.def.apparel?.bodyPartGroups) - .Any(groups => groups != null && groups.Contains(BodyPartGroupDefOf.Torso)) ?? false; + pawn.apparel?.WornApparel?.Select(t => t.def.apparel?.bodyPartGroups).Any(groups => groups != null && groups.Contains(BodyPartGroupDefOf.Torso)) ?? false; private static ThingStuffPair? CheapestEligibleTorsoApparel(Pawn pawn) { diff --git a/1.6/Source/Patches/PawnGenPatch.cs b/1.6/Source/Patches/PawnGenPatch.cs index 990ac8f..8205ff7 100644 --- a/1.6/Source/Patches/PawnGenPatch.cs +++ b/1.6/Source/Patches/PawnGenPatch.cs @@ -172,3 +172,56 @@ public static bool Prefix(Pawn_GuestTracker __instance, Pawn ___pawn) return false; } } + +/// +/// Forces per-pawnkind ideologies by setting in a +/// PREFIX, so vanilla assigns the belief before it rolls ideology-styled hair, tattoos, and +/// precept apparel — a post-generation SetIdeo would leave pawns visually contradicting their +/// forced ideoligion. Registered in ModCore only when the Ideology DLC is active. +/// +public static class PawnGenPatchIdeo +{ + [HarmonyPrefix] + public static void Prefix(ref PawnGenerationRequest request) + { + if (!ForcedIdeoGameComponent.AnyIdeologyEditsActive || request.ForceNoIdeo || request.KindDef == null || (request.FixedIdeo != null && !MySettings.OverrideForcedIdeos)) + return; + + ForcedIdeoGameComponent forcedIdeoGameComponent = ForcedIdeoGameComponent.Current; + if (forcedIdeoGameComponent == null) + return; + + string key = null; + ForcedIdeoSource source = ForcedIdeoSource.SavedFile; + foreach (PawnKindEdit edit in PawnKindEdit.GetEditsFor(request.KindDef, request.Faction?.def)) + { + if (!string.IsNullOrEmpty(edit.ForcedIdeoKey) && (!edit.IsGlobal || key == null)) + { + key = edit.ForcedIdeoKey; + source = edit.ForcedIdeoSourceKind; + } + } + + if (key == null) + return; + + Ideo forced = forcedIdeoGameComponent.GetOrInjectIdeo(request.Faction, source, key); + if (forced != null) + request.FixedIdeo = forced; + } +} + +/// +/// Applies faction-level forced primary ideologies the moment a faction enters the game — +/// covers world generation and mid-game factions (quests) without any per-pawn work. +/// +public static class FactionAddIdeoPatch +{ + [HarmonyPostfix] + public static void Postfix(Faction faction) + { + if (!ForcedIdeoGameComponent.AnyIdeologyEditsActive) + return; + ForcedIdeoGameComponent.Current?.EnsurePrimaryIdeo(faction); + } +} diff --git a/1.6/Source/PawnKindEdit.cs b/1.6/Source/PawnKindEdit.cs index 9f7e1a2..7ece262 100644 --- a/1.6/Source/PawnKindEdit.cs +++ b/1.6/Source/PawnKindEdit.cs @@ -155,6 +155,15 @@ public static void AddActiveEdit(PawnKindDef def, PawnKindEdit edit) public Dictionary ForcedXenotypeChances = new(); public Dictionary ForcedXenotypeChanceDefs = new(); public Gender? ForcedGender = null; + + /// + /// Portable forced-ideology reference: a .rid filename (when + /// is ) or an + /// defName (when ). + /// At pawn generation this is realised once per save and reused via . + /// + public string ForcedIdeoKey = null; + public ForcedIdeoSource ForcedIdeoSourceKind = ForcedIdeoSource.SavedFile; public SimpleCurve RaidCommonalityFromPointsCurve = null; public SimpleCurve RaidLootValueFromPointsCurve = null; public SimpleCurve MaxPawnCostPerTotalPointsCurve = null; @@ -253,6 +262,8 @@ public void ExposeData() Scribe_Values.Look(ref Label, "label"); Scribe_Defs.Look(ref Race, "race"); Scribe_Values.Look(ref ForcedGender, "forcedGender"); + Scribe_Values.Look(ref ForcedIdeoKey, "forcedIdeoKey"); + Scribe_Values.Look(ref ForcedIdeoSourceKind, "forcedIdeoSourceKind", ForcedIdeoSource.SavedFile); ScribeMigrateDefRefList(ref BodyTypes, "bodyTypes"); ScribeMigrateDefRefList(ref CustomBeards, "customBeards"); ScribeMigrateDefRefList(ref CustomHair, "customHair"); @@ -271,6 +282,9 @@ public void ExposeData() if (Scribe.mode == LoadSaveMode.PostLoadInit) { + // A missing node leaves the dict null (hand-edited or + // third-party-generated preset XML) — restore the field's default instead of NREing. + ForcedXenotypeChances ??= new Dictionary(); ForcedXenotypeChanceDefs = ForcedXenotypeChances .Select(kvp => (Def: DefDatabase.GetNamedSilentFail(kvp.Key), Value: kvp.Value)) .Where(c => c.Def != null) @@ -488,8 +502,7 @@ private static void ScribeMigrateDefRefList(ref List> field, string } } - private static bool IsDefListOldFormat(XmlNode collectionNode) => - collectionNode != null && collectionNode.HasChildNodes && collectionNode.SelectSingleNode("li/defName") == null; + private static bool IsDefListOldFormat(XmlNode collectionNode) => collectionNode is { HasChildNodes: true } && collectionNode.SelectSingleNode("li/defName") == null; // ==================== Queries ==================== diff --git a/1.6/Source/Tabs/GeneralTab.cs b/1.6/Source/Tabs/GeneralTab.cs index e8e778f..9dd4bc0 100644 --- a/1.6/Source/Tabs/GeneralTab.cs +++ b/1.6/Source/Tabs/GeneralTab.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; +using System.IO; using FactionLoadout.UISupport; using FactionLoadout.Util; using RimWorld; @@ -37,6 +39,12 @@ protected override void DrawContents(Listing_Standard ui) ); DrawOverride(ui, Gender.None, ref Current.ForcedGender, "FactionLoadout_General_ForcedGender".Translate().ToString(), DrawGender, pasteGet: e => e.ForcedGender); + + if (ModsConfig.IdeologyActive && !isAnimal) + { + DrawIdeoOverride(ui); + } + DrawOverride(ui, DefaultKind.label, ref Current.Label, "FactionLoadout_General_CustomName".Translate().ToString(), DrawCustomName, pasteGet: e => e.Label); DrawOverride( ui, @@ -226,4 +234,57 @@ private void DrawUnwaveringlyLoyalChance(Rect rect, bool active, float def) { DrawChance(ref Current.UnwaveringlyLoyalChance, def, rect, active); } + + private void DrawIdeoOverride(Listing_Standard ui) + { + Rect headerRect = ui.GetRect(Text.LineHeight); + Widgets.Label(headerRect, "" + "FactionLoadout_General_ForcedIdeo".Translate() + ""); + TooltipHandler.TipRegion(headerRect, "FactionLoadout_General_ForcedIdeoTooltip".Translate()); + + if (ForcedIdeoRefUI.DisabledByClassicMode) + { + Rect disabledRow = ui.GetRect(32); + GUI.color = Color.grey; + Widgets.Label(disabledRow, "FactionLoadout_General_IdeoClassicDisabled".Translate()); + GUI.color = Color.white; + ui.Gap(); + return; + } + + Rect row = ui.GetRect(32); + bool active = Current.ForcedIdeoKey != null; + + string toggleLabel = "FactionLoadout_OverrideYesNo".Translate(active ? "#81f542" : "#ff4d4d", active ? "Yes".Translate() : "No".Translate()); + const float toggleW = 120f; + Rect toggleRect = new Rect(row.x, row.y, toggleW, 32); + if (Widgets.ButtonText(toggleRect, toggleLabel)) + { + Current.ForcedIdeoKey = active ? null : ""; + active = !active; + } + + Rect contentRect = new(row.x + toggleW + 4, row.y, row.width - toggleW - 6, 32); + + if (!active) + { + string txt = Current.IsGlobal ? "---" : "FactionLoadout_General_FactionDefault".Translate().ToString(); + Widgets.Label(contentRect.GetCentered(txt), txt); + ui.Gap(); + return; + } + + if (Widgets.ButtonText(contentRect, ForcedIdeoRefUI.DisplayName(Current.ForcedIdeoSourceKind, Current.ForcedIdeoKey))) + { + ForcedIdeoRefUI.OpenPicker( + includeFactionPrimary: true, + (source, key) => + { + Current.ForcedIdeoSourceKind = source; + Current.ForcedIdeoKey = key; + } + ); + } + + ui.Gap(); + } } diff --git a/1.6/Source/UISupport/CustomFloatMenu.cs b/1.6/Source/UISupport/CustomFloatMenu.cs index 7c0d4f3..6b880b8 100644 --- a/1.6/Source/UISupport/CustomFloatMenu.cs +++ b/1.6/Source/UISupport/CustomFloatMenu.cs @@ -7,13 +7,14 @@ namespace FactionLoadout.UISupport; public class CustomFloatMenu : Window { - public static CustomFloatMenu Open(List items, Action onSelected, int columns = 2) + public static CustomFloatMenu Open(List items, Action onSelected, int columns = 2, bool stretchItems = false) { CustomFloatMenu created = new() { Items = items, OnSelected = onSelected, Columns = columns, + StretchItems = stretchItems, closeOnAccept = false, closeOnCancel = true, closeOnClickedOutside = true, @@ -54,6 +55,9 @@ public static List MakeItems(IEnumerable rawItems, FuncStretch item widths so the columns fill the window instead of leaving dead space. + public bool StretchItems; + private readonly List preRenderItems = []; private float lastHeight, @@ -93,6 +97,14 @@ public override void DoWindowContents(Rect inRect) preRenderItems.AddRange(FilteredItems(SearchString)); int perColumnTarget = Mathf.CeilToInt((float)preRenderItems.Count / Columns); + if (StretchItems) + { + // 16 for the scrollbar, minus inter-column padding, split evenly. + float columnWidth = (inRect.width - 16f - (Columns - 1) * 12f) / Columns; + foreach (MenuItemBase item in preRenderItems) + item.SetWidth(columnWidth); + } + float padding = 6; float x = 0; @@ -184,6 +196,9 @@ public abstract class MenuItemBase : IComparable /// Returns the item's layout size without drawing. Used for virtual scrolling. public abstract Vector2 GetSize(); + + /// Adjust the item's layout width (used by ). No-op by default. + public virtual void SetWidth(float width) { } } public class MenuItemText : MenuItemBase @@ -215,6 +230,8 @@ public override bool Matches(string search) return drawLabel != null; } + public override void SetWidth(float width) => Size.x = width; + public override int CompareTo(MenuItemBase other) { if (other is MenuItemText txt) diff --git a/1.6/Source/UISupport/Dialog_FactionLoadout.cs b/1.6/Source/UISupport/Dialog_FactionLoadout.cs index 164bf84..47ee2b7 100644 --- a/1.6/Source/UISupport/Dialog_FactionLoadout.cs +++ b/1.6/Source/UISupport/Dialog_FactionLoadout.cs @@ -53,6 +53,11 @@ public override void DoWindowContents(Rect inRect) "FactionLoadout_Settings_PatchKindInRequestsDesc".Translate() ); ui.CheckboxLabeled("FactionLoadout_Settings_IgnorePrice".Translate(), ref MySettings.IgnorePriceLimits, "FactionLoadout_Settings_IgnorePriceDesc".Translate()); + ui.CheckboxLabeled( + "FactionLoadout_Settings_OverrideForcedIdeos".Translate(), + ref MySettings.OverrideForcedIdeos, + "FactionLoadout_Settings_OverrideForcedIdeosDesc".Translate() + ); ui.GapLine(); ui.Label("FactionLoadout_Settings_FactionPresetDesc".Translate()); ui.GapLine(); diff --git a/1.6/Source/UISupport/ForcedIdeoRefUI.cs b/1.6/Source/UISupport/ForcedIdeoRefUI.cs new file mode 100644 index 0000000..d69b2d1 --- /dev/null +++ b/1.6/Source/UISupport/ForcedIdeoRefUI.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.IO; +using RimWorld; +using UnityEngine; +using Verse; + +namespace FactionLoadout.UISupport; + +/// Shared float-menu builder and display helper for forced-ideology references. +public static class ForcedIdeoRefUI +{ + /// Sentinel key stored when the source is (the key itself is unused). + public const string FactionPrimaryKey = "primary"; + + /// Row width for picker entries — preset labels plus suffixes run long. + public const float PickerItemWidth = 270f; + + /// + /// Opens ideology picker with def-based ones, with their icons, then user-saved .rid files. + /// + public static void OpenPicker(bool includeFactionPrimary, Action onPick, Action onClear = null, string clearLabel = null) + { + List items = []; + + if (onClear != null) + { + items.Add(MakeItem(null, clearLabel ?? "FactionLoadout_None".Translate().ToString(), null, null)); + } + + if (includeFactionPrimary) + { + items.Add( + MakeItem( + (ForcedIdeoSource.FactionPrimary, FactionPrimaryKey), + "FactionLoadout_General_IdeoFactionPrimary".Translate().ToString(), + "FactionLoadout_General_IdeoFactionPrimaryPickTooltip".Translate().ToString(), + null + ) + ); + } + + List presets = []; + foreach (IdeoPresetDef preset in DefDatabase.AllDefsListForReading) + { + string label = BuildPresetLabel(preset.LabelCap.ToString()); + string tip = "FactionLoadout_General_IdeoPresetPickTooltip".Translate().ToString(); + if (!preset.description.NullOrEmpty()) + tip += "\n\n" + preset.description; + presets.Add(MakeItem((ForcedIdeoSource.Preset, preset.defName), label, tip, preset.Icon)); + } + presets.Sort(); + items.AddRange(presets); + + List files = []; + foreach (FileInfo file in GenFilePaths.AllCustomIdeoFiles) + { + string fileName = Path.GetFileNameWithoutExtension(file.Name); + string label = BuildSavedFileLabel(fileName, isMissing: false); + files.Add(MakeItem((ForcedIdeoSource.SavedFile, fileName), label, "FactionLoadout_General_IdeoSavedPickTooltip".Translate().ToString(), null)); + } + files.Sort(); + items.AddRange(files); + + if (items.Count == 0) + { + Messages.Message("FactionLoadout_General_IdeoNoTemplates".Translate(), MessageTypeDefOf.RejectInput, historical: false); + return; + } + + CustomFloatMenu.Open( + items, + item => + { + if (item.Payload == null) + { + onClear?.Invoke(); + return; + } + (ForcedIdeoSource source, string key) = ((ForcedIdeoSource, string))item.Payload; + onPick(source, key); + ForcedIdeoGameComponent.AnyIdeologyEditsActive = true; + }, + columns: 1, + stretchItems: true + ); + } + + public static MenuItemText MakeItem(object payload, string label, string tooltip, Texture2D icon) => + new(payload, label, icon, tooltip: tooltip) { Size = new Vector2(PickerItemWidth, 28f) }; + + /// + /// Label for a stored reference. SavedFile references whose .rid is absent get a red warning. + /// + public static string DisplayName(ForcedIdeoSource source, string key) + { + if (string.IsNullOrEmpty(key)) + return "FactionLoadout_None".Translate().ToString(); + + switch (source) + { + case ForcedIdeoSource.FactionPrimary: + return "FactionLoadout_General_IdeoFactionPrimary".Translate().ToString(); + case ForcedIdeoSource.Preset: + IdeoPresetDef preset = DefDatabase.GetNamedSilentFail(key); + string label = preset != null ? preset.LabelCap.ToString() : key; + return BuildPresetLabel(label); + case ForcedIdeoSource.SavedFile: + default: + return BuildSavedFileLabel(key, isMissing: !File.Exists(GenFilePaths.AbsPathForIdeo(key))); + } + } + + private static string BuildPresetLabel(string presetName) => "FactionLoadout_General_IdeoPresetLabel".Translate(presetName).ToString(); + + private static string BuildSavedFileLabel(string fileName, bool isMissing) + { + if (!isMissing) + return "FactionLoadout_General_IdeoSavedLabel".Translate(fileName).ToString(); + + string missingMarker = "FactionLoadout_General_IdeoFileMissing".Translate().ToString(); + return "FactionLoadout_General_IdeoSavedLabelMissing".Translate(fileName, missingMarker).ToString(); + } + + /// Whether ideology overrides are unavailable in the current game (classic mode). + public static bool DisabledByClassicMode => Current.Game != null && ForcedIdeoGameComponent.ClassicMode; +} diff --git a/CLAUDE.md b/CLAUDE.md index 0f53d28..b1d23d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,9 @@ Two runtime-created factions (`SpecialWildManFaction`, `SpecialCreepjoinerFactio field = null; // ← not allowed ``` +### UI Pickers +- **Never use vanilla `FloatMenu` for def/item selection lists** — with mods installed these lists grow unbounded and a FloatMenu fills the whole screen with no filtering. Use the in-house **`CustomFloatMenu`** (`UISupport/CustomFloatMenu.cs`): searchable, scrollable, virtualized, multi-column, supports icons and tooltips. Build entries with `MenuItemText`/`MenuItemIcon` and open via `CustomFloatMenu.Open(items, onSelected)` (see `Dialog_XenotypeEdit` and `ForcedIdeoRefUI.OpenPicker` for examples). Plain `FloatMenu` is acceptable only for short fixed lists (a handful of enum-like choices). + ### Translations - **All user-facing strings must use translation keys**, never hardcoded English text. Add keys to `Common/Languages/English/Keyed/FactionLoadout_Keys.xml` and reference them via `"KeyName".Translate()`. - This includes UI labels, button text, tooltips, section headers, and default/placeholder text. diff --git a/Common/Languages/English/Keyed/FactionLoadout_Keys.xml b/Common/Languages/English/Keyed/FactionLoadout_Keys.xml index 0abef3f..2889e43 100644 --- a/Common/Languages/English/Keyed/FactionLoadout_Keys.xml +++ b/Common/Languages/English/Keyed/FactionLoadout_Keys.xml @@ -46,6 +46,8 @@ Patch the pawn kind set in PawnGenerationRequests. Should catch cases where the game generates a pawn without taking the patched version from the faction when generating pawns like for royal permits. Currently experimental. Ignore price limits when necessary When a Total Control-managed pawn can't afford any allowed weapon or torso apparel within its budget, equip the cheapest matching option anyway instead of leaving the slot empty. With verbose logging on, such cases are logged even when this is off, so you know to raise the budget. + Override Forced Ideoligions + Allow Total Control Ideoligion overrides to apply even to pawns with forced ideoligions like traders, warning, may affect quests if they require a specific ideoligion. Tech Level Not Overriden ({0}) @@ -285,7 +287,7 @@ Mode: Allowlist (only these) Mode: Blocklist (all except these) Allowed: {0} - No material allowed by this kind's material rule can make this item - it will be skipped when the pawn is generated. + No material allowed by this kind's material rule can make this item, it will be skipped when the pawn is generated. Override: <color={0}>{1}</color> @@ -319,6 +321,24 @@ - Forced By Global This will give the cloned pawn kind a new name\nThis may have unintended consequences and may break existing pawns spawned for this faction. + + Forced Ideo + Forces pawns of this kind to follow a specific ideo instead of their faction's default.\n\n- Presets create a NEW random ideo in that style, the name and details will differ in every save.\n- Saved files reproduce your exact ideo, (save one in-game via the Ideoligion tab's Save button). Files live on this computer and are not carried inside a shared preset.\n- A forced ideo becomes a minor belief of the whole faction, so a few pawns of other kinds may adopt it too unless forced.\n\nEach choice is created once per faction per save and then reused. Disabled in classic ideo mode. + Faction's primary ideo + Pin to the faction's current primary ideology, never a minor one. Use this to lock out minor-ideo assignment while still tracking whatever ideo the faction ends up with + {0} (preset) + {0} (saved file) + {0} (saved file) <color=red>{1}</color> + Generates a NEW random ideo in this style. Each faction and each save rolls its own, the name and precepts will differ. + Reproduces this exact saved ideo. The file lives on this computer only, sharing a preset does not include it; the recipient needs the same file in their Ideos folder. + (file missing!) + Ideo overrides are disabled in classic ideo mode. + No ideo presets or saved ideo files found. Save an ideo in-game via the Ideoligion tab's Save button. + Primary Ideo + Not overridden (game decides) + Forces this faction's primary ideo. Each faction in a game realises this choice once and keeps it. Pawnkind edits that pick the same ideology automatically share it; pawnkinds picking a different one become minority believers within the faction. Does not affect the player faction. + Use faction ideology + Forced Beard Styles Forced Hair Styles diff --git a/Compatibility/CombatExtended/1.6/Assemblies/TotalControlCECompat.dll b/Compatibility/CombatExtended/1.6/Assemblies/TotalControlCECompat.dll index 065783e..09fa504 100644 Binary files a/Compatibility/CombatExtended/1.6/Assemblies/TotalControlCECompat.dll and b/Compatibility/CombatExtended/1.6/Assemblies/TotalControlCECompat.dll differ diff --git a/Compatibility/GiddyUp/1.6/Assemblies/TotalControlGiddyUpCompat.dll b/Compatibility/GiddyUp/1.6/Assemblies/TotalControlGiddyUpCompat.dll index 2ce5ae1..d5525d5 100644 Binary files a/Compatibility/GiddyUp/1.6/Assemblies/TotalControlGiddyUpCompat.dll and b/Compatibility/GiddyUp/1.6/Assemblies/TotalControlGiddyUpCompat.dll differ diff --git a/Compatibility/VEPsycasts/1.6/Assemblies/TotalControlVEPsycastsCompat.dll b/Compatibility/VEPsycasts/1.6/Assemblies/TotalControlVEPsycastsCompat.dll index 18a5495..a370002 100644 Binary files a/Compatibility/VEPsycasts/1.6/Assemblies/TotalControlVEPsycastsCompat.dll and b/Compatibility/VEPsycasts/1.6/Assemblies/TotalControlVEPsycastsCompat.dll differ