AutoHotkey v2 零基础快速入门 Part 10:给路线回放添加高精度计时,理解累计延迟与路线一致性

3 阅读24分钟

承接 Part 9。上一章,我们给路线录制器添加了重复回放功能。这一章,我们进一步观察回放过程:计划什么时候发送按键,脚本实际什么时候准备发送,为什么两者会逐渐偏离,以及为什么不能为了追赶时间而牺牲路线。

一、Part 9 留下的问题

Part 9 的回放核心逻辑是这样的:

PreviousEventTime := 0
for Event in RouteEvents {
    Interval := Event.Time - PreviousEventTime
    Sleep(Interval)
    SendEvent("{" Event.ScanCode " down}")
    PreviousEventTime := Event.Time
}

它每次只等"相邻两个事件之间的时间差"。看起来没问题,但实测日志会变成这样:

事件 1:   DelayMs = 0
事件 2:   DelayMs = 16
事件 3:   DelayMs = 31
事件 4:   DelayMs = 47
...
事件 54:  DelayMs = 1000

每轮末尾晚约 1 秒,跑 25 轮就是 25 秒的累计偏差。Part 9 的文章里我们把这归因于"逐事件累加 Sleep 误差"。Part 10 要回答三个问题:

  1. 这个误差到底从哪来?
  2. 为什么"等到绝对时刻"能消除它?
  3. AHK v2 里怎么把"等到绝对时刻"写得准、写得稳、还能随时按 F12 停?

二、误差从哪来:Sleep 不是精确的

2.1 Windows 的计时器精度

Windows 默认的系统计时器精度是 15.6ms(1/64 秒)。你调用 Sleep(16),实际可能睡 15ms,也可能睡 31ms,取决于当前 tick 落在哪里。

Sleep(16)   ; 实际耗时可能是 15ms 或 31ms

即使你调用 timeBeginPeriod(1) 把精度提到 1ms,Sleep(1) 实际也常是 1~2ms,而且有调度开销。

你可以把它理解成:你让程序“休息 16 毫秒”,但系统不一定能准时叫醒它。

想象你在等公交车:

  • 你希望 16 分钟后出发。
  • 但公交车不是随到随走,而是隔一段时间才来一班。
  • 到了你想出发的时间,如果车还没来,你就得继续等。

2.2 误差会累积

相对调度的每个间隔误差,会叠加到下一个间隔上:

计划间隔: 100  100  100  100
实际间隔: 115  115  115  115
实际时刻: 115  230  345  460   ← 误差线性增长

54 个事件,每个多约 18ms,累积起来正好约 1 秒。这就是日志里 DelayMs 从 0 涨到 1000 的原因。

2.3 为什么不能简单"少睡一点"

有人会想:那我 Sleep(Interval - 10) 不就行了?

不行。因为误差不是固定 10ms,它随系统负载、tick 位置波动。你减 10ms,可能这次刚好,下次又多了 5ms,再下次少了 3ms。开环补偿治标不治本。

正确的做法是闭环:每个事件都对准一个绝对时刻,晚了就少等,早了就多等。


三、绝对时间调度:每个事件对准自己的时刻

3.1 思路

路线里每个事件记录的是"相对于路线起点的绝对时间":

事件 1: 1000ms
事件 2: 1688ms
事件 3: 5813ms

回放时,先记下本轮开始的时刻 RoundStartMs,然后对每个事件计算它的绝对目标时刻:

TargetMs = RoundStartMs + Event.Time

然后一直等到 TargetMs,而不是等"和上一个事件的差值"。

3.2 误差不再累积

计划时刻: 100  200  300  400
实际时刻: 115  200  315  400
误差:     +15    0  +15    0

第 1 个事件晚了 15ms,第 2 个事件发现"已经 115ms 了,目标 200ms,再等 85ms 就行",于是准时。第 3 个又晚 15ms,第 4 个又追回来。误差有界,不增长。

3.3 代码

PlayOneRouteRound() {
    RoundStartMs := QpcMs()

    for EventIndex, Event in RouteEvents {
        PlannedTime := Round(Event.Time * PlaybackSpeed)
        TargetMs := RoundStartMs + PlannedTime

        ; 等到绝对时刻
        PreciseSleepUntilMs(TargetMs)

        SendEvent("{" Event.ScanCode " down}")
    }
}

注意:RoundStartMs 只在每轮开始时取一次,轮内所有事件都基于它计算目标时刻。这样某个事件晚了,不会影响后面事件的目标时刻。


四、高精度计时:为什么不用 A_TickCount

4.1 A_TickCount 的局限

A_TickCount 返回系统启动以来的毫秒数,够用,但:

  • 精度受系统计时器影响,默认 15.6ms;
  • 它是整数毫秒,短间隔事件(16ms)的舍入误差占比很大;
  • 不适合做"毫秒级对准"。

4.2 QueryPerformanceCounter

Windows 提供高精度计时器 QueryPerformanceCounter(QPC),精度通常是微秒级。AHK v2 通过 DllCall 调用:

InitQpc() {
    global QpcFreq
    DllCall("QueryPerformanceFrequency", "Int64*", &QpcFreq)
}

QpcMs() {
    global QpcFreq
    DllCall("QueryPerformanceCounter", "Int64*", &Count)
    return Count / QpcFreq * 1000
}

QueryPerformanceFrequency 返回每秒的计数频率(比如 10,000,000),QueryPerformanceCounter 返回当前计数。两者相除就是秒,乘 1000 就是毫秒。

4.3 为什么先除后乘

return Count / QpcFreq * 1000

而不是:

return Count * 1000 / QpcFreq   ; 不推荐

因为 Count 是 64 位整数,乘 1000 可能溢出,或者先转成浮点丢精度。先除后乘,中间结果是浮点,安全。

4.4 初始化要检查失败

DllCall 可能失败,QpcFreq 可能是 0。除以 0 会抛异常,所以在初始化时检查:

if !Freq
    throw Error("QueryPerformanceFrequency 返回 0,无法进行高精度计时。")

回放开始前调用 InitQpc(),失败就取消回放并提示,而不是跑到一半崩掉。


五、Sleep 精度:timeBeginPeriod 与最后几毫秒

5.1 临时提高系统计时器精度

DllCall("winmm\timeBeginPeriod", "UInt", 1)

这行告诉 Windows:"接下来一段时间我需要 1ms 精度的计时器。" 调用后,Sleep(1) 实际约 1~2ms,而不是 15.6ms。

用完要还,否则会影响系统功耗和其他程序:

DllCall("winmm\timeEndPeriod", "UInt", 1)

脚本里用 TimePeriodSet 标志记录是否设置过,在 CleanupBeforeExit 里恢复。

