Sign in Join

Live chat for Free Pascal, Lazarus, Delphi and every Pascal programmer.

Catalog · How the catalog works

Random project

hid-macro-keypad

Unofficial configuration GUI for a mini macro keypad (VID 1189 / PID 8890). HID protocol reverse-engineered from the vendor binary and verified against real hardware.

View on GitHub

Anyone may press Interesting, signed in or not. It helps others find this project.

Member rating

No ratings yet.

Sign in to rate this project.

README

HIDKeyboard

English README

미니 매크로 키패드(6키 + 로터리 노브 1개)를 위한 비공식 설정 GUI다. 키보드 조합·멀티미디어 키·마우스 동작을 각 키에 매핑하고, 매핑을 프로파일 파일로 저장·불러오며, 장치 LED 모드를 설정한다. 원본 제조사 프로그램 없이 HID Output Report로 장치와 직접 통신한다.

소스 코드도 문서도 없는 장치의 통신 프로토콜을, 제조사 프로그램의 바이너리만 가지고 복원해 만들었다. 이 문서는 그 과정과 결과를 함께 기록한다.

대상 키패드 — 물리 키 6개와 로터리 노브 1개


목차

  1. 왜 만들었나
  2. 대상 장치
  3. 코드 없이 프로토콜을 알아낸 과정
  4. 프로토콜 상세
  5. 앱 구조
  6. 빌드 방법
  7. 프로파일 파일
  8. tools\ 진단 스크립트
  9. 알려진 제약
  10. 작업에서 얻은 교훈

왜 만들었나

제조사가 제공하는 전용 프로그램(MINI KeyBoard.exe, ProductName HIDTester)에는 두 가지 결정적인 한계가 있다.

  • 설정 저장/불러오기가 없다. 코드 어디에도 Save/Load 경로가 없다. 용도를 바꿀 때마다 6개 키를 손으로 다시 찍어야 한다.
  • 장치에서 현재 설정을 읽어올 수 없다. 읽기 경로 자체가 없다. 지금 키패드에 무엇이 매핑되어 있는지 장치만 봐서는 확인할 방법이 없다.

즉 장치는 쓰기 전용이고, 장치에 이미 구워진 매핑이 "진짜 설정"인지 확인할 도구가 원본 앱에는 없다. 이 앱은 그래서 프로파일을 파일로 관리한다 — %APPDATA%\HIDKeyboard\profiles\*.json 에 저장된 프로파일 파일이 유일한 진실의 원천(source of truth)이고, 장치는 그 내용을 흘려보내는 대상일 뿐이다. 장치에서 다시 읽어올 수 없으므로, 프로파일 파일을 잃으면 그 설정도 함께 잃는다 — 백업은 사용자 책임이다.

대상 장치

항목 값
VID 0x1189
PID 0x8890
구성 물리 키 6개(2행 × 3열) + 로터리 노브 1개(좌회전/누름/우회전)
설정용 인터페이스 USB\VID_1189&PID_8890&MI_01 (디바이스 경로에 mi_01 포함, vendor-defined HID collection)

장치는 네 개의 USB 인터페이스를 노출한다.

pascal
USB\VID_1189&PID_8890&MI_00   HID ?????????          Windows ?????? ??????
USB\VID_1189&PID_8890&MI_01   ???????????? ?????? HID    ??? ????????? ??? ??? ?????? ????????? ???????????????
USB\VID_1189&PID_8890&MI_02   HID ????????? + ????????? ?????????   Windows ?????? ??????
USB\VID_1189&PID_8890&MI_03   HID ?????????          Windows ?????? ??????

mi_01 을 제외한 나머지는 CreateFile 이 ERROR_ACCESS_DENIED(5)로 실패한다. Windows가 키보드·마우스 클래스 드라이버로 붙잡고 있기 때문이다. 설정 통신은 mi_01 로만 가능하다.


코드 없이 프로토콜을 알아낸 과정

출발점 — 가진 것은 실행 파일뿐

시작할 때 있던 것은 제조사가 준 폴더 하나였다.

pascal
MINI KeyBoard.exe        217 KB
MINI KeyBoard.pdb        153 KB     ??? ?????????
HidLibrary.dll            45 KB
Theraot.Core.dll         788 KB
en-US\, zh-CN\                      ????????? DLL

문서도, 소스도, 프로토콜 사양도 없었다. 장치는 표준 HID 키보드로 인식되지만 설정을 바꾸는 방법은 이 exe 안에만 있었다.

