diff --git a/paper-api/src/main/java/io/papermc/paper/event/packet/UncheckedSignChangeEvent.java b/paper-api/src/main/java/io/papermc/paper/event/packet/UncheckedSignChangeEvent.java
new file mode 100644
index 000000000..e36aeb196
--- /dev/null
+++ b/paper-api/src/main/java/io/papermc/paper/event/packet/UncheckedSignChangeEvent.java
@@ -0,0 +1,94 @@
+package io.papermc.paper.event.packet;
+
+import io.papermc.paper.math.BlockPosition;
+import io.papermc.paper.math.Position;
+import java.util.Collections;
+import java.util.List;
+import net.kyori.adventure.text.Component;
+import org.bukkit.block.sign.Side;
+import org.bukkit.entity.Player;
+import org.bukkit.event.Cancellable;
+import org.bukkit.event.HandlerList;
+import org.bukkit.event.player.PlayerEvent;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.Unmodifiable;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * Called when a client attempts to modify a sign, but the location at which the sign should be edited
+ * has not yet been checked for the existence of a real sign.
+ *
+ * Cancelling this event will prevent further processing of the sign change, but needs further handling
+ * by the plugin as the client's local world might be in an inconsistent state.
+ *
+ * @see Player#openVirtualSign(Position, Side)
+ */
+@NullMarked
+@ApiStatus.Experimental
+public class UncheckedSignChangeEvent extends PlayerEvent implements Cancellable {
+
+ private static final HandlerList HANDLER_LIST = new HandlerList();
+ private boolean cancel = false;
+ private final BlockPosition editedBlockPosition;
+ private final Side side;
+ private final List lines;
+
+ @ApiStatus.Internal
+ public UncheckedSignChangeEvent(
+ final Player editor,
+ final BlockPosition editedBlockPosition,
+ final Side side,
+ final List lines
+ ) {
+ super(editor);
+ this.editedBlockPosition = editedBlockPosition;
+ this.side = side;
+ this.lines = lines;
+ }
+
+ /**
+ * Gets the location at which a potential sign was edited.
+ *
+ * @return location where the change happened
+ */
+ public BlockPosition getEditedBlockPosition() {
+ return editedBlockPosition;
+ }
+
+ /**
+ * Gets which side of the sign was edited.
+ *
+ * @return {@link Side} that was edited
+ */
+ public Side getSide() {
+ return side;
+ }
+
+ /**
+ * Gets the lines that the player has entered.
+ *
+ * @return the lines
+ */
+ public @Unmodifiable List lines() {
+ return Collections.unmodifiableList(lines);
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancel;
+ }
+
+ @Override
+ public void setCancelled(final boolean cancel) {
+ this.cancel = cancel;
+ }
+
+ @Override
+ public HandlerList getHandlers() {
+ return HANDLER_LIST;
+ }
+
+ public static HandlerList getHandlerList() {
+ return HANDLER_LIST;
+ }
+}
diff --git a/paper-api/src/main/java/org/bukkit/entity/Player.java b/paper-api/src/main/java/org/bukkit/entity/Player.java
index c3dfe3471..d34419693 100644
--- a/paper-api/src/main/java/org/bukkit/entity/Player.java
+++ b/paper-api/src/main/java/org/bukkit/entity/Player.java
@@ -12,6 +12,7 @@ import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import io.papermc.paper.entity.LookAnchor;
import io.papermc.paper.entity.PlayerGiveResult;
+import io.papermc.paper.math.Position;
import org.bukkit.BanEntry;
import org.bukkit.DyeColor;
import org.bukkit.Effect;
@@ -52,7 +53,6 @@ import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.messaging.PluginMessageRecipient;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
-import org.bukkit.profile.PlayerProfile;
import org.bukkit.scoreboard.Scoreboard;
import org.jetbrains.annotations.ApiStatus;
import org.jspecify.annotations.NullMarked;
@@ -3449,6 +3449,21 @@ public interface Player extends HumanEntity, Conversable, OfflinePlayer, PluginM
*/
public void openSign(Sign sign, Side side);
+ /**
+ * Open a sign for editing by the player.
+ *
+ * The sign must only be placed locally for the player, which can be done with {@link #sendBlockChange(Location, BlockData)} and {@link #sendBlockUpdate(Location, TileState)}.
+ * A side-effect of this is that normal events, like {@link org.bukkit.event.block.SignChangeEvent} will not be called (unless there is an actual sign in the world).
+ * Additionally, the client may enforce distance limits to the opened position.
+ *
+ *
+ * @param block The block where the client has a sign placed
+ * @param side The side to edit
+ * @see io.papermc.paper.event.packet.UncheckedSignChangeEvent
+ */
+ @ApiStatus.Experimental
+ void openVirtualSign(Position block, Side side);
+
/**
* Shows the demo screen to the player, this screen is normally only seen in
* the demo version of the game.
diff --git a/paper-server/patches/sources/net/minecraft/server/network/ServerGamePacketListenerImpl.java.patch b/paper-server/patches/sources/net/minecraft/server/network/ServerGamePacketListenerImpl.java.patch
index 0b712a82b..4ae0a3e92 100644
--- a/paper-server/patches/sources/net/minecraft/server/network/ServerGamePacketListenerImpl.java.patch
+++ b/paper-server/patches/sources/net/minecraft/server/network/ServerGamePacketListenerImpl.java.patch
@@ -2486,7 +2486,7 @@
} else if (flag && flag2) {
if (this.dropSpamThrottler.isUnderThreshold()) {
this.dropSpamThrottler.increment();
-@@ -1895,11 +_,24 @@
+@@ -1895,15 +_,38 @@
@Override
public void handleSignUpdate(ServerboundSignUpdatePacket packet) {
@@ -2512,6 +2512,20 @@
this.player.resetLastActionTime();
ServerLevel serverLevel = this.player.serverLevel();
BlockPos pos = packet.getPos();
+ if (serverLevel.hasChunkAt(pos)) {
++ // Paper start - Add API for client-side signs
++ if (!new io.papermc.paper.event.packet.UncheckedSignChangeEvent(
++ this.player.getBukkitEntity(),
++ io.papermc.paper.util.MCUtil.toPosition(pos),
++ packet.isFrontText() ? org.bukkit.block.sign.Side.FRONT : org.bukkit.block.sign.Side.BACK,
++ filteredText.stream().map(line -> net.kyori.adventure.text.Component.text(line.raw())).toList())
++ .callEvent()) {
++ return;
++ }
++ // Paper end - Add API for client-side signs
+ if (!(serverLevel.getBlockEntity(pos) instanceof SignBlockEntity signBlockEntity)) {
+ return;
+ }
@@ -1915,14 +_,32 @@
@Override
public void handlePlayerAbilities(ServerboundPlayerAbilitiesPacket packet) {
diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java
index 8a1e2785d..1db6276ae 100644
--- a/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java
+++ b/paper-server/src/main/java/org/bukkit/craftbukkit/entity/CraftPlayer.java
@@ -12,6 +12,8 @@ import io.papermc.paper.configuration.GlobalConfiguration;
import io.papermc.paper.entity.LookAnchor;
import io.papermc.paper.entity.PaperPlayerGiveResult;
import io.papermc.paper.entity.PlayerGiveResult;
+import io.papermc.paper.math.Position;
+import io.papermc.paper.util.MCUtil;
import it.unimi.dsi.fastutil.shorts.ShortArraySet;
import it.unimi.dsi.fastutil.shorts.ShortSet;
import java.io.ByteArrayOutputStream;
@@ -73,6 +75,7 @@ import net.minecraft.network.protocol.game.ClientboundHurtAnimationPacket;
import net.minecraft.network.protocol.game.ClientboundLevelEventPacket;
import net.minecraft.network.protocol.game.ClientboundLevelParticlesPacket;
import net.minecraft.network.protocol.game.ClientboundMapItemDataPacket;
+import net.minecraft.network.protocol.game.ClientboundOpenSignEditorPacket;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket;
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
import net.minecraft.network.protocol.game.ClientboundRemoveMobEffectPacket;
@@ -3037,6 +3040,13 @@ public class CraftPlayer extends CraftHumanEntity implements Player {
CraftSign.openSign(sign, this, side);
}
+ @Override
+ public void openVirtualSign(Position block, Side side) {
+ if (this.getHandle().connection == null) return;
+
+ this.getHandle().connection.send(new ClientboundOpenSignEditorPacket(MCUtil.toBlockPos(block), side == Side.FRONT));
+ }
+
@Override
public void showDemoScreen() {
if (this.getHandle().connection == null) return;