From ff2d9b5703fd09a326a5d984e8d3fbd21201b6a7 Mon Sep 17 00:00:00 2001 From: hxef <111711970+hxef@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:41:37 +0300 Subject: [PATCH 1/4] Add ATTACK_STATE_ENUM and Actor::GetCurrentAmmoCount EquippedWeaponData stores an attack state, which says where an actor is in a swing or a shot. The enum was only forward declared, so the type had a name but no values, and the field could be held without ever being read. The values are not inferred from how the code behaves. The executable carries the whole list as a string at 0x1424BF3D0, reading "0 = None, 1 = Draw, 2 = Swing, 3 = Hit, 4 = Next Attack, 5 = Follow Through, 6 = Bash". GetCurrentAmmoCount reads how many rounds are loaded in the weapon an actor holds in a given equip slot, the counterpart of SetCurrentAmmoCount. ID 2229950. --- include/RE/A/ATTACK_STATE_ENUM.h | 15 +++++++++++++++ include/RE/A/Actor.h | 7 +++++++ include/RE/E/EquippedWeaponData.h | 2 +- include/RE/Fallout.h | 1 + include/RE/IDs.h | 1 + 5 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 include/RE/A/ATTACK_STATE_ENUM.h diff --git a/include/RE/A/ATTACK_STATE_ENUM.h b/include/RE/A/ATTACK_STATE_ENUM.h new file mode 100644 index 000000000..9e0ee3f33 --- /dev/null +++ b/include/RE/A/ATTACK_STATE_ENUM.h @@ -0,0 +1,15 @@ +#pragma once + +namespace RE +{ + enum class ATTACK_STATE_ENUM : std::int32_t + { + kNone = 0x0, + kDraw = 0x1, + kSwing = 0x2, + kHit = 0x3, + kNextAttack = 0x4, + kFollowThrough = 0x5, + kBash = 0x6 + }; +} diff --git a/include/RE/A/Actor.h b/include/RE/A/Actor.h index 661e59f26..cb22f411d 100644 --- a/include/RE/A/Actor.h +++ b/include/RE/A/Actor.h @@ -307,6 +307,13 @@ namespace RE return currentProcess ? currentProcess->GetCurrentAmmo(a_equipIndex) : nullptr; } + [[nodiscard]] std::uint32_t GetCurrentAmmoCount(BGSEquipIndex a_equipIndex) const + { + using func_t = decltype(&Actor::GetCurrentAmmoCount); + static REL::Relocation func{ ID::Actor::GetCurrentAmmoCount }; + return func(this, a_equipIndex); + } + std::uint32_t GetCurrentCollisionGroup() { using func_t = decltype(&Actor::GetCurrentCollisionGroup); diff --git a/include/RE/E/EquippedWeaponData.h b/include/RE/E/EquippedWeaponData.h index 8ae199369..e98d37a7e 100644 --- a/include/RE/E/EquippedWeaponData.h +++ b/include/RE/E/EquippedWeaponData.h @@ -1,5 +1,6 @@ #pragma once +#include "RE/A/ATTACK_STATE_ENUM.h" #include "RE/B/BGSObjectInstance.h" #include "RE/B/BSSoundHandle.h" #include "RE/B/BSTArray.h" @@ -9,7 +10,6 @@ namespace RE { - enum class ATTACK_STATE_ENUM; class AimModel; class BSCloneReserver; class MuzzleFlash; diff --git a/include/RE/Fallout.h b/include/RE/Fallout.h index 81fc7203b..3c2e2dc1a 100644 --- a/include/RE/Fallout.h +++ b/include/RE/Fallout.h @@ -23,6 +23,7 @@ #include "RE/A/AITimeStamp.h" #include "RE/A/AITimer.h" #include "RE/A/AMMO_DATA.h" +#include "RE/A/ATTACK_STATE_ENUM.h" #include "RE/A/ActionInput.h" #include "RE/A/ActionOutput.h" #include "RE/A/ActionPoints.h" diff --git a/include/RE/IDs.h b/include/RE/IDs.h index d04ca2165..bebf0939f 100644 --- a/include/RE/IDs.h +++ b/include/RE/IDs.h @@ -18,6 +18,7 @@ namespace RE::ID inline constexpr REL::ID GetClosestBone{ 2230051 }; inline constexpr REL::ID GetCollisionFilter{ 2277949 }; inline constexpr REL::ID GetCombatStyle{ 2231053 }; + inline constexpr REL::ID GetCurrentAmmoCount{ 2229950 }; inline constexpr REL::ID SetCurrentAmmoCount{ 2229952 }; inline constexpr REL::ID GetCurrentCollisionGroup{ 2229993 }; inline constexpr REL::ID GetCurrentFireLocation{ 2231167 }; From 3ba7efed05a77b814cef09a21673211d10eb3f1b Mon Sep 17 00:00:00 2001 From: hxef <111711970+hxef@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:13:52 +0300 Subject: [PATCH 2/4] Add ExtraLeveledItem, ExtraOutfitItem and FindRecipeForCreatedForm ExtraLeveledItem and ExtraOutfitItem record where a spawned item came from. The engine stamps them onto the item's extra data before it reaches any inventory, naming the leveled list the item was poured out of or the outfit it was issued with. Both hold a form ID rather than a pointer to the form, and size alone cannot show that. BSExtraData ends at 0x18. A pointer put there is eight bytes wide and fills 0x18 through 0x1F. A uint32 is four bytes wide and fills 0x18 through 0x1B, after which the compiler pads 0x1C through 0x1F to keep the record eight byte aligned. Either way the record measures 0x20, so sizeof settles nothing. The constructor settles it. It stores the value with a four byte write, and four bytes cannot hold a 64 bit address. BGSConstructibleObject::FindRecipeForCreatedForm answers the same question for an item built at a workbench rather than spawned. It finds the recipe that produces a form, matching either the form itself or a form list the form belongs to. --- include/RE/B/BGSConstructibleObject.h | 10 ++++++++++ include/RE/E/ExtraLeveledItem.h | 26 ++++++++++++++++++++++++++ include/RE/E/ExtraOutfitItem.h | 26 ++++++++++++++++++++++++++ include/RE/Fallout.h | 2 ++ include/RE/IDs.h | 1 + 5 files changed, 65 insertions(+) create mode 100644 include/RE/E/ExtraLeveledItem.h create mode 100644 include/RE/E/ExtraOutfitItem.h diff --git a/include/RE/B/BGSConstructibleObject.h b/include/RE/B/BGSConstructibleObject.h index 4fba545c8..0cb549efa 100644 --- a/include/RE/B/BGSConstructibleObject.h +++ b/include/RE/B/BGSConstructibleObject.h @@ -33,6 +33,16 @@ namespace RE [[nodiscard]] TESForm* GetCreatedItem() const noexcept { return createdItem; } [[nodiscard]] std::uint16_t GetWorkshopPriority() const noexcept { return data.workshopPriority; } + // The recipe that builds a form, or null when nothing builds it. Matches + // a recipe that creates the form itself, and one that creates a form list + // the form belongs to. + [[nodiscard]] static BGSConstructibleObject* FindRecipeForCreatedForm(const TESForm* a_form) + { + using func_t = decltype(&BGSConstructibleObject::FindRecipeForCreatedForm); + static REL::Relocation func{ ID::BGSConstructibleObject::FindRecipeForCreatedForm }; + return func(a_form); + } + bool PlayerPassesConditions() { using func_t = decltype(&BGSConstructibleObject::PlayerPassesConditions); diff --git a/include/RE/E/ExtraLeveledItem.h b/include/RE/E/ExtraLeveledItem.h new file mode 100644 index 000000000..3149e9193 --- /dev/null +++ b/include/RE/E/ExtraLeveledItem.h @@ -0,0 +1,26 @@ +#pragma once + +#include "RE/B/BSExtraData.h" + +namespace RE +{ + class __declspec(novtable) ExtraLeveledItem : + public BSExtraData // 00 + { + public: + static constexpr auto RTTI{ RTTI::ExtraLeveledItem }; + static constexpr auto VTABLE{ VTABLE::ExtraLeveledItem }; + static constexpr auto TYPE{ EXTRA_DATA_TYPE::kLevelItem }; + + ExtraLeveledItem(std::uint32_t a_levItem) : + BSExtraData(TYPE), + levItem(a_levItem) + { + REX::EMPLACE_VTABLE(this); + } + + // members + std::uint32_t levItem; // 18 + }; + static_assert(sizeof(ExtraLeveledItem) == 0x20); +} diff --git a/include/RE/E/ExtraOutfitItem.h b/include/RE/E/ExtraOutfitItem.h new file mode 100644 index 000000000..0367acd8d --- /dev/null +++ b/include/RE/E/ExtraOutfitItem.h @@ -0,0 +1,26 @@ +#pragma once + +#include "RE/B/BSExtraData.h" + +namespace RE +{ + class __declspec(novtable) ExtraOutfitItem : + public BSExtraData // 00 + { + public: + static constexpr auto RTTI{ RTTI::ExtraOutfitItem }; + static constexpr auto VTABLE{ VTABLE::ExtraOutfitItem }; + static constexpr auto TYPE{ EXTRA_DATA_TYPE::kOutfitItem }; + + ExtraOutfitItem(std::uint32_t a_outfit) : + BSExtraData(TYPE), + outfit(a_outfit) + { + REX::EMPLACE_VTABLE(this); + } + + // members + std::uint32_t outfit; // 18 + }; + static_assert(sizeof(ExtraOutfitItem) == 0x20); +} diff --git a/include/RE/Fallout.h b/include/RE/Fallout.h index 3c2e2dc1a..2e09d03b9 100644 --- a/include/RE/Fallout.h +++ b/include/RE/Fallout.h @@ -692,6 +692,7 @@ #include "RE/E/ExtraHealth.h" #include "RE/E/ExtraInstanceData.h" #include "RE/E/ExtraLeveledCreature.h" +#include "RE/E/ExtraLeveledItem.h" #include "RE/E/ExtraLight.h" #include "RE/E/ExtraLinkedRef.h" #include "RE/E/ExtraLinkedRefChildren.h" @@ -699,6 +700,7 @@ #include "RE/E/ExtraLock.h" #include "RE/E/ExtraMapMarker.h" #include "RE/E/ExtraMaterialSwap.h" +#include "RE/E/ExtraOutfitItem.h" #include "RE/E/ExtraPoison.h" #include "RE/E/ExtraPowerLinks.h" #include "RE/E/ExtraRadioData.h" diff --git a/include/RE/IDs.h b/include/RE/IDs.h index bebf0939f..e2220055c 100644 --- a/include/RE/IDs.h +++ b/include/RE/IDs.h @@ -171,6 +171,7 @@ namespace RE::ID namespace BGSConstructibleObject { + inline constexpr REL::ID FindRecipeForCreatedForm{ 2197324 }; inline constexpr REL::ID PlayerPassesConditions{ 2197318 }; } From 29e7ac38c044b0d28f0b8bc5f72fb01f28dd6ed4 Mon Sep 17 00:00:00 2001 From: hxef <111711970+hxef@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:26:09 +0300 Subject: [PATCH 3/4] Add ExtraDataList::IsItemBroken and ActorEquipManager::CanEquip IsItemBroken and IsDamaged read the same stored health and ask different questions of it. IsDamaged asks whether the health is anything below full. IsItemBroken asks whether it is zero or less, which is what the engine calls a broken item. Both answer false for a list carrying no health at all, and nothing in the base game writes an item's health, so every item in an unmodified game answers false to both. ID 2190223, placed beside IsDamaged. CanEquip decides whether an actor may put an item on, returning a CanEquipResult that names what stopped it when the answer is no. Equipping through the Pip-Boy, the favourites bar or a quick key asks this first and turns anything but kSuccess into the matching HUD message. kItemBroken is the one result an unmodified game never produces, for the reason above. The this pointer is unused in the body but the call is a member call, so it is declared as one. ID 2231405. --- include/RE/A/ActorEquipManager.h | 16 ++++++++++++++++ include/RE/E/ExtraDataList.h | 11 +++++++++++ include/RE/IDs.h | 2 ++ 3 files changed, 29 insertions(+) diff --git a/include/RE/A/ActorEquipManager.h b/include/RE/A/ActorEquipManager.h index 7d82f3431..7d211c520 100644 --- a/include/RE/A/ActorEquipManager.h +++ b/include/RE/A/ActorEquipManager.h @@ -37,6 +37,22 @@ namespace RE return *singleton; } + // Whether the actor is allowed to equip the item the handle names, and + // what stops it when it is not. Equipping through the Pip-Boy, the + // favourites bar or a quick key asks this first and turns anything but + // kSuccess into the matching HUD message. + // + // kItemBroken is the one answer an unmodified game never gives. It comes + // from ExtraDataList::IsItemBroken, so it needs an item whose health has + // been written down as zero or less, and nothing in the base game writes + // an item's health at all. + [[nodiscard]] CanEquipResult CanEquip(Actor* a_actor, const std::uint32_t& a_handleID, std::uint32_t a_stackID) + { + using func_t = decltype(&ActorEquipManager::CanEquip); + static REL::Relocation func{ ID::ActorEquipManager::CanEquip }; + return func(this, a_actor, a_handleID, a_stackID); + } + bool EquipObject( Actor* a_actor, const BGSObjectInstance& a_object, diff --git a/include/RE/E/ExtraDataList.h b/include/RE/E/ExtraDataList.h index 5c67a9b93..af6fa03f4 100644 --- a/include/RE/E/ExtraDataList.h +++ b/include/RE/E/ExtraDataList.h @@ -178,6 +178,17 @@ namespace RE return func(this); } + // Whether the health in this list is zero or less, which is what the game + // calls a broken item. The counterpart of IsDamaged above, which asks the + // weaker question of whether the health is anything below full. Both + // answer false for a list carrying no health at all. + inline bool IsItemBroken() + { + using func_t = decltype(&ExtraDataList::IsItemBroken); + static REL::Relocation func{ ID::ExtraDataList::IsItemBroken }; + return func(this); + } + inline bool CompareList(const ExtraDataList* a_compare, ComparisonQualifier a_qualifier) { using func_t = decltype(&ExtraDataList::CompareList); diff --git a/include/RE/IDs.h b/include/RE/IDs.h index e2220055c..4aae0a632 100644 --- a/include/RE/IDs.h +++ b/include/RE/IDs.h @@ -78,6 +78,7 @@ namespace RE::ID inline constexpr REL::ID EquipObject{ 2231392 }; inline constexpr REL::ID UnequipObject{ 2231395 }; inline constexpr REL::ID UnequipItem{ 2231399 }; + inline constexpr REL::ID CanEquip{ 2231405 }; } namespace ActorUtils @@ -1056,6 +1057,7 @@ namespace RE::ID inline constexpr REL::ID ClearFavorite{ 2190191 }; inline constexpr REL::ID IsFavorite{ 2190189 }; inline constexpr REL::ID IsDamaged{ 2190224 }; + inline constexpr REL::ID IsItemBroken{ 2190223 }; inline constexpr REL::ID CompareList{ 2190098 }; inline constexpr REL::ID SetFavorite{ 2190188 }; inline constexpr REL::ID GetPrimitive{ 2190427 }; From bbe9a6ba61f1d5667779549402c8c97c2dafde0c Mon Sep 17 00:00:00 2001 From: hxef <111711970+hxef@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:48:21 +0300 Subject: [PATCH 4/4] Add CombatFormulas::CalcWeaponDamage and the explosion damage path CalcWeaponDamage works out the damage of one attack before armour, for combat and for the figure an item card shows. ID 2209001. For a launcher that is only half the story. A Fat Man's WEAP record is worth 18 damage, because the launcher is a tube and nearly all of the damage belongs to the shell it fires. Reaching the shell takes two more functions, and they are not interchangeable. TESObjectWEAP::GetProjectile, which is static, returns what a weapon fires, or null for one that fires nothing. The instance data's own override is preferred, then the base form's, and the ammunition's projectile stands in when neither names one, so a barrel that swaps the projectile and a change of ammunition are both followed. ID 2198934. Explosion::GetDamage returns what a blast already in the world is worth. It multiplies the explosion record's damage by that explosion's own damageMult, keeps the result in calculatedDamage and hands the same number back every time afterwards, so everything the engine asks about one blast agrees with itself. ID 2236658. Which of the two applies depends on whether anything has been fired. An item card describes a weapon that has fired nothing, so there is no Explosion to ask and CombatFormulas::GetWeaponDisplayDamage calls GetProjectile and reads the explosion record directly. A blast in the world calls Explosion::GetDamage instead. The two routes reach the same figure through different code. Explosion::GetActorOwner resolves an explosion's owner handle and returns it only when it names an actor, which is the aggressor a blast's blows are credited to. ID 2236659. --- include/RE/C/CombatFormulas.h | 14 ++++++++++++++ include/RE/E/Explosion.h | 28 ++++++++++++++++++++++++++++ include/RE/IDs.h | 7 +++++++ include/RE/T/TESObjectWEAP.h | 16 ++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/include/RE/C/CombatFormulas.h b/include/RE/C/CombatFormulas.h index 7b5657eb4..743663ca1 100644 --- a/include/RE/C/CombatFormulas.h +++ b/include/RE/C/CombatFormulas.h @@ -42,6 +42,20 @@ namespace RE return func(a_target, a_bodyPart, a_physicalDamage, a_damageTypes); } + // The damage one attack deals before the target's armour. Combat and the + // damage an item card shows both work it out through this. + // + // a_avOwner is the actor the damage belongs to, and a TESNPC record is + // taken as well, with the actor values read off that instead. a_condition + // is the weapon's item health, and a_rangeMult the falloff for a target + // past the weapon's range, 1.0 within it. + [[nodiscard]] inline float CalcWeaponDamage(const TESForm* a_avOwner, const TESObjectWEAP::InstanceData* a_data, const TESAmmo* a_ammo, float a_condition, float a_rangeMult) + { + using func_t = decltype(&CombatFormulas::CalcWeaponDamage); + static REL::Relocation func{ ID::CombatFormulas::CalcWeaponDamage }; + return func(a_avOwner, a_data, a_ammo, a_condition, a_rangeMult); + } + [[nodiscard]] inline std::int64_t GetNumCrippledAttackConditions(Actor* a_actor) { using func_t = decltype(&CombatFormulas::GetNumCrippledAttackConditions); diff --git a/include/RE/E/Explosion.h b/include/RE/E/Explosion.h index 332bdcfe9..3a9cb55b5 100644 --- a/include/RE/E/Explosion.h +++ b/include/RE/E/Explosion.h @@ -14,6 +14,7 @@ namespace RE { + class Actor; class ActorCause; class NiLight; class NonActorMagicCaster; @@ -74,6 +75,33 @@ namespace RE virtual void Update(float a_delta); // C7 virtual void FindTargets(); // C8 + // Whoever set the blast off, or null when the owner has gone away or is + // not an actor. This is the aggressor the engine credits an explosion's + // blows to. + [[nodiscard]] Actor* GetActorOwner() + { + using func_t = decltype(&Explosion::GetActorOwner); + static REL::Relocation func{ ID::Explosion::GetActorOwner }; + return func(this); + } + + // What the blast is worth. Worked out from the explosion record's damage + // and this explosion's damageMult the first time anything asks, then kept + // in calculatedDamage and handed back unchanged after that. + // + // Every question the engine asks about a live blast is answered from + // here, from whether it is worth looking for targets at all to what each + // of them loses. The item card is the one thing that does not come + // through it, because a weapon being looked at has set nothing off, so + // CombatFormulas::GetWeaponDisplayDamage reads the explosion record + // instead and the two arrive at the same number by different routes. + [[nodiscard]] float GetDamage() + { + using func_t = decltype(&Explosion::GetDamage); + static REL::Relocation func{ ID::Explosion::GetDamage }; + return func(this); + } + // members hknpClosestUniqueBodyIdHitCollector collector; // 110 void* explosionDBHandle; // 520 - TODO diff --git a/include/RE/IDs.h b/include/RE/IDs.h index 4aae0a632..c7f2cf7ce 100644 --- a/include/RE/IDs.h +++ b/include/RE/IDs.h @@ -1036,6 +1036,12 @@ namespace RE::ID inline constexpr REL::ID GetBuildConfirmQuestion{ 2223057 }; } + namespace Explosion + { + inline constexpr REL::ID GetActorOwner{ 2236659 }; + inline constexpr REL::ID GetDamage{ 2236658 }; + } + namespace ExteriorCellSingleton { inline constexpr REL::ID Singleton{ 4796370 }; @@ -2322,6 +2328,7 @@ namespace RE::ID namespace TESObjectWEAP { + inline constexpr REL::ID GetProjectile{ 2198934 }; inline constexpr REL::ID GetMeleeAttackSpeed{ 2198957 }; inline constexpr REL::ID GetMeleeAttackSpeedLabel{ 2198959 }; inline constexpr REL::ID Fire{ 2198960 }; diff --git a/include/RE/T/TESObjectWEAP.h b/include/RE/T/TESObjectWEAP.h index b34a703d8..2d85d0e5b 100644 --- a/include/RE/T/TESObjectWEAP.h +++ b/include/RE/T/TESObjectWEAP.h @@ -140,6 +140,22 @@ namespace RE }; static_assert(sizeof(Data) == 0x138); + // What this weapon fires, or null for one that fires nothing. + // + // The instance data's own override is preferred, then the base form's, + // and the ammunition's projectile stands in when neither names one. A + // muzzle or a barrel that swaps the projectile is therefore followed, + // and so is a change of ammunition. + // + // a_instanceData may be null, in which case only the base form and the + // ammunition are consulted. + [[nodiscard]] static BGSProjectile* GetProjectile(const TESObjectWEAP* a_weapon, const TESAmmo* a_ammo, const InstanceData* a_instanceData) + { + using func_t = decltype(&TESObjectWEAP::GetProjectile); + static REL::Relocation func{ ID::TESObjectWEAP::GetProjectile }; + return func(a_weapon, a_ammo, a_instanceData); + } + [[nodiscard]] MELEE_ATTACK_SPEED GetMeleeAttackSpeed() { using func_t = decltype(&TESObjectWEAP::GetMeleeAttackSpeed);