From a8688f7bfd7136a8edaa7055c119771f8ef9b0b7 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 22 Oct 2026 16:51:35 +0200 Subject: [PATCH 01/97] Keep kicked players' names in everyone's player list PartyService::OnPartyKick called BroadcastPlayerList(pKick). That argument is meant for a player who is leaving the server, so every client dropped the kicked (still connected) player from NotifyPlayerList and the kicked player got no list at all. The kick now broadcasts the full list. The client's PartyService also keeps the last name seen per player id for the session (GetKnownName), so a player who drops out of the list is still shown by name. It is cleared on disconnect. Verified: - Source: the server kick path and BroadcastPlayerList's ignore argument (also used by OnPlayerLeave, which is unchanged). - SkyrimTogetherServer and SkyrimTogetherClient build (releasedbg, MSVC). - Played on the fork's 1.8-playtest builds. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit d2f0e891b68c8e3227e94ce6e467d9725a390823) --- Code/client/Services/Generic/PartyService.cpp | 4 ++++ Code/client/Services/PartyService.h | 7 +++++++ Code/server/Services/PartyService.cpp | 3 ++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Code/client/Services/Generic/PartyService.cpp b/Code/client/Services/Generic/PartyService.cpp index 60d17fc54a..466525f3ed 100644 --- a/Code/client/Services/Generic/PartyService.cpp +++ b/Code/client/Services/Generic/PartyService.cpp @@ -103,12 +103,16 @@ void PartyService::OnDisconnected(const DisconnectedEvent& acEvent) noexcept { DestroyParty(); m_invitations.clear(); + m_knownNames.clear(); m_nextUpdate = 0; } void PartyService::OnPlayerList(const NotifyPlayerList& acPlayerList) noexcept { m_players = acPlayerList.Players; + + for (const auto& [playerId, name] : m_players) + m_knownNames[playerId] = name; } void PartyService::OnPartyInfo(const NotifyPartyInfo& acPartyInfo) noexcept diff --git a/Code/client/Services/PartyService.h b/Code/client/Services/PartyService.h index f3ee2f3108..730c9339b5 100644 --- a/Code/client/Services/PartyService.h +++ b/Code/client/Services/PartyService.h @@ -27,6 +27,12 @@ struct PartyService const Vector& GetPartyMembers() const noexcept { return m_partyMembers; } const Map& GetPlayers() const noexcept { return m_players; } + // Last name seen for a player id this session, kept after they drop out of the player list. + [[nodiscard]] const String* GetKnownName(uint32_t aPlayerId) const noexcept + { + auto it = m_knownNames.find(aPlayerId); + return it != m_knownNames.end() && !it->second.empty() ? &it->second : nullptr; + } Map& GetInvitations() noexcept { return m_invitations; } [[nodiscard]] bool HasPendingInvitations() const noexcept { return !m_invitations.empty(); } @@ -50,6 +56,7 @@ struct PartyService void DestroyParty() noexcept; Map m_players; + Map m_knownNames; Map m_invitations; uint64_t m_nextUpdate{0}; diff --git a/Code/server/Services/PartyService.cpp b/Code/server/Services/PartyService.cpp index 5fc357781b..f46305fffd 100644 --- a/Code/server/Services/PartyService.cpp +++ b/Code/server/Services/PartyService.cpp @@ -190,7 +190,8 @@ void PartyService::OnPartyKick(const PacketEvent& acPacket) no { spdlog::debug("[PartyService]: Kicking player {} from party", pKick->GetId()); RemovePlayerFromParty(pKick); - BroadcastPlayerList(pKick); + // The kicked player is still connected: everyone, them included, gets the full list. + BroadcastPlayerList(); } } } From 791a69d21bb40babcd63bba39f483bf273b3ddc6 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 22 Oct 2026 16:51:36 +0200 Subject: [PATCH 02/97] Add player name tags with privacy settings Name tags (ImGui, NameTagService): - Plain name text above other players, no level or id. The party leader gets the party widget's crown (crown.svg, drawn as convex pieces). An optional thin "Name backdrop" (On/Party/Off, default Party). - Anchored on the neck bone ("NPC Neck [Neck]") plus the head height, so tags stay centred in profile, crouching and lying down, and stay on the body if a dismemberment mod removes the head. Helmet clearance of 16 world units, clamped to 14-56 px; text scale clamp(800 / distance, 0.65, 1.2). - Fade start / invisible distances for names and for party (3000 / 5000). - A tag hides while the crosshair targets that player, so the HUD's activate prompt stays readable. - Hidden while full-screen menus are open; dead players only up close. - Font: the overlay's Futura Condensed (Latin, Cyrillic). Player UI settings (Angular, stored per client): - Known names: All / Party / None. Unknown names show as an italic "Stranger" in tags, the player list, the party HUD, the party menu and party invites. An eye in the player list overrides it per username. - Party invitations (on/off), Preview mini chat (On/Party/Off), Name tags (On/Party/Off, default Party), name tag size. - Locales: English and Swedish; other languages fall back to English. Verified: - Ghidra (SkyrimSE 1.6.1170): - NiAVObject world translate at +0xA0 (decapitation code 0x140685200 walks parent +0x30, local transform +0x48, world +0x7C, reads +0xA0/A4/A8). - NiAVObject::GetByName is vtable slot 0x2A (+0x150), called with FixedStrings entries (0x1406b5c10); "NPC Neck [Neck]" is FixedStrings +0x238. - CrosshairPickData (ID 401585 -> 0x143138ed0): the activate code 0x14073e980 reads the target handle at +0x4. - World-to-screen (ID 70640, 0x140d2c780) only rejects |w| <= tolerance, so the z <= epsilon check is what drops points behind the camera. - SkyrimTogetherClient, SkyrimTogetherServer and the Angular production build succeed. Played on the fork's 1.8-playtest builds. Assumed: - Neck-to-head-top (24 units) and helmet clearance (16 units) are tuned by eye in game, not measured. - Actor nodes and camera globals are read from the ImGui draw (Present path), as the original v1.8.1 name tags did; the thread was not verified. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit f618aefab041cfef1ac59fafc9c99a1949949f3d) --- .../Games/Skyrim/Misc/CrosshairPickData.cpp | 10 + .../Games/Skyrim/Misc/CrosshairPickData.h | 16 + .../Games/Skyrim/NetImmerse/NiAVObject.h | 7 +- Code/client/Services/Generic/ImguiService.cpp | 17 + .../Services/Generic/NameTagService.cpp | 382 ++++++++++++++++++ .../client/Services/Generic/OverlayClient.cpp | 57 +++ Code/client/Services/ImguiService.h | 7 + Code/client/Services/NameTagService.h | 97 +++++ Code/client/Services/OverlayClient.h | 1 + Code/client/World.cpp | 2 + .../app/components/group/group.component.html | 11 +- .../app/components/group/group.component.scss | 5 + .../app/components/group/group.component.ts | 3 + .../notifications/notifications.component.ts | 9 +- .../party-menu/party-menu.component.html | 51 ++- .../party-menu/party-menu.component.scss | 5 + .../party-menu/party-menu.component.ts | 3 + .../player-list/player-list.component.html | 35 +- .../player-list/player-list.component.scss | 21 + .../player-list/player-list.component.ts | 10 + .../src/app/components/root/root.component.ts | 3 + .../settings/settings.component.html | 177 ++++++++ .../settings/settings.component.scss | 6 + .../components/settings/settings.component.ts | 31 ++ .../src/app/mock/skyrimtogether.mock.ts | 4 + .../src/app/services/client.service.ts | 7 + .../src/app/services/player-list.service.ts | 13 +- .../src/app/services/player-ui.service.ts | 173 ++++++++ .../src/app/services/setting.service.ts | 116 ++++++ Code/skyrim_ui/src/assets/i18n/en.json | 36 ++ Code/skyrim_ui/src/assets/i18n/sv.json | 36 ++ Code/skyrim_ui/src/typings.d.ts | 23 ++ Code/tp_process/ProcessHandler.cpp | 1 + 33 files changed, 1354 insertions(+), 21 deletions(-) create mode 100644 Code/client/Games/Skyrim/Misc/CrosshairPickData.cpp create mode 100644 Code/client/Games/Skyrim/Misc/CrosshairPickData.h create mode 100644 Code/client/Services/Generic/NameTagService.cpp create mode 100644 Code/client/Services/NameTagService.h create mode 100644 Code/skyrim_ui/src/app/services/player-ui.service.ts diff --git a/Code/client/Games/Skyrim/Misc/CrosshairPickData.cpp b/Code/client/Games/Skyrim/Misc/CrosshairPickData.cpp new file mode 100644 index 0000000000..316974646d --- /dev/null +++ b/Code/client/Games/Skyrim/Misc/CrosshairPickData.cpp @@ -0,0 +1,10 @@ +#include + +#include + +CrosshairPickData* CrosshairPickData::Get() +{ + POINTER_SKYRIMSE(CrosshairPickData*, s_instance, 401585); + + return *s_instance.Get(); +} diff --git a/Code/client/Games/Skyrim/Misc/CrosshairPickData.h b/Code/client/Games/Skyrim/Misc/CrosshairPickData.h new file mode 100644 index 0000000000..596cc892a9 --- /dev/null +++ b/Code/client/Games/Skyrim/Misc/CrosshairPickData.h @@ -0,0 +1,16 @@ +#pragma once + +// What the crosshair points at; Target is the ref the HUD's activate prompt ("E Talk") is for. +struct CrosshairPickData +{ + static CrosshairPickData* Get(); + + uint32_t pad0; + uint32_t Target; // ObjectRefHandle + uint32_t TargetActor; // ObjectRefHandle + uint8_t padC[0x38 - 0xC]; +}; + +static_assert(offsetof(CrosshairPickData, Target) == 0x4); +static_assert(offsetof(CrosshairPickData, TargetActor) == 0x8); +static_assert(sizeof(CrosshairPickData) == 0x38); diff --git a/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h b/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h index 9dcbd4fac9..ce5b6775d6 100644 --- a/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h +++ b/Code/client/Games/Skyrim/NetImmerse/NiAVObject.h @@ -1,6 +1,7 @@ #pragma once #include +#include struct BSFixedString; @@ -14,7 +15,11 @@ struct NiAVObject : NiObjectNET virtual void sub_29(); virtual NiAVObject* GetByName(BSFixedString& aName); - uint8_t pad30[0x110 - 0x30]; + uint8_t pad30[0xA0 - 0x30]; + // Translation of the world transform (which starts at 0x7C). + NiPoint3 worldTranslate; + uint8_t padAC[0x110 - 0xAC]; }; +static_assert(offsetof(NiAVObject, worldTranslate) == 0xA0); static_assert(sizeof(NiAVObject) == 0x110); diff --git a/Code/client/Services/Generic/ImguiService.cpp b/Code/client/Services/Generic/ImguiService.cpp index 8faba9c50f..3cd8a34c03 100644 --- a/Code/client/Services/Generic/ImguiService.cpp +++ b/Code/client/Services/Generic/ImguiService.cpp @@ -22,6 +22,7 @@ ImguiService::~ImguiService() noexcept void ImguiService::Create(RenderSystemD3D11* apRenderSystem, HWND aHwnd) { m_imDriver.Initialize(static_cast(aHwnd)); + LoadNameTagFont(); // init platform if (!ImGui_ImplWin32_Init(aHwnd)) @@ -30,6 +31,22 @@ void ImguiService::Create(RenderSystemD3D11* apRenderSystem, HWND aHwnd) ImGui_ImplDX11_Init(apRenderSystem->GetDevice(), apRenderSystem->GetDeviceContext()); } +void ImguiService::LoadNameTagFont() +{ + // Same font file the overlay UI ships with. Loaded large so tags stay sharp when scaled down. + const auto fontPath = TiltedPhoques::GetPath() / "UI" / "assets" / "futura-condensed-medium.otf"; + if (!std::filesystem::exists(fontPath)) + { + spdlog::warn("Name tag font not found at {}, using the default ImGui font", fontPath.string()); + return; + } + + auto& io = ImGui::GetIO(); + m_pNameTagFont = io.Fonts->AddFontFromFileTTF(fontPath.string().c_str(), 40.f, nullptr, io.Fonts->GetGlyphRangesCyrillic()); + if (!m_pNameTagFont) + spdlog::warn("Failed to load name tag font {}", fontPath.string()); +} + void ImguiService::Render() const { ImGui_ImplDX11_NewFrame(); diff --git a/Code/client/Services/Generic/NameTagService.cpp b/Code/client/Services/Generic/NameTagService.cpp new file mode 100644 index 0000000000..30d1a83a6a --- /dev/null +++ b/Code/client/Services/Generic/NameTagService.cpp @@ -0,0 +1,382 @@ +#include + +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace +{ +float Clamp01(float aValue) noexcept +{ + return std::clamp(aValue, 0.f, 1.f); +} + +float SmoothStep(float aEdge0, float aEdge1, float aValue) noexcept +{ + if (aEdge0 == aEdge1) + return aValue >= aEdge1 ? 1.f : 0.f; + + const float t = Clamp01((aValue - aEdge0) / (aEdge1 - aEdge0)); + return t * t * (3.f - 2.f * t); +} + +bool HasAllGlyphs(ImFont* apFont, const String& acText) noexcept +{ + const char* pText = acText.c_str(); + const char* pEnd = pText + acText.size(); + while (pText < pEnd) + { + unsigned int codepoint = 0; + pText += ImTextCharFromUtf8(&codepoint, pText, pEnd); + if (codepoint < 0x80) + continue; + if (!apFont->FindGlyphNoFallback(static_cast(codepoint))) + return false; + } + return true; +} + +// The party widget's leader crown (skyrim_ui assets/images/group/crown.svg, a 26x26 box), drawn as +// convex pieces since ImGui 1.89 can't fill concave paths. Points are clockwise, as ImGui's AA fill expects. +void DrawPartyCrown(ImDrawList* apDrawList, ImVec2 aOrigin, float aSize, ImU32 aColor) noexcept +{ + const float s = aSize / 26.f; + auto at = [&](float aX, float aY) { return ImVec2(aOrigin.x + aX * s, aOrigin.y + aY * s); }; + + const ImVec2 centerSpike[] = {at(12.09f, 5.78f), at(13.91f, 5.78f), at(18.f, 14.f), at(8.f, 14.f)}; + const ImVec2 leftSpike[] = {at(2.41f, 10.97f), at(3.53f, 10.28f), at(8.f, 14.f), at(4.09f, 19.f)}; + const ImVec2 rightSpike[] = {at(21.91f, 19.f), at(18.f, 14.f), at(22.47f, 10.28f), at(23.59f, 10.97f)}; + const ImVec2 band[] = {at(8.f, 14.f), at(18.f, 14.f), at(21.91f, 19.f), at(4.09f, 19.f)}; + apDrawList->AddConvexPolyFilled(centerSpike, 4, aColor); + apDrawList->AddConvexPolyFilled(leftSpike, 4, aColor); + apDrawList->AddConvexPolyFilled(rightSpike, 4, aColor); + apDrawList->AddConvexPolyFilled(band, 4, aColor); + + apDrawList->AddCircleFilled(at(13.f, 4.f), 2.f * s, aColor, 12); + apDrawList->AddCircleFilled(at(2.f, 9.f), 2.f * s, aColor, 12); + apDrawList->AddCircleFilled(at(24.f, 9.f), 2.f * s, aColor, 12); + + apDrawList->AddRectFilled(at(4.f, 21.f), at(22.f, 24.f), aColor, 1.5f * s); +} + +// Text with a 1 px shadow; ImGui has no italic style, so italics shear the glyph quads around the baseline. +void DrawShadowedText(ImDrawList* apDrawList, ImFont* apFont, float aSize, ImVec2 aPos, const char* apBegin, const char* apEnd, ImU32 aColor, ImU32 aShadowColor, bool aItalic) noexcept +{ + const int firstVertex = apDrawList->VtxBuffer.Size; + apDrawList->AddText(apFont, aSize, ImVec2(aPos.x + 1.f, aPos.y + 1.f), aShadowColor, apBegin, apEnd); + apDrawList->AddText(apFont, aSize, aPos, aColor, apBegin, apEnd); + + if (aItalic) + { + const float baseline = aPos.y + aSize; + for (int i = firstVertex; i < apDrawList->VtxBuffer.Size; ++i) + apDrawList->VtxBuffer[i].pos.x += (baseline - apDrawList->VtxBuffer[i].pos.y) * NameTagService::kItalicShear; + } +} +} // namespace + +NameTagService::NameTagService(World& aWorld, entt::dispatcher& aDispatcher) noexcept + : m_world(aWorld) +{ + auto& imgui = m_world.ctx().at(); + m_drawConnection = imgui.OnDraw.connect<&NameTagService::OnDraw>(this); + m_updateConnection = aDispatcher.sink().connect<&NameTagService::OnUpdate>(this); +} + +void NameTagService::OnUpdate(const UpdateEvent&) noexcept +{ + auto* pLocalPlayer = PlayerCharacter::Get(); + if (!pLocalPlayer) + { + m_visibility.clear(); + return; + } + + const uint64_t tick = m_world.GetTick(); + + auto view = m_world.view(); + for (auto entity : view) + { + const auto& playerComponent = view.get(entity); + const auto& formComponent = view.get(entity); + + Actor* pActor = Cast(TESForm::GetById(formComponent.Id)); + if (!pActor) + continue; + + const float dx = pLocalPlayer->position.x - pActor->position.x; + const float dy = pLocalPlayer->position.y - pActor->position.y; + const float dz = pLocalPlayer->position.z - pActor->position.z; + const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); + + bool isVisible = !pActor->IsDead(); + if (!isVisible && distance <= kNearBypassDistance) + isVisible = true; + + VisibilityInfo& info = m_visibility[playerComponent.Id]; + info.Visible = isVisible; + info.Tick = tick; + } + + constexpr uint64_t kMaxAgeMs = 2000; + for (auto it = m_visibility.begin(); it != m_visibility.end();) + { + if (tick - it->second.Tick > kMaxAgeMs) + it = m_visibility.erase(it); + else + ++it; + } +} + +void NameTagService::SetSettings(Settings aSettings) noexcept +{ + aSettings.NameFadeStart = std::max(aSettings.NameFadeStart, 0.f); + aSettings.NameInvisible = std::max(aSettings.NameInvisible, aSettings.NameFadeStart + 1.f); + aSettings.PartyFadeStart = std::max(aSettings.PartyFadeStart, 0.f); + aSettings.PartyInvisible = std::max(aSettings.PartyInvisible, aSettings.PartyFadeStart + 1.f); + aSettings.FontSize = std::clamp(aSettings.FontSize, 8.f, 48.f); + + m_settings = std::move(aSettings); +} + +void NameTagService::OnDraw() noexcept +{ + if (m_settings.NameTags == Filter::kOff) + return; + + auto* pLocalPlayer = PlayerCharacter::Get(); + if (!pLocalPlayer) + return; + + // Don't draw nametags while menus that cover the screen are open. + if (auto* pUI = UI::Get()) + { + auto menuOpen = [pUI](const char* acName) { + return acName && pUI->GetMenuOpen(BSFixedString(acName)); + }; + + if (menuOpen("Dialogue Menu") || menuOpen("InventoryMenu") || menuOpen("StatsMenu") || + menuOpen("SkillsMenu") || menuOpen("MagicMenu") || menuOpen("MapMenu") || menuOpen("TweenMenu") || + menuOpen("FavoritesMenu")) + { + return; + } + } + + auto view = m_world.view(); + if (view.begin() == view.end()) + return; + + const auto& partyService = m_world.GetPartyService(); + const auto& partyMembers = partyService.GetPartyMembers(); + + ImDrawList* drawList = ImGui::GetForegroundDrawList(); + if (!drawList) + return; + + const ImVec2 viewport = ImGui::GetIO().DisplaySize; + const float uiScale = viewport.y / 1080.f; + ImFont* font = m_world.ctx().at().GetNameTagFont(); + if (!font) + font = ImGui::GetFont(); + + // The label comes translated from the UI; fall back to English when the + // tag font lacks its glyphs (e.g. CJK). + const char* strangerLabel = m_settings.StrangerLabel.c_str(); + if (!HasAllGlyphs(font, m_settings.StrangerLabel)) + strangerLabel = "Stranger"; + + const glm::vec3 localPos = {pLocalPlayer->position.x, pLocalPlayer->position.y, pLocalPlayer->position.z}; + + // The HUD's activate prompt shows over the player the crosshair is on, so their tag ducks. + const CrosshairPickData* pCrosshair = CrosshairPickData::Get(); + const uint32_t crosshairTarget = pCrosshair ? pCrosshair->Target : 0; + + for (auto entity : view) + { + const auto& playerComponent = view.get(entity); + const auto& formComponent = view.get(entity); + + Actor* pActor = Cast(TESForm::GetById(formComponent.Id)); + if (!pActor) + continue; + + const ActorExtension* pExtension = pActor->GetExtension(); + if (!pExtension || pExtension->IsLocalPlayer()) + continue; + + const bool isPartyMember = std::find(partyMembers.begin(), partyMembers.end(), playerComponent.Id) != partyMembers.end(); + if (!IsEnabledFor(m_settings.NameTags, isPartyMember)) + continue; + + if (!ShouldRenderTag(playerComponent.Id)) + continue; + + if (crosshairTarget != 0 && pActor->GetHandle().handle.iBits == crosshairTarget) + continue; + + // Names are cached for the session, so a player dropped from the player list (e.g. after a kick) + // keeps their name. Without one, the stranger label is shown; never a player id. + bool isNameRevealed = m_settings.RevealedPlayerIds.count(playerComponent.Id) > 0; + std::string displayName = strangerLabel; + if (isNameRevealed) + { + if (const String* pName = partyService.GetKnownName(playerComponent.Id)) + displayName = pName->c_str(); + else + isNameRevealed = false; + } + + const bool isLeader = isPartyMember && partyService.GetLeaderPlayerId() == playerComponent.Id; + + const NiPoint3 headTop = BuildHeadTopPoint(pActor); + + ImVec2 headScreen; + float depth = 0.f; + if (!ProjectWorldPoint(headTop, headScreen, depth, viewport)) + continue; + + const float fadeStart = isPartyMember ? m_settings.PartyFadeStart : m_settings.NameFadeStart; + const float invisible = isPartyMember ? m_settings.PartyInvisible : m_settings.NameInvisible; + + const float distance = glm::distance(localPos, glm::vec3{headTop.x, headTop.y, headTop.z}); + if (distance > invisible) + continue; + + float alpha = 1.f; + if (distance > fadeStart) + { + const float fadeT = Clamp01((distance - fadeStart) / (invisible - fadeStart)); + alpha = 1.f - SmoothStep(0.f, 1.f, fadeT); + } + alpha *= Clamp01(1.f - std::max(depth - 0.95f, 0.f) * 4.f); + if (alpha <= 0.01f) + continue; + + // Helmet clearance: a world-space gap, clamped on screen so close tags don't float and far ones don't sit on the head. + const float heightScale = std::clamp(pActor->GetHeight() / 128.f, 0.5f, 2.f); + NiPoint3 clearancePoint = headTop; + clearancePoint.z += kHelmetClearance * heightScale; + float gap = kMaxGapPx * uiScale; + ImVec2 clearanceScreen; + float clearanceDepth = 0.f; + if (ProjectWorldPoint(clearancePoint, clearanceScreen, clearanceDepth, viewport)) + gap = std::clamp(headScreen.y - clearanceScreen.y, kMinGapPx * uiScale, kMaxGapPx * uiScale); + + const float distanceScale = std::clamp(kScaleDistance / std::max(distance, 1.f), kMinScale, kMaxScale); + const float fontSize = m_settings.FontSize * kFontSizeScale * uiScale * distanceScale; + // Proportions below are tuned at the 22 px base size. + const float k = fontSize / 22.f; + + const ImVec2 textSize = font->CalcTextSizeA(fontSize, FLT_MAX, 0.f, displayName.c_str(), displayName.c_str() + displayName.size()); + const float crownWidth = isLeader ? fontSize * 0.8f : 0.f; + const float crownGap = isLeader ? fontSize * 0.25f : 0.f; + const float paddingX = 5.f * k; + const float paddingY = 2.f * k; + const float contentWidth = crownWidth + crownGap + textSize.x; + + const ImVec2 bottomRight(headScreen.x + contentWidth * 0.5f + paddingX, headScreen.y - gap); + const ImVec2 topLeft(headScreen.x - contentWidth * 0.5f - paddingX, bottomRight.y - textSize.y - paddingY * 2.f); + + // A thin box with no border, so it reads as a backdrop rather than a plate. + if (IsEnabledFor(m_settings.Backdrop, isPartyMember)) + drawList->AddRectFilled(topLeft, bottomRight, ColorWithAlpha(ImVec4(0.f, 0.f, 0.f, 0.30f), alpha)); + + const ImU32 shadowColor = ColorWithAlpha(ImVec4(0.f, 0.f, 0.f, 0.8f), alpha); + const ImVec2 contentPos(topLeft.x + paddingX, topLeft.y + paddingY); + + if (isLeader) + { + // Same icon as the party widget, a square crownWidth wide, centred on the text. + const ImVec2 origin(contentPos.x, contentPos.y + (textSize.y - crownWidth) * 0.5f); + DrawPartyCrown(drawList, ImVec2(origin.x + 1.f, origin.y + 1.f), crownWidth, shadowColor); + DrawPartyCrown(drawList, origin, crownWidth, ColorWithAlpha(ImVec4(0.95f, 0.95f, 0.95f, 1.f), alpha)); + } + + const ImVec2 namePos(contentPos.x + crownWidth + crownGap, contentPos.y); + const ImVec4 nameColor = isNameRevealed ? ImVec4(0.95f, 0.95f, 0.95f, 1.f) : ImVec4(0.78f, 0.78f, 0.78f, 1.f); + + DrawShadowedText(drawList, font, fontSize, namePos, displayName.c_str(), displayName.c_str() + displayName.size(), ColorWithAlpha(nameColor, alpha), shadowColor, !isNameRevealed); + } +} + +bool NameTagService::ShouldRenderTag(uint32_t aPlayerId) const noexcept +{ + const uint64_t tick = m_world.GetTick(); + auto it = m_visibility.find(aPlayerId); + if (it == m_visibility.end()) + return true; + + constexpr uint64_t kStaleThresholdMs = 1500; + if (tick - it->second.Tick > kStaleThresholdMs) + return true; + + return it->second.Visible; +} + +bool NameTagService::ProjectWorldPoint(const NiPoint3& aWorldPoint, ImVec2& aScreenPos, float& aDepth, const ImVec2& aViewport) const noexcept +{ + NiPoint3 projected{}; + if (!HUDMenuUtils::WorldPtToScreenPt3(aWorldPoint, projected)) + return false; + + if (projected.z <= kVisibilityEpsilon || projected.z >= 1.f) + return false; + + aScreenPos.x = projected.x * aViewport.x; + aScreenPos.y = (1.f - projected.y) * aViewport.y; + aDepth = projected.z; + + return true; +} + +NiPoint3 NameTagService::BuildHeadTopPoint(Actor* apActor) const noexcept +{ + NiPoint3 point = apActor->position; + const float height = apActor->GetHeight(); + + // The neck bone follows crouching, sitting and lying down, and stays on the body when a + // dismemberment mod detaches the head. Never freed: the engine's string cache outlives us. + static BSFixedString* s_pNeckNodeName = new BSFixedString("NPC Neck [Neck]"); + if (NiNode* pRoot = apActor->GetNiNode()) + { + if (NiAVObject* pNeck = pRoot->GetByName(*s_pNeckNodeName)) + { + point = pNeck->worldTranslate; + point.z += kHeadTopAboveNeck * std::clamp(height / 128.f, 0.5f, 2.f); + return point; + } + } + + // No 3D yet: the top of the bounding box. + point.z += height; + return point; +} + +bool NameTagService::IsEnabledFor(Filter aFilter, bool aIsPartyMember) noexcept +{ + return aFilter == Filter::kOn || (aFilter == Filter::kParty && aIsPartyMember); +} + +ImU32 NameTagService::ColorWithAlpha(const ImVec4& aColor, float aAlpha) noexcept +{ + ImVec4 adjusted = aColor; + adjusted.w *= Clamp01(aAlpha); + return ImGui::ColorConvertFloat4ToU32(adjusted); +} diff --git a/Code/client/Services/Generic/OverlayClient.cpp b/Code/client/Services/Generic/OverlayClient.cpp index a0378612fa..938c680890 100644 --- a/Code/client/Services/Generic/OverlayClient.cpp +++ b/Code/client/Services/Generic/OverlayClient.cpp @@ -11,6 +11,8 @@ #include +#include + #include OverlayClient::OverlayClient(TransportService& aTransport, TiltedPhoques::OverlayRenderHandler* apHandler) @@ -47,6 +49,8 @@ bool OverlayClient::OnProcessMessageReceived(CefRefPtr browser, CefR ProcessDisconnectMessage(); else if (eventName == "revealPlayers") ProcessRevealPlayersMessage(); + else if (eventName == "setNameTagSettings") + ProcessSetNameTagSettings(eventArgs); else if (eventName == "sendMessage") ProcessChatMessage(eventArgs); else if (eventName == "setTime") @@ -167,3 +171,56 @@ void OverlayClient::SetUIVisible(bool aVisible) noexcept World::Get().GetOverlayService().SetActive(aVisible); pRenderer->SetCursorVisible(aVisible); } + +void OverlayClient::ProcessSetNameTagSettings(CefRefPtr aEventArgs) +{ + auto pDict = aEventArgs->GetDictionary(0); + if (!pDict) + return; + + // JS numbers arrive as int or double depending on their value. + auto getNumber = [&pDict](const char* acKey, float aDefault) { + switch (pDict->GetType(acKey)) + { + case VTYPE_INT: return static_cast(pDict->GetInt(acKey)); + case VTYPE_DOUBLE: return static_cast(pDict->GetDouble(acKey)); + default: return aDefault; + } + }; + // A missing key keeps the setting's default. + auto getFilter = [&getNumber](const char* acKey, NameTagService::Filter aDefault) { + const int value = static_cast(getNumber(acKey, static_cast(aDefault))); + return static_cast(std::clamp(value, 0, 2)); + }; + + NameTagService::Settings settings{}; + settings.NameTags = getFilter("nameTags", settings.NameTags); + settings.Backdrop = getFilter("backdrop", settings.Backdrop); + settings.FontSize = getNumber("fontSize", settings.FontSize); + settings.NameFadeStart = getNumber("nameFadeStart", settings.NameFadeStart); + settings.NameInvisible = getNumber("nameInvisible", settings.NameInvisible); + settings.PartyFadeStart = getNumber("partyFadeStart", settings.PartyFadeStart); + settings.PartyInvisible = getNumber("partyInvisible", settings.PartyInvisible); + + const std::string strangerLabel = pDict->GetString("strangerLabel").ToString(); + if (!strangerLabel.empty()) + settings.StrangerLabel = strangerLabel.c_str(); + + auto readIds = [&pDict](const char* acKey, Set& aIds) { + auto pIds = pDict->GetList(acKey); + if (!pIds) + return; + + for (size_t i = 0; i < pIds->GetSize(); ++i) + { + const auto type = pIds->GetType(i); + if (type == VTYPE_INT) + aIds.insert(static_cast(pIds->GetInt(i))); + else if (type == VTYPE_DOUBLE) + aIds.insert(static_cast(pIds->GetDouble(i))); + } + }; + readIds("revealedPlayerIds", settings.RevealedPlayerIds); + + World::Get().GetRunner().Queue([settings = std::move(settings)]() mutable { World::Get().ctx().at().SetSettings(std::move(settings)); }); +} diff --git a/Code/client/Services/ImguiService.h b/Code/client/Services/ImguiService.h index 036eab2c6f..5594298fcf 100644 --- a/Code/client/Services/ImguiService.h +++ b/Code/client/Services/ImguiService.h @@ -4,6 +4,7 @@ struct RenderSystemD3D9; struct RenderSystemD3D11; +struct ImFont; /** * @brief Draws the ImGui UI. @@ -25,9 +26,15 @@ struct ImguiService LRESULT WndProcHandler(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); void RawInputHandler(RAWINPUT& aRawinput); + // Skyrim Together UI font for in-world name tags, or null if it couldn't be loaded. + [[nodiscard]] ImFont* GetNameTagFont() const noexcept { return m_pNameTagFont; } + entt::sink> OnDraw; private: + void LoadNameTagFont(); + ImGuiImpl::ImGuiDriver m_imDriver; entt::sigh m_drawSignal; + ImFont* m_pNameTagFont = nullptr; }; diff --git a/Code/client/Services/NameTagService.h b/Code/client/Services/NameTagService.h new file mode 100644 index 0000000000..937eea85bb --- /dev/null +++ b/Code/client/Services/NameTagService.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include + +#include + +#include + +struct World; +struct PlayerCharacter; +struct Actor; +struct NiPoint3; +struct ImguiService; + +/** + * @brief Draws 3D-aware name tags above other players' heads. + * + * Plain name text (a crown marks the party leader), scaled with distance. + * Which tags show, and how, is set by the "Player UI filters" settings in the + * overlay (see SetSettings). + */ +struct NameTagService +{ + // Matches the order of PlayerUiFilter in the UI. + enum class Filter : uint8_t + { + kOn = 0, + kParty = 1, + kOff = 2, + }; + + struct Settings + { + Filter NameTags = Filter::kParty; + Filter Backdrop = Filter::kParty; + float FontSize = 16.f; + float NameFadeStart = 3000.f; + float NameInvisible = 5000.f; + float PartyFadeStart = 3000.f; + float PartyInvisible = 5000.f; + // Drawn in italics instead of the name of players whose name is hidden. + String StrangerLabel = "Stranger"; + // Players whose real name may be shown. The UI decides this from the + // stranger name setting, party membership and the per-name eye. + Set RevealedPlayerIds; + }; + + NameTagService(World& aWorld, entt::dispatcher& aDispatcher) noexcept; + ~NameTagService() noexcept = default; + + TP_NOCOPYMOVE(NameTagService); + + void SetSettings(Settings aSettings) noexcept; + + // Tunables (kept few on purpose). Pixel values are at 1080p and scale with the screen height. + static constexpr float kNearBypassDistance = 600.f; + // The tag anchors to the neck bone, which stays on the body when dismemberment mods remove the head. + // World units from the neck bone to the top of the head, for a 128-unit tall actor. + static constexpr float kHeadTopAboveNeck = 24.f; + // Clearance above the head for helmets, in world units, clamped on screen to [kMinGapPx, kMaxGapPx]. + static constexpr float kHelmetClearance = 16.f; + static constexpr float kMinGapPx = 14.f; + static constexpr float kMaxGapPx = 56.f; + // Text scale = clamp(kScaleDistance / distance, kMinScale, kMaxScale). + static constexpr float kScaleDistance = 800.f; + static constexpr float kMinScale = 0.65f; + static constexpr float kMaxScale = 1.2f; + // UI font sizes are tuned for the overlay; tags match the HUD's activate prompt (16 -> 22 px). + static constexpr float kFontSizeScale = 1.375f; + static constexpr float kItalicShear = 0.2f; + static constexpr float kVisibilityEpsilon = 1e-3f; + +private: + struct VisibilityInfo + { + bool Visible = true; + uint64_t Tick = 0; + }; + + void OnDraw() noexcept; + void OnUpdate(const UpdateEvent& acEvent) noexcept; + + [[nodiscard]] bool ShouldRenderTag(uint32_t aPlayerId) const noexcept; + [[nodiscard]] bool ProjectWorldPoint(const NiPoint3& aWorldPoint, ImVec2& aScreenPos, float& aDepth, const ImVec2& aViewport) const noexcept; + [[nodiscard]] NiPoint3 BuildHeadTopPoint(Actor* apActor) const noexcept; + [[nodiscard]] static bool IsEnabledFor(Filter aFilter, bool aIsPartyMember) noexcept; + [[nodiscard]] static ImU32 ColorWithAlpha(const ImVec4& aColor, float aAlpha) noexcept; + + World& m_world; + + Settings m_settings; + std::unordered_map m_visibility; + + entt::scoped_connection m_drawConnection; + entt::scoped_connection m_updateConnection; +}; diff --git a/Code/client/Services/OverlayClient.h b/Code/client/Services/OverlayClient.h index 0c71ac87da..65e441e2cf 100644 --- a/Code/client/Services/OverlayClient.h +++ b/Code/client/Services/OverlayClient.h @@ -30,6 +30,7 @@ struct OverlayClient : TiltedPhoques::OverlayClient void ProcessTeleportMessage(CefRefPtr aEventArgs); void ProcessToggleDebugUI(); void ProcessOpenPlayGuide(); + void ProcessSetNameTagSettings(CefRefPtr aEventArgs); void SetUIVisible(bool aVisible) noexcept; TransportService& m_transport; diff --git a/Code/client/World.cpp b/Code/client/World.cpp index b0596604b5..a1830d8d02 100644 --- a/Code/client/World.cpp +++ b/Code/client/World.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include @@ -54,6 +55,7 @@ World::World() ctx().emplace(*this, m_transport, m_dispatcher); ctx().emplace(*this, m_transport, m_dispatcher); ctx().emplace(*this, m_dispatcher, m_transport); + ctx().emplace(*this, m_dispatcher); BehaviorVar::Get()->Init(); } diff --git a/Code/skyrim_ui/src/app/components/group/group.component.html b/Code/skyrim_ui/src/app/components/group/group.component.html index 522ea6325f..26df57b3d7 100644 --- a/Code/skyrim_ui/src/app/components/group/group.component.html +++ b/Code/skyrim_ui/src/app/components/group/group.component.html @@ -13,7 +13,16 @@ {{ player.level }}
- {{ player.name }} + {{ player.name }} + + {{ + 'COMPONENT.PARTY_LIST.STRANGER' | transloco + }} +
; group$: Observable; + hiddenPlayerIds$ = this.playerUiService.hiddenPlayerIds$; public isAutoHide = new BehaviorSubject(true); public isShown = new BehaviorSubject(true); @@ -51,6 +53,7 @@ export class GroupComponent implements OnInit, OnDestroy { private readonly groupService: GroupService, private readonly clientService: ClientService, private readonly settingService: SettingService, + private readonly playerUiService: PlayerUiService, ) { this.isShown.next(this.settings.isPartyShown.getValue()); this.isAutoHide.next(this.settings.autoHideParty.getValue()); diff --git a/Code/skyrim_ui/src/app/components/notifications/notifications.component.ts b/Code/skyrim_ui/src/app/components/notifications/notifications.component.ts index f949000711..50cb3065dc 100644 --- a/Code/skyrim_ui/src/app/components/notifications/notifications.component.ts +++ b/Code/skyrim_ui/src/app/components/notifications/notifications.component.ts @@ -1,7 +1,8 @@ import { Component } from '@angular/core'; -import { takeUntil } from 'rxjs'; +import { filter, takeUntil } from 'rxjs'; import { ChatMessage, ChatService } from 'src/app/services/chat.service'; import { DestroyService } from '../../services/destroy.service'; +import { PlayerUiService } from '../../services/player-ui.service'; interface Notification extends ChatMessage { timer: number; @@ -19,9 +20,13 @@ export class NotificationsComponent { public constructor( private readonly destroy$: DestroyService, private readonly chatService: ChatService, + private readonly playerUiService: PlayerUiService, ) { this.chatService.messageList - .pipe(takeUntil(this.destroy$)) + .pipe( + filter(message => this.playerUiService.showsInMiniChat(message)), + takeUntil(this.destroy$), + ) .subscribe(message => { this.notifications.push({ ...message, diff --git a/Code/skyrim_ui/src/app/components/party-menu/party-menu.component.html b/Code/skyrim_ui/src/app/components/party-menu/party-menu.component.html index dda94c2de4..962ea23a17 100644 --- a/Code/skyrim_ui/src/app/components/party-menu/party-menu.component.html +++ b/Code/skyrim_ui/src/app/components/party-menu/party-menu.component.html @@ -36,19 +36,32 @@ {{ 'COMPONENT.PARTY_MENU.INVITES' | transloco }}
-
-
- {{ 'COMPONENT.PARTY_MENU.NO_INVITES' | transloco }} -
- +
+ +
+ {{ 'COMPONENT.PARTY_MENU.NO_INVITES' | transloco }} +
+ +
@@ -79,7 +92,17 @@ {{ player.level }} - {{ player.name }} + + {{ player.name }} + + {{ + 'COMPONENT.PARTY_LIST.STRANGER' | transloco + }} + + {{ player.cellName }} + + + {{ + player.name + }} + + {{ + 'COMPONENT.PARTY_LIST.STRANGER' | transloco + }} + + + + + + + + diff --git a/Code/skyrim_ui/src/app/components/player-list/player-list.component.scss b/Code/skyrim_ui/src/app/components/player-list/player-list.component.scss index f8192dccce..99906f48ee 100644 --- a/Code/skyrim_ui/src/app/components/player-list/player-list.component.scss +++ b/Code/skyrim_ui/src/app/components/player-list/player-list.component.scss @@ -45,11 +45,17 @@ padding: 15px; } + .actions-col { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + } + .user-action-button { padding: 0.25vw; background-color: #292929; border-radius: 2px; - margin-left: 5%; } } } diff --git a/Code/skyrim_ui/src/app/components/player-list/player-list.component.ts b/Code/skyrim_ui/src/app/components/player-list/player-list.component.ts index 5e812f2bb9..f6ba6722ff 100644 --- a/Code/skyrim_ui/src/app/components/player-list/player-list.component.ts +++ b/Code/skyrim_ui/src/app/components/player-list/player-list.component.ts @@ -3,6 +3,7 @@ import { faEye, faEyeSlash } from '@fortawesome/free-solid-svg-icons'; import { combineLatest, Observable, pluck, ReplaySubject, share } from 'rxjs'; import { map } from 'rxjs/operators'; import { ClientService } from 'src/app/services/client.service'; +import { TradeUiService } from 'src/app/services/trade-ui.service'; import { GroupService } from 'src/app/services/group.service'; import { PlayerListService } from 'src/app/services/player-list.service'; import { Player } from '../../models/player'; @@ -15,7 +16,14 @@ import { PlayerUiService } from 'src/app/services/player-ui.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class PlayerListComponent { - playerList$: Observable<(Player & { isMember: boolean })[]>; + playerList$: Observable< + (Player & { + isMember: boolean; + isTrading: boolean; + tradeBusy: boolean; + pendingInvite: boolean; + })[] + >; playerListLength$: Observable; isPartyLeader$: Observable; readonly faEye = faEye; @@ -27,19 +35,35 @@ export class PlayerListComponent { private readonly playerUiService: PlayerUiService, private readonly clientService: ClientService, private readonly groupService: GroupService, + private readonly tradeUiService: TradeUiService, ) { this.playerList$ = combineLatest([ this.playerListService.playerList.asObservable().pipe(pluck('players')), this.groupService.group.asObservable().pipe(pluck('members')), + this.tradeUiService.session$, + this.tradeUiService.pendingOutgoing$, ]).pipe( - map(([players, members]) => { + map(([players, members, session, pendingOutgoing]) => { if (!players) { return []; } - return players.map(player => ({ - ...player, - isMember: members.includes(player.id), - })); + const localId = this.clientService.localPlayerId; + const activePartnerId = + session && session.active ? session.partnerId : undefined; + const memberList = Array.isArray(members) ? members : []; + const outgoing = new Set( + pendingOutgoing ? Array.from(pendingOutgoing) : [], + ); + const tradeBusy = Boolean(session?.active) || outgoing.size > 0; + return players + .filter(player => player.id !== localId) + .map(player => ({ + ...player, + isMember: memberList.includes(player.id), + isTrading: activePartnerId === player.id, + tradeBusy, + pendingInvite: outgoing.has(player.id), + })); }), share({ connector: () => new ReplaySubject(1), @@ -68,4 +92,12 @@ export class PlayerListComponent { public sendPartyInvite(inviteeId: number) { this.playerListService.sendPartyInvite(inviteeId); } + + public sendTradeInvite(inviteeId: number) { + this.tradeUiService.sendInvite(inviteeId); + } + + public cancelTradeInvite(inviteeId: number) { + this.tradeUiService.cancelInvite(inviteeId); + } } diff --git a/Code/skyrim_ui/src/app/components/root/root.component.html b/Code/skyrim_ui/src/app/components/root/root.component.html index e660fa4c60..b7c5933216 100644 --- a/Code/skyrim_ui/src/app/components/root/root.component.html +++ b/Code/skyrim_ui/src/app/components/root/root.component.html @@ -13,7 +13,8 @@
+
+ + +
+ +
+ {{ (session.inventory.length === 0 + ? 'COMPONENT.TRADE_MENU.NO_INVENTORY' + : 'COMPONENT.TRADE_MENU.NO_MATCHING_ITEMS') | transloco }} +
+ + + + + + + + + + + + + + + +
+ {{ 'COMPONENT.TRADE_MENU.ITEM_COLUMNS.NAME' | transloco }} + + {{ + 'COMPONENT.TRADE_MENU.ITEM_COLUMNS.AVAILABLE' | transloco + }} + + {{ 'COMPONENT.TRADE_MENU.ITEM_COLUMNS.OFFERED' | transloco }} +
+
{{ item.name }}
+
{{ item.baseName }}
+
    +
  • {{ detail }}
  • +
+
+ {{ 'COMPONENT.TRADE_MENU.QUEST_ITEM_WARNING' | transloco }} +
+
+ {{ 'COMPONENT.TRADE_MENU.EQUIPPED_ITEM_WARNING' | transloco }} +
+
{{ item.available }} +
+ + + + + +
+
+ + +
+
+
+
+
+
+

+ {{ 'COMPONENT.TRADE_MENU.OFFER_TITLE' | transloco }} +

+
+ {{ 'COMPONENT.TRADE_MENU.EMPTY_OFFER' | transloco }} +
+
    +
  • +
    + {{ item.name }} + ×{{ item.count }} +
    +
      +
    • {{ detail }}
    • +
    +
  • +
+
+
+ + +
+
+

+ {{ 'COMPONENT.TRADE_MENU.PARTNER_OFFER_TITLE' | transloco }} +

+
+ {{ 'COMPONENT.TRADE_MENU.EMPTY_PARTNER_OFFER' | transloco }} +
+
    +
  • +
    + {{ item.name }} + ×{{ item.count }} +
    +
      +
    • {{ detail }}
    • +
    +
  • +
+
+
+
+ {{ 'COMPONENT.TRADE_MENU.COUNTDOWN' | transloco : { seconds: formatCountdown(session.countdownMs) } }} +
+
+ +
{{ 'COMPONENT.TRADE_MENU.NO_ACTIVE_TRADE' | transloco }}
+ diff --git a/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.scss b/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.scss new file mode 100644 index 0000000000..488f5867a4 --- /dev/null +++ b/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.scss @@ -0,0 +1,78 @@ +@import 'env'; + +:host { display: block; width: 100%; height: 100%; min-width: 0; max-width: 100%; pointer-events: auto; } +.trade-popup { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: $-size-gap; + width: 100%; + height: 100%; + min-width: 0; + max-width: 100%; + padding: $-size-gap; + overflow: hidden; + background: $-color-background; + font-family: $-font-default; + font-size: 1rem; +} +.trade-popup > * { min-width: 0; max-width: 100%; } +.trade-header { display: grid; grid-template-columns: minmax(11rem, 18%) minmax(0, 1fr); gap: $-size-gap; flex: 0 0 auto; } +.title { font-size: 1.25rem; margin: 0 0 .6rem; } +.status { display: flex; flex-direction: column; gap: .25rem; } +.status span[data-ready='true'] { color: #a8d59a; } +.section-title { font-size: 1.1rem; margin: 0 0 .5rem; } +.inventory-heading { min-width: 0; } +.inventory-title-row { display: flex; flex-wrap: wrap; align-items: center; gap: $-size-gap; margin-bottom: .5rem; } +.inventory-title-row .section-title { margin: 0; flex: 0 0 auto; } +.inventory-search { flex: 1 1 auto; min-width: 0; max-width: 24rem; padding: .25rem .5rem; font-size: 1rem; } +.category-filters { display: flex; flex-wrap: nowrap; gap: .2rem; overflow-x: auto; padding-bottom: .3rem; } +.category-filters button { flex: 0 0 auto; white-space: nowrap; text-transform: uppercase; font-size: 1rem; padding: .25rem .4rem; border: 1px solid #555; } +.category-filters button.selected { background: #555; border-color: #ccc; opacity: 1; } +.category-filters button:focus-visible { outline: 2px solid #ccc; outline-offset: -2px; } +.inventory-list { flex: 1 1 auto; min-height: 0; overflow-y: scroll; scrollbar-gutter: stable; } +.inventory-list { --quantity-step-width: 2rem; --quantity-gap: .35rem; --inventory-cell-padding: .55rem; } +table { width: 100%; table-layout: fixed; border-collapse: collapse; } +th.available-column { width: 8rem; } +th.offered-heading { width: 18rem; } +td:first-child { overflow-wrap: anywhere; } +th { position: sticky; top: 0; background: $-color-background; text-align: left; z-index: 1; } +th, td { padding: var(--inventory-cell-padding); border-bottom: 1px solid #555; } +.offered-heading { padding-left: calc(var(--inventory-cell-padding) + var(--quantity-step-width) + var(--quantity-gap)); } +.available-column { text-align: center; padding-right: 2rem; } +button { font-size: 1rem; padding: .35rem .6rem; } +button:disabled, input:disabled { opacity: .45; } +.numeric, .count { text-align: right; } +.offer-editor { + display: grid; + grid-template-columns: var(--quantity-step-width) 4rem var(--quantity-step-width) max-content max-content; + gap: var(--quantity-gap); + justify-content: start; + align-items: center; + white-space: nowrap; +} +.offer-editor button { min-width: 0; } +.quick-actions, .line { display: flex; gap: .35rem; align-items: center; } +.offer-editor input { box-sizing: border-box; width: 100%; min-width: 0; font-size: 1rem; } +.quick-actions { margin-top: .35rem; } +.offers-section { display: grid; grid-template-columns: minmax(0, 1fr) minmax(8rem, 18%) minmax(0, 1fr); gap: $-size-gap; height: clamp(8rem, 21vh, 14rem); flex: 0 0 auto; } +.offer-column { min-width: 0; min-height: 0; overflow-y: scroll; scrollbar-gutter: stable; padding: .65rem; border: 1px solid #555; overflow-wrap: anywhere; } +.offer-column .section-title { position: sticky; top: -.65rem; padding: .35rem 0; background: $-color-background; } +.offer-column > ul { margin: 0; padding: 0; list-style: none; } +.offer-column > ul > li { margin-bottom: .35rem; } +.actions { display: flex; flex-direction: column; justify-content: center; gap: $-size-gap; } +.actions button { font-size: 1.1rem; opacity: 1; } +.line { justify-content: space-between; } +.line .name { min-width: 0; overflow-wrap: anywhere; } +.line .count { flex-shrink: 0; } +.item-details { padding-left: 1rem; margin: .2rem 0; font-size: 1rem; color: #bbb; } +.quest-warning { color: #d8b88a; font-size: 1rem; } +.empty, .no-trade { padding: .5rem; color: #aaa; } +.bar { height: .3rem; background: #555; margin-top: .35rem; } +.fill { height: 100%; background: #a8d59a; transition: width $-time-transition linear; } +.countdown { flex: 0 0 auto; } +.inventory-list::-webkit-scrollbar, .offer-column::-webkit-scrollbar { width: var(--scrollbar-thickness, .7rem); } +@media (max-width: 800px) { + .trade-header { grid-template-columns: 1fr; } + .offers-section { gap: .5rem; grid-template-columns: minmax(0, 1fr) 7rem minmax(0, 1fr); } +} diff --git a/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.ts b/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.ts new file mode 100644 index 0000000000..e0e0c59e73 --- /dev/null +++ b/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.ts @@ -0,0 +1,65 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + TradeInventoryItemView, + TradeOfferItemView, + TradeSessionView, + TradeUiService, +} from '../../services/trade-ui.service'; + +@Component({ + selector: 'app-trade-popup', + templateUrl: './trade-popup.component.html', + styleUrls: ['./trade-popup.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TradePopupComponent { + public readonly categories = this.tradeUiService.categories; + public readonly category$ = this.tradeUiService.category$; + public readonly visibleInventory$ = this.tradeUiService.visibleInventory$; + public readonly search$ = this.tradeUiService.search$; + public readonly session$: Observable = + this.tradeUiService.session$; + + constructor(public readonly tradeUiService: TradeUiService) {} + + public cancelTrade(): void { + this.tradeUiService.cancelTrade(); + } + + public toggleReady(session: TradeSessionView): void { + this.tradeUiService.setReady(!session.selfReady); + } + + public updateOfferFromInput( + item: TradeInventoryItemView, + value: string | number, + ): void { + this.tradeUiService.updateOfferFromInput(item.index, value); + } + + public addToOffer(item: TradeInventoryItemView, delta: number): void { + this.tradeUiService.addToOffer(item.index, delta); + } + + public offerAll(item: TradeInventoryItemView): void { + this.tradeUiService.offerAll(item.index); + } + + public clearOffer(item: TradeInventoryItemView): void { + this.tradeUiService.clearOffer(item.index); + } + + public trackByInventory(_: number, item: TradeInventoryItemView): number { + return item.index; + } + + public trackByOffer(_: number, item: TradeOfferItemView): string { + return item.key ?? `${item.modId}:${item.baseId}:${item.index ?? -1}`; + } + + public formatCountdown(ms: number): string { + if (ms <= 0) return ''; + return (ms / 1000).toFixed(1); + } +} diff --git a/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts b/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts index c103c0d3b4..7c217cf6b9 100644 --- a/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts +++ b/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts @@ -197,6 +197,51 @@ export class SkyrimtogetherMock extends EventEmitter implements SkyrimTogether { ); } + sendTradeInvite(playerId: number): void { + if (!this.connected) { + return; + } + if (this.showEvents) { + console.log('[mock] sendTradeInvite', playerId); + } + } + + respondTradeInvite(playerId: number, accept: boolean): void { + if (!this.connected) { + return; + } + if (this.showEvents) { + console.log('[mock] respondTradeInvite', playerId, accept); + } + } + + cancelTrade(): void { + if (!this.connected) { + return; + } + if (this.showEvents) { + console.log('[mock] cancelTrade'); + } + } + + setTradeReady(ready: boolean): void { + if (!this.connected) { + return; + } + if (this.showEvents) { + console.log('[mock] setTradeReady', ready); + } + } + + updateTradeOffer(entries: SkyrimTogetherTypes.TradeOfferEntry[]): void { + if (!this.connected) { + return; + } + if (this.showEvents) { + console.log('[mock] updateTradeOffer', entries); + } + } + initMock() { Object.keys((this as any)._events).forEach(e => { this.on(e, (...params) => { diff --git a/Code/skyrim_ui/src/app/models/trade-category.ts b/Code/skyrim_ui/src/app/models/trade-category.ts new file mode 100644 index 0000000000..997060f785 --- /dev/null +++ b/Code/skyrim_ui/src/app/models/trade-category.ts @@ -0,0 +1,21 @@ +export const tradeCategories = [ + 'all', 'weapons', 'armor', 'potions', 'scrolls', + 'food', 'ingredients', 'books', 'keys', 'misc', +] as const; + +export type TradeCategory = typeof tradeCategories[number]; +const tradeItemCategories = [...tradeCategories.filter(category => category !== 'all'), 'ammunition', 'poisons', 'soul_gems'] as const; +export type TradeItemCategory = Exclude | 'ammunition' | 'poisons' | 'soul_gems'; + +export function normalizeTradeCategory(value: unknown): TradeItemCategory { + return tradeItemCategories.includes(value as TradeItemCategory) + ? value as TradeItemCategory : 'misc'; +} + +export function matchesTradeCategory(item: TradeItemCategory, filter: TradeCategory): boolean { + if (filter === 'all' || filter === item) return true; + // The broad Skyrim categories retain their normal inventory grouping. + return (filter === 'weapons' && item === 'ammunition') + || (filter === 'potions' && item === 'poisons') + || (filter === 'misc' && item === 'soul_gems'); +} diff --git a/Code/skyrim_ui/src/app/models/view.enum.ts b/Code/skyrim_ui/src/app/models/view.enum.ts index d9d0e04654..aee49ef781 100644 --- a/Code/skyrim_ui/src/app/models/view.enum.ts +++ b/Code/skyrim_ui/src/app/models/view.enum.ts @@ -6,4 +6,5 @@ export enum View { SERVER_LIST, SETTINGS, PLAYER_MANAGER, + TRADE, } diff --git a/Code/skyrim_ui/src/app/services/client.service.ts b/Code/skyrim_ui/src/app/services/client.service.ts index abe61afd62..8aea46cb6d 100644 --- a/Code/skyrim_ui/src/app/services/client.service.ts +++ b/Code/skyrim_ui/src/app/services/client.service.ts @@ -8,6 +8,49 @@ import { Player } from '../models/player'; import { ChatService } from './chat.service'; import { ErrorEvents, ErrorService } from './error.service'; import { LoadingService } from './loading.service'; +import { normalizeTradeCategory, TradeItemCategory } from '../models/trade-category'; + +export interface TradeItemPayload { + category?: TradeItemCategory; + modId: number; + baseId: number; + count: number; + isQuestItem: boolean; + isEquipped?: boolean; + isUnsupportedTemporary?: boolean; + /** Non-playable or nameless (skins, transformation gear, dummies). */ + isUntradable?: boolean; + name: string; + customNames?: string[]; + inventoryIndex?: number; + offeredCount?: number; + isGold?: boolean; + details?: string[]; +} + +export interface TradeStatePayload { + active: boolean; + partnerId: number; + initiatedBySelf: boolean; + selfReady: boolean; + partnerReady: boolean; + selfItems: TradeItemPayload[]; + partnerItems: TradeItemPayload[]; + inventory: TradeItemPayload[]; + countdownMs: number; + countdownTotalMs: number; +} + +export interface TradeInvitePayload { + inviterId: number; + expiryTick: number; +} + +export interface TradeCancellationPayload { + partnerId: number; + reason: number; + wasInitiator: boolean; +} /** Client game service. */ @Injectable({ @@ -58,6 +101,23 @@ export class ClientService implements OnDestroy { /** Local quest started or updated while not in a party. */ public questUpdatedChange = new Subject(); + /** Trade invite received. */ + public tradeInviteChange = new Subject(); + + /** Trade invite expired. */ + public tradeInviteExpiredChange = new Subject(); + + /** Active trade state change. */ + public tradeStateChange = new BehaviorSubject( + undefined, + ); + + /** Trade cancellation notifications. */ + public tradeCancelledChange = new Subject(); + + /** Trade completion notifications. */ + public tradeCompletedChange = new Subject(); + /** Disconnect player to server change. */ public playerDisconnectedChange = new Subject(); @@ -154,6 +214,17 @@ export class ClientService implements OnDestroy { 'partyInviteReceived', this.onPartyInviteReceived.bind(this), ); + skyrimtogether.on( + 'tradeInviteReceived', + this.onTradeInviteReceived.bind(this), + ); + skyrimtogether.on( + 'tradeInviteExpired', + this.onTradeInviteExpired.bind(this), + ); + skyrimtogether.on('tradeStateUpdated', this.onTradeStateUpdated.bind(this)); + skyrimtogether.on('tradeCancelled', this.onTradeCancelled.bind(this)); + skyrimtogether.on('tradeCompleted', this.onTradeCompleted.bind(this)); skyrimtogether.on('questUpdated', this.onQuestUpdated.bind(this)); } @@ -188,6 +259,11 @@ export class ClientService implements OnDestroy { skyrimtogether.off('partyCreated'); skyrimtogether.off('partyLeft'); skyrimtogether.off('partyInviteReceived'); + skyrimtogether.off('tradeInviteReceived'); + skyrimtogether.off('tradeInviteExpired'); + skyrimtogether.off('tradeStateUpdated'); + skyrimtogether.off('tradeCancelled'); + skyrimtogether.off('tradeCompleted'); skyrimtogether.off('questUpdated'); } @@ -266,6 +342,31 @@ export class ClientService implements OnDestroy { /** * Open the play guide on the wiki in the default browser. */ + /** Send a trade invite to another player. */ + public sendTradeInvite(playerId: number): void { + skyrimtogether.sendTradeInvite(playerId); + } + + /** Respond to an incoming trade invite. */ + public respondTradeInvite(playerId: number, accept: boolean): void { + skyrimtogether.respondTradeInvite(playerId, accept); + } + + /** Cancel the current trade session or outstanding invite. */ + public cancelTrade(): void { + skyrimtogether.cancelTrade(); + } + + /** Toggle readiness state in the current trade session. */ + public setTradeReady(ready: boolean): void { + skyrimtogether.setTradeReady(ready); + } + + /** Update the offered items for the current trade session. */ + public updateTradeOffer(entries: { index: number; count: number }[]): void { + skyrimtogether.updateTradeOffer(entries); + } + public openPlayGuide(): void { skyrimtogether.openPlayGuide(); } @@ -651,6 +752,177 @@ export class ClientService implements OnDestroy { }); } + private onTradeInviteReceived(inviterId: number, expiryTick: number) { + if (environment.game) { + console.log( + `%conTradeInviteReceived`, + 'background: #FFC107; color: #000; padding: 3px; font-size: 9px;', + inviterId, + expiryTick, + ); + } + this.zone.run(() => { + this.tradeInviteChange.next({ inviterId, expiryTick }); + }); + } + + private onTradeInviteExpired(inviterId: number) { + if (environment.game) { + console.log( + `%conTradeInviteExpired`, + 'background: #FFC107; color: #000; padding: 3px; font-size: 9px;', + inviterId, + ); + } + this.zone.run(() => { + this.tradeInviteExpiredChange.next(inviterId); + }); + } + + private onTradeStateUpdated( + active: boolean, + partnerId: number, + initiatedBySelf: boolean, + selfReady: boolean, + partnerReady: boolean, + selfItems: unknown[], + partnerItems: unknown[], + inventory: unknown[], + countdownMs: number, + countdownTotalMs: number, + ) { + if (environment.game) { + console.log( + `%conTradeStateUpdated`, + 'background: #FFC107; color: #000; padding: 3px; font-size: 9px;', + active, + partnerId, + initiatedBySelf, + selfReady, + partnerReady, + selfItems, + partnerItems, + inventory, + countdownMs, + countdownTotalMs, + ); + } + + const normalized: TradeStatePayload = { + active: Boolean(active), + partnerId: Number.isFinite(partnerId) ? partnerId : 0, + initiatedBySelf: Boolean(initiatedBySelf), + selfReady: Boolean(selfReady), + partnerReady: Boolean(partnerReady), + selfItems: Array.isArray(selfItems) + ? selfItems.map(item => this.normalizeTradeItem(item)) + : [], + partnerItems: Array.isArray(partnerItems) + ? partnerItems.map(item => this.normalizeTradeItem(item)) + : [], + inventory: Array.isArray(inventory) + ? inventory.map(item => this.normalizeTradeItem(item)) + : [], + countdownMs: Number.isFinite(countdownMs) ? Number(countdownMs) : 0, + countdownTotalMs: Number.isFinite(countdownTotalMs) + ? Number(countdownTotalMs) + : 0, + }; + + this.zone.run(() => { + this.tradeStateChange.next(normalized); + }); + } + + private onTradeCancelled( + partnerId: number, + reason: number, + wasInitiator: boolean, + ) { + if (environment.game) { + console.log( + `%conTradeCancelled`, + 'background: #FFC107; color: #000; padding: 3px; font-size: 9px;', + partnerId, + reason, + wasInitiator, + ); + } + this.zone.run(() => { + this.tradeCancelledChange.next({ partnerId, reason, wasInitiator }); + }); + } + + private onTradeCompleted(partnerId: number) { + if (environment.game) { + console.log( + `%conTradeCompleted`, + 'background: #FFC107; color: #000; padding: 3px; font-size: 9px;', + partnerId, + ); + } + this.zone.run(() => { + this.tradeCompletedChange.next(partnerId); + }); + } + + private normalizeTradeItem(payload: any): TradeItemPayload { + const modId = Number.isFinite(payload?.modId) ? Number(payload.modId) : 0; + const baseId = Number.isFinite(payload?.baseId) + ? Number(payload.baseId) + : 0; + const count = Math.max( + 0, + Number.isFinite(payload?.count) ? Number(payload.count) : 0, + ); + const isQuestItem = Boolean(payload?.isQuestItem); + const fallbackName = `0x${modId.toString(16).padStart(8, '0')}:0x${baseId + .toString(16) + .padStart(8, '0')}`; + const name = + typeof payload?.name === 'string' && payload.name.length > 0 + ? payload.name + : fallbackName; + + const item: TradeItemPayload = { + category: normalizeTradeCategory(payload?.category), + modId, + baseId, + count, + isQuestItem, + isEquipped: Boolean(payload?.isEquipped), + isUnsupportedTemporary: Boolean(payload?.isUnsupportedTemporary), + isUntradable: Boolean(payload?.isUntradable), + name, + }; + + if (payload?.inventoryIndex !== undefined) { + item.inventoryIndex = Number(payload.inventoryIndex); + } + if (payload?.offeredCount !== undefined) { + item.offeredCount = Math.max( + 0, + Number.isFinite(payload.offeredCount) + ? Number(payload.offeredCount) + : 0, + ); + } + if (payload?.isGold !== undefined) { + item.isGold = Boolean(payload.isGold); + } + if (Array.isArray(payload?.details)) { + item.details = payload.details.map((entry: any) => String(entry)); + } + if (Array.isArray(payload?.customNames)) { + item.customNames = payload.customNames.filter((name: unknown): name is string => typeof name === 'string' && name.trim().length > 0); + } + if (item.isGold === undefined) { + item.isGold = modId === 0 && baseId === 0x0000000f; + } + + return item; + } + private onQuestUpdated() { this.zone.run(() => { this.questUpdatedChange.next(); diff --git a/Code/skyrim_ui/src/app/services/popup-notification.service.ts b/Code/skyrim_ui/src/app/services/popup-notification.service.ts index 7e8803d3bc..a067fe6e4c 100644 --- a/Code/skyrim_ui/src/app/services/popup-notification.service.ts +++ b/Code/skyrim_ui/src/app/services/popup-notification.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { faHandshakeSimple, faUserGroup, + faScaleBalanced, } from '@fortawesome/free-solid-svg-icons'; import { Subject } from 'rxjs'; import { PopupNotification } from '../models/popup-notification'; @@ -38,6 +39,62 @@ export class PopupNotificationService { }); } + public addTradeInvite( + from: string, + acceptCallback: () => void, + declineCallback: () => void, + ) { + this.addMessage({ + messageKey: 'SERVICE.PLAYER_LIST.TRADE_INVITE', + messageParams: { from }, + icon: faScaleBalanced, + duration: 30000, + actions: [ + { + nameKey: 'COMPONENT.NOTIFICATIONS.ACCEPT', + callback: acceptCallback, + }, + { + nameKey: 'COMPONENT.NOTIFICATIONS.DECLINE', + callback: declineCallback, + }, + ], + }); + } + + public addTradeInviteSent(target: string, cancelCallback: () => void): void { + this.addMessage({ + messageKey: 'SERVICE.TRADE.INVITE_SENT', + messageParams: { target }, + icon: faScaleBalanced, + duration: 10000, + actions: [ + { + nameKey: 'COMPONENT.NOTIFICATIONS.CANCEL', + callback: cancelCallback, + }, + ], + }); + } + + public addTradeCancelled(partner: string, reason: string) { + this.addMessage({ + messageKey: 'SERVICE.TRADE.CANCELLED', + messageParams: { partner, reason }, + icon: faScaleBalanced, + duration: 8000, + }); + } + + public addTradeCompleted(partner: string) { + this.addMessage({ + messageKey: 'SERVICE.TRADE.COMPLETED', + messageParams: { partner }, + icon: faScaleBalanced, + duration: 8000, + }); + } + public addPartyHint(onOpenGuide: () => void, onDontShow: () => void) { this.addMessage({ messageKey: 'SERVICE.PARTY_HINT.MESSAGE', diff --git a/Code/skyrim_ui/src/app/services/trade-invite-policy.ts b/Code/skyrim_ui/src/app/services/trade-invite-policy.ts new file mode 100644 index 0000000000..40bb2eaf17 --- /dev/null +++ b/Code/skyrim_ui/src/app/services/trade-invite-policy.ts @@ -0,0 +1,15 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Lets player privacy settings decide who may invite you to trade and which + * name trade popups and the trade window show. Without a provider every + * invite is allowed and real names are shown. + */ +export interface TradeInvitePolicy { + acceptsTradeInvite(playerId: number): boolean; + displayName(playerId: number, name: string): string; +} + +export const TRADE_INVITE_POLICY = new InjectionToken( + 'TradeInvitePolicy', +); diff --git a/Code/skyrim_ui/src/app/services/trade-ui.service.ts b/Code/skyrim_ui/src/app/services/trade-ui.service.ts new file mode 100644 index 0000000000..d70a918b64 --- /dev/null +++ b/Code/skyrim_ui/src/app/services/trade-ui.service.ts @@ -0,0 +1,274 @@ +import { Inject, Injectable, OnDestroy, Optional } from '@angular/core'; +import { BehaviorSubject, combineLatest, map, Subscription } from 'rxjs'; +import { TranslocoService } from '@ngneat/transloco'; +import { ClientService, TradeItemPayload, TradeStatePayload } from './client.service'; +import { PlayerListService } from './player-list.service'; +import { PopupNotificationService } from './popup-notification.service'; +import { TRADE_INVITE_POLICY, TradeInvitePolicy } from './trade-invite-policy'; +import { UiRepository } from '../store/ui.repository'; +import { View } from '../models/view.enum'; +import { matchesTradeCategory, normalizeTradeCategory, tradeCategories, TradeCategory, TradeItemCategory } from '../models/trade-category'; + +function isExcludedTradeItem(item: TradeItemPayload): boolean { + return ( + item.isQuestItem || + item.isUnsupportedTemporary === true || + item.isUntradable === true || + (item.modId === 0 && item.baseId === 0x1f4) + ); +} + +export interface TradeInventoryItemView { + baseName: string; + category: TradeItemCategory; + index: number; + name: string; + modId: number; + baseId: number; + available: number; + offered: number; + isQuestItem: boolean; + isEquipped: boolean; + isGold: boolean; + details: string[]; + key: string; +} + +export interface TradeOfferItemView { + index?: number; + name: string; + modId: number; + baseId: number; + count: number; + isQuestItem: boolean; + details: string[]; + key: string; +} + +export interface TradeSessionView { + active: boolean; + partnerId: number; + partnerName: string; + initiatedBySelf: boolean; + selfReady: boolean; + partnerReady: boolean; + selfOffer: TradeOfferItemView[]; + partnerOffer: TradeOfferItemView[]; + inventory: TradeInventoryItemView[]; + countdownMs: number; + countdownTotalMs: number; + countdownProgress: number; +} + +@Injectable({ providedIn: 'root' }) +export class TradeUiService implements OnDestroy { + private readonly subscriptions = new Subscription(); + private readonly sessionSubject = new BehaviorSubject(undefined); + public readonly session$ = this.sessionSubject.asObservable(); + public readonly categories = tradeCategories; + private readonly categorySubject = new BehaviorSubject('all'); + public readonly category$ = this.categorySubject.asObservable(); + private readonly searchSubject = new BehaviorSubject(''); + public readonly search$ = this.searchSubject.asObservable(); + public readonly visibleInventory$ = combineLatest([this.session$, this.category$, this.search$]).pipe( + map(([session, category, search]) => { + const terms = this.normalizeSearch(search).trim().split(/\s+/u).filter(Boolean); + return (session?.inventory ?? []).filter(item => matchesTradeCategory(item.category, category) + && terms.every(term => this.normalizeSearch(`${item.name} ${item.baseName}`).includes(term))); + }), + ); + + public setSearch(search: string): void { this.searchSubject.next(search); } + + private normalizeSearch(value: string): string { + return value.normalize('NFKD').replace(/\p{M}/gu, '').toLocaleLowerCase() + .replace(/ß/g, 'ss').replace(/ı/g, 'i').replace(/ς/g, 'σ'); + } + + public selectCategory(category: TradeCategory): void { + if (tradeCategories.includes(category)) this.categorySubject.next(category); + } + private readonly outgoing = new BehaviorSubject>(new Set()); + public readonly pendingOutgoing$ = this.outgoing.asObservable(); + private state?: TradeStatePayload; + private previousView: View | null = null; + private readonly incoming = new Set(); + // Last name seen per player id this session, so a partner who left or was + // dropped from the player list still shows by name rather than by id. + private readonly knownNames = new Map(); + // Invites declined by the invite policy; the server echoes the decline back to us. + private readonly silentlyDeclined = new Set(); + + constructor( + private readonly clientService: ClientService, + private readonly playerListService: PlayerListService, + private readonly popupNotificationService: PopupNotificationService, + private readonly transloco: TranslocoService, + private readonly uiRepository: UiRepository, + @Optional() @Inject(TRADE_INVITE_POLICY) private readonly invitePolicy?: TradeInvitePolicy, + ) { + this.subscriptions.add(clientService.tradeStateChange.subscribe(state => this.applyState(state))); + this.subscriptions.add(playerListService.playerList.subscribe(list => { + for (const player of list?.players ?? []) { + if (player.name) this.knownNames.set(player.id, player.name); + } + })); + this.subscriptions.add(clientService.connectionStateChange.subscribe(connected => { + if (!connected) this.reset(); + })); + this.subscriptions.add(clientService.tradeInviteChange.subscribe(invite => { + this.incoming.add(invite.inviterId); + if (this.invitePolicy && !this.invitePolicy.acceptsTradeInvite(invite.inviterId)) { + this.silentlyDeclined.add(invite.inviterId); + this.respond(invite.inviterId, false); + return; + } + this.popupNotificationService.addTradeInvite(this.getDisplayName(invite.inviterId), + () => this.respond(invite.inviterId, true), () => this.respond(invite.inviterId, false)); + })); + this.subscriptions.add(clientService.tradeInviteExpiredChange.subscribe(id => this.incoming.delete(id))); + this.subscriptions.add(clientService.tradeCancelledChange.subscribe(cancelled => { + this.incoming.delete(cancelled.partnerId); + this.removeOutgoing(cancelled.partnerId); + if (this.silentlyDeclined.delete(cancelled.partnerId) && !cancelled.wasInitiator) return; + const reasons = ['DECLINED', 'CANCELLED', 'PARTNER_BUSY', 'SELF_BUSY', 'PLAYER_LEFT', 'TIMEOUT', 'FAILED_VALIDATION']; + this.popupNotificationService.addTradeCancelled(this.getDisplayName(cancelled.partnerId), + this.transloco.translate('SERVICE.TRADE.CANCELLED_REASONS.' + (reasons[cancelled.reason] ?? 'UNKNOWN'))); + if (this.state?.partnerId === cancelled.partnerId) this.applyState(undefined); + })); + this.subscriptions.add(clientService.tradeCompletedChange.subscribe(id => { + this.removeOutgoing(id); + this.popupNotificationService.addTradeCompleted(this.getDisplayName(id)); + if (this.state?.partnerId === id) this.applyState(undefined); + })); + this.subscriptions.add(playerListService.playerList.subscribe(() => { + if (this.state?.active) this.sessionSubject.next(this.toView(this.state)); + })); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + this.reset(); + } + + public sendInvite(id: number): void { + if (id === this.clientService.localPlayerId || this.state?.active || this.outgoing.value.size) return; + this.outgoing.next(new Set([id])); + this.clientService.sendTradeInvite(id); + this.popupNotificationService.addTradeInviteSent(this.getDisplayName(id), () => this.cancelInvite(id)); + } + + public cancelInvite(id: number): void { + if (!this.outgoing.value.has(id)) return; + this.clientService.cancelTrade(); + this.removeOutgoing(id); + } + + private respond(id: number, accept: boolean): void { + // Expired popups may still be visible, but their actions must not send stale responses. + if (!this.incoming.delete(id)) return; + this.clientService.respondTradeInvite(id, accept); + } + + public cancelTrade(): void { + if (this.state?.active) this.clientService.cancelTrade(); + this.applyState(undefined); + } + + public setReady(ready: boolean): void { + if (this.state?.active) this.clientService.setTradeReady(ready); + } + + public updateOfferFromInput(index: number, value: string | number): void { + this.setOfferCount(index, Number(value)); + } + + public addToOffer(index: number, delta: number): void { + const item = this.sessionSubject.value?.inventory.find(item => item.index === index); + if (item) this.setOfferCount(index, item.offered + delta); + } + + public offerAll(index: number): void { + const item = this.sessionSubject.value?.inventory.find(item => item.index === index); + if (item) this.setOfferCount(index, item.available); + } + + public clearOffer(index: number): void { this.setOfferCount(index, 0); } + + private setOfferCount(index: number, value: number): void { + const state = this.state; + const item = state?.inventory.find(item => item.inventoryIndex === index); + if (!state?.active || !item || isExcludedTradeItem(item) || item.isEquipped || !Number.isFinite(value)) return; + const count = Math.max(0, Math.min(item.count, Math.floor(value))); + const entries = state.inventory.filter(entry => !isExcludedTradeItem(entry)).map(entry => ({ + index: entry.inventoryIndex!, + count: entry.inventoryIndex === index ? count : entry.offeredCount ?? 0, + })).filter(entry => Number.isInteger(entry.index) && entry.count > 0); + this.clientService.updateTradeOffer(entries); + } + + public getDisplayName(id: number): string { + const name = this.playerListService.playerList.value?.players.find(player => player.id === id)?.name + ?? this.knownNames.get(id); + if (name === undefined) return this.transloco.translate('SERVICE.TRADE.UNKNOWN_PLAYER'); + return this.invitePolicy ? this.invitePolicy.displayName(id, name) : name; + } + + private removeOutgoing(id: number): void { + const next = new Set(this.outgoing.value); + next.delete(id); + this.outgoing.next(next); + } + + private reset(): void { + this.incoming.clear(); + this.knownNames.clear(); + this.silentlyDeclined.clear(); + this.outgoing.next(new Set()); + this.applyState(undefined); + } + + private applyState(state?: TradeStatePayload): void { + const startingSession = state?.active && (!this.state?.active || this.state.partnerId !== state.partnerId); + this.state = state?.active ? state : undefined; + if (!this.state) { + this.categorySubject.next('all'); + this.searchSubject.next(''); + this.sessionSubject.next(undefined); + if (this.uiRepository.getView() === View.TRADE) this.uiRepository.openView(this.previousView); + this.previousView = null; + return; + } + this.incoming.delete(this.state.partnerId); + this.removeOutgoing(this.state.partnerId); + if (startingSession && this.uiRepository.getView() !== View.TRADE) { + this.previousView = this.uiRepository.getView(); + this.uiRepository.openView(View.TRADE); + } + this.sessionSubject.next(this.toView(this.state)); + } + + private toView(state: TradeStatePayload): TradeSessionView { + const offer = (item: TradeItemPayload, position: number): TradeOfferItemView => ({ + index: item.inventoryIndex, name: item.name, modId: item.modId, baseId: item.baseId, + count: item.count, isQuestItem: item.isQuestItem, details: item.details ?? [], + key: `${item.modId}:${item.baseId}:${position}`, + }); + return { + active: true, partnerId: state.partnerId, partnerName: this.getDisplayName(state.partnerId), + initiatedBySelf: state.initiatedBySelf, selfReady: state.selfReady, partnerReady: state.partnerReady, + selfOffer: state.selfItems.map(offer), partnerOffer: state.partnerItems.map(offer), + inventory: state.inventory.filter(item => Number.isInteger(item.inventoryIndex) && !isExcludedTradeItem(item)).map((item, position) => ({ + ...offer(item, position), index: item.inventoryIndex!, available: item.count, + baseName: item.name, + name: item.customNames?.length ? item.customNames.join(' / ') : item.name, + category: normalizeTradeCategory(item.category), + isEquipped: item.isEquipped ?? false, + offered: item.offeredCount ?? 0, isGold: item.isGold ?? false, + })).sort((a, b) => a.name.localeCompare(b.name)), + countdownMs: state.countdownMs, countdownTotalMs: state.countdownTotalMs, + countdownProgress: state.countdownTotalMs > 0 + ? Math.max(0, Math.min(1, 1 - state.countdownMs / state.countdownTotalMs)) : 0, + }; + } +} diff --git a/Code/skyrim_ui/src/assets/i18n/en.json b/Code/skyrim_ui/src/assets/i18n/en.json index 8e7ad03240..9d81f1edad 100644 --- a/Code/skyrim_ui/src/assets/i18n/en.json +++ b/Code/skyrim_ui/src/assets/i18n/en.json @@ -39,7 +39,8 @@ "DECLINE": "Decline", "OPEN_GUIDE": "Open guide", "DISMISS": "Dismiss", - "DONT_SHOW": "Don't show" + "DONT_SHOW": "Don't show", + "CANCEL": "Cancel" }, "PARTY_MENU": { "ACCEPT_INVITE": "Accept invite from {{name}}", @@ -78,7 +79,9 @@ "NAME": "Name", "LOCATION": "Location" }, - "INVITE": "Invite" + "INVITE": "Invite", + "TRADE": "Trade", + "CANCEL_TRADE_INVITE": "Cancel trade invite" }, "PLAYER_MANAGER": { "PLAYER_LIST": "Player List", @@ -185,6 +188,72 @@ "XL": "Extra Large" }, "BACK": "Back" + }, + "TRADE_MENU": { + "SEARCH_ITEMS": "Search items…", + "NO_MATCHING_ITEMS": "No items match your search and category.", + "FILTER_CATEGORY": "Filter inventory by item category", + "NO_CATEGORY_ITEMS": "No items in this category.", + "CATEGORIES": { + "all": "All", + "weapons": "Weapons", + "armor": "Apparel", + "ammunition": "Ammunition", + "potions": "Potions", + "poisons": "Poisons", + "scrolls": "Scrolls", + "food": "Food", + "ingredients": "Ingredients", + "books": "Books", + "keys": "Keys", + "soul_gems": "Soul Gems", + "misc": "Misc" + }, + "EQUIPPED_ITEM_WARNING": "Unequip this item before trading it.", + "INVITES_TITLE": "Trade invitations", + "NO_INVITES": "No pending trade invitations.", + "INVITE_ACTIONS": { + "ACCEPT": "Accept", + "DECLINE": "Decline" + }, + "TRADING_WITH": "Trading with {{name}}", + "COUNTDOWN": "Finalizing in {{seconds}}s", + "SELF_READY": "You are ready", + "SELF_NOT_READY": "You are not ready", + "PARTNER_READY": "{{name}} is ready", + "PARTNER_NOT_READY": "{{name}} is not ready", + "ACTIONS": { + "READY": "Ready up", + "UNREADY": "Cancel ready", + "CANCEL_TRADE": "Cancel trade", + "CLEAR": "Clear", + "OFFER_ALL": "Offer all", + "MAX": "Max", + "CLOSE": "Close window" + }, + "INVENTORY_TITLE": "Your inventory", + "NO_INVENTORY": "No tradable items available.", + "ITEM_COLUMNS": { + "NAME": "Item", + "AVAILABLE": "Available", + "OFFERED": "Offered" + }, + "QUEST_ITEM_WARNING": "Quest items cannot be traded.", + "OFFER_TITLE": "Your offer", + "EMPTY_OFFER": "You are not offering any items.", + "PARTNER_OFFER_TITLE": "Partner offer", + "EMPTY_PARTNER_OFFER": "The partner is not offering any items.", + "NO_ACTIVE_TRADE": "No active trade session.", + "DETAILS": { + "QUEST_ITEM": "Quest item", + "ENCHANTED": "Enchanted", + "ENCHANT_REMOVE": "Removes enchant on unequip", + "CHARGE": "Charge: {{value}}", + "POISON": "Poisoned ({{count}})", + "SOUL_LEVEL": "Soul level: {{level}}", + "EXTRA_HEALTH": "Refined (+{{value}})", + "CUSTOM_NAME": "Custom item" + } } }, "SERVICE": { @@ -219,10 +288,27 @@ } }, "PLAYER_LIST": { - "PARTY_INVITE": "{{from}} invited you to join a party" + "PARTY_INVITE": "{{from}} invited you to join a party", + "TRADE_INVITE": "{{from}} wants to trade with you." }, "PARTY_HINT": { "MESSAGE": "Want to quest together? Form a party and stay close to share quest progress! Check out our play guide to learn more." + }, + "TRADE": { + "UNKNOWN_PLAYER": "Unknown player", + "CANCELLED": "{{partner}} cancelled the trade ({{reason}}).", + "COMPLETED": "Trade completed with {{partner}}.", + "INVITE_SENT": "Trade invite sent to {{target}}.", + "CANCELLED_REASONS": { + "DECLINED": "Invite declined", + "CANCELLED": "Trade cancelled", + "PARTNER_BUSY": "Partner is busy", + "SELF_BUSY": "You are busy", + "PLAYER_LEFT": "Player left the session", + "TIMEOUT": "Invitation expired", + "FAILED_VALIDATION": "Trade failed validation", + "UNKNOWN": "Unknown reason" + } } } } diff --git a/Code/skyrim_ui/src/assets/i18n/sv.json b/Code/skyrim_ui/src/assets/i18n/sv.json index e235a8563e..ec54264be2 100644 --- a/Code/skyrim_ui/src/assets/i18n/sv.json +++ b/Code/skyrim_ui/src/assets/i18n/sv.json @@ -39,7 +39,8 @@ "DECLINE": "Neka", "OPEN_GUIDE": "Öppna guide", "DISMISS": "Ignorera", - "DONT_SHOW": "Sluta visa" + "DONT_SHOW": "Sluta visa", + "CANCEL": "Avbryt" }, "PARTY_MENU": { "ACCEPT_INVITE": "Acceptera inbjudan från {{name}}", @@ -78,7 +79,9 @@ "NAME": "Namn", "LOCATION": "Plats" }, - "INVITE": "Bjud in" + "INVITE": "Bjud in", + "TRADE": "Byte", + "CANCEL_TRADE_INVITE": "Avbryt bytesinbjudan" }, "PLAYER_MANAGER": { "PLAYER_LIST": "Spelarlista", @@ -184,6 +187,72 @@ "XL": "Störst" }, "BACK": "Tillbaka" + }, + "TRADE_MENU": { + "SEARCH_ITEMS": "Sök efter…", + "NO_MATCHING_ITEMS": "Inga föremål matchar din sökning och kategori.", + "FILTER_CATEGORY": "Filtrera via föremålskategorier", + "NO_CATEGORY_ITEMS": "Inga föremål i kategorin.", + "CATEGORIES": { + "all": "Alla", + "weapons": "Vapen", + "armor": "Utrustning", + "ammunition": "Ammunition", + "potions": "Mixturer", + "poisons": "Gifter", + "scrolls": "Pergament", + "food": "Mat", + "ingredients": "Ingredienser", + "books": "Böcker", + "keys": "Nycklar", + "soul_gems": "Själstenar", + "misc": "Annat" + }, + "EQUIPPED_ITEM_WARNING": "Ta av dig föremålet innan du byter det.", + "INVITES_TITLE": "Bytesinbjudningar", + "NO_INVITES": "Inga väntande bytesinbjudningar.", + "INVITE_ACTIONS": { + "ACCEPT": "Acceptera", + "DECLINE": "Neka" + }, + "TRADING_WITH": "Byter med {{name}}", + "COUNTDOWN": "Slutförs om {{seconds}}s", + "SELF_READY": "Du är redo", + "SELF_NOT_READY": "Du är inte redo", + "PARTNER_READY": "{{name}} är redo", + "PARTNER_NOT_READY": "{{name}} är inte redo", + "ACTIONS": { + "READY": "Redo", + "UNREADY": "Inte redo", + "CANCEL_TRADE": "Avbryt", + "CLEAR": "Rensa", + "OFFER_ALL": "Erbjud alla", + "MAX": "Max", + "CLOSE": "Stäng fönster" + }, + "INVENTORY_TITLE": "Dina föremål", + "NO_INVENTORY": "Inga föremål som kan bytas.", + "ITEM_COLUMNS": { + "NAME": "Föremål", + "AVAILABLE": "Tillgängliga", + "OFFERED": "Erbjudna" + }, + "QUEST_ITEM_WARNING": "Quest-föremål kan inte bytas.", + "OFFER_TITLE": "Du erbjuder", + "EMPTY_OFFER": "Du erbjuder inga föremål.", + "PARTNER_OFFER_TITLE": "Partner erbjuder", + "EMPTY_PARTNER_OFFER": "Partner erbjuder inga föremål.", + "NO_ACTIVE_TRADE": "Ingen aktiv bytessession.", + "DETAILS": { + "QUEST_ITEM": "Quest-föremål", + "ENCHANTED": "Förtrollad", + "ENCHANT_REMOVE": "Förtrollning bryts om den tas av", + "CHARGE": "Laddning: {{value}}", + "POISON": "Förgiftad ({{count}})", + "SOUL_LEVEL": "Själsnivå: {{level}}", + "EXTRA_HEALTH": "Förbättrad (+{{value}})", + "CUSTOM_NAME": "Omdöpt föremål" + } } }, "SERVICE": { @@ -218,10 +287,27 @@ } }, "PLAYER_LIST": { - "PARTY_INVITE": "{{from}} bjöd in dig till ett party" + "PARTY_INVITE": "{{from}} bjöd in dig till ett party", + "TRADE_INVITE": "{{from}} vill byta med dig." }, "PARTY_HINT": { "MESSAGE": "Vill du göra en quest tillsammans? Gå med i samma party och håll er nära varandra för att dela framsteg! Läs vår playguide för att lära er mer." + }, + "TRADE": { + "UNKNOWN_PLAYER": "Okänd spelare", + "CANCELLED": "{{partner}} avbröt bytet ({{reason}}).", + "COMPLETED": "Bytet slutfördes med {{partner}}.", + "INVITE_SENT": "Bytesinbjudan skickad till {{target}}.", + "CANCELLED_REASONS": { + "DECLINED": "Inbjudan nekades", + "CANCELLED": "Bytet avbröts", + "PARTNER_BUSY": "Partner upptagen", + "SELF_BUSY": "Du är upptagen", + "PLAYER_LEFT": "Spelare lämnade sessionen", + "TIMEOUT": "Inbjudan gick ut", + "FAILED_VALIDATION": "Bytet kunde inte valideras", + "UNKNOWN": "Okänd anledning" + } } } } diff --git a/Code/skyrim_ui/src/typings.d.ts b/Code/skyrim_ui/src/typings.d.ts index 046aa460fd..27d052304e 100644 --- a/Code/skyrim_ui/src/typings.d.ts +++ b/Code/skyrim_ui/src/typings.d.ts @@ -95,6 +95,49 @@ declare namespace SkyrimTogetherTypes { type PartyInviteReceivedCallback = (inviterId: number) => void; + type TradeInviteCallback = (inviterId: number, expiryTick: number) => void; + type TradeInviteExpiredCallback = (inviterId: number) => void; + + interface TradeItemPayload { + isUnsupportedTemporary?: boolean; + customNames?: string[]; + category?: string; + modId: number; + baseId: number; + count: number; + isQuestItem: boolean; + name: string; + inventoryIndex?: number; + offeredCount?: number; + } + + interface TradeInventoryPayload extends TradeItemPayload { + inventoryIndex: number; + offeredCount: number; + } + + type TradeStateUpdatedCallback = ( + active: boolean, + partnerId: number, + initiatedBySelf: boolean, + selfReady: boolean, + partnerReady: boolean, + selfItems: TradeItemPayload[], + partnerItems: TradeItemPayload[], + inventory: TradeInventoryPayload[], + countdownMs: number, + countdownTotalMs: number, + ) => void; + + type TradeCancelledCallback = ( + partnerId: number, + reason: number, + wasInitiator: boolean, + ) => void; + + type TradeCompletedCallback = (partnerId: number) => void; + + type TradeOfferEntry = { index: number; count: number }; type QuestUpdatedCallback = () => void; } @@ -221,6 +264,27 @@ interface SkyrimTogether { callback: SkyrimTogetherTypes.PartyInviteReceivedCallback, ): void; + on( + event: 'tradeInviteReceived', + callback: SkyrimTogetherTypes.TradeInviteCallback, + ): void; + on( + event: 'tradeInviteExpired', + callback: SkyrimTogetherTypes.TradeInviteExpiredCallback, + ): void; + on( + event: 'tradeStateUpdated', + callback: SkyrimTogetherTypes.TradeStateUpdatedCallback, + ): void; + on( + event: 'tradeCancelled', + callback: SkyrimTogetherTypes.TradeCancelledCallback, + ): void; + on( + event: 'tradeCompleted', + callback: SkyrimTogetherTypes.TradeCompletedCallback, + ): void; + on( event: 'questUpdated', callback: SkyrimTogetherTypes.QuestUpdatedCallback, @@ -356,6 +420,27 @@ interface SkyrimTogether { callback?: SkyrimTogetherTypes.PartyInviteReceivedCallback, ): void; + off( + event: 'tradeInviteReceived', + callback?: SkyrimTogetherTypes.TradeInviteCallback, + ): void; + off( + event: 'tradeInviteExpired', + callback?: SkyrimTogetherTypes.TradeInviteExpiredCallback, + ): void; + off( + event: 'tradeStateUpdated', + callback?: SkyrimTogetherTypes.TradeStateUpdatedCallback, + ): void; + off( + event: 'tradeCancelled', + callback?: SkyrimTogetherTypes.TradeCancelledCallback, + ): void; + off( + event: 'tradeCompleted', + callback?: SkyrimTogetherTypes.TradeCompletedCallback, + ): void; + off( event: 'questUpdated', callback?: SkyrimTogetherTypes.QuestUpdatedCallback, @@ -448,6 +533,11 @@ interface SkyrimTogether { /** * Open the play guide on the wiki in the default browser. */ + sendTradeInvite(playerId: number): void; + respondTradeInvite(playerId: number, accept: boolean): void; + cancelTrade(): void; + setTradeReady(ready: boolean): void; + updateTradeOffer(entries: SkyrimTogetherTypes.TradeOfferEntry[]): void; openPlayGuide(): void; /** diff --git a/Code/skyrim_ui/test/trade-ui.test.cjs b/Code/skyrim_ui/test/trade-ui.test.cjs new file mode 100644 index 0000000000..dd8885a175 --- /dev/null +++ b/Code/skyrim_ui/test/trade-ui.test.cjs @@ -0,0 +1,381 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +const { BehaviorSubject, Subject } = require('rxjs'); + +const source = fs.readFileSync(path.join(__dirname, '../src/app/services/trade-ui.service.ts'), 'utf8'); +const output = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, experimentalDecorators: true }, +}).outputText; +const exported = {}; +const categoryExports = {}; +const categorySource = fs.readFileSync(path.join(__dirname, '../src/app/models/trade-category.ts'), 'utf8'); +vm.runInNewContext(ts.transpileModule(categorySource, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText, { exports: categoryExports }); +vm.runInNewContext(output, { + exports: exported, + require(name) { + if (name === '@angular/core') return { Injectable: () => value => value, Inject: () => () => {}, Optional: () => () => {} }; + if (name === 'rxjs') return require(name); + if (name.endsWith('view.enum')) return { View: { TRADE: 'trade' } }; + if (name.endsWith('trade-category')) return categoryExports; + return {}; + }, +}); + +function setup() { + const calls = []; + const client = { + localPlayerId: 1, + tradeStateChange: new BehaviorSubject(undefined), + connectionStateChange: new BehaviorSubject(true), + tradeInviteChange: new Subject(), tradeInviteExpiredChange: new Subject(), + tradeCancelledChange: new Subject(), tradeCompletedChange: new Subject(), + }; + for (const method of ['sendTradeInvite', 'respondTradeInvite', 'cancelTrade', 'setTradeReady', 'updateTradeOffer']) { + client[method] = (...args) => calls.push([method, ...args]); + } + const players = { playerList: new BehaviorSubject({ players: [{ id: 2, name: 'Partner' }] }) }; + const notifications = []; + const popups = Object.fromEntries(['addTradeInvite', 'addTradeInviteSent', 'addTradeCancelled', 'addTradeCompleted'] + .map(method => [method, (...args) => notifications.push([method, ...args])])); + const ui = { + view: 'players', view$: new BehaviorSubject('players'), getView() { return this.view; }, + openView(view) { this.view = view; this.view$.next(view); }, + }; + const policy = { + allowInvites: true, hiddenNames: new Set(), + acceptsTradeInvite() { return this.allowInvites; }, + displayName(id, name) { return this.hiddenNames.has(name) ? 'Stranger' : name; }, + }; + const service = new exported.TradeUiService(client, players, popups, { translate: key => key }, ui, policy); + return { client, calls, service, notifications, ui, policy }; +} + +function state() { + return { + active: true, partnerId: 2, initiatedBySelf: true, selfReady: false, partnerReady: false, + selfItems: [], partnerItems: [], countdownMs: 0, countdownTotalMs: 0, + inventory: [ + { modId: 0, baseId: 1, name: 'Z item', count: 10, isQuestItem: false, inventoryIndex: 7, offeredCount: 2 }, + { modId: 0, baseId: 2, name: 'A item', count: 5, isQuestItem: false, inventoryIndex: 3, offeredCount: 1 }, + ], + }; +} + +test('supported crafted potions are selectable while unsupported temporary forms are excluded', () => { + const fixture = setup(); + const payload = state(); + payload.inventory = [ + { ...payload.inventory[0], modId: 0xffffffff, category: 'potions', name: 'Potion of Healing', isUnsupportedTemporary: false }, + { ...payload.inventory[1], modId: 0xffffffff, isUnsupportedTemporary: true }, + ]; + let visible; + const subscription = fixture.service.visibleInventory$.subscribe(items => visible = items); + fixture.client.tradeStateChange.next(payload); + assert.equal(visible.length, 1); + assert.equal(visible[0].name, 'Potion of Healing'); + fixture.service.updateOfferFromInput(7, 3); + assert.equal(fixture.calls.at(-1)[1].length, 1); + assert.equal(fixture.calls.at(-1)[1][0].index, 7); + subscription.unsubscribe(); + fixture.service.ngOnDestroy(); +}); + +test('custom names are searchable only in inventory while both offers retain base names', () => { + const fixture = setup(); + const payload = state(); + const item = { ...payload.inventory[0], name: 'Iron Sword', customNames: ['Dragon Slayer', 'Épée du héros'] }; + payload.inventory = [item]; + payload.selfItems = [item]; + payload.partnerItems = [item]; + let visible; + let session; + const inventorySubscription = fixture.service.visibleInventory$.subscribe(items => visible = items); + const sessionSubscription = fixture.service.session$.subscribe(value => session = value); + fixture.client.tradeStateChange.next(payload); + assert.equal(visible[0].name, 'Dragon Slayer / Épée du héros'); + assert.equal(visible[0].baseName, 'Iron Sword'); + assert.equal(session.selfOffer[0].name, 'Iron Sword'); + assert.equal(session.partnerOffer[0].name, 'Iron Sword'); + for (const search of ['dragon', 'epee heros', 'iron sword', 'slayer iron']) { + fixture.service.setSearch(search); + assert.equal(visible.length, 1, search); + } + fixture.service.updateOfferFromInput(7, 3); + assert.deepEqual(Object.keys(fixture.calls.at(-1)[1][0]).sort(), ['count', 'index']); + assert.equal(payload.inventory[0].name, 'Iron Sword'); + inventorySubscription.unsubscribe(); + sessionSubscription.unsubscribe(); + fixture.service.ngOnDestroy(); +}); + +test('offers retain native indices after display sorting without mutating payloads', () => { + const fixture = setup(); + const payload = state(); + payload.inventory.forEach(Object.freeze); + Object.freeze(payload.inventory); + Object.freeze(payload); + fixture.client.tradeStateChange.next(payload); + fixture.service.updateOfferFromInput(3, 4); + const entries = fixture.calls.at(-1)[1]; + assert.equal(entries[0].index, 7); + assert.equal(entries[0].count, 2); + assert.equal(entries[1].index, 3); + assert.equal(entries[1].count, 4); + fixture.service.ngOnDestroy(); +}); + +test('disconnect clears outgoing invitations so reconnection can trade', () => { + const fixture = setup(); + fixture.service.sendInvite(2); + fixture.client.connectionStateChange.next(false); + fixture.client.connectionStateChange.next(true); + fixture.service.sendInvite(2); + assert.equal(fixture.calls.filter(call => call[0] === 'sendTradeInvite').length, 2); + fixture.service.ngOnDestroy(); +}); + +test('invites refused by the invite policy are declined without any popup', () => { + const fixture = setup(); + fixture.policy.allowInvites = false; + fixture.client.tradeInviteChange.next({ inviterId: 2, expiryTick: 1000 }); + assert.equal(fixture.notifications.length, 0); + assert.deepEqual(fixture.calls, [['respondTradeInvite', 2, false]]); + // The server echoes the decline back; that must not show the inviter's name either. + fixture.client.tradeCancelledChange.next({ partnerId: 2, reason: 0, wasInitiator: false }); + assert.equal(fixture.notifications.length, 0); + fixture.service.ngOnDestroy(); +}); + +test('trade popups use the name chosen by the invite policy', () => { + const fixture = setup(); + fixture.policy.hiddenNames.add('Partner'); + fixture.client.tradeInviteChange.next({ inviterId: 2, expiryTick: 1000 }); + assert.equal(fixture.notifications.at(-1)[1], 'Stranger'); + fixture.service.ngOnDestroy(); +}); + +test('without an invite policy every invite shows the real name', () => { + const fixture = setup(); + const service = new exported.TradeUiService(fixture.client, { playerList: new (require('rxjs').BehaviorSubject)({ players: [{ id: 3, name: 'Other' }] }) }, + { addTradeInvite: (...args) => fixture.notifications.push(['addTradeInvite', ...args]) }, { translate: key => key }, fixture.ui); + fixture.client.tradeInviteChange.next({ inviterId: 3, expiryTick: 1000 }); + assert.equal(fixture.notifications.at(-1)[1], 'Other'); + service.ngOnDestroy(); + fixture.service.ngOnDestroy(); +}); + +test('expired notification actions cannot respond to an obsolete invitation', () => { + const fixture = setup(); + fixture.client.tradeInviteChange.next({ inviterId: 2, expiryTick: 1000 }); + const accept = fixture.notifications.at(-1)[2]; + fixture.client.tradeInviteExpiredChange.next(2); + accept(); + assert.equal(fixture.calls.length, 0); + fixture.service.ngOnDestroy(); +}); + +test('Cancel Trade explicitly cancels the exchange and restores the previous target view', () => { + const fixture = setup(); + fixture.client.tradeStateChange.next(state()); + assert.equal(fixture.ui.view, 'trade'); + fixture.service.cancelTrade(); + assert.equal(fixture.calls.at(-1)[0], 'cancelTrade'); + assert.equal(fixture.ui.view, 'players'); + fixture.service.ngOnDestroy(); +}); + +test('quest items, equipped items and nonfinite counts never enter an offer', () => { + const fixture = setup(); + const payload = state(); + payload.inventory[0].isQuestItem = true; + payload.inventory[1].isEquipped = true; + fixture.client.tradeStateChange.next(payload); + fixture.service.updateOfferFromInput(7, 2); + fixture.service.updateOfferFromInput(3, 2); + fixture.service.updateOfferFromInput(7, NaN); + assert.equal(fixture.calls.length, 0); + fixture.service.ngOnDestroy(); +}); + +test('navigation and subsequent state updates preserve an exchange without reopening it', () => { + const fixture = setup(); + fixture.client.tradeStateChange.next(state()); + fixture.ui.openView('party'); + fixture.client.tradeStateChange.next({ ...state(), selfReady: true, countdownMs: 2000 }); + assert.equal(fixture.calls.length, 0); + assert.equal(fixture.ui.view, 'party'); + fixture.service.ngOnDestroy(); +}); + +test('category filtering preserves offers and native indices for hidden items', () => { + const fixture = setup(); + const payload = state(); + payload.inventory[0].category = 'weapons'; + payload.inventory[1].category = 'food'; + payload.selfItems = [{ ...payload.inventory[0], count: 2 }]; + let visible; + let session; + const inventorySubscription = fixture.service.visibleInventory$.subscribe(items => visible = items); + const sessionSubscription = fixture.service.session$.subscribe(value => session = value); + fixture.client.tradeStateChange.next(payload); + fixture.service.selectCategory('food'); + assert.equal(visible.length, 1); + assert.equal(visible[0].index, 3); + assert.equal(session.selfOffer.length, 1); + assert.equal(session.selfOffer[0].count, 2); + fixture.service.updateOfferFromInput(3, 4); + const selections = fixture.calls.at(-1)[1]; + assert.equal(selections.find(item => item.index === 7).count, 2); + assert.equal(selections.find(item => item.index === 3).count, 4); + fixture.service.selectCategory('all'); + assert.equal(visible.length, 2); + inventorySubscription.unsubscribe(); + sessionSubscription.unsubscribe(); + fixture.service.ngOnDestroy(); +}); + +test('category selection survives inventory updates and resets for the next session', () => { + const fixture = setup(); + const payload = state(); + payload.inventory[0].category = 'weapons'; + payload.inventory[1].category = 'food'; + let visible; + const subscription = fixture.service.visibleInventory$.subscribe(items => visible = items); + fixture.client.tradeStateChange.next(payload); + fixture.service.selectCategory('weapons'); + fixture.client.tradeStateChange.next({ ...payload, inventory: [payload.inventory[1]] }); + assert.equal(visible.length, 0); + fixture.client.tradeStateChange.next(undefined); + fixture.client.tradeStateChange.next(payload); + assert.equal(visible.length, 2); + subscription.unsubscribe(); + fixture.service.ngOnDestroy(); +}); + +test('broad categories include ammo, poisons and soul gems; unknown types remain accessible', () => { + const fixture = setup(); + const payload = state(); + payload.inventory = ['weapons', 'ammunition', 'potions', 'poisons', 'soul_gems', 'unrecognized'].map((category, index) => ({ + ...payload.inventory[0], category, inventoryIndex: index, + })); + let visible; + const subscription = fixture.service.visibleInventory$.subscribe(items => visible = items); + fixture.client.tradeStateChange.next(payload); + fixture.service.selectCategory('weapons'); + assert.equal(visible.length, 2); + fixture.service.selectCategory('potions'); + assert.equal(visible.length, 2); + fixture.service.selectCategory('misc'); + assert.equal(visible.length, 2); + assert.equal(fixture.service.categories.includes('soul_gems'), false); + assert.equal(fixture.service.categories.includes('ammunition'), false); + assert.equal(fixture.service.categories.includes('poisons'), false); + subscription.unsubscribe(); + fixture.service.ngOnDestroy(); +}); + +test('F2 activation changes retain the trade view, ready status and countdown', () => { + const rootExports = {}; + const rootSource = fs.readFileSync(path.join(__dirname, '../src/app/components/root/root.component.ts'), 'utf8'); + vm.runInNewContext(ts.transpileModule(rootSource, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, experimentalDecorators: true }, + }).outputText, { + exports: rootExports, setTimeout, + require(name) { + if (name === '@angular/core') return { Component: () => value => value, ViewChild: () => () => {} }; + if (name.startsWith('rxjs')) return require(name); + if (name.endsWith('view.enum')) return { View: { TRADE: 'trade' } }; + if (name.endsWith('/environment')) return { environment: { game: false } }; + return {}; + }, + }); + const fixture = setup(); + fixture.ui.isViewOpen = () => fixture.ui.view !== null; + fixture.client.activationStateChange = new Subject(); + fixture.client.inGameStateChange = new BehaviorSubject(true); + const root = Object.create(rootExports.RootComponent.prototype); + root.client = fixture.client; + root.uiRepository = fixture.ui; + root.destroy$ = new Subject(); + root.onActivationStateSubscription(); + let session; + const subscription = fixture.service.session$.subscribe(value => session = value); + fixture.client.tradeStateChange.next({ ...state(), selfReady: true, countdownMs: 3000 }); + fixture.client.activationStateChange.next(false); + fixture.client.tradeStateChange.next({ ...state(), selfReady: true, countdownMs: 2000 }); + fixture.client.activationStateChange.next(true); + assert.equal(fixture.ui.view, 'trade'); + assert.equal(session.selfReady, true); + assert.equal(session.countdownMs, 2000); + assert.equal(fixture.calls.length, 0); + root.destroy$.next(); + subscription.unsubscribe(); + fixture.service.ngOnDestroy(); +}); + +test('partial text search handles localized names and combines with categories without changing offers', () => { + const fixture = setup(); + const payload = state(); + payload.inventory = ['Épée enchantée', 'Двемерская стрела', '鋼鉄の剣', 'Straße', 'Ilık çorba'].map((name, index) => ({ + ...payload.inventory[0], name, category: index === 4 ? 'food' : 'weapons', inventoryIndex: index, + })); + let visible; + const subscription = fixture.service.visibleInventory$.subscribe(items => visible = items); + fixture.client.tradeStateChange.next(payload); + for (const [query, expected] of [['EPE ENCH', 0], ['МЕРСК', 1], ['鉄の', 2], ['STRASS', 3], ['ılık', 4]]) { + fixture.service.setSearch(query); + assert.equal(visible.length, 1); + assert.equal(visible[0].index, expected); + } + fixture.service.selectCategory('weapons'); + assert.equal(visible.length, 0); + assert.equal(fixture.calls.length, 0); + fixture.service.setSearch(''); + assert.equal(visible.length, 4); + fixture.client.tradeStateChange.next(undefined); + fixture.client.tradeStateChange.next(payload); + assert.equal(visible.length, 5); + subscription.unsubscribe(); + fixture.service.ngOnDestroy(); +}); + +test('row Max chooses the maximum and Clear chooses zero while preserving other offers', () => { + const fixture = setup(); + fixture.client.tradeStateChange.next(state()); + fixture.service.offerAll(3); + assert.equal(fixture.calls.at(-1)[1].find(item => item.index === 3).count, 5); + assert.equal(fixture.calls.at(-1)[1].find(item => item.index === 7).count, 2); + fixture.service.clearOffer(3); + assert.equal(fixture.calls.at(-1)[1].some(item => item.index === 3), false); + assert.equal(fixture.calls.at(-1)[1].find(item => item.index === 7).count, 2); + fixture.service.ngOnDestroy(); +}); + +test('quest items and internal fists are hidden while native indices and other offers remain intact', () => { + const fixture = setup(); + const payload = state(); + payload.inventory = [ + { ...payload.inventory[0], inventoryIndex: 0, isQuestItem: true }, + { ...payload.inventory[0], inventoryIndex: 1, baseId: 0x1f4, offeredCount: 0 }, + { ...payload.inventory[1], inventoryIndex: 9 }, + ]; + let visible; + const subscription = fixture.service.visibleInventory$.subscribe(items => visible = items); + fixture.client.tradeStateChange.next(payload); + assert.equal(visible.length, 1); + assert.equal(visible[0].index, 9); + fixture.service.offerAll(0); + fixture.service.offerAll(1); + assert.equal(fixture.calls.length, 0); + fixture.service.offerAll(9); + assert.equal(fixture.calls.at(-1)[1].length, 1); + assert.equal(fixture.calls.at(-1)[1][0].index, 9); + subscription.unsubscribe(); + fixture.service.ngOnDestroy(); +}); diff --git a/Code/tests/trading.cpp b/Code/tests/trading.cpp new file mode 100644 index 0000000000..519848557e --- /dev/null +++ b/Code/tests/trading.cpp @@ -0,0 +1,575 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +Inventory::Entry CraftedPotion(uint32_t aLocalId = 1) +{ + Inventory::Entry item; + item.BaseId.ModId = 0xFFFFFFFF; + item.BaseId.BaseId = aLocalId; + item.Count = 2; + Inventory::EffectItem effect; + effect.EffectId.BaseId = 0x3EB15; + effect.Magnitude = 37.5f; + effect.Duration = 12; + effect.RawCost = 42.0f; + item.Potion.Effects.push_back(effect); + return item; +} +} + +TEST_CASE("Crafted potion trades identify recipes independently of temporary form IDs", "[trading][alchemy]") +{ + auto source = CraftedPotion(123); + auto recipient = CraftedPotion(987); + REQUIRE(source.Potion.IsValid()); + REQUIRE(SameTradeItem(source, recipient)); + Inventory left, right, leftResult, rightResult; + left.Entries.push_back(source); + right.Entries.push_back(recipient); + auto offer = source; + offer.Count = 1; + REQUIRE(PrepareTradeExchange(left, right, {offer}, {}, leftResult, rightResult)); + REQUIRE(leftResult.Entries[0].Count == 1); + REQUIRE(rightResult.Entries.size() == 1); + REQUIRE(rightResult.Entries[0].Count == 3); + REQUIRE(rightResult.Entries[0].Potion == source.Potion); + // Drinking or transferring the recipient's locally recreated ID updates the same server stack. + recipient.Count = -1; + rightResult.AddOrRemoveEntry(recipient); + REQUIRE(rightResult.Entries[0].Count == 2); + SECTION("Magnitude, duration and type cannot be substituted") + { + recipient.Potion.Effects[0].Magnitude += 1; + REQUIRE_FALSE(SameTradeItem(source, recipient)); + recipient = source; + recipient.Potion.Effects[0].Duration += 1; + REQUIRE_FALSE(SameTradeItem(source, recipient)); + recipient = source; + recipient.Potion.IsPoison = true; + REQUIRE_FALSE(SameTradeItem(source, recipient)); + } + SECTION("Unknown temporary forms and invalid effect data cannot enter an offer") + { + offer.Potion.Effects.clear(); + REQUIRE(IsExcludedTradeItem(offer)); + offer = source; + offer.Potion.Effects[0].Magnitude = std::numeric_limits::quiet_NaN(); + REQUIRE(IsExcludedTradeItem(offer)); + offer = source; + offer.Potion.Effects[0].EffectId.ModId = 0xFFFFFFFF; + REQUIRE(IsExcludedTradeItem(offer)); + } + SECTION("Static food remains ordinary form-based inventory") + { + Inventory::Entry meal; + meal.BaseId.BaseId = 0x64B3F; + meal.Count = 1; + REQUIRE_FALSE(IsExcludedTradeItem(meal)); + REQUIRE_FALSE(SameTradeItem(meal, source)); + } +} + +TEST_CASE("Crafted potion recipes compare independently of effect order", "[trading][alchemy]") +{ + auto first = CraftedPotion(); + first.Potion.Effects.push_back(first.Potion.Effects[0]); + first.Potion.Effects[1].EffectId.BaseId += 1; + first.Potion.Effects[1].Magnitude = 5.0f; + auto second = first; + std::swap(second.Potion.Effects[0], second.Potion.Effects[1]); + REQUIRE(first.Potion == second.Potion); + REQUIRE(SameTradeItem(first, second)); + + second.Potion.Effects[0] = second.Potion.Effects[1]; + REQUIRE_FALSE(first.Potion == second.Potion); +} + +TEST_CASE("Applied crafted poisons identify by recipe, not temporary ID", "[trading][alchemy]") +{ + Inventory::Entry sword; + sword.BaseId.BaseId = 0x12EB7; + sword.Count = 1; + sword.ExtraPoisonId.ModId = 0xFFFFFFFF; + sword.ExtraPoisonId.BaseId = 0x900; + sword.ExtraPoisonCount = 3; + sword.PoisonData = CraftedPotion().Potion; + sword.PoisonData.IsPoison = true; + REQUIRE(sword.PoisonData.IsValid()); + + auto recreated = sword; + recreated.ExtraPoisonId.BaseId = 0xABC; + REQUIRE(SameTradeItem(sword, recreated)); + recreated.PoisonData.Effects[0].Magnitude += 1; + REQUIRE_FALSE(SameTradeItem(sword, recreated)); + + TiltedPhoques::Buffer buffer(1024); + TiltedPhoques::Buffer::Writer writer(&buffer); + sword.Serialize(writer); + TiltedPhoques::Buffer::Reader reader(&buffer); + Inventory::Entry decoded; + decoded.Deserialize(reader); + REQUIRE(decoded == sword); + REQUIRE(decoded.PoisonData == sword.PoisonData); +} + +TEST_CASE("Accepted items must be an in-order subset of the incoming offer", "[trading][alchemy]") +{ + auto potion = CraftedPotion(); + REQUIRE(ValidateAcceptedItems({potion}, {})); + REQUIRE(ValidateAcceptedItems({potion}, {potion})); + auto stale = potion; + stale.Count += 1; + REQUIRE_FALSE(ValidateAcceptedItems({potion}, {stale})); + stale = potion; + stale.Potion.Effects[0].Magnitude += 1; + REQUIRE_FALSE(ValidateAcceptedItems({potion}, {stale})); + REQUIRE_FALSE(ValidateAcceptedItems({}, {potion})); + REQUIRE_FALSE(ValidateAcceptedItems({potion}, {potion, potion})); + REQUIRE(ValidateAcceptedItems({}, {})); +} + +TEST_CASE("Only items each recipient accepts change hands", "[trading][alchemy]") +{ + // A offers a modded-effect potion and a vanilla-effect poison; B offers a vanilla weapon and modded armor. + auto moddedPotion = CraftedPotion(1); + moddedPotion.Count = 1; + moddedPotion.Potion.Effects[0].EffectId.ModId = 7; + auto poison = CraftedPotion(2); + poison.Count = 1; + poison.Potion.IsPoison = true; + Inventory::Entry weapon; + weapon.BaseId.BaseId = 0x12EB7; + weapon.Count = 1; + weapon.ExtraEnchantId.ModId = 0xFFFFFFFF; + weapon.ExtraEnchantId.BaseId = 0x800; + weapon.EnchantData.IsWeapon = true; + weapon.EnchantData.Effects.push_back(poison.Potion.Effects[0]); + Inventory::Entry armor; + armor.BaseId.ModId = 9; + armor.BaseId.BaseId = 0x801; + armor.Count = 1; + + Inventory left; + left.Entries = {moddedPotion, poison}; + Inventory right; + right.Entries = {weapon, armor}; + const Vector leftOffer{moddedPotion, poison}; + const Vector rightOffer{weapon, armor}; + const Vector acceptedByB{poison}; + const Vector acceptedByA{weapon}; + REQUIRE(ValidateAcceptedItems(leftOffer, acceptedByB)); + REQUIRE(ValidateAcceptedItems(rightOffer, acceptedByA)); + REQUIRE(MatchAcceptedItems(leftOffer, acceptedByB) == Vector{false, true}); + REQUIRE(MatchAcceptedItems(rightOffer, acceptedByA) == Vector{true, false}); + + Inventory leftResult; + Inventory rightResult; + REQUIRE(PrepareTradeExchange(left, right, acceptedByB, acceptedByA, leftResult, rightResult)); + REQUIRE(leftResult.Entries.size() == 2); + REQUIRE(SameTradeItem(leftResult.Entries[0], moddedPotion)); + REQUIRE(SameTradeItem(leftResult.Entries[1], weapon)); + REQUIRE(rightResult.Entries.size() == 2); + REQUIRE(SameTradeItem(rightResult.Entries[0], armor)); + REQUIRE(SameTradeItem(rightResult.Entries[1], poison)); +} + +TEST_CASE("Potion recipes and readiness proofs round trip through packet factories", "[trading][encoding][alchemy]") +{ + TiltedPhoques::Buffer buffer(4096); + auto potion = CraftedPotion(); + potion.Potion.IsPoison = true; + potion.Potion.Effects.push_back(potion.Potion.Effects[0]); + potion.Potion.Effects[1].EffectId.BaseId += 1; + potion.Potion.Effects[1].Area = 3; + SECTION("Ready with a prepared multi-effect poison") + { + TradeSetReadyRequest request; + request.Ready = true; + request.AcceptedItems.push_back(potion); + TiltedPhoques::Buffer::Writer writer(&buffer); + request.Serialize(writer); + TiltedPhoques::Buffer::Reader reader(&buffer); + auto message = ClientMessageFactory{}.Extract(reader); + REQUIRE(message); + const auto* decoded = static_cast(message.get()); + REQUIRE(decoded->Ready); + REQUIRE(decoded->AcceptedItems == request.AcceptedItems); + REQUIRE(decoded->AcceptedItems[0].Potion == potion.Potion); + } + SECTION("Full inventory state") + { + NotifyTradeState state; + state.PartnerItems.push_back(potion); + state.SelfInventory.push_back(potion); + state.SelfAcceptedItems.push_back(potion); + TiltedPhoques::Buffer::Writer writer(&buffer); + state.Serialize(writer); + TiltedPhoques::Buffer::Reader reader(&buffer); + auto message = ServerMessageFactory{}.Extract(reader); + REQUIRE(message); + const auto* decoded = static_cast(message.get()); + REQUIRE(decoded->PartnerItems[0].Potion == potion.Potion); + REQUIRE(decoded->SelfInventory[0].Potion == potion.Potion); + REQUIRE(decoded->SelfAcceptedItems.size() == 1); + REQUIRE(decoded->SelfAcceptedItems[0].Potion == potion.Potion); + } +} + +TEST_CASE("Trade offers reserve inventory quantities", "[trading]") +{ + Inventory inventory; + Inventory::Entry entry; + entry.BaseId.BaseId = 0xF; + entry.Count = 10; + inventory.Entries.push_back(entry); + + entry.Count = 6; + Vector offer{entry, entry}; + REQUIRE_FALSE(ValidateTradeOffer(inventory, offer)); + offer[1].Count = 4; + REQUIRE(ValidateTradeOffer(inventory, offer)); + REQUIRE(inventory.Entries[0].Count == 10); + + SECTION("Quest items cannot be offered") + { + offer[0].IsQuestItem = true; + REQUIRE_FALSE(ValidateTradeOffer(inventory, offer)); + } + SECTION("Removing a quest flag cannot bypass the canonical inventory check") + { + inventory.Entries[0].IsQuestItem = true; + REQUIRE_FALSE(ValidateTradeOffer(inventory, offer)); + } + SECTION("The internal unarmed weapon cannot be traded") + { + inventory.Entries[0].BaseId.BaseId = 0x1F4; + offer[0].BaseId.BaseId = offer[1].BaseId.BaseId = 0x1F4; + REQUIRE_FALSE(ValidateTradeOffer(inventory, offer)); + } + SECTION("Counts must be positive") + { + offer[0].Count = -1; + REQUIRE_FALSE(ValidateTradeOffer(inventory, offer)); + } + SECTION("Extra data must match") + { + offer[0].ExtraHealth = 2.0f; + REQUIRE_FALSE(ValidateTradeOffer(inventory, offer)); + } + SECTION("Custom enchantment effects must match") + { + offer[0].EnchantData.Effects.push_back(Inventory::EffectItem{}); + REQUIRE_FALSE(ValidateTradeOffer(inventory, offer)); + } + SECTION("Equipped items must be unequipped before trading") + { + inventory.Entries[0].ExtraWorn = true; + offer[0].ExtraWorn = true; + REQUIRE_FALSE(ValidateTradeOffer(inventory, offer)); + } +} + +TEST_CASE("Trade exchange stages both inventories before publishing", "[trading]") +{ + Inventory left; + Inventory right; + Inventory::Entry gold; + gold.BaseId.BaseId = 0xF; + gold.Count = 10; + left.Entries.push_back(gold); + Inventory::Entry item; + item.BaseId.BaseId = 0x123; + item.Count = 1; + right.Entries.push_back(item); + Inventory leftResult = left; + Inventory rightResult = right; + Vector leftOffer{gold}; + Vector rightOffer{item}; + + SECTION("Valid exchange conserves quantities") + { + REQUIRE(PrepareTradeExchange(left, right, leftOffer, rightOffer, leftResult, rightResult)); + REQUIRE(leftResult.Entries == right.Entries); + REQUIRE(rightResult.Entries == left.Entries); + REQUIRE(left.Entries[0].Count == 10); + REQUIRE(right.Entries[0].Count == 1); + } + SECTION("Invalid second offer publishes neither inventory") + { + rightOffer[0].Count = 2; + REQUIRE_FALSE(PrepareTradeExchange(left, right, leftOffer, rightOffer, leftResult, rightResult)); + REQUIRE(leftResult == left); + REQUIRE(rightResult == right); + } + SECTION("Overflow publishes neither inventory") + { + right.Entries.push_back(gold); + right.Entries.back().Count = std::numeric_limits::max(); + rightResult = right; + REQUIRE_FALSE(PrepareTradeExchange(left, right, leftOffer, rightOffer, leftResult, rightResult)); + REQUIRE(leftResult == left); + REQUIRE(rightResult == right); + } +} + +TEST_CASE("Trade packets round trip through message factories", "[trading][encoding]") +{ + TiltedPhoques::Buffer buffer(4096); + Inventory::Entry item; + item.BaseId.BaseId = 0xF; + item.Count = 8; + + SECTION("Offer update") + { + TradeOfferUpdateRequest request; + request.Items.push_back(item); + TiltedPhoques::Buffer::Writer writer(&buffer); + request.Serialize(writer); + TiltedPhoques::Buffer::Reader reader(&buffer); + auto message = ClientMessageFactory{}.Extract(reader); + REQUIRE(message); + REQUIRE(message->GetOpcode() == TradeOfferUpdateRequest::Opcode); + const auto* decoded = static_cast(message.get()); + REQUIRE(decoded->Items == request.Items); + } + SECTION("Session state and countdown") + { + NotifyTradeState state; + state.PartnerPlayerId = 42; + state.SelfReady = true; + state.CountdownMs = 2000; + state.CountdownTotalMs = 4000; + state.SelfItems.push_back(item); + state.SelfInventory.push_back(item); + TiltedPhoques::Buffer::Writer writer(&buffer); + state.Serialize(writer); + TiltedPhoques::Buffer::Reader reader(&buffer); + auto message = ServerMessageFactory{}.Extract(reader); + REQUIRE(message); + REQUIRE(message->GetOpcode() == NotifyTradeState::Opcode); + const auto* decoded = static_cast(message.get()); + REQUIRE(decoded->PartnerPlayerId == state.PartnerPlayerId); + REQUIRE(decoded->SelfReady); + REQUIRE(decoded->CountdownMs == state.CountdownMs); + REQUIRE(decoded->CountdownTotalMs == state.CountdownTotalMs); + REQUIRE(decoded->SelfItems == state.SelfItems); + REQUIRE(decoded->SelfInventory == state.SelfInventory); + } +} + +TEST_CASE("Inventory sync keeps only offered items the live inventory still covers", "[trading]") +{ + Inventory::Entry sword; + sword.BaseId.BaseId = 0x12EB7; + sword.Count = 1; + auto enchanted = sword; + enchanted.ExtraEnchantId.ModId = 0xFFFFFFFF; + enchanted.ExtraEnchantId.BaseId = 0x800; + enchanted.EnchantData.IsWeapon = true; + enchanted.EnchantData.Effects.push_back(CraftedPotion().Potion.Effects[0]); + auto potion = CraftedPotion(); + + // The server copy still had the plain sword; enchanting changed it in place. + Inventory live; + live.Entries = {enchanted, potion}; + const auto kept = KeepCoveredTradeOffer(live, {sword, potion, enchanted}); + REQUIRE(kept.size() == 2); + REQUIRE(SameTradeItem(kept[0], potion)); + REQUIRE(SameTradeItem(kept[1], enchanted)); + + auto twoPotions = potion; + twoPotions.Count = potion.Count; + REQUIRE(KeepCoveredTradeOffer(live, {twoPotions, twoPotions}).size() == 1); + + TiltedPhoques::Buffer buffer(4096); + TradeInventorySyncRequest request; + request.Entries = live.Entries; + TiltedPhoques::Buffer::Writer writer(&buffer); + request.Serialize(writer); + TiltedPhoques::Buffer::Reader reader(&buffer); + auto message = ClientMessageFactory{}.Extract(reader); + REQUIRE(message); + REQUIRE(message->GetOpcode() == kTradeInventorySyncRequest); + const auto* decoded = static_cast(message.get()); + REQUIRE(decoded->Entries == request.Entries); +} + +namespace +{ +Inventory::Entry FullEntry() +{ + auto item = CraftedPotion(7); + item.ExtraEnchantId.BaseId = 0x800; + item.EnchantData.IsWeapon = true; + item.EnchantData.Effects = item.Potion.Effects; + item.ExtraPoisonId.ModId = 0xFFFFFFFF; + item.ExtraPoisonId.BaseId = 0x801; + item.ExtraPoisonCount = 3; + item.PoisonData = item.Potion; + item.PoisonData.IsPoison = true; + return item; +} + +// A copy of the first aSize bytes, so the reader runs out where a truncated packet would. +TiltedPhoques::Buffer Prefix(const TiltedPhoques::Buffer& acBuffer, size_t aSize) +{ + return TiltedPhoques::Buffer(acBuffer.GetData(), aSize); +} +} // namespace + +TEST_CASE("Inventory entries round trip with every effect list", "[trading][encoding]") +{ + const auto item = FullEntry(); + Inventory::Entry plain; + plain.BaseId.BaseId = 0xF; + plain.Count = 1; + + TiltedPhoques::Buffer buffer(4096); + TradeOfferUpdateRequest request; + request.Items = {item, plain}; + TiltedPhoques::Buffer::Writer writer(&buffer); + request.Serialize(writer); + + TiltedPhoques::Buffer exact = Prefix(buffer, writer.Size()); + TiltedPhoques::Buffer::Reader reader(&exact); + auto message = ClientMessageFactory{}.Extract(reader); + REQUIRE(message); + const auto* decoded = static_cast(message.get()); + REQUIRE(decoded->Items.size() == 2); + REQUIRE(SameTradeItem(decoded->Items[0], item)); + REQUIRE(decoded->Items[0].Potion == item.Potion); + REQUIRE(decoded->Items[0].PoisonData == item.PoisonData); + REQUIRE(decoded->Items[0].ExtraPoisonCount == item.ExtraPoisonCount); + REQUIRE(decoded->Items[1] == plain); + REQUIRE(decoded->Items[1].Potion.Effects.empty()); + REQUIRE_FALSE(decoded->Items[1].Potion.IsPoison); + REQUIRE(decoded->Items[1].PoisonData.Effects.empty()); +} + +TEST_CASE("Truncated trade packets are rejected", "[trading][encoding]") +{ + TiltedPhoques::Buffer buffer(1 << 16); + TradeInventorySyncRequest request; + for (uint32_t i = 0; i < 50; ++i) + { + auto item = FullEntry(); + item.BaseId.BaseId = 0x1000 + i; + request.Entries.push_back(item); + } + TiltedPhoques::Buffer::Writer writer(&buffer); + request.Serialize(writer); + const size_t cSize = writer.Size(); + + // Cut into the first entry, halfway, and a few entries short. + for (size_t size : {size_t{3}, cSize / 2, cSize - 3 * (cSize / 50)}) + { + TiltedPhoques::Buffer truncated = Prefix(buffer, size); + TiltedPhoques::Buffer::Reader reader(&truncated); + REQUIRE_FALSE(ClientMessageFactory{}.Extract(reader)); + } + + TiltedPhoques::Buffer exact = Prefix(buffer, cSize); + TiltedPhoques::Buffer::Reader reader(&exact); + auto message = ClientMessageFactory{}.Extract(reader); + REQUIRE(message); + REQUIRE(static_cast(message.get())->Entries.size() == 50); +} + +TEST_CASE("Absurd list lengths are rejected without allocating", "[trading][encoding]") +{ + TiltedPhoques::Buffer buffer(4096); + constexpr uint64_t cHuge = uint64_t{1} << 62; + + SECTION("Client trade lists") + { + for (ClientOpcode opcode : {kTradeOfferUpdateRequest, kTradeInventorySyncRequest, kTradeSetReadyRequest}) + { + TiltedPhoques::Buffer::Writer writer(&buffer); + writer.WriteBits(opcode, sizeof(ClientOpcode) * 8); + if (opcode == kTradeSetReadyRequest) + TiltedPhoques::Serialization::WriteBool(writer, true); + TiltedPhoques::Serialization::WriteVarInt(writer, cHuge); + TiltedPhoques::Buffer::Reader reader(&buffer); + REQUIRE_FALSE(ClientMessageFactory{}.Extract(reader)); + } + } + SECTION("Server trade state") + { + TiltedPhoques::Buffer::Writer writer(&buffer); + writer.WriteBits(kNotifyTradeState, sizeof(ServerOpcode) * 8); + TiltedPhoques::Serialization::WriteVarInt(writer, 42); + TiltedPhoques::Serialization::WriteBool(writer, false); + TiltedPhoques::Serialization::WriteBool(writer, false); + TiltedPhoques::Serialization::WriteVarInt(writer, 0); + TiltedPhoques::Serialization::WriteVarInt(writer, 0); + TiltedPhoques::Serialization::WriteVarInt(writer, 0); + TiltedPhoques::Serialization::WriteVarInt(writer, cHuge); + TiltedPhoques::Buffer::Reader reader(&buffer); + REQUIRE_FALSE(ServerMessageFactory{}.Extract(reader)); + } + SECTION("Effect lists over the limit") + { + auto item = FullEntry(); + SECTION("Potion") { item.Potion.Effects.resize(Inventory::kMaxEffects + 1, item.Potion.Effects[0]); } + SECTION("Poison") { item.PoisonData.Effects.resize(Inventory::kMaxEffects + 1, item.PoisonData.Effects[0]); } + SECTION("Enchantment") { item.EnchantData.Effects.resize(Inventory::kMaxEffects + 1, item.EnchantData.Effects[0]); } + + NotifyTradeState state; + state.PartnerItems.push_back(item); + TiltedPhoques::Buffer::Writer writer(&buffer); + state.Serialize(writer); + TiltedPhoques::Buffer::Reader reader(&buffer); + REQUIRE_FALSE(ServerMessageFactory{}.Extract(reader)); + } + SECTION("Effect list at the limit") + { + auto item = FullEntry(); + item.Potion.Effects.resize(Inventory::kMaxEffects, item.Potion.Effects[0]); + NotifyTradeState state; + state.PartnerItems.push_back(item); + TiltedPhoques::Buffer::Writer writer(&buffer); + state.Serialize(writer); + TiltedPhoques::Buffer::Reader reader(&buffer); + auto message = ServerMessageFactory{}.Extract(reader); + REQUIRE(message); + REQUIRE(static_cast(message.get())->PartnerItems[0].Potion == item.Potion); + } + SECTION("Effect count past the end of the data") + { + // A present potion recipe that claims five effects and stops. + TiltedPhoques::Buffer::Writer writer(&buffer); + TiltedPhoques::Serialization::WriteBool(writer, true); + TiltedPhoques::Serialization::WriteBool(writer, false); + TiltedPhoques::Serialization::WriteVarInt(writer, 5); + TiltedPhoques::Buffer potion = Prefix(buffer, writer.Size()); + TiltedPhoques::Buffer::Reader reader(&potion); + Inventory::PotionData decoded; + decoded.Deserialize(reader); + REQUIRE(Decoding::IsMalformed(reader)); + REQUIRE(decoded.Effects.empty()); + } + SECTION("Inventories") + { + TiltedPhoques::Buffer::Writer writer(&buffer); + TiltedPhoques::Serialization::WriteVarInt(writer, cHuge); + TiltedPhoques::Buffer::Reader reader(&buffer); + Inventory inventory; + inventory.Deserialize(reader); + REQUIRE(Decoding::IsMalformed(reader)); + REQUIRE(inventory.Entries.empty()); + } +} diff --git a/Code/tp_process/ProcessHandler.cpp b/Code/tp_process/ProcessHandler.cpp index 5e43e1666b..a1e5fa774c 100644 --- a/Code/tp_process/ProcessHandler.cpp +++ b/Code/tp_process/ProcessHandler.cpp @@ -26,6 +26,11 @@ void ProcessHandler::OnContextCreated(CefRefPtr browser, CefRefPtrSetValue("kickPartyMember", CefV8Value::CreateFunction("kickPartyMember", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("changePartyLeader", CefV8Value::CreateFunction("changePartyLeader", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("teleportToPlayer", CefV8Value::CreateFunction("teleportToPlayer", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("sendTradeInvite", CefV8Value::CreateFunction("sendTradeInvite", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("respondTradeInvite", CefV8Value::CreateFunction("respondTradeInvite", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("cancelTrade", CefV8Value::CreateFunction("cancelTrade", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("setTradeReady", CefV8Value::CreateFunction("setTradeReady", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("updateTradeOffer", CefV8Value::CreateFunction("updateTradeOffer", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("toggleDebugUI", CefV8Value::CreateFunction("toggleDebugUI", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("openPlayGuide", CefV8Value::CreateFunction("openPlayGuide", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("setNameTagSettings", CefV8Value::CreateFunction("setNameTagSettings", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); diff --git a/PLAYER_TRADING_POTIONS.md b/PLAYER_TRADING_POTIONS.md new file mode 100644 index 0000000000..28b368f310 --- /dev/null +++ b/PLAYER_TRADING_POTIONS.md @@ -0,0 +1,29 @@ +# Crafted potion trade playtest + +Build both clients and the server from the same commit on `codex/player-trading-1.6.1170`. Inventory packets and the Ready request now carry potion recipes; the previous server build is incompatible. + +1. Craft a single-effect potion and a potion with multiple effects. Record each effect's magnitude and duration and the stack counts. +2. Give the recipient different Alchemy skills/perks and record their ingredients and Alchemy experience. +3. Offer part of each stack. Verify the offer lists show the actual potion name, the owner's temporary ID (FFxxxxxx) and each effect's magnitude and duration, and complete the trade. +4. Check that exactly the offered quantity left the sender and arrived at the recipient. Effects must match; ingredients and Alchemy experience must stay unchanged. +5. Drink one received potion, trade another back, and verify subsequent stack counts. Save/reload and verify the remaining potion still works. +6. Repeat with a crafted poison, with both players offering potions, and with an existing matching potion stack on the recipient. +7. Cancel Ready, change quantities, cancel the trade and disconnect during the countdown. Preparation must not add potions to inventory or consume anything. +8. Trade a normal cooked meal and a normal purchased potion to verify the existing static-form path. + +Recreation uses Skyrim's `BGSCreatedObjectManager::AddPotion` (or `AddPoison` for poisons) with the captured effects directly, in the sender's effect order. It does not invoke the crafting menu or calculate strength from the recipient's skills. The Ready request confirms successful reconstruction; changing an offer invalidates readiness. Unsupported temporary effects and effect conditions are excluded from trading. Custom per-instance names remain local inventory UI metadata. + +Native engine behavior, including the AddPotion address and created-object reference lifetime, still requires this in-game test. Passing compilation and packet tests does not establish in-game correctness. + +## Items the recipient cannot load + +Each client shows only the partner items it can rebuild: every plugin form the item references must be loaded locally, and crafted (temporary) potions and enchantments must have effects that resolve locally. Weapons coated with a crafted poison carry the poison's recipe and are receivable when its effects resolve; the recipient's coating is recreated with `AddPoison`, keeping the remaining uses. When a player readies, the Ready request lists exactly the partner items that client accepts; the server transfers only those, and everything else stays with its owner. Once the partner is ready, the sender's own offer marks any item the partner cannot receive. + +9. A offers a crafted potion with an effect from a plugin B lacks, plus a crafted poison with vanilla effects. B offers a weapon with a crafted vanilla enchantment, plus armor from a plugin A lacks. Verify A sees only the weapon, B sees only the poison, each sender's offer marks the hidden item after the partner readies, and after the countdown only the weapon and poison change hands. +10. Coat a weapon with a crafted vanilla-effect poison, trade it, and verify the recipient's weapon shows the same poison effects and remaining uses, then hits apply the poison. Repeat with a poison using an effect from a plugin the recipient lacks: the weapon must stay hidden and remain with the sender. + +## Live inventory snapshot + +Enchanting, renaming and brewing can change items without the incremental inventory events the server relies on. When a trade starts and each time a player readies, their client sends its live inventory and the server replaces its copy for the trade. Offered items the snapshot no longer covers are dropped from the offer and both players are unreadied. Readying an outdated offer, or one built on an outdated partner offer, unreadies instead of cancelling. + +11. Enchant and rename an item ("AAA ...") and brew a potion while connected, then start a trade without relogging. Both must appear in the picker, the renamed item must be found by searching "AAA", and readying with both offered must not cancel the trade. From 03e3d52bf4430a1fa72c05af3c7271ece0a85a0f Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 22 Oct 2026 20:22:21 +0200 Subject: [PATCH 07/97] Apply Known names and a trade invitation setting to trades Depends on tiltedphoques/TiltedEvolution#931 (player name tags with privacy settings): it uses #931's PlayerUiService, Known names and Player UI settings, and this branch is stacked on #931. Merge #931 first. - PlayerUiService provides TRADE_INVITE_POLICY: - displayName: trade popups and the trade window show "Stranger" for players whose name isn't known, the same rule as the player list (Known names plus the eye). - acceptsTradeInvite: the new "Trade invitations" setting (On/Party/Off, default On). Party: only party members can invite you; other invites are declined silently. - Settings row under Player UI filters; English and Swedish strings. Tests: the policy paths are covered by trade-ui.test.cjs (refused invites are declined without a popup, popups use the policy's name, no policy shows real names). Verified: - Angular production build; trade UI tests pass. Played on the fork's 1.8-playtest builds. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit ddb1c9606ea7db10b674b1130af2df89c7882fa3) --- Code/skyrim_ui/src/app/app.module.ts | 4 ++++ .../settings/settings.component.html | 19 +++++++++++++++++++ .../src/app/services/player-ui.service.ts | 17 ++++++++++++++++- .../src/app/services/setting.service.ts | 6 ++++++ Code/skyrim_ui/src/assets/i18n/en.json | 2 ++ Code/skyrim_ui/src/assets/i18n/sv.json | 2 ++ 6 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Code/skyrim_ui/src/app/app.module.ts b/Code/skyrim_ui/src/app/app.module.ts index df3130b59a..a30fbf7565 100644 --- a/Code/skyrim_ui/src/app/app.module.ts +++ b/Code/skyrim_ui/src/app/app.module.ts @@ -43,6 +43,8 @@ import { ErrorService } from './services/error.service'; import { GroupService } from './services/group.service'; import { PlayerListService } from './services/player-list.service'; import { SettingService } from './services/setting.service'; +import { PlayerUiService } from './services/player-ui.service'; +import { TRADE_INVITE_POLICY } from './services/trade-invite-policy'; import { SoundService } from './services/sound.service'; import { StoreService } from './services/store.service'; import { TranslocoRootModule } from './transloco-root.module'; @@ -101,6 +103,8 @@ import { TranslocoRootModule } from './transloco-root.module'; PlayerListService, StoreService, SettingService, + // Player privacy settings decide trade invites and trade names. + { provide: TRADE_INVITE_POLICY, useExisting: PlayerUiService }, ...environment.providers, ], diff --git a/Code/skyrim_ui/src/app/components/settings/settings.component.html b/Code/skyrim_ui/src/app/components/settings/settings.component.html index 7df2349f88..6b528de5c9 100644 --- a/Code/skyrim_ui/src/app/components/settings/settings.component.html +++ b/Code/skyrim_ui/src/app/components/settings/settings.component.html @@ -127,6 +127,25 @@

{{ 'COMPONENT.SETTINGS.PLAYER_UI.TITLE' | transloco }}

(ngModelChange)="settings.partyInvites.next($event)" /> +
+ + + + +
+
+

{{ 'COMPONENT.SETTINGS.VOICE.HEADER' | transloco }}

+
+ + + + + + +
+
+ + +
+
+ + + + + +
+
+ + + + +
+
+ + +
+
+

{{ 'COMPONENT.SETTINGS.UI' | transloco }}

diff --git a/Code/skyrim_ui/src/app/components/settings/settings.component.ts b/Code/skyrim_ui/src/app/components/settings/settings.component.ts index bc28079513..b8d4074e9e 100644 --- a/Code/skyrim_ui/src/app/components/settings/settings.component.ts +++ b/Code/skyrim_ui/src/app/components/settings/settings.component.ts @@ -14,6 +14,11 @@ import { Sound, SoundService } from '../../services/sound.service'; import { environment } from 'src/environments/environment'; import { HttpClient } from '@angular/common/http'; import { ClientService } from 'src/app/services/client.service'; +import { + VoipChannel, + VoipService, + VoipSettings, +} from 'src/app/services/voip.service'; @Component({ selector: 'app-settings', @@ -78,6 +83,22 @@ export class SettingsComponent { }, ]; + readonly availableVoipChannels: { id: VoipChannel; label: string }[] = [ + { + id: VoipChannel.EVERYONE, + label: 'COMPONENT.SETTINGS.VOICE.CHANNEL_EVERYONE', + }, + { id: VoipChannel.PARTY, label: 'COMPONENT.SETTINGS.VOICE.CHANNEL_PARTY' }, + { + id: VoipChannel.STRANGERS, + label: 'COMPONENT.SETTINGS.VOICE.CHANNEL_STRANGERS', + }, + ]; + + readonly voipSettings$ = this.voipService.settings; + readonly inputDevices$ = this.voipService.inputDevices; + readonly outputDevices$ = this.voipService.outputDevices; + public settings = this.settingService.settings; public autoHideTime: number; public partyAnchor: ScreenCorner; @@ -99,12 +120,24 @@ export class SettingsComponent { private readonly translocoService: TranslocoService, private readonly http: HttpClient, private readonly client: ClientService, + private readonly voipService: VoipService, ) { this.clientVersion$ = this.client.versionSet.pipe(map(version => version.split('-')[0])); } ngOnInit(): void { this.isVersionOutdated = this.isGameVersionOutdated(); + // Picks up ini edits made while the game runs and devices plugged in since + this.voipService.refresh(); + } + + updateVoip(changes: Partial): void { + this.voipService.update(changes); + } + + updateVoipFade(event: Event): void { + const value = Number((event.target as HTMLInputElement).value); + this.voipService.update({ fadeStart: isFinite(value) ? value : 2500 }); } close() { diff --git a/Code/skyrim_ui/src/app/components/voip/voip-mic.component.html b/Code/skyrim_ui/src/app/components/voip/voip-mic.component.html new file mode 100644 index 0000000000..3565812e94 --- /dev/null +++ b/Code/skyrim_ui/src/app/components/voip/voip-mic.component.html @@ -0,0 +1,84 @@ + + {{ 'COMPONENT.VOIP.HEADER' | transloco }} + + + + +
+
+ {{ 'COMPONENT.VOIP.MIC_VOLUME' | transloco }} + {{ settings.micVolume }}% +
+ + +
+ +
+ {{ 'COMPONENT.VOIP.NOISE_GATE' | transloco }} + {{ settings.noiseGate }}% +
+ + +
+ {{ + (settings.inputDevice + ? 'COMPONENT.VOIP.NOISE_GATE_HINT' + : 'COMPONENT.VOIP.NO_DEVICE_HINT' + ) | transloco + }} +
+
+ {{ 'COMPONENT.VOIP.NO_DEVICE' | transloco }} +
+
+ {{ 'COMPONENT.VOIP.SAVED_UNTIL_CONNECTED' | transloco }} +
+
+
diff --git a/Code/skyrim_ui/src/app/components/voip/voip-mic.component.ts b/Code/skyrim_ui/src/app/components/voip/voip-mic.component.ts new file mode 100644 index 0000000000..b3250dfb34 --- /dev/null +++ b/Code/skyrim_ui/src/app/components/voip/voip-mic.component.ts @@ -0,0 +1,64 @@ +import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core'; +import { + faMicrophone, + faMicrophoneSlash, +} from '@fortawesome/free-solid-svg-icons'; +import { map } from 'rxjs'; +import { VoipService } from '../../services/voip.service'; + +/** "Voip" column header with the local microphone button and its popup: mic volume and noise gate. */ +@Component({ + selector: 'app-voip-mic', + templateUrl: './voip-mic.component.html', + styleUrls: ['./voip.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VoipMicComponent implements OnDestroy { + readonly faMicrophone = faMicrophone; + readonly faMicrophoneSlash = faMicrophoneSlash; + + isOpen = false; + + readonly settings$ = this.voipService.settings; + readonly micLevel$ = this.voipService.micLevel; + readonly state$ = this.voipService.settings.pipe( + map(settings => { + if (!settings.connect || !settings.inputDevice) { + return 'off'; + } + return settings.micVolume === 0 ? 'muted' : 'on'; + }), + ); + + constructor(private readonly voipService: VoipService) {} + + toggle(): void { + this.setOpen(!this.isOpen); + } + + close(): void { + this.setOpen(false); + } + + setMicVolume(value: number): void { + this.voipService.update({ micVolume: Number(value) }); + } + + setNoiseGate(value: number): void { + this.voipService.update({ noiseGate: Number(value) }); + } + + ngOnDestroy(): void { + if (this.isOpen) { + this.voipService.setMeter(false); + } + } + + private setOpen(open: boolean): void { + if (open === this.isOpen) { + return; + } + this.isOpen = open; + this.voipService.setMeter(open); + } +} diff --git a/Code/skyrim_ui/src/app/components/voip/voip-speaker.component.html b/Code/skyrim_ui/src/app/components/voip/voip-speaker.component.html new file mode 100644 index 0000000000..7b4b109a63 --- /dev/null +++ b/Code/skyrim_ui/src/app/components/voip/voip-speaker.component.html @@ -0,0 +1,55 @@ + + + + +
+
+ {{ + 'COMPONENT.VOIP.PLAYER_VOLUME' | transloco : { name: name$ | async } + }} + {{ volume$ | async }}% +
+ +
+ {{ + (settings.connect + ? 'COMPONENT.VOIP.MUTES_PLAYER' + : 'COMPONENT.VOIP.SAVED_UNTIL_CONNECTED' + ) | transloco + }} +
+
+
+
diff --git a/Code/skyrim_ui/src/app/components/voip/voip-speaker.component.ts b/Code/skyrim_ui/src/app/components/voip/voip-speaker.component.ts new file mode 100644 index 0000000000..dd7fffb4ad --- /dev/null +++ b/Code/skyrim_ui/src/app/components/voip/voip-speaker.component.ts @@ -0,0 +1,99 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { + faVolumeHigh, + faVolumeLow, + faVolumeXmark, +} from '@fortawesome/free-solid-svg-icons'; +import { BehaviorSubject, combineLatest, map } from 'rxjs'; +import { VoipChannel, VoipService } from '../../services/voip.service'; + +type SpeakerState = 'off' | 'muted' | 'quiet' | 'talking'; + +/** A player's speaker icon in the Voip column, and the popup with their volume. */ +@Component({ + selector: 'app-voip-speaker', + templateUrl: './voip-speaker.component.html', + styleUrls: ['./voip.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class VoipSpeakerComponent { + readonly faVolumeHigh = faVolumeHigh; + readonly faVolumeLow = faVolumeLow; + readonly faVolumeXmark = faVolumeXmark; + + private readonly player$ = new BehaviorSubject<{ + id: number; + name: string; + isMember: boolean; + }>({ + id: 0, + name: '', + isMember: false, + }); + + @Input() set playerId(value: number) { + this.player$.next({ ...this.player$.value, id: value }); + } + @Input() set playerName(value: string) { + this.player$.next({ ...this.player$.value, name: value }); + } + @Input() set isMember(value: boolean) { + this.player$.next({ ...this.player$.value, isMember: !!value }); + } + + isOpen = false; + + readonly settings$ = this.voipService.settings; + readonly volume$ = combineLatest([ + this.player$, + this.voipService.volumes, + ]).pipe(map(([player]) => this.voipService.getVolume(player.name))); + readonly state$ = combineLatest([ + this.player$, + this.voipService.settings, + this.voipService.volumes, + this.voipService.talking, + ]).pipe( + map(([player, settings, , talking]): SpeakerState => { + const heard = + settings.channel === VoipChannel.EVERYONE || + (settings.channel === VoipChannel.PARTY && player.isMember) || + (settings.channel === VoipChannel.STRANGERS && !player.isMember); + if (!settings.connect || !heard) { + return 'off'; + } + if (this.voipService.getVolume(player.name) === 0) { + return 'muted'; + } + return talking.has(player.id) ? 'talking' : 'quiet'; + }), + ); + readonly isMember$ = this.player$.pipe(map(player => player.isMember)); + readonly name$ = this.player$.pipe(map(player => player.name)); + + constructor(private readonly voipService: VoipService) {} + + icon(state: SpeakerState) { + switch (state) { + case 'talking': + return this.faVolumeHigh; + case 'quiet': + return this.faVolumeLow; + default: + return this.faVolumeXmark; + } + } + + toggle(): void { + this.isOpen = !this.isOpen; + } + + close(): void { + this.isOpen = false; + } + + setVolume(value: number): void { + const player = this.player$.value; + this.voipService.setVolume(player.id, player.name, Number(value)); + } +} diff --git a/Code/skyrim_ui/src/app/components/voip/voip.component.scss b/Code/skyrim_ui/src/app/components/voip/voip.component.scss new file mode 100644 index 0000000000..ecf9e5453b --- /dev/null +++ b/Code/skyrim_ui/src/app/components/voip/voip.component.scss @@ -0,0 +1,96 @@ +// Shared by the Voip column's mic header and speaker buttons. The popups render in the CDK overlay container, +// outside the component, so their styles are global through ::ng-deep. + +.voip-head { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +.voip-icon-button { + background: none; + border: 0; + padding: 0.1rem 0.3rem; + cursor: pointer; + color: #e8e4dc; + line-height: 1; + + &.off { + color: #8a857c; + } + + &.muted { + color: #d9534f; + } + + // Same colour as the party chat + &.party { + color: #9affe6; + } +} + +::ng-deep .voip-popup { + display: grid; + gap: 0.35rem; + width: 16rem; + margin-top: 0.4rem; + padding: 0.6rem 0.75rem; + background: #151515; + border: 1px solid grey; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6); + color: #e8e4dc; + font-size: 0.9rem; + + .row { + display: flex; + justify-content: space-between; + color: #a9a49b; + + b { + color: #e8e4dc; + font-variant-numeric: tabular-nums; + } + } + + input[type='range'] { + width: 100%; + } + + .voip-separator { + border-top: 1px solid #3a3833; + margin: 0.25rem 0 0.1rem; + } + + .meter { + position: relative; + height: 0.5rem; + background: #0d0d0c; + border: 1px solid #3a3833; + + .level { + position: absolute; + top: 0; + bottom: 0; + left: 0; + background: #6f6a60; + transition: width 60ms linear; + + &.open { + background: #8fd18a; + } + } + + .gate { + position: absolute; + top: -3px; + bottom: -3px; + width: 2px; + background: #e8e4dc; + } + } + + .hint { + font-size: 0.8rem; + color: #a9a49b; + } +} diff --git a/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts b/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts index 4d2a219fe8..b552282e36 100644 --- a/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts +++ b/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts @@ -29,6 +29,17 @@ export class SkyrimtogetherMock extends EventEmitter implements SkyrimTogether { private showEvents = true; private localPlayerId: number; public readonly players$ = playerStore.pipe(selectAllEntities()); + private voiceSettings: [ + string, + number, + string, + string, + number, + number, + number, + ] = ['', 0, '', 'everyone', 2500, 100, 10]; + private voiceMeterTimer: ReturnType | undefined; + private voiceTalkingTimer: ReturnType | undefined; connect(host: string, port: number, password: string): void { if (!this.connected) { @@ -363,6 +374,78 @@ export class SkyrimtogetherMock extends EventEmitter implements SkyrimTogether { console.log('setNameTagSettings', settings); } + voiceRefresh(): void { + this.emit( + 'voiceDevices', + [ + 'Microphone (Realtek High Definition Audio)', + 'Headset Microphone (HyperX Cloud II)', + ], + [ + 'Speakers (Realtek High Definition Audio)', + 'Headphones (HyperX Cloud II)', + ], + ); + this.emit('voiceSettings', ...this.voiceSettings); + } + + setVoiceSettings( + inputDevice: string, + connect: number, + outputDevice: string, + channel: string, + fadeStart: number, + micVolume: number, + noiseGate: number, + ): void { + this.voiceSettings = [ + inputDevice, + connect, + outputDevice, + channel, + fadeStart, + micVolume, + noiseGate, + ]; + this.emit('voiceSettings', ...this.voiceSettings); + + // Pretend some connected players talk now and then + clearInterval(this.voiceTalkingTimer); + this.voiceTalkingTimer = undefined; + if (connect) { + let tick = 0; + this.voiceTalkingTimer = setInterval(() => { + tick++; + const ids = playerStore + .query(getAllEntities()) + .filter((p, i) => (tick + i * 3) % 7 < 4) + .map(p => p.id); + this.emit('voiceTalking', ids); + }, 500); + } else { + this.emit('voiceTalking', []); + } + } + + setVoicePlayerVolume(playerId: number, volume: number): void {} + + setVoiceMeter(enabled: number): void { + clearInterval(this.voiceMeterTimer); + this.voiceMeterTimer = undefined; + if (enabled && this.voiceSettings[0] !== '') { + let tick = 0; + this.voiceMeterTimer = setInterval(() => { + tick++; + const speaking = tick % 40 < 22; + const raw = speaking ? 25 + Math.random() * 45 : 2 + Math.random() * 7; + this.emit( + 'voiceMicLevel', + Math.round((raw * this.voiceSettings[5]) / 100), + ); + }, 90); + } + } + openPlayGuide(): void { window.open( 'https://wiki.tiltedphoques.com/tilted-online/general-information/playguide', diff --git a/Code/skyrim_ui/src/app/services/voip.service.ts b/Code/skyrim_ui/src/app/services/voip.service.ts new file mode 100644 index 0000000000..32b8d09d30 --- /dev/null +++ b/Code/skyrim_ui/src/app/services/voip.service.ts @@ -0,0 +1,211 @@ +import { Injectable, NgZone } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { PlayerListService } from './player-list.service'; +import { StoreService } from './store.service'; + +export enum VoipChannel { + EVERYONE = 'everyone', + PARTY = 'party', + STRANGERS = 'strangers', +} + +/** Voice chat settings, saved by the game in the player's STServer.ini. */ +export interface VoipSettings { + /** Empty sends nothing, 'default' follows the Windows default device. */ + inputDevice: string; + connect: boolean; + /** Empty plays on the game's (the Windows default) device. */ + outputDevice: string; + channel: VoipChannel; + /** Full volume up to this distance in game units, silent at twice it. */ + fadeStart: number; + /** 0 to 100 */ + micVolume: number; + /** 0 to 100, like the Windows "Test your microphone" bar. */ + noiseGate: number; +} + +export const defaultVoipSettings: VoipSettings = { + inputDevice: '', + connect: false, + outputDevice: '', + channel: VoipChannel.EVERYONE, + fadeStart: 2500, + micVolume: 100, + noiseGate: 10, +}; + +/** Per player voice volumes by username, 0 to 1, kept across servers and sessions like the name overrides. */ +const volumesStoreKey = 'playerUi_voiceVolumes'; +const defaultPlayerVolume = 100; + +@Injectable({ + providedIn: 'root', +}) +export class VoipService { + public readonly settings = new BehaviorSubject( + defaultVoipSettings, + ); + public readonly inputDevices = new BehaviorSubject([]); + public readonly outputDevices = new BehaviorSubject([]); + /** Players whose voice is getting through their noise gate right now. */ + public readonly talking = new BehaviorSubject>(new Set()); + /** Local microphone level, 0 to 100, while the meter is on. */ + public readonly micLevel = new BehaviorSubject(0); + /** Per player volumes by username, 0 to 100. */ + public readonly volumes: BehaviorSubject>; + + constructor( + private readonly zone: NgZone, + private readonly storeService: StoreService, + private readonly playerListService: PlayerListService, + ) { + this.volumes = new BehaviorSubject(this.loadVolumes()); + + skyrimtogether.on('voiceSettings', this.onSettings.bind(this)); + skyrimtogether.on('voiceDevices', this.onDevices.bind(this)); + skyrimtogether.on('voiceTalking', this.onTalking.bind(this)); + skyrimtogether.on('voiceMicLevel', this.onMicLevel.bind(this)); + + // The game forgets volumes when it disconnects and only knows players by id, so they're sent again whenever + // the player list changes + this.playerListService.playerList.subscribe(() => this.sendVolumes()); + + this.refresh(); + } + + /** Asks the game for the saved settings and the device lists. */ + public refresh(): void { + skyrimtogether.voiceRefresh(); + } + + public update(changes: Partial): void { + const settings = { ...this.settings.value, ...changes }; + settings.fadeStart = Math.max( + 0, + Math.min(100000, Math.round(settings.fadeStart || 0)), + ); + settings.micVolume = this.clampPercent(settings.micVolume); + settings.noiseGate = this.clampPercent(settings.noiseGate); + this.settings.next(settings); + + skyrimtogether.setVoiceSettings( + settings.inputDevice, + settings.connect ? 1 : 0, + settings.outputDevice, + settings.channel, + settings.fadeStart, + settings.micVolume, + settings.noiseGate, + ); + } + + public getVolume(name: string): number { + return this.volumes.value[name] ?? defaultPlayerVolume; + } + + public setVolume(playerId: number, name: string, volume: number): void { + const clamped = this.clampPercent(volume); + const volumes = { ...this.volumes.value }; + if (clamped === defaultPlayerVolume) { + delete volumes[name]; + } else { + volumes[name] = clamped; + } + + this.volumes.next(volumes); + this.saveVolumes(volumes); + skyrimtogether.setVoicePlayerVolume(playerId, clamped); + } + + public setMeter(enabled: boolean): void { + if (!enabled) { + this.micLevel.next(0); + } + skyrimtogether.setVoiceMeter(enabled ? 1 : 0); + } + + private sendVolumes(): void { + const players = this.playerListService.playerList.value?.players ?? []; + for (const player of players) { + skyrimtogether.setVoicePlayerVolume( + player.id, + this.getVolume(player.name), + ); + } + } + + private onSettings( + inputDevice: string, + connect: number, + outputDevice: string, + channel: string, + fadeStart: number, + micVolume: number, + noiseGate: number, + ): void { + const knownChannel = Object.values(VoipChannel).includes( + channel as VoipChannel, + ) + ? (channel as VoipChannel) + : VoipChannel.EVERYONE; + + this.zone.run(() => { + this.settings.next({ + inputDevice, + connect: !!connect, + outputDevice, + channel: knownChannel, + fadeStart, + micVolume, + noiseGate, + }); + }); + } + + private onDevices(inputs: string[], outputs: string[]): void { + this.zone.run(() => { + this.inputDevices.next(inputs ?? []); + this.outputDevices.next(outputs ?? []); + }); + } + + private onTalking(playerIds: number[]): void { + this.zone.run(() => { + this.talking.next(new Set(playerIds ?? [])); + }); + } + + private onMicLevel(level: number): void { + this.zone.run(() => { + this.micLevel.next(this.clampPercent(level)); + }); + } + + private loadVolumes(): Record { + try { + const stored = JSON.parse(this.storeService.get(volumesStoreKey, '{}')); + const volumes: Record = {}; + for (const [name, value] of Object.entries(stored ?? {})) { + if (typeof value === 'number' && isFinite(value)) { + volumes[name] = this.clampPercent(value * 100); + } + } + return volumes; + } catch { + return {}; + } + } + + private saveVolumes(volumes: Record): void { + const stored: Record = {}; + for (const [name, value] of Object.entries(volumes)) { + stored[name] = value / 100; + } + this.storeService.set(volumesStoreKey, JSON.stringify(stored)); + } + + private clampPercent(value: number): number { + return Math.max(0, Math.min(100, Math.round(Number(value) || 0))); + } +} diff --git a/Code/skyrim_ui/src/assets/i18n/en.json b/Code/skyrim_ui/src/assets/i18n/en.json index bfe0e7b618..039be36f65 100644 --- a/Code/skyrim_ui/src/assets/i18n/en.json +++ b/Code/skyrim_ui/src/assets/i18n/en.json @@ -162,6 +162,25 @@ "AUDIO": "Audio", "MUTE_UI": "Mute User Interface", "UI_VOLUME": "User Interface Volume", + "VOICE": { + "HEADER": "Voice chat", + "INPUT_DEVICE": "Voip input device", + "INPUT_HINT": "None sends nothing, not even to test", + "INPUT_NONE": "None", + "INPUT_DEFAULT": "Windows default", + "CONNECT": "Connect to voip", + "CONNECT_HINT": "Hear and talk to nearby players on servers that allow it", + "OUTPUT_DEVICE": "Voip output device", + "OUTPUT_HINT": "Default follows the game's sound device", + "OUTPUT_GAME": "Game audio device (same as the game)", + "CHANNEL": "Voip channel", + "CHANNEL_HINT": "Party: only your party. Strangers: everyone but your party, e.g. when your party is on Discord", + "CHANNEL_EVERYONE": "Everyone", + "CHANNEL_PARTY": "Party", + "CHANNEL_STRANGERS": "Strangers", + "FADE_START": "Voip fade start", + "FADE_HINT": "Full volume up to this distance, silent at {{ silent }} units" + }, "PARTY": "Party", "SHOW_PARTY": "Show Party", "AUTO_HIDE_PARTY": "Auto Hide Party", @@ -256,6 +275,17 @@ "EXTRA_HEALTH": "Refined (+{{value}})", "CUSTOM_NAME": "Custom item" } + }, + "VOIP": { + "HEADER": "Voip", + "MIC_VOLUME": "Your microphone volume", + "NOISE_GATE": "Noise gate", + "NOISE_GATE_HINT": "Sound below the line is not sent.", + "NO_DEVICE_HINT": "Pick an input device in Settings to see your level.", + "NO_DEVICE": "No input device picked in Settings", + "SAVED_UNTIL_CONNECTED": "Saved, applies when Connected to Voip", + "PLAYER_VOLUME": "{{ name }} volume", + "MUTES_PLAYER": "0 mutes this player" } }, "SERVICE": { diff --git a/Code/skyrim_ui/src/styles.scss b/Code/skyrim_ui/src/styles.scss index 6948fd0368..ed93d672bd 100644 --- a/Code/skyrim_ui/src/styles.scss +++ b/Code/skyrim_ui/src/styles.scss @@ -1,3 +1,4 @@ +@import '@angular/cdk/overlay-prebuilt.css'; @import 'functions/mod'; @import 'functions/rem'; diff --git a/Code/skyrim_ui/src/typings.d.ts b/Code/skyrim_ui/src/typings.d.ts index 758980816c..4f59a73c6d 100644 --- a/Code/skyrim_ui/src/typings.d.ts +++ b/Code/skyrim_ui/src/typings.d.ts @@ -139,6 +139,29 @@ declare namespace SkyrimTogetherTypes { type TradeOfferEntry = { index: number; count: number }; type QuestUpdatedCallback = () => void; + + /** Voice chat settings, as saved in the player's STServer.ini. Volumes are 0 to 100. */ + type VoiceSettingsCallback = ( + inputDevice: string, + connect: number, + outputDevice: string, + channel: string, + fadeStart: number, + micVolume: number, + noiseGate: number, + ) => void; + + /** Audio devices Windows knows about, by name. */ + type VoiceDevicesCallback = ( + inputs: Array, + outputs: Array, + ) => void; + + /** Players whose voice is getting through their noise gate right now. */ + type VoiceTalkingCallback = (playerIds: Array) => void; + + /** The local microphone's peak level, 0 to 100, while the meter is on. */ + type VoiceMicLevelCallback = (level: number) => void; } /** Global Skyrim: Together object. */ @@ -545,6 +568,52 @@ interface SkyrimTogether { updateTradeOffer(entries: SkyrimTogetherTypes.TradeOfferEntry[]): void; openPlayGuide(): void; + /** Add listener to voice chat settings. */ + on( + event: 'voiceSettings', + callback: SkyrimTogetherTypes.VoiceSettingsCallback, + ): void; + + /** Add listener to the audio device lists. */ + on( + event: 'voiceDevices', + callback: SkyrimTogetherTypes.VoiceDevicesCallback, + ): void; + + /** Add listener to who is talking. */ + on( + event: 'voiceTalking', + callback: SkyrimTogetherTypes.VoiceTalkingCallback, + ): void; + + /** Add listener to the local microphone level. */ + on( + event: 'voiceMicLevel', + callback: SkyrimTogetherTypes.VoiceMicLevelCallback, + ): void; + + /** Re-read the voice settings and audio devices, they arrive as voiceSettings and voiceDevices. */ + voiceRefresh(): void; + + /** + * Save and apply voice chat settings. Numbers are whole numbers: volumes 0 to 100, the fade start in game units. + */ + setVoiceSettings( + inputDevice: string, + connect: number, + outputDevice: string, + channel: string, + fadeStart: number, + micVolume: number, + noiseGate: number, + ): void; + + /** Volume of one player's voice, 0 to 100. */ + setVoicePlayerVolume(playerId: number, volume: number): void; + + /** Turn the microphone meter on (1) or off (0). */ + setVoiceMeter(enabled: number): void; + /** * Configure the in-world player name tags. */ From 798b47ce0b94c042c933a39a450303e0cd4d9b09 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:26:35 +0000 Subject: [PATCH 46/97] Ship the Opus licence notice with the client Opus is BSD-3-Clause, which asks binary redistributions to include its copyright notice and licence. THIRD_PARTY_NOTICES.txt is copied next to SkyrimTogether.exe on install, so it ends up in the release zip. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxRbmmR1Uv7KRvfWVgW --- Code/client/xmake.lua | 2 ++ THIRD_PARTY_NOTICES.txt | 62 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 THIRD_PARTY_NOTICES.txt diff --git a/Code/client/xmake.lua b/Code/client/xmake.lua index 2855c5810a..6147dc8df7 100644 --- a/Code/client/xmake.lua +++ b/Code/client/xmake.lua @@ -26,6 +26,8 @@ target(name) local uidir = path.join(target:scriptdir(), "..", "skyrim_ui", "src") os.cp(path.join(uidir, "assets", "images", "cursor.dds"), path.join(target:installdir(), "bin", "assets", "images", "cursor.dds")) os.cp(path.join(uidir, "assets", "images", "cursor.png"), path.join(target:installdir(), "bin", "assets", "images", "cursor.png")) + -- licence notices of bundled libraries (Opus asks for its notice to ship with the binaries) + os.cp(path.join(target:scriptdir(), "..", "..", "THIRD_PARTY_NOTICES.txt"), path.join(target:installdir(), "bin", "THIRD_PARTY_NOTICES.txt")) os.rm(path.join(target:installdir(), "bin", "**Tests.exe")) end) diff --git a/THIRD_PARTY_NOTICES.txt b/THIRD_PARTY_NOTICES.txt new file mode 100644 index 0000000000..5d88b16cbf --- /dev/null +++ b/THIRD_PARTY_NOTICES.txt @@ -0,0 +1,62 @@ +Third-party notices for Skyrim Together +======================================= + +The game client includes the software below. Their licences ask for these +notices to ship with the binaries. + +------------------------------------------------------------------------------- +Opus 1.5.2 (https://opus-codec.org), voice chat codec, BSD-3-Clause +------------------------------------------------------------------------------- + +Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic, + Jean-Marc Valin, Timothy B. Terriberry, + CSIRO, Gregory Maxwell, Mark Borgerding, + Erik de Castro Lopo, Mozilla, Amazon + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +- Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +- Neither the name of Internet Society, IETF or IETF Trust, nor the +names of specific contributors, may be used to endorse or promote +products derived from this software without specific prior written +permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER +OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Opus is subject to the royalty-free patent licenses which are +specified at: + +Xiph.Org Foundation: +https://datatracker.ietf.org/ipr/1524/ + +Microsoft Corporation: +https://datatracker.ietf.org/ipr/1914/ + +Broadcom Corporation: +https://datatracker.ietf.org/ipr/1526/ + +------------------------------------------------------------------------------- +miniaudio 0.11.22 (https://miniaud.io), audio devices for voice chat +------------------------------------------------------------------------------- + +Copyright David Reid. Used under its public domain dedication (Unlicense), +alternatively MIT No Attribution. Neither requires this notice, it is listed +for completeness. From e364022f6a1898355fa89cedc14438d588a29c22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 19:42:49 +0000 Subject: [PATCH 47/97] Qualify MakeUnique in the voice audio device VoiceAudio.cpp called MakeUnique unqualified, which MSVC can't find in this tree (C3861). Use TiltedPhoques::MakeUnique like the rest of the client. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxNiMZ7kPLL1PGMuxYo --- Code/client/Services/Generic/VoiceAudio.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Code/client/Services/Generic/VoiceAudio.cpp b/Code/client/Services/Generic/VoiceAudio.cpp index abc670a19a..004607cc46 100644 --- a/Code/client/Services/Generic/VoiceAudio.cpp +++ b/Code/client/Services/Generic/VoiceAudio.cpp @@ -325,7 +325,7 @@ struct VoiceAudio::Impl }; VoiceAudio::VoiceAudio() noexcept - : m_pImpl(MakeUnique()) + : m_pImpl(TiltedPhoques::MakeUnique()) { } From eb61c3edbee67174563e18f7e48c636e6210c684 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 22:08:50 +0100 Subject: [PATCH 48/97] Treat death gold settings above one as flat fees Keep fractional penalties through 1.0, cap deductions at available gold, and reject invalid settings. Preserve decimal notation for floating-point INI defaults and cover the calculation with unit tests. --- .../console/IniSettingsProvider.cpp | 17 ++++++++++- Code/server/Services/GoldLoss.h | 25 ++++++++++++++++ Code/server/Services/PlayerService.cpp | 24 ++++++++------- Code/tests/gold_loss.cpp | 29 +++++++++++++++++++ 4 files changed, 84 insertions(+), 11 deletions(-) create mode 100644 Code/server/Services/GoldLoss.h create mode 100644 Code/tests/gold_loss.cpp diff --git a/Code/components/console/IniSettingsProvider.cpp b/Code/components/console/IniSettingsProvider.cpp index 1c148cdcc4..164cd7b333 100644 --- a/Code/components/console/IniSettingsProvider.cpp +++ b/Code/components/console/IniSettingsProvider.cpp @@ -10,6 +10,9 @@ #include #include +#include +#include +#include #include #include @@ -21,7 +24,19 @@ namespace template static SI_Error SetIniValue(CSimpleIni& ini, const T* a_pSection, const T* a_pKey, const TVal a_nValue, const T* a_pComment = nullptr) { char szValue[64]{}; - std::to_chars(szValue, szValue + sizeof(szValue), a_nValue); + const auto result = std::to_chars(szValue, szValue + sizeof(szValue) - 3, a_nValue); + if (result.ec != std::errc{}) + return SI_FAIL; + + // Keep floating-point defaults recognizable in generated settings (0.0, not 0). + if constexpr (std::is_floating_point_v) + { + if (std::isfinite(a_nValue) && !std::strpbrk(szValue, ".eE")) + { + result.ptr[0] = '.'; + result.ptr[1] = '0'; + } + } // convert to output text T szOutput[256]; diff --git a/Code/server/Services/GoldLoss.h b/Code/server/Services/GoldLoss.h new file mode 100644 index 0000000000..0f581a9453 --- /dev/null +++ b/Code/server/Services/GoldLoss.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include + +namespace GoldLoss +{ +inline bool IsValid(float aSetting) noexcept +{ + return std::isfinite(aSetting) && aSetting >= 0.0f; +} + +inline int32_t Calculate(int32_t aGoldCount, float aSetting) noexcept +{ + if (aGoldCount <= 0 || !IsValid(aSetting)) + return 0; + + // 0..1 is a fraction; larger values are a flat fee. Clamp before converting + // to an integer so even huge finite settings cannot overflow the deduction. + // Preserve the existing float multiplication for fractional penalties. + const double cAmount = aSetting <= 1.0f ? static_cast(aGoldCount * aSetting) : static_cast(aSetting); + return static_cast(std::min(cAmount, static_cast(aGoldCount))); +} +} diff --git a/Code/server/Services/PlayerService.cpp b/Code/server/Services/PlayerService.cpp index e887a6ae28..041b2621c2 100644 --- a/Code/server/Services/PlayerService.cpp +++ b/Code/server/Services/PlayerService.cpp @@ -3,6 +3,7 @@ #include "Events/PlayerLeaveCellEvent.h" #include +#include #include #include @@ -21,7 +22,7 @@ #include namespace { -Console::Setting fGoldLossFactor{"Gameplay:fGoldLossFactor", "How much of their gold players lose on death, from 0 to 1 (e.g. 0.05 loses 5%, 0 loses nothing)", 0.0f}; +Console::Setting fGoldLossFactor{"Gameplay:fGoldLossFactor", "Gold lost on death: 0.0 disables, 0.0 to 1.0 is a fraction (0.10 = 10%, 1.0 = all), above 1.0 is flat gold (10 = 10 gold); rounded down and capped at available gold", 0.0f}; } PlayerService::PlayerService(World& aWorld, entt::dispatcher& aDispatcher) noexcept @@ -152,7 +153,9 @@ void PlayerService::HandleInteriorCellEnter(const PacketEvent& acMessage) const noexcept { - float goldLossFactor = fGoldLossFactor.as_float(); + const float cGoldLossSetting = fGoldLossFactor.as_float(); + if (!GoldLoss::IsValid(cGoldLossSetting)) + spdlog::warn("Ignored invalid Gameplay:fGoldLossFactor {}; expected a finite, nonnegative value", cGoldLossSetting); auto character = acMessage.pPlayer->GetCharacter(); if (!character) @@ -164,17 +167,18 @@ void PlayerService::OnPlayerRespawnRequest(const PacketEvent(*it); + GameId goldId(0, 0xF); + const int32_t cGoldCount = inventoryComponent.Content.GetEntryCountById(goldId); + const int32_t cGoldToRemove = GoldLoss::Calculate(cGoldCount, cGoldLossSetting); + if (cGoldToRemove > 0) { - auto& inventoryComponent = view.get(*it); - - GameId goldId(0, 0xF); - int32_t goldCount = inventoryComponent.Content.GetEntryCountById(goldId); - int32_t goldToRemove = static_cast(goldCount * goldLossFactor); + spdlog::info("Death gold penalty for actor {:X}: setting {}, recorded balance {}, deducted {}", + World::ToInteger(*character), cGoldLossSetting, cGoldCount, cGoldToRemove); Inventory::Entry entry{}; entry.BaseId = goldId; - entry.Count = -goldToRemove; + entry.Count = -cGoldToRemove; inventoryComponent.Content.AddOrRemoveEntry(entry); @@ -191,7 +195,7 @@ void PlayerService::OnPlayerRespawnRequest(const PacketEventSend(notifyPlayerRespawn); } diff --git a/Code/tests/gold_loss.cpp b/Code/tests/gold_loss.cpp new file mode 100644 index 0000000000..64e13a5d71 --- /dev/null +++ b/Code/tests/gold_loss.cpp @@ -0,0 +1,29 @@ +#include "../server/Services/GoldLoss.h" +#include +#include + +TEST_CASE("Death gold settings distinguish fractions from flat amounts", "[gold-loss]") +{ + REQUIRE(GoldLoss::Calculate(360, 0.0f) == 0); + REQUIRE(GoldLoss::Calculate(360, 0.1f) == 36); + REQUIRE(GoldLoss::Calculate(360, 1.0f) == 360); + REQUIRE(GoldLoss::Calculate(360, 10.0f) == 10); + REQUIRE(GoldLoss::Calculate(360, 10.9f) == 10); + REQUIRE(GoldLoss::Calculate(360, 1.01f) == 1); + REQUIRE(GoldLoss::Calculate(3, 0.5f) == 1); + REQUIRE(GoldLoss::Calculate(10, 0.7f) == 7); + REQUIRE(GoldLoss::Calculate(3, 10.0f) == 3); +} + +TEST_CASE("Death gold penalties never create gold or overflow", "[gold-loss]") +{ + REQUIRE(GoldLoss::Calculate(0, 10.0f) == 0); + REQUIRE(GoldLoss::Calculate(-10, 0.5f) == 0); + REQUIRE(GoldLoss::Calculate(360, -1.0f) == 0); + REQUIRE(GoldLoss::Calculate(360, std::numeric_limits::infinity()) == 0); + REQUIRE(GoldLoss::Calculate(360, std::numeric_limits::quiet_NaN()) == 0); + REQUIRE(GoldLoss::Calculate(360, std::numeric_limits::max()) == 360); + constexpr int32_t kMax = std::numeric_limits::max(); + REQUIRE(GoldLoss::Calculate(kMax, 1.0f) == kMax); + REQUIRE(GoldLoss::Calculate(kMax, 10.0f) == 10); +} From da46b37f20611498182484cb8d77034051d043a8 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 21:44:16 +0100 Subject: [PATCH 49/97] Sync exterior locks across nearby cells Port the server relay change from Dosylia/urSovngarde 87ea449f. Nearby exterior players in the same worldspace receive lock updates while interior delivery stays cell-scoped. Validation: SkyrimTogetherServer release build passed using a temporary correction of the existing TiltedCore tag to v0.2.9. Two-client cell-boundary testing remains required. --- Code/server/Services/ObjectService.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Code/server/Services/ObjectService.cpp b/Code/server/Services/ObjectService.cpp index f0a07d7cd9..7f5d9d0e78 100644 --- a/Code/server/Services/ObjectService.cpp +++ b/Code/server/Services/ObjectService.cpp @@ -146,7 +146,11 @@ void ObjectService::OnLockChange(const PacketEvent& acMessage if (pPlayer == acMessage.pPlayer) continue; - if (pPlayer->GetCellComponent().Cell == acMessage.Packet.CellId) + const auto& senderCell = acMessage.pPlayer->GetCellComponent(); + const auto& recipientCell = pPlayer->GetCellComponent(); + // Exterior references can be loaded by players in neighboring cells. + const bool cNearbyExterior = !senderCell.IsInInteriorCell() && !recipientCell.IsInInteriorCell() && senderCell.IsInRange(recipientCell, false); + if (recipientCell.Cell == acMessage.Packet.CellId || cNearbyExterior) pPlayer->Send(notifyLockChange); } } From 55322b6690933eb5f17a47b5f19e44a43287fe34 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 22:02:22 +0100 Subject: [PATCH 50/97] Stop sending truncated client messages Adapt urSovngarde 347ce181 and local 35717e8d. Track failed buffer growth explicitly, retry outside the scratch allocator up to the transport's 512 KiB limit, and preserve packet flags. Validation: client release build and TPTests passed (76 assertions, 8 cases), including exact-size limits, failed writes followed by successful writes, and a 2000-plugin authentication round trip. --- .../Services/Generic/TransportService.cpp | 35 +++------- Code/client/Services/SendBuffer.h | 64 +++++++++++++++++++ Code/tests/send_buffer.cpp | 63 ++++++++++++++++++ 3 files changed, 136 insertions(+), 26 deletions(-) create mode 100644 Code/client/Services/SendBuffer.h create mode 100644 Code/tests/send_buffer.cpp diff --git a/Code/client/Services/Generic/TransportService.cpp b/Code/client/Services/Generic/TransportService.cpp index ad184f79d6..61de8cbf23 100644 --- a/Code/client/Services/Generic/TransportService.cpp +++ b/Code/client/Services/Generic/TransportService.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -29,9 +30,6 @@ // #include static constexpr wchar_t kMO2DllName[] = L"usvfs_x64.dll"; -static constexpr size_t kScratchSendSize = 1 << 16; -// GameNetworkingSockets rejects larger messages. -static constexpr size_t kMaxSendSize = 512 * 1024; using TiltedPhoques::Packet; @@ -81,41 +79,26 @@ bool TransportService::Send(const ClientMessage& acMessage, const TiltedPhoques: if (!IsConnected()) return false; - // A writer grows its buffer when it fills up, and writes fail silently once the allocator can't, which would - // send a truncated message. A buffer that had to grow is therefore treated as too small. { - ScopedAllocator _{s_allocator}; - - Buffer buffer(kScratchSendSize); - Buffer::Writer writer(&buffer); - writer.WriteBits(0, 8); // Write first byte as packet needs it - - acMessage.Serialize(writer); - if (buffer.GetSize() == kScratchSendSize) + SendBuffer::PacketBuffer buffer(acMessage, s_allocator, 1 << 16); + if (buffer.IsComplete()) { - TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetWriteData()), writer.Size()); + TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetData()), static_cast(buffer.Size())); Client::Send(&packet, aPacketFlags); return true; } } - // Rare large messages, such as a big inventory, are serialized again on the heap. - ScopedAllocator _{Allocator::GetDefault()}; - - Buffer buffer(kMaxSendSize); - Buffer::Writer writer(&buffer); - writer.WriteBits(0, 8); // Write first byte as packet needs it - - acMessage.Serialize(writer); - if (buffer.GetSize() != kMaxSendSize) + SendBuffer::PacketBuffer buffer(acMessage, *Allocator::GetDefault(), k_cbMaxSteamNetworkingSocketsMessageSizeSend); + if (!buffer.IsComplete()) { - spdlog::error("Message with opcode {} is over the {} byte limit, not sending it", static_cast(acMessage.GetOpcode()), kMaxSendSize); + spdlog::error("Message with opcode {} could not be serialized within the {} byte send limit", static_cast(acMessage.GetOpcode()), + k_cbMaxSteamNetworkingSocketsMessageSizeSend); return false; } - TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetWriteData()), writer.Size()); + TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetData()), static_cast(buffer.Size())); Client::Send(&packet, aPacketFlags); - return true; } diff --git a/Code/client/Services/SendBuffer.h b/Code/client/Services/SendBuffer.h new file mode 100644 index 0000000000..73c9463c16 --- /dev/null +++ b/Code/client/Services/SendBuffer.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include + +namespace SendBuffer +{ +// The writer does not retain failed-write state. Remember every rejected growth +// attempt, including a large write followed by smaller writes that still fit. +class LimitedAllocator final : public TiltedPhoques::Allocator +{ +public: + LimitedAllocator(TiltedPhoques::Allocator& aAllocator, size_t aCapacity) noexcept + : m_allocator(aAllocator), m_capacity(aCapacity) + { + } + + void* Allocate(size_t aSize) noexcept override + { + if (aSize > m_capacity) + { + m_failed = true; + return nullptr; + } + void* pData = m_allocator.Allocate(aSize); + m_failed |= pData == nullptr; + return pData; + } + void Free(void* apData) noexcept override { m_allocator.Free(apData); } + size_t Size(void* apData) noexcept override { return m_allocator.Size(apData); } + bool Failed() const noexcept { return m_failed; } + +private: + TiltedPhoques::Allocator& m_allocator; + size_t m_capacity; + bool m_failed = false; +}; + +class PacketBuffer +{ +public: + PacketBuffer(const ClientMessage& acMessage, TiltedPhoques::Allocator& aAllocator, size_t aCapacity) noexcept + : m_allocator(aAllocator, aCapacity), m_writer(&m_buffer) + { + m_buffer.SetAllocator(&m_allocator); + if (!m_buffer.Resize(aCapacity)) + return; + m_writer.WriteBits(0, 8); // Packet layer header. + acMessage.Serialize(m_writer); + } + + TP_NOCOPYMOVE(PacketBuffer); + bool IsComplete() const noexcept { return !m_allocator.Failed(); } + uint8_t* GetData() const noexcept { return m_buffer.GetWriteData(); } + size_t Size() const noexcept { return m_writer.Size(); } + +private: + LimitedAllocator m_allocator; + TiltedPhoques::Buffer m_buffer; + TiltedPhoques::Buffer::Writer m_writer; +}; +} diff --git a/Code/tests/send_buffer.cpp b/Code/tests/send_buffer.cpp new file mode 100644 index 0000000000..7cc7cc6230 --- /dev/null +++ b/Code/tests/send_buffer.cpp @@ -0,0 +1,63 @@ +#include "../client/Services/SendBuffer.h" +#include +#include +#include +#include + +namespace +{ +struct PayloadMessage final : ClientMessage +{ + explicit PayloadMessage(size_t aSize) : ClientMessage(kAuthenticationRequest), Data(aSize, 0x5A) {} + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override + { + aWriter.WriteBytes(Data.data(), Data.size()); + aWriter.WriteBits(0xAB, 8); // A successful write after a failed large write must not hide the failure. + } + std::vector Data; +}; +} + +TEST_CASE("Client send buffer rejects partial messages and accepts exact fits", "[encoding.send]") +{ + auto& allocator = *TiltedPhoques::Allocator::GetDefault(); + constexpr size_t kCapacity = 512 * 1024; + const PayloadMessage empty(0); + const SendBuffer::PacketBuffer overhead(empty, allocator, 64); + REQUIRE(overhead.IsComplete()); + + const PayloadMessage exact(kCapacity - overhead.Size()); + const SendBuffer::PacketBuffer full(exact, allocator, kCapacity); + REQUIRE(full.IsComplete()); + REQUIRE(full.Size() == kCapacity); + REQUIRE(full.GetData()[kCapacity - 1] == 0xAB); + + const PayloadMessage oversized(kCapacity); + const SendBuffer::PacketBuffer rejected(oversized, allocator, kCapacity); + REQUIRE_FALSE(rejected.IsComplete()); + REQUIRE(rejected.Size() < kCapacity); +} + +TEST_CASE("Large authentication retries after the scratch buffer overflows", "[encoding.send]") +{ + AuthenticationRequest request; + for (uint16_t i = 0; i < 2000; ++i) + request.UserMods.ModList.push_back({TiltedPhoques::String(40, 'x') + ".esp", i, true}); + TiltedPhoques::ScratchAllocator scratch(1 << 18); + const SendBuffer::PacketBuffer small(request, scratch, 1 << 16); + REQUIRE_FALSE(small.IsComplete()); + + const SendBuffer::PacketBuffer large(request, *TiltedPhoques::Allocator::GetDefault(), 512 * 1024); + REQUIRE(large.IsComplete()); + TiltedPhoques::Buffer copy(large.GetData(), large.Size()); + TiltedPhoques::Buffer::Reader reader(©); + uint64_t header = 1; + REQUIRE(reader.ReadBits(header, 8)); + REQUIRE(header == 0); + uint64_t opcode = 0; + REQUIRE(reader.ReadBits(opcode, 8)); + REQUIRE(opcode == kAuthenticationRequest); + AuthenticationRequest decoded; + decoded.DeserializeRaw(reader); + REQUIRE(decoded == request); +} From d78cb2a00fdb2d1da55db02d1f47ede60037b2e3 Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 22:02:22 +0100 Subject: [PATCH 51/97] Retry Papyrus natives that were unavailable at startup Port urSovngarde 9342bca4 lookup hardening. Synchronize our registry, cache only successful lookups, and skip missing calls with one diagnostic. Missing values are false, null or zero; void calls are not replayed. Ghidra MCP: SE 1.6.1170 registration ID 104788 at 0x141445FE0 locks the VM registration table. Our queued registry access is source-verified. Native ABI, positive function-pointer lifetime and 1.7.104 equivalence remain assumed; hooks are unchanged. Client release build passed; delayed-registration gameplay testing remains required. --- .../Services/Generic/PapyrusService.cpp | 33 ++++++++ Code/client/Services/PapyrusService.h | 82 +++++++++++++++---- 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/Code/client/Services/Generic/PapyrusService.cpp b/Code/client/Services/Generic/PapyrusService.cpp index 6c644d540e..6c66a956d4 100644 --- a/Code/client/Services/Generic/PapyrusService.cpp +++ b/Code/client/Services/Generic/PapyrusService.cpp @@ -4,6 +4,8 @@ #include +#include + PapyrusService::PapyrusService(entt::dispatcher& aDispatcher) noexcept { m_papyrusFunctionRegisterConnection = aDispatcher.sink().connect<&PapyrusService::HandlePapyrusFunctionEvent>(this); @@ -11,6 +13,7 @@ PapyrusService::PapyrusService(entt::dispatcher& aDispatcher) noexcept const void* PapyrusService::Get(const String& acNamespace, const String& acFunction) const noexcept { + std::shared_lock lock(m_lock); const auto itor = m_functions.find(acNamespace + "::" + acFunction); if (itor != std::end(m_functions)) return itor->second; @@ -18,7 +21,37 @@ const void* PapyrusService::Get(const String& acNamespace, const String& acFunct return nullptr; } +const void* PapyrusLookup::Resolve() const noexcept +{ + if (const void* pFunction = m_pFunction.load(std::memory_order_acquire)) + return pFunction; + + const auto& papyrus = World::Get().ctx().at(); + const void* pFunction = papyrus.Get(m_pScope, m_pName); + if (!pFunction) + { + if (!m_reported.test_and_set()) + spdlog::error("Papyrus: {}.{} not found among the {} functions recorded; not called until it is", m_pScope, m_pName, papyrus.FunctionCount()); + return nullptr; + } + + m_pFunction.store(pFunction, std::memory_order_release); + + // Once, so every log says how many natives were recorded by the time they were first used. + static std::atomic_flag s_counted = ATOMIC_FLAG_INIT; + if (!s_counted.test_and_set()) + spdlog::info("Papyrus: {} native functions recorded at the first call ({}.{})", papyrus.FunctionCount(), m_pScope, m_pName); + return pFunction; +} + +size_t PapyrusService::FunctionCount() const noexcept +{ + std::shared_lock lock(m_lock); + return m_functions.size(); +} + void PapyrusService::HandlePapyrusFunctionEvent(const PapyrusFunctionRegisterEvent& acEvent) noexcept { + std::unique_lock lock(m_lock); m_functions[acEvent.Namespace + "::" + acEvent.Name] = acEvent.Function; } diff --git a/Code/client/Services/PapyrusService.h b/Code/client/Services/PapyrusService.h index 13737da5c7..79366cd7a2 100644 --- a/Code/client/Services/PapyrusService.h +++ b/Code/client/Services/PapyrusService.h @@ -2,6 +2,11 @@ #include +#include +#include +#include +#include + struct TESForm; struct TESObjectREFR; struct PapyrusFunctionRegisterEvent; @@ -17,43 +22,86 @@ struct PapyrusService TP_NOCOPYMOVE(PapyrusService); const void* Get(const String& acNamespace, const String& acFunction) const noexcept; + size_t FunctionCount() const noexcept; void HandlePapyrusFunctionEvent(const PapyrusFunctionRegisterEvent&) noexcept; private: + // Written on our update as the game registers its natives, read from any thread that calls one. + mutable std::shared_mutex m_lock; Map m_functions; entt::scoped_connection m_papyrusFunctionRegisterConnection; }; +//! Retry missing natives instead of permanently caching a null startup lookup. +class PapyrusLookup +{ +public: + PapyrusLookup(const char* acpScope, const char* acpName) noexcept + : m_pScope(acpScope) + , m_pName(acpName) + { + } + + //! The function, or null while it is not recorded. + const void* Resolve() const noexcept; + +private: + const char* m_pScope; + const char* m_pName; + mutable std::atomic m_pFunction{nullptr}; + mutable std::atomic_flag m_reported = ATOMIC_FLAG_INIT; +}; + +// Missing predicates return false, pointers return null, and GetOpenState returns kNone. +// Void operations are skipped; they are not queued for replay. +template Return MissingPapyrusResult() noexcept +{ + if constexpr (!std::is_void_v) + return Return{}; +} + template struct PapyrusFunction { using TFunction = Return(__fastcall*)(BSScript::IVirtualMachine*, uint32_t, const Type*, Args...); - PapyrusFunction(const void* apAddress) - : m_pFunction(reinterpret_cast(apAddress)) + PapyrusFunction(const char* acpScope, const char* acpName) noexcept + : m_lookup(acpScope, acpName) { } - Return operator()(const Type* apThis, Args... args) const noexcept { return m_pFunction(GameVM::Get()->virtualMachine, 0, apThis, std::forward(args)...); } + Return operator()(const Type* apThis, Args... args) const noexcept + { + const auto pFunction = reinterpret_cast(m_lookup.Resolve()); + if (!pFunction) + return MissingPapyrusResult(); + return pFunction(GameVM::Get()->virtualMachine, 0, apThis, std::forward(args)...); + } private: - TFunction m_pFunction; + PapyrusLookup m_lookup; }; template struct GlobalPapyrusFunction { using TFunction = Return(__fastcall*)(BSScript::IVirtualMachine*, Args...); - GlobalPapyrusFunction(const void* apAddress) - : m_pFunction(reinterpret_cast(apAddress)) + GlobalPapyrusFunction(const char* acpScope, const char* acpName) noexcept + : m_lookup(acpScope, acpName) { } - Return operator()(Args... args) const noexcept { return m_pFunction(GameVM::Get()->virtualMachine, std::forward(args)...); } + Return operator()(Args... args) const noexcept + { + const auto pFunction = reinterpret_cast(m_lookup.Resolve()); + if (!pFunction) + return MissingPapyrusResult(); + return pFunction(GameVM::Get()->virtualMachine, std::forward(args)...); + } private: - TFunction m_pFunction; + PapyrusLookup m_lookup; }; struct RefrOrInventoryObj @@ -67,22 +115,26 @@ template struct LatentPapyrusFunction { using TFunction = Return(__fastcall*)(BSScript::IVirtualMachine*, uint32_t, const RefrOrInventoryObj&, Args...); - LatentPapyrusFunction(const void* apAddress) - : m_pFunction(reinterpret_cast(apAddress)) + LatentPapyrusFunction(const char* acpScope, const char* acpName) noexcept + : m_lookup(acpScope, acpName) { } Return operator()(const Type* apThis, Args... args) const noexcept { + const auto pFunction = reinterpret_cast(m_lookup.Resolve()); + if (!pFunction) + return MissingPapyrusResult(); + RefrOrInventoryObj self{apThis, nullptr, 0}; - return m_pFunction(GameVM::Get()->virtualMachine, 0, self, std::forward(args)...); + return pFunction(GameVM::Get()->virtualMachine, 0, self, std::forward(args)...); } private: - TFunction m_pFunction; + PapyrusLookup m_lookup; }; -#define PAPYRUS_FUNCTION(returnType, scope, name, ...) static PapyrusFunction s_p##name(World::Get().ctx().at().Get(#scope, #name)); -#define GLOBAL_PAPYRUS_FUNCTION(returnType, scope, name, ...) static GlobalPapyrusFunction s_p##name(World::Get().ctx().at().Get(#scope, #name)); -#define LATENT_PAPYRUS_FUNCTION(returnType, scope, name, ...) static LatentPapyrusFunction s_p##name(World::Get().ctx().at().Get(#scope, #name)); +#define PAPYRUS_FUNCTION(returnType, scope, name, ...) static PapyrusFunction s_p##name(#scope, #name); +#define GLOBAL_PAPYRUS_FUNCTION(returnType, scope, name, ...) static GlobalPapyrusFunction s_p##name(#scope, #name); +#define LATENT_PAPYRUS_FUNCTION(returnType, scope, name, ...) static LatentPapyrusFunction s_p##name(#scope, #name); From 6efb569c35c64aec94c8abac704656988ecf994e Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 22:02:23 +0100 Subject: [PATCH 52/97] Defer actor inventories until their process is loaded Adapt the inventory portion of urSovngarde 7712c3e6. Store pending snapshots on entities with ownership epochs, preserve subsequent inventory and equipment updates in order, and clear deferred state on removal or disconnect. Require process and 3D readiness for spawn and ownership grants. Ghidra MCP, SE 1.6.1170: equip ID 38894 at 0x1406C9820 reaches 0x1406CBE30, reading Actor+0xF8; spell equip ID 38896 reaches 0x1406CBAD0 with the same process access. CommonLib places middleHigh at AIProcess+0x8, now asserted. This is a conservative readiness gate; the exact fork crash, all engine threading guarantees and 1.7.104 equivalence remain unproven. Client release build passed; two-client loading, loot, transfer and disconnect tests remain required. Adapted to API-Together-1.8.7-8: the inventory and equipment handlers use OwnershipSystem and queue on WaitingFor3D first, so the deferred inventory only covers actors whose 3D is loaded but whose process isn't. Spawns no longer add one, since WaitingFor3D already carries the spawn inventory and now waits for the process too, and ReconcileActorData skips it while the actor waits for its 3D. Each change is queued and applied once. --- Code/client/Components.h | 1 + .../Components/PendingInventoryComponent.h | 22 +++++++ Code/client/Games/Skyrim/AI/AIProcess.h | 1 + Code/client/Services/CharacterService.h | 1 + .../Services/Generic/CharacterService.cpp | 58 ++++++++++++++++--- .../Services/Generic/InventoryService.cpp | 13 +++++ 6 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 Code/client/Components/PendingInventoryComponent.h diff --git a/Code/client/Components.h b/Code/client/Components.h index 0de09050e3..ddbb1dfbce 100644 --- a/Code/client/Components.h +++ b/Code/client/Components.h @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include diff --git a/Code/client/Components/PendingInventoryComponent.h b/Code/client/Components/PendingInventoryComponent.h new file mode 100644 index 0000000000..e47819629d --- /dev/null +++ b/Code/client/Components/PendingInventoryComponent.h @@ -0,0 +1,22 @@ +#pragma once + +#ifndef TP_INTERNAL_COMPONENTS_GUARD +#error Include Components.h instead +#endif + +#include +#include +#include +#include + +struct PendingInventoryComponent +{ + PendingInventoryComponent(const Inventory& acInventory, uint32_t aOwnershipEpoch) + : Content(acInventory), OwnershipEpoch(aOwnershipEpoch) + { + } + + Inventory Content; + uint32_t OwnershipEpoch; + Vector> Updates; +}; diff --git a/Code/client/Games/Skyrim/AI/AIProcess.h b/Code/client/Games/Skyrim/AI/AIProcess.h index d08fb69ec5..35ffa2517b 100644 --- a/Code/client/Games/Skyrim/AI/AIProcess.h +++ b/Code/client/Games/Skyrim/AI/AIProcess.h @@ -36,3 +36,4 @@ struct HighProcessData }; static_assert(offsetof(AIProcess, movementType) == 0x137); +static_assert(offsetof(AIProcess, middleProcess) == 0x8); diff --git a/Code/client/Services/CharacterService.h b/Code/client/Services/CharacterService.h index 031d4e24ce..35155205cd 100644 --- a/Code/client/Services/CharacterService.h +++ b/Code/client/Services/CharacterService.h @@ -109,6 +109,7 @@ struct CharacterService void RunSpawnUpdates() const noexcept; void RunExperienceUpdates() noexcept; void ApplyCachedWeaponDraws(const UpdateEvent& acUpdateEvent) noexcept; + void ApplyPendingInventories() noexcept; World& m_world; entt::dispatcher& m_dispatcher; diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 8363fd0e55..659aa0c12a 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -70,6 +70,15 @@ #include #include +#include + +namespace +{ +bool CanApplyInventory(Actor* apActor) noexcept +{ + return apActor && apActor->currentProcess && apActor->currentProcess->middleProcess && apActor->GetNiNode(); +} +} CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept : m_world(aWorld) @@ -118,7 +127,7 @@ CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher, void CharacterService::DeleteRemoteEntityComponents(entt::entity aEntity) const noexcept { - m_world.remove(aEntity); + m_world.remove(aEntity); } void CharacterService::DeclineOwnership(const uint32_t aServerId, const uint32_t aOwnershipEpoch) const noexcept @@ -144,12 +153,18 @@ void CharacterService::ReconcileActorData( pWaitingFor3D->PendingEquipmentChanges.clear(); } + // A new canonical snapshot supersedes deferred data from the previous owner. + m_world.remove(aEntity); + // While the actor waits for its 3D, the spawn request above already carries the snapshot. + if (aApplyInventory && !CanApplyInventory(apActor) && !m_world.all_of(aEntity)) + m_world.emplace(aEntity, acActorData.InitialInventory, aOwnershipEpoch); + if (!apActor) return; apActor->SetActorValues(acActorData.InitialActorValues); - if (aApplyInventory) + if (aApplyInventory && CanApplyInventory(apActor)) { const Inventory currentInventory = apActor->GetActorInventory(); if (currentInventory.Entries != acActorData.InitialInventory.Entries || currentInventory.CurrentMagicEquipment != acActorData.InitialInventory.CurrentMagicEquipment) @@ -289,7 +304,7 @@ void CharacterService::OnActorRemoved(const ActorRemovedEvent& acEvent) noexcept auto& formIdComponent = view.get(cId); CancelServerAssignment(*entityIt, formIdComponent.Id); - m_world.remove(cId); + m_world.remove(cId); if (m_world.all_of(cId)) m_world.remove(cId); @@ -306,6 +321,7 @@ void CharacterService::OnUpdate(const UpdateEvent& acUpdateEvent) noexcept RunLocalUpdates(); RunFactionsUpdates(); RunRemoteUpdates(); + ApplyPendingInventories(); RunExperienceUpdates(); ApplyCachedWeaponDraws(acUpdateEvent); ProcessLeveledConforms(); @@ -351,7 +367,7 @@ void CharacterService::OnDisconnected(const DisconnectedEvent& acDisconnectedEve pActor->GetExtension()->SetRemote(false); } - m_world.clear(); + m_world.clear(); for (const auto& [formId, pickFormId] : m_pendingLeveledConforms) { @@ -838,7 +854,7 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess if (isLocalOwner) { - if (!pFormIdComponent || !pActor || !pActor->GetNiNode()) + if (!pFormIdComponent || !CanApplyInventory(pActor)) { uint32_t cachedRefId = pFormIdComponent ? pFormIdComponent->Id : 0; if (const auto* pRemoteComponent = m_world.try_get(cEntity)) @@ -902,7 +918,7 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess pRemoteComponent->OwnershipEpoch = acMessage.OwnershipEpoch; } - ReconcileActorData(cEntity, pActor, acMessage.OwnershipEpoch, acMessage.CurrentActorData, pActor && pActor->GetNiNode(), false); + ReconcileActorData(cEntity, pActor, acMessage.OwnershipEpoch, acMessage.CurrentActorData, true, false); if (pActor) ApplyLeveledNpcPick(pActor, acMessage.LeveledNpcPickId); @@ -2005,7 +2021,7 @@ void CharacterService::RunRemoteUpdates() noexcept auto& waitingFor3D = waitingView.get(entity); Actor* pActor = Cast(TESForm::GetById(formIdComponent.Id)); - if (!pActor || !pActor->GetNiNode()) + if (!CanApplyInventory(pActor)) continue; // By now, the actor has materialized in the world and is ready for further setup @@ -2156,6 +2172,34 @@ void CharacterService::RunExperienceUpdates() noexcept spdlog::debug("Sending over experience {}", message.Experience); } +void CharacterService::ApplyPendingInventories() noexcept +{ + auto view = m_world.view(entt::exclude); + for (auto entity : view) + { + const auto& remote = view.get(entity); + auto& pending = view.get(entity); + if (remote.OwnershipEpoch != pending.OwnershipEpoch) + { + m_world.remove(entity); + continue; + } + + Actor* pActor = Cast(TESForm::GetById(view.get(entity).Id)); + if (!CanApplyInventory(pActor)) + continue; + + const Inventory current = pActor->GetActorInventory(); + if (current.Entries != pending.Content.Entries || current.CurrentMagicEquipment != pending.Content.CurrentMagicEquipment) + pActor->SetActorInventory(pending.Content); + + auto updates = std::move(pending.Updates); + m_world.remove(entity); + for (const auto& update : updates) + std::visit([this](const auto& acMessage) { m_dispatcher.trigger(acMessage); }, update); + } +} + void CharacterService::ApplyCachedWeaponDraws(const UpdateEvent& acUpdateEvent) noexcept { std::vector toRemove{}; diff --git a/Code/client/Services/Generic/InventoryService.cpp b/Code/client/Services/Generic/InventoryService.cpp index fc901987ed..71c12f275a 100644 --- a/Code/client/Services/Generic/InventoryService.cpp +++ b/Code/client/Services/Generic/InventoryService.cpp @@ -159,6 +159,13 @@ void InventoryService::OnNotifyInventoryChanges(const NotifyInventoryChanges& ac return; } + // The actor's process isn't loaded yet; the change is applied after its deferred inventory + if (auto* pPending = m_world.try_get(*remoteEntity); pPending && pPending->OwnershipEpoch == acMessage.OwnershipEpoch) + { + pPending->Updates.emplace_back(acMessage); + return; + } + if (const auto* pFormIdComponent = m_world.try_get(*remoteEntity)) pActor = Cast(TESForm::GetById(pFormIdComponent->Id)); } @@ -205,6 +212,12 @@ void InventoryService::OnNotifyEquipmentChanges(const NotifyEquipmentChanges& ac return; } + if (auto* pPending = m_world.try_get(*entity); pPending && pPending->OwnershipEpoch == acMessage.OwnershipEpoch) + { + pPending->Updates.emplace_back(acMessage); + return; + } + const auto* pFormIdComponent = m_world.try_get(*entity); if (!pFormIdComponent) return; From 1f123dc6105d25f06cb0e06cc692fcd820d485db Mon Sep 17 00:00:00 2001 From: absol89 Date: Mon, 2 Nov 2026 23:30:35 +0100 Subject: [PATCH 53/97] fix/CharacterService Dead NPC stands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I found the relevant fix in 34fe6f91, plus a correction in 83be966c. The problem is timing: applying zero health before an NPC’s body loads can mark it dead before it gets a chance to fall over. The fix keeps a newly arriving copy briefly alive until its body is ready, then applies the death. The correction prevents doing that to an NPC that is already dead locally. I’m checked the Skyrim 1.6.1170 death routine before adapting those changes to our existing loading flow. Used Ghidra integration, ran commands In the 1.6.1170 binary, the death routine at 0x1406972D0 (Address Library ID 37896) has an early-return state check and separate handling when the actor’s process or loaded body is missing. That supports deferring the death until loading completes, though the visible result still needs an in-game test. I ported the health and loading guards, including the “already dead locally” correction. The fork’s VR-specific corpse repair code isn’t needed for this port. The port is in place across new spawns, recreated actors, and reconciliation. Already-dead NPCs retain their original health values. The client build has compiled the changed service without errors and is continuing through the remaining files. This port uses the existing WaitingFor3D step and adds no hooks or offsets. The key in-game check is to kill an NPC, have a second player enter the cell, then leave and re-enter. The corpse should lie down correctly on each client, while bodies already dead locally should stay untouched. The client release build passed. I’ll restore the temporary build-file change, commit this as the fifth reliability fix, and push it to the same fork branch. --- .../Services/Generic/CharacterService.cpp | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 659aa0c12a..982a1ea23b 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -74,6 +75,16 @@ namespace { +ActorValues ValuesUntilKilled(const ActorValues& acValues) noexcept +{ + // Zero health can kill a newly loaded copy before its body is ready to fall. + // Only use this for a living NPC that will be killed after loading its 3D. + ActorValues values = acValues; + if (auto it = values.ActorValuesList.find(ActorValueInfo::kHealth); it != values.ActorValuesList.end() && it->second <= 0.f) + it.value() = 1.f; + return values; +} + bool CanApplyInventory(Actor* apActor) noexcept { return apActor && apActor->currentProcess && apActor->currentProcess->middleProcess && apActor->GetNiNode(); @@ -162,7 +173,9 @@ void CharacterService::ReconcileActorData( if (!apActor) return; - apActor->SetActorValues(acActorData.InitialActorValues); + apActor->SetActorValues(acActorData.IsDead && !apActor->GetExtension()->IsPlayer() && !apActor->IsDead() + ? ValuesUntilKilled(acActorData.InitialActorValues) + : acActorData.InitialActorValues); if (aApplyInventory && CanApplyInventory(apActor)) { @@ -175,8 +188,12 @@ void CharacterService::ReconcileActorData( { if (acActorData.IsDead) { - spdlog::info("Killing actor {:X} to match server state, local owner: {}", apActor->formID, aIsLocalOwner); - apActor->Kill(); + // Remote spawns finish their death transition in the WaitingFor3D step. + if (aIsLocalOwner || !m_world.any_of(aEntity)) + { + spdlog::info("Killing actor {:X} to match server state, local owner: {}", apActor->formID, aIsLocalOwner); + apActor->Kill(); + } } // Respawn resets the reference, which re-rolls leveled actors and reloads their 3D. The owner would // rediscover it as a new actor, so keep the local death and let the death state sync report it. @@ -672,7 +689,9 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) pActor->rotation.x = acMessage.Rotation.x; pActor->rotation.z = acMessage.Rotation.y; pActor->MoveTo(PlayerCharacter::Get()->parentCell, acMessage.Position); - pActor->SetActorValues(acMessage.InitialActorValues); + pActor->SetActorValues(acMessage.IsDead && !acMessage.IsPlayer && !pActor->IsDead() + ? ValuesUntilKilled(acMessage.InitialActorValues) + : acMessage.InitialActorValues); pActor->GetExtension()->SetPlayer(acMessage.IsPlayer); if (acMessage.IsPlayer) @@ -682,7 +701,8 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) m_world.emplace_or_replace(*entity, acMessage.PlayerId); } - if (pActor->IsDead() != acMessage.IsDead) + // Let WaitingFor3D kill a new corpse once its loaded body can fall. + if (pActor->IsDead() != acMessage.IsDead && (!acMessage.IsDead || CanApplyInventory(pActor))) acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); spdlog::info("Spawn Request Is summon {}", acMessage.IsPlayerSummon); @@ -1710,7 +1730,9 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no pActor->rotation.x = acMessage.Rotation.x; pActor->rotation.z = acMessage.Rotation.y; pActor->MoveTo(PlayerCharacter::Get()->parentCell, pInterpolationComponent->Position); - pActor->SetActorValues(acMessage.InitialActorValues); + pActor->SetActorValues(acMessage.IsDead && !acMessage.IsPlayer && !pActor->IsDead() + ? ValuesUntilKilled(acMessage.InitialActorValues) + : acMessage.InitialActorValues); pActor->GetExtension()->SetPlayer(acMessage.IsPlayer); if (acMessage.IsPlayer) @@ -1720,7 +1742,8 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no m_world.emplace_or_replace(aEntity, acMessage.PlayerId); } - if (pActor->IsDead() != acMessage.IsDead) + // Recreated copies use the same WaitingFor3D death transition as new spawns. + if (pActor->IsDead() != acMessage.IsDead && (!acMessage.IsDead || CanApplyInventory(pActor))) acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); spdlog::info("Spawned character for entity, server id: {:X}", remoteComponent.Id); From c738d75c24da1a0a3b2f3252d279912aa5331424 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:55:00 +0000 Subject: [PATCH 54/97] README: features in the v1.8.7-8 playtest Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxNiMZ7kPLL1PGMuxYo --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index eecbb99a6f..0d1b123efc 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,38 @@ TiltedEvolution is a framework created to enable multiplayer in Bethesda games, currently supporting **Skyrim Special Edition**. +## v1.8.7-8 playtest +This branch (`API-Together-1.8.7-8`, tag `v1.8.7-8`) is a community playtest build of this fork. It is not an official Skyrim Together release. Please report anything below that misbehaves, ideally with two or more players and the client and server logs. + +### New features +- **Proximity voice chat**: built in and relayed through the game server, so it needs no separate program or port. Uses Opus. Servers turn it on with `Voice:bVoip`. Players pick an input device and *Connect to voip* in Settings, and can set per-player volumes and a noise gate from the player list. +- **Player-to-player trading**: invites, offers, ready-up countdown, crafted potions and enchanted items, and a trade invitation setting. +- **Name tags and privacy**: name tags above players with on/party/off filters, "Known names" (players outside your party show as *Stranger*), floating chat lines above heads, and Swedish strings. +- **Item drops** (upstream #913, draft): dropped items are synced as server-tracked references. Other players' crafted potions spawn from their recipe. +- **Mod animation sync and `/wave`** (upstream #879, DJLegends). + +### New server settings +- `Gameplay:bAllowConsoleCommands` (default `true`). When `false`, commands typed into the console are refused, but the console still opens for reading reference ids, and mods can still run commands. `ToggleConsoleCommands` switches it while the server runs. +- `Voice:bVoip` (default `false`): proximity voice chat. +- `Gameplay:fGoldLossFactor`: values above 1.0 are now a flat amount of gold lost on death. +- Server settings can be overridden with environment variables (upstream #936). + +### Sync and stability fixes to test +- NPC ownership and inventories (upstream #932, miredirex): no stale updates after a missed transfer, no duplicate owners, outfits kept through assignment. Inventories wait until the NPC's body and process are loaded. A snapshot without worn items no longer strips a living NPC. +- Dead NPCs stay down when another player loads the cell. +- Exterior door and container locks sync across nearby cells. +- Large messages (big inventories, long mod lists) are no longer sent cut off. +- Sleeping or waiting advances the shared time (upstream #897). +- Weapon draw and sheathe state is restored when refused (upstream #898). Stale object replication bindings are cleaned up (upstream #891). +- Crash fixes: an activated object deleted before activation, a missing launched projectile, teleporting a remote actor into an unloaded exterior cell, and Papyrus natives that weren't ready at startup. +- Summons no longer get death items. Unresolved spells and shouts aren't equipped. Remote actors don't look crouched with different behavior mods. Torso-less remote players get an invisible body. Beast form leave and the Riften North Gate guard scene are fixed. +- Floating Subtitles can hook subtitles alongside Skyrim Together. +- Server list announces over IPv4 only. + +### Playtest-only diagnostics +- mimalloc secure mode: heap corruption crashes with a log in `logs\mimalloc_errors.log` instead of freezing. +- Tag builds that aren't on master keep F6/F7 and the console. The console follows Skyrim Together Tweaks' `bEnableConsole`. + ## Getting started To play Skyrim Together, go to the [nexus page](https://www.nexusmods.com/skyrimspecialedition/mods/69993). From fc6bb44667db0e4363a41fce55bec555cb60030f Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 3 Nov 2026 13:33:45 +0100 Subject: [PATCH 55/97] Restore the V1 plugin API with optional nearby channels Restore DJLegends' pre-V2 party-message API so STCampfireSync can resolve SkyrimTogetherRequestPluginAPI. Preserve the launcher export directory and exclude the generic RequestPluginAPI name. No Plugin API V2 implementation is included. Add a separate scoped-registration export without changing the V1 vtable. Party-only remains the default; plugins can choose nearby recipients with server worldspace/interior and range checks. Publish recipient changes from locked snapshots after World updates. Validation: launcher and server release builds passed, both specific exports verified, and TPTests passed 396 assertions in 37 cases. Runtime export visibility and two-client audience/late-arrival tests remain. --- Code/client/PluginAPI.cpp | 19 ++ .../client/Services/Generic/PluginService.cpp | 185 ++++++++++++++++++ Code/client/Services/PluginService.h | 58 ++++++ Code/client/World.cpp | 6 + Code/encoding/Messages/ClientMessageFactory.h | 3 +- Code/encoding/Messages/ModMessage.h | 44 +++++ Code/encoding/Messages/NotifyModMessage.cpp | 26 +++ Code/encoding/Messages/NotifyModMessage.h | 32 +++ .../Messages/SendModMessageRequest.cpp | 26 +++ .../encoding/Messages/SendModMessageRequest.h | 33 ++++ Code/encoding/Messages/ServerMessageFactory.h | 3 +- Code/encoding/Opcodes.h | 2 + Code/encoding/xmake.lua | 2 +- Code/immersive_launcher/loader/ExeLoader.cpp | 3 + Code/plugin_api/README.md | 60 ++++++ Code/plugin_api/include/SkyrimTogetherAPI.h | 86 ++++++++ Code/server/Services/ModMessageService.cpp | 103 ++++++++++ Code/server/Services/ModMessageService.h | 41 ++++ Code/server/World.cpp | 2 + Code/tests/encoding.cpp | 87 ++++++++ 20 files changed, 818 insertions(+), 3 deletions(-) create mode 100644 Code/client/PluginAPI.cpp create mode 100644 Code/client/Services/Generic/PluginService.cpp create mode 100644 Code/client/Services/PluginService.h create mode 100644 Code/encoding/Messages/ModMessage.h create mode 100644 Code/encoding/Messages/NotifyModMessage.cpp create mode 100644 Code/encoding/Messages/NotifyModMessage.h create mode 100644 Code/encoding/Messages/SendModMessageRequest.cpp create mode 100644 Code/encoding/Messages/SendModMessageRequest.h create mode 100644 Code/plugin_api/README.md create mode 100644 Code/plugin_api/include/SkyrimTogetherAPI.h create mode 100644 Code/server/Services/ModMessageService.cpp create mode 100644 Code/server/Services/ModMessageService.h diff --git a/Code/client/PluginAPI.cpp b/Code/client/PluginAPI.cpp new file mode 100644 index 0000000000..77d5b41283 --- /dev/null +++ b/Code/client/PluginAPI.cpp @@ -0,0 +1,19 @@ +#include + +// Looked up by SKSE plugins with GetProcAddress(GetModuleHandleW(nullptr), SkyrimTogetherAPI::kRequestPluginAPIName). +// ExeLoader::Load restores the export directory so the main module still exposes it. +extern "C" __declspec(dllexport) void* SkyrimTogetherRequestPluginAPI(SkyrimTogetherAPI::InterfaceVersion aVersion) +{ + if (aVersion != SkyrimTogetherAPI::InterfaceVersion::V1) + return nullptr; + + return static_cast(&PluginService::Get()); +} + +// Keep scope registration out of the V1 vtable so existing compiled plugins retain their ABI. +extern "C" __declspec(dllexport) SkyrimTogetherAPI::ChannelHandle SkyrimTogetherRegisterScopedChannel( + const char* apName, SkyrimTogetherAPI::ChannelScope aScope, SkyrimTogetherAPI::MessageCallback aOnMessage, + SkyrimTogetherAPI::PartyChangedCallback aOnPeersChanged) +{ + return PluginService::Get().RegisterScopedChannel(apName, aScope, aOnMessage, aOnPeersChanged); +} diff --git a/Code/client/Services/Generic/PluginService.cpp b/Code/client/Services/Generic/PluginService.cpp new file mode 100644 index 0000000000..170940b9d6 --- /dev/null +++ b/Code/client/Services/Generic/PluginService.cpp @@ -0,0 +1,185 @@ +#include + +#include +#include + +#include +#include +#include + +using namespace SkyrimTogetherAPI; + +PluginService& PluginService::Get() noexcept +{ + static PluginService s_instance; + return s_instance; +} + +ChannelHandle PluginService::RegisterChannel(const char* apName, MessageCallback aOnMessage, PartyChangedCallback aOnPartyChanged) noexcept +{ + return RegisterScopedChannel(apName, ChannelScope::Party, aOnMessage, aOnPartyChanged); +} + +ChannelHandle PluginService::RegisterScopedChannel(const char* apName, ChannelScope aScope, MessageCallback aOnMessage, PartyChangedCallback aOnPartyChanged) noexcept +{ + if (aScope != ChannelScope::Party && aScope != ChannelScope::Nearby) + return 0; + + // Reading up to one byte past the limit is enough to tell the name is too long + const String cName = apName ? String(apName, strnlen(apName, kMaxChannelNameSize + 1)) : String(); + if (!ModMessage::IsValidChannelName(cName)) + return 0; + + std::lock_guard cLock(m_lock); + + for (const Channel& cChannel : m_channels) + { + if (cChannel.Name == cName) + return 0; + } + + m_channels.push_back({cName, aScope, {}, aOnMessage, aOnPartyChanged}); + return static_cast(m_channels.size()); +} + +SendResult PluginService::Send(ChannelHandle aChannel, const char* apBytes, size_t aLength) noexcept +{ + return Queue(aChannel, 0, apBytes, aLength); +} + +SendResult PluginService::SendTo(ChannelHandle aChannel, uint32_t aPlayerId, const char* apBytes, size_t aLength) noexcept +{ + // 0 means the whole party, so it is not a valid target + if (aPlayerId == 0) + return SendResult::NotInParty; + + return Queue(aChannel, aPlayerId, apBytes, aLength); +} + +uint32_t PluginService::GetLocalPlayerId() const noexcept +{ + std::lock_guard cLock(m_lock); + return m_localPlayerId; +} + +bool PluginService::IsInParty() const noexcept +{ + std::lock_guard cLock(m_lock); + return m_inParty; +} + +SendResult PluginService::Queue(ChannelHandle aChannel, uint32_t aTargetPlayerId, const char* apBytes, size_t aLength) noexcept +{ + if (aLength > kMaxMessageSize || (aLength != 0 && !apBytes)) + return SendResult::TooLarge; + + String channelName; + ChannelScope scope; + { + std::lock_guard cLock(m_lock); + if (aChannel == 0 || aChannel > m_channels.size()) + return SendResult::BadChannel; + if (!m_localPlayerId) + return SendResult::NotConnected; + const auto& channel = m_channels[aChannel - 1]; + channelName = channel.Name; + scope = channel.Scope; + if (scope == ChannelScope::Party && !m_inParty) + return SendResult::NotInParty; + const auto& peers = scope == ChannelScope::Party ? m_members : m_nearby; + if (aTargetPlayerId != 0 && !std::binary_search(peers.begin(), peers.end(), aTargetPlayerId)) + return SendResult::NotInParty; + } + + // The state may have changed by the time the World update gets to it, in which case the message is dropped + World::Get().GetRunner().Queue( + [channelName = std::move(channelName), scope, aTargetPlayerId, data = String(apBytes ? apBytes : "", apBytes ? aLength : 0)]() + { + auto& world = World::Get(); + if (!world.GetTransport().IsOnline() || (scope == ChannelScope::Party && !world.GetPartyService().IsInParty())) + return; + + SendModMessageRequest request{}; + request.Scope = scope; + request.Channel = channelName; + request.TargetPlayerId = aTargetPlayerId; + request.Data = data; + world.GetTransport().Send(request); + }); + + return SendResult::Ok; +} + +void PluginService::OnNotifyModMessage(const NotifyModMessage& acMessage) noexcept +{ + MessageCallback onMessage = nullptr; + { + std::lock_guard cLock(m_lock); + + for (const Channel& cChannel : m_channels) + { + if (cChannel.Name == acMessage.Channel && cChannel.Scope == acMessage.Scope) + { + onMessage = cChannel.OnMessage; + break; + } + } + } + + // No plugin registered that channel on this client: nothing to do + if (onMessage) + onMessage(acMessage.SenderPlayerId, acMessage.Data.data(), acMessage.Data.size()); +} + +void PluginService::Update() noexcept +{ + auto& world = World::Get(); + const bool cOnline = world.GetTransport().IsOnline(); + const uint32_t cLocalId = cOnline ? world.GetTransport().GetLocalPlayerId() : 0; + const auto& party = world.GetPartyService(); + const bool cInParty = cOnline && party.IsInParty(); + Vector members, nearby; + if (cInParty) + for (uint32_t id : party.GetPartyMembers()) + if (id != cLocalId) + members.push_back(id); + if (cOnline) + for (auto entity : world.view()) + { + const uint32_t id = world.get(entity).Id; + if (id != cLocalId) + nearby.push_back(id); + } + for (auto* pPeers : {&members, &nearby}) + { + std::sort(pPeers->begin(), pPeers->end()); + pPeers->erase(std::unique(pPeers->begin(), pPeers->end()), pPeers->end()); + } + + struct Change { PartyChangedCallback Callback; uint32_t Id; bool Joined; }; + Vector changes; + { + std::lock_guard cLock(m_lock); + m_localPlayerId = cLocalId; + m_inParty = cInParty; + m_members = members; + m_nearby = nearby; + for (Channel& channel : m_channels) + { + const auto& peers = channel.Scope == ChannelScope::Party ? members : nearby; + if (channel.OnPartyChanged) + { + for (uint32_t id : channel.Peers) + if (!std::binary_search(peers.begin(), peers.end(), id)) + changes.push_back({channel.OnPartyChanged, id, false}); + for (uint32_t id : peers) + if (!std::binary_search(channel.Peers.begin(), channel.Peers.end(), id)) + changes.push_back({channel.OnPartyChanged, id, true}); + } + channel.Peers = peers; + } + } + // Callbacks can register channels or send, so never invoke them while holding the lock. + for (const Change& change : changes) + change.Callback(change.Id, change.Joined); +} diff --git a/Code/client/Services/PluginService.h b/Code/client/Services/PluginService.h new file mode 100644 index 0000000000..ed53ca6b7b --- /dev/null +++ b/Code/client/Services/PluginService.h @@ -0,0 +1,58 @@ +#pragma once + +#include + +struct NotifyModMessage; + +/** + * @brief The Plugin API that SKSE plugins use to sync their own state through Skyrim Together. + * + * Not a World service, as plugins may ask for the interface before the World exists. The World forwards + * NotifyModMessage to it. + */ +struct PluginService final : SkyrimTogetherAPI::IVSkyrimTogether1 +{ + [[nodiscard]] static PluginService& Get() noexcept; + + TP_NOCOPYMOVE(PluginService); + + SkyrimTogetherAPI::ChannelHandle + RegisterChannel(const char* apName, SkyrimTogetherAPI::MessageCallback aOnMessage, SkyrimTogetherAPI::PartyChangedCallback aOnPartyChanged) noexcept override; + SkyrimTogetherAPI::ChannelHandle RegisterScopedChannel(const char* apName, SkyrimTogetherAPI::ChannelScope aScope, + SkyrimTogetherAPI::MessageCallback aOnMessage, SkyrimTogetherAPI::PartyChangedCallback aOnPeersChanged) noexcept; + void Update() noexcept; + + SkyrimTogetherAPI::SendResult Send(SkyrimTogetherAPI::ChannelHandle aChannel, const char* apBytes, size_t aLength) noexcept override; + SkyrimTogetherAPI::SendResult SendTo(SkyrimTogetherAPI::ChannelHandle aChannel, uint32_t aPlayerId, const char* apBytes, size_t aLength) noexcept override; + uint32_t GetLocalPlayerId() const noexcept override; + bool IsInParty() const noexcept override; + + /** + * @brief Hands a relayed Mod Message to the plugin on its channel, if any. Runs during the World update. + */ + void OnNotifyModMessage(const NotifyModMessage& acMessage) noexcept; + +private: + PluginService() = default; + + struct Channel + { + String Name; + SkyrimTogetherAPI::ChannelScope Scope; + Vector Peers; + SkyrimTogetherAPI::MessageCallback OnMessage; + SkyrimTogetherAPI::PartyChangedCallback OnPartyChanged; + }; + + // Validates on the calling thread, then sends during the World update. aTargetPlayerId 0 means the registered audience. + SkyrimTogetherAPI::SendResult Queue(SkyrimTogetherAPI::ChannelHandle aChannel, uint32_t aTargetPlayerId, const char* apBytes, size_t aLength) noexcept; + + mutable std::mutex m_lock; + // A handle is the index in here plus one + Vector m_channels; + // The other party members as of the last change, sorted. Lets any thread check a SendTo target. + Vector m_members; + Vector m_nearby; + uint32_t m_localPlayerId{0}; + bool m_inParty{false}; +}; diff --git a/Code/client/World.cpp b/Code/client/World.cpp index e9b75075e8..ac8568b451 100644 --- a/Code/client/World.cpp +++ b/Code/client/World.cpp @@ -25,6 +25,8 @@ #include #include #include +#include +#include #include #include @@ -63,6 +65,8 @@ World::World() ctx().emplace(*this, m_dispatcher, m_transport); ctx().emplace(*this, m_dispatcher, m_transport); + m_dispatcher.sink().connect<&PluginService::OnNotifyModMessage>(PluginService::Get()); + BehaviorVar::Get()->Init(); } @@ -81,6 +85,8 @@ void World::Update() noexcept // Force run this before so we get the tasks scheduled to run m_runner.OnUpdate(UpdateEvent(cDeltaSeconds)); m_dispatcher.trigger(UpdateEvent(cDeltaSeconds)); + // Snapshot party and loaded players after all sync handlers have finished. + PluginService::Get().Update(); } RunnerService& World::GetRunner() noexcept diff --git a/Code/encoding/Messages/ClientMessageFactory.h b/Code/encoding/Messages/ClientMessageFactory.h index b20280f9bd..5ce5eb2323 100644 --- a/Code/encoding/Messages/ClientMessageFactory.h +++ b/Code/encoding/Messages/ClientMessageFactory.h @@ -69,6 +69,7 @@ #include #include #include +#include using TiltedPhoques::UniquePtr; @@ -82,7 +83,7 @@ struct ClientMessageFactory AuthenticationRequest, AssignCharacterRequest, ClientReferencesMoveRequest, EnterInteriorCellRequest, RequestInventoryChanges, RequestInventorySnapshot, RequestFactionsChanges, RequestQuestUpdate, PartyInviteRequest, PartyAcceptInviteRequest, PartyLeaveRequest, PartyCreateRequest, PartyChangeLeaderRequest, PartyKickRequest, TradeInviteRequest, TradeInviteResponseRequest, TradeOfferUpdateRequest, TradeSetReadyRequest, TradeCancelRequest, TradeInventorySyncRequest, RequestActorValueChanges, RequestActorMaxValueChanges, EnterExteriorCellRequest, RequestHealthChangeBroadcast, ActivateRequest, LockChangeRequest, AssignObjectsRequest, RequestDeathStateChange, ShiftGridCellRequest, RequestOwnershipTransfer, RequestOwnershipClaim, RequestObjectInventoryChanges, SpellCastRequest, ProjectileLaunchRequest, InterruptCastRequest, AddTargetRequest, ScriptAnimationRequest, DrawWeaponRequest, MountRequest, NewPackageRequest, RequestRespawn, SyncExperienceRequest, RequestEquipmentChanges, SendChatMessageRequest, TeleportCommandRequest, PlayerRespawnRequest, DialogueRequest, SubtitleRequest, PlayerDialogueRequest, PlayerLevelRequest, TeleportRequest, RequestPlayerHealthUpdate, RequestWeatherChange, RequestCurrentWeather, RequestSetWaypoint, - RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents, RequestTimeSkip, DropItemRequest, DroppedItemMoveRequest, PickUpDroppedItemRequest, VoiceDataRequest, VoiceStateRequest>; + RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents, RequestTimeSkip, DropItemRequest, DroppedItemMoveRequest, PickUpDroppedItemRequest, VoiceDataRequest, VoiceStateRequest, SendModMessageRequest>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Messages/ModMessage.h b/Code/encoding/Messages/ModMessage.h new file mode 100644 index 0000000000..d58d5ca786 --- /dev/null +++ b/Code/encoding/Messages/ModMessage.h @@ -0,0 +1,44 @@ +#pragma once + +#include "Message.h" + +#include + +// Helpers shared by the two Plugin API messages and the services that handle them. +namespace ModMessage +{ +// 1 to kMaxChannelNameSize bytes of [A-Za-z0-9._-] +inline bool IsValidChannelName(const TiltedPhoques::String& acName) noexcept +{ + if (acName.empty() || acName.size() > SkyrimTogetherAPI::kMaxChannelNameSize) + return false; + + for (const char c : acName) + { + const bool cIsValid = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + if (!cIsValid) + return false; + } + return true; +} + +inline void WriteBytes(TiltedPhoques::Buffer::Writer& aWriter, const TiltedPhoques::String& acBytes) noexcept +{ + Serialization::WriteVarInt(aWriter, acBytes.size()); + aWriter.WriteBytes(reinterpret_cast(acBytes.data()), acBytes.size()); +} + +// Unlike Serialization::ReadString, a length prefix larger than what is left in the packet is rejected before +// anything is allocated, as these messages come from untrusted clients. Returns false on a malformed length. +inline bool ReadBytes(TiltedPhoques::Buffer::Reader& aReader, TiltedPhoques::String& aBytes) noexcept +{ + aBytes.clear(); + + const uint64_t cLength = Serialization::ReadVarInt(aReader); + if (cLength > aReader.GetBuffer()->GetSize() - aReader.GetBytePosition()) + return false; + + aBytes.resize(cLength); + return aReader.ReadBytes(reinterpret_cast(aBytes.data()), cLength); +} +} // namespace ModMessage diff --git a/Code/encoding/Messages/NotifyModMessage.cpp b/Code/encoding/Messages/NotifyModMessage.cpp new file mode 100644 index 0000000000..5e14e26c76 --- /dev/null +++ b/Code/encoding/Messages/NotifyModMessage.cpp @@ -0,0 +1,26 @@ +#include +#include + +void NotifyModMessage::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + ModMessage::WriteBytes(aWriter, Channel); + Serialization::WriteVarInt(aWriter, SenderPlayerId); + ModMessage::WriteBytes(aWriter, Data); + aWriter.WriteBits(static_cast(Scope), 8); +} + +void NotifyModMessage::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + // A malformed message gets an empty channel, which no plugin has registered + if (!ModMessage::ReadBytes(aReader, Channel)) + Channel.clear(); + SenderPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + if (!ModMessage::ReadBytes(aReader, Data)) + Channel.clear(); + uint64_t scope = 0; + if (!aReader.ReadBits(scope, 8) || scope > static_cast(SkyrimTogetherAPI::ChannelScope::Nearby)) + Channel.clear(); + Scope = static_cast(scope); +} diff --git a/Code/encoding/Messages/NotifyModMessage.h b/Code/encoding/Messages/NotifyModMessage.h new file mode 100644 index 0000000000..7c364d4ec3 --- /dev/null +++ b/Code/encoding/Messages/NotifyModMessage.h @@ -0,0 +1,32 @@ +#pragma once + +#include "Message.h" +#include + +/** + * @brief A Mod Message relayed to a plugin. The sender is stamped by the server, never taken from the payload. + */ +struct NotifyModMessage final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyModMessage; + + NotifyModMessage() + : ServerMessage(Opcode) + { + } + + virtual ~NotifyModMessage() = default; + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyModMessage& acRhs) const noexcept + { + return GetOpcode() == acRhs.GetOpcode() && Channel == acRhs.Channel && SenderPlayerId == acRhs.SenderPlayerId && Data == acRhs.Data && Scope == acRhs.Scope; + } + + SkyrimTogetherAPI::ChannelScope Scope{SkyrimTogetherAPI::ChannelScope::Party}; + TiltedPhoques::String Channel; + uint32_t SenderPlayerId{0}; + TiltedPhoques::String Data; +}; diff --git a/Code/encoding/Messages/SendModMessageRequest.cpp b/Code/encoding/Messages/SendModMessageRequest.cpp new file mode 100644 index 0000000000..f2e9124c31 --- /dev/null +++ b/Code/encoding/Messages/SendModMessageRequest.cpp @@ -0,0 +1,26 @@ +#include +#include + +void SendModMessageRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + ModMessage::WriteBytes(aWriter, Channel); + Serialization::WriteVarInt(aWriter, TargetPlayerId); + ModMessage::WriteBytes(aWriter, Data); + aWriter.WriteBits(static_cast(Scope), 8); +} + +void SendModMessageRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + // A malformed message gets an empty channel, which every handler rejects + if (!ModMessage::ReadBytes(aReader, Channel)) + Channel.clear(); + TargetPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + if (!ModMessage::ReadBytes(aReader, Data)) + Channel.clear(); + uint64_t scope = 0; + if (!aReader.ReadBits(scope, 8) || scope > static_cast(SkyrimTogetherAPI::ChannelScope::Nearby)) + Channel.clear(); + Scope = static_cast(scope); +} diff --git a/Code/encoding/Messages/SendModMessageRequest.h b/Code/encoding/Messages/SendModMessageRequest.h new file mode 100644 index 0000000000..2afddde475 --- /dev/null +++ b/Code/encoding/Messages/SendModMessageRequest.h @@ -0,0 +1,33 @@ +#pragma once + +#include "Message.h" +#include + +/** + * @brief A Mod Message from a plugin, for the selected audience or one eligible recipient. Opaque to Skyrim Together. + */ +struct SendModMessageRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kSendModMessageRequest; + + SendModMessageRequest() + : ClientMessage(Opcode) + { + } + + virtual ~SendModMessageRequest() = default; + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const SendModMessageRequest& acRhs) const noexcept + { + return GetOpcode() == acRhs.GetOpcode() && Channel == acRhs.Channel && TargetPlayerId == acRhs.TargetPlayerId && Data == acRhs.Data && Scope == acRhs.Scope; + } + + SkyrimTogetherAPI::ChannelScope Scope{SkyrimTogetherAPI::ChannelScope::Party}; + TiltedPhoques::String Channel; + // 0 sends to the whole selected audience + uint32_t TargetPlayerId{0}; + TiltedPhoques::String Data; +}; diff --git a/Code/encoding/Messages/ServerMessageFactory.h b/Code/encoding/Messages/ServerMessageFactory.h index 4a25bf6925..76a7a4ce22 100644 --- a/Code/encoding/Messages/ServerMessageFactory.h +++ b/Code/encoding/Messages/ServerMessageFactory.h @@ -71,6 +71,7 @@ #include #include #include +#include using TiltedPhoques::UniquePtr; @@ -84,7 +85,7 @@ struct ServerMessageFactory AuthenticationResponse, AssignCharacterResponse, ServerReferencesMoveRequest, ServerTimeSettings, CharacterSpawnRequest, NotifyInventoryChanges, NotifyInventorySnapshot, StringCacheUpdate, NotifyFactionsChanges, NotifyRemoveCharacter, NotifyQuestUpdate, NotifyPlayerList, NotifyPartyInfo, NotifyPartyInvite, NotifyActorValueChanges, NotifyPartyJoined, NotifyPartyLeft, NotifyTradeInvite, NotifyTradeStarted, NotifyTradeState, NotifyTradeCancel, NotifyTradeComplete, NotifyActorMaxValueChanges, NotifyHealthChangeBroadcast, NotifyActivate, NotifyLockChange, AssignObjectsResponse, NotifyDeathStateChange, NotifyOwnershipTransfer, NotifyObjectInventoryChanges, NotifySpellCast, NotifyProjectileLaunch, NotifyInterruptCast, NotifyAddTarget, NotifyScriptAnimation, NotifyDrawWeapon, NotifyMount, NotifyNewPackage, NotifyRespawn, NotifySyncExperience, NotifyEquipmentChanges, NotifyChatMessageBroadcast, TeleportCommandResponse, NotifyPlayerRespawn, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyActorTeleport, NotifyPlayerLeft, NotifyPlayerJoined, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyPlayerLevel, NotifyPlayerCellChanged, NotifyTeleport, NotifyPlayerHealthUpdate, NotifySettingsChange, - NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents, NotifyRemoveObjects, DropItemResponse, NotifyDroppedItemsSpawn, NotifyDroppedItemMove, NotifyDroppedItemsRemove, NotifyVoiceData>; + NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents, NotifyRemoveObjects, DropItemResponse, NotifyDroppedItemsSpawn, NotifyDroppedItemMove, NotifyDroppedItemsRemove, NotifyVoiceData, NotifyModMessage>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Opcodes.h b/Code/encoding/Opcodes.h index 73205af2c3..16e578f465 100644 --- a/Code/encoding/Opcodes.h +++ b/Code/encoding/Opcodes.h @@ -67,6 +67,7 @@ enum ClientOpcode : unsigned char kPickUpDroppedItemRequest, kVoiceDataRequest, kVoiceStateRequest, + kSendModMessageRequest, kClientOpcodeMax }; @@ -139,5 +140,6 @@ enum ServerOpcode : unsigned char kNotifyDroppedItemMove, kNotifyDroppedItemsRemove, kNotifyVoiceData, + kNotifyModMessage, kServerOpcodeMax }; diff --git a/Code/encoding/xmake.lua b/Code/encoding/xmake.lua index dff8d9b0cb..bc73269454 100644 --- a/Code/encoding/xmake.lua +++ b/Code/encoding/xmake.lua @@ -2,7 +2,7 @@ local function build_encoding(name) target(name) set_kind("static") set_group("common") - add_includedirs(".", "../", {public = true}) + add_includedirs(".", "../", "../plugin_api/include", {public = true}) add_headerfiles("**.h|Structs/Skyrim/**", {prefixdir = "Encoding"}) add_files("**.cpp|Structs/Skyrim/**") set_pcxxheader("EncodingPch.h") diff --git a/Code/immersive_launcher/loader/ExeLoader.cpp b/Code/immersive_launcher/loader/ExeLoader.cpp index 31e89d146e..466c79c902 100644 --- a/Code/immersive_launcher/loader/ExeLoader.cpp +++ b/Code/immersive_launcher/loader/ExeLoader.cpp @@ -300,6 +300,8 @@ bool ExeLoader::Load(const uint8_t* apProgramBuffer) auto sourceChecksum = sourceNtHeader->OptionalHeader.CheckSum; auto sourceTimestamp = sourceNtHeader->FileHeader.TimeDateStamp; auto sourceDebugDir = sourceNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG]; + // SkyrimSE.exe has no exports, but SKSE plugins need to find SkyrimTogetherRequestPluginAPI in the main module. + auto sourceExportDir = sourceNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; LoadSections(ntHeader); @@ -327,6 +329,7 @@ bool ExeLoader::Load(const uint8_t* apProgramBuffer) sourceNtHeader->OptionalHeader.CheckSum = sourceChecksum; sourceNtHeader->FileHeader.TimeDateStamp = sourceTimestamp; sourceNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG] = sourceDebugDir; + sourceNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT] = sourceExportDir; m_pBinary = nullptr; // Set a hook to check if anything loaded messes with critical hooks. diff --git a/Code/plugin_api/README.md b/Code/plugin_api/README.md new file mode 100644 index 0000000000..aac65ad87b --- /dev/null +++ b/Code/plugin_api/README.md @@ -0,0 +1,60 @@ +# Plugin API V1 and scoped channels + +The client exports `SkyrimTogetherRequestPluginAPI` from SkyrimTogether.exe. Request V1 to get +`IVSkyrimTogether1`. Unknown versions return null. There is deliberately no generic `RequestPluginAPI` +export: plugins looking for an absent third-party DLL must not accidentally receive STR's interface. +The launcher preserves its PE export directory while loading SkyrimSE.exe. + +This restores DJLegends' V1 party-message implementation from the October 17 commits +2108feaa, 433bc52d, 734c5410, bc63f848, f5b4625d, 18a5312a, 0029b127 and 5fa96b48. +Plugin API V2 (84cbc0ce and later V2 implementations) is excluded. Scoped registration below is new code; +it does not expose actor control or use the V2 interface. + +## Choose an audience in the plugin + +Existing `RegisterChannel` calls remain party-only. New plugins may look up +`SkyrimTogetherRegisterScopedChannel` using `RegisterScopedChannel_t` from the header: + +```cpp +auto reg = reinterpret_cast( + GetProcAddress(GetModuleHandleW(nullptr), SkyrimTogetherAPI::kRegisterScopedChannelName)); +auto channel = reg + ? reg("Example.MyPlugin", SkyrimTogetherAPI::ChannelScope::Nearby, OnMessage, OnPeersChanged) + : api->RegisterChannel("Example.MyPlugin", OnMessage, OnPeersChanged); +api->Send(channel, bytes, length); +``` + +Use `ChannelScope::Party` or the original registration method for private party features. Use +`ChannelScope::Nearby` for features visible to players in the local area, regardless of party. +The channel name and scope must match on both clients. Names are not hardcoded in STR, so new plugins +can use either audience without another executable release. Channel registrations last until process exit; +callbacks must remain valid for that lifetime. Invalid scopes/duplicate names return handle 0. + +`Send` broadcasts to the channel's audience; `SendTo` addresses one eligible peer. `IsInParty` retains +its literal V1 meaning; it is not a requirement for nearby sends. `NotInParty` from a nearby targeted +send means the target is not in the current nearby snapshot (the existing result enum is unchanged). +The server independently checks every recipient: party equality, or matching interior/worldspace +and loaded-grid range. Both client and server must run the new protocol build. + +## Lifetime and resynchronization + +The registration callback reports audience joins/leaves. Nearby membership follows loaded remote player +entities; party membership follows the current party. Snapshots refresh after the World update. Callbacks +run in that update context, NOT guaranteed on Skyrim's main thread. Queue engine work through SKSE AddTask. +Calls from plugin threads use locked snapshots. Do not retain message data beyond the callback. + +There is no persistent object store or replay on the server. A plugin resends its own state on a peer's +arrival and removes remote state on departure. Objects which should survive their owner leaving require +plugin-level persistence/ownership. Spatial objects must include or validate their own location context; +a nearby channel follows the sending player, not an independently tracked object. + +Limits remain 256 payload bytes, 64 channel-name bytes, and a per-player 20 messages/second budget with +40-message bursts across channels. Plugins with larger state should batch and pace their announcements. + +## Validation + +Check exports in the built launcher, protocol round trips for both scopes and invalid scope rejection. +In game: test Campfire F8, late arrival, disconnect/reconnect and cell travel with two players outside +any party; then repeat with party-only configuration and a third player outside the party. Confirm +unrelated plugins never find a generic RequestPluginAPI in the launcher. No new engine hooks or offsets +are introduced here; actual runtime export visibility and in-game behavior still require this test. diff --git a/Code/plugin_api/include/SkyrimTogetherAPI.h b/Code/plugin_api/include/SkyrimTogetherAPI.h new file mode 100644 index 0000000000..80134b6038 --- /dev/null +++ b/Code/plugin_api/include/SkyrimTogetherAPI.h @@ -0,0 +1,86 @@ +#pragma once + +// Skyrim Together Plugin API, version 1. +// +// Lets an SKSE plugin sync its own state through Skyrim Together: the plugin registers a Channel and sends small +// Mod Messages on it, Skyrim Together relays them to the sender's party without interpreting them, and the same +// plugin on the other clients receives them. This header is self-contained, copy it into your plugin. +// +// Usage: +// auto pRequest = reinterpret_cast( +// GetProcAddress(GetModuleHandleW(nullptr), "SkyrimTogetherRequestPluginAPI")); +// // nullptr: not running under Skyrim Together, stay inactive. +// auto* pApi = static_cast(pRequest(SkyrimTogetherAPI::InterfaceVersion::V1)); + +#include +#include + +namespace SkyrimTogetherAPI +{ +enum class InterfaceVersion : uint8_t +{ + V1 = 1 +}; + +enum class SendResult : uint8_t +{ + Ok, // Queued for sending. Delivery is not guaranteed. + NotConnected, // Not connected to a server + NotInParty, // Not in a party, or the target is not in the local party + TooLarge, // Payload is longer than kMaxMessageSize + BadChannel // Not a handle returned by RegisterChannel +}; + +// A Mod Message is opaque bytes counted by length. It need not be text or null-terminated. +constexpr size_t kMaxMessageSize = 256; +// Channel names are 1 to kMaxChannelNameSize bytes of [A-Za-z0-9._-]. +constexpr size_t kMaxChannelNameSize = 64; + +// Scope is chosen by a plugin once, when registering its channel. +enum class ChannelScope : uint8_t { Party = 0, Nearby = 1 }; + +using ChannelHandle = uint32_t; // 0 is invalid + +// Both callbacks run during Skyrim Together's per-frame update, where its own sync code runs. That is not +// necessarily the game's main thread, and the thread can differ between calls. To change game state from the +// main thread, hand the work over with SKSE's task interface (SKSE::GetTaskInterface()->AddTask). + +// aSenderId is the player ID the server saw the message come from. +using MessageCallback = void (*)(uint32_t aSenderId, const char* apBytes, size_t aLength); +// A player joined (aJoined) or left the local player's party. +using PartyChangedCallback = void (*)(uint32_t aPlayerId, bool aJoined); + +class IVSkyrimTogether1 +{ +public: + virtual ~IVSkyrimTogether1() = default; + + // Returns 0 if the name is invalid or already registered. Channels live until the process exits. + // Either callback may be null. + virtual ChannelHandle RegisterChannel(const char* apName, MessageCallback aOnMessage, PartyChangedCallback aOnPartyChanged) noexcept = 0; + + // Sends to the registered audience, sender excluded. Callable from any thread. + virtual SendResult Send(ChannelHandle aChannel, const char* apBytes, size_t aLength) noexcept = 0; + + // Sends to one member of the registered audience. Callable from any thread. + virtual SendResult SendTo(ChannelHandle aChannel, uint32_t aPlayerId, const char* apBytes, size_t aLength) noexcept = 0; + + // 0 while not connected. + virtual uint32_t GetLocalPlayerId() const noexcept = 0; + virtual bool IsInParty() const noexcept = 0; +}; + +// Use a unique name so an absent third-party DLL cannot accidentally resolve this API. +constexpr const char* kRequestPluginAPIName = "SkyrimTogetherRequestPluginAPI"; + +// Exported by SkyrimTogether.exe. Returns nullptr for a version it does not know. +using RequestPluginAPI_t = void* (*)(InterfaceVersion aVersion); +// Optional extension, independent of the V1 vtable. Older executables return no symbol. +// Nearby channels notify about loaded remote players entering/leaving the local area, +// regardless of party. The server also checks worldspace/interior and range on every send. +// Both ends must register the same channel name and scope. No retained server state: +// plugins resend their own state on joined=true and discard remote state on joined=false. +// Callbacks run in the World update context; queue engine work through SKSE AddTask. +using RegisterScopedChannel_t = ChannelHandle (*)(const char*, ChannelScope, MessageCallback, PartyChangedCallback); +constexpr const char* kRegisterScopedChannelName = "SkyrimTogetherRegisterScopedChannel"; +} // namespace SkyrimTogetherAPI diff --git a/Code/server/Services/ModMessageService.cpp b/Code/server/Services/ModMessageService.cpp new file mode 100644 index 0000000000..ec0bdc29c4 --- /dev/null +++ b/Code/server/Services/ModMessageService.cpp @@ -0,0 +1,103 @@ +#include +#include + +#include +#include +#include + +#include +#include +#include + +namespace +{ +constexpr double kMessagesPerSecond = 20.0; +constexpr double kBurstSize = 40.0; +} // namespace + +ModMessageService::ModMessageService(World& aWorld, entt::dispatcher& aDispatcher) noexcept + : m_world(aWorld) +{ + m_sendModMessageConnection = aDispatcher.sink>().connect<&ModMessageService::OnSendModMessage>(this); + m_playerLeaveConnection = aDispatcher.sink().connect<&ModMessageService::OnPlayerLeave>(this); +} + +void ModMessageService::OnSendModMessage(const PacketEvent& acMessage) noexcept +{ + const auto& cRequest = acMessage.Packet; + + // Clients are not trusted: a modded one can send anything, so drop whatever the Plugin API would not have sent. + // The rate limit comes first so that malformed messages count against it too. + if (!TryConsume(*acMessage.pPlayer)) + return; + + if (!ModMessage::IsValidChannelName(cRequest.Channel) || cRequest.Data.size() > SkyrimTogetherAPI::kMaxMessageSize) + return; + + const auto& cParty = acMessage.pPlayer->GetParty(); + if (cRequest.Scope != SkyrimTogetherAPI::ChannelScope::Party && cRequest.Scope != SkyrimTogetherAPI::ChannelScope::Nearby) + return; + + if (cRequest.Scope == SkyrimTogetherAPI::ChannelScope::Party && !cParty.JoinedPartyId.has_value()) + return; + + NotifyModMessage notify{}; + notify.Scope = cRequest.Scope; + notify.Channel = cRequest.Channel; + notify.SenderPlayerId = acMessage.pPlayer->GetId(); + notify.Data = cRequest.Data; + + for (Player* pTarget : m_world.GetPlayerManager()) + { + if (pTarget == acMessage.pPlayer || (cRequest.TargetPlayerId != 0 && cRequest.TargetPlayerId != pTarget->GetId())) + continue; + + if (cRequest.Scope == SkyrimTogetherAPI::ChannelScope::Party) + { + if (pTarget->GetParty().JoinedPartyId != cParty.JoinedPartyId) + continue; + } + else + { + const auto& senderCell = acMessage.pPlayer->GetCellComponent(); + const auto& targetCell = pTarget->GetCellComponent(); + if (!acMessage.pPlayer->GetCharacter() || !pTarget->GetCharacter() || !senderCell || !targetCell || !senderCell.IsInRange(targetCell, false)) + continue; + } + pTarget->Send(notify); + } +} + +void ModMessageService::OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept +{ + m_buckets.erase(acEvent.pPlayer->GetId()); +} + +bool ModMessageService::TryConsume(const Player& acPlayer) noexcept +{ + const auto cNow = std::chrono::steady_clock::now(); + auto [itor, cIsNew] = m_buckets.try_emplace(acPlayer.GetId(), Bucket{kBurstSize, cNow}); + Bucket& bucket = itor.value(); + + if (!cIsNew) + { + const double cElapsed = std::chrono::duration(cNow - bucket.LastRefill).count(); + bucket.Tokens = std::min(kBurstSize, bucket.Tokens + cElapsed * kMessagesPerSecond); + bucket.LastRefill = cNow; + } + + if (bucket.Tokens >= 1.0) + { + bucket.Tokens -= 1.0; + bucket.Dropping = false; + return true; + } + + // Log once per burst, not once per dropped message + if (!bucket.Dropping) + { + bucket.Dropping = true; + spdlog::warn("[ModMessageService]: Player {} ('{}') is sending Mod Messages too fast, dropping the excess", acPlayer.GetId(), acPlayer.GetUsername()); + } + return false; +} diff --git a/Code/server/Services/ModMessageService.h b/Code/server/Services/ModMessageService.h new file mode 100644 index 0000000000..db311eb7bf --- /dev/null +++ b/Code/server/Services/ModMessageService.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +struct World; +struct Player; +struct SendModMessageRequest; +struct PlayerLeaveEvent; + +/** + * @brief Relays Mod Messages sent through the Plugin API. The payload is never interpreted, only length-checked. + */ +struct ModMessageService +{ + ModMessageService(World& aWorld, entt::dispatcher& aDispatcher) noexcept; + ~ModMessageService() noexcept = default; + + TP_NOCOPYMOVE(ModMessageService); + +protected: + void OnSendModMessage(const PacketEvent& acMessage) noexcept; + void OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept; + +private: + // A token bucket per player: refills at a steady rate up to a burst size + struct Bucket + { + double Tokens; + std::chrono::steady_clock::time_point LastRefill; + bool Dropping = false; + }; + + // False when the player is over the rate limit, in which case the message must be dropped + [[nodiscard]] bool TryConsume(const Player& acPlayer) noexcept; + + World& m_world; + Map m_buckets; + + entt::scoped_connection m_sendModMessageConnection; + entt::scoped_connection m_playerLeaveConnection; +}; diff --git a/Code/server/World.cpp b/Code/server/World.cpp index 98eb95ac2c..615f2365c5 100644 --- a/Code/server/World.cpp +++ b/Code/server/World.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include @@ -43,6 +44,7 @@ World::World() ctx().emplace(*this, m_dispatcher); ctx().emplace(*this, m_dispatcher); ctx().emplace(*this, m_dispatcher); + ctx().emplace(*this, m_dispatcher); ESLoader::ESLoader loader; // emplace loaded mods into modscomponent. diff --git a/Code/tests/encoding.cpp b/Code/tests/encoding.cpp index e08051883b..6f57d44e3a 100644 --- a/Code/tests/encoding.cpp +++ b/Code/tests/encoding.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include @@ -829,6 +830,78 @@ TEST_CASE("Chat broadcast", "[encoding.chat]") REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); } +TEST_CASE("Mod messages", "[encoding.mod_message]") +{ + String maxPayload(SkyrimTogetherAPI::kMaxMessageSize, 'p'); + maxPayload[10] = '\0'; // payloads are opaque bytes, not text + + // Empty payload, 256-byte payload, 64-byte channel name + const auto [channel, payload] = GENERATE_COPY(std::make_pair(String("a"), String()), std::make_pair(String("campfire"), maxPayload), std::make_pair(String(SkyrimTogetherAPI::kMaxChannelNameSize, 'c'), String("hi"))); + + SECTION("SendModMessageRequest") + { + SendModMessageRequest sendMessage; + sendMessage.Scope = GENERATE(SkyrimTogetherAPI::ChannelScope::Party, SkyrimTogetherAPI::ChannelScope::Nearby); + sendMessage.Channel = channel; + sendMessage.TargetPlayerId = 7; + sendMessage.Data = payload; + + Buffer buff(1000); + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + auto pMessage = ClientMessageFactory{}.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); + } + + SECTION("NotifyModMessage") + { + NotifyModMessage sendMessage; + sendMessage.Scope = GENERATE(SkyrimTogetherAPI::ChannelScope::Party, SkyrimTogetherAPI::ChannelScope::Nearby); + sendMessage.Channel = channel; + sendMessage.SenderPlayerId = 3; + sendMessage.Data = payload; + + Buffer buff(1000); + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + auto pMessage = ServerMessageFactory{}.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); + } +} + +TEST_CASE("Mod message with a bogus length", "[encoding.mod_message]") +{ + // A length prefix far larger than the packet must not be allocated, the message just comes out empty + Buffer buff(1000); + Buffer::Writer writer(&buff); + Serialization::WriteVarInt(writer, 0xFFFFFFFFFFull); + + Buffer::Reader reader(&buff); + SendModMessageRequest request; + request.DeserializeRaw(reader); + + REQUIRE(request.Channel.empty()); + REQUIRE(request.Data.empty()); +} + +TEST_CASE("Mod message channel names", "[encoding.mod_message]") +{ + REQUIRE(ModMessage::IsValidChannelName("Campfire_Sync-1.0")); + REQUIRE(ModMessage::IsValidChannelName(String(SkyrimTogetherAPI::kMaxChannelNameSize, 'a'))); + REQUIRE_FALSE(ModMessage::IsValidChannelName("")); + REQUIRE_FALSE(ModMessage::IsValidChannelName(String(SkyrimTogetherAPI::kMaxChannelNameSize + 1, 'a'))); + REQUIRE_FALSE(ModMessage::IsValidChannelName("has space")); + REQUIRE_FALSE(ModMessage::IsValidChannelName(String("nul\0", 4))); +} + TEST_CASE("StringCache", "[encoding.string_cache]") { SECTION("Messages") @@ -852,3 +925,17 @@ TEST_CASE("StringCache", "[encoding.string_cache]") REQUIRE(update == recvUpdate); } } + +TEST_CASE("Mod message rejects an unknown audience", "[encoding.mod_message]") +{ + SendModMessageRequest message; + message.Channel = "Test.Scope"; + message.Scope = static_cast(255); + Buffer buffer(128); + Buffer::Writer writer(&buffer); + message.SerializeRaw(writer); + Buffer::Reader reader(&buffer); + SendModMessageRequest received; + received.DeserializeRaw(reader); + REQUIRE(received.Channel.empty()); +} From 06ed7834c5320325cc07fd6ee38ea0e060fe57e9 Mon Sep 17 00:00:00 2001 From: absol89 Date: Tue, 3 Nov 2026 13:33:36 +0100 Subject: [PATCH 56/97] Add a voice master volume slider up to 500 percent Default received voice playback to 250 percent and save the setting in STServer.ini. Multiply per-player and distance gains without changing microphone capture, and retain the final output clamp. Validation: client release build and production UI build passed; TPTests passed 134 assertions in 12 cases. In-game listening and persistence checks remain. --- .../client/Services/Generic/OverlayClient.cpp | 2 ++ Code/client/Services/Generic/VoiceService.cpp | 7 +++++- Code/client/Services/VoiceService.h | 1 + Code/client/Systems/VoiceDsp.cpp | 5 ++++ Code/client/Systems/VoiceDsp.h | 3 +++ .../settings/settings.component.html | 15 ++++++++++++ .../src/app/mock/skyrimtogether.mock.ts | 5 +++- .../src/app/services/voip.service.ts | 9 +++++++ Code/skyrim_ui/src/assets/i18n/en.json | 1 + Code/skyrim_ui/src/typings.d.ts | 2 ++ Code/tests/voice_dsp.cpp | 24 +++++++++++++++++++ 11 files changed, 72 insertions(+), 2 deletions(-) diff --git a/Code/client/Services/Generic/OverlayClient.cpp b/Code/client/Services/Generic/OverlayClient.cpp index 96f0d26db4..8868151430 100644 --- a/Code/client/Services/Generic/OverlayClient.cpp +++ b/Code/client/Services/Generic/OverlayClient.cpp @@ -256,6 +256,8 @@ void OverlayClient::ProcessSetVoiceSettings(CefRefPtr aEventArgs) settings.FadeStart = static_cast(aEventArgs->GetInt(4)); settings.MicVolume = static_cast(aEventArgs->GetInt(5)) / 100.f; settings.NoiseGate = static_cast(aEventArgs->GetInt(6)) / 100.f; + if (aEventArgs->GetSize() > 7) + settings.MasterVolume = static_cast(aEventArgs->GetInt(7)) / 100.f; World::Get().GetRunner().Queue([settings]() { World::Get().ctx().at().ApplySettings(settings); }); } diff --git a/Code/client/Services/Generic/VoiceService.cpp b/Code/client/Services/Generic/VoiceService.cpp index a10d6a4cae..89f8060ef6 100644 --- a/Code/client/Services/Generic/VoiceService.cpp +++ b/Code/client/Services/Generic/VoiceService.cpp @@ -184,6 +184,7 @@ void VoiceService::ApplySettings(const Settings& acSettings) noexcept m_settings.FadeStart = std::isfinite(m_settings.FadeStart) ? std::clamp(m_settings.FadeStart, 0.f, kMaxFadeStart) : 2500.f; m_settings.MicVolume = Clamp01(m_settings.MicVolume, 1.f); m_settings.NoiseGate = Clamp01(m_settings.NoiseGate, 0.1f); + m_settings.MasterVolume = VoiceDsp::ClampMasterVolume(m_settings.MasterVolume); m_audio.SetCaptureSettings(m_settings.MicVolume, m_settings.NoiseGate); SaveSettings(); @@ -334,7 +335,7 @@ void VoiceService::UpdateSpeakerGains() noexcept const float dz = pActor->position.z - listener.Position.z; const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); - const float gain = volume * VoiceDsp::DistanceGain(distance, m_settings.FadeStart); + const float gain = m_settings.MasterVolume * volume * VoiceDsp::DistanceGain(distance, m_settings.FadeStart); const float horizontal = std::sqrt(dx * dx + dy * dy); const float pan = horizontal > 1.f ? (dx * listener.RightX + dy * listener.RightY) / horizontal * kMaxPan : 0.f; @@ -404,6 +405,7 @@ void VoiceService::LoadSettings() noexcept settings.FadeStart = static_cast(ini.GetDoubleValue(kSection, "fVoipFadeStart", 2500.0)); settings.MicVolume = static_cast(ini.GetDoubleValue(kSection, "fMicVolume", 1.0)); settings.NoiseGate = static_cast(ini.GetDoubleValue(kSection, "fNoiseGate", 0.1)); + settings.MasterVolume = static_cast(ini.GetDoubleValue(kSection, "fVoipMasterVolume", 2.5)); } else { @@ -414,6 +416,7 @@ void VoiceService::LoadSettings() noexcept settings.FadeStart = std::isfinite(settings.FadeStart) ? std::clamp(settings.FadeStart, 0.f, kMaxFadeStart) : 2500.f; settings.MicVolume = Clamp01(settings.MicVolume, 1.f); settings.NoiseGate = Clamp01(settings.NoiseGate, 0.1f); + settings.MasterVolume = VoiceDsp::ClampMasterVolume(settings.MasterVolume); m_settings = settings; } @@ -439,6 +442,7 @@ void VoiceService::SaveSettings() const noexcept ini.SetValue(kSection, "sOutputDevice", m_settings.OutputDevice.c_str(), "; Player: where voices play. Empty uses the Windows default device, like the game"); ini.SetValue(kSection, "sVoipChannel", ChannelToString(m_settings.ListenChannel), "; Player: who you hear, everyone, party or strangers"); ini.SetDoubleValue(kSection, "fVoipFadeStart", m_settings.FadeStart, "; Player: voices are full volume up to this distance in game units, silent at twice it"); + ini.SetDoubleValue(kSection, "fVoipMasterVolume", m_settings.MasterVolume, "; Player: all received voices, 0 to 5 (0-500%), default 2.5 (250%)"); ini.SetDoubleValue(kSection, "fMicVolume", m_settings.MicVolume, "; Player: microphone volume, 0 to 1"); ini.SetDoubleValue(kSection, "fNoiseGate", m_settings.NoiseGate, "; Player: microphone level below which nothing is sent, 0 to 1 like the Windows mic test bar"); @@ -460,5 +464,6 @@ void VoiceService::SendSettingsToUi() const noexcept pArguments->SetInt(4, static_cast(std::lround(m_settings.FadeStart))); pArguments->SetInt(5, static_cast(std::lround(m_settings.MicVolume * 100.f))); pArguments->SetInt(6, static_cast(std::lround(m_settings.NoiseGate * 100.f))); + pArguments->SetInt(7, static_cast(std::lround(m_settings.MasterVolume * 100.f))); pOverlay->ExecuteAsync("voiceSettings", pArguments); } diff --git a/Code/client/Services/VoiceService.h b/Code/client/Services/VoiceService.h index faaaf035e0..53922d55d4 100644 --- a/Code/client/Services/VoiceService.h +++ b/Code/client/Services/VoiceService.h @@ -41,6 +41,7 @@ struct VoiceService float FadeStart{2500.f}; float MicVolume{1.f}; float NoiseGate{0.1f}; + float MasterVolume{2.5f}; }; VoiceService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept; diff --git a/Code/client/Systems/VoiceDsp.cpp b/Code/client/Systems/VoiceDsp.cpp index f138b3d956..423b7901da 100644 --- a/Code/client/Systems/VoiceDsp.cpp +++ b/Code/client/Systems/VoiceDsp.cpp @@ -5,6 +5,11 @@ namespace VoiceDsp { +float ClampMasterVolume(float aVolume) noexcept +{ + return std::isfinite(aVolume) ? std::clamp(aVolume, 0.f, 5.f) : 2.5f; +} + float Peak(const float* apSamples, size_t aCount) noexcept { float peak = 0.f; diff --git a/Code/client/Systems/VoiceDsp.h b/Code/client/Systems/VoiceDsp.h index 8a0ddcf3e5..661fb67fdb 100644 --- a/Code/client/Systems/VoiceDsp.h +++ b/Code/client/Systems/VoiceDsp.h @@ -15,6 +15,9 @@ namespace VoiceDsp constexpr uint32_t kSampleRate = 48000; constexpr uint32_t kFrameSamples = 960; +// Playback multiplier: 0 mutes, 1 is unchanged, 5 is the maximum. Invalid values use 250%. +float ClampMasterVolume(float aVolume) noexcept; + // Peak of a frame, 0 to 1. This is what the Windows "Test your microphone" bar shows. float Peak(const float* apSamples, size_t aCount) noexcept; diff --git a/Code/skyrim_ui/src/app/components/settings/settings.component.html b/Code/skyrim_ui/src/app/components/settings/settings.component.html index 929c827aae..e442d6cc1c 100644 --- a/Code/skyrim_ui/src/app/components/settings/settings.component.html +++ b/Code/skyrim_ui/src/app/components/settings/settings.component.html @@ -108,6 +108,21 @@

{{ 'COMPONENT.SETTINGS.VOICE.HEADER' | transloco }}

>
+
+ + +
| undefined; private voiceTalkingTimer: ReturnType | undefined; diff --git a/Code/skyrim_ui/src/app/services/voip.service.ts b/Code/skyrim_ui/src/app/services/voip.service.ts index 426d905283..12dfd8ef09 100644 --- a/Code/skyrim_ui/src/app/services/voip.service.ts +++ b/Code/skyrim_ui/src/app/services/voip.service.ts @@ -17,7 +17,7 @@ export interface VoipSettings { /** Empty plays on the game's (the Windows default) device. */ outputDevice: string; channel: VoipChannel; - /** Full volume up to this distance in game units, silent at twice it. */ + /** Full volume up to this distance in game units, then quieter like real sound, silent at four times it. */ fadeStart: number; /** 0 to 100 */ micVolume: number; @@ -32,7 +32,7 @@ export const defaultVoipSettings: VoipSettings = { connect: false, outputDevice: '', channel: VoipChannel.EVERYONE, - fadeStart: 2500, + fadeStart: 2000, micVolume: 100, noiseGate: 5, masterVolume: 100, diff --git a/Code/skyrim_ui/src/assets/i18n/en.json b/Code/skyrim_ui/src/assets/i18n/en.json index a2a2a674be..7da18d4a6b 100644 --- a/Code/skyrim_ui/src/assets/i18n/en.json +++ b/Code/skyrim_ui/src/assets/i18n/en.json @@ -180,7 +180,7 @@ "CHANNEL_PARTY": "Party", "CHANNEL_STRANGERS": "Strangers", "FADE_START": "Voip fade start", - "FADE_HINT": "Full volume up to this distance, silent at {{ silent }} units" + "FADE_HINT": "Full volume up to this distance, then fades like real sound, silent at {{ silent }} units" }, "PARTY": "Party", "SHOW_PARTY": "Show Party", diff --git a/Code/tests/voice_dsp.cpp b/Code/tests/voice_dsp.cpp index 33104994e2..3943428f7b 100644 --- a/Code/tests/voice_dsp.cpp +++ b/Code/tests/voice_dsp.cpp @@ -9,11 +9,25 @@ using namespace VoiceDsp; TEST_CASE("Voice distance falloff", "[voice.dsp]") { - REQUIRE(DistanceGain(0.f, 2500.f) == 1.f); - REQUIRE(DistanceGain(2500.f, 2500.f) == 1.f); - REQUIRE(DistanceGain(3750.f, 2500.f) == Approx(0.5f)); - REQUIRE(DistanceGain(5000.f, 2500.f) == 0.f); - REQUIRE(DistanceGain(9000.f, 2500.f) == 0.f); + REQUIRE(DistanceGain(0.f, 2000.f) == 1.f); + REQUIRE(DistanceGain(2000.f, 2000.f) == 1.f); + // -6 dB per doubling: half the amplitude at twice the fade start, a third at three times before the taper + REQUIRE(DistanceGain(3000.f, 2000.f) == Approx(2.f / 3.f)); + REQUIRE(DistanceGain(4000.f, 2000.f) == Approx(0.5f)); + // Halfway through the taper the inverse law (1/3) is halved + REQUIRE(DistanceGain(6000.f, 2000.f) == Approx(1.f / 6.f)); + REQUIRE(DistanceGain(8000.f, 2000.f) == 0.f); + REQUIRE(DistanceGain(9000.f, 2000.f) == 0.f); + + // Never gets louder further away + float previous = 1.f; + for (float distance = 0.f; distance <= 8400.f; distance += 50.f) + { + const float gain = DistanceGain(distance, 2000.f); + REQUIRE(gain <= previous); + previous = gain; + } + // A fade start of 0 would divide by zero, it means nobody is heard REQUIRE(DistanceGain(10.f, 0.f) == 0.f); } From 68e65f27809be6821724f928f907ef572a4a8719 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:52:21 +0000 Subject: [PATCH 61/97] Keep the voice fade hint to the two distances The settings hint names where voices start to fade and where they go silent, without describing the curve. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxRbmmR1Uv7KRvfWVgW --- Code/client/Services/Generic/VoiceService.cpp | 2 +- Code/skyrim_ui/src/assets/i18n/en.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Code/client/Services/Generic/VoiceService.cpp b/Code/client/Services/Generic/VoiceService.cpp index 1841ad2738..d6ac748edc 100644 --- a/Code/client/Services/Generic/VoiceService.cpp +++ b/Code/client/Services/Generic/VoiceService.cpp @@ -450,7 +450,7 @@ void VoiceService::SaveSettings() const noexcept "; Built-in proximity voice chat. On a server: lets players talk to players nearby. For a player: the Connect to voip checkbox in Settings"); ini.SetValue(kSection, "sOutputDevice", m_settings.OutputDevice.c_str(), "; Player: where voices play. Empty uses the Windows default device, like the game"); ini.SetValue(kSection, "sVoipChannel", ChannelToString(m_settings.ListenChannel), "; Player: who you hear, everyone, party or strangers"); - ini.SetDoubleValue(kSection, "fVoipFadeStart", m_settings.FadeStart, "; Player: voices are full volume up to this distance in game units, then -6 dB per doubling, silent at 4 times it"); + ini.SetDoubleValue(kSection, "fVoipFadeStart", m_settings.FadeStart, "; Player: voices are full volume up to this distance in game units, silent at 4 times it"); ini.SetDoubleValue(kSection, "fVoipMasterVolume", m_settings.MasterVolume, "; Player: all received voices, 0 to 5 (0-500%), default 1 (100%)"); ini.SetDoubleValue(kSection, "fMicVolume", m_settings.MicVolume, "; Player: microphone volume, 0 to 1"); ini.SetDoubleValue(kSection, "fNoiseGate", m_settings.NoiseGate, "; Player: microphone level below which nothing is sent, 0 to 1 like the Windows mic test bar"); diff --git a/Code/skyrim_ui/src/assets/i18n/en.json b/Code/skyrim_ui/src/assets/i18n/en.json index 7da18d4a6b..a2a2a674be 100644 --- a/Code/skyrim_ui/src/assets/i18n/en.json +++ b/Code/skyrim_ui/src/assets/i18n/en.json @@ -180,7 +180,7 @@ "CHANNEL_PARTY": "Party", "CHANNEL_STRANGERS": "Strangers", "FADE_START": "Voip fade start", - "FADE_HINT": "Full volume up to this distance, then fades like real sound, silent at {{ silent }} units" + "FADE_HINT": "Full volume up to this distance, silent at {{ silent }} units" }, "PARTY": "Party", "SHOW_PARTY": "Show Party", From e8b61fc4006c2cf87a3cd9f053c65ae60a34d6f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 14:42:47 +0000 Subject: [PATCH 62/97] Centre the leader crown on name tags; widen default tag ranges - The party leader's crown is now centred on the capital letters of the name instead of on the font's line box, which includes room for accents and descenders and left the crown sitting low. - Name tags now start fading at 2000 units (was 3000) and are invisible from 8000 units (was 5000), for both strangers and party members. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxPvkxb6UCNKSi6tjgx (cherry picked from commit c0744776679d5e2b9737773cdb85ddc4a8803587) --- Code/client/Services/Generic/NameTagService.cpp | 8 ++++++-- Code/client/Services/NameTagService.h | 8 ++++---- Code/skyrim_ui/src/app/services/setting.service.ts | 8 ++++---- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/Code/client/Services/Generic/NameTagService.cpp b/Code/client/Services/Generic/NameTagService.cpp index d5de5cab8e..978eedef5b 100644 --- a/Code/client/Services/Generic/NameTagService.cpp +++ b/Code/client/Services/Generic/NameTagService.cpp @@ -394,8 +394,12 @@ void NameTagService::OnDraw() noexcept if (isLeader) { - // Same icon as the party widget, a square crownWidth wide, centred on the text. - const ImVec2 origin(contentPos.x, contentPos.y + (textSize.y - crownWidth) * 0.5f); + // Same icon as the party widget, a square crownWidth wide. The line height includes room for + // accents and descenders, so centre the crown on the capital letters instead of on the line. + float textCenterY = textSize.y * 0.5f; + if (const ImFontGlyph* pCapital = font->FindGlyph('H')) + textCenterY = (pCapital->Y0 + pCapital->Y1) * 0.5f * (fontSize / font->FontSize); + const ImVec2 origin(contentPos.x, contentPos.y + textCenterY - crownWidth * 0.5f); DrawPartyCrown(drawList, ImVec2(origin.x + 1.f, origin.y + 1.f), crownWidth, shadowColor); DrawPartyCrown(drawList, origin, crownWidth, ColorWithAlpha(ImVec4(0.95f, 0.95f, 0.95f, 1.f), alpha)); } diff --git a/Code/client/Services/NameTagService.h b/Code/client/Services/NameTagService.h index 1789c63e95..9d43e48ae0 100644 --- a/Code/client/Services/NameTagService.h +++ b/Code/client/Services/NameTagService.h @@ -40,10 +40,10 @@ struct NameTagService // Party: party members' chat and dialogue choices float above them. On: strangers' chat too. Filter FloatingChat = Filter::kParty; float FontSize = 16.f; - float NameFadeStart = 3000.f; - float NameInvisible = 5000.f; - float PartyFadeStart = 3000.f; - float PartyInvisible = 5000.f; + float NameFadeStart = 2000.f; + float NameInvisible = 8000.f; + float PartyFadeStart = 2000.f; + float PartyInvisible = 8000.f; // Drawn in italics instead of the name of players whose name is hidden. String StrangerLabel = "Stranger"; // Players whose real name may be shown. The UI decides this from the diff --git a/Code/skyrim_ui/src/app/services/setting.service.ts b/Code/skyrim_ui/src/app/services/setting.service.ts index 179a015304..cc5e7ec93b 100644 --- a/Code/skyrim_ui/src/app/services/setting.service.ts +++ b/Code/skyrim_ui/src/app/services/setting.service.ts @@ -230,22 +230,22 @@ export class SettingService { nameFadeStart: new SliderSetting( this.storeService, 'playerUi_nameFadeStart', - 3000, + 2000, ), nameInvisible: new SliderSetting( this.storeService, 'playerUi_nameInvisible', - 5000, + 8000, ), partyFadeStart: new SliderSetting( this.storeService, 'playerUi_partyFadeStart', - 3000, + 2000, ), partyInvisible: new SliderSetting( this.storeService, 'playerUi_partyInvisible', - 5000, + 8000, ), }; From c43915c9eecb96c61879d4f69c8ffa4d3396d2da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 18:28:05 +0000 Subject: [PATCH 63/97] Don't block in the crash handler The vectored exception handler held a non-recursive mutex for every exception, so a second fault while it ran deadlocked the thread and every other thread that faulted after it. It also shut down spdlog even when the exception was later handled, after which any log call crashed. Use an atomic flag instead of the mutex and flush the logs instead of shutting spdlog down. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSuYsY5Nn19wykLCiD --- Code/client/CrashHandler.cpp | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/Code/client/CrashHandler.cpp b/Code/client/CrashHandler.cpp index d6c113d97e..092fc64c8a 100644 --- a/Code/client/CrashHandler.cpp +++ b/Code/client/CrashHandler.cpp @@ -2,6 +2,7 @@ #include "CrashHandler.h" #include #include +#include #include #include #include @@ -23,16 +24,12 @@ std::string SerializeTimePoint(const time_point& time, const std::string& format LONG WINAPI VectoredExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo) { - static int alreadyCrashed = 0; + static std::atomic_bool s_alreadyCrashed = false; auto retval = EXCEPTION_CONTINUE_SEARCH; - // Serialize - static std::mutex singleThreaded; - const std::lock_guard lock{singleThreaded}; - - // Check for severe, not continuable and not software-originated exception + // Handle only the first access violation. Later or nested ones pass through instead of blocking. if (pExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION && - alreadyCrashed++ == 0) + !s_alreadyCrashed.exchange(true)) { spdlog::critical (__FUNCTION__ ": crash occurred!"); @@ -115,13 +112,13 @@ LONG WINAPI VectoredExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo) { spdlog::critical(__FUNCTION__ ": UnhandledExceptionFilter() workaround triggered."); - singleThreaded.unlock(); // Might reenter, but is safe at this point. if ((*pCurrentUnhandledExceptionFilter)(pExceptionInfo) == EXCEPTION_CONTINUE_EXECUTION) retval = EXCEPTION_CONTINUE_EXECUTION; - singleThreaded.lock(); } - spdlog::shutdown(); + // The exception may still be handled, and logging after spdlog::shutdown() crashes + if (auto pLogger = spdlog::default_logger()) + pLogger->flush(); } return retval; } From 9f6bd00bdedd2b6f42989a9af6dc59f8699a99d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 20:39:25 +0000 Subject: [PATCH 64/97] Don't spawn dead actors that are disabled locally When another player's save has an actor enabled and dead but ours has it disabled, the spawn request enabled it and then killed it, which ran its OnDeath scripts again. With quest dragons that replayed quest stages and rewards. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSuYsY5Nn19wykLCiD --- Code/client/Services/Generic/CharacterService.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 7d3ac21af5..e807292d96 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -576,6 +576,13 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) return; } + // Enabling and killing it here would run its death scripts again, e.g. repeating quest stages and rewards + if (acMessage.IsDead && pActor->IsDisabled() && pActor->GetExtension()->Reconciliation != ActorExtension::ReconciliationStage::WaitingForDisable) + { + spdlog::info("Skipped spawn for dead actor {:X} that is disabled locally, server id: {:X}", cActorId, acMessage.ServerId); + return; + } + const auto view = m_world.view(); const auto itor = std::find_if(std::begin(view), std::end(view), [cActorId, view](entt::entity entity) { return view.get(entity).Id == cActorId; }); From 96eff8c7513217084e38e5a6b3919560898f8967 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 20:55:51 +0000 Subject: [PATCH 65/97] Don't restart completed run once quests from party updates A party member who hadn't finished a run once quest, like Dragon Rising, sent its start and stages to players who already had. That restarted the quest for them and replayed its rewards, such as the Axe of Whiterun and the Fus shout. Ignore starts and stages for run once quests that are already completed locally, as the game itself would. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSuYsY5Nn19wykLCiD --- Code/client/Services/Generic/QuestService.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Code/client/Services/Generic/QuestService.cpp b/Code/client/Services/Generic/QuestService.cpp index f6a5d39b83..94c644e550 100644 --- a/Code/client/Services/Generic/QuestService.cpp +++ b/Code/client/Services/Generic/QuestService.cpp @@ -517,6 +517,14 @@ void QuestService::OnQuestUpdate(const NotifyQuestUpdate& aUpdate) noexcept return; } + // Like the game, never restart a run once quest that's already done here, which would hand out its rewards again + const bool cIsStartOrStage = aUpdate.Status == NotifyQuestUpdate::Started || aUpdate.Status == NotifyQuestUpdate::StageUpdate; + if (cIsStartOrStage && (pQuest->flags & TESQuest::Flags::RunOnce) && (pQuest->flags & TESQuest::Flags::Completed)) + { + spdlog::info("Remote quest {:X} is a completed run once quest here, ignoring stage {}", formId, aUpdate.Stage); + return; + } + if (aUpdate.Status == NotifyQuestUpdate::StageUpdate && pQuest->IsStageReached(aUpdate.Stage)) { spdlog::info("Remote quest {:X} already reached stage {}, current stage: {}", formId, aUpdate.Stage, pQuest->currentStage); From 869aa9ea4cf8cc411f69cc90f5f71ce7b9d87f76 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 18:18:17 +0000 Subject: [PATCH 66/97] Cut party UI redraws while a party member is in combat A party member's health percentage changed on nearly every 500 ms tick during combat (damage, then regeneration), and every update rebuilt the party list and made the overlay repaint. Each overlay repaint copies the whole screen into a texture, which shows up as a periodic frametime spike. - Round the sent health percentage up to a whole number, so it only goes out when the bar visibly changes and 0 still means dead. - Ignore health updates that don't change a member's value, and only flash the party list on a real change. - Track party members by id so their elements are reused, and drop the width transition, which would otherwise repaint for 0.8 s per update. See tiltedphoques/TiltedEvolution#940. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxPkaY5xU6UUaRU3L8h --- Code/client/Services/Generic/OverlayService.cpp | 7 ++++++- .../src/app/components/group/group.component.html | 2 +- .../src/app/components/group/group.component.scss | 1 - Code/skyrim_ui/src/app/components/group/group.component.ts | 6 +++++- Code/skyrim_ui/src/app/services/client.service.ts | 4 +++- Code/skyrim_ui/src/app/services/group.service.ts | 7 ++++++- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/Code/client/Services/Generic/OverlayService.cpp b/Code/client/Services/Generic/OverlayService.cpp index 50f2b3a734..8c9a9fce18 100644 --- a/Code/client/Services/Generic/OverlayService.cpp +++ b/Code/client/Services/Generic/OverlayService.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include @@ -491,7 +493,10 @@ void OverlayService::RunPlayerHealthUpdates() noexcept static float s_previousPercentage = -1.f; - const float newPercentage = CalculateHealthPercentage(PlayerCharacter::Get()); + // The party UI shows a bar, so whole percents are enough. Round up so that 0 still means dead. + // Without rounding, regeneration in combat changes the value on every tick and each update + // makes the overlay redraw. + const float newPercentage = std::ceil(CalculateHealthPercentage(PlayerCharacter::Get())); if (newPercentage == s_previousPercentage) return; diff --git a/Code/skyrim_ui/src/app/components/group/group.component.html b/Code/skyrim_ui/src/app/components/group/group.component.html index 26df57b3d7..b2ef806fbd 100644 --- a/Code/skyrim_ui/src/app/components/group/group.component.html +++ b/Code/skyrim_ui/src/app/components/group/group.component.html @@ -5,7 +5,7 @@ [ngStyle]="positionStyle | async" >
{ this.flashGroup(); @@ -213,6 +213,10 @@ export class GroupComponent implements OnInit, OnDestroy { } } + public trackByMemberId(_index: number, member: Player) { + return member.id; + } + public invite(playerId: number) { this.groupService.invite(playerId); } diff --git a/Code/skyrim_ui/src/app/services/client.service.ts b/Code/skyrim_ui/src/app/services/client.service.ts index 8aea46cb6d..b6b09c1e59 100644 --- a/Code/skyrim_ui/src/app/services/client.service.ts +++ b/Code/skyrim_ui/src/app/services/client.service.ts @@ -592,7 +592,9 @@ export class ClientService implements OnDestroy { private onSetHealth(playerId: number, health: number) { this.zone.run(() => { - this.healthChange.next(new Player({ id: playerId, health: health })); + this.healthChange.next( + new Player({ id: playerId, health: Math.ceil(health) }), + ); }); } diff --git a/Code/skyrim_ui/src/app/services/group.service.ts b/Code/skyrim_ui/src/app/services/group.service.ts index 706a19f93b..539b628d40 100644 --- a/Code/skyrim_ui/src/app/services/group.service.ts +++ b/Code/skyrim_ui/src/app/services/group.service.ts @@ -6,6 +6,7 @@ import { Observable, of, pluck, + Subject, Subscription, } from 'rxjs'; import { filter, map } from 'rxjs/operators'; @@ -25,6 +26,9 @@ import { Sound, SoundService } from './sound.service'; export class GroupService implements OnDestroy { public group = new BehaviorSubject(undefined); + /** Emits the id of a party member whose health value actually changed. */ + public memberHealthChange = new Subject(); + private debugSubscription: Subscription; private connectionSubscription: Subscription; private userHealthSubscription: Subscription; @@ -102,9 +106,10 @@ export class GroupService implements OnDestroy { if (group) { const foundPlayer = this.playerListService.getPlayerById(player.id); - if (foundPlayer) { + if (foundPlayer && foundPlayer.health !== player.health) { foundPlayer.health = player.health; this.updateGroup(); + this.memberHealthChange.next(foundPlayer.id); } } }, From 7aabbf5b80901ac1d7de61d62238e5ec5dbced32 Mon Sep 17 00:00:00 2001 From: absol89 Date: Wed, 4 Nov 2026 22:27:49 +0100 Subject: [PATCH 67/97] Harden actor spawning and add crash diagnostics --- Code/client/Games/Skyrim/Actor.cpp | 49 ++++++++++++++++++- Code/client/Services/Debug/DebugService.cpp | 2 + .../Services/Generic/CharacterService.cpp | 27 ++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index c3f594a45d..498d79e4c9 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -231,7 +232,14 @@ GamePtr Actor::Create(TESNPC* apBaseForm) noexcept if (pCell && !(pCell->cellFlags & 1)) pCell = nullptr; - ModManager::Get()->Spawn(position, rotation, pCell, pWorldSpace, pActor); + // A failed placement leaves this actor without a form ID or handle. Returning its only GamePtr + // reference would free it while callers may still queue engine work against the raw pointer. + const uint32_t cRefHandle = ModManager::Get()->Spawn(position, rotation, pCell, pWorldSpace, pActor); + if (cRefHandle == 0 || pActor->formID == 0) + { + spdlog::error("Actor::Create: failed to place actor with base form {:X}", apBaseForm ? apBaseForm->formID : 0); + return {}; + } pActor->ForcePosition(position); @@ -452,12 +460,45 @@ bool TP_MAKE_THISCALL(HookRemoveSpell, Actor, MagicItem* apSpell) return result; } +namespace +{ +// Diagnostic for characters constructed or loaded without a valid form ID. +void LogFormIdZeroActor(const char* acpWhere, const Actor* apActor) noexcept +{ + void* frames[24]{}; + const USHORT cCount = RtlCaptureStackBackTrace(1, static_cast(std::size(frames)), frames, nullptr); + std::string stack; + for (USHORT i = 0; i < cCount; ++i) + { + HMODULE hModule = nullptr; + char moduleName[MAX_PATH] = "?"; + if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, reinterpret_cast(frames[i]), &hModule) && hModule) + { + char path[MAX_PATH]{}; + if (GetModuleFileNameA(hModule, path, MAX_PATH)) + { + const char* cpName = std::strrchr(path, '\\'); + strncpy_s(moduleName, cpName ? cpName + 1 : path, _TRUNCATE); + } + } + const auto cOffset = hModule ? reinterpret_cast(frames[i]) - reinterpret_cast(hModule) : reinterpret_cast(frames[i]); + stack += fmt::format(" {}+{:X}", moduleName, cOffset); + } + const auto* pNpc = Cast(apActor->baseForm); + spdlog::warn("[FormIdZeroActor] {}: actor {}, base {:X} ({}), parent cell {}, flags {:X}, thread {}, stack:{}", acpWhere, fmt::ptr(apActor), + apActor->baseForm ? apActor->baseForm->formID : 0, pNpc && pNpc->fullName.value.AsAscii() ? pNpc->fullName.value.AsAscii() : "", fmt::ptr(apActor->parentCell), + apActor->flags, GetCurrentThreadId(), stack); +} +} // namespace Actor* TP_MAKE_THISCALL(HookCharacterConstructor, Actor) { TP_EMPTY_HOOK_PLACEHOLDER; TiltedPhoques::ThisCall(RealCharacterConstructor, apThis); + if (apThis->formID == 0) + LogFormIdZeroActor("Character constructor", apThis); + return apThis; } @@ -467,6 +508,9 @@ Actor* TP_MAKE_THISCALL(HookCharacterConstructor2, Actor, uint8_t aUnk) TiltedPhoques::ThisCall(RealCharacterConstructor2, apThis, aUnk); + if (apThis->formID == 0) + LogFormIdZeroActor("Character constructor 2", apThis); + return apThis; } @@ -990,6 +1034,9 @@ bool TP_MAKE_THISCALL(HookSpawnActorInWorld, Actor) spdlog::info("Spawn Actor: {:X}, and NPC {}", apThis->formID, pNpc->fullName.value); } + if (apThis->formID == 0) + LogFormIdZeroActor("UpdateReference3D", apThis); + return TiltedPhoques::ThisCall(RealSpawnActorInWorld, apThis); } diff --git a/Code/client/Services/Debug/DebugService.cpp b/Code/client/Services/Debug/DebugService.cpp index c36d6693e0..8502d92fff 100644 --- a/Code/client/Services/Debug/DebugService.cpp +++ b/Code/client/Services/Debug/DebugService.cpp @@ -87,6 +87,8 @@ void __declspec(noinline) DebugService::PlaceActorInWorld() noexcept const auto pPlayerBaseForm = static_cast(PlayerCharacter::Get()->baseForm); auto pActor = Actor::Create(pPlayerBaseForm); + if (!pActor) + return; const Inventory inventory = PlayerCharacter::Get()->GetActorInventory(); pActor->SetActorInventory(inventory); diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index fbbeae2618..eae23583de 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -89,7 +89,25 @@ bool CanApplyInventory(Actor* apActor) noexcept { return apActor && apActor->currentProcess && apActor->currentProcess->middleProcess && apActor->GetNiNode(); } + +void LogActorPlacement(const char* acpWhat, uint32_t aFormId) noexcept +{ + auto* pActor = Cast(TESForm::GetById(aFormId)); + const auto* pPlayer = PlayerCharacter::Get(); + if (!pActor || !pPlayer) + return; + + const auto* pCell = pActor->GetParentCellEx(); + const auto* pPlayerCell = pPlayer->GetParentCellEx(); + const auto* pWorldSpace = pActor->GetWorldSpace(); + const auto* pPlayerWorldSpace = pPlayer->GetWorldSpace(); + const auto* pExtension = pActor->GetExtension(); + spdlog::info("[ActorPlacement] {} {:X}: cell {:X}, worldspace {:X}, position ({:.0f}, {:.0f}, {:.0f}), remote {}; player cell {:X}, worldspace {:X}, position ({:.0f}, {:.0f}, {:.0f})", + acpWhat, aFormId, pCell ? pCell->formID : 0, pWorldSpace ? pWorldSpace->formID : 0, pActor->position.x, pActor->position.y, + pActor->position.z, pExtension && pExtension->IsRemote(), pPlayerCell ? pPlayerCell->formID : 0, pPlayerWorldSpace ? pPlayerWorldSpace->formID : 0, + pPlayer->position.x, pPlayer->position.y, pPlayer->position.z); } +} // namespace CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept : m_world(aWorld) @@ -297,11 +315,13 @@ void CharacterService::OnActorAdded(const ActorAddedEvent& acEvent) noexcept m_world.emplace_or_replace(entity, acEvent.FormId); m_world.emplace_or_replace(entity); + LogActorPlacement("Added", acEvent.FormId); ProcessNewEntity(entity); } void CharacterService::OnActorRemoved(const ActorRemovedEvent& acEvent) noexcept { + LogActorPlacement("Removing", acEvent.FormId); if (auto* pActor = Cast(TESForm::GetById(acEvent.FormId))) pActor->GetExtension()->Reconciliation = ActorExtension::ReconciliationStage::None; @@ -541,7 +561,12 @@ void CharacterService::OnAssignCharacter(const AssignCharacterResponse& acMessag ReconcileActorData(cEntity, pActor, acMessage.OwnershipEpoch, actorData, true, false); + spdlog::info("[ActorPlacement] Moving remote {:X} to owner worldspace {:X}:{:X}, cell {:X}:{:X}, position ({:.0f}, {:.0f}, {:.0f})", pActor->formID, + acMessage.WorldSpaceId.ModId, acMessage.WorldSpaceId.BaseId, acMessage.CellId.ModId, acMessage.CellId.BaseId, acMessage.Position.x, acMessage.Position.y, + acMessage.Position.z); + LogActorPlacement("Before move", pActor->formID); MoveActor(pActor, acMessage.WorldSpaceId, acMessage.CellId, acMessage.Position); + LogActorPlacement("After move", pActor->formID); // The owner's leveled pick rides the assignment response for actors we discovered ourselves ApplyLeveledNpcPick(pActor, acMessage.LeveledNpcPickId); @@ -679,6 +704,8 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) if (!pActor) { spdlog::error("Actor object {:X} could not be created.", acMessage.ServerId); + if (acMessage.FormId == GameId{} && entity) + m_world.destroy(*entity); return; } From 4940509f5ee4fd00d9643294f9f7e33152c13610 Mon Sep 17 00:00:00 2001 From: absol89 Date: Wed, 4 Nov 2026 22:43:25 +0100 Subject: [PATCH 68/97] Keep remote corpses dead through loading and animation replay Retain canonical death state across unloaded actors and defer NPC death until its high process and body are ready. Suppress stale living animation writes while retaining position synchronization. Adapt the playtest missed-transfer recovery path and preserve its ownership validation and equipment replay. Read-only Ghidra evidence for Skyrim 1.6.1170 and manual regression checks are in RemoteDeathSystem.md. In-game behavior and 1.7.104 binary equivalence remain unverified. (cherry picked from commit 7560b464c411a202fbc52a0e8da30be6a7a5459f) --- Code/client/Components/RemoteComponent.h | 2 + .../Services/Generic/ActorValueService.cpp | 47 +++++++------ .../Services/Generic/CharacterService.cpp | 69 ++++++++++++++----- Code/client/Systems/InterpolationSystem.cpp | 5 +- Code/client/Systems/InterpolationSystem.h | 2 +- Code/client/Systems/RemoteDeathSystem.h | 29 ++++++++ Code/client/Systems/RemoteDeathSystem.md | 48 +++++++++++++ 7 files changed, 160 insertions(+), 42 deletions(-) create mode 100644 Code/client/Systems/RemoteDeathSystem.h create mode 100644 Code/client/Systems/RemoteDeathSystem.md diff --git a/Code/client/Components/RemoteComponent.h b/Code/client/Components/RemoteComponent.h index 9704cca749..74e4188cdf 100644 --- a/Code/client/Components/RemoteComponent.h +++ b/Code/client/Components/RemoteComponent.h @@ -18,4 +18,6 @@ struct RemoteComponent uint32_t Id; uint32_t CachedRefId; uint32_t OwnershipEpoch; + // Canonical death state survives loading and prevents living animation replay on corpses. + bool IsDead{false}; }; diff --git a/Code/client/Services/Generic/ActorValueService.cpp b/Code/client/Services/Generic/ActorValueService.cpp index 612564c29b..868981f985 100644 --- a/Code/client/Services/Generic/ActorValueService.cpp +++ b/Code/client/Services/Generic/ActorValueService.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include #include @@ -296,6 +298,21 @@ void ActorValueService::OnHealthChangeBroadcast(const NotifyHealthChangeBroadcas } const float newHealth = pActor->GetActorValue(ActorValueInfo::kHealth) + acMessage.DeltaHealth; + if (!pActor->GetExtension()->IsPlayer() && pActor->GetExtension()->IsRemote() && !pActor->IsDead() && newHealth <= 0.f) + { + const auto entity = Utils::FindEntityByServerId(acMessage.Id); + if (entity && m_world.all_of(*entity)) + { + m_world.get(*entity).IsDead = true; + if (auto* pWaiting = m_world.try_get(*entity)) + pWaiting->SpawnRequest.IsDead = true; + // Do not let zero health commit death before the physical body is ready. + pActor->ForceActorValue(ActorValueOwner::ForceMode::DAMAGE, ActorValueInfo::kHealth, 1.f); + if (!m_world.any_of(*entity)) + RemoteDeathSystem::Apply(pActor, true); + return; + } + } pActor->ForceActorValue(ActorValueOwner::ForceMode::DAMAGE, ActorValueInfo::kHealth, newHealth); const float health = pActor->GetActorValue(ActorValueInfo::kHealth); @@ -369,29 +386,15 @@ void ActorValueService::OnActorMaxValueChanges(const NotifyActorMaxValueChanges& void ActorValueService::OnDeathStateChange(const NotifyDeathStateChange& acMessage) const noexcept { const auto entity = OwnershipSystem::AcceptRemoteUpdate(m_world, acMessage.Id, acMessage.OwnershipEpoch); - const auto* pFormIdComponent = entity ? m_world.try_get(*entity) : nullptr; - if (!pFormIdComponent) - { - const auto cEntity = Utils::FindEntityByServerId(acMessage.Id); - spdlog::info( - "Dropped death state {} for server id {:X} at epoch {}, entity found: {}, has form id: {}, waiting for 3D: {}", acMessage.IsDead, acMessage.Id, acMessage.OwnershipEpoch, - cEntity.has_value(), cEntity && m_world.all_of(*cEntity), cEntity && m_world.all_of(*cEntity)); + if (!entity) return; - } - Actor* pActor = Cast(TESForm::GetById(pFormIdComponent->Id)); - - if (!pActor) - return; + m_world.get(*entity).IsDead = acMessage.IsDead; + if (auto* pWaiting = m_world.try_get(*entity)) + pWaiting->SpawnRequest.IsDead = acMessage.IsDead; - ActorExtension* pExtension = pActor->GetExtension(); - // Players should never be killed - if (pExtension->IsPlayer()) - return; - - if (pActor->IsDead() != acMessage.IsDead) - { - spdlog::info("Applying death state {} to remote actor {:X}, server id: {:X}", acMessage.IsDead, pActor->formID, acMessage.Id); - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); - } + const auto* pFormId = m_world.try_get(*entity); + Actor* pActor = pFormId ? Cast(TESForm::GetById(pFormId->Id)) : nullptr; + if (!m_world.any_of(*entity)) + RemoteDeathSystem::Apply(pActor, acMessage.IsDead); } diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index eae23583de..7a466d294b 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -188,6 +189,9 @@ void CharacterService::ReconcileActorData( if (aApplyInventory && !CanApplyInventory(apActor) && !m_world.all_of(aEntity)) m_world.emplace(aEntity, acActorData.InitialInventory, aOwnershipEpoch); + if (!aIsLocalOwner) + m_world.get(aEntity).IsDead = acActorData.IsDead; + if (!apActor) return; @@ -206,8 +210,8 @@ void CharacterService::ReconcileActorData( { if (acActorData.IsDead) { - // Remote spawns finish their death transition in the WaitingFor3D step. - if (aIsLocalOwner || !m_world.any_of(aEntity)) + // Remote actors finish death in RunRemoteUpdates after placement and 3D setup. + if (aIsLocalOwner) { spdlog::info("Killing actor {:X} to match server state, local owner: {}", apActor->formID, aIsLocalOwner); apActor->Kill(); @@ -596,6 +600,7 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) // A transfer was missed, so the updates relayed since then were dropped as stale. Catch up from the spawn state. spdlog::info("Adopting ownership epoch {} for spawned actor {:X}, the transfer from epoch {} was missed", acMessage.OwnershipEpoch, acMessage.ServerId, remoteComponent.OwnershipEpoch); remoteComponent.OwnershipEpoch = acMessage.OwnershipEpoch; + remoteComponent.IsDead = acMessage.IsDead; InventoryService::ApplyRemoteInventory(m_world, *remoteItor, acMessage.InventoryContent); @@ -609,9 +614,12 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) { if (Actor* pActor = Cast(TESForm::GetById(pFormIdComponent->Id))) { - pActor->SetActorValues(acMessage.InitialActorValues); - if (!acMessage.IsPlayer && pActor->IsDead() != acMessage.IsDead) - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); + pActor->SetActorValues(acMessage.IsDead && !acMessage.IsPlayer && !pActor->IsDead() + ? ValuesUntilKilled(acMessage.InitialActorValues) + : acMessage.InitialActorValues); + // RunRemoteUpdates completes death after the loaded body is ready. + if (!acMessage.IsPlayer && !acMessage.IsDead) + RemoteDeathSystem::Apply(pActor, false); } } @@ -736,8 +744,8 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) } // Let WaitingFor3D kill a new corpse once its loaded body can fall. - if (pActor->IsDead() != acMessage.IsDead && (!acMessage.IsDead || CanApplyInventory(pActor))) - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); + if (pActor->IsDead() && !acMessage.IsDead) + pActor->Respawn(); spdlog::info("Spawn Request Is summon {}", acMessage.IsPlayerSummon); @@ -751,7 +759,7 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) if (acMessage.FormId != GameId{}) ApplyLeveledNpcPick(pActor, acMessage.LeveledNpcPickId); - m_world.emplace_or_replace(*entity, acMessage.ServerId, pActor->formID, acMessage.OwnershipEpoch); + m_world.emplace_or_replace(*entity, acMessage.ServerId, pActor->formID, acMessage.OwnershipEpoch).IsDead = acMessage.IsDead; auto& interpolationComponent = InterpolationSystem::Setup(m_world, *entity); interpolationComponent.Position = acMessage.Position; @@ -908,7 +916,8 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess if (isLocalOwner) { - if (!pFormIdComponent || !CanApplyInventory(pActor)) + if (!pFormIdComponent || !CanApplyInventory(pActor) || + (acMessage.CurrentActorData.IsDead && !pActor->IsDead() && !RemoteDeathSystem::CanKill(pActor))) { uint32_t cachedRefId = pFormIdComponent ? pFormIdComponent->Id : 0; if (const auto* pRemoteComponent = m_world.try_get(cEntity)) @@ -923,6 +932,9 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess else if (cachedRefId != 0) m_world.emplace(cEntity, acMessage.ServerId, cachedRefId, acMessage.OwnershipEpoch); + if (auto* pRemote = m_world.try_get(cEntity)) + pRemote->IsDead = acMessage.CurrentActorData.IsDead; + spdlog::warn("Declined ownership of actor {:X} at epoch {} because the actor is not ready", acMessage.ServerId, acMessage.OwnershipEpoch); DeclineOwnership(acMessage.ServerId, acMessage.OwnershipEpoch); return; @@ -1776,9 +1788,11 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no m_world.emplace_or_replace(aEntity, acMessage.PlayerId); } + remoteComponent.IsDead = acMessage.IsDead; + // Recreated copies use the same WaitingFor3D death transition as new spawns. - if (pActor->IsDead() != acMessage.IsDead && (!acMessage.IsDead || CanApplyInventory(pActor))) - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); + if (pActor->IsDead() && !acMessage.IsDead) + pActor->Respawn(); spdlog::info("Spawned character for entity, server id: {:X}", remoteComponent.Id); @@ -2033,7 +2047,12 @@ void CharacterService::RunRemoteUpdates() noexcept pActor = Cast(pForm); } - InterpolationSystem::Update(pActor, interpolationComponent, cNow); + const bool cDeadNpc = pActor && !pActor->GetExtension()->IsPlayer() && + (interpolatedEntities.get(entity).IsDead || pActor->IsDead()); + // Keep position synchronization, but do not overwrite a corpse's animation graph. + InterpolationSystem::Update(pActor, interpolationComponent, cNow, !cDeadNpc); + if (cDeadNpc && interpolatedEntities.get(entity).IsDead && !m_world.any_of(entity)) + RemoteDeathSystem::Apply(pActor, true); } auto animatedView = m_world.view(); @@ -2048,6 +2067,16 @@ void CharacterService::RunRemoteUpdates() noexcept if (!pActor) continue; + if (!pActor->GetExtension()->IsPlayer() && + (animatedView.get(entity).IsDead || pActor->IsDead())) + { + // Keep LastProcessedAction for differential decoding, but discard stale living actions. + animationComponent.TimePoints.clear(); + animationComponent.ReplayCount = 0; + animationComponent.ResetAnimationGraphForReplay = false; + continue; + } + // Actions run on the clock the interpolation pass above just advanced, so the body and its animations stay in step const auto cTick = InterpolationSystem::GetPlaybackTick(animatedView.get(entity)); @@ -2080,6 +2109,9 @@ void CharacterService::RunRemoteUpdates() noexcept Actor* pActor = Cast(TESForm::GetById(formIdComponent.Id)); if (!CanApplyInventory(pActor)) continue; + if (waitingFor3D.SpawnRequest.IsDead && !pActor->GetExtension()->IsPlayer() && + !pActor->IsDead() && !RemoteDeathSystem::CanKill(pActor)) + continue; // By now, the actor has materialized in the world and is ready for further setup @@ -2097,15 +2129,17 @@ void CharacterService::RunRemoteUpdates() noexcept InventoryService::ApplyEquipmentChange(pActor, change); } - if (!waitingFor3D.SpawnRequest.ActionsToReplay.Actions.empty()) + if (!waitingFor3D.SpawnRequest.IsDead && !waitingFor3D.SpawnRequest.ActionsToReplay.Actions.empty()) { pActor->LoadAnimationVariables(waitingFor3D.SpawnRequest.ActionsToReplay.Actions[0].Variables); } - m_weaponDrawUpdates[pActor->formID] = {waitingFor3D.SpawnRequest.IsWeaponDrawn}; + if (!waitingFor3D.SpawnRequest.IsDead) + m_weaponDrawUpdates[pActor->formID] = {waitingFor3D.SpawnRequest.IsWeaponDrawn}; + else + m_weaponDrawUpdates.erase(pActor->formID); - if (pActor->IsDead() != waitingFor3D.SpawnRequest.IsDead) - waitingFor3D.SpawnRequest.IsDead ? pActor->Kill() : pActor->Respawn(); + RemoteDeathSystem::Apply(pActor, waitingFor3D.SpawnRequest.IsDead); if (pActor->IsVampireLord()) pActor->FixVampireLordModel(); @@ -2273,7 +2307,8 @@ void CharacterService::ApplyCachedWeaponDraws(const UpdateEvent& acUpdateEvent) continue; Actor* pActor = Cast(TESForm::GetById(cId)); - if (!pActor || !pActor->GetExtension()->IsRemote()) + if (!pActor || !pActor->GetExtension()->IsRemote() || + (!pActor->GetExtension()->IsPlayer() && pActor->IsDead())) { toRemove.push_back(cId); continue; diff --git a/Code/client/Systems/InterpolationSystem.cpp b/Code/client/Systems/InterpolationSystem.cpp index e5fdb7e30a..9a7f4f7415 100644 --- a/Code/client/Systems/InterpolationSystem.cpp +++ b/Code/client/Systems/InterpolationSystem.cpp @@ -22,7 +22,7 @@ constexpr double cPositionCorrectionTime = 50.0; constexpr size_t cMaxTimePoints = 64; } // namespace -void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, const double aNow) noexcept +void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, const double aNow, const bool aApplyAnimation) noexcept { auto& clock = aInterpolationComponent.Clock; const double cDeltaTime = clock.Advance(aNow); @@ -97,7 +97,8 @@ void InterpolationSystem::Update(Actor* apActor, InterpolationComponent& aInterp return; apActor->ForcePosition(aInterpolationComponent.Position); - apActor->LoadAnimationVariables(second.Variables); + if (aApplyAnimation) + apActor->LoadAnimationVariables(second.Variables); if (apActor->currentProcess && apActor->currentProcess->middleProcess) { diff --git a/Code/client/Systems/InterpolationSystem.h b/Code/client/Systems/InterpolationSystem.h index 6a44764107..0363671267 100644 --- a/Code/client/Systems/InterpolationSystem.h +++ b/Code/client/Systems/InterpolationSystem.h @@ -9,7 +9,7 @@ struct Actor; struct InterpolationSystem { // aNow and aArrivalTime are on the same local clock, see PlaybackClock - static void Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, double aNow) noexcept; + static void Update(Actor* apActor, InterpolationComponent& aInterpolationComponent, double aNow, bool aApplyAnimation = true) noexcept; static void AddPoint(InterpolationComponent& aInterpolationComponent, InterpolationComponent::TimePoint aPoint, double aArrivalTime) noexcept; // The owner tick that remote actions should be played up to, 0 until the first snapshot has arrived static uint64_t GetPlaybackTick(const InterpolationComponent& acInterpolationComponent) noexcept; diff --git a/Code/client/Systems/RemoteDeathSystem.h b/Code/client/Systems/RemoteDeathSystem.h new file mode 100644 index 0000000000..25235388bd --- /dev/null +++ b/Code/client/Systems/RemoteDeathSystem.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace RemoteDeathSystem +{ +inline bool CanKill(Actor* apActor) noexcept +{ + // SE 1.6.1170 KillImpl (37896) requires a high process and loaded body for its physical death path. + // AIProcess::movementType is the existing name for the process-level byte at 0x137 (high == 0). + return apActor && apActor->currentProcess && apActor->currentProcess->movementType == 0 && + apActor->currentProcess->middleProcess && apActor->GetNiNode() && apActor->animationGraphHolder.IsReady(); +} + +inline void Apply(Actor* apActor, bool aIsDead) noexcept +{ + if (!apActor || apActor->GetExtension()->IsPlayer() || apActor->IsDead() == aIsDead) + return; + + if (!aIsDead) + apActor->Respawn(); + else if (CanKill(apActor)) + { + spdlog::info("Applying loaded remote NPC death {:X}", apActor->formID); + apActor->Kill(); + } +} +} // namespace RemoteDeathSystem diff --git a/Code/client/Systems/RemoteDeathSystem.md b/Code/client/Systems/RemoteDeathSystem.md new file mode 100644 index 0000000000..5a03eca6ca --- /dev/null +++ b/Code/client/Systems/RemoteDeathSystem.md @@ -0,0 +1,48 @@ +# Remote corpse synchronization + +The remote component retains the canonical dead/alive state even without loaded 3D. +Spawning and reconciliation defer NPC death until a high process, middle process, +loaded node and ready animation graph are available. Zero-health broadcasts use the +same path. Death notifications update pending spawns, including those without a form +component. Ownership grants that would kill an unready actor are declined. + +For dead NPCs, movement snapshots still synchronize position, but do not overwrite +the animation graph. Queued actions are discarded without +resetting the differential decoder. Already-dead actors are not resurrected to repair +their pose; this patch prevents premature death and subsequent living animation replay. + +## Binary evidence + +Read-only Ghidra MCP, Skyrim SE 1.6.1170 unpacked database, image base 0x140000000. +Address Library script maps ID 37896 to 0x1406972D0 (KillImpl). +The decompiled routine has an early virtual-state check at vtable +0x4C8 and returns +when that check succeeds. Its physical-body branch checks: + +```c +lVar12 = param_1[0x1f]; +if (((lVar12 == 0) || (*(char *)(lVar12 + 0x137) != 0)) || (lVar10 == 0)) { + FUN_14068e640(param_1); + *(uint *)(param_1 + 0x1d) |= 0x800; +} else { + // loaded-body death processing +} +``` + +`lVar10` comes from 0x1402E5770, which returns the cached loaded node or the pointer +at [actor+0x68]+0x68. The repository already asserts AIProcess::movementType at 0x137; +CommonLibSSE-NG names this byte processLevel and defines high as zero. No offsets or +hooks were added. Requiring a ready animation graph is a conservative source-level +guard; it is not proven sufficient by the decompilation. Skyrim 1.7.104 was not +independently inspected, so equivalence remains assumed. + +## In-game verification still required + +- Player 1 owns an existing dead NPC near Embershard Mine; player 2 approaches and + must see a lying, lootable corpse without idle animations. +- Repeat with player 2 leaving/re-entering, joining late, and receiving ownership. +- Kill a living remote NPC while player 2 is loading its cell. +- Verify a genuine respawn permits normal animation again; player death is unchanged. +- Confirm corpse loot/equipment and ordinary living NPC animations still work. + +Compilation does not establish that these gameplay checks pass. Previously saved +standing corpses may still need a normal game reload; no forced resurrection is used. From 6edab7bb80fb96bd3cff32b4473eaf929af17aa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 00:26:16 +0000 Subject: [PATCH 69/97] Don't teleport remote actors into a worldspace the player isn't in When an owner gives up its actors, the server sends their positions to every player. A player in an interior then loaded those exterior cells and moved the actors there, which crashed a QueuedPromoteLargeReferencesTask with a null cell. It also left quest actors like dead dragons in the wrong place in that player's save. Skip the move when the target worldspace isn't the player's; the actor is placed when it spawns. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxSuYsY5Nn19wykLCiD --- Code/client/Services/Generic/CharacterService.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index e807292d96..9763e1433d 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -1293,6 +1293,15 @@ void CharacterService::MoveActor(const Actor* apActor, const GameId& acWorldSpac { const uint32_t cWorldSpaceId = m_world.GetModSystem().GetGameId(acWorldSpaceId); TESWorldSpace* const pWorldSpace = Cast(TESForm::GetById(cWorldSpaceId)); + + // Loading a cell outside the player's worldspace can crash the reference promote tasks and strands the actor + // there in our save. It's placed when it spawns instead. + if (pWorldSpace && pWorldSpace != PlayerCharacter::Get()->GetWorldSpace()) + { + spdlog::info(__FUNCTION__ ": skipped moving actor {:X} to worldspace {:X}, the player isn't in it", apActor->formID, cWorldSpaceId); + return; + } + if (pWorldSpace) { GridCellCoords coordinates = GridCellCoords::CalculateGridCellCoords(acPosition); From e2c45e44f5a795d55fe1c73de8ff7863339878de Mon Sep 17 00:00:00 2001 From: absol89 Date: Wed, 4 Nov 2026 21:45:17 +0100 Subject: [PATCH 70/97] Make offensive perks work against other players in PvP PvP hits resolve on the victim's client against its copy of the attacker, which only has the perks on its TESNPC, not the ones the player earned. Players now share their perk list and the victim's client answers perk checks and entry points for that copy from it, without adding the perks to the copy so their abilities don't run. Perks missing from the load order are skipped. Only active with PvP on. Entry points only answer when the local player is one of their arguments, so nothing changes when a remote player hits an NPC. Needs an in-game test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Games/Skyrim/Forms/BGSPerk.h | 75 ++++++ .../client/Games/Skyrim/Magic/MagicTarget.cpp | 15 +- Code/client/Games/Skyrim/RemotePerks.cpp | 228 ++++++++++++++++++ Code/client/Games/Skyrim/RemotePerks.h | 52 ++++ .../client/Services/Generic/PlayerService.cpp | 79 ++++++ Code/client/Services/PlayerService.h | 19 ++ Code/encoding/Messages/ClientMessageFactory.h | 3 +- Code/encoding/Messages/NotifyPlayerPerks.cpp | 23 ++ Code/encoding/Messages/NotifyPlayerPerks.h | 25 ++ Code/encoding/Messages/PlayerPerksRequest.cpp | 21 ++ Code/encoding/Messages/PlayerPerksRequest.h | 24 ++ Code/encoding/Messages/ServerMessageFactory.h | 3 +- Code/encoding/Opcodes.h | 2 + Code/encoding/Structs/PlayerPerk.cpp | 20 ++ Code/encoding/Structs/PlayerPerk.h | 18 ++ Code/server/Services/PlayerService.cpp | 12 + Code/server/Services/PlayerService.h | 3 + Code/tests/encoding.cpp | 41 ++++ 18 files changed, 660 insertions(+), 3 deletions(-) create mode 100644 Code/client/Games/Skyrim/Forms/BGSPerk.h create mode 100644 Code/client/Games/Skyrim/RemotePerks.cpp create mode 100644 Code/client/Games/Skyrim/RemotePerks.h create mode 100644 Code/encoding/Messages/NotifyPlayerPerks.cpp create mode 100644 Code/encoding/Messages/NotifyPlayerPerks.h create mode 100644 Code/encoding/Messages/PlayerPerksRequest.cpp create mode 100644 Code/encoding/Messages/PlayerPerksRequest.h create mode 100644 Code/encoding/Structs/PlayerPerk.cpp create mode 100644 Code/encoding/Structs/PlayerPerk.h diff --git a/Code/client/Games/Skyrim/Forms/BGSPerk.h b/Code/client/Games/Skyrim/Forms/BGSPerk.h new file mode 100644 index 0000000000..0f18accc57 --- /dev/null +++ b/Code/client/Games/Skyrim/Forms/BGSPerk.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include + +struct Actor; + +struct BGSPerkEntry +{ + enum Type : uint32_t + { + kQuest = 0, + kAbility = 1, + kEntryPoint = 2, + }; + + virtual bool CheckConditionFilters(uint32_t aArgCount, void* apArgs); + virtual uint32_t GetFunction(); + virtual void* GetFunctionData(); + virtual void sub_3(); + virtual Type GetType(); + + uint8_t rank; + uint8_t priority; +}; +static_assert(offsetof(BGSPerkEntry, rank) == 0x8); +static_assert(offsetof(BGSPerkEntry, priority) == 0x9); + +struct BGSEntryPointPerkEntry : BGSPerkEntry +{ + static constexpr uint32_t kEntryPointCount = 0x5C; + + uint8_t entryPoint; +}; +static_assert(offsetof(BGSEntryPointPerkEntry, entryPoint) == 0x10); + +struct BGSPerk : TESForm +{ + uint8_t pad20[0x60 - sizeof(TESForm)]; + GameArray perkEntries; +}; +static_assert(offsetof(BGSPerk, perkEntries) == 0x60); + +// Element of TESNPC::perkRanks. PlayerCharacter stores pointers to these for the perks a player earned. +struct PerkRankData +{ + BGSPerk* pPerk; + uint8_t rank; +}; +static_assert(sizeof(PerkRankData) == 0x10); + +// The engine's visitors for Actor::VisitPerks (vtable slot 0xFA) and Actor::ForEachPerkEntry (slot 0x100). +// Returning 1 means continue. +struct PerkRankVisitor +{ + virtual uint32_t Visit(PerkRankData* apPerkRank) = 0; +}; + +struct PerkEntryVisitor +{ + virtual uint32_t Visit(BGSPerkEntry* apEntry) = 0; +}; + +// The visitor that BGSEntryPoint::HandleEntryPoint passes to Actor::ForEachPerkEntry. +struct HandleEntryPointVisitor : PerkEntryVisitor +{ + uint32_t function; + void** ppArguments; // The perk owner, followed by the entry point's filter arguments + void* pResults; + Actor* pPerkOwner; + uint8_t argumentCount; + uint8_t resultCount; +}; +static_assert(offsetof(HandleEntryPointVisitor, ppArguments) == 0x10); +static_assert(offsetof(HandleEntryPointVisitor, argumentCount) == 0x28); diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index fe6a5ac5b8..0e98d08811 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -49,7 +50,18 @@ bool MagicTarget::AddTarget(AddTargetData& arData, bool aApplyHealPerkBonus, boo s_applyHealPerkBonus = aApplyHealPerkBonus; s_applyStaminaPerkBonus = aApplyStaminaPerkBonus; - bool result = TiltedPhoques::ThisCall(RealAddTarget, this, arData); + bool result = false; + if (arData.pSpell->IsHealingSpell() || arData.pSpell->IsBuffSpell()) + { + // The caster's perks are already part of what they send for these, see ApplyHealPerkBonus. + ScopedIgnoreRemotePerks ignoreRemotePerks; + result = TiltedPhoques::ThisCall(RealAddTarget, this, arData); + } + else + { + ScopedRemotePerks remotePerks; + result = TiltedPhoques::ThisCall(RealAddTarget, this, arData); + } s_autoSucceedEffectCheck = false; s_applyHealPerkBonus = false; @@ -153,6 +165,7 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar } } + ScopedRemotePerks remotePerks; bool result = TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); if (result && arData.ShouldSync()) World::Get().GetRunner().Trigger(addTargetEvent); diff --git a/Code/client/Games/Skyrim/RemotePerks.cpp b/Code/client/Games/Skyrim/RemotePerks.cpp new file mode 100644 index 0000000000..93bd2d392d --- /dev/null +++ b/Code/client/Games/Skyrim/RemotePerks.cpp @@ -0,0 +1,228 @@ +#include + +#include +#include +#include + +#include + +#include +#include + +namespace +{ +TP_THIS_FUNCTION(TVisitPerks, void, Actor, PerkRankVisitor* apVisitor); +TP_THIS_FUNCTION(THasPerkEntries, bool, Actor, uint8_t aEntryPoint); +TP_THIS_FUNCTION(TForEachPerkEntry, void, Actor, uint8_t aEntryPoint, PerkEntryVisitor* apVisitor); + +TVisitPerks* RealVisitPerks = nullptr; +THasPerkEntries* RealHasPerkEntries = nullptr; +TForEachPerkEntry* RealForEachPerkEntry = nullptr; + +// Hits and spells can resolve on engine worker threads, so readers take a reference to an immutable list. +std::shared_mutex s_perksLock; +RemotePerks::PerkMap s_perks; + +thread_local uint32_t s_scopeDepth = 0; +thread_local uint32_t s_ignoreDepth = 0; + +RemotePerks::PerkDataPtr GetRemotePlayerPerks(Actor* apActor) noexcept +{ + if (s_ignoreDepth > 0 || !apActor->GetExtension()->IsRemotePlayer()) + return nullptr; + + if (!World::Get().GetServerSettings().PvpEnabled) + return nullptr; + + std::shared_lock lock(s_perksLock); + + const auto it = s_perks.find(apActor->formID); + return it != s_perks.end() ? it->second : nullptr; +} + +// Entry points on the attacker's side of a hit take the target as a filter argument. Everything else, such as the +// attacker's stamina costs or weapon speed, is simulated by the client that owns the attacker. +bool TargetsLocalPlayer(PerkEntryVisitor* apVisitor) noexcept +{ + POINTER_SKYRIMSE(uint8_t, s_handleEntryPointVisitorVtable, 195172); + + if (*reinterpret_cast(apVisitor) != s_handleEntryPointVisitorVtable.Get()) + return false; + + const auto* pVisitor = static_cast(apVisitor); + const void* pPlayer = PlayerCharacter::Get(); + + for (uint32_t i = 1; i < pVisitor->argumentCount; ++i) + { + if (pVisitor->ppArguments[i] == pPlayer) + return true; + } + + return false; +} + +void TP_MAKE_THISCALL(HookVisitPerks, Actor, PerkRankVisitor* apVisitor) +{ + if (s_scopeDepth > 0) + { + if (const auto pData = GetRemotePlayerPerks(apThis)) + { + for (const PerkRankData& cPerk : pData->Perks) + { + if (apVisitor->Visit(const_cast(&cPerk)) != 1) + break; + } + return; + } + } + + TiltedPhoques::ThisCall(RealVisitPerks, apThis, apVisitor); +} + +bool TP_MAKE_THISCALL(HookHasPerkEntries, Actor, uint8_t aEntryPoint) +{ + if (aEntryPoint < BGSEntryPointPerkEntry::kEntryPointCount) + { + if (const auto pData = GetRemotePlayerPerks(apThis)) + return !pData->Entries[aEntryPoint].empty(); + } + + return TiltedPhoques::ThisCall(RealHasPerkEntries, apThis, aEntryPoint); +} + +void TP_MAKE_THISCALL(HookForEachPerkEntry, Actor, uint8_t aEntryPoint, PerkEntryVisitor* apVisitor) +{ + if (aEntryPoint < BGSEntryPointPerkEntry::kEntryPointCount) + { + if (const auto pData = GetRemotePlayerPerks(apThis)) + { + if (TargetsLocalPlayer(apVisitor)) + { + // Perk conditions can check the attacker's perks + ScopedRemotePerks remotePerks; + + for (BGSPerkEntry* pEntry : pData->Entries[aEntryPoint]) + { + if (apVisitor->Visit(pEntry) != 1) + break; + } + } + return; + } + } + + TiltedPhoques::ThisCall(RealForEachPerkEntry, apThis, aEntryPoint, apVisitor); +} +} // namespace + +ScopedRemotePerks::ScopedRemotePerks() noexcept +{ + ++s_scopeDepth; +} + +ScopedRemotePerks::~ScopedRemotePerks() noexcept +{ + --s_scopeDepth; +} + +ScopedIgnoreRemotePerks::ScopedIgnoreRemotePerks() noexcept +{ + ++s_ignoreDepth; +} + +ScopedIgnoreRemotePerks::~ScopedIgnoreRemotePerks() noexcept +{ + --s_ignoreDepth; +} + +namespace RemotePerks +{ +PerkDataPtr BuildPerkData(const TiltedPhoques::Vector& acPerks) noexcept +{ + auto pData = std::make_shared(); + auto& modSystem = World::Get().GetModSystem(); + + for (const PlayerPerk& cPerk : acPerks) + { + const uint32_t cPerkId = modSystem.GetGameId(cPerk.Id); + BGSPerk* pPerk = cPerkId ? Cast(TESForm::GetById(cPerkId)) : nullptr; + if (!pPerk) + { + spdlog::debug("{}: skipping unknown perk, GameId base: {:X}, mod: {:X}", __FUNCTION__, cPerk.Id.BaseId, cPerk.Id.ModId); + continue; + } + + pData->Perks.push_back(PerkRankData{pPerk, cPerk.Rank}); + + for (BGSPerkEntry* pEntry : pPerk->perkEntries) + { + // BGSPerk::ApplyPerk only applies the entries of the rank the perk is at. + if (!pEntry || pEntry->rank + 1 != cPerk.Rank || pEntry->GetType() != BGSPerkEntry::kEntryPoint) + continue; + + const uint8_t cEntryPoint = static_cast(pEntry)->entryPoint; + if (cEntryPoint >= BGSEntryPointPerkEntry::kEntryPointCount) + continue; + + // Same order as AIProcess::AddPerkEntry: highest priority first, then lowest rank. + auto& entries = pData->Entries[cEntryPoint]; + const auto cPosition = std::upper_bound(entries.begin(), entries.end(), pEntry, [](const BGSPerkEntry* apLhs, const BGSPerkEntry* apRhs) { + if (apLhs->priority != apRhs->priority) + return apLhs->priority > apRhs->priority; + return apLhs->rank < apRhs->rank; + }); + entries.insert(cPosition, pEntry); + } + } + + return pData; +} + +void SetPerks(PerkMap&& aPerks) noexcept +{ + std::unique_lock lock(s_perksLock); + s_perks.swap(aPerks); +} + +void CollectLocalPlayerPerks(TiltedPhoques::Vector& aPerks) noexcept +{ + struct Visitor final : PerkRankVisitor + { + explicit Visitor(TiltedPhoques::Vector& aPerks) noexcept + : Perks(aPerks) + { + } + + uint32_t Visit(PerkRankData* apPerkRank) override + { + PlayerPerk perk{}; + if (apPerkRank && apPerkRank->pPerk && apPerkRank->rank > 0 && World::Get().GetModSystem().GetServerModId(apPerkRank->pPerk->formID, perk.Id)) + { + perk.Rank = apPerkRank->rank; + Perks.push_back(perk); + } + return 1; + } + + TiltedPhoques::Vector& Perks; + } visitor(aPerks); + + // PlayerCharacter::VisitPerks: the base form's perks, then the earned ones. + POINTER_SKYRIMSE(TVisitPerks, s_visitPlayerPerks, 40680); + TiltedPhoques::ThisCall(s_visitPlayerPerks.Get(), static_cast(PlayerCharacter::Get()), static_cast(&visitor)); +} +} // namespace RemotePerks + +static TiltedPhoques::Initializer s_remotePerkHooks([]() { + POINTER_SKYRIMSE(TVisitPerks, visitPerks, 37693); + POINTER_SKYRIMSE(THasPerkEntries, hasPerkEntries, 37701); + POINTER_SKYRIMSE(TForEachPerkEntry, forEachPerkEntry, 37702); + + RealVisitPerks = visitPerks.Get(); + RealHasPerkEntries = hasPerkEntries.Get(); + RealForEachPerkEntry = forEachPerkEntry.Get(); + + TP_HOOK(&RealVisitPerks, HookVisitPerks); + TP_HOOK(&RealHasPerkEntries, HookHasPerkEntries); + TP_HOOK(&RealForEachPerkEntry, HookForEachPerkEntry); +}); diff --git a/Code/client/Games/Skyrim/RemotePerks.h b/Code/client/Games/Skyrim/RemotePerks.h new file mode 100644 index 0000000000..2c655f60a9 --- /dev/null +++ b/Code/client/Games/Skyrim/RemotePerks.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include +#include +#include + +/** + * Remote player copies have none of the perks their player earned: Character::VisitPerks only reads the TESNPC, those + * perks are stored on PlayerCharacter. When a remote player hits or casts on the local player, this client runs the + * attacker side of the hit against that copy, so the attacker's perks have to come from here. + * The perks are never added to the copy, since AddPerk would also run the perk's abilities on it. + */ +namespace RemotePerks +{ +struct PerkData +{ + std::vector Perks; + // Sorted the same way as AIProcess's perk entry arrays + std::vector Entries[BGSEntryPointPerkEntry::kEntryPointCount]; +}; + +using PerkDataPtr = std::shared_ptr; +using PerkMap = std::unordered_map; + +// Perks that aren't in the local player's load order are skipped. +PerkDataPtr BuildPerkData(const TiltedPhoques::Vector& acPerks) noexcept; +// Replaces the remote players' perks, keyed by the form id of their copy. +void SetPerks(PerkMap&& aPerks) noexcept; +// The local player's perks, those on the base form included. +void CollectLocalPlayerPerks(TiltedPhoques::Vector& aPerks) noexcept; +} // namespace RemotePerks + +// While alive, HasPerk and GetPerkRank on remote players answer from their perk list on this thread. +struct ScopedRemotePerks +{ + ScopedRemotePerks() noexcept; + ~ScopedRemotePerks() noexcept; + + TP_NOCOPYMOVE(ScopedRemotePerks); +}; + +// While alive, remote players' perk lists are ignored on this thread. +struct ScopedIgnoreRemotePerks +{ + ScopedIgnoreRemotePerks() noexcept; + ~ScopedIgnoreRemotePerks() noexcept; + + TP_NOCOPYMOVE(ScopedIgnoreRemotePerks); +}; diff --git a/Code/client/Services/Generic/PlayerService.cpp b/Code/client/Services/Generic/PlayerService.cpp index 6ced4d55b4..a4e31ab752 100644 --- a/Code/client/Services/Generic/PlayerService.cpp +++ b/Code/client/Services/Generic/PlayerService.cpp @@ -20,9 +20,15 @@ #include #include #include +#include +#include +#include +#include #include +#include + #include #include #include @@ -48,6 +54,9 @@ PlayerService::PlayerService(World& aWorld, entt::dispatcher& aDispatcher, Trans m_playerLevelConnection = m_dispatcher.sink().connect<&PlayerService::OnPlayerLevelEvent>(this); m_partyJoinedConnection = aDispatcher.sink().connect<&PlayerService::OnPartyJoinedEvent>(this); m_partyLeftConnection = aDispatcher.sink().connect<&PlayerService::OnPartyLeftEvent>(this); + m_notifyPlayerPerksConnection = aDispatcher.sink().connect<&PlayerService::OnNotifyPlayerPerks>(this); + m_playerJoinedConnection = aDispatcher.sink().connect<&PlayerService::OnNotifyPlayerJoined>(this); + m_playerLeftConnection = aDispatcher.sink().connect<&PlayerService::OnNotifyPlayerLeft>(this); } void PlayerService::OnUpdate(const UpdateEvent&) noexcept @@ -57,6 +66,7 @@ void PlayerService::OnUpdate(const UpdateEvent&) noexcept RunDifficultyUpdates(); RunLevelUpdates(); RunBeastFormDetection(); + RunPerkUpdates(); } void PlayerService::OnConnected(const ConnectedEvent& acEvent) noexcept @@ -85,6 +95,10 @@ void PlayerService::OnDisconnected(const DisconnectedEvent& acEvent) noexcept TESGlobal* pWorldEncountersEnabled = Cast(TESForm::GetById(0xB8EC1)); pWorldEncountersEnabled->f = 1.f; + + m_arePerksSent = false; + m_remotePerks.clear(); + RemotePerks::SetPerks({}); } void PlayerService::OnServerSettingsReceived(const ServerSettings& acSettings) noexcept @@ -173,6 +187,22 @@ void PlayerService::OnPlayerLevelEvent(const PlayerLevelEvent& acEvent) const no m_transport.Send(request); } +void PlayerService::OnNotifyPlayerPerks(const NotifyPlayerPerks& acMessage) noexcept +{ + m_remotePerks[acMessage.PlayerId] = RemotePerks::BuildPerkData(acMessage.Perks); +} + +void PlayerService::OnNotifyPlayerJoined(const NotifyPlayerJoined& acMessage) noexcept +{ + // The server doesn't keep perk lists around, so the new player gets them from the other players. + m_arePerksSent = false; +} + +void PlayerService::OnNotifyPlayerLeft(const NotifyPlayerLeft& acMessage) noexcept +{ + m_remotePerks.erase(acMessage.PlayerId); +} + void PlayerService::OnPartyJoinedEvent(const PartyJoinedEvent& acEvent) noexcept { // TODO: this can be done a bit prettier @@ -318,6 +348,55 @@ void PlayerService::RunLevelUpdates() const noexcept } } +void PlayerService::RunPerkUpdates() noexcept +{ + static std::chrono::steady_clock::time_point lastUpdateTimePoint; + constexpr auto cDelayBetweenUpdates = 1000ms; + + const auto now = std::chrono::steady_clock::now(); + if (now - lastUpdateTimePoint < cDelayBetweenUpdates) + return; + + lastUpdateTimePoint = now; + + if (!m_transport.IsConnected()) + return; + + if (!m_world.GetServerSettings().PvpEnabled) + { + // Perks are sent again if PvP gets enabled later on + m_arePerksSent = false; + m_remotePerks.clear(); + RemotePerks::SetPerks({}); + return; + } + + PlayerPerksRequest request{}; + RemotePerks::CollectLocalPlayerPerks(request.Perks); + + std::vector> perks; + perks.reserve(request.Perks.size()); + for (const PlayerPerk& cPerk : request.Perks) + perks.emplace_back(cPerk.Id, cPerk.Rank); + + if ((!m_arePerksSent || perks != m_sentPerks) && m_transport.Send(request)) + { + m_sentPerks = std::move(perks); + m_arePerksSent = true; + } + + // Copies get a new form id when they respawn, so look them up every time + RemotePerks::PerkMap formPerks; + auto view = m_world.view(); + for (const auto entity : view) + { + const auto it = m_remotePerks.find(view.get(entity).Id); + if (it != m_remotePerks.end()) + formPerks[view.get(entity).Id] = it->second; + } + RemotePerks::SetPerks(std::move(formPerks)); +} + void PlayerService::RunBeastFormDetection() const noexcept { static uint32_t lastRaceFormID = 0; diff --git a/Code/client/Services/PlayerService.h b/Code/client/Services/PlayerService.h index 8ed51a823d..330d62f227 100644 --- a/Code/client/Services/PlayerService.h +++ b/Code/client/Services/PlayerService.h @@ -1,5 +1,7 @@ #pragma once +#include + struct World; struct TransportService; @@ -15,6 +17,9 @@ struct PartyJoinedEvent; struct PartyLeftEvent; struct NotifyPlayerRespawn; +struct NotifyPlayerPerks; +struct NotifyPlayerJoined; +struct NotifyPlayerLeft; /** * @brief Handles logic related to the local player. @@ -38,6 +43,9 @@ struct PlayerService void OnPlayerLevelEvent(const PlayerLevelEvent& acEvent) const noexcept; void OnPartyJoinedEvent(const PartyJoinedEvent& acEvent) noexcept; void OnPartyLeftEvent(const PartyLeftEvent& acEvent) noexcept; + void OnNotifyPlayerPerks(const NotifyPlayerPerks& acMessage) noexcept; + void OnNotifyPlayerJoined(const NotifyPlayerJoined& acMessage) noexcept; + void OnNotifyPlayerLeft(const NotifyPlayerLeft& acMessage) noexcept; private: /** @@ -51,6 +59,10 @@ struct PlayerService void RunDifficultyUpdates() const noexcept; void RunLevelUpdates() const noexcept; void RunBeastFormDetection() const noexcept; + /** + * @brief Share the local player's perks when they change, and tie the remote players' perks to their copies. + */ + void RunPerkUpdates() noexcept; void ToggleDeathSystem(bool aSet) noexcept; @@ -74,6 +86,10 @@ struct PlayerService uint32_t m_cachedSecondarySpellId = 0; uint32_t m_cachedPowerId = 0; + std::vector> m_sentPerks; + bool m_arePerksSent = false; + std::unordered_map m_remotePerks; + entt::scoped_connection m_updateConnection; entt::scoped_connection m_connectedConnection; entt::scoped_connection m_disconnectedConnection; @@ -85,4 +101,7 @@ struct PlayerService entt::scoped_connection m_playerLevelConnection; entt::scoped_connection m_partyJoinedConnection; entt::scoped_connection m_partyLeftConnection; + entt::scoped_connection m_notifyPlayerPerksConnection; + entt::scoped_connection m_playerJoinedConnection; + entt::scoped_connection m_playerLeftConnection; }; diff --git a/Code/encoding/Messages/ClientMessageFactory.h b/Code/encoding/Messages/ClientMessageFactory.h index 4f94d6d10e..65ab4267bc 100644 --- a/Code/encoding/Messages/ClientMessageFactory.h +++ b/Code/encoding/Messages/ClientMessageFactory.h @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -70,7 +71,7 @@ struct ClientMessageFactory PartyChangeLeaderRequest, PartyKickRequest, RequestActorValueChanges, RequestActorMaxValueChanges, EnterExteriorCellRequest, RequestHealthChangeBroadcast, ActivateRequest, LockChangeRequest, AssignObjectsRequest, RequestDeathStateChange, ShiftGridCellRequest, RequestOwnershipTransfer, RequestOwnershipClaim, RequestObjectInventoryChanges, SpellCastRequest, ProjectileLaunchRequest, InterruptCastRequest, AddTargetRequest, ScriptAnimationRequest, DrawWeaponRequest, MountRequest, NewPackageRequest, RequestRespawn, SyncExperienceRequest, RequestEquipmentChanges, SendChatMessageRequest, TeleportCommandRequest, PlayerRespawnRequest, DialogueRequest, SubtitleRequest, PlayerDialogueRequest, PlayerLevelRequest, TeleportRequest, RequestPlayerHealthUpdate, RequestWeatherChange, RequestCurrentWeather, RequestSetWaypoint, - RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents>; + RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents, PlayerPerksRequest>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Messages/NotifyPlayerPerks.cpp b/Code/encoding/Messages/NotifyPlayerPerks.cpp new file mode 100644 index 0000000000..f9b3a2ebfb --- /dev/null +++ b/Code/encoding/Messages/NotifyPlayerPerks.cpp @@ -0,0 +1,23 @@ +#include + +#include + +void NotifyPlayerPerks::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, PlayerId); + Serialization::WriteVarInt(aWriter, Perks.size()); + + for (const auto& perk : Perks) + perk.Serialize(aWriter); +} + +void NotifyPlayerPerks::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + PlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + Perks.resize(Serialization::ReadVarInt(aReader)); + + for (auto& perk : Perks) + perk.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/NotifyPlayerPerks.h b/Code/encoding/Messages/NotifyPlayerPerks.h new file mode 100644 index 0000000000..877e2f5a59 --- /dev/null +++ b/Code/encoding/Messages/NotifyPlayerPerks.h @@ -0,0 +1,25 @@ +#pragma once + +#include "Message.h" + +#include + +using TiltedPhoques::Vector; + +struct NotifyPlayerPerks final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyPlayerPerks; + + NotifyPlayerPerks() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyPlayerPerks& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && PlayerId == acRhs.PlayerId && Perks == acRhs.Perks; } + + uint32_t PlayerId{}; + Vector Perks{}; +}; diff --git a/Code/encoding/Messages/PlayerPerksRequest.cpp b/Code/encoding/Messages/PlayerPerksRequest.cpp new file mode 100644 index 0000000000..a1bea97f30 --- /dev/null +++ b/Code/encoding/Messages/PlayerPerksRequest.cpp @@ -0,0 +1,21 @@ +#include + +#include + +void PlayerPerksRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, Perks.size()); + + for (const auto& perk : Perks) + perk.Serialize(aWriter); +} + +void PlayerPerksRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + Perks.resize(Serialization::ReadVarInt(aReader)); + + for (auto& perk : Perks) + perk.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/PlayerPerksRequest.h b/Code/encoding/Messages/PlayerPerksRequest.h new file mode 100644 index 0000000000..8e342ff2c3 --- /dev/null +++ b/Code/encoding/Messages/PlayerPerksRequest.h @@ -0,0 +1,24 @@ +#pragma once + +#include "Message.h" + +#include + +using TiltedPhoques::Vector; + +struct PlayerPerksRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kPlayerPerksRequest; + + PlayerPerksRequest() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const PlayerPerksRequest& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && Perks == acRhs.Perks; } + + Vector Perks{}; +}; diff --git a/Code/encoding/Messages/ServerMessageFactory.h b/Code/encoding/Messages/ServerMessageFactory.h index 516878b1d9..3d5d8356d4 100644 --- a/Code/encoding/Messages/ServerMessageFactory.h +++ b/Code/encoding/Messages/ServerMessageFactory.h @@ -50,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -73,7 +74,7 @@ struct ServerMessageFactory NotifyActorValueChanges, NotifyPartyJoined, NotifyPartyLeft, NotifyActorMaxValueChanges, NotifyHealthChangeBroadcast, NotifyActivate, NotifyLockChange, AssignObjectsResponse, NotifyDeathStateChange, NotifyOwnershipTransfer, NotifyObjectInventoryChanges, NotifySpellCast, NotifyProjectileLaunch, NotifyInterruptCast, NotifyAddTarget, NotifyScriptAnimation, NotifyDrawWeapon, NotifyMount, NotifyNewPackage, NotifyRespawn, NotifySyncExperience, NotifyEquipmentChanges, NotifyChatMessageBroadcast, TeleportCommandResponse, NotifyPlayerRespawn, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyActorTeleport, NotifyPlayerLeft, NotifyPlayerJoined, NotifyDialogue, NotifySubtitle, NotifyPlayerDialogue, NotifyPlayerLevel, NotifyPlayerCellChanged, NotifyTeleport, NotifyPlayerHealthUpdate, NotifySettingsChange, - NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents>; + NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents, NotifyPlayerPerks>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Opcodes.h b/Code/encoding/Opcodes.h index baab493ccb..881da737f4 100644 --- a/Code/encoding/Opcodes.h +++ b/Code/encoding/Opcodes.h @@ -54,6 +54,7 @@ enum ClientOpcode : unsigned char kRequestRemoveWaypoint, kSetTimeCommandRequest, kRequestActionEvents, + kPlayerPerksRequest, kClientOpcodeMax }; @@ -114,5 +115,6 @@ enum ServerOpcode : unsigned char kNotifyRemoveWaypoint, kNotifySetTimeResult, kNotifyActionEvents, + kNotifyPlayerPerks, kServerOpcodeMax }; diff --git a/Code/encoding/Structs/PlayerPerk.cpp b/Code/encoding/Structs/PlayerPerk.cpp new file mode 100644 index 0000000000..abed517a2a --- /dev/null +++ b/Code/encoding/Structs/PlayerPerk.cpp @@ -0,0 +1,20 @@ +#include + +#include + +using TiltedPhoques::Serialization; + +void PlayerPerk::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Id.Serialize(aWriter); + aWriter.WriteBits(Rank, 8); +} + +void PlayerPerk::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + Id.Deserialize(aReader); + + uint64_t rank = 0; + aReader.ReadBits(rank, 8); + Rank = rank & 0xFF; +} diff --git a/Code/encoding/Structs/PlayerPerk.h b/Code/encoding/Structs/PlayerPerk.h new file mode 100644 index 0000000000..e8d418e435 --- /dev/null +++ b/Code/encoding/Structs/PlayerPerk.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +/** + * @brief A perk a player has, and the rank they have it at. + */ +struct PlayerPerk +{ + bool operator==(const PlayerPerk& acRhs) const noexcept { return Id == acRhs.Id && Rank == acRhs.Rank; } + bool operator!=(const PlayerPerk& acRhs) const noexcept { return !operator==(acRhs); } + + void Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept; + void Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept; + + GameId Id{}; + uint8_t Rank{}; +}; diff --git a/Code/server/Services/PlayerService.cpp b/Code/server/Services/PlayerService.cpp index e887a6ae28..a020ecab99 100644 --- a/Code/server/Services/PlayerService.cpp +++ b/Code/server/Services/PlayerService.cpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include #include @@ -31,6 +33,7 @@ PlayerService::PlayerService(World& aWorld, entt::dispatcher& aDispatcher) noexc , m_exteriorCellEnterConnection(aDispatcher.sink>().connect<&PlayerService::HandleExteriorCellEnter>(this)) , m_playerRespawnConnection(aDispatcher.sink>().connect<&PlayerService::OnPlayerRespawnRequest>(this)) , m_playerLevelConnection(aDispatcher.sink>().connect<&PlayerService::OnPlayerLevelRequest>(this)) + , m_playerPerksConnection(aDispatcher.sink>().connect<&PlayerService::OnPlayerPerksRequest>(this)) { } @@ -215,3 +218,12 @@ void PlayerService::OnPlayerLevelRequest(const PacketEvent& GameServer::Get()->SendToPlayers(notify, acMessage.pPlayer); } + +void PlayerService::OnPlayerPerksRequest(const PacketEvent& acMessage) const noexcept +{ + NotifyPlayerPerks notify{}; + notify.PlayerId = acMessage.pPlayer->GetId(); + notify.Perks = acMessage.Packet.Perks; + + GameServer::Get()->SendToPlayers(notify, acMessage.pPlayer); +} diff --git a/Code/server/Services/PlayerService.h b/Code/server/Services/PlayerService.h index 9ac7351762..ba4c179267 100644 --- a/Code/server/Services/PlayerService.h +++ b/Code/server/Services/PlayerService.h @@ -8,6 +8,7 @@ struct EnterInteriorCellRequest; struct EnterExteriorCellRequest; struct PlayerRespawnRequest; struct PlayerLevelRequest; +struct PlayerPerksRequest; /** * @brief Handles player specific actions that might change the information needed by other clients about that player. @@ -25,6 +26,7 @@ struct PlayerService void HandleInteriorCellEnter(const PacketEvent& acMessage) const noexcept; void OnPlayerRespawnRequest(const PacketEvent& acMessage) const noexcept; void OnPlayerLevelRequest(const PacketEvent& acMessage) const noexcept; + void OnPlayerPerksRequest(const PacketEvent& acMessage) const noexcept; private: World& m_world; @@ -34,4 +36,5 @@ struct PlayerService entt::scoped_connection m_interiorCellEnterConnection; entt::scoped_connection m_playerRespawnConnection; entt::scoped_connection m_playerLevelConnection; + entt::scoped_connection m_playerPerksConnection; }; diff --git a/Code/tests/encoding.cpp b/Code/tests/encoding.cpp index defb808418..248fd672bf 100644 --- a/Code/tests/encoding.cpp +++ b/Code/tests/encoding.cpp @@ -63,6 +63,47 @@ TEST_CASE("Encoding factory", "[encoding.factory]") auto pRequest = CastUnique(std::move(pMessage)); REQUIRE(pRequest->InviterId == request.InviterId); } + + { + PlayerPerksRequest request; + request.Perks.push_back(PlayerPerk{GameId(1, 0x581f8), 1}); + request.Perks.push_back(PlayerPerk{GameId(0xFE012, 0x805), 3}); + + Buffer::Writer writer(&buff); + request.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ClientMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == request.GetOpcode()); + + auto pRequest = CastUnique(std::move(pMessage)); + REQUIRE(pRequest->Perks == request.Perks); + } + + { + NotifyPlayerPerks notify; + notify.PlayerId = 1234; + notify.Perks.push_back(PlayerPerk{GameId(1, 0x581f9), 2}); + + Buffer::Writer writer(&buff); + notify.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ServerMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == notify.GetOpcode()); + + auto pNotify = CastUnique(std::move(pMessage)); + REQUIRE(pNotify->PlayerId == notify.PlayerId); + REQUIRE(pNotify->Perks == notify.Perks); + } } TEST_CASE("Static structures", "[encoding.static]") From d22821a7f19b472f47bc9419e5f52df0b78d6306 Mon Sep 17 00:00:00 2001 From: absol89 Date: Wed, 4 Nov 2026 21:50:43 +0100 Subject: [PATCH 71/97] Make poisons apply to other players in PvP The attacker's client now tells the victim's client when a poisoned hit lands, since it owns the charges. The victim drops the poison from its copy of the attacker, whose ExtraPoison can be out of date, so a poison lands once. Needs an in-game test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Games/Skyrim/Forms/MagicItem.cpp | 6 ++++++ Code/client/Games/Skyrim/Forms/MagicItem.h | 1 + Code/client/Games/Skyrim/Magic/MagicTarget.cpp | 15 ++++++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Code/client/Games/Skyrim/Forms/MagicItem.cpp b/Code/client/Games/Skyrim/Forms/MagicItem.cpp index d10f7c6bed..e7a7e0e862 100644 --- a/Code/client/Games/Skyrim/Forms/MagicItem.cpp +++ b/Code/client/Games/Skyrim/Forms/MagicItem.cpp @@ -88,6 +88,12 @@ bool MagicItem::IsBuffSpell() const noexcept } } +bool MagicItem::IsPoison() const noexcept +{ + // AlchemyItem's spell type is always POTION, whether it's a poison or not. + return formType == FormType::Alchemy && iHostileCount > 0; +} + bool MagicItem::IsBoundWeaponSpell() noexcept { for (EffectItem* pEffect : listOfEffects) diff --git a/Code/client/Games/Skyrim/Forms/MagicItem.h b/Code/client/Games/Skyrim/Forms/MagicItem.h index bd2bf95e22..6cd71536e3 100644 --- a/Code/client/Games/Skyrim/Forms/MagicItem.h +++ b/Code/client/Games/Skyrim/Forms/MagicItem.h @@ -13,6 +13,7 @@ struct MagicItem : TESBoundObject bool IsInvisibilitySpell() const noexcept; bool IsHealingSpell() const noexcept; bool IsBuffSpell() const noexcept; + bool IsPoison() const noexcept; bool IsBoundWeaponSpell() noexcept; bool HasSummonEffect() const noexcept; diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index fe6a5ac5b8..7c46fb01b4 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -45,7 +45,8 @@ void MagicTarget::DispelAllSpells(bool aNow) noexcept bool MagicTarget::AddTarget(AddTargetData& arData, bool aApplyHealPerkBonus, bool aApplyStaminaPerkBonus) noexcept { - s_autoSucceedEffectCheck = true; + // Poisons come from the attacker's hit, so the victim's conditions still get to refuse them. + s_autoSucceedEffectCheck = !arData.pSpell->IsPoison(); s_applyHealPerkBonus = aApplyHealPerkBonus; s_applyStaminaPerkBonus = aApplyStaminaPerkBonus; @@ -118,6 +119,14 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (!arData.pCaster) return false; + // The charge of a poison belongs to the attacker's weapon, so this client decides that it lands and tells the + // victim's client. It isn't applied to the copy here, its health comes from the victim's client anyway. + if (arData.pSpell->IsPoison() && World::Get().GetServerSettings().PvpEnabled && arData.pCaster->GetExtension()->IsLocalPlayer()) + { + World::Get().GetRunner().Trigger(addTargetEvent); + return false; + } + if (!arData.pSpell->IsHealingSpell() && !arData.pSpell->IsBuffSpell()) return false; @@ -150,6 +159,10 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar // Heal and buff spells are already synced by the caster. if (arData.pSpell->IsHealingSpell() || arData.pSpell->IsBuffSpell()) return false; + + // Poisons are synced by the attacker. The copy's ExtraPoison and its charges can be out of date. + if (arData.pSpell->IsPoison()) + return false; } } From c09cfa780efd0330904de93cacef5b2f1a960686 Mon Sep 17 00:00:00 2001 From: absol89 Date: Wed, 4 Nov 2026 21:55:02 +0100 Subject: [PATCH 72/97] Show other players with Detect Life and keep it to the caster A remote player's Detect Life replayed on every client and synced to nearby players, so everyone saw living targets. It now only applies on the caster's own client. With PvP on, the caster's Detect Life also shows other players, which HookAddTarget used to refuse. Needs an in-game test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Games/Skyrim/Magic/EffectItem.cpp | 5 +++++ Code/client/Games/Skyrim/Magic/EffectItem.h | 1 + Code/client/Games/Skyrim/Magic/MagicTarget.cpp | 16 ++++++++++++++++ 3 files changed, 22 insertions(+) diff --git a/Code/client/Games/Skyrim/Magic/EffectItem.cpp b/Code/client/Games/Skyrim/Magic/EffectItem.cpp index e74998c46c..0cbb06156c 100644 --- a/Code/client/Games/Skyrim/Magic/EffectItem.cpp +++ b/Code/client/Games/Skyrim/Magic/EffectItem.cpp @@ -33,6 +33,11 @@ bool EffectItem::IsVampireLordEffect() const noexcept return pEffectSetting->eArchetype == EffectArchetypes::ArchetypeID::kVampireLord; } +bool EffectItem::IsDetectLifeEffect() const noexcept +{ + return pEffectSetting->eArchetype == EffectArchetypes::ArchetypeID::kDetectLife; +} + bool EffectItem::IsNightVisionEffect() const noexcept { BGSKeyword* pMagicNightEye = Cast(TESForm::GetById(0xad7c6)); diff --git a/Code/client/Games/Skyrim/Magic/EffectItem.h b/Code/client/Games/Skyrim/Magic/EffectItem.h index c4ee9a5d4f..9c673828f7 100644 --- a/Code/client/Games/Skyrim/Magic/EffectItem.h +++ b/Code/client/Games/Skyrim/Magic/EffectItem.h @@ -20,6 +20,7 @@ struct EffectItem bool IsWerewolfEffect() const noexcept; bool IsVampireLordEffect() const noexcept; bool IsNightVisionEffect() const noexcept; + bool IsDetectLifeEffect() const noexcept; EffectItemData data; uint32_t padC; diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index fe6a5ac5b8..3151a7ad77 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -60,6 +60,10 @@ bool MagicTarget::AddTarget(AddTargetData& arData, bool aApplyHealPerkBonus, boo bool MagicTarget::AddTargetData::ShouldSync() { + // Detect life is an area effect on every actor around the caster, syncing it would show them to other players too. + if (pEffectItem->IsDetectLifeEffect()) + return false; + // Constant effect slow time is an ability each client's scripts add and remove on their own player, so a synced // copy would stay on the other players for good. Fire and forget slow time (shouts) still syncs. if (pEffectItem->IsSlowEffect()) @@ -102,6 +106,18 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (arData.pEffectItem->IsVampireLordEffect()) pTargetActorEx->GraphDescriptorHash = AnimationGraphDescriptor_VampireLordBehavior::m_key; + if (arData.pEffectItem->IsDetectLifeEffect() && arData.pCaster) + { + const ActorExtension* pCasterExtension = arData.pCaster->GetExtension(); + + // The caster's copy repeats the cast on this client, but only the caster gets to see living targets. + if (pCasterExtension->IsRemotePlayer()) + return false; + + if (pCasterExtension->IsLocalPlayer() && pTargetActorEx->IsRemotePlayer() && World::Get().GetServerSettings().PvpEnabled) + return TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); + } + if (ScopedSpellCastOverride::IsOverriden()) return TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); From f9df222cb9cdcb844581d93717c31d94a5602fb5 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 02:40:37 +0100 Subject: [PATCH 73/97] Make Disarm work on other players in PvP The engine won't disarm essential actors and every player is essential, so the shout and the perks silently did nothing. Lift that check only for the local player being disarmed by another player. Perk disarms are sent to the victim's client since they only run on the attacker's, and the weapon is only dropped on the victim's client so it can't be duplicated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Games/Skyrim/Actor.cpp | 76 ++++++++++++++++++- Code/client/Games/Skyrim/Actor.h | 2 + Code/client/Games/Skyrim/Magic/EffectItem.cpp | 5 ++ Code/client/Games/Skyrim/Magic/EffectItem.h | 1 + .../client/Games/Skyrim/Magic/MagicTarget.cpp | 19 ++++- 5 files changed, 101 insertions(+), 2 deletions(-) diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 9e1933e862..a9037b8d81 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -129,6 +129,10 @@ TP_THIS_FUNCTION(TCharacterDestructor, Actor*, Actor); TP_THIS_FUNCTION(TAddInventoryItem, void, Actor, TESBoundObject* apItem, ExtraDataList* apExtraData, int32_t aCount, TESObjectREFR* apOldOwner); TP_THIS_FUNCTION(TPickUpObject, void*, Actor, TESObjectREFR* apObject, int32_t aCount, bool aUnk1, float aUnk2); TP_THIS_FUNCTION(TDropObject, void*, Actor, void* apResult, TESBoundObject* apObject, ExtraDataList* apExtraData, int32_t aCount, NiPoint3* apLocation, NiPoint3* apRotation); +TP_THIS_FUNCTION(TGetDisarmableWeapon, void*, Actor); +TP_THIS_FUNCTION(TDisarm, bool, Actor, Actor* apCaster); +TP_THIS_FUNCTION(TDisarmEffectStart, void, ActiveEffect); +using TDisarmEffectCheck = bool(void** apArgs); TP_THIS_FUNCTION(TSetPosition, char, Actor, NiPoint3& acPosition); TP_THIS_FUNCTION(TActorProcess, char, Actor, float aValue); @@ -143,6 +147,10 @@ static TRemoveSpell* RealRemoveSpell = nullptr; static TAddInventoryItem* RealAddInventoryItem = nullptr; static TPickUpObject* RealPickUpObject = nullptr; static TDropObject* RealDropObject = nullptr; +static TGetDisarmableWeapon* RealGetDisarmableWeapon = nullptr; +static TDisarm* RealDisarm = nullptr; +static TDisarmEffectStart* RealDisarmEffectStart = nullptr; +static TDisarmEffectCheck* RealDisarmEffectCheck = nullptr; static TSetPosition* RealSetPosition = nullptr; static TActorProcess* RealActorProcess = nullptr; @@ -547,6 +555,12 @@ bool Actor::IsPlayerSummon() const noexcept return pCommandingActor && pCommandingActor->formID == 0x14; } +bool Actor::IsPvpDisarmedBy(Actor* apCaster) noexcept +{ + return apCaster && GetExtension()->IsLocalPlayer() && apCaster->GetExtension()->IsRemotePlayer() + && World::Get().GetServerSettings().PvpEnabled; +} + TESForm* Actor::GetCurrentLocation() { // we use the safe function which also @@ -1107,6 +1121,52 @@ void Actor::PickUpObject(TESObjectREFR* apObject, int32_t aCount, bool aUnk1, fl TiltedPhoques::ThisCall(RealPickUpObject, this, apObject, aCount, aUnk1, aUnk2); } +// The engine refuses to disarm essential actors, and every player is essential because of +// SetPlayerRespawnMode. That has to be lifted while the engine looks for the victim's weapon, in the +// three places that do so: the effect's add check, its start and the task that does the actual drop. +static thread_local bool s_allowEssentialDisarm = false; +static thread_local bool s_isPvpDisarming = false; + +void* TP_MAKE_THISCALL(HookGetDisarmableWeapon, Actor) +{ + if (!s_allowEssentialDisarm || !apThis->IsEssential()) + return TiltedPhoques::ThisCall(RealGetDisarmableWeapon, apThis); + + apThis->SetEssential(false); + void* pResult = TiltedPhoques::ThisCall(RealGetDisarmableWeapon, apThis); + apThis->SetEssential(true); + return pResult; +} + +bool HookDisarmEffectCheck(void** apArgs) +{ + auto* pTarget = static_cast(apArgs[0]); + Actor* pTargetActor = pTarget ? pTarget->GetTargetAsActor() : nullptr; + + s_allowEssentialDisarm = pTargetActor && pTargetActor->IsPvpDisarmedBy(static_cast(apArgs[1])); + const bool cResult = RealDisarmEffectCheck(apArgs); + s_allowEssentialDisarm = false; + return cResult; +} + +void TP_MAKE_THISCALL(HookDisarmEffectStart, ActiveEffect) +{ + Actor* pTargetActor = apThis->pTarget ? apThis->pTarget->GetTargetAsActor() : nullptr; + Actor* pCaster = Cast(TESObjectREFR::GetByHandle(apThis->hCaster)); + + s_allowEssentialDisarm = pTargetActor && pTargetActor->IsPvpDisarmedBy(pCaster); + TiltedPhoques::ThisCall(RealDisarmEffectStart, apThis); + s_allowEssentialDisarm = false; +} + +bool TP_MAKE_THISCALL(HookDisarm, Actor, Actor* apCaster) +{ + s_allowEssentialDisarm = s_isPvpDisarming = apThis->IsPvpDisarmedBy(apCaster); + const bool cResult = TiltedPhoques::ThisCall(RealDisarm, apThis, apCaster); + s_allowEssentialDisarm = s_isPvpDisarming = false; + return cResult; +} + void* TP_MAKE_THISCALL(HookDropObject, Actor, void* apResult, TESBoundObject* apObject, ExtraDataList* apExtraData, int32_t aCount, NiPoint3* apLocation, NiPoint3* apRotation) { auto& modSystem = World::Get().GetModSystem(); @@ -1118,7 +1178,9 @@ void* TP_MAKE_THISCALL(HookDropObject, Actor, void* apResult, TESBoundObject* ap if (apExtraData) apThis->GetItemFromExtraData(item, apExtraData); - QueueActorInventoryChange(apThis, InventoryChangeEvent(apThis->formID, std::move(item), true)); + // A disarmed weapon only exists on the victim's client, so only they can pick it back up. If the + // other clients dropped their own copy too, whoever picked that up would duplicate the weapon. + QueueActorInventoryChange(apThis, InventoryChangeEvent(apThis->formID, std::move(item), !s_isPvpDisarming)); ScopedInventoryOverride _; @@ -1303,6 +1365,10 @@ static TiltedPhoques::Initializer s_actorHooks( POINTER_SKYRIMSE(TAddInventoryItem, s_addInventoryItem, 37525); POINTER_SKYRIMSE(TPickUpObject, s_pickUpObject, 37521); POINTER_SKYRIMSE(TDropObject, s_dropObject, 40454); + POINTER_SKYRIMSE(TGetDisarmableWeapon, s_getDisarmableWeapon, 37523); + POINTER_SKYRIMSE(TDisarm, s_disarm, 37524); + POINTER_SKYRIMSE(TDisarmEffectStart, s_disarmEffectStart, 34286); + POINTER_SKYRIMSE(TDisarmEffectCheck, s_disarmEffectCheck, 34289); POINTER_SKYRIMSE(TUpdateDetectionState, s_updateDetectionState, 42704); POINTER_SKYRIMSE(TProcessResponse, s_processResponse, 39643); POINTER_SKYRIMSE(TInitiateMountPackage, s_initiateMountPackage, 37905); @@ -1325,6 +1391,10 @@ static TiltedPhoques::Initializer s_actorHooks( RealAddInventoryItem = s_addInventoryItem.Get(); RealPickUpObject = s_pickUpObject.Get(); RealDropObject = s_dropObject.Get(); + RealGetDisarmableWeapon = s_getDisarmableWeapon.Get(); + RealDisarm = s_disarm.Get(); + RealDisarmEffectStart = s_disarmEffectStart.Get(); + RealDisarmEffectCheck = s_disarmEffectCheck.Get(); RealUpdateDetectionState = s_updateDetectionState.Get(); RealProcessResponse = s_processResponse.Get(); RealInitiateMountPackage = s_initiateMountPackage.Get(); @@ -1346,6 +1416,10 @@ static TiltedPhoques::Initializer s_actorHooks( TP_HOOK(&RealAddInventoryItem, HookAddInventoryItem); TP_HOOK(&RealPickUpObject, HookPickUpObject); TP_HOOK(&RealDropObject, HookDropObject); + TP_HOOK(&RealGetDisarmableWeapon, HookGetDisarmableWeapon); + TP_HOOK(&RealDisarm, HookDisarm); + TP_HOOK(&RealDisarmEffectStart, HookDisarmEffectStart); + TP_HOOK(&RealDisarmEffectCheck, HookDisarmEffectCheck); TP_HOOK(&RealUpdateDetectionState, HookUpdateDetectionState); TP_HOOK(&RealProcessResponse, HookProcessResponse); TP_HOOK(&RealInitiateMountPackage, HookInitiateMountPackage); diff --git a/Code/client/Games/Skyrim/Actor.h b/Code/client/Games/Skyrim/Actor.h index f82b411017..094c91f6ae 100644 --- a/Code/client/Games/Skyrim/Actor.h +++ b/Code/client/Games/Skyrim/Actor.h @@ -206,6 +206,8 @@ struct Actor : TESObjectREFR [[nodiscard]] bool IsDead() const noexcept; [[nodiscard]] bool IsDragon() const noexcept; [[nodiscard]] bool IsPlayerSummon() const noexcept; + // True when this is the local player being disarmed by another player with PvP on. + [[nodiscard]] bool IsPvpDisarmedBy(Actor* apCaster) noexcept; [[nodiscard]] bool IsInCombat() const noexcept; [[nodiscard]] Actor* GetCombatTarget() const noexcept; [[nodiscard]] bool HasPerk(uint32_t aPerkFormId) const noexcept; diff --git a/Code/client/Games/Skyrim/Magic/EffectItem.cpp b/Code/client/Games/Skyrim/Magic/EffectItem.cpp index e74998c46c..05dcc757b5 100644 --- a/Code/client/Games/Skyrim/Magic/EffectItem.cpp +++ b/Code/client/Games/Skyrim/Magic/EffectItem.cpp @@ -8,6 +8,11 @@ bool EffectItem::IsHealingEffect() const noexcept return pEffectSetting->eArchetype == EffectArchetypes::ArchetypeID::kValueModifier && data.fMagnitude > 0.0f; } +bool EffectItem::IsDisarmEffect() const noexcept +{ + return pEffectSetting->eArchetype == EffectArchetypes::ArchetypeID::kDisarm; +} + bool EffectItem::IsSummonEffect() const noexcept { return pEffectSetting->eArchetype == EffectArchetypes::ArchetypeID::kSummonCreature; diff --git a/Code/client/Games/Skyrim/Magic/EffectItem.h b/Code/client/Games/Skyrim/Magic/EffectItem.h index c4ee9a5d4f..1669b2f5b6 100644 --- a/Code/client/Games/Skyrim/Magic/EffectItem.h +++ b/Code/client/Games/Skyrim/Magic/EffectItem.h @@ -14,6 +14,7 @@ struct EffectItemData struct EffectItem { bool IsHealingEffect() const noexcept; + bool IsDisarmEffect() const noexcept; bool IsSummonEffect() const noexcept; bool IsSlowEffect() const noexcept; bool IsInivisibilityEffect() const noexcept; diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index fe6a5ac5b8..2cd9919f6e 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -118,6 +118,17 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (!arData.pCaster) return false; + // Perk disarms (Disarming Bash etc.) come from the attacker's hit and its perks, which the victim's client + // doesn't run, so it gets told here. Shouts reach the victim's client through their own projectile. + if (arData.pEffectItem->IsDisarmEffect() && arData.pCaster->GetExtension()->IsLocalPlayer() && World::Get().GetServerSettings().PvpEnabled) + { + const SpellItem* pSpellItem = Cast(arData.pSpell); + if (pSpellItem && pSpellItem->eDelivery == MagicSystem::Delivery::TOUCH) + World::Get().GetRunner().Trigger(addTargetEvent); + + return false; + } + if (!arData.pSpell->IsHealingSpell() && !arData.pSpell->IsBuffSpell()) return false; @@ -193,7 +204,13 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar bool TP_MAKE_THISCALL(HookCheckAddEffectTargetData, MagicTarget::AddTargetData, void* arArgs, float afResistance) { if (s_autoSucceedEffectCheck) - return true; + { + // The victim's client decides on a disarm: the effect checks what the victim's weapon and attack are + // doing, which the attacker's copy of them doesn't reliably know. + Actor* pTargetActor = (*static_cast(arArgs))->GetTargetAsActor(); + if (!apThis->pEffectItem->IsDisarmEffect() || !pTargetActor || !pTargetActor->IsPvpDisarmedBy(apThis->pCaster)) + return true; + } return TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); } From c444763ee1792d582ccdbe31ec03171da2eaa08a Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 02:44:07 +0100 Subject: [PATCH 74/97] Make summons attack a player fighting their owner in PvP Commanded actors aren't hostile to a remote copy, as it has no combat group of its own to mark them as enemies. Force the target while a fight between the two players lasts. Also stop a remote player's summon from hurting the local player with PvP off, which only remote players themselves were kept from doing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Games/Skyrim/Actor.cpp | 3 +- .../client/Games/Skyrim/Magic/MagicTarget.cpp | 12 +++ Code/client/Services/CombatService.h | 4 + .../client/Services/Generic/CombatService.cpp | 75 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 9e1933e862..604c648996 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -976,7 +976,8 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo { if (!World::Get().GetServerSettings().PvpEnabled) { - if (apHitter && apHitter->GetExtension()->IsRemotePlayer()) + // A remote player's summon counts too, its copy swings at whatever the owner's client tells it to. + if (apHitter && (apHitter->GetExtension()->IsRemotePlayer() || (apHitter->GetExtension()->IsRemote() && apHitter->IsPlayerSummon()))) return false; } diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index fe6a5ac5b8..6ee5a01497 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -118,6 +118,10 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (!arData.pCaster) return false; + // The spell doesn't land here, but it does make the two players enemies. + if (arData.pSpell->iHostileCount > 0 && arData.pCaster->GetExtension()->IsLocalPlayer()) + World::Get().GetRunner().Trigger(HitEvent(arData.pCaster->formID, pTargetActor->formID)); + if (!arData.pSpell->IsHealingSpell() && !arData.pSpell->IsBuffSpell()) return false; @@ -150,6 +154,14 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar // Heal and buff spells are already synced by the caster. if (arData.pSpell->IsHealingSpell() || arData.pSpell->IsBuffSpell()) return false; + + if (arData.pSpell->iHostileCount > 0) + World::Get().GetRunner().Trigger(HitEvent(arData.pCaster->formID, pTargetActor->formID)); + } + else if (pCasterExtension->IsRemote() && arData.pCaster->IsPlayerSummon() && arData.pSpell->iHostileCount > 0) + { + if (!World::Get().GetServerSettings().PvpEnabled) + return false; } } diff --git a/Code/client/Services/CombatService.h b/Code/client/Services/CombatService.h index 4e02e701cf..75f372cc4b 100644 --- a/Code/client/Services/CombatService.h +++ b/Code/client/Services/CombatService.h @@ -26,6 +26,10 @@ struct CombatService void RunTargetUpdates(const float acDelta) const noexcept; + // Local summons attack a remote player that is fighting the local player, for as long as the fight lasts. + void SendSummonsAtEnemyPlayer(const HitEvent& acEvent) const noexcept; + void RunSummonTargetUpdates(const float acDelta) const noexcept; + private: World& m_world; TransportService& m_transport; diff --git a/Code/client/Services/Generic/CombatService.cpp b/Code/client/Services/Generic/CombatService.cpp index df07fd3277..4ac9ac56b5 100644 --- a/Code/client/Services/Generic/CombatService.cpp +++ b/Code/client/Services/Generic/CombatService.cpp @@ -15,6 +15,13 @@ #include #include #include +#include + +namespace +{ +// How long after the last hit between two players the local summons keep attacking the other player. +constexpr float kSummonAggroSeconds = 30.f; +} CombatService::CombatService(World& aWorld, TransportService& aTransport, entt::dispatcher& aDispatcher) : m_world(aWorld) @@ -30,6 +37,7 @@ CombatService::CombatService(World& aWorld, TransportService& aTransport, entt:: void CombatService::OnUpdate(const UpdateEvent& acEvent) const noexcept { RunTargetUpdates(static_cast(acEvent.Delta)); + RunSummonTargetUpdates(static_cast(acEvent.Delta)); } void CombatService::OnLocalComponentRemoved(entt::registry& aRegistry, entt::entity aEntity) const noexcept @@ -162,6 +170,8 @@ void CombatService::OnNotifyProjectileLaunch(const NotifyProjectileLaunch& acMes void CombatService::OnHitEvent(const HitEvent& acEvent) const noexcept { + SendSummonsAtEnemyPlayer(acEvent); + #if 0 if (!m_transport.IsConnected()) return; @@ -199,6 +209,71 @@ void CombatService::OnHitEvent(const HitEvent& acEvent) const noexcept #endif } +void CombatService::SendSummonsAtEnemyPlayer(const HitEvent& acEvent) const noexcept +{ + if (!m_world.GetServerSettings().PvpEnabled) + return; + + Actor* pHitter = Cast(TESForm::GetById(acEvent.HitterId)); + Actor* pHittee = Cast(TESForm::GetById(acEvent.HitteeId)); + if (!pHitter || !pHittee) + return; + + Actor* pEnemy = nullptr; + if (pHitter->GetExtension()->IsRemotePlayer() && pHittee->GetExtension()->IsLocalPlayer()) + pEnemy = pHitter; + else if (pHitter->GetExtension()->IsLocalPlayer() && pHittee->GetExtension()->IsRemotePlayer()) + pEnemy = pHittee; + + if (!pEnemy || pEnemy->IsDead()) + return; + + auto view = m_world.view(); + for (const auto entity : view) + { + Actor* pSummon = Cast(TESForm::GetById(view.get(entity).Id)); + if (!pSummon || !pSummon->IsPlayerSummon() || pSummon->IsDead()) + continue; + + const auto* pCombatComponent = m_world.try_get(entity); + const bool cIsAttackingEnemy = pCombatComponent && pCombatComponent->TargetFormId == pEnemy->formID && pSummon->IsInCombat(); + + m_world.emplace_or_replace(entity, pEnemy->formID).Timer = kSummonAggroSeconds; + + // Commanded actors are only hostile to whoever is fighting their commander according to the target's own + // combat group, and a remote copy doesn't have one, so the target has to be forced. + if (!cIsAttackingEnemy) + pSummon->StartCombat(pEnemy); + } +} + +void CombatService::RunSummonTargetUpdates(const float acDelta) const noexcept +{ + const bool cIsPvpEnabled = m_world.GetServerSettings().PvpEnabled; + + Vector toRemove{}; + + auto view = m_world.view(); + for (const auto entity : view) + { + auto& combatComponent = view.get(entity); + combatComponent.Timer -= acDelta; + + Actor* pEnemy = Cast(TESForm::GetById(combatComponent.TargetFormId)); + if (cIsPvpEnabled && combatComponent.Timer > 0.f && pEnemy && !pEnemy->IsDead()) + continue; + + // Combat also ends against anyone else here, but the summon picks actual enemies right back up. + if (Actor* pSummon = Cast(TESForm::GetById(view.get(entity).Id))) + pSummon->StopCombat(); + + toRemove.push_back(entity); + } + + for (const auto entity : toRemove) + m_world.remove(entity); +} + void CombatService::RunTargetUpdates(const float acDelta) const noexcept { #if 0 From 1ea469f61e7d4e52b2ad471822d2fb88b1ea3500 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 11:30:44 +0100 Subject: [PATCH 75/97] Make area spells hit other players in PvP Mass spells only ran on the caster's client, and hazards like the fire from wall spells skipped the local player since it isn't hostile to a remote player. Send self-cast area attacks to the other clients, and let hazards and explosions from a remote player reach the local player while PvP is on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Games/Skyrim/Actor.cpp | 48 +++++++++++++++++++ .../client/Games/Skyrim/Magic/MagicTarget.cpp | 26 +++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 9e1933e862..c86c5e46f6 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -129,6 +129,9 @@ TP_THIS_FUNCTION(TCharacterDestructor, Actor*, Actor); TP_THIS_FUNCTION(TAddInventoryItem, void, Actor, TESBoundObject* apItem, ExtraDataList* apExtraData, int32_t aCount, TESObjectREFR* apOldOwner); TP_THIS_FUNCTION(TPickUpObject, void*, Actor, TESObjectREFR* apObject, int32_t aCount, bool aUnk1, float aUnk2); TP_THIS_FUNCTION(TDropObject, void*, Actor, void* apResult, TESBoundObject* apObject, ExtraDataList* apExtraData, int32_t aCount, NiPoint3* apLocation, NiPoint3* apRotation); +TP_THIS_FUNCTION(TIsHostileToActor, bool, Actor, Actor* apTarget); +TP_THIS_FUNCTION(TCheckValidTarget, bool, Actor, TESObjectREFR* apTarget); +using TApplyHazardToPlayer = uint64_t(void** apContext, void* apPlayerHandle); TP_THIS_FUNCTION(TSetPosition, char, Actor, NiPoint3& acPosition); TP_THIS_FUNCTION(TActorProcess, char, Actor, float aValue); @@ -143,6 +146,9 @@ static TRemoveSpell* RealRemoveSpell = nullptr; static TAddInventoryItem* RealAddInventoryItem = nullptr; static TPickUpObject* RealPickUpObject = nullptr; static TDropObject* RealDropObject = nullptr; +static TIsHostileToActor* RealIsHostileToActor = nullptr; +static TCheckValidTarget* RealCheckValidTarget = nullptr; +static TApplyHazardToPlayer* RealApplyHazardToPlayer = nullptr; static TSetPosition* RealSetPosition = nullptr; static TActorProcess* RealActorProcess = nullptr; @@ -1107,6 +1113,39 @@ void Actor::PickUpObject(TESObjectREFR* apObject, int32_t aCount, bool aUnk1, fl TiltedPhoques::ThisCall(RealPickUpObject, this, apObject, aCount, aUnk1, aUnk2); } +// A hazard (the fire on the ground from a wall spell, for example) only affects actors that are hostile to its owner, +// which the local player never is to a remote player since both are in the player faction. +static thread_local Actor* s_pRemoteHazardOwner = nullptr; + +bool TP_MAKE_THISCALL(HookIsHostileToActor, Actor, Actor* apTarget) +{ + if (s_pRemoteHazardOwner && s_pRemoteHazardOwner == apTarget && apThis->GetExtension()->IsLocalPlayer()) + return true; + + return TiltedPhoques::ThisCall(RealIsHostileToActor, apThis, apTarget); +} + +uint64_t HookApplyHazardToPlayer(void** apContext, void* apPlayerHandle) +{ + auto* pOwner = static_cast(apContext[0]); + if (pOwner && pOwner->GetExtension()->IsRemotePlayer() && World::Get().GetServerSettings().PvpEnabled) + s_pRemoteHazardOwner = pOwner; + + const uint64_t cResult = RealApplyHazardToPlayer(apContext, apPlayerHandle); + s_pRemoteHazardOwner = nullptr; + return cResult; +} + +// An explosion ignores whoever its owner isn't fighting while the owner is in combat, and a remote player's +// combat group doesn't have the local player in it. +bool TP_MAKE_THISCALL(HookCheckValidTarget, Actor, TESObjectREFR* apTarget) +{ + if (apTarget == PlayerCharacter::Get() && apThis->GetExtension()->IsRemotePlayer() && World::Get().GetServerSettings().PvpEnabled) + return true; + + return TiltedPhoques::ThisCall(RealCheckValidTarget, apThis, apTarget); +} + void* TP_MAKE_THISCALL(HookDropObject, Actor, void* apResult, TESBoundObject* apObject, ExtraDataList* apExtraData, int32_t aCount, NiPoint3* apLocation, NiPoint3* apRotation) { auto& modSystem = World::Get().GetModSystem(); @@ -1303,6 +1342,9 @@ static TiltedPhoques::Initializer s_actorHooks( POINTER_SKYRIMSE(TAddInventoryItem, s_addInventoryItem, 37525); POINTER_SKYRIMSE(TPickUpObject, s_pickUpObject, 37521); POINTER_SKYRIMSE(TDropObject, s_dropObject, 40454); + POINTER_SKYRIMSE(TIsHostileToActor, s_isHostileToActor, 37537); + POINTER_SKYRIMSE(TCheckValidTarget, s_checkValidTarget, 38560); + POINTER_SKYRIMSE(TApplyHazardToPlayer, s_applyHazardToPlayer, 43987); POINTER_SKYRIMSE(TUpdateDetectionState, s_updateDetectionState, 42704); POINTER_SKYRIMSE(TProcessResponse, s_processResponse, 39643); POINTER_SKYRIMSE(TInitiateMountPackage, s_initiateMountPackage, 37905); @@ -1325,6 +1367,9 @@ static TiltedPhoques::Initializer s_actorHooks( RealAddInventoryItem = s_addInventoryItem.Get(); RealPickUpObject = s_pickUpObject.Get(); RealDropObject = s_dropObject.Get(); + RealIsHostileToActor = s_isHostileToActor.Get(); + RealCheckValidTarget = s_checkValidTarget.Get(); + RealApplyHazardToPlayer = s_applyHazardToPlayer.Get(); RealUpdateDetectionState = s_updateDetectionState.Get(); RealProcessResponse = s_processResponse.Get(); RealInitiateMountPackage = s_initiateMountPackage.Get(); @@ -1346,6 +1391,9 @@ static TiltedPhoques::Initializer s_actorHooks( TP_HOOK(&RealAddInventoryItem, HookAddInventoryItem); TP_HOOK(&RealPickUpObject, HookPickUpObject); TP_HOOK(&RealDropObject, HookDropObject); + TP_HOOK(&RealIsHostileToActor, HookIsHostileToActor); + TP_HOOK(&RealCheckValidTarget, HookCheckValidTarget); + TP_HOOK(&RealApplyHazardToPlayer, HookApplyHazardToPlayer); TP_HOOK(&RealUpdateDetectionState, HookUpdateDetectionState); TP_HOOK(&RealProcessResponse, HookProcessResponse); TP_HOOK(&RealInitiateMountPackage, HookInitiateMountPackage); diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index fe6a5ac5b8..1d1250e6e3 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -81,6 +81,18 @@ bool MagicTarget::AddTargetData::IsForbiddenEffect(Actor* apTarget) return pEffectItem->IsNightVisionEffect(); } +// Hostile area spells that are cast on the caster (Mass Paralysis, for example) have no projectile, so nothing about +// them runs on any client but the caster's. Those are sent to the other clients once they find a player. +static bool IsSelfCastAreaAttack(const MagicTarget::AddTargetData& acData) noexcept +{ + if (acData.pSpell->iHostileCount <= 0 || acData.pEffectItem->data.iArea <= 0 || !World::Get().GetServerSettings().PvpEnabled) + return false; + + const SpellItem* pSpellItem = Cast(acData.pSpell); + return pSpellItem && pSpellItem->eDelivery == MagicSystem::Delivery::SELF && pSpellItem->eCastingType != MagicSystem::CastingType::CONCENTRATION + && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; +} + Actor* MagicTarget::GetTargetAsActor() { TP_THIS_FUNCTION(TGetTargetAsActor, Actor*, MagicTarget); @@ -118,6 +130,12 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (!arData.pCaster) return false; + if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData)) + { + World::Get().GetRunner().Trigger(addTargetEvent); + return TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); + } + if (!arData.pSpell->IsHealingSpell() && !arData.pSpell->IsBuffSpell()) return false; @@ -193,7 +211,13 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar bool TP_MAKE_THISCALL(HookCheckAddEffectTargetData, MagicTarget::AddTargetData, void* arArgs, float afResistance) { if (s_autoSucceedEffectCheck) - return true; + { + // The victim's resistances and the effect's conditions still get to refuse an area attack from another player. + Actor* pTargetActor = (*static_cast(arArgs))->GetTargetAsActor(); + const bool cIsFromRemotePlayer = pTargetActor && pTargetActor->GetExtension()->IsLocalPlayer() && apThis->pCaster && apThis->pCaster->GetExtension()->IsRemotePlayer(); + if (!cIsFromRemotePlayer || !IsSelfCastAreaAttack(*apThis)) + return true; + } return TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); } From f0ec40dc62cd71157076059c69f516a69c171222 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 11:37:38 +0100 Subject: [PATCH 76/97] Make cloak spells hit other players in PvP A cloak's copy on the victim's client can't be relied on, since a concentration cloak only lasts while it's cast on the wearer's client. Let that client decide on the hits and send them to the victim, whose own checks can still refuse them, and ignore the hits from the copy's cloak. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Events/AddTargetEvent.h | 2 ++ .../client/Games/Skyrim/Magic/MagicTarget.cpp | 25 +++++++++++++++++-- Code/client/Services/Generic/MagicService.cpp | 2 +- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/Code/client/Events/AddTargetEvent.h b/Code/client/Events/AddTargetEvent.h index fc7baf8317..ff3d91582f 100644 --- a/Code/client/Events/AddTargetEvent.h +++ b/Code/client/Events/AddTargetEvent.h @@ -15,4 +15,6 @@ struct AddTargetEvent bool IsDualCasting{false}; bool ApplyHealPerkBonus{false}; bool ApplyStaminaPerkBonus{false}; + // A cloak hit on another player. Unlike other concentration spells, it isn't replayed on the victim's client. + bool IsPvpCloakHit{false}; }; diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index 1d1250e6e3..9c0cff3800 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -93,6 +93,13 @@ static bool IsSelfCastAreaAttack(const MagicTarget::AddTargetData& acData) noexc && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; } +// CloakEffect passes this callback along with each of the targets it finds around the wearer. +static bool IsCloakHit(const MagicTarget::AddTargetData& acData) noexcept +{ + POINTER_SKYRIMSE(uint8_t, s_cloakCallbackVtable, 205851); + return acData.pCallback && *reinterpret_cast(acData.pCallback) == s_cloakCallbackVtable.Get(); +} + Actor* MagicTarget::GetTargetAsActor() { TP_THIS_FUNCTION(TGetTargetAsActor, Actor*, MagicTarget); @@ -130,6 +137,15 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (!arData.pCaster) return false; + // The wearer's client is the one that knows how long the cloak lasts, a concentration cloak only lives on as + // long as it's being cast there. So it decides on the hits, and the copy's cloak is ignored on the victim's client. + if (IsCloakHit(arData) && arData.pCaster->GetExtension()->IsLocalPlayer() && World::Get().GetServerSettings().PvpEnabled) + { + addTargetEvent.IsPvpCloakHit = true; + World::Get().GetRunner().Trigger(addTargetEvent); + return false; + } + if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData)) { World::Get().GetRunner().Trigger(addTargetEvent); @@ -168,6 +184,10 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar // Heal and buff spells are already synced by the caster. if (arData.pSpell->IsHealingSpell() || arData.pSpell->IsBuffSpell()) return false; + + // Cloak hits are synced by the wearer. + if (IsCloakHit(arData)) + return false; } } @@ -212,10 +232,11 @@ bool TP_MAKE_THISCALL(HookCheckAddEffectTargetData, MagicTarget::AddTargetData, { if (s_autoSucceedEffectCheck) { - // The victim's resistances and the effect's conditions still get to refuse an area attack from another player. + // The victim's resistances and the effect's conditions still get to refuse an attack from another player. + // Heal and buff spells have already passed them on the caster's client. Actor* pTargetActor = (*static_cast(arArgs))->GetTargetAsActor(); const bool cIsFromRemotePlayer = pTargetActor && pTargetActor->GetExtension()->IsLocalPlayer() && apThis->pCaster && apThis->pCaster->GetExtension()->IsRemotePlayer(); - if (!cIsFromRemotePlayer || !IsSelfCastAreaAttack(*apThis)) + if (!cIsFromRemotePlayer || apThis->pSpell->IsHealingSpell() || apThis->pSpell->IsBuffSpell()) return true; } diff --git a/Code/client/Services/Generic/MagicService.cpp b/Code/client/Services/Generic/MagicService.cpp index cfbbe10424..1a3e3e8a27 100644 --- a/Code/client/Services/Generic/MagicService.cpp +++ b/Code/client/Services/Generic/MagicService.cpp @@ -305,7 +305,7 @@ void MagicService::OnAddTargetEvent(const AddTargetEvent& acEvent) noexcept // These effects are applied through spell cast sync if (SpellItem* pSpellItem = Cast(TESForm::GetById(acEvent.SpellID))) { - if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell()) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) + if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell() && !acEvent.IsPvpCloakHit) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) { return; } From 86d67c43b90180a6ed1d81f57611e4a2b7650c80 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 11:51:23 +0100 Subject: [PATCH 77/97] Let spell conditions accept attacks from other players in PvP Cloak damage only lands on targets hostile to the caster, and some area effects skip the player's base form outright, so hits sent to the victim were refused. Pass those two conditions for an attack from another player, as other conditions like resistances still apply, and send concentration area effects from the caster's client too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- Code/client/Events/AddTargetEvent.h | 4 +- .../client/Games/Skyrim/Magic/MagicTarget.cpp | 69 +++++++++++++++---- Code/client/Services/Generic/MagicService.cpp | 2 +- 3 files changed, 58 insertions(+), 17 deletions(-) diff --git a/Code/client/Events/AddTargetEvent.h b/Code/client/Events/AddTargetEvent.h index ff3d91582f..e6df8f259d 100644 --- a/Code/client/Events/AddTargetEvent.h +++ b/Code/client/Events/AddTargetEvent.h @@ -15,6 +15,6 @@ struct AddTargetEvent bool IsDualCasting{false}; bool ApplyHealPerkBonus{false}; bool ApplyStaminaPerkBonus{false}; - // A cloak hit on another player. Unlike other concentration spells, it isn't replayed on the victim's client. - bool IsPvpCloakHit{false}; + // A hit on another player that the victim's client can't work out by itself, even for a concentration spell. + bool IsPvpForwarded{false}; }; diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index 9c0cff3800..4357bceaae 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -24,6 +24,7 @@ TP_THIS_FUNCTION(TFindTargets, bool, MagicCaster, float afEffectivenessMult, int TP_THIS_FUNCTION(TAdjustForPerks, void, ActiveEffect, Actor* apCaster, MagicTarget* apTarget); TP_THIS_FUNCTION(THasPerk, bool, Actor, TESForm* apPerk, void* apUnk1, double* afReturnValue); TP_THIS_FUNCTION(TGetPerkRank, uint8_t, Actor, TESForm* apPerk); +using TConditionFunction = bool(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult); static TAddTarget* RealAddTarget = nullptr; static TCheckAddEffectTargetData* RealCheckAddEffectTargetData = nullptr; @@ -31,10 +32,13 @@ static TFindTargets* RealFindTargets = nullptr; static TAdjustForPerks* RealAdjustForPerks = nullptr; static THasPerk* RealHasPerk = nullptr; static TGetPerkRank* RealGetPerkRank = nullptr; +static TConditionFunction* RealConditionIsHostileToActor = nullptr; +static TConditionFunction* RealConditionGetIsID = nullptr; static thread_local bool s_autoSucceedEffectCheck = false; static thread_local bool s_applyHealPerkBonus = false; static thread_local bool s_applyStaminaPerkBonus = false; +static thread_local bool s_isCheckingAttackFromPlayer = false; void MagicTarget::DispelAllSpells(bool aNow) noexcept { @@ -82,15 +86,15 @@ bool MagicTarget::AddTargetData::IsForbiddenEffect(Actor* apTarget) } // Hostile area spells that are cast on the caster (Mass Paralysis, for example) have no projectile, so nothing about -// them runs on any client but the caster's. Those are sent to the other clients once they find a player. +// them runs on any client but the caster's. Those are sent to the other clients once they find a player. A replayed +// concentration spell doesn't get to decide on the targets either, since it only finds them once. static bool IsSelfCastAreaAttack(const MagicTarget::AddTargetData& acData) noexcept { if (acData.pSpell->iHostileCount <= 0 || acData.pEffectItem->data.iArea <= 0 || !World::Get().GetServerSettings().PvpEnabled) return false; const SpellItem* pSpellItem = Cast(acData.pSpell); - return pSpellItem && pSpellItem->eDelivery == MagicSystem::Delivery::SELF && pSpellItem->eCastingType != MagicSystem::CastingType::CONCENTRATION - && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; + return pSpellItem && pSpellItem->eDelivery == MagicSystem::Delivery::SELF && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; } // CloakEffect passes this callback along with each of the targets it finds around the wearer. @@ -121,6 +125,10 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar if (arData.pEffectItem->IsVampireLordEffect()) pTargetActorEx->GraphDescriptorHash = AnimationGraphDescriptor_VampireLordBehavior::m_key; + // The caster's client sends these, see below. + if (pTargetActorEx->IsLocalPlayer() && arData.pCaster && arData.pCaster->GetExtension()->IsRemotePlayer() && IsSelfCastAreaAttack(arData)) + return false; + if (ScopedSpellCastOverride::IsOverriden()) return TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); @@ -141,14 +149,20 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar // long as it's being cast there. So it decides on the hits, and the copy's cloak is ignored on the victim's client. if (IsCloakHit(arData) && arData.pCaster->GetExtension()->IsLocalPlayer() && World::Get().GetServerSettings().PvpEnabled) { - addTargetEvent.IsPvpCloakHit = true; + addTargetEvent.IsPvpForwarded = true; World::Get().GetRunner().Trigger(addTargetEvent); return false; } if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData)) { + addTargetEvent.IsPvpForwarded = true; World::Get().GetRunner().Trigger(addTargetEvent); + + // A script (the lightning from Unbounded Storms, for example) only has to run on the victim's client. + if (arData.pEffectItem->pEffectSetting->eArchetype == EffectArchetypes::ArchetypeID::kScript) + return false; + return TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); } @@ -230,17 +244,38 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar bool TP_MAKE_THISCALL(HookCheckAddEffectTargetData, MagicTarget::AddTargetData, void* arArgs, float afResistance) { - if (s_autoSucceedEffectCheck) - { - // The victim's resistances and the effect's conditions still get to refuse an attack from another player. - // Heal and buff spells have already passed them on the caster's client. - Actor* pTargetActor = (*static_cast(arArgs))->GetTargetAsActor(); - const bool cIsFromRemotePlayer = pTargetActor && pTargetActor->GetExtension()->IsLocalPlayer() && apThis->pCaster && apThis->pCaster->GetExtension()->IsRemotePlayer(); - if (!cIsFromRemotePlayer || apThis->pSpell->IsHealingSpell() || apThis->pSpell->IsBuffSpell()) - return true; - } + // The victim's resistances and the effect's conditions still get to refuse an attack from another player. + // Heal and buff spells have already passed them on the caster's client. + Actor* pTargetActor = (*static_cast(arArgs))->GetTargetAsActor(); + const bool cIsAttackFromPlayer = pTargetActor && pTargetActor->GetExtension()->IsLocalPlayer() && apThis->pCaster && apThis->pCaster->GetExtension()->IsRemotePlayer() + && !apThis->pSpell->IsHealingSpell() && !apThis->pSpell->IsBuffSpell() && World::Get().GetServerSettings().PvpEnabled; + + if (s_autoSucceedEffectCheck && !cIsAttackFromPlayer) + return true; + + s_isCheckingAttackFromPlayer = cIsAttackFromPlayer; + const bool cResult = TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); + s_isCheckingAttackFromPlayer = false; + return cResult; +} - return TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); +// Effects keep players out of their reach with conditions, which have to let an attack from another player through: +// cloak damage asks if the target is hostile to the caster or to the player, some area effects ask if the target isn't +// the player. These only run when an effect is added, not while it's active. +static bool HookConditionIsHostileToActor(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult) +{ + const bool cResult = RealConditionIsHostileToActor(apSubject, apParam1, apParam2, apResult); + if (s_isCheckingAttackFromPlayer && apSubject == PlayerCharacter::Get()) + *apResult = 1.0; + return cResult; +} + +static bool HookConditionGetIsID(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult) +{ + const bool cResult = RealConditionGetIsID(apSubject, apParam1, apParam2, apResult); + if (s_isCheckingAttackFromPlayer && apSubject == PlayerCharacter::Get()) + *apResult = 0.0; + return cResult; } bool TP_MAKE_THISCALL(HookFindTargets, MagicCaster, float afEffectivenessMult, int32_t* aruiTargetCount, TESBoundObject* apSource, char abLoadCast, char abAdjust) @@ -288,6 +323,8 @@ static TiltedPhoques::Initializer s_magicTargetHooks([]() { POINTER_SKYRIMSE(TAdjustForPerks, adjustForPerks, 34053); POINTER_SKYRIMSE(THasPerk, hasPerk, 21622); POINTER_SKYRIMSE(TGetPerkRank, getPerkRank, 37698); + POINTER_SKYRIMSE(TConditionFunction, conditionIsHostileToActor, 21472); + POINTER_SKYRIMSE(TConditionFunction, conditionGetIsID, 21487); RealAddTarget = addTarget.Get(); RealCheckAddEffectTargetData = checkAddEffectTargetData.Get(); @@ -295,11 +332,15 @@ static TiltedPhoques::Initializer s_magicTargetHooks([]() { RealAdjustForPerks = adjustForPerks.Get(); RealHasPerk = hasPerk.Get(); RealGetPerkRank = getPerkRank.Get(); + RealConditionIsHostileToActor = conditionIsHostileToActor.Get(); + RealConditionGetIsID = conditionGetIsID.Get(); TP_HOOK(&RealAddTarget, HookAddTarget); TP_HOOK(&RealCheckAddEffectTargetData, HookCheckAddEffectTargetData); TP_HOOK(&RealFindTargets, HookFindTargets); TP_HOOK(&RealAdjustForPerks, HookAdjustForPerks); TP_HOOK(&RealHasPerk, HookHasPerk); + TP_HOOK(&RealConditionIsHostileToActor, HookConditionIsHostileToActor); + TP_HOOK(&RealConditionGetIsID, HookConditionGetIsID); //TP_HOOK(&RealGetPerkRank, HookGetPerkRank); }); diff --git a/Code/client/Services/Generic/MagicService.cpp b/Code/client/Services/Generic/MagicService.cpp index 1a3e3e8a27..fbafed581c 100644 --- a/Code/client/Services/Generic/MagicService.cpp +++ b/Code/client/Services/Generic/MagicService.cpp @@ -305,7 +305,7 @@ void MagicService::OnAddTargetEvent(const AddTargetEvent& acEvent) noexcept // These effects are applied through spell cast sync if (SpellItem* pSpellItem = Cast(TESForm::GetById(acEvent.SpellID))) { - if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell() && !acEvent.IsPvpCloakHit) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) + if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell() && !acEvent.IsPvpForwarded) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) { return; } From 3c923d1e886deeb1ff155073ea549da2a8a62f71 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 22:25:24 +0100 Subject: [PATCH 78/97] Keep Unbounded Freezing's knockdown off other players It's the same ice form paralysis that has been kept away from players, and its effect refuses them on purpose. Stop overriding that condition for paralysis and stop sending the channeled kind to the victim. The cloak's frost damage and Mass Paralysis, which has no such condition, still land. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur --- .../client/Games/Skyrim/Magic/MagicTarget.cpp | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp index 4357bceaae..aa3cb4abb3 100644 --- a/Code/client/Games/Skyrim/Magic/MagicTarget.cpp +++ b/Code/client/Games/Skyrim/Magic/MagicTarget.cpp @@ -39,6 +39,7 @@ static thread_local bool s_autoSucceedEffectCheck = false; static thread_local bool s_applyHealPerkBonus = false; static thread_local bool s_applyStaminaPerkBonus = false; static thread_local bool s_isCheckingAttackFromPlayer = false; +static thread_local bool s_isCheckingParalysis = false; void MagicTarget::DispelAllSpells(bool aNow) noexcept { @@ -97,6 +98,16 @@ static bool IsSelfCastAreaAttack(const MagicTarget::AddTargetData& acData) noexc return pSpellItem && pSpellItem->eDelivery == MagicSystem::Delivery::SELF && pSpellItem->eCastingType != MagicSystem::CastingType::CONSTANT_EFFECT; } +// The ice form effect of Unbounded Freezing. Like the shout it comes from, it isn't meant to reach players. +static bool IsChanneledParalysis(const MagicTarget::AddTargetData& acData) noexcept +{ + if (acData.pEffectItem->pEffectSetting->eArchetype != EffectArchetypes::ArchetypeID::kParalysis) + return false; + + const SpellItem* pSpellItem = Cast(acData.pSpell); + return pSpellItem && pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION; +} + // CloakEffect passes this callback along with each of the targets it finds around the wearer. static bool IsCloakHit(const MagicTarget::AddTargetData& acData) noexcept { @@ -154,7 +165,7 @@ bool TP_MAKE_THISCALL(HookAddTarget, MagicTarget, MagicTarget::AddTargetData& ar return false; } - if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData)) + if (arData.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData) && !IsChanneledParalysis(arData)) { addTargetEvent.IsPvpForwarded = true; World::Get().GetRunner().Trigger(addTargetEvent); @@ -254,14 +265,16 @@ bool TP_MAKE_THISCALL(HookCheckAddEffectTargetData, MagicTarget::AddTargetData, return true; s_isCheckingAttackFromPlayer = cIsAttackFromPlayer; + s_isCheckingParalysis = apThis->pEffectItem->pEffectSetting->eArchetype == EffectArchetypes::ArchetypeID::kParalysis; const bool cResult = TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); s_isCheckingAttackFromPlayer = false; + s_isCheckingParalysis = false; return cResult; } // Effects keep players out of their reach with conditions, which have to let an attack from another player through: // cloak damage asks if the target is hostile to the caster or to the player, some area effects ask if the target isn't -// the player. These only run when an effect is added, not while it's active. +// the player. These only run when an effect is added, not while it's active. Paralysis keeps its player check. static bool HookConditionIsHostileToActor(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult) { const bool cResult = RealConditionIsHostileToActor(apSubject, apParam1, apParam2, apResult); @@ -273,7 +286,7 @@ static bool HookConditionIsHostileToActor(TESObjectREFR* apSubject, void* apPara static bool HookConditionGetIsID(TESObjectREFR* apSubject, void* apParam1, void* apParam2, double* apResult) { const bool cResult = RealConditionGetIsID(apSubject, apParam1, apParam2, apResult); - if (s_isCheckingAttackFromPlayer && apSubject == PlayerCharacter::Get()) + if (s_isCheckingAttackFromPlayer && !s_isCheckingParalysis && apSubject == PlayerCharacter::Get()) *apResult = 0.0; return cResult; } From 65b3e5fc96547c6b22228b8bb11cca1a18259883 Mon Sep 17 00:00:00 2001 From: jotagalindo <108361070+jotagalindo@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:26:16 +0100 Subject: [PATCH 79/97] Make the F2 UI usable on ultrawide screens (#945) * Make the F2 UI usable on ultrawide screens TiltedUI's D3D11 overlay handler copied and uploaded the whole CEF view on every paint, through a deferred context. At 5120x1440 that is about 30 MB per paint, and paints pile up in each game frame when the game runs below the UI's 60 fps, so the menu barely rendered and ignored clicks, worst outdoors. Details in https://github.com/tiltedphoques/TiltedUI/pull/20 --------- Co-authored-by: Claude Opus 5.5 (cherry picked from commit ed46728c44b10844b64948b2b6bf9150b0a02cb2) --- .../Services/Generic/OverlayService.cpp | 23 ++++++++++++++++++- Libraries/TiltedUI | 2 +- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Code/client/Services/Generic/OverlayService.cpp b/Code/client/Services/Generic/OverlayService.cpp index 8c9a9fce18..d6e57eaca2 100644 --- a/Code/client/Services/Generic/OverlayService.cpp +++ b/Code/client/Services/Generic/OverlayService.cpp @@ -69,6 +69,27 @@ struct D3D11RenderProvider final : OverlayApp::RenderProvider, OverlayRenderHand RenderSystemD3D11* m_pRenderSystem; }; +/** + * @brief Makes CEF draw the UI on the CPU instead of the GPU. + * + * On the GPU, CEF copies every frame back to memory for OnPaint, and that waits behind the game's frames when + * the game keeps the GPU busy: CEF drops UI frames and the UI shows up half drawn. + */ +struct CpuRenderedOverlayApp final : OverlayApp +{ + using OverlayApp::OverlayApp; + + void OnBeforeCommandLineProcessing(const CefString& acProcessType, CefRefPtr apCommandLine) override + { + OverlayApp::OnBeforeCommandLineProcessing(acProcessType, apCommandLine); + + apCommandLine->AppendSwitch("disable-gpu"); + apCommandLine->AppendSwitch("disable-gpu-compositing"); + + spdlog::info("Overlay: CEF draws the UI on the CPU (disable-gpu, disable-gpu-compositing)"); + } +}; + String GetCellName(const GameId& aWorldSpaceId, const GameId& aCellId) noexcept { auto& modSystem = World::Get().GetModSystem(); @@ -138,7 +159,7 @@ OverlayService::~OverlayService() noexcept void OverlayService::Create(RenderSystemD3D11* apRenderSystem) noexcept { m_pProvider = TiltedPhoques::MakeUnique(apRenderSystem); - m_pOverlay = new OverlayApp(m_pProvider.get(), new ::OverlayClient(m_transport, m_pProvider->Create())); + m_pOverlay = new CpuRenderedOverlayApp(m_pProvider.get(), new ::OverlayClient(m_transport, m_pProvider->Create())); if (!m_pOverlay->Initialize()) { diff --git a/Libraries/TiltedUI b/Libraries/TiltedUI index 0bec46b174..c7789de214 160000 --- a/Libraries/TiltedUI +++ b/Libraries/TiltedUI @@ -1 +1 @@ -Subproject commit 0bec46b174d730832cbe0a10e8162bf6b0d3d66b +Subproject commit c7789de2149ab107038ed189f6fa5eb7507914d1 From 1a11c74e3d74b24b5b544736bee3fcf728f04e06 Mon Sep 17 00:00:00 2001 From: absol89 Date: Thu, 5 Nov 2026 23:07:40 +0100 Subject: [PATCH 80/97] Lay dead bodies back down when they stand up A corpse can stand because it was rebuilt for a leveled pick or got health back, and saves keep it that way. Keep those from happening to dead actors, and while connected check nearby bodies for a standing skeleton and ragdoll them, logging BodyStand lines. Also log a respawn caused by a save disagreeing with the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018fvQxQEu7yvi9eRZyKsEur (cherry picked from commit 879ac2808264b1abd466924ca4c66601c726b951) --- Code/client/Services/CharacterService.h | 1 + .../Services/Generic/ActorValueService.cpp | 4 + .../Services/Generic/CharacterService.cpp | 137 ++++++++++++++++++ Code/client/Systems/RemoteDeathSystem.h | 3 + 4 files changed, 145 insertions(+) diff --git a/Code/client/Services/CharacterService.h b/Code/client/Services/CharacterService.h index 35155205cd..fba5a30b11 100644 --- a/Code/client/Services/CharacterService.h +++ b/Code/client/Services/CharacterService.h @@ -102,6 +102,7 @@ struct CharacterService ActorData BuildActorData(Actor* apActor) const noexcept; void ApplyLeveledNpcPick(Actor* apActor, const GameId& acPickId) const noexcept; void ProcessLeveledConforms() noexcept; + void RunStandingBodyRepairs() noexcept; void RunLocalUpdates() const noexcept; void RunRemoteUpdates() noexcept; diff --git a/Code/client/Services/Generic/ActorValueService.cpp b/Code/client/Services/Generic/ActorValueService.cpp index 868981f985..59a4fde111 100644 --- a/Code/client/Services/Generic/ActorValueService.cpp +++ b/Code/client/Services/Generic/ActorValueService.cpp @@ -297,6 +297,10 @@ void ActorValueService::OnHealthChangeBroadcast(const NotifyHealthChangeBroadcas return; } + // A dead body with health can end up back on its feet, and there's nothing to take that health away again. + if (!pActor->GetExtension()->IsPlayer() && pActor->GetExtension()->IsRemote() && pActor->IsDead()) + return; + const float newHealth = pActor->GetActorValue(ActorValueInfo::kHealth) + acMessage.DeltaHealth; if (!pActor->GetExtension()->IsPlayer() && pActor->GetExtension()->IsRemote() && !pActor->IsDead() && newHealth <= 0.f) { diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index bc66397381..9b3fa07b1d 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -366,6 +367,7 @@ void CharacterService::OnUpdate(const UpdateEvent& acUpdateEvent) noexcept RunExperienceUpdates(); ApplyCachedWeaponDraws(acUpdateEvent); ProcessLeveledConforms(); + RunStandingBodyRepairs(); } void CharacterService::OnConnected(const ConnectedEvent& acConnectedEvent) const noexcept @@ -745,7 +747,10 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) // Let WaitingFor3D kill a new corpse once its loaded body can fall. if (pActor->IsDead() && !acMessage.IsDead) + { + spdlog::warn("Respawning actor {:X} on spawn, it's dead in this save but alive on the server", pActor->formID); pActor->Respawn(); + } spdlog::info("Spawn Request Is summon {}", acMessage.IsPlayerSummon); @@ -1801,7 +1806,10 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no // Recreated copies use the same WaitingFor3D death transition as new spawns. if (pActor->IsDead() && !acMessage.IsDead) + { + spdlog::warn("Respawning actor {:X} on spawn, it's dead in this save but alive on the server", pActor->formID); pActor->Respawn(); + } spdlog::info("Spawned character for entity, server id: {:X}", remoteComponent.Id); @@ -1848,6 +1856,13 @@ void CharacterService::ApplyLeveledNpcPick(Actor* apActor, const GameId& acPickI return; } + // Conforming disables and rebuilds the actor, and a corpse can come back from that standing. + if (apActor->IsDead() && m_pendingLeveledConforms.find(apActor->formID) == m_pendingLeveledConforms.end()) + { + spdlog::info("Not rebuilding dead actor {:X} for the owner's leveled pick {:X}", apActor->formID, cPickId); + return; + } + const TESNPC* pLocalPick = apActor->GetLeveledPick(); const uint32_t localPickId = pLocalPick ? pLocalPick->formID : 0; @@ -1867,6 +1882,128 @@ void CharacterService::ApplyLeveledNpcPick(Actor* apActor, const GameId& acPickI m_pendingLeveledConforms[apActor->formID] = cPickId; } +// Dead bodies can end up on their feet, and saves keep them that way. The game has no way to tell: IsDead only reads +// the life state and IsInRagdollState is true for any dead actor, so the skeleton has to show it. +void CharacterService::RunStandingBodyRepairs() noexcept +{ + constexpr auto cLookInterval = std::chrono::seconds(2); + constexpr float cStandingHeight = 50.f; // Pelvis over the feet: about 60 standing, up to 43 lying on a slope. + constexpr float cStillDistance = 3.f; + constexpr float cNearPlayer = 4096.f; + constexpr uint32_t cBodiesPerLook = 24; + constexpr uint32_t cMaxTries = 3; + + struct StandingBody + { + NiPoint3 Pelvis{}; + uint32_t Tries = 0; + }; + static TiltedPhoques::Map s_standingBodies; + static std::chrono::steady_clock::time_point s_nextLook{}; + static uint32_t s_bodiesToSkip = 0; + + if (!m_transport.IsConnected()) + { + s_standingBodies.clear(); + return; + } + + const auto cNow = std::chrono::steady_clock::now(); + if (cNow < s_nextLook) + return; + s_nextLook = cNow + cLookInterval; + + const PlayerCharacter* pPlayer = PlayerCharacter::Get(); + UI* pUI = UI::Get(); + if (!pPlayer || (pUI && pUI->GetMenuOpen(BSFixedString("Loading Menu")))) + return; + + static BSFixedString s_pelvisName("NPC Pelvis [Pelv]"); + static BSFixedString s_leftFootName("NPC L Foot [Lft ]"); + static BSFixedString s_rightFootName("NPC R Foot [Rft ]"); + static BSFixedString s_ragdollEvent("RagdollInstant"); + + uint32_t seen = 0; + uint32_t looked = 0; + for (const auto cEntity : m_world.view(entt::exclude)) + { + Actor* pActor = Cast(TESForm::GetById(m_world.get(cEntity).Id)); + if (!pActor || pActor->GetExtension()->IsPlayer() || !pActor->IsDead() || pActor->IsDisabled()) + continue; + + const float cDeltaX = pActor->position.x - pPlayer->position.x; + const float cDeltaY = pActor->position.y - pPlayer->position.y; + const float cDeltaZ = pActor->position.z - pPlayer->position.z; + if (cDeltaX * cDeltaX + cDeltaY * cDeltaY + cDeltaZ * cDeltaZ > cNearPlayer * cNearPlayer || !RemoteDeathSystem::CanKill(pActor)) + continue; + + if (seen++ < s_bodiesToSkip) + continue; + if (++looked > cBodiesPerLook) + break; + + const auto* pNpc = Cast(pActor->baseForm); + const char* cpName = pNpc && pNpc->fullName.value.AsAscii() ? pNpc->fullName.value.AsAscii() : "?"; + + if (const float cHealth = pActor->GetActorValue(ActorValueInfo::kHealth); cHealth > 0.f) + { + spdlog::info("BodyStand: dead {:X} ({}) had {:.0f} health, taking it", pActor->formID, cpName, cHealth); + pActor->ForceActorValue(ActorValueOwner::ForceMode::DAMAGE, ActorValueInfo::kHealth, 0.f); + } + + // Only skeletons with these bones can be told apart. Creatures without them are left as they are. + NiNode* pRoot = pActor->GetNiNode(); + const NiAVObject* pPelvis = pRoot->GetByName(s_pelvisName); + const NiAVObject* pLeftFoot = pRoot->GetByName(s_leftFootName); + const NiAVObject* pRightFoot = pRoot->GetByName(s_rightFootName); + if (!pPelvis || !pLeftFoot || !pRightFoot) + continue; + + const NiPoint3 cPelvis = pPelvis->worldTranslate; + const float cHeight = cPelvis.z - std::min(pLeftFoot->worldTranslate.z, pRightFoot->worldTranslate.z); + + auto it = s_standingBodies.find(pActor->formID); + if (cHeight < cStandingHeight) + { + if (it != s_standingBodies.end()) + { + spdlog::info("BodyStand: dead {:X} ({}) is lying down, pelvis {:.0f} over its feet", pActor->formID, cpName, cHeight); + s_standingBodies.erase(it); + } + continue; + } + + // It has to stand still between two looks. A body that's moving is falling or being carried. + if (it == s_standingBodies.end()) + { + s_standingBodies[pActor->formID].Pelvis = cPelvis; + continue; + } + + const float cMovedX = cPelvis.x - it.value().Pelvis.x; + const float cMovedY = cPelvis.y - it.value().Pelvis.y; + const float cMovedZ = cPelvis.z - it.value().Pelvis.z; + const bool cIsStill = cMovedX * cMovedX + cMovedY * cMovedY + cMovedZ * cMovedZ < cStillDistance * cStillDistance; + it.value().Pelvis = cPelvis; + if (!cIsStill || it.value().Tries > cMaxTries) + continue; + + if (it.value().Tries == cMaxTries) + { + ++it.value().Tries; + spdlog::warn("BodyStand: dead {:X} ({}) still stands after {} tries, giving up", pActor->formID, cpName, cMaxTries); + continue; + } + + ++it.value().Tries; + const bool cWasSent = pActor->SendAnimationEvent(&s_ragdollEvent); + spdlog::info("BodyStand: dead {:X} ({}) stands, pelvis {:.0f} over its feet, laying it down (try {}, event accepted: {})", pActor->formID, cpName, cHeight, + it.value().Tries, cWasSent); + } + + s_bodiesToSkip = looked > cBodiesPerLook ? seen - 1 : 0; +} + void CharacterService::ProcessLeveledConforms() noexcept { using ReconciliationStage = ActorExtension::ReconciliationStage; diff --git a/Code/client/Systems/RemoteDeathSystem.h b/Code/client/Systems/RemoteDeathSystem.h index 25235388bd..50ce963ccb 100644 --- a/Code/client/Systems/RemoteDeathSystem.h +++ b/Code/client/Systems/RemoteDeathSystem.h @@ -19,7 +19,10 @@ inline void Apply(Actor* apActor, bool aIsDead) noexcept return; if (!aIsDead) + { + spdlog::info("Respawning remote NPC {:X} because its owner has it alive", apActor->formID); apActor->Respawn(); + } else if (CanKill(apActor)) { spdlog::info("Applying loaded remote NPC death {:X}", apActor->formID); From b7d56bef0c2458d2b3946c92677dbe8d81fa960b Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 7 Nov 2026 22:39:07 +0100 Subject: [PATCH 81/97] Stop retrying a refused remote death on every tick RunRemoteUpdates called Kill() on every tick while the owner had an NPC dead and it was alive here. When the game refused the kill, that meant about 100 calls and log lines a second until ownership moved (1300 in 13 seconds in a playtest log). Retry once a second, give up after five tries with one warning, and start over when the body reloads or dies. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit faa5fff8fbe1c508bcf267c71ded468581fe7d79) --- Code/client/Components/RemoteComponent.h | 3 ++ .../Services/Generic/CharacterService.cpp | 34 ++++++++++++++++--- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/Code/client/Components/RemoteComponent.h b/Code/client/Components/RemoteComponent.h index 74e4188cdf..8fe37cdb2b 100644 --- a/Code/client/Components/RemoteComponent.h +++ b/Code/client/Components/RemoteComponent.h @@ -20,4 +20,7 @@ struct RemoteComponent uint32_t OwnershipEpoch; // Canonical death state survives loading and prevents living animation replay on corpses. bool IsDead{false}; + // Retries of a death the game did not take, see CharacterService::RunRemoteUpdates. + uint8_t KillAttempts{0}; + double NextKillAttempt{0.0}; }; diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 9b3fa07b1d..5adf67a471 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -2193,12 +2193,38 @@ void CharacterService::RunRemoteUpdates() noexcept pActor = Cast(pForm); } - const bool cDeadNpc = pActor && !pActor->GetExtension()->IsPlayer() && - (interpolatedEntities.get(entity).IsDead || pActor->IsDead()); + auto& remoteComponent = interpolatedEntities.get(entity); + const bool cDeadNpc = pActor && !pActor->GetExtension()->IsPlayer() && (remoteComponent.IsDead || pActor->IsDead()); // Keep position synchronization, but do not overwrite a corpse's animation graph. InterpolationSystem::Update(pActor, interpolationComponent, cNow, !cDeadNpc); - if (cDeadNpc && interpolatedEntities.get(entity).IsDead && !m_world.any_of(entity)) - RemoteDeathSystem::Apply(pActor, true); + + if (!cDeadNpc || !remoteComponent.IsDead) + continue; + + // A body that is (re)loading or already dead gets a fresh set of tries the next time it is needed. + if (m_world.any_of(entity) || pActor->IsDead()) + { + remoteComponent.KillAttempts = 0; + continue; + } + + // The owner has this NPC dead but it is alive here. Kill() can be refused (essential actors, a kill + // already queued), and retrying on every tick floods the log and the game, so retry once a second + // and give up after a few tries. + constexpr uint8_t cMaxKillAttempts = 5; + constexpr double cKillRetryMs = 1000.0; + if (remoteComponent.KillAttempts >= cMaxKillAttempts || cNow < remoteComponent.NextKillAttempt || + !RemoteDeathSystem::CanKill(pActor)) + continue; + + ++remoteComponent.KillAttempts; + remoteComponent.NextKillAttempt = cNow + cKillRetryMs; + if (remoteComponent.KillAttempts == 1) + spdlog::info("Applying loaded remote NPC death {:X}", pActor->formID); + else if (remoteComponent.KillAttempts == cMaxKillAttempts) + spdlog::warn("Remote NPC {:X} is still alive after {} kills while its owner has it dead, giving up until its body reloads", + pActor->formID, cMaxKillAttempts); + pActor->Kill(); } auto animatedView = m_world.view(); From 3a99822d9592640eb4484239bd798388016b054d Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 7 Nov 2026 22:43:42 +0100 Subject: [PATCH 82/97] Move per-actor spawn diagnostics to debug level Every character the engine loads is constructed with form ID 0 and gets its ID later, so the FormIdZeroActor constructor checks logged a stack trace for each NPC of every cell load (20 to 60 warnings per load). The before/after move placement lines added two more lines per assigned actor. Log those at debug and skip the stack walk when debug is off. The UpdateReference3D form ID 0 check, the move line and actor added/removed placement lines stay at their levels. Co-Authored-By: Claude Opus 5.5 --- Code/client/Games/Skyrim/Actor.cpp | 12 ++++++++---- Code/client/Services/Generic/CharacterService.cpp | 11 +++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/Code/client/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 664327cf3c..79410e7485 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -477,8 +477,12 @@ bool TP_MAKE_THISCALL(HookRemoveSpell, Actor, MagicItem* apSpell) namespace { // Diagnostic for characters constructed or loaded without a valid form ID. -void LogFormIdZeroActor(const char* acpWhere, const Actor* apActor) noexcept +// Every loaded character is constructed with form ID 0 and gets its ID later, so the constructors log at debug only. +void LogFormIdZeroActor(const char* acpWhere, const Actor* apActor, spdlog::level::level_enum aLevel = spdlog::level::warn) noexcept { + if (!spdlog::should_log(aLevel)) + return; + void* frames[24]{}; const USHORT cCount = RtlCaptureStackBackTrace(1, static_cast(std::size(frames)), frames, nullptr); std::string stack; @@ -499,7 +503,7 @@ void LogFormIdZeroActor(const char* acpWhere, const Actor* apActor) noexcept stack += fmt::format(" {}+{:X}", moduleName, cOffset); } const auto* pNpc = Cast(apActor->baseForm); - spdlog::warn("[FormIdZeroActor] {}: actor {}, base {:X} ({}), parent cell {}, flags {:X}, thread {}, stack:{}", acpWhere, fmt::ptr(apActor), + spdlog::log(aLevel, "[FormIdZeroActor] {}: actor {}, base {:X} ({}), parent cell {}, flags {:X}, thread {}, stack:{}", acpWhere, fmt::ptr(apActor), apActor->baseForm ? apActor->baseForm->formID : 0, pNpc && pNpc->fullName.value.AsAscii() ? pNpc->fullName.value.AsAscii() : "", fmt::ptr(apActor->parentCell), apActor->flags, GetCurrentThreadId(), stack); } @@ -511,7 +515,7 @@ Actor* TP_MAKE_THISCALL(HookCharacterConstructor, Actor) TiltedPhoques::ThisCall(RealCharacterConstructor, apThis); if (apThis->formID == 0) - LogFormIdZeroActor("Character constructor", apThis); + LogFormIdZeroActor("Character constructor", apThis, spdlog::level::debug); return apThis; } @@ -523,7 +527,7 @@ Actor* TP_MAKE_THISCALL(HookCharacterConstructor2, Actor, uint8_t aUnk) TiltedPhoques::ThisCall(RealCharacterConstructor2, apThis, aUnk); if (apThis->formID == 0) - LogFormIdZeroActor("Character constructor 2", apThis); + LogFormIdZeroActor("Character constructor 2", apThis, spdlog::level::debug); return apThis; } diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 5adf67a471..d4f3df9285 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -92,8 +92,11 @@ bool CanApplyInventory(Actor* apActor) noexcept return apActor && apActor->currentProcess && apActor->currentProcess->middleProcess && apActor->GetNiNode(); } -void LogActorPlacement(const char* acpWhat, uint32_t aFormId) noexcept +void LogActorPlacement(const char* acpWhat, uint32_t aFormId, spdlog::level::level_enum aLevel = spdlog::level::info) noexcept { + if (!spdlog::should_log(aLevel)) + return; + auto* pActor = Cast(TESForm::GetById(aFormId)); const auto* pPlayer = PlayerCharacter::Get(); if (!pActor || !pPlayer) @@ -104,7 +107,7 @@ void LogActorPlacement(const char* acpWhat, uint32_t aFormId) noexcept const auto* pWorldSpace = pActor->GetWorldSpace(); const auto* pPlayerWorldSpace = pPlayer->GetWorldSpace(); const auto* pExtension = pActor->GetExtension(); - spdlog::info("[ActorPlacement] {} {:X}: cell {:X}, worldspace {:X}, position ({:.0f}, {:.0f}, {:.0f}), remote {}; player cell {:X}, worldspace {:X}, position ({:.0f}, {:.0f}, {:.0f})", + spdlog::log(aLevel, "[ActorPlacement] {} {:X}: cell {:X}, worldspace {:X}, position ({:.0f}, {:.0f}, {:.0f}), remote {}; player cell {:X}, worldspace {:X}, position ({:.0f}, {:.0f}, {:.0f})", acpWhat, aFormId, pCell ? pCell->formID : 0, pWorldSpace ? pWorldSpace->formID : 0, pActor->position.x, pActor->position.y, pActor->position.z, pExtension && pExtension->IsRemote(), pPlayerCell ? pPlayerCell->formID : 0, pPlayerWorldSpace ? pPlayerWorldSpace->formID : 0, pPlayer->position.x, pPlayer->position.y, pPlayer->position.z); @@ -570,9 +573,9 @@ void CharacterService::OnAssignCharacter(const AssignCharacterResponse& acMessag spdlog::info("[ActorPlacement] Moving remote {:X} to owner worldspace {:X}:{:X}, cell {:X}:{:X}, position ({:.0f}, {:.0f}, {:.0f})", pActor->formID, acMessage.WorldSpaceId.ModId, acMessage.WorldSpaceId.BaseId, acMessage.CellId.ModId, acMessage.CellId.BaseId, acMessage.Position.x, acMessage.Position.y, acMessage.Position.z); - LogActorPlacement("Before move", pActor->formID); + LogActorPlacement("Before move", pActor->formID, spdlog::level::debug); MoveActor(pActor, acMessage.WorldSpaceId, acMessage.CellId, acMessage.Position); - LogActorPlacement("After move", pActor->formID); + LogActorPlacement("After move", pActor->formID, spdlog::level::debug); // The owner's leveled pick rides the assignment response for actors we discovered ourselves ApplyLeveledNpcPick(pActor, acMessage.LeveledNpcPickId); From 554265a41b5da44b7db3f5d156683b83b82903f9 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 7 Nov 2026 23:02:10 +0100 Subject: [PATCH 83/97] Fit the F2 menu bar to its labels and match the chat to it The menu bar was fixed at 32vw, so labels that are one long word in some translations (Swedish "Spelarhanterare") couldn't wrap and pushed the last button, Settings, out of the bar. The controls column now grows to the menu's width (at least 32vw, at most the screen) with labels kept on one line, and the chat fills that width without adding its own, so long chat lines still wrap. While trading the column stays at 32vw because the trade dock is placed next to it, and the menu keeps wrapping its labels. Swedish "Player Manager" is now "Hantera Spelare". Co-Authored-By: Claude Opus 5.5 (cherry picked from commit ec2b4b8c287f81ab07330596e95ff14190202fa0) --- .../app/components/root/root.component.scss | 23 +++++++++++++++---- Code/skyrim_ui/src/assets/i18n/sv.json | 2 +- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Code/skyrim_ui/src/app/components/root/root.component.scss b/Code/skyrim_ui/src/app/components/root/root.component.scss index 853c7d3173..70075c8ca7 100644 --- a/Code/skyrim_ui/src/app/components/root/root.component.scss +++ b/Code/skyrim_ui/src/app/components/root/root.component.scss @@ -114,10 +114,20 @@ left: $-size-gap; display: flex; flex-direction: column; - width: 32vw; + // As wide as the menu bar needs for its labels (some translations have long single words that can't wrap), + // but never narrower than the usual 32vw or wider than the screen. The chat only fills the width, see below. + width: max-content; + min-width: 32vw; + max-width: calc(100vw - #{2 * $-size-gap}); bottom: $-size-gap; } +// The trade dock is placed next to a 32vw column, so the controls keep that width while trading and the +// menu wraps its labels in a grid instead (see .trading-controls .app-root-menu). +.app-root-controls.trading-controls { + width: 32vw; +} + .app-root-menu, .app-root-chat { flex: 0 0 auto; @@ -125,14 +135,14 @@ .app-root-menu { box-sizing: border-box; - width: 100%; + width: auto; max-width: 100%; display: flex; height: 4rem; - margin: 0 auto; > button { - flex: 1 1 auto; + flex: 1 0 auto; + white-space: nowrap; padding-top: 0; padding-bottom: 0; } @@ -171,7 +181,10 @@ } .app-root-chat { - width: 100%; + // Fill the column without adding to its width: long chat lines wrap instead of widening the menu column. + box-sizing: border-box; + width: 0; + min-width: 100%; height: 30vh; display: flex; flex-direction: column; diff --git a/Code/skyrim_ui/src/assets/i18n/sv.json b/Code/skyrim_ui/src/assets/i18n/sv.json index 58aff70d4d..ee559bb210 100644 --- a/Code/skyrim_ui/src/assets/i18n/sv.json +++ b/Code/skyrim_ui/src/assets/i18n/sv.json @@ -93,7 +93,7 @@ "DISCONNECT": "Logga ut", "REVEAL_PLAYERS": "Lys upp spelare", "RECONNECT": "Återanslut", - "PLAYER_MANAGER": "Spelarhanterare", + "PLAYER_MANAGER": "Hantera Spelare", "SETTINGS": "Inställningar", "CONNECTION_IN_PROGRESS": "Anslutning pågår..." }, From 4e952a3de99fbb7c0874be745f51f4025407684d Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 7 Nov 2026 23:11:16 +0100 Subject: [PATCH 84/97] Use a wider fixed menu column with word-wrapped labels instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fitting the column to its labels made it far too wide at large font sizes and squeezed the trade window. Go back to a fixed column, now 10% wider than 32vw and reaching to one gap from the trade window, with menu labels that wrap between words. Without a trade the buttons can now shrink (they are flex boxes, so they needed min-width: 0); while trading the columns size to their labels, so a short label like Trade leaves room for one long word like Inställningar. The chat keeps the bar's width. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 32b8a5a39e2dc5af2ad3e99c47791578bdfb4c16) --- .../app/components/root/root.component.scss | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/Code/skyrim_ui/src/app/components/root/root.component.scss b/Code/skyrim_ui/src/app/components/root/root.component.scss index 70075c8ca7..e08cf7735e 100644 --- a/Code/skyrim_ui/src/app/components/root/root.component.scss +++ b/Code/skyrim_ui/src/app/components/root/root.component.scss @@ -4,6 +4,10 @@ @import 'functions/mod'; @import 'functions/rem'; +// Width of the menu bar and chat column (10% over the original 32vw, so longer translations and larger +// fonts fit). The trade dock is placed right of it. +$-root-controls-width: 35.2vw; + :host { display: block; @@ -77,11 +81,11 @@ --trade-edge-gap: 1rem; position: fixed; box-sizing: border-box; - left: calc(32vw + #{4 * $-size-gap}); + left: calc(#{$-root-controls-width} + #{4 * $-size-gap}); right: var(--trade-edge-gap); top: 9vh; bottom: var(--trade-edge-gap); - width: calc(68vw - #{4 * $-size-gap} - var(--trade-edge-gap)); + width: calc(#{100vw - $-root-controls-width} - #{4 * $-size-gap} - var(--trade-edge-gap)); height: calc(91vh - var(--trade-edge-gap)); min-width: 0; min-height: 0; @@ -114,20 +118,11 @@ left: $-size-gap; display: flex; flex-direction: column; - // As wide as the menu bar needs for its labels (some translations have long single words that can't wrap), - // but never narrower than the usual 32vw or wider than the screen. The chat only fills the width, see below. - width: max-content; - min-width: 32vw; - max-width: calc(100vw - #{2 * $-size-gap}); + // Reaches to one gap from the trade dock (which starts 4 gaps past $-root-controls-width). + width: calc(#{$-root-controls-width} + #{2 * $-size-gap}); bottom: $-size-gap; } -// The trade dock is placed next to a 32vw column, so the controls keep that width while trading and the -// menu wraps its labels in a grid instead (see .trading-controls .app-root-menu). -.app-root-controls.trading-controls { - width: 32vw; -} - .app-root-menu, .app-root-chat { flex: 0 0 auto; @@ -135,16 +130,23 @@ .app-root-menu { box-sizing: border-box; - width: auto; + width: 100%; max-width: 100%; display: flex; height: 4rem; + margin: 0 auto; > button { - flex: 1 0 auto; - white-space: nowrap; + flex: 1 1 auto; padding-top: 0; padding-bottom: 0; + // Labels wrap between words ("Hantera / Spelare") instead of pushing the last button out of the bar. + // The buttons are flex boxes themselves (label plus ornament arrows), so without min-width: 0 they + // refuse to shrink below their one-line width. + min-width: 0; + white-space: normal; + text-align: center; + justify-content: center; } } @@ -155,13 +157,13 @@ height: auto; min-height: 4rem; display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + // Columns sized to their labels, so a short label (Trade) leaves room for one long word (Inställningar). + grid-template-columns: repeat(5, auto); align-items: stretch; gap: .2rem; > button { position: relative; - min-width: 0; justify-content: center; text-align: center; white-space: normal; @@ -181,10 +183,7 @@ } .app-root-chat { - // Fill the column without adding to its width: long chat lines wrap instead of widening the menu column. - box-sizing: border-box; - width: 0; - min-width: 100%; + width: 100%; height: 30vh; display: flex; flex-direction: column; From 176e9e1ff21ea765537ee62b77be389885235732 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sat, 7 Nov 2026 23:18:52 +0100 Subject: [PATCH 85/97] Widen the F2 menu bar for words that can't wrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At small window widths or large fonts a menu label word that can't be broken (Swedish "Inställningar") still ran into the ornament arrows. The menu is now one grid column per button with the arrows positioned at the button edges, so labels wrap between words, every column is at least its longest word plus padding, and the column grows past its usual width only when that minimum needs it. The chat keeps the bar's width without adding its own, and the trade dock is placed one gap right of wherever the column ends (measured by RootComponent into --app-root-controls-right). Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 4001078dd8969b8aa727f8fc0ffe6282f1df18b0) --- .../app/components/root/root.component.html | 1 + .../app/components/root/root.component.scss | 58 ++++++++----------- .../src/app/components/root/root.component.ts | 38 +++++++++++- 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/Code/skyrim_ui/src/app/components/root/root.component.html b/Code/skyrim_ui/src/app/components/root/root.component.html index b7c5933216..2d45b1286d 100644 --- a/Code/skyrim_ui/src/app/components/root/root.component.html +++ b/Code/skyrim_ui/src/app/components/root/root.component.html @@ -11,6 +11,7 @@
button { - flex: 1 1 auto; - padding-top: 0; - padding-bottom: 0; - // Labels wrap between words ("Hantera / Spelare") instead of pushing the last button out of the bar. - // The buttons are flex boxes themselves (label plus ornament arrows), so without min-width: 0 they - // refuse to shrink below their one-line width. - min-width: 0; - white-space: normal; - text-align: center; - justify-content: center; - } -} - -.trading-controls .app-root-menu { - box-sizing: border-box; - width: 100%; - max-width: 100%; - height: auto; - min-height: 4rem; display: grid; - // Columns sized to their labels, so a short label (Trade) leaves room for one long word (Inställningar). - grid-template-columns: repeat(5, auto); + grid-auto-flow: column; + grid-auto-columns: auto; align-items: stretch; gap: .2rem; + min-height: 4rem; + margin: 0 auto; > button { position: relative; justify-content: center; text-align: center; white-space: normal; - padding: .2rem .45rem; + // Room for the ornament arrows on both sides, plus a little air before the label. + padding: .2rem calc(.5em + .3rem); - // Ornament arrows must not reserve a second label's worth of horizontal space. + // The ornament arrows sit at the edges instead of being laid out next to the label; laid out inline + // they keep the label from wrapping (the button's minimum width becomes its one-line width). &::before, &::after { position: absolute; @@ -183,7 +168,10 @@ $-root-controls-width: 35.2vw; } .app-root-chat { - width: 100%; + // Fill the column without adding to its minimum width: long chat lines wrap instead of widening it. + box-sizing: border-box; + width: 0; + min-width: 100%; height: 30vh; display: flex; flex-direction: column; diff --git a/Code/skyrim_ui/src/app/components/root/root.component.ts b/Code/skyrim_ui/src/app/components/root/root.component.ts index e566ebf101..5d09df8c37 100644 --- a/Code/skyrim_ui/src/app/components/root/root.component.ts +++ b/Code/skyrim_ui/src/app/components/root/root.component.ts @@ -1,5 +1,11 @@ import { Overlay } from '@angular/cdk/overlay'; -import { Component, OnInit, ViewChild } from '@angular/core'; +import { + Component, + ElementRef, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; import { TranslocoService } from '@ngneat/transloco'; import { takeUntil } from 'rxjs'; import { environment } from '../../../environments/environment'; @@ -36,7 +42,7 @@ const REVEAL_EFFECT_DURATION_MS = 10000 // todo: pass value from C++? host: { 'data-app-root-game': environment.game.toString() }, providers: [DestroyService], }) -export class RootComponent implements OnInit { +export class RootComponent implements OnInit, OnDestroy { /* ### ENUMS ### */ readonly RootView = View; @@ -53,6 +59,29 @@ export class RootComponent implements OnInit { @ViewChild('chat') private chatComp!: ChatComponent; @ViewChild(GroupComponent) private groupComponent: GroupComponent; + // The menu column grows past its usual width when a label word can't be broken, so the trade dock is + // placed from where the column actually ends, published as --app-root-controls-right. + private controlsObserver?: ResizeObserver; + @ViewChild('controls') private set controlsElement( + ref: ElementRef | undefined, + ) { + this.controlsObserver?.disconnect(); + this.controlsObserver = undefined; + if (!ref) { + return; + } + + const element = ref.nativeElement; + this.controlsObserver = new ResizeObserver(() => { + // Layout offsets, not getBoundingClientRect, so the slide-in animation's transform doesn't move the dock. + this.host.nativeElement.style.setProperty( + '--app-root-controls-right', + `${element.offsetLeft + element.offsetWidth}px`, + ); + }); + this.controlsObserver.observe(element); + } + public constructor( private readonly destroy$: DestroyService, // Created here so name tag settings reach the game as soon as the UI loads. @@ -66,6 +95,7 @@ export class RootComponent implements OnInit { // Created at start so voice volumes and settings reach the game before any menu is opened private readonly voipService: VoipService, public readonly overlay: Overlay, // used for mockup + private readonly host: ElementRef, ) { this.translocoService.setActiveLang( this.settingService.settings.language.getValue(), @@ -78,6 +108,10 @@ export class RootComponent implements OnInit { this.onFontSizeSubscription(); } + public ngOnDestroy(): void { + this.controlsObserver?.disconnect(); + } + public onInGameStateSubscription() { this.client.inGameStateChange .pipe(takeUntil(this.destroy$)) From a5c32cae4abac95b8c9c137d964bfcfe6dc4d7d9 Mon Sep 17 00:00:00 2001 From: absol89 Date: Sun, 8 Nov 2026 00:01:11 +0100 Subject: [PATCH 86/97] Keep trading readable on small screens and large text Add a non-overlapping minimize toolbar, a font-aware popup and compact inventory layout, and scrolling for short screens. Preserve the trade when minimized and update the Angular test mock. (cherry picked from commit 17ac04d8e6d53c59c863710d60bb067e2bb67bad) --- .../app/components/root/root.component.html | 2 +- .../app/components/root/root.component.scss | 20 ++++--- .../src/app/components/root/root.component.ts | 55 +++++++++++++++---- .../trade-popup/trade-popup.component.html | 8 ++- .../trade-popup/trade-popup.component.scss | 30 ++++++++-- .../trade-popup/trade-popup.component.ts | 3 +- Code/skyrim_ui/src/assets/i18n/en.json | 1 + Code/skyrim_ui/src/assets/i18n/sv.json | 1 + Code/skyrim_ui/test/trade-ui.test.cjs | 2 +- 9 files changed, 94 insertions(+), 28 deletions(-) diff --git a/Code/skyrim_ui/src/app/components/root/root.component.html b/Code/skyrim_ui/src/app/components/root/root.component.html index 2d45b1286d..0806921bef 100644 --- a/Code/skyrim_ui/src/app/components/root/root.component.html +++ b/Code/skyrim_ui/src/app/components/root/root.component.html @@ -125,7 +125,7 @@
- +
diff --git a/Code/skyrim_ui/src/app/components/root/root.component.scss b/Code/skyrim_ui/src/app/components/root/root.component.scss index 08e9305c1e..29363428a5 100644 --- a/Code/skyrim_ui/src/app/components/root/root.component.scss +++ b/Code/skyrim_ui/src/app/components/root/root.component.scss @@ -4,10 +4,9 @@ @import 'functions/mod'; @import 'functions/rem'; -// Usual width of the menu bar and chat column (10% over the original 32vw). The column grows past it when -// a label word that can't be broken would not fit (long translations, large fonts); the trade dock is -// placed right of wherever the column ends, see --app-root-controls-right in RootComponent. -$-root-controls-width: 35.2vw; +// Usual width of the menu bar and chat column. The column grows past it only when a label word that can't +// be broken would not fit (long translations, large fonts); the trade dock follows it, see .trade-dock. +$-root-controls-width: 32vw; :host { display: block; @@ -82,8 +81,9 @@ $-root-controls-width: 35.2vw; --trade-edge-gap: 1rem; position: fixed; box-sizing: border-box; - // One gap right of the controls column (its usual edge until RootComponent has measured it). - left: calc(var(--app-root-controls-right, calc(#{$-root-controls-width} + #{3 * $-size-gap})) + #{$-size-gap}); + // Beside the controls column, one gap right of where it ends (its usual edge until RootComponent has + // measured it). + left: calc(var(--app-root-controls-right, calc(#{$-root-controls-width} + #{$-size-gap})) + #{$-size-gap}); right: var(--trade-edge-gap); top: 9vh; bottom: var(--trade-edge-gap); @@ -92,6 +92,12 @@ $-root-controls-width: 35.2vw; pointer-events: none; } +// Cover the controls when there is not enough room alongside them. +:host[data-trade-dock='popup'] .trade-dock { + left: var(--trade-edge-gap); + top: var(--trade-edge-gap); +} + ::ng-deep { .app-root-popups { > app-popup > app-window { @@ -118,7 +124,7 @@ $-root-controls-width: 35.2vw; left: $-size-gap; display: flex; flex-direction: column; - width: calc(#{$-root-controls-width} + #{2 * $-size-gap}); + width: $-root-controls-width; // Never narrower than the menu needs for its longest unbreakable words (the chat doesn't count, see below). min-width: min-content; max-width: calc(100vw - #{2 * $-size-gap}); diff --git a/Code/skyrim_ui/src/app/components/root/root.component.ts b/Code/skyrim_ui/src/app/components/root/root.component.ts index 5d09df8c37..10bd75eac6 100644 --- a/Code/skyrim_ui/src/app/components/root/root.component.ts +++ b/Code/skyrim_ui/src/app/components/root/root.component.ts @@ -2,6 +2,7 @@ import { Overlay } from '@angular/cdk/overlay'; import { Component, ElementRef, + HostListener, OnDestroy, OnInit, ViewChild, @@ -30,6 +31,10 @@ import { VoipService } from '../../services/voip.service'; const REVEAL_EFFECT_DURATION_MS = 10000 // todo: pass value from C++? +// Narrowest the trade window stays usable (its item table alone has 26rem of fixed columns). In rem, so a +// larger font needs a wider dock; with less room beside the menu column the dock becomes a popup. +const TRADE_DOCK_MIN_WIDTH_REM = 40; + @Component({ selector: 'app-root', templateUrl: './root.component.html', @@ -60,26 +65,23 @@ export class RootComponent implements OnInit, OnDestroy { @ViewChild(GroupComponent) private groupComponent: GroupComponent; // The menu column grows past its usual width when a label word can't be broken, so the trade dock is - // placed from where the column actually ends, published as --app-root-controls-right. + // placed from where the column actually ends. When the space right of it is too narrow for the trade + // window, the dock becomes a popup over the whole screen that can be minimized to use the chat. + // See .trade-dock in the stylesheet. + private controls?: HTMLElement; private controlsObserver?: ResizeObserver; @ViewChild('controls') private set controlsElement( ref: ElementRef | undefined, ) { this.controlsObserver?.disconnect(); this.controlsObserver = undefined; - if (!ref) { + this.controls = ref?.nativeElement; + if (!this.controls) { return; } - const element = ref.nativeElement; - this.controlsObserver = new ResizeObserver(() => { - // Layout offsets, not getBoundingClientRect, so the slide-in animation's transform doesn't move the dock. - this.host.nativeElement.style.setProperty( - '--app-root-controls-right', - `${element.offsetLeft + element.offsetWidth}px`, - ); - }); - this.controlsObserver.observe(element); + this.controlsObserver = new ResizeObserver(() => this.layoutTradeDock()); + this.controlsObserver.observe(this.controls); } public constructor( @@ -112,6 +114,29 @@ export class RootComponent implements OnInit, OnDestroy { this.controlsObserver?.disconnect(); } + @HostListener('window:resize') + public layoutTradeDock(): void { + const controls = this.controls; + if (!controls) { + return; + } + + // Layout offsets, not getBoundingClientRect, so the slide-in animation's transform doesn't move the dock. + const controlsRight = controls.offsetLeft + controls.offsetWidth; + + const rootFontPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + const besideWidth = window.innerWidth - controlsRight - 2 * rootFontPx; + const placement = besideWidth >= TRADE_DOCK_MIN_WIDTH_REM * rootFontPx ? 'beside' : 'popup'; + + const style = this.host.nativeElement.style; + style.setProperty('--app-root-controls-right', `${controlsRight}px`); + this.host.nativeElement.setAttribute('data-trade-dock', placement); + // Account for both frame borders and padding before choosing the inventory layout. + const dockWidth = placement === 'popup' ? window.innerWidth - 2 * rootFontPx : besideWidth; + const contentWidth = dockWidth - 52 - 2 * rootFontPx; + this.host.nativeElement.setAttribute('data-trade-compact', String(contentWidth < 40 * rootFontPx)); + } + public onInGameStateSubscription() { this.client.inGameStateChange .pipe(takeUntil(this.destroy$)) @@ -147,6 +172,7 @@ export class RootComponent implements OnInit, OnDestroy { ) .subscribe(size => { document.documentElement.setAttribute('style', `font-size: ${size}px;`); + this.layoutTradeDock(); }); } @@ -160,6 +186,13 @@ export class RootComponent implements OnInit, OnDestroy { } } + /** Hides the trade window without ending the trade; the menu's Trade button brings it back. */ + public minimizeTrade() { + if (this.uiRepository.getView() === View.TRADE) { + this.setView(null); + } + } + public closeView() { if (this.uiRepository.getView() === View.TRADE) { // F2 hides the overlay through its activation animation; keep the exchange selected for reopening. diff --git a/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.html b/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.html index a000ffd141..db4af13dc7 100644 --- a/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.html +++ b/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.html @@ -1,4 +1,9 @@ +
+ +
@@ -88,8 +93,9 @@

{{ 'COMPONENT.TRADE_MENU.INVENTORY_TITLE' | transloco {{ 'COMPONENT.TRADE_MENU.EQUIPPED_ITEM_WARNING' | transloco }}

- {{ item.available }} + {{ 'COMPONENT.TRADE_MENU.ITEM_COLUMNS.AVAILABLE' | transloco }}: {{ item.available }} + {{ 'COMPONENT.TRADE_MENU.ITEM_COLUMNS.OFFERED' | transloco }}