前一陣子跟一個硬體工程師聊天,然後他順便推銷他們公司出的桌面燈,因為是他經手過的產品。老兄,不是我要貶低你的努力,只是我的消費水準是隨便找個鋁擠、幾顆 M4 螺絲、一支 USB 燈條就處理掉桌面照明的人,找我推銷桌面燈是真的找錯人了。



就算真的有興趣想買的,大概也是手術燈那種東西,畢竟在做事的時候光源被手或是頭擋住真的很煩。
前一陣子跟一個硬體工程師聊天,然後他順便推銷他們公司出的桌面燈,因為是他經手過的產品。老兄,不是我要貶低你的努力,只是我的消費水準是隨便找個鋁擠、幾顆 M4 螺絲、一支 USB 燈條就處理掉桌面照明的人,找我推銷桌面燈是真的找錯人了。



就算真的有興趣想買的,大概也是手術燈那種東西,畢竟在做事的時候光源被手或是頭擋住真的很煩。

我的 Homelab 是透過筆電和 USB 外接 DAS (Direct Attached Storage) 建構的,並且採過一些小坑。最近在建置新的節點時,發現我把相關的資訊分散在不同的筆記和 IaC (Infrastructure as Code) 裡面,於是想說趁這個機會整理一下資 訊。
Framework 的話可以在 BIOS 設定充電上限:

(從 Framework 的論壇借用螢幕截圖)
另外一台 Lenovo 的筆電則是靠這個設定:
https://github.com/makifdb/lenopow
我會把把充電上限設在 80% 避免筆電作為伺服器長時間插著充電造成電池膨脹。
讓筆電不會因為螢幕蓋上而進入休眠模式,編輯 /etc/systemd/logind.conf:
HandleLidSwitch=ignore
執行指令重啟服務:
systemctl restart systemd-logind.service
但是上述設定會造成另外一個問題:螢幕蓋著還是繼續發光,於是還需要一個步驟,編輯 /etc/default/grub:
在 GRUB_CMDLINE_LINUX_DEFAULT 插入 consoleblank=60。
這會讓螢幕在 60 秒後熄滅。
- name: Setup laptop
hosts:
- arachne-node-beta
tasks:
- name: check if consoleblank is configured in the boot command
ansible.builtin.lineinfile:
backup: true
path: /etc/default/grub
regexp: '^GRUB_CMDLINE_LINUX_DEFAULT=".*consoleblank=60'
state: absent
check_mode: true
register: grub_cmdline_check
changed_when: false
- name: insert consoleblank if missing
ansible.builtin.lineinfile:
backrefs: true
path: /etc/default/grub
regexp: '^(GRUB_CMDLINE_LINUX_DEFAULT=".*)"$'
line: '\1 consoleblank=60"'
when: grub_cmdline_check.found == 0
notify: update grub
- name: Set HandleLidSwitch
ansible.builtin.lineinfile:
backrefs: true
path: /etc/systemd/logind.conf
regexp: "#?(HandleLidSwitch=)(?:.*)$"
line: '\1ignore'
notify: Restart logind
handlers:
- name: update grub
ansible.builtin.command: update-grub
- name: Restart logind
ansible.builtin.systemd_service:
name: systemd-logind
state: restarted
當服務透過 Docker 跑在容器內,持久化資料卻儲存在透過 USB 連線的外部儲存中,這個仰賴關係需要額外處理。
編輯 /etc/fstab 加入以下內容:
# DAS
UUID=7f858b7e-f942-45b3-92dd-8c99b497b6a4 /mnt/das-storage ext4 defaults,nofail,x-systemd.automount 0 2
修改後執行:
systemctl daemon-reload
UUID 可以透過 lsblk -f 之類的指令獲得。
新增檔案 /etc/systemd/system/docker.service.d/override.conf:
[Unit]
After=mnt-das\\x2dstorage.automount
ConditionPathExists=/mnt/das-storage
systemctl restart docker.service
確保 Docker Daemon 在 DAS 掛載後才運行。
因為電腦沒有直接和硬碟建立連線而是隔著一層 DAS,因此不能使用普通的 smartctl 指令確認,而必須使用額外的參數1:
smartctl -a -d jmb39x-q,0 /dev/sdd
smartctl -a --device jmb39x-q,1 /dev/sdd
smartctl -a -d jmb39x-q,2 /dev/sdd
smartctl -a --device jmb39x-q,3 /dev/sdd
QNAP External RAID Manager - Export SMART Data - QNAP NAS Community Forum. Retrieved 2026-01-03, from https://forum.qnap.com/viewtopic.php?p=873575&sid=573c6149a1276e4ab8d9f4785f1c6029#p873575 ↩
聲明一個 Pod:
apiVersion: v1
kind: Pod
metadata:
name: awesome-pod
labels:
app: awesomeApp
spec:
containers:
- name: awesome-container
image: mendhak/http-https-echo:38
ports:
- containerPort: 8080
根據聲明建立資源(在這個例子中是建立 Pod):
kubectl create -f sample.yaml
接著使用這個指令建立隧道 (tunnel):
kubectl port-forward awesome-pod 3001:8080
接著就可以透過這個連結訪問剛剛建立的 Pod 了: http://localhost:3001

