From e461ce886246fde9b32e8bb59a0e64c281c9561d Mon Sep 17 00:00:00 2001 From: NutyoPR Date: Sun, 8 Nov 2026 01:28:46 +0100 Subject: [PATCH 1/2] Respawn the puppet for any non-playable (beast) race, not just vanilla werewolf/vampire lord --- Code/client/Games/Skyrim/Forms/TESRace.h | 12 ++++++++++++ Code/client/Services/Generic/PlayerService.cpp | 13 ++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Code/client/Games/Skyrim/Forms/TESRace.h b/Code/client/Games/Skyrim/Forms/TESRace.h index ea686aea5..dd20fd749 100644 --- a/Code/client/Games/Skyrim/Forms/TESRace.h +++ b/Code/client/Games/Skyrim/Forms/TESRace.h @@ -5,4 +5,16 @@ struct TESRace : TESForm, TESFullName { + // RACE_DATA::Flag + enum : uint32_t + { + kPlayable = 1 << 0, + }; + + [[nodiscard]] bool IsPlayable() const noexcept { return (raceFlags & kPlayable) != 0; } + + uint8_t unk30[0x108 - 0x30]; + uint32_t raceFlags; // RACE_DATA (DATA) at 0xE8, its flags at +0x20 }; + +static_assert(offsetof(TESRace, raceFlags) == 0x108); diff --git a/Code/client/Services/Generic/PlayerService.cpp b/Code/client/Services/Generic/PlayerService.cpp index c0eab4d95..b6f5a0580 100644 --- a/Code/client/Services/Generic/PlayerService.cpp +++ b/Code/client/Services/Generic/PlayerService.cpp @@ -402,9 +402,16 @@ void PlayerService::RunBeastFormDetection() const noexcept static uint32_t lastRaceFormID = 0; static std::chrono::steady_clock::time_point lastSendTimePoint; constexpr auto cDelayBetweenUpdates = 250ms; - // Werewolf (0xCDD84) and Vampire Lord (0x200283A) - constexpr auto isBeastRace = [](uint32_t aFormId) noexcept { - return aFormId == 0xCDD84 || aFormId == 0x200283A; + // Werewolf (0xCDD84) and Vampire Lord (0x200283A), and any other non-playable race: mods transform the + // player with SetRace into their own beast races (e.g. Enderal's Theriantrophist wolf form), and without + // the respawn the remote puppet keeps its old body on the new race's skeleton and behavior. Playable races + // are excluded so race changes in the RaceSex menu don't respawn the puppet. + const auto isBeastRace = [](uint32_t aFormId) noexcept { + if (aFormId == 0xCDD84 || aFormId == 0x200283A) + return true; + + const TESRace* pRace = Cast(TESForm::GetById(aFormId)); + return pRace && !pRace->IsPlayable(); }; const auto now = std::chrono::steady_clock::now(); From a33f93a1d95428815f22994ed655613ac4fd27ef Mon Sep 17 00:00:00 2001 From: NutyoPR Date: Thu, 12 Nov 2026 02:41:51 +0100 Subject: [PATCH 2/2] feat(gameplay): party damage scale and limit build to 1.7.104 --- .github/workflows/windows-playable-build.yml | 3 ++- Code/client/Games/References.cpp | 26 ++++++++++++++++++++ Code/client/Games/References.h | 1 + Code/client/Games/Skyrim/Actor.cpp | 2 ++ Code/encoding/Structs/ServerSettings.cpp | 4 ++- Code/encoding/Structs/ServerSettings.h | 1 + Code/server/GameServer.cpp | 19 ++++++++++++++ 7 files changed, 54 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-playable-build.yml b/.github/workflows/windows-playable-build.yml index 8a83e0e5b..2ef99ae6b 100644 --- a/.github/workflows/windows-playable-build.yml +++ b/.github/workflows/windows-playable-build.yml @@ -8,6 +8,7 @@ on: push: branches: - master + - nonplayable-beast-race workflow_call: jobs: @@ -16,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - skyrim-version: ['latest', '1.6.1170'] + skyrim-version: ['latest'] uses: ./.github/workflows/windows.yml with: build-mode: release diff --git a/Code/client/Games/References.cpp b/Code/client/Games/References.cpp index d626bd0ae..723787472 100644 --- a/Code/client/Games/References.cpp +++ b/Code/client/Games/References.cpp @@ -69,6 +69,32 @@ float CalculateRealDamage(Actor* apHittee, float aDamage, bool aKillMove) noexce return realDamage; } +float GetPartyDamageMultiplier(Actor* apHitter) noexcept +{ + const float cScale = World::Get().GetServerSettings().PartyDamageScale; + if (cScale <= 0.f || !apHitter) + return 1.f; + + const auto& partyService = World::Get().GetPartyService(); + if (!partyService.IsInParty()) + return 1.f; + + const size_t cMembers = partyService.GetPartyMembers().size(); + if (cMembers < 2) + return 1.f; + + Actor* pSource = apHitter; + if (!pSource->GetExtension()->IsPlayer()) + { + if (Actor* pCommander = pSource->GetCommandingActor()) + pSource = pCommander; + } + if (!pSource->GetExtension()->IsPlayer()) + return 1.f; + + return 1.f / (1.f + cScale * static_cast(cMembers - 1)); +} + } // namespace GameplayFormulas void FadeOutGame(bool aFadingOut, bool aBlackFade, float aFadeDuration, bool aRemainVisible, float aSecondsToFade) noexcept diff --git a/Code/client/Games/References.h b/Code/client/Games/References.h index b2df69b5e..50f5d569f 100644 --- a/Code/client/Games/References.h +++ b/Code/client/Games/References.h @@ -6,6 +6,7 @@ namespace GameplayFormulas { float CalculateRealDamage(Actor* apHittee, float aDamage, bool aKillMove) noexcept; +float GetPartyDamageMultiplier(Actor* apHitter) noexcept; } namespace Settings diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 79410e748..4bb148fb1 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -1073,6 +1073,8 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo if (apHitter) World::Get().GetRunner().Trigger(HitEvent(apHitter->formID, apThis->formID)); + aDamage *= GameplayFormulas::GetPartyDamageMultiplier(apHitter); + float realDamage = GameplayFormulas::CalculateRealDamage(apThis, aDamage, aKillMove); float currentHealth = apThis->GetActorValue(ActorValueInfo::kHealth); diff --git a/Code/encoding/Structs/ServerSettings.cpp b/Code/encoding/Structs/ServerSettings.cpp index 44dc7a55a..c1b1c8af3 100644 --- a/Code/encoding/Structs/ServerSettings.cpp +++ b/Code/encoding/Structs/ServerSettings.cpp @@ -5,7 +5,7 @@ using TiltedPhoques::Serialization; bool ServerSettings::operator==(const ServerSettings& acRhs) const noexcept { - return Difficulty == acRhs.Difficulty && GreetingsEnabled == acRhs.GreetingsEnabled && PvpEnabled == acRhs.PvpEnabled && SyncPlayerHomes == acRhs.SyncPlayerHomes && DeathSystemEnabled == acRhs.DeathSystemEnabled && AutoPartyJoin == acRhs.AutoPartyJoin && ItemDropsEnabled == acRhs.ItemDropsEnabled && ConsoleCommandsAllowed == acRhs.ConsoleCommandsAllowed && VoipEnabled == acRhs.VoipEnabled; + return Difficulty == acRhs.Difficulty && GreetingsEnabled == acRhs.GreetingsEnabled && PvpEnabled == acRhs.PvpEnabled && SyncPlayerHomes == acRhs.SyncPlayerHomes && DeathSystemEnabled == acRhs.DeathSystemEnabled && AutoPartyJoin == acRhs.AutoPartyJoin && ItemDropsEnabled == acRhs.ItemDropsEnabled && ConsoleCommandsAllowed == acRhs.ConsoleCommandsAllowed && VoipEnabled == acRhs.VoipEnabled && PartyDamageScale == acRhs.PartyDamageScale; } bool ServerSettings::operator!=(const ServerSettings& acRhs) const noexcept @@ -24,6 +24,7 @@ void ServerSettings::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noe Serialization::WriteBool(aWriter, AutoPartyJoin); Serialization::WriteBool(aWriter, ItemDropsEnabled); Serialization::WriteBool(aWriter, ConsoleCommandsAllowed); + Serialization::WriteFloat(aWriter, PartyDamageScale); Serialization::WriteBool(aWriter, VoipEnabled); } @@ -38,5 +39,6 @@ void ServerSettings::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcep AutoPartyJoin = Serialization::ReadBool(aReader); ItemDropsEnabled = Serialization::ReadBool(aReader); ConsoleCommandsAllowed = Serialization::ReadBool(aReader); + PartyDamageScale = Serialization::ReadFloat(aReader); VoipEnabled = Serialization::ReadBool(aReader); } diff --git a/Code/encoding/Structs/ServerSettings.h b/Code/encoding/Structs/ServerSettings.h index 1f6a93b3e..d3ca34d86 100644 --- a/Code/encoding/Structs/ServerSettings.h +++ b/Code/encoding/Structs/ServerSettings.h @@ -19,6 +19,7 @@ struct ServerSettings bool AutoPartyJoin{}; bool ItemDropsEnabled{}; bool ConsoleCommandsAllowed{true}; + float PartyDamageScale{}; // Built-in proximity voice chat, players also need Connect to voip on bool VoipEnabled{}; }; diff --git a/Code/server/GameServer.cpp b/Code/server/GameServer.cpp index d97441cce..90ed628bd 100644 --- a/Code/server/GameServer.cpp +++ b/Code/server/GameServer.cpp @@ -42,6 +42,7 @@ Console::Setting uTimeScale{"Gameplay:uTimeScale", "How many seconds pass ingame Console::Setting bSyncPlayerCalendar{"Gameplay:bSyncPlayerCalendar", "Syncs up all player calendars to be the same day, month, and year. This uses the date of the player with the furthest ahead date at connection.", false}; Console::Setting bAutoPartyJoin{"Gameplay:bAutoPartyJoin", "Join parties automatically, as long as there is only one party in the server", true}; Console::Setting bEnableItemDrops{"Gameplay:bEnableItemDrops", "Syncs items dropped by players", true}; +Console::Setting fPartyDamageScale{"Gameplay:fPartyDamageScale", "Longer fights in a party: damage from players, their spells and their summons is divided by 1 + this x (party members - 1). 1 = like enemies having x party members health, 0 = off. NPCs and followers keep full damage", 0.0f}; Console::Setting bAllowConsoleCommands{"Gameplay:bAllowConsoleCommands", "Lets players run console commands they type while connected. Turn it off to stop console trolling such as spawning enemies or killing NPCs. The console still opens for reading ids, and mods can still run commands", true}; // ModPolicy Stuff Console::Setting bEnableModCheck{"ModPolicy:bEnableModCheck", "Refuse clients whose mods do not match Data/loadorder.txt", false, Console::SettingsFlags::kLocked}; @@ -77,6 +78,23 @@ Console::Command<> ToggleConsoleCommands( GameServer::Get()->UpdateSettings(); }); +Console::Command SetPartyDamageScale( + "SetPartyDamageScale", "Set Gameplay:fPartyDamageScale (0 turns it off, 1 is like enemies having party size x health)", + [](Console::ArgStack& aStack) + { + const auto& cValue = aStack.Pop(); + char* pEnd = nullptr; + const float cScale = std::strtof(cValue.c_str(), &pEnd); + if (pEnd == cValue.c_str() || cScale < 0.0f || cScale > 10.0f) + { + spdlog::get("ConOut")->error("Party damage scale must be a number from 0 to 10, e.g. 1 or 0.75"); + return; + } + fPartyDamageScale = cScale; + spdlog::get("ConOut")->info("Party damage scale has been set to {}.", cScale); + GameServer::Get()->UpdateSettings(); + }); + Console::Command SetDifficulty( "SetDifficulty", "Set server difficulty (0 being Novice and 5 being Legendary; default is 4)", [](Console::ArgStack& aStack) @@ -156,6 +174,7 @@ ServerSettings GetSettings() settings.AutoPartyJoin = bAutoPartyJoin; settings.ItemDropsEnabled = bEnableItemDrops; settings.ConsoleCommandsAllowed = bAllowConsoleCommands; + settings.PartyDamageScale = std::clamp(fPartyDamageScale.value_as(), 0.0f, 10.0f); settings.VoipEnabled = bVoip; return settings; }