5.2 等到目标时刻的写法

PreciseSleepUntilMs(TargetMs) {
    global IsRoutePlaying

    while IsRoutePlaying {
        Remaining := TargetMs - QpcMs()

        if Remaining <= 0
            return true

        if Remaining > 3 {
            Sleep(Min(5, Max(1, Floor(Remaining) - 2)))
        } else {
            Sleep(1)
        }
    }

    return false
}

几个设计点:

为什么大块时间用 Sleep(5) 而不是 Sleep(Remaining)? 因为 Sleep(Remaining) 可能睡过头,醒来发现已经超过目标。每次睡一小段,醒来重新检查剩余时间,更稳。

为什么最后 3ms 用 Sleep(1) 而不是纯忙等? 纯忙等(while QpcMs() < TargetMs)会占满 CPU,AHK 的消息循环停止,F12 热键、窗口焦点检查都无法及时响应。Sleep(1) 在 timeBeginPeriod(1) 下精度足够,又能让出 CPU。

为什么循环条件里有 IsRoutePlaying? 这样 F12 紧急停止时,IsRoutePlaying 变成 false,等待循环立即退出,不会卡在 Sleep 里。

5.3 绝对调度的好处:不追求单次极致精度

因为每个事件都对准绝对时刻,单次晚 10ms 不影响下一个事件。所以 PreciseSleepUntilMs 不需要精确到 1ms 以内,只要"平均误差小、不累积"就够了。这让实现可以兼顾精度和响应性。


六、可中断等待:轮间等待与 F12

6.1 轮间等待不能直接用 Sleep

Part 9 的轮间等待是:

Sleep(PlaybackRepeatIntervalMs)

问题是:Sleep(8000) 期间,F12 按了也没用,脚本要等 8 秒才响应。

6.2 可中断等待

InterruptibleSleep(DurationMs) {
    global IsRoutePlaying

    if DurationMs <= 0
        return IsRoutePlaying

    EndTick := A_TickCount + DurationMs

    while A_TickCount < EndTick {
        if !IsRoutePlaying
            return false

        Remaining := EndTick - A_TickCount
        Sleep(Min(Remaining, 20))
    }

    return IsRoutePlaying
}

思路:把长等待切成 20ms 的小段,每段醒来检查 IsRoutePlaying。F12 把 IsRoutePlaying 设为 false,下一个 20ms 内就退出。

为什么用 A_TickCount 而不是 QPC? 轮间等待不参与事件计划时间,精度要求低,A_TickCount 足够,而且 InterruptibleSleep 本身会被调用很多次,用更轻量的 A_TickCount 更合适。

6.3 两种等待的分工

函数用途计时器精度要求
PreciseSleepUntilMs轮内事件对准QPC高
InterruptibleSleep轮间等待、倒计时A_TickCount低

这个分工很重要:不是所有等待都需要高精度,轮间 8 秒等待差 5ms 完全无所谓。


七、诊断日志:让误差可见

7.1 三个时间字段

PlannedTime  := Round(Event.Time * PlaybackSpeed)
ActualTime   := Round(QpcMs() - RoundStartMs)
DelayMs      := ActualTime - PlannedTime
  • PlannedTime:按路线和速度倍率算出的计划相对时刻;
  • ActualTime:实际发送前,相对于本轮开始的时刻;
  • DelayMs:实际减计划,正数表示晚了,负数表示早了。

7.2 日志只写内存,不写磁盘

AppendPlaybackLog(...) {
    global PlaybackLogRows
    PlaybackLogRows.Push({...})
}

每个事件都写磁盘会显著放大计时误差。正确做法是:事件之间只往数组里 push,回放全部结束后一次性写 CSV。

7.3 日志能告诉你什么

  • DelayMs 随事件序号增长 → 相对调度,误差累积;
  • DelayMs 在固定范围抖动 → 绝对调度,误差有界;
  • GameActive = 0 → 发送时窗口失去焦点,回放被中断;
  • 某个事件 DelayMs 异常大 → 那个位置系统负载高,或 Sleep 被拉长。

八、错误处理:不让一次异常毁掉整轮日志

8.1 try / catch / finally

回放主循环用 try ... catch ... finally 包住:

try {
    Loop PlaybackRepeatCount {
        ...
        if !PlayOneRouteRound()
            break
        ...
    }
} catch as Err {
    MsgBox("轮次:" CurrentPlaybackRound "`n错误:" Err.Message ...)
} finally {
    IsRoutePlaying := false
    ReleaseAllRecordedKeys()
    ReleaseCommonGameKeys()
    try SavePlaybackLogToCsv()
}

finally 是关键:无论正常结束、F12 中断、失焦、还是抛异常,都执行清理和日志保存。这样即使第 13 轮出错,前 12 轮的诊断日志还在。

8.2 单事件级别的错误定位

PlayOneRouteRound 里每个事件包了 try/catch:

try {
    ...
} catch as Err {
    throw Error(
        "第 " CurrentPlaybackRound " 轮第 " EventIndex " 个事件出错。"
        . "`n按键:" Event.Key " / 动作:" Event.Action
        . "`n原始错误:" Err.Message
    )
}

这样报错时能看到"第几轮、第几个事件、哪个键",而不是只有一行"DllCall 失败"。

8.3 资源清理

CleanupBeforeExit 在脚本退出时执行:

if TimePeriodSet {
    try DllCall("winmm\timeEndPeriod", "UInt", 1)
    TimePeriodSet := false
}

恢复系统计时器精度,释放按键,保存日志。自己设置的状态,自己负责恢复。


九、完整结构回顾

把 Part 10 新增的部分放回整个脚本,结构是这样的:

全局配置
  ├─ PlaybackRepeatIntervalMs = 8000
  ├─ QpcFreq, TimePeriodSet
  └─ ...

基础设置
  ├─ SetKeyDelay
  ├─ OnExit(CleanupBeforeExit)
  └─ InitializeKeyboardRecorder()

高精度计时
  ├─ InitQpc()          ; 初始化 QPC + timeBeginPeriod(1)
  ├─ QpcMs()            ; 返回高精度毫秒
  └─ PreciseSleepUntilMs(TargetMs)   ; 等到绝对时刻

录制(Part 9 不变)
  ├─ StartRouteRecording
  ├─ RecordAnyKeyboardEvent
  └─ StopRouteRecording

回放(Part 10 改动)
  ├─ PlayRecordedRoute          ; 轮次循环 + 轮间等待
  ├─ PlayOneRouteRound          ; 绝对时间调度
  ├─ InterruptibleSleep         ; 轮间可中断等待
  └─ ReplayKeyboardEvent

诊断日志
  ├─ InitializePlaybackLog
  ├─ AppendPlaybackLog
  └─ SavePlaybackLogToCsv

