Files
mtgodot-poc/extension/tests/port_login_flow_test.cpp
T
shenleiandClaude Opus 5.5 668f48d6ec port 2V2-d.2: Granny runtime on libgr2, main character model animates
Implement the Granny SDK 2.11 API the client uses on top of libgr2
(platform/EterGrnLib/GrannyRuntime.cpp, shim granny.h declared from the
SDK header): file sections, type-definition vertex conversion, material
textures by Usage, mesh/bone bindings, deformer, control clock/loop/ease
and weighted SRT sampling. Port EterGrnLib (LODController, Material, Mesh,
Model, ModelInstance*, Motion, Thing, ThingInstance, Util) plus EterLib
GrpObjectInstance/GrpCollisionObject/CollisionData verbatim and drop their
platform skeletons and pending stand-ins; register the gr2 resource
factory; vertex/index buffers are CPU-side until the 2V2-e render adapter.

app.UpdateGame/RenderGame now run 40250's bodies, reaching the
CPythonApplication members through their singletons (the platform has no
app object); CPythonGraphic joins GameSingletons in member order, and
GetMousePosition reads the host cursor.

port.login_flow asserts the main instance's PART_MAIN model has meshes,
its diffuse image loaded and the WAIT motion moves the bones between
frames; offline and live pass, port_gate macos PASS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:43:27 +09:00

387 lines
17 KiB
C++