1단계 — 무엇으로 만들어졌는지 확인

파일 버전 정보부터 봤다.

powershell
(Get-Item "MINI KeyBoard.exe").VersionInfo
# ProductName : HIDTester
# CompanyName : 微软中国

HidLibrary.dll 이 함께 있는 것으로 보아 .NET 앱이고, HID 통신에 오픈소스 HidLibrary 를 쓴다는 것을 알 수 있었다. 그리고 .pdb 가 동봉되어 있었다 — 디버그 심볼이 있으면 디스어셈블 결과에 원래 클래스·메서드·필드 이름이 그대로 남는다. 난독화도 없었다.

2단계 — IL 디스어셈블

.NET 어셈블리는 IL(중간 언어)로 저장되므로 완전히 복원할 수 있다. Windows SDK에 포함된 ildasm 을 썼다.

pascal
"C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\x64\ildasm.exe" ^
    "MINI KeyBoard.exe" /out=keyboard.il

결과는 IL 소스 약 18,000줄. 클래스 구조가 그대로 드러났다.

pascal
HIDTester.FormMain              ?????? ???
HIDTester.FormMain/KeyParam     ?????? ?????? (?????? ??????, ????????? ??????)
HIDTester.HidLib                HID ?????? ??????
HIDTester.BasicKeys             ????????? ??? ?????? UI
HIDTester.MULKey                ??????????????? ??? UI
HIDTester.MouseKey              ????????? ?????? UI
HIDTester.LEDkey                LED ?????? UI
HIDTester.LayerFun              ????????? ?????? UI

3단계 — 통신 계층부터 읽기

HidLib.Connect_Device() 가 장치를 어떻게 찾는지 먼저 봤다.

pascal
IL_0001:  ldc.i4     0x1189                          ??? VID
IL_0006:  call       HidDevices::Enumerate(int32)
...
IL_0045:  ldstr      "mi_01"                         ??? ??????????????? ??????
IL_004a:  callvirt   String::IndexOf(string)

VID 0x1189 로 열거한 뒤 디바이스 경로에 mi_01 이 든 것을 고른다. PID는 아예 보지 않는다(원본 앱이 여러 모델을 지원하기 때문으로 보인다).

이어서 HidLib.WriteDevice(id, buff) 를 읽었다.

pascal
IL_0019:  ldc.i4.0    ???  Data[0] := arrayBuff[0]
IL_0025:  ldc.i4.1    ???  Data[1] := arrayBuff[1]
...
IL_0067:  ldc.i4.7    ???  Data[7] := arrayBuff[7]
IL_0072:  ldc.i4     0x1f4                           ??? ???????????? 500ms
IL_0077:  callvirt   HidDevice::WriteReport(...)

버퍼는 65바이트로 잡지만 실제로 복사되는 것은 8바이트뿐이라는 게 여기서 드러났다. 프로토콜의 실질 페이로드가 8바이트라는 뜻이다.

4단계 — 패킷 구조 추론

KeyParam 클래스의 정적 생성자에 버퍼 인덱스 상수가 모여 있었다.

pascal
Data_Send_Buff  = new Byte[65]
KeySet_KeyNum    = 0      ??? [0] ??? ??????
KeyType_Num      = 1      ??? [1] ?????????
KeyGroupCharNum  = 2      ??? [2] ?????? ??????
KeySet_KeyValNum = 3      ??? [3]
Key_Fun_Num      = 4      ??? [4] modifier
KEY_Char_Num     = 5      ??? [5] ?????????

각 UI 버튼의 클릭 핸들러가 이 자리에 무엇을 넣는지 확인했다.

pascal
KEY_A_Click:     Data_Send_Buff[KEY_Char_Num] := 4      ??? ?????? HID usage 'A'
KEY1_Click:      Data_Send_Buff[KeySet_KeyNum] := 1     ??? ??? ?????? 1
K1_Left_Click:   Data_Send_Buff[KeySet_KeyNum] := 13    ??? ?????? ?????????

키코드가 표준 HID Keyboard usage ID(A = 0x04)라는 것, 노브가 키 번호 13/14/15로 일반 키와 동일하게 취급된다는 것이 여기서 확인됐다.

키타입은 각 UI 클래스의 *General_Char_Set() 이 [1] 에 OR 하는 값이었다.

