Skip to main content

Wei Ji

作為一個網頁前端導向的開發者,接觸 Systemd 的角度較資深一輩的工程師(或正規 CS (Computer Science) 路線出生的人)不太一樣,當前輩們已經在進行「Systemd 大一統」跟「No Systemd」之間的宗教戰爭的時候,我才在 Docker 容器內接觸內接觸第一個行程的問題 (PID 1 Problem)。

這裡紀錄並整理一下自己在這條路上的一些經歷和觀察。

info

正確的術語應該是 OCI (Open Container Initiative) 容器,不過為了方便溝通本文會視情況使用俗稱的 Docker。另一方面,筆者在學習過程確實是先使用 Docker,使用 Docker 以外的 Runtime 其實也只是不久之前的事。

Dockerfile 中的 ENTRYPOINTCMD

實際上 ENTRYPOINT 才決定了第一個行程 (Process) 是誰,CMD 只是後面被帶進去的參數。

有沒有經歷過 docker compose down 但是某個服務 (service) 卻遲遲沒有被關閉,整個過程停頓了一會兒才把整套服務簇釋放掉?

這十之八九是因為 ENTRYPOINT 沒有處理 SIGTERM 的能力造成的,開發環境或許不打緊,但是在生產環境如果有請求 (request) 掛著還沒處理完,然而容器被更新強制關閉的話,帶來的問題是十分致命的。

容器編排 (Container Orchestration)

在 Docker Compose 允許用 YAML 將多個容器組織起來構成服務簇,並且使用 depends_on 來設定仰賴關係並控制容器的啟動順序、restart 來設定重啟策略、healthcheck 判斷容器狀態。

info

一個 service 其實可以設定多個容器(透過 replicas 或是 scale),不過為了簡化描述我在上面並沒有直接指出。

多個 Process 的容器

info

以下不少經驗來自工作上面對的問題,專案細節就不透漏了。

第一次遇到這樣的需求來自業務上的要求:

將調用 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 不只能夠啟動多個程式,還能聲明程式與程式之間的仰賴關係,來依序啟動,甚至區分了「執行一次」和「持續在背景運作」的程式。

info

因為不是本文重點,所以我沒有細談整個過程,大致上就是根據那個 Blender 容器的文件與原始碼找到實作的方式,留下 repo 供參考:

嵌入式 Linux 中的 /etc/init.d/rcS

我曾經在嵌入式 Linux 看到這樣的東西:

$ ls /etc/init.d
rcK rcS S01syslogd S02klogd S02sysctl

當時我也根據需求寫了一個簡單的 S03sdmnt 腳本來掛載 SD 卡。

後來才知道這是根據 System V Init 簡化後的設計。

info

完整的故事跟細節我有寫另外一篇文章描述:

https://flyskypie.github.io/blog/2022-12-03_3ds_linux_javascript_bad_apple/

Systemd 口味的容器編排軟體

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

從領域驅動(Domain-driven)的角度來看這件事

不難發現上述不同的工具都在解決幾個類似的問題:

  • 系統初始化(啟動與關機),如:掛起背景程式。
  • 根據背景程式的仰賴關係依序掛起,如:應用程式仰賴資料庫。
  • 處理背景程式的異常狀態,如:崩潰重啟。

這些問題構成的問題領域 (Problem Domain) 可以被定位為系統初始化 (System Init) 和行程管理 (Process Supervision)。

而上述工具則是這個 Problem Domain 對應的解決方案領域 (Solution Domain)。

Systemd 則是這個 Solution Domain 中最有名的工具之一。

Footnotes

  1. 使用 Supervisor 來管理程式 | 《Docker —— 從入門到實踐》正體中文版. Retrieved 2025-12-29, from https://philipzheng.gitbook.io/docker_practice/cases/supervisor

  2. 在 Podman 管理多個 container • 鰭狀漏斗. Retrieved 2025-12-29, from https://vrabe.tw/blog/manage-multi-containers-in-podman/

Wei Ji

最近想學 K8s 然後陸續把 Homelab 的服務從 Docker Swarm 移過去,不過一直靜不下心好好讀它,思緒糊成一團,於是想說先把一些已經知道的事情整理一下。

動機

info

