從原始碼編譯與客製化 Ioskeley Mono

解剖 Iosevka 參數化造字管線、Patel 幾何引擎與 Nerd Font 封裝實戰
featured.svg

在上一篇〈Berkeley Mono 與 Ioskeley Mono〉中,我們深入比較了 U.S. Graphics Company 的商業字體 Berkeley Mono (TX-02) 與開源社群基於 Iosevka 打造的 Ioskeley Mono,並透過線上互動實驗室對照了兩者在點狀 0、單層 g、平弧括號 () 以及 Normal / SemiCondensed 字寬上的細節表現。

然而,當我把 Ioskeley Mono 換成日常主力字體、在編輯器與 terminal 裡寫了一整天程式碼後,心裡突然冒出一個小小的念頭:如果我想讓數字 7 的斜桿正中央多加一道水平橫槓(crossbar),就像歐洲手寫體或老式工程圖紙上的 7 那樣,有沒有辦法自己動手改?

如果今天面對的是一般用 FontForge 或 Glyphs 手工繪製的傳統字體,這個念頭大概會在五分鐘內被打消——因為一個完整的現代程式字體家族包含 10 種字重(Thin 100 到 Black 900)、2 種字寬(Normal 與 SemiCondensed)以及正體與斜體,光是一個家族就有 40 個 .ttf 字型檔。手動去改 40 個檔案的貝茲曲線,不僅筆畫粗細與斜率難以對齊,還會破壞原有的 TrueType hinting 與連字表。

但 Ioskeley Mono 最迷人的地方就在這裡:它不是一套靜態的向量圖檔,而是一座由程式碼驅動的參數化造字工廠。 昨晚我在 herdr 的 ioskeley-mono workspace 裡,直接從零複製儲存庫、修改一行設定、一路追蹤到底層的 Patel (.ptl) 幾何語言,並寫腳本用 8 個平行 worker 打上完整的 Nerd Font 圖示補丁。這篇文章就來完整紀錄這趟從原始碼編譯與客製化字體的實戰過程,以及途中踩到的兩個精彩陷阱。

為什麼從原始碼編譯字體比想像中更有趣?

打開 ahatem/IoskeleyMono 的儲存庫會發現,它本身並不儲存成千上萬個字元的曲線座標,核心只有一份不到 400 行的 private-build-plans.toml 藍圖檔,搭配上游的 Iosevka 造字引擎(固定於 v34.4.0)。

當我們從原始碼建置時,整個流程其實是一條橫跨編譯器、計算幾何與二進位封裝的八階段管線:

ioskeley-build-pipeline.svg

藍圖結構:四個繼承自同一核心的字體家族

在 private-build-plans.toml 中,作者透過 Iosevka 的繼承機制(inherits = "buildPlans.IoskeleyMono")定義了四個編譯目標(build plans):

  1. 標準版 IoskeleyMono:設定 spacing = "normal"、serifs = "sans" 與 noCvSs = true,包含完整的程式碼連字規則(calt)、10 種字重、2 種字寬(Normal = 600、SemiCondensed = 540)與 11.8° 斜體。
  2. 終端機版 IoskeleyMonoTerm:繼承自標準版,唯一關鍵差異是設定 spacing = "term"。這會強制將所有箭頭、幾何方塊與 box-drawing 符號嚴格限制在單一 terminal 字元格(character cell)寬度內,避免在 Ghostty、Kitty 或 Alacritty 中破格。
  3. 無連字版 IoskeleyMonoNL:設定 noLigation = true,供無法單獨關閉連字的編輯器(如舊版 Xcode)使用。
  4. 網頁子集版 IoskeleyMonoWeb:透過 [buildPlans.IoskeleyMonoWeb.subset.include] 僅保留拉丁字母、標點、箭頭、數學運算子與框線符號的 Unicode 區間,將原本完整版每個約 480–550 KB 的 .woff2 透過字元集子集化(subsetting)大幅縮減至約 94 KB。

此外,所有家族都共享一組精心調校的度量參數(private-build-plans.toml 第 201 至 223 行):

[buildPlans.IoskeleyMono.metricOverride]
xHeight = 520               # 小寫字母高度
cap = 690                   # 大寫字母與數字高度 (CAP)
ascender = 740              # 上伸部高度
sb = 85                     # 左右邊距 (Sidebearing)

accentWidth = 182           # 變音符號寬度與間距參數
accentClearance = 76
accentHeight = 162
accentStackOffset = 208

leading = 1250              # 預設行距
parenSize = 860             # 括號垂直跨度
dotSize = 'blend(weight, [100, 110], [400, 125], [900, 150])'
periodSize = 140

essRatio = 1.03             # 微調 S / s 與問號中央轉折處的水平加寬比例
essRatioUpper = 1.03
essRatioLower = 1.03
essRatioQuestion = 1.03
archDepth = 152
smallArchDepth = 157

注意看 dotSize 那行:blend(weight, [100, 110], [400, 125], [900, 150]) 是一個隨字重動態插值的函數,確保在極細體(Thin 100)時標點與 i 的方點不會太小而消失,而在極粗體(Black 900)時又不會過度膨脹糊成一團。

本地端建置環境準備

要在 Linux 本地端從頭編譯 Ioskeley Mono,只需要準備 Node.js 20+(上游 package.json 最低需求為 >=16.0.0,官方 CI 採用 Node.js 20)與 ttfautohint(用於產生標準解析度螢幕所需的 TrueType hinting 指令):

# 1. 複製 Ioskeley Mono 儲存庫
git clone [email protected]:ahatem/IoskeleyMono.git ioskeley-mono
cd ioskeley-mono

# 2. 複製上游 Iosevka 引擎(鎖定 v34.4.0 確保變體名稱與度量完全一致)
git clone --depth 1 --branch v34.4.0 https://github.com/be5invis/Iosevka.git iosevka-src

# 3. 確認系統已安裝 ttfautohint 與 fontforge(後續打 Nerd Font 補丁需要)
sudo pacman -S --needed ttfautohint fontforge python-fonttools

為什麼一定要鎖定 Iosevka v34.4.0?因為上游 Iosevka 的 main 分支演進速度極快,有時會重新命名部分字形變體或調整內部幾何常數;固定在 v34.4.0 可以確保我們編譯出來的結果與官方發行版完全一致。

一行設定的魔法:為數字 7 加上水平橫槓

現在回到我們最初的目標:把數字 7 改成中間帶有水平橫槓的版本。

打開 private-build-plans.toml 的第 15 行,在 [buildPlans.IoskeleyMono.variants.design] 區塊中可以看到上游預設使用的是無襯線直桿 7:

[buildPlans.IoskeleyMono.variants.design]
one = "no-base"
two = "straight-neck-serifless"
three = "flat-top-serifless"
four = "semi-open-serifless"
five = "oblique-flat-serifless"
six = "open-contour"
seven = "straight-serifless"             # <-- 上游預設:無橫槓的俐落直桿 7
eight = "two-circles"
nine = "open-contour"
zero = "dotted"

在 Iosevka 的命名系統中,數字 7 的變體名稱是由三個正交維度組合而成的:[主幹形狀 body]-[左上角襯線 serif]-[中段橫槓 overlay]:

變體名稱 (seven = ...) 主幹形狀 (body) 左上角垂直短襯線 (serif) 中段水平橫槓 (crossbar) 視覺風格建議
straight-serifless(預設) 直線斜桿 無 無 極簡現代,貼近 TX-02 原生設定
straight-serifless-crossbar 直線斜桿 無 有 最推薦:保留幾何直桿,同時強化 7 與 1/T 的辨識度
straight-serifed-crossbar 直線斜桿 有 有 帶有濃厚打字機與古典印刷感
bend-serifless-crossbar 微彎弧形斜桿 無 有 偏柔和手寫風格,較不適合方正的 TX-02 家族

因為 IoskeleyMonoTerm、IoskeleyMonoNL 與 IoskeleyMonoWeb 全部都透過 inherits = "buildPlans.IoskeleyMono" 繼承字形設定,我們只需要改動 private-build-plans.toml 的第 15 行:

seven = "straight-serifless-crossbar"

接著把修改後的設定檔複製進 iosevka-src/,啟動 Verda 建置系統:

cp private-build-plans.toml iosevka-src/
cd iosevka-src
npm install

# 同時編譯標準版 (IDE 用) 與 Term 版 (終端機用)
npm run build -- contents::IoskeleyMono contents::IoskeleyMonoTerm

Verda 是 Iosevka 作者 Belleve Invis 專門開發的增量平行建置引擎(定義於 iosevka-src/verdafile.mjs)。當我們修改 private-build-plans.toml 並針對單一家族執行建置時,Verda 會自動解析相依圖、調動多核心 CPU 平行更新 614 個建置目標(終端機印出 Finished with 614 targets updated.),短短幾分鐘內就在 iosevka-src/dist/ 下產出全部 40 種樣式的 TTF/(Hinted)、TTF-Unhinted/、WOFF2/ 與 WOFF2-Unhinted/。

深入造字引擎:Patel (.ptl) 如何將一行字串編譯成 TrueType 輪廓?

改一行 TOML 就能讓 40 個字型檔同步長出比例完美的水平橫槓,這背後到底發生了什麼事?我在 ioskeley-mono workspace 中一路追進了 iosevka-src/packages/font-glyphs/src/number/7.ptl,發現 Iosevka 內部的幾何編譯管線設計得極為精妙。

讓我們結合實際從編譯後的 IoskeleyMono-Regular.ttf 抽取出來的 TrueType 頂點座標,一步步拆解 "straight-serifless-crossbar" 是如何變成字型檔裡的二進位曲線(下圖與後文的頂點追蹤圖皆直接擷取自編譯後 TTF 的真實 glyf 輪廓座標):

seven-anatomy.svg

Patel 語法編譯與 SuffixCfg.weave 變體編織

Iosevka 所有的字形都不是手畫的,而是用一種名為 Patel(.ptl)的領域特定語言(Lisp 方言,採用縮排語法)撰寫的程式。由於 Node.js 無法直接執行 .ptl,建置系統第一步會呼叫 patel-c 編譯器,將 7.ptl 轉譯為標準的 JavaScript ES Module(lib/number/7.mjs):

patel-c --strict --esm packages/font-glyphs/src/number/7.ptl -o packages/font-glyphs/lib/number/7.mjs

打開 packages/font-glyphs/src/number/7.ptl 的第 65 至 93 行,可以看到 SuffixCfg.weave 如何動態編織出所有合法的 7 變體組合:

local SevenConfig : SuffixCfg.weave
    object # body
        straight              StraightBody
        bend                  BendBody
        curly                 CurlyBody
    object # serif
        serifless             nothing
        serifed               StartSerif
    function [body] : object # crossbar
        ""                    nothing
        crossbar : match body
            [Just "straight"] Crossbar
            [Just "bend"]     BendCrossbar
            [Just "curly"]    CurlyCrossbar

foreach { suffix { Body Serif Crossbar } } [Object.entries SevenConfig] : do
    create-glyph "seven.lnum.\(suffix)" : glyph-proc
        include : MarkSet.capital
        if Body : include : Body CAP
        if Serif : include : Serif CAP
        if Crossbar : include : Crossbar CAP

    create-glyph "seven.onum.\(suffix)" : glyph-proc
        include : OnumMarks.p
        include : refer-glyph "seven.lnum.\(suffix)"
        include : ShiftDown

select-variant 'seven.lnum' [CodeLnum '7'] (follow -- 'seven')
select-variant 'seven.onum' [CodeOnum '7'] (follow -- 'seven')

當我們在 TOML 中指定 seven = "straight-serifless-crossbar" 時:

  • Body 對映到 StraightBody 幾何程序;
  • Serif 對映到 nothing(不畫左上角垂直襯線);
  • Crossbar 根據 body == "straight" 自動匹配到 Crossbar 程序;
  • 最後 select-variant 將產生的 seven.lnum.straight-serifless-crossbar 綁定到等高數字(Lining Figures)的 Unicode 碼位 U+0037 ('7')(同時也透過 seven.onum 衍生出供 OpenType onum 特徵使用的老式高低數字版本)。

參數化座標求值:StraightBody 與 Crossbar

當 Node.js 在編譯某個特定字重與字寬(例如 Regular 400,字寬 600、大寫高度 CAP = 690、筆畫粗細 Stroke = 73、左右邊距 SB = 85、右邊界 RightSB = 600 - 85 = 515)時,它會把這些度量常數代入 7.ptl 第 13 至 20 行與第 44 至 49 行的幾何公式中:

define SevenXLeft : mix SB RightSB 0.28
define [StraightBody top] : union [HBar.t SB RightSB top] : intersection
    Rect (top - Stroke) 0 SB RightSB
    dispiro
        widths.rhs
        corner RightSB (top - Stroke)
        corner [mix RightSB (SevenXLeft + [HSwToV : 0.75 * Stroke]) 2] [mix (top - Stroke) 0 2]

# ... (第 21–43 行定義 BendBody、CurlyBody 與 StartSerif)

define [Crossbar top] : glyph-proc
    local m : mix SevenXLeft (RightSB - [HSwToV HalfStroke]) 0.5
    include : HBar.m
        m - [HSwToV QuarterStroke] - LongJut * 0.8
        m + [HSwToV QuarterStroke] + LongJut * 0.7
        mix 0 (top - QuarterStroke) 0.5