pascal
BasicKeys.General_Char_Set:     buff[1] |= 1    ?????????
MULKey.MULGeneral_Char_Set:     buff[1] |= 2    ???????????????
MouseKey.MouseGeneral_Char_Set: buff[1] |= 3    ?????????
LEDkey.LEDGeneral_Char_Set:     buff[1] |= 8    LED

전역 커맨드는 특수 상수를 찾아 확인했다.

pascal
Send_SwLayer:         buff[0] := 0xA1, buff[1] := ?????????      ????????? ??????
Send_WriteFlash_Cmd:  buff[0] := 0xAA, buff[1] := 0xAA        ????????? ??????
KeyBoardVersion_Check: buff[0] := 0x00, buff[1] := 0x00       ??? (ReportID ??????)

여기까지가 정적 분석으로 얻은 전부다. 그리고 이 시점의 이해에는 틀린 것이 여러 개 섞여 있었다.

5단계 — 실측: 추측이 무너진 지점들

정적 분석만으로 만든 첫 구현은 동작하지 않았다. 실장치에 직접 패킷을 쏘아 보며 하나씩 바로잡았다.

이 단계에서 쓴 도구가 tools\hidwrite.ps1 이다. GUI 없이 PowerShell로 SetupAPI를 직접 호출해 패킷을 굽는다. 앱의 버그와 프로토콜의 오해를 분리해서 볼 수 있다는 게 핵심이었다.

① HidD_SetOutputReport 로는 아무것도 못 쓴다

세 후보 ReportID(0/2/3)에 두 가지 전송 방식을 모두 시도해 봤다.

pascal
ReportID 0: SetOutputReport=False(err 0)  WriteFile=False(err 87)
ReportID 2: SetOutputReport=False(err 0)  WriteFile=False(err 87)
ReportID 3: SetOutputReport=False(err 0)  WriteFile=True (err 0)   ??? ????????? ??????

HidD_SetOutputReport 는 세 ReportID 모두에서 실패한다. 이 장치는 SET_REPORT 컨트롤 전송을 받지 않고 인터럽트 아웃 엔드포인트(WriteFile)만 받는다. 그리고 ReportID는 3 으로 확정됐다.

② 순번 0 은 헤더다 — 이것을 몰라서 매핑이 지워졌다

첫 실측에서 키 1~6에 F13~F18을 구웠더니 기존 매핑이 전부 사라졌다. 원인을 찾으러 Download_Click 의 루프를 다시 읽었다.

pascal
IL_0192:  V_0 := V_0 + 1
IL_0197:  if V_0 <= Data_Send_Buff[KeyGroupCharNum] then goto ????????????

<(미만)가 아니라 <=(이하) 였다. 그리고 switch 의 case 0 은 키코드 자리에 항상 0 을 넣는다.

pascal
case 0:  V_1[4] := buff[4];  V_1[5] := 0        ??? ??????
case 1:  V_1[4] := buff[4];  V_1[5] := buff[5]  ??? 1??????
case 2:  V_1[4] := buff[6];  V_1[5] := buff[7]  ??? 2??????

순번 0은 헤더이고 실제 키는 순번 1부터다. 조합 1개짜리 키도 패킷을 2개 보내야 한다. 헤더만 보내고 실제 단계를 보내지 않으면 장치가 매핑을 지운다 — 우연히 발견한 이 동작이 나중에 "빈 키 지우기" 방법이 됐다.

부수적으로 switch 가 case 0~case 5 뿐이므로 매크로 최대 단계는 6이 아니라 5 라는 것도 확정됐다.

③ 이 장치는 레이어를 지원하지 않는다

레이어 1/2/3의 같은 키에 서로 다른 값을 구워 확인했다.

  • 장치를 레이어 3으로 전환한 상태에서도 키 4와 노브가 레이어 1에서 구운 값을 그대로 냈다
  • 0xA1 01 로 레이어 1로 전환한 뒤에도 키 1이 레이어 3의 값을 냈다

매핑 저장소는 한 벌뿐이고 레이어 니블과 0xA1 은 무시된다. 그리고 이 발견이 심각한 버그를 드러냈다 — 당시 코드는 3개 레이어를 순회하며 전송했는데, 저장소가 공유되므로 레이어 2·3의 빈 키 패킷이 레이어 1에서 방금 쓴 매핑을 덮어썼다. 즉 [적용] 을 누를 때마다 설정이 전부 지워지는 상태였다.

④ 키타입마다 패킷 구조가 완전히 다르다

