Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand All @@ -96,6 +104,7 @@ internal constructor(
private val lastLoggedStatus = AtomicReference<PackSetClientStatus?>(null)
private val configurationWaiter = ResourcePackConfigurationWaiter()
private val appearanceTracker = AtomicReference<PlayerChatAppearanceTracker?>(null)
private val sidebarTracker = AtomicReference<PlayerChatAppearanceTracker?>(null)
private val messages =
Translations.forBundle("gg.grounds.resourcepacks.messages", javaClass.classLoader)
private val deadlines = Any()
Expand All @@ -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}, " +
Expand All @@ -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 =
Expand All @@ -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
}
}
Expand All @@ -178,6 +198,7 @@ internal constructor(
)
} ?: return@statusObserver
observed.appearance?.let { chatStyleBridge.send(player, it) }
publishSidebarStyle(player)
configurationWaiter.onStatus(
playerId,
packId,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}
}
Expand All @@ -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() }
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -509,6 +554,7 @@ internal constructor(
private data class DisconnectedPlayer(
val initial: InitialDeadline,
val appearance: PlayerChatStyleSnapshot?,
val sidebarAppearance: PlayerChatStyleSnapshot?,
)

private companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String>): Set<String> {
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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,
Expand All @@ -1662,6 +1714,8 @@ class GroundsResourcePacksPluginTest {
platformLookup = platformLookup,
appearanceRegistry = appearanceRegistry,
chatStyleBridge = chatStyleBridge,
sidebarStyleBridge = sidebarStyleBridge,
sidebarProtocol = sidebarProtocol,
)

private fun fireSubscribed(plugin: GroundsResourcePacksPlugin, event: Any) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> {
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))
}
}
Loading