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:
shenlei
2026-09-23 15:20:32 +09:00
co-authored by Claude Opus 5.5
parent 4d9b800efb
commit 8d9d863c7e
10 changed files with 753 additions and 5 deletions
+187
View File
@@ -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;
}