2V1-e: live login smoke harness (port.login_live)

port_login_flow_test gains a `live` mode that runs the same script path
against a real 40250 server and checks the client's own state: the
character list, then LoadingWindow.LoadData from GC_MAIN_CHARACTER (which
CPythonNetworkStream answers with CG_CLIENT_VERSION), exiting before the
game phase. Host, ports, account and slot come only from MT_LIVE_* env
vars, are handed to Python as objects and never printed; without them
the test skips. Not yet run live: the server was unreachable today.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-23 15:26:56 +09:00
co-authored by Claude Opus 5.5
parent 8d9d863c7e
commit 2a8750e736
4 changed files with 137 additions and 50 deletions
+1
View File
@@ -510,3 +510,4 @@
{"time": "2026-09-23T05:25:43Z", "event": "port_round", "unit": "PORT-PLAN 2V1-a: EterLib network layer + EterBase cipher", "ported": ["EterLib/NetStream.cpp, NetAddress.cpp, NetDevice.cpp, NetPacketHeaderMap.cpp (port_copy verbatim)", "EterBase/cipher.cpp (port_copy verbatim, Crypto++ DH2 key agreement + CTR block ciphers)"], "adapted": ["shim/win32/winsock.h: Winsock names over BSD sockets; select ignores nfds and drops writable sockets without a peer (Winsock reports a failed connect only in exceptfds); EINPROGRESS -> WSAEWOULDBLOCK; ioctlsocket FIONBIO; WSAStartup ignores SIGPIPE"], "divergent": [], "needs_live": ["cipher agreement against the 40250 game server (2V1-e)"], "tests": ["port.net: pass (loopback connect, send/recv, peer close, refused-connect timeout, client/server cipher agreement)"], "todo": ["2V1-b PythonNetworkStream phases", "2V1-c net module", "2V1-d FakeServer", "2V1-e live smoke"]} {"time": "2026-09-23T05:25:43Z", "event": "port_round", "unit": "PORT-PLAN 2V1-a: EterLib network layer + EterBase cipher", "ported": ["EterLib/NetStream.cpp, NetAddress.cpp, NetDevice.cpp, NetPacketHeaderMap.cpp (port_copy verbatim)", "EterBase/cipher.cpp (port_copy verbatim, Crypto++ DH2 key agreement + CTR block ciphers)"], "adapted": ["shim/win32/winsock.h: Winsock names over BSD sockets; select ignores nfds and drops writable sockets without a peer (Winsock reports a failed connect only in exceptfds); EINPROGRESS -> WSAEWOULDBLOCK; ioctlsocket FIONBIO; WSAStartup ignores SIGPIPE"], "divergent": [], "needs_live": ["cipher agreement against the 40250 game server (2V1-e)"], "tests": ["port.net: pass (loopback connect, send/recv, peer close, refused-connect timeout, client/server cipher agreement)"], "todo": ["2V1-b PythonNetworkStream phases", "2V1-c net module", "2V1-d FakeServer", "2V1-e live smoke"]}
{"time": "2026-09-23T06:00:34Z", "event": "port_round", "unit": "PORT-PLAN 2V1-b/c: PythonNetworkStream phases + AccountConnector + net module", "ported": ["UserInterface/PythonNetworkStream.cpp, PythonNetworkStreamEvent.cpp, PhaseHandShake/Login/Select/Loading/Offline (port_copy verbatim)", "UserInterface/AccountConnector.cpp, InsultChecker.cpp, AffectFlagContainer.cpp (port_copy verbatim)", "UserInterface/PythonNetworkStreamModule.cpp (net module, replaces the 2V0-e stub)", "PythonPackModule.cpp login-key functions compiled again", "Locale.cpp LocaleService_Is*/security key block (platform PythonApplication.cpp, verbatim)", "WiseLogicXTrap no-XTRAP branch"], "adapted": ["Packet.h long fields -> 32-bit LONG (PORT)", "singleton<T> pointer offset via intptr_t (PORT)", "grammar.h shim #undef BYTE (40250's bitset.h copy drops #define BYTE char)", "PythonBoot creates CPythonNetworkStream/CAccountConnector/CPythonPlayer/CFlyingManager/CEffectManager and calls initnet()", "app.Create creates CNetworkDevice; Process() runs CPythonNetworkStream then CAccountConnector", "port_gate.sh macos builds all; port-only config finds iconv"], "divergent": ["ProcessCRC stubbed (magic cube piece only feeds PhaseGame attack packets)", "unported logic units in the link closure (PythonPlayer, NetworkActorManager, FlyingObjectManager, GuildMarkDownloader/Uploader, MarkManager, PhaseGame*/PythonGuild members) are stand-ins under platform/pending/; loading-phase CharacterUpdate/PointChange/ItemSet/QuickSlotAdd stand-ins only consume the packet", "guild mark uploader/downloader Process not called yet"], "needs_live": ["login/select/loading against the 40250 server (2V1-e)"], "tests": ["port.packet: pass (38 login/select/loading packet sizes match wire_classic.h)", "ctest 33/33 pass", "script/port_gate.sh macos: PASS", "godot --headless --path project --script python_host_test.gd: pass (net is the ported module)"], "todo": ["2V1-d FakeServer HandShake->Login->Select->Loading", "2V1-e live smoke", "port the platform/pending units"]} {"time": "2026-09-23T06:00:34Z", "event": "port_round", "unit": "PORT-PLAN 2V1-b/c: PythonNetworkStream phases + AccountConnector + net module", "ported": ["UserInterface/PythonNetworkStream.cpp, PythonNetworkStreamEvent.cpp, PhaseHandShake/Login/Select/Loading/Offline (port_copy verbatim)", "UserInterface/AccountConnector.cpp, InsultChecker.cpp, AffectFlagContainer.cpp (port_copy verbatim)", "UserInterface/PythonNetworkStreamModule.cpp (net module, replaces the 2V0-e stub)", "PythonPackModule.cpp login-key functions compiled again", "Locale.cpp LocaleService_Is*/security key block (platform PythonApplication.cpp, verbatim)", "WiseLogicXTrap no-XTRAP branch"], "adapted": ["Packet.h long fields -> 32-bit LONG (PORT)", "singleton<T> pointer offset via intptr_t (PORT)", "grammar.h shim #undef BYTE (40250's bitset.h copy drops #define BYTE char)", "PythonBoot creates CPythonNetworkStream/CAccountConnector/CPythonPlayer/CFlyingManager/CEffectManager and calls initnet()", "app.Create creates CNetworkDevice; Process() runs CPythonNetworkStream then CAccountConnector", "port_gate.sh macos builds all; port-only config finds iconv"], "divergent": ["ProcessCRC stubbed (magic cube piece only feeds PhaseGame attack packets)", "unported logic units in the link closure (PythonPlayer, NetworkActorManager, FlyingObjectManager, GuildMarkDownloader/Uploader, MarkManager, PhaseGame*/PythonGuild members) are stand-ins under platform/pending/; loading-phase CharacterUpdate/PointChange/ItemSet/QuickSlotAdd stand-ins only consume the packet", "guild mark uploader/downloader Process not called yet"], "needs_live": ["login/select/loading against the 40250 server (2V1-e)"], "tests": ["port.packet: pass (38 login/select/loading packet sizes match wire_classic.h)", "ctest 33/33 pass", "script/port_gate.sh macos: PASS", "godot --headless --path project --script python_host_test.gd: pass (net is the ported module)"], "todo": ["2V1-d FakeServer HandShake->Login->Select->Loading", "2V1-e live smoke", "port the platform/pending units"]}
{"time": "2026-09-23T06:18:52Z", "event": "port_round", "unit": "PORT-PLAN 2V1-d: offline FakeServer login -> select -> loading", "ported": ["PythonNetworkStreamPhaseGame SendClientVersionPacket body (verbatim, in the platform/pending stand-in)"], "adapted": ["NetStream::ActivateCipher decrypts the unread receive buffer (PORT: ciphertext pipelined behind KEY_AGREEMENT_COMPLETED)", "CPythonApplication marked final (PORT: no application object on the platform, virtual overrides must be called directly)", "platform CPythonApplication::GetGlobalTime reads CTimer (40250 caches it in m_fGlobalTime each Process); SetCenterPosition/SetEventCamera/SetDefaultCamera stubs (2V3)"], "divergent": ["GetExcutedFileName is a platform stub, so CG_CLIENT_VERSION carries an empty filename", "SendClientVersionPacket lives in the PhaseGame stand-in until PhaseGame is ported"], "needs_live": ["login/select/loading against the 40250 server (2V1-e)"], "tests": ["port.login_flow: pass (auth + two game connections, every sequence byte checked; 5x repeat stable)", "ctest 34/34 pass", "script/port_gate.sh macos: PASS", "godot python_host_test.gd: pass"], "todo": ["2V1-e live smoke", "port the platform/pending units", "game.GameWindow after loading (2V2)"]} {"time": "2026-09-23T06:18:52Z", "event": "port_round", "unit": "PORT-PLAN 2V1-d: offline FakeServer login -> select -> loading", "ported": ["PythonNetworkStreamPhaseGame SendClientVersionPacket body (verbatim, in the platform/pending stand-in)"], "adapted": ["NetStream::ActivateCipher decrypts the unread receive buffer (PORT: ciphertext pipelined behind KEY_AGREEMENT_COMPLETED)", "CPythonApplication marked final (PORT: no application object on the platform, virtual overrides must be called directly)", "platform CPythonApplication::GetGlobalTime reads CTimer (40250 caches it in m_fGlobalTime each Process); SetCenterPosition/SetEventCamera/SetDefaultCamera stubs (2V3)"], "divergent": ["GetExcutedFileName is a platform stub, so CG_CLIENT_VERSION carries an empty filename", "SendClientVersionPacket lives in the PhaseGame stand-in until PhaseGame is ported"], "needs_live": ["login/select/loading against the 40250 server (2V1-e)"], "tests": ["port.login_flow: pass (auth + two game connections, every sequence byte checked; 5x repeat stable)", "ctest 34/34 pass", "script/port_gate.sh macos: PASS", "godot python_host_test.gd: pass"], "todo": ["2V1-e live smoke", "port the platform/pending units", "game.GameWindow after loading (2V2)"]}
{"time": "2026-09-23T06:23:33Z", "event": "port_round", "unit": "PORT-PLAN 2V1-e: live smoke harness (port.login_live)", "ported": [], "adapted": ["port_login_flow_test live mode: server and account from MT_LIVE_* env vars only, passed to Python as objects, never printed"], "divergent": [], "needs_live": ["login -> character list -> loading against 192.168.21.203 (host unreachable from this machine on 2026-09-23)", "the live loading-phase packet burst through the platform/pending stand-ins", "auth/login failure popups (wrong password, already logged in, blocked)", "CG_CLIENT_VERSION with an empty filename (GetExcutedFileName stub) accepted by the server"], "tests": ["port.login_flow: pass", "port.login_live: skipped without MT_LIVE_*", "script/port_gate.sh macos: PASS"], "todo": ["run port.login_live once the server is reachable", "port the platform/pending units"]}
+1 -1
View File
@@ -412,7 +412,7 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次
| 2V1-b | `PythonNetworkStream` + HandShake/Login/Select/Loading phase + `AccountConnector` | 完成:`PythonNetworkStream{,Event,PhaseHandShake,PhaseLogin,PhaseSelect,PhaseLoading,PhaseOffline}.cpp`、`AccountConnector.cpp`、`InsultChecker.cpp`、`AffectFlagContainer.cpp` 原样 `port_copy`;`Packet.h` 里所有 `long` 字段改为 32 位 `LONG`(PORT 标注),`port.packet` 把登录/选角/Loading 用到的 38 个包的尺寸与已在真服 1:1 验证过的 `net/classic/wire_classic.h` 比对(两头宏冲突,分两个编译单元);`PythonPackModule.cpp` 的登录密钥函数恢复编译;`Locale.cpp` 的 `LocaleService_Is*`/安全密钥部分原样放进 platform `PythonApplication.cpp`;`WiseLogicXTrap` 取 40250 无 XTRAP 分支,`ProcessCRC` 为桩(魔方只用于 PhaseGame 攻击包)。链接闭包里尚未移植的逻辑单元(`PythonPlayer`、`NetworkActorManager`、`FlyingObjectManager`、`GuildMark*`、`MarkManager`、PhaseGame*/`PythonGuild` 的部分成员)放在 `extension/src/platform/pending/<Lib>/` 作为替身,移植对应单元的提交里删除;Loading 阶段会收到的 CharacterUpdate/PointChange/ItemSet/QuickSlotAdd 替身只吃掉包体,保证读流不断。`port_gate.sh macos` 改为构建 `all`(此前 ctest 会跑门禁没构建的测试),port-only 配置补 `find_library(iconv)` | | 2V1-b | `PythonNetworkStream` + HandShake/Login/Select/Loading phase + `AccountConnector` | 完成:`PythonNetworkStream{,Event,PhaseHandShake,PhaseLogin,PhaseSelect,PhaseLoading,PhaseOffline}.cpp`、`AccountConnector.cpp`、`InsultChecker.cpp`、`AffectFlagContainer.cpp` 原样 `port_copy`;`Packet.h` 里所有 `long` 字段改为 32 位 `LONG`(PORT 标注),`port.packet` 把登录/选角/Loading 用到的 38 个包的尺寸与已在真服 1:1 验证过的 `net/classic/wire_classic.h` 比对(两头宏冲突,分两个编译单元);`PythonPackModule.cpp` 的登录密钥函数恢复编译;`Locale.cpp` 的 `LocaleService_Is*`/安全密钥部分原样放进 platform `PythonApplication.cpp`;`WiseLogicXTrap` 取 40250 无 XTRAP 分支,`ProcessCRC` 为桩(魔方只用于 PhaseGame 攻击包)。链接闭包里尚未移植的逻辑单元(`PythonPlayer`、`NetworkActorManager`、`FlyingObjectManager`、`GuildMark*`、`MarkManager`、PhaseGame*/`PythonGuild` 的部分成员)放在 `extension/src/platform/pending/<Lib>/` 作为替身,移植对应单元的提交里删除;Loading 阶段会收到的 CharacterUpdate/PointChange/ItemSet/QuickSlotAdd 替身只吃掉包体,保证读流不断。`port_gate.sh macos` 改为构建 `all`(此前 ctest 会跑门禁没构建的测试),port-only 配置补 `find_library(iconv)` |
| 2V1-c | `net` 模块(`PythonNetworkStreamModule.cpp`)替换 2V0-e 的桩;`app.Create`/`Process` 接网络 | 完成:`PythonNetworkStreamModule.cpp` 原样移植,`GameplayModules.cpp` 的 `net` 桩删除,`PythonBoot` 按 40250 `CPythonApplication` 成员顺序创建 `CPythonNetworkStream`/`CAccountConnector`/`CPythonPlayer`/`CFlyingManager`/`CEffectManager` 并调用 `initnet()`;`app.Create` 创建 `CNetworkDevice`,`Process()` 每帧先 `CPythonNetworkStream::Process` 再 `CAccountConnector::Process`(公会标记上传/下载的 Process 随 `GuildMark*` 移植)。`python_host_test.gd` 断言 `net` 不再是桩(`PHASE_WINDOW_SELECT == 2`、未连接) | | 2V1-c | `net` 模块(`PythonNetworkStreamModule.cpp`)替换 2V0-e 的桩;`app.Create`/`Process` 接网络 | 完成:`PythonNetworkStreamModule.cpp` 原样移植,`GameplayModules.cpp` 的 `net` 桩删除,`PythonBoot` 按 40250 `CPythonApplication` 成员顺序创建 `CPythonNetworkStream`/`CAccountConnector`/`CPythonPlayer`/`CFlyingManager`/`CEffectManager` 并调用 `initnet()`;`app.Create` 创建 `CNetworkDevice`,`Process()` 每帧先 `CPythonNetworkStream::Process` 再 `CAccountConnector::Process`(公会标记上传/下载的 Process 随 `GuildMark*` 移植)。`python_host_test.gd` 断言 `net` 不再是桩(`PHASE_WINDOW_SELECT == 2`、未连接) |
| 2V1-d | FakeServer 离线跑完 HandShake→Login→Select→Loading | 完成:`port.login_flow` 用真实 40250 root 脚本(intrologin/introselect/introloading/networkmodule)驱动移植的 `CPythonNetworkStream` + `CAccountConnector`,对接 `tests/port_login_flow_server.cpp` 的回环 auth + game 假服(用已在真服 1:1 验证过的 `net/classic` 包布局、序列表与 cipher 按 40250 `desc.cpp`/`input*.cpp` 应答,与客户端不共享代码):握手 bias/重试 + 密钥协商 → `CG_LOGIN3`/`GC_AUTH_SUCCESS` → `CG_LOGIN2` → `GC_EMPIRE`/PHASE_SELECT/`GC_LOGIN_SUCCESS_NEWSLOT` 到 `SelectCharacterWindow` → `StartGame()` 3 秒后 `DirectEnter` 按槽位地址重连、再登录、直入 `LoadingWindow` 发 `CG_CHARACTER_SELECT` → PHASE_LOADING/`GC_MAIN_CHARACTER`/`GC_CHARACTER_POINTS` → 客户端回 `CG_CLIENT_VERSION(2)`;假服逐包校验序列字节。修正:`NetStream::ActivateCipher` 解密 COMPLETED 后已缓冲的密文(PORT,40250 靠服务器 pulse 间隔规避此竞争);`CPythonApplication` 加 `final`(PORT,platform 无应用对象,虚函数需直接调用),补 `GetGlobalTime`(`app.GetTime`)与 `SetCenterPosition`/`SetEventCamera`/`SetDefaultCamera` 桩;`SendClientVersionPacket` 原样放进 PhaseGame 替身。偏差:`GetExcutedFileName` 仍为桩(版本包文件名为空)。Loading 结束后 `net.StartGame()` 打开 `game.GameWindow` 属 2V2 | | 2V1-d | FakeServer 离线跑完 HandShake→Login→Select→Loading | 完成:`port.login_flow` 用真实 40250 root 脚本(intrologin/introselect/introloading/networkmodule)驱动移植的 `CPythonNetworkStream` + `CAccountConnector`,对接 `tests/port_login_flow_server.cpp` 的回环 auth + game 假服(用已在真服 1:1 验证过的 `net/classic` 包布局、序列表与 cipher 按 40250 `desc.cpp`/`input*.cpp` 应答,与客户端不共享代码):握手 bias/重试 + 密钥协商 → `CG_LOGIN3`/`GC_AUTH_SUCCESS` → `CG_LOGIN2` → `GC_EMPIRE`/PHASE_SELECT/`GC_LOGIN_SUCCESS_NEWSLOT` 到 `SelectCharacterWindow` → `StartGame()` 3 秒后 `DirectEnter` 按槽位地址重连、再登录、直入 `LoadingWindow` 发 `CG_CHARACTER_SELECT` → PHASE_LOADING/`GC_MAIN_CHARACTER`/`GC_CHARACTER_POINTS` → 客户端回 `CG_CLIENT_VERSION(2)`;假服逐包校验序列字节。修正:`NetStream::ActivateCipher` 解密 COMPLETED 后已缓冲的密文(PORT,40250 靠服务器 pulse 间隔规避此竞争);`CPythonApplication` 加 `final`(PORT,platform 无应用对象,虚函数需直接调用),补 `GetGlobalTime`(`app.GetTime`)与 `SetCenterPosition`/`SetEventCamera`/`SetDefaultCamera` 桩;`SendClientVersionPacket` 原样放进 PhaseGame 替身。偏差:`GetExcutedFileName` 仍为桩(版本包文件名为空)。Loading 结束后 `net.StartGame()` 打开 `game.GameWindow` 属 2V2 |
| 2V1-e | 真服 smoke(凭据只从环境变量读) | 待做 | | 2V1-e | 真服 smoke(凭据只从环境变量读) | 进行中:`port.login_live`(同一 `port_login_flow_test`,参数 `live`)走同一脚本路径连真服,以客户端自身状态为证据(选角窗口 + 槽位有名字 + 帝国号;`LoadingWindow.LoadData` 已由 `GC_MAIN_CHARACTER` 调用,随后同一调用发 `CG_CLIENT_VERSION`),在 `net.StartGame()` 进游戏前退出。`MT_LIVE_HOST`/`MT_LIVE_LOGIN`/`MT_LIVE_PASSWORD` 必填(缺则 SKIP),`MT_LIVE_AUTH_PORT`(11000)/`MT_LIVE_GAME_PORT`(13000)/`MT_LIVE_SLOT`(0) 可选,凭据不打印不入库。NEEDS_LIVE:2026-09-23 本机到 192.168.21.203 不通,尚未实跑 |
3. **2V2 — GamePhase/角色显示**:加入 GamePhase 的最小分派闭包、`PythonCharacterManager`、`InstanceBase`、`ActorInstance` 3. **2V2 — GamePhase/角色显示**:加入 GamePhase 的最小分派闭包、`PythonCharacterManager`、`InstanceBase`、`ActorInstance`
及角色模型/动画/资源 adapter。验收为进入游戏、创建主角并显示可辨认的静止角色;这个切片要求 2D 的 proto 和所需资源通过 及角色模型/动画/资源 adapter。验收为进入游戏、创建主角并显示可辨认的静止角色;这个切片要求 2D 的 proto 和所需资源通过
strict gate。 strict gate。
+4
View File
@@ -219,6 +219,10 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME)
add_test(NAME port.login_flow add_test(NAME port.login_flow
COMMAND $<TARGET_FILE:port_login_flow_test> ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP}) COMMAND $<TARGET_FILE:port_login_flow_test> ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP})
set_tests_properties(port.login_flow PROPERTIES SKIP_RETURN_CODE 77) set_tests_properties(port.login_flow PROPERTIES SKIP_RETURN_CODE 77)
# 2V1-e: the same path against a live 40250 server; skipped unless MT_LIVE_* is set (see the test).
add_test(NAME port.login_live
COMMAND $<TARGET_FILE:port_login_flow_test> ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP} live)
set_tests_properties(port.login_live PROPERTIES SKIP_RETURN_CODE 77 RUN_SERIAL TRUE)
add_executable(port_python_ui_modules_test ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_python_ui_modules_test.cpp) add_executable(port_python_ui_modules_test ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_python_ui_modules_test.cpp)
target_link_libraries(port_python_ui_modules_test PRIVATE port_platform) target_link_libraries(port_python_ui_modules_test PRIVATE port_platform)
+131 -49
View File
@@ -18,7 +18,16 @@
// The server checks every sequence byte against the 40250 table, so a skipped or extra sequenced // The server checks every sequence byte against the 40250 table, so a skipped or extra sequenced
// packet fails the run. // packet fails the run.
// //
// port_login_flow_test <40250 Client dir> <python27.zip> // 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, and the loading window got
// LoadData from GC_MAIN_CHARACTER, which CPythonNetworkStream::RecvMainCharacter answers with
// CG_CLIENT_VERSION. The test exits before net.StartGame() would enter the game. 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. // Exit 77 (ctest SKIP) when the client is missing, unless MT_ASSETS_STRICT=1, which fails instead.
#include "ScriptLib/StdAfx.h" #include "ScriptLib/StdAfx.h"
@@ -27,12 +36,15 @@
#include "../src/platform/UserInterface/UserInterface.h" #include "../src/platform/UserInterface/UserInterface.h"
#include "port_login_flow_server.h" #include "port_login_flow_server.h"
#include <memory>
#include <chrono> #include <chrono>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <functional> #include <functional>
#include <string> #include <string>
#include <thread> #include <thread>
#include <vector>
#include <unistd.h> #include <unistd.h>
@@ -91,24 +103,62 @@ static void dump(FakeLoginServer& server)
std::fprintf(stderr, "\nserver error: %s\n", server.Error().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) int main(int argc, char** argv)
{ {
const char* strict = std::getenv("MT_ASSETS_STRICT"); const char* strict = std::getenv("MT_ASSETS_STRICT");
const bool is_strict = strict && std::string(strict) == "1"; const bool is_strict = strict && std::string(strict) == "1";
const std::string client = argc > 1 ? argv[1] : ""; const std::string client = argc > 1 ? argv[1] : "";
const std::string stdlib = argc > 2 ? argv[2] : PythonHost::DefaultStdLibPath(); 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) 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()); std::fprintf(stderr, "port_login_flow_test: no 40250 Client/pack at '%s'\n", client.c_str());
return is_strict ? 1 : 77; return is_strict ? 1 : 77;
} }
FakeLoginServer server; std::string host = "127.0.0.1", login = FakeLoginServer::kLogin, password = FakeLoginServer::kPassword;
if (!server.Start()) int auth_port = 0, game_port = 0, slot = 0;
std::unique_ptr<FakeLoginServer> server;
if (live)
{ {
std::fprintf(stderr, "port_login_flow_test: cannot listen on loopback\n"); host = env("MT_LIVE_HOST");
return 1; 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(); };
PackSingletons(); PackSingletons();
CHECK(PackInitialize("pack")); CHECK(PackInitialize("pack"));
@@ -125,63 +175,95 @@ int main(int argc, char** argv)
"__main__._stream = [o for o in __import__('gc').get_objects() if isinstance(o, networkModule.MainStream)][0]")); "__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)"); })); 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. // 2. The login button's path (LoginWindow.Connect), pointed at the fake or the live server.
char connect[256]; py_set("_host", host);
std::snprintf(connect, sizeof(connect), py_set("_login", login);
"_stream.SetConnectInfo('127.0.0.1', %d, '127.0.0.1', %d)\n" py_set("_password", password);
"_stream.curPhaseWindow.Connect('%s', '%s')", char ports[128];
server.GamePort(), server.AuthPort(), FakeLoginServer::kLogin, FakeLoginServer::kPassword); std::snprintf(ports, sizeof(ports), "_auth_port, _game_port, _slot = %d, %d, %d", auth_port, game_port, slot);
CHECK(py(connect)); 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. // 3. The character list.
const bool selecting = pump_until(20, [&] { const bool selecting = pump_until(live ? 30 : 20, [&] {
return server.Failed() || return failed() || ((!server || server->Has("game1:login2")) &&
(server.Has("game1:login2") && py_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)")); py_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)"));
}); });
CHECK(selecting && !server.Failed()); CHECK(selecting && !failed());
CHECK(server.Has("auth:login3") && server.Has("auth:closed")); if (!selecting)
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; // The login window reports failures in its popup (LOGIN_FAILURE, connect errors).
for (const auto& e : expected) py("import sys\n"
if (e.rfind(prefix, 0) == 0) "_w = _stream.curPhaseWindow\n"
want.push_back(e); "_msg = _stream.popupWindow and _stream.popupWindow.IsShow() and _stream.popupWindow.GetChild('message').GetText()\n"
for (const auto& e : events) "sys.stderr.write('phase window %s, popup %r\\n' % (_w.__class__.__name__, _msg))");
if (e.rfind(prefix, 0) == 0) }
got.push_back(e); if (server)
CHECK(want == got); {
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());
// 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(loaded));
}
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",
};
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);
} }
if (g_failures)
dump(server);
CHECK(py("import app\napp.Exit()")); CHECK(py("import app\napp.Exit()"));
pump_until(2, [] { return !PythonBoot::IsAppLooping(); }); pump_until(2, [] { return !PythonBoot::IsAppLooping(); });
CHECK(!PythonBoot::IsAppLooping()); CHECK(!PythonBoot::IsAppLooping());
PythonBoot::Stop(); PythonBoot::Stop();
server.Stop(); if (server)
server->Stop();
if (g_failures) if (g_failures)
std::fprintf(stderr, "%d check(s) failed\n", g_failures); std::fprintf(stderr, "%d check(s) failed\n", g_failures);
else else
std::printf("port_login_flow_test: ok\n"); std::printf("port_login_flow_test: ok%s\n", live ? " (live)" : "");
return g_failures ? 1 : 0; return g_failures ? 1 : 0;
} }