這個通道是臨時的,中止指令就會消滅通道,使用過 ngrok 的人或許會對這種感覺不陌生,它就像是「反向的 ngrok」,暫時將 Cluster 內特定的資源暴露到本機上。
玩耍結束後不要忘記銷毀資源:
kubectl delete -f sample.yaml

Pod 在 K8s 的世界裡其實是雜魚、消耗品,可能會故障然後被清除掉之後被新建的 Pod 取代,又或是為了負載需求而被複製成一堆一樣參數的 Pod,Pod 的 IP 也因此是浮動的,實際使用你不會直接連線到特定的 Pod,而是透過一層抽象找到「在運行這種服務的 Pod」,那個抽象就是 Service。這個抽象不只是用於外部訪問,也包含 Pod 對 Pod 的內部連線。
一個 Service 大概長得像這樣:
apiVersion: v1
kind: Service
metadata:
name: awesome-service
spec:
selector:
app: awesomeApp
type: ClusterIP
ports:
- protocol: TCP
port: 3001
targetPort: 8080
apiVersion: v1
kind: Pod
metadata:
name: awesome-pod
labels:
app: awesomeApp
spec:
containers:
- name: awesome-container
image: mendhak/http-https-echo:38
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: awesome-service
spec:
selector:
app: awesomeApp
type: ClusterIP
ports:
- protocol: TCP
port: 3001
targetPort: 8080
# 用一樣的指令把服務帶起來:
kubectl create -f sample.yaml
# 開 tunnel
kubectl port-forward service/awesome-service 3002:3001
只是這次我們連線的目標不是 Pod 而是 Service: http://localhost:3002

