Jump to content
Maintenance : Final step ×

Stateful Server and Sync Position between Clients


Recommended Posts

  • Premium

DISCLAIMER: maybe one or two people will recognize this topic from a different community, saying "hey, you are not the original author". I am not, but I am a co-worker of his and have the permission to do so.

This topic wants to be a study case of one of the most basic mechanics in Metin: autoattacks and the in game entities' sync position.

The discussion will be divided like this:

  • 1. Analysis on the idea and implementation of the synchronization between clients and management of the attacks
    • 1.1 Design problems and bugs in the code
    • 1.2 Consideration about some derived "mechanics" (fly, rolling dagger skill)
  • 2. Challenges faced when working on an highly customized revision of Metin2
  • 3. My approach on how to fix the missing implementation done in 2009
    • 3.1 Result
    • 3.2 Benefits
  • 4. Possible expansions

Chapter 1: Movements and Attacks on Metin2

Metin gives charge to the entire flow logic of attacks and synchronization of the clients, to the client itself.  The server is merely there and rarely intervenes to validate and force the position of the players at regular intervals (so the the clients actually know the positions of the players and not at random coords).

Every client is notified of basic info, such as "I am 123 and I started attacking in this direction" or "I am 123 and I hit 456" or yet "I am 456, I am standing still, here" and every client executes, locally, with his infos on the state of the game (such as 123 was at coords x,y whereas 456 was at coords x2,y2) the animations and displacements, modifying the local state of the characters (after the hit, 456 will be at the coords x3,y3 whereas 123 moved to coords x+1,y+1).

Sometimes, precisely 300ms, every client sends the server his state and the server synchronizes the other clients to have something similar to a stable and solid playable game.

So, here a first overview of the logic Metin2 has been using for more than 15 years:

 

  1. 123's client starts wielding the sword and sends to the server the HEADER_CG_CHARACTER_MOVE with argument FUNC_COMBO
  2. Server broadcasts the packet to all the nearby clients
  3. All the clients receive the packet and show 123 wielding the sword
  4. 123's client hits 456 pushing it down on a 10m distance and sends the HEADER_CG_ATTACK packet to the server, saying it hit 456 to then process the dmg
    1. During this step, the server will give "ownership" to 123 of 456 (establishing that 123 is attacking 456)
    2. It will send all the clients the info that 123 is the only player that can push and edit the position of 456
  5. 123's client will gain the coords at which 456 will arrive after standing up and keeps them in memory
  6. Meanwhile, in 456's client, 123 eventually will hit 456 and it will push him down on a 10m distance
  7. After a maximum of 300ms, 123's client will send the position of all the entities it has "ownership" to the server, with the HEADER_CG_SYNC_POSITION packet, making few controls and broadcast the new position to all the clients, even 456's
  8. All the clients will pull the characters involved to the indicated coords, which can be corrected or not
    1. This process starts forcing the movement and eventually the KNOCK_BACK of the characters to the coords indicated by 123
  9. Eventually, when 456 ends the STANDUP animation, the client will send the HEADER_CG_CHARACTER_MOVE packet with argument FUNC_WAIT communicating again the definitive position of 456 to all the clients

This flow has the advantage that all the clients process locally all the in-between actions, therefore it looks "fast paced", just because the server rarely intervenes and supposedly the clients start from the same conditions and they arrive at the same results.

One of the biggest con, though, is that the server has very little info about the real state of the game, therefore it can do very little to do something about malicious declaration by one client.

Let's get more into it.

Chapter 1.1: Design problems and bugs in the code

First critic by the system designed by YMIR is that, as already said above, the server has not much information on the state of the clients.

I wanna point out:

  • The server only knows the initial position of the players at the start of the flow: before 123 attacks and 456 gets pushed
  • The server will be notified about the new position of 123 and 456 only at the end of the HEADER_CG_SYNC_POSITION packet, which will immediately move the character to the indicated coords
  • Everything that happens in-between these 2 moments is unknown to the server and can't perform any meaningful control on the attacks or coords where 456 gets pushed
    • For example, the server could see 456 to the initial coords, but on the client's side it already moved 4 meters away from the third sword hit and when the final knock back arrives, the server will see a potential illegal movement (third sword hit + fourth)
  • The server, during this process, can only see two states: initial and final, making it completely stupid.

Taking aside the security flaw - that we saw paid very well during these years - an acceptable solution would be if the process would be consistent and functional. Unfortunately, it's not like that, at all.

The first problem that it inevitably occurs is that, if for some reason, the initial states of the clients are not 100% the same, they will compute different movements, animations and angles.

.png

In this picture we can see how a miniscule difference on the B's initial position can change its final destination, rendering the interaction between A and B, kind of interesting, where you can see getting damaged while thinking to be safe. Despite thinking this is a remote situation, it's actually pretty common.

Remember that first, HEADER_CG_CHARACTER_MOVE packet is sent first, with argument FUNC_COMBO to communicate the beginning of a combo and eventually further attacks and the new position. If the characters are very close to one another, the attacker might send HEADER_CG_CHARACTER_MOVE, HEADER_CG_ATTACK and HEADER_CG_SYNC_POSITION before that who gets hit even started the attack animation, forcing a little movement and a KOCK_BACK before the sword hits the character in the victim's client.

