diff --git a/audit/history.jsonl b/audit/history.jsonl index 786ebd1f..c4028d53 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -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-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 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: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"]} diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index c8dbd9c3..c631fd60 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -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//` 作为替身,移植对应单元的提交里删除;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-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` 及角色模型/动画/资源 adapter。验收为进入游戏、创建主角并显示可辨认的静止角色;这个切片要求 2D 的 proto 和所需资源通过 strict gate。 diff --git a/extension/src/port/CMakeLists.txt b/extension/src/port/CMakeLists.txt index 635e576e..c1b28ecb 100644 --- a/extension/src/port/CMakeLists.txt +++ b/extension/src/port/CMakeLists.txt @@ -219,6 +219,10 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) add_test(NAME port.login_flow COMMAND $ ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP}) 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 $ ${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) target_link_libraries(port_python_ui_modules_test PRIVATE port_platform) diff --git a/extension/tests/port_login_flow_test.cpp b/extension/tests/port_login_flow_test.cpp index cf5de7d1..cca0acdc 100644 --- a/extension/tests/port_login_flow_test.cpp +++ b/extension/tests/port_login_flow_test.cpp @@ -18,7 +18,16 @@ // 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> +// port_login_flow_test <40250 Client dir> [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. #include "ScriptLib/StdAfx.h" @@ -27,12 +36,15 @@ #include "../src/platform/UserInterface/UserInterface.h" #include "port_login_flow_server.h" +#include + #include #include #include #include #include #include +#include #include @@ -91,24 +103,62 @@ static void dump(FakeLoginServer& server) 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; } - FakeLoginServer server; - if (!server.Start()) + 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 server; + if (live) { - std::fprintf(stderr, "port_login_flow_test: cannot listen on loopback\n"); - return 1; + 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(); + 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(); 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]")); 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)); + // 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(20, [&] { - return server.Failed() || - (server.Has("game1:login2") && py_true("isinstance(_stream.curPhaseWindow, introSelect.SelectCharacterWindow)")); + const bool selecting = pump_until(live ? 30 : 20, [&] { + return failed() || ((!server || 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 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:"}) + CHECK(selecting && !failed()); + if (!selecting) { - std::vector 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); + // 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()); + // 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 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 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()")); pump_until(2, [] { return !PythonBoot::IsAppLooping(); }); CHECK(!PythonBoot::IsAppLooping()); PythonBoot::Stop(); - server.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\n"); + std::printf("port_login_flow_test: ok%s\n", live ? " (live)" : ""); return g_failures ? 1 : 0; }