멀티미디어를 키보드와 같은 구조로 구현하려다 Download_Click 의 분기 조건을 다시 봤다.

pascal
IL_0083:  if (buff[1] and $0F) <> 1 then goto IL_01af   ??? ???????????? ?????? ??????
IL_01af:  if (buff[1] and $0F) <> 2 then goto IL_0219   ??? ???????????????
IL_0219:  if (buff[1] and $0F) <> 8 then goto IL_0279   ??? LED
IL_0279:  if (buff[1] and $0F) <> 3 then goto IL_0301   ??? ?????????

순번·헤더 개념은 키보드 타입에만 있다. 나머지 셋은 패킷 하나로 끝나고 각자 [2] 부터 자기 형식대로 채운다.

그리고 멀티미디어 코드값은 ReportID 세대별로 갈렸다. 우리 세대(3)의 값을 뽑아 보니 전부 표준 USB HID Consumer Page usage ID 였다.

pascal
KEY_Play:      ReportID 0 ??? 0x40    ReportID 3 ??? 0xCD   (Play/Pause)
KEY_VolumeAdd: ReportID 0 ??? 0x40    ReportID 3 ??? 0xE9   (Volume Increment)

마우스는 buff[5..8] 4바이트로, 표준 HID 마우스 리포트(버튼/X/Y/휠) 그대로였다.

⑤ 실제로 써봐야만 드러난 것

키 조합 캡처 기능을 만들고 나서 Ctrl+C 를 눌렀더니 저장된 값이 {"mods": 0, "code": 6} — C 만 남고 Ctrl 이 사라졌다.

원인은 훅의 자기모순이었다. modifier 상태를 GetAsyncKeyState 로 읽는데, 같은 훅이 modifier 키다운을 삼켜서 Windows의 키 상태 테이블이 갱신되지 않았다. 자기가 소비한 정보를 시스템에 되묻는 구조였다. 훅이 키다운·키업을 직접 추적하도록 바꿔 해결했다.

컴파일도 리뷰도 통과한 코드였다. 실제로 눌러봐야만 드러나는 종류의 결함이었다.

정적 분석과 실측의 역할 분담

정적 분석(IL)으로 알아낸 것 실측으로만 확정된 것
VID와 인터페이스 필터 ReportID 값 (3)
8바이트 페이로드 전송 방식 (WriteFile 전용)
버퍼 오프셋의 의미 순번 0이 헤더라는 것
키타입 코드 (1/2/3/8) 레이어 미지원
전역 커맨드 (0xA1/0xAA/0xB0) 물리 키 번호 ↔ 위치 대응
키코드가 표준 HID usage modifier 비트 배치
타입별로 분기한다는 사실 LED 모드 3종의 실제 동작

IL은 "무엇을 보내는가"를 알려주지만 "장치가 그것을 어떻게 해석하는가"는 알려주지 않는다. 후자는 실측 외에 방법이 없었다.


프로토콜 상세

연결

  1. HidD_GetHidGuid 로 HID 인터페이스 클래스 GUID를 얻는다
  2. SetupDiGetClassDevs + SetupDiEnumDeviceInterfaces 로 HID 장치를 열거한다
  3. 디바이스 경로에 mi_01 이 포함된 것만 남긴다
  4. CreateFile 로 열고 HidD_GetAttributes 로 VID 0x1189 / PID 0x8890 을 확인한다
  5. HidP_GetCaps 로 OutputReportByteLength(=65)를 얻는다
  6. ReportID를 0 → 2 → 3 순으로 핑(0x00 0x00)을 보내 탐지한다 — 이 장치는 3

Win64에서 SP_DEVICE_INTERFACE_DETAIL_DATA.cbSize 는 8 이다. SizeOf(DWORD) + SizeOf(Char) = 6으로 계산하면 SetupDiGetDeviceInterfaceDetail 이 ERROR_INVALID_PARAMETER 로 거부해 장치를 영영 못 찾는다.

전송

pascal
?????? 65?????????:  [ReportID][????????? 8?????????][????????? 56????????? = 0]

WriteFile(handle, buffer[0], 65, written, nil)

HidD_SetOutputReport 는 이 장치에서 항상 실패한다. WriteFile 만 동작한다.

패킷 구조 — 타입에 따라 완전히 다르다

공통은 앞 두 바이트뿐이다.

