diff --git a/src/main/java/thaumicenergistics/common/container/EssentiaFillHelper.java b/src/main/java/thaumicenergistics/common/container/EssentiaFillHelper.java
new file mode 100644
index 0000000..b0384c0
--- /dev/null
+++ b/src/main/java/thaumicenergistics/common/container/EssentiaFillHelper.java
@@ -0,0 +1,216 @@
+package thaumicenergistics.common.container;
+
+import net.minecraft.core.registries.BuiltInRegistries;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+
+import org.jetbrains.annotations.Nullable;
+
+import appeng.api.config.Actionable;
+import appeng.api.networking.energy.IEnergySource;
+import appeng.api.networking.security.IActionSource;
+import appeng.api.stacks.AEKey;
+import appeng.api.storage.MEStorage;
+import appeng.api.storage.StorageHelper;
+
+import thaumcraft.api.aspects.Aspect;
+import thaumcraft.api.aspects.AspectList;
+import thaumcraft.api.aspects.IEssentiaContainerItem;
+import thaumcraft.common.items.JarBlockItem;
+import thaumicenergistics.common.integration.appeng.AEssentiaKey;
+import thaumicenergistics.common.integration.tc.TCReflection;
+
+/**
+ * 源质容器 ↔ ME 网络通用存取逻辑(服务端)。
+ *
+ * 与具体菜单解耦:调用方(源质终端覆写、或 ME 终端 mixin)只需提供
+ * storage / energySource / actionSource / player / 物品栈,本类完成
+ * 罐子与安瓿的装罐、存入网络操作。
+ *
+ * - 取出(装罐):网络有源质 → 装入手持空罐(有多少装多少,最多 64)/ 空安瓿(固定 8)。
+ * - 存入:手持/点击的罐子或安瓿 → 源质注入网络;罐子留空罐,安瓿返回空 glass_phial(不消失)。
+ *
+ */
+public final class EssentiaFillHelper {
+
+ private EssentiaFillHelper() {}
+
+ /** 玻璃安瓿固定容量(TC 原版:8 源质/支)。 */
+ public static final int PHIAL_CAPACITY = 8;
+ /** 罐子容量(TC 原版:64 源质/罐)。 */
+ public static final int JAR_CAPACITY = 64;
+
+ /**
+ * 右键网络源质条目装罐:从网络提取源质,装入手持的源质罐子或玻璃安瓿。
+ *
+ * @return true = 处理成功(装罐/装安瓿),false = 无法处理
+ */
+ public static boolean fillContainerFromGrid(MEStorage storage, IEnergySource energySource,
+ IActionSource actionSource, Player player,
+ ItemStack carried, ResourceLocation aspectId) {
+ if (carried.isEmpty() || aspectId == null) {
+ return false;
+ }
+ AEKey key = AEssentiaKey.of(aspectId);
+ if (key == null) {
+ return false;
+ }
+ long amountInGrid = storage.getAvailableStacks().get(key);
+ if (amountInGrid <= 0) {
+ return false;
+ }
+
+ // 玻璃安瓿:消耗空安瓿 → 得到装满安瓿(固定 8 源质)
+ if (carried.getItem() == BuiltInRegistries.ITEM.get(
+ ResourceLocation.fromNamespaceAndPath("thaumcraft", "glass_phial"))) {
+ if (amountInGrid < PHIAL_CAPACITY) {
+ return false;
+ }
+ long extracted = storage.extract(key, PHIAL_CAPACITY, Actionable.MODULATE, actionSource);
+ if (extracted < PHIAL_CAPACITY) {
+ if (extracted > 0) {
+ storage.insert(key, extracted, Actionable.MODULATE, actionSource);
+ }
+ return false;
+ }
+ carried.shrink(1);
+ // 得到装满安瓿并放入背包(装好后自动放入背包)
+ ItemStack filled = filledPhialStack(aspectId.getPath());
+ if (!player.getInventory().add(filled)) {
+ player.drop(filled, false);
+ }
+ return true;
+ }
+
+ // 罐子(IEssentiaContainerItem):每次只装 1 个空罐(手堆叠 -1,背包得 1 满罐)
+ if (!(carried.getItem() instanceof IEssentiaContainerItem container)) {
+ return false;
+ }
+ AspectList existing = container.getAspects(carried);
+ if (existing != null && !existing.isEmpty()) {
+ return false; // 罐子非空,不能装
+ }
+ // 部分取出:网络有多少装多少(最多 JAR_CAPACITY 64);不足 64 也能装,不再整组拒绝
+ long toExtract = Math.min(amountInGrid, JAR_CAPACITY);
+ long extracted = storage.extract(key, toExtract, Actionable.MODULATE, actionSource);
+ if (extracted <= 0) {
+ return false;
+ }
+ Aspect aspect = Aspect.getAspect(aspectId.getPath());
+ if (aspect == null) {
+ storage.insert(key, extracted, Actionable.MODULATE, actionSource);
+ return false;
+ }
+ // 生成 1 个满罐:必须先 copy(在 shrink 之前),因为 count==1 的堆叠 shrink 后
+ // 会变成空堆叠,ItemStack.copy() 对空堆叠直接返回全局 EMPTY 单例,setCount(1)
+ // 会污染该常量产生"空气幽灵堆叠",罐子因此消失。copyWithCount(1) 生成独立副本,
+ // setAspects 只影响这一个,不影响原堆叠。
+ ItemStack filledJar = carried.copyWithCount(1);
+ // 手堆叠 -1
+ carried.shrink(1);
+ AspectList newAspects = new AspectList();
+ newAspects.add(aspect, (int) extracted);
+ container.setAspects(filledJar, newAspects);
+ if (!player.getInventory().add(filledJar)) {
+ player.drop(filledJar, false);
+ }
+ return true;
+ }
+
+ /**
+ * 把源质容器物品中的源质注入 ME 原生存储,返回处理后的物品栈。
+ * 堆叠中每个容器的源质相同(NBT 共享),按"单个量 × 堆叠数"一次注入;
+ * 全部注入成功才消耗容器,否则保留原堆叠,避免源质凭空消失。
+ *
+ * 罐子抽干后留空罐(可重复使用);安瓿抽干后返回空 glass_phial(不消失)。
+ *
+ * @return null = 非源质容器(未处理,调用方应走原逻辑);
+ * 原 stack = 未注入(网络满/能量不足)或部分处理保留;
+ * 空罐 = 罐子全部抽干(可重复使用);
+ * glass_phial 空安瓿 = 安瓿全部抽干(返回空安瓿,不再消耗掉)
+ */
+ @Nullable
+ public static ItemStack extractContainerContent(MEStorage storage, IEnergySource energySource,
+ IActionSource actionSource, ItemStack stack) {
+ if (!(stack.getItem() instanceof IEssentiaContainerItem container)) {
+ return null;
+ }
+ int count = stack.getCount();
+ if (count <= 0) {
+ return stack;
+ }
+ AspectList aspects = container.getAspects(stack);
+ if (aspects == null || aspects.isEmpty()) {
+ return stack;
+ }
+
+ // 阶段1:预检(SIMULATE 无副作用)——网络必须能完整容纳全部源质才继续;
+ // 任一项放不下则整个堆叠原样保留,避免"部分注入后罐子被消耗、剩余源质凭空消失"。
+ Aspect[] aspectArr = aspects.getAspects();
+ ResourceLocation[] ids = new ResourceLocation[aspectArr.length];
+ long[] totals = new long[aspectArr.length];
+ int n = 0;
+ for (Aspect aspect : aspectArr) {
+ if (aspect == null) {
+ continue;
+ }
+ int perItem = aspects.getAmount(aspect);
+ if (perItem <= 0) {
+ continue;
+ }
+ ResourceLocation aspectId = TCReflection.getAspectId(aspect);
+ if (aspectId == null) {
+ continue;
+ }
+ long total = (long) perItem * count;
+ if (storage.insert(AEssentiaKey.of(aspectId), total,
+ Actionable.SIMULATE, actionSource) < total) {
+ return stack; // 网络容量不足:完全不注入,罐子/安瓿原样保留
+ }
+ ids[n] = aspectId;
+ totals[n] = total;
+ n++;
+ }
+ if (n == 0) {
+ return stack;
+ }
+
+ // 阶段2:执行注入(预检已保证容量;若能量不足导致未完全注入,则回滚本次及此前
+ // 已注入的量并保留罐子,保证网络数量与罐子状态一致——不丢源质也不复制源质)
+ for (int i = 0; i < n; i++) {
+ long injected = StorageHelper.poweredInsert(
+ energySource, storage, AEssentiaKey.of(ids[i]), totals[i], actionSource);
+ if (injected < totals[i]) {
+ if (injected > 0) {
+ storage.insert(AEssentiaKey.of(ids[i]), injected,
+ Actionable.MODULATE, actionSource);
+ }
+ for (int j = 0; j < i; j++) {
+ storage.insert(AEssentiaKey.of(ids[j]), totals[j],
+ Actionable.MODULATE, actionSource);
+ }
+ return stack;
+ }
+ }
+
+ // 全部注入成功则消耗整个堆叠(罐子留空罐、安瓿返回空 glass_phial)
+ if (stack.getItem() instanceof JarBlockItem) {
+ return new ItemStack(stack.getItem());
+ }
+ // 安瓿:返回空安瓿(glass_phial),不直接消失
+ ItemStack emptyPhial = new ItemStack(BuiltInRegistries.ITEM.get(
+ ResourceLocation.fromNamespaceAndPath("thaumcraft", "glass_phial")), count);
+ return emptyPhial;
+ }
+
+ /** 生成装满的安瓿(phial_of_essentia_<tag>),与 TC JarBlock.filledPhialStack 一致。 */
+ private static ItemStack filledPhialStack(String aspectTag) {
+ ResourceLocation id = ResourceLocation.fromNamespaceAndPath(
+ "thaumcraft", "phial_of_essentia_" + aspectTag);
+ net.minecraft.world.item.Item item = BuiltInRegistries.ITEM.getOptional(id)
+ .orElse(BuiltInRegistries.ITEM.get(
+ ResourceLocation.fromNamespaceAndPath("thaumcraft", "glass_phial")));
+ return new ItemStack(item);
+ }
+}
diff --git a/src/main/java/thaumicenergistics/common/container/EssentiaTerminalMenu.java b/src/main/java/thaumicenergistics/common/container/EssentiaTerminalMenu.java
new file mode 100644
index 0000000..0b51cb7
--- /dev/null
+++ b/src/main/java/thaumicenergistics/common/container/EssentiaTerminalMenu.java
@@ -0,0 +1,43 @@
+package thaumicenergistics.common.container;
+
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.inventory.MenuType;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.resources.ResourceLocation;
+import appeng.api.storage.ITerminalHost;
+import appeng.menu.me.common.MEStorageMenu;
+
+/**
+ * 源质终端的菜单(有线端与无线端共用)。
+ *
+ * 继承 AE2 原版 {@link MEStorageMenu} 的全部普通物品行为(左键/Shift+点击/拖拽均按普通物品处理)。
+ * 源质的存入与取出统一由右键交互承担:
+ *
+ * - 手持装有源质的罐子/安瓿 + 右键任意处 → 源质存入网络({@link EssentiaFillHelper#extractContainerContent})。
+ * - 手持空罐/空安瓿 + 右键源质条目 → 从网络取出装罐({@link #fillContainerFromGrid})。
+ *
+ */
+public class EssentiaTerminalMenu extends MEStorageMenu {
+
+ public EssentiaTerminalMenu(MenuType> menuType, int id, Inventory ip, ITerminalHost host) {
+ super(menuType, id, ip, host);
+ }
+
+ /**
+ * 右键终端源质条目装罐:从网络提取源质,装入手持的源质罐子或玻璃安瓿。
+ * 具体逻辑见 {@link EssentiaFillHelper#fillContainerFromGrid}。
+ *
+ * @param aspectId 源质 aspect 的 ResourceLocation
+ * @return true = 处理成功(装罐/装安瓿),false = 无法处理
+ */
+ public boolean fillContainerFromGrid(ResourceLocation aspectId) {
+ if (isClientSide()) {
+ return false;
+ }
+ var player = getPlayerInventory().player;
+ var carried = getCarried();
+ return EssentiaFillHelper.fillContainerFromGrid(
+ this.storage, this.energySource, getActionSource(), player, carried, aspectId);
+ }
+}
diff --git a/src/main/java/thaumicenergistics/common/container/GuiEssentiaTerminal.java b/src/main/java/thaumicenergistics/common/container/GuiEssentiaTerminal.java
new file mode 100644
index 0000000..8410361
--- /dev/null
+++ b/src/main/java/thaumicenergistics/common/container/GuiEssentiaTerminal.java
@@ -0,0 +1,118 @@
+package thaumicenergistics.common.container;
+
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.entity.player.Inventory;
+import net.minecraft.world.item.ItemStack;
+
+import org.jetbrains.annotations.Nullable;
+
+import thaumcraft.api.aspects.AspectList;
+import thaumcraft.api.aspects.IEssentiaContainerItem;
+
+import appeng.client.gui.me.common.MEStorageScreen;
+import appeng.client.gui.style.ScreenStyle;
+
+import net.neoforged.neoforge.network.PacketDistributor;
+import thaumicenergistics.common.network.EssentiaTerminalDepositC2SPacket;
+import thaumicenergistics.common.network.EssentiaTerminalFillC2SPacket;
+
+/**
+ * 源质终端 GUI:在 AE2 通用终端基础上扩展右键交互。
+ *
+ * - Shift+右键玩家背包格:格内是源质容器 → 源质存入网络(空容器留原格)。
+ * - 右键玩家物品栏/背包槽 → 放行(AE2 原版物品交互)。
+ * - 右键网络区域(源质条目或空白),手持装有源质的罐子/安瓿 → 源质存入 ME 网络(空容器回背包)。
+ * - 手持空罐/空安瓿 + 右键源质条目 → 从网络取出源质装罐/装安瓿。
+ * - 左键仍是正常物品操作(不受影响)。
+ *
+ */
+public class GuiEssentiaTerminal extends MEStorageScreen {
+
+ public GuiEssentiaTerminal(EssentiaTerminalMenu menu, Inventory playerInventory,
+ Component title, ScreenStyle style) {
+ super(menu, playerInventory, title, style);
+ }
+
+ @Override
+ public boolean mouseClicked(double xCoord, double yCoord, int btn) {
+ if (btn == 1) {
+ var carried = this.menu.getCarried();
+ boolean shiftDown = net.minecraft.client.gui.screens.inventory.AbstractContainerScreen.hasShiftDown();
+ // 源质物品来源:优先菜单 carried;为空时回退玩家主手物品
+ ItemStack sourceStack = !carried.isEmpty() ? carried
+ : this.menu.getPlayerInventory().player.getMainHandItem();
+ boolean isContainer = isSourceContainer(sourceStack);
+ boolean containerHasEssentia = isContainer && getContainerEssentia(sourceStack) != null;
+
+ // Shift+右键玩家物品栏/背包格:格内是源质容器 → 存入源质(空容器留原格)
+ if (shiftDown && this.hoveredSlot != null && this.menu.isPlayerSideSlot(this.hoveredSlot)) {
+ var slotStack = this.hoveredSlot.getItem();
+ if (slotStack.getItem() instanceof IEssentiaContainerItem) {
+ PacketDistributor.sendToServer(EssentiaTerminalDepositC2SPacket.fromSlot(
+ this.menu.containerId, this.hoveredSlot.getSlotIndex(), slotStack));
+ return true;
+ }
+ // 非源质容器 → 放行(AE2 原版 Shift+右键快速移动)
+ return super.mouseClicked(xCoord, yCoord, btn);
+ }
+
+ // 普通右键点击玩家物品栏/背包槽 → 放行(AE2 原版物品交互)
+ if (!shiftDown && this.hoveredSlot != null && this.menu.isPlayerSideSlot(this.hoveredSlot)) {
+ return super.mouseClicked(xCoord, yCoord, btn);
+ }
+
+ // Shift+右键:手持源质容器(满或空)→ 拦截。满→存入源质;空→阻止被当普通物品放入网络
+ if (shiftDown && isContainer) {
+ if (containerHasEssentia) {
+ this.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY);
+ PacketDistributor.sendToServer(EssentiaTerminalDepositC2SPacket.fromClientStack(
+ this.menu.containerId, sourceStack));
+ }
+ return true;
+ }
+
+ // 普通右键网络区域:手持满容器 → 存入源质
+ if (containerHasEssentia) {
+ this.menu.setCarried(net.minecraft.world.item.ItemStack.EMPTY);
+ PacketDistributor.sendToServer(EssentiaTerminalDepositC2SPacket.fromClientStack(
+ this.menu.containerId, sourceStack));
+ return true;
+ }
+
+ // 手持空容器:右键源质条目 → 取出装罐;右键网络其他 → 拦截(防被放入网络)
+ if (isContainer) {
+ if (this.hoveredSlot instanceof appeng.client.gui.me.common.RepoSlot repoSlot) {
+ var entry = repoSlot.getEntry();
+ if (entry != null && entry.getWhat() instanceof thaumicenergistics.common.integration.appeng.AEssentiaKey key) {
+ PacketDistributor.sendToServer(EssentiaTerminalFillC2SPacket.create(
+ this.menu.containerId, key.getId(), sourceStack));
+ return true;
+ }
+ }
+ return true;
+ }
+ }
+ return super.mouseClicked(xCoord, yCoord, btn);
+ }
+
+ /** 源质容器:实现 IEssentiaContainerItem 的物品,或空玻璃安瓿(glass_phial 不实现该接口但可装罐)。 */
+ private static boolean isSourceContainer(ItemStack stack) {
+ if (stack.getItem() instanceof IEssentiaContainerItem) {
+ return true;
+ }
+ return stack.is(net.minecraft.core.registries.BuiltInRegistries.ITEM.get(
+ net.minecraft.resources.ResourceLocation.fromNamespaceAndPath("thaumcraft", "glass_phial")));
+ }
+
+ /** 若手持物品是源质容器且非空,返回其 AspectList;否则返回 null。 */
+ @Nullable
+ private static AspectList getContainerEssentia(ItemStack stack) {
+ if (stack.getItem() instanceof IEssentiaContainerItem container) {
+ AspectList aspects = container.getAspects(stack);
+ if (aspects != null && !aspects.isEmpty()) {
+ return aspects;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/thaumicenergistics/common/container/ThEEssentiaMenuAccess.java b/src/main/java/thaumicenergistics/common/container/ThEEssentiaMenuAccess.java
new file mode 100644
index 0000000..e9cfc89
--- /dev/null
+++ b/src/main/java/thaumicenergistics/common/container/ThEEssentiaMenuAccess.java
@@ -0,0 +1,22 @@
+package thaumicenergistics.common.container;
+
+import appeng.api.networking.energy.IEnergySource;
+import appeng.api.storage.MEStorage;
+
+/**
+ * 由 {@code MixinMEStorageMenuAccessors} 注入到 {@code MEStorageMenu} 的访问器接口。
+ *
+ * 编译期接口存在、运行期目标类实现,调用方(如网络包处理)通过
+ * {@code instanceof ThEEssentiaMenuAccess} 安全访问 ME 存储与能量源。
+ *
+ * 注意:必须放在非 mixin 包(mixin 包 {@code thaumicenergistics.mixins.*} 的类
+ * 禁止被普通类直接引用)。
+ */
+public interface ThEEssentiaMenuAccess {
+
+ /** ME 存储(对应 MEStorageMenu.storage)。 */
+ MEStorage thE_getStorage();
+
+ /** 能量源(对应 MEStorageMenu.energySource)。 */
+ IEnergySource thE_getEnergySource();
+}
diff --git a/src/main/java/thaumicenergistics/common/container/slot/ReadOnlySlot.java b/src/main/java/thaumicenergistics/common/container/slot/ReadOnlySlot.java
new file mode 100644
index 0000000..b802867
--- /dev/null
+++ b/src/main/java/thaumicenergistics/common/container/slot/ReadOnlySlot.java
@@ -0,0 +1,58 @@
+package thaumicenergistics.common.container.slot;
+
+import net.minecraft.world.Container;
+import net.minecraft.world.SimpleContainer;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.inventory.Slot;
+import net.minecraft.world.item.ItemStack;
+import net.neoforged.neoforge.items.IItemHandler;
+
+/**
+ * 只读展示槽:显示 IItemHandler 中真实物品,但禁止玩家/整理 mod 交互。
+ * 与 AE2 ClientReadOnlySlot 等效(保留 inventory 用于渲染,mayPlace/mayPickup/set/remove 空操作)。
+ * 用于玩家不交互的纯展示槽(装配器的预存配方槽、目标产物槽)。
+ */
+public class ReadOnlySlot extends Slot {
+
+ /** 假 inventory 占位:阻止整理 mod 通过 slot 的 inventory/index 交互。 */
+ private static final Container EMPTY_INVENTORY = new SimpleContainer(0);
+
+ private final IItemHandler handler;
+ private final int handlerIndex;
+
+ public ReadOnlySlot(IItemHandler handler, int index, int xPosition, int yPosition) {
+ super(EMPTY_INVENTORY, 0, xPosition, yPosition);
+ this.handler = handler;
+ this.handlerIndex = index;
+ }
+
+ @Override
+ public ItemStack getItem() {
+ // 读取真实 inventory 用于渲染显示;handlerIndex 是 handler 内索引(Slot.index 是菜单槽序号,勿用)
+ return handler.getStackInSlot(handlerIndex);
+ }
+
+ @Override
+ public boolean mayPlace(ItemStack stack) {
+ return false;
+ }
+
+ @Override
+ public final void set(ItemStack stack) {
+ }
+
+ @Override
+ public int getMaxStackSize() {
+ return 0;
+ }
+
+ @Override
+ public ItemStack remove(int amount) {
+ return ItemStack.EMPTY;
+ }
+
+ @Override
+ public boolean mayPickup(Player player) {
+ return false;
+ }
+}