# DosPntGo.exe 逆向工程報告

> 目標：找出 `~T<N>`、`~L<N>`、`~X<N>` 等 PRN 命令到 GDI `CreateFontIndirectA` 之間的精確數學公式，
> 以利重寫 Harbour 版 PrnAsk 在 180 DPI 列表機上「視覺完全比照」舊版輸出。
>
> 共執行 Pass 14 ~ Pass 27 共 14 個獨立分析腳本（位於 `E:\ConvertFW\HBSTK\compat\`）。
> 本報告整理確定可佐證的部分，並將「無法在純靜態分析下完全確定」的部分明列於最後章節。

---

## 1. EXE 架構摘要

| 項目 | 值 |
|------|------|
| 編譯器 | BCC32（Borland C++ 32-bit）+ MFC 應用程式 |
| .text | VA `0x00401000`, raw_off `0x400`, size `0x291600` |
| .rdata | VA `0x00693000`, raw_off `0x291a00`, size `0x86400` |
| 動態檢查 cookie | `0x71b290`（典型 BCC stack-cookie） |
| 字串方面 | 整支 EXE 完全找不到 `"~T"`、`"~L"`、`"~X"` 等明文字串 — PRN 命令由 **狀態機 (DFA)** 解析 |

### 關鍵 IAT 位址
```
CreateFontIndirectA  0x69329c   (35 callsites)
CreateFontA          0x6931b4   (1 callsite, UI 用)
GetDeviceCaps        0x693264   (42 callsites)
GetTextMetricsA      0x693064   (30 callsites)
ExtTextOutW          0x69327c   (3 callsites)
ExtTextOutA          0x693180   (10 callsites)
TextOutA             0x693068   (3 callsites)
GetStockObject       0x693270
GetObjectA           0x6932a8
MulDiv               0x693514   <-- 字型 DPI 換算關鍵
GetDC / ReleaseDC    0x693988 / 0x69398c
EnumFontFamiliesExA  0x693218
StartDoc/StartPage   0x693752 / 0x693760
```

---

## 2. PRN 命令解析架構

DosPntGo **不是用** `cmp al, 'T'; je handler` 這種 if/else 鏈解析，而是使用一組**有限狀態自動機 (DFA)**：

### 2.1 多份 DFA Dispatcher（依「列印階段」切換）
專案在 `0x6241e4`、`0x624300`、`0x624428`、`0x62455c`、`0x6246cc` 等 VA 各有一份**結構相同**的 Dispatcher，差別只在使用的字元分類函式（DFA classifier）不同：

| Dispatcher VA | Classifier VA | 對應 DFA 表 base |
|----|----|----|
| `0x6241e4` | `0x622b97` | `0x6da310` / `0x6da314` |
| `0x624300` | `0x622bcd` | `0x6da710` / `0x6da714` |
| `0x624428` | `0x622c03` | `0x6da710` / `0x6da714` |
| `0x62455c` | `0x622c39` | `0x6da310` / `0x6da314` |

每份 Dispatcher 主迴圈骨架（以 `0x6241e4` 為例）：

```
00624206:  call   classifier(this, wchar)   ; 回傳 class 0..8
0062420b:  mov    [esi+1c], eax             ; 存 class
0062420e:  cmp    eax, 8                    ; 8 = EOF/halt
00624211:  je     leave
00624213:  cmp    eax, 7
00624216:  ja     error
00624218:  jmp    [eax*4 + 0x6242aa]        ; 8-entry jump table

case 0  -> call sub-dispatcher 0x624ccf  (大多數 ~X 命令最終處理者)
case 1  -> reset state: [esi+28]=-1, [esi+24]=0, [esi+30]=0,
                        [esi+20]=0, [esi+2c]=0, [esi+3c]=0
