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