From 8199d2412cdf64d83feaa62bc65105f225276338 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 18 Oct 2026 04:40:34 +0200 Subject: [PATCH 1/3] Don't equip unresolved spells or shouts on spawned actors Actor::SetMagicEquipment equipped whatever TESForm::GetById returned for the owner's hand spells and shout. When the owner has magic from a mod this client doesn't have, GetGameId returns 0 and a null form gets equipped. Combat AI later walks the actor's spells (Actor::VisitSpells) and reads the form type through that null pointer, crashing a BSJobs thread with an access violation at SkyrimSE+0x811CF7 (ID 44882) when the remote character fights nearby. Seen with a Khajiit player using Moonlight Tales, which the other client didn't have: crashes began once he reverted from werewolf form and happened whenever he loaded near the Northwatch guards. Skip and warn about hand spells that aren't MagicItems and voice-slot forms that are neither shouts nor powers, as the equip-change path in InventoryService already does for unknown items. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Mdy6FmrJTc1m7KuVM1n3H --- Code/client/Games/Skyrim/Actor.cpp | 51 ++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 9e1933e86..4c294be45 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -44,6 +44,8 @@ #include #include #include +#include +#include #include #include @@ -701,25 +703,48 @@ void Actor::SetMagicEquipment(const MagicEquipment& acEquipment) noexcept auto* pEquipManager = EquipManager::Get(); auto& modSystem = World::Get().GetModSystem(); - if (acEquipment.LeftHandSpell) + // The owner may have magic from a mod this client doesn't have (e.g. a power a werewolf mod + // equips after transforming back). Such ids resolve to 0, and equipping a null form leaves the + // actor with an empty hand/voice entry that combat AI dereferences later (crash in + // Actor::VisitSpells while building the combat inventory). Skip anything that doesn't resolve. + const auto equipHandSpell = [&](const GameId& acSpellId, uint32_t aSlot, const char* apHand) { - uint32_t mainHandWeaponId = modSystem.GetGameId(acEquipment.LeftHandSpell); - spdlog::debug("Setting left hand spell: {:X}", mainHandWeaponId); - pEquipManager->EquipSpell(this, TESForm::GetById(mainHandWeaponId), 0); - } + const uint32_t cSpellId = modSystem.GetGameId(acSpellId); + auto* pSpell = Cast(TESForm::GetById(cSpellId)); + if (!pSpell) + { + spdlog::warn( + "Actor {:X}: skipping {} hand spell {:X}:{:X}, not a spell on this client (missing mod?)", formID, apHand, acSpellId.ModId, acSpellId.BaseId); + return; + } + + spdlog::debug("Setting {} hand spell: {:X}", apHand, cSpellId); + pEquipManager->EquipSpell(this, pSpell, aSlot); + }; + + if (acEquipment.LeftHandSpell) + equipHandSpell(acEquipment.LeftHandSpell, 0, "left"); if (acEquipment.RightHandSpell) - { - uint32_t secondaryHandWeaponId = modSystem.GetGameId(acEquipment.RightHandSpell); - spdlog::debug("Setting right hand spell: {:X}", secondaryHandWeaponId); - pEquipManager->EquipSpell(this, TESForm::GetById(secondaryHandWeaponId), 1); - } + equipHandSpell(acEquipment.RightHandSpell, 1, "right"); if (acEquipment.Shout) { - uint32_t shoutId = modSystem.GetGameId(acEquipment.Shout); - spdlog::debug("Setting shout: {:X}", shoutId); - pEquipManager->EquipShout(this, TESForm::GetById(shoutId)); + const uint32_t cShoutId = modSystem.GetGameId(acEquipment.Shout); + TESForm* pShout = TESForm::GetById(cShoutId); + + // The voice slot holds either a shout or a power (a spell) + if (!Cast(pShout) && !Cast(pShout)) + { + spdlog::warn( + "Actor {:X}: skipping shout/power {:X}:{:X}, not a shout or power on this client (missing mod?)", formID, acEquipment.Shout.ModId, + acEquipment.Shout.BaseId); + } + else + { + spdlog::debug("Setting shout: {:X}", cShoutId); + pEquipManager->EquipShout(this, pShout); + } } } From ad60f09e3c492747e44b7c53e719a122e8319767 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 18 Oct 2026 04:44:58 +0200 Subject: [PATCH 2/3] Don't attach unresolved enchantments to synced items When another player's item carries an enchantment from a mod this client doesn't have, GetExtraDataFromItem looked it up, got null, and TP_ASSERT only reported it before SetEnchantmentData attached the null enchantment to the item. Player-created enchantments had the same problem one level down: EnchantmentItem::Create logged missing effect settings but still built the enchantment with null effects. Either way the game crashes later when the item is equipped, displayed or its effect is applied. EnchantmentItem::Create now keeps only the effects that exist on this client and returns null when none do. GetExtraDataFromItem skips a null enchantment with a warning, so the item still syncs, just unenchanted on clients missing the mod. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Mdy6FmrJTc1m7KuVM1n3H --- .../Games/Skyrim/Forms/EnchantmentItem.cpp | 32 ++++++++++++++----- Code/client/Games/Skyrim/TESObjectREFR.cpp | 11 +++++-- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/Code/client/Games/Skyrim/Forms/EnchantmentItem.cpp b/Code/client/Games/Skyrim/Forms/EnchantmentItem.cpp index cc586d708..3e5fd3f20 100644 --- a/Code/client/Games/Skyrim/Forms/EnchantmentItem.cpp +++ b/Code/client/Games/Skyrim/Forms/EnchantmentItem.cpp @@ -14,26 +14,42 @@ EnchantmentItem* EnchantmentItem::Create(const Inventory::EnchantmentData& aData ModSystem& modSystem = World::Get().GetModSystem(); - GameArray effects{}; - effects.Resize(aData.Effects.size()); - for (int i = 0; i < aData.Effects.size(); i++) + // Effects from a mod this client doesn't have resolve to null. Keep only the ones that exist here; a created + // enchantment with a null effect crashes the game as soon as it is displayed, equipped or applied. + Vector resolvedEffects; + resolvedEffects.reserve(aData.Effects.size()); + for (const Inventory::EffectItem& effect : aData.Effects) { - Inventory::EffectItem effect = aData.Effects[i]; + auto* pEffectSetting = Cast(TESForm::GetById(modSystem.GetGameId(effect.EffectId))); + if (!pEffectSetting) + { + spdlog::warn("Dropping enchantment effect {:X}:{:X}, not found on this client (missing mod?)", effect.EffectId.ModId, effect.EffectId.BaseId); + continue; + } EffectItem effectItem{}; effectItem.data.fMagnitude = effect.Magnitude; effectItem.data.iArea = effect.Area; effectItem.data.iDuration = effect.Duration; effectItem.fRawCost = effect.RawCost; - effectItem.pEffectSetting = Cast(TESForm::GetById(modSystem.GetGameId(effect.EffectId))); - if (!effectItem.pEffectSetting) - spdlog::error("Effect setting not found: {:X}:{:X}", effect.EffectId.ModId, effect.EffectId.BaseId); + effectItem.pEffectSetting = pEffectSetting; // TODO: TESCondition? - effects[i] = effectItem; + resolvedEffects.push_back(effectItem); + } + + if (resolvedEffects.empty()) + { + spdlog::warn("Not creating enchantment, none of its {} effects exist on this client", aData.Effects.size()); + return nullptr; } + GameArray effects{}; + effects.Resize(resolvedEffects.size()); + for (size_t i = 0; i < resolvedEffects.size(); i++) + effects[i] = resolvedEffects[i]; + BGSCreatedObjectManager* objManager = BGSCreatedObjectManager::Get(); EnchantmentItem* pItem = nullptr; diff --git a/Code/client/Games/Skyrim/TESObjectREFR.cpp b/Code/client/Games/Skyrim/TESObjectREFR.cpp index e58526960..5d51b734b 100644 --- a/Code/client/Games/Skyrim/TESObjectREFR.cpp +++ b/Code/client/Games/Skyrim/TESObjectREFR.cpp @@ -622,9 +622,14 @@ ExtraDataList* TESObjectREFR::GetExtraDataFromItem(const Inventory::Entry& arEnt pEnchantment = Cast(TESForm::GetById(enchantId)); } - TP_ASSERT(pEnchantment, "No Enchantment created or found."); - - pExtraDataList->SetEnchantmentData(pEnchantment, arEntry.ExtraEnchantCharge, arEntry.ExtraEnchantRemoveUnequip); + // Null when the enchantment (or all of a created one's effects) comes from a mod this client doesn't have. + // Attaching a null enchantment crashes later when the item is equipped or shown, so sync the item without it. + if (pEnchantment) + pExtraDataList->SetEnchantmentData(pEnchantment, arEntry.ExtraEnchantCharge, arEntry.ExtraEnchantRemoveUnequip); + else + spdlog::warn( + "Item {:X}:{:X}: skipping enchantment {:X}:{:X}, not available on this client (missing mod?)", arEntry.BaseId.ModId, arEntry.BaseId.BaseId, + arEntry.ExtraEnchantId.ModId, arEntry.ExtraEnchantId.BaseId); } if (arEntry.ExtraPoisonId != 0) From 2fcf2c4d1f755ee07f2164ec7ed80af3f2e15276 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 19 Oct 2026 22:03:32 +0200 Subject: [PATCH 3/3] Fix crash when a launched projectile can't be found HookLaunch asserted that the engine's launch returned a resolvable projectile and then read its power anyway. When the handle didn't resolve, the game crashed on a null pointer, or froze on the assert's breakpoint with a debugger attached. Such launches are now left unsynced with a warning. Co-Authored-By: Claude Opus 5.5 --- .../client/Games/Skyrim/Projectiles/Projectile.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Code/client/Games/Skyrim/Projectiles/Projectile.cpp b/Code/client/Games/Skyrim/Projectiles/Projectile.cpp index 201b640f4..dec4f4357 100644 --- a/Code/client/Games/Skyrim/Projectiles/Projectile.cpp +++ b/Code/client/Games/Skyrim/Projectiles/Projectile.cpp @@ -97,12 +97,20 @@ BSPointerHandle* TP_MAKE_THISCALL(HookLaunch, BSPointerHandlehandle.iBits); Projectile* pProjectile = Cast(pObject); - - TP_ASSERT(pProjectile, "No projectile found."); + if (!pProjectile) + { + spdlog::warn("No projectile found for handle {:X}, not syncing the launch", result->handle.iBits); + return result; + } Event.Power = pProjectile->fPower;