因為我實際上還未實際使用 K8s,所以以下可以當成「對 K8s 的美好幻想」。

簡單紀錄一下幾個讓我想學 K8s 的動機。

Image Volume

前一陣子在鼓搗 Keyclaok,並且外掛了兩個組件:

Keycloak 的插件機制是將 *.jar 檔案放到 /opt/keycloak/providers/ 下,待啟動時被載入。

在 OCI (Open Container Initiative) 佈署下處理這種這整合有兩種方法:

  • 建置階段:直接在 Dockerfile 用 COPY 完成組裝。
  • 佈署階段:透過 Volume 將 jar 檔案掛載進容器。

前者缺乏彈性;後者 Docker 不支援,但是 K8s 支援1

info

話說回來這幾天在讀 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_on

Docker Compose 使用 depends_on 來延遲一些容器的啟動,避免仰賴其他容器的服務過早的啟動,例如:後端嘗試對還沒準備完成的資料庫進行連線。

Docker Swarm 則不支援這個功能,它的邏輯是透過 health check:反正有仰賴的容器連線失敗會 health check 失敗,直接重啟容器直到成功為止。但是這樣的設計無法運行相對複雜的服務,例如:我嘗試在 Swarm 模式下運行 GVM (OpenVas) 並沒有成功過。

而 K8s 則是透過 initContainers 的機制來處理這類需求。

Serverless

工作時使用雲端的 Serverless 的服務 (GCP Cloud Run),因此我對於自架 (selfhosted) 方案很感興趣,也有找到一些開源方案,不過它們多基於 K8s,例如:

info

我這裡是指基於 OCI 的 Serverless,非 OCI 的 "Function based" Serverless 方案通常都有緊支援特定程式語言的限制。

K3s 與遠端訪問