清理
  ├─ EmergencyStop              ; F12
  └─ CleanupBeforeExit          ; 退出时恢复计时器精度

十、小结

这一篇讲了什么

  1. Sleep 不精确,相对调度的误差会线性累积;
  2. 绝对时间调度让每个事件对准自己的目标时刻,误差有界;
  3. QPC 提供微秒级计时,替代 A_TickCount;
  4. timeBeginPeriod(1) 临时提高系统计时器精度,用完要 timeEndPeriod(1);
  5. PreciseSleepUntilMs 大块 Sleep + 最后 Sleep(1),兼顾精度和响应性;
  6. InterruptibleSleep 把长等待切段,保证 F12 能及时停;
  7. 诊断日志只写内存,结束后一次性落盘;
  8. try/catch/finally 保证异常时也保存日志、释放按键、恢复计时器精度。

下一篇预告

Part 11 可以往两个方向走:

  • 条件等待与状态检测:用 PixelGetColor / PixelSearch 判断游戏是否加载完成、是否回到起点,把"死等 8 秒"变成"加载完就走";
  • 多路线与流程编排:把多个 CSV 路线串起来,支持"跑图 → 偷窃 → 回城"这样的复合流程。
#Requires AutoHotkey v2.0
#SingleInstance Force
#Warn

; =============================================================================
; 八方旅人路线录制器 - AutoHotkey v2
; 功能:
; 1. 全键盘 Down/Up 录制
; 2. 路线 CSV 保存与加载
; 3. 多轮重复回放
; 4. 绝对时间调度回放,避免逐事件 Sleep 误差累积
; 5. 输出计划时间、实际发送时间、延迟、焦点状态等回放诊断日志
; 6. F12 紧急停止并释放按键
;
; 热键:
; F5  设置重复回放次数
; F6  查看脚本状态
; F7  捕获当前活动窗口作为游戏窗口
; F8  开始录制
; F9  停止录制并保存
; F10 回放路线
; F11 从 CSV 重新加载路线
; F12 紧急停止并释放按键
; =============================================================================

; =============================== 全局配置 ====================================
global GameHwnd := 0
global IsRouteRecording := false
global IsRoutePlaying := false
global RouteStartTick := 0
global RouteEvents := []
global RecordedKeyStates := Map()
global RegisteredKeyboardKeys := []
global RouteFile := A_ScriptDir "\octopath_route.csv"

; 回放诊断日志。每次按 F10 启动新的完整回放任务时覆盖旧日志。
global PlaybackLogFile := A_ScriptDir "\octopath_playback_log.csv"
global PlaybackLogRows := []

; 回放速度倍率:1.00 原速;0.90 更快;1.10 更慢。
global PlaybackSpeed := 1.00

; 重复回放次数。可以直接修改,也可以运行时按 F5 设置。
global PlaybackRepeatCount := 1

; 每轮路线结束后,到下一轮开始前的等待时间,单位毫秒。
global PlaybackRepeatIntervalMs := 8000

; 回放前倒计时秒数。仅在整个回放任务开始前执行一次。
global PlaybackCountdownSeconds := 3

; 当前正在执行第几轮,仅用于状态显示和日志记录。
global CurrentPlaybackRound := 0

; 是否实时显示最新录制事件。
global ShowRecordingEventToolTip := true

; 只在已捕获游戏窗口位于前台时记录。
global RecordOnlyWhenGameActive := true
global RecordingWasPausedForFocus := false
global RecordingPauseStartedTick := 0

; 高精度计时相关。
global QpcFreq := 0
global TimePeriodSet := false

; F5-F12 是脚本控制键,不写入路线。
global ExcludedRecordingKeys := Map(
    "F5", true,
    "F6", true,
    "F7", true,
    "F8", true,
    "F9", true,
    "F10", true,
    "F11", true,
    "F12", true
)

; ============================== 基础设置 =====================================
SetKeyDelay(10, 10)
CoordMode("Mouse", "Screen")
CoordMode("Pixel", "Screen")
CoordMode("ToolTip", "Screen")
OnExit(CleanupBeforeExit)

; 自动执行段在第一条热键定义处结束,因此启动初始化必须放在热键之前
InitializeKeyboardRecorder()
ShowTemporaryToolTip(
    "路线录制器已启动`n"
    . "F7 捕获窗口,F8 开始录制`n"
    . "F5 设置重复回放次数",
    3200
)

; ============================== 控制热键. =====================================
F5::SetPlaybackRepeatCount()
F6::ShowRecorderStatus()
F7::CaptureGameWindow()
F8::StartRouteRecording()
F9::StopRouteRecording()
F10::PlayRecordedRoute()
F11::LoadRouteFromCsv()
F12::EmergencyStop()

; =============================================================================
; 高精度计时初始化
; 同时把 Windows 系统计时器精度临时提高到 1ms,退出时恢复
; =============================================================================
InitQpc() {
    global QpcFreq, TimePeriodSet

    if !QpcFreq {
        Freq := 0
        try {
            DllCall("QueryPerformanceFrequency", "Int64*", &Freq)
        } catch as Err {
            throw Error("QueryPerformanceFrequency 调用失败:" Err.Message)
        }

        if !Freq
            throw Error("QueryPerformanceFrequency 返回 0,无法进行高精度计时。")

        QpcFreq := Freq
    }

    if !TimePeriodSet {
        try DllCall("winmm\timeBeginPeriod", "UInt", 1)
        TimePeriodSet := true
    }
}

; =============================================================================
; 返回高精度毫秒时间.
; 先除后乘,避免 Count * 1000 在 AHK v2 中溢出或丢精度
; =============================================================================
QpcMs() {
    global QpcFreq

    if !QpcFreq
        InitQpc()

    Count := 0
    try {
        DllCall("QueryPerformanceCounter", "Int64*", &Count)
    } catch as Err {
        throw Error("QueryPerformanceCounter 调用失败:" Err.Message)
    }

    if !QpcFreq
        throw Error("QpcFreq 为 0,无法计算高精度时间。")

    return Count / QpcFreq * 1000
}

; =============================================================================
; 等到指定的绝对毫秒时间点.
; 大块时间用 Sleep;最后阶段用 Sleep(1),不再纯忙等,
; 保证 F12 热键和窗口焦点检查能及时响应。
; 因为回放是绝对时间调度,即使某个事件晚了几毫秒,下一个事件会追赶,
; 不会像旧逻辑那样逐事件累积误差。
; =============================================================================
PreciseSleepUntilMs(TargetMs) {
    global IsRoutePlaying

    while IsRoutePlaying {
        if !EnsurePlaybackWindowActive()
            return false

        Remaining := TargetMs - QpcMs()

        if Remaining <= 0
            return true

        if Remaining > 3 {
            Sleep(Min(5, Max(1, Floor(Remaining) - 2)))
        } else {
            ; timeBeginPeriod(1) 下 Sleep(1) 约 1~2ms,
            ; 比纯忙等更能保持消息循环响应。
            Sleep(1)
        }
    }

    return false
}

