From 38cec9f9a5d52e4e65e94a40a81d3e07f0b7a2f5 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 18 Oct 2026 04:40:34 +0200 Subject: [PATCH 1/4] 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 00a99684a..0d05d9bfc 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 a1786179c41780e22559edd619ed40f23817949b Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 18 Oct 2026 04:44:58 +0200 Subject: [PATCH 2/4] 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 22f2f224aca39519b5eb72ecf418386564dde489 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 19 Oct 2026 22:03:32 +0200 Subject: [PATCH 3/4] 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; From 33737a03efc575e58c4096f9f52a0e6275206bc8 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 20 Oct 2026 00:56:53 +0200 Subject: [PATCH 4/4] Drop unresolved spells and shouts from spawned NPCs' spell lists A remote character whose owner has spells or shouts from a mod this client doesn't have crashed nearby combat AI at SkyrimSE+0x811CF7 (ID 44882) with a null form. Seen again after a Khajiit werewolf with Moonlight Tales reverted form and healed, even with the SetMagicEquipment fix in place. TESNPC::Deserialize loads the owner's NPC change form through the game's own loader. TESSpellList::LoadGame stores dynamic_cast(LookupFormByID(id)) for every saved entry without a null check, and our ReadFormId hook resolves forms from missing mods to 0, so those entries become null. When the combat inventory is built, the base NPC's shouts are added without a null check and the null shout is dereferenced. Compact null entries out of all three lists after deserializing, and log how many were dropped. Verified in Ghidra (SkyrimSE 1.6.1170): - 44882 (0x140811cc0) switches on the item's form type at +0x1A; the crashing call at 0x14080f25e in 44857 loops over (*(npc + 0xA8))->shouts (+0x10, count +0x20) with no null check. Actor::VisitSpells (38781), called just before it, does skip null NPC spells. - 24388 is TESActorBase::LoadGame and calls 14942 (TESSpellList::LoadGame) on npc + 0xA0, so lists is at +0xA8 as in TESSpellList.h. - 14942 only runs with change flag 0x10. For spells, leveled spells and shouts it ignores ReadFormId's (36000) return value and stores the cast lookup result. LookupFormByID (14617) returns null for an id it doesn't know. Returning false from the hook would therefore not help. Assumed, not checked in the binary: - The 1.7.104 binary has the same loader and combat loop. - Shrinking the counts in place is safe because the lists keep their arrays, and the next LoadGame frees and reallocates whenever the count differs. - The Moonlight Tales entry in this crash was a shout (a howl). The crashing loop reads only shouts, but the fix also covers spells and leveled spells. Co-Authored-By: Claude Opus 5.5 --- Code/client/Games/Forms.cpp | 6 ++++ .../Games/Skyrim/Components/TESSpellList.cpp | 29 +++++++++++++++++++ .../Games/Skyrim/Components/TESSpellList.h | 2 ++ 3 files changed, 37 insertions(+) diff --git a/Code/client/Games/Forms.cpp b/Code/client/Games/Forms.cpp index bc09df6a2..6995d0dad 100644 --- a/Code/client/Games/Forms.cpp +++ b/Code/client/Games/Forms.cpp @@ -48,6 +48,12 @@ void TESNPC::Deserialize(const String& acBuffer, uint32_t aChangeFlags) noexcept Load(&loadBuffer); loadBuffer.buffer = nullptr; + + // The spell list loader (TESSpellList::LoadGame) stores whatever the form lookup returns, so spells or + // shouts from a mod this client doesn't have (resolved to 0 by our ReadFormId hook) end up as null + // entries. Combat AI adds the base NPC's shouts to its inventory without a null check and crashes. + if (const uint32_t cRemoved = spellList.RemoveNullEntries()) + spdlog::warn("NPC {:X}: dropped {} spell/shout entries not found on this client (missing mod?)", formID, cRemoved); } void TESNPC::Initialize() noexcept diff --git a/Code/client/Games/Skyrim/Components/TESSpellList.cpp b/Code/client/Games/Skyrim/Components/TESSpellList.cpp index f4457e417..f76aff24c 100644 --- a/Code/client/Games/Skyrim/Components/TESSpellList.cpp +++ b/Code/client/Games/Skyrim/Components/TESSpellList.cpp @@ -10,3 +10,32 @@ void TESSpellList::Initialize() lists = Memory::New(); } + +namespace +{ +template uint32_t CompactNonNull(T** apEntries, uint32_t& aCount) noexcept +{ + if (!apEntries) + return 0; + + uint32_t kept = 0; + for (uint32_t i = 0; i < aCount; ++i) + { + if (apEntries[i]) + apEntries[kept++] = apEntries[i]; + } + + const uint32_t cRemoved = aCount - kept; + aCount = kept; + return cRemoved; +} +} // namespace + +uint32_t TESSpellList::RemoveNullEntries() noexcept +{ + if (!lists) + return 0; + + return CompactNonNull(lists->spells, lists->spellCount) + CompactNonNull(lists->unk4, lists->unk4Count) + + CompactNonNull(lists->shouts, lists->shoultCount); +} diff --git a/Code/client/Games/Skyrim/Components/TESSpellList.h b/Code/client/Games/Skyrim/Components/TESSpellList.h index f32f2d382..458a70df9 100644 --- a/Code/client/Games/Skyrim/Components/TESSpellList.h +++ b/Code/client/Games/Skyrim/Components/TESSpellList.h @@ -21,6 +21,8 @@ struct TESSpellList : BaseFormComponent Lists* lists; void Initialize(); + // Drops null spells/shouts left by a change-form load, returns how many were dropped + uint32_t RemoveNullEntries() noexcept; }; static_assert(sizeof(TESSpellList::Lists) == 0x28);