You are viewing a potentially older version of this package. View all versions.
s6652289-StackmasterFix-1.2.0 icon

StackmasterFix

[AI-generated] Two client-only patches for Stackmaster 1.2.0: (A) Quick Grab Materials never debited chests (duplication); (B) its new remembered-chest fallback refills chests you just emptied (intentional upstream, not a bug) - disabled.

Date uploaded 2 weeks ago
Version 1.2.0
Download link s6652289-StackmasterFix-1.2.0.zip
Downloads 43
Dependency string s6652289-StackmasterFix-1.2.0

This mod requires the following mods to function

denikson-BepInExPack_Valheim-5.4.2350 icon
denikson-BepInExPack_Valheim

BepInEx pack for Valheim. Preconfigured with the correct entry point for mods and preferred defaults for the community.

Preferred version: 5.4.2350
JStack424-Stackmaster-1.2.0 icon
JStack424-Stackmaster

Turn a messy Viking inventory into a tidy, adventure-ready loadout.

Preferred version: 1.2.0

README

StackmasterFix

适用性声明 / Applicability Notice

中文:本模组是给第三方模组 JStack424-Stackmaster 打的客户端补丁,含两个互相独立的修复项:

项 内容 性质
A 建造菜单 Alt + 左键(上游叫 Quick Grab Materials)拉取一整套材料时,箱子里的材料不扣减(等于复制) 是 bug(在上游 1.2.0 上依然存在)
B 上游 1.2.0 新增的「记住上次直接用过的箱子」回退:你把某个箱子清空后,在别处按 Alt + E(Quick Stack),刚取出来的东西会被原样塞回那个空箱 不是 bug,是上游有意做的特性(见下)

⚠️ 关于 B 项:这不是原模组的 bug,只是我个人的偏好开关

上游 1.2.0 的 changelog 明写这是有意添加的功能 —— 「Quick Stack remembers the last directly observed chest for each exact item type, even after that chest reaches zero stock」,目的是方便"补货": 你有个装木材的箱子、把它用空了,下次快速堆叠时木材会自动回到那个箱子。

我认为"被我清空的箱子就应该保持空着",所以用 DisableRememberedChests 这个开关把它关掉。 这只是个人习惯,不代表上游做错了。如果你觉得上游的行为挺好,请不要用本补丁, 或者把该开关设成 false 即可原样恢复上游行为。

  • 这个 A 项 bug 是在我这台装了 30+ 模组、且以客机身份连远程专用服务器的环境里复现并定位的。 原模组作者与这些问题无关,有疑问请先找我,不要去找原作者。
  • 原模组在别的环境下可能并没有 A 项这个 bug,症状也可能是我这台的特定组合导致; 不保证适用于你的环境。
  • 本补丁只做这两件事: A:把扣减从「只读快照副本」改到「真实容器」,并在事务成功后强制写回 ZDO; B:把上游那张「记忆箱子」回退表的返回值换成空表,让回退永不触发。 不改任何数值、不动上游的记忆存档数据、不加任何新功能。
  • 本补丁未经原作者同意或审阅,也不主张任何「原模组有 bug」的结论(A 项除外,那一项我给了 IL 证据)—— 整体上只是我这台机器上的绕行方案。
  • 若上游之后自己修好了 A 项,请优先升级上游模组并停用本补丁; 若你喜欢上游 B 项的行为,把 DisableRememberedChests 设为 false 即可。

English: This is a client-side patch for the third-party mod JStack424-Stackmaster, with two independent fixes:

  • A|Quick Grab Materials did not consume materials (Alt + left-click a build piece): chests were never debited, i.e. item duplication.
  • B|Quick Stack put items back into a chest you just emptied: Stackmaster 1.2.0 introduced "remember the last directly observed chest" as a fallback destination.

