顺便踩穿了 Windows 的 UIPI 权限隔离------那个"调用成功、按键却凭空消失"的坑。
一、起因:一把只能在游戏里吹的口琴
《三角洲行动》S11 赛季出了个限定道具,官方叫「守夜人口琴」,玩家喊它口风琴。它的玩法很简单:掏出琴,键盘上一共 8 个音键 ------Z X C V B N M 七个字母键吹出 do re mi fa sol la si,,(逗号)是高音 1;再按住鼠标中键把整排音升高半音,按住左键降八度、右键升八度。

问题在于------这玩意儿得手动一个音一个音地按。当然也有固定的几个谱子。
我把《打靶归来》的简谱摆在旁边试着吹了两句,手指就抽筋了。于是很自然地冒出一个念头:
我是写代码的,为什么不让代码替我按?
一开始我以为这是个玩具级需求:解析简谱 → 换算成键位 → 用 SendInput 模拟按键。三五百行的事。
结果它确实只有三百来行。但它让我在 Windows 的权限模型里结结实实地卡了一整个下午。
二、第一步:把「简谱」翻译成「按键」
先得搞清楚游戏的发声规则。口琴键盘上一共 8 个音键------7 个字母键 Z X C V B N M,加 1 个逗号键 ,(不是 7 个键,第 8 个音也不是靠鼠标凑出来的)。它们的映射是:
| 音符 | 键位 | 说明 |
|---|---|---|
| 1 2 3 4 5 6 7 | Z X C V B N M |
7 个字母键,基础七音 |
| 高音 1 | ,(逗号) |
第 8 个音键,唯一的高音键 |
| 高音 2~7 | 对应键 + 鼠标右键 | 右键 = 升八度 |
| 低音 1~7 | 对应键 + 鼠标左键 | 左键 = 降八度 |
| 升半音 | 对应键 + 鼠标中键 | 中键 = 升半音 |
也就是说:8 个音键只够吹出本位音,三个八度和半音全靠鼠标修饰键扩展出来。
有意思的地方就在这里:只有 8 个物理音键,却要覆盖三个八度加半音------靠的是"键盘键 + 鼠标修饰键"的组合。
所以真正的问题不是"哪个键对应哪个音",而是:给定一个绝对音高,怎么找到最优的键位组合?
比如中央 C,你可以按 1'(逗号),也可以按 R1(右键 + Z)。两个都能发声,但显然前者更省事。
我的做法是:把所有合法组合提前枚举成一张表,解析时直接查表,并且给每个组合打一个"代价分"------修饰键越少越好,同等条件下优先用逗号键。
python
# 枚举所有物理组合:(digit, hi, mods, pitch)
_PITCH_COMBOS = []
for _digit in range(1, 8):
for _hi in (0, 1):
if _hi == 1 and _digit != 1: # 高音键只有 1'
continue
for _mods in MOD_COMBOS:
_off = sum(MOD_OFFSET_SINGLE[m] for m in _mods)
_pitch = 60 + DEGREE_SEMIS[_digit] + 12 * _hi + _off
_PITCH_COMBOS.append((_digit, _hi, _mods, _pitch))
def resolve_pitch(pitch, root_semis=0):
"""绝对音高 -> 最优物理键位组合"""
target = 60 + pitch + root_semis
best = None
for digit, hi, mods, p in _PITCH_COMBOS:
if p == target:
score = (len(mods), -hi, digit) # 修饰键少 > 高音键 > 序号
if best is None or score < best[0]:
best = (score, digit, hi, mods)
return None if best is None else best[1:]
这张表一建好,简谱和 MIDI 就共用同一套解析逻辑 了------不管谱子从哪来,最终都归一到"绝对音高",再查表落到物理键位。转调也顺带解决了:换个根音,resolve_pitch 里加个偏移量就行。
简谱解析器本身支持的东西比我想象的多:
bash
音符:1 2 3 4 5 6 7;休止:0
时值:5_ 半拍 5- 两拍 5. 附点 5:1.25 精确拍数
升降:#4 升四 b7 降七
八度:1' 高八度 ,1 低八度
连音:5~ 5
踩坑记录:简谱的延长线
-必须紧贴音符 。5-是两拍,5 -会被解析器当成"孤立字符"丢掉,那个音就莫名其妙少了一拍。这个 bug 我在四首曲子上都犯了,排查时一脸问号。
三、第二步:让旋律有「灵魂」------毫秒级时序
第一版跑通之后,我发现声音是出来了,但听着像机器人打字。
原因有两个:一是所有音符时值都是整齐的一拍,二是按键时序太粗糙。第二点尤其关键------游戏的输入采样是有节奏的,你把修饰键和音符键同时按下,游戏很可能只采到其中一个。
参考社区开源宏工具的参数,我最终定下这么一套时序:
python
# ---- 演奏时序(毫秒)----
TRANSITION_GAP_MS = 10 # 相邻音符间的释放窗口
MODIFIER_SETTLE_MS = 24 # 修饰键按下后、音符键按下前的稳定时间
MIN_NOTE_HOLD_MS = 40 # 单音最短保持时间
每个音符的实际动作是这样一串:
python
hold = max(min_hold, note.beats * beat_ms - gap)
if note.mods: # 1. 修饰键先按下
self._wait_until(start - settle / 1000.0)
for m in note.mods:
input_sim.press_mouse(m) # 并稳定 24ms
self._wait_until(start)
input_sim.press_key(config.KEY_VK[note.key_name]) # 2. 音符键按下
self._wait_until(start + hold / 1000.0)
input_sim.release_key(config.KEY_VK[note.key_name]) # 3. 保持时值后松开
for m in reversed(note.mods): # 4. 反向释放修饰键
input_sim.release_mouse(m)
几个数字都是有理由的:
- 24ms 稳定期:修饰键先按住,等游戏采样到"现在是升半音状态"了,再按音符键。修饰键提前量不够,音就吹成本位音。
- 10ms 释放窗口:相邻音符之间必须留缝。两个音挨得太紧,游戏会采样到残留状态,听感上就是粘连、含糊。
- 40ms 最短保持:太快的一闪而过,游戏根本来不及判定发声。
还有个容易被忽略的点:Windows 默认定时器精度只有 15.6ms ,time.sleep(0.01) 可能实际睡 15ms 以上,节奏会整个糊掉。所以演奏开始时要:
python
ctypes.windll.winmm.timeBeginPeriod(1) # 把定时器精度提到 1ms
再配合"前 5ms 忙等、之后才 sleep"的混合等待策略,才把节奏掐准。
顺带一提,节奏也是要"设计"的。初版所有曲子都是均匀一拍,听感极其机械。后来我按曲目类型重写了时值:军歌按进行曲处理(八分音符推进 + 句尾长音),《欢乐颂》加附点,《雪绒花》按 3/4 圆舞曲的摆动来。旋律一个音没改,只是改了时值,味道就完全不同了。
四、第三步:不按键,也能先听一遍
调参最痛苦的地方在于:每改一次时序,都得切回游戏实吹一遍才能知道效果。这个反馈回路太长了。
于是我加了个「试听」功能:用纯 Python 现场合成一段口琴音色,先在软件里听,觉得对了再去游戏里试。
合成思路很朴素------口琴的声音本质是簧片振动,谐波很丰富,所以用一堆正弦波叠加,再配上"起音瞬态 + 余振衰减 + 轻颤音"的包络:
python
s = (math.sin(phase)
+ 0.55 * math.sin(2 * phase)
+ 0.45 * math.sin(3 * phase)
+ 0.35 * math.sin(4 * phase)
+ 0.25 * math.sin(5 * phase)
+ 0.15 * math.sin(6 * phase)) / 2.75
起音 12ms 模拟簧片"扑"的那一下,释放 50ms 模拟余振,再加上 5.5Hz、幅度 0.3% 的颤音------听起来就有那股子口琴味儿了。
整段波形写进临时 wav,用 winsound 异步播放,播完删掉。零第三方依赖,标准库全搞定。
五、翻车现场:游戏里一点动静都没有
到这里,软件已经能跑、能听、能调了。我信心满满地进游戏,掏出琴,点了「开始演奏」,倒计时结束......
游戏里,毫无反应。
软件状态栏写着"演奏中...",键盘灯也没闪。但游戏里的琴弦一动没动。
排查第一步:先怀疑自己
我写了几个诊断脚本,从最底层往上排:
- 直接看
SendInput的返回值 ------ 返回 1,也就是"成功发出去了 1 个事件"。 - 用底层键盘钩子(
WH_KEYBOARD_LL)监听系统输入链 ------ 钩子没捕到任何事件。 - 用 CapsLock 当探针 ------ 注入一次 CapsLock,系统的锁定状态没有翻转。
第 3 点是最致命的:如果按键真的进入了系统输入链,CapsLock 一定会切换状态。它没切,说明事件在到达系统之前就消失了。
但 SendInput 明明返回了成功。这就是整件事最反直觉的地方------它返回成功,却什么也没发生。
排查第二步:对比进程权限
既然输入注入本身"看似成功",那问题多半出在目标窗口 上。我写了个脚本,把游戏进程和我的工具进程的完整性级别(Integrity Level)都读出来:
| 进程 | 完整性 SID | 级别 |
|---|---|---|
三角洲行动 DeltaForceClient-Win64-Shipping |
S-1-16-12288 |
High(管理员) |
我的工具 python.exe |
S-1-16-8192 |
Medium(普通) |
真相大白:游戏是以管理员权限运行的,而我的工具是普通权限。
真相:UIPI(用户界面特权隔离)
Windows 从 Vista 起引入了一套安全机制叫 UIPI(User Interface Privilege Isolation) :低权限进程不能向高权限窗口注入输入。
这是防"提权攻击"的设计------否则任何一个小程序都能模拟按键去操作管理员窗口。
关键在于它的失败方式是静默的:
SendInput调用会成功返回 ,但按键会被系统在投递阶段悄悄丢弃。
所以你会看到"函数返回成功 + 系统里毫无痕迹 + 目标窗口毫无反应"这种诡异组合。这是整个排查过程中最消耗时间的地方------错误信息根本不存在,你得自己去找。
修复
解决办法很朴素:让工具和游戏拥有相同的权限级别。
但我不想每次手动右键"以管理员身份运行",于是做了个自动提权的启动脚本,并在程序里加了两道保险:
- 启动脚本:检测到不是管理员,就弹 UAC 重新拉起自己。
- 界面告警:没提权时,窗口底部显示一条橙色提示条。
- 注入自检 :
SendInput的返回值会被检查,失败时抛出带错误码的异常------错误码5直接翻译成"权限不足,请以管理员身份运行"。
改完之后再对比一次权限,终于对上了:
| 进程 | PID | 完整性级别 |
|---|---|---|
口琴自动演奏器 python |
18800 | High(管理员) |
| 三角洲行动 | 19372 | High(管理员) |
进游戏再试------琴,响了。
六、第二个坑:我自己写的提权脚本,把我自己坑了
本以为提权修好就完事了,结果用户反馈:"没启动软件啊。"
双击 bat,什么都没发生。
我盯着那个脚本看了半天,最后发现问题出在我自己写的两行"聪明"代码上:
bat
rem 罪魁祸首 1:用 for /f 调用 powershell 判断权限
for /f "usebackq tokens=*" %%i in (`powershell -NoProfile -Command "...(有引号有括号)..."`) do set "ELEV=%%i"
for /f 把 PowerShell 命令里的引号和括号 解析乱了,cmd 直接报 's' is not recognized,脚本在跑到 python 之前就退出了。
bat
rem 罪魁祸首 2:echo 里的中文全角括号
echo 正在请求管理员权限(模拟按键注入游戏所必需)...
echo 遇到全角括号 () 会把命令行截断------同样报错退出。
两个问题叠在一起,表现就是"双击,闪都不闪一下"。
修复方案:
bat
@echo off
cd /d "%~dp0"
if /i "%~1"=="elevated" goto run rem 用参数标记提权后的实例,防止 UAC 死循环
fltmc >nul 2>&1 rem 一行判断是否管理员,无引号括号陷阱
if not errorlevel 1 goto run
echo Requesting administrator privileges (required to inject keys into the game)...
powershell -NoProfile -Command "Start-Process -FilePath '%~f0' -ArgumentList elevated -Verb RunAs -WorkingDirectory '%~dp0'"
exit /b
:run
where py >nul 2>nul
if %errorlevel%==0 (
py -3.12 src\main.py
if errorlevel 1 py -3 src\main.py
) else (
where python >nul 2>nul
if %errorlevel%==0 ( python src\main.py ) else ( echo Python 3.10+ not found. )
)
三条经验,写 bat 的都该记住:
for /f里塞带引号括号的复杂命令,迟早出事------能用一条命令判断就别绕。- bat 里的中文,尤其是全角标点,是定时炸弹 ------非 ASCII 内容尽量别放进
echo。 - 提权脚本一定要防止自我循环 ------用一个参数(如
%1=elevated)标记"我已经提过了"。
七、最后聊聊曲谱:不凭空编旋律
工具写完了,接下来是最花时间的部分:往曲库里填歌。
我给曲库定了一条硬规矩:每首曲谱必须有依据,不许凭空编旋律。
于是每首谱子文件头都强制带上两行标注:
bash
# 歌名:打靶归来
# BPM:120
# 来源:牛宝源 词、王永泉 曲(1960年作);原调 1=bA 2/4;主旋律与苍强曲谱文本谱逐音一致
# 核对:已核对
2 5 | 2 5 | 3 2 | 1 ,5 | 2- | 2 5 2 5 | 1 ,7 ,5 | ,5 5 1 | 5- |
# 开头的注释行会被加载器自动忽略,所以标注信息不干扰演奏,但留下了可追溯的来源。
曲库现在有 36 首,分成了几个来源档位:
- 公有领域 / 权威教材(小星星、欢乐颂、送别......)------ 逐音核对,标「已核对」
- 军旅歌曲(打靶归来、团结就是力量、强军战歌......)------ 对照文本谱核对
- 游戏配乐 / 游戏内曲目 (星河追梦、风声未止四首)------ 只有图片谱或自动提取谱,如实标注「待人工核对」
有些曲目(比如《超级玛丽》《小燕子》)网上流传的版本差异很大,我就老老实实标上"待人工核对",并在文档里说明差异点------宁可标注"存疑",也不假装它是权威版本。
顺带一提,曲谱和代码是完全分离 的:songs/ 目录下丢一个 .txt 就是一首新歌,改谱、加歌完全不用碰代码。
八、收尾
整个项目最终是这样:
bash
delta_harmonica/
├── src/
│ ├── main.py # Tkinter 图形界面(入口)
│ ├── config.py # 键位映射与演奏时序配置
│ ├── engine.py # 演奏引擎 + 口琴音色试听合成
│ ├── input_sim.py # SendInput 键鼠模拟
│ ├── jianpu.py # 简谱解析器
│ ├── midi_import.py # MIDI 导入转谱
│ └── song_loader.py # 曲谱加载器
├── songs/ # 36 首曲谱(纯文本,与代码分离)
├── docs/ # 使用文档 + 曲谱来源总表
└── 启动演奏器.bat # Windows 一键启动(自动提权)
回头看,这个项目技术上并不复杂------SendInput、简谱正则、正弦波合成,都是很基础的东西。
但它让我重新体会了一件事:
在 Windows 上做输入模拟,最大的敌人从来不是"怎么发按键",而是"按键到底有没有真的到达"。
而 UIPI 这种**"返回成功但静默失败"**的设计,恰恰是最难排查的一类问题------它不报错、不抛异常、不留痕迹,你只能靠钩子、状态探针和进程权限对比,一层一层把真相挖出来。
整个过程里,真正有用的排查手段就三个:底层钩子看事件有没有进输入链、CapsLock 看系统状态有没有变、进程完整性级别看权限对不对等。 这三个探针一套下来,问题基本无处遁形。
最后照例免责声明一句:本工具只做键鼠模拟,不注入进程、不修改内存;自动演奏是否合规请以游戏官方规定为准,使用不当存在账号风险。
代码已开源,曲谱持续在补。如果你也在《三角洲行动》里吹琴,欢迎一起把谱子填满。