Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 6759ff851a Revert "Radiant shader pack (#184)": publish can't parse it
publish installs cargo-horizon-mod at the HORIZON_REV repository variable
(6f8c7a0, 2026-10-31), which has no `repo` field. The fork PR's check ran with
HORIZON_REV=main (fork runs get no repository variables), so it passed, while
every publish since (41711060508, 41723171707) has failed on mods/radiant.toml.
Radiant was never published; re-add it once HORIZON_REV supports
repository-sourced entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-03 23:43:55 +01:00
82e29462a9 SMP: the sidebar no longer lists the richest players (#148)
The sidebar keeps the player's own block (name, balance, playtime, online)
and the address. /baltop still ranks the richest. board.top is ignored.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Ludovic Marie <[email protected]>
2026-11-03 22:35:19 +01:00
725ff7a410 Update chatheads 0.1.1 (#151)
* Update chatheads 0.1.1

Submitted by @Domanffe through the form.

Submission: m9iIfQyNTA6ee66EpcsIdlIFDTgx3mIakQro02chGVU
Co-authored-by: Domanffe <[email protected]>

* chatheads: fix the doubled icon URL and restore the name ChatHeads

The submission form pasted the icon URL twice and lowercased the name; the
in-game name is still ChatHeads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Domanffe <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 21:16:30 +01:00
Nuggetandadasjusk 64b5237f7c Add killc, stuck and rmotd native mods (#125)
Co-authored-by: adasjusk <[email protected]>
2026-11-03 21:16:24 +01:00
Ayano KaitoandGPT-6.1 Sol 7d17c4d63a Add Right-Click Crops 1.0.0 registry submission (#124)
Add the author's approved project page, icon and verified Minisign signature. Request maintainer staging because the web form rejects the submission origin.

Co-authored-by: GPT-6.1 Sol <[email protected]>
2026-11-03 21:16:18 +01:00
Matteo FranguelliandClaude Opus 5.5 aab9aa4c22 Add customhud (Beautiful Day Counter), a native mod (#115)
* Add customhud (Beautiful Day Counter), a native mod

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* customhud: list version 1.6.0 explicitly instead of repo

Every version of a native mod is reviewed, so the entry names its file
and SHA-256 rather than following the repository's releases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 21:16:11 +01:00
tariksabanuzandClaude Sonnet 5.5 6d9a7492e2 PhysicsModHorizon (#191)
* Add files via upload

* Delete mods/mod_menu-0.3.0.hnmod

* Add Horizon Physics 0.12.1 (native mod)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

* Horizon Physics 0.13.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

* Horizon Physics 0.14.0

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

* horizon_physics 0.15.0

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

* horizon_physics 0.15.1

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PaZj999vuofU9gSEtyNeUq

---------

Co-authored-by: Claude <[email protected]>
2026-11-03 20:04:20 +01:00
expensiverest 96c7c46e41 Radiant shader pack (#184) 2026-11-03 19:53:27 +01:00
AkirabaneandClaude Sonnet 5.5 09f69374d9 smp: /spawn lands on a safe spot within spawn_radius of the spawn point (#190)
/spawn teleported to one fixed point, which on the anarchy server can sit in
a lava cast. After the tp it now runs vanilla's spreadplayers around the
spawn point with places.spawn_radius (default 250, like the live server's
respawn_radius), which picks a random column that is not fluid, fire or
magma and has solid ground, retrying until one is found. Warps, homes, /back
and /tpa are unchanged. Radius 0 keeps the exact point.

Refs #144

Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-11-03 19:41:23 +01:00
Ludovic MarieandDomanffe 4e8a65e584 Update rightharvest 0.1.1 (#152)
Submitted by @Domanffe through the form.

Submission: raf1Of2yOgkT_BRuLqW580FVXVBZqItOyqTK-1tIrj4

Co-authored-by: Domanffe <[email protected]>
2026-11-03 18:37:21 +01:00
Ludovic MarieandDomanffe bba4bda2d7 Update blockscope 0.3.1 (#150)
Submitted by @Domanffe through the form.

Submission: yQxeJFeA1pxjYCJZusO7QY83yMPaOXOZ1mKmjvzqfiU

Co-authored-by: Domanffe <[email protected]>
2026-11-03 18:37:16 +01:00
Ludovic Marieandkeksi2k 6263473f81 Add horizonmatica 0.1.1 (#145)
Submitted by @keksi2k through the form.

Submission: DXHbuwRsHNMIModbM_217IiUgMiBXsORLzIecTtl5TI

Co-authored-by: keksi2k <[email protected]>
2026-11-03 18:37:10 +01:00
Ludovic MarieandVanishingTacos 1fc4bd47d5 Add inventory_wizard 2.3.3 (#120)
Submitted by @VanishingTacos through the form.

Submission: MV28vLDz8HAq_j0ApAE1yIC4BXlLuoDRF8RWnRBpWkg

Co-authored-by: VanishingTacos <[email protected]>
2026-11-03 18:37:04 +01:00
Ludovic MarieandLauch2 7558c0945c Add waymap 0.1.4 (#179)
Submitted by @Lauch2 through the form.

Submission: eLte5hlJxy2U5VIR8-lJIqN2qCkgJ-dqT4i3o95uIVY

Co-authored-by: Lauch2 <[email protected]>
2026-11-03 18:34:43 +01:00
Ludovic MarieandNirokx 467b17bf38 Add voicechat 1.0.0 (#169)
Submitted by @Nirokx through the form.

Submission: AG7Q9X_ycyCwuouRding4ld-wXJjIAaCwGlv0aXEloc

Co-authored-by: Nirokx <[email protected]>
2026-11-03 18:34:37 +01:00
Ludovic MarieandNirokx 3d113f473a Add waypoints 1.0.0 (#168)
Submitted by @Nirokx through the form.

Submission: Ad0rlAatq4jNcgAxUy5vaU21EsMFePIXwBTE_ARctBM

Co-authored-by: Nirokx <[email protected]>
2026-11-03 18:34:32 +01:00
Ludovic MarieandNirokx 792fa822f4 Add fullbright_nofog 1.0.0 (#167)
Submitted by @Nirokx through the form.

Submission: slTmWEe55jWWepzv9ESpVWpyu4VvTiF0Jcl-9vNxfoo

Co-authored-by: Nirokx <[email protected]>
2026-11-03 18:34:26 +01:00
Ludovic Marieandsoufied 7b65e9b10f Update appleskin 0.1.2 (#163)
Submitted by @soufied through the form.

Submission: 3Qw5P2ElK53IqvJ2sEHjEZ-MUba5C0tz8aAXwIK23OA

Co-authored-by: soufied <[email protected]>
2026-11-03 18:34:21 +01:00
Ludovic Marieandumbx-dot cd1f1c3970 Add flair 1.0.0 (#199)
Submitted by @umbx-dot through the form.

Submission: XpkshdIFDfLtP-GXJPDX1VGRlBjjT8geDr-JKyATzvk

Co-authored-by: umbx-dot <[email protected]>
2026-11-03 18:32:11 +01:00
Ludovic Marieandrepositorykreml1n be266234cb Update basic_hud 0.4.1 (#198)
Submitted by @repositorykreml1n through the form.

Submission: 9rPRCH2NULh3Iy5kRA1ctwN9shoKNDOc0jW7uTbaeNo

Co-authored-by: repositorykreml1n <[email protected]>
2026-11-03 18:32:06 +01:00
Ludovic MarieandLutziano888 cf1f608745 Add accurate_block_placement 1.0.0 (#196)
Submitted by @Lutziano888 through the form.

Submission: o1TJGn5bAsBJA0ZlAhulN0DVgOliaVK0rD2jxCIXlR8

Co-authored-by: Lutziano888 <[email protected]>
2026-11-03 18:31:59 +01:00
Ludovic MarieandMayonesu0 f1f5aba33a Add chop 1.0.1 (#194)
Submitted by @Mayonesu0 through the form.

Submission: ABM9UnrYSccp3Lk313QYJcJI19WMsFz5VGL2n2tr1bQ

Co-authored-by: Mayonesu0 <[email protected]>
2026-11-03 18:31:54 +01:00
Ludovic Marieandtaxtii 0062a1e27a Add hitbox_plus 1.3.0 (#193)
Submitted by @taxtii through the form.

Submission: rkAkGw06AMsDZfPvNUJJT6YIwW6BWpiWu_teeKlJdJU

Co-authored-by: taxtii <[email protected]>
2026-11-03 18:31:49 +01:00
Ludovic Marieandtariksabanuz e79b92ff2c Add resourcify 0.1.0 (#189)
Submitted by @tariksabanuz through the form.

Submission: z-ohfvwLJTjWO_6m0RzyB-KvPLI-vNMmUkZ-vVnklR8

Co-authored-by: tariksabanuz <[email protected]>
2026-11-03 18:31:43 +01:00
Ludovic Marieandtariksabanuz ce14e90beb Add mod_menu 0.3.0 (#188)
Submitted by @tariksabanuz through the form.

Submission: YwrDR2k6vkOZjxp4ucVa-sBOHH2mUnBd45UuaIDYwys

Co-authored-by: tariksabanuz <[email protected]>
2026-11-03 18:31:38 +01:00
Ludovic MarieandJustKarkat 93b3f04390 Update crossversion 0.8.0 (#186)
Submitted by @JustKarkat through the form.

Submission: v_bcduqKfPvkdT3qbeHXZ9scM3YNOSvwoWEXL7agSes

Co-authored-by: JustKarkat <[email protected]>
2026-11-03 18:30:54 +01:00
Ludovic MarieandClaude Opus 5.5 bc40e7bafe Take crossversion 0.7.1 down (revoked.toml) (#185)
* Revert "Add crossversion 0.7.1 (#180)"

This reverts commit 7645804495: the listing
comes down while the mod's account handling is reviewed with its author.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* revoked: turn installed crossversion 0.7.1 copies off

Removing the entry stops new installs only; the revocation also turns
copies players already installed off, with the reason shown to them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Keep the crossversion listing; the revocation alone takes 0.7.1 down

The registry refuses removing a listing ("take its files down in revoked.toml instead").

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 05:04:42 +01:00
Ludovic MarieandClaude Opus 5.5 5628be3e6b voicechat-server: proximity voice chat on Horizon's own server (native server mod) (#173)
* 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]>

* 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]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 04:16:31 +01:00
Ludovic MarieandClaude Opus 5.5 58fbad4a85 SMP 0.31.0: the auction house lists whole stacks (#183)
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 04:04:06 +01:00
Ludovic MarieandJustKarkat 7645804495 Add crossversion 0.7.1 (#180)
Submitted by @JustKarkat through the form.

Submission: eL0F1_shzScYc6vd_ujpkodYloPjMgUo2cfokvjAw28

Co-authored-by: JustKarkat <[email protected]>
2026-11-03 02:51:04 +01:00
levzzzandClaude Opus 5.5 e5d840dabf Add Motion Blur (native mod) (#171)
* Add Motion Blur (native mod, levzzz5154/horizon-motion-blur)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* motion_blur: the registry's current entry format

source and a [[versions]] entry instead of repo; the file is in the
staging release as <sha256>.hnmod, as the submission form puts it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-03 00:58:53 +05:00
63 changed files with 4363 additions and 204 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
Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+31
View File
@@ -0,0 +1,31 @@
id = "accurate_block_placement"
kind = "native-mod"
name = "Accurate Block Placement"
summary = "Hold right click with a block and every new spot you look at gets one, however fast you move."
description = """
Placing blocks fast is all about timing: vanilla places a block every four ticks while you hold right click, so moving fast skips spots and moving slowly stacks extra blocks. **Accurate Block Placement** times it for you: hold right click and every *new* spot you look at gets a block at once.
- **A new spot, a block**: bridging, building walls and floors, or sweeping along a row; no spot skipped, however fast you move.
- **No extra blocks**: staring at one spot places nothing more.
- **A single click stays a single block**: right after the click it waits as vanilla does, unless you turn the view.
- **Building backwards**: walking backwards while placing toward yourself places one block per step.
- Doors, chests, buttons and other blocks you click still work as usual.
**Settings** (Options > Mods > Accurate Block Placement): turn it on or off, bind a key to toggle it (unbound by default), and choose whether it works on servers.
Some servers see fast, accurate placing as a cheat: check their rules, or turn "Allowed on servers" off.
A port of [Accurate Block Placement: Reborn](https://modrinth.com/mod/accurate-block-placement-reborn) (MIT) by Clayborn, Flourick, schwar and KadTheHunter, rewritten in Rust for Horizon."""
authors = ["Lutziano888"]
owners = ["Lutziano888"]
license = "MIT"
source = "https://github.com/Lutziano888/horizon-accurate-block-placement"
icon = "https://raw.githubusercontent.com/Lutziano888/horizon-accurate-block-placement/main/icon.png"
[[versions]]
version = "1.0.0"
published = "2026-11-03"
game = "26.3"
file = "accurate_block_placement-1.0.0.hnmod"
sha256 = "e631745f6d267360de6eb51995ee995bf1ab3d64393d287b7b93c332b85939ce"
changelog = "First release."
+14 -1
View File
@@ -13,9 +13,22 @@ authors = ["soufi"]
owners = ["soufied"]
license = "MIT"
source = "https://github.com/soufied/AppleSkin-Horizon"
icon = "icons/appleskin.png"
icon = "https://raw.githubusercontent.com/soufied/AppleSkin-Horizon/refs/heads/main/icons/appleskin.png"
key = "RWT6eNq4e5WfVDA2lzOOi6XCm/+jJgilqT1+HUUM33ie5MCjcRfSg760"
[[versions]]
version = "0.1.2"
published = "2026-11-02"
game = "26.3"
file = "appleskin-0.1.2.hnmod"
sha256 = "627d1e7c4b4fa3dd191a4ff5887fe38b20ceb7d7d7f1197c1e03d1f25b6b6b92"
signature = """
untrusted comment: signature from minisign secret key
RUT6eNq4e5WfVJqiP4tt8vQGW6sVLSxiLXLeVunyGuyfS9aZWqPilg77biu/2B+HUDS1FlBE31F/HOhBR2YWy0GSmdwhUO7xNAs=
trusted comment: timestamp:1793633868\tfile:appleskin-0.1.2.hnmod\thashed
IW9M2qJIktmQDlARS6ZTJ1IA9BoGCfMZvatnjIlSpVPkpeG/Qpr5QKRRRPFhnZa93a5KcUG34ok+cgFxipRjBg=="""
changelog = "animation fix"
[[versions]]
version = "0.1.1"
published = "2026-10-31"
+52 -13
View File
@@ -1,29 +1,68 @@
id = "basic_hud"
kind = "native-mod"
name = "The Basic HUD"
summary = "Effect timers, potions, golden apples and totems carried, totems used by each player, and armour, elytra and shield durability."
summary = "A visual HUD editor with movable widgets, FPS, coordinates, effect timers, cooldowns, mini armour and a customizable Tab list."
description = """
A small HUD box in a corner of the screen.
![The Basic HUD](https://raw.githubusercontent.com/repositorykreml1n/the-basic-hud/v0.4.1/assets/cover-0.4.1.png)
## What it shows
## Your HUD, your layout
- Active effects with their time left. Potion effects are pinned by default; any other effect can be pinned on the mod's settings page, or all of them shown at once.
- Potions of each kind, golden apples, enchanted golden apples and totems in your inventory.
- Totems each player has used, above that player's name. Your own count is in the box.
- Durability of worn armour, elytra and a held shield.
Arrange 13 independent widgets directly over the game. Drag, resize and style each block with colours, opacity, padding and shadows. Use full labels or compact icons with useful values such as ×16 and 1:45.
The count of a player's totems resets when they die or when you leave the world.
Includes effects, consumables, durability, observed totem uses, available player-health data, item cooldowns, coordinates, FPS, ping, optional speed/CPS/clock, and mini armour next to the hotbar. Customize the Tab list with coloured numeric ping, bars, counts, sorting, scale and opacity.
## What it does not do
Ten interface languages plus Auto. A short first-visit guide can be replayed through ?. Reset requires confirmation; leaving unsaved edits offers Save / Discard changes / Stay. Failed writes retain the draft.
- It builds on one Horizon commit only, 68ac6f6 (Minecraft 26.3). Counting totems and writing above a player have no hook point, so it hooks two of the game's own functions (totem_event, name_tag_geometry). The rest uses hook points but reads the game's internal data.
- It knows only what the client is told: a totem used out of your sight is not counted.
- Its own texts are in English. Effect and item names follow the game's language.
- Tested on Windows in single player and over LAN with two clients, not on a dedicated server."""
## Install and compatibility
Native .hnmod for Minecraft 26.3, pinned to Horizon **68ac6f647e7022b2c696e81863d7cface28c9416**. Install through the launcher and select **Performance**. Dev can overflow the startup stack on this Windows baseline. Other Nightlies require separate verification.
Client-side only. Unknown player health remains unknown, unseen totem uses cannot be counted and server-plugin-only timers are unavailable. No server mod is required. No FPS improvement is promised. Native mods compile into the game and run with the player’s rights, without a sandbox. Tested on Windows; other platforms were not tested.
## Other languages
**Русский:** редактор поверх игры, размер и оформление каждого блока, значки с числами, FPS, координаты, кулдауны, мини-броня и настройка Tab. Обучение один раз; подтверждение сброса и выхода с правками.
**Українська:** редактор поверх гри, розмір і стиль кожного віджета, значки зі значеннями, FPS, координати, час відновлення, мініброня та налаштування Tab. Навчання під час першого входу й захист незбережених змін.
**Deutsch:** Editor direkt im Spiel, frei verschiebbare und skalierbare Widgets, Symbole mit Werten, FPS, Koordinaten, Abklingzeiten, Rüstungsanzeige und anpassbare Tab-Liste. Einführung beim ersten Besuch und Schutz ungespeicherter Änderungen.
**Français :** éditeur superposé au jeu, widgets déplaçables et redimensionnables, icônes avec valeurs, FPS, coordonnées, délais de récupération, petite vue de l’armure et liste Tab personnalisable. Tutoriel initial et protection des modifications non enregistrées.
**Español:** editor superpuesto al juego, widgets movibles y ajustables, iconos con valores, FPS, coordenadas, tiempos de recarga, vista pequeña de la armadura y lista Tab personalizable. Tutorial inicial y protección de cambios sin guardar.
[Full update notes in six languages](https://github.com/repositorykreml1n/the-basic-hud/blob/main/CHANGELOG.md)
MIT. Not an official Horizon, Mojang or Microsoft product. Cover is a generated promotional illustration, not a gameplay screenshot or measured FPS claim."""
authors = ["repositorykreml1n"]
owners = ["repositorykreml1n"]
license = "MIT"
source = "https://github.com/repositorykreml1n/the-basic-hud"
icon = "https://raw.githubusercontent.com/repositorykreml1n/the-basic-hud/main/icon.png"
[[versions]]
version = "0.4.1"
published = "2026-11-03"
game = "26.3"
file = "basic_hud-0.4.1.hnmod"
sha256 = "36431d3a2d810e2aefbd8611767a80f9184e3df032e4cc2b9b9fd9398f794560"
changelog = """
Major update from 0.1.0 to 0.4.1.
- Visual in-game editor: drag, individual scale, colours, opacity, shadows, padding and snap.
- 13 widgets, including FPS, coordinates, available player health and actual item cooldowns.
- Icons + values: ×16, 1:45 and durability without long labels.
- Mini armour beside the hotbar, including elytra.
- Custom Tab list: numeric ping, bars, player counts, sorting and scale.
- Smoothed optional speed; single-player ping labelled Local.
- Ten UI languages plus Auto; first-visit tutorial, replay through ?.
- Reset confirmation; Save / Discard changes / Stay on leaving with edits; failed-save recovery.
Pinned to Minecraft 26.3 / Horizon 68ac6f6. Use the Performance build profile on Windows; Dev can overflow the startup stack on this baseline. No compatibility with other Nightlies or FPS gain is claimed.
Validated: 67 Rust test executions, native build, isolated gameplay/editor tests, restart persistence and ordinary asynchronous startup.
Full notes (English + Russian, Ukrainian, German, French and Spanish summaries): https://github.com/repositorykreml1n/the-basic-hud/blob/main/CHANGELOG.md"""
[[versions]]
version = "0.1.0"
+10 -2
View File
@@ -1,14 +1,22 @@
id = "blockscope"
kind = "native-mod"
name = "Blockscope"
summary = "The block under your crosshair: its name, id, distance and icon, with reach, placement and opacity on its own settings page. A Jade-style tooltip for Horizon."
description = "Jade-style block info for Horizon: the block under your crosshair, its name in your language, its block id, how far it is and its icon."
summary = "The block under your crosshair: its name, id, distance and icon, plus hardness, states and a container's slots."
description = "A Jade-style tooltip for Horizon: the block under your crosshair with its name in your language, block id, distance and icon, plus its hardness and the tool it wants, its state properties, a crop's growth and, for a container, its slots. Ten switches on its own settings page."
authors = ["Domanffe"]
owners = ["Domanffe"]
license = "MIT"
source = "https://github.com/Domanffe/blockscope"
icon = "https://horizon-rust.github.io/horizon-mods/icons/blockscope.png"
[[versions]]
version = "0.3.1"
published = "2026-11-02"
game = "26.3"
file = "blockscope-0.3.1.hnmod"
sha256 = "4fea94e5337aa82df3d981fc6231fecc80161262add577b596bb6661e0e303d1"
changelog = "Rebuilt on the 2026-11-01 Nightly (Horizon `4aa6854`). All five hook points are named now — `mods`, `settings`, `settings_click`, `hud`, `tick` — so the bundle still applies when the game moves them."
[[versions]]
version = "0.3.0"
published = "2026-10-30"
+9 -6
View File
@@ -2,18 +2,21 @@ id = "chatheads"
kind = "native-mod"
name = "ChatHeads"
summary = "A player's head left of every chat line they wrote."
description = """
Each chat line from a player gets their 8x8 skin face in the strip to its left, like the Chat Heads Java mod. Lines without a player (system messages, /me, wrapped continuations) stay as they are; the face fades with its line.
Settings: Options > Mods > ChatHeads > Settings (on or off).
Known limit: the chat's hit-test does not know about the 10 px indent, so clicking a link in chat lands 10 px left of its pixels."""
description = "Each chat line from a player gets their 8x8 skin face in the strip to its left; lines without a player stay as they are, and the face fades with its line. The switch lives in Options > Mods."
authors = ["Domanffe"]
owners = ["Domanffe"]
license = "MIT"
source = "https://github.com/Domanffe/chatheads"
icon = "https://horizon-rust.github.io/horizon-mods/icons/chatheads.png"
[[versions]]
version = "0.1.1"
published = "2026-11-02"
game = "26.3"
file = "chatheads-0.1.1.hnmod"
sha256 = "16f5e1db33255c550c087fcf3ed982a4be9abf61b547b470a08f471d309ad1fe"
changelog = "Rebuilt on the 2026-11-01 Nightly (Horizon `4aa6854`). The three hook points it shares — `mods`, `settings`, `settings_click` — are named now instead of pinned to raw source targets, so a Horizon update that moves them no longer breaks the bundle."
[[versions]]
version = "0.1.0"
published = "2026-10-30"
+41
View File
@@ -0,0 +1,41 @@
id = "chop"
kind = "native-mod"
name = "Chop!"
summary = "Cut one log and the whole tree tips over, crashes down and breaks apart, with soft vanilla sounds and particles."
description = """
Cut one log with an axe: the whole tree tips over like a real falling tree, crashes to the ground, and breaks apart from the base to the top in little pops. Everything is made of vanilla sounds and particles: no resource pack.
- **The log shakes while you cut it**, then pops when it gives way.
- **A physical fall**: the trunk drops onto the stump, creaks, tips over with gravity and stops where it meets the ground. Leaves in the way are crushed.
- **Falls in front of you**, within an adjustable angle, toward the clearest side.
- **A cute break-up** from the base to the top, with clouds, sparkles and hearts.
- **Replants a sapling** of the tree's kind (from its loot, or always).
- **Chain reactions**: a falling trunk can knock over the next tree.
- **Adjustable damage**: hurts monsters by default; animals, allies and players are spared unless you turn them on.
- Vanilla loot (Fortune, Silk Touch), honest axe wear, fall preview, shaking trees with an empty hand, giant mushrooms and Nether fungi, angry bees, birds, apples, biome-tinted leaves, optional loot into your inventory.
- **Everything is configurable in game**, with commands or by clicking in the chat: `/chop config` (operators), `/chop perso` (each player's sounds, particles and preview), `/chop stats`, `/chop aide`. The menus are in French.
**What it does and doesn't do.** It runs server-side, where the world runs: players who join install nothing. No network access, no telemetry, never runs a program. It reads and writes one file, `<world>/data/chop.txt` (settings, stats, player-placed logs). It only touches trees, the sapling it replants, its own `block_display` entities and what a falling tree hits. Besides the game's functions, it runs a few vanilla commands as the server: `kill` on its own tagged displays, `item modify` for the axe's wear, `damage` on a player the tree hits (off by default), and `summon item` / `setblock … keep` for loot and replanting when the tree is in another dimension.
Native source mod: install it on the launcher's Mods page, press Build there, and confirm the native-code warning. The launcher builds a separate game with it, and it runs with your account's permissions. Each version is pinned to one Horizon Nightly and Horizon Mixins revision, named in its changelog; the launcher builds those exact revisions.
Original mod by Mayonesu0. All rights reserved, except the grants in its [LICENSE](https://github.com/Mayonesu0/chop/blob/main/LICENSE): players may use it unchanged, Horizon's registry may distribute it, and Horizon's reviewers may read it to review it."""
authors = ["Mayonesu0", "Claude"]
owners = ["Mayonesu0"]
license = "LicenseRef-Chop-1.0"
source = "https://github.com/Mayonesu0/chop"
icon = "https://raw.githubusercontent.com/Mayonesu0/chop/main/registry/icon.png"
key = "RWSwx1v4zUCUjQ8rssfZCXvXkvgVc9TbA60RJyCtVCeMx4lSTanIleIs"
[[versions]]
version = "1.0.1"
published = "2026-11-03"
game = "26.3"
file = "chop-1.0.1.hnmod"
sha256 = "5abac195c54da7716f35f641489c0dba780444c91b0b3e4f3387ecd4cb2b260b"
signature = """
untrusted comment: signature from minisign secret key\r
RUSwx1v4zUCUjfR+WYS97be0UecaBcKGyNXNsCQAPVG0j2U2yeMfnNPkWMEU9CMOobMI6nKaPj/Kqv0EPAuw2xiPnRaduNgI8wA=\r
trusted comment: timestamp:1793711491\tfile:chop-1.0.1.hnmod\thashed\r
eOi2t+LMQ4LOM/nsJqmpvAraWsLcgdRZA4OJotjBkgxcANkG3MeZ9tbAKbEaidIy1kBSZyc4Dri+3MtOsd+xBA=="""
changelog = "First release (1.0.0 was never published: its package carried a LICENSE file the registry's checker refuses; the code is the same). Pinned to Horizon Nightly 2026-11-01 (c9d474d) and Horizon Mixins ddd9700; the launcher builds those exact revisions."
+41
View File
@@ -0,0 +1,41 @@
id = "crossversion"
kind = "native-mod"
name = "Cross Version"
summary = "Allows version hopping offline."
description = """
Hop versions, offline.
Uses ViaProxy as an inbetween for now."""
authors = ["JustKarkat"]
owners = ["JustKarkat"]
license = "MIT"
source = "https://github.com/JustKarkat/horizon-crossversion/"
icon = "https://github.com/JustKarkat/horizon-crossversion/blob/master/icon.png?raw=true"
key = "RWTcPJHJYjPW0pV3lPwr45crCu0wJxW5HbDLc3fr3Eom1TzxO++kHOo4"
[[versions]]
version = "0.8.0"
published = "2026-11-03"
game = "26.3"
file = "crossversion-0.8.0.hnmod"
sha256 = "5b371f1b7b3582e0c92aa1dc22b491b50c024fe95833aa2bd9457189631caafd"
signature = """
untrusted comment: signature from rsign secret key
RUTcPJHJYjPW0vc8h9pG+LC/9EkYg60UM28gNkxJ31iDOXapyV80qqnB9H12OuOtzBmPkbB33fkh0IKE5O8zcCzat0jyj6Tpqw8=
trusted comment: timestamp:1793679858\tfile:crossversion-0.8.0.hnmod\thashed
3hY5NK517bzva/2q54Pmj9QXO3z6WF0WWMfuA8T9H8qCuOe6TYnmYB5eEagRMpZUNK/ICcrfPEx+DE303XIOAQ=="""
changelog = "Offline mode only. "
[[versions]]
version = "0.7.1"
published = "2026-11-02"
game = "26.3"
file = "crossversion-0.7.1.hnmod"
sha256 = "deb9cf8a84838e6c48999f70fbd3c81db90992c456d6e85a0e0746b088c5b8fa"
signature = """
untrusted comment: signature from minisign secret key
RUTcPJHJYjPW0nmeON4u6rxOS7AxK+s/e3kPaLkJ3lm618iPPc8oP8nWdJQ8k9yYriSZSu091TZ5m7qX56nNovAjeZyTcOdrUAk=
trusted comment: timestamp:1793657606\tfile:crossversion-0.7.1.hnmod\thashed
q2Oy6yVv5T+GHc2iM+HxNDImfPcaKGV/XPToNCTQFHSTKJ8+rz5IV4LfJnOBwBp1rZQZoGkExopDrXfZyQybCQ=="""
changelog = """
Version: 0.7.1
Changelog: The MIT notice now ships inside the .hnmod (it is the header of the bundled engine source). The settings page's 'Delete the downloaded ViaProxy' deletes the jars only, so the account added to ViaProxy and its refresh token in saves.json stay. Also: a version switch on Edit Server and Direct Connect: choose what version ViaProxy tells that server it is (auto, 1.8, 1.9, 1.12.2, 1.16.5, 1.20.1, 1.21.1, 1.21.4, 26.3), kept per address, for servers whose status reports the wrong version."""
+28
View File
@@ -0,0 +1,28 @@
id = "customhud"
kind = "native-mod"
name = "Beautiful Day Counter"
summary = "A lightweight day counter, clock and day/night cycle bar for the HUD, with an in-game settings screen."
description = """
Beautiful Day Counter for Horizon, a native mod.
Shows the current survival day, an in-game clock and a color-shifting bar that follows the day/night cycle, without changing gameplay. It is a port of the author's own Fabric/NeoForge mod [CustomHUD](https://github.com/sonoFrangu/CustomHUD), with the same HUD and the same settings screen.
- Day counter with the game mode icon; a golden apple or totem, a rainbow counter and a sound on milestone days (50, 100, 365, every 100).
- 12-hour or 24-hour clock; the clock icon turns into a bed when you can sleep.
- Day/night bar with four color themes (Classic, Pastel, Colorblind, Mono), night marks, countdown to night or day, moon phase.
- Top or bottom placement, size, opacity, offsets, optional background; vanilla boss bars move below the HUD when it is at the top.
- Settings screen opened with H (or from Options > Mods): Elements, Look and Behavior, with tooltips.
- English, Italian, German, Spanish, French and Portuguese."""
authors = ["SCARABOCCHIO100"]
owners = ["sonoFrangu"]
license = "MIT"
source = "https://github.com/sonoFrangu/CustomHUD-Horizon"
icon = "icons/customhud.png"
[[versions]]
version = "1.6.0"
published = "2026-10-31"
game = "26.3"
file = "customhud-1.6.0.hnmod"
sha256 = "ab255d3c722b40f4d068a762857d6c027c11381308bfe23196e2974307814a7f"
changelog = "First release for Horizon: the CustomHUD 1.6.0 HUD and settings screen as a native mod."
+24
View File
@@ -0,0 +1,24 @@
id = "flair"
kind = "native-mod"
name = "flair"
summary = "Custom accessory mod"
description = "A cosmetics catalog for Horizon. Open it from the title screen or the pause menu and wear HD capes that move like cloth, animated bandanas with Japanese script, hats, wings, shoulder pets and auras with colour themes. Turn your player in a live preview, hide your armor, and add your own capes and Blockbench cosmetics with animations. Only you see them."
authors = ["_idom_"]
owners = ["umbx-dot"]
license = "MIT"
source = "https://github.com/umbx-dot/horizon-flair"
icon = "https://raw.githubusercontent.com/umbx-dot/horizon-flair/main/icon.png"
key = "RWT9Ujo/oCprPGHHiSLHFNOJV1ZzoJdAU6EdZyiQcBwT2iMXfiYms4hD"
[[versions]]
version = "1.0.0"
published = "2026-11-03"
game = "26.3"
file = "flair-1.0.0.hnmod"
sha256 = "64e4d645d5bda3c363603d483af2095d6d3673d91803954f338d54265bf4fab5"
signature = """
untrusted comment: signature from minisign secret key\r
RUT9Ujo/oCprPBIbqV9rmrjRgpF9gnCIL+4FlZXSTq0Qi4kkklSKfjNDx9YFIw1DuufGVSL3+lOieSX4Fx0nRl7j/CCUNjiWIQs=\r
trusted comment: timestamp:1793723044\tfile:flair-1.0.0.hnmod\thashed\r
QPCGNEAWFCaZLdZLgkUVY9v+hRP4/gJe4H2hCMh7YT7k/V4tynkQ0AM3hZqEdrBoPn4q5SFHDVIcloGkqCTzDw=="""
changelog = "Initial Release."
+25
View File
@@ -0,0 +1,25 @@
id = "fullbright_nofog"
kind = "native-mod"
name = "Fullbright & NoFog"
summary = "Toggle full brightness and the world's fog with a key (G / N), /fullbright or /nofog."
description = """
A native Horizon mod: the launcher builds it into the game (install it on the launcher's Mods page, then press Build there). Native mods are not sandboxed and run with your account's rights, so read the source before you build it. Uses only Horizon's native hook points (hooks 80, for `render_fog`); tested on Nightly 2026-11-01 (c9d474d) and horizon-mixins 0d6b103.
- **Fullbright**: press G (or `/fullbright`, `/fb`) to toggle full brightness (gamma 1500%). Your brightness slider and `options.txt` are never changed; turning it off gives your own brightness back.
- **NoFog**: press N (or `/nofog`) to remove the world's fog. `/nofog fluids` also clears water, lava and powder snow fog.
- `/fullbright on|off` and `/nofog on|off` set them directly.
- Options → Mods → Fullbright & NoFog: a button for each toggle and both keys, rebindable.
Toggling is silent (no chat messages). Client-side: works in single player, on LAN and on servers. Settings are remembered between sessions."""
authors = ["Nirokx"]
owners = ["Nirokx"]
license = "MIT"
source = "https://github.com/Nirokx/horizon-fullbright-nofog"
[[versions]]
version = "1.0.0"
published = "2026-11-02"
game = "26.3"
file = "fullbright_nofog-1.0.0.hnmod"
sha256 = "07a13abd8af2405343241f8985db7595a03bfb8ced2f1dfab8a105848eb1b7d7"
changelog = "First release."
+38
View File
@@ -0,0 +1,38 @@
id = "hitbox_plus"
kind = "native-mod"
name = "Hitbox+"
summary = "Customizable entity hitboxes with exact weapon & spear in-reach colors, thickness, eye rays, and sprint HUD."
description = """
Hitbox+ for Horizon
Hitbox+ is a high-performance native mod ported to Horizon that revamps vanilla F3+B debug hitboxes with full customization and PvP reach indicators.
Key Features:
Dynamic In-Reach Detection: Hitboxes automatically change to your chosen reach color when your crosshair is within hitting distance.
Spear Reach: Accurately extends reach to 4.5 blocks (survival) and 6.5 blocks (creative).
Melee Reach: Standard 3.0 blocks for swords, axes, maces, and fists.
11 Selectable Reach Colors: Choose between Bright Green, Neon Cyan, Aqua Mint, Gold Yellow, Sunset Orange, Hot Pink, Neon Purple, Electric Blue, Crimson Red, Pure White, or smooth dynamic Rainbow.
Hitbox Styles & Thickness: Custom styles (White, Cyan, Yellow, Red, Rainbow) and 3 thickness modes (1.5px Thin, 2.5px Default, 4.0px Thick).
Eye-Ray & Looking Direction: Toggle looking direction lines and forehead level markers.
Always-On Sprint HUD: Built-in sprint status indicator with customizable screen corner placement (Top-Left, Top-Right, Bottom-Left, Bottom-Right).
Zero FPS Cost: Pure lock-free atomic architecture.
Controls:
Press H to toggle hitboxes on/off.
Access Options > Mods > Hitbox+ to customize all colors, thickness, and HUD positions."""
authors = ["taxtii", "itzglecni"]
owners = ["taxtii"]
license = "MIT"
source = "https://github.com/taxtii/hitbox_plus.git"
[[versions]]
version = "1.3.0"
published = "2026-11-03"
game = "26.3"
file = "hitbox_plus-1.3.0.hnmod"
sha256 = "8d1b95e7461e38fc40994b35a82c1f753a58e9751ce27af1dc101a4717dba8a4"
changelog = """
- Added dynamic spear attack reach support (4.5 blocks in survival, 6.5 blocks in creative with 0.125 hitbox margin).
- Standard melee weapons (swords, axes, fists) use exact 3.0-block in-reach detection.
- Added 11 selectable reach color options (Bright Green, Neon Cyan, Aqua Mint, Gold Yellow, Sunset Orange, Hot Pink, Neon Purple, Electric Blue, Crimson Red, Pure White, Rainbow).
- Removed red hit flashing when attacking entities.
- Zero-overhead lock-free atomic rendering with configurable line thickness, eye rays, and always-on toggle sprint HUD."""
+31
View File
@@ -0,0 +1,31 @@
id = "horizon_physics"
kind = "native-mod"
name = "Horizon Physics"
summary = "Physics for blocks, mobs, items, snow, water, smoke, capes and more, with its own settings screen."
description = """
By tariksabanuz. Physics for Horizon in the spirit of PhysicsMod: broken blocks fall apart into pieces, mobs die as
ragdolls, snow gives way under your feet, water waves, smoke rises and plants sway in the wind.
- Broken blocks crumble into pieces (blocky, particles, crumbled voxel or fractured). Mobs die as ragdolls or come apart.
- Structures and trees fall when what holds them up is broken. Dropped items tumble and settle.
- Snow layers are pressed down by you and the mobs, water has waves that boats (and you in them) ride, and leaves, plants and vines sway.
- Hit a ragdoll or a piece and it flies off; grass and flowers bend away as you walk through them.
- Fire, campfires and lava give off smoke and embers that gather and darken under ceilings, capes and banners are cloth, doors swing open and shut, leads and fishing lines are ropes.
- Rain splashes and rings the water, snow falls, and the wind blows harder with the weather.
- A Physics Settings button in Options opens a screen with a page for every part, and a Quick Setup the first time you start.
Needs the Performance build in the launcher. Inspired by PhysicsMod by Haubna; this is a clean-room version for
Horizon and contains none of its code or assets. It uses your own game's textures at runtime, and the icon is my own."""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
license = "MIT"
source = "https://github.com/tariksabanuz/PhysicsModHorizon"
icon = "icons/horizon_physics.png"
[[versions]]
version = "0.15.1"
published = "2026-11-03"
game = "26.3"
file = "horizon_physics-0.15.1.hnmod"
sha256 = "d7cf1201f0a47a852aa1a4c9684ef9e658ec3c86852104d44716d05f11281ca3"
changelog = "First release: block, mob, item, snow, water, smoke, cloth, weather and collapse physics, with a Physics Settings screen."
+64
View File
@@ -0,0 +1,64 @@
id = "horizonmatica"
kind = "native-mod"
name = "Horizonmatica"
summary = "Litematica for Horizon: schematics as ghost blocks, a verifier, material lists and a printer that builds the missing blocks for you."
description = """
Horizonmatica is a re-creation of **Litematica** (with its **printer**) for Horizon. Load a schematic, see it as ghost blocks in your world, check your build against it, get a material list, and let the printer place the missing blocks.
## Features
- **Formats:** `.litematic` (versions 1–7, several regions), `.schem` (Sponge v1–v3: WorldEdit, FAWE, Amulet, Axiom), `.schematic` (MCEdit / WorldEdit up to 1.12) and vanilla structure `.nbt`. Older files are upgraded with Horizon's data fixer, the same way old worlds are.
- **Overlay:** missing blocks as translucent real block models (chests, banners, heads and other block entities included), optionally visible through walls.
- **Verifier:** wrong block (red), wrong state (orange), extra block (pink), plus a progress HUD.
- **Layers:** all / one layer / above / below / range.
- **Placement:** move, rotate, mirror; a move mode for mouse wheel and arrow keys. Each placement is remembered per world and dimension and restored when you join again.
- **Material list:** missing, total and carried, with item icons, in the HUD, in chat or in the menu.
- **Menu (M):** a screen of its own, drawn with the game's widgets.
- **Printer:** places one missing block within reach every few ticks, from the bottom up. It only clicks when Horizon's own placement logic says the click produces exactly the wanted state (stairs, slabs, logs, furnaces…). Redstone-aware order, repeater, comparator and note block setup after placing, double slabs and stacks, chests, falling blocks. The camera never moves.
Usage: put schematics into `schematics/` next to `options.txt` of your instance (`/hm folder` shows the path), then `/hm load <name>` or press **M**. All commands and keys are in the [README](https://github.com/keksi2k/horizonmatica#usage).
## What it does and doesn't do
- **Reads** only files in the instance's `schematics/` folder, and creates that folder if it's missing.
- **Writes** nothing itself: settings and remembered placements go through Horizon's mod settings.
- **Network:** no connections of its own. The only thing sent to a server is what the printer does: the same block-placing clicks a player would make (with the matching look direction), and hotbar slot changes.
- **"Open folder"** in the menu opens the schematics folder in your file manager through Horizon's own function.
- No telemetry, no downloads, no code run from schematic files. Schematics are parsed as data only.
⚠️ On other people's servers, automatic placing may break the rules or trip anti-cheat. The printer is off until you turn it on (`K` or `/hm printer on`). Its *air place* option (on by default) is noticed by anti-cheat plugins such as Grim.
**Version:** native mods use game internals, so Horizonmatica is pinned to one Horizon commit (currently Nightly 2026-11-01, `4aa6854`). A new Nightly needs a new release.
## Credits
The idea, the way it's used, the file format and the behaviour come from **[Litematica](https://github.com/maruohon/litematica)** by masa (maruohon), maintained by **[Sakura-Ryoko](https://github.com/sakura-ryoko/litematica)**, and from **[Litematica Printer](https://github.com/aleksilassila/litematica-printer)** by aleksilassila. Horizonmatica is not an official project of these authors, isn't made or endorsed by them, and contains none of their code. Only the file format and the behaviour are re-created. If you like it, go and support the originals.
Menus built with [horizon-menus](https://github.com/keksi2k/horizon-menus). MIT licensed."""
authors = ["keksi2k"]
owners = ["keksi2k"]
license = "MIT"
source = "https://github.com/keksi2k/horizonmatica"
icon = "https://raw.githubusercontent.com/keksi2k/horizonmatica/main/icon.png"
key = "RWS+BRoCJslNCFapck3cbG2VFQcjlr1MUH5m3+CWA8zhJecoBoja7X94"
[[versions]]
version = "0.1.1"
published = "2026-11-02"
game = "26.3"
file = "horizonmatica-0.1.1.hnmod"
sha256 = "2ddc562dd2ea0f9a76a47eb761c64c803def68a8c81b92cb5d15ec26c5d432aa"
signature = """
untrusted comment: signature from minisign secret key\r
RUS+BRoCJslNCOkWo88+wHXYmMr1C6TzXpxZIGwzFuUN+K4lbd9RT6tLC8sIQQ5uD3QZQZx2rawTHRJvUvRhCgUNDoqhIek18w4=\r
trusted comment: timestamp:1793599192\tfile:horizonmatica-0.1.1.hnmod\thashed\r
BmryY+OjbG0qZnnFappBEO2tR8zx+a5BmqBFA4AVytjqvLnuvDWYE0PUwZLLodqbN218NM0JluVGpstnI3DvBQ=="""
changelog = """
First release in the registry.
- Loads .litematic, .schem (Sponge v1–v3), .schematic (MCEdit, 1.12 ids) and structure .nbt; older files are upgraded with Horizon's data fixer
- Ghost overlay with real block models, see-through mode, verifier colours and layer modes
- Placements remembered per world and dimension; move mode, rotate, mirror
- Own menu screen (M) and a material list with item icons
- Printer: places missing blocks within reach, checked against the game's own placement logic; the camera never moves
Since 0.1.0 on GitHub: loading another schematic no longer shows the previous one's blocks.
Built for Horizon Nightly 2026-11-01 (c9d474d, hook API 80)."""
+39
View File
@@ -0,0 +1,39 @@
id = "inventory_wizard"
kind = "native-mod"
name = "InventoryWizard"
summary = "InventoryWizard's one-click sorting as a native mod for Horizon"
description = """
## Features
- **Smart sorting** by category (stone, earth, wood, ores, blocks, tools, weapons, armor, food, redstone,
transportation, decoration), from item tags and components, so other mods' items are sorted too
- **Survival-friendly hotbar**: weapons, tools, food, building blocks, utility items, then the rest
- **Stacks merged**, but enchanted or named items are never combined
- **Three modes**: Default, Alphabetical and Stack-based
- **Sort buttons** by the inventory and container titles, plus a mode button (it shows the mode's letter)
- **Locked slots**: **Alt+Click** a hotbar or inventory slot to lock it, and sorting leaves it alone
- **Keys**: **R** (or **Shift+Right Click**) sorts the part under the cursor: the hotbar sorts everything, the
inventory the inventory, a container the container. Outside a screen, R sorts the whole inventory.
**Shift+Right Click** on the middle hotbar slot cycles the mode. The mode also has its own key, unbound by default.
- **Commands**: `/iwiz [hotbar|inventory|all|mode]` (also `/inventorywizard`)
- **Works on** the inventory, hotbar, chests, barrels, shulker boxes and ender chests, **in singleplayer and on any
server**: every sort is made of ordinary left clicks, as a player could click them, so a vanilla server accepts it"""
authors = ["VanishingTacos"]
owners = ["VanishingTacos"]
license = "MIT"
source = "https://github.com/VanishingTacos/InventoryWizard-Horizon"
icon = "https://cdn.modrinth.com/data/cU0ynk3p/21b58402405493964af438e3e3b9551676b68433.png"
key = "RWTos7ZtiqwDTrtjtHk3WhMJJc1+sSTTyTu9CaDtIYWSQvww2D9WrAN1"
[[versions]]
version = "2.3.3"
published = "2026-11-01"
game = "26.3"
file = "inventory_wizard-2.3.3.hnmod"
sha256 = "03a7c8a528148611e5f309f3d05294a97b6766ca5e3bda9b3f1f79458fe65a42"
signature = """
untrusted comment: signature from minisign secret key\r
RUTos7ZtiqwDTmRp04qx/n5ZXozvaeOig3nJZFeNqmfmZ1PjB/2JUcfiF0JLZZJBkqpwm6sZQAvrt4HNce0juB5KaxJUOQJtAAo=\r
trusted comment: timestamp:1793498930\tfile:inventory_wizard-2.3.3.hnmod\thashed\r
Q0SE4CMnaFQHCmizFD0JcIPkUfiYQVs3KyGCCpS99m3OfFNhnS0gQt20ul0QlyYDAC8bAPJRjyrSIgcD1KzPAQ=="""
changelog = "Full native port of InventoryWizard for Horizon "
+34
View File
@@ -0,0 +1,34 @@
id = "killc"
kind = "native-mod"
owners = ["Vakarux12"]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "KillC"
summary = "/killc, /killc <player> and /suicide, with an optional random respawn near (0, 0)."
description = """
A Horizon port of [adasjusk's KillC](https://github.com/adasjusk/KillC), the kill command plugin for Paper, Folia, Velocity and Fabric.
- `/killc` and `/suicide` kill you.
- `/killc <player>` kills another player (operator level 2).
- After a death in the Overworld, players respawn at a random surface spot within `randomSpawnRadius` of (0, 0).
- Config: `config/killc.json` (`enabled`, `useRandomSpawn`, `randomSpawnRadius`), the same file as the Fabric build.
Vanilla keeps `/kill`. Runs where the world is hosted: single player and worlds opened to LAN (Horizon's dedicated server cannot build native mods yet). Native mods run with the player's account permissions and are not sandboxed.
Source: `horizon/` in https://github.com/Vakarux12/KillC, a fork of the original. CC-BY-SA-4.0, the original's licence, with a grant for Horizon's reviewers in `horizon/REVIEW-GRANT.md`.
"""
authors = ["adasjusk", "Vakarux12"]
license = "CC-BY-SA-4.0"
source = "https://github.com/Vakarux12/KillC/tree/main/horizon"
[[versions]]
version = "5.1.0"
published = "2026-11-01"
game = "26.3"
file = "killc-5.1.0.hnmod"
sha256 = "6c44d0a5e93aef42f2a33df8286980d9957a04526ff2c1884dfe7aa83f837086"
signature = """
untrusted comment: signature from minisign secret key
RUS5u/7lEyKbzbHxeuREFeXgBYM6FjB/wiwBtmdu5na35k0DXINdt3FILWvtW7yDekrFYjn4Xrl5pv9ttijPbocgpNbqZpPqLgY=
trusted comment: timestamp:1793694719 file:killc-5.1.0.hnmod hashed
vC2AGwA5JYAw1yjFLScjYyy+ef03gA5lxE3mwpD/D6CqKRblrpIKtHacgjYViC714ob5w1fgXFPya8AobYmVBg=="""
changelog = "First Horizon release, ported from adasjusk's KillC."
+22
View File
@@ -0,0 +1,22 @@
id = "mod_menu"
kind = "native-mod"
name = "Mod Menu"
summary = "ModMenu For Horizon"
description = """
Adds a Mods button next to Accessibility on the title screen. It opens a Mod Menu style screen that lists every mod you have installed, with its icon, name, version and description.
- Search by name, id or description, and filter by on, off, native or content mods.
- Pick a mod to see its details, turn it on or off, or open its own settings page.
- Open Mods Folder and Done at the bottom, as in the Java mod."""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
license = "MIT"
source = "https://github.com/tariksabanuz/ModMenuHorizon"
icon = "https://raw.githubusercontent.com/tariksabanuz/ModMenuHorizon/main/icon.png"
[[versions]]
version = "0.3.0"
published = "2026-11-03"
game = "26.3"
file = "mod_menu-0.3.0.hnmod"
sha256 = "7896b3b5ad727ef6e2bc56039796713809483fb157daa385f78ebd3353d653bd"
+22
View File
@@ -0,0 +1,22 @@
id = "motion_blur"
kind = "native-mod"
name = "Motion Blur"
summary = "Blurs what moves: mobs, players, items and vehicles along their own motion, with optional camera blur."
description = """
Moving mobs, players, items, minecarts and boats blur along their own motion. The camera's motion (turning, walking) can blur too, and is off by default.
Each frame the mod works out every pixel's motion since the last frame (the world's from the depth buffer and the camera, entities from last frame's vertices), finds the largest motion near each tile, and gathers along it with depth-aware weights (McGuire et al., "A Reconstruction Filter for Plausible Motion Blur", 2012).
Settings are in Options → Mods → Motion Blur: strength for moving things and the camera, shutter angle or a fixed exposure, the longest blur, quality, blurring yourself in third person, and a view that shows each pixel's motion."""
authors = ["levzzz5154"]
owners = ["levzzz5154"]
license = "MIT"
source = "https://github.com/levzzz5154/horizon-motion-blur"
[[versions]]
version = "0.1.0"
published = "2026-11-02"
game = "26.3"
file = "motion_blur-0.1.0.hnmod"
sha256 = "4cdaa6369fffea8116b013edfc618358ca989f48ed65c83306d2102f0e506c47"
changelog = "First release, built against Nightly 2026-11-01: motion blur for moving mobs, players, items and vehicles, with optional camera blur."
+24
View File
@@ -0,0 +1,24 @@
id = "resourcify"
kind = "native-mod"
name = "Resourcify"
summary = "Download resource packs in Horizon"
description = """
Resourcify lets you browse and download resource packs without ever leaving Horizon.
Features:
- Browse resource packs right inside the game
- One-click download and install
- Search to find the pack you want quickly
- Native Horizon mod, fast and lightweight"""
authors = ["tariksabanuz"]
owners = ["tariksabanuz"]
license = "MIT"
source = "https://github.com/tariksabanuz/ResourcifyHorizon"
icon = "https://github.com/tariksabanuz/ResourcifyHorizon/blob/364648324ac0e67489a3f06faa716d96d24668ed/icon.png"
[[versions]]
version = "0.1.0"
published = "2026-11-03"
game = "26.3"
file = "resourcify-0.1.0.hnmod"
sha256 = "789447fb454864af2a711ad8204f395331248d70dba570016b248766b959a2a2"
+50
View File
@@ -0,0 +1,50 @@
id = "right_click_crops"
kind = "native-mod"
name = "Right-Click Crops"
summary = "Right-click harvest for 16 plant families, with replanting and individual settings."
description = """
Harvest mature plants with a right-click, replant crops, and keep your garden growing.
### Features
- Supports 16 plant families.
- Replants crops using one planting item from the harvest, or from your inventory.
- Keeps the base of tall plants and the stems of pumpkins and melons.
- Lets you enable or disable each plant separately in **Options > Mods > Right-Click Crops > Settings**.
### Supported plants
Wheat, carrots, potatoes, beetroot, nether wart, cocoa, torchflower, pitcher crop, sugar cane, cactus, bamboo, kelp, twisting vines, weeping vines, pumpkin and melon.
### Good to know
- Harvesting works in survival, one targeted plant at a time.
- Without a suitable planting item, the crop is harvested but not replanted.
- Torchflower needs spare seeds; pitcher crop needs spare pitcher pods.
- Twisting and weeping vines can drop nothing when harvested by hand. Use shears for guaranteed drops.
- Bone meal, ordinary breaking and planting, sweet berries and glow berries keep their normal behaviour.
### Compatibility
Last checked on **November 1, 2026**, for Minecraft 26.3. The release is pinned to Horizon Nightly 2026-10-31 (`9d979ca`), where single-player and LAN were tested. A compilation-only check also passed on the newest Nightly available on that date (`d91cab6`); gameplay on that newer revision was not tested.
This is a native mod: install only if you trust its source and accept the launcher's native-code warning.
[Watch the demo](https://youtu.be/kll-qvj5s54) · [Harvesting guide](https://github.com/AyanoKaito/horizon-right-click-crops/blob/main/docs/harvesting.md)
"""
authors = ["AyanoKaito"]
owners = ["AyanoKaito"]
license = "MIT"
source = "https://github.com/AyanoKaito/horizon-right-click-crops"
icon = "icons/right_click_crops.png"
key = "RWRwbuaByP5mM4VUm4kZEQVPiuw+3IEVJ7CZcEHIjY5fPKf9lhQKUZRy"
[[versions]]
version = "1.0.0"
published = "2026-11-01"
game = "26.3"
file = "right_click_crops-1.0.0.hnmod"
sha256 = "681ce896c50dd041132900e7bbab71167f133b3f3862f86e8b1a0cac52754b97"
signature = """
untrusted comment: signature from minisign secret key
RURwbuaByP5mM4x8HGWHv/S/WlHPsmamRwsi70PTq7E5u4uJ7wyNzNT4rwpR/vGA9V2H6w9yLhmF1CPtS1grEfWorPixnk6efAw=
trusted comment: timestamp:1793523764\tfile:right_click_crops-1.0.0.hnmod\thashed
seiWz0D/X34FIKhiE1yfZc2uAkkSGuwAnTHbGrK63sHg+HdL+EgqEkzaxNgJTiMOdnf/iIdSkDVVwuF+Od/SCQ==
"""
changelog = "First public release: right-click harvesting for 16 plant families, ordinary Horizon loot, paid replanting, retained bases and stems, and individual plant settings. Validated in single-player and for LAN host and guest."
+9 -4
View File
@@ -2,16 +2,21 @@ id = "rightharvest"
kind = "native-mod"
name = "RightClickHarvest"
summary = "Right-click a ripe crop to pick it and sow it again."
description = """
With an empty hand, right-click a ripe wheat, carrot, potato, beetroot or nether wart: the crop is harvested with the game's own drops, sound and statistics, and sown again from a seed in your inventory.
No settings: turn it off in Options > Mods."""
description = "With an empty hand, right-click a ripe wheat, carrot, potato, beetroot or nether wart: the crop is harvested with the game's own drops, sound and statistics, and sown again from a seed in your inventory."
authors = ["Domanffe"]
owners = ["Domanffe"]
license = "MIT"
source = "https://github.com/Domanffe/rightharvest"
icon = "https://horizon-rust.github.io/horizon-mods/icons/rightharvest.png"
[[versions]]
version = "0.1.1"
published = "2026-11-02"
game = "26.3"
file = "rightharvest-0.1.1.hnmod"
sha256 = "2f70f3a5abaca35f1ff8df7b198f0fd616eacaa5229f5113f75935e16f50255b"
changelog = "Rebuilt on the 2026-11-01 Nightly (Horizon `4aa6854`); the `mods` hook is named instead of pinned to a source target, so it survives an update that moves that point."
[[versions]]
version = "0.1.0"
published = "2026-10-30"
+34
View File
@@ -0,0 +1,34 @@
id = "rmotd"
kind = "native-mod"
owners = ["Vakarux12"]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "RMotd"
summary = "Rotating server list MOTDs, managed in game with /randommotd and /motd."
description = """
A Horizon port of [adasjusk's RMotd](https://github.com/adasjusk/RMotd), the rotating MOTD plugin.
- The MOTD in the server list changes every 10 seconds, through the list in `config/rmotd.json` (the Fabric build's file).
- `/randommotd` turns rotation on and off.
- `/motd create <name> <motd>` adds an MOTD, `/motd reload` reads the file again (operator level 2).
- MOTDs take `&` colour codes.
It sets the MOTD of a world opened to LAN. The server list ping shows the rotation, the LAN broadcast keeps the first one. Horizon has no MOTD hook point, so a support module in `horizon_client::lan` writes the private MOTD field of the LAN server. Its injections are all hook points, but that field is not a stable API, so a later Nightly can break the build. Runs where the world is hosted: single player and worlds opened to LAN (Horizon's dedicated server cannot build native mods yet). Native mods run with the player's account permissions and are not sandboxed.
Source: `horizon/` in https://github.com/Vakarux12/RMotd, a fork of the original. CC-BY-SA-4.0, the original's licence, with a grant for Horizon's reviewers in `horizon/REVIEW-GRANT.md`.
"""
authors = ["adasjusk", "Vakarux12"]
license = "CC-BY-SA-4.0"
source = "https://github.com/Vakarux12/RMotd/tree/main/horizon"
[[versions]]
version = "1.0.0"
published = "2026-11-01"
game = "26.3"
file = "rmotd-1.0.0.hnmod"
sha256 = "68d43b760e95178a67bc1c7d54fbde004eee4bb764d9891d83bd4e4fa4bd8ce5"
signature = """
untrusted comment: signature from minisign secret key
RUS5u/7lEyKbzSnV+LLOgmX/1ryZ/5d3asf4KyfdUUOJKALk9isprehFuFIDN5X+GZy0C7jzHAImFnq9FRt2yc1J3N5lbA1oUgM=
trusted comment: timestamp:1793694734 file:rmotd-1.0.0.hnmod hashed
x+Ecd8KV0OFBcUEONAvAHxw87IG2MnjWZzcrIiMLjRNtl7KcuZQXvakrAm6slC1qADzj+n+HpwYoGT1QmQ07Bg=="""
changelog = "First Horizon release, ported from adasjusk's RMotd."
+35
View File
@@ -0,0 +1,35 @@
id = "stuck"
kind = "native-mod"
owners = ["Vakarux12"]
key = "RWS5u/7lEyKbzVHC6uMJRlhM/N7vzKp5fZe2LCDUlHsDK4lvPupxJbqQ"
name = "Stuck?"
summary = "/stuck and /escape move you to a safe spot nearby, off the Nether roof too."
description = """
A Horizon port of [adasjusk's Stuck?](https://github.com/adasjusk/Stuck), the unstuck plugin for Spigot, Paper, Folia and Fabric.
- `/stuck` and `/escape` search random columns near you for solid ground with two blocks of air above, and teleport you there.
- Skips lava, fire, magma, cactus and other hazards, and leaves and snow.
- A player on the Nether's bedrock roof is brought back under it.
- Cooldown between uses, 30 seconds by default.
- Config: `config/stuck.properties`, the same keys as the Fabric build.
Only loaded chunks are searched. Runs where the world is hosted: single player and worlds opened to LAN (Horizon's dedicated server cannot build native mods yet). Native mods run with the player's account permissions and are not sandboxed.
Source: `horizon/` in https://github.com/Vakarux12/Stuck, a fork of the original. CC-BY-SA-4.0, the original's licence, with a grant for Horizon's reviewers in `horizon/REVIEW-GRANT.md`.
"""
authors = ["adasjusk", "Vakarux12"]
license = "CC-BY-SA-4.0"
source = "https://github.com/Vakarux12/Stuck/tree/main/horizon"
[[versions]]
version = "1.0.0"
published = "2026-11-01"
game = "26.3"
file = "stuck-1.0.0.hnmod"
sha256 = "8e6292d9c89f660e72f01e54be4103b5273017955c7bd8034dd5ae8ec2e98630"
signature = """
untrusted comment: signature from minisign secret key
RUS5u/7lEyKbzf0/fn5Is2b8jJq55073yA8Sl7hSeLN1jAg9itcMlCO2kcEMVJaGyYrxFZIwPYfQJMCb61o/0g8XD8iYIUCeswU=
trusted comment: timestamp:1793694727 file:stuck-1.0.0.hnmod hashed
/JDVcy8eoaPHppahV7p+f+3LTx0CtfE34jqqZq7bU4TNvnSOx7ISCgp6Eg3+X1xYMRJRwTYM2aJwgbytRGzOCA=="""
changelog = "First Horizon release, ported from adasjusk's Stuck."
+42
View File
@@ -0,0 +1,42 @@
id = "voicechat"
kind = "native-mod"
name = "Horizon Voice Chat"
summary = "Talk with Simple Voice Chat players: proximity voice, groups, push to talk and a menu (V)."
description = """
An unofficial client for the [Simple Voice Chat](https://github.com/henkelmax/simple-voice-chat) protocol. On a server with the Simple Voice Chat mod or plugin, you can talk with players who use the Java mod on the regular launcher.
## What it does
- **Menu (V):** connection status, microphone and voice chat on/off, push to talk or voice activation, voice volume, microphone gain and activation level with a live meter, and every hotkey as it is bound now. The right side lists the server's groups with their members: join (with a password if needed), leave, or create a group.
- **Talking:** hold Caps Lock (push to talk) or use voice activation. Voices come from where players stand, getting quieter with distance and panned left or right.
- **Groups:** each player's group shows above their head. `/vc join <group> [password]`, `/vc leave`, `/vc create <name> [password] [normal|open|isolated]` and `/vc groups` work too.
- **Keys** (Options > Mods > Horizon Voice Chat): menu (V), push to talk (Caps Lock), whisper (unbound), mute (J), voice chat off/on (K).
- **Protocols:** Simple Voice Chat 2.6.x (compatibility 20) and 2.7 (21).
## What it touches
- It reads the server's `voicechat:*` custom payloads and other players' positions inside `game::remote`, so it is pinned to Horizon `c9d474d`.
- It opens one UDP connection to the server's voice chat port, encrypted with AES-128-GCM using the secret the server sends.
- It uses the default microphone and speakers through cpal, and loads the system **libopus** at run time (the `opus` package must be installed).
- It keeps its settings in Horizon's own mod settings. It contacts no host other than the Minecraft server you are on (and DNS, for the server's SRV record).
## Not affiliated
This project is not made, endorsed or supported by Max Henkel or the Simple Voice Chat project. It contains none of Simple Voice Chat's code: everything is written from scratch in Rust.
## Tested
The engine's unit tests pass, and the modded client type-checks. Live, against Paper 26.3 with the Simple Voice Chat 2.6.27 plugin, it authenticated and connected, a test tone was heard through the server, and groups and labels came from the server. Not tested yet: the menu in the real game, a real microphone, 2.7 servers, and talking with the Java mod itself.
"""
authors = ["Nirokx"]
owners = ["Nirokx"]
license = "MIT"
source = "https://github.com/Nirokx/horizon-voice-chat"
[[versions]]
version = "1.0.0"
published = "2026-11-02"
game = "26.3"
file = "voicechat-1.0.0.hnmod"
sha256 = "7a12ef9e691d10128ea081906a313d597c67f22897380618871477f9dd8eabf1"
changelog = "First release."
+31
View File
@@ -0,0 +1,31 @@
id = "waymap"
kind = "native-mod"
name = "Waymap"
summary = "Native Horizon waypoint mod with per-context storage, minimap dots, world markers, and full command suite."
description = """
Waymap — Native client-side waypoints for Horizon.
Features:
- World markers (3D, camera-projected)
- Minimap dots (rotation-aware)
- Waypoint list panel (bottom-left, 6 nearest)
- Per-context storage (per server server_<host:port>.txt / world world_<folder>.txt)
- Atomic saves (temp + atomic rename), legacy migration
- Commands: /wp add|del|list|rename|color|clear|edit|help
- Keys: N = add, B = toggle list"""
authors = ["Aron"]
owners = ["Lauch2"]
license = "MIT"
source = "https://github.com/Lauch2/horizon-waymap-mod"
key = "RWS1B1QZPeYaSNKJr9rffnfHDSvB0AZj/Ul1B2pKrJ2XFBgKXL0FC96u"
[[versions]]
version = "0.1.4"
published = "2026-11-02"
game = "26.3"
file = "waymap-0.1.4.hnmod"
sha256 = "61bf573af5b2711453ca37a41b4ac2abc78ab827863bcafb9bfabe7dbf19ccd5"
signature = """
untrusted comment: signature from minisign secret key
RUS1B1QZPeYaSBV67KshMqdN7e9tTtnd0aJ2j2hE0IOvZNQpo896FjvOETBrxu0OhB738CE44moftY8pvDMvF8o+dxXsfxQduAU=
trusted comment: timestamp:1793662341\tfile:waymap-0.1.4.hnmod\thashed
j0dStr7Yhq33q3uA+wVGhfiEfjdNx0XBCUIHiZ5AfD7vPhcCqtVnrnYx8KKcliC2UOMNBP0Kl3gOz2MyiqJSAQ=="""
+27
View File
@@ -0,0 +1,27 @@
id = "waypoints"
kind = "native-mod"
name = "Waypoints"
summary = "Place waypoints with N, manage them with M, and see their name and distance in the world."
description = """
Waypoints for Horizon.
- **N**: create a waypoint where you stand: name, X/Y/Z, dimension, colour, visible or hidden. Enter saves, Tab moves between fields, Esc cancels.
- **M**: the waypoint list, filtered by All / Overworld / Nether / End. Each row shows its colour, name, coordinates, dimension and distance, with Hide/Show, Edit and Delete buttons.
- **In the world**: each visible waypoint in your current dimension shows its name, its distance and a marker in its colour.
- **Options > Mods > Waypoints**: rebind both keys and turn the in-world labels on or off.
**What it does and doesn't do:** it only reads the game's own state (player position, dimension, camera, world folder name, server address) and draws on the HUD and its own screen. It stores waypoints as a text file per world and per server in `horizon-mods/.horizon-mod-data/waypoints/` inside the game folder. It uses only the Rust standard library, opens no network connections, and reads or writes no other files.
Pinned to Horizon `c9d474d`, because it reads the world folder, server address and last frame's camera from the game itself."""
authors = ["Nirokx"]
owners = ["Nirokx"]
license = "MIT"
source = "https://github.com/Nirokx/horizon-waypoints"
[[versions]]
version = "1.0.0"
published = "2026-11-02"
game = "26.3"
file = "waypoints-1.0.0.hnmod"
sha256 = "9c0e670f1205c23fed0fe0b0eaf8f0b90a01f3cbb883a8eee445ebc03f4d90a4"
changelog = "First release for Horizon 26.3 (Nightly c9d474d)."
+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
+17 -11
View File
@@ -9,7 +9,7 @@ lines, vanilla chest menus the server fills with ordinary items (the trade menu)
bar, boss bars and the sidebar).
This version: the economy, the auction house, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance, playtime and the richest players, the Tab list's
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance and playtime, the Tab list's
header and footer).
| Command | Does |
@@ -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 |
@@ -39,7 +39,7 @@ header and footer).
| `/casino`, `/casino roulette\|slots\|crash\|blackjack` | the casino's lobby menu, or a game's table (needs `gambling.enabled` and `casino.enabled`) |
| `/casino help`, `/casino top`, `/casino stats` | every game's odds and house edge and the limits; today's biggest wins; what you bet, won and may still lose today |
| `/coinflip accept <player>`, `/coinflip cancel`, `/coinflip list` | takes an offer (a fair coin decides, the winner gets both stakes), withdraws yours, lists those waiting |
| `/spawn` | to the server's spawn; `/setspawn` (operators of level 3) sets it where you stand |
| `/spawn` | to a safe spot within `places.spawn_radius` of the server's spawn; `/setspawn` (operators of level 3) sets it where you stand |
| `/home [name]`, `/sethome [name]`, `/delhome [name]`, `/homes` | your homes (`home` when unnamed): go, set or move one where you stand, delete, list |
| `/warp <name>`, `/warps` | to a warp, the list; `/setwarp <name>`, `/delwarp <name>` for operators of level 3 |
| `/tpa <player>`, `/tpaccept [player]`, `/tpdeny [player]` | asks to go to a player (60 s to answer); answers a request |
@@ -213,7 +213,7 @@ stays where they are, and `cooldown_seconds` (10) passes between two of one play
`places.fees` is ignored). `/tpa` takes the
asker to where the other player is when the warmup ends. Homes grow with the time played on the server since the mod
was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20, 5 from 100). `rules` holds `/rules`'
lines. Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
lines. `/spawn` lands on a random safe column within `spawn_radius` (250) blocks of the spawn point, like vanilla's `respawn_radius` for a first join or a respawn: after the `tp`, vanilla's `spreadplayers` moves the player off fluid, fire and magma onto solid ground (0: exactly the spawn point; if no column is found the player stays at the spawn point). Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
as any teleport does.
**Claims.** In a claimed chunk, only its owner, the players they trust and operators of level 3 break blocks or right
@@ -230,14 +230,14 @@ items, shearing, leads), which waits for Horizon's hook (#4648). The server's `s
the spawn.
**The scoreboard.** A sidebar on the right, titled `board.title` ("Horizon SMP"): your own block first (your name, or
your nickname; your balance, `125.4k`, `3.2M`; your playtime, `2h 05m`; the players online), then the `board.top` (3)
richest players with their balance, then `board.footer` (`play.playhorizon.dev`, empty for none). The ranking is
built once for everyone, only when a balance changed (at most every `board.min_seconds`, 5) or every
`board.refresh_seconds` (60); each player's own lines are looked at on the same `board.refresh_seconds`, and a
your nickname; your balance, `125.4k`, `3.2M`; your playtime, `2h 05m`; the players online), then `board.footer`
(`play.playhorizon.dev`, empty for none). It lists no ranking: `/baltop` does (`board.top`, of 0.14 to 0.29, is
ignored). The balances are read once for everyone, only when one changed (at most every `board.min_seconds`, 5) or
every `board.refresh_seconds` (60); each player's own lines are looked at on the same `board.refresh_seconds`, and a
sidebar is sent only to the players whose lines changed and to newcomers: nothing runs per tick per player. The Tab
list shows `board.tab_header` and `board.tab_footer` (`{server}`, `{tps}`, `{players}` filled in, every
`board.tab_refresh_seconds`, 5). `board.sidebar` and `board.tab` turn either off; `board.server_name` names the Tab
list. A server that kept 0.13's defaults (`Richest players`, the top 10) gets these at its first start of 0.14. The
list. A server that kept 0.13's title (`Richest players`) gets `Horizon SMP` at its first start. The
sidebar uses one scoreboard objective of its own (`horizon:native_sidebar`) and leaves the world's scoreboard alone.
**Presence.** A player's first join is announced to everyone (`presence.first_join`, empty for none) and they read
@@ -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"
}
+163 -12
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).
@@ -164,7 +164,7 @@ fn open(dir: PathBuf) -> Smp {
let mut config = config;
// Prices never edited: today's defaults (the balances are untouched).
let write = smp_market::migrate(&mut config.market) | write;
// 0.13's sidebar (the richest ten), never edited: 0.14's scoreboard.
// 0.13's sidebar title, never edited: today's.
let write = smp_board::migrate(&mut config.board) | write;
if write && let Err(e) = smp_store::write_json(&config_path, &config) {
log::error!("[smp] {e}");
@@ -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)?;
@@ -1889,6 +2041,13 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
out.push(Out::Tell(t.uuid, "The teleport failed.".into()));
continue;
}
// `/spawn`: on to a safe column within the radius, as vanilla's
// respawn does. If none is found the player stays at the spawn.
if let Some(spread) = t.spread_line()
&& !game.native_run_command(&spread)
{
log::warn!("[smp] no safe spot near the spawn: {spread}");
}
out.push(Out::Tell(t.uuid, t.done.clone()));
}
}
@@ -2243,14 +2402,6 @@ fn sidebar(title: &str, lines: &[Line]) -> NativeSidebar {
)
}
Line::Gap => (Text::literal(""), None),
Line::Heading => (Text::literal("Richest").with_color(text::YELLOW), None),
Line::Top { rank, name, amount, me } => {
let color = if *me { text::GREEN } else { text::WHITE };
(
Text::literal(format!("{rank}. ")).with_color(text::GRAY).append(Text::literal(name.clone()).with_color(color)),
Some(Text::literal(amount.clone()).with_color(text::GOLD)),
)
}
Line::Footer(address) => (Text::literal(address.clone()).with_color(text::AQUA), None),
})
.collect();
+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());
}
}
+36 -89
View File
@@ -1,10 +1,10 @@
// The SMP's screens: a scoreboard sidebar, the player's own block first (their
// name or nickname, balance, playtime, the players on), then the three
// richest and the server's address, which a player hides with `/sidebar off`
// (kept in `board.json`); and the Tab list's header and footer (the server's
// name, its TPS and the players on). Nothing happens per tick: the ranking is
// built once for everyone when a balance changed (at most once each
// `min_seconds`) or every `refresh_seconds`; the players' own lines are looked
// name or nickname, balance, playtime, the players on), then the server's
// address, which a player hides with `/sidebar off` (kept in `board.json`);
// and the Tab list's header and footer (the server's name, its TPS and the
// players on). Nothing happens per tick: the balances are read once for
// everyone when one changed (at most once each `min_seconds`) or every
// `refresh_seconds`; the players' own lines are looked
// at on the same `refresh_seconds`, and a sidebar is sent only to the players
// whose lines changed and to newcomers. The Tab list goes out every
// `tab_refresh_seconds`, and to a player who just joined. No game types. Added to the game as `game::smp_board`;
@@ -16,10 +16,6 @@ use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};
/// The most of the richest a sidebar lists: vanilla's 15 lines, less the
/// player's four, the two gaps, the heading and the address.
const MAX_TOP: usize = 7;
/// `config.json`'s `board` section.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -27,11 +23,9 @@ pub struct Config {
/// The sidebar (each player may hide it).
pub sidebar: bool,
pub title: String,
/// How many of the richest players it lists (at most 7; 0: none).
pub top: usize,
/// Its last line, the server's address ("": none).
pub footer: String,
/// The ranking is built again, and the players' own lines looked at,
/// The balances are read again, and the players' own lines looked at,
/// at least this often.
pub refresh_seconds: i64,
/// After a balance changed, at most this often.
@@ -50,7 +44,6 @@ impl Default for Config {
Self {
sidebar: true,
title: "Horizon SMP".into(),
top: 3,
footer: "play.playhorizon.dev".into(),
refresh_seconds: 60,
min_seconds: 5,
@@ -63,19 +56,14 @@ impl Default for Config {
}
}
/// SMP 0.13's sidebar defaults (the richest ten), moved to 0.14's when a
/// server never changed them. True when something changed.
/// SMP 0.13's sidebar title (`Richest players`), moved to today's when a
/// server never changed it. True when something changed.
pub fn migrate(config: &mut Config) -> bool {
let mut changed = false;
if config.title == "Richest players" {
config.title = Config::default().title;
changed = true;
return true;
}
if config.top == 10 {
config.top = Config::default().top;
changed = true;
}
changed
false
}
/// `board.json`.
@@ -95,16 +83,6 @@ pub enum Line {
Stat { label: &'static str, value: String },
/// An empty line.
Gap,
/// The heading over the richest.
Heading,
/// One of the richest: their rank, name and balance; `me` on the
/// player's own line.
Top {
rank: usize,
name: String,
amount: String,
me: bool,
},
/// The server's address.
Footer(String),
}
@@ -131,10 +109,9 @@ pub struct Board {
path: PathBuf,
data: Data,
changed: bool,
/// Every account by balance, richest first: UUID, name, balance.
ranked: Vec<(u128, String, i64)>,
rank_of: HashMap<u128, usize>,
/// When the ranking was built, and over which economy revision.
/// Every account's balance.
balances: HashMap<u128, i64>,
/// When the balances were read, and at which economy revision.
built_at: Option<i64>,
built_revision: u64,
/// What each player on was last sent.
@@ -180,8 +157,7 @@ impl Board {
path,
data,
changed: false,
ranked: Vec::new(),
rank_of: HashMap::new(),
balances: HashMap::new(),
built_at: None,
built_revision: 0,
sent: HashMap::new(),
@@ -204,7 +180,7 @@ impl Board {
self.data.hidden.contains(&smp_store::uuid_string(uuid))
}
/// Whether the ranking should be built again: the economy changed
/// Whether the balances should be read again: the economy changed
/// (`revision`) and `min_seconds` passed, or `refresh_seconds` passed.
pub fn due(&self, now: i64, revision: u64) -> bool {
if !self.config.sidebar {
@@ -218,27 +194,17 @@ impl Board {
|| since >= self.config.refresh_seconds * 1000
}
/// The ranking from the accounts (UUID, name, balance, any order).
pub fn rebuild(&mut self, mut accounts: Vec<(u128, String, i64)>, now: i64, revision: u64) {
accounts.sort_by(|a, b| b.2.cmp(&a.2).then_with(|| a.1.cmp(&b.1)));
self.rank_of = accounts
.iter()
.enumerate()
.map(|(i, a)| (a.0, i + 1))
.collect();
self.ranked = accounts;
/// The balances from the accounts (UUID, name, balance).
pub fn rebuild(&mut self, accounts: Vec<(u128, String, i64)>, now: i64, revision: u64) {
self.balances = accounts.into_iter().map(|(u, _, b)| (u, b)).collect();
self.built_at = Some(now);
self.built_revision = revision;
self.stale = true;
}
/// `uuid`'s sidebar: their own block, the richest from the ranking, the
/// address.
/// `uuid`'s sidebar: their own block, then the address.
pub fn lines(&self, uuid: u128, me: &Me, online: usize) -> Vec<Line> {
let balance = self
.rank_of
.get(&uuid)
.map_or(0, |&rank| self.ranked[rank - 1].2);
let balance = self.balances.get(&uuid).copied().unwrap_or(0);
let mut lines = vec![
Line::Name(me.name.clone()),
Line::Stat {
@@ -254,19 +220,6 @@ impl Board {
value: online.to_string(),
},
];
let top = self.config.top.min(MAX_TOP);
if top > 0 && !self.ranked.is_empty() {
lines.push(Line::Gap);
lines.push(Line::Heading);
lines.extend(self.ranked.iter().take(top).enumerate().map(
|(i, (u, name, balance))| Line::Top {
rank: i + 1,
name: name.clone(),
amount: compact(*balance),
me: *u == uuid,
},
));
}
if !self.config.footer.is_empty() {
lines.push(Line::Gap);
lines.push(Line::Footer(self.config.footer.clone()));
@@ -429,7 +382,7 @@ mod tests {
}
#[test]
fn the_sidebar_shows_the_player_then_the_three_richest_then_the_address() {
fn the_sidebar_shows_the_player_then_the_address_and_no_ranking() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
assert!(b.due(0, 0));
@@ -442,12 +395,6 @@ mod tests {
label,
value: value.into(),
};
let top = |rank, name: &str, amount: &str, me| Line::Top {
rank,
name: name.into(),
amount: amount.into(),
me,
};
assert_eq!(
b.lines(11, &rocky, 42),
vec![
@@ -456,27 +403,20 @@ mod tests {
stat("Playtime", "2h 05m"),
stat("Online", "42"),
Line::Gap,
Line::Heading,
top(1, "P12", "1,200", false),
top(2, "P11", "1,100", true),
top(3, "P10", "1,000", false),
Line::Gap,
Line::Footer("play.playhorizon.dev".into()),
]
);
// A player with no account yet; a server with no address and no top.
// A player with no account yet; a server with no address.
assert_eq!(b.lines(99, &rocky, 1)[1], stat("Balance", "0"));
b.config.footer.clear();
b.config.top = 0;
assert_eq!(b.lines(99, &rocky, 1).len(), 4);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn the_old_defaults_move_to_the_new_ones_and_set_ones_stay() {
fn the_old_title_moves_to_the_new_one_and_a_set_one_stays() {
let mut old = Config {
title: "Richest players".into(),
top: 10,
..Config::default()
};
assert!(migrate(&mut old));
@@ -484,15 +424,21 @@ mod tests {
assert!(!migrate(&mut old));
let mut own = Config {
title: "My server".into(),
top: 5,
..Config::default()
};
assert!(!migrate(&mut own));
assert_eq!((own.title.as_str(), own.top), ("My server", 5));
assert_eq!(own.title, "My server");
}
#[test]
fn sidebars_go_out_on_join_on_a_new_ranking_and_on_the_refresh() {
fn an_old_config_with_a_top_still_reads() {
let old = serde_json::json!({ "title": "Horizon SMP", "top": 3 });
let c: Config = serde_json::from_value(old).unwrap();
assert_eq!(c, Config::default());
}
#[test]
fn sidebars_go_out_on_join_on_a_balance_change_and_on_the_refresh() {
let d = dir();
let mut b = Board::open(&d, Config::default()).unwrap();
b.rebuild(accounts(3), 0, 1);
@@ -511,11 +457,12 @@ mod tests {
assert!(b.due(5_000, 2));
b.rebuild(accounts(3), 5_000, 2);
assert!(b.updates(5_000, &[1, 2], me).is_empty(), "same lines");
// Player 1 gets richer: everyone's top changes.
// Player 1 gets richer: only their own sidebar changes.
let mut a = accounts(3);
a[0].2 = 1_000;
b.rebuild(a, 10_000, 3);
assert_eq!(b.updates(10_000, &[1, 2], me).len(), 2);
let sent = b.updates(10_000, &[1, 2], me);
assert_eq!(sent.iter().map(|s| s.0).collect::<Vec<_>>(), vec![1]);
// A newcomer gets theirs at once, and only they are looked at.
asked.set(0);
assert_eq!(b.updates(11_000, &[1, 2, 3], me).len(), 1);
+75 -1
View File
@@ -30,6 +30,10 @@ pub struct Config {
pub homes: Vec<[i64; 2]>,
/// `/rules`, a line each.
pub rules: Vec<String>,
/// `/spawn` lands on a random safe column within this many blocks of the
/// spawn point, like vanilla's `respawn_radius` for a first join or a
/// respawn (0: exactly the spawn point).
pub spawn_radius: i64,
}
impl Default for Config {
@@ -44,6 +48,7 @@ impl Default for Config {
"No griefing, no stealing, no cheats.".into(),
"The currency is earned in game only: never bought or sold for real money.".into(),
],
spawn_radius: 250,
}
}
}
@@ -84,6 +89,8 @@ struct Data {
enum To {
/// A place, and what it is (`home "base"`).
Place(Place, String),
/// The spawn point: `/spawn` fuzzes it by `spawn_radius`.
Spawn(Place),
/// A player, wherever they are when the warmup ends.
Player(u128, String),
}
@@ -116,10 +123,28 @@ pub struct Teleport {
pub done: String,
/// Where to, in words (`where you died`).
pub what: String,
/// Land on a safe column within this many blocks of `to` instead of on it
/// (0: on it). Only `/spawn` sets it.
pub spread: i64,
/// The cooldown and `/back` before it: `call_off` puts them back.
undo: (Option<i64>, Option<(Place, bool)>),
}
impl Teleport {
/// The command that moves the player from the point `to` to a safe column
/// within `spread` blocks of it: vanilla's `spreadplayers` (a column that
/// is not fluid, fire or magma and has solid ground, found by retrying),
/// the check behind `respawn_radius`. None when `spread` is 0.
pub fn spread_line(&self) -> Option<String> {
(self.spread >= 1).then(|| {
format!(
"execute in {} run spreadplayers {:.3} {:.3} 0 {} false {}",
self.to.dimension, self.to.x, self.to.z, self.spread, self.name
)
})
}
}
/// The places' commands.
pub const COMMANDS: [&str; 16] = [
"spawn", "setspawn", "home", "sethome", "delhome", "homes", "warp", "warps", "setwarp",
@@ -336,7 +361,7 @@ impl Places {
out
}
("spawn", []) => match self.data.spawn.clone() {
Some(p) => self.start(body, To::Place(p, "the spawn".into()), now),
Some(p) => self.start(body, To::Spawn(p), now),
None => vec![Out::Error(
"No spawn yet: an operator sets it with /setspawn.".into(),
)],
@@ -557,8 +582,13 @@ impl Places {
} else if now < p.due {
self.pending.push(p);
} else {
let mut spread = 0;
let (to, what) = match &p.to {
To::Place(place, what) => (Some(place.clone()), what.clone()),
To::Spawn(place) => {
spread = self.config.spawn_radius.max(0);
(Some(place.clone()), "the spawn".to_owned())
}
To::Player(u, name) => (
bodies.iter().find(|b| b.uuid == *u).map(|b| b.at.clone()),
name.clone(),
@@ -576,6 +606,7 @@ impl Places {
to,
done: format!("Teleported to {what}."),
what,
spread,
undo,
});
}
@@ -821,8 +852,51 @@ mod tests {
(go[0].to.x, go[0].done.as_str()),
(7.0, "Teleported to the spawn.")
);
assert_eq!(go[0].spread, 250, "the spawn is fuzzed by default");
let out = p.command(&player(A, 0), &here, 0, "rules").unwrap();
assert_eq!(out.len(), 4);
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn only_the_spawn_is_spread_over_a_safe_spot() {
let d = dir();
let mut p = Places::open(&d, Config::default()).unwrap();
let here = [body(A, 7.0)];
p.command(&player(A, 3), &here, 0, "setspawn").unwrap();
p.command(&player(A, 3), &here, 0, "setwarp shop").unwrap();
p.command(&player(A, 3), &here, 0, "sethome").unwrap();
let far = [body(A, 50.0)];
p.command(&player(A, 0), &far, 0, "spawn").unwrap();
let (_, go) = p.tick(&far, 3_000);
assert_eq!(go[0].to, place(7.0));
assert_eq!(
go[0].spread_line().as_deref(),
Some("execute in minecraft:overworld run spreadplayers 7.000 0.000 0 250 false Alex")
);
for (i, line) in ["warp shop", "home"].into_iter().enumerate() {
let t = 100_000 * (i as i64 + 1);
p.command(&player(A, 0), &far, t, line).unwrap();
let (_, go) = p.tick(&far, t + 3_000);
assert_eq!(go.len(), 1, "{line}");
assert_eq!(go[0].spread_line(), None, "{line} is exact");
}
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_zero_radius_sends_players_to_the_exact_spawn() {
let d = dir();
let config = Config {
spawn_radius: 0,
..Config::default()
};
let mut p = Places::open(&d, config).unwrap();
let here = [body(A, 7.0)];
p.command(&player(A, 3), &here, 0, "setspawn").unwrap();
p.command(&player(A, 0), &here, 0, "spawn").unwrap();
let (_, go) = p.tick(&here, 3_000);
assert_eq!(go[0].spread_line(), None);
let _ = std::fs::remove_dir_all(d);
}
}
+3 -4
View File
@@ -452,7 +452,7 @@ def world_guard(server, alex, steve):
def board(alex, steve, out):
"""The scoreboard sidebar (the player's own block, the richest, the address) and the Tab list; /sidebar off
"""The scoreboard sidebar (the player's own block, then the address) and the Tab list; /sidebar off
hides it, on shows it again."""
r = {}
side = lambda c: c.run("status").get("sidebar")
@@ -462,9 +462,8 @@ def board(alex, steve, out):
break
time.sleep(0.5)
assert r["sidebar"]["objective"] == "horizon:native_sidebar" and r["sidebar"]["title"] == "Horizon SMP", r
# Steve's name, balance, playtime and the players on; a gap, "Richest", Alex and Steve (the two accounts); a gap,
# the address.
assert len(r["sidebar"]["scores"]) == 10, r
# Steve's name, balance, playtime and the players on; a gap, the address. No ranking of the richest.
assert len(r["sidebar"]["scores"]) == 6, r
# Holders starting with '#' would be hidden from the sidebar (only the title would show).
assert not any(o.startswith("#") for o in r["sidebar"]["scores"]), r
steve.run("key", "tab", "down")
+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"
}
+6
View File
@@ -6,3 +6,9 @@
# sha256 = "..."
# reason = "What it did, in one sentence players will read."
# date = "2026-10-24"
[[revoked]]
id = "crossversion"
sha256 = "deb9cf8a84838e6c48999f70fbd3c81db90992c456d6e85a0e0746b088c5b8fa"
reason = "Paused while its Microsoft sign-in for online-mode servers is reviewed with its author; a reviewed version will replace it."
date = "2026-11-03"