⚠️ B is NOT a bug in the original mod. The upstream 1.2.0 changelog states this behaviour is intentional ("even after that chest reaches zero stock", to make restocking convenient). This patch merely turns that feature off because I personally prefer an emptied chest to stay empty. If you like the upstream behaviour, do not use this patch, or set DisableRememberedChests = false to restore it exactly.

  • The A bug was reproduced and diagnosed on my setup: a 30+ mod client connecting to a remote dedicated server. The original mod's author is not responsible for any of this.
  • The original mod may be perfectly fine in your setup; this patch is not guaranteed to apply to you.
  • The patch only (A) redirects the withdrawal from a read-only snapshot copy to the live container and forces the container's ZDO write after a successful transaction, and (B) returns an empty map for the remembered-chest fallback. It changes no values, never touches the stored memory data, and adds no features.
  • This patch is not endorsed or reviewed by the original author, and makes no claim that the original mod is buggy (except item A, for which IL evidence is given) — it is a "works-on-my-machine" workaround.
  • If upstream fixes item A, update Stackmaster and disable this patch. If you like upstream's item B behaviour, set DisableRememberedChests = false.

🤖 本模组的代码由 AI 生成(AI Generated),需求、验证与发布由人类完成。


v1.2.0:新增「拦截记忆箱子回退」(B 项)

症状:把一个箱子里的东西取空 → 走到别的箱子按 Alt + E 快速堆叠 → 刚取出来的东西又被塞回那个空箱子。

根因(对 Stackmaster.dll 1.2.0 反汇编核对,316,416 B / sha256 be2fc8941b1abd96…):

  1. 上游 1.2.0 新增了 RememberedChestDestinations:只要你直接打开/对准过某个箱子, 就按"物品种类"把它记进本地 PlayerPrefs(上限 512 条、180 天过期)。 箱子被清空也不会抹掉这条记忆 —— 这是 changelog 里明写的有意设计。
  2. 正常堆叠只进"当前已经装有同类物品"的箱子,不会主动填空箱: <>c__DisplayClass2_0::<PlanDeposits>b__1 的整个筛选逻辑就是 container.InitialCompatibilityKeys.Contains(source.CompatibilityKey) ⇒ 空箱子的候选键集合为空,永远进不了正常候选列表。
  3. 于是有了「回退」这条路(StorageTransferPlanner.PlanDeposits,IL 0x00FF-0x014A): 当剩余量 > 0 且本次一件都没搬走、且 remembered.TryGetValue(stackId, out containerId) 命中、且该箱子在当前发现范围内 ⇒ 把这一份放进那个箱子。 ⇒ 回退只可能发生在"这个箱子当前一件同类物品都没有"的时候。

修法:在 IReadOnlyDictionary<string,string> Stackmaster.RememberedChestDestinations.ResolveFallbacks( Player, InventorySnapshot, DiscoveryResult) 上打一个 Postfix,把返回的 StackId → ContainerId 回退表换成空表 ⇒ PlanDeposits 里的 TryGetValue 永远 miss ⇒ 回退永不触发。

影响面为什么是封闭的(scripts/verify_stackmasterfix.py 每次交付前自动核这四条):

核对项 实测结论
ResolveFallbacks 全 DLL 调用点 只有 2 个:StorageAction.Begin 与 <FinishAfterOwnership>d__17.MoveNext —— 都是 Quick Stack 存料路径
记忆数据的读取点 RememberedDestinationState::TryGet 只被 ResolveFallbacks 调用 1 次 ⇒ 在返回值上拦截即完全封闭
是否影响合成扣料 / 快速取料(A 项) 不影响,那是另外的类(NearbyResources / QuickGrabMaterialsAction)
目标签名 参数 3 个、返回类型恰为 IReadOnlyDictionary<string,string>,与 Postfix 的 ref 参数一字不差

