From 687489dc28f0b51c2646d15ef4b1f112a4d63957 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:46:05 +0000 Subject: [PATCH 1/3] Initial plan From 4e8cb6a57ac0496b04ba80b47a4925a62909ca12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:49:31 +0000 Subject: [PATCH 2/3] fix: restore F11 toggle behavior and add voice prompt/docs Agent-Logs-Url: https://github.com/othercat/PalTimer/sessions/fcd49260-9684-4808-b7e1-a4a693909463 Co-authored-by: othercat <509048+othercat@users.noreply.github.com> --- Pal98Timer/GForm.cs | 14 +- Pal98Timer/Pal98Timer.csproj | 3 +- Pal98Timer/TimerCore.cs | 20 ++- Pal98Timer/VoicePrompt.cs | 282 +++++++++++++++++++++++++++++++++++ docs/TODO_PLAN.md | 13 ++ docs/livesplit_pal98_kb.md | 85 +++++++++++ 6 files changed, 400 insertions(+), 17 deletions(-) create mode 100644 Pal98Timer/VoicePrompt.cs create mode 100644 docs/TODO_PLAN.md create mode 100644 docs/livesplit_pal98_kb.md diff --git a/Pal98Timer/GForm.cs b/Pal98Timer/GForm.cs index f22811e..1955240 100644 --- a/Pal98Timer/GForm.cs +++ b/Pal98Timer/GForm.cs @@ -547,22 +547,12 @@ public void OnKeyPress(KeyboardLib.HookStruct hookStruct, out bool handle) if (KeyChangerDel.IsEnable()) { KeyChangerDel.Disable(); - ShowKCEnable(); - KeyChangerDel.Close(); } else { - Run(delegate() { - KeyChangerDel.Open(); - // Wait for the KeyChanger window to be ready (up to 3 seconds) - for (int i = 0; i < 30 && !KeyChangerDel.IsWindowOpen(); i++) - { - System.Threading.Thread.Sleep(100); - } - KeyChangerDel.Enable(); - UI(delegate() { ShowKCEnable(); }); - }); + KeyChangerDel.Enable(); } + ShowKCEnable(); core.OnFunctionKey(11); } handle = core.NeedBlockFunctionKey(11); diff --git a/Pal98Timer/Pal98Timer.csproj b/Pal98Timer/Pal98Timer.csproj index 68aa8cc..5c1a410 100644 --- a/Pal98Timer/Pal98Timer.csproj +++ b/Pal98Timer/Pal98Timer.csproj @@ -236,6 +236,7 @@ Upload.cs + @@ -330,4 +331,4 @@ --> - \ No newline at end of file + diff --git a/Pal98Timer/TimerCore.cs b/Pal98Timer/TimerCore.cs index 2b0ad56..979400e 100644 --- a/Pal98Timer/TimerCore.cs +++ b/Pal98Timer/TimerCore.cs @@ -95,6 +95,7 @@ public TimerCore(GForm form) { this.form = form; CMD5 = GetFileMD5(this.GetType().Assembly.Location); + VoicePrompt.ReloadConfig(); } /// /// 计算文件的MD5 @@ -512,11 +513,22 @@ protected void Checking() if (CurrentStep < CheckPoints.Count) { - CheckPoints[CurrentStep].Current = MT.CurrentTSOnly; - if (CheckPoints[CurrentStep].Check()) + CheckPoint currentCheckPoint = CheckPoints[CurrentStep]; + currentCheckPoint.Current = MT.CurrentTSOnly; + if (currentCheckPoint.Check()) { - CheckPoints[CurrentStep].Current = new TimeSpan(MT.CurrentTSOnly.Ticks); - CheckPoints[CurrentStep].IsEnd = true; + currentCheckPoint.Current = new TimeSpan(MT.CurrentTSOnly.Ticks); + currentCheckPoint.IsEnd = true; + VoicePrompt.PlayCheckpointSound(currentCheckPoint.GetNickName()); + long cha = currentCheckPoint.GetCHA(); + if (cha < 0) + { + VoicePrompt.PlayFasterSound(); + } + else if (cha > 0) + { + VoicePrompt.PlaySlowerSound(); + } //CurrentStep++; int nextstep = CurrentStep + 1; if (nextstep >= CheckPoints.Count) diff --git a/Pal98Timer/VoicePrompt.cs b/Pal98Timer/VoicePrompt.cs new file mode 100644 index 0000000..0881c42 --- /dev/null +++ b/Pal98Timer/VoicePrompt.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace Pal98Timer +{ + public static class VoicePrompt + { + [DllImport("winmm.dll", CharSet = CharSet.Auto)] + private static extern int mciSendString(string command, StringBuilder buffer, int bufferSize, IntPtr hwndCallback); + + private static readonly object _configLock = new object(); + private static readonly object _playLock = new object(); + private static readonly string _playerAlias = "paltimer_voice"; + private static readonly string[] _soundExts = new string[] { ".mp3", ".wav" }; + private static bool _isLoaded = false; + private static bool _isEnable = true; + private static string _fasterPath = @"sounds\faster.mp3"; + private static string _slowerPath = @"sounds\slower.mp3"; + private static Dictionary _checkpointSounds = new Dictionary(StringComparer.OrdinalIgnoreCase); + + private static string AppDir + { + get + { + return Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName); + } + } + + public static void ReloadConfig() + { + lock (_configLock) + { + _isLoaded = false; + } + EnsureConfigLoaded(); + } + + private static void EnsureConfigLoaded() + { + if (_isLoaded) + { + return; + } + lock (_configLock) + { + if (_isLoaded) + { + return; + } + _isEnable = true; + _fasterPath = @"sounds\faster.mp3"; + _slowerPath = @"sounds\slower.mp3"; + _checkpointSounds = new Dictionary(StringComparer.OrdinalIgnoreCase); + + string cfgPath = Path.Combine(AppDir, "voice_config.txt"); + if (File.Exists(cfgPath)) + { + Encoding charset = TimerCore.GetFileEncodeType(cfgPath); + using (FileStream fs = new FileStream(cfgPath, FileMode.Open, FileAccess.Read)) + { + using (StreamReader sr = new StreamReader(fs, charset)) + { + while (!sr.EndOfStream) + { + string line = sr.ReadLine(); + if (line == null) + { + continue; + } + line = line.Trim(); + if (line == "" || line.StartsWith("#") || line.StartsWith(";") || line.StartsWith("//")) + { + continue; + } + int idx = line.IndexOf('='); + if (idx <= 0) + { + continue; + } + string key = line.Substring(0, idx).Trim(); + string val = line.Substring(idx + 1).Trim(); + if (key == "" || val == "") + { + continue; + } + string lowKey = key.ToLowerInvariant(); + if (lowKey == "enable" || lowKey == "enabled" || lowKey == "voice_enable") + { + _isEnable = ParseBool(val, true); + } + else if (lowKey == "faster" || lowKey == "faster_sound") + { + _fasterPath = val; + } + else if (lowKey == "slower" || lowKey == "slower_sound") + { + _slowerPath = val; + } + else if (lowKey.StartsWith("checkpoint.")) + { + string checkpointName = key.Substring("checkpoint.".Length).Trim(); + if (checkpointName != "") + { + _checkpointSounds[checkpointName] = val; + } + } + else if (lowKey.StartsWith("cp.")) + { + string checkpointName = key.Substring("cp.".Length).Trim(); + if (checkpointName != "") + { + _checkpointSounds[checkpointName] = val; + } + } + } + } + } + } + _isLoaded = true; + } + } + + private static bool ParseBool(string str, bool defaultValue) + { + if (string.IsNullOrEmpty(str)) + { + return defaultValue; + } + string s = str.Trim().ToLowerInvariant(); + if (s == "1" || s == "true" || s == "yes" || s == "on") + { + return true; + } + if (s == "0" || s == "false" || s == "no" || s == "off") + { + return false; + } + return defaultValue; + } + + public static void PlaySound(string filePath) + { + EnsureConfigLoaded(); + if (!_isEnable) + { + return; + } + string fullPath = ResolvePath(filePath); + if (fullPath == "") + { + return; + } + if (!File.Exists(fullPath)) + { + return; + } + + lock (_playLock) + { + try + { + mciSendString("close " + _playerAlias, null, 0, IntPtr.Zero); + string escaped = fullPath.Replace("\"", "\"\""); + int openRes = mciSendString("open \"" + escaped + "\" alias " + _playerAlias, null, 0, IntPtr.Zero); + if (openRes == 0) + { + mciSendString("play " + _playerAlias + " from 0", null, 0, IntPtr.Zero); + } + } + catch + { } + } + } + + public static void PlayCheckpointSound(string checkpointName) + { + EnsureConfigLoaded(); + if (string.IsNullOrWhiteSpace(checkpointName)) + { + return; + } + string mapping; + if (_checkpointSounds.TryGetValue(checkpointName.Trim(), out mapping)) + { + PlaySound(mapping); + return; + } + + string autoPath = FindNamedSound(checkpointName.Trim()); + if (autoPath != "") + { + PlaySound(autoPath); + } + } + + public static void PlayFasterSound() + { + EnsureConfigLoaded(); + PlaySound(_fasterPath); + } + + public static void PlaySlowerSound() + { + EnsureConfigLoaded(); + PlaySound(_slowerPath); + } + + private static string ResolvePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return ""; + } + string p = path.Trim().Trim('"'); + if (Path.IsPathRooted(p)) + { + return p; + } + return Path.Combine(AppDir, p); + } + + private static string FindNamedSound(string checkpointName) + { + string[] bases = new string[] + { + Path.Combine(AppDir, "sounds"), + Path.Combine(AppDir, "voice") + }; + + foreach (string baseDir in bases) + { + string matched = FindNamedSoundInDir(baseDir, checkpointName); + if (matched != "") + { + return matched; + } + } + return ""; + } + + private static string FindNamedSoundInDir(string baseDir, string checkpointName) + { + if (!Directory.Exists(baseDir)) + { + return ""; + } + string safeName = MakeSafeFileName(checkpointName); + foreach (string ext in _soundExts) + { + string p1 = Path.Combine(baseDir, checkpointName + ext); + if (File.Exists(p1)) + { + return p1; + } + if (safeName != checkpointName) + { + string p2 = Path.Combine(baseDir, safeName + ext); + if (File.Exists(p2)) + { + return p2; + } + } + } + return ""; + } + + private static string MakeSafeFileName(string fileName) + { + string res = fileName; + char[] invs = Path.GetInvalidFileNameChars(); + foreach (char c in invs) + { + res = res.Replace(c, '_'); + } + return res; + } + } +} diff --git a/docs/TODO_PLAN.md b/docs/TODO_PLAN.md new file mode 100644 index 0000000..7c408fb --- /dev/null +++ b/docs/TODO_PLAN.md @@ -0,0 +1,13 @@ +# TODO PLAN + +- [ ] 支持 LiveSplit 的 .lss(LiveSplit Splits)格式导入导出 +- [ ] 支持 LiveSplit Server 协议通信,使 PalTimer 可以作为 LiveSplit 的数据源 +- [ ] 实现 ASL(Auto Split Language)兼容层,复用 LiveSplit 社区已有的 Auto Splitter 脚本 +- [ ] 支持 Sum of Best Segments 计算 +- [ ] 支持 Delta 显示(对比最佳/平均的时间差) +- [ ] 支持多种比较方式(Personal Best, Average, Median, Best Segments 等) +- [ ] 支持 Subsplits(子节点分组) +- [ ] 节点语音提示功能(支持 checkpoint 到达、快了/慢了提示、自定义 mp3/wav 与配置文件) +- [ ] 支持通过 WebSocket/HTTP 暴露实时计时数据给 OBS 等工具 +- [ ] 支持回放(Run History)统计和图表 +- [ ] 支持自定义主题/皮肤导入 diff --git a/docs/livesplit_pal98_kb.md b/docs/livesplit_pal98_kb.md new file mode 100644 index 0000000..93385a2 --- /dev/null +++ b/docs/livesplit_pal98_kb.md @@ -0,0 +1,85 @@ +# LiveSplit 与仙剑98 DX9 知识库 + +## 1) LiveSplit 简介 + +- LiveSplit 是著名的开源速通计时器项目。 +- GitHub: https://github.com/LiveSplit/LiveSplit +- 典型能力:分段计时(splits)、自动分段(Auto Splitter)、丰富布局组件、对外通信(如 LiveSplit Server)。 + +## 2) LiveSplit 与 PalTimer 对比 + +- **共同点**:都支持分段计时、PB/最佳段等速通核心场景。 +- **PalTimer 优势**:针对仙剑系列(尤其仙剑98 DX9)内核已做深度适配,已有检查点与内存读取逻辑。 +- **LiveSplit 优势**:生态成熟(ASL 脚本社区、组件体系、布局可定制、外部工具兼容广)。 + +## 3) 通过 Auto Splitter(ASL)支持仙剑98 DX9 + +LiveSplit 通常通过 ASL(Auto Split Language)脚本读取进程内存并驱动计时状态: + +- 识别目标进程(如 `PAL98.exe`/DX9 对应进程名) +- 读取关键状态(地图、剧情标志、战斗状态、章节变量等) +- 根据状态变化触发 `start/split/reset` +- 可通过 `isLoading` 控制计时是否扣除读盘/载入时间 + +## 4) ASL 脚本结构示例(简化) + +```asl +state("PAL98") +{ + int sceneId : 0x123456; + int flagMainQuest : 0x234567; +} + +start +{ + return old.flagMainQuest == 0 && current.flagMainQuest == 1; +} + +split +{ + // 示例:场景切换到某关键节点 + return old.sceneId != current.sceneId && current.sceneId == 2101; +} + +reset +{ + return current.flagMainQuest == 0 && current.sceneId == 1001; +} + +isLoading +{ + // 示例:按加载状态位判断 + return false; +} +``` + +## 5) 仙剑98 DX9 内存地址读取思路 + +可参考 PalTimer 现有内核做法: + +1. 先定位目标进程与模块基址 +2. 维护稳定的“检查点判定字段”(地图ID、剧情变量、道具标志等) +3. 用“状态边沿变化”判定节点触发(避免同一状态重复触发) +4. 把检查点与最佳时间线关联,生成当前段差与预计完赛时间 + +实践中建议: + +- 优先使用“语义稳定”的判定字段(剧情flag比瞬时动画状态更稳) +- 对易抖动状态加去抖/二次确认 +- 地址版本变化时通过签名或版本分支管理地址表。 + +## 6) LiveSplit Server 与 WebSocket 通信 + +LiveSplit Server 常用于把计时状态提供给外部工具(覆盖层、OBS脚本、远端控制器等): + +- 可通过 TCP 文本命令协议交互(开始/分段/重置/读状态) +- 在工程扩展里也可做 WebSocket 网关,将状态转发给网页或直播辅助层 +- PalTimer 若对齐该协议,可直接复用大量现有工具链。 + +## 7) 自定义 Layout 与组件配置 + +LiveSplit 的 Layout 可组合多种组件: + +- Timer / Splits / Delta / Previous Segment / Sum of Best / Graph 等 +- 支持字体、颜色、间距、阴影、背景等细粒度配置 +- 通过布局配置与组件组合,可以快速适配“练习版”“比赛版”“直播版”三类界面需求。 From 9b168b9eec1d12fd1da58177952661ce983659f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:53:48 +0000 Subject: [PATCH 3/3] chore: address validation feedback for voice prompt safety Agent-Logs-Url: https://github.com/othercat/PalTimer/sessions/fcd49260-9684-4808-b7e1-a4a693909463 Co-authored-by: othercat <509048+othercat@users.noreply.github.com> --- Pal98Timer/TimerCore.cs | 6 +- Pal98Timer/VoicePrompt.cs | 169 ++++++++++++++++++++++++++------------ docs/TODO_PLAN.md | 2 +- 3 files changed, 120 insertions(+), 57 deletions(-) diff --git a/Pal98Timer/TimerCore.cs b/Pal98Timer/TimerCore.cs index 979400e..ea0cd5d 100644 --- a/Pal98Timer/TimerCore.cs +++ b/Pal98Timer/TimerCore.cs @@ -520,12 +520,12 @@ protected void Checking() currentCheckPoint.Current = new TimeSpan(MT.CurrentTSOnly.Ticks); currentCheckPoint.IsEnd = true; VoicePrompt.PlayCheckpointSound(currentCheckPoint.GetNickName()); - long cha = currentCheckPoint.GetCHA(); - if (cha < 0) + long deltaSeconds = currentCheckPoint.GetCHA(); + if (deltaSeconds < 0) { VoicePrompt.PlayFasterSound(); } - else if (cha > 0) + else if (deltaSeconds > 0) { VoicePrompt.PlaySlowerSound(); } diff --git a/Pal98Timer/VoicePrompt.cs b/Pal98Timer/VoicePrompt.cs index 0881c42..86af4c7 100644 --- a/Pal98Timer/VoicePrompt.cs +++ b/Pal98Timer/VoicePrompt.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Reflection; using System.Runtime.InteropServices; using System.Text; @@ -26,7 +27,27 @@ private static string AppDir { get { - return Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName); + try + { + string p = Path.GetDirectoryName(Process.GetCurrentProcess().MainModule.FileName); + if (!string.IsNullOrEmpty(p)) + { + return p; + } + } + catch + { } + try + { + string p = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + if (!string.IsNullOrEmpty(p)) + { + return p; + } + } + catch + { } + return Environment.CurrentDirectory; } } @@ -59,66 +80,77 @@ private static void EnsureConfigLoaded() string cfgPath = Path.Combine(AppDir, "voice_config.txt"); if (File.Exists(cfgPath)) { - Encoding charset = TimerCore.GetFileEncodeType(cfgPath); - using (FileStream fs = new FileStream(cfgPath, FileMode.Open, FileAccess.Read)) + try { - using (StreamReader sr = new StreamReader(fs, charset)) + Encoding charset = TimerCore.GetFileEncodeType(cfgPath); + if (charset == null) { - while (!sr.EndOfStream) + charset = Encoding.UTF8; + } + using (FileStream fs = new FileStream(cfgPath, FileMode.Open, FileAccess.Read)) + { + using (StreamReader sr = new StreamReader(fs, charset)) { - string line = sr.ReadLine(); - if (line == null) - { - continue; - } - line = line.Trim(); - if (line == "" || line.StartsWith("#") || line.StartsWith(";") || line.StartsWith("//")) + while (!sr.EndOfStream) { - continue; - } - int idx = line.IndexOf('='); - if (idx <= 0) - { - continue; - } - string key = line.Substring(0, idx).Trim(); - string val = line.Substring(idx + 1).Trim(); - if (key == "" || val == "") - { - continue; - } - string lowKey = key.ToLowerInvariant(); - if (lowKey == "enable" || lowKey == "enabled" || lowKey == "voice_enable") - { - _isEnable = ParseBool(val, true); - } - else if (lowKey == "faster" || lowKey == "faster_sound") - { - _fasterPath = val; - } - else if (lowKey == "slower" || lowKey == "slower_sound") - { - _slowerPath = val; - } - else if (lowKey.StartsWith("checkpoint.")) - { - string checkpointName = key.Substring("checkpoint.".Length).Trim(); - if (checkpointName != "") + string line = sr.ReadLine(); + if (line == null) { - _checkpointSounds[checkpointName] = val; + continue; } - } - else if (lowKey.StartsWith("cp.")) - { - string checkpointName = key.Substring("cp.".Length).Trim(); - if (checkpointName != "") + line = line.Trim(); + if (line == "" || line.StartsWith("#") || line.StartsWith(";") || line.StartsWith("//")) + { + continue; + } + int idx = line.IndexOf('='); + if (idx <= 0) + { + continue; + } + string key = line.Substring(0, idx).Trim(); + string val = line.Substring(idx + 1).Trim(); + if (key == "" || val == "") + { + continue; + } + string lowKey = key.ToLowerInvariant(); + if (lowKey == "enable" || lowKey == "enabled" || lowKey == "voice_enable") + { + _isEnable = ParseBool(val, true); + } + else if (lowKey == "faster" || lowKey == "faster_sound") + { + _fasterPath = val; + } + else if (lowKey == "slower" || lowKey == "slower_sound") + { + _slowerPath = val; + } + else if (lowKey.StartsWith("checkpoint.")) { - _checkpointSounds[checkpointName] = val; + string checkpointName = key.Substring("checkpoint.".Length).Trim(); + if (checkpointName != "") + { + _checkpointSounds[checkpointName] = val; + } + } + else if (lowKey.StartsWith("cp.")) + { + string checkpointName = key.Substring("cp.".Length).Trim(); + if (checkpointName != "") + { + _checkpointSounds[checkpointName] = val; + } } } } } } + catch (Exception ex) + { + Debug.WriteLine("VoicePrompt config load failed: " + ex.Message); + } } _isLoaded = true; } @@ -158,21 +190,39 @@ public static void PlaySound(string filePath) { return; } + string normalizedPath; + try + { + normalizedPath = Path.GetFullPath(fullPath); + } + catch + { + return; + } + if (!IsAllowedAudioFile(normalizedPath)) + { + return; + } + if (normalizedPath.IndexOf('\r') >= 0 || normalizedPath.IndexOf('\n') >= 0 || normalizedPath.IndexOf('"') >= 0) + { + return; + } lock (_playLock) { try { mciSendString("close " + _playerAlias, null, 0, IntPtr.Zero); - string escaped = fullPath.Replace("\"", "\"\""); - int openRes = mciSendString("open \"" + escaped + "\" alias " + _playerAlias, null, 0, IntPtr.Zero); + int openRes = mciSendString("open \"" + normalizedPath + "\" alias " + _playerAlias, null, 0, IntPtr.Zero); if (openRes == 0) { mciSendString("play " + _playerAlias + " from 0", null, 0, IntPtr.Zero); } } - catch - { } + catch (Exception ex) + { + Debug.WriteLine("VoicePrompt.PlaySound error: " + ex.Message); + } } } @@ -278,5 +328,18 @@ private static string MakeSafeFileName(string fileName) } return res; } + + private static bool IsAllowedAudioFile(string path) + { + string ext = Path.GetExtension(path); + foreach (string item in _soundExts) + { + if (string.Equals(ext, item, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } } } diff --git a/docs/TODO_PLAN.md b/docs/TODO_PLAN.md index 7c408fb..3d7fc73 100644 --- a/docs/TODO_PLAN.md +++ b/docs/TODO_PLAN.md @@ -7,7 +7,7 @@ - [ ] 支持 Delta 显示(对比最佳/平均的时间差) - [ ] 支持多种比较方式(Personal Best, Average, Median, Best Segments 等) - [ ] 支持 Subsplits(子节点分组) -- [ ] 节点语音提示功能(支持 checkpoint 到达、快了/慢了提示、自定义 mp3/wav 与配置文件) +- [ ] 节点语音提示功能增强(图形化配置、试听、每节点独立开关与优先级) - [ ] 支持通过 WebSocket/HTTP 暴露实时计时数据给 OBS 等工具 - [ ] 支持回放(Run History)统计和图表 - [ ] 支持自定义主题/皮肤导入