; =============================================================================
; 设置重复回放次数
; =============================================================================
SetPlaybackRepeatCount() {
    global PlaybackRepeatCount, IsRouteRecording, IsRoutePlaying

    if IsRouteRecording {
        ShowTemporaryToolTip("录制期间不能修改回放次数。", 1800)
        return
    }

    if IsRoutePlaying {
        ShowTemporaryToolTip("回放期间不能修改回放次数。", 1800)
        return
    }

    Result := InputBox(
        "请输入路线重复回放次数。`n范围:1 至 999。`n`n"
        . "当前设置:" PlaybackRepeatCount,
        "设置重复回放次数",
        "w360 h175",
        PlaybackRepeatCount
    )

    if Result.Result != "OK"
        return

    InputValue := Trim(Result.Value)

    if !IsInteger(InputValue) {
        MsgBox("请输入整数,例如 1、3 或 10。", "输入无效", "Icon!")
        return
    }

    NewCount := Integer(InputValue)

    if NewCount < 1 || NewCount > 999 {
        MsgBox("重复次数必须在 1 至 999 之间。", "输入无效", "Icon!")
        return
    }

    PlaybackRepeatCount := NewCount
    ShowTemporaryToolTip("重复回放次数已设置为:" PlaybackRepeatCount, 2200)
}

; =============================================================================
; 注册大部分键盘扫描码的 Down/Up 监听
; =============================================================================
InitializeKeyboardRecorder() {
    global RegisteredKeyboardKeys
    RegisteredKeyboardKeys := []

    Loop 0x1FF {
        ScanCode := Format("sc{:03X}", A_Index)
        KeyName := GetKeyName(ScanCode)

        if KeyName = "" || IsExcludedRecordingKey(KeyName)
            continue

        try {
            ; $ 防止脚本自己的 SendEvent 再触发录制。
            ; ~ 不拦截真实按键,按键仍传给游戏。
            ; * 无论修饰键状态如何都能触发。
            Hotkey(
                "$~*" ScanCode,
                RecordAnyKeyboardEvent.Bind(ScanCode, KeyName, "Down"),
                "On"
            )

            Hotkey(
                "$~*" ScanCode " Up",
                RecordAnyKeyboardEvent.Bind(ScanCode, KeyName, "Up"),
                "On"
            )

            RegisteredKeyboardKeys.Push({
                ScanCode: ScanCode,
                KeyName: KeyName
            })
        } catch {
            ; 某些扫描码无法注册,忽略即可。
        }
    }
}

; =============================================================================
; 捕获当前活动窗口
; =============================================================================
CaptureGameWindow() {
    global GameHwnd, IsRouteRecording, IsRoutePlaying

    if IsRouteRecording || IsRoutePlaying {
        ShowTemporaryToolTip("录制或回放期间不能重新捕获窗口。", 1800)
        return
    }

    ActiveHwnd := WinExist("A")
    if !ActiveHwnd {
        MsgBox("没有找到当前活动窗口。")
        return
    }

    try {
        WindowTitle := WinGetTitle("ahk_id " ActiveHwnd)
        ProcessName := WinGetProcessName("ahk_id " ActiveHwnd)
    } catch as Err {
        MsgBox("无法读取窗口信息。`n`n错误:" Err.Message)
        return
    }

    GameHwnd := ActiveHwnd

    ToolTip(
        "游戏窗口捕获成功`n"
        . "标题:" WindowTitle "`n"
        . "进程:" ProcessName "`n"
        . "将角色放到固定起点后按 F8",
        20,
        20
    )
    SetTimer(HideToolTip, -4000)
}

; =============================================================================
; 开始录制
; =============================================================================
StartRouteRecording() {
    global GameHwnd, IsRouteRecording, IsRoutePlaying
    global RouteStartTick, RouteEvents, RecordedKeyStates
    global RecordingWasPausedForFocus, RecordingPauseStartedTick

    if IsRoutePlaying {
        ShowTemporaryToolTip("当前正在回放,不能开始录制。", 1800)
        return
    }

    if IsRouteRecording {
        ShowTemporaryToolTip("路线录制已经开始。", 1500)
        return
    }

    if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
        GameHwnd := 0
        MsgBox("游戏窗口无效。`n`n请激活游戏窗口后按 F7。")
        return
    }

    if !WinActive("ahk_id " GameHwnd) {
        MsgBox("游戏窗口当前不在前台。`n`n请先点击游戏窗口,再按 F8。")
        return
    }

    ReleaseAllRecordedKeys()
    ReleaseCommonGameKeys()

    RouteEvents := []
    RecordedKeyStates := Map()
    RecordingWasPausedForFocus := false
    RecordingPauseStartedTick := 0
    RouteStartTick := A_TickCount
    IsRouteRecording := true

    SetTimer(CheckRecordingWindowFocus, 100)

    ToolTip(
        "全键盘路线录制已开始`n"
        . "仅记录游戏窗口中的键盘事件`n"
        . "F5-F12 不会写入路线`n"
        . "到达目标后按 F9",
        20,
        20
    )
}

; =============================================================================
; 记录一个键盘事件
; =============================================================================
RecordAnyKeyboardEvent(ScanCode, KeyName, Action, *) {
    global GameHwnd, IsRouteRecording, IsRoutePlaying
    global RouteStartTick, RouteEvents, RecordedKeyStates
    global RecordOnlyWhenGameActive, ShowRecordingEventToolTip

    if !IsRouteRecording || IsRoutePlaying
        return

    if IsExcludedRecordingKey(KeyName)
        return

    if RecordOnlyWhenGameActive {
        if !GameHwnd || !WinActive("ahk_id " GameHwnd)
            return
    }

    ResumeRouteRecordingClock()

    if !RecordedKeyStates.Has(ScanCode)
        RecordedKeyStates[ScanCode] := false

    if Action = "Down" {
        ; 忽略长按产生的重复 Down。
        if RecordedKeyStates[ScanCode]
            return
        RecordedKeyStates[ScanCode] := true
    } else {
        ; 忽略没有对应 Down 的孤立 Up。
        if !RecordedKeyStates[ScanCode]
            return
        RecordedKeyStates[ScanCode] := false
    }

    RelativeTime := A_TickCount - RouteStartTick

    RouteEvents.Push({
        Time: RelativeTime,
        Key: KeyName,
        ScanCode: ScanCode,
        Action: Action
    })

    if ShowRecordingEventToolTip {
        ToolTip(
            "正在录制游戏路线`n"
            . "事件数量:" RouteEvents.Length "`n"
            . "按键:" KeyName "`n"
            . "动作:" Action "`n"
            . "时间:" RelativeTime " ms`n"
            . "按 F9 停止",
            20,
            20
        )
    }
}

