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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ members = [
'crates/hyperion-inventory',
'crates/hyperion-item',
'crates/hyperion-minecraft-proto',
'crates/hyperion-minecraft-proto-derive',
'crates/hyperion-nerd-font',
'crates/hyperion-packet-macros',
'crates/hyperion-palette',
Expand Down Expand Up @@ -209,6 +210,12 @@ path = 'crates/hyperion-inventory'
[workspace.dependencies.hyperion-item]
path = 'crates/hyperion-item'

[workspace.dependencies.hyperion-minecraft-proto]
path = 'crates/hyperion-minecraft-proto'

[workspace.dependencies.hyperion-minecraft-proto-derive]
path = 'crates/hyperion-minecraft-proto-derive'

[workspace.dependencies.hyperion-nerd-font]
path = 'crates/hyperion-nerd-font'

Expand Down
1 change: 1 addition & 0 deletions crates/hyperion-minecraft-proto-derive/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/target
18 changes: 18 additions & 0 deletions crates/hyperion-minecraft-proto-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[dependencies]
proc-macro2.workspace = true
quote.workspace = true
syn.workspace = true

[lib]
proc-macro = true

[lints]
workspace = true

[package]
authors = ["Andrew Gazelka <andrew.gazelka@gmail.com>"]
edition.workspace = true
name = "hyperion-minecraft-proto-derive"
publish = false
readme = "README.md"
version.workspace = true
53 changes: 53 additions & 0 deletions crates/hyperion-minecraft-proto-derive/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# hyperion-minecraft-proto-derive

`#[derive(Encode, Decode)]` for the Minecraft wire format.

A packet body is a fixed sequence of fields with nothing between them, so the
derive is a loop over the fields in declaration order. Anything that is not
that — a length that is not a `VarInt`, a discriminant that selects a layout —
is deliberately out of scope: a derive that guessed would produce a codec that
looks right and desynchronises the stream.

```rust,ignore
use hyperion_minecraft_proto::{Decode, Encode, Uuid};

#[derive(Debug, Clone, PartialEq, Eq, Encode, Decode)]
pub struct Hello<'a> {
#[proto(max_len = 16)]
pub name: &'a str,
pub profile_id: Uuid,
}
```

## What it supports

- Named, tuple and unit structs. A unit struct occupies no bytes, matching the
server's `StreamCodec.unit` packets.
- Fieldless enums, written as a `VarInt` discriminant. An unrecognised value
decodes to `Error::InvalidEnum` rather than to a wrong variant.
- Borrowed fields: a type with one lifetime gets `impl<'a> Decode<'a>`, one
with none gets a fresh lifetime.
- Type parameters, bounded with `Encode` and `Decode<'de>` respectively.

## Field attributes

| attribute | effect |
| --- | --- |
| `#[proto(max_len = N)]` | limit on the innermost string, in UTF-16 code units |
| `#[proto(max_count = N)]` | limit on the innermost collection's element count |
| `#[proto(with = path)]` | use `path::encode` and `path::decode` for this field |

`max_len` and `max_count` thread through `Option<_>` and `Vec<_>` to the type
they constrain, so `#[proto(max_len = 1024)] pages: Vec<&'a str>` bounds each
page rather than the list.

## What it refuses

Every one of these is a compile error rather than a silent approximation:

- an enum variant with fields, since the discriminant alone does not say how to
read what follows
- more than one lifetime, since the derive would have to guess which one the
reader lends to
- a `with` beside a limit, since the limit would never be applied
- a union
54 changes: 54 additions & 0 deletions crates/hyperion-minecraft-proto-derive/src/attr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//! Parsing of the `#[proto(...)]` field attribute.

use syn::{Attribute, Expr, LitInt, Path, Result, spanned::Spanned};

/// What a `#[proto(...)]` attribute says about one field.
#[derive(Default)]
pub struct Field {
/// `max_len = N`: the innermost string's limit in UTF-16 code units.
pub max_len: Option<usize>,
/// `max_count = N`: the innermost collection's element limit.
pub max_count: Option<usize>,
/// `with = path`: a module supplying `encode` and `decode` for this field.
pub with: Option<Path>,
}

impl Field {
/// Read the single `#[proto(...)]` attribute a field may carry.
pub fn parse(attrs: &[Attribute]) -> Result<Self> {
let mut out = Self::default();
for attr in attrs.iter().filter(|a| a.path().is_ident("proto")) {
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("max_len") {
out.max_len = Some(usize_value(&meta.value()?.parse()?)?);
} else if meta.path.is_ident("max_count") {
out.max_count = Some(usize_value(&meta.value()?.parse()?)?);
} else if meta.path.is_ident("with") {
out.with = Some(meta.value()?.parse()?);
} else {
return Err(meta.error("expected `max_len`, `max_count` or `with`"));
}
Ok(())
})?;
}
if out.with.is_some() && (out.max_len.is_some() || out.max_count.is_some()) {
// A `with` module owns the whole field, so a limit beside it would
// be silently ignored rather than enforced somewhere unexpected.
return Err(syn::Error::new(
attrs[0].span(),
"`with` supplies the whole codec, so it cannot be combined with a limit",
));
}
Ok(out)
}
}

fn usize_value(expr: &Expr) -> Result<usize> {
let Expr::Lit(lit) = expr else {
return Err(syn::Error::new(expr.span(), "expected an integer literal"));
};
let syn::Lit::Int(int) = &lit.lit else {
return Err(syn::Error::new(expr.span(), "expected an integer literal"));
};
LitInt::base10_parse::<usize>(int)
}
Loading
Loading