ClusterIP 尚未真正對外暴露我們的服務,這個模式主要給 Pod 內部訪問用(路徑3→2)1:
apiVersion: v1
kind: Pod
metadata:
name: awesome-pod
labels:
app: awesomeApp
spec:
containers:
- name: awesome-container
image: mendhak/http-https-echo:38
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: awesome-service
spec:
selector:
app: awesomeApp
type: NodePort
ports:
- protocol: TCP
port: 3001
targetPort: 8080
nodePort: 30390
接著用一樣的指令把服務帶起來:
kubectl create -f sample.yaml
這次從 Node 的 IP 訪問(例 如: http://192.168.0.123:30390)

Service 除了 ClusterIP 和 NodePort 以外,還有其他種類,不過在此不做過多的解釋,因為不是本文重點。

Pod 在 K8s 的世界裡其實是雜魚、消耗品
是的,在 K8s 我們一般不會直接佈署 Pod,而是透過 Deployment 組件(或是其他類似功能的組件)來間接的佈署 Pod:
apiVersion: apps/v1
kind: Deployment
metadata:
name: awesome-deployment
spec:
replicas: 3
template:
metadata:
labels:
app: awesomeApp
spec:
containers:
- name: kubernetes-demo-container
image: mendhak/http-https-echo:38
ports:
- containerPort: 8080
selector:
matchLabels:
app: awesomeApp
---
apiVersion: v1
kind: Service
metadata:
name: awesome-service
spec:
selector:
app: awesomeApp
type: NodePort
ports:
- protocol: TCP
port: 3001
targetPort: 8080
nodePort: 30390
你可以用瀏覽器打開它(例如: http://192.168.0.123:30390), 不過你可能只會看到相同的 os.hostname,試試多執行幾次 curl:
curl http://192.168.0.123:30390/ | jq .os.hostname
Kubernetes Service 概念詳解 | Kubernetes. Retrieved 2026-01-01, from https://tachingchen.com/tw/blog/kubernetes-service/ ↩


在同一個 Cluster 下可以再設定一個 Namespace 來隔離資源,知道有這個東西就好,初學者可以先不管它的存在。
作為一個網頁前端導向的開發者,接觸 Systemd 的角度較資深一輩的工程師(或正規 CS (Computer Science) 路線出生的人)不太一樣,當前輩們已經在進行「Systemd 大一統」跟「No Systemd」之間的宗教戰爭的時候,我才在 Docker 容器內接觸內接觸第一個行程的問題 (PID 1 Problem)。
這裡紀錄並整理一下自己在這條路上的一些經歷和觀察。
正確的術語應該是 OCI (Open Container Initiative) 容器,不過為了方便溝通本文會視情況使用俗稱的 Docker。另一方面,筆者在學習過程確實是先使用 Docker,使用 Docker 以外的 Runtime 其實也只是不久之前的事。
Dockerfile 中的 ENTRYPOINT 與 CMD實際上 ENTRYPOINT 才決定了第一個行程 (Process) 是誰,CMD 只是後面被帶進去的參數。
有沒有經歷過 docker compose down 但是某個服務 (service) 卻遲遲沒有被關閉,整個過程停頓了一會兒才把整套服務簇釋放掉?
這十之八九是因為 ENTRYPOINT 沒有處理 SIGTERM 的能力造成的,開發環境或許不打緊,但是在生產環境如果有請求 (request) 掛著還沒處理完,然而容器被更新強制關閉的話,帶來的問題是十分致命的。
在 Docker Compose 允許用 YAML 將多個容器組織起來構成服務簇,並且使用 depends_on 來設定仰賴關係並控制容器的啟動順序、restart 來設定重啟策略、healthcheck 判斷容器狀態。
一個 service 其實可以設定多個容器(透過 replicas 或是 scale),不過為了簡化描述我在上面並沒有直接指出。
以下不少經驗來自工作上面對的問題,專案細節就不透漏了。
第一次遇到這樣的需求來自業務上的要求:
將調用 SideFX Houdini 的 Python 伺服器容器化
它要求運行一個授權伺服器 (License Server),透過 Python 載入的 SDK 和這個伺服器之間會定時檢查彼此的存在。當時簡單使用 entrypoint.sh 、& 語法和 sleep 把兩個行程跑了起來。
後來遇到另外一個需要解決的問題:
LibreOffice 作為一個無頭 (headless) 伺服器運作。 Python 程式使用 UNO (Universal Network Objects) 界面與之溝通。 佈署在單一容器的 Serverless 雲環境,因此只能有一個容器。
這次我使用了 Supervisor 來解決問題1。
接下來的案例是真正讓我將 「Docker 內的 PID 1 問題」與「主機上的 PID 1 問題」連在一起的關鍵:LinuxServer.io。
Blender 是一個面向 3D 設計師的軟體,它是 Maya 或 3ds Max 這類軟體的開源競爭者。LinuxServer.io 使用了某種黑魔法允許 Blender 被運行在容器內,使用者則透過去網頁瀏覽器與之互動,並且 Blender 並不是唯一被這樣操作的軟體,除此之外 LinuxServer.io 還封裝了各種 GUI 軟體到 Dokcer 容器內。
LinuxServer.io 使用已經存在的實作 (KasmVNC) 來達成這些目標 ,不過魔法的關鍵在於:
使用 s6-overlay 運行與管理包含 X Server 在內的多個程式。
s6-overlay 不只能夠啟動多個程式,還能聲明程式與程式之間的仰賴關係,來依序啟動,甚至區分了「執行一次」和「持續在背景運作」的程式。
因為不是本文重點,所以我沒有細談整個過程,大致上就是根據那個 Blender 容器的文件與原始碼找到實作的方式,留下 repo 供參考:
/etc/init.d/rcS我曾經在嵌入式 Linux 看到這樣的東西:
$ ls /etc/init.d
rcK rcS S01syslogd S02klogd S02sysctl
當時我也根據需求寫了一個簡單的 S03sdmnt 腳本來掛載 SD 卡。
後來才知道這是根據 System V Init 簡化後的設計。
完整的故事跟細節我有寫另外一篇文章描述:
https://flyskypie.github.io/blog/2022-12-03_3ds_linux_javascript_bad_apple/
Quadlet 是我試著轉向使用 Podman 得知的東西,雖然最後我並沒有用過就了,而是使用 Podman Compose,省得還要另外維護一份語法不一樣的東西。
下面是 Quadlet 聲明一個服務的例子2:
[Unit]
Description=A minimal container
[Container]
# Use the centos image
Image=quay.io/centos/centos:latest
# In the container we just run sleep
Exec=sleep 60
[Service]
# Restart service when sleep finishes
Restart=always
[Install]
# Start by default on boot
WantedBy=multi-user.target default.target
不難發現上述不同的工具都在解決幾個類似的問題:
這些問題構成的問題領域 (Problem Domain) 可以被定位為系統初始化 (System Init) 和行程管理 (Process Supervision)。
而上述工具則是這個 Problem Domain 對應的解決方案領域 (Solution Domain)。
Systemd 則是這個 Solution Domain 中最有名的工具之一。
使用 Supervisor 來管理程式 | 《Docker —— 從入門到實踐》正體中文版. Retrieved 2025-12-29, from https://philipzheng.gitbook.io/docker_practice/cases/supervisor ↩
在 Podman 管理多個 container • 鰭狀漏斗. Retrieved 2025-12-29, from https://vrabe.tw/blog/manage-multi-containers-in-podman/ ↩

最近想學 K8s 然後陸續把 Homelab 的服務從 Docker Swarm 移過去,不過一直靜不下心好好讀它,思緒糊成一團,於是想說先把一些已經知道的事情整理一下。
因為我實際上還未實際使用 K8s,所以以下可以當成「對 K8s 的美好幻想」。
簡單紀錄一下幾個讓我想學 K8s 的動機。
前一陣子在鼓搗 Keyclaok,並且外掛了兩個組件:
Keycloak 的插件機制是將 *.jar 檔案放到 /opt/keycloak/providers/ 下,待啟動時被載入。
在 OCI (Open Container Initiative) 佈署下處理這種這整合有兩種方法:
前者缺乏彈性;後者 Docker 不支援,但是 K8s 支援1。
話說回來這幾天在讀 Docker Compose 的 spec 看到這東西(image):
// service.volumes.type
"type": {
"type": "string",
"enum": ["bind", "volume", "tmpfs", "cluster", "npipe", "image"],
"description": "The mount type: bind for mounting host directories, volume for named volumes, tmpfs for temporary filesystems, cluster for cluster volumes, npipe for named pipes, or image for mounting from an image."
},
也許 Docker 也支援了說不定。
depends_onDocker Compose 使用 depends_on 來延遲一些容器的啟動,避免仰賴其他容器的服務過早的啟動,例如:後端嘗試對還沒準備完成的資料庫進行連線。
Docker Swarm 則不支援這個功能,它的邏輯是透過 health check:反正有仰賴的容器連線失敗會 health check 失敗,直接重啟容器直到成功為止。但是這樣的設計無法運行相對複雜的服務,例如:我嘗試在 Swarm 模式下運行 GVM (OpenVas) 並沒有成功過。
而 K8s 則是透過 initContainers 的機制來處理這類需求。
工作時使用雲端的 Serverless 的服務 (GCP Cloud Run),因此我對於自架 (selfhosted) 方案很感興趣,也有找到一些開源方案,不過它們多基於 K8s,例如:
我這裡是指基於 OCI 的 Serverless,非 OCI 的 "Function based" Serverless 方案通常都有緊支援特定程式語言的限制。
前幾天 (2025-12-23) 安裝完 K3s 設定遠端訪問時看到這個:
$ kubectl config view
apiVersion: v1
clusters:
- cluster:
certificate-authority-data: DATA+OMITTED
server: https://127.0.0.1:6443
name: default
contexts:
- context:
cluster: default
user: default
name: default
current-context: default
kind: Config
preferences: {}
users:
- name: default
user:
client-certificate-data: DATA+OMITTED
client-key-data: DATA+OMITTED
雖然知道 certificate-authority-data 大概是為了 HTTPS 的加密機制而準備的,不過我其實不知所以然。
查資料的時候看到這張架構圖:

我注意到 K8s 的組件之間通訊都是加密的,這讓我想到之前透過「Kubernetes The Hard Way」學習 K8s 的經驗。
以及某篇文章,有個老兄抱怨 K8s 難用,伺服器癱瘓的原因似乎是憑證過期,並且它們最後在 AWS 找到了歸屬。
Kubernetes The Hard Way 是一個架設 K8s 的教學,但是不使用現成的安裝精靈,而是手動安裝與配置每一個組件。
去年(2024 年 1 月)我其實試著依照這份指南的步驟嘗試在一台 x86 32 位元筆電上建立 K8s,雖然最後沒有持續下去,但是我隱約記得過程中一直在生成金鑰跟簽署憑證。
我當時有試著將執行過得步驟寫成 Make/Ansible 腳本:
https://github.com/FlySkyPie/k8s-builder-and-installer-for-x86-single-node
作為一個以開發前端業務邏輯為重心的開發者,密碼學相關的基礎概念可以說是很容易被輕忽的部份。或許我該好好的梳理以下對於加密技術的基礎概念作為。
當然,如果我只是要作為 K8s 的使用者,這些知識似乎沒有這麼重要,不過我同時還是要在 Homelab 架設 K8s 的維護者,我不認為紮紮時時的把基礎打好有什麼壞處。
Use an Image Volume With a Pod | Kubernetes. https://kubernetes.io/docs/tasks/configure-pod-container/image-volumes/ ↩
Matt Might 的「圖解博士學位 (The illustrated guide to a Ph.D.)」主要是以視覺化的方式解釋「全人類的知識邊界」與博士學位之間關係。不過我認為這個概念在幫助人理解教育體系本身的設計以及自身的知識邊界非常有幫助。沒有看過得人可以點擊上面的連結 或是這個中文翻譯。
以下我會參考這個概念解釋,加上一點我的個人見解。
“As our circle of knowledge expands, so does the circumference of darkness surrounding it.”
-- 愛因斯坦
用圓圈來形容知識,Matt Might 並不是第一個人,不過視覺化的表達可能遠比名言金句更有力量。現在想像一個圓圈代表全人類知識體系已知的知識,而圓圈外則是未知:

當一個人接受了國民義務教育,它從整個知識體系中均衡的學習到了一點東西:
在 Matt Might 的圖解中,高中是另外一個均勻的知識圈圈,開始出現知識領域專業化是大學系所才發生的事情。不過在我國教育實務上有分成普通高中跟高職;普通高中又有分組(108 課綱之後為班群),而高職則是已經開始進行類似大學系所的專業化學習(理論與實務的差異先不談):
普通高中是綠色的比較均勻圈圈,高職則是以較偏頗的虛線,看過銀之匙(荒川弘)這部作品的讀者可能會對這種差異更有感覺,作品中普通升學體系的主角進入技職體系所受到的衝擊就是來自於兩種體系對於知識邊界預期不太一樣。
如果你繼續留在學術界,完成學士、碩士的訓練;並且持續閱讀論文、學習往特定的知識領域前進;最後在人類的知識邊界挖出一角,那個成果將讓你成為博士。
Matt Might 花比較多篇幅在醞釀這個小角,因為擴展人類知識邊界的博士並不容易,不過這並不是我今天要談的重點,所以容我快速帶過,但是到這裡為止,你應該對「知識邊界」的概念有比較清晰的想像。
另外在我看來,知識邊界拓展的方式從微觀尺度來講,更像是「劈裂」過去的,當每你經歷一個得以觸動的知識點時,知識邊界會以閃電一般的路徑劈裂過去。

觸動的與否取決於該知識點與知識邊界的距離,以及原本「裂痕的方向」有關,這是為什麼同樣的事件、同樣的體驗可以成為某些人的知識點並拓展它的知識邊界,對某些人而言卻只是成為被忘卻的記憶。
專題導向學習 (Project-Based Learning) 似乎是一種教育科學的方法論,並且在我國被提倡也只是最近(108 課綱)的事情而已。而我有幸在成長過程自然的捕捉到這個方法論的脈絡。
「想做遊戲」,於是有了:
但是實際上沒有完成任何遊戲。
「看著 LEGO® NXT 2.0,心想自己造一個」,於是有了:
電機開發平台 (MMFEDP, Modular Multi-Faceted Electrical Develop Platform) 專案,
但是實際上沒有完成任何跟可程式化有關的東西或是足夠實用的減速機。
「看著機器人比賽,對著那種與他人合作打造機器人的憧憬,心想自己造一個」,於是有了:
獨立性無人地面載具 (ITUGV, Independent Task Unmanned Ground Vehicle),
但是實際上沒有實現遠端遙控的機器人。
「YouTube 的『homemad』影片往往包含了車銑床之類的加工方式,但是我想自己製作機器人零件,那就用鑄造的好了」,於是有了:
土砲熔爐,
但是實際上沒有用它製作過機器人零件。
「想和同儕分享寫程式的樂趣」,於是有了:
程式蠱,
但是實際上沒有讓其他人參與過。
「想做 2.5D RPG」,於是有了:
但是實際上沒有實現遊戲該有的抽象化與職責分離。
「看著 KSP 遊戲但是無法合法的擁有,心想自己造一個」,於是有了:
但是實際上停留在 2D 模擬,而且沒有更進一步的多組件編輯機制。
「心想開發一個多人連線 FPS」,於是有了:
但是實際上 3D 射擊的部份並不是使用 GPU 繪圖,而連線功能也僅停留在 2D 的實驗性開發 (prototype)。
「想用論壇機制來解決抽象的『自造者銀行』」,於是有了:
但是實際上交易功能並沒有投入使用。
「既然我已經做過熔爐了,這次順便解決廢氣問題好了」,於是有了:
但是實際上廢氣處理系統並沒有得到足夠多的關注與測試。
當我說:
我有「閃亮事物症候群」
不是在開玩笑的。
當我說:
「智者從歷史中學習,愚者從錯誤中學習」,而我是愚者。
不只是說說而已。
每一個 Side Project 其實都源自微不足道的願望,並且乍看之下所有專案都以失敗告終,不過我尚未提起我在當中獲得了什麼:
dlopen 的使用經驗。是的,大部分的專題目標都在我當下的知識邊界之外,這也是為什麼它們大多數都會失敗的原因。
我們選擇在這個十年登上月球,並完成其他的事,不是因為它們很簡單,而是因為它們很困難。
-- 約翰.甘迺迪(John F. Kennedy)
每一個 Side Project 都是對於「博士挑戰人類知識邊界」的微小仿作,對我而言知識不是一個被好像很偉大的人站在教室裡授予的東西,而是在知識邊界之外,透過一個又一個知識點劈開未知獲得的東西。
如此不斷的前進,過個幾年回過頭來看,可能會驚訝於那些曾經遙不可及的目標不知不覺已經處於自己的身後;又或是它已經處於自己當下的知識邊界觸手可及的地方。

這個部份算是上一次的研究筆記漏掉了,這個模組沒什麼特別的,就是一堆放在 Git LFS 的檔案,只是我抽出來單獨放在一個 repository 內。
做這件事的時候,順便參考了幾個開源遊戲,看看它們處理遊戲素材的:

Cayley.js 是一個 Cayley WASM 的再封裝,除了實做了一些東西以外,還提供一些單元測試跟 Benchmark 測試。
處理它的時候算是踩了 Javascript 生態系一個很經典的小坑,原本我打算用 Vitest 取代原本的 AVA 單元測試框架,但是我忘記了 Vite 工具鏈是 ESM 本位主義,因此單元測試會 import Web 版本的 WASM 載入器。
然而 Web 與 Node.js 是屬於兩個不同的 Javascript Runtime,對於「如何載入 WASM」的方式也不同;Web 是使用 fetch 從網路下載 WASM;Node.js 則是使用 fs 從檔案系統讀取 WASM。
使用 Vitest 呼叫 fetch 會觸發 "Not implemented yet" 之類的錯誤訊息。最後是使用 Jest 取代 AVA,慶幸的是已經有人寫了遷移指南1,照著做就完成了。
Benchmark 那邊則是用 tsx 取代 ts-node。
Zod 是一個方便開發者在 Runtime 對 JSON 資料進行 Schema 驗證的函式庫,我其實不確定 Biomes 為什麼要刻意 fork 出一個版本,最明顯的差異是 Biomes 的版本多了這個方法:
z.object().annotate(Symbol.for("some"), true)
並且 Symbol 並不在 Zod 的支援路線上:
Symbols aren't considered literal values, nor can they be simply compared with
===. This was an oversight in Zod 3.2
考慮到這並不屬於 JSON 解析的範疇,想想也合理,不過 Biomes 專案本身已經用下去了,我也不能在不熟悉的專案的情況下貿然移除這個客製化 Zod 轉而使用原本的。Biomes 原本是直接從 GitHub 安裝,我 fork 之後做了兩件事:
/lib) 從 Git 紀錄中清除。
ecs 和 shared 之間有很嚴重的循環仰賴,無奈之下只好用 PNPM 的 workspace 處理。ecs 對 server 有輕微的仰賴,我直接複製那部份的程式碼進入 ecs,在我看來這裡解偶的效益遠比 DRY 還重要。
shared 則是測試的部份對 server 仰賴,仰賴了 Voxeloo WASM loader 的部份,這一點我打算暫時忽略,之後再來處理,畢竟這個部份可以參考 Cayley 的處理方式。
這是我在 shared 上面臨另外一個比較麻煩的問題:

shared 使用 Zod 大量定義 Schema,而一些複雜的 Schema 在編譯 Typescript 型別檔 (.d.ts) 時,無法正常推論,因此必須透過手動聲明的方式來解決:
export const zChallengeCompleteMessage: z.ZodObject<{
kind: z.ZodLiteral<"challenge_complete">;
challengeId: typeof zBiomesId;
}> = z.object({
kind: z.literal("challenge_complete"),
challengeId: zBiomesId,
});
而且看起來跟前面提到的 Symbol 有關...F**k...
Switching from Ava to Jest for TypeScript | by Gant Laborde | Red Shift. Retrieved 2025-12-24, from https://shift.infinite.red/a6dac7d1712f ↩
Migration guide | Zod. Retrieved 2025-12-24, from https://zod.dev/v4/changelog#drops-symbol-support ↩
最近我在研究一個開源專案 (ill-inc/biomes-game),並且在 2025-12-14 算是完成了一個里程碑,我成功重建專案內的素材瀏覽器,將遊戲素材讀出並且播放動畫:

「這個是怎麼幫 Voxel 上動畫的?」一個工程師這個問我。
「我不知道」我回道。
雖然我有瞄過遊戲素材檔案一眼,知道裡面有 .vox 和 JSON 檔,但是我不知道它們在這個專案具體是怎麼組裝起來的。
.vox 這個檔案格式我其實不陌生,早在 2021 年 11 月的時候我就有一篇筆記紀錄關於各種 Voxel 儲存的檔案格式,不過並沒有整理成能夠拿給別人看的程度。於是我想說趁這個機會把東西整理出來發一篇廢文。
.gox (Goxel)
Goxel 是 一個 Voxel 編輯軟體,而 .gox 則是它的專有(專案)格式,檔案格式的 Spec 直接寫在程式碼的註解內:
/*
* File format, version 2:
*
* This is inspired by the png format, where the file consists of a list of
* chunks with different types.
*
* 4 bytes magic string : "GOX "
* 4 bytes version : 2
* List of chunks:
* 4 bytes: type
* 4 bytes: data length
* n bytes: data
* 4 bytes: CRC
*
* The layer can end with a DICT:
* for each entry:
* 4 byte : key size (0 = end of dict)
* n bytes: key
* 4 bytes: value size
* n bytes: value
*
* chunks types:
*
* IMG : a dict of info:
* - box: the image gox.
*
* PREV: a png image for preview.
*
* BL16: a 16^3 block saved as a 64x64 png image.
*
* LAYR: a layer:
* 4 bytes: number of blocks.
* for each block:
* 4 bytes: block index
* 4 bytes: x
* 4 bytes: y
* 4 bytes: z
* 4 bytes: 0
* [DICT]
*
* CAMR: a camera:
* [DICT] containing the following entries:
* name: string
* dist: float
* rot: quaternion
* ofs: offset
* ortho: bool
*
* LIGH: the light:
* [DICT] containing the following entries:
* pitch: radian
* yaw: radian
* intensity: float
*/
順便提一下,我有回報過 Issue,雖然不是我修的;其實不值得炫耀,不過回報 bug 也是 FOSS 的參與方式之一,對吧?

在 介紹這些檔案格式以前,可能需要談談 Ken Silverman 這個人,他是 Build 遊戲引擎 的作者,並且他設計 KVX 檔案格式被用於 Shadow Warrior 和 Blood 兩款遊戲。12
不過對我而言這些資訊並不是我認識他的原因,我是從這個影片得知這號人物存在的:
根據影片的說明,我們可以找到 Voxlap 引擎的說明頁面:
https://advsys.net/ken/voxlap.htm
接著我們可以在網頁中看到它指向一個名為 SLAB6 的工具:
https://advsys.net/ken/download.htm#slab6
這裡有一個 SLAB6 原始碼的備份:
https://github.com/vuolen/slab6-mirror
並且這裡有一個筆記是從 SLAB6 中抽出 slab6.txt 檔案格式的介紹整理而成的:
https://gist.github.com/falkreon/8b873ec6797ffad247375fc73614fd08
該文件提供了 VOX,KVX 和 KV6 三種檔案格式的詳細說明,因此我們可以知道:
注意,這裡的 VOX 不要跟目前真正流行的 .vox (MagicaVoxel) 檔案格式搞混,我稍後會介紹。
為什麼要介紹這個看起來有點古老的格式?因為你依然可以在一些 Voxel 遊戲中看到它的蹤跡,例如: OpenSpades (Ace of Spades 的 Clone)


.vxl 是紅色警戒 2 用於繪製單位模型的格式,具體的檔案結構如以下文件所示:
This document describes the VXL format for storing voxel (volume pixels) models for the game Tiberian Sun by Westwood Studios. 3

Minetest 是一個「很像 Minecraft」的開源遊戲引擎,並且地圖的資料是儲存在 SQLite 中,
CREATE TABLE `blocks` (
`x` INTEGER, `y` INTEGER, `z` INTEGER,
`data` BLOB NOT NULL,
PRIMARY KEY (`x`, `z`, `y`)
);
至於當中的 BLOB 是如何編碼的,則可以在 world_format.md 中找到4。

MagicaVoxel 是一個閉源的免費 Voxel 編輯軟體。
.vox 檔案是 RIFF (Resource Interchange File Format) 風格的格式5,完整的文件和檔案 sample 可以在這裡找到:
https://github.com/ephtracy/voxel-model
它是單色的 voxel 格式,檔案結構如下:
#binvox 1
dim 128 128 128
translate -0.120158 -0.481158 -0.863158
scale 7.24632
<data>
由文本的資訊與二進制的資料構成。
資料則是由數個 word 組成,一個 word 用來描述一段連續的 voxel:
0 or 1 用來表示實體或是空氣1~255 表示前一個 byte 的資料要重複幾次
Qubicle 是一個付費的 Voxel 編輯器。檔案格式的內容可以在它舊的網站找到7。
NBT 是一種 Minecraft 用於儲存資料的數據結構。而序列化的 NBT 則是 SNBT (stringified NBT)。
SNBT 有著類似於 JSON 的結構(與 JSON 並不兼容),比如:
{name1:123,name2:"sometext1",name3:{subname1:456,subname2:"sometext2"}}
從這段程式碼可以更好的理解 NBT 和 SNBT 的關係:8
var tag1;
try {
tag1 = nbtlint.parse(input.value);
} catch (e) {
output.value = e.message;
}
// 這是 NBT
var tag2 = new nbtlint.TagCompound({
Score: new nbtlint.TagInteger(1500),
Pos: new nbtlint.TagList(nbtlint.TagDouble, [
new nbtlint.TagDouble(15.5),
new nbtlint.TagDouble(123),
new nbtlint.TagDouble(-491.77),
]),
SelectedItem: new nbtlint.TagCompound({
id: new nbtlint.TagString("minecraft:diamond_sword"),
}),
});
// 這是 SNBT
// {
// Score: 1500,
// Pos: [15.5d, 123d, -491.77d],
// SelectedItem: {
// id: "minecraft:diamond_sword"
// }
// }
console.log(nbtlint.stringify(tag2, "\t"));
「能夠儲存樹狀結構以及各種資料型別的定義」這個抽象概念本身就是 NBT,不論實作的語言是什麼;不論資料儲存在記憶體還是硬碟上。而 SNBT 就是序列化的 BNT。
以下是四年前 (2021) 寫的筆記,而且我也退坑 Minecraft 一陣子了,不確定最新版本的 Minecraft 是否有所調整。
不同時期(版本)的 Minecraft 使用不盡相同的資料結構來儲存遊戲世界。
一個 Chunk 被定義為 16x16x128 個 Blocks ,一個 Block 消耗 20 bit:
在 Infdev 版本中,一個 Chunk 被定義為 16x16x128 個 Blocks ,一個 Block 消耗 20 bit。10
Ken Silverman's Projects Page. Retrieved 2025-12-24, from https://advsys.net/ken/download.htm#slab6 ↩
RTCM - Files - General Tools - Voxel. Retrieved 2025-12-24, from https://web.archive.org/web/20200706184402/http://www.r-t-c-m.com/knowledge-base/downloads-rtcm/general-tools-voxel/ ↩
VXL_Format.txt. Retrieved 2025-12-24, from http://xhp.xwis.net/documents/VXL_Format.txt ↩
luanti/doc/world_format.md. GitHub. Retrieved 2025-12-24, from https://github.com/luanti-org/luanti/blob/46436248de4d64887f5ee3f1005224495ede895c/doc/world_format.md ↩
MagicaVoxel-file-format-vox.txt. GitHub. Retrieved 2025-12-24, from https://github.com/ephtracy/voxel-model/blob/8044f9eb086216f3485cdaa525a52120d72274e9/MagicaVoxel-file-format-vox.txt ↩
BINVOX voxel file format. Retrieved 2025-12-24, from https://www.patrickmin.com/binvox/binvox.html ↩
Qubicle Binary (QB) | Qubicle 3.0 Documentation. Retrieved 2025-12-24, from https://web.archive.org/web/20250417030951/https://getqubicle.com/qubicle/documentation/docs/file/qb/ ↩
AjaxGb/NBTLint: Quickly and easily validate the stringified NBT format (SNBT) used in Minecraft commands. Retrieved 2025-12-24, from https://github.com/AjaxGb/NBTLint ↩
server_level.dat – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.fandom.com/wiki/Server_level.dat ↩
Java Edition Alpha level format – Official Minecraft Wiki. Retrieved 2021-02-28, from https://minecraft.gamepedia.com/Java_Edition_Alpha_level_format ↩ ↩2
Region file format – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.fandom.com/wiki/Region_file_format ↩
Anvil file format – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.gamepedia.com/Anvil_file_format ↩