; =============================================================================
; 录制期间监控窗口焦点
; =============================================================================
CheckRecordingWindowFocus() {
    global GameHwnd, IsRouteRecording, RecordingWasPausedForFocus
    global RouteStartTick, RecordingPauseStartedTick

    if !IsRouteRecording {
        SetTimer(CheckRecordingWindowFocus, 0)
        return
    }

    if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
        StopRouteRecordingBecauseWindowLost()
        return
    }

    if !WinActive("ahk_id " GameHwnd) {
        if !RecordingWasPausedForFocus {
            RecordingPauseStartedTick := A_TickCount
            RecordingWasPausedForFocus := true
            CloseHeldKeysAtTime(RecordingPauseStartedTick - RouteStartTick)
        }

        ToolTip(
            "录制已暂停`n"
            . "游戏窗口当前不在前台`n"
            . "重新激活游戏后继续记录",
            20,
            20
        )
        return
    }

    if RecordingWasPausedForFocus {
        ; 将失焦时长从路线时钟中扣除,恢复后路线时间继续从暂停点累计
        ResumeRouteRecordingClock()
        ToolTip("游戏窗口已恢复前台`n路线录制继续", 20, 20)
    }
}

; =============================================================================
; 恢复路线录制时钟 
; 比如暂停5000ms,恢复后路线起始时间需要增加这段暂停的时间,从而获得更精准的相对时间
; =============================================================================
ResumeRouteRecordingClock() {
    global RouteStartTick, RecordingPauseStartedTick, RecordingWasPausedForFocus

    if !RecordingWasPausedForFocus
        return

    RouteStartTick += A_TickCount - RecordingPauseStartedTick
    RecordingPauseStartedTick := 0
    RecordingWasPausedForFocus := false
}

; =============================================================================
; 当找不到游戏窗口时,停止录制并保存
; =============================================================================
StopRouteRecordingBecauseWindowLost() {
    global IsRouteRecording

    if !IsRouteRecording
        return

    IsRouteRecording := false
    SetTimer(CheckRecordingWindowFocus, 0)
    ReleaseAllRecordedKeys()
    ReleaseCommonGameKeys()
    MsgBox("游戏窗口已关闭或句柄失效。`n本次录制已停止,未自动保存。")
}

; =============================================================================
; 停止录制并保存
; =============================================================================
StopRouteRecording() {
    global IsRouteRecording, RouteEvents, RouteStartTick, RouteFile
    global RecordingWasPausedForFocus, RecordingPauseStartedTick

    if !IsRouteRecording {
        ShowTemporaryToolTip("当前没有正在进行的路线录制。", 1800)
        return
    }

    StopTime := RecordingWasPausedForFocus
        ? RecordingPauseStartedTick - RouteStartTick
        : A_TickCount - RouteStartTick
    IsRouteRecording := false
    RecordingWasPausedForFocus := false
    RecordingPauseStartedTick := 0
    SetTimer(CheckRecordingWindowFocus, 0)

    CloseHeldKeysAtTime(StopTime)
    ReleaseAllRecordedKeys()
    ReleaseCommonGameKeys()

    if RouteEvents.Length = 0 {
        ShowTemporaryToolTip("没有录制到游戏窗口中的键盘事件。", 2200)
        return
    }

    SortRouteEventsByTime()

    try SaveRouteToCsv()
    catch as Err {
        MsgBox("路线保存失败。`n`n错误:" Err.Message)
        return
    }

    ToolTip(
        "路线录制完成并已保存`n"
        . "事件数量:" RouteEvents.Length "`n"
        . "总时长:" Round(GetRouteDuration() / 1000, 2) " 秒`n"
        . "文件:" RouteFile "`n"
        . "按 F5 设置次数,按 F10 回放",
        20,
        20
    )
    SetTimer(HideToolTip, -5000)
}

; =============================================================================
; 释放当前按住的所有键.
; =============================================================================
CloseHeldKeysAtCurrentTime() {
    global RouteStartTick
    CloseHeldKeysAtTime(A_TickCount - RouteStartTick)
}

; =============================================================================
; 释放当前按住的所有键
; =============================================================================
CloseHeldKeysAtTime(EventTime) {
    global RouteEvents, RecordedKeyStates

    for ScanCode, IsPressed in RecordedKeyStates {
        if !IsPressed
            continue

        KeyName := GetKeyName(ScanCode)
        if KeyName = ""
            KeyName := ScanCode

        RouteEvents.Push({
            Time: EventTime,
            Key: KeyName,
            ScanCode: ScanCode,
            Action: "Up"
        })

        ; 补记路线松键时,也发送实际松键,避免按键状态残留。
        try SendEvent("{" ScanCode " up}")
        RecordedKeyStates[ScanCode] := false
    }
}

; =============================================================================
; 按时间稳定排序
; =============================================================================
SortRouteEventsByTime() {
    global RouteEvents

    Count := RouteEvents.Length
    if Count < 2
        return

    Loop Count - 1 {
        SourceIndex := A_Index + 1
        CurrentEvent := RouteEvents[SourceIndex]
        InsertIndex := SourceIndex - 1

        while InsertIndex >= 1
            && RouteEvents[InsertIndex].Time > CurrentEvent.Time {

            RouteEvents[InsertIndex + 1] := RouteEvents[InsertIndex]
            InsertIndex -= 1
        }
        RouteEvents[InsertIndex + 1] := CurrentEvent
    }
}

; =============================================================================
; 保存和加载路线 CSV.
; =============================================================================
SaveRouteToCsv() {
    global RouteEvents, RouteFile

    CsvContent := "TimeMs,ScanCode,Action,KeyName`n"

    for Event in RouteEvents {
        CsvContent .= Event.Time ","
            . EscapeCsvField(Event.ScanCode) ","
            . EscapeCsvField(Event.Action) ","
            . EscapeCsvField(Event.Key) "`n"
    }

    ; 先完整写入同目录临时文件,再替换正式路线,避免写入失败时丢失旧文件。
    TempRouteFile := RouteFile ".tmp"
    try {
        if FileExist(TempRouteFile)
            FileDelete(TempRouteFile)

        FileAppend(CsvContent, TempRouteFile, "UTF-8")
        FileMove(TempRouteFile, RouteFile, true)
    } catch as Err {
        if FileExist(TempRouteFile)
            try FileDelete(TempRouteFile)
        throw Err
    }
}

