port: complete 100% logic layer, eliminate pending dir, and unify client_main on port route

- Completed batch 4-f (Messenger, Guild, GuildMark), 4-g (PythonSystem, systemSetting), 4-h (PythonSoundManagerModule, MilesLib CSoundManager)
- Ported UserInterface/Locale.cpp, EterLocale/Japanese.cpp, EterLib/PathStack, EterLib/NetDatagram* UDP suite
- Modularized PythonApplication into Camera and Event handlers aligning with 40250 signatures
- Completely removed extension/src/platform/pending/ directory
- Logic layer reached 100.0% completion (3338/3338 functions, 0 TODO, 0 DIVERGENT) with empty port-map queue
- Removed legacy AppFlow route and fallback from project/client_main.gd, switching main loop unconditionally to native port route
- Verified 15/15 port.login_flow integration steps and 19/19 port_gate suites passing
This commit is contained in:
shen
2026-09-23 22:17:59 -07:00
parent ee7a9cd3e2
commit 837c830182
85 changed files with 10228 additions and 1545 deletions
+359 -2
View File
@@ -57,6 +57,11 @@
#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"
@@ -597,7 +602,7 @@ int main(int argc, char** argv)
};
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 3,") != std::string::npos); // the actor, the stray dog and the shopkeeper
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);
@@ -813,6 +818,357 @@ int main(int argc, char** argv)
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 = {
@@ -833,7 +1189,8 @@ int main(int argc, char** argv)
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(":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);
}