這幾行 Patel 程式碼藏著字體設計師滿滿的視覺補償細節:

  1. 頂部橫樑 (HBar.t SB RightSB top):在 X = 85..515、Y = 617..690 畫出一道頂部切齊 CAP (690)、厚度為 Stroke (73) 的水平橫樑。
  2. 斜向主幹 (dispiro + Rect 交集裁切): 如果直接畫一條斜線段,斜線的上下兩端切口會垂直於斜線本身(呈現斜角突出)。因此 Iosevka 故意用 mix ... 2 把 dispiro 斜桿往外延伸兩倍長,再用一個 Rect (top - Stroke) 0 SB RightSB(即 Y = 0..617 的矩形遮罩)做布林交集(intersection)!這樣斜桿頂端就會完美貼合在 Y = 617(頂部橫樑下緣),底端則在 Y = 0 基線上切出俐落的水平平頭(X = 180..267)。
  3. 水平橫槓 (Crossbar) 的不對稱視覺平衡:
    • 垂直位置:不是單純取 CAP / 2 = 345,而是 mix 0 (top - QuarterStroke) 0.5 = 336(扣除頂部橫樑視覺重量後的幾何中心,落在 Y = 299..373)。
    • 水平位置 (m):計算斜桿在半高處的水平中心點 m。
    • 左右突出的視覺微調:注意橫槓往左延伸了 0.8 * LongJut(到 X = 158),往右卻只延伸 0.7 * LongJut(到 X = 501)!為什麼左邊比右邊多出 0.1 * LongJut?因為 7 的斜桿是從右上往左下傾斜,左上方的字懷空間比右下方開闊;左端稍微多探出一點,在人眼看來才會覺得橫槓「正好位在斜桿正中央」。

Spiro 螺旋曲線展開與 2D CSG 布林聯集 (typo-geom)

在傳統字體設計中,曲線是由三次貝茲曲線(cubic Bézier)的控制點手拉出來的;而在 Iosevka 中,曲線是由 Raph Levien(同時也是 Google Fonts Inconsolata 與 Zed GPUI 部分文字渲染的先驅)提出的 Spiro 螺線演算法(Euler spiral)計算而成,確保曲率達到 G2 甚至 G4 連續。

經過 spiro-pen-expand.mjs 將一維骨架向兩側展開成二維筆畫多邊形後,重頭戲交給了 typo-geom 幾何引擎:

  • 當我們把頂部橫樑、斜向主幹與水平橫槓透過 union 組合在一起時,三個零件在十字交會處會有內部的重疊邊線。
  • 如果直接把三個重疊的矩形寫進字型檔,在開啟半透明渲染、外框描邊或低解析度光柵化時,交界處會出現破洞(winding rule 衝突)或墨水過深。
  • typo-geom 會執行嚴謹的 2D 建構實體幾何布林聯集運算(CSG Union),自動找出所有交點(例如斜桿與橫槓相交的 (300, 299)、(329, 373)、(417, 373)、(388, 299) 四個內角),徹底剔除內部重疊線段,融合成單一封閉的 15 頂點外輪廓!

我們直接用 Python 的 fontTools 把編譯出來的 IoskeleyMono-Regular.ttf 拆開,印出 seven 字形的 SVG 路徑指令,就能親眼驗證這個結果:

M180 0 300 299H158V373H329L427 617H85V690H515V617L417 373H501V299H388L267 0Z
seven-csg-vertices.svg

順著上圖右半邊標出的 [1] 到 [15] 號頂點走一圈,可以看到 typo-geom 如何把原本三個重疊的幾何零件融合成一條順時針方向(TrueType 外輪廓規範)的一筆畫封閉路徑:從基線左下角 [1] (180, 0) 出發,沿斜桿左緣爬升到橫槓下緣交角 [2] (300, 299),往左折出橫槓左翼 [3]➔[4]➔[5],繼續沿斜桿爬升到頂樑下緣交角 [6] (427, 617),繞過頂部橫樑 [7]➔[8]➔[9]➔[10],再沿斜桿右緣一路折出橫槓右翼 [11]➔[12]➔[13]➔[14] 回到基線右下角 [15] (267, 0) 閉合。整顆字元沒有任何多餘的內部節點,而且當 Verda 編譯 Thin (100)、Bold (700)、Black (900) 或 SemiCondensed (90%) 時,這 15 個頂點都會根據各自的 Stroke 與 Width 參數自動重新計算。

二次貝茲曲線擬合 (Quadify) 與 OpenType 封裝 (ot-builder)

最後兩個底層步驟則是跨格式的幾何轉換:

  1. 三次轉二次貝茲曲線 (Quadification): Iosevka 內部的幾何運算全部採用三次樣條(degree-3 cubic curves),但 TrueType(.ttf)的 glyf 表規範只支援二次貝茲曲線(degree-2 quadratic Bézier,每段曲線只有一個 off-curve 控制點)。packages/geometry/src/quadify.mjs 會呼叫 TypoGeom.Quadify.auto(arc, 1, 8),將所有曲線自動細分並擬合為誤差小於 1 個字體設計單位(1/1000 em)的二次貝茲曲線。
  2. 二進位表格序列化 (ot-builder) 與 ttfautohint: 最後由 ot-builder 將輪廓點座標寫入 glyf 與 loca 表、將 Unicode 映射寫入 cmap 表、將連字與 zero 特徵寫入 GSUB 表,產出 TTF-Unhinted/,再交由 ttfautohint 注入對齊螢幕像素網格的 bytecode 指令產出 TTF/。

為自訂字體打上 Nerd Font 補丁:平行加速與兩個實戰陷阱

在 Linux 桌面環境(尤其是搭配 eza、lazygit、neovim、starship 與 herdr 的 terminal 工作流)中,只有純文字的 .ttf 是不夠的——我們還需要把 FontAwesome、Devicons、Octicons、Material Design Icons 與 Powerline 符號打包進去,做成 Nerd Font 版本。

平行化批次修補腳本 (xargs -P 8)

官方的 Nerd Fonts Patcher 是一個由 FontForge Python 介面驅動的腳本。如果用單執行緒 for 迴圈逐一修補 40 個字型檔,每個檔案約需 15 至 20 秒,跑完一個家族就得花上十幾分鐘。

實務上,我們可以直接下載 FontPatcher.zip(下方範例固定於 v3.5.1 確保可重現性),並利用 xargs -n 2 -P 8 啟動 8 個平行 worker 同時修補:

#!/usr/bin/env bash
set -e

# 1. 下載並解壓縮官方 Nerd Fonts Patcher (v3.5.1)
if [ ! -d "nerd-fonts-patcher" ]; then
    curl -fLO https://github.com/ryanoasis/nerd-fonts/releases/download/v3.5.1/FontPatcher.zip
    unzip -q FontPatcher.zip -d nerd-fonts-patcher
fi

DIST_TERM="iosevka-src/dist/IoskeleyMonoTerm/TTF"
PATCHER="nerd-fonts-patcher/font-patcher"
mkdir -p patched-fonts-term/Normal patched-fonts-term/SemiCondensed

patch_font() {
    local font="$1"
    local outdir="$2"
    fontforge -script "$PATCHER" "$font" \
        --complete --careful --mono --outputdir "$outdir" >/dev/null 2>&1
    echo "Finished $(basename "$font")"
}
export -f patch_font
export PATCHER

# 2. 建立任務佇列(區分 SemiCondensed 與 Normal)
for font in "$DIST_TERM"/IoskeleyMonoTerm-SemiCondensed*.ttf; do
    echo "$font" "patched-fonts-term/SemiCondensed"
done > /tmp/fonts-term-to-patch.txt

for font in "$DIST_TERM"/IoskeleyMonoTerm-*.ttf; do
    [[ "$font" == *Condensed* ]] && continue
    echo "$font" "patched-fonts-term/Normal"
done >> /tmp/fonts-term-to-patch.txt

# 3. 使用 8 個平行 worker 批次修補 40 個字型檔
cat /tmp/fonts-term-to-patch.txt | xargs -n 2 -P 8 bash -c 'patch_font "$@"' _

這裡傳給 font-patcher 的三個參數至關重要:

  • --complete:注入所有開發者圖示集(FontAwesome、Devicons、Octicons、Codicons、Powerline 等)。
  • --careful:若圖示碼位與 Ioskeley Mono 原生已有的字元重疊,保留原生字元而不覆蓋。
  • --mono:將所有注入的圖示嚴格縮放至單一等寬字元格(1-cell),產出家族名稱帶有 Nerd Font Mono 後綴的字型,確保在 terminal 裡圖示不會溢位蓋住後面的空白或文字。

FontForge 清除 post.isFixedPitch 等寬旗標的副作用

當 font-patcher 透過 FontForge 重新匯出 .ttf 字型檔時,有一個歷史悠久的經典陷阱:FontForge 有時會將 TrueType post 表中的 isFixedPitch 欄位重設為 0。

一旦 post.isFixedPitch 不等於 1,部分 terminal 模擬器或作業系統的字體選擇器就會誤以為這是一套「比例字體(proportional font)」,進而拒絕在終端機字體清單中顯示它,或是導致字元間距計算異常。

因此在 font-patcher 跑完之後,務必補上一段幾行就能跑完的 Python fontTools 修復腳本,把所有 .ttf 的 isFixedPitch 強制改回 1:

from pathlib import Path
from fontTools.ttLib import TTFont

fixed = 0
for f in sorted(Path("patched-fonts-term").rglob("*.ttf")):
    font = TTFont(f)
    if "post" in font and font["post"].isFixedPitch != 1:
        font["post"].isFixedPitch = 1
        font.save(f)
        fixed += 1

print(f"Restored isFixedPitch on {fixed} fonts")

為何重刷 fc-cache 後終端機裡的 7 依然沒有橫槓?

這是我昨晚在 ioskeley-mono workspace 裡遇到最有趣的一個實戰插曲。

當我改完 seven = "straight-serifless-crossbar"、編譯完 contents::IoskeleyMono、跑完 40 個檔案的 Nerd Font 修補、複製進 ~/.local/share/fonts/,並執行 fc-cache -fv 重新載入後——我滿懷期待地在 terminal 裡打出 1234567890,結果畫面上的 7 依然是光禿禿的一根直桿,完全沒有水平橫槓!

第一時間我還以為是 terminal 沒重讀字體快取(在 Kitty 可以按 Ctrl + Shift + F5 熱重載,Ghostty 可按 Ctrl + Shift + , 重載設定,Alacritty 則會自動監聽設定檔變更,VS Code 可執行 Developer: Reload Window)。但就算重開整個 terminal 視窗,7 還是沒變。

一查之下才揪出真正的兇手:

  1. IDE 與 Terminal 使用不同的編譯目標 (IoskeleyMono vs. IoskeleyMonoTerm): 檢查我的 ~/.config/ghostty/config、~/.config/kitty/kitty.conf、~/.config/alacritty/alacritty.toml、~/.config/foot/foot.ini 與 ~/.config/fontconfig/fonts.conf 後發現,為了讓 terminal 的格線完美對齊,我所有的終端機設定檔指定的都是 IoskeleyMonoTerm Nerd Font Mono(也就是 Term 家族),而不是標準版的 IoskeleyMono Nerd Font Mono。
  2. ~/.local/share/fonts/ 裡殘留了舊版的 Term 字型檔: 因為我第一輪只編譯並安裝了標準版 contents::IoskeleyMono,而 ~/.local/share/fonts/IoskeleyMono/ 資料夾底下還躺著先前安裝的舊版 IoskeleyMonoTermNerdFontMono-*.ttf!於是當 Fontconfig 解析 IoskeleyMonoTerm Nerd Font Mono 時,毫無懸念地繼續指向那批還沒加上橫槓的舊檔案。

找出原因後,解法就非常俐落了:

  1. 補跑 npm run build -- contents::IoskeleyMonoTerm 編譯帶橫槓 7 的 Term 家族。
  2. 執行 patch-term-fonts.sh 將 40 個 IoskeleyMonoTerm 字型檔打上 Nerd Font 補丁並修復 isFixedPitch = 1。
  3. 清理 ~/.local/share/fonts/ 下舊的散落檔案,統一安裝至 ~/.local/share/fonts/IoskeleyMono/ 與 ~/.local/share/fonts/IoskeleyMonoNerdFont/。
  4. 重刷快取並用 fc-match 驗證最終解析路徑:
# 重新建立 Fontconfig 快取
fc-cache -fv

# 驗證 Fontconfig 實際匹配到的字型檔路徑
fc-match -v "IoskeleyMonoTerm Nerd Font Mono" | grep -E "file:|family:"

當看到輸出明確指向剛出爐的 ~/.local/share/fonts/IoskeleyMonoNerdFont/IoskeleyMonoTermNerdFontMono-Regular.ttf,再重新載入終端機,整個畫面裡的 7 瞬間長出了那道俐落的水平橫槓!

結語:把字體當作可編譯的基礎設施

從單純「下載別人打包好的 .zip 字型檔」,跨出一步到「自己擁有字體的建置管線」,帶來的掌控感非常奇妙。

透過 Iosevka 的 Patel 參數化引擎與 private-build-plans.toml,字體不再是一顆無法修改的黑盒子:不管你是想幫數字 7 加上水平橫槓、把預設的點狀 0 換成斜線 0、微調行距 leading,還是針對自己的螢幕 DPI 調整字重曲線,都只需要改幾行設定檔,就能在幾分鐘內編譯出一套橫跨 10 種字重、2 種字寬、涵蓋 IDE 與 terminal Nerd Font 的專屬字體家族。如果你也在使用 Ioskeley Mono,強烈推薦把儲存庫 clone 下來親手編譯一次,打造真正百分百貼合自己視覺習慣的開發者字體!


Berkeley Mono 與 Ioskeley Mono

為 IDE 與終端機打造的等寬字體美學與線上互動對照
featured.svg

每天盯著編輯器與 terminal 超過八小時,字體幾乎是開發者最貼身的視覺介面。回顧過去幾年,我的主力字體一路從微軟經典的 Consolas、支援連字與手寫斜體的 Cascadia Code、字腔寬裕且閱讀極為舒適的商業字體 MonoLisa,一路換到 GitHub Next 主打 texture healing 動態字距與五大超家族的 Monaspace;這些字體都在不同時期陪伴我寫下無數行程式碼,但當工作流越來越依賴高密度的終端機與多視窗並排時,心裡總覺得想找一款更貼近老派工程手冊、俐落緊湊且毫無拖泥帶水的機械秩序感字體。

直到遇見 U.S. Graphics Company 推出的商業字體 Berkeley Mono (TX-02),以及開源社群以此為靈感、基於 Iosevka 參數化引擎打造的 Ioskeley Mono,那種源自 1970 年代技術手冊與資料終端機的字形比例,才真正讓程式碼與 TUI 介面同時安靜下來。

