Guida Stateful Server e sincronizzazione della posizione tra Clients

Luscha

Utente Platinum
21 Dicembre 2009
1,883
116
2,593
1,146
Questa discussione vuole essere uno study case di una delle meccaniche più base di Metin: attacchi e sincronizzazione della posizione delle entità presenti in gioco.

Scrivo inoltre per creare un reminder per il me stesso del futuro in modo da non dimenticarmi tutti i passaggi e le scelte fatte nel refactor dello stesso e dare uno spunto per chiunque volesse sperimentare in prima persona.



La discussione sarà così divisa:

  • 1. Analisi di come è stata ideata ed implementata la sincronizzazione tra client e la gestione degli attacchi
    • 1.1 Problemi di progettazione e bug nel codice
    • 1.2 Considerazione su alcune meccaniche derivate (fly, vortice del pugnale)
  • 2. Sfide che ci si trova ad affrontare quando si crea una revisione altamente customizzata di Metin2
  • 3. Il mio approccio su come risolvere le mancanze dell'implementazione fatta nel 2009
    • 3.1 Risultato
    • 3.2 Benefici
  • 4. Possibili espansioni


Capitolo 1: Movimenti e Attacchi su Metin2

Metin delega l'intero flow degli attacchi e della sincronizzazione dei client al client stesso. Il server funge solo da passacarte e raramente interviene per validare e forzare la posizione dei PGs ad intervalli regolari (per evitare che i clients siano completamente allo sbaraglio e si trovino ad avere PG a coordinate completamente randomiche).
Ogni client viene notificato di informazioni base, come "Sono 123 e ho iniziato ad attaccare in questa direzione" o "Sono 123 e ho colpito 456" o ancora "Sono 456, sono fermo e sono qui" ed ogni client esegue localmente, con le sue informazioni sullo stato del gioco (ad esempio che 123 stava alle coordinate x, y mentre 456 stava alle coordinate x2, y2) le animazioni e gli spostamenti, modificando lo stato locale dei pg (dopo l'hit 456 sta alle coordinate x3, y3 mentre 123 attaccando si è spostato alle coordinate x+1, y+1).
Ogni tanto, per la precisione ogni 300 ms, ogni client manda al server il proprio stato e il server sincronizza gli altri client per avere qualcosa simile ad un gioco giocabile e solido.



Di seguito una prima overview del flusso che Metin2 utilizza da più di 15 anni:

  1. Il client di 123 inizia a volteggiare la spada (o arma che sia) e manda al server un pacchetto HEADER_CG_CHARACTER_MOVE con argument FUNC_COMBO.
  2. Il server broadcasta il pacchetto a tutti i client nelle vicinanze
  3. Tutti i client che ricevono il pacchetto iniziano a fare volteggiare la spada di 123
  4. Il client di 123 colpisce 456 buttandolo per terra a 10 metri di distanza e manda un pacchetto HEADER_CG_ATTACK al server che dice al server che ha colpito 456 in modo che il server possa processare il danno e diminuire la vita
    1. Durante questo ste, inoltre, il server darà a 123 il "possesso" (ovvero il fatto che lo sta attaccando) di 456
    2. Manderà a tutti i client l'informazione che 123 è l'unico PG che può spostare e modificare la posizione del PG 456
  5. Il client di 123 raccoglie le coordinate alle quali 456 arriverà dopo essersi rialzato e le tiene in memoria
  6. Nel mentre nel client di 456, il PG di 123 eventualmente colpirà 456 e lo farà cadere a 10 metri di distanza
  7. Passati al massimo 300 ms il client di 123 manderà le posizioni di tutte le entità di cui ha il "possesso" al server tramite un pacchetto HEADER_CG_SYNC_POSITION, il quale effettuerà dei controllo e broadcasterà le nuove posizioni a tutti i client; compreso quello di 456
  8. Tutti i client porteranno i PG in questione alle coordinate indicate, che potrebbero essere già corrette come no
    1. Questo processo avviene forzando lo spostamento ed eventualmente il KNOCK_BACK dei pg alle coordinate indicate da 123
  9. Eventualmente, quando 456 termina l'animazione STANDUP (il rialzarsi) il client manderà un pacchetto HEADER_CG_CHARACTER_MOVE con argument FUNC_WAIT che comunicherà nuovamente la posizione definitiva del pg 456 a tutti i client

Questo flusso ha il vantaggio che tutti i clients processano localmente le azioni intermedie e risulta quindi molto fluido in quanto il server interviene raramente e si presuppone che a tutti gli stadi i clients partano dalle stesse condizioni e pertanto arrivino agli stessi risultati.
Uno dei contro più palesi, però, è che il server mantiene informazioni limitatissime e frammentate sullo stato reale del gioco e può quindi fare poco per intervenire in caso di dichiarazioni malevoli da parte di un client.



Entrando più nel merito

Capitolo 1.1: Problemi di progettazione e bug nel codice

La prima critica che si può muovere al sistema disegnato da YMIR è, come già detto, che il server mantiene informazioni molto frammentate dello stato dei client.
Voglio evidenziare in particolare:
  • Il server sa solo la posizione iniziale dei PG al punto 1 del flusso. prima che 123 attacchi 456 e prima che 456 venga spostato
  • Il server verrà notificato della nuova posizione di 123 e 456 solo all'arrivo del pacchetto HEADER_CG_SYNC_POSITION, che sposterà istantaneamente alle coordinate indicate
  • Tutto quello che avviene tra questi 2 momenti è sconosciuto al server e non può quindi effettuare particolari controlli sull'attacco e sulle coordinate dove il 456 viene spostato
    • Per esempio il server potrebbe vedere 456 alle coordinate di partenza, ma client side esso è già stato spostato dalla 3 spadata di 4 metri e quando arriva il knock back finale il server vedrà uno spostamento potenzialmente illegale (3 spadata + 4)
  • Il server durante l'intero processo vede solo 2 stati: quello iniziale e quello finale, rendendolo inutilmente stupido
Tralasciando la falla di sicurezza, che abbiamo visto ha pagato negli anni, sarebbe comunque una soluzione accettabile se il processo fosse effettivamente consistente e funzionale.
Sfortunatamente però non è così.

Il primo problema in cui si può e si inevitabilmente incorre è che se per qualche motivo gli stati iniziali dei clients non sono uguali al 100%, essi computeranno e processeranno spostamenti, animazioni e angolazioni differenti.

1647997986693.png


Nell'immagine sopra si vede come una minuscola differenza nella posizione iniziale di B, la sua destinazione potrebbe cambiare sensibilmente rendendo l'interazione strana in quando ci si potrebbe trovare a prendere danni mentre si pensa di essere al sicuro. Per quanto possa sembrare una situazione remota, questa è in realtà la norma.

Ricordiamo che viene prima mandato il pacchetto HEADER_CG_CHARACTER_MOVE con argument FUNC_COMBO per comunicare l'inizio di una combo e poi eventualmente quelli dell'attacco avvenuto e la nuova posizione.
Ebbene se i PG sono particolarmente vicini, l'attacccante potrebbe mandare il HEADER_CG_CHARACTER_MOVE, il HEADER_CG_ATTACK e il HEADER_CG_SYNC_POSITION prima ancora che chi viene attaccato inizi a processare l'animazione dell'attacco, forzando uno spostament ed un KNOCK_BACK prima ancora che la spada colpisca il PG nel client della vittima.
È quindi inevitabile che finchè l'attaccande non manda il HEADER_CG_SYNC_POSITION, gli stati che i giocatori vedono potrebbero divergere pesantamente e quando finalmente arriva il server a mettere in riga tutti, i PG si trovano a venire risucchiati, spinti, traslati in posizioni dove non pensavano di essere, conferendo alle risse e le guerre quel tocco di Metin2 che noi tutti (non) adoriamo.
Più pg interagiranno tra di loro infatti, più l'imprecisione aumenterà e più drastici saranno gli spostamenti forzati da effetturare per ripristinare la sincronia.

Per rendere più reattivi il client, gli sviluppatori decisero inoltre di aggiungere una forzatura sugli spostamenti e sui KNOCK_BACK prima che il pacchetto HEADER_CG_SYNC_POSITION fosse mandato e broadcastato:

Nella funzione void CPythonPlayerEventHandler::OnHit(UINT uSkill, CActorInstance& rkActorVictim, BOOL isSendPacket) infatti viene forzata la sincronizzazione con un rkActorVictim.TEMP_Push(kVictim.m_lPixelX, kVictim.m_lPixelY);. Tutto legit fino ad ora.
Se non per il fatto che le coordinate che vengono sincronizzate vengono ottenute da rkActorVictim.NEW_GetLastPixelPositionRef(. Questa funzione che internamente richiama GetBlendingPosition fa una cosa semplice:
C++:
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;
    }
}

