From eec2ad0d3a3995b9a70970a51c34f37ed3e2f441 Mon Sep 17 00:00:00 2001 From: jneb802 <73610029+jneb802@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:50:31 -0700 Subject: [PATCH 1/6] Add Adrenaline Echo unique trinket shardstone --- PraetorisClient.csproj | 1 + Source/EpicLoot/AdrenalineEchoRuntime.cs | 187 +++++++++++++++++++++ Source/EpicLoot/PraetorisMagicEffects.cs | 10 ++ Source/EpicLoot/PraetorisShardstones.cs | 40 +++-- docs/adrenaline-echo.md | 100 ++++++++++++ tests/AdrenalineEchoProbe/Probe.cs | 200 +++++++++++++++++++++++ tests/AdrenalineEchoProbe/Probe.csproj | 14 ++ 7 files changed, 541 insertions(+), 11 deletions(-) create mode 100644 Source/EpicLoot/AdrenalineEchoRuntime.cs create mode 100644 docs/adrenaline-echo.md create mode 100644 tests/AdrenalineEchoProbe/Probe.cs create mode 100644 tests/AdrenalineEchoProbe/Probe.csproj diff --git a/PraetorisClient.csproj b/PraetorisClient.csproj index 8572624..08e96d0 100644 --- a/PraetorisClient.csproj +++ b/PraetorisClient.csproj @@ -49,6 +49,7 @@ + PraetorisClient.GuideImages.%(Filename)%(Extension) diff --git a/Source/EpicLoot/AdrenalineEchoRuntime.cs b/Source/EpicLoot/AdrenalineEchoRuntime.cs new file mode 100644 index 0000000..ec8459c --- /dev/null +++ b/Source/EpicLoot/AdrenalineEchoRuntime.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; +using EpicLoot.MagicItemEffects; +using HarmonyLib; +using UnityEngine; + +namespace PraetorisClient +{ + internal static class AdrenalineEchoRuntime + { + private static Attack? activeEcho; + private static readonly FieldInfo PendingShot = AccessTools.Field(typeof(MultiShot), "_pendingShot"); + private static readonly AccessTools.FieldRef Trinket = AccessTools.FieldRefAccess("m_trinketItem"); + private static readonly AccessTools.FieldRef AttackAmmo = AccessTools.FieldRefAccess("m_ammoItem"); + private static readonly Func FindAmmo = + AccessTools.MethodDelegate>(AccessTools.Method(typeof(Attack), "FindAmmo")); + private static readonly Action FireProjectileBurst = + AccessTools.MethodDelegate>(AccessTools.Method(typeof(Attack), "FireProjectileBurst")); + + private static void OnTrinketActivated(Player player) + { + if (player != Player.m_localPlayer || player.IsDead() || activeEcho != null || + Trinket(player) is not { m_equipped: true } trinket || + trinket.m_shared.m_fullAdrenalineSE == null || + EpicLootApiBridge.GetTotalActiveMagicEffectValue(player, trinket, PraetorisMagicEffects.AdrenalineEcho, 1f) <= 0f) + { + return; + } + + ItemDrop.ItemData weapon = player.GetCurrentWeapon(); + if (weapon?.m_shared.m_attack?.m_attackType != Attack.AttackType.Projectile) + { + return; + } + + // Leave the attack that filled adrenaline before entering another attack's Harmony hooks. + // Epic Loot temporarily changes shared weapon damage while a projectile burst runs. + PraetorisClientPlugin.Instance?.StartCoroutine(FireAfterAttack(player, weapon)); + } + + private static IEnumerator FireAfterAttack(Player player, ItemDrop.ItemData weapon) + { + yield return null; + if (player == null || player != Player.m_localPlayer || player.IsDead() || + player.GetCurrentWeapon() != weapon || !weapon.m_equipped) + { + yield break; + } + + ItemDrop.ItemData ammo = FindAmmo(player, weapon); + if (!string.IsNullOrEmpty(weapon.m_shared.m_ammoType) && + (ammo == null || (ammo.m_shared.m_itemType != ItemDrop.ItemData.ItemType.Ammo && + ammo.m_shared.m_itemType != ItemDrop.ItemData.ItemType.AmmoNonEquipable))) + { + yield break; + } + GameObject? projectile = ammo?.m_shared.m_attack.m_attackProjectile; + if (projectile == null) + { + projectile = weapon.m_shared.m_attack.m_attackProjectile; + } + if (projectile == null || projectile.GetComponent() == null) + { + yield break; + } + + Attack echo = weapon.m_shared.m_attack.Clone(); + AttackAmmo(echo) = ammo; + echo.m_projectiles = 1; + echo.m_projectileBursts = 1; + echo.m_perBurstResourceUsage = false; + echo.m_attackStamina = 0f; + echo.m_attackHealth = 0f; + echo.m_attackHealthPercentage = 0f; + echo.m_attackUseAdrenaline = 0f; + echo.m_blockReloadTime = 0f; + echo.m_requiresReload = false; + echo.m_destroyPreviousProjectile = false; + echo.m_consumeItem = false; + echo.m_toggleFlying = false; + echo.m_recoilPushback = 0f; + echo.m_selfDamage = 0; + + // MultiShot stores its pending roll globally, including between a staff's bursts. + // Give the echo its own normal roll, then restore the attack already in progress. + object pendingShot = PendingShot.GetValue(null); + bool tripleShot = MultiShot.IsTripleShotActive; + int shotProjectiles = MultiShot.ShotProjectiles; + GameObject previousProjectile = weapon.m_lastProjectile; + float previousAttackTime = weapon.m_lastAttackTime; + activeEcho = echo; + try + { + echo.StartWithoutAnimation(player, player.GetComponent(), player.GetVisEquipment(), weapon, 1f); + } + catch (Exception exception) + { + PraetorisClientPlugin.Log.LogError("Adrenaline Echo failed: " + exception); + } + finally + { + activeEcho = null; + PendingShot.SetValue(null, pendingShot); + MultiShot.IsTripleShotActive = tripleShot; + MultiShot.ShotProjectiles = shotProjectiles; + weapon.m_lastProjectile = previousProjectile; + weapon.m_lastAttackTime = previousAttackTime; + } + } + + [HarmonyPatch(typeof(Player), nameof(Player.AddAdrenaline))] + private static class ActivationPatch + { + private static IEnumerable Transpiler(IEnumerable instructions) + { + // This branch runs only when full adrenaline activates equipment, including a + // refresh of an existing status effect and a fill from zero in one hit. + return new CodeMatcher(instructions) + .MatchStartForward(new CodeMatch(OpCodes.Ldfld, + AccessTools.Field(typeof(Player), nameof(Player.m_adrenalinePopEffects)))) + .ThrowIfNotMatch("Cannot locate trinket activation for Adrenaline Echo.") + .Insert(new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(AdrenalineEchoRuntime), nameof(OnTrinketActivated)))) + .InstructionEnumeration(); + } + } + + [HarmonyPatch(typeof(Attack), nameof(Attack.UseAmmo))] + private static class EchoAmmoPatch + { + private static bool Prefix(Attack __instance, ref ItemDrop.ItemData? ammoItem, ref bool __result) + { + if (__instance != activeEcho) return true; + ammoItem = AttackAmmo(__instance); + __result = true; + return false; + } + } + + [HarmonyPatch(typeof(Attack), nameof(Attack.OnAttackTrigger))] + private static class EchoStaggerPatch + { + private static bool BlocksAttack(bool staggering, Attack attack) => staggering && attack != activeEcho; + + private static IEnumerable Transpiler(IEnumerable instructions) + { + return new CodeMatcher(instructions) + .MatchStartForward(new CodeMatch(instruction => instruction.Calls( + AccessTools.Method(typeof(Character), nameof(Character.IsStaggering))))) + .ThrowIfNotMatch("Cannot locate attack stagger check for Adrenaline Echo.") + .Advance(1) + .Insert(new CodeInstruction(OpCodes.Ldarg_0), + new CodeInstruction(OpCodes.Call, AccessTools.Method(typeof(EchoStaggerPatch), nameof(BlocksAttack)))) + .InstructionEnumeration(); + } + } + + [HarmonyPatch(typeof(Attack), nameof(Attack.GetAttackEitr), new Type[] { })] + private static class EchoEitrPatch + { + private static bool Prefix(Attack __instance, ref float __result) + { + if (__instance != activeEcho) return true; + // Vanilla reads eitr cost from the weapon's shared template, not this clone. + __result = 0f; + return false; + } + } + + [HarmonyPatch(typeof(Attack), nameof(Attack.ProjectileAttackTriggered))] + private static class EchoProjectilePatch + { + private static bool Prefix(Attack __instance) + { + if (__instance != activeEcho) return true; + // Use the normal patched burst with the real ItemData. This preserves Epic Loot + // damage, speed, multishot, projectile markers and weapon attribution on impact. + // The burst supplies the projectile effects without durability or reload changes. + FireProjectileBurst(__instance); + return false; + } + } + } +} diff --git a/Source/EpicLoot/PraetorisMagicEffects.cs b/Source/EpicLoot/PraetorisMagicEffects.cs index da207cf..18b86c2 100644 --- a/Source/EpicLoot/PraetorisMagicEffects.cs +++ b/Source/EpicLoot/PraetorisMagicEffects.cs @@ -21,6 +21,7 @@ internal static class PraetorisMagicEffects internal const string PointBlank = "PointBlank"; internal const string ReloadOnKill = "ReloadOnKill"; internal const string ArrowRain = "ArrowRain"; + internal const string AdrenalineEcho = "AdrenalineEcho"; internal static readonly float[] PointBlankValues = { 5, 7, 10, 15, 20, 25 }; internal static readonly float[] PiercingShotValues = { 2, 3, 3, 4 }; @@ -31,6 +32,15 @@ internal static class PraetorisMagicEffects private static readonly string[] MagicEffectDefinitionJson = { @"{ + ""Type"": ""AdrenalineEcho"", + ""CanBeAugmented"": false, + ""CanBeDisenchanted"": false, + ""CanBeRunified"": false, + ""DisplayText"": ""Adrenaline Echo: Full adrenaline fires your equipped weapon's projectile."", + ""Description"": ""When this trinket activates its full-adrenaline effect, fire a bonus projectile toward your aim using your equipped weapon and its enchantments. Requires a projectile weapon and compatible ammunition for bows and crossbows. Costs no ammunition, durability, stamina, health, or eitr."", + ""Requirements"": { ""NoRoll"": true, ""AllowedItemTypes"": [ ""Trinket"" ] } +}", + @"{ ""Type"": ""IncreaseEffectDuration"", ""DisplayText"": ""Effect Duration +{0:0.#}%"", ""Description"": ""Increase the duration of timed status effects applied by this item by +X%. Also affects supported timed status effects from equipped gear."", diff --git a/Source/EpicLoot/PraetorisShardstones.cs b/Source/EpicLoot/PraetorisShardstones.cs index dd98dc1..238ec56 100644 --- a/Source/EpicLoot/PraetorisShardstones.cs +++ b/Source/EpicLoot/PraetorisShardstones.cs @@ -26,7 +26,9 @@ internal static class PraetorisShardstones new ShardSpec((ShardType)0x50520003, "Piercing Shot", "Stormcaller", PraetorisMagicEffects.PiercingShot, ShardCategory.Unique, ItemRarity.Epic, PraetorisMagicEffects.PiercingShotValues), new ShardSpec((ShardType)0x50520005, "Arrow Rain", "Stormcaller", PraetorisMagicEffects.ArrowRain, - ShardCategory.Unique, ItemRarity.Epic, new float[] { 1, 1, 1, 1 }) + ShardCategory.Unique, ItemRarity.Epic, new float[] { 1, 1, 1, 1 }), + new ShardSpec((ShardType)0x50520006, "Adrenaline Echo", "Stormcaller", PraetorisMagicEffects.AdrenalineEcho, + ShardCategory.Unique, ItemRarity.Epic, new float[] { 1, 1, 1, 1 }, ShardSlotCategory.Trinket) }; internal static void Initialize() @@ -105,22 +107,36 @@ internal static void AddDefinitions(ShardStonesConfig config) { if (config.Shards.TryGetValue(spec.Id, out ShardDefinition existing)) { - if (existing?.UniformEffect?.EffectType != spec.Effect) + ShardEffectDefinition? existingEffect = existing?.UniformEffect; + if (spec.Slot.HasValue && existing?.TypeEffects != null) + { + existing.TypeEffects.TryGetValue(spec.Slot.Value, out existingEffect); + } + if (existingEffect?.EffectType != spec.Effect) { throw new InvalidOperationException($"Shardstone ID {spec.Id} is already in use."); } continue; } - config.Shards.Add(spec.Id, new ShardDefinition + ShardEffectDefinition effect = new ShardEffectDefinition + { + EffectType = spec.Effect, + ValuesPerRarity = new Dictionary(spec.Values) + }; + ShardDefinition definition = new ShardDefinition { Category = spec.Category, - Rarities = spec.Values.Keys.ToList(), - UniformEffect = new ShardEffectDefinition - { - EffectType = spec.Effect, - ValuesPerRarity = new Dictionary(spec.Values) - } - }); + Rarities = spec.Values.Keys.ToList() + }; + if (spec.Slot.HasValue) + { + definition.TypeEffects.Add(spec.Slot.Value, effect); + } + else + { + definition.UniformEffect = effect; + } + config.Shards.Add(spec.Id, definition); } } @@ -175,16 +191,18 @@ private sealed class ShardSpec internal readonly string Name; internal readonly string Template; internal readonly string Effect; + internal readonly ShardSlotCategory? Slot; internal readonly ShardCategory Category; internal readonly Dictionary Values = new Dictionary(); internal ShardSpec(ShardType id, string name, string template, string effect, - ShardCategory category, ItemRarity firstRarity, float[] values) + ShardCategory category, ItemRarity firstRarity, float[] values, ShardSlotCategory? slot = null) { Id = id; Name = name; Template = template; Effect = effect; + Slot = slot; Category = category; for (int index = 0; index < values.Length; index++) { diff --git a/docs/adrenaline-echo.md b/docs/adrenaline-echo.md new file mode 100644 index 0000000..719056e --- /dev/null +++ b/docs/adrenaline-echo.md @@ -0,0 +1,100 @@ +# Adrenaline Echo + +Adrenaline Echo is a Unique shardstone for trinkets. Its permanent shard ID is +`0x50520006`. It uses the existing Stormcaller shard visuals and drop distribution. +Epic, Legendary, Mythic, and Ancient shards each grant the same effect. + +When the equipped trinket activates its full-adrenaline status effect, the shard +fires one projectile from the equipped weapon toward the player's aim. The weapon +must have a projectile primary attack. Bows and crossbows need compatible ammunition +in the inventory. Melee weapons and empty hands do not create a projectile. + +The bonus shot costs no ammunition, durability, stamina, health, or eitr. It does +not change the current weapon's reload state or interrupt its attack animation. +Epic Loot multishot can increase the projectile count through its normal roll. + +The shot uses the actual equipped `ItemData` and the normal patched attack and +projectile setup methods. This preserves weapon damage enchantments, projectile +modifiers, and the weapon identity that Epic Loot uses when the projectile hits. +Attack hooks use an isolated copy of the weapon's attack definition. Epic Loot's +pending multishot state is restored after the bonus shot. + +The activation hook runs at the game's full-adrenaline equipment activation +branch. It also covers a fill from zero and a refresh of an existing trinket +effect. Partial gains and adrenaline loss do not activate the shard. The shot +runs on the next frame to avoid nested changes to shared weapon damage. If the +player dies or switches weapons before that frame, the shot is cancelled. + +## Validation + +Run the live test with the currently deployed Praetoris client mod set and the +candidate DLL. Socket the actual shard prefab, `1347551238_Epic_ShardStone`, into +a magic trinket that has a full-adrenaline status effect. + +- Fill adrenaline with an unenchanted Staff of Embers equipped. Confirm one shot. +- Fill again while the trinket effect is active. Confirm one more shot. +- Repeat with damage, frost damage, and projectile speed enchantments. Inspect + the projectile damage and speed, and confirm the impact retains frost damage. +- Check partial gains, adrenaline loss, an unsocketed trinket, melee weapons, + empty hands, and a bow without ammunition. Confirm no bonus shot. +- Check a bow with ammunition, a staggered player, and the Double Magic Shot + effect. Confirm ammunition and weapon durability remain unchanged. +- Compare startup and test logs with the unmodified production baseline. +- Capture and inspect images and a video of the real activation and impact. + +This is a client feature. A local Valnet proof world can test the firing and +damage paths. It does not prove remote target ownership or multiplayer behavior. + +### Recorded result — 2026-09-28 + +Built in Release and tested on `valnet-client-02`, in local `TestingWorld`, with +Valheim 1.0.16 and the deployed Season 8 release 8.0.29 (Epic Loot 0.14.13). +Thirty shared DLLs matched production hashes. PraetorisClient was the candidate. +The maintained profile needed temporary repairs to reach this baseline. + +All 12 live cases passed: partial gain, drain, plain staff, status refresh, +enchanted staff, stagger, no shard, melee, unarmed, missing ammunition, bow, +and Double Magic Shot. The staff projectile kept the real weapon reference. +Its speed increased from 20 to 40 with the speed enchantment. Frost damage +was present in both projectile setup and Troll impact logs. The forced +Double Magic Shot roll produced two projectiles. Staff durability and ammunition +did not change. Resource checks account for normal food cap decay. + +A saved socketed trinket fired an enchanted projectile after a game restart. +The four shard rarity prefabs registered, and the socket reported Unique category. +The proof uses real `Player.AddAdrenaline` calls to activate the trinket. It does +not test every way that combat can grant adrenaline or every Epic Loot effect. + +The final log contained existing Linux shader errors, Jotunn command constructor +errors, and location registration errors. No Adrenaline Echo exception occurred. +The original profile files and metadata were restored after testing. +All 483 original file hashes matched, with no extra files. A restored-profile +startup reproduced its pre-existing Epic Loot patch errors. This older profile +still needs separate maintenance; a clean restored startup is not claimed. + +Evidence is stored outside the repository at: +`/Users/benjmarston/Develop/valheim-validation-evidence/adrenaline-echo-20260928`. +The main files are `final-proof-run.log`, `first-proof-run.log` (video impacts), +`baseline.json`, `restoration.json`, `trinket-tooltip.png`, `staff-tooltip.png`, +`bonus-projectile.png`, and `adrenaline-echo-proof.mp4`. +The 1080p/60 fps recording shows the bonus activation and fireball near 11 seconds. +The images and video contact sheets were inspected manually. The automated visual +review helper could not run because its requested models were unavailable. + +### Repeat the live probe + +Build `tests/AdrenalineEchoProbe/Probe.csproj` in Release. Install its DLL only +in the leased test client's profile beside the candidate. The main project +excludes the probe source. Use a disposable character in a local proof world. +`seed` adds food, a staff, and a socketed trinket; `suite` changes that inventory. + +```text +cli_clear_inventory +puke +echo_probe seed +echo_probe suite +``` + +Wait for `ECHO_PROBE SUITE_DONE` in the BepInEx log. Require 12 `CASE` lines +with `PASS`, and inspect the projectile and impact lines. `echo_probe fill` +activates full adrenaline for manual proof. Remove the probe after testing. diff --git a/tests/AdrenalineEchoProbe/Probe.cs b/tests/AdrenalineEchoProbe/Probe.cs new file mode 100644 index 0000000..8908c49 --- /dev/null +++ b/tests/AdrenalineEchoProbe/Probe.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using BepInEx; +using EpicLoot; +using EpicLoot.ShardStones; +using HarmonyLib; +using UnityEngine; + +[BepInPlugin("local.adrenaline.echo.probe", "Adrenaline Echo Probe", "1.0.0")] +[BepInDependency("warpalicious.PraetorisClient")] +public class Probe : BaseUnityPlugin +{ + private static Probe instance; + private static ItemDrop.ItemData staff, trinket; + private static string label = "idle"; + private static readonly List shots = new List(); + private static readonly AccessTools.FieldRef Adrenaline = AccessTools.FieldRefAccess("m_adrenaline"); + private static readonly AccessTools.FieldRef ProjectileWeapon = AccessTools.FieldRefAccess("m_weapon"); + private void Awake() + { + instance = this; + new Harmony("local.adrenaline.echo.probe").PatchAll(); + new Terminal.ConsoleCommand("echo_probe", "Adrenaline Echo live validation", args => + { + try + { + string command = args.Length > 1 ? args[1] : "status"; + if (Player.m_localPlayer == null || ZNet.instance == null || !ZNet.instance.IsServer()) + { + args.Context.AddString("ECHO_PROBE requires a loaded local proof world."); + return; + } + if (command == "seed") Seed(); + if (command == "suite") + { + if (staff == null || trinket == null) throw new InvalidOperationException("Run seed successfully before suite."); + StartCoroutine(Suite()); + } + if (command == "fill") { label = "manual"; Player.m_localPlayer.AddAdrenaline(10000f); } + if (command == "plain") Enchant(false); + if (command == "enchanted") Enchant(true); + if (command == "status") Report(); + args.Context.AddString("ECHO_PROBE " + command + " OK"); + } + catch (Exception e) { Logger.LogError(e); args.Context.AddString("ECHO_PROBE FAIL " + e.Message); } + }); + } + private static void Print(string value) => instance.Logger.LogInfo("ECHO_PROBE " + value); + private static ItemDrop.ItemData Give(string prefab) + { + ItemDrop.ItemData item = ObjectDB.instance.GetItemPrefab(prefab).GetComponent().m_itemData.Clone(); + item.m_dropPrefab = ObjectDB.instance.GetItemPrefab(prefab); + item.m_durability = item.GetMaxDurability(); + Player.m_localPlayer.GetInventory().AddItem(item); + return item; + } + private static void Seed() + { + Player p = Player.m_localPlayer; + foreach (string food in new[] { "Salad", "SeekerAspic", "YggdrasilPorridge" }) + { + ItemDrop.ItemData foodItem = Give(food); + p.ConsumeItem(p.GetInventory(), foodItem); + } + staff = Give("StaffFireball"); + trinket = Give("TrinketBronzeStamina"); + trinket.SaveMagicItem(new MagicItem { Rarity = ItemRarity.Epic, SocketCount = 1 }); + ItemDrop.ItemData shard = Give("1347551238_Epic_ShardStone"); + bool added = ShardSocketManager.AddShard(trinket, shard); + if (!added) throw new Exception("Cannot socket registered shard: " + trinket.GetTooltip()); + p.GetInventory().RemoveItem(shard); + p.EquipItem(trinket, false); + p.EquipItem(staff, false); + Print("socketed=" + added + " effect=" + trinket.GetMagicItem().Sockets[0].Effect.EffectType + + " category=" + Shards.GetCFG().Shards[(ShardType)0x50520006].Category + + " fullSE=" + trinket.m_shared.m_fullAdrenalineSE?.name); + foreach (ItemRarity rarity in new[] {ItemRarity.Epic,ItemRarity.Legendary,ItemRarity.Mythic,(ItemRarity)5}) + Print("prefab=" + rarity + " present=" + (ObjectDB.instance.GetItemPrefab("1347551238_" + rarity + "_ShardStone") != null)); + Enchant(false); + } + private static void Enchant(bool enabled) + { + MagicItem magic = new MagicItem { Rarity = ItemRarity.Legendary }; + if (enabled) + { + magic.Effects.Add(new MagicItemEffect("ModifyDamage", 100f)); + magic.Effects.Add(new MagicItemEffect("AddFrostDamage", 50f)); + magic.Effects.Add(new MagicItemEffect("ModifyProjectileSpeed", 100f)); + } + staff.SaveMagicItem(magic); + Player.m_localPlayer.UnequipItem(staff, false); + Player.m_localPlayer.EquipItem(staff, false); + Print("enchant=" + enabled + " damage=" + staff.GetDamage().GetTotalDamage()); + } + private static void Report() + { + Player p = Player.m_localPlayer; + Print("adrenaline=" + p.GetAdrenaline() + "/" + p.GetMaxAdrenaline() + " weapon=" + p.GetCurrentWeapon()?.m_dropPrefab?.name + " shots=" + shots.Count); + } + private IEnumerator Suite() + { + Player p = Player.m_localPlayer; + float plainDamage = 0f; + float plainSpeed = 0f; + Dictionary doubleConfig = MagicItemEffectDefinitions.AllDefinitions["DoubleMagicShot"].Config; + bool hadChance = doubleConfig.TryGetValue("Chance", out float originalChance); + foreach (string test in new[] {"partial", "drain", "plain", "refresh", "enchanted", "staggered", "no-shard", "melee", "unarmed", "no-ammo", "bow", "double-magic"}) + { + label = test; + shots.Clear(); + Adrenaline(p) = 0f; + if (test == "enchanted") Enchant(true); + if (test == "no-shard") + { + trinket.SaveMagicItem(new MagicItem { Rarity = ItemRarity.Epic, SocketCount = 1 }); + p.UnequipItem(trinket, false); p.EquipItem(trinket, false); + } + if (test == "melee") + { + ItemDrop.ItemData shard = Give("1347551238_Epic_ShardStone"); + if (!ShardSocketManager.AddShard(trinket, shard)) throw new Exception("Restore socket failed"); + p.GetInventory().RemoveItem(shard); + p.UnequipItem(trinket, false); p.EquipItem(trinket, false); + p.EquipItem(Give("SwordBronze"), false); + } + if (test == "unarmed") p.UnequipItem(p.GetCurrentWeapon(), false); + if (test == "no-ammo") p.EquipItem(Give("Bow"), false); + if (test == "bow") Give("ArrowWood"); + if (test == "double-magic") + { + staff.SaveMagicItem(new MagicItem { Rarity = ItemRarity.Legendary, Effects = new List { new MagicItemEffect("DoubleMagicShot", 100f) } }); + p.EquipItem(staff, false); + doubleConfig["Chance"] = 1f; + } + p.AddEitr(1000f); + p.AddStamina(1000f); + int ammoBefore = p.GetInventory().GetAllItems().Where(item => item.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Ammo).Sum(item => item.m_stack); + float durability = staff.m_durability, stamina = p.GetStamina(), eitr = p.GetEitr(), health = p.GetHealth(); + UnityEngine.Random.InitState(491); + if (test == "staggered") + { + p.Stagger(Vector3.back); + yield return new WaitForSeconds(0.2f); + Print("STAGGER active=" + p.IsStaggering()); + } + if (test == "partial") p.AddAdrenaline(0.01f); + else if (test == "drain") {Adrenaline(p) = 10f; p.AddAdrenaline(-10f);} + else p.AddAdrenaline(10000f); + yield return new WaitForSeconds(0.2f); + int expected = test == "double-magic" ? 2 : test == "plain" || test == "refresh" || test == "enchanted" || test == "staggered" || test == "bow" ? 1 : 0; + bool passed = shots.Count == expected && shots.All(shot => shot.sameWeapon) && Mathf.Approximately(staff.m_durability, durability); + int ammoAfter = p.GetInventory().GetAllItems().Where(item => item.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Ammo).Sum(item => item.m_stack); + // Food decay lowers the resource caps even when no shot is fired. Check spending + // against the remaining cap rather than treating that independent change as a cost. + passed &= ammoBefore == ammoAfter && p.GetEitr() >= Mathf.Min(eitr, p.GetMaxEitr()) - 0.01f && + p.GetStamina() >= Mathf.Min(stamina, p.GetMaxStamina()) - 0.01f && + p.GetHealth() >= Mathf.Min(health, p.GetMaxHealth()) - 0.01f; + if (test == "plain" && shots.Count == 1) { plainDamage = shots[0].damage; plainSpeed = shots[0].speed; } + if (test == "enchanted") passed &= shots.Count == 1 && shots[0].frost > 0f && shots[0].damage > plainDamage && Mathf.Approximately(shots[0].speed, plainSpeed * 2f); + Print("CASE=" + test + " " + (passed ? "PASS" : "FAIL") + " shots=" + shots.Count + " expected=" + expected + + " durabilityDelta=" + (staff.m_durability-durability) + " healthDelta=" + (p.GetHealth()-health) + + " staminaDelta=" + (p.GetStamina()-stamina) + " eitrDelta=" + (p.GetEitr()-eitr) + " ammoDelta=" + (ammoAfter-ammoBefore)); + yield return new WaitForSeconds(2f); + } + if (hadChance) doubleConfig["Chance"] = originalChance; + else doubleConfig.Remove("Chance"); + Print("SUITE_DONE"); + } + private class Shot { public float damage, frost, speed; public bool sameWeapon; } + [HarmonyPatch(typeof(Projectile), nameof(Projectile.Setup))] + private class Capture + { + private static void Postfix(Projectile __instance, Character owner) + { + if (owner != Player.m_localPlayer) return; + ItemDrop.ItemData weapon = ProjectileWeapon(__instance); + float speed = __instance.GetVelocity().magnitude; + shots.Add(new Shot { + damage = __instance.m_damage.GetTotalDamage(), frost = __instance.m_damage.m_frost, + speed = speed, + sameWeapon = ReferenceEquals(weapon, Player.m_localPlayer.GetCurrentWeapon()) + }); + Print("SHOT case=" + label + " prefab=" + __instance.name + " sameWeapon=" + ReferenceEquals(weapon,Player.m_localPlayer.GetCurrentWeapon()) + + " damage=" + __instance.m_damage.GetTotalDamage() + " fire=" + __instance.m_damage.m_fire + " frost=" + __instance.m_damage.m_frost + + " speed=" + speed + " staggering=" + Player.m_localPlayer.IsStaggering() + + " effects=" + string.Join(",", weapon?.GetMagicItem()?.Effects.Select(e => e.EffectType) ?? Enumerable.Empty())); + } + } + [HarmonyPatch(typeof(Character), nameof(Character.Damage))] + private class Hit + { + private static void Prefix(Character __instance, HitData hit) + { + if (hit.GetAttacker() == Player.m_localPlayer && !__instance.IsPlayer()) + Print("HIT case=" + label + " target=" + __instance.name + " fire=" + hit.m_damage.m_fire + " frost=" + hit.m_damage.m_frost); + } + } +} diff --git a/tests/AdrenalineEchoProbe/Probe.csproj b/tests/AdrenalineEchoProbe/Probe.csproj new file mode 100644 index 0000000..6162a0e --- /dev/null +++ b/tests/AdrenalineEchoProbe/Probe.csproj @@ -0,0 +1,14 @@ + + + + net481 + 10 + AdrenalineEchoProbe + + + + ../../Libs/EpicLoot-publicized.dll + false + + + From b8eabd66b3e97a120b93b8583e812ebfc22cbc8d Mon Sep 17 00:00:00 2001 From: jneb802 <73610029+jneb802@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:02:25 -0700 Subject: [PATCH 2/6] Trigger Adrenaline Echo impact at the player position --- Source/EpicLoot/AdrenalineEchoRuntime.cs | 45 +++++++++++++- Source/EpicLoot/PraetorisMagicEffects.cs | 4 +- docs/adrenaline-echo.md | 68 ++++++++++++++-------- tests/AdrenalineEchoProbe/Probe.cs | 74 +++++++++++++++++++++--- 4 files changed, 157 insertions(+), 34 deletions(-) diff --git a/Source/EpicLoot/AdrenalineEchoRuntime.cs b/Source/EpicLoot/AdrenalineEchoRuntime.cs index ec8459c..4280da2 100644 --- a/Source/EpicLoot/AdrenalineEchoRuntime.cs +++ b/Source/EpicLoot/AdrenalineEchoRuntime.cs @@ -12,6 +12,9 @@ namespace PraetorisClient internal static class AdrenalineEchoRuntime { private static Attack? activeEcho; + private static Vector3 echoPosition; + private static bool collectingProjectiles; + private static readonly List EchoProjectiles = new List(); private static readonly FieldInfo PendingShot = AccessTools.Field(typeof(MultiShot), "_pendingShot"); private static readonly AccessTools.FieldRef Trinket = AccessTools.FieldRefAccess("m_trinketItem"); private static readonly AccessTools.FieldRef AttackAmmo = AccessTools.FieldRefAccess("m_ammoItem"); @@ -62,7 +65,7 @@ private static IEnumerator FireAfterAttack(Player player, ItemDrop.ItemData weap { projectile = weapon.m_shared.m_attack.m_attackProjectile; } - if (projectile == null || projectile.GetComponent() == null) + if (projectile == null || projectile.GetComponent() == null) { yield break; } @@ -92,9 +95,21 @@ private static IEnumerator FireAfterAttack(Player player, ItemDrop.ItemData weap GameObject previousProjectile = weapon.m_lastProjectile; float previousAttackTime = weapon.m_lastAttackTime; activeEcho = echo; + echoPosition = player.transform.position; + collectingProjectiles = true; try { echo.StartWithoutAnimation(player, player.GetComponent(), player.GetVisEquipment(), weapon, 1f); + collectingProjectiles = false; + // Finish all setup and burst hooks before impact. Epic Loot adds some impact + // effects after Projectile.Setup, including Explosive Arrows. + foreach (Projectile spawnedProjectile in EchoProjectiles) + { + if (spawnedProjectile != null) + { + spawnedProjectile.OnHit(null, echoPosition, false, Vector3.up); + } + } } catch (Exception exception) { @@ -103,6 +118,8 @@ private static IEnumerator FireAfterAttack(Player player, ItemDrop.ItemData weap finally { activeEcho = null; + collectingProjectiles = false; + EchoProjectiles.Clear(); PendingShot.SetValue(null, pendingShot); MultiShot.IsTripleShotActive = tripleShot; MultiShot.ShotProjectiles = shotProjectiles; @@ -111,6 +128,32 @@ private static IEnumerator FireAfterAttack(Player player, ItemDrop.ItemData weap } } + [HarmonyPatch(typeof(Attack), nameof(Attack.GetProjectileSpawnPoint))] + private static class EchoPositionPatch + { + private static void Postfix(Attack __instance, ref Vector3 spawnPoint) + { + if (__instance == activeEcho) spawnPoint = echoPosition; + } + } + + [HarmonyPatch(typeof(Projectile), nameof(Projectile.Setup))] + private static class EchoSetupPatch + { + [HarmonyPriority(Priority.Last)] + private static void Prefix(Projectile __instance, Character owner, ref Vector3 velocity) + { + if (!collectingProjectiles || owner != Player.m_localPlayer) return; + __instance.transform.position = echoPosition; + velocity = Vector3.zero; + __instance.m_hitOwner = false; + __instance.m_bounce = false; + __instance.m_onlyStopOnTerrain = false; + __instance.m_respawnItemOnHit = false; + EchoProjectiles.Add(__instance); + } + } + [HarmonyPatch(typeof(Player), nameof(Player.AddAdrenaline))] private static class ActivationPatch { diff --git a/Source/EpicLoot/PraetorisMagicEffects.cs b/Source/EpicLoot/PraetorisMagicEffects.cs index 18b86c2..fde6fb2 100644 --- a/Source/EpicLoot/PraetorisMagicEffects.cs +++ b/Source/EpicLoot/PraetorisMagicEffects.cs @@ -36,8 +36,8 @@ internal static class PraetorisMagicEffects ""CanBeAugmented"": false, ""CanBeDisenchanted"": false, ""CanBeRunified"": false, - ""DisplayText"": ""Adrenaline Echo: Full adrenaline fires your equipped weapon's projectile."", - ""Description"": ""When this trinket activates its full-adrenaline effect, fire a bonus projectile toward your aim using your equipped weapon and its enchantments. Requires a projectile weapon and compatible ammunition for bows and crossbows. Costs no ammunition, durability, stamina, health, or eitr."", + ""DisplayText"": ""Adrenaline Echo: Full adrenaline triggers your weapon's projectile impact at your position."", + ""Description"": ""When this trinket activates its full-adrenaline effect, trigger your equipped weapon's projectile impact at your position, without travel. Uses the projectile's area damage and your weapon's enchantments. Requires a projectile weapon and compatible ammunition for bows and crossbows. Costs no ammunition, durability, stamina, health, or eitr."", ""Requirements"": { ""NoRoll"": true, ""AllowedItemTypes"": [ ""Trinket"" ] } }", @"{ diff --git a/docs/adrenaline-echo.md b/docs/adrenaline-echo.md index 719056e..232e640 100644 --- a/docs/adrenaline-echo.md +++ b/docs/adrenaline-echo.md @@ -5,9 +5,13 @@ Adrenaline Echo is a Unique shardstone for trinkets. Its permanent shard ID is Epic, Legendary, Mythic, and Ancient shards each grant the same effect. When the equipped trinket activates its full-adrenaline status effect, the shard -fires one projectile from the equipped weapon toward the player's aim. The weapon -must have a projectile primary attack. Bows and crossbows need compatible ammunition +creates the equipped weapon's projectile at the player's feet and immediately +triggers its impact. It does not travel toward the player's aim or an enemy. +Staff of Embers therefore explodes around the player, using its normal area +radius and enchanted damage. The weapon must have a projectile primary attack. +Bows and crossbows need compatible ammunition in the inventory. Melee weapons and empty hands do not create a projectile. +Projectiles without area damage do not gain an invented damage radius. The bonus shot costs no ammunition, durability, stamina, health, or eitr. It does not change the current weapon's reload state or interrupt its attack animation. @@ -16,6 +20,9 @@ Epic Loot multishot can increase the projectile count through its normal roll. The shot uses the actual equipped `ItemData` and the normal patched attack and projectile setup methods. This preserves weapon damage enchantments, projectile modifiers, and the weapon identity that Epic Loot uses when the projectile hits. +Projectile speed has no effect because the projectile has zero velocity. +The impact cannot hit its owner. All projectile setup and burst hooks finish +before impact so effects added late by Epic Loot remain available. Attack hooks use an isolated copy of the weapon's attack definition. Epic Loot's pending multishot state is restored after the bonus shot. @@ -31,10 +38,15 @@ Run the live test with the currently deployed Praetoris client mod set and the candidate DLL. Socket the actual shard prefab, `1347551238_Epic_ShardStone`, into a magic trinket that has a full-adrenaline status effect. -- Fill adrenaline with an unenchanted Staff of Embers equipped. Confirm one shot. -- Fill again while the trinket effect is active. Confirm one more shot. +- Fill adrenaline with an unenchanted Staff of Embers equipped. Confirm one + immediate impact at the player's position, with zero projectile velocity. +- Fill again while the trinket effect is active. Confirm one more impact. - Repeat with damage, frost damage, and projectile speed enchantments. Inspect - the projectile damage and speed, and confirm the impact retains frost damage. + the projectile damage, and confirm the impact retains frost damage and remains + at the player even with a projectile speed enchantment. +- Place enemies on both sides of the player and one outside the explosion radius. + Confirm nearby enemies take damage, the distant enemy does not, and the player's + health does not change with god mode disabled. - Check partial gains, adrenaline loss, an unsocketed trinket, melee weapons, empty hands, and a bow without ammunition. Confirm no bonus shot. - Check a bow with ammunition, a staggered player, and the Double Magic Shot @@ -54,32 +66,40 @@ The maintained profile needed temporary repairs to reach this baseline. All 12 live cases passed: partial gain, drain, plain staff, status refresh, enchanted staff, stagger, no shard, melee, unarmed, missing ammunition, bow, -and Double Magic Shot. The staff projectile kept the real weapon reference. -Its speed increased from 20 to 40 with the speed enchantment. Frost damage -was present in both projectile setup and Troll impact logs. The forced -Double Magic Shot roll produced two projectiles. Staff durability and ammunition -did not change. Resource checks account for normal food cap decay. - -A saved socketed trinket fired an enchanted projectile after a game restart. +and Double Magic Shot. Every bonus projectile had zero speed and zero distance +from the player. Every impact occurred at the player's position. The staff +projectile kept the real weapon reference and its 3-metre explosion radius. +The forced Double Magic Shot roll produced two immediate impacts. +Weapon durability and ammunition did not change. Resource checks account for +normal food cap decay. + +The area test hit both Trolls placed 3 metres to either side of the player and +did not hit the Troll placed 15 metres away. Both hits carried fire and frost +damage of 90.55515 each. Player health did not change, with god mode disabled. The four shard rarity prefabs registered, and the socket reported Unique category. The proof uses real `Player.AddAdrenaline` calls to activate the trinket. It does not test every way that combat can grant adrenaline or every Epic Loot effect. The final log contained existing Linux shader errors, Jotunn command constructor errors, and location registration errors. No Adrenaline Echo exception occurred. -The original profile files and metadata were restored after testing. -All 483 original file hashes matched, with no extra files. A restored-profile -startup reproduced its pre-existing Epic Loot patch errors. This older profile -still needs separate maintenance; a clean restored startup is not claimed. +An enemy killed the character during a rejected recording attempt. The existing +death-screenshot mod then logged an empty webhook error. The accepted recording +uses god and ghost modes for staging; the separate area test used neither. +The maintained profile had pre-existing Epic Loot patch errors before the test. +This older profile still needs separate maintenance; a clean restored startup +is not claimed. +After the restored-startup check, all 483 original profile files and both profile +metadata files were restored exactly. Test plugins and temporary backups were +removed. OBS scene inputs and its recording path were restored. Evidence is stored outside the repository at: -`/Users/benjmarston/Develop/valheim-validation-evidence/adrenaline-echo-20260928`. -The main files are `final-proof-run.log`, `first-proof-run.log` (video impacts), -`baseline.json`, `restoration.json`, `trinket-tooltip.png`, `staff-tooltip.png`, -`bonus-projectile.png`, and `adrenaline-echo-proof.mp4`. -The 1080p/60 fps recording shows the bonus activation and fireball near 11 seconds. -The images and video contact sheets were inspected manually. The automated visual -review helper could not run because its requested models were unavailable. +`/Users/benjmarston/Develop/valheim-validation-evidence/adrenaline-echo-aoe-20260928`. +The original `adrenaline-echo-20260928` folder contains the superseded travelling +projectile proof. Use the `aoe` folder for this behavior. +The accepted `adrenaline-echo-aoe-proof.mp4` is 6.883 seconds at 1080p/60 fps. +The explosion occurs near 3.2 seconds. `player-centered-explosion.png` shows the +impact. The video contact sheet and both equipment tooltips were inspected +manually because the automated review models were unavailable. ### Repeat the live probe @@ -98,3 +118,5 @@ echo_probe suite Wait for `ECHO_PROBE SUITE_DONE` in the BepInEx log. Require 12 `CASE` lines with `PASS`, and inspect the projectile and impact lines. `echo_probe fill` activates full adrenaline for manual proof. Remove the probe after testing. +`echo_probe area` creates two nearby Trolls and one distant Troll, then checks +the area damage and player health. `echo_probe cleanup` removes these targets. diff --git a/tests/AdrenalineEchoProbe/Probe.cs b/tests/AdrenalineEchoProbe/Probe.cs index 8908c49..88ab8bf 100644 --- a/tests/AdrenalineEchoProbe/Probe.cs +++ b/tests/AdrenalineEchoProbe/Probe.cs @@ -16,6 +16,10 @@ public class Probe : BaseUnityPlugin private static ItemDrop.ItemData staff, trinket; private static string label = "idle"; private static readonly List shots = new List(); + private static int impacts; + private static bool centeredImpacts; + private static readonly List areaTargets = new List(); + private static readonly HashSet areaHits = new HashSet(); private static readonly AccessTools.FieldRef Adrenaline = AccessTools.FieldRefAccess("m_adrenaline"); private static readonly AccessTools.FieldRef ProjectileWeapon = AccessTools.FieldRefAccess("m_weapon"); private void Awake() @@ -39,6 +43,8 @@ private void Awake() StartCoroutine(Suite()); } if (command == "fill") { label = "manual"; Player.m_localPlayer.AddAdrenaline(10000f); } + if (command == "area") StartCoroutine(Area()); + if (command == "cleanup") CleanupTargets(); if (command == "plain") Enchant(false); if (command == "enchanted") Enchant(true); if (command == "status") Report(); @@ -103,13 +109,14 @@ private IEnumerator Suite() { Player p = Player.m_localPlayer; float plainDamage = 0f; - float plainSpeed = 0f; Dictionary doubleConfig = MagicItemEffectDefinitions.AllDefinitions["DoubleMagicShot"].Config; bool hadChance = doubleConfig.TryGetValue("Chance", out float originalChance); foreach (string test in new[] {"partial", "drain", "plain", "refresh", "enchanted", "staggered", "no-shard", "melee", "unarmed", "no-ammo", "bow", "double-magic"}) { label = test; shots.Clear(); + impacts = 0; + centeredImpacts = true; Adrenaline(p) = 0f; if (test == "enchanted") Enchant(true); if (test == "no-shard") @@ -137,7 +144,8 @@ private IEnumerator Suite() p.AddEitr(1000f); p.AddStamina(1000f); int ammoBefore = p.GetInventory().GetAllItems().Where(item => item.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Ammo).Sum(item => item.m_stack); - float durability = staff.m_durability, stamina = p.GetStamina(), eitr = p.GetEitr(), health = p.GetHealth(); + ItemDrop.ItemData equipped = p.GetCurrentWeapon(); + float durability = equipped.m_durability, stamina = p.GetStamina(), eitr = p.GetEitr(), health = p.GetHealth(); UnityEngine.Random.InitState(491); if (test == "staggered") { @@ -150,17 +158,19 @@ private IEnumerator Suite() else p.AddAdrenaline(10000f); yield return new WaitForSeconds(0.2f); int expected = test == "double-magic" ? 2 : test == "plain" || test == "refresh" || test == "enchanted" || test == "staggered" || test == "bow" ? 1 : 0; - bool passed = shots.Count == expected && shots.All(shot => shot.sameWeapon) && Mathf.Approximately(staff.m_durability, durability); + bool passed = shots.Count == expected && impacts == expected && centeredImpacts && + shots.All(shot => shot.sameWeapon && shot.speed < 0.001f && shot.distance < 0.01f) && + Mathf.Approximately(equipped.m_durability, durability); int ammoAfter = p.GetInventory().GetAllItems().Where(item => item.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Ammo).Sum(item => item.m_stack); // Food decay lowers the resource caps even when no shot is fired. Check spending // against the remaining cap rather than treating that independent change as a cost. passed &= ammoBefore == ammoAfter && p.GetEitr() >= Mathf.Min(eitr, p.GetMaxEitr()) - 0.01f && p.GetStamina() >= Mathf.Min(stamina, p.GetMaxStamina()) - 0.01f && p.GetHealth() >= Mathf.Min(health, p.GetMaxHealth()) - 0.01f; - if (test == "plain" && shots.Count == 1) { plainDamage = shots[0].damage; plainSpeed = shots[0].speed; } - if (test == "enchanted") passed &= shots.Count == 1 && shots[0].frost > 0f && shots[0].damage > plainDamage && Mathf.Approximately(shots[0].speed, plainSpeed * 2f); + if (test == "plain" && shots.Count == 1) plainDamage = shots[0].damage; + if (test == "enchanted") passed &= shots.Count == 1 && shots[0].frost > 0f && shots[0].damage > plainDamage; Print("CASE=" + test + " " + (passed ? "PASS" : "FAIL") + " shots=" + shots.Count + " expected=" + expected + - " durabilityDelta=" + (staff.m_durability-durability) + " healthDelta=" + (p.GetHealth()-health) + + " impacts=" + impacts + " centered=" + centeredImpacts + " durabilityDelta=" + (equipped.m_durability-durability) + " healthDelta=" + (p.GetHealth()-health) + " staminaDelta=" + (p.GetStamina()-stamina) + " eitrDelta=" + (p.GetEitr()-eitr) + " ammoDelta=" + (ammoAfter-ammoBefore)); yield return new WaitForSeconds(2f); } @@ -168,7 +178,38 @@ private IEnumerator Suite() else doubleConfig.Remove("Chance"); Print("SUITE_DONE"); } - private class Shot { public float damage, frost, speed; public bool sameWeapon; } + private static void CleanupTargets() + { + foreach (Character target in areaTargets) + if (target != null) ZNetScene.instance.Destroy(target.gameObject); + areaTargets.Clear(); + } + private IEnumerator Area() + { + CleanupTargets(); + Enchant(true); + Player p = Player.m_localPlayer; + foreach (Vector3 offset in new[] {p.transform.right * 3f, -p.transform.right * 3f, p.transform.forward * 15f}) + { + GameObject go = Instantiate(ZNetScene.instance.GetPrefab("Troll"), p.transform.position + offset, Quaternion.identity); + Character target = go.GetComponent(); + target.SetMaxHealth(10000f); + target.SetHealth(10000f); + go.GetComponent().enabled = false; + areaTargets.Add(target); + } + yield return new WaitForSeconds(1f); + areaHits.Clear(); + label = "area"; + float health = p.GetHealth(); + p.AddAdrenaline(10000f); + yield return new WaitForSeconds(0.3f); + bool passed = areaHits.Contains(areaTargets[0]) && areaHits.Contains(areaTargets[1]) && + !areaHits.Contains(areaTargets[2]) && Mathf.Approximately(health, p.GetHealth()); + Print("AREA " + (passed ? "PASS" : "FAIL") + " left=" + areaHits.Contains(areaTargets[0]) + + " right=" + areaHits.Contains(areaTargets[1]) + " distant=" + areaHits.Contains(areaTargets[2]) + " selfHealthDelta=" + (p.GetHealth()-health)); + } + private class Shot { public float damage, frost, speed, distance; public bool sameWeapon; } [HarmonyPatch(typeof(Projectile), nameof(Projectile.Setup))] private class Capture { @@ -177,24 +218,41 @@ private static void Postfix(Projectile __instance, Character owner) if (owner != Player.m_localPlayer) return; ItemDrop.ItemData weapon = ProjectileWeapon(__instance); float speed = __instance.GetVelocity().magnitude; + float distance = Vector3.Distance(__instance.transform.position, owner.transform.position); shots.Add(new Shot { damage = __instance.m_damage.GetTotalDamage(), frost = __instance.m_damage.m_frost, speed = speed, + distance = distance, sameWeapon = ReferenceEquals(weapon, Player.m_localPlayer.GetCurrentWeapon()) }); Print("SHOT case=" + label + " prefab=" + __instance.name + " sameWeapon=" + ReferenceEquals(weapon,Player.m_localPlayer.GetCurrentWeapon()) + " damage=" + __instance.m_damage.GetTotalDamage() + " fire=" + __instance.m_damage.m_fire + " frost=" + __instance.m_damage.m_frost + - " speed=" + speed + " staggering=" + Player.m_localPlayer.IsStaggering() + + " speed=" + speed + " playerDistance=" + distance + " staggering=" + Player.m_localPlayer.IsStaggering() + " effects=" + string.Join(",", weapon?.GetMagicItem()?.Effects.Select(e => e.EffectType) ?? Enumerable.Empty())); } } + [HarmonyPatch(typeof(Projectile), nameof(Projectile.OnHit))] + private class Impact + { + private static void Prefix(Projectile __instance, Vector3 hitPoint) + { + if (ProjectileWeapon(__instance) != Player.m_localPlayer?.GetCurrentWeapon()) return; + float distance = Vector3.Distance(hitPoint, Player.m_localPlayer.transform.position); + impacts++; + centeredImpacts &= distance < 0.01f; + Print("IMPACT case=" + label + " playerDistance=" + distance + " radius=" + __instance.m_aoe); + } + } [HarmonyPatch(typeof(Character), nameof(Character.Damage))] private class Hit { private static void Prefix(Character __instance, HitData hit) { if (hit.GetAttacker() == Player.m_localPlayer && !__instance.IsPlayer()) + { + if (label == "area") areaHits.Add(__instance); Print("HIT case=" + label + " target=" + __instance.name + " fire=" + hit.m_damage.m_fire + " frost=" + hit.m_damage.m_frost); + } } } } From 09517f1e1fbfb5133a24422b69b84fd5eb8c18ba Mon Sep 17 00:00:00 2001 From: jneb802 <73610029+jneb802@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:42:03 -0700 Subject: [PATCH 3/6] Remove Adrenaline Echo test helper and documentation --- PraetorisClient.csproj | 1 - docs/adrenaline-echo.md | 122 ------------ tests/AdrenalineEchoProbe/Probe.cs | 258 ------------------------- tests/AdrenalineEchoProbe/Probe.csproj | 14 -- 4 files changed, 395 deletions(-) delete mode 100644 docs/adrenaline-echo.md delete mode 100644 tests/AdrenalineEchoProbe/Probe.cs delete mode 100644 tests/AdrenalineEchoProbe/Probe.csproj diff --git a/PraetorisClient.csproj b/PraetorisClient.csproj index 08e96d0..8572624 100644 --- a/PraetorisClient.csproj +++ b/PraetorisClient.csproj @@ -49,7 +49,6 @@ - PraetorisClient.GuideImages.%(Filename)%(Extension) diff --git a/docs/adrenaline-echo.md b/docs/adrenaline-echo.md deleted file mode 100644 index 232e640..0000000 --- a/docs/adrenaline-echo.md +++ /dev/null @@ -1,122 +0,0 @@ -# Adrenaline Echo - -Adrenaline Echo is a Unique shardstone for trinkets. Its permanent shard ID is -`0x50520006`. It uses the existing Stormcaller shard visuals and drop distribution. -Epic, Legendary, Mythic, and Ancient shards each grant the same effect. - -When the equipped trinket activates its full-adrenaline status effect, the shard -creates the equipped weapon's projectile at the player's feet and immediately -triggers its impact. It does not travel toward the player's aim or an enemy. -Staff of Embers therefore explodes around the player, using its normal area -radius and enchanted damage. The weapon must have a projectile primary attack. -Bows and crossbows need compatible ammunition -in the inventory. Melee weapons and empty hands do not create a projectile. -Projectiles without area damage do not gain an invented damage radius. - -The bonus shot costs no ammunition, durability, stamina, health, or eitr. It does -not change the current weapon's reload state or interrupt its attack animation. -Epic Loot multishot can increase the projectile count through its normal roll. - -The shot uses the actual equipped `ItemData` and the normal patched attack and -projectile setup methods. This preserves weapon damage enchantments, projectile -modifiers, and the weapon identity that Epic Loot uses when the projectile hits. -Projectile speed has no effect because the projectile has zero velocity. -The impact cannot hit its owner. All projectile setup and burst hooks finish -before impact so effects added late by Epic Loot remain available. -Attack hooks use an isolated copy of the weapon's attack definition. Epic Loot's -pending multishot state is restored after the bonus shot. - -The activation hook runs at the game's full-adrenaline equipment activation -branch. It also covers a fill from zero and a refresh of an existing trinket -effect. Partial gains and adrenaline loss do not activate the shard. The shot -runs on the next frame to avoid nested changes to shared weapon damage. If the -player dies or switches weapons before that frame, the shot is cancelled. - -## Validation - -Run the live test with the currently deployed Praetoris client mod set and the -candidate DLL. Socket the actual shard prefab, `1347551238_Epic_ShardStone`, into -a magic trinket that has a full-adrenaline status effect. - -- Fill adrenaline with an unenchanted Staff of Embers equipped. Confirm one - immediate impact at the player's position, with zero projectile velocity. -- Fill again while the trinket effect is active. Confirm one more impact. -- Repeat with damage, frost damage, and projectile speed enchantments. Inspect - the projectile damage, and confirm the impact retains frost damage and remains - at the player even with a projectile speed enchantment. -- Place enemies on both sides of the player and one outside the explosion radius. - Confirm nearby enemies take damage, the distant enemy does not, and the player's - health does not change with god mode disabled. -- Check partial gains, adrenaline loss, an unsocketed trinket, melee weapons, - empty hands, and a bow without ammunition. Confirm no bonus shot. -- Check a bow with ammunition, a staggered player, and the Double Magic Shot - effect. Confirm ammunition and weapon durability remain unchanged. -- Compare startup and test logs with the unmodified production baseline. -- Capture and inspect images and a video of the real activation and impact. - -This is a client feature. A local Valnet proof world can test the firing and -damage paths. It does not prove remote target ownership or multiplayer behavior. - -### Recorded result — 2026-09-28 - -Built in Release and tested on `valnet-client-02`, in local `TestingWorld`, with -Valheim 1.0.16 and the deployed Season 8 release 8.0.29 (Epic Loot 0.14.13). -Thirty shared DLLs matched production hashes. PraetorisClient was the candidate. -The maintained profile needed temporary repairs to reach this baseline. - -All 12 live cases passed: partial gain, drain, plain staff, status refresh, -enchanted staff, stagger, no shard, melee, unarmed, missing ammunition, bow, -and Double Magic Shot. Every bonus projectile had zero speed and zero distance -from the player. Every impact occurred at the player's position. The staff -projectile kept the real weapon reference and its 3-metre explosion radius. -The forced Double Magic Shot roll produced two immediate impacts. -Weapon durability and ammunition did not change. Resource checks account for -normal food cap decay. - -The area test hit both Trolls placed 3 metres to either side of the player and -did not hit the Troll placed 15 metres away. Both hits carried fire and frost -damage of 90.55515 each. Player health did not change, with god mode disabled. -The four shard rarity prefabs registered, and the socket reported Unique category. -The proof uses real `Player.AddAdrenaline` calls to activate the trinket. It does -not test every way that combat can grant adrenaline or every Epic Loot effect. - -The final log contained existing Linux shader errors, Jotunn command constructor -errors, and location registration errors. No Adrenaline Echo exception occurred. -An enemy killed the character during a rejected recording attempt. The existing -death-screenshot mod then logged an empty webhook error. The accepted recording -uses god and ghost modes for staging; the separate area test used neither. -The maintained profile had pre-existing Epic Loot patch errors before the test. -This older profile still needs separate maintenance; a clean restored startup -is not claimed. -After the restored-startup check, all 483 original profile files and both profile -metadata files were restored exactly. Test plugins and temporary backups were -removed. OBS scene inputs and its recording path were restored. - -Evidence is stored outside the repository at: -`/Users/benjmarston/Develop/valheim-validation-evidence/adrenaline-echo-aoe-20260928`. -The original `adrenaline-echo-20260928` folder contains the superseded travelling -projectile proof. Use the `aoe` folder for this behavior. -The accepted `adrenaline-echo-aoe-proof.mp4` is 6.883 seconds at 1080p/60 fps. -The explosion occurs near 3.2 seconds. `player-centered-explosion.png` shows the -impact. The video contact sheet and both equipment tooltips were inspected -manually because the automated review models were unavailable. - -### Repeat the live probe - -Build `tests/AdrenalineEchoProbe/Probe.csproj` in Release. Install its DLL only -in the leased test client's profile beside the candidate. The main project -excludes the probe source. Use a disposable character in a local proof world. -`seed` adds food, a staff, and a socketed trinket; `suite` changes that inventory. - -```text -cli_clear_inventory -puke -echo_probe seed -echo_probe suite -``` - -Wait for `ECHO_PROBE SUITE_DONE` in the BepInEx log. Require 12 `CASE` lines -with `PASS`, and inspect the projectile and impact lines. `echo_probe fill` -activates full adrenaline for manual proof. Remove the probe after testing. -`echo_probe area` creates two nearby Trolls and one distant Troll, then checks -the area damage and player health. `echo_probe cleanup` removes these targets. diff --git a/tests/AdrenalineEchoProbe/Probe.cs b/tests/AdrenalineEchoProbe/Probe.cs deleted file mode 100644 index 88ab8bf..0000000 --- a/tests/AdrenalineEchoProbe/Probe.cs +++ /dev/null @@ -1,258 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using BepInEx; -using EpicLoot; -using EpicLoot.ShardStones; -using HarmonyLib; -using UnityEngine; - -[BepInPlugin("local.adrenaline.echo.probe", "Adrenaline Echo Probe", "1.0.0")] -[BepInDependency("warpalicious.PraetorisClient")] -public class Probe : BaseUnityPlugin -{ - private static Probe instance; - private static ItemDrop.ItemData staff, trinket; - private static string label = "idle"; - private static readonly List shots = new List(); - private static int impacts; - private static bool centeredImpacts; - private static readonly List areaTargets = new List(); - private static readonly HashSet areaHits = new HashSet(); - private static readonly AccessTools.FieldRef Adrenaline = AccessTools.FieldRefAccess("m_adrenaline"); - private static readonly AccessTools.FieldRef ProjectileWeapon = AccessTools.FieldRefAccess("m_weapon"); - private void Awake() - { - instance = this; - new Harmony("local.adrenaline.echo.probe").PatchAll(); - new Terminal.ConsoleCommand("echo_probe", "Adrenaline Echo live validation", args => - { - try - { - string command = args.Length > 1 ? args[1] : "status"; - if (Player.m_localPlayer == null || ZNet.instance == null || !ZNet.instance.IsServer()) - { - args.Context.AddString("ECHO_PROBE requires a loaded local proof world."); - return; - } - if (command == "seed") Seed(); - if (command == "suite") - { - if (staff == null || trinket == null) throw new InvalidOperationException("Run seed successfully before suite."); - StartCoroutine(Suite()); - } - if (command == "fill") { label = "manual"; Player.m_localPlayer.AddAdrenaline(10000f); } - if (command == "area") StartCoroutine(Area()); - if (command == "cleanup") CleanupTargets(); - if (command == "plain") Enchant(false); - if (command == "enchanted") Enchant(true); - if (command == "status") Report(); - args.Context.AddString("ECHO_PROBE " + command + " OK"); - } - catch (Exception e) { Logger.LogError(e); args.Context.AddString("ECHO_PROBE FAIL " + e.Message); } - }); - } - private static void Print(string value) => instance.Logger.LogInfo("ECHO_PROBE " + value); - private static ItemDrop.ItemData Give(string prefab) - { - ItemDrop.ItemData item = ObjectDB.instance.GetItemPrefab(prefab).GetComponent().m_itemData.Clone(); - item.m_dropPrefab = ObjectDB.instance.GetItemPrefab(prefab); - item.m_durability = item.GetMaxDurability(); - Player.m_localPlayer.GetInventory().AddItem(item); - return item; - } - private static void Seed() - { - Player p = Player.m_localPlayer; - foreach (string food in new[] { "Salad", "SeekerAspic", "YggdrasilPorridge" }) - { - ItemDrop.ItemData foodItem = Give(food); - p.ConsumeItem(p.GetInventory(), foodItem); - } - staff = Give("StaffFireball"); - trinket = Give("TrinketBronzeStamina"); - trinket.SaveMagicItem(new MagicItem { Rarity = ItemRarity.Epic, SocketCount = 1 }); - ItemDrop.ItemData shard = Give("1347551238_Epic_ShardStone"); - bool added = ShardSocketManager.AddShard(trinket, shard); - if (!added) throw new Exception("Cannot socket registered shard: " + trinket.GetTooltip()); - p.GetInventory().RemoveItem(shard); - p.EquipItem(trinket, false); - p.EquipItem(staff, false); - Print("socketed=" + added + " effect=" + trinket.GetMagicItem().Sockets[0].Effect.EffectType + - " category=" + Shards.GetCFG().Shards[(ShardType)0x50520006].Category + - " fullSE=" + trinket.m_shared.m_fullAdrenalineSE?.name); - foreach (ItemRarity rarity in new[] {ItemRarity.Epic,ItemRarity.Legendary,ItemRarity.Mythic,(ItemRarity)5}) - Print("prefab=" + rarity + " present=" + (ObjectDB.instance.GetItemPrefab("1347551238_" + rarity + "_ShardStone") != null)); - Enchant(false); - } - private static void Enchant(bool enabled) - { - MagicItem magic = new MagicItem { Rarity = ItemRarity.Legendary }; - if (enabled) - { - magic.Effects.Add(new MagicItemEffect("ModifyDamage", 100f)); - magic.Effects.Add(new MagicItemEffect("AddFrostDamage", 50f)); - magic.Effects.Add(new MagicItemEffect("ModifyProjectileSpeed", 100f)); - } - staff.SaveMagicItem(magic); - Player.m_localPlayer.UnequipItem(staff, false); - Player.m_localPlayer.EquipItem(staff, false); - Print("enchant=" + enabled + " damage=" + staff.GetDamage().GetTotalDamage()); - } - private static void Report() - { - Player p = Player.m_localPlayer; - Print("adrenaline=" + p.GetAdrenaline() + "/" + p.GetMaxAdrenaline() + " weapon=" + p.GetCurrentWeapon()?.m_dropPrefab?.name + " shots=" + shots.Count); - } - private IEnumerator Suite() - { - Player p = Player.m_localPlayer; - float plainDamage = 0f; - Dictionary doubleConfig = MagicItemEffectDefinitions.AllDefinitions["DoubleMagicShot"].Config; - bool hadChance = doubleConfig.TryGetValue("Chance", out float originalChance); - foreach (string test in new[] {"partial", "drain", "plain", "refresh", "enchanted", "staggered", "no-shard", "melee", "unarmed", "no-ammo", "bow", "double-magic"}) - { - label = test; - shots.Clear(); - impacts = 0; - centeredImpacts = true; - Adrenaline(p) = 0f; - if (test == "enchanted") Enchant(true); - if (test == "no-shard") - { - trinket.SaveMagicItem(new MagicItem { Rarity = ItemRarity.Epic, SocketCount = 1 }); - p.UnequipItem(trinket, false); p.EquipItem(trinket, false); - } - if (test == "melee") - { - ItemDrop.ItemData shard = Give("1347551238_Epic_ShardStone"); - if (!ShardSocketManager.AddShard(trinket, shard)) throw new Exception("Restore socket failed"); - p.GetInventory().RemoveItem(shard); - p.UnequipItem(trinket, false); p.EquipItem(trinket, false); - p.EquipItem(Give("SwordBronze"), false); - } - if (test == "unarmed") p.UnequipItem(p.GetCurrentWeapon(), false); - if (test == "no-ammo") p.EquipItem(Give("Bow"), false); - if (test == "bow") Give("ArrowWood"); - if (test == "double-magic") - { - staff.SaveMagicItem(new MagicItem { Rarity = ItemRarity.Legendary, Effects = new List { new MagicItemEffect("DoubleMagicShot", 100f) } }); - p.EquipItem(staff, false); - doubleConfig["Chance"] = 1f; - } - p.AddEitr(1000f); - p.AddStamina(1000f); - int ammoBefore = p.GetInventory().GetAllItems().Where(item => item.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Ammo).Sum(item => item.m_stack); - ItemDrop.ItemData equipped = p.GetCurrentWeapon(); - float durability = equipped.m_durability, stamina = p.GetStamina(), eitr = p.GetEitr(), health = p.GetHealth(); - UnityEngine.Random.InitState(491); - if (test == "staggered") - { - p.Stagger(Vector3.back); - yield return new WaitForSeconds(0.2f); - Print("STAGGER active=" + p.IsStaggering()); - } - if (test == "partial") p.AddAdrenaline(0.01f); - else if (test == "drain") {Adrenaline(p) = 10f; p.AddAdrenaline(-10f);} - else p.AddAdrenaline(10000f); - yield return new WaitForSeconds(0.2f); - int expected = test == "double-magic" ? 2 : test == "plain" || test == "refresh" || test == "enchanted" || test == "staggered" || test == "bow" ? 1 : 0; - bool passed = shots.Count == expected && impacts == expected && centeredImpacts && - shots.All(shot => shot.sameWeapon && shot.speed < 0.001f && shot.distance < 0.01f) && - Mathf.Approximately(equipped.m_durability, durability); - int ammoAfter = p.GetInventory().GetAllItems().Where(item => item.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Ammo).Sum(item => item.m_stack); - // Food decay lowers the resource caps even when no shot is fired. Check spending - // against the remaining cap rather than treating that independent change as a cost. - passed &= ammoBefore == ammoAfter && p.GetEitr() >= Mathf.Min(eitr, p.GetMaxEitr()) - 0.01f && - p.GetStamina() >= Mathf.Min(stamina, p.GetMaxStamina()) - 0.01f && - p.GetHealth() >= Mathf.Min(health, p.GetMaxHealth()) - 0.01f; - if (test == "plain" && shots.Count == 1) plainDamage = shots[0].damage; - if (test == "enchanted") passed &= shots.Count == 1 && shots[0].frost > 0f && shots[0].damage > plainDamage; - Print("CASE=" + test + " " + (passed ? "PASS" : "FAIL") + " shots=" + shots.Count + " expected=" + expected + - " impacts=" + impacts + " centered=" + centeredImpacts + " durabilityDelta=" + (equipped.m_durability-durability) + " healthDelta=" + (p.GetHealth()-health) + - " staminaDelta=" + (p.GetStamina()-stamina) + " eitrDelta=" + (p.GetEitr()-eitr) + " ammoDelta=" + (ammoAfter-ammoBefore)); - yield return new WaitForSeconds(2f); - } - if (hadChance) doubleConfig["Chance"] = originalChance; - else doubleConfig.Remove("Chance"); - Print("SUITE_DONE"); - } - private static void CleanupTargets() - { - foreach (Character target in areaTargets) - if (target != null) ZNetScene.instance.Destroy(target.gameObject); - areaTargets.Clear(); - } - private IEnumerator Area() - { - CleanupTargets(); - Enchant(true); - Player p = Player.m_localPlayer; - foreach (Vector3 offset in new[] {p.transform.right * 3f, -p.transform.right * 3f, p.transform.forward * 15f}) - { - GameObject go = Instantiate(ZNetScene.instance.GetPrefab("Troll"), p.transform.position + offset, Quaternion.identity); - Character target = go.GetComponent(); - target.SetMaxHealth(10000f); - target.SetHealth(10000f); - go.GetComponent().enabled = false; - areaTargets.Add(target); - } - yield return new WaitForSeconds(1f); - areaHits.Clear(); - label = "area"; - float health = p.GetHealth(); - p.AddAdrenaline(10000f); - yield return new WaitForSeconds(0.3f); - bool passed = areaHits.Contains(areaTargets[0]) && areaHits.Contains(areaTargets[1]) && - !areaHits.Contains(areaTargets[2]) && Mathf.Approximately(health, p.GetHealth()); - Print("AREA " + (passed ? "PASS" : "FAIL") + " left=" + areaHits.Contains(areaTargets[0]) + - " right=" + areaHits.Contains(areaTargets[1]) + " distant=" + areaHits.Contains(areaTargets[2]) + " selfHealthDelta=" + (p.GetHealth()-health)); - } - private class Shot { public float damage, frost, speed, distance; public bool sameWeapon; } - [HarmonyPatch(typeof(Projectile), nameof(Projectile.Setup))] - private class Capture - { - private static void Postfix(Projectile __instance, Character owner) - { - if (owner != Player.m_localPlayer) return; - ItemDrop.ItemData weapon = ProjectileWeapon(__instance); - float speed = __instance.GetVelocity().magnitude; - float distance = Vector3.Distance(__instance.transform.position, owner.transform.position); - shots.Add(new Shot { - damage = __instance.m_damage.GetTotalDamage(), frost = __instance.m_damage.m_frost, - speed = speed, - distance = distance, - sameWeapon = ReferenceEquals(weapon, Player.m_localPlayer.GetCurrentWeapon()) - }); - Print("SHOT case=" + label + " prefab=" + __instance.name + " sameWeapon=" + ReferenceEquals(weapon,Player.m_localPlayer.GetCurrentWeapon()) + - " damage=" + __instance.m_damage.GetTotalDamage() + " fire=" + __instance.m_damage.m_fire + " frost=" + __instance.m_damage.m_frost + - " speed=" + speed + " playerDistance=" + distance + " staggering=" + Player.m_localPlayer.IsStaggering() + - " effects=" + string.Join(",", weapon?.GetMagicItem()?.Effects.Select(e => e.EffectType) ?? Enumerable.Empty())); - } - } - [HarmonyPatch(typeof(Projectile), nameof(Projectile.OnHit))] - private class Impact - { - private static void Prefix(Projectile __instance, Vector3 hitPoint) - { - if (ProjectileWeapon(__instance) != Player.m_localPlayer?.GetCurrentWeapon()) return; - float distance = Vector3.Distance(hitPoint, Player.m_localPlayer.transform.position); - impacts++; - centeredImpacts &= distance < 0.01f; - Print("IMPACT case=" + label + " playerDistance=" + distance + " radius=" + __instance.m_aoe); - } - } - [HarmonyPatch(typeof(Character), nameof(Character.Damage))] - private class Hit - { - private static void Prefix(Character __instance, HitData hit) - { - if (hit.GetAttacker() == Player.m_localPlayer && !__instance.IsPlayer()) - { - if (label == "area") areaHits.Add(__instance); - Print("HIT case=" + label + " target=" + __instance.name + " fire=" + hit.m_damage.m_fire + " frost=" + hit.m_damage.m_frost); - } - } - } -} diff --git a/tests/AdrenalineEchoProbe/Probe.csproj b/tests/AdrenalineEchoProbe/Probe.csproj deleted file mode 100644 index 6162a0e..0000000 --- a/tests/AdrenalineEchoProbe/Probe.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net481 - 10 - AdrenalineEchoProbe - - - - ../../Libs/EpicLoot-publicized.dll - false - - - From 8a0536312a06779e3c4fb6d4f77168e5da006b89 Mon Sep 17 00:00:00 2001 From: jneb802 <73610029+jneb802@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:53:52 -0700 Subject: [PATCH 4/6] Restrict Adrenaline Echo to weapon area-impact projectiles --- Source/EpicLoot/AdrenalineEchoRuntime.cs | 38 +++++------------------- Source/EpicLoot/PraetorisMagicEffects.cs | 4 +-- 2 files changed, 10 insertions(+), 32 deletions(-) diff --git a/Source/EpicLoot/AdrenalineEchoRuntime.cs b/Source/EpicLoot/AdrenalineEchoRuntime.cs index 4280da2..b074fe2 100644 --- a/Source/EpicLoot/AdrenalineEchoRuntime.cs +++ b/Source/EpicLoot/AdrenalineEchoRuntime.cs @@ -17,9 +17,6 @@ internal static class AdrenalineEchoRuntime private static readonly List EchoProjectiles = new List(); private static readonly FieldInfo PendingShot = AccessTools.Field(typeof(MultiShot), "_pendingShot"); private static readonly AccessTools.FieldRef Trinket = AccessTools.FieldRefAccess("m_trinketItem"); - private static readonly AccessTools.FieldRef AttackAmmo = AccessTools.FieldRefAccess("m_ammoItem"); - private static readonly Func FindAmmo = - AccessTools.MethodDelegate>(AccessTools.Method(typeof(Attack), "FindAmmo")); private static readonly Action FireProjectileBurst = AccessTools.MethodDelegate>(AccessTools.Method(typeof(Attack), "FireProjectileBurst")); @@ -34,7 +31,11 @@ private static void OnTrinketActivated(Player player) } ItemDrop.ItemData weapon = player.GetCurrentWeapon(); - if (weapon?.m_shared.m_attack?.m_attackType != Attack.AttackType.Projectile) + if (weapon?.m_shared.m_attack?.m_attackType != Attack.AttackType.Projectile || + weapon.m_shared.m_itemType == ItemDrop.ItemData.ItemType.Bow || + weapon.m_shared.m_skillType == Skills.SkillType.Bows || + weapon.m_shared.m_skillType == Skills.SkillType.Crossbows || + !string.IsNullOrEmpty(weapon.m_shared.m_ammoType)) { return; } @@ -53,25 +54,14 @@ private static IEnumerator FireAfterAttack(Player player, ItemDrop.ItemData weap yield break; } - ItemDrop.ItemData ammo = FindAmmo(player, weapon); - if (!string.IsNullOrEmpty(weapon.m_shared.m_ammoType) && - (ammo == null || (ammo.m_shared.m_itemType != ItemDrop.ItemData.ItemType.Ammo && - ammo.m_shared.m_itemType != ItemDrop.ItemData.ItemType.AmmoNonEquipable))) - { - yield break; - } - GameObject? projectile = ammo?.m_shared.m_attack.m_attackProjectile; - if (projectile == null) - { - projectile = weapon.m_shared.m_attack.m_attackProjectile; - } - if (projectile == null || projectile.GetComponent() == null) + // Use only the weapon's own area-impact projectile, never arrows or bolts. + Projectile? projectile = weapon.m_shared.m_attack.m_attackProjectile?.GetComponent(); + if (projectile == null || projectile.m_aoe <= 0f) { yield break; } Attack echo = weapon.m_shared.m_attack.Clone(); - AttackAmmo(echo) = ammo; echo.m_projectiles = 1; echo.m_projectileBursts = 1; echo.m_perBurstResourceUsage = false; @@ -171,18 +161,6 @@ private static IEnumerable Transpiler(IEnumerable Date: Mon, 28 Sep 2026 21:55:57 -0700 Subject: [PATCH 5/6] Remove bow exclusion text from Adrenaline Echo description --- Source/EpicLoot/PraetorisMagicEffects.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/EpicLoot/PraetorisMagicEffects.cs b/Source/EpicLoot/PraetorisMagicEffects.cs index 97b1022..6cfae99 100644 --- a/Source/EpicLoot/PraetorisMagicEffects.cs +++ b/Source/EpicLoot/PraetorisMagicEffects.cs @@ -37,7 +37,7 @@ internal static class PraetorisMagicEffects ""CanBeDisenchanted"": false, ""CanBeRunified"": false, ""DisplayText"": ""Adrenaline Echo: Full adrenaline triggers your weapon's projectile area impact at your position."", - ""Description"": ""When this trinket activates its full-adrenaline effect, trigger your equipped weapon's projectile area impact at your position, without travel. Uses the projectile's area damage and your weapon's enchantments. Requires a weapon whose own projectile has area damage, such as Staff of Embers. Bows, crossbows, and ammunition weapons are excluded. Costs no durability, stamina, health, or eitr."", + ""Description"": ""When this trinket activates its full-adrenaline effect, trigger your equipped weapon's projectile area impact at your position, without travel. Uses the projectile's area damage and your weapon's enchantments. Requires a weapon whose own projectile has area damage, such as Staff of Embers. Costs no durability, stamina, health, or eitr."", ""Requirements"": { ""NoRoll"": true, ""AllowedItemTypes"": [ ""Trinket"" ] } }", @"{ From a333f0e6a441376e850328b9cabdfac9f850595f Mon Sep 17 00:00:00 2001 From: jneb802 <73610029+jneb802@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:05:24 -0700 Subject: [PATCH 6/6] Prepare PraetorisClient 0.1.84 release --- CHANGELOG.md | 6 ++++++ Properties/AssemblyInfo.cs | 4 ++-- Source/Core/Plugin.cs | 2 +- manifest.json | 2 +- thunderstore.toml | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8ae7b4..4274cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.1.84 + +- Add Adrenaline Echo, a Unique trinket shardstone available at Epic, Legendary, Mythic, and Ancient tiers. +- When the trinket activates its full-adrenaline effect, trigger the equipped weapon's projectile area impact at the player's position. Staff of Embers creates an explosion around the player with its Epic Loot damage effects. +- Require a weapon whose own projectile has area damage. The effect costs no durability, stamina, health, or eitr. + ## 0.1.82 - Save the builder's character name on newly built ships and carts so their owner labels work when the builder is offline or far away, including after a server restart. diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index f58648f..a3b5f8b 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -31,5 +31,5 @@ // You can specify all the values or you can default the Build and Revision Numbers // by using the '*' as shown below: // [assembly: AssemblyVersion("1.0.*")] -[assembly: AssemblyVersion("0.1.83")] -[assembly: AssemblyFileVersion("0.1.83")] +[assembly: AssemblyVersion("0.1.84")] +[assembly: AssemblyFileVersion("0.1.84")] diff --git a/Source/Core/Plugin.cs b/Source/Core/Plugin.cs index 36739b8..c3b3274 100644 --- a/Source/Core/Plugin.cs +++ b/Source/Core/Plugin.cs @@ -29,7 +29,7 @@ namespace PraetorisClient public class PraetorisClientPlugin : BaseUnityPlugin { private const string ModName = "PraetorisClient"; - private const string ModVersion = "0.1.83"; + private const string ModVersion = "0.1.84"; private const string Author = "warpalicious"; private const string ModGUID = Author + "." + ModName; private const string EpicLootGuid = "randyknapp.mods.epicloot"; diff --git a/manifest.json b/manifest.json index 7a4e575..556b06a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "PraetorisClient", "description": "Client-side Praetoris tools for Discord linking, creative server bridges, RPC tracing, socket metrics, and RPC probes.", - "version_number": "0.1.83", + "version_number": "0.1.84", "website_url": "https://discord.gg/3aaru2VyHJ", "dependencies": [ "denikson-BepInExPack_Valheim-5.4.2333", diff --git a/thunderstore.toml b/thunderstore.toml index 4846424..6efbc67 100644 --- a/thunderstore.toml +++ b/thunderstore.toml @@ -4,7 +4,7 @@ schemaVersion = "0.0.1" [package] namespace = "praetoris" name = "PraetorisClient" -versionNumber = "0.1.83" +versionNumber = "0.1.84" description = "Client-side Praetoris tools for Discord linking, creative server bridges, RPC tracing, socket metrics, and RPC probes." websiteUrl = "https://discord.gg/3aaru2VyHJ" containsNsfwContent = false