這篇文章除了整理這兩款字體的設計脈絡、字形解剖差異,以及在 IDE 與 Linux terminal(包含 Omarchy 環境)的最佳實務配置外,我也直接在文內嵌入了一套雙模式線上互動字體實驗室:你可以直接拖曳捲簾滑桿或原位閃切,對照 Berkeley Mono (TX-02) 與 Ioskeley Mono 的像素級差異,也能即時調整字級、行高、字寬與 OpenType 連字特徵,親手測試它們在程式碼與 terminal 框線下的實際表現。

為什麼開發者開始迷戀 1970 年代技術手冊風格?

過去我非常喜歡的幾款字體各有千秋:Consolas 在 Windows ClearType 時代奠定了極高的小字級易讀性;Cascadia Code 為現代終端機帶來活潑的連字;MonoLisa 擁有稍微加寬的字腔與極佳的長時間閱讀舒適度;而 Monaspace 則透過局部的字寬補償改善了等寬字體的疏密不均。然而當我們的工作流轉向多重 split pane、在左半邊開著編輯器、右半邊同時跑著多個 Ghostty 終端機與 TUI 工具(如 herdr、lazygit、btop 或 AI coding agent 的多工視窗)時,偏寬或偏柔和的字體往往會遇到三個取捨:

  1. 水平字距偏寬,分割視窗容納量受限:在 14 吋或 16 吋筆電螢幕上一旦切出左右兩個 80 列視窗,常見等寬字體很容易被迫折行。
  2. 字形情緒過於圓潤,大量符號堆疊時缺乏邊界感:當畫面充滿 Rust 的泛型與生命週期標記(<'a, T: Into<Cow<'a, str>>>)或巢狀括號時,過於柔和的弧線反而降低了視覺錨點。
  3. 終端機框線與文字基線的微妙脫節:部分針對 IDE 設計的字體在開啟連字後,遇到 terminal 內的 box-drawing 格線與資訊密度極高的 log 輸出時,整體節奏容易顯得鬆散。

Berkeley Mono (TX-02):源自冷戰時期工程圖紙的秩序感

由設計師 Neil Panchal 與 U.S. Graphics Company 主導設計的 Berkeley Mono (TX-02),最初是為了自家 CAD 與工程軟體介面量身打造的專屬字體。它的設計哲學非常明確:回到 1970 至 1980 年代的微處理器資料手冊(databooks)、航太儀表與早期 CRT 終端機,追求高辨識度、方正的幾何轉角,以及長時間閱讀不疲勞的灰度節奏。

Berkeley Mono 最吸引人的特質在於它「收斂卻不擁擠」的字腔結構:

  • 方中帶剛的幾何輪廓:字母的轉角帶有微妙的平切弧度(superellipse),既不像純點陣字體那樣生硬,也沒有多餘的裝飾襯線。
  • 零歧義的字元家族:針對程式設計最容易混淆的 0 / O、1 / I / l 做了極為鮮明的結構區隔。
  • 緊湊的水平節奏:在維持充裕字懷(counter)的前提下微調字元推進寬度,讓每一行程式碼像排好隊的積體電路針腳一樣整齊。

不過,Berkeley Mono 是一款採用商業授權的閉源字體(個人開發者授權 FX-102 定價為 $75 美元;官方另有提供代號 FX-050 7 天免費評估試用版可供下載測試,但 EULA 授權條款禁止將字型檔二次散佈或直接託管於公開網站)。如果你想在多台 Linux 工作站、容器環境、公開的 dotfiles 儲存庫或網頁專案中自由封裝使用,商業授權條款自然有其邊界。這也促成了開源社群最令人驚豔的一次致敬行動。

Ioskeley Mono:站在 Iosevka 肩膀上的開源致敬之作

Ioskeley Mono 是由開發者 Ahmed Hatem(ahatem/IoskeleyMono)發起、採用 SIL Open Font License 1.1 (OFL) 開源授權釋出的等寬字體專案。

它並沒有從零手繪每一筆貝茲曲線,而是巧妙運用了開源字體界最負盛名的參數化造字引擎 Iosevka v34.4.0。透過一份精心調校的 private-build-plans.toml 建置規格檔,Ioskeley Mono 在 Iosevka 數百種字形變體(glyph variants)與度量參數中,逐一挑選出最貼近 Berkeley Mono (TX-02) 視覺精神的組合,並編譯出完整的字體家族。

ioskeley-architecture.svg

這套建置管線帶來了幾個非常務實的工程優勢:

  • 完整的字重與字寬矩陣:原生提供從 Thin (100) 到 Black (900)(額外包含 SemiLight 350)共 10 種字重,並同時提供標準寬度 Normal (100%) 與微縮寬度 SemiCondensed (90%) 兩種版本。
  • 針對應用場景拆分的專屬變體:除了支援連字的標準版 Ioskeley Mono 與無連字版 Ioskeley Mono NL,還專門提供針對 terminal 單格對齊最佳化的 Ioskeley Mono Term,以及預先打好圖示補丁的 Nerd Font 版本。
  • 完全自由的開源授權:SIL OFL 1.1 允許你自由安裝在任何機器、打包進 Linux 系統映像檔,甚至直接子集化為 .woff2 嵌入個人技術部落格中。

線上雙模式互動字體對照實驗室

講再多字形理論,都不如直接把兩款字體放在同一塊畫布上親眼比對。由於 Berkeley Mono 屬於商業字體,我們無法直接將其原始字型檔散佈於公開網頁;因此我將下方的互動實驗室設計為兩個互補的模式:

  1. 即時互動編輯與終端機實驗室:直接載入自託管的 Ioskeley Mono(含 Regular、SemiCondensed 90% 與 Italic)、Monaspace Neon 與 Cascadia Code / Mono 網頁字型。為了遵守 U.S. Graphics Company 的 EULA 條款(禁止公開散佈字型檔),我們不將商業版或 FX-050 免費試用版字型打包進 GitHub 儲存庫;但只要你本機已安裝 Berkeley Mono / Berkeley Mono Trial,或點擊實驗室右上角的 「載入本機 FX-050 字型檔」 按鈕選取從官網免費下載的 .ttf / .otf / .woff2 檔案(100% 於瀏覽器記憶體內透過 FontFace API 渲染,不經過任何伺服器上傳),就能直接在左右面板即時對照!
  2. 像素級對照儀:將 Berkeley Mono (TX-02) 與 Ioskeley Mono 在完全相同字級(14.2px)、行高(1.54)與推進寬度下渲染的樣本進行零誤差像素對齊。你可以使用 捲簾滑桿 (Curtain Slider)左右拖曳,或點擊 原位閃切 (Blink Comparator)在同一座標瞬間切換,觀察兩者在字腔、筆畫粗細與細節上的異同。
↗ 下載 FX-050 試用版
Ioskeley Mono (Normal 100%) 約 -- 字元/列
Ioskeley Mono (SemiCondensed 90%) 約 -- 字元/列
提示:你可以直接在上方左右區塊打字或貼上自己的程式碼。試著勾選「切換斜線零 ('zero')」,觀察 0xFC82_0000 內部的中心方點瞬間變成斜線;也可以比較 Normal (100%) 與 SemiCondensed (90%) 在相同視窗寬度下每列可容納的字元數差異。
左側:Ioskeley Mono 右側:Berkeley Mono (TX-02)
IOSKELEY MONO (OPEN SOURCE) BERKELEY MONO (TX-02) Berkeley Mono TX-02 specimen
Ioskeley Mono specimen
⇄
實戰程式碼像素對照:兩款字體在完全相同的字級(14.2px)與字元格線上渲染。拖曳滑桿或切換至「原位一鍵閃切」,可以發現兩者的整體行距與字元落點完全吻合;細看則能察覺 Berkeley Mono (TX-02) 的筆畫略微厚實溫潤,而 Ioskeley Mono 的轉角與小寫 r、g、@ 則展現出更鮮明的幾何線條。

字形解剖:Ioskeley Mono 如何重現經典並走出自己的路?

如果你在上方實驗室切換到「Berkeley Mono (TX-02) × Ioskeley Mono 像素級對照儀」的第三個場景(128px 像素疊影解剖),會看到一個非常有趣的現象:兩款字體在單字元推進寬度(advance width)上達成了 +0.00 px 的零誤差同步,但在個別字元的局部筆畫上,因為 Iosevka 參數化骨架與手工繪製曲線的本質差異,展現了耐人尋味的細節對比。

招牌識別字元:點狀 0、單層 g、Q 與 @

我們可以從 ahatem/IoskeleyMono 的建置設定檔 private-build-plans.toml 中,看出作者是如何一磚一瓦挑選字形零件的:

[buildPlans.IoskeleyMono.variants.design]
zero = "dotted"                          # 預設採用中心點狀 0(Iosevka 另自動編譯 GSUB zero 斜線零特徵)
one = "no-base"
two = "straight-neck-serifless"
three = "flat-top-serifless"
six = "open-contour"                     # 6 與 9 採用開放式弧線缺口
eight = "two-circles"                    # 由上下兩個圓環組成的 8
nine = "open-contour"
capital-q = "crossing"                   # Q 的斜向尾筆俐落切過底部邊界
a = "double-storey-serifless"            # 雙層無襯線 a
g = "single-storey-serifless"            # 單層無襯線 g
k = "symmetric-touching-serifless"
r = "hookless-serifless"                 # 無鉤無襯線 r
y = "straight-serifless"
tittle = "square"                        # i 與 j 頂部圓點改為方點
punctuation-dot = "square"               # 句號、冒號與分號全面採用方點
paren = "flat-arc"                       # 近似垂直卻帶微弧的平弧括號 ()
brace = "curly-flat-boundary"
number-sign = "slanted"
at = "fourfold"                          # 高挑緊湊的四折 @ 符號
dollar = "through"                       # 豎線貫穿上下的 $ 符號

透過下方的像素疊影與對照表(圖中 象牙白 為共同骨架、琥珀金 為 Ioskeley Mono、珊瑚橘 為 Berkeley Mono TX-02),我們可以更清楚地歸納兩者在關鍵字形上的異同:

字元類別 像素疊影與視覺對照 Berkeley Mono (TX-02) 細節特徵 Ioskeley Mono 細節特徵
數字 0
方圓外框+中心點
0 像素疊影對照 中心點略呈圓潤橢圓,外框轉角較柔和 設定 zero = "dotted" 呈現俐落方點,且保留 Iosevka 內建的 OpenType zero 特徵可隨時切換為斜線 0
小寫 a 與 g
雙層 a・單層 g
a 與 g 像素疊影對照 a 的肚圍較貼近左緣;g 的下伸迴圈弧度較早收尾 採用 double-storey-serifless 與 single-storey-serifless,筆畫轉折更偏幾何直線
大寫 Q
矩形圓角+斜向尾筆
Q 像素疊影對照 尾筆從字腔內部偏左處起筆向右下延伸 採用 capital-q = "crossing",尾筆切入角度略高且向外探出更鮮明
符號 @ 與 $
高挑比例・貫穿 $
@ 與 $ 像素疊影對照 @ 內圈 a 較窄且外框底部收平 採用 at = "fourfold" 與 dollar = "through",內圈字懷更寬敞易讀
括號 () 與標點
平弧括號・方形句點
括號與標點對照 括號中段近似垂直,在巢狀呼叫時宛如垂直分隔線 透過 paren = "flat-arc"、tittle = "square" 與 punctuation-dot = "square" 忠實還原相同視覺節奏

為什麼點狀零(Dotted Zero)與平弧括號在程式碼中特別耐看?

許多程式字體為了區分 0 與 O,習慣使用斜線零(Slashed Zero)。然而當程式碼中出現大量十六進位常數(如 0x00FF_0000)或浮點數陣列時,密集的對角斜線容易與大寫 Ø、8 或斜線運算子 / 產生視覺干擾。

Berkeley Mono 與 Ioskeley Mono 預設採用的 點狀零 (Dotted Zero),將識別錨點收斂在字腔正中央的一顆方點上,既能瞬間與內部全空的字母 O 區隔,又不會破壞一整排零的水平安定感。而如果你在特定環境下還是偏愛斜線零,Ioskeley Mono 也貼心地把斜線零封裝在 OpenType 的 zero 特徵槽位中——不需要換字體檔,只要在編輯器或 terminal 設定裡加上一行 zero feature 就能隨時切換。