刻意不做的事:

  • 不改上游的记忆存档(ObserveDirect / PlayerPrefs 原样保留)。 ⇒ 本修复完全可逆:把 DisableRememberedChests 设成 false 立刻恢复上游行为,不用重建记忆。
  • 不碰"失败堆叠标红框"那套(FailedDepositWarnings)。 ⚠️ 需要知道的副作用:上游 1.2.0 会对「合格但最终留在背包里」的物品临时打 !数量 红框 (悬浮该物品一次即清除)。本修复让这部分物品真的留在背包里 ⇒ 红框出现的机会比以前多一点。 这是上游既有的提示机制,不是本补丁引入的新行为 —— 在离家老远、附近没箱子时按 Alt+E 本来就会满屏红框。

配置:

键 默认 说明
DisableRememberedChests true 关掉「记忆箱子」回退。设 false = 完全恢复上游 1.2.0 行为。

新增配置节后需要重启游戏才会写出该节。


⚠️ v1.1.0:合成/升级那半已剔除

Stackmaster 1.1.5 自己修好了「用箱子材料合成/升级不扣料」。 依据:

  • 1.1.5 changelog 首条即声明该修复(… recaptured from each selected owned Container.GetInventory() rather than from detached ZDO inventory snapshots …),并写明已在游戏内验证。
  • 源码层面,NearbyResourceService 在执行期改用活库存重建 runtime stack (NearbyResources.cs:1288 取 Container.GetInventory(),:1294-1313 用它重建 handle), 并在扣减前用 ReferenceEquals(runtime.Inventory, runtime.Container.Container.GetInventory()) 做 fail-closed 身份校验(:1673-1681),另配精确扣减账本与逆序回滚。

⇒ 本补丁原先针对 ExecuteWithRollback 的 transpiler 已退化为恒等替换, 故 1.1.0 连同 FixCraftingFromStorage 配置项一起删除。

⚠️ 但 1.1.5 的修复只覆盖 crafting:Quick Grab Materials 那条路径没动(见下),所以本补丁保留那一半。 2026-09-25 对上游 1.2.0 复核:这条路径依旧没修 —— ExecuteAtomic 里仍是 playerInventory.MoveItemToThis(runtime.Inventory, …),而那个 runtime.Inventory 仍来自 CaptureForQuickGrab → Capture → Discover(…, resourceReadOnly: true) 的 detached ZDO 快照 ⇒ 本补丁依然必需,没有可剔除的功能。

依赖因此升到 JStack424-Stackmaster-1.2.0。 ⚠️ 上游 1.2.0 把内部类 ExpeditionKitAction 改名为 QuickGrabMaterialsAction (1.2.0 changelog 把用户可见的"存放/补货"动作改叫 Quick Stack,而 "Quick Grab Materials" 这个名字保留不变 —— 改的是内部类名)。本补丁 1.1.1 已跟随:改成先试新名、再回退旧名, 两代上游都能装上。1.1.0 在 1.2.0 上会整体静默失效(找不到类型、180 秒后报错),请勿使用。


它修的是什么(Quick Grab Materials 路径)

Stackmaster 为了「不拿所有权也能读箱子」,把容器的序列化库存从 ZDO 解进一个临时 Inventory (new Inventory(true)),命名为 ContainerHandle.ResourceInventory,作者注释自述 never claims ownership or mutates the live Container inventory。

crafting 路径的执行期已经换成活库存了,但 Quick Grab Materials 没有:

QuickGrabMaterialsAction.Finish()
  → TryPrepare → CaptureForQuickGrab(player, true, true)
  → NearbyResources.Capture → ContainerDiscovery.Discover(…, resourceReadOnly: true)
  → ContainerDiscovery.Inspect:refreshed = !resourceReadOnly && … ⇒ false
                               inventory = refreshed ? container.GetInventory() : null ⇒ null
  → 于是只解 ZDO 字节到 new Inventory(true),塞进 handle.ResourceInventory
  → NearbyResources.cs:1157  AddInventory(…, handle.ResourceInventory, …)   ← runtimeStack 指向副本
  → QuickGrabMaterialsAction.cs:656  MoveItemToThis(runtime.Inventory, …)   ← 从副本里搬走

