Complete chat with the server's custom chat completions

ClientboundCustomChatCompletionsPacket was only an id constant, so the
names and tags proxies and plugins add with it (players on other backend
servers, chat tags) never showed when Tab completes chat. Decode it, keep
the connection's set like ClientSuggestionProvider.modifyCustomCompletions
(ADD, REMOVE, SET; an unknown action id is ADD), clear it on a
reconfiguration, and complete chat from getCustomTabSuggestions: the
players and the custom completions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-30 14:05:27 +07:00
1 parent b1dfde280b
commit e9eda298fd
9 files changed
+184 -7

No files matched your search

@@ -28,6 +28,10 @@ pub struct Registries {
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
/// each once.
pub custom_completions: std::collections::BTreeSet<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
@@ -87,7 +91,55 @@ pub struct RegistryIds {
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
/// ClientboundCustomChatCompletionsPacket.Action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CustomCompletionAction {
Add,
Remove,
Set,
}
impl CustomCompletionAction {
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
/// so an unknown id is `ADD`.
pub fn from_id(id: i32) -> Self {
match id {
1 => Self::Remove,
2 => Self::Set,
_ => Self::Add,
}
}
}
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
/// removes or replaces the extra chat completions.
pub fn modify_custom_completions(
set: &mut std::collections::BTreeSet<String>,
action: CustomCompletionAction,
entries: &[String],
) {
match action {
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
CustomCompletionAction::Remove => {
for e in entries {
set.remove(e);
}
}
CustomCompletionAction::Set => {
set.clear();
set.extend(entries.iter().cloned());
}
}
}
impl Registries {
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
/// command) completes, the online players' names with the server's custom
/// completions, each once.
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
self.players
.iter()
.chain(&self.custom_completions)
.map(String::as_str)
.collect()
}
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
+3 -1
View File
@@ -929,7 +929,9 @@ impl ChatScreen {
} else if !command.chars().all(char::is_whitespace) {
let partial = &command[..cursor_b];
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
// ClientSuggestionProvider.getCustomTabSuggestions: the players
// and the server's custom chat completions.
builder.suggest_matching(c.registries.custom_tab_suggestions());
let (range, list) = merge_suggestions(partial, builder.out);
self.awaiting = None;
self.pending = Some((range.0, list));
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
assert_eq!(screen.value(), "/coords");
}
#[test]
fn chat_completes_the_servers_custom_completions_with_the_players() {
// CommandSuggestions.updateCommandInfo completes chat from
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
// and what ClientboundCustomChatCompletionsPacket added.
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
let font = font();
let m = Measure(&font);
let mut registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
assert_eq!(
registries
.custom_tab_suggestions()
.into_iter()
.collect::<Vec<_>>(),
["#trade", "Notch", "Steve", "Zuri"]
);
let completed = |registries: &Registries, line: &str| {
let dispatcher = crate::command::vanilla::game_dispatcher();
let c = Commands {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
}
screen.value().to_owned()
};
assert_eq!(completed(&registries, "hi no"), "hi Notch");
assert_eq!(completed(&registries, "sell #tr"), "sell #trade");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
assert_eq!(completed(&registries, "hi al"), "hi al");
// SET replaces them all; the players stay.
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
assert_eq!(completed(&registries, "hi no"), "hi no");
assert_eq!(completed(&registries, "hi al"), "hi Alex");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
}
#[test]
fn ask_server_nodes_wait_for_the_servers_completions() {
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
+1
View File
@@ -474,6 +474,7 @@ impl Game<'_> {
if let Some(names) = self.remote_online_names() {
self.ui.registries.players = names;
}
self.ui.registries.custom_completions = self.remote_custom_completions();
}
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
/// last time, when this method can restore it (a command draft only for
+28 -1
View File
@@ -183,6 +183,9 @@ pub(super) struct Remote {
chat_previous: Option<Instant>,
/// The server's command tree (ClientboundCommandsPacket).
pub commands: Option<horizon_client::command::Dispatcher>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions this server sent (ClientboundCustomChatCompletionsPacket).
custom_completions: std::collections::BTreeSet<String>,
players: HashMap<u128, String>,
/// `ClientPacketListener.playerInfoMap` as player-list entries, and
/// which of them are listed.
@@ -518,6 +521,7 @@ impl Game<'_> {
chat_queue: VecDeque::new(),
chat_previous: None,
commands: None,
custom_completions: Default::default(),
players: HashMap::new(),
tab_players: HashMap::new(),
skin_textures: HashMap::new(),
@@ -908,6 +912,14 @@ impl Game<'_> {
)
}
/// `ClientSuggestionProvider.customCompletionSuggestions` of the server
/// connection (none without one).
pub(super) fn remote_custom_completions(&self) -> std::collections::BTreeSet<String> {
self.remote
.as_ref()
.map(|r| r.custom_completions.clone())
.unwrap_or_default()
}
/// The names in the server's player list, sorted.
pub(super) fn remote_online_names(&self) -> Option<Vec<String>> {
let r = self.remote.as_ref()?;
@@ -1049,14 +1061,18 @@ impl Game<'_> {
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard.
// ClientPacketListener starts with no players or scoreboard,
// and its new ClientSuggestionProvider with no custom
// chat completions.
self.hud_disconnected();
self.score.board = Default::default();
if let Some(r) = self.remote.as_mut() {
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
@@ -3052,6 +3068,17 @@ impl Game<'_> {
{
self.handle_command_suggestions(&data);
}
C::CustomChatCompletions(p) => {
// ClientPacketListener.handleCustomChatCompletions.
if let Some(r) = self.remote.as_mut() {
horizon_client::command::modify_custom_completions(
&mut r.custom_completions,
horizon_client::command::CustomCompletionAction::from_id(p.action),
&p.entries,
);
self.ui.registries.custom_completions = r.custom_completions.clone();
}
}
C::Commands(p) => {
let tree = horizon_client::command::network::from_packet(
&p,
+3 -1
View File
@@ -2,7 +2,9 @@
//! the vanilla error messages and completion suggestions.
use super::{CmdResult, CommandError, Context, Reader, SuggestionsBuilder};
pub use horizon_client_core::registries::{Registries, RegistryIds};
pub use horizon_client_core::registries::{
CustomCompletionAction, Registries, RegistryIds, modify_custom_completions,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Resource {
+1 -1
View File
@@ -22,7 +22,7 @@ pub mod tree_world;
pub mod vanilla;
mod vanilla_order;
pub use args::{Arg, ArgType, Registries};
pub use args::{Arg, ArgType, CustomCompletionAction, Registries, modify_custom_completions};
use horizon_client_core::text::Text;
/// The command id of a mod's command (`Dispatcher::with_mod_commands`).
@@ -211,6 +211,18 @@ packet!(ContainerSetData { container: i32 => VarInt, id: i16 => Short, value: i1
// ClientboundCooldownPacket: the cooldown group (an identifier) and the ticks
// its entry lasts (0 ends it).
packet!(Cooldown { group: String => Ident, duration: i32 => VarInt });
packet!(
/// ClientboundCustomChatCompletionsPacket: how the server changes the
/// extra chat completions (`custom_chat_action`) and the entries.
CustomChatCompletions { action: i32 => VarInt, entries: Vec<String> => List<Str> }
);
/// ClientboundCustomChatCompletionsPacket.Action, by id (`ByIdMap.continuous`
/// with `OutOfBoundsStrategy.ZERO`: an unknown id is `ADD`).
pub mod custom_chat_action {
pub const ADD: i32 = 0;
pub const REMOVE: i32 = 1;
pub const SET: i32 = 2;
}
packet!(ContainerSetSlot { container: i32 => VarInt, state: i32 => VarInt, slot: i16 => Short, item: Option<Stack> => Slot });
/// A holder reference: registry id + 1, or 0 for an inline value (unsupported).
pub struct HolderId;
@@ -1840,6 +1852,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
ContainerSetSlot(ContainerSetSlot) = CONTAINER_SET_SLOT,
CookieRequest(CookieRequest) = COOKIE_REQUEST,
Cooldown(Cooldown) = COOLDOWN,
CustomChatCompletions(CustomChatCompletions) = CUSTOM_CHAT_COMPLETIONS,
CustomPayload(CustomPayload) = CUSTOM_PAYLOAD,
Disconnect(Disconnect) = DISCONNECT,
DisguisedChat(DisguisedChat) = DISGUISED_CHAT,
@@ -2401,6 +2414,34 @@ mod tests {
}
}
#[test]
fn custom_chat_completions_are_an_action_and_a_string_list() {
// ClientboundCustomChatCompletionsPacket.STREAM_CODEC: Action.STREAM_CODEC
// (ByteBufCodecs.idMapper, a VarInt), then STRING_UTF8 in a list.
let payload = b"Steve#tag";
let packet = Clientbound::CustomChatCompletions(CustomChatCompletions {
action: custom_chat_action::SET,
entries: vec!["Steve".into(), "#tag".into()],
});
assert_eq!(
Clientbound::decode(
crate::ids::play::clientbound::CUSTOM_CHAT_COMPLETIONS,
payload
)
.unwrap(),
packet
);
assert_eq!(&packet.encode()[1..], payload);
round_trip(packet, Clientbound::encode, Clientbound::decode);
assert!(
Clientbound::decode(
crate::ids::play::clientbound::CUSTOM_CHAT_COMPLETIONS,
&payload[..payload.len() - 1]
)
.is_err()
);
}
#[test]
fn serverbound_abilities_carry_only_flying() {
// ServerboundPlayerAbilitiesPacket.write: one byte, 2 when flying.
+3 -3
View File
@@ -65,7 +65,7 @@ ClientPacketListener.handleSetBorderSize port crates/horizon-client/src/game/rem
ClientPacketListener.handleSetBorderWarningDistance port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderWarningDistance arm
ClientPacketListener.handleSetBorderWarningDelay port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderWarningDelay arm
ClientPacketListener.handleTitlesClear port crates/horizon-client/src/game/remote.rs::Game::handle_packet ClearTitles arm: clear, optionally reset the times
ClientPacketListener.handleCustomChatCompletions missing minor: server-sent custom chat completions are not used
ClientPacketListener.handleCustomChatCompletions port crates/horizon-client/src/game/remote.rs::Game::handle_packet CustomChatCompletions arm: modify_custom_completions on the connection's set
ClientPacketListener.setTitleText port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetTitleText arm
ClientPacketListener.setSubtitleText port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetSubtitleText arm
ClientPacketListener.setTitlesAnimation port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetTitlesAnimation arm
@@ -116,12 +116,12 @@ ClientPacketListener.notifyPlayerLoaded port crates/horizon-client/src/game/remo
ClientPacketListener.setKeyPair missing player: no chat key pair is ever set, so chat and commands stay unsigned
ClientRegistryLayer.createRegistryAccess skip:data registry layers come from Horizon's own tables
ClientSuggestionProvider.getOnlinePlayerNames port crates/horizon-client/src/game/remote.rs::Game::remote_online_names sorted names of the server's player list
ClientSuggestionProvider.getCustomTabSuggestions missing minor: server-sent custom completions are not used
ClientSuggestionProvider.getCustomTabSuggestions port crates/horizon-client-core/src/registries.rs::Registries::custom_tab_suggestions the player names and the server's custom completions, each once
ClientSuggestionProvider.getSelectedEntities missing minor: no suggestion for the entity under the crosshair
ClientSuggestionProvider.suggestRegistryElements port crates/horizon-command/src/args.rs::suggest_resources registry ids come from Horizon's registry tables
ClientSuggestionProvider.getRelevantCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates the block under the crosshair for block positions
ClientSuggestionProvider.getAbsoluteCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates gap: minor: the exact hit point is not offered; Vec3 arguments only get ~
ClientSuggestionProvider.modifyCustomCompletions missing minor: server-sent custom completions are not stored
ClientSuggestionProvider.modifyCustomCompletions port crates/horizon-client-core/src/registries.rs::modify_custom_completions ADD, REMOVE or SET on the set the connection keeps
KnownPacksManager.trySelectingPacks skip:data Horizon answers SelectKnownPacks with no packs, so the server sends full registries (net.rs configure)
LegacyServerPinger.channelActive skip:other legacy pre-1.7 server-list ping, not used by this client
LegacyServerPinger.channelRead0 skip:other legacy pre-1.7 server-list ping, not used by this client
1 ClientPacketListener.<init> port crates/horizon-client/src/game/remote.rs::Game::join_server gap: minor: clears the chat on join; vanilla restores chat state kept across a reconfiguration
65 ClientPacketListener.handleSetBorderWarningDistance port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderWarningDistance arm
66 ClientPacketListener.handleSetBorderWarningDelay port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetBorderWarningDelay arm
67 ClientPacketListener.handleTitlesClear port crates/horizon-client/src/game/remote.rs::Game::handle_packet ClearTitles arm: clear, optionally reset the times
68 ClientPacketListener.handleCustomChatCompletions missing port crates/horizon-client/src/game/remote.rs::Game::handle_packet minor: server-sent custom chat completions are not used CustomChatCompletions arm: modify_custom_completions on the connection's set
69 ClientPacketListener.setTitleText port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetTitleText arm
70 ClientPacketListener.setSubtitleText port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetSubtitleText arm
71 ClientPacketListener.setTitlesAnimation port crates/horizon-client/src/game/remote.rs::Game::handle_packet SetTitlesAnimation arm
116 ClientPacketListener.setKeyPair missing player: no chat key pair is ever set, so chat and commands stay unsigned
117 ClientRegistryLayer.createRegistryAccess skip:data registry layers come from Horizon's own tables
118 ClientSuggestionProvider.getOnlinePlayerNames port crates/horizon-client/src/game/remote.rs::Game::remote_online_names sorted names of the server's player list
119 ClientSuggestionProvider.getCustomTabSuggestions missing port crates/horizon-client-core/src/registries.rs::Registries::custom_tab_suggestions minor: server-sent custom completions are not used the player names and the server's custom completions, each once
120 ClientSuggestionProvider.getSelectedEntities missing minor: no suggestion for the entity under the crosshair
121 ClientSuggestionProvider.suggestRegistryElements port crates/horizon-command/src/args.rs::suggest_resources registry ids come from Horizon's registry tables
122 ClientSuggestionProvider.getRelevantCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates the block under the crosshair for block positions
123 ClientSuggestionProvider.getAbsoluteCoordinates port crates/horizon-command/src/args.rs::suggest_coordinates gap: minor: the exact hit point is not offered; Vec3 arguments only get ~
124 ClientSuggestionProvider.modifyCustomCompletions missing port crates/horizon-client-core/src/registries.rs::modify_custom_completions minor: server-sent custom completions are not stored ADD, REMOVE or SET on the set the connection keeps
125 KnownPacksManager.trySelectingPacks skip:data Horizon answers SelectKnownPacks with no packs, so the server sends full registries (net.rs configure)
126 LegacyServerPinger.channelActive skip:other legacy pre-1.7 server-list ping, not used by this client
127 LegacyServerPinger.channelRead0 skip:other legacy pre-1.7 server-list ping, not used by this client