pascal
[0]  ??? ?????? (0?????? ?????? ??????)
[1]  (????????? << 4) | ?????????      ???????????? ??? ???????????? ?????????
타입 패킷 수 [2] [3] [4] [5] [6]
1 키보드 N+1 (헤더 + N단계) 조합 개수 순번 modifier 키코드 —
2 멀티미디어 1 Consumer 코드 예비 — — —
3 마우스 1 버튼 비트 X 이동 Y 이동 휠 예비
8 LED 1 모드 번호 — — — —

키보드 — 순번 0은 헤더

조합 N개를 설정하려면 패킷 N+1개를 보낸다.

pascal
[?????????][??????][N][0][modifier][0x00]      ?????? 0 ??? ??????, ???????????? ????????? 0
[?????????][??????][N][1][modifier][?????????]     ?????? 1 ??? ?????? ??? ??????
[?????????][??????][N][2][modifier][?????????]     ?????? 2 ??? ?????? ??????
...

헤더를 빼먹으면 장치가 매핑을 지운다. 반대로 이 성질을 이용해 헤더만 보내고 순번 1 이후를 생략하면 그 키가 지워진다 — 이것이 빈 키 처리 방법이다.

순번은 0~5 범위이므로 실제 매크로는 최대 5단계다.

예시 — 키 1에 Ctrl+Shift+S:

pascal
01 11 01 00 03 00 00 00     ??????    (?????????1<<4|??????1 = 0x11, mods 0x03)
01 11 01 01 03 16 00 00     1??????   (S = 0x16)

modifier 비트 (표준 HID)

비트 값 의미
0 0x01 Ctrl
1 0x02 Shift
2 0x04 Alt
3 0x08 Win

멀티미디어 — 표준 Consumer Page

패킷 하나로 끝나며 코드가 [2] 자리에 들어간다.

동작 코드 Consumer Page 표준
재생/일시정지 0xCD Play/Pause
이전 곡 0xB6 Scan Previous Track
다음 곡 0xB5 Scan Next Track
음소거 0xE2 Mute
볼륨 증가 0xE9 Volume Increment
볼륨 감소 0xEA Volume Decrement

예시 — 노브 좌회전에 볼륨 감소:

pascal
0D 12 EA 00 00 00 00 00     ???13, ?????????1|??????2, 0xEA

마우스 — 표준 HID 마우스 리포트

[2]~[5] 가 버튼/X/Y/휠 4바이트다.

동작 버튼 [2] X [3] Y [4] 휠 [5]
왼쪽 버튼 1 0 0 0
오른쪽 버튼 2 0 0 0
가운데 버튼 4 0 0 0
휠 위로 0 0 0 1
휠 아래로 0 0 0 0xFF (-1)

버튼과 휠이 서로 다른 바이트를 쓴다. 단일 코드값으로는 표현할 수 없어서, 이 앱은 내부적으로 MOUSE_LEFT~MOUSE_WHEEL_DOWN(1~5) 식별자를 쓰고 MiniKB.Protocol 이 전송 직전에 4바이트로 펼친다.

키 번호

번호 대상
1~6 물리 키 — 가로 우선, 좌상단이 1
13 / 14 / 15 노브 — 좌회전 / 누르기 / 우회전
7~12, 16~18 12키·노브 2개 모델용 (이 장치에는 없음)
pascal
??????????????????????????????????????????????????????????????????
???  1   ???  2   ???  3   ???      ?????????????????????
??????????????????????????????????????????????????????????????????      ??? 13??? ???
???  4   ???  5   ???  6   ???      ??? 14??? ???
??????????????????????????????????????????????????????????????????      ??? 15??? ???
                            ?????????????????????

전역 커맨드

[0] [1] [2] 동작
0x00 0x00 — 핑 (ReportID 탐지용)
0xA1 레이어 번호 — 레이어 전환 — 이 장치는 무시
0xAA 0xAA — 플래시 저장 — 이걸 보내야 설정이 영구 반영
0xB0 (레이어<<4)|8 모드 LED 모드 설정

LED 모드는 셋뿐이다.

모드 동작
0 꺼짐
1 항상 켜짐
2 순차 점등 — 6개 키가 번갈아 켜지며 색도 무작위로 바뀜 (RGB)

전체 전송 흐름

[적용] 을 누르면 이렇게 나간다.

pascal
A1 01 ...                    ????????? 1 ??????
01 11 01 00 03 00 ...        ??? 1 ??????
01 11 01 01 03 16 ...        ??? 1 ?????? 1
02 11 01 00 00 00 ...        ??? 2 ??????        (????????? ????????? ????????? ??? ?????????)
...                          ??? 3~6, ?????? 13~15