(crafting 专用的 TryCaptureOwnedPlan / mutableCapture 从未被它调用。)

行号按 1.2.0 的源码给出;1.1.5 的对应位置在 ExpeditionKitAction.cs 里,内容一字不差 —— 也就是说上游这次只是改了文件与类的名字,没改行为。

而 new Inventory(true) 的 m_temoraryInventory == true,游戏里 Inventory.Changed() 第一句就是 ldfld m_temoraryInventory; brfalse; ret ⇒ 永不发 m_onChanged ⇒ 不触发 Container.OnContainerChanged ⇒ 从不调用 Container.Save() ⇒ ZDO 里材料一点没少。 同时模组用 before - after 在同一个副本上量差值,必然判定成功 ⇒ 玩家白拿一套材料。

它是怎么修的

transpiler 只挂一个目标方法:

目标方法 替换内容
Stackmaster.QuickGrabMaterialsAction::ExecuteAtomic
(上游 1.1.5 及更早叫 ExpeditionKitAction)
RuntimeResourceStack.get_Inventory、get_Item
  • get_Inventory → 真实容器的活库存(Container.GetInventory())
  • get_Item → 活库存里同一槽位的那个物品对象 (必须成对替换:MoveItemToThis 内部的扣减带 m_inventory.Contains(item) 的引用包含检查, 只换 Inventory 不换 Item 会直接扣不动)
  • 事务成功后:对本次真的重定向过、且确实是本机 owner 的容器执行 Container.Save() + ZDOMan.ForceSendZDO,确保写回权威数据

安全取向:拿不到安全写入权(IsOwner() == false、容器已卸载、反射句柄缺失)时,返回一个空库存, 让模组的 ExecuteAtomic 自己判定失败并取消本次操作。 宁可取消,也不放行复制。

配置

BepInEx/config/s6652289.StackmasterFix.cfg

项 默认 说明
FixExpeditionKit true 修「建造菜单 Alt + 左键拉取一整套材料(上游叫 Quick Grab Materials)」的材料复制。
键名沿用旧版的 FixExpeditionKit(上游 1.2.0 之前管它叫 expedition kit),改名会让已有配置失效,故不改
Verbose true 打印每次重定向与强制写回的细节

旧版遗留在 cfg 里的 FixCraftingFromStorage 键已无用,可手动删除(不删也无副作用)。

怎么看它生效了

日志(BepInEx/LogOutput.log)里搜 [Fix]:

[Fix] 已打补丁:QuickGrabMaterialsAction.ExecuteAtomic(transpiler + 写回 postfix)
[Fix] transpiler 生效:QuickGrabMaterialsAction.ExecuteAtomic(N 条指令)→ get_Inventory 替换 4 处,get_Item 替换 5 处
[Fix] 扣减重定向 → 真实容器 $piece_chest_blackmetal(Clone) uid=… 活库存总数=369
[Fix] 已强制写回 ZDO: $piece_chest_blackmetal(Clone) uid=… 库存=368 zdoRev=1067

关键判据:拉一次材料后,箱子真的少了对应材料 = 修好了。 (在 1.1.5 及更早的上游上,日志里的名字会是 ExpeditionKitAction.ExecuteAtomic —— 同一个方法,只是上游改过类名。) 若看到 [Fix] ★transpiler 未匹配到任何目标指令 ⇒ Stackmaster 版本变了,本补丁失效,请反馈。

安装 / 卸载

  • 安装:解压把 StackmasterFix.dll 放到 BepInEx/plugins/StackmasterFix/
  • 卸载:删掉该目录
  • 依赖:denikson-BepInExPack_Valheim + JStack424-Stackmaster(不需要 Jotunn、不联网、纯客户端)

源码

