diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index bdc3efcbfd..901322eebd 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -43,10 +43,10 @@ jobs: git submodule sync --recursive git submodule update --init --force --recursive --depth=1 - - name: Checkout master on tag push + - name: Prepare tagged release checkout if: github.ref_type == 'tag' run: | - test "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" + # Build the tagged commit; the local branch name enables release packaging. git checkout -B master HEAD - name: Cache xmake dependencies diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5388813178..703da590eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,8 @@ jobs: upload-build-for-next-job: true publish: + # Retagging builds CI artifacts without changing an existing release or its assets. + if: ${{ github.event.created }} needs: [build-windows, build-linux] runs-on: ubuntu-24.04 permissions: diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 67a5557594..f1229ae71a 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -48,11 +48,16 @@ jobs: git submodule sync --recursive git submodule update --init --force --recursive --depth=1 - - name: Checkout master on tag push + - name: Prepare tagged release checkout if: github.ref_type == 'tag' run: | - test "$(git rev-parse HEAD)" = "$(git rev-parse origin/master)" - git checkout -B master HEAD + # Build the tagged commit. On master the local branch name makes it a release build (IS_MASTER: no + # debug keys or console). A tag on any other branch builds like that branch, keeping F6/F7 and the console. + if git merge-base --is-ancestor HEAD origin/master 2>/dev/null; then + git checkout -B master HEAD + else + git checkout -B "tag/${GITHUB_REF_NAME}" HEAD + fi - name: Resolve artifact version if: ${{ inputs.upload-build-for-next-job }} 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/Components/RemoteComponent.h b/Code/client/Components/RemoteComponent.h index 9704cca749..8fe37cdb2b 100644 --- a/Code/client/Components/RemoteComponent.h +++ b/Code/client/Components/RemoteComponent.h @@ -18,4 +18,9 @@ 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}; + // 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/Components/WaitingFor3D.h b/Code/client/Components/WaitingFor3D.h index 112393b829..dcd9f54616 100644 --- a/Code/client/Components/WaitingFor3D.h +++ b/Code/client/Components/WaitingFor3D.h @@ -5,6 +5,7 @@ #endif #include +#include struct WaitingFor3D { @@ -14,4 +15,6 @@ struct WaitingFor3D } CharacterSpawnRequest SpawnRequest{}; + // Equipment changes relayed before the 3D loaded, replayed after the spawn request's inventory is applied + Vector PendingEquipmentChanges{}; }; 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; } diff --git a/Code/client/Events/ActivateEvent.h b/Code/client/Events/ActivateEvent.h index 02b38448c9..fbaa2b0796 100644 --- a/Code/client/Events/ActivateEvent.h +++ b/Code/client/Events/ActivateEvent.h @@ -6,12 +6,17 @@ struct TESBoundObject; /** * @brief In-game activation (i.e. interacting with an object). + * + * Handled after a queue, when the game may have deleted the object or the activator: check that ObjectId and + * ActivatorId still name them (TESForm::GetById) before using the pointers. */ struct ActivateEvent { ActivateEvent(TESObjectREFR* apObject, Actor* apActivator, TESBoundObject* apObjectToGet, int32_t aCount, bool aDefaultProcessing, uint8_t aUnk1, TESObjectREFR::OpenState aPreActivationOpenState, bool aActivateFlag = false) : pObject(apObject) , pActivator(apActivator) + , ObjectId(apObject ? apObject->formID : 0) + , ActivatorId(apActivator ? apActivator->formID : 0) , pObjectToGet(apObjectToGet) , Count(aCount) , DefaultProcessing(aDefaultProcessing) @@ -23,6 +28,8 @@ struct ActivateEvent TESObjectREFR* pObject; Actor* pActivator; + uint32_t ObjectId; + uint32_t ActivatorId; TESBoundObject* pObjectToGet; int32_t Count; bool DefaultProcessing; diff --git a/Code/client/Events/AddTargetEvent.h b/Code/client/Events/AddTargetEvent.h index fc7baf8317..e6df8f259d 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 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/Events/DropItemEvent.h b/Code/client/Events/DropItemEvent.h new file mode 100644 index 0000000000..70bf66c067 --- /dev/null +++ b/Code/client/Events/DropItemEvent.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +/** + * @brief Dispatched when the local player drops an item into the world. + */ +struct DropItemEvent +{ + DropItemEvent(const uint32_t aFormId, Inventory::Entry aItem) + : FormId(aFormId) + , Item(std::move(aItem)) + { + } + + // Form id of the reference the engine placed in the world. + uint32_t FormId{}; + Inventory::Entry Item{}; +}; diff --git a/Code/client/Events/EquipmentChangeEvent.h b/Code/client/Events/EquipmentChangeEvent.h index d61fd029b6..6489e16ff2 100644 --- a/Code/client/Events/EquipmentChangeEvent.h +++ b/Code/client/Events/EquipmentChangeEvent.h @@ -6,8 +6,6 @@ struct EquipmentChangeEvent { uint32_t ActorId = 0; - uint32_t ServerId = 0; - uint32_t OwnershipEpoch = 0; uint32_t ItemId = 0; uint32_t EquipSlotId = 0; uint32_t Count = 0; diff --git a/Code/client/Events/InventoryChangeEvent.h b/Code/client/Events/InventoryChangeEvent.h index 4862a50104..b2520f9dbf 100644 --- a/Code/client/Events/InventoryChangeEvent.h +++ b/Code/client/Events/InventoryChangeEvent.h @@ -5,8 +5,6 @@ /** * @brief Dispatched when the contents of an object or actor inventory changes locally. - * - * The event has a Drop member variable, since dropped items need to be handled differently. */ struct InventoryChangeEvent { @@ -16,25 +14,16 @@ struct InventoryChangeEvent { } - InventoryChangeEvent(const uint32_t aFormId, Inventory::Entry arItem, bool aDrop) + InventoryChangeEvent(const uint32_t aFormId, Inventory::Entry arItem, bool aUpdateClients) : FormId(aFormId) , Item(std::move(arItem)) - , Drop(aDrop) - { - } - - InventoryChangeEvent(const uint32_t aFormId, Inventory::Entry arItem, bool aDrop, bool aUpdateClients) - : FormId(aFormId) - , Item(std::move(arItem)) - , Drop(aDrop) , UpdateClients(aUpdateClients) { } uint32_t FormId{}; - uint32_t ServerId{}; - uint32_t OwnershipEpoch{}; Inventory::Entry Item{}; - bool Drop = false; bool UpdateClients = true; + // The item moved to or from the local player, e.g. looting or pickpocketing an actor owned by someone else + bool IsPlayerTransfer = false; }; diff --git a/Code/client/Events/ReferencePickUpEvent.h b/Code/client/Events/ReferencePickUpEvent.h new file mode 100644 index 0000000000..2492338306 --- /dev/null +++ b/Code/client/Events/ReferencePickUpEvent.h @@ -0,0 +1,14 @@ +#pragma once + +/** + * @brief Dispatched when a local actor picks up a temporary reference, such as a dropped item. + */ +struct ReferencePickUpEvent +{ + explicit ReferencePickUpEvent(const uint32_t aFormId) + : FormId(aFormId) + { + } + + uint32_t FormId{}; +}; diff --git a/Code/client/Events/ScriptAnimationEvent.h b/Code/client/Events/ScriptAnimationEvent.h index 514a422264..f41f5c8483 100644 --- a/Code/client/Events/ScriptAnimationEvent.h +++ b/Code/client/Events/ScriptAnimationEvent.h @@ -5,14 +5,19 @@ */ struct ScriptAnimationEvent { - ScriptAnimationEvent(uint32_t aFormID, String aAnimation, String aEventName) + ScriptAnimationEvent(uint32_t aFormID, String aAnimation, String aEventName, bool aIsSubGraph = false) : FormID(aFormID) , Animation(aAnimation) , EventName(aEventName) + , IsSubGraph(aIsSubGraph) { } + // Local form id of the animating reference; ObjectService::OnScriptAnimationEvent + // translates it to a server id before it goes on the wire. uint32_t FormID; String Animation; String EventName; + // Sent to the graphs of what the actor wears (Actor.PlaySubGraphAnimation), not its own. + bool IsSubGraph; }; diff --git a/Code/client/Events/WaveCommandEvent.h b/Code/client/Events/WaveCommandEvent.h new file mode 100644 index 0000000000..2d0ab81fe8 --- /dev/null +++ b/Code/client/Events/WaveCommandEvent.h @@ -0,0 +1,8 @@ +#pragma once + +/** + * @brief Dispatched when the local player uses the /wave chat command. + */ +struct WaveCommandEvent +{ +}; diff --git a/Code/client/Games/Animation.cpp b/Code/client/Games/Animation.cpp index 0714b2b88d..69812d566a 100644 --- a/Code/client/Games/Animation.cpp +++ b/Code/client/Games/Animation.cpp @@ -19,6 +19,8 @@ static TPerformAction* RealPerformAction; // TODO: make scoped override thread_local bool g_forceAnimation = false; +// Result of the last action the game performed while g_forceAnimation was set (-1: none). +thread_local int g_forcedActionResult = -1; uint8_t TP_MAKE_THISCALL(HookPerformAction, ActorMediator, TESActionData* apAction) { @@ -40,6 +42,9 @@ uint8_t TP_MAKE_THISCALL(HookPerformAction, ActorMediator, TESActionData* apActi const auto res = TiltedPhoques::ThisCall(RealPerformAction, apThis, apAction); + if (g_forceAnimation) + g_forcedActionResult = res; + // spdlog::debug("Action event name: {}, target name: {}", apAction->eventName.AsAscii(), apAction->targetEventName.AsAscii()); // This is a weird case where it gets spammed and doesn't do much, not sure if it still needs to be sent over the network diff --git a/Code/client/Games/Memory.cpp b/Code/client/Games/Memory.cpp index d5553ff776..d070c980f7 100644 --- a/Code/client/Games/Memory.cpp +++ b/Code/client/Games/Memory.cpp @@ -130,9 +130,49 @@ void* Hook_aligned_malloc(size_t aSize, size_t aAlignment) return mi_malloc_aligned(aSize, aAlignment); } +// Playtest builds, not meant for upstream. mimalloc's secure mode reports a double free (EAGAIN), a corrupted free list +// (EFAULT) or an invalid pointer (EINVAL) where it is detected; the handler then crashes on the spot, so CrashLogger +// records the stack of the code that freed the block instead of a hang somewhere later. Messages go to +// logs\mimalloc_errors.log through the Windows file API, as the mimalloc heap cannot be trusted at that point. +static constexpr DWORD kMimallocErrorException = 0xE04D494D; // "MIM" + +static void WriteMimallocLog(const char* acpText) noexcept +{ + const HANDLE cFile = CreateFileA("Data\\SkyrimTogetherReborn\\logs\\mimalloc_errors.log", FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (cFile == INVALID_HANDLE_VALUE) + return; + + DWORD written = 0; + WriteFile(cFile, acpText, static_cast(strlen(acpText)), &written, nullptr); + CloseHandle(cFile); +} + +static void OnMimallocOutput(const char* acpMessage, void*) noexcept +{ + OutputDebugStringA(acpMessage); + WriteMimallocLog(acpMessage); +} + +static void OnMimallocError(int aError, void*) noexcept +{ + char line[96]; + sprintf_s(line, "mimalloc error %d on thread %lu\r\n", aError, GetCurrentThreadId()); + WriteMimallocLog(line); + + // An invalid pointer is a free of memory mimalloc never handed out. No playtest session logged one by mistake, so it + // crashes too. + if (aError == EAGAIN || aError == EFAULT || aError == EINVAL) + RaiseException(kMimallocErrorException, EXCEPTION_NONCONTINUABLE, 0, nullptr); +} + static TiltedPhoques::Initializer s_memoryHooks( []() { + mi_register_output(&OnMimallocOutput, nullptr); + mi_register_error(&OnMimallocError, nullptr); + mi_option_enable(mi_option_show_errors); + POINTER_SKYRIMSE(TFormAllocate, s_formAllocate, 68115); POINTER_SKYRIMSE(TFormFree, s_formFree, 68117); diff --git a/Code/client/Games/Misc/BSScript.cpp b/Code/client/Games/Misc/BSScript.cpp index 4eef585e3e..040a6705bc 100644 --- a/Code/client/Games/Misc/BSScript.cpp +++ b/Code/client/Games/Misc/BSScript.cpp @@ -9,6 +9,10 @@ #include #include #include +#include +#include +#include +#include TP_THIS_FUNCTION(TRegisterPapyrusFunction, void, BSScript::IVirtualMachine, NativeFunction*); TP_THIS_FUNCTION(TBindEverythingToScript, void, BSScript::IVirtualMachine*); @@ -20,6 +24,60 @@ TBindEverythingToScript* RealBindEverythingToScript = nullptr; TSignaturesMatch* RealSignaturesMatch = nullptr; TCompareVariables* RealCompareVariables = nullptr; +// Papyrus global native: Debug.SendAnimationEvent(ObjectReference arRef, string asEventName). +// ABI matches the repo's PapyrusFunction<> convention: (VM*, stackId, this/tag, args...). +using TDebugSendAnimationEvent = void(BSScript::IVirtualMachine*, uint32_t, void*, TESObjectREFR*, BSFixedString*); +static TDebugSendAnimationEvent* RealDebugSendAnimationEvent = nullptr; + +static void HookDebugSendAnimationEvent(BSScript::IVirtualMachine* apVm, uint32_t aStackId, void* apTag, TESObjectREFR* apRef, BSFixedString* apEventName) +{ + const char* pcEventName = apEventName ? apEventName->AsAscii() : nullptr; + if (apRef && pcEventName && pcEventName[0] != '\0') + { + spdlog::debug("Debug.SendAnimationEvent captured: ref {:X}, event {}", apRef->formID, pcEventName); + World::Get().GetRunner().Trigger(ScriptAnimationEvent(apRef->formID, String{}, pcEventName)); + } + + RealDebugSendAnimationEvent(apVm, aStackId, apTag, apRef, apEventName); +} + +// Papyrus native: Actor.PlaySubGraphAnimation(string asEventName). It reaches the graphs of what the actor +// wears, such as a skin armor that fades itself in (Weredragon), which the actor's own animation sync doesn't cover. +using TPlaySubGraphAnimation = void(BSScript::IVirtualMachine*, uint32_t, TESObjectREFR*, BSFixedString*); +static TPlaySubGraphAnimation* RealPlaySubGraphAnimation = nullptr; + +static void HookPlaySubGraphAnimation(BSScript::IVirtualMachine* apVm, uint32_t aStackId, TESObjectREFR* apRef, BSFixedString* apEventName) +{ + const char* pcEventName = apEventName ? apEventName->AsAscii() : nullptr; + Actor* pActor = Cast(apRef); + // Remote copies replay what their owner sends, so only the owner's call is sent. + if (pActor && pActor->GetExtension()->IsLocal() && pcEventName && pcEventName[0] != '\0') + { + spdlog::info("Sending subgraph event {} of {:X} to the other players", pcEventName, apRef->formID); + World::Get().GetRunner().Trigger(ScriptAnimationEvent(apRef->formID, String{}, pcEventName, true)); + } + + RealPlaySubGraphAnimation(apVm, aStackId, apRef, apEventName); +} + +bool SendSubGraphEventUnsynced(TESObjectREFR* apRef, const char* acpEventName) noexcept +{ + if (!apRef || !acpEventName) + return false; + + const auto spManager = apRef->animationGraphHolder.GetAnimationGraphManagerPtr(); + if (!spManager) + return false; + + // 63372 is what the PlaySubGraphAnimation native calls with the actor's graph manager. It sends the event to the + // graphs of what the actor wears and returns whether one of them handled it. It takes the manager's lock itself. + using TSendEventToSubGraphs = bool(BSAnimationGraphManager*, const BSFixedString*); + POINTER_SKYRIMSE(TSendEventToSubGraphs, s_sendEventToSubGraphs, 63372); + + const BSFixedString cEventName(acpEventName); + return s_sendEventToSubGraphs.Get()(spManager.get(), &cEventName); +} + void TP_MAKE_THISCALL(HookRegisterPapyrusFunction, BSScript::IVirtualMachine, NativeFunction* apFunction) { auto& runner = World::Get().GetRunner(); @@ -28,6 +86,20 @@ void TP_MAKE_THISCALL(HookRegisterPapyrusFunction, BSScript::IVirtualMachine, Na runner.Trigger(std::move(event)); + if (!RealDebugSendAnimationEvent && apFunction->functionAddress && !strcmp(apFunction->typeName.AsAscii(), "Debug") && !strcmp(apFunction->functionName.AsAscii(), "SendAnimationEvent")) + { + RealDebugSendAnimationEvent = reinterpret_cast(apFunction->functionAddress); + TP_HOOK_IMMEDIATE(&RealDebugSendAnimationEvent, HookDebugSendAnimationEvent); + spdlog::info("Hooked Debug.SendAnimationEvent at {}", apFunction->functionAddress); + } + + if (!RealPlaySubGraphAnimation && apFunction->functionAddress && !strcmp(apFunction->typeName.AsAscii(), "Actor") && !strcmp(apFunction->functionName.AsAscii(), "PlaySubGraphAnimation")) + { + RealPlaySubGraphAnimation = reinterpret_cast(apFunction->functionAddress); + TP_HOOK_IMMEDIATE(&RealPlaySubGraphAnimation, HookPlaySubGraphAnimation); + spdlog::info("Hooked Actor.PlaySubGraphAnimation at {}", apFunction->functionAddress); + } + TiltedPhoques::ThisCall(RealRegisterPapyrusFunction, apThis, apFunction); } diff --git a/Code/client/Games/Misc/SubtitleManager.cpp b/Code/client/Games/Misc/SubtitleManager.cpp index 99cac10da0..ce4da24386 100644 --- a/Code/client/Games/Misc/SubtitleManager.cpp +++ b/Code/client/Games/Misc/SubtitleManager.cpp @@ -56,12 +56,24 @@ void TP_MAKE_THISCALL(HookShowSubtitle, SubtitleManager, TESObjectREFR* apSpeake TiltedPhoques::ThisCall(RealShowSubtitle, apThis, apSpeaker, apSubtitleText, aIsInDialogue); } +void SubtitleManager::InstallHooks() noexcept +{ + // Not installed with the startup hooks: SKSE plugins load after those, and some (po3's Floating + // Subtitles) hook this prologue by copying its first 5 bytes verbatim, which breaks on our jump. + // Hooked after them, MinHook relocates their jump instead and both hooks run. + static bool s_installed = false; + if (s_installed) + return; + s_installed = true; + + TP_HOOK_IMMEDIATE(&RealShowSubtitle, HookShowSubtitle); +} + static TiltedPhoques::Initializer s_subtitleHooks( []() { POINTER_SKYRIMSE(TShowSubtitle, s_showSubtitle, 52626); + // Usable before InstallHooks; it then points at the trampoline. RealShowSubtitle = s_showSubtitle.Get(); - - TP_HOOK(&RealShowSubtitle, HookShowSubtitle); }); diff --git a/Code/client/Games/Misc/SubtitleManager.h b/Code/client/Games/Misc/SubtitleManager.h index 3d590a6682..49a7708767 100644 --- a/Code/client/Games/Misc/SubtitleManager.h +++ b/Code/client/Games/Misc/SubtitleManager.h @@ -5,6 +5,8 @@ struct TESObjectREFR; struct SubtitleManager { static SubtitleManager* Get() noexcept; + // Installs the ShowSubtitle hook once. Called from the first frame, after SKSE plugins have loaded. + static void InstallHooks() noexcept; // TODO(cosideci): confirm usage of bool void ShowSubtitle(TESObjectREFR* apSpeaker, const char* apSubtitleText, TESTopicInfo* apTopicInfo, bool aIsInDialogue = false) noexcept; diff --git a/Code/client/Games/ModManager.cpp b/Code/client/Games/ModManager.cpp index a8f0c45623..3110c6a4be 100644 --- a/Code/client/Games/ModManager.cpp +++ b/Code/client/Games/ModManager.cpp @@ -3,6 +3,7 @@ #include #include +#include ModManager* ModManager::Get() noexcept { @@ -47,6 +48,20 @@ uint32_t ModManager::Spawn(NiPoint3& aPosition, NiPoint3& aRotation, TESObjectCE return refrHandle; } +// Places a new temporary reference the way the engine places a dropped item. +// For an exterior, pass the worldspace and no cell; the engine picks the cell from the position. +TESObjectREFR* ModManager::SpawnReference(TESBoundObject* apBaseForm, NiPoint3& aPosition, NiPoint3& aRotation, TESObjectCELL* apParentCell, TESWorldSpace* apWorldSpace) noexcept +{ + uint32_t refrHandle = 0; + + TiltedPhoques::ThisCall(RealSpawnNewREFR, this, refrHandle, apBaseForm, &aPosition, &aRotation, apParentCell, apWorldSpace, nullptr, 0, 0, static_cast(0), static_cast(1)); + + if (!refrHandle) + return nullptr; + + return TESObjectREFR::GetByHandle(refrHandle); +} + Mod* ModManager::GetByName(const char* acpName) const noexcept { auto pEntry = &mods.entry; diff --git a/Code/client/Games/References.h b/Code/client/Games/References.h index b2df69b5e8..e072e72e89 100644 --- a/Code/client/Games/References.h +++ b/Code/client/Games/References.h @@ -15,6 +15,10 @@ float* GetGreetDistance() noexcept; float* GetVATSSelectTargetTimeMultiplier() noexcept; } // namespace Settings +// Actor.PlaySubGraphAnimation without sending it to the other players. Returns whether a worn item's graph took the +// event, which it can't while that item isn't loaded. +bool SendSubGraphEventUnsynced(TESObjectREFR* apRef, const char* acpEventName) noexcept; + void FadeOutGame(bool aFadingOut, bool aBlackFade, float aFadeDuration, bool aRemainVisible, float aSecondsToFade) noexcept; struct ExActor : Actor, ActorExtension 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/Games/Skyrim/Actor.cpp b/Code/client/Games/Skyrim/Actor.cpp index 9e1933e862..8a5a09ddfa 100644 --- a/Code/client/Games/Skyrim/Actor.cpp +++ b/Code/client/Games/Skyrim/Actor.cpp @@ -1,4 +1,5 @@ #include +#include #include #include #include @@ -15,10 +16,13 @@ #include #include +#include +#include #include #include #include #include +#include #include #include @@ -44,6 +48,8 @@ #include #include #include +#include +#include #include #include @@ -58,17 +64,10 @@ namespace { -void QueueActorInventoryChange(Actor* apActor, InventoryChangeEvent aEvent, TESObjectREFR* apTransferReference = nullptr) +// AI updates run on job threads, so ownership is resolved when the runner hands the event to the main thread +void QueueActorInventoryChange(InventoryChangeEvent aEvent, TESObjectREFR* apTransferReference = nullptr) { - auto ownershipToken = Utils::GetLocalOwnershipToken(apActor->formID); - if (!ownershipToken && apTransferReference == PlayerCharacter::Get()) - ownershipToken = Utils::GetRemoteOwnershipToken(apActor->formID); - - if (!ownershipToken) - return; - - aEvent.ServerId = ownershipToken->ServerId; - aEvent.OwnershipEpoch = ownershipToken->OwnershipEpoch; + aEvent.IsPlayerTransfer = apTransferReference && apTransferReference == PlayerCharacter::Get(); World::Get().GetRunner().Trigger(std::move(aEvent)); } } @@ -129,6 +128,13 @@ 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(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 +149,13 @@ 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 TIsHostileToActor* RealIsHostileToActor = nullptr; +static TCheckValidTarget* RealCheckValidTarget = nullptr; +static TApplyHazardToPlayer* RealApplyHazardToPlayer = nullptr; static TSetPosition* RealSetPosition = nullptr; static TActorProcess* RealActorProcess = nullptr; @@ -234,7 +247,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); @@ -295,21 +315,37 @@ ExPlayerCharacter* Actor::AsExPlayerCharacter() noexcept } extern thread_local bool g_forceAnimation; +extern thread_local int g_forcedActionResult; void Actor::SetWeaponDrawnEx(bool aDraw) noexcept { spdlog::debug("Setting weapon drawn: {:X}:{}, current state: {}", formID, aDraw, actorState.IsWeaponDrawn()); - if (actorState.IsWeaponDrawn() == aDraw) + // When the actor already reports the requested state, the internal weapon state is flipped so that + // the native call re-issues the draw/sheathe action and the animation is forced again. + const bool alreadyMatching = actorState.IsWeaponDrawn() == aDraw; + if (alreadyMatching) { actorState.SetWeaponDrawn(!aDraw); spdlog::debug("Setting weapon drawn after update: {:X}:{}, current state: {}", formID, aDraw, actorState.IsWeaponDrawn()); } + g_forcedActionResult = -1; g_forceAnimation = true; SetWeaponDrawn(aDraw); g_forceAnimation = false; + + if (alreadyMatching && g_forcedActionResult != 1) + { + // The behavior graph refused the forced action (for example "Unequip" while already sheathed), or + // no action was issued. Without this the actor stays in a transitional state (wants to sheathe / + // wants to draw) that the graph never completes, and once it gains ownership its AI can no longer + // draw the weapon (#810). Restore the stable state that was requested instead. + actorState.SetWeaponDrawn(aDraw); + + spdlog::debug("Forced weapon draw action refused, restoring state: {:X}:{}, current state: {}", formID, aDraw, actorState.IsWeaponDrawn()); + } } static thread_local bool s_execInitPackage = false; @@ -439,12 +475,49 @@ bool TP_MAKE_THISCALL(HookRemoveSpell, Actor, MagicItem* apSpell) return result; } +namespace +{ +// Diagnostic for characters constructed or loaded without a valid form ID. +// 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; + 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::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); +} +} // namespace Actor* TP_MAKE_THISCALL(HookCharacterConstructor, Actor) { TP_EMPTY_HOOK_PLACEHOLDER; TiltedPhoques::ThisCall(RealCharacterConstructor, apThis); + if (apThis->formID == 0) + LogFormIdZeroActor("Character constructor", apThis, spdlog::level::debug); + return apThis; } @@ -454,6 +527,9 @@ Actor* TP_MAKE_THISCALL(HookCharacterConstructor2, Actor, uint8_t aUnk) TiltedPhoques::ThisCall(RealCharacterConstructor2, apThis, aUnk); + if (apThis->formID == 0) + LogFormIdZeroActor("Character constructor 2", apThis, spdlog::level::debug); + return apThis; } @@ -547,6 +623,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 @@ -701,25 +783,48 @@ void Actor::SetMagicEquipment(const MagicEquipment& acEquipment) noexcept auto* pEquipManager = EquipManager::Get(); auto& modSystem = World::Get().GetModSystem(); - if (acEquipment.LeftHandSpell) + // The owner may have magic from a mod this client doesn't have (e.g. a power a werewolf mod + // equips after transforming back). Such ids resolve to 0, and equipping a null form leaves the + // actor with an empty hand/voice entry that combat AI dereferences later (crash in + // Actor::VisitSpells while building the combat inventory). Skip anything that doesn't resolve. + const auto equipHandSpell = [&](const GameId& acSpellId, uint32_t aSlot, const char* apHand) { - uint32_t mainHandWeaponId = modSystem.GetGameId(acEquipment.LeftHandSpell); - spdlog::debug("Setting left hand spell: {:X}", mainHandWeaponId); - pEquipManager->EquipSpell(this, TESForm::GetById(mainHandWeaponId), 0); - } + const uint32_t cSpellId = modSystem.GetGameId(acSpellId); + auto* pSpell = Cast(TESForm::GetById(cSpellId)); + if (!pSpell) + { + spdlog::warn( + "Actor {:X}: skipping {} hand spell {:X}:{:X}, not a spell on this client (missing mod?)", formID, apHand, acSpellId.ModId, acSpellId.BaseId); + return; + } + + spdlog::debug("Setting {} hand spell: {:X}", apHand, cSpellId); + pEquipManager->EquipSpell(this, pSpell, aSlot); + }; + + if (acEquipment.LeftHandSpell) + equipHandSpell(acEquipment.LeftHandSpell, 0, "left"); if (acEquipment.RightHandSpell) - { - uint32_t secondaryHandWeaponId = modSystem.GetGameId(acEquipment.RightHandSpell); - spdlog::debug("Setting right hand spell: {:X}", secondaryHandWeaponId); - pEquipManager->EquipSpell(this, TESForm::GetById(secondaryHandWeaponId), 1); - } + equipHandSpell(acEquipment.RightHandSpell, 1, "right"); if (acEquipment.Shout) { - uint32_t shoutId = modSystem.GetGameId(acEquipment.Shout); - spdlog::debug("Setting shout: {:X}", shoutId); - pEquipManager->EquipShout(this, TESForm::GetById(shoutId)); + const uint32_t cShoutId = modSystem.GetGameId(acEquipment.Shout); + TESForm* pShout = TESForm::GetById(cShoutId); + + // The voice slot holds either a shout or a power (a spell) + if (!Cast(pShout) && !Cast(pShout)) + { + spdlog::warn( + "Actor {:X}: skipping shout/power {:X}:{:X}, not a shout or power on this client (missing mod?)", formID, acEquipment.Shout.ModId, + acEquipment.Shout.BaseId); + } + else + { + spdlog::debug("Setting shout: {:X}", cShoutId); + pEquipManager->EquipShout(this, pShout); + } } } @@ -954,6 +1059,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); } @@ -971,21 +1079,37 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo float currentHealth = apThis->GetActorValue(ActorValueInfo::kHealth); bool wouldKill = (currentHealth - realDamage) <= 0.f; + // Applies the hit here and sends it to the others, with the damage diagnostics logging both. + const auto applyHere = [&](const char* acpPath) -> bool + { + const uint64_t cHitId = DamageDiagnostics::OnDamageHook(apThis, apHitter, aDamage, realDamage, aKillMove, acpPath, true); + World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -realDamage)); + const bool cResult = TiltedPhoques::ThisCall(RealDamageActor, apThis, aDamage, apHitter, aKillMove); + DamageDiagnostics::OnDamageApplied(cHitId, apThis, currentHealth); + return cResult; + }; + // Leaves the hit to another client, the diagnostics still log what it would have done here. + const auto leaveToOwner = [&](const char* acpPath, bool aResult) -> bool + { + DamageDiagnostics::OnDamageHook(apThis, apHitter, aDamage, realDamage, aKillMove, acpPath, false); + return aResult; + }; + const auto* pExHittee = apThis->GetExtension(); if (pExHittee->IsLocalPlayer()) { if (!World::Get().GetServerSettings().PvpEnabled) { - if (apHitter && apHitter->GetExtension()->IsRemotePlayer()) - return false; + // 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 leaveToOwner("pvp-off-blocked", false); } - World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -realDamage)); - return TiltedPhoques::ThisCall(RealDamageActor, apThis, aDamage, apHitter, aKillMove); + return applyHere("hittee-local-player"); } else if (pExHittee->IsRemotePlayer()) { - return wouldKill; + return leaveToOwner("hittee-remote-player", wouldKill); } if (apHitter) @@ -993,23 +1117,21 @@ bool TP_MAKE_THISCALL(HookDamageActor, Actor, float aDamage, Actor* apHitter, bo const auto* pExHitter = apHitter->GetExtension(); if (pExHitter->IsLocalPlayer()) { - World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -realDamage)); - return TiltedPhoques::ThisCall(RealDamageActor, apThis, aDamage, apHitter, aKillMove); + return applyHere("hitter-local-player"); } if (pExHitter->IsRemotePlayer()) { - return wouldKill; + return leaveToOwner("hitter-remote-player", wouldKill); } } if (pExHittee->IsLocal()) { - World::Get().GetRunner().Trigger(HealthChangeEvent(apThis->formID, -realDamage)); - return TiltedPhoques::ThisCall(RealDamageActor, apThis, aDamage, apHitter, aKillMove); + return applyHere("hittee-local-npc"); } else { - return wouldKill; + return leaveToOwner("hittee-remote-npc", wouldKill); } } @@ -1068,12 +1190,13 @@ void TP_MAKE_THISCALL(HookAddInventoryItem, Actor, TESBoundObject* apItem, Extra Inventory::Entry item{}; modSystem.GetServerModId(apItem->formID, item.BaseId); + AlchemyItem::Capture(apItem, item); item.Count = aCount; if (apExtraData) apThis->GetItemFromExtraData(item, apExtraData); - QueueActorInventoryChange(apThis, InventoryChangeEvent(apThis->formID, std::move(item)), apOldOwner); + QueueActorInventoryChange(InventoryChangeEvent(apThis->formID, std::move(item)), apOldOwner); } TiltedPhoques::ThisCall(RealAddInventoryItem, apThis, apItem, apExtraData, aCount, apOldOwner); @@ -1087,6 +1210,7 @@ void* TP_MAKE_THISCALL(HookPickUpObject, Actor, TESObjectREFR* apObject, int32_t Inventory::Entry item{}; modSystem.GetServerModId(apObject->baseForm->formID, item.BaseId); + AlchemyItem::Capture(apObject->baseForm, item); item.Count = aCount; if (apObject->GetExtraDataList()) @@ -1096,9 +1220,13 @@ void* TP_MAKE_THISCALL(HookPickUpObject, Actor, TESObjectREFR* apObject, int32_t // The inventory change event should always be sent to the server, otherwise the server inventory won't be updated. bool shouldUpdateClients = apObject->IsTemporary() && !ScopedActivateOverride::IsOverriden(); - QueueActorInventoryChange(apThis, InventoryChangeEvent(apThis->formID, std::move(item), false, shouldUpdateClients)); + QueueActorInventoryChange(InventoryChangeEvent(apThis->formID, std::move(item), shouldUpdateClients)); } + // A dropped item may be server-tracked; the other clients need to delete their copy. + if (apObject->IsTemporary() && apThis->GetExtension()->IsLocal()) + World::Get().GetRunner().Trigger(ReferencePickUpEvent(apObject->formID)); + return TiltedPhoques::ThisCall(RealPickUpObject, apThis, apObject, aCount, aUnk1, aUnk2); } @@ -1107,48 +1235,119 @@ 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; + +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 = apThis->IsPvpDisarmedBy(apCaster); + const bool cResult = TiltedPhoques::ThisCall(RealDisarm, apThis, apCaster); + s_allowEssentialDisarm = false; + return cResult; +} + +// 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(); Inventory::Entry item{}; modSystem.GetServerModId(apObject->formID, item.BaseId); + AlchemyItem::Capture(apObject, item); item.Count = -aCount; if (apExtraData) apThis->GetItemFromExtraData(item, apExtraData); - QueueActorInventoryChange(apThis, InventoryChangeEvent(apThis->formID, std::move(item), true)); - - ScopedInventoryOverride _; - - return TiltedPhoques::ThisCall(RealDropObject, apThis, apResult, apObject, apExtraData, aCount, apLocation, apRotation); -} - -void Actor::DropOrPickUpObject(const Inventory::Entry& arEntry, NiPoint3* apLocation, NiPoint3* apRotation) noexcept -{ - ExtraDataList* pExtraData = GetExtraDataFromItem(arEntry); + // GetItemFromExtraData() overwrites the count with the stack's ExtraCount. + item.Count = -aCount; - ModSystem& modSystem = World::Get().GetModSystem(); + // Other clients only remove the item from the inventory; DroppedItemService syncs the placed reference. + QueueActorInventoryChange(InventoryChangeEvent(apThis->formID, item)); - uint32_t objectId = modSystem.GetGameId(arEntry.BaseId); - TESBoundObject* pObject = Cast(TESForm::GetById(objectId)); - if (!pObject) + void* pResult = nullptr; { - spdlog::warn("Object to drop not found, {:X}:{:X}.", arEntry.BaseId.ModId, arEntry.BaseId.BaseId); - return; + ScopedInventoryOverride _; + pResult = TiltedPhoques::ThisCall(RealDropObject, apThis, apResult, apObject, apExtraData, aCount, apLocation, apRotation); } - if (arEntry.Count < 0) - DropObject(pObject, pExtraData, -arEntry.Count, apLocation, apRotation); - // TODO: pick up -} + if (apThis == PlayerCharacter::Get()) + { + const auto* pHandle = static_cast*>(apResult); + if (TESObjectREFR* pDropped = TESObjectREFR::GetByHandle(pHandle->handle.iBits)) + { + item.Count = aCount; + World::Get().GetRunner().Trigger(DropItemEvent(pDropped->formID, std::move(item))); + } + } -void Actor::DropObject(TESBoundObject* apObject, ExtraDataList* apExtraData, int32_t aCount, NiPoint3* apLocation, NiPoint3* apRotation) noexcept -{ - spdlog::debug("Dropping object, form id: {:X}, count: {}, actor: {:X}", apObject->formID, aCount, formID); - BSPointerHandle result{}; - TiltedPhoques::ThisCall(RealDropObject, this, &result, apObject, apExtraData, aCount, apLocation, apRotation); + return pResult; } TP_THIS_FUNCTION(TUpdateDetectionState, void, ActorKnowledge, void*); @@ -1269,6 +1468,11 @@ void TP_MAKE_THISCALL(HookAddDeathItems, Actor) if (apThis->GetExtension()->IsRemote()) return; + // The game only skips reanimated corpses. A summon vanishes when it dies, so its death items (a flame atronach's + // fire salts) would only be synced to the server and the other players for a body nobody can loot. + if (apThis->IsCommandedActor()) + return; + TiltedPhoques::ThisCall(RealAddDeathItems, apThis); } @@ -1303,6 +1507,13 @@ 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(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 +1536,13 @@ 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(); + 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 +1564,13 @@ 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(&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/Actor.h b/Code/client/Games/Skyrim/Actor.h index f82b411017..babb25994a 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; @@ -242,8 +244,6 @@ struct Actor : TESObjectREFR void Kill() noexcept; void Respawn() noexcept; void PickUpObject(TESObjectREFR* apObject, int32_t aCount, bool aUnk1, float aUnk2) noexcept; - void DropObject(TESBoundObject* apObject, ExtraDataList* apExtraData, int32_t aCount, NiPoint3* apLocation, NiPoint3* apRotation) noexcept; - void DropOrPickUpObject(const Inventory::Entry& arEntry, NiPoint3* apPoint, NiPoint3* apRotate) noexcept; void SpeakSound(const char* pFile); void StartCombatEx(Actor* apTarget) noexcept; void SetCombatTargetEx(Actor* apTarget) noexcept; diff --git a/Code/client/Games/Skyrim/EquipManager.cpp b/Code/client/Games/Skyrim/EquipManager.cpp index c351f1cda4..f1b53f16fe 100644 --- a/Code/client/Games/Skyrim/EquipManager.cpp +++ b/Code/client/Games/Skyrim/EquipManager.cpp @@ -46,20 +46,6 @@ struct ShoutEquipData }; static_assert(sizeof(MagicEquipData) == 0x10); -namespace -{ -void QueueEquipmentChange(Actor* apActor, EquipmentChangeEvent aEvent) -{ - const auto ownershipToken = Utils::GetLocalOwnershipToken(apActor->formID); - if (!ownershipToken) - return; - - aEvent.ServerId = ownershipToken->ServerId; - aEvent.OwnershipEpoch = ownershipToken->OwnershipEpoch; - World::Get().GetRunner().Trigger(std::move(aEvent)); -} -} - TP_THIS_FUNCTION(TEquip, void*, EquipManager, Actor* apActor, TESForm* apItem, EquipData* apData); TP_THIS_FUNCTION(TUnEquip, void*, EquipManager, Actor* apActor, TESForm* apItem, EquipData* apData); TP_THIS_FUNCTION(TEquipSpell, void*, EquipManager, Actor* apActor, TESForm* apSpell, MagicEquipData* apData); @@ -180,7 +166,7 @@ void* TP_MAKE_THISCALL(EquipHook, EquipManager, Actor* apActor, TESForm* apItem, evt.EquipSlotId = apData->pSlot ? apData->pSlot->formID : 0; evt.IsAmmo = apItem->formType == FormType::Ammo; - QueueEquipmentChange(apActor, std::move(evt)); + World::Get().GetRunner().Trigger(evt); } ScopedUnequipOverride _; @@ -213,7 +199,7 @@ void* TP_MAKE_THISCALL(UnEquipHook, EquipManager, Actor* apActor, TESForm* apIte evt.Unequip = true; evt.IsAmmo = apItem->formType == FormType::Ammo; - QueueEquipmentChange(apActor, std::move(evt)); + World::Get().GetRunner().Trigger(evt); } spdlog::debug("UnEquipHook, actor: {:X}", apActor->formID); @@ -238,7 +224,7 @@ void* TP_MAKE_THISCALL(EquipSpellHook, EquipManager, Actor* apActor, TESForm* ap evt.EquipSlotId = apData->pEquipSlot->formID; evt.IsSpell = true; - QueueEquipmentChange(apActor, std::move(evt)); + World::Get().GetRunner().Trigger(evt); } ScopedUnequipOverride _; @@ -264,7 +250,7 @@ void* TP_MAKE_THISCALL(UnEquipSpellHook, EquipManager, Actor* apActor, TESForm* evt.Unequip = true; evt.IsSpell = true; - QueueEquipmentChange(apActor, std::move(evt)); + World::Get().GetRunner().Trigger(evt); } return TiltedPhoques::ThisCall(RealUnEquipSpell, apThis, apActor, apSpell, apData); @@ -287,7 +273,7 @@ void* TP_MAKE_THISCALL(EquipShoutHook, EquipManager, Actor* apActor, TESForm* ap evt.ItemId = apShout->formID; evt.IsShout = true; - QueueEquipmentChange(apActor, std::move(evt)); + World::Get().GetRunner().Trigger(evt); } ScopedUnequipOverride _; @@ -313,7 +299,7 @@ void* TP_MAKE_THISCALL(UnEquipShoutHook, EquipManager, Actor* apActor, TESForm* evt.Unequip = true; evt.IsShout = true; - QueueEquipmentChange(apActor, std::move(evt)); + World::Get().GetRunner().Trigger(evt); } return TiltedPhoques::ThisCall(RealUnEquipShout, apThis, apActor, apShout, apData); diff --git a/Code/client/Games/Skyrim/Events/EventDispatcher.h b/Code/client/Games/Skyrim/Events/EventDispatcher.h index d3da23ca60..2824f80c55 100644 --- a/Code/client/Games/Skyrim/Events/EventDispatcher.h +++ b/Code/client/Games/Skyrim/Events/EventDispatcher.h @@ -221,12 +221,27 @@ struct TESSellEvent { }; +// Times are game time in days, like GameDaysPassed. struct TESSleepStartEvent { + float sleepStartTime; + float desiredSleepEndTime; }; struct TESSleepStopEvent { + bool interrupted; +}; + +struct TESWaitStartEvent +{ + float waitStartTime; + float desiredWaitEndTime; +}; + +struct TESWaitStopEvent +{ + bool interrupted; }; struct TESSpellCastEvent @@ -328,7 +343,7 @@ struct EventDispatcherManager EventDispatcher sceneActionEvent; EventDispatcher scenePhaseEvent; EventDispatcher sellEvent; - EventDispatcher unknownDispatcher39; + EventDispatcher sleepStartEvent; EventDispatcher sleepStopEvent; EventDispatcher spellCastEvent; EventDispatcher unknownDispatcher42; @@ -339,8 +354,8 @@ struct EventDispatcherManager EventDispatcher triggerEnterEvent; EventDispatcher triggerLeaveEvent; EventDispatcher uniqueIDChangeEvent; - EventDispatcher unknownDispatcher50; // waitevent - EventDispatcher unknownDispatcher51; // TESWaitStopEvent + EventDispatcher waitStartEvent; + EventDispatcher waitStopEvent; EventDispatcher switchRaceCompleteEvent; EventDispatcher fastTravelEndEvent; }; diff --git a/Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp b/Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp index 6f183fa909..b408c1b606 100644 --- a/Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp +++ b/Code/client/Games/Skyrim/ExtraData/ExtraDataList.cpp @@ -135,6 +135,13 @@ void ExtraDataList::SetEnchantmentData(EnchantmentItem* apItem, uint16_t aCharge TiltedPhoques::ThisCall(setEnchantmentData, this, apItem, aCharge, aRemoveOnUnequip); } +void ExtraDataList::SetCount(uint16_t aCount) noexcept +{ + TP_THIS_FUNCTION(TSetCount, void, ExtraDataList, uint16_t aCount); + POINTER_SKYRIMSE(TSetCount, setCount, 11617); + TiltedPhoques::ThisCall(setCount, this, aCount); +} + bool ExtraDataList::HasQuestObjectAlias() noexcept { TP_THIS_FUNCTION(THasQuestObjectAlias, bool, ExtraDataList); diff --git a/Code/client/Games/Skyrim/ExtraData/ExtraDataList.h b/Code/client/Games/Skyrim/ExtraData/ExtraDataList.h index 5fb21356c2..642fc950de 100644 --- a/Code/client/Games/Skyrim/ExtraData/ExtraDataList.h +++ b/Code/client/Games/Skyrim/ExtraData/ExtraDataList.h @@ -29,6 +29,7 @@ struct ExtraDataList void SetPoison(AlchemyItem* apItem, uint32_t aCount) noexcept; void SetHealth(float aHealth) noexcept; void SetEnchantmentData(EnchantmentItem* apItem, uint16_t aCharge, bool aRemoveOnUnequip) noexcept; + void SetCount(uint16_t aCount) noexcept; [[nodiscard]] bool HasQuestObjectAlias() noexcept; diff --git a/Code/client/Games/Skyrim/Forms/AlchemyItem.cpp b/Code/client/Games/Skyrim/Forms/AlchemyItem.cpp new file mode 100644 index 0000000000..7e6ad73980 --- /dev/null +++ b/Code/client/Games/Skyrim/Forms/AlchemyItem.cpp @@ -0,0 +1,123 @@ +#include "AlchemyItem.h" +#include +#include +#include +#include + +bool AlchemyItem::CaptureRecipe(TESForm* apForm, Inventory::PotionData& aData) noexcept +{ + aData = {}; + auto* potion = apForm && apForm->IsTemporary() ? Cast(apForm) : nullptr; + if (!potion || potion->IsFood()) + return false; + aData.IsPoison = potion->IsPoison(); + auto& mods = World::Get().GetModSystem(); + for (auto* effect : potion->listOfEffects) + { + Inventory::EffectItem result; + // Conditions and temporary magic effects cannot be reproduced faithfully. + if (!effect || !effect->pEffectSetting || effect->Condition.pHead || effect->pEffectSetting->IsTemporary() || + !mods.GetServerModId(effect->pEffectSetting->formID, result.EffectId)) + { + aData.Effects.clear(); + return false; + } + result.Magnitude = effect->data.fMagnitude; + result.Area = effect->data.iArea; + result.Duration = effect->data.iDuration; + result.RawCost = effect->fRawCost; + aData.Effects.push_back(result); + } + if (!aData.IsValid()) + aData.Effects.clear(); + return !aData.Effects.empty(); +} + +void AlchemyItem::Capture(TESForm* apForm, Inventory::Entry& aEntry) noexcept +{ + auto* potion = apForm && apForm->IsTemporary() ? Cast(apForm) : nullptr; + if (!potion || potion->IsFood()) + return; + CaptureRecipe(apForm, aEntry.Potion); +} + +AlchemyItem* AlchemyItem::Find(TESObjectREFR* apOwner, const Inventory::PotionData& aData) noexcept +{ + auto* changes = apOwner ? apOwner->GetContainerChanges() : nullptr; + if (!changes || !changes->entries || !aData.IsValid()) + return nullptr; + for (auto* entry : *changes->entries) + { + if (!entry || !entry->form || entry->count <= 0) + continue; + Inventory::Entry local; + Capture(entry->form, local); + if (local.Potion == aData) + return Cast(entry->form); + } + return nullptr; +} + +namespace +{ +// Effect cost only feeds the item's gold value; the engine may keep its own when it reuses a created potion. +bool SameEffects(const Inventory::PotionData& acLhs, const Inventory::PotionData& acRhs) noexcept +{ + return acLhs.IsPoison == acRhs.IsPoison && acLhs.Effects.size() == acRhs.Effects.size() && + std::is_permutation(acLhs.Effects.begin(), acLhs.Effects.end(), acRhs.Effects.begin(), [](const auto& acA, const auto& acB) { + return acA.EffectId == acB.EffectId && acA.Magnitude == acB.Magnitude && acA.Area == acB.Area && acA.Duration == acB.Duration; + }); +} +} // namespace + +AlchemyItem* AlchemyItem::Create(const Inventory::PotionData& aData) noexcept +{ + if (!aData.IsValid()) + return nullptr; + auto* manager = BGSCreatedObjectManager::Get(); + if (!manager) + return nullptr; + auto& mods = World::Get().GetModSystem(); + GameArray effects; + effects.Resize(aData.Effects.size()); + for (uint32_t i = 0; i < aData.Effects.size(); ++i) + { + const auto& source = aData.Effects[i]; + EffectItem effect{}; + effect.data = {source.Magnitude, source.Area, source.Duration}; + effect.fRawCost = source.RawCost; + effect.pEffectSetting = Cast(TESForm::GetById(mods.GetGameId(source.EffectId))); + if (!effect.pEffectSetting) + { + Memory::Free(effects.data); + return nullptr; + } + effects[i] = effect; + } + // CommonLibSSE AddPotion/AddPoison: output is a one-pointer created-object smart pointer. + // AE address-library IDs 36167 (potion) and 36168 (poison), the same calls the alchemy menu ends with. + // No AlchemyMenu, recipe, ingredient or skill calculation is involved. + TP_THIS_FUNCTION(TAddPotion, void, BGSCreatedObjectManager, AlchemyItem**, GameArray*); + POINTER_SKYRIMSE(TAddPotion, addPotion, 36167); + POINTER_SKYRIMSE(TAddPotion, addPoison, 36168); + AlchemyItem* created = nullptr; + TiltedPhoques::ThisCall(aData.IsPoison ? addPoison : addPotion, manager, &created, &effects); + Memory::Free(effects.data); + if (!created) + { + spdlog::error("[TradeService]: {} returned no form for a {}-effect recipe", aData.IsPoison ? "AddPoison" : "AddPotion", aData.Effects.size()); + return nullptr; + } + Inventory::PotionData check; + CaptureRecipe(created, check); + if (SameEffects(check, aData)) + return created; + // The engine may hand back an existing created potion, including one in this player's own inventory. + // Dropping its reference here could free a form an inventory still holds, so it is only refused. + spdlog::error("[TradeService]: {} returned {:X} whose effects differ from the offered recipe", aData.IsPoison ? "AddPoison" : "AddPotion", created->formID); + for (const auto& effect : aData.Effects) + spdlog::error("[TradeService]: wanted {:X}:{:X} magnitude {} area {} duration {} cost {}", effect.EffectId.ModId, effect.EffectId.BaseId, effect.Magnitude, effect.Area, effect.Duration, effect.RawCost); + for (const auto& effect : check.Effects) + spdlog::error("[TradeService]: got {:X}:{:X} magnitude {} area {} duration {} cost {}", effect.EffectId.ModId, effect.EffectId.BaseId, effect.Magnitude, effect.Area, effect.Duration, effect.RawCost); + return nullptr; +} diff --git a/Code/client/Games/Skyrim/Forms/AlchemyItem.h b/Code/client/Games/Skyrim/Forms/AlchemyItem.h index 73346a4c13..90974e0e9e 100644 --- a/Code/client/Games/Skyrim/Forms/AlchemyItem.h +++ b/Code/client/Games/Skyrim/Forms/AlchemyItem.h @@ -1,7 +1,44 @@ #pragma once -#include "TESForm.h" +#include "MagicItem.h" +#include -struct AlchemyItem : TESForm +struct TESObjectREFR; + +struct AlchemyItem : MagicItem { + enum AlchemyFlags : uint32_t + { + Food = 1 << 1, + Poison = 1 << 17, + }; + + struct Data + { + int32_t costOverride; + uint32_t flags; + void* pAddiction; + float addictionChance; + uint32_t pad14; + void* pConsumptionSound; + }; + + // ENIT layout: https://github.com/CharmedBaryon/CommonLibSSE-NG/blob/main/include/RE/A/AlchemyItem.h + uint8_t pad90[0x138 - sizeof(MagicItem)]; + Data data; + uint8_t pad158[0x10]; + + bool IsPoison() const noexcept { return (data.flags & Poison) != 0; } + bool IsFood() const noexcept { return (data.flags & Food) != 0 && !IsPoison(); } + // Records a player-crafted potion or poison's effects; false for other forms or unsupported effects. + static bool CaptureRecipe(TESForm* apForm, Inventory::PotionData& aData) noexcept; + static void Capture(TESForm* apForm, Inventory::Entry& aEntry) noexcept; + static AlchemyItem* Find(TESObjectREFR* apOwner, const Inventory::PotionData& aData) noexcept; + // The returned form may be an existing created potion with the same effects. Its created-object + // reference is never dropped: other inventories can point at the same form. + static AlchemyItem* Create(const Inventory::PotionData& aData) noexcept; }; + +static_assert(sizeof(AlchemyItem::Data) == 0x20); +static_assert(offsetof(AlchemyItem, data) == 0x138); +static_assert(sizeof(AlchemyItem) == 0x168); 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/Forms/EnchantmentItem.cpp b/Code/client/Games/Skyrim/Forms/EnchantmentItem.cpp index cc586d708a..3e5fd3f207 100644 --- a/Code/client/Games/Skyrim/Forms/EnchantmentItem.cpp +++ b/Code/client/Games/Skyrim/Forms/EnchantmentItem.cpp @@ -14,26 +14,42 @@ EnchantmentItem* EnchantmentItem::Create(const Inventory::EnchantmentData& aData ModSystem& modSystem = World::Get().GetModSystem(); - GameArray effects{}; - effects.Resize(aData.Effects.size()); - for (int i = 0; i < aData.Effects.size(); i++) + // Effects from a mod this client doesn't have resolve to null. Keep only the ones that exist here; a created + // enchantment with a null effect crashes the game as soon as it is displayed, equipped or applied. + Vector resolvedEffects; + resolvedEffects.reserve(aData.Effects.size()); + for (const Inventory::EffectItem& effect : aData.Effects) { - Inventory::EffectItem effect = aData.Effects[i]; + auto* pEffectSetting = Cast(TESForm::GetById(modSystem.GetGameId(effect.EffectId))); + if (!pEffectSetting) + { + spdlog::warn("Dropping enchantment effect {:X}:{:X}, not found on this client (missing mod?)", effect.EffectId.ModId, effect.EffectId.BaseId); + continue; + } EffectItem effectItem{}; effectItem.data.fMagnitude = effect.Magnitude; effectItem.data.iArea = effect.Area; effectItem.data.iDuration = effect.Duration; effectItem.fRawCost = effect.RawCost; - effectItem.pEffectSetting = Cast(TESForm::GetById(modSystem.GetGameId(effect.EffectId))); - if (!effectItem.pEffectSetting) - spdlog::error("Effect setting not found: {:X}:{:X}", effect.EffectId.ModId, effect.EffectId.BaseId); + effectItem.pEffectSetting = pEffectSetting; // TODO: TESCondition? - effects[i] = effectItem; + resolvedEffects.push_back(effectItem); + } + + if (resolvedEffects.empty()) + { + spdlog::warn("Not creating enchantment, none of its {} effects exist on this client", aData.Effects.size()); + return nullptr; } + GameArray effects{}; + effects.Resize(resolvedEffects.size()); + for (size_t i = 0; i < resolvedEffects.size(); i++) + effects[i] = resolvedEffects[i]; + BGSCreatedObjectManager* objManager = BGSCreatedObjectManager::Get(); EnchantmentItem* pItem = nullptr; diff --git a/Code/client/Games/Skyrim/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/Forms/TESForm.h b/Code/client/Games/Skyrim/Forms/TESForm.h index 331dcc0f8c..bbe684f04f 100644 --- a/Code/client/Games/Skyrim/Forms/TESForm.h +++ b/Code/client/Games/Skyrim/Forms/TESForm.h @@ -5,16 +5,23 @@ enum class FormType : uint8_t { + Scroll = 23, Armor = 26, Book = 27, Container = 28, Door = 29, Ingredient = 30, + Light = 31, + Misc = 32, + Apparatus = 33, Weapon = 41, Ammo = 42, Npc = 43, LeveledCharacter = 44, + Key = 45, Alchemy = 46, + Note = 48, + SoulGem = 52, LeveledItem = 53, Character = 62, QuestItem = 77, @@ -64,7 +71,9 @@ struct TESForm : BaseFormComponent virtual void sub_16(); virtual void sub_17(); virtual void sub_18(); - virtual void sub_19(); + // False for non-playable forms, which the inventory menu hides: armour and most items check form flag 0x4, + // weapons and ammo their own data flags. + virtual bool IsPlayable() const noexcept; virtual void sub_1A(); virtual void sub_1B(); virtual void sub_1C(); diff --git a/Code/client/Games/Skyrim/Forms/TESRace.h b/Code/client/Games/Skyrim/Forms/TESRace.h index ea686aea56..dd20fd7490 100644 --- a/Code/client/Games/Skyrim/Forms/TESRace.h +++ b/Code/client/Games/Skyrim/Forms/TESRace.h @@ -5,4 +5,16 @@ struct TESRace : TESForm, TESFullName { + // RACE_DATA::Flag + enum : uint32_t + { + kPlayable = 1 << 0, + }; + + [[nodiscard]] bool IsPlayable() const noexcept { return (raceFlags & kPlayable) != 0; } + + uint8_t unk30[0x108 - 0x30]; + uint32_t raceFlags; // RACE_DATA (DATA) at 0xE8, its flags at +0x20 }; + +static_assert(offsetof(TESRace, raceFlags) == 0x108); diff --git a/Code/client/Games/Skyrim/Forms/TESWorldSpace.cpp b/Code/client/Games/Skyrim/Forms/TESWorldSpace.cpp index 9c4d0a615e..6bff02f2b7 100644 --- a/Code/client/Games/Skyrim/Forms/TESWorldSpace.cpp +++ b/Code/client/Games/Skyrim/Forms/TESWorldSpace.cpp @@ -2,7 +2,40 @@ TESObjectCELL* TESWorldSpace::LoadCell(int32_t aXCoordinate, int32_t aYCoordinate) noexcept { + struct BGSSaveLoadGame + { + enum Flags : uint32_t + { + kLoading = 1 << 1, + kInitingForms = 1 << 3, + }; + + uint8_t pad0[0x340]; + uint32_t flags; + }; + TP_THIS_FUNCTION(TLoadCell, TESObjectCELL*, TESWorldSpace, int32_t aXCoordinate, int32_t aYCoordinate); + TP_THIS_FUNCTION(TInitDeferredForms, void, BGSSaveLoadGame, bool aUnk); + using TPromoteTasks = void(); + POINTER_SKYRIMSE(TLoadCell, s_loadCell, 20460); - return TiltedPhoques::ThisCall(s_loadCell, this, aXCoordinate, aYCoordinate); + POINTER_SKYRIMSE(TPromoteTasks, s_pausePromoteTasks, 13333); + POINTER_SKYRIMSE(TPromoteTasks, s_resumePromoteTasks, 13334); + POINTER_SKYRIMSE(TInitDeferredForms, s_initDeferredForms, 35567); + POINTER_SKYRIMSE(BGSSaveLoadGame*, s_saveLoadGame, 403330); + + // Loading a cell queues its references' saved changes on BGSSaveLoadGame without a lock, and the + // QueuedPromoteReferencesTasks drain that queue on worker threads. Like the game's own cell loads, pause + // those tasks and apply the queued changes before resuming, so no freed reference is left in the queue. + s_pausePromoteTasks.Get()(); + + TESObjectCELL* pCell = TiltedPhoques::ThisCall(s_loadCell, this, aXCoordinate, aYCoordinate); + + BGSSaveLoadGame* pSaveLoadGame = *s_saveLoadGame.Get(); + if (pSaveLoadGame && (pSaveLoadGame->flags & (BGSSaveLoadGame::kLoading | BGSSaveLoadGame::kInitingForms)) == 0) + TiltedPhoques::ThisCall(s_initDeferredForms, pSaveLoadGame, false); + + s_resumePromoteTasks.Get()(); + + return pCell; } diff --git a/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp b/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp new file mode 100644 index 0000000000..f26cce0e29 --- /dev/null +++ b/Code/client/Games/Skyrim/Interface/ConsoleCommandGuard.cpp @@ -0,0 +1,119 @@ +#include + +#include +#include + +// Refuses typed console commands while connected to a server with Gameplay:bAllowConsoleCommands off. +// Commands run by code (ConsoleUtil, Papyrus ExecuteCommand) don't go through this callback. +// +// 51084 is the Console menu's "ExecuteCommand" callback (registered by 51083). It queues the typed line as a +// Script to be compiled and run later. 51109 is ConsoleLog::Print, 401203 the ConsoleLog singleton. + +namespace +{ +// Commands that stay allowed while typed commands are turned off, by short and full name. They help players +// get past things that block a playthrough without affecting other players. +// +// tgm and tcl stay blocked: tgm makes a player invulnerable in PvP, and tcl lets a player walk through walls +// and breaks enemy pathing. The game already offers fair alternatives: fast travel, teleporting to a party +// member, and the Become Ethereal shout. +constexpr std::string_view kAllowedCommands[]{ + "tfc", "ToggleFreeCamera", + "tm", "ToggleMenus", + "coc", "CenterOnCell", + "fov", "SetCameraFOV", // Only changes the local camera (fDefaultFOV, fDefault1stPersonFOV, fDefaultWorldFOV). +}; + +bool EqualsIgnoreCase(std::string_view aLhs, std::string_view aRhs) noexcept +{ + return aLhs.size() == aRhs.size() && _strnicmp(aLhs.data(), aRhs.data(), aLhs.size()) == 0; +} + +bool IsSpace(char aChar) noexcept +{ + return aChar == ' ' || aChar == '\t'; +} + +// Only the command name is checked. A reference prefix ("player.tcl") isn't allowed, so the command can't be +// aimed at another reference. +bool IsAllowedCommand(std::string_view aLine) noexcept +{ + if (aLine.find_first_of("\r\n") != std::string_view::npos) + return false; + + while (!aLine.empty() && IsSpace(aLine.front())) + aLine.remove_prefix(1); + + size_t nameLength = 0; + while (nameLength < aLine.size() && !IsSpace(aLine[nameLength])) + ++nameLength; + + const std::string_view cName = aLine.substr(0, nameLength); + for (const std::string_view cAllowed : kAllowedCommands) + { + if (EqualsIgnoreCase(cName, cAllowed)) + return true; + } + + return false; +} + +const char* GetTypedLine(const FxDelegateArgs* apArgs) noexcept +{ + if (!apArgs || !apArgs->pArgs || apArgs->GetArgCount() == 0) + return nullptr; + + const GFxValue& cValue = (*apArgs)[0]; + if (!cValue.IsString() || !cValue.value.pObject) + return nullptr; + + return cValue.GetString(); +} + +void PrintToConsole(const char* acpText) noexcept +{ + using TPrint = void(void*, const char*, ...); + POINTER_SKYRIMSE(TPrint, s_print, 51109); + POINTER_SKYRIMSE(void*, s_consoleLog, 401203); + + void* pConsoleLog = *s_consoleLog.Get(); + if (pConsoleLog) + s_print.Get()(pConsoleLog, "%s", acpText); +} + +bool AreTypedCommandsBlocked() noexcept +{ + World& world = World::Get(); + return world.GetTransport().IsConnected() && !world.GetServerSettings().ConsoleCommandsAllowed; +} +} // namespace + +using TExecuteCommand = void(const FxDelegateArgs* apArgs); +static TExecuteCommand* RealExecuteCommand = nullptr; + +static void HookExecuteCommand(const FxDelegateArgs* apArgs) +{ + if (!AreTypedCommandsBlocked()) + { + RealExecuteCommand(apArgs); + return; + } + + const char* cpLine = GetTypedLine(apArgs); + if (cpLine && IsAllowedCommand(cpLine)) + { + RealExecuteCommand(apArgs); + return; + } + + spdlog::info("Console command refused, the server has turned console commands off: {}", cpLine ? cpLine : ""); + PrintToConsole("Console commands are turned off on this server."); +} + +static TiltedPhoques::Initializer s_consoleCommandGuardHooks( + []() + { + POINTER_SKYRIMSE(TExecuteCommand, s_executeCommand, 51084); + RealExecuteCommand = s_executeCommand.Get(); + TP_HOOK(&RealExecuteCommand, HookExecuteCommand); + }); diff --git a/Code/client/Games/Skyrim/Interface/IMenu.h b/Code/client/Games/Skyrim/Interface/IMenu.h index d472b4f73b..5fa90b767c 100644 --- a/Code/client/Games/Skyrim/Interface/IMenu.h +++ b/Code/client/Games/Skyrim/Interface/IMenu.h @@ -76,9 +76,71 @@ struct GStatGroups }; }; +// Layout from powerof3's CommonLibSSE (RE/G/GFxValue.h). +struct GFxValue +{ + enum ValueType : uint32_t + { + kUndefined = 0x00, + kNull = 0x01, + kBoolean = 0x02, + kNumber = 0x03, + kString = 0x04, + kStringW = 0x05, + kObject = 0x06, + kArray = 0x07, + kDisplayObject = 0x08, + + kManagedBit = 1 << 6, + kConvertBit = 1 << 7, + kValueMask = 0x0F, + kTypeMask = kConvertBit | kValueMask, + }; + + [[nodiscard]] uint32_t GetType() const noexcept { return type & kTypeMask; } + [[nodiscard]] bool IsString() const noexcept { return GetType() == kString; } + [[nodiscard]] const char* GetString() const noexcept { return (type & kManagedBit) ? *value.pManagedString : value.pString; } + + union ValueUnion + { + double number; + bool boolean; + const char* pString; + const char** pManagedString; + const wchar_t* pWideString; + const wchar_t** pManagedWideString; + void* pObject; + }; + static_assert(sizeof(ValueUnion) == 0x8); + + void* pObjectInterface; // 00 + uint32_t type; // 08 + uint32_t pad0C; // 0C + ValueUnion value; // 10 +}; +static_assert(sizeof(GFxValue) == 0x18); + +struct FxDelegateHandler; + +// Layout from powerof3's CommonLibSSE (RE/F/FxDelegateArgs.h). +struct FxDelegateArgs +{ + [[nodiscard]] uint32_t GetArgCount() const noexcept { return numArgs; } + [[nodiscard]] const GFxValue& operator[](uint32_t aIndex) const noexcept { return pArgs[aIndex]; } + + GFxValue responseId; // 00 + FxDelegateHandler* pHandler; // 18 + void* pMovieView; // 20 + const GFxValue* pArgs; // 28 + uint32_t numArgs; // 30 + uint32_t pad34; // 34 +}; +static_assert(offsetof(FxDelegateArgs, pArgs) == 0x28); +static_assert(sizeof(FxDelegateArgs) == 0x38); + struct FxDelegateHandler : GRefCountBase { - using CallbackFn = void(const void*); + using CallbackFn = void(const FxDelegateArgs* apArgs); class CallbackProcessor { diff --git a/Code/client/Games/Skyrim/Magic/EffectItem.cpp b/Code/client/Games/Skyrim/Magic/EffectItem.cpp index e74998c46c..30738ce6fd 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; @@ -33,6 +38,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..d31c964cae 100644 --- a/Code/client/Games/Skyrim/Magic/EffectItem.h +++ b/Code/client/Games/Skyrim/Magic/EffectItem.h @@ -14,12 +14,14 @@ struct EffectItemData struct EffectItem { bool IsHealingEffect() const noexcept; + bool IsDisarmEffect() const noexcept; bool IsSummonEffect() const noexcept; bool IsSlowEffect() const noexcept; bool IsInivisibilityEffect() const noexcept; 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..ec53d8dd62 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 @@ -24,6 +25,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 +33,14 @@ 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; +static thread_local bool s_isCheckingParalysis = false; void MagicTarget::DispelAllSpells(bool aNow) noexcept { @@ -45,11 +51,23 @@ 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; - 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; @@ -60,6 +78,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()) @@ -81,6 +103,35 @@ 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. 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::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 +{ + 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); @@ -102,6 +153,22 @@ 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); + } + + // 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); @@ -118,6 +185,50 @@ 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)); + + // 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; + } + + // 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.IsPvpForwarded = true; + World::Get().GetRunner().Trigger(addTargetEvent); + 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.pCaster->GetExtension()->IsLocalPlayer() && IsSelfCastAreaAttack(arData) && !IsChanneledParalysis(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); + } + if (!arData.pSpell->IsHealingSpell() && !arData.pSpell->IsBuffSpell()) return false; @@ -150,9 +261,26 @@ 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)); + + // Poisons are synced by the attacker. The copy's ExtraPoison and its charges can be out of date. + if (arData.pSpell->IsPoison()) + return false; + + // Cloak hits are synced by the wearer. + if (IsCloakHit(arData)) + return false; + } + else if (pCasterExtension->IsRemote() && arData.pCaster->IsPlayerSummon() && arData.pSpell->iHostileCount > 0) + { + if (!World::Get().GetServerSettings().PvpEnabled) + return false; } } + ScopedRemotePerks remotePerks; bool result = TiltedPhoques::ThisCall(RealAddTarget, apThis, arData); if (result && arData.ShouldSync()) World::Get().GetRunner().Trigger(addTargetEvent); @@ -192,10 +320,40 @@ 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 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; - return TiltedPhoques::ThisCall(RealCheckAddEffectTargetData, apThis, arArgs, afResistance); + 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. Paralysis keeps its player check. +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 && !s_isCheckingParalysis && 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) @@ -243,6 +401,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(); @@ -250,11 +410,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/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/Games/Skyrim/ObScript.cpp b/Code/client/Games/Skyrim/ObScript.cpp new file mode 100644 index 0000000000..25a4b2b035 --- /dev/null +++ b/Code/client/Games/Skyrim/ObScript.cpp @@ -0,0 +1,76 @@ +#include + +#include +#include + +#include + +#include + +// Bound from CommonLibSSE-NG SCRIPT_FUNCTION::LocateScriptCommand, which scans +// kScriptCommandsEnd (0x02E0) entries from the first script command. +static constexpr uint32_t kScriptCommandCount = 0x2E0; + +using TParseParameters = bool(const ObScriptParam* apParamInfo, ObScriptData* apScriptData, uint32_t& arOpcodeOffsetPtr, TESObjectREFR* apThisObj, TESObjectREFR* apContainingObj, Script* apScriptObj, ScriptLocals* apLocals, ...); + +static ObScriptCommand::TExecute* RealSendAnimationEventExecute = nullptr; +static ObScriptCommand* s_pSaeCommand = nullptr; // kept for a future unhook/restore path + +static bool HookSendAnimationEventExecute(const ObScriptParam* apParamInfo, ObScriptData* apScriptData, TESObjectREFR* apThisObj, TESObjectREFR* apContainingObj, Script* apScriptObj, ScriptLocals* apLocals, double& arResult, uint32_t& arOpcodeOffsetPtr) +{ + POINTER_SKYRIMSE(TParseParameters, s_parseParameters, 21910); + + // Parse a copy so the original execute below still sees the unconsumed offset. + uint32_t opcodeOffset = arOpcodeOffsetPtr; + char eventNameBuffer[512]{}; + + const bool cParsed = s_parseParameters.Get()(apParamInfo, apScriptData, opcodeOffset, apThisObj, apContainingObj, apScriptObj, apLocals, eventNameBuffer); + + const bool cResult = RealSendAnimationEventExecute(apParamInfo, apScriptData, apThisObj, apContainingObj, apScriptObj, apLocals, arResult, arOpcodeOffsetPtr); + + if (cResult && cParsed && apThisObj && eventNameBuffer[0] != '\0') + { + spdlog::debug("Console sae captured: ref {:X}, event {}", apThisObj->formID, eventNameBuffer); + World::Get().GetRunner().Trigger(ScriptAnimationEvent(apThisObj->formID, String{}, eventNameBuffer)); + } + + return cResult; +} + +static TiltedPhoques::Initializer s_obScriptHooks( + []() + { + // First script command, AE address-library id 361120 (CommonLibSSE-NG include/RE/Offsets.h:481). + // The sae command is a SCRIPT function ("SendAnimEvent"), not a console command — + // confirmed via the in-game help listing. + POINTER_SKYRIMSE(ObScriptCommand, s_firstScriptCommand, 361120); + + ObScriptCommand* pCommands = s_firstScriptCommand.Get(); + if (!pCommands) + { + spdlog::error("ObScript: script command table not found, sae sync disabled"); + return; + } + + for (uint32_t i = 0; i < kScriptCommandCount; ++i) + { + ObScriptCommand& command = pCommands[i]; + + if (command.pFunctionName && _stricmp(command.pFunctionName, "SendAnimEvent") == 0) + { + s_pSaeCommand = &command; + RealSendAnimationEventExecute = command.pExecuteFunction; + command.pExecuteFunction = HookSendAnimationEventExecute; + + spdlog::info("ObScript: hooked script command SendAnimEvent (sae)"); + return; + } + } + + spdlog::error("ObScript: SendAnimEvent script command not found, sae sync disabled"); + for (uint32_t i = 0; i < kScriptCommandCount; ++i) + { + const ObScriptCommand& command = pCommands[i]; + spdlog::debug("ObScript table [{}]: {} ({})", i, command.pFunctionName ? command.pFunctionName : "", command.pShortName ? command.pShortName : ""); + } + }); diff --git a/Code/client/Games/Skyrim/ObScript.h b/Code/client/Games/Skyrim/ObScript.h new file mode 100644 index 0000000000..cdfcf97161 --- /dev/null +++ b/Code/client/Games/Skyrim/ObScript.h @@ -0,0 +1,53 @@ +#pragma once + +struct Script; +struct ScriptLocals; +struct TESObjectREFR; + +// Minimal layout of the engine's ObScript (console/script) command table entry. +// Cross-checked against CommonLibSSE-NG RE::SCRIPT_FUNCTION (include/RE/C/CommandTable.h). +struct ObScriptParam +{ + const char* pParamName; // 00 + uint32_t paramType; // 08 + bool optional; // 0C + uint8_t pad0D; // 0D + uint16_t pad0E; // 0E +}; + +static_assert(sizeof(ObScriptParam) == 0x10); + +struct ObScriptData +{ + uint16_t opcode; // 00 + uint16_t chunkSize; // 02 + uint16_t numParams; // 04 +}; + +static_assert(sizeof(ObScriptData) == 0x6); + +struct ObScriptCommand +{ + using TExecute = bool(const ObScriptParam* apParamInfo, ObScriptData* apScriptData, TESObjectREFR* apThisObj, TESObjectREFR* apContainingObj, Script* apScriptObj, ScriptLocals* apLocals, double& arResult, uint32_t& arOpcodeOffsetPtr); + + const char* pFunctionName; // 00 + const char* pShortName; // 08 + uint32_t output; // 10 + uint32_t pad14; // 14 + const char* pHelpString; // 18 + bool referenceFunction; // 20 + uint8_t pad21; // 21 + uint16_t numParams; // 22 + uint32_t pad24; // 24 + ObScriptParam* pParams; // 28 + TExecute* pExecuteFunction; // 30 + void* pCompileFunction; // 38 + void* pConditionFunction; // 40 + bool editorFilter; // 48 + bool invalidatesCellList; // 49 + uint16_t pad4A; // 4A + uint32_t pad4C; // 4C +}; + +static_assert(offsetof(ObScriptCommand, pExecuteFunction) == 0x30); +static_assert(sizeof(ObScriptCommand) == 0x50); diff --git a/Code/client/Games/Skyrim/PlayerCharacter.cpp b/Code/client/Games/Skyrim/PlayerCharacter.cpp index 12b1a3bc79..e3e21970d3 100644 --- a/Code/client/Games/Skyrim/PlayerCharacter.cpp +++ b/Code/client/Games/Skyrim/PlayerCharacter.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -7,6 +8,7 @@ #include #include +#include #include #include #include @@ -168,6 +170,7 @@ char TP_MAKE_THISCALL(HookPickUpObject, PlayerCharacter, TESObjectREFR* apObject Inventory::Entry item{}; modSystem.GetServerModId(apObject->baseForm->formID, item.BaseId); + AlchemyItem::Capture(apObject->baseForm, item); item.Count = aCount; if (apObject->GetExtraDataList() && !ScopedExtraDataOverride::IsOverriden()) @@ -180,14 +183,11 @@ char TP_MAKE_THISCALL(HookPickUpObject, PlayerCharacter, TESObjectREFR* apObject // The inventory change event should always be sent to the server, otherwise the server inventory won't be updated. bool shouldUpdateClients = apObject->IsTemporary() && !ScopedActivateOverride::IsOverriden(); - // The player still needs its server entity and ownership epoch so stale inventory events can be rejected. - if (const auto ownershipToken = Utils::GetLocalOwnershipToken(apThis->formID)) - { - InventoryChangeEvent event(apThis->formID, std::move(item), false, shouldUpdateClients); - event.ServerId = ownershipToken->ServerId; - event.OwnershipEpoch = ownershipToken->OwnershipEpoch; - World::Get().GetRunner().Trigger(std::move(event)); - } + World::Get().GetRunner().Trigger(InventoryChangeEvent(apThis->formID, std::move(item), shouldUpdateClients)); + + // A dropped item may be server-tracked; the other clients need to delete their copy. + if (apObject->IsTemporary()) + World::Get().GetRunner().Trigger(ReferencePickUpEvent(apObject->formID)); ScopedInventoryOverride _; @@ -196,11 +196,10 @@ char TP_MAKE_THISCALL(HookPickUpObject, PlayerCharacter, TESObjectREFR* apObject void TP_MAKE_THISCALL(HookSetBeastForm, void, void* apUnk1, void* apUnk2, bool aEntering) { + // Graph hash must flip before RealSetBeastForm; appearance RequestRespawn is owned by + // PlayerService::RunBeastFormDetection (post-race), so we do not fire BeastFormChangeEvent here. if (!aEntering) - { PlayerCharacter::Get()->GetExtension()->GraphDescriptorHash = BehaviorVar::GetHumanoidHash(); - World::Get().GetRunner().Trigger(BeastFormChangeEvent()); - } TiltedPhoques::ThisCall(RealSetBeastForm, apThis, apUnk1, apUnk2, aEntering); } diff --git a/Code/client/Games/Skyrim/Projectiles/Projectile.cpp b/Code/client/Games/Skyrim/Projectiles/Projectile.cpp index 201b640f48..dec4f4357c 100644 --- a/Code/client/Games/Skyrim/Projectiles/Projectile.cpp +++ b/Code/client/Games/Skyrim/Projectiles/Projectile.cpp @@ -97,12 +97,20 @@ BSPointerHandle* TP_MAKE_THISCALL(HookLaunch, BSPointerHandlehandle.iBits); Projectile* pProjectile = Cast(pObject); - - TP_ASSERT(pProjectile, "No projectile found."); + if (!pProjectile) + { + spdlog::warn("No projectile found for handle {:X}, not syncing the launch", result->handle.iBits); + return result; + } Event.Power = pProjectile->fPower; 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/Games/Skyrim/TESObjectREFR.cpp b/Code/client/Games/Skyrim/TESObjectREFR.cpp index 1c9acd925e..806feb168e 100644 --- a/Code/client/Games/Skyrim/TESObjectREFR.cpp +++ b/Code/client/Games/Skyrim/TESObjectREFR.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -40,21 +42,10 @@ extern const AnimationGraphDescriptor* BehaviorVarPatch(BSAnimationGraphManager* namespace { -void QueueReferenceInventoryChange(TESObjectREFR* apReference, InventoryChangeEvent aEvent, TESObjectREFR* apTransferReference) +// AI updates run on job threads, so ownership is resolved when the runner hands the event to the main thread +void QueueReferenceInventoryChange(InventoryChangeEvent aEvent, TESObjectREFR* apTransferReference) { - if (const auto* pActor = Cast(apReference)) - { - auto ownershipToken = Utils::GetLocalOwnershipToken(pActor->formID); - if (!ownershipToken && apTransferReference == PlayerCharacter::Get()) - ownershipToken = Utils::GetRemoteOwnershipToken(pActor->formID); - - if (!ownershipToken) - return; - - aEvent.ServerId = ownershipToken->ServerId; - aEvent.OwnershipEpoch = ownershipToken->OwnershipEpoch; - } - + aEvent.IsPlayerTransfer = apTransferReference && apTransferReference == PlayerCharacter::Get(); World::Get().GetRunner().Trigger(std::move(aEvent)); } @@ -183,6 +174,63 @@ void TESObjectREFR::SetRotation(float aX, float aY, float aZ) noexcept TiltedPhoques::ThisCall(RealRotateZ, this, aZ); } +void TESObjectREFR::SetLocation(const NiPoint3& acPosition) noexcept +{ + TP_THIS_FUNCTION(TSetLocation, void, TESObjectREFR, const NiPoint3&); + POINTER_SKYRIMSE(TSetLocation, s_setLocation, 19790); + TiltedPhoques::ThisCall(s_setLocation, this, acPosition); +} + +// Returns false while the reference has no 3D or collision yet. +bool TESObjectREFR::SetMotionType(MotionType aMotionType) noexcept +{ + NiNode* pNode = GetNiNode(); + if (!pNode) + return false; + + TP_THIS_FUNCTION(TSetMotionType, bool, NiNode, uint32_t aMotionType, bool aRecurse, bool aForce, bool aAllowActivate); + POINTER_SKYRIMSE(TSetMotionType, s_setMotionType, 77866); + + const bool result = TiltedPhoques::ThisCall(s_setMotionType, pNode, static_cast(aMotionType), true, false, true); + if (result) + MarkChanged(CHANGE_REFR_HAVOK_MOVE); + + return result; +} + +// Moves a keyframed reference to the target over time, at the given speed in units per second. +void TESObjectREFR::TranslateTo(const NiPoint3& acPosition, const NiPoint3& acRotation, float aSpeed) noexcept +{ + using ObjectReference = TESObjectREFR; + + PAPYRUS_FUNCTION(void, ObjectReference, TranslateTo, float, float, float, float, float, float, float, float); + + // Papyrus takes degrees; a max rotation speed of 0 means unlimited. + s_pTranslateTo(this, acPosition.x, acPosition.y, acPosition.z, glm::degrees(acRotation.x), glm::degrees(acRotation.y), glm::degrees(acRotation.z), aSpeed, 0.f); +} + +void TESObjectREFR::StopTranslation() noexcept +{ + using ObjectReference = TESObjectREFR; + + PAPYRUS_FUNCTION(void, ObjectReference, StopTranslation); + + s_pStopTranslation(this); +} + +// Gives a newly placed item reference the count and extra data of an inventory entry, +// like the engine does when an actor drops an item. +void TESObjectREFR::SetItemData(const Inventory::Entry& arEntry) noexcept +{ + // ExtraCount stores an int16, so larger stacks would wrap negative. + if (arEntry.Count > 1) + extraData.SetCount(static_cast(std::min(arEntry.Count, static_cast(INT16_MAX)))); + + AddExtraDataFromItem(arEntry, extraData, false); + + MarkChanged(CHANGE_REFR_ITEM_EXTRA_DATA); +} + void TESObjectREFR::SetLeveledCreature(TESActorBase* apOriginalBase, TESActorBase* apTemplateA) noexcept { TP_THIS_FUNCTION(TSetLeveledCreature, void, TESObjectREFR, TESActorBase*, TESActorBase*); @@ -569,6 +617,7 @@ void TESObjectREFR::GetItemFromExtraData(Inventory::Entry& arEntry, ExtraDataLis { modSystem.GetServerModId(pExtraPoison->pPoison->formID, arEntry.ExtraPoisonId); arEntry.ExtraPoisonCount = pExtraPoison->uiCount; + AlchemyItem::CaptureRecipe(pExtraPoison->pPoison, arEntry.PoisonData); } } @@ -595,8 +644,6 @@ void TESObjectREFR::GetItemFromExtraData(Inventory::Entry& arEntry, ExtraDataLis ExtraDataList* TESObjectREFR::GetExtraDataFromItem(const Inventory::Entry& arEntry) noexcept { - auto& modSystem = World::Get().GetModSystem(); - ExtraDataList* pExtraDataList = nullptr; if (!arEntry.ContainsExtraData()) @@ -604,9 +651,25 @@ ExtraDataList* TESObjectREFR::GetExtraDataFromItem(const Inventory::Entry& arEnt pExtraDataList = ExtraDataList::New(); + AddExtraDataFromItem(arEntry, *pExtraDataList, true); + + if (pExtraDataList->data == nullptr) + { + Memory::Delete(pExtraDataList->bitfield); + Memory::Delete(pExtraDataList); + pExtraDataList = nullptr; + } + + return pExtraDataList; +} + +void TESObjectREFR::AddExtraDataFromItem(const Inventory::Entry& arEntry, ExtraDataList& aExtraDataList, bool aIncludeWorn) noexcept +{ + auto& modSystem = World::Get().GetModSystem(); + if (arEntry.ExtraCharge > 0.f) { - pExtraDataList->SetChargeData(arEntry.ExtraCharge); + aExtraDataList.SetChargeData(arEntry.ExtraCharge); } if (arEntry.ExtraEnchantId != 0) @@ -622,42 +685,53 @@ ExtraDataList* TESObjectREFR::GetExtraDataFromItem(const Inventory::Entry& arEnt pEnchantment = Cast(TESForm::GetById(enchantId)); } - TP_ASSERT(pEnchantment, "No Enchantment created or found."); - - pExtraDataList->SetEnchantmentData(pEnchantment, arEntry.ExtraEnchantCharge, arEntry.ExtraEnchantRemoveUnequip); + // Null when the enchantment (or all of a created one's effects) comes from a mod this client doesn't have. + // Attaching a null enchantment crashes later when the item is equipped or shown, so sync the item without it. + if (pEnchantment) + aExtraDataList.SetEnchantmentData(pEnchantment, arEntry.ExtraEnchantCharge, arEntry.ExtraEnchantRemoveUnequip); + else + spdlog::warn( + "Item {:X}:{:X}: skipping enchantment {:X}:{:X}, not available on this client (missing mod?)", arEntry.BaseId.ModId, arEntry.BaseId.BaseId, + arEntry.ExtraEnchantId.ModId, arEntry.ExtraEnchantId.BaseId); } if (arEntry.ExtraPoisonId != 0) { - // TODO: does poison have the same temp problem as enchants? - // doesn't seem to be the case, there are only like 3 poisons, and no custom ones - TP_ASSERT(arEntry.ExtraPoisonId.ModId != 0xFFFFFFFF, "Poison is sent as temp!"); - - uint32_t poisonId = modSystem.GetGameId(arEntry.ExtraPoisonId); - if (AlchemyItem* pPoison = Cast(TESForm::GetById(poisonId))) + AlchemyItem* pPoison = nullptr; + if (arEntry.ExtraPoisonId.ModId == 0xFFFFFFFF) { - pExtraDataList->SetPoison(pPoison, arEntry.ExtraPoisonCount); + // Player-crafted poison: recreate it from the owner's recipe. The created-object reference + // is kept so the form outlives this extra data list being attached to the item. + pPoison = AlchemyItem::Create(arEntry.PoisonData); + if (!pPoison) + spdlog::warn("{}: Unable to recreate applied crafted poison", __FUNCTION__); + } + else + pPoison = Cast(TESForm::GetById(modSystem.GetGameId(arEntry.ExtraPoisonId))); + if (pPoison) + { + aExtraDataList.SetPoison(pPoison, arEntry.ExtraPoisonCount); } } if (arEntry.ExtraHealth > 0.f) { - pExtraDataList->SetHealth(arEntry.ExtraHealth); + aExtraDataList.SetHealth(arEntry.ExtraHealth); } if (arEntry.ExtraSoulLevel > 0 && arEntry.ExtraSoulLevel <= 5) { - pExtraDataList->SetSoulData(static_cast(arEntry.ExtraSoulLevel)); + aExtraDataList.SetSoulData(static_cast(arEntry.ExtraSoulLevel)); } - if (arEntry.ExtraWorn) + if (aIncludeWorn && arEntry.ExtraWorn) { - pExtraDataList->SetWorn(false); + aExtraDataList.SetWorn(false); } - if (arEntry.ExtraWornLeft) + if (aIncludeWorn && arEntry.ExtraWornLeft) { - pExtraDataList->SetWorn(true); + aExtraDataList.SetWorn(true); } // TODO: this is causing crashes @@ -671,18 +745,9 @@ ExtraDataList* TESObjectREFR::GetExtraDataFromItem(const Inventory::Entry& arEnt pExtraText->usCustomNameLength = arEntry.ExtraTextDisplayName.length(); pExtraText->iOwnerInstance = -2; pExtraText->fTemperFactor = 1.0F; - pExtraDataList->Add(ExtraDataType::TextDisplayData, pExtraText); + aExtraDataList.Add(ExtraDataType::TextDisplayData, pExtraText); } */ - - if (pExtraDataList->data == nullptr) - { - Memory::Delete(pExtraDataList->bitfield); - Memory::Delete(pExtraDataList); - pExtraDataList = nullptr; - } - - return pExtraDataList; } Inventory TESObjectREFR::GetInventory() const noexcept @@ -711,6 +776,7 @@ Inventory TESObjectREFR::GetInventory(std::function aFilter) con Inventory::Entry entry; modSystem.GetServerModId(pGameEntry->form->formID, entry.BaseId); + AlchemyItem::Capture(pGameEntry->form, entry); entry.Count = pGameEntry->count; inventory.Entries.push_back(std::move(entry)); @@ -730,6 +796,7 @@ Inventory TESObjectREFR::GetInventory(std::function aFilter) con Inventory::Entry entry{}; modSystem.GetServerModId(pGameEntry->form->formID, entry.BaseId); + AlchemyItem::Capture(pGameEntry->form, entry); entry.Count = pGameEntry->count; for (ExtraDataList* pExtraDataList : *pGameEntry->dataList) @@ -739,6 +806,7 @@ Inventory TESObjectREFR::GetInventory(std::function aFilter) con Inventory::Entry innerEntry; innerEntry.BaseId = entry.BaseId; + innerEntry.Potion = entry.Potion; innerEntry.Count = 1; GetItemFromExtraData(innerEntry, pExtraDataList); @@ -820,11 +888,49 @@ void TESObjectREFR::SetInventory(const Inventory& aInventory) noexcept { spdlog::debug("Setting inventory for {:X}", formID); + // Adapted from upstream PR #880, commit 040ecbde: supply a body model + // when a remote player's snapshot has no worn torso armor. The received + // snapshot stays unchanged; only the inventory applied on this client differs. + Inventory applied = aInventory; + if (auto* pActor = Cast(this); pActor && pActor->GetExtension()->IsRemotePlayer()) + { + auto& modSystem = World::Get().GetModSystem(); + const bool hasBody = std::any_of(applied.Entries.begin(), applied.Entries.end(), + [&modSystem](const Inventory::Entry& aEntry) + { + if (aEntry.Count <= 0 || !aEntry.IsWorn()) + return false; + + auto* pArmor = Cast(TESForm::GetById(modSystem.GetGameId(aEntry.BaseId))); + return pArmor && (pArmor->slotType & 0x4) != 0; + }); + + if (!hasBody) + { + // Preserve the original commit's Dawnguard body stand-in, but resolve + // both the local form and server mod ID instead of assuming load order. + if (auto* pDawnguard = ModManager::Get()->GetByName("Dawnguard.esm")) + { + auto* pBodyArmor = Cast(TESForm::GetById(pDawnguard->GetFormId(0x11A85))); + GameId bodyId; + if (pBodyArmor && (pBodyArmor->slotType & 0x4) != 0 && modSystem.GetServerModId(pBodyArmor->formID, bodyId)) + { + Inventory::Entry bodyEntry{}; + bodyEntry.BaseId = bodyId; + bodyEntry.Count = 1; + bodyEntry.ExtraWorn = true; + applied.Entries.push_back(bodyEntry); + spdlog::debug("[NakedFix] Applied body fallback to remote player {:X}", formID); + } + } + } + } + ScopedInventoryOverride _; RemoveAllItems(); - for (const Inventory::Entry& entry : aInventory.Entries) + for (const Inventory::Entry& entry : applied.Entries) { if (entry.Count != 0) AddOrRemoveItem(entry, true); @@ -886,17 +992,25 @@ void TESObjectREFR::AddOrRemoveItem(const Inventory::Entry& arEntry, bool aIsSet ModSystem& modSystem = World::Get().GetModSystem(); uint32_t objectId = modSystem.GetGameId(arEntry.BaseId); + AlchemyItem* createdPotion = nullptr; TESBoundObject* pObject = Cast(TESForm::GetById(objectId)); + if (!arEntry.Potion.Effects.empty()) + { + if (arEntry.Count > 0) + pObject = createdPotion = AlchemyItem::Create(arEntry.Potion); + else + pObject = AlchemyItem::Find(this, arEntry.Potion); + objectId = pObject ? pObject->formID : 0; + } if (!pObject) { spdlog::warn("{}: Object to add not found, {:X}:{:X}.", __FUNCTION__, arEntry.BaseId.ModId, arEntry.BaseId.BaseId); return; } - ExtraDataList* pExtraDataList = GetExtraDataFromItem(arEntry); - if (arEntry.Count > 0) { + ExtraDataList* pExtraDataList = GetExtraDataFromItem(arEntry); bool isWorn = false; bool isWornLeft = false; if (pExtraDataList) @@ -917,7 +1031,41 @@ void TESObjectREFR::AddOrRemoveItem(const Inventory::Entry& arEntry, bool aIsSet else if (arEntry.Count < 0) { spdlog::debug("Removing item {:X}, count {}", pObject->formID, -arEntry.Count); - RemoveItem(pObject, -arEntry.Count, ITEM_REMOVE_REASON::kRemove, pExtraDataList, nullptr); + // Removal must name the inventory's own extra data list. A freshly built list matches none of the + // stored ones, so an enchanted, renamed or poisoned item would stay while a plain copy was taken. + auto removalList = [this, &arEntry](TESBoundObject* apItem) -> ExtraDataList* { + if (!arEntry.ContainsExtraData()) + return nullptr; + if (ExtraDataList* pExisting = FindExtraDataForItem(apItem, arEntry)) + return pExisting; + // Never hand RemoveItem a list the inventory does not own; remote copies often lack the exact data. + spdlog::warn("{}: No stored extra data matches item {:X}; removing without extra data", __FUNCTION__, apItem->formID); + return nullptr; + }; + if (arEntry.Potion.Effects.empty()) + RemoveItem(pObject, -arEntry.Count, ITEM_REMOVE_REASON::kRemove, removalList(pObject), nullptr); + else + { + // Equivalent recipes can have different local IDs. Remove across their actual stacks. + int64_t remaining = -static_cast(arEntry.Count); + while (remaining > 0) + { + auto* potion = AlchemyItem::Find(this, arEntry.Potion); + if (!potion) + break; + const auto before = GetItemCountInInventory(potion); + const auto count = static_cast(std::min(remaining, before)); + if (count <= 0) + break; + RemoveItem(potion, count, ITEM_REMOVE_REASON::kRemove, removalList(potion), nullptr); + const auto removed = before - GetItemCountInInventory(potion); + if (removed <= 0) + break; + remaining -= removed; + } + if (remaining) + spdlog::error("[TradeService]: Crafted potion removal was short by {} items", remaining); + } } // TODO(cosideci): this is still flawed. Adding the refr to the quest leader is hard. @@ -934,9 +1082,40 @@ void TESObjectREFR::AddOrRemoveItem(const Inventory::Entry& arEntry, bool aIsSet } } + // The created potion's reference is deliberately kept: dropping it could free a form the inventory + // still points at if the inventory does not hold its own created-object reference. + (void)createdPotion; UpdateItemList(nullptr); } +ExtraDataList* TESObjectREFR::FindExtraDataForItem(TESBoundObject* apObject, const Inventory::Entry& arEntry) const noexcept +{ + auto* pChanges = GetContainerChanges(); + if (!apObject || !pChanges || !pChanges->entries) + return nullptr; + + for (auto* pEntry : *pChanges->entries) + { + if (!pEntry || pEntry->form != apObject || !pEntry->dataList) + continue; + for (ExtraDataList* pExtraDataList : *pEntry->dataList) + { + if (!pExtraDataList) + continue; + Inventory::Entry local{}; + local.BaseId = arEntry.BaseId; + local.Potion = arEntry.Potion; + { + ScopedExtraDataOverride _; + GetItemFromExtraData(local, pExtraDataList); + } + if (local.CanBeMerged(arEntry)) + return pExtraDataList; + } + } + return nullptr; +} + void TESObjectREFR::UpdateItemList(TESForm* pUnkForm) noexcept { TP_THIS_FUNCTION(TUpdateItemList, void, TESObjectREFR, TESForm*); @@ -1056,12 +1235,13 @@ void TP_MAKE_THISCALL(HookAddInventoryItem, TESObjectREFR, TESBoundObject* apIte Inventory::Entry item{}; modSystem.GetServerModId(apItem->formID, item.BaseId); + AlchemyItem::Capture(apItem, item); item.Count = aCount; if (apExtraData) apThis->GetItemFromExtraData(item, apExtraData); - QueueReferenceInventoryChange(apThis, InventoryChangeEvent(apThis->formID, std::move(item)), apOldOwner); + QueueReferenceInventoryChange(InventoryChangeEvent(apThis->formID, std::move(item)), apOldOwner); } spdlog::debug("Adding inventory item {:X} to {:X}", apItem->formID, apThis->formID); @@ -1078,6 +1258,7 @@ TP_MAKE_THISCALL(HookRemoveInventoryItem, TESObjectREFR, BSPointerHandleformID, item.BaseId); + AlchemyItem::Capture(apItem, item); if (apExtraList) { @@ -1087,7 +1268,7 @@ TP_MAKE_THISCALL(HookRemoveInventoryItem, TESObjectREFR, BSPointerHandleformID, std::move(item)), apMoveToRef); + QueueReferenceInventoryChange(InventoryChangeEvent(apThis->formID, std::move(item)), apMoveToRef); } spdlog::debug("Removing inventory item {:X} from {:X}", apItem->formID, apThis->formID); diff --git a/Code/client/Games/Skyrim/TESObjectREFR.h b/Code/client/Games/Skyrim/TESObjectREFR.h index a16241aabe..8d96a8dab9 100644 --- a/Code/client/Games/Skyrim/TESObjectREFR.h +++ b/Code/client/Games/Skyrim/TESObjectREFR.h @@ -41,6 +41,7 @@ struct TESObjectREFR : TESForm CHANGE_REFR_INVENTORY = 1 << 5, CHANGE_REFR_EXTRA_OWNERSHIP = 1 << 6, CHANGE_REFR_BASEOBJECT = 1 << 7, + CHANGE_REFR_ITEM_EXTRA_DATA = 1 << 10, CHANGE_REFR_PROMOTED = 1 << 25, CHANGE_REFR_EXTRA_ACTIVATING_CHILDREN = 1 << 26, CHANGE_REFR_LEVELED_INVENTORY = 1 << 27, @@ -50,6 +51,13 @@ struct TESObjectREFR : TESForm CHANGE_REFR_EXTRA_GAME_ONLY = 1u << 31, }; + // hkpMotion::MotionType + enum class MotionType : uint32_t + { + kDynamic = 1, + kKeyframed = 4, + }; + enum OpenState : uint8_t { kNone = 0, @@ -64,6 +72,9 @@ struct TESObjectREFR : TESForm static void GetItemFromExtraData(Inventory::Entry& arEntry, ExtraDataList* apExtraDataList) noexcept; static ExtraDataList* GetExtraDataFromItem(const Inventory::Entry& arEntry) noexcept; + // The inventory's own extra data list describing this entry, so removal takes that exact item. + ExtraDataList* FindExtraDataForItem(TESBoundObject* apObject, const Inventory::Entry& arEntry) const noexcept; + static void AddExtraDataFromItem(const Inventory::Entry& arEntry, ExtraDataList& aExtraDataList, bool aIncludeWorn) noexcept; virtual void sub_3B(); virtual void sub_3C(); @@ -140,7 +151,8 @@ struct TESObjectREFR : TESForm virtual void sub_83(); virtual void SetObjectReference(TESBoundObject* apObject); virtual void sub_85(); - virtual void sub_86(); + // Leaves the velocity at zero when there is no rigid body. The return value is not a reliable bool, so it is void here. + virtual void GetLinearVelocity(NiPoint3& aVelocity) const; virtual void sub_87(); virtual void sub_88(); virtual void DisableImpl(); @@ -166,6 +178,11 @@ struct TESObjectREFR : TESForm virtual void sub_9B(); void SetRotation(float aX, float aY, float aZ) noexcept; + void SetLocation(const NiPoint3& acPosition) noexcept; + bool SetMotionType(MotionType aMotionType) noexcept; + void TranslateTo(const NiPoint3& acPosition, const NiPoint3& acRotation, float aSpeed) noexcept; + void StopTranslation() noexcept; + void SetItemData(const Inventory::Entry& arEntry) noexcept; void SetLeveledCreature(TESActorBase* apOriginalBase, TESActorBase* apTemplateA) noexcept; BSPointerHandle GetHandle() const noexcept; diff --git a/Code/client/Games/TES.h b/Code/client/Games/TES.h index 8c19d3a5cf..101349f3d6 100644 --- a/Code/client/Games/TES.h +++ b/Code/client/Games/TES.h @@ -7,6 +7,8 @@ struct NiPoint3; struct TESForm; struct TESFaction; struct Actor; +struct TESObjectREFR; +struct TESBoundObject; struct ImageSpaceModifierInstance; struct GridCellArray @@ -107,6 +109,7 @@ struct ModManager static ModManager* Get() noexcept; uint32_t Spawn(NiPoint3& aPosition, NiPoint3& aRotation, TESObjectCELL* apParentCell, TESWorldSpace* apWorldSpace, Actor* apCharacter) noexcept; + TESObjectREFR* SpawnReference(TESBoundObject* apBaseForm, NiPoint3& aPosition, NiPoint3& aRotation, TESObjectCELL* apParentCell, TESWorldSpace* apWorldSpace) noexcept; Mod* GetByName(const char* acpName) const noexcept; TESObjectCELL* GetCellFromCoordinates(int32_t aX, int32_t aY, TESWorldSpace* aWorldSpace, bool aSpawnCell) noexcept; diff --git a/Code/client/ModCompat/BehaviorVar.cpp b/Code/client/ModCompat/BehaviorVar.cpp index d9266b418b..a8f53e0fc5 100644 --- a/Code/client/ModCompat/BehaviorVar.cpp +++ b/Code/client/ModCompat/BehaviorVar.cpp @@ -80,10 +80,15 @@ void LowerCaseKeys(const TiltedPhoques::Map& ac aLowerCaseMap.insert({ToLowerCase(item.first), item.second}); } -// Process a set of variables, adding them to the aVariableSet +// Slot of a synced variable this graph doesn't have. Save/LoadAnimationVariables skip indices outside the variable set, +// so the slot keeps its place on the wire without touching the graph. +constexpr uint32_t kMissingVariable = std::numeric_limits::max(); + +// Process a list of original variables, translating each to its index in this graph. Variables are synced by slot +// position, so every name keeps its slot, missing ones as kMissingVariable. void ProcessVariableSet( TiltedPhoques::Map& acReverseMap, - TiltedPhoques::Set& aVariableSet, + TiltedPhoques::Vector& aVariables, const TiltedPhoques::Vector& acVariables, spdlog::level::level_enum aLogLevel) { @@ -92,6 +97,12 @@ void ProcessVariableSet( for (const auto& item : acVariables) { + if (item.empty()) + { + aVariables.push_back(kMissingVariable); + continue; + } + auto foundForm = item; auto found = acReverseMap.find(item); if (found == acReverseMap.end()) @@ -108,25 +119,42 @@ void ProcessVariableSet( found = acReverseMap.find(foundForm); } - if (found == acReverseMap.end()) + if (found != acReverseMap.end()) { - // Check case-independent - if (lowerCaseMap.empty()) - LowerCaseKeys(acReverseMap, lowerCaseMap); - found = lowerCaseMap.find(foundForm); + aVariables.push_back(found->second); + if (item != found->first) + spdlog::warn(__FUNCTION__ ": misspelled variable {} corrected to {}", item, found->first); + continue; } - if (found == acReverseMap.end()) - spdlog::warn(__FUNCTION__ ": unable to find variable {} in any of the common misspellings", item); + // Check case-independent + if (lowerCaseMap.empty()) + LowerCaseKeys(acReverseMap, lowerCaseMap); + + const auto lowerCaseFound = lowerCaseMap.find(foundForm); + if (lowerCaseFound != lowerCaseMap.end()) + { + aVariables.push_back(lowerCaseFound->second); + spdlog::warn(__FUNCTION__ ": misspelled variable {} corrected to index {}", item, lowerCaseFound->second); + } else { - aVariableSet.insert(found->second); - if (item != found->first) - spdlog::warn(__FUNCTION__ ": misspelled variable {} corrected to {}", item, found->first); + spdlog::log(aLogLevel, __FUNCTION__ ": unable to find variable {} in any of the common misspellings", item); + aVariables.push_back(kMissingVariable); } } } +// The variables a replacer asks to sync on top of the original ones, without duplicates and in an order that doesn't +// depend on how the replacer files were enumerated. +TiltedPhoques::Vector SortedExtraVariables(const TiltedPhoques::Vector& acVariables) +{ + TiltedPhoques::Vector result(acVariables.begin(), acVariables.end()); + std::sort(result.begin(), result.end(), [](const auto& acLhs, const auto& acRhs) { return ToLowerCase(acLhs) < ToLowerCase(acRhs); }); + result.erase(std::unique(result.begin(), result.end(), [](const auto& acLhs, const auto& acRhs) { return ToLowerCase(acLhs) == ToLowerCase(acRhs); }), result.end()); + return result; +} + } // End anonymous namespace // @@ -144,13 +172,14 @@ void BehaviorVar::SeedAnimationVariables( const uint64_t acHash, const AnimationGraphDescriptor* acpDescriptor, TiltedPhoques::Map& acReverseMap, - TiltedPhoques::Set& aBoolVars, - TiltedPhoques::Set& aFloatVars, - TiltedPhoques::Set& aIntVars) + TiltedPhoques::Vector& aBoolVars, + TiltedPhoques::Vector& aFloatVars, + TiltedPhoques::Vector& aIntVars) { auto& origVars = BehaviorVarsMap::getInstance(); - // Prepare lists of variables to process + // Prepare lists of variables to process, in the original descriptor's order so the slots line up with clients + // running the unmodded behavior. An empty name keeps the slot of a variable we can't translate. TiltedPhoques::Vector boolVarNames; TiltedPhoques::Vector floatVarNames; TiltedPhoques::Vector intVarNames; @@ -158,22 +187,25 @@ void BehaviorVar::SeedAnimationVariables( // Populate lists from the original descriptor TiltedPhoques::String strValue; for (auto& item : acpDescriptor->BooleanLookUpTable) + { if ((strValue = origVars.find(acHash, item)).empty()) spdlog::error(__FUNCTION__ ": unable to find string for original BooleanVar {}", item); - else - boolVarNames.push_back(strValue); + boolVarNames.push_back(strValue); + } for (auto& item : acpDescriptor->FloatLookupTable) + { if ((strValue = origVars.find(acHash, item)).empty()) spdlog::error(__FUNCTION__ ": unable to find string for original FloatVar {}", item); - else - floatVarNames.push_back(strValue); + floatVarNames.push_back(strValue); + } for (auto& item : acpDescriptor->IntegerLookupTable) + { if ((strValue = origVars.find(acHash, item)).empty()) spdlog::error(__FUNCTION__ ": unable to find string for original IntVar {}", item); - else - intVarNames.push_back(strValue); + intVarNames.push_back(strValue); + } // Process each set of variables ProcessVariableSet(acReverseMap, aBoolVars, boolVarNames, spdlog::level::level_enum::err); @@ -214,10 +246,11 @@ const AnimationGraphDescriptor* BehaviorVar::ConstructModdedDescriptor( const Replacer& acReplacer, TiltedPhoques::Map& acReverseMap) { - // Build the set of BehaviorVar strings as sets (not vectors) to eliminate dups - TiltedPhoques::Set boolVar; - TiltedPhoques::Set floatVar; - TiltedPhoques::Set intVar; + // Variables are synced by slot position, so the slots must not depend on where this graph keeps them. + // The original variables come first in the original descriptor's order, then the replacer's extras sorted by name. + TiltedPhoques::Vector boolVector; + TiltedPhoques::Vector floatVector; + TiltedPhoques::Vector intVector; // If we can find the original behavior that is being modded, // get the descriptor and seed the behavior vars with it. @@ -226,73 +259,56 @@ const AnimationGraphDescriptor* BehaviorVar::ConstructModdedDescriptor( const AnimationGraphDescriptor* pTmpGraph = nullptr; if (acReplacer.origHash && (pTmpGraph = AnimationGraphDescriptorManager::Get().GetDescriptor(acReplacer.origHash))) { - SeedAnimationVariables(acReplacer.origHash, pTmpGraph, acReverseMap, boolVar, floatVar, intVar); + SeedAnimationVariables(acReplacer.origHash, pTmpGraph, acReverseMap, boolVector, floatVector, intVector); spdlog::info( __FUNCTION__ ": Original game descriptor with hash {} has {} boolean, {} float, {} integer behavior vars", acReplacer.origHash, - boolVar.size(), - floatVar.size(), - intVar.size()); + boolVector.size(), + floatVector.size(), + intVector.size()); } - // Check requested behavior vars for those that ARE legit - // behavior vars for this Actor AND are not yet synced - int foundCount = 0; - for (auto& item : acReplacer.syncBooleanVar) + // Add the requested behavior vars that aren't original ones. Every requested name gets a slot, the ones this graph + // doesn't have as kMissingVariable, so the slots line up between clients whose behavior mods differ. Which names are + // skipped only depends on the names, never on this graph. + const auto addExtras = [&](TiltedPhoques::Vector& aVector, const TiltedPhoques::Vector* acpOriginalTable, + const TiltedPhoques::Vector& acNames, const char* acpType) { - bool found = acReverseMap.find(item) != acReverseMap.end(); - if (found && boolVar.find(acReverseMap[item]) == boolVar.end()) + TiltedPhoques::Set originalNames; + if (pTmpGraph && acpOriginalTable) { - if (foundCount++ == 0) - spdlog::info("Boolean variables added to sync:"); - boolVar.insert(acReverseMap[item]); - spdlog::info(" {}", item); + for (auto item : *acpOriginalTable) + originalNames.insert(ToLowerCase(BehaviorVarsMap::getInstance().find(acReplacer.origHash, item))); } - } - if (foundCount) - spdlog::info("Now have {} boolVar descriptors after searching {} BehavivorVar strings", boolVar.size(), acReplacer.syncBooleanVar.size()); - foundCount = 0; - for (auto& item : acReplacer.syncFloatVar) - { - bool found = acReverseMap.find(item) != acReverseMap.end(); - if (found && floatVar.find(acReverseMap[item]) == floatVar.end()) + int foundCount = 0; + int missingCount = 0; + for (auto& item : SortedExtraVariables(acNames)) { - if (foundCount++ == 0) - spdlog::info("Float variables added to sync:"); - floatVar.insert(acReverseMap[item]); - spdlog::info(" {}", item); - } - } - if (foundCount) - spdlog::info("Now have {} floatVar descriptors after searching {} BehavivorVar strings", floatVar.size(), acReplacer.syncFloatVar.size()); + if (originalNames.find(ToLowerCase(item)) != originalNames.end()) + continue; + + const auto found = acReverseMap.find(item); + if (found == acReverseMap.end()) + { + aVector.push_back(kMissingVariable); + missingCount++; + continue; + } - foundCount = 0; - for (auto& item : acReplacer.syncIntegerVar) - { - bool found = acReverseMap.find(item) != acReverseMap.end(); - if (found && intVar.find(acReverseMap[item]) == intVar.end()) - { if (foundCount++ == 0) - spdlog::info("Int variables added to sync:"); - intVar.insert(acReverseMap[item]); + spdlog::info("{} variables added to sync:", acpType); + aVector.push_back(found->second); spdlog::info(" {}", item); } - } - if (foundCount) - spdlog::info(__FUNCTION__ ": now have {} intVar descriptors after searching {} BehavivorVar strings", intVar.size(), acReplacer.syncIntegerVar.size()); - - // We need the sets sorted, and TiltedPhoques::Set is not. Copying to an std::set - // isn't the most efficient approach, but it is simple and doesn't happen often enough - // to worry about. And doing it here isolates/minimizes use of std::set. - std::set boolVarSorted(boolVar.begin(), boolVar.end()); - std::set floatVarSorted(floatVar.begin(), floatVar.end()); - std::set intVarSorted(intVar.begin(), intVar.end()); - - // Reshape the (sorted, unique) sets to vectors - TiltedPhoques::Vector boolVector(boolVarSorted.begin(), boolVarSorted.end()); - TiltedPhoques::Vector floatVector(floatVarSorted.begin(), floatVarSorted.end()); - TiltedPhoques::Vector intVector(intVarSorted.begin(), intVarSorted.end()); + if (foundCount || missingCount) + spdlog::info(__FUNCTION__ ": now have {} {} descriptors ({} not in this behavior) after searching {} BehavivorVar strings", aVector.size(), + acpType, missingCount, acNames.size()); + }; + + addExtras(boolVector, pTmpGraph ? &pTmpGraph->BooleanLookUpTable : nullptr, acReplacer.syncBooleanVar, "Boolean"); + addExtras(floatVector, pTmpGraph ? &pTmpGraph->FloatLookupTable : nullptr, acReplacer.syncFloatVar, "Float"); + addExtras(intVector, pTmpGraph ? &pTmpGraph->IntegerLookupTable : nullptr, acReplacer.syncIntegerVar, "Int"); // Construct a new descriptor auto panimGraphDescriptor = new AnimationGraphDescriptor(); diff --git a/Code/client/ModCompat/BehaviorVar.h b/Code/client/ModCompat/BehaviorVar.h index a70e0a3e80..fae9af95fb 100644 --- a/Code/client/ModCompat/BehaviorVar.h +++ b/Code/client/ModCompat/BehaviorVar.h @@ -42,9 +42,9 @@ struct BehaviorVar const uint64_t acHash, const AnimationGraphDescriptor* acpDescriptor, TiltedPhoques::Map& acReverseMap, - TiltedPhoques::Set& aBoolVars, - TiltedPhoques::Set& aFloatVars, - TiltedPhoques::Set& aIntVars); + TiltedPhoques::Vector& aBoolVars, + TiltedPhoques::Vector& aFloatVars, + TiltedPhoques::Vector& aIntVars); const TiltedPhoques::Vector TokenizeBehaviorSig(const TiltedPhoques::String& acSignature) const; const AnimationGraphDescriptor* ConstructModdedDescriptor( 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/CalendarService.h b/Code/client/Services/CalendarService.h index dd442b6019..8e4e55c8d2 100644 --- a/Code/client/Services/CalendarService.h +++ b/Code/client/Services/CalendarService.h @@ -13,14 +13,24 @@ struct TransportService; /** * @brief Handles time sync. */ -class CalendarService final : public BSTEventSink +class CalendarService final : public BSTEventSink, public BSTEventSink, public BSTEventSink, public BSTEventSink { public: CalendarService(World&, entt::dispatcher&, TransportService&); + ~CalendarService() override; static bool AllowGameTick() noexcept; + // Sleeping and waiting are turned into a shared time skip (#467). + BSTEventResult OnEvent(const TESSleepStartEvent* apEvent, const EventDispatcher*) override; + BSTEventResult OnEvent(const TESSleepStopEvent* apEvent, const EventDispatcher*) override; + BSTEventResult OnEvent(const TESWaitStartEvent* apEvent, const EventDispatcher*) override; + BSTEventResult OnEvent(const TESWaitStopEvent* apEvent, const EventDispatcher*) override; + private: + void BeginTimeSkip(float aStartTimeInDays, float aDesiredEndTimeInDays, const char* apKind) noexcept; + void EndTimeSkip(bool aInterrupted, const char* apKind) noexcept; + void OnTimeUpdate(const ServerTimeSettings&) noexcept; void HandleUpdate(const UpdateEvent&) noexcept; void OnDisconnected(const DisconnectedEvent&) noexcept; @@ -34,11 +44,15 @@ class CalendarService final : public BSTEventSink DateTime m_onlineTime; DateTime m_offlineTime; + // Mirror of the server clock (date included, whatever bSyncPlayerCalendar says) to measure time jumps. + DateTime m_serverTime; + bool m_hasServerTime = false; float m_fadeTimer = 0.f; static bool s_gameClockLocked; uint64_t m_lastTick = 0; uint64_t m_lastLogTick = 0; + float m_pendingSkipHours = 0.f; World& m_world; TransportService& m_transport; }; diff --git a/Code/client/Services/CharacterService.h b/Code/client/Services/CharacterService.h index 031d4e24ce..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; @@ -109,6 +110,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/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/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/Debug/Views/AnimDebugView.cpp b/Code/client/Services/Debug/Views/AnimDebugView.cpp index 75b5ae80cc..02213522fd 100644 --- a/Code/client/Services/Debug/Views/AnimDebugView.cpp +++ b/Code/client/Services/Debug/Views/AnimDebugView.cpp @@ -155,18 +155,24 @@ void DebugService::DrawAnimDebugView() for (size_t i = 0; i < pDescriptor->BooleanLookUpTable.size(); ++i) { const auto idx = pDescriptor->BooleanLookUpTable[i]; + if (idx >= pVariableSet->size) + continue; bools[s_varMap[idx]] = *reinterpret_cast(&pVariableSet->data[idx]); } for (size_t i = 0; i < pDescriptor->FloatLookupTable.size(); ++i) { const auto idx = pDescriptor->FloatLookupTable[i]; + if (idx >= pVariableSet->size) + continue; floats[s_varMap[idx]] = *reinterpret_cast(&pVariableSet->data[idx]); } for (size_t i = 0; i < pDescriptor->IntegerLookupTable.size(); ++i) { const auto idx = pDescriptor->IntegerLookupTable[i]; + if (idx >= pVariableSet->size) + continue; ints[s_varMap[idx]] = *reinterpret_cast(&pVariableSet->data[idx]); } diff --git a/Code/client/Services/DroppedItemService.h b/Code/client/Services/DroppedItemService.h new file mode 100644 index 0000000000..6fbe25a896 --- /dev/null +++ b/Code/client/Services/DroppedItemService.h @@ -0,0 +1,91 @@ +#pragma once + +#include + +struct World; +struct TransportService; +struct TESObjectREFR; + +struct UpdateEvent; +struct DisconnectedEvent; +struct DropItemEvent; +struct ReferencePickUpEvent; +struct DropItemResponse; +struct NotifyDroppedItemsSpawn; +struct NotifyDroppedItemMove; +struct NotifyDroppedItemsRemove; + +/** + * @brief Syncs dropped items: the dropper simulates and streams the transform, other clients follow with a keyframed copy. + * The server decides which copies each client holds, so none is left untracked; the first pickup deletes the rest. + */ +struct DroppedItemService +{ + DroppedItemService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept; + ~DroppedItemService() noexcept = default; + + TP_NOCOPYMOVE(DroppedItemService); + +protected: + void OnUpdate(const UpdateEvent& acEvent) noexcept; + void OnDisconnected(const DisconnectedEvent&) noexcept; + void OnDropItem(const DropItemEvent& acEvent) noexcept; + void OnReferencePickUp(const ReferencePickUpEvent& acEvent) noexcept; + void OnDropItemResponse(const DropItemResponse& acMessage) noexcept; + void OnNotifyDroppedItemsSpawn(const NotifyDroppedItemsSpawn& acMessage) noexcept; + void OnNotifyDroppedItemMove(const NotifyDroppedItemMove& acMessage) noexcept; + void OnNotifyDroppedItemsRemove(const NotifyDroppedItemsRemove& acMessage) noexcept; + +private: + struct TrackedItem + { + uint32_t FormId{}; + // Checked before touching the reference, in case the temporary form id was reused. + uint32_t BaseFormId{}; + // Zero until the server answers the drop. + uint32_t ServerId{}; + bool IsSimulatedLocally{}; + bool IsAtRest{}; + + // Simulating client + bool IsPickedUpBeforeRegistration{}; + double SimulationTime{}; + double RestTime{}; + double TimeSinceSend{}; + DroppedItemTransform LastTransform{}; + DroppedItemTransform LastSentTransform{}; + + // Following client + bool IsKeyframed{}; + bool IsSettling{}; + double TimeSinceUpdate{}; + DroppedItemTransform TargetTransform{}; + }; + + void UpdateSimulatedItem(TrackedItem& aItem, double aDelta) noexcept; + void UpdateFollowingItem(TrackedItem& aItem, double aDelta) noexcept; + void SendMove(TrackedItem& aItem) noexcept; + void SendPickUp(const TrackedItem& acItem) const noexcept; + static void EnsureKeyframed(TrackedItem& aItem, TESObjectREFR* apReference) noexcept; + void FinishFollowing(TrackedItem& aItem) noexcept; + TESObjectREFR* SpawnItem(const DroppedItemData& acData) const noexcept; + TESObjectREFR* GetReference(const TrackedItem& acItem) const noexcept; + void DeleteReference(const TrackedItem& acItem) const noexcept; + TrackedItem* FindByFormId(uint32_t aFormId) noexcept; + TrackedItem* FindByServerId(uint32_t aServerId) noexcept; + void Erase(const TrackedItem& acItem) noexcept; + + World& m_world; + TransportService& m_transport; + + Vector m_items{}; + + entt::scoped_connection m_updateConnection; + entt::scoped_connection m_disconnectedConnection; + entt::scoped_connection m_dropItemConnection; + entt::scoped_connection m_referencePickUpConnection; + entt::scoped_connection m_dropItemResponseConnection; + entt::scoped_connection m_droppedItemsSpawnConnection; + entt::scoped_connection m_droppedItemMoveConnection; + entt::scoped_connection m_droppedItemsRemoveConnection; +}; diff --git a/Code/client/Services/Generic/ActorValueService.cpp b/Code/client/Services/Generic/ActorValueService.cpp index b3f309a7ba..843be37d71 100644 --- a/Code/client/Services/Generic/ActorValueService.cpp +++ b/Code/client/Services/Generic/ActorValueService.cpp @@ -1,10 +1,14 @@ #include +#include + #include #include #include #include #include +#include +#include #include #include @@ -200,6 +204,7 @@ void ActorValueService::OnHealthChange(const HealthChangeEvent& acEvent) noexcep requestHealthChange.DeltaHealth = acEvent.DeltaHealth; m_transport.Send(requestHealthChange); + DamageDiagnostics::OnHealthChangeSent(serverId, acEvent.HitteeId, acEvent.DeltaHealth, false); spdlog::debug("Sent out delta health through collection: {:X}:{:f}", serverId, acEvent.DeltaHealth); } @@ -224,6 +229,8 @@ void ActorValueService::RunSmallHealthUpdates() noexcept requestHealthChange.DeltaHealth = value.second; m_transport.Send(requestHealthChange); + if (Actor* pBatchedActor = Utils::GetByServerId(value.first)) + DamageDiagnostics::OnHealthChangeSent(value.first, pBatchedActor->formID, value.second, true); spdlog::debug("Sent out delta health through timer, {:X}:{:f}", value.first, value.second); } @@ -294,10 +301,32 @@ void ActorValueService::OnHealthChangeBroadcast(const NotifyHealthChangeBroadcas return; } - const float newHealth = pActor->GetActorValue(ActorValueInfo::kHealth) + acMessage.DeltaHealth; + // 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 healthBefore = pActor->GetActorValue(ActorValueInfo::kHealth); + const float newHealth = healthBefore + 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); + DamageDiagnostics::OnHealthBroadcastReceived(acMessage.Id, pActor, acMessage.DeltaHealth, healthBefore, pActor->GetActorValue(ActorValueInfo::kHealth)); + return; + } + } pActor->ForceActorValue(ActorValueOwner::ForceMode::DAMAGE, ActorValueInfo::kHealth, newHealth); const float health = pActor->GetActorValue(ActorValueInfo::kHealth); + DamageDiagnostics::OnHealthBroadcastReceived(acMessage.Id, pActor, acMessage.DeltaHealth, healthBefore, health); if (!pActor->IsDead() && health <= 0.f) { ActorExtension* pExtension = pActor->GetExtension(); @@ -315,25 +344,22 @@ void ActorValueService::OnHealthChangeBroadcast(const NotifyHealthChangeBroadcas void ActorValueService::OnActorValueChanges(const NotifyActorValueChanges& acMessage) const noexcept { - auto view = m_world.view(); - - const auto itor = std::find_if(std::begin(view), std::end(view), [&acMessage, view](entt::entity entity) - { - const auto& remote = view.get(entity); - return remote.Id == acMessage.Id && acMessage.OwnershipEpoch != 0 && remote.OwnershipEpoch == acMessage.OwnershipEpoch; - }); - - if (itor == std::end(view)) + const auto entity = OwnershipSystem::AcceptRemoteUpdate(m_world, acMessage.Id, acMessage.OwnershipEpoch); + const auto* pFormIdComponent = entity ? m_world.try_get(*entity) : nullptr; + if (!pFormIdComponent) return; - auto& formIdComponent = view.get(*itor); - Actor* const pActor = Cast(TESForm::GetById(formIdComponent.Id)); + Actor* const pActor = Cast(TESForm::GetById(pFormIdComponent->Id)); if (!pActor) return; for (const auto& [key, value] : acMessage.Values) { + // Health stays delta-driven, the owner's value is only compared for the damage diagnostics. + if (key == ActorValueInfo::kHealth) + DamageDiagnostics::OnHealthSnapshot(acMessage.Id, pActor, value); + // Syncing dragon souls triggers "Dragon soul collected" event if (key == ActorValueInfo::kDragonSouls || key == ActorValueInfo::kHealth) continue; @@ -351,19 +377,12 @@ void ActorValueService::OnActorValueChanges(const NotifyActorValueChanges& acMes void ActorValueService::OnActorMaxValueChanges(const NotifyActorMaxValueChanges& acMessage) const noexcept { - auto view = m_world.view(); - - const auto it = std::find_if(std::begin(view), std::end(view), [&acMessage, view](entt::entity entity) - { - const auto& remote = view.get(entity); - return remote.Id == acMessage.Id && acMessage.OwnershipEpoch != 0 && remote.OwnershipEpoch == acMessage.OwnershipEpoch; - }); - - if (it == std::end(view)) + const auto entity = OwnershipSystem::AcceptRemoteUpdate(m_world, acMessage.Id, acMessage.OwnershipEpoch); + const auto* pFormIdComponent = entity ? m_world.try_get(*entity) : nullptr; + if (!pFormIdComponent) return; - auto& formIdComponent = view.get(*it); - Actor* pActor = Cast(TESForm::GetById(formIdComponent.Id)); + Actor* pActor = Cast(TESForm::GetById(pFormIdComponent->Id)); if (!pActor) return; @@ -381,37 +400,16 @@ void ActorValueService::OnActorMaxValueChanges(const NotifyActorMaxValueChanges& void ActorValueService::OnDeathStateChange(const NotifyDeathStateChange& acMessage) const noexcept { - auto view = m_world.view(); - - const auto it = std::find_if(std::begin(view), std::end(view), [&acMessage, view](entt::entity entity) - { - const auto& remote = view.get(entity); - return remote.Id == acMessage.Id && acMessage.OwnershipEpoch != 0 && remote.OwnershipEpoch == acMessage.OwnershipEpoch; - }); - - if (it == std::end(view)) - { - 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)); - return; - } - - auto& formIdComponent = view.get(*it); - Actor* pActor = Cast(TESForm::GetById(formIdComponent.Id)); - - if (!pActor) + const auto entity = OwnershipSystem::AcceptRemoteUpdate(m_world, acMessage.Id, acMessage.OwnershipEpoch); + if (!entity) return; - ActorExtension* pExtension = pActor->GetExtension(); - // Players should never be killed - if (pExtension->IsPlayer()) - return; + m_world.get(*entity).IsDead = acMessage.IsDead; + if (auto* pWaiting = m_world.try_get(*entity)) + pWaiting->SpawnRequest.IsDead = acMessage.IsDead; - 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/CalendarService.cpp b/Code/client/Services/Generic/CalendarService.cpp index 585d034dc6..30579a1451 100644 --- a/Code/client/Services/Generic/CalendarService.cpp +++ b/Code/client/Services/Generic/CalendarService.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include #include @@ -24,10 +26,97 @@ CalendarService::CalendarService(World& aWorld, entt::dispatcher& aDispatcher, T m_timeUpdateConnection = aDispatcher.sink().connect<&CalendarService::OnTimeUpdate>(this); m_updateConnection = aDispatcher.sink().connect<&CalendarService::HandleUpdate>(this); m_disconnectedConnection = aDispatcher.sink().connect<&CalendarService::OnDisconnected>(this); + + auto* pEventList = EventDispatcherManager::Get(); + pEventList->sleepStartEvent.RegisterSink(this); + pEventList->sleepStopEvent.RegisterSink(this); + pEventList->waitStartEvent.RegisterSink(this); + pEventList->waitStopEvent.RegisterSink(this); +} + +CalendarService::~CalendarService() +{ + auto* pEventList = EventDispatcherManager::Get(); + pEventList->sleepStartEvent.UnRegisterSink(this); + pEventList->sleepStopEvent.UnRegisterSink(this); + pEventList->waitStartEvent.UnRegisterSink(this); + pEventList->waitStopEvent.UnRegisterSink(this); +} + +BSTEventResult CalendarService::OnEvent(const TESSleepStartEvent* apEvent, const EventDispatcher*) +{ + if (apEvent) + BeginTimeSkip(apEvent->sleepStartTime, apEvent->desiredSleepEndTime, "sleep"); + return BSTEventResult::kOk; +} + +BSTEventResult CalendarService::OnEvent(const TESSleepStopEvent* apEvent, const EventDispatcher*) +{ + EndTimeSkip(apEvent && apEvent->interrupted, "sleep"); + return BSTEventResult::kOk; +} + +BSTEventResult CalendarService::OnEvent(const TESWaitStartEvent* apEvent, const EventDispatcher*) +{ + if (apEvent) + BeginTimeSkip(apEvent->waitStartTime, apEvent->desiredWaitEndTime, "wait"); + return BSTEventResult::kOk; +} + +BSTEventResult CalendarService::OnEvent(const TESWaitStopEvent* apEvent, const EventDispatcher*) +{ + EndTimeSkip(apEvent && apEvent->interrupted, "wait"); + return BSTEventResult::kOk; +} + +void CalendarService::BeginTimeSkip(float aStartTimeInDays, float aDesiredEndTimeInDays, const char* apKind) noexcept +{ + m_pendingSkipHours = (aDesiredEndTimeInDays - aStartTimeInDays) * 24.f; + + spdlog::info("{} started: game time {} -> {} days, {} hours requested, online {}", apKind, aStartTimeInDays, aDesiredEndTimeInDays, m_pendingSkipHours, s_gameClockLocked); +} + +void CalendarService::EndTimeSkip(bool aInterrupted, const char* apKind) noexcept +{ + const float hours = m_pendingSkipHours; + m_pendingSkipHours = 0.f; + + // Offline the game advanced its own clock; online the clock is driven by the server, so ask it to skip. + if (!s_gameClockLocked || !m_transport.IsConnected()) + return; + + if (aInterrupted || !(hours > 0.f) || hours > 48.f) + { + spdlog::info("{} ended without a time skip: interrupted {}, hours {}", apKind, aInterrupted, hours); + return; + } + + RequestTimeSkip request{}; + request.Hours = hours; + m_transport.Send(request); + + spdlog::info("{} ended: requested a shared time skip of {} hours", apKind, hours); } void CalendarService::OnTimeUpdate(const ServerTimeSettings& acMessage) noexcept { + // A jump of the shared clock (sleep, wait, /settime) must also move GameDaysPassed, which drives script + // timers, the way the game itself does when the player sleeps. Regular ticks are handled in HandleUpdate. + // The comparison uses a mirror of the server clock, not m_onlineTime: with bSyncPlayerCalendar off the + // online date is the local one and a midnight crossed on the server would count as a whole extra day. + const DateTime newServerTime{acMessage.timeModel}; + if (s_gameClockLocked && m_hasServerTime) + { + const float skippedDays = newServerTime.GetTimeInDays() - m_serverTime.GetTimeInDays(); + if (skippedDays > 0.f && skippedDays < 60.f) + { + TimeData::Get()->GameDaysPassed->f += skippedDays; + spdlog::info("Shared time jumped forward by {} days", skippedDays); + } + } + m_serverTime = newServerTime; + m_hasServerTime = true; + // disable the game clock ToggleGameClock(false); m_onlineTime.m_timeModel.TimeScale = acMessage.timeModel.TimeScale; @@ -49,6 +138,8 @@ void CalendarService::OnTimeUpdate(const ServerTimeSettings& acMessage) noexcept void CalendarService::OnDisconnected(const DisconnectedEvent&) noexcept { + m_hasServerTime = false; + m_pendingSkipHours = 0.f; // signal a time transition m_fadeTimer = 0.f; ToggleGameClock(true); @@ -101,6 +192,7 @@ void CalendarService::HandleUpdate(const UpdateEvent& aEvent) noexcept m_lastTick = now; m_onlineTime.Update(delta); + m_serverTime.Update(delta); pGameTime->TimeScale->f = m_onlineTime.m_timeModel.TimeScale; pGameTime->GameDay->f = m_onlineTime.m_timeModel.Day; pGameTime->GameMonth->f = m_onlineTime.m_timeModel.Month; diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 7d3ac21af5..785b04ae0c 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -11,6 +12,9 @@ #include #include +#include +#include +#include #include #include @@ -19,9 +23,11 @@ #include #include +#include #include #include #include +#include #include #include @@ -51,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -67,6 +74,46 @@ #include #include +#include + +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(); +} + +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) + 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::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); +} +} // namespace CharacterService::CharacterService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept : m_world(aWorld) @@ -115,7 +162,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 @@ -137,14 +184,27 @@ void CharacterService::ReconcileActorData( pWaitingFor3D->SpawnRequest.IsDead = acActorData.IsDead; pWaitingFor3D->SpawnRequest.IsWeaponDrawn = acActorData.IsWeaponDrawn; pWaitingFor3D->SpawnRequest.OwnershipEpoch = aOwnershipEpoch; + // The server's inventory already contains them + 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 (!aIsLocalOwner) + m_world.get(aEntity).IsDead = acActorData.IsDead; + if (!apActor) return; - apActor->SetActorValues(acActorData.InitialActorValues); + apActor->SetActorValues(acActorData.IsDead && !apActor->GetExtension()->IsPlayer() && !apActor->IsDead() + ? ValuesUntilKilled(acActorData.InitialActorValues) + : acActorData.InitialActorValues); - if (aApplyInventory) + if (aApplyInventory && CanApplyInventory(apActor)) { const Inventory currentInventory = apActor->GetActorInventory(); if (currentInventory.Entries != acActorData.InitialInventory.Entries || currentInventory.CurrentMagicEquipment != acActorData.InitialInventory.CurrentMagicEquipment) @@ -155,8 +215,12 @@ void CharacterService::ReconcileActorData( { if (acActorData.IsDead) { - spdlog::info("Killing actor {:X} to match server state, local owner: {}", apActor->formID, aIsLocalOwner); - apActor->Kill(); + // 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(); + } } // 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. @@ -260,11 +324,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; @@ -284,7 +350,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); @@ -301,9 +367,11 @@ void CharacterService::OnUpdate(const UpdateEvent& acUpdateEvent) noexcept RunLocalUpdates(); RunFactionsUpdates(); RunRemoteUpdates(); + ApplyPendingInventories(); RunExperienceUpdates(); ApplyCachedWeaponDraws(acUpdateEvent); ProcessLeveledConforms(); + RunStandingBodyRepairs(); } void CharacterService::OnConnected(const ConnectedEvent& acConnectedEvent) const noexcept @@ -346,7 +414,7 @@ void CharacterService::OnDisconnected(const DisconnectedEvent& acDisconnectedEve pActor->GetExtension()->SetRemote(false); } - m_world.clear(); + m_world.clear(); for (const auto& [formId, pickFormId] : m_pendingLeveledConforms) { @@ -465,6 +533,24 @@ void CharacterService::OnAssignCharacter(const AssignCharacterResponse& acMessag localComponent.IsDead = acMessage.IsDead; localComponent.IsWeaponDrawn = acMessage.IsWeaponDrawn; pActor->GetExtension()->SetRemote(false); + + // The hooks can't send inventory changes without a LocalComponent, so anything that changed while the request was + // in flight (e.g. the outfit being equipped as the actor loads) is missing on the server. Send the whole inventory. + const Inventory currentInventory = pActor->GetActorInventory(); + // Still undressed while its 3D loads: the outfit equip is sent incrementally once it happens. + const bool cIsLoadingUndressed = !acMessage.IsDead && !pActor->GetExtension()->IsPlayer() && !currentInventory.HasWornItems() && acMessage.CurrentInventory.HasWornItems(); + if (cIsLoadingUndressed) + spdlog::info("Not sending the inventory of local actor {:X}, it has no worn items yet", pActor->formID); + else if (currentInventory.Entries != acMessage.CurrentInventory.Entries || currentInventory.CurrentMagicEquipment != acMessage.CurrentInventory.CurrentMagicEquipment) + { + spdlog::info("Inventory of local actor {:X} changed while its assignment was in flight, sending it to the server", pActor->formID); + + RequestInventorySnapshot request{}; + request.ServerId = acMessage.ServerId; + request.OwnershipEpoch = acMessage.OwnershipEpoch; + request.CurrentInventory = currentInventory; + m_transport.Send(request); + } } else { @@ -485,7 +571,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, spdlog::level::debug); MoveActor(pActor, acMessage.WorldSpaceId, acMessage.CellId, acMessage.Position); + 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); @@ -505,7 +596,39 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) if (remoteItor != std::end(remoteView)) { - spdlog::warn("Character with remote id {:X} is already spawned.", acMessage.ServerId); + auto& remoteComponent = remoteView.get(*remoteItor); + if (acMessage.OwnershipEpoch <= remoteComponent.OwnershipEpoch) + { + spdlog::warn("Character with remote id {:X} is already spawned.", acMessage.ServerId); + return; + } + + // 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); + + if (auto* pWaitingFor3D = m_world.try_get(*remoteItor)) + { + pWaitingFor3D->SpawnRequest.InitialActorValues = acMessage.InitialActorValues; + pWaitingFor3D->SpawnRequest.IsDead = acMessage.IsDead; + pWaitingFor3D->SpawnRequest.OwnershipEpoch = acMessage.OwnershipEpoch; + } + else if (const auto* pFormIdComponent = m_world.try_get(*remoteItor)) + { + if (Actor* pActor = Cast(TESForm::GetById(pFormIdComponent->Id))) + { + 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); + } + } + return; } @@ -576,6 +699,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; }); @@ -588,6 +718,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; } @@ -605,7 +737,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) @@ -615,8 +749,12 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) m_world.emplace_or_replace(*entity, acMessage.PlayerId); } - if (pActor->IsDead() != acMessage.IsDead) - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); + // 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); @@ -630,7 +768,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; @@ -758,14 +896,7 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess if (entity && !m_world.any_of(*entity)) entity.reset(); - uint32_t currentEpoch = 0; - if (entity) - { - if (const auto* pLocalComponent = m_world.try_get(*entity)) - currentEpoch = pLocalComponent->OwnershipEpoch; - else if (const auto* pRemoteComponent = m_world.try_get(*entity)) - currentEpoch = pRemoteComponent->OwnershipEpoch; - } + const uint32_t currentEpoch = entity ? OwnershipSystem::GetOwnershipEpoch(m_world, *entity) : 0; if (currentEpoch != 0 && acMessage.OwnershipEpoch <= currentEpoch) { @@ -794,7 +925,8 @@ void CharacterService::OnOwnershipTransfer(const NotifyOwnershipTransfer& acMess if (isLocalOwner) { - if (!pFormIdComponent || !pActor || !pActor->GetNiNode()) + 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)) @@ -809,6 +941,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; @@ -858,31 +993,53 @@ 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); spdlog::info("Actor {:X} is now owned by player {:X} at epoch {}", acMessage.ServerId, acMessage.OwnerPlayerId, acMessage.OwnershipEpoch); + + // The leader owns every actor it has loaded. It gets here when an actor went to someone else while the leader wasn't + // ready for it, or when a stale claim was answered with the current owner. Without 3D the grant would only be declined. + if (pFormIdComponent && pActor && pActor->GetNiNode() && !m_world.all_of(cEntity)) + ProcessNewEntity(cEntity); } void CharacterService::OnRemoveCharacter(const NotifyRemoveCharacter& acMessage) const noexcept { - auto view = m_world.view(); + const auto entity = Utils::FindEntityByServerId(acMessage.ServerId); + if (!entity || !m_world.any_of(*entity)) + return; - const auto itor = std::find_if(std::begin(view), std::end(view), [id = acMessage.ServerId, view](entt::entity entity) { return view.get(entity).Id == id; }); + const entt::entity cEntity = *entity; + const auto* pFormIdComponent = m_world.try_get(cEntity); + Actor* pActor = pFormIdComponent ? Cast(TESForm::GetById(pFormIdComponent->Id)) : nullptr; - if (itor != std::end(view)) + if (m_world.all_of(cEntity)) { - if (auto* pFormIdComponent = m_world.try_get(*itor)) + // The server only drops an actor once nobody accepts it, so we can still be holding it as local when our assignment + // request was answered after we had declined the same grant. Streaming it under the old id would sync nothing. + spdlog::warn("Server removed actor {:X} while it is still owned locally", acMessage.ServerId); + m_world.remove(cEntity); + } + else + { + if (pActor && pActor->IsTemporary()) { - Actor* pActor = Cast(TESForm::GetById(pFormIdComponent->Id)); - if (pActor && pActor->IsTemporary()) - CharacterService::DeleteTempActor(pFormIdComponent->Id); - else if (pActor) - pActor->GetExtension()->SetRemote(false); + CharacterService::DeleteTempActor(pActor->formID); + pActor = nullptr; } + else if (pActor) + pActor->GetExtension()->SetRemote(false); + + DeleteRemoteEntityComponents(cEntity); + } - DeleteRemoteEntityComponents(*itor); + // Nobody owns the actor on the server anymore, so if it is still loaded here, ask for it instead of running it unsynced. + if (pActor && pActor->GetNiNode() && !pActor->GetExtension()->IsPlayer()) + { + spdlog::info("Requesting assignment of actor {:X} after the server removed it, server id: {:X}", pActor->formID, acMessage.ServerId); + ProcessNewEntity(cEntity); } } @@ -913,6 +1070,7 @@ void CharacterService::OnNotifyRespawn(const NotifyRespawn& acMessage) const noe RequestRespawn request; request.ActorId = acMessage.ActorId; + spdlog::info("Respawning remote server id {:X}, its owner changed form", acMessage.ActorId); m_transport.Send(request); } @@ -951,6 +1109,7 @@ void CharacterService::OnBeastFormChange(const BeastFormChangeEvent& acEvent) co pNpc->Serialize(&request.AppearanceBuffer); request.ChangeFlags = pNpc->GetChangeFlags(); + spdlog::info("Changed form (race {:X}), asking the other players to respawn our copy (server id {:X})", pActor->race ? pActor->race->formID : 0, serverId); m_transport.Send(request); } @@ -1286,6 +1445,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); @@ -1628,7 +1796,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) @@ -1638,8 +1808,14 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no m_world.emplace_or_replace(aEntity, acMessage.PlayerId); } - if (pActor->IsDead() != acMessage.IsDead) - acMessage.IsDead ? pActor->Kill() : pActor->Respawn(); + remoteComponent.IsDead = acMessage.IsDead; + + // 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); @@ -1686,6 +1862,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; @@ -1705,6 +1888,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; @@ -1894,7 +2199,38 @@ void CharacterService::RunRemoteUpdates() noexcept pActor = Cast(pForm); } - InterpolationSystem::Update(pActor, interpolationComponent, cNow); + 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 || !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(); @@ -1909,6 +2245,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)); @@ -1939,7 +2285,10 @@ void CharacterService::RunRemoteUpdates() noexcept auto& waitingFor3D = waitingView.get(entity); Actor* pActor = Cast(TESForm::GetById(formIdComponent.Id)); - if (!pActor || !pActor->GetNiNode()) + 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 @@ -1947,15 +2296,28 @@ void CharacterService::RunRemoteUpdates() noexcept pActor->SetActorInventory(waitingFor3D.SpawnRequest.InventoryContent); pActor->SetFactions(waitingFor3D.SpawnRequest.FactionsContent); - if (!waitingFor3D.SpawnRequest.ActionsToReplay.Actions.empty()) + const auto& inventoryEntries = waitingFor3D.SpawnRequest.InventoryContent.Entries; + for (const auto& change : waitingFor3D.PendingEquipmentChanges) + { + // Items removed again before the 3D loaded are gone from the inventory, don't equip them back + const bool isInventoryItem = !change.IsSpell && !change.IsShout; + if (isInventoryItem && std::none_of(inventoryEntries.begin(), inventoryEntries.end(), [&change](const auto& acEntry) { return acEntry.BaseId == change.ItemId && acEntry.Count > 0; })) + continue; + + InventoryService::ApplyEquipmentChange(pActor, change); + } + + 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(); @@ -2079,6 +2441,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{}; @@ -2095,7 +2485,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/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 diff --git a/Code/client/Services/Generic/DroppedItemService.cpp b/Code/client/Services/Generic/DroppedItemService.cpp new file mode 100644 index 0000000000..646489ff94 --- /dev/null +++ b/Code/client/Services/Generic/DroppedItemService.cpp @@ -0,0 +1,448 @@ +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace +{ +// How often the simulating client sends the transform while the item moves. +constexpr double cSendInterval = 0.1; +// Settled: slower than cRestSpeed (units/s) for cRestDuration. +constexpr float cRestSpeed = 2.f; +constexpr double cRestDuration = 0.5; +// Stops the simulation of an item that never settles, such as one jittering against geometry. +constexpr double cMaxSimulationTime = 10.0; +// A following copy gives up and rests where it is if the stream stops. +constexpr double cFollowTimeout = 3.0; +// Time a following copy gets to reach its final transform. +constexpr double cSettleTimeout = 1.0; +constexpr float cSettleDistance = 1.f; +constexpr float cMinTranslationSpeed = 10.f; + +DroppedItemTransform GetTransform(const TESObjectREFR* apReference) noexcept +{ + DroppedItemTransform transform{}; + transform.Position = apReference->position; + transform.Rotation = apReference->rotation; + return transform; +} +} // namespace + +DroppedItemService::DroppedItemService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept + : m_world(aWorld) + , m_transport(aTransport) +{ + m_updateConnection = aDispatcher.sink().connect<&DroppedItemService::OnUpdate>(this); + m_disconnectedConnection = aDispatcher.sink().connect<&DroppedItemService::OnDisconnected>(this); + m_dropItemConnection = aDispatcher.sink().connect<&DroppedItemService::OnDropItem>(this); + m_referencePickUpConnection = aDispatcher.sink().connect<&DroppedItemService::OnReferencePickUp>(this); + m_dropItemResponseConnection = aDispatcher.sink().connect<&DroppedItemService::OnDropItemResponse>(this); + m_droppedItemsSpawnConnection = aDispatcher.sink().connect<&DroppedItemService::OnNotifyDroppedItemsSpawn>(this); + m_droppedItemMoveConnection = aDispatcher.sink().connect<&DroppedItemService::OnNotifyDroppedItemMove>(this); + m_droppedItemsRemoveConnection = aDispatcher.sink().connect<&DroppedItemService::OnNotifyDroppedItemsRemove>(this); +} + +void DroppedItemService::OnUpdate(const UpdateEvent& acEvent) noexcept +{ + for (auto& item : m_items) + { + if (item.IsAtRest) + continue; + + if (item.IsSimulatedLocally) + UpdateSimulatedItem(item, acEvent.Delta); + else + UpdateFollowingItem(item, acEvent.Delta); + } +} + +void DroppedItemService::OnDisconnected(const DisconnectedEvent&) noexcept +{ + // The server owns registered items. Keeping a copy would leave an untracked item behind. + for (const auto& item : m_items) + { + if (item.ServerId != 0) + DeleteReference(item); + } + + m_items.clear(); +} + +void DroppedItemService::OnDropItem(const DropItemEvent& acEvent) noexcept +{ + if (!m_transport.IsConnected() || !m_world.GetServerSettings().ItemDropsEnabled) + return; + + auto* pReference = Cast(TESForm::GetById(acEvent.FormId)); + if (!pReference || !pReference->baseForm) + return; + + TESObjectCELL* pCell = pReference->GetParentCellEx(); + if (!pCell) + return; + + auto& modSystem = m_world.GetModSystem(); + + DropItemRequest request{}; + request.LocalId = acEvent.FormId; + request.Item = acEvent.Item; + request.Transform = GetTransform(pReference); + + if (!modSystem.GetServerModId(pCell->formID, request.CellId)) + { + spdlog::error("{}: server cell id not found for cell {:X}", __FUNCTION__, pCell->formID); + return; + } + + if (TESWorldSpace* pWorldSpace = pReference->GetWorldSpace()) + { + if (!modSystem.GetServerModId(pWorldSpace->formID, request.WorldSpaceId)) + { + spdlog::error("{}: server worldspace id not found for worldspace {:X}", __FUNCTION__, pWorldSpace->formID); + return; + } + } + + TrackedItem item{}; + item.FormId = acEvent.FormId; + item.BaseFormId = pReference->baseForm->formID; + item.IsSimulatedLocally = true; + item.LastTransform = request.Transform; + item.LastSentTransform = request.Transform; + m_items.push_back(item); + + m_transport.Send(request); +} + +void DroppedItemService::OnReferencePickUp(const ReferencePickUpEvent& acEvent) noexcept +{ + TrackedItem* pItem = FindByFormId(acEvent.FormId); + if (!pItem) + return; + + if (pItem->ServerId == 0) + { + // The server has not answered the drop yet; send the pickup once it does. + pItem->IsPickedUpBeforeRegistration = true; + pItem->IsAtRest = true; + return; + } + + SendPickUp(*pItem); + Erase(*pItem); +} + +void DroppedItemService::OnDropItemResponse(const DropItemResponse& acMessage) noexcept +{ + // Only the local player's unregistered drops have no server id. + TrackedItem* pItem = FindByFormId(acMessage.LocalId); + if (!pItem || pItem->ServerId != 0) + return; + + // The server did not register the drop, so the item stays local only. + if (acMessage.ServerId == 0) + { + Erase(*pItem); + return; + } + + pItem->ServerId = acMessage.ServerId; + + if (pItem->IsPickedUpBeforeRegistration) + { + SendPickUp(*pItem); + Erase(*pItem); + return; + } + + // The item settled before the server answered. + if (pItem->IsAtRest) + SendMove(*pItem); +} + +void DroppedItemService::OnNotifyDroppedItemsSpawn(const NotifyDroppedItemsSpawn& acMessage) noexcept +{ + m_items.reserve(m_items.size() + acMessage.Items.size()); + + for (const DroppedItemData& data : acMessage.Items) + { + if (FindByServerId(data.ServerId)) + continue; + + TESObjectREFR* pReference = SpawnItem(data); + if (!pReference) + { + spdlog::warn("{}: failed to spawn dropped item {:X} ({:X}:{:X})", __FUNCTION__, data.ServerId, data.Item.BaseId.ModId, data.Item.BaseId.BaseId); + continue; + } + + TrackedItem item{}; + item.FormId = pReference->formID; + item.BaseFormId = pReference->baseForm->formID; + item.ServerId = data.ServerId; + item.IsAtRest = data.IsAtRest; + item.TargetTransform = data.Transform; + + // Physics stays with the simulating client while the item moves. + if (!item.IsAtRest) + EnsureKeyframed(item, pReference); + + m_items.push_back(item); + } +} + +void DroppedItemService::OnNotifyDroppedItemMove(const NotifyDroppedItemMove& acMessage) noexcept +{ + const auto& move = acMessage.Move; + + TrackedItem* pItem = FindByServerId(move.ServerId); + if (!pItem || pItem->IsSimulatedLocally) + return; + + // A copy that timed out resumes following, so a late update still lands it. + pItem->IsAtRest = false; + pItem->TimeSinceUpdate = 0.0; + pItem->TargetTransform = move.Transform; + pItem->IsSettling = move.IsAtRest; + + TESObjectREFR* pReference = GetReference(*pItem); + + const NiPoint3 position(move.Transform.Position); + const NiPoint3 rotation(move.Transform.Rotation); + + // Without 3D there is nothing to animate; place the reference so it loads in the right spot. + if (!pReference || !pReference->GetNiNode()) + { + if (pReference) + { + pReference->SetLocation(position); + pReference->SetRotation(rotation.x, rotation.y, rotation.z); + } + + pItem->IsAtRest = move.IsAtRest; + return; + } + + EnsureKeyframed(*pItem, pReference); + + // Reach the new sample by the time the next one arrives. + const float distance = glm::distance(static_cast(pReference->position), static_cast(position)); + const float speed = std::max(distance / static_cast(cSendInterval), cMinTranslationSpeed); + pReference->TranslateTo(position, rotation, speed); +} + +void DroppedItemService::OnNotifyDroppedItemsRemove(const NotifyDroppedItemsRemove& acMessage) noexcept +{ + for (const uint32_t serverId : acMessage.ServerIds) + { + TrackedItem* pItem = FindByServerId(serverId); + if (!pItem) + continue; + + DeleteReference(*pItem); + Erase(*pItem); + } +} + +void DroppedItemService::UpdateSimulatedItem(TrackedItem& aItem, double aDelta) noexcept +{ + if (TESObjectREFR* pReference = GetReference(aItem)) + { + aItem.SimulationTime += aDelta; + aItem.TimeSinceSend += aDelta; + aItem.LastTransform = GetTransform(pReference); + + // No 3D means no rigid body yet. + if (pReference->GetNiNode()) + { + NiPoint3 velocity{}; + pReference->GetLinearVelocity(velocity); + + if (glm::length(static_cast(velocity)) < cRestSpeed) + aItem.RestTime += aDelta; + else + aItem.RestTime = 0.0; + } + + aItem.IsAtRest = aItem.RestTime >= cRestDuration || aItem.SimulationTime >= cMaxSimulationTime; + } + else + { + // The reference is gone, e.g. its cell unloaded. Leave the item where it was last seen. + aItem.IsAtRest = true; + } + + // Without a server id yet, the drop response sends the final transform. + if (aItem.ServerId == 0) + return; + + if (aItem.IsAtRest || (aItem.TimeSinceSend >= cSendInterval && aItem.LastTransform != aItem.LastSentTransform)) + SendMove(aItem); +} + +void DroppedItemService::UpdateFollowingItem(TrackedItem& aItem, double aDelta) noexcept +{ + aItem.TimeSinceUpdate += aDelta; + + TESObjectREFR* pReference = GetReference(aItem); + if (!pReference) + return; + + // The copy may have spawned before its 3D loaded. + EnsureKeyframed(aItem, pReference); + + if (aItem.IsSettling) + { + const float distance = glm::distance(static_cast(pReference->position), static_cast(aItem.TargetTransform.Position)); + if (distance <= cSettleDistance || aItem.TimeSinceUpdate >= cSettleTimeout) + FinishFollowing(aItem); + } + else if (aItem.TimeSinceUpdate >= cFollowTimeout) + { + spdlog::debug("{}: no transform for dropped item {:X} in {}s, letting it rest", __FUNCTION__, aItem.ServerId, cFollowTimeout); + FinishFollowing(aItem); + } +} + +void DroppedItemService::SendMove(TrackedItem& aItem) noexcept +{ + DroppedItemMoveRequest request{}; + request.Move.ServerId = aItem.ServerId; + request.Move.Transform = aItem.LastTransform; + request.Move.IsAtRest = aItem.IsAtRest; + + m_transport.Send(request); + + aItem.TimeSinceSend = 0.0; + aItem.LastSentTransform = aItem.LastTransform; +} + +void DroppedItemService::SendPickUp(const TrackedItem& acItem) const noexcept +{ + PickUpDroppedItemRequest request{}; + request.ServerId = acItem.ServerId; + + m_transport.Send(request); +} + +void DroppedItemService::EnsureKeyframed(TrackedItem& aItem, TESObjectREFR* apReference) noexcept +{ + if (!aItem.IsKeyframed) + aItem.IsKeyframed = apReference->SetMotionType(TESObjectREFR::MotionType::kKeyframed); +} + +void DroppedItemService::FinishFollowing(TrackedItem& aItem) noexcept +{ + aItem.IsAtRest = true; + aItem.IsSettling = false; + + TESObjectREFR* pReference = GetReference(aItem); + if (!pReference) + return; + + pReference->StopTranslation(); + + // Give physics back, so the item reacts to hits like any other loose item. + if (aItem.IsKeyframed) + pReference->SetMotionType(TESObjectREFR::MotionType::kDynamic); + + aItem.IsKeyframed = false; +} + +TESObjectREFR* DroppedItemService::SpawnItem(const DroppedItemData& acData) const noexcept +{ + auto& modSystem = m_world.GetModSystem(); + + TESBoundObject* pBaseForm = nullptr; + // A player-crafted potion or poison only has a temporary form id, so recreate it from the dropper's recipe. + if (!acData.Item.Potion.Effects.empty()) + pBaseForm = AlchemyItem::Create(acData.Item.Potion); + else + pBaseForm = Cast(TESForm::GetById(modSystem.GetGameId(acData.Item.BaseId))); + if (!pBaseForm) + return nullptr; + + TESObjectCELL* pCell = nullptr; + TESWorldSpace* pWorldSpace = nullptr; + + if (acData.WorldSpaceId) + pWorldSpace = Cast(TESForm::GetById(modSystem.GetGameId(acData.WorldSpaceId))); + else + pCell = Cast(TESForm::GetById(modSystem.GetGameId(acData.CellId))); + + if (!pCell && !pWorldSpace) + return nullptr; + + NiPoint3 position(acData.Transform.Position); + NiPoint3 rotation(acData.Transform.Rotation); + + TESObjectREFR* pReference = ModManager::Get()->SpawnReference(pBaseForm, position, rotation, pCell, pWorldSpace); + if (!pReference) + return nullptr; + + pReference->SetItemData(acData.Item); + + return pReference; +} + +TESObjectREFR* DroppedItemService::GetReference(const TrackedItem& acItem) const noexcept +{ + auto* pReference = Cast(TESForm::GetById(acItem.FormId)); + if (!pReference || !pReference->baseForm || pReference->baseForm->formID != acItem.BaseFormId) + return nullptr; + + return pReference; +} + +void DroppedItemService::DeleteReference(const TrackedItem& acItem) const noexcept +{ + TESObjectREFR* pReference = GetReference(acItem); + if (!pReference) + return; + + pReference->Disable(); + pReference->Delete(); +} + +DroppedItemService::TrackedItem* DroppedItemService::FindByFormId(uint32_t aFormId) noexcept +{ + const auto it = std::find_if(m_items.begin(), m_items.end(), [aFormId](const TrackedItem& acItem) { return acItem.FormId == aFormId; }); + return it != m_items.end() ? &*it : nullptr; +} + +DroppedItemService::TrackedItem* DroppedItemService::FindByServerId(uint32_t aServerId) noexcept +{ + if (aServerId == 0) + return nullptr; + + const auto it = std::find_if(m_items.begin(), m_items.end(), [aServerId](const TrackedItem& acItem) { return acItem.ServerId == aServerId; }); + return it != m_items.end() ? &*it : nullptr; +} + +// Order doesn't matter: swap with the last item instead of shifting. +void DroppedItemService::Erase(const TrackedItem& acItem) noexcept +{ + auto& item = m_items[&acItem - m_items.data()]; + if (&item != &m_items.back()) + item = std::move(m_items.back()); + + m_items.pop_back(); +} 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/InventoryService.cpp b/Code/client/Services/Generic/InventoryService.cpp index 8d9f2c78ca..71c12f275a 100644 --- a/Code/client/Services/Generic/InventoryService.cpp +++ b/Code/client/Services/Generic/InventoryService.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -14,6 +15,7 @@ #include #include +#include #include #include #include @@ -37,6 +39,7 @@ InventoryService::InventoryService(World& aWorld, entt::dispatcher& aDispatcher, m_equipmentConnection = m_dispatcher.sink().connect<&InventoryService::OnEquipmentChangeEvent>(this); m_inventoryChangeConnection = m_dispatcher.sink().connect<&InventoryService::OnNotifyInventoryChanges>(this); m_equipmentChangeConnection = m_dispatcher.sink().connect<&InventoryService::OnNotifyEquipmentChanges>(this); + m_inventorySnapshotConnection = m_dispatcher.sink().connect<&InventoryService::OnNotifyInventorySnapshot>(this); } void InventoryService::OnUpdate(const UpdateEvent& acUpdateEvent) noexcept @@ -57,26 +60,22 @@ void InventoryService::OnInventoryChangeEvent(const InventoryChangeEvent& acEven return; uint32_t serverId = 0; - if (acEvent.OwnershipEpoch != 0) + uint32_t ownershipEpoch = 0; + if (Cast(TESForm::GetById(acEvent.FormId))) { - const auto* pLocalComponent = m_world.try_get(*iter); - const auto* pRemoteComponent = m_world.try_get(*iter); - const bool ownershipMatches = (pLocalComponent && pLocalComponent->Id == acEvent.ServerId && pLocalComponent->OwnershipEpoch == acEvent.OwnershipEpoch) - || (pRemoteComponent && pRemoteComponent->Id == acEvent.ServerId && pRemoteComponent->OwnershipEpoch == acEvent.OwnershipEpoch); - - if (!ownershipMatches) + // The player's own changes to someone else's actor (looting, pickpocketing) are relayed to its owner + const auto ownership = OwnershipSystem::GetOutgoingOwnership(m_world, *iter, acEvent.IsPlayerTransfer); + if (!ownership) { - spdlog::debug("Discarded an inventory change for actor {:X} because ownership changed after it was queued (epoch {})", acEvent.ServerId, acEvent.OwnershipEpoch); + spdlog::debug("Discarded an inventory change for actor {:X} that isn't owned locally (item {:X}, count {})", acEvent.FormId, acEvent.Item.BaseId.BaseId, acEvent.Item.Count); return; } - serverId = acEvent.ServerId; + serverId = ownership->ServerId; + ownershipEpoch = ownership->OwnershipEpoch; } else { - if (Cast(TESForm::GetById(acEvent.FormId))) - return; - const std::optional serverIdRes = Utils::GetServerId(*iter); if (!serverIdRes) { @@ -89,9 +88,8 @@ void InventoryService::OnInventoryChangeEvent(const InventoryChangeEvent& acEven RequestInventoryChanges request; request.ServerId = serverId; - request.OwnershipEpoch = acEvent.OwnershipEpoch; + request.OwnershipEpoch = ownershipEpoch; request.Item = acEvent.Item; - request.Drop = acEvent.Drop; request.UpdateClients = acEvent.UpdateClients; m_transport.Send(request); @@ -111,10 +109,10 @@ void InventoryService::OnEquipmentChangeEvent(const EquipmentChangeEvent& acEven if (iter == std::end(view)) return; - const auto* pLocalComponent = m_world.try_get(*iter); - if (acEvent.OwnershipEpoch == 0 || !pLocalComponent || pLocalComponent->Id != acEvent.ServerId || pLocalComponent->OwnershipEpoch != acEvent.OwnershipEpoch) + const auto ownership = OwnershipSystem::GetOutgoingOwnership(m_world, *iter, false); + if (!ownership) { - spdlog::debug("Discarded an equipment change for actor {:X} because ownership changed after it was queued (epoch {})", acEvent.ServerId, acEvent.OwnershipEpoch); + spdlog::debug("Discarded an equipment change for actor {:X} that isn't owned locally (item {:X})", acEvent.ActorId, acEvent.ItemId); return; } @@ -125,8 +123,8 @@ void InventoryService::OnEquipmentChangeEvent(const EquipmentChangeEvent& acEven auto& modSystem = World::Get().GetModSystem(); RequestEquipmentChanges request; - request.ServerId = acEvent.ServerId; - request.OwnershipEpoch = acEvent.OwnershipEpoch; + request.ServerId = ownership->ServerId; + request.OwnershipEpoch = ownership->OwnershipEpoch; if (!modSystem.GetServerModId(acEvent.EquipSlotId, request.EquipSlotId)) return; @@ -151,26 +149,31 @@ void InventoryService::OnNotifyInventoryChanges(const NotifyInventoryChanges& ac { Actor* pActor = nullptr; - auto remoteView = m_world.view(entt::exclude); - const auto remoteIt = std::find_if(remoteView.begin(), remoteView.end(), [remoteView, &acMessage](const entt::entity aEntity) + const auto remoteEntity = OwnershipSystem::AcceptRemoteUpdate(m_world, acMessage.ServerId, acMessage.OwnershipEpoch); + if (remoteEntity) { - const auto& remoteComponent = remoteView.get(aEntity); - return remoteComponent.Id == acMessage.ServerId && remoteComponent.OwnershipEpoch == acMessage.OwnershipEpoch; - }); + // The spawn request's inventory replaces the actor's once its 3D loads, so the change has to go there too + if (auto* pWaitingFor3D = m_world.try_get(*remoteEntity)) + { + pWaitingFor3D->SpawnRequest.InventoryContent.AddOrRemoveEntry(acMessage.Item); + return; + } - if (remoteIt != remoteView.end()) - pActor = Cast(TESForm::GetById(remoteView.get(*remoteIt).Id)); - else - { - auto localView = m_world.view(); - const auto localIt = std::find_if(localView.begin(), localView.end(), [localView, &acMessage](const entt::entity aEntity) + // 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) { - const auto& localComponent = localView.get(aEntity); - return localComponent.Id == acMessage.ServerId && localComponent.OwnershipEpoch == acMessage.OwnershipEpoch; - }); + pPending->Updates.emplace_back(acMessage); + return; + } - if (localIt != localView.end()) - pActor = Cast(TESForm::GetById(localView.get(*localIt).Id)); + if (const auto* pFormIdComponent = m_world.try_get(*remoteEntity)) + pActor = Cast(TESForm::GetById(pFormIdComponent->Id)); + } + else if (const auto localEntity = OwnershipSystem::AcceptOwnerUpdate(m_world, acMessage.ServerId, acMessage.OwnershipEpoch)) + { + // Remote interactions with our own actor, e.g. a player looting it, are relayed back to the owner. + if (const auto* pFormIdComponent = m_world.try_get(*localEntity)) + pActor = Cast(TESForm::GetById(pFormIdComponent->Id)); } if (!pActor) @@ -180,11 +183,7 @@ void InventoryService::OnNotifyInventoryChanges(const NotifyInventoryChanges& ac } ScopedInventoryOverride _; - - if (acMessage.Drop) - pActor->DropOrPickUpObject(acMessage.Item, nullptr, nullptr); - else - pActor->AddOrRemoveItem(acMessage.Item); + pActor->AddOrRemoveItem(acMessage.Item); return; } @@ -199,20 +198,36 @@ void InventoryService::OnNotifyInventoryChanges(const NotifyInventoryChanges& ac void InventoryService::OnNotifyEquipmentChanges(const NotifyEquipmentChanges& acMessage) noexcept { - auto view = m_world.view(entt::exclude); - const auto it = std::find_if(view.begin(), view.end(), [view, &acMessage](const entt::entity aEntity) - { - const auto& remoteComponent = view.get(aEntity); - return remoteComponent.Id == acMessage.ServerId && remoteComponent.OwnershipEpoch == acMessage.OwnershipEpoch; - }); - if (it == view.end()) + const auto entity = OwnershipSystem::AcceptRemoteUpdate(m_world, acMessage.ServerId, acMessage.OwnershipEpoch); + if (!entity) { spdlog::debug("Discarded an equipment update for actor {:X} because epoch {} is no longer current", acMessage.ServerId, acMessage.OwnershipEpoch); return; } - Actor* pActor = Cast(TESForm::GetById(view.get(*it).Id)); - if (!pActor) + // Equipping before the 3D loads would be undone by the spawn request's inventory + if (auto* pWaitingFor3D = m_world.try_get(*entity)) + { + pWaitingFor3D->PendingEquipmentChanges.push_back(acMessage); + 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; + + ApplyEquipmentChange(Cast(TESForm::GetById(pFormIdComponent->Id)), acMessage); +} + +void InventoryService::ApplyEquipmentChange(Actor* apActor, const NotifyEquipmentChanges& acMessage) noexcept +{ + if (!apActor) return; auto& modSystem = World::Get().GetModSystem(); @@ -238,18 +253,18 @@ void InventoryService::OnNotifyEquipmentChanges(const NotifyEquipmentChanges& ac if (acMessage.IsSpell) { if (acMessage.Unequip) - pEquipManager->UnEquipSpell(pActor, pItem, slotId); + pEquipManager->UnEquipSpell(apActor, pItem, slotId); else - pEquipManager->EquipSpell(pActor, pItem, slotId); + pEquipManager->EquipSpell(apActor, pItem, slotId); return; } else if (acMessage.IsShout) { if (acMessage.Unequip) - pEquipManager->UnEquipShout(pActor, pItem); + pEquipManager->UnEquipShout(apActor, pItem); else - pEquipManager->EquipShout(pActor, pItem); + pEquipManager->EquipShout(apActor, pItem); return; } @@ -257,7 +272,7 @@ void InventoryService::OnNotifyEquipmentChanges(const NotifyEquipmentChanges& ac // TODO: ExtraData necessary? probably if (acMessage.Unequip) { - pEquipManager->UnEquip(pActor, pItem, nullptr, acMessage.Count, pEquipSlot, false, true, false, false, nullptr); + pEquipManager->UnEquip(apActor, pItem, nullptr, acMessage.Count, pEquipSlot, false, true, false, false, nullptr); } else { @@ -265,28 +280,66 @@ void InventoryService::OnNotifyEquipmentChanges(const NotifyEquipmentChanges& ac Inventory wornArmor{}; if (pItem->formType == FormType::Armor) { - wornArmor = pActor->GetWornArmor(); + wornArmor = apActor->GetWornArmor(); for (const auto& armor : wornArmor.Entries) { uint32_t armorId = modSystem.GetGameId(armor.BaseId); TESForm* pArmor = TESForm::GetById(armorId); if (pArmor) - pEquipManager->UnEquip(pActor, pArmor, nullptr, 1, pEquipSlot, false, true, false, false, nullptr); + pEquipManager->UnEquip(apActor, pArmor, nullptr, 1, pEquipSlot, false, true, false, false, nullptr); } } - pEquipManager->Equip(pActor, pItem, nullptr, acMessage.Count, pEquipSlot, false, true, false, false); + pEquipManager->Equip(apActor, pItem, nullptr, acMessage.Count, pEquipSlot, false, true, false, false); for (const auto& armor : wornArmor.Entries) { uint32_t armorId = modSystem.GetGameId(armor.BaseId); TESForm* pArmor = TESForm::GetById(armorId); if (pArmor) - pEquipManager->Equip(pActor, pArmor, nullptr, 1, pEquipSlot, false, true, false, false); + pEquipManager->Equip(apActor, pArmor, nullptr, 1, pEquipSlot, false, true, false, false); } } } +void InventoryService::OnNotifyInventorySnapshot(const NotifyInventorySnapshot& acMessage) noexcept +{ + const auto entity = OwnershipSystem::AcceptRemoteUpdate(m_world, acMessage.ServerId, acMessage.OwnershipEpoch); + if (!entity) + { + spdlog::debug("Discarded an inventory snapshot for actor {:X} because epoch {} is no longer current", acMessage.ServerId, acMessage.OwnershipEpoch); + return; + } + + ApplyRemoteInventory(m_world, *entity, acMessage.CurrentInventory); +} + +void InventoryService::ApplyRemoteInventory(World& aWorld, const entt::entity aEntity, const Inventory& acInventory) noexcept +{ + // The spawn request's inventory is applied once the 3D loads, anything applied before that would be overwritten + if (auto* pWaitingFor3D = aWorld.try_get(aEntity)) + { + pWaitingFor3D->SpawnRequest.InventoryContent = acInventory; + pWaitingFor3D->PendingEquipmentChanges.clear(); + return; + } + + const auto* pFormIdComponent = aWorld.try_get(aEntity); + Actor* pActor = pFormIdComponent ? Cast(TESForm::GetById(pFormIdComponent->Id)) : nullptr; + if (!pActor || !pActor->GetNiNode()) + return; + + const Inventory currentInventory = pActor->GetActorInventory(); + if (!acInventory.HasWornItems() && currentInventory.HasWornItems() && !pActor->IsDead() && !pActor->GetExtension()->IsPlayer()) + { + spdlog::info("Ignored an inventory without worn items for living actor {:X}", pActor->formID); + return; + } + + if (currentInventory.Entries != acInventory.Entries || currentInventory.CurrentMagicEquipment != acInventory.CurrentMagicEquipment) + pActor->SetActorInventory(acInventory); +} + void InventoryService::RunWeaponStateUpdates() noexcept { if (!m_transport.IsConnected()) diff --git a/Code/client/Services/Generic/MagicService.cpp b/Code/client/Services/Generic/MagicService.cpp index cfbbe10424..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()) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) + if ((pSpellItem->eCastingType == MagicSystem::CastingType::CONCENTRATION && !pSpellItem->IsHealingSpell() && !acEvent.IsPvpForwarded) || pSpellItem->IsWardSpell() || pSpellItem->IsInvisibilitySpell() || pSpellItem->IsBoundWeaponSpell()) { return; } diff --git a/Code/client/Services/Generic/NameTagService.cpp b/Code/client/Services/Generic/NameTagService.cpp new file mode 100644 index 0000000000..978eedef5b --- /dev/null +++ b/Code/client/Services/Generic/NameTagService.cpp @@ -0,0 +1,568 @@ +#include + +#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); +} + +// Name colours of the F2 chat (chat.component.scss), so a floating line reads like its chat entry. +ImVec4 ChatNameColor(ChatMessageType aType) noexcept +{ + switch (aType) + { + case kPlayerDialogue: return ImVec4(0.f, 1.f, 0.2f, 1.f); + case kPartyChat: return ImVec4(0.604f, 1.f, 0.902f, 1.f); + case kLocalChat: return ImVec4(0.933f, 1.f, 0.f, 1.f); + default: return ImVec4(0.95f, 0.95f, 0.95f, 1.f); + } +} + +// 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; + } +} + +void PopLastUtf8Char(std::string& aText) noexcept +{ + while (!aText.empty() && (static_cast(aText.back()) & 0xC0) == 0x80) + aText.pop_back(); + if (!aText.empty()) + aText.pop_back(); +} +} // 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); + m_chatConnection = aDispatcher.sink().connect<&NameTagService::OnChatMessage>(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; + } + + for (auto it = m_floatingLines.begin(); it != m_floatingLines.end();) + { + if (it->second.ExpiresAt <= tick) + it = m_floatingLines.erase(it); + else + ++it; + } +} + +void NameTagService::OnChatMessage(const NotifyChatMessageBroadcast& acMessage) noexcept +{ + if (m_settings.FloatingChat == Filter::kOff || acMessage.PlayerId == 0 || acMessage.MessageType == kSystemMessage || acMessage.ChatMessage.empty()) + return; + + const auto& partyService = m_world.GetPartyService(); + const auto& partyMembers = partyService.GetPartyMembers(); + const bool isPartyMember = std::find(partyMembers.begin(), partyMembers.end(), acMessage.PlayerId) != partyMembers.end(); + + // Dialogue is only synced within the party; strangers' dialogue choices never float. + if (acMessage.MessageType == kPlayerDialogue ? !isPartyMember : !IsEnabledFor(m_settings.FloatingChat, isPartyMember)) + return; + + FloatingLine line; + // Decided now, like the chat, so a player who leaves later stays hidden. + if (m_settings.RevealedChatPlayerIds.count(acMessage.PlayerId) > 0) + { + const String* pName = partyService.GetKnownName(acMessage.PlayerId); + line.Name = pName ? pName->c_str() : acMessage.PlayerName.c_str(); + } + line.Text = acMessage.ChatMessage.c_str(); + std::replace(line.Text.begin(), line.Text.end(), '\n', ' '); + line.Type = acMessage.MessageType; + + const uint64_t chars = static_cast(ImTextCountCharsFromUtf8(line.Text.c_str(), line.Text.c_str() + line.Text.size())); + line.ExpiresAt = m_world.GetTick() + std::min(kLineBaseMs + kLinePerCharMs * chars, kLineMaxMs); + + m_floatingLines[acMessage.PlayerId] = std::move(line); +} + +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); + + // UI health updates can re-emit identical settings; avoid replacing the + // game-thread state when nothing affecting the tags has changed. + if (m_settings.NameTags == aSettings.NameTags && + m_settings.Backdrop == aSettings.Backdrop && + m_settings.FloatingChat == aSettings.FloatingChat && + m_settings.FontSize == aSettings.FontSize && + m_settings.NameFadeStart == aSettings.NameFadeStart && + m_settings.NameInvisible == aSettings.NameInvisible && + m_settings.PartyFadeStart == aSettings.PartyFadeStart && + m_settings.PartyInvisible == aSettings.PartyInvisible && + m_settings.StrangerLabel == aSettings.StrangerLabel && + m_settings.RevealedPlayerIds == aSettings.RevealedPlayerIds && + m_settings.RevealedChatPlayerIds == aSettings.RevealedChatPlayerIds) + return; + + if (aSettings.FloatingChat == Filter::kOff) + m_floatingLines.clear(); + + m_settings = std::move(aSettings); +} + +void NameTagService::OnDraw() noexcept +{ + if (m_settings.NameTags == Filter::kOff && m_floatingLines.empty()) + 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}; + const uint64_t tick = m_world.GetTick(); + + // 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(); + + // A floating chat line shows even where the name tag filter hides the tag. + const auto lineIt = m_floatingLines.find(playerComponent.Id); + const bool hasLine = lineIt != m_floatingLines.end() && lineIt->second.ExpiresAt > tick; + if (!hasLine && !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; + + // A floating chat line starts with the name, so it replaces the tag while it shows. + if (hasLine) + { + DrawFloatingLine(drawList, font, lineIt->second, strangerLabel, ImVec2(headScreen.x, headScreen.y - gap), fontSize * 0.9f, alpha, IsEnabledFor(m_settings.Backdrop, isPartyMember)); + continue; + } + + 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. 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)); + } + + 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); +} + +void NameTagService::LayoutFloatingLine(FloatingLine& aLine, ImFont* apFont, const std::string& acPrefix, float aFontSize) noexcept +{ + aLine.LayoutSize = aFontSize; + aLine.LayoutPrefix = acPrefix; + aLine.LineCount = 0; + aLine.LineHeight = 0.f; + aLine.Width = 0.f; + + const std::string full = acPrefix + " " + aLine.Text; + const float wrapWidth = kLineWrapPx * aFontSize / 22.f; + const float scale = aFontSize / apFont->FontSize; + auto width = [&](const char* apBegin, const char* apEnd) { return apFont->CalcTextSizeA(aFontSize, FLT_MAX, 0.f, apBegin, apEnd); }; + + // Word-wrap into at most kLineMaxLines lines; the last is cut with "..." if the text goes on. + const char* pBegin = full.c_str(); + const char* pText = pBegin; + const char* pEnd = pBegin + full.size(); + while (pText < pEnd && aLine.LineCount < kLineMaxLines) + { + const char* pBreak = apFont->CalcWordWrapPositionA(scale, pText, pEnd, wrapWidth); + if (pBreak <= pText) + { + // A single glyph wider than the line: take it anyway. + unsigned int codepoint = 0; + pBreak = pText + ImTextCharFromUtf8(&codepoint, pText, pEnd); + } + + const size_t start = static_cast(pText - pBegin); + std::string text(pText, pBreak); + pText = pBreak; + while (pText < pEnd && *pText == ' ') + ++pText; + while (!text.empty() && text.back() == ' ') + text.pop_back(); + + // The part of this line that belongs to "Name:", so a name wrapped over two lines keeps its colour. + size_t nameBytes = start < acPrefix.size() ? std::min(acPrefix.size() - start, text.size()) : 0; + if (aLine.LineCount == kLineMaxLines - 1 && pText < pEnd) + { + constexpr const char* kEllipsis = "..."; + while (!text.empty() && width(text.c_str(), nullptr).x + width(kEllipsis, nullptr).x > wrapWidth) + PopLastUtf8Char(text); + nameBytes = std::min(nameBytes, text.size()); + text += kEllipsis; + } + + FloatingLine::LayoutLine& line = aLine.Lines[aLine.LineCount++]; + const ImVec2 size = width(text.c_str(), text.c_str() + text.size()); + line.NameBytes = nameBytes; + line.NameWidth = nameBytes ? width(text.c_str(), text.c_str() + nameBytes).x : 0.f; + line.Width = size.x; + line.Text = std::move(text); + aLine.LineHeight = std::max(aLine.LineHeight, size.y); + aLine.Width = std::max(aLine.Width, size.x); + } +} + +void NameTagService::DrawFloatingLine(ImDrawList* apDrawList, ImFont* apFont, FloatingLine& aLine, const char* acpStrangerLabel, ImVec2 aBottomCenter, float aFontSize, float aAlpha, bool aBackdrop) const noexcept +{ + const bool isNameKnown = !aLine.Name.empty(); + const std::string prefix = (isNameKnown ? aLine.Name : std::string(acpStrangerLabel)) + ":"; + + // The layout only changes with the size (distance, in half pixels) or the stranger label, so it's cached on the line. + const float fontSize = std::round(aFontSize * 2.f) * 0.5f; + if (aLine.LayoutSize != fontSize || aLine.LayoutPrefix != prefix) + LayoutFloatingLine(aLine, apFont, prefix, fontSize); + + const float k = fontSize / 22.f; + const float paddingX = 5.f * k; + const float paddingY = 2.f * k; + const ImVec2 bottomRight(aBottomCenter.x + aLine.Width * 0.5f + paddingX, aBottomCenter.y); + const ImVec2 topLeft(aBottomCenter.x - aLine.Width * 0.5f - paddingX, aBottomCenter.y - aLine.LineHeight * aLine.LineCount - paddingY * 2.f); + if (aBackdrop) + apDrawList->AddRectFilled(topLeft, bottomRight, ColorWithAlpha(ImVec4(0.f, 0.f, 0.f, 0.30f), aAlpha)); + + const ImU32 shadowColor = ColorWithAlpha(ImVec4(0.f, 0.f, 0.f, 0.8f), aAlpha); + const ImU32 nameColor = ColorWithAlpha(ChatNameColor(aLine.Type), aAlpha); + const ImU32 textColor = ColorWithAlpha(ImVec4(0.95f, 0.95f, 0.95f, 1.f), aAlpha); + for (int i = 0; i < aLine.LineCount; ++i) + { + const FloatingLine::LayoutLine& line = aLine.Lines[i]; + const ImVec2 pos(aBottomCenter.x - line.Width * 0.5f, topLeft.y + paddingY + aLine.LineHeight * i); + const char* pText = line.Text.c_str(); + + if (line.NameBytes > 0) + DrawShadowedText(apDrawList, apFont, fontSize, pos, pText, pText + line.NameBytes, nameColor, shadowColor, !isNameKnown); + if (line.NameBytes < line.Text.size()) + DrawShadowedText(apDrawList, apFont, fontSize, ImVec2(pos.x + line.NameWidth, pos.y), pText + line.NameBytes, pText + line.Text.size(), textColor, shadowColor, false); + } +} diff --git a/Code/client/Services/Generic/ObjectService.cpp b/Code/client/Services/Generic/ObjectService.cpp index b2140e2eda..75914fd691 100644 --- a/Code/client/Services/Generic/ObjectService.cpp +++ b/Code/client/Services/Generic/ObjectService.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -7,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -16,6 +18,10 @@ #include #include #include +#include +#include +#include +#include #include #include @@ -37,8 +43,13 @@ ObjectService::ObjectService(World& aWorld, entt::dispatcher& aDispatcher, Trans m_lockChangeConnection = aDispatcher.sink().connect<&ObjectService::OnLockChange>(this); m_lockChangeNotifyConnection = aDispatcher.sink().connect<&ObjectService::OnLockChangeNotify>(this); m_assignObjectConnection = aDispatcher.sink().connect<&ObjectService::OnAssignObjectsResponse>(this); + m_removeObjectsConnection = aDispatcher.sink().connect<&ObjectService::OnRemoveObjects>(this); m_scriptAnimationConnection = aDispatcher.sink().connect<&ObjectService::OnScriptAnimationEvent>(this); m_scriptAnimationNotifyConnection = aDispatcher.sink().connect<&ObjectService::OnNotifyScriptAnimation>(this); + m_waveCommandConnection = aDispatcher.sink().connect<&ObjectService::OnWaveCommand>(this); + m_updateConnection = aDispatcher.sink().connect<&ObjectService::OnUpdate>(this); + m_equipmentChangesConnection = aDispatcher.sink().connect<&ObjectService::OnNotifyEquipmentChanges>(this); + m_characterSpawnConnection = aDispatcher.sink().connect<&ObjectService::OnCharacterSpawnRequest>(this); EventDispatcherManager::Get()->activateEvent.RegisterSink(this); } @@ -110,7 +121,8 @@ bool ShouldSyncObject(const TESObjectREFR* apObject, const Set(); - - auto it = std::find_if(view.begin(), view.end(), [acServerId, view](entt::entity entity) { return view.get(entity).Id == acServerId; }); - - if (it != view.end()) - return *it; - - entt::entity entity = m_world.create(); - spdlog::info("Created object entity, server id: {:X}, form id {:X}", acServerId, acFormId); - - m_world.emplace(entity, acFormId); - m_world.emplace(entity, acServerId); + // Only remove ECS bindings; the corresponding Skyrim references still exist. + ObjectSystem::Remove(m_world, acMessage.ServerIds); +} - return entity; +entt::entity ObjectService::CreateObjectEntity(const uint32_t acFormId, const uint32_t acServerId) noexcept +{ + spdlog::debug("Assigning object form id {:X} to server id {:X}", acFormId, acServerId); + return ObjectSystem::Setup(m_world, acFormId, acServerId); } void ObjectService::OnActivate(const ActivateEvent& acEvent) noexcept { + // The event was queued when the game activated the object, and the game may have deleted the object since (an item + // an NPC picked up) or the activator. Use them only if their form IDs still name the same objects. + TESObjectREFR* pObject = Cast(TESForm::GetById(acEvent.ObjectId)); + Actor* pActivator = Cast(TESForm::GetById(acEvent.ActivatorId)); + if (!pObject || pObject != acEvent.pObject || !pActivator || pActivator != acEvent.pActivator) + { + spdlog::debug("Activation of {:X} by {:X} skipped: one of them no longer exists", acEvent.ObjectId, acEvent.ActivatorId); + return; + } + if (acEvent.ActivateFlag) { - acEvent.pObject->Activate(acEvent.pActivator, acEvent.Unk1, acEvent.pObjectToGet, acEvent.Count, acEvent.DefaultProcessing); + pObject->Activate(pActivator, acEvent.Unk1, acEvent.pObjectToGet, acEvent.Count, acEvent.DefaultProcessing); } if (!m_transport.IsConnected()) return; - if (Lock* pLock = acEvent.pObject->GetLock()) + if (Lock* pLock = pObject->GetLock()) { if (pLock->flags & 0xFF) return; @@ -266,16 +282,16 @@ void ObjectService::OnActivate(const ActivateEvent& acEvent) noexcept ActivateRequest request; - if (!m_world.GetModSystem().GetServerModId(acEvent.pObject->formID, request.Id)) + if (!m_world.GetModSystem().GetServerModId(pObject->formID, request.Id)) { - spdlog::error("Server form id not found for object form id {:X}", acEvent.pObject->formID); + spdlog::error("Server form id not found for object form id {:X}", pObject->formID); return; } - TESObjectCELL* pCell = acEvent.pObject->GetParentCellEx(); + TESObjectCELL* pCell = pObject->GetParentCellEx(); if (!pCell) { - spdlog::error("Activated object has no parent cell: {:X}", acEvent.pObject->formID); + spdlog::error("Activated object has no parent cell: {:X}", pObject->formID); return; } @@ -286,11 +302,11 @@ void ObjectService::OnActivate(const ActivateEvent& acEvent) noexcept } auto view = m_world.view(); - const auto pEntity = std::find_if(std::begin(view), std::end(view), [id = acEvent.pActivator->formID, view](entt::entity entity) { return view.get(entity).Id == id; }); + const auto pEntity = std::find_if(std::begin(view), std::end(view), [id = pActivator->formID, view](entt::entity entity) { return view.get(entity).Id == id; }); if (pEntity == std::end(view)) { - // spdlog::error("Activator entity not found for form id {:X}", acEvent.pActivator->formID); + // spdlog::error("Activator entity not found for form id {:X}", pActivator->formID); return; } @@ -412,34 +428,93 @@ void ObjectService::OnLockChangeNotify(const NotifyLockChange& acMessage) noexce pObject->LockChange(); } +// ScriptAnimationEvent carries a LOCAL form id; the wire format +// (ScriptAnimationRequest/NotifyScriptAnimation.FormID) carries a SERVER id. +// The translation happens here; refs without a server id are local-only. void ObjectService::OnScriptAnimationEvent(const ScriptAnimationEvent& acEvent) noexcept { + if (!m_transport.IsOnline()) + return; + + auto view = m_world.view(); + const auto it = std::find_if(view.begin(), view.end(), [view, id = acEvent.FormID](auto entity) { return view.get(entity).Id == id; }); + + if (it == view.end()) + { + spdlog::debug("{}: no synced entity for form id {:X}, event {}", __FUNCTION__, acEvent.FormID, acEvent.EventName.c_str()); + return; + } + + std::optional serverIdRes = Utils::GetServerId(*it); + if (!serverIdRes.has_value()) + return; + ScriptAnimationRequest request{}; - request.FormID = acEvent.FormID; + request.FormID = serverIdRes.value(); request.Animation = acEvent.Animation; request.EventName = acEvent.EventName; + request.IsSubGraph = acEvent.IsSubGraph; + spdlog::info("Sending script animation {}/{} of {:X} (server id {:X}), subgraph: {}", acEvent.Animation.c_str(), acEvent.EventName.c_str(), + acEvent.FormID, request.FormID, acEvent.IsSubGraph); m_transport.Send(request); } -void ObjectService::OnNotifyScriptAnimation(const NotifyScriptAnimation& acMessage) noexcept +void ObjectService::OnWaveCommand(const WaveCommandEvent& acEvent) noexcept { - if (acMessage.FormID == 0) + if (!m_transport.IsOnline()) return; - auto* pForm = TESForm::GetById(acMessage.FormID); - auto* pObject = Cast(pForm); + auto view = m_world.view(); + const auto it = std::find_if(view.begin(), view.end(), [view](auto entity) { return view.get(entity).Id == 0x14; }); - if (!pObject) + if (it == view.end()) + { + spdlog::debug("{}: local player entity not found", __FUNCTION__); + return; + } + + std::optional serverIdRes = Utils::GetServerId(*it); + if (!serverIdRes.has_value()) + { + spdlog::debug("{}: local player has no server id yet", __FUNCTION__); + return; + } + + ScriptAnimationRequest request{}; + request.FormID = serverIdRes.value(); + request.EventName = "IdleWave"; + + m_transport.Send(request); + + // The relay no longer echoes to the sender; play the wave locally. + BSFixedString eventName("IdleWave"); + PlayerCharacter::Get()->SendAnimationEvent(&eventName); +} + +void ObjectService::OnNotifyScriptAnimation(const NotifyScriptAnimation& acMessage) noexcept +{ + // FormID carries a server id; resolve it to whatever local form mirrors that entity + TESObjectREFR* pObject = Utils::GetByServerId(acMessage.FormID); + + spdlog::info("Received script animation {}/{} for server id {:X} ({:X}), subgraph: {}", acMessage.Animation.c_str(), acMessage.EventName.c_str(), + acMessage.FormID, pObject ? pObject->formID : 0, acMessage.IsSubGraph); + + // Kept even when the copy isn't loaded yet, so it still gets the event once it is. + if (acMessage.IsSubGraph) { - spdlog::error("Failed to fetch notify script animation object, form id: {:X}", acMessage.FormID); + m_subGraphReplays[acMessage.FormID].EventName = acMessage.EventName.c_str(); + RetrySubGraphEvent(acMessage.FormID, "received"); return; } + if (!pObject) + return; + BSFixedString eventName(acMessage.EventName.c_str()); if (acMessage.Animation == String{}) { - pObject->PlayAnimation(&eventName); + pObject->SendAnimationEvent(&eventName); } else { @@ -448,6 +523,69 @@ void ObjectService::OnNotifyScriptAnimation(const NotifyScriptAnimation& acMessa } } +// Tries the event from the next update on, until a worn item's graph takes it or it times out. +void ObjectService::RetrySubGraphEvent(uint32_t aServerId, const char* acpReason) noexcept +{ + constexpr double cGiveUpAfter = 10.0; + + auto it = m_subGraphReplays.find(aServerId); + if (it == m_subGraphReplays.end()) + return; + + spdlog::info("Subgraph event {} for server id {:X} is pending ({})", it.value().EventName.c_str(), aServerId, acpReason); + it.value().Delay = 0.0; + it.value().TimeLeft = cGiveUpAfter; +} + +// The skin armor can be equipped on the copy after the event arrived. A pending event gets more time, one that was +// already taken is left alone. +void ObjectService::OnNotifyEquipmentChanges(const NotifyEquipmentChanges& acMessage) noexcept +{ + auto it = m_subGraphReplays.find(acMessage.ServerId); + if (it != m_subGraphReplays.end() && it.value().Delay >= 0.0) + RetrySubGraphEvent(acMessage.ServerId, "gear changed"); +} + +// A beast form respawns the copy, which loads a new body that has missed the event. +void ObjectService::OnCharacterSpawnRequest(const CharacterSpawnRequest& acMessage) noexcept +{ + RetrySubGraphEvent(acMessage.ServerId, "copy respawned"); +} + +void ObjectService::OnUpdate(const UpdateEvent& acEvent) noexcept +{ + constexpr double cRetryInterval = 0.25; + + for (auto it = m_subGraphReplays.begin(); it != m_subGraphReplays.end(); ++it) + { + auto& replay = it.value(); + if (replay.Delay < 0.0) + continue; + + replay.Delay -= acEvent.Delta; + replay.TimeLeft -= acEvent.Delta; + if (replay.Delay > 0.0) + continue; + + TESObjectREFR* pObject = Utils::GetByServerId(it->first); + if (pObject && pObject->GetNiNode() && SendSubGraphEventUnsynced(pObject, replay.EventName.c_str())) + { + replay.Delay = -1.0; + spdlog::info("Subgraph event {} taken by {:X} (server id {:X})", replay.EventName.c_str(), pObject->formID, it->first); + continue; + } + + if (replay.TimeLeft <= 0.0) + { + replay.Delay = -1.0; + spdlog::info("Subgraph event {} for server id {:X} not taken, giving up", replay.EventName.c_str(), it->first); + continue; + } + + replay.Delay = cRetryInterval; + } +} + BSTEventResult ObjectService::OnEvent(const TESActivateEvent* acEvent, const EventDispatcher* aDispatcher) { #if ENVIRONMENT_DEBUG diff --git a/Code/client/Services/Generic/OverlayClient.cpp b/Code/client/Services/Generic/OverlayClient.cpp index a0378612fa..8868151430 100644 --- a/Code/client/Services/Generic/OverlayClient.cpp +++ b/Code/client/Services/Generic/OverlayClient.cpp @@ -5,11 +5,18 @@ #include #include +#include + +#include +#include #include #include #include +#include + +#include #include @@ -32,11 +39,6 @@ bool OverlayClient::OnProcessMessageReceived(CefRefPtr browser, CefR auto eventName = pArguments->GetString(0).ToString(); auto eventArgs = pArguments->GetList(1); - spdlog::info(eventName); - spdlog::info(eventArgs->GetString(0).ToString()); - spdlog::info(std::to_string(eventArgs->GetInt(1))); - spdlog::info(eventArgs->GetString(2).ToString()); - #ifndef PUBLIC_BUILD LOG(INFO) << "event=ui_event name=" << eventName; #endif @@ -47,10 +49,14 @@ 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") ProcessSetTimeCommand(eventArgs); + else if (eventName == "wave") + ProcessWaveCommand(); else if (eventName == "launchParty") World::Get().GetPartyService().CreateParty(); else if (eventName == "leaveParty") @@ -82,6 +88,80 @@ bool OverlayClient::OnProcessMessageReceived(CefRefPtr browser, CefR ProcessToggleDebugUI(); else if (eventName == "openPlayGuide") ProcessOpenPlayGuide(); + else if (eventName == "sendTradeInvite") + { + uint32_t aPlayerId = eventArgs->GetInt(0); + World::Get().GetRunner().Queue([aPlayerId]() { World::Get().GetTradeService().SendInvite(aPlayerId); }); + } + else if (eventName == "respondTradeInvite") + { + uint32_t inviterId = eventArgs->GetInt(0); + bool accept = eventArgs->GetBool(1); + World::Get().GetRunner().Queue([inviterId, accept]() { World::Get().GetTradeService().RespondToInvite(inviterId, accept); }); + } + else if (eventName == "cancelTrade") + { + World::Get().GetRunner().Queue([]() { World::Get().GetTradeService().CancelTrade(); }); + } + else if (eventName == "setTradeReady") + { + bool ready = eventArgs->GetBool(0); + World::Get().GetRunner().Queue([ready]() { World::Get().GetTradeService().SetReady(ready); }); + } + else if (eventName == "updateTradeOffer") + { + TiltedPhoques::Vector selections; + auto pList = eventArgs->GetList(0); + if (pList) + { + const auto cCount = pList->GetSize(); + selections.reserve(cCount); + for (size_t i = 0; i < cCount; ++i) + { + TradeService::OfferSelection selection{}; + + if (pList->GetType(static_cast(i)) == VTYPE_DICTIONARY) + { + auto pEntry = pList->GetDictionary(static_cast(i)); + if (!pEntry) + continue; + + selection.Index = static_cast(pEntry->GetInt("index")); + selection.Count = pEntry->GetInt("count"); + } + else + { + auto pEntry = pList->GetList(static_cast(i)); + if (!pEntry || pEntry->GetSize() < 2) + continue; + + selection.Index = static_cast(pEntry->GetInt(0)); + selection.Count = pEntry->GetInt(1); + } + + if (selection.Count <= 0) + continue; + + selections.push_back(selection); + } + } + World::Get().GetRunner().Queue([selections = std::move(selections)]() { World::Get().GetTradeService().UpdateOffer(selections); }); + } + else if (eventName == "voiceRefresh") + World::Get().GetRunner().Queue([]() { World::Get().ctx().at().RefreshUi(); }); + else if (eventName == "setVoiceSettings") + ProcessSetVoiceSettings(eventArgs); + else if (eventName == "setVoicePlayerVolume") + { + const uint32_t playerId = eventArgs->GetInt(0); + const float volume = static_cast(eventArgs->GetInt(1)) / 100.f; + World::Get().GetRunner().Queue([playerId, volume]() { World::Get().ctx().at().SetPlayerVolume(playerId, volume); }); + } + else if (eventName == "setVoiceMeter") + { + const bool enabled = eventArgs->GetInt(0) != 0; + World::Get().GetRunner().Queue([enabled]() { World::Get().ctx().at().SetMeterEnabled(enabled); }); + } return true; } @@ -139,6 +219,11 @@ void OverlayClient::ProcessSetTimeCommand(CefRefPtr aEventArgs) World::Get().GetDispatcher().trigger(SetTimeCommandEvent(hours, minutes, senderId)); } +void OverlayClient::ProcessWaveCommand() +{ + World::Get().GetRunner().Trigger(WaveCommandEvent()); +} + void OverlayClient::ProcessTeleportMessage(CefRefPtr aEventArgs) { TeleportRequest request{}; @@ -157,6 +242,26 @@ void OverlayClient::ProcessOpenPlayGuide() ShellExecuteW(nullptr, L"open", LR"(https://wiki.tiltedphoques.com/tilted-online/general-information/playguide)", nullptr, nullptr, SW_SHOWNORMAL); } +// Numbers come as whole numbers from the UI: percentages for the volumes and units for the fade start +void OverlayClient::ProcessSetVoiceSettings(CefRefPtr aEventArgs) +{ + VoiceService::Settings settings{}; + settings.InputDevice = aEventArgs->GetString(0).ToString().c_str(); + settings.Connect = aEventArgs->GetInt(1) != 0; + settings.OutputDevice = aEventArgs->GetString(2).ToString().c_str(); + + const auto channel = aEventArgs->GetString(3).ToString(); + settings.ListenChannel = channel == "party" ? VoiceService::Channel::kParty : channel == "strangers" ? VoiceService::Channel::kStrangers : VoiceService::Channel::kEveryone; + + 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); }); +} + void OverlayClient::SetUIVisible(bool aVisible) noexcept { auto pRenderer = GetOverlayRenderHandler(); @@ -167,3 +272,58 @@ 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.FloatingChat = getFilter("floatingChat", settings.FloatingChat); + 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); + readIds("revealedChatPlayerIds", settings.RevealedChatPlayerIds); + + World::Get().GetRunner().Queue([settings = std::move(settings)]() mutable { World::Get().ctx().at().SetSettings(std::move(settings)); }); +} diff --git a/Code/client/Services/Generic/OverlayService.cpp b/Code/client/Services/Generic/OverlayService.cpp index 50f2b3a734..d6e57eaca2 100644 --- a/Code/client/Services/Generic/OverlayService.cpp +++ b/Code/client/Services/Generic/OverlayService.cpp @@ -1,5 +1,7 @@ #include +#include + #include #include @@ -67,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(); @@ -136,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()) { @@ -491,7 +514,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/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/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/Generic/PlayerService.cpp b/Code/client/Services/Generic/PlayerService.cpp index 6ced4d55b4..b6f5a05807 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,11 +348,71 @@ 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; static std::chrono::steady_clock::time_point lastSendTimePoint; constexpr auto cDelayBetweenUpdates = 250ms; + // Werewolf (0xCDD84) and Vampire Lord (0x200283A), and any other non-playable race: mods transform the + // player with SetRace into their own beast races (e.g. Enderal's Theriantrophist wolf form), and without + // the respawn the remote puppet keeps its old body on the new race's skeleton and behavior. Playable races + // are excluded so race changes in the RaceSex menu don't respawn the puppet. + const auto isBeastRace = [](uint32_t aFormId) noexcept { + if (aFormId == 0xCDD84 || aFormId == 0x200283A) + return true; + + const TESRace* pRace = Cast(TESForm::GetById(aFormId)); + return pRace && !pRace->IsPlayable(); + }; const auto now = std::chrono::steady_clock::now(); if (now - lastSendTimePoint < cDelayBetweenUpdates) @@ -334,13 +424,17 @@ void PlayerService::RunBeastFormDetection() const noexcept if (!pPlayer->race) return; - if (pPlayer->race->formID == lastRaceFormID) + const uint32_t raceFormId = pPlayer->race->formID; + if (raceFormId == lastRaceFormID) return; - if (pPlayer->race->formID == 0x200283A || pPlayer->race->formID == 0xCDD84) + // Fire on both enter and leave once the race (and thus serialized appearance) has + // settled. HookSetBeastForm alone is too early on leave (same late-inform rationale + // as 1b0f698 for enter). Transform quests stay denylisted in QuestService. + if (isBeastRace(raceFormId) || isBeastRace(lastRaceFormID)) m_world.GetDispatcher().trigger(BeastFormChangeEvent()); - lastRaceFormID = pPlayer->race->formID; + lastRaceFormID = raceFormId; } void PlayerService::ToggleDeathSystem(bool aSet) noexcept 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/Generic/QuestService.cpp b/Code/client/Services/Generic/QuestService.cpp index f6a5d39b83..5ab8ca0df0 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); @@ -615,6 +623,8 @@ static constexpr std::array kNonSyncableQuestIds = std::to_array({ 0x2BA16, // Werewolf transformation quest 0x20071D0, // Vampire transformation quest 0x3AC44, // MS13BleakFallsBarrowLeverScene + 0xAA0B3, // Riften Scripted Scene North Gate: per-player gate guard toll scene, restarts while anyone + // stands in its trigger and looped stage 200 updates through the party (freeze on player join) // 0xFE014801, // Unknown dynamic ID, kept as note, maybe lookup correct ID this game? 0xF2593 // Skill experience quest }); diff --git a/Code/client/Services/Generic/TradeService.cpp b/Code/client/Services/Generic/TradeService.cpp new file mode 100644 index 0000000000..65a6fc7716 --- /dev/null +++ b/Code/client/Services/Generic/TradeService.cpp @@ -0,0 +1,621 @@ +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr uint64_t kInviteCleanupIntervalMs = 1000; + +const char* GetItemCategory(TESForm* apForm) noexcept +{ + if (!apForm) + return "misc"; + switch (apForm->formType) + { + case FormType::Weapon: return "weapons"; + case FormType::Ammo: return "ammunition"; + case FormType::Armor: return "armor"; + case FormType::Scroll: return "scrolls"; + case FormType::Ingredient: return "ingredients"; + case FormType::Book: + case FormType::Note: return "books"; + case FormType::Key: return "keys"; + case FormType::SoulGem: return "soul_gems"; + case FormType::Alchemy: + if (const auto* pAlchemy = Cast(apForm)) + { + if (pAlchemy->IsPoison()) + return "poisons"; + if (pAlchemy->IsFood()) + return "food"; + } + return "potions"; + default: return "misc"; + } +} + +bool EffectsResolve(ModSystem& aModSystem, const Vector& acEffects) noexcept +{ + for (const auto& effect : acEffects) + if (!Cast(TESForm::GetById(aModSystem.GetGameId(effect.EffectId)))) + return false; + return true; +} + +// Whether this game can rebuild a partner's item: every plugin form it references must be loaded, +// and temporary forms must carry data to recreate them here. +bool CanReceiveTradeItem(ModSystem& aModSystem, const Inventory::Entry& acItem) noexcept +{ + if (!acItem.Potion.Effects.empty()) + { + if (!acItem.Potion.IsValid() || !EffectsResolve(aModSystem, acItem.Potion.Effects)) + return false; + } + else if (acItem.BaseId.ModId == 0xFFFFFFFF || !Cast(TESForm::GetById(aModSystem.GetGameId(acItem.BaseId)))) + return false; + + if (acItem.ExtraEnchantId != 0) + { + if (acItem.ExtraEnchantId.ModId == 0xFFFFFFFF) + { + // Player-crafted enchantment: recreated from its effects on the recipient. + if (acItem.EnchantData.Effects.empty() || !EffectsResolve(aModSystem, acItem.EnchantData.Effects)) + return false; + } + else if (!TESForm::GetById(aModSystem.GetGameId(acItem.ExtraEnchantId))) + return false; + } + + if (acItem.ExtraPoisonId != 0) + { + if (acItem.ExtraPoisonId.ModId == 0xFFFFFFFF) + { + // Applied player-crafted poison: recreated from its effects on the recipient. + if (!acItem.PoisonData.IsValid() || !EffectsResolve(aModSystem, acItem.PoisonData.Effects)) + return false; + } + else if (!Cast(TESForm::GetById(aModSystem.GetGameId(acItem.ExtraPoisonId)))) + return false; + } + return true; +} + +std::string MakeDisplayName(ModSystem& aModSystem, const Inventory::Entry& aEntry) +{ + if (!aEntry.Potion.Effects.empty()) + { + // Only name from a potion this game already has; creating forms just to render the UI is avoided. + if (auto* local = AlchemyItem::Find(PlayerCharacter::Get(), aEntry.Potion); local && local->GetName()) + return local->GetName(); + auto* first = TESForm::GetById(aModSystem.GetGameId(aEntry.Potion.Effects[0].EffectId)); + return fmt::format("{} of {}", aEntry.Potion.IsPoison ? "Poison" : "Potion", first && first->GetName() ? first->GetName() : "unknown effect"); + } + const uint32_t formId = aModSystem.GetGameId(aEntry.BaseId); + if (formId) + { + if (auto* pForm = TESForm::GetById(formId)) + { + if (const char* pName = pForm->GetName(); pName && std::strlen(pName) > 0) + return pName; + } + } + + return fmt::format("0x{:08X}:0x{:08X}", aEntry.BaseId.ModId, aEntry.BaseId.BaseId); +} + +// Names are local UI metadata only: offers and network inventory retain the base name. +CefRefPtr GetLocalCustomNames(ModSystem& aModSystem, const Inventory::Entry& aEntry) +{ + auto names = CefListValue::Create(); + auto* player = PlayerCharacter::Get(); + auto* changes = player ? player->GetContainerChanges() : nullptr; + if (!changes || !changes->entries) + return names; + + const auto* potion = aEntry.Potion.Effects.empty() ? nullptr : AlchemyItem::Find(player, aEntry.Potion); + const auto formId = potion ? potion->formID : aModSystem.GetGameId(aEntry.BaseId); + std::vector seen; + for (auto* entry : *changes->entries) + { + if (!entry || !entry->form || entry->form->formID != formId || !entry->dataList) + continue; + for (auto* extra : *entry->dataList) + { + if (!extra) + continue; + auto* text = Cast(extra->GetByType(ExtraDataType::TextDisplayData)); + const char* name = text ? text->DisplayName.AsAscii() : nullptr; + if (!name || !*name) + continue; + Inventory::Entry local; + local.BaseId = aEntry.BaseId; + local.Potion = aEntry.Potion; + TESObjectREFR::GetItemFromExtraData(local, extra); + if (!SameTradeItem(local, aEntry) || std::find(seen.begin(), seen.end(), name) != seen.end()) + continue; + seen.emplace_back(name); + names->SetString(static_cast(seen.size() - 1), name); + } + } + return names; +} + +// Creature skins, transformation gear (werewolf, Vampire Lord), bound weapons, traps and dummy items: the game marks +// them non-playable, which also hides them from the inventory menu, or gives them no name. +bool IsUntradableForm(ModSystem& aModSystem, const Inventory::Entry& aEntry) +{ + if (!aEntry.Potion.Effects.empty()) + return false; + + const uint32_t formId = aModSystem.GetGameId(aEntry.BaseId); + const TESForm* pForm = formId ? TESForm::GetById(formId) : nullptr; + if (!pForm) + return false; + + const char* pName = pForm->GetName(); + return !pForm->IsPlayable() || !pName || !*pName; +} +} // namespace + +TradeService::TradeService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept + : m_world(aWorld) + , m_transport(aTransport) +{ + m_updateConnection = aDispatcher.sink().connect<&TradeService::OnUpdate>(this); + m_disconnectConnection = aDispatcher.sink().connect<&TradeService::OnDisconnected>(this); + m_tradeInviteConnection = aDispatcher.sink().connect<&TradeService::OnTradeInvite>(this); + m_tradeStartedConnection = aDispatcher.sink().connect<&TradeService::OnTradeStarted>(this); + m_tradeStateConnection = aDispatcher.sink().connect<&TradeService::OnTradeState>(this); + m_tradeCancelConnection = aDispatcher.sink().connect<&TradeService::OnTradeCancel>(this); + m_tradeCompleteConnection = aDispatcher.sink().connect<&TradeService::OnTradeComplete>(this); +} + +void TradeService::SendInvite(uint32_t aTargetPlayerId) const noexcept +{ + TradeInviteRequest request{}; + request.TargetPlayerId = aTargetPlayerId; + m_transport.Send(request); +} + +void TradeService::RespondToInvite(uint32_t aRequesterPlayerId, bool aAccept) const noexcept +{ + TradeInviteResponseRequest response{}; + response.RequesterPlayerId = aRequesterPlayerId; + response.Accept = aAccept; + m_transport.Send(response); +} + +void TradeService::CancelTrade() const noexcept +{ + TradeCancelRequest request{}; + m_transport.Send(request); +} + +TradeService::~TradeService() noexcept +{ + ReleasePreparedPotions(); +} + +void TradeService::ReleasePreparedPotions() noexcept +{ + // References are kept: the same forms may already be in this player's inventory after the trade. + m_preparedPotions.clear(); +} + +void TradeService::SetReady(bool aReady) noexcept +{ + TradeSetReadyRequest request{}; + request.Ready = aReady; + if (aReady) + { + // The server validates the offer against this snapshot; an outdated offer unreadies instead of cancelling. + SyncInventory(); + // Accept only partner items this game can rebuild; the rest stay with the partner. + // Keep prepared potion forms alive through cancellation races until the session ends. + auto& modSystem = m_world.GetModSystem(); + for (const auto& item : m_session.PartnerItems) + { + if (!CanReceiveTradeItem(modSystem, item)) + continue; + if (!item.Potion.Effects.empty()) + { + auto* created = AlchemyItem::Create(item.Potion); + if (!created) + { + spdlog::error("[TradeService]: Unable to prepare incoming crafted potion; leaving it with the partner"); + continue; + } + if (std::find(m_preparedPotions.begin(), m_preparedPotions.end(), created) == m_preparedPotions.end()) + m_preparedPotions.push_back(created); + } + request.AcceptedItems.push_back(item); + } + } + m_transport.Send(request); +} + +void TradeService::SyncInventory() const noexcept +{ + auto* player = PlayerCharacter::Get(); + if (!player) + return; + TradeInventorySyncRequest request{}; + request.Entries = player->GetInventory().Entries; + m_transport.Send(request); +} + +void TradeService::UpdateOffer(const TiltedPhoques::Vector& aSelections) noexcept +{ + ApplyOfferSelection(aSelections); +} + +void TradeService::ApplyOfferSelection(const TiltedPhoques::Vector& aSelections) +{ + if (!m_session.Active) + return; + + TiltedPhoques::Vector entries; + entries.reserve(aSelections.size()); + + for (const auto& selection : aSelections) + { + if (selection.Index >= m_session.SelfInventory.size()) + continue; + + if (selection.Count <= 0) + continue; + + const auto& sourceEntry = m_session.SelfInventory[selection.Index]; + if (selection.Count > sourceEntry.Count) + continue; + + if (IsUntradableForm(m_world.GetModSystem(), sourceEntry)) + continue; + + Inventory::Entry entry = sourceEntry; + entry.Count = selection.Count; + entries.push_back(entry); + } + + TradeOfferUpdateRequest request{}; + request.Items = entries; + m_transport.Send(request); + + m_session.SelfItems = request.Items; + m_session.SelfReady = false; + + EmitStateToUI(); +} + +void TradeService::OnUpdate(const UpdateEvent&) noexcept +{ + const auto cCurrentTick = m_transport.GetClock().GetCurrentTick(); + + static uint64_t s_nextCleanupTick = 0; + if (s_nextCleanupTick > cCurrentTick) + return; + + s_nextCleanupTick = cCurrentTick + kInviteCleanupIntervalMs; + + auto it = std::begin(m_pendingInvites); + while (it != std::end(m_pendingInvites)) + { + if (it->second <= cCurrentTick) + { + const uint32_t inviterId = it->first; + it = m_pendingInvites.erase(it); + EmitInviteUpdate(inviterId, false); + } + else + { + ++it; + } + } +} + +void TradeService::OnDisconnected(const DisconnectedEvent&) noexcept +{ + if (!m_pendingInvites.empty()) + { + auto invites = m_pendingInvites; + m_pendingInvites.clear(); + for (const auto& [inviterId, _] : invites) + EmitInviteUpdate(inviterId, false); + } + + if (m_session.Active) + { + EmitCancellation(m_session.PartnerId, TradeCancelReason::Cancelled, m_session.InitiatedBySelf); + ClearSession(); + } +} + +void TradeService::OnTradeInvite(const NotifyTradeInvite& acMessage) noexcept +{ + m_pendingInvites[acMessage.InviterPlayerId] = acMessage.ExpiryTick; + EmitInviteUpdate(acMessage.InviterPlayerId, true, acMessage.ExpiryTick); +} + +void TradeService::OnTradeStarted(const NotifyTradeStarted& acMessage) noexcept +{ + m_session.Active = true; + m_session.PartnerId = acMessage.PartnerPlayerId; + m_session.InitiatedBySelf = acMessage.InitiatedBySelf; + m_session.SelfReady = false; + m_session.PartnerReady = false; + m_session.SelfItems.clear(); + m_session.PartnerItems.clear(); + m_session.SelfInventory.clear(); + m_session.SelfAcceptedItems.clear(); + m_session.CountdownMs = 0; + m_session.CountdownTotalMs = 0; + + m_pendingInvites.erase(acMessage.PartnerPlayerId); + EmitInviteUpdate(acMessage.PartnerPlayerId, false); + + // Offer from the live inventory: enchanted, renamed and brewed items may be missing from the server copy. + SyncInventory(); + + EmitStateToUI(); +} + +void TradeService::OnTradeState(const NotifyTradeState& acMessage) noexcept +{ + if (!m_session.Active || m_session.PartnerId != acMessage.PartnerPlayerId) + { + m_session.Active = true; + m_session.PartnerId = acMessage.PartnerPlayerId; + } + + m_session.SelfReady = acMessage.SelfReady; + m_session.PartnerReady = acMessage.PartnerReady; + m_session.SelfItems = acMessage.SelfItems; + m_session.PartnerItems = acMessage.PartnerItems; + m_session.SelfInventory = acMessage.SelfInventory; + m_session.SelfAcceptedItems = acMessage.SelfAcceptedItems; + m_session.CountdownMs = acMessage.CountdownMs; + m_session.CountdownTotalMs = acMessage.CountdownTotalMs; + + EmitStateToUI(); +} + +void TradeService::OnTradeCancel(const NotifyTradeCancel& acMessage) noexcept +{ + EmitCancellation(acMessage.PartnerPlayerId, acMessage.Reason, acMessage.WasInitiator); + + if (HasActiveSessionWith(acMessage.PartnerPlayerId)) + ClearSession(); +} + +void TradeService::OnTradeComplete(const NotifyTradeComplete& acMessage) noexcept +{ + auto* pOverlay = m_world.GetOverlayService().GetOverlayApp(); + if (pOverlay) + { + auto pArgs = CefListValue::Create(); + pArgs->SetInt(0, acMessage.PartnerPlayerId); + pOverlay->ExecuteAsync("tradeCompleted", pArgs); + } + + if (HasActiveSessionWith(acMessage.PartnerPlayerId)) + ClearSession(); +} + +void TradeService::ClearSession() noexcept +{ + ReleasePreparedPotions(); + m_session = TradeSession{}; + EmitStateToUI(); +} + +void TradeService::EmitStateToUI() const noexcept +{ + auto* pOverlay = m_world.GetOverlayService().GetOverlayApp(); + if (!pOverlay) + return; + + auto pArgs = CefListValue::Create(); + pArgs->SetBool(0, m_session.Active); + pArgs->SetInt(1, m_session.PartnerId); + pArgs->SetBool(2, m_session.InitiatedBySelf); + pArgs->SetBool(3, m_session.SelfReady); + pArgs->SetBool(4, m_session.PartnerReady); + + auto& modSystem = m_world.GetModSystem(); + + auto formatEffect = [&](const Inventory::EffectItem& effect) { + auto* form = TESForm::GetById(modSystem.GetGameId(effect.EffectId)); + std::string text = fmt::format("{}: {:.0f}", form && form->GetName() ? form->GetName() : "Unknown effect", effect.Magnitude); + if (effect.Area > 0) + text += fmt::format(", {} ft", effect.Area); + if (effect.Duration > 0) + text += fmt::format(" for {}s", effect.Duration); + return text; + }; + + auto makeDict = [&](const Inventory::Entry& entry, const char* apNote = nullptr) { + auto dict = CefDictionaryValue::Create(); + dict->SetInt("modId", entry.BaseId.ModId); + dict->SetInt("baseId", entry.BaseId.BaseId); + dict->SetBool("isQuestItem", entry.IsQuestItem); + dict->SetBool("isEquipped", entry.IsWorn()); + dict->SetBool("isUnsupportedTemporary", entry.BaseId.ModId == 0xFFFFFFFF && !entry.Potion.IsValid()); + dict->SetBool("isUntradable", IsUntradableForm(modSystem, entry)); + dict->SetString("name", MakeDisplayName(modSystem, entry)); + dict->SetBool("isGold", entry.BaseId.ModId == 0 && entry.BaseId.BaseId == 0x0000000F); + const uint32_t formId = modSystem.GetGameId(entry.BaseId); + dict->SetString("category", !entry.Potion.Effects.empty() ? (entry.Potion.IsPoison ? "poisons" : "potions") : GetItemCategory(formId ? TESForm::GetById(formId) : nullptr)); + + auto details = CefListValue::Create(); + int detailIndex = 0; + if (!entry.Potion.Effects.empty()) + { + // Temporary IDs belong to the owner's game session; the recipient gets a fresh one from AddPotion. + details->SetString(detailIndex++, fmt::format("Temporary ID: {:08X}", 0xFF000000u | (entry.BaseId.BaseId & 0x00FFFFFFu))); + for (const auto& effect : entry.Potion.Effects) + details->SetString(detailIndex++, formatEffect(effect)); + } + if (entry.ExtraHealth > 1.0f) + details->SetString(detailIndex++, fmt::format("Improvement: {:.0f}%", entry.ExtraHealth * 100.0f)); + if (entry.ExtraEnchantId.BaseId || !entry.EnchantData.Effects.empty()) + details->SetString(detailIndex++, "Enchanted"); + if (!entry.PoisonData.Effects.empty()) + { + details->SetString(detailIndex++, fmt::format("Poisoned ({} uses left):", entry.ExtraPoisonCount)); + for (const auto& effect : entry.PoisonData.Effects) + details->SetString(detailIndex++, " " + formatEffect(effect)); + } + else if (entry.ExtraPoisonId.BaseId) + details->SetString(detailIndex++, "Poisoned"); + if (entry.ExtraSoulLevel > 0) + details->SetString(detailIndex++, fmt::format("Soul level: {}", entry.ExtraSoulLevel)); + if (apNote) + details->SetString(detailIndex++, apNote); + dict->SetList("details", details); + return dict; + }; + + std::vector usedCounts(m_session.SelfInventory.size(), 0); + const auto selfAccepted = MatchAcceptedItems(m_session.SelfItems, m_session.SelfAcceptedItems); + + auto selfList = CefListValue::Create(); + for (size_t i = 0; i < m_session.SelfItems.size(); ++i) + { + const auto& item = m_session.SelfItems[i]; + const bool notReceivable = m_session.PartnerReady && !selfAccepted[i]; + auto dict = makeDict(item, notReceivable ? "Partner can't receive this item. It stays with you." : nullptr); + dict->SetInt("count", std::abs(item.Count)); + + std::optional match; + const auto needed = std::abs(item.Count); + for (uint32_t idx = 0; idx < m_session.SelfInventory.size(); ++idx) + { + const auto& inventoryEntry = m_session.SelfInventory[idx]; + if (!SameTradeItem(inventoryEntry, item)) + continue; + + const int32_t available = inventoryEntry.Count; + const int32_t alreadyUsed = usedCounts[idx]; + if (alreadyUsed + needed > available) + continue; + + match = idx; + usedCounts[idx] += needed; + break; + } + + if (match) + dict->SetInt("inventoryIndex", static_cast(*match)); + + selfList->SetDictionary(static_cast(i), dict); + } + pArgs->SetList(5, selfList); + + // Partner items this game cannot rebuild are hidden; they are never accepted and stay with the partner. + auto partnerList = CefListValue::Create(); + int partnerIndex = 0; + for (const auto& item : m_session.PartnerItems) + { + if (!CanReceiveTradeItem(modSystem, item)) + continue; + auto dict = makeDict(item); + dict->SetInt("count", std::abs(item.Count)); + partnerList->SetDictionary(partnerIndex++, dict); + } + pArgs->SetList(6, partnerList); + + auto inventoryList = CefListValue::Create(); + for (size_t i = 0; i < m_session.SelfInventory.size(); ++i) + { + const auto& entry = m_session.SelfInventory[i]; + auto dict = makeDict(entry); + dict->SetInt("count", entry.Count); + dict->SetInt("inventoryIndex", static_cast(i)); + dict->SetList("customNames", GetLocalCustomNames(modSystem, entry)); + dict->SetInt("offeredCount", i < usedCounts.size() ? usedCounts[i] : 0); + inventoryList->SetDictionary(static_cast(i), dict); + } + pArgs->SetList(7, inventoryList); + pArgs->SetInt(8, static_cast(m_session.CountdownMs)); + pArgs->SetInt(9, static_cast(m_session.CountdownTotalMs)); + + pOverlay->ExecuteAsync("tradeStateUpdated", pArgs); +} + +void TradeService::EmitInviteUpdate(uint32_t aInviterId, bool aAdded, uint64_t aExpiryTick) const noexcept +{ + auto* pOverlay = m_world.GetOverlayService().GetOverlayApp(); + if (!pOverlay) + return; + + auto pArgs = CefListValue::Create(); + pArgs->SetInt(0, aInviterId); + if (aAdded) + { + pArgs->SetDouble(1, static_cast(aExpiryTick)); + pOverlay->ExecuteAsync("tradeInviteReceived", pArgs); + } + else + { + pOverlay->ExecuteAsync("tradeInviteExpired", pArgs); + } +} + +void TradeService::EmitCancellation(uint32_t aPartnerId, TradeCancelReason aReason, bool aWasInitiator) const noexcept +{ + auto* pOverlay = m_world.GetOverlayService().GetOverlayApp(); + if (!pOverlay) + return; + + auto pArgs = CefListValue::Create(); + pArgs->SetInt(0, aPartnerId); + pArgs->SetInt(1, static_cast(aReason)); + pArgs->SetBool(2, aWasInitiator); + + pOverlay->ExecuteAsync("tradeCancelled", pArgs); +} + +bool TradeService::HasActiveSessionWith(uint32_t aPartnerId) const noexcept +{ + return m_session.Active && m_session.PartnerId == aPartnerId; +} diff --git a/Code/client/Services/Generic/TransportService.cpp b/Code/client/Services/Generic/TransportService.cpp index ed490d1502..61de8cbf23 100644 --- a/Code/client/Services/Generic/TransportService.cpp +++ b/Code/client/Services/Generic/TransportService.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -75,23 +76,30 @@ bool TransportService::Send(const ClientMessage& acMessage, const TiltedPhoques: ~ScopedReset() { s_allocator.Reset(); } } allocatorGuard; - if (IsConnected()) - { - ScopedAllocator _{s_allocator}; - - Buffer buffer(1 << 16); - Buffer::Writer writer(&buffer); - writer.WriteBits(0, 8); // Write first byte as packet needs it - - acMessage.Serialize(writer); - TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetWriteData()), writer.Size()); + if (!IsConnected()) + return false; - Client::Send(&packet, aPacketFlags); + { + SendBuffer::PacketBuffer buffer(acMessage, s_allocator, 1 << 16); + if (buffer.IsComplete()) + { + TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetData()), static_cast(buffer.Size())); + Client::Send(&packet, aPacketFlags); + return true; + } + } - return true; + SendBuffer::PacketBuffer buffer(acMessage, *Allocator::GetDefault(), k_cbMaxSteamNetworkingSocketsMessageSizeSend); + if (!buffer.IsComplete()) + { + spdlog::error("Message with opcode {} could not be serialized within the {} byte send limit", static_cast(acMessage.GetOpcode()), + k_cbMaxSteamNetworkingSocketsMessageSizeSend); + return false; } - return false; + TiltedPhoques::PacketView packet(reinterpret_cast(buffer.GetData()), static_cast(buffer.Size())); + Client::Send(&packet, aPacketFlags); + return true; } void TransportService::OnConsume(const void* apData, uint32_t aSize) diff --git a/Code/client/Services/Generic/VoiceAudio.cpp b/Code/client/Services/Generic/VoiceAudio.cpp new file mode 100644 index 0000000000..439533c29e --- /dev/null +++ b/Code/client/Services/Generic/VoiceAudio.cpp @@ -0,0 +1,518 @@ +#include + +#include +#include + +// Only devices are used, leave the decoders, encoders and engine out +#define MA_NO_DECODING +#define MA_NO_ENCODING +#define MA_NO_ENGINE +#define MA_NO_RESOURCE_MANAGER +#define MA_NO_NODE_GRAPH +#define MA_NO_GENERATION +#define MINIAUDIO_IMPLEMENTATION +#include + +#include + +#include +#include +#include +#include + +namespace +{ +// Speech at this bitrate sounds clear, 50 frames a second come to about 3 KB/s per speaker +constexpr int32_t kOpusBitrate = 24000; +constexpr size_t kMaxQueuedFrames = 50; +// Missing frames up to this many are concealed, a longer gap restarts the voice +constexpr int32_t kMaxConcealedFrames = 3; +constexpr auto kActiveSpeakerTime = std::chrono::milliseconds(300); +constexpr char kDefaultDeviceName[] = "default"; +} // namespace + +struct VoiceAudio::Impl +{ + struct Speaker + { + VoiceDsp::JitterBuffer Buffer; + float LeftGain{0.f}; + float RightGain{0.f}; + }; + + // Main thread only + struct Decoder + { + OpusDecoder* pDecoder{nullptr}; + uint16_t LastSequence{0}; + bool HasSequence{false}; + std::chrono::steady_clock::time_point LastFrameTime{}; + }; + + ~Impl() noexcept + { + StopCapture(); + StopPlayback(); + + for (auto& [id, decoder] : Decoders) + opus_decoder_destroy(decoder.pDecoder); + + if (pEncoder) + opus_encoder_destroy(pEncoder); + + if (HasContext) + ma_context_uninit(&Context); + } + + bool EnsureContext() noexcept + { + if (HasContext) + return true; + + if (ma_context_init(nullptr, 0, nullptr, &Context) != MA_SUCCESS) + { + spdlog::error("Voice chat: no audio backend could be started"); + return false; + } + + HasContext = true; + return true; + } + + bool FindDevice(ma_device_type aType, const String& acName, ma_device_id& aOutId) noexcept + { + ma_device_info* pPlayback = nullptr; + ma_uint32 playbackCount = 0; + ma_device_info* pCapture = nullptr; + ma_uint32 captureCount = 0; + if (ma_context_get_devices(&Context, &pPlayback, &playbackCount, &pCapture, &captureCount) != MA_SUCCESS) + return false; + + ma_device_info* pInfos = aType == ma_device_type_capture ? pCapture : pPlayback; + const ma_uint32 count = aType == ma_device_type_capture ? captureCount : playbackCount; + for (ma_uint32 i = 0; i < count; ++i) + { + if (acName == pInfos[i].name) + { + aOutId = pInfos[i].id; + return true; + } + } + + return false; + } + + void StartCapture(const String& acName) noexcept + { + StopCapture(); + CaptureName = acName; + if (acName.empty()) + return; + + ma_device_id id{}; + const bool useDefault = acName == kDefaultDeviceName; + if (!useDefault && !FindDevice(ma_device_type_capture, acName, id)) + { + spdlog::warn("Voice chat: input device \"{}\" is not connected, nothing is sent", acName); + return; + } + + if (!pEncoder) + { + int error = OPUS_OK; + pEncoder = opus_encoder_create(VoiceDsp::kSampleRate, 1, OPUS_APPLICATION_VOIP, &error); + if (error != OPUS_OK || !pEncoder) + { + spdlog::error("Voice chat: Opus encoder failed to start ({})", error); + pEncoder = nullptr; + return; + } + + opus_encoder_ctl(pEncoder, OPUS_SET_BITRATE(kOpusBitrate)); + opus_encoder_ctl(pEncoder, OPUS_SET_SIGNAL(OPUS_SIGNAL_VOICE)); + } + + ma_device_config config = ma_device_config_init(ma_device_type_capture); + config.capture.format = ma_format_f32; + config.capture.channels = 1; + config.capture.pDeviceID = useDefault ? nullptr : &id; + config.sampleRate = VoiceDsp::kSampleRate; + config.periodSizeInMilliseconds = 10; + config.dataCallback = &Impl::OnCapture; + config.pUserData = this; + + CaptureFill = 0; + if (ma_device_init(&Context, &config, &CaptureDevice) != MA_SUCCESS) + { + spdlog::error("Voice chat: input device \"{}\" could not be opened", acName); + return; + } + + HasCapture = true; + if (ma_device_start(&CaptureDevice) != MA_SUCCESS) + { + spdlog::error("Voice chat: input device \"{}\" could not be started", acName); + StopCapture(); + return; + } + + spdlog::info("Voice chat: capturing from \"{}\"", CaptureDevice.capture.name); + } + + void StopCapture() noexcept + { + if (HasCapture) + { + ma_device_uninit(&CaptureDevice); + HasCapture = false; + } + + MicLevel = 0.f; + } + + void StartPlayback(const String& acName) noexcept + { + StopPlayback(); + PlaybackName = acName; + + ma_device_id id{}; + bool useDefault = acName.empty() || acName == kDefaultDeviceName; + if (!useDefault && !FindDevice(ma_device_type_playback, acName, id)) + { + spdlog::warn("Voice chat: output device \"{}\" is not connected, using the Windows default", acName); + useDefault = true; + } + + ma_device_config config = ma_device_config_init(ma_device_type_playback); + config.playback.format = ma_format_f32; + config.playback.channels = 2; + config.playback.pDeviceID = useDefault ? nullptr : &id; + config.sampleRate = VoiceDsp::kSampleRate; + config.periodSizeInMilliseconds = 10; + config.dataCallback = &Impl::OnPlayback; + config.pUserData = this; + + if (ma_device_init(&Context, &config, &PlaybackDevice) != MA_SUCCESS) + { + spdlog::error("Voice chat: output device could not be opened"); + return; + } + + HasPlayback = true; + if (ma_device_start(&PlaybackDevice) != MA_SUCCESS) + { + spdlog::error("Voice chat: output device could not be started"); + StopPlayback(); + return; + } + + spdlog::info("Voice chat: playing on \"{}\"", PlaybackDevice.playback.name); + } + + void StopPlayback() noexcept + { + if (HasPlayback) + { + ma_device_uninit(&PlaybackDevice); + HasPlayback = false; + } + } + + // Capture thread + static void OnCapture(ma_device* apDevice, void*, const void* apInput, ma_uint32 aFrameCount) + { + auto* pSelf = static_cast(apDevice->pUserData); + const auto* pInput = static_cast(apInput); + if (!pSelf || !pInput) + return; + + for (ma_uint32 i = 0; i < aFrameCount; ++i) + { + pSelf->CaptureFrame[pSelf->CaptureFill++] = pInput[i]; + if (pSelf->CaptureFill == VoiceDsp::kFrameSamples) + { + pSelf->CaptureFill = 0; + pSelf->ProcessCapturedFrame(); + } + } + } + + // Capture thread + void ProcessCapturedFrame() noexcept + { + const float volume = MicVolume.load(std::memory_order_relaxed); + for (float& sample : CaptureFrame) + sample = std::clamp(sample * volume, -1.f, 1.f); + + const float peak = VoiceDsp::Peak(CaptureFrame.data(), CaptureFrame.size()); + MicLevel.store(peak, std::memory_order_relaxed); + + const bool open = Gate.Process(peak, NoiseGate.load(std::memory_order_relaxed)); + if (!open || !Transmitting.load(std::memory_order_relaxed) || !pEncoder) + return; + + uint8_t packet[VoiceFrame::kMaxSize]; + const opus_int32 size = opus_encode_float(pEncoder, CaptureFrame.data(), VoiceDsp::kFrameSamples, packet, sizeof(packet)); + if (size <= 0) + return; + + VoiceFrame frame{}; + frame.Sequence = NextSequence++; + frame.Data.assign(packet, packet + size); + + std::scoped_lock lock(OutgoingMutex); + if (Outgoing.size() >= kMaxQueuedFrames) + Outgoing.erase(Outgoing.begin()); + Outgoing.push_back(std::move(frame)); + } + + // Playback thread + static void OnPlayback(ma_device* apDevice, void* apOutput, const void*, ma_uint32 aFrameCount) + { + auto* pSelf = static_cast(apDevice->pUserData); + auto* pOutput = static_cast(apOutput); + if (!pOutput) + return; + + std::memset(pOutput, 0, sizeof(float) * 2 * aFrameCount); + if (!pSelf) + return; + + { + std::scoped_lock lock(pSelf->SpeakersMutex); + // Map values are only writable through the iterator + for (auto itor = pSelf->Speakers.begin(); itor != pSelf->Speakers.end(); ++itor) + { + auto& speaker = itor.value(); + speaker.Buffer.MixInto(pOutput, aFrameCount, speaker.LeftGain, speaker.RightGain); + } + } + + for (ma_uint32 i = 0; i < aFrameCount * 2; ++i) + pOutput[i] = std::clamp(pOutput[i], -1.f, 1.f); + } + + ma_context Context{}; + bool HasContext{false}; + + ma_device CaptureDevice{}; + bool HasCapture{false}; + String CaptureName{}; + ma_device PlaybackDevice{}; + bool HasPlayback{false}; + String PlaybackName{}; + bool Running{false}; + + // Capture thread state + OpusEncoder* pEncoder{nullptr}; + std::array CaptureFrame{}; + uint32_t CaptureFill{0}; + VoiceDsp::NoiseGate Gate{}; + uint16_t NextSequence{0}; + + std::atomic MicVolume{1.f}; + std::atomic NoiseGate{0.05f}; + std::atomic MicLevel{0.f}; + std::atomic Transmitting{false}; + + std::mutex OutgoingMutex; + Vector Outgoing; + + std::mutex SpeakersMutex; + Map Speakers; + + Map Decoders; +}; + +VoiceAudio::VoiceAudio() noexcept + : m_pImpl(TiltedPhoques::MakeUnique()) +{ +} + +VoiceAudio::~VoiceAudio() noexcept = default; + +bool VoiceAudio::ListDevices(DeviceLists& aOutLists) noexcept +{ + aOutLists.Inputs.clear(); + aOutLists.Outputs.clear(); + + if (!m_pImpl->EnsureContext()) + return false; + + ma_device_info* pPlayback = nullptr; + ma_uint32 playbackCount = 0; + ma_device_info* pCapture = nullptr; + ma_uint32 captureCount = 0; + if (ma_context_get_devices(&m_pImpl->Context, &pPlayback, &playbackCount, &pCapture, &captureCount) != MA_SUCCESS) + return false; + + for (ma_uint32 i = 0; i < captureCount; ++i) + aOutLists.Inputs.push_back(pCapture[i].name); + for (ma_uint32 i = 0; i < playbackCount; ++i) + aOutLists.Outputs.push_back(pPlayback[i].name); + + return true; +} + +void VoiceAudio::Start(const String& acInputDevice, const String& acOutputDevice) noexcept +{ + if (!m_pImpl->EnsureContext()) + return; + + if (!m_pImpl->Running || acInputDevice != m_pImpl->CaptureName) + m_pImpl->StartCapture(acInputDevice); + if (!m_pImpl->Running || acOutputDevice != m_pImpl->PlaybackName) + m_pImpl->StartPlayback(acOutputDevice); + + m_pImpl->Running = true; +} + +void VoiceAudio::Stop() noexcept +{ + m_pImpl->StopCapture(); + m_pImpl->StopPlayback(); + m_pImpl->Running = false; + + { + std::scoped_lock lock(m_pImpl->OutgoingMutex); + m_pImpl->Outgoing.clear(); + } + + RemoveAllSpeakers(); +} + +bool VoiceAudio::IsRunning() const noexcept +{ + return m_pImpl->Running; +} + +void VoiceAudio::SetCaptureSettings(float aMicVolume, float aNoiseGate) noexcept +{ + m_pImpl->MicVolume.store(std::clamp(aMicVolume, 0.f, 1.f), std::memory_order_relaxed); + m_pImpl->NoiseGate.store(std::clamp(aNoiseGate, 0.f, 1.f), std::memory_order_relaxed); +} + +void VoiceAudio::SetTransmitting(bool aTransmitting) noexcept +{ + m_pImpl->Transmitting.store(aTransmitting, std::memory_order_relaxed); +} + +float VoiceAudio::GetMicLevel() const noexcept +{ + return m_pImpl->HasCapture ? m_pImpl->MicLevel.load(std::memory_order_relaxed) : 0.f; +} + +bool VoiceAudio::PopEncodedFrame(VoiceFrame& aOutFrame) noexcept +{ + std::scoped_lock lock(m_pImpl->OutgoingMutex); + if (m_pImpl->Outgoing.empty()) + return false; + + aOutFrame = std::move(m_pImpl->Outgoing.front()); + m_pImpl->Outgoing.erase(m_pImpl->Outgoing.begin()); + return true; +} + +void VoiceAudio::PushRemoteFrame(uint32_t aPlayerId, const VoiceFrame& acFrame) noexcept +{ + if (!m_pImpl->Running || acFrame.Data.empty()) + return; + + auto& decoder = m_pImpl->Decoders[aPlayerId]; + if (!decoder.pDecoder) + { + int error = OPUS_OK; + decoder.pDecoder = opus_decoder_create(VoiceDsp::kSampleRate, 1, &error); + if (error != OPUS_OK || !decoder.pDecoder) + { + spdlog::error("Voice chat: Opus decoder failed to start ({})", error); + m_pImpl->Decoders.erase(aPlayerId); + return; + } + } + + // Late and duplicate frames are dropped, a few missing ones are concealed by Opus + int32_t concealed = 0; + if (decoder.HasSequence) + { + const int32_t delta = VoiceDsp::SequenceDelta(decoder.LastSequence, acFrame.Sequence); + if (delta <= 0) + return; + + concealed = std::min(delta - 1, kMaxConcealedFrames); + } + + decoder.LastSequence = acFrame.Sequence; + decoder.HasSequence = true; + decoder.LastFrameTime = std::chrono::steady_clock::now(); + + std::array pcm{}; + size_t samples = 0; + + for (int32_t i = 0; i < concealed; ++i) + { + const int decoded = opus_decode_float(decoder.pDecoder, nullptr, 0, pcm.data() + samples, VoiceDsp::kFrameSamples, 0); + if (decoded > 0) + samples += static_cast(decoded); + } + + const int decoded = opus_decode_float(decoder.pDecoder, acFrame.Data.data(), static_cast(acFrame.Data.size()), pcm.data() + samples, VoiceDsp::kFrameSamples, 0); + if (decoded > 0) + samples += static_cast(decoded); + + if (samples == 0) + return; + + std::scoped_lock lock(m_pImpl->SpeakersMutex); + m_pImpl->Speakers[aPlayerId].Buffer.Push(pcm.data(), samples); +} + +void VoiceAudio::SetSpeakerGains(uint32_t aPlayerId, float aLeft, float aRight) noexcept +{ + std::scoped_lock lock(m_pImpl->SpeakersMutex); + const auto itor = m_pImpl->Speakers.find(aPlayerId); + if (itor == m_pImpl->Speakers.end()) + return; + + itor.value().LeftGain = aLeft; + itor.value().RightGain = aRight; +} + +void VoiceAudio::RemoveSpeaker(uint32_t aPlayerId) noexcept +{ + { + std::scoped_lock lock(m_pImpl->SpeakersMutex); + m_pImpl->Speakers.erase(aPlayerId); + } + + const auto itor = m_pImpl->Decoders.find(aPlayerId); + if (itor != m_pImpl->Decoders.end()) + { + opus_decoder_destroy(itor->second.pDecoder); + m_pImpl->Decoders.erase(itor); + } +} + +void VoiceAudio::RemoveAllSpeakers() noexcept +{ + { + std::scoped_lock lock(m_pImpl->SpeakersMutex); + m_pImpl->Speakers.clear(); + } + + for (auto& [id, decoder] : m_pImpl->Decoders) + opus_decoder_destroy(decoder.pDecoder); + m_pImpl->Decoders.clear(); +} + +void VoiceAudio::GetActiveSpeakers(Vector& aOutPlayerIds) const noexcept +{ + aOutPlayerIds.clear(); + + const auto now = std::chrono::steady_clock::now(); + for (const auto& [id, decoder] : m_pImpl->Decoders) + { + if (now - decoder.LastFrameTime < kActiveSpeakerTime) + aOutPlayerIds.push_back(id); + } +} diff --git a/Code/client/Services/Generic/VoiceService.cpp b/Code/client/Services/Generic/VoiceService.cpp new file mode 100644 index 0000000000..d6ac748edc --- /dev/null +++ b/Code/client/Services/Generic/VoiceService.cpp @@ -0,0 +1,478 @@ +#include + +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +#include + +#include +#include + +namespace +{ +// Same file the dedicated server uses when it runs from the game folder, so bVoip is one switch for both +constexpr char kSettingsPath[] = "Data/SkyrimTogetherReborn/config/STServer.ini"; +constexpr char kSection[] = "Voice"; + +constexpr float kMaxFadeStart = 100000.f; +// How often talking icons and the mic meter are pushed to the UI +constexpr double kUiInterval = 0.066; +// Voices are panned this much at most, fully one sided sounds odd on headphones +constexpr float kMaxPan = 0.7f; + +// NiAVObject::world (NiTransform), checked in Ghidra on 1.7.104: NiAVObject::UpdateWorldData (0x140eebf40) writes +// world = parent world * local to 0x7C..0xAF, row major rotate at 0x7C, translate at 0xA0. The camera position getter +// (ID 50785) reads cameraRoot (TESCamera +0x20) world translate at 0xA0, and the engine's audio listener update +// (ID 40449) uses a camera node's world rotate Y column as forward. +constexpr size_t kNiAVObjectWorldRotateOffset = 0x7C; +constexpr size_t kNiAVObjectWorldTranslateOffset = 0xA0; +static_assert(kNiAVObjectWorldTranslateOffset + sizeof(float) * 3 <= sizeof(NiAVObject)); +static_assert(offsetof(TESCamera, cameraNode) == 0x20); + +struct Listener +{ + NiPoint3 Position{}; + // Horizontal, unit length, east/north + float RightX{1.f}; + float RightY{0.f}; +}; + +// Voices are heard from the camera, so free look, camera mods and ragdolls don't matter, unless the camera is far away +bool GetListener(Listener& aOutListener, float aMaxCameraDistance) noexcept +{ + PlayerCharacter* pPlayer = PlayerCharacter::Get(); + if (!pPlayer) + return false; + + aOutListener.Position = pPlayer->position; + // Skyrim heading is clockwise from north, so facing is (sin, cos) and right is (cos, -sin) + aOutListener.RightX = std::cos(pPlayer->rotation.z); + aOutListener.RightY = -std::sin(pPlayer->rotation.z); + + PlayerCamera* pCamera = PlayerCamera::Get(); + // PlayerCamera redeclares the TESCamera members at the wrong offsets, go through the base on purpose + const NiNode* pCameraRoot = pCamera ? static_cast(pCamera)->cameraNode : nullptr; + if (!pCameraRoot) + return true; + + const auto* pBytes = reinterpret_cast(pCameraRoot); + float rotate[3][3]; + float translate[3]; + std::memcpy(rotate, pBytes + kNiAVObjectWorldRotateOffset, sizeof(rotate)); + std::memcpy(translate, pBytes + kNiAVObjectWorldTranslateOffset, sizeof(translate)); + + if (!std::isfinite(translate[0]) || !std::isfinite(translate[1]) || !std::isfinite(translate[2])) + return true; + + // The world map, free camera and some cutscenes move the camera far from the character. Past the fade start, hear + // from the character instead, so nearby players don't go quiet while you look at the map. Using the player's own + // fade start ties both to one setting. Vanilla third person zoomed all the way out is about 750 units. + const float dx = translate[0] - pPlayer->position.x; + const float dy = translate[1] - pPlayer->position.y; + const float dz = translate[2] - pPlayer->position.z; + if (dx * dx + dy * dy + dz * dz > aMaxCameraDistance * aMaxCameraDistance) + return true; + + aOutListener.Position = glm::vec3(translate[0], translate[1], translate[2]); + + const float frontX = rotate[0][1]; + const float frontY = rotate[1][1]; + const float length = std::sqrt(frontX * frontX + frontY * frontY); + // Looking straight up or down, keep the character's facing + if (std::isfinite(length) && length > 0.01f) + { + aOutListener.RightX = frontY / length; + aOutListener.RightY = -frontX / length; + } + + return true; +} + +const char* ChannelToString(VoiceService::Channel aChannel) noexcept +{ + switch (aChannel) + { + case VoiceService::Channel::kParty: return "party"; + case VoiceService::Channel::kStrangers: return "strangers"; + default: return "everyone"; + } +} + +VoiceService::Channel ChannelFromString(const std::string_view acValue) noexcept +{ + if (acValue == "party") + return VoiceService::Channel::kParty; + if (acValue == "strangers") + return VoiceService::Channel::kStrangers; + return VoiceService::Channel::kEveryone; +} + +float Clamp01(double aValue, float aFallback) noexcept +{ + return std::isfinite(aValue) ? std::clamp(static_cast(aValue), 0.f, 1.f) : aFallback; +} + +std::filesystem::path GetSettingsPath() noexcept +{ + std::error_code error{}; + return std::filesystem::current_path(error) / kSettingsPath; +} +} // namespace + +VoiceService::VoiceService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept + : m_world(aWorld) + , m_transport(aTransport) +{ + LoadSettings(); + m_audio.SetCaptureSettings(m_settings.MicVolume, m_settings.NoiseGate); + + m_updateConnection = aDispatcher.sink().connect<&VoiceService::OnUpdate>(this); + m_connectedConnection = aDispatcher.sink().connect<&VoiceService::OnConnected>(this); + m_disconnectedConnection = aDispatcher.sink().connect<&VoiceService::OnDisconnected>(this); + m_voiceDataConnection = aDispatcher.sink().connect<&VoiceService::OnVoiceData>(this); + m_playerLeftConnection = aDispatcher.sink().connect<&VoiceService::OnPlayerLeft>(this); +} + +VoiceService::~VoiceService() noexcept +{ + m_audio.Stop(); +} + +void VoiceService::RefreshUi() noexcept +{ + // The ini may have been edited by hand or by the server since the menu was last open + LoadSettings(); + m_audio.SetCaptureSettings(m_settings.MicVolume, m_settings.NoiseGate); + + auto* pOverlay = m_world.GetOverlayService().GetOverlayApp(); + if (pOverlay) + { + VoiceAudio::DeviceLists devices{}; + m_audio.ListDevices(devices); + + auto pInputs = CefListValue::Create(); + for (size_t i = 0; i < devices.Inputs.size(); ++i) + pInputs->SetString(i, devices.Inputs[i].c_str()); + + auto pOutputs = CefListValue::Create(); + for (size_t i = 0; i < devices.Outputs.size(); ++i) + pOutputs->SetString(i, devices.Outputs[i].c_str()); + + auto pArguments = CefListValue::Create(); + pArguments->SetList(0, pInputs); + pArguments->SetList(1, pOutputs); + pOverlay->ExecuteAsync("voiceDevices", pArguments); + } + + SendSettingsToUi(); + UpdateAudioState(); +} + +void VoiceService::ApplySettings(const Settings& acSettings) noexcept +{ + m_settings = acSettings; + m_settings.FadeStart = std::isfinite(m_settings.FadeStart) ? std::clamp(m_settings.FadeStart, 0.f, kMaxFadeStart) : 2000.f; + m_settings.MicVolume = Clamp01(m_settings.MicVolume, 1.f); + m_settings.NoiseGate = Clamp01(m_settings.NoiseGate, 0.05f); + m_settings.MasterVolume = VoiceDsp::ClampMasterVolume(m_settings.MasterVolume); + + m_audio.SetCaptureSettings(m_settings.MicVolume, m_settings.NoiseGate); + SaveSettings(); + UpdateAudioState(); +} + +void VoiceService::SetPlayerVolume(uint32_t aPlayerId, float aVolume) noexcept +{ + m_playerVolumes[aPlayerId] = Clamp01(aVolume, 1.f); +} + +void VoiceService::SetMeterEnabled(bool aEnabled) noexcept +{ + m_meterEnabled = aEnabled; + UpdateAudioState(); +} + +void VoiceService::OnUpdate(const UpdateEvent& acEvent) noexcept +{ + UpdateAudioState(); + + if (m_audio.IsRunning()) + { + SendOutgoingFrames(); + UpdateSpeakerGains(); + } + + UpdateUi(acEvent.Delta); +} + +void VoiceService::OnConnected(const ConnectedEvent&) noexcept +{ + // A new server knows nothing about us yet + m_listeningSent = false; +} + +void VoiceService::OnDisconnected(const DisconnectedEvent&) noexcept +{ + m_listeningSent = false; + m_playerVolumes.clear(); + UpdateAudioState(); +} + +void VoiceService::OnVoiceData(const NotifyVoiceData& acMessage) noexcept +{ + if (!IsVoiceWanted()) + return; + + m_audio.PushRemoteFrame(acMessage.PlayerId, acMessage.Frame); +} + +void VoiceService::OnPlayerLeft(const NotifyPlayerLeft& acMessage) noexcept +{ + m_audio.RemoveSpeaker(acMessage.PlayerId); + m_playerVolumes.erase(acMessage.PlayerId); +} + +bool VoiceService::IsVoiceWanted() const noexcept +{ + return m_settings.Connect && m_transport.IsOnline() && m_world.GetServerSettings().VoipEnabled; +} + +void VoiceService::UpdateAudioState() noexcept +{ + const bool wanted = IsVoiceWanted(); + // The mic popup's meter works with voip off too, it only reads the microphone locally + const bool metering = m_meterEnabled && !m_settings.InputDevice.empty(); + + if (wanted || metering) + m_audio.Start(m_settings.InputDevice, m_settings.OutputDevice); + else if (m_audio.IsRunning()) + m_audio.Stop(); + + m_audio.SetTransmitting(wanted && !m_settings.InputDevice.empty()); + + if (!wanted && m_audio.IsRunning()) + m_audio.RemoveAllSpeakers(); + + SendListeningState(); +} + +void VoiceService::SendListeningState() noexcept +{ + if (!m_transport.IsOnline()) + return; + + const bool listening = IsVoiceWanted(); + if (m_listeningSent && m_sentListening == listening && m_sentChannel == m_settings.ListenChannel) + return; + + VoiceStateRequest request{}; + request.Listening = listening; + request.ListenChannel = static_cast(m_settings.ListenChannel); + if (!m_transport.Send(request)) + return; + + m_listeningSent = true; + m_sentListening = listening; + m_sentChannel = m_settings.ListenChannel; +} + +void VoiceService::SendOutgoingFrames() noexcept +{ + VoiceDataRequest request{}; + while (m_audio.PopEncodedFrame(request.Frame)) + { + if (!IsVoiceWanted()) + continue; + + // Late voice is useless, so no resends, and no Nagle delay either + m_transport.Send(request, TiltedPhoques::kUnreliableNoNagle); + } +} + +void VoiceService::UpdateSpeakerGains() noexcept +{ + Vector speakers{}; + m_audio.GetActiveSpeakers(speakers); + if (speakers.empty()) + return; + + Listener listener{}; + const bool hasListener = GetListener(listener, m_settings.FadeStart); + + const auto& partyMembers = m_world.GetPartyService().GetPartyMembers(); + auto view = m_world.view(); + + for (const uint32_t playerId : speakers) + { + float left = 0.f; + float right = 0.f; + + const bool inParty = std::find(partyMembers.begin(), partyMembers.end(), playerId) != partyMembers.end(); + const bool channelAllows = m_settings.ListenChannel == Channel::kEveryone || (m_settings.ListenChannel == Channel::kParty && inParty) || + (m_settings.ListenChannel == Channel::kStrangers && !inParty); + + const auto volumeItor = m_playerVolumes.find(playerId); + const float volume = volumeItor != m_playerVolumes.end() ? volumeItor->second : 1.f; + + // A player whose character isn't loaded here is too far away to be heard + const auto entityItor = std::find_if(view.begin(), view.end(), [&view, playerId](auto aEntity) { return view.get(aEntity).Id == playerId; }); + Actor* pActor = entityItor != view.end() ? Cast(TESForm::GetById(view.get(*entityItor).Id)) : nullptr; + + if (hasListener && channelAllows && volume > 0.f && pActor) + { + const float dx = pActor->position.x - listener.Position.x; + const float dy = pActor->position.y - listener.Position.y; + const float dz = pActor->position.z - listener.Position.z; + const float distance = std::sqrt(dx * dx + dy * dy + dz * dz); + + 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; + + VoiceDsp::PanGains(pan, left, right); + left *= gain; + right *= gain; + } + + m_audio.SetSpeakerGains(playerId, left, right); + } +} + +void VoiceService::UpdateUi(double aDelta) noexcept +{ + m_uiTimer += aDelta; + if (m_uiTimer < kUiInterval) + return; + m_uiTimer = 0.0; + + auto* pOverlay = m_world.GetOverlayService().GetOverlayApp(); + if (!pOverlay) + return; + + // Players whose voice is getting through their noise gate right now + Vector talking{}; + if (IsVoiceWanted()) + m_audio.GetActiveSpeakers(talking); + std::sort(talking.begin(), talking.end()); + + if (talking != m_lastTalking) + { + m_lastTalking = talking; + + auto pIds = CefListValue::Create(); + for (size_t i = 0; i < talking.size(); ++i) + pIds->SetInt(i, static_cast(talking[i])); + + auto pArguments = CefListValue::Create(); + pArguments->SetList(0, pIds); + pOverlay->ExecuteAsync("voiceTalking", pArguments); + } + + if (m_meterEnabled) + { + auto pArguments = CefListValue::Create(); + pArguments->SetInt(0, static_cast(std::lround(m_audio.GetMicLevel() * 100.f))); + pOverlay->ExecuteAsync("voiceMicLevel", pArguments); + } +} + +void VoiceService::LoadSettings() noexcept +{ + Settings settings{}; + + const auto path = GetSettingsPath(); + std::error_code error{}; + if (std::filesystem::exists(path, error) && !error) + { + CSimpleIni ini(true); + if (ini.LoadFile(path.c_str()) == SI_Error::SI_OK) + { + settings.InputDevice = ini.GetValue(kSection, "sInputDevice", ""); + settings.Connect = ini.GetBoolValue(kSection, "bVoip", false); + settings.OutputDevice = ini.GetValue(kSection, "sOutputDevice", ""); + settings.ListenChannel = ChannelFromString(ini.GetValue(kSection, "sVoipChannel", "everyone")); + settings.FadeStart = static_cast(ini.GetDoubleValue(kSection, "fVoipFadeStart", 2000.0)); + settings.MicVolume = static_cast(ini.GetDoubleValue(kSection, "fMicVolume", 1.0)); + settings.NoiseGate = static_cast(ini.GetDoubleValue(kSection, "fNoiseGate", 0.05)); + settings.MasterVolume = static_cast(ini.GetDoubleValue(kSection, "fVoipMasterVolume", 1.0)); + } + else + { + spdlog::warn("Voice chat: {} could not be read, using the defaults", kSettingsPath); + } + } + + settings.FadeStart = std::isfinite(settings.FadeStart) ? std::clamp(settings.FadeStart, 0.f, kMaxFadeStart) : 2000.f; + settings.MicVolume = Clamp01(settings.MicVolume, 1.f); + settings.NoiseGate = Clamp01(settings.NoiseGate, 0.05f); + settings.MasterVolume = VoiceDsp::ClampMasterVolume(settings.MasterVolume); + + m_settings = settings; +} + +void VoiceService::SaveSettings() const noexcept +{ + const auto path = GetSettingsPath(); + + std::error_code error{}; + std::filesystem::create_directories(path.parent_path(), error); + + // Load first so the server's own settings in the file are kept + CSimpleIni ini(true); + if (std::filesystem::exists(path, error) && !error && ini.LoadFile(path.c_str()) != SI_Error::SI_OK) + { + spdlog::error("Voice chat: {} could not be read, voice settings are not saved", kSettingsPath); + return; + } + + ini.SetValue(kSection, "sInputDevice", m_settings.InputDevice.c_str(), "; Player: microphone for voice chat. Empty sends nothing, default follows the Windows default device"); + ini.SetBoolValue(kSection, "bVoip", m_settings.Connect, + "; 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, 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"); + + if (ini.SaveFile(path.c_str(), false) != SI_Error::SI_OK) + spdlog::error("Voice chat: could not write {}", kSettingsPath); +} + +void VoiceService::SendSettingsToUi() const noexcept +{ + auto* pOverlay = m_world.GetOverlayService().GetOverlayApp(); + if (!pOverlay) + return; + + auto pArguments = CefListValue::Create(); + pArguments->SetString(0, m_settings.InputDevice.c_str()); + pArguments->SetInt(1, m_settings.Connect ? 1 : 0); + pArguments->SetString(2, m_settings.OutputDevice.c_str()); + pArguments->SetString(3, ChannelToString(m_settings.ListenChannel)); + 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/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/InventoryService.h b/Code/client/Services/InventoryService.h index cff53bb5f5..dae827c656 100644 --- a/Code/client/Services/InventoryService.h +++ b/Code/client/Services/InventoryService.h @@ -9,6 +9,9 @@ struct NotifyInventoryChanges; struct InventoryChangeEvent; struct EquipmentChangeEvent; struct NotifyEquipmentChanges; +struct NotifyInventorySnapshot; +struct Inventory; +struct Actor; /** * @brief Manages inventories of actors and containers. @@ -49,6 +52,19 @@ struct InventoryService * @brief Applies equipment changes sent by the server. */ void OnNotifyEquipmentChanges(const NotifyEquipmentChanges& acMessage) noexcept; + /** + * @brief Applies an owner's full inventory to a remote actor. + */ + void OnNotifyInventorySnapshot(const NotifyInventorySnapshot& acMessage) noexcept; + + /** + * @brief Applies a full inventory from the server to a remote actor, or to its pending spawn if its 3D isn't loaded yet. + */ + static void ApplyRemoteInventory(World& aWorld, entt::entity aEntity, const Inventory& acInventory) noexcept; + /** + * @brief Applies an equipment change relayed from an actor's owner. + */ + static void ApplyEquipmentChange(Actor* apActor, const NotifyEquipmentChanges& acMessage) noexcept; private: /** @@ -66,4 +82,5 @@ struct InventoryService entt::scoped_connection m_equipmentConnection; entt::scoped_connection m_inventoryChangeConnection; entt::scoped_connection m_equipmentChangeConnection; + entt::scoped_connection m_inventorySnapshotConnection; }; diff --git a/Code/client/Services/NameTagService.h b/Code/client/Services/NameTagService.h new file mode 100644 index 0000000000..9d43e48ae0 --- /dev/null +++ b/Code/client/Services/NameTagService.h @@ -0,0 +1,144 @@ +#pragma once + +#include +#include + +#include + +#include +#include + +struct World; +struct NotifyChatMessageBroadcast; +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). A player's floating chat line replaces their tag + * while it shows. + */ +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; + // Party: party members' chat and dialogue choices float above them. On: strangers' chat too. + Filter FloatingChat = Filter::kParty; + float FontSize = 16.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 + // stranger name setting, party membership and the per-name eye. + Set RevealedPlayerIds; + // Players whose name may be shown in front of their chat (known chat names setting and the eye). + Set RevealedChatPlayerIds; + }; + + 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; + // Floating chat: shown for kLineBaseMs + kLinePerCharMs per character, at most kLineMaxMs, + // wrapped at kLineWrapPx (at the 22 px base size) to kLineMaxLines lines. + static constexpr uint64_t kLineBaseMs = 2000; + static constexpr uint64_t kLinePerCharMs = 60; + static constexpr uint64_t kLineMaxMs = 8000; + static constexpr float kLineWrapPx = 300.f; + static constexpr int kLineMaxLines = 2; + static constexpr float kVisibilityEpsilon = 1e-3f; + +private: + struct VisibilityInfo + { + bool Visible = true; + uint64_t Tick = 0; + }; + + struct FloatingLine + { + // Empty when the name is hidden; the stranger label is drawn instead. + std::string Name; + std::string Text; + ChatMessageType Type = kGlobalChat; + uint64_t ExpiresAt = 0; + + // Wrapped layout, rebuilt when LayoutSize or LayoutPrefix ("Name:") changes. + struct LayoutLine + { + std::string Text; + // Leading bytes of Text that belong to "Name:", drawn in the name colour. + size_t NameBytes = 0; + float NameWidth = 0.f; + float Width = 0.f; + }; + float LayoutSize = 0.f; + std::string LayoutPrefix; + LayoutLine Lines[kLineMaxLines]; + int LineCount = 0; + float LineHeight = 0.f; + float Width = 0.f; + }; + + void OnDraw() noexcept; + void OnUpdate(const UpdateEvent& acEvent) noexcept; + void OnChatMessage(const NotifyChatMessageBroadcast& acMessage) noexcept; + + void DrawFloatingLine(ImDrawList* apDrawList, ImFont* apFont, FloatingLine& aLine, const char* acpStrangerLabel, ImVec2 aBottomCenter, float aFontSize, float aAlpha, bool aBackdrop) const noexcept; + static void LayoutFloatingLine(FloatingLine& aLine, ImFont* apFont, const std::string& acPrefix, float aFontSize) 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; + // Latest floating chat line per player id. Like m_visibility, it's written from dispatcher events and + // read in OnDraw without a lock, which assumes the ImGui draw runs on the main thread (not verified). + std::unordered_map m_floatingLines; + + entt::scoped_connection m_drawConnection; + entt::scoped_connection m_updateConnection; + entt::scoped_connection m_chatConnection; +}; diff --git a/Code/client/Services/ObjectService.h b/Code/client/Services/ObjectService.h index 9adf8f39bb..488d1070b7 100644 --- a/Code/client/Services/ObjectService.h +++ b/Code/client/Services/ObjectService.h @@ -13,8 +13,13 @@ struct LockChangeEvent; struct NotifyLockChange; struct CellChangeEvent; struct ScriptAnimationEvent; +struct WaveCommandEvent; struct AssignObjectsResponse; struct NotifyScriptAnimation; +struct NotifyRemoveObjects; +struct UpdateEvent; +struct NotifyEquipmentChanges; +struct CharacterSpawnRequest; /** * @brief Handles objects in the environment. @@ -28,12 +33,18 @@ class ObjectService final : public BSTEventSink void OnDisconnected(const DisconnectedEvent&) noexcept; void OnCellChange(const CellChangeEvent&) noexcept; void OnAssignObjectsResponse(const AssignObjectsResponse&) noexcept; + void OnRemoveObjects(const NotifyRemoveObjects&) noexcept; void OnActivate(const ActivateEvent&) noexcept; void OnActivateNotify(const NotifyActivate&) noexcept; void OnLockChange(const LockChangeEvent&) noexcept; void OnLockChangeNotify(const NotifyLockChange&) noexcept; void OnScriptAnimationEvent(const ScriptAnimationEvent&) noexcept; + void OnWaveCommand(const WaveCommandEvent&) noexcept; void OnNotifyScriptAnimation(const NotifyScriptAnimation&) noexcept; + void OnUpdate(const UpdateEvent&) noexcept; + void OnNotifyEquipmentChanges(const NotifyEquipmentChanges&) noexcept; + void OnCharacterSpawnRequest(const CharacterSpawnRequest&) noexcept; + void RetrySubGraphEvent(uint32_t aServerId, const char* acpReason) noexcept; BSTEventResult OnEvent(const TESActivateEvent*, const EventDispatcher*) override; @@ -49,6 +60,22 @@ class ObjectService final : public BSTEventSink entt::scoped_connection m_lockChangeConnection; entt::scoped_connection m_lockChangeNotifyConnection; entt::scoped_connection m_assignObjectConnection; + entt::scoped_connection m_removeObjectsConnection; entt::scoped_connection m_scriptAnimationConnection; entt::scoped_connection m_scriptAnimationNotifyConnection; + entt::scoped_connection m_waveCommandConnection; + entt::scoped_connection m_updateConnection; + entt::scoped_connection m_equipmentChangesConnection; + entt::scoped_connection m_characterSpawnConnection; + + // The last subgraph event of each remote actor, by server id. A skin armor that isn't equipped or loaded yet + // when it arrives can't take it, so it's tried again until the armor takes it. It's sent once per body: sending it + // again would restart what it plays, such as a skin fading in. + struct SubGraphReplay + { + String EventName; + double Delay = -1.0; // Until the next try, negative when the event was taken or given up on. + double TimeLeft = 0.0; // Before giving up. + }; + Map m_subGraphReplays; }; diff --git a/Code/client/Services/OverlayClient.h b/Code/client/Services/OverlayClient.h index 0c71ac87da..7a0dc987ba 100644 --- a/Code/client/Services/OverlayClient.h +++ b/Code/client/Services/OverlayClient.h @@ -27,9 +27,12 @@ struct OverlayClient : TiltedPhoques::OverlayClient void ProcessRevealPlayersMessage(); void ProcessChatMessage(CefRefPtr aEventArgs); void ProcessSetTimeCommand(CefRefPtr aEventArgs); + void ProcessWaveCommand(); void ProcessTeleportMessage(CefRefPtr aEventArgs); void ProcessToggleDebugUI(); void ProcessOpenPlayGuide(); + void ProcessSetNameTagSettings(CefRefPtr aEventArgs); + void ProcessSetVoiceSettings(CefRefPtr aEventArgs); void SetUIVisible(bool aVisible) noexcept; TransportService& m_transport; 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); 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/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/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/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/client/Services/TradeService.h b/Code/client/Services/TradeService.h new file mode 100644 index 0000000000..74dd9b9fd4 --- /dev/null +++ b/Code/client/Services/TradeService.h @@ -0,0 +1,89 @@ +#pragma once + +#include +#include + +#include + +struct World; +struct TransportService; + +struct UpdateEvent; +struct DisconnectedEvent; +struct NotifyTradeInvite; +struct NotifyTradeStarted; +struct NotifyTradeState; +struct NotifyTradeCancel; +struct NotifyTradeComplete; +struct AlchemyItem; + +/** + * @brief Handles client-side trade state and messaging to the UI overlay. + */ +struct TradeService +{ + TradeService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept; + ~TradeService() noexcept; + + TP_NOCOPYMOVE(TradeService); + + void SendInvite(uint32_t aTargetPlayerId) const noexcept; + void RespondToInvite(uint32_t aRequesterPlayerId, bool aAccept) const noexcept; + void CancelTrade() const noexcept; + void SetReady(bool aReady) noexcept; + struct OfferSelection + { + uint32_t Index{0}; + int32_t Count{0}; + }; + + void UpdateOffer(const TiltedPhoques::Vector& aSelections) noexcept; + +private: + void OnUpdate(const UpdateEvent& acEvent) noexcept; + void OnDisconnected(const DisconnectedEvent&) noexcept; + void OnTradeInvite(const NotifyTradeInvite& acMessage) noexcept; + void OnTradeStarted(const NotifyTradeStarted& acMessage) noexcept; + void OnTradeState(const NotifyTradeState& acMessage) noexcept; + void OnTradeCancel(const NotifyTradeCancel& acMessage) noexcept; + void OnTradeComplete(const NotifyTradeComplete& acMessage) noexcept; + + void ClearSession() noexcept; + void ReleasePreparedPotions() noexcept; + void SyncInventory() const noexcept; + void EmitStateToUI() const noexcept; + void EmitInviteUpdate(uint32_t aInviterId, bool aAdded, uint64_t aExpiryTick = 0) const noexcept; + void EmitCancellation(uint32_t aPartnerId, TradeCancelReason aReason, bool aWasInitiator) const noexcept; + bool HasActiveSessionWith(uint32_t aPartnerId) const noexcept; + void ApplyOfferSelection(const TiltedPhoques::Vector& aSelections); + + World& m_world; + TransportService& m_transport; + + struct TradeSession + { + bool Active{false}; + uint32_t PartnerId{0}; + bool InitiatedBySelf{false}; + bool SelfReady{false}; + bool PartnerReady{false}; + TiltedPhoques::Vector SelfItems; + TiltedPhoques::Vector PartnerItems; + TiltedPhoques::Vector SelfInventory; + TiltedPhoques::Vector SelfAcceptedItems; + uint32_t CountdownMs{0}; + uint32_t CountdownTotalMs{0}; + }; + + TradeSession m_session; + TiltedPhoques::Vector m_preparedPotions; + TiltedPhoques::Map m_pendingInvites; + + entt::scoped_connection m_updateConnection; + entt::scoped_connection m_disconnectConnection; + entt::scoped_connection m_tradeInviteConnection; + entt::scoped_connection m_tradeStartedConnection; + entt::scoped_connection m_tradeStateConnection; + entt::scoped_connection m_tradeCancelConnection; + entt::scoped_connection m_tradeCompleteConnection; +}; diff --git a/Code/client/Services/VoiceAudio.h b/Code/client/Services/VoiceAudio.h new file mode 100644 index 0000000000..5cd46f4f7e --- /dev/null +++ b/Code/client/Services/VoiceAudio.h @@ -0,0 +1,55 @@ +#pragma once + +#include + +/** + * @brief The audio side of voice chat: microphone capture with the noise gate and Opus encoding, and decoding and + * mixing of remote voices into an output device. Uses miniaudio for the devices and libopus for the codec. + * + * Capture and playback run on miniaudio's device threads. Everything else is called from the game's main thread, + * the two sides only share small locked queues and atomics. + */ +struct VoiceAudio +{ + struct DeviceLists + { + Vector Inputs; + Vector Outputs; + }; + + VoiceAudio() noexcept; + ~VoiceAudio() noexcept; + + TP_NOCOPYMOVE(VoiceAudio); + + // Device names as Windows shows them, for the Settings menu + bool ListDevices(DeviceLists& aOutLists) noexcept; + + // An empty input name captures nothing, "default" follows the Windows default device. An empty output name + // plays on the Windows default device, which is the one the game uses too. Restarts devices that changed. + void Start(const String& acInputDevice, const String& acOutputDevice) noexcept; + void Stop() noexcept; + [[nodiscard]] bool IsRunning() const noexcept; + + // Volume 0 to 1 applied before the gate and the meter, gate threshold 0 to 1 of peak level + void SetCaptureSettings(float aMicVolume, float aNoiseGate) noexcept; + // While false the microphone is still read for the meter, but nothing is encoded or sent + void SetTransmitting(bool aTransmitting) noexcept; + // Peak level of the last microphone frame after volume, 0 to 1, for the Settings meter + [[nodiscard]] float GetMicLevel() const noexcept; + // Encoded frames waiting to be sent, oldest first + bool PopEncodedFrame(VoiceFrame& aOutFrame) noexcept; + + // Decodes a remote player's frame into their jitter buffer + void PushRemoteFrame(uint32_t aPlayerId, const VoiceFrame& acFrame) noexcept; + // Stereo gains for a remote player, 0 mutes them without dropping their audio + void SetSpeakerGains(uint32_t aPlayerId, float aLeft, float aRight) noexcept; + void RemoveSpeaker(uint32_t aPlayerId) noexcept; + void RemoveAllSpeakers() noexcept; + // Players whose voice arrived in the last few hundred ms, whether or not they can be heard + void GetActiveSpeakers(Vector& aOutPlayerIds) const noexcept; + +private: + struct Impl; + UniquePtr m_pImpl; +}; diff --git a/Code/client/Services/VoiceService.h b/Code/client/Services/VoiceService.h new file mode 100644 index 0000000000..8099034407 --- /dev/null +++ b/Code/client/Services/VoiceService.h @@ -0,0 +1,101 @@ +#pragma once + +#include + +struct World; +struct TransportService; +struct UpdateEvent; +struct ConnectedEvent; +struct DisconnectedEvent; +struct NotifyVoiceData; +struct NotifyPlayerLeft; + +/** + * @brief Built-in proximity voice chat. + * + * The microphone is captured, gated and Opus encoded by VoiceAudio, sent to the server over the game connection + * and relayed to players nearby. Remote voices get louder or quieter with distance from the camera: full volume up to + * the fade start, then -6 dB per doubling of distance like real sound, silent at four times the fade start. + * Nothing else is needed, no separate program and no extra port. + * + * Voice runs while the server's Voice:bVoip is on and the player ticked Connect to voip, which is the same key in + * the player's own Data/SkyrimTogetherReborn/config/STServer.ini. The other [Voice] keys there are the player's + * Settings menu choices. Per player volumes live in the UI, which pushes them here by player id. + */ +struct VoiceService +{ + enum class Channel : uint8_t + { + kEveryone = 0, + kParty, + kStrangers + }; + + struct Settings + { + // Empty captures nothing, "default" follows the Windows default microphone + String InputDevice{}; + bool Connect{false}; + // Empty plays on the Windows default device, the one the game uses + String OutputDevice{}; + Channel ListenChannel{Channel::kEveryone}; + float FadeStart{2000.f}; + float MicVolume{1.f}; + float NoiseGate{0.05f}; + float MasterVolume{1.f}; + }; + + VoiceService(World& aWorld, entt::dispatcher& aDispatcher, TransportService& aTransport) noexcept; + ~VoiceService() noexcept; + + TP_NOCOPYMOVE(VoiceService); + + // Called from the UI, on the main thread + void RefreshUi() noexcept; + void ApplySettings(const Settings& acSettings) noexcept; + void SetPlayerVolume(uint32_t aPlayerId, float aVolume) noexcept; + void SetMeterEnabled(bool aEnabled) noexcept; + + [[nodiscard]] const Settings& GetSettings() const noexcept { return m_settings; } + +protected: + void OnUpdate(const UpdateEvent& acEvent) noexcept; + void OnConnected(const ConnectedEvent& acEvent) noexcept; + void OnDisconnected(const DisconnectedEvent& acEvent) noexcept; + void OnVoiceData(const NotifyVoiceData& acMessage) noexcept; + void OnPlayerLeft(const NotifyPlayerLeft& acMessage) noexcept; + +private: + [[nodiscard]] bool IsVoiceWanted() const noexcept; + void UpdateAudioState() noexcept; + void SendListeningState() noexcept; + void SendOutgoingFrames() noexcept; + void UpdateSpeakerGains() noexcept; + void UpdateUi(double aDelta) noexcept; + + void LoadSettings() noexcept; + void SaveSettings() const noexcept; + void SendSettingsToUi() const noexcept; + + World& m_world; + TransportService& m_transport; + VoiceAudio m_audio; + + Settings m_settings{}; + Map m_playerVolumes; + + // What the server was last told, so it's only sent on change + bool m_listeningSent{false}; + bool m_sentListening{false}; + Channel m_sentChannel{Channel::kEveryone}; + + bool m_meterEnabled{false}; + double m_uiTimer{0.0}; + Vector m_lastTalking; + + entt::scoped_connection m_updateConnection; + entt::scoped_connection m_connectedConnection; + entt::scoped_connection m_disconnectedConnection; + entt::scoped_connection m_voiceDataConnection; + entt::scoped_connection m_playerLeftConnection; +}; diff --git a/Code/client/Systems/DamageDiagnostics.cpp b/Code/client/Systems/DamageDiagnostics.cpp new file mode 100644 index 0000000000..862d5a003f --- /dev/null +++ b/Code/client/Systems/DamageDiagnostics.cpp @@ -0,0 +1,330 @@ +#include + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace DamageDiagnostics +{ +namespace +{ +using Clock = std::chrono::steady_clock; + +// How long a hit is kept to match it with later events. +constexpr auto kHistory = std::chrono::milliseconds(3000); +// Two applications of the same hit closer than this are counted as one hit applied twice. +constexpr auto kSameHitWindow = std::chrono::milliseconds(150); +// A delta from another client this soon after a hit on the same actor is matched with that hit. +constexpr auto kMatchWindow = std::chrono::milliseconds(2000); +// Snapshots for the same player closer than this are not logged unless they drift. +constexpr auto kSnapshotLogInterval = std::chrono::milliseconds(5000); + +struct HitRecord +{ + uint64_t Id = 0; + Clock::time_point Time{}; + uint32_t HitterId = 0; + uint32_t HitteeId = 0; + float RawDamage = 0.f; + float RealDamage = 0.f; + bool Applied = false; + bool Sent = false; + bool InvolvesPlayer = false; +}; + +struct BroadcastRecord +{ + Clock::time_point Time{}; + uint32_t ServerId = 0; + float Delta = 0.f; +}; + +std::mutex s_lock; +uint64_t s_nextHitId = 1; +std::deque s_hits; +std::deque s_broadcasts; +std::unordered_map s_lastSnapshotLog; + +long long Ms(Clock::duration aDuration) noexcept +{ + return std::chrono::duration_cast(aDuration).count(); +} + +void Prune(Clock::time_point aNow) noexcept +{ + while (!s_hits.empty() && aNow - s_hits.front().Time > kHistory) + s_hits.pop_front(); + while (!s_broadcasts.empty() && aNow - s_broadcasts.front().Time > kHistory) + s_broadcasts.pop_front(); +} + +const char* Role(Actor* apActor) noexcept +{ + if (!apActor) + return "none"; + ActorExtension* pExtension = apActor->GetExtension(); + if (!pExtension) + return "no-ext"; + if (pExtension->IsLocalPlayer()) + return "local player"; + if (pExtension->IsRemotePlayer()) + return "remote player"; + if (pExtension->IsLocal()) + return "local npc"; + if (pExtension->IsRemote()) + return "remote npc"; + return "unsynced"; +} + +bool IsPlayer(Actor* apActor) noexcept +{ + return apActor && apActor->GetExtension() && apActor->GetExtension()->IsPlayer(); +} + +std::string Describe(Actor* apActor) +{ + if (!apActor) + return "none"; + const char* cpName = apActor->baseForm ? apActor->baseForm->GetName() : nullptr; + return fmt::format("{}({:X}, {})", cpName && *cpName ? cpName : "?", apActor->formID, Role(apActor)); +} + +std::string Context() +{ + const auto* pPlayer = PlayerCharacter::Get(); + const auto& party = World::Get().GetPartyService(); + const char* cpParty = !party.IsInParty() ? "none" : (party.IsLeader() ? "leader" : "member"); + return fmt::format("diff={} party={}", pPlayer ? pPlayer->difficulty : -1, cpParty); +} + +HitRecord* FindHit(uint64_t aId) noexcept +{ + for (auto& hit : s_hits) + if (hit.Id == aId) + return &hit; + return nullptr; +} + +bool Close(float aA, float aB, float aTolerance) noexcept +{ + const float cScale = std::max(std::abs(aA), std::abs(aB)); + return std::abs(aA - aB) <= std::max(1.f, cScale * aTolerance); +} +} // namespace + +uint64_t OnDamageHook(Actor* apHittee, Actor* apHitter, float aRawDamage, float aRealDamage, bool aKillMove, const char* acpPath, bool aApplied) noexcept +{ + if (!apHittee) + return 0; + + std::scoped_lock lock(s_lock); + const auto cNow = Clock::now(); + Prune(cNow); + + HitRecord record; + record.Id = s_nextHitId++; + record.Time = cNow; + record.HitterId = apHitter ? apHitter->formID : 0; + record.HitteeId = apHittee->formID; + record.RawDamage = aRawDamage; + record.RealDamage = aRealDamage; + record.Applied = aApplied; + + // The same hit applied twice on this client within a few frames. + const HitRecord* pTwin = nullptr; + if (aApplied) + { + for (auto it = s_hits.rbegin(); it != s_hits.rend(); ++it) + { + if (cNow - it->Time > kSameHitWindow) + break; + if (it->Applied && it->HitteeId == record.HitteeId && it->HitterId == record.HitterId && Close(it->RawDamage, aRawDamage, 0.05f)) + { + pTwin = &*it; + break; + } + } + } + + const bool cInvolvesPlayer = IsPlayer(apHittee) || IsPlayer(apHitter); + record.InvolvesPlayer = cInvolvesPlayer; + if (cInvolvesPlayer || pTwin) + { + const float cMultiplier = aRawDamage != 0.f ? aRealDamage / aRawDamage : 0.f; + spdlog::info( + "[DmgDiag] #{} HIT {} path={} hitter={} hittee={} raw={:.1f} real={:.1f} mult={:.2f} killMove={} hp={:.1f}/{:.1f} armor(DR)={:.0f} {}", record.Id, + aApplied ? "applied" : "left-to-owner", acpPath, Describe(apHitter), Describe(apHittee), aRawDamage, aRealDamage, cMultiplier, aKillMove, + apHittee->GetActorValue(ActorValueInfo::kHealth), apHittee->GetActorPermanentValue(ActorValueInfo::kHealth), apHittee->GetActorValue(ActorValueInfo::kDamageResist), + Context()); + } + + if (pTwin) + spdlog::warn("[DmgDiag] #{} FLAG DUP_LOCAL_HIT: same hitter, hittee and damage as #{} applied {} ms earlier", record.Id, pTwin->Id, Ms(cNow - pTwin->Time)); + + s_hits.push_back(record); + return record.Id; +} + +void OnDamageApplied(uint64_t aHitId, Actor* apHittee, float aHealthBefore) noexcept +{ + if (!apHittee || aHitId == 0) + return; + + std::scoped_lock lock(s_lock); + const HitRecord* pHit = FindHit(aHitId); + if (!pHit) + return; + + const float cHealthAfter = apHittee->GetActorValue(ActorValueInfo::kHealth); + const float cDrop = aHealthBefore - cHealthAfter; + const bool cInvolvesPlayer = pHit->InvolvesPlayer; + + // What this client lost and what it tells the others should match, otherwise every other client sees a different number. + const bool cMismatch = pHit->RealDamage >= 1.f && cHealthAfter > 0.f && !Close(cDrop, pHit->RealDamage, 0.25f); + + if (cInvolvesPlayer || cMismatch) + spdlog::info("[DmgDiag] #{} APPLIED hp {:.1f} -> {:.1f} (game took {:.1f}, delta sent to others {:.1f})", aHitId, aHealthBefore, cHealthAfter, cDrop, -pHit->RealDamage); + + if (cMismatch) + spdlog::warn( + "[DmgDiag] #{} FLAG SENT_VS_APPLIED: game took {:.1f} but {:.1f} is sent to the other clients (ratio {:.2f})", aHitId, cDrop, pHit->RealDamage, + cDrop != 0.f ? pHit->RealDamage / cDrop : 0.f); +} + +void OnHealthChangeSent(uint32_t aServerId, uint32_t aFormId, float aDelta, bool aBatched) noexcept +{ + std::scoped_lock lock(s_lock); + const auto cNow = Clock::now(); + + // Match the newest applied hit on this actor that has not been sent yet. + HitRecord* pHit = nullptr; + for (auto it = s_hits.rbegin(); it != s_hits.rend(); ++it) + { + if (it->Applied && !it->Sent && it->HitteeId == aFormId && Close(-it->RealDamage, aDelta, 0.01f)) + { + pHit = &*it; + break; + } + } + if (pHit) + pHit->Sent = true; + + Actor* pActor = Cast(TESForm::GetById(aFormId)); + if (!IsPlayer(pActor) && !(pHit && pHit->InvolvesPlayer)) + return; + + if (pHit) + spdlog::info( + "[DmgDiag] #{} SENT delta {:.1f} for {} server {:X}{} ({} ms after the hit)", pHit->Id, aDelta, Describe(pActor), aServerId, aBatched ? " batched" : "", + Ms(cNow - pHit->Time)); + else if (IsPlayer(pActor)) + spdlog::info("[DmgDiag] SENT delta {:.1f} for {} server {:X}{} (not from a hit: heal, regen or batch)", aDelta, Describe(pActor), aServerId, aBatched ? " batched" : ""); +} + +void OnHealthBroadcastReceived(uint32_t aServerId, Actor* apActor, float aDelta, float aHealthBefore, float aHealthAfter) noexcept +{ + if (!apActor) + return; + + std::scoped_lock lock(s_lock); + const auto cNow = Clock::now(); + Prune(cNow); + + ActorExtension* pExtension = apActor->GetExtension(); + const bool cIsLocal = pExtension && pExtension->IsLocal(); + const bool cIsPlayer = IsPlayer(apActor); + + // The same delta for the same actor twice in a row. + const BroadcastRecord* pTwin = nullptr; + for (auto it = s_broadcasts.rbegin(); it != s_broadcasts.rend(); ++it) + { + if (cNow - it->Time > kSameHitWindow) + break; + if (it->ServerId == aServerId && Close(it->Delta, aDelta, 0.01f)) + { + pTwin = &*it; + break; + } + } + s_broadcasts.push_back({cNow, aServerId, aDelta}); + + // The newest hit this client saw on the same actor. + const HitRecord* pHit = nullptr; + for (auto it = s_hits.rbegin(); it != s_hits.rend(); ++it) + { + if (cNow - it->Time > kMatchWindow) + break; + if (it->HitteeId == apActor->formID) + { + pHit = &*it; + break; + } + } + + if (cIsPlayer || cIsLocal || pTwin) + { + spdlog::info( + "[DmgDiag] RECV delta {:.1f} for {} server {:X}, hp {:.1f} -> {:.1f}{} {}", aDelta, Describe(apActor), aServerId, aHealthBefore, aHealthAfter, + pHit ? fmt::format( + ", last hit seen here #{} {} ms ago (real {:.1f}, {})", pHit->Id, Ms(cNow - pHit->Time), pHit->RealDamage, pHit->Applied ? "applied here" : "left to owner") + : std::string(), + Context()); + } + + if (pTwin) + spdlog::warn("[DmgDiag] FLAG DUP_BROADCAST: delta {:.1f} for server {:X} arrived twice within {} ms", aDelta, aServerId, Ms(cNow - pTwin->Time)); + + if (cIsLocal && aDelta < 0.f) + { + // This client owns the actor and applies its hits itself, so another client's damage lands on top of its own. + if (pHit && pHit->Applied && Close(pHit->RealDamage, -aDelta, 0.15f)) + spdlog::warn( + "[DmgDiag] FLAG LIKELY_DOUBLE_DAMAGE: {} is owned here, hit #{} was already applied here for {:.1f} and another client sent {:.1f}", Describe(apActor), pHit->Id, + pHit->RealDamage, aDelta); + else + spdlog::warn("[DmgDiag] FLAG DAMAGE_ON_OWNED_ACTOR: {} is owned here but another client's damage {:.1f} was applied to it", Describe(apActor), aDelta); + } + else if (pExtension && pExtension->IsRemotePlayer() && aDelta < 0.f && pHit && !pHit->Applied && pHit->RealDamage >= 1.f) + { + // This client estimated the hit and left it to the player's own client, compare what that client reported. + const float cRatio = -aDelta / pHit->RealDamage; + if (cRatio < 0.5f || cRatio > 2.f) + spdlog::warn( + "[DmgDiag] FLAG REMOTE_DAMAGE_DIFFERS: {} reported {:.1f} for hit #{}, this client estimated {:.1f} (ratio {:.2f}, armor differs per client)", Describe(apActor), + -aDelta, pHit->Id, pHit->RealDamage, cRatio); + } +} + +void OnHealthSnapshot(uint32_t aServerId, Actor* apActor, float aReportedHealth) noexcept +{ + if (!apActor || !IsPlayer(apActor)) + return; + + std::scoped_lock lock(s_lock); + const auto cNow = Clock::now(); + + const float cLocal = apActor->GetActorValue(ActorValueInfo::kHealth); + const float cMax = apActor->GetActorPermanentValue(ActorValueInfo::kHealth); + const float cDrift = cLocal - aReportedHealth; + const bool cDrifting = std::abs(cDrift) >= std::max(10.f, cMax * 0.1f); + + auto& last = s_lastSnapshotLog[aServerId]; + if (!cDrifting && cNow - last < kSnapshotLogInterval) + return; + last = cNow; + + spdlog::info( + "[DmgDiag] SNAPSHOT {} owner says hp {:.1f}, local copy has {:.1f} (drift {:+.1f}, max {:.1f}) {}", Describe(apActor), aReportedHealth, cLocal, cDrift, cMax, Context()); + if (cDrifting) + spdlog::warn("[DmgDiag] FLAG HEALTH_DRIFT: local copy of {} is {:+.1f} off the owner's health, the synced deltas don't add up", Describe(apActor), cDrift); +} +} // namespace DamageDiagnostics diff --git a/Code/client/Systems/DamageDiagnostics.h b/Code/client/Systems/DamageDiagnostics.h new file mode 100644 index 0000000000..ad01728957 --- /dev/null +++ b/Code/client/Systems/DamageDiagnostics.h @@ -0,0 +1,28 @@ +#pragma once + +struct Actor; + +/** + * @brief Client-only logging of the damage and health sync path, to track down hits that are applied twice or not at all. + * + * Every line starts with "[DmgDiag]" in the normal client log. Lines that look wrong are logged as warnings and carry a + * "FLAG" with the reason, so a log can be searched for "[DmgDiag]" and "FLAG". Nothing here changes what is sent to the + * server or how damage is applied. + */ +namespace DamageDiagnostics +{ +// HookDamageActor saw a hit. aApplied is false when this client leaves the hit to the actor's owner. Returns the hit's number. +uint64_t OnDamageHook(Actor* apHittee, Actor* apHitter, float aRawDamage, float aRealDamage, bool aKillMove, const char* acpPath, bool aApplied) noexcept; + +// The game ran the hit that OnDamageHook() numbered. aHealthBefore is the hittee's health before the game applied it. +void OnDamageApplied(uint64_t aHitId, Actor* apHittee, float aHealthBefore) noexcept; + +// A health delta for an actor is being sent to the server. aBatched is true for the small changes sent on a timer. +void OnHealthChangeSent(uint32_t aServerId, uint32_t aFormId, float aDelta, bool aBatched) noexcept; + +// Another client's health delta arrived and was applied to apActor. +void OnHealthBroadcastReceived(uint32_t aServerId, Actor* apActor, float aDelta, float aHealthBefore, float aHealthAfter) noexcept; + +// The owner's absolute health for a player arrived. The client ignores it for health, so this only compares it with the local copy. +void OnHealthSnapshot(uint32_t aServerId, Actor* apActor, float aReportedHealth) noexcept; +} // namespace DamageDiagnostics 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/ObjectSystem.cpp b/Code/client/Systems/ObjectSystem.cpp new file mode 100644 index 0000000000..2f68bf6c58 --- /dev/null +++ b/Code/client/Systems/ObjectSystem.cpp @@ -0,0 +1,71 @@ +#include +#include +#include +#include +#include +#include +#include + +using TiltedPhoques::List; +using TiltedPhoques::Outcome; +using TiltedPhoques::Set; +using TiltedPhoques::Vector; + +#include +#include + +entt::entity ObjectSystem::Setup(entt::registry& aRegistry, const uint32_t aFormId, const uint32_t aServerId) noexcept +{ + const auto view = aRegistry.view(); + entt::entity existingEntity = entt::null; + Vector staleEntities; + + for (const auto entity : view) + { + const auto& formIdComponent = view.get(entity); + const auto& objectComponent = view.get(entity); + + if (formIdComponent.Id == aFormId && existingEntity == entt::null) + existingEntity = entity; + else if (formIdComponent.Id == aFormId || objectComponent.Id == aServerId) + staleEntities.push_back(entity); + } + + for (const auto entity : staleEntities) + aRegistry.destroy(entity); + + if (existingEntity != entt::null) + { + aRegistry.get(existingEntity).Id = aServerId; + return existingEntity; + } + + const entt::entity entity = aRegistry.create(); + aRegistry.emplace(entity, aFormId); + aRegistry.emplace(entity, aServerId); + return entity; +} + +void ObjectSystem::Remove(entt::registry& aRegistry, const std::span aServerIds) noexcept +{ + const Set serverIds(aServerIds.begin(), aServerIds.end()); + const auto view = aRegistry.view(); + Vector entities; + + for (const auto entity : view) + { + if (serverIds.count(view.get(entity).Id)) + entities.push_back(entity); + } + + for (const auto entity : entities) + aRegistry.destroy(entity); +} + +void ObjectSystem::Clear(entt::registry& aRegistry) noexcept +{ + const auto view = aRegistry.view(); + const Vector entities(view.begin(), view.end()); + for (const auto entity : entities) + aRegistry.destroy(entity); +} diff --git a/Code/client/Systems/ObjectSystem.h b/Code/client/Systems/ObjectSystem.h new file mode 100644 index 0000000000..f6e81c7f85 --- /dev/null +++ b/Code/client/Systems/ObjectSystem.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +/** + * @brief Manages object replication bindings without changing game references. + */ +struct ObjectSystem +{ + static entt::entity Setup(entt::registry& aRegistry, uint32_t aFormId, uint32_t aServerId) noexcept; + static void Remove(entt::registry& aRegistry, std::span aServerIds) noexcept; + static void Clear(entt::registry& aRegistry) noexcept; +}; diff --git a/Code/client/Systems/OwnershipSystem.cpp b/Code/client/Systems/OwnershipSystem.cpp new file mode 100644 index 0000000000..000f4bcbd7 --- /dev/null +++ b/Code/client/Systems/OwnershipSystem.cpp @@ -0,0 +1,70 @@ +#include + +#include +#include + +std::optional OwnershipSystem::AcceptRemoteUpdate(World& aWorld, const uint32_t aServerId, const uint32_t aOwnershipEpoch) noexcept +{ + if (aOwnershipEpoch == 0) + return std::nullopt; + + auto view = aWorld.view(entt::exclude); + const auto it = std::find_if(view.begin(), view.end(), [view, aServerId](const entt::entity aEntity) { return view.get(aEntity).Id == aServerId; }); + if (it == view.end()) + return std::nullopt; + + auto& remoteComponent = view.get(*it); + if (aOwnershipEpoch < remoteComponent.OwnershipEpoch) + return std::nullopt; + + if (aOwnershipEpoch > remoteComponent.OwnershipEpoch) + { + spdlog::info("Adopting ownership epoch {} for remote actor {:X}, the transfer from epoch {} was missed", aOwnershipEpoch, aServerId, remoteComponent.OwnershipEpoch); + remoteComponent.OwnershipEpoch = aOwnershipEpoch; + } + + return *it; +} + +std::optional OwnershipSystem::AcceptOwnerUpdate(World& aWorld, const uint32_t aServerId, const uint32_t aOwnershipEpoch) noexcept +{ + if (aOwnershipEpoch == 0) + return std::nullopt; + + auto view = aWorld.view(); + const auto it = std::find_if(view.begin(), view.end(), [view, aServerId, aOwnershipEpoch](const entt::entity aEntity) + { + const auto& localComponent = view.get(aEntity); + return localComponent.Id == aServerId && localComponent.OwnershipEpoch == aOwnershipEpoch; + }); + + if (it == view.end()) + return std::nullopt; + + return *it; +} + +std::optional OwnershipSystem::GetOutgoingOwnership(World& aWorld, const entt::entity aEntity, const bool aAllowRemote) noexcept +{ + if (const auto* pLocalComponent = aWorld.try_get(aEntity); pLocalComponent && pLocalComponent->OwnershipEpoch != 0) + return Ownership{pLocalComponent->Id, pLocalComponent->OwnershipEpoch}; + + if (!aAllowRemote) + return std::nullopt; + + if (const auto* pRemoteComponent = aWorld.try_get(aEntity); pRemoteComponent && pRemoteComponent->OwnershipEpoch != 0) + return Ownership{pRemoteComponent->Id, pRemoteComponent->OwnershipEpoch}; + + return std::nullopt; +} + +uint32_t OwnershipSystem::GetOwnershipEpoch(World& aWorld, const entt::entity aEntity) noexcept +{ + if (const auto* pLocalComponent = aWorld.try_get(aEntity)) + return pLocalComponent->OwnershipEpoch; + + if (const auto* pRemoteComponent = aWorld.try_get(aEntity)) + return pRemoteComponent->OwnershipEpoch; + + return 0; +} diff --git a/Code/client/Systems/OwnershipSystem.h b/Code/client/Systems/OwnershipSystem.h new file mode 100644 index 0000000000..f2742d0128 --- /dev/null +++ b/Code/client/Systems/OwnershipSystem.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +struct World; + +/** + * @brief Ownership epoch rules for actors, shared by the services that send and apply owner updates. + * + * The server only accepts and relays updates from an actor's current owner at its current epoch. + */ +struct OwnershipSystem +{ + struct Ownership + { + uint32_t ServerId{}; + uint32_t OwnershipEpoch{}; + }; + + /** + * @brief Returns the remote entity a relayed owner update applies to, or nothing if its epoch is stale. + * + * A newer epoch means this client missed the ownership transfer, so it is adopted instead of dropping every later update. + */ + static std::optional AcceptRemoteUpdate(World& aWorld, uint32_t aServerId, uint32_t aOwnershipEpoch) noexcept; + /** + * @brief Returns the local entity a relayed update for one of our own actors applies to, e.g. another player looting it. + */ + static std::optional AcceptOwnerUpdate(World& aWorld, uint32_t aServerId, uint32_t aOwnershipEpoch) noexcept; + /** + * @brief Returns the server id and epoch to send a local change to an actor with, or nothing if it isn't ours to send. + * + * @param aAllowRemote Also allows actors owned by someone else, for changes the local player makes to them (looting, pickpocketing). + */ + static std::optional GetOutgoingOwnership(World& aWorld, entt::entity aEntity, bool aAllowRemote) noexcept; + /** + * @brief Returns the ownership epoch this client knows for the actor, or 0 if it is neither local nor remote. + */ + static uint32_t GetOwnershipEpoch(World& aWorld, entt::entity aEntity) noexcept; +}; diff --git a/Code/client/Systems/RemoteDeathSystem.h b/Code/client/Systems/RemoteDeathSystem.h new file mode 100644 index 0000000000..50ce963ccb --- /dev/null +++ b/Code/client/Systems/RemoteDeathSystem.h @@ -0,0 +1,32 @@ +#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) + { + 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); + 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. diff --git a/Code/client/Systems/VoiceDsp.cpp b/Code/client/Systems/VoiceDsp.cpp new file mode 100644 index 0000000000..7a511b2c19 --- /dev/null +++ b/Code/client/Systems/VoiceDsp.cpp @@ -0,0 +1,131 @@ +#include "VoiceDsp.h" + +#include +#include + +namespace VoiceDsp +{ +float ClampMasterVolume(float aVolume) noexcept +{ + return std::isfinite(aVolume) ? std::clamp(aVolume, 0.f, 5.f) : 1.f; +} + +float Peak(const float* apSamples, size_t aCount) noexcept +{ + float peak = 0.f; + for (size_t i = 0; i < aCount; ++i) + peak = std::max(peak, std::fabs(apSamples[i])); + + return std::min(peak, 1.f); +} + +float DistanceGain(float aDistance, float aFadeStart) noexcept +{ + if (!std::isfinite(aDistance) || !std::isfinite(aFadeStart) || aFadeStart <= 0.f) + return 0.f; + + if (aDistance <= aFadeStart) + return 1.f; + + const float silentAt = aFadeStart * kSilentAtFadeStarts; + if (aDistance >= silentAt) + return 0.f; + + // Inverse square law: intensity falls with distance squared, so amplitude falls with distance, 6 dB per doubling + const float inverse = aFadeStart / aDistance; + + // Ease the last stretch down to silence so a voice fades out instead of cutting off at the edge + const float taperFrom = aFadeStart * kTaperFromFadeStarts; + if (aDistance <= taperFrom) + return inverse; + + const float t = (aDistance - taperFrom) / (silentAt - taperFrom); + return inverse * (1.f - t * t * (3.f - 2.f * t)); +} + +void PanGains(float aPan, float& aOutLeft, float& aOutRight) noexcept +{ + constexpr float kQuarterPi = 0.78539816f; + + const float pan = std::isfinite(aPan) ? std::clamp(aPan, -1.f, 1.f) : 0.f; + const float angle = (pan + 1.f) * kQuarterPi; + + aOutLeft = std::cos(angle); + aOutRight = std::sin(angle); +} + +int32_t SequenceDelta(uint16_t aLast, uint16_t aNow) noexcept +{ + return static_cast(static_cast(aNow - aLast)); +} + +bool NoiseGate::Process(float aPeak, float aThreshold) noexcept +{ + if (aPeak > 0.f && aPeak >= aThreshold) + m_holdLeft = kHoldFrames; + else if (m_holdLeft > 0) + --m_holdLeft; + + return m_holdLeft > 0; +} + +JitterBuffer::JitterBuffer() + : m_samples(kCapacity, 0.f) +{ +} + +void JitterBuffer::Push(const float* apSamples, size_t aCount) noexcept +{ + for (size_t i = 0; i < aCount; ++i) + { + if (m_size == kCapacity) + { + m_read = (m_read + 1) % kCapacity; + --m_size; + } + + m_samples[(m_read + m_size) % kCapacity] = apSamples[i]; + ++m_size; + } + + // Too far behind, skip ahead to the start threshold + if (m_size > kMaxSamples) + { + const size_t drop = m_size - kStartSamples; + m_read = (m_read + drop) % kCapacity; + m_size -= drop; + } + + if (!m_playing && m_size >= kStartSamples) + m_playing = true; +} + +size_t JitterBuffer::MixInto(float* apStereoOut, size_t aFrames, float aLeftGain, float aRightGain) noexcept +{ + if (!m_playing) + return 0; + + const size_t count = std::min(aFrames, m_size); + for (size_t i = 0; i < count; ++i) + { + const float sample = m_samples[(m_read + i) % kCapacity]; + apStereoOut[i * 2] += sample * aLeftGain; + apStereoOut[i * 2 + 1] += sample * aRightGain; + } + + m_read = (m_read + count) % kCapacity; + m_size -= count; + + if (m_size == 0) + m_playing = false; + + return count; +} + +void JitterBuffer::Clear() noexcept +{ + m_read = 0; + m_size = 0; + m_playing = false; +} +} // namespace VoiceDsp diff --git a/Code/client/Systems/VoiceDsp.h b/Code/client/Systems/VoiceDsp.h new file mode 100644 index 0000000000..1a4557813c --- /dev/null +++ b/Code/client/Systems/VoiceDsp.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include + +/** + * @brief Game independent pieces of voice chat: the noise gate, distance falloff, panning and the per speaker + * jitter buffer. Kept apart from the audio devices and the game so they can be unit tested. + * + * Audio is 48 kHz, mono per speaker, in 20 ms frames of 960 samples. + */ +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 100%. +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; + +// Voices fall off like real sound past the fade start, and ease out to silence over the last stretch +constexpr float kTaperFromFadeStarts = 2.f; +constexpr float kSilentAtFadeStarts = 4.f; + +// Full volume up to aFadeStart, then -6 dB each time the distance doubles (inverse square law), easing to silent at +// kSilentAtFadeStarts times aFadeStart +float DistanceGain(float aDistance, float aFadeStart) noexcept; + +// aPan is -1 (all left) to 1 (all right), constant power so a voice moving around doesn't change loudness +void PanGains(float aPan, float& aOutLeft, float& aOutRight) noexcept; + +// Frames between the last sequence and this one, wrapping at 65536. 1 is the next frame, 0 or less is a duplicate or late. +int32_t SequenceDelta(uint16_t aLast, uint16_t aNow) noexcept; + +/** + * @brief Opens on a frame whose peak reaches the threshold, and stays open a moment after so word endings and short + * pauses aren't cut off. A threshold of 0 is always open. + */ +struct NoiseGate +{ + // 300 ms + static constexpr uint32_t kHoldFrames = 15; + + // Returns whether this frame should be sent + bool Process(float aPeak, float aThreshold) noexcept; + + [[nodiscard]] bool IsOpen() const noexcept { return m_holdLeft > 0; } + +private: + uint32_t m_holdLeft{0}; +}; + +/** + * @brief Decoded audio of one remote speaker, waiting to be played. + * + * Playback starts once kStartSamples are buffered so small network jitter doesn't cause gaps. When the buffer + * runs dry the speaker goes quiet and buffers up again. If it grows past kMaxSamples, e.g. after our game froze, + * the oldest audio is dropped so the voice doesn't lag behind. + */ +struct JitterBuffer +{ + static constexpr size_t kStartSamples = kFrameSamples * 3; // 60 ms + static constexpr size_t kMaxSamples = kFrameSamples * 10; // 200 ms + static constexpr size_t kCapacity = kFrameSamples * 16; + + JitterBuffer(); + + void Push(const float* apSamples, size_t aCount) noexcept; + // Adds up to aFrames samples into the interleaved stereo output, returns how many were mixed + size_t MixInto(float* apStereoOut, size_t aFrames, float aLeftGain, float aRightGain) noexcept; + void Clear() noexcept; + + [[nodiscard]] size_t GetBuffered() const noexcept { return m_size; } + [[nodiscard]] bool IsPlaying() const noexcept { return m_playing; } + +private: + std::vector m_samples; + size_t m_read{0}; + size_t m_size{0}; + bool m_playing{false}; +}; +} // namespace VoiceDsp diff --git a/Code/client/TiltedOnlineApp.cpp b/Code/client/TiltedOnlineApp.cpp index 98b6c65b81..bf62bb3948 100644 --- a/Code/client/TiltedOnlineApp.cpp +++ b/Code/client/TiltedOnlineApp.cpp @@ -18,6 +18,8 @@ #include #include +#include + #include using TiltedPhoques::Debug; @@ -125,6 +127,9 @@ void TiltedOnlineApp::Update() *bAlwaysActive = 1; + // SKSE has sent kPostLoad by the first frame, so plugins have installed their hooks. + SubtitleManager::InstallHooks(); + World::Get().Update(); } diff --git a/Code/client/Utils.cpp b/Code/client/Utils.cpp index ed04a809c3..2893b60b5c 100644 --- a/Code/client/Utils.cpp +++ b/Code/client/Utils.cpp @@ -54,34 +54,6 @@ std::optional FindEntityByServerId(const uint32_t aServerId) noexc return std::nullopt; } -std::optional GetLocalOwnershipToken(const uint32_t aFormId) noexcept -{ - auto view = World::Get().view(); - const auto it = std::find_if(view.begin(), view.end(), [view, aFormId](const entt::entity aEntity) { return view.get(aEntity).Id == aFormId; }); - if (it == view.end()) - return std::nullopt; - - const auto& localComponent = view.get(*it); - if (localComponent.OwnershipEpoch == 0) - return std::nullopt; - - return ActorOwnershipToken{localComponent.Id, localComponent.OwnershipEpoch}; -} - -std::optional GetRemoteOwnershipToken(const uint32_t aFormId) noexcept -{ - auto view = World::Get().view(); - const auto it = std::find_if(view.begin(), view.end(), [view, aFormId](const entt::entity aEntity) { return view.get(aEntity).Id == aFormId; }); - if (it == view.end()) - return std::nullopt; - - const auto& remoteComponent = view.get(*it); - if (remoteComponent.OwnershipEpoch == 0) - return std::nullopt; - - return ActorOwnershipToken{remoteComponent.Id, remoteComponent.OwnershipEpoch}; -} - void ShowHudMessage(const TiltedPhoques::String& acMessage) { using TShowHudMessage = void(const char*, const char*, bool); diff --git a/Code/client/Utils.h b/Code/client/Utils.h index 6d7ae5947b..d7614e118a 100644 --- a/Code/client/Utils.h +++ b/Code/client/Utils.h @@ -19,12 +19,6 @@ struct TESForm; namespace Utils { -struct ActorOwnershipToken -{ - uint32_t ServerId{}; - uint32_t OwnershipEpoch{}; -}; - static void Assert(const char* apExpression, const char* apMessage) { spdlog::critical("Assertion failed ({}): {}", apExpression, apMessage); @@ -35,8 +29,6 @@ static void Assert(const char* apExpression, const char* apMessage) std::optional GetServerId(entt::entity aEntity) noexcept; std::optional FindEntityByServerId(uint32_t aServerId) noexcept; -std::optional GetLocalOwnershipToken(uint32_t aFormId) noexcept; -std::optional GetRemoteOwnershipToken(uint32_t aFormId) noexcept; template T* GetByServerId(const uint32_t acServerId) noexcept { diff --git a/Code/client/World.cpp b/Code/client/World.cpp index b0596604b5..ac8568b451 100644 --- a/Code/client/World.cpp +++ b/Code/client/World.cpp @@ -15,12 +15,18 @@ #include #include #include +#include #include #include #include #include #include #include +#include +#include +#include +#include +#include #include #include @@ -45,6 +51,7 @@ World::World() ctx().emplace(*this, m_dispatcher, m_transport); ctx().emplace(*this, m_dispatcher); ctx().emplace(*this, m_dispatcher, m_transport); + ctx().emplace(*this, m_dispatcher, m_transport); ctx().emplace(*this, m_dispatcher, m_transport); ctx().emplace(*this, m_dispatcher, m_transport); ctx().emplace(*this, m_dispatcher, m_transport); @@ -54,6 +61,11 @@ 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); + 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(); } @@ -73,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/client/World.h b/Code/client/World.h index 2c3fee2eb7..cb201416b3 100644 --- a/Code/client/World.h +++ b/Code/client/World.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,8 @@ struct World : entt::registry const PartyService& GetPartyService() const noexcept { return ctx().at(); } CharacterService& GetCharacterService() noexcept { return ctx().at(); } const CharacterService& GetCharacterService() const noexcept { return ctx().at(); } + TradeService& GetTradeService() noexcept { return ctx().at(); } + const TradeService& GetTradeService() const noexcept { return ctx().at(); } OverlayService& GetOverlayService() noexcept { return ctx().at(); } const OverlayService& GetOverlayService() const noexcept { return ctx().at(); } DebugService& GetDebugService() noexcept { return ctx().at(); } diff --git a/Code/client/xmake.lua b/Code/client/xmake.lua index ec4be6bc32..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) @@ -59,7 +61,9 @@ target(name) "entt", "glm", "mem", - "xbyak") + "xbyak", + "libopus", + "miniaudio") add_syslinks( "version", diff --git a/Code/common/DateTime.cpp b/Code/common/DateTime.cpp index b091ef6158..bfb1eeb426 100644 --- a/Code/common/DateTime.cpp +++ b/Code/common/DateTime.cpp @@ -15,7 +15,12 @@ uint32_t DateTime::GetNumberOfDaysByMonthIndex(int aIndex) void DateTime::Update(uint64_t aDeltaTick) { - m_timeModel.Time += GetDeltaTime(aDeltaTick); + AddHours(GetDeltaTime(aDeltaTick)); +} + +void DateTime::AddHours(float aHours) +{ + m_timeModel.Time += aHours; m_timeModel.Day += static_cast(m_timeModel.Time / 24.f); m_timeModel.Time = std::fmod(m_timeModel.Time, 24.f); diff --git a/Code/common/DateTime.h b/Code/common/DateTime.h index e5e7a758ab..ec9b4d2bcf 100644 --- a/Code/common/DateTime.h +++ b/Code/common/DateTime.h @@ -14,6 +14,8 @@ struct DateTime bool operator==(const DateTime& other) const; void Update(uint64_t aDeltaTick); + // Advances the clock by a number of game hours, rolling over days, months and years. + void AddHours(float aHours); float GetDeltaTime(uint64_t aDeltaTick) const noexcept; float GetTimeInDays() const noexcept; diff --git a/Code/common/xmake.lua b/Code/common/xmake.lua index 37b059a08a..453c8b88e1 100644 --- a/Code/common/xmake.lua +++ b/Code/common/xmake.lua @@ -1,6 +1,12 @@ +local cBuildVersion = get_config("build_version") + target("CommonLib") - add_configfiles("BuildInfo.h.in") + if cBuildVersion and cBuildVersion ~= "" then + add_configfiles("BuildInfo.h.in", {variables = {GIT_TAG = cBuildVersion}}) + else + add_configfiles("BuildInfo.h.in") + end set_kind("static") set_group("common") add_includedirs(".", "../", "../../build", {public = true}) diff --git a/Code/components/console/EnvSettingsProvider.cpp b/Code/components/console/EnvSettingsProvider.cpp new file mode 100644 index 0000000000..f972782051 --- /dev/null +++ b/Code/components/console/EnvSettingsProvider.cpp @@ -0,0 +1,77 @@ +// Copyright (C) 2022 TiltedPhoques SRL. +// For licensing information see LICENSE at the root of this distribution. + +#include + +#include +#include +#include +#include + +#include +#include +#include + +namespace Console +{ +std::string ToEnvironmentVariableName(const char* apSettingName) +{ + std::string_view name(apSettingName); + + // Strip the "Section:" prefix, the key alone names the variable. + const auto cSplitLoc = name.find_first_of(':'); + if(cSplitLoc != std::string_view::npos) + name = name.substr(cSplitLoc + 1); + + std::string result; + result.reserve(name.size()); + for(const char c : name) + result += static_cast(std::toupper(static_cast(c))); + + return result; +} + +namespace +{ +bool ParseBoolValue(const char* apValue, bool aCurrent) +{ + // Same spellings the runner already accepts for TILTED_ACCEPT_EULA, + // plus the explicit false ones. Anything else keeps the current value. + const std::string_view value(apValue); + if(value == "true" || value == "True" || value == "TRUE" || value == "1") + return true; + if(value == "false" || value == "False" || value == "FALSE" || value == "0") + return false; + return aCurrent; +} +} // namespace + +void LoadSettingsFromEnvironment(ConsoleRegistry& aReg) +{ + aReg.ForAllSettings( + [&](SettingBase* setting) + { + const std::string variable = ToEnvironmentVariableName(setting->name); + const char* pValue = std::getenv(variable.c_str()); + if(!pValue) + return; + + switch(setting->type) + { + case SettingBase::Type::kBoolean: setting->data.as_boolean = ParseBoolValue(pValue, setting->data.as_boolean); break; + case SettingBase::Type::kInt: setting->data.as_int32 = ConvertStringValue(pValue, setting->data.as_int32); break; + case SettingBase::Type::kUInt: setting->data.as_uint32 = ConvertStringValue(pValue, setting->data.as_uint32); break; + case SettingBase::Type::kInt64: setting->data.as_int64 = ConvertStringValue(pValue, setting->data.as_int64); break; + case SettingBase::Type::kUInt64: setting->data.as_uint64 = ConvertStringValue(pValue, setting->data.as_uint64); break; + case SettingBase::Type::kFloat: setting->data.as_float = ConvertStringValue(pValue, setting->data.as_float); break; + // Strings however are a special case, as it has its own allocator. + case SettingBase::Type::kString: + { + static_cast(setting)->StoreValue(*setting, pValue); + break; + } + default: BASE_ASSERT(true, "LoadSettingsFromEnvironment(): Unknown type index for {}", setting->name); break; + } + }); +} +} // namespace Console diff --git a/Code/components/console/EnvSettingsProvider.h b/Code/components/console/EnvSettingsProvider.h new file mode 100644 index 0000000000..a6e5ab0b72 --- /dev/null +++ b/Code/components/console/EnvSettingsProvider.h @@ -0,0 +1,20 @@ +// Copyright (C) 2022 TiltedPhoques SRL. +// For licensing information see LICENSE at the root of this distribution. +#pragma once + +#include + +namespace Console +{ +class ConsoleRegistry; + +// Maps a setting name to the environment variable that overrides it. +// The variable is the setting's key (the part after the "Section:" prefix) +// in upper case, e.g. "Gameplay:uDifficulty" becomes "UDIFFICULTY" and +// "GameServer:sPassword" becomes "SPASSWORD". +std::string ToEnvironmentVariableName(const char* apSettingName); + +// Applies every set environment variable on top of the settings in the +// registry, so environment values win over the ini file and the defaults. +void LoadSettingsFromEnvironment(ConsoleRegistry& aReg); +} // namespace Console diff --git a/Code/components/console/EnvSettingsProviderTest.cpp b/Code/components/console/EnvSettingsProviderTest.cpp new file mode 100644 index 0000000000..85f0712fde --- /dev/null +++ b/Code/components/console/EnvSettingsProviderTest.cpp @@ -0,0 +1,116 @@ +// Copyright (C) 2022 TiltedPhoques SRL. +// For licensing information see LICENSE at the root of this distribution. + +#include +#include +#include + +#include +#include + +#include + +namespace Console +{ +namespace +{ +void SetEnv(const char* apName, const char* apValue) +{ +#ifdef _WIN32 + _putenv_s(apName, apValue); +#else + setenv(apName, apValue, 1); +#endif +} + +void ClearEnv(const char* apName) +{ +#ifdef _WIN32 + _putenv_s(apName, ""); +#else + unsetenv(apName); +#endif +} + +class EnvSettingsProviderTest : public ::testing::Test +{ +public: + void SetUp() override; + void TearDown() override; +}; + +void EnvSettingsProviderTest::SetUp() +{ + spdlog::stdout_color_mt("Test"); +} + +void EnvSettingsProviderTest::TearDown() +{ + ClearEnv("UDIFFICULTY"); + ClearEnv("BENABLEPVP"); + ClearEnv("SPASSWORD"); + ClearEnv("FINVALIDNUMBER"); + spdlog::drop("Test"); +} + +TEST(EnvSettingsProvider, VariableNameStripsSectionAndUppercases) +{ + // The names from the examples in the feature request. + EXPECT_EQ(ToEnvironmentVariableName("Gameplay:uDifficulty"), "UDIFFICULTY"); + EXPECT_EQ(ToEnvironmentVariableName("ModPolicy:bEnableModCheck"), "BENABLEMODCHECK"); + EXPECT_EQ(ToEnvironmentVariableName("GameServer:sPassword"), "SPASSWORD"); + // Settings without a section keep their full name. + EXPECT_EQ(ToEnvironmentVariableName("bConsole"), "BCONSOLE"); +} + +// The settings are function-local statics, the pattern ConsoleRegistryTest +// uses, and the registry is leaked on purpose. Every command/setting +// construction links into the static lists that a new ConsoleRegistry +// drains in BindStaticItems, including the registry's own native commands: +// if this registry were destroyed, the next registry in the process would +// drain dangling entries. Production runs a single registry for the life +// of the process, so this only matters in tests. +TEST_F(EnvSettingsProviderTest, EnvironmentOverridesRegisteredSettings) +{ + static Setting s_uDifficulty{"Gameplay:uDifficulty", "desc", 4u}; + static Setting s_bEnablePvp{"Gameplay:bEnablePvp", "desc", false}; + static StringSetting s_sPassword{"GameServer:sPassword", "desc", ""}; + static Setting s_fInvalidNumber{"Test:fInvalidNumber", "desc", 1.5f}; + + static ConsoleRegistry* s_pRegistry = new ConsoleRegistry("Test"); + auto* r = s_pRegistry; + + // Reset to the defaults, so the phases below do not depend on test + // order or repetition. + s_uDifficulty.data.as_uint32 = 4u; + s_bEnablePvp.data.as_boolean = false; + s_sPassword.StoreValue(s_sPassword, ""); + s_fInvalidNumber.data.as_float = 1.5f; + ClearEnv("UDIFFICULTY"); + ClearEnv("BENABLEPVP"); + ClearEnv("SPASSWORD"); + ClearEnv("FINVALIDNUMBER"); + + // Nothing set: every setting keeps its current value. + LoadSettingsFromEnvironment(*r); + EXPECT_EQ(r->FindSetting("Gameplay:uDifficulty")->data.as_uint32, 4u); + EXPECT_FALSE(r->FindSetting("Gameplay:bEnablePvp")->data.as_boolean); + + // Set variables override the current values. + SetEnv("UDIFFICULTY", "3"); + SetEnv("BENABLEPVP", "true"); + SetEnv("SPASSWORD", "supersecretpassword"); + + LoadSettingsFromEnvironment(*r); + + EXPECT_EQ(r->FindSetting("Gameplay:uDifficulty")->data.as_uint32, 3u); + EXPECT_TRUE(r->FindSetting("Gameplay:bEnablePvp")->data.as_boolean); + EXPECT_EQ(std::string(r->FindSetting("GameServer:sPassword")->c_str()), "supersecretpassword"); + + // A value that does not parse keeps the current setting. + SetEnv("FINVALIDNUMBER", "not-a-number"); + LoadSettingsFromEnvironment(*r); + EXPECT_FLOAT_EQ(r->FindSetting("Test:fInvalidNumber")->data.as_float, 1.5f); +} +} // namespace +} // namespace Console diff --git a/Code/components/console/IniSettingsProvider.cpp b/Code/components/console/IniSettingsProvider.cpp index 1c148cdcc4..af059c9aad 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]; @@ -64,6 +79,16 @@ void SaveSettingsToIni(ConsoleRegistry& aReg, const std::filesystem::path& aPath { CSimpleIni ini; + // Start from the file on disk so keys that aren't settings here survive, the game client keeps its own + // [Voice] keys in the same STServer.ini + std::error_code existsError{}; + if (std::filesystem::exists(aPath, existsError) && !existsError) + { + const auto existing = TiltedPhoques::LoadFile(aPath); + if (ini.LoadData(existing.c_str()) != SI_Error::SI_OK) + ini.Reset(); + } + SI_Error error{SI_Error::SI_OK}; aReg.ForAllSettings( [&](SettingBase* setting) diff --git a/Code/encoding/Decoding.h b/Code/encoding/Decoding.h new file mode 100644 index 0000000000..d6df1c86c6 --- /dev/null +++ b/Code/encoding/Decoding.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +// TiltedCore's reader has no error state: reads past the end return zeros. A decoder that finds malformed data +// moves the cursor past the end, which reads never do, and the message factories then drop the message. +namespace Decoding +{ +inline bool IsMalformed(const TiltedPhoques::Buffer::Reader& acReader) noexcept +{ + return acReader.GetBitPosition() > acReader.GetBuffer()->GetSize() * 8; +} + +inline void MarkMalformed(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + if (IsMalformed(aReader)) + return; + + const size_t cRemainingBits = aReader.GetBuffer()->GetSize() * 8 - aReader.GetBitPosition(); + aReader.Advance(cRemainingBits / 8 + 1); +} + +// Reads a length-prefixed list. A length above aMax, or more elements than the data holds, marks the reader +// malformed. Wire lengths are never used to allocate up front. +template bool ReadList(TiltedPhoques::Buffer::Reader& aReader, TiltedPhoques::Vector& aOut, uint64_t aMax) noexcept +{ + aOut.clear(); + + const uint64_t cCount = TiltedPhoques::Serialization::ReadVarInt(aReader); + if (cCount > aMax) + { + MarkMalformed(aReader); + return false; + } + + for (uint64_t i = 0; i < cCount; ++i) + { + // Every element takes at least one bit, so running out of data means the list was truncated. + if (aReader.Eof()) + { + MarkMalformed(aReader); + aOut.clear(); + return false; + } + + T item{}; + item.Deserialize(aReader); + aOut.push_back(std::move(item)); + } + + if (IsMalformed(aReader)) + { + aOut.clear(); + return false; + } + + return true; +} +} // namespace Decoding diff --git a/Code/encoding/Messages/ClientMessageFactory.cpp b/Code/encoding/Messages/ClientMessageFactory.cpp index 0c1f71810d..b59e0db5f8 100644 --- a/Code/encoding/Messages/ClientMessageFactory.cpp +++ b/Code/encoding/Messages/ClientMessageFactory.cpp @@ -1,6 +1,7 @@ #pragma once #include +#include static std::function(TiltedPhoques::Buffer::Reader& aReader)> s_clientMessageExtractor[kClientOpcodeMax]; @@ -14,10 +15,12 @@ static struct ClientMessageFactoryInit { using T = typename std::remove_reference_t::Type; - s_clientMessageExtractor[T::Opcode] = [](TiltedPhoques::Buffer::Reader& aReader) + s_clientMessageExtractor[T::Opcode] = [](TiltedPhoques::Buffer::Reader& aReader) -> UniquePtr { auto ptr = TiltedPhoques::MakeUnique(); ptr->DeserializeRaw(aReader); + if (Decoding::IsMalformed(aReader)) [[unlikely]] + return {nullptr}; return TiltedPhoques::CastUnique(std::move(ptr)); }; @@ -38,5 +41,9 @@ UniquePtr ClientMessageFactory::Extract(TiltedPhoques::Buffer::Re return {nullptr}; const auto opcode = static_cast(data); + // Calling an empty std::function throws, so opcodes without a registered message are dropped here. + if (!s_clientMessageExtractor[opcode]) [[unlikely]] + return {nullptr}; + return s_clientMessageExtractor[opcode](aReader); } diff --git a/Code/encoding/Messages/ClientMessageFactory.h b/Code/encoding/Messages/ClientMessageFactory.h index 4f94d6d10e..1c33ad8f3c 100644 --- a/Code/encoding/Messages/ClientMessageFactory.h +++ b/Code/encoding/Messages/ClientMessageFactory.h @@ -16,6 +16,12 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include @@ -41,21 +47,30 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include #include #include +#include #include #include #include #include #include #include +#include +#include +#include +#include +#include +#include using TiltedPhoques::UniquePtr; @@ -66,11 +81,10 @@ struct ClientMessageFactory template static auto Visit(T&& func) { auto s_visitor = CreateMessageVisitor< - AuthenticationRequest, AssignCharacterRequest, ClientReferencesMoveRequest, EnterInteriorCellRequest, RequestInventoryChanges, RequestFactionsChanges, RequestQuestUpdate, PartyInviteRequest, PartyAcceptInviteRequest, PartyLeaveRequest, PartyCreateRequest, - PartyChangeLeaderRequest, PartyKickRequest, RequestActorValueChanges, RequestActorMaxValueChanges, EnterExteriorCellRequest, RequestHealthChangeBroadcast, ActivateRequest, LockChangeRequest, AssignObjectsRequest, RequestDeathStateChange, ShiftGridCellRequest, + 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>; + RequestRemoveWaypoint, RemoveSpellRequest, SetTimeCommandRequest, RequestActionEvents, RequestTimeSkip, DropItemRequest, DroppedItemMoveRequest, PickUpDroppedItemRequest, VoiceDataRequest, VoiceStateRequest, SendModMessageRequest, PlayerPerksRequest>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Messages/DropItemRequest.cpp b/Code/encoding/Messages/DropItemRequest.cpp new file mode 100644 index 0000000000..478189a4ff --- /dev/null +++ b/Code/encoding/Messages/DropItemRequest.cpp @@ -0,0 +1,22 @@ +#include +#include + +void DropItemRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, LocalId); + Item.Serialize(aWriter); + CellId.Serialize(aWriter); + WorldSpaceId.Serialize(aWriter); + Transform.Serialize(aWriter); +} + +void DropItemRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + LocalId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + Item.Deserialize(aReader); + CellId.Deserialize(aReader); + WorldSpaceId.Deserialize(aReader); + Transform.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/DropItemRequest.h b/Code/encoding/Messages/DropItemRequest.h new file mode 100644 index 0000000000..2bd2606ffd --- /dev/null +++ b/Code/encoding/Messages/DropItemRequest.h @@ -0,0 +1,35 @@ +#pragma once + +#include "Message.h" + +#include + +/** + * @brief Sent when the local player drops an item, so the server can track it and tell the other clients. + */ +struct DropItemRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kDropItemRequest; + + DropItemRequest() + : ClientMessage(Opcode) + { + } + + virtual ~DropItemRequest() = default; + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const DropItemRequest& acRhs) const noexcept + { + return GetOpcode() == acRhs.GetOpcode() && LocalId == acRhs.LocalId && Item == acRhs.Item && CellId == acRhs.CellId && WorldSpaceId == acRhs.WorldSpaceId && Transform == acRhs.Transform; + } + + // Form id of the dropping client's reference, echoed back in DropItemResponse. + uint32_t LocalId{}; + Inventory::Entry Item{}; + GameId CellId{}; + GameId WorldSpaceId{}; + DroppedItemTransform Transform{}; +}; diff --git a/Code/encoding/Messages/DropItemResponse.cpp b/Code/encoding/Messages/DropItemResponse.cpp new file mode 100644 index 0000000000..e4e47929ad --- /dev/null +++ b/Code/encoding/Messages/DropItemResponse.cpp @@ -0,0 +1,16 @@ +#include +#include + +void DropItemResponse::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, LocalId); + Serialization::WriteVarInt(aWriter, ServerId); +} + +void DropItemResponse::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + LocalId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + ServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; +} diff --git a/Code/encoding/Messages/DropItemResponse.h b/Code/encoding/Messages/DropItemResponse.h new file mode 100644 index 0000000000..65b22e3fa8 --- /dev/null +++ b/Code/encoding/Messages/DropItemResponse.h @@ -0,0 +1,24 @@ +#pragma once + +#include "Message.h" + +/** + * @brief Tells the dropping client which server id was assigned to its dropped item. + */ +struct DropItemResponse final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kDropItemResponse; + + DropItemResponse() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const DropItemResponse& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && LocalId == acRhs.LocalId && ServerId == acRhs.ServerId; } + + uint32_t LocalId{}; + uint32_t ServerId{}; +}; diff --git a/Code/encoding/Messages/DroppedItemMoveRequest.cpp b/Code/encoding/Messages/DroppedItemMoveRequest.cpp new file mode 100644 index 0000000000..e506536109 --- /dev/null +++ b/Code/encoding/Messages/DroppedItemMoveRequest.cpp @@ -0,0 +1,13 @@ +#include + +void DroppedItemMoveRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Move.Serialize(aWriter); +} + +void DroppedItemMoveRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + Move.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/DroppedItemMoveRequest.h b/Code/encoding/Messages/DroppedItemMoveRequest.h new file mode 100644 index 0000000000..cd55dfcdba --- /dev/null +++ b/Code/encoding/Messages/DroppedItemMoveRequest.h @@ -0,0 +1,27 @@ +#pragma once + +#include "Message.h" + +#include + +/** + * @brief Streams the transform of a dropped item while its physics runs on the dropping client. + */ +struct DroppedItemMoveRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kDroppedItemMoveRequest; + + DroppedItemMoveRequest() + : ClientMessage(Opcode) + { + } + + virtual ~DroppedItemMoveRequest() = default; + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const DroppedItemMoveRequest& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && Move == acRhs.Move; } + + DroppedItemMove Move{}; +}; 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/NotifyChatMessageBroadcast.cpp b/Code/encoding/Messages/NotifyChatMessageBroadcast.cpp index bedd16a84c..52668de8c1 100644 --- a/Code/encoding/Messages/NotifyChatMessageBroadcast.cpp +++ b/Code/encoding/Messages/NotifyChatMessageBroadcast.cpp @@ -3,6 +3,7 @@ void NotifyChatMessageBroadcast::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept { Serialization::WriteVarInt(aWriter, static_cast(MessageType)); + Serialization::WriteVarInt(aWriter, PlayerId); Serialization::WriteString(aWriter, PlayerName); Serialization::WriteString(aWriter, ChatMessage); } @@ -12,6 +13,7 @@ void NotifyChatMessageBroadcast::DeserializeRaw(TiltedPhoques::Buffer::Reader& a ServerMessage::DeserializeRaw(aReader); MessageType = static_cast(Serialization::ReadVarInt(aReader)); + PlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; PlayerName = Serialization::ReadString(aReader); ChatMessage = Serialization::ReadString(aReader); } diff --git a/Code/encoding/Messages/NotifyChatMessageBroadcast.h b/Code/encoding/Messages/NotifyChatMessageBroadcast.h index 43f9be8511..5ef8d8cd6c 100644 --- a/Code/encoding/Messages/NotifyChatMessageBroadcast.h +++ b/Code/encoding/Messages/NotifyChatMessageBroadcast.h @@ -18,9 +18,11 @@ struct NotifyChatMessageBroadcast final : ServerMessage void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; - bool operator==(const NotifyChatMessageBroadcast& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && MessageType == acRhs.MessageType && PlayerName == acRhs.PlayerName && ChatMessage == acRhs.ChatMessage; } + bool operator==(const NotifyChatMessageBroadcast& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && MessageType == acRhs.MessageType && PlayerId == acRhs.PlayerId && PlayerName == acRhs.PlayerName && ChatMessage == acRhs.ChatMessage; } ChatMessageType MessageType; + // Sender, so clients can find their character; 0 for server and script messages. + uint32_t PlayerId{}; TiltedPhoques::String PlayerName; TiltedPhoques::String ChatMessage; }; diff --git a/Code/encoding/Messages/NotifyDroppedItemMove.cpp b/Code/encoding/Messages/NotifyDroppedItemMove.cpp new file mode 100644 index 0000000000..bee62053ae --- /dev/null +++ b/Code/encoding/Messages/NotifyDroppedItemMove.cpp @@ -0,0 +1,13 @@ +#include + +void NotifyDroppedItemMove::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Move.Serialize(aWriter); +} + +void NotifyDroppedItemMove::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + Move.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/NotifyDroppedItemMove.h b/Code/encoding/Messages/NotifyDroppedItemMove.h new file mode 100644 index 0000000000..db0e276580 --- /dev/null +++ b/Code/encoding/Messages/NotifyDroppedItemMove.h @@ -0,0 +1,25 @@ +#pragma once + +#include "Message.h" + +#include + +/** + * @brief Relays a dropped item's transform from the client that simulates its physics. + */ +struct NotifyDroppedItemMove final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyDroppedItemMove; + + NotifyDroppedItemMove() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyDroppedItemMove& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && Move == acRhs.Move; } + + DroppedItemMove Move{}; +}; diff --git a/Code/encoding/Messages/NotifyDroppedItemsRemove.cpp b/Code/encoding/Messages/NotifyDroppedItemsRemove.cpp new file mode 100644 index 0000000000..b00f896e44 --- /dev/null +++ b/Code/encoding/Messages/NotifyDroppedItemsRemove.cpp @@ -0,0 +1,21 @@ +#include +#include + +void NotifyDroppedItemsRemove::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, ServerIds.size()); + + for (const uint32_t serverId : ServerIds) + Serialization::WriteVarInt(aWriter, serverId); +} + +void NotifyDroppedItemsRemove::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + const auto count = Serialization::ReadVarInt(aReader); + ServerIds.resize(count); + + for (auto& serverId : ServerIds) + serverId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; +} diff --git a/Code/encoding/Messages/NotifyDroppedItemsRemove.h b/Code/encoding/Messages/NotifyDroppedItemsRemove.h new file mode 100644 index 0000000000..49e28a10b5 --- /dev/null +++ b/Code/encoding/Messages/NotifyDroppedItemsRemove.h @@ -0,0 +1,26 @@ +#pragma once + +#include "Message.h" + +using TiltedPhoques::Vector; + +/** + * @brief Tells a client to delete its copy of dropped items, because they were picked up, + * evicted, or went out of the client's range. + */ +struct NotifyDroppedItemsRemove final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyDroppedItemsRemove; + + NotifyDroppedItemsRemove() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyDroppedItemsRemove& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && ServerIds == acRhs.ServerIds; } + + Vector ServerIds{}; +}; diff --git a/Code/encoding/Messages/NotifyDroppedItemsSpawn.cpp b/Code/encoding/Messages/NotifyDroppedItemsSpawn.cpp new file mode 100644 index 0000000000..132977d6f7 --- /dev/null +++ b/Code/encoding/Messages/NotifyDroppedItemsSpawn.cpp @@ -0,0 +1,21 @@ +#include +#include + +void NotifyDroppedItemsSpawn::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, Items.size()); + + for (const auto& item : Items) + item.Serialize(aWriter); +} + +void NotifyDroppedItemsSpawn::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + const auto count = Serialization::ReadVarInt(aReader); + Items.resize(count); + + for (auto& item : Items) + item.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/NotifyDroppedItemsSpawn.h b/Code/encoding/Messages/NotifyDroppedItemsSpawn.h new file mode 100644 index 0000000000..33508a4ffd --- /dev/null +++ b/Code/encoding/Messages/NotifyDroppedItemsSpawn.h @@ -0,0 +1,27 @@ +#pragma once + +#include "Message.h" + +#include + +using TiltedPhoques::Vector; + +/** + * @brief Tells a client to spawn its copy of dropped items that came into its range. + */ +struct NotifyDroppedItemsSpawn final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyDroppedItemsSpawn; + + NotifyDroppedItemsSpawn() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyDroppedItemsSpawn& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && Items == acRhs.Items; } + + Vector Items{}; +}; diff --git a/Code/encoding/Messages/NotifyInventoryChanges.cpp b/Code/encoding/Messages/NotifyInventoryChanges.cpp index 88ba3e024d..45b3b9c7c6 100644 --- a/Code/encoding/Messages/NotifyInventoryChanges.cpp +++ b/Code/encoding/Messages/NotifyInventoryChanges.cpp @@ -6,7 +6,6 @@ void NotifyInventoryChanges::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter Serialization::WriteVarInt(aWriter, ServerId); Serialization::WriteVarInt(aWriter, OwnershipEpoch); Item.Serialize(aWriter); - Serialization::WriteBool(aWriter, Drop); } void NotifyInventoryChanges::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -16,5 +15,4 @@ void NotifyInventoryChanges::DeserializeRaw(TiltedPhoques::Buffer::Reader& aRead ServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; OwnershipEpoch = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; Item.Deserialize(aReader); - Drop = Serialization::ReadBool(aReader); } diff --git a/Code/encoding/Messages/NotifyInventoryChanges.h b/Code/encoding/Messages/NotifyInventoryChanges.h index cee0456cd4..2fcf6838e6 100644 --- a/Code/encoding/Messages/NotifyInventoryChanges.h +++ b/Code/encoding/Messages/NotifyInventoryChanges.h @@ -18,11 +18,10 @@ struct NotifyInventoryChanges final : ServerMessage bool operator==(const NotifyInventoryChanges& acRhs) const noexcept { - return GetOpcode() == acRhs.GetOpcode() && ServerId == acRhs.ServerId && OwnershipEpoch == acRhs.OwnershipEpoch && Item == acRhs.Item && Drop == acRhs.Drop; + return GetOpcode() == acRhs.GetOpcode() && ServerId == acRhs.ServerId && OwnershipEpoch == acRhs.OwnershipEpoch && Item == acRhs.Item; } uint32_t ServerId{}; uint32_t OwnershipEpoch{}; Inventory::Entry Item{}; - bool Drop = false; }; diff --git a/Code/encoding/Messages/NotifyInventorySnapshot.cpp b/Code/encoding/Messages/NotifyInventorySnapshot.cpp new file mode 100644 index 0000000000..033363d967 --- /dev/null +++ b/Code/encoding/Messages/NotifyInventorySnapshot.cpp @@ -0,0 +1,18 @@ +#include +#include + +void NotifyInventorySnapshot::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, ServerId); + Serialization::WriteVarInt(aWriter, OwnershipEpoch); + CurrentInventory.Serialize(aWriter); +} + +void NotifyInventorySnapshot::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + ServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + OwnershipEpoch = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + CurrentInventory.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/NotifyInventorySnapshot.h b/Code/encoding/Messages/NotifyInventorySnapshot.h new file mode 100644 index 0000000000..f178b68478 --- /dev/null +++ b/Code/encoding/Messages/NotifyInventorySnapshot.h @@ -0,0 +1,26 @@ +#pragma once + +#include "Message.h" +#include + +struct NotifyInventorySnapshot final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyInventorySnapshot; + + NotifyInventorySnapshot() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyInventorySnapshot& acRhs) const noexcept + { + return GetOpcode() == acRhs.GetOpcode() && ServerId == acRhs.ServerId && OwnershipEpoch == acRhs.OwnershipEpoch && CurrentInventory == acRhs.CurrentInventory; + } + + uint32_t ServerId{}; + uint32_t OwnershipEpoch{}; + Inventory CurrentInventory{}; +}; 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/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/NotifyRemoveObjects.cpp b/Code/encoding/Messages/NotifyRemoveObjects.cpp new file mode 100644 index 0000000000..2f2e91c2c4 --- /dev/null +++ b/Code/encoding/Messages/NotifyRemoveObjects.cpp @@ -0,0 +1,18 @@ +#include + +void NotifyRemoveObjects::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, ServerIds.size()); + for (const uint32_t serverId : ServerIds) + Serialization::WriteVarInt(aWriter, serverId); +} + +void NotifyRemoveObjects::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + const auto count = Serialization::ReadVarInt(aReader); + ServerIds.resize(count); + for (auto& serverId : ServerIds) + serverId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; +} diff --git a/Code/encoding/Messages/NotifyRemoveObjects.h b/Code/encoding/Messages/NotifyRemoveObjects.h new file mode 100644 index 0000000000..12e94d5427 --- /dev/null +++ b/Code/encoding/Messages/NotifyRemoveObjects.h @@ -0,0 +1,20 @@ +#pragma once + +#include "Message.h" + +struct NotifyRemoveObjects final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyRemoveObjects; + + NotifyRemoveObjects() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyRemoveObjects& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && ServerIds == acRhs.ServerIds; } + + TiltedPhoques::Vector ServerIds{}; +}; diff --git a/Code/encoding/Messages/NotifyScriptAnimation.cpp b/Code/encoding/Messages/NotifyScriptAnimation.cpp index 86f648a429..caf0dd66c9 100644 --- a/Code/encoding/Messages/NotifyScriptAnimation.cpp +++ b/Code/encoding/Messages/NotifyScriptAnimation.cpp @@ -5,6 +5,7 @@ void NotifyScriptAnimation::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) Serialization::WriteVarInt(aWriter, FormID); Animation.Serialize(aWriter); EventName.Serialize(aWriter); + Serialization::WriteBool(aWriter, IsSubGraph); } void NotifyScriptAnimation::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -14,4 +15,5 @@ void NotifyScriptAnimation::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReade FormID = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; Animation.Deserialize(aReader); EventName.Deserialize(aReader); + IsSubGraph = Serialization::ReadBool(aReader); } diff --git a/Code/encoding/Messages/NotifyScriptAnimation.h b/Code/encoding/Messages/NotifyScriptAnimation.h index 09a4d1fa0f..76d83272bb 100644 --- a/Code/encoding/Messages/NotifyScriptAnimation.h +++ b/Code/encoding/Messages/NotifyScriptAnimation.h @@ -15,9 +15,10 @@ struct NotifyScriptAnimation final : ServerMessage void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; - bool operator==(const NotifyScriptAnimation& acRhs) const noexcept { return FormID == acRhs.FormID && Animation == acRhs.Animation && EventName == acRhs.EventName && GetOpcode() == acRhs.GetOpcode(); } + bool operator==(const NotifyScriptAnimation& acRhs) const noexcept { return FormID == acRhs.FormID && Animation == acRhs.Animation && EventName == acRhs.EventName && IsSubGraph == acRhs.IsSubGraph && GetOpcode() == acRhs.GetOpcode(); } uint32_t FormID; CachedString Animation; CachedString EventName; + bool IsSubGraph{}; }; diff --git a/Code/encoding/Messages/NotifyTradeCancel.cpp b/Code/encoding/Messages/NotifyTradeCancel.cpp new file mode 100644 index 0000000000..6f066230eb --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeCancel.cpp @@ -0,0 +1,17 @@ +#include + +void NotifyTradeCancel::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, PartnerPlayerId); + Serialization::WriteVarInt(aWriter, static_cast(Reason)); + Serialization::WriteBool(aWriter, WasInitiator); +} + +void NotifyTradeCancel::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + PartnerPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + Reason = static_cast(Serialization::ReadVarInt(aReader)); + WasInitiator = Serialization::ReadBool(aReader); +} diff --git a/Code/encoding/Messages/NotifyTradeCancel.h b/Code/encoding/Messages/NotifyTradeCancel.h new file mode 100644 index 0000000000..67ab5edc2e --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeCancel.h @@ -0,0 +1,31 @@ +#pragma once + +#include "Message.h" + +enum class TradeCancelReason : uint8_t +{ + Declined = 0, + Cancelled, + PartnerBusy, + SelfBusy, + PlayerLeft, + Timeout, + FailedValidation +}; + +struct NotifyTradeCancel final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyTradeCancel; + + NotifyTradeCancel() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + uint32_t PartnerPlayerId{}; + TradeCancelReason Reason{TradeCancelReason::Cancelled}; + bool WasInitiator{false}; +}; diff --git a/Code/encoding/Messages/NotifyTradeComplete.cpp b/Code/encoding/Messages/NotifyTradeComplete.cpp new file mode 100644 index 0000000000..37e7984c80 --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeComplete.cpp @@ -0,0 +1,13 @@ +#include + +void NotifyTradeComplete::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, PartnerPlayerId); +} + +void NotifyTradeComplete::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + PartnerPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; +} diff --git a/Code/encoding/Messages/NotifyTradeComplete.h b/Code/encoding/Messages/NotifyTradeComplete.h new file mode 100644 index 0000000000..ad99ffe15e --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeComplete.h @@ -0,0 +1,18 @@ +#pragma once + +#include "Message.h" + +struct NotifyTradeComplete final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyTradeComplete; + + NotifyTradeComplete() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + uint32_t PartnerPlayerId{}; +}; diff --git a/Code/encoding/Messages/NotifyTradeInvite.cpp b/Code/encoding/Messages/NotifyTradeInvite.cpp new file mode 100644 index 0000000000..97730c57f0 --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeInvite.cpp @@ -0,0 +1,15 @@ +#include + +void NotifyTradeInvite::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, InviterPlayerId); + Serialization::WriteVarInt(aWriter, ExpiryTick); +} + +void NotifyTradeInvite::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + InviterPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + ExpiryTick = Serialization::ReadVarInt(aReader); +} diff --git a/Code/encoding/Messages/NotifyTradeInvite.h b/Code/encoding/Messages/NotifyTradeInvite.h new file mode 100644 index 0000000000..a8e97dd02c --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeInvite.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Message.h" + +struct NotifyTradeInvite final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyTradeInvite; + + NotifyTradeInvite() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + uint32_t InviterPlayerId{}; + uint64_t ExpiryTick{}; +}; diff --git a/Code/encoding/Messages/NotifyTradeStarted.cpp b/Code/encoding/Messages/NotifyTradeStarted.cpp new file mode 100644 index 0000000000..9542bed728 --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeStarted.cpp @@ -0,0 +1,15 @@ +#include + +void NotifyTradeStarted::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, PartnerPlayerId); + Serialization::WriteBool(aWriter, InitiatedBySelf); +} + +void NotifyTradeStarted::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + PartnerPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + InitiatedBySelf = Serialization::ReadBool(aReader); +} diff --git a/Code/encoding/Messages/NotifyTradeStarted.h b/Code/encoding/Messages/NotifyTradeStarted.h new file mode 100644 index 0000000000..9c083f45da --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeStarted.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Message.h" + +struct NotifyTradeStarted final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyTradeStarted; + + NotifyTradeStarted() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + uint32_t PartnerPlayerId{}; + bool InitiatedBySelf{false}; +}; diff --git a/Code/encoding/Messages/NotifyTradeState.cpp b/Code/encoding/Messages/NotifyTradeState.cpp new file mode 100644 index 0000000000..281d6ecceb --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeState.cpp @@ -0,0 +1,44 @@ +#include + +#include + +void NotifyTradeState::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, PartnerPlayerId); + Serialization::WriteBool(aWriter, SelfReady); + Serialization::WriteBool(aWriter, PartnerReady); + Serialization::WriteVarInt(aWriter, CountdownMs); + Serialization::WriteVarInt(aWriter, CountdownTotalMs); + + Serialization::WriteVarInt(aWriter, SelfItems.size()); + for (const auto& item : SelfItems) + item.Serialize(aWriter); + + Serialization::WriteVarInt(aWriter, PartnerItems.size()); + for (const auto& item : PartnerItems) + item.Serialize(aWriter); + + Serialization::WriteVarInt(aWriter, SelfInventory.size()); + for (const auto& item : SelfInventory) + item.Serialize(aWriter); + + Serialization::WriteVarInt(aWriter, SelfAcceptedItems.size()); + for (const auto& item : SelfAcceptedItems) + item.Serialize(aWriter); +} + +void NotifyTradeState::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + PartnerPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + SelfReady = Serialization::ReadBool(aReader); + PartnerReady = Serialization::ReadBool(aReader); + CountdownMs = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + CountdownTotalMs = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + + Decoding::ReadList(aReader, SelfItems, Inventory::kMaxEntries); + Decoding::ReadList(aReader, PartnerItems, Inventory::kMaxEntries); + Decoding::ReadList(aReader, SelfInventory, Inventory::kMaxEntries); + Decoding::ReadList(aReader, SelfAcceptedItems, Inventory::kMaxEntries); +} diff --git a/Code/encoding/Messages/NotifyTradeState.h b/Code/encoding/Messages/NotifyTradeState.h new file mode 100644 index 0000000000..5ade628b5c --- /dev/null +++ b/Code/encoding/Messages/NotifyTradeState.h @@ -0,0 +1,29 @@ +#pragma once + +#include "Message.h" + +#include + +struct NotifyTradeState final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyTradeState; + + NotifyTradeState() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + uint32_t PartnerPlayerId{}; + bool SelfReady{false}; + bool PartnerReady{false}; + uint32_t CountdownMs{0}; + uint32_t CountdownTotalMs{0}; + TiltedPhoques::Vector SelfItems; + TiltedPhoques::Vector PartnerItems; + TiltedPhoques::Vector SelfInventory; + // Own offered items the ready partner can receive; empty while the partner is not ready. + TiltedPhoques::Vector SelfAcceptedItems; +}; diff --git a/Code/encoding/Messages/NotifyVoiceData.cpp b/Code/encoding/Messages/NotifyVoiceData.cpp new file mode 100644 index 0000000000..ec4e033a0d --- /dev/null +++ b/Code/encoding/Messages/NotifyVoiceData.cpp @@ -0,0 +1,15 @@ +#include + +void NotifyVoiceData::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, PlayerId); + Frame.Serialize(aWriter); +} + +void NotifyVoiceData::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerMessage::DeserializeRaw(aReader); + + PlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + Frame.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/NotifyVoiceData.h b/Code/encoding/Messages/NotifyVoiceData.h new file mode 100644 index 0000000000..a706ebe000 --- /dev/null +++ b/Code/encoding/Messages/NotifyVoiceData.h @@ -0,0 +1,23 @@ +#pragma once + +#include "Message.h" +#include + +// Another player's voice, relayed by the server +struct NotifyVoiceData final : ServerMessage +{ + static constexpr ServerOpcode Opcode = kNotifyVoiceData; + + NotifyVoiceData() + : ServerMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const NotifyVoiceData& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && PlayerId == acRhs.PlayerId && Frame == acRhs.Frame; } + + uint32_t PlayerId{}; + VoiceFrame Frame{}; +}; diff --git a/Code/encoding/Messages/PickUpDroppedItemRequest.cpp b/Code/encoding/Messages/PickUpDroppedItemRequest.cpp new file mode 100644 index 0000000000..a5503986fc --- /dev/null +++ b/Code/encoding/Messages/PickUpDroppedItemRequest.cpp @@ -0,0 +1,14 @@ +#include +#include + +void PickUpDroppedItemRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, ServerId); +} + +void PickUpDroppedItemRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + ServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; +} diff --git a/Code/encoding/Messages/PickUpDroppedItemRequest.h b/Code/encoding/Messages/PickUpDroppedItemRequest.h new file mode 100644 index 0000000000..a19b41de49 --- /dev/null +++ b/Code/encoding/Messages/PickUpDroppedItemRequest.h @@ -0,0 +1,25 @@ +#pragma once + +#include "Message.h" + +/** + * @brief Sent when a local actor picks up a server-tracked dropped item. + */ +struct PickUpDroppedItemRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kPickUpDroppedItemRequest; + + PickUpDroppedItemRequest() + : ClientMessage(Opcode) + { + } + + virtual ~PickUpDroppedItemRequest() = default; + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const PickUpDroppedItemRequest& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && ServerId == acRhs.ServerId; } + + uint32_t ServerId{}; +}; 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/RequestInventoryChanges.cpp b/Code/encoding/Messages/RequestInventoryChanges.cpp index ba395cef4b..14d9b4b4f1 100644 --- a/Code/encoding/Messages/RequestInventoryChanges.cpp +++ b/Code/encoding/Messages/RequestInventoryChanges.cpp @@ -6,7 +6,6 @@ void RequestInventoryChanges::SerializeRaw(TiltedPhoques::Buffer::Writer& aWrite Serialization::WriteVarInt(aWriter, ServerId); Serialization::WriteVarInt(aWriter, OwnershipEpoch); Item.Serialize(aWriter); - Serialization::WriteBool(aWriter, Drop); Serialization::WriteBool(aWriter, UpdateClients); } @@ -17,6 +16,5 @@ void RequestInventoryChanges::DeserializeRaw(TiltedPhoques::Buffer::Reader& aRea ServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; OwnershipEpoch = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; Item.Deserialize(aReader); - Drop = Serialization::ReadBool(aReader); UpdateClients = Serialization::ReadBool(aReader); } diff --git a/Code/encoding/Messages/RequestInventoryChanges.h b/Code/encoding/Messages/RequestInventoryChanges.h index e125478f9f..562aabe10c 100644 --- a/Code/encoding/Messages/RequestInventoryChanges.h +++ b/Code/encoding/Messages/RequestInventoryChanges.h @@ -19,12 +19,11 @@ struct RequestInventoryChanges final : ClientMessage bool operator==(const RequestInventoryChanges& acRhs) const noexcept { - return GetOpcode() == acRhs.GetOpcode() && ServerId == acRhs.ServerId && OwnershipEpoch == acRhs.OwnershipEpoch && Item == acRhs.Item && Drop == acRhs.Drop && UpdateClients == acRhs.UpdateClients; + return GetOpcode() == acRhs.GetOpcode() && ServerId == acRhs.ServerId && OwnershipEpoch == acRhs.OwnershipEpoch && Item == acRhs.Item && UpdateClients == acRhs.UpdateClients; } uint32_t ServerId{}; uint32_t OwnershipEpoch{}; Inventory::Entry Item{}; - bool Drop = false; bool UpdateClients = true; }; diff --git a/Code/encoding/Messages/RequestInventorySnapshot.cpp b/Code/encoding/Messages/RequestInventorySnapshot.cpp new file mode 100644 index 0000000000..8e7c34ed21 --- /dev/null +++ b/Code/encoding/Messages/RequestInventorySnapshot.cpp @@ -0,0 +1,18 @@ +#include +#include + +void RequestInventorySnapshot::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, ServerId); + Serialization::WriteVarInt(aWriter, OwnershipEpoch); + CurrentInventory.Serialize(aWriter); +} + +void RequestInventorySnapshot::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + ServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + OwnershipEpoch = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + CurrentInventory.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/RequestInventorySnapshot.h b/Code/encoding/Messages/RequestInventorySnapshot.h new file mode 100644 index 0000000000..faf603e009 --- /dev/null +++ b/Code/encoding/Messages/RequestInventorySnapshot.h @@ -0,0 +1,28 @@ +#pragma once + +#include "Message.h" +#include + +struct RequestInventorySnapshot final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kRequestInventorySnapshot; + + RequestInventorySnapshot() + : ClientMessage(Opcode) + { + } + + virtual ~RequestInventorySnapshot() = default; + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const RequestInventorySnapshot& acRhs) const noexcept + { + return GetOpcode() == acRhs.GetOpcode() && ServerId == acRhs.ServerId && OwnershipEpoch == acRhs.OwnershipEpoch && CurrentInventory == acRhs.CurrentInventory; + } + + uint32_t ServerId{}; + uint32_t OwnershipEpoch{}; + Inventory CurrentInventory{}; +}; diff --git a/Code/encoding/Messages/RequestTimeSkip.cpp b/Code/encoding/Messages/RequestTimeSkip.cpp new file mode 100644 index 0000000000..c3b940f806 --- /dev/null +++ b/Code/encoding/Messages/RequestTimeSkip.cpp @@ -0,0 +1,17 @@ +#include "RequestTimeSkip.h" + +#include + +using TiltedPhoques::Serialization; + +void RequestTimeSkip::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteFloat(aWriter, Hours); +} + +void RequestTimeSkip::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + Hours = Serialization::ReadFloat(aReader); +} diff --git a/Code/encoding/Messages/RequestTimeSkip.h b/Code/encoding/Messages/RequestTimeSkip.h new file mode 100644 index 0000000000..231357a341 --- /dev/null +++ b/Code/encoding/Messages/RequestTimeSkip.h @@ -0,0 +1,25 @@ +#pragma once + +#include "Message.h" + +/** + * Sent when the local player finished sleeping or waiting, so that the server can advance the shared + * game time by the same amount for everyone (#467). + */ +struct RequestTimeSkip final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kRequestTimeSkip; + + RequestTimeSkip() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const RequestTimeSkip& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && Hours == acRhs.Hours; } + + // Game hours to skip, as requested in the sleep/wait menu. + float Hours{}; +}; diff --git a/Code/encoding/Messages/ScriptAnimationRequest.cpp b/Code/encoding/Messages/ScriptAnimationRequest.cpp index 4d5691911c..cc7791ba58 100644 --- a/Code/encoding/Messages/ScriptAnimationRequest.cpp +++ b/Code/encoding/Messages/ScriptAnimationRequest.cpp @@ -5,6 +5,7 @@ void ScriptAnimationRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter Serialization::WriteVarInt(aWriter, FormID); Animation.Serialize(aWriter); EventName.Serialize(aWriter); + Serialization::WriteBool(aWriter, IsSubGraph); } void ScriptAnimationRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -14,4 +15,5 @@ void ScriptAnimationRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aRead FormID = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; Animation.Deserialize(aReader); EventName.Deserialize(aReader); + IsSubGraph = Serialization::ReadBool(aReader); } diff --git a/Code/encoding/Messages/ScriptAnimationRequest.h b/Code/encoding/Messages/ScriptAnimationRequest.h index 56cc7d1e57..0b85cb6eb2 100644 --- a/Code/encoding/Messages/ScriptAnimationRequest.h +++ b/Code/encoding/Messages/ScriptAnimationRequest.h @@ -15,9 +15,10 @@ struct ScriptAnimationRequest final : ClientMessage void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; - bool operator==(const ScriptAnimationRequest& acRhs) const noexcept { return FormID == acRhs.FormID && Animation == acRhs.Animation && EventName == acRhs.EventName && GetOpcode() == acRhs.GetOpcode(); } + bool operator==(const ScriptAnimationRequest& acRhs) const noexcept { return FormID == acRhs.FormID && Animation == acRhs.Animation && EventName == acRhs.EventName && IsSubGraph == acRhs.IsSubGraph && GetOpcode() == acRhs.GetOpcode(); } uint32_t FormID; CachedString Animation; CachedString EventName; + bool IsSubGraph{}; }; 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.cpp b/Code/encoding/Messages/ServerMessageFactory.cpp index cc77392de9..67c0d34838 100644 --- a/Code/encoding/Messages/ServerMessageFactory.cpp +++ b/Code/encoding/Messages/ServerMessageFactory.cpp @@ -1,6 +1,7 @@ #pragma once #include +#include static std::function(TiltedPhoques::Buffer::Reader& aReader)> s_serverMessageExtractor[kServerOpcodeMax]; @@ -14,10 +15,12 @@ static struct ServerMessageFactoryInit { using T = typename std::remove_reference_t::Type; - s_serverMessageExtractor[T::Opcode] = [](TiltedPhoques::Buffer::Reader& aReader) + s_serverMessageExtractor[T::Opcode] = [](TiltedPhoques::Buffer::Reader& aReader) -> UniquePtr { auto ptr = TiltedPhoques::MakeUnique(); ptr->DeserializeRaw(aReader); + if (Decoding::IsMalformed(aReader)) [[unlikely]] + return {nullptr}; return TiltedPhoques::CastUnique(std::move(ptr)); }; @@ -38,5 +41,9 @@ UniquePtr ServerMessageFactory::Extract(TiltedPhoques::Buffer::Re return {nullptr}; const auto opcode = static_cast(data); + // Calling an empty std::function throws, so opcodes without a registered message are dropped here. + if (!s_serverMessageExtractor[opcode]) [[unlikely]] + return {nullptr}; + return s_serverMessageExtractor[opcode](aReader); } diff --git a/Code/encoding/Messages/ServerMessageFactory.h b/Code/encoding/Messages/ServerMessageFactory.h index 516878b1d9..7022b2deed 100644 --- a/Code/encoding/Messages/ServerMessageFactory.h +++ b/Code/encoding/Messages/ServerMessageFactory.h @@ -18,6 +18,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -39,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +56,7 @@ #include #include #include +#include #include #include #include @@ -59,6 +66,13 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include using TiltedPhoques::UniquePtr; @@ -69,11 +83,10 @@ struct ServerMessageFactory template static auto Visit(T&& func) { auto s_visitor = CreateMessageVisitor< - AuthenticationResponse, AssignCharacterResponse, ServerReferencesMoveRequest, ServerTimeSettings, CharacterSpawnRequest, NotifyInventoryChanges, StringCacheUpdate, NotifyFactionsChanges, NotifyRemoveCharacter, NotifyQuestUpdate, NotifyPlayerList, NotifyPartyInfo, NotifyPartyInvite, - NotifyActorValueChanges, NotifyPartyJoined, NotifyPartyLeft, NotifyActorMaxValueChanges, NotifyHealthChangeBroadcast, NotifyActivate, NotifyLockChange, AssignObjectsResponse, NotifyDeathStateChange, NotifyOwnershipTransfer, NotifyObjectInventoryChanges, NotifySpellCast, + 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>; + NotifyWeatherChange, NotifySetWaypoint, NotifyRemoveWaypoint, NotifySetTimeResult, NotifyRemoveSpell, NotifyActionEvents, NotifyRemoveObjects, DropItemResponse, NotifyDroppedItemsSpawn, NotifyDroppedItemMove, NotifyDroppedItemsRemove, NotifyVoiceData, NotifyModMessage, NotifyPlayerPerks>; return s_visitor(std::forward(func)); } diff --git a/Code/encoding/Messages/TradeCancelRequest.cpp b/Code/encoding/Messages/TradeCancelRequest.cpp new file mode 100644 index 0000000000..b0136223be --- /dev/null +++ b/Code/encoding/Messages/TradeCancelRequest.cpp @@ -0,0 +1,10 @@ +#include + +void TradeCancelRequest::SerializeRaw(TiltedPhoques::Buffer::Writer&) const noexcept +{ +} + +void TradeCancelRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); +} diff --git a/Code/encoding/Messages/TradeCancelRequest.h b/Code/encoding/Messages/TradeCancelRequest.h new file mode 100644 index 0000000000..aeca15abb8 --- /dev/null +++ b/Code/encoding/Messages/TradeCancelRequest.h @@ -0,0 +1,16 @@ +#pragma once + +#include "Message.h" + +struct TradeCancelRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kTradeCancelRequest; + + TradeCancelRequest() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; +}; diff --git a/Code/encoding/Messages/TradeInventorySyncRequest.cpp b/Code/encoding/Messages/TradeInventorySyncRequest.cpp new file mode 100644 index 0000000000..c618334ae0 --- /dev/null +++ b/Code/encoding/Messages/TradeInventorySyncRequest.cpp @@ -0,0 +1,17 @@ +#include + +#include + +void TradeInventorySyncRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, Entries.size()); + for (const auto& entry : Entries) + entry.Serialize(aWriter); +} + +void TradeInventorySyncRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + Decoding::ReadList(aReader, Entries, Inventory::kMaxEntries); +} diff --git a/Code/encoding/Messages/TradeInventorySyncRequest.h b/Code/encoding/Messages/TradeInventorySyncRequest.h new file mode 100644 index 0000000000..fe5b2b0507 --- /dev/null +++ b/Code/encoding/Messages/TradeInventorySyncRequest.h @@ -0,0 +1,21 @@ +#pragma once + +#include "Message.h" +#include + +// The trading player's live inventory. Incremental inventory events miss changes made in place, +// such as enchanting, renaming or brewing, so trades work from this snapshot. +struct TradeInventorySyncRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kTradeInventorySyncRequest; + + TradeInventorySyncRequest() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + Vector Entries; +}; diff --git a/Code/encoding/Messages/TradeInviteRequest.cpp b/Code/encoding/Messages/TradeInviteRequest.cpp new file mode 100644 index 0000000000..ce97a4c324 --- /dev/null +++ b/Code/encoding/Messages/TradeInviteRequest.cpp @@ -0,0 +1,13 @@ +#include + +void TradeInviteRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, TargetPlayerId); +} + +void TradeInviteRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + TargetPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; +} diff --git a/Code/encoding/Messages/TradeInviteRequest.h b/Code/encoding/Messages/TradeInviteRequest.h new file mode 100644 index 0000000000..87b31f74ff --- /dev/null +++ b/Code/encoding/Messages/TradeInviteRequest.h @@ -0,0 +1,18 @@ +#pragma once + +#include "Message.h" + +struct TradeInviteRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kTradeInviteRequest; + + TradeInviteRequest() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + uint32_t TargetPlayerId{}; +}; diff --git a/Code/encoding/Messages/TradeInviteResponseRequest.cpp b/Code/encoding/Messages/TradeInviteResponseRequest.cpp new file mode 100644 index 0000000000..d1f1f368e9 --- /dev/null +++ b/Code/encoding/Messages/TradeInviteResponseRequest.cpp @@ -0,0 +1,15 @@ +#include + +void TradeInviteResponseRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, RequesterPlayerId); + Serialization::WriteBool(aWriter, Accept); +} + +void TradeInviteResponseRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + RequesterPlayerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + Accept = Serialization::ReadBool(aReader); +} diff --git a/Code/encoding/Messages/TradeInviteResponseRequest.h b/Code/encoding/Messages/TradeInviteResponseRequest.h new file mode 100644 index 0000000000..b954fd90c4 --- /dev/null +++ b/Code/encoding/Messages/TradeInviteResponseRequest.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Message.h" + +struct TradeInviteResponseRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kTradeInviteResponseRequest; + + TradeInviteResponseRequest() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + uint32_t RequesterPlayerId{}; + bool Accept{false}; +}; diff --git a/Code/encoding/Messages/TradeOfferUpdateRequest.cpp b/Code/encoding/Messages/TradeOfferUpdateRequest.cpp new file mode 100644 index 0000000000..6d5d97c4e7 --- /dev/null +++ b/Code/encoding/Messages/TradeOfferUpdateRequest.cpp @@ -0,0 +1,17 @@ +#include + +#include + +void TradeOfferUpdateRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, Items.size()); + for (const auto& item : Items) + item.Serialize(aWriter); +} + +void TradeOfferUpdateRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + Decoding::ReadList(aReader, Items, Inventory::kMaxEntries); +} diff --git a/Code/encoding/Messages/TradeOfferUpdateRequest.h b/Code/encoding/Messages/TradeOfferUpdateRequest.h new file mode 100644 index 0000000000..5467619b15 --- /dev/null +++ b/Code/encoding/Messages/TradeOfferUpdateRequest.h @@ -0,0 +1,20 @@ +#pragma once + +#include "Message.h" + +#include + +struct TradeOfferUpdateRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kTradeOfferUpdateRequest; + + TradeOfferUpdateRequest() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + TiltedPhoques::Vector Items; +}; diff --git a/Code/encoding/Messages/TradeSetReadyRequest.cpp b/Code/encoding/Messages/TradeSetReadyRequest.cpp new file mode 100644 index 0000000000..d0808d4b6d --- /dev/null +++ b/Code/encoding/Messages/TradeSetReadyRequest.cpp @@ -0,0 +1,19 @@ +#include + +#include + +void TradeSetReadyRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteBool(aWriter, Ready); + Serialization::WriteVarInt(aWriter, AcceptedItems.size()); + for (const auto& item : AcceptedItems) + item.Serialize(aWriter); +} + +void TradeSetReadyRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + Ready = Serialization::ReadBool(aReader); + Decoding::ReadList(aReader, AcceptedItems, Inventory::kMaxEntries); +} diff --git a/Code/encoding/Messages/TradeSetReadyRequest.h b/Code/encoding/Messages/TradeSetReadyRequest.h new file mode 100644 index 0000000000..129bf0c179 --- /dev/null +++ b/Code/encoding/Messages/TradeSetReadyRequest.h @@ -0,0 +1,20 @@ +#pragma once + +#include "Message.h" +#include + +struct TradeSetReadyRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kTradeSetReadyRequest; + + TradeSetReadyRequest() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool Ready{false}; + Vector AcceptedItems; +}; diff --git a/Code/encoding/Messages/VoiceDataRequest.cpp b/Code/encoding/Messages/VoiceDataRequest.cpp new file mode 100644 index 0000000000..24fededade --- /dev/null +++ b/Code/encoding/Messages/VoiceDataRequest.cpp @@ -0,0 +1,13 @@ +#include + +void VoiceDataRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Frame.Serialize(aWriter); +} + +void VoiceDataRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + Frame.Deserialize(aReader); +} diff --git a/Code/encoding/Messages/VoiceDataRequest.h b/Code/encoding/Messages/VoiceDataRequest.h new file mode 100644 index 0000000000..dbbb83a630 --- /dev/null +++ b/Code/encoding/Messages/VoiceDataRequest.h @@ -0,0 +1,22 @@ +#pragma once + +#include "Message.h" +#include + +// The local player's voice, sent unreliably, the server relays it to players in range +struct VoiceDataRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kVoiceDataRequest; + + VoiceDataRequest() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const VoiceDataRequest& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && Frame == acRhs.Frame; } + + VoiceFrame Frame{}; +}; diff --git a/Code/encoding/Messages/VoiceStateRequest.cpp b/Code/encoding/Messages/VoiceStateRequest.cpp new file mode 100644 index 0000000000..fcb9e69cf5 --- /dev/null +++ b/Code/encoding/Messages/VoiceStateRequest.cpp @@ -0,0 +1,17 @@ +#include + +void VoiceStateRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteBool(aWriter, Listening); + aWriter.WriteBits(ListenChannel, 8); +} + +void VoiceStateRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ClientMessage::DeserializeRaw(aReader); + + Listening = Serialization::ReadBool(aReader); + uint64_t channel = 0; + aReader.ReadBits(channel, 8); + ListenChannel = channel < kChannelCount ? static_cast(channel) : kEveryone; +} diff --git a/Code/encoding/Messages/VoiceStateRequest.h b/Code/encoding/Messages/VoiceStateRequest.h new file mode 100644 index 0000000000..3ff6bc4b04 --- /dev/null +++ b/Code/encoding/Messages/VoiceStateRequest.h @@ -0,0 +1,31 @@ +#pragma once + +#include "Message.h" + +// Which voices the local player wants, so the server only relays what will be played +struct VoiceStateRequest final : ClientMessage +{ + static constexpr ClientOpcode Opcode = kVoiceStateRequest; + + enum Channel : uint8_t + { + kEveryone = 0, + kParty, + kStrangers, + kChannelCount + }; + + VoiceStateRequest() + : ClientMessage(Opcode) + { + } + + void SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const noexcept override; + void DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept override; + + bool operator==(const VoiceStateRequest& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && Listening == acRhs.Listening && ListenChannel == acRhs.ListenChannel; } + + // False while the player has voip off, nothing is relayed to them + bool Listening{}; + Channel ListenChannel{kEveryone}; +}; diff --git a/Code/encoding/Opcodes.h b/Code/encoding/Opcodes.h index baab493ccb..ac1cd77679 100644 --- a/Code/encoding/Opcodes.h +++ b/Code/encoding/Opcodes.h @@ -27,6 +27,7 @@ enum ClientOpcode : unsigned char kRequestOwnershipClaim, kRequestObjectInventoryChanges, kRequestInventoryChanges, + kRequestInventorySnapshot, kSpellCastRequest, kInterruptCastRequest, kAddTargetRequest, @@ -54,6 +55,20 @@ enum ClientOpcode : unsigned char kRequestRemoveWaypoint, kSetTimeCommandRequest, kRequestActionEvents, + kTradeInviteRequest, + kTradeInviteResponseRequest, + kTradeOfferUpdateRequest, + kTradeSetReadyRequest, + kTradeCancelRequest, + kTradeInventorySyncRequest, + kRequestTimeSkip, + kDropItemRequest, + kDroppedItemMoveRequest, + kPickUpDroppedItemRequest, + kVoiceDataRequest, + kVoiceStateRequest, + kSendModMessageRequest, + kPlayerPerksRequest, kClientOpcodeMax }; @@ -82,6 +97,7 @@ enum ServerOpcode : unsigned char kNotifyOwnershipTransfer, kNotifyObjectInventoryChanges, kNotifyInventoryChanges, + kNotifyInventorySnapshot, kNotifySpellCast, kNotifyInterruptCast, kNotifyAddTarget, @@ -114,5 +130,18 @@ enum ServerOpcode : unsigned char kNotifyRemoveWaypoint, kNotifySetTimeResult, kNotifyActionEvents, + kNotifyTradeInvite, + kNotifyTradeStarted, + kNotifyTradeState, + kNotifyTradeCancel, + kNotifyTradeComplete, + kNotifyRemoveObjects, + kDropItemResponse, + kNotifyDroppedItemsSpawn, + kNotifyDroppedItemMove, + kNotifyDroppedItemsRemove, + kNotifyVoiceData, + kNotifyModMessage, + kNotifyPlayerPerks, kServerOpcodeMax }; diff --git a/Code/encoding/Structs/AnimationVariables.cpp b/Code/encoding/Structs/AnimationVariables.cpp index b968f4de13..0d99af8c09 100644 --- a/Code/encoding/Structs/AnimationVariables.cpp +++ b/Code/encoding/Structs/AnimationVariables.cpp @@ -78,10 +78,15 @@ void AnimationVariables::GenerateDiff(const AnimationVariables& aPrevious, Tilte auto changedVector = Booleans; changedVector.reserve(sizeChangedVector); + // When the sizes differ the receiver starts from zeros (see ApplyDiff), so only nonzero values have to be sent. + // Slots of variables a behavior doesn't have stay zero and cost a bit each. + const bool cSameIntegers = aPrevious.Integers.size() == Integers.size(); + const bool cSameFloats = aPrevious.Floats.size() == Floats.size(); + for (size_t i = 0; i < Integers.size(); i++) - changedVector.push_back(aPrevious.Integers.size() != Integers.size() || aPrevious.Integers[i] != Integers[i]); + changedVector.push_back(Integers[i] != (cSameIntegers ? aPrevious.Integers[i] : 0)); for (size_t i = 0; i < Floats.size(); i++) - changedVector.push_back(aPrevious.Floats.size() != Floats.size() || aPrevious.Floats[i] != Floats[i]); + changedVector.push_back(Floats[i] != (cSameFloats ? aPrevious.Floats[i] : 0.f)); // Now serialize: VarInts Booleans.size(), Integers.size(), Floats.size(), // then the change table bits, then changed Integers, then changed Floats. diff --git a/Code/encoding/Structs/DroppedItemData.cpp b/Code/encoding/Structs/DroppedItemData.cpp new file mode 100644 index 0000000000..f17198c30e --- /dev/null +++ b/Code/encoding/Structs/DroppedItemData.cpp @@ -0,0 +1,84 @@ +#include +#include + +using TiltedPhoques::Serialization; + +bool DroppedItemTransform::operator==(const DroppedItemTransform& acRhs) const noexcept +{ + return Position == acRhs.Position && Rotation == acRhs.Rotation; +} + +bool DroppedItemTransform::operator!=(const DroppedItemTransform& acRhs) const noexcept +{ + return !this->operator==(acRhs); +} + +void DroppedItemTransform::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Position.Serialize(aWriter); + Serialization::WriteFloat(aWriter, Rotation.x); + Serialization::WriteFloat(aWriter, Rotation.y); + Serialization::WriteFloat(aWriter, Rotation.z); +} + +void DroppedItemTransform::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + Position.Deserialize(aReader); + Rotation.x = Serialization::ReadFloat(aReader); + Rotation.y = Serialization::ReadFloat(aReader); + Rotation.z = Serialization::ReadFloat(aReader); +} + +bool DroppedItemMove::operator==(const DroppedItemMove& acRhs) const noexcept +{ + return ServerId == acRhs.ServerId && Transform == acRhs.Transform && IsAtRest == acRhs.IsAtRest; +} + +bool DroppedItemMove::operator!=(const DroppedItemMove& acRhs) const noexcept +{ + return !this->operator==(acRhs); +} + +void DroppedItemMove::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, ServerId); + Transform.Serialize(aWriter); + Serialization::WriteBool(aWriter, IsAtRest); +} + +void DroppedItemMove::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + Transform.Deserialize(aReader); + IsAtRest = Serialization::ReadBool(aReader); +} + +bool DroppedItemData::operator==(const DroppedItemData& acRhs) const noexcept +{ + return ServerId == acRhs.ServerId && Item == acRhs.Item && CellId == acRhs.CellId && WorldSpaceId == acRhs.WorldSpaceId && Transform == acRhs.Transform && IsAtRest == acRhs.IsAtRest; +} + +bool DroppedItemData::operator!=(const DroppedItemData& acRhs) const noexcept +{ + return !this->operator==(acRhs); +} + +void DroppedItemData::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + Serialization::WriteVarInt(aWriter, ServerId); + Item.Serialize(aWriter); + CellId.Serialize(aWriter); + WorldSpaceId.Serialize(aWriter); + Transform.Serialize(aWriter); + Serialization::WriteBool(aWriter, IsAtRest); +} + +void DroppedItemData::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + ServerId = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; + Item.Deserialize(aReader); + CellId.Deserialize(aReader); + WorldSpaceId.Deserialize(aReader); + Transform.Deserialize(aReader); + IsAtRest = Serialization::ReadBool(aReader); +} diff --git a/Code/encoding/Structs/DroppedItemData.h b/Code/encoding/Structs/DroppedItemData.h new file mode 100644 index 0000000000..ccc007e088 --- /dev/null +++ b/Code/encoding/Structs/DroppedItemData.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +/** + * @brief Transform of a dropped item reference in the world. + */ +struct DroppedItemTransform +{ + bool operator==(const DroppedItemTransform& acRhs) const noexcept; + bool operator!=(const DroppedItemTransform& acRhs) const noexcept; + + void Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept; + void Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept; + + Vector3_NetQuantize Position{}; + // Euler angles in radians, as stored on the reference. + glm::vec3 Rotation{}; +}; + +/** + * @brief Transform update of a dropped item, streamed by the client that simulates its physics. + */ +struct DroppedItemMove +{ + bool operator==(const DroppedItemMove& acRhs) const noexcept; + bool operator!=(const DroppedItemMove& acRhs) const noexcept; + + void Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept; + void Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept; + + uint32_t ServerId{}; + DroppedItemTransform Transform{}; + // Set on the last update, once the item has settled. + bool IsAtRest{}; +}; + +/** + * @brief A dropped item tracked by the server, sent to clients so they can spawn their own copy. + */ +struct DroppedItemData +{ + bool operator==(const DroppedItemData& acRhs) const noexcept; + bool operator!=(const DroppedItemData& acRhs) const noexcept; + + void Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept; + void Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept; + + uint32_t ServerId{}; + Inventory::Entry Item{}; + GameId CellId{}; + GameId WorldSpaceId{}; + DroppedItemTransform Transform{}; + // False while the dropping client is still streaming the item's physics. + bool IsAtRest{}; +}; diff --git a/Code/encoding/Structs/Inventory.cpp b/Code/encoding/Structs/Inventory.cpp index 8221c63825..d1eb177590 100644 --- a/Code/encoding/Structs/Inventory.cpp +++ b/Code/encoding/Structs/Inventory.cpp @@ -1,8 +1,53 @@ #include +#include #include +#include +#include using TiltedPhoques::Serialization; +bool Inventory::PotionData::operator==(const PotionData& acRhs) const noexcept +{ + return IsPoison == acRhs.IsPoison && Effects.size() == acRhs.Effects.size() && + std::is_permutation(Effects.begin(), Effects.end(), acRhs.Effects.begin()); +} + +bool Inventory::PotionData::IsValid() const noexcept +{ + if (Effects.empty() || Effects.size() > kMaxEffects) + return false; + for (const auto& effect : Effects) + if (!std::isfinite(effect.Magnitude) || effect.Magnitude < 0 || !std::isfinite(effect.RawCost) || effect.RawCost < 0 || + effect.Area < 0 || effect.Duration < 0 || effect.EffectId.ModId == 0xFFFFFFFF || !effect.EffectId.BaseId) + return false; + return true; +} + +void Inventory::PotionData::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + // Most entries are not crafted potions, so a single bit stands in for an empty recipe. + const bool cPresent = IsPoison || !Effects.empty(); + Serialization::WriteBool(aWriter, cPresent); + if (!cPresent) + return; + + Serialization::WriteBool(aWriter, IsPoison); + Serialization::WriteVarInt(aWriter, Effects.size()); + for (const auto& effect : Effects) + effect.Serialize(aWriter); +} + +void Inventory::PotionData::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + IsPoison = false; + Effects.clear(); + if (!Serialization::ReadBool(aReader)) + return; + + IsPoison = Serialization::ReadBool(aReader); + Decoding::ReadList(aReader, Effects, kMaxEffects); +} + void Inventory::EffectItem::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept { Serialization::WriteFloat(aWriter, Magnitude); @@ -48,6 +93,8 @@ void Inventory::Entry::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const n Serialization::WriteBool(aWriter, ExtraWorn); Serialization::WriteBool(aWriter, ExtraWornLeft); Serialization::WriteBool(aWriter, IsQuestItem); + Potion.Serialize(aWriter); + PoisonData.Serialize(aWriter); } void Inventory::Entry::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -59,13 +106,7 @@ void Inventory::Entry::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexc ExtraEnchantId.Deserialize(aReader); ExtraEnchantCharge = Serialization::ReadVarInt(aReader) & 0xFFFF; - uint64_t effectCount = Serialization::ReadVarInt(aReader); - for (uint64_t i = 0; i < effectCount; i++) - { - EffectItem effect; - effect.Deserialize(aReader); - EnchantData.Effects.push_back(effect); - } + Decoding::ReadList(aReader, EnchantData.Effects, kMaxEffects); ExtraHealth = Serialization::ReadFloat(aReader); @@ -79,6 +120,8 @@ void Inventory::Entry::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexc ExtraWorn = Serialization::ReadBool(aReader); ExtraWornLeft = Serialization::ReadBool(aReader); IsQuestItem = Serialization::ReadBool(aReader); + Potion.Deserialize(aReader); + PoisonData.Deserialize(aReader); } bool Inventory::operator==(const Inventory& acRhs) const noexcept @@ -93,7 +136,7 @@ bool Inventory::operator!=(const Inventory& acRhs) const noexcept bool Inventory::Entry::operator==(const Inventory::Entry& acRhs) const noexcept { - return BaseId == acRhs.BaseId && Count == acRhs.Count && IsExtraDataEquals(acRhs); + return SameBase(acRhs) && Count == acRhs.Count && IsExtraDataEquals(acRhs); } bool Inventory::Entry::operator!=(const Inventory::Entry& acRhs) const noexcept @@ -114,13 +157,7 @@ void Inventory::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept void Inventory::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept { - uint32_t count = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; - for (uint32_t i = 0; i < count; i++) - { - Entry entry; - entry.Deserialize(aReader); - Entries.push_back(entry); - } + Decoding::ReadList(aReader, Entries, kMaxEntries); CurrentMagicEquipment.Deserialize(aReader); } @@ -202,4 +239,9 @@ bool Inventory::ContainsQuestItems() const noexcept return true; } return false; -} \ No newline at end of file +} + +bool Inventory::HasWornItems() const noexcept +{ + return std::any_of(std::begin(Entries), std::end(Entries), [](const Entry& acEntry) { return acEntry.IsWorn(); }); +} diff --git a/Code/encoding/Structs/Inventory.h b/Code/encoding/Structs/Inventory.h index 72a4d0a639..cab9fee9e2 100644 --- a/Code/encoding/Structs/Inventory.h +++ b/Code/encoding/Structs/Inventory.h @@ -8,6 +8,10 @@ using TiltedPhoques::Vector; struct Inventory { + // Decoding limits for data read from the network. + static constexpr uint64_t kMaxEffects = 32; + static constexpr uint64_t kMaxEntries = 1 << 14; + struct EffectItem { float Magnitude{}; @@ -15,6 +19,7 @@ struct Inventory int32_t Duration{}; float RawCost{}; GameId EffectId{}; + bool operator==(const EffectItem&) const noexcept = default; void Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept; void Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept; @@ -26,6 +31,18 @@ struct Inventory Vector Effects{}; }; + struct PotionData + { + bool IsPoison{}; + // Engine order is kept so recreation matches the original potion; comparison ignores order. + Vector Effects{}; + bool operator==(const PotionData& acRhs) const noexcept; + bool IsValid() const noexcept; + + void Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept; + void Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept; + }; + struct Entry { GameId BaseId{}; @@ -36,11 +53,14 @@ struct Inventory GameId ExtraEnchantId{}; uint16_t ExtraEnchantCharge{}; EnchantmentData EnchantData{}; + PotionData Potion{}; float ExtraHealth{}; GameId ExtraPoisonId{}; uint32_t ExtraPoisonCount{}; + // Recipe of an applied player-crafted poison, whose temporary ID is only valid for its owner. + PotionData PoisonData{}; int32_t ExtraSoulLevel{}; @@ -57,17 +77,31 @@ struct Inventory bool ContainsExtraData() const noexcept { return !IsExtraDataEquals(Entry{}); } - bool CanBeMerged(const Entry& acRhs) const noexcept { return BaseId == acRhs.BaseId && IsExtraDataEquals(acRhs); } + // Crafted potion identity is its recipe, not another client's temporary form ID. + bool SameBase(const Entry& acRhs) const noexcept + { + if (!Potion.Effects.empty() || !acRhs.Potion.Effects.empty()) + return BaseId.ModId == 0xFFFFFFFF && acRhs.BaseId.ModId == 0xFFFFFFFF && Potion == acRhs.Potion; + return BaseId == acRhs.BaseId; + } + bool CanBeMerged(const Entry& acRhs) const noexcept { return SameBase(acRhs) && IsExtraDataEquals(acRhs); } bool IsExtraDataEquals(const Entry& acRhs) const noexcept { // TODO: the whole server side state thing is very flawed // since many of these things can and will change, like poison id or charge // or the fact that the enchant id can be temp - return ExtraCharge == acRhs.ExtraCharge && ExtraEnchantId == acRhs.ExtraEnchantId && ExtraEnchantCharge == acRhs.ExtraEnchantCharge && ExtraEnchantRemoveUnequip == acRhs.ExtraEnchantRemoveUnequip && ExtraHealth == acRhs.ExtraHealth && ExtraPoisonId == acRhs.ExtraPoisonId && + return ExtraCharge == acRhs.ExtraCharge && ExtraEnchantId == acRhs.ExtraEnchantId && ExtraEnchantCharge == acRhs.ExtraEnchantCharge && ExtraEnchantRemoveUnequip == acRhs.ExtraEnchantRemoveUnequip && ExtraHealth == acRhs.ExtraHealth && IsSamePoison(acRhs) && ExtraPoisonCount == acRhs.ExtraPoisonCount && ExtraSoulLevel == acRhs.ExtraSoulLevel && ExtraWorn == acRhs.ExtraWorn && ExtraWornLeft == acRhs.ExtraWornLeft && IsQuestItem == acRhs.IsQuestItem; } + bool IsSamePoison(const Entry& acRhs) const noexcept + { + if (!PoisonData.Effects.empty() || !acRhs.PoisonData.Effects.empty()) + return ExtraPoisonId.ModId == 0xFFFFFFFF && acRhs.ExtraPoisonId.ModId == 0xFFFFFFFF && PoisonData == acRhs.PoisonData; + return ExtraPoisonId == acRhs.ExtraPoisonId; + } + bool IsWorn() const noexcept { return ExtraWorn || ExtraWornLeft; } }; @@ -84,6 +118,7 @@ struct Inventory void AddOrRemoveEntry(const Entry& acEntry) noexcept; void UpdateEquipment(const Inventory& acNewInventory) noexcept; bool ContainsQuestItems() const noexcept; + bool HasWornItems() const noexcept; Vector Entries{}; MagicEquipment CurrentMagicEquipment{}; 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/encoding/Structs/ServerSettings.cpp b/Code/encoding/Structs/ServerSettings.cpp index bdf8f77adc..44dc7a55a2 100644 --- a/Code/encoding/Structs/ServerSettings.cpp +++ b/Code/encoding/Structs/ServerSettings.cpp @@ -5,7 +5,7 @@ using TiltedPhoques::Serialization; bool ServerSettings::operator==(const ServerSettings& acRhs) const noexcept { - return Difficulty == acRhs.Difficulty && GreetingsEnabled == acRhs.GreetingsEnabled && PvpEnabled == acRhs.PvpEnabled && SyncPlayerHomes == acRhs.SyncPlayerHomes && DeathSystemEnabled == acRhs.DeathSystemEnabled && AutoPartyJoin == acRhs.AutoPartyJoin; + return Difficulty == acRhs.Difficulty && GreetingsEnabled == acRhs.GreetingsEnabled && PvpEnabled == acRhs.PvpEnabled && SyncPlayerHomes == acRhs.SyncPlayerHomes && DeathSystemEnabled == acRhs.DeathSystemEnabled && AutoPartyJoin == acRhs.AutoPartyJoin && ItemDropsEnabled == acRhs.ItemDropsEnabled && ConsoleCommandsAllowed == acRhs.ConsoleCommandsAllowed && VoipEnabled == acRhs.VoipEnabled; } bool ServerSettings::operator!=(const ServerSettings& acRhs) const noexcept @@ -22,6 +22,9 @@ void ServerSettings::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noe Serialization::WriteBool(aWriter, DeathSystemEnabled); Serialization::WriteBool(aWriter, SyncPlayerCalendar); Serialization::WriteBool(aWriter, AutoPartyJoin); + Serialization::WriteBool(aWriter, ItemDropsEnabled); + Serialization::WriteBool(aWriter, ConsoleCommandsAllowed); + Serialization::WriteBool(aWriter, VoipEnabled); } void ServerSettings::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -33,4 +36,7 @@ void ServerSettings::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcep DeathSystemEnabled = Serialization::ReadBool(aReader); SyncPlayerCalendar = Serialization::ReadBool(aReader); AutoPartyJoin = Serialization::ReadBool(aReader); + ItemDropsEnabled = Serialization::ReadBool(aReader); + ConsoleCommandsAllowed = Serialization::ReadBool(aReader); + VoipEnabled = Serialization::ReadBool(aReader); } diff --git a/Code/encoding/Structs/ServerSettings.h b/Code/encoding/Structs/ServerSettings.h index 02a49302fe..1f6a93b3e3 100644 --- a/Code/encoding/Structs/ServerSettings.h +++ b/Code/encoding/Structs/ServerSettings.h @@ -17,4 +17,8 @@ struct ServerSettings bool DeathSystemEnabled{}; bool SyncPlayerCalendar{}; bool AutoPartyJoin{}; + bool ItemDropsEnabled{}; + bool ConsoleCommandsAllowed{true}; + // Built-in proximity voice chat, players also need Connect to voip on + bool VoipEnabled{}; }; diff --git a/Code/encoding/Structs/TradeValidation.h b/Code/encoding/Structs/TradeValidation.h new file mode 100644 index 0000000000..a63902f1fa --- /dev/null +++ b/Code/encoding/Structs/TradeValidation.h @@ -0,0 +1,172 @@ +#pragma once + +#include +#include +#include + +inline bool IsExcludedTradeItem(const Inventory::Entry& acItem) noexcept +{ + // Skyrim's internal unarmed weapon has no inventory name and is not a player-tradable item. + return acItem.IsQuestItem || (acItem.BaseId.ModId == 0 && acItem.BaseId.BaseId == 0x1F4) || + (acItem.BaseId.ModId == 0xFFFFFFFF && !acItem.Potion.IsValid()) || + (acItem.BaseId.ModId != 0xFFFFFFFF && !acItem.Potion.Effects.empty()); +} + +inline bool SameTradeItem(const Inventory::Entry& left, const Inventory::Entry& right) noexcept +{ + if (!left.CanBeMerged(right) || left.EnchantData.IsWeapon != right.EnchantData.IsWeapon || + left.EnchantData.Effects.size() != right.EnchantData.Effects.size()) + return false; + for (size_t i = 0; i < left.EnchantData.Effects.size(); ++i) + { + const auto& a = left.EnchantData.Effects[i]; + const auto& b = right.EnchantData.Effects[i]; + if (a.EffectId != b.EffectId || a.Magnitude != b.Magnitude || a.Area != b.Area || a.Duration != b.Duration || a.RawCost != b.RawCost) + return false; + } + return true; +} + +// A ready client lists the partner items it can resolve and receive, in offer order. +// Items it leaves out stay with their owner. +inline bool ValidateAcceptedItems(const Vector& acIncoming, const Vector& acAccepted) noexcept +{ + size_t index = 0; + for (const auto& item : acIncoming) + { + if (index < acAccepted.size() && acAccepted[index].Count == item.Count && SameTradeItem(item, acAccepted[index])) + ++index; + } + return index == acAccepted.size(); +} + +// Marks which offered items appear in the partner's accepted list, matching in offer order. +inline Vector MatchAcceptedItems(const Vector& acOffer, const Vector& acAccepted) noexcept +{ + Vector result(acOffer.size(), false); + size_t index = 0; + for (size_t i = 0; i < acOffer.size(); ++i) + { + if (index < acAccepted.size() && acAccepted[index].Count == acOffer[i].Count && SameTradeItem(acOffer[i], acAccepted[index])) + { + result[i] = true; + ++index; + } + } + return result; +} + +// Reserve quantities across the entire offer so repeated entries cannot reuse the same items. +inline bool ValidateTradeOffer(const Inventory& acInventory, const Vector& acItems) noexcept +{ + Vector remaining; + remaining.reserve(acInventory.Entries.size()); + for (const auto& entry : acInventory.Entries) + remaining.push_back(std::max(0, entry.Count)); + + for (const auto& item : acItems) + { + if (item.Count <= 0 || IsExcludedTradeItem(item) || item.IsWorn()) + return false; + + int32_t needed = item.Count; + for (size_t i = 0; i < acInventory.Entries.size() && needed > 0; ++i) + { + const auto& entry = acInventory.Entries[i]; + if (!SameTradeItem(entry, item)) + continue; + + const int32_t reserved = std::min(needed, remaining[i]); + remaining[i] -= reserved; + needed -= reserved; + } + if (needed != 0) + return false; + } + return true; +} + +// Keeps the offered items an inventory can still cover, reserving quantities in offer order. +inline Vector KeepCoveredTradeOffer(const Inventory& acInventory, const Vector& acItems) noexcept +{ + Vector remaining; + remaining.reserve(acInventory.Entries.size()); + for (const auto& entry : acInventory.Entries) + remaining.push_back(std::max(0, entry.Count)); + + Vector kept; + for (const auto& item : acItems) + { + if (item.Count <= 0 || IsExcludedTradeItem(item) || item.IsWorn()) + continue; + auto reserved = remaining; + int32_t needed = item.Count; + for (size_t i = 0; i < acInventory.Entries.size() && needed > 0; ++i) + { + if (!SameTradeItem(acInventory.Entries[i], item)) + continue; + const int32_t count = std::min(needed, reserved[i]); + reserved[i] -= count; + needed -= count; + } + if (needed != 0) + continue; + remaining = std::move(reserved); + kept.push_back(item); + } + return kept; +} + +// Publish neither inventory until both offers and resulting stack counts are valid. +inline bool PrepareTradeExchange(const Inventory& acLeft, const Inventory& acRight, + const Vector& acLeftOffer, const Vector& acRightOffer, + Inventory& aLeftResult, Inventory& aRightResult) noexcept +{ + if (!ValidateTradeOffer(acLeft, acLeftOffer) || !ValidateTradeOffer(acRight, acRightOffer)) + return false; + Inventory left = acLeft; + Inventory right = acRight; + auto remove = [](Inventory& inventory, const Vector& offer) + { + for (const auto& item : offer) + { + int32_t needed = item.Count; + for (auto& entry : inventory.Entries) + { + if (!SameTradeItem(entry, item) || entry.Count <= 0) + continue; + const int32_t count = std::min(needed, entry.Count); + entry.Count -= count; + needed -= count; + if (!needed) + break; + } + } + inventory.Entries.erase(std::remove_if(inventory.Entries.begin(), inventory.Entries.end(), + [](const auto& entry) { return entry.Count == 0; }), inventory.Entries.end()); + }; + auto add = [](Inventory& inventory, const Vector& offer) + { + for (const auto& item : offer) + { + auto it = std::find_if(inventory.Entries.begin(), inventory.Entries.end(), + [&item](const auto& entry) { return SameTradeItem(entry, item); }); + if (it == inventory.Entries.end()) + inventory.Entries.push_back(item); + else + { + if (it->Count < 0 || it->Count > std::numeric_limits::max() - item.Count) + return false; + it->Count += item.Count; + } + } + return true; + }; + remove(left, acLeftOffer); + remove(right, acRightOffer); + if (!add(left, acRightOffer) || !add(right, acLeftOffer)) + return false; + aLeftResult = std::move(left); + aRightResult = std::move(right); + return true; +} diff --git a/Code/encoding/Structs/VoiceFrame.cpp b/Code/encoding/Structs/VoiceFrame.cpp new file mode 100644 index 0000000000..75e972fba5 --- /dev/null +++ b/Code/encoding/Structs/VoiceFrame.cpp @@ -0,0 +1,35 @@ +#include "VoiceFrame.h" +#include + +using TiltedPhoques::Serialization; + +void VoiceFrame::Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept +{ + const size_t size = std::min(Data.size(), kMaxSize); + + aWriter.WriteBits(Sequence, 16); + Serialization::WriteVarInt(aWriter, size); + if (size > 0) + aWriter.WriteBytes(Data.data(), size); +} + +bool VoiceFrame::Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept +{ + uint64_t sequence = 0; + aReader.ReadBits(sequence, 16); + Sequence = static_cast(sequence); + + const uint64_t size = Serialization::ReadVarInt(aReader); + Data.clear(); + if (size == 0 || size > kMaxSize) + return false; + + Data.resize(static_cast(size)); + if (!aReader.ReadBytes(Data.data(), Data.size())) + { + Data.clear(); + return false; + } + + return true; +} diff --git a/Code/encoding/Structs/VoiceFrame.h b/Code/encoding/Structs/VoiceFrame.h new file mode 100644 index 0000000000..f5201be221 --- /dev/null +++ b/Code/encoding/Structs/VoiceFrame.h @@ -0,0 +1,21 @@ +#pragma once + +using TiltedPhoques::Buffer; + +// One 20 ms Opus packet of a player's voice +struct VoiceFrame +{ + // Opus packets for 20 ms of speech at our bitrate are well under this, anything bigger is dropped + static constexpr size_t kMaxSize = 512; + + bool operator==(const VoiceFrame& acRhs) const noexcept { return Sequence == acRhs.Sequence && Data == acRhs.Data; } + bool operator!=(const VoiceFrame& acRhs) const noexcept { return !this->operator==(acRhs); } + + void Serialize(TiltedPhoques::Buffer::Writer& aWriter) const noexcept; + // False when the frame is empty or too big to be a voice packet + bool Deserialize(TiltedPhoques::Buffer::Reader& aReader) noexcept; + + // Counts up per frame the speaker sends, receivers use it to spot lost and reordered frames + uint16_t Sequence{}; + TiltedPhoques::Vector Data{}; +}; 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/Launcher.cpp b/Code/immersive_launcher/Launcher.cpp index 48d69ec683..5b5e6ef89a 100644 --- a/Code/immersive_launcher/Launcher.cpp +++ b/Code/immersive_launcher/Launcher.cpp @@ -19,6 +19,8 @@ #include +#include + // These symbols are defined within the client code skyrimtogetherclient extern void InstallStartHook(); extern void RunTiltedApp(); @@ -68,6 +70,22 @@ void SetMaxstdio() setmaxstdioFunc(8192); } +#if (!IS_MASTER) +// Skyrim Together Tweaks (an SKSE plugin) switches the debug log console with bEnableConsole in its ini; builds that +// open the console themselves follow that switch too. Without the ini, or without the setting, the console opens. +bool IsConsoleWanted(const std::filesystem::path& acGamePath) +{ + const auto cIniPath = acGamePath / L"Data" / L"SKSE" / L"Plugins" / L"SkyrimTogetherTweaks.ini"; + wchar_t value[32]{}; + GetPrivateProfileStringW(L"General", L"bEnableConsole", L"", value, static_cast(std::size(value)), cIniPath.c_str()); + + // The first word only, in case a comment follows the value. + std::wstring_view word(value); + word = word.substr(0, word.find_first_of(L" \t;#")); + return !(word.size() == 5 && _wcsnicmp(word.data(), L"false", 5) == 0) && word != L"0"; +} +#endif + int StartUp(int argc, char** argv) { bool askSelect = (GetAsyncKeyState(VK_SPACE) & 0x8000); @@ -77,10 +95,6 @@ int StartUp(int argc, char** argv) // TODO(Force): Make some InitSharedResources func. g_SharedWindowIcon = LoadIconW(GetModuleHandleW(nullptr), MAKEINTRESOURCEW(102)); -#if (!IS_MASTER) - TiltedPhoques::Debug::CreateConsole(); -#endif - SetMaxstdio(); if (!EarlyInstallSucceeded()) @@ -105,6 +119,12 @@ int StartUp(int argc, char** argv) if (!oobe::SelectInstall(askSelect)) DIE_NOW(L"Failed to select game install."); +#if (!IS_MASTER) + // Opened once the game folder, and with it the Tweaks ini, is known; nothing is printed before this. + if (IsConsoleWanted(LC->gamePath)) + TiltedPhoques::Debug::CreateConsole(); +#endif + // Bind path environment. loader::InstallPathRouting(LC->gamePath); steam::Load(LC->gamePath); 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/Components.h b/Code/server/Components.h index f2e6c13c5d..1328dcfdb6 100644 --- a/Code/server/Components.h +++ b/Code/server/Components.h @@ -15,5 +15,6 @@ #include #include #include +#include #undef TP_INTERNAL_COMPONENTS_GUARD diff --git a/Code/server/Components/DroppedItemComponent.h b/Code/server/Components/DroppedItemComponent.h new file mode 100644 index 0000000000..d3095e3a31 --- /dev/null +++ b/Code/server/Components/DroppedItemComponent.h @@ -0,0 +1,22 @@ +#pragma once + +#ifndef TP_INTERNAL_COMPONENTS_GUARD +#error Include Components.h instead +#endif + +#include + +struct Player; + +/** + * @brief An item a player dropped into the world. + */ +struct DroppedItemComponent +{ + Inventory::Entry Item{}; + DroppedItemTransform Transform{}; + // The client that simulates the item's physics; null once the item is at rest. + Player* pSimulator{}; + // Increases with every drop; used to evict the oldest items first. + uint64_t Sequence{}; +}; diff --git a/Code/server/GameServer.cpp b/Code/server/GameServer.cpp index de5e917934..d97441cce2 100644 --- a/Code/server/GameServer.cpp +++ b/Code/server/GameServer.cpp @@ -41,10 +41,14 @@ Console::Setting bEnableDeathSystem{"Gameplay:bEnableDeathSystem", "Enables the Console::Setting uTimeScale{"Gameplay:uTimeScale", "How many seconds pass ingame for every real second (0 to 1000). Changing this can make the game unstable", 20u}; Console::Setting bSyncPlayerCalendar{"Gameplay:bSyncPlayerCalendar", "Syncs up all player calendars to be the same day, month, and year. This uses the date of the player with the furthest ahead date at connection.", false}; Console::Setting bAutoPartyJoin{"Gameplay:bAutoPartyJoin", "Join parties automatically, as long as there is only one party in the server", true}; +Console::Setting bEnableItemDrops{"Gameplay:bEnableItemDrops", "Syncs items dropped by players", true}; +Console::Setting bAllowConsoleCommands{"Gameplay:bAllowConsoleCommands", "Lets players run console commands they type while connected. Turn it off to stop console trolling such as spawning enemies or killing NPCs. The console still opens for reading ids, and mods can still run commands", true}; // ModPolicy Stuff Console::Setting bEnableModCheck{"ModPolicy:bEnableModCheck", "Refuse clients whose mods do not match Data/loadorder.txt", false, Console::SettingsFlags::kLocked}; Console::Setting bAllowSKSE{"ModPolicy:bAllowSKSE", "Allow players using SKSE to join. Applies even when bEnableModCheck is off", true, Console::SettingsFlags::kLocked}; Console::Setting bAllowMO2{"ModPolicy:bAllowMO2", "Allow players using Mod Organizer 2 to join. Applies even when bEnableModCheck is off", true, Console::SettingsFlags::kLocked}; +// Voice, the game client reads and writes this section too when the server runs from the game folder +Console::Setting bVoip{"Voice:bVoip", "Built-in proximity voice chat. On a server: lets players talk to players nearby. For a player: the Connect to voip checkbox in Settings", false}; // -- Commands -- Console::Command<> TogglePremium( @@ -64,6 +68,15 @@ Console::Command<> TogglePvp( GameServer::Get()->UpdateSettings(); }); +Console::Command<> ToggleConsoleCommands( + "ToggleConsoleCommands", "Toggle whether players can run console commands they type", + [](Console::ArgStack&) + { + bAllowConsoleCommands = !bAllowConsoleCommands; + spdlog::get("ConOut")->info("Typed console commands have been {}.", bAllowConsoleCommands == true ? "allowed" : "turned off"); + GameServer::Get()->UpdateSettings(); + }); + Console::Command SetDifficulty( "SetDifficulty", "Set server difficulty (0 being Novice and 5 being Legendary; default is 4)", [](Console::ArgStack& aStack) @@ -141,6 +154,9 @@ ServerSettings GetSettings() settings.DeathSystemEnabled = bEnableDeathSystem; settings.SyncPlayerCalendar = bSyncPlayerCalendar; settings.AutoPartyJoin = bAutoPartyJoin; + settings.ItemDropsEnabled = bEnableItemDrops; + settings.ConsoleCommandsAllowed = bAllowConsoleCommands; + settings.VoipEnabled = bVoip; return settings; } @@ -211,6 +227,11 @@ bool GameServer::AllowsAutoPartyJoin() const noexcept return bAutoPartyJoin && !IsPublicServer(); } +bool GameServer::AllowsItemDrops() const noexcept +{ + return bEnableItemDrops; +} + void GameServer::Initialize() { if (!CheckMoPo()) diff --git a/Code/server/GameServer.h b/Code/server/GameServer.h index c063101504..55ba24887b 100644 --- a/Code/server/GameServer.h +++ b/Code/server/GameServer.h @@ -66,6 +66,7 @@ struct GameServer final : Server bool IsPasswordProtected() const noexcept { return m_isPasswordProtected; } [[nodiscard]] bool IsPublicServer() const noexcept; [[nodiscard]] bool AllowsAutoPartyJoin() const noexcept; + [[nodiscard]] bool AllowsItemDrops() const noexcept; struct Uptime { diff --git a/Code/server/Services/CalendarService.cpp b/Code/server/Services/CalendarService.cpp index 7014c28535..29619d7088 100644 --- a/Code/server/Services/CalendarService.cpp +++ b/Code/server/Services/CalendarService.cpp @@ -99,6 +99,17 @@ bool CalendarService::SetDate(int aDay, int aMonth, float aYear) noexcept return false; } +bool CalendarService::AdvanceTime(float aHours) noexcept +{ + // The sleep/wait menu allows at most 24 hours; anything beyond that is not a real request. + if (!(aHours > 0.f) || aHours > 48.f) + return false; + + m_dateTime.AddHours(aHours); + SendTimeResync(); + return true; +} + void CalendarService::SendTimeResync() noexcept { ServerTimeSettings timeMsg; diff --git a/Code/server/Services/CalendarService.h b/Code/server/Services/CalendarService.h index 4380490449..b286b6d6af 100644 --- a/Code/server/Services/CalendarService.h +++ b/Code/server/Services/CalendarService.h @@ -24,6 +24,8 @@ class CalendarService bool SetTime(int aHour, int aMinutes, float aScale) noexcept; bool SetDate(int aDay, int aMonth, float aYear) noexcept; + // Advances the shared clock by a number of game hours (sleep/wait sync, #467). + bool AdvanceTime(float aHours) noexcept; // returns hours, minutes TTime GetTime() const noexcept; diff --git a/Code/server/Services/CharacterService.cpp b/Code/server/Services/CharacterService.cpp index b23c775a5d..21c4b237c3 100644 --- a/Code/server/Services/CharacterService.cpp +++ b/Code/server/Services/CharacterService.cpp @@ -351,7 +351,28 @@ void CharacterService::OnOwnershipClaimRequest(const PacketEvent(message.ServerId); if (!CanClaimOwnership(acMessage.pPlayer, cEntity, message.ExpectedOwnershipEpoch, OwnershipTransferReason::LeaderClaim)) + { + // A claim from an older epoch means the leader missed a transfer and would keep claiming with it. + // Send it the current owner so it catches up and can claim again. + const auto view = m_world.view(); + const auto it = view.find(cEntity); + if (it == view.end()) + return; + + const auto& ownerComponent = view.get(*it); + Player* const pOwner = ownerComponent.GetOwner(); + if (!pOwner || pOwner == acMessage.pPlayer || message.ExpectedOwnershipEpoch >= ownerComponent.OwnershipEpoch) + return; + + NotifyOwnershipTransfer notify{}; + notify.ServerId = message.ServerId; + notify.OwnerPlayerId = pOwner->GetId(); + notify.OwnershipEpoch = ownerComponent.OwnershipEpoch; + notify.CurrentActorData = BuildActorData(cEntity); + notify.LeveledNpcPickId = view.get(*it).LeveledNpcPickId.Id; + acMessage.pPlayer->Send(notify); return; + } TransferOwnership(acMessage.pPlayer, cEntity, OwnershipTransferReason::LeaderClaim); } diff --git a/Code/server/Services/CommandService.cpp b/Code/server/Services/CommandService.cpp index 527e780a58..d3ba6b8730 100644 --- a/Code/server/Services/CommandService.cpp +++ b/Code/server/Services/CommandService.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -21,6 +22,37 @@ CommandService::CommandService(World& aWorld, entt::dispatcher& aDispatcher) noe { m_setTimeConnection = aDispatcher.sink>().connect<&CommandService::OnSetTimeCommand>(this); m_teleportConnection = aDispatcher.sink>().connect<&CommandService::OnTeleportCommandRequest>(this); + m_timeSkipConnection = aDispatcher.sink>().connect<&CommandService::OnTimeSkipRequest>(this); +} + +bool CommandService::CanChangeTime(const Player* apPlayer) const noexcept +{ + // Admin override: always allowed + for (const auto session : GameServer::Get()->GetAdminSessions()) + { + const Player* pAdmin = PlayerManager::Get()->GetByConnectionId(session); + if (pAdmin && pAdmin->GetId() == apPlayer->GetId()) + return true; + } + + // Party leader allowed on private servers only + return m_world.GetPartyService().IsPlayerLeader(apPlayer) && !bAnnounceServer; +} + +void CommandService::OnTimeSkipRequest(const PacketEvent& acMessage) const noexcept +{ + const float cHours = acMessage.Packet.Hours; + + if (!CanChangeTime(acMessage.pPlayer)) + { + spdlog::debug("Ignoring time skip of {} hours from player {:X}: no permission", cHours, acMessage.pPlayer->GetId()); + return; + } + + if (m_world.GetCalendarService().AdvanceTime(cHours)) + spdlog::info("Player {:X} slept or waited: advanced the shared time by {} hours", acMessage.pPlayer->GetId(), cHours); + else + spdlog::warn("Rejected time skip of {} hours from player {:X}", cHours, acMessage.pPlayer->GetId()); } void CommandService::OnSetTimeCommand(const PacketEvent& acMessage) const noexcept diff --git a/Code/server/Services/CommandService.h b/Code/server/Services/CommandService.h index 79c72d8d0b..860a01e809 100644 --- a/Code/server/Services/CommandService.h +++ b/Code/server/Services/CommandService.h @@ -3,8 +3,10 @@ #include struct World; +struct Player; struct TeleportCommandRequest; struct SetTimeCommandRequest; +struct RequestTimeSkip; /** * @brief Processes incoming commands. @@ -18,6 +20,11 @@ struct CommandService protected: void OnSetTimeCommand(const PacketEvent& acMessage) const noexcept; + /** + * @brief Advances the shared time after a player slept or waited (#467). Same permissions as /settime. + */ + void OnTimeSkipRequest(const PacketEvent& acMessage) const noexcept; + bool CanChangeTime(const Player* apPlayer) const noexcept; /** * @brief Returns the location of the target player of the teleport command. */ @@ -27,5 +34,6 @@ struct CommandService World& m_world; entt::scoped_connection m_setTimeConnection; + entt::scoped_connection m_timeSkipConnection; entt::scoped_connection m_teleportConnection; }; diff --git a/Code/server/Services/DroppedItemService.cpp b/Code/server/Services/DroppedItemService.cpp new file mode 100644 index 0000000000..c8483dface --- /dev/null +++ b/Code/server/Services/DroppedItemService.cpp @@ -0,0 +1,266 @@ +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +Console::Setting uMaxDroppedItems{"Gameplay:uMaxDroppedItems", "Maximum number of dropped items the server keeps. When the limit is reached, the oldest items disappear", 1000u}; + +constexpr float cInterestUpdateInterval = 0.25f; + +CellIdComponent MakeCellComponent(const GameId& acCellId, const GameId& acWorldSpaceId, const DroppedItemTransform& acTransform) noexcept +{ + if (!acWorldSpaceId) + return CellIdComponent{acCellId, {}, {}}; + + return CellIdComponent{acCellId, acWorldSpaceId, GridCellCoords::CalculateGridCellCoords(acTransform.Position)}; +} +} // namespace + +DroppedItemService::DroppedItemService(World& aWorld, entt::dispatcher& aDispatcher) noexcept + : m_world(aWorld) +{ + m_updateConnection = aDispatcher.sink().connect<&DroppedItemService::OnUpdate>(this); + m_playerLeaveConnection = aDispatcher.sink().connect<&DroppedItemService::OnPlayerLeave>(this); + m_dropItemConnection = aDispatcher.sink>().connect<&DroppedItemService::OnDropItem>(this); + m_droppedItemMoveConnection = aDispatcher.sink>().connect<&DroppedItemService::OnDroppedItemMove>(this); + m_pickUpDroppedItemConnection = aDispatcher.sink>().connect<&DroppedItemService::OnPickUpDroppedItem>(this); +} + +void DroppedItemService::OnUpdate(const UpdateEvent& acEvent) noexcept +{ + m_timeSinceInterestUpdate += acEvent.Delta; + if (m_timeSinceInterestUpdate < cInterestUpdateInterval) + return; + + m_timeSinceInterestUpdate = 0.f; + UpdateInterest(); +} + +void DroppedItemService::OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept +{ + m_knownItems.erase(acEvent.pPlayer); + + auto view = m_world.view(); + for (auto entity : view) + { + auto& droppedItem = view.get(entity); + if (droppedItem.pSimulator == acEvent.pPlayer) + StopSimulation(entity, droppedItem); + } +} + +void DroppedItemService::OnDropItem(const PacketEvent& acMessage) noexcept +{ + const auto& message = acMessage.Packet; + Player* pPlayer = acMessage.pPlayer; + + DropItemResponse response{}; + response.LocalId = message.LocalId; + + // A zero server id tells the client to stop tracking the item. + if (!GameServer::Get()->AllowsItemDrops() || message.Item.Count <= 0 || !pPlayer->GetCellComponent()) + { + pPlayer->Send(response); + return; + } + + EvictOldestItems(); + + const auto entity = m_world.create(); + + auto& droppedItem = m_world.emplace(entity); + droppedItem.Item = message.Item; + droppedItem.Transform = message.Transform; + droppedItem.pSimulator = pPlayer; + droppedItem.Sequence = m_nextSequence++; + + m_world.emplace(entity, MakeCellComponent(message.CellId, message.WorldSpaceId, message.Transform)); + + // The dropping client already has its own reference. + m_knownItems[pPlayer].insert(entity); + + response.ServerId = World::ToInteger(entity); + pPlayer->Send(response); + + spdlog::debug("Player {:X} dropped item {:X}:{:X} (count {}), server id {:X}", pPlayer->GetId(), message.Item.BaseId.ModId, message.Item.BaseId.BaseId, message.Item.Count, response.ServerId); + + // Spawn it for players in range now, not on the next interest tick. + UpdateInterest(); +} + +void DroppedItemService::OnDroppedItemMove(const PacketEvent& acMessage) noexcept +{ + const auto& move = acMessage.Packet.Move; + + auto view = m_world.view(); + const auto it = view.find(static_cast(move.ServerId)); + if (it == view.end()) + return; + + auto& droppedItem = view.get(*it); + if (droppedItem.pSimulator != acMessage.pPlayer) + { + spdlog::debug("Ignored dropped item move from player {:X} for item {:X}, which it does not simulate", acMessage.pPlayer->GetId(), move.ServerId); + return; + } + + droppedItem.Transform = move.Transform; + + // An exterior item can fall into another cell; keep its range checks up to date. + auto& cellComponent = view.get(*it); + if (!cellComponent.IsInInteriorCell()) + cellComponent.CenterCoords = GridCellCoords::CalculateGridCellCoords(move.Transform.Position); + + if (move.IsAtRest) + droppedItem.pSimulator = nullptr; + + NotifyDroppedItemMove notify{}; + notify.Move = move; + + SendToPlayersWithItem(notify, *it, acMessage.pPlayer); +} + +void DroppedItemService::OnPickUpDroppedItem(const PacketEvent& acMessage) noexcept +{ + const auto entity = static_cast(acMessage.Packet.ServerId); + + auto view = m_world.view(); + if (view.find(entity) == view.end()) + { + // Another player picked it up first. The first pickup wins; this one is not relayed. + spdlog::debug("Player {:X} picked up dropped item {:X}, which is already gone", acMessage.pPlayer->GetId(), acMessage.Packet.ServerId); + return; + } + + RemoveItem(entity, acMessage.pPlayer); +} + +void DroppedItemService::UpdateInterest() noexcept +{ + auto view = m_world.view(); + + for (Player* pPlayer : m_world.GetPlayerManager()) + { + const auto& playerCell = pPlayer->GetCellComponent(); + if (!playerCell) + continue; + + auto& knownItems = m_knownItems[pPlayer]; + + NotifyDroppedItemsSpawn spawn{}; + NotifyDroppedItemsRemove remove{}; + + for (auto entity : view) + { + const bool isInRange = playerCell.IsInRange(view.get(entity), false); + const bool isKnown = knownItems.contains(entity); + + if (isInRange && !isKnown) + { + knownItems.insert(entity); + spawn.Items.push_back(ToData(entity)); + } + else if (!isInRange && isKnown) + { + knownItems.erase(entity); + remove.ServerIds.push_back(World::ToInteger(entity)); + + // The client deletes its copy, so it can no longer stream the physics. + auto& droppedItem = view.get(entity); + if (droppedItem.pSimulator == pPlayer) + StopSimulation(entity, droppedItem); + } + } + + if (!spawn.Items.empty()) + pPlayer->Send(spawn); + + if (!remove.ServerIds.empty()) + pPlayer->Send(remove); + } +} + +void DroppedItemService::EvictOldestItems() noexcept +{ + const auto maxItems = uMaxDroppedItems.value_as(); + + auto view = m_world.view(); + while (!view.empty() && view.size() >= maxItems) + { + const auto oldest = *std::min_element( + view.begin(), view.end(), + [view](entt::entity aLhs, entt::entity aRhs) { return view.get(aLhs).Sequence < view.get(aRhs).Sequence; }); + + spdlog::debug("Evicting dropped item {:X}, the limit of {} dropped items was reached", World::ToInteger(oldest), maxItems); + RemoveItem(oldest, nullptr); + } +} + +void DroppedItemService::RemoveItem(entt::entity aEntity, const Player* apExcludedPlayer) noexcept +{ + NotifyDroppedItemsRemove notify{}; + notify.ServerIds.push_back(World::ToInteger(aEntity)); + + // hopscotch_map only exposes mutable values through the iterator. + for (auto it = m_knownItems.begin(); it != m_knownItems.end(); ++it) + { + if (it.value().erase(aEntity) && it.key() != apExcludedPlayer) + it.key()->Send(notify); + } + + m_world.destroy(aEntity); +} + +void DroppedItemService::StopSimulation(entt::entity aEntity, DroppedItemComponent& aDroppedItem) noexcept +{ + const Player* pSimulator = aDroppedItem.pSimulator; + aDroppedItem.pSimulator = nullptr; + + NotifyDroppedItemMove notify{}; + notify.Move.ServerId = World::ToInteger(aEntity); + notify.Move.Transform = aDroppedItem.Transform; + notify.Move.IsAtRest = true; + + SendToPlayersWithItem(notify, aEntity, pSimulator); +} + +void DroppedItemService::SendToPlayersWithItem(const ServerMessage& acMessage, entt::entity aEntity, const Player* apExcludedPlayer) const noexcept +{ + for (const auto& [pPlayer, knownItems] : m_knownItems) + { + if (pPlayer != apExcludedPlayer && knownItems.contains(aEntity)) + pPlayer->Send(acMessage); + } +} + +DroppedItemData DroppedItemService::ToData(entt::entity aEntity) const noexcept +{ + const auto& droppedItem = m_world.get(aEntity); + const auto& cellComponent = m_world.get(aEntity); + + DroppedItemData data{}; + data.ServerId = World::ToInteger(aEntity); + data.Item = droppedItem.Item; + data.CellId = cellComponent.Cell; + data.WorldSpaceId = cellComponent.WorldSpaceId; + data.Transform = droppedItem.Transform; + data.IsAtRest = droppedItem.pSimulator == nullptr; + + return data; +} diff --git a/Code/server/Services/DroppedItemService.h b/Code/server/Services/DroppedItemService.h new file mode 100644 index 0000000000..459442fb2c --- /dev/null +++ b/Code/server/Services/DroppedItemService.h @@ -0,0 +1,60 @@ +#pragma once + +#include + +struct World; +struct UpdateEvent; +struct PlayerLeaveEvent; +struct DropItemRequest; +struct DroppedItemMoveRequest; +struct PickUpDroppedItemRequest; +struct DroppedItemComponent; +struct DroppedItemData; +struct ServerMessage; +struct Player; + +/** + * @brief Registers dropped items for the whole session (until picked up or evicted) and relays them to players in range. + * The server tells each client when to spawn and delete its copies, so no untracked copy can be picked up twice. + */ +class DroppedItemService +{ +public: + DroppedItemService(World& aWorld, entt::dispatcher& aDispatcher) noexcept; + ~DroppedItemService() noexcept = default; + + TP_NOCOPYMOVE(DroppedItemService); + +private: + void OnUpdate(const UpdateEvent& acEvent) noexcept; + void OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept; + void OnDropItem(const PacketEvent& acMessage) noexcept; + void OnDroppedItemMove(const PacketEvent& acMessage) noexcept; + void OnPickUpDroppedItem(const PacketEvent& acMessage) noexcept; + + /** + * Spawns items that came into each player's range and removes the ones that left it. + */ + void UpdateInterest() noexcept; + void EvictOldestItems() noexcept; + void RemoveItem(entt::entity aEntity, const Player* apExcludedPlayer) noexcept; + /** + * Marks an item at rest when its simulating client can no longer stream it. + */ + void StopSimulation(entt::entity aEntity, DroppedItemComponent& aDroppedItem) noexcept; + void SendToPlayersWithItem(const ServerMessage& acMessage, entt::entity aEntity, const Player* apExcludedPlayer) const noexcept; + DroppedItemData ToData(entt::entity aEntity) const noexcept; + + World& m_world; + + // Items each player has been told to spawn. + TiltedPhoques::Map> m_knownItems{}; + uint64_t m_nextSequence{}; + float m_timeSinceInterestUpdate{}; + + entt::scoped_connection m_updateConnection; + entt::scoped_connection m_playerLeaveConnection; + entt::scoped_connection m_dropItemConnection; + entt::scoped_connection m_droppedItemMoveConnection; + entt::scoped_connection m_pickUpDroppedItemConnection; +}; 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/InventoryService.cpp b/Code/server/Services/InventoryService.cpp index 2d7d0f0797..ae6ccee474 100644 --- a/Code/server/Services/InventoryService.cpp +++ b/Code/server/Services/InventoryService.cpp @@ -7,20 +7,18 @@ #include #include #include +#include +#include #include #include #include -#include -namespace -{ -Console::Setting bEnableItemDrops{"Gameplay:bEnableItemDrops", "(Experimental) Syncs dropped items by players", false}; -} InventoryService::InventoryService(World& aWorld, entt::dispatcher& aDispatcher) : m_world(aWorld) { m_inventoryChangeConnection = aDispatcher.sink>().connect<&InventoryService::OnInventoryChanges>(this); + m_inventorySnapshotConnection = aDispatcher.sink>().connect<&InventoryService::OnInventorySnapshot>(this); m_equipmentChangeConnection = aDispatcher.sink>().connect<&InventoryService::OnEquipmentChanges>(this); m_drawWeaponConnection = aDispatcher.sink>().connect<&InventoryService::OnWeaponDrawnRequest>(this); } @@ -89,13 +87,57 @@ void InventoryService::OnInventoryChanges(const PacketEvent(message.ServerId); if (!GameServer::Get()->SendToPlayersInRange(notify, cOrigin, acMessage.GetSender())) spdlog::error("{}: SendToPlayersInRange failed", __FUNCTION__); } +void InventoryService::OnInventorySnapshot(const PacketEvent& acMessage) noexcept +{ + auto& message = acMessage.Packet; + + auto view = m_world.view(); + + const auto it = view.find(static_cast(message.ServerId)); + + if (it == view.end()) + return; + + const auto& ownerComponent = view.get(*it); + if (!ownerComponent.IsCurrentOwner(acMessage.pPlayer, message.OwnershipEpoch)) + { + const uint32_t ownerId = ownerComponent.GetOwner() ? ownerComponent.GetOwner()->GetId() : 0; + spdlog::debug( + "Rejected inventory snapshot from player {:X} for actor {:X}; current owner is {:X} and requested epoch {} does not match {}", + acMessage.pPlayer->GetId(), message.ServerId, ownerId, message.OwnershipEpoch, ownerComponent.OwnershipEpoch); + return; + } + + auto& content = view.get(*it).Content; + + // An NPC is briefly undressed while its 3D loads. A snapshot taken then would strip it for everyone, and a + // living NPC never legitimately loses every worn item at once (looting and pickpocketing are incremental). + if (!message.CurrentInventory.HasWornItems() && content.HasWornItems()) + { + const auto* pCharacterComponent = m_world.try_get(*it); + if (pCharacterComponent && !pCharacterComponent->IsPlayer() && !pCharacterComponent->IsDead()) + { + spdlog::info("Ignored an inventory snapshot without worn items for living actor {:X}", message.ServerId); + return; + } + } + + content = message.CurrentInventory; + + NotifyInventorySnapshot notify; + notify.ServerId = message.ServerId; + notify.OwnershipEpoch = message.OwnershipEpoch; + notify.CurrentInventory = message.CurrentInventory; + + if (!GameServer::Get()->SendToPlayersInRange(notify, *it, acMessage.GetSender())) + spdlog::error("{}: SendToPlayersInRange failed", __FUNCTION__); +} + void InventoryService::OnEquipmentChanges(const PacketEvent& acMessage) noexcept { auto& message = acMessage.Packet; diff --git a/Code/server/Services/InventoryService.h b/Code/server/Services/InventoryService.h index 3e9a26f5dd..9f4c6adf77 100644 --- a/Code/server/Services/InventoryService.h +++ b/Code/server/Services/InventoryService.h @@ -6,6 +6,7 @@ struct World; struct UpdateEvent; struct RequestObjectInventoryChanges; struct RequestInventoryChanges; +struct RequestInventorySnapshot; struct RequestEquipmentChanges; struct DrawWeaponRequest; struct PlayerLeaveCellEvent; @@ -22,6 +23,10 @@ class InventoryService * @brief Relays inventory changes to other clients and updates server side inventories. */ void OnInventoryChanges(const PacketEvent& acMessage) noexcept; + /** + * @brief Replaces an actor's server side inventory with its owner's and relays it to other clients. + */ + void OnInventorySnapshot(const PacketEvent& acMessage) noexcept; /** * @brief Relays equipment changes to other clients and updates server side equipment. */ @@ -35,6 +40,7 @@ class InventoryService World& m_world; entt::scoped_connection m_inventoryChangeConnection; + entt::scoped_connection m_inventorySnapshotConnection; entt::scoped_connection m_equipmentChangeConnection; entt::scoped_connection m_drawWeaponConnection; }; 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/Services/ObjectLifecycle.cpp b/Code/server/Services/ObjectLifecycle.cpp new file mode 100644 index 0000000000..e041755e85 --- /dev/null +++ b/Code/server/Services/ObjectLifecycle.cpp @@ -0,0 +1,36 @@ +#include + +#include +#include + +#include + +namespace ObjectLifecycle +{ +void RetireCell(entt::registry& aRegistry, const GameId& acCell, std::span aPlayerCells, const SendRemoval& aSendRemoval) noexcept +{ + // Preserve the existing policy: occupied current cells are retained. + // Tracking all loaded exterior cells remains a separate concern. + if (std::find(aPlayerCells.begin(), aPlayerCells.end(), acCell) != aPlayerCells.end()) + return; + + const auto objectView = aRegistry.view(); + Vector toDestroy; + NotifyRemoveObjects notify{}; + + for (const auto entity : objectView) + { + if (objectView.get(entity).Cell != acCell) + continue; + + toDestroy.push_back(entity); + notify.ServerIds.push_back(entt::to_integral(entity)); + } + + if (!notify.ServerIds.empty()) + aSendRemoval(notify); + + for (const auto entity : toDestroy) + aRegistry.destroy(entity); +} +} // namespace ObjectLifecycle diff --git a/Code/server/Services/ObjectLifecycle.h b/Code/server/Services/ObjectLifecycle.h new file mode 100644 index 0000000000..a725517b39 --- /dev/null +++ b/Code/server/Services/ObjectLifecycle.h @@ -0,0 +1,17 @@ +#pragma once + +#include +#include +#include + +struct GameId; +struct NotifyRemoveObjects; + +namespace ObjectLifecycle +{ +using SendRemoval = std::function; + +// aPlayerCells contains the players' current cells after the departure. +// aSendRemoval must broadcast synchronously before entity identifiers can be reused. +void RetireCell(entt::registry& aRegistry, const GameId& acCell, std::span aPlayerCells, const SendRemoval& aSendRemoval) noexcept; +} // namespace ObjectLifecycle diff --git a/Code/server/Services/ObjectService.cpp b/Code/server/Services/ObjectService.cpp index 2bd7d154f4..2ef1927f08 100644 --- a/Code/server/Services/ObjectService.cpp +++ b/Code/server/Services/ObjectService.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include ObjectService::ObjectService(World& aWorld, entt::dispatcher& aDispatcher) : m_world(aWorld) @@ -25,34 +27,16 @@ ObjectService::ObjectService(World& aWorld, entt::dispatcher& aDispatcher) m_scriptAnimationConnection = aDispatcher.sink>().connect<&ObjectService::OnScriptAnimationRequest>(this); } -// TODO(cosideci): the cell handling of objects need to be revamped. -// We already store the location and worldspace of the mod through CellIdComponent. -// Clients need a message saying the entity was destroyed. void ObjectService::OnPlayerLeaveCellEvent(const PlayerLeaveCellEvent& acEvent) noexcept { + Vector playerCells; + playerCells.reserve(m_world.GetPlayerManager().Count()); for (Player* pPlayer : m_world.GetPlayerManager()) - { - if (pPlayer->GetCellComponent().Cell == acEvent.OldCell) - return; - } - - auto objectView = m_world.view(); - Vector toDestroy; - - for (auto entity : objectView) - { - const auto& cellIdComponent = objectView.get(entity); - - if (cellIdComponent.Cell != acEvent.OldCell) - continue; + playerCells.push_back(pPlayer->GetCellComponent().Cell); - toDestroy.push_back(entity); - } - - for (auto& entity : toDestroy) - { - m_world.destroy(entity); - } + // Clients retain object bindings after leaving a cell. Retire those bindings + // everywhere before the server entity identifiers can be reused. + ObjectLifecycle::RetireCell(m_world, acEvent.OldCell, playerCells, [](const NotifyRemoveObjects& acNotify) { GameServer::Get()->SendToPlayers(acNotify); }); } // NOTE: this whole system kinda relies on all objects in a cell being static. @@ -162,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); } } @@ -175,9 +163,13 @@ void ObjectService::OnScriptAnimationRequest(const PacketEventSend(message); } } diff --git a/Code/server/Services/OverlayService.cpp b/Code/server/Services/OverlayService.cpp index df53432901..d368182dd6 100644 --- a/Code/server/Services/OverlayService.cpp +++ b/Code/server/Services/OverlayService.cpp @@ -33,6 +33,7 @@ void sendPlayerMessage(const ChatMessageType acType, const String acContent, Pla std::regex escapeHtml{"<[^>]+>\\s+(?=<)|<[^>]+>"}; notifyMessage.MessageType = acType; + notifyMessage.PlayerId = aSendingPlayer->GetId(); notifyMessage.PlayerName = std::regex_replace(aSendingPlayer->GetUsername(), escapeHtml, ""); notifyMessage.ChatMessage = std::regex_replace(acContent, escapeHtml, ""); 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(); } } } diff --git a/Code/server/Services/PlayerService.cpp b/Code/server/Services/PlayerService.cpp index e887a6ae28..0346dc35f3 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 @@ -16,12 +17,14 @@ #include #include #include +#include +#include #include #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 @@ -31,6 +34,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)) { } @@ -152,7 +156,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 +170,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); @@ -183,7 +190,6 @@ void PlayerService::OnPlayerRespawnRequest(const PacketEvent(*character)) notifyInventoryChanges.OwnershipEpoch = pOwnerComponent->OwnershipEpoch; notifyInventoryChanges.Item = entry; - notifyInventoryChanges.Drop = false; // Exclude respawned player from inventory changes notification... if (!GameServer::Get()->SendToPlayersInRange(notifyInventoryChanges, *character, acMessage.GetSender())) @@ -191,7 +197,7 @@ void PlayerService::OnPlayerRespawnRequest(const PacketEventSend(notifyPlayerRespawn); } @@ -215,3 +221,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/server/Services/ServerListService.cpp b/Code/server/Services/ServerListService.cpp index 9ead32cf4b..0ef2ee2271 100644 --- a/Code/server/Services/ServerListService.cpp +++ b/Code/server/Services/ServerListService.cpp @@ -92,6 +92,9 @@ void ServerListService::PostAnnouncement(String acName, String acDesc, String ac }; httplib::Client client(kMasterServerEndpoint); + // The list records the address the announce came from, so stick to IPv4 or dual-stack hosts get listed + // under an IPv6 address most players can't reach. + client.set_address_family(AF_INET); client.enable_server_certificate_verification(false); client.set_read_timeout(std::chrono::milliseconds(30000)); const auto response = client.Post("/announce", params); diff --git a/Code/server/Services/TradeService.cpp b/Code/server/Services/TradeService.cpp new file mode 100644 index 0000000000..f70c78b792 --- /dev/null +++ b/Code/server/Services/TradeService.cpp @@ -0,0 +1,732 @@ +#include +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include + +namespace +{ +constexpr uint64_t kTradeInviteDurationMs = 15000; +constexpr uint32_t kTradeFinalizeCountdownMs = 4000; +constexpr uint64_t kCountdownBroadcastIntervalMs = 200; +} + +TradeService::TradeService(World& aWorld, entt::dispatcher& aDispatcher) noexcept + : m_world(aWorld) +{ + m_updateConnection = aDispatcher.sink().connect<&TradeService::OnUpdate>(this); + m_playerLeaveConnection = aDispatcher.sink().connect<&TradeService::OnPlayerLeave>(this); + m_tradeInviteConnection = aDispatcher.sink>().connect<&TradeService::OnTradeInvite>(this); + m_tradeInviteResponseConnection = aDispatcher.sink>().connect<&TradeService::OnTradeInviteResponse>(this); + m_tradeOfferUpdateConnection = aDispatcher.sink>().connect<&TradeService::OnTradeOfferUpdate>(this); + m_tradeSetReadyConnection = aDispatcher.sink>().connect<&TradeService::OnTradeSetReady>(this); + m_tradeCancelConnection = aDispatcher.sink>().connect<&TradeService::OnTradeCancel>(this); + m_tradeInventorySyncConnection = aDispatcher.sink>().connect<&TradeService::OnTradeInventorySync>(this); +} + +void TradeService::OnUpdate(const UpdateEvent&) noexcept +{ + const auto cCurrentTick = GameServer::Get()->GetTick(); + + auto it = std::begin(m_pendingInvites); + while (it != std::end(m_pendingInvites)) + { + if (it->second.ExpiryTick <= cCurrentTick) + { + auto* pTarget = it->first; + auto* pRequester = it->second.Requester; + + spdlog::debug("[TradeService]: Invite between {} and {} expired", pRequester ? pRequester->GetId() : 0, pTarget ? pTarget->GetId() : 0); + + if (pRequester) + SendTradeCancelled(pRequester, pTarget, TradeCancelReason::Timeout, true); + if (pTarget) + SendTradeCancelled(pTarget, pRequester, TradeCancelReason::Timeout, false); + + it = m_pendingInvites.erase(it); + } + else + { + ++it; + } + } + + TiltedPhoques::Vector finalizeSessions; + for (auto it = m_sessions.begin(); it != m_sessions.end(); ++it) + { + const uint32_t id = it->first; + auto* pSession = const_cast(&it->second); + if (!pSession || !pSession->CountdownActive) + continue; + + const uint32_t remaining = GetRemainingCountdownMs(*pSession, cCurrentTick); + if (remaining == 0) + { + pSession->CountdownActive = false; + finalizeSessions.push_back(id); + continue; + } + + if (cCurrentTick - pSession->LastCountdownBroadcastTick >= kCountdownBroadcastIntervalMs) + { + pSession->LastCountdownBroadcastTick = cCurrentTick; + SendStateUpdate(*pSession); + } + } + + for (const auto sessionId : finalizeSessions) + { + auto sessionIt = m_sessions.find(sessionId); + if (sessionIt == std::end(m_sessions)) + continue; + + FinalizeTrade(const_cast(sessionIt->second)); + } +} + +void TradeService::OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept +{ + auto* pPlayer = acEvent.pPlayer; + if (!pPlayer) + return; + + if (auto* pSession = GetSession(pPlayer)) + { + spdlog::debug("[TradeService]: Player {} left during trade, cancelling session {}", pPlayer->GetId(), pSession->Id); + CancelSession(*pSession, TradeCancelReason::PlayerLeft, pPlayer); + } + + RemoveInviteFor(pPlayer, TradeCancelReason::PlayerLeft, pPlayer); +} + +void TradeService::OnTradeInvite(const PacketEvent& acPacket) noexcept +{ + Player* pRequester = acPacket.pPlayer; + if (!pRequester) + return; + + const auto& message = acPacket.Packet; + + Player* pTarget = m_world.GetPlayerManager().GetById(message.TargetPlayerId); + if (!pTarget || pTarget == pRequester) + { + SendTradeCancelled(pRequester, pTarget, TradeCancelReason::FailedValidation, true); + return; + } + + if (IsPlayerBusy(pRequester)) + { + SendTradeCancelled(pRequester, pTarget, TradeCancelReason::SelfBusy, true); + return; + } + + if (IsPlayerBusy(pTarget) || m_pendingInvites.find(pTarget) != std::end(m_pendingInvites)) + { + SendTradeCancelled(pRequester, pTarget, TradeCancelReason::PartnerBusy, true); + return; + } + + if (HasOutgoingInvite(pRequester)) + { + SendTradeCancelled(pRequester, pTarget, TradeCancelReason::SelfBusy, true); + return; + } + + const auto cExpiryTick = GameServer::Get()->GetTick() + kTradeInviteDurationMs; + m_pendingInvites[pTarget] = PendingInvite{pRequester, cExpiryTick}; + + spdlog::debug("[TradeService]: Player {} invited {} to trade", pRequester->GetId(), pTarget->GetId()); + + NotifyTradeInvite notify; + notify.InviterPlayerId = pRequester->GetId(); + notify.ExpiryTick = cExpiryTick; + pTarget->Send(notify); +} + +void TradeService::OnTradeInviteResponse(const PacketEvent& acPacket) noexcept +{ + Player* pResponder = acPacket.pPlayer; + if (!pResponder) + return; + + const auto& message = acPacket.Packet; + + auto it = m_pendingInvites.find(pResponder); + if (it == std::end(m_pendingInvites)) + { + SendTradeCancelled(pResponder, nullptr, TradeCancelReason::FailedValidation, false); + return; + } + + Player* pRequester = it->second.Requester; + if (!pRequester || pRequester->GetId() != message.RequesterPlayerId) + { + SendTradeCancelled(pResponder, pRequester, TradeCancelReason::FailedValidation, false); + if (pRequester) + SendTradeCancelled(pRequester, pResponder, TradeCancelReason::FailedValidation, true); + m_pendingInvites.erase(it); + return; + } + + PendingInvite invite = it->second; + m_pendingInvites.erase(it); + + if (invite.ExpiryTick <= GameServer::Get()->GetTick()) + { + SendTradeCancelled(pRequester, pResponder, TradeCancelReason::Timeout, true); + SendTradeCancelled(pResponder, pRequester, TradeCancelReason::Timeout, false); + return; + } + + if (!message.Accept) + { + spdlog::debug("[TradeService]: Player {} declined trade invite from {}", pResponder->GetId(), pRequester->GetId()); + SendTradeCancelled(pRequester, pResponder, TradeCancelReason::Declined, true); + SendTradeCancelled(pResponder, pRequester, TradeCancelReason::Declined, false); + return; + } + + if (IsPlayerBusy(pRequester) || IsPlayerBusy(pResponder)) + { + spdlog::warn("[TradeService]: Trade invite accept failed because one of the players is busy"); + SendTradeCancelled(pRequester, pResponder, TradeCancelReason::PartnerBusy, true); + SendTradeCancelled(pResponder, pRequester, TradeCancelReason::PartnerBusy, false); + return; + } + + auto* pSession = CreateSession(pRequester, pResponder); + if (!pSession) + { + spdlog::error("[TradeService]: Failed to create trade session between {} and {}", pRequester->GetId(), pResponder->GetId()); + SendTradeCancelled(pRequester, pResponder, TradeCancelReason::FailedValidation, true); + SendTradeCancelled(pResponder, pRequester, TradeCancelReason::FailedValidation, false); + return; + } + + spdlog::debug("[TradeService]: Created trade session {} between {} and {}", pSession->Id, pRequester->GetId(), pResponder->GetId()); + + SendTradeStarted(*pSession); + SendStateUpdate(*pSession); +} + +void TradeService::OnTradeOfferUpdate(const PacketEvent& acPacket) noexcept +{ + Player* pPlayer = acPacket.pPlayer; + if (!pPlayer) + return; + + TradeSession* pSession = GetSession(pPlayer); + if (!pSession) + return; + + const int32_t cIndex = GetSessionIndex(*pSession, pPlayer); + if (cIndex < 0) + return; + + const auto& message = acPacket.Packet; + + if (!ValidateOffer(pPlayer, message.Items)) + { + CancelSession(*pSession, TradeCancelReason::FailedValidation, pPlayer); + return; + } + const auto* inventory = GetInventoryFor(pPlayer); + TiltedPhoques::Vector sanitized; + sanitized.reserve(message.Items.size()); + for (const auto& requested : message.Items) + { + const auto it = std::find_if(inventory->Entries.begin(), inventory->Entries.end(), + [&requested](const auto& entry) { return SameTradeItem(entry, requested); }); + auto canonical = *it; + canonical.Count = requested.Count; + sanitized.push_back(std::move(canonical)); + } + + pSession->Offers[cIndex].Items = std::move(sanitized); + pSession->Offers[cIndex].Ready = false; + pSession->Offers[1 - cIndex].Ready = false; + pSession->Offers[cIndex].AcceptedItems.clear(); + pSession->Offers[1 - cIndex].AcceptedItems.clear(); + ResetCountdown(*pSession); + + spdlog::debug("[TradeService]: Updated offer for player {} in session {}", pPlayer->GetId(), pSession->Id); + + SendStateUpdate(*pSession); +} + +void TradeService::OnTradeSetReady(const PacketEvent& acPacket) noexcept +{ + Player* pPlayer = acPacket.pPlayer; + if (!pPlayer) + return; + + TradeSession* pSession = GetSession(pPlayer); + if (!pSession) + return; + + const int32_t cIndex = GetSessionIndex(*pSession, pPlayer); + if (cIndex < 0) + return; + + const auto& message = acPacket.Packet; + + if (!message.Ready) + { + pSession->Offers[cIndex].Ready = false; + pSession->Offers[cIndex].AcceptedItems.clear(); + ResetCountdown(*pSession); + SendStateUpdate(*pSession); + return; + } + + if (!ValidateOffer(pPlayer, pSession->Offers[cIndex].Items)) + { + // The offer no longer matches the player's inventory; keep the trade open so they can adjust it. + spdlog::warn("[TradeService]: Player {} attempted to ready invalid offer", pPlayer->GetId()); + pSession->Offers[cIndex].Ready = false; + pSession->Offers[cIndex].AcceptedItems.clear(); + ResetCountdown(*pSession); + SendStateUpdate(*pSession); + return; + } + + if (!ValidateAcceptedItems(pSession->Offers[1 - cIndex].Items, message.AcceptedItems)) + { + // The partner's offer changed after this player last saw it; they can ready again on the new state. + pSession->Offers[cIndex].Ready = false; + pSession->Offers[cIndex].AcceptedItems.clear(); + ResetCountdown(*pSession); + SendStateUpdate(*pSession); + return; + } + // Keep the server's canonical copies of the accepted partner items, not the client's echo. + const auto& incoming = pSession->Offers[1 - cIndex].Items; + const auto accepted = MatchAcceptedItems(incoming, message.AcceptedItems); + pSession->Offers[cIndex].AcceptedItems.clear(); + for (size_t i = 0; i < incoming.size(); ++i) + if (accepted[i]) + pSession->Offers[cIndex].AcceptedItems.push_back(incoming[i]); + pSession->Offers[cIndex].Ready = true; + + const bool cBothReady = pSession->Offers[0].Ready && pSession->Offers[1].Ready; + if (cBothReady) + StartCountdown(*pSession); + else + ResetCountdown(*pSession); + + SendStateUpdate(*pSession); +} + +void TradeService::OnTradeCancel(const PacketEvent& acPacket) noexcept +{ + Player* pPlayer = acPacket.pPlayer; + if (!pPlayer) + return; + + if (auto* pSession = GetSession(pPlayer)) + { + spdlog::debug("[TradeService]: Player {} cancelled trade session {}", pPlayer->GetId(), pSession->Id); + CancelSession(*pSession, TradeCancelReason::Cancelled, pPlayer); + return; + } + + RemoveInviteFor(pPlayer, TradeCancelReason::Cancelled, pPlayer); +} + +void TradeService::OnTradeInventorySync(const PacketEvent& acPacket) noexcept +{ + Player* pPlayer = acPacket.pPlayer; + TradeSession* pSession = GetSession(pPlayer); + if (!pSession) + return; + + const int32_t cIndex = GetSessionIndex(*pSession, pPlayer); + auto* pInventory = GetInventoryFor(pPlayer); + if (cIndex < 0 || !pInventory) + return; + + // Incremental inventory events miss in-place changes such as enchanting, renaming or brewing, + // so the trading player's live inventory replaces the server copy for the trade. + pInventory->Entries = acPacket.Packet.Entries; + + auto& offer = pSession->Offers[cIndex]; + auto covered = KeepCoveredTradeOffer(*pInventory, offer.Items); + if (covered.size() != offer.Items.size()) + { + spdlog::debug("[TradeService]: Dropped {} offered items no longer in player {}'s inventory", offer.Items.size() - covered.size(), pPlayer->GetId()); + offer.Items = std::move(covered); + for (auto& each : pSession->Offers) + { + each.Ready = false; + each.AcceptedItems.clear(); + } + ResetCountdown(*pSession); + } + + SendStateUpdate(*pSession); +} + +TradeService::TradeSession* TradeService::GetSession(Player* apPlayer) noexcept +{ + if (!apPlayer) + return nullptr; + + auto it = m_playerToSession.find(apPlayer); + if (it == std::end(m_playerToSession)) + return nullptr; + + auto sessionIt = m_sessions.find(it->second); + if (sessionIt == std::end(m_sessions)) + return nullptr; + + return const_cast(&sessionIt->second); +} + +const TradeService::TradeSession* TradeService::GetSession(const Player* apPlayer) const noexcept +{ + if (!apPlayer) + return nullptr; + + auto it = m_playerToSession.find(const_cast(apPlayer)); + if (it == std::end(m_playerToSession)) + return nullptr; + + auto sessionIt = m_sessions.find(it->second); + if (sessionIt == std::end(m_sessions)) + return nullptr; + + return &sessionIt->second; +} + +int32_t TradeService::GetSessionIndex(const TradeSession& aSession, const Player* apPlayer) const noexcept +{ + if (apPlayer == aSession.Players[0]) + return 0; + if (apPlayer == aSession.Players[1]) + return 1; + return -1; +} + +TradeService::TradeSession* TradeService::CreateSession(Player* apInitiator, Player* apPartner) noexcept +{ + if (!apInitiator || !apPartner) + return nullptr; + + const uint32_t cSessionId = m_nextSessionId++; + auto result = m_sessions.insert({cSessionId, TradeSession{}}); + if (!result.second) + return nullptr; + + auto* pSession = const_cast(&result.first->second); + pSession->Id = cSessionId; + pSession->Players[0] = apInitiator; + pSession->Players[1] = apPartner; + pSession->Initiator = apInitiator; + pSession->CountdownActive = false; + pSession->CountdownStartTick = 0; + pSession->CountdownDurationMs = kTradeFinalizeCountdownMs; + pSession->LastCountdownBroadcastTick = 0; + + m_playerToSession[apInitiator] = cSessionId; + m_playerToSession[apPartner] = cSessionId; + + return pSession; +} + +void TradeService::DestroySession(TradeSession& aSession) noexcept +{ + for (auto* pPlayer : aSession.Players) + { + if (pPlayer) + m_playerToSession.erase(pPlayer); + } + + m_sessions.erase(aSession.Id); +} + +void TradeService::CancelSession(TradeSession& aSession, TradeCancelReason aReason, Player* apOriginator) noexcept +{ + ResetCountdown(aSession); + TradeSession sessionCopy = aSession; + DestroySession(aSession); + + for (auto* pPlayer : sessionCopy.Players) + { + if (!pPlayer) + continue; + + Player* pPartner = (pPlayer == sessionCopy.Players[0]) ? sessionCopy.Players[1] : sessionCopy.Players[0]; + const bool cWasInitiator = (pPlayer == sessionCopy.Initiator); + SendTradeCancelled(pPlayer, pPartner, aReason, cWasInitiator); + } +} + +void TradeService::SendStateUpdate(const TradeSession& aSession) const noexcept +{ + const auto currentTick = GameServer::Get()->GetTick(); + const uint32_t remaining = GetRemainingCountdownMs(aSession, currentTick); + const uint32_t total = aSession.CountdownActive ? aSession.CountdownDurationMs : 0; + + for (int i = 0; i < 2; ++i) + { + Player* pPlayer = aSession.Players[i]; + if (!pPlayer) + continue; + + Player* pPartner = aSession.Players[1 - i]; + + NotifyTradeState notify; + notify.PartnerPlayerId = pPartner ? pPartner->GetId() : 0; + notify.SelfReady = aSession.Offers[i].Ready; + notify.PartnerReady = aSession.Offers[1 - i].Ready; + notify.SelfItems = aSession.Offers[i].Items; + notify.PartnerItems = aSession.Offers[1 - i].Items; + notify.CountdownTotalMs = total; + notify.CountdownMs = remaining; + if (aSession.Offers[1 - i].Ready) + notify.SelfAcceptedItems = aSession.Offers[1 - i].AcceptedItems; + notify.SelfInventory.clear(); + if (auto* pInventory = GetInventoryFor(pPlayer)) + notify.SelfInventory = pInventory->Entries; + + pPlayer->Send(notify); + } +} + +void TradeService::SendTradeStarted(const TradeSession& aSession) const noexcept +{ + for (int i = 0; i < 2; ++i) + { + Player* pPlayer = aSession.Players[i]; + if (!pPlayer) + continue; + + Player* pPartner = aSession.Players[1 - i]; + + NotifyTradeStarted notify; + notify.PartnerPlayerId = pPartner ? pPartner->GetId() : 0; + notify.InitiatedBySelf = (pPlayer == aSession.Initiator); + pPlayer->Send(notify); + } +} + +void TradeService::SendTradeCancelled(Player* apRecipient, Player* apPartner, TradeCancelReason aReason, bool aWasInitiator) const noexcept +{ + if (!apRecipient) + return; + + NotifyTradeCancel notify; + notify.PartnerPlayerId = apPartner ? apPartner->GetId() : 0; + notify.Reason = aReason; + notify.WasInitiator = aWasInitiator; + + apRecipient->Send(notify); +} + +void TradeService::FinalizeTrade(TradeSession& aSession) noexcept +{ + TradeSession sessionCopy = aSession; + + auto* leftInventory = GetInventoryFor(sessionCopy.Players[0]); + auto* rightInventory = GetInventoryFor(sessionCopy.Players[1]); + Inventory leftResult; + Inventory rightResult; + // Only items the recipient could resolve change hands; the rest stay with their owner. + const auto& leftTransfer = sessionCopy.Offers[1].AcceptedItems; + const auto& rightTransfer = sessionCopy.Offers[0].AcceptedItems; + if (!leftInventory || !rightInventory || + !ValidateAcceptedItems(sessionCopy.Offers[0].Items, leftTransfer) || + !ValidateAcceptedItems(sessionCopy.Offers[1].Items, rightTransfer) || + !PrepareTradeExchange(*leftInventory, *rightInventory, leftTransfer, rightTransfer, leftResult, rightResult)) + { + CancelSession(aSession, TradeCancelReason::FailedValidation); + return; + } + + // Both inventories have been prepared and checked before any live state changes. + *leftInventory = std::move(leftResult); + *rightInventory = std::move(rightResult); + for (int from = 0; from < 2; ++from) + { + Player* sender = sessionCopy.Players[from]; + Player* recipient = sessionCopy.Players[1 - from]; + const auto senderEntity = *sender->GetCharacter(); + const auto recipientEntity = *recipient->GetCharacter(); + for (const auto& item : sessionCopy.Offers[1 - from].AcceptedItems) + { + NotifyInventoryChanges removal; + removal.ServerId = World::ToInteger(senderEntity); + removal.OwnershipEpoch = m_world.get(senderEntity).OwnershipEpoch; + removal.Item = item; + removal.Item.Count = -item.Count; + sender->Send(removal); + if (!GameServer::Get()->SendToPlayersInRange(removal, senderEntity, sender)) + spdlog::error("[TradeService]: Failed to broadcast removal for {}", sender->GetId()); + + NotifyInventoryChanges addition; + addition.ServerId = World::ToInteger(recipientEntity); + addition.OwnershipEpoch = m_world.get(recipientEntity).OwnershipEpoch; + addition.Item = item; + recipient->Send(addition); + if (!GameServer::Get()->SendToPlayersInRange(addition, recipientEntity, recipient)) + spdlog::error("[TradeService]: Failed to broadcast addition for {}", recipient->GetId()); + } + } + + DestroySession(aSession); + + for (int i = 0; i < 2; ++i) + { + Player* pPlayer = sessionCopy.Players[i]; + if (!pPlayer) + continue; + + Player* pPartner = sessionCopy.Players[1 - i]; + + NotifyTradeComplete notify; + notify.PartnerPlayerId = pPartner ? pPartner->GetId() : 0; + pPlayer->Send(notify); + } +} + +bool TradeService::ValidateOffer(Player* apPlayer, const TiltedPhoques::Vector& aItems) const noexcept +{ + auto* pInventory = GetInventoryFor(apPlayer); + return pInventory && ValidateTradeOffer(*pInventory, aItems); +} + +Inventory* TradeService::GetInventoryFor(Player* apPlayer) const noexcept +{ + if (!apPlayer) + return nullptr; + + auto character = apPlayer->GetCharacter(); + if (!character) + return nullptr; + + if (!m_world.valid(*character) || !m_world.all_of(*character)) + return nullptr; + + if (m_world.get(*character).GetOwner() != apPlayer) + return nullptr; + + auto& component = m_world.get(*character); + return const_cast(&component.Content); +} + +bool TradeService::IsPlayerBusy(Player* apPlayer) const noexcept +{ + if (!apPlayer) + return false; + + if (m_playerToSession.find(const_cast(apPlayer)) != std::end(m_playerToSession)) + return true; + + if (m_pendingInvites.find(const_cast(apPlayer)) != std::end(m_pendingInvites)) + return true; + + if (HasOutgoingInvite(apPlayer)) + return true; + + return false; +} + +bool TradeService::HasOutgoingInvite(Player* apPlayer) const noexcept +{ + if (!apPlayer) + return false; + + for (const auto& [target, invite] : m_pendingInvites) + { + if (invite.Requester == apPlayer) + return true; + } + + return false; +} + +void TradeService::RemoveInviteFor(Player* apPlayer, TradeCancelReason aReason, Player* apOriginator) noexcept +{ + if (!apPlayer) + return; + + auto targetIt = m_pendingInvites.find(apPlayer); + if (targetIt != std::end(m_pendingInvites)) + { + Player* pRequester = targetIt->second.Requester; + m_pendingInvites.erase(targetIt); + + if (pRequester) + SendTradeCancelled(pRequester, apPlayer, aReason, true); + SendTradeCancelled(apPlayer, pRequester, aReason, false); + return; + } + + auto it = std::begin(m_pendingInvites); + while (it != std::end(m_pendingInvites)) + { + if (it->second.Requester == apPlayer) + { + Player* pTarget = it->first; + SendTradeCancelled(pTarget, apPlayer, aReason, false); + SendTradeCancelled(apPlayer, pTarget, aReason, true); + it = m_pendingInvites.erase(it); + } + else + { + ++it; + } + } +} + +void TradeService::StartCountdown(TradeSession& aSession) noexcept +{ + const uint64_t currentTick = GameServer::Get()->GetTick(); + aSession.CountdownActive = true; + aSession.CountdownStartTick = currentTick; + if (aSession.CountdownDurationMs == 0) + aSession.CountdownDurationMs = kTradeFinalizeCountdownMs; + aSession.LastCountdownBroadcastTick = currentTick; +} + +void TradeService::ResetCountdown(TradeSession& aSession) noexcept +{ + aSession.CountdownActive = false; + aSession.CountdownStartTick = 0; + aSession.LastCountdownBroadcastTick = 0; +} + +uint32_t TradeService::GetRemainingCountdownMs(const TradeSession& aSession, uint64_t aCurrentTick) const noexcept +{ + if (!aSession.CountdownActive || aSession.CountdownDurationMs == 0) + return 0; + + const uint64_t endTick = aSession.CountdownStartTick + aSession.CountdownDurationMs; + if (aCurrentTick >= endTick) + return 0; + + return static_cast(endTick - aCurrentTick); +} diff --git a/Code/server/Services/TradeService.h b/Code/server/Services/TradeService.h new file mode 100644 index 0000000000..ff9a56965b --- /dev/null +++ b/Code/server/Services/TradeService.h @@ -0,0 +1,108 @@ +#pragma once + +#include + +#include +#include + +struct World; +struct UpdateEvent; +struct PlayerLeaveEvent; +struct TradeInviteRequest; +struct TradeInviteResponseRequest; +struct TradeOfferUpdateRequest; +struct TradeSetReadyRequest; +struct TradeCancelRequest; +struct TradeInventorySyncRequest; + +struct Player; + +/** + * @brief Handles player to player trading sessions. + */ +class TradeService +{ +public: + TradeService(World& aWorld, entt::dispatcher& aDispatcher) noexcept; + ~TradeService() noexcept = default; + + TP_NOCOPYMOVE(TradeService); + +protected: + void OnUpdate(const UpdateEvent& acEvent) noexcept; + void OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept; + void OnTradeInvite(const PacketEvent& acPacket) noexcept; + void OnTradeInviteResponse(const PacketEvent& acPacket) noexcept; + void OnTradeOfferUpdate(const PacketEvent& acPacket) noexcept; + void OnTradeSetReady(const PacketEvent& acPacket) noexcept; + void OnTradeCancel(const PacketEvent& acPacket) noexcept; + void OnTradeInventorySync(const PacketEvent& acPacket) noexcept; + +private: + struct TradeOffer + { + // GCC evaluates the map's value traits before the enclosing service is complete. + TradeOffer() noexcept + : Ready(false) + { + } + + TiltedPhoques::Vector Items; + // Items of the partner's offer this player can resolve and receive. + TiltedPhoques::Vector AcceptedItems; + bool Ready; + }; + + struct TradeSession + { + uint32_t Id{0}; + Player* Players[2]{nullptr, nullptr}; + Player* Initiator{nullptr}; + TradeOffer Offers[2]; + bool CountdownActive{false}; + uint64_t CountdownStartTick{0}; + uint32_t CountdownDurationMs{0}; + uint64_t LastCountdownBroadcastTick{0}; + }; + + struct PendingInvite + { + Player* Requester{nullptr}; + uint64_t ExpiryTick{0}; + }; + + TradeSession* GetSession(Player* apPlayer) noexcept; + const TradeSession* GetSession(const Player* apPlayer) const noexcept; + int32_t GetSessionIndex(const TradeSession& aSession, const Player* apPlayer) const noexcept; + TradeSession* CreateSession(Player* apInitiator, Player* apPartner) noexcept; + void DestroySession(TradeSession& aSession) noexcept; + void CancelSession(TradeSession& aSession, TradeCancelReason aReason, Player* apOriginator = nullptr) noexcept; + void SendStateUpdate(const TradeSession& aSession) const noexcept; + void SendTradeStarted(const TradeSession& aSession) const noexcept; + void SendTradeCancelled(Player* apRecipient, Player* apPartner, TradeCancelReason aReason, bool aWasInitiator) const noexcept; + void FinalizeTrade(TradeSession& aSession) noexcept; + bool ValidateOffer(Player* apPlayer, const TiltedPhoques::Vector& aItems) const noexcept; + Inventory* GetInventoryFor(Player* apPlayer) const noexcept; + bool IsPlayerBusy(Player* apPlayer) const noexcept; + bool HasOutgoingInvite(Player* apPlayer) const noexcept; + void RemoveInviteFor(Player* apPlayer, TradeCancelReason aReason, Player* apOriginator = nullptr) noexcept; + void StartCountdown(TradeSession& aSession) noexcept; + void ResetCountdown(TradeSession& aSession) noexcept; + uint32_t GetRemainingCountdownMs(const TradeSession& aSession, uint64_t aCurrentTick) const noexcept; + + World& m_world; + + TiltedPhoques::Map m_sessions; + TiltedPhoques::Map m_playerToSession; + TiltedPhoques::Map m_pendingInvites; + uint32_t m_nextSessionId{1}; + + entt::scoped_connection m_updateConnection; + entt::scoped_connection m_playerLeaveConnection; + entt::scoped_connection m_tradeInviteConnection; + entt::scoped_connection m_tradeInviteResponseConnection; + entt::scoped_connection m_tradeOfferUpdateConnection; + entt::scoped_connection m_tradeSetReadyConnection; + entt::scoped_connection m_tradeCancelConnection; + entt::scoped_connection m_tradeInventorySyncConnection; +}; diff --git a/Code/server/Services/VoiceService.cpp b/Code/server/Services/VoiceService.cpp new file mode 100644 index 0000000000..20cb7fbd4f --- /dev/null +++ b/Code/server/Services/VoiceService.cpp @@ -0,0 +1,108 @@ +#include + +#include +#include +#include + +#include + +#include +#include +#include + +#include + +extern Console::Setting bVoip; + +namespace +{ +// A speaker sends 50 frames a second, this leaves room for bursts after a stall without letting a client flood +constexpr uint32_t kMaxFramesPerSecond = 75; +} // namespace + +VoiceService::VoiceService(World& aWorld, entt::dispatcher& aDispatcher) noexcept + : m_world(aWorld) +{ + m_voiceDataConnection = aDispatcher.sink>().connect<&VoiceService::OnVoiceDataRequest>(this); + m_voiceStateConnection = aDispatcher.sink>().connect<&VoiceService::OnVoiceStateRequest>(this); + m_playerLeaveConnection = aDispatcher.sink().connect<&VoiceService::OnPlayerLeave>(this); +} + +void VoiceService::OnVoiceDataRequest(const PacketEvent& acMessage) noexcept +{ + if (!bVoip) + return; + + const auto& frame = acMessage.Packet.Frame; + if (frame.Data.empty()) + return; + + Player* const pSpeaker = acMessage.pPlayer; + if (!IsWithinRateLimit(pSpeaker->GetId())) + return; + + // Only players who are in the world can be heard + const auto speakerCharacter = pSpeaker->GetCharacter(); + if (!speakerCharacter) + return; + + const auto* pSpeakerCell = m_world.try_get(*speakerCharacter); + const CellIdComponent& speakerCell = pSpeakerCell ? *pSpeakerCell : pSpeaker->GetCellComponent(); + const auto& speakerParty = pSpeaker->GetParty().JoinedPartyId; + + NotifyVoiceData notify{}; + notify.PlayerId = pSpeaker->GetId(); + notify.Frame = frame; + + for (Player* pListener : m_world.GetPlayerManager()) + { + if (pListener == pSpeaker) + continue; + + const auto listenerItor = m_listeners.find(pListener->GetId()); + if (listenerItor == m_listeners.end() || !listenerItor->second.Listening) + continue; + + const auto& listenerParty = pListener->GetParty().JoinedPartyId; + const bool sameParty = speakerParty.has_value() && listenerParty.has_value() && *speakerParty == *listenerParty; + + const auto channel = listenerItor->second.Channel; + if (channel == VoiceStateRequest::kParty && !sameParty) + continue; + if (channel == VoiceStateRequest::kStrangers && sameParty) + continue; + + if (!speakerCell.IsInRange(pListener->GetCellComponent(), false)) + continue; + + // Late voice is useless, so no resends, and no Nagle delay either + pListener->Send(notify, TiltedPhoques::kUnreliableNoNagle); + } +} + +void VoiceService::OnVoiceStateRequest(const PacketEvent& acMessage) noexcept +{ + auto& listener = m_listeners[acMessage.pPlayer->GetId()]; + listener.Listening = acMessage.Packet.Listening; + listener.Channel = acMessage.Packet.ListenChannel; +} + +void VoiceService::OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept +{ + m_listeners.erase(acEvent.pPlayer->GetId()); + m_speakers.erase(acEvent.pPlayer->GetId()); +} + +bool VoiceService::IsWithinRateLimit(uint32_t aPlayerId) noexcept +{ + const auto now = std::chrono::steady_clock::now(); + auto& speaker = m_speakers[aPlayerId]; + + if (now - speaker.WindowStart >= std::chrono::seconds(1)) + { + speaker.WindowStart = now; + speaker.FramesInWindow = 0; + } + + return ++speaker.FramesInWindow <= kMaxFramesPerSecond; +} diff --git a/Code/server/Services/VoiceService.h b/Code/server/Services/VoiceService.h new file mode 100644 index 0000000000..dc00123800 --- /dev/null +++ b/Code/server/Services/VoiceService.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +struct World; +struct Player; +struct PlayerLeaveEvent; +struct VoiceDataRequest; +struct VoiceStateRequest; + +/** + * @brief Relays proximity voice chat between players. + * + * Voice frames are Opus packets the clients encode and decode themselves, the server only decides who gets them: + * players in range of the speaker who have voip on and want that speaker's channel. Distance falloff is done by + * each listener. Enabled with Voice:bVoip in STServer.ini. + */ +struct VoiceService +{ + VoiceService(World& aWorld, entt::dispatcher& aDispatcher) noexcept; + ~VoiceService() noexcept = default; + + TP_NOCOPYMOVE(VoiceService); + +protected: + void OnVoiceDataRequest(const PacketEvent& acMessage) noexcept; + void OnVoiceStateRequest(const PacketEvent& acMessage) noexcept; + void OnPlayerLeave(const PlayerLeaveEvent& acEvent) noexcept; + +private: + struct Listener + { + bool Listening{false}; + uint8_t Channel{0}; + }; + + struct Speaker + { + std::chrono::steady_clock::time_point WindowStart{}; + uint32_t FramesInWindow{0}; + }; + + bool IsWithinRateLimit(uint32_t aPlayerId) noexcept; + + World& m_world; + + // Keyed by player id + Map m_listeners; + Map m_speakers; + + entt::scoped_connection m_voiceDataConnection; + entt::scoped_connection m_voiceStateConnection; + entt::scoped_connection m_playerLeaveConnection; +}; diff --git a/Code/server/World.cpp b/Code/server/World.cpp index 8bfd510499..615f2365c5 100644 --- a/Code/server/World.cpp +++ b/Code/server/World.cpp @@ -12,9 +12,13 @@ #include #include #include +#include #include #include #include +#include +#include +#include #include @@ -34,9 +38,13 @@ World::World() ctx().emplace(*this, m_dispatcher); ctx().emplace(*this, m_dispatcher); ctx().emplace(*this, m_dispatcher); + ctx().emplace(*this, m_dispatcher); ctx().emplace(*this, m_dispatcher); ctx().emplace(*this, m_dispatcher); 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/server/World.h b/Code/server/World.h index e3509ffc96..efe8ebfb4a 100644 --- a/Code/server/World.h +++ b/Code/server/World.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "Game/PlayerManager.h" @@ -29,6 +30,8 @@ struct World : entt::registry const PlayerService& GetPlayerService() const noexcept { return ctx().at(); } PartyService& GetPartyService() noexcept { return ctx().at(); } const PartyService& GetPartyService() const noexcept { return ctx().at(); } + TradeService& GetTradeService() noexcept { return ctx().at(); } + const TradeService& GetTradeService() const noexcept { return ctx().at(); } CalendarService& GetCalendarService() noexcept { return ctx().at(); } const CalendarService& GetCalendarService() const noexcept { return ctx().at(); } QuestService& GetQuestService() noexcept { return ctx().at(); } diff --git a/Code/server_runner/DediRunner.cpp b/Code/server_runner/DediRunner.cpp index 334d1a98d9..b5de7db489 100644 --- a/Code/server_runner/DediRunner.cpp +++ b/Code/server_runner/DediRunner.cpp @@ -2,6 +2,7 @@ #include "DediRunner.h" #include +#include #include #include @@ -79,11 +80,17 @@ bool DediRunner::LoadSettings(int argc, char** argv) { create_directory(fs::current_path() / kConfigPathName); SaveSettingsToIni(m_console, m_SettingsPath); + // Environment variables override the defaults in the fresh ini too. + LoadSettingsFromEnvironment(m_console); return true; } LoadSettingsFromIni(m_console, m_SettingsPath); } + // Environment variables override both the ini file and the command line, + // so a container can be configured without touching either. + LoadSettingsFromEnvironment(m_console); + return true; } diff --git a/Code/skyrim_ui/package.json b/Code/skyrim_ui/package.json index 1e203fc5cc..3669f15e3e 100644 --- a/Code/skyrim_ui/package.json +++ b/Code/skyrim_ui/package.json @@ -5,6 +5,7 @@ "ng": "ng", "start": "ng serve --host 0.0.0.0 --disable-host-check --configuration=browser", "build": "ng build", + "test:trading": "node --test test/trade-ui.test.cjs", "deploy:develop": "ng build --output-hashing none --configuration=develop --base-href ./ --resources-output-path ./assets/", "deploy:production": "ng build --output-hashing none --configuration=production --base-href ./ --resources-output-path ./assets/", "lint": "ng lint", diff --git a/Code/skyrim_ui/src/app/app.module.ts b/Code/skyrim_ui/src/app/app.module.ts index e27772139e..aa74f2f2df 100644 --- a/Code/skyrim_ui/src/app/app.module.ts +++ b/Code/skyrim_ui/src/app/app.module.ts @@ -6,6 +6,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { VoipMicComponent } from './components/voip/voip-mic.component'; +import { VoipSpeakerComponent } from './components/voip/voip-speaker.component'; import { environment } from '../environments/environment'; import { ActionButtonsComponent } from './components/action-buttons/action-buttons.component'; import { ChatComponent } from './components/chat/chat.component'; @@ -25,6 +27,7 @@ import { OrderComponent } from './components/order/order.component'; import { PartyMenuComponent } from './components/party-menu/party-menu.component'; import { PlayerListComponent } from './components/player-list/player-list.component'; import { PlayerManagerComponent } from './components/player-manager/player-manager.component'; +import { TradePopupComponent } from './components/trade-popup/trade-popup.component'; import { PopupComponent } from './components/popup/popup.component'; import { RootComponent } from './components/root/root.component'; import { ServerListComponent } from './components/server-list/server-list.component'; @@ -42,6 +45,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'; @@ -65,12 +70,15 @@ import { TranslocoRootModule } from './transloco-root.module'; PartyMenuComponent, PlayerListComponent, PlayerManagerComponent, + TradePopupComponent, ActionButtonsComponent, PopupComponent, RootComponent, ServerListComponent, SettingsComponent, ToggleComponent, + VoipMicComponent, + VoipSpeakerComponent, WindowComponent, CheckboxDirective, @@ -99,6 +107,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/chat/chat.component.ts b/Code/skyrim_ui/src/app/components/chat/chat.component.ts index 4e09f2e3f9..6fe651f8c2 100644 --- a/Code/skyrim_ui/src/app/components/chat/chat.component.ts +++ b/Code/skyrim_ui/src/app/components/chat/chat.component.ts @@ -18,6 +18,7 @@ import { MessageTypes, } from 'src/app/services/chat.service'; import { ClientService } from 'src/app/services/client.service'; +import { PlayerUiService } from 'src/app/services/player-ui.service'; interface ChatComponentMessage extends ChatMessage { date: number; @@ -80,12 +81,21 @@ export class ChatComponent implements AfterViewChecked { private readonly chatService: ChatService, private readonly sound: SoundService, private readonly translocoService: TranslocoService, + private readonly playerUiService: PlayerUiService, ) { chatService.messageList .pipe(takeUntil(this.destroy$)) .subscribe(message => { const typeClass = messageTypeToClassName(message.type); - this.messages.push({ ...message, date: Date.now(), typeClass }); + const senderName = + message.senderName && + this.playerUiService.chatSenderName(message.senderName); + this.messages.push({ + ...message, + senderName, + date: Date.now(), + typeClass, + }); if (this.messages.length > 100) { if (this.entryRefQuery && this.entryRefQuery.first) { 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..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" >
{{ 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()); @@ -95,7 +98,7 @@ export class GroupComponent implements OnInit, OnDestroy { } private subscribeChangeHealth() { - this.clientService.healthChange + this.groupService.memberHealthChange .pipe(takeUntil(this.destroy$)) .subscribe(() => { this.flashGroup(); @@ -210,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/components/notifications/notifications.component.ts b/Code/skyrim_ui/src/app/components/notifications/notifications.component.ts index f949000711..b0f5b4a9f6 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,12 +20,19 @@ 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, + senderName: + message.senderName && + this.playerUiService.chatSenderName(message.senderName), timer: setTimeout(() => { this.notifications.shift(); }, 5000), 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..4574419f74 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 }} +
+ +
@@ -69,6 +82,7 @@ {{ 'COMPONENT.PARTY_MENU.TABLE_HEADERS.LEVEL' | transloco }} + {{ 'COMPONENT.PARTY_MENU.TABLE_HEADERS.NAME' | transloco }} {{ 'COMPONENT.PARTY_MENU.TABLE_HEADERS.LOCATION' | transloco }} @@ -79,7 +93,24 @@ {{ 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 00d7353b6b..a1cd2bc0bc 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,43 @@ 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%; } } } + +.eye-col { + width: 4em; + text-align: center; +} + +.eye-button { + padding: 0.25vw 0.5vw; + background: none; + border: 0; + cursor: pointer; + + &.hidden-name { + color: #e0483e; + } +} + +.stranger-name { + font-style: italic; + opacity: 0.75; +} + +.voip-col { + white-space: nowrap; + text-align: center; +} 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 f65bd9ae98..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 @@ -1,10 +1,13 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; +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'; +import { PlayerUiService } from 'src/app/services/player-ui.service'; @Component({ selector: 'app-player-list', @@ -13,27 +16,54 @@ import { Player } from '../../models/player'; 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; + readonly faEyeSlash = faEyeSlash; + hiddenPlayerIds$ = this.playerUiService.hiddenPlayerIds$; constructor( private readonly playerListService: PlayerListService, + 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), @@ -55,7 +85,19 @@ export class PlayerListComponent { ); } + public toggleName(name: string, isHidden: boolean) { + this.playerUiService.setNameRevealed(name, isHidden); + } + 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..0806921bef 100644 --- a/Code/skyrim_ui/src/app/components/root/root.component.html +++ b/Code/skyrim_ui/src/app/components/root/root.component.html @@ -11,9 +11,11 @@
+
+

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

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

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

@@ -73,6 +195,240 @@

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

+
+

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

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

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

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

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

diff --git a/Code/skyrim_ui/src/app/components/settings/settings.component.scss b/Code/skyrim_ui/src/app/components/settings/settings.component.scss index 208f2549b7..6685dd24f3 100644 --- a/Code/skyrim_ui/src/app/components/settings/settings.component.scss +++ b/Code/skyrim_ui/src/app/components/settings/settings.component.scss @@ -62,6 +62,12 @@ & > label { width: 15em; + + .hint { + display: block; + font-size: 0.75em; + opacity: 0.7; + } } & > input, 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 6e235e70a8..4e9563b8d4 100644 --- a/Code/skyrim_ui/src/app/components/settings/settings.component.ts +++ b/Code/skyrim_ui/src/app/components/settings/settings.component.ts @@ -5,6 +5,8 @@ import { Tag } from 'src/app/models/tag'; import { autoHideTimerLengths, FontSize, + KnownNames, + PlayerUiFilter, ScreenCorner, SettingService, } from 'src/app/services/setting.service'; @@ -12,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', @@ -46,6 +53,51 @@ export class SettingsComponent { }, ]; readonly availableAutoHideTimes = autoHideTimerLengths; + readonly availableKnownNames: { id: KnownNames; label: string }[] = [ + { + id: KnownNames.ALL, + label: 'COMPONENT.SETTINGS.PLAYER_UI.KNOWN_NAMES_OPTIONS.ALL', + }, + { + id: KnownNames.PARTY, + label: 'COMPONENT.SETTINGS.PLAYER_UI.KNOWN_NAMES_OPTIONS.PARTY', + }, + { + id: KnownNames.NONE, + label: 'COMPONENT.SETTINGS.PLAYER_UI.KNOWN_NAMES_OPTIONS.NONE', + }, + ]; + readonly availablePlayerUiFilters: { id: PlayerUiFilter; label: string }[] = + [ + { + id: PlayerUiFilter.ON, + label: 'COMPONENT.SETTINGS.PLAYER_UI.FILTERS.ON', + }, + { + id: PlayerUiFilter.PARTY, + label: 'COMPONENT.SETTINGS.PLAYER_UI.FILTERS.PARTY', + }, + { + id: PlayerUiFilter.OFF, + label: 'COMPONENT.SETTINGS.PLAYER_UI.FILTERS.OFF', + }, + ]; + + 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; @@ -68,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 : 2000 }); } close() { 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 new file mode 100644 index 0000000000..db4af13dc7 --- /dev/null +++ b/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.html @@ -0,0 +1,201 @@ + +
+ +
+ +
+
+

{{ 'COMPONENT.TRADE_MENU.TRADING_WITH' | transloco : { name: session.partnerName } }}

+
+ + {{ + (session.selfReady + ? 'COMPONENT.TRADE_MENU.SELF_READY' + : 'COMPONENT.TRADE_MENU.SELF_NOT_READY' + ) | transloco + }} + + + {{ + (session.partnerReady + ? 'COMPONENT.TRADE_MENU.PARTNER_READY' + : 'COMPONENT.TRADE_MENU.PARTNER_NOT_READY' + ) | transloco : { name: session.partnerName } + }} + +
+ +
+
+
+

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

+ +
+
+ +
+
+
+
+ +
+ {{ (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 }} +
+
{{ 'COMPONENT.TRADE_MENU.ITEM_COLUMNS.AVAILABLE' | transloco }}: {{ item.available }} + {{ 'COMPONENT.TRADE_MENU.ITEM_COLUMNS.OFFERED' | transloco }} +
+ + + + + +
+
+ + +
+
+
+
+
+
+

+ {{ '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..663d07cbea --- /dev/null +++ b/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.scss @@ -0,0 +1,96 @@ +@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-y: auto; + 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; } +// Keep the list usable on short screens; the window scrolls when its sections no longer fit. +.inventory-list { flex: 1 1 auto; min-height: 10rem; 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); min-height: 8rem; 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); } +.trade-toolbar { display: flex; justify-content: flex-end; flex: 0 0 auto; } +.compact-label { display: none; } +.title, .status { overflow-wrap: anywhere; } + +// Use the measured content width in rem so XL text also triggers this layout on larger displays. +:host-context([data-trade-compact='true']) { + .trade-header { grid-template-columns: minmax(0, 1fr); } + .inventory-list { flex: 0 0 auto; max-height: 18rem; } + table, tbody, tr, td { display: block; width: auto; } + thead { display: none; } + tr { padding-bottom: .75rem; border-bottom: 1px solid #555; } + td { border: 0; } + .available-column { text-align: left; padding-right: var(--inventory-cell-padding); } + .compact-label { display: inline; } + .offer-editor { display: flex; flex-wrap: wrap; white-space: normal; } + .offer-editor input { width: 4rem; } + .offers-section { height: auto; grid-template-columns: minmax(0, 1fr); } + .offer-column { max-height: 12rem; } + .actions { grid-row: 3; flex-direction: row; flex-wrap: wrap; } + .actions button { overflow-wrap: anywhere; } +} 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..bc9bcd19b6 --- /dev/null +++ b/Code/skyrim_ui/src/app/components/trade-popup/trade-popup.component.ts @@ -0,0 +1,66 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Output } 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 { + @Output() public readonly minimize = new EventEmitter(); + 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/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 9a2ec37900..bdccd52c58 100644 --- a/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts +++ b/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts @@ -29,6 +29,18 @@ 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, + number, + ] = ['', 0, '', 'everyone', 2000, 100, 5, 100]; + private voiceMeterTimer: ReturnType | undefined; + private voiceTalkingTimer: ReturnType | undefined; connect(host: string, port: number, password: string): void { if (!this.connected) { @@ -113,6 +125,10 @@ export class SkyrimtogetherMock extends EventEmitter implements SkyrimTogether { this.sendMessage(MessageTypes.SYSTEM_MESSAGE, `Setting time to "${hours}:${minutes}"!`); } + wave(): void { + this.sendMessage(MessageTypes.SYSTEM_MESSAGE, 'You wave.'); + } + sendMessage(type: MessageTypes, message: string): void { if (this.connected) { this.emit('message', type, message, this.playerName); @@ -197,6 +213,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) => { @@ -310,6 +371,84 @@ export class SkyrimtogetherMock extends EventEmitter implements SkyrimTogether { ); } + setNameTagSettings(settings: NameTagSettings): void { + 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, + masterVolume: number, + ): void { + this.voiceSettings = [ + inputDevice, + connect, + outputDevice, + channel, + fadeStart, + micVolume, + noiseGate, + masterVolume, + ]; + 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/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/chat/commands.ts b/Code/skyrim_ui/src/app/services/chat/commands.ts index e43c735930..a9cc3f1737 100644 --- a/Code/skyrim_ui/src/app/services/chat/commands.ts +++ b/Code/skyrim_ui/src/app/services/chat/commands.ts @@ -23,12 +23,12 @@ export class CommandHandler { } private SetTime: Command = { - name: 'settime', + name: 'settime', executor: async (args) => { const cmds = [...this.commands.keys()].join(', '); if (args.length != 2) { this.chatService.pushSystemMessage( - 'COMPONENT.CHAT.SET_TIME_ARGUMENT_COUNT', + 'COMPONENT.CHAT.SET_TIME_ARGUMENT_COUNT', { cmds }, ); return; @@ -48,11 +48,19 @@ export class CommandHandler { }, } + private Wave: Command = { + name: 'wave', + executor: async () => { + skyrimtogether.wave(); + }, + } + private readonly commands = new Map(); public constructor(private readonly chatService: ChatService) { this.register(this.Help); this.register(this.SetTime); + this.register(this.Wave); } public readonly COMMAND_PREFIX = '/'; diff --git a/Code/skyrim_ui/src/app/services/client.service.ts b/Code/skyrim_ui/src/app/services/client.service.ts index cb69624922..b6b09c1e59 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,10 +342,42 @@ 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(); } + /** + * Send the name tag settings to the game client. + */ + public setNameTagSettings(settings: NameTagSettings): void { + skyrimtogether.setNameTagSettings(settings); + } + /** * Deactivate UI and release control. */ @@ -484,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) }), + ); }); } @@ -644,6 +754,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/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); } } }, diff --git a/Code/skyrim_ui/src/app/services/player-list.service.ts b/Code/skyrim_ui/src/app/services/player-list.service.ts index cc81ac68e7..08f366a0a9 100644 --- a/Code/skyrim_ui/src/app/services/player-list.service.ts +++ b/Code/skyrim_ui/src/app/services/player-list.service.ts @@ -8,6 +8,7 @@ import { View } from '../models/view.enum'; import { UiRepository } from '../store/ui.repository'; import { ClientService } from './client.service'; import { PopupNotificationService } from './popup-notification.service'; +import { SettingService } from './setting.service'; @Injectable({ providedIn: 'root', @@ -30,6 +31,7 @@ export class PlayerListService implements OnDestroy { private readonly popupNotificationService: PopupNotificationService, private readonly uiRepository: UiRepository, private readonly translocoService: TranslocoService, + private readonly settingService: SettingService, ) { this.onDebug(); this.onConnectionStateChanged(); @@ -128,6 +130,10 @@ export class PlayerListService implements OnDestroy { this.partyInviteReceivedSubscription = this.clientService.partyInviteReceivedChange.subscribe( async (inviterId: number) => { + if (!this.settingService.settings.partyInvites.getValue()) { + return; + } + const playerList = this.getPlayerList(); if (playerList) { @@ -135,7 +141,12 @@ export class PlayerListService implements OnDestroy { invitingPlayer.hasInvitedLocalPlayer = true; this.playerList.next(playerList); this.popupNotificationService.addPartyInvite( - invitingPlayer.name, + // The inviter can't be in our party yet. + this.settingService.isNameRevealed(invitingPlayer.name, false) + ? invitingPlayer.name + : this.translocoService.translate( + 'COMPONENT.PARTY_LIST.STRANGER', + ), () => this.acceptPartyInvite(inviterId), ); } diff --git a/Code/skyrim_ui/src/app/services/player-ui.service.ts b/Code/skyrim_ui/src/app/services/player-ui.service.ts new file mode 100644 index 0000000000..7ac7eff7ae --- /dev/null +++ b/Code/skyrim_ui/src/app/services/player-ui.service.ts @@ -0,0 +1,268 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { TranslocoService } from '@ngneat/transloco'; +import { + combineLatest, + debounceTime, + distinctUntilChanged, + map, + Observable, + ReplaySubject, + share, + Subscription, +} from 'rxjs'; +import { Player } from '../models/player'; +import { ChatMessage, MessageTypes } from './chat.service'; +import { ClientService } from './client.service'; +import { GroupService } from './group.service'; +import { PlayerListService } from './player-list.service'; +import { TradeInvitePolicy } from './trade-invite-policy'; +import { + fontSizeToPixels, + PlayerUiFilter, + SettingService, +} from './setting.service'; + +export interface PlayerUiEntry extends Player { + isMember: boolean; + /** Whether the real name may be shown, after the eye override. */ + isNameRevealed: boolean; +} + +/** + * Applies the "Player UI filters" settings: stranger names (with per-username + * overrides), invite filters, the mini chat preview, and the name tag options + * forwarded to the game client. + */ +@Injectable({ + providedIn: 'root', +}) +export class PlayerUiService implements OnDestroy, TradeInvitePolicy { + public readonly players$: Observable; + /** Players whose name is hidden behind the "Stranger" label. */ + public readonly hiddenPlayerIds$: Observable>; + + private readonly settings = this.settingService.settings; + private nameTagSubscription: Subscription; + + constructor( + private readonly settingService: SettingService, + private readonly playerListService: PlayerListService, + private readonly groupService: GroupService, + private readonly clientService: ClientService, + private readonly translocoService: TranslocoService, + ) { + this.players$ = combineLatest([ + this.playerListService.playerList, + this.groupService.group, + this.settings.knownNames, + this.settingService.nameOverrides, + ]).pipe( + map(([playerList, group]) => { + const members = group?.members ?? []; + return (playerList?.players ?? []).map(player => { + const isMember = members.includes(player.id); + return { + ...player, + isMember, + // The eye wins over both defaults, so party members can be + // hidden too. + isNameRevealed: this.settingService.isNameRevealed( + player.name, + isMember, + ), + }; + }); + }), + share({ + connector: () => new ReplaySubject(1), + resetOnRefCountZero: false, + }), + ); + + this.hiddenPlayerIds$ = this.players$.pipe( + map( + players => + new Set( + players + .filter(player => !player.isNameRevealed) + .map(player => player.id), + ), + ), + ); + + this.onNameTagSettingsChanged(); + } + + ngOnDestroy() { + this.nameTagSubscription.unsubscribe(); + } + + /** Set by the eye in the player list; stored per username. */ + public setNameRevealed(name: string, revealed: boolean) { + this.settingService.setNameOverride(name, revealed); + } + + public acceptsPartyInvites(): boolean { + return this.settings.partyInvites.getValue(); + } + + /** + * Chat sender name after the known chat names setting; the eye in the + * player list overrides it per name. Call it when the message arrives, so a + * sender who leaves later stays hidden. + */ + public chatSenderName(name: string): string { + const players = this.playerListService.playerList.getValue()?.players; + const sender = players?.find(player => player.name === name); + // The local player isn't in their own player list, so that's who an + // unknown sender is. Known risk: a player whose list entry hasn't arrived + // yet (just joined) is shown by name too. + if (!sender) { + return name; + } + + return this.isChatNameKnown(name, this.isPartyMember(sender.id)) + ? name + : this.translocoService.translate('COMPONENT.PARTY_LIST.STRANGER'); + } + + /** Name for trade popups and the trade window, same rule as the player list. */ + public displayName(playerId: number, name: string): string { + return this.settingService.isNameRevealed(name, this.isPartyMember(playerId)) + ? name + : this.translocoService.translate('COMPONENT.PARTY_LIST.STRANGER'); + } + + public acceptsTradeInvite(inviterId: number): boolean { + return this.matchesFilter( + this.settings.tradeInvites.getValue(), + this.isPartyMember(inviterId), + ); + } + + /** Whether a chat message may show in the in-game preview (F2 closed). */ + public showsInMiniChat(message: ChatMessage): boolean { + const filter = this.settings.miniChat.getValue(); + if (filter !== PlayerUiFilter.PARTY) { + return filter === PlayerUiFilter.ON; + } + + if ( + message.type === MessageTypes.SYSTEM_MESSAGE || + message.type === MessageTypes.PARTY_CHAT + ) { + return true; + } + + const members = this.groupService.group.getValue()?.members ?? []; + const players = this.playerListService.playerList.getValue()?.players; + return !!players?.some( + player => + player.name === message.senderName && members.includes(player.id), + ); + } + + private isChatNameKnown( + name: string, + isPartyMember: boolean, + knownChatNames = this.settings.knownChatNames.getValue(), + ): boolean { + return ( + this.settingService.nameOverrides.getValue()[name] ?? + this.matchesFilter(knownChatNames, isPartyMember) + ); + } + + private isPartyMember(playerId: number): boolean { + return !!this.groupService.group.getValue()?.members.includes(playerId); + } + + private matchesFilter(filter: PlayerUiFilter, isMember: boolean): boolean { + return ( + filter === PlayerUiFilter.ON || + (filter === PlayerUiFilter.PARTY && isMember) + ); + } + + private onNameTagSettingsChanged() { + const filterIndex = (filter: PlayerUiFilter) => + Object.values(PlayerUiFilter).indexOf(filter); + + this.nameTagSubscription = combineLatest([ + this.players$, + this.settings.nameTags, + this.settings.nameBackdrop, + this.settings.floatingChat, + this.settings.knownChatNames, + this.settings.nameTagSize, + this.settings.nameFadeStart, + this.settings.nameInvisible, + this.settings.partyFadeStart, + this.settings.partyInvisible, + this.translocoService.selectTranslate('COMPONENT.PARTY_LIST.STRANGER'), + ]).pipe( + debounceTime(100), + map( + ([ + players, + nameTags, + backdrop, + floatingChat, + knownChatNames, + size, + nameFadeStart, + nameInvisible, + partyFadeStart, + partyInvisible, + strangerLabel, + ]) => ({ + nameTags: filterIndex(nameTags), + backdrop: filterIndex(backdrop), + floatingChat: filterIndex(floatingChat), + fontSize: fontSizeToPixels[size], + nameFadeStart: Number(nameFadeStart), + nameInvisible: Number(nameInvisible), + partyFadeStart: Number(partyFadeStart), + partyInvisible: Number(partyInvisible), + strangerLabel, + // The order of the player list is not a settings change. + revealedPlayerIds: players + .filter(player => player.isNameRevealed) + .map(player => player.id) + .sort((a, b) => a - b), + revealedChatPlayerIds: players + .filter(player => + this.isChatNameKnown( + player.name, + player.isMember, + knownChatNames, + ), + ) + .map(player => player.id) + .sort((a, b) => a - b), + }), + ), + distinctUntilChanged( + (previous, current) => + previous.nameTags === current.nameTags && + previous.backdrop === current.backdrop && + previous.floatingChat === current.floatingChat && + previous.fontSize === current.fontSize && + previous.nameFadeStart === current.nameFadeStart && + previous.nameInvisible === current.nameInvisible && + previous.partyFadeStart === current.partyFadeStart && + previous.partyInvisible === current.partyInvisible && + previous.strangerLabel === current.strangerLabel && + previous.revealedPlayerIds.length === current.revealedPlayerIds.length && + previous.revealedPlayerIds.every( + (id, index) => id === current.revealedPlayerIds[index], + ) && + previous.revealedChatPlayerIds.length === + current.revealedChatPlayerIds.length && + previous.revealedChatPlayerIds.every( + (id, index) => id === current.revealedChatPlayerIds[index], + ), + ), + ).subscribe(settings => this.clientService.setNameTagSettings(settings)); + } +} 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/setting.service.ts b/Code/skyrim_ui/src/app/services/setting.service.ts index 3007454390..cc5e7ec93b 100644 --- a/Code/skyrim_ui/src/app/services/setting.service.ts +++ b/Code/skyrim_ui/src/app/services/setting.service.ts @@ -18,6 +18,19 @@ export enum ScreenCorner { BOTTOM_LEFT, } +export enum PlayerUiFilter { + ON = 'on', + PARTY = 'party', + OFF = 'off', +} + +/** Whose names are known by default; the eye in the player list overrides per name. */ +export enum KnownNames { + ALL = 'all', + PARTY = 'party', + NONE = 'none', +} + export const autoHideTimerLengths = [1, 3, 5]; export const fontSizeToPixels: Record = { @@ -101,6 +114,7 @@ export class SettingService { ScreenCorner, ) as ScreenCorner[]; private readonly autoHideTimeValues = autoHideTimerLengths; + private readonly playerUiFilterValues = Object.values(PlayerUiFilter); public settings = { volume: new SliderSetting(this.storeService, 'audio_volume', 0.5), @@ -152,8 +166,98 @@ export class SettingService { this.screenCornerValues, ScreenCorner.TOP_LEFT, ), + // Unknown names show as "Stranger", invites included. Replaces the old + // "Global stranger names" toggle: checked maps to None, unchecked to All. + knownNames: new SelectSetting( + this.storeService, + 'playerUi_knownNames', + Object.values(KnownNames), + this.storeService.getBool('playerUi_hideStrangerNames', false) + ? KnownNames.NONE + : KnownNames.ALL, + ), + // Chat sender names; the eye in the player list overrides this per name. + knownChatNames: new SelectSetting( + this.storeService, + 'playerUi_knownChatNames', + this.playerUiFilterValues, + PlayerUiFilter.ON, + ), + // Party: party members' chat and dialogue choices float above them. + // On: strangers' chat too. + floatingChat: new SelectSetting( + this.storeService, + 'playerUi_floatingChat', + this.playerUiFilterValues, + PlayerUiFilter.PARTY, + ), + partyInvites: new ToggleSetting( + this.storeService, + 'playerUi_partyInvites', + true, + ), + tradeInvites: new SelectSetting( + this.storeService, + 'playerUi_tradeInvites', + this.playerUiFilterValues, + PlayerUiFilter.ON, + ), + miniChat: new SelectSetting( + this.storeService, + 'playerUi_miniChat', + this.playerUiFilterValues, + PlayerUiFilter.ON, + ), + nameTags: new SelectSetting( + this.storeService, + 'playerUi_nameTags', + this.playerUiFilterValues, + PlayerUiFilter.PARTY, + ), + // New key: the old backplate setting defaulted to On. + nameBackdrop: new SelectSetting( + this.storeService, + 'playerUi_nameBackdrop', + this.playerUiFilterValues, + PlayerUiFilter.PARTY, + ), + nameTagSize: new SelectSetting( + this.storeService, + 'playerUi_nameTagSize', + this.fontSizeValues, + FontSize.M, + ), + nameFadeStart: new SliderSetting( + this.storeService, + 'playerUi_nameFadeStart', + 2000, + ), + nameInvisible: new SliderSetting( + this.storeService, + 'playerUi_nameInvisible', + 8000, + ), + partyFadeStart: new SliderSetting( + this.storeService, + 'playerUi_partyFadeStart', + 2000, + ), + partyInvisible: new SliderSetting( + this.storeService, + 'playerUi_partyInvisible', + 8000, + ), }; + /** + * Per-username overrides of the stranger name setting, set with the eye in + * the player list. Keyed by username so the choice follows that name across + * servers and applies to everyone using it. + */ + public nameOverrides = new BehaviorSubject>( + this.loadNameOverrides(), + ); + constructor( private readonly storeService: StoreService, private readonly translocoService: TranslocoService, @@ -162,4 +266,37 @@ export class SettingService { translocoService.setActiveLang(lang), ); } + + /** + * Whether a player's real name may be shown: the eye in the player list + * wins, then the known names setting. + */ + public isNameRevealed(name: string, isPartyMember: boolean): boolean { + const knownNames = this.settings.knownNames.getValue(); + return ( + this.nameOverrides.getValue()[name] ?? + (knownNames === KnownNames.ALL || + (knownNames === KnownNames.PARTY && isPartyMember)) + ); + } + + public setNameOverride(name: string, revealed: boolean) { + const overrides = { ...this.nameOverrides.getValue(), [name]: revealed }; + this.storeService.set( + 'playerUi_nameOverrides', + JSON.stringify(overrides), + ); + this.nameOverrides.next(overrides); + } + + private loadNameOverrides(): Record { + try { + const parsed = JSON.parse( + this.storeService.get('playerUi_nameOverrides', '{}'), + ); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } + } } 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/app/services/voip.service.ts b/Code/skyrim_ui/src/app/services/voip.service.ts new file mode 100644 index 0000000000..12dfd8ef09 --- /dev/null +++ b/Code/skyrim_ui/src/app/services/voip.service.ts @@ -0,0 +1,220 @@ +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, then quieter like real sound, silent at four times it. */ + fadeStart: number; + /** 0 to 100 */ + micVolume: number; + /** 0 to 100, like the Windows "Test your microphone" bar. */ + noiseGate: number; + /** Playback gain, 0 to 500 percent. */ + masterVolume: number; +} + +export const defaultVoipSettings: VoipSettings = { + inputDevice: '', + connect: false, + outputDevice: '', + channel: VoipChannel.EVERYONE, + fadeStart: 2000, + micVolume: 100, + noiseGate: 5, + masterVolume: 100, +}; + +/** 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); + settings.masterVolume = Number.isFinite(settings.masterVolume) + ? Math.max(0, Math.min(500, Math.round(settings.masterVolume))) + : 100; + this.settings.next(settings); + + skyrimtogether.setVoiceSettings( + settings.inputDevice, + settings.connect ? 1 : 0, + settings.outputDevice, + settings.channel, + settings.fadeStart, + settings.micVolume, + settings.noiseGate, + settings.masterVolume, + ); + } + + 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, + masterVolume: number = 100, + ): 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, + masterVolume, + }); + }); + } + + 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 1f03d698cd..518328a179 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}}", @@ -68,13 +69,19 @@ "LEAVE_PARTY": "Leave party" }, "PARTY_LIST": { + "STRANGER": "Stranger", + "SHOW_NAME": "Show name", + "HIDE_NAME": "Hide name", "NO_OTHER_PLAYERS": "Nobody here but us chickens", "TABLE_HEADERS": { + "KNOWN": "Known", "LEVEL": "Lv.", "NAME": "Name", "LOCATION": "Location" }, - "INVITE": "Invite" + "INVITE": "Invite", + "TRADE": "Trade", + "CANCEL_TRADE_INVITE": "Cancel trade invite" }, "PLAYER_MANAGER": { "PLAYER_LIST": "Player List", @@ -109,6 +116,44 @@ "JOIN": "Join" }, "SETTINGS": { + "PLAYER_UI": { + "TITLE": "Player UI filters", + "KNOWN_NAMES": "Known names", + "KNOWN_NAMES_HINT": "Party: Names are known within the party, others' are not. The eye in the player list overrides this per name.", + "KNOWN_NAMES_OPTIONS": { + "ALL": "All", + "PARTY": "Party", + "NONE": "None" + }, + "KNOWN_CHAT_NAMES": "Known chat names", + "KNOWN_CHAT_NAMES_HINT": "Party: chat shows party members' names, others as \"Stranger\". The eye in the player list overrides this per name.", + "PARTY_INVITES": "Party invitations", + "PARTY_INVITES_HINT": "Off: Incoming party invites are declined silently.", + "TRADE_INVITES": "Trade invitations", + "TRADE_INVITES_HINT": "Party: Only party members can invite you to trade.", + "MINI_CHAT": "Preview mini chat", + "MINI_CHAT_HINT": "Shows the latest white chat text when the F2 UI isn't open.", + "FLOATING_CHAT": "Floating subtitle chat", + "FLOATING_CHAT_HINT": "Party: party members' chat and dialogue choices float above them. On: strangers' chat too.", + "NAME_TAGS": "Name tags", + "NAME_TAGS_HINT": "Party: Show name tags only above party members.", + "NAME_TAG_BACKPLATE": "Name backdrop", + "NAME_TAG_BACKPLATE_HINT": "Dark box behind the name. Party: Show only on party members.", + "NAME_TAG_SIZE": "Name tag size", + "DISTANCES": "Distances", + "NAME_FADE_START": "Name fade start", + "NAME_FADE_START_HINT": "Closer than this: 100% opacity.", + "NAME_INVISIBLE": "Name is invisible", + "NAME_INVISIBLE_HINT": "Farther than this: 0% opacity.", + "PARTY_FADE_START": "Party fade start", + "PARTY_INVISIBLE": "Party invisible from", + "UNITS": "units", + "FILTERS": { + "ON": "On", + "PARTY": "Party", + "OFF": "Off" + } + }, "VERSION": { "HEADER": "Version", "INFO": "Currently installed:", @@ -117,6 +162,26 @@ "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", + "MASTER_VOLUME": "Voice master volume", + "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", @@ -145,6 +210,84 @@ "XL": "Extra Large" }, "BACK": "Back" + }, + "TRADE_MENU": { + "MINIMIZE": "Minimize", + "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" + } + }, + "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": { @@ -179,10 +322,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 4a73654411..a9688189c8 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}}", @@ -68,13 +69,19 @@ "LEAVE_PARTY": "Lämna party" }, "PARTY_LIST": { + "STRANGER": "Främling", + "SHOW_NAME": "Visa namn", + "HIDE_NAME": "Dölj namn", "NO_OTHER_PLAYERS": "Inte en kotte här, bara hönorna", "TABLE_HEADERS": { + "KNOWN": "Känd", "LEVEL": "Lv.", "NAME": "Namn", "LOCATION": "Plats" }, - "INVITE": "Bjud in" + "INVITE": "Bjud in", + "TRADE": "Byte", + "CANCEL_TRADE_INVITE": "Avbryt bytesinbjudan" }, "PLAYER_MANAGER": { "PLAYER_LIST": "Spelarlista", @@ -86,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..." }, @@ -109,6 +116,44 @@ "JOIN": "Anslut" }, "SETTINGS": { + "PLAYER_UI": { + "TITLE": "Filter för spelar-UI", + "KNOWN_NAMES": "Kända namn", + "KNOWN_NAMES_HINT": "Party: Namn är kända i ert party, andras är okända. Ögat i spelarlistan tar prioritet per namn.", + "KNOWN_NAMES_OPTIONS": { + "ALL": "Alla", + "PARTY": "Party", + "NONE": "Inga" + }, + "KNOWN_CHAT_NAMES": "Kända chattnamn", + "KNOWN_CHAT_NAMES_HINT": "Party: Chatten visar partymedlemmars namn, andra visas som \"Främling\". Ögat i spelarlistan ändrar det per namn.", + "PARTY_INVITES": "Partyinbjudningar", + "PARTY_INVITES_HINT": "Av: Inkommande partyinbjudningar avböjs automatiskt.", + "TRADE_INVITES": "Bytesinbjudningar", + "TRADE_INVITES_HINT": "Party: Bara partymedlemmar kan bjuda in dig till byten.", + "MINI_CHAT": "Förhandsvisa minichatt", + "MINI_CHAT_HINT": "Visar den senaste vita chattexten när F2-UI inte är öppet.", + "FLOATING_CHAT": "Svävande chattar", + "FLOATING_CHAT_HINT": "Party: Medlemmars chattar och dialogval visas i namntaggen över huvudet. På: Främlingars chatt också", + "NAME_TAGS": "Namnskyltar", + "NAME_TAGS_HINT": "Party: Se namnskyltar bara ovanför partymedlemmar.", + "NAME_TAG_BACKPLATE": "Namn i ruta", + "NAME_TAG_BACKPLATE_HINT": "Mörk ruta bakom namnet. Party: Visa bara på partymedlemmar.", + "NAME_TAG_SIZE": "Namnstorlek", + "DISTANCES": "Avstånd", + "NAME_FADE_START": "Namnet bleknar från", + "NAME_FADE_START_HINT": "Närmare än: 100 % opacitet.", + "NAME_INVISIBLE": "Namnet är osynligt", + "NAME_INVISIBLE_HINT": "Längre bort än: 0 % opacitet.", + "PARTY_FADE_START": "Party bleknar från", + "PARTY_INVISIBLE": "Party osynligt från", + "UNITS": "enheter", + "FILTERS": { + "ON": "På", + "PARTY": "Party", + "OFF": "Av" + } + }, "VERSION": { "HEADER": "Version", "INFO": "Nuvarande installation:", @@ -144,6 +189,73 @@ "XL": "Störst" }, "BACK": "Tillbaka" + }, + "TRADE_MENU": { + "MINIMIZE": "Minimera", + "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": { @@ -178,10 +290,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/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 be77792364..8a05d82a13 100644 --- a/Code/skyrim_ui/src/typings.d.ts +++ b/Code/skyrim_ui/src/typings.d.ts @@ -95,7 +95,74 @@ 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; + + /** 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, + masterVolume: 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. */ @@ -221,6 +288,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 +444,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, @@ -390,6 +499,11 @@ interface SkyrimTogether { */ setTime(hours: number, minutes: number): void; + /** + * Ask the server to make the local player's character wave at everyone. + */ + wave(): void; + /** * Deactivate UI and release control. */ @@ -448,5 +562,84 @@ 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; + + /** 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, + masterVolume: 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. + */ + setNameTagSettings(settings: NameTagSettings): void; +} + +/** + * Name tag settings. Filters are 0 = on, 1 = party only, 2 = off. + */ +interface NameTagSettings { + nameTags: number; + backdrop: number; + /** Floating subtitle chat filter. */ + floatingChat: number; + /** Font size in pixels. */ + fontSize: number; + nameFadeStart: number; + nameInvisible: number; + partyFadeStart: number; + partyInvisible: number; + /** Shown instead of the name of players whose name is hidden. */ + strangerLabel: string; + /** Players whose real name may be shown. */ + revealedPlayerIds: number[]; + /** Players whose name may be shown in front of their floating chat. */ + revealedChatPlayerIds: number[]; } 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..c1ad560237 --- /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: () => () => {}, HostListener: () => () => {} }; + 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/encoding.cpp b/Code/tests/encoding.cpp index defb808418..032092a2ad 100644 --- a/Code/tests/encoding.cpp +++ b/Code/tests/encoding.cpp @@ -15,6 +15,7 @@ #include #include +#include #include #include @@ -63,6 +64,146 @@ 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); + } + + { + DropItemRequest request; + request.LocalId = 0xFF001234; + request.Item.BaseId = GameId(0, 0x12EB7); + request.Item.Count = 3; + request.Item.ExtraHealth = 1.5f; + request.CellId = GameId(0, 0x3C); + request.WorldSpaceId = GameId(0, 0x3C); + request.Transform.Position = glm::vec3(1234.f, -5678.f, 90.f); + request.Transform.Rotation = glm::vec3(0.1f, -0.2f, 3.1f); + + 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 == request); + } + + { + NotifyDroppedItemsSpawn notify; + + DroppedItemData item{}; + item.ServerId = 42; + item.Item.BaseId = GameId(0, 0xF); + item.Item.Count = 100; + item.CellId = GameId(0, 0x165A7); + item.Transform.Position = glm::vec3(-12.f, 34.f, -56.f); + item.Transform.Rotation = glm::vec3(0.f, 0.f, 1.5f); + item.IsAtRest = true; + notify.Items.push_back(item); + + item.ServerId = 43; + item.IsAtRest = false; + notify.Items.push_back(item); + + 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 == notify); + } + + { + DroppedItemMoveRequest request; + request.Move.ServerId = 0x1234; + request.Move.Transform.Position = glm::vec3(-100.f, 200.f, -300.f); + request.Move.Transform.Rotation = glm::vec3(1.f, 2.f, 3.f); + request.Move.IsAtRest = true; + + 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 == request); + } + + { + NotifyDroppedItemsRemove notify; + notify.ServerIds = {1, 0x12345, 0xFFFFFFFF}; + + 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 == notify); + } } TEST_CASE("Static structures", "[encoding.static]") @@ -331,6 +472,25 @@ TEST_CASE("Differential structures", "[encoding.differential]") REQUIRE(vars.Floats == recvVars.Floats); REQUIRE(vars.Integers == recvVars.Integers); } + + // A size change restarts from zeros on both sides, so zeros are left out and the old values must not survive + vars.Floats.push_back(0.f); + vars.Floats[1] = 0.f; + vars.Integers.push_back(0); + vars.Integers[1] = 0; + + { + Buffer::Writer writer(&buff); + + vars.GenerateDiff(recvVars, writer); + + Buffer::Reader reader(&buff); + recvVars.ApplyDiff(reader); + + REQUIRE(vars.Booleans == recvVars.Booleans); + REQUIRE(vars.Floats == recvVars.Floats); + REQUIRE(vars.Integers == recvVars.Integers); + } } } @@ -360,6 +520,26 @@ TEST_CASE("Packets", "[encoding.packets]") REQUIRE(sendMessage == recvMessage); } + SECTION("RequestTimeSkip") + { + Buffer buff(1000); + + RequestTimeSkip sendMessage, recvMessage; + sendMessage.Hours = 7.5f; + + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + + uint64_t trash; + reader.ReadBits(trash, 8); // pop opcode + + recvMessage.DeserializeRaw(reader); + + REQUIRE(sendMessage == recvMessage); + } + SECTION("AuthenticationResponse") { Buffer buff(1000); @@ -527,6 +707,240 @@ TEST_CASE("Packets", "[encoding.packets]") REQUIRE(pMessage->GetOpcode() == sendMessage.GetOpcode()); REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); } + + SECTION("VoiceDataRequest") + { + Buffer buff(1000); + + VoiceDataRequest sendMessage; + sendMessage.Frame.Sequence = 65535; + for (uint32_t i = 0; i < 97; ++i) + sendMessage.Frame.Data.push_back(static_cast(i * 7)); + + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ClientMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == sendMessage.GetOpcode()); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); + } + + SECTION("NotifyVoiceData") + { + Buffer buff(1000); + + NotifyVoiceData sendMessage; + sendMessage.PlayerId = 123456; + sendMessage.Frame.Sequence = 42; + for (uint32_t i = 0; i < 60; ++i) + sendMessage.Frame.Data.push_back(static_cast(255 - i)); + + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ServerMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == sendMessage.GetOpcode()); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); + } + + SECTION("VoiceFrame rejects oversized data") + { + Buffer buff(2000); + + // Written by hand, Serialize itself never writes more than kMaxSize + Buffer::Writer writer(&buff); + writer.WriteBits(7, 16); + Serialization::WriteVarInt(writer, VoiceFrame::kMaxSize + 1); + + Buffer::Reader reader(&buff); + + VoiceFrame frame; + REQUIRE_FALSE(frame.Deserialize(reader)); + REQUIRE(frame.Data.empty()); + } + + SECTION("VoiceStateRequest") + { + Buffer buff(1000); + + VoiceStateRequest sendMessage; + sendMessage.Listening = true; + sendMessage.ListenChannel = VoiceStateRequest::kStrangers; + + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ClientMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == sendMessage.GetOpcode()); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); + } + + GIVEN("RequestInventorySnapshot") + { + Inventory::Entry armor; + armor.BaseId.BaseId = 0x12E46; + armor.Count = 1; + armor.ExtraWorn = true; + + Inventory::Entry gold; + gold.BaseId.BaseId = 0xF; + gold.Count = 250; + + RequestInventorySnapshot sendMessage; + sendMessage.ServerId = 0x10042; + sendMessage.OwnershipEpoch = 7; + sendMessage.CurrentInventory.Entries = {armor, gold}; + sendMessage.CurrentInventory.CurrentMagicEquipment.RightHandSpell.BaseId = 0x12FCD; + + Buffer buff(1000); + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ClientMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == sendMessage.GetOpcode()); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); + } + + GIVEN("NotifyInventorySnapshot") + { + Inventory::Entry armor; + armor.BaseId.BaseId = 0x12E46; + armor.Count = 1; + armor.ExtraWornLeft = true; + + NotifyInventorySnapshot sendMessage; + sendMessage.ServerId = 0x10042; + sendMessage.OwnershipEpoch = 7; + sendMessage.CurrentInventory.Entries = {armor}; + sendMessage.CurrentInventory.CurrentMagicEquipment.Shout.BaseId = 0x13E07; + + Buffer buff(1000); + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ServerMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == sendMessage.GetOpcode()); + REQUIRE(*CastUnique(std::move(pMessage)) == sendMessage); + } +} + +TEST_CASE("Chat broadcast", "[encoding.chat]") +{ + NotifyChatMessageBroadcast sendMessage; + sendMessage.MessageType = kPartyChat; + sendMessage.PlayerId = 0x1234567; + sendMessage.PlayerName = "absol"; + sendMessage.ChatMessage = "Follow me"; + + Buffer buff(1000); + Buffer::Writer writer(&buff); + sendMessage.Serialize(writer); + + Buffer::Reader reader(&buff); + + const ServerMessageFactory factory; + auto pMessage = factory.Extract(reader); + + REQUIRE(pMessage); + REQUIRE(pMessage->GetOpcode() == sendMessage.GetOpcode()); + 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]") @@ -552,3 +966,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()); +} 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); +} diff --git a/Code/tests/object_encoding.cpp b/Code/tests/object_encoding.cpp new file mode 100644 index 0000000000..14d25c7de9 --- /dev/null +++ b/Code/tests/object_encoding.cpp @@ -0,0 +1,47 @@ +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +using namespace TiltedPhoques; + +TEST_CASE("Object removal notifications preserve server entity identifiers", "[encoding.objects]") +{ + NotifyRemoveObjects sent; + + SECTION("Empty notification") + { + } + + SECTION("Entity indices and generation bits") + { + sent.ServerIds = {0, 1, 0x100000, 0xABC12345, 0xFFFFFFFE}; + } + + SECTION("A cell containing many objects") + { + for (uint32_t i = 0; i < 1024; ++i) + sent.ServerIds.push_back(0xABC00000 + i); + } + + Buffer buffer(16384); + Buffer::Writer writer(&buffer); + sent.Serialize(writer); + + Buffer::Reader reader(&buffer); + const ServerMessageFactory factory; + auto message = factory.Extract(reader); + + REQUIRE(message); + REQUIRE(message->GetOpcode() == NotifyRemoveObjects::Opcode); + auto received = CastUnique(std::move(message)); + REQUIRE(*received == sent); +} diff --git a/Code/tests/object_lifecycle.cpp b/Code/tests/object_lifecycle.cpp new file mode 100644 index 0000000000..8f1e86ffc0 --- /dev/null +++ b/Code/tests/object_lifecycle.cpp @@ -0,0 +1,103 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using TiltedPhoques::List; +using TiltedPhoques::Outcome; +using TiltedPhoques::Vector; + +#include +#include + +TEST_CASE("Object bindings follow reassignment without duplicating forms", "[objects]") +{ + entt::registry registry; + const auto object = ObjectSystem::Setup(registry, 0x1234, 17); + + SECTION("Repeated assignment keeps the same entity") + { + REQUIRE(ObjectSystem::Setup(registry, 0x1234, 17) == object); + REQUIRE(registry.view().size() == 1); + } + + SECTION("Returning to a retired cell changes the server id") + { + REQUIRE(ObjectSystem::Setup(registry, 0x1234, 0x100011) == object); + REQUIRE(registry.get(object).Id == 0x100011); + REQUIRE(registry.view().size() == 1); + + // A late or duplicate removal of the old incarnation must not erase the new one. + ObjectSystem::Remove(registry, std::array{17}); + REQUIRE(registry.valid(object)); + REQUIRE(registry.get(object).Id == 0x100011); + } + + SECTION("A server id reused for another form retires the previous binding") + { + const auto replacement = ObjectSystem::Setup(registry, 0x5678, 17); + REQUIRE_FALSE(registry.valid(object)); + REQUIRE(registry.get(replacement).Id == 0x5678); + REQUIRE(registry.view().size() == 1); + } + + SECTION("Legacy duplicates are collapsed on reassignment") + { + const auto duplicate = registry.create(); + registry.emplace(duplicate, 0x1234u); + registry.emplace(duplicate, 99); + + const auto assigned = ObjectSystem::Setup(registry, 0x1234, 42); + REQUIRE(registry.valid(assigned)); + REQUIRE(registry.get(assigned).Id == 42); + REQUIRE(registry.view().size() == 1); + } +} + +TEST_CASE("Object removal preserves unrelated entities and tolerates repeated messages", "[objects]") +{ + entt::registry registry; + const auto first = ObjectSystem::Setup(registry, 0x1234, 17); + const auto second = ObjectSystem::Setup(registry, 0x5678, 18); + const auto actor = registry.create(); + registry.emplace(actor, 0x14u); + registry.emplace(actor, 17); + + ObjectSystem::Remove(registry, std::array{17, 17, 999}); + REQUIRE_FALSE(registry.valid(first)); + REQUIRE(registry.valid(second)); + REQUIRE(registry.valid(actor)); + REQUIRE(registry.view().size() == 1); + + ObjectSystem::Remove(registry, std::array{17}); + ObjectSystem::Remove(registry, {}); + REQUIRE(registry.valid(second)); + REQUIRE(registry.valid(actor)); +} + +TEST_CASE("Disconnect clears both sides of object bindings before reconnect", "[objects]") +{ + entt::registry registry; + const auto first = ObjectSystem::Setup(registry, 0x1234, 17); + const auto second = ObjectSystem::Setup(registry, 0x5678, 18); + const auto actor = registry.create(); + registry.emplace(actor, 0x14u); + + ObjectSystem::Clear(registry); + REQUIRE_FALSE(registry.valid(first)); + REQUIRE_FALSE(registry.valid(second)); + REQUIRE(registry.valid(actor)); + REQUIRE(registry.view().empty()); + REQUIRE(registry.view().size() == 1); + + ObjectSystem::Clear(registry); + const auto reconnected = ObjectSystem::Setup(registry, 0x5678, 17); + REQUIRE(registry.get(reconnected).Id == 0x5678); + REQUIRE(registry.get(reconnected).Id == 17); + REQUIRE(registry.view().size() == 1); +} 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); +} diff --git a/Code/tests/server/object_lifecycle.cpp b/Code/tests/server/object_lifecycle.cpp new file mode 100644 index 0000000000..a8420ab89d --- /dev/null +++ b/Code/tests/server/object_lifecycle.cpp @@ -0,0 +1,149 @@ +#include + +#include +#include +#include + +#include +#include + +namespace +{ +entt::entity CreateObject(entt::registry& aRegistry, const GameId& acCell) +{ + const auto entity = aRegistry.create(); + aRegistry.emplace(entity, nullptr); + aRegistry.emplace(entity, acCell); + return entity; +} + +struct RetirementRecorder +{ + explicit RetirementRecorder(entt::registry& aRegistry) + : Registry(aRegistry) + { + } + + void operator()(const NotifyRemoveObjects& acMessage) + { + for (const auto id : acMessage.ServerIds) + { + const auto entity = static_cast(id); + ObjectsAliveAtSend &= Registry.valid(entity) && Registry.all_of(entity); + } + + Messages.push_back(acMessage); + } + + entt::registry& Registry; + Vector Messages; + bool ObjectsAliveAtSend{true}; +}; +} // namespace + +TEST_CASE("An occupied cell retains server objects and their lock state", "[server.objects]") +{ + entt::registry registry; + const GameId cell{1, 0x100}; + const auto object = CreateObject(registry, cell); + auto& lock = registry.get(object).CurrentLockData; + lock.IsLocked = true; + lock.LockLevel = 75; + + RetirementRecorder sent(registry); + const std::array playerCells{GameId{1, 0x200}, GameId{1, 0x300}, cell}; + ObjectLifecycle::RetireCell(registry, cell, playerCells, std::ref(sent)); + + REQUIRE(sent.Messages.empty()); + REQUIRE(registry.valid(object)); + REQUIRE(registry.get(object).CurrentLockData.IsLocked); + REQUIRE(registry.get(object).CurrentLockData.LockLevel == 75); +} + +TEST_CASE("The last departure retires only objects belonging to the complete cell id", "[server.objects]") +{ + entt::registry registry; + const GameId cell{1, 0x100}; + const auto first = CreateObject(registry, cell); + const auto second = CreateObject(registry, cell); + const auto otherCell = CreateObject(registry, GameId{1, 0x200}); + const auto otherMod = CreateObject(registry, GameId{2, 0x100}); + const auto actor = registry.create(); + registry.emplace(actor, cell); + const auto noCell = registry.create(); + registry.emplace(noCell, nullptr); + + // Former visitors are now outside this cell, including one with the same + // base cell id in a different mod. They must not prevent its retirement. + const std::array playerCells{GameId{1, 0x200}, GameId{2, 0x100}}; + RetirementRecorder sent(registry); + ObjectLifecycle::RetireCell(registry, cell, playerCells, std::ref(sent)); + + REQUIRE(sent.Messages.size() == 1); + auto actual = sent.Messages.front().ServerIds; + std::sort(actual.begin(), actual.end()); + Vector expected{entt::to_integral(first), entt::to_integral(second)}; + std::sort(expected.begin(), expected.end()); + REQUIRE(actual == expected); + REQUIRE(sent.ObjectsAliveAtSend); + REQUIRE_FALSE(registry.valid(first)); + REQUIRE_FALSE(registry.valid(second)); + REQUIRE(registry.valid(otherCell)); + REQUIRE(registry.valid(otherMod)); + REQUIRE(registry.valid(actor)); + REQUIRE(registry.valid(noCell)); +} + +TEST_CASE("An empty cell does not broadcast an empty retirement", "[server.objects]") +{ + entt::registry registry; + const GameId cell{1, 0x100}; + const auto other = CreateObject(registry, GameId{1, 0x200}); + RetirementRecorder sent(registry); + + ObjectLifecycle::RetireCell(registry, cell, {}, std::ref(sent)); + + REQUIRE(sent.Messages.empty()); + REQUIRE(registry.valid(other)); +} + +TEST_CASE("Repeated departure events are harmless after all players disconnect", "[server.objects]") +{ + entt::registry registry; + const GameId cell{1, 0x100}; + const auto object = CreateObject(registry, cell); + RetirementRecorder sent(registry); + + ObjectLifecycle::RetireCell(registry, cell, {}, std::ref(sent)); + REQUIRE(sent.Messages.size() == 1); + REQUIRE(sent.Messages.front().ServerIds == Vector{entt::to_integral(object)}); + REQUIRE(sent.ObjectsAliveAtSend); + REQUIRE_FALSE(registry.valid(object)); + + ObjectLifecycle::RetireCell(registry, cell, {}, std::ref(sent)); + REQUIRE(sent.Messages.size() == 1); +} + +TEST_CASE("Server retirement preserves entity generation bits after reentry", "[server.objects]") +{ + entt::registry registry; + const GameId cell{1, 0x100}; + const auto original = CreateObject(registry, cell); + RetirementRecorder sent(registry); + + ObjectLifecycle::RetireCell(registry, cell, {}, std::ref(sent)); + const auto replacement = CreateObject(registry, cell); + REQUIRE(entt::to_entity(original) == entt::to_entity(replacement)); + REQUIRE(entt::to_integral(original) != entt::to_integral(replacement)); + + const std::array playerCells{cell}; + ObjectLifecycle::RetireCell(registry, cell, playerCells, std::ref(sent)); + REQUIRE(registry.valid(replacement)); + REQUIRE(sent.Messages.size() == 1); + + ObjectLifecycle::RetireCell(registry, cell, {}, std::ref(sent)); + REQUIRE(sent.Messages.size() == 2); + REQUIRE(sent.Messages.back().ServerIds == Vector{entt::to_integral(replacement)}); + REQUIRE(sent.ObjectsAliveAtSend); + REQUIRE_FALSE(registry.valid(replacement)); +} diff --git a/Code/tests/server/xmake.lua b/Code/tests/server/xmake.lua new file mode 100644 index 0000000000..f30c0ccd74 --- /dev/null +++ b/Code/tests/server/xmake.lua @@ -0,0 +1,17 @@ +target("TPServerTests") + set_kind("binary") + set_group("Tests") + add_includedirs("../../server", "../../../Libraries") + set_pcxxheader("../../server/Pch.h") + add_files("../main.cpp", "*.cpp", "../../server/Services/ObjectLifecycle.cpp") + add_deps("SkyrimEncoding", "CommonLib", "Console", "TiltedConnect") + add_packages( + "tiltedcore", + "hopscotch-map", + "catch2", + "glm", + "entt", + "spdlog", + "gamenetworkingsockets", + "lua", + "sol2") 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/tests/voice_dsp.cpp b/Code/tests/voice_dsp.cpp new file mode 100644 index 0000000000..3943428f7b --- /dev/null +++ b/Code/tests/voice_dsp.cpp @@ -0,0 +1,134 @@ +#include +#include + +#include "../client/Systems/VoiceDsp.h" + +#include + +using namespace VoiceDsp; + +TEST_CASE("Voice distance falloff", "[voice.dsp]") +{ + 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); +} + +TEST_CASE("Voice panning keeps loudness", "[voice.dsp]") +{ + float left = 0.f, right = 0.f; + + PanGains(0.f, left, right); + REQUIRE(left == Approx(right)); + REQUIRE(left * left + right * right == Approx(1.f)); + + PanGains(-1.f, left, right); + REQUIRE(left == Approx(1.f)); + REQUIRE(right == Approx(0.f).margin(1e-6)); + + PanGains(5.f, left, right); + REQUIRE(right == Approx(1.f)); +} + +TEST_CASE("Voice sequence numbers wrap", "[voice.dsp]") +{ + REQUIRE(SequenceDelta(10, 11) == 1); + REQUIRE(SequenceDelta(10, 13) == 3); + REQUIRE(SequenceDelta(65535, 0) == 1); + REQUIRE(SequenceDelta(65534, 2) == 4); + REQUIRE(SequenceDelta(11, 10) == -1); + REQUIRE(SequenceDelta(10, 10) == 0); +} + +TEST_CASE("Voice noise gate", "[voice.dsp]") +{ + NoiseGate gate; + + // Below the threshold stays closed + REQUIRE_FALSE(gate.Process(0.05f, 0.1f)); + + // Opens on a loud frame and holds through a pause + REQUIRE(gate.Process(0.3f, 0.1f)); + for (uint32_t i = 1; i < NoiseGate::kHoldFrames; ++i) + REQUIRE(gate.Process(0.01f, 0.1f)); + REQUIRE_FALSE(gate.Process(0.01f, 0.1f)); + + // A threshold of 0 sends anything but digital silence + NoiseGate openGate; + REQUIRE(openGate.Process(0.001f, 0.f)); + + REQUIRE(Peak(std::vector{0.1f, -0.4f, 0.2f}.data(), 3) == Approx(0.4f)); +} + +TEST_CASE("Voice jitter buffer", "[voice.dsp]") +{ + JitterBuffer buffer; + std::vector frame(kFrameSamples, 0.5f); + std::vector out(kFrameSamples * 2, 0.f); + + // Waits for 60 ms before playing + buffer.Push(frame.data(), frame.size()); + buffer.Push(frame.data(), frame.size()); + REQUIRE_FALSE(buffer.IsPlaying()); + REQUIRE(buffer.MixInto(out.data(), kFrameSamples, 1.f, 1.f) == 0); + + buffer.Push(frame.data(), frame.size()); + REQUIRE(buffer.IsPlaying()); + + REQUIRE(buffer.MixInto(out.data(), kFrameSamples, 1.f, 0.5f) == kFrameSamples); + REQUIRE(out[0] == Approx(0.5f)); + REQUIRE(out[1] == Approx(0.25f)); + + // Runs dry and stops until it has buffered again + buffer.MixInto(out.data(), kFrameSamples, 1.f, 1.f); + buffer.MixInto(out.data(), kFrameSamples, 1.f, 1.f); + REQUIRE_FALSE(buffer.IsPlaying()); + REQUIRE(buffer.GetBuffered() == 0); + + // Falls too far behind and drops the oldest audio + for (int i = 0; i < 12; ++i) + buffer.Push(frame.data(), frame.size()); + REQUIRE(buffer.GetBuffered() <= JitterBuffer::kMaxSamples); + REQUIRE(buffer.IsPlaying()); +} + +TEST_CASE("Voice master volume supports amplification and rejects invalid settings", "[voice.dsp]") +{ + REQUIRE(ClampMasterVolume(0.f) == 0.f); + REQUIRE(ClampMasterVolume(1.f) == 1.f); + REQUIRE(ClampMasterVolume(2.5f) == 2.5f); + REQUIRE(ClampMasterVolume(5.f) == 5.f); + REQUIRE(ClampMasterVolume(10.f) == 5.f); + REQUIRE(ClampMasterVolume(-1.f) == 0.f); + REQUIRE(ClampMasterVolume(std::numeric_limits::quiet_NaN()) == 1.f); + REQUIRE(ClampMasterVolume(std::numeric_limits::infinity()) == 1.f); + + JitterBuffer buffer; + std::vector frame(JitterBuffer::kStartSamples, 0.1f); + std::vector out(6, 0.f); + buffer.Push(frame.data(), frame.size()); + REQUIRE(buffer.MixInto(out.data(), 1, 2.5f, 2.5f) == 1); + REQUIRE(out[0] == Approx(0.25f)); + REQUIRE(buffer.MixInto(out.data() + 2, 1, 5.f, 5.f) == 1); + REQUIRE(out[2] == Approx(0.5f)); + REQUIRE(buffer.MixInto(out.data() + 4, 1, 0.f, 0.f) == 1); + REQUIRE(out[4] == 0.f); +} diff --git a/Code/tests/xmake.lua b/Code/tests/xmake.lua index 19abce5747..a2ba08d637 100644 --- a/Code/tests/xmake.lua +++ b/Code/tests/xmake.lua @@ -3,15 +3,29 @@ target("TPTests") set_kind("binary") set_group("Tests") add_includedirs( - ".", "../encoding") + ".", "../encoding", "../client") add_headerfiles("**.h") add_files("*.cpp") -- Has no game dependencies, so the client's remote playback timing can be tested here add_files("../client/Systems/PlaybackClock.cpp") + add_files("../client/Systems/ObjectSystem.cpp") + -- Voice chat's noise gate, falloff and jitter buffer, also game independent + add_files("../client/Systems/VoiceDsp.cpp") add_deps("SkyrimEncoding") add_packages( "tiltedcore", "hopscotch-map", "catch2", "mimalloc", - "glm") + "glm", + "entt") + +option("server_tests") + set_default(false) + set_showmenu(true) + set_description("Build isolated server object lifecycle tests") +option_end() + +if has_config("server_tests") then + includes("server") +end diff --git a/Code/tp_process/ProcessHandler.cpp b/Code/tp_process/ProcessHandler.cpp index 9c67fd1ec4..716292d745 100644 --- a/Code/tp_process/ProcessHandler.cpp +++ b/Code/tp_process/ProcessHandler.cpp @@ -18,6 +18,7 @@ void ProcessHandler::OnContextCreated(CefRefPtr browser, CefRefPtrSetValue("revealPlayers", CefV8Value::CreateFunction("revealPlayers", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("sendMessage", CefV8Value::CreateFunction("sendMessage", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("setTime", CefV8Value::CreateFunction("setTime", m_pOverlayHandler),V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("wave", CefV8Value::CreateFunction("wave", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("deactivate", CefV8Value::CreateFunction("deactivate", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("launchParty", CefV8Value::CreateFunction("launchParty", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("leaveParty", CefV8Value::CreateFunction("leaveParty", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); @@ -26,8 +27,18 @@ 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); + m_pCoreObject->SetValue("voiceRefresh", CefV8Value::CreateFunction("voiceRefresh", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("setVoiceSettings", CefV8Value::CreateFunction("setVoiceSettings", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("setVoicePlayerVolume", CefV8Value::CreateFunction("setVoicePlayerVolume", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("setVoiceMeter", CefV8Value::CreateFunction("setVoiceMeter", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); } void ProcessHandler::OnContextReleased(CefRefPtr browser, CefRefPtr frame, CefRefPtr context) 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 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. 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). 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. diff --git a/docker-compose.yml b/docker-compose.yml index be0b74ca68..8cc86bbc05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,12 @@ services: - /home/user/skyrim-together/Data:/st-server/Data - /etc/localtime:/etc/localtime:ro - /etc/timezone:/etc/timezone:ro + # Server settings can be overridden with environment variables: the + # variable is the setting's key in upper case (see STServer.ini), e.g. + #environment: + # UDIFFICULTY: '3' + # BENABLEMODCHECK: 'true' + # SPASSWORD: 'supersecretpassword' ports: - 10578:10578/udp restart: unless-stopped diff --git a/xmake.lua b/xmake.lua index 31ea6831cf..9ec4ebb27a 100644 --- a/xmake.lua +++ b/xmake.lua @@ -38,6 +38,14 @@ option("skyrim_version") end) option_end() +-- Stamps a fixed version into BuildInfo.h instead of `git describe`, so a local diagnostic build +-- from a branch can still join a server running that release (the protocol has to match). +option("build_version") + set_default("") + set_showmenu(true) + set_description("Version string to report instead of git describe, e.g. v1.8.7-10") +option_end() + if has_config("unitybuild") then add_rules("c.unity_build") add_rules("c++.unity_build", {batchsize = 12}) @@ -58,12 +66,18 @@ add_requires( if is_plat("windows") then add_requires( "discord 3.2.1", - "imgui v1.89.7" + "imgui v1.89.7", + -- built-in voice chat: Opus codec (BSD-3-Clause) and audio devices (miniaudio, MIT-0 or public domain) + "libopus 1.5.2", + "miniaudio 0.11.22" ) end -- dependencies' dependencies version pinning -add_requireconfs("*.mimalloc", { version = "2.2.4", override = true }) +-- Playtest builds: secure mode encodes and checks the free lists, so heap corruption crashes with a log instead of +-- hanging the allocator, and a double free is reported where it happens (see the error handler in +-- Code/client/Games/Memory.cpp). Costs some allocation speed; not meant for upstream. +add_requireconfs("*.mimalloc", { version = "2.2.4", configs = { secure = true }, override = true }) add_requireconfs("*.cmake", { version = "3.30.2", override = true }) add_requireconfs("*.openssl", { version = "1.1.1-w", override = true }) add_requireconfs("*.zlib", { version = "v1.3.1", override = true })