Ritorna le coordinate correnti se il PG è fermo, o le coordinate correnti + m_PhysicsObject.GetLastPosition(pPosition); se il PG sta venendo spostato.
Questa classe mistica chiamata CPhysicsObject è la responsabile degli spostamenti che avvengono su metin. Gli viene detto di quanto un'entità va spostata in quale direzione in quale tempo, e lei si occupa di spostare man mano l'entità.
Il bug che ho trovato è che utilizzando m_PhysicsObject.GetLastPosition(pPosition);, che ritorna lo spostamento complessivo che l'entità deve compiere, GetBlendingPosition non ritorna la posizione finale dell'entità, ma le coordinate attuali + lo spostamento.
Sebbene questo sembri corretto, esso non lo è nel caso in cui questa funzione venga chiamata dopo che l'entità si è spostata di un po'; in quel caso il valore ritornato sarà ancora più "in là" rispetto alla destinazione inizialmente prevista.


Capitolo 1.2: Considerazione su alcune meccaniche derivate (fly, vortice del pugnale)

Quando eventualmente le posizioni tra i 2 client vengono sincronizzate, la funzione che processa il pacchetto HEADER_CG_SYNC_POSITION
C++:
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);
        }
    }
}

Utilizza la funzione void SetBlendingPosition(const TPixelPosition & c_rPosition, float fBlendingTime = 1.0f) che internamente dice al client di effetturaer uno spostamento e di distribuirlo nel lasso di 1 secondo.