D:\WorkBuddy\StackmasterFix\(本地工作区)

已知边界

  • 本文只验证到「材料确实从真实容器扣掉、并写回 ZDO」。联机双端一致性、专用服务器长时间运行、 容器被他人同时打开等边界场景未做穷尽测试。
  • 上游若改变 RuntimeResourceStack / ExecuteAtomic 的结构,本补丁会打印 transpiler 未匹配 并自然失效(不会造成新的破坏)。
  • 合成/升级不再属于本补丁的职责范围:那由上游 1.1.5 自行处理,本补丁不再提供任何兜底。

CHANGELOG

Changelog

1.2.0

新增 B 项:拦截上游 1.2.0 的「记忆上次用过的箱子」回退 —— 快速堆叠不再把物品塞回你刚清空的箱子。

  • 症状:把箱子取空 → 走到别的箱子按 Alt+E 快速堆叠 → 刚取出来的东西又被塞回那个空箱子。
  • 根因:上游 1.2.0(及 1.1.9 测试版)changelog 原话 —— 「Quick Stack remembers the last directly observed chest for each exact item type, even after that chest reaches zero stock」,即箱子被清空也不会抹掉这条记忆。 随后 StorageTransferPlanner.PlanDeposits 在「剩余量 > 0 且本次一件都没搬走」时, 用这张记忆表把物品回退进那个箱子。
  • 判据 A(正常路由不会填空箱):<>c__DisplayClass2_0::<PlanDeposits>b__1 的整个筛选逻辑就是 container.InitialCompatibilityKeys.Contains(source.CompatibilityKey) ⇒ 空箱子的候选键集合为空 ⇒ 永远进不了正常候选列表。 ⇒ 所以「往清空的箱子塞东西」只有"记忆箱子回退"这一条通道。
  • 判据 B(回退只在箱子已无同类物品时才会发生):见 PlanDeposits IL 0x00FF-0x014A 的四重条件。⇒「拦截回退」与「不再填空箱」是同一件事,不存在误伤正常堆叠的中间态。
  • 修法:Postfix 打在 IReadOnlyDictionary<string,string> Stackmaster.RememberedChestDestinations.ResolveFallbacks(Player, InventorySnapshot, DiscoveryResult) 上,把返回的 StackId → ContainerId 回退表换成空表 ⇒ TryGetValue 永远 miss。
  • 影响面封闭(三条实证):ResolveFallbacks 全 DLL 只有 2 个调用点 (StorageAction.Begin / <FinishAfterOwnership>d__17.MoveNext,都是 Quick Stack 存料路径); 记忆数据的唯一读取点 RememberedDestinationState::TryGet 全 DLL 只被 ResolveFallbacks 调 1 次; 不涉及合成扣料与快速取料(A 项那两套类是另外的)。
  • 刻意不做:不动上游的记忆存档(ObserveDirect / PlayerPrefs 原样保留) ⇒ 本修复可逆,DisableRememberedChests = false 即恢复上游行为,不用重建记忆。
  • 新配置:DisableRememberedChests(默认 true)。
  • ⚠️ 已知副作用:上游 1.2.0 会对「合格但最终留在背包里」的物品临时打 !数量 红框 (悬浮一次即清除)。本修复让这部分物品真的留在背包里 ⇒ 红框出现机会略增。 该机制是上游既有的,不是本补丁引入的。
  • 结构改动:A / B 两个修复项改为独立安装 —— 任何一项配置关掉、或任何一项挂载失败, 都不再连累另一项(1.1.1 及更早是"任一早退就整体不挂载")。
  • ⚠️ 适用性声明已更新:B 项不是上游 bug,上游是有意这么设计的; 本补丁只是按个人偏好关掉它(详见 README 顶部)。

上游 1.2.0 没有修 A 项(继续保留)

QuickGrabMaterialsAction 的扣减源仍是 detached ZDO 快照 ⇒ A 项补丁依然必需(1.1.1 已核对,本轮复核未变)。

