Compare commits

...
4 Commits
Author SHA1 Message Date
Ludovic Marie 1db37be21e Merge commit 'f22d1edc7824f9a8e8321b6d6784b236223a09fb' into HEAD 2026-11-03 02:27:04 +00:00
Ludovic MarieandClaude Opus 5.5 f22d1edc78 voicechat-server: replay-proof re-authentication; packages carry LICENSE
- A secret belongs to the first address that authenticates with it: an
  Authenticate copied and sent from another address is refused however long
  the client was quiet (the 5 s move rule is gone). Exact replays are dropped
  too: the last 64 control packets' nonces per player, and a 64-wide window
  on voice packets' sequence numbers.
- A client the server stops hearing for 10 keep-alives gets a fresh secret
  over the game connection (Complex Voice Chat 2.1.0 reconnects on any
  voicechat:secret), keeping its group; the old key then opens nothing. So
  a NAT that rebinds recovers without a rejoin (2.1.0 never re-sends
  Authenticate after its ack, so the old move rule never helped it).
- Secret requests: one answer a second a player (more requests meanwhile
  get one answer), the snapshot replaces what still waited. Group and state
  changes 2 a second (burst 10). At most 512 messages wait per player.
- Client pings aren't echoed (the server never pings; SVC's server doesn't
  echo either), and the relay thread never spins on a socket error.
- The glue lists the players on a message only when the sender is new, not
  on every voice chat message.