另一個令人一用就回不去的細節是 平弧括號 (flat-arc parentheses)。當我們在寫 Lisp、Rust 巨集或深層函式呼叫 foo(bar(baz(x))) 時,傳統圓弧括號 ((( 會形成一團向外膨脹的視覺噪音;而平弧括號把中段拉直、僅在上下兩端保留俐落折角,讓連續括號排在一起時就像一排整齊的柵欄,大幅降低了配對括號時的眼球掃描負擔。

如何挑選適合你的 Ioskeley Mono 變體?

第一次點開 Ioskeley Mono 的 GitHub Releases 頁面時,看到多達二十幾個壓縮檔可能會稍微猶豫一下。其實只要順著「使用場景(IDE vs. Terminal)」、「字元寬度(100% vs. 90%)」與「螢幕解析度(Hinted vs. Unhinted)」三個維度,就能在十秒內鎖定最適合的安裝包:

變體家族:Ioskeley Mono vs. NL vs. Term

  1. 標準版 Ioskeley Mono (推薦用於 IDE): 內建完整的 Iosevka 程式連字規則(contextual ligatures),包含常見的 !=、===、<=、>=、=>、->、:: 與 <=>。適合在 VS Code、Zed 或 JetBrains IDE 中做為主力字體。
  2. 無連字版 Ioskeley Mono NL (No-Ligature): 如果你是「所見即所得」的純粹主義者,不希望兩個獨立的 ASCII 字元被合併成單一圖形,選 NL 版本即可在字型層級徹底關閉連字。
  3. 終端機專屬版 Ioskeley Mono Term (推薦搭配 Nerd Font): 這是專為 terminal emulator 打造的變體。在終端機環境中,部分連字或特殊符號如果跨出單一字元格(character cell),可能會導致游標定位偏移或 TUI 表格錯位。Term 版本嚴格鎖定所有符號的格線邊界;而在實務上,我最推薦直接安裝社群打包好的 IoskeleyMonoTerm Nerd Font Mono,一次搞定 terminal 格線對齊與 eza、lazygit、starship 所需的開發者圖示。

字寬選擇:Normal (100%) vs. SemiCondensed (90%)

這是 Ioskeley Mono 相比原版商業字體的一大彈性優勢:

  • Normal (100%):字元寬高比與 Berkeley Mono (TX-02) 標準版一致,適合外接 27 吋以上 4K 螢幕、或是習慣將視窗維持在兩欄以內的開發環境。
  • SemiCondensed (90%):將水平推進寬度微縮 10%。正如你在上方互動實驗室看到的即時統計,在相同的視窗寬度下,SemiCondensed 每一列可以多塞進約 11% 的字元數,例如原本只能塞 80 列的分割視窗可以直接擴充到 89 列,而且因為字懷原本就方正飽滿,縮減 10% 後完全不會有壓扁變形的窒息感,非常適合筆電螢幕或重度 tmux / TUI 多視窗使用者。

渲染微調:Hinted vs. Unhinted

  • macOS 與 Linux HiDPI 外接螢幕:在 2x 縮放以上的高解析度螢幕上,建議選擇 Unhinted,讓 FreeType 或 CoreText 直接根據原始向量輪廓進行平滑抗鋸齒渲染,最能保留字體原本的粗細比例。
  • Windows ClearType 或標準 1080p 螢幕:在較低像素密度的環境下,建議選擇 Hinted 版本,透過字型內嵌的網格對齊指令(grid-fitting hints)強制將水平與垂直筆畫貼齊實體像素邊界,避免小字級下出現邊緣發虛。

在 IDE 與 Linux 終端機的實戰配置

以下整理我在日常使用的編輯器與 Linux 終端機環境中,讓 Berkeley Mono 與 Ioskeley Mono 發揮最佳視覺效果的設定範例。

VS Code 與 Zed 編輯器設定

在 VS Code 的 settings.json(支援 JSONC 註解語法)中,我們可以將 Ioskeley Mono 與 Berkeley Mono 排進 fallback 鏈,並透過 editor.fontLigatures 同時控制連字與點狀/斜線零:

{
  "editor.fontFamily": "'Ioskeley Mono', 'Berkeley Mono', 'Monaspace Neon', 'Cascadia Code', Consolas, monospace",
  "editor.fontSize": 14.5,
  "editor.lineHeight": 1.55,
  "editor.fontWeight": "400",
  // 若想維持預設點狀 0 並開啟連字:
  "editor.fontLigatures": "'calt' 1, 'liga' 1, 'zero' 0"
  // 若想將 0 切換為斜線 0,改設為: "'calt' 1, 'liga' 1, 'zero' 1"
}

而在以 Rust 與 GPUI 打造的 Zed 編輯器(~/.config/zed/settings.json)中,則可以透過 buffer_font_features 精準指定:

{
  "buffer_font_family": "Ioskeley Mono",
  "buffer_font_size": 15,
  "buffer_line_height": { "custom": 1.55 },
  "buffer_font_features": {
    "calt": true,
    "zero": false
  }
}

主流終端機設定(Ghostty、Alacritty、Foot)

在 terminal 環境中,建議統一使用 IoskeleyMonoTerm Nerd Font Mono 以確保 TUI 框線(┌─┬─┐)與圖示完美對齊。

Ghostty (~/.config/ghostty/config)

font-family = "IoskeleyMonoTerm Nerd Font Mono"
font-size = 12
adjust-cell-height = 10%
# 若偏好斜線 0,可取消下一行註解:
# font-feature = +zero

Alacritty (~/.config/alacritty/alacritty.toml) 與 Foot (~/.config/foot/foot.ini)

在 Alacritty 中:

[font]
size = 12.0
normal = { family = "IoskeleyMonoTerm Nerd Font Mono", style = "Regular" }
bold = { family = "IoskeleyMonoTerm Nerd Font Mono", style = "Bold" }
italic = { family = "IoskeleyMonoTerm Nerd Font Mono", style = "Italic" }

在 Wayland 原生輕量終端機 Foot 中:

[main]
font=IoskeleyMonoTerm Nerd Font Mono:size=12
line-height=18px

在 Omarchy Linux 中一鍵完成全系統切換

如果你跟我一樣在桌機或筆電上使用 DHH 主導的 Omarchy 開發者環境(基於 Arch Linux + Hyprland),設定起來又更省事了。將字型檔解壓縮放入 ~/.local/share/fonts/IoskeleyMono/ 並更新字型快取後,只需要執行內建的 omarchy-font-set 指令:

# 重新掃描字型快取並確認系統已識別
fc-cache -fv ~/.local/share/fonts/IoskeleyMono
fc-list | grep -i "IoskeleyMonoTerm"

# 一鍵同步更新系統所有終端機與預設等寬字體
omarchy-font-set "IoskeleyMonoTerm Nerd Font Mono"
# 或在較新版 Omarchy 使用: omarchy font set "IoskeleyMonoTerm Nerd Font Mono"

這行指令會自動幫你把 Ghostty、Kitty、Alacritty、Foot、Waybar 狀態列,以及 ~/.config/fontconfig/fonts.conf 中的全域 <family>monospace</family> 對應一次更新到位。當所有視窗、狀態列與 TUI 面板都換上統一的 1970 年代工程手冊字形時,整個桌面環境的視覺一致性真的非常賞心悅目。

結語:商業原創與開源參數化造字的良性共鳴

回頭看 Berkeley Mono (TX-02) 與 Ioskeley Mono 的關係,我認為它們並不是零和替代,而是字體生態系裡一段很有意思的對話:

  • 如果你深深著迷於 Neil Panchal 對每一筆曲線的手工打磨,且日常主力環境是固定的個人工作站,直接購買一套商業版 Berkeley Mono 支持獨立字體設計師,絕對是一筆值得的投資。
  • 如果你需要跨多台 Linux 機器透過 dotfiles 自動部署、偏愛 SemiCondensed (90%) 的高密度分割視窗排版、需要原生的 Nerd Font terminal 支援,或者想先在自己的日常專案裡零成本體驗這種冷冽俐落的工程手冊美學,那麼開源的 Ioskeley Mono 無疑是目前完成度極高的選擇。

不妨現在就捲回上方的互動實驗室,把你最常寫的程式碼貼進去,親眼試試看哪一種字寬與字形組合最對你的胃口吧!


Python 3.15 的 Tail-Call Interpreter 架構與效能實證

釐清 TCO 迷思 · CPython 指令調度架構演進 · 組合語言剖析與 Rust 2,000 萬次運算基準測試
featured.svg

前陣子翻閱 Python 3.15 的 What’s New 文件時,官方安裝檔的一項底層變更引起了我的注意:在官方 64-bit Windows 安裝檔中,預設正式啟用了 Tail-Call Interpreter(尾呼叫直譯器)。

社群裡不少人的第一反應是:「哇!Python 終於支援尾遞迴最佳化(Tail Call Optimization, TCO)了嗎?以後寫深度遞迴不用再怕 RecursionError 了?」

很可惜,答案是完全沒有。

如果你在 Python 3.15 裡寫了一個沒有終止條件的遞迴函式,它依然會在跑完第一千層時準時噴出熟悉的 RecursionError: maximum recursion depth exceeded。Python 之父 Guido van Rossum 與核心開發團隊過去多次明確表示,為了保留完整的呼叫堆疊資訊(traceback)與除錯能力,Python 不會採用語言層級的尾遞迴消除。

那麼,官方所說的 Tail-Call Interpreter 究竟是什麼?

它不是提供給一般開發者的語言語法特性,而是 CPython 虛擬機核心在執行位元組碼(bytecode)時,在指令調度(opcode dispatch)架構上的一場精妙重構。這項機制最初在 Python 3.14 以選用編譯參數(--with-tail-call-interp)亮相,到了 Python 3.15 則正式成為官方 Windows 64-bit 二進位發行版的預設架構。

這篇文章我們就來拆解:直譯器核心是如何執行位元組碼的?從經典的 switch-case 迴圈到 computed goto,再到現在的 tail-call interpreter,底層發生了什麼改變?接著,我們會用 Rust 親手刻出兩套直譯器,透過真實的 A/B 效能評測與組合語言反組譯,看看尾呼叫調度究竟是憑藉哪些硬體機制跑出 15% 到 20% 的效能提升。

什麼是直譯器的指令調度(Opcode Dispatch)?

要理解尾呼叫直譯器,首先得先看看所有虛擬機的心臟地帶。

當我們把 Python 原始碼編譯成位元組碼後,程式會變成一連串緊湊的指令流。虛擬機的主要工作,就是一個無止境的調度迴圈:讀取下一個 opcode、執行它對應的邏輯、再讀取下一個。

在 CPython 歷史與大多數入門直譯器中,最直觀的寫法是 Switch Dispatch:

// 傳統的 switch 調度迴圈
while (true) {
    switch (*pc++) {
        case OP_ADD:
            acc += *pc++;
            break;
        case OP_SUB:
            acc -= *pc++;
            break;
        case OP_HALT:
            return acc;
    }
}

這段程式碼看起來非常單純,但在現代超純量處理器(superscalar CPU)的硬體管線上,這樣的設計存在兩個嚴重的效能瓶頸:

switch-dispatch.svg

每執行一條 bytecode,CPU 至少得承擔兩次跳躍:

  1. 跳到目標 opcode:從中央 switch 跳躍表發起的間接跳躍(indirect branch)。
  2. 跳回迴圈頂端:opcode 處理完後踩到 break,無條件跳回 while 迴圈開頭。

更致命的是硬體分支預測器(branch predictor)的負擔。在中央 switch 處,只有唯一一個間接跳躍指令位址。不管當前執行的位元組碼是加法、乘法還是屬性存取,全都必須通過這同一個跳躍點。現代處理器的分支目標緩衝區(Branch Target Buffer, BTB)很難在單一站點記錄高動態的跳躍規律,導致間接分支預測失誤(branch misprediction)頻繁發生。每次預測失誤,CPU 都得清空管線(pipeline flush),白白浪費 15 到 20 個時脈週期。

從 Direct-Threaded Code 到編譯器的極限

為了解決中央跳躍點的擁擠問題,直譯器領域多年前就發展出了 Direct-Threaded Code(直通線程代碼,常利用 C 語言的 computed goto 實現):

// GCC / Clang 的 Labels as Values 擴充
static void* dispatch_table[] = { &&do_add, &&do_sub, &&do_halt };

#define DISPATCH() goto *dispatch_table[*pc++]

do_add:
    acc += *pc++;
    DISPATCH();

do_sub:
    acc -= *pc++;
    DISPATCH();

do_halt:
    return acc;

在 direct-threaded code 裡,不再有中央的 while 迴圈與 switch。每個 opcode 執行完後,直接抓取下一個 opcode 的位址並就地跳躍。

這個做法把間接跳躍點分散到各個 opcode 的末端。例如,do_add 末尾有一個專屬的 goto *,do_sub 末尾也有自己的 goto *。硬體分支預測器可以分別記錄不同 opcode 之後通常會接續哪些指令,分支預測命中率因此顯著上升。

自 2010 年前後起,CPython 在 Unix 平台(使用 GCC/Clang 編譯)就一直預設開啟 computed goto。但這個方案有兩個難以克服的痛點:

  • 非標準語法與平台碎片化:Labels as values 是 GCC/Clang 的擴充功能,微軟的 MSVC 編譯器長期以來完全不支援。這意味著近二十年間,Windows 平台上的 Python 官方安裝檔只能退回最慢的 switch 迴圈!
  • 編譯器最佳化的惡夢:CPython 的執行核心 ceval.c(連同內嵌由 Python/bytecodes.c DSL 產生的 generated_cases.c.h)超過五千行,核心評估函式 _PyEval_EvalFrameDefault 本身也有數千行。當一個函式裡塞入幾百個互相 goto 的標籤時,編譯器的暫存器分配器(register allocator)會面臨嚴重的溢出(register spilling)。現代 LLVM/GCC 很難對這種錯綜複雜的控制流程圖(Control Flow Graph, CFG)進行激進的就地最佳化。

這就是為什麼 Faster CPython 團隊(包括 Mark Shannon、Brandt Bucher、Ken Jin 等人)開始探索新的調度架構:Tail-Call Interpreter。

Tail-Call Interpreter 的運作機制

尾呼叫直譯器的核心概念非常純粹:將每一個 opcode 拆成一個獨立、專門的小函式,函式的最後一行直接尾呼叫(tail call)下一個 opcode 函式。

在現代 C 語言中(仰賴 Clang 與 MSVC 提供的屬性):

typedef void (*OpHandler)(State* state, const uint8_t* pc, int64_t acc);

void op_add(State* state, const uint8_t* pc, int64_t acc) {
    acc += *pc++;
    uint8_t next_op = *pc;
    [[clang::musttail]] return state->table[next_op](state, pc, acc);
}

void op_sub(State* state, const uint8_t* pc, int64_t acc) {
    acc -= *pc++;
    uint8_t next_op = *pc;
    [[clang::musttail]] return state->table[next_op](state, pc, acc);
}

這個結構看起來像是相互遞迴,直覺上會讓人擔心:「每執行一個指令就呼叫一個函式,呼叫堆疊(stack frame)不是幾千層就爆掉了嗎?」

這正是關鍵所在:musttail 屬性會強制編譯器保證進行尾呼叫消除 (tail call elimination, TCE)。

當函式在 return 時直接呼叫另一個函式,且本身不再需要保留任何本地變數時,編譯器不需要分配新的堆疊框架,也不需要發出 call 與 ret 指令。它會直接重複使用當前的堆疊框架,並將呼叫改寫成單純的跳躍指令:

jmp    rax                            ; 一條直接跳躍,堆疊深度永遠維持在 1!
tail-call-dispatch.svg

關鍵利器:preserve_none 呼叫慣例

許多人不知道的是,單純依靠 musttail 其實並不足以榨出直譯器的極致效能。在傳統 ABI 呼叫慣例(calling convention)下,跨函式呼叫依然必須維護被呼叫端保存暫存器(callee-saved registers)的序幕(prologue)與尾聲(epilogue)。

CPython 真正的關鍵突破,是結合了源自 GHC 風格的專屬呼叫慣例:__attribute__((preserve_none))。

何謂 GHC 風格?

這裡提到的 GHC,指的是純函數式程式語言 Haskell 最知名的旗艦編譯器——Glasgow Haskell Compiler。

在純函數式語言的世界裡,語法層面完全沒有傳統的 while 或 for 迴圈。所有的重複運算、狀態移轉與控制流程,本質上都是透過高階函式與廣泛的尾遞迴呼叫來實現。這意味著 GHC 編譯出的二進位程式,每一秒鐘都在底層進行數以百萬計的尾呼叫與跳躍。

如果 GHC 遵循標準 C 語言的呼叫慣例(例如 x86-64 上的 System V AMD64 ABI 或 Windows x64 ABI),編譯器就必須在每次呼叫時為呼叫端保留一整批 callee-saved 暫存器(如 rbx、r12–r15)。在無窮無盡的連續尾呼叫場景中,反覆把暫存器 push 到記憶體堆疊、跳躍後再 pop 還原,完全是在虛耗寶貴的 CPU 週期。

為了解決這個瓶頸,GHC 很久以前就為其內部虛擬機設計了專屬的呼叫慣例:

  • 零 callee-saved 暫存器:沒有任何暫存器需要為呼叫端保留,所有一般暫存器全數視為拋棄式的暫時暫存器(scratch registers)。
  • 狀態長駐暫存器:核心狀態(如堆疊指標、當前指令指標、累加值)可以在暫存器之間自由直通傳遞,跳躍時不需要進出記憶體堆疊。

這項設計在編譯器領域極為著名,甚至十多年前 LLVM 就特別為 GHC 內建了專屬呼叫慣例代碼 ghccc(GHC Calling Convention)。

將 GHC 哲學帶入 CPython

傳統上,這種高度客製化的呼叫慣例只存在於 GHC 等少數編譯器內部。但在位元組碼直譯器的場景中,問題本質其實與 GHC 如出一轍:每一個 opcode handler 執行完畢後,只會直接尾呼叫下一個 opcode handler,根本沒有返回原本呼叫端的需求。

現代 Clang 所引入的 __attribute__((preserve_none)),本質上就是將 GHC 這種「零保留負擔」的設計哲學通用化引進 C 語言體系。

當我們為 opcode handler 加上這個屬性時,等於明確告訴編譯器「被呼叫的函式不需要為呼叫端保留任何一般暫存器」。這讓虛擬機能將當前評估堆疊指標(stack pointer)、位元組碼指標(program counter)與累加狀態長駐在 CPU 暫存器中,跳躍時不需要反覆在記憶體堆疊上存取與還原,大幅釋放了暫存器分配器(register allocator)的自由度,才真正實現了與 musttail 完美搭配的極致調度效能。

跨平台編譯器支援:Clang 與 MSVC 18

在編譯器支援層面上:

  • [[clang::musttail]] 屬性早在 Clang 13(2021 年)就已支援;而 CPython 的尾呼叫直譯器之所以在規格上指定 Clang 19+,是因為結合了 preserve_none 慣例與針對 tail call 的深度優化。
  • 在 Windows 平台,微軟在 MSVC 18(Visual Studio 2026)中正式引入了 [[msvc::musttail]](見 CPython 相關 issue gh-143068 與 gh-139922)。這讓 Python 官方 Windows 64-bit 發行版在 Python 3.15 終於能原生啟用尾呼叫直譯器,徹底告別長年落後的 switch 迴圈!

Nelson Elhage 的效能排錯與真實數據

在 Python 3.14 開發初期,社群首次測試 --with-tail-call-interp 時,曾傳出「效能暴增 10% 到 15%」的驚人數據。

知名系統工程師 Nelson Elhage 在其部落格文章中深入分析反組譯與編譯器行為後,揭開了有趣的真相:這項驚人的數據,很大一部分是因為當時主流的 LLVM 19 出現了針對 computed goto 的效能衰退(regression),導致作為對照組的基準直譯器異常變慢,才反襯出尾呼叫直譯器的巨大優勢。

當基準線修正回正常的編譯器設定後:

  • 在原本就有 computed goto 的 Linux/macOS 環境中,尾呼叫帶來的純效能增益大約是穩健的 1% 到 5%。
  • 但在原本只能跑 switch 迴圈的 Windows x86-64 平台,官方 What’s New 數據顯示,在 Visual Studio 18.1.1 編譯下獲得了高達 15% 到 20% 的幾何平均吞吐量躍升(pyperformance geomean)!

用 Rust 親手實作兩套直譯器

理論說得再漂亮,不如親手實作並觀察底層指令。我們用現代系統語言 Rust 來實作一個精簡的虛擬機,並同時打造 Switch Dispatch 與 Tail-Call Dispatch 兩個版本進行 A/B 評測。

指令集定義

我們定義一組包含算術、位元運算與控制流的典型指令:

#[repr(C)]
#[derive(Copy, Clone, Debug)]
pub struct Instruction {
    pub op: u32,
    pub arg: i32,
}

pub const OP_ADD: u32 = 0;
pub const OP_SUB: u32 = 1;
pub const OP_MUL: u32 = 2;
pub const OP_XOR: u32 = 3;
pub const OP_INC: u32 = 4;
pub const OP_DEC: u32 = 5;
pub const OP_HALT: u32 = 6;

對照組:經典 Switch 直譯器

首先是大家最熟悉的中央迴圈版本:

#[inline(never)]
pub fn run_switch(code: &[Instruction], mut acc: i64) -> i64 {
    let mut pc = 0;
    loop {
        let instr = unsafe { *code.get_unchecked(pc) };
        match instr.op {
            OP_ADD => {
                acc = acc.wrapping_add(instr.arg as i64);
            }
            OP_SUB => {
                acc = acc.wrapping_sub(instr.arg as i64);
            }
            OP_MUL => {
                acc = acc.wrapping_mul(instr.arg as i64);
            }
            OP_XOR => {
                acc ^= instr.arg as i64;
            }
            OP_INC => {
                acc = acc.wrapping_add(1);
            }
            OP_DEC => {
                acc = acc.wrapping_sub(1);
            }
            OP_HALT => {
                break;
            }
            _ => unreachable!(),
        }
        pc += 1;
    }
    acc
}

實驗組:Tail-Call 直譯器

在 Tail-Call 版本中,我們定義一個函式指標簽名 OpHandler。每個指令都是一個獨立函式,參數透過暫存器傳遞,最後一行抓出下一個指令並直接尾呼叫:

pub struct VmContext {
    pub table: [OpHandler; 16],
}

pub type OpHandler = unsafe fn(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64;

#[inline(never)]
pub unsafe fn op_add(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_add((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_sub(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_sub((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_mul(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_mul((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_xor(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc ^ ((*pc).arg as i64);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_inc(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_add(1);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_dec(ctx: &VmContext, pc: *const Instruction, acc: i64) -> i64 {
    let next_acc = acc.wrapping_sub(1);
    let next_pc = pc.add(1);
    let next_op = (*next_pc).op as usize;
    let next_fn = *ctx.table.get_unchecked(next_op);
    next_fn(ctx, next_pc, next_acc)
}

#[inline(never)]
pub unsafe fn op_halt(_ctx: &VmContext, _pc: *const Instruction, acc: i64) -> i64 {
    acc
}

static TABLE: [OpHandler; 16] = {
    let mut t: [OpHandler; 16] = [op_unreachable; 16];
    t[OP_ADD as usize] = op_add;
    t[OP_SUB as usize] = op_sub;
    t[OP_MUL as usize] = op_mul;
    t[OP_XOR as usize] = op_xor;
    t[OP_INC as usize] = op_inc;
    t[OP_DEC as usize] = op_dec;
    t[OP_HALT as usize] = op_halt;
    t
};

pub unsafe fn op_unreachable(_ctx: &VmContext, _pc: *const Instruction, _acc: i64) -> i64 {
    unreachable!()
}

#[inline(never)]
pub fn run_tail_call(code: &[Instruction], acc: i64) -> i64 {
    let ctx = VmContext { table: TABLE };
    let pc = code.as_ptr();
    unsafe {
        let first_op = (*pc).op as usize;
        let first_fn = *ctx.table.get_unchecked(first_op);
        first_fn(&ctx, pc, acc)
    }
}
 Note

在 Rust 穩定的編譯器版本中,當函式末端的型別簽名與呼叫慣例完全契合時,LLVM 後端會自動將其最佳化為單一跳躍指令。在 Rust 的語言演進中,亦有專屬的 become 關鍵字提案(RFC 3407 / #![feature(explicit_tail_calls)],追蹤於 issue #112788)正在推進,旨在讓開發者能在語言層級強制要求編譯器進行保證消除。

組合語言對決:為什麼尾呼叫能勝出?

我們使用 objdump -d -M intel 將編譯出來的機器碼反組譯,答案立刻浮現眼前。

對照組:run_switch 的反組譯機器碼

; 中央 switch 迴圈的頭部 (位址 16fd0)
.loop_head:
    mov    r8d, DWORD PTR [rdi+rax*8]     ; [16fd0] 讀取 code[pc].op
    cmp    r8, 0x6                        ; [16fd4] 邊界檢查
    ja     .out_of_bounds                 ; [16fd8] 超出範圍跳出 (1701e)
    movsxd rsi, DWORD PTR [rdi+rax*8+0x4] ; [16fda] 讀取 code[pc].arg
    movsxd r8, DWORD PTR [rcx+r8*4]       ; [16fdf] 查詢跳躍表
    add    r8, rcx                        ; [16fe3]
    jmp    r8                             ; [16fe6] 【跳躍 1】間接跳躍到 handler

; OP_ADD 處理區塊 (位址 16fe9)
.op_add:
    add    rdx, rsi                       ; [16fe9] acc += arg
    inc    rax                            ; [16fec] pc++
    jmp    .loop_head                     ; [16fef] 【跳躍 2】無條件跳回迴圈頭部 (16fd0)!

看見問題了嗎? 每執行一個指令,CPU 必須:

  1. 執行運算並增加 pc。
  2. 執行一次無條件跳躍 jmp .loop_head(位址 16fd0)回到迴圈頭部。
  3. 執行 cmp r8, 0x6 與條件分支 ja 進行保護性檢查。
  4. 查表後執行間接跳躍 jmp r8 進入下一個 opcode。

實驗組:op_add(Tail Call)的反組譯機器碼

再來看看 Tail Call 版本的 op_add:

op_add:
    movsxd rax, DWORD PTR [rsi+0x4]       ; [18320] 讀取當前 arg
    add    rdx, rax                       ; [18324] acc += arg
    mov    eax, DWORD PTR [rsi+0x8]       ; [18327] 預先讀取下一個指令的 op
    add    rsi, 0x8                       ; [1832a] pc++
    jmp    QWORD PTR [rdi+rax*8]          ; [1832e] 【唯一的跳躍】直接跳進下一個 handler!

整整只有 5 條組合語言指令!

  • 沒有第二個跳躍指令:執行完運算後,直接一條 jmp 跳到下一個 handler 的位址。
  • 免除熱路徑檢查:不需要跳回迴圈頂端做 cmp/ja。
  • 零堆疊框架:沒有 call、沒有 ret、沒有 push rbp。
  • 分散的分支預測目標:op_add 的間接跳躍位址固定在 1832e,op_sub 則在 1834e,兩者各自建立獨立的分支歷史快取。

A/B 實測 Benchmark 數據

我們寫了一個基準測試程式,隨機生成包含加法、減法、乘法、XOR、遞增、遞減等混合分佈的 2,000 萬條(20,000,000)bytecode 指令序列。

為了避免編譯器 dead code elimination,我們使用 std::hint::black_box 封裝輸入,並嚴格斷言兩種直譯器跑完 2,000 萬條指令後產生的數值完全一致:

// 驗證運算結果正確性
assert_eq!(res_switch, res_tail, "Results must match!");

實測數據對比

在 x86-64 機器(Linux, Release Mode, opt-level = 3)上執行 5 次運算取平均值:

指令調度機制 2,000 萬條指令平均執行時間 指令吞吐量(Ops / sec) 效能表現
Switch Dispatch 91.09 ms 219.6 M ops/sec 基準對照組
Tail-Call Dispatch 74.86 ms 267.1 M ops/sec 快 17.81%(1.22x 吞吐量)

各輪次運算時間十分穩定:

[Switch]   Run 1: 91.25 ms | Run 2: 91.03 ms | Run 3: 90.93 ms | Run 4: 90.87 ms | Run 5: 91.38 ms
[TailCall] Run 1: 74.85 ms | Run 2: 74.82 ms | Run 3: 74.78 ms | Run 4: 74.89 ms | Run 5: 74.98 ms

在完全相同的指令流與相同的硬體環境下,Tail-Call Dispatch 跑出了整整 17.81% 的效能提升!

這 17.8% 的差距正來自我們稍早在反組譯分析中所看見的三大優勢:

  1. 指令跳躍次數減半:每條 opcode 省下了跳回迴圈頂端的一道跳躍指令。
  2. 省除熱路徑檢查:省下了每輪迴圈的邊界比對。
  3. 更友善的分支預測:處理器 BTB 分散記錄跳躍歷史,管線空泡大幅減少。

這個實測幅度,也正好落在 CPython 官方於 What’s New 中公布的 Windows(Visual Studio 18.1.1)15% 到 20% 增益區間。

 Tip

線上即時體驗與組合語言對照:本實驗的完整可重現程式碼已發布至 Compiler Explorer (Godbolt),包含兩種直譯器的完整實作與 2,000 萬次指令隨機測試。讀者可直接在瀏覽器中對照編譯後的 x86-64 組合語言,並點擊 Run 即時執行驗證。

結語與技術省思

回過頭來看 Python 3.15 的變更:

  1. Python 依然沒有尾遞迴最佳化:使用者寫的遞迴函式行為完全沒變,依舊保留最完整的 traceback 偵錯堆疊(沒有語言層級的 TCO)。
  2. Tail-Call 是直譯器核心的工程升級:它藉由把 opcode 拆分為獨立函式、依賴編譯器的尾呼叫消除與 preserve_none 慣例,在兼顧程式碼模組化與編譯器暫存器優化的同時,在硬體層級模擬出高效的分散式間接跳躍。
  3. Windows 使用者是最大受益者:長期受限於 MSVC 無法支援 computed goto 的 Windows 版 Python,透過 MSVC 18 的 [[msvc::musttail]] 終於補齊了近二十年的跳躍效能缺口。

在軟體工程的世界裡,許多看似屬於高階語言語義的名詞,換到了編譯器與硬體管線的視角,往往展現出截然不同、卻又無比精妙的底層面貌。下次看見直譯器更新日誌中的「Tail-Call」,我們就能會心一笑:它不是讓你在程式碼裡寫無窮遞迴,而是悄悄替 CPU 的分支預測器卸下了一副沉重的枷鎖。


將閒置 Pixel 3 改造為 Omarchy 桌面麥克風插件 Pocket Mic

從 Shell/Python 到 Rust 重構 · ADB 音訊串流 · PipeWire 虛擬音訊源 · Quickshell 狀態列整合
featured.svg

前陣子在整理抽屜時,翻出了退役已久的 Google Pixel 3。這款 2018 年推出的手機雖然早已停止系統版本更新,但硬體功能依然正常。閒置在抽屜裡有些可惜。

恰巧最近在我的 Omarchy Linux 桌面環境 上,日常遠端會議(Google Meet、Discord)與語音輸入的需求增加——特別是 Omarchy 整合的 voxtype(預設長按 F9 進行 push-to-talk,亦可透過 Super + Ctrl + X 或在 ~/.config/hypr/bindings.lua 自訂綁定至 Meh 鍵組合的 Ctrl + Alt + Shift + V 切換,呼叫本地 Whisper 模型進行語音辨識)。桌上型電腦通常沒有內建麥克風,若不想常態配戴耳機,就得外接一支獨立麥克風佔用桌面空間。

Pixel 系列內建多麥克風硬體矩陣與聲學降噪調校,收音品質比許多常見的視訊鏡頭或平價外接麥克風更好。如果能把這台 Pixel 3 作為電腦的專屬桌上型麥克風使用,會是很實用的硬體重用方式。

市面上現成的 Android 麥克風方案(例如透過區域網路 WebRTC 或特定第三方應用程式)大多需要額外開啟瀏覽器分頁,且延遲相對不穩定。我希望的運作方式是:

  • 隨插即用,支援 USB 與無線 ADB。
  • 直接註冊為 Linux 系統層級的 PipeWire / PulseAudio 音訊輸入裝置,名稱清楚顯示為 Pixel 3 Microphone。
  • 整合進 Omarchy 桌面——在 Quickshell 狀態列提供常駐圖示,點擊展開即時選單,支援熱插拔與斷線自動重連,且具備全鍵盤操控能力。

最初我使用了一包 Bash 腳本搭配 Python(pysocat + connect.py)進行概念驗證。雖然功能可行,但在設備探測與狀態監聽時反覆啟動 Python 直譯器會帶來不必要的冷啟動開銷,且在行程異常中斷時的資源清理也較難保證。

因此,我將桌面端串流核心改寫為 Rust,整合成單一二進位程式的 Pocket Mic(patrick.pocketmic)插件。本文記錄這套音訊串流的架構設計、從腳本走向 Rust 的重構考量、Quickshell 雙重插件模型,以及在 Linux 音訊子系統中的實作細節。

端到端音訊傳輸架構

要將 Android 手機上的麥克風訊號低延遲轉送至 Linux 桌面,並被系統各個應用程式識別為標準錄音設備,資料管線需要跨越 Android 系統、ADB 橋接層、Linux IPC 與 PipeWire 音訊核心:

audiosource-pipeline.svg

整個流程在底層依序由四個核心階段組成:

Android 端:原始 PCM 音訊擷取

手機端執行開源輕量級 App fr.dzx.audiosource。App 在前景啟動錄音服務,透過 Android 原生 AudioRecord API 擷取 44.1 kHz、16-bit PCM 單聲道(mono)音訊。擷取後的未壓縮 PCM 資料流不經過額外編碼(減少運算負擔與編解碼延遲),直接寫入手機本地的抽象 Unix domain socket(localabstract:audiosource)。

Linux 桌面音訊核心:PipeWire / PulseAudio 虛擬音訊源建立

現代 Linux 發行版大多採用 PipeWire 作為音訊伺服器,並提供 PulseAudio 相容介面。在開始串流前,pocket-mic 會先呼叫 pactl 動態載入 module-pipe-source:

pactl load-module module-pipe-source \
    source_name="android-89ay04l" \
    channels=1 \
    format=s16 \
    rate=44100 \
    file="/tmp/pocket-mic-1234-5678/audio" \
    source_properties="device.description=\"Pixel 3 Microphone\""

值得注意的是:正是 module-pipe-source 負責在指定路徑建立具名管線 (FIFO),並同時在系統中註冊虛擬錄音裝置 Pixel 3 Microphone。

串流轉送與低延遲橋接:Rust pocket-mic 核心

接著,pocket-mic 建立並接管音訊串流:

  1. 通訊埠轉送:透過 adb forward localabstract:android-xxxx localabstract:audiosource 將手機端的抽象 socket 轉送至本機。
  2. 原生抽象 Socket 連線:直接使用 Rust 提供的 std::os::linux::net::SocketAddrExt,連線至本機的抽象 Unix domain socket,無需依賴外部工具或腳本封裝。
  3. 開啟 FIFO 管線:連線成功後,pocket-mic 以 O_NONBLOCK | O_NOFOLLOW 開啟剛才由 PulseAudio 建立的 FIFO(若 PipeWire 尚未完成建立,會在 ENXIO 錯誤上持續等待並重試最多 10 秒)。
  4. 延遲與原子寫入機制:
    • 在串流迴圈 forward_pcm 中,每次讀取 PCM 後以 1024 位元組為單位寫入 FIFO。在 Linux 上 PIPE_BUF 為 4096 位元組,小於等於 PIPE_BUF 的寫入符合 POSIX 原子寫入保證。
    • 透過 libc::fcntl 將管線容量鎖定在 4096 位元組(一個記憶體分頁大小):
    // 限制管線緩衝區大小為 4096 位元組,確保原子寫入且無音訊積壓
    unsafe {
        libc::fcntl(pipe.as_raw_fd(), libc::F_SETPIPE_SZ, 4096);
    }
    若應用程式端讀取暫時落後,非阻塞寫入會主動略過區塊,徹底避免管線積累過期音訊。

Omarchy 桌面整合

最上層則是專為 Omarchy 打造的 Quickshell 插件,負責監聽設備插拔、管理後台 pocket-mic 守護行程,並在狀態列提供連線與控制介面。

為什麼從 Shell / Python 重構為 Rust?

在第一版原型中,系統由 Bash 腳本(audiosource)與 Python 腳本(connect.py)組成。功能雖然能運作,但在長期日常使用中,浮現了幾項架構問題:

  1. 設備探測與輪詢開銷 (Discovery Overhead): 早期原型使用 Python 執行 --list 設備探測,每次執行都得重新初始化 Python 直譯器、載入標準庫模組與解析 JSON,帶來明顯的冷啟動延遲。改用 Rust 原生二進位檔後,單次探測僅需約 20 毫秒(主要開銷為 adb devices 子行程,相較於先前啟動 Python 直譯器大幅精簡)。更進一步地,在後續架構優化中,我們將設備探測從背景常駐計時器改為「展開選單時按需探測」,讓日常桌面閒置時的探測開銷徹底歸零。
  2. 資源釋放的脆弱性 (Brittle Cleanup): 在 Bash 中使用 trap,或在 Python 中使用訊號處理程序來卸載 PulseAudio 模組與清理 FIFO,一旦遭遇父行程被非正常終止、未捕獲的例外或孤兒行程問題,系統中容易殘留虛擬音訊設備與暫存檔案。
  3. 宣告式 CLI 與型別安全 (Type-Safe CLI & Process Management): 改用 Rust 後,透過 clap 定義宣告式 CLI 結構體與子命令,嚴謹驗證參數衝突與全域選項;並透過 thiserror 與 anyhow 建立結構化錯誤模型,搭配 prctl(PR_SET_PDEATHSIG, SIGTERM) 與獨立行程群組隔離,確保子行程生命週期嚴密綁定,不再依賴未結構化的字串錯誤與外部 Python 環境。

實作細節:Linux 音訊處理與 Rust RAII 機制

改寫為 Rust 後,程式架構與資源管理更加清晰。以下是實作過程中的核心設計:

RAII Drop 模式:自動資源釋放

在 Rust 中,我們為管理串流生命週期的 Session 實作了 Drop trait:

struct Session<'a> {
    runner: &'a Runner,
    serial: &'a str,
    name: String,
    directory: PathBuf,
    module: Option<String>,
    load_attempted: bool,
    forwarded: bool,
}

impl Drop for Session<'_> {
    fn drop(&mut self) {
        if let Some(module) = &self.module {
            self.runner.cleanup("pactl", &["unload-module", module]);
        } else if self.load_attempted {
            // 若 load-module 超時但伺服器端可能已建立模組,主動掃描並卸載匹配的殘留模組
            if let Ok(listing) = self.runner.execute(
                "pactl",
                &["list", "modules", "short"],
                Duration::from_secs(3),
                true,
            ) {
                for line in listing.lines().filter(|line| module_matches(line, &self.name)) {
                    self.runner.cleanup("pactl", &["unload-module", line.split_whitespace().next().unwrap()]);
                }
            }
        }
        if self.forwarded {
            self.runner.cleanup(
                "adb",
                &["-s", self.serial, "forward", "--remove", &format!("localabstract:{}", self.name)],
            );
        }
        let _ = fs::remove_dir_all(&self.directory);
    }
}

透過 RAII 機制,無論程式是因為手機離線、使用者主動停止、遭遇 I/O 錯誤或是收到中斷訊號,一旦 session 離開作用域,編譯器就會確保觸發 drop,依序卸載 PulseAudio 模組、移除 ADB forward 規則並刪除暫存目錄。

特別值得一提的是 else if self.load_attempted 的邊界保護:若 load-module 在客戶端等待超時但 PulseAudio 伺服器端實際已建立模組,Drop 會主動掃描 pactl list modules short 並精確卸載同名模組,防止伺服器端在高負載下洩漏殘留裝置。

宣告式 CLI 與子命令架構(clap)

在第一版 Rust 原型中,命令列參數採用手動字串匹配處理,當功能擴充至設備列舉、音量控制與 APK 安裝時,參數驗證變得脆弱且難以維護。

重構後引入 clap(v4 derive),將 CLI 定義為宣告式結構體與子命令列舉:

#[derive(Debug, Parser)]
#[command(version, about)]
pub struct Cli {
    #[arg(short = 's', long, global = true, value_name = "SERIAL")]
    pub serial: Option<String>,

    #[arg(long, global = true, value_name = "PATH")]
    pub config: Option<PathBuf>,

    #[arg(long, conflicts_with_all = ["once", "retry"])]
    pub list: bool,

    #[arg(long, global = true, conflicts_with = "retry")]
    pub once: bool,

    #[command(subcommand)]
    pub command: Option<Action>,
}

#[derive(Clone, Debug, PartialEq, Eq, Subcommand)]
pub enum Action {
    Run,
    List,
    Install {
        #[arg(value_name = "APK", env = "AUDIOSOURCE_APK")]
        apk: PathBuf,
    },
    Volume {
        #[arg(value_name = "LEVEL")]
        level: String,
    },
    Build {
        #[arg(long)]
        release: bool,
    },
}

透過 clap 的型別系統與屬性巨集,帶來幾項架構優勢:

  • 靈活的全域參數:-s / --serial 與 --config 宣告為 global = true,無論使用者將其放置於子命令之前或之後(例如 pocket-mic -s phone volume 100% 或 pocket-mic volume 100% -s phone),都能正確解析。
  • 嚴謹的互斥校驗:透過 conflicts_with 防止矛盾參數傳入(例如 --list 與子命令衝突、--once 與 --retry 衝突),在進入主邏輯前自動截斷無效指令。
  • 環境變數整合:Install 子命令直接綁定 AUDIOSOURCE_APK 環境變數,兼顧腳本自動化與手動執行的便利性。

結構化錯誤處理與行程防護(thiserror 與 PR_SET_PDEATHSIG)

過去在呼叫外部命令(adb、pactl)時,多數腳本或原型容易將錯誤簡化為無型別的字串訊息,難以區分「使用者主動中斷」、「指令逾時」還是「ADB 連線拒絕」。

在 Rust 核心中,我們透過 thiserror 定義了專屬的 ProcessError 列舉:

#[derive(Debug, Error)]
pub enum ProcessError {
    #[error("Stopped")]
    Stopped,
    #[error("{program}: {source}")]
    Io {
        program: String,
        #[source]
        source: io::Error,
    },
    #[error("{program}: timed out after {timeout:?}")]
    TimedOut { program: String, timeout: Duration },
    #[error("{program}: {status}: {message}")]
    Failed {
        program: String,
        status: ExitStatus,
        message: String,
    },
    #[error("{program}: {stream} reader failed")]
    ReaderPanicked {
        program: String,
        stream: &'static str,
    },
    #[error("{0}")]
    Connect(String),
}

底層以精確的錯誤型別表達故障原因,上層呼叫端則配合 anyhow::Context 注入業務層脈絡(例如 .context("Discover devices"))。在單元測試中,我們可以直接使用 matches! 驗證錯誤型別與終止碼,而終端使用者也能看見包含具體成因的完整錯誤鏈。

在子行程管理層面,為了避免父行程中斷時留下無人管理的孤兒行程,pocket-mic 在 pre_exec 階段調用 Linux 原生 prctl:

// 建立獨立行程群組,並在父行程結束時發送 SIGTERM,確保無殘留子行程
command.process_group(0);
unsafe {
    command.pre_exec(move || {
        if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
            return Err(io::Error::last_os_error());
        }
        if libc::getppid() != parent {
            libc::_exit(1);
        }
        Ok(())
    });
}

當父行程意外崩潰或收到退出訊號時,Linux 核心會保證向衍生出的子行程發送 SIGTERM;同時,配合獨立的行程群組(process_group(0)),在逾時或清理時可直接透過 libc::kill(-(child.id() as i32), libc::SIGKILL) 清理整個行程樹,徹底杜絕懸掛的管線描述子或孤兒行程。

裝置名稱的多詞引號處理(PipeWire Quoting)

在載入 PulseAudio 模組時,傳入的裝置描述通常包含空格(如 Pixel 3 Microphone)。在 PipeWire 底層的屬性解析器中,引號會經過模組參數與屬性列表的雙重解析。若只包一層引號,名稱常會在第一個空格處被截斷,最後在系統設定中看到的裝置名稱會變成單詞 "Pixel"。

在 Rust 模組中,我們對字串進行兩層跳脫處理:

fn pulse_quote(value: &str) -> String {
    format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}

pub fn description_property(description: &str) -> String {
    let property = format!("device.description={}", pulse_quote(description));
    format!("source_properties={}", pulse_quote(&property))
}

經此處理,傳入 pactl 的字串為 source_properties="device.description=\"Pixel 3 Microphone\"",PipeWire 便能正確解析出完整名稱。

精確卸載與同名前綴防護

在每次連線時,為了避免重複載入同名來源,程式會先呼叫 pactl list modules short 尋找既有模組。此處必須精準匹配整個名稱,避免誤卸載名稱具有相同前綴的其他音訊模組:

pub fn module_matches(line: &str, name: &str) -> bool {
    let fields: Vec<_> = line.split_whitespace().collect();
    fields.get(1) == Some(&"module-pipe-source")
        && fields[2..].iter().any(|field| {
            field
                .strip_prefix("source_name=")
                .is_some_and(|value| value.trim_matches('"') == name)
        })
}

Android 12 與 Android 13+ 的動態權限相容

Pixel 3 的最後一個官方系統版本為 Android 12。在測試自動授權邏輯時,程式在 Pixel 3 上會發生錯誤。

原因在於通知權限 android.permission.POST_NOTIFICATIONS 是在 Android 13(API 33)才引入的動態執行階段權限。在 Android 12 的 Pixel 3 上執行 pm grant ... POST_NOTIFICATIONS 會被系統回傳失敗。

因此在 Rust 檢查邏輯中,將兩者分開處理:

for permission in [
    "android.permission.POST_NOTIFICATIONS",
    "android.permission.RECORD_AUDIO",
] {
    let granted = dumpsys
        .lines()
        .any(|line| line.contains(&format!("{permission}:")) && line.contains("granted=true"));
    if !granted
        && runner.adb(serial, &["exec-out", "pm", "grant", PACKAGE, permission]).is_err()
        && permission == "android.permission.RECORD_AUDIO"
    {
        return Err("Could not grant microphone permission; grant it in Android app settings".into());
    }
}

通知權限即使授權未成功也僅視為選用項目略過,唯獨麥克風錄音權限必須確保成功。這樣一來,Android 12 的 Pixel 3 也能順利自動取得授權並啟動串流。

Quickshell 插件架構:雙重角色設計

在 Omarchy Linux 桌面架構 中,桌面 UI 與狀態列是基於 Quickshell (Qt6/QML) 運作。在 manifest.json 中,我們將插件註冊為 patrick.pocketmic,並同時啟用 service 與 bar-widget:

{
  "schemaVersion": 1,
  "id": "patrick.pocketmic",
  "name": "Pocket Mic",
  "version": "1.0.0",
  "description": "Turn your Android phone into a desktop microphone from the bar, with automatic reconnection",
  "kinds": ["service", "bar-widget"],
  "entryPoints": {
    "service": "Service.qml",
    "barWidget": "BarWidget.qml"
  },
  "barWidget": {
    "displayName": "Pocket Mic",
    "category": "Audio",
    "allowMultiple": false,
    "defaultSection": "right"
  }
}

背景常駐服務:Service.qml

將後台管理抽離為獨立單例服務,使音訊串流與狀態列 UI 解耦,避免狀態列重新渲染或選單關閉時中斷音訊通話:

  • worker 程序生命週期管理:使用者選取設備後,直接啟動 pocket-mic --serial <serial>。若手機意外拔除或斷線,服務會自動進入 5 秒重試倒數,直到手機重新插入便自動恢復連線。
  • 設備探測與就地更新:提供 refreshDevices() 函式,透過 pocket-mic --list 取得線上設備的原始 ADB 狀態(如 device、unauthorized、offline、no permissions)並更新反應式屬性 devices。
  • 零背景盲目輪詢:不同於早期版本在背景常駐 5 秒定時器,最新架構完全移除了背景輪詢定時器,將探測控制權交由前端選單,徹底消除了桌面閒置時的背景開銷。
readonly property string binaryPath: decodeURIComponent(
    Qt.resolvedUrl("pocket-mic").toString().replace(/^file:\/\//, "")
)

Process {
    id: worker
    command: [root.binaryPath, "--serial", root.selectedSerial]
    // ...
}

狀態列與即時控制面板:BarWidget.qml

  • 按需輪詢機制 (On-Demand Polling):將設備探測計時器綁定在選單展開狀態(popupOpen):
    Timer {
        interval: 5000
        running: root.popupOpen && root.controller !== null
        repeat: true
        triggeredOnStart: true
        onTriggered: root.controller.refreshDevices()
    }
    當使用者點擊圖示展開面板時,triggeredOnStart: true 立即執行一次設備探測,讓使用者能第一時間看到最新狀態;選單保持展開時,每 5 秒更新一次;一旦關閉選單,探測排程立即休眠。閒置狀態下維持真正的零子行程開銷。
  • 手動刷新按鈕:面板右上角提供 󰑐 重新整理按鈕,支援隨時手動點擊觸發 controller.refreshDevices()。
  • 狀態圖示與提示:未啟用時顯示半透明的休眠麥克風圖示 󰍭;串流時點亮為 󰍬,並提供懸停狀態說明。
  • 彈出式控制面板 (PopupCard):比對序號就地更新模型,確保選單平順更新且不丟失焦點。
  • 鍵盤導航支援:配合 Omarchy 的鍵盤操作慣例,支援使用 Tab 鍵在不同設備間跳轉,並按下 Space 或 Enter 快速啟動或停用。

下圖為插件在 Omarchy 桌面狀態列中的實際運作畫面——未啟用時狀態列顯示為休眠麥克風圖示 󰍭,點選設備後立即轉為串流狀態,圖示點亮為 󰍬:

未連線休眠狀態 串流進行中狀態
pocketmic-dropdown-stopped.png pocketmic-dropdown-streaming.png

日常體驗:會議通話與語音聽寫

將 Pocket Mic 整合至日常工作後,使用體驗相當穩定。

遠端會議與通話

開會時只需用一條 USB 傳輸線將 Pixel 3 連接電腦,狀態列右上角的麥克風圖示隨即變亮。點擊圖示或用鍵盤快捷鍵勾選,系統層級的 PipeWire 音訊核心便會載入虛擬音訊源,在 Omarchy 的 Audio 面板中自動識別為 Pixel 3 Microphone 並設為預設輸入源:

pipewire-audio-source.png

在 Google Meet 與 Discord 的麥克風選單裡就能直接選取 Pixel 3 Microphone。Pixel 3 內建的硬體消噪與指向性收音表現良好,對話時背景的機械鍵盤敲擊聲能被有效抑制,不需要額外開啟降噪軟體。

隨時呼叫 voxtype 本地 Whisper 聽寫

在日常開發或寫作時,無論是長按 F9(push-to-talk,放開即停止)或是按下 Ctrl + Alt + Shift + V(切換錄音開關),桌上立著的 Pixel 3 都是方便的桌面麥克風。說完一段話,文字便即時輸入至螢幕游標處,辨識率因為清晰的收音大幅提升。

發熱與功耗表現

因為手機端只執行純粹的 AudioRecord 與 socket 寫入,螢幕處於休眠關閉狀態,沒有額外的 UI 繪製與複雜編碼運算,Pixel 3 在持續收音數小時的情況下完全處於常溫狀態,不會發燙,同時電腦的 USB 孔還能維持手機微量補電。

結語:延續硬體價值的實踐

在電子產品更迭頻繁的環境下,一支舊手機往往因為不再支援新版作業系統或電池衰退而閒置。然而它內部搭載的感測器、音訊晶片與麥克風硬體素質,依然足以勝任許多周邊設備的需求。

從最初的 Shell + Python 概念驗證腳本,到最後透過 Rust RAII 與 Linux 抽象通訊協定重構成型,Pocket Mic 展現了現代 Linux 與 Wayland 桌面的擴充能力:不需要依賴特定商業軟體,就能讓退役的手機在日常工作中發揮實際價值。

如果你手邊也有一台閒置的 Android 手機,不妨也把它接上電腦試試看。

專案原始碼與安裝說明:


讓 Jev 學會煞車:從被冷落的 Reposition 到 17 秒通關的戰術調校

透過狀態特徵工程、提示詞決策邊界與在地反向推進,打破 AI 飛行盲區
featured.svg

在上一篇專題中,我記錄了如何為經典街機《Asteroids》打造雙層飛行副駕駛:由 TypeSafe AI 的 System One 結構化決策模型 Jev 負責宏觀戰術判斷,在地 TypeScript 控制器則以 120 Hz 固定步長負責物理致動。那套架構成功讓飛船無傷清空了第一星區(Sector 01)。

然而在持續把玩與檢視飛行儀表板日誌時,我察覺到一個相當尷尬的現象:

在系統提供給 Jev 的三種戰術姿態(intercept、evade、reposition)中,reposition(重新佔位)在實戰中幾乎從未被選取過。

統計連續數場飛行紀錄後,數據呈現極端的兩極化分佈:一旦空域出現撞擊危險,Jev 幾乎 100% 選擇 evade;而當周遭暫時安全時,Jev 則壓倒性地選擇 intercept。被寄予厚望的 reposition 選取率甚至不足 2%,幾乎形同虛設。

更嚴重的後遺症在於:缺乏主動佔位與控速機制的飛船,在無重力太空中總是帶著極高的慣性橫衝直撞(漂移速度經常飆破 250 px/s)。即便 Jev 想鎖定下一顆目標,飛船也往往因為速度過快而難以轉向瞄準,只能任由慣性帶著船體在星圖中漫長滑行。上一版通關第一星區足足花費了 84 秒。

這促使我著手進行了一場深入的戰術重構:如何讓 Jev 理解何時該減速重新佔位,並讓在地控制器真正落實相應的物理動力學?

先來看調校完成後的最新成果。

成果驗證:17 秒極速通關與均衡戰術

在經過特徵工程、提示詞決策邊界與在地煞車實作後,Jev 在實戰中的飛行身段有了質的飛躍。

以下是同一款遊戲、同一位 Jev 飛行員在調校後錄製的實戰紀錄:

在這段約 17 秒的實戰錄影中,視訊精確記錄至第一星區全數清空(SECTOR CLEAR,得分 2,600),幾個關鍵指標發生了劇烈的變化:

  • 通關時間大幅縮減:清空第一星區(得分達到 2,600)的耗時從原本的 84 秒急遽縮短至 17 秒,效率提升了近 5 倍。
  • 戰術分佈達成動態平衡:在通關的 17 秒內,Jev 總計執行了 20 次戰術決策。其中 reposition 7 次、intercept 6 次、evade 7 次,徹底打破了過去非打即逃的二元困境。
  • 飛行姿態洗鍊沉穩:飛船不再失控地以高速在全場漂移甩尾,而是呈現出清晰的作戰節奏——高速逼近 ➔ 逆向噴射煞車 ➔ 穩定轉身鎖定 ➔ 伺機精準點放 ➔ 規避破片。

這項轉變是如何實現的?背後正是「狀態特徵工程」、「提示詞操作邊界」與「物理致動分立」三者的環環相扣。

診斷:為什麼模型過去從不選 Reposition?

回頭檢視最初的設計,我發現了兩個致命的工程盲點:

盲點一:提示詞語意重疊與狀態貧乏

在最初的提示詞中,我對 reposition 的描述僅有寥寥數語:

reposition: 'Move to a clearer part of the field to regain room to aim. Fire opportunistically.'

對模型而言,「移動到開闊處以重新獲取瞄準空間」這句話在語意上存在巨大的模糊性:

  • 如果空域有迫近的隕石,模型自然認為逃往開闊處就是 evade 的責任。
  • 如果空域沒有迫近的隕石,模型看到遠處有敵人,自然直覺地認為應該上前進攻(intercept)。

此外,原本送給模型的雷達快照只包含單純的相對距離、方位角與接近速度。模型「看不到」全域威脅的總體評估,更「不知道」自己當前到底有沒有對準任何一顆隕石的射擊角度。在缺乏領域特徵支撐的情況下,模型只能在進攻與逃亡之間粗糙搖擺。

盲點二:在地控制器缺乏專屬執行邏輯

更致命的是,當初在瀏覽器端 src/game.ts 的 steer() 函式中,我為了圖省事,寫下了這樣的邏輯:

// 舊版實作:evade 與 reposition 共用相同的 24 方位逃逸分支
if (reflex || maneuver === 'evade' || maneuver === 'reposition') {
  // 24 方位候選落點取樣 ...
}

這意味著即便 Jev 在某些情境下選了 reposition,在地控制器做的事情也跟 evade 完全相同:同樣是在全速逃竄,根本無法達到「調整航向、降低過剩慣性、建立穩定射擊射角」的戰術目的。

解法一:感知特徵工程(Tactics Feature Extraction)

要讓模型做出精密的戰術抉擇,首先必須提供具有明確戰術語意的指標,而不是丟一堆未經加工的 raw 座標讓模型猜測。

在 src/game.ts 的 snapshot() 中,我為快照擴充了專屬的 tactics 物件與每顆隕石的 leadBearing:

snapshot(sequence: number): FlightSnapshot {
  const round = (n: number) => Math.round(n * 10) / 10;
  const asteroids = this.rocks.map(r => {
    const d = delta(this.ship, r), dist = length(d);
    // ...
    return {
      id: r.id,
      distance: round(dist),
      bearing: round(angleDifference(Math.atan2(d.y, d.x), this.ship.angle) * 180 / Math.PI),
      // 關鍵新增:包含移動目標動態前置量的真實瞄準夾角
      leadBearing: round(angleDifference(leadAngle(this.ship, r), this.ship.angle) * 180 / Math.PI),
      radius: r.radius,
      closingSpeed: round(-dot / (dist || 1)),
      closestApproach: round(Math.hypot(d.x + v.x * t, d.y + v.y * t) - r.radius - SHIP_RADIUS),
      secondsToClosest: round(t),
    };
  }).sort((a, b) => a.distance - b.distance).slice(0, 10);

  const inRange = asteroids.filter(r => r.distance < 580);
  return {
    sequence,
    wave: this.wave,
    lives: this.lives,
    ship: {
      speed: round(Math.hypot(this.ship.vx, this.ship.vy)),
      heading: round(wrap(this.ship.angle * 180 / Math.PI, 360)),
      invulnerable: this.ship.shield > 0,
    },
    // 戰術衍生特徵
    tactics: {
      // 1. 全場即將在 0.8 秒內闖入安全包絡線的急迫威脅總數
      imminentThreats: this.rocks.filter(r => imminentThreat(this.ship, r)).length,
      // 2. 當前船頭已對齊前置角且在射程內的有效射擊機會數
      firingOpportunities: this.rocks.filter(r => canFireAt(this.ship, r)).length,
      // 3. 射程內所有候選隕石中,最小的絕對瞄準偏差角(度)
      bestAimError: inRange.length ? Math.min(...inRange.map(r => Math.abs(r.leadBearing))) : null,
    },
    asteroids,
  };
}

這項特徵工程的價值在於:

  1. tactics.imminentThreats:讓模型一目了然全場是否有迫在眉睫的碰撞威脅,將「生存防禦」從模稜兩可的推測轉化為確鑿的整數計數。
  2. tactics.bestAimError:如果當前速度很高且射程內的最佳目標都在船尾(例如 bestAimError = 180°),模型就能明確判定「目前沒有任何良好的射擊窗口,盲目開火只會浪費機會」。
  3. leadBearing:消除了目標運動造成的視覺角度盲區,直接給出砲管到底需要修正多少度才能擊中目標。

解法二:提示詞決策邊界(Prompt Boundaries Tuning)

特徵到位後,第二步是在 server/pilot.ts 中重塑決策樹邏輯,為三個選項劃清非重疊的操作邊界。

export function questionsFor(snapshot: FlightSnapshot) {
  // ...
  return {
    maneuver: choice(
      'Choose the most useful maneuver for the next roughly 1.2 seconds in Asteroids. Survive and destroy rocks. Units are pixels, pixels/second, degrees, seconds. Bearing 0 is ahead; negative is left. leadBearing includes moving-target lead. `tactics.imminentThreats` counts asteroids entering a safety envelope within 0.8 seconds across the whole field. `tactics.firingOpportunities` counts currently aligned in-range shots across the whole field. `tactics.bestAimError` is the smallest absolute leadBearing among in-range radar candidates, or null if none are in range. First consider immediate danger: evade takes priority over preparing an attack. With no immediate danger, consider whether to prepare or attack: speed above about 180 or no aligned shots with bestAimError above about 45 degrees favors reposition. At controlled speed with a useful firing angle, intercept; distant targets alone favor approaching with intercept, not reposition. Negative closestApproach predicts overlap at constant velocity over up to 5 seconds, not necessarily immediate danger. secondsToClosest=0 may describe a receding rock; positive closingSpeed means approaching. A local reflex handles emergencies in every maneuver. Shield is temporary.',
      {
        intercept: 'Attack from a usable firing angle at controlled speed, or close the distance to out-of-range targets. Lead shots and approach when appropriate.',
        evade: 'Escape an imminent collision indicated by tactics.imminentThreats or a clear approaching near-term threat. Accelerate toward a clear endpoint. High ship speed alone is NOT a reason to evade when threats are absent and asteroids are receding or well separated; use reposition to brake in that situation.',
        reposition: 'No imminent threat, but the ship is drifting too fast (roughly above 180 px/s) OR needs a large turn to get a shot. Even if a shot is currently aligned, excessive safe drift is a reason to reposition. Counter-thrust to reduce speed, then coast and aim without accelerating toward the target. Fire opportunistically. This is preparation and braking, not emergency escape.',
      }
    ),
    target: choice(
      'Independently choose the most useful asteroid to aim at if intercept or reposition is selected. Prefer a nearby target with a small absolute leadBearing or a closing threat that can be destroyed. Reposition can first brake before aiming. Use none if no useful target exists. This answer cannot see the maneuver answer.',
      targets
    ),
  };
}

這次提示詞調校的核心關鍵在於:

  • 明確的優先級階層:imminentThreats > 0 時,evade 享有絕對優先權;
  • 定量的操作門檻:當威脅為 0 時,若航速大於 180 px/s 或射擊偏差角大於 45°,模型被引導優先選擇 reposition;
  • 排他性邊界釐清:在 evade 的定義中特別強調「無威脅時的高航速不是逃跑的理由,應使用 reposition 進行煞車」;在 reposition 的定義中特別標明「這是準備與煞車,不是逃生」。

解法三:在地控制器分立——主動逆向推進煞車

即使模型精確下達了 reposition 指令,如果底層控制器沒有實作對應的物理行為,指令依然是一紙空文。

在《Asteroids》的牛頓慣性物理中,太空船沒有大氣阻力。如果你想在太空中停下來或變換航向,單純轉動船頭不會改變任何速度向量!唯一的煞車方式是:將船頭旋轉至目前速度向量的完全反方向,啟動引擎進行 反向噴射推進 (Counter-Thrust Braking)。

在 src/game.ts 的 steer() 中,我將 reposition 從 evade 徹底剝離,實作了精緻的雙階段姿態控制:

export function steer(game: Game, maneuver: Maneuver, targetId: string | null): PilotOutput {
  const ship = game.ship;
  let target = game.rocks.find(r => r.id === targetId);
  if (!target) target = [...game.rocks].sort((a, b) => length(delta(ship, a)) - length(delta(ship, b)))[0];
  if (!target) return { controls: IDLE, reflex: false, target: null };

  const reflex = game.rocks.some(r => imminentThreat(ship, r));
  let desired: number, thrust = false;

  if (reflex || maneuver === 'evade') {
    // 1. 規避分支:24 方位候選落點取樣,尋找空間淨距最大方向加速脫離
    let best = -Infinity; desired = ship.angle;
    for (let i = 0; i < 24; i++) {
      const a = i * Math.PI / 12;
      const point = { x: ship.x + ship.vx * 0.45 + Math.cos(a) * 180, y: ship.y + ship.vy * 0.45 + Math.sin(a) * 180 };
      const clearance = Math.min(...game.rocks.map(r => length(delta(point, { x: r.x + r.vx * 0.7, y: r.y + r.vy * 0.7 })) - r.radius));
      const value = clearance - Math.abs(angleDifference(a, ship.angle)) * 28;
      if (value > best) { best = value; desired = a; }
    }
    thrust = Math.abs(angleDifference(desired, ship.angle)) < 0.75;
  } else if (maneuver === 'reposition') {
    // 2. 重新佔位分支:逆向噴射減速,隨後慣性滑行鎖定目標射角
    if (Math.hypot(ship.vx, ship.vy) > 120) {
      // 階段 1:航速超過 120 px/s,瞄準速度向量的反方向 (-vx, -vy)
      desired = Math.atan2(-ship.vy, -ship.vx);
      // 嚴格等待船頭完全轉到反方向(誤差小於 0.35 徑度 / 約 20 度)才點火反推!
      thrust = Math.abs(angleDifference(desired, ship.angle)) < 0.35;
    } else {
      // 階段 2:航速已受控 (< 120 px/s),關閉推進器,船頭轉向目標移動前置角
      desired = leadAngle(ship, target);
      thrust = false;
    }
  } else {
    // 3. 截擊分支:對準目標前置角,距離遠且未超速時加速進逼
    desired = leadAngle(ship, target);
    thrust = length(delta(ship, target)) > 260 && Math.hypot(ship.vx, ship.vy) < 170
      && Math.abs(angleDifference(desired, ship.angle)) < 0.45;
  }

  const error = angleDifference(desired, ship.angle);
  // 全域伺機開火:船頭對齊任何一顆隕石的預估前置角且通過 0.15s 冷卻即擊發
  const fire = game.rocks.some(r => canFireAt(ship, r));
  return { controls: { turn: Math.max(-1, Math.min(1, error * 3.5)), thrust, fire }, reflex, target: target.id };
}

整套架構的決策分流與執行流程如下圖所示:

tactics-tuning.svg

這個雙階段設計帶來了三大優勢:

  1. 防止無效自旋加速:在航速尚未降下來前,若未對準反向就貿然噴射,只會讓飛船沿著錯誤的切線越轉越快。透過 angleDifference < 0.35 門檻,飛船一定會先轉到位,再穩健煞車。
  2. 慣性滑行瞄準:當速度低於 120 px/s 時,飛船嚴格熄火滑行,將全部角速度用於鎖定目標前置角,不再向前推擠衝撞目標。
  3. 伺機擊發不中斷:即使飛船正在逆向煞車,只要旋轉中的船頭掠過任何隕石的前置航角,全域伺機開火(opportunistic firing)依然會順手補上一槍,不浪費任何消滅敵人的機會。

離線驗證:定型化場景評估(Evaluation Fixtures)

在正式讓模型接管飛行前,如何驗證 Jev 真的掌握了這套戰術邊界,而不是在單一場次中碰巧走運?

在 scripts/evaluate-pilot.ts 中,我設計了 4 組涵蓋極端戰術邊界的測試情境(fixtures):

const cases = [
  { name: '受控速度下的開闊正面目標', expected: 'intercept', speed: 0, heading: 0, distance: 300, rockVx: 0 },
  { name: '背向目標高速漂移(需煞車)', expected: 'reposition', speed: 260, heading: Math.PI, distance: -300, rockVx: 0 },
  { name: '空域安全但目標全在身後(偏差角 180°)', expected: 'reposition', speed: 0, heading: Math.PI, distance: 300, rockVx: 0 },
  { name: '即將迎面撞上的急迫隕石', expected: 'evade', speed: 0, heading: 0, distance: 60, rockVx: -100 },
];

執行實際呼叫 TypeSafe API 的評估腳本:

npm run test:jev

終端機印出了令人振奮的結果:

{"scenario":"受控速度下的開闊正面目標","expected":"intercept","matched":true,"probabilities":{"intercept":0.89,"evade":0.06,"reposition":0.05},"latencyMs":326}
{"scenario":"背向目標高速漂移(需煞車)","expected":"reposition","matched":true,"probabilities":{"reposition":0.52,"evade":0.34,"intercept":0.14},"latencyMs":172}
{"scenario":"空域安全但目標全在身後(偏差角 180°)","expected":"reposition","matched":true,"probabilities":{"reposition":0.68,"intercept":0.16,"evade":0.16},"latencyMs":129}
{"scenario":"即將迎面撞上的急迫隕石","expected":"evade","matched":true,"probabilities":{"evade":0.97,"intercept":0.03,"reposition":0.00},"latencyMs":143}

在 4/4 全部命中的情境測試中,我們清晰看到了 Jev 戰術意識的確立:

  • 面對身後的敵人(bestAimError = 180°),reposition 獲得了 68% 的高票勝出;
  • 面對超速漂移(260 px/s),即便目標在射程內,reposition 也以 52% 壓倒了盲目進攻;
  • 面對迎面撞擊,evade 以 97% 的壓倒性置信度主導避難;
  • 一旦空域安全且航速穩定,intercept 便以 89% 的機率主動迎擊。

結語與工程省思

從 84 秒到 17 秒,這場看似簡單的戰術調校給了我非常深刻的體會。

在當前 AI Agent 的開發過程中,很多團隊在遇到模型表現不佳時,直覺反應往往是:

  • 「是不是模型不夠大?換旗艦模型試試?」
  • 「提示詞是不是不夠長?再寫五百字叮囑它要注意各種狀況。」

但這次 hello-jev 的實作證明了完全不同的工程事實:

  1. AI 的盲區往往源於人類定義的模糊:當你給模型一個名為 reposition 的選項,卻沒有在提示詞中為它劃定清晰的定量觸發條件(何時該選、何時不該選),模型自然會選擇更有把握的極端選項(進攻或逃跑)。
  2. 領域特徵工程永遠無可取代:不要指望模型在幾百毫秒內從原始座標矩陣中自行頓悟出「我現在漂移太快了」或「所有敵人都背對著我」。把領域常識預先提煉為緊湊的數值(如 bestAimError 與 imminentThreats),能為模型節省大量的推理負擔。
  3. 思考與致動必須對稱 (Symmetry of Brain & Actuator):如果在認知層提供了三種姿態,在執行層卻只實作了兩種物理分支,整個系統的智慧就會產生斷層。在無重力物理中,「減速重新佔位」需要專門的反向噴射與慣性滑行狀態機;只有底層致動器具備足夠的物理能力,上層模型的決策才能真正開花結果。

當星圖中的這艘小飛船學會沉著地逆向點火煞車時,它才真正脫離了盲目逃竄的程式碼反射,展現出宛如人類王牌飛行員般的沉穩戰術。