1.1.1

修 bug:上游 1.2.0 改了内部类名,本补丁不改就会整体静默失效。

  • 根因:Stackmaster 1.2.0 把 ExpeditionKitAction 改名为 QuickGrabMaterialsAction (1.2.0 changelog:把用户可见的"存放/补货"动作改叫 Quick Stack,而 Quick Grab Materials 这个名字保留不变 —— 改的只是内部类名)。 本补丁原先写死 FindType("Stackmaster.ExpeditionKitAction") ⇒ 在 1.2.0 上永远拿到 null,TryInstall 每秒空转、180 秒后报 "180 秒内没等到 Stackmaster 类型,补丁未安装" ⇒ 整条补丁一点作用都没有。
  • 修正:改为先试新名、再回退旧名: FindType("Stackmaster.QuickGrabMaterialsAction") ?? FindType("Stackmaster.ExpeditionKitAction") ⇒ 1.2.0 与 1.1.5 及更早两代上游都能装上。 安装日志也会打印实际用到的类型名,方便一眼看出上游又换没换名字。
  • 依赖:JStack424-Stackmaster 1.1.5 → 1.2.0。
  • 配置键保持不变:仍叫 FixExpeditionKit(上游 1.2.0 之前管这个功能叫 expedition kit)。 改名会让老用户已写好的 cfg 失效 —— BepInEx 不会用新默认值覆盖已存在的键, 结果是"开关突然变回默认",得不偿失;故键名不动,只在说明里点明它对应上游的 Quick Grab Materials。

复核结论:没有可剔除的功能

同时按最新源码逐项复核了"上游是否已经自己修好这条路径",结论是没有:

  • QuickGrabMaterialsAction.cs:626-634(1.2.0)里仍然是 var sourceItem = runtime.Inventory.GetItemAt(runtime.Item.m_gridPos.x, runtime.Item.m_gridPos.y); 加 ReferenceEquals(sourceItem, runtime.Item) 校验;
  • :655-660 仍然是 playerInventory.MoveItemToThis(runtime.Inventory, sourceItem, …) —— runtime.Inventory 依旧是那个 detached 快照;
  • 而它的来源 QuickGrabMaterialsAction.Finish() → CaptureForQuickGrab(player, true, true) → NearbyResources.Capture → ContainerDiscovery.Discover(…, resourceReadOnly: true) 一字未改(NearbyResources.cs:1145,ContainerDiscovery.cs:387 仍是 new Inventory(true))。

⇒ 与 1.1.5 相比,上游这次只是把文件与类改了名字,行为完全没动 (对照 1.1.5 的 ExpeditionKitAction.cs:626/627/650/656/670,行号语义一一对应)。 本补丁依然必需。

验证

  • dotnet build -c Release → 0 警告 0 错误;DLL 17,920 字节, sha256 00bd424e96bcb56c5aaed0b15a080fa8e358e196248f57fa4f724dc1e3535f7c。
  • DLL 字节检索:Stackmaster.QuickGrabMaterialsAction 与 Stackmaster.ExpeditionKitAction 两个名字都在(双名查找);ExecuteAtomic / RuntimeResourceStack / ContainerHandle / FixExpeditionKit 均在;版本常量与 Win32 版本资源均为 1.1.1。
  • 上游官方 1.2.0 DLL metadata 核对: ExpeditionKitAction 已不存在 ✓(印证本 bug); QuickGrabMaterialsAction.ExecuteAtomic 6 参 ✓(与补丁的 FindMethod(…, 6) 一致); RuntimeResourceStack.get_Inventory/get_Item/get_Container、ContainerHandle.get_Container ✓ 全在。
  • 装机 DLL 与官方 1.2.0 包逐字节一致(316,416 B / be2fc8941b1abd96)。

1.1.0