// 批次 2V1-d/2V2: the real 40250 login → select → loading → game 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;
// 6. (批次 2V2) the loading steps end in net.StartGame(): networkModule.SetGamePhase opens
// game.GameWindow, whose Open sends CG_ENTERGAME;
// 7. the server's Entergame answer (the actor's insert packets, PHASE_GAME, the Entergame burst) is
// read by the ported GamePhase without an unknown packet, and a final ping gets its pong.
//
// 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> [live]
//
// With `live` (批次 2V1-e, ctest port.login_live) the same path runs against a real 40250 server and
// the client's own state is the proof: the character list arrived, the loading window got
// LoadData from GC_MAIN_CHARACTER, which CPythonNetworkStream::RecvMainCharacter answers with
// CG_CLIENT_VERSION, game.GameWindow is open, which sent CG_ENTERGAME, and 10 s of the real game
// phase later it is still open and connected with no unknown packet. Everything about the
// server comes from the environment and nothing of it is printed:
//
// MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD required; without them the test is skipped
// MT_LIVE_AUTH_PORT (11000), MT_LIVE_GAME_PORT (13000, channel 1), MT_LIVE_SLOT (0)
//
// 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 "../src/platform/EterBase/TraceErrorObserver.h"
#include "GameLib/StdAfx.h"
#include "GameLib/RaceManager.h"
#include "GameLib/RaceData.h"
#include "GameLib/RaceMotionData.h"
#include "UserInterface/StdAfx.h"
#include "UserInterface/PythonCharacterManager.h"
#include "UserInterface/InstanceBase.h"
#include "EterGrnLib/StdAfx.h"
#include "EterGrnLib/LODController.h"
#include "EterGrnLib/ModelInstance.h"
#include "EterGrnLib/Model.h"
#include "port_login_flow_server.h"
#include <memory>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#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)
// TraceError lines; the script thread writes them while the host thread waits for its baton.
static std::mutex g_syserr_mutex;
static std::vector<std::string> g_syserr;
// The two headers CheckPacket saw last (UserInterface/PythonNetworkStream.cpp).
extern int g_iLastPacket[2];
// CPythonNetworkStream::RecvDefaultPacket's CP949 "unhandled packet header %d, state %s": a phase
// dispatcher dropped the whole recv buffer.
static const char kUnhandledPacket[] = "\xc3\xb3\xb8\xae\xb5\xc7\xc1\xf6 \xbe\xca\xc0\xba \xc6\xd0\xc5\xb6 \xc7\xec\xb4\xf5";
static void on_trace_error(const char* line)
{
std::lock_guard<std::mutex> lock(g_syserr_mutex);
g_syserr.emplace_back(line);
// RecvDefaultPacket does not print the preceding headers; the packet before a stray header is
// the one whose size disagrees with the server.
if (std::string(line).find(kUnhandledPacket) != std::string::npos)
std::fprintf(stderr, "port_login_flow_test: unhandled packet after headers %d, %d\n", g_iLastPacket[0], g_iLastPacket[1]);
}
static int count_syserr(const char* needle)
{
std::lock_guard<std::mutex> lock(g_syserr_mutex);
int n = 0;
for (const auto& line : g_syserr)
n += line.find(needle) != std::string::npos;
return n;
}
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());
}
static std::string env(const char* name, const char* fallback = "")
{
const char* value = std::getenv(name);
return value && *value ? value : fallback;
}
// Hands a C string to __main__ as a Python object, so account data never goes through source text.
static void py_set(const char* name, const std::string& value)
{
PyObject* object = PyString_FromString(value.c_str());
PyDict_SetItemString(PyModule_GetDict(PyImport_AddModule((char*) "__main__")), name, object);
Py_DECREF(object);
}
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();
const bool live = argc > 3 && std::string(argv[3]) == "live";
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;
}
std::string host = "127.0.0.1", login = FakeLoginServer::kLogin, password = FakeLoginServer::kPassword;
int auth_port = 0, game_port = 0, slot = 0;
std::unique_ptr<FakeLoginServer> server;
if (live)
{
host = env("MT_LIVE_HOST");
login = env("MT_LIVE_LOGIN");
password = env("MT_LIVE_PASSWORD");
if (host.empty() || login.empty() || password.empty())
{
std::fprintf(stderr, "port_login_flow_test: live mode needs MT_LIVE_HOST, MT_LIVE_LOGIN, MT_LIVE_PASSWORD\n");
return 77;
}
auth_port = std::atoi(env("MT_LIVE_AUTH_PORT", "11000").c_str());
game_port = std::atoi(env("MT_LIVE_GAME_PORT", "13000").c_str());
slot = std::atoi(env("MT_LIVE_SLOT", "0").c_str());
}
else
{
server = std::make_unique<FakeLoginServer>();
if (!server->Start())
{
std::fprintf(stderr, "port_login_flow_test: cannot listen on loopback\n");
return 1;
}
auth_port = server->AuthPort();
game_port = server->GamePort();
}
auto failed = [&] { return server && server->Failed(); };
SetTraceErrorObserver(on_trace_error);
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 (LoginWindow.Connect), pointed at the fake or the live server.
py_set("_host", host);
py_set("_login", login);
py_set("_password", password);
char ports[128];
std::snprintf(ports, sizeof(ports), "_auth_port, _game_port, _slot = %d, %d, %d", auth_port, game_port, slot);
CHECK(py(ports));
CHECK(py("_stream.SetConnectInfo(_host, _game_port, _host, _auth_port)\n"
"_stream.curPhaseWindow.Connect(_login, _password)\n"
"del __main__._login, __main__._password"));
// 3. The character list.
const bool selecting = pump_until(live ? 30 : 20, [&] {
return failed() || ((!server || server->Has("game1:login2")) &&
py_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"));
});
CHECK(selecting && !failed());
if (!selecting)
{
// The login window reports failures in its popup (LOGIN_FAILURE, connect errors).
py("import sys\n"
"_w = _stream.curPhaseWindow\n"
"_msg = _stream.popupWindow and _stream.popupWindow.IsShow() and _stream.popupWindow.GetChild('message').GetText()\n"
"sys.stderr.write('phase window %s, popup %r\\n' % (_w.__class__.__name__, _msg))");
}
if (server)
{
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"));
}
else if (selecting)
{
CHECK(py("import net\n"
"assert net.GetAccountCharacterSlotDataString(_slot, net.ACCOUNT_CHARACTER_SLOT_NAME), 'slot is empty'\n"
"assert net.GetEmpireID() in (1, 2, 3), net.GetEmpireID()"));
}
// 4-5. Start the game on the slot: DirectEnter, the second game login and the loading phase.
if (selecting)
{
CHECK(py("_stream.curPhaseWindow.SelectSlot(_slot)\n_stream.curPhaseWindow.StartGame()"));
// GC_MAIN_CHARACTER → LoadingWindow.LoadData(x, y), then CG_CLIENT_VERSION in the same call.
const char* loaded = "isinstance(_stream.curPhaseWindow, introLoading.LoadingWindow) and "
"getattr(_stream.curPhaseWindow, 'playerX', None) is not None";
const bool loading = pump_until(live ? 40 : 30, [&] {
return failed() || (server ? server->Has("game2:client_version") : py_true(loaded));
});
CHECK(loading && !failed());
CHECK(py_true(loaded));
// 6. introLoading's update steps end in net.StartGame(); the next CPythonNetworkStream::Process calls
// networkModule's SetGamePhase, which builds game.GameWindow (interfaceModule.MakeInterface) and
// opens it, and GameWindow.Open sends CG_ENTERGAME.
CHECK(py("import game"));
const char* gaming = "isinstance(_stream.curPhaseWindow, game.GameWindow) and _stream.curPhaseWindow.IsShow()";
const bool entered = pump_until(30, [&] {
return failed() || (py_true(gaming) && (!server || server->Has("game2:entergame")));
});
CHECK(entered && !failed());
// 7. (批次 2V2-b) The server answers CG_ENTERGAME with the character's own insert packets and
// PHASE_GAME, then the Entergame burst (PVP/land list, time, channel, greet chat, ...). GamePhase
// has to read all of it: an unknown or mis-sized packet makes CheckPacket log "Unknown packet
// header" and drop the receive buffer. Offline the fake server ends with a ping the client
// must answer; live the stream has to stay in the game window and connected for 10 s.
const bool burst = pump_until(live ? 10 : 30, [&] {
return failed() || (server && server->Has("game2:burst_pong"));
});
CHECK(live || (burst && !failed()));
CHECK(py_true(gaming));
CHECK(py("import net\nassert net.IsConnect(), 'disconnected in the game phase'"));
CHECK(count_syserr("Unknown packet header") == 0);
CHECK(count_syserr(kUnhandledPacket) == 0);
CHECK(count_syserr("does not handle this header") == 0);
// The fake greet notice went through RecvChatPacket into the ported CPythonChat, where
// uichat's chat set (created by the game interface) counts it.
CHECK(live || py("import chat\nassert any(chat.GetLineCount(i) > 0 for i in range(4)), 'greet notice not in chat'"));
// playerSettingModule ran its race registration through the real chrmgr module: the warrior
// (race 0) has a general-mode wait motion in its CRaceData.
CHECK(py("import chr, chrmgr\nassert not hasattr(chr, '__port_stub__') and not hasattr(chrmgr, '__port_stub__')"));
CRaceData* race = nullptr;
const CRaceData::TMotionVector* waits = nullptr;
CHECK(CRaceManager::Instance().GetRaceDataPointer(0, &race) && race &&
race->GetMotionVectorPointer(CRaceMotionData::MODE_GENERAL, CRaceMotionData::NAME_WAIT, &waits) &&
waits && !waits->empty());
// (批次 2V2-d) GC_CHARACTER_ADD for the player's own VID went through RecvCharacterAppendPacket and
// CNetworkActorManager into CPythonCharacterManager: the main instance exists and SetRace ran on it
// (offline the fake server sends a warrior, race 0), and CHAR_ADDITIONAL_INFO found the actor.
CInstanceBase* main_instance = CPythonCharacterManager::Instance().GetMainInstancePtr();
CHECK(main_instance != nullptr);
CHECK(live || (main_instance && main_instance->GetRace() == 0));
CHECK(count_syserr("TPacketGCCharacterAdditionalInfo") == 0);
// (批次 2V2-d.2) SetRace/SetShape loaded the body .gr2 through the Granny runtime into PART_MAIN, and
// game.GameWindow's OnUpdate/OnRender (app.UpdateGame/RenderGame) play the WAIT motion: the
// sampled bone matrices change between frames.
CGrannyLODController* lod = main_instance ?
main_instance->GetGraphicThingInstanceRef().GetLODControllerPointer(CRaceData::PART_MAIN) : nullptr;
CGrannyModelInstance* model_instance = lod ? lod->GetModelInstance() : nullptr;
CGrannyModel* model = model_instance ? model_instance->GetModel() : nullptr;
CHECK(model && model->GetMeshCount() > 0);
// The material's diffuse map resolved through GrannyGetMaterialTextureByType to a loaded image.
CGrannyMaterialPalette* palette = model ? const_cast<CGrannyMaterialPalette*>(&model->GetMaterialPalette()) : nullptr;
CHECK(palette && palette->GetMaterialCount() > 0 && palette->GetMaterialRef(0).GetImagePointer(0));
const int bone_count = model ? model->GetGrannyModelPointer()->Skeleton->BoneCount : 0;
CHECK(bone_count > 1);
auto bones = [&] {
std::vector<float> out;
for (int i = 0; i < bone_count; ++i)
{
const float* m = model_instance->GetBoneMatrixPointer(i);
out.insert(out.end(), m, m + 16);
}
return out;
};
if (bone_count > 1)
{
const std::vector<float> before = bones();
CHECK(pump_until(3, [&] { return bones() != before; }));
}
}
if (server)
{
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", "game2:entergame",
"game2:burst_sent", "game2:burst_pong",
};
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();
if (server)
server->Stop();
if (g_failures)
std::fprintf(stderr, "%d check(s) failed\n", g_failures);
else
std::printf("port_login_flow_test: ok%s\n", live ? " (live)" : "");
return g_failures ? 1 : 0;
}