; =============================================================================
; 加载路线 CSV
; =============================================================================
LoadRouteFromCsv() {
    global RouteEvents, RouteFile, IsRouteRecording, IsRoutePlaying
    global PlaybackRepeatCount

    if IsRouteRecording || IsRoutePlaying {
        ShowTemporaryToolTip("录制或回放期间不能加载路线。", 1800)
        return false
    }

    if !FileExist(RouteFile) {
        MsgBox("路线文件不存在:`n" RouteFile)
        return false
    }

    try FileContent := FileRead(RouteFile, "UTF-8")
    catch as Err {
        MsgBox("路线文件读取失败。`n`n错误:" Err.Message)
        return false
    }

    LoadedEvents := []
    Lines := StrSplit(FileContent, "`n", "`r")

    for LineNumber, Line in Lines {
        if LineNumber = 1 || Trim(Line) = ""
            continue

        Columns := ParseCsvLine(Line)
        if Columns.Length < 4
            continue

        TimeText := Trim(Columns[1])
        ScanCode := Trim(Columns[2])
        Action := Trim(Columns[3])
        KeyName := Trim(Columns[4])

        if !IsInteger(TimeText)
            continue
        if ScanCode = ""
            continue
        if Action != "Down" && Action != "Up"
            continue

        LoadedEvents.Push({
            Time: Integer(TimeText),
            ScanCode: ScanCode,
            Action: Action,
            Key: KeyName
        })
    }

    if LoadedEvents.Length = 0 {
        MsgBox("路线文件中没有有效的键盘事件。")
        return false
    }

    RouteEvents := LoadedEvents
    SortRouteEventsByTime()

    ToolTip(
        "路线读取成功`n"
        . "事件数量:" RouteEvents.Length "`n"
        . "总时长:" Round(GetRouteDuration() / 1000, 2) " 秒`n"
        . "重复次数:" PlaybackRepeatCount,
        20,
        20
    )
    SetTimer(HideToolTip, -3500)
    return true
}

; =============================================================================
; 用来把一个字段转换成合法的 CSV 格式,避免内容里的逗号或引号被误认为字段分隔符
; =============================================================================
EscapeCsvField(Value) {
    Value := String(Value)

    if InStr(Value, '"')
        Value := StrReplace(Value, '"', '""')

    if InStr(Value, ",")
        || InStr(Value, '"')
        || InStr(Value, "`n")
        || InStr(Value, "`r") {

        return '"' Value '"'
    }

    return Value
}

; =============================================================================
; 解析CSV每一行
; =============================================================================
ParseCsvLine(Line) {
    Fields := []
    CurrentField := ""
    InsideQuotes := false
    Index := 1
    LineLength := StrLen(Line)

    while Index <= LineLength {
        Character := SubStr(Line, Index, 1)

        if Character = '"' {
            if InsideQuotes
                && Index < LineLength
                && SubStr(Line, Index + 1, 1) = '"' {

                CurrentField .= '"'
                Index += 2
                continue
            }

            InsideQuotes := !InsideQuotes
            Index += 1
            continue
        }

        if Character = "," && !InsideQuotes {
            Fields.Push(CurrentField)
            CurrentField := ""
            Index += 1
            continue
        }

        CurrentField .= Character
        Index += 1
    }

    Fields.Push(CurrentField)
    return Fields
}

; =============================================================================
; 回放诊断日志
; =============================================================================
InitializePlaybackLog() {
    global PlaybackLogRows
    PlaybackLogRows := []
}

; =============================================================================
; 追加回放诊断日志
; =============================================================================
AppendPlaybackLog(
    RoundNumber,
    EventIndex,
    PlannedTime,
    ActualTime,
    DelayMs,
    KeyName,
    Action,
    GameActive
) {
    global PlaybackLogRows

    ; 只写入内存,不在事件之间访问磁盘,避免日志本身造成明显计时干扰。
    PlaybackLogRows.Push({
        Round: RoundNumber,
        EventIndex: EventIndex,
        PlannedTime: PlannedTime,
        ActualTime: ActualTime,
        DelayMs: DelayMs,
        Key: KeyName,
        Action: Action,
        GameActive: GameActive
    })
}

; =============================================================================
; 保存回放诊断日志
; =============================================================================
SavePlaybackLogToCsv() {
    global PlaybackLogRows, PlaybackLogFile

    CsvContent := "Round,EventIndex,PlannedTime,ActualTime,DelayMs,Key,Action,GameActive`n"

    for Row in PlaybackLogRows {
        CsvContent .= Row.Round ","
            . Row.EventIndex ","
            . Row.PlannedTime ","
            . Row.ActualTime ","
            . Row.DelayMs ","
            . EscapeCsvField(Row.Key) ","
            . EscapeCsvField(Row.Action) ","
            . Row.GameActive "`n"
    }

    if FileExist(PlaybackLogFile)
        FileDelete(PlaybackLogFile)

    FileAppend(CsvContent, PlaybackLogFile, "UTF-8")
}

