中文 | English
箱子物品「延遲反序列化(不急著把資料拆解成遊戲內物件,拖到真的要用才拆)+ 沒碰過就原樣寫回」的 Java agent。 針對 Paper 26.2,把 chunk(遊戲世界切成一塊一塊的地圖區域,伺服器以此為單位載入/卸載)載入時「立刻把每個箱子的物品從 NBT(Minecraft 儲存物品/方塊資料的二進位格式)解包」與卸載時「重新打包」這兩筆白工砍掉。
🧠 經 Claude Fable 5 對抗審計(49 個獨立 agent 分工找碴+交叉反駁,詳見 FABLE5-AUDIT.md):14 條發現全數逐一核實、0 條被推翻;確認記憶體有界不洩漏、存檔淨省不做白工、正常玩家操作零掉物風險;找到並已修復一個需要管理員指令才會踩到的邊角漏洞(容器複製/直接改存檔資料時可能共用到同一份資料)。
-javaagent: 掛在 JVM 上,不要丟 plugins/(丟了沒用)。
🔒 版本敏感(務必先讀) 本 agent 以 bytecode 直接織入 Paper 26.2 / Java 25 的內部類別(template classfile major 69),屬版本綁死的工具。
- 僅適用於 Paper 26.2 + Java 25。 任何其他 Minecraft 版本或 Java 版本,一律不要直接套用。
- 換版必須:① 以對應版本的 NMS 重新編譯
template/、② 將 ASM 升級到能解析目標 classfile 版本、③ 重新以 shadow 模式驗證。- 版本不符時會在開機或首次載入箱子時直接拋出例外(
VerifyError/NoSuchMethodError)。這是刻意的「安全停機」設計——絕不會靜默改壞或弄丟資料,但該節點會無法啟動,因此務必先在測試環境驗證再上線。- 測試素材(region / 物品 dump)為目標版格式,請勿在其他版本載入。
- 26.2 實機測試報告:
docs/test-reports/26.2.md。
前提:Paper 26.2 + Java 25(其他版本請先看上面的「版本敏感」)。
1. 放 jar —— 把 LazyContainerAgent.jar 放到節點看得到的位置(跟你的伺服器 jar 放同一層最省事)。不要丟進 plugins/:它是 Java agent、不是外掛,丟了沒作用。
2. 改啟動參數 —— 在 java 那行、-jar 的前面,加上以下幾段(第一次請先用 shadow 驗證模式):
java ... \
-javaagent:LazyContainerAgent.jar \
-Dlazycontainer.shadow=true \
-Dlazycontainer.verbose=true \
-jar <你的 Paper>.jar nogui3. 先驗證,別急著上真效能 —— 開著 shadow=true 跑個幾天。它會把優化後的輸出跟原版做法逐位元組對照:只要 shadowMismatch 一直是 0,就代表輸出跟原版完全一致、資料零風險。代價是這階段兩套都做、暫時不會變快。
- 開機 log 應出現
[LazyContainer] agent installed … [SHADOW mode]。- verbose 每隔一段印一行
stash=… rawSave=… shadowMismatch=0 …;stash持續往上爬 = 正在運作。
4. 確認沒問題,再換真效能 —— 跑數天 shadowMismatch=0、也沒玩家回報少東西,就把 -Dlazycontainer.shadow=true 拿掉、重啟。這時「沒人碰過的箱子」會直接原樣寫回(跳過打包),效能才真正省下來。
回滾 —— 把那幾段 -D 與 -javaagent 拿掉、重啟,立刻回 100% 原版,不需要任何資料遷移(硬碟格式從頭到尾沒被改過)。
開伺服器開久了,你大概都會撞上一種很微妙的卡頓:
明明沒什麼人在線上,主執行緒卻莫名其妙地忙。
抓 spark 一看,真兇往往不是怪、不是紅石——是箱子。
更精確一點,是「箱子裡的東西」。Minecraft 把物品存在硬碟上時是壓縮打包的;每當一塊地圖(chunk)被載進記憶體,伺服器就把那一區每一個箱子、每一格物品,從 NBT 完整拆包一遍;這塊地圖要卸載時,又整批重新打包寫回去。
問題是——那些箱子,絕大多數從載入到卸載,根本沒人去開。
拆了、又包回去,中間沒人看一眼。純白工。
而且 1.21 之後物品帶了 data components(附魔、lore、自訂名稱、容器內容…),拆包打包更貴。一座放滿地圖畫的倉庫、一條塞滿界伏盒的儲存線,光是「被載入」這件事,就能把主執行緒吃掉好幾成——在廢土(約 110 個 Paper 節點)的正式環境裡,負載最重的節點一度有 45% 的主執行緒,就卡在這一條鏈上:
ChunkFullTask.run → … → ChestBlockEntity.loadAdditional
→ ContainerHelper.loadAllItems ← 拆包箱子物品 ≈ 45%
面對這種卡頓,最輕鬆的解法是禁止——限制每個箱子能放幾張地圖、叫大家別蓋大倉庫。但這就像為了省電把冰箱拔掉:LAG 是不見了,玩家的東西也跟著不見了。我一向不信這套——能用技術克服的,就不該用規則去閹割玩法。
所以這個 agent 做的事,白話講就一句:
沒人要看的箱子,別急著拆;沒人動過的箱子,就原封不動地放回去。
載入時,先把箱子的原始資料收著、先不拆;真的有人去開、漏斗去抽、比較器去讀,才當場拆那一個。從載入到卸載都沒人碰的,就把當初收著的那包原始 bytes 逐位元組原樣寫回——完全跳過重新打包。
對玩家來說,箱子裡裝什麼、擺在第幾格,一模一樣,你驗證不出任何差別。差別只在伺服器:那一大批「拆了又包、卻根本沒人看」的白工,消失了。
vanilla 載入一個放滿地圖畫/唱片的箱子,光把物品從 NBT 解出來,呼叫堆疊就深到 ~206 層——因為資料是真的巢狀(箱子 → 界伏盒 → 地圖畫 → lore → 不同顏色文字)再乘上 Mojang codec 框架每層疊 15-20 個 frame。最底層那一行只是在 TextColor.parseColor(解析 lore 顏色)/ String.equals(比對欄位名)。
.lctest 同一塊密集容器 chunk、forceload churn、跑兩輪:
| Run | 模式 | spark | 容器解碼佔主執行緒 | profile 節點數 | 最深呼叫 |
|---|---|---|---|---|---|
| 1 | vanilla | WOVkupfiJx |
62.17% | 3378 | 200 層 |
| 1 | agent | wGDbUTbZKN |
0.00% | 482 | 9 層 |
| 2 | vanilla | AjXLAdXzTd |
65.65% | 4305 | 200 層 |
| 2 | agent | caXFofKSVQ |
0.00% | 763 | 36 層 |
整座解碼塔在 agent profile 直接消失。4 份 spark 原始檔(已靜態存檔避免連結失效)+ 完整 206 行鏈 + 說明:docs/spark/。
⚠️ 62~66% 是「容器解碼單獨隔離」的壓力測;真實混合負載下佔比為載入 ~24% + 卸載 ~11%(負載最重的節點可達 ~45%)。省的是解/打包 CPU,不省 I/O / GC。
以下按真實發生順序走一遍。括號裡是程式碼位置,想自己查證用。
現行版本是 26.2-2(26.2-9 是同一份程式、只換版本字串)。
硬碟上的區塊檔先被核心解壓、解析成一棵 NBT 樹。這一步全是核心做的,跟本 agent 無關。 接著核心替樹上每個方塊實體(附在方塊上、替它存額外資料的物件,箱子就是一種)建出 Java 物件, 並呼叫它的載入方法把資料填進去。
原版在這裡會把「Items 清單」整份解碼:每一格都要查物品登錄表、解出附魔耐久等屬性、建出物品物件。 一格倉庫幾百個箱子,這筆錢在 chunk 載入當下一次付清。
本 agent 把這個呼叫改掉了(lazycontainer$load,template:141)。它做三件事,順序是固定的:
- 把「Items」那棵子樹原封序列化成 bytes 存起來(26.2-10 起是每段 ≤256 KB 的分段,不再是一整塊大陣列)。整棵 chunk 樹隨後可以被回收,記憶體只留這些 bytes。
- 趁樹還在手上,順便算一張摘要(下面第 ④ 步會用)。
- 最後才把「尚未解碼」的旗標打開。
省下的:整格箱子的物品解碼,一個都沒做。
沒省的:硬碟 I/O、解壓、NBT 解析,那些是核心的工作。
退路:輸入不是預期的型別、或序列化出了任何意外,整個退回原版的立即解碼(計在 eagerLoad,正常恆為 0)。
箱子就這樣躺著。記憶體裡是一段 bytes 加一張摘要,沒有物品物件。 這是整個 agent 的全部收益來源:倉庫區大部分箱子,從載入到卸載沒有任何人真的需要看它的內容。
漏斗運作時會不斷問旁邊的容器兩件事:你滿了嗎(滿了我就不推)、這一格有東西可以拿嗎。 原版回答這兩個問題要把整箱物品解出來看。如果為了回答就解碼,懶載入等於白做。
所以漏斗的這兩個檢查點也被攔了(isFullContainer 與 tryTakeInItemFromSlot,transformer:242/246),
改成先問那張摘要(lazycontainer$fullState / slotEmpty,template:582/595)。摘要在載入時就算好了,查詢只是讀幾個欄位:
- 回答**「證明全滿」或「證明不滿」** → 漏斗直接用,零解碼。
- 回答**「不知道」** → 退回原版做法,該解就解。
什麼時候會說不知道:物品堆疊上限要看物品屬性才知道、遇到本工具不敢斷言的資料、或還沒開封的戰利品箱(刻意不答)。
摘要只會少答,不會答錯。這件事有專門的差分測試守著:同一份資料餵給摘要和真正的解碼器,
只要摘要開口,結論就必須跟真解碼一致(tests/SummaryDifferentialTest.java)。
雙箱兩半各查,任一半證明不滿就是不滿、兩半都滿才算滿(template:457)。
log 裡的 fullQ= 四個數字就是「證明滿 / 證明不滿 / 答不出來 / 整份放棄」的分佈。
玩家開箱、漏斗真的要搬、外掛讀內容、比較器要算訊號、指令改資料 —— 這些最後都會走到取物品清單那個方法。
那個入口被插了一行檢查:還沒解碼就先解(getItems / getContents 的 guard,transformer:307)。
解碼本身(lazycontainer$ensure,template:239)在鎖裡面做:bytes 解回 NBT 樹 → 跑原版物品解碼 → 逐格填進清單
→ 丟掉 bytes → 最後才把旗標關掉。
每個箱子每次載入最多解一次。 解完之後,這個箱子的一切行為跟沒裝 agent 完全相同。 已解碼箱子的取用成本是「一個 volatile 讀」,不進鎖。
順便記帳:解一次要多久、是誰害的(漏斗 / 比較器 / 玩家 / 商店外掛 / 存檔 / 破壞 / 原版 / 其他外掛),
超過 100 毫秒的還會印座標。這些是 log 裡的 decodeMs decodeMaxMs decodeHist 與 attr* 各桶。
有些路徑會把整個物品清單換掉(例如指令改資料、外掛重載方塊狀態)。
那些入口會把旗標和 bytes 一起清掉(lazycontainer$clear,template:312),之後就是純原版行為。
這條路是 26.2-2 修掉「物品原地復活」那個複製漏洞的地方,不要動。
存檔分兩步,這件事值得講清楚:
- 第一步(遊戲執行緒):核心把這個 chunk 的現況整理成一份「不會再變的副本」。 它會逐一向每個方塊實體要一份 NBT。因為遊戲還在跑,寫硬碟的人必須拿到一份定格的資料。
- 第二步(寫盤執行緒):副本被序列化、壓縮、寫進區塊檔。這一步早就不在遊戲執行緒上,硬碟快慢影響不到遊戲。
本 agent 只出現在第一步,也就是「核心來要 NBT」的那一刻(lazycontainer$save,template:327):
- 從頭到尾沒人碰過的箱子 → 把那段 bytes 解析成 NBT 樹交出去(
lazycontainer$trySaveRaw,template:350)。 這裡只做 NBT 解析,不跑物品解碼:不查物品登錄表、不建物品物件、不重新打包屬性。 每次存檔都解一棵全新的私有樹,所以存檔輸出和記憶體裡的狀態不會共用同一份資料。計在rawSave。 - 已經被碰過的箱子 → 走原版的打包流程,一步都不省。
- 界伏盒的空清單有特別規則(原版會把空的 Items 整個丟掉),遇到就退回原版處理。
整段讀旗標、讀 bytes、寫出去都在同一把鎖裡,跟進行中的解碼互斥。 這是 26.2-2 修掉的另一個真實 bug:舊版在鎖外讀旗標,解碼解到一半時存檔,會把半填的清單寫進硬碟。
寫進硬碟的內容和原版差在哪:結構完全相同,唯一可能不同的是 NBT compound 內部的 key 排列順序, 那個順序本來就由 Paper 的雜湊表決定,不是資料。
存完之後 chunk 物件被丟掉,那段 bytes 跟著一起被回收。沒有跨 chunk 的快取,沒有東西留在記憶體裡。
多執行緒核心(Folia / EndRod 這類把世界切成多個 region 各自跑的核心)上,插件執行緒可能直接讀到活著的箱子物件。 所以順序不是風格問題,是正確性:
- 旗標是 volatile,而且「填完才翻」。解碼的寫入順序固定是「逐格填清單 → 丟掉 bytes → 最後關旗標」。 任何執行緒只要讀到旗標是關的,依 Java 記憶體模型保證清單必然已經完整。反過來寫就是舊版那個 bug: 實測 2000 輪裡有 1943 輪被另一條執行緒讀到半填的清單,最壞把 27 格滿箱存成空的。
- 會碰旗標和 bytes 的四條路徑全部進同一把鎖:載入、解碼、存檔、整批替換。彼此排隊,不會交錯。
- 不進鎖的讀者只准讀那個 volatile 旗標,正確性完全靠第 1 條。漏斗查摘要就屬於這一類。
- 載入的寫入順序是 bytes → 摘要 → 最後開旗標,所以讀到旗標開著的人,一定看得到完整的 bytes 和摘要。
- 解碼中途會呼叫自己(填清單時要先拿到清單物件,而 guard 看到旗標還開著會再進解碼), 所以有一個欄位專門記「現在是誰在解」,認出是自己就直接放行。這個欄位不能為了省事拿掉。
回歸測試:tests/EnsureRaceTest.java,裡面有一條斷言守著「真的搶到視窗」,避免測試變成假綠。
| 名稱 | 一句話 | 生效的時刻 | log 欄位 |
|---|---|---|---|
| 延遲載入 | 載入時只抄 bytes,不解物品 | chunk 載入 | stash |
| 存取時才解碼 | 真的有人要看才解,每箱每次載入至多一次 | 第一次被碰 | ensure |
| 摘要快答 | 漏斗問滿不滿 / 這格空不空,讀欄位就能答,零解碼 | 漏斗每 tick | summaryFull summarySkip fullQ |
| 存檔只做 NBT 解析 | 沒被碰過的箱子存檔時不跑物品打包 | 存檔做副本時 | rawSave |
| 解碼歸因與計時 | 記錄是誰、花多久、慢的印座標 | 每次解碼 | attr* decodeMs decodeHist |
| 安全退路 | 任何意外一律退回原版行為 | 全路徑 | eagerLoad |
| shadow 驗證 | 上線前把兩種做法都算一遍逐位元組對照 | 開旗標時 | shadowMismatch |
| 分段 raw(26.2-10) | bytes 分成 ≤256 KB 的段,不倍增、不整份複製,永遠不產生 G1 humongous 物件;寫出的 bytes 與舊版逐位元組相同 | chunk 載入 | rawMaxKB rawBig |
| chunk 權重(26.2-10) | 每箱記下載入時的大小,lazycontainer$chunkWeight 加總給 ChunkForceManager 26.2-4 判斷「重的格」 |
外掛查詢時 | — |
兩張單的共同根是「巨箱 chunk 反覆載入 / 卸載」:每跨一次邊界就付一次整格處理。26.2-10 做兩件事,都不碰存檔內容:
- 分段 raw:舊版
encodeRaw用會倍增的緩衝再整份複製,一個 26 MB 的箱子光載入就配出一串超過 G1 humongous 門檻的物件 (s18 終界:region 4 MB ⟹ 門檻 2 MB,gc.log 1,742 行 humongous,每小時數次 Full GC 全 JVM 停 2–6 秒)。 改成每段 ≤256 KB 的分段:不倍增、不整份複製、沒有「先估大小、估錯就爆掉」這種失敗情況。 真實 region 56,265 個容器對拍:分段版與舊版位元組不同 = 0;順便快了一點(讀取端沒有ByteArrayInputStream的 synchronized)。 - chunk 權重:每箱記下載入時的大小(物化後不清,避免權重抖動),
lazycontainer$chunkWeight(LevelChunk)把整格加總。 給姊妹外掛 ChunkForceManager 26.2-4 用:重的格第一次載入就用 EndRodEndRodHotspotPinAPI 釘住 (level 33:常駐、不 tick),輕的格不碰。⛔ 不可用addPluginChunkTicket(level 31 會 tick,r174 在 s3 踩過)。 這是 2026-06 ChunkForceManager 做不到的事 —— 它為了秤重去序列化每個物品,把 s48 秤到 OOM;現在秤是免費的。
沒做的:卸載存檔那一次 NBT 解析仍在 region 執行緒上(#329 卡的那幾秒)。要讓它消失只有直寫(已封殺)或平行預解析(服主裁示不做)。 ChunkForceManager 做的是把次數壓下來。
2026-09-12 起,26.2-3 到 26.2-8 的所有新增功能都已從程式碼移除,runtime 退回 26.2-2:
- 存檔直寫(把 bytes 掛在核心的資料物件上、跳過 NBT 解析直接寫盤)
- 暫留原始資料 10 秒、寫入保真檢查、沒人碰過卻要寫空就補回、大面積清空警報、自動降級、故障注入
原因:這三個版本每次上線都發生大面積容器清空(09-08 s3;09-12 s3、s37、s69),26.2-2 則跑了數週無事。
根因至今未破,所以整條路封掉。出貨 gate 全綠過、紅綠台驗證過,一樣出事 —— 這件事本身也記在 gates/README.md。
現行程式碼裡已經找不到這些功能的任何殘留(對 src/ template/ 搜尋直寫相關符號為 0 命中)。
替代方案的評估寫在 docs/DESIGN-safe-perf-proposals.md,目前未實作、未上線。
開機 log 應該有這三行:
[LazyContainer] spliced 6 fields + 18 methods into BaseContainerBlockEntity
[LazyContainer] transformed leaf .../ChestBlockEntity
[LazyContainer] agent installed (transformer registered)
開 -Dlazycontainer.verbose=true 之後,每 30 秒印一行統計。主要看:
stash持續往上爬 = 懶載入正在運作。ensure相對stash越小越好,代表大多數箱子從沒被碰過。rawSave= 這段時間有多少次「沒被碰過的箱子」走了省事的存檔路徑。eagerLoadshadowMismatchsummaryMismatch正常恆為 0,不是 0 就要查。rawMaxKB= 看過最大的單一箱子(KB);rawBig= 超過 4 MB 的箱子數。超過 4 MB 會印一行[LazyContainer] BIG CONTAINER … KB @ x, y, z(最多 40 行,純提示不是錯誤;-Dlazycontainer.bigRaw.bytes可調)。- 統計行裡如果出現
rawPassthroughkeptRawbadWrite這類字,代表跑的是已拔除的舊版本,該台要換回 26.2-2。
| 步驟 | 程式碼 |
|---|---|
| ② 載入只抄 bytes | template/…/LazyContainerTemplate.java:141 lazycontainer$load |
| ② 摘要建置 | 同檔 :156、演算法本體 lazycontainer$computeSummary(同檔案內搜尋方法名) |
| ④ 漏斗攔截點 | src/…/LazyContainerTransformer.java:242 :246 transformHopper |
| ④ 摘要查詢 | template:582 lazycontainer$fullState、:595 lazycontainer$slotEmpty、:457 雙箱 |
| ⑤ 存取 guard | src/…/LazyContainerTransformer.java:307 guardKind |
| ⑤ 解碼 | template:239 lazycontainer$ensure |
| ⑥ 整批換內容 | template:312 lazycontainer$clear |
| ⑦ 存檔入口 | template:327 lazycontainer$save、:335 saveNoEmpty |
| ⑦ 存檔只做 NBT 解析 | template:350 lazycontainer$trySaveRaw、:199 lazycontainer$decodeRaw |
| 統計行欄位 | src/…/LazyContainerRuntime.java:449 stats() |
時間順序的完整說明見上面「一個箱子的一生」;這一節是給趕時間的人的速查。
像搬家公司本來每個經過倉庫的箱子都拆開檢查再封回(連沒人問的也拆)。改成:沒人要看的別拆;沒動過的原封出貨。
注入 NMS 容器類別,加入 6 個合成欄位(pending / ensuring / raw + 3 個摘要欄位)+ 改寫存取點:
| 動作 | 計數器 | 說明 |
|---|---|---|
| 延遲載入 | stash |
loadAdditional 不呼 ContainerHelper.loadAllItems,改抓未解碼的原始 Items ListTag 暫存、標記 pending。跳過解包。 |
| 存取時物化 | ensure |
首次有人呼 getItems()/getContents() → 才把暫存的 raw 解進清單(只解這一個)。 |
| 存檔省打包 | rawSave |
存檔時若該容器全程沒被碰(pending)→ 把暫存 bytes 解析成 NBT 樹交給核心。只做 NBT 解析,不跑物品打包。 |
| 退回 eager | eagerLoad |
input 不是 TagValueInput(理論上不會)→ 安全退回原本 vanilla 行為。 |
上面「一個箱子的一生」已用白話講過同一件事;這一節是要動
template/的人該看的精確版本。
這幾條不是風格偏好,是 26.2-2 修掉一個真實掉物/複製/存檔殘缺 bug 之後留下的不變式。動 template 之前先讀完。
lazycontainer$pending是volatile,而且「填完才翻」。ensure()的寫入序固定為「逐格填清單 →raw=null→ 最後pending=false」。任何執行緒只要讀到pending==false, 依 happens-before 清單就必然已經完整。反過來寫(先清旗標再填)就是 26.2-1 的 bug:實測 2000 輪中 1943 輪 被另一條執行緒讀到半填清單,最壞把 27 格滿箱存成Items: []。- 碰
pending/raw的路徑一律進thismonitor。 載入(load)、物化(ensure)、存檔(save/saveNoEmpty)、 整批替換(clear)四條全部synchronized,彼此序列化。transformer 的GUARD_CLEAR必須呼叫lazycontainer$clear(), 不可就地PUTFIELD——就地寫就是鎖外改狀態,等於沒修。 - 未持鎖的讀者只准讀 volatile 旗標。 leaf guard(
if (pending) ensure();)與漏斗摘要查詢不進鎖; 它們的正確性完全靠第 1 條。 load的寫入序是 raw → 摘要 → 最後pending=true。 讀到pending==true的查詢端必看得到 raw 與完整摘要。lazycontainer$ensuring(Thread)只做重入偵測。ensure()在 monitor 內呼叫getItems(),而 guard 看到pending仍是 true 會再進ensure();monitor 可重入,不擋就是無限遞迴。不要為了「省一個欄位」把它拿掉。- 為什麼 Paper 上看不到問題、EndRod 上會:EndRod 的 PIW(R39)允許非擁有 region 的插件執行緒讀活體容器,
而 paper-server 的
CraftInventory.getItem/getContents是先呼叫 NMS 才做跨區快照 → guard 與整段解碼都跑在插件執行緒上。 舊文件寫的「三路徑皆單一主緒」只描述純 Paper。
回歸測試:tests/io/github/kuohsuanlo/lazycontainer/EnsureRaceTest.java(./test.sh 會跑)。
它有 assertTrue(raced > 0) 守著「真的有搶到視窗」,不會變成假綠。細節見 RELEASE-NOTE-26.2-2.md。
| 路徑 | 26.2-2 的額外成本 |
|---|---|
已物化容器的 getItems()(穩態的絕大多數) |
一個 volatile 讀。x86 上就是普通 mov(零額外指令,只擋編譯器重排),ARM 為 ldar。不進 monitor。 |
ensure() |
每容器每次載入至多一次,不在 tick 熱路徑。 |
load / save / clear |
各多一次無競爭 thin-lock CAS。三者都不是每 tick 路徑(存檔本來就是週期性動作)。 |
| 漏斗摘要查詢(滿/空檢查) | 零。只讀欄位,不進鎖。 |
沒有拿效能換正確性的取捨;上方效能實證的數字在 26.2-2 之後不變。
涵蓋型別:ChestBlockEntity、BarrelBlockEntity、ShulkerBoxBlockEntity。
唯一咽喉 = getItems():NMS BaseContainerBlockEntity 所有容器讀寫(isEmpty/getItem/removeItem/setItem/clearContent/掉落/比較器…)都經它,守一個即覆蓋全部;CraftBukkit 的 getContents() 會繞過,額外守。getContainerSize() 不經內容(結構性),不守。
純外掛(plugin)無法覆寫 NMS(Minecraft 伺服器內部程式碼)裡標記 final(禁止被子類別覆寫)的方法,所以用 Java agent + ASM(操作 Java bytecode、能在類別載入當下動態改寫它的工具)注入:
LazyContainerAgentMain(premain,JVM 啟動時最先跑的進入點):把整個 jar 用appendToBootstrapClassLoaderSearch掛上 bootstrap classloader(JVM 最底層、所有類別載入器共同的祖先,這樣才能繞過 Paper 把 Minecraft 內部程式碼隔離起來的機制),再註冊 transformer(下面第 2 點的類別改寫器)。LazyContainerTransformer(執行實際改寫的 ASM 邏輯):- 把
LazyContainerTemplate(用一般 Java 語法、對著「真實的 Minecraft 伺服器內部程式碼」編譯出來的邏輯,而不是手刻 bytecode)splice(接枝:把外來的欄位/方法插進既有類別)進BaseContainerBlockEntity(所有容器方塊共同的父類別)。→ 編譯器會幫忙驗證方法簽章對不對,比手寫 bytecode 安全得多。 - 在箱子/木桶/界伏盒這三個實際子類別(繼承鏈最底層的類別,術語叫 leaf)的
getItems/getContents/setItems入口插「守門檢查」(guard,判斷這容器還沒被解碼、要不要先補解碼)、把 load/save 裡呼叫ContainerHelper的地方改成呼叫延遲版邏輯。
- 把
LazyContainerRuntime(掛在 bootstrap classloader、純 JDK 沒有依賴任何 Minecraft 類別):shadow(驗證模式)開關 + 計數器。- 安全鐵律:父類別(base/superclass)沒改寫成功,就完全不動子類別(leaf) → 整個退回純原版行為,絕不會產生「方法不存在」這類崩潰性錯誤(
NoSuchMethodError);過程中任何例外 → 回傳原本沒改過的 bytecode。
它改的是「什麼時候解包」,不是「箱子存什麼」。硬碟格式從頭到尾沒變。
- 沒碰的箱子 → 寫回的是載入時讀到的那份資料本身(只做 NBT 解析,不經物品解碼/重新打包),物品內容不可能被改寫。
- 被碰的箱子 → 跟 vanilla 一模一樣地解碼、再一模一樣地存回。
- 只動箱子的
Items(容器裡的物品清單),不碰地形 / 方塊 / 實體 / 光照 / 其他 BE(BlockEntity,附在方塊上、替它存額外資料的物件,例如告示牌的文字、箱子的內容物)。
已驗證:
- 離線 JVM bytecode 驗證:注入的 4 個類別全通過 link/verify(JVM 載入類別時檢查 bytecode 合不合法的機制)。
- 真實 Paper 26.2 端對端:放物品(diamond×42 / sword{damage:10} / netherite×7)→重啟→重載,逐字相同(含 data-component,1.20.5 之後 Minecraft 用來描述物品屬性——附魔、耐久、自訂名稱等——的資料格式);shadow 真實世界 56 容器
shadowMismatch=0。詳見docs/test-reports/26.2.md。 - 對抗審查(8 種失效模式 × 對抗驗證):0 個會改/掉資料的路徑;查到 2 個無關痛癢的 byte-identity(逐位元組完全相同)小差異,已修。
- Fable 5 二輪對抗審計(49 agent,更大規模、針對現行程式碼):同樣 0 個會掉物的路徑,額外找到並修復 1 個管理員指令才會踩到的邊角漏洞。詳見
FABLE5-AUDIT.md。 - DFU 跨版本:暫存的原始資料本來就是「DFU(DataFixerUpper,Minecraft 用來把舊版存檔資料自動升級成新版格式的機制)升級後」的版本(DFU 在區塊資料被讀出來的最早期、BE 物件都還沒建立前就跑完了),回寫的自然也是升級後的版本,不會有跨版本相容性問題。
詳見 FINDINGS.md、ADVERSARIAL-REVIEW.md、FABLE5-AUDIT.md。
-Dlazycontainer.shadow=true:每次要寫 raw 之前,額外把 vanilla 的做法(解開→重打包)算一遍逐位元組比對:
- 一樣 → 寫 raw(並得到一筆「快路徑正確」的證據)。
- 不一樣 → 改寫 vanilla 那份(安全的),
shadowMismatch++並印座標。
→ 開著 shadow,硬碟輸出在數學上不可能跟 vanilla 不同(零風險驗證)。代價:兩套都做了,暫時沒加速。
跑數天 shadowMismatch=0 + 無玩家回報少東西 → 才關 shadow 換真效能。
純逐位元組比對對「同一組物品、只是 Items 清單順序不同」會誤判(常見於外掛產的容器——每個 entry 自帶 Slot,清單順序不影響槽位)。所以 v2 起把差異分兩類:
benignReorder— raw 與 eager 是同一組物品+槽位、只差清單順序(以 multiset 比對確認)→ 安全寫 raw、不算問題。但仍偵測並回報:印benign reorder @ <pos> — … NO IMPACT (raw kept)(前 30 次,之後僅累加避免洗版)。shadowMismatch— 真正的結構差異(物品數量/內容變了,例如槽位越界被丟棄)→ 寫 eager(對齊 vanilla)+ 印座標。
→ 盯 shadowMismatch=0 即可;benignReorder 只是「外掛寫法不同」的無害提示,不是要修的東西。-Dlazycontainer.dump=true 時兩類都會把 raw/eager 各存一份(lc-mismatch-N / lc-benign-N)供離線 diff。
每一項優化都有自己的 use case、測試素材與通過條件,收在 gates/:
bash gates/run.sh # 全部 G0…G8,約 90–120 分鐘
bash gates/run.sh --tiers G0,G1,G2,G3 # 只跑離線關卡,約 5 分鐘
bash gates/run.sh --version 26.3 # 換版| 關卡 | 守什麼 |
|---|---|
| G0 環境/版本 | JDK、核心 jar、classfile major、工具、素材來源 |
| G1 建置 + 政策閘門 | 編得出來、鎖政策沒被破壞、版本字串一致 |
| G2 注入形狀 diff | 目標版 NMS 的注入假設指紋與基準逐行比(換版最重要的一關) |
| G3 差分/併發單元 | 摘要 vs 真 codec、物化競態 |
| G4 存檔 E2E | 原版 vs 本 agent,真倉庫逐容器輸出結構相等(直寫拔除後 LC_HAS_PASSTHROUGH=0,只跑這兩種) |
| G5 逐格裁判 | 真伺服器裡逐容器逐格 ItemStack.matches |
| G6 摘要/影子對抗 | 執行中用真解碼當神諭校驗每次快答 |
| G7 互動對抗 | 真 bot + 外掛 API + 指令 + 漏斗,單一 FINAL VERDICT |
| G8 出貨物件 | 版本字串、文件、交付夾 |
換版(26.2 → 26.3 → …)照 gates/UPGRADE.md 走:測試資產是版本無關的基礎建設,換版是「重跑」不是「重寫」。
bash build.sh # 需要 JDK 25(見下);產出 target/LazyContainerAgent.jar需要 nms-lib/(你的伺服器核心 Paper 的 NMS 編譯相依 libraries,供 template/ 對真實 NMS 編譯;NMS = Minecraft 伺服器內部程式碼)。
此目錄不入 git(太大、含 Mojang/Paper 產物),建置前自行放好。
流程:① mvn package(編出 agent 本體類別 + 把 ASM 這個依賴打包重定位進 jar + 產生 manifest)→ ② javac 把 template 對真實 NMS 原始碼編譯(這步必須用 JDK 25,因為要編出跟 26.2 伺服器相符的 bytecode 版本;agent 本體類別編譯目標是相容性較廣的 JDK 21 格式,但整個編譯流程統一用 JDK 25 跑,build.sh 已內建這個設定)→ ③ jar uf 把 template 編出來的 .class 檔當成「純資料」塞進 jar(執行期只會被讀取原始 bytes,不會真的被當一個類別載入)。
把 jar 放到節點看得到的位置,在 java 那行 -jar 前面插旗標:
java -Xms8000M -Xmx8000M \
-javaagent:LazyContainerAgent.jar \
-Dlazycontainer.shadow=true \
-Dlazycontainer.verbose=true \
... 原本的 -XX 旗標 ... \
-jar <你的 Paper>.jar nogui開機 log 應出現:
[LazyContainer] LazyContainerAgent —— crafted by 廢土貓大 LogoCat · 廢土 · mcfallout.net
[LazyContainer] spliced 6 fields + 18 methods into BaseContainerBlockEntity
[LazyContainer] transformed leaf .../ChestBlockEntity
[LazyContainer] agent installed (transformer registered) [SHADOW mode]
| 旗標 | 作用 |
|---|---|
-Dlazycontainer.shadow=true |
上線必開。輸出保證等同 vanilla;暫無加速。 |
-Dlazycontainer.verbose=true |
背景 daemon 定期印計數。 |
-Dlazycontainer.verbose.ms=8000 |
verbose 列印間隔(ms,預設 30000)。 |
-Dlazycontainer.dump=true |
mismatch / benign reorder 時把 raw/eager SNBT 各落一檔(lc-mismatch-N / lc-benign-N,各前 30 次),供離線 diff。 |
-Dlazycontainer.dump.dir=<路徑> |
dump 落檔目錄(預設 . = 伺服器工作目錄)。 |
-Dlazycontainer.summary=false |
關掉「漏斗問滿不滿/這格空不空」的摘要快答(保留延遲解碼本身)。 |
-Dlazycontainer.attribution=false |
關掉解碼觸發者歸因(stats 行會印 attribution=off)。 |
26.2-3 ~ 26.2-8 曾經把暫存的 bytes 掛在核心存檔用的資料物件上、跳過 NBT 解析直接寫盤, 後續版本又在上面加了暫留原始資料、寫入保真檢查、空寫守門、大面積清空警報與自動降級。 這一整條路已經從程式碼移除,runtime 退回 26.2-2。
原因:那三個版本每次上線都發生大面積容器清空(09-08 s3;09-12 s3、s37、s69),而 26.2-2 跑了數週無事。
根因未破。出貨 gate 全綠、紅綠台也驗過,一樣出事 —— 所以「gate 全綠」不等於生產安全,這點記在 gates/README.md。
替代方案(存檔預解析、逐格平行物化、調 autosave 節奏)的評估寫在
docs/DESIGN-safe-perf-proposals.md,未實作、未上線。
歷史設計文件保留在 docs/RAW-PASSTHROUGH-261.md 供考古,不代表現況。
純 Python、不需要伺服器。verify --deep 逐格解壓並走完整 NBT,列出壞掉的區塊;list 列容器與其 Items 大小;restore-chunk 從備份把整格貼回;restore-items 只把單一容器的 Items 貼回。
還原一律是位元組拼接,不重新編碼:Java 的 NBT 字串是 modified-UTF-8(U+0000 與增補字元的寫法和一般 UTF-8 不同),浮點數的 NaN 位元樣式、compound 的 key 順序也都會在重寫時改變——整份重寫等於在每個欄位上重新賭一次。所有寫入前會確認世界的 session.lock 沒被鎖住、沒有程序開著那個檔(伺服器在跑時,它記憶體裡的區域檔檔頭會把外部修改整份蓋掉),並先備份目標檔。
回滾:刪掉那幾段旗標重啟 → 回 100% vanilla,不需任何資料遷移(硬碟格式沒被改過)。
在正式環境的 Paper 節點實掛 shadow 模式,觀察到的行為:
shadowMismatch=0(持續) → 輸出與 vanilla 逐位元組一致,資料零風險。stash持續累積 → 載入時「立刻解包」這件白工確實被攔下(也就是那 45% 的源頭)。ensure的高低取決於該節點漏斗/比較器的活躍度:被碰到的箱子會即時物化(分散到各 tick),「完全省掉」的是「從載入到卸載都沒被碰」的那一批(rawSave)。
因此最大效益落在「閒置或 churn 中的容器」;最終加速幅度待關閉 shadow 後重抓 spark 對照(見上方「效能實證」)。
- 益處依賴「箱子沒被碰」:churn / 閒置儲存(載入→沒人碰→卸載)大勝;活躍的漏斗/比較器分類倉會把箱子 ensure 掉,純省比例變小(主要益處變成「把載入尖峰打散」)。姊妹專案 ChunkForceManager 從「別讓 chunk 反覆載卸」那端互補。
- 版本綁定:Paper 26.2 / Java 25(template major 69)。換版需用對應 NMS 重編
template/,並把 ASM 升到能讀目標 classfile 版本。版本不符會在開機/首次載箱子時大聲報錯(VerifyError/NoSuchMethod),不會靜默毀資料。詳見下方「版本敏感(務必先讀)」。 - 不影響:loot table 箱子(走另一條路徑,正交)、雙箱 CompoundContainer(委派到子箱 getItems,已守)。
- 多執行緒核心(EndRod / Folia 系):26.2-1 以前假設「載入/tick/卸載皆主執行緒」,那個假設只在純 Paper 成立——EndRod 的 PIW(R39)允許插件執行緒讀活體容器,舊版因此有真實的半填視窗。26.2-2 起
pending為 volatile、ensure()填完才翻旗標、四條狀態路徑全進 monitor,在單主緒與多執行緒核心上都正確。見上方「跨執行緒鐵律」。
src/main/java/io/github/kuohsuanlo/lazycontainer/
LazyContainerAgentMain.java premain / bootstrap 掛載
LazyContainerRuntime.java bootstrap 純 JDK:shadow 開關 + 計數器
LazyContainerTransformer.java ASM:splice base + 改寫 leaf
template/.../LazyContainerTemplate.java 對真實 NMS 編譯的延遲邏輯(splice 來源)
tools/scan_containers.py 掃 region 檔找箱子最密的 chunk(找「載入最貴」的地點)
tools/mca_restore.py 離線修/還原區塊檔(verify/list/restore-chunk/restore-items)
tools/mca_merge3.py 整格三方合併還原(避免整格貼舊備份把新建築倒掉)
tools/decode_bench.sh 離線量存檔路徑 NBT 解析與物品解碼的成本(序列 vs 多核心)
gates/cfm_e2e.sh ChunkForceManager 26.2-4 真伺服器端對端(Paper / EndRod rig;常駐探針看 /cforce status 的 resident=)
tests/.../RawSegmentsTest.java 分段 raw 與舊版逐位元組對拍
tests/.../SummaryDifferentialTest.java 摘要 vs 真 codec 差分(含 A2 案例)
tests/.../EnsureRaceTest.java 跨執行緒物化視窗回歸(26.2-2 / A1)
tests/.../NmsTestSupport.java 零 Minecraft server 的 headless NMS 啟動
test.sh 跑上面三支測試(需 nms-lib/)
build.sh pom.xml nms-lib/(不入 git)
FINDINGS.md 反編譯確認的事實 + 設計定案 + 風險分析
ADVERSARIAL-REVIEW.md 對抗審查報告(8 失效模式,12 agent)
FABLE5-AUDIT.md Fable 5 二輪對抗審計(49 agent,含記憶體/掉物三問結論)
RELEASE-NOTE-26.2-2.md 跨執行緒視窗(A1)+ 摘要非數值 Slot(A2)的根因、JMM 論證、紅/綠證據
TESTING.md 怎麼自己測(自動 round-trip / 手動玩測 / 真實世界副本驗 shadow)
延伸閱讀:FINDINGS.md(技術全貌)· TESTING.md(自測)· ADVERSARIAL-REVIEW.md(審查)· FABLE5-AUDIT.md(Fable 5 審計)。

