using System; using System.Collections.Generic; using HarmonyLib; using UnityEngine; namespace NecromancerTome { /// /// "Пирамида Ереси" (Pyramid of Heresy) - user request 2026-08-31, REWRITTEN 2026-09-01. /// /// FIRST VERSION (see BACKLOG.md's original entry) used Harmony patches on plain Block's /// OnBlockAdded/OnBlockRemoved/GetBlockActivationCommands/OnBlockActivated, with all state in a /// static Dictionary keyed by block position. Two real problems came out of actually testing it: /// 1. USER REPORT: "Навожу прицел, но подсказка про E не появляется" - no E-prompt at all, /// pressing E did nothing. Root cause, decompiled: there's a SEPARATE gate method, /// `Block.HasBlockActivationCommands(WorldBase, BlockValue, Vector3i, EntityAlive)`, with /// its OWN independent logic (not calling GetBlockActivationCommands at all) that the /// game's HUD/input layer checks FIRST to decide whether to even show the prompt. It was /// never patched, and for a plain decorative block it always returns false (no /// CanPickup, no CustomCmds) - so the prompt correctly never appeared, and E correctly did /// nothing, regardless of how correct the other three patches were. /// 2. USER REQUEST: "Сделай TileEntity" - wants EffectOn/ZoneShown to actually survive a /// save/reload, which the static-Dictionary version explicitly could not do (documented as /// a known caveat at the time). /// /// Rather than patch a fourth Block method, this is a full rewrite onto the real, sanctioned /// extension point for exactly this situation - the same one vanilla's own Land Claim block /// uses: a CompositeTileEntity feature. Confirmed by decompiling the actual chain, not guessed: /// - `TEFeatureAbs` (the real base class - `TEFeatureLandClaim : TEFeatureAbs`, decompiled to /// confirm) already declares virtual OnAdded/OnRemove/UpdateTick/Read/Write/ /// InitBlockActivationCommands/AllowBlockActivationCommand/OnBlockActivated - literally /// every hook this feature needs, with NO separate "HasBlockActivationCommands" gap: that /// whole problem belongs to plain Block's activation path, not this one. /// - `BlockCompositeTileEntity` (the Class="CompositeTileEntity" block class - decompiled /// directly) correctly overrides HasBlockActivationCommands/GetBlockActivationCommands/ /// OnBlockActivated itself and wires them through `TileEntityComposite`/each feature - this /// is the ALREADY-WORKING pipeline every vanilla composite block (Land Claim included) has /// used for years; not new engineering, just finally the right entry point. /// - Feature discovery is NOT a hardcoded switch (unlike raw `TileEntityType`/ /// `TileEntity.InstantiateFromRead`, which genuinely IS a closed hardcoded enum switch with /// no mod slot - checked this first and ruled it out for exactly that reason). /// `TileEntityCompositeData.Init()` (decompiled) calls /// `ReflectionHelpers.FindTypesImplementingBase(typeof(ITileEntityFeature), ...)` and keys /// the result by `_type.Name` (the short type name, NAMESPACE-INDEPENDENT - confirmed by /// reading the exact line) - so `TEFeaturePyramidWard` below is found automatically by the /// engine's own startup scan of every loaded assembly (including this mod's DLL) purely by /// matching that literal class name against blocks.xml's own /// `` - the same mechanism vanilla's own /// TEFeatureLandClaim/TEFeatureStorage/TEFeatureAreaRepair (see keystoneBlock) already rely /// on. Only real requirement (also confirmed by decompile, `TileEntityCompositeData.Init` /// warns and skips otherwise): a public, non-abstract class with a parameterless /// constructor - both true here without writing one explicitly. /// - Activation command TEXT is a real constraint worth noting: `BlockCompositeTileEntity` /// caches its `BlockActivationCommand[]` PER BLOCK TYPE (a field on the Block instance /// itself, shared by every placed pyramid), rebuilt once from InitBlockActivationCommands /// and never again - only `.enabled` gets refreshed per-activation (via /// AllowBlockActivationCommand). So button TEXT can't dynamically say "Enable"/"Disable" /// per-instance; the real vanilla pattern (confirmed in TEFeatureLandClaim's own /// show_bounds/hide_bounds pair) is to register BOTH command variants up front and only /// ENABLE whichever one currently applies - copied exactly here for effect_on/effect_off /// and zone_show/zone_hide. /// - Command display text is resolved via `Localization.Get("blockcommand_" + fullCommandName)` /// (confirmed by finding vanilla's own `blockcommand_show_bounds`/ /// `blockcommand_TEFeatureLandClaim:show_bounds` keys in Data/Config/Localization.csv) - /// NOT pre-resolved text passed directly to BlockActivationCommand's constructor (the /// earlier version's mistake). See this mod's own Localization.csv for the /// `blockcommand_TEFeaturePyramidWard:*` keys this relies on. /// /// NOT a Harmony patch, despite the filename/this mod's usual convention and despite still /// living in HarmonySrc/ for continuity with the rest of this mod's file layout - nothing here /// patches anything. `Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName` (CharmPatch.cs) is /// still reused as-is for the actual charm effect; that patch is untouched by this rewrite. /// public class TEFeaturePyramidWard : TEFeatureAbs { /// How far out (in blocks/meters) the ward reaches. Not specified by the user - /// picked to roughly cover a small base perimeter, same ballpark as vanilla's own land /// claim radius. Easy to retune, just one constant. public const float EffectRadius = 15f; /// CHANGED 2026-09-02 (user report: "Никаких частиц на включённом состоянии не /// летает" - literally nothing spawned, at all, neither the main glow nor the zone ring). /// Root cause: "RadiatedParticlesOnMesh" is loaded/played through a completely DIFFERENT /// mechanism than the one this file actually calls. Decompiled `ParticleEffect.LoadResources()` /// (the loader behind `GameManager.SpawnBlockParticleEffect`/`new ParticleEffect(string,...)`, /// which this file uses): it bulk-loads addressables from the "particleeffects" group whose /// FIRST FOLDER SEGMENT starts with "p_", then keys each loaded prefab into a dictionary by /// its own filename via `ToId(name)`. "RadiatedParticlesOnMesh" is referenced elsewhere in /// this mod (buffs.xml's `AttachParticleEffectToEntity`) via the literal path /// "ParticleEffects/RadiatedParticlesOnMesh" - no "p_"-prefixed folder anywhere in that path, /// meaning it almost certainly never gets bulk-loaded into that same lookup dictionary at all /// (that XML action resolves its own particle reference through an entirely separate, /// direct-path mechanism, not this bulk-addressables-by-folder-prefix one) - so /// `GetDynamicTransform`/`ToId` lookups for it here would always silently fail (logged as /// "Unknown particle effect", nothing spawned) - exactly matching what got reported. Switched /// to "campfire" instead - confirmed loadable through THIS exact code path already (it's /// vanilla's own `` on the real campfire /// block, going through the same GameManager block-particle registry) - and it happens to /// double as the user's other request ("фиолетовое пламя, будто блок горит холодным /// пламенем") almost for free: a real fire effect, tinted purple by ApplyGlowTint below /// instead of its natural orange. public const string GlowParticleName = "campfire"; /// CHANGED 2026-09-02 (user, after seeing the fire-ring in-game: "Границу лучше /// показывать не огнём, а какими-нибудь частицами" - reversed their earlier "оставим так" /// once they'd actually seen it). Only real vanilla `ParticleName` values confirmed to exist /// at all (grepped every one in Data/Config/blocks.xml - the same property this whole /// mechanism is built on): ember_pile/hotembers/campfire/forgeWorkstation/chemistryStation/ /// flame_hazard - every single one of them is fire/ember/industrial-themed, there is no /// confirmed "generic sparkle/magic" particle name to fall back on. Picked /// "chemistryStation" specifically because it's the one NOT visually built around an open /// flame (a chemistry set's bubbling/vapor effect) - best available guess from a short list, /// not a confirmed-good look; say if it still reads wrong once seen; it tints purple the /// same way as everything else here regardless of its native color. public const string ZoneRingParticleName = "chemistryStation"; /// Purple, per the user's explicit request ("окрашивается фиолетовым" for the /// zone, "светится фиолетовыми частицами"/"холодным пламенем" for the effect glow) - same /// color used for both. public static readonly Color WardTint = new Color(0.6f, 0.15f, 0.95f); /// Dropped from 32 - a full-size effect at every ring point would be both visually /// overwhelming and comparatively expensive; 16 small markers still reads clearly as a /// circle at EffectRadius=15. public const int ZoneRingPointCount = 16; /// ADDED 2026-09-02, direct user request ("Можешь накладывать кроме девиации ещё /// и дебаф горения?"). This is the real vanilla "a zombie is on fire" buff (Data/Config/ /// buffs.xml - `damage_type="heat"`, cascades into `buffBurningElement`'s own 10s countdown/ /// damage-over-time/AddBuff(buffIsOnFire) chain, the same one torches/molotovs/fire traps /// trigger), not a new buff invented for this mod. Re-applied every scan tick (not /// gated behind "already has it" like the charm below) since `buffBurningElement` itself /// resets its own countdown on every re-trigger (`stack_type="replace"`) - the intent is /// "keeps burning the whole time it's in the zone", not "burns once". /// /// Needs a REAL instigator entity id, unlike the charm buff: `EntityBuffs.AddBuff` (decompiled) /// checks `buff.DamageType != None && ... && !FriendlyFireCheck(instigator)` and fails the /// whole call outright if that trips - a buff with a real damage_type (this one has "heat"; /// buffNecroDeviatorCharm has none, which is why it never needed this) requires a /// non-null/valid instigator that FriendlyFireCheck accepts, or the call can fail. Passed the /// in-zone player's own entityId (already resolved above for the player-presence gate) - /// matches the fictional framing anyway (the necromancer is the one wielding this ward). public const string BurnBuffName = "buffBurningZombie"; /// Persisted (see Read/Write below) - real per-instance state now, one pyramid's /// toggle no longer affects any other's, and both survive a save/reload. public bool EffectOn = true; public bool ZoneShown; /// Glow/ring particle keys queued via SpawnBlockParticleEffect but not tinted yet - /// GameManager.updateBlockParticles() only processes its spawn queue once per frame /// (decompiled to confirm), so tinting has to be deferred at least one tick rather than done /// inline right after spawning. Instance-level now (was a shared static list in the old /// version) - each pyramid only tracks its own pending keys. public readonly List pendingTint = new List(); // ------------------------------------------------------------------ // Lifecycle. // ------------------------------------------------------------------ public override void CopyFromInternal(TileEntityComposite _other) { if (_other.TryGetSelfOrFeature(out TEFeaturePyramidWard other)) { EffectOn = other.EffectOn; ZoneShown = other.ZoneShown; } } public override void OnAdded(Vector3i _blockPos, BlockValue _blockValue) { base.OnAdded(_blockPos, _blockValue); if (EffectOn) { SpawnGlow(); } if (ZoneShown) { SpawnZoneRing(); } } public override void OnRemove(World _world) { base.OnRemove(_world); if (ZoneShown) { RemoveZoneRing(); } RemoveGlow(); } // ------------------------------------------------------------------ // Persistence - real save/load now, per the user's direct request. // ------------------------------------------------------------------ /// Routed through PyramidWardWriteHelper.Write (TEPersistenceSrc/, a separate /// satellite project+DLL) rather than calling PooledBinaryWriter.Write directly - that call /// does not compile from THIS project at all. Real, decompile/compiler-confirmed reason, not /// a style choice: see NecromancerTEPersistence.csproj's own comment for the full story /// (short version: Assembly-CSharp.dll's Write overload set includes a /// ReadOnlySpan<byte> variant that only resolves against Unity/Mono's own mscorlib, /// which conflicts with this project's UnityEngine-type usage everywhere else if referenced /// directly here - isolating the one call that needs it into its own tiny project was the /// only combination found that keeps both working). No version byte (kept simple per the /// user's "как проще" - this is a brand-new feature, nothing to migrate from yet; /// PooledBinaryReader.ReadBoolean() below has no such compile restriction, confirmed /// separately, so Read() needs no equivalent workaround. public override void Write(PooledBinaryWriter _bw, TileEntity.StreamModeWrite _eStreamMode) { base.Write(_bw, _eStreamMode); PyramidWardWriteHelper.Write(_bw, EffectOn, ZoneShown); } public override void Read(PooledBinaryReader _br, TileEntity.StreamModeRead _eStreamMode) { base.Read(_br, _eStreamMode); EffectOn = _br.ReadBoolean(); ZoneShown = _br.ReadBoolean(); } // ------------------------------------------------------------------ // E-menu (activation commands) - see class doc comment for why both states of each // toggle are registered up front rather than swapping text dynamically. // ------------------------------------------------------------------ /// Icons FIXED 2026-09-02 (user report: "на 'Отключить эффект' нету иконки"). /// "ui_game_symbol_zombie"/"hand" (the first version's guesses) were never real /// BlockActivationCommand icon names - that field takes a small closed set of simple /// glyph-font names, NOT full UI sprite-atlas names (confirmed by harvesting every real /// `new BlockActivationCommand(...)` call across every other TEFeature class decompiled so /// far: "frames"/"x" (TEFeatureLandClaim), "door" (TEFeatureDoor), "lock"/"unlock"/"keypad" /// (TEFeatureLockable), "search" (TEFeatureStorage), "wrench" (the trigger command on /// TileEntityComposite itself)). Reused two of those real, confirmed names instead of /// guessing again: "unlock"/"lock" for effect on/off (a locked/unlocked padlock reads fine /// as "active"/"inactive"), and "frames" - literally the SAME icon vanilla's own Land Claim /// uses for its own show_bounds/hide_bounds pair - for our own zone_show/zone_hide, since /// it's the exact same kind of toggle. public override void InitBlockActivationCommands(Action _addCallback) { base.InitBlockActivationCommands(_addCallback); _addCallback(new BlockActivationCommand("effect_on", "unlock", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData); _addCallback(new BlockActivationCommand("effect_off", "lock", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData); _addCallback(new BlockActivationCommand("zone_show", "frames", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData); _addCallback(new BlockActivationCommand("zone_hide", "frames", _enabled: false), TileEntityComposite.EBlockCommandOrder.Normal, FeatureData); } /// ADDED 2026-09-02 (user report: "при наведении нет никакой надписи-подсказки про /// E"). TEFeatureAbs.GetActivationText was never overridden at all, so it fell through to /// the base's default `return null` - no ReadOnlySpan in this method's signature (confirmed /// by decompile), so unlike AllowBlockActivationCommand/OnBlockActivated below, this one /// overrides cleanly with no workaround needed. Mirrors TEFeatureLandClaim's own /// GetActivationText shape (`_activateHotkeyMarkup` + the block's own localized name) - /// same real, decompiled API, not guessed. public override string GetActivationText(WorldBase _world, Vector3i _blockPos, BlockValue _blockValue, EntityAlive _entityFocusing, string _activateHotkeyMarkup, string _focusedTileEntityName) { base.GetActivationText(_world, _blockPos, _blockValue, _entityFocusing, _activateHotkeyMarkup, _focusedTileEntityName); return _activateHotkeyMarkup + " " + _blockValue.Block.GetLocalizedBlockName(); } // AllowBlockActivationCommand/OnBlockActivated deliberately NOT overridden here - see the // long comment block below (right above the two Harmony patches that replace them) for why // this specific pair of TEFeatureAbs virtuals cannot be overridden from this mod's project // at all, and how the same behavior is achieved instead. // ------------------------------------------------------------------ // Visuals - same GameManager block-particle registry as the first version, just called on // `ToWorldPos()` (this feature's own position) instead of a dictionary-passed key. // ------------------------------------------------------------------ public void SpawnGlow() { if (GameManager.Instance == null) { return; } Vector3i pos = ToWorldPos(); if (GameManager.Instance.HasBlockParticleEffect(pos)) { return; } // FIXED 2026-09-02 (user report: "горение пирамидки почему-то смещено на куб вверх и // вбок"): World.blockToTransformPos(Vector3i) ALREADY returns (x+0.5, y, z+0.5) // (decompiled to confirm - horizontally centered, y left raw/un-centered) - adding // another +0.5 on x/z on top of that (the original bug) double-centered it, landing a // full extra block over on both horizontal axes. Only the vertical lift (how far above // the block the flame sits) is actually ours to add. Height LOWERED again same day // ("сделай ниже не 2/3 куба" - after the sideways offset was gone, still sat too high): // 1.2 (0.2 above the full block top) -> 0.6, under 2/3 (0.667) of a block as asked. // Height lowered twice same day: 1.2 -> 0.6 ("сделай ниже не 2/3 куба"), then -> 0.4 // (direct follow-up: "Снизь высоту пламени до +0.4"). Vector3 worldPos = World.blockToTransformPos(pos) + new Vector3(0f, 0.4f, 0f); // WardTint (not Color.white) as the ParticleEffect's own _color: ParticleEffect. // SpawnParticleEffect applies this directly to any non-ParticleSystem Renderer on the // prefab (decompiled to confirm) - covers a sub-emitter/glow sprite ApplyGlowTint's own // ParticleSystem-only loop wouldn't reach, belt-and-suspenders alongside it. GameManager.Instance.SpawnBlockParticleEffect(pos, new ParticleEffect(GlowParticleName, worldPos, Quaternion.identity, 0f, WardTint)); pendingTint.Add(pos); } public void RemoveGlow() { if (GameManager.Instance == null) { return; } Vector3i pos = ToWorldPos(); if (GameManager.Instance.HasBlockParticleEffect(pos)) { GameManager.Instance.RemoveBlockParticleEffect(pos); } pendingTint.Remove(pos); } /// Ring of glow points marking EffectRadius, keyed by y = -1000-i (real block /// y-coordinates never go negative that far, so these keys can never collide with an /// actual placed block's own glow key). public void SpawnZoneRing() { if (GameManager.Instance == null) { return; } Vector3i basePos = ToWorldPos(); // Same double-centering bug as SpawnGlow's own fix above - blockToTransformPos already // centers x/z, only the vertical offset (0.5, mid-block height) is ours to add. Vector3 center = World.blockToTransformPos(basePos) + new Vector3(0f, 0.5f, 0f); for (int i = 0; i < ZoneRingPointCount; i++) { float angle = i * (360f / ZoneRingPointCount) * Mathf.Deg2Rad; Vector3 point = center + new Vector3(Mathf.Cos(angle) * EffectRadius, 0f, Mathf.Sin(angle) * EffectRadius); Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z); if (GameManager.Instance.HasBlockParticleEffect(key)) { continue; } // ZoneRingParticleName (not GlowParticleName) - see that constant's own comment: // user asked for the boundary to read as "some particles", not literal fire. GameManager.Instance.SpawnBlockParticleEffect(key, new ParticleEffect(ZoneRingParticleName, point, Quaternion.identity, 0f, WardTint)); pendingTint.Add(key); } } public void RemoveZoneRing() { if (GameManager.Instance == null) { return; } Vector3i basePos = ToWorldPos(); for (int i = 0; i < ZoneRingPointCount; i++) { Vector3i key = new Vector3i(basePos.x, -1000 - i, basePos.z); if (GameManager.Instance.HasBlockParticleEffect(key)) { GameManager.Instance.RemoveBlockParticleEffect(key); } pendingTint.Remove(key); } } /// Mirrors ParticlePatch.cs's own tint technique (same ParticleSystem.MainModule /// fields, same reasoning: the particle prefab always instantiates at its authored color, /// nothing in XML/the block-particle API can override that) - kept as its own copy here /// rather than refactoring ParticlePatch.cs itself, so this feature can't regress the /// already-working zombie glow if something about this call site needs different handling /// once tested in-game. /// /// EXTENDED 2026-09-02 for "campfire" (see GlowParticleName's own comment for why the /// particle changed) with Gradient/TwoGradients handling - ParticlePatch.cs's original /// only ever needed Color/TwoColors (RadiatedParticlesOnMesh's own authored mode) and /// explicitly left Gradient/TwoGradients alone as "no generic way to override". That's not /// actually true - a Gradient's color keys ARE reassignable at runtime via /// `Gradient.SetKeys` - so it's handled here now, since a real fire effect plausibly /// animates through multiple colors (yellow-orange-red-smoke) via an actual Gradient rather /// than one flat color, and the request is specifically "холодным пламенем" (COLD flame) - /// if this branch never actually runs because campfire turns out to use plain Color/ /// TwoColors after all, no harm, the two branches above still cover it. public static void ApplyGlowTint(Transform particleTransform, float sizeFactor, float alphaFactor) { if (particleTransform == null) { return; } particleTransform.localScale = Vector3.one * sizeFactor; ParticleSystem[] systems = particleTransform.GetComponentsInChildren(true); foreach (ParticleSystem ps in systems) { ParticleSystem.MainModule main = ps.main; main.startSizeMultiplier *= sizeFactor; ParticleSystem.MinMaxGradient startColor = main.startColor; switch (startColor.mode) { case ParticleSystemGradientMode.Color: { Color c = WardTint; c.a = startColor.color.a * alphaFactor; startColor.color = c; main.startColor = startColor; break; } case ParticleSystemGradientMode.TwoColors: { Color min = WardTint; Color max = WardTint; min.a = startColor.colorMin.a * alphaFactor; max.a = startColor.colorMax.a * alphaFactor; startColor.colorMin = min; startColor.colorMax = max; main.startColor = startColor; break; } case ParticleSystemGradientMode.Gradient: startColor.gradient = TintGradient(startColor.gradient, alphaFactor); main.startColor = startColor; break; case ParticleSystemGradientMode.TwoGradients: startColor.gradientMin = TintGradient(startColor.gradientMin, alphaFactor); startColor.gradientMax = TintGradient(startColor.gradientMax, alphaFactor); main.startColor = startColor; break; } // Belt-and-suspenders for any sub-emitter/light-flicker Renderer that isn't a // ParticleSystem itself - SpawnGlow/SpawnZoneRing already pass WardTint as the // ParticleEffect's own _color (which ParticleEffect.SpawnParticleEffect applies to // exactly this kind of non-ParticleSystem Renderer automatically), this loop only // covers the ParticleSystem-driven part. A real-time Light component (if "campfire" // has one for dynamic scene lighting) is NOT touched by either mechanism - if the // flame reads purple but still casts an orange glow on nearby surfaces, that's why, // and would need its own separate fix once actually seen in-game. } } /// Rebuilds a Gradient with every color key replaced by WardTint, keeping the /// original alpha keys (and their timing) intact so the fade-in/fade-out shape of the /// effect is preserved - only the color changes, not the timing/opacity curve. public static Gradient TintGradient(Gradient original, float alphaFactor) { Gradient g = new Gradient(); GradientAlphaKey[] alphaKeys = original != null ? original.alphaKeys : new GradientAlphaKey[] { new GradientAlphaKey(1f, 0f) }; for (int i = 0; i < alphaKeys.Length; i++) { alphaKeys[i].alpha *= alphaFactor; } GradientColorKey[] colorKeys = new GradientColorKey[] { new GradientColorKey(WardTint, 0f), new GradientColorKey(WardTint, 1f) }; g.SetKeys(colorKeys, alphaKeys); return g; } // ------------------------------------------------------------------ // Per-tick: deferred tint + charm/burn scan. Replaces the first version's global // ModEvents.UnityUpdate handler + static Dictionary loop entirely - each pyramid now ticks // itself via this real per-feature hook, called directly by the engine (no throttle of our // own - see the comment on `center` below for why a self-imposed one is actively wrong here). // ------------------------------------------------------------------ public override void UpdateTick(World _world) { base.UpdateTick(_world); if (GameManager.Instance == null) { return; } for (int i = pendingTint.Count - 1; i >= 0; i--) { Vector3i key = pendingTint[i]; if (!GameManager.Instance.HasBlockParticleEffect(key)) { continue; } Transform t = GameManager.Instance.GetBlockParticleEffect(key); bool isRingPoint = key.y <= -1000; // 1.0 = natural "campfire" size for the main glow; ring markers shrunk hard (0.35) // so the 16 of them read as small flame-markers instead of a circle of bonfires. // Near-opaque alpha (0.9) so the purple tint reads clearly. ApplyGlowTint(t, isRingPoint ? 0.35f : 1.0f, 0.9f); pendingTint.RemoveAt(i); } if (!EffectOn) { return; } Vector3i posI = ToWorldPos(); // World.blockToTransformPos already returns X/Z centered on the block (confirmed by // decompile) - only the vertical lift is ours to add. (A past version double-added the // X/Z centering here, offsetting the whole detection circle by about a block - fixed.) Vector3 center = World.blockToTransformPos(posI) + new Vector3(0f, 0.5f, 0f); // Horizontal-only (X/Z) distance, ignoring Y: the zone-ring visual is a flat disc at one // height, so the real detection area is a matching vertical column, not a shrinking // sphere - also just more useful for a base with any stairs/floors. Bounds query // widened vertically (256 = full world height) since the real filter below doesn't // restrict Y at all. Bounds bounds = new Bounds(center, new Vector3(EffectRadius * 2f, 256f, EffectRadius * 2f)); // Skip the (more expensive) zombie query/loop entirely unless a player is actually in // range - also doubles as the burning debuff's required instigator id (a buff with a // real damage_type, unlike the charm, needs one or EntityBuffs.AddBuff fails its // FriendlyFireCheck outright - see BurnBuffName's own comment). List playersNearby = new List(); _world.GetEntitiesInBounds(typeof(EntityPlayer), bounds, playersNearby); int playerInstigatorId = -1; foreach (Entity p in playersNearby) { float pdx = p.position.x - center.x; float pdz = p.position.z - center.z; if (pdx * pdx + pdz * pdz <= EffectRadius * EffectRadius) { playerInstigatorId = p.entityId; break; } } if (playerInstigatorId == -1) { return; } List nearby = new List(); _world.GetEntitiesInBounds(typeof(EntityZombie), bounds, nearby); foreach (Entity e in nearby) { if (!(e is EntityZombie zombie) || zombie.IsDead() || zombie.Buffs == null) { continue; } float dx = zombie.position.x - center.x; float dz = zombie.position.z - center.z; if (dx * dx + dz * dz > EffectRadius * EffectRadius) { continue; } if (!zombie.Buffs.HasBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName)) { zombie.Buffs.AddBuff(Patch_EntityBuffs_AddBuff_DeviatorCharm.CharmBuffName); Debug.Log("[NecromancerTome] TEFeaturePyramidWard: charmed zombie " + zombie.entityId + " near pyramid " + posI); } // Burning re-applied every tick a zombie is in the zone (not gated behind "already // has it" like the charm above) - buffBurningElement resets its own countdown on // every re-trigger, so this keeps it topped up rather than a one-shot. zombie.Buffs.AddBuff(BurnBuffName, playerInstigatorId); } } } /// /// Real, verified-by-compiler blocker found while writing TEFeaturePyramidWard above: /// TEFeatureAbs.AllowBlockActivationCommand and TEFeatureAbs.OnBlockActivated cannot be /// overridden from this mod's project AT ALL, on any target framework tried (netstandard2.1 /// AND net8.0 both fail identically, confirmed with an isolated throwaway repro project - this /// is not a langversion/TargetFramework setting to tune away). Root cause, found by dumping raw /// IL (`ilspycmd -il`): Assembly-CSharp.dll declares these two methods' shared parameter type as /// `valuetype [mscorlib]System.ReadOnlySpan\`1<char>` - i.e. Unity's own Mono/IL2CPP /// runtime backports Span/ReadOnlySpan INTO mscorlib itself, unlike a normal modern .NET SDK /// project (this mod's own csproj included), where ReadOnlySpan<T> instead lives in /// System.Private.CoreLib/System.Memory. Same type NAME, but the CLR treats a type's identity /// as (name + DECLARING ASSEMBLY) - these are two different types to the compiler, so an /// override that looks byte-for-byte identical in source (confirmed via a live reflection probe /// against the real DLL, not just the decompiled source) still fails to bind as a valid /// override. The only real fix on our side would be adding an explicit reference to the game's /// own Managed/mscorlib.dll so our ReadOnlySpan<char> resolves from the same assembly - /// not attempted, since forcing a second mscorlib into this project risks colliding with every /// other basic type (string, object, List<T>...) the SDK's own implicit framework /// reference already provides, for every file in this mod, not just this one method pair. Not /// worth that blast radius for two methods this patch below covers just as well anyway. /// /// WORKAROUND: everything both blocked methods needed to do is instead done one layer up, on /// the STRING/array-based (no ReadOnlySpan anywhere) methods that wrap them: /// - `TileEntityComposite.UpdateBlockActivationCommands(BlockActivationCommand[], ...)` - /// confirmed by decompile to run AFTER every feature's (unoverridden, always-true-by- /// default) AllowBlockActivationCommand, so a Postfix here can simply overwrite `.enabled` /// for our 4 known commands with the real per-instance answer - same end result. /// - `BlockCompositeTileEntity.OnBlockActivated(string _commandName, ...)` - the SAME method /// the pre-rewrite plain-Block version patched, just on the composite block class instead; /// `_commandName` here is still the FULL "TEFeaturePyramidWard:effect_on" form (splitting /// into module+bare command only happens one level deeper, inside TileEntityComposite's own /// OnBlockActivated) - checked with plain string.EndsWith, no ReadOnlySpan needed. /// Both patches guard on TryGetSelfOrFeature<TEFeaturePyramidWard> first and bail /// immediately for every other composite block in the game (doors, Land Claim, etc.) - same /// "patch the shared method, filter by identity" idiom as everywhere else in this mod. /// [HarmonyPatch(typeof(TileEntityComposite), "UpdateBlockActivationCommands")] public static class Patch_TileEntityComposite_UpdateBlockActivationCommands_PyramidWard { public static void Postfix(TileEntityComposite __instance, BlockActivationCommand[] _commands) { if (__instance == null || _commands == null) { return; } if (!__instance.TryGetSelfOrFeature(out TEFeaturePyramidWard feature)) { return; } for (int i = 0; i < _commands.Length; i++) { string text = _commands[i].text; if (string.IsNullOrEmpty(text)) { continue; } if (text.EndsWith("effect_on")) { _commands[i].enabled = !feature.EffectOn; } else if (text.EndsWith("effect_off")) { _commands[i].enabled = feature.EffectOn; } else if (text.EndsWith("zone_show")) { _commands[i].enabled = !feature.ZoneShown; } else if (text.EndsWith("zone_hide")) { _commands[i].enabled = feature.ZoneShown; } } } } [HarmonyPatch(typeof(BlockCompositeTileEntity), "OnBlockActivated", new Type[] { typeof(string), typeof(WorldBase), typeof(Vector3i), typeof(BlockValue), typeof(EntityPlayerLocal) })] public static class Patch_BlockCompositeTileEntity_OnBlockActivated_PyramidWard { public static bool Prefix(string _commandName, Vector3i _blockPos, ref bool __result) { if (string.IsNullOrEmpty(_commandName)) { return true; } bool isEffectCommand = _commandName.EndsWith("effect_on") || _commandName.EndsWith("effect_off"); bool isZoneCommand = _commandName.EndsWith("zone_show") || _commandName.EndsWith("zone_hide"); if (!isEffectCommand && !isZoneCommand) { return true; } World world = GameManager.Instance != null ? GameManager.Instance.World : null; TileEntity te = world != null ? world.GetTileEntity(_blockPos) : null; if (!(te is TileEntityComposite composite) || !composite.TryGetSelfOrFeature(out TEFeaturePyramidWard feature)) { __result = false; return false; } if (isEffectCommand) { feature.EffectOn = !feature.EffectOn; if (feature.EffectOn) { feature.SpawnGlow(); } else { feature.RemoveGlow(); } Debug.Log("[NecromancerTome] TEFeaturePyramidWard: effect " + (feature.EffectOn ? "ON" : "OFF") + " at " + _blockPos); } else { feature.ZoneShown = !feature.ZoneShown; if (feature.ZoneShown) { feature.SpawnZoneRing(); } else { feature.RemoveZoneRing(); } Debug.Log("[NecromancerTome] TEFeaturePyramidWard: zone display " + (feature.ZoneShown ? "ON" : "OFF") + " at " + _blockPos); } feature.SetModified(); __result = true; return false; } } }