Compare commits

...
3 Commits
Author SHA1 Message Date
dmg f5298543d0 fix(inventory-helper): restore native and advanced anvil combining
Release / release (push) Successful in 1m49s
CI / build (push) Successful in 1m22s
US-010: 275 automated cases pass; exact candidate tree d0497330ba. Final security/release/live gates separate.
2026-10-13 01:16:27 +00:00
dmg 990028c728 fix(anvil): finalize queued dispatch ownership safely
CI / build (push) Successful in 1m26s
CI / build (pull_request) Successful in 1m28s
2026-10-12 21:11:48 -04:00
dmg 09bc15e979 fix(inventory-helper): restore native and advanced anvil combining
CI / build (push) Successful in 1m23s
CI / build (pull_request) Successful in 1m23s
2026-10-12 20:03:25 -04:00
16 changed files with 1632 additions and 59 deletions

No files matched your search

+8 -7
View File
@@ -28,8 +28,7 @@ world, or accept the EULA. The native suite executes real `ServerPlaceRecipe`, `
known/highlight codec persistence, and `ResultSlot` ingredient consumption for both Sorting Stick variants and the alternative Dispenser recipe, including repeated batch completion.
The narrowly scoped anvil-generation adapter also uses this checksum-pinned native runtime as a
compile-only dependency. Server/API and ProtocolLib classes are not bundled.
CraftServer, CraftPlayer, and client boundaries are doubled, so this is not evidence of live client
rendering, search interaction, packet transport, or a full server lifecycle. Those remain
CraftServer, CraftPlayer, and client boundaries are doubled; byte I/O is a controlled port. Anvil tests execute actual ProtocolLib forwarding, one registered local Netty pipeline/event loop, the declared library scheduler with a queued main-task port, and the real native packet queue, including final cancellation/replacement, skipped handling, owner disable and late-forward retirement. This is not evidence of live client rendering, search interaction, socket/installed-stack packet transport, or a full server lifecycle. Those remain
non-blocking live checks.
## Sorting Stick recipe
@@ -84,7 +83,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 +95,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 +104,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. Transport ownership is bounded to 32 records per connection. A queued ProtocolLib-owned main callback followed by a barrier on the same native channel event loop proves whether each exact raw was forwarded; mutable packet/post-event snapshots do not. This library-owned cleanup survives this plugin's task cancellation. Revoked native wrappers retain their denial even when native admission or queue processing skips their handler, while genuinely unresolved late raws keep protective ownership until arrival, the ordered fence or disconnect. Missing/rejected library scheduling or event-loop execution fails closed; it does not establish successful cleanup. 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 +121,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,28 @@ 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;
// Transport-monitor protected, sticky raw-seen/wrapper ownership (not post acknowledgement).
boolean wrapped;
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 +51,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 +62,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 +111,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); });
}, () -> plugin.getServer().isPrimaryThread());
dispatches.put(player, dispatch);
}
if (!dispatch.ready()) return false;
watched.add(view);
actors.add(player);
invalidate(view);
@@ -108,7 +137,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) {
@@ -160,13 +193,16 @@ final class AdvancedAnvilProtocolGuard implements AutoCloseable {
}
if (!(event.getPlayer().getOpenInventory() instanceof AnvilView view) || !watched.contains(view)) return;
invalidate(view); // A new raw intent can never reuse an earlier ticket.
NativeAnvilDispatch acquiredDispatch = null;
Admission acquired = null;
try {
Object raw = event.getPacket().getHandle();
if (seen.put(event, Boolean.TRUE) != null || event.isCancelled()
|| !(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,14 +211,45 @@ 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);
});
} catch (RuntimeException | LinkageError unavailable) { refuse(event, 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; }
acquiredDispatch = dispatch;
acquired = admission;
admissions.put(view, admission);
// No owned post listener/marker: foreign markers remain entirely untouched.
admission.expiry = Objects.requireNonNull(plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
dispatch.revoke(admission);
if (admissions.get(view) == admission) admissions.remove(view);
// Unresolved raw ownership waits for the two-queue fence or exact raw arrival.
// Already-wrapped ownership is safe to release even if native skips handle.
}, ADMISSION_TICKS));
} catch (RuntimeException | LinkageError unavailable) {
if (acquired != null) {
acquiredDispatch.revoke(acquired);
if (admissions.get(view) == acquired) admissions.remove(view);
}
refuse(event, view);
} finally {
// Also fences a partially acquired, cancelled record after timer setup throws.
// Cancellation inside a callback is never upfront proof of no raw forward.
if (acquired != null && !acquiredDispatch.requestFence()) {
invalidate(view);
refuse(event, view);
}
}
}
private boolean currentFrame(AnvilView view, ServerboundContainerClickPacket packet) {
@@ -205,11 +272,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 +293,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 +317,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,241 @@
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 com.comphenix.protocol.scheduler.ProtocolScheduler scheduler;
private final java.util.function.BooleanSupplier mainThread;
private boolean mainFencePending;
private volatile boolean unavailable;
private volatile boolean ready;
private volatile boolean removing;
private boolean retiring;
NativeAnvilDispatch(Connection connection, PacketProcessor processor, Runnable readyCallback,
java.util.function.BooleanSupplier mainThread) {
// Link the public, library-owned main queue BEFORE enhanced output/hook installation.
// DefaultScheduler.runTask runs after the executing library filters/posts return;
// this is an ordering fence, not a promise to wait a strict server tick.
scheduler = java.util.Objects.requireNonNull(com.comphenix.protocol.ProtocolLibrary.getScheduler(),
"Missing ProtocolLib main scheduler");
this.mainThread = java.util.Objects.requireNonNull(mainThread);
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 && !unavailable && !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;
}
/** One pending library-main callback per dispatch, never one job per admission. */
synchronized boolean requestFence() {
if (unavailable) return false;
if (mainFencePending) return true;
mainFencePending = true;
try {
if (scheduler.runTask(this::mainFence) == null) {
failBridge();
return false;
}
return true;
} catch (RuntimeException | LinkageError failure) {
failBridge();
return false;
}
}
private void mainFence() {
try {
if (!mainThread.getAsBoolean()) {
failBridge();
return;
}
final java.util.List<AdvancedAnvilProtocolGuard.Admission> snapshot;
synchronized (this) {
mainFencePending = false;
if (unavailable) return;
// Snapshot HERE, not on Netty: later unfinished receives cannot be declared
// NO_FORWARD by a barrier queued before their actual library raw forwards.
snapshot = java.util.List.copyOf(records.values()); // bounded by MAX_OUTSTANDING
}
connection.channel.eventLoop().execute(() -> terminalFence(snapshot));
} catch (RuntimeException | LinkageError failure) {
// Rejected submission is not evidence of NO_FORWARD.
failBridge();
}
}
private synchronized void terminalFence(java.util.List<AdvancedAnvilProtocolGuard.Admission> snapshot) {
for (var admission : snapshot) {
if (records.get(admission.packet) != admission) continue;
if (!admission.wrapped) {
// All actual raw forwards captured before mainFence are ahead of us on
// this same event loop. Never infer acknowledgement from mutable events/posts.
admission.revoked = true;
records.remove(admission.packet);
} else if (admission.revoked) records.remove(admission.packet);
}
// Transport-only completion: no Bukkit/map/policy callback on the socket loop.
removeIfDrained();
}
private synchronized void failBridge() {
unavailable = true;
mainFencePending = false;
revokeRecords(); // Keep unresolved exact-raw tombstones; no false cleanup claim.
removeIfDrained();
}
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) {
if (admission.wrapped) return; // Duplicate exact raw while still owned.
admission.wrapped = true; // Sticky, even after ledger retirement.
if (admission.revoked) complete(admission);
}
}
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);
removeIfDrained();
}
synchronized void revoke(AdvancedAnvilProtocolGuard.Admission admission) {
admission.revoked = true;
// The wrapper retains irreversible denial even if native never calls its handle.
if (admission.wrapped) complete(admission);
}
private void revokeRecords() {
records.values().removeIf(admission -> {
admission.revoked = true;
return admission.wrapped;
});
}
synchronized void retire() {
retiring = true;
revokeRecords();
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();
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,468 @@
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, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
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, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
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, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
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, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
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);
io.netty.channel.Channel 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,15 @@ 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;
RuntimeException admissionSchedulingFailure;
RuntimeException librarySchedulingFailure;
final java.util.Queue<Runnable> libraryTasks = new java.util.concurrent.ConcurrentLinkedQueue<>();
final org.bukkit.plugin.Plugin libraryPlugin = mock(org.bukkit.plugin.Plugin.class);
private final Object previousLibraryScheduler;
private final Object previousLibraryPlugin;
private int taskId;
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<>();
@@ -88,9 +106,58 @@ final class NativeAnvilFixture implements AutoCloseable {
this(directory, risk, wear, "Purpur 26.2.build.2618-stable");
}
NativeAnvilFixture(Path directory, boolean queuedTransport) throws Exception {
this(directory, () -> 99, () -> 1.0, "Purpur 26.2.build.2618-stable", queuedTransport);
}
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, boolean queuedTransport) throws Exception {
this(directory, risk, wear, "Purpur 26.2.build.2618-stable", queuedTransport);
}
NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, String version) throws Exception {
this(directory, risk, wear, version, false);
}
private NativeAnvilFixture(Path directory, java.util.function.IntSupplier risk,
java.util.function.DoubleSupplier wear, String version, boolean queuedTransport) 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 = queuedTransport ? new NativeAnvilTestChannel() : 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; })
@@ -99,7 +166,8 @@ final class NativeAnvilFixture implements AutoCloseable {
setServer(server);
when(server.getItemFactory()).thenAnswer(ignored -> CraftItemFactory.instance());
when(server.getUnsafe()).thenReturn(CraftMagicNumbers.INSTANCE);
when(server.isPrimaryThread()).thenReturn(true);
var mainThread = Thread.currentThread();
when(server.isPrimaryThread()).thenAnswer(ignored -> Thread.currentThread() == mainThread);
when(server.getVersion()).thenReturn("Purpur 2618 (MC: 26.2)");
when(server.getMinecraftVersion()).thenReturn("26.2");
when(server.getBukkitVersion()).thenReturn("26.2.build.2618-stable");
@@ -120,7 +188,32 @@ final class NativeAnvilFixture implements AutoCloseable {
doReturn(List.of()).when(server).getOnlinePlayers();
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.runTask(any(), any(Runnable.class))).thenAnswer(inv -> {
if (inv.getArgument(0) == libraryPlugin) {
if (!libraryPlugin.isEnabled()) throw new IllegalStateException("Required library disabled");
if (librarySchedulingFailure != null) throw librarySchedulingFailure;
return schedule(inv.getArgument(1), libraryTasks);
}
return schedule(inv.getArgument(1), nextTick);
});
when(libraryPlugin.getServer()).thenReturn(server);
when(libraryPlugin.isEnabled()).thenReturn(true);
var schedulerField = com.comphenix.protocol.ProtocolLibrary.class.getDeclaredField("scheduler");
schedulerField.setAccessible(true);
previousLibraryScheduler = schedulerField.get(null);
var pluginField = com.comphenix.protocol.ProtocolLibrary.class.getDeclaredField("plugin");
pluginField.setAccessible(true);
previousLibraryPlugin = pluginField.get(null);
schedulerField.set(null, new com.comphenix.protocol.scheduler.DefaultScheduler(libraryPlugin));
pluginField.set(null, libraryPlugin);
when(scheduler.runTaskLater(any(), any(Runnable.class), anyLong())).thenAnswer(inv -> {
if (admissionSchedulingFailure != null) throw admissionSchedulingFailure;
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<>()));
});
doAnswer(ignored -> { List.copyOf(tasks).forEach(BukkitTask::cancel); return null; })
.when(scheduler).cancelTasks(any(org.bukkit.plugin.Plugin.class));
when(scheduler.runTaskTimer(any(), any(Runnable.class), anyLong(), anyLong()))
.thenAnswer(inv -> schedule(inv.getArgument(1), repeating));
when(player.getHandle()).thenReturn(nativePlayer);
@@ -183,8 +276,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 +338,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 +355,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);
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.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);
@@ -311,7 +431,17 @@ final class NativeAnvilFixture implements AutoCloseable {
return menu.getBukkitView().getTopInventory().getItem(2);
}
void pumpLibraryTasks() {
if (!server.isPrimaryThread()) throw new AssertionError("Library callbacks must run on main");
Runnable task;
while ((task = libraryTasks.poll()) != null) task.run();
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.runPendingTasks();
}
void tick() {
pumpLibraryTasks();
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);
@@ -319,6 +449,13 @@ final class NativeAnvilFixture implements AutoCloseable {
assertNoEventErrors();
}
// Paper disables the plugin before cancelling its scheduler tasks. Do not let a fixture
// silently supply the expiry callbacks that real disable removes.
void disableAndCancelTasks() {
plugin.setEnabled(false);
server.getScheduler().cancelTasks(plugin);
}
void assertNoEventErrors() {
if (!eventErrors.isEmpty()) throw new AssertionError("Native listener failed", eventErrors.getFirst());
}
@@ -327,7 +464,9 @@ final class NativeAnvilFixture implements AutoCloseable {
queue.add(runnable);
BukkitTask task = mock(BukkitTask.class);
doAnswer(ignored -> { queue.remove(runnable); return null; }).when(task).cancel();
tasks.add(task);
when(task.getTaskId()).thenReturn(++taskId);
// Only owning-plugin tasks belong to realistic owner-disable cancellation.
if (queue != libraryTasks) tasks.add(task);
return task;
}
@@ -335,6 +474,12 @@ final class NativeAnvilFixture implements AutoCloseable {
try {
plugin.setEnabled(false);
HandlerList.unregisterAll(plugin);
if (channel instanceof io.netty.channel.embedded.EmbeddedChannel embedded) embedded.finishAndReleaseAll();
else ((NativeAnvilTestChannel) channel).finish();
field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", previousLibraryScheduler);
field(com.comphenix.protocol.ProtocolLibrary.class, null, "plugin", previousLibraryPlugin);
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);
@@ -0,0 +1,342 @@
package games.dmg.spigotinventoryhelper;
import static games.dmg.spigotinventoryhelper.NativeAnvilCombiningRegressionTest.raw;
import static games.dmg.spigotinventoryhelper.NativeAnvilCombiningRegressionTest.sword;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.*;
import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import net.minecraft.network.HashedStack;
import net.minecraft.network.protocol.game.ServerboundContainerClickPacket;
import net.minecraft.world.inventory.ContainerInput;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
/** Terminal ownership through actual local ProtocolLib forwarding/pipeline/native queue.
* No sockets, live server, or arbitrary gameplay-plugin compatibility claim.
*/
class NativeAnvilLifecycleRegressionTest {
@TempDir Path directory;
@ParameterizedTest @ValueSource(strings = {"replace", "cancel"})
void finalFilterNoForwardOfAdmittedHandleRestoresCapacityAtExpiry(String mode) throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
var foreign = filter(f, event -> {
if (mode.equals("cancel")) event.setCancelled(true);
else event.setPacket(new PacketContainer(event.getPacketType(), inert(f)));
});
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) transport.receive(raw(f));
for (var event : transport.received) assertEquals(mode.equals("cancel"), event.isCancelled());
expire(f);
transport.forwardToNativeQueue(); // Resolve queued fence before asserting capacity.
f.protocol.removePacketListener(foreign);
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled(),
"Final filter never forwarded admitted A: expiry must restore its capacity");
assertTrue(packet.clicks.isEmpty());
}
}
@Test void cancelledPacketDisableDoesNotRelyOnPluginExpiryAndPreservesForeignHandler() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
var probe = new io.netty.channel.ChannelInboundHandlerAdapter();
f.channel.pipeline().addBefore("packet_handler", "foreign-probe", probe);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
filter(f, event -> event.setCancelled(true));
int before = f.channel.pipeline().names().size();
transport.receive(raw(f));
assertTrue(transport.received.getFirst().isCancelled());
f.disableAndCancelTasks();
expire(f);
transport.forwardToNativeQueue();
assertFalse(transport.processor.executeSinglePacket(), "Cancelled means no native forward");
assertSame(probe, f.channel.pipeline().get("foreign-probe"));
assertSame(f.connection, f.channel.pipeline().get("packet_handler"));
assertEquals(before - 1, f.channel.pipeline().names().size(),
"Resolved no-forward shutdown removes only owned hook without a plugin timer");
assertTrue(packet.clicks.isEmpty());
}
}
@Test void admissionTimerAcquisitionFailureRollsBackCapacity() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
f.admissionSchedulingFailure = new IllegalStateException("Controlled timer acquisition failure");
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
transport.receive(raw(f));
assertTrue(transport.received.getLast().isCancelled());
}
transport.forwardToNativeQueue();
f.admissionSchedulingFailure = null;
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled(),
"Failed acquisition must not fill the exact-identity budget");
assertTrue(packet.clicks.isEmpty());
}
}
@Test void nativeQueueSkipWithoutWrapperHandleRestoresCapacityAtExpiry() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled());
}
transport.forwardToNativeQueue();
when(packet.handler.shouldHandleMessage(any())).thenReturn(false);
for (int i = 0; i < NativeAnvilDispatch.MAX_OUTSTANDING; i++) {
assertTrue(transport.processor.executeSinglePacket());
}
assertFalse(transport.processor.executeSinglePacket());
assertTrue(packet.clicks.isEmpty(), "Native queue really skipped wrapper.handle");
expire(f);
when(packet.handler.shouldHandleMessage(any())).thenReturn(true);
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled(),
"Revoked already-wrapped ownership can retire without handle/finally");
}
}
@Test void aggregateNativeRefusalWithoutWrapperHandleRetiresHookAtExpiryAndClose() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
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(
1, 0.1, io.papermc.paper.configuration.GlobalConfiguration.PacketLimiter.PacketLimit.ViolateAction.KICK);
limiter.overrides = new java.util.HashMap<>();
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);
// Keep the inspected frame-synchronizer port: changing the listener's
// connection must not bypass the fixture's actual ProtocolLib frame publication.
doAnswer(inv -> { f.connection.send(inv.getArgument(0), inv.getArgument(1), true); return null; })
.when(f.transport).send(any(net.minecraft.network.protocol.Packet.class), any());
var disconnects = new java.util.concurrent.CopyOnWriteArrayList<Object>();
// Controlled outbound port: hold completion of the real native disconnect
// write so the channel stays active; do not silently let disconnect clear ownership.
var foreign = new io.netty.channel.ChannelOutboundHandlerAdapter() {
@Override public void write(io.netty.channel.ChannelHandlerContext context, Object message,
io.netty.channel.ChannelPromise promise) { disconnects.add(message); }
};
f.channel.pipeline().addFirst("held-disconnect-write", foreign);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
int before = f.channel.pipeline().names().size();
transport.receive(raw(f));
assertFalse(transport.received.getFirst().isCancelled());
transport.forwardToNativeQueue();
assertFalse(transport.processor.executeSinglePacket(), "Aggregate refusal happens before wrapper.handle");
assertTrue(disconnects.stream().anyMatch(message -> message instanceof
net.minecraft.network.protocol.common.ClientboundDisconnectPacket), "Native kick path actually executed");
assertTrue(f.channel.isActive(), "Retirement proof must not rely on channelInactive");
expire(f);
packet.closeMenu(org.bukkit.event.inventory.InventoryCloseEvent.Reason.PLAYER);
assertSame(foreign, f.channel.pipeline().get("held-disconnect-write"));
assertSame(connection, f.channel.pipeline().get("packet_handler"));
assertEquals(before - 1, f.channel.pipeline().names().size(),
"Already-wrapped aggregate refusal must not strand retired owned hook");
assertTrue(packet.clicks.isEmpty());
} finally { config.packetLimiter = previous; }
}
}
@ParameterizedTest @ValueSource(strings = {"filter", "post"})
void reentrantDisableKeepsUnresolvedOrScheduledExactRawSafeBeyondExpiryAndWindowReuse(String phase)
throws Exception {
var rolls = new AtomicInteger();
try (var f = new NativeAnvilFixture(directory, () -> { rolls.incrementAndGet(); return 99; }, () -> 1, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
NativeAnvilCombiningRegressionTest.nativeExperienceAndWear(f, 100);
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
filter(f, event -> {
if (phase.equals("filter")) {
// Cancellation is NOT terminal inside a filter: the same callback can
// still restore the captured exact raw before ProtocolLib schedules it.
event.setCancelled(true);
f.disableAndCancelTasks();
event.setCancelled(false);
} else {
post(f, event, forwarded -> {
// ProtocolLib has already captured/scheduled the exact raw. A
// post cancellation is not proof that this raw will never arrive.
forwarded.setReadOnly(false);
forwarded.setCancelled(true);
f.disableAndCancelTasks();
});
}
});
transport.receive(raw(f));
assertEquals(phase.equals("post"), transport.received.getFirst().isCancelled());
expire(f); f.tick(); // Raw exact forward is still held in the real library event loop.
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);
reopened.getSlot(0).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
reopened.getSlot(1).set(org.bukkit.craftbukkit.inventory.CraftItemStack.asNMSCopy(sword(4)));
var expected = reopened.getBukkitView().getTopInventory().getItem(2).clone();
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors(); f.tick();
assertTrue(packet.clicks.isEmpty(), "Late revoked exact raw must not fall through to vanilla");
assertEquals(expected, reopened.getBukkitView().getTopInventory().getItem(2));
assertEquals(sword(4), reopened.getBukkitView().getTopInventory().getItem(0));
assertEquals(100, f.nativePlayer.experienceLevel);
assertEquals(0, rolls.get());
assertTrue(f.player.getItemOnCursor().isEmpty());
}
}
@ParameterizedTest @ValueSource(strings = {"replace", "cancel"})
void postHandleMutationCannotEraseCapturedForwardAndRetiredWrapperDenial(String mode) throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
int before = f.channel.pipeline().names().size();
filter(f, event -> post(f, event, forwarded -> {
forwarded.setReadOnly(false);
if (mode.equals("cancel")) forwarded.setCancelled(true);
else forwarded.setPacket(new PacketContainer(forwarded.getPacketType(), inert(f)));
f.disableAndCancelTasks();
}));
transport.receive(raw(f));
assertEquals(1, NetworkMarker.getNetworkMarker(transport.received.getFirst()).getPostListeners().size(),
"Only the foreign marker listener exists; ownership never adds an acknowledgement");
expire(f);
assertEquals(before, f.channel.pipeline().names().size(), "Captured exact raw is still unresolved");
when(packet.handler.shouldHandleMessage(any())).thenReturn(false);
transport.forwardToNativeQueue();
assertEquals(before - 1, f.channel.pipeline().names().size(),
"Late raw is wrapped revoked and releases the retired hook without wrapper.handle");
assertTrue(transport.processor.executeSinglePacket());
assertFalse(transport.processor.executeSinglePacket());
assertTrue(packet.clicks.isEmpty());
assertTrue(f.player.getItemOnCursor().isEmpty(), "Skipped revoked wrapper delivers nothing");
}
}
@Test void mainFenceSnapshotExcludesSubsequentReceiveWhoseRawIsBehindBarrier() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
var cancellation = filter(f, event -> event.setCancelled(true));
transport.receive(raw(f));
transport.pumpLibraryTasks(); // First snapshot/barrier precedes the next raw forward.
f.protocol.removePacketListener(cancellation);
transport.receive(raw(f));
assertFalse(transport.received.getLast().isCancelled());
transport.forwardToNativeQueue();
assertTrue(transport.processor.executeSinglePacket());
transport.assertNoErrors();
assertEquals(1, packet.clicks.size(), "Later admitted raw must still reach the exact native wrapper");
f.tick();
assertEquals(6, f.player.getItemOnCursor().getEnchantmentLevel(org.bukkit.enchantments.Enchantment.SHARPNESS));
}
}
@Test void unavailablePublicLibrarySchedulerWithholdsEnhancedOutputBeforeHookInstallation() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
var scheduler = com.comphenix.protocol.ProtocolLibrary.getScheduler();
int before = f.channel.pipeline().names().size();
try {
NativeAnvilFixture.field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", null);
f.inputs(sword(5), sword(5));
transport.acknowledgeLoop();
assertNull(f.output(), "No library-main fence port means no enhanced bypass/output");
assertEquals(before, f.channel.pipeline().names().size());
} finally {
NativeAnvilFixture.field(com.comphenix.protocol.ProtocolLibrary.class, null, "scheduler", scheduler);
}
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
assertNotNull(f.output(), "A failed constructor did not leave an owned boundary behind");
}
}
@Test void rejectedLibraryFenceKeepsUnresolvedTombstoneRatherThanClaimingNoForward() throws Exception {
try (var f = new NativeAnvilFixture(directory, true);
var packet = new NativeAnvilPacketFixture(f);
var transport = new NativeAnvilTransportFixture(f, packet)) {
f.inputs(sword(5), sword(5));
transport.acknowledgeHook();
int before = f.channel.pipeline().names().size();
f.librarySchedulingFailure = new IllegalStateException("Controlled library fence rejection");
transport.receive(raw(f));
assertTrue(transport.received.getFirst().isCancelled());
f.disableAndCancelTasks();
expire(f);
transport.forwardToNativeQueue();
assertFalse(transport.processor.executeSinglePacket());
assertEquals(before, f.channel.pipeline().names().size(),
"Failed fence cannot prove NO_FORWARD; unresolved ownership stays until raw/disconnect");
assertTrue(packet.clicks.isEmpty());
}
}
private static void post(NativeAnvilFixture f, PacketEvent event, Consumer<PacketEvent> callback) {
// A foreign post listener owns its own marker; production no longer creates one.
var marker = NetworkMarker.getNetworkMarker(event);
if (marker == null) {
marker = new NetworkMarker(ConnectionSide.CLIENT_SIDE, event.getPacketType());
event.setNetworkMarker(marker);
}
marker.addPostListener(new PacketPostListener() {
@Override public org.bukkit.plugin.Plugin getPlugin() { return f.plugin; }
@Override public void onPostEvent(PacketEvent forwarded) { callback.accept(forwarded); }
});
}
private static PacketAdapter filter(NativeAnvilFixture f, Consumer<PacketEvent> callback) {
var listener = new PacketAdapter(f.plugin, ListenerPriority.MONITOR, PacketType.Play.Client.WINDOW_CLICK) {
@Override public void onPacketReceiving(PacketEvent event) { callback.accept(event); }
};
f.protocol.addPacketListener(listener);
return listener;
}
private static ServerboundContainerClickPacket inert(NativeAnvilFixture f) {
return new ServerboundContainerClickPacket(f.menu.containerId, f.menu.getStateId(), (short) -999,
(byte) 0, ContainerInput.PICKUP, new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), HashedStack.EMPTY);
}
private static void expire(NativeAnvilFixture f) {
for (long i = 0; i < AdvancedAnvilProtocolGuard.ADMISSION_TICKS; i++) f.tick();
}
}
@@ -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,51 @@
package games.dmg.spigotinventoryhelper;
import io.netty.channel.*;
import java.util.List;
import java.util.concurrent.TimeUnit;
/** Real registered local pipeline/event loop, with no byte transport or sockets. */
final class NativeAnvilTestChannel extends AbstractChannel {
final DefaultEventLoop loop = new DefaultEventLoop();
private final ChannelConfig config = new DefaultChannelConfig(this);
final List<Throwable> failures = new java.util.concurrent.CopyOnWriteArrayList<>();
private volatile boolean open = true;
NativeAnvilTestChannel() throws InterruptedException {
super(null);
loop.register(this).sync();
}
void acknowledge() throws Exception { loop.submit(() -> {}).get(10, TimeUnit.SECONDS); }
void finish() throws InterruptedException {
close().sync();
loop.shutdownGracefully(0, 1, TimeUnit.SECONDS).sync();
}
@Override public ChannelConfig config() { return config; }
@Override public ChannelMetadata metadata() { return new ChannelMetadata(false); }
@Override public boolean isOpen() { return open; }
@Override public boolean isActive() { return open && isRegistered(); }
@Override protected boolean isCompatible(EventLoop candidate) { return candidate == loop; }
@Override protected java.net.SocketAddress localAddress0() { return null; }
@Override protected java.net.SocketAddress remoteAddress0() { return null; }
@Override protected void doBind(java.net.SocketAddress address) { throw new UnsupportedOperationException("No sockets"); }
@Override protected void doDisconnect() { doClose(); }
@Override protected void doClose() { open = false; }
@Override protected void doBeginRead() { }
@Override protected void doWrite(ChannelOutboundBuffer buffer) { while (buffer.current() != null) buffer.remove(); }
@Override protected AbstractUnsafe newUnsafe() {
return new AbstractUnsafe() {
@Override public void connect(java.net.SocketAddress remote, java.net.SocketAddress local,
ChannelPromise promise) { promise.setFailure(new UnsupportedOperationException("No sockets")); }
};
}
@Override protected DefaultChannelPipeline newChannelPipeline() {
return new DefaultChannelPipeline(this) {
@Override protected void onUnhandledInboundException(Throwable failure) {
if (!(failure instanceof net.minecraft.server.RunningOnDifferentThreadException)) failures.add(failure);
}
};
}
}
@@ -0,0 +1,115 @@
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.*;
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 library forwarding, one registered real event loop/pipeline, and real native queue.
* Only byte I/O, packet lookup/filter invocation and external player/world ports are controlled.
* No sockets/server startup. Handler readiness and library-main callbacks require explicit pumping.
*/
final class NativeAnvilTransportFixture implements AutoCloseable {
final NativeAnvilPacketFixture packet;
final PacketProcessor processor;
private final NativeAnvilFixture f;
private final DefaultEventLoop loop;
private final CountDownLatch forward = new CountDownLatch(1);
private boolean held;
private boolean receiving;
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.f = f;
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());
if (!(f.channel instanceof NativeAnvilTestChannel channel)) {
throw new AssertionError("Transport must start with its real channel; native handlers are not sharable");
}
loop = channel.loop;
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));
channel.pipeline().addFirst("protocollib-boundary", new ChannelInboundHandlerAdapter());
acknowledgeLoop();
context = channel.pipeline().context("protocollib-boundary");
if (context.channel() != packet.connection.channel || context.executor() != channel.eventLoop()
|| channel.eventLoop() != loop) throw new AssertionError("Library and native loop must be identical");
}
void acknowledgeHook() throws Exception {
acknowledgeLoop(); // handlerAdded on the actual event loop, not an inline readiness mock
f.tick(); // queued owner readiness/recompute callback on main
acknowledgeLoop();
if (f.output() == null) throw new AssertionError("Readiness callback did not publish enhanced output");
}
void receive(ServerboundContainerClickPacket raw) throws Exception {
if (!held) {
held = true;
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); }
});
}
var method = NettyChannelInjector.class.getDeclaredMethod("processInboundInternal",
ChannelHandlerContext.class, PacketContainer.class);
method.setAccessible(true);
receiving = true;
try { method.invoke(injector, context, new PacketContainer(PacketType.Play.Client.WINDOW_CLICK, raw)); }
finally { receiving = false; }
}
void pumpLibraryTasks() {
if (receiving) throw new AssertionError("Fence main callback must run after library callback returns");
f.pumpLibraryTasks();
}
void forwardToNativeQueue() throws Exception {
pumpLibraryTasks(); // queues barriers behind captured raw tasks, never inline with filters/posts
forward.countDown();
acknowledgeLoop();
}
void acknowledgeLoop() throws Exception {
loop.submit(() -> {}).get(10, TimeUnit.SECONDS);
failures.addAll(((NativeAnvilTestChannel) f.channel).failures);
assertNoErrors();
}
void assertNoErrors() {
if (!failures.isEmpty()) throw new AssertionError("Transport/native dispatch failed", failures.getFirst());
}
@Override public void close() {
forward.countDown();
try { ((NativeAnvilTestChannel) f.channel).acknowledge(); }
catch (Exception failure) { throw new AssertionError("Transport shutdown acknowledgment failed", failure); }
assertNoErrors();
}
}