Files
mtgodot-poc/tests/port/port_login_flow_test.cpp
T
shenleiandClaude Opus 5.5 5c8d583b31 fix(touch): raise the soft keyboard only for a tap on the visible focused EditLine
A chat line keeps the IME focus under a hidden board (interfacemodule
__HideWindows for an NPC dialog, the focus handed back by
UnlockWindow/DeactivateWindow), and IsPointInsideFocusedWindow only
checked its own show flag and rect, so taps on the NPC dialog or the
minimap buttons opened the keyboard. Require the whole parent chain to
render and the picked window to be the focused one or its child.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:00:39 +09:00

1331 lines
64 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/ServerClock.h"
#include "../../src/platform/UserInterface/UserInterface.h"
#include "../../src/platform/EterBase/TraceErrorObserver.h"
#include "../../src/platform/EterLib/RenderCommands3D.h"
#include "GameLib/StdAfx.h"
#include "GameLib/RaceManager.h"
#include "GameLib/RaceData.h"
#include "GameLib/RaceMotionData.h"
#include "GameLib/FlyingData.h"
#include "GameLib/FlyingObjectManager.h"
#include "EffectLib/EffectManager.h"
#include "EffectLib/EffectInstance.h"
#include "EterLib/Camera.h"
#include "GameLib/ItemManager.h"
#include "UserInterface/PythonItem.h"
#include "UserInterface/StdAfx.h"
#include "UserInterface/PythonPlayer.h"
#include "UserInterface/PythonExchange.h"
#include "UserInterface/PythonTextTail.h"
#include "UserInterface/PythonCharacterManager.h"
#include "UserInterface/InstanceBase.h"
#include "UserInterface/PythonBackground.h"
#include "UserInterface/PythonMessenger.h"
#include "UserInterface/PythonGuild.h"
#include "UserInterface/MarkManager.h"
#include "UserInterface/PythonSystem.h"
#include "MilesLib/SoundManager.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 <cmath>
#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();
}
// Runs a fixed number of frames: the wait between a mouse move and its click must not depend on how
// many frames a loaded machine fits into a wall-clock slice (ctest -j).
// CPythonNetworkStream::GamePhase (40250) refreshes the inventory/safebox windows at most every 300 ms,
// so a slot can still be empty in the UI after player/safebox state already holds the item.
static void pump_ui_refresh()
{
pump_until(0.35, [] { return false; });
}
static void pump_frames(int count)
{
for (int i = 0; i < count && PythonBoot::IsAppLooping(); ++i)
{
PythonBoot::UIUpdate();
std::this_thread::sleep_for(std::chrono::milliseconds(16));
}
}
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;
const bool started = PythonBoot::Start(stdlib.c_str(), &error);
if (!started)
std::fprintf(stderr, "PythonBoot::Start: %s\n", error.c_str());
CHECK(started);
// 40250 keeps a server-supplied base plus local elapsed time. Check both units before the
// fake server's GC_TIME packet replaces this base during the game phase.
SetPlatformServerTime(123456789);
CHECK(GetPlatformServerTime() >= 123456789 && GetPlatformServerTime() < 123457789);
CHECK(GetPlatformServerTimeStamp() >= 123456789 && GetPlatformServerTimeStamp() <= 123456791);
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)"));
// Typed into the login board through the 40250 IME (WM_CHAR -> CPythonIME::WMChar, WM_KEYDOWN ->
// OnIMEKeyDown): Tab moves to the password line, Return runs __OnClickLoginButton -> Connect.
// The IME only reaches a rendering EditLine: open the login board as the server list's OK button does.
CHECK(py("_w = _stream.curPhaseWindow\n"
"_w._LoginWindow__OpenLoginBoard()\n"
"_w.idEditLine.SetText('')\n"
"_w.pwdEditLine.SetText('')\n"
"_w.idEditLine.SetFocus()"));
auto type = [](const std::string& text) {
for (unsigned char c : text)
PythonBoot::UIChar(c);
};
type(login + "xy");
PythonBoot::UIIMEKeyDown(VK_LEFT);
PythonBoot::UIIMEKeyDown(VK_DELETE); // y
PythonBoot::UIChar(VK_BACK); // x
CHECK(py("assert _w.idEditLine.GetText() == _login, repr(_w.idEditLine.GetText())"));
PythonBoot::UIChar(VK_TAB);
type(password);
CHECK(py("assert _w.pwdEditLine.GetText() == _password, len(_w.pwdEditLine.GetText())\n"
"del __main__._login, __main__._password, __main__._w"));
PythonBoot::UIChar(VK_RETURN);
// 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()"));
}
// 3b. intrologin's channel status list (__RequestServerStateList / NotifyChannelState): ServerStateChecker
// connects to the first channel, sends CG_STATE_CHECKER and reports each channel the answer lists
// by port. TChannelStatus::nPort is a short, so the fake server's ephemeral state port (> 32767)
// never matches its own channel; offline the proof is the second channel (13002) it also lists.
// Live the game port answers for the channels the db knows, the game port among them.
{
const int state_port = server ? server->StatePort() : game_port;
const int key = server ? 2 : 1;
char channels[256];
std::snprintf(channels, sizeof(channels),
"ServerStateChecker.AddChannel(1, _host, %d)\nServerStateChecker.AddChannel(2, _host, %d)\n"
"_channel_key = %d\n",
state_port, FakeLoginServer::kOtherChannelPort, key);
CHECK(py(std::string("import ServerStateChecker\n"
"class _ChannelStates:\n"
" def __init__(self): self.states = {}\n"
" def NotifyChannelState(self, key, state): self.states[key] = state\n"
"_channel_states = _ChannelStates()\n"
"ServerStateChecker.Create(_channel_states)\n"
"ServerStateChecker.Initialize()\n") +
channels + "ServerStateChecker.Request()"));
CHECK(pump_until(10, [] { return py("ServerStateChecker.Update()") && py_true("_channel_key in _channel_states.states"); }));
py("import sys\nsys.stderr.write('port_login_flow_test: channel states %r\\n' % _channel_states.states)");
CHECK(py_true("_channel_states.states.get(_channel_key, 0) > 0"));
if (server)
CHECK(server->Has("state:checker"));
}
// 3c. SetSelectCharacterPhase ran localeInfo.LoadLocaleData() → app.LoadLocaleData(app.GetLocalePath()):
// CItemManager read item_list.txt/item_proto/itemdesc.txt and CPythonNonPlayer read mob_proto.
{
CItemData* sword = nullptr;
CHECK(CItemManager::Instance().GetItemDataPointer(19, &sword) && sword);
if (sword)
{
std::printf("port_login_flow_test: item 19 %s type %u refine %u\n", sword->GetName(), sword->GetType(), sword->GetRefine());
CHECK(sword->GetType() == CItemData::ITEM_TYPE_WEAPON && sword->GetRefine() == 9);
CHECK(sword->GetModelThing() != nullptr);
}
CHECK(py("import nonplayer\n"
"_mob = nonplayer.GetMonsterName(101)\n"
"assert _mob, 'mob 101 has no name'\n"
"import sys\nsys.stderr.write('port_login_flow_test: mob 101 %s\\n' % _mob)"));
// CPythonSkill read skilldesc.txt and skilltable.txt (RegisterSkillDesc/RegisterSkillTable); skill 1's
// cool time is skilltable's constant "12".
CHECK(py("import skill\n"
"assert skill.GetSkillName(1, 0) == 'Three-Way Cut', skill.GetSkillName(1, 0)\n"
"assert skill.GetSkillType(1) == skill.SKILL_TYPE_ACTIVE, skill.GetSkillType(1)\n"
"assert skill.GetSkillCoolTime(1, 1.0) == 12, skill.GetSkillCoolTime(1, 1.0)"));
}
// 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()));
// The fake server's GC_TIME is milliseconds since this test server started, so its
// value is small. The sentinel set before login must have been replaced by RecvTimePacket.
CHECK(live || (GetPlatformServerTime() < 120000 && GetPlatformServerTimeStamp() < 120000));
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; }));
}
// (批次 2V2-e.1) RenderGame drew the character through CStateManager into the recording device:
// a frame holds a textured, lit triangle draw (the PNT vertex format of RenderWithOneTexture)
// under CPythonGraphic::SetPerspective's right-handed projection (_34 = -1), and the camera that
// __UpdateCamera centred on the main actor puts every vertex of it on screen.
auto on_screen = [](const Render3DDraw& d) {
auto mul = [](const float* v, const float* m, float* o) {
for (int c = 0; c < 4; ++c)
o[c] = v[0] * m[c] + v[1] * m[4 + c] + v[2] * m[8 + c] + v[3] * m[12 + c];
};
for (size_t i = 0; i + 2 < d.positions.size(); i += 3)
{
const float local[4] = {d.positions[i], d.positions[i + 1], d.positions[i + 2], 1.0f};
float world[4], view[4], clip[4];
mul(local, d.world, world);
mul(world, d.view, view);
mul(view, d.proj, clip);
if (clip[3] <= 0.0f || std::fabs(clip[0]) > clip[3] || std::fabs(clip[1]) > clip[3])
return false;
}
return true;
};
auto character_draw = [&] {
for (const Render3DDraw& d : Render3DDraws())
if (!d.pretransformed && !d.lines && !d.indices.empty() && !d.texture0.empty() &&
!d.normals.empty() && !d.uv0.empty() && d.proj[11] == -1.0f && on_screen(d))
return true;
return false;
};
CHECK(pump_until(3, character_draw));
// 7. (批次 2V3) Keyboard movement through the original path: the key reaches game.GameWindow's
// onPressKeyDict, MoveUp calls player.SetSingleDIKKeyState(DIK_UP, True), CPythonPlayer turns
// it into NEW_MoveToDirection on the main instance, and CPythonPlayerEventHandler::OnMove sends
// CG_MOVE FUNC_MOVE. Releasing the key stops the actor (OnStop: FUNC_WAIT at the new position).
TPixelPosition start = {};
if (main_instance)
main_instance->NEW_GetPixelPosition(&start);
auto walked = [&] {
TPixelPosition now = {};
main_instance->NEW_GetPixelPosition(&now);
return std::hypot(now.x - start.x, now.y - start.y);
};
PythonBoot::UIKey(DIK_UP, true);
CHECK(main_instance && pump_until(3, [&] { return walked() > 100.0f; }));
PythonBoot::UIKey(DIK_UP, false);
CHECK(main_instance && pump_until(2, [&] { return !main_instance->IsWalking(); }));
const float distance = main_instance ? walked() : 0.0f;
std::printf("port_login_flow_test: DIK_UP walked %.0f cm\n", distance);
if (server)
{
// The fake server records "game2:move <func> <arg> <rot> <x> <y>"; x, y are the TPixelPosition
// (y negated back, as SendCharacterStatePacket does) plus the map base the server gave.
auto moves = [&] {
std::vector<std::string> out;
for (const auto& e : server->Events())
if (e.rfind("game2:move ", 0) == 0)
out.push_back(e);
return out;
};
CHECK(pump_until(2, [&] { auto m = moves(); return m.size() >= 2 && m.back().rfind("game2:move 0 ", 0) == 0; }));
const auto m = moves();
CHECK(!m.empty() && m.front().rfind("game2:move 1 0 ", 0) == 0);
for (const auto& e : m)
std::printf("port_login_flow_test: %s\n", e.c_str());
}
// The terrain attribute map (attr.atr) under the actor: CInstanceBase's block/water/ban-PK checks read it
// through CPythonBackground::isAttrOn. The actor stands on a free cell; its 256x256 tile has blocked cells.
if (main_instance)
{
TPixelPosition here = {};
main_instance->NEW_GetPixelPosition(&here);
CPythonBackground& bg = CPythonBackground::Instance();
CHECK(!bg.isAttrOn(here.x, here.y, CTerrainImpl::ATTRIBUTE_BLOCK));
const int tx = int(here.x) / CTerrainImpl::TERRAIN_XSIZE * CTerrainImpl::TERRAIN_XSIZE;
const int ty = int(here.y) / CTerrainImpl::TERRAIN_YSIZE * CTerrainImpl::TERRAIN_YSIZE;
int blocked = 0;
for (int y = 0; y < CTerrainImpl::TERRAIN_YSIZE; y += CTerrainImpl::HALF_CELLSCALE)
for (int x = 0; x < CTerrainImpl::TERRAIN_XSIZE; x += CTerrainImpl::HALF_CELLSCALE)
blocked += bg.isAttrOn(tx + x, ty + y, CTerrainImpl::ATTRIBUTE_BLOCK) ? 1 : 0;
std::printf("port_login_flow_test: %d blocked attribute cells in the actor's terrain\n", blocked);
CHECK(blocked > 0);
}
// playersettingmodule.py registered the indexed fly data (effect.RegisterIndexedFlyData → CFlyingManager →
// CFlyingData::LoadScriptFile): the .msf parses, and registering the name again finds it already present.
{
const char* msf = "d:/ymir work/effect/etc/gathering/ga_piece_yellow_small2.msf";
CFlyingData* data = CFlyingData::New();
CHECK(data->LoadScriptFile(msf));
CFlyingData::Delete(data);
CHECK(!CFlyingManager::Instance().RegisterFlyingData(msf));
}
// EffectLib: playersettingmodule.py registered the level-up effect (chrmgr.RegisterEffect → CEffectManager::
// RegisterEffect2, including the .mse files the .msf fly data attach by relative name). Created at the
// actor, RenderGame's CEffectManager::Render draws its particles as textured, alpha-blended world draws.
if (main_instance)
{
const char* mse = "d:/ymir work/effect/etc/levelup_1/level_up.mse";
TPixelPosition here = {};
main_instance->NEW_GetPixelPosition(&here);
CEffectManager& effects = CEffectManager::Instance();
const int index = effects.CreateEffect(mse, D3DXVECTOR3(here.x, -here.y, here.z), D3DXVECTOR3(0, 0, 0));
CHECK(index >= 0 && effects.IsAliveEffect(index));
auto effect_draw = [] {
for (const Render3DDraw& d : Render3DDraws())
if (!d.pretransformed && d.alpha_blend && d.texture0.find("effect\\etc\\levelup_1\\") != std::string::npos)
return true;
return false;
};
CHECK(pump_until(3, effect_draw));
effects.DestroyEffectInstance(index);
}
// item module (PythonItemModule.cpp) over the loaded item_proto: SelectItem + the selected item's fields.
CHECK(py("import item\n"
"item.SelectItem(19)\n"
"assert item.GetItemName() == 'Sword+9', item.GetItemName()\n"
"assert item.GetItemType() == item.ITEM_TYPE_WEAPON and item.GetItemSubType() == item.WEAPON_SWORD\n"
"assert item.GetValue(3) > 0, 'min attack'"));
// skill icons: RegisterSkillDesc loaded each skill's .sub icons; an active skill has one per grade
// (__RegisterGradeIconImage) and no plain icon, as in 40250. The icon instance handle survives the 64-bit
// round trip (Py_BuildPointer).
CHECK(py("import skill\n"
"assert skill.GetIconImage(1) == 0\n"
"assert skill.GetIconImageNew(1, 0) != 0, 'skill 1 has no grade icon'\n"
"_icon = skill.GetIconInstanceNew(1, 0)\n"
"assert _icon != 0\n"
"skill.DeleteIconInstance(_icon)"));
// miniMap module (PythonMiniMapModule.cpp): uiminimap.MiniMap created and showed it. The warp loaded the
// outdoor map through CPythonBackground::Warp -> CMapManager::LoadMap -> CMapOutdoor, so the map is outdoor
// and CPythonMiniMap::LoadAtlas found the map's atlas image (its size is the atlas bitmap's).
CHECK(CPythonBackground::Instance().IsMapOutdoor());
CHECK(py("import miniMap, background\n"
"assert miniMap.isShow()\n"
"(bGet, x, y) = miniMap.GetAtlasSize()\n"
"assert bGet and x > 0 and y > 0, (bGet, x, y)\n"
"print 'port_login_flow_test: atlas %dx%d' % (x, y)\n"
"assert background.GetCurrentMapName() != '', background.GetCurrentMapName()"));
// Ground items (CPythonItem): the fake server put vnum 19 next to the actor at entergame (GC_ITEM_GROUND_ADD
// + GC_ITEM_OWNERSHIP). player.PickCloseItem finds it within __GetPickableDistance, sends CG_ITEM_PICKUP,
// and the server's GC_ITEM_GROUND_DEL removes it.
if (server)
{
CPythonItem& ground = CPythonItem::Instance();
TPixelPosition item_pos = {};
const char* owner = nullptr;
CHECK(ground.GetGroundItemPosition(FakeLoginServer::kGroundItemVID, &item_pos));
CHECK(ground.GetOwnership(FakeLoginServer::kGroundItemVID, &owner) && owner &&
std::string(owner) == FakeLoginServer::kCharacterName);
CHECK(ground.GetVirtualNumberOfGroundItem(FakeLoginServer::kGroundItemVID) == FakeLoginServer::kGroundItemVnum);
std::printf("port_login_flow_test: ground item at %.0f %.0f\n", item_pos.x, item_pos.y);
// CPythonItem::CreateItem registered the item's name tail (CPythonTextTail::RegisterItemTextTail) next to
// the actor's character tail. Shown and projected, the name is pickable on screen: textTail.Pick is how
// game.GameWindow.OnRender finds the item under the cursor while names are shown.
auto tail_info = [] {
std::string info;
CPythonTextTail::Instance().GetInfo(&info);
return info;
};
std::printf("port_login_flow_test: %s\n", tail_info().c_str());
CHECK(tail_info().find("ItemTail (Map 1,") != std::string::npos);
CHECK(tail_info().find("ChrTail (Map 5,") != std::string::npos); // the actor, the stray dog, the shopkeeper, the quest elder and the storekeeper
// Holding ALT (game.GameWindow.ShowName) makes OnRender show every tail in range and project it after
// app.RenderGame; the item tail keeps that screen rect, which is what textTail.Pick tests.
PythonBoot::UIKey(DIK_LMENU, true);
pump_frames(6);
PythonBoot::UIKey(DIK_LMENU, false);
int picked = -1, pick_x = 0, pick_y = 0;
for (int y = 0; y < 1200 && picked < 0; y += 2)
for (int x = 0; x < 1600 && picked < 0; x += 2)
if ((picked = CPythonTextTail::Instance().Pick(x, y)) >= 0)
pick_x = x, pick_y = y;
std::printf("port_login_flow_test: item name tail picked at %d %d\n", pick_x, pick_y);
CHECK(picked == (int) FakeLoginServer::kGroundItemVID);
CHECK(py("import player\nplayer.PickCloseItem()"));
const std::string pickup = "game2:pickup " + std::to_string(FakeLoginServer::kGroundItemVID);
CHECK(pump_until(2, [&] { return server->Has(pickup); }));
CHECK(pump_until(2, [&] { return !ground.GetGroundItemPosition(FakeLoginServer::kGroundItemVID, &item_pos); }));
CHECK(tail_info().find("ItemTail (Map 0,") != std::string::npos);
}
// Exchange (CPythonExchange + exchange module): net.SendExchangeStartPacket → the fake partner's GC_EXCHANGE
// START/ITEM_ADD/GOLD_ADD fill the target side and open uiexchange; net.SendExchangeExitPacket → END.
if (server)
{
CHECK(py("import net\nnet.SendExchangeStartPacket(" + std::to_string(FakeLoginServer::kMainVID) + ")"));
CHECK(pump_until(2, [&] { return CPythonExchange::Instance().GetElkFromTarget() == FakeLoginServer::kExchangeGold; }));
CHECK(py("import exchange\n"
"assert exchange.isTrading() == 1\n"
"assert exchange.GetItemVnumFromTarget(0) == " + std::to_string(FakeLoginServer::kExchangeItemVnum) + "\n"
"assert exchange.GetItemCountFromTarget(0) == 1\n"
"assert exchange.GetElkFromSelf() == 0"));
CHECK(py("import net\nnet.SendExchangeExitPacket()"));
CHECK(pump_until(2, [&] { return !CPythonExchange::Instance().isTrading(); }));
CHECK(server->Has("game2:exchange 0 " + std::to_string(FakeLoginServer::kMainVID)));
CHECK(server->Has("game2:exchange 5"));
// uiexchange.CloseDialog armed wndMgr.OnceIgnoreMouseLeftButtonUpEvent (in the client that release belongs
// to the click that closed the dialog); spend it so step 8's click-release is not swallowed.
PythonBoot::UIMouseButton(1, false, 400, 520);
// Chat (批次 4-a): Return on the focused game.GameWindow → OnIMEReturn → interface.ToggleChat opens the
// chat line; the typed text goes through CPythonIME, a second Return sends CG_CHAT (TALKING) and the fake
// server echoes GC_CHAT back.
CHECK(py_true("not _stream.curPhaseWindow.interface.IsOpenChat()"));
PythonBoot::UIChar(VK_RETURN);
CHECK(pump_until(1, [] { return py_true("_stream.curPhaseWindow.interface.IsOpenChat()"); }));
for (char c : std::string("hello"))
PythonBoot::UIChar((unsigned char)c);
pump_until(0.05, [] { return false; });
PythonBoot::UIChar(VK_RETURN);
CHECK(pump_until(2, [&] { return server->Has("game2:chat 0 hello"); }));
// uichat.__SendTalkingChatPacket only resets the line; a Return on the empty line closes it (eventReturn).
CHECK(py_true("_stream.curPhaseWindow.interface.IsOpenChat() and __import__('ime').GetText(0) == ''"));
// Touch hosts raise the system keyboard only for a tap on the focused, visible EditLine. A chat line
// that keeps the focus under a hidden board (interfacemodule.__HideWindows for an NPC dialog, then the
// focus handed back by UnlockWindow/DeactivateWindow) must not, wherever the tap lands.
{
std::string xywh;
CHECK(py("_line = _stream.curPhaseWindow.interface.wndChat.chatInputSet.chatLine"));
CHECK(PythonBoot::Evaluate("'%d %d %d %d' % (_line.GetGlobalPosition() + (_line.GetWidth(), _line.GetHeight()))",
&xywh, nullptr));
int x = 0, y = 0, w = 0, h = 0;
std::sscanf(xywh.c_str(), "%d %d %d %d", &x, &y, &w, &h);
const int cx = x + w / 2, cy = y + h / 2;
CHECK(w > 0 && h > 0);
CHECK(PythonBoot::TextInputFocused());
CHECK(PythonBoot::IsPointInsideFocusedWindow(cx, cy));
CHECK(!PythonBoot::IsPointInsideFocusedWindow(790, 10));
CHECK(py("_stream.curPhaseWindow.interface.wndChat.Hide()"));
CHECK(PythonBoot::TextInputFocused());
CHECK(!PythonBoot::IsPointInsideFocusedWindow(cx, cy));
CHECK(py("_stream.curPhaseWindow.interface.wndChat.Show()"));
CHECK(PythonBoot::IsPointInsideFocusedWindow(cx, cy));
}
PythonBoot::UIChar(VK_RETURN);
CHECK(pump_until(1, [] { return py_true("not _stream.curPhaseWindow.interface.IsOpenChat()"); }));
}
// 8. (批次 2V3) Click-to-move: a left click on the ground below the actor reaches game.GameWindow's
// OnMouseLeftButtonDown → player.SetMouseState(MBT_LEFT, MBS_PRESS); CPythonPlayer picks the ground
// through the cursor ray (CScreen::SetCursorPosition → CPythonBackground::GetPickingPoint) and the
// actor walks to it, sending CG_MOVE FUNC_MOVE, then FUNC_WAIT on arrival.
const size_t moves_before = server ? server->Events().size() : 0;
if (main_instance)
main_instance->NEW_GetPixelPosition(&start);
PythonBoot::UIMouseButton(1, true, 400, 220);
pump_frames(6);
PythonBoot::UIMouseButton(1, false, 400, 220);
CHECK(main_instance && pump_until(3, [&] { return walked() > 100.0f; }));
CHECK(main_instance && pump_until(10, [&] { return !main_instance->IsWalking(); }));
std::printf("port_login_flow_test: click walked %.0f cm\n", main_instance ? walked() : 0.0f);
if (server)
{
auto click_moves = [&] {
std::vector<std::string> out;
const auto events = server->Events();
for (size_t i = moves_before; i < events.size(); ++i)
if (events[i].rfind("game2:move ", 0) == 0)
out.push_back(events[i]);
return out;
};
CHECK(pump_until(2, [&] { auto m = click_moves(); return m.size() >= 2 && m.back().rfind("game2:move 0 ", 0) == 0; }));
const auto m = click_moves();
CHECK(!m.empty() && m.front().rfind("game2:move 1 0 ", 0) == 0);
for (const auto& e : m)
std::printf("port_login_flow_test: %s\n", e.c_str());
}
}
// 9. (批次 4-b) Combat: a left click on the stray dog reaches CPythonPlayer::__OnClickActor, which targets it
// (CG_TARGET → GC_TARGET, uitarget shows the HP board) and walks into range to attack. Each attack frame's
// hit (CPythonPlayerEventHandler::OnHit) sends CG_ATTACK; the fake server answers GC_DAMAGE_INFO + GC_TARGET,
// and GC_DEAD on the third hit.
if (server)
{
CInstanceBase* mob = CPythonCharacterManager::Instance().GetInstancePtr(FakeLoginServer::kMobVID);
CHECK(mob != nullptr);
if (mob)
{
CHECK(!mob->IsDead());
// Project the dog's body with RenderGame's perspective and the current camera (after the frame the
// graphic state holds the UI's ortho projection).
auto dog_on_screen = [&] {
UI::CWindowManager& wm = UI::CWindowManager::Instance();
D3DXMATRIX proj, identity;
D3DXMatrixPerspectiveFovRH(&proj, D3DXToRadian(30.0f), wm.GetAspect(), 100.0f,
CPythonBackground::Instance().GetFarClip());
D3DXMatrixIdentity(&identity);
D3DVIEWPORT8 viewport = {0, 0, (DWORD) wm.GetScreenWidth(), (DWORD) wm.GetScreenHeight(), 0.0f, 1.0f};
const D3DXVECTOR3 body = mob->GetGraphicThingInstanceRef().GetPosition() + D3DXVECTOR3(0.0f, 0.0f, 40.0f);
D3DXVECTOR3 screen;
D3DXVec3Project(&screen, &body, &viewport, &proj,
&CCameraManager::Instance().GetCurrentCamera()->GetViewMatrix(), &identity);
return std::make_pair((int) screen.x, (int) screen.y);
};
auto [sx, sy] = dog_on_screen();
std::printf("port_login_flow_test: stray dog on screen at %d %d\n", sx, sy);
PythonBoot::UIMouseMove(sx, sy);
pump_frames(6);
// game.GameWindow.OnMouseLeftButtonDown → player.SetMouseState(MBT_LEFT, MBS_PRESS): MBF_SMART on an
// actor is __OnPressActor, which walks into range (__ReserveClickActor) and attacks while the button is
// held; the cursor follows the dog as the camera follows the actor, as a player's hand would.
PythonBoot::UIMouseButton(1, true, sx, sy);
const std::string mob_vid = std::to_string(FakeLoginServer::kMobVID);
CHECK(pump_until(2, [&] { return server->Has("game2:target " + mob_vid); }));
CHECK(pump_until(2, [] { return py_true("_stream.curPhaseWindow.targetBoard.IsShow()"); }));
CHECK(pump_until(10, [&] {
std::tie(sx, sy) = dog_on_screen();
PythonBoot::UIMouseMove(sx, sy);
return mob->IsDead();
}));
PythonBoot::UIMouseButton(1, false, sx, sy);
size_t attacks = 0;
for (const auto& e : server->Events())
if (e == "game2:attack 0 " + mob_vid)
++attacks;
std::printf("port_login_flow_test: %zu attack(s) until the stray dog died\n", attacks);
CHECK(attacks >= (size_t) FakeLoginServer::kMobHits);
// Camera mouse wheel zoom test:
// Test zooming in (Wheel UP, positive delta) and zooming out (Wheel DOWN, negative delta).
{
CCamera* pkCmrCur = CCameraManager::Instance().GetCurrentCamera();
CHECK(pkCmrCur != nullptr);
if (pkCmrCur)
{
const float initial_dist = pkCmrCur->GetDistance();
std::printf("port_login_flow_test: initial camera distance = %f\n", initial_dist);
// Zoom IN: positive wheel delta
PythonBoot::UIMouseWheel(120);
pump_frames(6);
const float zoomed_in_dist = pkCmrCur->GetDistance();
std::printf("port_login_flow_test: zoomed in camera distance = %f\n", zoomed_in_dist);
CHECK(zoomed_in_dist < initial_dist);
// Zoom OUT: negative wheel delta
PythonBoot::UIMouseWheel(-240);
pump_frames(6);
const float zoomed_out_dist = pkCmrCur->GetDistance();
std::printf("port_login_flow_test: zoomed out camera distance = %f\n", zoomed_out_dist);
CHECK(zoomed_out_dist > zoomed_in_dist);
}
}
// P-grade skill test: verify P-grade skill (geomgyeong / Aura of the Sword) uses Grade 3 motion (geomgyeong_4.msa),
// spawns effect instances (geom_4_badak.mse, geom_4_sword_making.mse), and renders them.
{
CPythonPlayer& rkPlayer = CPythonPlayer::Instance();
rkPlayer.SetStatus(POINT_SP, 1000);
rkPlayer.SetStatus(POINT_MAX_SP, 1000);
rkPlayer.SetStatus(POINT_HP, 1000);
rkPlayer.SetStatus(POINT_MAX_HP, 1000);
CInstanceBase* pkInstMain = rkPlayer.NEW_GetMainActorPtr();
CHECK(pkInstMain != nullptr);
if (pkInstMain)
{
pkInstMain->ChangeWeapon(19);
rkPlayer.SetSkill(4, 4); // slot 4 = geomgyeong
rkPlayer.SetSkillLevel_(4, 3, 40); // Grade 3 (P), Level 40
CHECK(rkPlayer.GetSkillGrade(4) == 3);
rkPlayer.ClickSkillSlot(4);
CHECK(pkInstMain->IsUsingSkill());
bool has_effects = false;
for (int step = 0; step < 10; ++step)
{
pump_frames(6);
if (CEffectInstance::GetRenderingEffectCount() > 0)
has_effects = true;
}
CHECK(has_effects);
}
}
}
}
// 10. (批次 4-c) NPC shop: a click on the general store walks up to it (__OnClickActor) and sends CG_ON_CLICK;
// the fake server's GC_SHOP START fills CPythonShop and game.StartShop opens uishop.ShopDialog next to the
// inventory. A right click on shelf slot 0 buys (uishop.UnselectItemSlot → net.SendShopBuyPacket); the
// potion lands in inventory cell 0 and the gold drops. Picking it up in the inventory and dropping it on an
// empty shelf slot sells it (SelectEmptySlot → SellAttachedItem → SendShopSellPacketNew); ESC closes the
// shop (CG_SHOP END → GC_SHOP END).
if (server)
{
CInstanceBase* keeper = CPythonCharacterManager::Instance().GetInstancePtr(FakeLoginServer::kShopVID);
CHECK(keeper != nullptr);
if (keeper)
{
auto keeper_on_screen = [&] {
UI::CWindowManager& wm = UI::CWindowManager::Instance();
D3DXMATRIX proj, identity;
D3DXMatrixPerspectiveFovRH(&proj, D3DXToRadian(30.0f), wm.GetAspect(), 100.0f,
CPythonBackground::Instance().GetFarClip());
D3DXMatrixIdentity(&identity);
D3DVIEWPORT8 viewport = {0, 0, (DWORD) wm.GetScreenWidth(), (DWORD) wm.GetScreenHeight(), 0.0f, 1.0f};
const D3DXVECTOR3 body = keeper->GetGraphicThingInstanceRef().GetPosition() + D3DXVECTOR3(0.0f, 0.0f, 100.0f);
D3DXVECTOR3 screen;
D3DXVec3Project(&screen, &body, &viewport, &proj,
&CCameraManager::Instance().GetCurrentCamera()->GetViewMatrix(), &identity);
return std::make_pair((int) screen.x, (int) screen.y);
};
auto [kx, ky] = keeper_on_screen();
std::printf("port_login_flow_test: shopkeeper on screen at %d %d\n", kx, ky);
PythonBoot::UIMouseMove(kx, ky);
pump_frames(6);
PythonBoot::UIMouseButton(1, true, kx, ky);
pump_frames(6);
PythonBoot::UIMouseButton(1, false, kx, ky);
CHECK(pump_until(5, [&] { return server->Has("game2:click " + std::to_string(FakeLoginServer::kShopVID)); }));
CHECK(pump_until(2, [] { return py_true("__import__('shop').IsOpen() and _stream.curPhaseWindow.interface.dlgShop.IsShow()"); }));
CHECK(py("import shop\n"
"assert shop.GetItemID(0) == " + std::to_string(FakeLoginServer::kShopItemVnum) + ", shop.GetItemID(0)\n"
"assert shop.GetItemPrice(0) == " + std::to_string(FakeLoginServer::kShopItemPrice) + "\n"
"assert shop.IsPrivateShop() == 0\n"
"assert _stream.curPhaseWindow.interface.wndInventory.IsShow()"));
// Slot 0 is the top-left cell of each grid.
auto slot0 = [](const char* grid) {
std::string xy;
CHECK(PythonBoot::Evaluate((std::string("'%d %d' % _stream.curPhaseWindow.interface.") + grid + ".GetGlobalPosition()").c_str(), &xy, nullptr));
int x = 0, y = 0;
std::sscanf(xy.c_str(), "%d %d", &x, &y);
return std::make_pair(x + 16, y + 16);
};
auto click = [](int button, std::pair<int, int> at) {
PythonBoot::UIMouseMove(at.first, at.second);
pump_until(0.05, [] { return false; });
PythonBoot::UIMouseButton(button, true, at.first, at.second);
pump_until(0.05, [] { return false; });
PythonBoot::UIMouseButton(button, false, at.first, at.second);
pump_until(0.05, [] { return false; });
};
const auto shelf0 = slot0("dlgShop.itemSlotWindow");
const auto bag0 = slot0("wndInventory.wndItem");
std::printf("port_login_flow_test: shelf slot at %d %d, bag slot at %d %d\n", shelf0.first, shelf0.second, bag0.first, bag0.second);
click(2, shelf0);
CHECK(pump_until(2, [&] { return server->Has("game2:shop 1 1 0"); }));
const std::string bought_gold = std::to_string(FakeLoginServer::kStartGold - (int) FakeLoginServer::kShopItemPrice);
CHECK(pump_until(2, [&] {
return py_true(("__import__('player').GetItemIndex(0) == " + std::to_string(FakeLoginServer::kShopItemVnum) +
" and __import__('player').GetElk() == " + bought_gold).c_str());
}));
pump_ui_refresh();
click(1, bag0);
CHECK(py_true("__import__('mouseModule').mouseController.isAttached()"));
click(1, std::make_pair(shelf0.first + 32, shelf0.second));
CHECK(pump_until(2, [&] { return server->Has("game2:shop 3 0 1"); }));
const std::string sold_gold = std::to_string(FakeLoginServer::kStartGold - (int) FakeLoginServer::kShopItemPrice + FakeLoginServer::kShopSellPrice);
CHECK(pump_until(2, [&] {
return py_true(("__import__('player').GetItemIndex(0) == 0 and __import__('player').GetElk() == " + sold_gold).c_str());
}));
PythonBoot::UIKey(DIK_ESCAPE, true);
PythonBoot::UIKey(DIK_ESCAPE, false);
CHECK(pump_until(2, [&] { return server->Has("game2:shop 0"); }));
CHECK(pump_until(2, [] { return py_true("not __import__('shop').IsOpen() and not _stream.curPhaseWindow.interface.dlgShop.IsShow()"); }));
}
}
// Quest dialogue (CPythonEventManager + CPythonQuest + event module): clicking the quest NPC (kQuestVID)
// triggers CHARACTER::OnClick → CQuestManager::Click which sends GC_SCRIPT. The client parses the script
// through CPythonEventManager / parser.cpp and opens uiQuest.QuestDialog.
// 1. Initial page: say + select ([QUESTION 1;Yes|2;No]).
// Clicking "Yes" (answer 0) sends CG_SCRIPT_ANSWER 0.
// 2. Second page: say + [NEXT].
// Clicking Next sends CG_SCRIPT_ANSWER 254 (> 250).
// 3. Final page: say + [DONE].
// Clicking OK closes the dialogue.
if (server)
{
py("if _stream.curPhaseWindow.interface.wndInventory.IsShow(): _stream.curPhaseWindow.interface.wndInventory.Hide()");
CInstanceBase* elder = CPythonCharacterManager::Instance().GetInstancePtr(FakeLoginServer::kQuestVID);
CHECK(elder != nullptr);
if (elder)
{
auto elder_on_screen = [&] {
UI::CWindowManager& wm = UI::CWindowManager::Instance();
D3DXMATRIX proj, identity;
D3DXMatrixPerspectiveFovRH(&proj, D3DXToRadian(30.0f), wm.GetAspect(), 100.0f,
CPythonBackground::Instance().GetFarClip());
D3DXMatrixIdentity(&identity);
D3DVIEWPORT8 viewport = {0, 0, (DWORD) wm.GetScreenWidth(), (DWORD) wm.GetScreenHeight(), 0.0f, 1.0f};
const D3DXVECTOR3 body = elder->GetGraphicThingInstanceRef().GetPosition() + D3DXVECTOR3(0.0f, 0.0f, 100.0f);
D3DXVECTOR3 screen;
D3DXVec3Project(&screen, &body, &viewport, &proj,
&CCameraManager::Instance().GetCurrentCamera()->GetViewMatrix(), &identity);
return std::make_pair((int) screen.x, (int) screen.y);
};
// CPythonPlayer::__SendClickActorPacket enforces a 1000ms TCP rate-limit between NPC clicks
pump_until(1.0, [] { return false; });
auto [ex, ey] = elder_on_screen();
std::printf("port_login_flow_test: quest elder on screen at %d %d\n", ex, ey);
auto click_elder = [&] {
std::tie(ex, ey) = elder_on_screen();
PythonBoot::UIMouseMove(ex, ey);
pump_until(0.05, [] { return false; });
PythonBoot::UIMouseButton(1, true, ex, ey);
pump_until(0.05, [] { return false; });
PythonBoot::UIMouseButton(1, false, ex, ey);
};
click_elder();
CHECK(pump_until(5, [&] {
if (server->Has("game2:click " + std::to_string(FakeLoginServer::kQuestVID)))
return true;
click_elder();
return false;
}));
CHECK(pump_until(2, [] {
return py_true("len(_stream.curPhaseWindow.interface.wndQuestWindow) > 0 and "
"list(_stream.curPhaseWindow.interface.wndQuestWindow.values())[-1].btnAnswer is not None and "
"len(list(_stream.curPhaseWindow.interface.wndQuestWindow.values())[-1].btnAnswer) >= 2");
}));
auto press_enter = [] {
PythonBoot::UIKey(DIK_RETURN, true);
pump_until(0.05, [] { return false; });
PythonBoot::UIKey(DIK_RETURN, false);
pump_until(0.05, [] { return false; });
};
// Page 1: DIK_RETURN triggers focusBtn.CallEvent() for choice 0 ("Yes").
press_enter();
CHECK(pump_until(2, [&] { return server->Has("game2:answer 0"); }));
// Page 2: [NEXT]
CHECK(pump_until(2, [] {
return py_true("len(_stream.curPhaseWindow.interface.wndQuestWindow) > 0 and "
"list(_stream.curPhaseWindow.interface.wndQuestWindow.values())[-1].btnNext is not None");
}));
// Page 2: DIK_RETURN on Next button triggers OnPressEscapeKey() which selects 254 and closes.
press_enter();
CHECK(pump_until(2, [&] { return server->Has("game2:answer 254"); }));
// Page 3: [DONE]
CHECK(pump_until(2, [] {
return py_true("len(_stream.curPhaseWindow.interface.wndQuestWindow) > 0 and "
"list(_stream.curPhaseWindow.interface.wndQuestWindow.values())[-1].btnNext is not None and "
"getattr(list(_stream.curPhaseWindow.interface.wndQuestWindow.values())[-1], 'nextButtonType', None) == 1");
}));
// Page 3: DIK_RETURN on Done button closes the dialogue without further packets.
press_enter();
CHECK(pump_until(2, [] {
return py_true("len(_stream.curPhaseWindow.interface.wndQuestWindow) == 0");
}));
}
}
// Safebox (CPythonSafeBox + safebox module): clicking the storekeeper (kSafeboxVID)
// triggers CHARACTER::OnClick → ShowMeSafeboxPassword server command. The client opens
// PasswordDialog. Typing the password sends /safebox_password <pass>, opening SafeboxWindow.
// We verify:
// 1. Click Storekeeper, receive password dialog.
// 2. Press DIK_RETURN to submit password, server answers GC_SAFEBOX_SIZE (1 page = 45 slots),
// GC_SAFEBOX_MONEY_CHANGE (500) and initial item (kSafeboxItemVnum = 10 in slot 0).
// 3. Verify SafeboxWindow is open, safebox.GetCurrentSafeboxSize() == 45, safebox.GetItemID(0) == 10.
// 4. Checkout: drag safebox slot 0 to inventory slot 0.
// Server answers GC_SAFEBOX_DEL 0 and GC_ITEM_SET (inventory cell 0 = 10).
// Verify safebox slot 0 is empty (0) and player inventory slot 0 has 10.
// 5. Checkin: drag inventory slot 0 to safebox slot 0.
// Server answers GC_ITEM_DEL (inventory cell 0) and GC_SAFEBOX_SET (safebox cell 0 = 10).
// Verify player inventory slot 0 is empty (0) and safebox slot 0 has 10.
// 6. Close safebox via ESC: client sends /safebox_close, server replies CloseSafebox,
// SafeboxWindow closes.
if (server)
{
CInstanceBase* storekeeper = CPythonCharacterManager::Instance().GetInstancePtr(FakeLoginServer::kSafeboxVID);
CHECK(storekeeper != nullptr);
if (storekeeper)
{
auto storekeeper_on_screen = [&] {
UI::CWindowManager& wm = UI::CWindowManager::Instance();
D3DXMATRIX proj, identity;
D3DXMatrixPerspectiveFovRH(&proj, D3DXToRadian(30.0f), wm.GetAspect(), 100.0f,
CPythonBackground::Instance().GetFarClip());
D3DXMatrixIdentity(&identity);
D3DVIEWPORT8 viewport = {0, 0, (DWORD) wm.GetScreenWidth(), (DWORD) wm.GetScreenHeight(), 0.0f, 1.0f};
const D3DXVECTOR3 body = storekeeper->GetGraphicThingInstanceRef().GetPosition() + D3DXVECTOR3(0.0f, 0.0f, 100.0f);
D3DXVECTOR3 screen;
D3DXVec3Project(&screen, &body, &viewport, &proj,
&CCameraManager::Instance().GetCurrentCamera()->GetViewMatrix(), &identity);
return std::make_pair((int) screen.x, (int) screen.y);
};
auto [sx, sy] = storekeeper_on_screen();
std::printf("port_login_flow_test: storekeeper on screen at %d %d\n", sx, sy);
PythonBoot::UIMouseMove(sx, sy);
pump_frames(6);
PythonBoot::UIMouseButton(1, true, sx, sy);
pump_frames(6);
PythonBoot::UIMouseButton(1, false, sx, sy);
CHECK(pump_until(5, [&] { return server->Has("game2:click " + std::to_string(FakeLoginServer::kSafeboxVID)); }));
CHECK(pump_until(2, [] {
return py_true("_stream.curPhaseWindow.interface.dlgPassword.IsShow()");
}));
// Submit password dialog with VK_RETURN (IME return on focused edit line)
PythonBoot::UIChar(VK_RETURN);
CHECK(pump_until(2, [&] { return server->Has("game2:safebox_password "); }));
CHECK(pump_until(2, [] {
return py_true("_stream.curPhaseWindow.interface.wndSafebox.IsShow()");
}));
CHECK(pump_until(2, [] {
return py_true(("__import__('safebox').GetCurrentSafeboxSize() == 45 and "
"__import__('safebox').GetItemID(0) == " + std::to_string(FakeLoginServer::kSafeboxItemVnum) + " and "
"__import__('safebox').GetMoney() == " + std::to_string(FakeLoginServer::kSafeboxMoney)).c_str());
}));
// Ensure inventory is visible for slot drag-and-drop
py("_stream.curPhaseWindow.interface.wndInventory.Show()");
CHECK(pump_until(2, [] {
return py_true("_stream.curPhaseWindow.interface.wndInventory.IsShow()");
}));
auto slot0 = [](const char* grid) {
std::string xy;
CHECK(PythonBoot::Evaluate((std::string("'%d %d' % _stream.curPhaseWindow.interface.") + grid + ".GetGlobalPosition()").c_str(), &xy, nullptr));
int x = 0, y = 0;
std::sscanf(xy.c_str(), "%d %d", &x, &y);
return std::make_pair(x + 16, y + 16);
};
auto click = [](int btn, std::pair<int, int> pos) {
PythonBoot::UIMouseMove(pos.first, pos.second);
pump_until(0.05, [] { return false; });
PythonBoot::UIMouseButton(btn, true, pos.first, pos.second);
pump_until(0.05, [] { return false; });
PythonBoot::UIMouseButton(btn, false, pos.first, pos.second);
pump_until(0.05, [] { return false; });
};
const auto safe0 = slot0("wndSafebox.wndItem");
const auto bag0 = slot0("wndInventory.wndItem");
std::printf("port_login_flow_test: safebox slot 0 at %d %d, bag slot 0 at %d %d\n", safe0.first, safe0.second, bag0.first, bag0.second);
// Checkout: drag from safebox slot 0 to inventory slot 0
pump_ui_refresh();
click(1, safe0);
CHECK(py_true("__import__('mouseModule').mouseController.isAttached()"));
click(1, bag0);
CHECK(pump_until(2, [&] { return server->Has("game2:safebox_checkout 0 -> 0"); }));
CHECK(pump_until(2, [&] {
return py_true(("__import__('safebox').GetItemID(0) == 0 and "
"__import__('player').GetItemIndex(0) == " + std::to_string(FakeLoginServer::kSafeboxItemVnum)).c_str());
}));
// Checkin: drag from inventory slot 0 back to safebox slot 0
pump_ui_refresh();
click(1, bag0);
CHECK(py_true("__import__('mouseModule').mouseController.isAttached()"));
click(1, safe0);
CHECK(pump_until(2, [&] { return server->Has("game2:safebox_checkin 0 -> 0"); }));
CHECK(pump_until(2, [&] {
return py_true(("__import__('player').GetItemIndex(0) == 0 and "
"__import__('safebox').GetItemID(0) == " + std::to_string(FakeLoginServer::kSafeboxItemVnum)).c_str());
}));
// Close Safebox via ESC
PythonBoot::UIKey(DIK_ESCAPE, true);
pump_until(0.05, [] { return false; });
PythonBoot::UIKey(DIK_ESCAPE, false);
pump_until(0.05, [] { return false; });
CHECK(pump_until(2, [&] { return server->Has("game2:safebox_close"); }));
CHECK(pump_until(2, [] {
return py_true("not _stream.curPhaseWindow.interface.wndSafebox.IsShow()");
}));
}
}
// 13. (批次 4-f) Messenger / Guild / GuildMark:
// The fake server sent friend list ("TestFriend") and guild info/name ("TestGuild", ID 1)
// during the entergame burst.
// Verify:
// - CPythonMessenger / messenger module: IsFriendByName, RemoveFriend
// - CPythonGuild / guild module: IsGuildEnable, GetGuildID, GetGuildName, GetGuildMoney, HasGuildLand
// - CGuildMarkManager: AddMarkIDByGuildID, GetMarkID, GuildIDToMarkID, GetMarkIndexByMarkID
if (server)
{
CHECK(py("import messenger, guild"));
// Messenger
CHECK(CPythonMessenger::Instance().IsFriendByName(FakeLoginServer::kFriendName));
CHECK(py_true("messenger.IsFriendByName('TestFriend') == 1"));
CHECK(py_true("messenger.IsFriendByName('NonExistent') == 0"));
// Guild
CHECK(CPythonGuild::Instance().IsGuildEnable());
CHECK(CPythonGuild::Instance().GetGuildID() == FakeLoginServer::kGuildID);
std::string guild_name;
CHECK(CPythonGuild::Instance().GetGuildName(FakeLoginServer::kGuildID, &guild_name) && guild_name == FakeLoginServer::kGuildName);
CHECK(py_true("guild.IsGuildEnable() == 1"));
CHECK(py_true("guild.GetGuildID() == 1"));
CHECK(py_true("guild.GetGuildName() == 'TestGuild'"));
CHECK(py_true("guild.GetGuildName(1) == 'TestGuild'"));
CHECK(py_true("guild.GetGuildMoney() == 123456"));
CHECK(py_true("guild.HasGuildLand() == 1"));
// GuildMark
CGuildMarkManager::Instance().AddMarkIDByGuildID(FakeLoginServer::kGuildID, 42);
CHECK(CGuildMarkManager::Instance().GetMarkID(FakeLoginServer::kGuildID) == 42);
CHECK(py_true("guild.GuildIDToMarkID(1) == 42"));
CHECK(py_true("guild.GetMarkIndexByMarkID(42) == 42"));
// Messenger RemoveFriend
CHECK(py("import messenger\nmessenger.RemoveFriend('TestFriend')"));
CHECK(!CPythonMessenger::Instance().IsFriendByName(FakeLoginServer::kFriendName));
CHECK(py_true("messenger.IsFriendByName('TestFriend') == 0"));
}
// 14. (批次 4-g) System Settings & Combat Damage Floating Text (CPythonSystem / systemSetting):
// Verify:
// - systemSetting module imported cleanly without __port_stub__
// - CPythonSystem resolution enumeration (via CDummyDirect3D8): GetResolutionCount, GetResolution
// - Width, Height, BPP
// - Combat floating damage flag: IsShowDamage, SetShowDamageFlag, CPythonSystem::Instance().IsShowDamage()
// - Other system setting flags: IsViewChat, SetViewChatFlag, IsAlwaysShowName, SetAlwaysShowNameFlag, IsShowSalesText, SetShowSalesTextFlag
// - Music/Sound volume: GetMusicVolume, SetMusicVolume, GetSoundVolume, SetSoundVolumef
// - Config save & load: GetConfig, SaveConfig
{
CHECK(py("import systemSetting"));
CHECK(py_true("not hasattr(systemSetting, '__port_stub__')"));
// Resolution enumeration & screen size
CHECK(CPythonSystem::Instance().GetResolutionCount() > 0);
CHECK(py_true("systemSetting.GetResolutionCount() > 0"));
CHECK(py_true("systemSetting.GetWidth() > 0 and systemSetting.GetHeight() > 0"));
CHECK(py_true("len(systemSetting.GetResolution(0)) == 3"));
// Combat damage text flag
CPythonSystem::Instance().SetShowDamageFlag(true);
CHECK(CPythonSystem::Instance().IsShowDamage());
CHECK(py_true("systemSetting.IsShowDamage() == 1"));
CHECK(py("systemSetting.SetShowDamageFlag(0)"));
CHECK(!CPythonSystem::Instance().IsShowDamage());
CHECK(py_true("systemSetting.IsShowDamage() == 0"));
CHECK(py("systemSetting.SetShowDamageFlag(1)"));
CHECK(CPythonSystem::Instance().IsShowDamage());
CHECK(py_true("systemSetting.IsShowDamage() == 1"));
// Chat view flag
CHECK(py("systemSetting.SetViewChatFlag(0)"));
CHECK(!CPythonSystem::Instance().IsViewChat());
CHECK(py_true("systemSetting.IsViewChat() == 0"));
CHECK(py("systemSetting.SetViewChatFlag(1)"));
CHECK(CPythonSystem::Instance().IsViewChat());
CHECK(py_true("systemSetting.IsViewChat() == 1"));
// Always show name flag
CHECK(py("systemSetting.SetAlwaysShowNameFlag(0)"));
CHECK(!CPythonSystem::Instance().IsAlwaysShowName());
CHECK(py_true("systemSetting.IsAlwaysShowName() == 0"));
CHECK(py("systemSetting.SetAlwaysShowNameFlag(1)"));
CHECK(CPythonSystem::Instance().IsAlwaysShowName());
CHECK(py_true("systemSetting.IsAlwaysShowName() == 1"));
// Sales text flag
CHECK(py("systemSetting.SetShowSalesTextFlag(0)"));
CHECK(!CPythonSystem::Instance().IsShowSalesText());
CHECK(py_true("systemSetting.IsShowSalesText() == 0"));
CHECK(py("systemSetting.SetShowSalesTextFlag(1)"));
CHECK(CPythonSystem::Instance().IsShowSalesText());
CHECK(py_true("systemSetting.IsShowSalesText() == 1"));
// Sound & music volume
CHECK(py("systemSetting.SetMusicVolume(0.5)"));
CHECK(CPythonSystem::Instance().GetMusicVolume() == 0.5f);
CHECK(py_true("abs(systemSetting.GetMusicVolume() - 0.5) < 0.01"));
CHECK(py("systemSetting.SetSoundVolumef(0.8)"));
CHECK(py_true("systemSetting.GetSoundVolume() >= 0"));
// Config tuple: (res_index, freq_index, is_software_cursor, shadow_level, view_chat, always_show_name, show_damage, show_sales_text)
CHECK(py_true("len(systemSetting.GetConfig()) == 8"));
CHECK(py_true("systemSetting.SaveConfig() == 1"));
unlink("metin2.cfg");
}
// 15. Verify audio & sound manager module (PythonSoundManagerModule / snd)
// - Module existence and no stub flag: import snd; not hasattr(snd, '__port_stub__')
// - Music volume controls: SetMusicVolume, SetMusicVolumef
// - Sound volume controls: SetSoundVolume, SetSoundVolumef
// - Scale controls: SetSoundScale, SetAmbienceSoundScale
// - Sound playback calls: PlaySound, PlaySound3D, StopAllSound
// - Music playback and fading: PlayMusic, FadeInMusic, FadeLimitOutMusic, FadeOutMusic, FadeOutAllMusic
// - CSoundManager C++ singleton state reflection
{
CHECK(py("import snd"));
CHECK(py_true("not hasattr(snd, '__port_stub__')"));
// Music volume
CHECK(py("snd.SetMusicVolume(0.7)"));
CHECK(py("snd.SetMusicVolumef(0.7)"));
CHECK(std::abs(CSoundManager::Instance().GetMusicVolume() - 0.7f) < 0.05f);
// Sound volume
CHECK(py("snd.SetSoundVolume(3)"));
CHECK(py("snd.SetSoundVolumef(0.6)"));
CHECK(CSoundManager::Instance().GetSoundVolume() > 0.0f);
const float savedMusic = CSoundManager::Instance().GetMusicVolume();
const float savedSound = CSoundManager::Instance().GetSoundVolume();
CSoundManager::Instance().SaveVolume();
CSoundManager::Instance().SaveVolume();
CHECK(CSoundManager::Instance().GetMusicVolume() == 0.0f);
CHECK(CSoundManager::Instance().GetSoundVolume() == 0.0f);
CSoundManager::Instance().RestoreVolume();
CHECK(std::abs(CSoundManager::Instance().GetMusicVolume() - savedMusic) < 0.001f);
CHECK(std::abs(CSoundManager::Instance().GetSoundVolume() - savedSound) < 0.001f);
// Scales
CHECK(py("snd.SetSoundScale(250.0)"));
CHECK(CSoundManager::Instance().GetSoundScale() == 250.0f);
CHECK(py("snd.SetAmbienceSoundScale(1500.0)"));
// Playback APIs execute cleanly
CHECK(py("snd.PlaySound('sound/ui/click.wav')"));
CHECK(py("snd.PlaySound3D(100.0, 200.0, 0.0, 'sound/ui/click.wav')"));
CHECK(py("snd.PlayMusic('BGM/login_window.mp3')"));
CHECK(py("snd.FadeInMusic('BGM/login_window.mp3')"));
CHECK(py("snd.FadeLimitOutMusic('BGM/login_window.mp3', 0.2)"));
CHECK(py("snd.FadeOutMusic('BGM/login_window.mp3')"));
CHECK(py("snd.FadeOutAllMusic()"));
CHECK(py("snd.StopAllSound()"));
}
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 && e.find(":move ") == std::string::npos &&
e.find(":pickup ") == std::string::npos &&
e.find(":exchange ") == std::string::npos && e.find(":chat ") == std::string::npos &&
e.find(":target ") == std::string::npos && e.find(":attack ") == std::string::npos &&
e.find(":click ") == std::string::npos && e.find(":shop ") == std::string::npos &&
e.find(":answer ") == std::string::npos && e.find(":safebox_") == std::string::npos)
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;
}