Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions Changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4131,3 +4131,7 @@ When setting a property like MORE to the a spell or skill defname, trying to rea

02-04-2026, Mulambo
- Fixed: Timer on field spells with `SPELLFLAG_FIELD_RANDOMDECAY` was incrementally increasing for each field item created.

11-08-2026
- Fixed: World saves and garbage collection could fire region/location triggers on mounted or shrunken NPCs, killing pets or leaving mounts at stale positions.
- Fixed: The hidden NPC representation of an equipped mount now follows its rider without entering the normal gameplay movement pipeline.
58 changes: 37 additions & 21 deletions src/game/CSector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1015,6 +1015,26 @@ void CSector::MoveItemToSector( CItem * pItem )
}
}

void CSector::CheckCharSaveParity(CChar* pChar)
{
ADDTOCALLSTACK("CSector::CheckCharSaveParity");
ASSERT(pChar);

if ((pChar->IsStatFlag(STATF_SAVEPARITY) == m_fSaveParity) || !g_World.IsSaving())
return;

if (m_fSaveParity == g_World.m_fSaveParity)
{
// The destination sector has already been saved. Save the char before moving it there.
pChar->r_WriteParity(pChar->m_pPlayer ? g_World.m_FilePlayers : g_World.m_FileWorld);
}
else
{
// The destination sector has not been saved yet. Save it before adding the char.
r_Write();
}
}

bool CSector::MoveCharToSector( CChar * pChar )
{
ADDTOCALLSTACK("CSector::MoveCharToSector");
Expand All @@ -1025,26 +1045,7 @@ bool CSector::MoveCharToSector( CChar * pChar )
if (IsCharActiveIn(pChar))
return false;

// Check my save parity vs. this sector's
if ( pChar->IsStatFlag( STATF_SAVEPARITY ) != m_fSaveParity )
{
if ( g_World.IsSaving())
{
if ( m_fSaveParity == g_World.m_fSaveParity )
{
// Save out the CChar now. the sector has already been saved.
if ( pChar->m_pPlayer )
pChar->r_WriteParity(g_World.m_FilePlayers);
else
pChar->r_WriteParity(g_World.m_FileWorld);
}
else
{
// We need to write out this CSector now. (before adding client)
r_Write();
}
}
}
CheckCharSaveParity(pChar);

// Remove from previous spot.
m_Chars_Active.AddCharActive(pChar);
Expand Down Expand Up @@ -1084,6 +1085,22 @@ bool CSector::MoveCharToSector( CChar * pChar )
return true;
}

bool CSector::MoveDisconnectedCharToSector(CChar* pChar)
{
ADDTOCALLSTACK("CSector::MoveDisconnectedCharToSector");
ASSERT(pChar);
ASSERT(pChar->IsDisconnected());

if (IsCharDisconnectedIn(pChar))
return false;

m_Chars_Disconnect.AddCharDisconnected(pChar);
ASSERT(IsCharDisconnectedIn(pChar));
ASSERT(pChar->IsDisconnected());
CheckCharSaveParity(pChar);
return true;
}

bool CSector::_CanSleep(bool fCheckAdjacents) const
{
ADDTOCALLSTACK_DEBUG("CSector::_CanSleep");
Expand Down Expand Up @@ -1511,4 +1528,3 @@ int64 CSector::GetLastClientTime() const
{
return m_Chars_Active.GetTimeLastClient() ;
}

2 changes: 2 additions & 0 deletions src/game/CSector.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class CSector : public CScriptObj, public CSectorBase, public CTimedObject // sq
bool IsMoonVisible( uint iPhase, int iLocalTime ) const;
void SetDefaultWeatherChance();
bool IsInDungeon() const;
void CheckCharSaveParity(CChar* pChar);

public:
CSector();
Expand Down Expand Up @@ -103,6 +104,7 @@ public: virtual bool IsDeleted() const override;
size_t GetClientsNumber() const;
int64 GetLastClientTime() const;
bool MoveCharToSector(CChar* pChar);
bool MoveDisconnectedCharToSector(CChar* pChar);

bool _CanSleep(bool fCheckAdjacents) const;
void SetSectorWakeStatus(); // Ships may enter a sector before it's riders !
Expand Down
22 changes: 12 additions & 10 deletions src/game/chars/CChar.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -959,19 +959,18 @@ int CChar::FixWeirdness()
return iResultCode;
}