- native.py package puts native/<mod>/LICENSE into the .hnmod as
  mixins/<id>/LICENSE (Horizon's bundle reader accepts LICENSE* there); the
  CI check requires it. Every native mod's package now carries its licence.

Tests: a_replayed_handshake_after_a_silence_moves_nothing,
a_client_the_server_stops_hearing_gets_a_fresh_secret,
repeated_secret_requests_and_group_changes_stay_bounded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:12:33 +01:00
Ludovic MarieandClaude Opus 5.5 ad74e43a70 SMP 0.31.0: the auction house lists whole stacks
rdbt's ask: /ah refused anything that wasn't a plain item. A listing now
carries the whole stack (enchantments, name, damage, lore, a shulker box's
or a bundle's contents, every component) as the game writes it in player
files (ItemStack.CODEC NBT, hex), through listing, purchase, auction win,
mail and returns, saved in economy.json in the same step as the money.
Given back with native_give_stack; what doesn't fit waits in the mail.

Taken only when it reads back equal; not market charts, nor stacks over
32 KiB of data. Creative players are still refused. Listings and mail from
0.30 (no `stack`) load unchanged and give plain items as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 03:03:35 +01:00
Ludovic MarieandClaude Opus 5.5 f5696b3e90 voicechat-server: proximity voice chat on Horizon's own server
A native server mod (voicechat_server 1.0.0): the server half for voice chat
clients that speak Simple Voice Chat 2.6.x's protocol, such as azetech's
Complex Voice Chat. The handshake rides Horizon's mod messages on voicechat:
channel names; voices go over UDP through the server, sealed per player with
AES-128-GCM, to the players nearby in the same dimension or in the speaker's
group. A player without a voice client is never sent anything, and the UDP
port opens only when the first voice client asks.

The relay is a bundle crate (horizon-mixins 0.6.0): every native mod now pins
horizon-mixins d9c83d7, since native.py builds them with one, and package
includes a bundle's crates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:39:32 +01:00
31 changed files with 3450 additions and 67 deletions

No files matched your search

+3 -1
View File
@@ -78,7 +78,9 @@ jobs:
names = zipfile.ZipFile(path).namelist()
manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json"))
assert manifest["hooks"] >= 1, manifest
assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names
bundle = f"mixins/{manifest['id']}/"
assert all(n == "horizon-native.json" or n == bundle + "LICENSE" or (n.startswith(bundle) and n.endswith((".rs", ".toml"))) for n in names), names
assert bundle + "LICENSE" in names, "the mod's LICENSE travels in its package"
print(path, manifest)
PY
- uses: actions/upload-artifact@v4
+2 -1
View File
@@ -83,7 +83,8 @@ The index: `https://horizon-rust.github.io/horizon-mods/index.json`.
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundles on Horizon's native hook points
([docs/native-mods.md](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md)). Each folder keeps the
history of the repository it came from. A mod builds on any Horizon whose hook points are at least the version its
`compatibility.json` names; `horizon_revision` is the commit it was last tested on.
`compatibility.json` names; `horizon_revision` is the commit it was last tested on. `smp` and `voicechat-server`
are server mods, built into the official server's engine (see their READMEs).
```sh
python3 native/tools/native.py check zoom # apply on the tested Horizon and cargo check the game
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.30.1"
version = "0.31.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.30.1"
version = "0.31.0"
edition = "2024"
license = "MIT"
publish = false
+9 -3
View File
@@ -21,7 +21,7 @@ header and footer).
| `/eco take <player> <amount> [reason]`, `/money take ...` | operators of level 3: takes a cheat's gains out of the game, logged with the reason |
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, how evenly money is spread |
| `/ah` | the auction house: a chest menu of listings by category, cheapest a unit first; click one to buy it or bid; your listings (click to take one back) and your mail |
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it) at a fixed price, for `listing_hours` (48); listing is free |
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it), whole (enchantments, name, contents...), at a fixed price, for `listing_hours` (48); listing is free |
| `/ah auction <start> [hours]` | auctions the stack in your hand from a starting bid, for 1 to 24 hours (12) |
| `/ah bid <listing> <amount>`, `/ah buy <listing>` | bids (the money is held until someone bids more or it ends), buys a fixed-price listing |
| `/ah cancel <listing>`, `/ah claim`, `/ah search <words>`, `/ah mine`, `/ah mail` | takes back a listing nobody bid on; gives you your mail; the menu on a search, your listings, your mail |
@@ -261,8 +261,14 @@ raising one's own only holds the difference, and a bid in the last `snipe_second
60 seconds away. When an auction ends, the seller is paid the winning bid and the items go to the winner's mail. Sellers
and outbid players who are offline find a note when they come back. Everything (listings, bids, mail, notes) is
written in `economy.json` with the balances, in one step: a crash can't keep the money and lose the items, or the
reverse. Only plain items for now (not worn, named or enchanted); whole stacks with their enchantments wait for
Horizon's `native_give_stack` to be stamped. Survival and adventure players only. `config.json`'s `auction`:
reverse. A listing carries the whole stack (since 0.31): its enchantments, name, damage, lore, a shulker box's
or a bundle's contents and every other component, written as the game writes it in player files, and given back
exactly so (Horizon's `native_give_stack`) to the buyer, the winner, or the seller when it comes back. `/ah sell
<price> <count>` lists part of the stack in hand; the rest stays in the hand, the same. The menu shows the stack
itself (its tooltip), with the listing's price, seller and time under it. A stack is taken only if it would come
back the same (its data written and read back equal); not market charts (free at `/market`), nor a stack holding
more than 32 KiB of data (a box of books). Listings and mail from before 0.31 hold plain items and are given back as
before. Survival and adventure players only. `config.json`'s `auction`:
`enabled`, `max_listings` (8 a player), `listing_hours` (48), `auction_hours` (12),
`auction_hours_max` (24), `bid_step_percent` (5: the smallest raise), `snipe_seconds` (60). Ended listings are
looked at once a second (a pass over the open listings).
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "975c07d9151847e6af13afc4b5ab37b6bbe78ef5",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
+155 -3
View File
@@ -39,7 +39,7 @@ use std::{path::PathBuf, sync::Mutex};
/// The mod's id, name and version.
pub const ID: &str = "smp";
pub const NAME: &str = "SMP";
pub const VERSION: &str = "0.30.1";
pub const VERSION: &str = "0.31.0";
/// `config.json` in `world/horizon-smp/`, written with its defaults the
/// first time, and again with the keys a newer version adds (values kept).
@@ -429,6 +429,127 @@ impl Bag for GameBag<'_, '_> {
}
}
/// The most encoded data one listed stack may carry (a shulker box of
/// written books can hold megabytes; economy.json is written whole).
const MAX_STACK_DATA: usize = 32 * 1024;
/// A stack as the auction house keeps it: `ItemStack.CODEC`'s NBT (id,
/// count, components), as the game saves it in player files, in hex.
fn encode_stack(stack: &ItemStack) -> Option<String> {
let tag = horizon_items::item_nbt::item_to_nbt(stack);
let bytes = horizon_world::nbt::write_network(&tag, horizon_world::nbt::Limits::saved()).ok()?;
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
fn decode_stack(data: &str) -> Option<ItemStack> {
if data.len() % 2 != 0 || data.len() > 2 * MAX_STACK_DATA {
return None;
}
let bytes: Vec<u8> = (0..data.len())
.step_by(2)
.map(|i| u8::from_str_radix(data.get(i..i + 2)?, 16).ok())
.collect::<Option<_>>()?;
let (tag, _) = horizon_world::nbt::read_network(&bytes, horizon_world::nbt::Limits::default()).ok()?;
horizon_items::item_nbt::item_from_nbt(&tag)
}
fn whole_of(stack: &ItemStack) -> Option<smp_auction::Whole> {
Some(smp_auction::Whole { item: stack.item.to_string(), count: stack.count, data: encode_stack(stack)? })
}
/// Why `stack` can't be listed whole, if it can't: only what comes back
/// exactly as it went (every component read back the same) is taken.
fn unsellable(stack: &ItemStack) -> Option<String> {
if &*stack.item == "minecraft:filled_map" && stack.data().and_then(|d| d.map_id).is_some_and(|m| m >= CHART_MAPS) {
return Some("Market charts are free at /market: they can't be sold.".into());
}
let Some(data) = encode_stack(stack) else {
return Some("That stack can't be listed: its data can't be written.".into());
};
if data.len() > 2 * MAX_STACK_DATA {
return Some("That stack holds too much (books, contents...) to be listed.".into());
}
if decode_stack(&data).as_ref() != Some(stack) {
return Some("That stack can't be listed whole: some of its data wouldn't come back the same.".into());
}
None
}
/// The same stack but for the count.
fn same_kind(a: &ItemStack, b: &ItemStack) -> bool {
a.item == b.item && a.damage == b.damage && a.extra == b.extra
}
/// Whether `stack` fits whole in `slots` (the 36 main slots), as
/// `Inventory.add` places it: onto equal stacks, then into empty slots.
fn fits_in(slots: &[Option<ItemStack>], stack: &ItemStack, max: u32) -> bool {
let room: u32 = slots
.iter()
.take(36)
.map(|s| match s {
None => max,
Some(s) if same_kind(s, stack) => max.saturating_sub(s.count),
Some(_) => 0,
})
.sum();
room >= stack.count
}
/// `native_give_stack` for the auction house: what didn't fit, encoded
/// again (all of it when the data can't be read: it waits in the mail).
fn give_whole_to(game: &mut Game<'_>, uuid: u128, w: &smp_auction::Whole) -> Option<smp_auction::Whole> {
let Some(stack) = decode_stack(&w.data) else {
log::error!("[smp] auction house: a stack's data can't be read; it stays in the mail");
return Some(w.clone());
};
let rest = game.native_give_stack(uuid, stack)?;
Some(whole_of(&rest).unwrap_or_else(|| w.clone()))
}
impl smp_auction::Hand for GameBag<'_, '_> {
/// The acting player's state is swapped into the game inside their
/// command, so the hand is the game's selected slot (no helper hands out
/// a whole stack yet).
fn held_whole(&self) -> Option<Result<smp_auction::Whole, String>> {
let s = self.0.inventory.get(self.0.selected)?.as_ref()?;
if let Some(why) = unsellable(s) {
return Some(Err(why));
}
whole_of(s).map(Ok)
}
fn take_held(&mut self, held: &smp_auction::Whole, count: u32) -> Option<smp_auction::Whole> {
self.0.player.as_ref()?;
let selected = self.0.selected;
let slot = self.0.inventory.get_mut(selected)?;
let s = slot.as_ref()?;
if count == 0 || count > s.count || encode_stack(s).as_deref() != Some(held.data.as_str()) {
return None;
}
let mut taken = s.clone();
taken.count = count;
let encoded = whole_of(&taken)?;
if count == s.count {
*slot = None;
} else if let Some(s) = slot.as_mut() {
s.count -= count;
}
Some(encoded)
}
fn fits(&mut self, w: &smp_auction::Whole) -> bool {
let Some(stack) = decode_stack(&w.data) else {
return false;
};
let max = self.0.native_max_stack_size(&stack.item).unwrap_or(64).max(1);
fits_in(&self.0.inventory, &stack, max)
}
fn give_whole(&mut self, w: &smp_auction::Whole) -> Option<smp_auction::Whole> {
let Some(me) = self.0.native_acting_player() else {
return Some(w.clone());
};
give_whole_to(self.0, me.uuid, w)
}
}
const COMMANDS: [&str; 16] = [
"balance", "bal", "pay", "baltop", "eco", "money", "worth", "sell", "buy", "shop", "market", "trade", "coinflip",
"lottery", "ah", "casino",
@@ -1125,6 +1246,25 @@ impl Bag for MenuBag<'_, '_> {
}
}
impl smp_auction::Hand for MenuBag<'_, '_> {
fn held_whole(&self) -> Option<Result<smp_auction::Whole, String>> {
None
}
fn take_held(&mut self, _held: &smp_auction::Whole, _count: u32) -> Option<smp_auction::Whole> {
None
}
fn fits(&mut self, w: &smp_auction::Whole) -> bool {
let Some(stack) = decode_stack(&w.data) else {
return false;
};
let max = self.game.native_max_stack_size(&stack.item).unwrap_or(64).max(1);
fits_in(&self.stacks(), &stack, max)
}
fn give_whole(&mut self, w: &smp_auction::Whole) -> Option<smp_auction::Whole> {
give_whole_to(self.game, self.uuid, w)
}
}
/// The shop menu's cells as items.
fn shop_items(smp: &Smp, uuid: u128) -> Vec<Option<ItemStack>> {
let economy = smp.economy.as_ref().ok();
@@ -1717,12 +1857,24 @@ fn ah_items(smp: &Smp, uuid: u128) -> Vec<Option<ItemStack>> {
C::Listing(id) => {
let l = a.listing(id)?;
let color = if l.auction { "gold" } else { "white" };
named_stack(&l.item, l.count, &a.label(l, now, &money), color)
let label = a.label(l, now, &money);
match l.stack.as_deref().and_then(decode_stack) {
// The stack itself (its name, enchantments, contents
// in the tooltip), the listing's facts under it.
Some(stack) => {
let lines: Vec<(String, &str)> = label.split(" · ").map(|p| (p.to_owned(), color)).collect();
with_lore(stack, &lines)
}
None => named_stack(&l.item, l.count, &label, color),
}
}
C::Parcel(i) => {
let p = a.parcel(uuid, i)?;
let name = format!("Claim {} {} ({})", p.count, smp_shop::title(&p.item), p.why);
named_stack(&p.item, p.count, &name, "green")
match p.stack.as_deref().and_then(decode_stack) {
Some(stack) => with_lore(stack, &[(name, "green")]),
None => named_stack(&p.item, p.count, &name, "green"),
}
}
C::Confirm(id) => {
let l = a.listing(id)?;
+1 -1
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.30.1"
version = "0.31.0"
priority = 100
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
+495 -45
View File
@@ -13,11 +13,14 @@
// action on its one server thread, under the SMP's lock: of two players who
// buy the same listing at once, the second finds it gone.
//
// Plain items only for now (not worn, named or enchanted): the hook point
// that gives a whole stack with its components (`native_give_stack`) isn't
// stamped yet. No game types: the glue lends the items (`Bag`) and draws the
// menu from `Cell`s. Added to the game as `game::smp_auction`; also a module
// of this crate, for its tests.
// A listing carries the whole stack: its enchantments, name, damage,
// contents (a shulker box's, a bundle's) and every other component, kept as
// the glue encodes it (`Whole::data`) and given back exactly so
// (`native_give_stack`). Listings and mail from before 0.31 hold plain items
// by id and count (no `stack`) and are given back as they were. No game
// types: the glue lends the items (`Hand`) and draws the menu from `Cell`s.
// Added to the game as `game::smp_auction`; also a module of this crate, for
// its tests.
use super::smp_economy::{DAY_MS, Economy, MAX_AMOUNT, Out, Player, parse_amount};
use super::smp_market::{Bag, item_name, valid_id};
@@ -98,6 +101,10 @@ pub struct Listing {
pub seller_name: String,
pub item: String,
pub count: u32,
/// The whole stack as the glue encodes it; None: `count` plain `item`
/// (a listing from before 0.31).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stack: Option<String>,
/// The price (fixed), or the starting bid (auction).
pub price: i64,
pub auction: bool,
@@ -119,6 +126,9 @@ impl Listing {
pub struct Parcel {
pub item: String,
pub count: u32,
/// The whole stack, as `Listing::stack`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stack: Option<String>,
/// `expired`, `won`, `cancelled`...
pub why: String,
}
@@ -154,6 +164,91 @@ pub struct Data {
pub today: Today,
}
/// A whole stack as the glue lends it: its id and count (what lines and
/// menus show) and the stack itself in the glue's encoding, every component
/// kept. The glue's encoding is the truth; `item` and `count` repeat it.
#[derive(Clone, Debug, PartialEq)]
pub struct Whole {
pub item: String,
pub count: u32,
pub data: String,
}
/// The acting player's items, whole stacks too.
pub trait Hand: Bag {
/// The main hand's stack (None: empty hand), or why it can't be sold.
fn held_whole(&self) -> Option<Result<Whole, String>>;
/// Takes `count` of the main hand's stack while it is still exactly
/// `held`: the part taken (the rest stays in the hand), or None and
/// nothing moved.
fn take_held(&mut self, held: &Whole, count: u32) -> Option<Whole>;
/// Whether `stack` fits whole in the inventory.
fn fits(&mut self, stack: &Whole) -> bool;
/// Gives `stack` where it fits: what didn't fit (None: all of it given).
fn give_whole(&mut self, stack: &Whole) -> Option<Whole>;
}
/// Items on their way: plain (`stack` None) or one whole stack.
struct Goods {
item: String,
count: u32,
stack: Option<String>,
}
impl Goods {
fn of(l: &Listing) -> Self {
Self {
item: l.item.clone(),
count: l.count,
stack: l.stack.clone(),
}
}
fn whole(&self) -> Option<Whole> {
self.stack.as_ref().map(|data| Whole {
item: self.item.clone(),
count: self.count,
data: data.clone(),
})
}
/// Whether they fit in `bag` now.
fn fit(&self, bag: &mut dyn Hand) -> bool {
match self.whole() {
Some(w) => bag.fits(&w),
None => bag.room(&self.item) >= self.count,
}
}
/// Gives them: how many went, and what is left (None: all went).
fn give(&self, bag: &mut dyn Hand) -> (u32, Option<Goods>) {
match self.whole() {
Some(w) => match bag.give_whole(&w) {
None => (self.count, None),
Some(rest) => (
self.count.saturating_sub(rest.count),
Some(Goods {
item: rest.item,
count: rest.count,
stack: Some(rest.data),
}),
),
},
None => {
let given = if self.count > 0 {
bag.give(&self.item, self.count)
} else {
0
};
let left = self.count - given.min(self.count);
let rest = (left > 0).then(|| Goods {
item: self.item.clone(),
count: left,
stack: None,
});
(given, rest)
}
}
}
}
/// Which listings a list page shows.
#[derive(Clone, Debug, PartialEq)]
pub enum Filter {
@@ -342,8 +437,8 @@ impl AuctionHouse {
}
/// Items to `uuid`'s mail.
fn post(&mut self, uuid: &str, item: &str, count: u32, why: &str) {
if count == 0 {
fn post(&mut self, uuid: &str, goods: Goods, why: &str) {
if goods.count == 0 {
return;
}
self.data
@@ -351,8 +446,9 @@ impl AuctionHouse {
.entry(uuid.to_owned())
.or_default()
.push(Parcel {
item: item.to_owned(),
count,
item: goods.item,
count: goods.count,
stack: goods.stack,
why: why.to_owned(),
});
self.dirty = true;
@@ -426,7 +522,7 @@ impl AuctionHouse {
economy: &mut Economy,
me: &Player,
trades: bool,
bag: &mut dyn Bag,
bag: &mut dyn Hand,
now: i64,
line: &str,
) -> Option<Reply> {
@@ -510,7 +606,7 @@ impl AuctionHouse {
&mut self,
economy: &mut Economy,
me: &Player,
bag: &mut dyn Bag,
bag: &mut dyn Hand,
price: &str,
count: Option<u32>,
hours: Option<i64>,
@@ -534,41 +630,55 @@ impl AuctionHouse {
self.config.max_listings
));
}
let Some((item, held, plain)) = bag.held() else {
return err("Hold the items you want to sell.".into());
let held = match bag.held_whole() {
None => return err("Hold the items you want to sell.".into()),
Some(Err(why)) => return err(why),
Some(Ok(h)) => h,
};
if !plain || !valid_id(&item) {
return err(
"The auction house takes only plain items for now (not worn, named or enchanted)."
.into(),
);
if !valid_id(&held.item) {
return err("That item can't be sold here.".into());
}
let count = count.unwrap_or(held);
if count == 0 || count > held {
return err(format!("You hold {held} {}.", item_name(&item)));
let count = count.unwrap_or(held.count);
if count == 0 || count > held.count {
return err(format!(
"You hold {} {}.",
held.count,
item_name(&held.item)
));
}
let taken = bag.take(&item, count);
if taken != count {
bag.give(&item, taken);
// The stack leaves the hand before anything is written: from here
// on it is either listed or given back, never both.
let Some(taken) = bag.take_held(&held, count) else {
return err("Those items couldn't be taken.".into());
}
};
self.data.next_id += 1;
let id = self.data.next_id;
let ends = now + hours.unwrap_or(self.config.listing_hours).max(1) * 3_600_000;
let entry = serde_json::json!({
"kind": "ah_list", "id": id, "seller": mine, "item": &item, "count": count,
"price": price, "auction": hours.is_some(), "ends": ends,
"kind": "ah_list", "id": id, "seller": mine, "item": &taken.item, "count": taken.count,
"stack": &taken.data, "price": price, "auction": hours.is_some(), "ends": ends,
});
if let Err(e) = economy.record(entry) {
bag.give(&item, taken);
self.data.next_id -= 1;
// Back whole; what doesn't fit (it came from the hand: nothing,
// unless the hand filled meanwhile) waits in the mail.
if let Some(rest) = bag.give_whole(&taken) {
let goods = Goods {
item: rest.item,
count: rest.count,
stack: Some(rest.data),
};
self.post(&mine, goods, "not listed");
}
return err(e);
}
let l = Listing {
id,
seller: mine,
seller_name: me.name.clone(),
item,
count,
item: taken.item,
count: taken.count,
stack: Some(taken.data),
price,
auction: hours.is_some(),
bid: None,
@@ -580,7 +690,7 @@ impl AuctionHouse {
"Listed #{id}: {} for {} ({:.2} each), {}h.",
Self::what(&l),
economy.money(price),
price as f64 / f64::from(count),
price as f64 / f64::from(l.count.max(1)),
self.config.listing_hours
),
Some(h) => format!(
@@ -609,7 +719,7 @@ impl AuctionHouse {
&mut self,
economy: &mut Economy,
me: &Player,
bag: &mut dyn Bag,
bag: &mut dyn Hand,
id: u64,
now: i64,
) -> Vec<Out> {
@@ -638,12 +748,12 @@ impl AuctionHouse {
let Some(seller) = parse_uuid(&l.seller) else {
return err("That listing is damaged.".into());
};
if bag.room(&l.item) < l.count {
if !Goods::of(&l).fit(bag) {
return err(format!("Make room for {} first.", Self::what(&l)));
}
let entry = serde_json::json!({
"kind": "ah_buy", "id": id, "buyer": uuid_string(me.uuid), "seller": &l.seller,
"item": &l.item, "count": l.count, "price": l.price,
"item": &l.item, "count": l.count, "stack": &l.stack, "price": l.price,
});
if let Err(e) = economy.record(entry) {
return err(e);
@@ -651,12 +761,14 @@ impl AuctionHouse {
// The listing leaves first: nothing can sell it twice.
self.data.listings.remove(i);
self.dirty = true;
let given = bag.give(&l.item, l.count);
let (given, rest) = Goods::of(&l).give(bag);
if given == 0 {
self.data.listings.insert(i, l);
return err("The items couldn't be given: nothing was bought.".into());
}
self.post(&uuid_string(me.uuid), &l.item, l.count - given, "bought");
if let Some(rest) = rest {
self.post(&uuid_string(me.uuid), rest, "bought");
}
if let Err(e) = Self::pay_seller(economy, Ok(me.uuid), seller, l.price) {
self.problems.push(format!("auction house sale #{id}: {e}"));
}
@@ -785,13 +897,13 @@ impl AuctionHouse {
return vec![Out::Error("Someone bid on it: it runs to its end.".into())];
}
let l = self.data.listings.remove(i);
self.post(&l.seller, &l.item, l.count, "withdrawn");
self.post(&l.seller, Goods::of(&l), "withdrawn");
vec![Out::Reply(format!("Took #{id} ({}) back.", Self::what(&l)))]
}
/// Gives `uuid` their mail (one parcel, or all of it), as much as fits;
/// the rest stays.
pub fn claim(&mut self, uuid: u128, bag: &mut dyn Bag, only: Option<usize>) -> Vec<Out> {
pub fn claim(&mut self, uuid: u128, bag: &mut dyn Hand, only: Option<usize>) -> Vec<Out> {
let key = uuid_string(uuid);
let Some(mut parcels) = self.data.mail.remove(&key) else {
return vec![Out::Reply("Your mail is empty.".into())];
@@ -802,9 +914,33 @@ impl AuctionHouse {
if only.is_some_and(|o| o != i) {
continue;
}
let n = p.count.min(bag.room(&p.item));
let given = if n > 0 { bag.give(&p.item, n) } else { 0 };
p.count -= given.min(p.count);
let goods = Goods {
item: p.item.clone(),
count: p.count,
stack: p.stack.clone(),
};
let given = match &goods.stack {
// Plain: as much as there is room for.
None => {
let n = p.count.min(bag.room(&p.item));
let given = if n > 0 { bag.give(&p.item, n) } else { 0 };
p.count -= given.min(p.count);
given
}
// A whole stack: once it fits whole; what didn't go stays.
Some(_) if !goods.fit(bag) => 0,
Some(_) => {
let (given, rest) = goods.give(bag);
match rest {
None => p.count = 0,
Some(r) => {
p.count = r.count;
p.stack = r.stack;
}
}
given
}
};
full |= p.count > 0;
if given > 0 {
given_all.push(format!("{given} {}", item_name(&p.item)));
@@ -858,7 +994,7 @@ impl AuctionHouse {
for l in ended {
let Some(b) = l.bid.clone() else {
let why = if l.auction { "no bids" } else { "expired" };
self.post(&l.seller, &l.item, l.count, why);
self.post(&l.seller, Goods::of(&l), why);
let line = format!(
"#{} ({}) ended with {why}: the items are in your mail (/ah claim).",
l.id,
@@ -872,7 +1008,7 @@ impl AuctionHouse {
self.problems
.push(format!("auction #{} payment: {e}", l.id));
}
self.post(&b.uuid, &l.item, l.count, "won");
self.post(&b.uuid, Goods::of(&l), "won");
self.record_sale(&l, b.amount, now);
let line = format!(
"You won #{} ({}) for {}: the items are in your mail (/ah claim).",
@@ -1132,15 +1268,89 @@ mod tests {
const NOTCH: u128 = 3;
const T0: i64 = 1_793_577_600_000;
/// A player's inventory: plain stacks by id, 36 slots of 64.
/// A player's inventory: plain stacks by id, 36 slots of 64, and whole
/// stacks (`id|count|components`, the glue's encoding stands in), one a
/// slot.
#[derive(Default)]
struct Inv {
hand: Option<(String, u32)>,
/// A whole stack in the hand (instead of `hand`).
hand_whole: Option<String>,
items: BTreeMap<String, u32>,
wholes: Vec<String>,
slots_free: u32,
fail_give: bool,
}
/// `id|count|components` as a `Whole`.
fn whole(data: &str) -> Whole {
let mut parts = data.splitn(3, '|');
let item = parts.next().unwrap().to_owned();
let count = parts.next().unwrap().parse().unwrap();
Whole {
item,
count,
data: data.to_owned(),
}
}
fn with_count(data: &str, n: u32) -> String {
let p: Vec<&str> = data.splitn(3, '|').collect();
format!("{}|{n}|{}", p[0], p[2])
}
fn is_plain(data: &str) -> bool {
data.ends_with('|')
}
impl Hand for Inv {
fn held_whole(&self) -> Option<Result<Whole, String>> {
if let Some(d) = &self.hand_whole {
return Some(Ok(whole(d)));
}
self.held()
.map(|(item, n, _)| Ok(whole(&format!("{item}|{n}|"))))
}
fn take_held(&mut self, held: &Whole, count: u32) -> Option<Whole> {
if let Some(d) = self.hand_whole.clone() {
if d != held.data || count > held.count {
return None;
}
let left = held.count - count;
self.hand_whole = (left > 0).then(|| with_count(&d, left));
return Some(whole(&with_count(&d, count)));
}
let taken = self.take(&held.item, count);
if taken != count {
self.give(&held.item, taken);
return None;
}
Some(whole(&with_count(&held.data, count)))
}
fn fits(&mut self, stack: &Whole) -> bool {
if is_plain(&stack.data) {
self.room(&stack.item) >= stack.count
} else {
self.slots_free > 0
}
}
fn give_whole(&mut self, stack: &Whole) -> Option<Whole> {
if self.fail_give {
return Some(stack.clone());
}
if is_plain(&stack.data) {
let n = self.give(&stack.item, stack.count);
return (n < stack.count).then(|| whole(&with_count(&stack.data, stack.count - n)));
}
if self.slots_free == 0 {
return Some(stack.clone());
}
self.slots_free -= 1;
self.wholes.push(stack.data.clone());
None
}
}
impl Bag for Inv {
fn held(&self) -> Option<(String, u32, bool)> {
self.hand
@@ -1183,10 +1393,63 @@ mod tests {
hand: Some((format!("minecraft:{item}"), n)),
items: BTreeMap::from([(format!("minecraft:{item}"), n)]),
slots_free: 30,
fail_give: false,
..Inv::default()
}
}
fn holding_whole(data: &str) -> Inv {
Inv {
hand_whole: Some(data.into()),
slots_free: 30,
..Inv::default()
}
}
/// An enchanted, named, worn sword (one stack).
const SWORD: &str = "minecraft:diamond_sword|1|custom_name=Excalibur;enchantments={sharpness:5,unbreaking:3};damage=12";
/// A shulker box with its contents.
const SHULKER: &str = "minecraft:shulker_box|1|container=[{slot:0,id:diamond,count:64},{slot:1,id:bundle,count:1,bundle_contents=[{id:emerald,count:8}]}]";
/// Where the copies of `data` are: in bags, listings and mail.
fn copies(ah: &AuctionHouse, bags: &[&Inv], data: &str) -> usize {
let listed = ah
.data
.listings
.iter()
.filter(|l| l.stack.as_deref() == Some(data))
.count();
let mailed = ah
.data
.mail
.values()
.flatten()
.filter(|p| p.stack.as_deref() == Some(data))
.count();
let held = bags
.iter()
.map(|b| {
b.wholes.iter().filter(|w| *w == data).count()
+ usize::from(b.hand_whole.as_deref() == Some(data))
})
.sum::<usize>();
listed + mailed + held
}
/// Saves everything and opens it again from disk, as a restart (or a
/// crash right after the save) does.
fn reopen(
ah: &mut AuctionHouse,
e: &mut Economy,
dir: &std::path::Path,
) -> (Economy, AuctionHouse) {
ah.store(e);
e.save().unwrap();
let mut e = Economy::open(dir, EcoConfig::default()).unwrap();
e.now = T0;
let ah = AuctionHouse::open(&e, Config::default());
(e, ah)
}
fn empty() -> Inv {
Inv {
slots_free: 30,
@@ -1567,4 +1830,191 @@ mod tests {
.starts_with("#4 32 Oak Log · 16 coins (0.50 each) · Alex · 2d 0h left")
);
}
#[test]
fn an_enchanted_named_sword_comes_back_identical_after_a_purchase_an_expiry_an_auction_and_a_claim()
{
let (mut e, mut ah, dir) = setup("whole");
let (alex, steve, notch) = (p(ALEX, "Alex"), p(STEVE, "Steve"), p(NOTCH, "Notch"));
ah.online = [ALEX, STEVE, NOTCH].into();
// Listed whole: the stack leaves the hand, the listing keeps it all.
let mut a = holding_whole(SWORD);
let out = run(&mut ah, &mut e, &alex, &mut a, T0, "ah sell 500");
assert!(
matches!(&out[0], Out::Reply(s) if s.starts_with("Listed #1: 1 diamond sword")),
"{out:?}"
);
assert_eq!(a.hand_whole, None);
assert_eq!(ah.listing(1).unwrap().stack.as_deref(), Some(SWORD));
// Bought: Steve gets the very stack.
let mut s = empty();
ah.buy(&mut e, &steve, &mut s, 1, T0 + 1);
assert_eq!(s.wholes, [SWORD]);
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (1500, 500));
// Steve lists it; nobody buys; a restart; it comes back by mail.
let mut s = holding_whole(SWORD);
run(&mut ah, &mut e, &steve, &mut s, T0, "ah sell 900");
ah.tick(&mut e, &[STEVE], T0 + 48 * 3_600_000);
let (mut e, mut ah) = reopen(&mut ah, &mut e, &dir);
assert_eq!(ah.mail_of(STEVE)[0].stack.as_deref(), Some(SWORD));
let mut s = empty();
assert_eq!(
ah.claim(STEVE, &mut s, None),
[Out::Reply("From your mail: 1 diamond sword.".into())]
);
assert_eq!(s.wholes, [SWORD]);
// Auctioned: Notch wins it, claims it after a restart.
let mut s = holding_whole(SWORD);
run(&mut ah, &mut e, &steve, &mut s, T0, "ah auction 100 1");
ah.bid(&mut e, &notch, 3, None, T0);
ah.tick(&mut e, &[STEVE, NOTCH], T0 + 3_600_000);
let (_e, mut ah) = reopen(&mut ah, &mut e, &dir);
let mut n = empty();
ah.claim(NOTCH, &mut n, None);
assert_eq!(n.wholes, [SWORD]);
assert_eq!(copies(&ah, &[&a, &s, &n], SWORD), 1);
// A shulker box keeps its contents (a bundle inside, with its own).
let (mut e, mut ah, _dir) = setup("shulker");
let mut a = holding_whole(SHULKER);
run(&mut ah, &mut e, &alex, &mut a, T0, "ah sell 10");
let mut s = empty();
ah.buy(&mut e, &steve, &mut s, 1, T0);
assert_eq!(s.wholes, [SHULKER]);
}
#[test]
fn part_of_a_named_stack_is_listed_and_the_rest_stays_in_the_hand() {
let (mut e, mut ah, _dir) = setup("part");
let pearls = "minecraft:ender_pearl|16|custom_name=Warp";
let mut a = holding_whole(pearls);
run(
&mut ah,
&mut e,
&p(ALEX, "Alex"),
&mut a,
T0,
"ah sell 60 6",
);
assert_eq!(
a.hand_whole.as_deref(),
Some("minecraft:ender_pearl|10|custom_name=Warp")
);
let l = ah.listing(1).unwrap();
assert_eq!(
(l.count, l.stack.as_deref()),
(6, Some("minecraft:ender_pearl|6|custom_name=Warp"))
);
assert_eq!(
run(
&mut ah,
&mut e,
&p(ALEX, "Alex"),
&mut a,
T0,
"ah sell 60 11"
),
[Out::Error("You hold 10 ender pearl.".into())]
);
}
#[test]
fn a_failure_or_a_crash_between_steps_never_dupes_or_loses_the_stack() {
// The audit log can't be written: the listing isn't made and the
// sword is back in the inventory, once.
let dir = std::env::temp_dir().join(format!("smp-ah-crash-log-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("audit.log")).unwrap();
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
let mut ah = AuctionHouse::open(&e, Config::default());
let mut a = holding_whole(SWORD);
let out = run(&mut ah, &mut e, &p(ALEX, "Alex"), &mut a, T0, "ah sell 500");
assert!(matches!(&out[0], Out::Error(_)), "{out:?}");
assert!(ah.data.listings.is_empty() && ah.data.mail.is_empty());
assert_eq!(
(a.hand_whole, &a.wholes[..]),
(None, &[SWORD.to_owned()][..])
);
// Each step saved and read back (a restart or a crash after the
// save): the sword is in exactly one place each time.
let (mut e, mut ah, dir) = setup("crash");
let (alex, steve) = (p(ALEX, "Alex"), p(STEVE, "Steve"));
let mut a = holding_whole(SWORD);
let mut s = empty();
run(&mut ah, &mut e, &alex, &mut a, T0, "ah sell 500");
let (mut e, mut ah) = reopen(&mut ah, &mut e, &dir);
assert_eq!(copies(&ah, &[&a, &s], SWORD), 1, "listed");
// A give that fails: nothing bought, nothing moved.
let mut broken = Inv {
slots_free: 5,
fail_give: true,
..Inv::default()
};
assert!(matches!(
&ah.buy(&mut e, &steve, &mut broken, 1, T0)[0],
Out::Error(_)
));
assert_eq!((ah.listing(1).is_some(), e.balance(STEVE)), (true, 1000));
// No room: refused before anything moves.
let mut full = Inv::default();
assert!(
matches!(&ah.buy(&mut e, &steve, &mut full, 1, T0)[0], Out::Error(s) if s.starts_with("Make room"))
);
ah.buy(&mut e, &steve, &mut s, 1, T0);
let (mut e, mut ah) = reopen(&mut ah, &mut e, &dir);
assert_eq!(copies(&ah, &[&a, &s], SWORD), 1, "bought");
let mut s = holding_whole(SWORD);
run(&mut ah, &mut e, &steve, &mut s, T0, "ah sell 500");
ah.tick(&mut e, &[STEVE], T0 + 48 * 3_600_000);
let (mut e, mut ah) = reopen(&mut ah, &mut e, &dir);
assert_eq!(copies(&ah, &[&a, &s], SWORD), 1, "expired to the mail");
// A full inventory: the parcel waits, whole.
let mut full = Inv::default();
ah.claim(STEVE, &mut full, None);
let (_e, mut ah) = reopen(&mut ah, &mut e, &dir);
assert_eq!(copies(&ah, &[&a, &s, &full], SWORD), 1, "claim refused");
ah.claim(STEVE, &mut s, None);
assert_eq!(
ah.claim(STEVE, &mut s, None),
[Out::Reply("Your mail is empty.".into())]
);
assert_eq!(
(copies(&ah, &[&a, &s], SWORD), &s.wholes[..]),
(1, &[SWORD.to_owned()][..])
);
}
#[test]
fn listings_and_mail_from_0_30_load_unchanged_and_give_plain_items() {
let (mut e, _ah, dir) = setup("old");
let old = serde_json::json!({
"next_id": 7,
"listings": [{"id": 7, "seller": uuid_string(ALEX), "seller_name": "Alex", "item": "minecraft:wheat",
"count": 64, "price": 100, "auction": false, "created": T0, "ends": T0 + 3_600_000}],
"mail": {uuid_string(STEVE): [{"item": "minecraft:diamond", "count": 3, "why": "won"}]},
"notes": {}, "sales": [], "today": {"day": 0, "sales": 0, "volume": 0},
});
e.set_ext("auction", old.clone());
e.save().unwrap();
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
let mut ah = AuctionHouse::open(&e2, Config::default());
assert_eq!(
serde_json::to_value(&ah.data).unwrap(),
old,
"read and written back the same"
);
let mut e = e2;
e.now = T0;
let mut s = empty();
assert_eq!(
ah.claim(STEVE, &mut s, None),
[Out::Reply("From your mail: 3 diamond.".into())]
);
ah.buy(&mut e, &p(STEVE, "Steve"), &mut s, 7, T0);
assert_eq!(
(s.items["minecraft:diamond"], s.items["minecraft:wheat"]),
(3, 64)
);
assert!(s.wholes.is_empty());
}
}
+10 -4
View File
@@ -170,7 +170,9 @@ def cargo(stage_dir, action):
def package(m):
"""A deterministic .hnmod: horizon-native.json and the bundle's .rs and .toml files."""
"""A deterministic .hnmod: horizon-native.json, the bundle's .rs and .toml files (its crates' too) and the mod's
LICENSE as mixins/<id>/LICENSE, so the licence's notice travels with the sources (Horizon's bundle reader
accepts LICENSE* files there)."""
manifest = {"schema": 1, "id": m.id, "version": m.version, "game": m.compat["game"],
"horizon": m.compat["horizon_revision"], "mixins": m.compat["horizon_mixins_revision"],
"hooks": m.compat["hooks"]}
@@ -178,9 +180,13 @@ def package(m):
if pinned != m.compat["horizon_revision"]:
sys.exit(f"{m.id}: horizon-mixin.toml pins {pinned}, compatibility.json {m.compat['horizon_revision']}")
files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()}
for path in sorted(m.bundle.iterdir()):
if path.suffix in (".rs", ".toml"):
files[f"mixins/{m.id}/{path.name}"] = path.read_bytes()
# The helpers and modules, and the sources of the bundle's own crates (horizon-mixins 0.6.0).
for path in sorted(m.bundle.rglob("*")):
if path.is_file() and path.suffix in (".rs", ".toml"):
files[f"mixins/{m.id}/{path.relative_to(m.bundle).as_posix()}"] = path.read_bytes()
licence = m.root / "LICENSE"
if licence.is_file():
files.setdefault(f"mixins/{m.id}/LICENSE", licence.read_bytes())
out = TARGET / f"{m.id}-{m.version}.hnmod"
TARGET.mkdir(exist_ok=True)
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as archive:
+252
View File
@@ -0,0 +1,252 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cc"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"libc",
"untrusted",
"windows-sys",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "voicechat_server_relay"
version = "1.0.0"
dependencies = [
"ring",
"serde",
"serde_json",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+4
View File
@@ -0,0 +1,4 @@
# The relay crate the bundle adds to the game (mixins/voicechat_server/relay), with its tests.
[workspace]
members = ["mixins/voicechat_server/relay"]
resolver = "3"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+53
View File
@@ -0,0 +1,53 @@
# Voice chat server (Horizon native mod)
The server half of proximity voice chat on a Horizon dedicated server. Players who have a voice chat client mod
(azetech's Complex Voice Chat, any client that speaks Simple Voice Chat 2.6.x's protocol over Horizon's mod
messages) talk to the players near them, or to their group from anywhere. Players without one join and play as on
any server: the server sends them nothing.
- **The handshake** goes over Horizon's mod messages (`native_net_from_client` / `native_net_send_to_player`, the
`horizon:mod_net` channel) on `voicechat:` channel names, the same messages and layouts as Simple Voice Chat 2.6.x
(compatibility version 20). A client gets a fresh 16-byte secret when it asks; nothing is sent to anyone before.
- **Voices** go over UDP, through the server, never from player to player: no player learns another's address.
Each player's packets are sealed with their own secret (AES-128-GCM). The server relays a voice to the players
in the same dimension within `distance` blocks (`whisper_distance` for a whisper), and to the speaker's group
wherever they are. Groups: normal (members aren't heard outside but hear who is near), open (heard and hearing
nearby), isolated (members only).
- **Limits:** 75 voice packets a second a player (burst 30), Opus frames up to 1275 bytes, 128 groups, names and
passwords up to 24 characters, group and state changes 2 a second a player (burst 10), one secret a second a
player (more requests meanwhile get one answer), 24 messages a player a tick and at most 512 waiting.
- **Replays:** a packet that doesn't open with the player's key is dropped without an answer. A secret belongs to
the first address that authenticates with it: a handshake copied and sent from elsewhere moves nothing. Exact
copies are dropped too (the last 64 control packets' nonces, and voice packets' sequence numbers). A client the
server stops hearing for 10 keep-alives (its NAT rebound, say) gets a fresh secret over the game connection and
comes back from its new address with it; the old key then opens nothing.
- **No port is opened** until the first voice chat client asks. Then the relay binds `bind:port` (UDP).
## Config
`config/voicechat-server.json`, beside `server.properties`, written with its defaults the first time a client asks:
| Key | Default | |
|---|---|---|
| `enabled` | `true` | `false`: no port, no answer to clients |
| `port` | `24454` | the UDP port. A host that gives one port for the game usually forwards UDP on it too: use the game's port |
| `bind` | `0.0.0.0` | |
| `voice_host` | `""` | where clients send UDP if not the game server's address: a port, a host, or host:port |
| `distance` | `48.0` | blocks a voice carries |
| `whisper_distance` | `24.0` | |
| `groups` | `true` | players may make and join groups |
| `keep_alive_ms` | `1000` | a client silent 10 times this long shows as disconnected |
| `mtu` | `1024` | |
## Files
| File | What it is |
|---|---|
| `mixins/voicechat_server/horizon-mixin.toml` | The bundle: the relay crate, the glue module and four HEAD hooks (mod messages, server tick, player logged out, world closing) |
| `mixins/voicechat_server/relay/` | The relay crate (`voicechat_server_relay`): the wire format (`proto.rs`), the handshake, groups and UDP relay (`lib.rs`) and its tests over real UDP sockets (`tests.rs`). Depends on `ring` (AES-GCM) and `serde`, both in Horizon's `Cargo.lock` |
| `mixins/voicechat_server/glue.rs` | Added to the game as `game::voicechat_server`: the config, starting the relay, the players each tick |
It needs horizon-mixins 0.6.0 (bundle crates) and hook points API 57. Tests: `cargo test` here.
Not made with or endorsed by Simple Voice Chat or its author: it speaks the same protocol, written from its packet
layouts, with none of its code.
@@ -0,0 +1,8 @@
{
"game": "26.3",
"hooks": 57,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "f4833621f9037e08c487b1779e8c030487e65cc2",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}
@@ -0,0 +1,143 @@
// Voice chat's server half in the game (`game::voicechat_server`): the relay
// (crate voicechat_server_relay) opens its UDP port when the first voice chat
// client asks for a secret, hears the clients' messages through Horizon's mod
// messages, and learns each tick where every player is. A player without a
// voice chat client is never sent anything.
use crate::game::Game;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use voicechat_server_relay::{Config, Player, Server, proto};
/// Messages to one player a tick at most (mod messages allow 32, shared with other mods).
const PER_TICK: usize = 24;
/// The config file in the server's `config/` folder.
const FILE: &str = "voicechat-server.json";
/// After the port failed to open, wait this long before trying again.
const RETRY: Duration = Duration::from_secs(60);
enum Relay {
Off,
Running(Server),
Failed(Instant),
}
static RELAY: Mutex<Relay> = Mutex::new(Relay::Off);
fn relay() -> std::sync::MutexGuard<'static, Relay> {
RELAY.lock().unwrap_or_else(|e| e.into_inner())
}
fn players(game: &Game<'_>) -> Vec<Player> {
game.native_server_players()
.into_iter()
.map(|p| Player {
uuid: p.uuid,
name: p.name,
dimension: p.dimension,
position: p.position,
})
.collect()
}
/// `config/voicechat-server.json`, written with its defaults the first time
/// and again with the keys a newer version adds (values kept). A file that
/// doesn't read is left alone and the defaults apply.
fn config() -> Config {
let Ok(dir) = Game::native_config_dir() else {
return Config::default();
};
let path = dir.join(FILE);
let config = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<Config>(&text) {
Ok(c) => c,
Err(e) => {
log::warn!(
"Voice chat: {} doesn't read ({e}); using the defaults",
path.display()
);
return Config::default();
}
},
Err(_) => Config::default(),
};
if let Ok(text) = serde_json::to_string_pretty(&config) {
let _ = std::fs::write(&path, text + "\n");
}
config
}
/// A message from a player's client: only `voicechat:` channels are ours.
pub(super) fn message(game: &mut Game<'_>, player: u128, channel: &str, data: &[u8]) {
if !channel.starts_with("voicechat:") {
return;
}
let mut relay = relay();
if !matches!(*relay, Relay::Running(_)) {
if channel != proto::REQUEST_SECRET {
return;
}
if let Relay::Failed(at) = *relay
&& at.elapsed() < RETRY
{
return;
}
let config = config();
match Server::start(config.clone()) {
Ok(server) => {
let at = server
.local_addr()
.map(|a| a.to_string())
.unwrap_or_default();
log::info!("Voice chat: relaying voices on UDP {at}");
*relay = Relay::Running(server);
}
Err(e) => {
log::warn!(
"Voice chat: not started on UDP {}:{}: {e}",
config.bind,
config.port
);
*relay = Relay::Failed(Instant::now());
return;
}
}
}
let Relay::Running(server) = &*relay else {
return;
};
// A player who joined since the last tick (or a relay that just started):
// learn who is on first. Not for every message: it lists every player.
if !server.knows(player) {
server.tick(players(game));
}
server.message(player, channel, data);
}
/// Each server tick, once the relay runs.
pub(super) fn tick(game: &mut Game<'_>) {
let out = {
let relay = relay();
let Relay::Running(server) = &*relay else {
return;
};
server.tick(players(game));
server.outgoing(PER_TICK)
};
for (player, channel, data) in out {
if let Err(e) = game.native_net_send_to_player(player, &channel, &data) {
log::debug!("Voice chat: {channel} not sent: {e}");
}
}
}
pub(super) fn left(player: u128) {
if let Relay::Running(server) = &*relay() {
server.left(player);
}
}
/// The world closed: the port closes too (it opens again on the next request).
pub(super) fn stop() {
*relay() = Relay::Off;
}
@@ -0,0 +1,60 @@
[mod]
id = "voicechat_server"
version = "1.0.0"
priority = 100
# The commit it was tested on (the official server's engine).
[compatibility]
horizon_revision = "f4833621f9037e08c487b1779e8c030487e65cc2"
# The handshake, the groups and the UDP relay: plain Rust with its tests.
[[crate]]
id = "relay"
path = "relay"
# The edges between the game and the relay.
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
name = "voicechat_server"
source = "glue.rs"
[[injection]]
id = "net"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_net_from_client"
helper = "net.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "player", ty = "u128", value = "player" },
{ name = "channel", ty = "&str", value = "channel" },
{ name = "data", ty = "&[u8]", value = "data" },
]
[[injection]]
id = "tick"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_server_tick"
helper = "tick.rs"
bindings = [
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
{ name = "phase", ty = "crate::game::native_lifecycle::ServerTickPhase", value = "phase" },
]
[[injection]]
id = "logged_out"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_player_logged_out"
helper = "logged_out.rs"
bindings = [
{ name = "player", ty = "u128", value = "player" },
]
[[injection]]
id = "lifecycle"
kind = "HEAD"
target = "horizon_client::game::native_hooks::Game<'_>::native_world_lifecycle"
helper = "lifecycle.rs"
bindings = [
{ name = "phase", ty = "crate::game::native_lifecycle::WorldPhase", value = "phase" },
]
@@ -0,0 +1,10 @@
// The world closing: the relay closes its UDP port.
fn voicechat_server_lifecycle(phase: crate::game::native_lifecycle::WorldPhase) {
use crate::game::native_lifecycle::WorldPhase;
if matches!(
phase,
WorldPhase::WorldCloseFinal | WorldPhase::WorldCloseFailed
) {
crate::game::voicechat_server::stop();
}
}
@@ -0,0 +1,4 @@
// A player left: the relay forgets them and tells the others.
fn voicechat_server_logged_out(player: u128) {
crate::game::voicechat_server::left(player);
}
@@ -0,0 +1,9 @@
// A voice chat client's message (`voicechat:` channels) to the relay.
fn voicechat_server_net(
game: &mut crate::game::Game<'_>,
player: u128,
channel: &str,
data: &[u8],
) {
crate::game::voicechat_server::message(game, player, channel, data);
}
@@ -0,0 +1,14 @@
[package]
name = "voicechat_server_relay"
version = "1.0.0"
edition = "2024"
license = "MIT"
publish = false
description = "Horizon's voice chat server: the handshake for voice chat clients and the UDP relay of their voices."
[dependencies]
ring = "0.17"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
@@ -0,0 +1,788 @@
//! The server half of proximity voice chat on Horizon: it answers the voice
//! chat clients' handshake (sent over Horizon's mod messages) and relays their
//! voices over UDP to the players who should hear them, by distance and
//! group. Players without a voice chat client never hear of it: nothing is
//! sent to a player until their client asks.
//!
//! Voices go through the server, never player to player, so nobody learns
//! another player's address. Each player's packets are sealed with their own
//! key (AES-128-GCM), which they get over the game connection.
pub mod proto;
use proto::{FromClient, GroupKind, PlayerState, Request, ToClient};
use ring::rand::{SecureRandom, SystemRandom};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::net::{SocketAddr, UdpSocket};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
/// `config/voicechat-server.json`.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Config {
/// Off: no port is opened and clients get no answer.
pub enabled: bool,
/// The UDP port voices use. A host that gives one port for the game
/// (TCP) usually forwards UDP on it too: use the game's port then.
pub port: u16,
/// The address the UDP socket binds.
pub bind: String,
/// Where clients send UDP, when not the game server's own address:
/// a port, a host, or host:port.
pub voice_host: String,
/// How far a voice carries, in blocks.
pub distance: f64,
/// How far a whisper carries.
pub whisper_distance: f64,
/// Players may make and join groups.
pub groups: bool,
pub keep_alive_ms: u32,
pub mtu: u32,
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: true,
port: 24454,
bind: "0.0.0.0".into(),
voice_host: String::new(),
distance: 48.0,
whisper_distance: 24.0,
groups: true,
keep_alive_ms: 1000,
mtu: 1024,
}
}
}
/// A player on the game server, as the last tick saw them.
#[derive(Clone, Debug, PartialEq)]
pub struct Player {
pub uuid: u128,
pub name: String,
pub dimension: String,
pub position: [f64; 3],
}
/// A message for a player's client: (player, channel, data).
pub type Message = (u128, String, Vec<u8>);
/// Groups at most, and voice packets a second a player may send.
const MAX_GROUPS: usize = 128;
const MIC_PER_SECOND: f64 = 75.0;
const MIC_BURST: f64 = 30.0;
/// Group and voice state changes a second a player may make.
const OPS_PER_SECOND: f64 = 2.0;
const OPS_BURST: f64 = 10.0;
/// Secrets a player's own requests get at most this often; more requests
/// meanwhile are answered once, when the time is up.
const SECRET_EVERY: Duration = Duration::from_secs(1);
/// Messages waiting for one player at most; more are dropped.
const MAX_QUEUED: usize = 512;
/// The nonces of a player's last control packets remembered, to drop exact
/// replays; and how far behind the newest a voice packet's sequence may be.
const SEEN_NONCES: usize = 64;
const MIC_WINDOW: i128 = 64;
/// A player's voice connection, for the life of one secret.
///
/// The first address that authenticates holds the secret: no other address
/// can use it, so a handshake captured and sent again from elsewhere moves
/// nothing. A client that moves (a NAT that rebinds) gets a fresh secret
/// over the game connection, which the game session authenticates: when the
/// server stops hearing it, or when it asks again. The old key then opens
/// nothing.
struct Client {
secret: [u8; 16],
cipher: proto::Cipher,
addr: Option<SocketAddr>,
/// Answered the connection check: its voice connection works.
connected: bool,
last_keep_alive: Instant,
disabled: bool,
group: Option<u128>,
tokens: f64,
refilled: Instant,
ops: f64,
ops_refilled: Instant,
/// When this secret was given, and whether a request for another waits.
issued: Instant,
asked_again: bool,
/// Nonces of the control packets already taken (the newest last).
seen: VecDeque<[u8; 12]>,
/// The newest voice sequence, and which of the 64 before it arrived.
mic_top: Option<i64>,
mic_seen: u64,
}
impl Client {
fn new(secret: [u8; 16], now: Instant) -> Self {
Self {
secret,
cipher: proto::Cipher::new(&secret),
addr: None,
connected: false,
last_keep_alive: now,
disabled: false,
group: None,
tokens: MIC_BURST,
refilled: now,
ops: OPS_BURST,
ops_refilled: now,
issued: now,
asked_again: false,
seen: VecDeque::new(),
mic_top: None,
mic_seen: 0,
}
}
/// A control packet's nonce: false if it was already taken.
fn fresh_nonce(&mut self, nonce: [u8; 12]) -> bool {
if self.seen.contains(&nonce) {
return false;
}
if self.seen.len() >= SEEN_NONCES {
self.seen.pop_front();
}
self.seen.push_back(nonce);
true
}
/// A voice packet's sequence: false if it was already relayed or is too
/// old (a replay). A client counts up from 0 for each secret.
fn fresh_sequence(&mut self, sequence: i64) -> bool {
let Some(top) = self.mic_top else {
self.mic_top = Some(sequence);
self.mic_seen = 1;
return true;
};
let ahead = i128::from(sequence) - i128::from(top);
if ahead > 0 {
self.mic_seen = if ahead >= MIC_WINDOW {
1
} else {
(self.mic_seen << ahead) | 1
};
self.mic_top = Some(sequence);
return true;
}
let back = -ahead;
if back >= MIC_WINDOW {
return false;
}
let bit = 1u64 << back;
if self.mic_seen & bit != 0 {
return false;
}
self.mic_seen |= bit;
true
}
/// One group or state change, if the player has one left.
fn spend_op(&mut self, now: Instant) -> bool {
self.ops = (self.ops
+ now.duration_since(self.ops_refilled).as_secs_f64() * OPS_PER_SECOND)
.min(OPS_BURST);
self.ops_refilled = now;
if self.ops < 1.0 {
return false;
}
self.ops -= 1.0;
true
}
}
struct Group {
name: String,
password: Option<String>,
kind: GroupKind,
}
struct State {
config: Config,
/// The UDP port the socket got.
port: u16,
clients: HashMap<u128, Client>,
players: HashMap<u128, Player>,
groups: HashMap<u128, Group>,
/// Players whose state changed since the last tick, to tell everyone.
changed: HashSet<u128>,
outbox: HashMap<u128, VecDeque<(String, Vec<u8>)>>,
}
struct Shared {
state: Mutex<State>,
socket: UdpSocket,
rng: SystemRandom,
running: AtomicBool,
}
/// The running voice server: its UDP socket and thread. Dropping it stops them.
pub struct Server {
shared: Arc<Shared>,
thread: Option<JoinHandle<()>>,
}
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
impl Server {
/// Opens the UDP port and starts relaying.
pub fn start(config: Config) -> io::Result<Self> {
if !config.enabled {
return Err(io::Error::other("voice chat is turned off in its config"));
}
let socket = UdpSocket::bind((config.bind.as_str(), config.port))?;
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
let port = socket.local_addr()?.port();
let shared = Arc::new(Shared {
state: Mutex::new(State {
config,
port,
clients: HashMap::new(),
players: HashMap::new(),
groups: HashMap::new(),
changed: HashSet::new(),
outbox: HashMap::new(),
}),
socket,
rng: SystemRandom::new(),
running: AtomicBool::new(true),
});
let worker = shared.clone();
let thread = std::thread::Builder::new()
.name("voicechat-relay".into())
.spawn(move || relay(&worker))?;
Ok(Self {
shared,
thread: Some(thread),
})
}
/// The UDP address voices arrive on.
pub fn local_addr(&self) -> io::Result<SocketAddr> {
self.shared.socket.local_addr()
}
/// Each server tick: the players on the server now. Voice chat forgets
/// the ones who left, and tells everyone what changed.
pub fn tick(&self, players: Vec<Player>) {
let mut st = lock(&self.shared.state);
st.players = players.into_iter().map(|p| (p.uuid, p)).collect();
let gone: Vec<u128> = st
.clients
.keys()
.filter(|u| !st.players.contains_key(u))
.copied()
.collect();
for uuid in gone {
st.forget(uuid);
}
// Requests that came too soon after a secret get one now.
let due: Vec<u128> = st
.clients
.iter()
.filter(|(_, c)| c.asked_again && c.issued.elapsed() >= SECRET_EVERY)
.map(|(u, _)| *u)
.collect();
for uuid in due {
st.give_secret(&self.shared.rng, uuid, false);
}
let changed: Vec<u128> = st.changed.drain().collect();
for uuid in changed {
if let Some(s) = st.player_state(uuid) {
st.tell_all(proto::STATE, proto::state(&s));
}
}
}
/// Whether `player` was on the server at the last tick.
pub fn knows(&self, player: u128) -> bool {
lock(&self.shared.state).players.contains_key(&player)
}
/// A player left the game.
pub fn left(&self, player: u128) {
lock(&self.shared.state).forget(player);
}
/// A message from `player`'s client on a `voicechat:` channel.
pub fn message(&self, player: u128, channel: &str, data: &[u8]) {
let Some(Ok(request)) = Request::read(channel, data) else {
return;
};
let mut st = lock(&self.shared.state);
if !st.players.contains_key(&player) {
return;
}
match request {
Request::Secret { version } => {
if version != proto::COMPATIBILITY_VERSION {
return;
}
if let Some(c) = st.clients.get_mut(&player)
&& c.issued.elapsed() < SECRET_EVERY
{
c.asked_again = true;
return;
}
st.give_secret(&self.shared.rng, player, false);
}
Request::State { disabled } => {
if let Some(c) = st.clients.get_mut(&player)
&& c.spend_op(Instant::now())
&& c.disabled != disabled
{
c.disabled = disabled;
st.changed.insert(player);
}
}
Request::CreateGroup {
name,
password,
kind,
} => {
let name = name.trim().to_owned();
if !st.config.groups
|| name.is_empty()
|| st.groups.len() >= MAX_GROUPS
|| !st.spend_op(player)
{
return;
}
let mut id = [0u8; 16];
if self.shared.rng.fill(&mut id).is_err() {
return;
}
let id = u128::from_be_bytes(id);
let password = password
.map(|p| p.trim().to_owned())
.filter(|p| !p.is_empty());
let add = proto::add_group(id, &name, password.is_some(), kind);
st.groups.insert(
id,
Group {
name,
password,
kind,
},
);
st.tell_all(proto::ADD_GROUP, add);
st.join(player, id);
}
Request::JoinGroup { group, password } => {
if !st.config.groups || !st.groups.contains_key(&group) || !st.spend_op(player) {
return;
}
let Some(g) = st.groups.get(&group) else {
return;
};
let ok = g.password.is_none()
|| g.password.as_deref() == password.as_deref().map(str::trim);
if ok {
st.join(player, group);
} else {
let current = st.clients.get(&player).and_then(|c| c.group);
st.tell(
player,
proto::JOINED_GROUP,
proto::joined_group(current, true),
);
}
}
Request::LeaveGroup => {
let Some(c) = st.clients.get_mut(&player) else {
return;
};
if c.group.is_none() || !c.spend_op(Instant::now()) {
return;
}
let Some(g) = c.group.take() else { return };
st.tell(
player,
proto::JOINED_GROUP,
proto::joined_group(None, false),
);
st.changed.insert(player);
st.leave_group_cleanup(g);
}
}
}
/// What to send to the players' clients now, at most `per_player` messages each (the rest wait for the
/// next call).
pub fn outgoing(&self, per_player: usize) -> Vec<Message> {
let mut st = lock(&self.shared.state);
let mut out = Vec::new();
st.outbox.retain(|player, queue| {
for _ in 0..per_player {
let Some((channel, data)) = queue.pop_front() else {
break;
};
out.push((*player, channel, data));
}
!queue.is_empty()
});
out
}
}
impl Drop for Server {
fn drop(&mut self) {
self.shared.running.store(false, Ordering::Relaxed);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
impl State {
/// Queues a message for `player`'s client (dropped when MAX_QUEUED wait).
fn tell(&mut self, player: u128, channel: &str, data: Vec<u8>) {
let queue = self.outbox.entry(player).or_default();
if queue.len() < MAX_QUEUED {
queue.push_back((channel.to_owned(), data));
}
}
fn spend_op(&mut self, player: u128) -> bool {
self.clients
.get_mut(&player)
.is_some_and(|c| c.spend_op(Instant::now()))
}
/// A fresh secret for `player`: the old one opens nothing any more.
/// `rekey`: the server's own, for a client it stopped hearing (it keeps its
/// group and state, and gets only the secret). Otherwise the client asked:
/// it starts over and gets everyone's state and the groups, instead of
/// whatever still waited for it.
fn give_secret(&mut self, rng: &SystemRandom, player: u128, rekey: bool) {
let mut secret = [0u8; 16];
if rng.fill(&mut secret).is_err() {
return;
}
let mut client = Client::new(secret, Instant::now());
let old = self.clients.remove(&player);
if rekey && let Some(old) = &old {
client.group = old.group;
client.disabled = old.disabled;
client.ops = old.ops;
client.ops_refilled = old.ops_refilled;
}
self.clients.insert(player, client);
if !rekey && let Some(g) = old.and_then(|o| o.group) {
self.leave_group_cleanup(g);
}
let c = &self.config;
let body = proto::secret(&proto::SecretInfo {
secret: &secret,
port: self.port,
player,
mtu: c.mtu.min(i32::MAX as u32) as i32,
distance: c.distance,
keep_alive_ms: c.keep_alive_ms.clamp(100, 60_000) as i32,
groups: c.groups,
voice_host: &c.voice_host,
});
self.changed.insert(player);
if rekey {
// Past the cap: the secret is what gets the client back.
self.outbox
.entry(player)
.or_default()
.push_back((proto::SECRET.to_owned(), body));
return;
}
self.outbox.remove(&player);
self.tell(player, proto::SECRET, body);
let all: Vec<PlayerState> = self
.clients
.keys()
.filter_map(|u| self.player_state(*u))
.collect();
self.tell(player, proto::STATES, proto::states(&all));
let groups: Vec<Vec<u8>> = self
.groups
.iter()
.map(|(id, g)| proto::add_group(*id, &g.name, g.password.is_some(), g.kind))
.collect();
for g in groups {
self.tell(player, proto::ADD_GROUP, g);
}
}
/// To every player with a voice chat client.
fn tell_all(&mut self, channel: &str, data: Vec<u8>) {
let all: Vec<u128> = self.clients.keys().copied().collect();
for p in all {
self.tell(p, channel, data.clone());
}
}
fn player_state(&self, uuid: u128) -> Option<PlayerState> {
let c = self.clients.get(&uuid)?;
Some(PlayerState {
uuid,
name: self
.players
.get(&uuid)
.map(|p| p.name.clone())
.unwrap_or_default(),
disabled: c.disabled,
disconnected: !c.connected,
group: c.group,
})
}
fn join(&mut self, player: u128, group: u128) {
let Some(c) = self.clients.get_mut(&player) else {
return;
};
let old = c.group.replace(group);
self.tell(
player,
proto::JOINED_GROUP,
proto::joined_group(Some(group), false),
);
self.changed.insert(player);
if let Some(old) = old
&& old != group
{
self.leave_group_cleanup(old);
}
}
/// An empty group goes away.
fn leave_group_cleanup(&mut self, group: u128) {
if self.clients.values().any(|c| c.group == Some(group)) {
return;
}
if self.groups.remove(&group).is_some() {
self.tell_all(proto::REMOVE_GROUP, proto::remove_group(group));
}
}
fn forget(&mut self, uuid: u128) {
self.outbox.remove(&uuid);
self.changed.remove(&uuid);
let Some(c) = self.clients.remove(&uuid) else {
return;
};
self.tell_all(proto::REMOVE_STATE, proto::remove_state(uuid));
if let Some(g) = c.group {
self.leave_group_cleanup(g);
}
}
/// Who hears `sender`, and how: (listener, address, positional range or None for the group's voice).
fn listeners(&self, sender: u128, whispering: bool) -> Vec<(u128, SocketAddr, Option<f64>)> {
let Some(from) = self.clients.get(&sender) else {
return Vec::new();
};
let from_kind = from.group.and_then(|g| self.groups.get(&g)).map(|g| g.kind);
let at = self.players.get(&sender);
let range = if whispering {
self.config.whisper_distance
} else {
self.config.distance
};
let mut out = Vec::new();
for (uuid, c) in &self.clients {
if *uuid == sender || c.disabled || !c.connected {
continue;
}
let Some(addr) = c.addr else { continue };
if from.group.is_some() && from.group == c.group {
out.push((*uuid, addr, None));
continue;
}
let to_kind = c.group.and_then(|g| self.groups.get(&g)).map(|g| g.kind);
// Normal and isolated groups aren't heard outside; isolated ones hear nobody outside.
if matches!(from_kind, Some(GroupKind::Normal | GroupKind::Isolated))
|| to_kind == Some(GroupKind::Isolated)
{
continue;
}
let (Some(a), Some(b)) = (at, self.players.get(uuid)) else {
continue;
};
if a.dimension != b.dimension {
continue;
}
let d = (0..3)
.map(|i| (a.position[i] - b.position[i]).powi(2))
.sum::<f64>()
.sqrt();
if d <= range {
out.push((*uuid, addr, Some(range)));
}
}
out
}
}
/// The UDP side: handshakes, keep-alives and voices.
fn relay(shared: &Shared) {
let mut buf = vec![0u8; 4096];
let mut last_keep_alive = Instant::now();
while shared.running.load(Ordering::Relaxed) {
match shared.socket.recv_from(&mut buf) {
Ok((n, from)) => datagram(shared, &buf[..n], from),
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) => {}
// Never spin on an error that comes back at once.
Err(_) => std::thread::sleep(Duration::from_millis(10)),
}
let every = Duration::from_millis(u64::from(
lock(&shared.state).config.keep_alive_ms.clamp(100, 60_000),
));
if last_keep_alive.elapsed() >= every {
last_keep_alive = Instant::now();
keep_alive(shared, every);
}
}
}
fn send(shared: &Shared, cipher: &proto::Cipher, to: SocketAddr, packet: &ToClient) {
if let Some(sealed) = cipher.seal(&shared.rng, &packet.write()) {
let _ = shared.socket.send_to(&proto::server_datagram(&sealed), to);
}
}
/// Keep-alives to every authenticated client. One that stopped answering
/// gets a fresh secret over the game connection: if it moved (its NAT
/// rebound), it comes back from its new address with that secret.
fn keep_alive(shared: &Shared, every: Duration) {
let mut st = lock(&shared.state);
let mut lost = Vec::new();
for (uuid, c) in st.clients.iter_mut() {
let Some(addr) = c.addr else { continue };
if c.connected && c.last_keep_alive.elapsed() > every * 10 {
c.connected = false;
lost.push(*uuid);
continue;
}
send(shared, &c.cipher, addr, &ToClient::KeepAlive);
}
for uuid in lost {
st.give_secret(&shared.rng, uuid, true);
}
}
fn datagram(shared: &Shared, datagram: &[u8], from: SocketAddr) {
let Ok((player, sealed)) = proto::client_datagram(datagram) else {
return;
};
let mut st = lock(&shared.state);
let Some(c) = st.clients.get_mut(&player) else {
return;
};
let Some(plain) = c.cipher.open(sealed) else {
return;
};
let Ok(packet) = FromClient::read(&plain) else {
return;
};
// It opened, so it holds at least a nonce and a tag.
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&sealed[..12]);
let now = Instant::now();
if let FromClient::Authenticate {
player: who,
secret,
} = packet
{
if who != player || !constant_eq(&secret, &c.secret) {
return;
}
// The secret belongs to the first address that authenticated with it.
if c.addr.is_some_and(|a| a != from) || !c.fresh_nonce(nonce) {
return;
}
c.addr = Some(from);
send(shared, &c.cipher, from, &ToClient::AuthenticateAck);
return;
}
// Everything else only from the address that authenticated.
if c.addr != Some(from) {
return;
}
if let FromClient::Mic { sequence, .. } = packet {
if !c.fresh_sequence(sequence) {
return;
}
} else if !c.fresh_nonce(nonce) {
return;
}
match packet {
FromClient::ConnectionCheck => {
c.last_keep_alive = now;
if !c.connected {
c.connected = true;
st.changed.insert(player);
}
let c = &st.clients[&player];
send(shared, &c.cipher, from, &ToClient::ConnectionCheckAck);
}
FromClient::KeepAlive => {
c.last_keep_alive = now;
if !c.connected {
c.connected = true;
st.changed.insert(player);
}
}
// The server never pings, so this isn't an answer: not echoed (a
// client that echoes pings would otherwise bounce it forever).
FromClient::Ping { .. } => {}
FromClient::Mic {
data,
sequence,
whispering,
} => {
if c.disabled || !c.connected {
return;
}
c.tokens = (c.tokens + now.duration_since(c.refilled).as_secs_f64() * MIC_PER_SECOND)
.min(MIC_BURST);
c.refilled = now;
if c.tokens < 1.0 {
return;
}
c.tokens -= 1.0;
for (listener, addr, range) in st.listeners(player, whispering) {
let packet = match range {
Some(r) => ToClient::Positional {
sender: player,
data: &data,
sequence,
distance: r as f32,
whispering,
},
None => ToClient::Group {
sender: player,
data: &data,
sequence,
},
};
send(shared, &st.clients[&listener].cipher, addr, &packet);
}
}
FromClient::Authenticate { .. } => {}
}
}
fn constant_eq(a: &[u8], b: &[u8]) -> bool {
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,734 @@
//! The voice chat wire format Simple Voice Chat 2.6.x clients speak
//! (compatibility version 20), from the server's side: control messages on
//! `voicechat:` channels over the game connection, and AES-128-GCM UDP
//! datagrams for the voice itself.
//!
//! Numbers are big-endian, a UUID is 16 bytes (most significant half first),
//! strings and byte arrays are a VarInt length then the bytes.
/// The only version this server speaks.
pub const COMPATIBILITY_VERSION: i32 = 20;
/// Every UDP datagram starts with it.
pub const MAGIC: u8 = 0xFF;
/// The largest Opus frame relayed.
pub const MAX_OPUS: usize = 1275;
/// The largest encrypted body read from a datagram.
pub const MAX_BODY: usize = 2048;
/// Group names and passwords, in characters.
pub const MAX_GROUP_TEXT: usize = 24;
pub const REQUEST_SECRET: &str = "voicechat:request_secret";
pub const SECRET: &str = "voicechat:secret";
pub const UPDATE_STATE: &str = "voicechat:update_state";
pub const STATE: &str = "voicechat:state";
pub const STATES: &str = "voicechat:states";
pub const REMOVE_STATE: &str = "voicechat:remove_state";
pub const CREATE_GROUP: &str = "voicechat:create_group";
pub const SET_GROUP: &str = "voicechat:set_group";
pub const LEAVE_GROUP: &str = "voicechat:leave_group";
pub const JOINED_GROUP: &str = "voicechat:joined_group";
pub const ADD_GROUP: &str = "voicechat:add_group";
pub const REMOVE_GROUP: &str = "voicechat:remove_group";
/// A message that can't be read.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Malformed;
type Read<T> = Result<T, Malformed>;
pub struct In<'a> {
rest: &'a [u8],
}
impl<'a> In<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { rest: data }
}
pub fn take(&mut self, n: usize) -> Read<&'a [u8]> {
if self.rest.len() < n {
return Err(Malformed);
}
let (head, tail) = self.rest.split_at(n);
self.rest = tail;
Ok(head)
}
fn fixed<const N: usize>(&mut self) -> Read<[u8; N]> {
let mut out = [0; N];
out.copy_from_slice(self.take(N)?);
Ok(out)
}
pub fn byte(&mut self) -> Read<u8> {
Ok(self.take(1)?[0])
}
pub fn flag(&mut self) -> Read<bool> {
Ok(self.byte()? != 0)
}
pub fn short(&mut self) -> Read<i16> {
Ok(i16::from_be_bytes(self.fixed()?))
}
pub fn int(&mut self) -> Read<i32> {
Ok(i32::from_be_bytes(self.fixed()?))
}
pub fn long(&mut self) -> Read<i64> {
Ok(i64::from_be_bytes(self.fixed()?))
}
pub fn uuid(&mut self) -> Read<u128> {
Ok(u128::from_be_bytes(self.fixed()?))
}
pub fn varint(&mut self) -> Read<i32> {
let mut value = 0u32;
for shift in 0..5 {
let b = self.byte()?;
value |= u32::from(b & 0x7F) << (shift * 7);
if b & 0x80 == 0 {
return Ok(value as i32);
}
}
Err(Malformed)
}
/// A VarInt length of at most `max`, then that many bytes.
pub fn bytes(&mut self, max: usize) -> Read<&'a [u8]> {
let n = usize::try_from(self.varint()?).map_err(|_| Malformed)?;
if n > max {
return Err(Malformed);
}
self.take(n)
}
/// A string of at most `max` characters.
pub fn text(&mut self, max: usize) -> Read<String> {
let raw = self.bytes(max.saturating_mul(4))?;
let s = std::str::from_utf8(raw).map_err(|_| Malformed)?;
if s.chars().count() > max {
return Err(Malformed);
}
Ok(s.to_owned())
}
}
#[derive(Default)]
pub struct Out(pub Vec<u8>);
impl Out {
pub fn byte(&mut self, v: u8) -> &mut Self {
self.0.push(v);
self
}
pub fn flag(&mut self, v: bool) -> &mut Self {
self.byte(u8::from(v))
}
pub fn short(&mut self, v: i16) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn int(&mut self, v: i32) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn long(&mut self, v: i64) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn float(&mut self, v: f32) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn double(&mut self, v: f64) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn uuid(&mut self, v: u128) -> &mut Self {
self.0.extend(v.to_be_bytes());
self
}
pub fn varint(&mut self, v: i32) -> &mut Self {
let mut v = v as u32;
while v >= 0x80 {
self.0.push((v as u8) | 0x80);
v >>= 7;
}
self.0.push(v as u8);
self
}
pub fn bytes(&mut self, b: &[u8]) -> &mut Self {
self.varint(b.len() as i32);
self.0.extend_from_slice(b);
self
}
pub fn text(&mut self, s: &str) -> &mut Self {
self.bytes(s.as_bytes())
}
pub fn done(&mut self) -> Vec<u8> {
std::mem::take(&mut self.0)
}
}
// ------------------------------------------------------------ the game connection
/// How a group hears the world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GroupKind {
/// Members hear each other anywhere and players nearby; nobody outside hears them.
Normal,
/// Members hear each other anywhere; players nearby hear them and are heard.
Open,
/// Members hear only each other.
Isolated,
}
impl GroupKind {
pub fn from_wire(v: i16) -> Self {
match v {
1 => Self::Open,
2 => Self::Isolated,
_ => Self::Normal,
}
}
pub fn wire(self) -> i16 {
match self {
Self::Normal => 0,
Self::Open => 1,
Self::Isolated => 2,
}
}
}
/// What a client asks on a `voicechat:` channel.
#[derive(Debug, Clone, PartialEq)]
pub enum Request {
Secret {
version: i32,
},
State {
disabled: bool,
},
CreateGroup {
name: String,
password: Option<String>,
kind: GroupKind,
},
JoinGroup {
group: u128,
password: Option<String>,
},
LeaveGroup,
}
impl Request {
/// None: not a channel a client sends on.
pub fn read(channel: &str, data: &[u8]) -> Option<Read<Self>> {
let mut r = In::new(data);
let parsed = match channel {
REQUEST_SECRET => r.int().map(|version| Self::Secret { version }),
UPDATE_STATE => r.flag().map(|disabled| Self::State { disabled }),
CREATE_GROUP => (|| {
let name = r.text(MAX_GROUP_TEXT)?;
let password = if r.flag()? {
Some(r.text(MAX_GROUP_TEXT)?)
} else {
None
};
let kind = GroupKind::from_wire(r.short()?);
Ok(Self::CreateGroup {
name,
password,
kind,
})
})(),
SET_GROUP => (|| {
let group = r.uuid()?;
let password = if r.flag()? {
Some(r.text(MAX_GROUP_TEXT)?)
} else {
None
};
Ok(Self::JoinGroup { group, password })
})(),
LEAVE_GROUP => Ok(Self::LeaveGroup),
_ => return None,
};
Some(parsed)
}
}
/// `voicechat:secret`: what the client needs to open its voice connection.
pub struct SecretInfo<'a> {
pub secret: &'a [u8; 16],
pub port: u16,
pub player: u128,
pub mtu: i32,
pub distance: f64,
pub keep_alive_ms: i32,
pub groups: bool,
/// Empty: the game server's own address.
pub voice_host: &'a str,
}
pub fn secret(s: &SecretInfo) -> Vec<u8> {
let mut o = Out::default();
o.0.extend_from_slice(s.secret);
o.int(i32::from(s.port))
.uuid(s.player)
.byte(0) // the Opus application: VoIP
.int(s.mtu)
.double(s.distance)
.int(s.keep_alive_ms)
.flag(s.groups)
.text(s.voice_host)
.flag(false) // recording not allowed
.done()
}
/// A player's voice chat state, as `voicechat:state` and each of `voicechat:states` carry it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlayerState {
pub uuid: u128,
pub name: String,
pub disabled: bool,
pub disconnected: bool,
pub group: Option<u128>,
}
fn put_state(o: &mut Out, s: &PlayerState) {
o.flag(s.disabled)
.flag(s.disconnected)
.uuid(s.uuid)
.text(&s.name)
.flag(s.group.is_some());
if let Some(g) = s.group {
o.uuid(g);
}
}
pub fn state(s: &PlayerState) -> Vec<u8> {
let mut o = Out::default();
put_state(&mut o, s);
o.done()
}
pub fn states(all: &[PlayerState]) -> Vec<u8> {
let mut o = Out::default();
o.int(all.len() as i32);
for s in all {
put_state(&mut o, s);
}
o.done()
}
pub fn remove_state(uuid: u128) -> Vec<u8> {
Out::default().uuid(uuid).done()
}
pub fn add_group(id: u128, name: &str, has_password: bool, kind: GroupKind) -> Vec<u8> {
Out::default()
.uuid(id)
.text(name)
.flag(has_password)
.flag(false) // persistent
.flag(false) // hidden
.short(kind.wire())
.done()
}
pub fn remove_group(id: u128) -> Vec<u8> {
Out::default().uuid(id).done()
}
pub fn joined_group(group: Option<u128>, wrong_password: bool) -> Vec<u8> {
let mut o = Out::default();
o.flag(group.is_some());
if let Some(g) = group {
o.uuid(g);
}
o.flag(wrong_password).done()
}
// ------------------------------------------------------------ UDP
/// A voice packet a client sends.
#[derive(Debug, Clone, PartialEq)]
pub enum FromClient {
/// The microphone: an Opus frame (empty: stopped talking).
Mic {
data: Vec<u8>,
sequence: i64,
whispering: bool,
},
Authenticate {
player: u128,
secret: [u8; 16],
},
Ping {
id: u128,
timestamp: i64,
},
KeepAlive,
ConnectionCheck,
}
impl FromClient {
pub fn read(plain: &[u8]) -> Read<Self> {
let mut r = In::new(plain);
Ok(match r.byte()? {
1 => Self::Mic {
data: r.bytes(MAX_OPUS)?.to_vec(),
sequence: r.long()?,
whispering: r.flag()?,
},
5 => Self::Authenticate {
player: r.uuid()?,
secret: r.fixed()?,
},
7 => Self::Ping {
id: r.uuid()?,
timestamp: r.long()?,
},
8 => Self::KeepAlive,
9 => Self::ConnectionCheck,
_ => return Err(Malformed),
})
}
}
/// A voice packet this server sends.
#[derive(Debug, Clone, PartialEq)]
pub enum ToClient<'a> {
/// A voice heard where `sender` stands.
Positional {
sender: u128,
data: &'a [u8],
sequence: i64,
distance: f32,
whispering: bool,
},
/// A group member's voice, heard everywhere.
Group {
sender: u128,
data: &'a [u8],
sequence: i64,
},
AuthenticateAck,
Ping {
id: u128,
timestamp: i64,
},
KeepAlive,
ConnectionCheckAck,
}
impl ToClient<'_> {
pub fn write(&self) -> Vec<u8> {
let mut o = Out::default();
match self {
// The channel is the sender: the client places a positional voice at that entity.
Self::Positional {
sender,
data,
sequence,
distance,
whispering,
} => o
.byte(2)
.uuid(*sender)
.uuid(*sender)
.bytes(data)
.long(*sequence)
.float(*distance)
.flag(*whispering),
Self::Group {
sender,
data,
sequence,
} => o
.byte(3)
.uuid(*sender)
.uuid(*sender)
.bytes(data)
.long(*sequence)
.byte(0),
Self::AuthenticateAck => o.byte(6),
Self::Ping { id, timestamp } => o.byte(7).uuid(*id).long(*timestamp),
Self::KeepAlive => o.byte(8),
Self::ConnectionCheckAck => o.byte(10),
};
o.done()
}
}
/// AES-128-GCM keyed by a player's secret: a random 12-byte nonce, then the
/// ciphertext and its 16-byte tag.
pub struct Cipher {
key: ring::aead::LessSafeKey,
}
impl Cipher {
pub fn new(secret: &[u8; 16]) -> Self {
let key =
ring::aead::UnboundKey::new(&ring::aead::AES_128_GCM, secret).expect("a 16-byte key");
Self {
key: ring::aead::LessSafeKey::new(key),
}
}
pub fn seal(&self, rng: &dyn ring::rand::SecureRandom, plain: &[u8]) -> Option<Vec<u8>> {
let mut nonce = [0u8; 12];
rng.fill(&mut nonce).ok()?;
let mut body = plain.to_vec();
self.key
.seal_in_place_append_tag(
ring::aead::Nonce::assume_unique_for_key(nonce),
ring::aead::Aad::empty(),
&mut body,
)
.ok()?;
let mut out = nonce.to_vec();
out.extend(body);
Some(out)
}
pub fn open(&self, sealed: &[u8]) -> Option<Vec<u8>> {
if sealed.len() < 12 + 16 {
return None;
}
let (nonce, body) = sealed.split_at(12);
let nonce = ring::aead::Nonce::try_assume_unique_for_key(nonce).ok()?;
let mut body = body.to_vec();
let plain = self
.key
.open_in_place(nonce, ring::aead::Aad::empty(), &mut body)
.ok()?;
Some(plain.to_vec())
}
}
/// A client's datagram: magic, the player's UUID (in clear, to find their key), the sealed packet.
pub fn client_datagram(datagram: &[u8]) -> Read<(u128, &[u8])> {
let mut r = In::new(datagram);
if r.byte()? != MAGIC {
return Err(Malformed);
}
let player = r.uuid()?;
Ok((player, r.bytes(MAX_BODY)?))
}
/// A server datagram: magic, then the sealed packet.
pub fn server_datagram(sealed: &[u8]) -> Vec<u8> {
Out::default().byte(MAGIC).bytes(sealed).done()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varints_match_minecraft() {
for (v, bytes) in [
(0, vec![0]),
(127, vec![0x7F]),
(128, vec![0x80, 1]),
(300, vec![0xAC, 2]),
(-1, vec![0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
] {
assert_eq!(Out::default().varint(v).done(), bytes, "{v}");
assert_eq!(In::new(&bytes).varint().unwrap(), v);
}
assert_eq!(In::new(&[0x80; 6]).varint(), Err(Malformed));
}
#[test]
fn requests_read_their_layouts() {
assert_eq!(
Request::read(REQUEST_SECRET, &[0, 0, 0, 20]),
Some(Ok(Request::Secret { version: 20 }))
);
assert_eq!(
Request::read(UPDATE_STATE, &[1]),
Some(Ok(Request::State { disabled: true }))
);
// name "ab", a password "x", kind open.
assert_eq!(
Request::read(CREATE_GROUP, &[2, b'a', b'b', 1, 1, b'x', 0, 1]),
Some(Ok(Request::CreateGroup {
name: "ab".into(),
password: Some("x".into()),
kind: GroupKind::Open
}))
);
let mut join = 7u128.to_be_bytes().to_vec();
join.push(0);
assert_eq!(
Request::read(SET_GROUP, &join),
Some(Ok(Request::JoinGroup {
group: 7,
password: None
}))
);
assert_eq!(
Request::read(LEAVE_GROUP, &[]),
Some(Ok(Request::LeaveGroup))
);
assert_eq!(
Request::read("voicechat:secret", &[]),
None,
"a server's channel"
);
let long = [&[30u8][..], &[b'n'; 30]].concat();
assert_eq!(
Request::read(CREATE_GROUP, &[&long[..], &[0, 0, 0]].concat()),
Some(Err(Malformed))
);
assert_eq!(Request::read(REQUEST_SECRET, &[0, 0]), Some(Err(Malformed)));
}
#[test]
fn secret_layout() {
let key = [9u8; 16];
let bytes = secret(&SecretInfo {
secret: &key,
port: 24454,
player: 0xAB,
mtu: 1024,
distance: 48.0,
keep_alive_ms: 1000,
groups: true,
voice_host: "",
});
// key, port, uuid, codec, mtu, distance, keep-alive, groups, "" , recording
assert_eq!(bytes.len(), 16 + 4 + 16 + 1 + 4 + 8 + 4 + 1 + 1 + 1);
assert_eq!(&bytes[..16], &key);
assert_eq!(&bytes[16..20], &24454i32.to_be_bytes());
assert_eq!(&bytes[20..36], &0xABu128.to_be_bytes());
assert_eq!(&bytes[41..49], &48.0f64.to_be_bytes());
assert_eq!(bytes[53], 1);
}
#[test]
fn states_and_groups_layouts() {
let a = PlayerState {
uuid: 1,
name: "a".into(),
disabled: true,
disconnected: false,
group: Some(2),
};
assert_eq!(
state(&a),
[
&[1u8, 0][..],
&1u128.to_be_bytes(),
&[1, b'a', 1],
&2u128.to_be_bytes()
]
.concat()
);
let both = states(&[
a.clone(),
PlayerState {
group: None,
..a.clone()
},
]);
assert_eq!(&both[..4], &2i32.to_be_bytes());
assert_eq!(both.len(), 4 + 37 + 21);
let g = add_group(5, "Base", true, GroupKind::Isolated);
assert_eq!(
g,
[
&5u128.to_be_bytes()[..],
&[4, b'B', b'a', b's', b'e', 1, 0, 0, 0, 2]
]
.concat()
);
assert_eq!(joined_group(None, true), vec![0, 1]);
assert_eq!(
joined_group(Some(3), false),
[&[1u8][..], &3u128.to_be_bytes(), &[0]].concat()
);
}
#[test]
fn voice_packets() {
let mic = [&[1u8, 2, 0xAA, 0xBB][..], &5i64.to_be_bytes(), &[1]].concat();
assert_eq!(
FromClient::read(&mic),
Ok(FromClient::Mic {
data: vec![0xAA, 0xBB],
sequence: 5,
whispering: true
})
);
let auth = [&[5u8][..], &7u128.to_be_bytes(), &[3; 16]].concat();
assert_eq!(
FromClient::read(&auth),
Ok(FromClient::Authenticate {
player: 7,
secret: [3; 16]
})
);
assert_eq!(FromClient::read(&[8]), Ok(FromClient::KeepAlive));
assert_eq!(FromClient::read(&[9]), Ok(FromClient::ConnectionCheck));
assert_eq!(FromClient::read(&[2]), Err(Malformed), "a server's packet");
let big = [
&[1u8][..],
&Out::default().varint(1276).done(),
&[0; 1276],
&[0; 9],
]
.concat();
assert_eq!(FromClient::read(&big), Err(Malformed));
let sound = ToClient::Positional {
sender: 4,
data: &[1, 2],
sequence: 9,
distance: 48.0,
whispering: false,
}
.write();
assert_eq!(sound[0], 2);
assert_eq!(&sound[1..17], &4u128.to_be_bytes());
assert_eq!(&sound[17..33], &4u128.to_be_bytes());
assert_eq!(&sound[33..36], &[2, 1, 2]);
assert_eq!(&sound[36..44], &9i64.to_be_bytes());
assert_eq!(&sound[44..48], &48.0f32.to_be_bytes());
assert_eq!(sound[48], 0);
assert_eq!(sound.len(), 49);
let group = ToClient::Group {
sender: 4,
data: &[1],
sequence: 2,
}
.write();
assert_eq!(
(group[0], group.len(), group[group.len() - 1]),
(3, 1 + 32 + 2 + 8 + 1, 0)
);
assert_eq!(ToClient::AuthenticateAck.write(), vec![6]);
assert_eq!(ToClient::ConnectionCheckAck.write(), vec![10]);
}
#[test]
fn sealed_bodies_round_trip_and_reject_tampering() {
let rng = ring::rand::SystemRandom::new();
let c = Cipher::new(&[1; 16]);
let sealed = c.seal(&rng, b"hello").unwrap();
assert_eq!(sealed.len(), 12 + 5 + 16);
assert_eq!(c.open(&sealed).unwrap(), b"hello");
let mut bad = sealed.clone();
bad[14] ^= 1;
assert_eq!(c.open(&bad), None);
assert_eq!(Cipher::new(&[2; 16]).open(&sealed), None);
assert_ne!(
c.seal(&rng, b"hello").unwrap(),
sealed,
"fresh nonce each time"
);
let datagram = [
&[MAGIC][..],
&9u128.to_be_bytes(),
&Out::default().bytes(&sealed).done(),
]
.concat();
assert_eq!(client_datagram(&datagram), Ok((9, &sealed[..])));
assert_eq!(client_datagram(&datagram[1..]), Err(Malformed));
assert_eq!(server_datagram(&[1, 2])[..], [MAGIC, 2, 1, 2]);
}
}
@@ -0,0 +1,653 @@
//! The relay with real UDP sockets on localhost: clients that speak the
//! wire format the way a voice chat client does, written from the layouts.
use super::*;
use proto::{In, Out};
const A: u128 = 0xA;
const B: u128 = 0xB;
const FAR: u128 = 0xC;
const NETHER: u128 = 0xD;
const PLAIN: u128 = 0xE;
fn player(uuid: u128, name: &str, dimension: &str, x: f64) -> Player {
Player {
uuid,
name: name.into(),
dimension: dimension.into(),
position: [x, 64.0, 0.0],
}
}
fn world() -> Vec<Player> {
vec![
player(A, "alice", "minecraft:overworld", 0.0),
player(B, "bob", "minecraft:overworld", 10.0),
player(FAR, "carol", "minecraft:overworld", 500.0),
player(NETHER, "dave", "minecraft:the_nether", 0.0),
// Joined without a voice chat client: never asks, never told.
player(PLAIN, "erin", "minecraft:overworld", 5.0),
]
}
fn server() -> Server {
server_with(Config::default())
}
fn server_with(config: Config) -> Server {
let s = Server::start(Config {
bind: "127.0.0.1".into(),
port: 0,
..config
})
.unwrap();
s.tick(world());
s
}
/// The messages for `who` among `all`.
fn for_player(all: &[Message], who: u128) -> Vec<(String, Vec<u8>)> {
all.iter()
.filter(|m| m.0 == who)
.map(|m| (m.1.clone(), m.2.clone()))
.collect()
}
struct Voice {
uuid: u128,
secret: [u8; 16],
cipher: proto::Cipher,
socket: UdpSocket,
server: SocketAddr,
rng: SystemRandom,
/// The datagrams of its handshake: authenticate, connection check.
handshake: Vec<Vec<u8>>,
}
impl Voice {
/// Reads the secret the server gave, then authenticates and checks the connection.
fn connect(uuid: u128, secret_message: &[u8]) -> Self {
let mut r = In::new(secret_message);
let secret: [u8; 16] = r.take(16).unwrap().try_into().unwrap();
let port = r.int().unwrap() as u16;
assert_eq!(r.uuid().unwrap(), uuid);
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
let mut v = Self {
uuid,
secret,
cipher: proto::Cipher::new(&secret),
socket,
server: SocketAddr::from(([127, 0, 0, 1], port)),
rng: SystemRandom::new(),
handshake: Vec::new(),
};
let auth = v.send(
&Out::default()
.byte(5)
.uuid(uuid)
.done()
.into_iter()
.chain(secret)
.collect::<Vec<_>>(),
);
assert_eq!(v.next(|p| p == [6]), Some(vec![6]), "authenticated");
let check = v.send(&[9]);
assert_eq!(v.next(|p| p == [10]), Some(vec![10]), "connection checked");
v.handshake = vec![auth, check];
v
}
/// Sends a sealed packet; returns the datagram.
fn send(&self, plain: &[u8]) -> Vec<u8> {
let sealed = self.cipher.seal(&self.rng, plain).unwrap();
let datagram = Out::default()
.byte(proto::MAGIC)
.uuid(self.uuid)
.bytes(&sealed)
.done();
self.socket.send_to(&datagram, self.server).unwrap();
datagram
}
/// Answers keep-alives for a while, as a live client does.
fn idle(&self, for_ms: u64) {
let until = Instant::now() + Duration::from_millis(for_ms);
while Instant::now() < until {
self.next(|_| false);
}
}
fn talk(&self, sequence: i64, whispering: bool) -> Vec<u8> {
self.send(
&Out::default()
.byte(1)
.bytes(&[0xAB; 40])
.long(sequence)
.flag(whispering)
.done(),
)
}
/// The next packet `wanted` accepts, skipping keep-alives (answered) and others; None after a quiet while.
fn next(&self, wanted: impl Fn(&[u8]) -> bool) -> Option<Vec<u8>> {
let mut buf = [0u8; 4096];
let until = Instant::now() + Duration::from_millis(900);
while Instant::now() < until {
let Ok((n, _)) = self.socket.recv_from(&mut buf) else {
continue;
};
let mut r = In::new(&buf[..n]);
assert_eq!(r.byte().unwrap(), proto::MAGIC);
let plain = self
.cipher
.open(r.bytes(4096).unwrap())
.expect("sealed with my key");
if plain == [8] {
self.send(&[8]);
}
if wanted(&plain) {
return Some(plain);
}
}
None
}
fn hears(&self) -> Option<Vec<u8>> {
self.next(|p| p[0] == 2 || p[0] == 3)
}
}
fn ask_secret(s: &Server, who: u128) -> Vec<u8> {
s.message(who, proto::REQUEST_SECRET, &20i32.to_be_bytes());
let out = s.outgoing(64);
for_player(&out, who)
.into_iter()
.find(|(c, _)| c == proto::SECRET)
.expect("a secret")
.1
}
#[test]
fn only_asking_clients_hear_of_voice_chat() {
let s = server();
assert!(s.outgoing(64).is_empty(), "nothing before anyone asks");
// Wrong version: no answer.
s.message(A, proto::REQUEST_SECRET, &21i32.to_be_bytes());
assert!(s.outgoing(64).is_empty());
s.message(A, proto::REQUEST_SECRET, &20i32.to_be_bytes());
s.tick(world());
let out = s.outgoing(64);
assert!(out.iter().all(|m| m.0 == A), "{out:?}");
let channels: Vec<String> = for_player(&out, A).into_iter().map(|m| m.0).collect();
assert_eq!(channels, [proto::SECRET, proto::STATES, proto::STATE]);
// A message from someone not on the server, or garbage, is ignored.
s.message(0xF, proto::REQUEST_SECRET, &20i32.to_be_bytes());
s.message(B, proto::CREATE_GROUP, &[200]);
s.tick(world());
assert!(s.outgoing(64).is_empty());
}
#[test]
fn voices_reach_who_is_near_in_the_same_dimension() {
let s = server();
let voices: Vec<Voice> = [A, B, FAR, NETHER]
.iter()
.map(|&u| Voice::connect(u, &ask_secret(&s, u)))
.collect();
let [a, b, far, nether] = &voices[..] else {
unreachable!()
};
s.tick(world());
let states = s.outgoing(64);
assert!(
for_player(&states, PLAIN).is_empty(),
"the player without voice chat gets nothing"
);
a.talk(0, false);
let heard = b.hears().expect("bob, 10 blocks away, hears alice");
assert_eq!(heard[0], 2, "positional");
assert_eq!(&heard[1..17], &A.to_be_bytes(), "at alice");
assert_eq!(&heard[33..34], &[40]);
assert_eq!(
&heard[heard.len() - 13..heard.len() - 5],
&0i64.to_be_bytes()
);
assert_eq!(far.hears(), None, "500 blocks away");
assert_eq!(nether.hears(), None, "another dimension");
assert_eq!(a.hears(), None, "not your own voice");
// A whisper carries 24 blocks: bob at 10 still hears it; at 30 he doesn't.
a.talk(1, true);
assert_eq!(
b.hears().map(|p| p[p.len() - 1]),
Some(1),
"whispering flag"
);
let mut moved = world();
moved[1].position[0] = 30.0;
s.tick(moved);
a.talk(2, true);
assert_eq!(b.hears(), None);
a.talk(3, false);
assert!(b.hears().is_some());
s.tick(world());
// Voice chat off: bob neither hears nor is heard.
s.message(B, proto::UPDATE_STATE, &[1]);
a.talk(4, false);
assert_eq!(b.hears(), None);
b.talk(0, false);
assert_eq!(a.hears(), None);
s.message(B, proto::UPDATE_STATE, &[0]);
a.talk(5, false);
assert!(b.hears().is_some());
}
#[test]
fn groups_follow_their_kind() {
let s = server();
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
let far = Voice::connect(FAR, &ask_secret(&s, FAR));
s.tick(world());
s.outgoing(64);
// Alice makes an isolated group with a password; carol joins it from 500 blocks.
s.message(
A,
proto::CREATE_GROUP,
&Out::default()
.text("Base")
.flag(true)
.text("pw")
.short(2)
.done(),
);
let out = s.outgoing(64);
let add = for_player(&out, FAR)
.into_iter()
.find(|m| m.0 == proto::ADD_GROUP)
.expect("everyone sees the group")
.1;
let id = In::new(&add).uuid().unwrap();
let joined = for_player(&out, A)
.into_iter()
.find(|m| m.0 == proto::JOINED_GROUP)
.unwrap()
.1;
assert_eq!(joined, proto::joined_group(Some(id), false));
s.message(
FAR,
proto::SET_GROUP,
&Out::default().uuid(id).flag(true).text("nope").done(),
);
let out = s.outgoing(64);
assert_eq!(
for_player(&out, FAR),
vec![(
proto::JOINED_GROUP.to_owned(),
proto::joined_group(None, true)
)]
);
s.message(
FAR,
proto::SET_GROUP,
&Out::default().uuid(id).flag(true).text("pw").done(),
);
s.tick(world());
s.outgoing(64);
a.talk(0, false);
assert_eq!(
far.hears().map(|p| p[0]),
Some(3),
"a group voice, from anywhere"
);
assert_eq!(b.hears(), None, "isolated: bob nearby doesn't hear alice");
b.talk(0, false);
assert_eq!(a.hears(), None, "isolated: alice doesn't hear bob");
// Both leave: the empty group goes away for everyone.
s.message(A, proto::LEAVE_GROUP, &[]);
s.message(FAR, proto::LEAVE_GROUP, &[]);
let out = s.outgoing(64);
assert!(
for_player(&out, B).contains(&(proto::REMOVE_GROUP.to_owned(), proto::remove_group(id)))
);
a.talk(1, false);
assert_eq!(b.hears().map(|p| p[0]), Some(2), "back to proximity");
// An open group: members are heard nearby, and hear who is nearby.
s.message(
A,
proto::CREATE_GROUP,
&Out::default().text("Open").flag(false).short(1).done(),
);
s.tick(world());
s.outgoing(64);
a.talk(2, false);
assert_eq!(b.hears().map(|p| p[0]), Some(2));
b.talk(1, false);
assert_eq!(a.hears().map(|p| p[0]), Some(2));
// A normal one: members aren't heard outside, but hear who is nearby.
s.message(
A,
proto::CREATE_GROUP,
&Out::default().text("Normal").flag(false).short(0).done(),
);
s.tick(world());
s.outgoing(64);
a.talk(3, false);
assert_eq!(b.hears(), None);
b.talk(2, false);
assert_eq!(a.hears().map(|p| p[0]), Some(2));
}
#[test]
fn leaving_and_strangers() {
let s = server();
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
s.tick(world());
s.outgoing(64);
// Someone without the key can't speak as alice, nor take her address.
let stranger = UdpSocket::bind("127.0.0.1:0").unwrap();
let fake = proto::Cipher::new(&[0; 16])
.seal(&SystemRandom::new(), &[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0])
.unwrap();
stranger
.send_to(
&Out::default()
.byte(proto::MAGIC)
.uuid(A)
.bytes(&fake)
.done(),
b.server,
)
.unwrap();
assert_eq!(b.hears(), None);
// A copy of alice's own handshake from elsewhere, while she's active, is refused.
let replay = a
.cipher
.seal(
&a.rng,
&Out::default()
.byte(5)
.uuid(A)
.done()
.into_iter()
.chain(a.secret)
.collect::<Vec<_>>(),
)
.unwrap();
stranger
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
stranger
.send_to(
&Out::default()
.byte(proto::MAGIC)
.uuid(A)
.bytes(&replay)
.done(),
b.server,
)
.unwrap();
let mut buf = [0u8; 64];
assert!(
stranger.recv_from(&mut buf).is_err(),
"no answer to a stranger"
);
a.talk(0, false);
assert!(
b.hears().is_some(),
"alice still talks from her own address"
);
// Bob leaves the game: alice is told, and his voice is gone.
let mut without_bob = world();
without_bob.retain(|p| p.uuid != B);
s.tick(without_bob);
let out = s.outgoing(64);
assert_eq!(
for_player(&out, A),
vec![(proto::REMOVE_STATE.to_owned(), proto::remove_state(B))]
);
b.talk(5, false);
assert_eq!(a.hears(), None);
s.left(A);
assert!(s.outgoing(64).is_empty());
}
#[test]
fn outgoing_is_paced_per_player() {
let s = server();
ask_secret(&s, A);
for i in 0..10 {
s.message(
A,
proto::CREATE_GROUP,
&Out::default()
.text(&format!("g{i}"))
.flag(false)
.short(0)
.done(),
);
}
let first = s.outgoing(4);
assert_eq!(first.len(), 4);
let mut rest = 0;
loop {
let more = s.outgoing(4);
if more.is_empty() {
break;
}
assert!(more.len() <= 4);
rest += more.len();
}
// Each group: its add and alice's joined (the old one, emptied, is removed).
assert_eq!(4 + rest, 10 * 2 + 9);
}
#[test]
fn config_defaults_fill_in() {
let c: Config = serde_json::from_str(r#"{"port": 25565}"#).unwrap();
assert_eq!((c.port, c.distance, c.enabled), (25565, 48.0, true));
assert!(
Server::start(Config {
enabled: false,
..Config::default()
})
.is_err()
);
}
/// A socket elsewhere that copies datagrams it saw and sends them as its own.
fn stranger() -> UdpSocket {
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
socket
.set_read_timeout(Some(Duration::from_millis(300)))
.unwrap();
socket
}
fn answered(socket: &UdpSocket) -> bool {
let mut buf = [0u8; 4096];
socket.recv_from(&mut buf).is_ok()
}
#[test]
fn a_replayed_handshake_after_a_silence_moves_nothing() {
let s = server();
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
s.tick(world());
s.outgoing(64);
// Alice says nothing for longer than the old 5 s move rule (but less than
// the 10 s after which she counts as lost).
std::thread::sleep(Duration::from_millis(5300));
let eve = stranger();
for datagram in &a.handshake {
eve.send_to(datagram, a.server).unwrap();
assert!(!answered(&eve), "no answer to a copied handshake");
}
b.talk(0, false);
assert!(a.hears().is_some(), "bob's voice still goes to alice");
assert!(!answered(&eve), "and not to the copier");
// Exact copies from alice's own address are dropped too.
a.socket.send_to(&a.handshake[1], a.server).unwrap();
assert_eq!(a.next(|p| p == [10]), None, "a copied check isn't answered");
let said = a.talk(0, false);
assert!(b.hears().is_some());
a.socket.send_to(&said, a.server).unwrap();
assert_eq!(b.hears(), None, "a copied voice packet isn't relayed twice");
a.talk(1, false);
assert!(b.hears().is_some(), "the next one is");
// Once her handshake has left the nonces remembered, the secret still
// belongs to her address alone.
for _ in 0..SEEN_NONCES + 4 {
a.send(&[8]);
}
a.idle(200);
for datagram in &a.handshake {
eve.send_to(datagram, a.server).unwrap();
assert!(!answered(&eve), "no answer to an old copy either");
}
b.talk(1, false);
assert!(a.hears().is_some());
assert!(!answered(&eve));
}
#[test]
fn a_client_the_server_stops_hearing_gets_a_fresh_secret() {
let s = server_with(Config {
// Lost after 3 s: bob, who answers keep-alives only while a test
// reads his socket, must not count as lost too.
keep_alive_ms: 300,
..Config::default()
});
let a = Voice::connect(A, &ask_secret(&s, A));
let b = Voice::connect(B, &ask_secret(&s, B));
s.message(
A,
proto::CREATE_GROUP,
&Out::default().text("Open").flag(false).short(1).done(),
);
s.tick(world());
let out = s.outgoing(64);
let group = for_player(&out, A)
.into_iter()
.find(|m| m.0 == proto::ADD_GROUP)
.map(|m| In::new(&m.1).uuid().unwrap())
.unwrap();
// Alice's NAT rebinds: the server hears nothing from her for 10 keep-alives.
b.idle(3500);
let out = s.outgoing(64);
let to_alice = for_player(&out, A);
assert_eq!(
to_alice.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(),
[proto::SECRET],
"only a new secret, over the game connection"
);
let fresh = &to_alice[0].1;
assert_ne!(&fresh[..16], &a.secret, "a new key");
// The old key opens nothing now, from anywhere.
let eve = stranger();
eve.send_to(&a.handshake[0], a.server).unwrap();
assert!(!answered(&eve));
a.send(&[9]);
assert_eq!(a.next(|p| p == [10]), None);
// From her new address with the new secret, she is back, in her group.
let moved = Voice::connect(A, fresh);
s.tick(world());
let out = s.outgoing(64);
let alice = proto::state(&PlayerState {
uuid: A,
name: "alice".into(),
disabled: false,
disconnected: false,
group: Some(group),
});
assert!(
for_player(&out, B).contains(&(proto::STATE.to_owned(), alice)),
"{out:?}"
);
b.talk(0, false);
assert!(
moved.hears().is_some(),
"bob's voice reaches her new address"
);
assert_eq!(a.hears(), None, "and not the old one");
moved.talk(0, false);
assert!(b.hears().is_some());
}
#[test]
fn repeated_secret_requests_and_group_changes_stay_bounded() {
let s = server();
// 40 players, each in a group of their own.
let many: Vec<Player> = (0..40u128)
.map(|i| player(0x100 + i, &format!("p{i}"), "minecraft:overworld", 0.0))
.collect();
s.tick(many.clone());
for p in &many {
ask_secret(&s, p.uuid);
s.message(
p.uuid,
proto::CREATE_GROUP,
&Out::default().text(&p.name).flag(false).short(0).done(),
);
}
s.tick(many.clone());
s.outgoing(usize::MAX);
// A thousand requests in a row: answered once now, once more a second later.
let first = many[0].uuid;
for _ in 0..1000 {
s.message(first, proto::REQUEST_SECRET, &20i32.to_be_bytes());
}
let count = |out: &[Message], channel: &str| {
out.iter()
.filter(|m| m.0 == first && m.1 == channel)
.count()
};
let out = s.outgoing(usize::MAX);
assert_eq!(count(&out, proto::SECRET), 0, "too soon after the last one");
std::thread::sleep(SECRET_EVERY + Duration::from_millis(50));
s.tick(many.clone());
let out = s.outgoing(usize::MAX);
assert_eq!(count(&out, proto::SECRET), 1);
// Starting over, it left its own group, which went away.
assert_eq!(count(&out, proto::ADD_GROUP), 39, "the groups once");
let mine: Vec<String> = for_player(&out, first).into_iter().map(|m| m.0).collect();
assert!(mine.len() <= 1 + 1 + 39 + 1, "{mine:?}");
s.tick(many.clone());
assert_eq!(count(&s.outgoing(usize::MAX), proto::SECRET), 0);
// Group changes: a burst of 10, then 2 a second.
let second = many[1].uuid;
for i in 0..100 {
s.message(
second,
proto::CREATE_GROUP,
&Out::default()
.text(&format!("g{i}"))
.flag(false)
.short(0)
.done(),
);
}
let out = s.outgoing(usize::MAX);
let joined = out
.iter()
.filter(|m| m.0 == second && m.1 == proto::JOINED_GROUP)
.count();
// Its bucket refilled during the wait: 10 of the 100.
assert_eq!(joined, OPS_BURST as usize);
}
@@ -0,0 +1,13 @@
// The end of each server tick: the relay learns where everyone is, and its
// messages leave for the players' clients.
fn voicechat_server_tick(
game: &mut crate::game::Game<'_>,
phase: crate::game::native_lifecycle::ServerTickPhase,
) {
if matches!(
phase,
crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. }
) {
crate::game::voicechat_server::tick(game);
}
}
+1 -1
View File
@@ -4,5 +4,5 @@
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
}