In questo lasso di tempo il PG entra in uno stato di __IsSyncing(), nel quale esso non può muoversi in alcum nodo se non utilizzando un'abilità.
Questo è ciò che dà vita al fenomeno chiamato "Fly", nel quale il pg scivola lentamente e non può né muoversi né attaccare.

In pratica il Fly non è altro che il tentativo del client di sincronizzare il proprio stato con altri clients, che forza il PG ad un lento spostamento in posizione e si assicura che lo stato non possa cambiare bloccando ogni azione al PG stesso.
Se volete la mia opinione, è comprensibile che una spadata blocchi il PG in loco per dare modo di effettuare una combo fisica. Ma questa dovrebbe essere una dinamica controllata degli sviluppatori che mettono, per esempio, un mezzo secondo di stordimento alle spadate piuttosto che il risultato del processo di sincronizzazione con altri clients.

Ancora più affascinante è l'impatto del bug inerente alla funzione GetBlendingPosition descritta poco sopra:
durante l'esecuzione di Vortice Del Pugnale, infatti, se il PG è già in uno stato di IsPushing (ovvero sta venendo spostato dalla 4 spadata) durante l'esecuzione della funzione OnHit(UINT uSkill, CActorInstance& rkActorVictim, BOOL isSendPacket), verrà effettuato un nuovo TEMP_Push con le coordinate che, presumibilmente, rappresentano il punto di arrivo del PG.

Se questo è vero per il primo hit di Vortice, durante il secondo la vittima si sarà già spostata in avanti di un po'; a quel punto le coordinate ritornate da GetBlendingPosition non saranno più quelle della 4° spadata, ma saranno 4° spadata + qualcosa. Il pg sarà quindi spostato un po' più in la e verrà triggherata un'altra animazione di KNOCK_BACK.

1647999576634.png


Non so se gli sviluppatori abbiano deliberatamente introdotto questa dinamica, o se sia solo il risultato casuale, ma ancora una volta ritengo che se un'abilità come Vortice Del Pugnale faccia rimbalzare il PG, questa dovrebbe essere codificata con parametri specifici della skill (external_force? refresh_push?), ma non sicuramente tramite un'interazione così poco visibile e controintuitiva che fa uso di una logica probabilmente buggata.

2. Sfide che ci si ritrova ad affrontare quando si crea una revisione altamente customizzata di Metin2

Se siete persone che intendono effetturare modifiche pesanti, magari al sistema delle skill, del pvp o che intende sviluppare un sistema di monitoring per cheats e hack server-side, lo stato attuale rende un lavoro difficile ancora più difficile.
Il server non può contribuire in alcun modo alla gestione degli stati e questo è un problema per tutti quei sistemi che si basano sulla precisione delle posizioni dei PGs durante l'interazione del giocatore con il gioco.
Il client non aiuta in nessun senso in quanto ogni client ha uno stato differente e non si possono pertanto considerare attendibili.
Ogni tentativo di rendere più reattivo e preciso il client si scontra inevitabilmente con meccaniche nate da strane e casuali interazioni delle entità nel client come nel caso del "Fly" o di Vortice del Pugnale che per quanto possano essere etichettate come BUG, sono comunque parte del gioco da anni e vanno pertanto preservate o emulate.

