From 400973da113c66ef052bda527d209630ad776f77 Mon Sep 17 00:00:00 2001 From: shen <> Date: Thu, 24 Sep 2026 00:31:51 -0700 Subject: [PATCH] Fix reached macOS UI primitives and audio fade behavior --- audit/history.jsonl | 1 + audit/port-map/EterLib/GrpScreen.cpp.json | 14 +- audit/port-map/MilesLib/SoundManager.cpp.json | 163 ++++++++++++++++++ audit/reports/platform-runtime-trace.md | 4 +- extension/src/platform/EterLib/GrpScreen.cpp | 16 +- .../src/platform/EterLib/UIRenderCommands.h | 3 +- .../src/platform/MilesLib/AudioCommands.h | 3 + .../src/platform/MilesLib/SoundManager.cpp | 37 +++- extension/src/python_host_node.cpp | 10 +- extension/tests/port_app_loop_test.cpp | 23 +++ extension/tests/port_login_flow_test.cpp | 9 + project/audio_driver.gd | 159 +++++++++++------ project/audio_driver_test.gd | 47 +++++ project/python_ui_surface.gd | 16 ++ 14 files changed, 439 insertions(+), 66 deletions(-) create mode 100644 audit/port-map/MilesLib/SoundManager.cpp.json diff --git a/audit/history.jsonl b/audit/history.jsonl index 2217aca1..0d891b4a 100644 --- a/audit/history.jsonl +++ b/audit/history.jsonl @@ -554,3 +554,4 @@ {"time": "2026-09-25T02:30:00Z", "event": "port", "unit": "EterLib/NetDatagram.cpp, NetDatagramReceiver.cpp, NetDatagramSender.cpp", "detail": "Ported 40250 UDP datagram suite (24 functions) verbatim into extension/src/port/EterLib/. Added mt_winsock_recvfrom wrapper in winsock.h shim handling POSIX socklen_t* to int* conversion. Updated LAYER_PATTERNS in port_map.py for MapOutdoorCharacterShadow.cpp. Logic layer reached 100.0% completion (3338/3338 functions done, 0 TODO, 0 DIVERGENT). Queue empty.", "tests": ["port.login_flow (15/15 PASS, 26.55s)", "script/port_gate.sh macos PASS (19/19 suites)", "port_map.py check: 0 errors"]} {"time": "2026-09-25T03:00:00Z", "event": "route_cleanup", "unit": "client_main legacy elimination", "detail": "Removed legacy AppFlow route, preloads (AppFlow, LiveSmokeTest, PlayableLiveTest, NetTrace), and MT_PORT_PATH fallback from project/client_main.gd. client_main.tscn now unconditionally boots the native 40250 port route (PythonUISurface + Python3DSurface -> system.py). Passing MT_PORT_PATH=legacy emits a deprecation warning and continues on port route.", "tests": ["client_main.tscn verified on port route and legacy warning", "script/port_gate.sh macos PASS (19/19 suites)", "port_map.py check: 0 errors"]} {"time": "2026-09-24T07:07:19Z", "event": "port_verification_fix", "unit": "Client/root/*.py + UserInterface/PythonApplication platform adapter + MilesLib/SoundManager platform adapter", "detail": "Corrected 74 RUN_AS_IS mappings from unrelated assets/root to 40250 root.epk, added per-script byte/hash verification and stricter port-map validation. Restored GC_TIME server clock semantics and routed packed sound bytes to Godot without applying volume twice; replaced deleted legacy asset-gate tests with active host/audio tests. Qualified MT_PLATFORM_STUB_TRACE exposed 93 called stubs in offline login_flow; SetGlobalCenterPosition was implemented from the original call order, and remaining reached groups were recorded in audit/reports/platform-runtime-trace.md. Android port-only gate exposed the pre-existing Win32Crt.cpp glob/iconv API-24 build blocker after a missing PCH dependency in Japanese.cpp was repaired.", "tests": ["script/verify_root_pack.py PASS (74/74)", "port_map.py check: 0 errors", "script/port_gate.sh macos PASS", "script/run_40250_asset_gate.sh PASS", "script/python_game_render_test.sh PASS", "MT_PLATFORM_STUB_TRACE=1 port.login_flow PASS (93 called stubs identified)", "android port_gate FAIL: Win32Crt.cpp glob/iconv declarations under NDK API 24"]} +{"time": "2026-09-24T07:31:08Z", "event": "platform_adapter_fix", "unit": "EterLib/GrpScreen.cpp + MilesLib/SoundManager.cpp", "detail": "Implemented reached 40250 UI box and vertical gradient command bridge; audio bridge now preserves named three-slot music fades, frame-step fade speed and limit, indexed 3D volume, 3D play counts, and SaveVolume/RestoreVolume mute state. Remaining sky/water/shadow/culling/IME adapters stay TODO.", "evidence": ["./script/port_gate.sh macos", "./script/python_game_render_test.sh", "godot --headless --path project -s res://audio_driver_test.gd", "port_map.py check"]} diff --git a/audit/port-map/EterLib/GrpScreen.cpp.json b/audit/port-map/EterLib/GrpScreen.cpp.json index 18f430ae..006bfb5e 100644 --- a/audit/port-map/EterLib/GrpScreen.cpp.json +++ b/audit/port-map/EterLib/GrpScreen.cpp.json @@ -26,13 +26,23 @@ "status": "TODO" }, "CScreen::RenderBox2d": { - "status": "TODO" + "status": "ADAPTED", + "impl": [ + "extension/src/platform/EterLib/GrpScreen.cpp:CScreen::RenderBox2d" + ], + "test": "extension/tests/port_app_loop_test.cpp", + "note": "40250 four edge line pairs, including the bottom edge extending one pixel, are recorded into the Godot UI command stream." }, "CScreen::RenderBar2d": { "status": "TODO" }, "CScreen::RenderGradationBar2d": { - "status": "TODO" + "status": "ADAPTED", + "impl": [ + "extension/src/platform/EterLib/GrpScreen.cpp:CScreen::RenderGradationBar2d" + ], + "test": "extension/tests/port_app_loop_test.cpp", + "note": "40250 top and bottom vertex colours are serialized as a vertically interpolated Godot canvas polygon; zero-width and zero-height guards match the source." }, "CScreen::RenderCircle2d": { "status": "TODO" diff --git a/audit/port-map/MilesLib/SoundManager.cpp.json b/audit/port-map/MilesLib/SoundManager.cpp.json new file mode 100644 index 00000000..f4885a74 --- /dev/null +++ b/audit/port-map/MilesLib/SoundManager.cpp.json @@ -0,0 +1,163 @@ +{ + "reference": "MilesLib/SoundManager.cpp", + "reference_sha256": "6602fcd22004c2335634a4a4041c33409174ef11c583f4e257dda676f5b9229e", + "priority": "P4", + "contracts": [], + "functions": { + "CSoundManager::CSoundManager": { + "status": "TODO" + }, + "CSoundManager::~CSoundManager": { + "status": "TODO" + }, + "CSoundManager::Create": { + "status": "TODO" + }, + "CSoundManager::Destroy": { + "status": "TODO" + }, + "CSoundManager::SetPosition": { + "status": "TODO" + }, + "CSoundManager::SetDirection": { + "status": "TODO" + }, + "CSoundManager::Update": { + "status": "TODO" + }, + "CSoundManager::UpdateSoundData": { + "status": "TODO" + }, + "CSoundManager::UpdateSoundInstance": { + "status": "TODO" + }, + "CSoundManager::GetSoundScale": { + "status": "TODO" + }, + "CSoundManager::SetSoundScale": { + "status": "TODO" + }, + "CSoundManager::SetAmbienceSoundScale": { + "status": "TODO" + }, + "CSoundManager::SetSoundVolume": { + "status": "TODO" + }, + "CSoundManager::__SetMusicVolume": { + "status": "TODO" + }, + "CSoundManager::__ConvertRatioVolumeToApplyVolume": { + "status": "TODO" + }, + "CSoundManager::__ConvertGradeVolumeToApplyVolume": { + "status": "TODO" + }, + "CSoundManager::SetSoundVolumeGrade": { + "status": "TODO" + }, + "CSoundManager::SetSoundVolumeRatio": { + "status": "TODO" + }, + "CSoundManager::SetMusicVolume": { + "status": "TODO" + }, + "CSoundManager::GetSoundVolume": { + "status": "TODO" + }, + "CSoundManager::GetMusicVolume": { + "status": "TODO" + }, + "CSoundManager::GetSoundInstance2D": { + "status": "TODO" + }, + "CSoundManager::GetSoundInstance3D": { + "status": "TODO" + }, + "CSoundManager::PlaySound2D": { + "status": "TODO" + }, + "CSoundManager::PlaySound3D": { + "status": "TODO" + }, + "CSoundManager::PlayAmbienceSound3D": { + "status": "TODO" + }, + "CSoundManager::PlayCharacterSound3D": { + "status": "TODO" + }, + "CSoundManager::StopSound3D": { + "status": "TODO" + }, + "CSoundManager::SetSoundVolume3D": { + "status": "ADAPTED", + "impl": [ + "extension/src/platform/MilesLib/SoundManager.cpp:CSoundManager::SetSoundVolume3D" + ], + "test": "project/audio_driver_test.gd", + "note": "40250 indexed instance volume is forwarded to the active Godot 3D sound player." + }, + "CSoundManager::StopAllSound3D": { + "status": "TODO" + }, + "CSoundManager::PlayMusic": { + "status": "ADAPTED", + "impl": [ + "extension/src/platform/MilesLib/SoundManager.cpp:CSoundManager::PlayMusic" + ], + "test": "project/audio_driver_test.gd", + "note": "40250 direct PlayMusic uses stream slot 0; the Godot driver now replaces slot 0." + }, + "CSoundManager::FadeInMusic": { + "status": "ADAPTED", + "impl": [ + "extension/src/platform/MilesLib/SoundManager.cpp:CSoundManager::FadeInMusic" + ], + "test": "project/audio_driver_test.gd", + "note": "40250 named stream fade-in advances by fVolumeSpeed per update; Godot uses three slots and the same per-frame volume step." + }, + "CSoundManager::FadeLimitOutMusic": { + "status": "ADAPTED", + "impl": [ + "extension/src/platform/MilesLib/SoundManager.cpp:CSoundManager::FadeLimitOutMusic" + ], + "test": "project/audio_driver_test.gd", + "note": "40250 ratio limit is converted by the original volume formula and the named stream fades to that limit." + }, + "CSoundManager::FadeOutMusic": { + "status": "ADAPTED", + "impl": [ + "extension/src/platform/MilesLib/SoundManager.cpp:CSoundManager::FadeOutMusic" + ], + "test": "project/audio_driver_test.gd", + "note": "40250 fade-out targets only the named active stream and stops it at volume zero." + }, + "CSoundManager::FadeOutAllMusic": { + "status": "TODO" + }, + "CSoundManager::SaveVolume": { + "status": "ADAPTED", + "impl": [ + "extension/src/platform/MilesLib/SoundManager.cpp:CSoundManager::SaveVolume" + ], + "test": "extension/tests/port_login_flow_test.cpp", + "note": "40250 backup and mute sequence is preserved, including the repeated-save guard." + }, + "CSoundManager::RestoreVolume": { + "status": "ADAPTED", + "impl": [ + "extension/src/platform/MilesLib/SoundManager.cpp:CSoundManager::RestoreVolume" + ], + "test": "extension/tests/port_login_flow_test.cpp", + "note": "40250 clears the disabled flag before restoring backed-up music and sound volumes." + }, + "CSoundManager::StopMusic": { + "status": "TODO" + }, + "CSoundManager::GetMusicIndex": { + "status": "TODO" + }, + "CSoundManager::FadeAll": { + "status": "TODO" + } + } +} diff --git a/audit/reports/platform-runtime-trace.md b/audit/reports/platform-runtime-trace.md index 639b87bf..39e55914 100644 --- a/audit/reports/platform-runtime-trace.md +++ b/audit/reports/platform-runtime-trace.md @@ -12,8 +12,10 @@ The first-call trace reported **93 distinct stub signatures**. `PlatformStub.h` | Runtime group | Reached stubs to address first | Observable gap | | --- | --- | --- | | World and sky | `CSkyBox::Update/Render/RenderCloud`, `CScreenFilter::Set*`, `CLensFlare::Draw*`, `CSpeedTreeForest::CreateInstance/UpdateSystem/Render` | Sky, weather, foliage and postprocessing do not follow the original path. | -| Culling and drawing | `CCullingManager::Register/Update`, `Frustum::ViewVolumeTest`, `CScreen::BuildViewFrustum/RenderBox2d/RenderGradationBar2d`, `CGraphicImageInstance::OnSetImagePointer` | Visibility and several UI/render primitives still depend on default-return stubs. | +| Culling and drawing | `CCullingManager::Register/Update`, `Frustum::ViewVolumeTest`, `CScreen::BuildViewFrustum`, `CGraphicImageInstance::OnSetImagePointer` | Visibility and remaining UI/render primitives still depend on default-return stubs. | | Audio internals | `NSound::LoadSoundInformationPiece`, `CSoundBase`, `CSoundManager2D/3D/Stream` constructors | The new Godot command bridge plays common sounds, but the original Miles object model is not implemented. | | Platform services | `CFileLoaderThread::Fetch`, `CInputKeyboard::IsPressed`, `BuildProcessCRC`, `GetProcessCRCMagicCubePiece`, `CMSWindow::GetWindowHandle` | Resource scheduling, input polling and the CRC path need focused verification. | `CPythonApplication::SetGlobalCenterPosition` appeared in the first trace and was implemented from the 40250 `GlobalPositionToLocalPosition → GetHeight → SetCenterPosition` sequence in this round. The other groups remain open in the platform queue; do not mark them verified from this smoke test. + +2026-09-24 follow-up: `CScreen::RenderBox2d` and `RenderGradationBar2d` now emit Godot canvas commands with the source's edge and colour semantics. `CSoundManager` now forwards named music fade speed/limit, 3D loop counts and indexed volume; its SaveVolume/RestoreVolume sequence preserves the backup. These changes passed the macOS port gate, audio driver test and game render test. The sky, foliage, water, shadows, sphere-pack culling and IME gaps remain open. diff --git a/extension/src/platform/EterLib/GrpScreen.cpp b/extension/src/platform/EterLib/GrpScreen.cpp index 9ac11f31..d681f198 100644 --- a/extension/src/platform/EterLib/GrpScreen.cpp +++ b/extension/src/platform/EterLib/GrpScreen.cpp @@ -71,9 +71,13 @@ auto CScreen::RenderLine2d(float sx, float sy, float ex, float ey, float) -> voi UIRenderAdd({UIRenderCommand::Line, sx, sy, ex, ey, ms_diffuseColor}); } -auto CScreen::RenderBox2d(float, float, float, float, float) -> void +auto CScreen::RenderBox2d(float sx, float sy, float ex, float ey, float) -> void { - MT_PLATFORM_STUB(); + // 40250's four line pairs, including its one-pixel extension of the bottom edge. + UIRenderAdd({UIRenderCommand::Line, sx, sy, ex, sy, ms_diffuseColor}); + UIRenderAdd({UIRenderCommand::Line, sx, sy, sx, ey, ms_diffuseColor}); + UIRenderAdd({UIRenderCommand::Line, ex, sy, ex, ey, ms_diffuseColor}); + UIRenderAdd({UIRenderCommand::Line, sx, ey, ex + 1.0f, ey, ms_diffuseColor}); } auto CScreen::RenderBar2d(float sx, float sy, float ex, float ey, float) -> void @@ -81,9 +85,13 @@ auto CScreen::RenderBar2d(float sx, float sy, float ex, float ey, float) -> void UIRenderAdd({UIRenderCommand::Bar, sx, sy, ex, ey, ms_diffuseColor}); } -auto CScreen::RenderGradationBar2d(float, float, float, float, DWORD, DWORD, float) -> void +auto CScreen::RenderGradationBar2d(float sx, float sy, float ex, float ey, DWORD start, DWORD end, float) -> void { - MT_PLATFORM_STUB(); + if (sx == ex || sy == ey) + return; + UIRenderCommand command{UIRenderCommand::GradientBar, sx, sy, ex, ey, start}; + command.end_argb = end; + UIRenderAdd(std::move(command)); } auto CScreen::RenderCircle2d(float, float, float, float, int) -> void diff --git a/extension/src/platform/EterLib/UIRenderCommands.h b/extension/src/platform/EterLib/UIRenderCommands.h index 2286d47a..8217eedf 100644 --- a/extension/src/platform/EterLib/UIRenderCommands.h +++ b/extension/src/platform/EterLib/UIRenderCommands.h @@ -7,9 +7,10 @@ // Commands emitted by the 40250 UI drawing calls, consumed by Godot's CanvasItem. // The port layer stays independent of godot-cpp. struct UIRenderCommand { - enum Kind { Bar, Line, Text, Image } kind; + enum Kind { Bar, Line, Text, Image, GradientBar } kind; float x1, y1, x2, y2; std::uint32_t argb; + std::uint32_t end_argb = 0; // GradientBar only: colour on the bottom edge. float clip_x1 = 0, clip_y1 = 0, clip_x2 = 0, clip_y2 = 0; std::string text; // Image only: text is the texture's file, drawn as the quad (vertices 0..3 = TL, TR, BL, BR) diff --git a/extension/src/platform/MilesLib/AudioCommands.h b/extension/src/platform/MilesLib/AudioCommands.h index dc5cab93..764173d3 100644 --- a/extension/src/platform/MilesLib/AudioCommands.h +++ b/extension/src/platform/MilesLib/AudioCommands.h @@ -16,6 +16,9 @@ struct AudioCommand FadeOutAllMusic = 7, SetSoundVolume = 8, SetMusicVolume = 9, + FadeInMusic = 10, + FadeLimitOutMusic = 11, + SetSoundVolume3D = 12, }; Type type = PlaySound2D; diff --git a/extension/src/platform/MilesLib/SoundManager.cpp b/extension/src/platform/MilesLib/SoundManager.cpp index fce23e88..d61489f4 100644 --- a/extension/src/platform/MilesLib/SoundManager.cpp +++ b/extension/src/platform/MilesLib/SoundManager.cpp @@ -171,14 +171,20 @@ void CSoundManager::SetMusicVolumeGrade(int iGrade) void CSoundManager::SaveVolume() { - m_fBackupMusicVolume = m_fMusicVolume; - m_fBackupSoundVolume = m_fSoundVolume; + if (m_isSoundDisable) + return; + const float musicVolume = m_fMusicVolume; + const float soundVolume = m_fSoundVolume; SetMusicVolume(0.0f); SetSoundVolume(0.0f); + m_fBackupMusicVolume = musicVolume; + m_fBackupSoundVolume = soundVolume; + m_isSoundDisable = TRUE; } void CSoundManager::RestoreVolume() { + m_isSoundDisable = FALSE; SetMusicVolume(m_fBackupMusicVolume); SetSoundVolume(m_fBackupSoundVolume); } @@ -251,8 +257,13 @@ void CSoundManager::PlayCharacterSound3D(float fx, float fy, float fz, const cha PlaySound3D(fx, fy, fz, c_szFileName, 1); } -void CSoundManager::SetSoundVolume3D(int, float) +void CSoundManager::SetSoundVolume3D(int index, float volume) { + AudioCommand cmd; + cmd.type = AudioCommand::SetSoundVolume3D; + cmd.id = index; + cmd.volume = volume; + PushAudioCommand(cmd); } void CSoundManager::StopAllSound3D() @@ -274,9 +285,16 @@ void CSoundManager::PlayMusic(const char * c_szFileName) PushAudioCommand(cmd); } -void CSoundManager::FadeInMusic(const char * c_szFileName, float) +void CSoundManager::FadeInMusic(const char * c_szFileName, float speed) { - PlayMusic(c_szFileName); + if (!c_szFileName || !c_szFileName[0]) + return; + AudioCommand cmd; + cmd.type = AudioCommand::FadeInMusic; + cmd.filename = c_szFileName; + cmd.volume = m_fMusicVolume; + cmd.speed = speed; + PushAudioCommand(cmd); } void CSoundManager::FadeOutMusic(const char * c_szFileName, float fSpeed) @@ -288,9 +306,14 @@ void CSoundManager::FadeOutMusic(const char * c_szFileName, float fSpeed) PushAudioCommand(cmd); } -void CSoundManager::FadeLimitOutMusic(const char * c_szFileName, float, float fSpeed) +void CSoundManager::FadeLimitOutMusic(const char * c_szFileName, float limit, float speed) { - FadeOutMusic(c_szFileName, fSpeed); + AudioCommand cmd; + cmd.type = AudioCommand::FadeLimitOutMusic; + cmd.filename = c_szFileName ? c_szFileName : ""; + cmd.volume = __ConvertRatioVolumeToApplyVolume(limit); + cmd.speed = speed; + PushAudioCommand(cmd); } void CSoundManager::FadeOutAllMusic() diff --git a/extension/src/python_host_node.cpp b/extension/src/python_host_node.cpp index d8f31ad9..797170f3 100644 --- a/extension/src/python_host_node.cpp +++ b/extension/src/python_host_node.cpp @@ -91,14 +91,17 @@ Array Metin2PythonHost::ui_render_commands() { Array out; for (const auto &command : UIRenderCommands()) { Dictionary item; - item["kind"] = command.kind == UIRenderCommand::Bar ? "bar" : + item["kind"] = command.kind == UIRenderCommand::GradientBar ? "gradient_bar" : + (command.kind == UIRenderCommand::Bar ? "bar" : (command.kind == UIRenderCommand::Line ? "line" : - (command.kind == UIRenderCommand::Image ? "image" : "text")); + (command.kind == UIRenderCommand::Image ? "image" : "text"))); item["x1"] = command.x1; item["y1"] = command.y1; item["x2"] = command.x2; item["y2"] = command.y2; item["argb"] = static_cast(command.argb); + if (command.kind == UIRenderCommand::GradientBar) + item["end_argb"] = static_cast(command.end_argb); item["clip_x1"] = command.clip_x1; item["clip_y1"] = command.clip_y1; item["clip_x2"] = command.clip_x2; @@ -259,6 +262,9 @@ Array Metin2PythonHost::audio_commands() { case AudioCommand::FadeOutAllMusic: item["type"] = "fade_out_all_music"; break; case AudioCommand::SetSoundVolume: item["type"] = "set_sound_volume"; break; case AudioCommand::SetMusicVolume: item["type"] = "set_music_volume"; break; + case AudioCommand::FadeInMusic: item["type"] = "fade_in_music"; break; + case AudioCommand::FadeLimitOutMusic: item["type"] = "fade_limit_out_music"; break; + case AudioCommand::SetSoundVolume3D: item["type"] = "set_sound_volume_3d"; break; default: continue; } item["filename"] = String::utf8(cmd.filename.c_str()); diff --git a/extension/tests/port_app_loop_test.cpp b/extension/tests/port_app_loop_test.cpp index 08a8ca76..f1d29f19 100644 --- a/extension/tests/port_app_loop_test.cpp +++ b/extension/tests/port_app_loop_test.cpp @@ -20,6 +20,7 @@ #include "../src/platform/ScriptLib/PythonHost.h" #include "../src/platform/UserInterface/UserInterface.h" #include "../src/platform/EterLib/UIRenderCommands.h" +#include "EterLib/GrpScreen.h" #include // DIK_ESCAPE #include @@ -108,9 +109,31 @@ static void frames(int count) PythonBoot::UIUpdate(); } +static void drawing_primitives() +{ + UIRenderSetSize(800, 600); + UIRenderBeginFrame(); + CScreen screen; + screen.SetDiffuseColor(0xFF123456u); + screen.RenderBox2d(10, 20, 30, 40); + screen.RenderGradationBar2d(50, 60, 70, 80, 0xFF102030u, 0xFF405060u); + const auto& commands = UIRenderCommands(); + CHECK(commands.size() == 5); + if (commands.size() == 5) + { + CHECK(commands[0].kind == UIRenderCommand::Line && near(commands[0].x1, 10) && near(commands[0].y2, 20)); + CHECK(commands[3].kind == UIRenderCommand::Line && near(commands[3].x2, 31)); + CHECK(commands[4].kind == UIRenderCommand::GradientBar && commands[4].argb == 0xFF102030u && + commands[4].end_argb == 0xFF405060u); + } + UIRenderSetSize(0, 0); + UIRenderBeginFrame(); +} + int main(int argc, char** argv) { + drawing_primitives(); 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] : ""; diff --git a/extension/tests/port_login_flow_test.cpp b/extension/tests/port_login_flow_test.cpp index 3edf6500..8e49ac2d 100644 --- a/extension/tests/port_login_flow_test.cpp +++ b/extension/tests/port_login_flow_test.cpp @@ -1161,6 +1161,15 @@ int main(int argc, char** argv) 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)")); diff --git a/project/audio_driver.gd b/project/audio_driver.gd index 5b5c41da..984a0752 100644 --- a/project/audio_driver.gd +++ b/project/audio_driver.gd @@ -9,10 +9,10 @@ var _cache := {} # 绝对路径 -> AudioStream var _bgm_root: String var _sound_roots: PackedStringArray = [] -var _bgm := [null, null] # 两个 AudioStreamPlayer 用于 BGM 交叉淡入淡出 -var _bgm_cur := 0 -var _bgm_name := "" -var _bgm_fade_tween: Tween = null +var _bgm: Array[AudioStreamPlayer] = [] # 40250 CSoundManagerStream has three music slots. +var _bgm_names: Array[String] = [] +var _bgm_volumes: Array[float] = [] +var _bgm_fades := {} # slot -> {target, speed, mode} var _ui_pool: Array[AudioStreamPlayer] = [] var _ui_next := 0 @@ -55,13 +55,14 @@ func _init_roots() -> void: _sound_roots.append(r) func _init_players() -> void: - # 2 个 BGM 播放器 - for i in 2: + for i in 3: var pl := AudioStreamPlayer.new() pl.bus = "Master" pl.name = "BgmPlayer%d" % i add_child(pl) - _bgm[i] = pl + _bgm.append(pl) + _bgm_names.append("") + _bgm_volumes.append(0.0) # 16 个 2D 音效池播放器 for i in 16: @@ -76,30 +77,34 @@ func _process(_delta: float) -> void: return var commands: Array = Metin2PythonHost.audio_commands() - if commands.is_empty(): - return - for cmd in commands: var type: String = cmd.get("type", "") match type: "play_sound_2d": play_sound_2d(cmd.get("filename", ""), cmd.get("volume", 1.0)) "play_sound_3d": - play_sound_3d(cmd.get("filename", ""), Vector3(cmd.get("x", 0.0) / 100.0, cmd.get("z", 0.0) / 100.0, -cmd.get("y", 0.0) / 100.0), cmd.get("volume", 1.0), cmd.get("id", 0)) + play_sound_3d(cmd.get("filename", ""), Vector3(cmd.get("x", 0.0) / 100.0, cmd.get("z", 0.0) / 100.0, -cmd.get("y", 0.0) / 100.0), cmd.get("volume", 1.0), cmd.get("id", 0), cmd.get("play_count", 1)) "stop_sound_3d": stop_sound_3d(cmd.get("id", 0)) "stop_all_sound_3d": stop_all_sound_3d() + "set_sound_volume_3d": + set_sound_volume_3d(cmd.get("id", 0), cmd.get("volume", 1.0)) "play_music": play_music(cmd.get("filename", ""), cmd.get("volume", 1.0)) + "fade_in_music": + fade_in_music(cmd.get("filename", ""), cmd.get("volume", 1.0), cmd.get("speed", 0.016)) "fade_out_music": - fade_out_music(cmd.get("speed", 0.0)) + fade_out_music(cmd.get("filename", ""), cmd.get("speed", 0.016)) + "fade_limit_out_music": + fade_limit_out_music(cmd.get("filename", ""), cmd.get("volume", 0.0), cmd.get("speed", 0.016)) "fade_out_all_music": fade_out_all_music() "set_sound_volume": set_sound_volume(cmd.get("volume", 1.0)) "set_music_volume": set_music_volume(cmd.get("volume", 1.0)) + _update_music_fades() # --- 音效路径检索与加载 --------------------------------------------------- @@ -186,7 +191,7 @@ func play_sound_2d(rel: String, volume_scale := 1.0) -> void: # --- 3D 空间音效播放 ------------------------------------------------------- -func play_sound_3d(rel: String, pos: Vector3, volume_scale := 1.0, sound_id := 0) -> void: +func play_sound_3d(rel: String, pos: Vector3, volume_scale := 1.0, sound_id := 0, play_count := 1) -> void: var path := _find_sound(rel) if path.is_empty(): return @@ -205,12 +210,24 @@ func play_sound_3d(rel: String, pos: Vector3, volume_scale := 1.0, sound_id := 0 pl.play() if sound_id > 0: _active_3d[sound_id] = pl + var state := {"remaining": play_count} pl.finished.connect(func(): + if state.remaining == 0 or state.remaining > 1: + if state.remaining > 1: + state.remaining -= 1 + pl.play() + return _all_3d.erase(pl) if sound_id > 0 and _active_3d.get(sound_id) == pl: _active_3d.erase(sound_id) pl.queue_free()) +func set_sound_volume_3d(sound_id: int, volume: float) -> void: + var pl: AudioStreamPlayer3D = _active_3d.get(sound_id) + if pl != null and is_instance_valid(pl): + var level := clampf(volume, 0.0, 1.0) + pl.volume_db = linear_to_db(level) if level > 0.001 else -80.0 + func stop_sound_3d(sound_id: int) -> void: if _active_3d.has(sound_id): var pl: AudioStreamPlayer3D = _active_3d[sound_id] @@ -230,10 +247,7 @@ func stop_all_sound_3d() -> void: # --- BGM 背景音乐播放 ----------------------------------------------------- -func play_music(name: String, volume_scale := 1.0) -> void: - if name.is_empty() or name == _bgm_name: - return - +func _music_stream(name: String) -> AudioStream: var path := name.replace("\\", "/") if not path.is_absolute_path(): var direct := _bgm_root.path_join(name) @@ -243,45 +257,92 @@ func play_music(name: String, volume_scale := 1.0) -> void: path = direct + ".mp3" else: path = _find_sound(name) + return _load_stream(path) - var stream := _load_stream(path) +func _music_slot(name: String) -> int: + var normalized := name.replace("\\", "/").to_lower() + for i in _bgm_names.size(): + if _bgm_names[i].replace("\\", "/").to_lower() == normalized and _bgm[i].playing: + return i + return -1 + +func _set_music_volume(slot: int, volume: float) -> void: + _bgm_volumes[slot] = clampf(volume, 0.0, 1.0) + _bgm[slot].volume_db = linear_to_db(_bgm_volumes[slot]) if _bgm_volumes[slot] > 0.001 else -80.0 + +func _start_music(slot: int, name: String, volume: float) -> bool: + var stream := _music_stream(name) if stream == null: - return - _bgm_name = name + return false if stream is AudioStreamMP3: stream.loop = true + _bgm[slot].stop() + _bgm[slot].stream = stream + _bgm_names[slot] = name + _set_music_volume(slot, volume) + _bgm[slot].play() + return true - var nxt := 1 - _bgm_cur - _bgm[nxt].stream = stream - _bgm[nxt].volume_db = -40.0 - _bgm[nxt].play() +func play_music(name: String, volume_scale := 1.0) -> void: + if name.is_empty(): + return + # 40250 PlayMusic(name) always replaces stream slot 0. + _bgm_fades.erase(0) + _start_music(0, name, volume_scale) - var target_vol: float = clampf(volume_scale, 0.0, 1.0) - var target_db: float = linear_to_db(target_vol) if target_vol > 0.001 else -80.0 +func fade_in_music(name: String, target_volume := 1.0, speed := 0.016) -> void: + if name.is_empty(): + return + var slot := _music_slot(name) + if slot < 0: + fade_out_all_music() + for i in _bgm.size(): + if not _bgm[i].playing: + slot = i + break + if slot < 0 or not _start_music(slot, name, 0.0): + return + _bgm_fades[slot] = {"target": clampf(target_volume, 0.0, 1.0), "speed": speed, "mode": "in"} - if _bgm_fade_tween and _bgm_fade_tween.is_valid(): - _bgm_fade_tween.kill() - _bgm_fade_tween = create_tween() - _bgm_fade_tween.parallel().tween_property(_bgm[nxt], "volume_db", target_db, 1.2) - var cur_player: AudioStreamPlayer = _bgm[_bgm_cur] - if cur_player and cur_player.playing: - _bgm_fade_tween.parallel().tween_property(cur_player, "volume_db", -40.0, 1.2) - _bgm_fade_tween.chain().tween_callback(cur_player.stop) - _bgm_cur = nxt +func fade_out_music(name: String, speed := 0.016) -> void: + var slot := _music_slot(name) + if slot >= 0: + _bgm_fades[slot] = {"target": 0.0, "speed": speed, "mode": "out"} -func fade_out_music(speed := 0.0) -> void: - _bgm_name = "" - var dur := 1.5 if speed <= 0.0 else maxf(0.5, 3.0 / speed) - var cur_player: AudioStreamPlayer = _bgm[_bgm_cur] - if cur_player and cur_player.playing: - if _bgm_fade_tween and _bgm_fade_tween.is_valid(): - _bgm_fade_tween.kill() - _bgm_fade_tween = create_tween() - _bgm_fade_tween.tween_property(cur_player, "volume_db", -50.0, dur) - _bgm_fade_tween.tween_callback(cur_player.stop) +func fade_limit_out_music(name: String, limit: float, speed := 0.016) -> void: + var slot := _music_slot(name) + if slot >= 0: + _bgm_fades[slot] = {"target": clampf(limit, 0.0, 1.0), "speed": speed, "mode": "limit"} func fade_out_all_music() -> void: - fade_out_music(0.0) + for i in _bgm.size(): + if _bgm[i].playing: + _bgm_fades[i] = {"target": 0.0, "speed": 0.01, "mode": "out"} + +func _update_music_fades() -> void: + # 40250 advances fVolume by fVolumeSpeed once per SoundManager::Update frame. + for slot in _bgm_fades.keys(): + if not _bgm[slot].playing: + _bgm_fades.erase(slot) + _bgm_names[slot] = "" + continue + var fade: Dictionary = _bgm_fades[slot] + var target: float = fade.target + var step: float = fade.speed + var volume: float = _bgm_volumes[slot] + match fade.mode: + "in": + volume = minf(target, volume + step) + "limit": + volume = maxf(target, volume - step) + "out": + volume = maxf(0.0, volume - step) + _set_music_volume(slot, volume) + if (fade.mode == "in" and volume >= target) or (fade.mode != "in" and volume <= target): + if fade.mode == "out": + _bgm[slot].stop() + _bgm_names[slot] = "" + _bgm_fades.erase(slot) # --- 音量控制 ------------------------------------------------------------- @@ -290,6 +351,6 @@ func set_sound_volume(v: float) -> void: func set_music_volume(v: float) -> void: master_music = clampf(v, 0.0, 1.0) - var pl: AudioStreamPlayer = _bgm[_bgm_cur] - if pl and pl.playing: - pl.volume_db = linear_to_db(master_music) if master_music > 0.001 else -80.0 + for i in _bgm.size(): + if _bgm[i].playing and not (_bgm_fades.has(i) and _bgm_fades[i].mode == "out"): + _set_music_volume(i, master_music) diff --git a/project/audio_driver_test.gd b/project/audio_driver_test.gd index 397b9a1a..c9022fc0 100644 --- a/project/audio_driver_test.gd +++ b/project/audio_driver_test.gd @@ -27,8 +27,55 @@ func _run() -> void: "sound volume was applied more than once") driver.play_sound_3d(sound, Vector3.ZERO, 0.5) _check(driver._all_3d.size() == 1, "3D sound not tracked") + driver.play_sound_3d(sound, Vector3.ZERO, 0.5, 17, 0) + driver.set_sound_volume_3d(17, 0.25) + _check(absf(driver._active_3d[17].volume_db - linear_to_db(0.25)) < 0.01, + "indexed 3D sound volume did not update") + _check(driver._all_3d.size() == 2, "looping 3D sound not tracked") + driver._active_3d[17].finished.emit() + _check(driver._active_3d.has(17), "zero-loop-count ambience did not restart") + driver.play_sound_3d(sound, Vector3.ZERO, 0.5, 18, 2) + var twice: AudioStreamPlayer3D = driver._active_3d[18] + twice.finished.emit() + _check(driver._active_3d.has(18), "two-play ambience stopped after first play") + twice.finished.emit() + _check(not driver._active_3d.has(18), "two-play ambience did not finish") + driver.stop_sound_3d(17) + _check(not driver._active_3d.has(17), "StopSound3D left indexed sound running") driver.stop_all_sound_3d() _check(driver._all_3d.is_empty(), "StopAllSound3D left a player running") + driver.play_music("login_window.mp3", 0.8) + _check(driver._music_slot("login_window.mp3") == 0, "PlayMusic did not use slot 0") + driver.fade_limit_out_music("login_window.mp3", 0.2, 0.1) + for i in 8: + driver._update_music_fades() + _check(absf(driver._bgm_volumes[0] - 0.2) < 0.001, + "FadeLimitOutMusic did not retain the requested limit") + driver.fade_limit_out_music("login_window.mp3", 0.7, 0.1) + driver._update_music_fades() + _check(absf(driver._bgm_volumes[0] - 0.7) < 0.001, + "40250 limit fade must clamp immediately when already below the limit") + driver.fade_limit_out_music("login_window.mp3", 0.2, 0.5) + driver._update_music_fades() + driver.fade_out_music("another_track.mp3", 0.1) + _check(not driver._bgm_fades.has(1), "FadeOutMusic affected an unrelated track") + driver.fade_out_music("login_window.mp3", 0.1) + for i in 2: + driver._update_music_fades() + _check(not driver._bgm[0].playing, "FadeOutMusic did not stop named track") + driver.fade_in_music("characterselect.mp3", 0.3, 0.1) + for i in 3: + driver._update_music_fades() + _check(absf(driver._bgm_volumes[0] - 0.3) < 0.001, + "FadeInMusic did not advance by the requested frame step") + for player in driver._bgm: + player.stop() + player.stream = null + for player in driver._ui_pool: + player.stop() + player.stream = null + driver._cache.clear() driver.free() + await process_frame print("PASS: audio_driver_test" if failures == 0 else "FAIL: audio_driver_test") quit(0 if failures == 0 else 1) diff --git a/project/python_ui_surface.gd b/project/python_ui_surface.gd index f90dc27c..cf05e31c 100644 --- a/project/python_ui_surface.gd +++ b/project/python_ui_surface.gd @@ -177,6 +177,22 @@ func _draw() -> void: used = _segment(used, RID()) plain = true RenderingServer.canvas_item_add_rect(_segments[used - 1], visible, color) + elif command["kind"] == "gradient_bar": + var visible := Rect2(p1, p2 - p1).intersection(clip) + if visible.has_area(): + if not plain: + used = _segment(used, RID()) + plain = true + var bottom_argb: int = command["end_argb"] + var bottom := Color8((bottom_argb >> 16) & 255, (bottom_argb >> 8) & 255, + bottom_argb & 255, (bottom_argb >> 24) & 255) + var top_factor := (visible.position.y - p1.y) / (p2.y - p1.y) + var bottom_factor := (visible.end.y - p1.y) / (p2.y - p1.y) + RenderingServer.canvas_item_add_polygon(_segments[used - 1], + PackedVector2Array([visible.position, Vector2(visible.end.x, visible.position.y), + visible.end, Vector2(visible.position.x, visible.end.y)]), + PackedColorArray([color.lerp(bottom, top_factor), color.lerp(bottom, top_factor), + color.lerp(bottom, bottom_factor), color.lerp(bottom, bottom_factor)])) elif command["kind"] == "line": var segment := _clip_line(p1, p2, clip) if segment.size() == 2: