From 41fa915320eb692eb10d6c21a3e66ef69391d59c Mon Sep 17 00:00:00 2001 From: Lukas Jost Date: Thu, 29 Oct 2026 17:20:23 +0100 Subject: [PATCH] feat(sidebar): bridge verified font readiness to lobby --- README.md | 18 +++++++ .../velocity/GroundsResourcePacksPlugin.kt | 52 ++++++++++++++++-- .../velocity/PlayerChatStyleBridge.kt | 4 +- .../velocity/PlayerSidebarAppearance.kt | 18 +++++++ .../GroundsResourcePacksPluginTest.kt | 54 +++++++++++++++++++ .../velocity/PlayerSidebarAppearanceTest.kt | 41 ++++++++++++++ 6 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerSidebarAppearance.kt create mode 100644 velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerSidebarAppearanceTest.kt diff --git a/README.md b/README.md index 0da5cdf..9bad281 100644 --- a/README.md +++ b/README.md @@ -94,3 +94,21 @@ is available. ## Current scope There is no Minestom adapter and no gamemode or per-server PackSet overlay support yet. + +## Sidebar font confirmation + +`SIDEBAR_FONT_PACK_SHA1S` is a comma-separated allowlist of lowercase SHA-1 hashes +for platform packs carrying the new `grounds:sidebar` font. It is independent of +`CHAT_FONT_PACK_SHA1S`; an old chat-font pack never enables sidebar glyphs. With +no sidebar allowlist the lobby stays in readable text mode. + +Only a successful load of an offered, allowlisted pack on Java protocol 777, +with a positively identified non-Bedrock player, sets readiness. A replacement +offer, load failure or disconnect revokes it. The revisioned, UUID-bound status +is sent on `grounds:sidebar_style` to the player's actual current backend and +resent after switching servers. Incoming client messages on this channel are +consumed; they cannot enable graphics. + +The backend must include the matching lobby receiver. Roll out the new pack +and allowlisted hash, the proxy sender on every proxy, then the new lobby. +An old receiver or sender continues to use text. diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt index d50c14f..5cf85ca 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPlugin.kt @@ -61,6 +61,8 @@ internal constructor( private val platformLookup: ChatPlatformLookup = ChatPlatformLookup { null }, private val appearanceRegistry: PlayerChatAppearanceRegistry = NoopPlayerChatAppearanceRegistry, private val chatStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, + private val sidebarStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, + private val sidebarProtocol: (java.util.UUID) -> Int? = { appearanceProtocol(it)?.protocol }, ) { @Inject constructor( @@ -85,6 +87,12 @@ internal constructor( registerChannel = { proxy.channelRegistrar.register(it) }, unregisterChannel = { proxy.channelRegistrar.unregister(it) }, ), + sidebarStyleBridge = + VelocityPlayerChatStyleBridge( + registerChannel = { proxy.channelRegistrar.register(it) }, + unregisterChannel = { proxy.channelRegistrar.unregister(it) }, + channelId = SidebarAppearanceEnvironment.CHANNEL, + ), ) private val lifecycle = Any() @@ -96,6 +104,7 @@ internal constructor( private val lastLoggedStatus = AtomicReference(null) private val configurationWaiter = ResourcePackConfigurationWaiter() private val appearanceTracker = AtomicReference(null) + private val sidebarTracker = AtomicReference(null) private val messages = Translations.forBundle("gg.grounds.resourcepacks.messages", javaClass.classLoader) private val deadlines = Any() @@ -113,6 +122,7 @@ internal constructor( deliveryObserver = ResourcePackDeliveryObserver { player, prepared -> appearanceTracker.get()?.offered(player.uniqueId, prepared.packHashes) + sidebarTracker.get()?.offered(player.uniqueId, prepared.packHashes) publishChatStyle(player) log.info( "Resource-pack request sent (playerId=${player.uniqueId}, " + @@ -139,10 +149,14 @@ internal constructor( val tracker = PlayerChatAppearanceTracker(ChatAppearanceEnvironment.from(deployment).fontPackSha1s) appearanceTracker.set(tracker) + sidebarTracker.set( + PlayerChatAppearanceTracker(SidebarAppearanceEnvironment.from(deployment)) + ) appearanceRegistry.register( ResourcePackPlayerChatAppearanceQuery(appearanceProtocol, platformLookup, tracker) ) chatStyleBridge.register() + sidebarStyleBridge.register() val definition = resourcePackSettingsDefinition(bootstrapPackSetChannel(deployment)) val deploymentEnvironment = ResourcePackEnvironment.from(deployment).deploymentEnvironment val listener = @@ -162,13 +176,19 @@ internal constructor( val publishAppearance = when (status) { PlayerResourcePackStatusEvent.Status.SUCCESSFUL -> { - if (packId != null) tracker.loaded(playerId, packId) + if (packId != null) { + tracker.loaded(playerId, packId) + sidebarTracker.get()?.loaded(playerId, packId) + } true } PlayerResourcePackStatusEvent.Status.ACCEPTED, PlayerResourcePackStatusEvent.Status.DOWNLOADED -> false else -> { - if (packId != null) tracker.failed(playerId, packId) + if (packId != null) { + tracker.failed(playerId, packId) + sidebarTracker.get()?.failed(playerId, packId) + } true } } @@ -178,6 +198,7 @@ internal constructor( ) } ?: return@statusObserver observed.appearance?.let { chatStyleBridge.send(player, it) } + publishSidebarStyle(player) configurationWaiter.onStatus( playerId, packId, @@ -227,6 +248,7 @@ internal constructor( synchronized(deadlines) { if (initialDeadlines[playerId]?.player !== event.player()) { appearanceTracker.get()?.reset(playerId) + sidebarTracker.get()?.reset(playerId) } initialDeadlines.put(playerId, initial)?.handle } @@ -265,9 +287,11 @@ internal constructor( DisconnectedPlayer( initial, appearanceTracker.get()?.disconnectSnapshot(event.player.uniqueId), + sidebarTracker.get()?.disconnectSnapshot(event.player.uniqueId), ) } ?: return disconnected.appearance?.let { snapshot -> chatStyleBridge.send(event.player, snapshot) } + disconnected.sidebarAppearance?.let { sidebarStyleBridge.send(event.player, it) } disconnected.initial.handle?.close() coordinator.forget(event.player.uniqueId, disconnected.initial.session) configurationWaiter.forget(event.player.uniqueId, disconnected.initial.completion) @@ -289,7 +313,10 @@ internal constructor( @Subscribe fun onPluginMessage(event: PluginMessageEvent) { - if (event.identifier == chatStyleBridge.identifier) { + if ( + event.identifier == chatStyleBridge.identifier || + event.identifier == sidebarStyleBridge.identifier + ) { event.result = PluginMessageEvent.ForwardResult.handled() } } @@ -303,8 +330,10 @@ internal constructor( var clientToClose: ResourcePackClient? = null coordinator.clear() appearanceTracker.getAndSet(null)?.clear() + sidebarTracker.getAndSet(null)?.clear() appearanceRegistry.unregister() chatStyleBridge.unregister() + sidebarStyleBridge.unregister() snapshotDeadline.close() synchronized(deadlines) { initialDeadlines.values.forEach { it.handle?.close() } @@ -332,6 +361,22 @@ internal constructor( private fun publishChatStyle(player: com.velocitypowered.api.proxy.Player) { val snapshot = captureChatStyle(player) ?: return chatStyleBridge.send(player, snapshot) + publishSidebarStyle(player) + } + + private fun publishSidebarStyle(player: com.velocitypowered.api.proxy.Player) { + val snapshot = + synchronized(deadlines) { + if (stopped.get() || initialDeadlines[player.uniqueId]?.player !== player) + return@synchronized null + sidebarTracker.get()?.snapshot(player.uniqueId) { + SidebarAppearanceEnvironment.eligible( + sidebarProtocol(player.uniqueId), + platformLookup.isBedrock(player.uniqueId), + ) + } + } ?: return + sidebarStyleBridge.send(player, snapshot) } private fun captureChatStyle( @@ -509,6 +554,7 @@ internal constructor( private data class DisconnectedPlayer( val initial: InitialDeadline, val appearance: PlayerChatStyleSnapshot?, + val sidebarAppearance: PlayerChatStyleSnapshot?, ) private companion object { diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridge.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridge.kt index 140dc30..62c8663 100644 --- a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridge.kt +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerChatStyleBridge.kt @@ -42,12 +42,12 @@ internal class VelocityPlayerChatStyleBridge( private val currentServer: (Player) -> ServerConnection? = { player -> player.currentServer.orElse(null) }, + channelId: String = PlayerChatStylePayload.CHANNEL, ) : PlayerChatStyleBridge { private val lifecycle = Any() private var active = false - override val identifier: ChannelIdentifier = - MinecraftChannelIdentifier.from(PlayerChatStylePayload.CHANNEL) + override val identifier: ChannelIdentifier = MinecraftChannelIdentifier.from(channelId) override fun register() { synchronized(lifecycle) { diff --git a/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerSidebarAppearance.kt b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerSidebarAppearance.kt new file mode 100644 index 0000000..415a475 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/resourcepacks/velocity/PlayerSidebarAppearance.kt @@ -0,0 +1,18 @@ +package gg.grounds.resourcepacks.velocity + +/** Separate from chat: an old accepted chat pack does not contain sidebar graphics. */ +internal object SidebarAppearanceEnvironment { + const val CHANNEL = "grounds:sidebar_style" + + fun from(environment: Map): Set { + val raw = environment["SIDEBAR_FONT_PACK_SHA1S"].orEmpty() + if (raw.isBlank()) return emptySet() + val hashes = raw.split(',').map(String::trim) + require(hashes.all { it.matches(Regex("[0-9a-f]{40}")) }) { + "SIDEBAR_FONT_PACK_SHA1S must contain lowercase SHA-1 values" + } + return hashes.toSet() + } + + fun eligible(protocol: Int?, bedrock: Boolean?): Boolean = protocol == 777 && bedrock == false +} diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt index 431b9c0..e6fbbdb 100644 --- a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/GroundsResourcePacksPluginTest.kt @@ -1394,6 +1394,56 @@ class GroundsResourcePacksPluginTest { assertEquals(1, continuation.resumes) } + @Test + fun `disconnect cannot clear sidebar readiness established by a replacement connection`() { + val initial = settings() + val clients = FakeClientFactory() + val events = FakeEventRegistry() + val old = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val replacement = player("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + val sidebar = RecordingPlayerChatStyleBridge() + var onChatSend: () -> Unit = {} + val chat = + object : PlayerChatStyleBridge by NoopPlayerChatStyleBridge { + override fun send(player: Player, snapshot: PlayerChatStyleSnapshot) = onChatSend() + } + val plugin = + plugin( + FakeConfigGateway(ConfigRegistrationResult.ready(), initial), + clients, + events = events, + environment = + mapOf( + "GROUNDS_ENVIRONMENT" to "stage", + "SIDEBAR_FONT_PACK_SHA1S" to "1".repeat(40), + ), + sidebarProtocol = { 777 }, + platformLookup = ChatPlatformLookup { false }, + chatStyleBridge = chat, + sidebarStyleBridge = sidebar, + ) + plugin.onInitialize(ProxyInitializeEvent()) + clients.created + .single() + .emit( + readyState( + initial, + snapshot(initial, packs = listOf(resolvedPack(role = "platform"))), + ) + ) + plugin.onPlayerConfiguration(PlayerConfigurationEvent(old, null)) + terminalStatus(events, old) + assertTrue(sidebar.snapshots.last().second.ready) + onChatSend = { + onChatSend = {} + plugin.onPlayerConfiguration(PlayerConfigurationEvent(replacement, null)) + terminalStatus(events, replacement) + } + plugin.onDisconnect(DisconnectEvent(old, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN)) + plugin.onServerPostConnect(ServerPostConnectEvent(replacement, null)) + assertTrue(sidebar.snapshots.last().second.ready) + } + private fun terminalStatus(events: FakeEventRegistry, player: Player) { packStatus(events, player, PlayerResourcePackStatusEvent.Status.SUCCESSFUL) } @@ -1648,6 +1698,8 @@ class GroundsResourcePacksPluginTest { platformLookup: ChatPlatformLookup = ChatPlatformLookup { null }, appearanceRegistry: PlayerChatAppearanceRegistry = NoopPlayerChatAppearanceRegistry, chatStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, + sidebarStyleBridge: PlayerChatStyleBridge = NoopPlayerChatStyleBridge, + sidebarProtocol: (UUID) -> Int? = { appearanceProtocol(it)?.protocol }, ) = GroundsResourcePacksPlugin( dataDirectory = directory, @@ -1662,6 +1714,8 @@ class GroundsResourcePacksPluginTest { platformLookup = platformLookup, appearanceRegistry = appearanceRegistry, chatStyleBridge = chatStyleBridge, + sidebarStyleBridge = sidebarStyleBridge, + sidebarProtocol = sidebarProtocol, ) private fun fireSubscribed(plugin: GroundsResourcePacksPlugin, event: Any) { diff --git a/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerSidebarAppearanceTest.kt b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerSidebarAppearanceTest.kt new file mode 100644 index 0000000..6595c93 --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/resourcepacks/velocity/PlayerSidebarAppearanceTest.kt @@ -0,0 +1,41 @@ +package gg.grounds.resourcepacks.velocity + +import java.util.UUID +import kotlin.test.* + +class PlayerSidebarAppearanceTest { + @Test + fun `sidebar has its own strict allowlist and exact Java protocol eligibility`() { + val hash = "a".repeat(40) + assertEquals( + setOf(hash), + SidebarAppearanceEnvironment.from(mapOf("SIDEBAR_FONT_PACK_SHA1S" to hash)), + ) + assertTrue( + SidebarAppearanceEnvironment.from(mapOf("CHAT_FONT_PACK_SHA1S" to hash)).isEmpty() + ) + assertFailsWith { + SidebarAppearanceEnvironment.from(mapOf("SIDEBAR_FONT_PACK_SHA1S" to "invalid")) + } + assertTrue(SidebarAppearanceEnvironment.eligible(777, false)) + assertFalse(SidebarAppearanceEnvironment.eligible(776, false)) + assertFalse(SidebarAppearanceEnvironment.eligible(777, true)) + assertFalse(SidebarAppearanceEnvironment.eligible(777, null)) + } + + @Test + fun `old chat pack never confirms sidebar and changed pack identity revokes readiness`() { + val player = UUID.randomUUID() + val pack = UUID.randomUUID() + val tracker = PlayerChatAppearanceTracker(setOf("b".repeat(40))) + tracker.offered(player, mapOf(pack to "a".repeat(40))) + tracker.loaded(player, pack) + assertFalse(tracker.chatFontReady(player)) + val newPack = UUID.randomUUID() + tracker.offered(player, mapOf(newPack to "b".repeat(40))) + tracker.loaded(player, newPack) + assertTrue(tracker.chatFontReady(player)) + tracker.failed(player, newPack) + assertFalse(tracker.chatFontReady(player)) + } +}