Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5e8b5b8
Hide location from player list; default to party menu tab (#868)
absol89 Jun 18, 2026
c50e592
CI: pin `windows-2022` runner for now (#876)
miredirex Jun 18, 2026
9d81ef0
Allow party leaders to use the `/settime` chat command on private ser…
absol89 Jun 21, 2026
4fb53dd
chore: add leveled NPC resolution probe (temporary)
DJLegends1011 Jul 3, 2026
4e7e608
feat: add LeveledNpcPickId to character assignment and spawn messages
DJLegends1011 Jul 3, 2026
e37ce17
feat: store and relay leveled NPC pick server-side
DJLegends1011 Jul 3, 2026
dd20290
feat: capture resolved leveled NPC pick on assignment
DJLegends1011 Jul 3, 2026
227c005
feat: conform remote clients to owner's leveled NPC pick
DJLegends1011 Jul 3, 2026
b3f3ddd
fix: harden leveled pick capture against races and forced-pick bleed
DJLegends1011 Jul 3, 2026
970a772
style: drop const prefix from mutable npc id lookups
DJLegends1011 Jul 3, 2026
47ce3e1
fix: relay leveled pick on assignment response and add sync diagnostics
DJLegends1011 Jul 3, 2026
daab680
fix: recover leveled pick from template chain when resolver hook miss…
DJLegends1011 Jul 4, 2026
ca8d894
fix: conform leveled actors by base swap with frame-deferred re-enable
DJLegends1011 Jul 4, 2026
cf2c6a9
fix: defer leveled conform until actor 3D settles
DJLegends1011 Jul 4, 2026
1a6dba9
fix: hold leveled conform while the loading screen is open
DJLegends1011 Jul 4, 2026
41c6b8d
fix: apply leveled conforms from the service update tick
DJLegends1011 Jul 4, 2026
2aa1696
fix: prefer template chain over resolver map when capturing leveled pick
DJLegends1011 Jul 4, 2026
1779088
feat: reconcile leveled pick across ownership transfers
DJLegends1011 Jul 4, 2026
559f0fb
fix: recompute graph descriptor when conforming leveled npcs
DJLegends1011 Jul 5, 2026
f609305
fix: bounds-check animation variable serialization
DJLegends1011 Jul 5, 2026
fe27d18
chore: ignore local tooling and workspace files
DJLegends1011 Jul 5, 2026
a8065f4
fix: conform shell-based actors and keep pending conforms until 3D ex…
DJLegends1011 Jul 5, 2026
200c5d4
tweak: demote leveled pick storage and relay logging to debug
DJLegends1011 Jul 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/windows-playable-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
build:
strategy:
matrix:
os: [windows-latest]
os: [windows-2022]
arch: [x64]

runs-on: ${{ matrix.os }}
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 30 additions & 3 deletions Code/client/Games/Skyrim/Forms/TESNPC.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint32_t, uint32_t> 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<TESNPC>(TESForm::GetById(0x3B547)));
const auto cIt = s_leveledPicks.find(aTempNpcFormId);
return cIt != s_leveledPicks.end() ? cIt->second : 0;
}

static TiltedPhoques::Initializer s_npcInitHooks(
Expand All @@ -19,5 +46,5 @@ static TiltedPhoques::Initializer s_npcInitHooks(

RealSetLeveledNpc = s_SetLeveledNpc.Get();

// TP_HOOK(&RealSetLeveledNpc, HookSetLeveledNpc);
TP_HOOK(&RealSetLeveledNpc, HookSetLeveledNpc);
});
25 changes: 25 additions & 0 deletions Code/client/Games/Skyrim/Forms/TESNPC.h
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
18 changes: 13 additions & 5 deletions Code/client/Games/Skyrim/TESObjectREFR.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -202,22 +202,24 @@ 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;
}

for (size_t i = 0; i < pDescriptor->FloatLookupTable.size(); ++i)
{
const auto idx = pDescriptor->FloatLookupTable[i];

aVariables.Floats[i] = *reinterpret_cast<float*>(&pVariableSet->data[idx]);
if (pVariableSet->size > idx)
aVariables.Floats[i] = *reinterpret_cast<float*>(&pVariableSet->data[idx]);
}

for (size_t i = 0; i < pDescriptor->IntegerLookupTable.size(); ++i)
{
const auto idx = pDescriptor->IntegerLookupTable[i];

aVariables.Integers[i] = *reinterpret_cast<uint32_t*>(&pVariableSet->data[idx]);
if (pVariableSet->size > idx)
aVariables.Integers[i] = *reinterpret_cast<uint32_t*>(&pVariableSet->data[idx]);
}
}

Expand Down Expand Up @@ -278,14 +280,20 @@ void TESObjectREFR::LoadAnimationVariables(const AnimationVariables& aVariables)
{
const auto idx = pDescriptor->FloatLookupTable[i];

*reinterpret_cast<float*>(&pVariableSet->data[idx]) = aVariables.Floats.size() > i ? aVariables.Floats[i] : 0.f;
if (pVariableSet->size > idx)
{
*reinterpret_cast<float*>(&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<uint32_t*>(&pVariableSet->data[idx]) = aVariables.Integers.size() > i ? aVariables.Integers[i] : 0;
if (pVariableSet->size > idx)
{
*reinterpret_cast<uint32_t*>(&pVariableSet->data[idx]) = aVariables.Integers.size() > i ? aVariables.Integers[i] : 0;
}
}
}

Expand Down
11 changes: 11 additions & 0 deletions Code/client/Services/CharacterService.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -130,6 +132,15 @@ struct CharacterService

Map<uint32_t, WeaponDrawData> m_weaponDrawUpdates{};

struct LeveledConformData
{
uint32_t PickFormId{};
bool Disabled{};
};

// Written from const message handlers, drained by ProcessLeveledConforms
mutable Map<uint32_t, LeveledConformData> m_pendingLeveledConforms{};

entt::scoped_connection m_referenceAddedConnection;
entt::scoped_connection m_referenceRemovedConnection;
entt::scoped_connection m_updateConnection;
Expand Down
Loading
Loading