Protocol adapter: 26.2's packet ids against 26.3's (part of #6702)

The first layer of joining older servers: a ServerVersion type and, per
connection state, how a 26.2 server numbers each packet, generated from
both releases' packets reports. Nothing calls it yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
some-du6eandClaude Opus 5.5 committed 2026-11-01 19:53:43 -06:00
1 parent 09a669a6c3
commit 07ad7d2abc
4 files changed
+371

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
//! Speaking to servers of an older release: the first layer of the native protocol adapter (roadmap
//! `older-servers`). Horizon's packets are 26.3's; a server on another protocol numbers them differently.
//! This maps packet ids per connection state, both ways. It does not translate packet contents.
pub mod v26_2;
use crate::{
Direction, Result, State,
buf::{Reader, write_varint},
};
/// The protocol a server speaks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum ServerVersion {
/// The pinned version ([`crate::PROTOCOL_VERSION`]): nothing to translate.
#[default]
Current,
V26_2,
}
impl ServerVersion {
pub const ALL: [Self; 2] = [Self::Current, Self::V26_2];
/// The protocol number its handshake states.
pub fn protocol(self) -> i32 {
match self {
Self::Current => crate::PROTOCOL_VERSION,
Self::V26_2 => v26_2::PROTOCOL,
}
}
/// The release name, as a server list shows it.
pub fn name(self) -> &'static str {
match self {
Self::Current => crate::GAME_VERSION,
Self::V26_2 => v26_2::NAME,
}
}
/// The version a server's status reports by its protocol number.
pub fn from_protocol(protocol: i32) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.protocol() == protocol)
}
/// The pinned version's id for a packet this version's server sent as `id`. `None` when the pinned
/// version has no such packet.
pub fn clientbound_id(self, state: State, id: i32) -> Option<i32> {
self.map(state, Direction::Clientbound, id)
}
/// The id this version's server knows a packet Horizon sends as `id` by. `None` when that server has
/// no such packet, so it must not be sent.
pub fn serverbound_id(self, state: State, id: i32) -> Option<i32> {
self.map(state, Direction::Serverbound, id)
}
fn map(self, state: State, direction: Direction, id: i32) -> Option<i32> {
let table = match (self, direction) {
(Self::Current, _) => return Some(id),
(Self::V26_2, Direction::Clientbound) => v26_2::clientbound(state),
(Self::V26_2, Direction::Serverbound) => v26_2::serverbound(state),
};
let mapped = *table.get(usize::try_from(id).ok()?)?;
mapped.map(i32::from)
}
/// A packet body (VarInt id + payload) Horizon sends, renumbered for this version's server. `None`
/// when that server has no such packet.
pub fn serverbound_body(self, state: State, body: &[u8]) -> Result<Option<Vec<u8>>> {
let mut r = Reader::new(body);
let id = r.varint()?;
let payload = r.rest();
let Some(mapped) = self.serverbound_id(state, id) else {
return Ok(None);
};
if mapped == id {
return Ok(Some(body.to_vec()));
}
let mut out = Vec::with_capacity(body.len() + 1);
write_varint(&mut out, mapped);
out.extend_from_slice(payload);
Ok(Some(out))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids;
// Expected ids below are read off the 26.2 and 26.3 packets reports by hand
// (`version_port.py diff 26.2 26.3`), not from the generated tables.
#[test]
fn a_26_2_server_is_known_by_its_protocol() {
assert_eq!(
ServerVersion::from_protocol(776),
Some(ServerVersion::V26_2)
);
assert_eq!(
ServerVersion::from_protocol(crate::PROTOCOL_VERSION),
Some(ServerVersion::Current)
);
assert_eq!(ServerVersion::from_protocol(775), None);
}
#[test]
fn renumbered_clientbound_play_packets_reach_their_26_3_ids() {
let v = ServerVersion::V26_2;
// keep_alive 44 -> 45, login 49 -> 50, respawn 82 -> 84, clear_dialog 139 -> 142.
assert_eq!(
v.clientbound_id(State::Play, 44),
Some(ids::play::clientbound::KEEP_ALIVE)
);
assert_eq!(
v.clientbound_id(State::Play, 49),
Some(ids::play::clientbound::LOGIN)
);
assert_eq!(
v.clientbound_id(State::Play, 82),
Some(ids::play::clientbound::RESPAWN)
);
assert_eq!(
v.clientbound_id(State::Play, 139),
Some(ids::play::clientbound::CLEAR_DIALOG)
);
// Unchanged before the first insertion (add_transient_block at 37).
assert_eq!(v.clientbound_id(State::Play, 0), Some(0));
// Past 26.2's 141 packets.
assert_eq!(v.clientbound_id(State::Play, 141), None);
// Configuration: store_cookie 10 -> 11 (post_effects took 10).
assert_eq!(
v.clientbound_id(State::Configuration, 10),
Some(ids::configuration::clientbound::STORE_COOKIE)
);
}
#[test]
fn serverbound_packets_26_2_lacks_are_not_sent() {
let v = ServerVersion::V26_2;
// punch is new in 26.3; swing, which 26.2 has at 63, is gone.
assert_eq!(
v.serverbound_id(State::Play, ids::play::serverbound::PUNCH),
None
);
// resource_pack 50 -> 49, spectator_action 63 -> 62, keep_alive unchanged.
assert_eq!(
v.serverbound_id(State::Play, ids::play::serverbound::RESOURCE_PACK),
Some(49)
);
assert_eq!(
v.serverbound_id(State::Play, ids::play::serverbound::SPECTATOR_ACTION),
Some(62)
);
assert_eq!(
v.serverbound_id(State::Play, ids::play::serverbound::KEEP_ALIVE),
Some(ids::play::serverbound::KEEP_ALIVE)
);
assert_eq!(
v.serverbound_id(State::Handshake, ids::handshake::serverbound::INTENTION),
Some(0)
);
}
#[test]
fn serverbound_bodies_keep_their_payload_under_the_new_id() {
let v = ServerVersion::V26_2;
let mut body = Vec::new();
write_varint(&mut body, ids::play::serverbound::RESOURCE_PACK);
body.extend_from_slice(&[1, 2, 3]);
assert_eq!(
v.serverbound_body(State::Play, &body).unwrap(),
Some(vec![49, 1, 2, 3])
);
let mut punch = Vec::new();
write_varint(&mut punch, ids::play::serverbound::PUNCH);
assert_eq!(v.serverbound_body(State::Play, &punch).unwrap(), None);
assert_eq!(
ServerVersion::Current
.serverbound_body(State::Play, &body)
.unwrap(),
Some(body)
);
}
}
@@ -0,0 +1,84 @@
//! Packet ids of protocol 776 (26.2) against the pinned 777 (26.3).
//! Generated by tools/generate_packet_id_adapter.py from both versions' packets reports;
//! do not edit by hand.
use crate::State;
pub const PROTOCOL: i32 = 776;
pub const NAME: &str = "26.2";
#[rustfmt::skip]
const HANDSHAKE_CLIENTBOUND: &[Option<u8>] = &[
];
#[rustfmt::skip]
const HANDSHAKE_SERVERBOUND: &[Option<u8>] = &[
Some(0),
];
#[rustfmt::skip]
const STATUS_CLIENTBOUND: &[Option<u8>] = &[
Some(0), Some(1),
];
#[rustfmt::skip]
const STATUS_SERVERBOUND: &[Option<u8>] = &[
Some(0), Some(1),
];
#[rustfmt::skip]
const LOGIN_CLIENTBOUND: &[Option<u8>] = &[
Some(0), Some(1), Some(2), Some(3), Some(4), Some(5),
];
#[rustfmt::skip]
const LOGIN_SERVERBOUND: &[Option<u8>] = &[
Some(0), Some(1), Some(2), Some(3), Some(4),
];
#[rustfmt::skip]
const CONFIGURATION_CLIENTBOUND: &[Option<u8>] = &[
Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7), Some(8), Some(9), Some(11), Some(12),
Some(13), Some(14), Some(15), Some(16), Some(17), Some(18), Some(19), Some(20),
];
#[rustfmt::skip]
const CONFIGURATION_SERVERBOUND: &[Option<u8>] = &[
Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7), Some(8), Some(9),
];
#[rustfmt::skip]
const PLAY_CLIENTBOUND: &[Option<u8>] = &[
Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7), Some(8), Some(9), Some(10), Some(11),
Some(12), Some(13), Some(14), Some(15), Some(16), Some(17), Some(18), Some(19), Some(20), Some(21), Some(22), Some(23),
Some(24), Some(25), Some(26), Some(27), Some(28), Some(29), Some(30), Some(31), Some(32), Some(33), Some(34), Some(35),
Some(36), Some(38), Some(39), Some(40), Some(41), Some(42), Some(43), Some(44), Some(45), Some(46), Some(47), Some(48),
Some(49), Some(50), Some(51), Some(52), Some(53), Some(54), Some(55), Some(56), Some(57), Some(58), Some(59), Some(60),
Some(61), Some(62), Some(63), Some(64), Some(65), Some(66), Some(67), Some(68), Some(69), Some(70), Some(71), Some(72),
Some(73), Some(74), Some(75), Some(76), Some(77), Some(78), Some(79), Some(80), Some(81), Some(82), Some(84), Some(85),
Some(86), Some(87), Some(88), Some(89), Some(90), Some(91), Some(92), Some(93), Some(94), Some(95), Some(96), Some(97),
Some(98), Some(99), Some(100), Some(101), Some(102), Some(103), Some(104), Some(105), Some(106), Some(107), Some(108), Some(109),
Some(110), Some(111), Some(112), Some(113), Some(114), Some(115), Some(116), Some(117), Some(118), Some(119), Some(120), Some(121),
Some(122), Some(124), Some(125), Some(126), Some(127), Some(128), Some(129), Some(130), Some(131), Some(132), Some(133), Some(134),
Some(135), Some(136), Some(137), Some(138), Some(139), Some(140), Some(141), Some(142), Some(143),
];
#[rustfmt::skip]
const PLAY_SERVERBOUND: &[Option<u8>] = &[
Some(0), Some(1), Some(2), Some(3), Some(4), Some(5), Some(6), Some(7), Some(8), Some(9), Some(10), Some(11),
Some(12), Some(13), Some(14), Some(15), Some(16), Some(17), Some(18), Some(19), Some(20), Some(21), Some(22), Some(23),
Some(24), Some(25), Some(26), Some(27), Some(28), Some(29), Some(30), Some(31), Some(32), Some(33), Some(34), Some(35),
Some(36), Some(37), Some(38), Some(39), Some(40), Some(41), Some(42), Some(43), Some(44), Some(45), None, Some(46),
Some(47), Some(48), Some(49), Some(50), Some(51), Some(52), Some(53), Some(54), Some(55), Some(56), Some(57), Some(58),
Some(59), Some(60), Some(61), Some(62), Some(64), Some(65), Some(66), Some(67), Some(68),
];
/// For each id a 26.2 server sends in `state`, the pinned version's id of that packet.
pub fn clientbound(state: State) -> &'static [Option<u8>] {
match state {
State::Handshake => HANDSHAKE_CLIENTBOUND,
State::Status => STATUS_CLIENTBOUND,
State::Login => LOGIN_CLIENTBOUND,
State::Configuration => CONFIGURATION_CLIENTBOUND,
State::Play => PLAY_CLIENTBOUND,
}
}
/// For each pinned-version id Horizon sends in `state`, the id a 26.2 server knows it by.
pub fn serverbound(state: State) -> &'static [Option<u8>] {
match state {
State::Handshake => HANDSHAKE_SERVERBOUND,
State::Status => STATUS_SERVERBOUND,
State::Login => LOGIN_SERVERBOUND,
State::Configuration => CONFIGURATION_SERVERBOUND,
State::Play => PLAY_SERVERBOUND,
}
}
+1
View File
@@ -3,6 +3,7 @@
//! every connection state, shared by the Horizon client and (later) server.
// `deny`, not `forbid`: crypto/blocks.rs allows the ARMv8 AES intrinsics, nothing else.
#![deny(unsafe_code)]
pub mod adapter;
pub mod buf;
pub mod chunk;
pub mod connection;
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Generate crates/horizon-protocol/src/adapter/v<OLD>.rs: how an older release numbers the packets of each
connection state against the pinned one, from both versions' data generator packets reports.
generate_packet_id_adapter.py --old ~/code/horizon-parity/versions/26.2
`--old` (and `--new`, the pinned version in .local by default) is a directory `tools/version_port.py prepare
VERSION --download` made: `jars/client.jar` and `datagen/reports/packets.json`.
Packets are matched by name, as both reports list them; a packet only one version has maps to `None`.
"""
import argparse
import json
from pathlib import Path
try:
from . import lab
except ImportError:
import lab
ROOT = Path(__file__).resolve().parents[1]
STATES = ["handshake", "status", "login", "configuration", "play"]
PER_LINE = 12
def ids(report, state, direction):
"""{name: id} of one state and direction, checked to number its packets 0..n."""
packets = {name: entry["protocol_id"] for name, entry in report.get(state, {}).get(direction, {}).items()}
assert sorted(packets.values()) == list(range(len(packets))), (state, direction)
return packets
def table(source, target):
"""For each id of `source` (in order), the id `target` gives the same packet, or None."""
by_id = sorted(source.items(), key=lambda kv: kv[1])
return [target.get(name) for name, _ in by_id]
def rust_table(name, values):
cells = ["None" if v is None else f"Some({v})" for v in values]
lines = [f"const {name}: &[Option<u8>] = &["]
for i in range(0, len(cells), PER_LINE):
lines.append(" " + ", ".join(cells[i:i + PER_LINE]) + ",")
lines.append("];")
return lines
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--old", type=Path, required=True, help="the older version's prepared directory")
parser.add_argument("--new", type=Path, help="the pinned version's prepared directory (default: .local)")
parser.add_argument("--out", type=Path)
args = parser.parse_args()
old = json.loads((args.old / "datagen/reports/packets.json").read_text())
old_info = lab.version_info(args.old / "jars/client.jar")
if args.new:
new = json.loads((args.new / "datagen/reports/packets.json").read_text())
info = lab.version_info(args.new / "jars/client.jar")
else:
new = json.loads((ROOT / ".local/datagen/out/reports/packets.json").read_text())
info = lab.version_info()
old_version, old_protocol = old_info["id"], old_info["protocol_version"]
module = "v" + old_version.replace(".", "_")
out = args.out or ROOT / f"crates/horizon-protocol/src/adapter/{module}.rs"
lines = [
f"//! Packet ids of protocol {old_protocol} ({old_version}) against the pinned"
f" {info['protocol_version']} ({info['name']}).",
"//! Generated by tools/generate_packet_id_adapter.py from both versions' packets reports;",
"//! do not edit by hand.",
"use crate::State;",
"",
f"pub const PROTOCOL: i32 = {old_protocol};",
f'pub const NAME: &str = "{old_version}";',
"",
]
for state in STATES:
for direction in ["clientbound", "serverbound"]:
o, n = ids(old, state, direction), ids(new, state, direction)
# Clientbound: indexed by the old server's id. Serverbound: indexed by Horizon's id.
values = table(o, n) if direction == "clientbound" else table(n, o)
assert all(v is None or v < 256 for v in values)
lines.append("#[rustfmt::skip]")
lines.extend(rust_table(f"{state.upper()}_{direction.upper()}", values))
for direction, doc in [
("clientbound", f"For each id a {old_version} server sends in `state`, the pinned version's id"
" of that packet."),
("serverbound", f"For each pinned-version id Horizon sends in `state`, the id a {old_version}"
" server knows it by."),
]:
lines.append(f"/// {doc}")
lines.append(f"pub fn {direction}(state: State) -> &'static [Option<u8>] {{")
lines.append(" match state {")
for state in STATES:
lines.append(f" State::{state.capitalize()} => {state.upper()}_{direction.upper()},")
lines.append(" }")
lines.append("}")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines) + "\n")
if __name__ == "__main__":
main()