* fix(installer): TSF DLL 改装到系统目录,CS2 等 Trusted Mode 游戏里可以切出输入法 CS2 的 Trusted Mode 只放行位于系统目录且有代码签名的第三方 in-proc DLL, 装在 Program Files 下的 DLL 会被拒绝加载,游戏里切不出输入法。 x64 DLL 改装到 System32\IME\MetasequoiaIME\msime_v*,x86 装到 SysWOW64 下同名目录, 语言配置图标随之放到两处根目录;升级时清掉旧版留在 Program Files 的版本目录。 * fix(windows): CS2、Dota 2 不理会 UI-less 声明,照常显示候选窗 起源 2 引擎的 Valve 游戏以 UI-less 激活 TSF,但只替白名单里的输入法绘制候选。 我们据声明收起了自己的候选窗,游戏又不画,结果只剩左上角的系统默认 IME 小框,只能盲打。 对 cs2.exe、dota2.exe 忽略 UI-less,照常由 Server 绘制候选窗。 * fix(server): 前台为 D3D 独占全屏时不弹候选窗与光标状态提示 独占全屏下别的进程的窗口画不上去,弹出去反而可能把游戏踢出独占模式(闪黑、切分辨率、卡死)。 在 UI 线程真正显示前用 SHQueryUserNotificationState 判一次(300ms 缓存),独占全屏就收起候选窗, 并清掉已渲染可见标记,数字键与空格选词不再等待不会到来的绘制。 * fix(engine,server,windows): 游戏自绘候选时后台改动的候选列表实时同步给宿主 英雄联盟等 UI-less 宿主自己画候选。云候选、AI 候选、模型重排、英文混排在按键回复之后才改 候选列表,却从不通知 DLL,游戏还画着上一键读到的旧列表,数字键和空格却按新列表上屏, 于是「选的和上屏的不一样」。 - 契约新增 Worker opcode 35 UiLessCandidatesChanged,载荷与 UiLessComposition 同形, 由 uiless_page.h 统一格式化与解析,并附候选页代号;旧 DLL 读选中位时在第三个制表符处停下,兼容。 - Server 在上述后台改动之后把新页推给候选归属的客户端。 - DLL 收到后回到 TSF 线程核对焦点与组合纪元,更新候选 UI 元素并通知宿主重读。推送与按键回复 走不同管道,按代号只接受不旧于当前的页,避免旧页盖掉新页。 - 从共享内存重读到不同的页时高亮回到第一项,与 Server 每次重建都选中首项一致, 不再保留旧高亮造成「高亮这个、空格上屏那个」。 * feat(windows,server,ui-html,installer): 游戏兼容开关,CS2 / Dota 2 独占全屏打字不再黑屏 CS2、Dota 2 只替内置白名单(按输入法登记名称全等比对)里的输入法处理输入法消息。不在名单里时 组合落到 Windows 默认组合窗 CiceroUIWndFrame,左上角弹出编码小框;独占全屏下这个窗口一出现 游戏就被踢出全屏,表现为一打字就黑屏。清风输入法未开兼容时同样如此。 - 设置页帮助页新增「游戏兼容(CS2 / Dota 2)」:开启后把水杉在系统里登记的名称换成白名单里的 名字(默认「拼音输入法」),游戏会自己画候选。需要管理员权限:设置进程以 --apply-game-compat-name 提权重启自身,写 HKLM 记录值并改语言配置的 Description。 - TSF DLL 注册时以记录值为准,安装/升级重新注册不再把用户选的名称冲掉;卸载时删除记录值。 - 开启时不再对 cs2.exe / dota2.exe 忽略「宿主自己画候选」的声明,避免两个候选框。 - Server 的独占全屏判断覆盖进出独占模式时的 QUNS_BUSY 间隙,不在切换途中弹候选窗。 - TSF 诊断日志在组合开始/结束与 BeginUIElement 时记录宿主窗口状态(只有窗口与标志,没有文本)。 * fix(windows): 后台刷新回调持有加载器引用,宿主卸载 TSF DLL 后不再崩溃 当前输入法是水杉时,CS2 启动途中会加载再卸载 TSF DLL。诊断日志的线程池回调 FlushTsfDiagnosticLogs 正在 Sleep(250),只靠 DllAddRef 撑着:那只回答 DllCanUnloadNow, 挡不住宿主直接卸载。DLL 卸载后 SDL3 映射进同一段地址,回调醒来跑进 SDL3 的代码, 游戏崩溃(0xc0000005,崩溃转储里卸载模块的地址范围与崩溃地址吻合)。 - 诊断日志与统计上报的线程池回调改为用 GetModuleHandleEx 取加载器引用, 回调里 FreeLibraryWhenCallbackReturns,函数完全返回后才放手。 - 析构函数把 DllRelease 挪到最后,不在等待 IPC 线程退出之前就让 DLL 可卸载。 * fix(windows): 游戏自绘候选时不再把 Server 的 HTML 候选页当成候选交给游戏 CS2 开了游戏兼容后打一个码,游戏里的候选只有一项且是乱码。宿主要等 BeginUIElement 才说 自己画候选,而那时组合首键已经不带 UI-less 标记发给了 Server;Server 往共享内存写的是给 自己候选窗用的 HTML 页(辅助码、译文 span),DLL 的候选元素从共享内存回读后整串交给了游戏。 - 开了游戏兼容名称的 cs2.exe / dota2.exe 在 ActivateEx 就认定由游戏画候选,每个按键都带标记。 - 候选会话里收到过 Server 的 UI-less 页之后才从共享内存回读,之前一律不读。 * fix(windows): 游戏自绘候选时翻页能刷新,部分上屏后不再出现乱码候选 三角洲行动(虚幻引擎,以 TF_TMF_UIELEMENTENABLEDONLY 激活)里按 = / - / PageUp / PageDown 翻页,Server 已翻到第 N 页,游戏却还画着第一页;接着按空格上屏的是第 N 页的首项(多为单字), 剩下的拼音继续组合时候选框里出现乱码。 - 翻页、移高亮等键走异步的 WM_AsyncServerCandidateKey,以前把回复当作「只是确认」丢掉; UI-less 下这份回复就是新的候选页,现在应用并通知宿主重读。 - UI-less 的 _SetText 拿不到 Server 的候选页时不再退回本地引擎的占位列表——那不是候选, 宿主会画成一条乱码。部分上屏后从共享内存回读 Server 写好的新页;_ClearList 同时清掉上次 的页记录,页文本相同也会重新载入。
388 lines
26 KiB
C++
388 lines
26 KiB
C++
#include "../date_time_input.h"
|
||
#include "../direct_helpcode.h"
|
||
#include "../ipc_negotiation.h"
|
||
#include "../mid_sentence_helpcode.h"
|
||
#include "../trilingual_input.h"
|
||
#include "../uiless_page.h"
|
||
#include "../v_mode_input.h"
|
||
#include "../voice_composition_pipe.h"
|
||
|
||
#include <algorithm>
|
||
#include <array>
|
||
#include <cstring>
|
||
#include <iostream>
|
||
|
||
#define CHECK(expression) \
|
||
do \
|
||
{ \
|
||
if (!(expression)) \
|
||
{ \
|
||
std::cerr << #expression << " at " << __LINE__ << '\n'; \
|
||
return 1; \
|
||
} \
|
||
} while (false)
|
||
|
||
int main()
|
||
{
|
||
// Wire bytes, not just matching C++ declarations: existing v1 DLLs write
|
||
// these offsets on both x86 and x64. Changes must not move UTF-16 data.
|
||
std::array<unsigned char, 304> bytes{};
|
||
bytes[0] = 10; // ClientHello
|
||
bytes[8] = 7; // client id
|
||
FanyImeNamedpipeData legacy{};
|
||
std::memcpy(&legacy, bytes.data(), bytes.size());
|
||
CHECK(legacy.client_id == 7);
|
||
CHECK(FanyImePipeEventType::HideCaretState == 16);
|
||
CHECK(FanyImePipeEventType::HideCaretState != FanyImePipeEventType::HideCandidateWnd);
|
||
CHECK(!FanyImePipeEventType::IsRouteDeactivation(FanyImePipeEventType::HideCaretState));
|
||
CHECK(FanyImeProtocol::CaretStateIndicator == (1u << 5));
|
||
CHECK((FanyImeProtocol::CaretStateIndicator & FanyImeProtocol::RequiredCapabilities) == 0);
|
||
// CapsLockEdge keeps the VK_CAPITAL value the first Servers matched on.
|
||
CHECK(FanyImeCaretStateTrigger::UserToggle == 0);
|
||
CHECK(FanyImeCaretStateTrigger::CapsLockEdge == 0x14);
|
||
CHECK(FanyImeCaretStateTrigger::FocusEntered != FanyImeCaretStateTrigger::UserToggle);
|
||
CHECK(FanyImeCaretStateTrigger::FocusEntered != FanyImeCaretStateTrigger::CapsLockEdge);
|
||
CHECK(FanyImeProtocol::Negotiate(legacy).legacy);
|
||
CHECK(FanyImeProtocol::Negotiate(legacy).accepted);
|
||
|
||
const auto capsOffSnapshot = FanyImePipeFlags::EncodeImeSwitchCapsLockSnapshot(false);
|
||
const auto capsOnSnapshot = FanyImePipeFlags::EncodeImeSwitchCapsLockSnapshot(true);
|
||
CHECK((FanyImePipeFlags::ImeSwitchCapsSnapshotPresent & FanyImePipeFlags::UiLess) == 0);
|
||
CHECK((FanyImePipeFlags::ImeSwitchCapsSnapshotEnabled & FanyImePipeFlags::UiLess) == 0);
|
||
CHECK((FanyImePipeFlags::ImeSwitchCapsSnapshotPresent & 0xffu) == 0);
|
||
CHECK((FanyImePipeFlags::ImeSwitchCapsSnapshotEnabled & 0xffu) == 0);
|
||
CHECK(FanyImePipeFlags::HasImeSwitchCapsLockSnapshot(capsOffSnapshot));
|
||
CHECK(!FanyImePipeFlags::ImeSwitchCapsLockSnapshotEnabled(capsOffSnapshot));
|
||
CHECK(FanyImePipeFlags::HasImeSwitchCapsLockSnapshot(capsOnSnapshot));
|
||
CHECK(FanyImePipeFlags::ImeSwitchCapsLockSnapshotEnabled(capsOnSnapshot));
|
||
CHECK(!FanyImePipeFlags::HasImeSwitchCapsLockSnapshot(0)); // old DLL packet
|
||
CHECK(!FanyImePipeFlags::ImeSwitchCapsLockSnapshotEnabled(FanyImePipeFlags::ImeSwitchCapsSnapshotEnabled));
|
||
CHECK(FanyImePipeFlags::DecodeImeSwitchCapsLockSnapshot(capsOffSnapshot).has_value());
|
||
CHECK(!*FanyImePipeFlags::DecodeImeSwitchCapsLockSnapshot(capsOffSnapshot));
|
||
CHECK(*FanyImePipeFlags::DecodeImeSwitchCapsLockSnapshot(capsOnSnapshot));
|
||
CHECK(!FanyImePipeFlags::DecodeImeSwitchCapsLockSnapshot(0).has_value());
|
||
CHECK(
|
||
!FanyImePipeFlags::DecodeImeSwitchCapsLockSnapshot(FanyImePipeFlags::ImeSwitchCapsSnapshotEnabled).has_value());
|
||
|
||
auto hello = FanyImeProtocol::Hello(7, 19);
|
||
auto result = FanyImeProtocol::Negotiate(hello);
|
||
CHECK(result.accepted && !result.legacy);
|
||
auto reply = FanyImeProtocol::Reply(hello, result);
|
||
CHECK(FanyImeProtocol::AcceptReply(reply, 19));
|
||
CHECK(!FanyImeProtocol::AcceptReply(reply, 18)); // stale reconnect ACK
|
||
CHECK(!FanyImeProtocol::AcceptReply(reply, 0));
|
||
|
||
const auto shortcutCapabilities = FanyImeProtocol::Capabilities | FanyImeProtocol::CharacterSetShortcut;
|
||
const auto shortcutHello = FanyImeProtocol::Hello(7, 20, shortcutCapabilities);
|
||
const auto oldServer = FanyImeProtocol::Negotiate(shortcutHello);
|
||
CHECK(oldServer.accepted);
|
||
CHECK((oldServer.capabilities & FanyImeProtocol::CharacterSetShortcut) == 0);
|
||
const auto newServer = FanyImeProtocol::Negotiate(shortcutHello, shortcutCapabilities);
|
||
CHECK(newServer.accepted);
|
||
CHECK((newServer.capabilities & FanyImeProtocol::CharacterSetShortcut) != 0);
|
||
const auto shortcutReply = FanyImeProtocol::Reply(shortcutHello, newServer);
|
||
CHECK(FanyImeProtocol::AcceptReply(shortcutReply, 20));
|
||
CHECK(FanyImeProtocol::ReplyCapabilities(shortcutReply) == shortcutCapabilities);
|
||
CHECK((FanyImeProtocol::Negotiate(hello, shortcutCapabilities).capabilities &
|
||
FanyImeProtocol::CharacterSetShortcut) == 0); // old client/new server
|
||
CHECK(FanyImeProtocol::IsCharacterSetShortcut('F', 3));
|
||
CHECK(FanyImeProtocol::IsCharacterSetShortcut('F', 0x80000003u));
|
||
for (unsigned modifiers = 0; modifiers < 8; ++modifiers)
|
||
CHECK(FanyImeProtocol::IsCharacterSetShortcut('F', modifiers) == (modifiers == 3));
|
||
CHECK(!FanyImeProtocol::IsCharacterSetShortcut('E', 3));
|
||
|
||
// Backspace retraction is negotiated separately: an old Server must keep
|
||
// its current behavior, and an old DLL must never receive CompositionRestored.
|
||
const auto restoreCapabilities = FanyImeProtocol::Capabilities | FanyImeProtocol::CompositionRestore;
|
||
const auto restoreHello = FanyImeProtocol::Hello(7, 21, restoreCapabilities);
|
||
const auto oldServerForRestore = FanyImeProtocol::Negotiate(restoreHello);
|
||
CHECK(oldServerForRestore.accepted);
|
||
CHECK((oldServerForRestore.capabilities & FanyImeProtocol::CompositionRestore) == 0);
|
||
const auto newServerForRestore = FanyImeProtocol::Negotiate(restoreHello, restoreCapabilities);
|
||
CHECK(newServerForRestore.accepted);
|
||
CHECK((newServerForRestore.capabilities & FanyImeProtocol::CompositionRestore) != 0);
|
||
CHECK(FanyImeProtocol::AcceptReply(FanyImeProtocol::Reply(restoreHello, newServerForRestore), 21));
|
||
CHECK(FanyImeProtocol::ReplyCapabilities(FanyImeProtocol::Reply(restoreHello, newServerForRestore)) ==
|
||
restoreCapabilities);
|
||
CHECK((FanyImeProtocol::Negotiate(hello, restoreCapabilities).capabilities & FanyImeProtocol::CompositionRestore) ==
|
||
0); // old client/new server
|
||
CHECK(FanyImeReplyType::CompositionRestored == 14);
|
||
CHECK(FanyImeReplyType::TrilingualCycle == 15);
|
||
CHECK(FanyImeReplyType::MaxKnown == FanyImeReplyType::TrilingualCycle);
|
||
CHECK(FanyImeReplyType::TransportUnavailable > FanyImeReplyType::MaxKnown);
|
||
|
||
const auto cycleCapabilities = FanyImeProtocol::Capabilities | FanyImeProtocol::TrilingualCycle;
|
||
const auto cycleHello = FanyImeProtocol::Hello(7, 22, cycleCapabilities);
|
||
const auto oldServerForCycle = FanyImeProtocol::Negotiate(cycleHello);
|
||
CHECK(oldServerForCycle.accepted);
|
||
CHECK((oldServerForCycle.capabilities & FanyImeProtocol::TrilingualCycle) == 0);
|
||
const auto newServerForCycle = FanyImeProtocol::Negotiate(cycleHello, cycleCapabilities);
|
||
CHECK(newServerForCycle.accepted && !newServerForCycle.legacy);
|
||
CHECK((newServerForCycle.capabilities & FanyImeProtocol::TrilingualCycle) != 0);
|
||
CHECK(FanyImeProtocol::AcceptReply(FanyImeProtocol::Reply(cycleHello, newServerForCycle), 22));
|
||
CHECK((FanyImeProtocol::Negotiate(hello, cycleCapabilities).capabilities & FanyImeProtocol::TrilingualCycle) == 0);
|
||
CHECK((FanyImeProtocol::TrilingualCycle & FanyImeProtocol::RequiredCapabilities) == 0);
|
||
|
||
{
|
||
using FanyImeTrilingualInput::Mode;
|
||
CHECK(FanyImeTrilingualInput::Next(true, false) == Mode::Japanese);
|
||
CHECK(FanyImeTrilingualInput::Next(true, true) == Mode::English);
|
||
CHECK(FanyImeTrilingualInput::Next(false, true) == Mode::Chinese);
|
||
CHECK(FanyImeTrilingualInput::Next(false, false) == Mode::Chinese);
|
||
// A plain VK_SHIFT is the binary toggle; only the explicit request bit cycles.
|
||
CHECK(!FanyImeTrilingualInput::IsCycleRequest(0x10, 0));
|
||
CHECK(!FanyImeTrilingualInput::IsCycleRequest(0x10, FanyImePipeFlags::UiLess));
|
||
for (unsigned modifiers = 0; modifiers < 8; ++modifiers)
|
||
{
|
||
const auto request = FanyImeTrilingualInput::EncodeCycleRequest(true, false) | modifiers;
|
||
CHECK(FanyImeTrilingualInput::IsCycleRequest(0x10, request) == (modifiers == 0));
|
||
CHECK(!FanyImeTrilingualInput::IsCycleRequest(0x09, request));
|
||
}
|
||
CHECK(FanyImeTrilingualInput::IsCycleRequest(0x10, FanyImeTrilingualInput::EncodeCycleRequest(false, true) |
|
||
FanyImePipeFlags::UiLess));
|
||
CHECK(!FanyImeTrilingualInput::IsCycleRequest(0x20, FanyImeTrilingualInput::EncodeCycleRequest(true, true)));
|
||
// The request bits stay clear of the Shift/Ctrl/Alt bits and every pipe flag.
|
||
CHECK((FanyImeTrilingualInput::CycleRequestMask & 7u) == 0);
|
||
CHECK((FanyImeTrilingualInput::CycleRequestMask &
|
||
(FanyImePipeFlags::UiLess | FanyImePipeFlags::ImeSwitchCapsSnapshotPresent |
|
||
FanyImePipeFlags::ImeSwitchCapsSnapshotEnabled)) == 0);
|
||
for (const bool ime : {false, true})
|
||
{
|
||
for (const bool japanese : {false, true})
|
||
{
|
||
const auto request = FanyImeTrilingualInput::EncodeCycleRequest(ime, japanese);
|
||
CHECK(FanyImeTrilingualInput::CycleRequestImeEnabled(request) == ime);
|
||
CHECK(FanyImeTrilingualInput::CycleRequestJapanese(request) == japanese);
|
||
// The requester's state, not a Server global, picks the destination.
|
||
CHECK(FanyImeTrilingualInput::Destination(ime, japanese, true, !japanese) ==
|
||
FanyImeTrilingualInput::Next(ime, japanese));
|
||
}
|
||
}
|
||
// Turned off while queued: answer as the binary toggle.
|
||
CHECK(FanyImeTrilingualInput::Destination(true, true, false, false) == Mode::English);
|
||
CHECK(FanyImeTrilingualInput::Destination(true, false, false, true) == Mode::English);
|
||
CHECK(FanyImeTrilingualInput::Destination(false, true, false, false) == Mode::Chinese);
|
||
CHECK(FanyImeTrilingualInput::Destination(false, false, false, true) == Mode::Japanese);
|
||
Mode destination = Mode::Chinese;
|
||
std::wstring text;
|
||
CHECK(FanyImeTrilingualInput::ParsePayload(FanyImeTrilingualInput::BuildPayload(Mode::Japanese, L"你好"),
|
||
destination, text));
|
||
CHECK(destination == Mode::Japanese && text == L"你好");
|
||
CHECK(FanyImeTrilingualInput::ParsePayload(L"2\t", destination, text));
|
||
CHECK(destination == Mode::English && text.empty());
|
||
CHECK(FanyImeTrilingualInput::ParsePayload(L"0\tかな\tword", destination, text));
|
||
CHECK(destination == Mode::Chinese && text == L"かな\tword");
|
||
for (const auto *invalid : {L"", L"0", L"3\ttext", L"00\ttext", L"1|text", L"\ttext"})
|
||
{
|
||
CHECK(!FanyImeTrilingualInput::ParsePayload(invalid, destination, text));
|
||
}
|
||
const std::wstring embeddedNull(L"1\ttext\0tail", 11);
|
||
CHECK(!FanyImeTrilingualInput::ParsePayload(embeddedNull, destination, text));
|
||
}
|
||
|
||
hello.wch += 1;
|
||
result = FanyImeProtocol::Negotiate(hello);
|
||
CHECK(!result.accepted);
|
||
CHECK(!FanyImeProtocol::AcceptReply(FanyImeProtocol::Reply(hello, result), 19));
|
||
hello = FanyImeProtocol::Hello(7, 19);
|
||
hello.point[1] |= 1u << 20; // client requires an unknown capability
|
||
CHECK(!FanyImeProtocol::Negotiate(hello).accepted);
|
||
hello = FanyImeProtocol::Hello(7, 19);
|
||
hello.modifiers_down |= 1u << 20; // unknown optional capabilities are ignored
|
||
CHECK(FanyImeProtocol::Negotiate(hello).accepted);
|
||
hello.modifiers_down &= ~FanyImeProtocol::FocusEpochs;
|
||
CHECK(!FanyImeProtocol::Negotiate(hello).accepted);
|
||
hello = FanyImeProtocol::Hello(7, 19);
|
||
hello.keycode ^= 1;
|
||
CHECK(!FanyImeProtocol::Negotiate(hello).accepted);
|
||
hello = FanyImeProtocol::Hello(7, 0);
|
||
CHECK(!FanyImeProtocol::Negotiate(hello).accepted);
|
||
|
||
FanyImeNamedpipeDataToTsf old_server{};
|
||
old_server.msg_type = FanyImeReplyType::PipeReady;
|
||
old_server.request_id = 19;
|
||
CHECK(!FanyImeProtocol::AcceptReply(old_server, 19));
|
||
// Corrupt/missing negotiated capabilities cannot authorize the new DLL.
|
||
reply.candidate_string[2] = 0;
|
||
CHECK(!FanyImeProtocol::AcceptReply(reply, 19));
|
||
|
||
CHECK(FanyImeWorkerReplyType::SwitchToEn == FanyImeWorkerReplyType::SwitchToEnglish);
|
||
CHECK(FanyImeWorkerReplyType::CommitCandidate == FanyImeWorkerReplyType::CommitCurCandidate);
|
||
CHECK(FanyImeWorkerReplyType::CommitCandidateAndContinue == 27);
|
||
CHECK(FanyImeWorkerReplyType::MidSentenceHelpcodeChanged == 28);
|
||
CHECK(FanyImeWorkerReplyType::MidSentenceHelpcodeSemicolonChanged == 29);
|
||
CHECK(FanyImeWorkerReplyType::DirectHelpcodeChanged == 30);
|
||
CHECK(FanyImeWorkerReplyType::MidSentenceHelpcodeUppercaseChanged == 31);
|
||
CHECK(FanyImeWorkerReplyType::VModeChanged == 32);
|
||
CHECK(FanyImeWorkerReplyType::TrilingualCycleChanged == 33);
|
||
CHECK(FanyImeWorkerReplyType::AiContextUploadChanged == 34);
|
||
CHECK(FanyImeWorkerReplyType::UiLessCandidatesChanged == 35);
|
||
CHECK(FanyImeWorkerReplyType::MaxKnown == FanyImeWorkerReplyType::UiLessCandidatesChanged);
|
||
{
|
||
const auto full = FanyImeUiLessPage::Parse(FanyImeUiLessPage::Format(L"ni hao", L"你好,拟好", 1, 42));
|
||
CHECK(full.preedit == L"ni hao" && full.page == L"你好,拟好" && full.selection == 1 && full.generation == 42);
|
||
// A Server predating the generation field.
|
||
CHECK(full.hasPage && !FanyImeUiLessPage::Parse(L"ni hao").hasPage);
|
||
const auto legacy = FanyImeUiLessPage::Parse(L"\t你好,拟好\t1");
|
||
CHECK(legacy.page == L"你好,拟好" && legacy.selection == 1 && legacy.generation == 0);
|
||
// A DLL predating the field reads selection with _wtoi, which must stop at the third tab.
|
||
const std::wstring formatted = FanyImeUiLessPage::Format(L"", L"你好", 3, 7);
|
||
CHECK(std::wcstol(formatted.c_str() + formatted.find(L'\t', 1) + 1, nullptr, 10) == 3);
|
||
CHECK(FanyImeUiLessPage::IsCurrent(0, 9) && FanyImeUiLessPage::IsCurrent(9, 9));
|
||
CHECK(FanyImeUiLessPage::IsCurrent(10, 9) && !FanyImeUiLessPage::IsCurrent(8, 9));
|
||
}
|
||
// The sensitive-field flag stays clear of Shift/Ctrl/Alt, every pipe flag and the cycle request bits.
|
||
CHECK((FanyImePipeFlags::SensitiveField & 7u) == 0);
|
||
CHECK((FanyImePipeFlags::SensitiveField &
|
||
(FanyImePipeFlags::UiLess | FanyImePipeFlags::ImeSwitchCapsSnapshotPresent |
|
||
FanyImePipeFlags::ImeSwitchCapsSnapshotEnabled | FanyImeTrilingualInput::CycleRequestMask)) == 0);
|
||
// V 模式的形状规则,TSF(WCHAR)与 Server(char)共用。
|
||
{
|
||
using FanyImeVModeInput::Trigger;
|
||
const auto v_at = [](const std::wstring &text, std::size_t caret, wchar_t ch, Trigger trigger) {
|
||
return FanyImeVModeInput::AcceptsAt(text.data(), text.size(), caret, ch, trigger);
|
||
};
|
||
const auto v_end = [&](const std::wstring &text, wchar_t ch, Trigger trigger) {
|
||
return v_at(text, text.size(), ch, trigger);
|
||
};
|
||
CHECK(v_end(L"V", L'1', Trigger::UppercaseOnly) && v_end(L"V", L'(', Trigger::UppercaseOnly));
|
||
CHECK(!v_end(L"V", L'+', Trigger::UppercaseOnly) && !v_end(L"V", L'.', Trigger::AnyCase));
|
||
CHECK(v_end(L"V1", L'+', Trigger::UppercaseOnly) && v_end(L"V1+2", L'*', Trigger::AnyCase));
|
||
CHECK(v_end(L"V12", L'.', Trigger::AnyCase) && v_end(L"V(1", L')', Trigger::AnyCase));
|
||
CHECK(!v_end(L"v", L'1', Trigger::UppercaseOnly) && v_end(L"v", L'1', Trigger::AnyCase));
|
||
CHECK(!v_end(L"V", L'1', Trigger::Off) && !v_end(L"vi", L'1', Trigger::AnyCase));
|
||
CHECK(!v_end(L"V1", L'a', Trigger::AnyCase) && !v_end(L"T1", L'1', Trigger::AnyCase));
|
||
// 光标在中间:不能把运算符插到 V 和第一个数字之间。
|
||
CHECK(!v_at(L"V12", 1, L'+', Trigger::AnyCase) && v_at(L"V12", 1, L'3', Trigger::AnyCase) &&
|
||
v_at(L"V12", 2, L'+', Trigger::AnyCase) && !v_at(L"V12", 0, L'3', Trigger::AnyCase));
|
||
CHECK(!FanyImeVModeInput::IsComposition(L"V", 1, Trigger::UppercaseOnly) &&
|
||
!FanyImeVModeInput::IsComposition(L"v", 1, Trigger::AnyCase) &&
|
||
FanyImeVModeInput::IsComposition(L"v1", 2, Trigger::AnyCase) &&
|
||
!FanyImeVModeInput::IsComposition(L"v1", 2, Trigger::UppercaseOnly) &&
|
||
FanyImeVModeInput::IsComposition("V1a", 3, Trigger::UppercaseOnly) &&
|
||
!FanyImeVModeInput::IsComposition("vip", 3, Trigger::AnyCase));
|
||
CHECK(FanyImeVModeInput::TriggerFromPayload(L'2') == Trigger::AnyCase &&
|
||
FanyImeVModeInput::TriggerFromPayload(L'1') == Trigger::UppercaseOnly &&
|
||
FanyImeVModeInput::TriggerFromPayload(L'x') == Trigger::Off &&
|
||
FanyImeVModeInput::PayloadFromTrigger(Trigger::UppercaseOnly) == L'1');
|
||
}
|
||
// 直接辅助码的 / 与 ; 形状规则,TSF(WCHAR)与引擎(char)共用。
|
||
const auto slash_at = [](const std::wstring &text, std::size_t caret) {
|
||
return FanyImeDirectHelpcode::AcceptsSlashAt(text.data(), text.size(), caret);
|
||
};
|
||
CHECK(slash_at(L"uiab", 4) && slash_at(L"x;ab", 4) && !slash_at(L"uia", 3) && !slash_at(L"ui'b", 4));
|
||
CHECK(!slash_at(L"uiab/", 4) && slash_at(L"uiabui", 4) && !slash_at(L"uiab", 5));
|
||
CHECK(FanyImeDirectHelpcode::AcceptsSemicolonFinalAt("uiax", 4, 4) &&
|
||
!FanyImeDirectHelpcode::AcceptsSemicolonFinalAt("uix;", 4, 4) &&
|
||
!FanyImeDirectHelpcode::AcceptsSemicolonFinalAt("", 0, 0));
|
||
// DirectHelpcodeChanged 的载荷:开关和 / 勾没勾。旧 DLL 认的 "0"/"1" 意思不变。
|
||
CHECK(FanyImeDirectHelpcode::PayloadFor(false, true) == L'0' &&
|
||
FanyImeDirectHelpcode::PayloadFor(true, true) == L'1' &&
|
||
FanyImeDirectHelpcode::PayloadFor(true, false) == L'2');
|
||
CHECK(FanyImeDirectHelpcode::EnabledFromPayload(L'2') && !FanyImeDirectHelpcode::SlashFromPayload(L'2') &&
|
||
FanyImeDirectHelpcode::SlashFromPayload(L'1') && !FanyImeDirectHelpcode::EnabledFromPayload(L'0') &&
|
||
!FanyImeDirectHelpcode::IsValidPayload(L'3'));
|
||
// TSF(WCHAR)与引擎(char)共用同一条句中辅助码形状规则。
|
||
const auto accepts = [](const std::wstring &text) {
|
||
return FanyImeMidSentenceHelpcode::AcceptsMarker(text.data(), text.size());
|
||
};
|
||
CHECK(accepts(L"ulpb") && !accepts(L"ulp") && !accepts(L"") && !accepts(L"ulpb'"));
|
||
CHECK(!accepts(L"ulpb`") && !accepts(L"ulpb`x") && !accepts(L"ulpb`xY") && !accepts(L"ulpb`xi"));
|
||
CHECK(accepts(L"ulpb`xih") && !accepts(L"ulpb`xihf") && accepts(L"ulpb`xYih") && accepts(L"ulpb`xihfa"));
|
||
CHECK(FanyImeMidSentenceHelpcode::AcceptsMarker("ni'hc", 5) &&
|
||
!FanyImeMidSentenceHelpcode::AcceptsMarker("ni'h", 4));
|
||
// 光标移回句中:只看光标前的部分,光标后已是这个音节的段时不收。
|
||
const auto accepts_at = [](const std::wstring &text, std::size_t caret) {
|
||
return FanyImeMidSentenceHelpcode::AcceptsMarkerAt(text.data(), text.size(), caret);
|
||
};
|
||
CHECK(accepts_at(L"ulpbih", 2) && accepts_at(L"ulpbih", 4) && accepts_at(L"ulpbih", 6));
|
||
CHECK(!accepts_at(L"ulpbih", 0) && !accepts_at(L"ulpbih", 3) && !accepts_at(L"ulpbih", 7));
|
||
CHECK(!accepts_at(L"ul`xpb", 2) && accepts_at(L"ul`xpb", 6) && !accepts_at(L"ul`xpb", 3));
|
||
// 大写触发:完整音节后的大写字母自己开一段(≡ 反引号 + 这个字母),紧跟的大写字母是第二码。
|
||
const auto upper_marker = [](const std::wstring &text) {
|
||
return FanyImeMidSentenceHelpcode::AcceptsMarker(text.data(), text.size(), true);
|
||
};
|
||
CHECK(!upper_marker(L"ulX") && upper_marker(L"ulXpb") && !upper_marker(L"ulXp") && upper_marker(L"ulXYpb"));
|
||
// 关着时大写字母只是普通的一个键:ulXpb 是 5 键(奇数),ulXpbi 是 6 键。
|
||
CHECK(!accepts(L"ulXpb") && accepts(L"ulXpbi"));
|
||
const auto upper_at = [](const std::wstring &text, std::size_t caret) {
|
||
return FanyImeMidSentenceHelpcode::AcceptsMarkerAt(text.data(), text.size(), caret, true);
|
||
};
|
||
CHECK(!upper_at(L"ulXpb", 2) && upper_at(L"ulXpb", 5) && upper_at(L"ulpb", 2));
|
||
const auto second_at = [](const std::wstring &text, std::size_t caret, bool upper) {
|
||
return FanyImeMidSentenceHelpcode::AcceptsSecondCodeAt(text.data(), text.size(), caret, upper);
|
||
};
|
||
CHECK(second_at(L"ulX", 3, true) && !second_at(L"ulXY", 4, true) && !second_at(L"ulX", 3, false));
|
||
CHECK(second_at(L"ul`x", 4, false) && !second_at(L"ul`xY", 5, false) && !second_at(L"ul", 2, true));
|
||
const auto semicolon_at = [](const std::wstring &text, bool upper) {
|
||
return FanyImeMidSentenceHelpcode::AcceptsSemicolonFinalAt(text.data(), text.size(), text.size(), upper);
|
||
};
|
||
// 关着时与原来的规则一致(只看最后一个 ');开着时大写段不算键:nihkXb 里 b 后面接 ; 是 b; 音节。
|
||
CHECK(semicolon_at(L"nihkb", false) && !semicolon_at(L"nihkXb", false) && semicolon_at(L"nihkXb", true));
|
||
CHECK(semicolon_at(L"ni'b", false) && !semicolon_at(L"nihk", true));
|
||
// Shift+T 指定日期时间的形状规则,TSF(WCHAR)、Server 与引擎(char)共用。
|
||
const auto date_time_at = [](const std::wstring &text, std::size_t caret, wchar_t ch) {
|
||
return FanyImeDateTimeInput::AcceptsAt(text.data(), text.size(), caret, ch);
|
||
};
|
||
const auto date_time_end = [&](const std::wstring &text, wchar_t ch) {
|
||
return date_time_at(text, text.size(), ch);
|
||
};
|
||
CHECK(date_time_end(L"T", L'2') && date_time_end(L"T2024122", L'5') && date_time_end(L"T20241225", L'1'));
|
||
CHECK(date_time_end(L"T2024122514", L':') && date_time_end(L"T2024122514:30", L':'));
|
||
CHECK(!date_time_end(L"T2024122514:30:00", L'1') && !date_time_end(L"T20241225", L':'));
|
||
CHECK(date_time_end(L"T2024", L'/') && date_time_end(L"T2024/1", L'2') && date_time_end(L"T2024/12", L'/'));
|
||
CHECK(!date_time_end(L"T2024/12/25", L'1') && !date_time_end(L"T2024/12/25", L'/'));
|
||
CHECK(date_time_end(L"T12", L'/') && date_time_end(L"T9", L':') && date_time_end(L"T9:0", L'5'));
|
||
CHECK(!date_time_end(L"T123", L'/') && !date_time_end(L"T123", L':') && !date_time_end(L"T12/25", L':'));
|
||
CHECK(!date_time_end(L"T", L'/') && !date_time_end(L"T", L':') && !date_time_end(L"Trq", L'1'));
|
||
CHECK(!date_time_end(L"U12", L'3') && !date_time_end(L"T2024", L'a') && !date_time_end(L"T", L'!'));
|
||
// 光标在中间:插入后的整串仍要能接成某种形状;光标不能停在开头的 T 前面。
|
||
CHECK(!date_time_at(L"T2024/12", 1, L'1') && date_time_at(L"T202412", 5, L'1') && !date_time_at(L"T2", 0, L'1'));
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("20241225", 8).shape == FanyImeDateTimeInput::Shape::Date);
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("2024122514", 10).shape == FanyImeDateTimeInput::Shape::DateTime);
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("2024122514:30:05", 16).group_count == 3);
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("2024/1/5", 8).shape == FanyImeDateTimeInput::Shape::YearMonthDay);
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("2024/12", 7).shape == FanyImeDateTimeInput::Shape::YearMonth);
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("12/25", 5).shape == FanyImeDateTimeInput::Shape::MonthDay);
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("9:05", 4).shape == FanyImeDateTimeInput::Shape::Time);
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("1430", 4).shape == FanyImeDateTimeInput::Shape::None);
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("12/", 3).shape == FanyImeDateTimeInput::Shape::None);
|
||
CHECK(FanyImeDateTimeInput::MatchComplete("9:5", 3).shape == FanyImeDateTimeInput::Shape::None);
|
||
static_assert(FanyImeDateTimeInput::AcceptsAt("T2024", 5, 5, '/'), "the date shape must be usable at compile time");
|
||
const std::wstring voice(1000, L'x');
|
||
const auto frames = FanyImeVoiceCompositionPipe::EncodeSnapshot(voice, 7);
|
||
CHECK(FanyImeVoiceCompositionPipe::AssembleFrames(frames) == voice);
|
||
auto incomplete = frames;
|
||
incomplete.erase(incomplete.begin());
|
||
CHECK(FanyImeVoiceCompositionPipe::AssembleFrames(incomplete).empty());
|
||
// A middle frame legally carries flags 0, so an unterminated packet must be rejected on the chunk, not accepted
|
||
// because data[0] happens to be a NUL.
|
||
std::array<wchar_t, FanyImeVoiceCompositionPipe::kPacketChars> packet{};
|
||
packet.fill(L'x');
|
||
packet[0] = 0; // middle frame
|
||
packet[1] = 7; // generation
|
||
CHECK(!FanyImeVoiceCompositionPipe::ParseFrame(packet.data()).valid);
|
||
packet[FanyImeVoiceCompositionPipe::kPacketChars - 1] = 0;
|
||
const auto middle = FanyImeVoiceCompositionPipe::ParseFrame(packet.data());
|
||
CHECK(middle.valid && !middle.first && !middle.last && middle.generation == 7);
|
||
CHECK(middle.chunk == std::wstring(FanyImeVoiceCompositionPipe::kMaxChunkChars, L'x'));
|
||
const auto blank = FanyImeVoiceCompositionPipe::EncodeSnapshot(std::wstring(), 7);
|
||
CHECK(blank.size() == 1);
|
||
std::array<wchar_t, FanyImeVoiceCompositionPipe::kPacketChars> header{};
|
||
std::copy(blank[0].begin(), blank[0].end(), header.begin());
|
||
const auto empty = FanyImeVoiceCompositionPipe::ParseFrame(header.data());
|
||
CHECK(empty.valid && empty.first && empty.last && empty.chunk.empty());
|
||
|
||
// Statistics frames: fixed 28-byte header plus 24-byte events; the Go stats
|
||
// tool mirrors these numbers in internal/frames, so both sides must change
|
||
// together.
|
||
CHECK(FANY_IME_STATS_MAGIC == 0x54415453);
|
||
CHECK(FANY_IME_STATS_VERSION == 1);
|
||
CHECK(sizeof(FanyImeStatsBatchHeader) == 28);
|
||
CHECK(sizeof(FanyImeStatsEvent) == 24);
|
||
CHECK(offsetof(FanyImeStatsEvent, timestamp_utc_ft) == 0);
|
||
CHECK(offsetof(FanyImeStatsEvent, cjk) == 8);
|
||
CHECK(sizeof(FanyImeStatsBatchHeader) + 256 * sizeof(FanyImeStatsEvent) <= FANY_IME_STATS_MAX_FRAME_BYTES);
|
||
std::cout << "Windows IPC layout, negotiation, upgrade, voice and stats contracts passed\n";
|
||
}
|