It's the inevitable that until the attacker doesn't send HEADER_CG_SYNC_POSITION, the states that the players see could differ massively and when it actually arrives to the server to align everything, the players find themselves sucked, pushed or slid in positions where they didn't think to be, giving the PvP that Metin2's touch we all (don't) love. More players interact with each other, the more the imprecision rises and the more drastic the forced movements have to be made to reset the synchrony.

To make the clients reactive, the developers decided to force the movements and the KNOCK_BACKs before the packet HEADER_CG_SYNC_POSITION is sent and broadcasted:

In the function 

void CPythonPlayerEventHandler::OnHit(UINT uSkill, CActorInstance& rkActorVictim, BOOL isSendPacket)

the synchronization is forced with

rkActorVictim.TEMP_Push(kVictim.m_lPixelX, kVictim.m_lPixelY);

All good for now, if it wasn't for the fact that the coords synchronized are obtained from

rkActorVictim.NEW_GetLastPixelPositionRef

a function, which calls

GetBlendingPosition

that does a simple thing:

void CActorInstance::GetBlendingPosition(TPixelPosition * pPosition)
{
    if (m_PhysicsObject.isBlending())
    {
        m_PhysicsObject.GetLastPosition(pPosition);
        pPosition->x += m_x;
        pPosition->y += m_y;
        pPosition->z += m_z;
    }
    else
    {
        pPosition->x = m_x;
        pPosition->y = m_y;
        pPosition->z = m_z;
    }
}

Basically, it returns the current coords if the character is standing still, or the current coords + m_PhysicsObject.GetLastPosition(pPosition); if the character is getting pushed.

This mystic class called CPhysicsObject is responsible of the movements that occur on Metin. It gets told how much an entity is pushed, in what direction and the time of it, to then move the entity. The bug I found was that using m_PhysicsObject.GetLastPosition(pPosition), which returns the whole movement that the entity has to make, GetBlendingPosition doesn't return the final position of the entity, but the current coords + the movement. Despite this looks correct, it isn't when this function is called after the entity moved a little. In that case the returned value will be a little bit further of the destination initially expected.


Chapter: Consideration on some derived mechanics (fly,  rolling dagger skill)

When eventually the positions between the two clients are synchronized, the function that processes the packet HEADER_CG_SYNC_POSITION
 

void CActorInstance::__Push(int x, int y)
{
    if (IsResistFallen())
        return;  

    const D3DXVECTOR3& c_rv3Src=GetPosition();
    const D3DXVECTOR3 c_v3Dst=D3DXVECTOR3(x, -y, c_rv3Src.z);
    const D3DXVECTOR3 c_v3Delta=c_v3Dst-c_rv3Src;
   
    const int LoopValue = 100;
    const D3DXVECTOR3 inc=c_v3Delta / LoopValue;
   
    D3DXVECTOR3 v3Movement(0.0f, 0.0f, 0.0f);

    IPhysicsWorld* pWorld = IPhysicsWorld::GetPhysicsWorld();
           
    if (!pWorld)
    {
        return;
    }

    for(int i = 0; i < LoopValue; ++i)
    {
        if (pWorld->isPhysicalCollision(c_rv3Src + v3Movement))
        {
            ResetBlendingPosition();
            return;
        }
        v3Movement += inc;
    }

    SetBlendingPosition(c_v3Dst);

    const TPixelPosition& kPPosLast2 = NEW_GetLastPixelPositionRef();

    if (!IsUsingSkill())
    {
        int len=sqrt(c_v3Delta.x*c_v3Delta.x+c_v3Delta.y*c_v3Delta.y);
        if (len>150.0f)
        {
            InterceptOnceMotion(CRaceMotionData::NAME_DAMAGE_FLYING);
            PushOnceMotion(CRaceMotionData::NAME_STAND_UP);
        }
    }
}

uses the function

void SetBlendingPosition(const TPixelPosition & c_rPosition, float fBlendingTime = 1.0f)

which internally says to the client to make a movement and distribute it on a second timespan.

During this time, the character enters a state - __IsSyncing() - in which it cannot move at all if not using an ability.

This creates that phenomenon called "Fly", where the character slowly slides and can't either move or attack. Basically the Fly is the attempt, from the client, to synchronize his state with other clients. This forces the character in a slow movement in his position and assures that the state can't be changed, blocking every action.

If you want my opinion, it's ok for a sword hit to block a character, to give the possibility to perform a physical combo. This is though a mechanic that should be controlled by the developers who, for example, put half a second of "stun" to the hits instead of being a result of the synchronization process with the other clients.

More fascinating is the bug with the function GetBlendingPosition: during the execution  of the Rolling Dagger skill, if the player is already in a state of IsPushing (it's getting pushed by the fourth sword hit) during the execution of the function

OnHit(UINT uSkill, CActorInstance& rkActorVictim, BOOL isSendPacket)

a new TEMP_Push will be performed, with the coordinates that, presumably, represent the point of arrival of the character.

If this is true for the first hit of the skill, during the second, the victim will already be moved a little further. At this point, the coords returned by GetBlendingPosition are not the ones of the fourth sword hit but that plus something. The player will be then moved a little bit further and another KNOCK_BACK animation is triggered.

.png

I don't know if the developers deliberately introduced this dynamic, or if it was a happy accident, but once again I believe that if an ability like Rolling Dagger can bounce a player, this should be coded with specific parameters (external force? refresh push?), but definitely not with an interaction not so visible and counterintuitive that uses a logic most probably bugged.

 

2. Challenges found with an highly customized revision of Metin2

If you are people who intend to do significant changes to, maybe the skill system, pvp or want to develop a monitoring system for hacks and cheats server side, the current state makes an hard work even harder.

The server cannot contribute much to the management of all the states and this is a problem for all those systems based on the precision of the characters' positions during the interactions of the player with the game.

Every attempt to make the client more reactive and precise, clashes inevitably with mechanics born from weird and casual interactions of the entities in the client like the "Fly"'s case or Rolling Dagger's that, while BUGS, are still part of the game for years, therefore they must be preserved or emulated

 

3. My approach on how to resolve the lacks of the implementation done in 2009

My implementation is strongly linked to UniversalElements files (my server) therefore are not 1:1 applicable to most of the existing servers. I will not post much code (it's not a release) but I will describe how to arrive to a similar result and the logic who brought me to determine my choices.

Briefly, short said it will be the following:

 

  • Expansion of the packet HEADER_CG_ATTACK to better describe the action
  • Broadcast of HEADER_CG_ATTACK of all the clients in real time, to fix immediately the state
  • server-side state implementation emulating the movements of the entities in real time
  • Usage of HEADER_CG_SYNC_POSITION only to rollback the state in case wrong info sent from a client are present sent (to cancel an illegal action)
  • Fix of GetBlendingPosition

These simple edits look foregone, so much that the only answer to the question "why was it not designed like this from the beginning" was that, at the time, the servers were not able to maintain such an updated state and the internet connections were not enough for large packets used to the frequency of the HEADER_CG_ATTACK one.

Therefore, the new logic will appear like this:

  1. 123's client starts wielding and sends the server the HEADER_CG_CHARACTER_MOVE packet with argument FUNC_COMBO
  2. Server broadcasts the packet to all the clients nearby
  3. All the clients receive the packet and show 123 wielding the sword
  4. 123's client hit 456 pushing it on a distance of 10 and send the HEADER_CG_ATTACK packet to the server, saying it hit 456 so that it can process the dmg
    1. The packet contains info like 456's initial position (with double precision to be exact to the millimeter), timestamp of the attack, which part of the combo generated the hit, duration of the movement in ms
  5. The server validates the packet HEADER_CG_ATTACK, that processes the damage and broadcasts the information to the clients
    1. Furthermore the server initializes the emulation of the movement from the initial coord to the 456 final's one.
  6. Meanwhile in 456's client, 123 eventually will hit 456 making it fall at 10m of distance
  7. When 456's client receives the HEADER_CG_ATTACK packet broadcasted by 123, it corrects the internal state whit the new on-the-fly info
    1. For example if the client already processed the movement and the KNOCK_BACK, the trajectory will be corrected with the coords validated by the server

So, the synchronization corrects the local execution of the client and overwrites it (whether or not the packet HEADER_CG_ATTACK arrives before or after the sword hit in the observer's clients)

The big difference though, is that the server keeps an updated state of every entity at every attack and during the execution of the movement. It becomes then able to perform checks, edits and corrections on the data coming from the attacker with high precision.

To give an idea to the edits, in the server I added a state that can be executed in parallel in FCM.cpp

// Update
void CFSM::Update()
{
    // Check New State
    if(m_pNewState)
    {
        // Execute End State
        m_pCurrentState->ExecuteEndState();

        // Set New State
        m_pCurrentState = m_pNewState;
        m_pNewState = 0;

        // Execute Begin State
        m_pCurrentState->ExecuteBeginState();
    }

    // Check New State
    if(m_pNewConcurrentState)
    {
        // Execute End State
        if (m_pConcurrentState)
            m_pConcurrentState->ExecuteEndState();

        // Set New State
        m_pConcurrentState = m_pNewConcurrentState;
        m_pNewConcurrentState = 0;

        // Execute Begin State
        m_pConcurrentState->ExecuteBeginState();
    }
   
    if (bStopConcurrent && m_pConcurrentState) {
        // Execute End State
        m_pConcurrentState->ExecuteEndState();
        m_pConcurrentState = 0;
        bStopConcurrent = false;
    }

    // Execute State
    m_pCurrentState->ExecuteState();

    if (m_pConcurrentState) {
        m_pConcurrentState->ExecuteState();
    }
}

This enables me to use a fourth state in the CHARACTER class

CHARACTER::CHARACTER()
{
    m_stateIdle.Set(this, &CHARACTER::BeginStateEmpty, &CHARACTER::StateIdle, &CHARACTER::EndStateEmpty);
    m_stateMove.Set(this, &CHARACTER::BeginStateEmpty, &CHARACTER::StateMove, &CHARACTER::EndStateEmpty);
    m_stateBattle.Set(this, &CHARACTER::BeginStateEmpty, &CHARACTER::StateBattle, &CHARACTER::EndStateEmpty);
    m_stateSyncing.Set(this, &CHARACTER::BeginStateEmpty, &CHARACTER::StateSyncing, &CHARACTER::EndStateEmpty);
   
    Initialize();
}
void CHARACTER::StateSyncing()
{
    if (IsStone() || IsDoor()) {
        StopConcurrentState();
        return;
    }

    DWORD dwElapsedTime = get_dword_time() - m_dwSyncStartTime;
    float fRate = (float) dwElapsedTime / (float) m_dwSyncDuration;

    if(fRate > 1.0f)
        fRate = 1.0f;

    int x = (int) ((float) (m_posDest.x - m_posStart.x) * fRate + m_posStart.x);
    int y = (int) ((float) (m_posDest.y - m_posStart.y) * fRate + m_posStart.y);


    Sync(x, y);

    if(1.0f == fRate)
    {
        StopConcurrentState();
    }
}

///////////////////
////// To use to gradually "move" the entity on the desired position while it can do whatever it wants (to use when receiving HEADER_CG_ATTACK)
bool CHARACTER::BlendSync(long x, long y, unsigned int unDuration)
{
    // TODO distance check required
    // No need to go the same side as the position (automatic success)
    if(GetX() == x && GetY() == y)
        return false;

    m_posDest.x = m_posStart.x = GetX();
    m_posDest.y = m_posStart.y = GetY();

    m_posDest.x = x;
    m_posDest.y = y;

    m_dwSyncStartTime = get_dword_time();
    m_dwSyncDuration = unDuration;
    m_dwStateDuration = 1;

    ConcurrentState(m_stateSyncing);
    return true;
}

The new TPacketCGAttack will present iself like this

typedef struct command_attack
{
    BYTE    bHeader;
    BYTE    bType;
    DWORD    dwVID;
    BOOL    bPacket;
    LONG    lSX;
    LONG    lSY;
    LONG    lX;
    LONG    lY;
    float    fSyncDestX;
    float    fSyncDestY;
    DWORD    dwBlendDuration;
    DWORD    dwComboMotion;
    DWORD    dwTime;
} TPacketCGAttack;

with its counter part TPacketGCAttack
 

typedef struct packet_attack
{
    BYTE    bHeader;
    BYTE    bType;
    DWORD    dwAttacakerVID;
    DWORD    dwVID;
    BOOL    bPacket;
    LONG    lSX;
    LONG    lSY;
    LONG    lX;
    LONG    lY;
    float    fSyncDestX;
    float    fSyncDestY;
    DWORD    dwBlendDuration;
} TPacketGCAttack;

Client side I fixed the inherent logic to GetBlendingPosition and all his variants. To resolve it we must pass to the function that initializes it, not only the displacement, but even the final position.

void CPhysicsObject::SetLastPosition(const TPixelPosition& c_rPosition, const TPixelPosition& c_rDeltaPosition, float fBlendingTime)
{
    m_v3FinalPosition.x = float(c_rPosition.x + c_rDeltaPosition.x);
    m_v3FinalPosition.y = float(c_rPosition.y + c_rDeltaPosition.y);
    m_v3FinalPosition.z = float(c_rPosition.z + c_rDeltaPosition.z);
    m_v3DeltaPosition.x = float(c_rDeltaPosition.x);
    m_v3DeltaPosition.y = float(c_rDeltaPosition.y);
    m_v3DeltaPosition.z = float(c_rDeltaPosition.z);
    m_xPushingPosition.Setup(0.0f, c_rDeltaPosition.x, fBlendingTime);
    m_yPushingPosition.Setup(0.0f, c_rDeltaPosition.y, fBlendingTime);
}
void CPhysicsObject::GetFinalPosition(TPixelPosition* pPosition)
{
    pPosition->x = (m_v3FinalPosition.x);
    pPosition->y = (m_v3FinalPosition.y);
    pPosition->z = (m_v3FinalPosition.z);
}

void CPhysicsObject::GetDeltaPosition(TPixelPosition* pPosition)
{
    pPosition->x = (m_v3DeltaPosition.x);
    pPosition->y = (m_v3DeltaPosition.y);
    pPosition->z = (m_v3DeltaPosition.z);
}

and fixing the function

void CActorInstance::GetBlendingPosition(TPixelPosition * pPosition)
{
    if (m_PhysicsObject.isBlending())
    {
        m_PhysicsObject.GetFinalPosition(pPosition);
    }
    else
    {
        GetPixelPosition(pPosition);
    }
}

The management of the new HEADER_CG_CHARACTER_ATTACK will present itself like this:

bool CPythonNetworkStream::RecvCharacterAttackPacket()
{
    TPacketGCAttack kPacket;
    if (!Recv(sizeof(TPacketGCAttack), &kPacket))
    {
        Tracen("CPythonNetworkStream::RecvCharacterAttackPacket - PACKET READ ERROR");
        return false;
    }

    if (kPacket.lX && kPacket.lY) {
        __GlobalPositionToLocalPosition(kPacket.lX, kPacket.lY);
    }
    __GlobalPositionToLocalPosition(kPacket.lSX, kPacket.lSY);

    TPixelPosition tSyncPosition = TPixelPosition{ kPacket.fSyncDestX, kPacket.fSyncDestY, 0 };

    m_rokNetActorMgr->AttackActor(kPacket.dwVID, kPacket.dwAttacakerVID, kPacket.lX, kPacket.lY, tSyncPosition, kPacket.dwBlendDuration);

    return true;
}

void CNetworkActorManager::AttackActor(DWORD dwVID, DWORD dwAttacakerVID, LONG lDestPosX, LONG lDestPosY, const TPixelPosition& k_pSyncPos, DWORD dwBlendDuration)
{
    std::map<DWORD, SNetworkActorData>::iterator f = m_kNetActorDict.find(dwVID);
    if (m_kNetActorDict.end() == f)
    {
        return;
    }

    SNetworkActorData& rkNetActorData = f->second;

    if (k_pSyncPos.x && k_pSyncPos.y) {
        CInstanceBase* pkInstFind = __FindActor(rkNetActorData);
        if (pkInstFind)
        {
            const bool bProcessingClientAttack = pkInstFind->ProcessingClientAttack(dwAttacakerVID);
            pkInstFind->ServerAttack(dwAttacakerVID);
           
            // if already blending, update
            if (bProcessingClientAttack && pkInstFind->IsPushing() && pkInstFind->GetBlendingRemainTime() > 0.15) {
                pkInstFind->SetBlendingPosition(k_pSyncPos, pkInstFind->GetBlendingRemainTime());
            } else {
                // otherwise sync
                //pkInstFind->SCRIPT_SetPixelPosition(k_pSyncPos.x, k_pSyncPos.y);
                pkInstFind->NEW_SyncPixelPosition(k_pSyncPos, dwBlendDuration);
            }
        }

        rkNetActorData.SetPosition(long(k_pSyncPos.x), long(k_pSyncPos.y));
    }
}

//////////////////////
//// Semplified __Push and called only by AttackActor
void CActorInstance::__Push(const TPixelPosition& c_rkPPosDst, unsigned int unDuration)
{
    DWORD dwVID = GetVirtualID();
    Tracenf("VID %d SyncPixelPosition %f %f", dwVID, c_rkPPosDst.x, c_rkPPosDst.y);

    if (unDuration == 0)
        unDuration = 1000;

    const D3DXVECTOR3& c_rv3Src = GetPosition();
    const D3DXVECTOR3 c_v3Delta = c_rkPPosDst - c_rv3Src;

    SetBlendingPosition(c_rkPPosDst, float(unDuration) / 1000);

    if (!IsUsingSkill() && !IsResistFallen())
    {
        int len = sqrt(c_v3Delta.x * c_v3Delta.x + c_v3Delta.y * c_v3Delta.y);
        if (len > 150.0f)
        {
            InterceptOnceMotion(CRaceMotionData::NAME_DAMAGE_FLYING);
            PushOnceMotion(CRaceMotionData::NAME_STAND_UP);
        }
    }
}

//////////////////////
// To understand if the client already started processing the attack or it must be initialized with a new sync_pixelposition:

void CInstanceBase::ServerAttack(DWORD dwVID)
{
    m_GraphicThingInstance.ServerAttack(dwVID);
}

bool CInstanceBase::ProcessingClientAttack(DWORD dwVID)
{
    return m_GraphicThingInstance.ProcessingClientAttack(dwVID);
}

// client attack decreases the count
void CActorInstance::ClientAttack(DWORD dwVID)
{
    if (m_mapAttackSync.find(dwVID) == m_mapAttackSync.end()) {
        m_mapAttackSync.insert(std::make_pair(dwVID, -1));
    }
    else
    {
        if (m_mapAttackSync[dwVID] == 1)
        {
            m_mapAttackSync.erase(dwVID);
            return;
        }
        m_mapAttackSync[dwVID]--;
    }
}

// server attack increases
void CActorInstance::ServerAttack(DWORD dwVID)
{
    if (m_mapAttackSync.find(dwVID) == m_mapAttackSync.end()) {
        m_mapAttackSync.insert(std::make_pair(dwVID, 1));
    }
    else
    {
        if (m_mapAttackSync[dwVID] == -1)
        {
            m_mapAttackSync.erase(dwVID);
            return;
        }
        m_mapAttackSync[dwVID]++;
    }
}

bool CActorInstance::ProcessingClientAttack(DWORD dwVID)
{
    return m_mapAttackSync.find(dwVID) != m_mapAttackSync.end() && m_mapAttackSync[dwVID] < 0;
}

//
bool CActorInstance::ServerAttackCameFirst(DWORD dwVID)
{
    return m_mapAttackSync.find(dwVID) != m_mapAttackSync.end() && m_mapAttackSync[dwVID] > 0;
}

New management of the hit which processes locally the movement, collect the infos, and send the packet to the server:

struct BlendingPosition {
    D3DXVECTOR3 source;
    D3DXVECTOR3 dest;
    float duration;
};

void CActorInstance::__ProcessDataAttackSuccess(const NRaceData::TAttackData & c_rAttackData, CActorInstance & rVictim, const D3DXVECTOR3 & c_rv3Position, UINT uiSkill, BOOL isSendPacket)
{
    if (NRaceData::HIT_TYPE_NONE == c_rAttackData.iHittingType)
        return;

    InsertDelay(c_rAttackData.fStiffenTime);

    BlendingPosition sBlending;
    memset(&sBlending, 0, sizeof(sBlending));
    sBlending.source = rVictim.NEW_GetCurPixelPositionRef();

    if (__CanPushDestActor(rVictim) && c_rAttackData.fExternalForce > 0.0f)
    {
        const bool bServerAttackAlreadyCame = rVictim.ServerAttackCameFirst(GetVirtualID());
        rVictim.ClientAttack(GetVirtualID());
        if (!bServerAttackAlreadyCame)
        {
            __PushCircle(rVictim);

            // VICTIM_COLLISION_TEST
            const D3DXVECTOR3& kVictimPos = rVictim.GetPosition();

            rVictim.m_PhysicsObject.IncreaseExternalForce(kVictimPos, c_rAttackData.fExternalForce);
            rVictim.GetBlendingPosition(&(sBlending.dest));
            sBlending.duration = rVictim.m_PhysicsObject.GetRemainingTime();
            // VICTIM_COLLISION_TEST_END
        }
    }

    // Invisible Time
    rVictim.m_fInvisibleTime = CTimer::Instance().GetCurrentSecond() + (c_rAttackData.fInvisibleTime - __GetInvisibleTimeAdjust(uiSkill, c_rAttackData));

    // Stiffen Time
    rVictim.InsertDelay(c_rAttackData.fStiffenTime);

    // Hit Effect
    D3DXVECTOR3 vec3Effect(rVictim.m_x, rVictim.m_y, rVictim.m_z);
   
    // #0000780: [M2KR] ¼ö·æ Ÿ°Ý±¸ ¹®Á¦
    extern bool IS_HUGE_RACE(unsigned int vnum);
    if (IS_HUGE_RACE(rVictim.GetRace()))
    {
        vec3Effect = c_rv3Position;
    }
   
    const D3DXVECTOR3 & v3Pos = GetPosition();

    float fHeight = D3DXToDegree(atan2(-vec3Effect.x + v3Pos.x,+vec3Effect.y - v3Pos.y));

    // 2004.08.03.myevan.ºôµùÀ̳ª ¹®ÀÇ °æ¿ì Ÿ°Ý È¿°ú°¡ º¸ÀÌÁö ¾Ê´Â´Ù
    if (rVictim.IsBuilding()||rVictim.IsDoor())
    {
        D3DXVECTOR3 vec3Delta=vec3Effect-v3Pos;
        D3DXVec3Normalize(&vec3Delta, &vec3Delta);
        vec3Delta*=30.0f;

        CEffectManager& rkEftMgr=CEffectManager::Instance();
        if (m_dwBattleHitEffectID)
            rkEftMgr.CreateEffect(m_dwBattleHitEffectID, v3Pos+vec3Delta, D3DXVECTOR3(0.0f, 0.0f, 0.0f));
    }
    else
    {
        if(c_rAttackData.isEnemy == 0)
        {
            if(rVictim.IsEnemy() || rVictim.IsPC() || rVictim.IsBoss() || rVictim.IsStone())
                {
                    return;
                }
        }
        else
        {
            CEffectManager& rkEftMgr=CEffectManager::Instance();
            if (m_dwBattleHitEffectID)
                rkEftMgr.CreateEffect(m_dwBattleHitEffectID, vec3Effect, D3DXVECTOR3(0.0f, 0.0f, fHeight));
            if (m_dwBattleAttachEffectID)
                rVictim.AttachEffectByID(0, NULL, m_dwBattleAttachEffectID);  
        }
    }

    if (rVictim.IsBuilding())
    {
        // 2004.08.03.ºôµùÀÇ °æ¿ì Èçµé¸®¸é ÀÌ»óÇÏ´Ù
    }
    else if (rVictim.IsStone() || rVictim.IsDoor())
    {
        __HitStone(rVictim);
    }
    else
    {
        ///////////
        // Motion
        bool ForceHitGOOD = rVictim.IsPC() && (rVictim.IsKnockDown() || rVictim.__IsStandUpMotion());
        if (NRaceData::HIT_TYPE_GOOD == c_rAttackData.iHittingType || (TRUE == rVictim.IsResistFallen()))
        {
            __HitGood(rVictim);
        }
        else if (NRaceData::HIT_TYPE_GREAT == c_rAttackData.iHittingType)
        {
            if(c_rAttackData.isEnemy == 0)
            {  
                if(rVictim.IsEnemy() || rVictim.IsPC() || rVictim.IsBoss() || rVictim.IsStone())
                {
                    return;
                }
                else
                {
                    __HitGreate(rVictim, uiSkill, c_rAttackData.isEnemy);
                }
            }      
            else
            {
                __HitGreate(rVictim, uiSkill, c_rAttackData.isEnemy);
            }
        }
        else
        {
            TraceError("ProcessSucceedingAttacking: Unknown AttackingData.iHittingType %d", c_rAttackData.iHittingType);
        }
    }

    __OnHit(uiSkill, rVictim, isSendPacket, &sBlending);
}

void CPythonPlayerEventHandler::OnHit(UINT uSkill, CActorInstance& rkActorVictim, BOOL isSendPacket, BlendingPosition* sBlending)
{
    DWORD dwVIDVictim=rkActorVictim.GetVirtualID();

    CPythonCharacterManager::Instance().AdjustCollisionWithOtherObjects(&rkActorVictim);
    BlendingPosition kBlendingPacket;
    memset(&kBlendingPacket, 0, sizeof(kBlendingPacket));
   
    kBlendingPacket.source = rkActorVictim.NEW_GetCurPixelPositionRef();
    if (rkActorVictim.IsPushing()) {
        kBlendingPacket.dest = rkActorVictim.NEW_GetLastPixelPositionRef();
        kBlendingPacket.duration = sBlending->duration;
    }

    // Update Target
    CPythonPlayer::Instance().SetTarget(dwVIDVictim, FALSE);
    // Update Target

//#define ATTACK_TIME_LOG
#ifdef ATTACK_TIME_LOG
        static std::map<DWORD, float> s_prevTimed;
        float curTime = timeGetTime() / 1000.0f;
        bool isFirst = false;
        if (s_prevTimed.end() == s_prevTimed.find(dwVIDVictim))
        {
            s_prevTimed[dwVIDVictim] = curTime;
            isFirst = true;
        }
        float diffTime = curTime-s_prevTimed[dwVIDVictim];
        if (diffTime < 0.1f && !isFirst)
        {
            TraceError("ATTACK(SPEED_HACK): %.4f(%.4f) %d", curTime, diffTime, dwVIDVictim);
        }
        else
        {
            TraceError("ATTACK: %.4f(%.4f) %d", curTime, diffTime, dwVIDVictim);
        }
       
        s_prevTimed[dwVIDVictim] = curTime;
#endif
        CPythonNetworkStream& rkStream=CPythonNetworkStream::Instance();
        rkStream.SendAttackPacket(uSkill, dwVIDVictim, isSendPacket, kBlendingPacket);
}

bool CPythonNetworkStream::SendAttackPacket(UINT uMotAttack, DWORD dwVIDVictim, BOOL bPacket, BlendingPosition& sBlending)
{
    NANOBEGIN
    if (!__CanActMainInstance())
        return true;

    CPythonCharacterManager& rkChrMgr = CPythonCharacterManager::Instance();
    CInstanceBase* pkInstMain = rkChrMgr.GetMainInstancePtr();

#ifdef ATTACK_TIME_LOG
    static DWORD prevTime = timeGetTime();
    DWORD curTime = timeGetTime();
    TraceError("TIME: %.4f(%.4f) ATTACK_PACKET: %d TARGET: %d", curTime/1000.0f, (curTime-prevTime)/1000.0f, uMotAttack, dwVIDVictim);
    prevTime = curTime;
#endif
   
    TPacketCGAttack kPacketAtk;

    kPacketAtk.header = HEADER_CG_ATTACK;
    kPacketAtk.bType = uMotAttack;
    kPacketAtk.dwVictimVID = dwVIDVictim;
    kPacketAtk.bPacket = bPacket;
    kPacketAtk.lX =  (long)sBlending.dest.x;
    kPacketAtk.lY =  (long)sBlending.dest.y;
    kPacketAtk.lSX = (long)sBlending.source.x;
    kPacketAtk.lSY = (long)sBlending.source.y;
    kPacketAtk.fSyncDestX = sBlending.dest.x;
    // sources and dest are normalized with both coordinates positive
    // since fSync are ment to be broadcasted to other clients, the Y has to preserve the negative coord
    kPacketAtk.fSyncDestY = -sBlending.dest.y;
    kPacketAtk.dwBlendDuration = (unsigned int) (sBlending.duration *1000);
    kPacketAtk.dwComboMotion = pkInstMain->GetComboMotion();
    kPacketAtk.dwTime = ELTimer_GetServerMSec();

    if (kPacketAtk.lX && kPacketAtk.lY)
        __LocalPositionToGlobalPosition(kPacketAtk.lX, kPacketAtk.lY);

    __LocalPositionToGlobalPosition(kPacketAtk.lSX, kPacketAtk.lSY);

    if (!SendSpecial(sizeof(kPacketAtk), &kPacketAtk))
    {
        Tracen("Send Battle Attack Packet Error");
        return false;
    }

    return SendSequence();
}

To preserve the behavior of Rolling Dagger, that was based on a bug, it will be necessary to:

//// InstanceBase.cpp
/// void CInstanceBase::StateProcess()
if (eFunc & FUNC_SKILL)
....
        // NOTICE HERE THE 1!!!!!
        NEW_UseSkill(1, eFunc & FUNC_SKILL - 1, uArg&0x0f, (uArg>>4) ? true : false);
        //Tracen("°¡±õ±â ¶§¹®¿¡ ¿öÇÁ °ø°Ý");
    }
}
break;

/// void CInstanceBase::MovementProcess()
        // TODO get skill vnum
        // NOTICE HERE THE 1!!!!!!!!!!
        NEW_UseSkill(1, m_kMovAfterFunc.eFunc & FUNC_SKILL - 1, m_kMovAfterFunc.uArg & 0x0f, (m_kMovAfterFunc.uArg >> 4) ? true : false);
    ....

// ActorInstaceBattle.cpp
void CActorInstance::__HitGreate(CActorInstance& rVictim, unsigned int uiSkill, bool isEnemy)
{
    // DISABLE_KNOCKDOWN_ATTACK
    // !!!! uiSkill
    if (!uiSkill && rVictim.IsKnockDown())
        return;

    if (rVictim.__IsStandUpMotion())
        return;

    // END_OF_DISABLE_KNOCKDOWN_ATTACK

    float fRotRad = D3DXToRadian(GetRotation());
    float fVictimRotRad = D3DXToRadian(rVictim.GetRotation());

    D3DXVECTOR2 v2Normal(sin(fRotRad), cos(fRotRad));
    D3DXVECTOR2 v2VictimNormal(sin(fVictimRotRad), cos(fVictimRotRad));
        ....

and add external force to the msa of the animation to give Rolling Dagger the ability to push therefore making the enemy jump. This way even without the daggers combo it will be possible to do a double or triple rolling.

If you wanna make the daggers combo necessary, you can create a new property in the msa like "refresh_displacement" that says to the launcher, in case of a shift happening, the animation must refresh the shifting and knock_back.

Chapter 3.1: Result

After having applied these structural changes and implementations, you will obtain a revision where the clients will process attacks and movements client side to gain immediate reactivity and the server will perform a validation and synchronization of the states in real-time.

The server will act as a referee, while the clients will just execute since they will give the server all the necessary infos to referee. This approach connects the fluidity of the execution of the client with a millimetric real time synchronization of all the clients connected, presenting a more reactive and precise pvp experience. This approach also allows for multiple entities to control the push/movements of another entity at the same time. Therefore it would be possible to do stuff like performing a rolling dagger after the combo of one of your guildmates, or pushing another character already in mid-air.

Chapter 3.2: Benefits

Other than the precision and reactivity of the synchronization, this approach gives complete control to the server on the actions of the clients say to have done: timestamp, comboArgument manage to detect speed and combohack, starting and final coords are able to detect antifly and rangehacks etc Therefore, in case of malicious intents, the server has the ability to reset to everyone the initial positions.

Moreover these changes remove many "obscure" aspects of the combat system. Fly, Rolling Dagger, Falling duration, Flame Strike etc. will all be parameters that the developers and maintainers can manage and orchestrate, not something that happnes because it happens. The Metin2 combat system will, finally, become configurable, not something given for granted and unchangeable.

 

Chapter 4: Possible expansions

At this point, changes and integrations are over. Serverside anticheat systems, implementation of a collisions' engine and server side animation to reproduce, on the server, the client's actions, complete removal of the fly.. everything that includes the interaction and management of the synchronization system of the characters between clients and state of the game are finally possible.

  • Metin2 Dev 20
  • Scream 4
  • Good 9
  • muscle 1
  • Love 4
  • Love 28
Link to comment
Share on other sites

  • Premium

Very well organized article, good examples and followable thinking process. Rare to see the whats whys and hows answered in one place. Been a while since I had such a good reading from the metin community. Also really appreciate that you didn't post any ready to use implementation with it. This is how every topic should look like.

  • Metin2 Dev 1
  • Love 1
Link to comment
Share on other sites

7 hours ago, xXIntelXx said:

lacks of the implementation done in 2009

I really dont like when someone says something like this. Friendly reminder, that in 2009 most players had under 5Mb/s connection, and devs had to make sacrafices.

For example, you made TPacketGCAttack bigger about 32 bytes, back then it was significant. 

 

Also, you really shouldnt send floats, eventually you will run into conversion problems, there is a reason why YMIR never put any into packets (well, with one excpetion).

  • Metin2 Dev 1
  • Good 1
Link to comment
Share on other sites

  • Premium
11 hours ago, Tekanse said:

I really dont like when someone says something like this. Friendly reminder, that in 2009 most players had under 5Mb/s connection, and devs had to make sacrafices.

For example, you made TPacketGCAttack bigger about 32 bytes, back then it was significant. 

 

Also, you really shouldnt send floats, eventually you will run into conversion problems, there is a reason why YMIR never put any into packets (well, with one excpetion).

Source was first leaked at the end of 2013 so your point is only partially correct. YMIR did things as small, unexperienced business do, which is: not keeping track of problems, solving critical errors with minimal effort and developing new ideas ignoring limitations they have. In one of branches you can actually see their attempt to boost::asio implementation, I guess that was before _IMPROVED_PACKET_ENCRYPTION_ but they abandoned that idea for something easier. What I mean here is, they should have note somewhere problems of the game and as the hardware, software, networking and so on developed, solved this problems. They didn't and they still don't, only when it appears in game and is dangerous for players. For the float thing, you are right, but I think that's a right place for CLang both in server and client.

This topic is a golden one, thanks for publishing it.

  • Metin2 Dev 2
  • Good 1
Link to comment
Share on other sites

  • Premium
15 hours ago, Tekanse said:

I really dont like when someone says something like this. Friendly reminder, that in 2009 most players had under 5Mb/s connection, and devs had to make sacrafices.

For example, you made TPacketGCAttack bigger about 32 bytes, back then it was significant. 

 

Also, you really shouldnt send floats, eventually you will run into conversion problems, there is a reason why YMIR never put any into packets (well, with one excpetion).

Yes, I've written how we've theorized about the packet size and old internet connection.

Regarding the floats, you could always multiply by 10000 and dividing by 10000 when you need the float value again.

Link to comment
Share on other sites

48 minutes ago, xXIntelXx said:

Regarding the floats, you could always multiply by 10000 and dividing by 10000 when you need the float value again.

I know that, but when you want to give ready solution, you should either do it correctly, or at least inform about it. Looks like you have good programming knowledge so you are held to higher standards.

  • Metin2 Dev 1
Link to comment
Share on other sites

  • Premium
1 hour ago, Tekanse said:

I know that, but when you want to give ready solution, you should either do it correctly, or at least inform about it. Looks like you have good programming knowledge so you are held to higher standards.

I get it, but this was just a proof of concept and float's been used for testing purposes. Obviously it can always be polished.

Link to comment
Share on other sites

  • Forum Moderator
On 3/23/2022 at 11:45 PM, Tekanse said:

I really dont like when someone says something like this. Friendly reminder, that in 2009 most players had under 5Mb/s connection, and devs had to make sacrafices.

For example, you made TPacketGCAttack bigger about 32 bytes, back then it was significant. 

 

Also, you really shouldnt send floats, eventually you will run into conversion problems, there is a reason why YMIR never put any into packets (well, with one excpetion).

You are completely right on this. Metin2 movement and PvP was done in pre-2002 actually and worked super well for the limitations at the time (thinking bandwith and usual internet connection outside of Korea) and it gave that fast paced pvp with only very few packets and server usage. It was however reworked in 2013 for the new maps (Level 90 maps) to introduce fall and stuff like this and it ended up creating this issue. The fly problem was not a thing until their "New__" movement and new ownership functions that they made in 2013-2014.

13 hours ago, filipw1 said:

Source was first leaked at the end of 2013 so your point is only partially correct. YMIR did things as small, unexperienced business do, which is: not keeping track of problems, solving critical errors with minimal effort and developing new ideas ignoring limitations they have. In one of branches you can actually see their attempt to boost::asio implementation, I guess that was before _IMPROVED_PACKET_ENCRYPTION_ but they abandoned that idea for something easier. What I mean here is, they should have note somewhere problems of the game and as the hardware, software, networking and so on developed, solved this problems. They didn't and they still don't, only when it appears in game and is dangerous for players. For the float thing, you are right, but I think that's a right place for CLang both in server and client.

This topic is a golden one, thanks for publishing it.

Yes and no. They do not bother to update things unless they need it or are "forced" to. The boost::asio implementation was from the same upgraded packet system as Metin2, but was intended for Inferna, not for Metin, at least at that time.

 

About the original topic, that's a good summary and it gives an interesting paths and hint, that is overall a really smart and interesting topic on this! Good job and thank you for sharing!

  • Metin2 Dev 1

Gurgarath
coming soon

Link to comment
Share on other sites

  • 1 month later...
  • 1 year later...
  • 2 months later...
1 hour ago, Jimmermania said:

Is there any fix for this ?

Yes, I found a fix for this. Maybe I'll make a post here.

What I did fixed the auto-attack/no fall sync bug, it updates the positions now correctly.

No more player1 attacks player2 and player2 goes 10km away, but on player1 client he appears next to him.

 

  • Love 2
Link to comment
Share on other sites

5 minutes ago, HFWhite said:

Yes, I found a fix for this. Maybe I'll make a post here.

What I did fixed the auto-attack/no fall sync bug, it updates the positions now correctly.

No more player1 attacks player2 and player2 goes 10km away, but on player1 client he appears next to him.

 

Please share what you have. Its important fix.

Link to comment
Share on other sites

On 5/22/2024 at 1:14 PM, HFWhite said:

Yes, I found a fix for this. Maybe I'll make a post here.

What I did fixed the auto-attack/no fall sync bug, it updates the positions now correctly.

No more player1 attacks player2 and player2 goes 10km away, but on player1 client he appears next to him.

 

So could you share it ? @ HFWhite

Link to comment
Share on other sites

  • 2 weeks later...
  • Active+ Member
9 hours ago, Intel said:

The only update I can give, is this twitter thread of which I found a blog post about the client/client communication in games:

https://bymuno.com/post/rollback

The main content is quite accurate and covers almost everything that should be included, but in here what you sent has nothing to do with the topic. These checks are already being taken in real-time and distributed to clients on Metin2.

Quote from the original content;
"In rollback netcode, you're still sending input data back and forth. However, each input is sent along with a timestamp of when it was pressed."

This is completely nonsense and irrelevant for the current logic of Metin2; just this change will not make any difference. The attacks, pushes, etc., are already being calculated and APPLIED first on the client side and then sent to the server, which then communicates back to the client to apply these changes to the target entity (even if the values are not correct).

If you want to do this synchronously and correctly, all you need to do is send the action to the server without any prior action being taken on the sending client. The server should then validate the sanity of the received data, bypass ping corrections/lag checks with the handshake values, and if the data is correct, send an ACK packet to the sending client and also send the validated data to the target entity in the same logic as before, making the process synchronous.

The rollback netcode logic mentioned in the link you sent is already partially implemented in Metin2. It recalculates the packet arrival time based on an average ping value but as static. If you think the content could be useful, you can simply modify it as shown below, but don't bother as it won't work for the reasons explained above.

UserInterface/InstanceBase.cpp;
m_nAverageNetworkGap = (m_nAverageNetworkGap * 70 + nNetworkGap * 30) / 100;
kCmdNew.m_dwChkTime = dwCmdTime + m_nAverageNetworkGap;

Better values for local env(or you can change as you want);
kCmdNew.m_dwChkTime = m_dwBaseChkTime + (dwCmdTime - m_dwBaseCmdTime);

  • Good 2
Link to comment
Share on other sites

  • Premium
2 hours ago, Koray said:

The main content is quite accurate and covers almost everything that should be included, but in here what you sent has nothing to do with the topic. These checks are already being taken in real-time and distributed to clients on Metin2.

Quote from the original content;
"In rollback netcode, you're still sending input data back and forth. However, each input is sent along with a timestamp of when it was pressed."

This is completely nonsense and irrelevant for the current logic of Metin2; just this change will not make any difference. The attacks, pushes, etc., are already being calculated and APPLIED first on the client side and then sent to the server, which then communicates back to the client to apply these changes to the target entity (even if the values are not correct).

If you want to do this synchronously and correctly, all you need to do is send the action to the server without any prior action being taken on the sending client. The server should then validate the sanity of the received data, bypass ping corrections/lag checks with the handshake values, and if the data is correct, send an ACK packet to the sending client and also send the validated data to the target entity in the same logic as before, making the process synchronous.

The rollback netcode logic mentioned in the link you sent is already partially implemented in Metin2. It recalculates the packet arrival time based on an average ping value but as static. If you think the content could be useful, you can simply modify it as shown below, but don't bother as it won't work for the reasons explained above.

UserInterface/InstanceBase.cpp;
m_nAverageNetworkGap = (m_nAverageNetworkGap * 70 + nNetworkGap * 30) / 100;
kCmdNew.m_dwChkTime = dwCmdTime + m_nAverageNetworkGap;

Better values for local env(or you can change as you want);
kCmdNew.m_dwChkTime = m_dwBaseChkTime + (dwCmdTime - m_dwBaseCmdTime);

Brother, it's an article that was just interesting to read and the subject was adjacent, don't overthink it ahah

  • kekw 1
Link to comment
Share on other sites

×
×
  • Create New...

Important Information

Terms of Use / Privacy Policy / Guidelines / We have placed cookies on your device to help make this website better. You can adjust your cookie settings, otherwise we'll assume you're okay to continue.