[장치에 저장] 을 눌러야 AA AA 가 나가고 플래시에 굳는다. 그 전까지는 휘발성이다.

프로파일 전체를 매번 덮어쓴다. 부분 전송을 하면 장치에 남은 이전 설정과 프로파일 내용이 어긋나는데, 장치를 읽을 수 없으니 그 어긋남을 검출할 방법이 없기 때문이다.


앱 구조

pascal
HIDKeyboard\
?????? build.ps1                 ?????? ????????????
?????? README.md                 ?????????
?????? README.en.md              ??????
?????? src\                      ????????? ???????????? ??????
???  ?????? HIDKeyboard.dpr        ?????????
???  ?????? HIDKeyboard.rc         ????????? ???????????? (??????????????? ??????)
???  ?????? HIDKeyboard.manifest   PerMonitorV2 DPI ??????
???  ?????? MiniKB.Types.pas       ?????? ?????????????????????? ??????
???  ?????? MiniKB.HID.pas         Win32 HID ?????? ??? ?????????????????????????????? ??????
???  ?????? MiniKB.Protocol.pas    ?????? ?????? (?????? ??????, ????????? ??????)
???  ?????? MiniKB.KeyCodes.pas    HID usage code ??? ?????? ?????? ?????????
???  ?????? MiniKB.Model.pas       TKeyStep ?? TKeyBinding ?? TProfile
???  ?????? MiniKB.Profile.pas     ???????????? JSON ????????? + ?????? IO
???  ?????? MiniKB.Layout.pas      ?????? ?????? ?????? (??? ?????? ??? ?????? ??????)
???  ?????? MiniKB.Capture.pas     ???????????? ??? ?????? ??? ?????? ??????
???  ?????? Main.pas / .dfm        ?????? ???
???  ?????? LEDConfig.pas / .dfm   LED ?????? ???????????????
?????? bin\                      HIDKeyboard.exe          (git ??????)
?????? dcu\                      ????????? ?????? ?????????        (git ??????)
?????? tools\                    ?????? ????????????
?????? docs\                     ???????????? ?????????????? ??????

의존 방향은 한쪽이다.

  • MiniKB.Protocol 은 MiniKB.HID 를 참조하지 않는다. 장치를 모르는 순수 함수 집합이라, 프로토콜 해석이 틀렸을 때 고칠 곳이 여기 하나로 모이고 장치 없이도 출력만 보고 검증할 수 있다
  • MiniKB.Capture 는 모델도 UI도 참조하지 않는다. 훅 관리와 키 조합 해석만 하고 결과를 콜백으로 넘긴다
  • MiniKB.Layout 은 장치도 프로토콜도 참조하지 않는다. 배치 정의만 담는다

외부 의존성은 없다. setupapi.dll 과 hid.dll 을 직접 선언해 쓰므로 배포는 exe 하나면 된다.


빌드 방법

Delphi 13.0(Studio 37.0) Win64 전용. 32비트는 지원하지 않는다.

powershell
.\build.ps1          # 전체 리빌드
.\build.ps1 -Run     # 빌드 후 실행

스크립트가 실행 중인 HIDKeyboard.exe 를 먼저 종료하고(켜져 있으면 링크가 실패한다), 출력 폴더를 만들고, 두 단계를 순서대로 돌린다.

1단계 — 리소스 컴파일. 매니페스트(PerMonitorV2 DPI 선언)를 RT_MANIFEST 리소스로 묶는다. .dpr 의 {$R *.res} 가 이 파일을 링크하므로 이 단계를 건너뛰면 컴파일이 실패한다.

pascal
brcc32.exe HIDKeyboard.rc

2단계 — 컴파일.

pascal
dcc64.exe -B -U"C:\works\COMPONENT\core\src" "-NSSystem;Winapi;Vcl;Data" ^
          -NU"..\dcu" -E"..\bin" HIDKeyboard.dpr
옵션 뜻
-B 전체 리빌드
-U 공용 컴포넌트(SCLog 등) 검색 경로
-NS 네임스페이스 접두어
-NU DCU 출력 폴더 — 중간 산출물을 dcu\ 로 분리
-E 실행 파일 출력 폴더

두 명령 모두 src\ 에서 실행한다. 소스와 프로젝트 파일이 그곳에 함께 있고, 중간 산출물(dcu\)과 결과물(bin\)만 밖으로 나간다.