前幾天 (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 架構圖

查資料的時候看到這張架構圖:

我注意到 K8s 的組件之間通訊都是加密的,這讓我想到之前透過「Kubernetes The Hard Way」學習 K8s 的經驗。

以及某篇文章,有個老兄抱怨 K8s 難用,伺服器癱瘓的原因似乎是憑證過期,並且它們最後在 AWS 找到了歸屬。

Kubernetes The Hard Way

Kubernetes The Hard Way 是一個架設 K8s 的教學,但是不使用現成的安裝精靈,而是手動安裝與配置每一個組件。

去年(2024 年 1 月)我其實試著依照這份指南的步驟嘗試在一台 x86 32 位元筆電上建立 K8s,雖然最後沒有持續下去,但是我隱約記得過程中一直在生成金鑰跟簽署憑證。

info

我當時有試著將執行過得步驟寫成 Make/Ansible 腳本:

https://github.com/FlySkyPie/k8s-builder-and-installer-for-x86-single-node

正確打開 K8s 的方式...難道是密碼學?

作為一個以開發前端業務邏輯為重心的開發者,密碼學相關的基礎概念可以說是很容易被輕忽的部份。或許我該好好的梳理以下對於加密技術的基礎概念作為。

當然,如果我只是要作為 K8s 的使用者,這些知識似乎沒有這麼重要,不過我同時還是要在 Homelab 架設 K8s 的維護者,我不認為紮紮時時的把基礎打好有什麼壞處。

Footnotes

  1. Use an Image Volume With a Pod | Kubernetes. https://kubernetes.io/docs/tasks/configure-pod-container/image-volumes/

Wei Ji

知識邊界

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」,於是有了:

VB.NET 土砲 2.D 遊戲

但是實際上沒有實現遊戲該有的抽象化與職責分離。


「看著 KSP 遊戲但是無法合法的擁有,心想自己造一個」,於是有了:

VB.NET 太空軌道模擬遊戲

但是實際上停留在 2D 模擬,而且沒有更進一步的多組件編輯機制。


「心想開發一個多人連線 FPS」,於是有了:

香巴朵 Online

但是實際上 3D 射擊的部份並不是使用 GPU 繪圖,而連線功能也僅停留在 2D 的實驗性開發 (prototype)。


「想用論壇機制來解決抽象的『自造者銀行』」,於是有了:

C 幣論壇

但是實際上交易功能並沒有投入使用。


「既然我已經做過熔爐了,這次順便解決廢氣問題好了」,於是有了:

畢業專題-熔爐

但是實際上廢氣處理系統並沒有得到足夠多的關注與測試。


當我說:

我有「閃亮事物症候群」

不是在開玩笑的。

當我說:

「智者從歷史中學習,愚者從錯誤中學習」,而我是愚者。

不只是說說而已。

每一個 Side Project 其實都源自微不足道的願望,並且乍看之下所有專案都以失敗告終,不過我尚未提起我在當中獲得了什麼:

  • 「飛貓工作室」:我當過傲慢毫無能力(不論是領導能力或是技術能力)的籌備者;這讓我每次在團體或團隊中手握權力時,不斷提醒著自身與他人之間的關係。
  • 「MMFEDP」、「ITUGV」:我已經成長到不必透過 NXT 2.0 這樣的東西,而是使用 Arduino、樹莓派來開發機電玩具的程度了。
  • 「土砲熔爐」、「畢業專題-熔爐」:它帶給我「無法製造」的無能感是引導我前往材料系的因素之一,如今我已經能夠使用車床、銑床、鉗工、手工電弧焊、3D 列印...等等方式打造我想要的東西。
  • 「程式蠱」:Zero-player game 的概念至今仍然在影響著我學習的方向,實作 2.0 的時候更是直接點開 dlopen 的使用經驗。
  • 「VB.NET *」:雖然現在我已經不使用 VB 了,但是第一次使用參考(指標)以及從靜態記憶體到動態記憶體的過程依然是不可多得的體驗。
  • 「香巴朵 Online」:它對我提供了一個很強烈的 TCP Socket 的記憶點,這讓我在使用諸如 HTTP 其他網路連線機制的時候很有幫助。
  • 「C 幣論壇」:使用 Laravel 的經驗可以說是幫我在後端軟體的開發經驗上打下非常堅硬的基礎。

專題與知識邊界

是的,大部分的專題目標都在我當下的知識邊界之外,這也是為什麼它們大多數都會失敗的原因。

我們選擇在這個十年登上月球,並完成其他的事,不是因為它們很簡單,而是因為它們很困難。

-- 約翰.甘迺迪(John F. Kennedy)

每一個 Side Project 都是對於「博士挑戰人類知識邊界」的微小仿作,對我而言知識不是一個被好像很偉大的人站在教室裡授予的東西,而是在知識邊界之外,透過一個又一個知識點劈開未知獲得的東西。

如此不斷的前進,過個幾年回過頭來看,可能會驚訝於那些曾經遙不可及的目標不知不覺已經處於自己的身後;又或是它已經處於自己當下的知識邊界觸手可及的地方。

Wei Ji

Assets

這個部份算是上一次的研究筆記漏掉了,這個模組沒什麼特別的,就是一堆放在 Git LFS 的檔案,只是我抽出來單獨放在一個 repository 內。

做這件事的時候,順便參考了幾個開源遊戲,看看它們處理遊戲素材的:

Cayley.js

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

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 之後做了兩件事:

  • 將原本應該被 gitignore 的發布路徑 (/lib) 從 Git 紀錄中清除。
  • 將打包結果發布到 NPM。

Shared

ecsshared 之間有很嚴重的循環仰賴,無奈之下只好用 PNPM 的 workspace 處理。ecsserver 有輕微的仰賴,我直接複製那部份的程式碼進入 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...

Footnotes

  1. Switching from Ava to Jest for TypeScript | by Gant Laborde | Red Shift. Retrieved 2025-12-24, from https://shift.infinite.red/a6dac7d1712f

  2. Migration guide | Zod. Retrieved 2025-12-24, from https://zod.dev/v4/changelog#drops-symbol-support

Wei Ji

前情提要

最近我在研究一個開源專案 (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 的參與方式之一,對吧?

KVX, KV6 (Ken Silverman's Voxel file)

在介紹這些檔案格式以前,可能需要談談 Ken Silverman 這個人,他是 Build 遊戲引擎 的作者,並且他設計 KVX 檔案格式被用於 Shadow WarriorBlood 兩款遊戲。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: 無壓縮的三維 RGB 資料。
  • KVX: Ken Silverman 比較早期 (1995 年) 設計的 Voxel 格式。
  • KV6: 在 Ken Silverman 比較後期 (2020 年) 設計的格式,原本屬於 SLAB6 這個軟體的一部分。
info

注意,這裡的 VOX 不要跟目前真正流行的 .vox (MagicaVoxel) 檔案格式搞混,我稍後會介紹。

為什麼要介紹這個看起來有點古老的格式?因為你依然可以在一些 Voxel 遊戲中看到它的蹤跡,例如: OpenSpades (Ace of Spades 的 Clone)

VXL

.vxl 是紅色警戒 2 用於繪製單位模型的格式,具體的檔案結構如以下文件所示:

This document describes the VXL format for storing voxel (volume pixels) models for the game Tiberian Sun by Westwood Studios. 3

Luanti (Minetest)

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

Vox (MagicaVoxel)

MagicaVoxel 是一個閉源的免費 Voxel 編輯軟體。

.vox 檔案是 RIFF (Resource Interchange File Format) 風格的格式5,完整的文件和檔案 sample 可以在這裡找到:

https://github.com/ephtracy/voxel-model

BINVOX6

它是單色的 voxel 格式,檔案結構如下:

#binvox 1
dim 128 128 128
translate -0.120158 -0.481158 -0.863158
scale 7.24632
<data>

由文本的資訊與二進制的資料構成。

資料則是由數個 word 組成,一個 word 用來描述一段連續的 voxel:

  1. 0 or 1 用來表示實體或是空氣
  2. 1~255 表示前一個 byte 的資料要重複幾次

.qb (Qubicle Binary)

Qubicle 是一個付費的 Voxel 編輯器。檔案格式的內容可以在它舊的網站找到7

Minecraft 地圖格式

Named Binary Tag (NBT)

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。

不同版本的地圖資料

info

以下是四年前 (2021) 寫的筆記,而且我也退坑 Minecraft 一陣子了,不確定最新版本的 Minecraft 是否有所調整。

不同時期(版本)的 Minecraft 使用不盡相同的資料結構來儲存遊戲世界。

Server_level.dat9

  • 單一檔案
  • gzip 壓縮
  • 使用於 Classic

Java_Edition_Alpha_level_format10

  • 複數個檔案
  • 使用 NBT
  • 使用資料夾結構區分 chunk
  • GZip 壓縮
  • 使用於 Infdev 、 Alpha 和部份版本的 Beta

一個 Chunk 被定義為 16x16x128 個 Blocks ,一個 Block 消耗 20 bit:

  • ID: 8 bits
  • Data: 4 bits
  • Light: 4 bits
  • SkyLight: 4 bits

在 Infdev 版本中,一個 Chunk 被定義為 16x16x128 個 Blocks ,一個 Block 消耗 20 bit。10

Region file format 11

  • 複數個檔案
  • 使用 NBT
  • 使用檔案名稱區分 chunk (r.x.z.mcr)
  • GZip 或 Zlib 壓縮
  • 開始使用於 Beta 1.3

Anvil File Format 12

  • 複數個檔案
  • 使用 NBT
  • 使用檔案名稱區分 chunk (a.x,z.mca)
  • GZip 或 Zlib 壓縮
  • 開始使用於 Java Edition 1.2.1
  • 格式大致跟 Region file format 相同,只有描述 chunk 的 NBT 與部份 label 有進行調整。

Footnotes

  1. Ken Silverman's Projects Page. Retrieved 2025-12-24, from https://advsys.net/ken/download.htm#slab6

  2. 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/

  3. VXL_Format.txt. Retrieved 2025-12-24, from http://xhp.xwis.net/documents/VXL_Format.txt

  4. luanti/doc/world_format.md. GitHub. Retrieved 2025-12-24, from https://github.com/luanti-org/luanti/blob/46436248de4d64887f5ee3f1005224495ede895c/doc/world_format.md

  5. MagicaVoxel-file-format-vox.txt. GitHub. Retrieved 2025-12-24, from https://github.com/ephtracy/voxel-model/blob/8044f9eb086216f3485cdaa525a52120d72274e9/MagicaVoxel-file-format-vox.txt

  6. BINVOX voxel file format. Retrieved 2025-12-24, from https://www.patrickmin.com/binvox/binvox.html

  7. 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/

  8. 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

  9. server_level.dat – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.fandom.com/wiki/Server_level.dat

  10. Java Edition Alpha level format – Official Minecraft Wiki. Retrieved 2021-02-28, from https://minecraft.gamepedia.com/Java_Edition_Alpha_level_format 2

  11. Region file format – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.fandom.com/wiki/Region_file_format

  12. Anvil file format – Minecraft Wiki. Retrieved 2025-12-24, from https://minecraft.gamepedia.com/Anvil_file_format

Wei Ji

Galois

上一篇已經說明過 Viewer/Editor 是以 Electron 實做,並且和一個副程式透過 stdio 通訊。

經過研究發現該副程式的工作依然是採取 request-response 模式,並沒有太複雜的非同步通訊行為,因此我把處理副程式的實做改到 Nest.js 建立的 API 伺服器內,並且把原本用 Electron 呈現的 Viewer/Editor 重構成單純的 Web 應用程式。

呈現結果如下:

viewer

editor

Editor 給我的感覺有點失望,因為它實際上並沒有編輯的能力,只是畫面更完整一點的 Viewer,並且 Block 類的素材是沒辦法讀取的,這方面的問題預計暫時先跳過,不過已知的資訊有:

  • Trace 到 voxeloo 實做,看起來是單純的斷言失敗,而失敗的原因看起來是缺少 texture 參數。
  • Viewer 那邊是可以渲染方塊的,只是兩邊的 query 不太一樣。

循環仰賴

Galois 和 shared 之間原本有循環仰賴的問題,我試著移除一些看起來不是核心功能的東西,並且把真的消除不掉的仰賴直接從 shared 內抽出來直接放進 Galois 內。

Wei Ji
info

重構 Biomes 過程遇到的問題,涉及比較技術細節的故障排除所以額外發一篇文分開講。

原始錯誤

Exception occurred during build:
Traceback (most recent call last):
File "impl/materializers.py", line 1783, in materialize
File "impl/materializers.py", line 716, in materialize_ToIcon_Flora
File "impl/vox.py", line 739, in iconify_voxel_array
File "impl/render_voxels.py", line 111, in render_map
ModuleNotFoundError: No module named 'numpy.core.multiarray'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
File "build.py", line 65, in exec_program
File "impl/lru_cache_by_hash.py", line 30, in set_default
File "impl/lru_cache_by_hash.py", line 24, in make_value_cached
File "build.py", line 66, in <lambda>
File "impl/materializers.py", line 1785, in materialize
impl.types.MaterializationError: Error materializing node: DerivedNode(kind='ToIcon_Flora', deps=[Flora:0x1fc2cb2e4002fa67], dep_hashes=['57036fed7713db64e4fb917e35d1c6cc5436883b'])

執行上下文

整個呼叫鏈是從 HTTP request 觸發,在 Nest.js 內部 spawn 一個副程式, 而該副程式是透過 pyinstaller 打包的 Python 程式, 並且該程式又 import 了另外一個透過 pybind11 打包的 C++ (實作的)動態函式庫。

出現問題的 render_voxels.py 則是「透過 pyinstaller 打包的 Python 程式」當中的一部分。

解決方法

build.py 所在的專案改變 numpy 的版本:

// pyproject.toml

- "numpy>=1.26.0",
+ "numpy==1.26.4",

直後重新用 pyinstaller 打包一份新的執行檔。

推論過程

查資料的過程會發現常見的錯誤訊息跟我的有一點點不同,不是 numpy.core.multiarray 而是 numpy._core.multiarray1

E DeprecationWarning: numpy.core.multiarray is deprecated and has been renamed to numpy._core.multiarray. The numpy._core namespace contains private NumPy internals and its use is discouraged, as NumPy internals can change without warning in any release. In practice, most real-world usage of numpy.core is to access functionality in the public NumPy API. If that is the case, use the public NumPy API. If not, you are using NumPy internals. If you would still like to access an internal attribute, use numpy._core.multiarray._ARRAY_API.

於是這個問題可能是來自 numpy 的破壞性更新。

確認一下 Biomes 專案下 requirements.txt 使用的版本:numpy==1.24.2

檢查一下自己的專案配置使用 "numpy>=1.26.0,並且 lock 檔顯示實際安裝 2.3.5,到這邊為止幾乎可以確定是 numpy 版本的問題,不過以防萬一還是做更嚴謹的驗證。

如果我們根據錯誤訊息 File "impl/render_voxels.py", line 111, in render_map 可以找到:

    proj = rays.render_orthographic_color(
cm=cm,
normals=dense_normals,
size=size,
src=src_after_padding,
dir=dir,
lighting_dir=lighting_dir,
up=up,
far=1000.0,
zoom=zoom,
distance_capacity=0.5,
)

rays.render_orthographic_color 的實做實際是在 C++ 內完成的,現在我們回去看一下 C++ 實作:

#include <pybind11/numpy.h>
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
// ...
auto render_orthographic_color(
const ColorMap& cm,
const py::array_t<float>& normals,
Vec2i size,
Vec3f src,
Vec3f dir,
Vec3f lighting_dir,
Vec3f up = {0.0f, -1.0f, 0.0f},
float far = 100.0f,
float zoom = 1.0f,
float distance_capacity = 0.5f) {
auto [w, h] = size;
Vec3f z_dir = normalized(dir);
Vec3f x_dir = normalized(cross(z_dir, up));
Vec3f y_dir = normalized(cross(x_dir, z_dir));

auto ret = py::array_t<float>({h, w, 4});
auto acc = ret.mutable_unchecked<3>();
auto inv_zoom = 1.0f / zoom;

for (int i = 0; i < h; i += 1) {
auto x_shift = 0.5f * (w - 1) * x_dir;
auto y_shift = (0.5f * (h - 1) - i) * y_dir;
auto pos = src - inv_zoom * (x_shift - y_shift);
for (int j = 0; j < w; j += 1) {
auto val = integrate_color(
cm, normals, pos, z_dir, lighting_dir, far, distance_capacity);
acc(i, j, 0) = val.x;
acc(i, j, 1) = val.y;
acc(i, j, 2) = val.z;
acc(i, j, 3) = val.w;
pos += x_dir * inv_zoom;
}
}

return ret;
}

因此我們知道這個試圖 import numpy.core.multiarray 的 numpy 實際上應該是 pybind11 提供的,確認一下我們正在使用哪個版本:pybind/pybind11@2.9.2

README.md 說得很清楚了2

Integrated NumPy support (NumPy 2 requires pybind11 2.12+)

Footnotes

  1. MAINT: NumPy _ARRAY_API import deprecation warning · Issue #4886 · pybind/pybind11. Retrieved 2025-12-13, from https://github.com/pybind/pybind11/issues/4886

  2. pybind/pybind11: Seamless operability between C++11 and Python. Retrieved 2025-12-13, from https://github.com/pybind/pybind11

Wei Ji

Galois Viewer

viewer 是一個 Electron 實做。server 則是一個薄封裝,實作了簡單的佇列,並且會從 Python 那邊的實作 (build.py) spawn 一個 process。

從資料流的角度則如下圖所示:

spawn 出來的 process 是透過 FD (File descriptor) 3 和 4 跟 Electron 的後端※溝通。而兩者傳輸的資料格式則是使用 base64 編碼過得 JSON,而 JSON 的 sechma 可以在 biomes-aql-utils 裡面找到。

info

※更精確的說法是 main process,與之相對的瀏覽器端則是 renderer process,這裡選擇使用一個對 Electron 不熟悉的人應該也比較好理解的用詞。

Error: spawn /bin/sh ENOENT at Process.ChildProcess

目前遇到的主要障礙是以下錯誤訊息:

Error: A JavaScript error occurred in the main process

Uncaught Exception: Error: spawn /bin/sh ENOENT at Process.ChildProcess._handle.onexit (node:internal/child_process:282:19) at onErrorNT (node:internal/child_process:477:16) at processTicksAndRejections (node:internal/process/task_queues:83:21)
Uncaught Exception: Error: write EPIPE at afterWriteDispatched (node:internal/stream_base_commons:164:15) at writeGeneric (node:internal/stream_base_commons:155:3) at Socket._writeGeneric (node:net:780:11) at Socket._write (node:net:792:8) at writeOrBuffer (node:internal/streams/writable:389:12) at _write (node:internal/streams/writable:330:10) at Socket.Writable._write (node:internal/streams/writable:334:10) at BatchAssetServer.send (/home/flyskypie/Desktop/2025-10-22_biomes-game_research/2025-11-16_galois-extract/galois-fe-investigation/js/server/dist/server.cjs:93:30) at /home/flyskypie/Desktop/2025-10-22_biomes-game_research/2025-11-16_galois-extract/galois-fe-investigation/js/server/dist/server.cjs:110:9 at processTicksAndRejections (node:internal/process/task_queues:96:5)

目前盲猜是因為 spawn 發生在 Electron 內,在 Linux 上應屬於某種沙盒環境,因此「有 shell 的 spawn」沒辦法正常運作。

我不是很想處理 Electron 打包機制與虛擬目錄引入的複雜性,之後應該會把架構改寫成單純的 Web 前端跟 HTTP API 伺服器。

Wei Ji
  • 我最喜歡的資料結構:Graph
  • 我最喜歡的演算法:Perlin noise
  • 我最喜歡的語言:ECMAScript
  • 我最喜歡的物理現象:穿隧效應、絕緣破壞
  • 我最喜歡的社會哲學概念:巨靈論(Leviathan, Thomas Hobbe, 1651)
  • 我最喜歡的設計模式:Pipeline
  • 我最有興趣的軟體架構:ECS (Entity–component–system)
  • 最喜歡的 RFC:RFC 2119
  • 最喜歡的 ISO:ISO 8601
  • 最喜歡的 Desktop:Xfce
  • 最喜歡的 CSS 開發模式:CSS Module
  • 最喜歡的金屬:鋁
  • 最喜歡的筆記軟體:CodiMD
  • 最喜歡的歌:To Be Human (MARINA)
  • 最喜歡的影集:太空無垠 (The Expanse)
  • 最喜歡的輕小說:小書痴的下剋上 (本好きの下剋上)

Wei Ji

最近在研究 Biomes 的程式碼看到這個:

    this.process = spawn(
buildCommand,
["batch", `--workspace="${dataDir}"`, "--ignore_sigint"].concat(
additionalArgs
),
{
cwd: execDir,
stdio: ["ignore", "inherit", "inherit", "pipe", "pipe"],
shell: true,
windowsHide: true,
}
);

恩?就算不看文件也猜得到 stdio 前三個是 stdinstdoutstderr,但是後面兩個是怎麼回事?

點進去看型別會看到1

        readonly stdio: [
Writable | null,
// stdin
Readable | null,
// stdout
Readable | null,
// stderr
Readable | Writable | null | undefined,
// extra
Readable | Writable | null | undefined, // extra
];

在 Node.js 的官方文件則只有稍稍帶過2

// Open an extra fd=4, to interact with programs presenting a
// startd-style interface.
spawn('prg', [], { stdio: ['pipe', null, null, null, 'pipe'] });

這個用法我不太熟悉,於是稍微往下追究。

File Descriptor (Python 被呼叫端)

如果直接執行該指令會得到錯誤:

$ ./build \
batch \
--workspace="${PWD}/data" \
--ignore_sigint

Traceback (most recent call last):
File "build.py", line 161, in <module>
File "build.py", line 115, in build_batch
File "<frozen os>", line 1037, in fdopen
OSError: [Errno 9] Bad file descriptor
[PYI-453501:ERROR] Failed to execute script 'build' due to unhandled exception!

回去看實做的地方則是會看到:

    infile = os.fdopen(3, "r")
outfile = os.fdopen(4, "w")

再找一下範例程式3

# Import os Library
import os

# Open file
fd = os.open("test.txt", os.O_RDWR|os.O_CREAT)

# Get a file object for the file
fo = os.fdopen(fd, "w+")

# Write something on open file
fo.write( "This is a test content for w3schools")

# Close file
fo.close()

好吧,我還是不太清楚,這個 fd 到底是什麼,看起來是某種檔案有關的 id,但是不知道為什麼會跟 stdio 扯上關係。

File Descriptor (System call)

嗯...這個 os.openos.fdopen 是用整數在操作,而不是實例或物件之類的,看起來很底層,

讓我翻一下 System call 的資料456

int open(const char* path, int oflag, /* mode_t mode */...);

ssize_t read(int fd, void* buf, size_t count);

ssize_t write(int fildes, const void* buf, size_t nbyte);

File Descriptor (Unix/Linux User space)

上面簡單介紹了User space和Kernel space,這對於理解fd有很大的幫助。fd會存在,就是因為使用者程序無法直接訪問硬體,因此當程序向核心發起system call打開一個檔案時,在使用者處理程序中必須有一個東西標識著打開的檔案,這個東西就是fd。7

file descriptor 和 file 之間的關係並不是一對一的。8

Process

寫一個簡單的 Node.js 程式 spawn 這個程式把它掛起來之後,用 pstree 找到 PID 之後調查一下:

$ pwd
/proc/435759

$ cat cmdline | xargs -n1 --null
./build
batch
--workspace=/some/where/data
--ignore_sigint

$ ll fd
total 0
dr-x------ 2 flyskypie flyskypie 5 Dec 6 20:34 ./
dr-xr-xr-x 9 flyskypie flyskypie 0 Dec 6 20:26 ../
lr-x------ 1 flyskypie flyskypie 64 Dec 6 20:34 0 -> /dev/null
lrwx------ 1 flyskypie flyskypie 64 Dec 6 20:34 1 -> /dev/pts/15
lrwx------ 1 flyskypie flyskypie 64 Dec 6 20:34 2 -> /dev/pts/15
lrwx------ 1 flyskypie flyskypie 64 Dec 6 20:34 3 -> 'socket:[1744182]'
lrwx------ 1 flyskypie flyskypie 64 Dec 6 20:34 4 -> 'socket:[1744184]'

在檔案系統中,每個磁碟有多個分區,每個分區有多個柱面組,每個柱面組有一個inode陣列。上面的數字2248868代表inode的一個編號,稱為inode編號。可以用來唯一標識改該檔案,也可以定位inode在磁碟中的位置。在socketfs虛擬檔案系統中,socket對應的inode並不像普通的檔案系統,位於磁碟上,而是位於核心的socket結構中(記憶體)。9

結論

"一切皆是文件" 是 Unix/Linux 的核心哲學之一,即便是 stdin, stdout, stderr 這些標準的 IO (stdio, standard input/output),實際上跟「打開檔案進行讀寫」的行為處於一套相同的體系:File Descriptor。

File Descriptor 是作業系統發給 Process 的整數 ID;stdin, stdout, stderr 固定為 0, 1, 2。透過 open 之類的操作界面 (System call),系統會另外分配 ID 並且再連結到對應要進行讀寫的檔案。

Node.js 在 Spawn 的時候可以在 stdin, stdout, stderr 以外再額外指定兩條管道,它們在 process 內的 File Descriptor 為 3 和 4。

Footnotes

  1. DefinitelyTyped/types/node/child_process.d.ts. GitHub. Retrieved 2025-12-06, from https://github.com/DefinitelyTyped/DefinitelyTyped/blob/e70e0eca2af7ad212c893ee94bbcb1ba33b4dd3d/types/node/child_process.d.ts#L181-L191

  2. Child process | Node.js v25.2.1 Documentation. Retrieved 2025-12-06, from https://nodejs.org/api/child_process.html#optionsstdio

  3. Python os.fdopen(). W3Schools Online Web Tutorials. Retrieved 2025-12-06, from https://www.w3schools.com/python/ref_os_fdopen.asp

  4. open (system call) - Wikipedia. https://en.wikipedia.org/wiki/Open_(system_call)

  5. read (system call) - Wikipedia. https://en.wikipedia.org/wiki/Read_(system_call)

  6. write (system call) - Wikipedia. https://en.wikipedia.org/wiki/Write_(system_call)

  7. 理解linux中的file descriptor(檔案描述符) | Bigbyto. https://wiyi.org/linux-file-descriptor.html

  8. Linux 的 file descriptor 筆記 - Kakashi's Blog. https://kkc.github.io/2020/08/22/file-descriptor/

  9. linux /proc/[pid]/fd 中socket描述符後面的數字是什麼意思?inode(information node)_proc fd socket-CSDN部落格. https://blog.csdn.net/Dontla/article/details/124854177