Compare commits

...
Author SHA1 Message Date
Ludovic Marie 7f995af677 Merge pull request #1570 from ludovic111/feat/server-packs-client 2026-10-24 21:02:56 +02:00
Ludovic Marie 61e044e67c Merge pull request #1569 from ludovic111/feat/server-packs-manager 2026-10-24 21:01:05 +02:00
Ludovic Marie 18b63d7acb Merge pull request #1581 from ludovic111/docs/mod-api-reference 2026-10-24 20:58:01 +02:00
Ludovic Marie e683cdf322 Merge pull request #1579 from ludovic111/fix/builtin-pack-icons 2026-10-24 20:56:28 +02:00
Ludovic MarieandClaude Opus 5.5 1b23a14ee7 Mod API reference generated from the WIT contract
docs/api/mods-reference.md lists every capability, world, interface, type,
function and hook of horizon:mods, generated from
crates/horizon-mod-sdk/wit/horizon.wit and the manifest's capabilities by a
test in horizon-mods (feature pack, as CI runs it). The test fails when the
page is out of date (HORIZON_WRITE_DOCS=1 rewrites it), when a function has
no doc comment, or when a capability grants an interface the contract lacks.

The WIT's undocumented functions and fields get doc comments (no change to
the contract itself).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:38:18 +02:00
Ludovic Marie 16d49fe660 Merge pull request #1558 from rdbtCVS/t3/retire-java-optimization-mod
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 20:33:21 +02:00
Ludovic Marie fccac1e744 Merge pull request #1560 from rdbtCVS/t3/remove-voxel-kernels
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 20:31:50 +02:00
Ludovic MarieandClaude Opus 5.5 1dffadeea3 Pack screen: draw each pack's own pack.png instead of unknown_pack
PackSelectionScreen.loadPackIcon registers every pack's pack.png as a
texture; Horizon never loaded them, so every pack (Default, Programmer Art,
High Contrast and the player's own) fell back to unknown_pack, the
cobblestone. The icons now load from each pack when the screen opens and are
written into 64 new 128x128 HUD atlas slots, one per icon on screen. The
vanilla pack's icon is the client JAR's pack.png, which horizon-prepare now
copies into the prepared resources.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 20:19:05 +02:00
Ludovic Marie 29c5e65d17 Merge pull request #1573 from Domanffe/fix/placed-block-entity-keeps-state
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 20:12:22 +02:00
Ludovic MarieandClaude Opus 5.5 0c6816811c Commands: fold the block check into place_state's condition; cover /fill in the corpus
The comment names vanilla's rule (LevelChunk.setBlockState keeps or creates
the block entity when the block, not the state, is still there after
onPlace). The corpus checks the `enabled=false` flip, drops the cleanup lines
the group's closing /fill already does, and adds a powered hopper placed by
/fill, which goes through the same place_state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 19:49:37 +02:00
Ludovic Marie 7fca3c2f97 Merge pull request #1572 from ludovic111/fix/mac-dock-icon 2026-10-24 19:11:53 +02:00
Alexander KrylovandHermes Agent 493e5f8d3a Commands: a placed block keeps its block entity when a neighbour update changes its state
/setblock created the block entity only when the state that landed matched the
requested state exactly. HopperBlock.onPlace sets enabled=false while the block is
being placed, so a hopper put into power had no inventory: /data get block answered
"not a block entity", items dropped into it stayed item entities, and
/setblock hopper{Items:[...]} dropped the NBT as well.

Compare the block at the position with the block that was placed, the way vanilla
does when it creates the entity from the placed state, before the neighbour updates
run.

Fixes #1506.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-24 19:50:45 +03:00
Ludovic Marie ccb827282b Merge remote-tracking branch 'origin/main' into pa1/gate-1558
# Conflicts:
#	docs/vulkan.md
#	mods/horizon/src/main/java/dev/horizonlab/client/HorizonClient.java
#	mods/horizon/src/test/java/dev/horizonlab/client/DistantTerrainSettingsTest.java
#	mods/horizon/src/test/java/dev/horizonlab/metrics/ClientGraphicsIntegration.java
#	packs/START-HERE.txt
#	packs/mods.lock.json
#	tests/test_fixtures.py
#	tests/test_packs.py
#	tools/Compare-Benchmarks.html
#	tools/build_pack.py
#	tools/compare_benchmarks.py
#	tools/test_client_graphics.py
2026-10-24 18:47:57 +02:00
Ludovic Marie 56aa5f9549 Merge remote-tracking branch 'origin/main' into pa1/gate-1560
# Conflicts:
#	Cargo.toml
2026-10-24 18:47:16 +02:00
Ludovic Marie d65282fb0f Merge pull request #1566 from ludovic111/fix/baby-villager-type-texture 2026-10-24 18:34:08 +02:00
Ludovic MarieandClaude Opus 5.5 6a698c40f0 Launcher: start the game from a Horizon.app on macOS, so the Dock and Cmd+Tab show Horizon
Started as a bare executable, the game showed in the Dock and Cmd+Tab as
"horizon-client" with the generic executable icon: winit's window icon does
nothing on macOS. The launcher now keeps a small Horizon.app in each version
folder (Info.plist, the launcher's icon, hard links to the version's
horizon-client and horizon-ctl) and starts the game from it. Server setup
leaves the bundle out of its copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 18:34:04 +02:00
Ludovic Marie 71292d2ed7 Merge pull request #1562 from ludovic111/feat/server-pack-status 2026-10-24 18:12:16 +02:00
Ludovic Marie 46f4237b67 Merge pull request #1565 from ludovic111/fix/remote-villager-data 2026-10-24 18:03:07 +02:00
Ludovic MarieandClaude Opus 5.5 186ab70305 Server resource packs: prompt, Server Resource Packs setting, apply and remove
Servers that push a resource pack now work (reported by scanfee on Discord:
"Servers requiring custom resource packs do not work yet"; net.rs declined
every push).

- net.rs forwards resource_pack_push and _pop in configuration and play, and
  answers in the phase the connection is in.
- The prompt is vanilla's PackConfirmScreen on a shared ConfirmScreen
  (confirm_screen.rs, now also used by the game rules screen): the required
  or optional wording, the server's message, Proceed/Disconnect or Yes/No,
  Escape for no. Declining a required pack disconnects with vanilla's reason.
- The server list's Edit screen has the Server Resource Packs cycle button
  (Enabled, Disabled, Prompt), saved as acceptTextures; the prompt's answer
  is saved there too.
- Downloaded packs go on top of the player's packs, in push order, through
  the same reload as the Resource Packs screen; they are removed on pop and
  when leaving the server.

tools/server_pack_e2e.py drives the real client against vanilla's dedicated
server or Horizon's, with a test pack (blue dirt, lang strings) from a local
HTTP server, and checks the statuses the client sends through net_capture:
decline, proceed, the cache, a wrong SHA-1, an optional pack declined and
accepted, a dead URL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 17:20:35 +02:00
Ludovic MarieandClaude Opus 5.5 cdfcc3d4d4 Server resource packs: the client's pack manager and downloads
Vanilla's ServerPackManager, DownloadedPackSource and DownloadQueue, without
the game around them yet: the prompt state, each pack's download and
activation, the statuses in vanilla's order (onSuccess repeats
SUCCESSFULLY_LOADED, a failed reload recovers with the packs that loaded),
pop, and leaving.

Downloads follow HttpUtil.downloadFile: downloads/<pack id>/<sha1>, a cached
file whose hash matches is reused, a wrong hash fails, no hash downloads to a
temporary file renamed to its SHA-1, 250 MiB at most, vanilla's X-Minecraft-*
headers, a log.json entry per download, and 20 files kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 17:15:35 +02:00
Ludovic MarieandClaude Opus 5.5 ab8b379097 Dedicated server: offer server.properties' resource pack as vanilla does
ServerResourcePackConfigurationTask: after the tags, the configuration phase
sends the pack (id from the URL when none is given, hash, required, prompt)
and waits for a final answer, with keepalives every 15 s. A declined required
pack disconnects with multiplayer.requiredTexturePrompt.disconnect, in
configuration or in play, with vanilla's log lines.