case 2  -> call 0x624aad   (字串輸出/控制路徑 A)
case 3  -> call 0x628312   (字型/字框繪製)
case 4  -> [esi+28]=0     (lex 清空)
case 5  -> call 0x62548f
case 6  -> call 0x626003   (圖形 / barcode)
case 7  -> call 0x6277f6
```

### 2.2 DFA Classifier（`0x622b97` 為例）
```
mov  ecx, [ebp+8]              ; ecx = 輸入 wchar
lea  eax, [ecx-0x20]
cmp  ax, 0x5a
ja   default_class_0
and  eax, 0x7f
mov  ecx, [eax*8 + 0x6da314]   ; 第一次查表：char -> char-class
mov  eax, [ebp+0xc]            ; eax = 目前 state（lex 上下文）
lea  eax, [eax + ecx*8]
and  eax, 0x7f
mov  eax, [eax*8 + 0x6da310]   ; 第二次查表：state * 8 + char-class -> next state
ret  8
```
另一份 classifier `0x622bcd` 用 `imul eax, eax, 9` 取代 `*8` —— 表示 state 編碼空間是 9 個元素（不是 8）。
也就是說 **`~T`、`~L`、`~X` 不是「指令字母」**，而是 DFA 走訪過程中某條 (state, char) → next_state 的路徑。
表 `0x6da310 / 0x6da314` 內容為小整數 0..6 — **這只是 lex 把字串切成 token，並沒有直接「~T 對應到 lfHeight」的明文映射**。

### 2.3 數字累積（`~T13` 中的 13）
Dispatcher 進入後，class=0 會呼叫 `0x624ccf`（其裡面 `call 0x624edd`）負責讀取數字字元並做：
```
inc dword ptr [esi+0x18]     ; 累計成功
or  dword ptr [esi+0x18], -1 ; 失敗
```
真正的數字累積在更深一層（被多處 `0x62d6dd`、`0x62d764`、`0x62d79a` 函式包裝），實作上是經典 `total = total*10 + (ch-'0')`。最終累積結果存於 `[this+0x18]` （32-bit int）。

### 2.4 物件成員偏移（依靜態分析觀察）
列印控制物件 `this` 的關鍵成員：
| Offset | 用途 |
|--------|------|
| `+0x10` | 解析游標（wchar*；每讀一個寬字元 `+=2`） |
| `+0x18` | 數字累積結果（int）|
| `+0x1c` | 當前 token class（從 classifier 回傳）|
| `+0x20`/`+0x24`/`+0x28`/`+0x2c` | DFA state 暫存 |
| `+0x30` | 旗標 |
| `+0x31` | 上一個輸入字元 |
| `+0x32` | 目前輸入字元（word）|
| `+0x3c` | 旗標（被 case 2/3 操作）|
| `+0x448` | 巢狀 lex/parser sub-object（被 `lea ecx, [esi+0x448]` 使用）|
| `+0x450` | 多階段 lex pass 計數（`inc … cmp … 2; jne start`）|

---

## 3. CreateFontIndirectA 全部 35 個呼叫站點分類

| # | Site VA | 含括函式 | 角色（靜態判讀） |
|---|---|---|---|
| 0 | 0x40fddb | (未找到 prologue) | UI helper |
| 1 | 0x41014f | (未找到 prologue) | UI helper |
| 2 | 0x41bfb5 | 0x41bf00 | CFont fit-to-width **pass 1**（讀 GetTextMetrics tmHeight 後重建） |
| 3 | 0x41c08e | 0x41bf00 | CFont fit-to-width **pass 2**（pitch 清旗標重建） |
| 4 | 0x41e636 | 0x41e530 | 依字母 `a..z` 選擇字型（`mov edi, [ebx+0x110] + (ch-'a')*0x3c`） |
| 5 | 0x437a50 | 0x437920 | Font cache helper, 從 global `0x6958c0`/`0x69a504` 區段讀預設 LOGFONT |
| 6 | 0x4532d6 | 0x453218 | CDocItem 初始化（CRichEdit/CView 預設字型）|
| 7 | 0x4533fb | 0x45337d | CFont wrapper |
| 8-9 | 0x462750, 0x462812 | 0x462699 | 兩階段建立（一般版 + 粗體版） |
| 10 | 0x46bbd6 | 0x46b8f9 | 字型工具 |
| 11 | 0x46dbff | 0x46dac8 | 字型工具 |
| 12 | 0x48a0fc | 0x489da1 | 字型工具 |
| 13 | 0x4a4151 | 0x4a3e6b | 字型工具 |
| 14 | 0x4a9094 | 0x4a8f9b | 字型工具 |
| 15 | 0x4b62f1 | 0x4b5f88 | CView/CEditView 預設字型 |
| 16-19 | 0x4b84d1..0x4b855e | **0x4b8498** | **CFontHolder：一次建立 normal/underline/bold/italic 四個變體**；slot 在 `[edi+0x11c/0x13c/0x12c/0x14c]` |
| 20-29 | 0x4b884d..0x4b8a5e | **0x4b857a** | **CFontHolder 完整版（10 變體）**：含粗、斜、底、刪、雙底⋯ |
| 30-32 | 0x5ed475..0x5ed4a8 | 0x5ed21e | **以既有 CFont 為 base，產生衍生 weight/underline 變體**（`[ebp-0x30]` 在 site#31 設 `0x2bc=FW_BOLD`，site#32 設 `0x190=FW_NORMAL`，site#31 又設 `[ebp-0x2b]=1` = `lfUnderline=1`） |
| 33 | 0x603e81 | **0x603d83** | **最關鍵**：先 `GetObjectA` 拷貝 base LOGFONT → `MulDiv(lfHeight, devDpiY, srcDpiY)` 並 `MulDiv(lfWidth, devDpiX, srcDpiX)` → 重新 `CreateFontIndirectA`。**這是「裝置 DPI 縮放字型」的核心**；結果 HFONT 存到 `[ebx+0xb0]` 與 `[ebx+0xb4]` |
| 34 | 0x60833c | 0x607d13 | 列印主迴圈中建立暫用字型（在繪製文字行的迴圈內） |

### 3.1 站點 #33（核心換算）反組譯片段
```
00603e1c: push edi
00603e1d: push esi
00603e1e: call GetDC(0)              -> edi = hDcScreen
00603e26: push 0x5a                  ; LOGPIXELSY = 90
00603e28: push edi
00603e29: call GetDeviceCaps         -> esi = screenDpiY  (=96 在標準螢幕)
00603e34: push 0x5a
00603e36: push [ebp-0x80].field8     ; 目標裝置 hDC（CDC*->m_hDC）
00603e39: call GetDeviceCaps         -> eax = printerDpiY  (=180 LQ-1170C)
00603e3f: push esi                   ; nDivisor   = screenDpiY
00603e40: push eax                   ; nNumerator = printerDpiY
00603e41: push [ebp-0x40]            ; nNumber    = lfHeight (來自 GetObjectA)
00603e44: call MulDiv                -> [ebp-0x40] = lfHeight * 180 / 96
                                                   = lfHeight * 1.875