const CItem * pFigurine = Horse_GetValidMountItem();
if ( pFigurine == nullptr )
if (!_SyncRiddenPositionFromAnchor(true))
{
CItem* pLinkedItem = m_atRidden.m_uidFigurine.ItemFind();
const CObjBaseTemplate* pAnchor = pLinkedItem ? pLinkedItem->GetTopLevelObj() : nullptr;
g_Log.EventError("Ridden NPC (UID=0%x, id=0%x '%s') could not be synchronized without triggers. P=%s, ACTARG1=0%x, itemtype=%d, anchor=0%x, flags=0%" PRIx64 ", action=%d, disconnected=%d, servermode=%d.\n",
(dword)GetUID(), GetIDCommon(), GetName(), GetTopPoint().WriteUsed(),
(dword)m_atRidden.m_uidFigurine, pLinkedItem ? (int)pLinkedItem->GetType() : -1,
pAnchor ? (dword)pAnchor->GetUID() : 0, _uiStatFlag, (int)Skill_GetActive(),
(int)IsDisconnected(), (int)g_Serv.GetServerMode());
iResultCode = 0x1204;
return iResultCode;
}

const CPointMap& pt = pFigurine->GetTopLevelObj()->GetTopPoint();
if ( pt != GetTopPoint())
{
MoveToChar( pt, true, true );
SetDisconnected();
}
}
}
if ( IsStatFlag( STATF_CRIMINAL ))
Expand Down Expand Up @@ -4143,7 +4142,10 @@ void CChar::r_Write( CScript & s )
if ( m_pNPC )
m_pNPC->r_WriteChar(this, s);

const CPointMap& pt = GetTopPoint();
CPointMap pt(GetTopPoint());
CPointMap ptAnchor;
if (IsStatFlag(STATF_RIDDEN) && _GetRiddenAnchorPoint(ptAnchor))
pt = ptAnchor;
if (pt.IsValidPoint())
s.WriteKeyStr("P", pt.WriteUsed());

Expand Down
4 changes: 4 additions & 0 deletions src/game/chars/CChar.h
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,10 @@ public: void StatFlag_Mod(uint64 uiStatFlag, bool fMod) noexcept;
private:
[[nodiscard]]
CItem* Horse_ValidateMountItem(CItem *pMountItem) const;
bool _GetRiddenAnchorPoint(CPointMap& ptAnchor) const;
bool _RelocateDisconnectedNoTriggers(const CPointMap& pt);
bool _SyncRiddenPositionFromAnchor(bool fAttemptRepair);
void _SyncEquippedMountPosition();

CItem* Horse_GetMountItem() const;
CChar* Horse_GetMountChar() const;
Expand Down
84 changes: 84 additions & 0 deletions src/game/chars/CCharAct.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3715,6 +3715,84 @@ CItem* CChar::Horse_ValidateMountItem(CItem *pMountItem) const

}

bool CChar::_GetRiddenAnchorPoint(CPointMap& ptAnchor) const
{
ADDTOCALLSTACK("CChar::_GetRiddenAnchorPoint");

if (!IsStatFlag(STATF_RIDDEN) || !IsDisconnected() || (Skill_GetActive() != NPCACT_RIDDEN))
return false;

CItem* pMountItem = Horse_GetMountItem();
if (!pMountItem)
return false;

const CObjBaseTemplate* pAnchor = pMountItem->GetTopLevelObj();
if (!pAnchor)
return false;

ptAnchor = pAnchor->GetTopPoint();
return ptAnchor.IsValidPoint();
}

