diff --git a/audit/history.jsonl b/audit/history.jsonl index 76464703..786ebd1f 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -509,3 +509,4 @@ {"time": "2026-09-23T05:15:20Z", "event": "port_round", "unit": "PORT-PLAN 2V0-g: .fnt font pipeline (CGraphicText/CGraphicFontTexture/CGraphicDib/CGraphicTextInstance)", "ported": ["EterLib/GrpText.cpp, GrpFontTexture.cpp, GrpDIB.cpp (40250 verbatim)", "EterLib/GrpTextInstance.cpp (verbatim except D3D draw -> UI render commands)", "EterLib/TextTag, EterLocale/StringCodec, Arabic, StringCodec_Vietnamese (port_copy)", "EterLib/Util.cpp code page / font face table, SetDefaultCodePage", "UserInterface/GameType.cpp DefaultFont_* block", "CGraphicImageTexture Create/Lock/Unlock memory texture path", "GetMaxTextureWidth/Height"], "adapted": ["GDI subset (CreateFontIndirect, CreateDIBSection, TextOutW, GetTextExtentPoint32W, GetCharABCWidthsFloatW) emulated over vendored FreeType 2.13.3 with GDI cell-height/win-metric/gasp rules", "MultiByteToWideChar/WideCharToMultiByte: hand UTF-8/1252, iconv for other code pages", "glyph pages reach Godot as mem:@ memory textures; Canvas caches by id", "PythonBoot::Start calls SetDefaultCodePage(LocaleService_GetCodePage()) like 40250 Main", "CPythonResource registers fnt -> CGraphicText"], "divergent": ["Android /system/fonts has no Tahoma/Gulim: substitute faces change metrics until fonts are bundled", "no per-pixel comparison against Windows GDI output yet"], "needs_live": [], "tests": ["port.text: pass (Tahoma 12 height 12, width, PixelPositionToCharacterPosition, centre align, CP949 round trip)", "port.app_loop: pass (popup glyph quads, inked glyph page)", "ctest 31/31 pass", "godot --headless --path project --script python_host_test.gd: pass", "login screen screenshot: server list, popup and button captions rendered"], "todo": ["ExpandedImage blend modes", "Android font bundling", "2V1 login/select"]} {"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)"]} diff --git a/docs/PORT-PLAN.md b/docs/PORT-PLAN.md index a6c7e05a..c8dbd9c3 100644 --- a/docs/PORT-PLAN.md +++ b/docs/PORT-PLAN.md @@ -411,7 +411,7 @@ extension/third_party/cpython-2.7.18/ # 静态库(2P 批次 | 2V1-a | `EterLib/NetStream`、`NetAddress`、`NetDevice`、`NetPacketHeaderMap`、`EterBase/cipher.cpp` | 完成:五个文件 `port_copy` 原样(含 32768 字节序列表、`_IMPROVED_PACKET_ENCRYPTION_` 的 Crypto++ DH2 + CTR 路径);`shim/win32/winsock.h` 把 Winsock 名字映射到 BSD socket,并补齐两处语义差异:`select` 忽略 nfds,connect 失败的 socket 不算可写(Winsock 只在 exceptfds 报,40250 靠超时判失败);非阻塞 connect 的 `EINPROGRESS` 报成 `WSAEWOULDBLOCK`;`WSAStartup` 忽略 SIGPIPE。`port.net` 用 loopback 覆盖连接、收发、对端断开、拒绝连接超时和双方密钥协商后的加解密 | | 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 | 待做 | + | 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(凭据只从环境变量读) | 待做 | 3. **2V2 — GamePhase/角色显示**:加入 GamePhase 的最小分派闭包、`PythonCharacterManager`、`InstanceBase`、`ActorInstance` 及角色模型/动画/资源 adapter。验收为进入游戏、创建主角并显示可辨认的静止角色;这个切片要求 2D 的 proto 和所需资源通过 diff --git a/extension/src/platform/UserInterface/PythonApplication.cpp b/extension/src/platform/UserInterface/PythonApplication.cpp index e554ec04..ba9ec160 100644 --- a/extension/src/platform/UserInterface/PythonApplication.cpp +++ b/extension/src/platform/UserInterface/PythonApplication.cpp @@ -49,6 +49,15 @@ void CPythonApplication::Abort() PythonBoot::AppRequestQuit(); } void CPythonApplication::SetMinFog(float) { MT_PLATFORM_STUB(); } +// In 40250 Process() copies CTimer::GetCurrentSecond() into m_fGlobalTime right after Advance() and +// this returns it. PORT: no member to hold it; the timer only advances in Process(), so reading it +// here gives the same value. +float CPythonApplication::GetGlobalTime() { return CTimer::Instance().GetCurrentSecond(); } +// Camera (2V3). +void CPythonApplication::SetEventCamera(const SCameraSetting&) { MT_PLATFORM_STUB(); } +void CPythonApplication::SetDefaultCamera() { MT_PLATFORM_STUB(); } +// In 40250 this stores m_v3CenterPosition for the game view (introselect.py sets it on the character list). +void CPythonApplication::SetCenterPosition(float, float, float) { MT_PLATFORM_STUB(); } void CPythonApplication::SetFrameSkip(bool) { MT_PLATFORM_STUB(); } // PORT: 40250 creates the Win32 window, the D3D8 device, cursors, sound, keyboard, the net device, // fonts and the IME here. The Godot window and its canvas are the device on this platform and they diff --git a/extension/src/platform/pending/UserInterface/PythonNetworkStreamPhaseGame.cpp b/extension/src/platform/pending/UserInterface/PythonNetworkStreamPhaseGame.cpp index 299e8c2f..570ff88f 100644 --- a/extension/src/platform/pending/UserInterface/PythonNetworkStreamPhaseGame.cpp +++ b/extension/src/platform/pending/UserInterface/PythonNetworkStreamPhaseGame.cpp @@ -3,6 +3,7 @@ // file in the commit that ports the unit into port/ (docs/PORT-PLAN.md 2V1). #include "UserInterface/StdAfx.h" #include "UserInterface/PythonNetworkStream.h" +#include "EterBase/Filename.h" #include "../../PlatformStub.h" @@ -44,10 +45,40 @@ bool CPythonNetworkStream::SendChatPacket(const char *, BYTE) return mt_platform_stub_return(); } +// PORT: the 40250 body verbatim ahead of its unit: the loading phase sends it on the main-character packet. bool CPythonNetworkStream::SendClientVersionPacket() { - MT_PLATFORM_STUB(); - return mt_platform_stub_return(); + std::string filename; + + GetExcutedFileName(filename); + + filename = CFileNameHelper::NoPath(filename); + CFileNameHelper::ChangeDosPath(filename); + + if (LocaleService_IsEUROPE() && false == LocaleService_IsYMIR()) + { + TPacketCGClientVersion2 kVersionPacket; + kVersionPacket.header = HEADER_CG_CLIENT_VERSION2; + strncpy(kVersionPacket.filename, filename.c_str(), sizeof(kVersionPacket.filename)-1); + strncpy(kVersionPacket.timestamp, "1215955205", sizeof(kVersionPacket.timestamp)-1); // # python time.time 앞자리 + //strncpy(kVersionPacket.timestamp, __TIMESTAMP__, sizeof(kVersionPacket.timestamp)-1); // old_string_ver + //strncpy(kVersionPacket.timestamp, "1218055205", sizeof(kVersionPacket.timestamp)-1); // new_future + //strncpy(kVersionPacket.timestamp, "1214055205", sizeof(kVersionPacket.timestamp)-1); // old_past + + if (!Send(sizeof(kVersionPacket), &kVersionPacket)) + Tracef("SendClientReportPacket Error"); + } + else + { + TPacketCGClientVersion kVersionPacket; + kVersionPacket.header = HEADER_CG_CLIENT_VERSION; + strncpy(kVersionPacket.filename, filename.c_str(), sizeof(kVersionPacket.filename)-1); + strncpy(kVersionPacket.timestamp, __TIMESTAMP__, sizeof(kVersionPacket.timestamp)-1); + + if (!Send(sizeof(kVersionPacket), &kVersionPacket)) + Tracef("SendClientReportPacket Error"); + } + return SendSequence(); } bool CPythonNetworkStream::SendExchangeAcceptPacket() diff --git a/extension/src/port/CMakeLists.txt b/extension/src/port/CMakeLists.txt index bec5e7d2..635e576e 100644 --- a/extension/src/port/CMakeLists.txt +++ b/extension/src/port/CMakeLists.txt @@ -211,6 +211,15 @@ if(BUILD_TESTING AND CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME) COMMAND $ ${MT_40250_CLIENT} ${MT_PYTHON_STDLIB_ZIP}) set_tests_properties(port.app_loop PROPERTIES SKIP_RETURN_CODE 77) + add_executable(port_login_flow_test ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_login_flow_test.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../../tests/port_login_flow_server.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../net/classic/classic_cipher.cpp) + target_link_libraries(port_login_flow_test PRIVATE port_platform) + add_dependencies(port_login_flow_test mtpython_stdlib) + 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) + 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) add_dependencies(port_python_ui_modules_test mtpython_stdlib) diff --git a/extension/src/port/EterLib/NetStream.cpp b/extension/src/port/EterLib/NetStream.cpp index c5866d27..42178559 100644 --- a/extension/src/port/EterLib/NetStream.cpp +++ b/extension/src/port/EterLib/NetStream.cpp @@ -3006,6 +3006,12 @@ bool CNetworkStream::Activate(size_t agreed_length, const void* buffer, size_t l void CNetworkStream::ActivateCipher() { - return m_cipher.set_activated(true); + m_cipher.set_activated(true); + // PORT: __RecvInternalBuffer decrypts each recv as it arrives, so encrypted bytes the server sent right + // behind KEY_AGREEMENT_COMPLETED that landed in the same recv are still ciphertext. In 40250 the + // server's next pulse usually keeps them apart; on loopback or a fast LAN they arrive together, so + // decrypt the unread rest of the buffer here. + if (m_recvBufInputPos > m_recvBufOutputPos) + m_cipher.Decrypt(m_recvBuf + m_recvBufOutputPos, m_recvBufInputPos - m_recvBufOutputPos); } #endif // _IMPROVED_PACKET_ENCRYPTION_ diff --git a/extension/src/port/UserInterface/PythonApplication.h b/extension/src/port/UserInterface/PythonApplication.h index c667c6b0..b249940a 100644 --- a/extension/src/port/UserInterface/PythonApplication.h +++ b/extension/src/port/UserInterface/PythonApplication.h @@ -42,7 +42,11 @@ #include "AbstractApplication.h" #include "MovieMan.h" -class CPythonApplication : public CMSApplication, public CInputKeyboard, public IAbstractApplication +// PORT: `final` added. The platform has no CPythonApplication object yet (platform/UserInterface/ +// PythonApplication.cpp), so its members are called on an empty Instance(); `final` lets the compiler +// call the IAbstractApplication/CMSApplication overrides directly instead of through a vtable that +// does not exist. Drop it with that note once the application object is constructed. +class CPythonApplication final : public CMSApplication, public CInputKeyboard, public IAbstractApplication { public: enum EDeviceState diff --git a/extension/tests/port_login_flow_server.cpp b/extension/tests/port_login_flow_server.cpp new file mode 100644 index 00000000..4f11542b --- /dev/null +++ b/extension/tests/port_login_flow_server.cpp @@ -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 +#include +#include +#include +#include + +#include +#include +#include + +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(duration_cast(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(&addr), sizeof(addr)) != 0 || listen(fd, 4) != 0) + { + close(fd); + return -1; + } + socklen_t len = sizeof(addr); + getsockname(fd, reinterpret_cast(&addr), &len); + *port = ntohs(addr.sin_port); + return fd; +} + +template +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* 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 bytes(static_cast(data), static_cast(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(n); + } + return true; + } + + template + 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(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(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 FakeLoginServer::Events() +{ + std::lock_guard lock(m_mutex); + return m_events; +} + +std::string FakeLoginServer::Error() +{ + std::lock_guard lock(m_mutex); + return m_error; +} + +bool FakeLoginServer::Has(const std::string& event) +{ + std::lock_guard 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 lock(m_mutex); + m_events.push_back(event); +} + +bool FakeLoginServer::Fail(const std::string& error) +{ + std::lock_guard 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(); + 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(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(now - in.time) / 2; + if (delta < 0) + delta = static_cast(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(); + 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(agreed_length); + agreement.data_length = static_cast(data_length); + if (!c.Send(agreement)) + return Fail("send GC_KEY_AGREEMENT"); + + auto reply = zeroed(); + 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(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(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(); + 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(); + 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(); + 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(htonl(INADDR_LOOPBACK)); + player.port = static_cast(m_game_port); + success.handle = static_cast(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(); + 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(); + 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(); + 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 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; +} diff --git a/extension/tests/port_login_flow_server.h b/extension/tests/port_login_flow_server.h new file mode 100644 index 00000000..5a9b10ad --- /dev/null +++ b/extension/tests/port_login_flow_server.h @@ -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 +#include +#include +#include +#include +#include + +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 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 m_stop{false}; + std::mutex m_mutex; + std::vector m_events; + std::string m_error; +}; diff --git a/extension/tests/port_login_flow_test.cpp b/extension/tests/port_login_flow_test.cpp new file mode 100644 index 00000000..cf5de7d1 --- /dev/null +++ b/extension/tests/port_login_flow_test.cpp @@ -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> +// +// 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 +#include +#include +#include +#include +#include + +#include + +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& done) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::duration(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 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); + + 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; +}