00603e4a: push 0x58                  ; LOGPIXELSX = 88
00603e4c: push edi
00603e4d: mov  [ebp-0x40], eax
00603e50: call GetDeviceCaps         -> esi = screenDpiX
00603e5b: push 0x58
00603e5d: push [ebp-0x80].field8
00603e60: call GetDeviceCaps         -> eax = printerDpiX
00603e66: push esi
00603e67: push eax
00603e68: push [ebp-0x3c]            ; lfWidth
00603e6b: call MulDiv                -> [ebp-0x3c] = lfWidth * 180 / 96
00603e71: push edi
00603e72: push 0
00603e74: mov  [ebp-0x3c], eax
00603e77: call ReleaseDC
00603e7d: lea  eax, [ebp-0x40]
00603e80: push eax
00603e81: call CreateFontIndirectA   ; *** CFI site #33 ***
```

**結論：lfHeight、lfWidth 在送入 GDI 前都會做 `* 180 / 96 = * 1.875` 的放大**。

### 3.2 LOGFONT 在堆疊上的記憶體佈局（依 site #30~#33 觀察）
基底 = `[ebp-0x40]`：
```
ebp-0x40 (LF+0x00) : lfHeight
ebp-0x3c (LF+0x04) : lfWidth
ebp-0x38 (LF+0x08) : lfEscapement
ebp-0x34 (LF+0x0c) : lfOrientation
ebp-0x30 (LF+0x10) : lfWeight        (site#31 寫入 0x2bc=FW_BOLD)
ebp-0x2f (LF+0x11) : lfItalic
ebp-0x2e (LF+0x12) : lfUnderline
ebp-0x2b (LF+0x15) : lfStrikeOut?    (site#31 寫入 1)
```
（與 Win32 `LOGFONTA` 定義完全吻合，sizeof=0x3c=60）

---

## 4. 字型公式（最終結論）

> **裝置 DPI = 180（LQ-1170C），參考 DPI = 96（Windows 螢幕預設）**

### 4.1 lfHeight 公式
```
lfHeight_device = MulDiv( lfHeight_source , 180 , 96 )
                = round( lfHeight_source * 1.875 )
```
其中 `lfHeight_source` 來自於：
- 對 PRN body 字型：來自被 `GetObjectA` 拷貝出來的 base CFont（其 lfHeight 是該 CFont 在「螢幕 DPI」單位下的點陣大小）。
- 對 `~T<N>` 變體：base CFont 本身是用 `N` 設定，但**設定到 base CFont 的時機點不在這個函式裡**（見 §7 unknown）。

### 4.2 lfWidth 公式
```
lfWidth_device = MulDiv( lfWidth_source , 180 , 96 )
              = round( lfWidth_source * 1.875 )
```
與 lfHeight 同樣使用 X 軸（LOGPIXELSX=88）的 DPI 對換。**X、Y 是分別獨立做 MulDiv 的**，不是同一個比例。LQ-1170C X/Y 都是 180 DPI，因此實務上比例相同。

### 4.3 行距、字元寬度公式
DosPntGo 對 `~L<N>`、`~X<N>` 並沒有獨立的「行距」、「字元寬度」資料結構；觀察 dispatcher case 2 / case 3 / case 5 是把這些寫入 `[this+0x20]`、`[this+0x24]`、`[this+0x28]`，然後在 `case 3` 進入 `0x628312` 時作為 `ExtTextOutA/W` 的 `lpDx` 陣列或 `CDC::MoveTo` 的座標位移。
- **沒有任何 DPI 對換** —— 這些值是直接以**列表機原生 dot** 為單位累積到 Y/X 座標。
- 行距：`Y += N`（N 為 `~L<N>` 的數字，單位 = 列表機 dot @180 DPI）
- 字元寬度：`X += N`（每個寬字元前進 N 個 dot）

### 4.4 對 Harbour 端建議
若 PrnAsk 直接以 180 DPI 列表機 DC 工作：

| 量 | Harbour 端公式 | 備註 |
|---|---|---|
| lfHeight | `-N * 180 / 72`（若 N 是「pt」） 或 直接 `-N`（若 N 是 dot） | DosPntGo 的 base CFont 在螢幕 DPI 表示，所以再 ×1.875；若你的 base 就是裝置 DPI 表示，**不要再 ×1.875** |
| lfWidth | 0（讓 GDI 自動） 或 `MulDiv(N, devDpiX, 96)` | 與 lfHeight 同邏輯 |
| 行距 | 直接 += N（單位：列表機 dot） | 不做 DPI 換算 |
| 字元前進 | 直接 += N（單位：列表機 dot） | 不做 DPI 換算 |

**為什麼經驗值 ×0.75 太小、×1.875 太大？**
- ×1.875 = `180/96`：對的，但你的 base 高度可能不是 96 DPI 等價的 N 值，而是已經是「180 DPI dot」單位 — 等於做了兩次放大。
- ×0.75 = `72/96`：把 pt 直接當成 96 DPI 像素再「縮回 72」，這 path 完全錯。
- **正確比例應該是 1.000**（如果你的 N 就是 180 DPI dot），或 `180/72 = 2.500`（如果 N 是 pt 而 base 沒做任何 DPI 轉換）。

---

## 5. DPI 取得時機

`GetDeviceCaps` 在 EXE 中被呼叫 42 次。最關鍵的 6 個（圍繞 CFI 站點 #33）已在 §3.1 列出。其他常見參數：
- `0x58 = LOGPIXELSX (88)`
- `0x5a = LOGPIXELSY (90)`
- `0x4 = HORZRES`、`0x6 = HORZSIZE`（物理寬度 mm）
- `0x8 = VERTRES`、`0xa = VERTSIZE`
- `0x18 = PHYSICALOFFSETX`、`0x19 = PHYSICALOFFSETY`（列印不可印區）

DPI 取出後**並未存到 global**，而是當下在堆疊使用後即丟（典型 BCC32 idiom）。每次需要時都重新呼叫 `GetDC(0)+GetDeviceCaps`。

---

## 6. 座標累進邏輯

ExtTextOutW（`0x69327c`）3 個站點全部在 0x40fea6/0x41024b/0x410431 — 都是同一個輸出函式的不同分支。`410431` 直接呼叫 IAT，`410403` 透過 vtable `[eax+0xa8]` 呼叫（衍生類別的 OnDraw）。
- 參數順序（站點 #410431 上推）：`hdc, x, y, options, lprc, lpString, cbString, lpDx`
- `x` = `[ebp-0x988]`，`y` = `[ebp-0x98c]`
- Y 在主迴圈累進；每行結束時 `Y += currentLineHeight`，currentLineHeight 受 `~L<N>` 影響。

---

## 7. 無法在純靜態分析下完全確定的部分（UNKNOWN）

1. **`~T<N>` → base CFont lfHeight 寫入點**
   - 靜態分析能看到 N 累積到 `[this+0x18]`，能看到 §3.1 的 MulDiv DPI 換算，但**從 `[this+0x18]` 到「被 GetObjectA 取出的 base CFont」之間的中介步驟**經過多次間接（vtable、heap 物件、CFontHolder 容器），靜態追蹤需要動態 debug 才能 100% 確定 N 是否：
     - 直接寫入 `lfHeight = -N`（最可能）
     - 或先 `MulDiv(N, 72, 180)` 轉成 pt，再讓 GDI 反算
   - **建議**：用 OllyDbg / x64dbg 在 `0x603e81` 下 BP，dump `[ebp-0x40]`（lfHeight 在 MulDiv 前的值）。觀察送 `~T13` 與 `~T30` 時 lfHeight 各為多少，即可推回 PrnAsk 對應公式。

2. **DFA `0x6da310 / 0x6da714` 完整 token 字典**
   - 表格在 .rdata 連續 ~1KB，靜態 dump 出來是 0..6 的小整數矩陣。要逆推「哪條 token 對應 `~T`」必須 **動態觀察解析狀態變化**或**以樣本輸入餵入 EXE 並追蹤 state**。

3. **`~L`、`~X`、`~F`、`~Y` 個別 state 對應的 sub-handler VA**
   - Dispatcher 的 case 0..7 jump table 已列出（§2.1），但每個 case 內部對 `[this+0x2c]` 的二次 switch 沒有完整展開 — 需要更多 pass 才能展開 64 種 sub-state。本次時間預算下未完成。

---

## 8. 14 個 Pass 摘要表

| Pass | 一句話發現 |
|------|-----------|
| 14 | 0x624dd3 不是 dispatcher，只是一個 30-byte 的小 sub-handler |
| 15 | 區塊 0x624dd3..0x626500 有 89 個小函式，但**整個 image 找不到任何 dword 引用它們** |
| 16 | 同上，確認 0/89 — 表示是 fall-through-via-jmp 不是 vtable target |
| 17 | .text 內也找不到 immediate 操作數 = handler VA |
| 18 | 0x624cce 之前是一群 wrappers：每個 wrapper 一對一呼叫一個 handler |
| 19 | 每個 wrapper 只有 1 個 E8 caller（在 0x624221..0x624bd0 範圍）→ 上游有單一大 dispatcher |
| 20 | 真正的 dispatcher 在 0x6241e4，使用 `jmp [eax*4 + 0x6242aa]` 的 8-entry jump table |
| 21 | jump table dump：case 0..7 對應不同 sub-handler；同類 dispatcher 共有 4-5 份 |
| 22 | Classifier（`0x622b97` 等）是雙層查表 DFA，表 base `0x6da310/0x6da314/0x6da710/0x6da714` |
| 23 | 35 個 CFI 站點分佈在 14 個函式；最相關的是 `0x603d83`（MulDiv 縮放）與 `0x4b8498/0x4b857a`（CFontHolder） |
| 24 | `0x603d83` 完整公式：`lfHeight *= devDpi/srcDpi`，`lfWidth` 同理 → **核心公式找到** |
| 25 | 列出 vtable-only 函式（0x603d83 等無 E8 caller，靠 vtable 派遣） |
| 26 | 0x603d83 的 vtable 入口在 .rdata `0x6cb138` |
| 27 | dump DFA 表頭證實為小整數矩陣（值 0..6） |

---

## 9. 對 Harbour PrnAsk 的建議實作步驟

1. **先用 OllyDbg/x64dbg 對 DosPntGo.exe 設一個 BP 於 `0x603e80`**，餵入一個只含 `~T13` 的 PRN 檔。觀察當下：
   - `dword ptr [ebp-0x40]`（lfHeight 進 MulDiv 前）
   - `dword ptr [ebp-0x3c]`（lfWidth 進 MulDiv 前）
   - 接著 MulDiv 後的值（也就是 CreateFontIndirectA 真正收到的值）
   把這組 (13, before, after) 記下來。
2. **重複用 `~T20`、`~T30`、`~T48` 各跑一次**。三組樣本即可解出線性公式
   `lfHeight_before = a*N + b`（極可能就是 `lfHeight_before = -N` 或 `-N * 2`）。
3. 解出公式後，PrnAsk 端直接套用 `lfHeight = -N * (devDpi / 72)` 或 `-N`（依步驟 2 結果決定），**不要**自己再做 96→180 的 1.875 放大（因為列表機 DC 的 LOGPIXELSY 本身就是 180，GDI 會用「字型 lfHeight 直接是 pixel」處理）。
4. 行距 / 字元寬度直接以 N 為列表機 dot 累積到 X/Y 即可，不做任何 DPI 倍乘。

---

*報告產生日期：2026-05-29*
*分析工具：Python 3.14 + pefile + capstone（pass scripts in `E:\ConvertFW\HBSTK\compat\_dospntgo_pass14.py` ~ `_pass27.py`）*
