fix(inventory-helper): restore native and advanced anvil combining
CI / build (push) Successful in 1m23s
CI / build (pull_request) Successful in 1m23s

This commit is contained in:
dmg committed 2026-10-12 20:03:25 -04:00
1 parent a08754a3aa
commit 09bc15e979
14 files changed
+1066 -52

No files matched your search

+7 -5
View File
@@ -84,7 +84,9 @@ When a main-hand tool breaks, an item with the exact same material is moved into
## Advanced anvil combining
All players can combine two **same-material, non-book items** in an ordinary anvil. Equal enchantment levels become one higher, even above the normal maximum; unequal levels keep the higher. Compatible applicable enchantments carry over. Conflicts, inapplicable enchantments and results the server cannot serialize exactly are refused rather than silently discarded.
All players can combine two **same-material, non-book items** in an ordinary anvil. Enhanced combining applies **only when the combined enchantments exceed a normal maximum**: equal levels become one higher; unequal levels keep the higher. Compatible applicable enchantments carry over. Enhanced conflicts, inapplicable enchantments and results the server cannot serialize exactly are refused rather than silently discarded.
Within-limit combinations (for example Sharpness I + I → II or IV + IV → V) stay entirely vanilla, including normal XP/prior-work costs, naming, repair and refusals. Removing advanced inputs and inserting normal items switches back immediately; **no closing/reopening or confirmation is required**. Old advanced clicks cannot take a newly prepared normal result.
The result preserves the first/left item's exact name, lore and unrelated metadata, with combined enchantments and normal durability repair. Rename text is ignored for enhanced merges. Book operations, rename-only, repair-only and unrelated operations remain vanilla.
@@ -94,7 +96,7 @@ Default risk uses the **largest resulting enchantment excess above its normal ma
| Excess | Failure chance |
| --- | --- |
| 0 | 0% |
| 0 | Native combining; no enhanced roll |
| 1 | 20% |
| 2 | 25% |
| 3 | 35% |
@@ -103,7 +105,7 @@ Default risk uses the **largest resulting enchantment excess above its normal ma
| 6 | 95% |
| 7 or more | 99% |
ProtocolLib binds taking to the displayed menu generation before payment. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.2 build 2618 and fails closed when enhanced generation admission is unavailable. This is a library dependency, not a Fantasy Stick/gameplay-plugin bridge.
ProtocolLib binds taking to the displayed menu generation before payment. A bounded 40-server-tick admission accommodates the main → Netty → native-main handoff; an owned native dispatch shim carries exact packet identity through that queue, so one click cannot borrow another click's cancellation or payment ticket. Native aggregate and packet-specific limits remain authoritative, using the actual connection's counters and violation actions; expired/refused/cancelled dispatches never pay. Input or acknowledged risk changes invalidate old clicks even when the result item is identical; the ordinary anvil stays open without another confirmation. The adapter is pinned to Purpur 26.2 build 2618 and fails closed when enhanced generation admission is unavailable. This is a library dependency, not a Fantasy Stick/gameplay-plugin bridge.
Use left/right pickup, shift-click or a native-eligible empty hotbar/offhand destination. Full destinations, cancelled/stale takes and unsupported cloning/drop/collection actions do not roll or spend items. Ordinary anvil wear remains in effect. Dedicated gameplay-plugin compatibility is unsupported; declared native cancellations and unrelated inventory state are preserved.
@@ -120,9 +122,9 @@ Administrators with `spigotinventoryhelper.admin` (default OP) and authorized co
| `advanced-anvil-risk-step-percent` | 5 |
| `advanced-anvil-max-failure-percent` | 99 |
Values are integers 0–100. For excess `E > 0`, failure is `min(cap, base + step × E × (E − 1) / 2)`; E0 always has zero risk. The default cap leaves 1% success, but an explicit cap100 can permit guaranteed destruction. Saves occur off-thread and acknowledge only after durable replacement; failed saves keep effective values unchanged. Acknowledged changes invalidate existing previews, and restart loads saved values. Autocomplete is permission-aware and side-effect free. A missing file uses packaged defaults without a startup write; malformed settings withhold enhanced gameplay, leaving existing features available.
Values are integers 0–100. For excess `E > 0`, failure is `min(cap, base + step × E × (E − 1) / 2)`; E0 stays native and never performs an enhanced roll. The default cap leaves 1% success, but an explicit cap100 can permit guaranteed destruction. Saves occur off-thread and acknowledge only after durable replacement; failed saves keep effective values unchanged. Acknowledged changes invalidate existing previews, and restart loads saved values. Autocomplete is permission-aware and side-effect free. A missing file uses packaged defaults without a startup write; malformed settings withhold enhanced gameplay, leaving existing features available.
Native automated tests cover menus, registered events/commands and ProtocolLib listeners, item serialization, packet/result-taking, stale client generations, and protected explosion boundaries. Actual client visuals and live destructive testing remain separate follow-ups, not deployment evidence.
Native automated tests cover menus, registered events/commands and ProtocolLib listeners, real declared-library forwarding/local Netty pipeline/native queue dispatch, item serialization, result-taking, native packet limits, stale client generations and protected explosion boundaries. Pipeline installation on a live server, real-client transport/rendering and installed-stack behavior are not established by these controlled fixtures. Actual client visuals and live destructive testing remain separate follow-ups, not deployment evidence.
## Releases
@@ -140,7 +140,7 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
view.getTopInventory().setItem(2, null);
return;
}
if (preview == null && !protocol.watched(view)) return; // Never-owned vanilla stays native.
if (preview == null) return; // Normal output/XP/cancellation stay native, including after enhanced use.
boolean duplicate = seen.put(event, Boolean.TRUE) != null;
var admission = protocol.consume(view, preview, event);
if (duplicate || admission == null) {
@@ -148,7 +148,6 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
if (preview != null) rerender(view);
return;
}
if (preview == null) return; // Fresh normal book/repair after enhanced context stays native.
boolean stale = !matches(view, preview)
|| !Objects.equals(preview.proposal().output(), view.getTopInventory().getItem(2));
AdvancedAnvilDelivery delivery = preview.proposal().output() == null ? null
@@ -267,6 +266,16 @@ final class AdvancedAnvilListener implements Listener, AutoCloseable {
}
}
void protocolHookReady(AnvilView view) {
if (!closed && current(view) && !pending.containsKey(view)) recompute(view);
}
void protocolRejected(AnvilView view) {
// Exact native dispatch ended without a Bukkit admission (expiry/refusal/revocation).
// Correct client prediction only for this still-current window, never a reopened one.
if (!closed && current(view) && view.getPlayer() instanceof Player player) player.updateInventory();
}
Object protocolPreview(AnvilView view) { return previews.get(view); }
boolean protocolReady(AnvilView view) {
@@ -35,6 +35,10 @@ final class AdvancedAnvilMerge {
} catch (ArithmeticException overflow) {
return refused("Enchantment level cannot be represented by this server.");
}
int excess = levels.entrySet().stream()
.mapToInt(entry -> Math.max(0, entry.getValue() - entry.getKey().getMaxLevel()))
.max().orElse(0);
if (excess == 0) return null; // Native XP, rename, repair and conflict decisions stay native.
ItemStack output = left.clone();
output.setAmount(1);
var enchantments = new ArrayList<>(levels.keySet());
@@ -65,9 +69,6 @@ final class AdvancedAnvilMerge {
} catch (RuntimeException unsupported) {
return refused("Result cannot be represented exactly by this server.");
}
int excess = levels.entrySet().stream()
.mapToInt(entry -> Math.max(0, entry.getValue() - entry.getKey().getMaxLevel()))
.max().orElse(0);
return new Proposal(output, policy.failurePercent(excess), null);
}
@@ -22,15 +22,27 @@ import org.bukkit.scheduler.BukkitTask;
/** Main-thread, one-use protocol-to-Bukkit admission for exact watched native anvil views. */
final class AdvancedAnvilProtocolGuard implements AutoCloseable {
// SYNC invokes adapters on main, not native handling: ProtocolLib forwards via Netty and
// Purpur queues back to main. Bound lost intents without racing the next scheduler tick.
static final long ADMISSION_TICKS = 40L;
static final class Admission {
final PacketEvent event;
final ServerboundContainerClickPacket packet;
final Object preview;
final java.util.function.BooleanSupplier available;
BukkitTask expiry;
boolean sealed;
volatile boolean sealed;
volatile boolean revoked;
volatile boolean handedOff;
boolean consumed;
final AnvilView view;
final net.minecraft.server.network.ServerGamePacketListenerImpl nativeListener;
java.util.function.BooleanSupplier dispatchable;
Runnable completed;
Admission(PacketEvent event, ServerboundContainerClickPacket packet, Object preview,
java.util.function.BooleanSupplier available) {
java.util.function.BooleanSupplier available, AnvilView view) {
this.view = view;
this.nativeListener = NativeAnvilGeneration.player(view).connection;
this.event = event;
this.packet = packet;
this.preview = preview;
@@ -38,7 +50,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
}
boolean valid() {
// Consumption removes the map ticket, but bridge loss must still revoke deferred payment.
return available.getAsBoolean() && sealed && !event.isCancelled() && event.getPacket().getHandle() == packet;
return !revoked && available.getAsBoolean() && sealed && !event.isCancelled() && event.getPacket().getHandle() == packet;
}
}
@@ -49,6 +61,7 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
// Only this immutable actor membership is read on an unexpectedly off-thread library callback.
private final Set<Player> actors = java.util.concurrent.ConcurrentHashMap.newKeySet();
private final Map<AnvilView, Admission> admissions = new IdentityHashMap<>();
private final Map<Player, NativeAnvilDispatch> dispatches = new IdentityHashMap<>();
private final Map<PacketEvent, Boolean> seen = new java.util.WeakHashMap<>();
private record Frame(PacketEvent event, Object packet, int version, int nativeState, Object preview) {
boolean valid() { return !event.isCancelled() && event.getPacket().getHandle() == packet; }
@@ -97,6 +110,21 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
try {
NativeAnvilGeneration.menu(view);
if (!(view.getPlayer() instanceof Player player)) return false;
NativeAnvilDispatch dispatch = dispatches.get(player);
if (dispatch != null && !dispatch.attached()) {
dispatches.remove(player);
dispatch = null;
}
if (dispatch == null) {
var nativePlayer = NativeAnvilGeneration.player(view);
dispatch = new NativeAnvilDispatch(nativePlayer.connection.connection,
nativePlayer.level().getServer().packetProcessor(), () -> {
if (!closed && !plugin.getServer().isPrimaryThread()) plugin.getServer().getScheduler().runTask(plugin,
() -> { if (!closed && !watched.contains(view)) owner.protocolHookReady(view); });
});
dispatches.put(player, dispatch);
}
if (!dispatch.ready()) return false;
watched.add(view);
actors.add(player);
invalidate(view);
@@ -108,7 +136,11 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
void invalidate(AnvilView view) {
Admission admission = admissions.remove(view);
if (admission != null && admission.expiry != null) admission.expiry.cancel();
if (admission != null) {
admission.revoked = true;
// Retain expiry for a cancelled/non-forwarded record too; cancelling it here
// would leak transport ownership when a newer intent replaces this admission.
}
}
private void send(PacketEvent event) {
@@ -166,7 +198,8 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
|| !(raw instanceof ServerboundContainerClickPacket packet)
|| packet.containerInput() == null || !currentFrame(view, packet)
|| (NativeAnvilGeneration.take(packet) && !NativeAnvilGeneration.supportedTake(packet))
|| !owner.protocolReady(view)) {
|| !owner.protocolReady(view)
|| (owner.protocolPreview(view) != null && !dispatches.get(event.getPlayer()).ready())) {
refuse(event, view);
return;
}
@@ -175,13 +208,41 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
var translated = new ServerboundContainerClickPacket(packet.containerId(), frames.get(view).nativeState(),
packet.slotNum(), packet.buttonNum(), packet.containerInput(), packet.changedSlots(), packet.carriedItem());
event.setPacket(new PacketContainer(event.getPacketType(), translated));
if (!NativeAnvilGeneration.take(translated)) return;
// Formerly enhanced windows retain only frame translation/refusal for normal
// operations. No normal take depends on enhanced identity-budget/queue ownership.
if (!NativeAnvilGeneration.take(translated) || owner.protocolPreview(view) == null) return;
NativeAnvilDispatch dispatch = dispatches.get(event.getPlayer());
Admission admission = new Admission(event, translated, owner.protocolPreview(view),
() -> !closed && !manager.isClosed() && !versions.exhausted());
admissions.put(view, admission);
admission.expiry = plugin.getServer().getScheduler().runTask(plugin, () -> {
() -> !closed && !manager.isClosed() && !versions.exhausted() && dispatch.ready(), view);
admission.dispatchable = () -> admissions.get(view) == admission && watched.contains(view)
&& NativeAnvilGeneration.current(view, admission.packet)
&& owner.protocolPreview(view) == admission.preview && owner.protocolReady(view);
admission.completed = () -> {
if (admissions.get(view) == admission) admissions.remove(view);
if (admission.expiry != null) admission.expiry.cancel();
if (!admission.consumed && !closed && !admissions.containsKey(view)) owner.protocolRejected(view);
};
if (!dispatch.admit(admission)) { refuse(event, view); return; }
admissions.put(view, admission);
var marker = com.comphenix.protocol.events.NetworkMarker.getNetworkMarker(event);
if (marker == null) {
marker = new com.comphenix.protocol.events.NetworkMarker(
com.comphenix.protocol.events.ConnectionSide.CLIENT_SIDE, event.getPacketType());
event.setNetworkMarker(marker);
}
marker.addPostListener(new com.comphenix.protocol.events.PacketPostListener() {
@Override public Plugin getPlugin() { return plugin; }
@Override public void onPostEvent(PacketEvent forwarded) {
if (forwarded == event) admission.handedOff = true;
}
});
admission.expiry = plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
admission.revoked = true;
if (admissions.get(view) == admission) admissions.remove(view);
// Post means forward scheduled, not native completion. Keep such tombstones
// until the exact record reaches the hook or its queued wrapper finishes.
if (!admission.handedOff) dispatch.complete(admission);
}, ADMISSION_TICKS);
} catch (RuntimeException | LinkageError unavailable) { refuse(event, view); }
}
@@ -205,11 +266,14 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
}
Admission consume(AnvilView view, Object preview, InventoryClickEvent event) {
Admission admission = admissions.get(view);
invalidate(view);
if (closed || admission == null || !admission.valid() || admission.preview != preview
Admission admission = NativeAnvilDispatch.current();
if (closed || admission == null || admission.view != view || admissions.get(view) != admission
|| !admission.valid() || admission.preview != preview
|| !NativeAnvilGeneration.current(view, admission.packet)
|| !NativeAnvilGeneration.matches(admission.packet, event) || !owner.protocolReady(view)) return null;
admissions.remove(view);
admission.consumed = true;
if (admission.expiry != null) admission.expiry.cancel();
return admission;
}
@@ -223,7 +287,13 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
watched.remove(view);
frames.remove(view);
if (view.getPlayer() instanceof Player player
&& watched.stream().noneMatch(other -> other.getPlayer() == player)) actors.remove(player);
&& watched.stream().noneMatch(other -> other.getPlayer() == player)) {
actors.remove(player);
NativeAnvilDispatch dispatch = dispatches.get(player);
if (dispatch != null) dispatch.retire();
// Reuse a draining hook across window reuse, never install a second boundary.
if (dispatch != null && !dispatch.ready()) dispatches.remove(player);
}
}
void prune() {
@@ -241,6 +311,8 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
// unrelated vanilla XP take after the protocol listeners have been removed.
if (view.getPlayer().getOpenInventory() == view) view.getPlayer().closeInventory();
}
dispatches.values().forEach(NativeAnvilDispatch::retire);
dispatches.clear();
actors.clear();
seen.clear();
manager.removePacketListener(outbound);
@@ -0,0 +1,158 @@
package games.dmg.spigotinventoryhelper;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelPipeline;
import java.util.IdentityHashMap;
import java.util.Map;
import net.minecraft.network.Connection;
import net.minecraft.network.PacketProcessor;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.PacketType;
import net.minecraft.network.protocol.PacketUtils;
import net.minecraft.network.protocol.game.ServerGamePacketListener;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
/** Exact record ownership behind ProtocolLib, retained through the native thread hop.
* The synchronized ledger is transport-only; all Bukkit/policy validation runs on native main.
*/
final class NativeAnvilDispatch extends ChannelInboundHandlerAdapter {
static final int MAX_OUTSTANDING = 32;
private static final ThreadLocal<AdvancedAnvilProtocolGuard.Admission> CURRENT = new ThreadLocal<>();
private final Connection connection;
private final PacketProcessor processor;
private final Runnable readyCallback;
private final Map<ServerboundContainerClickPacket, AdvancedAnvilProtocolGuard.Admission> records =
new IdentityHashMap<>();
private final java.util.Set<ServerboundContainerClickPacket> dispatched =
java.util.Collections.newSetFromMap(new IdentityHashMap<>());
private volatile boolean ready;
private volatile boolean removing;
private boolean retiring;
NativeAnvilDispatch(Connection connection, PacketProcessor processor, Runnable readyCallback) {
NativeAnvilPacketLimits.link();
this.connection = java.util.Objects.requireNonNull(connection);
this.processor = java.util.Objects.requireNonNull(processor);
this.readyCallback = readyCallback;
ChannelPipeline pipeline = java.util.Objects.requireNonNull(connection.channel).pipeline();
ChannelHandlerContext nativeContext = pipeline.context(connection);
if (nativeContext == null) throw new IllegalStateException("Missing native connection boundary");
// The pinned Connection handler must be last; ProtocolLib resumes fireChannelRead ahead
// of it. Do not replace, reorder or remove any foreign handler.
if (pipeline.last() != connection) throw new IllegalStateException("Unsupported connection pipeline");
pipeline.addBefore(nativeContext.name(), null, this);
}
boolean attached() { return !removing && connection.channel.pipeline().context(this) != null; }
boolean ready() {
var pipeline = connection.channel.pipeline();
var context = pipeline.context(this);
return ready && !removing && connection.channel.isActive() && context != null
&& pipeline.context(connection) != null && pipeline.last() == connection
&& pipeline.names().indexOf(context.name()) + 1
== pipeline.names().indexOf(pipeline.context(connection).name());
}
@Override public void handlerAdded(ChannelHandlerContext context) {
ready = true;
readyCallback.run(); // Schedules owner recomputation; never accesses a view here.
}
@Override public void handlerRemoved(ChannelHandlerContext context) {
ready = false;
synchronized (this) { records.values().forEach(admission -> admission.revoked = true); }
}
synchronized boolean admit(AdvancedAnvilProtocolGuard.Admission admission) {
if (!ready() || records.size() >= MAX_OUTSTANDING || records.containsKey(admission.packet)) return false;
retiring = false;
records.put(admission.packet, admission);
return true;
}
static AdvancedAnvilProtocolGuard.Admission current() { return CURRENT.get(); }
@Override public void channelRead(ChannelHandlerContext context, Object message) {
AdvancedAnvilProtocolGuard.Admission admission;
synchronized (this) {
admission = records.get(message);
if (admission != null && !dispatched.add(admission.packet)) return;
}
if (admission == null) {
context.fireChannelRead(message);
} else {
// Even a revoked owned record must traverse native connection admission/limits.
// Its exact wrapper denies gameplay on main; do not hide accepted traffic from
// aggregate or record-class native rate counters by dropping it ahead of Connection.
context.fireChannelRead(new BoundPacket(admission));
}
}
synchronized void complete(AdvancedAnvilProtocolGuard.Admission admission) {
if (records.get(admission.packet) == admission) records.remove(admission.packet);
dispatched.remove(admission.packet);
removeIfDrained();
}
synchronized void retire() {
retiring = true;
records.values().forEach(admission -> admission.revoked = true);
removeIfDrained();
}
private void removeIfDrained() {
if (retiring && records.isEmpty()) {
var pipeline = connection.channel.pipeline();
if (!removing && pipeline.context(this) != null) {
removing = true; // Do not reactivate a hook whose event-loop removal is queued.
pipeline.remove(this);
}
}
}
@Override public void channelInactive(ChannelHandlerContext context) {
synchronized (this) {
records.values().forEach(admission -> admission.revoked = true);
records.clear();
dispatched.clear();
retiring = true;
removeIfDrained();
}
context.fireChannelInactive();
}
final class BoundPacket implements Packet<ServerGamePacketListener> {
private final AdvancedAnvilProtocolGuard.Admission admission;
private boolean nativeLimitChecked;
BoundPacket(AdvancedAnvilProtocolGuard.Admission admission) { this.admission = admission; }
@Override public PacketType<? extends Packet<ServerGamePacketListener>> type() { return admission.packet.type(); }
@Override public void handle(ServerGamePacketListener listener) {
if (!nativeLimitChecked) {
nativeLimitChecked = true;
if (NativeAnvilPacketLimits.refuse(connection, admission.packet)) {
admission.revoked = true;
complete(admission);
// This refusal can occur on Netty: leave the main-thread view ledger to
// its bounded expiry/new intent, never invoke its completion callback here.
return;
}
}
// Queue THIS wrapper, not the original record, so identity survives native PacketProcessor.
PacketUtils.ensureRunningOnSameThread(this, listener, processor);
var previous = CURRENT.get();
try {
if (ready() && admission.valid() && admission.dispatchable.getAsBoolean()
&& listener == admission.nativeListener) {
CURRENT.set(admission);
admission.packet.handle(listener);
}
} finally {
if (previous == null) CURRENT.remove(); else CURRENT.set(previous);
complete(admission); // Includes native silent refusal before any Bukkit event.
admission.completed.run();
}
}
}
}
@@ -18,6 +18,13 @@ final class NativeAnvilGeneration {
return menu;
}
static net.minecraft.server.level.ServerPlayer player(AnvilView view) {
if (!(view.getPlayer() instanceof org.bukkit.craftbukkit.entity.CraftPlayer player)) {
throw new IllegalStateException("Unsupported native anvil actor");
}
return player.getHandle();
}
/** Ensure CraftEventFactory's post-event broadcast emits the final installed result.
* Quantity/risk-only changes may otherwise be skipped by native result diffing.
* Invalidate only the owned result's remote prediction, never an actual item or input.
@@ -0,0 +1,87 @@
package games.dmg.spigotinventoryhelper;
import io.papermc.paper.configuration.GlobalConfiguration;
import io.papermc.paper.util.IntervalledCounter;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Field;
import java.util.Map;
import net.minecraft.network.Connection;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
/** Pinned Connection class-specific admission for a record carried by a dispatch wrapper.
* Connection still performs its aggregate limit first. Use its SAME lock/counters/config, once
* before the wrapper's native thread hop, not an independent plugin rate policy or counter.
*/
final class NativeAnvilPacketLimits {
private static final Field LOCK = field("PACKET_LIMIT_LOCK", Object.class);
private static final Field ALL = field("allPacketCounts", IntervalledCounter.class);
private static final Field SPECIFIC = field("packetSpecificLimits", Map.class);
private static final Field RECEIVED = field("receivedPackets", int.class);
private static final MethodHandle PUT;
private static final MethodHandle KICK;
static {
try {
PUT = MethodHandles.lookup().findVirtual(Map.class, "put",
MethodType.methodType(Object.class, Object.class, Object.class));
var method = Connection.class.getDeclaredMethod("killForPacketSpam");
method.setAccessible(true);
KICK = MethodHandles.lookup().unreflect(method);
} catch (ReflectiveOperationException failure) { throw new ExceptionInInitializerError(failure); }
}
private NativeAnvilPacketLimits() {}
static void link() { /* Initialize/verify pinned linkage before any enhanced output. */ }
static boolean refuse(Connection connection, ServerboundContainerClickPacket record) {
try {
var configuration = GlobalConfiguration.get().packetLimiter;
if (configuration == null) throw new IllegalStateException("Missing native packet limiter");
// Match native fast-path: superclass overrides are inspected only if an aggregate
// counter exists or the exact incoming record class has a configured entry.
if (ALL.get(connection) == null && !configuration.overrides.containsKey(record.getClass())) return false;
Object lock = LOCK.get(connection);
if (lock == null || !(SPECIFIC.get(connection) instanceof Map<?, ?> counters)) {
throw new IllegalStateException("Unsupported native packet counters");
}
long now = System.nanoTime();
synchronized (lock) {
for (Class<?> type = record.getClass(); type != Object.class; type = type.getSuperclass()) {
var limit = configuration.overrides.get(type);
if (limit == null || !limit.isEnabled()) continue;
Object stored = counters.get(type);
IntervalledCounter counter;
if (stored == null) {
counter = new IntervalledCounter((long) (limit.interval() * 1.0E9));
PUT.invoke(counters, type, counter);
} else if (stored instanceof IntervalledCounter existing) counter = existing;
else throw new IllegalStateException("Unsupported native packet counter type");
counter.updateAndAdd(1L, now);
if (counter.getRate() < limit.maxPacketRate()) continue;
if (limit.action() == GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK) {
KICK.invoke(connection); // Native disconnect/cancellation path, not a custom kick.
} else if (limit.action() != GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP) {
continue;
}
// Native class refusal returns before receivedPackets++. The native outer
// Connection will increment after this callback, so preserve that accounting.
RECEIVED.setInt(connection, RECEIVED.getInt(connection) - 1);
return true;
}
}
return false;
} catch (Throwable failure) {
if (failure instanceof Error error) throw error;
throw new IllegalStateException("Unavailable native anvil packet-limit bridge", failure);
}
}
private static Field field(String name, Class<?> type) {
try {
var field = Connection.class.getDeclaredField(name);
if (field.getType() != type) throw new IllegalStateException("Unsupported native field " + name);
field.setAccessible(true);
return field;
} catch (ReflectiveOperationException failure) { throw new ExceptionInInitializerError(failure); }
}
}
@@ -80,11 +80,9 @@ class NativeAdvancedAnvilProtocolTest {
assertFalse(admitted.isCancelled());
if (policyOnly) {
f.admin(f.console, "advanced-anvil-base-failure-percent", 80);
// The admission expiry is deliberately pending: wait for the ACTUAL save
// acknowledgment, not accidentally treating ticket expiry as policy proof.
Runnable expiry = f.nextTick.remove();
// Wait for ACTUAL durable publication with the bounded admission expiry
// still pending; policy invalidation, not elapsed time, must refuse the take.
f.acknowledgeSave();
expiry.run();
} else f.inputs(NativeAdvancedAnvilTransactionTest.stackedSword(10, 3),
NativeAdvancedAnvilTransactionTest.stackedSword(10, 3));
var left = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
@@ -111,7 +109,9 @@ class NativeAdvancedAnvilProtocolTest {
var input = f.menu.getBukkitView().getTopInventory().getItem(0).clone();
assertFalse(f.receive(raw(f, mode.equals("slot") ? 3 : 2, 0,
mode.equals("slot") ? ContainerInput.PICKUP_ALL : ContainerInput.PICKUP)).isCancelled());
if (mode.equals("lost")) f.tick();
if (mode.equals("lost")) {
for (long tick = 0; tick < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; tick++) f.tick();
}
packet.handler.handleContainerClick(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) (mode.equals("button") ? 1 : 0),
mode.equals("input") ? ContainerInput.QUICK_MOVE : ContainerInput.PICKUP,
@@ -23,6 +23,33 @@ class NativeAdvancedAnvilRiskTest {
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return selectedRoll.get(); }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var effects = new NativeAnvilEffectsFixture(f);
if (excess == 0) {
// US-010 supersedes enhanced E0 routing: keep the 100 controls, but native
// payment/output and absence of ANY enhanced roll/preview are authoritative.
for (int roll = 0; roll < 100; roll++) {
selectedRoll.set(roll);
NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f, 100);
f.player.setItemOnCursor(null);
var ordinary = NativeAnvilCombiningRegressionTest.sword(4);
ordinary.addUnsafeEnchantment(Enchantment.UNBREAKING, 2);
f.inputs(ordinary, ordinary.clone());
assertTrue(f.actionBars.isEmpty());
assertFalse(f.menu.bypassCost);
int cost = f.menu.getCost();
assertTrue(cost > 0);
var expected = f.output().clone();
assertEquals(5, expected.getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3, expected.getEnchantmentLevel(Enchantment.UNBREAKING));
packet.click(2, 0, ContainerInput.PICKUP); f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(100 - cost, f.nativePlayer.experienceLevel);
assertEquals(0, rolls.get());
assertTrue(effects.explosions.isEmpty());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
}
return;
}
for (int roll = 0; roll < 100; roll++) {
selectedRoll.set(roll);
f.player.setItemOnCursor(null);
@@ -59,14 +59,14 @@ class NativeAdvancedAnvilTest {
fixture.inputs(sword(5), sword(7));
assertEquals(7, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.UNBREAKING, 3);
fixture.inputs(sword(5), right);
assertEquals(5, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), right);
fixture.inputs(sword(6), right);
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
fixture.inputs(new ItemStack(Material.DIAMOND_SWORD), sword(6));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(0, fixture.menu.getCost());
fixture.inputs(right, new ItemStack(Material.DIAMOND_SWORD));
assertEquals(3, fixture.output().getEnchantmentLevel(Enchantment.UNBREAKING));
fixture.inputs(sword(6), new ItemStack(Material.DIAMOND_SWORD));
assertEquals(6, fixture.output().getEnchantmentLevel(Enchantment.SHARPNESS));
}
@Test void absentRightMaxDamageUsesNativeZeroNotMaterialDefaultForRepair() {
@@ -82,11 +82,11 @@ class NativeAdvancedAnvilTest {
@Test void conflictingEntireOutputRefusesRatherThanNativeDropping() {
var right = enchanted(Material.DIAMOND_SWORD, Enchantment.SMITE, 5);
fixture.inputs(sword(5), right);
fixture.inputs(sword(6), right);
assertNull(fixture.output());
assertTrue(lastAction().contains("conflict"));
assertInputs(sword(5), right);
var alreadyConflicting = sword(5);
assertInputs(sword(6), right);
var alreadyConflicting = sword(6);
alreadyConflicting.addUnsafeEnchantment(Enchantment.SMITE, 4);
fixture.inputs(alreadyConflicting, new ItemStack(Material.DIAMOND_SWORD));
assertNull(fixture.output(), "Conflicts already present on left cannot be silently retained");
@@ -94,12 +94,12 @@ class NativeAdvancedAnvilTest {
@Test void inapplicableLeftAndRightEnchantmentsRefuseWithExplanation() {
var invalid = enchanted(Material.DIAMOND_SWORD, Enchantment.PROTECTION, 4);
fixture.inputs(sword(5), invalid);
fixture.inputs(sword(6), invalid);
assertNull(fixture.output());
assertTrue(lastAction().contains("applicable"));
fixture.inputs(invalid, sword(5));
fixture.inputs(invalid, sword(6));
assertNull(fixture.output());
assertInputs(invalid, sword(5));
assertInputs(invalid, sword(6));
}
@Test void nativeClampBoundaryIsAcceptedAt255AndRefusedAt256WithoutPayment() {
@@ -168,7 +168,7 @@ class NativeAdvancedAnvilTest {
int expected = Math.max(0, maximum - ((maximum - 1500) * 2 + maximum * 12 / 100));
assertEquals(expected, ((Damageable) fixture.output().getItemMeta()).getDamage());
assertInputs(left, right);
fixture.inputs(damaged(sword(5), 2), damaged(sword(4), maximum - 1));
fixture.inputs(damaged(sword(6), 2), damaged(sword(4), maximum - 1));
assertEquals(0, ((Damageable) fixture.output().getItemMeta()).getDamage());
}
@@ -0,0 +1,464 @@
package games.dmg.spigotinventoryhelper;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import net.minecraft.world.inventory.ContainerInput;
import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemStack;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Ordinary and over-limit combining through registered native result-taking paths. */
class NativeAnvilCombiningRegressionTest {
@TempDir Path directory;
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(ints = {1, 4})
void withinLimitCombiningRetainsNativeExperienceAndResultTaking(int level) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
org.mockito.Mockito.when(random.nextFloat()).thenReturn(0.99F);
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
org.mockito.Mockito.doCallRealMethod().when(f.nativePlayer).giveExperienceLevels(org.mockito.ArgumentMatchers.anyInt());
f.nativePlayer.experienceLevel = 20;
var left = sword(level); var right = sword(level);
f.inputs(left, right);
int cost = f.menu.getCost();
assertTrue(cost > 0, "Within-limit merging must retain its actual native XP cost");
assertFalse(f.menu.getBukkitView().canBypassCost(), "No advanced cost bypass for an ordinary merge");
assertTrue(f.actionBars.isEmpty(), "An ordinary merge must not acquire an advanced risk preview");
assertEquals(level + 1, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(level + 1, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(20 - cost, f.nativePlayer.experienceLevel);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
f.tick();
assertEquals(0, rolls.get(), "Native merging performs no enhanced roll or deferred payment");
assertEquals(level + 1, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
}
}
@Test void queuedDeclaredLibraryTakeSurvivesSchedulerBeforeNativeDispatch() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
assertEquals(6, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
transport.receive(raw);
assertEquals(1, transport.received.size());
assertFalse(transport.received.getFirst().isCancelled());
assertTrue(packet.clicks.isEmpty(), "ProtocolLib queues forwarding rather than native inline dispatch");
f.tick(); // Scheduler can run before the Netty forward/native-main handoff.
assertEquals(0, rolls.get());
transport.forwardToNativeQueue();
assertTrue(packet.clicks.isEmpty(), "Native PacketUtils must queue off-thread dispatch");
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors();
f.assertNoEventErrors();
assertEquals(1, packet.clicks.size());
assertFalse(packet.clicks.getFirst().isCancelled(), "An unchanged admitted generation remains takeable across real queues");
f.tick();
assertEquals(1, rolls.get());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(transport.processor.executeSinglePacket());
}
}
@Test void earlierForwardedCancellationCannotBorrowALaterIdenticalAdmission() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
var raw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
transport.receive(raw);
transport.forwardToNativeQueue(); // First exact wrapper is already in native PacketProcessor.
transport.receive(raw);
transport.forwardToNativeQueue();
assertEquals(2, transport.received.size());
assertFalse(transport.received.getFirst().isCancelled());
assertFalse(transport.received.getLast().isCancelled());
// ProtocolLib has already scheduled both forwards. The first event is cancelled
// before its native event; the later admission must not authorize the first event.
transport.received.getFirst().setCancelled(true);
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors();
f.assertNoEventErrors();
assertTrue(packet.clicks.isEmpty(), "Each native take must use its OWN final protocol cancellation: revoked wrapper is dropped before Bukkit");
f.tick();
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(sword(5), f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(sword(5), f.menu.getBukkitView().getTopInventory().getItem(1));
assertTrue(transport.processor.executeSinglePacket(), "Second exact wrapper still owns its admission");
assertEquals(1, packet.clicks.size());
assertFalse(packet.clicks.getFirst().isCancelled());
f.tick();
assertEquals(1, rolls.get());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(transport.processor.executeSinglePacket());
}
}
@Test void realConnectionClickSpecificNativeRefusalMustStillApplyToOwnedDispatch() throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
var previousLimiter = config.packetLimiter;
try {
var limiter = config.new PacketLimiter();
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
0, 0, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP);
limiter.overrides = new java.util.HashMap<>();
limiter.overrides.put(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.class,
new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
1, 0.1, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP));
config.packetLimiter = limiter;
// Actual pinned Connection, not the ordinary fixture's controlled handler port.
var nativeConnection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
nativeConnection.channel = f.channel;
NativeAnvilFixture.field(net.minecraft.network.Connection.class, nativeConnection, "packetListener", f.transport);
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
f.transport, "connection", nativeConnection);
f.channel.pipeline().replace(f.connection, "packet_handler", nativeConnection);
var handled = new AtomicInteger();
org.mockito.Mockito.doAnswer(inv -> { handled.incrementAndGet(); return null; })
.when(f.transport).handleContainerClick(org.mockito.ArgumentMatchers.any());
f.inputs(sword(5), sword(5));
var nativeRaw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
f.dispatchInbound(nativeRaw);
assertEquals(0, handled.get(), "Positive control: real native click-specific limiter refuses the original record");
var wireRaw = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
var admitted = f.receive(wireRaw);
assertFalse(admitted.isCancelled());
f.dispatchInbound(admitted.getPacket().getHandle());
assertEquals(0, handled.get(), "Owned identity wrapper must not bypass native click-specific refusal");
} finally { config.packetLimiter = previousLimiter; }
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"shared-drop", "class-kick"})
void ownedDispatchUsesSharedNativeClassCountersAndNativeViolationAction(String mode) throws Exception {
try (var f = new NativeAnvilFixture(directory)) {
var config = io.papermc.paper.configuration.GlobalConfiguration.get();
var previous = config.packetLimiter;
try {
var limiter = config.new PacketLimiter();
limiter.allPackets = new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
0, 0, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP);
limiter.overrides = new java.util.HashMap<>();
var action = mode.equals("class-kick")
? io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK
: io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.DROP;
limiter.overrides.put(net.minecraft.network.protocol.game.ServerboundContainerClickPacket.class,
new io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit(
1, mode.equals("class-kick") ? 0.1 : 2.0, action));
config.packetLimiter = limiter;
var connection = new net.minecraft.network.Connection(net.minecraft.network.protocol.PacketFlow.SERVERBOUND);
connection.channel = f.channel;
NativeAnvilFixture.field(net.minecraft.network.Connection.class, connection, "packetListener", f.transport);
NativeAnvilFixture.field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class,
f.transport, "connection", connection);
f.channel.pipeline().replace(f.connection, "packet_handler", connection);
var handled = new AtomicInteger();
org.mockito.Mockito.doAnswer(inv -> { handled.incrementAndGet(); return null; })
.when(f.transport).handleContainerClick(org.mockito.ArgumentMatchers.any());
f.inputs(sword(5), sword(5));
if (mode.equals("shared-drop")) {
var original = new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.menu.getStateId(), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
f.dispatchInbound(original);
assertEquals(1, handled.get(), "Native first class-rate count permits the original record");
}
var admitted = f.receive(raw(f));
assertFalse(admitted.isCancelled());
f.dispatchInbound(admitted.getPacket().getHandle());
assertEquals(mode.equals("shared-drop") ? 1 : 0, handled.get());
var received = net.minecraft.network.Connection.class.getDeclaredField("receivedPackets");
received.setAccessible(true);
assertEquals(mode.equals("shared-drop") ? 1 : 0, received.getInt(connection),
"Native class-refused packets must not increment successful receive accounting");
if (mode.equals("class-kick")) {
var stopped = net.minecraft.network.Connection.class.getDeclaredField("stopReadingPackets");
stopped.setAccessible(true);
assertTrue(stopped.getBoolean(connection), "Invoke the native kick/read-stop action");
}
} finally { config.packetLimiter = previous; }
}
}
@Test void removingAdvancedInputsAndPlacingNormalItemsViaNativePacketsImmediatelyRestoresVanilla() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
int oldFrame = f.clientState(f.menu);
packet.click(0, 0, ContainerInput.PICKUP);
packet.click(3, 0, ContainerInput.PICKUP); // Return left to native player slot9.
packet.click(1, 0, ContainerInput.PICKUP);
packet.click(4, 0, ContainerInput.PICKUP);
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
assertFalse(f.menu.bypassCost);
f.realInventory.setItem(11, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
f.realInventory.setItem(12, org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
f.menu.broadcastChanges();
packet.click(5, 0, ContainerInput.PICKUP);
packet.click(0, 0, ContainerInput.PICKUP);
packet.click(6, 0, ContainerInput.PICKUP);
packet.click(1, 0, ContainerInput.PICKUP);
assertSame(f.menu, f.nativePlayer.containerMenu, "No closing/reopening or advanced mode");
assertFalse(f.menu.bypassCost);
assertEquals(5, f.output().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(net.kyori.adventure.text.Component.empty(), f.actionBars.getLast());
int cost = f.menu.getCost();
assertTrue(cost > 0);
int eventsBefore = packet.clicks.size();
packet.click(f.menu.containerId, oldFrame, 2, 0, ContainerInput.PICKUP);
assertTrue(packet.received.getLast().isCancelled());
assertEquals(eventsBefore, packet.clicks.size());
packet.click(2, 0, ContainerInput.PICKUP);
assertEquals(5, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
assertEquals(100 - cost, f.nativePlayer.experienceLevel);
assertEquals(sword(5), f.player.getInventory().getItem(9));
assertEquals(sword(5), f.player.getInventory().getItem(10));
f.tick();
assertEquals(0, rolls.get());
assertTrue(f.menu.getSlot(0).getItem().isEmpty());
assertTrue(f.menu.getSlot(1).getItem().isEmpty());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void normalRenameRepairPriorWorkAndRefusalRemainNative(boolean previouslyAdvanced) throws Exception {
try (var f = new NativeAnvilFixture(directory);
var packet = new NativeAnvilPacketFixture(f)) {
packet.survival();
if (previouslyAdvanced) { f.inputs(sword(5), sword(5)); f.inputs(null, null); }
var left = sword(1);
var meta = (org.bukkit.inventory.meta.Damageable) left.getItemMeta();
meta.setDamage(1500);
((org.bukkit.inventory.meta.Repairable) meta).setRepairCost(8);
left.setItemMeta(meta);
var right = left.clone();
f.inputs(left, right);
f.menu.setItemName("ordinary rename");
f.prepareAgain();
assertFalse(f.menu.bypassCost);
assertTrue(f.menu.getCost() > 16, "Prior work plus native merge/repair/naming XP retained");
assertEquals(net.kyori.adventure.text.Component.text("ordinary rename"), f.output().getItemMeta().customName());
assertTrue(((org.bukkit.inventory.meta.Damageable) f.output().getItemMeta()).getDamage() < 1500);
assertEquals(17, ((org.bukkit.inventory.meta.Repairable) f.output().getItemMeta()).getRepairCost());
var expensive = sword(4);
var prior = (org.bukkit.inventory.meta.Repairable) expensive.getItemMeta();
prior.setRepairCost(80); expensive.setItemMeta(prior);
f.inputs(expensive, sword(4));
assertFalse(f.menu.bypassCost);
assertNull(f.output(), "Native Too Expensive refusal must not inherit enhanced bypass");
assertTrue(f.menu.getCost() >= 40);
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void normalInsufficientExperienceAndFinalCancellationSpendNothing(boolean previouslyAdvanced) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
nativeExperienceAndWear(f, 0);
if (previouslyAdvanced) { f.inputs(sword(5), sword(5)); f.inputs(null, null); }
f.inputs(sword(1), sword(1));
assertTrue(f.menu.getCost() > 0);
assertFalse(f.menu.getSlot(2).mayPickup(f.nativePlayer));
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(0));
nativeExperienceAndWear(f, 100);
packet.listen(org.bukkit.event.EventPriority.MONITOR, event -> event.setCancelled(true));
packet.click(2, 0, ContainerInput.PICKUP);
f.tick();
assertTrue(f.player.getItemOnCursor().isEmpty());
assertEquals(100, f.nativePlayer.experienceLevel);
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(0));
assertEquals(sword(1), f.menu.getBukkitView().getTopInventory().getItem(1));
assertEquals(0, rolls.get());
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"expiry", "inputs", "policy", "close", "shutdown", "native-refusal"})
void queuedRetiredOrRefusedIntentsNeverDebitAndCannotAuthorizeLaterTakes(String mode) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.receive(raw(f));
transport.forwardToNativeQueue();
switch (mode) {
case "expiry" -> { for (long i = 0; i < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; i++) f.tick(); }
case "inputs" -> f.inputs(sword(6), sword(6));
case "policy" -> { f.admin(f.console, "advanced-anvil-base-failure-percent", 80); f.acknowledgeSave(); }
case "close" -> packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
case "shutdown" -> f.plugin.setEnabled(false);
case "native-refusal" -> org.mockito.Mockito.when(f.nativePlayer.isImmobile()).thenReturn(true);
default -> throw new AssertionError(mode);
}
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
if (mode.equals("close") || mode.equals("shutdown")) {
assertEquals(2, NativeAdvancedAnvilBoundaryTest.count(f, sword(5)));
assertEquals(1, f.channel.pipeline().names().stream().filter(n -> n.equals("packet_handler")).count());
} else {
org.mockito.Mockito.when(f.nativePlayer.isImmobile()).thenReturn(false);
var expected = f.output().clone();
transport.receive(raw(f)); transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
assertEquals(1, rolls.get(), "A refused old dispatch must not strand or lend a ticket to the fresh take");
assertEquals(expected, f.player.getItemOnCursor());
assertFalse(transport.processor.executeSinglePacket());
}
}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void alreadyQueuedOldIdentityCannotTakeFromAReusedNativeWindow(boolean newAdvanced) throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
transport.receive(raw(f)); transport.forwardToNativeQueue();
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
var reopened = new net.minecraft.world.inventory.AnvilMenu(f.menu.containerId, f.realInventory,
net.minecraft.world.inventory.ContainerLevelAccess.create(f.level, f.position));
reopened.setTitle(net.minecraft.network.chat.Component.literal("Repair & Name"));
f.nativePlayer.containerMenu = reopened;
reopened.setSynchronizer(packet.synchronizer);
var input = sword(newAdvanced ? 5 : 4);
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(input));
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(input));
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
int cost = reopened.getCost();
assertEquals(newAdvanced, reopened.bypassCost);
transport.receive(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
reopened.containerId, f.clientState(reopened), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket()); // Retired exact old-window wrapper.
transport.assertNoErrors();
assertTrue(packet.clicks.isEmpty());
assertEquals(expected, reopened.getBukkitView().getTopInventory().getItem(2));
assertEquals(input, reopened.getBukkitView().getTopInventory().getItem(0));
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.assertNoEventErrors(); f.tick();
assertEquals(expected, f.player.getItemOnCursor());
assertEquals(newAdvanced ? 1 : 0, rolls.get());
assertEquals(newAdvanced ? 100 : 100 - cost, f.nativePlayer.experienceLevel);
assertTrue(reopened.getSlot(0).getItem().isEmpty());
assertTrue(reopened.getSlot(1).getItem().isEmpty());
assertFalse(transport.processor.executeSinglePacket());
}
}
@Test void outstandingIdentityBudgetRefusesOverflowAndRecoversOnlyAfterExactRetirement() throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1);
var packet = new NativeAnvilPacketFixture(f)) {
f.inputs(sword(5), sword(5));
var held = new java.util.ArrayList<Object>();
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
var admitted = f.receive(raw(f));
assertFalse(admitted.isCancelled());
held.add(admitted.getPacket().getHandle());
}
assertTrue(f.receive(raw(f)).isCancelled(), "Bound outstanding exact records instead of replacing their identities");
for (var record : held) f.dispatchInbound(record);
f.tick();
assertTrue(packet.clicks.isEmpty());
assertEquals(0, rolls.get());
packet.click(2, 0, ContainerInput.PICKUP); f.tick();
assertEquals(1, rolls.get());
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(Enchantment.SHARPNESS));
}
}
@Test void hookPassesUnrelatedTrafficAndRemovesOnlyItselfAfterShutdownDrain() throws Exception {
try (var f = new NativeAnvilFixture(directory);
var packet = new NativeAnvilPacketFixture(f)) {
var forwarded = new java.util.ArrayList<Object>();
var probe = new io.netty.channel.ChannelInboundHandlerAdapter() {
@Override public void channelRead(io.netty.channel.ChannelHandlerContext context, Object message) {
forwarded.add(message); context.fireChannelRead(message);
}
};
f.channel.pipeline().addBefore("packet_handler", "foreign-probe", probe);
f.inputs(sword(5), sword(5));
var baseline = f.channel.pipeline().names();
var message = new Object();
// Replace only the fixture's controlled transport port behavior for non-packets.
org.mockito.Mockito.doAnswer(inv -> null).when(f.connection).channelRead(
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.same(message));
f.channel.pipeline().fireChannelRead(message);
assertEquals(java.util.List.of(message), forwarded);
var admitted = f.receive(raw(f));
assertFalse(admitted.isCancelled());
f.plugin.setEnabled(false);
f.dispatchInbound(admitted.getPacket().getHandle());
assertSame(probe, f.channel.pipeline().get("foreign-probe"));
assertSame(f.connection, f.channel.pipeline().get("packet_handler"));
assertEquals(baseline.size() - 1, f.channel.pipeline().names().size(), "Remove only drained owned hook");
assertTrue(packet.clicks.isEmpty());
}
}
static net.minecraft.network.protocol.game.ServerboundContainerClickPacket raw(NativeAnvilFixture f) {
return new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
f.menu.containerId, f.clientState(f.menu), (short) 2, (byte) 0, ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY);
}
static void nativeExperienceAndWear(NativeAnvilFixture f, int xp) {
var random = org.mockito.Mockito.mock(net.minecraft.util.RandomSource.class);
org.mockito.Mockito.when(random.nextFloat()).thenReturn(0.99F);
org.mockito.Mockito.when(f.nativePlayer.getRandom()).thenReturn(random);
org.mockito.Mockito.doCallRealMethod().when(f.nativePlayer).giveExperienceLevels(org.mockito.ArgumentMatchers.anyInt());
f.nativePlayer.experienceLevel = xp;
}
static ItemStack sword(int sharpness) {
var item = new ItemStack(Material.DIAMOND_SWORD);
item.addUnsafeEnchantment(Enchantment.SHARPNESS, sharpness);
return item;
}
}
@@ -56,6 +56,15 @@ final class NativeAnvilFixture implements AutoCloseable {
final Server server = mock(org.bukkit.craftbukkit.CraftServer.class,
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
final ServerPlayer nativePlayer = mock(ServerPlayer.class);
final net.minecraft.server.MinecraftServer nativeServer = mock(net.minecraft.server.MinecraftServer.class);
final net.minecraft.network.PacketProcessor processor = new net.minecraft.network.PacketProcessor(Thread.currentThread());
final net.minecraft.network.Connection connection;
final net.minecraft.server.network.ServerGamePacketListenerImpl transport =
mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
final io.netty.channel.embedded.EmbeddedChannel channel;
private java.util.function.Consumer<net.minecraft.network.protocol.game.ServerboundContainerClickPacket> legacyClick;
final List<Object> unrelatedInbound = new ArrayList<>();
private final io.papermc.paper.configuration.GlobalConfiguration previousConfiguration;
final CraftPlayer player = mock(CraftPlayer.class);
final ServerLevel level = mock(ServerLevel.class);
final CraftWorld world = mock(CraftWorld.class);
@@ -69,6 +78,8 @@ final class NativeAnvilFixture implements AutoCloseable {
final List<String> adminMessages = new java.util.concurrent.CopyOnWriteArrayList<>();
final org.bukkit.command.ConsoleCommandSender console = mock(org.bukkit.command.ConsoleCommandSender.class);
final java.util.concurrent.BlockingQueue<Runnable> nextTick = new java.util.concurrent.LinkedBlockingQueue<>();
private long tickCount;
private final Map<Long, java.util.Collection<Runnable>> delayed = new java.util.TreeMap<>();
final List<Runnable> repeating = new ArrayList<>();
final List<BukkitTask> tasks = new ArrayList<>();
final List<Throwable> eventErrors = new ArrayList<>();
@@ -91,6 +102,41 @@ final class NativeAnvilFixture implements AutoCloseable {
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, String version) throws Exception {
NativeRuntime.bootstrap();
previousConfiguration = io.papermc.paper.configuration.GlobalConfiguration.get();
if (previousConfiguration == null) {
var config = new io.papermc.paper.configuration.GlobalConfiguration();
config.misc = config.new Misc();
config.packetLimiter = config.new PacketLimiter();
config.unsupportedSettings = config.new UnsupportedSettings();
field(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", config);
}
connection = mock(net.minecraft.network.Connection.class);
when(level.getServer()).thenReturn(nativeServer);
when(nativeServer.packetProcessor()).thenReturn(processor);
when(transport.shouldHandleMessage(any())).thenReturn(true);
doAnswer(inv -> { eventErrors.add(inv.getArgument(1)); return null; })
.when(transport).onPacketError(any(), any());
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, transport, "connection", connection);
field(net.minecraft.server.network.ServerCommonPacketListenerImpl.class, transport, "server", nativeServer);
doAnswer(inv -> {
Object message = inv.getArgument(1);
try {
if (message instanceof net.minecraft.network.protocol.Packet<?> raw) dispatchPacket(raw);
else unrelatedInbound.add(message);
} catch (net.minecraft.server.RunningOnDifferentThreadException expected) {
// Actual PacketUtils enqueued the wrapper or unrelated native packet.
}
return null;
}).when(connection).channelRead(any(), any());
channel = new io.netty.channel.embedded.EmbeddedChannel();
channel.pipeline().addLast("packet_handler", connection);
connection.channel = channel;
nativePlayer.connection = transport;
doAnswer(inv -> {
if (legacyClick == null) throw new AssertionError("Unconfigured native click port");
legacyClick.accept(inv.getArgument(0));
return null;
}).when(transport).handleContainerClick(any());
doAnswer(inv -> { protocolListeners.add(inv.getArgument(0)); return null; })
.when(protocol).addPacketListener(any());
doAnswer(inv -> { protocolListeners.remove(inv.getArgument(0)); return null; })
@@ -121,6 +167,11 @@ final class NativeAnvilFixture implements AutoCloseable {
var scheduler = mock(org.bukkit.craftbukkit.scheduler.CraftScheduler.class);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTask(any(), any(Runnable.class))).thenAnswer(inv -> schedule(inv.getArgument(1), nextTick));
when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(inv -> {
long delay = inv.getArgument(2);
if (delay < 1) throw new AssertionError("Unexpected non-positive delayed task");
return schedule(inv.getArgument(1), delayed.computeIfAbsent(tickCount + delay, ignored -> new ArrayList<>()));
});
when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong()))
.thenAnswer(inv -> schedule(inv.getArgument(1), repeating));
when(player.getHandle()).thenReturn(nativePlayer);
@@ -183,8 +234,6 @@ final class NativeAnvilFixture implements AutoCloseable {
plugin.setEnabled(true);
// Even event-level fixtures must echo a genuinely published client frame, never the
// server counter. Reuse the pinned native synchronizer with only its transport mocked.
var transport = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
nativePlayer.connection = transport;
doAnswer(inv -> { send(inv.getArgument(0)); return null; })
.when(transport).send(any(net.minecraft.network.protocol.Packet.class));
var constructor = Class.forName("net.minecraft.server.level.ServerPlayer$1")
@@ -247,9 +296,16 @@ final class NativeAnvilFixture implements AutoCloseable {
var packet = receive(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
menu.containerId, clientState(menu), (short) rawSlot, (byte) button, input,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
if (packet.isCancelled()) event.setCancelled(true);
else events.callEvent(event);
if (!event.isCancelled()) menu.clicked(rawSlot, button, input, nativePlayer);
event.setCancelled(true); // A dropped/revoked transport record never reaches Bukkit.
if (!packet.isCancelled()) {
legacyClick = translated -> {
event.setCancelled(false);
events.callEvent(event);
if (!event.isCancelled()) menu.clicked(rawSlot, button, input, nativePlayer);
};
try { dispatchInbound(packet.getPacket().getHandle()); }
finally { legacyClick = null; }
}
assertNoEventErrors();
return event;
}
@@ -257,9 +313,31 @@ final class NativeAnvilFixture implements AutoCloseable {
com.comphenix.protocol.events.PacketEvent receive(Object payload) {
var event = clientEvent(payload);
dispatchProtocol(event);
if (!event.isCancelled()) {
var marker = com.comphenix.protocol.events.NetworkMarker.getNetworkMarker(event);
if (marker != null) marker.getPostListeners().forEach(listener -> listener.onPostEvent(event));
}
return event;
}
void dispatchInbound(Object record) {
channel.pipeline().fireChannelRead(record);
channel.checkException();
}
private void dispatchPacket(net.minecraft.network.protocol.Packet<?> raw) {
if (raw instanceof net.minecraft.network.protocol.game.ServerboundContainerClickPacket click) click.handle(transport);
else if (raw instanceof NativeAnvilDispatch.BoundPacket bound) bound.handle(transport);
else throw new AssertionError("Unsupported native game packet port: " + raw.getClass());
}
static void field(Class<?> owner, Object target, String name, Object value) throws ReflectiveOperationException {
var field = owner.getDeclaredField(name);
if (value != null && !field.getType().isInstance(value)) throw new IllegalStateException("Unexpected native field: " + field);
field.setAccessible(true);
field.set(target, value);
}
com.comphenix.protocol.events.PacketEvent clientEvent(Object payload) {
var container = new com.comphenix.protocol.events.PacketContainer(
com.comphenix.protocol.PacketType.Play.Client.WINDOW_CLICK, payload);
@@ -312,6 +390,8 @@ final class NativeAnvilFixture implements AutoCloseable {
}
void tick() {
var due = delayed.remove(++tickCount);
if (due != null) List.copyOf(due).forEach(Runnable::run);
var ready = List.copyOf(nextTick);
nextTick.clear();
ready.forEach(Runnable::run);
@@ -335,6 +415,9 @@ final class NativeAnvilFixture implements AutoCloseable {
try {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
channel.finishAndReleaseAll();
processor.close();
field(io.papermc.paper.configuration.GlobalConfiguration.class, null, "instance", previousConfiguration);
setServer(previous);
} catch (Exception failure) {
throw new AssertionError("Could not restore server", failure);
@@ -30,8 +30,8 @@ import org.bukkit.event.inventory.InventoryClickEvent;
/** Real packet classification/event dispatch/native clicked; explicit world/thread/network ports only. */
final class NativeAnvilPacketFixture implements AutoCloseable {
final NativeAnvilFixture anvil;
final MinecraftServer nativeServer = mock(MinecraftServer.class);
final PacketProcessor processor = mock(PacketProcessor.class);
final MinecraftServer nativeServer;
final PacketProcessor processor;
// Final external CraftServer port only; retain the suite's subclass maker everywhere else.
final CraftServer craftServer = mock(CraftServer.class,
withSettings().mockMaker(org.mockito.MockMakers.INLINE));
@@ -58,8 +58,10 @@ final class NativeAnvilPacketFixture implements AutoCloseable {
config.unsupportedSettings = config.new UnsupportedSettings();
field(GlobalConfiguration.class, null, "instance", GlobalConfiguration.class, config);
}
connection = mock(Connection.class);
handler = mock(ServerGamePacketListenerImpl.class);
connection = anvil.connection;
handler = anvil.transport;
nativeServer = anvil.nativeServer;
processor = anvil.processor;
when(anvil.level.getServer()).thenReturn(nativeServer);
when(anvil.level.getCraftServer()).thenReturn(craftServer);
when(anvil.nativePlayer.isAlive()).thenReturn(true);
@@ -68,7 +70,6 @@ final class NativeAnvilPacketFixture implements AutoCloseable {
doAnswer(inv -> { drops.add(inv.<net.minecraft.world.item.ItemStack>getArgument(0).copy()); return null; })
.when(anvil.nativePlayer).drop(any(net.minecraft.world.item.ItemStack.class), anyBoolean());
when(nativeServer.packetProcessor()).thenReturn(processor);
when(processor.isSameThread()).thenReturn(true);
when(craftServer.getPluginManager()).thenReturn(anvil.events);
when(anvil.nativePlayer.isWithinBlockInteractionRange(anvil.position, 4.0)).thenReturn(true);
when(anvil.nativePlayer.getAbilities()).thenReturn(abilities);
@@ -158,7 +159,7 @@ final class NativeAnvilPacketFixture implements AutoCloseable {
if (!event.isCancelled()) {
Object admitted = event.getPacket().getHandle();
if (!(admitted instanceof ServerboundContainerClickPacket translated)) throw new AssertionError("Invalid admitted packet");
handler.handleContainerClick(translated);
anvil.dispatchInbound(translated);
}
anvil.assertNoEventErrors();
}
@@ -0,0 +1,103 @@
package games.dmg.spigotinventoryhelper;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.injector.ListenerManager;
import com.comphenix.protocol.injector.netty.channel.NettyChannelInjector;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.DefaultEventLoop;
import io.netty.util.DefaultAttributeMap;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import net.minecraft.network.PacketProcessor;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
/** Actual ProtocolLib post-adapter forwarding and native thread/queue dispatch, controlled transport ports.
* Real owned local pipeline, no socket, world/server startup or live-client claim. Strict private library
* entry is used after packet-type lookup; library callbacks and main->Netty->native-main are real.
*/
final class NativeAnvilTransportFixture implements AutoCloseable {
final NativeAnvilPacketFixture packet;
final PacketProcessor processor;
private final DefaultEventLoop loop = new DefaultEventLoop();
private final CountDownLatch forward = new CountDownLatch(1);
private final List<Throwable> failures = new java.util.concurrent.CopyOnWriteArrayList<>();
private final NettyChannelInjector injector;
private final ChannelHandlerContext context;
final List<PacketEvent> received = new ArrayList<>();
NativeAnvilTransportFixture(NativeAnvilFixture f, NativeAnvilPacketFixture packet) throws Exception {
this.packet = packet;
this.processor = f.processor;
when(packet.handler.shouldHandleMessage(any())).thenReturn(true);
doAnswer(inv -> { failures.add(inv.getArgument(1)); return null; }).when(packet.handler).onPacketError(any(), any());
var channel = mock(Channel.class);
var attributes = new DefaultAttributeMap();
when(channel.attr(any())).thenAnswer(inv -> attributes.attr(inv.getArgument(0)));
when(channel.eventLoop()).thenReturn(loop);
when(channel.closeFuture()).thenReturn(mock(io.netty.channel.ChannelFuture.class));
var listeners = mock(ListenerManager.class);
when(listeners.hasMainThreadListener(PacketType.Play.Client.WINDOW_CLICK)).thenReturn(true);
doAnswer(inv -> {
PacketEvent event = inv.getArgument(0);
received.add(event);
f.dispatchProtocol(event);
return null;
}).when(listeners).invokeInboundPacketListeners(any());
injector = new NettyChannelInjector(f.player, packet.connection, channel, listeners,
mock(com.comphenix.protocol.injector.netty.channel.InjectionFactory.class),
mock(com.comphenix.protocol.error.ErrorReporter.class));
f.channel.pipeline().addFirst("protocollib-boundary", new io.netty.channel.ChannelInboundHandlerAdapter());
var boundary = f.channel.pipeline().context("protocollib-boundary");
context = mock(ChannelHandlerContext.class);
when(context.channel()).thenReturn(channel);
doAnswer(inv -> {
try {
boundary.fireChannelRead(inv.getArgument(0));
f.channel.checkException();
}
catch (net.minecraft.server.RunningOnDifferentThreadException expected) {
// Native PacketUtils has actually enqueued the packet in our real PacketProcessor.
} catch (Throwable failure) { failures.add(failure); }
return context;
}).when(context).fireChannelRead(any());
loop.execute(() -> {
try { if (!forward.await(10, TimeUnit.SECONDS)) failures.add(new AssertionError("Forward gate timed out")); }
catch (InterruptedException failure) { Thread.currentThread().interrupt(); failures.add(failure); }
});
}
void receive(ServerboundContainerClickPacket raw) throws Exception {
var method = NettyChannelInjector.class.getDeclaredMethod("processInboundInternal",
ChannelHandlerContext.class, PacketContainer.class);
method.setAccessible(true);
method.invoke(injector, context, new PacketContainer(PacketType.Play.Client.WINDOW_CLICK, raw));
}
void forwardToNativeQueue() throws Exception {
forward.countDown();
loop.submit(() -> {}).get(10, TimeUnit.SECONDS);
assertNoErrors();
}
void assertNoErrors() {
if (!failures.isEmpty()) throw new AssertionError("Transport/native dispatch failed", failures.getFirst());
}
@Override public void close() {
forward.countDown();
try { loop.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync(); }
catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new AssertionError("Interrupted transport shutdown", failure);
}
assertNoErrors();
}
}