bool CChar::_RelocateDisconnectedNoTriggers(const CPointMap& pt)
{
ADDTOCALLSTACK("CChar::_RelocateDisconnectedNoTriggers");

if (!IsDisconnected() || !pt.IsValidPoint())
return false;

CSector* pNewSector = pt.GetSector();
if (!pNewSector)
return false;

// Internal bookkeeping only: disconnected chars must not enter the normal
// movement pipeline or fire region, room, environment or location triggers.
SetUnkPoint(pt);
if (!pNewSector->IsCharDisconnectedIn(this))
pNewSector->MoveDisconnectedCharToSector(this);

ASSERT(IsDisconnected());
ASSERT(pNewSector->IsCharDisconnectedIn(this));
ASSERT(GetTopPoint() == pt);
return IsDisconnected() && pNewSector->IsCharDisconnectedIn(this) && (GetTopPoint() == pt);
}

bool CChar::_SyncRiddenPositionFromAnchor(bool fAttemptRepair)
{
ADDTOCALLSTACK("CChar::_SyncRiddenPositionFromAnchor");

if (!IsStatFlag(STATF_RIDDEN) || !IsDisconnected() || (Skill_GetActive() != NPCACT_RIDDEN))
return false;

CItem* pMountItem = fAttemptRepair ? Horse_GetValidMountItem() : Horse_GetMountItem();
if (!pMountItem)
return false;

const CObjBaseTemplate* pAnchor = pMountItem->GetTopLevelObj();
if (!pAnchor)
return false;

return _RelocateDisconnectedNoTriggers(pAnchor->GetTopPoint());
}

void CChar::_SyncEquippedMountPosition()
{
ADDTOCALLSTACK("CChar::_SyncEquippedMountPosition");

if (!IsStatFlag(STATF_ONHORSE))
return;

CItem* pMountItem = LayerFind(LAYER_HORSE);
if (!pMountItem || !pMountItem->IsType(IT_EQ_HORSE))
return;

CChar* pMount = pMountItem->m_itFigurine.m_UID.CharFind();
if (!pMount || (pMount->Horse_ValidateMountItem(pMountItem) != pMountItem))
return;

pMount->_RelocateDisconnectedNoTriggers(GetTopPoint());
}

// I am a horse.
// Get my mount object. (attached to my rider)
CItem* CChar::Horse_GetMountItem() const
Expand Down Expand Up @@ -4021,6 +4099,7 @@ bool CChar::Horse_Mount(CChar *pHorse)

pHorse->StatFlag_Set(STATF_RIDDEN);
pHorse->Skill_Start(NPCACT_RIDDEN);
_SyncEquippedMountPosition();
return true;
}

Expand Down Expand Up @@ -4058,6 +4137,7 @@ bool CChar::Horse_UnMount()
}
else
{
_SyncEquippedMountPosition();
Use_Figurine(pMountItem, false);
pMountItem->Delete();
/*
Expand Down Expand Up @@ -5334,6 +5414,7 @@ bool CChar::MoveToChar(const CPointMap& pt, bool fStanding, bool fCheckLocationE
SetDisconnected(pSector);
SetTopPoint(pt); // This will clear the disconnected UID flag and the set the character position in the world.
SetDisconnected(); //Before entering here the player is not considered disconnected anymore, so we need to disconnect it again.
_SyncEquippedMountPosition();
return true;
}

Expand Down Expand Up @@ -5374,8 +5455,10 @@ bool CChar::MoveToChar(const CPointMap& pt, bool fStanding, bool fCheckLocationE
if (fCheckLocationEffects && (CheckLocationEffects(fStanding) == TRIGRET_RET_FALSE) && ptOld.IsValidPoint())
{
SetTopPoint(ptOld);
_SyncEquippedMountPosition();
return false;
}
_SyncEquippedMountPosition();
return true;
}

Expand All @@ -5392,6 +5475,7 @@ void CChar::SetTopZ( char z )
CObjBaseTemplate::SetTopZ( z );
m_fClimbUpdated = false; // update climb height
FixClimbHeight(); // can throw an exception
_SyncEquippedMountPosition();
}

// Move from here to a valid spot.
Expand Down