diff --git a/.github/workflows/windows-playable-build.yml b/.github/workflows/windows-playable-build.yml index 2749cd965..313175354 100644 --- a/.github/workflows/windows-playable-build.yml +++ b/.github/workflows/windows-playable-build.yml @@ -20,7 +20,7 @@ jobs: upload-build-for-next-job: true package-and-upload: - runs-on: windows-latest + runs-on: windows-2022 needs: build-windows steps: - name: Download artifact from the previous job diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index f2070226e..3c9b01311 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -25,7 +25,7 @@ jobs: build: strategy: matrix: - os: [windows-latest] + os: [windows-2022] arch: [x64] runs-on: ${{ matrix.os }} diff --git a/.gitignore b/.gitignore index 9161be0ee..d0dfb9da1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,15 @@ vs2019 vs2022 vsxmake2019 vsxmake2022 +vsxmake2026 .xmake vs2019 +# Local AI tooling and working notes +.claude/ +CLAUDE.md +docs/superpowers/ + build Build Build/*.log diff --git a/Code/client/Games/Skyrim/Forms/TESNPC.cpp b/Code/client/Games/Skyrim/Forms/TESNPC.cpp index a465215cf..0512246f8 100644 --- a/Code/client/Games/Skyrim/Forms/TESNPC.cpp +++ b/Code/client/Games/Skyrim/Forms/TESNPC.cpp @@ -5,11 +5,38 @@ TP_THIS_FUNCTION(TSetLeveledNpc, TESNPC*, TESNPC, TESNPC*); static TSetLeveledNpc* RealSetLeveledNpc = nullptr; +// The engine resolves a leveled spawn by creating a temporary TESNPC from +// (placed base, picked NPC); named leveled NPCs hide the pick from the temp +// NPC's template chain, so remember it here. CAUTION: temp form ids are +// recycled by the engine and cell attach resolves without this hook, so an +// entry may describe a previous occupant of its id - consumers must prefer +// the chain and treat this map as a last resort. The lock is needed since +// resolution can run on a loader thread while services read from the game +// thread. +static std::mutex s_leveledPicksLock; +static TiltedPhoques::Map s_leveledPicks; + TESNPC* TP_MAKE_THISCALL(HookSetLeveledNpc, TESNPC, TESNPC* apSelectedNpc) { - spdlog::info("For TESNPC: {}, spawning: {}", apThis->fullName.value, apSelectedNpc->fullName.value); + TESNPC* pResult = TiltedPhoques::ThisCall(RealSetLeveledNpc, apThis, apSelectedNpc); + + spdlog::debug("Leveled resolution: placed base {:X} -> pick {:X}, temp base {:X}", apThis ? apThis->formID : 0, apSelectedNpc ? apSelectedNpc->formID : 0, pResult ? pResult->formID : 0); + + if (pResult && apSelectedNpc) + { + std::lock_guard lock(s_leveledPicksLock); + s_leveledPicks[pResult->formID] = apSelectedNpc->formID; + } + + return pResult; +} + +uint32_t TESNPC::GetLeveledPickFormId(uint32_t aTempNpcFormId) noexcept +{ + std::lock_guard lock(s_leveledPicksLock); - return TiltedPhoques::ThisCall(RealSetLeveledNpc, apThis, Cast(TESForm::GetById(0x3B547))); + const auto cIt = s_leveledPicks.find(aTempNpcFormId); + return cIt != s_leveledPicks.end() ? cIt->second : 0; } static TiltedPhoques::Initializer s_npcInitHooks( @@ -19,5 +46,5 @@ static TiltedPhoques::Initializer s_npcInitHooks( RealSetLeveledNpc = s_SetLeveledNpc.Get(); - // TP_HOOK(&RealSetLeveledNpc, HookSetLeveledNpc); + TP_HOOK(&RealSetLeveledNpc, HookSetLeveledNpc); }); diff --git a/Code/client/Games/Skyrim/Forms/TESNPC.h b/Code/client/Games/Skyrim/Forms/TESNPC.h index e51de58ef..bcaaf9f1f 100644 --- a/Code/client/Games/Skyrim/Forms/TESNPC.h +++ b/Code/client/Games/Skyrim/Forms/TESNPC.h @@ -30,6 +30,31 @@ struct TESNPC : TESActorBase return pTemplate; } + static uint32_t GetLeveledPickFormId(uint32_t aTempNpcFormId) noexcept; + + // Best-effort pick recovery for temp bases the hooked resolver never saw + // (some engine spawn paths bypass fn 14375, e.g. live cell attach): the + // first static NPC in the template chain is the pick - unless it is the + // placed shell itself, recognizable by templating off a leveled list. + // Chain entries can be TESLevCharacter posing as TESNPC*, whose layout is + // too small to hold npcTemplate - hence the formType guards. + TESNPC* GetLeveledPick() const noexcept + { + TESNPC* pTemplate = npcTemplate; + + while (pTemplate && pTemplate->formType == FormType::Npc && pTemplate->IsTemporary()) + pTemplate = pTemplate->npcTemplate; + + if (!pTemplate || pTemplate->formType != FormType::Npc) + return nullptr; + + TESNPC* pShellTemplate = pTemplate->npcTemplate; + if (pShellTemplate && pShellTemplate->formType == FormType::LeveledCharacter) + return nullptr; + + return pTemplate; + } + struct FaceMorphs { float option[19]; diff --git a/Code/client/Games/Skyrim/TESObjectREFR.cpp b/Code/client/Games/Skyrim/TESObjectREFR.cpp index 3ef4afa92..8108008aa 100644 --- a/Code/client/Games/Skyrim/TESObjectREFR.cpp +++ b/Code/client/Games/Skyrim/TESObjectREFR.cpp @@ -202,7 +202,7 @@ void TESObjectREFR::SaveAnimationVariables(AnimationVariables& aVariables) const { const auto idx = pDescriptor->BooleanLookUpTable[i]; - if (pVariableSet->data[idx] != 0) + if (pVariableSet->size > idx && pVariableSet->data[idx] != 0) aVariables.Booleans[i] = true; } @@ -210,14 +210,16 @@ void TESObjectREFR::SaveAnimationVariables(AnimationVariables& aVariables) const { const auto idx = pDescriptor->FloatLookupTable[i]; - aVariables.Floats[i] = *reinterpret_cast(&pVariableSet->data[idx]); + if (pVariableSet->size > idx) + aVariables.Floats[i] = *reinterpret_cast(&pVariableSet->data[idx]); } for (size_t i = 0; i < pDescriptor->IntegerLookupTable.size(); ++i) { const auto idx = pDescriptor->IntegerLookupTable[i]; - aVariables.Integers[i] = *reinterpret_cast(&pVariableSet->data[idx]); + if (pVariableSet->size > idx) + aVariables.Integers[i] = *reinterpret_cast(&pVariableSet->data[idx]); } } @@ -278,14 +280,20 @@ void TESObjectREFR::LoadAnimationVariables(const AnimationVariables& aVariables) { const auto idx = pDescriptor->FloatLookupTable[i]; - *reinterpret_cast(&pVariableSet->data[idx]) = aVariables.Floats.size() > i ? aVariables.Floats[i] : 0.f; + if (pVariableSet->size > idx) + { + *reinterpret_cast(&pVariableSet->data[idx]) = aVariables.Floats.size() > i ? aVariables.Floats[i] : 0.f; + } } for (size_t i = 0; i < pDescriptor->IntegerLookupTable.size(); ++i) { const auto idx = pDescriptor->IntegerLookupTable[i]; - *reinterpret_cast(&pVariableSet->data[idx]) = aVariables.Integers.size() > i ? aVariables.Integers[i] : 0; + if (pVariableSet->size > idx) + { + *reinterpret_cast(&pVariableSet->data[idx]) = aVariables.Integers.size() > i ? aVariables.Integers[i] : 0; + } } } diff --git a/Code/client/Services/CharacterService.h b/Code/client/Services/CharacterService.h index 7ae13a0e6..ad0f2c2d5 100644 --- a/Code/client/Services/CharacterService.h +++ b/Code/client/Services/CharacterService.h @@ -100,6 +100,8 @@ struct CharacterService Actor* CreateCharacterForEntity(entt::entity aEntity) const noexcept; ActorData BuildActorData(Actor* apActor) const noexcept; + void ApplyLeveledNpcPick(Actor* apActor, const GameId& acPickId) const noexcept; + void ProcessLeveledConforms() noexcept; void RunLocalUpdates() const noexcept; void RunRemoteUpdates() noexcept; @@ -130,6 +132,15 @@ struct CharacterService Map m_weaponDrawUpdates{}; + struct LeveledConformData + { + uint32_t PickFormId{}; + bool Disabled{}; + }; + + // Written from const message handlers, drained by ProcessLeveledConforms + mutable Map m_pendingLeveledConforms{}; + entt::scoped_connection m_referenceAddedConnection; entt::scoped_connection m_referenceRemovedConnection; entt::scoped_connection m_updateConnection; diff --git a/Code/client/Services/Generic/CharacterService.cpp b/Code/client/Services/Generic/CharacterService.cpp index 7a1e83040..400c56726 100644 --- a/Code/client/Services/Generic/CharacterService.cpp +++ b/Code/client/Services/Generic/CharacterService.cpp @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -240,6 +241,7 @@ void CharacterService::OnUpdate(const UpdateEvent& acUpdateEvent) noexcept RunRemoteUpdates(); RunExperienceUpdates(); ApplyCachedWeaponDraws(acUpdateEvent); + ProcessLeveledConforms(); } void CharacterService::OnConnected(const ConnectedEvent& acConnectedEvent) const noexcept @@ -283,6 +285,8 @@ void CharacterService::OnDisconnected(const DisconnectedEvent& acDisconnectedEve } m_world.clear(); + + m_pendingLeveledConforms.clear(); } void CharacterService::OnAssignCharacter(const AssignCharacterResponse& acMessage) noexcept @@ -369,6 +373,9 @@ void CharacterService::OnAssignCharacter(const AssignCharacterResponse& acMessag m_weaponDrawUpdates[pActor->formID] = {acMessage.IsWeaponDrawn}; MoveActor(pActor, acMessage.WorldSpaceId, acMessage.CellId, acMessage.Position); + + // The owner's leveled pick rides the assignment response for actors we discovered ourselves + ApplyLeveledNpcPick(pActor, acMessage.LeveledNpcPickId); } } @@ -396,14 +403,25 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) if (acMessage.BaseId != GameId{}) { - const auto cNpcId = World::Get().GetModSystem().GetGameId(acMessage.BaseId); - if (cNpcId == 0) + // Prefer the owner's resolved leveled pick over the lossy template base + GameId baseId = acMessage.BaseId; + uint32_t npcId = World::Get().GetModSystem().GetGameId(baseId); + if (acMessage.LeveledNpcPickId != GameId{}) + { + if (const uint32_t cPickNpcId = World::Get().GetModSystem().GetGameId(acMessage.LeveledNpcPickId)) + { + baseId = acMessage.LeveledNpcPickId; + npcId = cPickNpcId; + } + } + + if (npcId == 0) { - spdlog::error("Failed to retrieve NPC, it will not be spawned, possibly missing mod, base: {:X}:{:X}, form: {:X}:{:X}", acMessage.BaseId.BaseId, acMessage.BaseId.ModId, acMessage.FormId.BaseId, acMessage.FormId.ModId); + spdlog::error("Failed to retrieve NPC, it will not be spawned, possibly missing mod, base: {:X}:{:X}, form: {:X}:{:X}", baseId.BaseId, baseId.ModId, acMessage.FormId.BaseId, acMessage.FormId.ModId); return; } - pNpc = Cast(TESForm::GetById(cNpcId)); + pNpc = Cast(TESForm::GetById(npcId)); pNpc->Deserialize(acMessage.AppearanceBuffer, acMessage.ChangeFlags); } else @@ -487,6 +505,10 @@ void CharacterService::OnCharacterSpawn(const CharacterSpawnRequest& acMessage) pActor->SetCommandingActor(PlayerCharacter::Get()->GetHandle()); } + // Static references arrive with their own locally rolled leveled pick; conform to the owner's + if (acMessage.FormId != GameId{}) + ApplyLeveledNpcPick(pActor, acMessage.LeveledNpcPickId); + auto& remoteComponent = m_world.emplace_or_replace(*entity, acMessage.ServerId, pActor->formID); auto& interpolationComponent = InterpolationSystem::Setup(m_world, *entity); @@ -546,6 +568,10 @@ void CharacterService::OnRemoteSpawnDataReceived(const NotifySpawnData& acMessag acMessage.NewActorData.IsDead ? pActor->Kill() : pActor->Respawn(); spdlog::info("Applied remote spawn data, actor form id: {:X}", pActor->formID); + + // Ownership transfers make the new owner's leveled pick authoritative; + // the demoted owner conforms like any other remote client + ApplyLeveledNpcPick(pActor, acMessage.LeveledNpcPickId); } void CharacterService::OnReferencesMoveRequest(const ServerReferencesMoveRequest& acMessage) const noexcept @@ -1275,7 +1301,31 @@ void CharacterService::RequestServerAssignment(const entt::entity aEntity) const message.IsPlayerSummon = pActor->GetCommandingActor() && pActor->GetCommandingActor()->formID == 0x14; if (pNpc->IsTemporary()) + { + // The chain is derived from the live actor and cannot go stale; the + // resolver map is keyed by temp ids the engine recycles, and cell + // attach bypasses the hook, so a map hit may describe a previous + // occupant of this id. Only named leveled NPCs, whose chain hides + // the pick, fall back to the map. + uint32_t pickFormId = 0; + if (TESNPC* pChainPick = pNpc->GetLeveledPick()) + pickFormId = pChainPick->formID; + + if (pickFormId == 0) + pickFormId = TESNPC::GetLeveledPickFormId(pNpc->formID); + + if (pickFormId != 0) + { + if (m_world.GetModSystem().GetServerModId(pickFormId, message.LeveledNpcPickId)) + spdlog::info("Captured leveled NPC pick {:X} for actor {:X} (temp base {:X})", pickFormId, pActor->formID, pNpc->formID); + else + spdlog::warn("Leveled NPC pick {:X} has no server id, identity sync skipped", pickFormId); + } + else + spdlog::info("No leveled pick recoverable for temp base {:X} (actor {:X}), identity sync unavailable", pNpc->formID, pActor->formID); + pNpc = pNpc->GetTemplateBase(); + } if (isTemporary) { @@ -1401,14 +1451,25 @@ Actor* CharacterService::CreateCharacterForEntity(entt::entity aEntity) const no if (acMessage.BaseId != GameId{}) { - const uint32_t cNpcId = World::Get().GetModSystem().GetGameId(acMessage.BaseId); - if (cNpcId == 0) + // Prefer the owner's resolved leveled pick over the lossy template base + GameId baseId = acMessage.BaseId; + uint32_t npcId = World::Get().GetModSystem().GetGameId(baseId); + if (acMessage.LeveledNpcPickId != GameId{}) + { + if (const uint32_t cPickNpcId = World::Get().GetModSystem().GetGameId(acMessage.LeveledNpcPickId)) + { + baseId = acMessage.LeveledNpcPickId; + npcId = cPickNpcId; + } + } + + if (npcId == 0) { spdlog::error("Failed to retrieve NPC, it will not be spawned, possibly missing mod"); return nullptr; } - pNpc = Cast(TESForm::GetById(cNpcId)); + pNpc = Cast(TESForm::GetById(npcId)); pNpc->Deserialize(acMessage.AppearanceBuffer, acMessage.ChangeFlags); } else @@ -1461,6 +1522,135 @@ ActorData CharacterService::BuildActorData(Actor* apActor) const noexcept return actorData; } +// A static base still templating onto a leveled list is the placed shell: +// the local engine has not rolled this actor yet. Shells have no model of +// their own - such actors render invisible or headless until conformed. +static bool IsUnresolvedLeveledShell(const TESNPC* apBase) noexcept +{ + if (!apBase || apBase->IsTemporary()) + return false; + + const TESNPC* pTemplate = apBase->npcTemplate; + return pTemplate && pTemplate->formType == FormType::LeveledCharacter; +} + +void CharacterService::ApplyLeveledNpcPick(Actor* apActor, const GameId& acPickId) const noexcept +{ + if (acPickId == GameId{}) + return; + + TESNPC* pBase = Cast(apActor->baseForm); + if (!pBase) + return; + + if (!pBase->IsTemporary()) + { + // Conforming a shell is exactly what resolution would have done; any + // other static base is an already conformed actor. + if (!IsUnresolvedLeveledShell(pBase)) + { + spdlog::info("Leveled pick {:x}:{:x} received for actor {:X} whose base is not a leveled temp, skipping", acPickId.ModId, acPickId.BaseId, apActor->formID); + return; + } + + spdlog::info("Actor {:X} still carries unresolved shell base {:X}, conforming to owner's pick", apActor->formID, pBase->formID); + } + + const uint32_t cPickId = World::Get().GetModSystem().GetGameId(acPickId); + if (cPickId == 0) + { + spdlog::warn("Leveled NPC pick {:X}:{:X} not resolvable, possibly missing mod, keeping local pick", acPickId.ModId, acPickId.BaseId); + return; + } + + TESNPC* pPick = Cast(TESForm::GetById(cPickId)); + if (!pPick) + { + spdlog::warn("Leveled NPC pick {:X} is not an NPC, keeping local pick", cPickId); + return; + } + + // Chain first for the same staleness reason as the capture side + uint32_t localPickId = 0; + if (TESNPC* pLocalPick = pBase->GetLeveledPick()) + localPickId = pLocalPick->formID; + + if (localPickId == 0) + localPickId = TESNPC::GetLeveledPickFormId(pBase->formID); + + if (localPickId == cPickId) + { + spdlog::info("Leveled actor {:X} already matches owner's pick {:X}", apActor->formID, cPickId); + return; + } + + spdlog::info("Conforming leveled actor {:X} (temp base {:X}, local pick {:X}) to owner's pick {:X}", apActor->formID, pBase->formID, localPickId, cPickId); + + // Never mutate the reference here: this runs from message handlers, while + // the cell attach may still own the reference, and queueing to the runner + // from a drained task re-locks the drain mutex (UB). The service update + // tick applies pending conforms once the world has settled. + m_pendingLeveledConforms[apActor->formID] = {cPickId, false}; +} + +void CharacterService::ProcessLeveledConforms() noexcept +{ + if (m_pendingLeveledConforms.empty()) + return; + + // Never touch references while the loading screen is up - the cell attach + // owns them and mutating mid-stream crashes the loader + UI* pUI = UI::Get(); + if (pUI && pUI->GetMenuOpen(BSFixedString("Loading Menu"))) + return; + + for (auto it = m_pendingLeveledConforms.begin(); it != m_pendingLeveledConforms.end();) + { + LeveledConformData& conform = it.value(); + + Actor* pActor = Cast(TESForm::GetById(it->first)); + TESNPC* pPick = Cast(TESForm::GetById(conform.PickFormId)); + if (!pActor || !pPick) + { + it = m_pendingLeveledConforms.erase(it); + continue; + } + + if (conform.Disabled) + { + // Teardown ran last tick; rebuild the 3D from the pick + pActor->baseForm = pPick; + pActor->EnableImpl(); + + // The animation sync caches the graph descriptor per actor. A pick that + // crosses animation projects (rabbit -> fox) keeps the old project's + // variable indices, and every remote update then scribbles the owner's + // values through them into the new graph's variable set - the OOB + // variable-index crash. Zero it so the next sync tick recomputes it + // from the rebuilt graph, like the werewolf/vampire lord transforms do. + pActor->GetExtension()->GraphDescriptorHash = 0; + + spdlog::info("Re-enabled conformed leveled actor {:X}, base {:X}", it->first, conform.PickFormId); + it = m_pendingLeveledConforms.erase(it); + continue; + } + + if (!pActor->loadedState && !IsUnresolvedLeveledShell(Cast(pActor->baseForm))) + { + // Distant actors stream their 3D in whenever the player approaches - + // possibly minutes later. Stay pending until then; a newer pick + // overwrites this entry and a disconnect clears the map. Shell-based + // actors are exempt: they have no model to load until conformed. + ++it; + continue; + } + + pActor->DisableImpl(); + conform.Disabled = true; + ++it; + } +} + void CharacterService::RunLocalUpdates() const noexcept { static std::chrono::steady_clock::time_point lastSendTimePoint; diff --git a/Code/encoding/Messages/AssignCharacterRequest.cpp b/Code/encoding/Messages/AssignCharacterRequest.cpp index 9c4d1f567..7d7af8147 100644 --- a/Code/encoding/Messages/AssignCharacterRequest.cpp +++ b/Code/encoding/Messages/AssignCharacterRequest.cpp @@ -19,6 +19,7 @@ void AssignCharacterRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter Serialization::WriteBool(aWriter, IsMount); Serialization::WriteBool(aWriter, IsPlayerSummon); CurrentActorData.Serialize(aWriter); + LeveledNpcPickId.Serialize(aWriter); } void AssignCharacterRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -53,4 +54,5 @@ void AssignCharacterRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aRead IsPlayerSummon = Serialization::ReadBool(aReader); CurrentActorData.Deserialize(aReader); + LeveledNpcPickId.Deserialize(aReader); } diff --git a/Code/encoding/Messages/AssignCharacterRequest.h b/Code/encoding/Messages/AssignCharacterRequest.h index 5241633a4..b6f6561cd 100644 --- a/Code/encoding/Messages/AssignCharacterRequest.h +++ b/Code/encoding/Messages/AssignCharacterRequest.h @@ -30,12 +30,13 @@ struct AssignCharacterRequest final : ClientMessage bool operator==(const AssignCharacterRequest& acRhs) const noexcept { return GetOpcode() == acRhs.GetOpcode() && Cookie == acRhs.Cookie && ReferenceId == acRhs.ReferenceId && FormId == acRhs.FormId && CellId == acRhs.CellId && WorldSpaceId == acRhs.WorldSpaceId && Position == acRhs.Position && Rotation == acRhs.Rotation && ChangeFlags == acRhs.ChangeFlags && - AppearanceBuffer == acRhs.AppearanceBuffer && FactionsContent == acRhs.FactionsContent && LatestAction == acRhs.LatestAction && FaceTints == acRhs.FaceTints && QuestContent == acRhs.QuestContent && IsDragon == acRhs.IsDragon && IsMount == acRhs.IsMount && IsPlayerSummon == acRhs.IsPlayerSummon; + AppearanceBuffer == acRhs.AppearanceBuffer && FactionsContent == acRhs.FactionsContent && LatestAction == acRhs.LatestAction && FaceTints == acRhs.FaceTints && QuestContent == acRhs.QuestContent && IsDragon == acRhs.IsDragon && IsMount == acRhs.IsMount && IsPlayerSummon == acRhs.IsPlayerSummon && LeveledNpcPickId == acRhs.LeveledNpcPickId; } uint32_t Cookie{}; GameId ReferenceId{}; GameId FormId{}; + GameId LeveledNpcPickId{}; GameId CellId{}; GameId WorldSpaceId{}; Vector3_NetQuantize Position{}; diff --git a/Code/encoding/Messages/AssignCharacterResponse.cpp b/Code/encoding/Messages/AssignCharacterResponse.cpp index 499d4ddda..60ef260bf 100644 --- a/Code/encoding/Messages/AssignCharacterResponse.cpp +++ b/Code/encoding/Messages/AssignCharacterResponse.cpp @@ -14,6 +14,7 @@ void AssignCharacterResponse::SerializeRaw(TiltedPhoques::Buffer::Writer& aWrite Serialization::WriteBool(aWriter, Owner); Serialization::WriteBool(aWriter, IsDead); Serialization::WriteBool(aWriter, IsWeaponDrawn); + LeveledNpcPickId.Serialize(aWriter); } void AssignCharacterResponse::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -30,4 +31,5 @@ void AssignCharacterResponse::DeserializeRaw(TiltedPhoques::Buffer::Reader& aRea Owner = Serialization::ReadBool(aReader); IsDead = Serialization::ReadBool(aReader); IsWeaponDrawn = Serialization::ReadBool(aReader); + LeveledNpcPickId.Deserialize(aReader); } diff --git a/Code/encoding/Messages/AssignCharacterResponse.h b/Code/encoding/Messages/AssignCharacterResponse.h index deb3519f9..83e8e93d3 100644 --- a/Code/encoding/Messages/AssignCharacterResponse.h +++ b/Code/encoding/Messages/AssignCharacterResponse.h @@ -23,7 +23,7 @@ struct AssignCharacterResponse final : ServerMessage bool operator==(const AssignCharacterResponse& achRhs) const noexcept { return GetOpcode() == achRhs.GetOpcode() && Cookie == achRhs.Cookie && ServerId == achRhs.ServerId && PlayerId == achRhs.PlayerId && Position == achRhs.Position && CellId == achRhs.CellId && WorldSpaceId == achRhs.WorldSpaceId && AllActorValues == achRhs.AllActorValues && - CurrentInventory == achRhs.CurrentInventory && ActionsToReplay == achRhs.ActionsToReplay && Owner == achRhs.Owner && IsDead == achRhs.IsDead && IsWeaponDrawn == achRhs.IsWeaponDrawn; + CurrentInventory == achRhs.CurrentInventory && ActionsToReplay == achRhs.ActionsToReplay && Owner == achRhs.Owner && IsDead == achRhs.IsDead && IsWeaponDrawn == achRhs.IsWeaponDrawn && LeveledNpcPickId == achRhs.LeveledNpcPickId; } uint32_t Cookie{}; @@ -32,6 +32,7 @@ struct AssignCharacterResponse final : ServerMessage Vector3_NetQuantize Position{}; GameId CellId{}; GameId WorldSpaceId{}; + GameId LeveledNpcPickId{}; ActorValues AllActorValues{}; Inventory CurrentInventory{}; ActionReplayChain ActionsToReplay; diff --git a/Code/encoding/Messages/CharacterSpawnRequest.cpp b/Code/encoding/Messages/CharacterSpawnRequest.cpp index 8427d7693..838edfdc9 100644 --- a/Code/encoding/Messages/CharacterSpawnRequest.cpp +++ b/Code/encoding/Messages/CharacterSpawnRequest.cpp @@ -20,6 +20,7 @@ void CharacterSpawnRequest::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) Serialization::WriteBool(aWriter, IsPlayer); Serialization::WriteBool(aWriter, IsWeaponDrawn); Serialization::WriteBool(aWriter, IsPlayerSummon); + LeveledNpcPickId.Serialize(aWriter); } void CharacterSpawnRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -55,4 +56,5 @@ void CharacterSpawnRequest::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReade IsPlayer = Serialization::ReadBool(aReader); IsWeaponDrawn = Serialization::ReadBool(aReader); IsPlayerSummon = Serialization::ReadBool(aReader); + LeveledNpcPickId.Deserialize(aReader); } diff --git a/Code/encoding/Messages/CharacterSpawnRequest.h b/Code/encoding/Messages/CharacterSpawnRequest.h index a73f0eb2f..70b34477e 100644 --- a/Code/encoding/Messages/CharacterSpawnRequest.h +++ b/Code/encoding/Messages/CharacterSpawnRequest.h @@ -33,12 +33,13 @@ struct CharacterSpawnRequest final : ServerMessage InventoryContent == acRhs.InventoryContent && FactionsContent == acRhs.FactionsContent && ActionsToReplay == acRhs.ActionsToReplay && FaceTints == acRhs.FaceTints && PlayerId == acRhs.PlayerId && IsDead == acRhs.IsDead && IsPlayer == acRhs.IsPlayer && IsWeaponDrawn == acRhs.IsWeaponDrawn && - IsPlayerSummon == acRhs.IsPlayerSummon && GetOpcode() == acRhs.GetOpcode(); + IsPlayerSummon == acRhs.IsPlayerSummon && LeveledNpcPickId == acRhs.LeveledNpcPickId && GetOpcode() == acRhs.GetOpcode(); } uint32_t ServerId{}; GameId FormId{}; GameId BaseId{}; + GameId LeveledNpcPickId{}; GameId CellId{}; Vector3_NetQuantize Position{}; Rotator2_NetQuantize Rotation{}; diff --git a/Code/encoding/Messages/NotifySpawnData.cpp b/Code/encoding/Messages/NotifySpawnData.cpp index a7af0eb61..f74a021d6 100644 --- a/Code/encoding/Messages/NotifySpawnData.cpp +++ b/Code/encoding/Messages/NotifySpawnData.cpp @@ -4,6 +4,7 @@ void NotifySpawnData::SerializeRaw(TiltedPhoques::Buffer::Writer& aWriter) const { Serialization::WriteVarInt(aWriter, Id); NewActorData.Serialize(aWriter); + LeveledNpcPickId.Serialize(aWriter); } void NotifySpawnData::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noexcept @@ -12,4 +13,5 @@ void NotifySpawnData::DeserializeRaw(TiltedPhoques::Buffer::Reader& aReader) noe Id = Serialization::ReadVarInt(aReader) & 0xFFFFFFFF; NewActorData.Deserialize(aReader); + LeveledNpcPickId.Deserialize(aReader); } diff --git a/Code/encoding/Messages/NotifySpawnData.h b/Code/encoding/Messages/NotifySpawnData.h index 7ccb815e0..f100197b9 100644 --- a/Code/encoding/Messages/NotifySpawnData.h +++ b/Code/encoding/Messages/NotifySpawnData.h @@ -2,6 +2,7 @@ #include "Message.h" #include +#include struct NotifySpawnData final : ServerMessage { @@ -17,9 +18,10 @@ struct NotifySpawnData final : ServerMessage bool operator==(const NotifySpawnData& acRhs) const noexcept { - return GetOpcode() == acRhs.GetOpcode() && Id == acRhs.Id && NewActorData == acRhs.NewActorData; + return GetOpcode() == acRhs.GetOpcode() && Id == acRhs.Id && NewActorData == acRhs.NewActorData && LeveledNpcPickId == acRhs.LeveledNpcPickId; } uint32_t Id{}; ActorData NewActorData{}; + GameId LeveledNpcPickId{}; }; diff --git a/Code/immersive_launcher/loader/MemoryLayout.cpp b/Code/immersive_launcher/loader/MemoryLayout.cpp index d1e31e0ce..3f7a7b713 100644 --- a/Code/immersive_launcher/loader/MemoryLayout.cpp +++ b/Code/immersive_launcher/loader/MemoryLayout.cpp @@ -32,7 +32,7 @@ namespace { extern "C" const IMAGE_DOS_HEADER __ImageBase; -const uint8_t* const pBasePtr = reinterpret_cast(&__ImageBase); +constinit const uint8_t* pBasePtr = reinterpret_cast(&__ImageBase); const uint8_t* kpImageEnd{pBasePtr + ((PIMAGE_NT_HEADERS)(pBasePtr + __ImageBase.e_lfanew))->OptionalHeader.SizeOfImage}; bool InRange(const uint8_t* apObj, const uint8_t* apLo, const uint8_t* apHi) diff --git a/Code/server/Components/CharacterComponent.h b/Code/server/Components/CharacterComponent.h index 92e2ccd90..1fc9a34fa 100644 --- a/Code/server/Components/CharacterComponent.h +++ b/Code/server/Components/CharacterComponent.h @@ -81,6 +81,7 @@ struct CharacterComponent uint32_t ChangeFlags{0}; String SaveBuffer{}; FormIdComponent BaseId{}; + FormIdComponent LeveledNpcPickId{}; Tints FaceTints{}; Factions FactionsContent{}; uint16_t Flags{}; diff --git a/Code/server/Game/Player.h b/Code/server/Game/Player.h index 0ae7af3bb..ed80ad004 100644 --- a/Code/server/Game/Player.h +++ b/Code/server/Game/Player.h @@ -16,6 +16,7 @@ struct Player [[nodiscard]] ConnectionId_t GetConnectionId() const noexcept { return m_connectionId; } [[nodiscard]] std::optional GetCharacter() const noexcept { return m_character; } [[nodiscard]] PartyComponent& GetParty() noexcept { return m_party; } + [[nodiscard]] const PartyComponent& GetParty() const noexcept { return m_party; } [[nodiscard]] const String& GetUsername() const noexcept { return m_username; } [[nodiscard]] const String& GetEndPoint() const noexcept { return m_endpoint; } [[nodiscard]] const uint64_t GetDiscordId() const noexcept { return m_discordId; } diff --git a/Code/server/Scripting/Player_Bindings.cpp b/Code/server/Scripting/Player_Bindings.cpp index 999fea81d..90ea953ca 100644 --- a/Code/server/Scripting/Player_Bindings.cpp +++ b/Code/server/Scripting/Player_Bindings.cpp @@ -13,7 +13,7 @@ void BindPlayer(sol::state_view aState) playerType["GetId"] = &Player::GetId; playerType["GetConnectionId"] = &Player::GetConnectionId; playerType["GetCharacter"] = &Player::GetCharacter; - playerType["GetParty"] = &Player::GetParty; + playerType["GetParty"] = [](Player& aSelf) -> PartyComponent& { return aSelf.GetParty(); }; playerType["GetUsername"] = &Player::GetUsername; playerType["GetEndPoint"] = &Player::GetEndPoint; playerType["GetDiscordId"] = &Player::GetDiscordId; diff --git a/Code/server/Services/CharacterService.cpp b/Code/server/Services/CharacterService.cpp index 9ec33d9ad..276e9f84c 100644 --- a/Code/server/Services/CharacterService.cpp +++ b/Code/server/Services/CharacterService.cpp @@ -106,6 +106,11 @@ void CharacterService::Serialize(World& aRegistry, entt::entity aEntity, Charact apSpawnRequest->BaseId = characterComponent.BaseId.Id; } + if (characterComponent.LeveledNpcPickId) + { + apSpawnRequest->LeveledNpcPickId = characterComponent.LeveledNpcPickId.Id; + } + const auto* pMovementComponent = aRegistry.try_get(aEntity); if (pMovementComponent) { @@ -221,6 +226,9 @@ void CharacterService::OnAssignCharacterRequest(const PacketEventMembers.begin(), pParty->Members.end(), pOwningPlayer) != pParty->Members.end()) { + // The new owner's roll becomes authoritative for the leveled pick; + // empty means unknown, in which case nobody should conform + characterComponent.LeveledNpcPickId = FormIdComponent(message.LeveledNpcPickId); TransferOwnership(acMessage.pPlayer, World::ToInteger(*itor), acMessage.Packet.CurrentActorData); isOwner = true; } @@ -239,6 +247,12 @@ void CharacterService::OnAssignCharacterRequest(const PacketEventGetConnectionId()); + } + if (auto* pAnimationComponent = m_world.try_get(*itor)) { response.ActionsToReplay = pAnimationComponent->ActionsReplayCache.FormRefinedReplayChain(); @@ -602,6 +616,11 @@ void CharacterService::CreateCharacter(const PacketEvent characterComponent.ChangeFlags = message.ChangeFlags; characterComponent.SaveBuffer = std::move(message.AppearanceBuffer); characterComponent.BaseId = FormIdComponent(message.FormId); + // Client-authoritative like BaseId; worst case a forged id changes which NPC identity renders + characterComponent.LeveledNpcPickId = FormIdComponent(message.LeveledNpcPickId); + + if (characterComponent.LeveledNpcPickId) + spdlog::debug("Stored leveled NPC pick {:x}:{:x} for FormId {:x}:{:x}", message.LeveledNpcPickId.ModId, message.LeveledNpcPickId.BaseId, gameId.ModId, gameId.BaseId); characterComponent.FaceTints = message.FaceTints; characterComponent.FactionsContent = message.FactionsContent; characterComponent.SetDead(message.CurrentActorData.IsDead); @@ -739,6 +758,12 @@ void CharacterService::BroadcastActorData(Player* apPlayer, const entt::entity a notifySpawnData.Id = World::ToInteger(acEntity); notifySpawnData.NewActorData = acActorData; + if (const auto* pCharacterComponent = m_world.try_get(acEntity)) + { + if (pCharacterComponent->LeveledNpcPickId) + notifySpawnData.LeveledNpcPickId = pCharacterComponent->LeveledNpcPickId.Id; + } + GameServer::Get()->SendToPlayersInRange(notifySpawnData, acEntity, apPlayer); } diff --git a/Code/server/Services/CommandService.cpp b/Code/server/Services/CommandService.cpp index 7bb66ce86..527e780a5 100644 --- a/Code/server/Services/CommandService.cpp +++ b/Code/server/Services/CommandService.cpp @@ -9,6 +9,13 @@ #include #include +#include + +namespace +{ +Console::Setting bAnnounceServer{"LiveServices:bAnnounceServer", "Whether to list the server on the public server list", false}; +} + CommandService::CommandService(World& aWorld, entt::dispatcher& aDispatcher) noexcept : m_world(aWorld) { @@ -22,7 +29,7 @@ void CommandService::OnSetTimeCommand(const PacketEvent& const auto cPlayerId = static_cast(acMessage.Packet.PlayerId); - // Only set time if player is an admin + // Admin override: always allowed for (const auto session : GameServer::Get()->GetAdminSessions()) { if (PlayerManager::Get()->GetByConnectionId(session)->GetId() == cPlayerId) @@ -39,6 +46,21 @@ void CommandService::OnSetTimeCommand(const PacketEvent& } } + // Party leader allowed on private servers only + const auto* pPartyService = &m_world.GetPartyService(); + if (pPartyService->IsPlayerLeader(acMessage.pPlayer) && !bAnnounceServer) + { + const auto cHours = static_cast(acMessage.Packet.Hours); + const auto cMinutes = static_cast(acMessage.Packet.Minutes); + + m_world.GetCalendarService().SetTime(cHours, cMinutes, m_world.GetCalendarService().GetTimeScale()); + + response.Result = NotifySetTimeResult::SetTimeResult::kSuccess; + acMessage.pPlayer->Send(response); + + return; + } + response.Result = NotifySetTimeResult::SetTimeResult::kNoPermission; acMessage.pPlayer->Send(response); } diff --git a/Code/server/Services/PartyService.cpp b/Code/server/Services/PartyService.cpp index deabdd974..7dce6c51a 100644 --- a/Code/server/Services/PartyService.cpp +++ b/Code/server/Services/PartyService.cpp @@ -53,13 +53,13 @@ bool PartyService::IsPlayerInParty(Player* const apPlayer) const noexcept return apPlayer->GetParty().JoinedPartyId.has_value(); } -bool PartyService::IsPlayerLeader(Player* const apPlayer) noexcept +bool PartyService::IsPlayerLeader(const Player* const apPlayer) const noexcept { - auto& inviterPartyComponent = apPlayer->GetParty(); + const auto& inviterPartyComponent = apPlayer->GetParty(); if (inviterPartyComponent.JoinedPartyId) { - Party& party = m_parties[*inviterPartyComponent.JoinedPartyId]; - return party.LeaderPlayerId == apPlayer->GetId(); + if (const auto* const pParty = GetById(*inviterPartyComponent.JoinedPartyId)) + return pParty->LeaderPlayerId == apPlayer->GetId(); } return false; diff --git a/Code/server/Services/PartyService.h b/Code/server/Services/PartyService.h index fbb0f7f7d..e1803ca19 100644 --- a/Code/server/Services/PartyService.h +++ b/Code/server/Services/PartyService.h @@ -33,7 +33,7 @@ struct PartyService const Party* GetById(uint32_t aId) const noexcept; bool IsPlayerInParty(Player* const apPlayer) const noexcept; - bool IsPlayerLeader(Player* const apPlayer) noexcept; + bool IsPlayerLeader(const Player* const apPlayer) const noexcept; Party* GetPlayerParty(Player* const apPlayer) noexcept; protected: diff --git a/Code/skyrim_ui/src/app/components/player-list/player-list.component.html b/Code/skyrim_ui/src/app/components/player-list/player-list.component.html index 8bccbd5fe..712f2c59a 100644 --- a/Code/skyrim_ui/src/app/components/player-list/player-list.component.html +++ b/Code/skyrim_ui/src/app/components/player-list/player-list.component.html @@ -12,9 +12,6 @@ {{ 'COMPONENT.PARTY_LIST.TABLE_HEADERS.LEVEL' | transloco }} {{ 'COMPONENT.PARTY_LIST.TABLE_HEADERS.NAME' | transloco }} - - {{ 'COMPONENT.PARTY_LIST.TABLE_HEADERS.LOCATION' | transloco }} - @@ -22,7 +19,6 @@ {{ player.level }} {{ player.name }} - {{ player.cellName }}