1223 lines
60 KiB
C++
1223 lines
60 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 "EterLib/Camera.h"
|
|
#include "GameLib/ItemManager.h"
|
|
#include "UserInterface/PythonItem.h"
|
|
#include "UserInterface/StdAfx.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();
|
|
}
|
|
|
|
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_until(0.1, [] { return false; });
|
|
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) == ''"));
|
|
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, 520);
|
|
pump_until(0.1, [] { return false; });
|
|
PythonBoot::UIMouseButton(1, false, 400, 520);
|
|
CHECK(main_instance && pump_until(3, [&] { return walked() > 100.0f; }));
|
|
CHECK(main_instance && pump_until(5, [&] { 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_until(0.1, [] { return false; });
|
|
// 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);
|
|
}
|
|
}
|
|
// 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_until(0.1, [] { return false; });
|
|
PythonBoot::UIMouseButton(1, true, kx, ky);
|
|
pump_until(0.1, [] { return false; });
|
|
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());
|
|
}));
|
|
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_until(0.1, [] { return false; });
|
|
PythonBoot::UIMouseButton(1, true, sx, sy);
|
|
pump_until(0.1, [] { return false; });
|
|
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
|
|
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
|
|
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);
|
|
|
|
// 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;
|
|
}
|