2V1-d: offline login -> select -> loading against a fake 40250 server
port.login_flow drives the real 40250 root scripts through the ported CPythonNetworkStream and CAccountConnector against a loopback auth + game server built from net/classic (wire layouts, sequence table, cipher) that answers like the 40250 desc.cpp/input*.cpp: handshake, key agreement, LOGIN3/AUTH_SUCCESS, LOGIN2, the character list, DirectEnter reconnect, CHARACTER_SELECT, the loading packets and CLIENT_VERSION, with every sequence byte checked. - NetStream::ActivateCipher decrypts ciphertext already buffered behind KEY_AGREEMENT_COMPLETED (PORT) - CPythonApplication is final (PORT): no application object exists on the platform yet, so its virtual overrides must be called directly - app.GetTime (GetGlobalTime) and camera/center-position stubs - SendClientVersionPacket verbatim in the PhaseGame stand-in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4d9b800efb
commit
8d9d863c7e
@@ -0,0 +1,442 @@
|
||||
// See port_login_flow_server.h. Each step names the 40250 server function whose behaviour it plays.
|
||||
#include "port_login_flow_server.h"
|
||||
|
||||
#include "../src/net/classic/classic_cipher.h"
|
||||
#include "../src/net/classic/sequence_table.h"
|
||||
#include "../src/net/classic/wire_classic.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
using namespace mtnet::classic;
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr std::uint32_t kHandshake = 0x2468ace0;
|
||||
constexpr int kHandshakeRetryLimit = 32; // game/src/desc.h HANDSHAKE_RETRY_LIMIT
|
||||
constexpr int kIdleMs = 20000;
|
||||
|
||||
// get_dword_time(): milliseconds on the server's clock.
|
||||
std::uint32_t now_ms()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
static const auto start = steady_clock::now();
|
||||
return static_cast<std::uint32_t>(duration_cast<milliseconds>(steady_clock::now() - start).count());
|
||||
}
|
||||
|
||||
int listen_loopback(int* port)
|
||||
{
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
int on = 1;
|
||||
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
|
||||
sockaddr_in addr = {};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0 || listen(fd, 4) != 0)
|
||||
{
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
socklen_t len = sizeof(addr);
|
||||
getsockname(fd, reinterpret_cast<sockaddr*>(&addr), &len);
|
||||
*port = ntohs(addr.sin_port);
|
||||
return fd;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
T zeroed()
|
||||
{
|
||||
T value;
|
||||
std::memset(&value, 0, sizeof(value));
|
||||
return value;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// One DESC: the socket, its cipher and the sequence position of the next client packet.
|
||||
struct FakeLoginServer::Connection
|
||||
{
|
||||
int fd = -1;
|
||||
ClassicCipher cipher;
|
||||
size_t sequence = 0;
|
||||
const std::atomic<bool>* stop = nullptr;
|
||||
|
||||
~Connection()
|
||||
{
|
||||
if (fd >= 0)
|
||||
close(fd);
|
||||
}
|
||||
|
||||
// DESC::Packet: encrypted once the key agreement finished.
|
||||
bool Send(const void* data, size_t size)
|
||||
{
|
||||
std::vector<std::uint8_t> bytes(static_cast<const std::uint8_t*>(data), static_cast<const std::uint8_t*>(data) + size);
|
||||
if (cipher.activated())
|
||||
cipher.encrypt(bytes.data(), bytes.size());
|
||||
size_t sent = 0;
|
||||
while (sent < bytes.size())
|
||||
{
|
||||
ssize_t n = send(fd, bytes.data() + sent, bytes.size() - sent, 0);
|
||||
if (n <= 0)
|
||||
return false;
|
||||
sent += static_cast<size_t>(n);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
bool Send(const T& packet)
|
||||
{
|
||||
return Send(&packet, sizeof(packet));
|
||||
}
|
||||
|
||||
// DESC::SetPhase sends the phase packet.
|
||||
bool Phase(std::uint8_t phase)
|
||||
{
|
||||
Phase_ p = {HDR_GC_PHASE, phase};
|
||||
return Send(p);
|
||||
}
|
||||
|
||||
// Reads exactly `size` bytes, or returns 0 on EOF / -1 on timeout, stop or error.
|
||||
int Read(void* data, size_t size)
|
||||
{
|
||||
auto* p = static_cast<std::uint8_t*>(data);
|
||||
size_t got = 0;
|
||||
for (int waited = 0; got < size;)
|
||||
{
|
||||
if (stop->load())
|
||||
return -1;
|
||||
pollfd pfd = {fd, POLLIN, 0};
|
||||
int ready = poll(&pfd, 1, 50);
|
||||
if (ready == 0)
|
||||
{
|
||||
if ((waited += 50) >= kIdleMs)
|
||||
return -1;
|
||||
continue;
|
||||
}
|
||||
ssize_t n = recv(fd, p + got, size - got, 0);
|
||||
if (n == 0)
|
||||
return got == 0 ? 0 : -1;
|
||||
if (n < 0)
|
||||
return -1;
|
||||
got += static_cast<size_t>(n);
|
||||
}
|
||||
if (cipher.activated())
|
||||
cipher.decrypt(data, size);
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
FakeLoginServer::~FakeLoginServer()
|
||||
{
|
||||
Stop();
|
||||
}
|
||||
|
||||
bool FakeLoginServer::Start()
|
||||
{
|
||||
m_auth_listener = listen_loopback(&m_auth_port);
|
||||
m_game_listener = listen_loopback(&m_game_port);
|
||||
if (m_auth_listener < 0 || m_game_listener < 0)
|
||||
return false;
|
||||
m_thread = std::thread([this] { Run(); });
|
||||
return true;
|
||||
}
|
||||
|
||||
void FakeLoginServer::Stop()
|
||||
{
|
||||
m_stop = true;
|
||||
if (m_thread.joinable())
|
||||
m_thread.join();
|
||||
for (int* fd : {&m_auth_listener, &m_game_listener})
|
||||
if (*fd >= 0)
|
||||
{
|
||||
close(*fd);
|
||||
*fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> FakeLoginServer::Events()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_events;
|
||||
}
|
||||
|
||||
std::string FakeLoginServer::Error()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_error;
|
||||
}
|
||||
|
||||
bool FakeLoginServer::Has(const std::string& event)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
for (const auto& e : m_events)
|
||||
if (e == event)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void FakeLoginServer::Event(const std::string& event)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_events.push_back(event);
|
||||
}
|
||||
|
||||
bool FakeLoginServer::Fail(const std::string& error)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
if (m_error.empty())
|
||||
m_error = error;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool FakeLoginServer::Accept(int listener, Connection& c)
|
||||
{
|
||||
c.stop = &m_stop;
|
||||
for (int waited = 0; !m_stop; waited += 50)
|
||||
{
|
||||
pollfd pfd = {listener, POLLIN, 0};
|
||||
if (poll(&pfd, 1, 50) > 0)
|
||||
{
|
||||
c.fd = accept(listener, nullptr, nullptr);
|
||||
return c.fd >= 0;
|
||||
}
|
||||
if (waited >= kIdleMs)
|
||||
return Fail("no client connected");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void FakeLoginServer::Run()
|
||||
{
|
||||
{
|
||||
Connection auth;
|
||||
if (!Accept(m_auth_listener, auth) || !ServeAuth(auth))
|
||||
return;
|
||||
}
|
||||
// The first game connection ends at the character list; selecting a character reconnects
|
||||
// (net.DirectEnter → CPythonNetworkStream::ConnectGameServer) and that one goes on to loading.
|
||||
for (int index = 1; !m_stop; ++index)
|
||||
{
|
||||
Connection game;
|
||||
if (!Accept(m_game_listener, game) || !ServeGame(game, index))
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// DESC::Setup → SetPhase(PHASE_HANDSHAKE) + StartHandshake; CInputProcessor::Handshake /
|
||||
// DESC::HandshakeProcess; SendKeyAgreement; CInputHandshake::Analyze(HEADER_CG_KEY_AGREEMENT).
|
||||
bool FakeLoginServer::Handshake(Connection& c, std::uint8_t phase_after)
|
||||
{
|
||||
if (!c.Phase(PHASE_HANDSHAKE))
|
||||
return Fail("send PHASE_HANDSHAKE");
|
||||
mtnet::classic::Handshake out = {HDR_HANDSHAKE, kHandshake, now_ms(), 0};
|
||||
std::uint32_t sent_time = out.time;
|
||||
if (!c.Send(out))
|
||||
return Fail("send GC_HANDSHAKE");
|
||||
|
||||
for (int retry = 0;; ++retry)
|
||||
{
|
||||
auto in = zeroed<mtnet::classic::Handshake>();
|
||||
if (c.Read(&in, sizeof(in)) != 1 || in.header != HDR_HANDSHAKE)
|
||||
return Fail("expected CG_HANDSHAKE");
|
||||
if (in.handshake != kHandshake)
|
||||
return Fail("CG_HANDSHAKE carries the wrong handshake id");
|
||||
const std::uint32_t now = now_ms();
|
||||
const int bias = static_cast<int>(now - (in.time + in.delta));
|
||||
if (in.delta >= 0 && bias >= 0 && bias <= 50)
|
||||
break;
|
||||
if (retry + 1 > kHandshakeRetryLimit)
|
||||
return Fail("handshake retry limit reached");
|
||||
std::int32_t delta = static_cast<std::int32_t>(now - in.time) / 2;
|
||||
if (delta < 0)
|
||||
delta = static_cast<std::int32_t>(now - sent_time) / 2;
|
||||
out = {HDR_HANDSHAKE, kHandshake, now, delta};
|
||||
sent_time = now;
|
||||
if (!c.Send(out))
|
||||
return Fail("send GC_HANDSHAKE retry");
|
||||
}
|
||||
|
||||
auto agreement = zeroed<KeyAgreement>();
|
||||
size_t data_length = KEY_AGREEMENT_MAX_DATA_LEN;
|
||||
const size_t agreed_length = c.cipher.prepare(agreement.data, &data_length);
|
||||
if (agreed_length == 0)
|
||||
return Fail("cipher prepare");
|
||||
agreement.header = HDR_KEY_AGREEMENT;
|
||||
agreement.agreed_length = static_cast<std::uint16_t>(agreed_length);
|
||||
agreement.data_length = static_cast<std::uint16_t>(data_length);
|
||||
if (!c.Send(agreement))
|
||||
return Fail("send GC_KEY_AGREEMENT");
|
||||
|
||||
auto reply = zeroed<KeyAgreement>();
|
||||
if (c.Read(&reply, sizeof(reply)) != 1 || reply.header != HDR_KEY_AGREEMENT)
|
||||
return Fail("expected CG_KEY_AGREEMENT");
|
||||
// "Send out the key agreement completion packet first to help client to enter encryption mode".
|
||||
KeyAgreementCompleted completed = {HDR_GC_KEY_AGREEMENT_COMPLETED, {}};
|
||||
if (!c.Send(completed))
|
||||
return Fail("send GC_KEY_AGREEMENT_COMPLETED");
|
||||
if (!c.cipher.activate(false, reply.agreed_length, reply.data, reply.data_length))
|
||||
return Fail("key agreement failed");
|
||||
c.cipher.set_activated(true);
|
||||
return c.Phase(phase_after) || Fail("send phase after handshake");
|
||||
}
|
||||
|
||||
// Reads one client packet of a known header: the static size from packet_info.cpp plus the sequence
|
||||
// byte CInputProcessor::Process checks against gc_abSequence.
|
||||
static int read_packet(FakeLoginServer::Connection& c, std::uint8_t expect, void* packet, size_t size,
|
||||
std::string* error)
|
||||
{
|
||||
auto* bytes = static_cast<std::uint8_t*>(packet);
|
||||
int got = c.Read(bytes, 1);
|
||||
if (got != 1)
|
||||
return got;
|
||||
if (bytes[0] != expect)
|
||||
{
|
||||
char text[64];
|
||||
std::snprintf(text, sizeof(text), "expected CG header %u, got %u", expect, bytes[0]);
|
||||
*error = text;
|
||||
return -1;
|
||||
}
|
||||
if (static_cast<size_t>(packet_size_cg(expect)) != size)
|
||||
{
|
||||
*error = "size table disagrees with the packet struct";
|
||||
return -1;
|
||||
}
|
||||
if (size > 1 && c.Read(bytes + 1, size - 1) != 1)
|
||||
return -1;
|
||||
if (is_sequence_cg(expect))
|
||||
{
|
||||
std::uint8_t sequence = 0;
|
||||
if (c.Read(&sequence, 1) != 1)
|
||||
return -1;
|
||||
const std::uint8_t want = SEQUENCE_TABLE[c.sequence];
|
||||
if (sequence != want)
|
||||
{
|
||||
char text[80];
|
||||
std::snprintf(text, sizeof(text), "SEQUENCE mismatch 0x%x != 0x%x header %u", want, sequence, expect);
|
||||
*error = text;
|
||||
return -1;
|
||||
}
|
||||
c.sequence = (c.sequence + 1) % SEQUENCE_TABLE_SIZE;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// g_bAuthServer: CInputAuth::Login → GC_AUTH_SUCCESS with the login key the game server will accept.
|
||||
bool FakeLoginServer::ServeAuth(Connection& c)
|
||||
{
|
||||
if (!Handshake(c, PHASE_AUTH))
|
||||
return false;
|
||||
Event("auth:handshake");
|
||||
|
||||
auto login = zeroed<CGLogin3>();
|
||||
std::string error;
|
||||
if (read_packet(c, HDR_CG_LOGIN3, &login, sizeof(login), &error) != 1)
|
||||
return Fail("auth: CG_LOGIN3: " + error);
|
||||
if (std::strcmp(login.login, kLogin) != 0 || std::strcmp(login.passwd, kPassword) != 0)
|
||||
return Fail("auth: CG_LOGIN3 carries the wrong account");
|
||||
Event("auth:login3");
|
||||
|
||||
GCAuthSuccess success = {HDR_GC_AUTH_SUCCESS, kLoginKey, 1};
|
||||
if (!c.Send(success))
|
||||
return Fail("auth: send GC_AUTH_SUCCESS");
|
||||
|
||||
// CAccountConnector connects to the game server and then disconnects from us.
|
||||
std::uint8_t byte = 0;
|
||||
if (c.Read(&byte, 1) != 0)
|
||||
return Fail("auth: expected the client to hang up");
|
||||
Event("auth:closed");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool FakeLoginServer::ServeGame(Connection& c, int index)
|
||||
{
|
||||
const std::string name = "game" + std::to_string(index);
|
||||
if (!Handshake(c, PHASE_LOGIN))
|
||||
return false;
|
||||
Event(name + ":handshake");
|
||||
|
||||
// CInputLogin::LoginByKey → (DB) CInputDB::LoginSuccess: GC_EMPIRE, SetPhase(PHASE_SELECT),
|
||||
// SendLoginSuccessPacket.
|
||||
auto login = zeroed<CGLogin2>();
|
||||
std::string error;
|
||||
if (read_packet(c, HDR_CG_LOGIN2, &login, sizeof(login), &error) != 1)
|
||||
return Fail(name + ": CG_LOGIN2: " + error);
|
||||
if (std::strcmp(login.login, kLogin) != 0 || login.login_key != kLoginKey)
|
||||
return Fail(name + ": CG_LOGIN2 carries the wrong account or login key");
|
||||
Event(name + ":login2");
|
||||
|
||||
GCEmpire empire = {HDR_GC_EMPIRE, 1};
|
||||
auto success = zeroed<GCLoginSuccess>();
|
||||
success.header = HDR_GC_LOGIN_SUCCESS_NEWSLOT;
|
||||
SimplePlayer& player = success.players[0];
|
||||
player.id = 1;
|
||||
std::strncpy(player.name, kCharacterName, sizeof(player.name) - 1);
|
||||
player.job = 0;
|
||||
player.level = 10;
|
||||
player.st = player.ht = player.dx = player.iq = 5;
|
||||
player.x = kX;
|
||||
player.y = kY;
|
||||
player.addr = static_cast<std::int32_t>(htonl(INADDR_LOOPBACK));
|
||||
player.port = static_cast<std::uint16_t>(m_game_port);
|
||||
success.handle = static_cast<std::uint32_t>(index);
|
||||
success.random_key = 0x5a5a5a5a;
|
||||
if (!c.Send(empire) || !c.Phase(PHASE_SELECT) || !c.Send(success))
|
||||
return Fail(name + ": send login success");
|
||||
|
||||
// The character list screen: the client either reconnects (DirectEnter) or selects here.
|
||||
auto select = zeroed<CGPlayerSelect>();
|
||||
const int got = read_packet(c, HDR_CG_CHARACTER_SELECT, &select, sizeof(select), &error);
|
||||
if (got == 0)
|
||||
{
|
||||
Event(name + ":closed");
|
||||
return true;
|
||||
}
|
||||
if (got != 1)
|
||||
return Fail(name + ": CG_CHARACTER_SELECT: " + error);
|
||||
if (select.player_index != 0)
|
||||
return Fail(name + ": CG_CHARACTER_SELECT picks an empty slot");
|
||||
Event(name + ":select");
|
||||
|
||||
// CInputDB::PlayerLoad: SetPhase(PHASE_LOADING), CHARACTER::MainCharacterPacket, PointsPacket.
|
||||
auto main = zeroed<GCMainCharacter>();
|
||||
main.header = HDR_GC_MAIN_CHARACTER;
|
||||
main.vid = kMainVID;
|
||||
main.race = 0;
|
||||
std::strncpy(main.name, kCharacterName, sizeof(main.name) - 1);
|
||||
main.x = kX;
|
||||
main.y = kY;
|
||||
main.empire = 1;
|
||||
auto points = zeroed<GCPoints>();
|
||||
points.header = HDR_GC_CHARACTER_POINTS;
|
||||
points.points[1] = 10; // POINT_LEVEL
|
||||
if (!c.Phase(PHASE_LOADING) || !c.Send(main) || !c.Send(points))
|
||||
return Fail(name + ": send loading packets");
|
||||
|
||||
// The client answers the main character with its version (CInputProcessor::Version).
|
||||
std::uint8_t header = 0;
|
||||
if (c.Read(&header, 1) != 1)
|
||||
return Fail(name + ": expected CG_CLIENT_VERSION");
|
||||
const size_t size = header == HDR_CG_CLIENT_VERSION2 ? sizeof(CGClientVersion2)
|
||||
: header == HDR_CG_CLIENT_VERSION ? sizeof(CGClientVersion)
|
||||
: 0;
|
||||
if (!size)
|
||||
return Fail(name + ": expected CG_CLIENT_VERSION, got header " + std::to_string(header));
|
||||
std::vector<std::uint8_t> rest(size - 1 + 1);
|
||||
if (c.Read(rest.data(), rest.size()) != 1 || rest.back() != SEQUENCE_TABLE[c.sequence])
|
||||
return Fail(name + ": CG_CLIENT_VERSION body or sequence");
|
||||
c.sequence = (c.sequence + 1) % SEQUENCE_TABLE_SIZE;
|
||||
Event(name + ":client_version");
|
||||
|
||||
// Hold the connection (the loading screen) until the test stops us.
|
||||
std::uint8_t byte = 0;
|
||||
while (!m_stop && c.Read(&byte, 1) == 1)
|
||||
;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
// The server half of port_login_flow_test: a loopback auth server and game server that answer the way
|
||||
// the 40250 game server (game/src/desc.cpp, input.cpp, input_login.cpp, input_db.cpp) does for the
|
||||
// login → select → loading path. It is built from mtnet's classic wire layouts, sequence table and
|
||||
// cipher (net/classic/), which were checked 1:1 against the 40250 server on 192.168.21.203, and shares
|
||||
// no code with the ported client it talks to. Kept out of the client's translation unit: Packet.h's
|
||||
// macros collide with wire_classic.h's constants.
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
class FakeLoginServer
|
||||
{
|
||||
public:
|
||||
static constexpr const char* kLogin = "fakeuser";
|
||||
static constexpr const char* kPassword = "fakepass";
|
||||
static constexpr const char* kCharacterName = "FakeWarrior";
|
||||
static constexpr std::uint32_t kLoginKey = 0x13572468;
|
||||
static constexpr std::uint32_t kMainVID = 0x00012345;
|
||||
static constexpr std::int32_t kX = 469300, kY = 964200;
|
||||
|
||||
~FakeLoginServer();
|
||||
|
||||
// Listens on two loopback ports and serves them from a thread until Stop().
|
||||
bool Start();
|
||||
void Stop();
|
||||
|
||||
int AuthPort() const { return m_auth_port; }
|
||||
int GamePort() const { return m_game_port; }
|
||||
|
||||
// Milestones in arrival order ("auth:login3", "game1:login2", ...), and the first protocol error.
|
||||
std::vector<std::string> Events();
|
||||
std::string Error();
|
||||
bool Failed() { return !Error().empty(); }
|
||||
bool Has(const std::string& event);
|
||||
|
||||
struct Connection;
|
||||
|
||||
private:
|
||||
|
||||
void Run();
|
||||
bool ServeAuth(Connection& c);
|
||||
bool ServeGame(Connection& c, int index);
|
||||
bool Handshake(Connection& c, std::uint8_t phase_after);
|
||||
bool Accept(int listener, Connection& c);
|
||||
void Event(const std::string& event);
|
||||
bool Fail(const std::string& error);
|
||||
|
||||
int m_auth_listener = -1, m_game_listener = -1;
|
||||
int m_auth_port = 0, m_game_port = 0;
|
||||
std::thread m_thread;
|
||||
std::atomic<bool> m_stop{false};
|
||||
std::mutex m_mutex;
|
||||
std::vector<std::string> m_events;
|
||||
std::string m_error;
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
// 批次 2V1-d: the real 40250 login → select → loading path, offline.
|
||||
//
|
||||
// The 40250 root scripts (intrologin.py, introselect.py, introloading.py, networkmodule.py) drive the
|
||||
// ported CPythonNetworkStream and CAccountConnector against FakeLoginServer (port_login_flow_server.h),
|
||||
// which answers the way the 40250 game server does. The test only plays the player:
|
||||
//
|
||||
// 1. frames advance the logo phase into introLogin.LoginWindow;
|
||||
// 2. LoginWindow.Connect(id, pwd) → net.ConnectToAccountServer: handshake + key agreement with the
|
||||
// auth server, CG_LOGIN3, GC_AUTH_SUCCESS; the account connector hands the login key to the stream,
|
||||
// which connects to the game server and sends CG_LOGIN2;
|
||||
// 3. GC_EMPIRE, PHASE_SELECT, GC_LOGIN_SUCCESS_NEWSLOT open introSelect.SelectCharacterWindow;
|
||||
// 4. SelectCharacterWindow.StartGame() → (3 s later) net.DirectEnter(0): the stream reconnects to the
|
||||
// slot's addr/port, logs in again, and in direct-enter mode the select phase goes straight to
|
||||
// introLoading.LoadingWindow, which sends CG_CHARACTER_SELECT;
|
||||
// 5. PHASE_LOADING, GC_MAIN_CHARACTER, GC_CHARACTER_POINTS: the loading phase answers the main
|
||||
// character with CG_CLIENT_VERSION(2), the server's proof the client reached Loading.
|
||||
//
|
||||
// The server checks every sequence byte against the 40250 table, so a skipped or extra sequenced
|
||||
// packet fails the run.
|
||||
//
|
||||
// port_login_flow_test <40250 Client dir> <python27.zip>
|
||||
//
|
||||
// Exit 77 (ctest SKIP) when the client is missing, unless MT_ASSETS_STRICT=1, which fails instead.
|
||||
#include "ScriptLib/StdAfx.h"
|
||||
#include "../src/platform/ScriptLib/PythonBoot.h"
|
||||
#include "../src/platform/ScriptLib/PythonHost.h"
|
||||
#include "../src/platform/UserInterface/UserInterface.h"
|
||||
#include "port_login_flow_server.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
static int g_failures = 0;
|
||||
#define CHECK(cond) \
|
||||
do { \
|
||||
if (!(cond)) { \
|
||||
std::fprintf(stderr, "%s:%d: CHECK(%s)\n", __FILE__, __LINE__, #cond); \
|
||||
++g_failures; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static bool py(const std::string& source)
|
||||
{
|
||||
std::string error;
|
||||
if (PythonBoot::RunLine(source.c_str(), &error))
|
||||
return true;
|
||||
std::fprintf(stderr, "python: %s\n%s\n", source.c_str(), error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Evaluates a Python expression on the host thread without raising.
|
||||
static bool py_true(const char* expression)
|
||||
{
|
||||
PyObject* globals = PyModule_GetDict(PyImport_AddModule((char*) "__main__"));
|
||||
PyObject* result = PyRun_String(expression, Py_eval_input, globals, globals);
|
||||
if (!result)
|
||||
{
|
||||
PyErr_Clear();
|
||||
return false;
|
||||
}
|
||||
const bool truth = PyObject_IsTrue(result) == 1;
|
||||
Py_DECREF(result);
|
||||
return truth;
|
||||
}
|
||||
|
||||
// Runs frames (~60 per second of wall time) until `done` or the deadline.
|
||||
static bool pump_until(double seconds, const std::function<bool()>& done)
|
||||
{
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::duration<double>(seconds);
|
||||
while (std::chrono::steady_clock::now() < deadline && PythonBoot::IsAppLooping())
|
||||
{
|
||||
PythonBoot::UIUpdate();
|
||||
if (done())
|
||||
return true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(16));
|
||||
}
|
||||
return done();
|
||||
}
|
||||
|
||||
static void dump(FakeLoginServer& server)
|
||||
{
|
||||
std::fprintf(stderr, "server events:");
|
||||
for (const auto& e : server.Events())
|
||||
std::fprintf(stderr, " %s", e.c_str());
|
||||
std::fprintf(stderr, "\nserver error: %s\n", server.Error().c_str());
|
||||
}
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
const char* strict = std::getenv("MT_ASSETS_STRICT");
|
||||
const bool is_strict = strict && std::string(strict) == "1";
|
||||
const std::string client = argc > 1 ? argv[1] : "";
|
||||
const std::string stdlib = argc > 2 ? argv[2] : PythonHost::DefaultStdLibPath();
|
||||
if (client.empty() || chdir(client.c_str()) != 0 || access("pack/Index", 0) != 0)
|
||||
{
|
||||
std::fprintf(stderr, "port_login_flow_test: no 40250 Client/pack at '%s'\n", client.c_str());
|
||||
return is_strict ? 1 : 77;
|
||||
}
|
||||
|
||||
FakeLoginServer server;
|
||||
if (!server.Start())
|
||||
{
|
||||
std::fprintf(stderr, "port_login_flow_test: cannot listen on loopback\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
PackSingletons();
|
||||
CHECK(PackInitialize("pack"));
|
||||
|
||||
std::string error;
|
||||
CHECK(PythonBoot::Start(stdlib.c_str(), &error));
|
||||
PythonBoot::SetUISize(800, 600);
|
||||
const bool ran = PythonBoot::RunMainScript("", &error);
|
||||
if (!ran)
|
||||
std::fprintf(stderr, "RunMainScript: %s\n", error.c_str());
|
||||
CHECK(ran && PythonBoot::IsAppLooping());
|
||||
|
||||
CHECK(py("import __main__, networkModule, introLogin, introSelect, introLoading\n"
|
||||
"__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]"));
|
||||
CHECK(pump_until(20, [] { return py_true("isinstance(_stream.curPhaseWindow, introLogin.LoginWindow)"); }));
|
||||
|
||||
// 2. The login button's path, with the fake server for both the game and the auth address.
|
||||
char connect[256];
|
||||
std::snprintf(connect, sizeof(connect),
|
||||
"_stream.SetConnectInfo('127.0.0.1', %d, '127.0.0.1', %d)\n"
|
||||
"_stream.curPhaseWindow.Connect('%s', '%s')",
|
||||
server.GamePort(), server.AuthPort(), FakeLoginServer::kLogin, FakeLoginServer::kPassword);
|
||||
CHECK(py(connect));
|
||||
|
||||
// 3. The character list.
|
||||
const bool selecting = pump_until(20, [&] {
|
||||
return server.Failed() ||
|
||||
(server.Has("game1:login2") && py_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"));
|
||||
});
|
||||
CHECK(selecting && !server.Failed());
|
||||
CHECK(server.Has("auth:login3") && server.Has("auth:closed"));
|
||||
CHECK(py("import net\n"
|
||||
"_name = net.GetAccountCharacterSlotDataString(0, net.ACCOUNT_CHARACTER_SLOT_NAME)\n"
|
||||
"assert _name == '" + std::string(FakeLoginServer::kCharacterName) + "', _name\n"
|
||||
"assert net.GetEmpireID() == 1"));
|
||||
|
||||
// 4-5. Start the game on slot 0: DirectEnter, the second game login and the loading phase.
|
||||
CHECK(py("_stream.curPhaseWindow.StartGame()"));
|
||||
const bool loading = pump_until(30, [&] { return server.Failed() || server.Has("game2:client_version"); });
|
||||
CHECK(loading && !server.Failed());
|
||||
// Checked right away: introLoading's own update steps end in net.StartGame(), after which the stream
|
||||
// opens game.GameWindow itself (CPythonNetworkStream::Process, m_isStartGame), which is 2V2.
|
||||
CHECK(py_true("isinstance(_stream.curPhaseWindow, introLoading.LoadingWindow)"));
|
||||
|
||||
const std::vector<std::string> expected = {
|
||||
"auth:handshake", "auth:login3", "game1:handshake", "game1:login2", "auth:closed",
|
||||
"game1:closed", "game2:handshake", "game2:login2", "game2:select", "game2:client_version",
|
||||
};
|
||||
auto events = server.Events();
|
||||
// auth:closed races game1's handshake; compare the order within each connection.
|
||||
for (const char* prefix : {"auth:", "game1:", "game2:"})
|
||||
{
|
||||
std::vector<std::string> want, got;
|
||||
for (const auto& e : expected)
|
||||
if (e.rfind(prefix, 0) == 0)
|
||||
want.push_back(e);
|
||||
for (const auto& e : events)
|
||||
if (e.rfind(prefix, 0) == 0)
|
||||
got.push_back(e);
|
||||
CHECK(want == got);
|
||||
}
|
||||
if (g_failures)
|
||||
dump(server);
|
||||
|
||||
CHECK(py("import app\napp.Exit()"));
|
||||
pump_until(2, [] { return !PythonBoot::IsAppLooping(); });
|
||||
CHECK(!PythonBoot::IsAppLooping());
|
||||
PythonBoot::Stop();
|
||||
server.Stop();
|
||||
|
||||
if (g_failures)
|
||||
std::fprintf(stderr, "%d check(s) failed\n", g_failures);
|
||||
else
|
||||
std::printf("port_login_flow_test: ok\n");
|
||||
return g_failures ? 1 : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user