diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index 9d23ab2c5e..2d5caf3111 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -170,6 +170,8 @@ struct Server { world: World, next_entity: u32, placed: Vec<(u32, [i32; 3], u32, i32)>, + /// Every packet the hub handed to the game, in order. + packets: Vec, } impl Server { fn tick(&mut self, entities: &[EntityState]) { @@ -193,6 +195,7 @@ impl Server { match event { HostEvent::Joined(conn) => self.hub.place_new_player(conn, &mut ctx), HostEvent::Packet(conn, packet) => { + self.packets.push((*packet).clone()); if let S::UseItemOn(p) = *packet { let at = [p.pos[0], p.pos[1] + 1, p.pos[2]]; ctx.world @@ -260,6 +263,7 @@ fn server_sync(sync: Arc, net: NetConfig) -> Server { world: flat_world(), next_entity: 100, placed: Vec::new(), + packets: Vec::new(), } } @@ -1759,8 +1763,9 @@ fn a_boat_floating_too_long_kicks_its_driver() { } /// A client that numbers its packets as `version` does, from the protocol adapter's own tables: for 26.2 its -/// handshake states 776 and every id it sends and reads is 26.2's. The payloads stay the pinned ones: the -/// packets whose layout changed are a later step of `older-clients`. +/// handshake states 776 and every id it sends and reads is 26.2's. [`Self::send`]'s payloads are the pinned +/// ones; [`Self::send_raw`] sends a packet as the version lays it out. The clientbound world packets arrive +/// in 26.2's layout. struct Numbered { c: Connection, version: ServerVersion, @@ -1775,6 +1780,13 @@ impl Numbered { .ok_or("the client's version has no such packet")?; self.c.send(&body).map_err(|e| e.to_string()) } + /// A packet by the version's own `id`, its payload in the version's layout. + fn send_raw(&mut self, id: i32, payload: &[u8]) -> Result<(), String> { + let mut w = horizon_protocol::buf::Writer::new(); + w.varint(id); + w.bytes(payload); + self.c.send(&w.data).map_err(|e| e.to_string()) + } /// The next packet with its pinned id (the version's own id read off the wire). fn recv(&mut self) -> Result<(i32, Vec), String> { let (id, payload) = self.c.recv().map_err(|e| e.to_string())?; @@ -1975,6 +1987,28 @@ fn a_26_2_client_joins_only_a_server_that_accepts_older_clients() { if id == horizon_protocol::ids::play::clientbound::COMMANDS { continue; } + // World packets come in 26.2's own layout, which the pinned readers don't read (the adapter's + // unit tests read them back with translate's 26.2 readers). + { + use horizon_protocol::ids::play::clientbound as cb; + match id { + cb::LEVEL_CHUNK_WITH_LIGHT => { + chunks += 1; + continue; + } + cb::LIGHT_UPDATE + | cb::LEVEL_PARTICLES + | cb::EXPLODE + | cb::MOVE_ENTITY_POS + | cb::MOVE_ENTITY_POS_ROT + | cb::MOVE_ENTITY_ROT + | cb::ENTITY_POSITION_SYNC + | cb::ANIMATE + // Its tag displays are 26.2's bare tag ids. + | cb::RECIPE_BOOK_ADD => continue, + _ => {} + } + } match C::decode(id, &payload).map_err(|e| format!("packet {id}: {e}"))? { C::Login(_) => login = true, C::PlayerPosition(p) => bot.send( @@ -1988,7 +2022,6 @@ fn a_26_2_client_joins_only_a_server_that_accepts_older_clients() { }) .encode(), )?, - C::LevelChunkWithLight(_) => chunks += 1, C::ChunkBatchFinished(_) => { bot.send( S::ChunkBatchReceived(play::ChunkBatchReceived { @@ -2037,3 +2070,140 @@ fn a_26_2_client_joins_only_a_server_that_accepts_older_clients() { assert_eq!(placed, Some(([9, MIN_Y + 5, 9], STONE as i32))); assert_eq!(play.placed.len(), 1); } + +/// The serverbound packets whose layout 26.3 changed reach the game from a 26.2 client as 26.3's: its +/// teleport confirmations (the id alone) are taken, so its walk counts; its digging actions past 0 are one +/// up (start 0 stays 0, stop 2 is 3); its main-hand swing is a punch and its off-hand swing nothing. +#[test] +fn a_26_2_clients_walk_dig_swing_and_teleport_confirmations_read_as_26_3s() { + let Some(resources) = resources() else { + return; + }; + // 26.2's serverbound ids (play) of the packets whose layout changed. + const ACCEPT_TELEPORTATION_26_2: i32 = 0; + const PLAYER_ACTION_26_2: i32 = 41; + const SWING_26_2: i32 = 63; + let net = NetConfig { + accept_older_clients: true, + older_clients_play: true, + ..NetConfig::loopback(0, "Test - World".into(), 8) + }; + let mut s = server_with(&resources, net); + let port = s.hub.lan.port; + let below = [9, MIN_Y + 4, 9]; + let walked = [9.5, f64::from(MIN_Y + 5), 9.5]; + let (_bot, confirmed) = run(&mut s, move || { + let (mut bot, _) = + join_as(ServerVersion::V26_2, port, "Old")?.map_err(|r| format!("refused: {r}"))?; + let (mut confirmed, mut chunks, mut sent) = (0, 0, false); + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline { + let (id, payload) = bot.recv()?; + // The command tree has argument nodes the client side does not decode. + if id == horizon_protocol::ids::play::clientbound::COMMANDS { + continue; + } + // The packets written in 26.2's layout, which the pinned readers don't read. + { + use horizon_protocol::ids::play::clientbound as cb; + if id == cb::LEVEL_CHUNK_WITH_LIGHT { + chunks += 1; + } + if matches!( + id, + cb::LEVEL_CHUNK_WITH_LIGHT + | cb::LIGHT_UPDATE + | cb::LEVEL_PARTICLES + | cb::EXPLODE + | cb::MOVE_ENTITY_POS + | cb::MOVE_ENTITY_POS_ROT + | cb::MOVE_ENTITY_ROT + | cb::ENTITY_POSITION_SYNC + | cb::ANIMATE + | cb::RECIPE_BOOK_ADD + ) { + continue; + } + } + match C::decode(id, &payload).map_err(|e| format!("packet {id}: {e}"))? { + C::PlayerPosition(p) => { + let mut w = horizon_protocol::buf::Writer::new(); + w.varint(p.teleport); + bot.send_raw(ACCEPT_TELEPORTATION_26_2, &w.data)?; + confirmed += 1; + } + C::ChunkBatchFinished(_) => { + bot.send( + S::ChunkBatchReceived(play::ChunkBatchReceived { + chunks_per_tick: 20.0, + }) + .encode(), + )?; + } + C::BlockChangedAck(a) if a.sequence == 77 => break, + _ => {} + } + if chunks >= 9 && confirmed > 0 && !sent { + sent = true; + bot.send(S::PlayerLoaded(Empty).encode())?; + // A walk from the spawn (8.5, 8.5), on the stone: one move, as vanilla kicks a second in a tick. + bot.send( + S::MovePlayerPos(play::MovePlayerPos { + x: walked[0], + y: walked[1], + z: walked[2], + flags: 1, + }) + .encode(), + )?; + // Start and stop destroying the block below, as 26.2 numbers the actions. + for (action, sequence) in [(0, 5), (2, 6)] { + let mut w = horizon_protocol::buf::Writer::new(); + w.varint(action); + w.i64(horizon_protocol::buf::pack_block_pos(below)); + w.u8(1); + w.varint(sequence); + bot.send_raw(PLAYER_ACTION_26_2, &w.data)?; + } + // A main-hand swing, then an off-hand one. + bot.send_raw(SWING_26_2, &[0])?; + bot.send_raw(SWING_26_2, &[1])?; + // Its acknowledgement says the hub has handled everything before it. + bot.send( + S::UseItemOn(play::UseItemOn { + hand: 0, + pos: [12, MIN_Y + 4, 12], + face: 1, + cursor: [0.5, 1.0, 0.5], + inside: false, + world_border: false, + sequence: 77, + }) + .encode(), + )?; + } + } + assert!(sent, "the client never got its chunks"); + // The bot is kept, so its guest stays to be looked at. + Ok((bot, confirmed)) + }); + assert!(confirmed > 0, "no teleport to confirm"); + let g = &s.hub.guests[0]; + assert!(g.left_reason.is_empty(), "{}", g.left_reason); + assert_eq!(g.position, walked, "the walk was not taken"); + let actions: Vec = s + .packets + .iter() + .filter_map(|p| match p { + S::PlayerAction(a) if a.pos == below => Some(a.action), + _ => None, + }) + .collect(); + assert_eq!(actions, [0, 3]); + let punches = s + .packets + .iter() + .filter(|p| matches!(p, S::Punch(_))) + .count(); + assert_eq!(punches, 1); +} diff --git a/crates/horizon-protocol/src/adapter.rs b/crates/horizon-protocol/src/adapter.rs index 751cfb0813..eb85f5da3d 100644 --- a/crates/horizon-protocol/src/adapter.rs +++ b/crates/horizon-protocol/src/adapter.rs @@ -8,6 +8,7 @@ //! ([`ClientSession`], roadmap `older-clients`), when its owner lets such clients join. pub mod client_session; pub mod ids; +pub mod layout_26_2; pub mod session; mod translate; pub mod v1_21_10; diff --git a/crates/horizon-protocol/src/adapter/client_session.rs b/crates/horizon-protocol/src/adapter/client_session.rs index 9425114ed4..49399e2cac 100644 --- a/crates/horizon-protocol/src/adapter/client_session.rs +++ b/crates/horizon-protocol/src/adapter/client_session.rs @@ -3,18 +3,29 @@ //! reads it; what the server sends is renumbered to the client's, registry ids included, with a stand-in //! ([`ids::Substitutes`]) for a block, item, entity, particle or sound the client's version lacks. //! -//! This layer maps ids only: a packet whose layout changed between the client's release and the pinned one -//! passes in the pinned layout (the mirror of `translate`'s readers comes next). +//! This layer maps ids; the world packets whose layout changed between 26.2 and the pinned release are +//! written in 26.2's layout by [`world`]. Serverbound, 26.2's `accept_teleportation`, `player_action` and +//! `swing` are read as 26.3's (the mirror of `translate`'s writers); other packets whose layout changed +//! still pass in the pinned layout. //! //! The states change where vanilla's server-side `Connection` switches its codecs, after the handshake the //! server read itself: `login_acknowledged` and `login_finished` move each direction to configuration, //! `finish_configuration` to play, and `configuration_acknowledged` / `start_configuration` back. -use std::sync::atomic::{AtomicU8, Ordering}; +use std::{ + collections::VecDeque, + sync::{ + Mutex, + atomic::{AtomicU8, Ordering}, + }, +}; + +mod world; use super::{ ServerVersion, ids::{self, Ids, Way, down_or_substitute}, - translate::packet_ids_entity::FALLING_BLOCK, + layout_26_2, + translate::{packet_ids_entity::FALLING_BLOCK, swing_id}, }; use crate::{ Result, State, @@ -50,6 +61,58 @@ pub struct ClientSession { reading: AtomicU8, /// What the server sends. writing: AtomicU8, + /// Where the client last said or was told it is, for the place 26.3's `accept_teleportation` states and + /// 26.2's leaves out. + place: Mutex, + /// Teleports (`player_position`) sent and not yet accepted, oldest first. + teleports: Mutex>, +} + +/// A position, yaw and pitch. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Place { + position: [f64; 3], + yaw: f32, + pitch: f32, +} + +/// A `player_position` the server sent. +#[derive(Clone, Copy, Debug)] +struct Teleport { + id: i32, + to: Place, + relatives: i32, +} + +impl Teleport { + /// Where a client at `from` is once it took this teleport, as `LocalPlayer` applies + /// `ClientboundPlayerPositionPacket`'s relative flags. + fn applied(&self, from: Place) -> Place { + use play::relative as rel; + let pick = |bit: i32, base: f64, v: f64| { + if self.relatives & bit != 0 { + base + v + } else { + v + } + }; + let angle = |bit: i32, base: f32, v: f32| { + if self.relatives & bit != 0 { + base + v + } else { + v + } + }; + Place { + position: [ + pick(rel::X, from.position[0], self.to.position[0]), + pick(rel::Y, from.position[1], self.to.position[1]), + pick(rel::Z, from.position[2], self.to.position[2]), + ], + yaw: angle(rel::Y_ROT, from.yaw, self.to.yaw), + pitch: angle(rel::X_ROT, from.pitch, self.to.pitch).clamp(-90.0, 90.0), + } + } } impl ClientSession { @@ -59,6 +122,8 @@ impl ClientSession { version, reading: AtomicU8::new(to_u8(State::Login)), writing: AtomicU8::new(to_u8(State::Login)), + place: Mutex::new(Place::default()), + teleports: Mutex::new(VecDeque::new()), } } pub fn reading(&self) -> State { @@ -69,9 +134,14 @@ impl ClientSession { } /// A packet the client sent, with the id the pinned version knows it by. `None` drops it: the pinned - /// version has no such packet (26.2's `swing`). + /// version has no such packet. pub fn serverbound(&self, id: i32, payload: Vec) -> Result)>> { let state = self.reading(); + // 26.3's punch took over the older swing of the main hand; an off-hand swing has no packet there. + if state == State::Play && id == swing_id(self.version) { + let hand = Reader::new(&payload).varint()?; + return Ok((hand == 0).then_some((packet_ids::play::serverbound::PUNCH, Vec::new()))); + } let Some(pinned) = self.version.serverbound_id_up(state, id) else { return Ok(None); }; @@ -85,10 +155,27 @@ impl ClientSession { _ => state, }; self.reading.store(to_u8(next), Ordering::Release); + use packet_ids::play::serverbound as sb; + let payload = match (state, pinned) { + (State::Play, sb::ACCEPT_TELEPORTATION) => self.accept_teleport(&payload)?, + // 26.3 inserted CHANGE_DESTROY_DIRECTION at 1: 26.2's actions past 0 are one up. + (State::Play, sb::PLAYER_ACTION) => { + let mut r = Reader::new(&payload); + let action = r.varint()?; + let mut w = Writer::new(); + w.varint(if action == 0 { 0 } else { action + 1 }); + w.bytes(r.rest()); + w.data + } + (State::Play, sb::MOVE_PLAYER_POS | sb::MOVE_PLAYER_POS_ROT | sb::MOVE_PLAYER_ROT) => { + self.moved(pinned, &payload)?; + payload + } + _ => payload, + }; let Some(ids) = self.version.ids() else { return Ok(Some((pinned, payload))); }; - use packet_ids::play::serverbound as sb; let payload = match (state, pinned) { // The stacks a client names carry its item and component ids. (State::Play, sb::SET_CREATIVE_MODE_SLOT | sb::CONTAINER_CLICK) => { @@ -120,11 +207,32 @@ impl ClientSession { _ => state, }; self.writing.store(to_u8(next), Ordering::Release); + // 26.2 has no swing_animation: its hands swing through animate (world.rs). + if state == State::Play + && id == packet_ids::play::clientbound::SWING_ANIMATION + && self.version.ids().is_some() + { + let animate = packet_ids::play::clientbound::ANIMATE; + let Some(mapped) = self.version.clientbound_id_down(state, animate) else { + return Ok(None); + }; + let mut w = Writer::new(); + w.varint(mapped); + w.bytes(&world::swing(payload)?); + return Ok(Some(w.data)); + } + if (state, id) == (State::Play, packet_ids::play::clientbound::PLAYER_POSITION) { + self.teleporting(payload)?; + } let Some(mapped) = self.version.clientbound_id_down(state, id) else { return Ok(None); }; // A packet the renumbering can't read goes as it is, rather than the connection dropping here. - let rewritten = match self.version.ids().map(|ids| down(ids, state, id, payload)) { + let rewritten = match self + .version + .ids() + .map(|ids| layout_then_ids(self.version, ids, state, id, payload)) + { None | Some(Ok(Sent::AsIs)) => None, Some(Ok(Sent::Rewritten(payload))) => Some(payload), Some(Ok(Sent::Dropped)) => return Ok(None), @@ -143,6 +251,82 @@ impl ClientSession { } } +/// A 26.2 client's packets whose layout changed are written in its layout ([`layout_26_2`], when enabled), +/// the others renumbered by [`down`]. +fn layout_then_ids( + version: ServerVersion, + ids: &'static Ids, + state: State, + id: i32, + payload: &[u8], +) -> Result { + if version == ServerVersion::V26_2 && layout_26_2::enabled() { + match layout_26_2::clientbound(ids, state, id, payload)? { + layout_26_2::Out::Untouched => {} + layout_26_2::Out::Rewritten(p) => return Ok(Sent::Rewritten(p)), + layout_26_2::Out::Dropped => return Ok(Sent::Dropped), + } + } + down(ids, state, id, payload) +} + +impl ClientSession { + /// A `player_position` on its way: the client accepts it later by its id alone. + fn teleporting(&self, payload: &[u8]) -> Result<()> { + let mut r = Reader::new(payload); + let id = r.varint()?; + let position = [r.f64()?, r.f64()?, r.f64()?]; + let _delta = [r.f64()?, r.f64()?, r.f64()?]; + let to = Place { + position, + yaw: r.f32()?, + pitch: r.f32()?, + }; + let relatives = r.i32()?; + self.teleports + .lock() + .unwrap() + .push_back(Teleport { id, to, relatives }); + Ok(()) + } + + /// A move the client reports (`move_player_pos`, `_pos_rot`, `_rot`): where it now is. + fn moved(&self, pinned: i32, payload: &[u8]) -> Result<()> { + use packet_ids::play::serverbound as sb; + let mut r = Reader::new(payload); + let mut place = self.place.lock().unwrap(); + if pinned != sb::MOVE_PLAYER_ROT { + place.position = [r.f64()?, r.f64()?, r.f64()?]; + } + if pinned != sb::MOVE_PLAYER_POS { + place.yaw = r.f32()?; + place.pitch = r.f32()?; + } + Ok(()) + } + + /// 26.2's `accept_teleportation` (the teleport's id) as 26.3's, which also states where the client + /// now is: the teleport it accepts, and any sent before it, applied in order to where it was. + fn accept_teleport(&self, payload: &[u8]) -> Result> { + let id = Reader::new(payload).varint()?; + let mut place = self.place.lock().unwrap(); + let mut teleports = self.teleports.lock().unwrap(); + if let Some(i) = teleports.iter().position(|t| t.id == id) { + for t in teleports.drain(..=i) { + *place = t.applied(*place); + } + } + let mut w = Writer::new(); + w.varint(id); + for v in place.position { + w.f64(v); + } + w.f32(place.yaw); + w.f32(place.pitch); + Ok(w.data) + } +} + /// What becomes of a clientbound payload on its way to an older client. enum Sent { AsIs, @@ -196,6 +380,10 @@ fn down(ids: &'static Ids, state: State, id: i32, payload: &[u8]) -> Result { + assert_eq!((p.pos, p.direction, p.sequence), ([1, 2, 3], 1, 9)); + p.action + } + other => panic!("{other:?}"), + } + }; + assert_eq!(action(0), 0); // start destroying + assert_eq!(action(1), 2); // abort: 1 in 26.2, 2 in 26.3 + assert_eq!(action(2), 3); // stop destroying + assert_eq!(action(6), 7); // swap held items + } + + #[test] + fn a_26_2_teleport_confirmation_states_where_the_teleport_put_the_client() { + use play::relative as rel; + let s = in_play(); + let teleport = |id: i32, position: [f64; 3], yaw: f32, relatives: i32| { + let p = play::Clientbound::PlayerPosition(play::PlayerPosition { + teleport: id, + position, + delta: [0.0; 3], + yaw, + pitch: 30.0, + relatives, + }); + s.clientbound(&p.encode()).unwrap().unwrap(); + }; + let accept = |id: i32| { + let mut w = Writer::new(); + w.varint(id); + // accept_teleportation: 0 in both. + let (pinned, payload) = s.serverbound(0, w.data).unwrap().unwrap(); + assert_eq!(pinned, packet_ids::play::serverbound::ACCEPT_TELEPORTATION); + match play::Serverbound::decode(pinned, &payload).unwrap() { + play::Serverbound::AcceptTeleportation(p) => { + assert_eq!(p.id, id); + ([p.x, p.y, p.z], p.yaw, p.pitch) + } + other => panic!("{other:?}"), + } + }; + teleport(1, [8.5, -60.0, 8.5], 90.0, 0); + assert_eq!(accept(1), ([8.5, -60.0, 8.5], 90.0, 30.0)); + // The client walks (move_player_pos: 30 in both), then is moved by (1, 2, 3) and turned by 10. + let mut w = Writer::new(); + for v in [10.0, -60.0, 10.0] { + w.f64(v); + } + w.u8(1); + s.serverbound(30, w.data).unwrap().unwrap(); + teleport( + 2, + [1.0, 2.0, 3.0], + 10.0, + rel::X | rel::Y | rel::Z | rel::Y_ROT, + ); + assert_eq!(accept(2), ([11.0, -58.0, 13.0], 100.0, 30.0)); + // An unknown id: where the client last was. + assert_eq!(accept(7), ([11.0, -58.0, 13.0], 100.0, 30.0)); } #[test] diff --git a/crates/horizon-protocol/src/adapter/client_session/world.rs b/crates/horizon-protocol/src/adapter/client_session/world.rs new file mode 100644 index 0000000000..2844c16cbf --- /dev/null +++ b/crates/horizon-protocol/src/adapter/client_session/world.rs @@ -0,0 +1,757 @@ +//! Clientbound world packets in 26.2's layout for a 26.2 client (#10404, the mirror of `translate`'s +//! hand-written 26.2 readers): chunk columns and light, particles, explosions, entity moves and hand +//! swings. Everything else in a packet keeps its pinned layout; ids go down as the rest of +//! [`super`] renumbers them, with stand-ins where 26.2 lacks an entry. +use super::{Sent, state_down}; +use crate::{ + Result, + adapter::ids::{self, Ids, Way, down_or_substitute}, + buf::{Codec, Reader, Writer}, + chunk::{self, Paletted, Section, Strategy}, + entity_data::{Particle, ParticleData}, + ids as packet_ids, + packets::play, +}; + +/// `SoundEvents.EMPTY`: what a 26.2 client is told to play for a blast 26.3 keeps silent. +const SILENT: &str = "minecraft:intentionally_empty"; + +/// The pinned play packet `id` in 26.2's layout; `None` when 26.2 lays it out as the pinned version does. +pub(super) fn down(ids: &'static Ids, id: i32, payload: &[u8]) -> Result> { + use packet_ids::play::clientbound as cb; + Ok(Some(match id { + cb::LEVEL_CHUNK_WITH_LIGHT => Sent::Rewritten(level_chunk(ids, payload)?), + cb::LIGHT_UPDATE => Sent::Rewritten(light_update(payload)?), + cb::LEVEL_PARTICLES => particles(ids, payload)?, + cb::EXPLODE => explode(ids, payload)?, + cb::MOVE_ENTITY_POS | cb::MOVE_ENTITY_POS_ROT | cb::MOVE_ENTITY_ROT => { + Sent::Rewritten(move_entity(id, payload)?) + } + cb::ENTITY_POSITION_SYNC => Sent::Rewritten(position_sync(payload)?), + cb::ANIMATE => animate(payload)?, + _ => return Ok(None), + })) +} + +/// The column with its block states renumbered (stand-ins for states 26.2 lacks) and packed for 26.2's +/// narrower global palette, the block entities 26.2 has, and the light masks as 26.2 sends them. Biomes are +/// the server's own registry and stay as they are. +fn level_chunk(ids: &'static Ids, payload: &[u8]) -> Result> { + let mut p = play::LevelChunkWithLight::read(&mut Reader::new(payload))?; + let (old_bits, new_bits) = ids.state_bits(); + let mut r = Reader::new(&p.chunk.buffer); + let mut sections = Vec::new(); + while r.remaining() > 0 { + let mut s = Section::read(&mut r, new_bits, 0)?; + s.states = states_down(ids, &s.states, old_bits); + sections.push(s); + } + p.chunk.buffer = chunk::ChunkData::from_sections(§ions); + // A block entity type 26.2 lacks goes; its block still draws. + p.chunk.block_entities.retain_mut(|b| { + ids.block_entity_type + .down(b.kind) + .map(|kind| b.kind = kind) + .is_some() + }); + let mut w = Writer::new(); + w.i32(p.x); + w.i32(p.z); + chunk::ChunkData::write(&p.chunk, &mut w); + light_down(&p.light, &mut w); + Ok(w.data) +} + +/// A section's states in 26.2's numbering: a palette's entries renumbered, or the global palette repacked +/// at 26.2's width (`old_bits`). +fn states_down(ids: &Ids, states: &Paletted, old_bits: u8) -> Paletted { + if !states.palette.is_empty() { + let mut out = states.clone(); + out.palette + .iter_mut() + .for_each(|p| *p = state_down(ids, *p)); + return out; + } + let values: Vec = (0..chunk::BLOCKS) + .map(|i| state_down(ids, states.get(i))) + .collect(); + Paletted::from_values(&values, Strategy::Blocks, old_bits) +} + +/// 26.2's ClientboundLightUpdatePacketData: each mask as `BitSet.toLongArray` (a VarInt count of longs), +/// where 26.3 sends `toByteArray` (the same bits as little-endian bytes); the layers as they are. +fn light_down(light: &chunk::LightData, w: &mut Writer) { + for mask in [ + &light.sky_mask, + &light.block_mask, + &light.empty_sky_mask, + &light.empty_block_mask, + ] { + w.long_array(&mask_longs(mask)); + } + chunk::Layers::write(&light.sky, w); + chunk::Layers::write(&light.block, w); +} + +/// A mask's bytes as the longs `BitSet.toLongArray` gives: none past the last set bit. +fn mask_longs(bytes: &[u8]) -> Vec { + let mut longs: Vec = bytes + .chunks(8) + .map(|c| { + let mut b = [0u8; 8]; + b[..c.len()].copy_from_slice(c); + i64::from_le_bytes(b) + }) + .collect(); + while longs.last() == Some(&0) { + longs.pop(); + } + longs +} + +fn light_update(payload: &[u8]) -> Result> { + let p = play::LightUpdate::read(&mut Reader::new(payload))?; + let mut w = Writer::new(); + w.varint(p.x); + w.varint(p.z); + light_down(&p.light, &mut w); + Ok(w.data) +} + +/// A particle in 26.2's numbering, its block state too, or its stand-in; `None` when 26.2 has neither (a +/// native mod's particle). The options stay: the stand-ins in the 26.2 tables (pinned 43..=45 as 41) +/// carry none, as the particles they stand for; a stand-in with other options would need them rebuilt. +fn particle_down(ids: &Ids, p: &Particle) -> Option { + let mut p = p.clone(); + p.id = down_or_substitute(ids.particle_type, ids.substitutes.particle_type, p.id)?; + if let Some(ParticleData::State(s)) = &mut p.data { + *s = state_down(ids, *s); + } + Some(p) +} + +/// A particle written for 26.2: an item in its options takes 26.2's item and component ids. +fn write_particle(ids: &'static Ids, p: &Particle, w: &mut Writer) { + let _down = ids::renumbering(ids, Way::Down); + p.write(w); +} + +/// 26.2: flags, position, spread, one max speed, an Int count, then the particle. 26.3 sends the particle +/// first, a speed per axis, a VarInt count and how the spread is drawn, which 26.2 can't be told (it always +/// draws a gaussian spread). Of the three speeds the largest goes. +fn particles(ids: &'static Ids, payload: &[u8]) -> Result { + let p = play::LevelParticles::read(&mut Reader::new(payload))?; + let Some(particle) = particle_down(ids, &p.particle) else { + return Ok(Sent::Dropped); + }; + let speed = p + .max_speed + .into_iter() + .fold(0.0f32, |a, v| if v.abs() > a.abs() { v } else { a }); + let mut w = Writer::new(); + w.bool(p.override_limiter); + w.bool(p.always_show); + p.pos.iter().for_each(|v| w.f64(*v)); + p.dist.iter().for_each(|v| w.f32(*v)); + w.f32(speed); + w.i32(p.count); + write_particle(ids, &particle, &mut w); + Ok(Sent::Rewritten(w.data)) +} + +/// 26.2's explode is 26.3's without the trailing "sound plays" flag: 26.2 always plays the sound, so a +/// silent blast names `SoundEvents.EMPTY`. Its particles and sound take 26.2's ids; a scattered particle +/// 26.2 has nothing for is left out. +fn explode(ids: &'static Ids, payload: &[u8]) -> Result { + let mut e = play::Explode::read(&mut Reader::new(payload))?; + let Some(particle) = particle_down(ids, &e.particle) else { + return Ok(Sent::Dropped); + }; + e.particle = particle; + e.block_particles = std::mem::take(&mut e.block_particles) + .into_iter() + .filter_map(|mut b| { + b.particle = particle_down(ids, &b.particle)?; + Some(b) + }) + .collect(); + let silent = || (-1, Some((SILENT.to_string(), None))); + if !e.play_sound { + e.sound = silent(); + } else if e.sound.1.is_none() { + // An inline sound is named, not numbered. + match down_or_substitute(ids.sound_event, ids.substitutes.sound_event, e.sound.0) { + Some(id) => e.sound.0 = id, + None => e.sound = silent(), + } + } + let mut w = Writer::new(); + { + let _down = ids::renumbering(ids, Way::Down); + play::Explode::write(&e, &mut w); + } + w.data.pop(); + Ok(Sent::Rewritten(w.data)) +} + +/// 26.2 sends a move as three Short deltas (1/4096 of a block) then on-ground, and a turn as the two angles +/// then on-ground; 26.3 packs on-ground with the delta's step count and sends it before the angles. A +/// stepped delta goes as its sum (the next position sync corrects a sum past a Short). +fn move_entity(id: i32, payload: &[u8]) -> Result> { + use packet_ids::play::clientbound as cb; + let mut r = Reader::new(payload); + let m = match id { + cb::MOVE_ENTITY_POS => play::MoveEntityPos::read(&mut r)?.0, + cb::MOVE_ENTITY_POS_ROT => play::MoveEntityPosRot::read(&mut r)?.0, + _ => play::MoveEntityRot::read(&mut r)?.0, + }; + let mut w = Writer::new(); + w.varint(m.id); + if id != cb::MOVE_ENTITY_ROT { + delta_sum(m.delta.as_ref()).iter().for_each(|v| w.i16(*v)); + } + if id != cb::MOVE_ENTITY_POS { + let (yaw, pitch) = m.rotation.unwrap_or((0, 0)); + w.i8(yaw); + w.i8(pitch); + } + w.bool(m.on_ground); + Ok(w.data) +} + +/// A delta as one linear step, clamped to a Short per axis. +fn delta_sum(delta: Option<&play::VecDelta>) -> [i16; 3] { + match delta { + None => [0; 3], + Some(play::VecDelta::Linear(d)) => *d, + Some(play::VecDelta::Stepped(steps)) => { + let mut sum = [0i32; 3]; + for (d, _) in steps { + for (s, v) in sum.iter_mut().zip(d) { + *s += i32::from(*v); + } + } + sum.map(|v| v.clamp(i32::from(i16::MIN), i32::from(i16::MAX)) as i16) + } + } +} + +/// 26.2: the entity, PositionMoveRotation (position, motion, the two angles), on-ground. 26.3 sends where it +/// goes as a path, which 26.2 is told the end of, and no motion (zero). +fn position_sync(payload: &[u8]) -> Result> { + let p = play::EntityPositionSync::read(&mut Reader::new(payload))?; + let mut w = Writer::new(); + w.varint(p.id); + p.path.end().iter().for_each(|v| w.f64(*v)); + (0..3).for_each(|_| w.f64(0.0)); + w.f32(p.yaw); + w.f32(p.pitch); + w.bool(p.on_ground); + Ok(w.data) +} + +/// 26.3's animate actions as 26.2 numbers them (wake up 0 -> 2, critical hits 1, 2 -> 4, 5); 26.2's own 0 +/// and 3 swing hands, which 26.3 sends as `swing_animation` ([`swing`]). +fn animate(payload: &[u8]) -> Result { + let p = play::Animate::read(&mut Reader::new(payload))?; + let action = match p.action { + 0 => 2, + 1 => 4, + 2 => 5, + _ => return Ok(Sent::Dropped), + }; + let mut w = Writer::new(); + w.varint(p.id); + w.u8(action); + Ok(Sent::Rewritten(w.data)) +} + +/// 26.3's `swing_animation` as the payload of 26.2's `animate`: the main hand's swing (0) or the off hand's +/// (3). 26.2 has one kind of swing at its own pace, so the kind and duration go. +pub(super) fn swing(payload: &[u8]) -> Result> { + let s = play::SwingAnimation::read(&mut Reader::new(payload))?; + let mut w = Writer::new(); + w.varint(s.id); + w.u8(if s.hand == 1 { 3 } else { 0 }); + Ok(w.data) +} + +#[cfg(test)] +mod tests { + //! Each packet written in 26.2's layout here is read back with `translate`'s 26.2 reader, which brings + //! it to the pinned layout again. + use super::super::super::{ServerVersion, translate}; + use super::*; + use crate::State; + use horizon_world::nbt::Tag; + + fn ids() -> &'static Ids { + ServerVersion::V26_2.ids().unwrap() + } + + fn in_play() -> super::super::ClientSession { + use std::sync::atomic::Ordering; + let s = super::super::ClientSession::new(ServerVersion::V26_2); + let play = super::super::to_u8(State::Play); + s.reading.store(play, Ordering::Release); + s.writing.store(play, Ordering::Release); + s + } + + fn rewritten(sent: Option) -> Vec { + match sent { + Some(Sent::Rewritten(body)) => body, + Some(Sent::AsIs) => panic!("sent as is"), + Some(Sent::Dropped) => panic!("dropped"), + None => panic!("not a world packet"), + } + } + + fn encode>(p: &T) -> Vec { + let mut w = Writer::new(); + T::write(p, &mut w); + w.data + } + + fn light() -> chunk::LightData { + chunk::LightData { + // Sections 0 and 2; section 9 (past the first byte); section 64 (past the first long). + sky_mask: vec![0b101], + block_mask: vec![0, 0b10], + empty_sky_mask: vec![0, 0, 0, 0, 0, 0, 0, 0, 1], + empty_block_mask: Vec::new(), + sky: vec![vec![0x11; 2048], vec![0xf0; 2048]], + block: vec![vec![0x07; 2048]], + } + } + + #[test] + fn a_mask_becomes_the_longs_of_bitset_to_long_array() { + assert_eq!(mask_longs(&[0b101]), vec![0b101]); + assert_eq!(mask_longs(&[0, 0b10]), vec![1 << 9]); + assert_eq!(mask_longs(&[0, 0, 0, 0, 0, 0, 0, 0, 1]), vec![0, 1]); + assert!(mask_longs(&[]).is_empty()); + } + + #[test] + fn a_chunk_goes_down_and_back_up() { + let ids = ids(); + // A section of one state (chest's first: 5543 pinned, 3987 in 26.2), and one on the global palette: + // 4096 states that 26.2 has too (its 13000.., oak_slab's 13330 among them). + let one = Paletted::from_values(&[5543; chunk::BLOCKS], Strategy::Blocks, 16); + let many: Vec = (0..4096) + .map(|i| ids.block_state.up(13000 + i).unwrap_or(1)) + .collect(); + let global = Paletted::from_values(&many, Strategy::Blocks, 16); + assert_eq!(global.bits, 16); + let plains = Paletted::from_values(&[1; chunk::BIOMES], Strategy::Biomes, 0); + let section = |states: Paletted| Section { + non_empty: 4096, + fluids: 0, + states, + biomes: plains.clone(), + }; + let sections = vec![section(one), section(global)]; + // A block entity of the first type 26.2 has. + let kind = ids.block_entity_type.up(0).unwrap(); + let p = play::LevelChunkWithLight { + x: 3, + z: -7, + chunk: chunk::ChunkData { + heightmaps: vec![(1, vec![0x0101; 37])], + buffer: chunk::ChunkData::from_sections(§ions), + block_entities: vec![chunk::BlockEntityInfo { + packed_xz: 0x12, + y: 64, + kind, + tag: Tag::Compound(Default::default()), + }], + }, + light: light(), + }; + let old = rewritten( + down( + ids, + packet_ids::play::clientbound::LEVEL_CHUNK_WITH_LIGHT, + &encode(&p), + ) + .unwrap(), + ); + // In 26.2's own numbering: the chest is 3987, the global palette 15 bits wide, 26.2's ids. + { + let mut r = Reader::new(&old); + r.i32().unwrap(); + r.i32().unwrap(); + let c = chunk::ChunkData::read(&mut r).unwrap(); + let s = c.sections(2, ids.state_bits().0, 0).unwrap(); + assert_eq!(s[0].states.get(0), 3987); + assert_eq!(s[1].states.bits, 15); + assert_eq!(s[1].states.get(330), 13330); + assert_eq!(c.block_entities[0].kind, 0); + } + let back = translate::chunk(ids, &old).unwrap(); + let q = play::LevelChunkWithLight::read(&mut Reader::new(&back)).unwrap(); + assert_eq!((q.x, q.z), (3, -7)); + assert_eq!(q.chunk.heightmaps, p.chunk.heightmaps); + assert_eq!(q.chunk.block_entities, p.chunk.block_entities); + assert_eq!(q.light, p.light); + let s = q.chunk.sections(2, ids.state_bits().1, 0).unwrap(); + for (a, b) in s.iter().zip(§ions) { + for i in 0..chunk::BLOCKS { + assert_eq!(a.states.get(i), b.states.get(i)); + } + assert_eq!(a.biomes, b.biomes); + } + } + + #[test] + fn a_state_26_2_lacks_goes_down_as_its_stand_in() { + let ids = ids(); + // poplar_log axis=y (167, new in 26.3) shows as birch_log axis=y (143 in 26.2). + let p = Paletted::from_values(&[167; chunk::BLOCKS], Strategy::Blocks, 16); + assert_eq!(states_down(ids, &p, 15).get(0), 143); + } + + #[test] + fn a_light_update_goes_down_and_back_up() { + let ids = ids(); + let p = play::LightUpdate { + x: -1, + z: 9, + light: light(), + }; + let old = rewritten( + down( + ids, + packet_ids::play::clientbound::LIGHT_UPDATE, + &encode(&p), + ) + .unwrap(), + ); + let back = translate::light_update(&old).unwrap(); + assert_eq!(play::LightUpdate::read(&mut Reader::new(&back)).unwrap(), p); + } + + #[test] + fn particles_go_down_and_back_up() { + let ids = ids(); + let send = |particle: Particle| { + let p = play::LevelParticles { + particle, + override_limiter: true, + always_show: false, + pos: [1.5, 64.0, -3.25], + dist: [0.5, 0.25, 0.5], + max_speed: [0.1; 3], + count: 12, + randomization: 0, + }; + let old = rewritten( + down( + ids, + packet_ids::play::clientbound::LEVEL_PARTICLES, + &encode(&p), + ) + .unwrap(), + ); + let back = translate::particles(ids, &old).unwrap(); + let q = play::LevelParticles::read(&mut Reader::new(&back)).unwrap(); + assert_eq!( + ( + q.override_limiter, + q.always_show, + q.pos, + q.dist, + q.max_speed, + q.count + ), + ( + p.override_limiter, + p.always_show, + p.pos, + p.dist, + p.max_speed, + p.count + ) + ); + q.particle + }; + // entity_effect (28) carries a colour, alike in both numberings. + let colour = Particle { + id: 28, + data: Some(ParticleData::Color(0x00ff00ff)), + }; + assert_eq!(send(colour.clone()), colour); + // block (1) carries a state: chest's 5543 goes down to 3987 and comes back. + let block = Particle { + id: 1, + data: Some(ParticleData::State(5543)), + }; + assert_eq!(send(block.clone()), block); + // 44 is new in 26.3: it shows as its stand-in, 41. + assert_eq!( + send(Particle { id: 44, data: None }), + Particle { id: 41, data: None } + ); + } + + #[test] + fn of_three_speeds_the_largest_goes() { + let ids = ids(); + let p = play::LevelParticles { + particle: Particle { + id: 28, + data: Some(ParticleData::Color(0)), + }, + override_limiter: false, + always_show: false, + pos: [0.0; 3], + dist: [0.0; 3], + max_speed: [0.1, -0.4, 0.2], + count: 0, + randomization: 1, + }; + let old = rewritten( + down( + ids, + packet_ids::play::clientbound::LEVEL_PARTICLES, + &encode(&p), + ) + .unwrap(), + ); + // Two flags, three doubles, three floats, then the speed. + let mut r = Reader::new(&old[2 + 24 + 12..]); + assert_eq!(r.f32().unwrap(), -0.4); + assert_eq!(r.i32().unwrap(), 0); + } + + fn blast(play_sound: bool, sound: i32) -> play::Explode { + play::Explode { + center: [10.0, 70.0, -4.0], + radius: 4.0, + block_count: 17, + knockback: Some([0.1, 0.2, 0.3]), + particle: Particle { + id: 28, + data: Some(ParticleData::Color(-1)), + }, + sound: (sound, None), + block_particles: vec![ + play::ExplosionParticle { + particle: Particle { + id: 28, + data: Some(ParticleData::Color(7)), + }, + scaling: 1.0, + speed: 0.5, + weight: 3, + }, + // A native mod's particle: 26.2 has nothing for it. + play::ExplosionParticle { + particle: Particle { + id: crate::entity_data::MOD_PARTICLE_ID, + data: Some(ParticleData::Mod("mymod:spark".into())), + }, + scaling: 1.0, + speed: 0.5, + weight: 1, + }, + ], + play_sound, + } + } + + /// 26.2's explode read back as `translate` reads it: the flag 26.2 lacks appended, ids renumbered up. + fn explode_up(old: &[u8]) -> play::Explode { + let mut body = old.to_vec(); + body.push(1); + let _up = ids::renumbering(ids(), Way::Up); + play::Explode::read(&mut Reader::new(&body)).unwrap() + } + + #[test] + fn an_explosion_goes_down_without_the_sound_flag() { + let ids = ids(); + // A sound event both versions number alike. + let sound = (0..400) + .find(|&s| ids.sound_event.down(s) == Some(s)) + .unwrap(); + let e = blast(true, sound); + let old = + rewritten(down(ids, packet_ids::play::clientbound::EXPLODE, &encode(&e)).unwrap()); + assert_eq!( + old.len() + 1, + encode(&{ + let mut kept = e.clone(); + kept.block_particles.truncate(1); + kept + }) + .len() + ); + let back = explode_up(&old); + let mut expected = e; + expected.block_particles.truncate(1); + assert_eq!(back, expected); + } + + #[test] + fn a_silent_explosion_names_the_empty_sound() { + let ids = ids(); + let old = rewritten( + down( + ids, + packet_ids::play::clientbound::EXPLODE, + &encode(&blast(false, 0)), + ) + .unwrap(), + ); + assert_eq!( + explode_up(&old).sound, + (-1, Some((SILENT.to_string(), None))) + ); + } + + #[test] + fn moves_go_down_and_back_up() { + use packet_ids::play::clientbound as cb; + let ids = ids(); + let m = |delta: Option, rotation: Option<(i8, i8)>| play::MoveEntity { + id: 7, + delta, + rotation, + on_ground: true, + }; + let pos = play::MoveEntityPos(m(Some(play::VecDelta::Linear([4096, 0, -2048])), None)); + let old = rewritten(down(ids, cb::MOVE_ENTITY_POS, &encode(&pos)).unwrap()); + assert_eq!(old.len(), 1 + 6 + 1); + let back = translate::move_entity(&old, true, false).unwrap(); + assert_eq!( + play::MoveEntityPos::read(&mut Reader::new(&back)).unwrap(), + pos + ); + + let pos_rot = + play::MoveEntityPosRot(m(Some(play::VecDelta::Linear([1, 2, 3])), Some((10, -20)))); + let old = rewritten(down(ids, cb::MOVE_ENTITY_POS_ROT, &encode(&pos_rot)).unwrap()); + let back = translate::move_entity(&old, true, true).unwrap(); + assert_eq!( + play::MoveEntityPosRot::read(&mut Reader::new(&back)).unwrap(), + pos_rot + ); + + let rot = play::MoveEntityRot(m(None, Some((-5, 90)))); + let old = rewritten(down(ids, cb::MOVE_ENTITY_ROT, &encode(&rot)).unwrap()); + assert_eq!(old, vec![7, -5i8 as u8, 90, 1]); + let back = translate::move_entity(&old, false, true).unwrap(); + assert_eq!( + play::MoveEntityRot::read(&mut Reader::new(&back)).unwrap(), + rot + ); + } + + #[test] + fn a_stepped_move_goes_down_as_its_sum() { + let stepped = play::VecDelta::Stepped(vec![ + ([100, 0, -5], 1), + ([200, 30000, -5], 2), + ([0, 30000, 0], 3), + ]); + assert_eq!(delta_sum(Some(&stepped)), [300, i16::MAX, -10]); + } + + #[test] + fn a_position_sync_goes_down_and_back_up() { + let ids = ids(); + let p = play::EntityPositionSync { + id: 42, + path: play::PositionPath::Linear([100.5, 64.0, -20.25]), + yaw: 90.0, + pitch: -10.0, + on_ground: false, + }; + let old = rewritten( + down( + ids, + packet_ids::play::clientbound::ENTITY_POSITION_SYNC, + &encode(&p), + ) + .unwrap(), + ); + assert_eq!(old.len(), 1 + 48 + 8 + 1); + let back = translate::position_sync(&old).unwrap(); + assert_eq!( + play::EntityPositionSync::read(&mut Reader::new(&back)).unwrap(), + p + ); + // A stepped path goes as where it ends. + let stepped = play::EntityPositionSync { + path: play::PositionPath::Stepped(vec![([1.0, 2.0, 3.0], 1), ([4.0, 5.0, 6.0], 2)]), + ..p + }; + let old = rewritten( + down( + ids, + packet_ids::play::clientbound::ENTITY_POSITION_SYNC, + &encode(&stepped), + ) + .unwrap(), + ); + let back = translate::position_sync(&old).unwrap(); + let q = play::EntityPositionSync::read(&mut Reader::new(&back)).unwrap(); + assert_eq!(q.path, play::PositionPath::Linear([4.0, 5.0, 6.0])); + } + + #[test] + fn animate_actions_take_26_2_s_numbers() { + let ids = ids(); + let send = + |action: u8| down(ids, packet_ids::play::clientbound::ANIMATE, &[5, action]).unwrap(); + assert_eq!(rewritten(send(0)), vec![5, 2]); + assert_eq!(rewritten(send(1)), vec![5, 4]); + assert_eq!(rewritten(send(2)), vec![5, 5]); + // And back as translate reads them. + assert_eq!( + translate::animate(&[5, 4]).unwrap(), + (packet_ids::play::clientbound::ANIMATE, vec![5, 1]) + ); + } + + #[test] + fn a_swing_reaches_a_26_2_client_as_animate() { + let s = in_play(); + let swing = play::Clientbound::SwingAnimation(play::SwingAnimation { + id: 5, + hand: 1, + kind: 1, + duration: 6, + }); + let sent = s.clientbound(&swing.encode()).unwrap().unwrap(); + let mut r = Reader::new(&sent); + let animate = ServerVersion::V26_2 + .clientbound_id_down(State::Play, packet_ids::play::clientbound::ANIMATE) + .unwrap(); + assert_eq!(r.varint().unwrap(), animate); + // translate reads 26.2's off-hand swing as the swing sent. + let (id, body) = translate::animate(r.rest()).unwrap(); + assert_eq!(id, packet_ids::play::clientbound::SWING_ANIMATION); + assert_eq!(play::Clientbound::decode(id, &body).unwrap(), swing); + } + + #[test] + fn a_light_update_through_the_session_is_in_26_2_s_layout() { + let s = in_play(); + let p = play::Clientbound::LightUpdate(play::LightUpdate { + x: 0, + z: 0, + light: light(), + }); + let sent = s.clientbound(&p.encode()).unwrap().unwrap(); + let mut r = Reader::new(&sent); + r.varint().unwrap(); + let back = translate::light_update(r.rest()).unwrap(); + assert_eq!( + play::Clientbound::decode(packet_ids::play::clientbound::LIGHT_UPDATE, &back).unwrap(), + p + ); + } +} diff --git a/crates/horizon-protocol/src/adapter/ids.rs b/crates/horizon-protocol/src/adapter/ids.rs index b6a4e827d6..3d81225613 100644 --- a/crates/horizon-protocol/src/adapter/ids.rs +++ b/crates/horizon-protocol/src/adapter/ids.rs @@ -158,6 +158,15 @@ pub fn quirks() -> Quirks { } } +/// The older version's quirks while packets are encoded for it on this thread (an older client, or what is +/// sent to an older server); none otherwise. The writers then lay those structures out as it reads them. +pub fn quirks_written() -> Quirks { + match ACTIVE.get() { + Some((ids, Way::Down)) => ids.quirks, + _ => Quirks::NONE, + } +} + impl Ids { /// Bits per entry of a chunk section's global block palette: the older version's, then the pinned one's /// (`Mth.ceillog2` of the state count). diff --git a/crates/horizon-protocol/src/adapter/layout_26_2.rs b/crates/horizon-protocol/src/adapter/layout_26_2.rs new file mode 100644 index 0000000000..7843be48c0 --- /dev/null +++ b/crates/horizon-protocol/src/adapter/layout_26_2.rs @@ -0,0 +1,506 @@ +//! The pinned (26.3) clientbound packets a 26.2 client reads in another layout, written in 26.2's: the +//! mirror of `translate`'s 26.2 readers for a [`super::ClientSession`] (roadmap `older-clients`). Registry ids +//! inside these packets are renumbered here too, since they never reach the id layer's generic rewrite. +//! +//! Off by default ([`set_enabled`]): until a client has played through every packet here, the id layer +//! alone keeps the pinned layout. +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::ids::{self, Ids, Way, down_or_substitute}; +use crate::{ + Result, State, + buf::{Codec, Reader, Writer}, + ids as packet_ids, + packets::{configuration, play}, +}; + +static ENABLED: AtomicBool = AtomicBool::new(false); + +/// Whether 26.2 clients get these packets in their own layout. +pub fn set_enabled(on: bool) { + ENABLED.store(on, Ordering::Release); +} +pub fn enabled() -> bool { + ENABLED.load(Ordering::Acquire) +} + +/// What becomes of a pinned clientbound payload rewritten here. +#[derive(Debug, PartialEq)] +pub enum Out { + /// Not one of the packets this layer rewrites. + Untouched, + Rewritten(Vec), + /// The client has nothing to read it as. + Dropped, +} + +/// The pinned `payload` of packet `id` in `state`, as a 26.2 client (`ids`) reads it. +pub fn clientbound(ids: &'static Ids, state: State, id: i32, payload: &[u8]) -> Result { + use packet_ids::play::clientbound as cb; + Ok(match (state, id) { + (State::Configuration, packet_ids::configuration::clientbound::REGISTRY_DATA) => { + registry_data(payload)? + } + (State::Play, cb::LOGIN) => Out::Rewritten(login(payload)?), + (State::Play, cb::RESPAWN) => Out::Rewritten(respawn(payload)?), + (State::Play, cb::UPDATE_ADVANCEMENTS) => Out::Rewritten(advancements(ids, payload)?), + (State::Play, cb::COMMANDS) => Out::Rewritten(commands(ids, payload)?), + (State::Play, cb::MAP_ITEM_DATA) => Out::Rewritten(map_item_data(ids, payload)?), + (State::Play, cb::AWARD_STATS) => Out::Rewritten(award_stats(ids, payload)?), + _ => Out::Untouched, + }) +} + +/// Synchronized registries 26.3 added (RegistryDataLoader.SYNCHRONIZED_REGISTRIES of both jars compared): +/// 26.2 has no codec for their entries. +const NEW_REGISTRIES: [&str; 3] = [ + "minecraft:decorated_pot_pattern", + "minecraft:block_transformer", + "minecraft:worldgen/block_state_provider", +]; + +/// A registry 26.2 doesn't synchronize is not sent; the others pass as they are (unknown fields in an +/// entry's NBT are left to 26.2's record codecs, which read past them). +fn registry_data(payload: &[u8]) -> Result { + let p = configuration::RegistryData::read(&mut Reader::new(payload))?; + Ok(if NEW_REGISTRIES.contains(&p.registry.as_str()) { + Out::Dropped + } else { + Out::Untouched + }) +} + +/// 26.2's CommonPlayerSpawnInfo: the game types as bytes, -1 for no previous one (26.3: VarInts, the +/// optional one as id + 1). +fn spawn_info(s: &play::SpawnInfo, w: &mut Writer) { + w.varint(s.dimension_type); + w.string(&s.dimension); + w.i64(s.seed); + w.i8(s.game_type as i8); + w.i8(s.previous_game_type.map_or(-1, |g| g as i8)); + w.bool(s.debug); + w.bool(s.flat); + >::write(&s.last_death, w); + w.varint(s.portal_cooldown); + w.varint(s.sea_level); +} + +fn login(payload: &[u8]) -> Result> { + let l = play::Login::read(&mut Reader::new(payload))?; + let mut w = Writer::new(); + w.i32(l.player_id); + w.bool(l.hardcore); + w.varint(l.levels.len() as i32); + l.levels.iter().for_each(|s| w.string(s)); + w.varint(l.max_players); + w.varint(l.chunk_radius); + w.varint(l.simulation_distance); + w.bool(l.reduced_debug_info); + w.bool(l.show_death_screen); + w.bool(l.limited_crafting); + spawn_info(&l.spawn, &mut w); + w.bool(l.online_mode); + w.bool(l.enforces_secure_chat); + Ok(w.data) +} + +fn respawn(payload: &[u8]) -> Result> { + let p = play::Respawn::read(&mut Reader::new(payload))?; + let mut w = Writer::new(); + spawn_info(&p.spawn, &mut w); + w.i8(p.keep); + Ok(w.data) +} + +/// 26.3 sends an advancement's tab position after the whole advancement; 26.2 inside its display, after +/// the background (an advancement without a display has none). The icon takes the client's item ids. +fn advancements(ids: &'static Ids, payload: &[u8]) -> Result> { + let mut r = Reader::new(payload); + let mut w = Writer::new(); + w.bool(r.bool()?); + let n = r.count(r.remaining())?; + w.varint(n as i32); + for _ in 0..n { + w.string(&r.string(32767)?); + let parent = r.bool()?; + w.bool(parent); + if parent { + w.string(&r.string(32767)?); + } + // The display waits for the position, which comes last in 26.3. + let mut display = None; + if r.bool()? { + let mut d = Writer::new(); + d.nbt(&r.nbt()?); + d.nbt(&r.nbt()?); + let icon = crate::item::Template::read(&mut r)?; + { + let _down = ids::renumbering(ids, Way::Down); + crate::item::Template::write(&icon, &mut d); + } + d.varint(r.varint()?); + let flags = r.i32()?; + d.i32(flags); + if flags & 1 != 0 { + d.string(&r.string(32767)?); + } + display = Some(d.data); + } + let mut rest = Writer::new(); + let groups = r.count(r.remaining())?; + rest.varint(groups as i32); + for _ in 0..groups { + let k = r.count(r.remaining())?; + rest.varint(k as i32); + for _ in 0..k { + rest.string(&r.string(32767)?); + } + } + rest.bool(r.bool()?); + let (x, y) = (r.f32()?, r.f32()?); + w.bool(display.is_some()); + if let Some(d) = display { + w.bytes(&d); + w.f32(x); + w.f32(y); + } + w.bytes(&rest.data); + } + w.bytes(r.rest()); + Ok(w.data) +} + +/// `brigadier:string`, the same id in 26.2 and 26.3. +const STRING_PARSER: i32 = 5; + +/// Each argument node's parser in 26.2's numbering. A parser 26.2 lacks becomes a string (a greedy one on +/// a node nothing follows, a word otherwise) whose suggestions the client asks the server for, so the +/// command still parses on the client and the server checks the argument. +fn commands(ids: &Ids, payload: &[u8]) -> Result> { + let mut tree = play::Commands::read(&mut Reader::new(payload))?; + for node in &mut tree.nodes { + let Some((parser, _)) = &node.parser else { + continue; + }; + match ids.command_argument_type.down(*parser) { + Some(old) => { + if let Some(p) = &mut node.parser { + p.0 = old; + } + } + None => { + let kind = if node.children.is_empty() { 2 } else { 0 }; + node.parser = Some((STRING_PARSER, vec![kind])); + node.suggestions = Some("minecraft:ask_server".into()); + } + } + } + let mut w = Writer::new(); + play::Commands::write(&tree, &mut w); + Ok(w.data) +} + +/// Map decorations in 26.2's `map_decoration_type` numbering; one it lacks isn't drawn. +fn map_item_data(ids: &Ids, payload: &[u8]) -> Result> { + let mut p = play::MapItemData::read(&mut Reader::new(payload))?; + if let Some(decorations) = &mut p.decorations { + decorations.retain_mut(|d| { + ids.map_decoration_type + .down(d.kind) + .map(|k| d.kind = k) + .is_some() + }); + } + let mut w = Writer::new(); + play::MapItemData::write(&p, &mut w); + Ok(w.data) +} + +/// `minecraft:stat_type` in vanilla's registration order (Stats): what each type's value is an id of. +const STAT_MINED: i32 = 0; +const STAT_KILLED: i32 = 6; +const STAT_KILLED_BY: i32 = 7; +const STAT_CUSTOM: i32 = 8; + +/// Stats with their type and value in 26.2's numbering; one naming what 26.2 lacks isn't sent. +fn award_stats(ids: &Ids, payload: &[u8]) -> Result> { + let mut p = play::AwardStats::read(&mut Reader::new(payload))?; + p.stats.retain_mut(|(kind, value, _)| { + let renumbered = match *kind { + STAT_MINED => ids.block.down(*value), + STAT_KILLED | STAT_KILLED_BY => ids.entity_type.down(*value), + STAT_CUSTOM => ids.custom_stat.down(*value), + // crafted, used, broken, picked_up, dropped + _ => down_or_substitute(ids.item, ids.substitutes.item, *value), + }; + match (ids.stat_type.down(*kind), renumbered) { + (Some(k), Some(v)) => { + (*kind, *value) = (k, v); + true + } + _ => false, + } + }); + let mut w = Writer::new(); + play::AwardStats::write(&p, &mut w); + Ok(w.data) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::ServerVersion; + + fn ids() -> &'static Ids { + ServerVersion::V26_2.ids().unwrap() + } + + fn spawn(previous: Option) -> play::SpawnInfo { + play::SpawnInfo { + dimension_type: 0, + dimension: "minecraft:overworld".into(), + seed: 42, + game_type: 1, + previous_game_type: previous, + debug: false, + flat: true, + last_death: None, + portal_cooldown: 0, + sea_level: 63, + } + } + + #[test] + fn respawn_game_types_are_bytes_again() { + for previous in [None, Some(3)] { + let mut w = Writer::new(); + play::Respawn::write( + &play::Respawn { + spawn: spawn(previous), + keep: 3, + }, + &mut w, + ); + let body = respawn(&w.data).unwrap(); + // 26.2's own reader (translate's spawn_info) brings it back. + let back = super::super::translate::tests_support::respawn_26_2(&body).unwrap(); + let p = play::Respawn::read(&mut Reader::new(&back)).unwrap(); + assert_eq!(p.spawn, spawn(previous)); + assert_eq!(p.keep, 3); + } + } + + #[test] + fn login_reads_back_with_26_2_s_reader() { + let login_packet = play::Login { + player_id: 7, + hardcore: false, + levels: vec!["minecraft:overworld".into()], + max_players: 20, + chunk_radius: 10, + simulation_distance: 8, + reduced_debug_info: false, + show_death_screen: true, + limited_crafting: false, + spawn: spawn(None), + online_mode: true, + enforces_secure_chat: false, + }; + let mut w = Writer::new(); + play::Login::write(&login_packet, &mut w); + let body = login(&w.data).unwrap(); + let back = super::super::translate::tests_support::login_26_2(&body).unwrap(); + assert_eq!( + play::Login::read(&mut Reader::new(&back)).unwrap(), + login_packet + ); + } + + #[test] + fn an_advancement_s_position_goes_back_into_its_display() { + // One advancement with a display (diamond icon, no background) at (2.5, -1), one without at (0, 0). + let mut w = Writer::new(); + w.bool(true); + w.varint(2); + for (id, display) in [("minecraft:a", true), ("minecraft:b", false)] { + w.string(id); + w.bool(false); + w.bool(display); + if display { + w.nbt(&horizon_world::nbt::Tag::String("T".into())); + w.nbt(&horizon_world::nbt::Tag::String("D".into())); + crate::item::Template::write(&crate::item::Stack::new(1012, 1), &mut w); + w.varint(0); + w.i32(2); + } + w.varint(1); + w.varint(1); + w.string("done"); + w.bool(false); + w.f32(if display { 2.5 } else { 0.0 }); + w.f32(if display { -1.0 } else { 0.0 }); + } + w.varint(0); + w.varint(0); + w.bool(true); + let body = advancements(ids(), &w.data).unwrap(); + let back = super::super::translate::tests_support::advancements_26_2(ids(), &body).unwrap(); + // Read back with 26.2's reader the icon is the pinned diamond again (926 in 26.2). + assert_eq!(back, w.data); + } + + #[test] + fn a_26_3_only_parser_becomes_a_string_and_others_shift_down() { + let node = + |kind: u8, children: Vec, parser: Option<(i32, Vec)>| play::CommandNode { + kind, + executable: true, + restricted: false, + children, + redirect: None, + name: if kind == 0 { String::new() } else { "x".into() }, + parser, + suggestions: None, + }; + let uuid = play::ARGUMENT_TYPES + .iter() + .position(|&n| n == "minecraft:uuid") + .unwrap() as i32; + let tree = play::Commands { + nodes: vec![ + node(0, vec![1, 2], None), + node(2, vec![], Some((uuid, vec![]))), + node(2, vec![], Some((3, vec![0]))), // brigadier:integer, no bounds + ], + root: 0, + }; + let mut w = Writer::new(); + play::Commands::write(&tree, &mut w); + let body = commands(ids(), &w.data).unwrap(); + let out = play::Commands::read(&mut Reader::new(&body)).unwrap(); + let uuid_down = ids().command_argument_type.down(uuid); + match uuid_down { + // uuid is new in 26.3: a greedy string asking the server. + None => { + assert_eq!(out.nodes[1].parser, Some((STRING_PARSER, vec![2]))); + assert_eq!( + out.nodes[1].suggestions.as_deref(), + Some("minecraft:ask_server") + ); + } + Some(old) => assert_eq!(out.nodes[1].parser.as_ref().unwrap().0, old), + } + assert_eq!(out.nodes[2].parser, Some((3, vec![0]))); + } + + #[test] + fn registries_26_2_lacks_are_not_sent() { + let data = |registry: &str| { + let mut w = Writer::new(); + configuration::RegistryData::write( + &configuration::RegistryData { + registry: registry.into(), + entries: vec![], + }, + &mut w, + ); + registry_data(&w.data).unwrap() + }; + assert_eq!(data("minecraft:block_transformer"), Out::Dropped); + assert_eq!(data("minecraft:worldgen/biome"), Out::Untouched); + } + + #[test] + fn stats_and_map_decorations_take_26_2_s_ids() { + let ids = ids(); + // The last pinned custom stat and map decoration are the newest; whatever 26.2 lacks goes. + let mut w = Writer::new(); + play::AwardStats::write( + &play::AwardStats { + stats: vec![(STAT_CUSTOM, 0, 5), (STAT_CUSTOM, 77, 1), (1, 1012, 2)], + }, + &mut w, + ); + let out = award_stats(ids, &w.data).unwrap(); + let p = play::AwardStats::read(&mut Reader::new(&out)).unwrap(); + let expected: Vec<_> = [(STAT_CUSTOM, 0, 5), (STAT_CUSTOM, 77, 1), (1, 1012, 2)] + .into_iter() + .filter_map(|(k, v, n)| { + let v = match k { + STAT_CUSTOM => ids.custom_stat.down(v), + _ => ids.item.down(v), + }?; + Some((ids.stat_type.down(k)?, v, n)) + }) + .collect(); + assert_eq!(p.stats, expected); + // diamond: 1012 pinned, 926 in 26.2. + assert!(p.stats.contains(&(1, 926, 2))); + } + + #[test] + fn pot_sides_and_container_slots_take_26_2_s_layout_and_ids() { + use crate::item::{Component, Stack, Template, component}; + let v26_2 = ids(); + // A pot with a diamond (1012 pinned, 926 in 26.2) at the back, the rest undecorated, and a + // container holding three diamonds and an empty slot: their pinned bytes. + let mut pot = Writer::new(); + pot.bool(true); + Template::write(&Stack::new(1012, 1), &mut pot); + (0..3).for_each(|_| pot.bool(false)); + let mut container = Writer::new(); + container.varint(2); + container.bool(true); + Template::write(&Stack::new(1012, 3), &mut container); + container.bool(false); + let mut stack = Stack::new(1012, 1); + for (kind, bytes) in [ + (component::POT_DECORATIONS, &pot.data), + (component::CONTAINER, &container.data), + ] { + let c = Component::read(kind, &mut Reader::new(bytes)).unwrap(); + stack.added.push((kind, c)); + } + let mut w = Writer::new(); + { + let _down = ids::renumbering(v26_2, Way::Down); + Template::write(&stack, &mut w); + } + // Read with 26.2's reader (its quirks, its ids) both come back as they were. + let back = { + let _up = ids::renumbering(v26_2, Way::Up); + Template::read(&mut Reader::new(&w.data)).unwrap() + }; + assert_eq!(back.item, 1012); + for (kind, bytes) in [ + (component::POT_DECORATIONS, &pot.data), + (component::CONTAINER, &container.data), + ] { + let mut b = Writer::new(); + back.component(kind).unwrap().write(kind, &mut b); + assert_eq!(&b.data, bytes, "component {kind}"); + } + } + + #[test] + fn a_tag_slot_display_is_a_bare_tag_id_for_26_2() { + use crate::packets::recipes::{HolderSet, SlotDisplay}; + let round = |d: &SlotDisplay| { + let mut w = Writer::new(); + { + let _down = ids::renumbering(ids(), Way::Down); + d.write(&mut w); + } + let _up = ids::renumbering(ids(), Way::Up); + SlotDisplay::read(&mut Reader::new(&w.data)).unwrap() + }; + let tag = SlotDisplay::Tag(HolderSet::Tag("minecraft:planks".into())); + assert_eq!(round(&tag), tag); + // Items have no TagSlotDisplay form in 26.2: a composite of the items. + let items = SlotDisplay::Tag(HolderSet::Items(vec![1012])); + assert_eq!( + round(&items), + SlotDisplay::Composite(vec![SlotDisplay::Item(1012)]) + ); + } +} diff --git a/crates/horizon-protocol/src/adapter/translate.rs b/crates/horizon-protocol/src/adapter/translate.rs index fc59978d56..5409ca20fd 100644 --- a/crates/horizon-protocol/src/adapter/translate.rs +++ b/crates/horizon-protocol/src/adapter/translate.rs @@ -274,12 +274,7 @@ pub fn serverbound( (State::Play, sb::PUNCH) => { let mut w = Writer::new(); w.varint(0); - let swing = if session.version.at_most(ServerVersion::V1_21_11) { - v1_21_11::SWING - } else { - V26_2_SWING - }; - Ok(Some((swing, w.data))) + Ok(Some((swing_id(session.version), w.data))) } (State::Play, sb::SET_CREATIVE_MODE_SLOT | sb::CONTAINER_CLICK) => { let p = play::Serverbound::decode(id, payload)?; @@ -296,6 +291,15 @@ pub fn serverbound( /// 26.2's serverbound `swing` (play), which 26.3 replaced with `punch`. const V26_2_SWING: i32 = 63; +/// The id an older `version` numbers its serverbound `swing` (play) by. +pub(super) fn swing_id(version: ServerVersion) -> i32 { + if version.at_most(ServerVersion::V1_21_11) { + v1_21_11::SWING + } else { + V26_2_SWING + } +} + /// A play packet decoded with the older version's ids renumbered as they are read. fn decode_play(ids: &'static Ids, id: i32, payload: &[u8]) -> Result { let _up = ids::renumbering(ids, Way::Up); @@ -382,7 +386,7 @@ fn respawn(payload: &[u8]) -> Result> { /// The column's sections with their block states renumbered and packed for the pinned palette width (a /// section on the older global palette uses fewer bits per entry), and its light masks as 26.3 sends them. /// Biomes are the server's own registry and stay as they are. -fn chunk(ids: &'static Ids, payload: &[u8]) -> Result> { +pub(super) fn chunk(ids: &'static Ids, payload: &[u8]) -> Result> { let mut r = Reader::new(payload); let x = r.i32()?; let z = r.i32()?; @@ -447,7 +451,7 @@ fn light(r: &mut Reader) -> Result { }) } -fn light_update(payload: &[u8]) -> Result> { +pub(super) fn light_update(payload: &[u8]) -> Result> { let mut r = Reader::new(payload); let x = r.varint()?; let z = r.varint()?; @@ -524,7 +528,7 @@ fn advancements(ids: &'static Ids, payload: &[u8]) -> Result> { /// 26.2: flags, position, spread, one max speed, an Int count, then the particle. 26.3 sends the particle /// first, a speed per axis, a VarInt count and how the spread is drawn (0, gaussian, as 26.2 did). -fn particles(ids: &'static Ids, payload: &[u8]) -> Result> { +pub(super) fn particles(ids: &'static Ids, payload: &[u8]) -> Result> { let mut r = Reader::new(payload); let override_limiter = r.bool()?; let always_show = r.bool()?; @@ -555,7 +559,7 @@ fn particles(ids: &'static Ids, payload: &[u8]) -> Result> { /// 26.2 sends a move as three Short deltas (1/4096 of a block) then on-ground, and a turn as the two angles /// then on-ground; 26.3 packs on-ground with the delta's step count, and sends it before the angles. -fn move_entity(payload: &[u8], pos: bool, rot: bool) -> Result> { +pub(super) fn move_entity(payload: &[u8], pos: bool, rot: bool) -> Result> { let mut r = Reader::new(payload); let id = r.varint()?; let delta = if pos { @@ -582,7 +586,7 @@ fn move_entity(payload: &[u8], pos: bool, rot: bool) -> Result> { /// 26.2: the entity, PositionMoveRotation (position, motion, the two angles), on-ground. 26.3 sends where it /// goes as a path, then the angles; the motion is no longer sent. -fn position_sync(payload: &[u8]) -> Result> { +pub(super) fn position_sync(payload: &[u8]) -> Result> { let mut r = Reader::new(payload); let id = r.varint()?; let position = [r.f64()?, r.f64()?, r.f64()?]; @@ -606,7 +610,7 @@ fn position_sync(payload: &[u8]) -> Result> { /// 26.2's animate swings hands too (0 main, 3 off), which 26.3 sends as `swing_animation`; the others were /// renumbered (wake up 2 -> 0, critical hits 4, 5 -> 1, 2). -fn animate(payload: &[u8]) -> Result<(i32, Vec)> { +pub(super) fn animate(payload: &[u8]) -> Result<(i32, Vec)> { let mut r = Reader::new(payload); let id = r.varint()?; let action = r.u8()?; @@ -1288,3 +1292,19 @@ mod tests { assert_eq!(action(3), Some(2)); // stop destroying: 3 in 26.3, 2 in 26.2 } } + +/// 26.2's readers, for the tests of the layout written for a 26.2 client (`layout_26_2`). +#[cfg(test)] +pub(super) mod tests_support { + use super::*; + + pub fn login_26_2(payload: &[u8]) -> Result> { + login(payload) + } + pub fn respawn_26_2(payload: &[u8]) -> Result> { + respawn(payload) + } + pub fn advancements_26_2(ids: &'static Ids, payload: &[u8]) -> Result> { + advancements(ids, payload) + } +} diff --git a/crates/horizon-protocol/src/item.rs b/crates/horizon-protocol/src/item.rs index 07c0bc3f60..f694cb8ecd 100644 --- a/crates/horizon-protocol/src/item.rs +++ b/crates/horizon-protocol/src/item.rs @@ -993,6 +993,88 @@ impl Codec> for Slot { } } +/// A component value as the version being written for reads it. Values kept as their pinned bytes that hold +/// items (a pot's sides, a container's slots, a use remainder) are read again and written with that +/// version's item ids, and a pot's sides as 26.2's item ids when it reads them so +/// ([`ids::Quirks::pot_decorations_are_items`]). Written as they are when nothing is renumbered. +fn write_value(kind: i32, c: &Component, w: &mut Writer) { + use component::*; + let quirks = ids::quirks_written(); + let renumbered = matches!(ids::active(), Some((_, ids::Way::Down))); + let held = match kind { + POT_DECORATIONS => true, + // Before 26.1 these hold ItemStacks, written by another codec. + USE_REMAINDER | SULFUR_CUBE_CONTENT | CONTAINER => !quirks.stacks_in_components, + _ => false, + }; + if !renumbered || !held { + return c.write(kind, w); + } + let pinned = { + let _pinned = ids::pinned(); + let mut b = Writer::new(); + c.write(kind, &mut b); + b.data + }; + match value_down(kind, quirks, &pinned) { + Ok(bytes) => w.bytes(&bytes), + Err(_) => w.bytes(&pinned), + } +} + +/// A stack template as the pinned version wrote it. +fn read_pinned(r: &mut Reader) -> Result { + let _pinned = ids::pinned(); + Template::read(r) +} + +/// The pinned bytes of an item-holding component (see [`write_value`]) in the numbering written now. +fn value_down(kind: i32, quirks: ids::Quirks, pinned: &[u8]) -> Result> { + use component::*; + let mut r = Reader::new(pinned); + let mut w = Writer::new(); + match kind { + // Back, left, right, front: each an optional template of one item. + POT_DECORATIONS => { + let mut sides = Vec::with_capacity(4); + for _ in 0..4 { + sides.push(if r.bool()? { + Some(read_pinned(&mut r)?) + } else { + None + }); + } + if quirks.pot_decorations_are_items { + // ByteBufCodecs.registry(ITEM).apply(list(4)): a brick for an undecorated side. + w.varint(4); + for side in &sides { + w.varint(ids::item(side.as_ref().map_or(BRICK, |s| s.item))); + } + } else { + for side in &sides { + w.bool(side.is_some()); + if let Some(s) = side { + Template::write(s, &mut w); + } + } + } + } + CONTAINER => { + let n = r.count(256)?; + w.varint(n as i32); + for _ in 0..n { + let slot = r.bool()?; + w.bool(slot); + if slot { + Template::write(&read_pinned(&mut r)?, &mut w); + } + } + } + _ => Template::write(&read_pinned(&mut r)?, &mut w), + } + Ok(w.data) +} + /// A stack's components, without those an older client being written for lacks. fn write_patch(s: &Stack, w: &mut Writer) { let has = |kind: i32| ids::component(kind) >= 0; @@ -1000,7 +1082,7 @@ fn write_patch(s: &Stack, w: &mut Writer) { w.varint(s.removed.iter().filter(|k| has(**k)).count() as i32); for (kind, c) in s.added.iter().filter(|(k, _)| has(*k)) { w.varint(ids::component(*kind)); - c.write(*kind, w); + write_value(*kind, c, w); } for kind in s.removed.iter().filter(|k| has(**k)) { w.varint(ids::component(*kind)); @@ -1089,7 +1171,7 @@ impl Codec> for DelimitedSlot { for (kind, c) in &s.added { w.varint(ids::component(*kind)); let mut value = Writer::new(); - c.write(*kind, &mut value); + write_value(*kind, c, &mut value); w.byte_array(&value.data); } for kind in &s.removed { diff --git a/crates/horizon-protocol/src/packets/recipes.rs b/crates/horizon-protocol/src/packets/recipes.rs index 54c3a63f61..10fe37771c 100644 --- a/crates/horizon-protocol/src/packets/recipes.rs +++ b/crates/horizon-protocol/src/packets/recipes.rs @@ -166,6 +166,20 @@ impl SlotDisplay { w.varint(5); Template::write(stack, w); } + Self::Tag(set) if crate::adapter::ids::quirks_written().tag_slot_display_is_id => { + match set { + // TagSlotDisplay of 26.2: the tag's bare id. + HolderSet::Tag(name) => { + w.varint(6); + w.string(name); + } + // A list of items has no such form: a composite of item displays shows the same. + HolderSet::Items(items) => { + Self::Composite(items.iter().map(|&item| Self::Item(item)).collect()) + .write(w) + } + } + } Self::Tag(set) => { w.varint(6); set.write(w);