; =============================================================================
; 重复回放路线
; =============================================================================
PlayRecordedRoute() {
    global GameHwnd, IsRouteRecording, IsRoutePlaying
    global RouteEvents, PlaybackSpeed, PlaybackCountdownSeconds
    global PlaybackRepeatCount, PlaybackRepeatIntervalMs, CurrentPlaybackRound
    global PlaybackLogFile

    if IsRouteRecording {
        ShowTemporaryToolTip("请先按 F9 停止录制。", 1800)
        return
    }

    if IsRoutePlaying {
        ShowTemporaryToolTip("路线已经在回放。", 1600)
        return
    }

    if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
        MsgBox("游戏窗口无效。`n请重新激活游戏窗口并按 F7 捕获。")
        return
    }

    if RouteEvents.Length = 0 {
        if !LoadRouteFromCsv()
            return
    }

    if PlaybackRepeatCount < 1 {
        MsgBox("重复回放次数无效,请按 F5 重新设置。")
        return
    }

    try WinActivate("ahk_id " GameHwnd)
    catch as Err {
        MsgBox("无法激活游戏窗口。`n`n错误:" Err.Message)
        return
    }

    if !WinWaitActive("ahk_id " GameHwnd, , 2) {
        MsgBox("游戏窗口未能获得焦点,回放已取消。")
        return
    }

    ; 回放开始前初始化高精度计时,并把系统计时器精度临时提高到 1ms。
    try InitQpc()
    catch as Err {
        MsgBox("高精度计时初始化失败,回放已取消。`n`n错误:" Err.Message)
        return
    }

    InitializePlaybackLog()
    IsRoutePlaying := true
    CurrentPlaybackRound := 0
    CompletedRounds := 0
    PlaybackErrorMessage := ""
    try {
        ; 初始化回放环境
        SetKeyDelay(-1, -1)
        ReleaseAllRecordedKeys()
        ReleaseCommonGameKeys()

        RemainingSeconds := PlaybackCountdownSeconds
        while RemainingSeconds > 0 {
            ToolTip(
                RemainingSeconds " 秒后开始回放`n"
                . "计划重复:" PlaybackRepeatCount " 次`n"
                . "按 F12 可紧急停止",
                20,
                20
            )
            if !InterruptibleSleep(1000)
                break
            RemainingSeconds -= 1
        }

        if IsRoutePlaying {
            Loop PlaybackRepeatCount {
                if !IsRoutePlaying
                    break

                CurrentPlaybackRound := A_Index

                ; 每一轮开始前确认窗口仍存在并处于前台。
                if !GameHwnd || !WinExist("ahk_id " GameHwnd) {
                    IsRoutePlaying := false
                    MsgBox("游戏窗口已不存在,重复回放已停止。")
                    break
                }

                if !WinActive("ahk_id " GameHwnd) {
                    IsRoutePlaying := false
                    ToolTip("游戏窗口失去焦点`n重复回放已停止", 20, 20)
                    break
                }

                ; 保证上一轮没有留下按住状态。
                ReleaseAllRecordedKeys()
                ReleaseCommonGameKeys()

                ToolTip(
                    "正在回放路线`n"
                    . "当前轮次:" CurrentPlaybackRound " / " PlaybackRepeatCount "`n"
                    . "事件数量:" RouteEvents.Length "`n"
                    . "速度倍率:" PlaybackSpeed "`n"
                    . "按 F12 可紧急停止",
                    20,
                    20
                )

                ; 执行单轮路线。返回 false 表示被中断或失焦。
                if !PlayOneRouteRound()
                    break

                CompletedRounds += 1

                ; 每轮结束后释放按键。
                ReleaseAllRecordedKeys()
                ReleaseCommonGameKeys()

                ; 最后一轮结束后不再等待。
                if CurrentPlaybackRound < PlaybackRepeatCount {
                    ToolTip(
                        "第 " CurrentPlaybackRound " 轮完成`n"
                        . "下一轮:" (CurrentPlaybackRound + 1)
                        . " / " PlaybackRepeatCount "`n"
                        . "等待:" PlaybackRepeatIntervalMs " ms`n"
                        . "按 F12 可停止",
                        20,
                        20
                    )

                    if !InterruptibleSleep(PlaybackRepeatIntervalMs)
                        break
                }
            }
        }
    } catch as Err {
        PlaybackErrorMessage := "路线回放过程中发生错误。`n`n"
            . "轮次:" CurrentPlaybackRound "`n"
            . "错误:" Err.Message "`n"
            . "行号:" Err.Line "`n"
            . "函数:" (Err.What != "" ? Err.What : "未知")
    } finally {
        IsRoutePlaying := false
        CurrentPlaybackRound := 0
        ReleaseAllRecordedKeys()
        ReleaseCommonGameKeys()

        ; 无论正常结束、F12 中断、失焦或异常,都尽量保存已收集的日志。
        try SavePlaybackLogToCsv()
    }

    if PlaybackErrorMessage != "" {
        MsgBox(PlaybackErrorMessage, "回放错误")
        return
    }

    ShowTemporaryToolTip(
        "重复回放结束`n"
        . "完整执行轮次:" CompletedRounds " / " PlaybackRepeatCount "`n"
        . "诊断日志:" PlaybackLogFile,
        3500
    )
}

; =============================================================================
; 执行一轮完整路线(绝对时间调度)
;
; 每个事件的目标时间是:
;     TargetMs = RoundStartMs + Round(Event.Time * PlaybackSpeed)
; 某个事件晚了不会推迟后续事件的目标时间,因此误差不会累积
; =============================================================================
PlayOneRouteRound() {
    global GameHwnd, IsRoutePlaying, RouteEvents, PlaybackSpeed
    global CurrentPlaybackRound

    RoundStartMs := QpcMs()

    for EventIndex, Event in RouteEvents {
        if !IsRoutePlaying
            return false

        if !WinActive("ahk_id " GameHwnd) {
            IsRoutePlaying := false
            ToolTip("游戏窗口失去焦点`n路线回放已停止", 20, 20)
            return false
        }

        try {
            ; 该事件在本轮中的计划相对时间(已按速度倍率缩放)。
            PlannedTime := Round(Event.Time * PlaybackSpeed)

            ; 绝对目标时间:本轮开始时间 + 计划相对时间。
            TargetMs := RoundStartMs + PlannedTime

            ; 等到绝对目标时间,而不是等待相邻事件时间差。
            if !PreciseSleepUntilMs(TargetMs)
                return false

            if !IsRoutePlaying
                return false

            ; 等待期间可能切换窗口,发送前再次检查。
            GameActive := WinActive("ahk_id " GameHwnd) ? 1 : 0

            ; ActualTime 是调用 SendEvent 之前,相对于本轮开始的实际时间。
            ActualTime := Round(QpcMs() - RoundStartMs)
            DelayMs := ActualTime - PlannedTime

            if !GameActive {
                AppendPlaybackLog(
                    CurrentPlaybackRound,
                    EventIndex,
                    PlannedTime,
                    ActualTime,
                    DelayMs,
                    Event.Key,
                    Event.Action,
                    0
                )

                IsRoutePlaying := false
                ToolTip("游戏窗口失去焦点`n路线回放已停止", 20, 20)
                return false
            }

            ; 保留原来的发送方式。
            ReplayKeyboardEvent(Event)

            ; 只把日志加入内存,不在事件之间写磁盘,避免额外放大计时误差。
            AppendPlaybackLog(
                CurrentPlaybackRound,
                EventIndex,
                PlannedTime,
                ActualTime,
                DelayMs,
                Event.Key,
                Event.Action,
                1
            )
        } catch as Err {
            throw Error(
                "第 " CurrentPlaybackRound " 轮第 " EventIndex " 个事件出错。"
                . "`n按键:" Event.Key " / 动作:" Event.Action
                . "`n原始错误:" Err.Message,
                -1,
                Err.Message
            )
        }
    }

    return IsRoutePlaying
}

; =============================================================================
; 普通可中断等待,用于轮次间隔,不参与事件计划时间
; =============================================================================
InterruptibleSleep(DurationMs) {
    global IsRoutePlaying

    if DurationMs <= 0
        return IsRoutePlaying

    EndTick := A_TickCount + DurationMs

    while A_TickCount < EndTick {
        if !EnsurePlaybackWindowActive()
            return false

        Remaining := EndTick - A_TickCount
        Sleep(Min(Remaining, 20))
    }

    return IsRoutePlaying
}