3. Il mio approccio su come risolvere le mancanze dell'implementazione fatta nel 2009

Di seguito descriverò il mio refactor e la mia soluzione alla situazione sopra descritta.
La mia implementazione è fortemente legata ai file di UniversalElements e pertanto non applicabile alla maggior parte dei server esistenti; non posterò molto codice (non è una release) ma descriverò come arrivare ad un risultato simile e i processi mentali che mi hanno portato ad effettuare determinate scelte.

Il discorso si riassume berevemente in:

  • Arricchimento del pacchetto HEADER_CG_ATTACK in modo da descrivere meglio l'azione
  • Broadcast del HEADER_CG_ATTACK a tutti i clients in tempo reale per correggere lo stato immediatamente
  • Implementazione di uno "state" server-side che emuli lo spostamento delle entità in tempo reale
  • Utilizzo del HEADER_CG_SYNC_POSITION solo per rollbackare lo stato in caso di rilevamento di informazioni sbagliate mandate da un client (per annullare un'azione illegale)
  • Fix della funzione GetBlendingPosition
Tali semplici modifiche sembrano scontate, tanto scontate che l'unica spiegazione che mi sono dato alla domanda "perché non fu disegnato così sin dall'inizio" è che al tempo i server non potessero prendersi il carico di mantere uno stato aggiornato e che le connessioni internet non permettessero il traffico di grossi pacchetti alla frequenza con cui il HEADER_CG_ATTACK viene usato.

Il nuovo flusso appare quindi così:
  1. Il client di 123 inizia a volteggiare la spada (o arma che sia) e manda al server un pacchetto HEADER_CG_CHARACTER_MOVE con argument FUNC_COMBO.
  2. Il server broadcasta il pacchetto a tutti i clients nelle vicinanze
  3. Tutti i clients che ricevono il pacchetto iniziano a fare volteggiare la spada di 123
  4. Il client di 123 colpisce 456 buttandolo per terra a 10 metri di distanza e manda un pacchetto HEADER_CG_ATTACK al server che dice al server che ha colpito 456 in modo che il server possa processare il danno e diminuire la vita
    1. Il pacchetto contiene informazioni come posizione iniziale di 456, posizione finale di 456 (con doppia precisione per essere precisa al millimetro), timestamp dell'attacco, quale parte della combo ha generato l'hit, durata dello spostamento in millisecondi
  5. Il server valida il pacchetto HEADER_CG_ATTACK, processa il danno, e broadcasta le informazioni ai clients
    1. Il server inizializza inoltre l'emulazione dello postamento dalle coordinate iniziali a quelle finali di 456
  6. Nel mentre nel client di 456, il PG di 123 eventualmente colpirà 456 e lo farà cadere a 10 metri di distanza
  7. Quando il client di 456 riceve il pacchetto HEADER_CG_ATTACK broadcastato da 123, corregge lo stato interno con le nuove informazioni on-the-fly
    1. Ad esempio se il client ha già iniziato a processare lo spostamento e il KNOCK_BACK, la traiettoria verrà corretta con le coordinate validate dal server

La sincronizzazione non è inoltre "bloccante", in quanto corregge l'esecuzione locale del client o la sovrascrive (in base al fatto che il pacchetto HEADER_CG_ATTACK arrivi prima o dopo l'hit della spadata nel client degli osservatori).
Ma la differenza più grossa è che il server mantiene uno stato aggiornato di ogni entità ad ogni attacco e durante tutta l'esecuzione dello spostamento. Diventa quindi in grado di effettuare controlli, modifiche, correzioni ai dati che provengono dall'attaccante con una precisione molto alta.
Per dare un'idea delle modifiche, nel server ho aggiunto uno state che può essere eseguito in parallelo nel file FCM.cpp

C++:
// 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();
    }
}

che mi permette di utilizzare un 4 state nella classe character
C++:
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();
}
C++:
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();
    }
}

///////////////////
////// Da utilizzare per "muovere" gradualmente l'entità nella posizione desiderata mentre questa può continuare a fare quello che vuole (da utiulizzare alla recezione din un 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;
}
Il nuovo pacchetto TPacketCGAttack si presenta come

C++:
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;