프로파일 파일

위치: %APPDATA%\HIDKeyboard\profiles\<프로파일 이름>.json (예: C:\Users\<사용자>\AppData\Roaming\HIDKeyboard\profiles\기본.json)

파일 이름이 곧 프로파일의 정체성이다. 탐색기에서 복사하면 그대로 새 프로파일이 된다. JSON 안의 name 필드는 사람이 읽기 위한 것이고 앱은 읽지 않는다.

앱에서 키를 편집하면 그 즉시 해당 프로파일 파일에 저장된다(별도의 "저장" 버튼 없음). 화면 제목의 * 는 파일 미저장이 아니라 장치에 아직 반영되지 않음을 뜻한다 — 편집 내용을 실제 키패드에 굽지 않았다면 켜져 있는 표시다.

형식 예시:

json
{
  "name": "기본",
  "layout": "6key-1knob",
  "layers": {
    "1": {
      "4": {
        "type": "keyboard",
        "steps": [
          { "mods": 10, "code": 22 }
        ]
      },
      "13": {
        "type": "media",
        "steps": [
          { "mods": 0, "code": 234 }
        ]
      }
    }
  }
}
  • layers 의 키는 레이어 번호(이 장치는 항상 "1"), 그 안의 키는 물리 키 번호(1~6: 본체 키, 13/14/15: 노브 좌/누름/우)다
  • type 은 keyboard / media / mouse 중 하나. LED는 키 바인딩이 아니라 장치 전역 커맨드라 프로파일에 저장되지 않는다 — 메뉴의 [LED 설정...] 으로 그 자리에서 바로 적용한다
  • steps 는 순서대로 전송되는 (modifier, keycode) 조합 목록 — keyboard 타입만 여러 개(매크로)를 가질 수 있고, media/mouse 는 항상 하나뿐이다
  • 위 예시의 mods: 10 은 0x0A = Shift + Win, code: 22 는 0x16 = S. code: 234 는 0xEA = 볼륨 감소
  • 키코드는 숫자로 저장한다. 표시 이름은 UI 사정으로 바뀔 수 있어 파일 호환성을 깨뜨린다

tools\ 진단 스크립트

앱 없이 프로토콜/HID 계층을 직접 검증할 때 쓰는 PowerShell 스크립트들이다. 모두 pwsh -ExecutionPolicy Bypass -File <스크립트> 로 실행한다.

이 도구들이 프로토콜 복원의 핵심이었다. 앱의 버그와 프로토콜의 오해를 분리해서 볼 수 있게 해줬기 때문이다.

스크립트 용도
hidwrite.ps1 GUI 없이 프로토콜 패킷을 직접 구워 장치에 실측 매핑을 심는다. 옵션 스위치로 개별 검증: -Phase2(노브 번호·modifier 비트), -LayerTest(레이어 지원 여부), -MediaTest(멀티미디어·마우스 인코딩), -LedMode N(LED 모드), -SwitchTo N(레이어 전환만), -Restore(키 1~6 비우기)
keyprobe.ps1 물리 키를 누르면 가상 키 코드를 화면에 표시한다. F13~F18 매핑은 화면에 글자를 남기지 않으므로, 어느 물리 키가 어느 코드에 대응하는지 이 도구로 확인한다
readcaptions.ps1 실행 중인 HIDKeyboard.exe 창의 모든 자식 컨트롤 텍스트를 열거해 출력한다. GUI를 직접 볼 수 없는 환경에서 현재 화면 상태를 확인하는 용도
readlog.ps1 실행 중인 HIDKeyboard.exe 의 로그 메모 내용을 그대로 읽어온다. 진행 상황과 실제 전송된 패킷을 GUI 없이 확인할 때 쓴다

알려진 제약

  • 레이어를 지원하지 않는다. 프로토콜/모델 계층은 레이어 개념(최대 3개)을 유지하지만, 실측 결과 이 장치는 매핑 저장소가 한 벌뿐이고 레이어 전환 명령(0xA1)을 무시한다. 앱은 항상 레이어 1만 사용하고 전송한다. 프로토콜 계층에 레이어를 남겨둔 것은 다른 펌웨어 세대에서는 동작할 수 있기 때문이다
  • 장치에서 현재 설정을 읽어올 수 없다. 프로파일 파일이 유일한 진실의 원천이다. 파일을 잃으면 설정도 잃는다
  • 매크로는 최대 5단계. 한 키에 연속으로 매핑할 수 있는 키보드 조합은 5개까지다. 멀티미디어·마우스 타입은 애초에 단계가 하나뿐이다
  • 핫플러그가 완벽하지 않다. DBT_DEVNODES_CHANGED 를 재탐색 신호로 쓰므로, 다른 USB 장치의 연결·해제도 재탐색을 유발한다. 상태가 실제로 바뀔 때만 동작하므로 부작용은 없지만 정밀하지는 않다
  • Constraints.MinWidth 가 설계 폭(800)에 고정되어 있다. 창을 그보다 좁게 만들 수 없다