; =============================================================================
; 确保回放窗口处于激活状态,如果不是则停止回放
; =============================================================================
EnsurePlaybackWindowActive() {
    global GameHwnd, IsRoutePlaying

    if !IsRoutePlaying
        return false

    if GameHwnd && WinActive("ahk_id " GameHwnd)
        return true

    IsRoutePlaying := false
    ReleaseAllRecordedKeys()
    ReleaseCommonGameKeys()
    ToolTip("游戏窗口失去焦点或已关闭`n路线回放已停止", 20, 20)
    return false
}

; =============================================================================
; 回放原来的键盘事件
; =============================================================================
ReplayKeyboardEvent(Event) {
    global RecordedKeyStates

    ReplayKey := Event.ScanCode != "" ? Event.ScanCode : Event.Key
    if ReplayKey = ""
        return

    if Event.Action = "Down" {
        SendEvent("{" ReplayKey " down}")
        RecordedKeyStates[ReplayKey] := true
    } else {
        SendEvent("{" ReplayKey " up}")
        RecordedKeyStates[ReplayKey] := false
    }
}

; =============================================================================
; 释放监听的按键
; =============================================================================
ReleaseAllRecordedKeys() {
    global RecordedKeyStates

    for ScanCode, IsPressed in RecordedKeyStates {
        if IsPressed {
            try SendEvent("{" ScanCode " up}")
            RecordedKeyStates[ScanCode] := false
        }
    }
}

; =============================================================================
; 释放通用按键
; =============================================================================
ReleaseCommonGameKeys() {
    CommonKeys := [
        "Up", "Down", "Left", "Right",
        "w", "a", "s", "d",
        "Enter", "Escape", "Space", "Tab",
        "LShift", "RShift",
        "LControl", "RControl",
        "LAlt", "RAlt"
    ]

    for KeyName in CommonKeys {
        try SendEvent("{" KeyName " up}")
    }
}

; =============================================================================
; 紧急停止
; =============================================================================
EmergencyStop() {
    global IsRouteRecording, IsRoutePlaying, RecordedKeyStates
    global CurrentPlaybackRound

    WasRecording := IsRouteRecording
    WasPlaying := IsRoutePlaying
    StoppedRound := CurrentPlaybackRound

    IsRouteRecording := false
    IsRoutePlaying := false
    CurrentPlaybackRound := 0
    SetTimer(CheckRecordingWindowFocus, 0)

    ReleaseAllRecordedKeys()
    ReleaseCommonGameKeys()
    RecordedKeyStates := Map()

    ; 如果回放被中止,保存截至中止时已经收集的诊断日志。
    if WasPlaying {
        try SavePlaybackLogToCsv()
    }

    Message := "自动化已紧急停止`n所有常用按键已释放"

    if WasRecording
        Message .= "`n未完成的录制不会自动保存"
    else if WasPlaying
        Message .= "`n已中止第 " StoppedRound " 轮回放`n诊断日志已保存"

    ToolTip(Message, 20, 20)
    SetTimer(HideToolTip, -3000)
}

; =============================================================================
; 退出清理
; =============================================================================
CleanupBeforeExit(ExitReason, ExitCode) {
    global IsRouteRecording, IsRoutePlaying, CurrentPlaybackRound
    global TimePeriodSet

    WasPlaying := IsRoutePlaying
    IsRouteRecording := false
    IsRoutePlaying := false
    CurrentPlaybackRound := 0
    SetTimer(CheckRecordingWindowFocus, 0)
    ReleaseAllRecordedKeys()
    ReleaseCommonGameKeys()

    if WasPlaying {
        try SavePlaybackLogToCsv()
    }

    ; 恢复系统计时器精度。
    if TimePeriodSet {
        try DllCall("winmm\timeEndPeriod", "UInt", 1)
        TimePeriodSet := false
    }
}

; =============================================================================
; 状态与辅助函数
; =============================================================================
ShowRecorderStatus() {
    global GameHwnd, IsRouteRecording, IsRoutePlaying
    global RouteEvents, RouteFile, PlaybackSpeed, RegisteredKeyboardKeys
    global PlaybackRepeatCount, PlaybackRepeatIntervalMs, CurrentPlaybackRound
    global PlaybackLogFile

    if IsRouteRecording
        CurrentStatus := "正在录制"
    else if IsRoutePlaying
        CurrentStatus := "正在回放第 " CurrentPlaybackRound " 轮"
    else
        CurrentStatus := "空闲"

    if GameHwnd && WinExist("ahk_id " GameHwnd) {
        try GameTitle := WinGetTitle("ahk_id " GameHwnd)
        catch
            GameTitle := "无法读取"
        WindowStatus := "已捕获:" GameTitle
    } else {
        WindowStatus := "尚未捕获游戏窗口"
    }

    DurationText := RouteEvents.Length > 0
        ? Round(GetRouteDuration() / 1000, 2) " 秒"
        : "无路线"

    ToolTip(
        "路线录制器状态`n"
        . "状态:" CurrentStatus "`n"
        . "窗口:" WindowStatus "`n"
        . "已注册键数:" RegisteredKeyboardKeys.Length "`n"
        . "路线事件数:" RouteEvents.Length "`n"
        . "单轮时长:" DurationText "`n"
        . "回放倍率:" PlaybackSpeed "`n"
        . "重复次数:" PlaybackRepeatCount "`n"
        . "轮次间隔:" PlaybackRepeatIntervalMs " ms`n"
        . "路线文件:" RouteFile "`n"
        . "日志文件:" PlaybackLogFile,
        20,
        20
    )

    SetTimer(HideToolTip, -6000)
}

; =============================================================================
; 获得总持续时间
; =============================================================================
GetRouteDuration() {
    global RouteEvents

    return RouteEvents.Length = 0
        ? 0
        : RouteEvents[RouteEvents.Length].Time
}

; =============================================================================
; 排除录制相关快捷键
; =============================================================================
IsExcludedRecordingKey(KeyName) {
    global ExcludedRecordingKeys

    NormalizedName := GetKeyName(KeyName)
    if NormalizedName = ""
        NormalizedName := KeyName

    return ExcludedRecordingKeys.Has(NormalizedName)
}

; =============================================================================
; 隐藏临时提示
; =============================================================================
ShowTemporaryToolTip(Message, DurationMs := 1500) {
    ToolTip(Message, 20, 20)
    SetTimer(HideToolTip, -DurationMs)
}

; =============================================================================
; 隐藏提示
; =============================================================================
HideToolTip() {
    ToolTip()
}