tools/test_server_resource_pack_parity.py drives a vanilla 26.3 server and
Horizon's with the same five players (declines, accepts then declines in
play, fails the download, answers after 20 s, declines an optional pack) and
compares what each one receives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:21:53 +02:00
Ludovic MarieandClaude Opus 5.5 a3b4ed57b2 runtime.py: two blank lines between functions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:21:21 +02:00
Ludovic MarieandClaude Opus 5.5 e1052e1bd3 Baby villagers wear their biome's clothes again
VillagerProfessionLayer takes a baby's type texture from
`entity/villager/baby/<type>` (and `entity/zombie_villager/baby/<type>`),
not `type/baby/<type>`: that path names no texture, so baby villagers and
baby zombie villagers of every biome drew without their type layer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:06:50 +02:00
Ludovic MarieandClaude Opus 5.5 afb638b81c lan_e2e: a guest sees villagers' types, professions, levels and new jobs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:06:50 +02:00
Ludovic MarieandClaude Opus 5.5 f1369b4efe Clients show villagers' professions, types and levels
A client of a server (single player on its server thread, a LAN guest, a
dedicated server's player) never read Villager.DATA_VILLAGER_DATA or
ZombieVillager's from SetEntityData, so every villager drew as a plains
villager with no profession and no level badge, and a villager that took a
job never changed. The client now reads the type and the profession by
their built-in registry ids, and the level, which VillagerRenderer reads
from the synched data in vanilla.

horizon-ctl entities reports a villager's type, profession and level as the
game knows them, so a client's view can be checked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:06:50 +02:00
rdbtCVSandOpenAI Codex gpt-6.1-sol d49b0eb98b Name the profiling documentation for its current use
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:55:53 +01:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 68bc9f82b6 Remove documentation of the retired Java product
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:55:32 +01:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 06ec15f3a0 Retire the legacy Java optimization mod and performance packs
Preserve Java parity oracles and comparison benchmark instrumentation. Move their remaining dependency locks under tools/benchmark-deps and remove legacy product packaging, fixtures, and CI checks.

Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:50:55 +01:00
rdbtCVSandOpenAI Codex gpt-6.1-sol 2a9e0ab28d Remove unused voxel-kernels research experiment
Co-Authored-By: OpenAI Codex gpt-6.1-sol <[email protected]>
2026-10-24 14:49:46 +01:00
158 changed files with 4220 additions and 7169 deletions

No files matched your search

+1
View File
@@ -3,3 +3,4 @@
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+1 -95
View File
@@ -430,7 +430,7 @@ jobs:
env:
RUSTUP_TOOLCHAIN: stable
run: python tools/test_float_log_parity.py
portable-mod:
jfr-analysis:
if: github.event_name == 'workflow_dispatch'
runs-on: [self-hosted, linux, horizon-game]
steps:
@@ -442,101 +442,7 @@ jobs:
with:
distribution: temurin
java-version: '25'
- name: Compile original mod and test metrics
run: python tools/build_mod.py
- name: Verify streaming JFR analysis against the JDK exporter
run: python tools/test_jfr.py
- name: Verify ZGC pause and allocation event interpretation
run: python tools/test_jfr.py --gc zgc
- name: Build pinned portable packs
run: python tools/build_pack.py
- name: Measure isolated worker pacing on this platform
run: python -c "from tools.build_mod import benchmark_workers; benchmark_workers()"
- name: Measure isolated debounce queue polling
run: python -c "from tools.build_mod import benchmark_debounce; benchmark_debounce()"
- name: Measure wakeable idle queue latency
run: python -c "from tools.build_mod import benchmark_idle; benchmark_idle()"
- name: Measure object allocations with compact headers off and on
run: python -c "from tools.build_mod import benchmark_headers; benchmark_headers()"
- name: Test pack integrity and comparisons
run: python -m unittest discover -s tests -v
- name: Start recommended OpenGL macOS clients and verify framebuffer readback
if: runner.os == 'macOS'
run: |
python tools/test_client_graphics.py --native-display
cp dist/client-graphics-smoke.json dist/client-graphics-macos-title.json
python tools/test_client_graphics.py --native-display --world
cp dist/client-graphics-smoke.json dist/client-graphics-macos-world.json
- name: Compile the private dispatch fixture
run: python -c "from tools.build_mod import build_dispatch_fixture; build_dispatch_fixture()"
- name: Compile the private integrated-server graphics fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('server-graphics', 'dev.horizonlab.metrics.ServerGraphicsIntegration', fixture_dependencies={})"
- name: Compile the private tick-manager state fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
- name: Compile the private terrain tick membership fixture
run: python -c "from tools.build_mod import build_tick_dedup_fixture; build_tick_dedup_fixture()"
- name: Compile the private culling whitelist fixture
run: python -c "from tools.build_mod import build_culling_empty_fixture; build_culling_empty_fixture()"
- name: Compile the private culling ray geometry fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('culling-rays', 'dev.horizonlab.dispatch.CullingRayIntegration', ['entityculling'], ['CullingRayAccess'])"
- name: Compile the private terrain position lookup fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-position', 'dev.horizonlab.dispatch.PositionIntegration')"
- name: Compile the private terrain quad reuse fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('quad-reuse', 'dev.horizonlab.dispatch.QuadReuseIntegration')"
- name: Compile the private object-size agent and terrain quad fixture
run: python -c "from tools.test_quad_sizes import build_agent; from tools.build_mod import build_integration_fixture; build_agent(); build_integration_fixture('quad-size', 'dev.horizonlab.dispatch.QuadSizeIntegration')"
- name: Compile the private terrain read shutdown fixture
run: python -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('terrain-read', 'dev.horizonlab.dispatch.TerrainReadIntegration')"
- name: Install headless graphics libraries for applied-hook checks
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y xvfb libgl1 libegl1 libvulkan1 mesa-vulkan-drivers libasound2t64 libxkbcommon0
- name: Verify integrated-server graphics report against the actual device
run: python tools/test_server_graphics.py ${{ runner.os == 'macOS' && '--native-display' || '' }}
- name: Exercise the applied dispatcher hooks and upstream rejection path
if: runner.os == 'Linux'
run: python tools/test_dispatch.py
- name: Verify applied tick membership transitions and allocation
if: runner.os == 'Linux'
run: python tools/test_tick_dedup.py
- name: Verify live culling whitelist registration and empty iteration
if: runner.os == 'Linux'
run: python tools/test_culling_empty.py
- name: Compare applied culling ray geometry and provider queries
if: runner.os == 'Linux'
run: python tools/test_culling_rays.py
- name: Verify actual full-data position lookup equivalence
if: runner.os == 'Linux'
run: python tools/test_terrain_position.py
- name: Verify actual terrain quad geometry and allocation
if: runner.os == 'Linux'
run: python tools/test_quad_reuse.py
- name: Measure actual builder and quad shallow sizes in both header modes
if: runner.os == 'Linux'
run: python tools/test_quad_sizes.py
- name: Verify interrupted DH reads and preserved shared futures
if: runner.os == 'Linux'
run: python tools/test_terrain_read.py
- name: Upload original mod and pack manifests only
uses: actions/upload-artifact@v4
with:
name: horizon-linux
path: |
dist/*.mrpack
dist/horizon-*.jar
dist/worker-pacing-benchmark.txt
dist/debounce-queue-benchmark.txt
dist/idle-queue-benchmark.txt
dist/object-header-benchmark.txt
dist/dispatch-integration.json
dist/tick-dedup-integration.json
dist/culling-empty-integration.json
dist/terrain-read-integration.json
dist/culling-rays-integration.json
dist/terrain-position-integration.json
dist/quad-reuse-integration.json
dist/quad-size-integration.json
dist/server-graphics-smoke.json
dist/client-graphics-smoke.json
dist/client-graphics-macos-title.json
dist/client-graphics-macos-world.json
retention-days: 3
+1 -1
View File
@@ -47,7 +47,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
Generated
+1 -4
View File
@@ -1278,6 +1278,7 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"symphonia",
"ureq",
@@ -3577,10 +3578,6 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "voxel-kernels"
version = "0.1.0"
[[package]]
name = "walkdir"
version = "2.5.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
@@ -131,8 +131,8 @@ average) and reached the ThinkPad over the tailnet.
It ran with Paper's defaults except `connection-throttle: -1` in `bukkit.yml`. Every bot connects from one
address, and the default throttle (4 s) refused all but the first. Paperclip patched the pinned server jar
from its cache. Same Java and heap.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`packs/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`packs/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
- Fabric + Lithium: the pinned server jar on Fabric loader 0.19.5 (`tools/benchmark-deps/fabric.lock.json`) with Lithium
`mc26.3-0.26.2-fabric` (`tools/benchmark-deps/mods.lock.json`, checked against its SHA-512). Lithium needs no Fabric API.
Same Java and heap.
The Paper and Fabric jars are dev-only downloads into `.local/server-bench/jars` and are never committed or
+2
View File
@@ -27,6 +27,8 @@ bytemuck = { version = "1.24", features = ["derive"] }
glam = "0.30"
png = "0.18"
sha2 = "0.10.9"
# Server resource packs are checked and cached by their SHA-1, as vanilla does.
sha1 = "0.10"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
@@ -5,7 +5,7 @@
//!
//! usage: pack_screen_fixture RESOURCES CASES OUTPUT
use horizon_client::{
hud::HudAtlas,
hud::{HudAtlas, PACK_ICON_SIZE},
lang,
pack_screen::{Compat, PackInfo, PackScreen, Source},
packs::{self, Position},
@@ -13,7 +13,7 @@ use horizon_client::{
text::Text,
};
use serde_json::{Value, json};
use std::{fmt::Write as _, fs, path::PathBuf};
use std::{fmt::Write as _, fs, path::PathBuf, sync::Arc};
/// Styled runs: [text, rgb or null, bold, italic, underlined, strikethrough, obfuscated].
pub fn runs(t: &Text) -> Value {
@@ -105,7 +105,9 @@ pub fn main() -> horizon_client::Result<()> {
Some("unknown") => Compat::Unknown,
_ => Compat::Compatible,
},
icon: p["icon"].as_bool().unwrap_or(false),
icon: p["icon"].as_bool().unwrap_or(false).then(|| {
Arc::from(vec![0; (PACK_ICON_SIZE * PACK_ICON_SIZE * 4) as usize])
}),
required: p["required"].as_bool().unwrap_or(false),
position: if p["position"] == "top" {
Position::Top
@@ -0,0 +1,75 @@
//! `ConfirmScreen`: a title, a message wrapped to `width - 50` (15 lines at
//! most) and two 150-wide buttons 4 apart and 16 below it, the whole column
//! centred; Escape answers no.
use crate::{
font::Font,
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
pub struct Confirm {
/// Each line and where it is drawn.
pub lines: Vec<(Text, f32, f32)>,
/// The yes and no buttons' corners.
pub buttons: [(f32, f32); 2],
}
impl Confirm {
pub fn new(font: &Font, (w, h): (f32, f32), title: &Text, message: &Text) -> Self {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.map(|(line, _)| line)
.take(15)
.collect();
let tw = font.width_text(title);
let mw = message
.iter()
.map(|l| font.width_text(l))
.fold(0.0, f32::max);
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut lines = vec![(title.clone(), cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
lines.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines,
buttons: [(bx, by), (bx + 154.0, by)],
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
pub fn answer_at(&self, cursor: (f32, f32)) -> Option<bool> {
let [yes, no] = self.buttons;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if on(yes) {
Some(true)
} else if on(no) {
Some(false)
} else {
None
}
}
/// The lines and the buttons (yes first), over whatever is behind.
pub fn draw(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
}
}
}
+29 -2
View File
@@ -137,6 +137,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_packs;
mod server_travel;
mod shield;
mod sign_edit;
@@ -1126,6 +1127,8 @@ struct Game<'a> {
pending_entry: Option<PendingWorldEntry>,
world_loader: Option<world_loading::WorldLoader>,
pending_pack_reload: bool,
/// A server's resource packs (game/server_packs.rs).
server_packs: server_packs::ServerPackState,
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
@@ -4771,7 +4774,14 @@ impl Game<'_> {
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
let mut batch = self.hud_layers(size);
// The pack prompt replaces the connecting screen (setScreen), but
// in game it opens over the world.
let mut batch = if self.server_packs.prompt.is_some() && self.player.is_none() {
HudBatch::new(size)
} else {
self.hud_layers(size)
};
self.draw_pack_prompt(&mut batch);
// Gui.extractRenderState: the debug overlay comes after the screen
// and the toasts, so it is over everything, menus included.
self.draw_debug_overlay(&mut batch, size);
@@ -10995,6 +11005,13 @@ impl ApplicationHandler for Game<'_> {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
}
WindowEvent::MouseInput { state, button, .. }
if self.server_packs.prompt.is_some() =>
{
if state == ElementState::Pressed && button == MouseButton::Left {
self.pack_prompt_click()?;
}
}
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
if self.options_waiting_for_key() && state == ElementState::Pressed {
@@ -11194,6 +11211,14 @@ impl ApplicationHandler for Game<'_> {
_ => {}
}
}
// ConfirmScreen.keyPressed: Escape answers no.
WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => {
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
{
self.answer_pack_prompt(false)?;
}
}
WindowEvent::KeyboardInput { event, .. }
if self.screen == Screen::Chat && self.title.is_none() =>
{
@@ -11572,6 +11597,7 @@ impl ApplicationHandler for Game<'_> {
event_loop.exit();
return;
}
self.tick_server_packs();
self.poll_control(event_loop);
if self.pending_save.is_some() {
return;
@@ -11666,6 +11692,7 @@ impl<'a> Game<'a> {
pending_entry: None,
world_loader: None,
pending_pack_reload: false,
server_packs: Default::default(),
world_renderer_ready: false,
collision: res.collision(),
motion: res.motion(),
@@ -13254,7 +13281,7 @@ pub fn run() -> Result<()> {
return run_loading(options, early_settings, vanilla_root, None, started);
}
let mut pack_error = None;
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs)
options.resources = packs::startup_root(&vanilla_root, &early_settings.resource_packs, &[])
.unwrap_or_else(|e| {
log::warn!("Horizon: resource packs not applied: {e}");
pack_error = Some(e.to_string());
+15 -1
View File
@@ -9,6 +9,10 @@ use horizon_client::{
block_entity_anim::Ticker,
chunk::{MIN_Y, SECTIONS},
items::ItemStack,
mobs::{
Species,
render::{PROFESSIONS, VILLAGER_TYPES},
},
widgets,
};
use horizon_core::geometry::Vec3;
@@ -592,10 +596,20 @@ impl Game<'_> {
}
};
for m in self.mobs.iter().filter(|m| m.alive()) {
let mut v = json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time });
// VillagerData as this game knows it: on a client, what the
// server's synched data says.
if matches!(m.species(), Species::Villager | Species::ZombieVillager) {
v["villager"] = json!({
"type": VILLAGER_TYPES[m.extra.variant as usize % VILLAGER_TYPES.len()],
"profession": PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()],
"level": m.extra.variant3,
});
}
push(
[m.position.x, m.position.y, m.position.z],
m.kind.id.as_ref(),
json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time }),
v,
);
}
for item in &self.dropped {
+4 -1
View File
@@ -1783,8 +1783,11 @@ impl Game<'_> {
self.level_set(pos, state)
.map_err(|e| CommandError::new(Text::literal(e.to_string())))?;
}
// LevelChunk.setBlockState creates it when the block, not the state, is still
// there after onPlace: a powered hopper turns itself `enabled=false`.
let landed = self.world.block(pos[0], pos[1], pos[2]);
if old != new
&& self.world.block(pos[0], pos[1], pos[2]) == state
&& self.res.facts().get(landed).name == new
&& let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new)
{
self.world.block_entities.insert(key, entity);
@@ -608,6 +608,7 @@ impl Game<'_> {
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
log_ips: props.log_ips,
}),
resource_pack: props.resource_pack.clone(),
};
let config = HubConfig {
default_game_mode: game_mode,
@@ -3,6 +3,7 @@
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
//! Leaving with changes asks first (ConfirmScreen).
use horizon_client::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
hud::{HudAtlas, HudBatch},
@@ -66,9 +67,6 @@ fn parse(r: &Rule, s: &str) -> Option<i32> {
.flatten()
.filter(|v| (r.min..=r.max).contains(v))
}
fn widest(font: &Font, lines: &[Text]) -> f32 {
lines.iter().map(|l| font.width_text(l)).fold(0.0, f32::max)
}
fn lines(font: &Font, t: &Text, width: f32) -> Vec<Text> {
font.split_lines(t, width)
.into_iter()
@@ -95,38 +93,11 @@ fn yellow() -> u32 {
named_color("yellow").unwrap_or(0xFFFF55)
}
/// ConfirmScreen: the title, the message (at most `width - 50` wide), and
/// Yes / No 16 below, 4 apart, all centred.
struct Confirm {
lines: Vec<(Text, f32, f32)>,
buttons: [(f32, f32); 2],
}
impl Confirm {
fn new(font: &Font, (w, h): (f32, f32)) -> Self {
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
let message: Vec<Text> = lines(font, &message, w - 50.0)
.into_iter()
.take(15)
.collect();
let (tw, mw) = (font.width_text(&title), widest(font, &message));
let mh = 9.0 * message.len() as f32;
let col = tw.max(mw).max(304.0);
let total = 9.0 + 8.0 + mh + 8.0 + 36.0;
let (lx, ly) = (((w - col) / 2.0).trunc(), ((h - total) / 2.0).trunc());
let cell = |cw: f32| lx + ((col - cw) / 2.0).trunc();
let mid = cell(mw) + (mw / 2.0).trunc();
let mut out = vec![(title, cell(tw), ly)];
for (i, line) in message.into_iter().enumerate() {
let x = mid - (font.width_text(&line) / 2.0).trunc();
out.push((line, x, ly + 17.0 + 9.0 * i as f32));
}
let (bx, by) = (cell(304.0), ly + 9.0 + 8.0 + mh + 8.0 + 16.0);
Self {
lines: out,
buttons: [(bx, by), (bx + 154.0, by)],
}
}
/// ConfirmScreen "Game Rule Changes".
fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
let title = Text::translate("editGamerule.inGame.discardChanges.title", &[]);
let message = Text::translate("editGamerule.inGame.discardChanges.message", &[]);
Confirm::new(font, size, &title, &message)
}
impl RulesScreen {
@@ -301,13 +272,10 @@ impl RulesScreen {
let (w, h) = size;
let on = |x: f32, y: f32, bw: f32, bh: f32| ui::hovered(cursor, x, y, bw, bh);
if self.confirm {
let c = Confirm::new(&atlas.font, size);
let [(yx, yy), (nx, ny)] = c.buttons;
if on(yx, yy, 150.0, 20.0) {
return Some(Exit::Discard);
}
if on(nx, ny, 150.0, 20.0) {
self.confirm = false;
match confirm(&atlas.font, size).answer_at(cursor) {
Some(true) => return Some(Exit::Discard),
Some(false) => self.confirm = false,
None => {}
}
return None;
}
@@ -367,7 +335,7 @@ impl RulesScreen {
let (w, h) = size;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if self.confirm {
return Confirm::new(&atlas.font, size).buttons.into_iter().any(on);
return confirm(&atlas.font, size).answer_at(cursor).is_some();
}
let [done, cancel] = footer(w, h);
if (on(done) && !self.invalid()) || on(cancel) {
@@ -384,15 +352,11 @@ impl RulesScreen {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
if self.confirm {
let c = Confirm::new(&atlas.font, (w, h));
for (line, x, y) in &c.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), key) in c.buttons.into_iter().zip(["gui.yes", "gui.no"]) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
let label = Text::translate(key, &[]);
ui::button(batch, atlas, bx, by, 150.0, 20.0, &label, true, hover);
}
let labels = [
Text::translate("gui.yes", &[]),
Text::translate("gui.no", &[]),
];
confirm(&atlas.font, (w, h)).draw(batch, atlas, cursor, [&labels[0], &labels[1]]);
return;
}
let rows = self.rows();
+30 -17
View File
@@ -4,7 +4,7 @@ use super::Game;
use horizon_client::{
hud::HudAtlas,
lang,
pack_screen::{Compat, PackInfo, Source as PackSource},
pack_screen::{self, Compat, PackInfo, Source as PackSource},
packs::{self, Pack, Position},
text::{Style, Text},
};
@@ -34,10 +34,16 @@ pub fn open_folder(dir: &Path) {
log::warn!("Horizon: could not open {}: {e}", dir.display());
}
}
/// The resource root to load from, given the vanilla root and the selection.
pub fn startup_root(vanilla: &Path, selected: &[String]) -> std::io::Result<PathBuf> {
/// The resource root to load from, given the vanilla root, the selection and
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
pub fn startup_root(
vanilla: &Path,
selected: &[String],
server: &[Pack],
) -> std::io::Result<PathBuf> {
let all = packs::discover(vanilla, &packs_dir());
let enabled = packs::enabled(&all, selected);
let mut enabled = packs::enabled(&all, selected);
enabled.extend_from_slice(server);
packs::resource_root(vanilla, &enabled, &cache_dir())
}
fn info(p: &Pack) -> PackInfo {
@@ -53,14 +59,15 @@ fn info(p: &Pack) -> PackInfo {
PackSource::Default
},
compat: p.compatibility,
icon: p.icon,
icon: p.icon_png().as_deref().and_then(pack_screen::load_icon),
required: false,
position: Position::Top,
fixed: false,
}
}
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`).
fn vanilla_info() -> PackInfo {
/// The vanilla pack: required, at the bottom (`BuiltInPackSource`), with the
/// client JAR's `pack.png` that the prepared resources hold.
fn vanilla_info(vanilla: &Path) -> PackInfo {
PackInfo {
id: "vanilla".into(),
title: Text::translate("resourcePack.vanilla.name", &[]),
@@ -68,7 +75,10 @@ fn vanilla_info() -> PackInfo {
description_style: Style::default(),
source: PackSource::BuiltIn,
compat: Compat::Compatible,
icon: true,
icon: std::fs::read(vanilla.join("pack.png"))
.ok()
.as_deref()
.and_then(pack_screen::load_icon),
required: true,
position: Position::Bottom,
fixed: false,
@@ -80,7 +90,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
.iter()
.map(info)
.collect();
infos.push(vanilla_info());
infos.push(vanilla_info(vanilla));
infos
}
impl Game<'_> {
@@ -113,14 +123,7 @@ impl Game<'_> {
self.pending_pack_reload = true;
return;
}
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let result = startup_root(&vanilla, &self.settings.resource_packs)
.map_err(Into::into)
.and_then(|root| self.reload_resources(&root));
if let Err(e) = result {
if let Err(e) = self.reload_selected_packs() {
log::warn!("Horizon: resource reload failed: {e}");
if self.screen != super::Screen::Options {
self.open_options_at(
@@ -131,6 +134,16 @@ impl Game<'_> {
self.ui.options.resource_error = Some(e.to_string());
}
}
/// Reload from the selected packs and the server's.
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let server = &self.server_packs.active;
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
self.reload_resources(&root)
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
+13
View File
@@ -305,6 +305,16 @@ impl Game<'_> {
// A signed-in account always plays under its own name.
let auth = horizon_client::auth::current();
let name = auth.as_ref().map_or(name, |a| a.name.as_str());
let user = auth.as_ref().map_or_else(
|| {
(
name.to_owned(),
horizon_protocol::crypto::offline_uuid(name),
)
},
|a| (a.name.clone(), a.uuid),
);
self.server_packs_connect(local.is_none().then_some(address), user);
let session = match local {
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
None => {
@@ -453,6 +463,7 @@ impl Game<'_> {
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
self.server_packs_disconnect();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -827,6 +838,8 @@ impl Game<'_> {
}
}
Event::Reconfigured(registries) => self.set_registries(registries),
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
@@ -3,12 +3,15 @@
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
//! own simulation, which a client of a server doesn't run.
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{Mob, Species, tame};
use horizon_protocol::entity_data::Value;
use horizon_protocol::registry::Builtin;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
/// `builtin` names the ids of built-in registries.
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
let species = m.species();
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
@@ -42,16 +45,45 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
m.extra.size = (*size).clamp(1, 127);
}
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after
// Zombie's three): what VillagerProfessionLayer draws.
(Species::Villager, 19, Value::Ids(data))
| (Species::ZombieVillager, 20, Value::Ids(data)) => villager_data(m, data, builtin),
_ => {}
}
}
}
/// VillagerData as the network sends it: the type and the profession by
/// their built-in registry ids, then the level.
fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) {
let &[kind, profession, level] = data else {
return;
};
let index = |registry: &str, id: i32, names: &[&str]| {
let name = builtin
.get(registry)?
.name(id)?
.strip_prefix("minecraft:")?;
names.iter().position(|n| *n == name).map(|i| i as u8)
};
if let Some(t) = index("minecraft:villager_type", kind, &VILLAGER_TYPES) {
m.extra.variant = t;
}
if let Some(p) = index("minecraft:villager_profession", profession, &PROFESSIONS) {
m.extra.variant2 = p;
}
m.extra.variant3 = level.clamp(0, i32::from(u8::MAX)) as u8;
}
impl Game<'_> {
/// SetEntityData for a mob of the server's.
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let Some(r) = &self.remote else {
return;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
apply(m, values);
apply(m, values, &r.builtin);
}
}
}
@@ -72,11 +104,23 @@ mod tests {
/// A mob of the prepared resources' type `kind`, as a client spawns it
/// (None without the resources).
fn mob(kind: &str) -> Option<Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
fn root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
.join(horizon_core::VANILLA_VERSION)
}
/// The prepared resources' built-in registries (empty without them).
fn builtin() -> Builtin {
Builtin::load(&root().join("registries.json")).unwrap_or_default()
}
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
super::apply(m, values, &builtin());
}
fn mob(kind: &str) -> Option<Mob> {
let types = horizon_client::mobs::MobTypes::load(&root()).ok()?;
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
client_defaults(&mut m);
@@ -133,4 +177,45 @@ mod tests {
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
assert_eq!(m.fire_ticks, 0);
}
#[test]
fn a_villager_takes_its_type_profession_and_level() {
let Some(mut m) = mob("minecraft:villager") else {
return;
};
let registries = builtin();
let id = |registry: &str, name: &str| registries.get(registry)?.id(name);
let (Some(desert), Some(farmer), Some(librarian)) = (
id("minecraft:villager_type", "minecraft:desert"),
id("minecraft:villager_profession", "minecraft:farmer"),
id("minecraft:villager_profession", "minecraft:librarian"),
) else {
return;
};
let name = |m: &Mob| {
(
VILLAGER_TYPES[m.extra.variant as usize],
PROFESSIONS[m.extra.variant2 as usize],
m.extra.variant3,
)
};
apply(
&mut m,
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 2]))],
);
assert_eq!(name(&m), ("desert", "farmer", 2));
// A job taken later: only the profession and the level change.
apply(
&mut m,
&[(19, s::VILLAGER_DATA, Value::Ids(vec![desert, librarian, 5]))],
);
assert_eq!(name(&m), ("desert", "librarian", 5));
// A zombie villager's data comes after Zombie's three entries.
let mut z = mob("minecraft:zombie_villager").unwrap();
apply(
&mut z,
&[(20, s::VILLAGER_DATA, Value::Ids(vec![desert, farmer, 3]))],
);
assert_eq!(name(&z), ("desert", "farmer", 3));
}
}
@@ -0,0 +1,316 @@
//! A server's resource packs bound to the game (`ClientCommonPacketListenerImpl.
//! handleResourcePackPush` and `handleResourcePackPop`, its `PackConfirmScreen`,
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
//! server list's setting, downloads into `downloads/`, and a resource reload
//! with the server's packs on top of the player's own.
use super::Game;
use horizon_client::{
Result,
confirm_screen::Confirm,
hud::{HudAtlas, HudBatch},
multiplayer, packs,
scoreboard::{comp_text, tag_to_json},
server_packs::{Prompt, ServerPacks, action, download, parse_sha1, valid_url},
text::{Text, named_color},
ui,
};
use horizon_protocol::{packets::configuration::ResourcePackPush, servers};
use std::path::PathBuf;
/// `ClientCommonPacketListenerImpl.PackConfirmScreen`: the packs it asks
/// about, whether the last one is required, and the server's message.
pub(super) struct PackPrompt {
requests: Vec<(u128, String, String)>,
required: bool,
prompt: Option<Text>,
}
impl PackPrompt {
fn texts(&self) -> (Text, Text, [Text; 2]) {
let t = |key: &str| Text::translate(key, &[]);
let (title, line2) = if self.required {
let yellow = named_color("yellow").unwrap_or(0xFFFF55);
(
t("multiplayer.requiredTexturePrompt.line1"),
t("multiplayer.requiredTexturePrompt.line2")
.with_color(yellow)
.bold(),
)
} else {
(
t("multiplayer.texturePrompt.line1"),
t("multiplayer.texturePrompt.line2"),
)
};
// preparePackPrompt.
let message = match &self.prompt {
Some(prompt) => Text::translate(
"multiplayer.texturePrompt.serverPrompt",
&[line2, prompt.clone()],
),
None => line2,
};
let buttons = if self.required {
[t("gui.proceed"), t("menu.disconnect")]
} else {
[t("gui.yes"), t("gui.no")]
};
(title, message, buttons)
}
fn layout(&self, atlas: &HudAtlas, size: (f32, f32)) -> Confirm {
let (title, message, _) = self.texts();
Confirm::new(&atlas.font, size, &title, &message)
}
}
/// The game's `DownloadedPackSource`.
#[derive(Default)]
pub(super) struct ServerPackState {
packs: ServerPacks,
/// `downloads/` in the game folder, opened at the first download.
queue: Option<download::Queue>,
pub(super) prompt: Option<PackPrompt>,
/// The server list entry the setting is saved to (none: a direct join).
address: Option<String>,
/// The account the downloads are made for (the download headers).
user: (String, u128),
/// The packs loaded on top of the player's own, bottom first.
pub(super) active: Vec<packs::Pack>,
}
fn saves() -> PathBuf {
std::env::current_dir().unwrap_or_default().join("saves")
}
impl Game<'_> {
/// `ConnectScreen`: the server's setting decides what its packs do
/// (`configureForServerControl`); single player's own server takes them
/// all, quietly (`configureForLocalWorld`).
pub(super) fn server_packs_connect(&mut self, address: Option<&str>, user: (String, u128)) {
let state = &mut self.server_packs;
state.user = user;
state.prompt = None;
let Some(address) = address else {
state.address = None;
state.packs.local_world();
return;
};
let file = multiplayer::servers_file(&saves());
let entry = servers::load(&file).into_iter().find(|s| s.ip == address);
let setting = entry.as_ref().and_then(|e| e.accept_textures);
state.address = entry.map(|e| e.ip);
state.packs.connect(Prompt::for_server(setting));
}
/// `Minecraft.clearDownloadedResourcePacks`: every server pack goes at once.
pub(super) fn server_packs_disconnect(&mut self) {
self.server_packs.prompt = None;
self.server_packs.packs.disconnect();
self.tick_server_packs();
}
/// `handleResourcePackPush`.
pub(super) fn handle_pack_push(&mut self, p: ResourcePackPush) {
if !valid_url(&p.url) {
self.send_pack_status(p.id, action::INVALID_URL);
return;
}
let setting = self.server_setting();
let prompt = Prompt::for_server(setting);
if prompt != Prompt::Pending && (!p.required || prompt != Prompt::Declined) {
self.server_packs
.packs
.push(p.id, &p.url, parse_sha1(&p.hash));
return;
}
// addOrUpdatePackPrompt: one prompt for every pack pushed before it is answered.
let text = p.prompt.as_ref().map(|t| comp_text(&tag_to_json(t)));
let state = &mut self.server_packs;
let open = state.prompt.get_or_insert(PackPrompt {
requests: Vec::new(),
required: p.required,
prompt: None,
});
open.requests.push((p.id, p.url, p.hash));
open.required = p.required;
open.prompt = text;
self.capture(false);
}
/// `handleResourcePackPop`: one pack, or all of them.
pub(super) fn handle_pack_pop(&mut self, id: Option<u128>) {
match id {
Some(id) => self.server_packs.packs.pop(id),
None => self.server_packs.packs.pop_all(),
}
}
/// The joined server's entry in the server list: its `acceptTextures`.
fn server_setting(&self) -> Option<bool> {
let address = self.server_packs.address.as_deref()?;
servers::load(&multiplayer::servers_file(&saves()))
.into_iter()
.find(|s| s.ip == address)?
.accept_textures
}
fn send_pack_status(&self, id: u128, status: i32) {
if let Some(r) = &self.remote {
r.session.send_pack_status(id, status);
}
}
/// The prompt's answer (`PackConfirmScreen`'s callback): the server's
/// setting follows it, declining a required pack disconnects, and every
/// pack it asked about is pushed.
pub(super) fn answer_pack_prompt(&mut self, yes: bool) -> Result<()> {
let Some(prompt) = self.server_packs.prompt.take() else {
return Ok(());
};
if self.player.is_some() && self.screen == super::Screen::Playing {
self.capture(true);
}
let setting = if yes {
self.server_packs.packs.allow();
Some(true)
} else {
self.server_packs.packs.reject();
if prompt.required {
let reason = Text::translate("multiplayer.requiredTexturePrompt.disconnect", &[]);
return self.leave_server(Some(reason.plain()));
}
Some(false)
};
for (id, url, hash) in prompt.requests {
self.server_packs.packs.push(id, &url, parse_sha1(&hash));
}
// ServerList.saveSingleServer.
if let Some(address) = &self.server_packs.address {
let file = multiplayer::servers_file(&saves());
let mut list = servers::load(&file);
for entry in list.iter_mut().filter(|s| &s.ip == address) {
entry.accept_textures = setting;
}
if let Err(e) = servers::save(&file, &list) {
log::warn!("Horizon: could not save {}: {e}", file.display());
}
}
Ok(())
}
/// Runs what the packs need (`createUpdateScheduler`): the downloads
/// that finished, the ones to start, a resource reload when the active
/// packs change, and the statuses for the server.
pub(super) fn tick_server_packs(&mut self) {
// A reload waits for the world's resources (`reload_packs`).
if self.rest_loader.is_some() || self.world_loader.is_some() {
return;
}
let finished = self
.server_packs
.queue
.as_ref()
.map(download::Queue::finished)
.unwrap_or_default();
for batch in finished {
self.server_packs.packs.downloaded(batch);
}
while self.server_packs.packs.wants_tick() {
let tick = self.server_packs.packs.tick();
if !tick.download.is_empty() {
self.start_pack_downloads(tick.download);
}
if let Some(mut load) = tick.reload {
while let Some(recovery) = {
let ok = self.reload_server_packs(&load);
self.server_packs.packs.reloaded(ok)
} {
load = recovery;
}
}
}
for (id, status) in self.server_packs.packs.take_replies() {
self.send_pack_status(id, status);
}
}
fn start_pack_downloads(&mut self, requests: Vec<horizon_client::server_packs::Request>) {
let state = &mut self.server_packs;
if state.queue.is_none() {
let dir = std::env::current_dir()
.unwrap_or_default()
.join("downloads");
match download::Queue::open(dir) {
Ok(queue) => state.queue = Some(queue),
Err(e) => {
log::error!("Horizon: failed to open the download queue: {e}");
let failed = requests.iter().map(|r| r.id).collect();
state.packs.downloaded(horizon_client::server_packs::Batch {
downloaded: Vec::new(),
failed,
});
return;
}
}
}
let headers = download::headers(&state.user.0, state.user.1);
if let Some(queue) = &state.queue {
queue.download(requests, headers);
}
}
/// `DownloadedPackSource.startReload`: the packs on top of the player's
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
/// readable `pack.mcmeta` or the reload failed.
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
let packs: Option<Vec<_>> = load
.iter()
.map(|(id, path)| packs::server_pack(*id, path))
.collect();
let Some(packs) = packs else {
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
return false;
};
self.server_packs.active = packs;
match self.reload_selected_packs() {
Ok(()) => true,
Err(e) => {
log::warn!("Horizon: server resource pack reload failed: {e}");
false
}
}
}
pub(super) fn draw_pack_prompt(&self, batch: &mut HudBatch) {
let Some(prompt) = &self.server_packs.prompt else {
return;
};
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
if self.player.is_none() {
// In place of the connecting screen, on its background.
batch.fill(
atlas,
0.0,
0.0,
w,
h,
horizon_client::hud::rgba(20, 20, 20, 255),
);
}
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.player.is_some());
let (_, _, [yes, no]) = prompt.texts();
prompt
.layout(atlas, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
}
/// A left click on the prompt: its answer when it hit a button.
pub(super) fn pack_prompt_click(&mut self) -> Result<()> {
let Some(prompt) = &self.server_packs.prompt else {
return Ok(());
};
let answer = prompt
.layout(&self.gui.atlas, self.gui_size())
.answer_at(self.cursor);
match answer {
Some(yes) => {
self.audio.play_button_click();
self.answer_pack_prompt(yes)
}
None => Ok(()),
}
}
}
@@ -219,12 +219,11 @@ pub(super) fn spawn_loader(
.name("resource-loader".into())
.spawn(move || {
let mut pack_error = None;
let root = packs::startup_root(&vanilla_root, &settings.resource_packs).unwrap_or_else(
|error| {
let root = packs::startup_root(&vanilla_root, &settings.resource_packs, &[])
.unwrap_or_else(|error| {
pack_error = Some(error.to_string());
vanilla_root.clone()
},
);
});
let root = mods::content_root(&root, &settings.mods.enabled, &dev_mods);
let result = load_resources(
root,
+16 -1
View File
@@ -81,6 +81,12 @@ pub const MINIMAP_SPRITE: &str = "horizon/minimap";
pub const SKIN_SLOTS: usize = 32;
/// Reused by the visible rows of the classic world selector.
pub const WORLD_ICON_SLOTS: usize = 32;
/// Atlas slots for the pack icons on the pack screen, one per icon drawn
/// (`PackSelectionScreen.loadPackIcon` registers a texture per pack): two lists
/// of 36-pixel rows at GUI scale 2 on a 4K screen still fit.
pub const PACK_ICON_SLOTS: usize = 64;
/// A pack icon slot's side: vanilla's own icons are 128x128.
pub const PACK_ICON_SIZE: u32 = 128;
/// Atlas slots for armour trim textures made at run time (`trims`), each the
/// size of the largest trim texture.
pub const TRIM_SLOTS: usize = 32;
@@ -252,7 +258,7 @@ impl HudAtlas {
)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
@@ -284,6 +290,15 @@ impl HudAtlas {
vec![0; 64 * 64 * 4],
));
}
let side = PACK_ICON_SIZE as usize;
for i in 0..PACK_ICON_SLOTS {
images.push((
format!("horizon/pack-icon{i}"),
PACK_ICON_SIZE,
PACK_ICON_SIZE,
vec![0; side * side * 4],
));
}
// The on-screen keyboard's text and corner masks (ui_theme), rasterized at run time.
images.push((
"horizon/ui-text".into(),
+209 -6
View File
@@ -119,6 +119,88 @@ fn component_value(tag: &horizon_world::nbt::Tag) -> crate::server::json::Json {
}
}
/// The inverse of [`component_value`]: a JSON component (a server.properties
/// prompt) as ComponentSerialization.CODEC writes it with NbtOps. An array is
/// its first component with the rest appended (`createFromList`); a list of
/// components mixing plain strings and objects has the strings as `{text}`.
pub fn component_tag(v: &crate::server::json::Json) -> horizon_world::nbt::Tag {
use crate::server::json::Json;
use horizon_world::nbt::{JavaString, Tag};
fn list(items: &[Json]) -> Tag {
same_kind(items.iter().map(component_tag).collect())
}
fn same_kind(mut tags: Vec<Tag>) -> Tag {
if tags.iter().any(|t| matches!(t, Tag::Compound(_))) {
for t in &mut tags {
if let Tag::String(s) = t {
*t = Tag::compound([("text", Tag::String(s.clone()))]);
}
}
}
Tag::List(tags)
}
fn value(v: &Json) -> Tag {
match v {
Json::Bool(b) => Tag::Byte(i8::from(*b)),
Json::Number(n) => n.parse().map_or_else(
|_| Tag::Double(n.parse::<f64>().unwrap_or(0.0).to_bits()),
Tag::Int,
),
Json::String(s) => Tag::String(JavaString::from(s.as_str())),
Json::Array(items) => list(items),
Json::Object(members) => Tag::compound(
members
.iter()
.map(|(k, v)| (k.as_str(), value(v)))
.collect::<Vec<_>>(),
),
Json::Null => Tag::String(JavaString::from("")),
}
}
match v {
Json::Array(items) if !items.is_empty() => {
let mut first = component_tag(&items[0]);
if let Tag::String(s) = &first {
first = Tag::compound([("text", Tag::String(s.clone()))]);
}
if let Tag::Compound(map) = &mut first {
let mut extra = match map.remove(&JavaString::from("extra")) {
Some(Tag::List(e)) => e,
_ => Vec::new(),
};
extra.extend(items[1..].iter().map(component_tag));
map.insert(JavaString::from("extra"), same_kind(extra));
}
first
}
Json::Object(members) => {
if let [(key, Json::String(text))] = members.as_slice()
&& key == "text"
{
return Tag::String(JavaString::from(text.as_str()));
}
Tag::compound(
members
.iter()
.map(|(k, v)| {
let tag = match k.as_str() {
"extra" | "with" => match v {
Json::Array(items) => list(items),
other => value(other),
},
_ => value(v),
};
(k.as_str(), tag)
})
.collect::<Vec<_>>(),
)
}
Json::String(s) | Json::Number(s) => Tag::String(JavaString::from(s.as_str())),
Json::Bool(b) => Tag::String(JavaString::from(b.to_string().as_str())),
_ => Tag::String(JavaString::from("")),
}
}
/// StringUtil.isValidPlayerName.
pub fn valid_name(name: &str) -> bool {
(1..=16).contains(&name.len()) && name.chars().all(|c| c as u32 > 32 && (c as u32) < 127)
@@ -745,6 +827,14 @@ fn login_and_configure(
send(c, cfg::Clientbound::RegistryData(packet))?;
}
send(c, cfg::Clientbound::UpdateTags(sync.tags.clone()))?;
if let Some(pack) = &shared.config.resource_pack
&& let Some(rejected) = offer_pack(c, pack, &mut info)?
{
let (reason, text) = pack_rejected(shared, name, rejected);
let _ = send(c, cfg::Clientbound::Disconnect(cfg::Disconnect { reason }));
lost(shared, &format!("{name} ({})", uuid_string(uuid)), &text);
return Ok(None);
}
send(c, cfg::Clientbound::FinishConfiguration(Empty))?;
loop {
let (id, payload) = c.recv().map_err(e)?;
@@ -763,6 +853,79 @@ fn login_and_configure(
}))
}
/// ServerboundResourcePackPacket.Action.DECLINED.
const PACK_DECLINED: i32 = 1;
/// ServerCommonPacketListenerImpl.handleResourcePackResponse: a player who
/// declined the required pack is disconnected. The reason and its text.
fn pack_rejected(shared: &Shared, name: &str, pack: u128) -> (horizon_world::nbt::Tag, String) {
let reason = translatable("multiplayer.requiredTexturePrompt.disconnect", &[]);
let Some(log) = &shared.config.log else {
return (reason, String::new());
};
let line = format!(
"Disconnecting {name} due to resource pack {} rejection",
uuid_string(pack)
);
(log.write)("Server thread", "INFO", &line);
let text = horizon_protocol::text::plain(&reason, &log.lang);
(reason, text)
}
/// ServerResourcePackConfigurationTask: offer the server's pack, then wait
/// for the client's final answer (`Action.isTerminal`: anything but
/// ACCEPTED or DOWNLOADED). The player may take a while to answer the
/// prompt, so the connection is kept alive meanwhile, as
/// ServerConfigurationPacketListenerImpl.tick does. The id of a required
/// pack the client declined, which disconnects it.
fn offer_pack(
c: &mut Connection,
pack: &crate::server::properties::ResourcePackInfo,
info: &mut ClientInformation,
) -> Result<Option<u128>, String> {
let e = |e: horizon_protocol::Error| e.to_string();
let push = cfg::ResourcePackPush {
id: pack.id,
url: pack.url.clone(),
hash: pack.hash.clone(),
required: pack.required,
prompt: pack.prompt.as_ref().map(component_tag),
};
c.send(&cfg::Clientbound::ResourcePackPush(push).encode())
.map_err(e)?;
std::thread::scope(|scope| {
let (stop, stopped) = channel::<()>();
let writer = &mut c.writer;
scope.spawn(move || {
let start = Instant::now();
while stopped.recv_timeout(KEEP_ALIVE_EVERY) == Err(RecvTimeoutError::Timeout) {
let id = i64::try_from(start.elapsed().as_millis()).unwrap_or(0);
let keep_alive = cfg::Clientbound::KeepAlive(cfg::KeepAlive { id });
if writer.send(&keep_alive.encode()).is_err() {
break;
}
}
});
let answer = loop {
let (id, payload) = match c.reader.recv() {
Ok(packet) => packet,
Err(err) => break Err(err.to_string()),
};
match cfg::Serverbound::decode(id, &payload) {
Ok(cfg::Serverbound::ResourcePack(r)) if !matches!(r.action, 3 | 4) => {
let rejected = r.action == PACK_DECLINED && pack.required;
break Ok(rejected.then_some(r.id));
}
Ok(cfg::Serverbound::ClientInformation(i)) => *info = i,
Ok(_) => {}
Err(err) => break Err(err.to_string()),
}
};
drop(stop);
answer
})
}
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
let conn_id = shared.next_id();
let events = shared.events.clone();
@@ -784,6 +947,7 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
})
.ok()
};
let name = parts.name.clone();
let joined = Joined {
conn: conn_id,
uuid: parts.uuid,
@@ -795,8 +959,20 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
latency,
address,
};
let required_pack = shared
.config
.resource_pack
.as_ref()
.is_some_and(|p| p.required);
let reason = if events.send(NetEvent::Joined(Box::new(joined))).is_ok() {
read_loop(&mut reader, conn_id, &events, &alive)
match read_loop(&mut reader, conn_id, &events, &alive, required_pack) {
Ok(reason) => reason,
Err(rejected) => {
let (reason, text) = pack_rejected(shared, &name, rejected);
let _ = tx.send(Out::Disconnect(reason));
text
}
}
} else {
"server closed".to_owned()
};
@@ -808,12 +984,16 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
shared.release(parts.uuid);
}
/// Forward the player's packets until the connection ends: why it did, or
/// the id of a required resource pack the player declined (with
/// `required_pack`), which disconnects them.
fn read_loop(
reader: &mut ConnReader,
conn: u32,
events: &Sender<NetEvent>,
alive: &AtomicI64,
) -> String {
required_pack: bool,
) -> Result<String, u128> {
loop {
let (id, payload) = match reader.recv() {
Ok(p) => p,
@@ -824,19 +1004,24 @@ fn read_loop(
|| e.0.contains("reset by peer")
|| e.0.contains("aborted") =>
{
return "Disconnected".to_owned();
return Ok("Disconnected".to_owned());
}
Err(e) => return e.to_string(),
Err(e) => return Ok(e.to_string()),
};
match play::Serverbound::decode(id, &payload) {
Ok(play::Serverbound::KeepAlive(k)) => alive.store(k.id, Ordering::Relaxed),
Ok(play::Serverbound::ResourcePack(r))
if r.action == PACK_DECLINED && required_pack =>
{
return Err(r.id);
}
Ok(p) => {
if events.send(NetEvent::Packet(conn, Box::new(p))).is_err() {
return "server closed".to_owned();
return Ok("server closed".to_owned());
}
}
// A malformed packet disconnects like vanilla's decoder exception.
Err(e) => return format!("Invalid packet {id}: {e}"),
Err(e) => return Ok(format!("Invalid packet {id}: {e}")),
}
}
}
@@ -988,6 +1173,24 @@ fn write_loop(
mod tests {
use super::*;
#[test]
fn prompts_become_the_components_vanilla_sends() {
let tag = |json: &str| {
let tag = component_tag(&crate::server::json::parse(json, true).unwrap());
component_json(&tag)
};
assert_eq!(tag(r#"{"text":"Hi"}"#), r#""Hi""#);
assert_eq!(tag(r#""Hi""#), r#""Hi""#);
assert_eq!(
tag(r#"["Hi ",{"text":"there","bold":true}]"#),
r#"{"text":"Hi ","extra":[{"text":"there","bold":true}]}"#
);
assert_eq!(
tag(r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#),
r#"{"translate":"a","with":["b",{"text":"c","color":"red"}]}"#
);
}
fn unpack(out: Out) -> Vec<Vec<u8>> {
match out {
Out::Packet(body) => vec![body],
+4
View File
@@ -139,6 +139,9 @@ pub struct NetConfig {
/// Online mode: players are authenticated (none on a LAN world).
pub online: Option<Arc<OnlineMode>>,
pub log: Option<NetLog>,
/// server.properties `resource-pack`: offered to every player during
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
pub resource_pack: Option<crate::server::properties::ResourcePackInfo>,
}
impl NetConfig {
/// MinecraftServer.enforceSecureProfile.
@@ -164,6 +167,7 @@ impl NetConfig {
gate: None,
online: None,
log: None,
resource_pack: None,
}
}
}
+2
View File
@@ -41,6 +41,7 @@ pub mod command;
pub mod command_block;
pub mod command_storage;
pub mod component;
pub mod confirm_screen;
pub mod container_screen;
pub mod containers;
pub mod control;
@@ -157,6 +158,7 @@ pub mod screen_effects;
pub mod screen_layout;
pub mod sculk;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod settings;
pub mod shadows;
pub mod signs;
+45 -4
View File
@@ -636,11 +636,12 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
} else {
"entity/zombie_villager"
};
let baby = if m.is_baby() { "baby/" } else { "" };
// VillagerProfessionLayer: a baby's type texture is in `baby/`.
let group = if m.is_baby() { "baby" } else { "type" };
push(
&mut v,
&model,
&format!("{folder}/type/{baby}{t}"),
&format!("{folder}/{group}/{t}"),
white,
false,
);
@@ -1098,9 +1099,11 @@ fn render_visible(
let mut poses = Poses::new(&part);
anim::setup(m, &s, &mut poses, &data.animations);
if matches!(m.species(), Species::Villager | Species::ZombieVillager)
&& let Some((folder, variant)) = layer.texture.split_once("/type/")
&& let Some((folder, variant)) = layer
.texture
.split_once("/type/")
.or_else(|| layer.texture.split_once("/baby/"))
{
let variant = variant.strip_prefix("baby/").unwrap_or(variant);
let profession = PROFESSIONS[m.extra.variant2 as usize % PROFESSIONS.len()];
data.villager_hats
.read()
@@ -1275,6 +1278,44 @@ pub fn held_item_transform(
mod tests {
use super::*;
/// VillagerProfessionLayer's textures (type, or baby for a baby;
/// profession; level) of every villager and zombie villager are in the
/// prepared resources: a missing one draws nothing.
#[test]
fn villager_layers_name_textures_that_exist() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
let types = super::super::MobTypes::load(&root).unwrap();
for kind in ["minecraft:villager", "minecraft:zombie_villager"] {
let kind = std::sync::Arc::new(types.types[kind].clone());
for baby in [false, true] {
for t in 0..VILLAGER_TYPES.len() {
for p in 0..PROFESSIONS.len() {
let mut m =
Mob::new(1, kind.clone(), horizon_core::geometry::Vec3::ZERO, 0.0);
m.extra.age = if baby { -24000 } else { 0 };
(m.extra.variant, m.extra.variant2, m.extra.variant3) =
(t as u8, p as u8, 5);
let layers = layers(&m);
let group = if baby { "/baby/" } else { "/type/" };
assert!(
layers.iter().any(|l| l.texture.contains(group)),
"{kind:?} baby={baby}: no {group} layer"
);
for layer in layers {
let png = root
.join(format!("assets/minecraft/textures/{}.png", layer.texture));
assert!(png.is_file(), "missing {}", png.display());
}
}
}
}
}
}
#[test]
fn sulfur_cube_opaque_core_is_submitted_before_its_translucent_shell() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+94 -2
View File
@@ -67,6 +67,9 @@ pub enum Screen {
name: String,
address: String,
focus: u8,
/// Server Resource Packs (`acceptTextures`): Enabled, Disabled, or
/// none for Prompt.
packs: Option<bool>,
},
Direct {
address: String,
@@ -224,6 +227,19 @@ fn entry_address(servers: &[ServerEntry], lan: &Scanner, index: usize) -> Option
}
}
/// The Server Resource Packs button's label: `CommonComponents.optionNameValue`.
fn pack_setting_label(packs: Option<bool>) -> String {
let (key, fallback) = match packs {
Some(true) => ("manageServer.resourcePack.enabled", "Enabled"),
Some(false) => ("manageServer.resourcePack.disabled", "Disabled"),
None => ("manageServer.resourcePack.prompt", "Prompt"),
};
let name = crate::lang::get_or("manageServer.resourcePack", "Server Resource Packs");
crate::lang::get_or("options.generic_value", "%s: %s")
.replacen("%s", &name, 1)
.replacen("%s", &crate::lang::get_or(key, fallback), 1)
}
/// JoinMultiplayerScreen's `HeaderAndFooterLayout(33, 60)`.
const HEADER: f32 = 33.0;
const FOOTER: f32 = 60.0;
@@ -461,7 +477,16 @@ impl Screen {
),
]
}
Self::Edit { address, .. } => vec![
Self::Edit { address, packs, .. } => vec![
// ManageServerScreen's CycleButton, above Done.
b(
w / 2.0 - 100.0,
h / 4.0 + 72.0,
200.0,
&pack_setting_label(*packs),
true,
60,
),
b(
w / 2.0 - 100.0,
h / 4.0 + 96.0 + 18.0,
@@ -847,6 +872,7 @@ impl Screen {
name,
address,
focus,
..
} => {
centered(
batch,
@@ -996,6 +1022,7 @@ impl Screen {
name: "Minecraft Server".into(),
address: String::new(),
focus: 1,
packs: None,
}
}
(
@@ -1011,6 +1038,7 @@ impl Screen {
name: s.name,
address: s.ip,
focus: 0,
packs: s.accept_textures,
};
}
}
@@ -1125,21 +1153,32 @@ impl Screen {
*selected = Some(index);
}
}
// CycleButton: Enabled, Disabled, Prompt, and round again.
(Self::Edit { packs, .. }, Some(60)) => {
*packs = match *packs {
Some(true) => Some(false),
Some(false) => None,
None => Some(true),
}
}
(
Self::Edit {
index,
name,
address,
packs,
..
},
Some(50),
) => {
let mut list = servers::load(&servers_file(saves));
let entry = ServerEntry::new(name.trim(), address.trim());
let mut entry = ServerEntry::new(name.trim(), address.trim());
entry.accept_textures = *packs;
match index.and_then(|i| list.get_mut(i)) {
Some(e) => {
e.name = entry.name;
e.ip = entry.ip;
e.accept_textures = entry.accept_textures;
}
None => list.push(entry),
}
@@ -1637,4 +1676,57 @@ mod tests {
assert_eq!(names(), ["Keep"]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn edit_cycles_and_saves_the_server_resource_pack_setting() {
let dir = std::env::temp_dir().join(format!("horizon-mp-packs-{}", std::process::id()));
let saves = dir.join("saves");
std::fs::create_dir_all(&saves).unwrap();
let file = servers_file(&saves);
let mut entry = ServerEntry::new("Packs", "127.0.0.1:4");
entry.accept_textures = Some(false);
servers::save(&file, &[entry]).unwrap();
let size = (427.0, 240.0);
let mut screen = Screen::list(&saves);
if let Screen::List { selected, .. } = &mut screen {
*selected = Some(0);
}
screen.click(Some(43), 0.0, 0.0, size, &saves);
let cycle = |screen: &Screen| {
screen
.buttons(size.0, size.1)
.into_iter()
.find(|b| b.5 == 60)
.map(|b| (b.0, b.1, b.2, b.3))
};
// ManageServerScreen: 200 wide at height / 4 + 72, the saved value first.
assert_eq!(
cycle(&screen),
Some((
113.5,
132.0,
200.0,
"Server Resource Packs: Disabled".into()
))
);
// Disabled, Prompt, Enabled: Cancel keeps what was saved.
screen.click(Some(60), 0.0, 0.0, size, &saves);
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
screen.click(Some(51), 0.0, 0.0, size, &saves);
assert_eq!(servers::load(&file)[0].accept_textures, Some(false));
// Done saves it.
if let Screen::List { selected, .. } = &mut screen {
*selected = Some(0);
}
screen.click(Some(43), 0.0, 0.0, size, &saves);
screen.click(Some(60), 0.0, 0.0, size, &saves);
screen.click(Some(60), 0.0, 0.0, size, &saves);
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Enabled");
screen.click(Some(50), 0.0, 0.0, size, &saves);
assert_eq!(servers::load(&file)[0].accept_textures, Some(true));
// A new server asks.
screen.click(Some(42), 0.0, 0.0, size, &saves);
assert_eq!(cycle(&screen).unwrap().3, "Server Resource Packs: Prompt");
let _ = std::fs::remove_dir_all(&dir);
}
}
+58 -15
View File
@@ -20,6 +20,7 @@ use horizon_protocol::{
use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc::{Receiver, Sender, channel},
},
thread::JoinHandle,
@@ -43,6 +44,10 @@ pub enum Event {
DecodeError(String),
/// The server sent us back to configuration and finished it again.
Reconfigured(Arc<Dynamic>),
/// ClientboundResourcePackPushPacket, in configuration or in play.
PackPush(cfg::ResourcePackPush),
/// ClientboundResourcePackPopPacket: one pack, or every pack.
PackPop(Option<u128>),
/// The connection ended. `from_server`: the server gave the reason (or
/// the connection was refused for good), rather than the connection
/// failing (auto-reconnect retries only failures and shutdowns).
@@ -55,6 +60,9 @@ pub enum Event {
pub struct Session {
events: Receiver<Event>,
writer: Arc<Mutex<Option<ConnWriter>>>,
/// The connection is in configuration (else in play). Changed only
/// with `writer` locked, as the packet that switches is sent.
configuring: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
pub address: String,
pub name: String,
@@ -92,8 +100,10 @@ impl Session {
) -> Self {
let (send, events) = channel();
let writer = Arc::new(Mutex::new(None));
let configuring = Arc::new(AtomicBool::new(true));
let counters = Arc::new(Mutex::new(None));
let (thread_writer, thread_counters) = (writer.clone(), counters.clone());
let thread_configuring = configuring.clone();
let (address_owned, name_owned) = (address.to_owned(), name.to_owned());
let thread = std::thread::Builder::new()
.name("horizon-net".into())
@@ -107,6 +117,7 @@ impl Session {
&lang,
&send,
&thread_writer,
&thread_configuring,
&thread_counters,
);
let from_server = result.is_ok();
@@ -124,6 +135,7 @@ impl Session {
Self {
events,
writer,
configuring,
thread,
address: address.to_owned(),
name: name.to_owned(),
@@ -153,6 +165,21 @@ impl Session {
w.shutdown();
}
}
/// ServerboundResourcePackPacket, in whichever phase the connection is.
pub fn send_pack_status(&self, id: u128, action: i32) {
let status = cfg::ResourcePackResponse { id, action };
let mut writer = self.writer.lock().unwrap();
let body = if self.configuring.load(Ordering::Relaxed) {
cfg::Serverbound::ResourcePack(status).encode()
} else {
play::Serverbound::ResourcePack(status).encode()
};
if let Some(w) = writer.as_mut()
&& w.send(&body).is_err()
{
w.shutdown();
}
}
pub fn disconnect(&mut self) {
if let Some(w) = self.writer.lock().unwrap().take() {
w.shutdown();
@@ -253,6 +280,7 @@ fn run(
lang: &Language,
send: &Sender<Event>,
shared_writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
shared_counters: &Mutex<Option<Arc<Counters>>>,
) -> Result<String, String> {
let (host, port, mut c) = if let Some(pipe) = local {
@@ -377,18 +405,19 @@ fn run(
.map_err(e)?;
let (mut reader, writer) = c.split();
*shared_writer.lock().unwrap() = Some(writer);
let registries = configure(&mut reader, shared_writer, lang, send)?;
let registries = configure(&mut reader, shared_writer, configuring, lang, send)?;
let _ = send.send(Event::Joined {
registries: Arc::new(registries),
uuid: profile.uuid,
name: profile.name,
});
receive_play(&mut reader, shared_writer, lang, send)
receive_play(&mut reader, shared_writer, configuring, lang, send)
}
fn receive_play(
reader: &mut ConnReader,
shared_writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
lang: &Language,
send: &Sender<Event>,
) -> Result<String, String> {
@@ -416,15 +445,17 @@ fn receive_play(
play::Clientbound::KeepAlive(k) => reply(play::Serverbound::KeepAlive(k)),
play::Clientbound::BundleDelimiter(_) => {}
play::Clientbound::ResourcePackPush(p) => {
// Declined: Horizon has no server resource packs.
reply(play::Serverbound::ResourcePack(cfg::ResourcePackResponse {
id: p.id,
action: 1,
}));
let _ = send.send(Event::PackPush(p));
}
play::Clientbound::ResourcePackPop(p) => {
let _ = send.send(Event::PackPop(p.id));
}
play::Clientbound::StartConfiguration(_) => {
reply(play::Serverbound::ConfigurationAcknowledged(Empty));
let registries = configure(reader, shared_writer, lang, send)?;
if let Some(w) = shared_writer.lock().unwrap().as_mut() {
let _ = w.send(&play::Serverbound::ConfigurationAcknowledged(Empty).encode());
configuring.store(true, Ordering::Relaxed);
}
let registries = configure(reader, shared_writer, configuring, lang, send)?;
let _ = send.send(Event::Reconfigured(Arc::new(registries)));
}
play::Clientbound::Disconnect(d) => return Ok(plain(&d.reason, lang)),
@@ -445,6 +476,7 @@ fn receive_play(
fn configure(
reader: &mut ConnReader,
writer: &Mutex<Option<ConnWriter>>,
configuring: &AtomicBool,
lang: &Language,
send: &Sender<Event>,
) -> Result<Dynamic, String> {
@@ -471,11 +503,12 @@ fn configure(
cfg::Clientbound::CodeOfConduct(_) => {
reply(cfg::Serverbound::AcceptCodeOfConduct(Empty))?
}
// The game answers: it may prompt the player first.
cfg::Clientbound::ResourcePackPush(p) => {
reply(cfg::Serverbound::ResourcePack(cfg::ResourcePackResponse {
id: p.id,
action: 1,
}))?
let _ = send.send(Event::PackPush(p));
}
cfg::Clientbound::ResourcePackPop(p) => {
let _ = send.send(Event::PackPop(p.id));
}
cfg::Clientbound::CookieRequest(q) => {
reply(cfg::Serverbound::CookieResponse(cfg::CookieResponse {
@@ -485,7 +518,11 @@ fn configure(
}
cfg::Clientbound::Disconnect(d) => return Err(plain(&d.reason, lang)),
cfg::Clientbound::FinishConfiguration(_) => {
reply(cfg::Serverbound::FinishConfiguration(Empty))?;
let mut w = writer.lock().unwrap();
let w = w.as_mut().ok_or("Disconnected")?;
w.send(&cfg::Serverbound::FinishConfiguration(Empty).encode())
.map_err(|e| e.to_string())?;
configuring.store(false, Ordering::Relaxed);
return Ok(registries);
}
_ => {}
@@ -602,7 +639,13 @@ mod tests {
let (send, events) = channel();
let thread_writer = writer.clone();
let thread = std::thread::spawn(move || {
receive_play(&mut reader, &thread_writer, &Language::new(), &send)
receive_play(
&mut reader,
&thread_writer,
&AtomicBool::new(false),
&Language::new(),
&send,
)
});
let mut server = Connection::local(server);
let teleport = play::Clientbound::PlayerPosition(play::PlayerPosition {
+3 -1
View File
@@ -2079,7 +2079,9 @@ impl OptionsUi {
let blink = self.pack_focus.elapsed().as_millis() as u64;
let m = crate::screen_draw::Measure(&atlas.font);
let mouse = (cursor.0.floor() as i32, cursor.1.floor() as i32);
let draws = self.packs(atlas, (w, h)).draw(&m, mouse, blink);
let packs = self.packs(atlas, (w, h));
let mut draws = packs.draw(&m, mouse, blink);
packs.bind_icons(&mut draws, atlas, &mut batch.patches);
crate::screen_draw::paint(batch, atlas, &draws);
return;
}
+163 -3
View File
@@ -6,10 +6,13 @@
//! [`PackScreen::draw`] gives the screen's GUI calls. The game shows it;
//! tools/test_pack_screen_parity.py compares it with vanilla.
use crate::{
font::Patch,
hud::{HudAtlas, PACK_ICON_SIZE, PACK_ICON_SLOTS},
packs::{Position, Selectable, SelectionModel},
screen_draw::{self as d, Draw, Measure, TextField},
text::{Style, Text},
};
use std::{collections::HashMap, sync::Arc};
/// `PackCompatibility`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -50,8 +53,8 @@ pub struct PackInfo {
pub description_style: Style,
pub source: Source,
pub compat: Compat,
/// Whether the pack has a `pack.png`.
pub icon: bool,
/// The pack's `pack.png` ([`load_icon`]); none draws `unknown_pack`.
pub icon: Option<Arc<[u8]>>,
pub required: bool,
pub position: Position,
pub fixed: bool,
@@ -90,6 +93,27 @@ impl PackInfo {
(t, Style::color(d::GRAY))
}
}
/// A `pack.png` as the pack screen draws it: RGBA, stretched to a
/// `PACK_ICON_SIZE` square by nearest texels, as vanilla's icon texture is
/// sampled into its 32x32 square. None when it does not decode, where
/// `loadPackIcon` falls back to `unknown_pack`.
pub fn load_icon(png: &[u8]) -> Option<Arc<[u8]>> {
let (w, h, rgba) = crate::hud::decode_bytes(png).ok()?;
if w == 0 || h == 0 {
return None;
}
let size = u64::from(PACK_ICON_SIZE);
let mut out = Vec::with_capacity((size * size * 4) as usize);
for y in 0..size {
let sy = ((2 * y + 1) * u64::from(h) / (2 * size)) as usize;
for x in 0..size {
let sx = ((2 * x + 1) * u64::from(w) / (2 * size)) as usize;
let at = (sy * w as usize + sx) * 4;
out.extend_from_slice(&rgba[at..at + 4]);
}
}
Some(out.into())
}
/// `PackSelectionScreen.loadPackIcon`'s texture for a pack with an icon.
pub fn icon_texture(id: &str) -> String {
let sanitized: String = id
@@ -306,6 +330,8 @@ pub struct PackScreen {
confirm: Option<Confirm>,
/// The selection changed since the screen opened.
pub changed: bool,
/// The packs' icons by their [`icon_texture`].
icons: HashMap<String, Arc<[u8]>>,
}
fn tr(key: &str) -> Text {
@@ -329,11 +355,16 @@ impl PackScreen {
italic: Some(true),
..Style::default()
}));
let icons = packs
.iter()
.filter_map(|p| Some((icon_texture(&p.id), p.icon.clone()?)))
.collect();
let mut s = Self {
width,
height,
in_world,
packs,
icons,
model,
search,
focus: Focus::None,
@@ -760,6 +791,35 @@ impl PackScreen {
pub fn commit(&self) -> Vec<String> {
self.model.commit()
}
/// Points the icon blits of `draws` at the atlas's pack icon slots and
/// writes the icons into them, one slot per icon drawn (vanilla registers a
/// texture per pack). Icons past the last slot keep `unknown_pack`.
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
let mut slots = 0..PACK_ICON_SLOTS;
for draw in draws {
let Draw::Blit { texture, .. } = draw else {
continue;
};
let Some(icon) = self.icons.get(texture.as_str()) else {
continue;
};
let Some(slot) = slots.next() else {
return;
};
let sprite = format!("horizon/pack-icon{slot}");
let Some(rect) = atlas.sprites.get(&sprite) else {
return;
};
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
*texture = sprite;
}
}
/// The shown screen's calls (`Screen.extractRenderState`); `blink_ms`
/// is the time since the search box took the focus.
@@ -891,7 +951,7 @@ impl PackScreen {
-8978432,
);
}
let icon = if p.icon {
let icon = if p.icon.is_some() {
icon_texture(id)
} else {
"textures/misc/unknown_pack.png".into()
@@ -979,6 +1039,106 @@ impl PackScreen {
}
}
#[cfg(test)]
mod icon_tests {
use super::*;
fn png(w: u32, h: u32, rgba: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(rgba)
.unwrap();
out
}
fn pack(id: &str, icon: Option<Arc<[u8]>>) -> PackInfo {
PackInfo {
id: id.into(),
title: Text::literal(id),
description: Text::literal(""),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon,
required: false,
position: Position::Top,
fixed: false,
}
}
/// Any size stretches over the whole square, nearest texels, as the
/// icon texture is sampled into its 32x32 blit.
#[test]
fn icons_stretch_to_the_slot_by_nearest_texels() {
let (red, blue) = ([255, 0, 0, 255], [0, 0, 255, 128]);
let icon = load_icon(&png(2, 1, &[red, blue].concat())).unwrap();
let side = PACK_ICON_SIZE as usize;
assert_eq!(icon.len(), side * side * 4);
let at = |x: usize, y: usize| &icon[(y * side + x) * 4..][..4];
assert_eq!((at(0, 0), at(side / 2 - 1, side - 1)), (&red[..], &red[..]));
assert_eq!(
(at(side / 2, 0), at(side - 1, side - 1)),
(&blue[..], &blue[..])
);
assert!(load_icon(b"not a png").is_none());
}
/// Programmer Art and High Contrast draw their own `pack.png`; a pack
/// without one keeps `unknown_pack`.
#[test]
fn drawn_icons_take_atlas_slots() {
let atlas =
crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets"))
.unwrap();
let m = Measure(&atlas.font);
let dirt = load_icon(&png(1, 1, &[134, 96, 67, 255])).unwrap();
let contrast = load_icon(&png(1, 1, &[0, 0, 0, 255])).unwrap();
let packs = vec![
pack("programmer_art", Some(dirt.clone())),
pack("high_contrast", Some(contrast.clone())),
pack("file/Iconless", None),
];
let screen = PackScreen::new(&m, 480, 320, false, packs, &[]);
let mut draws = screen.draw(&m, (0, 0), 0);
let mut patches = Vec::new();
screen.bind_icons(&mut draws, &atlas, &mut patches);
let icons: Vec<&str> = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if texture.contains("pack") => Some(texture.as_str()),
_ => None,
})
.collect();
// The list shows them sorted by id.
assert_eq!(
icons,
[
"textures/misc/unknown_pack.png",
"horizon/pack-icon0",
"horizon/pack-icon1"
]
);
assert_eq!(patches.len(), 2);
for (i, (patch, icon)) in patches.iter().zip([&contrast, &dirt]).enumerate() {
let rect = atlas.sprites[&format!("horizon/pack-icon{i}")];
assert_eq!(
(patch.x, patch.y, patch.width, patch.height),
(
(rect.u0 * atlas.width as f32).round() as u32,
(rect.v0 * atlas.height as f32).round() as u32,
PACK_ICON_SIZE,
PACK_ICON_SIZE
)
);
assert_eq!(&patch.rgba[..], &icon[..]);
}
}
}
#[cfg(test)]
mod click_sound_tests {
use super::*;
+59 -7
View File
@@ -34,8 +34,6 @@ pub struct Pack {
pub description_style: Style,
pub compatible: bool,
pub compatibility: crate::pack_screen::Compat,
/// The pack has a `pack.png`.
pub icon: bool,
/// Overlay directories active for this game version, in `pack.mcmeta` order.
pub overlays: Vec<String>,
/// `filter.block` entries (namespace and path patterns) hiding resources
@@ -260,6 +258,15 @@ fn filter_of(meta: &serde_json::Value) -> Vec<Filter> {
})
.collect()
}
impl Pack {
/// The pack's `pack.png` (`PackResources.getRootResource`), for its icon.
pub fn icon_png(&self) -> Option<Vec<u8>> {
match &self.source {
Source::Dir(d) => fs::read(d.join("pack.png")).ok(),
Source::Zip(z) => Zip::open(z).ok()?.read("pack.png").ok(),
}
}
}
fn read_meta(source: &Source) -> Option<serde_json::Value> {
let bytes = match source {
Source::Dir(d) => fs::read(d.join("pack.mcmeta")).ok()?,
@@ -270,10 +277,6 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
let meta = read_meta(&source)?;
let (raw_description, description_style) = component_root(&meta["pack"]["description"]);
let icon = match &source {
Source::Dir(d) => d.join("pack.png").is_file(),
Source::Zip(z) => Zip::open(z).is_ok_and(|z| z.read("pack.png").is_ok()),
};
Some(Pack {
id,
title,
@@ -282,7 +285,6 @@ fn pack(id: String, title: Text, source: Source) -> Option<Pack> {
description_style,
compatible: compatible(&meta),
compatibility: compatibility(&meta),
icon,
overlays: overlays_of(&meta),
filter: filter_of(&meta),
source,
@@ -629,6 +631,17 @@ fn merge(kind: Merge, layers: &[Vec<u8>]) -> Option<Vec<u8>> {
};
serde_json::to_vec(&out).ok()
}
/// `DownloadedPackSource.loadRequestedPacks`: a server's downloaded pack,
/// "World Specific Resources"; None when its `pack.mcmeta` can't be read. Its
/// id names the file, which is named after its hash.
pub fn server_pack(id: u128, path: &Path) -> Option<Pack> {
let file = path.file_name()?.to_string_lossy();
pack(
format!("server/{id:032x}/{file}"),
Text::translate("resourcePack.server.name", &[]),
Source::Zip(path.to_path_buf()),
)
}
/// The packs to apply for an options.txt `resourcePacks` list (bottom first).
pub fn enabled(all: &[Pack], ids: &[String]) -> Vec<Pack> {
ids.iter()
@@ -983,6 +996,45 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn built_in_and_folder_packs_give_their_pack_png() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-icons-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
let built_in = base.join("vanilla/assets/minecraft/resourcepacks");
fs::create_dir_all(&built_in).unwrap();
let mut zip =
zip::ZipWriter::new(fs::File::create(built_in.join("programmer_art.zip")).unwrap());
for (name, data) in [("pack.mcmeta", &meta[..]), ("pack.png", b"dirt")] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap();
for (name, icon) in [("Iconless", None), ("Mine", Some(&b"mine"[..]))] {
let dir = base.join("packs").join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("pack.mcmeta"), meta).unwrap();
if let Some(icon) = icon {
fs::write(dir.join("pack.png"), icon).unwrap();
}
}
let packs = discover(&base.join("vanilla"), &base.join("packs"));
let icons: Vec<_> = packs
.iter()
.map(|p| (p.id.as_str(), p.icon_png()))
.collect();
assert_eq!(
icons,
[
("programmer_art", Some(b"dirt".to_vec())),
("file/Iconless", None),
("file/Mine", Some(b"mine".to_vec())),
]
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn overlays_and_filters() {
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
let meta = v(r#"{"overlays":{"entries":[
+618
View File
@@ -0,0 +1,618 @@
//! Server resource packs (`ServerPackManager`, `DownloadedPackSource`): the
//! packs a server pushes, the player's answer to its prompt, their downloads,
//! their activation on top of the player's own packs, and the status each one
//! reports back to the server. The game drives it: it shows the prompt,
//! starts the downloads [`ServerPacks::tick`] asks for and reloads the
//! resources with [`ServerPacks::active`] when it asks for a reload.
pub mod download;
use std::path::PathBuf;
/// `ServerboundResourcePackPacket.Action`, by its protocol number.
pub mod action {
pub const SUCCESSFULLY_LOADED: i32 = 0;
pub const DECLINED: i32 = 1;
pub const FAILED_DOWNLOAD: i32 = 2;
pub const ACCEPTED: i32 = 3;
pub const DOWNLOADED: i32 = 4;
pub const INVALID_URL: i32 = 5;
pub const FAILED_RELOAD: i32 = 6;
pub const DISCARDED: i32 = 7;
}
/// `ServerPackManager.PackPromptStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Prompt {
Pending,
Allowed,
Declined,
}
impl Prompt {
/// `ConnectScreen.convertPackStatus`: a server list entry's setting
/// (servers.dat `acceptTextures`; none is Prompt).
pub fn for_server(accept_textures: Option<bool>) -> Self {
match accept_textures {
Some(true) => Self::Allowed,
Some(false) => Self::Declined,
None => Self::Pending,
}
}
}
/// `ClientCommonPacketListenerImpl.parseResourcePackUrl`: only http and https.
pub fn valid_url(url: &str) -> bool {
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
}
/// `DownloadedPackSource.tryParseSha1Hash`: 40 hex digits, else no check.
pub fn parse_sha1(hash: &str) -> Option<String> {
(hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()))
.then(|| hash.to_ascii_lowercase())
}
/// One pack to download (`DownloadQueue.DownloadRequest`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Request {
pub id: u128,
pub url: String,
pub sha1: Option<String>,
}
/// `DownloadQueue.BatchResult`: the file each downloaded pack is in, and the
/// packs that failed.
#[derive(Debug, Default)]
pub struct Batch {
pub downloaded: Vec<(u128, PathBuf)>,
pub failed: Vec<u128>,
}
/// What the game has to do after a [`ServerPacks::tick`].
#[derive(Debug, Default, PartialEq)]
pub struct Tick {
/// Download these, then hand the result to [`ServerPacks::downloaded`].
pub download: Vec<Request>,
/// Reload the resources with these packs on top, bottom first
/// (`PackReloadConfig.scheduleReload`), then call [`ServerPacks::reloaded`].
pub reload: Option<Vec<(u128, PathBuf)>>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Download {
Requested,
Pending,
Done,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Activation {
Inactive,
Pending,
Active,
}
/// `ServerPackManager.RemovalReason`, with the status it reports.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Removal {
DownloadFailed,
ActivationFailed,
Declined,
Discarded,
ServerRemoved,
ServerReplaced,
}
impl Removal {
fn reply(self) -> Option<i32> {
match self {
Self::DownloadFailed => Some(action::FAILED_DOWNLOAD),
Self::ActivationFailed => Some(action::FAILED_RELOAD),
Self::Declined => Some(action::DECLINED),
Self::Discarded => Some(action::DISCARDED),
Self::ServerRemoved | Self::ServerReplaced => None,
}
}
}
/// `ServerPackManager.ServerPackData`.
#[derive(Debug)]
struct Pack {
id: u128,
url: String,
sha1: Option<String>,
path: Option<PathBuf>,
removal: Option<Removal>,
download: Download,
activation: Activation,
accepted: bool,
}
impl Pack {
fn remove(&mut self, reason: Removal) {
self.removal.get_or_insert(reason);
}
fn removed(&self) -> bool {
self.removal.is_some()
}
}
/// The reload in progress: the packs it loads and unloads (the callbacks
/// `triggerReloadIfNeeded` gives `scheduleReload`).
#[derive(Debug)]
struct Reload {
load: Vec<u128>,
unload: Vec<u128>,
/// It reloads what was active before a failed one (`isRecovery`).
recovery: bool,
}
/// `ServerPackManager` with the feedback `DownloadedPackSource` gives it.
#[derive(Debug)]
pub struct ServerPacks {
packs: Vec<Pack>,
prompt: Prompt,
reload: Option<Reload>,
/// `registerForUpdate`: something changed, [`Self::tick`] again.
update: bool,
/// Report to the server (`configureForServerControl`); otherwise the
/// statuses are only logged, as for a local world.
reporting: bool,
replies: Vec<(u128, i32)>,
}
impl Default for ServerPacks {
fn default() -> Self {
Self {
packs: Vec::new(),
prompt: Prompt::Pending,
reload: None,
update: false,
reporting: false,
replies: Vec::new(),
}
}
}
impl ServerPacks {
fn report(&mut self, id: u128, status: i32) {
log::debug!("Horizon: server pack {id:032x} changed status to {status}");
if self.reporting {
self.replies.push((id, status));
}
}
/// The statuses to send the server, oldest first.
pub fn take_replies(&mut self) -> Vec<(u128, i32)> {
std::mem::take(&mut self.replies)
}
/// Whether [`Self::tick`] has something to do.
pub fn wants_tick(&self) -> bool {
self.update
}
/// `DownloadedPackSource.configureForServerControl`.
pub fn connect(&mut self, prompt: Prompt) {
self.reporting = true;
match prompt {
Prompt::Allowed => self.allow(),
Prompt::Declined => self.reject(),
Prompt::Pending => self.prompt = Prompt::Pending,
}
}
/// `DownloadedPackSource.configureForLocalWorld`: single player's own
/// server; its packs are taken, and nothing is reported.
pub fn local_world(&mut self) {
self.reporting = false;
self.allow();
}
/// `DownloadedPackSource.cleanupAfterDisconnect`: every pack goes, and
/// the next server starts from its own setting.
pub fn disconnect(&mut self) {
self.pop_all();
self.reporting = false;
self.replies.clear();
self.prompt = Prompt::Pending;
}
pub fn push(&mut self, id: u128, url: &str, sha1: Option<String>) {
if self.prompt == Prompt::Declined {
self.report(id, action::DECLINED);
return;
}
for pack in self.packs.iter_mut().filter(|p| p.id == id) {
pack.remove(Removal::ServerReplaced);
}
self.packs.push(Pack {
id,
url: url.to_owned(),
sha1,
path: None,
removal: None,
download: Download::Requested,
activation: Activation::Inactive,
accepted: false,
});
if self.prompt == Prompt::Allowed {
self.accept(self.packs.len() - 1);
}
self.update = true;
}
fn accept(&mut self, i: usize) {
self.packs[i].accepted = true;
let id = self.packs[i].id;
self.report(id, action::ACCEPTED);
}
pub fn pop(&mut self, id: u128) {
if let Some(pack) = self.packs.iter_mut().find(|p| !p.removed() && p.id == id) {
pack.remove(Removal::ServerRemoved);
self.update = true;
}
}
pub fn pop_all(&mut self) {
for pack in &mut self.packs {
pack.remove(Removal::ServerRemoved);
}
self.update = true;
}
/// `allowServerPacks`: the prompt said yes (or the server is Enabled).
pub fn allow(&mut self) {
self.prompt = Prompt::Allowed;
for i in 0..self.packs.len() {
if !self.packs[i].accepted && !self.packs[i].removed() {
self.accept(i);
}
}
self.update = true;
}
/// `rejectServerPacks`: the prompt said no (or the server is Disabled).
pub fn reject(&mut self) {
self.prompt = Prompt::Declined;
for pack in self.packs.iter_mut().filter(|p| !p.accepted) {
pack.remove(Removal::Declined);
}
self.update = true;
}
/// `ServerPackManager.tick`: start the downloads the accepted packs
/// need; once none is left, reload if the active packs should change;
/// then forget the packs that are gone, reporting why.
pub fn tick(&mut self) -> Tick {
self.update = false;
let mut out = Tick::default();
let mut downloading = false;
for pack in self.packs.iter_mut().filter(|p| !p.removed() && p.accepted) {
downloading |= pack.download != Download::Done;
if pack.download == Download::Requested {
pack.download = Download::Pending;
out.download.push(Request {
id: pack.id,
url: pack.url.clone(),
sha1: pack.sha1.clone(),
});
}
}
if !downloading {
out.reload = self.reload_if_needed();
}
let mut gone = Vec::new();
self.packs.retain(|p| {
let keep = p.activation != Activation::Inactive || p.removal.is_none();
if !keep && let Some(reply) = p.removal.and_then(Removal::reply) {
gone.push((p.id, reply));
}
keep
});
for (id, reply) in gone {
self.report(id, reply);
}
out
}
fn reload_if_needed(&mut self) -> Option<Vec<(u128, PathBuf)>> {
if self.reload.is_some()
|| self
.packs
.iter()
.any(|p| p.activation == Activation::Pending)
{
return None;
}
let mut changed = false;
let (mut load, mut unload) = (Vec::new(), Vec::new());
for pack in &self.packs {
let wanted = pack.accepted && pack.download == Download::Done && !pack.removed();
match pack.activation {
Activation::Inactive if wanted => {
load.push(pack.id);
changed = true;
}
Activation::Active if wanted => load.push(pack.id),
Activation::Active => {
unload.push(pack.id);
changed = true;
}
_ => {}
}
}
if !changed {
return None;
}
for pack in &mut self.packs {
let loading = load.contains(&pack.id) && pack.activation != Activation::Active;
if loading || unload.contains(&pack.id) {
pack.activation = Activation::Pending;
}
}
let paths = self.paths(&load);
self.reload = Some(Reload {
load,
unload,
recovery: false,
});
Some(paths)
}
/// The packs to load (`PackReloadConfig.Callbacks.packsToLoad`), bottom first.
fn paths(&self, ids: &[u128]) -> Vec<(u128, PathBuf)> {
ids.iter()
.filter_map(|id| {
let pack = self
.packs
.iter()
.find(|p| p.id == *id && p.path.is_some())?;
Some((*id, pack.path.clone()?))
})
.collect()
}
/// `ServerPackManager.onDownload`.
pub fn downloaded(&mut self, batch: Batch) {
if !batch.failed.is_empty() {
for pack in self
.packs
.iter_mut()
.filter(|p| p.activation != Activation::Active)
{
pack.remove(if batch.failed.contains(&pack.id) {
Removal::DownloadFailed
} else {
Removal::Discarded
});
}
}
let mut done = Vec::new();
for (id, path) in batch.downloaded {
if let Some(pack) = self
.packs
.iter_mut()
.find(|p| p.id == id && p.download == Download::Pending)
{
pack.download = Download::Done;
pack.path = Some(path);
if !pack.removed() {
done.push(id);
}
}
}
for id in done {
self.report(id, action::DOWNLOADED);
}
self.update = true;
}
/// The reload [`Tick::reload`] asked for finished (`Callbacks.onSuccess`),
/// or its packs could not be loaded (`onFailure`). After a first failure
/// the packs that were active are loaded again: the packs to reload with
/// are returned, and this is called again with that reload's outcome.
pub fn reloaded(&mut self, ok: bool) -> Option<Vec<(u128, PathBuf)>> {
let mut reload = self.reload.take()?;
self.update = true;
if ok {
let mut applied = Vec::new();
for pack in &mut self.packs {
if reload.load.contains(&pack.id) {
pack.activation = Activation::Active;
if pack.removal.is_none() {
applied.push(pack.id);
}
} else if reload.unload.contains(&pack.id) {
pack.activation = Activation::Inactive;
}
}
for id in applied {
self.report(id, action::SUCCESSFULLY_LOADED);
}
return None;
}
if reload.recovery {
// onFailure(true): nothing of the server's is loaded.
for pack in &mut self.packs {
if pack.activation == Activation::Pending {
pack.activation = Activation::Inactive;
}
}
return None;
}
reload.load.clear();
for pack in &mut self.packs {
match pack.activation {
Activation::Inactive => pack.remove(Removal::Discarded),
Activation::Pending => {
pack.activation = Activation::Inactive;
pack.remove(Removal::ActivationFailed);
}
Activation::Active => reload.load.push(pack.id),
}
}
reload.recovery = true;
let paths = self.paths(&reload.load);
self.reload = Some(reload);
Some(paths)
}
}
#[cfg(test)]
mod tests {
use super::*;
use action::*;
fn ok(id: u128) -> Batch {
Batch {
downloaded: vec![(id, PathBuf::from(format!("/p/{id}")))],
failed: vec![],
}
}
/// Runs the updates as `createUpdateScheduler` does, downloading and
/// reloading with `download` and `reload` as the game would.
fn settle(
sp: &mut ServerPacks,
download: &mut dyn FnMut(&[Request]) -> Batch,
reload: &mut dyn FnMut(&[(u128, PathBuf)]) -> bool,
) {
let mut rounds = 0;
while sp.wants_tick() {
rounds += 1;
assert!(rounds < 50);
let t = sp.tick();
if !t.download.is_empty() {
let batch = download(&t.download);
sp.downloaded(batch);
}
if let Some(mut packs) = t.reload {
while let Some(back) = sp.reloaded(reload(&packs)) {
packs = back;
}
}
}
}
#[test]
fn an_enabled_server_s_pack_is_accepted_downloaded_and_loaded() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
let mut loaded = Vec::new();
settle(&mut sp, &mut |r| ok(r[0].id), &mut |p| {
loaded = p.to_vec();
true
});
assert_eq!(
sp.take_replies(),
[(1, ACCEPTED), (1, DOWNLOADED), (1, SUCCESSFULLY_LOADED)]
);
assert_eq!(loaded, [(1, PathBuf::from("/p/1"))]);
}
#[test]
fn the_prompt_holds_packs_until_answered() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Pending);
sp.push(1, "http://a/p.zip", None);
settle(&mut sp, &mut |_| unreachable!(), &mut |_| unreachable!());
assert!(sp.take_replies().is_empty());
sp.reject();
settle(&mut sp, &mut |_| unreachable!(), &mut |_| unreachable!());
assert_eq!(sp.take_replies(), [(1, DECLINED)]);
// Declined: later pushes are declined at once.
sp.push(2, "http://a/q.zip", None);
assert_eq!(sp.take_replies(), [(2, DECLINED)]);
}
#[test]
fn a_failed_download_discards_the_rest_of_the_batch() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
sp.push(2, "http://a/dead.zip", None);
settle(
&mut sp,
&mut |_| Batch {
downloaded: vec![(1, PathBuf::from("/p/1"))],
failed: vec![2],
},
&mut |_| unreachable!(),
);
assert_eq!(
sp.take_replies(),
[
(1, ACCEPTED),
(2, ACCEPTED),
(1, DISCARDED),
(2, FAILED_DOWNLOAD)
]
);
}
#[test]
fn a_pack_that_does_not_load_fails_its_reload() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
let mut reloads = Vec::new();
settle(&mut sp, &mut |r| ok(r[0].id), &mut |p| {
reloads.push(p.to_vec());
reloads.len() > 1
});
assert_eq!(
sp.take_replies(),
[(1, ACCEPTED), (1, DOWNLOADED), (1, FAILED_RELOAD)]
);
// The recovery reload has none of the server's packs.
assert_eq!(reloads[1], []);
}
#[test]
fn popping_and_disconnecting_unload_the_packs() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/p.zip", None);
sp.push(2, "http://a/q.zip", None);
let last = std::cell::RefCell::new(Vec::new());
let mut reload = |p: &[(u128, PathBuf)]| {
*last.borrow_mut() = p.to_vec();
true
};
settle(
&mut sp,
&mut |r| Batch {
downloaded: r
.iter()
.map(|q| (q.id, PathBuf::from(format!("/p/{}", q.id))))
.collect(),
failed: vec![],
},
&mut reload,
);
sp.take_replies();
sp.pop(1);
settle(&mut sp, &mut |_| unreachable!(), &mut reload);
// Callbacks.onSuccess reports every pack the reload loaded, again.
assert_eq!(sp.take_replies(), [(2, SUCCESSFULLY_LOADED)]);
assert_eq!(*last.borrow(), [(2, PathBuf::from("/p/2"))]);
sp.disconnect();
settle(&mut sp, &mut |_| unreachable!(), &mut reload);
assert!(last.borrow().is_empty());
assert!(sp.packs.is_empty());
}
#[test]
fn a_pushed_id_replaces_its_pack() {
let mut sp = ServerPacks::default();
sp.connect(Prompt::Allowed);
sp.push(1, "http://a/old.zip", None);
sp.push(1, "http://a/new.zip", None);
let mut urls = Vec::new();
settle(
&mut sp,
&mut |r| {
urls.extend(r.iter().map(|q| q.url.clone()));
ok(1)
},
&mut |_| true,
);
assert_eq!(urls, ["http://a/new.zip"]);
}
#[test]
fn urls_and_hashes_are_read_as_vanilla_reads_them() {
assert!(valid_url("https://example.com/p.zip"));
assert!(valid_url("HTTP://example.com/p.zip"));
assert!(!valid_url("ftp://example.com/p.zip"));
assert!(!valid_url("example.com/p.zip"));
let sha1 = "0123456789ABCDEF0123456789abcdef01234567";
assert_eq!(parse_sha1(sha1), Some(sha1.to_ascii_lowercase()));
assert_eq!(parse_sha1("abc"), None);
}
}
@@ -0,0 +1,482 @@
//! Server pack downloads into the game directory's `downloads/` cache, as
//! vanilla keeps it (`DownloadQueue`, `HttpUtil.downloadFile`,
//! `DownloadCacheCleaner`): `downloads/<pack id>/<SHA-1 of the file>`, one
//! batch at a time on a thread of its own, every download logged in
//! `downloads/log.json`, and the cache cut to its 20 newest files on start.
use super::{Batch, Request};
use sha1::{Digest, Sha1};
use std::{
fs,
io::{self, Read, Write},
path::{Path, PathBuf},
sync::mpsc::{Receiver, Sender, channel},
time::SystemTime,
};
/// `DownloadedPackSource`'s MAX_PACK_SIZE_BYTES (250 MiB).
pub const MAX_SIZE: u64 = 262_144_000;
/// `DownloadQueue.MAX_KEPT_PACKS`.
const MAX_KEPT: usize = 20;
/// `createDownloadHeaders`: who is downloading, with which game.
pub fn headers(name: &str, uuid: u128) -> Vec<(&'static str, String)> {
use horizon_core::version::{RESOURCE_PACK_FORMAT, VANILLA_VERSION, VERSION_ID};
vec![
("X-Minecraft-Username", name.to_owned()),
("X-Minecraft-UUID", format!("{uuid:032x}")),
("X-Minecraft-Version", VANILLA_VERSION.to_owned()),
("X-Minecraft-Version-ID", VERSION_ID.to_owned()),
(
"X-Minecraft-Pack-Format",
format!("{}.{}", RESOURCE_PACK_FORMAT.0, RESOURCE_PACK_FORMAT.1),
),
("User-Agent", format!("Minecraft Java/{VANILLA_VERSION}")),
]
}
type Job = (Vec<Request>, Vec<(&'static str, String)>);
/// `DownloadQueue`: batches download in order, on one thread.
pub struct Queue {
jobs: Sender<Job>,
done: Receiver<Batch>,
}
impl Queue {
pub fn open(dir: PathBuf) -> io::Result<Self> {
fs::create_dir_all(&dir)?;
vacuum(&dir, MAX_KEPT);
let (jobs, todo) = channel::<Job>();
let (finished, done) = channel();
std::thread::Builder::new()
.name("download-queue".into())
.spawn(move || {
for (requests, headers) in todo {
if finished.send(run(&dir, &requests, &headers)).is_err() {
break;
}
}
})?;
Ok(Self { jobs, done })
}
pub fn download(&self, requests: Vec<Request>, headers: Vec<(&'static str, String)>) {
let _ = self.jobs.send((requests, headers));
}
/// The batches finished since the last call.
pub fn finished(&self) -> Vec<Batch> {
self.done.try_iter().collect()
}
}
/// `DownloadQueue.runDownload`: each pack into `<dir>/<id>/`, logged.
fn run(dir: &Path, requests: &[Request], headers: &[(&'static str, String)]) -> Batch {
let mut batch = Batch::default();
for request in requests {
let id = horizon_protocol::crypto::uuid_string(request.id);
let result = download_file(&dir.join(&id), request, headers, MAX_SIZE);
// DownloadQueue.LogEntry, its fields in the codec's order.
let mut entry = vec![
("id", serde_json::Value::from(id)),
("url", request.url.as_str().into()),
(
"time",
chrono::Utc::now()
.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true)
.into(),
),
];
if let Some(sha1) = &request.sha1 {
entry.push(("hash", sha1.as_str().into()));
}
entry.push(match &result {
Ok(path) => match fs::metadata(path) {
Ok(meta) => {
let name = path.strip_prefix(dir).unwrap_or(path);
let name = name.to_string_lossy().replace('\\', "/");
(
"file",
serde_json::json!({"name": name, "size": meta.len()}),
)
}
Err(_) => ("error", "no_access".into()),
},
Err(e) => {
log::error!("Horizon: failed to download {}: {e}", request.url);
("error", "download_failed".into())
}
});
if let Err(e) = append_log(&dir.join("log.json"), &entry) {
log::error!("Horizon: failed to log download of {}: {e}", request.url);
}
match result {
Ok(path) => batch.downloaded.push((request.id, path)),
Err(_) => batch.failed.push(request.id),
}
}
batch
}
/// `JsonEventLog.write`: one JSON object a line, as Gson writes it (with
/// its HTML escaping).
fn append_log(path: &Path, entry: &[(&str, serde_json::Value)]) -> io::Result<()> {
let fields: Vec<String> = entry
.iter()
.map(|(k, v)| format!("{}:{v}", serde_json::Value::from(*k)))
.collect();
let mut line = String::new();
for c in format!("{{{}}}", fields.join(",")).chars() {
match c {
'<' | '>' | '&' | '=' | '\'' => line.push_str(&format!("\\u{:04x}", c as u32)),
c => line.push(c),
}
}
line.push('\n');
fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?
.write_all(line.as_bytes())
}
fn sha1_of(path: &Path) -> io::Result<String> {
let mut hasher = Sha1::new();
io::copy(&mut fs::File::open(path)?, &mut hasher)?;
Ok(hex(&hasher.finalize()))
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// `checkExistingFile`: the file is there with that hash.
fn matches(path: &Path, sha1: &str) -> bool {
path.exists() && sha1_of(path).is_ok_and(|actual| actual == sha1)
}
/// `updateModificationTime`: used now, so the cleaner keeps it longest.
fn touch(path: &Path) {
let _ = fs::File::options()
.append(true)
.open(path)
.and_then(|f| f.set_modified(SystemTime::now()));
}
/// `HttpUtil.downloadFile`: into `<dir>/<SHA-1>`. With a hash the cached file
/// is used when it matches, and a download that does not is an error;
/// without one the file is named after the hash of what came.
pub fn download_file(
dir: &Path,
request: &Request,
headers: &[(&'static str, String)],
max_size: u64,
) -> Result<PathBuf, String> {
let target = request.sha1.as_ref().map(|sha1| dir.join(sha1));
if let (Some(target), Some(sha1)) = (&target, &request.sha1) {
if matches(target, sha1) {
log::info!("Horizon: returning cached file since actual hash matches requested");
touch(target);
return Ok(target.clone());
}
if target.exists() {
log::warn!(
"Horizon: existing file {} not found or had mismatched hash",
target.display()
);
}
fs::remove_file(target)
.or_else(|e| {
if e.kind() == io::ErrorKind::NotFound {
Ok(())
} else {
Err(e)
}
})
.map_err(|e| format!("Failed to remove existing file {}: {e}", target.display()))?;
}
let mut call = ureq::AgentBuilder::new().build().get(&request.url);
for (name, value) in headers {
call = call.set(name, value);
}
let response = call.call().map_err(|e| e.to_string())?;
let size = response
.header("Content-Length")
.and_then(|v| v.trim().parse::<u64>().ok());
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
if let Some(size) = size
&& size > max_size
{
return Err(format!(
"Filesize is bigger than maximum allowed (file is {size}, limit is {max_size})"
));
}
let mut input = response.into_reader();
match (&target, &request.sha1) {
(Some(target), Some(sha1)) => {
let actual = save_hashed(&mut input, target, max_size).map_err(|e| e.to_string())?;
if &actual != sha1 {
return Err(format!(
"Hash of downloaded file ({actual}) did not match requested ({sha1})"
));
}
Ok(target.clone())
}
_ => {
let tmp = dir.join(format!("download{:016x}.tmp", rand_suffix()));
let result = save_hashed(&mut input, &tmp, max_size).and_then(|actual| {
let path = dir.join(&actual);
if matches(&path, &actual) {
touch(&path);
} else {
fs::rename(&tmp, &path)?;
}
Ok(path)
});
let _ = fs::remove_file(&tmp);
result.map_err(|e| e.to_string())
}
}
}
fn rand_suffix() -> u64 {
use std::hash::{BuildHasher, RandomState};
RandomState::new().hash_one(std::time::Instant::now())
}
/// `downloadAndHash`: write what comes, at most `max_size` bytes.
fn save_hashed(input: &mut dyn Read, path: &Path, max_size: u64) -> io::Result<String> {
let mut out = fs::File::create(path)?;
let mut hasher = Sha1::new();
let mut buffer = [0u8; 8196];
let mut read_so_far = 0u64;
loop {
let n = input.read(&mut buffer)?;
if n == 0 {
break;
}
read_so_far += n as u64;
if read_so_far > max_size {
return Err(io::Error::other(format!(
"Filesize was bigger than maximum allowed (got >= {read_so_far}, limit was {max_size})"
)));
}
out.write_all(&buffer[..n])?;
hasher.update(&buffer[..n]);
}
Ok(hex(&hasher.finalize()))
}
/// `DownloadCacheCleaner.vacuumCacheDir`: keep the `max_files` files most
/// worth keeping (in the pack folders: the newest, and one per pack before a
/// second of any), then drop the folders left empty.
pub fn vacuum(dir: &Path, max_files: usize) {
let mut files = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(entries) = fs::read_dir(&d) else {
continue;
};
for e in entries.flatten() {
let path = e.path();
let Ok(meta) = e.metadata() else { continue };
if meta.is_dir() {
stack.push(path);
} else if meta.is_file() && path.parent() != Some(dir) {
let time = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
files.push((path, time));
}
}
}
let Some(remove) = files.len().checked_sub(max_files).filter(|n| *n > 0) else {
return;
};
// Newest first, then each file's rank in its folder (0 for a folder's
// newest), reversed, and stably sorted by rank, highest first.
files.sort_by_key(|f| std::cmp::Reverse(f.1));
let mut counts = std::collections::HashMap::<PathBuf, usize>::new();
let mut ranked: Vec<(PathBuf, usize)> = files
.into_iter()
.map(|(path, _)| {
let count = counts
.entry(path.parent().unwrap_or(dir).to_path_buf())
.or_default();
let rank = *count;
*count += 1;
(path, rank)
})
.collect();
ranked.reverse();
ranked.sort_by_key(|f| std::cmp::Reverse(f.1));
let mut emptied = std::collections::HashSet::new();
for (path, rank) in ranked.into_iter().take(remove) {
match fs::remove_file(&path) {
Ok(()) if rank == 0 => {
emptied.insert(path.parent().unwrap_or(dir).to_path_buf());
}
Ok(()) => {}
Err(e) => log::warn!(
"Horizon: failed to delete cache file {}: {e}",
path.display()
),
}
}
emptied.remove(dir);
for d in emptied {
let _ = fs::remove_dir(d);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
/// A one-shot HTTP server answering `status` with `body`; the request it got.
fn serve(status: &str, body: Vec<u8>) -> (String, std::thread::JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/pack.zip", listener.local_addr().unwrap());
let status = status.to_owned();
let handle = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = Vec::new();
let mut buf = [0u8; 1024];
while !request.ends_with(b"\r\n\r\n") {
let n = stream.read(&mut buf).unwrap();
request.extend_from_slice(&buf[..n]);
}
let head = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(head.as_bytes()).unwrap();
stream.write_all(&body).unwrap();
String::from_utf8_lossy(&request).into_owned()
});
(url, handle)
}
fn sha1(bytes: &[u8]) -> String {
hex(&Sha1::digest(bytes))
}
fn scratch() -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-packs-{:016x}", rand_suffix()));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn a_pack_lands_under_its_hash_and_is_reused_from_the_cache() {
let dir = scratch();
let body = b"PK pack".to_vec();
let (url, server) = serve("200 OK", body.clone());
let request = Request {
id: 7,
url,
sha1: Some(sha1(&body)),
};
let path = download_file(&dir, &request, &headers("Alex", 0xab), MAX_SIZE).unwrap();
assert_eq!(path, dir.join(sha1(&body)));
assert_eq!(fs::read(&path).unwrap(), body);
let sent = server.join().unwrap();
assert!(sent.contains("X-Minecraft-Username: Alex"), "{sent}");
assert!(sent.contains("X-Minecraft-Pack-Format: 97.1"), "{sent}");
// Cached: no server this time.
let cached = Request {
url: "http://127.0.0.1:9/gone.zip".into(),
..request
};
assert_eq!(download_file(&dir, &cached, &[], MAX_SIZE).unwrap(), path);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_wrong_hash_a_dead_url_and_a_large_pack_fail() {
let dir = scratch();
let (url, server) = serve("200 OK", b"other bytes".to_vec());
let wrong = Request {
id: 1,
url,
sha1: Some("0".repeat(40)),
};
let err = download_file(&dir, &wrong, &[], MAX_SIZE).unwrap_err();
assert!(err.contains("did not match requested"), "{err}");
server.join().unwrap();
let dead = Request {
id: 2,
url: "http://127.0.0.1:9/pack.zip".into(),
sha1: None,
};
assert!(download_file(&dir.join("dead"), &dead, &[], MAX_SIZE).is_err());
assert!(!dir.join("dead").exists());
let (url, server) = serve("200 OK", vec![0; 100]);
let large = Request {
id: 3,
url,
sha1: None,
};
let err = download_file(&dir, &large, &[], 10).unwrap_err();
assert!(err.contains("bigger than maximum"), "{err}");
let _ = server.join();
let (url, server) = serve("404 Not Found", Vec::new());
let missing = Request {
id: 4,
url,
sha1: None,
};
assert!(download_file(&dir, &missing, &[], MAX_SIZE).is_err());
server.join().unwrap();
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_queue_logs_each_download() {
let dir = scratch();
let (url, server) = serve("200 OK", b"zip".to_vec());
let queue = Queue::open(dir.clone()).unwrap();
let id = 0x5b2c_7a4e_1f3d_4c8b_9a6e_2d4f_8b1c_3e5a;
queue.download(
vec![Request {
id,
url,
sha1: None,
}],
Vec::new(),
);
let batch = loop {
if let Some(b) = queue.finished().pop() {
break b;
}
std::thread::sleep(std::time::Duration::from_millis(10));
};
server.join().unwrap();
let file = dir
.join("5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a")
.join(sha1(b"zip"));
assert_eq!(batch.downloaded, [(id, file)]);
let log = fs::read_to_string(dir.join("log.json")).unwrap();
let entry: serde_json::Value = serde_json::from_str(log.trim()).unwrap();
assert_eq!(entry["id"], "5b2c7a4e-1f3d-4c8b-9a6e-2d4f8b1c3e5a");
assert_eq!(entry["file"]["size"], 3);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_cleaner_keeps_the_newest_file_of_each_pack_longest() {
let dir = scratch();
let at = |secs| SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs);
for (pack, file, time) in [
("a", "1", 10),
("a", "2", 20),
("a", "3", 30),
("b", "1", 5),
] {
fs::create_dir_all(dir.join(pack)).unwrap();
let path = dir.join(pack).join(file);
fs::write(&path, b"x").unwrap();
fs::File::options()
.append(true)
.open(&path)
.unwrap()
.set_modified(at(time))
.unwrap();
}
fs::write(dir.join("log.json"), b"").unwrap();
vacuum(&dir, 2);
assert!(dir.join("a/3").exists() && dir.join("b/1").exists());
assert!(!dir.join("a/1").exists() && !dir.join("a/2").exists());
assert!(dir.join("log.json").exists());
fs::remove_dir_all(dir).unwrap();
}
}
+12
View File
@@ -14,19 +14,24 @@ interface log {
warn,
error,
}
/// One line in the game's log as `[mod <id>] message`, at that level.
write: func(level: level, message: string);
}
/// Capability `game-read`: the local player and the world clock.
interface game {
record player {
/// Feet position, in blocks.
x: f64,
y: f64,
z: f64,
/// Degrees, as F3 shows them.
yaw: f32,
/// Degrees: -90 looks straight up, 90 straight down.
pitch: f32,
/// Hit points as vanilla counts them: 20 is full health without effects.
health: f32,
/// 0 to 20, as the hunger bar.
food: u32,
/// `minecraft:overworld`, `minecraft:the_nether`, ...
dimension: string,
@@ -39,6 +44,7 @@ interface game {
}
/// None on the title screen.
get-player: func() -> option<player>;
/// None on the title screen.
get-clock: func() -> option<clock>;
}
@@ -58,11 +64,14 @@ interface hud {
bottom-right,
}
record line {
/// Plain text, up to 256 characters.
text: string,
/// 0xRRGGBB.
color: u32,
}
/// Replaces the mod's panel: up to 16 lines in that corner of the screen.
set-panel: func(corner: corner, lines: list<line>);
/// Removes the mod's panel.
clear: func();
}
@@ -92,10 +101,13 @@ interface events {
}
/// Once, after loading. An error keeps the mod off and is shown.
init: func() -> result<registration, string>;
/// Each game tick while in a world, with the game's tick counter.
tick: func(tick: u64);
/// Each rendered frame, with the seconds since the last one.
frame: func(seconds: f32);
/// `/name args`: ok(some(text)) is shown as feedback, err(text) in red.
command: func(name: string, args: string) -> result<option<string>, string>;
/// Each chat line the player receives, as plain text.
chat: func(text: string);
}
+5
View File
@@ -30,5 +30,10 @@ pack = ["dep:wit-component", "dep:wit-parser"]
name = "sandbox"
required-features = ["runtime", "pack"]
# docs/api/mods-reference.md from the WIT contract (HORIZON_WRITE_DOCS=1 rewrites it).
[[test]]
name = "api_reference"
required-features = ["pack"]
[lints]
workspace = true
+304
View File
@@ -0,0 +1,304 @@
//! docs/api/mods-reference.md, generated from the WIT contract and the manifest's capabilities, and a test that
//! fails when it is out of date. Update it with:
//! `HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference`.
use horizon_mods::manifest::{APIS, Capability};
use std::{fmt::Write, path::Path};
use wit_parser::{Docs, Function, Handle, Resolve, Type, TypeDefKind, WorldItem};
const WIT: &str = "crates/horizon-mod-sdk/wit/horizon.wit";
const DOC: &str = "docs/api/mods-reference.md";
fn root() -> &'static Path {
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
}
/// A doc comment on one line, for a table cell.
fn cell(docs: &Docs) -> String {
let text = docs.contents.as_deref().unwrap_or("").trim();
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.replace('|', "\\|")
}
/// A type as WIT spells it.
fn ty(resolve: &Resolve, t: &Type) -> String {
let id = match t {
Type::Bool => return "bool".into(),
Type::U8 => return "u8".into(),
Type::U16 => return "u16".into(),
Type::U32 => return "u32".into(),
Type::U64 => return "u64".into(),
Type::S8 => return "s8".into(),
Type::S16 => return "s16".into(),
Type::S32 => return "s32".into(),
Type::S64 => return "s64".into(),
Type::F32 => return "f32".into(),
Type::F64 => return "f64".into(),
Type::Char => return "char".into(),
Type::String => return "string".into(),
Type::ErrorContext => return "error-context".into(),
Type::Id(id) => *id,
};
match &resolve.types[id].name {
Some(name) => name.clone(),
None => definition(resolve, &resolve.types[id].kind),
}
}
/// What an anonymous type, or the right-hand side of a `type` alias, spells.
fn definition(resolve: &Resolve, kind: &TypeDefKind) -> String {
let of = |t: &Option<Type>| t.as_ref().map_or("_".into(), |t| ty(resolve, t));
match kind {
TypeDefKind::Option(t) => format!("option<{}>", ty(resolve, t)),
TypeDefKind::List(t) => format!("list<{}>", ty(resolve, t)),
TypeDefKind::FixedSizeList(t, n) => format!("list<{}, {n}>", ty(resolve, t)),
TypeDefKind::Result(r) => match (&r.ok, &r.err) {
(None, None) => "result".into(),
(ok, None) => format!("result<{}>", of(ok)),
(ok, err) => format!("result<{}, {}>", of(ok), of(err)),
},
TypeDefKind::Tuple(t) => {
let items: Vec<_> = t.types.iter().map(|t| ty(resolve, t)).collect();
format!("tuple<{}>", items.join(", "))
}
TypeDefKind::Future(t) => t
.as_ref()
.map_or("future".into(), |t| format!("future<{}>", ty(resolve, t))),
TypeDefKind::Stream(t) => t
.as_ref()
.map_or("stream".into(), |t| format!("stream<{}>", ty(resolve, t))),
TypeDefKind::Handle(Handle::Own(t)) => ty(resolve, &Type::Id(*t)),
TypeDefKind::Handle(Handle::Borrow(t)) => format!("borrow<{}>", ty(resolve, &Type::Id(*t))),
TypeDefKind::Type(t) => ty(resolve, t),
other => panic!(
"{WIT}: an anonymous {} the reference can't spell",
other.as_str()
),
}
}
fn signature(resolve: &Resolve, f: &Function) -> String {
let params: Vec<_> = f
.params
.iter()
.map(|(name, t)| format!("{name}: {}", ty(resolve, t)))
.collect();
let result = f
.result
.as_ref()
.map_or(String::new(), |t| format!(" -> {}", ty(resolve, t)));
format!("func({}){result}", params.join(", "))
}
/// Who may use an interface: the capability that grants it, or what it is.
fn access(interface: &str, exported: bool) -> String {
if exported {
return "exported by the mod: the game calls it".into();
}
if interface == "log" {
return "always granted".into();
}
match Capability::ALL.iter().find(|c| c.interface() == interface) {
Some(c) => format!("capability `{c}`"),
None => "not granted by any capability".into(),
}
}
fn markdown() -> String {
let mut resolve = Resolve::new();
let package = resolve
.push_file(root().join(WIT))
.expect("the WIT contract parses");
let package = &resolve.packages[package];
let name = &package.name;
let mut out = String::new();
let w = &mut out;
writeln!(w, "<!-- Generated from {WIT} and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->").unwrap();
writeln!(w, "# Mod API reference\n").unwrap();
writeln!(
w,
"The contract between Horizon and its mods: the WIT package `{name}`, every interface, type, function and hook in it, \
and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: \
[stability.md](stability.md). Manifest `api` lines this game serves: {}.\n",
APIS.map(|a| format!("`{a}`")).join(", ")
)
.unwrap();
writeln!(w, "## Capabilities\n").unwrap();
writeln!(
w,
"| In `capabilities` | Grants | The player reads \"May …\" |\n| --- | --- | --- |"
)
.unwrap();
for c in Capability::ALL {
let grants = if package.interfaces.contains_key(c.interface()) {
format!("[`{0}`](#{0})", c.interface())
} else {
"data files (`assets/`, `data/`), no interface".into()
};
writeln!(w, "| `{c}` | {grants} | {} |", c.describe()).unwrap();
}
let mut exported = Vec::new();
for (world_name, &world) in &package.worlds {
let world = &resolve.worlds[world];
let prefix = format!("{}:{}/", name.namespace, name.name);
let names = |keys: Vec<&wit_parser::WorldKey>| {
keys.into_iter()
.map(|key| {
let full = resolve.name_world_key(key);
let short = full.trim_start_matches(&prefix);
format!("`{}`", short.split('@').next().unwrap_or(short))
})
.collect::<Vec<_>>()
.join(", ")
};
writeln!(w, "\n## World `{world_name}`\n").unwrap();
if world.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&world.docs)).unwrap();
}
writeln!(
w,
"- Imports (what the game offers): {}",
names(world.imports.keys().collect())
)
.unwrap();
writeln!(
w,
"- Exports (what the mod provides): {}",
names(world.exports.keys().collect())
)
.unwrap();
for item in world.exports.values() {
if let WorldItem::Interface { id, .. } = item {
exported.push(*id);
}
}
}
for (interface_name, &id) in &package.interfaces {
let interface = &resolve.interfaces[id];
writeln!(w, "\n## `{interface_name}`\n").unwrap();
writeln!(
w,
"Access: {}.\n",
access(interface_name, exported.contains(&id))
)
.unwrap();
if interface.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&interface.docs)).unwrap();
}
for (type_name, &type_id) in &interface.types {
let def = &resolve.types[type_id];
writeln!(w, "### {} `{type_name}`\n", def.kind.as_str()).unwrap();
if def.docs.contents.is_some() {
writeln!(w, "{}\n", cell(&def.docs)).unwrap();
}
match &def.kind {
TypeDefKind::Record(r) => {
writeln!(w, "| Field | Type | |\n| --- | --- | --- |").unwrap();
for f in &r.fields {
writeln!(
w,
"| `{}` | `{}` | {} |",
f.name,
ty(&resolve, &f.ty),
cell(&f.docs)
)
.unwrap();
}
}
TypeDefKind::Enum(e) => {
writeln!(w, "| Case | |\n| --- | --- |").unwrap();
for c in &e.cases {
writeln!(w, "| `{}` | {} |", c.name, cell(&c.docs)).unwrap();
}
}
TypeDefKind::Variant(v) => {
writeln!(w, "| Case | Payload | |\n| --- | --- | --- |").unwrap();
for c in &v.cases {
let payload =
c.ty.as_ref()
.map_or(String::new(), |t| format!("`{}`", ty(&resolve, t)));
writeln!(w, "| `{}` | {payload} | {} |", c.name, cell(&c.docs)).unwrap();
}
}
TypeDefKind::Flags(f) => {
writeln!(w, "| Flag | |\n| --- | --- |").unwrap();
for flag in &f.flags {
writeln!(w, "| `{}` | {} |", flag.name, cell(&flag.docs)).unwrap();
}
}
TypeDefKind::Resource => {
writeln!(w, "A resource: the game owns it, the mod holds a handle.").unwrap()
}
kind => writeln!(w, "`type {type_name} = {}`", definition(&resolve, kind)).unwrap(),
}
writeln!(w).unwrap();
}
if !interface.functions.is_empty() {
writeln!(
w,
"| Function | Signature | What it does |\n| --- | --- | --- |"
)
.unwrap();
for (fn_name, f) in &interface.functions {
writeln!(
w,
"| `{fn_name}` | `{}` | {} |",
signature(&resolve, f),
cell(&f.docs)
)
.unwrap();
}
}
}
out
}
#[test]
fn the_mod_api_reference_is_up_to_date() {
let path = root().join(DOC);
let want = markdown();
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
std::fs::write(&path, &want).unwrap();
}
let have = std::fs::read_to_string(&path)
.unwrap_or_default()
.replace("\r\n", "\n");
assert!(
have == want,
"{DOC} is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference"
);
}
#[test]
fn every_function_is_documented() {
let mut resolve = Resolve::new();
resolve.push_file(root().join(WIT)).unwrap();
for (_, interface) in resolve.interfaces.iter() {
for (name, f) in &interface.functions {
assert!(
f.docs.contents.is_some(),
"{WIT}: `{name}` needs a doc comment: the reference shows it to mod authors"
);
}
}
}
#[test]
fn every_capability_with_an_interface_is_in_the_contract() {
let mut resolve = Resolve::new();
let package = resolve.push_file(root().join(WIT)).unwrap();
let interfaces = &resolve.packages[package].interfaces;
for c in Capability::ALL {
if c != Capability::Registries {
assert!(
interfaces.contains_key(c.interface()),
"capability {c} grants `{}`, missing from {WIT}",
c.interface()
);
}
}
}
+4 -2
View File
@@ -676,13 +676,15 @@ fn steps(
}
// The playable client needs every client asset (textures, models, fonts, GUI)
// and every data-pack resource (worldgen, recipes, loot tables, tags).
// and every data-pack resource (worldgen, recipes, loot tables, tags), and
// the vanilla pack's icon (`pack.png`) for the pack screen.
(run.log)("Copying the client's assets and data");
let mut count = 0usize;
let mut client = open_zip(&run.client)?;
let version = pyjson::loads(&zip_text(&mut client, "version.json")?)?;
for name in zip_names(&client) {
if !(name.starts_with("assets/") || name.starts_with("data/")) || name.ends_with('/') {
let wanted = name.starts_with("assets/") || name.starts_with("data/") || name == "pack.png";
if !wanted || name.ends_with('/') {
continue;
}
let relative = member_path(&name, "Invalid resource path")?;
+3 -2
View File
@@ -13,8 +13,8 @@ use crate::{
packets::{
Empty,
configuration::{
ClientInformation, CustomPayload, Disconnect, KeepAlive, Ping, ResourcePackPush,
ResourcePackResponse, UpdateTags,
ClientInformation, CustomPayload, Disconnect, KeepAlive, Ping, ResourcePackPop,
ResourcePackPush, ResourcePackResponse, UpdateTags,
},
login::Property,
},
@@ -1640,6 +1640,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
RemoveEntities(RemoveEntities) = REMOVE_ENTITIES,
RemoveMobEffect(RemoveMobEffect) = REMOVE_MOB_EFFECT,
ResetScore(ResetScore) = RESET_SCORE,
ResourcePackPop(ResourcePackPop) = RESOURCE_PACK_POP,
ResourcePackPush(ResourcePackPush) = RESOURCE_PACK_PUSH,
Respawn(Respawn) = RESPAWN,
RotateHead(RotateHead) = ROTATE_HEAD,
-13
View File
@@ -1,13 +0,0 @@
[package]
name = "voxel-kernels"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "voxel-bench"
path = "src/main.rs"
[lints]
workspace = true
-136
View File
@@ -1,136 +0,0 @@
//! Original occupancy kernels for synthetic 16³ voxel sections.
//! A set bit means an opaque full cube; all neighbors outside the section are air.
//! This is not a Minecraft renderer or a model of partial/transparent blocks.
pub const SIDE: usize = 16;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Section {
rows: [u16; SIDE * SIDE],
}
impl Default for Section {
fn default() -> Self {
Self {
rows: [0; SIDE * SIDE],
}
}
}
impl Section {
pub fn from_fn(mut occupied: impl FnMut(usize, usize, usize) -> bool) -> Self {
let mut section = Self::default();
for z in 0..SIDE {
for y in 0..SIDE {
for x in 0..SIDE {
if occupied(x, y, z) {
section.rows[z * SIDE + y] |= 1 << x;
}
}
}
}
section
}
pub fn occupied(&self, x: usize, y: usize, z: usize) -> bool {
assert!(x < SIDE && y < SIDE && z < SIDE);
self.rows[z * SIDE + y] & (1 << x) != 0
}
/// Simple per-voxel reference. Count each opaque cube's exposed unit faces.
pub fn exposed_faces_scalar(&self) -> u32 {
let mut faces = 0;
for z in 0..SIDE {
for y in 0..SIDE {
for x in 0..SIDE {
if !self.occupied(x, y, z) {
continue;
}
faces += u32::from(x == 0 || !self.occupied(x - 1, y, z));
faces += u32::from(x + 1 == SIDE || !self.occupied(x + 1, y, z));
faces += u32::from(y == 0 || !self.occupied(x, y - 1, z));
faces += u32::from(y + 1 == SIDE || !self.occupied(x, y + 1, z));
faces += u32::from(z == 0 || !self.occupied(x, y, z - 1));
faces += u32::from(z + 1 == SIDE || !self.occupied(x, y, z + 1));
}
}
}
faces
}
/// Evaluate sixteen voxels at once with row masks and population counts.
pub fn exposed_faces_bitset(&self) -> u32 {
let mut faces = 0;
for z in 0..SIDE {
for y in 0..SIDE {
let i = z * SIDE + y;
let row = self.rows[i];
let below = if y == 0 { 0 } else { self.rows[i - 1] };
let above = if y + 1 == SIDE { 0 } else { self.rows[i + 1] };
let back = if z == 0 { 0 } else { self.rows[i - SIDE] };
let front = if z + 1 == SIDE {
0
} else {
self.rows[i + SIDE]
};
faces += (row & !(row << 1)).count_ones();
faces += (row & !(row >> 1)).count_ones();
faces += (row & !below).count_ones();
faces += (row & !above).count_ones();
faces += (row & !back).count_ones();
faces += (row & !front).count_ones();
}
}
faces
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check(section: &Section, expected: u32) {
assert_eq!(section.exposed_faces_scalar(), expected);
assert_eq!(section.exposed_faces_bitset(), expected);
}
#[test]
fn known_geometry() {
check(&Section::default(), 0);
check(&Section::from_fn(|_, _, _| true), 6 * 16 * 16);
check(&Section::from_fn(|x, y, z| x == 3 && y == 4 && z == 5), 6);
check(&Section::from_fn(|x, y, z| x < 2 && y == 4 && z == 5), 10);
check(
&Section::from_fn(|x, y, z| (x + y + z) % 2 == 0),
4096 / 2 * 6,
);
}
#[test]
fn every_single_voxel_including_boundaries() {
for i in 0..SIDE.pow(3) {
let mut section = Section::default();
section.rows[i / SIDE] = 1 << (i % SIDE);
check(&section, 6);
}
}
#[test]
fn random_occupancy_matches_reference_at_multiple_densities() {
let mut state = 0x1234_5678_9abc_def0_u64;
for threshold in [0, 1, 16, 64, 128, 192, 254, 255, 256] {
for _ in 0..32 {
let section = Section::from_fn(|_, _, _| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state & 255) < threshold
});
assert_eq!(
section.exposed_faces_scalar(),
section.exposed_faces_bitset()
);
}
}
}
}
-92
View File
@@ -1,92 +0,0 @@
use std::{hint::black_box, time::Instant};
use voxel_kernels::Section;
fn sample(corpus: &[Section], kernel: fn(&Section) -> u32, repeats: usize) -> f64 {
let start = Instant::now();
let mut checksum = 0_u64;
for _ in 0..repeats {
for section in corpus {
checksum = checksum.wrapping_add(u64::from(kernel(black_box(section))));
}
}
black_box(checksum);
start.elapsed().as_nanos() as f64 / (corpus.len() * repeats) as f64
}
fn median(samples: &mut [f64]) -> f64 {
samples.sort_by(f64::total_cmp);
samples[samples.len() / 2]
}
fn main() {
let repeats: usize = match std::env::args().nth(1) {
None => 100,
Some(value) => match value.parse() {
Ok(n) if n > 0 => n,
_ => {
eprintln!("usage: voxel-bench [positive repeat count]");
std::process::exit(2);
}
},
};
let mut rng = 0x7065_7273_6f6e_616c_u64;
let random = |rng: &mut u64| {
*rng ^= *rng << 13;
*rng ^= *rng >> 7;
*rng ^= *rng << 17;
*rng
};
println!(
"workload,sections,samples,repeats,scalar_ns_per_section,bitset_ns_per_section,speedup"
);
for workload in [
"empty",
"solid",
"flat",
"checkerboard",
"random_10",
"random_50",
"random_90",
] {
let corpus: Vec<_> = (0..32)
.map(|i| {
Section::from_fn(|x, y, z| match workload {
"empty" => false,
"solid" => true,
"flat" => y < 1 + i % 15,
"checkerboard" => (x + y + z + i) % 2 == 0,
"random_10" => random(&mut rng) % 100 < 10,
"random_50" => random(&mut rng) % 100 < 50,
"random_90" => random(&mut rng) % 100 < 90,
_ => unreachable!(),
})
})
.collect();
for section in &corpus {
assert_eq!(
section.exposed_faces_scalar(),
section.exposed_faces_bitset()
);
}
for kernel in [Section::exposed_faces_scalar, Section::exposed_faces_bitset] {
sample(&corpus, kernel, 10);
}
let mut scalar = Vec::new();
let mut bitset = Vec::new();
for trial in 0..21 {
if trial % 2 == 0 {
scalar.push(sample(&corpus, Section::exposed_faces_scalar, repeats));
bitset.push(sample(&corpus, Section::exposed_faces_bitset, repeats));
} else {
bitset.push(sample(&corpus, Section::exposed_faces_bitset, repeats));
scalar.push(sample(&corpus, Section::exposed_faces_scalar, repeats));
}
}
let scalar = median(&mut scalar);
let bitset = median(&mut bitset);
println!(
"{workload},32,21,{repeats},{scalar:.2},{bitset:.2},{:.2}",
scalar / bitset
);
}
}
+2 -2
View File
@@ -45,6 +45,6 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [Collision geometry](collision-geometry.md): shapes, collision, movement and fluids.
- [Face visibility](face-visibility.md): which model faces the mesher emits.
## The earlier Java track (no longer developed)
## Java comparison profiling
- [Java performance packs](java-packs.md), with [Vulkan on your Mac](vulkan.md), [collector comparisons](collectors.md), [profiling](profiling.md), [process memory](process-memory.md) and [tick state](tick-state.md). The Fabric server starter is retired: [host a server](hosting.md) with horizon-server.
- [JFR analysis for Java comparisons](profiling.md).
+141
View File
@@ -0,0 +1,141 @@
<!-- Generated from crates/horizon-mod-sdk/wit/horizon.wit and the manifest's capabilities by crates/horizon-mods/tests/api_reference.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-mods --features pack --test api_reference -->
# Mod API reference
The contract between Horizon and its mods: the WIT package `horizon:[email protected]`, every interface, type, function and hook in it, and the capabilities a manifest may list. The guide is [mods.md](../mods.md); stability and versions: [stability.md](stability.md). Manifest `api` lines this game serves: `0.1`, `0.2`.
## Capabilities
| In `capabilities` | Grants | The player reads "May …" |
| --- | --- | --- |
| `game-read` | [`game`](#game) | see your position, health and the time |
| `chat` | [`chat`](#chat) | show you chat messages |
| `hud` | [`hud`](#hud) | draw text on your screen |
| `storage` | [`storage`](#storage) | save its own settings to disk |
| `registries` | data files (`assets/`, `data/`), no interface | add blocks and items to your worlds |
## World `horizon-mod`
- Imports (what the game offers): `log`, `game`, `chat`, `hud`, `storage`
- Exports (what the mod provides): `events`
## `log`
Access: always granted.
The mod's log, in the game's log under the mod's id. Always granted.
### enum `level`
| Case | |
| --- | --- |
| `debug` | |
| `info` | |
| `warn` | |
| `error` | |
| Function | Signature | What it does |
| --- | --- | --- |
| `write` | `func(level: level, message: string)` | One line in the game's log as `[mod <id>] message`, at that level. |
## `game`
Access: capability `game-read`.
Capability `game-read`: the local player and the world clock.
### record `player`
| Field | Type | |
| --- | --- | --- |
| `x` | `f64` | Feet position, in blocks. |
| `y` | `f64` | |
| `z` | `f64` | |
| `yaw` | `f32` | Degrees, as F3 shows them. |
| `pitch` | `f32` | Degrees: -90 looks straight up, 90 straight down. |
| `health` | `f32` | Hit points as vanilla counts them: 20 is full health without effects. |
| `food` | `u32` | 0 to 20, as the hunger bar. |
| `dimension` | `string` | `minecraft:overworld`, `minecraft:the_nether`, ... |
### record `clock`
| Field | Type | |
| --- | --- | --- |
| `game-time` | `u64` | Ticks since the world was created. |
| `day-time` | `u64` | 24000 ticks per day; 0 is 6:00 on the first day. |
| Function | Signature | What it does |
| --- | --- | --- |
| `get-player` | `func() -> option<player>` | None on the title screen. |
| `get-clock` | `func() -> option<clock>` | None on the title screen. |
## `chat`
Access: capability `chat`.
Capability `chat`: messages only the local player sees.
| Function | Signature | What it does |
| --- | --- | --- |
| `show` | `func(text: string)` | Shown in chat after the mod's name, like a system message. |
## `hud`
Access: capability `hud`.
Capability `hud`: one panel of text lines per mod, drawn by the game every frame until the mod changes or clears it (no call per frame).
### enum `corner`
| Case | |
| --- | --- |
| `top-left` | |
| `top-right` | |
| `bottom-left` | |
| `bottom-right` | |
### record `line`
| Field | Type | |
| --- | --- | --- |
| `text` | `string` | Plain text, up to 256 characters. |
| `color` | `u32` | 0xRRGGBB. |
| Function | Signature | What it does |
| --- | --- | --- |
| `set-panel` | `func(corner: corner, lines: list<line>)` | Replaces the mod's panel: up to 16 lines in that corner of the screen. |
| `clear` | `func()` | Removes the mod's panel. |
## `storage`
Access: capability `storage`.
Capability `storage`: a per-mod folder of small key/value files, quota'd (docs/mods.md). Keys are `[a-z0-9_.-]{1,64}`, one path segment.
| Function | Signature | What it does |
| --- | --- | --- |
| `get` | `func(key: string) -> option<list<u8>>` | None if `key` has never been `set`, or the key is not well-formed. |
| `set` | `func(key: string, value: list<u8>) -> result<_, string>` | Errs on a malformed key or over quota (bytes for the whole mod). |
| `delete` | `func(key: string)` | No error if `key` was already absent. |
## `events`
Access: exported by the mod: the game calls it.
What the game calls. Only the hooks `init` asks for are ever called.
### record `registration`
| Field | Type | |
| --- | --- | --- |
| `commands` | `list<string>` | Chat commands, without the slash. A name vanilla already uses is refused (vanilla's command runs). |
| `tick` | `bool` | Call `tick` every game tick (20 per second while playing). |
| `frame` | `bool` | Call `frame` every rendered frame. Budget: 0.1 ms. |
| `chat` | `bool` | Call `chat` for each chat line the player receives. |
| Function | Signature | What it does |
| --- | --- | --- |
| `init` | `func() -> result<registration, string>` | Once, after loading. An error keeps the mod off and is shown. |
| `tick` | `func(tick: u64)` | Each game tick while in a world, with the game's tick counter. |
| `frame` | `func(seconds: f32)` | Each rendered frame, with the seconds since the last one. |
| `command` | `func(name: string, args: string) -> result<option<string>, string>` | `/name args`: ok(some(text)) is shown as feedback, err(text) in red. |
| `chat` | `func(text: string)` | Each chat line the player receives, as plain text. |
+1 -1
View File
@@ -27,7 +27,7 @@ file selects ~103.
| 10 | Launcher (124) | `launcher/src/` (`main.rs`, `backend.rs`, `cli.rs`, `mcp.rs`, `servers_page.rs`) | `cd launcher && cargo test`; `python3 tools/launcher_mcp_e2e.py` |
| 11 | Mob types and species (117) | `L/mobs.rs`, `L/mobs/species.rs`, `L/mobs/data.rs`, `L/mobs/living.rs`, `L/mobs/drops.rs`, `L/mobs/gear.rs` | mob_attributes, entity_attribute, entity_death_loot, mob_ai |
| 12 | Guests' actions on a server (98) | `G/lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `G/lan_{attributes,pvp,chat,crafting,stations,trading,mount_menu,waypoints}.rs` | server_player_combat, server_player_damage, server_player_targets, lan_e2e (all) |
| 13 | Modpack porting, mod content (97) | `crates/horizon-port-pack/`, `L/mod_content.rs`, `tools/port_*.py`, `packs/` | `cargo test -p horizon-port-pack`; `python3 tools/mod_port_harness.py` |
| 13 | Modpack porting, mod content (97) | `crates/horizon-port-pack/`, `L/mod_content.rs`, `tools/port_*.py` | `cargo test -p horizon-port-pack`; `python3 tools/mod_port_harness.py` |
| 14 | Mob AI (96) | `L/mobs/ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `control.rs` | mob_ai (probe `examples/mob_ai_probe.rs`; known failure on main: drowned_*, #290) |
| 15 | Items and item use (96) | `L/items.rs`, `L/item_data.rs`, `G/hand.rs`, `G/consume.rs`, `G/potions.rs`, `L/effects.rs`; game.rs `fn use_hand`, `fn use_item` | use_item_on, item_use, item_use_lifecycle, consumable, consume_effect, status_effect |
| 16 | Options and settings screens (93) | `L/options_ui.rs`, `G/options.rs`, `L/settings.rs`, `L/options_file.rs`, `L/graphics_ui.rs`, `L/graphics.rs` | options, toggle_keys |
+1 -1
View File
@@ -21,7 +21,7 @@ One game is started per window size (1280x720 and 1920x1080 by default) and rend
- **Warm-up excluded.** Each scene's flight starts only after its chunks are loaded and drawn and 2 seconds have passed; the load itself is reported separately. JIT warm-up of the JVM during the first scene is part of vanilla's real behaviour and is not removed.
- **The same statistics code.** Horizon returns its raw frame times (`horizon-ctl fly ... raw`) and the vanilla mod writes its own; `stats()` in the driver computes every figure for both.
- **Vanilla as players run it.** Your own 26.3 client jar and libraries (as the official launcher installs them for your OS), the official launcher's default JVM arguments (`-Xmx2G` and its G1 options), Java 25. The game's assets index is not used, so neither game plays sound (Horizon runs with `HORIZON_NO_AUDIO=1`, vanilla with OpenAL's null device); textures, models and shaders come from the client jar as usual.
- **Performance mods.** `--game vanilla-mods` adds the Fabric builds for 26.3 of Sodium, Lithium, FerriteCore and ImmediatelyFast, the versions pinned with their hashes in `packs/mods.lock.json` (downloaded from Modrinth, never committed), plus Fabric API if one of them needs it.
- **Performance mods.** `--game vanilla-mods` adds the Fabric builds for 26.3 of Sodium, Lithium, FerriteCore and ImmediatelyFast, the versions pinned with their hashes in `tools/benchmark-deps/mods.lock.json` (downloaded from Modrinth, never committed), plus Fabric API if one of them needs it.
## Known caveats
-48
View File
@@ -1,48 +0,0 @@
# Compare Java collectors
Use the balanced pack first with the launcher's existing JVM policy. Java 25
HotSpot supports explicit G1 (`-XX:+UseG1GC`) and ZGC (`-XX:+UseZGC`) selections;
the [Java command reference](https://docs.oracle.com/en/java/javase/25/docs/specs/man/java.html)
documents both. A collector choice can change CPU, heap headroom, collection
behavior and frame pacing. Lower pauses alone do not establish lower RAM or
higher FPS. The packs do not automatically select a collector.
For a Mac comparison, duplicate one instance. In Edit > Settings > Java, enable
the Java Arguments override checkbox. Preserve existing non-collector arguments,
but use exactly one collector selector in each instance. Choose G1 in one and
ZGC in the other. Use the same native Java 25 ARM64, maximum/minimum heap, compact
header setting, world copy, resolution, shaders, distances and warmed camera route.
Alternate at least three 120-second runs rather than comparing unrelated captures.
Remove the explicit selector to restore the launcher's policy, then restart.
The viewer shows the actual collector names captured by the game. Check them:
setting a launcher's text field does not establish which flags reached the VM.
Prism's per-instance Java Arguments and Memory settings have separate override
checkboxes. A global collector selection may need removal or replacement when
creating an explicit per-instance experiment.
Record FPS, slowest-1% rate, frame spikes, heap and process RSS together. RSS does
not represent all shared GPU memory on Apple Silicon. `gc_time_ms` sums collector
MXBean counters; concurrent cycles and overlapping phases can contribute, so it
is not a measured pause total. Use JFR root pause events for pause analysis.
The streaming reader is checked against the JDK's event exporter with both
default-GC and ZGC fixtures in CI on Windows, Linux and ARM macOS.
The developer demo launcher accepts `--gc default`, `--gc g1` or `--gc zgc`.
For example:
```sh
python3 tools/runtime.py demo --instance .local/runtime/collector-test --heap 6G --gc zgc --compact-headers
python3 tools/test_jfr.py --gc zgc
```
Prepare demo mods/worlds before comparing.
Options affect only the launched process; they do not change global Java settings.
The collector and compact-header experiments can be tested independently.
A local 3 GiB, compact-header ZGC client completed a 120-second game recording.
That recording also encountered a distant-terrain rejection storm. Its workload
and cache history differed from G1 captures, so the larger memory footprint and
different frame rate cannot be attributed to the collector alone. The dispatcher
experiment addresses the identified retry loop separately. No M2 collector
performance winner is established.
+2
View File
@@ -165,6 +165,8 @@ hardcore = false
# Any other server.properties key, spelled as vanilla spells it:
[properties]
# spawn-protection = 16
# resource-pack = "https://example.com/pack.zip" # offered to players as they join; with
# resource-pack-sha1, require-resource-pack and resource-pack-prompt, as on vanilla
[engine]
path = "" # the game program; empty: the one beside horizon-server
+1 -1
View File
@@ -49,7 +49,7 @@ Source: [FerriteCore's summary of its changes](https://github.com/malte0811/Ferr
| String deduplication in model locations | Duplicate `String`s from the constructor's round-trips | No `ModelResourceLocation` objects, so the round-trip doesn't exist | **n/a** |
| Partly loaded chunk NBT kept between loading steps | The whole tag is kept | Chunks are decoded into Horizon's own structures | **n/a** |
Horizon's process memory is tracked against vanilla in `docs/process-memory.md`.
Horizon's process memory is tracked against vanilla in [comparison benchmarks](../benchmark.md).
## ImmediatelyFast (client draw submission)
-53
View File
@@ -1,53 +0,0 @@
# Measuring resident process memory
Horizon 0.6.0 records process RSS alongside JVM heap and nonheap pools. RSS
counts resident pages belonging to the Minecraft process, including native
allocations and mapped pages. It helps detect cases where reducing heap use
merely moves memory elsewhere. It is not unique physical-memory ownership or
a complete accounting of GPU allocations, shared pages, compressed memory,
swap, or Apple Silicon unified memory. Do not add heap to RSS: heap pages can
already be included in it.
Minecraft 26.3 already ships OSHI 6.9.0 and JNA on both client and server. The
original mod uses those dependencies without embedding them. Native reader
initialization and refresh happen on a low-priority daemon thread, never on
the render or server-tick thread. One reader serves overlapping frame and
tick recordings. Reads run about once per second only while recordings are
active; the scheduler stops polling when the last recording finishes. Ordinary
gameplay without a benchmark performs no resident-memory reads.
Reports include these optional schema-1 fields:
| Field | Meaning |
| --- | --- |
| `process_rss_peak_sampled_bytes` | Largest successful resident-memory sample during this recording |
| `process_rss_last_sampled_bytes` | Last successful sample, which can precede recording completion |
| `process_rss_samples` | Number of successful samples delivered to this recording |
| `process_rss_sample_interval_ms` | Requested 1,000 ms interval; reads can take longer |
| `process_rss_last_sample_age_ms` | Time from the last successful sample to report completion |
Unsupported or failed reads leave these fields absent if no sample succeeded;
zero is not used as a substitute. Frames and ticks continue recording. A read
that completes after a recording finishes is ignored. Sampled peaks can miss
brief spikes, and the initial read can be delayed by native-library startup.
The offline viewer compares RSS separately from heap/nonheap and warns about
stale resident samples. Keep recording duration, workload and warm-up matched
when comparing peaks. Process CPU counters cover the whole process over the
recording wall time, including native work and the integrated server; they do
not identify which thread or mod caused CPU use.
`gc_time_ms` sums collector MXBean collection-time counters. With concurrent
collectors these can include cycle duration and overlapping phases; it is not a
direct stop-the-world pause measurement. The viewer calls it collection time.
Use [JFR pause events](profiling.md) when investigating GC-induced stalls.
A three-pair heap experiment compared 3 GiB and 1.5 GiB
maximum heaps with the same core stack. It recorded variable RSS savings, nearly
unchanged average FPS and increased GC accounting; playable defaults stayed
unchanged.
The native-read test exercises the real library, overlapping recordings,
background execution, idle cancellation and unavailable-reader behavior on
the build platform. This validates measurement availability, not an FPS or
RAM optimization result.
+4 -12
View File
@@ -1,7 +1,7 @@
# Find the next bottleneck
Frame and tick recordings describe player-visible timings. A private Java Flight
Recorder capture helps identify the code doing work or allocating objects. Use
For Java comparison runs, a private Java Flight Recorder capture helps identify
the code doing work or allocating objects. Use
native Java 25, with `jcmd` and `java` from a JDK. Keep normal benchmark runs separate
from profiling runs because sampling adds overhead.
@@ -53,8 +53,7 @@ allocated bytes or proving that one object type owns most of the allocation.
GC pause totals/maximum use top-level `jdk.GCPhasePause`, excluding nested phases
and concurrent collection-cycle duration. `gc_cycles` is a separate count.
`heap_used_peak_at_gc_bytes` considers recorded GC heap summaries and can miss
peaks between collections. It is separate from the game's once-per-second heap
and process-RSS measurements.
peaks between collections. It is separate from externally sampled process RSS.
The reader ignores unselected event types and never extracts properties or
arguments. `event_span_seconds` is the span of all observed event timestamps,
@@ -70,12 +69,5 @@ python3 tools/test_jfr.py
This creates a real JDK recording of bounded allocations and GC, then compares
the reader's counter differences, observation counts, GC counts and heap summaries
against `jfr print --json`. It also verifies escaped thread names and that sampled
weights do not appear as numeric allocation totals. CI runs it on Windows, Linux
and Apple Silicon macOS. The reader was independently checked against the older
weights do not appear as numeric allocation totals. The manual `jfr-analysis` CI job runs both collector fixtures on the Linux game runner. The reader was independently checked against the older
real-game profile's JDK JSON export for 85 threads.
The post-queue software-rendered profile confirms remaining allocations on the
integrated-server and chunk-meshing threads. It is not a matched comparison with
the older capture: world/cache state, heap and object-header flags differ. It
cannot establish M2 frame gains. Mac captures are needed before choosing a larger
renderer or native-kernel replacement.
-3
View File
@@ -75,11 +75,8 @@ separate implementation. [Geometry and collision scope](collision-geometry.md).
```sh
cargo test --workspace
cargo run --release --bin voxel-bench -- 100 > .local/voxel-benchmark.csv
```
The dependency-free occupancy experiment compares scalar and bitset exposed-face counting for synthetic 16³ sections. It has no real mesh, material/AO/light work, neighboring-section context, JNI bridge or game integration. Speedups are kernel-only, not Minecraft FPS.
Public branding and new mod downloads use **Horizon**. The existing Fabric mod
ID and `dev.horizonlab` namespace remain stable for installed instances and
measurement compatibility. The installer backs up both old `horizon-lab-*.jar`
+2 -4
View File
@@ -177,7 +177,5 @@ To be written as `docs/porting-from-fabric.md` once API 0.2 lands:
- NeoForge documentation (`docs.neoforged.net`): events, registries,
networking.
- Modrinth's most-downloaded Fabric mods, for the porting list.
- Horizon's own Java test mod (`mods/horizon`, built by `tools/build_mod.py`
and the CI `portable-mod` job) is a Fabric mod used by the benchmarks and
parity oracles: it shows the Mixin pattern (`FrameHookMixin`) this page
describes, and stays a developer tool, not a player-facing mod.
- Horizon's Fabric benchmark instrumentation (`mods/horizon/src/bench/`) remains
a developer tool for Java comparisons. The parity oracles live in `mods/horizon/src/test/`.
+3 -1
View File
@@ -33,6 +33,7 @@ harness named checks that loader against vanilla).
| Overlays per format | `pack.mcmeta` `overlays` | works | `test_resource_pack_parity` (an overlay for 26.3 applied, an older one not) |
| Filters | `pack.mcmeta` `filter` | works | `test_resource_pack_parity` (`packs/pattern.rs` regex subset) |
| Folder and zip packs, order, built-in packs | `resourcepacks/` | works | `test_resource_pack_parity`, `test_pack_selection_parity` |
| Server resource packs: the prompt, the server list's Server Resource Packs setting, download (250 MiB, SHA-1, `downloads/` keeps 20), applied on top of the player's packs, removed on leaving | the server's `resource_pack_push`/`_pop` | works, no download toasts | `tools/server_pack_e2e.py` (against vanilla's server and Horizon's), unit tests in `server_packs.rs`; `test_server_resource_pack_parity` for Horizon's server |
| Other namespaces | `assets/<namespace>/` | works | `test_resource_pack_parity` |
| Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` |
| Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` |
@@ -74,7 +75,7 @@ harness named checks that loader against vanilla).
| Core shaders | `shaders/core/`, `shaders/include/` | **no** | Horizon draws with WGSL; a pack's GLSL core shaders aren't run |
| GPU warn list, regional compliance | `gpu_warnlist.json`, `regional_compliancies.json` | n/a | OpenGL driver warnings and play-time notices for some regions; not used |
Summary: 44 rows; 31 work, 3 partly, 8 don't (interpolated animation, TrueType fonts, credits and end poem,
Summary: 45 rows; 32 work, 3 partly, 8 don't (interpolated animation, TrueType fonts, credits and end poem,
villager hats, trims on worn armour, locator bar styles, post effects, core shaders), 2 don't apply. Except the
shaders, the missing pieces are missing vanilla features too, and each vanilla parity fix covers the pack case as well.
@@ -83,6 +84,7 @@ shaders, the missing pieces are missing vanilla features too, and each vanilla p
| Gap | Size | Where |
| --- | --- | --- |
| Animation `interpolate` | small | fix PR (`gfx.rs` `animate_sprites`) |
| Server pack download toasts (`download.pack.*`) | small | `game/server_packs.rs` |
| GUI scaling `width`/`height`, `stretch_inner`, `tile`, `stretch` | small | next fix PR (`ui.rs`) |
| Villager hats | small | [#334](https://github.com/ludovic111/horizon/issues/334) |
| `blur`/`clamp` from packs | small | [#335](https://github.com/ludovic111/horizon/issues/335) |
-1
View File
@@ -54,7 +54,6 @@ across 1,441,816 state pairs, including all pinned suppression-rule families.
Animation metadata, frame sequences and tick-state selection now have native
differential coverage, including all bundled animated texture metadata. GPU
interpolation and game-loop synchronization remain unfinished.
The playable Java performance packs remain available during development.
## Completion criteria and playable builds
+2 -3
View File
@@ -7,8 +7,7 @@ should beat vanilla, Paper and Fabric + Lithium on TPS/MSPT, RAM, CPU, chunk gen
Gameplay stays vanilla: there are no Paper-style rule changes unless the owner turns one on.
This page covers what the server does today (measured), the design of `horizon-server`, and what ships for the
first working version (2026-10-23). It replaced the Java/Fabric starter (`docs/server-quickstart.txt`,
`tools/server.py`, retired on 2026-10-19). The owner's guide is docs/hosting.md, also at
first working version (2026-10-23). The owner's guide is docs/hosting.md, also at
https://playhorizon.dev/server.
## 1. Today
@@ -467,7 +466,7 @@ reproducible by anyone, and show the same gameplay on both sides.
- Horizon (`horizon-server`);
- vanilla 26.3 (the owner's `server.jar`);
- Paper (a jar the owner downloads; Paper's defaults are kept, and noted as non-vanilla);
- optionally Fabric + Lithium (the old starter's setup, via the pinned `packs/mods.lock.json`).
- optionally Fabric + Lithium (via the pinned `tools/benchmark-deps/mods.lock.json`).
Java servers run on Java 25 with `-Xms` = `-Xmx` (the same heap for all, 4 GB by default), and the heap is
reported.
-26
View File
@@ -1,26 +0,0 @@
# Interpreting measured server ticks
Tick execution time omits scheduler sleep. `stats.meanRate` is the inverse average duration, not actual TPS. `observed_ticks_per_second` already counts accepted plus dropped measured invocations divided by wall-clock recording duration. Frozen tick invocations count; paused wall time stays in the denominator. Neither number measures completed simulation work.
Version 0.20 observes the server tick manager after each measured invocation on the server thread. Reports record the first and last sampled configured rate, frozen/stepping/sprinting flags and `runs_game_elements`, plus counters for samples with each nonstandard state. This is read-only instrumentation: it does not set a tick rate, freeze worlds or change game rules. Frame reports do not claim to sample server state; use their accompanying integrated-server tick reports.
The recorder retains which state fields changed between samples. A 20 → 40 → 20 rate change or freeze → unfreeze sequence stays visible even when first and last states match. Both comparison tools mark such recordings as changed workloads, mark differing sampled boundary states, and warn about frozen, stepping, sprinting, suppressed-game-element and nonstandard-rate samples. A consistently configured custom rate can match another report at the same rate, with an explicit warning about that workload. Older, absent or invalid state metadata remains unknown, rather than normal by assumption.
State and tick duration are accepted under the same recording lock, including buffer overflow. The observation path retains scalar values and allocates no maps or lists per invocation; report serialization creates them once. Non-finite or sub-minimum rates are counted as invalid observations without aborting duration capture. The state object is only allocated for tick recordings.
Sampling has limits. These are first/last sampled server states, not exact recording boundary states. Changes entirely between samples can be missed. An integrated server paused by the game menu does not call the measured superclass method, so state counters do not enumerate paused time. The manager flag also does not establish that every entity/chunk was simulated, or detect all other reasons a server skips work. A final stepping tick may already have decremented the remaining-step counter to zero while `runs_game_elements` is still true. Compare world copies, entity populations, completed work and real player workloads separately.
## Validation
Pure recording checks exercise restored intermediate changes, distinct frozen/suppressed states, late observations, absent/invalid manager state and wall-clock TPS calculation under G1, compact headers and ZGC. Offline comparison checks cover map ordering, changed/custom states and legacy/malformed input. The desktop and 390-pixel viewer checks also load the actual discarded paused capture (12 ticks over approximately 120 seconds), display its roughly 0.1 observed TPS and distinguish it from execution capacity. That capture remains excluded from performance evidence.
The private `TickStateIntegration` fixture runs only on a disposable demo-world copy. It starts a short recording, samples normal ticking, changes to 40 configured ticks/second and freezes, steps ten ticks, restores 20/unfrozen state, and requests a five-tick sprint. It checks that sampled boundary states match, all five intermediate state changes/counters survive, and accepted state/duration counts agree. The fixture is compiled in platform CI and excluded from the production JAR and downloadable packs. The [final local applied-hook observation](../benchmarks/tick-state-2026-10-04/live-observations.json) records 285 invocations, with 161 frozen, 9 stepping, 5 sprinting, 151 suppressed-game-element and 161 nonstandard-rate samples. All five change fields survive restored boundary states; CSV count/mean and zero invalid/drop observations are verified, and the client exits with code zero. This is functional evidence rather than a performance comparison. Compilation is not a live Mac result.
Compile the private fixture after building the original mod:
```sh
python3 tools/build_mod.py
python3 -c "from tools.build_mod import build_integration_fixture; build_integration_fixture('tick-state', 'dev.horizonlab.metrics.TickStateIntegration')"
```
Install `.local/mod-build/tick-state-fixture.jar` only into a disposable locally prepared balanced demo instance. Open its copied demo world and dismiss the demo screen. The fixture validates the report and closes the client after its sequence. Never add it to a normal play instance or starter package.
-32
View File
@@ -1,32 +0,0 @@
# Compare the Vulkan renderer on your Mac
The `vulkan` profile asks Minecraft 26.3 to use its existing Vulkan backend, with
its original OpenGL fallback. It uses balanced mods/distances.
The game renderer handles the backend; this project has not written a separate
Metal renderer. The benchmark records the actual backend and driver.
The checksum-verified 26.3 manifest includes LWJGL Vulkan 3.4.3 Mac ARM64 natives.
Inspecting that official native JAR confirmed `libMoltenVK.dylib`; its paired
LWJGL loader first tries MoltenVK on macOS. [MoltenVK](https://github.com/KhronosGroup/MoltenVK)
implements Vulkan functionality over Apple's Metal framework. This verifies a
bundled route to Metal, rather than proving that a particular Mac backend starts
or performs better. No separately installed Vulkan SDK is required for the bundled
library; driver/backend compatibility still needs a real Mac test.
Import `m2-balanced` and `vulkan` as separate Prism instances. Keep Java 25 ARM64,
heap, header/collector flags, world copies, resolution, view/simulation distance
and camera route equal. Warm terrain and shader/pipeline caches before comparing.
Run at least three alternating 120-second F8 recordings. Inspect nearby blocks,
distant terrain, transparency, water, lighting and reloads as well as timings.
Use the viewer's actual backend field: OpenGL fallback is an OpenGL result.
After the isolated test works, `foreground-first` and `foreground-vulkan` compare
the two backends with the same combined terrain controls. Their 120 FPS adaptive
goal controls background duty cycle; rendering remains uncapped in pack options.
Do not add shaders to one side of an isolated backend comparison.
Linux software rendering checks reached the actual Vulkan backend, rendered the
demo world and recorded matching CSV/JSON frame/tick samples. An unavailable
Vulkan-driver test reached the menu using OpenGL fallback. These tests do not
validate MoltenVK, real GPU speeds, all visual details or macOS 27. The user's
M2 Pro remains the priority device for those checks.
+13 -1
View File
@@ -1524,7 +1524,7 @@ pub fn append_game_args(cmd: &mut Command) {
pub fn client_command(tag: &str) -> Command {
let home = game_home();
let _ = fs::create_dir_all(&home);
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
let mut cmd = Command::new(client_program(tag));
cmd.current_dir(home).arg("--resources").arg(resources(tag));
// PipeWire remembers volume and mute per application name. The game's own name is
// "PipeWire ALSA [horizon-client]", shared with development and test runs, where
@@ -1538,6 +1538,18 @@ pub fn client_command(tag: &str) -> Command {
cmd
}
/// The game's program: on macOS the one inside the version's Horizon.app, so the Dock and Cmd+Tab
/// show Horizon's name and icon (game_app.rs); the bare binary elsewhere, or if that fails.
fn client_program(tag: &str) -> PathBuf {
let dir = version_dir(tag);
#[cfg(target_os = "macos")]
match crate::game_app::ensure(&dir) {
Ok(program) => return program,
Err(e) => eprintln!("Starting the game outside {}: {e}", crate::game_app::APP),
}
dir.join(CLIENT)
}
pub fn server_command(tag: &str, folder: &Path) -> Command {
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
cmd.current_dir(folder)
+146
View File
@@ -0,0 +1,146 @@
//! The game's app bundle on macOS. Started as a bare executable, the game shows in the Dock and
//! in Cmd+Tab as "horizon-client" with the generic executable icon (winit's window icon does
//! nothing there). So the launcher starts it from a small Horizon.app inside its version folder:
//! the Info.plist names it and the icon is the launcher's own. The programs inside are hard links
//! to the version's, so the bundle takes no disk space and the release's signature is untouched.
use std::fs;
use std::io;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
/// The bundle's folder in a version folder.
pub const APP: &str = "Horizon.app";
/// The game, then horizon-ctl, which the game looks for beside itself (Copy Connection Details).
const PROGRAMS: [&str; 2] = ["horizon-client", "horizon-ctl"];
const ICON: &[u8] = include_bytes!("../../packaging/icons/horizon.icns");
/// Its own identifier, so macOS keeps it apart from the launcher (dev.horizon.launcher); the
/// games category lets macOS turn Game Mode on when it plays full screen.
const INFO_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Horizon</string>
<key>CFBundleExecutable</key>
<string>horizon-client</string>
<key>CFBundleIconFile</key>
<string>horizon</string>
<key>CFBundleIdentifier</key>
<string>dev.horizon.game</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Horizon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.games</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
"#;
/// Brings `version`'s Horizon.app up to date and returns the game's program inside it. A program
/// replaced in the version folder (a reinstall, a build copied over) is linked again.
pub fn ensure(version: &Path) -> io::Result<PathBuf> {
let contents = version.join(APP).join("Contents");
let programs = contents.join("MacOS");
fs::create_dir_all(&programs)?;
fs::create_dir_all(contents.join("Resources"))?;
write_if_changed(&contents.join("Info.plist"), INFO_PLIST.as_bytes())?;
write_if_changed(&contents.join("Resources/horizon.icns"), ICON)?;
for (i, name) in PROGRAMS.into_iter().enumerate() {
let (from, to) = (version.join(name), programs.join(name));
// Releases before horizon-ctl shipped have only the game.
if i > 0 && !from.exists() {
continue;
}
if !same_file(&from, &to) {
let _ = fs::remove_file(&to);
fs::hard_link(&from, &to)?;
}
}
Ok(programs.join(PROGRAMS[0]))
}
fn write_if_changed(path: &Path, bytes: &[u8]) -> io::Result<()> {
if fs::read(path).ok().as_deref() != Some(bytes) {
fs::write(path, bytes)?;
}
Ok(())
}
fn same_file(a: &Path, b: &Path) -> bool {
match (fs::metadata(a), fs::metadata(b)) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_bundle_links_the_version_programs_and_follows_a_replaced_one() {
let version = std::env::temp_dir().join(format!("horizon-game-app-{}", std::process::id()));
let _ = fs::remove_dir_all(&version);
fs::create_dir_all(&version).unwrap();
fs::write(version.join("horizon-client"), "game 1").unwrap();
fs::write(version.join("horizon-ctl"), "ctl").unwrap();
let program = ensure(&version).unwrap();
assert_eq!(
program,
version.join("Horizon.app/Contents/MacOS/horizon-client")
);
assert!(same_file(&program, &version.join("horizon-client")));
assert!(same_file(
&version.join("Horizon.app/Contents/MacOS/horizon-ctl"),
&version.join("horizon-ctl")
));
let plist = fs::read_to_string(version.join("Horizon.app/Contents/Info.plist")).unwrap();
assert!(plist.contains("<string>Horizon</string>"));
assert_eq!(
fs::read(version.join("Horizon.app/Contents/Resources/horizon.icns")).unwrap(),
ICON
);
// A new build copied over the version's game: the bundle runs it, not the old one.
fs::remove_file(version.join("horizon-client")).unwrap();
fs::write(version.join("horizon-client"), "game 2").unwrap();
assert_eq!(
fs::read_to_string(ensure(&version).unwrap()).unwrap(),
"game 2"
);
fs::remove_dir_all(&version).unwrap();
}
#[test]
fn a_version_without_horizon_ctl_still_gets_its_bundle() {
let version =
std::env::temp_dir().join(format!("horizon-game-app-old-{}", std::process::id()));
let _ = fs::remove_dir_all(&version);
fs::create_dir_all(&version).unwrap();
fs::write(version.join("horizon-client"), "game").unwrap();
assert!(ensure(&version).unwrap().exists());
assert!(!version
.join("Horizon.app/Contents/MacOS/horizon-ctl")
.exists());
fs::remove_dir_all(&version).unwrap();
}
#[test]
fn a_version_without_the_game_has_no_bundle_program() {
let version =
std::env::temp_dir().join(format!("horizon-game-app-none-{}", std::process::id()));
let _ = fs::remove_dir_all(&version);
fs::create_dir_all(&version).unwrap();
assert!(ensure(&version).is_err());
fs::remove_dir_all(&version).unwrap();
}
}
+2
View File
@@ -4,6 +4,8 @@ pub mod agents;
pub mod backend;
pub mod browser;
pub mod desktop;
#[cfg(unix)]
pub mod game_app;
pub mod heads;
pub mod modpacks;
pub mod mojang;
+3 -2
View File
@@ -197,13 +197,14 @@ pub fn set_up_in(root: &Path, version: &Path, tag: &str, game_version: &str) ->
Ok(())
}
/// A release folder without the launcher's own files (its prepared copy and install marker).
/// A release folder without the launcher's own files (its prepared copy, install marker and the
/// game's macOS app bundle).
fn copy_release(from: &Path, to: &Path) -> std::io::Result<()> {
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
let name = entry.file_name();
if name == ".local" || name == ".installed" {
if name == ".local" || name == ".installed" || name == "Horizon.app" {
continue;
}
let target = to.join(&name);
+3
View File
@@ -58,6 +58,7 @@ fn programs_are_laid_out_as_install_sh_does() {
fs::write(version.join(PROGRAM), "server").unwrap();
fs::write(version.join("horizon-client"), "game").unwrap();
fs::write(version.join(".installed"), "").unwrap();
crate::game_app::ensure(&version).unwrap();
fs::write(
version.join(".local/horizon-client/26.3/horizon-resources.json"),
"{}",
@@ -70,6 +71,8 @@ fn programs_are_laid_out_as_install_sh_does() {
"server"
);
assert!(!rt.join("releases/v1/.installed").exists());
// The game's macOS bundle is the launcher's, not the server's.
assert!(!rt.join("releases/v1/Horizon.app").exists());
// The release finds the game files where horizon-server looks: .local beside it.
assert!(rt
.join("current/.local/horizon-client/26.3/horizon-resources.json")
@@ -1,78 +0,0 @@
package dev.horizonlab;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import dev.horizonlab.metrics.Recording;
import dev.horizonlab.metrics.Reports;
import dev.horizonlab.metrics.ProcessMemoryMonitor;
import dev.horizonlab.metrics.ResidentMemory;
import java.nio.file.Path;
import java.util.Map;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.permissions.Permissions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class HorizonLab implements ModInitializer {
public static final Logger LOGGER = LoggerFactory.getLogger("horizonlab");
private static volatile Recording ticks;
@Override public void onInitialize() {
ProcessMemoryMonitor.configure(new ResidentMemory());
CommandRegistrationCallback.EVENT.register((dispatcher, context, selection) -> dispatcher.register(
Commands.literal("horizonserver")
.requires(source -> source.permissions().hasPermission(Permissions.COMMANDS_ADMIN))
.then(Commands.literal("bench")
.then(Commands.argument("label", StringArgumentType.word())
.then(Commands.argument("seconds", IntegerArgumentType.integer(10, 600))
.executes(command -> {
boolean started = startTicks(StringArgumentType.getString(command, "label"),
IntegerArgumentType.getInteger(command, "seconds"), Path.of("horizon-benchmarks"), "server",
command.getSource().getServer().isDedicatedServer() ? "dedicated" : "integrated", Map.of());
command.getSource().sendSuccess(() -> Component.literal(started
? "Horizon: recording tick times; run the intended workload now."
: "Horizon: a tick recording is already active."), false);
return started ? 1 : 0;
}))))
.then(Commands.literal("stop").executes(command -> { finishTicks(); return 1; }))));
LOGGER.info("Horizon ready. Simulation rules and tick rate are unchanged.");
}
public static synchronized boolean startTicks(String label, int seconds, Path output, String profile) {
return startTicks(label, seconds, output, profile, "unknown", Map.of());
}
public static synchronized boolean startTicks(String label, int seconds, Path output, String profile,
String runtimeKind, Map<String, ?> clientContext) {
if (ticks != null) return false;
Map<String, Object> context = dev.horizonlab.metrics.SoftwareContext.capture();
context.put("profile", profile);
context.put("minecraft", "26.3");
context.putAll(dev.horizonlab.metrics.ServerContext.capture(runtimeKind, clientContext));
ticks = new Recording(label, "ticks", output, seconds, context);
return true;
}
public static void tick(long durationNs, MinecraftServer server) {
Recording recording = ticks;
if (recording == null) return;
var tickRate = server.tickRateManager();
recording.addTick(durationNs, server.getPlayerList().getPlayerCount(), tickRate.tickrate(), tickRate.isFrozen(), tickRate.isSteppingForward(),
tickRate.isSprinting(), tickRate.runsNormally());
if (System.nanoTime() >= recording.deadlineNs) finishTicks();
}
public static synchronized void finishTicks() {
Recording recording = ticks;
if (recording == null) return;
ticks = null;
Reports.write(recording.finish()).whenComplete((path, error) -> {
if (error != null) LOGGER.error("Tick report failed", error);
else LOGGER.info("Horizon tick benchmark saved to {}", path);
});
}
}
@@ -1,14 +0,0 @@
package dev.horizonlab.budget;
/** Wait only while an unchanged debounce deadline blocks progress. */
public final class DebounceWait {
public static final long MAX_WAIT_NS = 1_000_000L;
private DebounceWait() {}
public static long delay(long now, long firstQueued, long debounce, boolean preprocessingPending) {
if (preprocessingPending || debounce <= 0) return 0;
long age = now - firstQueued;
if (age < 0 || age >= debounce) return 0;
return Math.min(MAX_WAIT_NS, debounce - age);
}
}
@@ -1,14 +0,0 @@
package dev.horizonlab.budget;
/** Per-picker state, used only while its existing dispatch lock is held. */
public final class DispatchRetry {
private boolean rejectedExecutor;
private boolean rejectedPass;
public void beginPass() { rejectedExecutor = false; rejectedPass = false; }
public void beginExecutor() { rejectedExecutor = false; }
public void rejected() { rejectedExecutor = true; rejectedPass = true; }
public int visibleSlots(int occupied) { return rejectedExecutor ? Integer.MAX_VALUE : occupied; }
public boolean needsRetry(boolean original, boolean shutdown) { return !shutdown && (original || rejectedPass); }
public long retryMilliseconds(long original) { return rejectedPass ? 1 : original; }
}
@@ -1,38 +0,0 @@
package dev.horizonlab.budget;
/** Foreground-first background duty cycle. Inputs are two-second observation windows. */
public final class TerrainBudget {
private double ratio;
private int healthyWindows;
public TerrainBudget(double ceiling) { ratio = ceiling; }
public double update(double ceiling, double meanFrameMs, double targetFrameMs,
double lateFraction, double heapFraction) {
if (!Double.isFinite(ceiling) || ceiling < 0.01 || ceiling > 1.0
|| !Double.isFinite(meanFrameMs) || meanFrameMs < 0
|| !Double.isFinite(targetFrameMs) || targetFrameMs <= 0
|| !Double.isFinite(lateFraction) || lateFraction < 0 || lateFraction > 1
|| !Double.isFinite(heapFraction) || heapFraction < 0 || heapFraction > 1) {
throw new IllegalArgumentException("Invalid budget observations");
}
double floor = Math.min(ceiling, 0.05);
ratio = Math.min(ratio, ceiling);
boolean pressure = meanFrameMs > targetFrameMs * 1.35 || lateFraction > 0.20 || heapFraction > 0.85;
boolean healthy = meanFrameMs < targetFrameMs * 1.10 && lateFraction < 0.05 && heapFraction < 0.75;
if (pressure) {
healthyWindows = 0;
ratio = Math.max(floor, ratio * 0.65);
} else if (healthy) {
if (++healthyWindows >= 3) {
ratio = Math.min(ceiling, ratio + 0.025);
healthyWindows = 0;
}
} else {
healthyWindows = 0;
}
return ratio;
}
public double ratio() { return ratio; }
}
@@ -1,27 +0,0 @@
package dev.horizonlab.budget;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutionException;
/** Allow the pinned DH generation pool's shutdown interrupt to release a read-only wait. */
public final class TerrainReadWait {
private TerrainReadWait() {}
public static <T> T join(CompletableFuture<T> future) {
// Ordinary game callers retain join's interrupt behavior. Completed reads use the original path.
if (future.isDone() || !Thread.currentThread().getName().startsWith("DH-World Gen Thread["))
return future.join();
try {
return future.get();
} catch (InterruptedException shutdown) {
Thread.currentThread().interrupt();
// DH recognizes this cause as shutdown. The cached future belongs to other callers too.
throw new CompletionException(shutdown);
} catch (ExecutionException | CancellationException failure) {
// Now completed: retain join's original exception identity and wrapping semantics.
return future.join();
}
}
}
@@ -1,15 +0,0 @@
package dev.horizonlab.budget;
import java.util.concurrent.locks.LockSupport;
/** A bounded idle park, preserving Thread.sleep's InterruptedException/cleared-flag contract. */
public final class WakeableIdleWait {
private WakeableIdleWait() { }
public static void pause(Object blocker, long nanoseconds) throws InterruptedException {
if (nanoseconds < 0) throw new IllegalArgumentException("Negative idle wait");
if (Thread.interrupted()) throw new InterruptedException();
LockSupport.parkNanos(blocker, nanoseconds);
if (Thread.interrupted()) throw new InterruptedException();
}
}
@@ -1,36 +0,0 @@
package dev.horizonlab.budget;
/** Per-worker primitive state; amortizes short waits without discarding fractional milliseconds. */
public final class WorkerPacer {
public static final long QUANTUM_NS = 250_000;
public static final long MAX_CREDIT_NS = 50_000_000;
private long started;
private long completed;
private double debtNs;
private double previousRatio = 1;
public void begin(long now) {
if (completed != 0 && now - completed > 1_000_000_000L) debtNs = 0;
started = now;
}
public long finish(long now, double ratio) {
long workNs = Math.max(0, now - started);
completed = now;
if (ratio != previousRatio) debtNs = 0;
previousRatio = ratio;
if (!Double.isFinite(ratio) || ratio <= 0 || ratio >= 1) {
debtNs = 0;
return 0;
}
debtNs += workNs * (1.0 / ratio - 1.0);
return debtNs < QUANTUM_NS ? 0 : (long) Math.min(debtNs, Long.MAX_VALUE / 4.0);
}
public void pauseCompleted(long now) {
debtNs = Math.max(-MAX_CREDIT_NS, debtNs - Math.max(0, now - completed));
completed = now;
}
public void interrupted(long now) { debtNs = 0; completed = now; }
}
@@ -1,74 +0,0 @@
package dev.horizonlab.client;
import com.seibel.distanthorizons.api.DhApi;
import com.seibel.distanthorizons.api.interfaces.config.IDhApiConfigValue;
import dev.horizonlab.HorizonLab;
import dev.horizonlab.budget.TerrainBudget;
/** Loaded only when DH is installed. Overrides are temporary, never written to its TOML. */
final class DistantTerrainBudget {
private IDhApiConfigValue<Double> setting;
private TerrainBudget policy;
private Double owned;
private boolean yielded;
private long windowStart;
private long totalNs;
private int count;
private int late;
private int changes;
void observe(long now, long frameNs, double targetMs) {
if (yielded || DhApi.Delayed.configs == null) return;
if (setting == null) {
setting = DhApi.Delayed.configs.multiThreading().threadRuntimeRatio();
if (!setting.getCanBeOverrodeByApi() || setting.getApiValue() != null) {
yielded = true;
HorizonLab.LOGGER.info("Adaptive terrain budget yielded to an existing DH override");
return;
}
policy = new TerrainBudget(setting.getTrueValue());
}
if (!java.util.Objects.equals(setting.getApiValue(), owned)) {
yielded = true;
owned = null;
HorizonLab.LOGGER.info("Adaptive terrain budget yielded after another DH override changed");
return;
}
if (windowStart == 0) windowStart = now;
if (frameNs > 0) {
totalNs += frameNs;
count++;
if (frameNs / 1e6 > targetMs * 2) late++;
}
if (now - windowStart < 2_000_000_000L || count == 0) return;
Runtime runtime = Runtime.getRuntime();
double heap = (runtime.totalMemory() - runtime.freeMemory()) / (double) runtime.maxMemory();
double next = policy.update(setting.getTrueValue(), totalNs / 1e6 / count, targetMs,
late / (double) count, heap);
if (owned == null || Math.abs(next - owned) > 0.0001) {
if (setting.setValue(next, "Horizon adaptive budget")) {
owned = next;
changes++;
HorizonLab.LOGGER.info("Adaptive terrain background runtime ratio: {}", next);
}
}
resetWindow();
}
void suspend() {
if (setting != null && owned != null && java.util.Objects.equals(setting.getApiValue(), owned)) {
setting.clearValue();
HorizonLab.LOGGER.info("Adaptive terrain override released; effective runtime ratio: {}", setting.getValue());
}
owned = null;
policy = setting == null ? null : new TerrainBudget(setting.getTrueValue());
resetWindow();
}
private void resetWindow() { windowStart = 0; totalNs = 0; count = 0; late = 0; }
String status() {
return yielded ? "yielded to another mod" : owned == null ? "waiting for active gameplay"
: "DH runtime ratio " + owned + "; adjustments " + changes;
}
}
@@ -1,46 +0,0 @@
package dev.horizonlab.client;
import com.seibel.distanthorizons.api.DhApi;
import com.seibel.distanthorizons.api.interfaces.config.IDhApiConfig;
import com.seibel.distanthorizons.api.interfaces.config.IDhApiConfigValue;
import java.util.LinkedHashMap;
import java.util.Map;
/** Read effective API values once at capture boundaries; never change terrain settings. */
final class DistantTerrainSettings {
private DistantTerrainSettings() {}
static Map<String, Object> read() { return read(DhApi.Delayed.configs); }
static Map<String, Object> read(IDhApiConfig configs) {
if (configs == null) return Map.of("dh_capture_status", "unavailable");
Map<String, Object> values = new LinkedHashMap<>();
try {
var graphics = configs.graphics();
put(values, "dh_rendering_enabled", graphics.renderingEnabled());
put(values, "dh_renderer_mode", graphics.renderingMode());
put(values, "dh_lod_radius_chunks", graphics.chunkRenderDistance());
put(values, "dh_horizontal_quality", graphics.horizontalQuality());
put(values, "dh_vertical_quality", graphics.verticalQuality());
put(values, "dh_max_horizontal_resolution", graphics.maxHorizontalResolution());
put(values, "dh_transparency", graphics.transparency());
put(values, "dh_lod_shading", graphics.lodShading());
put(values, "dh_lod_only_mode", graphics.lodOnlyMode());
put(values, "dh_camera_quality_dropoff", graphics.useCameraPositionForQualityDropOff());
put(values, "dh_ambient_occlusion", graphics.ambientOcclusion().enabled());
put(values, "dh_thread_count", configs.multiThreading().threadCount());
put(values, "dh_runtime_ratio", configs.multiThreading().threadRuntimeRatio());
values.put("dh_capture_status", "available");
return values;
} catch (RuntimeException | LinkageError unavailable) {
// Missing/changed APIs must not look like disabled rendering or a zero distance.
return Map.of("dh_capture_status", "unavailable");
}
}
private static void put(Map<String, Object> target, String name, IDhApiConfigValue<?> setting) {
Object value = setting.getValue(); // Includes active overrides; getTrueValue reads only the user base.
if (value == null) throw new IllegalStateException("Unavailable configuration value");
target.put(name, value instanceof Enum<?> choice ? choice.name() : value);
}
}
@@ -1,211 +0,0 @@
package dev.horizonlab.client;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.blaze3d.systems.RenderSystem;
import com.mojang.blaze3d.platform.InputConstants;
import dev.horizonlab.HorizonLab;
import dev.horizonlab.metrics.Recording;
import dev.horizonlab.metrics.Reports;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Options;
import net.minecraft.client.KeyMapping;
import net.minecraft.network.chat.Component;
import static net.fabricmc.fabric.api.client.command.v2.ClientCommands.argument;
import static net.fabricmc.fabric.api.client.command.v2.ClientCommands.literal;
public final class HorizonClient implements ClientModInitializer {
private static Recording frames;
private static long previousStart;
private static String profile = "custom";
private static DistantTerrainBudget terrainBudget;
private static long previousBudgetFrame;
private static int targetFps = 60;
@Override public void onInitializeClient() {
KeyMapping benchmarkKey = KeyMappingHelper.registerKeyMapping(new KeyMapping(
"key.horizonlab.benchmark", InputConstants.KEY_F8, KeyMapping.Category.MISC));
ClientTickEvents.END_CLIENT_TICK.register(client -> {
while (benchmarkKey.consumeClick()) {
if (client.level != null && client.gui.screen() == null) {
if (frames == null) start(profile, 120); else finish();
}
}
});
Path profileFile = FabricLoader.getInstance().getConfigDir().resolve("horizon-profile.txt");
try {
if (Files.exists(profileFile)) profile = Files.readString(profileFile).strip();
} catch (Exception error) { HorizonLab.LOGGER.warn("Profile label could not be read", error); }
Path budgetFile = FabricLoader.getInstance().getConfigDir().resolve("horizon-adaptive.properties");
try {
if (Files.exists(budgetFile)) {
var properties = new java.util.Properties();
try (var reader = Files.newBufferedReader(budgetFile)) { properties.load(reader); }
targetFps = Math.clamp(Integer.parseInt(properties.getProperty("targetFps", "60")), 10, 240);
if (Boolean.parseBoolean(properties.getProperty("enabled", "false"))) enableBudget();
}
} catch (Exception error) { HorizonLab.LOGGER.warn("Adaptive budget disabled: invalid configuration", error); }
ClientCommandRegistrationCallback.EVENT.register((dispatcher, context) -> dispatcher.register(
literal("horizon")
.then(literal("bench")
.executes(command -> start("benchmark", 120))
.then(argument("label", StringArgumentType.word())
.executes(command -> start(StringArgumentType.getString(command, "label"), 120))
.then(argument("seconds", IntegerArgumentType.integer(10, 600))
.executes(command -> start(StringArgumentType.getString(command, "label"),
IntegerArgumentType.getInteger(command, "seconds"))))))
.then(literal("stop").executes(command -> { finish(); return 1; }))
.then(literal("adaptive")
.then(literal("on").executes(command -> {
if (frames != null) { message("Finish the recording before changing the budget"); return 0; }
message(enableBudget() ? "Adaptive terrain budget enabled" : "Distant Horizons is required");
return 1;
}))
.then(literal("off").executes(command -> {
if (frames != null) { message("Finish the recording before changing the budget"); return 0; }
if (terrainBudget != null) terrainBudget.suspend();
terrainBudget = null;
previousBudgetFrame = 0;
message("Adaptive terrain budget disabled"); return 1;
})))
.then(literal("status").executes(command -> {
message("Profile: " + profile + "; recording: " + (frames != null)
+ "; adaptive: " + (terrainBudget == null ? "off" : terrainBudget.status())); return 1;
}))));
HorizonLab.LOGGER.info("Horizon client profile: {}. F8 or /horizon bench <label> [seconds]", profile);
}
private static int start(String label, int seconds) {
if (frames != null) { message("A benchmark is already recording; /horizon stop finishes it."); return 0; }
Minecraft client = Minecraft.getInstance();
Path output = client.gameDirectory.toPath().resolve("horizon-benchmarks");
Map<String, Object> context = new LinkedHashMap<>();
context.put("minecraft", "26.3");
context.put("profile", profile);
context.put("adaptive_budget_enabled", terrainBudget != null);
context.put("precise_worker_pacing", dev.horizonlab.mixin.HorizonMixinPlugin.workerPacingEnabled());
context.put("debounce_queue_wait", dev.horizonlab.mixin.HorizonMixinPlugin.debounceWaitEnabled());
context.put("wakeable_idle_wait", dev.horizonlab.mixin.HorizonMixinPlugin.debounceWaitEnabled());
context.put("dispatch_retry_backoff", dev.horizonlab.mixin.HorizonMixinPlugin.dispatchRetryEnabled());
context.put("tick_membership_dedup", dev.horizonlab.mixin.HorizonMixinPlugin.tickDedupEnabled());
context.put("terrain_quad_reuse", dev.horizonlab.mixin.HorizonMixinPlugin.quadReuseEnabled());
context.put("terrain_position_arithmetic", dev.horizonlab.mixin.HorizonMixinPlugin.terrainPositionEnabled());
context.put("culling_ray_scratch", dev.horizonlab.mixin.HorizonMixinPlugin.cullingRayScratchEnabled());
context.put("empty_culling_whitelist", dev.horizonlab.mixin.HorizonMixinPlugin.cullingEmptyEnabled());
context.put("interruptible_terrain_read", dev.horizonlab.mixin.HorizonMixinPlugin.terrainReadWaitEnabled());
context.put("adaptive_target_fps", targetFps);
context.putAll(visualSettings(client));
var device = RenderSystem.getDevice().getDeviceInfo();
context.put("gpu", device.name());
context.put("graphics_backend", device.backendName());
context.put("graphics_driver", device.driverInfo());
context.putAll(dev.horizonlab.metrics.SoftwareContext.capture());
frames = new Recording(label, "frames", output, seconds, context);
previousStart = 0;
if (client.getSingleplayerServer() != null) HorizonLab.startTicks(label, seconds, output, profile, "integrated", context);
message("Recording " + seconds + " seconds. Close chat, keep the game focused, and run your route.");
return 1;
}
private static Map<String, Object> visualSettings(Minecraft client) {
Map<String, Object> context = new LinkedHashMap<>();
if (client.level != null) context.put("world_difficulty", client.level.getDifficulty().name());
context.put("render_distance", client.options.renderDistance().get());
context.put("simulation_distance", client.options.simulationDistance().get());
context.put("vsync", client.options.enableVsync().get());
context.put("fps_limit", client.options.framerateLimit().get());
context.put("fps_limit_unlimited", client.options.framerateLimit().get() >= Options.UNLIMITED_FRAMERATE_CUTOFF);
context.put("framebuffer_width", client.getWindow().getWidth());
context.put("framebuffer_height", client.getWindow().getHeight());
context.put("graphics_preset", client.options.graphicsPreset().get().toString());
context.put("ambient_occlusion", client.options.ambientOcclusion().get());
context.put("improved_transparency", client.options.improvedTransparency().get());
context.put("mipmap_levels", client.options.mipmapLevels().get());
context.put("entity_distance_scaling", client.options.entityDistanceScaling().get());
context.put("entity_shadows", client.options.entityShadows().get());
context.put("particles", client.options.particles().get().toString());
context.put("clouds", client.options.cloudStatus().get().toString());
context.put("cloud_range", client.options.cloudRange().get());
context.put("fov", client.options.fov().get());
var loader = FabricLoader.getInstance();
if (loader.isModLoaded("distanthorizons")) {
try { context.putAll(DistantTerrainSettings.read()); }
catch (RuntimeException | LinkageError unavailable) { context.put("dh_capture_status", "unavailable"); }
} else context.put("dh_capture_status", "not_installed");
return context;
}
public static void frameStart() {
Recording recording = frames;
if (recording == null && terrainBudget == null) return;
long now = System.nanoTime();
if (recording != null && now >= recording.deadlineNs) { finish(); recording = null; }
Minecraft client = Minecraft.getInstance();
if (client.level == null || client.isPaused() || !client.isWindowActive() || client.gui.screen() != null) {
if (recording != null) recording.exclude();
if (terrainBudget != null && previousBudgetFrame != 0) terrainBudget.suspend();
previousBudgetFrame = 0;
previousStart = 0;
return;
}
if (terrainBudget != null) {
int cap = client.options.framerateLimit().get();
double targetMs = 1000.0 / Math.min(targetFps, Math.max(1, cap));
terrainBudget.observe(now, previousBudgetFrame == 0 ? 0 : now - previousBudgetFrame, targetMs);
previousBudgetFrame = now;
}
if (recording != null) {
if (previousStart != 0) recording.add(now - previousStart);
previousStart = now;
}
}
private static boolean enableBudget() {
if (!FabricLoader.getInstance().isModLoaded("distanthorizons")) return false;
if (terrainBudget == null) terrainBudget = new DistantTerrainBudget();
return true;
}
private static void finish() {
Recording recording = frames;
if (recording == null) return;
frames = null;
previousStart = 0;
HorizonLab.finishTicks();
Map<String, Object> end = visualSettings(Minecraft.getInstance());
java.util.List<String> changed = new java.util.ArrayList<>();
for (var setting : end.entrySet()) {
// Adaptive duty cycle is expected to change; visual quality changes are separate.
if (!setting.getKey().equals("dh_runtime_ratio")
&& !java.util.Objects.equals(recording.context.get(setting.getKey()), setting.getValue()))
changed.add(setting.getKey());
}
for (String setting : recording.context.keySet()) {
if (setting.startsWith("dh_") && !end.containsKey(setting))
changed.add(setting);
}
recording.context.put("visual_settings_end", end);
recording.context.put("visual_settings_changed", changed);
Reports.write(recording.finish()).whenComplete((path, error) -> {
if (error != null) HorizonLab.LOGGER.error("Frame report failed", error);
else {
HorizonLab.LOGGER.info("Horizon frame benchmark saved to {}", path);
Minecraft.getInstance().execute(() -> message("Benchmark saved: " + path.getFileName()));
}
});
}
private static void message(String text) {
Minecraft client = Minecraft.getInstance();
if (client.gui != null) client.gui.hud.getChat().addClientSystemMessage(Component.literal("[Horizon] " + text));
}
}
@@ -1,30 +0,0 @@
package dev.horizonlab.geometry;
import com.seibel.distanthorizons.core.pos.DhSectionPos;
/** Numeric-only conversion; preserve the pinned library's truncation and negative-coordinate bias. */
public final class RelativeTerrainPosition {
private RelativeTerrainPosition() {}
public static long resolve(long position, byte outputLevel) {
int delta = outputLevel - DhSectionPos.getDetailLevel(position);
int shift = delta & 31;
// Invalid levels keep the publisher exception; a sign-bit divisor keeps its original arithmetic.
if (delta < 0 || shift == 31) {
return DhSectionPos.getDhSectionRelativePositionForDetailLevel(position, outputLevel);
}
int divisorMask = (1 << shift) - 1;
int negativeBias = divisorMask | ((divisorMask - 1) >>> 31);
return DhSectionPos.encode(outputLevel,
coordinate(DhSectionPos.getX(position), shift, divisorMask, negativeBias),
coordinate(DhSectionPos.getZ(position), shift, divisorMask, negativeBias));
}
private static int coordinate(int value, int shift, int divisorMask, int negativeBias) {
int negative = value >> 31;
int adjusted = value + (negative & negativeBias);
// Adding the divisor mask only for a negative adjusted value gives truncation toward zero.
int truncated = (adjusted + ((adjusted >> 31) & divisorMask)) >> shift;
return (truncated + negative) & 63;
}
}
@@ -1,53 +0,0 @@
package dev.horizonlab.metrics;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.LongSupplier;
/** One background reader for this process, active only while recordings exist. */
public final class ProcessMemoryMonitor {
private static final Set<Recording> ACTIVE = ConcurrentHashMap.newKeySet();
private static final ScheduledExecutorService EXECUTOR = Executors.newSingleThreadScheduledExecutor(task -> {
Thread thread = new Thread(task, "Horizon process memory");
thread.setDaemon(true);
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
});
private static volatile LongSupplier reader;
private static ScheduledFuture<?> polling;
private ProcessMemoryMonitor() {}
public static synchronized void configure(LongSupplier residentMemoryReader) {
if (!ACTIVE.isEmpty()) throw new IllegalStateException("Configure before starting recordings");
reader = residentMemoryReader;
}
static synchronized void track(Recording recording) {
if (reader == null) return;
ACTIVE.add(recording);
if (polling == null) polling = EXECUTOR.scheduleWithFixedDelay(ProcessMemoryMonitor::poll, 0, 1, TimeUnit.SECONDS);
}
static synchronized void untrack(Recording recording) {
ACTIVE.remove(recording);
if (ACTIVE.isEmpty() && polling != null) {
polling.cancel(false);
polling = null;
}
}
private static void poll() {
Recording[] recordings = ACTIVE.toArray(Recording[]::new);
if (recordings.length == 0) return;
long bytes;
try { bytes = reader.getAsLong(); }
catch (RuntimeException | LinkageError unavailable) { return; }
if (bytes <= 0) return;
for (Recording recording : recordings) recording.residentMemory(bytes);
}
}
@@ -1,159 +0,0 @@
package dev.horizonlab.metrics;
import java.lang.management.ManagementFactory;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
/** One preallocated bounded buffer per recording, no per-sample allocations or disk I/O. */
public final class Recording {
public static final int MAX_SAMPLES = 300_000;
public final String label;
public final String kind;
public final Path output;
public final long startedNs;
public final long deadlineNs;
public final Map<String, Object> context;
private final long[] samples = new long[MAX_SAMPLES];
private int count;
private int dropped;
private int excluded;
private int peakPlayers;
private final long initialGcCount;
private final long initialGcMs;
private final java.lang.management.MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
private final com.sun.management.OperatingSystemMXBean operatingSystem =
ManagementFactory.getOperatingSystemMXBean() instanceof com.sun.management.OperatingSystemMXBean bean ? bean : null;
private final long initialCpuNs;
private long nextResourceSampleNs;
private long peakHeap;
private long peakNonHeap;
private int resourceSamples;
private long residentEnd;
private long residentPeak;
private int residentSamples;
private long residentSampleNs;
private boolean finished;
private final TickState tickState;
public Recording(String label, String kind, Path output, int seconds, Map<String, Object> context) {
if (seconds < 1 || seconds > 600) throw new IllegalArgumentException("Duration must be 1..600 seconds");
String safeLabel = label.replaceAll("[^A-Za-z0-9_-]", "_");
this.label = safeLabel.substring(0, Math.min(safeLabel.length(), 64));
this.kind = kind;
this.tickState = kind.equals("ticks") ? new TickState() : null;
this.output = output;
this.context = new LinkedHashMap<>(context);
this.startedNs = System.nanoTime();
this.deadlineNs = startedNs + seconds * 1_000_000_000L;
this.initialGcCount = gcCount();
this.initialGcMs = gcMs();
this.initialCpuNs = operatingSystem == null ? -1 : operatingSystem.getProcessCpuTime();
sampleResources(startedNs);
ProcessMemoryMonitor.track(this);
}
public synchronized void add(long ns) {
if (ns <= 0 || finished) return;
long now = System.nanoTime();
if (now >= nextResourceSampleNs) sampleResources(now);
if (count == samples.length) { dropped++; return; }
samples[count++] = ns;
}
public synchronized void exclude() { excluded++; }
public synchronized void players(int players) { peakPlayers = Math.max(players, peakPlayers); }
public synchronized void addTick(long ns, int players, float rate, boolean frozen, boolean stepping, boolean sprinting, boolean runsGameElements) {
if (ns <= 0 || finished || !kind.equals("ticks")) return;
tickState.observe(rate, frozen, stepping, sprinting, runsGameElements);
add(ns);
players(players);
}
public synchronized Snapshot finish() {
finished = true;
ProcessMemoryMonitor.untrack(this);
sampleResources(System.nanoTime());
Map<String, Object> metadata = new LinkedHashMap<>(context);
metadata.put("label", label);
metadata.put("kind", kind);
metadata.put("schema", 1);
double elapsed = (System.nanoTime() - startedNs) / 1e9;
metadata.put("elapsed_seconds", elapsed);
if (kind.equals("ticks")) {
metadata.put("observed_ticks_per_second", (count + (long) dropped) / elapsed);
metadata.putAll(tickState.metadata());
}
metadata.put("dropped_samples", dropped);
metadata.put("excluded_frames", excluded);
metadata.put("peak_players", peakPlayers);
metadata.put("java_version", System.getProperty("java.version"));
metadata.put("os", System.getProperty("os.name"));
metadata.put("os_version", System.getProperty("os.version"));
metadata.put("architecture", System.getProperty("os.arch"));
metadata.put("logical_processors", Runtime.getRuntime().availableProcessors());
metadata.put("garbage_collectors", ManagementFactory.getGarbageCollectorMXBeans().stream()
.map(java.lang.management.MemoryManagerMXBean::getName).sorted().toList());
try {
var diagnostic = ManagementFactory.getPlatformMXBean(com.sun.management.HotSpotDiagnosticMXBean.class);
if (diagnostic != null) metadata.put("compact_object_headers",
Boolean.parseBoolean(diagnostic.getVMOption("UseCompactObjectHeaders").getValue()));
} catch (RuntimeException | LinkageError unavailable) {
// Other VM implementations may not expose this option; absence is distinct from false.
}
var endHeap = memory.getHeapMemoryUsage();
peakHeap = Math.max(peakHeap, endHeap.getUsed());
metadata.put("heap_used_end_bytes", endHeap.getUsed());
metadata.put("heap_committed_end_bytes", endHeap.getCommitted());
metadata.put("heap_max_bytes", Runtime.getRuntime().maxMemory());
metadata.put("heap_used_peak_sampled_bytes", peakHeap);
metadata.put("nonheap_used_peak_sampled_bytes", peakNonHeap);
metadata.put("resource_samples", resourceSamples);
metadata.put("resource_sample_interval_ms", 1000);
if (residentSamples > 0) {
metadata.put("process_rss_last_sampled_bytes", residentEnd);
metadata.put("process_rss_peak_sampled_bytes", residentPeak);
metadata.put("process_rss_samples", residentSamples);
metadata.put("process_rss_sample_interval_ms", 1000);
metadata.put("process_rss_last_sample_age_ms", (System.nanoTime() - residentSampleNs) / 1e6);
}
long endCpuNs = operatingSystem == null ? -1 : operatingSystem.getProcessCpuTime();
if (initialCpuNs >= 0 && endCpuNs >= initialCpuNs) {
double cpuSeconds = (endCpuNs - initialCpuNs) / 1e9;
metadata.put("process_cpu_seconds", cpuSeconds);
metadata.put("process_cpu_mean_cores", cpuSeconds / elapsed);
}
metadata.put("gc_collections", gcCount() - initialGcCount);
metadata.put("gc_time_ms", gcMs() - initialGcMs);
return new Snapshot(label, kind, output, Arrays.copyOf(samples, count), metadata);
}
synchronized void residentMemory(long bytes) {
if (finished || bytes <= 0) return;
residentEnd = bytes;
residentPeak = Math.max(residentPeak, bytes);
residentSamples++;
residentSampleNs = System.nanoTime();
}
private void sampleResources(long now) {
peakHeap = Math.max(peakHeap, memory.getHeapMemoryUsage().getUsed());
peakNonHeap = Math.max(peakNonHeap, memory.getNonHeapMemoryUsage().getUsed());
resourceSamples++;
nextResourceSampleNs = now + 1_000_000_000L;
}
private static long gcCount() {
return ManagementFactory.getGarbageCollectorMXBeans().stream()
.mapToLong(gc -> Math.max(0, gc.getCollectionCount())).sum();
}
private static long gcMs() {
return ManagementFactory.getGarbageCollectorMXBeans().stream()
.mapToLong(gc -> Math.max(0, gc.getCollectionTime())).sum();
}
public record Snapshot(String label, String kind, Path output, long[] samples, Map<String, Object> metadata) {}
}
@@ -1,43 +0,0 @@
package dev.horizonlab.metrics;
import com.google.gson.GsonBuilder;
import java.io.BufferedWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
public final class Reports {
private Reports() {}
public static CompletableFuture<Path> write(Recording.Snapshot snapshot) {
return CompletableFuture.supplyAsync(() -> {
try {
Files.createDirectories(snapshot.output());
String name = snapshot.label() + "-" + snapshot.kind() + "-" + Instant.now().toEpochMilli();
Path csv = snapshot.output().resolve(name + ".csv");
try (BufferedWriter writer = Files.newBufferedWriter(csv, StandardCharsets.UTF_8)) {
writer.write("sample,duration_ns\n");
for (int i = 0; i < snapshot.samples().length; i++) {
writer.write(i + "," + snapshot.samples()[i] + "\n");
}
}
SampleStats stats = SampleStats.of(snapshot.samples(), snapshot.samples().length);
Map<String, Object> report = new LinkedHashMap<>(snapshot.metadata());
report.put("stats", stats);
report.put("samples_file", csv.getFileName().toString());
report.put("note", snapshot.kind().equals("frames")
? "Frame start intervals include frame limiting and presentation waits; menus, pauses and unfocused frames are excluded. Not GPU timings. Process RSS, when available, is sampled separately."
: "Tick execution durations exclude scheduler sleep. meanRate is execution capacity, not observed TPS.");
Path json = snapshot.output().resolve(name + ".json");
Files.writeString(json, new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n");
return json;
} catch (Exception exception) {
throw new IllegalStateException("Could not write Horizon benchmark", exception);
}
});
}
}
@@ -1,20 +0,0 @@
package dev.horizonlab.metrics;
import java.util.function.LongSupplier;
import oshi.SystemInfo;
import oshi.software.os.OSProcess;
/** Uses Minecraft's existing OSHI dependency; initialization stays on the sampler thread. */
public final class ResidentMemory implements LongSupplier {
private OSProcess process;
private boolean initialized;
@Override public long getAsLong() {
if (!initialized) {
initialized = true;
var os = new SystemInfo().getOperatingSystem();
process = os.getProcess(os.getProcessId());
}
return process != null && process.updateAttributes() ? process.getResidentSetSize() : -1;
}
}
@@ -1,36 +0,0 @@
package dev.horizonlab.metrics;
import java.util.Arrays;
/** Original bounded measurement utilities; no Minecraft dependency. */
public record SampleStats(int count, double meanMs, double p50Ms, double p95Ms,
double p99Ms, double maxMs, double meanRate, double lowOnePercentRate,
int over16Ms, int over33Ms, int over50Ms, int over100Ms) {
public static SampleStats of(long[] samples, int count) {
if (count < 0 || count > samples.length) throw new IllegalArgumentException("Invalid sample count");
if (count == 0) return new SampleStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
long[] sorted = Arrays.copyOf(samples, count);
double sum = 0;
int over16 = 0, over33 = 0, over50 = 0, over100 = 0;
for (long ns : sorted) {
if (ns <= 0) throw new IllegalArgumentException("Sample duration must be positive");
sum += ns;
if (ns > 16_666_667L) over16++;
if (ns > 33_333_333L) over33++;
if (ns > 50_000_000L) over50++;
if (ns > 100_000_000L) over100++;
}
Arrays.sort(sorted);
int tail = Math.max(1, (int) Math.ceil(count * 0.01));
double tailSum = 0;
for (int i = count - tail; i < count; i++) tailSum += sorted[i];
return new SampleStats(count, sum / count / 1e6, quantile(sorted, 0.50) / 1e6,
quantile(sorted, 0.95) / 1e6, quantile(sorted, 0.99) / 1e6,
sorted[count - 1] / 1e6, 1e9 * count / sum, 1e9 * tail / tailSum,
over16, over33, over50, over100);
}
private static long quantile(long[] sorted, double fraction) {
return sorted[Math.max(0, (int) Math.ceil(sorted.length * fraction) - 1)];
}
}
@@ -1,27 +0,0 @@
package dev.horizonlab.metrics;
import java.util.LinkedHashMap;
import java.util.Map;
/** Copies a recording-start snapshot; never accesses a graphics device on a server thread. */
public final class ServerContext {
private ServerContext() {}
public static Map<String, Object> capture(String runtimeKind, Map<String, ?> clientContext) {
Map<String, Object> result = new LinkedHashMap<>();
String kind = switch (runtimeKind) {
case "integrated", "dedicated" -> runtimeKind;
default -> "unknown";
};
result.put("server_runtime_kind", kind);
boolean captured = kind.equals("integrated") && clientContext != null;
for (String key : new String[]{"gpu", "graphics_backend", "graphics_driver"}) {
Object value = clientContext == null ? null : clientContext.get(key);
if (!(value instanceof String text) || text.isBlank()) captured = false;
}
result.put("client_graphics_capture_status", captured ? "captured_at_recording_start" : "not_supplied");
if (captured) for (String key : new String[]{"gpu", "graphics_backend", "graphics_driver"})
result.put(key, clientContext.get(key));
return Map.copyOf(result);
}
}
@@ -1,21 +0,0 @@
package dev.horizonlab.metrics;
import java.util.LinkedHashMap;
import java.util.Map;
import net.fabricmc.loader.api.FabricLoader;
/** Software identity captured once before a client or server recording starts. */
public final class SoftwareContext {
private SoftwareContext() {}
public static Map<String, Object> capture() {
Map<String, Object> context = new LinkedHashMap<>();
context.put("java_vendor", System.getProperty("java.vendor"));
context.put("java_vm_name", System.getProperty("java.vm.name"));
context.put("java_runtime_version", System.getProperty("java.runtime.version"));
context.put("mods", FabricLoader.getInstance().getAllMods().stream()
.map(mod -> mod.getMetadata().getId() + "@" + mod.getMetadata().getVersion().getFriendlyString())
.sorted().toList());
return context;
}
}
@@ -1,76 +0,0 @@
package dev.horizonlab.metrics;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map;
/** Scalar-only observations of the tick manager after each measured server invocation. */
public final class TickState {
private static final int FROZEN = 1;
private static final int STEPPING = 2;
private static final int SPRINTING = 4;
private static final int RUNS_GAME_ELEMENTS = 8;
private long count;
private float firstRate;
private float lastRate;
private int firstFlags;
private int lastFlags;
private int changedFlags;
private boolean rateChanged;
private long frozen;
private long stepping;
private long sprinting;
private long suppressed;
private long nonstandardRate;
private long invalid;
public void observe(float rate, boolean isFrozen, boolean isStepping, boolean isSprinting, boolean runsGameElements) {
if (!Float.isFinite(rate) || rate < 1) { invalid++; return; }
int flags = (isFrozen ? FROZEN : 0) | (isStepping ? STEPPING : 0)
| (isSprinting ? SPRINTING : 0) | (runsGameElements ? RUNS_GAME_ELEMENTS : 0);
if (count == 0) {
firstRate = rate;
firstFlags = flags;
} else {
rateChanged |= rate != lastRate;
changedFlags |= flags ^ lastFlags;
}
lastRate = rate;
lastFlags = flags;
count++;
if (isFrozen) frozen++;
if (isStepping) stepping++;
if (isSprinting) sprinting++;
if (!runsGameElements) suppressed++;
if (rate != 20) nonstandardRate++;
}
public Map<String, Object> metadata() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("tick_state_capture_status", invalid > 0 ? "invalid_samples" : count == 0 ? "unavailable" : "sampled_server_ticks");
result.put("tick_state_samples", count);
result.put("tick_state_invalid_samples", invalid);
if (count == 0) return result;
result.put("tick_state_first", state(firstRate, firstFlags));
result.put("tick_state_last", state(lastRate, lastFlags));
var changed = new ArrayList<String>();
if (rateChanged) changed.add("configured_tick_rate");
if ((changedFlags & FROZEN) != 0) changed.add("frozen");
if ((changedFlags & STEPPING) != 0) changed.add("stepping");
if ((changedFlags & SPRINTING) != 0) changed.add("sprinting");
if ((changedFlags & RUNS_GAME_ELEMENTS) != 0) changed.add("runs_game_elements");
result.put("tick_state_changed", changed);
result.put("tick_state_frozen_samples", frozen);
result.put("tick_state_stepping_samples", stepping);
result.put("tick_state_sprinting_samples", sprinting);
result.put("tick_state_suppressed_samples", suppressed);
result.put("tick_state_nonstandard_rate_samples", nonstandardRate);
return result;
}
private static Map<String, Object> state(float rate, int flags) {
return Map.of("configured_tick_rate", rate, "frozen", (flags & FROZEN) != 0,
"stepping", (flags & STEPPING) != 0, "sprinting", (flags & SPRINTING) != 0,
"runs_game_elements", (flags & RUNS_GAME_ELEMENTS) != 0);
}
}
@@ -1,19 +0,0 @@
package dev.horizonlab.mixin;
import java.util.Collections;
import java.util.Iterator;
import java.util.Set;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
/** Empty dynamic whitelists have no handlers to visit; nonempty sets retain their own iterator. */
@Mixin(targets = "dev.tr7zw.entityculling.EntityCullingModBase", remap = false)
public abstract class CullingEmptyWhitelistMixin {
@Redirect(method = {"isEntityDynamicWhitelisted", "isBlockEntityDynamicWhitelisted"},
at = @At(value = "INVOKE", target = "Ljava/util/Set;iterator()Ljava/util/Iterator;"))
private Iterator<?> horizon$iterateDynamicHandlers(Set<?> handlers) {
// Check the live set on every call. Registration, order, callbacks and nonempty iteration stay upstream.
return handlers.isEmpty() ? Collections.emptyIterator() : handlers.iterator();
}
}
@@ -1,28 +0,0 @@
package dev.horizonlab.mixin;
import com.logisticscraft.occlusionculling.util.Vec3d;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
/** Keep temporary numeric vectors on the already mutable, single-caller culling instance. */
@Mixin(targets = "com.logisticscraft.occlusionculling.OcclusionCullingInstance", remap = false)
public abstract class CullingRayScratchMixin {
@Unique private final Vec3d horizon$rayDirection = new Vec3d(0, 0, 0);
@Unique private final Vec3d horizon$inverseDirection = new Vec3d(0, 0, 0);
@Redirect(method = "isVisible", at = @At(value = "NEW",
target = "(DDD)Lcom/logisticscraft/occlusionculling/util/Vec3d;"))
private Vec3d horizon$direction(double x, double y, double z) {
horizon$rayDirection.set(x, y, z);
return horizon$rayDirection;
}
@Redirect(method = "rayIntersection", at = @At(value = "NEW",
target = "(DDD)Lcom/logisticscraft/occlusionculling/util/Vec3d;"))
private Vec3d horizon$inverse(double x, double y, double z) {
horizon$inverseDirection.set(x, y, z);
return horizon$inverseDirection;
}
}
@@ -1,77 +0,0 @@
package dev.horizonlab.mixin;
import com.seibel.distanthorizons.core.api.internal.chunkUpdating.ChunkPosQueue;
import com.seibel.distanthorizons.core.api.internal.chunkUpdating.ChunkUpdateData;
import com.seibel.distanthorizons.core.api.internal.chunkUpdating.ChunkUpdateQueueManager;
import com.seibel.distanthorizons.core.pos.DhChunkPos;
import dev.horizonlab.budget.DebounceWait;
import dev.horizonlab.budget.WakeableIdleWait;
import java.util.concurrent.locks.LockSupport;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Pseudo
@Mixin(targets = "com.seibel.distanthorizons.core.api.internal.chunkUpdating.ChunkUpdateQueueManager", remap = false)
public abstract class DhDebounceWaitMixin {
@Shadow @Final public ChunkPosQueue preUpdateQueue;
@Unique private volatile Thread horizonQueueWorker;
@Unique private long horizonLastIdleCleanup;
@Inject(method = "runQueueingLoop", at = @At("HEAD"), remap = false)
private void horizonRememberWorker(CallbackInfo callback) {
horizonQueueWorker = Thread.currentThread();
horizonLastIdleCleanup = System.nanoTime();
}
@Inject(method = "runQueueingLoop", at = @At("RETURN"), remap = false)
private void horizonReleaseWorker(CallbackInfo callback) {
horizonQueueWorker = null;
}
@Redirect(method = "runQueueingLoop", at = @At(value = "INVOKE",
target = "Ljava/lang/Thread;sleep(J)V"), require = 1, allow = 1, remap = false)
private void horizonWakeableIdle(long milliseconds) throws InterruptedException {
if (milliseconds != 50 || Thread.currentThread() != horizonQueueWorker) {
Thread.sleep(milliseconds);
return;
}
long remaining = 50_000_000L - (System.nanoTime() - horizonLastIdleCleanup);
WakeableIdleWait.pause(this, Math.max(0, remaining));
}
// Early wakeups must not turn the former 50 ms maintenance cadence into per-arrival work.
@Redirect(method = "runQueueingLoop", at = @At(value = "INVOKE",
target = "Lcom/google/common/cache/Cache;cleanUp()V"), require = 1, allow = 1, remap = false)
private void horizonBoundIdleMaintenance(com.google.common.cache.Cache<?, ?> cache) {
long now = System.nanoTime();
if (Thread.currentThread() != horizonQueueWorker || now - horizonLastIdleCleanup >= 50_000_000L) {
cache.cleanUp();
horizonLastIdleCleanup = System.nanoTime();
}
}
// Ordinal zero is specifically the debounce retry. Executor rejection retries remain unchanged.
@Redirect(method = "tryDispatchReadyChunkUpdate", at = @At(value = "INVOKE",
target = "Lcom/seibel/distanthorizons/core/api/internal/chunkUpdating/ChunkPosQueue;addItem(Lcom/seibel/distanthorizons/core/pos/DhChunkPos;Lcom/seibel/distanthorizons/core/api/internal/chunkUpdating/ChunkUpdateData;)V",
ordinal = 0), require = 1, allow = 1, remap = false)
private void horizonWaitForDebounce(ChunkPosQueue queue, DhChunkPos position, ChunkUpdateData update) {
queue.addItem(position, update);
if (Thread.currentThread() != horizonQueueWorker) return;
long delay = DebounceWait.delay(System.nanoTime(), update.firstQueuedNanoTime,
ChunkUpdateQueueManager.UPDATE_DEBOUNCE_NANOS, !preUpdateQueue.isEmpty());
if (delay > 0) LockSupport.parkNanos(this, delay);
}
@Inject(method = "addItemToQueue", at = @At("RETURN"), remap = false)
private void horizonWakeForNewWork(CallbackInfo callback) {
Thread worker = horizonQueueWorker;
if (worker != null && worker != Thread.currentThread()) LockSupport.unpark(worker);
}
}
@@ -1,76 +0,0 @@
package dev.horizonlab.mixin;
import com.seibel.distanthorizons.core.util.threading.PriorityTaskPicker;
import dev.horizonlab.budget.DispatchRetry;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Constant;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.ModifyConstant;
import org.spongepowered.asm.mixin.injection.ModifyVariable;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Pseudo
@Mixin(targets = "com.seibel.distanthorizons.core.util.threading.PriorityTaskPicker", remap = false)
public abstract class DhPriorityDispatchMixin {
@Shadow @Final private AtomicInteger occupiedThreadsRef;
@Shadow @Final private AtomicBoolean isShutDownRef;
@Unique private final DispatchRetry horizonDispatch = new DispatchRetry();
// This site is after the original lock acquisition, including the failed-tryLock early return.
@Inject(method = "startNextTask", at = @At(value = "INVOKE",
target = "Lcom/seibel/distanthorizons/core/util/threading/PriorityTaskPicker;getExecutorIteratorSortedByShortestTotalRunTime()Ljava/util/Iterator;"),
require = 1, allow = 1, remap = false)
private void horizonBeginDispatchPass(boolean blocking, CallbackInfo callback) {
horizonDispatch.beginPass();
}
@Redirect(method = "startNextTask", at = @At(value = "INVOKE",
target = "Lcom/seibel/distanthorizons/core/util/threading/PriorityTaskPicker$Executor;canRun()Z"),
require = 1, allow = 1, remap = false)
private boolean horizonBeginExecutor(PriorityTaskPicker.Executor executor) {
horizonDispatch.beginExecutor();
return executor.canRun();
}
@Redirect(method = "startNextTask", at = @At(value = "INVOKE",
target = "Lcom/seibel/distanthorizons/core/util/threading/PriorityTaskPicker$Executor;runTask(Ljava/lang/Runnable;)V"),
require = 1, allow = 1, remap = false)
private void horizonMarkRejected(PriorityTaskPicker.Executor executor, Runnable task) {
try {
executor.runTask(task);
} catch (RejectedExecutionException rejected) {
horizonDispatch.rejected();
throw rejected; // Original catch requeues the same task or clears it during shutdown.
}
}
@Redirect(method = "startNextTask", at = @At(value = "INVOKE",
target = "Ljava/util/concurrent/atomic/AtomicInteger;get()I"), require = 1, allow = 1, remap = false)
private int horizonLeaveRejectedExecutor(AtomicInteger counter) {
int occupied = counter.get();
return counter == occupiedThreadsRef
? horizonDispatch.visibleSlots(occupied) : occupied;
}
// Inspected DH 3.3.4 local slot 2 is the existing "executor paused" retry flag.
@ModifyVariable(method = "startNextTask", at = @At("LOAD"), index = 2,
require = 1, allow = 1, remap = false)
private boolean horizonRetryRejectedPass(boolean original) {
return horizonDispatch.needsRetry(original, isShutDownRef.get());
}
@ModifyConstant(method = "startNextTask", constant = @Constant(longValue = 1000L),
require = 1, allow = 1, remap = false)
private long horizonRetryDelay(long original) {
return horizonDispatch.retryMilliseconds(original);
}
}
@@ -1,31 +0,0 @@
package dev.horizonlab.mixin;
import com.seibel.distanthorizons.core.dataObjects.render.bufferBuilding.BufferMergeDirectionEnum;
import com.seibel.distanthorizons.core.dataObjects.render.bufferBuilding.BufferQuad;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
/** Reuse one discarded successful-premerge quad only when the existing pool needs a new object. */
@Mixin(targets = "com.seibel.distanthorizons.core.dataObjects.render.bufferBuilding.LodQuadBuilder", remap = false)
public abstract class DhQuadReuseMixin {
@Unique private BufferQuad horizon$discardedQuad;
@Redirect(method = "getOrCreateBufferQuad", at = @At(value = "NEW",
target = "com/seibel/distanthorizons/core/dataObjects/render/bufferBuilding/BufferQuad"), require = 2, allow = 2)
private BufferQuad horizon$freshQuad() {
BufferQuad available = horizon$discardedQuad;
horizon$discardedQuad = null;
return available == null ? new BufferQuad() : available;
}
@Redirect(method = "addQuadAdj", at = @At(value = "INVOKE",
target = "Lcom/seibel/distanthorizons/core/dataObjects/render/bufferBuilding/BufferQuad;tryMerge(Lcom/seibel/distanthorizons/core/dataObjects/render/bufferBuilding/BufferQuad;Lcom/seibel/distanthorizons/core/dataObjects/render/bufferBuilding/BufferMergeDirectionEnum;)Z"),
require = 2, allow = 2)
private boolean horizon$merge(BufferQuad receiver, BufferQuad incoming, BufferMergeDirectionEnum direction) {
boolean merged = receiver.tryMerge(incoming, direction);
if (merged && horizon$discardedQuad == null) horizon$discardedQuad = incoming;
return merged;
}
}
@@ -1,16 +0,0 @@
package dev.horizonlab.mixin;
import dev.horizonlab.geometry.RelativeTerrainPosition;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
/** Replace only the numeric conversion in the sampled full-data lookup. */
@Mixin(targets = "com.seibel.distanthorizons.core.dataObjects.fullData.sources.FullDataSourceV2", remap = false)
public abstract class DhRelativePositionMixin {
@Redirect(method = "getDataPointAtBlockPos", at = @At(value = "INVOKE",
target = "Lcom/seibel/distanthorizons/core/pos/DhSectionPos;getDhSectionRelativePositionForDetailLevel(JB)J"))
private long horizon$relativePosition(long position, byte outputLevel) {
return RelativeTerrainPosition.resolve(position, outputLevel);
}
}
@@ -1,18 +0,0 @@
package dev.horizonlab.mixin;
import dev.horizonlab.budget.TerrainReadWait;
import java.util.concurrent.CompletableFuture;
import net.minecraft.world.level.chunk.storage.IOWorker;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
/** Only blending metadata reads on pinned DH workers become interruptible; saves stay upstream. */
@Mixin(IOWorker.class)
public abstract class DhTerrainReadWaitMixin {
@Redirect(method = "isOldChunkAround", at = @At(value = "INVOKE",
target = "Ljava/util/concurrent/CompletableFuture;join()Ljava/lang/Object;"))
private Object horizon$waitForBlendingMetadata(CompletableFuture<?> future) {
return TerrainReadWait.join(future);
}
}
@@ -1,30 +0,0 @@
package dev.horizonlab.mixin;
import com.seibel.distanthorizons.core.render.QuadTree.QuadTreeTickNodeHolder;
import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet;
import java.util.HashSet;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/** Replace only the holder's private membership test; published action sets keep their order. */
@Mixin(value = QuadTreeTickNodeHolder.class, remap = false)
public abstract class DhTickDedupMixin {
@Unique private final ObjectOpenHashSet<Object> horizon$presentNodes = new ObjectOpenHashSet<>();
@Redirect(method = {"addEnableNode", "addDisableNode", "addEnableDeleteChildrenNode", "removeEnableAndDisableNode"},
at = @At(value = "INVOKE", target = "Ljava/util/HashSet;add(Ljava/lang/Object;)Z", ordinal = 0))
private boolean horizon$recordPresentNode(HashSet<?> original, Object node) {
// In pinned DH 3.3.4 each method's first add targets presentNodes.
// The original branches and action collections remain responsible for every transition.
return horizon$presentNodes.add(node);
}
@Inject(method = "clear", at = @At("HEAD"))
private void horizon$clearPresentNodes(CallbackInfo callback) {
horizon$presentNodes.clear(); // Release references at the same boundary; retain reusable table capacity.
}
}
@@ -1,42 +0,0 @@
package dev.horizonlab.mixin;
import com.seibel.distanthorizons.core.config.types.ConfigEntry;
import dev.horizonlab.budget.WorkerPacer;
import java.util.concurrent.TimeUnit;
import org.spongepowered.asm.mixin.Final;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Pseudo;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Pseudo
@Mixin(targets = "com.seibel.distanthorizons.core.util.threading.RateLimitedThreadPoolExecutor", remap = false)
public abstract class DhWorkerPacingMixin {
@Shadow @Final public ConfigEntry<Double> runTimeRatioConfig;
@Unique private final ThreadLocal<WorkerPacer> horizonPacing = ThreadLocal.withInitial(WorkerPacer::new);
// The inspected superclass's hooks are no-ops. Neither hook changes the runnable or task queue.
@Inject(method = "beforeExecute", at = @At("HEAD"), cancellable = true, remap = false)
private void horizonBeforeTask(Thread thread, Runnable task, CallbackInfo callback) {
horizonPacing.get().begin(System.nanoTime());
callback.cancel();
}
@Inject(method = "afterExecute", at = @At("HEAD"), cancellable = true, remap = false)
private void horizonAfterTask(Runnable task, Throwable error, CallbackInfo callback) {
WorkerPacer pacing = horizonPacing.get();
long delay = pacing.finish(System.nanoTime(), runTimeRatioConfig.get());
if (delay > 0) {
try {
TimeUnit.NANOSECONDS.sleep(delay);
pacing.pauseCompleted(System.nanoTime());
} catch (InterruptedException interrupted) {
pacing.interrupted(System.nanoTime());
}
}
callback.cancel();
}
}
@@ -1,16 +0,0 @@
package dev.horizonlab.mixin;
import dev.horizonlab.client.HorizonClient;
import net.minecraft.client.Minecraft;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(Minecraft.class)
public abstract class FrameMixin {
@Inject(method = "runTick", at = @At("HEAD"))
private void horizonFrame(boolean advanceGameTime, CallbackInfo callback) {
HorizonClient.frameStart();
}
}
@@ -1,193 +0,0 @@
package dev.horizonlab.mixin;
import java.nio.file.Files;
import java.util.List;
import java.util.Properties;
import java.util.Set;
import net.fabricmc.loader.api.FabricLoader;
import org.objectweb.asm.tree.ClassNode;
import org.slf4j.LoggerFactory;
import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin;
import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
/** Internal hooks are opt-in and limited to the inspected, pinned DH version. */
public final class HorizonMixinPlugin implements IMixinConfigPlugin {
private static boolean quadReuse;
private static boolean terrainPosition;
private static boolean workerPacing;
private static boolean debounceWait;
private static boolean dispatchRetry;
private static boolean tickDedup;
private static boolean cullingEmpty;
private static boolean terrainReadWait;
private static boolean cullingRayScratch;
@Override public void onLoad(String mixinPackage) {
var loader = FabricLoader.getInstance();
var file = loader.getConfigDir().resolve("horizon-worker-pacing.properties");
try {
Properties properties = new Properties();
if (Files.exists(file)) {
try (var reader = Files.newBufferedReader(file)) { properties.load(reader); }
}
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
boolean supported = loader.getModContainer("distanthorizons")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("3.3.4")).orElse(false);
workerPacing = requested && supported;
if (requested) LoggerFactory.getLogger("horizonlab").info(
"Precise DH worker pacing: {} (requires pinned DH 3.3.4)", workerPacing ? "enabled" : "unavailable");
} catch (Exception error) {
LoggerFactory.getLogger("horizonlab").warn("Worker pacing disabled: invalid configuration", error);
}
try {
Properties properties = new Properties();
var queueFile = loader.getConfigDir().resolve("horizon-queue-wait.properties");
if (Files.exists(queueFile)) {
try (var reader = Files.newBufferedReader(queueFile)) { properties.load(reader); }
}
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
boolean supported = loader.getModContainer("distanthorizons")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("3.3.4")).orElse(false);
debounceWait = requested && supported;
if (requested) LoggerFactory.getLogger("horizonlab").info(
"DH debounce queue wait: {} (requires pinned DH 3.3.4)", debounceWait ? "enabled" : "unavailable");
} catch (Exception error) {
LoggerFactory.getLogger("horizonlab").warn("Debounce queue wait disabled: invalid configuration", error);
}
try {
Properties properties = new Properties();
var dispatchFile = loader.getConfigDir().resolve("horizon-dispatch-retry.properties");
if (Files.exists(dispatchFile)) {
try (var reader = Files.newBufferedReader(dispatchFile)) { properties.load(reader); }
}
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
boolean supported = loader.getModContainer("distanthorizons")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("3.3.4")).orElse(false);
dispatchRetry = requested && supported;
if (requested) LoggerFactory.getLogger("horizonlab").info(
"DH rejected dispatch retry: {} (requires pinned DH 3.3.4)", dispatchRetry ? "enabled" : "unavailable");
} catch (Exception error) {
LoggerFactory.getLogger("horizonlab").warn("Dispatch retry disabled: invalid configuration", error);
}
try {
Properties properties = new Properties();
var dedupFile = loader.getConfigDir().resolve("horizon-tick-dedup.properties");
if (Files.exists(dedupFile)) {
try (var reader = Files.newBufferedReader(dedupFile)) { properties.load(reader); }
}
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
boolean supported = loader.getModContainer("distanthorizons")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("3.3.4")).orElse(false);
tickDedup = requested && supported;
if (requested) LoggerFactory.getLogger("horizonlab").info(
"DH tick membership deduplication: {} (requires pinned DH 3.3.4)", tickDedup ? "enabled" : "unavailable");
} catch (Exception error) {
LoggerFactory.getLogger("horizonlab").warn("Tick membership deduplication disabled: invalid configuration", error);
}
try {
Properties properties = new Properties();
var cullingFile = loader.getConfigDir().resolve("horizon-culling-empty.properties");
if (Files.exists(cullingFile)) {
try (var reader = Files.newBufferedReader(cullingFile)) { properties.load(reader); }
}
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
boolean supported = loader.getModContainer("entityculling")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("1.11.2")).orElse(false);
cullingEmpty = requested && supported;
if (requested) LoggerFactory.getLogger("horizonlab").info(
"Empty culling whitelist iteration: {} (requires pinned Entity Culling 1.11.2)", cullingEmpty ? "enabled" : "unavailable");
} catch (Exception error) {
LoggerFactory.getLogger("horizonlab").warn("Empty culling whitelist iteration disabled: invalid configuration", error);
}
try {
Properties properties = new Properties();
var readFile = loader.getConfigDir().resolve("horizon-terrain-read-wait.properties");
if (Files.exists(readFile)) {
try (var reader = Files.newBufferedReader(readFile)) { properties.load(reader); }
}
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
boolean supported = loader.getModContainer("distanthorizons")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("3.3.4")).orElse(false)
&& loader.getModContainer("minecraft")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("26.3")).orElse(false);
terrainReadWait = requested && supported;
if (requested) LoggerFactory.getLogger("horizonlab").info(
"Interruptible DH blending metadata reads: {} (requires Minecraft 26.3 and DH 3.3.4)",
terrainReadWait ? "enabled" : "unavailable");
} catch (Exception error) {
LoggerFactory.getLogger("horizonlab").warn("Interruptible terrain read disabled: invalid configuration", error);
}
try {
Properties properties = new Properties();
var rayFile = loader.getConfigDir().resolve("horizon-culling-rays.properties");
if (Files.exists(rayFile)) {
try (var reader = Files.newBufferedReader(rayFile)) { properties.load(reader); }
}
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
boolean supported = loader.getModContainer("entityculling")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("1.11.2")).orElse(false);
cullingRayScratch = requested && supported;
if (requested) LoggerFactory.getLogger("horizonlab").info(
"Culling ray scratch vectors: {} (requires pinned Entity Culling 1.11.2)", cullingRayScratch ? "enabled" : "unavailable");
} catch (Exception error) {
LoggerFactory.getLogger("horizonlab").warn("Culling ray scratch vectors disabled: invalid configuration", error);
}
try {
Properties properties = new Properties();
var positionFile = loader.getConfigDir().resolve("horizon-terrain-position.properties");
if (Files.exists(positionFile)) {
try (var reader = Files.newBufferedReader(positionFile)) { properties.load(reader); }
}
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
boolean supported = loader.getModContainer("distanthorizons")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("3.3.4")).orElse(false);
terrainPosition = requested && supported;
if (requested) LoggerFactory.getLogger("horizonlab").info(
"Relative terrain position arithmetic: {} (requires pinned DH 3.3.4)", terrainPosition ? "enabled" : "unavailable");
} catch (Exception error) {
LoggerFactory.getLogger("horizonlab").warn("Terrain position arithmetic disabled: invalid configuration", error);
}
try {
Properties properties = new Properties();
var quadFile = loader.getConfigDir().resolve("horizon-quad-reuse.properties");
if (Files.exists(quadFile)) {
try (var reader = Files.newBufferedReader(quadFile)) { properties.load(reader); }
}
boolean requested = Boolean.parseBoolean(properties.getProperty("enabled", "false"));
boolean supported = loader.getModContainer("distanthorizons")
.map(mod -> mod.getMetadata().getVersion().getFriendlyString().equals("3.3.4")).orElse(false);
quadReuse = requested && supported;
if (requested) LoggerFactory.getLogger("horizonlab").info(
"Discarded terrain quad reuse: {} (requires pinned DH 3.3.4)", quadReuse ? "enabled" : "unavailable");
} catch (Exception error) {
LoggerFactory.getLogger("horizonlab").warn("Terrain quad reuse disabled: invalid configuration", error);
}
}
public static boolean quadReuseEnabled() { return quadReuse; }
public static boolean terrainPositionEnabled() { return terrainPosition; }
public static boolean workerPacingEnabled() { return workerPacing; }
public static boolean debounceWaitEnabled() { return debounceWait; }
public static boolean dispatchRetryEnabled() { return dispatchRetry; }
public static boolean tickDedupEnabled() { return tickDedup; }
public static boolean cullingEmptyEnabled() { return cullingEmpty; }
public static boolean cullingRayScratchEnabled() { return cullingRayScratch; }
public static boolean terrainReadWaitEnabled() { return terrainReadWait; }
@Override public boolean shouldApplyMixin(String targetClassName, String mixinClassName) {
if (mixinClassName.endsWith("DhWorkerPacingMixin")) return workerPacing;
if (mixinClassName.endsWith("DhDebounceWaitMixin")) return debounceWait;
if (mixinClassName.endsWith("DhPriorityDispatchMixin")) return dispatchRetry;
if (mixinClassName.endsWith("DhTickDedupMixin")) return tickDedup;
if (mixinClassName.endsWith("CullingEmptyWhitelistMixin")) return cullingEmpty;
if (mixinClassName.endsWith("CullingRayScratchMixin")) return cullingRayScratch;
if (mixinClassName.endsWith("DhTerrainReadWaitMixin")) return terrainReadWait;
if (mixinClassName.endsWith("DhRelativePositionMixin")) return terrainPosition;
if (mixinClassName.endsWith("DhQuadReuseMixin")) return quadReuse;
return true;
}
@Override public String getRefMapperConfig() { return null; }
@Override public List<String> getMixins() { return null; }
@Override public void acceptTargets(Set<String> mine, Set<String> others) {}
@Override public void preApply(String name, ClassNode node, String mixin, IMixinInfo info) {}
@Override public void postApply(String name, ClassNode node, String mixin, IMixinInfo info) {}
}
@@ -1,25 +0,0 @@
package dev.horizonlab.mixin;
import dev.horizonlab.HorizonLab;
import java.util.function.BooleanSupplier;
import net.minecraft.server.MinecraftServer;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(MinecraftServer.class)
public abstract class ServerTickMixin {
@Unique private long horizonTickStart;
@Inject(method = "tickServer", at = @At("HEAD"))
private void horizonBegin(BooleanSupplier haveTime, CallbackInfo callback) {
horizonTickStart = System.nanoTime();
}
@Inject(method = "tickServer", at = @At("RETURN"))
private void horizonEnd(BooleanSupplier haveTime, CallbackInfo callback) {
HorizonLab.tick(System.nanoTime() - horizonTickStart, (MinecraftServer) (Object) this);
}
}
@@ -1,3 +0,0 @@
{
"key.horizonlab.benchmark": "Start / stop performance recording (120 seconds)"
}
@@ -1,29 +0,0 @@
{
"schemaVersion": 1,
"id": "horizonlab",
"version": "0.21.0",
"name": "Horizon",
"description": "Client frame pacing and server tick measurements for reproducible optimization experiments.",
"authors": [
"Ludovic"
],
"license": "MIT",
"environment": "*",
"entrypoints": {
"main": [
"dev.horizonlab.HorizonLab"
],
"client": [
"dev.horizonlab.client.HorizonClient"
]
},
"mixins": [
"horizonlab.mixins.json"
],
"depends": {
"fabricloader": ">=0.19.5",
"fabric-api": "*",
"minecraft": "26.3",
"java": ">=25"
}
}
@@ -1,24 +0,0 @@
{
"required": true,
"package": "dev.horizonlab.mixin",
"plugin": "dev.horizonlab.mixin.HorizonMixinPlugin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"ServerTickMixin"
],
"client": [
"FrameMixin",
"DhWorkerPacingMixin",
"DhDebounceWaitMixin",
"DhPriorityDispatchMixin",
"DhTickDedupMixin",
"CullingEmptyWhitelistMixin",
"DhTerrainReadWaitMixin",
"CullingRayScratchMixin",
"DhRelativePositionMixin",
"DhQuadReuseMixin"
],
"injectors": {
"defaultRequire": 1
}
}
@@ -1,50 +0,0 @@
package dev.horizonlab.budget;
import com.seibel.distanthorizons.core.api.internal.chunkUpdating.ChunkPosQueue;
import com.seibel.distanthorizons.core.api.internal.chunkUpdating.ChunkUpdateData;
import com.seibel.distanthorizons.core.pos.DhChunkPos;
import java.lang.management.ManagementFactory;
import java.util.concurrent.locks.LockSupport;
/** Measures the actual upstream queue during a synthetic pending-debounce interval, not game FPS. */
public final class DebounceQueueBenchmark {
private static void run(boolean waiting, int size, boolean report) {
ChunkPosQueue queue = new ChunkPosQueue();
DhChunkPos closest = new DhChunkPos(0, 0);
ChunkUpdateData closestData = null;
for (int i = 0; i < size; i++) {
ChunkUpdateData update = new ChunkUpdateData(null, null);
if (i == 0) closestData = update;
queue.addItem(new DhChunkPos(i, 0), update);
}
var threads = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
long id = Thread.currentThread().threadId();
long allocated = threads.getThreadAllocatedBytes(id), cpu = threads.getCurrentThreadCpuTime();
long start = System.nanoTime(), debounce = 250_000_000L, loops = 0;
while (System.nanoTime() - start < debounce) {
ChunkUpdateData update = queue.popClosest();
if (update != closestData) throw new AssertionError("Queue priority or data changed");
queue.addItem(closest, update);
loops++;
if (waiting) {
long delay = DebounceWait.delay(System.nanoTime(), start, debounce, false);
if (delay > 0) LockSupport.parkNanos(delay);
}
}
long elapsed = System.nanoTime() - start;
cpu = threads.getCurrentThreadCpuTime() - cpu;
allocated = threads.getThreadAllocatedBytes(id) - allocated;
if (queue.getQueuedCount() != size || queue.popClosest() != closestData)
throw new AssertionError("Pending queue data changed");
if (report) System.out.printf(java.util.Locale.ROOT,
"mode=%s size=%d polls=%d worker_cpu_ms=%.3f wall_ms=%.3f worker_allocated_bytes=%d%n",
waiting ? "wait" : "upstream", size, loops, cpu / 1e6, elapsed / 1e6, allocated);
}
public static void main(String[] args) {
for (int size : new int[]{1, 64}) {
for (int i = 0; i < 2; i++) { run(false, size, false); run(true, size, false); }
for (int i = 0; i < 3; i++) { run(false, size, true); run(true, size, true); }
}
}
}
@@ -1,30 +0,0 @@
package dev.horizonlab.budget;
public final class DebounceWaitTest {
public static void main(String[] args) {
long debounce = 250_000_000L;
check(DebounceWait.delay(0, 0, debounce, false) == 1_000_000L, "bounded wait for a fresh update");
check(DebounceWait.delay(249_900_000L, 0, debounce, false) == 100_000L, "remaining deadline limits wait");
check(DebounceWait.delay(debounce, 0, debounce, false) == 0, "ready update is not delayed");
check(DebounceWait.delay(debounce + 1, 0, debounce, false) == 0, "overdue update is not delayed");
check(DebounceWait.delay(1, 0, debounce, true) == 0, "pending preprocessing is not delayed");
check(DebounceWait.delay(0, 1, debounce, false) == 0, "future timestamp does not create a wait");
check(DebounceWait.delay(0, 0, 0, false) == 0, "disabled debounce is not delayed");
long wrapped = Long.MAX_VALUE - 500_000L;
check(DebounceWait.delay(wrapped + 1_000_000L, wrapped, debounce, false) == 1_000_000L, "monotonic timer wrap");
long now = 0;
int waits = 0;
while (now < debounce) {
long wait = DebounceWait.delay(now, 0, debounce, false);
check(wait > 0 && wait <= DebounceWait.MAX_WAIT_NS && now + wait <= debounce, "deadline unchanged");
now += wait;
waits++;
}
check(now == debounce && waits == 250, "debounce deadline is reached without extending it");
System.out.println("Debounce wait policy tests passed.");
}
private static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -1,23 +0,0 @@
package dev.horizonlab.budget;
public final class DispatchRetryTest {
private static void check(boolean condition) { if (!condition) throw new AssertionError(); }
public static void main(String[] args) {
DispatchRetry policy = new DispatchRetry();
policy.beginPass();
policy.beginExecutor();
check(policy.visibleSlots(0) == 0 && policy.visibleSlots(3) == 3);
check(!policy.needsRetry(false, false) && policy.needsRetry(true, false));
check(policy.retryMilliseconds(1000) == 1000);
policy.rejected();
check(policy.visibleSlots(0) == Integer.MAX_VALUE && policy.needsRetry(false, false));
check(policy.retryMilliseconds(1000) == 1);
policy.beginExecutor();
check(policy.visibleSlots(1) == 1); // Another executor can consume a real free slot.
check(policy.needsRetry(false, false)); // Still retry the earlier rejected executor.
check(!policy.needsRetry(false, true) && !policy.needsRetry(true, true));
policy.beginPass();
check(!policy.needsRetry(false, false) && policy.retryMilliseconds(1000) == 1000);
System.out.println("Dispatch retry policy tests passed.");
}
}
@@ -1,63 +0,0 @@
package dev.horizonlab.budget;
import com.seibel.distanthorizons.core.api.internal.chunkUpdating.ChunkPosQueue;
import com.seibel.distanthorizons.core.api.internal.chunkUpdating.ChunkUpdateData;
import com.seibel.distanthorizons.core.pos.DhChunkPos;
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
/** Isolates wakeup after synthetic incoming work; does not measure terrain rendering or debounce. */
public final class IdleQueueBenchmark {
private static void run(boolean wakeable, boolean report, int trial) throws Exception {
var queue = new ChunkPosQueue();
var update = new ChunkUpdateData(null, null);
var ready = new CountDownLatch(1);
var published = new AtomicLong();
var idleStarted = new AtomicLong();
var latency = new AtomicLong();
var failure = new AtomicReference<Throwable>();
Thread worker = new Thread(() -> {
try {
idleStarted.set(System.nanoTime());
ready.countDown();
while (queue.isEmpty()) {
if (wakeable) WakeableIdleWait.pause(queue, 50_000_000L);
else Thread.sleep(50);
}
var item = queue.popClosest();
latency.set(System.nanoTime() - published.get());
if (item != update || queue.getQueuedCount() != 0)
throw new AssertionError("Queued work identity/count changed");
} catch (Throwable error) { failure.set(error); }
}, "Horizon idle queue benchmark");
worker.setDaemon(true);
worker.start();
try {
if (!ready.await(5, TimeUnit.SECONDS)) throw new AssertionError("Worker did not start");
LockSupport.parkNanos(5_000_000L);
long start = System.nanoTime();
published.set(start);
queue.addItem(new DhChunkPos(0, 0), update);
if (wakeable) LockSupport.unpark(worker);
worker.join(5_000);
if (worker.isAlive()) throw new AssertionError("Incoming work did not reach worker");
if (failure.get() != null) throw new AssertionError("Worker failed", failure.get());
if (latency.get() < 0) throw new AssertionError("Invalid timestamp ordering");
if (report) System.out.printf(Locale.ROOT,
"mode=%s trial=%d incoming_after_idle_ms=%.3f publication_to_pop_ms=%.3f%n",
wakeable ? "wakeable" : "upstream", trial, (start - idleStarted.get()) / 1e6, latency.get() / 1e6);
} finally {
worker.interrupt();
worker.join(1_000);
}
}
public static void main(String[] args) throws Exception {
for (int warmup = 0; warmup < 4; warmup++) { run(false, false, warmup); run(true, false, warmup); }
for (int trial = 0; trial < 20; trial++) { run(false, true, trial); run(true, true, trial); }
}
}
@@ -1,30 +0,0 @@
package dev.horizonlab.budget;
public final class TerrainBudgetTest {
private static void check(boolean value, String reason) { if (!value) throw new AssertionError(reason); }
public static void main(String[] args) {
TerrainBudget budget = new TerrainBudget(0.5);
for (int i = 0; i < 100; i++) budget.update(0.5, 40, 16.667, 0.5, 0.3);
check(budget.ratio() == 0.05, "Sustained pressure reaches a nonzero floor");
for (int i = 0; i < 2; i++) budget.update(0.5, 10, 16.667, 0, 0.3);
check(budget.ratio() == 0.05, "Recovery requires three healthy windows");
budget.update(0.5, 10, 16.667, 0, 0.3);
check(Math.abs(budget.ratio() - 0.075) < 1e-9, "Recovery is gradual");
for (int i = 0; i < 1000; i++) budget.update(0.5, 10, 16.667, 0, 0.3);
check(budget.ratio() == 0.5, "Recovery never exceeds the user's ceiling");
check(budget.update(0.02, 40, 16.667, 0.5, 0.3) == 0.02, "Respect a user ceiling below our floor");
budget = new TerrainBudget(0.5);
check(budget.update(0.5, 10, 16.667, 0, 0.9) < 0.5, "Heap pressure reduces work");
budget = new TerrainBudget(0.5);
check(budget.update(0.5, 33.3, 1000.0 / 30, 0, 0.3) == 0.5, "Respect a 30 FPS cap");
budget = new TerrainBudget(0.5);
for (int i = 0; i < 100; i++) {
budget.update(0.5, 40, 16.667, 0.5, 0.3);
budget.update(0.5, 10, 16.667, 0, 0.3);
}
check(budget.ratio() == 0.05, "Alternating pressure cannot trigger rapid recovery");
try { budget.update(0.5, Double.NaN, 16, 0, 0); throw new AssertionError("NaN accepted"); }
catch (IllegalArgumentException expected) { }
System.out.println("Terrain budget tests passed");
}
}
@@ -1,70 +0,0 @@
package dev.horizonlab.budget;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.LockSupport;
public final class WakeableIdleWaitTest {
private static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
private static void workerCase(boolean interrupt, boolean beforePark) throws Exception {
var ready = new CountDownLatch(1);
var proceed = new AtomicBoolean(!beforePark);
var failure = new AtomicReference<Throwable>();
var completed = new AtomicBoolean();
Thread worker = new Thread(() -> {
ready.countDown();
// No blocking operation may consume the early unpark permit before the tested wait.
while (!proceed.get() && !Thread.currentThread().isInterrupted()) Thread.onSpinWait();
try {
WakeableIdleWait.pause(completed, TimeUnit.SECONDS.toNanos(30));
check(!interrupt, "Interrupt must throw");
completed.set(true);
} catch (InterruptedException expected) {
if (!interrupt) failure.set(expected);
else if (Thread.currentThread().isInterrupted()) failure.set(new AssertionError("Throw must clear interrupt status"));
else completed.set(true);
} catch (Throwable error) { failure.set(error); }
}, "Horizon idle-wait test");
worker.setDaemon(true);
worker.start();
try {
check(ready.await(5, TimeUnit.SECONDS), "Worker did not start");
if (!beforePark) {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
while (worker.getState() != Thread.State.TIMED_WAITING && worker.isAlive() && System.nanoTime() < deadline)
Thread.onSpinWait();
check(worker.getState() == Thread.State.TIMED_WAITING, "Worker did not park");
}
if (interrupt) worker.interrupt(); else LockSupport.unpark(worker);
proceed.set(true);
worker.join(5_000);
check(!worker.isAlive(), "Wakeup was lost or shutdown stalled");
if (failure.get() != null) throw new AssertionError("Worker failed", failure.get());
check(completed.get(), "Wait did not complete");
} finally {
proceed.set(true);
worker.interrupt();
worker.join(1_000);
}
}
public static void main(String[] args) throws Exception {
Thread.currentThread().interrupt();
try {
WakeableIdleWait.pause(null, 1_000_000);
throw new AssertionError("Pending interrupt must throw");
} catch (InterruptedException expected) {
check(!Thread.currentThread().isInterrupted(), "Pending interrupt must be cleared");
}
workerCase(false, true); // Producer arrived between empty check and park.
workerCase(false, false); // Producer arrived while worker was idle.
workerCase(true, true);
workerCase(true, false);
System.out.println("Wakeable idle-wait arrival races and interruption tests passed.");
}
}
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more