con la sua controparte GC
Codice:
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 inizio con il fix della logica inerente al GetBlendingPosition e tutte le sue varianti, che si risolve passando alla funzione che inizializza non solo lo spostamento, ma anche la posizione finale.
C++:
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);
}
e fixando la funzione stessa
C++:
void CActorInstance::GetBlendingPosition(TPixelPosition * pPosition)
{
    if (m_PhysicsObject.isBlending())
    {
        m_PhysicsObject.GetFinalPosition(pPosition);
    }
    else
    {
        GetPixelPosition(pPosition);
    }
}

La gestione del nuovo pacchetto HEADER_GC_CHARACTER_ATTACK si presenta più o meno così
C++:
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));
    }
}

//////////////////////
//// __Push semplificato e chiamato solo da 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);
        }
    }
}

//////////////////////
// Per capire se il client ha già iniziato a processare l'attacco o se va inizializzado un nuovo sync_pixelposition:

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

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

// client attack decrease 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 increase
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;
}

Nuova Gestione dell'hit dell'attacco che processa localmente lo spostamento, colleziona le informazioni, e manda il pacchetto al server:

C++:
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();
}

Per preservare il comportamento di Vortice Del Pugnale, che si basava su un "bug" sarà quindi necessario:

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

/// void CInstanceBase::MovementProcess()
        // TODO get skill vnum
        // NOTARE QUI l'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));
        ....

E aggiungere dell'external force all'MSA dell'animazione per conferire a Vortice l'abilità di pushare e quindi far saltare.
In questo modo anche senza la combo a pugnalate sarà possibile far un doppio o triplo vortice.

Se si volesse fare in modo che la combo a pugnalate sia necessaria, basta creare una nuova proprietà nell'msa tipo "refresh_displacement" che dica al launcher che in caso ci sia già uno spostamento in corso, l'animazione deve refreshare lo spostamento e il knock_back.

Capitolo 3.1: Risultato

Dopo aver applicato queste modifiche progettuali e d'implementazione si otterrà una revisione nella quale i clients processano attacchi e spostamenti client side per avere reattività immediata e dove il server comunque effettua una validazione e una sincronizzazione in real-time degli stati.
Il server farà da arbitro, mentre i clients faranno da esecutori e menestrelli in quanto daranno al server tutte le informazioni necessari per arbitrare.
Questo approccio unisce la fluidità dell'esecuzione client side con una sincronizzazione millimetrica e real time di tutti i clients connessi, regalando quindi unn'esperienza pvp più reattiva e precisa.

Inoltre questo approccio permette di rimuovere, se desiderato, il concetto di “proprietà” dello spostamento di un pg che limita ad un solo PG per volta di spostare entità nel game.
Questo permetterebbe quindi interazioni a metà di uno spostamento come effettuare un vortice del pugnale dopo la combo di un compagno di gilda, o lo spostare un PG già a mezz’aria.

Capitolo 3.2: Benefici
Oltre alla precisione e rattività della sincronizzazione, questo approccio dà completo controllo al server sulle azioni che i clients dicono di aver fatto:
timestamp e comboArgument permettono di detectare speedhack e combohack, le coordinate di partenza e arrivo permettono di rilevare l’antifly e il range hack, ….
E in caso di azione malevola, il server ha la facoltà di ripristinare a tutti le posizioni iniziali.

Ma ancora più importante queste modifiche eliminano molti degli aspetti “oscuri” del sistma di combattimento. Fly, Vortice del Pugnale, Colpo di Fiamma, durata delle cadute, e via dicendo saranno tutti parametri che il developer e il mantainer potranno gestire ed orchestrare. Non qualcosa che succede perché succede. Il sistema di combattimento di metin diventerà quindi, finalmente, configurabile, e non qualcosa che viene dato come calato dal cielo e quindi non modificabile.

Capitolo 4: Possibili espansioni
A questo punto le modifiche ed integrazioni sono infinite.
Sistemi anticheats serverside, implementazione di un motore di collisioni e animazioni server side per riprodurre server side le azioni del client, rimozione completa del fly, ...
Qualunque cosa preveda l’interazione e la gestione del sistema di sincornizzazione dei PG tra client e lo stato del gioco sono sdoganate.
 
Una delle poche volte che mi capita effettivamente di leggere un topic veramente interessante, spero che questo tuo "rilascio" di informazioni potrà essere utile ad altri developer e che si inizi a lavorare parecchio su questo, in modo magari da far rimanere le meccaniche di Metin come sono ora, ma controllate e migliorate.
Topic veramente interessante e tutto è spiegato in modo semplice e capibile da tutti, veramente good job. :D