剔除冗余功能:上游 Stackmaster 1.1.5 已自行修好「合成/升级不扣料」,本补丁不再介入那一半。

  • 依据(只读上游源码 + 官方包 metadata 核对):
    • 1.1.5 changelog 首条声明该修复,并写明已在游戏内验证 (prepared withdrawals are recaptured from each selected owned Container.GetInventory() rather than from detached ZDO inventory snapshots …)。
    • NearbyResources.cs:1288 取 Container.GetInventory() 活库存,:1294-1313 用它重建 mutableHandle 与 runtimeStacks ⇒ 执行期对象就是活库存; :1673-1681 另有 ReferenceEquals(runtime.Inventory, runtime.Container.Container.GetInventory()) 的 fail-closed 身份校验(注释直指 "grants a free crafted item"),并配 ExactResourceDebit 精确账本与逆序回滚。
    • ⇒ 本补丁针对 ExecuteWithRollback 的 transpiler 已退化为恒等替换,属纯冗余。
  • 移除:ExecuteWithRollback 的 transpiler + 写回 postfix 分支、FixCraftingFromStorage 配置项、以及 NearbyResourceService 的类型查找。 DLL 内已确认 FixCraftingFromStorage 与 ExecuteWithRollback 零引用。
  • 保留:ExpeditionKitAction.ExecuteAtomic(远征包)。1.1.5 没有修这条路径 —— 它仍走 CaptureForExpedition → Discover(…, resourceReadOnly: true) 的 detached ZDO 快照, 而 crafting 专用的 TryCaptureOwnedPlan/mutableCapture 从未被它调用。故这一半原样保留。
  • 依赖:JStack424-Stackmaster 1.1.4 → 1.1.5(不升会让 r2modman 要求把已升的上游降回去)。
  • 日志改进:transpiler 不再拿「指令总数」当方法指纹(上游一改 IL 就会退化成 未知方法(指令数 N),纯误导),改用 Harmony 注入的 __originalMethod 直接取方法名。
  • 阈值变化:FixExpeditionKit=false 时补丁不再尝试挂载,直接打日志说明并退出(等同于未安装)。

DLL 重新编译:16,896 字节,sha256 da4e8133d5e5b8ef991cc6f366f8e64ed31022d1d02bd27b0f497c8af1f5342e。

1.0.0

  • 首个版本。修复上游 JStack424-Stackmaster 1.1.4 的「用箱子材料合成/升级不扣料(复制)」问题。
  • 根因:Stackmaster 为了「不拿所有权也能读箱子」,把容器的序列化库存从 ZDO 解进一个 临时 Inventory(new Inventory(true),ContainerHandle.ResourceInventory), 但写入路径复用了同一个 capture ⇒ ExecuteWithRollback 实际是扣在只读副本上。 临时 Inventory 永不触发 Inventory.Changed() ⇒ 不会调用 Container.Save() ⇒ ZDO 里材料一点没少,而模组的自检在同一个副本上量差值必然判定成功 ⇒ 玩家白拿成品。
  • 修法:transpiler 把写入方法里的 RuntimeResourceStack.get_Inventory / get_Item 整体重定向到真实容器的活库存与活物品对象;事务成功后强制 Container.Save() + ZDOMan.ForceSendZDO。拿不到安全写入权时 fail-closed(取消操作,不放行复制)。
  • 覆盖:NearbyResourceService.ExecuteWithRollback(合成/升级/建造)与 ExpeditionKitAction.ExecuteAtomic(建造菜单 Alt+左键拉取远征包)。
  • 实测(2026-09-19):修复后逐笔扣减量与配方需求精确吻合 (建造 20/10/2 = 32、合成 −3、另一组 4/1 = 5),且合成事务内首次出现 Container.OnContainerChanged 与 Container.Save(修复前为 0 条)。
  • 配置项:FixCraftingFromStorage、FixExpeditionKit、Verbose(均默认开)。