작업에서 얻은 교훈

정적 분석은 절반이다

IL 디스어셈블로 얻은 것은 "프로그램이 무엇을 보내는가" 였다. 하지만 "장치가 그것을 어떻게 해석하는가" 는 알 수 없었다. 루프 조건 하나(< vs <=)를 잘못 읽어 매핑이 지워졌고, 레이어가 실제로는 동작하지 않는다는 것도 실측 전에는 몰랐다.

바이너리에서 읽은 것은 가설이다. 장치에 물어봐야 사실이 된다.

실측 도구를 앱과 분리한 것이 결정적이었다

hidwrite.ps1 로 패킷을 직접 쏘아볼 수 있었기에, 문제가 생겼을 때 "앱이 잘못 만들었나, 프로토콜 이해가 틀렸나" 를 즉시 가를 수 있었다. 앱을 통해서만 시험했다면 두 층의 오류가 섞여 원인을 찾기 훨씬 어려웠을 것이다.

문서가 코드와 어긋나면 대가를 치른다

이 프로젝트에서 가장 값비싼 실수들은 설명과 구현이 다른 지점에서 나왔다.

  • 계획서 산문에는 "WriteFile 로 폴백한다"고 썼는데 예제 코드에는 없었다 — 구현자가 코드를 따랐고, 장치는 응답하지 않았다
  • 주석에는 "Win64에서 8"이라고 정확히 적어놓고 코드는 6을 계산했다 — 장치를 영영 못 찾았다

그래서 이 저장소는 실측 결과를 스펙 문서에 절마다 근거와 함께 기록한다. 나중에 이 장치를 다시 건드릴 때 출발점은 코드가 아니라 그 문서다.

전체를 봐야만 보이는 결함이 있다

개별 기능을 아무리 꼼꼼히 봐도, 여러 파일에 걸친 불변식이 깨진 것은 보이지 않는다.

프로파일을 불러올 때 이름을 파일 내용에서 가져오는 코드가 있었다. 그 한 줄만 보면 아무 문제가 없다 — 기본값 처리까지 되어 있으니까. 하지만 저장·삭제·목록이 전부 파일명을 정체성으로 쓴다는 걸 동시에 알아야 모순이 드러난다. 파일을 복사해서 쓰면 편집이 원본에 써지고, 장치는 읽을 수 없으니 복구도 안 된다.

마지막에 전체를 한 번 훑는 과정이 그래서 필요하다.

실제로 써봐야 드러나는 것

키 조합 캡처는 컴파일도 리뷰도 통과했다. 훅 해제 경로 세 개까지 검증받았다. 그런데 Ctrl+C 를 누르면 C 만 잡혔다.

훅이 modifier 키다운을 삼켜서 Windows 키 상태 테이블이 갱신되지 않는데, 그 상태를 GetAsyncKeyState 로 읽고 있었다. 자기가 소비한 정보를 시스템에 되묻는 구조 — 코드만 봐서는 자연스러워 보인다.

정적 검증이 아무리 촘촘해도 실사용을 대체하지 못한다.


프로토콜 문서

원본 앱을 IL 디스어셈블해 확인한 통신 프로토콜 전체(장치 식별, 전송 형식, 패킷 구조, 레이어/멀티미디어/마우스/LED 인코딩, 실측 근거와 날짜)는 docs/PROTOCOL.md 에 기록되어 있다.

이 저장소에서 가장 값진 자산이다. 코드는 다시 쓸 수 있지만 이 프로토콜을 다시 복원하려면 같은 과정을 반복해야 한다.

라이선스

MIT — LICENSE 참조.

-U 로 지정하는 로깅 유닛(SCLog)은 이 저장소에 포함되지 않는다. 직접 쓰는 로거로 바꾸거나 TSCLog.Instance.Info/Warn/Error 호출을 비워야 이 저장소만으로 빌드된다.