mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 22:43:47 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb317c771 | ||
|
|
aef97f1e43 | ||
|
|
3f3bdfa663 | ||
|
|
5482b975fb | ||
|
|
18a02fe574 | ||
|
|
cb11c1d221 | ||
|
|
2aa6aa28cd | ||
|
|
df6e0f0547 | ||
|
|
02122e2532 |
No files matched your search
@@ -1,61 +0,0 @@
|
||||
name: pack load bench
|
||||
# How long resource packs take to load: crates/horizon-client-render/examples/pack_load_bench.rs on generated packs
|
||||
# (no game files), on the branch and then on `base` with the branch's copy of the bench, on the same runner, so a pull
|
||||
# request can show its numbers next to main's. Started by hand, or by a push to a `pack-bench/*` branch (a fork can't
|
||||
# start a workflow its main lacks), against horizon-rust/horizon's main.
|
||||
on:
|
||||
push:
|
||||
branches: ['pack-bench/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
base:
|
||||
description: Ref to compare against
|
||||
default: main
|
||||
scale:
|
||||
description: How many times the default pack sizes
|
||||
default: '1'
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
HORIZON_TELEMETRY_URL: "off"
|
||||
CARGO_TERM_COLOR: never
|
||||
|
||||
jobs:
|
||||
bench:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install Rust and the ALSA and udev headers
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
rustup toolchain install stable --profile minimal
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: pack-bench
|
||||
- name: Bench the branch
|
||||
env:
|
||||
SCALE: ${{ inputs.scale || '1' }}
|
||||
run: |
|
||||
cargo run --profile fast -p horizon-client-render --example pack_load_bench -- --scale "$SCALE" > head.md
|
||||
- name: Bench the base, with the branch's bench
|
||||
env:
|
||||
BASE: ${{ inputs.base || 'main' }}
|
||||
SCALE: ${{ inputs.scale || '1' }}
|
||||
run: |
|
||||
git fetch https://github.com/horizon-rust/horizon.git "$BASE"
|
||||
git worktree add ../base FETCH_HEAD
|
||||
cp crates/horizon-client-render/examples/pack_load_bench.rs ../base/crates/horizon-client-render/examples/
|
||||
# A bench row that times a function the base lacks keeps it from building there: then
|
||||
# the branch's own rows compare the two ways.
|
||||
(cd ../base && CARGO_TARGET_DIR="$GITHUB_WORKSPACE/target" cargo run --profile fast -p horizon-client-render \
|
||||
--example pack_load_bench -- --scale "$SCALE") > base.md \
|
||||
|| echo "The bench does not build on the base." > base.md
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "### Branch ($(git rev-parse --short HEAD))"; cat head.md
|
||||
echo; echo "### Base (${{ inputs.base || 'main' }})"; cat base.md
|
||||
} | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -30,7 +30,6 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
|
||||
6 hours ([docs/release-flow.md](docs/release-flow.md)).
|
||||
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
|
||||
- **No hardcoded user-facing strings.** Keep UI translation-ready from the start. In the launcher (`launcher/`), wrap visible text with `tr("...")` or `trf("... {arg} ...", &[("arg", &val)])` from `crate::i18n` instead of raw string literals or `format!`. In the game, use translation keys (`Text::tr`, `lang::get`), never hardcoded English strings.
|
||||
- **Claim before you start**: comment `/claim` on the issue (no issue yet: open one, then claim it). An assigned
|
||||
issue is taken: pick another. `/unclaim` when you stop; a claim with no open PR linking it is released after 5 days.
|
||||
|
||||
@@ -159,7 +158,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
Generated
-3
@@ -1324,7 +1324,6 @@ dependencies = [
|
||||
"horizon-items",
|
||||
"horizon-mods",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shader-api",
|
||||
@@ -1452,7 +1451,6 @@ dependencies = [
|
||||
"horizon-hdr",
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
"horizon-shaders",
|
||||
@@ -1574,7 +1572,6 @@ name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-bench",
|
||||
"log",
|
||||
"regex",
|
||||
]
|
||||
|
||||
|
||||
@@ -66,28 +66,3 @@ with Microsoft accounts
|
||||
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
|
||||
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
|
||||
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
|
||||
|
||||
## CIT Resewn
|
||||
|
||||
`crates/horizon-optifine/src/cit.rs` and `crates/horizon-client-render/src/cit.rs` follow the
|
||||
condition semantics of [CIT Resewn Continuation](https://github.com/EthanVisagie/CIT-Resewn-Continuation-1.21.11)
|
||||
(at `b792f9a`), a continuation of [CIT Resewn](https://github.com/SHsuperCM/CITResewn) by SHsuperCM: the
|
||||
1.21 component paths, enchantment levels, percent damage, text matching and rule ordering. It is
|
||||
licensed under the MIT license:
|
||||
|
||||
> Copyright (c) 2021 SHsuperCM
|
||||
>
|
||||
> Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||
> and associated documentation files (the "Software"), to deal in the Software without
|
||||
> restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
||||
> distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
||||
> Software is furnished to do so, subject to the following conditions:
|
||||
>
|
||||
> The above copyright notice and this permission notice shall be included in all copies or
|
||||
> substantial portions of the Software.
|
||||
>
|
||||
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||
> BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
> DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -281,13 +281,6 @@ pub struct Player {
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub sprinting: bool,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
@@ -202,8 +202,10 @@ fn os_version() -> Option<String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
fn cpu() -> Option<String> {
|
||||
let info = std::fs::read_to_string("/proc/cpuinfo").ok()?;
|
||||
// x86 names the CPU per core; arm64 only gives the core's implementer and part.
|
||||
horizon_telemetry::system::linux_cpu_name(&info)
|
||||
// x86 names the CPU per core; ARM boards name the machine once.
|
||||
key_value(&info, "model name", ':')
|
||||
.or_else(|| key_value(&info, "Model", ':'))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -225,12 +227,12 @@ fn key_value<'a>(text: &'a str, key: &str, sep: char) -> Option<&'a str> {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn os_version() -> Option<String> {
|
||||
horizon_telemetry::system::windows_version()
|
||||
output("cmd", &["/C", "ver"])
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn cpu() -> Option<String> {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -279,16 +281,4 @@ mod tests {
|
||||
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// `hardware.cpu` is the brand name, not the family and model `PROCESSOR_IDENTIFIER` holds.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_reports_the_cpu_brand() {
|
||||
let cpu = Hardware::detect().cpu.expect("a CPU name");
|
||||
assert!(
|
||||
!cpu.is_empty() && !cpu.contains(" Family ") && !cpu.contains("Stepping"),
|
||||
"family and model, not a name: {cpu:?}"
|
||||
);
|
||||
assert_eq!(cpu, cpu.trim());
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health. A UUID instead of a NAME: the
|
||||
entity with that UUID (a boat summoned with it, say)
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
|
||||
default no limit
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
@@ -156,11 +153,9 @@ struct Options {
|
||||
seed: u64,
|
||||
explore_speed: f64,
|
||||
pvp: bool,
|
||||
/// The players or entity UUIDs to attack, how long after joining to start,
|
||||
/// and how many rounds.
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
attack_rounds: u32,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
@@ -195,7 +190,6 @@ fn options() -> Result<Options, String> {
|
||||
pvp: true,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
attack_rounds: u32::MAX,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
@@ -253,7 +247,6 @@ fn options() -> Result<Options, String> {
|
||||
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
|
||||
"--attack" => o.attack = Some(value),
|
||||
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
|
||||
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
|
||||
"--name" => o.name = value,
|
||||
"--report" => o.report = Some(value),
|
||||
"--brand" => o.brand = Some(value),
|
||||
@@ -408,16 +401,6 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
|
||||
/// the offline UUID of the player with that name (a name has no `-`).
|
||||
fn target_uuid(name: &str) -> u128 {
|
||||
let hex: String = name.chars().filter(|&c| c != '-').collect();
|
||||
match u128::from_str_radix(&hex, 16) {
|
||||
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
|
||||
_ => offline_uuid(name),
|
||||
}
|
||||
}
|
||||
|
||||
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
|
||||
struct Random(u64);
|
||||
impl Random {
|
||||
@@ -1001,7 +984,7 @@ fn run_bot(
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (target_uuid(name), None))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
@@ -1056,7 +1039,6 @@ fn run_bot(
|
||||
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
|
||||
}
|
||||
let mut sequence = 0;
|
||||
let mut rounds = 0;
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
|
||||
@@ -1220,13 +1202,8 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
&& rounds < o.attack_rounds
|
||||
{
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
// A round counts once a target is known.
|
||||
rounds += u32::from(!targets.is_empty());
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
@@ -1469,20 +1446,6 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// `--attack`: a UUID names that entity, anything else a player.
|
||||
#[test]
|
||||
fn attack_targets_a_uuid_or_a_player() {
|
||||
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
|
||||
assert_eq!(
|
||||
target_uuid("0000000a-0000-0001-0000-000000000002"),
|
||||
0x0000000a_0000_0001_0000_000000000002
|
||||
);
|
||||
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
|
||||
// A name of 32 hex digits is a name: a UUID is written with dashes.
|
||||
let name = "0123456789abcdef0123456789abcdef";
|
||||
assert_eq!(target_uuid(name), offline_uuid(name));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_living_entities_only() {
|
||||
let mut e = Entities::default();
|
||||
|
||||
@@ -72,19 +72,6 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
@@ -329,20 +316,6 @@ mod tests {
|
||||
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
|
||||
let es = Language::load(&[&empty], "es_es");
|
||||
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
|
||||
// German and French, and their regional variants on the same file
|
||||
// (Swiss German without "ß").
|
||||
let de = Language::load(&[&empty], "de_de");
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
let fr = Language::load(&[&empty], "fr_fr");
|
||||
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
|
||||
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
|
||||
let variant = Language::load(&[&empty], code);
|
||||
assert_eq!(variant.map(), base.map(), "{code}");
|
||||
}
|
||||
let de_ch = Language::load(&[&empty], "de_ch");
|
||||
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
|
||||
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
|
||||
assert_eq!(de_ch.len(), de.len());
|
||||
let es_ve = Language::load(&[&empty], "es_ve");
|
||||
assert_eq!(
|
||||
es_ve.get("horizon.options.on_screen_keyboard"),
|
||||
@@ -350,148 +323,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
|
||||
fn bundled_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
body.lines()
|
||||
.filter(|line| line.starts_with(" \""))
|
||||
.map(|line| {
|
||||
serde_json::Deserializer::from_str(line.trim_start())
|
||||
.into_iter::<String>()
|
||||
.next()
|
||||
.and_then(Result::ok)
|
||||
.unwrap_or_else(|| panic!("no key on line {line}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
|
||||
/// percent sign that starts none of them ("100% is") is plain text.
|
||||
fn placeholders(text: &str) -> Vec<String> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find('%') {
|
||||
rest = &rest[at + 1..];
|
||||
let spec = rest
|
||||
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
|
||||
.unwrap_or(rest.len());
|
||||
let after = rest[spec..].chars().next();
|
||||
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
|
||||
let end = spec + 1;
|
||||
found.push(format!("%{}", &rest[..end]));
|
||||
rest = &rest[end..];
|
||||
}
|
||||
}
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
};
|
||||
seen += 1;
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
let map: HashMap<String, String> = serde_json::from_str(&body)
|
||||
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
|
||||
let keys = file_keys(&body);
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
map.len(),
|
||||
"{name} repeats a key or has a multi-line entry"
|
||||
);
|
||||
for (key, value) in &map {
|
||||
// Stale: the code no longer asks for it, or it was renamed in en_us only.
|
||||
let english = en
|
||||
.get(key)
|
||||
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
|
||||
// A translation keeps the fields the code fills in.
|
||||
assert_eq!(
|
||||
placeholders(value),
|
||||
placeholders(english),
|
||||
"{code}: {key} changes the placeholders of the English text"
|
||||
);
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_horizon_key_the_code_names_is_in_en_us() {
|
||||
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
rust_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
"horizon-client",
|
||||
"horizon-client-render",
|
||||
"horizon-client-core",
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
let text = std::fs::read_to_string(&file).unwrap();
|
||||
for (at, _) in text.match_indices("\"horizon.") {
|
||||
let rest = &text[at + 1..];
|
||||
let Some(end) = rest.find('"') else { continue };
|
||||
let key = &rest[..end];
|
||||
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
|
||||
// between the dots. A key built at run time has a brace; a file name
|
||||
// has one dot; the server addresses that look like keys are listed.
|
||||
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
let key_like = key
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
|
||||
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
found += 1;
|
||||
assert!(
|
||||
en.contains_key(key),
|
||||
"{} names {key}, which is not in en_us.json",
|
||||
file.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(found > 50, "found only {found} key names");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_selected_language_over_english() {
|
||||
let base = Mem(HashMap::from([
|
||||
|
||||
@@ -483,21 +483,6 @@ impl KeyBinds {
|
||||
.find(|mapping| self.get(mapping) == name)
|
||||
.and_then(|mapping| key_code(Self::default_of(mapping)))
|
||||
}
|
||||
/// `AbstractContainerScreen.checkHotbarKeyPressed`: the off hand and the
|
||||
/// hotbar slots, as the controls bind them now. Answers the key the menu
|
||||
/// acts on (`KeyF` for the off hand, `DigitN` for a slot), so a rebound key
|
||||
/// swaps instead of reaching the screen.
|
||||
pub fn container_hotbar_key(&self, key: KeyCode) -> Option<KeyCode> {
|
||||
let name = key_name(key)?;
|
||||
if self.get("key.swapOffhand") == name {
|
||||
return Some(KeyCode::KeyF);
|
||||
}
|
||||
MAPPINGS
|
||||
.iter()
|
||||
.filter(|m| m.0.starts_with("key.hotbar."))
|
||||
.find(|m| self.get(m.0) == name)
|
||||
.and_then(|m| key_code(Self::default_of(m.0)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -534,29 +519,6 @@ mod tests {
|
||||
assert_eq!(keys.container_mouse_key(MouseButton::Forward, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_hotkeys_follow_the_bound_keys() {
|
||||
let mut keys = KeyBinds::default();
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyF),
|
||||
Some(KeyCode::KeyF)
|
||||
);
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::Digit3),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), None);
|
||||
// The report's key: bound to the off hand, the menu takes it first.
|
||||
keys.set("key.swapOffhand", "key.keyboard.tab");
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::Tab), Some(KeyCode::KeyF));
|
||||
assert_eq!(keys.container_hotbar_key(KeyCode::KeyF), None);
|
||||
keys.set("key.hotbar.3", "key.keyboard.t");
|
||||
assert_eq!(
|
||||
keys.container_hotbar_key(KeyCode::KeyT),
|
||||
Some(KeyCode::Digit3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebinding_translates_to_defaults() {
|
||||
let mut k = KeyBinds::default();
|
||||
|
||||
@@ -549,7 +549,6 @@ pub fn fullscreen_request(
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size()?];
|
||||
|
||||
@@ -17,9 +17,6 @@ horizon-hdr = { path = "../horizon-hdr" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
# The narrator option's status and whether the narrator is there (Options.narrator).
|
||||
horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
//! How long a big resource pack takes to load and reload, on generated files only (no game
|
||||
//! resources): a stand-in vanilla root, a server-style zip pack of many namespaces, and a
|
||||
//! high-resolution pack over vanilla's own textures. Each stage runs a few times; the median
|
||||
//! is printed as a Markdown table.
|
||||
//! `cargo run --profile fast -p horizon-client-render --example pack_load_bench [-- --scale 1]`
|
||||
//! (`.github/workflows/pack-bench.yml` runs it on a branch and on its base, side by side).
|
||||
use horizon_client_render::{
|
||||
hud::HudAtlas,
|
||||
model_library::ParsedModels,
|
||||
packs::{self, Pack},
|
||||
sprites::Sprites,
|
||||
};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
/// A small deterministic generator: the same files on every run and every branch.
|
||||
struct Noise(u64);
|
||||
impl Noise {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A `side`x`side` RGBA PNG (`frames` tall when animated) of blotchy pixels, which compress
|
||||
/// about as well as real textures do.
|
||||
fn png(noise: &mut Noise, side: u32, frames: u32, alpha: bool) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut out, side, side * frames);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
let mut pixels = Vec::with_capacity((side * side * frames * 4) as usize);
|
||||
let mut colour = [0u8; 4];
|
||||
for i in 0..side * side * frames {
|
||||
if i % 3 == 0 {
|
||||
let n = noise.next();
|
||||
colour = [n as u8, (n >> 8) as u8, (n >> 16) as u8, 255];
|
||||
if alpha && n >> 40 & 3 == 0 {
|
||||
colour[3] = 0;
|
||||
}
|
||||
}
|
||||
pixels.extend_from_slice(&colour);
|
||||
}
|
||||
writer.write_image_data(&pixels).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn model(noise: &mut Noise, namespace: &str, texture: &str) -> Vec<u8> {
|
||||
let n = noise.next() % 4;
|
||||
format!(
|
||||
r##"{{"parent":"minecraft:block/cube_all","textures":{{"all":"{namespace}:{texture}"}},"elements":[{{"from":[0,0,0],"to":[16,{h},16],"faces":{{"north":{{"texture":"#all","uv":[0,0,16,16]}},"up":{{"texture":"#all"}}}}}}]}}"##,
|
||||
h = 4 + n * 4
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn write(root: &Path, name: &str, bytes: &[u8]) {
|
||||
let path = root.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// A deflated zip written by hand (the reader skips CRCs, so they stay zero), at most 65535
|
||||
/// entries.
|
||||
#[derive(Default)]
|
||||
struct ZipOut {
|
||||
out: Vec<u8>,
|
||||
central: Vec<u8>,
|
||||
count: u16,
|
||||
}
|
||||
impl ZipOut {
|
||||
fn put(&mut self, name: &str, body: &[u8]) {
|
||||
let data = miniz_oxide::deflate::compress_to_vec(body, 6);
|
||||
let offset = u32::try_from(self.out.len()).unwrap();
|
||||
let sizes = [data.len() as u32, body.len() as u32];
|
||||
self.out.extend(0x0403_4b50u32.to_le_bytes());
|
||||
self.out.extend([20, 0, 0, 0, 8, 0]);
|
||||
self.out.extend([0; 8]);
|
||||
sizes.iter().for_each(|s| self.out.extend(s.to_le_bytes()));
|
||||
self.out.extend((name.len() as u16).to_le_bytes());
|
||||
self.out.extend([0; 2]);
|
||||
self.out.extend(name.as_bytes());
|
||||
self.out.extend(&data);
|
||||
self.central.extend(0x0201_4b50u32.to_le_bytes());
|
||||
self.central.extend([20, 0, 20, 0, 0, 0, 8, 0]);
|
||||
self.central.extend([0; 8]);
|
||||
sizes
|
||||
.iter()
|
||||
.for_each(|s| self.central.extend(s.to_le_bytes()));
|
||||
self.central.extend((name.len() as u16).to_le_bytes());
|
||||
self.central.extend([0; 12]);
|
||||
self.central.extend(offset.to_le_bytes());
|
||||
self.central.extend(name.as_bytes());
|
||||
self.count = self.count.checked_add(1).expect("too many zip entries");
|
||||
}
|
||||
fn finish(mut self, path: &Path) {
|
||||
let start = self.out.len() as u32;
|
||||
self.out.extend(&self.central);
|
||||
self.out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
self.out.extend([0; 4]);
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend(self.count.to_le_bytes());
|
||||
self.out.extend((self.central.len() as u32).to_le_bytes());
|
||||
self.out.extend(start.to_le_bytes());
|
||||
self.out.extend([0; 2]);
|
||||
fs::write(path, self.out).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
const META: &[u8] = br#"{"pack":{"description":"bench","min_format":[69,0],"max_format":[999,0]}}"#;
|
||||
|
||||
/// The stand-in game resources: vanilla-sized texture folders, models and the stacked JSON
|
||||
/// files (lang, sounds, fonts, atlases) every reload merges.
|
||||
fn vanilla(root: &Path, scale: usize, noise: &mut Noise) -> Vec<String> {
|
||||
let mut blocks = Vec::new();
|
||||
for i in 0..1100 * scale {
|
||||
let name = format!("block/b{i}");
|
||||
let frames = if i % 50 == 0 { 8 } else { 1 };
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/textures/{name}.png"),
|
||||
&png(noise, 16, frames, i % 7 == 0),
|
||||
);
|
||||
if frames > 1 {
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/textures/{name}.png.mcmeta"),
|
||||
br#"{"animation":{"frametime":2}}"#,
|
||||
);
|
||||
}
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/models/block/b{i}.json"),
|
||||
&model(noise, "minecraft", &name),
|
||||
);
|
||||
blocks.push(format!("minecraft:{name}"));
|
||||
}
|
||||
for (folder, count, side) in [("item", 1500, 16), ("entity", 900, 64), ("gui", 800, 16)] {
|
||||
for i in 0..count * scale {
|
||||
let sub = if folder == "gui" { "sprites/" } else { "" };
|
||||
write(
|
||||
root,
|
||||
&format!(
|
||||
"assets/minecraft/textures/{folder}/{sub}{}/t{i}.png",
|
||||
i % 20
|
||||
),
|
||||
&png(noise, side, 1, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
for i in 0..1500 * scale {
|
||||
write(
|
||||
root,
|
||||
&format!("assets/minecraft/models/item/i{i}.json"),
|
||||
&model(noise, "minecraft", &format!("item/{}/t{i}", i % 20)),
|
||||
);
|
||||
}
|
||||
let lang: String = (0..4000)
|
||||
.map(|i| format!("\"key.{i}\":\"Value {i}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/lang/en_us.json",
|
||||
format!("{{{lang}}}").as_bytes(),
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/sounds.json",
|
||||
br#"{"a":{"sounds":["a"]}}"#,
|
||||
);
|
||||
write(
|
||||
root,
|
||||
"assets/minecraft/atlases/blocks.json",
|
||||
br#"{"sources":[{"type":"directory","source":"block","prefix":"block/"}]}"#,
|
||||
);
|
||||
write(root, "pack.mcmeta", META);
|
||||
blocks
|
||||
}
|
||||
|
||||
/// A server pack the way ItemsAdder-style plugins make them: one zip of many namespaces, each
|
||||
/// with its own items' textures and models, its sounds and its lang files.
|
||||
fn server_zip(path: &Path, scale: usize, noise: &mut Noise) {
|
||||
let mut zip = ZipOut::default();
|
||||
zip.put("pack.mcmeta", META);
|
||||
for n in 0..190 {
|
||||
let ns = format!("plugin{n}");
|
||||
for i in 0..50 * scale {
|
||||
let texture = format!("item/{}/t{i}", i % 5);
|
||||
zip.put(
|
||||
&format!("assets/{ns}/textures/{texture}.png"),
|
||||
&png(noise, 32, 1, true),
|
||||
);
|
||||
zip.put(
|
||||
&format!("assets/{ns}/models/item/{}/m{i}.json", i % 5),
|
||||
&model(noise, &ns, &texture),
|
||||
);
|
||||
if i % 10 == 0 {
|
||||
zip.put(&format!("assets/{ns}/sounds/s{i}.ogg"), &[0; 2048]);
|
||||
}
|
||||
}
|
||||
zip.put(
|
||||
&format!("assets/{ns}/lang/en_us.json"),
|
||||
br#"{"item.custom":"Custom"}"#,
|
||||
);
|
||||
}
|
||||
let lang: String = (0..2000)
|
||||
.map(|i| format!("\"server.{i}\":\"Server {i}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
zip.put(
|
||||
"assets/minecraft/lang/en_us.json",
|
||||
format!("{{{lang}}}").as_bytes(),
|
||||
);
|
||||
zip.finish(path);
|
||||
}
|
||||
|
||||
/// A 64x pack over vanilla's own block, item and entity textures, with OptiFine CTM-style
|
||||
/// tiles beside them: what the big OptiFine packs look like on disk.
|
||||
fn hd_zip(path: &Path, scale: usize, noise: &mut Noise) {
|
||||
let mut zip = ZipOut::default();
|
||||
zip.put("pack.mcmeta", META);
|
||||
for i in 0..1100 * scale {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/textures/block/b{i}.png"),
|
||||
&png(noise, 64, 1, i % 7 == 0),
|
||||
);
|
||||
if i % 4 == 0 {
|
||||
for tile in 0..12 {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/optifine/ctm/b{i}/{tile}.png"),
|
||||
&png(noise, 64, 1, false),
|
||||
);
|
||||
}
|
||||
zip.put(
|
||||
&format!("assets/minecraft/optifine/ctm/b{i}/b{i}.properties"),
|
||||
format!("matchTiles=b{i}\nmethod=ctm\ntiles=0-11\n").as_bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
for i in 0..750 * scale {
|
||||
zip.put(
|
||||
&format!("assets/minecraft/textures/item/{}/t{i}.png", i % 20),
|
||||
&png(noise, 64, 1, true),
|
||||
);
|
||||
}
|
||||
zip.finish(path);
|
||||
}
|
||||
|
||||
fn median(mut ms: Vec<f64>) -> f64 {
|
||||
ms.sort_by(f64::total_cmp);
|
||||
ms[ms.len() / 2]
|
||||
}
|
||||
|
||||
/// `f`'s median time over `runs`, with `before` (not timed) ahead of each run.
|
||||
fn time(runs: usize, mut before: impl FnMut(), mut f: impl FnMut()) -> f64 {
|
||||
median(
|
||||
(0..runs)
|
||||
.map(|_| {
|
||||
before();
|
||||
let started = Instant::now();
|
||||
f();
|
||||
started.elapsed().as_secs_f64() * 1000.0
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn zip_pack(id: &str, path: PathBuf) -> Pack {
|
||||
// Read the way `packs::folder` reads a `resourcepacks/` zip.
|
||||
let dir = path.parent().unwrap().join(format!("{id}-dir"));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let at = dir.join(path.file_name().unwrap());
|
||||
fs::rename(&path, &at).unwrap();
|
||||
packs::folder(&dir, packs::RESOURCE_FORMAT)
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("{id} is not a pack"))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let scale = args
|
||||
.iter()
|
||||
.position(|a| a == "--scale")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.map_or(1, |s| s.parse().unwrap());
|
||||
let runs = 3;
|
||||
let scratch = std::env::temp_dir().join(format!("horizon-pack-bench-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
fs::create_dir_all(&scratch).unwrap();
|
||||
let mut noise = Noise(0x9e37_79b9_7f4a_7c15);
|
||||
let vanilla_root = scratch.join("vanilla");
|
||||
let blocks = vanilla(&vanilla_root, scale, &mut noise);
|
||||
server_zip(&scratch.join("server.zip"), scale, &mut noise);
|
||||
hd_zip(&scratch.join("hd.zip"), scale, &mut noise);
|
||||
let server = zip_pack("server", scratch.join("server.zip"));
|
||||
let hd = zip_pack("hd", scratch.join("hd.zip"));
|
||||
let cache = scratch.join("cache");
|
||||
let both = [hd.clone(), server.clone()];
|
||||
|
||||
let mut rows: Vec<(String, f64)> = Vec::new();
|
||||
let mut row = |name: &str, ms: f64| {
|
||||
eprintln!("{name}: {ms:.1} ms");
|
||||
rows.push((name.to_owned(), ms));
|
||||
};
|
||||
let fresh = || {
|
||||
let _ = fs::remove_dir_all(&cache);
|
||||
};
|
||||
row(
|
||||
"overlay: build, both packs (cold)",
|
||||
time(runs, fresh, || {
|
||||
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
}),
|
||||
);
|
||||
let root = packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
row(
|
||||
"overlay: reuse, both packs (cache hit)",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
packs::resource_root(&vanilla_root, &both, &cache).unwrap();
|
||||
},
|
||||
),
|
||||
);
|
||||
// The pack screen: one pack turned off, then on again.
|
||||
row(
|
||||
"overlay: toggle the server pack off (new selection)",
|
||||
time(
|
||||
runs,
|
||||
|| {
|
||||
let only =
|
||||
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
|
||||
fs::remove_dir_all(&only).unwrap();
|
||||
},
|
||||
|| {
|
||||
packs::resource_root(&vanilla_root, std::slice::from_ref(&hd), &cache).unwrap();
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"models: parse every model",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
std::hint::black_box(ParsedModels::load(&root));
|
||||
},
|
||||
),
|
||||
);
|
||||
let sprite_cache = root.join("horizon-sprites.cache");
|
||||
// OptiFine connected-texture tiles, inserted by their path as `ConnectedTextures` does.
|
||||
let tiles: Vec<String> = (0..1100 * scale)
|
||||
.step_by(4)
|
||||
.flat_map(|i| (0..12).map(move |t| format!("assets/minecraft/optifine/ctm/b{i}/{t}.png")))
|
||||
.collect();
|
||||
let sprites = |cold: bool, prefetch: bool, names: &[String], files: bool| {
|
||||
time(
|
||||
runs,
|
||||
|| {
|
||||
if cold {
|
||||
let _ = fs::remove_file(&sprite_cache);
|
||||
}
|
||||
},
|
||||
|| {
|
||||
let mut sprites = Sprites::with_disk_cache(&root);
|
||||
if prefetch {
|
||||
sprites.prefetch(&root, names.iter().map(String::as_str));
|
||||
}
|
||||
for name in names {
|
||||
if files {
|
||||
sprites.insert_file(&root, name).unwrap();
|
||||
} else {
|
||||
sprites.insert(&root, name).unwrap();
|
||||
}
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
sprites.finish();
|
||||
std::hint::black_box(&sprites.pixels);
|
||||
},
|
||||
)
|
||||
};
|
||||
row(
|
||||
"block sprites: one by one (no cache)",
|
||||
sprites(true, false, &blocks, false),
|
||||
);
|
||||
row(
|
||||
"block sprites: prefetched (no cache)",
|
||||
sprites(true, true, &blocks, false),
|
||||
);
|
||||
row(
|
||||
"block sprites: from the disk cache",
|
||||
sprites(false, false, &blocks, false),
|
||||
);
|
||||
row("CTM tiles: one by one", sprites(false, false, &tiles, true));
|
||||
row("CTM tiles: prefetched", sprites(false, true, &tiles, true));
|
||||
row(
|
||||
"GUI atlas: decode and stitch, both packs",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
if let Err(e) = HudAtlas::build_over(&root, &vanilla_root) {
|
||||
eprintln!("GUI atlas failed: {e}");
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
row(
|
||||
"GUI atlas: vanilla alone",
|
||||
time(
|
||||
runs,
|
||||
|| {},
|
||||
|| {
|
||||
let _ = HudAtlas::build(&vanilla_root);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
|
||||
println!("| Stage (scale {scale}, {cores} threads, median of {runs}) | ms |");
|
||||
println!("|---|---:|");
|
||||
for (name, ms) in &rows {
|
||||
println!("| {name} | {ms:.0} |");
|
||||
}
|
||||
let _ = fs::remove_dir_all(&scratch);
|
||||
}
|
||||
@@ -101,8 +101,6 @@ pub struct RenderAssets {
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
}
|
||||
fn invalid(message: &str) -> io::Error {
|
||||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||||
@@ -242,34 +240,9 @@ impl RenderAssets {
|
||||
for stage in 0..10 {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
}
|
||||
let connected = if draws {
|
||||
if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
let catalog = &models.catalog;
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
root,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
sprites.finish();
|
||||
@@ -294,7 +267,6 @@ impl RenderAssets {
|
||||
pbr_normals: sprites.normals,
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,6 @@ fn color(v: &Value) -> Option<u32> {
|
||||
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
|
||||
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
|
||||
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size().ok_or("colormap too large")?];
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
//! Bulk block edits: runs of blocks up columns written straight into the
|
||||
//! resident sections, a section unpacked and packed once however many of its
|
||||
//! blocks change, instead of a `World::set` per block. Nothing round them is
|
||||
//! told (as `fill ... strict`); the light of each chunk changed is worked out
|
||||
//! again whole, off the game thread, rather than block by block, and each
|
||||
//! section changed is remeshed once.
|
||||
use crate::chunk::index;
|
||||
use crate::fast_hash::FxHashMap;
|
||||
use crate::world::World;
|
||||
|
||||
/// A run of one column, resolved: blocks `y0..=y1` of column (x, z) set to
|
||||
/// `state` where filter `only` (if any) passes the block there.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Run {
|
||||
pub x: i32,
|
||||
pub z: i32,
|
||||
pub y0: i32,
|
||||
pub y1: i32,
|
||||
pub state: u32,
|
||||
pub only: Option<u16>,
|
||||
}
|
||||
|
||||
/// What an edit changed.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Outcome {
|
||||
/// Blocks changed.
|
||||
pub changed: u64,
|
||||
/// Chunks with a change.
|
||||
pub chunks: usize,
|
||||
/// Changes of the states `tracked` asked for (block entities, watched
|
||||
/// blocks): position, previous and new state.
|
||||
pub tracked: Vec<((i32, i32, i32), u32, u32)>,
|
||||
}
|
||||
|
||||
/// Per state answers, worked out once each.
|
||||
struct Memo(Vec<u8>);
|
||||
impl Memo {
|
||||
#[inline]
|
||||
fn get(&mut self, state: u32, ask: impl FnOnce(u32) -> bool) -> bool {
|
||||
let i = state as usize;
|
||||
if i >= self.0.len() {
|
||||
self.0.resize(i + 1, 0);
|
||||
}
|
||||
if self.0[i] == 0 {
|
||||
self.0[i] = 1 + u8::from(ask(state));
|
||||
}
|
||||
self.0[i] == 2
|
||||
}
|
||||
}
|
||||
|
||||
impl World {
|
||||
/// Make `runs` in order, each seeing the changes of those before it.
|
||||
/// `matches(filter, state)` says whether a filter passes a state, and
|
||||
/// `tracked(state)` whether its changes are reported; each is asked once
|
||||
/// per state. Runs in unloaded chunks and the parts of runs outside each
|
||||
/// chunk's own column (`Chunk::layout`) are left out.
|
||||
///
|
||||
/// Unlike `World::set`, this records nothing per block: no
|
||||
/// `block_entities.state_changed` (a caller adds or removes block
|
||||
/// entities itself, from `Outcome::tracked`), no `watched_writes` (ask
|
||||
/// for those states through `tracked`), no `block_log` entry (the chunk
|
||||
/// goes in `bulk_log` instead, sent whole), no `path_writes` (mobs'
|
||||
/// paths are not recomputed for these blocks) and no `light_edits` (the
|
||||
/// chunk and its neighbours are lit again whole, through `dirty_light`).
|
||||
pub fn edit_runs(
|
||||
&mut self,
|
||||
runs: &[Run],
|
||||
mut matches: impl FnMut(u16, u32) -> bool,
|
||||
mut tracked: impl FnMut(u32) -> bool,
|
||||
) -> Outcome {
|
||||
let mut out = Outcome::default();
|
||||
// the runs of each chunk, in order, and the chunks in the order they were first named
|
||||
let mut by_chunk: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
|
||||
let mut order = Vec::new();
|
||||
for (i, r) in runs.iter().enumerate() {
|
||||
if r.y1 < r.y0 {
|
||||
continue;
|
||||
}
|
||||
let key = (r.x >> 4, r.z >> 4);
|
||||
by_chunk
|
||||
.entry(key)
|
||||
.or_insert_with(|| {
|
||||
order.push(key);
|
||||
Vec::new()
|
||||
})
|
||||
.push(i as u32);
|
||||
}
|
||||
let mut filter_memo: Vec<Memo> = Vec::new();
|
||||
let mut tracked_memo = Memo(Vec::new());
|
||||
let mut values: Vec<Option<Box<[u32; 4096]>>> = Vec::new();
|
||||
let mut changed: Vec<bool> = Vec::new();
|
||||
for key in order {
|
||||
let Some(chunk) = self.chunks.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
let (min_y, sections) = (chunk.min_y(), chunk.sections.len());
|
||||
let max_y = chunk.max_y();
|
||||
let list = &by_chunk[&key];
|
||||
changed.clear();
|
||||
changed.resize(sections, false);
|
||||
values.clear();
|
||||
values.resize_with(sections, || None);
|
||||
let mut any = 0u64;
|
||||
for &i in list {
|
||||
let r = runs[i as usize];
|
||||
let (lx, lz) = (r.x & 15, r.z & 15);
|
||||
let (y0, y1) = (r.y0.max(min_y), r.y1.min(max_y - 1));
|
||||
let mut y = y0;
|
||||
while y <= y1 {
|
||||
let s = ((y - min_y) >> 4) as usize;
|
||||
let top = (min_y + (s as i32 + 1) * 16 - 1).min(y1);
|
||||
let vals = values[s].get_or_insert_with(|| {
|
||||
let mut v = Box::new([0u32; 4096]);
|
||||
chunk.sections[s].unpack(&mut v);
|
||||
v
|
||||
});
|
||||
for yy in y..=top {
|
||||
let at = index(lx, yy, lz);
|
||||
let old = vals[at];
|
||||
if old == r.state {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = r.only {
|
||||
let f = f as usize;
|
||||
if filter_memo.len() <= f {
|
||||
filter_memo.resize_with(f + 1, || Memo(Vec::new()));
|
||||
}
|
||||
if !filter_memo[f].get(old, |s| matches(f as u16, s)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
vals[at] = r.state;
|
||||
changed[s] = true;
|
||||
any += 1;
|
||||
if tracked_memo.get(old, &mut tracked)
|
||||
|| tracked_memo.get(r.state, &mut tracked)
|
||||
{
|
||||
out.tracked.push(((r.x, yy, r.z), old, r.state));
|
||||
}
|
||||
}
|
||||
y = top + 1;
|
||||
}
|
||||
}
|
||||
if any == 0 {
|
||||
continue;
|
||||
}
|
||||
out.changed += any;
|
||||
out.chunks += 1;
|
||||
let chunk =
|
||||
std::sync::Arc::make_mut(self.chunks.get_mut(&key).expect("looked up above"));
|
||||
for s in 0..sections {
|
||||
if changed[s]
|
||||
&& let Some(v) = &values[s]
|
||||
{
|
||||
chunk.replace_blocks(s, v);
|
||||
}
|
||||
}
|
||||
self.revision = self.revision.wrapping_add(1);
|
||||
self.modified.insert(key);
|
||||
if self.block_log.is_some() {
|
||||
self.bulk_log.insert(key);
|
||||
}
|
||||
// remeshed once (with the neighbours whose faces and light it touches), lit again whole
|
||||
for (s, _) in changed.iter().enumerate().filter(|c| *c.1) {
|
||||
for ds in -1..=1 {
|
||||
let sy = s as i32 + ds;
|
||||
if !(0..sections as i32).contains(&sy) {
|
||||
continue;
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
self.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::Chunk;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn world() -> World {
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
for cz in -1..=1 {
|
||||
for cx in -1..=1 {
|
||||
w.chunks.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
// stone up to y 63
|
||||
for z in -16..32 {
|
||||
for x in -16..32 {
|
||||
for y in -64..64 {
|
||||
w.set(x, y, z, BlockStateId(1)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
w.dirty_light.clear();
|
||||
w.dirty_sections.clear();
|
||||
w.block_log = Some(Vec::new());
|
||||
w
|
||||
}
|
||||
fn run(x: i32, z: i32, y0: i32, y1: i32, state: u32, only: Option<u16>) -> Run {
|
||||
Run {
|
||||
x,
|
||||
z,
|
||||
y0,
|
||||
y1,
|
||||
state,
|
||||
only,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_change_their_blocks_in_order() {
|
||||
let mut w = world();
|
||||
let runs = [
|
||||
run(3, 4, 10, 40, 0, None),
|
||||
run(3, 4, 10, 12, 5, None),
|
||||
// only where it is air: over the stone, and the three blocks of 5 stay
|
||||
run(3, 4, 0, 80, 7, Some(0)),
|
||||
run(-1, 20, -70, 400, 9, None),
|
||||
];
|
||||
let out = w.edit_runs(&runs, |_, s| s == 0, |_| false);
|
||||
assert_eq!(w.block(3, 9, 4).0, 1);
|
||||
assert_eq!(w.block(3, 11, 4).0, 5);
|
||||
assert_eq!(w.block(3, 13, 4).0, 7);
|
||||
assert_eq!(w.block(3, 70, 4).0, 7);
|
||||
assert_eq!(w.block(3, 81, 4).0, 0);
|
||||
assert_eq!(w.block(3, 0, 4).0, 1);
|
||||
assert_eq!(w.block(-1, -64, 20).0, 9);
|
||||
assert_eq!(w.block(-1, 319, 20).0, 9);
|
||||
assert_eq!(w.block(0, 30, 20).0, 1);
|
||||
// 31 dug, 3 set again, the air over them filled (28, and 17 over the stone), the whole of the other column
|
||||
assert_eq!(out.changed, 31 + 3 + 45 + 384);
|
||||
assert_eq!(out.chunks, 2);
|
||||
assert!(w.bulk_log.contains(&(0, 0)) && w.bulk_log.contains(&(-1, 1)));
|
||||
assert!(w.dirty_light.contains(&(1, 1)) && w.dirty_light.contains(&(-2, 2)));
|
||||
assert!(w.dirty_sections.contains(&(1, 4, 1)));
|
||||
// the per-block log is left to the blocks set one at a time
|
||||
assert!(w.block_log.as_ref().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloaded_chunks_and_unchanged_blocks_are_left_out() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(
|
||||
&[run(100, 100, 0, 10, 0, None), run(0, 0, 0, 10, 1, None)],
|
||||
|_, _| true,
|
||||
|_| false,
|
||||
);
|
||||
assert_eq!(out.changed, 0);
|
||||
assert_eq!(out.chunks, 0);
|
||||
assert!(w.bulk_log.is_empty() && w.dirty_light.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_states_are_reported() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(&[run(1, 1, 60, 65, 3, None)], |_, _| true, |s| s == 3);
|
||||
assert_eq!(out.tracked.len(), 6);
|
||||
assert_eq!(out.tracked[0], ((1, 60, 1), 1, 3));
|
||||
assert_eq!(out.tracked[5], ((1, 65, 1), 0, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heightmaps_follow_the_edit() {
|
||||
let mut w = world();
|
||||
let air = |s: u32| s == 0;
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 64);
|
||||
w.edit_runs(&[run(5, 5, 20, 63, 0, None)], |_, _| true, |_| false);
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 20);
|
||||
w.edit_runs(&[run(5, 5, 90, 90, 2, None)], |_, _| true, |_| false);
|
||||
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 91);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_follow_each_chunks_own_layout() {
|
||||
use crate::chunk::ColumnLayout;
|
||||
// a data pack's taller dimension: -128..=511
|
||||
let tall = ColumnLayout::new(-128, 640).unwrap();
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
w.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::with_layout(0, 0, tall)));
|
||||
let out = w.edit_runs(&[run(2, 3, -500, 900, 4, None)], |_, _| true, |_| false);
|
||||
assert_eq!(out.changed, 640);
|
||||
let chunk = &w.chunks[&(0, 0)];
|
||||
assert_eq!(chunk.block(2, -128, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, -65, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, 320, 3).0, 4);
|
||||
assert_eq!(chunk.block(2, 511, 3).0, 4);
|
||||
assert!(w.dirty_sections.contains(&(0, 39, 0)));
|
||||
assert!(!w.dirty_sections.contains(&(0, 40, 0)));
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
//! Which states tick is a table by state id, built by the game from the block
|
||||
//! registry; whether a slot is made is decided when the slot is promoted at
|
||||
//! the start of the next tick, from the block state then.
|
||||
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
|
||||
use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -76,29 +76,6 @@ pub struct BlockEntities {
|
||||
/// The fluid tanks of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_fluids: FxHashMap<u32, Arc<ModFluidTanks>>,
|
||||
/// The energy buffers of mod blocks' block entities, by block state id;
|
||||
/// built with `states`, like the containers.
|
||||
mod_energy: FxHashMap<u32, Arc<ModEnergyBuffer>>,
|
||||
/// The positions of the mod block entities (`BlockEntity::Mod`) by chunk,
|
||||
/// for the renderer: the mesher lists block entities by their template,
|
||||
/// which leaves out a mod block whose template has none. One taken out
|
||||
/// (`take`) stays listed until `put` or `remove`.
|
||||
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
|
||||
}
|
||||
|
||||
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
|
||||
(x >> 4, z >> 4)
|
||||
}
|
||||
|
||||
/// Takes `pos` out of its chunk's mod block entity set.
|
||||
fn forget_mod(mods: &mut FxHashMap<(i32, i32), FxHashSet<Pos>>, pos: Pos) {
|
||||
let chunk = chunk_of(pos);
|
||||
if let Some(set) = mods.get_mut(&chunk) {
|
||||
set.remove(&pos);
|
||||
if set.is_empty() {
|
||||
mods.remove(&chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for BlockEntities {
|
||||
@@ -195,13 +172,6 @@ impl BlockEntities {
|
||||
pub fn mod_fluids(&self, state: u32) -> Option<&Arc<ModFluidTanks>> {
|
||||
self.mod_fluids.get(&state)
|
||||
}
|
||||
pub fn set_mod_energy(&mut self, energy: FxHashMap<u32, Arc<ModEnergyBuffer>>) {
|
||||
self.mod_energy = energy;
|
||||
}
|
||||
/// The energy buffer of a mod block's block entity in block state `state`.
|
||||
pub fn mod_energy(&self, state: u32) -> Option<&Arc<ModEnergyBuffer>> {
|
||||
self.mod_energy.get(&state)
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u32 {
|
||||
let n = self.next_slot;
|
||||
@@ -220,23 +190,6 @@ impl BlockEntities {
|
||||
self.pending.remove(pos);
|
||||
}
|
||||
|
||||
/// Whether any mod block entity is in the map: until one is, a block change
|
||||
/// never looks one up (`LevelAccess::has_mod_block_entity`).
|
||||
pub fn has_mods(&self) -> bool {
|
||||
!self.mods.is_empty()
|
||||
}
|
||||
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
|
||||
/// far ones are skipped whole; the caller checks each distance).
|
||||
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
|
||||
let far = move |c: i32, at: f64| {
|
||||
let (lo, hi) = (f64::from(c * 16), f64::from(c * 16 + 16));
|
||||
at < lo - radius || at > hi + radius
|
||||
};
|
||||
self.mods
|
||||
.iter()
|
||||
.filter(move |((cx, cz), _)| !far(*cx, x) && !far(*cz, z))
|
||||
.flat_map(|(_, set)| set)
|
||||
}
|
||||
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
|
||||
self.map.get_mut(pos)
|
||||
}
|
||||
@@ -249,16 +202,10 @@ impl BlockEntities {
|
||||
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
|
||||
/// block entity may get a slot; one replacing another keeps its slot.
|
||||
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
|
||||
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
|
||||
let old = self.map.insert(pos, entity);
|
||||
if old.is_none() {
|
||||
self.add(pos);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, pos);
|
||||
}
|
||||
old
|
||||
}
|
||||
/// The block entity at `pos`, made by `make` when there is none.
|
||||
@@ -267,24 +214,15 @@ impl BlockEntities {
|
||||
pos: Pos,
|
||||
make: impl FnOnce() -> BlockEntity,
|
||||
) -> &mut BlockEntity {
|
||||
let absent = !self.map.contains_key(&pos);
|
||||
if absent {
|
||||
if !self.map.contains_key(&pos) {
|
||||
self.add(pos);
|
||||
}
|
||||
let entity = self.map.entry(pos).or_insert_with(make);
|
||||
if absent && matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
entity
|
||||
self.map.entry(pos).or_insert_with(make)
|
||||
}
|
||||
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
|
||||
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
|
||||
self.cancel(pos);
|
||||
let removed = self.map.remove(pos);
|
||||
if matches!(removed, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, *pos);
|
||||
}
|
||||
removed
|
||||
self.map.remove(pos)
|
||||
}
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
/// for `put` to bring it back (a tick working on it).
|
||||
@@ -298,9 +236,6 @@ impl BlockEntities {
|
||||
/// Brings back what `take` took, in its slot.
|
||||
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
|
||||
self.out.remove(&pos);
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
if self.map.insert(pos, entity).is_none()
|
||||
&& !self.live.contains_key(&pos)
|
||||
&& !self.pending.contains_key(&pos)
|
||||
@@ -312,20 +247,17 @@ impl BlockEntities {
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
|
||||
let mods = &mut self.mods;
|
||||
self.map.retain(|p, e| {
|
||||
let kept = keep(p, e);
|
||||
if !kept {
|
||||
*freed |= live.remove(p).is_some();
|
||||
pending.remove(p);
|
||||
forget_mod(mods, *p);
|
||||
}
|
||||
kept
|
||||
});
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.mods.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
@@ -342,16 +274,9 @@ impl BlockEntities {
|
||||
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
let new_is_mod = matches!(e, BlockEntity::Mod(_));
|
||||
let old = self.map.insert(p, e);
|
||||
if old.is_some() {
|
||||
if self.map.insert(p, e).is_some() {
|
||||
self.replaced.insert(p);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(p)).or_default().insert(p);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, p);
|
||||
}
|
||||
}
|
||||
for p in order {
|
||||
self.add(p);
|
||||
@@ -444,38 +369,6 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
|
||||
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
#[test]
|
||||
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
|
||||
let mut b = level();
|
||||
let modded = || BlockEntity::Mod(Box::default());
|
||||
b.insert((1, 64, 1), modded());
|
||||
b.insert((200, 64, 1), modded());
|
||||
b.insert((2, 64, 2), hopper());
|
||||
b.get_or_insert_with((3, 64, 3), modded);
|
||||
b.load_chunk(vec![((4, 64, 4), modded()), ((5, 64, 5), hopper())]);
|
||||
// Only mods, and only chunks within reach.
|
||||
assert_eq!(
|
||||
near(&b, 0.0, 0.0, 64.0),
|
||||
[(1, 64, 1), (3, 64, 3), (4, 64, 4)]
|
||||
);
|
||||
assert_eq!(near(&b, 200.0, 0.0, 8.0), [(200, 64, 1)]);
|
||||
// Taken out for a tick, still listed; removed or replaced, gone.
|
||||
let taken = b.take(&(1, 64, 1)).unwrap();
|
||||
assert_eq!(near(&b, 0.0, 0.0, 64.0).len(), 3);
|
||||
b.put((1, 64, 1), taken);
|
||||
b.remove(&(3, 64, 3));
|
||||
b.insert((4, 64, 4), hopper());
|
||||
assert_eq!(near(&b, 0.0, 0.0, 64.0), [(1, 64, 1)]);
|
||||
b.retain(|p, _| p.0 != 1);
|
||||
assert!(near(&b, 0.0, 0.0, 64.0).is_empty());
|
||||
b.clear();
|
||||
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn slots_follow_vanilla_lifecycle() {
|
||||
let mut b = level();
|
||||
|
||||
@@ -29,10 +29,6 @@ pub type Signature = horizon_protocol::secure_chat::Signature;
|
||||
/// How long a message shows before a deletion replaces it
|
||||
/// (`ChatComponent.deleteMessageOrDelay`: `addedTime + 60`).
|
||||
const DELETION_DELAY: u64 = 60;
|
||||
/// The smooth chat extra: the ticks a new line takes to slide in and fade in,
|
||||
/// and how far left of its place it starts (GUI pixels).
|
||||
const INTRO_TICKS: f32 = 5.0;
|
||||
const INTRO_WIDTH: f32 = 12.0;
|
||||
|
||||
/// `ChatVisiblity`: the Chat option's three values.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -237,10 +233,14 @@ impl Tag {
|
||||
|
||||
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
|
||||
/// fading out quadratically over 20.
|
||||
fn time_alpha(ticks: f32, added: u64) -> f32 {
|
||||
let mut t = 1.0 - (ticks - added as f32) / 200.0;
|
||||
t = (t * 10.0).clamp(0.0, 1.0);
|
||||
t * t
|
||||
fn time_alpha(ticks: u64, added: u64) -> f32 {
|
||||
let delta = ticks as i64 - added as i64;
|
||||
let mut t = delta as f64 / 200.0;
|
||||
t = 1.0 - t;
|
||||
t *= 10.0;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
t *= t;
|
||||
t as f32
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Chat {
|
||||
@@ -256,11 +256,6 @@ pub struct Chat {
|
||||
scroll: usize,
|
||||
new_since_scroll: bool,
|
||||
pub options: ChatOptions,
|
||||
/// The smooth chat extra: a new line slides in from the left while it
|
||||
/// fades in, instead of appearing whole.
|
||||
pub smooth: bool,
|
||||
/// The smooth chat extra: no indicator bar at the left of a line.
|
||||
pub hide_indicator: bool,
|
||||
/// Messages printed to stdout as `[CHAT]` (vanilla logs them).
|
||||
pub log: bool,
|
||||
/// Messages added since the start, for the control socket's events.
|
||||
@@ -560,7 +555,7 @@ impl Chat {
|
||||
}
|
||||
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
|
||||
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
|
||||
pub fn layout(&self, height: i32, ticks: f32, focused: bool) -> Vec<GuiDraw> {
|
||||
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
|
||||
let total = self.lines.len();
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
@@ -574,47 +569,28 @@ impl Chat {
|
||||
let entry_height = o.line_height();
|
||||
let bottom_to_text =
|
||||
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
|
||||
// Vanilla's 200-tick fade reads the whole GUI ticks
|
||||
// (`Gui.getGuiTicks`); only the smooth chat extra eases it per frame.
|
||||
let fade_ticks = if self.smooth { ticks } else { ticks.floor() };
|
||||
let alpha_of = |line: &Line| {
|
||||
if focused {
|
||||
1.0
|
||||
} else {
|
||||
time_alpha(fade_ticks, line.added)
|
||||
time_alpha(ticks, line.added)
|
||||
}
|
||||
};
|
||||
// The smooth chat extra: how far into its intro a line is, 0 at the
|
||||
// tick it was added and 1 once it has arrived, as (alpha, offset x).
|
||||
let intro_of = |line: &Line| {
|
||||
if focused || !self.smooth {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
let t = ((ticks - line.added as f32) / INTRO_TICKS).clamp(0.0, 1.0);
|
||||
let eased = 1.0 - (1.0 - t) * (1.0 - t);
|
||||
(eased, (eased - 1.0) * INTRO_WIDTH)
|
||||
};
|
||||
let visible = total
|
||||
.saturating_sub(self.scroll)
|
||||
.min(self.lines_per_page(focused));
|
||||
let lines: Vec<(usize, &Line, f32, f32)> = (0..visible)
|
||||
let lines: Vec<(usize, &Line, f32)> = (0..visible)
|
||||
.rev()
|
||||
.map(|i| (i, &self.lines[i + self.scroll]))
|
||||
.map(|(i, line)| {
|
||||
let (eased, slide) = intro_of(line);
|
||||
(i, line, alpha_of(line) * eased, slide)
|
||||
})
|
||||
.filter(|(_, _, alpha, _)| *alpha > 1.0e-5)
|
||||
.map(|(i, line)| (i, line, alpha_of(line)))
|
||||
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
|
||||
.collect();
|
||||
let mut out = vec![GuiDraw::Push(vec![
|
||||
PoseOp::Scale(scale, scale),
|
||||
PoseOp::Translate(4.0, 0.0),
|
||||
])];
|
||||
for &(i, _, alpha, slide) in &lines {
|
||||
for &(i, _, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
bottom - entry_height,
|
||||
@@ -622,15 +598,9 @@ impl Chat {
|
||||
bottom,
|
||||
hud_layout::as8(alpha * background_opacity) << 24,
|
||||
));
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
for &(i, line, alpha, slide) in &lines {
|
||||
for &(i, line, alpha) in &lines {
|
||||
let bottom = chat_bottom - i as i32 * entry_height;
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Push(vec![PoseOp::Translate(slide, 0.0)]));
|
||||
}
|
||||
let opacity = alpha * text_opacity;
|
||||
let color = hud_layout::white(opacity);
|
||||
if color >> 24 != 0 {
|
||||
@@ -642,7 +612,7 @@ impl Chat {
|
||||
shadow: true,
|
||||
});
|
||||
}
|
||||
if let Some(tag) = line.tag.as_ref().filter(|_| !self.hide_indicator) {
|
||||
if let Some(tag) = &line.tag {
|
||||
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
|
||||
out.push(hud_layout::fill(
|
||||
-4,
|
||||
@@ -652,9 +622,6 @@ impl Chat {
|
||||
color,
|
||||
));
|
||||
}
|
||||
if slide != 0.0 {
|
||||
out.push(GuiDraw::Pop);
|
||||
}
|
||||
}
|
||||
if focused {
|
||||
let count = lines.len() as i32;
|
||||
@@ -691,7 +658,7 @@ impl Chat {
|
||||
}
|
||||
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
|
||||
/// open the restricted prompt above the lines.
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
|
||||
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
|
||||
let draws = self.layout(batch.height as i32, ticks, focused);
|
||||
hud_layout::paint(batch, atlas, &draws);
|
||||
if focused && self.abilities.any_restriction() {
|
||||
|
||||
@@ -39,61 +39,6 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
|
||||
let mut chat = Chat {
|
||||
smooth: true,
|
||||
..Chat::default()
|
||||
};
|
||||
chat.lines.push(Line {
|
||||
added: 0,
|
||||
content: Text::literal("hi"),
|
||||
tag: Some(std::sync::Arc::new(Tag::system_single_player(
|
||||
SYSTEM_INDICATOR,
|
||||
))),
|
||||
});
|
||||
let text_alpha = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Text { color, .. } => Some(color >> 24),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The slide is the negative translate the line carries while it moves.
|
||||
let slide = |draws: &[GuiDraw]| {
|
||||
draws.iter().find_map(|d| match d {
|
||||
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
|
||||
PoseOp::Translate(x, _) if *x < 0.0 => Some(*x),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
// The tick it arrives: fully faded out, so nothing is drawn.
|
||||
assert_eq!(text_alpha(&chat.layout(361, 0.0, false)), None);
|
||||
// Halfway through the intro: left of its place and still faint.
|
||||
let halfway = chat.layout(361, INTRO_TICKS / 2.0, false);
|
||||
assert!(slide(&halfway).is_some_and(|x| (-INTRO_WIDTH..0.0).contains(&x)));
|
||||
let faded = text_alpha(&halfway).expect("the line shows halfway through");
|
||||
// Settled: in place and as opaque as vanilla's line.
|
||||
let settled = chat.layout(361, INTRO_TICKS, false);
|
||||
assert_eq!(slide(&settled), None);
|
||||
assert!(text_alpha(&settled).expect("settled") > faded);
|
||||
// The extra off draws the line whole on the tick it arrives, as vanilla.
|
||||
chat.smooth = false;
|
||||
let plain = chat.layout(361, 0.0, false);
|
||||
assert_eq!(slide(&plain), None);
|
||||
assert_eq!(text_alpha(&plain), text_alpha(&settled));
|
||||
// The indicator bar is the -4 to -2 fill at the left of the line.
|
||||
let bar = |draws: &[GuiDraw]| {
|
||||
draws
|
||||
.iter()
|
||||
.any(|d| matches!(d, GuiDraw::Fill { x0: -4, x1: -2, .. }))
|
||||
};
|
||||
assert!(bar(&plain));
|
||||
chat.hide_indicator = true;
|
||||
assert!(!bar(&chat.layout(361, 0.0, false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_seed_copies_the_raw_value_only_inside_the_link() {
|
||||
let font = font();
|
||||
@@ -150,7 +95,7 @@ fn click_targets_match_rendered_spans_when_scaled_wrapped_and_scrolled() {
|
||||
for scroll in [0, 3, 12] {
|
||||
chat.scroll = scroll;
|
||||
let mut links = 0;
|
||||
for draw in chat.layout(361, 0.0, true) {
|
||||
for draw in chat.layout(361, 0, true) {
|
||||
if let GuiDraw::Text { text, x, y, .. } = draw {
|
||||
let mut at = x as f64;
|
||||
for (c, style) in text.chars() {
|
||||
|
||||
@@ -508,15 +508,6 @@ impl Chunk {
|
||||
}
|
||||
previous
|
||||
}
|
||||
/// Section `s`'s block states replaced whole (a bulk edit): its storage
|
||||
/// compacted, and the heightmaps built again on their next read.
|
||||
pub fn replace_blocks(&mut self, s: usize, values: &[u32; 4096]) {
|
||||
let section = Arc::make_mut(&mut self.sections[s]);
|
||||
section.blocks = Section::from_blocks(values);
|
||||
section.compact();
|
||||
self.surface = OnceLock::new();
|
||||
self.motion_blocking = OnceLock::new();
|
||||
}
|
||||
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
|
||||
/// chunk): one above the highest block that isn't `air`, or the column's
|
||||
/// minimum Y.
|
||||
|
||||
@@ -135,23 +135,6 @@ pub struct ModFluidTanks {
|
||||
/// The tanks a drain may take from (`canTakeItemThroughFace`).
|
||||
pub outputs: Vec<usize>,
|
||||
}
|
||||
/// The energy buffer of a mod block's block entity (NeoForge's
|
||||
/// `SimpleEnergyHandler`): its capacity, the most one insert and one extract
|
||||
/// moves, and the faces a fill may come through and a drain may leave by.
|
||||
/// The amount is not here: it lives in the entity's saved compound, under
|
||||
/// `energy`, so it survives restarts and the block's own tick reads it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ModEnergyBuffer {
|
||||
pub capacity: i32,
|
||||
/// The most one insert moves; 0 takes none.
|
||||
pub max_insert: i32,
|
||||
/// The most one extract moves; 0 gives none.
|
||||
pub max_extract: i32,
|
||||
/// The faces, by `Direction` number, a fill may come through.
|
||||
pub insert_faces: [bool; 6],
|
||||
/// The faces a drain may leave by.
|
||||
pub extract_faces: [bool; 6],
|
||||
}
|
||||
/// The vanilla menus a mod container can open, with the slot count each
|
||||
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
|
||||
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
|
||||
//! `horizon_optifine::cit`'s.
|
||||
|
||||
pub use horizon_optifine::cit::{Hand, Kind, Rule};
|
||||
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
|
||||
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
|
||||
let bytes = fs::read(root.join(&path)).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
|
||||
root.join(p).is_file()
|
||||
});
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
rules
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CitRules {
|
||||
pub rules: Vec<Rule>,
|
||||
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
|
||||
by_item: [HashMap<String, Vec<u32>>; 4],
|
||||
/// Enchantment rules without `items`, in priority order.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
const CACHE_LIMIT: usize = 4096;
|
||||
|
||||
impl CitRules {
|
||||
/// The enabled packs' rules under the overlay resource root.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
Self::new(load_rules(root))
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
pub fn new(rules: Vec<Rule>) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
Self {
|
||||
rules,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
|
||||
pub fn select(
|
||||
&self,
|
||||
kind: Kind,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
}
|
||||
|
||||
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
|
||||
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, Hand::Any),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
let found = hit.unwrap_or_else(|| {
|
||||
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
|
||||
all.sort_unstable();
|
||||
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Option<Hand>,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
|
||||
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
let text = format!("texture=x\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
CitRules::new(rules)
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// Glints are never an item's texture.
|
||||
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
|
||||
}
|
||||
}
|
||||
@@ -199,9 +199,8 @@ where
|
||||
}
|
||||
impl SceneCollisions<'_, '_> {
|
||||
/// Position contexts permit geometric movement probes without inventing a
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker or
|
||||
/// a living entity in leather boots collides as the living entity it is,
|
||||
/// empty-handed.
|
||||
/// player, inventory, effects or fluid-standing behavior; a lava walker
|
||||
/// collides as the living entity it is, empty-handed.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
bounds: Aabb,
|
||||
@@ -237,10 +236,7 @@ impl SceneCollisions<'_, '_> {
|
||||
}
|
||||
fn context(&self, bounds: Aabb) -> CollisionContext<'static> {
|
||||
let y = bounds.min_position().y;
|
||||
if !self
|
||||
.subject
|
||||
.is_some_and(|s| s.lava_walker || s.leather_boots)
|
||||
{
|
||||
if !self.subject.is_some_and(|s| s.lava_walker) {
|
||||
return CollisionContext::position(y);
|
||||
}
|
||||
CollisionContext::for_entity(
|
||||
|
||||
@@ -1,735 +0,0 @@
|
||||
//! OptiFine connected textures (`optifine/ctm`) at mesh time. The active packs' rules are read
|
||||
//! once at load, their tiles become sprite layers, and each block state and sprite layer gets
|
||||
//! the short list of rules that can touch it; meshing a face then walks only those lists
|
||||
//! ([`ConnectedTextures::pick`]). Without CTM files there are no rules and nothing changes.
|
||||
//!
|
||||
//! The rules and the tile geometry are `horizon_optifine::ctm`'s.
|
||||
|
||||
use crate::sprites::Sprites;
|
||||
use horizon_optifine::ctm::{
|
||||
self, Connect, Method, Orient, Rule, Tile,
|
||||
connect::{self as geometry, Axes, Axis, Face},
|
||||
};
|
||||
use horizon_world::block_state_properties::BlockProperties;
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// A tile as the mesher draws it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TileLayer {
|
||||
Layer {
|
||||
layer: u16,
|
||||
/// The sprite has fractional alpha (drawn in the translucent pass).
|
||||
translucent: bool,
|
||||
/// Every texel is opaque (drawn without an alpha test).
|
||||
opaque: bool,
|
||||
},
|
||||
/// Try the next rule.
|
||||
Skip,
|
||||
/// The face's own sprite.
|
||||
Default,
|
||||
}
|
||||
|
||||
/// What a face draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Pick {
|
||||
/// Its own sprite.
|
||||
Keep,
|
||||
Tile(TileLayer),
|
||||
/// `ctm_compact`: a tile per quarter of the sprite (top-left, top-right, bottom-left,
|
||||
/// bottom-right).
|
||||
Quarters([TileLayer; 4]),
|
||||
}
|
||||
|
||||
struct LoadedRule {
|
||||
rule: Rule,
|
||||
tiles: Vec<TileLayer>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct StateInfo {
|
||||
/// Index of the state's block (`connect=block`).
|
||||
block: u32,
|
||||
axis: Option<Axis>,
|
||||
/// `half=upper`: `linked` random tiles follow the lower half.
|
||||
upper: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ConnectedTextures {
|
||||
rules: Vec<LoadedRule>,
|
||||
/// By block state, then by sprite layer: ranges of `lists` (rule indices, in rule order).
|
||||
by_state: Vec<(u32, u32)>,
|
||||
by_layer: Vec<(u32, u32)>,
|
||||
lists: Vec<u16>,
|
||||
states: Vec<StateInfo>,
|
||||
/// By block state: the sprite of its model's face on each side (`connect=tile`); empty
|
||||
/// when no rule connects by tile.
|
||||
face_layers: Vec<[u16; 6]>,
|
||||
}
|
||||
|
||||
/// `list` appended to `lists`, as its range there.
|
||||
fn push_list(lists: &mut Vec<u16>, list: &[u16]) -> (u32, u32) {
|
||||
let start = lists.len() as u32;
|
||||
lists.extend_from_slice(list);
|
||||
(start, list.len() as u32)
|
||||
}
|
||||
|
||||
/// The sprite name of a texture resource path: `ns:path` under `textures/`, else the path
|
||||
/// itself (as [`Sprites::insert_file`] names a tile).
|
||||
fn sprite_name(resource: &str) -> String {
|
||||
let parts = resource
|
||||
.strip_prefix("assets/")
|
||||
.and_then(|p| p.split_once('/'))
|
||||
.and_then(|(ns, p)| Some((ns, p.strip_prefix("textures/")?.strip_suffix(".png")?)));
|
||||
match parts {
|
||||
Some((ns, path)) => format!("{ns}:{path}"),
|
||||
None => resource.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectedTextures {
|
||||
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
|
||||
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut rules = ctm::load(root);
|
||||
// Rules no face can use load no tiles: overlays (not drawn yet), and custom names (section
|
||||
// meshes see no block entity).
|
||||
rules.retain(|r| !r.method.is_overlay() && r.name.is_none());
|
||||
// Rule indices are `u16`.
|
||||
rules.truncate(usize::from(u16::MAX));
|
||||
if rules.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
// The tiles decode side by side; the loop below inserts them in rule order, under the
|
||||
// same names.
|
||||
let tiles: Vec<String> = rules
|
||||
.iter()
|
||||
.flat_map(|rule| &rule.tiles)
|
||||
.filter_map(|tile| match tile {
|
||||
Tile::Texture(path) => Some(sprite_name(path)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
sprites.prefetch(root, tiles.iter().map(String::as_str));
|
||||
let loaded = rules
|
||||
.into_iter()
|
||||
.filter_map(|rule| {
|
||||
let tiles = rule
|
||||
.tiles
|
||||
.iter()
|
||||
.map(|tile| match tile {
|
||||
Tile::Skip => Ok(TileLayer::Skip),
|
||||
Tile::Default => Ok(TileLayer::Default),
|
||||
Tile::Texture(path) => {
|
||||
// A tile among the textures is that sprite, under its own name.
|
||||
let layer = match sprite_name(path) {
|
||||
name if name != *path => sprites.insert(root, &name)?,
|
||||
_ => sprites.insert_file(root, path)?,
|
||||
};
|
||||
Ok(TileLayer::Layer {
|
||||
layer: u16::try_from(layer)?,
|
||||
translucent: sprites.translucent[layer as usize],
|
||||
opaque: sprites.opaque[layer as usize],
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect::<crate::Result<Vec<_>>>();
|
||||
match tiles {
|
||||
Ok(tiles) => Some(LoadedRule { rule, tiles }),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: connected textures {}: a tile: {e}", rule.path);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let states = (0..registry.len() as u32).map(|id| {
|
||||
registry
|
||||
.state(BlockStateId(id))
|
||||
.map(|s| (s.name.as_str(), &s.properties))
|
||||
});
|
||||
let mut out = Self::build(loaded, states, |name| sprites.layers.get(name).copied());
|
||||
if out.rules.iter().any(|r| r.rule.connect == Connect::Tile) {
|
||||
out.face_layers = (0..registry.len() as u32)
|
||||
.map(|id| {
|
||||
Face::ALL
|
||||
.map(|f| face_layer(BlockStateId(id), f).map_or(u16::MAX, |l| l as u16))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The lookups for `rules` (already ordered) over every block state (`None` for an id the
|
||||
/// registry skips), `layer` answering a sprite name's layer.
|
||||
fn build<'a>(
|
||||
rules: Vec<LoadedRule>,
|
||||
states: impl Iterator<Item = Option<(&'a str, &'a BlockProperties)>>,
|
||||
layer: impl Fn(&str) -> Option<u32>,
|
||||
) -> Self {
|
||||
let mut out = Self {
|
||||
rules,
|
||||
..Self::default()
|
||||
};
|
||||
let mut blocks = HashMap::new();
|
||||
let mut by_layer: Vec<Vec<u16>> = Vec::new();
|
||||
// Block rules by the block each entry names, so a state tests only its own.
|
||||
let mut by_block: HashMap<&str, Vec<u16>> = HashMap::new();
|
||||
for (i, r) in out.rules.iter().enumerate() {
|
||||
let i = i as u16;
|
||||
for tile in &r.rule.match_tiles {
|
||||
if let Some(l) = layer(&sprite_name(tile)) {
|
||||
let l = l as usize;
|
||||
if by_layer.len() <= l {
|
||||
by_layer.resize(l + 1, Vec::new());
|
||||
}
|
||||
if by_layer[l].last() != Some(&i) {
|
||||
by_layer[l].push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in &r.rule.match_blocks {
|
||||
let list = by_block.entry(m.id.as_str()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
for list in by_layer {
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_layer.push(range);
|
||||
}
|
||||
let mut list = Vec::new();
|
||||
for state in states {
|
||||
let Some((name, props)) = state else {
|
||||
out.by_state.push((0, 0));
|
||||
out.states.push(StateInfo::default());
|
||||
continue;
|
||||
};
|
||||
let next = blocks.len() as u32;
|
||||
let block = *blocks.entry(name).or_insert(next);
|
||||
let prop = |k: &str| props.get(k).map(String::as_str);
|
||||
out.states.push(StateInfo {
|
||||
block,
|
||||
axis: match prop("axis") {
|
||||
Some("x") => Some(Axis::X),
|
||||
Some("y") => Some(Axis::Y),
|
||||
Some("z") => Some(Axis::Z),
|
||||
_ => None,
|
||||
},
|
||||
upper: prop("half") == Some("upper"),
|
||||
});
|
||||
list.clear();
|
||||
if let Some(candidates) = by_block.get(name) {
|
||||
list.extend(
|
||||
candidates
|
||||
.iter()
|
||||
.filter(|&&i| out.rules[usize::from(i)].rule.matches_block(name, prop)),
|
||||
);
|
||||
}
|
||||
let range = push_list(&mut out.lists, &list);
|
||||
out.by_state.push(range);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
fn list(&self, ranges: &[(u32, u32)], i: usize) -> &[u16] {
|
||||
ranges.get(i).map_or(&[], |&(start, len)| {
|
||||
&self.lists[start as usize..(start + len) as usize]
|
||||
})
|
||||
}
|
||||
|
||||
/// What the `face` of the block `state` at `pos` draws, for a quad with sprite `layer`.
|
||||
/// `neighbour` answers the state at an offset from the block; `biome` its biome, asked only
|
||||
/// by rules that test one.
|
||||
pub fn pick<'b>(
|
||||
&self,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
pos: [i32; 3],
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
) -> Pick {
|
||||
let (mut by_state, mut by_layer) = (
|
||||
self.list(&self.by_state, state as usize),
|
||||
self.list(&self.by_layer, layer as usize),
|
||||
);
|
||||
// Both lists are in rule order: walk them as one. A rule naming blocks and tiles needs
|
||||
// both (it is in both lists); one naming either needs that one.
|
||||
loop {
|
||||
let (i, block, tile) = match (by_state.split_first(), by_layer.split_first()) {
|
||||
(None, None) => return Pick::Keep,
|
||||
(Some((&x, rest)), Some((&y, other))) if x == y => {
|
||||
(by_state, by_layer) = (rest, other);
|
||||
(x, true, true)
|
||||
}
|
||||
(Some((&x, rest)), Some((&y, _))) if x < y => {
|
||||
by_state = rest;
|
||||
(x, true, false)
|
||||
}
|
||||
(Some((&x, rest)), None) => {
|
||||
by_state = rest;
|
||||
(x, true, false)
|
||||
}
|
||||
(_, Some((&y, rest))) => {
|
||||
by_layer = rest;
|
||||
(y, false, true)
|
||||
}
|
||||
};
|
||||
let r = &self.rules[usize::from(i)];
|
||||
if (block || r.rule.match_blocks.is_empty())
|
||||
&& (tile || r.rule.match_tiles.is_empty())
|
||||
&& let Some(pick) = self.apply(r, state, layer, face, pos, &neighbour, &biome)
|
||||
{
|
||||
return pick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One rule on a face: `None` when it doesn't apply or picks `<skip>`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply<'b>(
|
||||
&self,
|
||||
r: &LoadedRule,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
pos: [i32; 3],
|
||||
neighbour: &impl Fn([i32; 3]) -> u32,
|
||||
biome: &impl Fn() -> Option<&'b str>,
|
||||
) -> Option<Pick> {
|
||||
let rule = &r.rule;
|
||||
// `Rule::applies_at`, with the biome looked up only for rules that test one. Rules with
|
||||
// a custom name are not loaded.
|
||||
if rule.faces & face.bit() == 0
|
||||
|| rule.heights.as_ref().is_some_and(|h| !h.contains(pos[1]))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(biomes) = &rule.biomes
|
||||
&& !biome().is_some_and(|b| biomes.matches(b))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let info = self.states.get(state as usize).copied().unwrap_or_default();
|
||||
let joins = |offset: [i32; 3]| {
|
||||
let other = neighbour(offset);
|
||||
match rule.connect {
|
||||
Connect::Block => {
|
||||
self.states.get(other as usize).map(|s| s.block) == Some(info.block)
|
||||
}
|
||||
Connect::State => other == state,
|
||||
Connect::Tile => self
|
||||
.face_layers
|
||||
.get(other as usize)
|
||||
.is_some_and(|f| f[face as usize] == layer),
|
||||
}
|
||||
};
|
||||
let axes = |face| match (rule.orient, info.axis) {
|
||||
(Orient::StateAxis, Some(axis)) => Axes::column(face, axis),
|
||||
// `orient=texture` follows the quad's own UVs; vanilla cubes are what it is used on.
|
||||
_ => Axes::cube(face),
|
||||
};
|
||||
let mask = || geometry::neighbours(axes(face), face.normal(), rule.inner_seams, joins);
|
||||
let tile = |i: usize| r.tiles.get(i).copied().unwrap_or(TileLayer::Default);
|
||||
let index = match rule.method {
|
||||
Method::Ctm => geometry::ctm_index(mask()),
|
||||
Method::CtmCompact => {
|
||||
let mask = mask();
|
||||
let case = geometry::ctm_index(mask) as u8;
|
||||
match rule.compact_overrides.iter().find(|&&(c, _)| c == case) {
|
||||
Some(&(_, t)) => t,
|
||||
None => {
|
||||
let quarters = geometry::compact_quarters(mask).map(|q| tile(q.into()));
|
||||
return if quarters.contains(&TileLayer::Skip) {
|
||||
None
|
||||
} else {
|
||||
Some(Pick::Quarters(quarters))
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Method::Horizontal => geometry::horizontal(mask()),
|
||||
Method::Vertical => geometry::vertical(mask()),
|
||||
Method::HorizontalVertical => geometry::horizontal_vertical(mask()),
|
||||
Method::VerticalHorizontal => geometry::vertical_horizontal(mask()),
|
||||
Method::Top => {
|
||||
// Only sides join the block above: the top and bottom go on to the next rule.
|
||||
if matches!(face, Face::Up | Face::Down) {
|
||||
return None;
|
||||
}
|
||||
if !joins([0, 1, 0]) {
|
||||
return Some(Pick::Keep);
|
||||
}
|
||||
0
|
||||
}
|
||||
Method::Fixed => 0,
|
||||
Method::Random => {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
let pos = if rule.linked && info.upper {
|
||||
[pos[0], pos[1] - 1, pos[2]]
|
||||
} else {
|
||||
pos
|
||||
};
|
||||
geometry::random_index(pos, face, rule.random_loops, &rule.weights, r.tiles.len())
|
||||
}
|
||||
Method::Repeat => {
|
||||
let face = geometry::symmetric_face(face, rule.symmetry);
|
||||
geometry::repeat_index(pos, axes(face), rule.width, rule.height)
|
||||
}
|
||||
// Overlays are not loaded yet.
|
||||
_ => return None,
|
||||
};
|
||||
match tile(index) {
|
||||
TileLayer::Skip => None,
|
||||
TileLayer::Default => Some(Pick::Keep),
|
||||
t => Some(Pick::Tile(t)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
/// A tile whose layer says which tile of its rule it is: rule `r`'s tile `i` is layer
|
||||
/// `1000 * (r + 1) + i`.
|
||||
fn rules(files: &[(&str, &str)]) -> Vec<LoadedRule> {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(path, text)| {
|
||||
Rule::parse(
|
||||
&format!("assets/minecraft/optifine/ctm/{path}"),
|
||||
&Properties::parse(text),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
ctm::sort(&mut rules);
|
||||
rules
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(n, rule)| LoadedRule {
|
||||
tiles: rule
|
||||
.tiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| match t {
|
||||
Tile::Skip => TileLayer::Skip,
|
||||
Tile::Default => TileLayer::Default,
|
||||
Tile::Texture(_) => TileLayer::Layer {
|
||||
layer: (1000 * (n + 1) + i) as u16,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
rule,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// State 0 air, 1 glass, 2 stone, 3 and 4 oak logs (axis y, x), 5 and 6 a door (lower,
|
||||
/// upper); the stone sprite is layer 7.
|
||||
fn world(files: &[(&str, &str)]) -> ConnectedTextures {
|
||||
let props = |pairs: &[(&str, &str)]| -> BlockProperties {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
};
|
||||
let states = [
|
||||
("minecraft:air", props(&[])),
|
||||
("minecraft:glass", props(&[])),
|
||||
("minecraft:stone", props(&[])),
|
||||
("minecraft:oak_log", props(&[("axis", "y")])),
|
||||
("minecraft:oak_log", props(&[("axis", "x")])),
|
||||
("minecraft:oak_door", props(&[("half", "lower")])),
|
||||
("minecraft:oak_door", props(&[("half", "upper")])),
|
||||
];
|
||||
ConnectedTextures::build(
|
||||
rules(files),
|
||||
states.iter().map(|(n, p)| Some((*n, p))),
|
||||
|name| (name == "minecraft:block/stone").then_some(7),
|
||||
)
|
||||
}
|
||||
|
||||
fn tile(layer: u16) -> Pick {
|
||||
Pick::Tile(TileLayer::Layer {
|
||||
layer,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// `pick` on `face` of the block at the origin among `blocks` (offset, state).
|
||||
fn pick_in(
|
||||
ctm: &ConnectedTextures,
|
||||
state: u32,
|
||||
layer: u16,
|
||||
face: Face,
|
||||
blocks: &[([i32; 3], u32)],
|
||||
) -> Pick {
|
||||
let at = |o: [i32; 3]| blocks.iter().find(|(p, _)| *p == o).map_or(0, |b| b.1);
|
||||
ctm.pick(state, layer, face, [0, 64, 0], at, || None)
|
||||
}
|
||||
|
||||
const GLASS: &str = "matchBlocks=glass\nmethod=ctm\ntiles=0-46";
|
||||
|
||||
#[test]
|
||||
fn glass_joins_its_neighbours() {
|
||||
let ctm = world(&[("glass.properties", GLASS)]);
|
||||
// Alone: tile 0. A north face's right is west: glass there is the left end of a row.
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(1000));
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1001)
|
||||
);
|
||||
// Glass all around in the face's plane: the middle tile, 26.
|
||||
let ring: Vec<_> = [-1, 0, 1]
|
||||
.into_iter()
|
||||
.flat_map(|x| [-1, 0, 1].map(|y| ([x, y, 0], 1)))
|
||||
.collect();
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &ring), tile(1026));
|
||||
// Stone beside it doesn't join; stone has no rule.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_order_faces_and_skips() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a_sides.properties",
|
||||
"matchBlocks=stone\nmethod=fixed\ntiles=<skip>\nfaces=sides",
|
||||
),
|
||||
("b_glass.properties", GLASS),
|
||||
("stone.properties", "method=fixed\ntiles=x\nfaces=top"),
|
||||
("z.properties", "matchBlocks=stone\nmethod=fixed\ntiles=y"),
|
||||
]);
|
||||
// The tile rule (stone.properties, by its file name) comes first, on the top face only.
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), tile(1000));
|
||||
// Sides: a_sides skips, so z draws.
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::North, &[]), tile(4000));
|
||||
// The tile rule follows the sprite, whatever the block: glass with the stone sprite.
|
||||
assert_eq!(pick_in(&ctm, 1, 7, Face::Up, &[]), tile(1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_quarters_and_overrides() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=ctm_compact\ntiles=0-5\nctm.1=5",
|
||||
)]);
|
||||
let layer = |i: u16| TileLayer::Layer {
|
||||
layer: 1000 + i,
|
||||
translucent: false,
|
||||
opaque: false,
|
||||
};
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[]),
|
||||
Pick::Quarters([layer(0); 4])
|
||||
);
|
||||
// Joined right only is case 1, replaced whole by tile 5.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1005)
|
||||
);
|
||||
// Joined left only (east of a north face): quarters [3, 0, 3, 0].
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([1, 0, 0], 1)]),
|
||||
Pick::Quarters([layer(3), layer(0), layer(3), layer(0)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_turn_with_their_axis() {
|
||||
let ctm = world(&[(
|
||||
"log.properties",
|
||||
"matchBlocks=oak_log\nmethod=vertical\ntiles=0-3",
|
||||
)]);
|
||||
// An upright log with a log above: the bottom of a column.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
|
||||
tile(1000)
|
||||
);
|
||||
// A log lying along x: on its north face the texture's up points east, so a log to the
|
||||
// east (same block, other axis state) is above it.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 4, 3, Face::North, &[([1, 0, 0], 4)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 4, 3, Face::North, &[([0, 1, 0], 4)]),
|
||||
tile(1003)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_and_connect_state() {
|
||||
let ctm = world(&[(
|
||||
"block_oak_log.properties",
|
||||
"method=top\ntiles=0\nconnect=state",
|
||||
)]);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 3)]),
|
||||
tile(1000)
|
||||
);
|
||||
// The other axis is another state.
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 3, 3, Face::North, &[([0, 1, 0], 4)]),
|
||||
Pick::Keep
|
||||
);
|
||||
assert_eq!(pick_in(&ctm, 3, 3, Face::Up, &[([0, 1, 0], 3)]), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_random_follows_the_lower_half() {
|
||||
let ctm = world(&[(
|
||||
"door.properties",
|
||||
"matchBlocks=oak_door\nmethod=random\ntiles=0-7\nlinked=true",
|
||||
)]);
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
for x in 0..20 {
|
||||
let lower = ctm.pick(5, 3, Face::North, [x, 64, 0], |_| 0, || None);
|
||||
let upper = ctm.pick(6, 3, Face::North, [x, 65, 0], |_| 0, || None);
|
||||
assert_eq!(lower, upper, "{x}");
|
||||
tiles.insert(format!("{lower:?}"));
|
||||
}
|
||||
assert!(tiles.len() > 3, "the doors are not all alike");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heights_and_biomes() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nheights=0-63\nbiomes=desert",
|
||||
)]);
|
||||
let at =
|
||||
|y, biome: &'static str| ctm.pick(1, 3, Face::Up, [0, y, 0], |_| 0, || Some(biome));
|
||||
assert_eq!(at(10, "minecraft:desert"), tile(1000));
|
||||
assert_eq!(at(64, "minecraft:desert"), Pick::Keep);
|
||||
assert_eq!(at(10, "minecraft:plains"), Pick::Keep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_by_tile_reads_the_neighbours_same_side() {
|
||||
let mut ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=horizontal\nconnect=tile\ntiles=0-3",
|
||||
)]);
|
||||
// Stone shows the glass sprite (3) on its north side; the door only on its south.
|
||||
let mut faces = vec![[9u16; 6]; 7];
|
||||
faces[1][Face::North as usize] = 3;
|
||||
faces[2][Face::North as usize] = 3;
|
||||
faces[5][Face::South as usize] = 3;
|
||||
ctm.face_layers = faces;
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 2)]),
|
||||
tile(1000)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&ctm, 1, 3, Face::North, &[([-1, 0, 0], 5)]),
|
||||
tile(1003)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeat_and_rows_then_columns() {
|
||||
let ctm = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=repeat\nwidth=2\nheight=2\ntiles=0-3",
|
||||
)]);
|
||||
let at = |pos| ctm.pick(1, 3, Face::North, pos, |_| 0, || None);
|
||||
assert_eq!(at([0, 64, 0]), tile(1000));
|
||||
// The north face's right is west; rows run down.
|
||||
assert_eq!(at([-1, 64, 0]), tile(1001));
|
||||
assert_eq!(at([0, 63, 0]), tile(1002));
|
||||
let hv = world(&[(
|
||||
"glass.properties",
|
||||
"matchBlocks=glass\nmethod=horizontal+vertical\ntiles=0-6",
|
||||
)]);
|
||||
// Alone in its row, glass above: the column's bottom, tile 4.
|
||||
assert_eq!(
|
||||
pick_in(&hv, 1, 3, Face::North, &[([0, 1, 0], 1)]),
|
||||
tile(1004)
|
||||
);
|
||||
assert_eq!(
|
||||
pick_in(&hv, 1, 3, Face::North, &[([-1, 0, 0], 1)]),
|
||||
tile(1000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_defaults_and_narrowed_rules() {
|
||||
let ctm = world(&[
|
||||
(
|
||||
"a.properties",
|
||||
"matchBlocks=glass\nmethod=ctm_compact\ntiles=<skip> 1-4",
|
||||
),
|
||||
(
|
||||
"b.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=sides",
|
||||
),
|
||||
(
|
||||
"c.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=<default>",
|
||||
),
|
||||
("d.properties", "matchBlocks=glass\nmethod=fixed\ntiles=0"),
|
||||
(
|
||||
"e.properties",
|
||||
"matchBlocks=stone\nmatchTiles=glass_pane_top\nmethod=fixed\ntiles=0",
|
||||
),
|
||||
]);
|
||||
// e names a tile, so it is tried first (tile 1000s), but needs a stone with the pane
|
||||
// sprite. a's skipped quarter drops it; b (3000s) draws the sides; on top c draws the
|
||||
// glass itself, which stops d.
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::North, &[]), tile(3000));
|
||||
assert_eq!(pick_in(&ctm, 1, 3, Face::Up, &[]), Pick::Keep);
|
||||
assert_eq!(pick_in(&ctm, 2, 7, Face::Up, &[]), Pick::Keep);
|
||||
let both = world(&[(
|
||||
"e.properties",
|
||||
"matchBlocks=glass\nmatchTiles=stone\nmethod=fixed\ntiles=0",
|
||||
)]);
|
||||
assert_eq!(pick_in(&both, 1, 7, Face::Up, &[]), tile(1000));
|
||||
assert_eq!(pick_in(&both, 1, 3, Face::Up, &[]), Pick::Keep);
|
||||
assert_eq!(pick_in(&both, 2, 7, Face::Up, &[]), Pick::Keep);
|
||||
// `top` passes the top face on.
|
||||
let top = world(&[
|
||||
("block_glass.properties", "method=top\ntiles=0"),
|
||||
(
|
||||
"z.properties",
|
||||
"matchBlocks=glass\nmethod=fixed\ntiles=0\nfaces=top",
|
||||
),
|
||||
]);
|
||||
assert_eq!(pick_in(&top, 1, 3, Face::Up, &[]), tile(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_names() {
|
||||
assert_eq!(
|
||||
sprite_name("assets/minecraft/textures/block/dirt.png"),
|
||||
"minecraft:block/dirt"
|
||||
);
|
||||
assert_eq!(
|
||||
sprite_name("assets/minecraft/optifine/ctm/glass/0.png"),
|
||||
"assets/minecraft/optifine/ctm/glass/0.png"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
pub use crate::chunk::block_entity::{
|
||||
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
|
||||
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
|
||||
ModEnergyBuffer, ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
|
||||
};
|
||||
use crate::items::ItemStack;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -195,6 +195,14 @@ fn read(path: &str) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
fn windows_processor_name(query: &str) -> Option<String> {
|
||||
query.lines().find_map(|line| {
|
||||
let (_, value) = line.split_once("REG_SZ")?;
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
|
||||
fn operating_system() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
@@ -212,7 +220,7 @@ fn operating_system() -> String {
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sw_vers", &["-productVersion"]).map(|v| format!("macOS {v}"))
|
||||
} else if cfg!(windows) {
|
||||
horizon_telemetry::system::windows_version()
|
||||
command_output("cmd", &["/C", "ver"])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -227,11 +235,26 @@ fn operating_system() -> String {
|
||||
pub fn cpu() -> String {
|
||||
let threads = std::thread::available_parallelism().map_or(0, |n| n.get());
|
||||
let name = if cfg!(target_os = "linux") {
|
||||
read("/proc/cpuinfo").and_then(|t| horizon_telemetry::system::linux_cpu_name(&t))
|
||||
read("/proc/cpuinfo").and_then(|t| {
|
||||
t.lines()
|
||||
.find(|l| l.starts_with("model name"))
|
||||
.and_then(|l| l.split_once(':'))
|
||||
.map(|(_, v)| v.trim().to_owned())
|
||||
})
|
||||
} else if cfg!(target_os = "macos") {
|
||||
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
|
||||
} else if cfg!(windows) {
|
||||
horizon_telemetry::system::windows_cpu()
|
||||
command_output(
|
||||
"reg",
|
||||
&[
|
||||
"query",
|
||||
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
|
||||
"/v",
|
||||
"ProcessorNameString",
|
||||
],
|
||||
)
|
||||
.and_then(|query| windows_processor_name(&query))
|
||||
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
|
||||
} else {
|
||||
std::env::var("PROCESSOR_IDENTIFIER").ok()
|
||||
};
|
||||
@@ -384,30 +407,6 @@ pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Opti
|
||||
)
|
||||
}
|
||||
|
||||
/// The report for a game that went over the player's RAM limit (`ram_limit`), written by the
|
||||
/// watcher thread: it says nothing about where the memory went, so it carries no stack. The player
|
||||
/// chose this limit, so it is no bug to send (`telemetry::keep_crash`), and leaving that out ends
|
||||
/// the game seconds sooner: it keeps running, over its limit, until the report is written.
|
||||
pub fn report_memory_limit(home: &Path, side: &'static str, message: String) {
|
||||
if REPORTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let report = Report {
|
||||
time: Local::now(),
|
||||
description: "RAM limit reached".into(),
|
||||
error: message,
|
||||
stack: String::new(),
|
||||
thread: thread_name(side),
|
||||
side,
|
||||
details: system_details(side),
|
||||
log: crate::logging::recent_lines(),
|
||||
};
|
||||
match report.save(home) {
|
||||
Ok(path) => eprintln!("{SAVED_LINE}{}", path.display()),
|
||||
Err(e) => eprintln!("Horizon: could not save a crash report: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
@@ -658,6 +657,22 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
|
||||
let query = r#"
|
||||
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
|
||||
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
|
||||
"#;
|
||||
assert_eq!(
|
||||
windows_processor_name(query).as_deref(),
|
||||
Some("13th Gen Intel(R) Core(TM) i7-13700F")
|
||||
);
|
||||
assert_eq!(
|
||||
windows_processor_name("ProcessorNameString REG_SZ "),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_reports_its_built_in_version() {
|
||||
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
|
||||
|
||||
@@ -575,7 +575,7 @@ pub struct DataStore {
|
||||
/// `item_modifier` files, for `/item modify`.
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
/// merge them over what they read from the resource root.
|
||||
pub files: BTreeMap<String, Vec<Vec<u8>>>,
|
||||
@@ -584,7 +584,7 @@ pub struct DataStore {
|
||||
}
|
||||
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
|
||||
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// `villager_trade/`, `dialog/` or `tags/<sub>/`, where the id keeps the sub
|
||||
/// path so a nested tag kind stays addressable.
|
||||
fn content_id(file: &str) -> Option<String> {
|
||||
let rest = file.strip_prefix("data/")?;
|
||||
@@ -592,7 +592,7 @@ fn content_id(file: &str) -> Option<String> {
|
||||
let (kind, rest) = rest.split_once('/')?;
|
||||
let kind = match kind {
|
||||
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
|
||||
| "villager_trade" | "dialog" | "data_maps" => kind,
|
||||
| "villager_trade" | "dialog" => kind,
|
||||
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
|
||||
// test:sub/mytag`), the whole path after the namespace, so nested
|
||||
// registries keep their directory (`TagLoader`'s id shape).
|
||||
@@ -623,7 +623,6 @@ impl DataStore {
|
||||
let builtin = pack.id == "vanilla";
|
||||
for f in pack.list_kinds(&[
|
||||
"advancement",
|
||||
"data_maps",
|
||||
"dialog",
|
||||
"function",
|
||||
"item_modifier",
|
||||
@@ -883,15 +882,6 @@ pub fn worldgen_overrides(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn data_map_files_are_content_files_keyed_by_map_namespace_and_registry() {
|
||||
assert_eq!(
|
||||
content_id("data/mymod/data_maps/item/fuel.json").as_deref(),
|
||||
Some("data_maps/mymod:item/fuel")
|
||||
);
|
||||
assert_eq!(content_id("data/mymod/data_maps/item/fuel.txt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
|
||||
@@ -111,10 +111,6 @@ pub struct Extras {
|
||||
pub sort: bool,
|
||||
/// A vanilla key name: sorts the part under the cursor.
|
||||
pub sort_key: String,
|
||||
/// A new chat line slides in from the left while it fades in (`Chat::layout`).
|
||||
pub chat_animation: bool,
|
||||
/// The indicator bar at the left of a chat line (on by default, as vanilla).
|
||||
pub chat_indicator: bool,
|
||||
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
|
||||
pub no_ai: bool,
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
@@ -156,8 +152,6 @@ impl Default for Extras {
|
||||
mouse_tweaks_wheel: true,
|
||||
sort: false,
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
chat_animation: false,
|
||||
chat_indicator: true,
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
@@ -236,8 +230,6 @@ impl Extras {
|
||||
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
|
||||
"sort" => e.sort = flag(e.sort),
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
"chatAnimation" => e.chat_animation = flag(e.chat_animation),
|
||||
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
@@ -249,7 +241,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -282,8 +274,6 @@ impl Extras {
|
||||
bool_text(self.mouse_tweaks_wheel),
|
||||
bool_text(self.sort),
|
||||
self.sort_key,
|
||||
bool_text(self.chat_animation),
|
||||
bool_text(self.chat_indicator),
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
@@ -416,10 +406,6 @@ pub enum Item {
|
||||
MouseTweaksWheel,
|
||||
Sort,
|
||||
SortKey,
|
||||
/// A new chat line slides in and fades in (`Chat::layout`).
|
||||
ChatAnimation,
|
||||
/// The coloured bar at the left of a chat line.
|
||||
ChatIndicator,
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
@@ -428,84 +414,29 @@ pub enum Item {
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
}
|
||||
/// The Extras page: headers (language keys) and items.
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
use Item::*;
|
||||
vec![
|
||||
Err("horizon.extras.header.lighting"),
|
||||
Err("Lighting"),
|
||||
Ok(Fullbright),
|
||||
Ok(DynamicLights),
|
||||
Err("horizon.extras.header.third_person"),
|
||||
Err("Third person"),
|
||||
Ok(OwnNameTag),
|
||||
Err("horizon.extras.header.particles"),
|
||||
Err("Particles"),
|
||||
Ok(HideBlockParticles),
|
||||
Err("horizon.extras.header.inventory"),
|
||||
Err("Inventory"),
|
||||
Ok(MouseTweaks),
|
||||
Ok(MouseTweaksWheel),
|
||||
Ok(Sort),
|
||||
Ok(SortKey),
|
||||
Err("horizon.extras.header.chat"),
|
||||
Ok(ChatAnimation),
|
||||
Ok(ChatIndicator),
|
||||
Err("horizon.extras.header.multiplayer"),
|
||||
Err("Multiplayer"),
|
||||
Ok(AutoReconnect),
|
||||
Err("horizon.extras.header.sound"),
|
||||
Err("Sound"),
|
||||
Ok(UiSoundReserve),
|
||||
]
|
||||
}
|
||||
impl Item {
|
||||
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
|
||||
pub fn caption_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
|
||||
Item::Sort => "horizon.extras.sort",
|
||||
Item::SortKey => "horizon.extras.sort_key",
|
||||
Item::NoAi => "horizon.extras.no_ai",
|
||||
Item::AgentControl => "horizon.extras.agent_control",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
}
|
||||
}
|
||||
/// The language key of the description shown as the row's tooltip.
|
||||
pub fn description_key(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "horizon.extras.fullbright.tooltip",
|
||||
Item::DynamicLights => "horizon.extras.dynamic_lights.tooltip",
|
||||
Item::OwnNameTag => "horizon.extras.own_name_tag.tooltip",
|
||||
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
|
||||
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
|
||||
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
|
||||
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
|
||||
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
|
||||
Item::Sort => "horizon.extras.sort.tooltip",
|
||||
Item::SortKey => "horizon.extras.sort_key.tooltip",
|
||||
Item::NoAi => "horizon.extras.no_ai.tooltip",
|
||||
Item::AgentControl => "horizon.extras.agent_control.tooltip",
|
||||
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
|
||||
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
}
|
||||
}
|
||||
/// The caption in the current language (English when the key is missing).
|
||||
pub fn caption_text(self) -> String {
|
||||
crate::lang::get_or(self.caption_key(), self.caption())
|
||||
}
|
||||
/// The description in the current language (English when the key is missing).
|
||||
pub fn description_text(self) -> String {
|
||||
crate::lang::get_or(self.description_key(), self.description())
|
||||
}
|
||||
/// The English caption: the fallback of [`Item::caption_text`], and the text of
|
||||
/// `horizon.extras.*` in en_us.json.
|
||||
pub fn caption(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Fullbright",
|
||||
@@ -518,8 +449,6 @@ impl Item {
|
||||
Item::MouseTweaksWheel => "Scroll wheel moves items",
|
||||
Item::Sort => "Sort buttons",
|
||||
Item::SortKey => "Sort key",
|
||||
Item::ChatAnimation => "Smooth chat",
|
||||
Item::ChatIndicator => "Message indicator",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
@@ -533,15 +462,13 @@ impl Item {
|
||||
UiSoundReserve => return None,
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
|
||||
| ChatAnimation => {
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
}
|
||||
/// The English description: the fallback of [`Item::description_text`].
|
||||
/// What the row says under its caption.
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
|
||||
@@ -572,10 +499,6 @@ impl Item {
|
||||
Item::SortKey => {
|
||||
"Sorts the part under the cursor. Click, then press a key; Escape unbinds it."
|
||||
}
|
||||
Item::ChatAnimation => {
|
||||
"A new chat line slides in from the left while it fades in, instead of appearing whole."
|
||||
}
|
||||
Item::ChatIndicator => "The coloured bar at the left of a chat line.",
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
@@ -597,7 +520,6 @@ impl Item {
|
||||
Item::Fullbright => Some(Cost::None),
|
||||
Item::UiSoundReserve => Some(Cost::None),
|
||||
Item::DynamicLights => Some(Cost::Low),
|
||||
Item::ChatAnimation => Some(Cost::None),
|
||||
Item::OwnNameTag
|
||||
| Item::HideBlockParticles
|
||||
| Item::AutoReconnect
|
||||
@@ -619,8 +541,6 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort,
|
||||
Item::ChatAnimation => e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
@@ -633,8 +553,8 @@ impl Item {
|
||||
match self {
|
||||
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
|
||||
other => match other.switch(e) {
|
||||
Some(true) => crate::lang::get_or("On", "On"),
|
||||
_ => crate::lang::get_or("Off", "Off"),
|
||||
Some(true) => "On".into(),
|
||||
_ => "Off".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -651,8 +571,6 @@ impl Item {
|
||||
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
|
||||
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
|
||||
Item::Sort => e.sort = !e.sort,
|
||||
Item::ChatAnimation => e.chat_animation = !e.chat_animation,
|
||||
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
@@ -715,8 +633,6 @@ mod tests {
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.chat_animation = true;
|
||||
e.chat_indicator = false;
|
||||
e.discord_presence = false;
|
||||
assert!(!e.splitscreen);
|
||||
Item::Splitscreen.cycle(&mut e, false);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -252,18 +252,6 @@ impl Arena {
|
||||
pub fn page_vertices(&self) -> u64 {
|
||||
self.page_limit
|
||||
}
|
||||
/// `page`'s buffer up to the end of its last range in use, as draws bind it:
|
||||
/// wgpu clears what a binding covers and nothing wrote yet when the frame is
|
||||
/// submitted, most of a page just made or grown.
|
||||
pub fn binding(&self, page: u32) -> wgpu::BufferSlice<'_> {
|
||||
let p = self.pages[page as usize].as_ref().expect("live arena page");
|
||||
let end = match p.free.0.last_key_value() {
|
||||
Some((&start, &len)) if start + len == p.capacity => start,
|
||||
_ => p.capacity,
|
||||
};
|
||||
// A page a draw names holds a range; wgpu refuses an empty slice.
|
||||
p.buffer.slice(..end.max(GRAIN) * self.stride)
|
||||
}
|
||||
pub fn has_page(&self, page: u32) -> bool {
|
||||
self.pages.get(page as usize).is_some_and(Option::is_some)
|
||||
}
|
||||
@@ -796,32 +784,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws bind a large page up to the end of its last range in use, a grain at
|
||||
/// least: wgpu clears what a binding covers and nothing wrote when the frame is
|
||||
/// submitted, so a grown page bound whole had its new tail cleared on the GPU.
|
||||
#[test]
|
||||
fn large_pages_are_bound_up_to_their_last_range_in_use() {
|
||||
let Some((device, queue)) = device() else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
return;
|
||||
};
|
||||
let mut arena = Arena::translucent(&device, 24, GRAIN);
|
||||
let ranges: Vec<_> = (0..3)
|
||||
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
|
||||
.collect();
|
||||
// The third grain grew the page to four.
|
||||
assert_eq!(arena.buffer(0).size(), 4 * GRAIN * 24);
|
||||
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
|
||||
// A free range before the last in use stays bound, none after it; an empty
|
||||
// page binds a grain (wgpu refuses an empty slice).
|
||||
arena.release(0, ranges[1].1, GRAIN);
|
||||
assert_eq!(arena.binding(0).size(), 3 * GRAIN * 24);
|
||||
arena.release(0, ranges[2].1, GRAIN);
|
||||
assert_eq!(arena.binding(0).size(), GRAIN * 24);
|
||||
arena.release(0, ranges[0].1, GRAIN);
|
||||
assert_eq!(arena.binding(0).size(), GRAIN * 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
|
||||
let Some((device, queue)) = device() else {
|
||||
|
||||
@@ -585,25 +585,23 @@ impl Cull {
|
||||
self.current = Some(self.next);
|
||||
}
|
||||
|
||||
/// The frame's results to read back, started by calling it right after the frame's
|
||||
/// submission (on the submit thread, when the frame goes there).
|
||||
pub fn submitted(&mut self) -> Option<impl FnOnce() + Send + 'static> {
|
||||
let i = self.current.take()?;
|
||||
/// After the frame's submission: start reading its results back.
|
||||
pub fn submitted(&mut self) {
|
||||
let Some(i) = self.current.take() else {
|
||||
return;
|
||||
};
|
||||
let r = &self.readbacks[i];
|
||||
let state = r.state.clone();
|
||||
state.store(PENDING, Ordering::Release);
|
||||
let (buffer, bytes) = (r.buffer.clone(), r.slots.len() as u64 * 4);
|
||||
r.buffer
|
||||
.slice(..r.slots.len() as u64 * 4)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
self.next = (i + 1) % READBACKS;
|
||||
Some(move || {
|
||||
buffer
|
||||
.slice(..bytes)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
state.store(
|
||||
if result.is_ok() { MAPPED } else { FREE },
|
||||
Ordering::Release,
|
||||
);
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
fn make_group(
|
||||
|
||||
@@ -1,572 +1,11 @@
|
||||
//! Frame generation (a Horizon setting, off by default): FSR 3.1's frame interpolation
|
||||
//! makes a frame halfway between each two real ones. The world, without the hand and
|
||||
//! HUD, is kept at the screen's size each frame; optical flow and the interpolation run
|
||||
//! on it, the hand and HUD are drawn over the made frame as over the real one, and a
|
||||
//! present thread shows the made frame, then the real one half a real frame later.
|
||||
//!
|
||||
//! The renderer draws into frames of its own while this is on; the present thread
|
||||
//! copies each to the swapchain, which it alone acquires and presents.
|
||||
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
|
||||
//! two real ones. So far this holds its optical flow, its interpolation and the motion
|
||||
//! vectors of what moves on its own; the setting that runs them comes next.
|
||||
|
||||
// Unused until the renderer runs them.
|
||||
#[allow(dead_code)]
|
||||
mod interpolation;
|
||||
#[allow(dead_code)]
|
||||
mod motion;
|
||||
#[allow(dead_code)]
|
||||
mod optical_flow;
|
||||
|
||||
use glam::{DMat4, DVec3, Mat4};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// Frames to draw into: a pair being drawn, a pair waiting and a pair being shown.
|
||||
const FRAMES: usize = 6;
|
||||
|
||||
/// `HORIZON_FRAMEGEN_DEBUG=generated`: show only made frames (AMD's debug view), so a
|
||||
/// screenshot or an offscreen frame is the made one; `motion`: the same, each made frame
|
||||
/// showing what moved on its own in blue (its motion vectors in red and green).
|
||||
pub(super) fn generated_only() -> bool {
|
||||
debug().is_some()
|
||||
}
|
||||
|
||||
fn debug() -> Option<&'static str> {
|
||||
static DEBUG: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
||||
DEBUG
|
||||
.get_or_init(|| {
|
||||
std::env::var("HORIZON_FRAMEGEN_DEBUG")
|
||||
.ok()
|
||||
.filter(|v| v == "generated" || v == "motion")
|
||||
})
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
pub struct FrameGen {
|
||||
pub on: bool,
|
||||
/// The present thread failed to show a frame (or died): until the renderer has
|
||||
/// acquired a frame itself, the normal path runs and handles the surface.
|
||||
pub paused: bool,
|
||||
gpu: Option<Gpu>,
|
||||
presenter: Option<Presenter>,
|
||||
/// When the last real frame was handed over, and the time between real frames.
|
||||
last: Option<Instant>,
|
||||
interval: Duration,
|
||||
/// The last frame's unjittered `view_proj` and camera.
|
||||
previous: Option<(DMat4, DVec3)>,
|
||||
/// The last frame's hand vertices and the bob and view they were drawn with.
|
||||
hand: Vec<crate::mesher::Vertex>,
|
||||
hand_view: Mat4,
|
||||
/// Offscreen, the frame the made frame is drawn into.
|
||||
offscreen_generated: Option<wgpu::Texture>,
|
||||
/// Motion vectors for what moves on its own, made on the first frame.
|
||||
motion: Option<motion::ObjectMotion>,
|
||||
/// Real frames and made ones handed to the screen since `shown_since`, and the share
|
||||
/// of real frames that had a made frame over the last half second (the F3 screen's),
|
||||
/// `None` while off.
|
||||
real: u32,
|
||||
made: u32,
|
||||
shown_since: Instant,
|
||||
pub made_share: Option<f32>,
|
||||
}
|
||||
|
||||
/// What runs on the GPU for one screen size.
|
||||
struct Gpu {
|
||||
size: [u32; 2],
|
||||
flow: optical_flow::OpticalFlow,
|
||||
interpolation: interpolation::Interpolation,
|
||||
/// This frame's world at the screen's size, and bindings that copy it or the
|
||||
/// interpolation's output (`Post::finish_with`).
|
||||
world: wgpu::TextureView,
|
||||
world_group: wgpu::BindGroup,
|
||||
output_group: wgpu::BindGroup,
|
||||
/// The next interpolation has no previous frame.
|
||||
reset: bool,
|
||||
}
|
||||
|
||||
/// This frame's camera, for the interpolation.
|
||||
pub struct Camera {
|
||||
/// The world's projection this frame (with TAA's jitter) and without the jitter.
|
||||
pub jittered: Mat4,
|
||||
pub view_proj: Mat4,
|
||||
pub depth: wgpu::TextureView,
|
||||
pub render_size: [u32; 2],
|
||||
pub jitter: [f32; 2],
|
||||
/// This frame's unjittered clip space to the previous frame's, `None` without one.
|
||||
pub reproject: Option<Mat4>,
|
||||
pub near: f32,
|
||||
pub far: f32,
|
||||
pub fov_y: f32,
|
||||
}
|
||||
|
||||
impl FrameGen {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
on: false,
|
||||
paused: false,
|
||||
gpu: None,
|
||||
presenter: None,
|
||||
last: None,
|
||||
interval: Duration::from_millis(16),
|
||||
previous: None,
|
||||
hand: Vec::new(),
|
||||
hand_view: Mat4::IDENTITY,
|
||||
offscreen_generated: None,
|
||||
motion: None,
|
||||
real: 0,
|
||||
made: 0,
|
||||
shown_since: Instant::now(),
|
||||
made_share: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends frame generation's frames and present thread: before the surface is
|
||||
/// reconfigured or the renderer acquires from it itself.
|
||||
pub fn stop(&mut self) {
|
||||
self.presenter = None;
|
||||
self.made_share = None;
|
||||
(self.real, self.made) = (0, 0);
|
||||
self.last = None;
|
||||
self.previous = None;
|
||||
self.hand.clear();
|
||||
if let Some(motion) = &mut self.motion {
|
||||
motion.reset();
|
||||
}
|
||||
if let Some(gpu) = &mut self.gpu {
|
||||
gpu.reset = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The hand for the made frame, appended to `out`: each vertex halfway between where
|
||||
/// it was on screen last frame and where it is now (the hand swings and bobs on its
|
||||
/// own, so the world's interpolation can't move it), in this frame's space
|
||||
/// (`view`: this frame's bob and view). This frame's hand where the two differ in
|
||||
/// shape (another item, the first frame).
|
||||
pub fn halfway_hand(
|
||||
&mut self,
|
||||
hand: &[crate::mesher::Vertex],
|
||||
view: Mat4,
|
||||
out: &mut Vec<crate::mesher::Vertex>,
|
||||
) {
|
||||
let same = self.hand.len() == hand.len()
|
||||
&& self
|
||||
.hand
|
||||
.iter()
|
||||
.zip(hand)
|
||||
.all(|(a, b)| a.layer == b.layer && a.uv == b.uv);
|
||||
if same {
|
||||
let back = view.inverse();
|
||||
out.extend(self.hand.iter().zip(hand).map(|(before, now)| {
|
||||
let then = self.hand_view.transform_point3(before.pos.into());
|
||||
let mid = (then + view.transform_point3(now.pos.into())) * 0.5;
|
||||
crate::mesher::Vertex {
|
||||
pos: back.transform_point3(mid).into(),
|
||||
..*now
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
out.extend_from_slice(hand);
|
||||
}
|
||||
self.hand.clear();
|
||||
self.hand.extend_from_slice(hand);
|
||||
self.hand_view = view;
|
||||
}
|
||||
|
||||
/// This frame's entity vertices (relative to `camera`) and the entity ids of their
|
||||
/// spans (`Frame::entity_motion`), for their own motion vectors; `atlas`: the
|
||||
/// entities' textures. Before `generate`, every frame.
|
||||
pub fn track_entities(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
atlas: &wgpu::TextureView,
|
||||
entities: &[crate::mesher::Vertex],
|
||||
spans: &[(u32, u64)],
|
||||
camera: DVec3,
|
||||
) {
|
||||
self.motion
|
||||
.get_or_insert_with(|| motion::ObjectMotion::new(device, atlas.clone()))
|
||||
.track(entities, spans, camera);
|
||||
}
|
||||
|
||||
/// This frame's unjittered clip space to the last frame's, from their `view_proj`s
|
||||
/// (relative to `camera`); `None` on the first frame.
|
||||
pub fn camera_motion(&mut self, view_proj: Mat4, camera: DVec3) -> Option<Mat4> {
|
||||
let current = view_proj.as_dmat4();
|
||||
let previous = self.previous.replace((current, camera));
|
||||
previous.map(|(p, pc)| super::world_post::reprojection(p, pc, current, camera).as_mat4())
|
||||
}
|
||||
|
||||
/// Frames to draw the real and the made frame into, from the present thread's
|
||||
/// (started for this `config`'s surface) or, offscreen, the offscreen target and
|
||||
/// one of its own. `None` once the present thread failed or died: it is stopped and
|
||||
/// frame generation `paused`.
|
||||
pub fn frames(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
surface: Option<&Arc<wgpu::Surface<'static>>>,
|
||||
offscreen: Option<&wgpu::Texture>,
|
||||
window: &Arc<winit::window::Window>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Option<(wgpu::Texture, wgpu::Texture)> {
|
||||
let Some(surface) = surface else {
|
||||
let real = offscreen.cloned().unwrap_or_else(|| frame(device, config));
|
||||
if self
|
||||
.offscreen_generated
|
||||
.as_ref()
|
||||
.is_none_or(|t| t.size() != real.size())
|
||||
{
|
||||
self.offscreen_generated = Some(frame(device, config));
|
||||
}
|
||||
let generated = self.offscreen_generated.clone().expect("made above");
|
||||
return Some((real, generated));
|
||||
};
|
||||
if self.presenter.as_ref().is_some_and(|p| p.config != *config) {
|
||||
self.presenter = None;
|
||||
}
|
||||
let presenter = self.presenter.get_or_insert_with(|| {
|
||||
Presenter::start(device, queue, surface.clone(), window.clone(), config)
|
||||
});
|
||||
// A closed channel: the thread died.
|
||||
let pair = if presenter.failed() {
|
||||
None
|
||||
} else {
|
||||
presenter.free.recv().ok().zip(presenter.free.recv().ok())
|
||||
};
|
||||
if pair.is_none() {
|
||||
self.stop();
|
||||
self.paused = true;
|
||||
}
|
||||
pair
|
||||
}
|
||||
|
||||
/// The world's target at the screen's size this frame, where `Post::finish` draws.
|
||||
pub fn world(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
post: &super::world_post::Post,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> wgpu::TextureView {
|
||||
let size = [config.width, config.height];
|
||||
if self.gpu.as_ref().is_none_or(|g| g.size != size) {
|
||||
let world = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation world"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: config.format.remove_srgb_suffix(),
|
||||
// COPY_SRC: the interpolation keeps it as the previous frame.
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let interpolation = interpolation::Interpolation::new(device, size);
|
||||
self.gpu = Some(Gpu {
|
||||
size,
|
||||
flow: optical_flow::OpticalFlow::new(device, size),
|
||||
world_group: post.bind(device, &world),
|
||||
output_group: post.bind(device, interpolation.output()),
|
||||
interpolation,
|
||||
world,
|
||||
reset: true,
|
||||
});
|
||||
}
|
||||
self.gpu.as_ref().expect("made above").world.clone()
|
||||
}
|
||||
|
||||
/// Copy the world (`world`) onto `screen` for the real frame, and draw the made
|
||||
/// frame's world onto `generated`. Returns false when there is no made frame (the
|
||||
/// first after a reset): `generated` then holds this frame's world too.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn generate(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
post: &super::world_post::Post,
|
||||
screen: &wgpu::TextureView,
|
||||
generated: &wgpu::TextureView,
|
||||
camera: &Camera,
|
||||
) -> bool {
|
||||
let gpu = self.gpu.as_mut().expect("world() ran this frame");
|
||||
post.finish_with(encoder, screen, &gpu.world_group, None);
|
||||
let reset = gpu.reset || camera.reproject.is_none();
|
||||
let reproject = camera.reproject.unwrap_or(Mat4::IDENTITY);
|
||||
let object_motion = self
|
||||
.motion
|
||||
.as_mut()
|
||||
.expect("track_entities() ran this frame")
|
||||
.draw(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
&camera.depth,
|
||||
camera.render_size,
|
||||
camera.jittered,
|
||||
camera.view_proj,
|
||||
reproject * camera.view_proj,
|
||||
);
|
||||
gpu.flow.dispatch(device, queue, encoder, &gpu.world, reset);
|
||||
gpu.interpolation.dispatch(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
&interpolation::Inputs {
|
||||
color: &gpu.world,
|
||||
depth: &camera.depth,
|
||||
render_size: camera.render_size,
|
||||
jitter: camera.jitter,
|
||||
reproject,
|
||||
object_motion: &object_motion,
|
||||
near: camera.near,
|
||||
far: camera.far,
|
||||
fov_y: camera.fov_y,
|
||||
reset,
|
||||
flow_vectors: gpu.flow.vectors(),
|
||||
scene_change: gpu.flow.scene_change(),
|
||||
},
|
||||
);
|
||||
gpu.reset = false;
|
||||
if debug() == Some("motion") {
|
||||
post.finish_with(encoder, generated, &post.bind(device, &object_motion), None);
|
||||
return true;
|
||||
}
|
||||
if reset {
|
||||
post.finish_with(encoder, generated, &gpu.world_group, None);
|
||||
} else {
|
||||
post.finish_with(encoder, generated, &gpu.output_group, None);
|
||||
}
|
||||
!reset
|
||||
}
|
||||
|
||||
/// Hand this frame's pair to the present thread, after the frame's submission: the
|
||||
/// made frame (when `made`) goes first.
|
||||
pub fn present(&mut self, real: wgpu::Texture, generated: wgpu::Texture, made: bool) {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.last {
|
||||
// A real frame's time, smoothed: the made frame shows for half of it.
|
||||
let dt = (now - last).min(Duration::from_millis(250));
|
||||
self.interval = self.interval.mul_f32(0.9) + dt.mul_f32(0.1);
|
||||
}
|
||||
self.last = Some(now);
|
||||
self.real += 1;
|
||||
self.made += u32::from(made && !generated_only());
|
||||
if now - self.shown_since >= Duration::from_millis(500) {
|
||||
self.made_share = Some(self.made as f32 / self.real as f32);
|
||||
(self.real, self.made, self.shown_since) = (0, 0, now);
|
||||
}
|
||||
let Some(send) = self.presenter.as_ref().and_then(|p| p.send.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let pair = if generated_only() {
|
||||
Pair {
|
||||
first: None,
|
||||
second: generated,
|
||||
spare: Some(real),
|
||||
interval: self.interval,
|
||||
}
|
||||
} else if made {
|
||||
Pair {
|
||||
first: Some(generated),
|
||||
second: real,
|
||||
spare: None,
|
||||
interval: self.interval,
|
||||
}
|
||||
} else {
|
||||
Pair {
|
||||
first: None,
|
||||
second: real,
|
||||
spare: Some(generated),
|
||||
interval: self.interval,
|
||||
}
|
||||
};
|
||||
let _ = send.send(pair);
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame to draw into, like the swapchain's.
|
||||
fn frame(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Frame generation frame"),
|
||||
size: wgpu::Extent3d {
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: config.format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &config.view_formats,
|
||||
})
|
||||
}
|
||||
|
||||
/// One real frame's frames for the present thread: `first` (the made frame) now,
|
||||
/// `second` (the real one) half an `interval` later; `spare` goes straight back.
|
||||
struct Pair {
|
||||
first: Option<wgpu::Texture>,
|
||||
second: wgpu::Texture,
|
||||
spare: Option<wgpu::Texture>,
|
||||
interval: Duration,
|
||||
}
|
||||
|
||||
/// The thread that copies frames to the swapchain and presents them on time.
|
||||
struct Presenter {
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
send: Option<mpsc::SyncSender<Pair>>,
|
||||
free: mpsc::Receiver<wgpu::Texture>,
|
||||
/// The surface gave something other than a frame: the renderer takes it back.
|
||||
failed: Arc<AtomicBool>,
|
||||
thread: Option<thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Presenter {
|
||||
fn start(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
surface: Arc<wgpu::Surface<'static>>,
|
||||
window: Arc<winit::window::Window>,
|
||||
config: &wgpu::SurfaceConfiguration,
|
||||
) -> Self {
|
||||
// One pair waiting at most: the renderer then waits as for a swapchain image.
|
||||
let (send, receive) = mpsc::sync_channel::<Pair>(1);
|
||||
let (give, free) = mpsc::channel();
|
||||
for _ in 0..FRAMES {
|
||||
let _ = give.send(frame(device, config));
|
||||
}
|
||||
let (device, queue) = (device.clone(), queue.clone());
|
||||
let vsync = config.present_mode == wgpu::PresentMode::Fifo;
|
||||
let failed = Arc::new(AtomicBool::new(false));
|
||||
let failed_here = failed.clone();
|
||||
let thread = thread::Builder::new()
|
||||
.name("Frame generation present".into())
|
||||
.spawn(move || {
|
||||
let show = |texture: &wgpu::Texture| {
|
||||
if failed_here.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// Anything but a frame (outdated, lost, occluded...): the renderer
|
||||
// stops this thread and acquires itself, handling it as it does
|
||||
// without frame generation. A suboptimal frame still shows.
|
||||
let target = match surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(t) => t,
|
||||
wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
|
||||
failed_here.store(true, Ordering::Relaxed);
|
||||
t
|
||||
}
|
||||
_ => {
|
||||
failed_here.store(true, Ordering::Relaxed);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_texture_to_texture(
|
||||
texture.as_image_copy(),
|
||||
target.texture.as_image_copy(),
|
||||
target.texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
if vsync {
|
||||
window.pre_present_notify();
|
||||
}
|
||||
queue.present(target);
|
||||
};
|
||||
while let Ok(pair) = receive.recv() {
|
||||
if let Some(first) = pair.first {
|
||||
let shown = Instant::now();
|
||||
show(&first);
|
||||
let _ = give.send(first);
|
||||
// ponytail: sleep pacing; a spin for the last half millisecond
|
||||
// if it shows uneven on some systems.
|
||||
let due = shown + pair.interval / 2;
|
||||
if let Some(wait) = due.checked_duration_since(Instant::now()) {
|
||||
thread::sleep(wait);
|
||||
}
|
||||
}
|
||||
show(&pair.second);
|
||||
let _ = give.send(pair.second);
|
||||
if let Some(spare) = pair.spare {
|
||||
let _ = give.send(spare);
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawn the frame generation present thread");
|
||||
Self {
|
||||
config: config.clone(),
|
||||
send: Some(send),
|
||||
free,
|
||||
failed,
|
||||
thread: Some(thread),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface failed it or the thread died (a panic).
|
||||
fn failed(&self) -> bool {
|
||||
self.failed.load(Ordering::Relaxed) || self.thread.as_ref().is_none_or(|t| t.is_finished())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Presenter {
|
||||
fn drop(&mut self) {
|
||||
self.send = None;
|
||||
if let Some(thread) = self.thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mesher::Vertex;
|
||||
use glam::Vec3;
|
||||
|
||||
fn vertex(pos: Vec3) -> Vertex {
|
||||
Vertex {
|
||||
pos: pos.into(),
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
}
|
||||
}
|
||||
|
||||
/// A swing lands halfway; a hand still on screen while the camera turns stays still;
|
||||
/// another item shows as it is.
|
||||
#[test]
|
||||
fn the_made_frame_s_hand_is_halfway_on_screen() {
|
||||
let mut fg = FrameGen::new();
|
||||
let mut out = Vec::new();
|
||||
fg.halfway_hand(
|
||||
&[vertex(Vec3::new(0.0, 0.0, -1.0))],
|
||||
Mat4::IDENTITY,
|
||||
&mut out,
|
||||
);
|
||||
fg.halfway_hand(
|
||||
&[vertex(Vec3::new(0.4, 0.0, -1.0))],
|
||||
Mat4::IDENTITY,
|
||||
&mut out,
|
||||
);
|
||||
assert!((Vec3::from(out[1].pos) - Vec3::new(0.2, 0.0, -1.0)).length() < 1e-6);
|
||||
// The camera turned: the vertex turned with it, the same on screen.
|
||||
let turned = Mat4::from_rotation_y(0.3);
|
||||
let on_screen = Vec3::new(0.4, 0.0, -1.0);
|
||||
let pos = turned.inverse().transform_point3(on_screen);
|
||||
fg.halfway_hand(&[vertex(pos)], turned, &mut out);
|
||||
assert!((Vec3::from(out[2].pos) - pos).length() < 1e-6);
|
||||
let mut other = vertex(Vec3::ZERO);
|
||||
other.uv = [0.5, 0.5];
|
||||
fg.halfway_hand(&[other], turned, &mut out);
|
||||
assert_eq!(out[3].pos, [0.0; 3]);
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
|
||||
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
|
||||
//! An ICC profile has no values above white, so those programs show a correct SDR picture
|
||||
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
|
||||
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
|
||||
|
||||
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
|
||||
const WHITE: f64 = 203.0;
|
||||
/// PQ's values from 0 to 1, in the curve's steps.
|
||||
const STEPS: usize = 4096;
|
||||
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
|
||||
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
|
||||
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
|
||||
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
|
||||
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
|
||||
|
||||
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
|
||||
fn pq_nits(signal: f64) -> f64 {
|
||||
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
|
||||
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
|
||||
let p = signal.powf(1.0 / m2);
|
||||
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
|
||||
}
|
||||
|
||||
fn s15_fixed16(v: f64) -> [u8; 4] {
|
||||
((v * 65536.0).round() as i32).to_be_bytes()
|
||||
}
|
||||
|
||||
fn xyz(v: [f64; 3]) -> Vec<u8> {
|
||||
let mut tag = b"XYZ \0\0\0\0".to_vec();
|
||||
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
|
||||
tag
|
||||
}
|
||||
|
||||
fn curve() -> Vec<u8> {
|
||||
let mut tag = b"curv\0\0\0\0".to_vec();
|
||||
tag.extend((STEPS as u32).to_be_bytes());
|
||||
for i in 0..STEPS {
|
||||
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
|
||||
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
|
||||
}
|
||||
tag
|
||||
}
|
||||
|
||||
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
|
||||
fn description(text: &str) -> Vec<u8> {
|
||||
let mut tag = b"desc\0\0\0\0".to_vec();
|
||||
tag.extend((text.len() as u32 + 1).to_be_bytes());
|
||||
tag.extend(text.as_bytes());
|
||||
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
|
||||
tag
|
||||
}
|
||||
|
||||
/// An ICC v2 matrix-shaper display profile.
|
||||
pub(super) fn profile() -> Vec<u8> {
|
||||
let trc = curve();
|
||||
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
|
||||
let tags: [(&[u8; 4], &[u8]); 9] = [
|
||||
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
|
||||
(b"cprt", ©right),
|
||||
(b"wtpt", &xyz(D50)),
|
||||
(b"rXYZ", &xyz(RED)),
|
||||
(b"gXYZ", &xyz(GREEN)),
|
||||
(b"bXYZ", &xyz(BLUE)),
|
||||
(b"rTRC", &trc),
|
||||
(b"gTRC", &trc),
|
||||
(b"bTRC", &trc),
|
||||
];
|
||||
let data_start = 128 + 4 + 12 * tags.len();
|
||||
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||
let mut data = Vec::new();
|
||||
let mut trc_at = None;
|
||||
for (name, tag) in tags {
|
||||
// The three channels share one curve.
|
||||
let is_trc = tag == &trc[..];
|
||||
let at = match trc_at.filter(|_| is_trc) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let at = data_start + data.len();
|
||||
data.extend(tag);
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
at
|
||||
}
|
||||
};
|
||||
if is_trc {
|
||||
trc_at = Some(at);
|
||||
}
|
||||
table.extend(name);
|
||||
table.extend((at as u32).to_be_bytes());
|
||||
table.extend((tag.len() as u32).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0; 128];
|
||||
let size = data_start + data.len();
|
||||
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
|
||||
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
|
||||
out[12..16].copy_from_slice(b"mntr");
|
||||
out[16..20].copy_from_slice(b"RGB ");
|
||||
out[20..24].copy_from_slice(b"XYZ ");
|
||||
out[36..40].copy_from_slice(b"acsp");
|
||||
// Relative colorimetric, and the connection space's illuminant.
|
||||
out[64..68].copy_from_slice(&1u32.to_be_bytes());
|
||||
for (i, &c) in D50.iter().enumerate() {
|
||||
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
|
||||
}
|
||||
out.extend(table);
|
||||
out.extend(data);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
|
||||
let at = (0..u32_at(128))
|
||||
.map(|i| 132 + 12 * i)
|
||||
.find(|&e| &profile[e..e + 4] == name)
|
||||
.unwrap();
|
||||
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_is_whole() {
|
||||
let p = profile();
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
|
||||
p.len()
|
||||
);
|
||||
assert_eq!(&p[36..40], b"acsp");
|
||||
assert_eq!(p.len() % 4, 0);
|
||||
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
|
||||
// One curve, shared: the profile stays small.
|
||||
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
|
||||
assert!(p.len() < 10_000, "{}", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_primaries_add_up_to_white() {
|
||||
for i in 0..3 {
|
||||
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_reaches_white_at_reference_white_and_stays() {
|
||||
let p = profile();
|
||||
let trc = tag(&p, b"rTRC");
|
||||
let value = |signal: f64| {
|
||||
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
|
||||
u16::from_be_bytes([trc[i], trc[i + 1]])
|
||||
};
|
||||
assert_eq!(value(0.0), 0);
|
||||
// 203 nits in PQ is a signal of 0.5807.
|
||||
assert!(value(0.57) < 65535);
|
||||
assert_eq!(value(0.59), 65535);
|
||||
assert_eq!(value(1.0), 65535);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ struct HorizonMaterialOut {
|
||||
}
|
||||
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
|
||||
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
|
||||
let pos = position + origin;
|
||||
var out: HorizonMaterialOut;
|
||||
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
|
||||
out.uv = uv;
|
||||
@@ -139,32 +139,17 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
|
||||
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
|
||||
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
|
||||
let normal = horizon_material_normal(in.position);
|
||||
horizon_material_face_normal = normal;
|
||||
horizon_surface = vec4<f32>(0.0);
|
||||
let tangent = horizon_material_tangent(in.position, in.uv, normal);
|
||||
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
|
||||
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
|
||||
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
|
||||
in.face, fluid, in.emission, normal, tangent)).color;
|
||||
}
|
||||
var<private> horizon_material_face_normal: vec3<f32>;
|
||||
struct HorizonMaterialSolid {
|
||||
@location(0) color: vec4<f32>,
|
||||
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
|
||||
// stage's `surface_at` compares with the final depth.
|
||||
@location(1) surface: vec4<f32>,
|
||||
}
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, false);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
if horizon_surface.w == 0.0 {
|
||||
set_surface(horizon_material_face_normal, 0.0, 0.0);
|
||||
}
|
||||
var out: HorizonMaterialSolid;
|
||||
out.color = horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
out.surface = vec4(horizon_surface.xyz, in.clip.z);
|
||||
return out;
|
||||
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
@@ -204,8 +189,6 @@ pub struct NativeMaterial {
|
||||
pub solid: wgpu::RenderPipeline,
|
||||
pub translucent: wgpu::RenderPipeline,
|
||||
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
|
||||
/// The stage calls `set_surface`: its solid pipelines also write the surface target.
|
||||
pub writes_surface: bool,
|
||||
}
|
||||
|
||||
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
|
||||
@@ -220,7 +203,7 @@ fn vanilla_terrain() -> String {
|
||||
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
|
||||
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
|
||||
/// (`MaterialInput.material`).
|
||||
pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
|
||||
let mut table = table.to_vec();
|
||||
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
|
||||
@@ -235,47 +218,12 @@ pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
|
||||
out + " default: { return 0u; }\n }\n}\n"
|
||||
}
|
||||
|
||||
/// Where a corner of terrain is drawn without a vertex stage.
|
||||
pub(super) const DISPLACE_VANILLA: &str = "
|
||||
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>) -> vec3<f32> {
|
||||
return pos;
|
||||
}
|
||||
";
|
||||
|
||||
/// The vertex stage's call, with its inputs from terrain's vertex.
|
||||
pub(super) const DISPLACE_PACK: &str = "
|
||||
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
light: vec2<u32>) -> vec3<f32> {
|
||||
let flags = u32(round(color.a * 255.0));
|
||||
let high = flags >> 4u;
|
||||
let fluid = high >= 8u && (high & 7u) < 6u;
|
||||
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
|
||||
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
|
||||
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
|
||||
}
|
||||
";
|
||||
|
||||
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
|
||||
/// bits), y smoothness, z the pack's data, w the fragment's depth.
|
||||
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
|
||||
|
||||
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
|
||||
/// `materials`, and the vertex stage in its module when `vertex`.
|
||||
fn shader_source(
|
||||
source: &str,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
) -> String {
|
||||
/// `materials`.
|
||||
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
|
||||
format!(
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
|
||||
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
|
||||
vanilla_terrain(),
|
||||
if vertex {
|
||||
DISPLACE_PACK
|
||||
} else {
|
||||
DISPLACE_VANILLA
|
||||
},
|
||||
layer_table("horizon_sprite_tags", tags),
|
||||
layer_table("horizon_sprite_material", materials)
|
||||
)
|
||||
@@ -286,9 +234,8 @@ fn module(
|
||||
stage: &CheckedStage,
|
||||
tags: &[(u32, u32)],
|
||||
materials: &[(u32, u32)],
|
||||
vertex: bool,
|
||||
) -> Result<naga::Module, String> {
|
||||
let text = shader_source(&stage.source, tags, materials, vertex);
|
||||
let text = shader_source(&stage.source, tags, materials);
|
||||
naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
|
||||
}
|
||||
@@ -302,10 +249,8 @@ impl NativeMaterial {
|
||||
stage: &CheckedStage,
|
||||
constants: &[(String, f64)],
|
||||
fog: bool,
|
||||
vertex: bool,
|
||||
) -> Result<Self, String> {
|
||||
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
|
||||
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
|
||||
let mut constants = present_constants(&module, constants);
|
||||
let sprite_names = [
|
||||
"horizon_terrain_SPRITE_ARRAYS",
|
||||
@@ -351,18 +296,6 @@ impl NativeMaterial {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
let solid_targets = [
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: SURFACE_FORMAT,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
}),
|
||||
];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&stage.label),
|
||||
layout: Some(layout),
|
||||
@@ -388,11 +321,11 @@ impl NativeMaterial {
|
||||
module: &module,
|
||||
entry_point: Some(fragment),
|
||||
compilation_options: options,
|
||||
targets: if fragment == "horizon_material_fs_solid" && writes_surface {
|
||||
&solid_targets
|
||||
} else {
|
||||
&solid_targets[..1]
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: shape.format,
|
||||
blend,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
@@ -437,7 +370,6 @@ impl NativeMaterial {
|
||||
solid,
|
||||
translucent,
|
||||
flat,
|
||||
writes_surface,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -492,7 +424,7 @@ mod tests {
|
||||
let tags = [(7, 1), (3, 1), (9, 2)];
|
||||
let materials = [(5, 1000)];
|
||||
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
|
||||
let module = module(&stage, &tags, &materials, false).unwrap();
|
||||
let module = module(&stage, &tags, &materials).unwrap();
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
@@ -501,7 +433,7 @@ mod tests {
|
||||
.unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{}",
|
||||
e.emit_to_string(&shader_source(&stage.source, &tags, &materials, false))
|
||||
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
|
||||
)
|
||||
});
|
||||
let entries: Vec<&str> = module
|
||||
|
||||
@@ -48,17 +48,7 @@ impl NativePack {
|
||||
let shadow = report
|
||||
.shadow
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
|
||||
NativeShadow::new(
|
||||
device,
|
||||
terrain,
|
||||
&frame,
|
||||
&s.solid_materials,
|
||||
vertex,
|
||||
constants,
|
||||
)
|
||||
})
|
||||
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
|
||||
.transpose()?;
|
||||
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
|
||||
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
|
||||
@@ -101,7 +91,6 @@ impl NativePack {
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Weather)
|
||||
.map(|s| s.label.clone());
|
||||
let has_vertex = report.stages.iter().any(|s| s.kind == StageKind::Vertex);
|
||||
let (post, others): (Vec<_>, Vec<_>) = report
|
||||
.stages
|
||||
.into_iter()
|
||||
@@ -114,9 +103,7 @@ impl NativePack {
|
||||
let material = others
|
||||
.iter()
|
||||
.find(|s| s.kind == StageKind::Material)
|
||||
.map(|stage| {
|
||||
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
|
||||
})
|
||||
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
|
||||
.transpose()?;
|
||||
let post = (!post.is_empty())
|
||||
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::Frame;
|
||||
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
|
||||
/// The world's colour and depth views a set of bind groups was made for.
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
|
||||
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
|
||||
|
||||
/// Where a post pass draws.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -103,9 +103,6 @@ pub struct NativePost {
|
||||
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
|
||||
/// what the material stage reads besides.
|
||||
pub struct NativeFrame {
|
||||
/// The solid terrain's second target, the post stage's `scene_surface`.
|
||||
surface: wgpu::Texture,
|
||||
pub surface_view: wgpu::TextureView,
|
||||
pub(super) buffer: wgpu::Buffer,
|
||||
/// Group 3 of the sky and material stages.
|
||||
pub layout: wgpu::BindGroupLayout,
|
||||
@@ -273,8 +270,6 @@ impl NativeFrame {
|
||||
label: Some("Native shader pack frame"),
|
||||
entries: &entries,
|
||||
});
|
||||
let surface = surface_target(device, [1, 1]);
|
||||
let surface_view = surface.create_view(&Default::default());
|
||||
let opaque_depth = depth_copy(device, [1, 1]);
|
||||
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
|
||||
let blank = copy_target(
|
||||
@@ -337,8 +332,6 @@ impl NativeFrame {
|
||||
buffer,
|
||||
layout,
|
||||
group,
|
||||
surface,
|
||||
surface_view,
|
||||
opaque_depth,
|
||||
opaque_color,
|
||||
reflection: blank.clone(),
|
||||
@@ -475,14 +468,6 @@ impl NativeFrame {
|
||||
self.regroup(device);
|
||||
}
|
||||
|
||||
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
|
||||
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
if [self.surface.width(), self.surface.height()] != size {
|
||||
self.surface = surface_target(device, size);
|
||||
self.surface_view = self.surface.create_view(&Default::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
|
||||
/// before the stages read it.
|
||||
pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
|
||||
@@ -588,24 +573,6 @@ pub fn present_constants<'a>(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The solid terrain's surface target of `size`, drawn and read.
|
||||
fn surface_target(device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Native shader pack surface"),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: super::native_material::SURFACE_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
}
|
||||
|
||||
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
@@ -685,7 +652,6 @@ impl NativePost {
|
||||
texture_entry(8, float),
|
||||
texture_entry(9, wgpu::TextureSampleType::Depth),
|
||||
texture_entry(10, float),
|
||||
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
|
||||
];
|
||||
entries.extend(pack_texture_entries(frame.textures.len()));
|
||||
entries
|
||||
@@ -791,7 +757,7 @@ impl NativePost {
|
||||
fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
size: [u32; 2],
|
||||
history_reset: bool,
|
||||
) {
|
||||
@@ -864,11 +830,8 @@ impl NativePost {
|
||||
if self
|
||||
.groups
|
||||
.as_ref()
|
||||
.is_some_and(|((color, depth, surface), blank, _)| {
|
||||
color == world.0
|
||||
&& depth == world.1
|
||||
&& surface == world.2
|
||||
&& *blank == blank_history
|
||||
.is_some_and(|((color, depth), blank, _)| {
|
||||
color == world.0 && depth == world.1 && *blank == blank_history
|
||||
})
|
||||
{
|
||||
return;
|
||||
@@ -930,10 +893,6 @@ impl NativePost {
|
||||
binding: 9,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 11,
|
||||
resource: wgpu::BindingResource::TextureView(world.2),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 10,
|
||||
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
|
||||
@@ -961,11 +920,7 @@ impl NativePost {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
self.groups = Some((
|
||||
(world.0.clone(), world.1.clone(), world.2.clone()),
|
||||
blank_history,
|
||||
groups,
|
||||
));
|
||||
self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
|
||||
}
|
||||
|
||||
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
|
||||
@@ -974,7 +929,7 @@ impl NativePost {
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
|
||||
world: (&wgpu::TextureView, &wgpu::TextureView),
|
||||
screen: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
history_reset: bool,
|
||||
@@ -1394,7 +1349,7 @@ mod tests {
|
||||
post.run(
|
||||
&device,
|
||||
&mut encoder,
|
||||
(&color, &depth, &frame.surface_view),
|
||||
(&color, &depth),
|
||||
&view,
|
||||
[4, 4],
|
||||
true,
|
||||
@@ -1501,7 +1456,7 @@ file = \"b.wgsl\"
|
||||
post.run(
|
||||
&device,
|
||||
&mut encoder,
|
||||
(&color, &depth, &frame.surface_view),
|
||||
(&color, &depth),
|
||||
&view,
|
||||
[4, 4],
|
||||
history_reset,
|
||||
|
||||
@@ -6,19 +6,6 @@
|
||||
use super::Vertex;
|
||||
use super::pack_draw::EntitySpan;
|
||||
use glam::{DVec3, Mat4};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// The game was built with native mods (the game sets it at start): one of them may draw in
|
||||
/// [`native_world_drawn`], so the first-person hand keeps its own pass after it.
|
||||
pub fn set_mods_built_in(on: bool) {
|
||||
MODS_BUILT_IN.store(on, Ordering::Relaxed);
|
||||
}
|
||||
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
|
||||
pub fn world_drawn_hooked() -> bool {
|
||||
MODS_BUILT_IN.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
|
||||
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
|
||||
|
||||
@@ -41,21 +41,17 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
|
||||
}
|
||||
@vertex fn horizon_shadow_vs(
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>,
|
||||
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
|
||||
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
|
||||
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
|
||||
return horizon_shadow_vertex(pos, atlas, layer, color);
|
||||
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
|
||||
}
|
||||
@vertex fn horizon_shadow_vs_flat(
|
||||
@builtin(vertex_index) index: u32,
|
||||
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
|
||||
@location(3) color: vec4<f32>, @location(4) light: vec2<u32>
|
||||
@location(3) color: vec4<f32>
|
||||
) -> HorizonShadowOut {
|
||||
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
|
||||
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
|
||||
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
|
||||
return horizon_shadow_vertex(pos, atlas, layer, color);
|
||||
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
|
||||
}
|
||||
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
@@ -419,24 +415,12 @@ fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
|
||||
|
||||
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
|
||||
/// `solid` of `shadow_solid`.
|
||||
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
|
||||
let (head, displace) = match vertex {
|
||||
Some(stage) => (
|
||||
stage.to_owned()
|
||||
+ &super::native_material::layer_table("horizon_sprite_material", materials),
|
||||
super::native_material::DISPLACE_PACK,
|
||||
),
|
||||
None => (
|
||||
format!(
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
),
|
||||
super::native_material::DISPLACE_VANILLA,
|
||||
),
|
||||
};
|
||||
fn shader_source(solid: &str) -> String {
|
||||
format!(
|
||||
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
|
||||
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
|
||||
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
|
||||
FrameInputs::WGSL.wgsl(),
|
||||
horizon_shader_api::stages::SHADOW_HELPERS,
|
||||
prefixed(
|
||||
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
|
||||
PREFIX
|
||||
@@ -450,21 +434,14 @@ impl NativeShadow {
|
||||
shape: &TerrainShape<'_>,
|
||||
frame: &super::native_post::NativeFrame,
|
||||
solid_materials: &[u32],
|
||||
vertex: Option<&horizon_shader_api::check::CheckedStage>,
|
||||
constants: &[(String, f64)],
|
||||
) -> Result<Self, String> {
|
||||
let text = shader_source(
|
||||
&shadow_solid(shape.sprite_materials, solid_materials),
|
||||
vertex.map(|s| s.source.as_str()),
|
||||
shape.sprite_materials,
|
||||
);
|
||||
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
|
||||
let module = naga::front::wgsl::parse_str(&text)
|
||||
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
|
||||
let mut constants = super::native_post::present_constants(&module, constants);
|
||||
constants.extend([
|
||||
let constants = [
|
||||
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
|
||||
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
|
||||
]);
|
||||
];
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Native shader pack shadow"),
|
||||
@@ -616,7 +593,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_shadow_shader_validates() {
|
||||
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
|
||||
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
|
||||
let text = shader_source(&shadow_solid(materials, &[7]));
|
||||
let module = naga::front::wgsl::parse_str(&text).unwrap();
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
//! `centerDepthSmooth`'s input: `depthtex0` at the centre of the screen. A depth texture can
|
||||
//! only be copied whole, so each frame a one-pixel pass loads that texel into a 1x1 `R32Float`
|
||||
//! target, copied to one of a few small buffers and read back frames later without waiting for
|
||||
//! the GPU (as `timing.rs` reads its timestamps); `FrameUniforms` smooths the latest value.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
const SLOTS: usize = 3;
|
||||
const FREE: u8 = 0;
|
||||
/// Copied this frame: mapped once the frame is submitted.
|
||||
const COPIED: u8 = 1;
|
||||
const PENDING: u8 = 2;
|
||||
const MAPPED: u8 = 3;
|
||||
/// One row of a texture copy, the alignment wgpu asks of `bytes_per_row`.
|
||||
const ROW: u32 = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||
|
||||
const SHADER: &str = "
|
||||
@group(0) @binding(0) var depth: texture_depth_2d;
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
@fragment fn fs() -> @location(0) vec4<f32> {
|
||||
let centre = vec2<i32>(textureDimensions(depth) / 2u);
|
||||
return vec4<f32>(textureLoad(depth, centre, 0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
";
|
||||
|
||||
struct Slot {
|
||||
buffer: wgpu::Buffer,
|
||||
state: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
pub struct CenterDepth {
|
||||
slots: Vec<Slot>,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
/// The 1x1 target the centre texel is loaded into.
|
||||
texel: wgpu::Texture,
|
||||
texel_view: wgpu::TextureView,
|
||||
/// The bind group of the depth texture it reads, made again when that texture changes.
|
||||
group: Option<(wgpu::Texture, wgpu::BindGroup)>,
|
||||
/// The depth last read back: GL window depth, 0..1.
|
||||
latest: Option<f32>,
|
||||
}
|
||||
|
||||
impl CenterDepth {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let slots = (0..SLOTS)
|
||||
.map(|_| Slot {
|
||||
buffer: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
size: u64::from(ROW),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
state: Arc::new(AtomicU8::new(FREE)),
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Depth,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
source: wgpu::ShaderSource::Wgsl(SHADER.into()),
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let format = wgpu::TextureFormat::R32Float;
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(format.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let texel = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let texel_view = texel.create_view(&Default::default());
|
||||
Self {
|
||||
slots,
|
||||
pipeline,
|
||||
layout,
|
||||
texel,
|
||||
texel_view,
|
||||
group: None,
|
||||
latest: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the centre texel of `depth` (a `Depth32Float` texture that can be bound) and
|
||||
/// copies it into a free slot; with none free (the GPU is behind), this frame is skipped.
|
||||
pub fn copy(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
depth: &wgpu::Texture,
|
||||
) {
|
||||
let Some(slot) = self
|
||||
.slots
|
||||
.iter()
|
||||
.find(|s| s.state.load(Ordering::Acquire) == FREE)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.group.as_ref().is_none_or(|(t, _)| t != depth) {
|
||||
let view = depth.create_view(&Default::default());
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
layout: &self.layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&view),
|
||||
}],
|
||||
});
|
||||
self.group = Some((depth.clone(), group));
|
||||
}
|
||||
let Some((_, group)) = &self.group else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack centre depth"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.texel_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
self.texel.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &slot.buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(ROW),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
self.texel.size(),
|
||||
);
|
||||
slot.state.store(COPIED, Ordering::Release);
|
||||
}
|
||||
|
||||
/// After the frame's submission: start reading back what it copied.
|
||||
pub fn submitted(&mut self) {
|
||||
for slot in &self.slots {
|
||||
if slot.state.load(Ordering::Acquire) != COPIED {
|
||||
continue;
|
||||
}
|
||||
slot.state.store(PENDING, Ordering::Release);
|
||||
let state = slot.state.clone();
|
||||
slot.buffer
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, move |result| {
|
||||
let next = if result.is_ok() { MAPPED } else { FREE };
|
||||
state.store(next, Ordering::Release);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest depth read back so far, `None` before the first arrives.
|
||||
pub fn collect(&mut self, device: &wgpu::Device) -> Option<f32> {
|
||||
let _ = device.poll(wgpu::PollType::Poll);
|
||||
for slot in &self.slots {
|
||||
if slot.state.load(Ordering::Acquire) != MAPPED {
|
||||
continue;
|
||||
}
|
||||
let depth = slot
|
||||
.buffer
|
||||
.slice(..4)
|
||||
.get_mapped_range()
|
||||
.ok()
|
||||
.map(|bytes| bytemuck::pod_read_unaligned::<f32>(&bytes[..4]));
|
||||
slot.buffer.unmap();
|
||||
slot.state.store(FREE, Ordering::Release);
|
||||
if let Some(depth) = depth.filter(|d| d.is_finite()) {
|
||||
self.latest = Some(depth.clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
self.latest
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// A depth texture cleared to a known depth comes back as that depth, a frame or more
|
||||
/// later, without the frame waiting for it.
|
||||
#[test]
|
||||
fn the_centre_depth_comes_back() {
|
||||
let Some((device, queue)) = test_device() else {
|
||||
return;
|
||||
};
|
||||
let depth = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 9,
|
||||
height: 7,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = depth.create_view(&Default::default());
|
||||
let mut center = CenterDepth::new(&device);
|
||||
assert_eq!(center.collect(&device), None);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.375),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted();
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
assert_eq!(center.collect(&device), Some(0.375));
|
||||
// Its slot is free again: every slot can be used once more.
|
||||
for _ in 0..SLOTS {
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
center.copy(&device, &mut encoder, &depth);
|
||||
queue.submit([encoder.finish()]);
|
||||
center.submitted();
|
||||
}
|
||||
let _ = device.poll(wgpu::PollType::wait_indefinitely());
|
||||
assert_eq!(center.collect(&device), Some(0.375));
|
||||
}
|
||||
}
|
||||
@@ -512,8 +512,6 @@ pub struct PackPost {
|
||||
page_groups: HashMap<u32, (wgpu::Buffer, wgpu::BindGroup)>,
|
||||
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
|
||||
pub timing: Option<super::pack_timing::PassTiming>,
|
||||
/// `depthtex0` at the screen's centre, read back for `centerDepthSmooth`.
|
||||
center_depth: Option<super::pack_center_depth::CenterDepth>,
|
||||
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
|
||||
custom: horizon_shaders::Custom,
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
@@ -2244,7 +2242,6 @@ impl PackPost {
|
||||
block_ids,
|
||||
page_groups: HashMap::new(),
|
||||
timing: None,
|
||||
center_depth: None,
|
||||
custom,
|
||||
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
|
||||
lightmap: world.lightmap,
|
||||
@@ -2743,13 +2740,6 @@ impl PackPost {
|
||||
let rp = &mut open.as_mut().expect("open").0;
|
||||
rp.set_pipeline(pass.render());
|
||||
rp.set_bind_group(0, group, &[]);
|
||||
// A sky program that reads the pack's SSBOs (Noble's `gbuffers_textured`, the
|
||||
// sun's fallback) binds them, as the other passes do.
|
||||
if pass.buffers
|
||||
&& let Some(g) = &self.buffers.group
|
||||
{
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
sky.geometry.bind(rp, part.textured);
|
||||
open.as_mut().expect("open").1 = i;
|
||||
}
|
||||
@@ -2906,13 +2896,6 @@ impl PackPost {
|
||||
}
|
||||
}
|
||||
|
||||
/// After the frame's submission: the centre depth it copied starts its way back.
|
||||
pub fn submitted(&mut self) {
|
||||
if let Some(center) = &mut self.center_depth {
|
||||
center.submitted();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pack draws entities or block entities itself.
|
||||
pub fn has_entities(&self) -> bool {
|
||||
self.entities.is_some()
|
||||
@@ -3630,10 +3613,6 @@ impl PackPost {
|
||||
if let Some(t) = &mut self.timing {
|
||||
t.begin(device, queue);
|
||||
}
|
||||
self.uniforms.center_depth_raw = self
|
||||
.center_depth
|
||||
.get_or_insert_with(|| super::pack_center_depth::CenterDepth::new(device))
|
||||
.collect(device);
|
||||
self.uniforms.update(frame, inputs, size);
|
||||
// Until the camera's biome is known, `smooth` waits for the world's values.
|
||||
if !inputs.biome_known {
|
||||
@@ -3872,10 +3851,6 @@ impl PackPost {
|
||||
self.draw_translucent(device, queue, encoder, size, terrain);
|
||||
}
|
||||
let targets = self.targets.as_ref().expect("resized");
|
||||
// depthtex0 as the frame leaves it, for next frames' `centerDepthSmooth`.
|
||||
if let Some(center) = &mut self.center_depth {
|
||||
center.copy(device, encoder, &targets.depth_texture);
|
||||
}
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
targets.color[i]
|
||||
.as_ref()
|
||||
|
||||
@@ -51,12 +51,6 @@ pub struct PackInputs {
|
||||
pub cloud_height: f32,
|
||||
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
|
||||
pub mod_params: [[f32; 4]; 8],
|
||||
/// The player's state, for Iris's status uniforms.
|
||||
pub status: PlayerStatus,
|
||||
/// The End flash: its intensity, 0..1 (0 outside the End), and its direction from the
|
||||
/// camera in world space, of length 100 (`endFlashIntensity`, `endFlashPosition`).
|
||||
pub end_flash: f32,
|
||||
pub end_flash_direction: Vec3,
|
||||
}
|
||||
|
||||
/// The `cloudHeight` uniform. Vanilla reads `visual/cloud_height` only while the cloud color
|
||||
@@ -70,35 +64,6 @@ pub fn cloud_height(cloud_color_alpha: f32, height: f32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The local player's state behind Iris's status uniforms (`currentPlayerHealth`,
|
||||
/// `isSpectator`, `is_burning`...).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PlayerStatus {
|
||||
/// Survival or adventure: the vitals are -1 in creative and spectator.
|
||||
pub survival: bool,
|
||||
pub health: f32,
|
||||
pub max_health: f32,
|
||||
pub food: f32,
|
||||
/// Air supply ticks, of 300.
|
||||
pub air: f32,
|
||||
/// Armor points (the `armor` attribute).
|
||||
pub armor: f32,
|
||||
pub spectator: bool,
|
||||
pub riding: bool,
|
||||
pub feet_in_water: bool,
|
||||
pub swimming: bool,
|
||||
pub burning: bool,
|
||||
pub hurt: bool,
|
||||
pub invisible: bool,
|
||||
pub sneaking: bool,
|
||||
pub sprinting: bool,
|
||||
}
|
||||
|
||||
/// `maxPlayerHunger`, `maxPlayerAir` and `maxPlayerArmor`: Minecraft's own maximums.
|
||||
const MAX_HUNGER: f32 = 20.0;
|
||||
const MAX_AIR: f32 = 300.0;
|
||||
const MAX_ARMOR: f32 = 50.0;
|
||||
|
||||
/// One uniform's value: up to 16 floats or 3 integers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Value {
|
||||
@@ -233,26 +198,6 @@ pub const NAMES: &[&str] = &[
|
||||
"playerLookVector",
|
||||
"playerBodyVector",
|
||||
"firstPersonCamera",
|
||||
"currentPlayerHealth",
|
||||
"maxPlayerHealth",
|
||||
"currentPlayerHunger",
|
||||
"maxPlayerHunger",
|
||||
"currentPlayerAir",
|
||||
"maxPlayerAir",
|
||||
"currentPlayerArmor",
|
||||
"maxPlayerArmor",
|
||||
"isSpectator",
|
||||
"isRiding",
|
||||
"feetInWater",
|
||||
"inSwimmingAnimation",
|
||||
"is_burning",
|
||||
"is_hurt",
|
||||
"is_invisible",
|
||||
"is_sneaking",
|
||||
"is_sprinting",
|
||||
"endFlashIntensity",
|
||||
"endFlashPosition",
|
||||
"centerDepthSmooth",
|
||||
"isRightHanded",
|
||||
"is_on_ground",
|
||||
"bedrockLevel",
|
||||
@@ -275,9 +220,6 @@ pub struct FrameUniforms {
|
||||
previous: Option<(glam::DVec3, Mat4, Mat4)>,
|
||||
wetness: f32,
|
||||
eye_smooth: [f32; 2],
|
||||
/// `depthtex0` at the screen's centre as last read back, and `centerDepthSmooth`.
|
||||
pub center_depth_raw: Option<f32>,
|
||||
center_depth: Option<f32>,
|
||||
/// The pack's shadow settings (7.1).
|
||||
pub shadow: Shadow,
|
||||
}
|
||||
@@ -293,8 +235,6 @@ impl Default for FrameUniforms {
|
||||
previous: None,
|
||||
wetness: 0.0,
|
||||
eye_smooth: [0.0; 2],
|
||||
center_depth_raw: None,
|
||||
center_depth: None,
|
||||
shadow: Shadow::default(),
|
||||
}
|
||||
}
|
||||
@@ -439,15 +379,6 @@ impl FrameUniforms {
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
|
||||
}
|
||||
// `centerDepthHalflife`'s default, 1 tick (OptiFine's shaders.txt); the first value
|
||||
// read back is taken as it is.
|
||||
if let Some(raw) = self.center_depth_raw {
|
||||
self.center_depth = Some(match self.center_depth {
|
||||
Some(v) => smooth(v, raw, 1.0 / 20.0, dt),
|
||||
None => raw,
|
||||
});
|
||||
}
|
||||
let center_depth = self.center_depth.unwrap_or(1.0);
|
||||
// Vanilla fades by the stronger of two linear fogs; a pack gets one: the pair (start
|
||||
// and end together) of the one that is complete nearer (OQ-36). Mixing the nearer start
|
||||
// of one with the nearer end of the other made fog begin at the camera on clear days.
|
||||
@@ -493,8 +424,6 @@ impl FrameUniforms {
|
||||
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
|
||||
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
|
||||
"sunPosition" => v3(sun),
|
||||
"endFlashPosition" => v3(to_view(inputs.end_flash_direction)),
|
||||
"centerDepthSmooth" => Value::f(&[center_depth]),
|
||||
"moonPosition" => v3(moon),
|
||||
"shadowLightPosition" => v3(if day { sun } else { moon }),
|
||||
"sunAngle" => Value::f(&[sun_angle]),
|
||||
@@ -554,7 +483,7 @@ impl FrameUniforms {
|
||||
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
|
||||
"cloudHeight" => Value::f(&[inputs.cloud_height]),
|
||||
// renderStage is NONE outside gbuffers draws.
|
||||
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
|
||||
_ => Value::i(&[0]),
|
||||
}
|
||||
};
|
||||
self.values.clear();
|
||||
@@ -600,46 +529,6 @@ impl FrameUniforms {
|
||||
}
|
||||
}
|
||||
|
||||
/// Iris's player status and dimension uniforms, from `inputs`: the vitals are fractions of
|
||||
/// their maximum, and -1 (with the maximums of health and air) in creative and spectator.
|
||||
fn status_value(name: &str, inputs: &PackInputs) -> Option<Value> {
|
||||
let status = inputs.status;
|
||||
let vital = |v: f32| {
|
||||
Value::f(&[if status.survival {
|
||||
v.clamp(0.0, 1.0)
|
||||
} else {
|
||||
-1.0
|
||||
}])
|
||||
};
|
||||
let flag = |b: bool| Value::i(&[i32::from(b)]);
|
||||
Some(match name {
|
||||
"currentPlayerHealth" => vital(status.health / status.max_health.max(1.0)),
|
||||
"maxPlayerHealth" => Value::f(&[if status.survival {
|
||||
status.max_health
|
||||
} else {
|
||||
-1.0
|
||||
}]),
|
||||
"currentPlayerHunger" => vital(status.food / MAX_HUNGER),
|
||||
"maxPlayerHunger" => Value::f(&[MAX_HUNGER]),
|
||||
"currentPlayerAir" => vital(status.air / MAX_AIR),
|
||||
"maxPlayerAir" => Value::f(&[if status.survival { MAX_AIR } else { -1.0 }]),
|
||||
"currentPlayerArmor" => vital(status.armor / MAX_ARMOR),
|
||||
"maxPlayerArmor" => Value::f(&[MAX_ARMOR]),
|
||||
"isSpectator" => flag(status.spectator),
|
||||
"isRiding" => flag(status.riding),
|
||||
"feetInWater" => flag(status.feet_in_water),
|
||||
"inSwimmingAnimation" => flag(status.swimming),
|
||||
"is_burning" => flag(status.burning),
|
||||
"is_hurt" => flag(status.hurt),
|
||||
// Spectators count as invisible.
|
||||
"is_invisible" => flag(status.invisible || status.spectator),
|
||||
"is_sneaking" => flag(status.sneaking),
|
||||
"is_sprinting" => flag(status.sprinting),
|
||||
"endFlashIntensity" => Value::f(&[inputs.end_flash]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// How many components the uniform `name` has (16 for a matrix).
|
||||
fn components(name: &str) -> usize {
|
||||
match name {
|
||||
@@ -658,8 +547,7 @@ fn components(name: &str) -> usize {
|
||||
| "moonPosition"
|
||||
| "shadowLightPosition"
|
||||
| "fogColor"
|
||||
| "skyColor"
|
||||
| "endFlashPosition" => 3,
|
||||
| "skyColor" => 3,
|
||||
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
|
||||
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
|
||||
_ => 1,
|
||||
@@ -688,77 +576,6 @@ mod tests {
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
/// Iris's status uniforms as its documentation defines them (shaders.properties, "Status").
|
||||
#[test]
|
||||
fn status_uniforms_follow_iris() {
|
||||
let float = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
|
||||
Some(Value::F(f)) => f[0],
|
||||
other => panic!("{name}: {other:?}"),
|
||||
};
|
||||
let int = |name: &str, inputs: &PackInputs| match status_value(name, inputs) {
|
||||
Some(Value::I(i)) => i[0],
|
||||
other => panic!("{name}: {other:?}"),
|
||||
};
|
||||
let mut inputs = PackInputs {
|
||||
status: PlayerStatus {
|
||||
survival: true,
|
||||
health: 15.0,
|
||||
max_health: 20.0,
|
||||
food: 10.0,
|
||||
air: 150.0,
|
||||
armor: 20.0,
|
||||
sneaking: true,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
// The vitals are fractions of their maximums.
|
||||
assert_eq!(float("currentPlayerHealth", &inputs), 0.75);
|
||||
assert_eq!(float("maxPlayerHealth", &inputs), 20.0);
|
||||
assert_eq!(float("currentPlayerHunger", &inputs), 0.5);
|
||||
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
|
||||
assert_eq!(float("currentPlayerAir", &inputs), 0.5);
|
||||
assert_eq!(float("maxPlayerAir", &inputs), 300.0);
|
||||
assert_eq!(float("currentPlayerArmor", &inputs), 0.4);
|
||||
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
|
||||
assert_eq!(int("is_sneaking", &inputs), 1);
|
||||
assert_eq!(int("is_invisible", &inputs), 0);
|
||||
// Creative and spectator: -1 for the vitals and the maximums of health and air;
|
||||
// spectators are invisible.
|
||||
inputs.status.survival = false;
|
||||
inputs.status.spectator = true;
|
||||
for name in [
|
||||
"currentPlayerHealth",
|
||||
"maxPlayerHealth",
|
||||
"currentPlayerHunger",
|
||||
"currentPlayerAir",
|
||||
"maxPlayerAir",
|
||||
"currentPlayerArmor",
|
||||
] {
|
||||
assert_eq!(float(name, &inputs), -1.0, "{name}");
|
||||
}
|
||||
assert_eq!(float("maxPlayerHunger", &inputs), 20.0);
|
||||
assert_eq!(float("maxPlayerArmor", &inputs), 50.0);
|
||||
assert_eq!(int("isSpectator", &inputs), 1);
|
||||
assert_eq!(int("is_invisible", &inputs), 1);
|
||||
// Each is provided, not left at zero for the pack.
|
||||
for name in [
|
||||
"currentPlayerHealth",
|
||||
"isSpectator",
|
||||
"is_sprinting",
|
||||
"bedrockLevel",
|
||||
] {
|
||||
assert!(index(name).is_some(), "{name} is not provided");
|
||||
}
|
||||
assert!(
|
||||
NAMES
|
||||
.iter()
|
||||
.filter(|n| status_value(n, &inputs).is_some())
|
||||
.count()
|
||||
== 18
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloud_height_needs_a_cloud_color() {
|
||||
assert_eq!(cloud_height(0.8, 192.33), 192.33);
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
//! The frame's commands finished, submitted and presented on a thread of their own.
|
||||
//!
|
||||
//! wgpu records a pass's commands lazily: `CommandEncoder::finish` validates and
|
||||
//! encodes the whole frame (about 0.1 ms with the submit), work the window's thread
|
||||
//! would otherwise wait for before building the next frame. The renderer hands the
|
||||
//! frame's encoder to [`Queue::submit_later`] and goes on; every use of the queue
|
||||
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
|
||||
//! submissions keep the order they had on one thread.
|
||||
use std::ops::Deref;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, mpsc};
|
||||
|
||||
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
|
||||
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
|
||||
/// macOS's Metal it stalled: offscreen at render distance 12, a tenth to a half of the frame rate
|
||||
/// with 20-180 ms p99 frames, against the same binary without it. Windows is not measured yet.
|
||||
pub fn wanted() -> bool {
|
||||
match std::env::var("HORIZON_SUBMIT_THREAD").as_deref() {
|
||||
Ok("0") => false,
|
||||
Ok("1") => true,
|
||||
_ => cfg!(target_os = "linux"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Work to start right after a frame's submission (a readback's `map_async`).
|
||||
pub type AfterSubmit = Box<dyn FnOnce() + Send>;
|
||||
|
||||
/// A frame for the submit thread: its commands and, in a window, the surface to present.
|
||||
pub struct Job {
|
||||
pub encoder: wgpu::CommandEncoder,
|
||||
pub surface: Option<wgpu::SurfaceTexture>,
|
||||
pub after: Option<AfterSubmit>,
|
||||
}
|
||||
|
||||
struct Thread {
|
||||
jobs: Option<mpsc::Sender<Job>>,
|
||||
handle: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
/// What the submit thread sent back, once it is taken in by [`Queue::wait`].
|
||||
struct Landing {
|
||||
done: mpsc::Receiver<wgpu::SubmissionIndex>,
|
||||
landed: Option<wgpu::SubmissionIndex>,
|
||||
}
|
||||
|
||||
/// The device's queue, with frames submitted on the submit thread when it runs.
|
||||
pub struct Queue {
|
||||
queue: wgpu::Queue,
|
||||
thread: Option<Thread>,
|
||||
/// A frame went to the submit thread and has not been waited for.
|
||||
pending: AtomicBool,
|
||||
landing: Mutex<Option<Landing>>,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
/// `threaded`: frames may go to a submit thread (the renderer asks [`wanted`]).
|
||||
pub fn new(queue: wgpu::Queue, threaded: bool) -> Self {
|
||||
let mut this = Self {
|
||||
queue,
|
||||
thread: None,
|
||||
pending: AtomicBool::new(false),
|
||||
landing: Mutex::new(None),
|
||||
};
|
||||
if !threaded {
|
||||
return this;
|
||||
}
|
||||
let (jobs, job_rx) = mpsc::channel::<Job>();
|
||||
let (done_tx, done) = mpsc::channel();
|
||||
let queue = this.queue.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Render submit".into())
|
||||
.spawn(move || {
|
||||
for job in job_rx {
|
||||
let commands = job.encoder.finish();
|
||||
let submission = queue.submit([commands]);
|
||||
if let Some(after) = job.after {
|
||||
after();
|
||||
}
|
||||
if let Some(surface) = job.surface {
|
||||
queue.present(surface);
|
||||
}
|
||||
if done_tx.send(submission).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
match spawned {
|
||||
Ok(handle) => {
|
||||
this.thread = Some(Thread {
|
||||
jobs: Some(jobs),
|
||||
handle: Some(handle),
|
||||
});
|
||||
*this.lock() = Some(Landing { done, landed: None });
|
||||
}
|
||||
Err(e) => log::warn!("Horizon renderer: no submit thread ({e}); submitting in place"),
|
||||
}
|
||||
this
|
||||
}
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Option<Landing>> {
|
||||
self.landing
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
/// Whether frames can go to the submit thread.
|
||||
pub fn threaded(&self) -> bool {
|
||||
self.thread.is_some()
|
||||
}
|
||||
/// Finish, submit and present the frame on the submit thread, then run its `after`;
|
||||
/// given back, with nothing done, when there is none.
|
||||
pub fn submit_later(&self, job: Job) -> Result<(), Box<Job>> {
|
||||
let Some(jobs) = self.thread.as_ref().and_then(|t| t.jobs.as_ref()) else {
|
||||
return Err(Box::new(job));
|
||||
};
|
||||
self.wait();
|
||||
match jobs.send(job) {
|
||||
Ok(()) => {
|
||||
self.pending.store(true, Ordering::Release);
|
||||
Ok(())
|
||||
}
|
||||
Err(mpsc::SendError(job)) => Err(Box::new(job)),
|
||||
}
|
||||
}
|
||||
/// Wait until the frame on the submit thread is submitted and presented.
|
||||
pub fn wait(&self) {
|
||||
if !self.pending.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let mut landing = self.lock();
|
||||
if !self.pending.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if let Some(l) = landing.as_mut() {
|
||||
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
|
||||
let submission = l
|
||||
.done
|
||||
.recv()
|
||||
.expect("Horizon renderer: the submit thread stopped");
|
||||
l.landed = Some(submission);
|
||||
}
|
||||
self.pending.store(false, Ordering::Release);
|
||||
}
|
||||
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
|
||||
/// made; `None` if it was taken already.
|
||||
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
|
||||
self.wait();
|
||||
self.lock().as_mut().and_then(|l| l.landed.take())
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Queue {
|
||||
type Target = wgpu::Queue;
|
||||
fn deref(&self) -> &wgpu::Queue {
|
||||
self.wait();
|
||||
&self.queue
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Queue {
|
||||
fn drop(&mut self) {
|
||||
self.wait();
|
||||
if let Some(thread) = &mut self.thread {
|
||||
thread.jobs.take();
|
||||
if let Some(handle) = thread.handle.take() {
|
||||
let _ = handle.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
fn buffer(device: &wgpu::Device, usage: wgpu::BufferUsages) -> wgpu::Buffer {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 4,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_after_a_frame_on_the_thread_land_after_it() {
|
||||
let Some((device, queue)) = device() else {
|
||||
return;
|
||||
};
|
||||
let queue = Queue::new(queue, true);
|
||||
assert!(queue.threaded());
|
||||
use wgpu::BufferUsages as U;
|
||||
let source = buffer(&device, U::COPY_SRC | U::COPY_DST);
|
||||
let copy = buffer(&device, U::COPY_SRC | U::COPY_DST);
|
||||
let read = buffer(&device, U::MAP_READ | U::COPY_DST);
|
||||
queue.write_buffer(&source, 0, &[1, 2, 3, 4]);
|
||||
for frame in 0..3u8 {
|
||||
// The frame copies `source`; the next frame's write must not reach that copy.
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(&source, 0, ©, 0, 4);
|
||||
let job = Job {
|
||||
encoder,
|
||||
surface: None,
|
||||
after: None,
|
||||
};
|
||||
assert!(queue.submit_later(job).is_ok());
|
||||
queue.write_buffer(&source, 0, &[frame + 10; 4]);
|
||||
assert!(queue.landed().is_some(), "frame {frame} submitted");
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(©, 0, &read, 0, 4);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let expected = if frame == 0 {
|
||||
[1, 2, 3, 4]
|
||||
} else {
|
||||
[frame + 9; 4]
|
||||
};
|
||||
assert_eq!(*read.slice(..).get_mapped_range().unwrap(), expected);
|
||||
read.unmap();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,16 +299,6 @@ impl Post {
|
||||
Mat4::from_translation(jitter.extend(0.0))
|
||||
}
|
||||
|
||||
/// This frame's jitter in rendered pixels, y down (FSR's `jitterOffset`); zero
|
||||
/// without TAA.
|
||||
pub fn jitter_pixels(&self) -> [f32; 2] {
|
||||
let Some(size) = self.size().filter(|_| self.taa_on) else {
|
||||
return [0.0; 2];
|
||||
};
|
||||
let j = self.taa_this_frame.jitter;
|
||||
[j[0] * size[0] as f32 * 0.5, -j[1] * size[1] as f32 * 0.5]
|
||||
}
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen.
|
||||
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
|
||||
@@ -517,12 +507,7 @@ impl Post {
|
||||
|
||||
/// This frame's clip space to the previous frame's, for camera-relative projections:
|
||||
/// a point `camera - previous_camera` further from the previous camera than from this one.
|
||||
pub(super) fn reprojection(
|
||||
previous: DMat4,
|
||||
previous_camera: DVec3,
|
||||
current: DMat4,
|
||||
camera: DVec3,
|
||||
) -> DMat4 {
|
||||
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
|
||||
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
|
||||
}
|
||||
|
||||
|
||||
@@ -79,47 +79,6 @@ impl Preset {
|
||||
}
|
||||
}
|
||||
|
||||
/// Entity occlusion strategy. The synchronous mode preserves the original
|
||||
/// same-frame CPU test; asynchronous results only hide a mesh when their
|
||||
/// camera, bounds and occluder evidence still match.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum EntityCullingMode {
|
||||
#[default]
|
||||
Off,
|
||||
Synchronous,
|
||||
Asynchronous,
|
||||
}
|
||||
impl EntityCullingMode {
|
||||
pub const ALL: [EntityCullingMode; 3] = [
|
||||
EntityCullingMode::Off,
|
||||
EntityCullingMode::Synchronous,
|
||||
EntityCullingMode::Asynchronous,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "off",
|
||||
Self::Synchronous => "sync",
|
||||
Self::Asynchronous => "async",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Off => "Off",
|
||||
Self::Synchronous => "Synchronous",
|
||||
Self::Asynchronous => "Asynchronous",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
match s {
|
||||
// Keep graphics files written before the mode selector readable.
|
||||
"true" | "synchronous" | "sync" => Self::Synchronous,
|
||||
"false" | "off" => Self::Off,
|
||||
"asynchronous" | "async" => Self::Asynchronous,
|
||||
_ => Self::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the swapchain presents frames.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PresentMode {
|
||||
@@ -287,91 +246,6 @@ impl FarDetail {
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame generation: a frame made between each two real ones.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum FrameGeneration {
|
||||
#[default]
|
||||
Off,
|
||||
/// AMD FidelityFX Super Resolution 3.1's frame interpolation, ported to WGSL.
|
||||
Fsr31,
|
||||
}
|
||||
impl FrameGeneration {
|
||||
pub const ALL: [FrameGeneration; 2] = [FrameGeneration::Off, FrameGeneration::Fsr31];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
FrameGeneration::Off => "off",
|
||||
FrameGeneration::Fsr31 => "fsr3.1",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FrameGeneration::Off => "Off",
|
||||
FrameGeneration::Fsr31 => "FSR 3.1",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|f| f.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What the section mesher does with leaf faces inside a canopy. Terrain
|
||||
/// geometry only: entities, particles and block entities are untouched.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LeafCullingMode {
|
||||
/// Every leaf face, as vanilla's fancy leaves draw them.
|
||||
#[default]
|
||||
Off,
|
||||
/// Leaves surrounded on all six sides by leaves or full opaque cubes draw
|
||||
/// in the opaque layer, so their cutouts no longer show the layers behind.
|
||||
Solid,
|
||||
/// Like `Solid`, but a leaf with one open side draws opaque too: the
|
||||
/// exposed shell thins by a layer and the canopy looks chunkier.
|
||||
SolidAggressive,
|
||||
/// Drops the faces two leaves share. Faces toward air, cavities, partial
|
||||
/// models, unknown neighbours and section boundaries stay.
|
||||
Hollow,
|
||||
}
|
||||
impl LeafCullingMode {
|
||||
pub const ALL: [LeafCullingMode; 4] = [
|
||||
LeafCullingMode::Off,
|
||||
LeafCullingMode::Solid,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
LeafCullingMode::Hollow,
|
||||
];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
LeafCullingMode::Off => "off",
|
||||
LeafCullingMode::Solid => "solid",
|
||||
LeafCullingMode::SolidAggressive => "solid_aggressive",
|
||||
LeafCullingMode::Hollow => "hollow",
|
||||
}
|
||||
}
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
LeafCullingMode::Off => "Off",
|
||||
LeafCullingMode::Solid => "Solid",
|
||||
LeafCullingMode::SolidAggressive => "Solid Aggressive",
|
||||
LeafCullingMode::Hollow => "Hollow",
|
||||
}
|
||||
}
|
||||
/// Whether this mode draws interior leaf faces in the solid layer.
|
||||
pub fn is_interior(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
LeafCullingMode::Solid | LeafCullingMode::SolidAggressive
|
||||
)
|
||||
}
|
||||
fn parse(s: &str) -> Self {
|
||||
Self::ALL
|
||||
.into_iter()
|
||||
.find(|mode| mode.name() == s)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// TAA upscaling: the world renders this much smaller than the resolution scale's size,
|
||||
/// and TAA builds the full-size picture from the jittered frames (FSR's and DLSS's ratios).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
@@ -487,46 +361,6 @@ impl Graphics {
|
||||
pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 {
|
||||
lod_distance_capped(self.lod_distance, info)
|
||||
}
|
||||
/// Whether the selected packs leave vanilla's block models in place.
|
||||
/// Horizon's occlusion assumptions (entity and leaf culling) hold only
|
||||
/// for those: a pack can replace an opaque cube's model or a leaf's.
|
||||
pub fn vanilla_models(&self, resource_packs: &[String]) -> bool {
|
||||
resource_packs.iter().all(|id| id == "vanilla")
|
||||
}
|
||||
/// The same check including packs overlaid by the connected server.
|
||||
/// Their ids are namespaced as `server/...`, so an active server pack
|
||||
/// disables model-dependent occlusion assumptions.
|
||||
pub fn vanilla_models_with_server_packs<'a>(
|
||||
&self,
|
||||
resource_packs: impl IntoIterator<Item = &'a str>,
|
||||
server_packs: impl IntoIterator<Item = &'a str>,
|
||||
) -> bool {
|
||||
resource_packs
|
||||
.into_iter()
|
||||
.chain(server_packs)
|
||||
.all(|id| id == "vanilla")
|
||||
}
|
||||
/// The leaf culling the mesher may apply. A pack that can replace leaf
|
||||
/// models falls back to `Off`, which draws exactly vanilla's leaves.
|
||||
pub fn leaf_culling_effective(&self, resource_packs: &[String]) -> LeafCullingMode {
|
||||
if self.vanilla_models(resource_packs) {
|
||||
self.leaf_culling
|
||||
} else {
|
||||
LeafCullingMode::Off
|
||||
}
|
||||
}
|
||||
/// The effective mode when local and server resource packs are active.
|
||||
pub fn leaf_culling_effective_with_server_packs<'a>(
|
||||
&self,
|
||||
resource_packs: impl IntoIterator<Item = &'a str>,
|
||||
server_packs: impl IntoIterator<Item = &'a str>,
|
||||
) -> LeafCullingMode {
|
||||
if self.vanilla_models_with_server_packs(resource_packs, server_packs) {
|
||||
self.leaf_culling
|
||||
} else {
|
||||
LeafCullingMode::Off
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Graphics {
|
||||
@@ -537,12 +371,8 @@ pub struct Graphics {
|
||||
pub far_detail: FarDetail,
|
||||
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
|
||||
pub cave_culling: bool,
|
||||
/// Skip drawing entities hidden behind opaque terrain, synchronously or asynchronously.
|
||||
pub entity_culling: EntityCullingMode,
|
||||
/// Terrain leaf faces inside a canopy (see [`LeafCullingMode`]); `Off` draws
|
||||
/// vanilla's fancy leaves. Ignored with a resource pack that may replace
|
||||
/// the vanilla leaf models ([`Graphics::leaf_culling_effective`]).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Skip drawing entities hidden behind opaque terrain.
|
||||
pub entity_culling: bool,
|
||||
/// Meshed sections uploaded per frame; 0 uploads all that are ready.
|
||||
pub chunk_updates: u32,
|
||||
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
|
||||
@@ -572,8 +402,6 @@ pub struct Graphics {
|
||||
pub upscaling: Upscaling,
|
||||
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
|
||||
pub screenshot_quality: u32,
|
||||
/// Frame generation (Horizon setting), off by default.
|
||||
pub frame_generation: FrameGeneration,
|
||||
/// HDR output (HDR10, or scRGB), where the display path offers it.
|
||||
pub hdr: bool,
|
||||
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
|
||||
@@ -617,8 +445,7 @@ impl Default for Graphics {
|
||||
lod_distance: 0,
|
||||
far_detail: FarDetail::Full,
|
||||
cave_culling: true,
|
||||
entity_culling: EntityCullingMode::Off,
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
entity_culling: false,
|
||||
chunk_updates: 0,
|
||||
upload_budget_kib: 0,
|
||||
texture_budget_mib: 0,
|
||||
@@ -636,7 +463,6 @@ impl Default for Graphics {
|
||||
taa: false,
|
||||
upscaling: Upscaling::Native,
|
||||
screenshot_quality: 1,
|
||||
frame_generation: FrameGeneration::Off,
|
||||
hdr: false,
|
||||
hdr_paper_white: 0,
|
||||
hdr_peak: 0,
|
||||
@@ -688,8 +514,7 @@ impl Graphics {
|
||||
}
|
||||
"farDetail" => g.far_detail = FarDetail::parse(value),
|
||||
"caveCulling" => g.cave_culling = flag(g.cave_culling),
|
||||
"entityCulling" => g.entity_culling = EntityCullingMode::parse(value),
|
||||
"leafCulling" => g.leaf_culling = LeafCullingMode::parse(value),
|
||||
"entityCulling" => g.entity_culling = flag(g.entity_culling),
|
||||
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
|
||||
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
|
||||
"textureBudgetMiB" => {
|
||||
@@ -718,7 +543,6 @@ impl Graphics {
|
||||
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
|
||||
.unwrap_or(1)
|
||||
}
|
||||
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
|
||||
"hdr" => g.hdr = flag(g.hdr),
|
||||
"hdrPaperWhite" => {
|
||||
g.hdr_paper_white = match number(g.hdr_paper_white) {
|
||||
@@ -756,13 +580,12 @@ impl Graphics {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
|
||||
self.preset.name(),
|
||||
self.lod_distance,
|
||||
self.far_detail.name(),
|
||||
bool_text(self.cave_culling),
|
||||
self.entity_culling.name(),
|
||||
self.leaf_culling.name(),
|
||||
bool_text(self.entity_culling),
|
||||
self.chunk_updates,
|
||||
self.upload_budget_kib,
|
||||
self.texture_budget_mib,
|
||||
@@ -787,7 +610,6 @@ impl Graphics {
|
||||
bool_text(self.taa),
|
||||
self.upscaling.name(),
|
||||
self.screenshot_quality,
|
||||
self.frame_generation.name(),
|
||||
bool_text(self.hdr),
|
||||
self.hdr_paper_white,
|
||||
self.hdr_peak,
|
||||
@@ -919,8 +741,7 @@ mod tests {
|
||||
lod_distance: 256,
|
||||
far_detail: FarDetail::Reduced,
|
||||
cave_culling: false,
|
||||
entity_culling: EntityCullingMode::Synchronous,
|
||||
leaf_culling: LeafCullingMode::SolidAggressive,
|
||||
entity_culling: true,
|
||||
chunk_updates: 12,
|
||||
upload_budget_kib: 4096,
|
||||
texture_budget_mib: 1536,
|
||||
@@ -938,7 +759,6 @@ mod tests {
|
||||
taa: true,
|
||||
upscaling: Upscaling::Balanced,
|
||||
screenshot_quality: 4,
|
||||
frame_generation: FrameGeneration::Fsr31,
|
||||
hdr: true,
|
||||
hdr_paper_white: 240,
|
||||
hdr_peak: 1400,
|
||||
@@ -952,21 +772,6 @@ mod tests {
|
||||
shader_pack: "Golden Hour".into(),
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g);
|
||||
for mode in EntityCullingMode::ALL {
|
||||
let graphics = Graphics {
|
||||
entity_culling: mode,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
|
||||
}
|
||||
assert_eq!(
|
||||
Graphics::parse("entityCulling:true\n").entity_culling,
|
||||
EntityCullingMode::Synchronous
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse("entityCulling:false\n").entity_culling,
|
||||
EntityCullingMode::Off
|
||||
);
|
||||
assert_eq!(Graphics::parse(""), Graphics::default());
|
||||
// Translucent Batching is opt-in for now: only the file turns it on.
|
||||
assert!(!Graphics::default().translucent_batching);
|
||||
@@ -996,96 +801,11 @@ mod tests {
|
||||
Graphics::default()
|
||||
);
|
||||
assert_eq!(
|
||||
Graphics::parse(
|
||||
"workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\nleafCulling:maybe\n"
|
||||
),
|
||||
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:true\nentityCulling:maybe\n"),
|
||||
Graphics::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_round_trips_its_stable_names() {
|
||||
assert_eq!(Graphics::default().leaf_culling, LeafCullingMode::Off);
|
||||
for mode in LeafCullingMode::ALL {
|
||||
let g = Graphics {
|
||||
leaf_culling: mode,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert_eq!(Graphics::parse(&g.serialize()), g, "{mode:?}");
|
||||
assert_eq!(
|
||||
Graphics::parse(&format!("leafCulling:{}\n", mode.name())).leaf_culling,
|
||||
mode
|
||||
);
|
||||
}
|
||||
for text in [
|
||||
"leafCulling:other\n",
|
||||
"leafCulling:\n",
|
||||
"leafCulling:SOLID\n",
|
||||
] {
|
||||
assert_eq!(
|
||||
Graphics::parse(text).leaf_culling,
|
||||
LeafCullingMode::Off,
|
||||
"{text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_keep_the_leaf_culling_choice() {
|
||||
for mode in LeafCullingMode::ALL {
|
||||
for preset in Preset::ALL {
|
||||
let mut s = crate::settings::Settings::default();
|
||||
s.horizon.leaf_culling = mode;
|
||||
apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.leaf_culling, mode, "{preset:?}");
|
||||
assert_eq!(Graphics::parse(&s.horizon.serialize()), s.horizon);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_packs_turn_leaf_culling_off() {
|
||||
let g = Graphics {
|
||||
leaf_culling: LeafCullingMode::Hollow,
|
||||
..Graphics::default()
|
||||
};
|
||||
assert!(g.vanilla_models(&["vanilla".into()]));
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective(&["vanilla".into()]),
|
||||
LeafCullingMode::Hollow
|
||||
);
|
||||
assert!(!g.vanilla_models(&["vanilla".into(), "My Pack".into()]));
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective(&["vanilla".into(), "My Pack".into()]),
|
||||
LeafCullingMode::Off
|
||||
);
|
||||
assert_eq!(g.leaf_culling_effective(&[]), LeafCullingMode::Hollow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_pack_apply_disables_leaf_culling_and_removal_restores_it() {
|
||||
let g = Graphics {
|
||||
leaf_culling: LeafCullingMode::Solid,
|
||||
..Graphics::default()
|
||||
};
|
||||
let selected = ["vanilla"];
|
||||
let applied_server_pack = ["server/0123456789abcdef/model-pack.zip"];
|
||||
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective_with_server_packs(selected, applied_server_pack),
|
||||
LeafCullingMode::Off,
|
||||
"an applied server pack can replace block models"
|
||||
);
|
||||
assert!(!g.vanilla_models_with_server_packs(selected, applied_server_pack));
|
||||
|
||||
assert_eq!(
|
||||
g.leaf_culling_effective_with_server_packs(selected, []),
|
||||
LeafCullingMode::Solid,
|
||||
"removing the server pack restores the selected local mode"
|
||||
);
|
||||
assert!(g.vanilla_models_with_server_packs(selected, []));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_gpus_other_than_apple_cap_the_lod_distance() {
|
||||
let adapter = |name: &str, device_type| wgpu::AdapterInfo {
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
//! they show, in what groups, with what description and cost, and their
|
||||
//! input. How they look comes from `graphics_style`.
|
||||
use crate::{
|
||||
graphics::{
|
||||
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
|
||||
Pacing, PresentMode, Preset, Upscaling,
|
||||
},
|
||||
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
|
||||
graphics_style::{Cost, STYLE},
|
||||
hud::{HudAtlas, HudBatch},
|
||||
lang,
|
||||
@@ -30,7 +27,6 @@ pub enum H {
|
||||
FarDetail,
|
||||
CaveCulling,
|
||||
EntityCulling,
|
||||
LeafCulling,
|
||||
ChunkUpdates,
|
||||
UploadBudget,
|
||||
Sky,
|
||||
@@ -46,7 +42,6 @@ pub enum H {
|
||||
Antialiasing,
|
||||
Upscaling,
|
||||
ScreenshotQuality,
|
||||
FrameGeneration,
|
||||
Hdr,
|
||||
/// Opens `Level::Hdr`.
|
||||
HdrSettings,
|
||||
@@ -192,12 +187,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
Medium,
|
||||
"How far away mobs and items are drawn, as a share of normal.",
|
||||
),
|
||||
h(
|
||||
H::ChunkUpdates,
|
||||
Low,
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
),
|
||||
Row::Header("Culling"),
|
||||
h(
|
||||
H::CaveCulling,
|
||||
Saves,
|
||||
@@ -206,12 +195,12 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
h(
|
||||
H::EntityCulling,
|
||||
None,
|
||||
"Hides models behind opaque blocks. Async checks off-thread; entities tick.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.",
|
||||
),
|
||||
h(
|
||||
H::LeafCulling,
|
||||
Saves,
|
||||
"Draws or drops leaf faces inside canopies. Off keeps vanilla's fancy leaves.",
|
||||
H::ChunkUpdates,
|
||||
Low,
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.",
|
||||
),
|
||||
Row::Header("Quality"),
|
||||
v(
|
||||
@@ -302,11 +291,6 @@ pub fn rows(level: Level) -> Vec<Row> {
|
||||
None,
|
||||
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
|
||||
),
|
||||
h(
|
||||
H::FrameGeneration,
|
||||
Medium,
|
||||
"Shows a made-up frame between each two real ones: smoother, a little more lag.",
|
||||
),
|
||||
h(
|
||||
H::HdrSettings,
|
||||
None,
|
||||
@@ -462,16 +446,6 @@ fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
|
||||
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
|
||||
"Set up for your display. Leave on Auto unless it looks wrong."
|
||||
}
|
||||
(Item::Horizon(H::LeafCulling), _) => match s.horizon.leaf_culling {
|
||||
LeafCullingMode::Off => "Every leaf face. Vanilla's fancy leaves.",
|
||||
LeafCullingMode::Solid => {
|
||||
"Leaves fully inside a canopy draw solid: fewer see-through layers."
|
||||
}
|
||||
LeafCullingMode::SolidAggressive => {
|
||||
"Leaves with one open side draw solid too: faster, chunkier edges."
|
||||
}
|
||||
LeafCullingMode::Hollow => "Drops faces between leaves: faster, but gaps look through.",
|
||||
},
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
@@ -541,8 +515,7 @@ impl H {
|
||||
},
|
||||
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
|
||||
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
|
||||
H::EntityCulling => labelled("Entity Culling", g.entity_culling.label()),
|
||||
H::LeafCulling => labelled("Leaf Culling", g.leaf_culling.label()),
|
||||
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
|
||||
H::ChunkUpdates => match g.chunk_updates {
|
||||
0 => labelled("Chunk Updates", "Unlimited"),
|
||||
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
|
||||
@@ -587,7 +560,6 @@ impl H {
|
||||
H::ScreenshotQuality => {
|
||||
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
|
||||
}
|
||||
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
|
||||
H::Hdr => labelled("HDR", on_off(g.hdr)),
|
||||
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
|
||||
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
|
||||
@@ -618,7 +590,7 @@ impl H {
|
||||
),
|
||||
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
|
||||
H::HdrKey => {
|
||||
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
|
||||
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
|
||||
}
|
||||
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
|
||||
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
|
||||
@@ -676,12 +648,7 @@ impl H {
|
||||
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
|
||||
}
|
||||
H::CaveCulling => g.cave_culling = !g.cave_culling,
|
||||
H::EntityCulling => {
|
||||
g.entity_culling = step(&EntityCullingMode::ALL, g.entity_culling, back)
|
||||
}
|
||||
H::LeafCulling => {
|
||||
g.leaf_culling = step(&LeafCullingMode::ALL, g.leaf_culling, back);
|
||||
}
|
||||
H::EntityCulling => g.entity_culling = !g.entity_culling,
|
||||
H::Sky => g.sky = !g.sky,
|
||||
H::Fog => g.fog = !g.fog,
|
||||
H::Weather => g.weather = !g.weather,
|
||||
@@ -711,9 +678,6 @@ impl H {
|
||||
back,
|
||||
)
|
||||
}
|
||||
H::FrameGeneration => {
|
||||
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
|
||||
}
|
||||
H::Hdr => g.hdr = !g.hdr,
|
||||
H::BatteryMode => g.battery = !g.battery,
|
||||
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
|
||||
@@ -772,6 +736,7 @@ impl Item {
|
||||
let g = &s.horizon;
|
||||
Some(match self {
|
||||
Item::Horizon(H::CaveCulling) => g.cave_culling,
|
||||
Item::Horizon(H::EntityCulling) => g.entity_culling,
|
||||
Item::Horizon(H::Sky) => g.sky,
|
||||
Item::Horizon(H::Fog) => g.fog,
|
||||
Item::Horizon(H::Weather) => g.weather,
|
||||
@@ -839,7 +804,6 @@ impl Item {
|
||||
| H::Pacing
|
||||
| H::PresentMode
|
||||
| H::Workers
|
||||
| H::FrameGeneration
|
||||
| H::BatteryMode
|
||||
| H::ShadowCache
|
||||
| H::GraphicsApi
|
||||
@@ -1075,12 +1039,9 @@ impl GraphicsUi {
|
||||
let mut label = item.label(s);
|
||||
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
|
||||
// KeyBindsScreen: "> key <" while it waits.
|
||||
label = Text::translate(
|
||||
"Shortcut: %s",
|
||||
&[Text::literal("> ")
|
||||
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
|
||||
.append(" <")],
|
||||
);
|
||||
label = Text::literal("Shortcut: > ")
|
||||
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
|
||||
.append(" <");
|
||||
}
|
||||
let blocked = item.blocked(s);
|
||||
let live = blocked.is_none();
|
||||
@@ -1865,92 +1826,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaf_culling_cycles_modes_in_the_advanced_culling_group() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::LeafCulling);
|
||||
assert_eq!(item.switch(&s), None);
|
||||
assert_eq!(item.label(&s).plain(), "Leaf Culling: Off");
|
||||
for label in ["Solid", "Solid Aggressive", "Hollow", "Off"] {
|
||||
item.cycle(&mut s, false, [854, 480]);
|
||||
assert_eq!(item.label(&s).plain(), format!("Leaf Culling: {label}"));
|
||||
assert_eq!(
|
||||
crate::graphics::Graphics::parse(&s.horizon.serialize()).leaf_culling,
|
||||
s.horizon.leaf_culling
|
||||
);
|
||||
}
|
||||
item.cycle(&mut s, true, [854, 480]);
|
||||
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Hollow);
|
||||
|
||||
// The three culling settings share one group, below Rendering's chunk
|
||||
// updates and above Quality.
|
||||
let rows = rows(Level::Advanced);
|
||||
let culling = rows
|
||||
.iter()
|
||||
.position(|row| matches!(row, Row::Header("Culling")))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
rows.get(culling + 1),
|
||||
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::CaveCulling)
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 2),
|
||||
Some(Row::Setting(setting)) if setting.item == Item::Horizon(H::EntityCulling)
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 3),
|
||||
Some(Row::Setting(setting)) if setting.item == item
|
||||
));
|
||||
assert!(matches!(
|
||||
rows.get(culling + 4),
|
||||
Some(Row::Header("Quality"))
|
||||
));
|
||||
|
||||
// The description names what the picked mode does.
|
||||
for mode in LeafCullingMode::ALL {
|
||||
s.horizon.leaf_culling = mode;
|
||||
let text = description(item, &s, "fallback");
|
||||
assert_ne!(text, "fallback", "{mode:?}");
|
||||
assert!(text.len() < 80, "{mode:?}: {text}");
|
||||
}
|
||||
|
||||
// A click cycles it and leaves the preset, as the other quality
|
||||
// settings do.
|
||||
let mut ui = GraphicsUi::default();
|
||||
let size = (480.0, 4000.0);
|
||||
let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
|
||||
let control = widgets
|
||||
.iter()
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
s.horizon.leaf_culling = LeafCullingMode::Off;
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
Level::Advanced,
|
||||
&mut s,
|
||||
(control.x + 1.0, control.y + 1.0),
|
||||
size,
|
||||
[854, 480],
|
||||
false,
|
||||
false
|
||||
),
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(s.horizon.leaf_culling, LeafCullingMode::Solid);
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_culling_modes_are_opt_in_and_cycle_from_advanced_graphics() {
|
||||
fn entity_culling_is_opt_in_and_toggles_from_advanced_graphics() {
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Horizon(H::EntityCulling);
|
||||
assert_eq!(s.horizon.entity_culling, EntityCullingMode::Off);
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
assert_eq!(item.label(&s).plain(), "Entity Culling: Off");
|
||||
for mode in EntityCullingMode::ALL {
|
||||
s.horizon.entity_culling = mode;
|
||||
for enabled in [false, true] {
|
||||
s.horizon.entity_culling = enabled;
|
||||
for preset in Preset::ALL {
|
||||
crate::graphics::apply_preset(preset, &mut s);
|
||||
assert_eq!(s.horizon.entity_culling, mode);
|
||||
assert_eq!(s.horizon.entity_culling, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1962,11 +1847,7 @@ mod tests {
|
||||
.find(|w| matches!(w.target, Target::Item(i) if i == item))
|
||||
.unwrap();
|
||||
let point = (switch.x + 1.0, switch.y + 1.0);
|
||||
for mode in [
|
||||
EntityCullingMode::Off,
|
||||
EntityCullingMode::Synchronous,
|
||||
EntityCullingMode::Asynchronous,
|
||||
] {
|
||||
for enabled in [false, true] {
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(
|
||||
ui.click(
|
||||
@@ -1980,10 +1861,10 @@ mod tests {
|
||||
),
|
||||
vec![Action::ButtonClick, Action::Changed]
|
||||
);
|
||||
assert_eq!(s.horizon.entity_culling, mode);
|
||||
assert_eq!(item.switch(&s), Some(enabled));
|
||||
assert_eq!(s.horizon.preset, Preset::Custom);
|
||||
}
|
||||
assert_eq!(item.label(&s).plain(), "Entity Culling: Asynchronous");
|
||||
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
//! or Escape leaves them as they were.
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch, rgba},
|
||||
lang,
|
||||
settings::Settings,
|
||||
text::Text,
|
||||
ui,
|
||||
@@ -65,19 +64,14 @@ impl Step {
|
||||
Step::PaperWhite => (10.0, 480.0, 10.0),
|
||||
}
|
||||
}
|
||||
/// The English name, which is also its key in the language table.
|
||||
fn name_key(self) -> &'static str {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Black Level",
|
||||
Step::Peak => "Peak Brightness",
|
||||
Step::PaperWhite => "Paper White",
|
||||
}
|
||||
}
|
||||
fn name(self) -> String {
|
||||
lang::get(self.name_key())
|
||||
}
|
||||
/// The English hint, which is also its key in the language table.
|
||||
fn hint_key(self) -> &'static str {
|
||||
fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Step::Black => "Lower it until the logo just disappears into the black.",
|
||||
Step::Peak => "Raise it until the logo just disappears into the bright square.",
|
||||
@@ -145,16 +139,10 @@ impl Calibration {
|
||||
}
|
||||
fn label(&self) -> Text {
|
||||
let v = self.values[self.step];
|
||||
let step = self.current();
|
||||
let nits = match step {
|
||||
Step::Black => format!("{v:.2}"),
|
||||
_ => format!("{v:.0}"),
|
||||
};
|
||||
Text::literal(format!(
|
||||
"{}: {}",
|
||||
step.name(),
|
||||
lang::tr("%s nits", &[&nits])
|
||||
))
|
||||
Text::literal(match self.current() {
|
||||
Step::Black => format!("Black Level: {v:.2} nits"),
|
||||
step => format!("{}: {v:.0} nits", step.name()),
|
||||
})
|
||||
}
|
||||
/// The step, its nits and where the controls start (a share of the height), for
|
||||
/// HDR output's test picture.
|
||||
@@ -172,12 +160,14 @@ impl Calibration {
|
||||
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
|
||||
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
|
||||
let step = self.current();
|
||||
let title = Text::literal(lang::tr(
|
||||
"HDR Calibration %s/%s: %s",
|
||||
&[&(self.step + 1), &STEPS.len(), &step.name()],
|
||||
let title = Text::literal(format!(
|
||||
"HDR Calibration {}/{}: {}",
|
||||
self.step + 1,
|
||||
STEPS.len(),
|
||||
step.name()
|
||||
));
|
||||
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
|
||||
let hint = Text::literal(lang::get(step.hint_key()));
|
||||
let hint = Text::literal(step.hint());
|
||||
ui::centered_text(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -202,12 +192,10 @@ impl Calibration {
|
||||
self.dragging,
|
||||
);
|
||||
let last = self.step + 1 == STEPS.len();
|
||||
let save = lang::get_or("selectWorld.edit.save", "Save");
|
||||
let next = lang::get("Next");
|
||||
for (rect, label, active) in [
|
||||
(l.cancel, lang::get_or("gui.cancel", "Cancel"), true),
|
||||
(l.back, lang::get_or("gui.back", "Back"), self.step > 0),
|
||||
(l.next, if last { save } else { next }, true),
|
||||
(l.cancel, "Cancel", true),
|
||||
(l.back, "Back", self.step > 0),
|
||||
(l.next, if last { "Save" } else { "Next" }, true),
|
||||
] {
|
||||
let hover = over(cursor, rect) && active;
|
||||
ui::button(
|
||||
|
||||
@@ -83,8 +83,6 @@ pub struct HudAtlas {
|
||||
/// A resource pack's own `core/text` shader for the GUI's text, translated;
|
||||
/// none draws the text natively.
|
||||
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
pub random_textures: horizon_optifine::random_textures::RandomTextures,
|
||||
white: Rect,
|
||||
}
|
||||
/// Atlas rows reserved for glyphs rasterized at run time.
|
||||
@@ -120,7 +118,6 @@ pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
/// A PNG in memory as RGBA pixels.
|
||||
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size().ok_or("image too large")?];
|
||||
@@ -215,28 +212,6 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
|
||||
}
|
||||
(name.clone(), bw, bh, out)
|
||||
}
|
||||
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
|
||||
/// the full atlas. A pack may add others through `atlases/blocks.json`.
|
||||
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
|
||||
"gui",
|
||||
"item",
|
||||
"block",
|
||||
"font",
|
||||
"environment",
|
||||
"misc",
|
||||
"painting",
|
||||
"entity",
|
||||
"particle",
|
||||
"mob_effect",
|
||||
"map",
|
||||
];
|
||||
|
||||
fn walked_minecraft_texture(name: &str) -> bool {
|
||||
WALKED_TEXTURE_FOLDERS
|
||||
.iter()
|
||||
.any(|folder| name.starts_with(folder) && name.as_bytes().get(folder.len()) == Some(&b'/'))
|
||||
}
|
||||
|
||||
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
|
||||
/// files already packed: generated ones (`paletted_permutations` such as the
|
||||
/// armour trim sprites, `unstitch` regions) and files listed under another id,
|
||||
@@ -264,13 +239,11 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
|
||||
if have.contains(name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// A file under a walked folder is packed under its own name already:
|
||||
// only a renamed one is new. Other namespaces' files (content mods)
|
||||
// are never in those folders, nor are extra directories a pack adds
|
||||
// to the blocks atlas (`custom/...`).
|
||||
// A vanilla file is packed under its own name already: only a
|
||||
// renamed one is new. Other namespaces' files (content mods) are
|
||||
// never in the walked directories.
|
||||
if let SpriteOrigin::File(texture) = &origin
|
||||
&& texture.strip_prefix("minecraft:") == Some(name.as_str())
|
||||
&& walked_minecraft_texture(name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -373,7 +346,24 @@ impl HudAtlas {
|
||||
/// The atlas of a resource root that layers packs over `defaults` (the
|
||||
/// game's own resources), whose textures give the sprites' GUI sizes.
|
||||
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
|
||||
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
|
||||
let mut atlas = Self::pack(
|
||||
root,
|
||||
defaults,
|
||||
&[
|
||||
"gui",
|
||||
"item",
|
||||
"block",
|
||||
"font",
|
||||
"environment",
|
||||
"misc",
|
||||
"painting",
|
||||
"entity",
|
||||
"particle",
|
||||
"mob_effect",
|
||||
"map",
|
||||
],
|
||||
true,
|
||||
)?;
|
||||
atlas.text_shader = pack_text::load(root, defaults);
|
||||
Ok(atlas)
|
||||
}
|
||||
@@ -489,7 +479,6 @@ impl HudAtlas {
|
||||
glint_armor: (vec![0; 4], 1, 1),
|
||||
white,
|
||||
text_shader: None,
|
||||
random_textures: Default::default(),
|
||||
}
|
||||
}
|
||||
/// Shelf placements of `images` (tallest first) in the narrowest atlas
|
||||
@@ -578,41 +567,15 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy();
|
||||
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
|
||||
});
|
||||
// OptiFine random entity variants, under the names their scan gives them (those
|
||||
// outside `textures/` are `<ns>:optifine/random/...`).
|
||||
let random_textures = if folders.contains(&"entity") {
|
||||
horizon_optifine::random_textures::RandomTextures::scan(root)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut named: HashMap<PathBuf, String> = HashMap::new();
|
||||
for (file, name) in random_textures.files() {
|
||||
named.insert(root.join(file), name.to_owned());
|
||||
}
|
||||
if !named.is_empty() {
|
||||
// Those beside their texture are walked already.
|
||||
let walked: HashSet<&PathBuf> = files.iter().collect();
|
||||
let mut extra: Vec<PathBuf> = named
|
||||
.keys()
|
||||
.filter(|p| !walked.contains(p))
|
||||
.cloned()
|
||||
.collect();
|
||||
extra.sort();
|
||||
files.extend(extra);
|
||||
}
|
||||
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
|
||||
let decode_one = |path: &PathBuf| -> Option<Decoded> {
|
||||
let name = if let Some(name) = named.get(path) {
|
||||
name.clone()
|
||||
} else {
|
||||
match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
let name = match path.strip_prefix(&textures) {
|
||||
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => {
|
||||
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
let (namespace, rest) = rel.split_once("/textures/")?;
|
||||
format!("{namespace}:{rest}")
|
||||
}
|
||||
};
|
||||
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
|
||||
@@ -814,7 +777,6 @@ impl HudAtlas {
|
||||
glint,
|
||||
glint_armor,
|
||||
text_shader: None,
|
||||
random_textures,
|
||||
font,
|
||||
white,
|
||||
})
|
||||
@@ -2159,40 +2121,6 @@ impl HudRenderer {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decode_bytes_ignores_crc_checksum_error() {
|
||||
let mut encoded = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut encoded, 2, 2);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&[
|
||||
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
|
||||
])
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
let idat_pos = encoded
|
||||
.windows(4)
|
||||
.position(|w| w == b"IDAT")
|
||||
.expect("IDAT chunk");
|
||||
let len = u32::from_be_bytes([
|
||||
encoded[idat_pos - 4],
|
||||
encoded[idat_pos - 3],
|
||||
encoded[idat_pos - 2],
|
||||
encoded[idat_pos - 1],
|
||||
]) as usize;
|
||||
let crc_pos = idat_pos + 4 + len;
|
||||
encoded[crc_pos] ^= 0xFF;
|
||||
|
||||
let (w, h, rgba) = decode_bytes(&encoded).expect("should decode despite bad CRC");
|
||||
assert_eq!(w, 2);
|
||||
assert_eq!(h, 2);
|
||||
assert_eq!(rgba.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() {
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
@@ -2405,33 +2333,6 @@ mod tests {
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
|
||||
}
|
||||
#[test]
|
||||
fn a_pack_directory_outside_the_walked_folders_still_packs() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-custom-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let atlases = root.join("assets/minecraft/atlases");
|
||||
let custom = root.join("assets/minecraft/textures/custom");
|
||||
fs::create_dir_all(&atlases).unwrap();
|
||||
fs::create_dir_all(&custom).unwrap();
|
||||
fs::write(
|
||||
atlases.join("blocks.json"),
|
||||
r#"{"sources":[{"type":"directory","source":"custom","prefix":"custom/"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
write_strip(
|
||||
&custom.join("test.png"),
|
||||
16,
|
||||
16,
|
||||
&[[40, 180, 80, 255]],
|
||||
None,
|
||||
);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let rect = atlas.sprites["custom/test"];
|
||||
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
|
||||
assert_eq!(texel_at(&atlas, rect, 0, 0), [40, 180, 80, 255]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
#[test]
|
||||
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod auth;
|
||||
pub mod ban_notice;
|
||||
pub mod bidi;
|
||||
pub mod biome;
|
||||
pub mod block_edit;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_render;
|
||||
@@ -19,11 +18,9 @@ pub mod camera;
|
||||
pub mod chat;
|
||||
pub mod chunk;
|
||||
pub mod chunk_map;
|
||||
pub mod cit;
|
||||
pub mod clouds;
|
||||
pub mod collisions;
|
||||
pub mod command_block;
|
||||
pub mod connected;
|
||||
pub mod containers;
|
||||
pub mod crash;
|
||||
pub mod datapack;
|
||||
|
||||
@@ -592,21 +592,18 @@ pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut Lig
|
||||
changed.keyed(chunk.layout());
|
||||
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
|
||||
let old = §ion.light;
|
||||
let classes = match (old, &light) {
|
||||
(Light::Uniform(a), Light::Uniform(b)) if a == b => continue,
|
||||
// (most sections of a chunk relit for a neighbour's edit keep their light: one comparison of the whole)
|
||||
(Light::Full(a), Light::Full(b)) => LightChanges::classes_differing(a, b),
|
||||
_ => {
|
||||
let mut classes = 0;
|
||||
for i in 0..4096 {
|
||||
if old.get(i) != light.get(i) {
|
||||
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
|
||||
classes |= 1 << LightChanges::class(x, y, z);
|
||||
}
|
||||
}
|
||||
classes
|
||||
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
|
||||
&& a == b
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut classes = 0;
|
||||
for i in 0..4096 {
|
||||
if old.get(i) != light.get(i) {
|
||||
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
|
||||
classes |= 1 << LightChanges::class(x, y, z);
|
||||
}
|
||||
};
|
||||
}
|
||||
if classes != 0 {
|
||||
changed.add((chunk.x, sy as i32, chunk.z), classes);
|
||||
Arc::make_mut(section).light = light;
|
||||
@@ -850,7 +847,7 @@ pub struct LightChanges {
|
||||
impl LightChanges {
|
||||
/// Changes in chunks of `layout`: what the relighter or a light job adds
|
||||
/// after this (they all come from one level).
|
||||
pub fn keyed(&mut self, layout: ColumnLayout) {
|
||||
fn keyed(&mut self, layout: ColumnLayout) {
|
||||
debug_assert!(self.sections.is_empty() || self.layout == layout);
|
||||
self.layout = layout;
|
||||
}
|
||||
@@ -861,7 +858,7 @@ impl LightChanges {
|
||||
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
|
||||
}
|
||||
/// Cells of section `key` (x, section index, z) in the given classes.
|
||||
pub fn add(&mut self, key: Pos, classes: u32) {
|
||||
fn add(&mut self, key: Pos, classes: u32) {
|
||||
*self.sections.entry(key).or_default() |= classes;
|
||||
}
|
||||
/// The class of a cell in its section: per axis 0 on the low face, 1
|
||||
@@ -874,25 +871,6 @@ impl LightChanges {
|
||||
};
|
||||
axis(x) + 3 * axis(y) + 9 * axis(z)
|
||||
}
|
||||
/// The classes (bit `class`) of the cells, in a section's index order (`y << 8 | z << 4 | x`), where `a` and
|
||||
/// `b` differ.
|
||||
pub fn classes_differing<T: PartialEq>(a: &[T; 4096], b: &[T; 4096]) -> u32 {
|
||||
let mut classes = 0;
|
||||
// (a row of 16 along x at a time: the rows alike, most of them, cost one comparison)
|
||||
let (rows_a, rows_b) = (a.as_chunks::<16>().0, b.as_chunks::<16>().0);
|
||||
for (row, (ra, rb)) in rows_a.iter().zip(rows_b).enumerate() {
|
||||
if ra == rb {
|
||||
continue;
|
||||
}
|
||||
let (y, z) = ((row >> 4) as i32, (row & 15) as i32);
|
||||
for x in 0..16 {
|
||||
if ra[x] != rb[x] {
|
||||
classes |= 1 << Self::class(x as i32, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
classes
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sections.is_empty()
|
||||
}
|
||||
@@ -1178,31 +1156,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
|
||||
|
||||
#[test]
|
||||
fn changed_cells_dirty_only_the_sections_within_a_block() {
|
||||
let dirty = |cells: &[(usize, usize, usize)]| {
|
||||
let a = [0u8; 4096];
|
||||
let mut b = a;
|
||||
for &(x, y, z) in cells {
|
||||
b[y << 8 | z << 4 | x] = 1;
|
||||
}
|
||||
let mut changes = LightChanges::default();
|
||||
changes.add((0, 5, 0), LightChanges::classes_differing(&a, &b));
|
||||
let mut out = Vec::new();
|
||||
changes.dirty_sections(|p| out.push(p));
|
||||
out.sort_unstable();
|
||||
out
|
||||
};
|
||||
assert_eq!(dirty(&[]), Vec::<Pos>::new());
|
||||
assert_eq!(dirty(&[(5, 5, 5), (9, 1, 14)]), vec![(0, 5, 0)]);
|
||||
assert_eq!(dirty(&[(0, 5, 5)]), vec![(-1, 5, 0), (0, 5, 0)]);
|
||||
assert_eq!(
|
||||
dirty(&[(15, 15, 5)]),
|
||||
vec![(0, 5, 0), (0, 6, 0), (1, 5, 0), (1, 6, 0)]
|
||||
);
|
||||
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
|
||||
}
|
||||
|
||||
const AIR: u32 = 0;
|
||||
const STONE: u32 = 1;
|
||||
const TORCH: u32 = 2;
|
||||
|
||||
@@ -4,22 +4,18 @@
|
||||
//! cardinal shading, tint sources, block offsets; see `lighting`). Fluids
|
||||
//! follow `FluidRenderer` (see `fluid`).
|
||||
mod fluid;
|
||||
mod leaf_culling;
|
||||
mod lighting;
|
||||
mod sides;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
blocks::{BlockFacts, RenderShape},
|
||||
chunk::{Chunk, MIN_Y},
|
||||
graphics::LeafCullingMode,
|
||||
models::catalog::BakedFace,
|
||||
};
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
use horizon_optifine::ctm::connect::Face as CtmFace;
|
||||
use horizon_world::{
|
||||
block_render::FaceVisibility,
|
||||
fluid::{FluidKind, FluidState},
|
||||
@@ -474,9 +470,6 @@ struct Info {
|
||||
overlay: bool,
|
||||
/// `LeavesBlock`: with opaque leaves the faces between two leaf blocks are skipped.
|
||||
leaves: bool,
|
||||
/// A full opaque cube: `RenderShape::Model`, `can_occlude`, `solid_render`
|
||||
/// and a full collision shape. The only block leaf culling treats as solid.
|
||||
opaque_cube: bool,
|
||||
blocks_flow: bool,
|
||||
ice: bool,
|
||||
sturdy: u8,
|
||||
@@ -654,12 +647,6 @@ impl MeshTables {
|
||||
legacy_solid: s.legacy_solid,
|
||||
overlay: s.has_class("HalfTransparentBlock") || s.has_class("LeavesBlock"),
|
||||
leaves: s.has_class("LeavesBlock"),
|
||||
opaque_cube: leaf_culling::opaque_cube(
|
||||
s.render_shape,
|
||||
s.can_occlude,
|
||||
s.solid_render,
|
||||
s.full_collision,
|
||||
),
|
||||
blocks_flow: assets.blocks_fluid_flow.contains(&*s.name),
|
||||
ice: s.has_class("IceBlock"),
|
||||
sturdy: s.sturdy_full,
|
||||
@@ -699,14 +686,6 @@ impl MeshTables {
|
||||
tint_sources: i.tint_count,
|
||||
})
|
||||
}
|
||||
/// Whether `state` is a full opaque cube: the only block leaf culling
|
||||
/// treats as a solid neighbour. Glass, fluids, slabs, stairs and models
|
||||
/// that only partly fill their block are not.
|
||||
pub fn opaque_cube(&self, state: BlockStateId) -> bool {
|
||||
self.info
|
||||
.get(state.0 as usize)
|
||||
.is_some_and(|i| i.opaque_cube)
|
||||
}
|
||||
/// `BlockTintSource.color(state)`: block-model colour without a level.
|
||||
/// `grass` is `GrassColor.getDefaultColor()` from the active pack's colormap.
|
||||
/// Untinted faces, blank sources and invalid tint indices return `None`.
|
||||
@@ -881,18 +860,14 @@ impl Padded {
|
||||
}
|
||||
}
|
||||
/// Per-worker temporary arrays. They never hold resource-pack references or
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
/// geometry still owned by the renderer, and total about 88 KiB per worker.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
leaf: leaf_culling::LeafMask,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod leaf_culling_tests;
|
||||
#[cfg(test)]
|
||||
mod reuse_tests;
|
||||
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
|
||||
pub const OPAQUE_LAYER: u16 = 1 << 15;
|
||||
@@ -1019,32 +994,6 @@ impl<'a> Tints<'a> {
|
||||
layers: Default::default(),
|
||||
}
|
||||
}
|
||||
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
|
||||
/// picks it.
|
||||
fn biome(&self, x: i32, y: i32, z: i32) -> &'a str {
|
||||
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, [x, y, z]);
|
||||
&self.biomes.get(id).name
|
||||
}
|
||||
/// `BiomeManager.getBiome` over the loaded chunks (plains where there is none), for block
|
||||
/// `(x, y, z)` of the section at `origin`. Takes the fields it reads, so `get` can call it
|
||||
/// while it holds a layer.
|
||||
fn biome_id(
|
||||
zoom: &BiomeZoom,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
plains: u16,
|
||||
[cx, cz, base_y]: [i32; 3],
|
||||
[x, y, z]: [i32; 3],
|
||||
) -> u16 {
|
||||
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
|
||||
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
|
||||
if (0..3).contains(&rx) && (0..3).contains(&rz) {
|
||||
region[rz as usize][rx as usize].map_or(plains, |c| {
|
||||
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
|
||||
})
|
||||
} else {
|
||||
plains
|
||||
}
|
||||
}
|
||||
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
|
||||
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
|
||||
let r = self.radius;
|
||||
@@ -1060,21 +1009,27 @@ impl<'a> Tints<'a> {
|
||||
if cached != UNSET {
|
||||
return cached;
|
||||
}
|
||||
let [cx, cz, _] = self.origin;
|
||||
let (bx, bz) = (cx * 16, cz * 16);
|
||||
let [cx, cz, base_y] = self.origin;
|
||||
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
|
||||
let (mut red, mut green, mut blue) = (0, 0, 0);
|
||||
for dz in -r..=r {
|
||||
for dx in -r..=r {
|
||||
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
|
||||
let mut biome = layer.zoomed[cell];
|
||||
if biome == NO_BIOME {
|
||||
biome = Self::biome_id(
|
||||
&self.zoom,
|
||||
self.region,
|
||||
self.plains,
|
||||
self.origin,
|
||||
[x + dx, y, z + dz],
|
||||
);
|
||||
// BiomeManager.getBiome over the loaded chunks (plains
|
||||
// where there is none).
|
||||
let q = self
|
||||
.zoom
|
||||
.quart_at_block(BlockPos::new(bx + x + dx, by, bz + z + dz));
|
||||
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
|
||||
biome = if (0..3).contains(&rx) && (0..3).contains(&rz) {
|
||||
self.region[rz as usize][rx as usize].map_or(self.plains, |c| {
|
||||
c.biome((q[0] & 3) * 4, q[1] * 4, (q[2] & 3) * 4)
|
||||
})
|
||||
} else {
|
||||
self.plains
|
||||
};
|
||||
layer.zoomed[cell] = biome;
|
||||
}
|
||||
let c = match resolver {
|
||||
@@ -1124,10 +1079,6 @@ pub struct Mesher<'a> {
|
||||
pub smooth_lighting: bool,
|
||||
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
|
||||
pub cutout_leaves: bool,
|
||||
/// Sprite layers a shader pack moves into the translucent pass, by layer.
|
||||
pub translucent_layers: &'a [bool],
|
||||
/// What the worker does with leaf faces inside a canopy.
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
}
|
||||
impl Mesher<'_> {
|
||||
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
|
||||
@@ -1157,11 +1108,6 @@ impl Mesher<'_> {
|
||||
let pad = &scratch.padded;
|
||||
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
|
||||
let base_y = center.min_y() + sy * 16;
|
||||
if self.leaf_culling.is_interior() {
|
||||
scratch
|
||||
.leaf
|
||||
.prepare(self.leaf_culling, pad, &self.tables.info, region, base_y);
|
||||
}
|
||||
let mut tints = Tints::new(
|
||||
region,
|
||||
self.biomes,
|
||||
@@ -1226,21 +1172,13 @@ impl Mesher<'_> {
|
||||
None => [x as f32, y as f32, z as f32],
|
||||
};
|
||||
let mut visible = [false; 6];
|
||||
let interior_leaf = state.leaves
|
||||
&& self.leaf_culling.is_interior()
|
||||
&& scratch.leaf.interior(self.leaf_culling, x, y, z);
|
||||
for d in Direction::ALL {
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
|
||||
let other = &info[neighbor as usize];
|
||||
// LeavesBlock.skipRendering with opaque leaves.
|
||||
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
|
||||
let hollow_leaf = self.leaf_culling == LeafCullingMode::Hollow
|
||||
&& state.leaves
|
||||
&& other.leaves
|
||||
&& leaf_culling::in_section(x + dx, y + dy, z + dz);
|
||||
visible[d as usize] = !opaque_leaves
|
||||
&& !hollow_leaf
|
||||
&& self
|
||||
.visibility
|
||||
.should_render_face(
|
||||
@@ -1264,9 +1202,7 @@ impl Mesher<'_> {
|
||||
origin,
|
||||
face,
|
||||
ao,
|
||||
(id, state),
|
||||
world,
|
||||
interior_leaf,
|
||||
state,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1304,9 +1240,7 @@ impl Mesher<'_> {
|
||||
origin: [f32; 3],
|
||||
face: &BakedFace,
|
||||
ao: bool,
|
||||
(id, state): (u32, &Info),
|
||||
world: BlockPos,
|
||||
interior_leaf: bool,
|
||||
state: &Info,
|
||||
) {
|
||||
let q = &face.quad;
|
||||
// A quad's own light (a model's glowing part) may raise the tier; whether it
|
||||
@@ -1333,265 +1267,39 @@ impl Mesher<'_> {
|
||||
([lighting::gray(directional); 4], [coords; 4])
|
||||
};
|
||||
let tint = Self::tint(state, q.tint_index, tints, pos);
|
||||
// OptiFine connected textures: another sprite, or one per quarter.
|
||||
let connected = &self.render.connected;
|
||||
let pick = if connected.is_empty() {
|
||||
Pick::Keep
|
||||
if self.tables.is_animated(face.layer as u16) {
|
||||
out.note_sprite_layer(face.layer as u16);
|
||||
}
|
||||
let target = if face.translucent {
|
||||
&mut out.translucent
|
||||
} else {
|
||||
let tints = &*tints;
|
||||
connected.pick(
|
||||
id,
|
||||
face.layer as u16,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
[world.x, world.y, world.z],
|
||||
|[dx, dy, dz]| pad.blocks[pi(pos[0] + dx, pos[1] + dy, pos[2] + dz)],
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
)
|
||||
&mut out.solid
|
||||
};
|
||||
let mut corners = [(TerrainVertex::new([0.0; 3], [0.0; 2], 0, 0, 0), [0.0; 2]); 4];
|
||||
for (i, corner) in corners.iter_mut().enumerate() {
|
||||
for i in 0..4 {
|
||||
let g = gray[i] as u32;
|
||||
let rgb = match tint {
|
||||
Some(t) => [16, 8, 0].map(|shift| g * (t >> shift & 255) / 255),
|
||||
None => [g; 3],
|
||||
};
|
||||
let p = q.positions[i];
|
||||
*corner = (
|
||||
TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
q.uv[i],
|
||||
0,
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
),
|
||||
target.push(TerrainVertex::new(
|
||||
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
|
||||
q.uv[i],
|
||||
);
|
||||
}
|
||||
// Opaque leaves (forceOpaque) and leaves a leaf-culling mode proved internal draw in the
|
||||
// solid layer whatever their sprite.
|
||||
let solid_leaves = !self.cutout_leaves && state.leaves || interior_leaf;
|
||||
let own = TileLayer::Layer {
|
||||
layer: face.layer as u16,
|
||||
translucent: face.translucent,
|
||||
opaque: face.opaque,
|
||||
};
|
||||
// A tile keeps the quad's forced translucency; `<skip>` and `<default>` are its own.
|
||||
let tile = |t: TileLayer| match t {
|
||||
TileLayer::Layer {
|
||||
layer,
|
||||
translucent,
|
||||
opaque,
|
||||
} => TileLayer::Layer {
|
||||
layer,
|
||||
translucent: translucent || q.material.force_translucent,
|
||||
opaque,
|
||||
},
|
||||
TileLayer::Skip | TileLayer::Default => own,
|
||||
};
|
||||
match pick {
|
||||
Pick::Keep => self.quad(out, solid_leaves, own, corners.map(|c| c.0)),
|
||||
Pick::Tile(t) => self.quad(out, solid_leaves, tile(t), corners.map(|c| c.0)),
|
||||
Pick::Quarters(tiles) => {
|
||||
let cuts = cuts(&corners);
|
||||
for s in cuts.windows(2) {
|
||||
for t in cuts.windows(2) {
|
||||
let piece = [(s[0], t[0]), (s[1], t[0]), (s[1], t[1]), (s[0], t[1])]
|
||||
.map(|(s, t)| bilinear(&corners, s, t));
|
||||
let quarter = quarter_of(&piece);
|
||||
self.quad(out, solid_leaves, tile(tiles[quarter]), piece.map(|c| c.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Opaque leaves (forceOpaque) and quads over opaque texels draw in the
|
||||
// solid layer: no alpha cutout (a dirt path's side skips its clear top row).
|
||||
if face.opaque && !face.translucent || !self.cutout_leaves && state.leaves {
|
||||
face.layer as u16 | OPAQUE_LAYER
|
||||
} else {
|
||||
face.layer as u16
|
||||
},
|
||||
emitting(pack_rgb(rgb), tier),
|
||||
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
|
||||
));
|
||||
}
|
||||
}
|
||||
/// One terrain quad with sprite `tile`, in the pass its alpha needs (`solid_leaves`: no
|
||||
/// alpha cutout whatever the sprite).
|
||||
fn quad(
|
||||
&self,
|
||||
out: &mut SectionMesh,
|
||||
solid_leaves: bool,
|
||||
tile: TileLayer,
|
||||
mut v: [TerrainVertex; 4],
|
||||
) {
|
||||
let TileLayer::Layer {
|
||||
layer,
|
||||
translucent,
|
||||
opaque,
|
||||
} = tile
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.tables.is_animated(layer) {
|
||||
out.note_sprite_layer(layer);
|
||||
}
|
||||
let translucent = translucent
|
||||
|| self
|
||||
.translucent_layers
|
||||
.get(usize::from(layer))
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
// Quads over opaque texels draw in the solid layer: no alpha cutout (a dirt path's side
|
||||
// skips its clear top row). All four vertices share the layer.
|
||||
let layer = if opaque && !translucent || solid_leaves {
|
||||
layer | OPAQUE_LAYER
|
||||
} else {
|
||||
layer
|
||||
};
|
||||
let target = if translucent {
|
||||
&mut out.translucent
|
||||
} else {
|
||||
&mut out.solid
|
||||
};
|
||||
for vertex in &mut v {
|
||||
vertex.layer = layer;
|
||||
}
|
||||
target.extend(v);
|
||||
}
|
||||
}
|
||||
/// The cuts of a `ctm_compact` face, in quad parameters, without allocating: the first `.1`
|
||||
/// of `.0`.
|
||||
struct Cuts([f32; 4], usize);
|
||||
impl std::ops::Deref for Cuts {
|
||||
type Target = [f32];
|
||||
fn deref(&self) -> &[f32] {
|
||||
&self.0[..self.1]
|
||||
}
|
||||
}
|
||||
/// Where a quad's edge from corner 0 to corner `to` (1 for `s`, 3 for `t`) crosses the sprite's
|
||||
/// middle line, the line its quarters meet on; `None` for an edge that stays on one side (a
|
||||
/// slab's side).
|
||||
fn crossing(c: &[(TerrainVertex, [f32; 2]); 4], to: usize) -> Option<f32> {
|
||||
let (a, b) = (c[0].1, c[to].1);
|
||||
// The UV the edge runs along.
|
||||
let k = usize::from((b[1] - a[1]).abs() > (b[0] - a[0]).abs());
|
||||
let at = (0.5 - a[k]) / (b[k] - a[k]);
|
||||
(at > 0.0 && at < 1.0).then_some(at)
|
||||
}
|
||||
/// The cuts of a `ctm_compact` face, the same along `s` and `t`: both edges' crossings. Pieces
|
||||
/// on the quad's 0-2 diagonal are then square, so their own triangles lie on the quad's and a
|
||||
/// split face shades exactly like the whole one; the others lie on one side of it.
|
||||
fn cuts(c: &[(TerrainVertex, [f32; 2]); 4]) -> Cuts {
|
||||
let mut out = Cuts([0.0; 4], 1);
|
||||
let (mut s, mut t) = (crossing(c, 1), crossing(c, 3));
|
||||
if s > t {
|
||||
std::mem::swap(&mut s, &mut t);
|
||||
}
|
||||
for at in [s, t].into_iter().flatten().chain([1.0]) {
|
||||
if out.0[out.1 - 1] < at {
|
||||
out.0[out.1] = at;
|
||||
out.1 += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The quarter of the sprite (top-left, top-right, bottom-left, bottom-right) a piece of a
|
||||
/// quad lies in, by its UV centre (v grows downward).
|
||||
fn quarter_of(piece: &[(TerrainVertex, [f32; 2]); 4]) -> usize {
|
||||
let [u, v] = [0, 1].map(|k| piece.iter().map(|c| c.1[k]).sum::<f32>() / 4.0);
|
||||
usize::from(v >= 0.5) * 2 + usize::from(u >= 0.5)
|
||||
}
|
||||
/// The vertex at `(s, t)` across a quad's corners (`s` from corner 0 to 1, `t` from 0 to 3),
|
||||
/// with its UV in sprite units: a quarter of a `ctm_compact` face. Blended within the quad's
|
||||
/// two triangles (0-1-2 and 0-2-3), as the GPU blends the whole quad, so a split face shades
|
||||
/// like an unsplit one.
|
||||
fn bilinear(c: &[(TerrainVertex, [f32; 2]); 4], s: f32, t: f32) -> (TerrainVertex, [f32; 2]) {
|
||||
let w = if s >= t {
|
||||
[1.0 - s, s - t, t, 0.0]
|
||||
} else {
|
||||
[1.0 - t, 0.0, s, t - s]
|
||||
};
|
||||
let mix = |f: &dyn Fn(&(TerrainVertex, [f32; 2])) -> f32| {
|
||||
(0..4).map(|i| w[i] * f(&c[i])).sum::<f32>()
|
||||
};
|
||||
// Packed bytes (colour, light) blend one by one.
|
||||
let bytes = |field: fn(&TerrainVertex) -> u32, count: u32| {
|
||||
(0..count)
|
||||
.map(|k| (mix(&|c| (field(&c.0) >> (8 * k) & 255) as f32).round() as u32) << (8 * k))
|
||||
.sum::<u32>()
|
||||
};
|
||||
let color = bytes(|v| v.color, 4);
|
||||
let light = bytes(|v| u32::from(v.light), 2) as u16;
|
||||
let pos = [0, 1, 2].map(|k| mix(&|c| c.0.pos[k]));
|
||||
let uv = [0, 1].map(|k| mix(&|c| c.1[k]));
|
||||
(TerrainVertex::new(pos, uv, 0, color, light), uv)
|
||||
}
|
||||
/// Snapshot of the 3x3 chunk neighbourhood used by a worker job.
|
||||
pub type Region = [[Option<Arc<Chunk>>; 3]; 3];
|
||||
pub fn borrow(region: &Region) -> [[Option<&Chunk>; 3]; 3] {
|
||||
std::array::from_fn(|z| std::array::from_fn(|x| region[z][x].as_deref()))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod quarter_tests {
|
||||
use super::*;
|
||||
|
||||
fn corner(pos: [f32; 3], uv: [f32; 2], red: u32, light: u16) -> (TerrainVertex, [f32; 2]) {
|
||||
(TerrainVertex::new(pos, uv, 0, 0xFF00_0000 | red, light), uv)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarters_blend_the_corners_by_triangle() {
|
||||
// Only corner 2 is lit: the GPU's triangles (0-1-2, 0-2-3) put half of it in the
|
||||
// middle, where a bilinear blend would put a quarter and the other diagonal none.
|
||||
let c = [
|
||||
corner([0.0, 0.0, 0.0], [0.0, 0.0], 0, 0),
|
||||
corner([1.0, 0.0, 0.0], [1.0, 0.0], 0, 0),
|
||||
corner([1.0, 1.0, 0.0], [1.0, 1.0], 200, 0x2010),
|
||||
corner([0.0, 1.0, 0.0], [0.0, 1.0], 0, 0),
|
||||
];
|
||||
let (centre, uv) = bilinear(&c, 0.5, 0.5);
|
||||
assert_eq!(centre.pos, [0.5, 0.5, 0.0]);
|
||||
assert_eq!(uv, [0.5, 0.5]);
|
||||
assert_eq!(centre.color, 0xFF00_0000 | 100);
|
||||
assert_eq!(centre.light, 0x1008, "sky and block light blend apart");
|
||||
// Off the diagonal, on the 0-1-2 triangle: corner 2 weighs t.
|
||||
assert_eq!(bilinear(&c, 0.75, 0.25).0.color & 255, 50);
|
||||
assert_eq!(bilinear(&c, 0.5, 0.0).0.pos, [0.5, 0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_faces_cut_where_the_sprite_does() {
|
||||
// A full face: cut in the middle both ways.
|
||||
let full = [
|
||||
corner([0.0, 1.0, 0.0], [0.0, 0.0], 0, 0),
|
||||
corner([1.0, 1.0, 0.0], [1.0, 0.0], 0, 0),
|
||||
corner([1.0, 0.0, 0.0], [1.0, 1.0], 0, 0),
|
||||
corner([0.0, 0.0, 0.0], [0.0, 1.0], 0, 0),
|
||||
];
|
||||
assert_eq!(*cuts(&full), [0.0, 0.5, 1.0]);
|
||||
// A dirt path's side, v from 1/16: the sprite's middle is 7/15 of the way down; both
|
||||
// directions are cut there and at the middle of u.
|
||||
let mut path = full;
|
||||
path[0].1 = [0.0, 1.0 / 16.0];
|
||||
path[1].1 = [1.0, 1.0 / 16.0];
|
||||
assert_eq!(
|
||||
*cuts(&path),
|
||||
[0.0, (0.5 - 1.0 / 16.0) / (15.0 / 16.0), 0.5, 1.0]
|
||||
);
|
||||
// A slab's lower side (v 0.5..1) crosses the middle only along u.
|
||||
let mut slab = full;
|
||||
slab[0].1 = [0.0, 0.5];
|
||||
slab[1].1 = [1.0, 0.5];
|
||||
assert_eq!(crossing(&slab, 3), None);
|
||||
assert_eq!(*cuts(&slab), [0.0, 0.5, 1.0]);
|
||||
// Quarters by UV centre: top-left, top-right, bottom-left, bottom-right.
|
||||
let piece = |s0: f32, t0: f32| {
|
||||
[
|
||||
(s0, t0),
|
||||
(s0 + 0.5, t0),
|
||||
(s0 + 0.5, t0 + 0.5),
|
||||
(s0, t0 + 0.5),
|
||||
]
|
||||
.map(|(s, t)| bilinear(&full, s, t))
|
||||
};
|
||||
assert_eq!(
|
||||
[
|
||||
piece(0.0, 0.0),
|
||||
piece(0.5, 0.0),
|
||||
piece(0.0, 0.5),
|
||||
piece(0.5, 0.5)
|
||||
]
|
||||
.map(|p| quarter_of(&p)),
|
||||
[0, 1, 2, 3]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,656 +0,0 @@
|
||||
//! Leaf culling: decides which leaf faces a section may draw solid or drop.
|
||||
//!
|
||||
//! The mask is built on the mesh worker from the same padded block snapshot the
|
||||
//! rest of the mesher reads, so a decision never needs a chunk lookup on the
|
||||
//! render thread. It classifies each padded cell as a leaf, a full opaque cube,
|
||||
//! open (air, fluids, partial models, anything else) or unknown (a missing
|
||||
//! chunk, a Y outside the column, or the mask's outer shell). Only leaves can
|
||||
//! hide or solidify anything; unknown and open cells always expose a face.
|
||||
//!
|
||||
//! `Solid` performs an immediate 6-neighbour occlusion check, solidifying fully
|
||||
//! enclosed leaves into the opaque layer without cutout testing. `Solid
|
||||
//! Aggressive` runs a multi-source breadth-first search from leaves touching
|
||||
//! openings, solidifying leaves with a single open face to thin the canopy shell
|
||||
//! by one layer. `Hollow` directly drops faces shared between two leaves within
|
||||
//! the section, preserving boundaries and cavity faces.
|
||||
|
||||
use super::{B, Info, P, Padded, pi};
|
||||
use crate::{blocks::RenderShape, chunk::Chunk, graphics::LeafCullingMode};
|
||||
|
||||
/// A cell of the padded snapshot, as far as leaf culling cares.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Cell {
|
||||
/// Air, a fluid, a partial model, glass or any other non-occluding block.
|
||||
Open,
|
||||
/// `LeavesBlock`.
|
||||
Leaf,
|
||||
/// A full opaque cube (see [`opaque_cube`]).
|
||||
Opaque,
|
||||
/// Missing chunk, missing section or the mask's outer shell.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Whether a state is a full opaque cube: `RenderShape::Model`, `can_occlude`,
|
||||
/// `solid_render` and a full collision shape. Glass, fluids, slabs, stairs,
|
||||
/// chests and models that only partly fill their block are not.
|
||||
pub(super) fn opaque_cube(
|
||||
shape: RenderShape,
|
||||
can_occlude: bool,
|
||||
solid_render: bool,
|
||||
full_collision: bool,
|
||||
) -> bool {
|
||||
shape == RenderShape::Model && can_occlude && solid_render && full_collision
|
||||
}
|
||||
|
||||
const UNVISITED: u8 = u8::MAX;
|
||||
|
||||
const STRIDE_X: isize = 1;
|
||||
const STRIDE_Z: isize = P as isize;
|
||||
const STRIDE_Y: isize = (P * P) as isize;
|
||||
|
||||
const ADJACENT_STRIDES: [isize; 6] = [
|
||||
STRIDE_X, -STRIDE_X, STRIDE_Z, -STRIDE_Z, STRIDE_Y, -STRIDE_Y,
|
||||
];
|
||||
|
||||
/// Per-worker leaf mask and BFS scratch. The vectors keep their capacity
|
||||
/// between sections and modes; `Off` does no work at all.
|
||||
#[derive(Default)]
|
||||
pub(super) struct LeafMask {
|
||||
cells: Vec<Cell>,
|
||||
depth: Vec<u8>,
|
||||
queue: Vec<u16>,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl LeafMask {
|
||||
/// Classify the padded snapshot and, for the interior modes, measure how
|
||||
/// deep each leaf sits below the canopy surface.
|
||||
pub(super) fn prepare(
|
||||
&mut self,
|
||||
mode: LeafCullingMode,
|
||||
pad: &Padded,
|
||||
info: &[Info],
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
base_y: i32,
|
||||
) {
|
||||
self.active = mode != LeafCullingMode::Off;
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
// Most sections hold no leaves at all; do not classify 8,000 cells to
|
||||
// learn that. The centre is the only part this mesh can change.
|
||||
let mut leaves = false;
|
||||
'scan: for y in 0..16 {
|
||||
for z in 0..16 {
|
||||
for x in 0..16 {
|
||||
if info
|
||||
.get(pad.blocks[pi(x, y, z)] as usize)
|
||||
.is_some_and(|state| state.leaves)
|
||||
{
|
||||
leaves = true;
|
||||
break 'scan;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !leaves {
|
||||
self.active = false;
|
||||
return;
|
||||
}
|
||||
if self.cells.len() != P * P * P {
|
||||
self.cells.resize(P * P * P, Cell::Open);
|
||||
}
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let i = pi(x, y, z);
|
||||
self.cells[i] = if !known(region, base_y, x, y, z) {
|
||||
Cell::Unknown
|
||||
} else {
|
||||
match info.get(pad.blocks[i] as usize) {
|
||||
None => Cell::Unknown,
|
||||
Some(state) if state.leaves => Cell::Leaf,
|
||||
Some(state) if state.opaque_cube => Cell::Opaque,
|
||||
Some(_) => Cell::Open,
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Solid is a purely local six-neighbour test. Reserve the breadth-first
|
||||
// search for Solid Aggressive, whose one-open-side rule needs to know
|
||||
// whether a leaf is connected to the canopy surface through other
|
||||
// leaves.
|
||||
if mode == LeafCullingMode::SolidAggressive {
|
||||
self.depths();
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-source BFS from leaves that touch an opening. Opaque neighbours do
|
||||
/// not expose a leaf: a leaf fully enclosed by stone is interior too.
|
||||
fn depths(&mut self) {
|
||||
if self.depth.len() != self.cells.len() {
|
||||
self.depth.resize(self.cells.len(), UNVISITED);
|
||||
} else {
|
||||
self.depth.fill(UNVISITED);
|
||||
}
|
||||
self.queue.clear();
|
||||
for y in -B..16 + B {
|
||||
for z in -B..16 + B {
|
||||
for x in -B..16 + B {
|
||||
let i = pi(x, y, z);
|
||||
if self.cells[i] != Cell::Leaf {
|
||||
continue;
|
||||
}
|
||||
let touches_open = ADJACENT_STRIDES.iter().any(|&stride| {
|
||||
let j = (i as isize + stride) as usize;
|
||||
matches!(self.cells[j], Cell::Open | Cell::Unknown)
|
||||
});
|
||||
if touches_open {
|
||||
self.depth[i] = 0;
|
||||
self.queue.push(i as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut head = 0;
|
||||
while head < self.queue.len() {
|
||||
let i = self.queue[head] as usize;
|
||||
head += 1;
|
||||
let next_depth = self.depth[i].saturating_add(1);
|
||||
for stride in ADJACENT_STRIDES {
|
||||
let j = (i as isize + stride) as usize;
|
||||
if self.cells[j] == Cell::Leaf && self.depth[j] == UNVISITED {
|
||||
self.depth[j] = next_depth;
|
||||
self.queue.push(j as u16);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the leaf at `(x, y, z)` may draw in the opaque layer.
|
||||
///
|
||||
/// `Solid` wants a leaf with no open neighbour, which is exactly depth 1 or
|
||||
/// more. `Solid Aggressive` also takes the next layer of the shell: a leaf
|
||||
/// with a single open side joins the opaque core, so the cutout shell is
|
||||
/// one layer thinner. Unknown neighbours never qualify, however many sides
|
||||
/// they leave open. A leaf the search never reached sits inside a closed
|
||||
/// volume: interior for both.
|
||||
pub(super) fn interior(&self, mode: LeafCullingMode, x: i32, y: i32, z: i32) -> bool {
|
||||
if !self.active {
|
||||
return false;
|
||||
}
|
||||
let aggressive = match mode {
|
||||
LeafCullingMode::Solid => false,
|
||||
LeafCullingMode::SolidAggressive => true,
|
||||
_ => return false,
|
||||
};
|
||||
// A section boundary is not proof of a closed volume: the neighbouring
|
||||
// section may not be drawn yet, and its data is not ours to assume.
|
||||
if !(1..15).contains(&x) || !(1..15).contains(&y) || !(1..15).contains(&z) {
|
||||
return false;
|
||||
}
|
||||
let i = pi(x, y, z);
|
||||
if self.cells.get(i) != Some(&Cell::Leaf) {
|
||||
return false;
|
||||
}
|
||||
if !aggressive {
|
||||
return ADJACENT_STRIDES.iter().all(|&stride| {
|
||||
let j = (i as isize + stride) as usize;
|
||||
matches!(self.cells[j], Cell::Leaf | Cell::Opaque)
|
||||
});
|
||||
}
|
||||
let depth = self.depth[i];
|
||||
if depth == UNVISITED || depth >= 1 {
|
||||
return true;
|
||||
}
|
||||
let mut open = 0;
|
||||
for stride in ADJACENT_STRIDES {
|
||||
let j = (i as isize + stride) as usize;
|
||||
match self.cells[j] {
|
||||
Cell::Unknown => return false,
|
||||
Cell::Open => {
|
||||
open += 1;
|
||||
if open > 1 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Cell::Leaf | Cell::Opaque => {}
|
||||
}
|
||||
}
|
||||
open == 1
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells this section's mesh owns. A face on this boundary belongs to the
|
||||
/// neighbouring section as much as to this one; leaf culling leaves it alone.
|
||||
#[inline]
|
||||
pub(super) fn in_section(x: i32, y: i32, z: i32) -> bool {
|
||||
(0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
|
||||
}
|
||||
|
||||
/// Whether the padded cell `(x, y, z)` has real block data. A missing chunk
|
||||
/// does not count: `Padded::fill` copies the centre's edge column into it, which
|
||||
/// would make a canopy look enclosed. A Y outside the column counts as unknown
|
||||
/// for the same reason. The mask's outer shell is unknown too, so nothing is
|
||||
/// inferred past the data the worker snapshotted.
|
||||
fn known(region: &[[Option<&Chunk>; 3]; 3], base_y: i32, x: i32, y: i32, z: i32) -> bool {
|
||||
if x <= -B || x >= 16 + B - 1 || y <= -B || y >= 16 + B - 1 || z <= -B || z >= 16 + B - 1 {
|
||||
return false;
|
||||
}
|
||||
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
|
||||
chunk.is_some_and(|chunk| chunk.section_index(base_y + y).is_some())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl LeafMask {
|
||||
/// Whether `Hollow` drops the face of the leaf at `(x, y, z)` toward `d`.
|
||||
/// Only a known leaf on the other side counts: open cells, partial models,
|
||||
/// missing neighbours and the mask's edge stay visible.
|
||||
pub(super) fn hides_face(
|
||||
&self,
|
||||
mode: LeafCullingMode,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
d: horizon_core::geometry::Direction,
|
||||
) -> bool {
|
||||
if !self.active || mode != LeafCullingMode::Hollow {
|
||||
return false;
|
||||
}
|
||||
let [dx, dy, dz] = d.normal();
|
||||
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
|
||||
if !in_section(x, y, z) || !in_section(nx, ny, nz) {
|
||||
return false;
|
||||
}
|
||||
let i = pi(x, y, z);
|
||||
let ni = pi(nx, ny, nz);
|
||||
self.cells.get(i) == Some(&Cell::Leaf) && self.cells.get(ni) == Some(&Cell::Leaf)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn cell(&self, x: i32, y: i32, z: i32) -> Cell {
|
||||
checked(x, y, z)
|
||||
.and_then(|i| self.cells.get(i))
|
||||
.copied()
|
||||
.unwrap_or(Cell::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[inline]
|
||||
fn checked(x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
if (-B..16 + B).contains(&x) && (-B..16 + B).contains(&y) && (-B..16 + B).contains(&z) {
|
||||
Some(pi(x, y, z))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[inline]
|
||||
fn unpi(i: usize) -> (i32, i32, i32) {
|
||||
let x = (i % P) as i32 - B;
|
||||
let z = (i / P % P) as i32 - B;
|
||||
let y = (i / (P * P)) as i32 - B;
|
||||
(x, y, z)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{blocks::RenderShape, graphics::LeafCullingMode};
|
||||
use horizon_core::geometry::Direction;
|
||||
use horizon_world::fluid::FluidState;
|
||||
use std::time::Instant;
|
||||
|
||||
fn info(leaves: bool, opaque: bool) -> Info {
|
||||
Info {
|
||||
solid: opaque,
|
||||
full: opaque,
|
||||
permeable: false,
|
||||
shade: 1.0,
|
||||
emission: 0,
|
||||
tier: 0,
|
||||
emissive: false,
|
||||
fluid: FluidState::EMPTY,
|
||||
model: true,
|
||||
block_entity: false,
|
||||
tints: [super::super::Tint::None; 2],
|
||||
tint_count: 0,
|
||||
offset: None,
|
||||
legacy_solid: false,
|
||||
overlay: leaves,
|
||||
leaves,
|
||||
opaque_cube: opaque,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
face: [0; 6],
|
||||
cover: [0.0; 6],
|
||||
}
|
||||
}
|
||||
|
||||
/// State 0 is air, 1 leaves, 2 a full opaque cube, 3 a partial model.
|
||||
fn states() -> Vec<Info> {
|
||||
vec![
|
||||
info(false, false),
|
||||
info(true, false),
|
||||
info(false, true),
|
||||
info(false, false),
|
||||
]
|
||||
}
|
||||
|
||||
fn padded(fill: impl Fn(i32, i32, i32) -> u32) -> Padded {
|
||||
Padded {
|
||||
blocks: (0..P * P * P)
|
||||
.map(|i| {
|
||||
let (x, y, z) = unpi(i);
|
||||
fill(x, y, z)
|
||||
})
|
||||
.collect(),
|
||||
light: vec![0; P * P * P],
|
||||
}
|
||||
}
|
||||
|
||||
fn cube(pad: &mut Padded, min: i32, max: i32, state: u32) {
|
||||
for y in min..=max {
|
||||
for z in min..=max {
|
||||
for x in min..=max {
|
||||
pad.blocks[pi(x, y, z)] = state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn loaded() -> Chunk {
|
||||
Chunk::empty(0, 0)
|
||||
}
|
||||
|
||||
fn region(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
|
||||
[[Some(chunk); 3]; 3]
|
||||
}
|
||||
|
||||
fn built(mode: LeafCullingMode, pad: &Padded, states: &[Info], chunk: &Chunk) -> LeafMask {
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(mode, pad, states, ®ion(chunk), 0);
|
||||
mask
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_full_model_cube_is_opaque() {
|
||||
let combinations = [
|
||||
(RenderShape::Model, true, true, true),
|
||||
(RenderShape::Model, true, true, false),
|
||||
(RenderShape::Model, true, false, true),
|
||||
(RenderShape::Model, false, true, true),
|
||||
(RenderShape::Model, false, false, false),
|
||||
(RenderShape::Invisible, true, true, true),
|
||||
(RenderShape::Animated, true, true, true),
|
||||
];
|
||||
let (shape, can_occlude, solid_render, full_collision) = combinations[0];
|
||||
assert!(opaque_cube(
|
||||
shape,
|
||||
can_occlude,
|
||||
solid_render,
|
||||
full_collision
|
||||
));
|
||||
for &(shape, can_occlude, solid_render, full_collision) in &combinations[1..] {
|
||||
assert!(
|
||||
!opaque_cube(shape, can_occlude, solid_render, full_collision),
|
||||
"{shape:?} {can_occlude} {solid_render} {full_collision}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_marks_the_cell_inside_a_three_cube() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
let chunk = loaded();
|
||||
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
|
||||
assert!(solid.interior(LeafCullingMode::Solid, 8, 8, 8));
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 8, 9, 8));
|
||||
// Aggressive also takes the six face-centre leaves, whose only open
|
||||
// side faces out of the cube. Corners and edges keep their cutout.
|
||||
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
|
||||
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 8));
|
||||
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 7, 8, 8));
|
||||
assert!(aggressive.interior(LeafCullingMode::SolidAggressive, 8, 8, 9));
|
||||
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 8));
|
||||
assert!(!aggressive.interior(LeafCullingMode::SolidAggressive, 7, 7, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggressive_takes_the_next_shell_layer() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 6, 10, 1);
|
||||
let chunk = loaded();
|
||||
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
|
||||
let aggressive = built(LeafCullingMode::SolidAggressive, &pad, &states, &chunk);
|
||||
let surface = |v: i32| v == 6 || v == 10;
|
||||
for [x, y, z] in [
|
||||
[6, 8, 8],
|
||||
[10, 8, 8],
|
||||
[6, 6, 8],
|
||||
[6, 6, 6],
|
||||
[7, 8, 8],
|
||||
[8, 8, 8],
|
||||
[7, 7, 7],
|
||||
[8, 8, 7],
|
||||
] {
|
||||
let expected = !surface(x) && !surface(y) && !surface(z);
|
||||
assert_eq!(
|
||||
solid.interior(LeafCullingMode::Solid, x, y, z),
|
||||
expected,
|
||||
"solid {x} {y} {z}"
|
||||
);
|
||||
let open = [x, y, z].iter().filter(|v| surface(**v)).count();
|
||||
let expected = open <= 1;
|
||||
assert_eq!(
|
||||
aggressive.interior(LeafCullingMode::SolidAggressive, x, y, z),
|
||||
expected,
|
||||
"aggressive {x} {y} {z}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hollow_hides_only_leaf_to_leaf_faces() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
// Glass (3, a partial model), water and a slab are open: their leaves
|
||||
// must keep the face.
|
||||
pad.blocks[pi(10, 8, 8)] = 3;
|
||||
let chunk = loaded();
|
||||
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
|
||||
for d in Direction::ALL {
|
||||
assert!(
|
||||
hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
|
||||
"{d:?}"
|
||||
);
|
||||
}
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 9, 8, 8, Direction::East));
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::West));
|
||||
// The other modes never hide a quad.
|
||||
for mode in [
|
||||
LeafCullingMode::Off,
|
||||
LeafCullingMode::Solid,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
] {
|
||||
let mask = built(mode, &pad, &states, &chunk);
|
||||
assert!(!mask.hides_face(mode, 8, 8, 8, Direction::Up));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cavity_keeps_its_faces_and_its_rim_visible() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 6, 10, 1);
|
||||
pad.blocks[pi(8, 8, 8)] = 0;
|
||||
let chunk = loaded();
|
||||
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 7, 8, 8, Direction::East));
|
||||
assert!(!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 7, Direction::South));
|
||||
let solid = built(LeafCullingMode::Solid, &pad, &states, &chunk);
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 7, 8, 8));
|
||||
assert!(!solid.interior(LeafCullingMode::Solid, 6, 8, 8));
|
||||
// The corner away from the cavity is still two layers in.
|
||||
assert!(solid.interior(LeafCullingMode::Solid, 9, 9, 9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opaque_neighbour_does_not_expose_a_leaf() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
for (dx, dy, dz) in [
|
||||
(1, 0, 0),
|
||||
(-1, 0, 0),
|
||||
(0, 1, 0),
|
||||
(0, -1, 0),
|
||||
(0, 0, 1),
|
||||
(0, 0, -1),
|
||||
] {
|
||||
pad.blocks[pi(8 + dx, 8 + dy, 8 + dz)] = 2;
|
||||
}
|
||||
pad.blocks[pi(8, 8, 8)] = 1;
|
||||
let chunk = loaded();
|
||||
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
|
||||
let mask = built(mode, &pad, &states, &chunk);
|
||||
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
|
||||
}
|
||||
let hollow = built(LeafCullingMode::Hollow, &pad, &states, &chunk);
|
||||
for d in Direction::ALL {
|
||||
assert!(
|
||||
!hollow.hides_face(LeafCullingMode::Hollow, 8, 8, 8, d),
|
||||
"{d:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_leaf_inside_a_closed_volume_is_interior() {
|
||||
let states = states();
|
||||
// A leaf cube inside opaque rock: the search has no opening to start
|
||||
// from, so every leaf stays unvisited and counts as interior.
|
||||
let mut pad = padded(|_, _, _| 2);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
let chunk = loaded();
|
||||
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
|
||||
let mask = built(mode, &pad, &states, &chunk);
|
||||
if mode == LeafCullingMode::SolidAggressive {
|
||||
assert_eq!(mask.depth[pi(8, 8, 8)], UNVISITED);
|
||||
}
|
||||
assert!(mask.interior(mode, 8, 8, 8), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_chunks_and_sections_are_unknown() {
|
||||
let states = states();
|
||||
let pad = padded(|_, _, _| 1);
|
||||
let chunk = loaded();
|
||||
let mut around = [[Some(&chunk); 3]; 3];
|
||||
around[1][2] = None; // east chunk
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Hollow, &pad, &states, &around, 0);
|
||||
// The leaf at the edge keeps its face toward the missing chunk, even
|
||||
// though `Padded::fill` copied the centre's edge leaf there.
|
||||
assert!(!mask.hides_face(LeafCullingMode::Hollow, 15, 8, 8, Direction::East));
|
||||
// A leaf one block inside is still between two known leaves.
|
||||
assert!(mask.hides_face(LeafCullingMode::Hollow, 14, 8, 8, Direction::East));
|
||||
|
||||
// A section above the column is unknown too: the same leaf one
|
||||
// section up has no data, so it can never be interior.
|
||||
let short = Chunk::with_layout(0, 0, crate::chunk::ColumnLayout::new(0, 16).unwrap());
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Solid, &pad, &states, ®ion(&short), 16);
|
||||
assert!(!mask.interior(LeafCullingMode::Solid, 8, 8, 8));
|
||||
|
||||
// The outer shell has no data beyond it.
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Hollow, &pad, &states, ®ion(&chunk), 0);
|
||||
assert_eq!(mask.cell(-B, 0, 0), Cell::Unknown);
|
||||
assert_eq!(mask.cell(16 + B - 1, 0, 0), Cell::Unknown);
|
||||
assert_eq!(mask.cell(0, 0, 0), Cell::Leaf);
|
||||
|
||||
// A section boundary is never enclosed, even with every chunk loaded:
|
||||
// the neighbouring section may not have been meshed yet.
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Solid, &pad, &states, ®ion(&chunk), 0);
|
||||
assert!(!mask.interior(LeafCullingMode::Solid, 15, 8, 8));
|
||||
assert!(mask.interior(LeafCullingMode::Solid, 14, 8, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_does_no_work_and_hides_nothing() {
|
||||
let states = states();
|
||||
let mut pad = padded(|_, _, _| 0);
|
||||
cube(&mut pad, 7, 9, 1);
|
||||
let chunk = loaded();
|
||||
let mut mask = LeafMask::default();
|
||||
mask.prepare(LeafCullingMode::Off, &pad, &states, ®ion(&chunk), 0);
|
||||
assert!(!mask.active);
|
||||
assert!(mask.cells.is_empty());
|
||||
assert!(!mask.interior(LeafCullingMode::Off, 8, 8, 8));
|
||||
assert!(!mask.hides_face(LeafCullingMode::Off, 8, 8, 8, Direction::Up));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reused_mask_reclassifies_every_cell() {
|
||||
let states = states();
|
||||
let chunk = loaded();
|
||||
let mut mask = LeafMask::default();
|
||||
let mut leaves = padded(|_, _, _| 0);
|
||||
cube(&mut leaves, 7, 9, 1);
|
||||
mask.prepare(
|
||||
LeafCullingMode::Hollow,
|
||||
&leaves,
|
||||
&states,
|
||||
®ion(&chunk),
|
||||
0,
|
||||
);
|
||||
assert_eq!(mask.cell(8, 8, 8), Cell::Leaf);
|
||||
let mut other = padded(|_, _, _| 0);
|
||||
cube(&mut other, 10, 12, 1);
|
||||
mask.prepare(LeafCullingMode::Hollow, &other, &states, ®ion(&chunk), 0);
|
||||
assert_eq!(mask.cell(8, 8, 8), Cell::Open);
|
||||
assert_eq!(mask.cell(11, 11, 11), Cell::Leaf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "microbenchmark; run with --ignored --nocapture"]
|
||||
fn mask_build_benchmark() {
|
||||
let states = states();
|
||||
let mut pad = padded(|x, y, z| {
|
||||
// Alternating leaf slabs make the search visit most of the volume.
|
||||
if (x / 2 + y / 2 + z / 2) % 3 == 0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
cube(&mut pad, 6, 10, 1);
|
||||
let chunk = loaded();
|
||||
let region = region(&chunk);
|
||||
let iterations = 1000;
|
||||
for mode in [LeafCullingMode::Solid, LeafCullingMode::SolidAggressive] {
|
||||
let mut mask = LeafMask::default();
|
||||
let started = Instant::now();
|
||||
for _ in 0..iterations {
|
||||
mask.prepare(mode, &pad, &states, ®ion, 0);
|
||||
}
|
||||
let per = started.elapsed() / iterations;
|
||||
println!("leaf mask {mode:?}: {per:?} per section ({iterations} iterations)");
|
||||
assert!(mask.active);
|
||||
if mode == LeafCullingMode::SolidAggressive {
|
||||
assert_ne!(mask.depth[pi(8, 8, 8)], UNVISITED);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
//! Leaf culling through the real mesher: face counts, the opaque layer and the
|
||||
//! conservative cases (cavities, partial neighbours, missing chunks, column
|
||||
//! ends). Needs prepared resources, like the other mesher geometry tests.
|
||||
use super::*;
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, ColumnLayout},
|
||||
graphics::LeafCullingMode,
|
||||
models,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
assets: Assets,
|
||||
biomes: Biomes,
|
||||
render: RenderAssets,
|
||||
visibility: horizon_world::block_render::FaceVisibility,
|
||||
tables: MeshTables,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
Fixture {
|
||||
assets,
|
||||
biomes,
|
||||
render,
|
||||
visibility,
|
||||
tables,
|
||||
}
|
||||
}
|
||||
|
||||
fn state(f: &Fixture, name: &str) -> u32 {
|
||||
f.assets.registry.default_state(name).unwrap().0
|
||||
}
|
||||
|
||||
fn leaves_cube(chunk: &mut Chunk, min: i32, max: i32, y: i32, state: u32) {
|
||||
for z in min..=max {
|
||||
for x in min..=max {
|
||||
chunk.set(x, y, z, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf_cube(f: &Fixture, min: i32, max: i32, base: i32, name: &str) -> Chunk {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let state = state(f, name);
|
||||
for y in base..base + (max - min + 1) {
|
||||
leaves_cube(&mut chunk, min, max, y, state);
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
fn mesh(
|
||||
f: &Fixture,
|
||||
_chunk: &Chunk,
|
||||
region: [[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
mode: LeafCullingMode,
|
||||
) -> SectionMesh {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
assets: &f.assets,
|
||||
render: &f.render,
|
||||
tables: &f.tables,
|
||||
biomes: &f.biomes,
|
||||
visibility: f.visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: mode,
|
||||
};
|
||||
mesher.mesh(®ion, sy)
|
||||
}
|
||||
|
||||
fn around(chunk: &Chunk) -> [[Option<&Chunk>; 3]; 3] {
|
||||
[[Some(chunk); 3]; 3]
|
||||
}
|
||||
|
||||
/// (opaque, cutout) leaf quads, rejecting a quad whose four vertices disagree.
|
||||
fn quads(mesh: &SectionMesh) -> (usize, usize) {
|
||||
let mut opaque = 0;
|
||||
let mut cutout = 0;
|
||||
for quad in mesh.solid.as_chunks::<4>().0 {
|
||||
let flags = quad
|
||||
.iter()
|
||||
.map(|v| v.layer & OPAQUE_LAYER != 0)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
flags.iter().all(|flag| *flag == flags[0]),
|
||||
"a quad mixed opaque and cutout vertices: {flags:?}"
|
||||
);
|
||||
if flags[0] {
|
||||
opaque += 1;
|
||||
} else {
|
||||
cutout += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(mesh.solid.len() % 4, 0);
|
||||
(opaque, cutout)
|
||||
}
|
||||
|
||||
const BASE: i32 = MIN_Y + 4;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn off_draws_every_fancy_leaf_face() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
|
||||
let mesh = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
|
||||
// 27 blocks, six quads each: vanilla's fancy leaves keep the inside.
|
||||
assert_eq!(quads(&mesh), (0, 162));
|
||||
assert_eq!(mesh.translucent.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn solid_draws_the_fully_enclosed_leaf_opaque() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// Only the centre has all six sides covered: its six quads go opaque.
|
||||
assert_eq!(quads(&solid), (6, 156));
|
||||
|
||||
let aggressive = mesh(
|
||||
&f,
|
||||
&chunk,
|
||||
around(&chunk),
|
||||
0,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
);
|
||||
// Aggressive also takes the six face-centre leaves: seven blocks opaque.
|
||||
assert_eq!(quads(&aggressive), (42, 120));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn aggressive_solidifies_the_next_shell_layer_of_a_five_cube() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// The 3x3x3 inside the surface: 27 blocks, six quads each.
|
||||
assert_eq!(quads(&solid), (162, 588));
|
||||
let aggressive = mesh(
|
||||
&f,
|
||||
&chunk,
|
||||
around(&chunk),
|
||||
0,
|
||||
LeafCullingMode::SolidAggressive,
|
||||
);
|
||||
// The 3x3x3 inside plus the 3x3 face centre of each of the six sides.
|
||||
assert_eq!(quads(&aggressive), (486, 264));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn hollow_drops_only_the_faces_two_leaves_share() {
|
||||
let f = fixture();
|
||||
let chunk = leaf_cube(&f, 6, 8, BASE, "minecraft:oak_leaves");
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
// 162 quads minus the 54 shared pairs' 108 faces: the 3x3x3 shell.
|
||||
assert_eq!(quads(&hollow), (0, 54));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn mixed_species_count_as_one_leaf_volume() {
|
||||
let f = fixture();
|
||||
let oak = state(&f, "minecraft:oak_leaves");
|
||||
let birch = state(&f, "minecraft:birch_leaves");
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in BASE..BASE + 3 {
|
||||
for z in 6..=8 {
|
||||
for x in 6..=8 {
|
||||
chunk.set(x, y, z, if (x + y + z) % 2 == 0 { oak } else { birch });
|
||||
}
|
||||
}
|
||||
}
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
assert_eq!(quads(&solid), (6, 156));
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
assert_eq!(quads(&hollow), (0, 54));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn a_cavity_keeps_its_six_faces_and_its_rim_stays_cutout() {
|
||||
let f = fixture();
|
||||
let mut chunk = leaf_cube(&f, 5, 9, BASE, "minecraft:oak_leaves");
|
||||
chunk.set(7, BASE + 2, 7, 0);
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
// Five-cube shell (150) plus the six faces around the one-block cavity.
|
||||
assert_eq!(quads(&hollow), (0, 156));
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// The six leaves around the cavity are exposed, so they stay cutout; the
|
||||
// other 20 inner leaves draw solid. The cavity block itself has no quads.
|
||||
assert_eq!(quads(&solid), (120, 624));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn section_boundary_faces_stay_with_a_loaded_neighbour() {
|
||||
let f = fixture();
|
||||
let leaf = state(&f, "minecraft:oak_leaves");
|
||||
let chunk = leaf_cube(&f, 12, 15, BASE, "minecraft:oak_leaves");
|
||||
// A loaded east chunk whose x=0 column continues the canopy.
|
||||
let mut east = Chunk::empty(1, 0);
|
||||
for y in BASE..BASE + 4 {
|
||||
for z in 12..=15 {
|
||||
east.set(0, y, z, leaf);
|
||||
}
|
||||
}
|
||||
let mut region = around(&chunk);
|
||||
region[1][2] = Some(&east);
|
||||
let joined = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
|
||||
let mut region = around(&chunk);
|
||||
region[1][2] = None;
|
||||
let missing = mesh(&f, &chunk, region, 0, LeafCullingMode::Hollow);
|
||||
// The east faces of the x=15 leaves are on the section boundary: a loaded
|
||||
// neighbour does not let Hollow drop them, and a missing one does not
|
||||
// change the mesh either.
|
||||
assert_eq!(joined.solid.len(), missing.solid.len());
|
||||
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
|
||||
assert!(joined.solid.len() < off.solid.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn the_top_of_a_one_section_column_stays_visible() {
|
||||
let f = fixture();
|
||||
let leaf = state(&f, "minecraft:oak_leaves");
|
||||
let layout = ColumnLayout::new(0, 16).unwrap();
|
||||
let mut chunk = Chunk::with_layout(0, 0, layout);
|
||||
for y in 13..16 {
|
||||
leaves_cube(&mut chunk, 5, 7, y, leaf);
|
||||
}
|
||||
let hollow = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Hollow);
|
||||
let solid = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Solid);
|
||||
// y=15 is the column's top: the faces toward y=16 are unknown, so they
|
||||
// stay, and the y=15 leaves are not enclosed.
|
||||
assert!(!hollow.solid.is_empty());
|
||||
assert!(solid.solid.iter().any(|v| v.pos[1] == 16.0));
|
||||
assert!(
|
||||
solid
|
||||
.solid
|
||||
.iter()
|
||||
.filter(|v| v.pos[1] == 16.0)
|
||||
.all(|v| v.layer & OPAQUE_LAYER == 0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn a_world_without_leaves_meshes_identically_in_every_mode() {
|
||||
let f = fixture();
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
for y in BASE..BASE + 3 {
|
||||
leaves_cube(&mut chunk, 6, 8, y, state(&f, "minecraft:stone"));
|
||||
}
|
||||
let off = mesh(&f, &chunk, around(&chunk), 0, LeafCullingMode::Off);
|
||||
for mode in LeafCullingMode::ALL {
|
||||
let other = mesh(&f, &chunk, around(&chunk), 0, mode);
|
||||
assert_eq!(off.solid.len(), other.solid.len(), "{mode:?}");
|
||||
assert_eq!(off.translucent.len(), other.translucent.len(), "{mode:?}");
|
||||
for (a, b) in off.solid.iter().zip(&other.solid) {
|
||||
assert_eq!(
|
||||
(a.pos, a.uv, a.layer, a.light, a.color),
|
||||
(b.pos, b.uv, b.layer, b.light, b.color),
|
||||
"{mode:?}"
|
||||
);
|
||||
}
|
||||
for (a, b) in off.translucent.iter().zip(&other.translucent) {
|
||||
assert_eq!(
|
||||
(a.pos, a.uv, a.layer, a.light, a.color),
|
||||
(b.pos, b.uv, b.layer, b.light, b.color),
|
||||
"{mode:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,8 +81,6 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mut colours: Vec<u32> = mesher
|
||||
.mesh(®ion, 0)
|
||||
@@ -158,8 +156,6 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: reload == 0,
|
||||
cutout_leaves: reload == 0,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
for (case, (center, east)) in [
|
||||
(&empty, None),
|
||||
|
||||
@@ -88,7 +88,6 @@ fn info(solid: bool) -> Info {
|
||||
legacy_solid: false,
|
||||
overlay: false,
|
||||
leaves: false,
|
||||
opaque_cube: solid,
|
||||
blocks_flow: false,
|
||||
ice: false,
|
||||
sturdy: 0,
|
||||
|
||||
@@ -219,24 +219,10 @@ impl StateModels {
|
||||
models.block_library_or_missing(root, names)?
|
||||
}
|
||||
};
|
||||
let resolved: Vec<_> = variants
|
||||
.iter()
|
||||
.map(|(name, ..)| library.resolve(name))
|
||||
.collect();
|
||||
// Every texture the models name, decoded side by side before the bake adds them one
|
||||
// by one in its own order.
|
||||
sprites.prefetch(
|
||||
root,
|
||||
resolved
|
||||
.iter()
|
||||
.flatten()
|
||||
.flat_map(|model| model.textures.values())
|
||||
.map(|material| material.sprite.as_str()),
|
||||
);
|
||||
let mut baked = Vec::with_capacity(variants.len());
|
||||
let mut index = BTreeMap::new();
|
||||
for ((name, turns, lock), model) in variants.into_iter().zip(resolved) {
|
||||
let model = model?;
|
||||
for (name, turns, lock) in variants {
|
||||
let model = library.resolve(&name)?;
|
||||
let particle = model
|
||||
.material("particle")?
|
||||
.map(|material| sprites.insert(root, &material.sprite))
|
||||
|
||||
@@ -1801,7 +1801,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
};
|
||||
let mut tip = None;
|
||||
if let Act::Extra(item) = act {
|
||||
tip = Some(Text::literal(item.description_text()));
|
||||
tip = Some(Text::literal(item.description()));
|
||||
}
|
||||
if let Act::Push(page) = act
|
||||
&& let Some(text) = horizon_page_tooltip(*page)
|
||||
@@ -1976,7 +1976,7 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
|
||||
impl OptionsUi {
|
||||
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
|
||||
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
|
||||
let label = format!("{}: {}", item.caption_text(), item.value(&s.extras));
|
||||
let label = format!("{}: {}", item.caption(), item.value(&s.extras));
|
||||
button(0.0, 0.0, 150.0, Text::literal(label), Act::Extra(item))
|
||||
}
|
||||
fn settings_search_active(&self) -> bool {
|
||||
@@ -2274,7 +2274,7 @@ impl OptionsUi {
|
||||
// Each section's switches two to a row, as vanilla's
|
||||
// OptionsList.addSmall lays out an options screen.
|
||||
let extra = |item: crate::extras::Item| {
|
||||
let value = format!("{}: {}", item.caption_text(), item.value(&s.extras));
|
||||
let value = format!("{}: {}", item.caption(), item.value(&s.extras));
|
||||
let label = if item.key_name().is_some() && self.selected_key == item.key_name()
|
||||
{
|
||||
Text::literal(format!("> {value} <")).with_color(text::YELLOW)
|
||||
@@ -4941,24 +4941,6 @@ impl OptionsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The window allows the input method only while `typing` says a search
|
||||
/// box has the focus (`Game::text_field_focused`), so a page without one
|
||||
/// must not claim the keys: the movement and menu keys keep working there.
|
||||
#[test]
|
||||
fn only_a_focused_search_box_takes_text() {
|
||||
let mut ui = OptionsUi::default();
|
||||
assert!(!ui.typing(), "the main page has no text box");
|
||||
ui.show(Page::Language);
|
||||
assert!(ui.typing(), "the language list's search takes the keys");
|
||||
ui.show(Page::Video);
|
||||
assert!(!ui.typing(), "the settings search is not focused");
|
||||
ui.settings_search.focused = true;
|
||||
assert!(ui.typing(), "the settings search, once focused, does");
|
||||
ui.settings_search.focused = false;
|
||||
ui.show(Page::Packs);
|
||||
assert!(!ui.typing(), "the pack screen's search is not open yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessibility_sliders_and_toggles_survive_options_round_trip() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -695,20 +695,18 @@ fn build_overlay_uncached(
|
||||
merged.insert(name.clone(), bytes);
|
||||
}
|
||||
}
|
||||
/// A zip entry to write: its pack, its name in the zip and where it goes.
|
||||
type Extract<'a> = (usize, &'a str, PathBuf);
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn mirror<'a>(
|
||||
fn mirror(
|
||||
rel: &str,
|
||||
vanilla: &Path,
|
||||
out: &Path,
|
||||
touched: &BTreeSet<String>,
|
||||
hidden: &BTreeSet<String>,
|
||||
files: &'a BTreeMap<String, (usize, Provided)>,
|
||||
files: &BTreeMap<String, (usize, Provided)>,
|
||||
merged: &BTreeMap<String, Vec<u8>>,
|
||||
filters: &[&Filter],
|
||||
zips: &[Option<Zip>],
|
||||
dependencies: &mut cache::Dependencies,
|
||||
extract: &mut Vec<Extract<'a>>,
|
||||
) -> io::Result<()> {
|
||||
let src = if rel.is_empty() {
|
||||
vanilla.to_path_buf()
|
||||
@@ -735,13 +733,7 @@ fn build_overlay_uncached(
|
||||
} else {
|
||||
format!("{rel}/")
|
||||
};
|
||||
// The files under this directory are one run of the sorted map: walking only that run
|
||||
// keeps a pack of tens of thousands of files in thousands of folders from being
|
||||
// scanned once per folder.
|
||||
for f in files
|
||||
.range(prefix.clone()..)
|
||||
.map_while(|(f, _)| f.strip_prefix(&prefix))
|
||||
{
|
||||
for f in files.keys().filter_map(|f| f.strip_prefix(&prefix)) {
|
||||
names.insert(f.split('/').next().unwrap_or("").to_owned());
|
||||
}
|
||||
for name in names {
|
||||
@@ -755,8 +747,19 @@ fn build_overlay_uncached(
|
||||
let target = out.join(&child);
|
||||
match path {
|
||||
Some(p) => link(p, &target)?,
|
||||
// Inflated and written after the walk, on every thread.
|
||||
None => extract.push((*owner, inner.as_str(), target)),
|
||||
None => {
|
||||
if let Some(Some(z)) = zips.get(*owner) {
|
||||
// One unreadable entry costs that file, not the
|
||||
// pack: an error here failed the build and the
|
||||
// reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(&target, bytes)?,
|
||||
Err(e) => log::warn!(
|
||||
"Horizon: skipping unreadable pack entry {inner}: {e}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if filters.is_empty()
|
||||
&& !touched.contains(&child)
|
||||
@@ -780,8 +783,8 @@ fn build_overlay_uncached(
|
||||
files,
|
||||
merged,
|
||||
filters,
|
||||
zips,
|
||||
dependencies,
|
||||
extract,
|
||||
)?;
|
||||
} else if filters.iter().any(|f| f.blocks(&child)) {
|
||||
// Hidden by a pack's filter.
|
||||
@@ -795,7 +798,6 @@ fn build_overlay_uncached(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut extract = Vec::new();
|
||||
mirror(
|
||||
"",
|
||||
vanilla,
|
||||
@@ -805,27 +807,9 @@ fn build_overlay_uncached(
|
||||
&files,
|
||||
&merged,
|
||||
&filters,
|
||||
&zips,
|
||||
dependencies,
|
||||
&mut extract,
|
||||
)?;
|
||||
// Inflating and writing a big pack's files one by one was most of a cold build; its
|
||||
// folders all exist by now.
|
||||
crate::par::par_map(&extract, |(owner, inner, target)| {
|
||||
let Some(Some(z)) = zips.get(*owner) else {
|
||||
return Ok(());
|
||||
};
|
||||
// One unreadable entry costs that file, not the pack: an error here
|
||||
// failed the build and the reload dropped every server pack in the stack.
|
||||
match z.read(inner) {
|
||||
Ok(bytes) => fs::write(target, bytes),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: skipping unreadable pack entry {inner}: {e}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_iter()
|
||||
.collect()
|
||||
)
|
||||
}
|
||||
/// Files vanilla loads from every pack in turn (`getResourceStack`) rather
|
||||
/// than from the top pack only.
|
||||
@@ -1505,72 +1489,6 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn every_file_of_a_big_stack_comes_from_its_top_pack() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-stack-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
let vanilla = base.join("vanilla");
|
||||
// Folders whose names start alike (`item`, `item_extra`, `item2`) and files beside
|
||||
// folders of almost their name: what a prefix lookup could mix up.
|
||||
let names: Vec<String> = (0..400)
|
||||
.map(|i| {
|
||||
let folder = ["item", "item_extra", "item2", "item/sub", "block"][i % 5];
|
||||
format!("assets/ns{}/textures/{folder}/f{i}.png", i % 3)
|
||||
})
|
||||
.chain(["assets/ns0/textures/item.png".to_owned()])
|
||||
.collect();
|
||||
let mut expected: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
for name in names.iter().step_by(4) {
|
||||
let path = vanilla.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, format!("vanilla {name}")).unwrap();
|
||||
expected.insert(name.clone(), format!("vanilla {name}").into_bytes());
|
||||
}
|
||||
let meta = br#"{"pack":{"description":"d","pack_format":97}}"#;
|
||||
// Packs a and b are zips, c a folder; each provides a different part of the names.
|
||||
fs::create_dir_all(base.join("packs/c")).unwrap();
|
||||
fs::write(base.join("packs/c/pack.mcmeta"), meta).unwrap();
|
||||
for (pack, every) in [("a", 2), ("b", 3)] {
|
||||
let file = fs::File::create(base.join(format!("packs/{pack}.zip"))).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(meta).unwrap();
|
||||
for name in names.iter().step_by(every) {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(format!("{pack} {name}").as_bytes()).unwrap();
|
||||
expected.insert(name.clone(), format!("{pack} {name}").into_bytes());
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
}
|
||||
for name in names.iter().skip(1).step_by(5) {
|
||||
let path = base.join("packs/c").join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, format!("c {name}")).unwrap();
|
||||
expected.insert(name.clone(), format!("c {name}").into_bytes());
|
||||
}
|
||||
let all = folder(&base.join("packs"), RESOURCE_FORMAT);
|
||||
let ids = ["file/a.zip", "file/b.zip", "file/c"].map(String::from);
|
||||
let out = base.join("overlay");
|
||||
build_overlay(&vanilla, &enabled(&all, &ids), &out).unwrap();
|
||||
fn walk(dir: &Path, root: &Path, out: &mut BTreeMap<String, Vec<u8>>) {
|
||||
for entry in fs::read_dir(dir).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, root, out);
|
||||
} else {
|
||||
let name = path.strip_prefix(root).unwrap().to_string_lossy();
|
||||
out.insert(name.replace('\\', "/"), fs::read(&path).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut built = BTreeMap::new();
|
||||
walk(&out.join("assets"), &out, &mut built);
|
||||
assert_eq!(built, expected);
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
#[test]
|
||||
fn zip_entries_never_leave_the_overlay() {
|
||||
use std::io::Write as _;
|
||||
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
|
||||
|
||||
@@ -926,8 +926,6 @@ impl PostEffects {
|
||||
let mut decoder = png::Decoder::new(std::io::BufReader::new(
|
||||
std::fs::File::open(&file).map_err(|e| format!("{}: {e}", file.display()))?,
|
||||
));
|
||||
// As the other resource-pack images: stb_image never checks chunk CRCs.
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
|
||||
let mut data = vec![0; reader.output_buffer_size().ok_or("a huge image")?];
|
||||
|
||||
@@ -827,14 +827,6 @@ impl TextField {
|
||||
);
|
||||
}
|
||||
}
|
||||
if self.focused {
|
||||
// `TextInputManager.setTextInputArea`, in GUI pixels: the caret
|
||||
// the input method's composition and its candidate window go to.
|
||||
// The chat and in-bed chat boxes and the pack screen's search are
|
||||
// `TextField`s; the other fields are an `EditBox` or a multiline
|
||||
// box, which report their own.
|
||||
crate::preedit::report_caret(cursor_x as f32, ty as f32, 10.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,9 +68,6 @@ pub enum Ticking {
|
||||
/// `RidingEntitySoundInstance` for the player riding a happy ghast
|
||||
/// (`underwater_sound` false) or a nautilus (true).
|
||||
RidingEntity { entity: u32, underwater_sound: bool },
|
||||
/// A native mod's music layer (`MusicLayers`), whose volume its owner
|
||||
/// sets each tick.
|
||||
MusicLayer,
|
||||
}
|
||||
/// What a minecart's sound instances read about it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -236,25 +233,6 @@ impl SoundInstance {
|
||||
true,
|
||||
)
|
||||
}
|
||||
/// A native mod's music layer: a looping, unattenuated track on the music
|
||||
/// source that starts silent (`MusicLayers` fades it in).
|
||||
pub fn for_music_layer(id: &str, random: LegacyRandom) -> Self {
|
||||
let mut s = Self::new(
|
||||
id,
|
||||
MUSIC,
|
||||
0.0,
|
||||
1.0,
|
||||
random,
|
||||
true,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
true,
|
||||
);
|
||||
s.can_start_silent = true;
|
||||
s.ticking = Ticking::MusicLayer;
|
||||
s
|
||||
}
|
||||
/// `SimpleSoundInstance.forJukeboxSong`.
|
||||
pub fn for_jukebox_song(id: &str, at: [f64; 3], random: LegacyRandom) -> Self {
|
||||
Self::new(
|
||||
@@ -544,7 +522,7 @@ impl SoundInstance {
|
||||
/// `TickableSoundInstance.tick`.
|
||||
pub fn tick(&mut self, ctx: &TickContext) {
|
||||
match &mut self.ticking {
|
||||
Ticking::No | Ticking::MusicLayer => {}
|
||||
Ticking::No => {}
|
||||
Ticking::BiomeLoop { fade, direction } => {
|
||||
let stop = *fade < 0;
|
||||
*fade += *direction;
|
||||
|
||||
@@ -13,11 +13,6 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
/// A string of this page: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
lang::get_or(key, english)
|
||||
}
|
||||
|
||||
/// Leave room for navigation, creation and cancellation in a 240-pixel menu.
|
||||
const SHOWN: usize = 4;
|
||||
pub mod store;
|
||||
@@ -137,12 +132,7 @@ impl SplitProfiles {
|
||||
};
|
||||
if let Some(name) = &self.forgetting {
|
||||
return vec![
|
||||
button(
|
||||
120.0,
|
||||
tr("horizon.splitscreen.profiles.forget", "Forget"),
|
||||
self.editable(name),
|
||||
Target::Forget,
|
||||
),
|
||||
button(120.0, "Forget".into(), self.editable(name), Target::Forget),
|
||||
button(
|
||||
144.0,
|
||||
lang::get_or("gui.cancel", "Cancel"),
|
||||
@@ -157,12 +147,7 @@ impl SplitProfiles {
|
||||
let ok = valid_name(name) && !self.names.iter().any(|n| n.eq_ignore_ascii_case(name));
|
||||
let top = field_top(h);
|
||||
return vec![
|
||||
button(
|
||||
top + 25.0,
|
||||
tr("horizon.splitscreen.profiles.create", "Create"),
|
||||
ok,
|
||||
Target::Create,
|
||||
),
|
||||
button(top + 25.0, "Create".into(), ok, Target::Create),
|
||||
button(
|
||||
top + 50.0,
|
||||
lang::get_or("gui.back", "Back"),
|
||||
@@ -181,7 +166,7 @@ impl SplitProfiles {
|
||||
{
|
||||
let mut label = name.clone();
|
||||
if self.current.as_ref() == Some(name) {
|
||||
label.push_str(&tr("horizon.splitscreen.profiles.you", " - you"));
|
||||
label.push_str(" - you");
|
||||
}
|
||||
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
|
||||
buttons.push((
|
||||
@@ -192,8 +177,7 @@ impl SplitProfiles {
|
||||
));
|
||||
buttons.push((
|
||||
[x + 180.0, y, 20.0, 20.0],
|
||||
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
|
||||
.replacen("%s", name, 1),
|
||||
format!("Forget {name}"),
|
||||
self.editable(name),
|
||||
Target::Remove(i),
|
||||
));
|
||||
@@ -202,24 +186,19 @@ impl SplitProfiles {
|
||||
if self.names.len() > SHOWN {
|
||||
buttons.push((
|
||||
[x, y, 98.0, 20.0],
|
||||
tr("horizon.splitscreen.profiles.previous", "Previous"),
|
||||
"Previous".into(),
|
||||
self.page() > 0,
|
||||
Target::Previous,
|
||||
));
|
||||
buttons.push((
|
||||
[x + 102.0, y, 98.0, 20.0],
|
||||
tr("horizon.splitscreen.profiles.next", "Next"),
|
||||
"Next".into(),
|
||||
(self.page() + 1) * SHOWN < self.names.len(),
|
||||
Target::Next,
|
||||
));
|
||||
y += 24.0;
|
||||
}
|
||||
buttons.push(button(
|
||||
y,
|
||||
tr("horizon.splitscreen.profiles.new_button", "New Profile..."),
|
||||
true,
|
||||
Target::New,
|
||||
));
|
||||
buttons.push(button(y, "New Profile...".into(), true, Target::New));
|
||||
let cancel = lang::get_or("gui.cancel", "Cancel");
|
||||
buttons.push(button(y + 24.0, cancel, true, Target::Cancel));
|
||||
buttons
|
||||
@@ -241,71 +220,30 @@ impl SplitProfiles {
|
||||
};
|
||||
let buttons = self.buttons(w, h);
|
||||
if let Some(name) = &self.forgetting {
|
||||
STYLE.title(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(tr(
|
||||
"horizon.splitscreen.profiles.forget_title",
|
||||
"Forget profile?",
|
||||
)),
|
||||
);
|
||||
STYLE.title(batch, atlas, &Text::literal("Forget profile?"));
|
||||
line(batch, name, 52.0, u32::MAX);
|
||||
line(
|
||||
batch,
|
||||
&tr(
|
||||
"horizon.splitscreen.profiles.forget_leaves",
|
||||
"It leaves this instance's list.",
|
||||
),
|
||||
76.0,
|
||||
grey,
|
||||
);
|
||||
line(
|
||||
batch,
|
||||
&tr(
|
||||
"horizon.splitscreen.profiles.forget_kept",
|
||||
"World progress and settings are kept.",
|
||||
),
|
||||
90.0,
|
||||
grey,
|
||||
);
|
||||
line(batch, "It leaves this instance's list.", 76.0, grey);
|
||||
line(batch, "World progress and settings are kept.", 90.0, grey);
|
||||
if let Some(why) = &self.refused {
|
||||
line(batch, why, 176.0, rgba(255, 85, 85, 255));
|
||||
}
|
||||
} else if let Some(field) = &mut self.new {
|
||||
STYLE.title(
|
||||
batch,
|
||||
atlas,
|
||||
&Text::literal(tr("horizon.splitscreen.profiles.new_title", "New Profile")),
|
||||
);
|
||||
STYLE.title(batch, atlas, &Text::literal("New Profile"));
|
||||
let (x, top) = ((w / 2.0).floor() - 100.0, field_top(h));
|
||||
let label = tr(
|
||||
"horizon.splitscreen.profiles.name_hint",
|
||||
"Name (3 to 16 letters, digits, _)",
|
||||
);
|
||||
batch.text(atlas, &label, x, top - 14.0, grey, true);
|
||||
let label = "Name (3 to 16 letters, digits, _)";
|
||||
batch.text(atlas, label, x, top - 14.0, grey, true);
|
||||
(field.x, field.y, field.width, field.height) = (x, top, 200.0, 20.0);
|
||||
field.draw(batch, atlas, None);
|
||||
} else {
|
||||
let title = match self.current {
|
||||
Some(_) => tr(
|
||||
"horizon.splitscreen.profiles.switch_title",
|
||||
"Switch profile",
|
||||
),
|
||||
None => tr("horizon.pages.split_profiles", "Who's playing?"),
|
||||
Some(_) => "Switch profile",
|
||||
None => "Who's playing?",
|
||||
};
|
||||
STYLE.title(batch, atlas, &Text::literal(title));
|
||||
self.draw_bumpers(batch, atlas, style);
|
||||
// Under Cancel: where offline profiles live.
|
||||
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
|
||||
line(
|
||||
batch,
|
||||
&tr(
|
||||
"horizon.splitscreen.profiles.offline_note",
|
||||
"Offline profiles belong to this instance",
|
||||
),
|
||||
y,
|
||||
grey,
|
||||
);
|
||||
line(batch, "Offline profiles belong to this instance", y, grey);
|
||||
// Why the last pick can't play, wrapped to the view (a quarter of a window is narrow).
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let why = self.refused.as_deref().unwrap_or_default();
|
||||
@@ -370,12 +308,8 @@ impl SplitProfiles {
|
||||
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
|
||||
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
|
||||
let words = match &self.place {
|
||||
Some(place) => tr(
|
||||
"horizon.splitscreen.profiles.move_place",
|
||||
"Move where you play: %s",
|
||||
)
|
||||
.replacen("%s", place, 1),
|
||||
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
|
||||
Some(place) => format!("Move where you play: {place}"),
|
||||
None => "Move where you play".to_owned(),
|
||||
};
|
||||
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
|
||||
let key_width = |b: PadButton| width(b.label(style)) + 4.0;
|
||||
|
||||
@@ -99,9 +99,6 @@ pub struct Sprites {
|
||||
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
|
||||
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
|
||||
disk: Option<crate::sprite_cache::SpriteCache>,
|
||||
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
|
||||
/// resource path.
|
||||
ready: std::collections::HashMap<String, crate::sprite_cache::Decoded>,
|
||||
}
|
||||
/// Edge length of vanilla's block textures: the smallest array layer.
|
||||
pub const SPRITE_SIZE: u32 = 16;
|
||||
@@ -245,8 +242,6 @@ impl Sprites {
|
||||
self.lay_out(false);
|
||||
}
|
||||
fn lay_out(&mut self, pixels: bool) {
|
||||
// Prefetched textures nothing inserted.
|
||||
self.ready = Default::default();
|
||||
let layers = self.native.len().max(1) as u64;
|
||||
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
|
||||
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
|
||||
@@ -340,45 +335,12 @@ impl Sprites {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Decode on every thread the textures about to be inserted: sprite names, or resource
|
||||
/// paths outside `textures/` (as [`Sprites::insert_file`] takes them). A loader that adds
|
||||
/// many (the block models, an OptiFine pack's tiles) calls this first and then inserts
|
||||
/// them in its own order, so the layers are the same as without it. What is already a
|
||||
/// layer or in the disk cache is skipped; what fails to decode is left for the insert,
|
||||
/// which reports it.
|
||||
pub fn prefetch<'a>(&mut self, root: &Path, names: impl IntoIterator<Item = &'a str>) {
|
||||
let names: Vec<&str> = names
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
!self.layers.contains_key(*name)
|
||||
&& !self.ready.contains_key(*name)
|
||||
&& self.disk.as_ref().is_none_or(|d| d.get(name).is_none())
|
||||
})
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let decoded = crate::par::par_map(&names, |name| {
|
||||
if name.contains(':') {
|
||||
Self::decode(root, name).ok()
|
||||
} else {
|
||||
Self::decode_file(&root.join(name), name).ok()
|
||||
}
|
||||
});
|
||||
for (name, decoded) in names.into_iter().zip(decoded) {
|
||||
if let Some(decoded) = decoded {
|
||||
self.ready.insert(name.to_owned(), decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn load(&mut self, root: &Path, name: &str) -> Result<u32> {
|
||||
let cached = self.disk.as_ref().and_then(|d| d.get(name)).cloned();
|
||||
let decoded = match cached {
|
||||
Some(decoded) => decoded,
|
||||
None => {
|
||||
let decoded = match self.ready.remove(name) {
|
||||
Some(decoded) => decoded,
|
||||
None => Self::decode(root, name)?,
|
||||
};
|
||||
let decoded = Self::decode(root, name)?;
|
||||
if let Some(disk) = &mut self.disk {
|
||||
disk.insert(name, decoded.clone());
|
||||
}
|
||||
@@ -387,29 +349,10 @@ impl Sprites {
|
||||
};
|
||||
self.register(root, name, decoded)
|
||||
}
|
||||
/// The layer of a texture outside `textures/` (an OptiFine pack's connected-texture tile),
|
||||
/// named by its resource path and added on first use. Unlike [`Sprites::insert`], a texture
|
||||
/// that does not load is an error: the caller drops what needed it. Such files bypass the
|
||||
/// disk cache, whose fingerprint covers only `textures/`.
|
||||
pub fn insert_file(&mut self, root: &Path, resource: &str) -> Result<u32> {
|
||||
if let Some(layer) = self.layers.get(resource) {
|
||||
return Ok(*layer);
|
||||
}
|
||||
let decoded = match self.ready.remove(resource) {
|
||||
Some(decoded) => decoded,
|
||||
None => Self::decode_file(&root.join(resource), resource)?,
|
||||
};
|
||||
self.register(root, resource, decoded)
|
||||
}
|
||||
/// Read and decode one texture and its `.mcmeta`.
|
||||
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let (namespace, path) = name.split_once(':').ok_or("invalid sprite identifier")?;
|
||||
Self::decode_file(
|
||||
&root.join(format!("assets/{namespace}/textures/{path}.png")),
|
||||
name,
|
||||
)
|
||||
}
|
||||
fn decode_file(path: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
|
||||
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
|
||||
Ok(text) => Some(serde_json::from_str::<
|
||||
BTreeMap<String, Box<serde_json::value::RawValue>>,
|
||||
@@ -425,7 +368,7 @@ impl Sprites {
|
||||
.as_deref()
|
||||
.map(AnimationMetadata::from_json)
|
||||
.transpose()?;
|
||||
let (png_width, png_height, pixels) = read_rgba(path)?;
|
||||
let (png_width, png_height, pixels) = read_rgba(&path)?;
|
||||
let (width, height) = if let Some(animation) = &animation {
|
||||
let [width, height] =
|
||||
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
|
||||
@@ -569,7 +512,6 @@ impl Sprites {
|
||||
/// A PNG's width, height and pixels as RGBA.
|
||||
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info()?;
|
||||
let mut bytes = vec![
|
||||
@@ -720,106 +662,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn files_outside_textures_load_by_path() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-sprite-file-{}", std::process::id()));
|
||||
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
|
||||
fs::create_dir_all(root.join(tile).parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(root.join(tile)).unwrap(), 16, 16);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..256).flat_map(|i| [i as u8, 0, 0, 128]).collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
let mut sprites = Sprites::default();
|
||||
let layer = sprites.insert_file(&root, tile).unwrap();
|
||||
assert_eq!(sprites.insert_file(&root, tile).unwrap(), layer);
|
||||
assert!(sprites.translucent[layer as usize]);
|
||||
// A missing tile is an error, not the missing texture.
|
||||
assert!(
|
||||
sprites
|
||||
.insert_file(&root, "assets/minecraft/optifine/ctm/glass/1.png")
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(sprites.count(), 1);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn prefetched_textures_take_the_layers_inserting_alone_gives() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-prefetch-{}", std::process::id()));
|
||||
let png = |path: &str, side: u32, seed: u8| {
|
||||
let file = root.join(path);
|
||||
fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(File::create(file).unwrap(), side, side);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let pixels: Vec<u8> = (0..side * side)
|
||||
.flat_map(|i| {
|
||||
[
|
||||
seed,
|
||||
i as u8,
|
||||
(i >> 8) as u8,
|
||||
if i % 3 == 0 { 0 } else { 255 },
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(&pixels)
|
||||
.unwrap();
|
||||
};
|
||||
for i in 0..40u8 {
|
||||
png(
|
||||
&format!("assets/minecraft/textures/block/b{i}.png"),
|
||||
16 << (i % 3),
|
||||
i,
|
||||
);
|
||||
}
|
||||
// Two textures alike share a layer; a tile outside `textures/`, a broken file and a
|
||||
// missing one take the paths they take without prefetching.
|
||||
png("assets/minecraft/textures/block/same.png", 16, 3);
|
||||
png("assets/minecraft/optifine/ctm/glass/0.png", 32, 77);
|
||||
fs::write(
|
||||
root.join("assets/minecraft/textures/block/broken.png"),
|
||||
b"no png",
|
||||
)
|
||||
.unwrap();
|
||||
let names: Vec<String> = (0..40)
|
||||
.map(|i| format!("minecraft:block/b{}", (i * 7) % 40))
|
||||
.chain(["minecraft:block/same", "minecraft:block/broken"].map(String::from))
|
||||
.chain(["minecraft:block/missing", "minecraft:block/b3"].map(String::from))
|
||||
.collect();
|
||||
let tile = "assets/minecraft/optifine/ctm/glass/0.png";
|
||||
let load = |prefetch: bool| {
|
||||
let mut sprites = Sprites::default();
|
||||
if prefetch {
|
||||
sprites.prefetch(&root, names.iter().map(String::as_str).chain([tile]));
|
||||
}
|
||||
let mut layers: Vec<u32> = names
|
||||
.iter()
|
||||
.map(|name| sprites.insert(&root, name).unwrap())
|
||||
.collect();
|
||||
layers.push(sprites.insert_file(&root, tile).unwrap());
|
||||
sprites.finish();
|
||||
(
|
||||
layers,
|
||||
sprites.layers,
|
||||
sprites.pixels,
|
||||
sprites.translucent,
|
||||
sprites.size,
|
||||
)
|
||||
};
|
||||
let alone = load(false);
|
||||
assert_eq!(load(true), alone);
|
||||
// The textures alike and the broken one's missing texture: fewer layers than names.
|
||||
assert!(alone.1.values().max().unwrap() + 1 < names.len() as u32);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn identical_still_textures_share_a_layer() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id()));
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use crate::section_mask::SectionMask;
|
||||
use crate::{
|
||||
chunk::Chunk,
|
||||
graphics::LeafCullingMode,
|
||||
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
|
||||
world::World,
|
||||
@@ -197,7 +196,7 @@ impl<G: ChunkGenerator> Saves for G {
|
||||
}
|
||||
}
|
||||
/// Per-job mesher inputs that change with the dimension or the options.
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct MeshSettings {
|
||||
nether: bool,
|
||||
shader_pack_lighting: bool,
|
||||
@@ -205,8 +204,6 @@ struct MeshSettings {
|
||||
blend_radius: i32,
|
||||
smooth_lighting: bool,
|
||||
cutout_leaves: bool,
|
||||
translucent_layers: Arc<[bool]>,
|
||||
leaf_culling: LeafCullingMode,
|
||||
}
|
||||
enum Job {
|
||||
Generate(i32, i32),
|
||||
@@ -441,11 +438,6 @@ pub struct Streaming {
|
||||
/// The `ao` and `cutoutLeaves` options.
|
||||
pub smooth_lighting: bool,
|
||||
pub cutout_leaves: bool,
|
||||
/// Sprite layers a shader pack draws in the translucent pass (`[[materials]]
|
||||
/// translucent`), by layer; empty without one.
|
||||
pub translucent_layers: Arc<[bool]>,
|
||||
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
|
||||
pub leaf_culling: LeafCullingMode,
|
||||
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
|
||||
light_tables: Arc<LightTables>,
|
||||
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
|
||||
@@ -780,8 +772,6 @@ impl Streaming {
|
||||
blend_radius: settings.blend_radius,
|
||||
smooth_lighting: settings.smooth_lighting,
|
||||
cutout_leaves: settings.cutout_leaves,
|
||||
translucent_layers: &settings.translucent_layers,
|
||||
leaf_culling: settings.leaf_culling,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
@@ -863,8 +853,6 @@ impl Streaming {
|
||||
biome_blend: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: Arc::from([]),
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
light_tables,
|
||||
light_log: Vec::new(),
|
||||
light_edit_sequence: 0,
|
||||
@@ -916,56 +904,9 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A resident column a server sent again whole (it changed it in bulk):
|
||||
/// its new blocks taken, the sections that did not change kept as they
|
||||
/// were, and its light and its neighbours' worked out again here, off
|
||||
/// the game thread, as light is for the blocks a server changes one at a
|
||||
/// time; the sections that changed remesh (with theirs) once lit. Until
|
||||
/// then the sections that changed show the light the packet carried (a
|
||||
/// Horizon server sends the light from before its relight, then the
|
||||
/// new light; another server's is already right, which matters where
|
||||
/// the neighbours this light needs aren't all here).
|
||||
pub fn replace_remote(&mut self, world: &mut World, mut chunk: Chunk) {
|
||||
let key = (chunk.x, chunk.z);
|
||||
let Some(old) = world.chunks.get(&key).cloned() else {
|
||||
return self.insert_remote(world, chunk);
|
||||
};
|
||||
if !self.lit.contains(&key) {
|
||||
return self.insert_remote(world, chunk);
|
||||
}
|
||||
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
|
||||
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
|
||||
// section often changes in a bulk edit, is mostly sections that did not).
|
||||
let mut cells = crate::light::LightChanges::default();
|
||||
cells.keyed(world.layout());
|
||||
let kept = keep_unchanged_sections(&old, &mut chunk, &mut cells);
|
||||
chunk.light_loaded = old.light_loaded;
|
||||
chunk.lit = true;
|
||||
world.chunks.insert(key, Arc::new(chunk));
|
||||
world.revision = world.revision.wrapping_add(1);
|
||||
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
|
||||
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
|
||||
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
|
||||
Self::light_updated(world, key.0, key.1, &kept.biomes);
|
||||
// A section with the same blocks and new light takes the packet's light (vanilla's
|
||||
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
|
||||
Self::light_updated(world, key.0, key.1, &kept.light);
|
||||
if !kept.blocks {
|
||||
return;
|
||||
}
|
||||
for dz in -1..=1 {
|
||||
for dx in -1..=1 {
|
||||
world.dirty_light.insert((key.0 + dx, key.1 + dz));
|
||||
}
|
||||
}
|
||||
cells.dirty_sections(|section| {
|
||||
world.dirty_sections.insert(section);
|
||||
});
|
||||
}
|
||||
/// A server's light update for a resident column: the sections whose
|
||||
/// light changed and their neighbours remesh
|
||||
/// (`setSectionDirtyWithNeighbors`). A resent column's sections with new
|
||||
/// biomes remesh the same way.
|
||||
/// (`setSectionDirtyWithNeighbors`).
|
||||
pub fn light_updated(world: &mut World, x: i32, z: i32, sections: &[usize]) {
|
||||
for &sy in sections {
|
||||
for dz in -1..=1 {
|
||||
@@ -1771,8 +1712,6 @@ impl Streaming {
|
||||
blend_radius: self.biome_blend,
|
||||
smooth_lighting: self.smooth_lighting,
|
||||
cutout_leaves: self.cutout_leaves,
|
||||
translucent_layers: self.translucent_layers.clone(),
|
||||
leaf_culling: self.leaf_culling,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -2424,136 +2363,10 @@ fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
|
||||
}
|
||||
candidates.sort_unstable();
|
||||
}
|
||||
/// What `keep_unchanged_sections` found changed.
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Resent {
|
||||
/// Any section's blocks changed (in a way light or the mesher sees).
|
||||
blocks: bool,
|
||||
/// The sections whose blocks did not change but whose biomes did.
|
||||
biomes: Vec<usize>,
|
||||
/// The sections whose blocks did not change but whose light the packet changes.
|
||||
light: Vec<usize>,
|
||||
}
|
||||
/// `new`, a resident column `old` sent again, takes `old`'s sections whose
|
||||
/// blocks it holds unchanged, with `new`'s biomes and light; the sections whose blocks
|
||||
/// changed keep `old`'s light (until lit again) and their cells' classes go in
|
||||
/// `cells`.
|
||||
fn keep_unchanged_sections(
|
||||
old: &Chunk,
|
||||
new: &mut Chunk,
|
||||
cells: &mut crate::light::LightChanges,
|
||||
) -> Resent {
|
||||
let (mut a, mut b) = (Box::new([0u32; 4096]), Box::new([0u32; 4096]));
|
||||
let mut out = Resent::default();
|
||||
let (x, z) = (new.x, new.z);
|
||||
for s in 0..new.sections.len().min(old.sections.len()) {
|
||||
// (sections stored alike hold the same blocks: most of a column sent again, found without unpacking)
|
||||
let same = old.sections[s].blocks.same_storage(&new.sections[s].blocks) || {
|
||||
old.sections[s].unpack(&mut a);
|
||||
new.sections[s].unpack(&mut b);
|
||||
let classes = crate::light::LightChanges::classes_differing(&a, &b);
|
||||
if classes != 0 {
|
||||
cells.add((x, s as i32, z), classes);
|
||||
}
|
||||
classes == 0
|
||||
};
|
||||
if !same {
|
||||
out.blocks = true;
|
||||
continue;
|
||||
}
|
||||
let biomes = new.sections[s].biomes.clone();
|
||||
let changed = !same_values(&old.sections[s].biomes, &biomes);
|
||||
let light = (!same_light(&old.sections[s].light, &new.sections[s].light))
|
||||
.then(|| new.sections[s].light.clone());
|
||||
new.sections[s] = old.sections[s].clone();
|
||||
if changed {
|
||||
Arc::make_mut(&mut new.sections[s]).biomes = biomes;
|
||||
out.biomes.push(s);
|
||||
}
|
||||
if let Some(light) = light {
|
||||
Arc::make_mut(&mut new.sections[s]).light = light;
|
||||
out.light.push(s);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Whether two sections' packed light (4,096 cells) holds the same values.
|
||||
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
|
||||
use crate::chunk::Light;
|
||||
match (a, b) {
|
||||
(Light::Uniform(x), Light::Uniform(y)) => x == y,
|
||||
(Light::Full(x), Light::Full(y)) => x == y,
|
||||
(Light::Uniform(v), Light::Full(f)) | (Light::Full(f), Light::Uniform(v)) => {
|
||||
f.iter().all(|c| c == v)
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether two biome containers (64 cells) hold the same values.
|
||||
fn same_values(
|
||||
a: &horizon_core::paletted::PalettedContainer,
|
||||
b: &horizon_core::paletted::PalettedContainer,
|
||||
) -> bool {
|
||||
if a.same_storage(b) {
|
||||
return true;
|
||||
}
|
||||
let (mut x, mut y) = ([0u32; 64], [0u32; 64]);
|
||||
a.unpack(&mut x);
|
||||
b.unpack(&mut y);
|
||||
x == y
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_resent_column_keeps_its_sections_but_takes_new_biomes() {
|
||||
let mut old = Chunk::empty(2, -3);
|
||||
old.set(1, 10, 1, 1);
|
||||
old.set(1, 40, 1, 1);
|
||||
old.set_biome_cell(0, 0, 0, 1);
|
||||
let mut new = old.clone();
|
||||
// a new biome under the same blocks (section 0), and a block changed (section 6)
|
||||
new.set_biome_cell(0, 0, 0, 3);
|
||||
new.set(1, 40, 1, 2);
|
||||
let mut cells = crate::light::LightChanges::default();
|
||||
cells.keyed(crate::chunk::ColumnLayout::VANILLA);
|
||||
let kept = keep_unchanged_sections(&old, &mut new, &mut cells);
|
||||
assert_eq!(
|
||||
kept,
|
||||
Resent {
|
||||
blocks: true,
|
||||
biomes: vec![0],
|
||||
light: vec![],
|
||||
}
|
||||
);
|
||||
assert_eq!(new.biome(0, -64, 0), 3);
|
||||
assert_eq!(new.block(1, -54, 1).0, 0);
|
||||
assert_eq!(new.block(1, 40, 1).0, 2);
|
||||
// the sections alike are shared with the old column
|
||||
assert!(Arc::ptr_eq(&new.sections[1], &old.sections[1]));
|
||||
assert!(!Arc::ptr_eq(&new.sections[0], &old.sections[0]));
|
||||
// nothing changed: nothing to do
|
||||
let mut same = old.clone();
|
||||
let kept = keep_unchanged_sections(&old, &mut same, &mut cells);
|
||||
assert_eq!(kept, Resent::default());
|
||||
assert!(Arc::ptr_eq(&same.sections[0], &old.sections[0]));
|
||||
// the same blocks under new light: the packet's light, as vanilla applies it
|
||||
let mut lit = old.clone();
|
||||
Arc::make_mut(&mut lit.sections[2]).light = crate::chunk::Light::Uniform(0xF0);
|
||||
Arc::make_mut(&mut old.sections[2]).light = crate::chunk::Light::Uniform(0x50);
|
||||
let kept = keep_unchanged_sections(&old, &mut lit, &mut cells);
|
||||
assert_eq!(
|
||||
kept,
|
||||
Resent {
|
||||
light: vec![2],
|
||||
..Resent::default()
|
||||
}
|
||||
);
|
||||
assert_eq!(lit.sections[2].light.get(0), 0xF0);
|
||||
assert!(Arc::ptr_eq(&lit.sections[1], &old.sections[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_pool_reuses_capacity_with_clean_geometry_state() {
|
||||
let mut mesh = SectionMesh::default();
|
||||
|
||||
@@ -591,11 +591,6 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
|
||||
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
|
||||
}
|
||||
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
|
||||
// far plane, written over the whole depth target with no colour.
|
||||
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
return vec4(0.0);
|
||||
}
|
||||
@fragment fn fs_dark_disc(in: SkyOut) -> @location(0) vec4<f32> {
|
||||
let d = view_ray(in.ndc);
|
||||
let dist = disc_distance(d, -4.0);
|
||||
|
||||
@@ -100,9 +100,6 @@ pub struct World {
|
||||
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
|
||||
/// the mobs of this world tick (`game/creatures.rs`).
|
||||
pub path_writes: Option<Vec<((i32, i32, i32), u32, u32)>>,
|
||||
/// The chunks changed in bulk (`edit_runs`) since the log was last
|
||||
/// drained, kept while `block_log` is: sent to the players whole.
|
||||
pub bulk_log: FxHashSet<(i32, i32)>,
|
||||
/// The sections whose light changed since the log was last drained, as
|
||||
/// a bit per section of each column, kept while `block_log` is
|
||||
/// (`ChunkHolder.sectionLightChanged`).
|
||||
@@ -211,7 +208,6 @@ impl World {
|
||||
weather: Weather::default(),
|
||||
block_log: None,
|
||||
path_writes: None,
|
||||
bulk_log: Default::default(),
|
||||
light_log: None,
|
||||
moving_piston_fallback: Vec::new(),
|
||||
moving_pistons: Vec::new(),
|
||||
|
||||
@@ -19,8 +19,6 @@ horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# OptiFine-format pack files: custom entity models, random entity textures.
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
{
|
||||
"horizon.options.controller": "Controller",
|
||||
"horizon.options.vibration": "Vibration",
|
||||
"horizon.options.adaptive_triggers": "Adaptive Trigger",
|
||||
"horizon.options.lightbar": "Lightbar",
|
||||
"horizon.options.look_sensitivity": "Blickempfindlichkeit",
|
||||
"horizon.options.invert_x": "Blick-X-Achse umkehren",
|
||||
"horizon.options.invert_y": "Blick-Y-Achse umkehren",
|
||||
"horizon.options.sneak_button": "Schleichtaste",
|
||||
"horizon.options.move_deadzone": "Totzone der Bewegung",
|
||||
"horizon.options.look_deadzone": "Totzone des Blicks",
|
||||
"horizon.options.button_glyphs": "Tastensymbole",
|
||||
"horizon.options.menu_navigation": "Menünavigation",
|
||||
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
|
||||
"horizon.options.chat_delay": "Chatverzögerung",
|
||||
"horizon.options.lightbar.health": "Gesundheit",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Zeiger",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"horizon.options.search": "Einstellungen durchsuchen …",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Shader-Pakete",
|
||||
"horizon.pages.shader_options": "Shader-Optionen",
|
||||
"horizon.pages.controller": "Controller-Einstellungen",
|
||||
"horizon.pages.controller_binds": "Controller-Tastenbelegung",
|
||||
"horizon.pages.extras": "Horizon-Extras",
|
||||
"horizon.pages.settings": "Horizon-Einstellungen",
|
||||
"horizon.pages.agent": "KI-Agent",
|
||||
"horizon.pages.split_profiles": "Wer spielt?",
|
||||
"horizon.pages.settings.tooltip": "Horizons eigene Einstellungen: Grafik, Controller, Extras und Mods.",
|
||||
"horizon.pages.graphics.tooltip": "Grafik, einfach: Jede Einstellung zeigt, was sie kostet.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Alle Grafikeinstellungen, auch die von Horizon, mit den Kosten jeder einzelnen.",
|
||||
"horizon.pages.controller.tooltip": "Controller: ihre Tasten, Sticks, Vibration und Menünavigation.",
|
||||
"horizon.pages.extras.tooltip": "Optionale Zusätze, alle standardmäßig aus: Das Spiel bleibt Vanilla, bis du einen einschaltest.",
|
||||
"horizon.pages.agent.tooltip": "Lass deinen eigenen KI-Agenten spielen: horizon-ctl und MCP-Clients auf diesem Computer.",
|
||||
"horizon.pages.split_profiles.tooltip": "Wer spielt: ein Profil, das in diesem Spielordner gespeichert ist.",
|
||||
"horizon.pages.shader_packs.tooltip": "Shader-Pakete aus dem Ordner shaderpacks: die von Horizon selbst und die im Format shaders/. Es läuft immer nur eines.",
|
||||
"horizon.pages.mods.tooltip": "Mods für Horizon aus dem Ordner horizon-mods. Jede läuft in einer Sandbox und bleibt aus, bis du sie einschaltest.",
|
||||
"horizon.pages.open.graphics": "Grafik …",
|
||||
"horizon.pages.open.graphics_advanced": "Erweiterte Grafik …",
|
||||
"horizon.pages.open.shader_packs": "Shader-Pakete …",
|
||||
"horizon.pages.open.controller": "Controller-Einstellungen …",
|
||||
"horizon.pages.open.extras": "Extras …",
|
||||
"horizon.pages.open.mods": "Mods …",
|
||||
"horizon.pages.open.agent": "KI-Agent …",
|
||||
"horizon.pages.open.horizon_graphics": "Horizon-Grafik …",
|
||||
"horizon.pages.open.controller_binds": "Controller-Belegung …",
|
||||
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
|
||||
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
|
||||
"horizon.mods.stopped": " (angehalten)",
|
||||
"horizon.mods.not_built": " (nicht gebaut)",
|
||||
"horizon.mods.dev": " (Entwicklung)",
|
||||
"horizon.mods.settings": "Einstellungen",
|
||||
"horizon.mods.open_folder": "Modordner öffnen",
|
||||
"horizon.shader_packs.empty": "Keine Shader-Pakete: Lege sie in den Ordner shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Shader-Optionen …",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Optionen des Shader-Pakets werden gelesen …",
|
||||
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
|
||||
"horizon.multiplayer.join": "Beitreten",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Neuverbindung abbrechen",
|
||||
"horizon.multiplayer.join_friend": "Freund beitreten",
|
||||
"horizon.multiplayer.invite_code": "Einladungscode",
|
||||
"horizon.multiplayer.relay": "Relay (optional)",
|
||||
"Preset": "Preset",
|
||||
"LOD Distance": "LOD-Weite",
|
||||
"Far Detail": "Ferndetails",
|
||||
"Cave Culling": "Höhlen-Culling",
|
||||
"Entity Culling": "Objekt-Culling",
|
||||
"Chunk Updates": "Chunk-Updates",
|
||||
"Upload Budget": "Upload-Budget",
|
||||
"Sky": "Himmel",
|
||||
"Fog": "Nebel",
|
||||
"Weather": "Wetter",
|
||||
"Animated Textures": "Animierte Texturen",
|
||||
"Present Mode": "Ausgabemodus",
|
||||
"Frame Pacing": "Bildtakt",
|
||||
"Worker Threads": "Arbeitsthreads",
|
||||
"Resolution Scale": "Auflösungsskalierung",
|
||||
"Anti-aliasing": "Kantenglättung",
|
||||
"Upscaling": "Upscaling",
|
||||
"HDR Settings...": "HDR-Einstellungen …",
|
||||
"Paper White": "Papierweiß",
|
||||
"Peak Brightness": "Spitzenhelligkeit",
|
||||
"Black Level": "Schwarzwert",
|
||||
"UI Brightness": "GUI-Helligkeit",
|
||||
"Battery Mode": "Akkumodus",
|
||||
"Graphics API": "Grafik-API",
|
||||
"Performance Overlay": "Leistungsanzeige",
|
||||
"Full Detail Distance": "Detailweite",
|
||||
"On": "An",
|
||||
"Off": "Aus",
|
||||
"Unlimited": "Unbegrenzt",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Benutzerdefiniert",
|
||||
"Performance": "Leistung",
|
||||
"Balanced": "Ausgewogen",
|
||||
"Quality": "Qualität",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra-Leistung",
|
||||
"Native": "Nativ",
|
||||
"Full": "Voll",
|
||||
"Reduced": "Reduziert",
|
||||
"Efficient": "Sparsam",
|
||||
"Precise": "Präzise",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS und Diagramm",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Keine Kosten",
|
||||
"Low cost": "Geringe Kosten",
|
||||
"Medium cost": "Mittlere Kosten",
|
||||
"High cost": "Hohe Kosten",
|
||||
"Saves time": "Spart Zeit",
|
||||
"Rendering": "Rendering",
|
||||
"Display": "Anzeige",
|
||||
"Preferences": "Präferenzen",
|
||||
"Overlay": "Einblendung",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Grafik",
|
||||
"Graphics: Advanced": "Grafik: Erweitert",
|
||||
"Graphics: HDR": "Grafik: HDR",
|
||||
"Advanced...": "Erweitert …",
|
||||
"Simple...": "Einfach …",
|
||||
"Done": "Fertig",
|
||||
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
|
||||
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
|
||||
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
|
||||
"%s chunks": "%s Chunks",
|
||||
"%s per frame": "%s pro Bild",
|
||||
"%s MiB/frame": "%s MiB/Bild",
|
||||
"%s KiB/frame": "%s KiB/Bild",
|
||||
"%s nits": "%s Nits",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s Nits)",
|
||||
"%.2f nits": "%.2f Nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gelände mit vollen Details. Die LOD-Entfernung reicht weiter.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduziert: fernes Gelände gröber, für GPUs, die sonst bremsen.",
|
||||
"Field of view. Wider shows more terrain at once.": "Sichtfeld. Ein größeres zeigt mehr Gelände auf einmal.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Begrenzt die Bilder pro Sekunde; spart Strom und Wärme.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Wartet auf den Bildaufbau: kein Tearing, etwas Latenz.",
|
||||
"Size of menus and the HUD.": "Größe der Menüs und des HUDs.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Stellt Optionen darunter ein. Änderung = „Benutzerdefiniert“.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, Feuer, Sonne heller als Weiß. Braucht HDR-Bildschirm.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Das dunkelste Schwarz deines Bildschirms. 0 für OLED; LCDs leuchten ein wenig.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Wie hell Lichter werden. Auto: der Spitzenwert, den dein System meldet.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Wie hell weißes Papier und Menüs aussehen. Auto: das Weiß deines Desktops.",
|
||||
"How bright the HUD and menus are, against paper white.": "Wie hell HUD und Menüs im Vergleich zu Papierweiß sind.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Mischt Gras- und Wasserfarben über Biome hinweg beim Chunk-Aufbau.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Wolkenschicht: aus, flach (schnell) oder 3D (schön).",
|
||||
"How far clouds reach, in chunks.": "Wie weit Wolken reichen, in Chunks.",
|
||||
"Draws the sky, sun, moon and stars.": "Zeichnet Himmel, Sonne, Mond und Sterne.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Nebel in der Ferne. Aus zeigt den Rand der geladenen Welt.",
|
||||
"Draws rain and snow.": "Zeichnet Regen und Schnee.",
|
||||
"How far rain and snow fall around you.": "Wie weit um dich herum Regen und Schnee fallen.",
|
||||
"How many particles effects spawn.": "Wie viele Partikel Effekte erzeugen.",
|
||||
"Round shadows under mobs and items.": "Runde Schatten unter Kreaturen und Gegenständen.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Laub mit durchsichtigen Lücken. Aus zeichnet es massiv.",
|
||||
"Sorts water and glass more exactly behind each other.": "Sortiert Wasser und Glas hintereinander genauer.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Welche Chunk-Neuaufbauten auf das Bild warten: keine, nahe oder deine.",
|
||||
"Animates water, lava, fire and other moving textures.": "Animiert Wasser, Lava, Feuer und andere bewegte Texturen.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
|
||||
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
|
||||
"Fills the screen (borderless).": "Füllt den Bildschirm (randlos).",
|
||||
"Brightens dark places.": "Hellt dunkle Orte auf.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Senkt die Bildrate bei Minimierung oder auch bei Inaktivität.",
|
||||
"Sways the view while walking.": "Lässt die Ansicht beim Gehen schwanken.",
|
||||
"Fades newly loaded chunks in.": "Blendet neu geladene Chunks ein.",
|
||||
"Darkens the screen's edges.": "Dunkelt die Bildschirmränder ab.",
|
||||
"Shows when the world saves.": "Zeigt an, wann die Welt gespeichert wird.",
|
||||
"Shows attack cooldown.": "Zeigt die Angriffsabklingzeit an.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
|
||||
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
|
||||
"horizon.loading.opening_world": "Welt wird geöffnet",
|
||||
"horizon.loading.cancelling_world": "Wird abgebrochen",
|
||||
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
|
||||
"horizon.extras.header.lighting": "Beleuchtung",
|
||||
"horizon.extras.header.third_person": "Dritte Person",
|
||||
"horizon.extras.header.inventory": "Inventar",
|
||||
"horizon.extras.header.multiplayer": "Mehrspieler",
|
||||
"horizon.extras.fullbright": "Maximale Helligkeit",
|
||||
"horizon.extras.dynamic_lights": "Dynamisches Licht",
|
||||
"horizon.extras.own_name_tag": "Eigenes Namensschild",
|
||||
"horizon.extras.auto_reconnect": "Neu verbinden",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Mausrad-Verschieben",
|
||||
"horizon.extras.sort": "Sortier-Schaltflächen",
|
||||
"horizon.extras.sort_key": "Sortiertaste",
|
||||
"horizon.extras.no_ai": "Keine KI",
|
||||
"horizon.extras.agent_control": "Agentensteuerung",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Zeigt Blöcke und Objekte bei vollem Licht, selbst an dunklen Orten.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Fackeln und andere Lichtquellen, die gehalten, fallen gelassen werden oder brennen, beleuchten die Blöcke um sich herum.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Zeigt deinen Namen in der dritten Person über deinem Kopf an, so wie andere Spieler ihn sehen. Versteckt, wenn der Server die Namen deines Teams verbirgt oder du unsichtbar bist.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Verbindet nach einem Verbindungsabbruch oder Neustart erneut mit dem Server, nie nach einem Rauswurf oder Bann.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Mit Rechtsklick ziehen legt je einen pro Feld ab, mit Linksklick ziehen sammelt den gehaltenen Gegenstand, Umschalt + Linksklick ziehen verschiebt jedes Feld.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Nach unten schickt einen Gegenstand des Feldes auf die andere Seite, nach oben holt einen zurück.",
|
||||
"horizon.extras.sort.tooltip": "Sortiert Inventar, Truhen, Fässer, Endertruhen und Shulker-Kisten in der Reihenfolge der Kreativ-Tabs und fasst Stapel zusammen.",
|
||||
"horizon.extras.sort_key.tooltip": "Sortiert den Bereich unter dem Mauszeiger. Klicke, dann drücke eine Taste; Esc hebt die Belegung auf.",
|
||||
"horizon.extras.no_ai.tooltip": "Blendet alle KI-Funktionen aus und schaltet sie ab: Agentensteuerung, horizon-ctl und die MCP-Server. Auch den Schalter des Launchers.",
|
||||
"horizon.extras.agent_control.tooltip": "Dein eigener KI-Agent kann dieses Spiel über horizon-ctl oder einen MCP-Client spielen, nur auf diesem Computer.",
|
||||
"horizon.extras.discord_presence.tooltip": "Zeigt „Spielt Horizon“ in deinem Discord-Profil an, solange Discord läuft: Menüs, Einzel- oder Mehrspielermodus, die Dimension und die Spielzeit. Nie den Namen einer Welt oder die Adresse eines Servers.",
|
||||
"Calibrate...": "Kalibrieren …",
|
||||
"Calibrate Anyway...": "Trotzdem kalibrieren …",
|
||||
"Use Automatic Setup": "Automatische Einrichtung verwenden",
|
||||
"Shortcut: %s": "Tastenkürzel: %s",
|
||||
"Next": "Weiter",
|
||||
"HDR Calibration %s/%s: %s": "HDR-Kalibrierung %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Stellt die drei Werte darunter im Vollbild ein, jeweils mit einem Testbild.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Eine Taste, die diesen Bildschirm im Spiel öffnet. Zunächst nicht belegt.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Bildschirm meldete keine Helligkeit: zum Einrichten kalibrieren.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Bereits eingerichtet. Nur kalibrieren, wenn HDR falsch aussieht.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Stellt Spitzenhelligkeit und Papierweiß des Bildschirms wieder her.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Für den Bildschirm eingerichtet. Auto lassen, wenn es passt.",
|
||||
"Lower it until the logo just disappears into the black.": "Verringere den Wert, bis das Logo gerade im Schwarz verschwindet.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Erhöhe den Wert, bis das Logo gerade im hellen Quadrat verschwindet.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Wähle ein Weiß, das bei deinem Licht lange angenehm ist.",
|
||||
"horizon.splitscreen.profiles.forget": "Entfernen",
|
||||
"horizon.splitscreen.profiles.create": "Erstellen",
|
||||
"horizon.splitscreen.profiles.you": " - du",
|
||||
"horizon.splitscreen.profiles.forget_named": "%s entfernen",
|
||||
"horizon.splitscreen.profiles.previous": "Zurück",
|
||||
"horizon.splitscreen.profiles.next": "Weiter",
|
||||
"horizon.splitscreen.profiles.new_button": "Neues Profil …",
|
||||
"horizon.splitscreen.profiles.forget_title": "Profil entfernen?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Es verschwindet aus der Liste dieser Instanz.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Weltfortschritt und Einstellungen bleiben erhalten.",
|
||||
"horizon.splitscreen.profiles.new_title": "Neues Profil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Name (3 bis 16 Buchstaben, Ziffern, _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Profil wechseln",
|
||||
"horizon.splitscreen.profiles.offline_note": "Offline-Profile gehören zu dieser Instanz",
|
||||
"horizon.splitscreen.profiles.move": "Spielort wechseln",
|
||||
"horizon.splitscreen.profiles.move_place": "Spielort wechseln: %s",
|
||||
"horizon.friends.title": "Mit Freunden spielen",
|
||||
"horizon.friends.hint": "Freunde fügen den Code nach dem Freunde-Knopf des Titelbildschirms ein, bei „Direktverbindung“ oder auf der Spielen-Seite des Launchers.",
|
||||
"horizon.friends.copied": "Kopiert",
|
||||
"horizon.friends.copy_code": "Code kopieren",
|
||||
"horizon.friends.stop_sharing": "Teilen beenden",
|
||||
"horizon.friends.none_yet": "Noch keine Freunde hier.",
|
||||
"horizon.friends.here_now": "Gerade hier: %s",
|
||||
"horizon.friends.share_intro": "Teile diese Welt mit einem Einladungscode: Sie wird im LAN geöffnet, und Freunde im Internet treten mit dem Code bei. Es ist kein Server von uns beteiligt.",
|
||||
"horizon.friends.start_sharing": "Teilen starten",
|
||||
"horizon.friends.troubleshooting": "Fehlerbehebung …",
|
||||
"horizon.friends.stopped": "Das Teilen des Einladungscodes wurde beendet.",
|
||||
"horizon.friends.short_code_changed": "Jemand hat immer wieder einen falschen Kurzcode versucht, deshalb wurde er geändert.",
|
||||
"horizon.friends.wrong_code": "Jemand hat einen falschen Code versucht.",
|
||||
"horizon.friends.code_ready": "Dein Einladungscode ist bereit: Spielmenü, Mit Freunden spielen. Kopiere ihn und schicke ihn an deine Freunde.",
|
||||
"horizon.friends.share_failed": "Die Welt konnte nicht geteilt werden: %s",
|
||||
"horizon.friends.finding_route": "Es wird ermittelt, wie Freunde dich erreichen können …",
|
||||
"horizon.osk.shift": "Umschalt",
|
||||
"horizon.osk.space": "Leertaste",
|
||||
"horizon.osk.delete": "Löschen",
|
||||
"horizon.osk.type": "Eingeben",
|
||||
"horizon.osk.symbols": "Symbole",
|
||||
"horizon.osk.close": "Schließen",
|
||||
"horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.",
|
||||
"horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
|
||||
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …"
|
||||
}
|
||||
@@ -213,98 +213,5 @@
|
||||
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
|
||||
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
|
||||
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
|
||||
"horizon.extras.header.lighting": "Lighting",
|
||||
"horizon.extras.header.third_person": "Third person",
|
||||
"horizon.extras.header.particles": "Particles",
|
||||
"horizon.extras.header.inventory": "Inventory",
|
||||
"horizon.extras.header.chat": "Chat",
|
||||
"horizon.extras.header.multiplayer": "Multiplayer",
|
||||
"horizon.extras.header.sound": "Sound",
|
||||
"horizon.extras.fullbright": "Fullbright",
|
||||
"horizon.extras.dynamic_lights": "Dynamic lights",
|
||||
"horizon.extras.own_name_tag": "Own name tag",
|
||||
"horizon.extras.hide_block_particles": "Hide block-break particles",
|
||||
"horizon.extras.auto_reconnect": "Auto-reconnect",
|
||||
"horizon.extras.ui_sound_reserve": "Keep channels for UI sounds",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks controls",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Scroll wheel moves items",
|
||||
"horizon.extras.sort": "Sort buttons",
|
||||
"horizon.extras.sort_key": "Sort key",
|
||||
"horizon.extras.no_ai": "No AI",
|
||||
"horizon.extras.agent_control": "Agent Control",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.splitscreen": "Splitscreen",
|
||||
"horizon.extras.chat_animation": "Smooth chat",
|
||||
"horizon.extras.chat_indicator": "Message indicator",
|
||||
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
|
||||
"horizon.extras.hide_block_particles.tooltip": "Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Rejoins a server after a lost connection or a restart, never after a kick or ban.",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Down sends one item of the slot to the other side, up brings one back.",
|
||||
"horizon.extras.sort.tooltip": "Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks.",
|
||||
"horizon.extras.sort_key.tooltip": "Sorts the part under the cursor. Click, then press a key; Escape unbinds it.",
|
||||
"horizon.extras.no_ai.tooltip": "Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too.",
|
||||
"horizon.extras.agent_control.tooltip": "Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only.",
|
||||
"horizon.extras.discord_presence.tooltip": "Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address.",
|
||||
"Calibrate...": "Calibrate...",
|
||||
"Calibrate Anyway...": "Calibrate Anyway...",
|
||||
"Use Automatic Setup": "Use Automatic Setup",
|
||||
"Shortcut: %s": "Shortcut: %s",
|
||||
"Next": "Next",
|
||||
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "A key that opens this screen from the game. Not bound at first.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
|
||||
"Lower it until the logo just disappears into the black.": "Lower it until the logo just disappears into the black.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Raise it until the logo just disappears into the bright square.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Make the card a white that's comfortable to look at for a long time, in the light you play in.",
|
||||
"horizon.splitscreen.profiles.forget": "Forget",
|
||||
"horizon.splitscreen.profiles.create": "Create",
|
||||
"horizon.splitscreen.profiles.you": " - you",
|
||||
"horizon.splitscreen.profiles.forget_named": "Forget %s",
|
||||
"horizon.splitscreen.profiles.previous": "Previous",
|
||||
"horizon.splitscreen.profiles.next": "Next",
|
||||
"horizon.splitscreen.profiles.new_button": "New Profile...",
|
||||
"horizon.splitscreen.profiles.forget_title": "Forget profile?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "It leaves this instance's list.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "World progress and settings are kept.",
|
||||
"horizon.splitscreen.profiles.new_title": "New Profile",
|
||||
"horizon.splitscreen.profiles.name_hint": "Name (3 to 16 letters, digits, _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Switch profile",
|
||||
"horizon.splitscreen.profiles.offline_note": "Offline profiles belong to this instance",
|
||||
"horizon.splitscreen.profiles.move": "Move where you play",
|
||||
"horizon.splitscreen.profiles.move_place": "Move where you play: %s",
|
||||
"horizon.friends.title": "Play with Friends",
|
||||
"horizon.friends.hint": "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
"horizon.friends.copied": "Copied",
|
||||
"horizon.friends.copy_code": "Copy Code",
|
||||
"horizon.friends.stop_sharing": "Stop Sharing",
|
||||
"horizon.friends.none_yet": "No friends here yet.",
|
||||
"horizon.friends.here_now": "Here now: %s",
|
||||
"horizon.friends.share_intro": "Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
"horizon.friends.start_sharing": "Start Sharing",
|
||||
"horizon.friends.troubleshooting": "Troubleshooting...",
|
||||
"horizon.friends.stopped": "Stopped sharing the invite code.",
|
||||
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
|
||||
"horizon.friends.wrong_code": "Someone tried a wrong code.",
|
||||
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
"horizon.friends.share_failed": "Couldn't share the world: %s",
|
||||
"horizon.friends.finding_route": "Finding how friends can reach you…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Space",
|
||||
"horizon.osk.delete": "Delete",
|
||||
"horizon.osk.type": "Type",
|
||||
"horizon.osk.symbols": "Symbols",
|
||||
"horizon.osk.close": "Close",
|
||||
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
|
||||
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
|
||||
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
|
||||
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
|
||||
}
|
||||
@@ -203,11 +203,5 @@
|
||||
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
|
||||
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…",
|
||||
"horizon.pages.shader_options": "Opciones de sombreadores",
|
||||
"horizon.pages.split_profiles": "¿Quién juega?",
|
||||
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
|
||||
"horizon.shader_options.open": "Opciones de sombreadores…",
|
||||
"horizon.shader_options.profile": "Perfil",
|
||||
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
|
||||
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
{
|
||||
"horizon.options.controller": "Manette",
|
||||
"horizon.options.vibration": "Vibrations",
|
||||
"horizon.options.adaptive_triggers": "Gâchettes adaptatives",
|
||||
"horizon.options.lightbar": "Barre lumineuse",
|
||||
"horizon.options.look_sensitivity": "Sensibilité de la vue",
|
||||
"horizon.options.invert_x": "Inverser l'axe X de la vue",
|
||||
"horizon.options.invert_y": "Inverser l'axe Y de la vue",
|
||||
"horizon.options.sneak_button": "Bouton pour s'accroupir",
|
||||
"horizon.options.move_deadzone": "Zone morte du déplacement",
|
||||
"horizon.options.look_deadzone": "Zone morte de la vue",
|
||||
"horizon.options.button_glyphs": "Symboles des boutons",
|
||||
"horizon.options.menu_navigation": "Navigation dans les menus",
|
||||
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
|
||||
"horizon.options.chat_delay": "Délai du tchat",
|
||||
"horizon.options.lightbar.health": "Santé",
|
||||
"horizon.options.lightbar.dimension": "Dimension",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Focus",
|
||||
"horizon.options.menu_pointer": "Pointeur",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"horizon.options.search": "Rechercher une option...",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Packs de shaders",
|
||||
"horizon.pages.shader_options": "Options des shaders",
|
||||
"horizon.pages.controller": "Options de la manette",
|
||||
"horizon.pages.controller_binds": "Assignation des boutons de la manette",
|
||||
"horizon.pages.extras": "Extras Horizon",
|
||||
"horizon.pages.settings": "Options Horizon",
|
||||
"horizon.pages.agent": "Agent IA",
|
||||
"horizon.pages.split_profiles": "Qui joue ?",
|
||||
"horizon.pages.settings.tooltip": "Les options propres à Horizon : graphismes, manettes, extras et mods.",
|
||||
"horizon.pages.graphics.tooltip": "Les graphismes, simplement : chaque réglage indique son coût.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Tous les réglages graphiques, y compris ceux d'Horizon, avec le coût de chacun.",
|
||||
"horizon.pages.controller.tooltip": "Les manettes : leurs boutons, leurs sticks, les vibrations et la navigation dans les menus.",
|
||||
"horizon.pages.extras.tooltip": "Ajouts facultatifs, tous désactivés par défaut : le jeu reste vanilla tant que vous n'en activez pas un.",
|
||||
"horizon.pages.agent.tooltip": "Laissez votre propre agent IA jouer : horizon-ctl et les clients MCP sur cet ordinateur.",
|
||||
"horizon.pages.split_profiles.tooltip": "Qui joue : un profil conservé dans ce dossier de jeu.",
|
||||
"horizon.pages.shader_packs.tooltip": "Packs de shaders du dossier shaderpacks : ceux d'Horizon et ceux au format shaders/. Un seul fonctionne à la fois.",
|
||||
"horizon.pages.mods.tooltip": "Mods conçus pour Horizon, du dossier horizon-mods. Chacun s'exécute dans un bac à sable et reste désactivé tant que vous ne l'activez pas.",
|
||||
"horizon.pages.open.graphics": "Graphismes...",
|
||||
"horizon.pages.open.graphics_advanced": "Graphismes avancés...",
|
||||
"horizon.pages.open.shader_packs": "Packs de shaders...",
|
||||
"horizon.pages.open.controller": "Options de la manette...",
|
||||
"horizon.pages.open.extras": "Extras...",
|
||||
"horizon.pages.open.mods": "Mods...",
|
||||
"horizon.pages.open.agent": "Agent IA...",
|
||||
"horizon.pages.open.horizon_graphics": "Graphismes Horizon...",
|
||||
"horizon.pages.open.controller_binds": "Boutons manette...",
|
||||
"horizon.pages.open.horizon_settings": "Options Horizon...",
|
||||
"horizon.mods.empty": "Aucun mod : placez des fichiers .hmod dans le dossier horizon-mods",
|
||||
"horizon.mods.stopped": " (arrêté)",
|
||||
"horizon.mods.not_built": " (non compilé)",
|
||||
"horizon.mods.dev": " (dév.)",
|
||||
"horizon.mods.settings": "Options",
|
||||
"horizon.mods.open_folder": "Ouvrir le dossier des mods",
|
||||
"horizon.shader_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Options des shaders...",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Lecture des options du pack de shaders...",
|
||||
"horizon.agent.copy_details": "Copier les détails de connexion",
|
||||
"horizon.multiplayer.join": "Rejoindre",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Annuler la reconnexion",
|
||||
"horizon.multiplayer.join_friend": "Rejoindre un ami",
|
||||
"horizon.multiplayer.invite_code": "Code d'invitation",
|
||||
"horizon.multiplayer.relay": "Relais (facultatif)",
|
||||
"Preset": "Préréglage",
|
||||
"LOD Distance": "Distance LOD",
|
||||
"Far Detail": "Détails lointains",
|
||||
"Cave Culling": "Occlusion des grottes",
|
||||
"Entity Culling": "Occlusion des entités",
|
||||
"Chunk Updates": "MàJ chunks",
|
||||
"Upload Budget": "Transfert",
|
||||
"Sky": "Ciel",
|
||||
"Fog": "Brouillard",
|
||||
"Weather": "Météo",
|
||||
"Animated Textures": "Textures animées",
|
||||
"Present Mode": "Présentation",
|
||||
"Frame Pacing": "Rythme des images",
|
||||
"Worker Threads": "Threads",
|
||||
"Resolution Scale": "Échelle de résolution",
|
||||
"Anti-aliasing": "Anticrénelage",
|
||||
"Upscaling": "Upscaling",
|
||||
"HDR Settings...": "Options HDR...",
|
||||
"Paper White": "Point blanc",
|
||||
"Peak Brightness": "Luminosité max.",
|
||||
"Black Level": "Niveau de noir",
|
||||
"UI Brightness": "Luminosité UI",
|
||||
"Battery Mode": "Mode batterie",
|
||||
"Graphics API": "API graphique",
|
||||
"Performance Overlay": "Infos de perfs",
|
||||
"Full Detail Distance": "Dist. détail",
|
||||
"On": "Oui",
|
||||
"Off": "Non",
|
||||
"Unlimited": "Illimité",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Personnalisé",
|
||||
"Performance": "Performances",
|
||||
"Balanced": "Équilibré",
|
||||
"Quality": "Qualité",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra perfs",
|
||||
"Native": "Native",
|
||||
"Full": "Complet",
|
||||
"Reduced": "Réduit",
|
||||
"Efficient": "Économe",
|
||||
"Precise": "Précis",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS et graphique",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Aucun coût",
|
||||
"Low cost": "Coût faible",
|
||||
"Medium cost": "Coût moyen",
|
||||
"High cost": "Coût élevé",
|
||||
"Saves time": "Fait gagner du temps",
|
||||
"Rendering": "Rendu",
|
||||
"Display": "Affichage",
|
||||
"Preferences": "Préférences",
|
||||
"Overlay": "Surimpression",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Graphismes",
|
||||
"Graphics: Advanced": "Graphismes : avancés",
|
||||
"Graphics: HDR": "Graphismes : HDR",
|
||||
"Advanced...": "Avancé...",
|
||||
"Simple...": "Simple...",
|
||||
"Done": "Terminé",
|
||||
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
|
||||
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
|
||||
"%s chunks": "%s chunks",
|
||||
"%s per frame": "%s par image",
|
||||
"%s MiB/frame": "%s MiB/image",
|
||||
"%s KiB/frame": "%s KiB/image",
|
||||
"%s nits": "%s nits",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s nits)",
|
||||
"%.2f nits": "%.2f nits",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Terrain en détail complet. La distance LOD va plus loin.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Réduit : terrain simplifié, pour les GPU qui ralentissent.",
|
||||
"Field of view. Wider shows more terrain at once.": "Champ de vision. Plus large, il montre plus de terrain à la fois.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Limite les images par seconde ; économise énergie et chaleur.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Attend l'écran : pas de déchirure, un peu de latence.",
|
||||
"Size of menus and the HUD.": "Taille des menus et de l'interface (HUD).",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Règle les options ci-dessous. Modifier une : « Personnalisé ».",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lave, feu et soleil plus vifs que le blanc. Écran HDR requis.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Noir le plus profond de l'écran. 0 pour OLED, les LCD brillent.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "La luminosité des hautes lumières. Auto : le pic indiqué par votre système.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Luminosité du blanc et des menus. Auto : blanc du bureau.",
|
||||
"How bright the HUD and menus are, against paper white.": "La luminosité du HUD et des menus, par rapport au blanc papier.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Fond les couleurs d'herbe et d'eau entre les biomes.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Couche de nuages : désactivée, plate (rapide) ou 3D (détaillée).",
|
||||
"How far clouds reach, in chunks.": "Jusqu'où s'étendent les nuages, en chunks.",
|
||||
"Draws the sky, sun, moon and stars.": "Dessine le ciel, le soleil, la lune et les étoiles.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Brouillard lointain. Désactivé, il montre le bord du monde chargé.",
|
||||
"Draws rain and snow.": "Dessine la pluie et la neige.",
|
||||
"How far rain and snow fall around you.": "Jusqu'où la pluie et la neige tombent autour de vous.",
|
||||
"How many particles effects spawn.": "Combien de particules les effets génèrent.",
|
||||
"Round shadows under mobs and items.": "Ombres rondes sous les créatures et les objets.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Feuilles avec des trous transparents. Désactivé, elles sont pleines.",
|
||||
"Sorts water and glass more exactly behind each other.": "Trie plus précisément l'eau et le verre les uns derrière les autres.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Quels chunks attendent l'image : aucun, proches ou les vôtres.",
|
||||
"Animates water, lava, fire and other moving textures.": "Anime l'eau, la lave, le feu et les autres textures mobiles.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixels du monde par pixel d'écran. Sous 100 %, GPU plus rapide.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Lisse les bords. FXAA : doux. TAA : lisse, traînées possibles.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendu réduit, reconstruit par TAA. Plus rapide, plus doux.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Sortie HDR sur écran HDR : lave, feu et soleil plus vifs.",
|
||||
"How frames reach the screen while VSync is off.": "Comment les images arrivent à l'écran sans synchro verticale.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Économe attend l'image suivante ; Précis tourne en boucle.",
|
||||
"Fills the screen (borderless).": "Remplit l'écran (sans bordure).",
|
||||
"Brightens dark places.": "Éclaircit les endroits sombres.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Réduit les FPS si la fenêtre est réduite, ou aussi inactive.",
|
||||
"Sways the view while walking.": "Fait osciller la vue pendant la marche.",
|
||||
"Fades newly loaded chunks in.": "Fait apparaître en fondu les chunks nouvellement chargés.",
|
||||
"Darkens the screen's edges.": "Assombrit les bords de l'écran.",
|
||||
"Shows when the world saves.": "Indique quand le monde est sauvegardé.",
|
||||
"Shows attack cooldown.": "Affiche le délai de recharge de l'attaque.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
|
||||
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
|
||||
"horizon.loading.opening_world": "Ouverture du monde",
|
||||
"horizon.loading.cancelling_world": "Annulation en cours",
|
||||
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
|
||||
"horizon.extras.header.lighting": "Éclairage",
|
||||
"horizon.extras.header.third_person": "Troisième personne",
|
||||
"horizon.extras.header.inventory": "Inventaire",
|
||||
"horizon.extras.header.multiplayer": "Multijoueur",
|
||||
"horizon.extras.fullbright": "Pleine luminosité",
|
||||
"horizon.extras.dynamic_lights": "Lumières dynamiques",
|
||||
"horizon.extras.own_name_tag": "Mon pseudo",
|
||||
"horizon.extras.auto_reconnect": "Reconnexion auto",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Déplacer à la molette",
|
||||
"horizon.extras.sort": "Boutons de tri",
|
||||
"horizon.extras.sort_key": "Touche de tri",
|
||||
"horizon.extras.no_ai": "Sans IA",
|
||||
"horizon.extras.agent_control": "Contrôle par agent",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Affiche les blocs et les entités en pleine lumière, même dans les zones sombres.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Les torches et autres sources de lumière tenues en main, jetées ou en feu éclairent les blocs autour d'elles.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Affiche votre nom au-dessus de votre tête à la troisième personne, comme les autres joueurs le voient. Masqué quand le serveur masque les noms de votre équipe ou que vous êtes invisible.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Rejoint de nouveau un serveur après une perte de connexion ou un redémarrage, jamais après une expulsion ou un bannissement.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Glisser avec le clic droit en place un par case, glisser avec le clic gauche rassemble l'objet tenu, Maj + glisser avec le clic gauche déplace chaque case.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Vers le bas, envoie un objet de la case de l'autre côté ; vers le haut, en ramène un.",
|
||||
"horizon.extras.sort.tooltip": "Trie l'inventaire, les coffres, les tonneaux, les coffres de l'Ender et les boîtes de Shulker selon l'ordre des onglets du mode créatif, en fusionnant les piles.",
|
||||
"horizon.extras.sort_key.tooltip": "Trie la zone sous le curseur. Cliquez, puis appuyez sur une touche ; Échap retire l'assignation.",
|
||||
"horizon.extras.no_ai.tooltip": "Masque et désactive toutes les fonctions d'IA : contrôle par agent, horizon-ctl et les serveurs MCP. Le commutateur du lanceur aussi.",
|
||||
"horizon.extras.agent_control.tooltip": "Votre propre agent IA peut jouer à ce jeu via horizon-ctl ou un client MCP, uniquement sur cet ordinateur.",
|
||||
"horizon.extras.discord_presence.tooltip": "Affiche « Joue à Horizon » sur votre profil Discord tant que Discord fonctionne : menus, solo ou multijoueur, la dimension et le temps de jeu. Jamais le nom d'un monde ni l'adresse d'un serveur.",
|
||||
"Calibrate...": "Calibrer...",
|
||||
"Calibrate Anyway...": "Calibrer quand même...",
|
||||
"Use Automatic Setup": "Utiliser la configuration automatique",
|
||||
"Shortcut: %s": "Raccourci : %s",
|
||||
"Next": "Suivant",
|
||||
"HDR Calibration %s/%s: %s": "Calibrage HDR %s/%s : %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Règle les trois valeurs ci-dessous en plein écran, une image test à la fois.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Une touche qui ouvre cet écran depuis le jeu. Non assignée au départ.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Votre écran n'a pas indiqué sa luminosité : calibrez-le.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Déjà configuré. Calibrez seulement si le HDR semble incorrect.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Rétablit Luminosité max. et Point blanc de votre écran.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Configuré pour votre écran. Laissez Auto si tout va bien.",
|
||||
"Lower it until the logo just disappears into the black.": "Baissez-le jusqu'à ce que le logo disparaisse tout juste dans le noir.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Augmentez jusqu'à ce que le logo se fonde dans le carré clair.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Un blanc agréable à regarder longtemps, selon votre éclairage.",
|
||||
"horizon.splitscreen.profiles.forget": "Oublier",
|
||||
"horizon.splitscreen.profiles.create": "Créer",
|
||||
"horizon.splitscreen.profiles.you": " - vous",
|
||||
"horizon.splitscreen.profiles.forget_named": "Oublier %s",
|
||||
"horizon.splitscreen.profiles.previous": "Précédent",
|
||||
"horizon.splitscreen.profiles.next": "Suivant",
|
||||
"horizon.splitscreen.profiles.new_button": "Nouveau profil...",
|
||||
"horizon.splitscreen.profiles.forget_title": "Oublier le profil ?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Il quitte la liste de cette instance.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "La progression des mondes et les options sont conservées.",
|
||||
"horizon.splitscreen.profiles.new_title": "Nouveau profil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Nom (3 à 16 lettres, chiffres ou _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Changer de profil",
|
||||
"horizon.splitscreen.profiles.offline_note": "Les profils hors ligne appartiennent à cette instance",
|
||||
"horizon.splitscreen.profiles.move": "Changer d'emplacement de jeu",
|
||||
"horizon.splitscreen.profiles.move_place": "Changer d'emplacement de jeu : %s",
|
||||
"horizon.friends.title": "Jouer avec des amis",
|
||||
"horizon.friends.hint": "Vos amis collent le code après le bouton Amis de l'écran titre, dans « Connexion rapide » ou sur la page Jouer du lanceur.",
|
||||
"horizon.friends.copied": "Copié",
|
||||
"horizon.friends.copy_code": "Copier le code",
|
||||
"horizon.friends.stop_sharing": "Arrêter le partage",
|
||||
"horizon.friends.none_yet": "Aucun ami pour l'instant.",
|
||||
"horizon.friends.here_now": "Présents : %s",
|
||||
"horizon.friends.share_intro": "Partagez ce monde avec un code d'invitation : il s'ouvre au LAN, et vos amis sur internet le rejoignent avec le code. Aucun serveur de notre part n'est impliqué.",
|
||||
"horizon.friends.start_sharing": "Commencer le partage",
|
||||
"horizon.friends.troubleshooting": "Dépannage...",
|
||||
"horizon.friends.stopped": "Le partage du code d'invitation est arrêté.",
|
||||
"horizon.friends.short_code_changed": "Quelqu'un a essayé plusieurs fois un mauvais code court, il a donc été changé.",
|
||||
"horizon.friends.wrong_code": "Quelqu'un a essayé un mauvais code.",
|
||||
"horizon.friends.code_ready": "Votre code d'invitation est prêt : Menu du jeu, Jouer avec des amis. Copiez-le et envoyez-le à vos amis.",
|
||||
"horizon.friends.share_failed": "Impossible de partager le monde : %s",
|
||||
"horizon.friends.finding_route": "Recherche de la manière dont vos amis peuvent vous joindre…",
|
||||
"horizon.osk.shift": "Maj.",
|
||||
"horizon.osk.space": "Espace",
|
||||
"horizon.osk.delete": "Effacer",
|
||||
"horizon.osk.type": "Saisir",
|
||||
"horizon.osk.symbols": "Symboles",
|
||||
"horizon.osk.close": "Fermer",
|
||||
"horizon.multiplayer.not_signed_in": "Ce serveur est en mode en ligne. Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour le rejoindre.",
|
||||
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
|
||||
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais..."
|
||||
}
|
||||
@@ -12,282 +12,11 @@
|
||||
"horizon.options.button_glyphs": "Symbole przycisków",
|
||||
"horizon.options.menu_navigation": "Nawigacja po menu",
|
||||
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
|
||||
"horizon.options.chat_delay": "Opóźnienie czatu",
|
||||
"horizon.options.lightbar.health": "Zdrowie",
|
||||
"horizon.options.lightbar.dimension": "Wymiar",
|
||||
"horizon.options.glyphs.auto": "Auto",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Fokus",
|
||||
"horizon.options.menu_pointer": "Wskaźnik",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"horizon.options.search": "Szukaj ustawień…",
|
||||
"horizon.pages.mods": "Mody",
|
||||
"horizon.pages.shader_packs": "Paczki shaderów",
|
||||
"horizon.pages.shader_options": "Ustawienia shaderów",
|
||||
"horizon.pages.controller": "Ustawienia kontrolera",
|
||||
"horizon.pages.controller_binds": "Przypisane przyciski kontrolera",
|
||||
"horizon.pages.extras": "Dodatki Horizon",
|
||||
"horizon.pages.settings": "Ustawienia Horizon",
|
||||
"horizon.pages.agent": "Agent AI",
|
||||
"horizon.pages.split_profiles": "Kto gra?",
|
||||
"horizon.pages.settings.tooltip": "Własne ustawienia Horizon: grafika, kontrolery, dodatki i mody.",
|
||||
"horizon.pages.graphics.tooltip": "Grafika w prostej formie: każde ustawienie podaje swój koszt.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Wszystkie ustawienia grafiki, także te z Horizon, wraz z kosztem każdego z nich.",
|
||||
"horizon.pages.controller.tooltip": "Kontrolery: ich przyciski, drążki, wibracje i nawigacja po menu.",
|
||||
"horizon.pages.extras.tooltip": "Opcjonalne dodatki, wszystkie domyślnie wyłączone: gra pozostaje vanilla, dopóki nie włączysz któregoś.",
|
||||
"horizon.pages.agent.tooltip": "Pozwól własnemu agentowi AI grać: horizon-ctl i klienci MCP na tym komputerze.",
|
||||
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
|
||||
"horizon.pages.shader_packs.tooltip": "Paczki shaderów z folderu shaderpacks: własne paczki Horizon i te w formacie shaders/. Naraz działa jedna.",
|
||||
"horizon.pages.mods.tooltip": "Mody stworzone dla Horizon, z folderu horizon-mods. Każdy działa w piaskownicy i pozostaje wyłączony, dopóki go nie włączysz.",
|
||||
"horizon.pages.open.graphics": "Grafika…",
|
||||
"horizon.pages.open.graphics_advanced": "Zaawansowana grafika…",
|
||||
"horizon.pages.open.shader_packs": "Paczki shaderów…",
|
||||
"horizon.pages.open.controller": "Ustawienia kontrolera…",
|
||||
"horizon.pages.open.extras": "Dodatki…",
|
||||
"horizon.pages.open.mods": "Mody…",
|
||||
"horizon.pages.open.agent": "Agent AI…",
|
||||
"horizon.pages.open.horizon_graphics": "Grafika Horizon…",
|
||||
"horizon.pages.open.controller_binds": "Przyciski kontrolera…",
|
||||
"horizon.pages.open.horizon_settings": "Ustawienia Horizon…",
|
||||
"horizon.mods.empty": "Brak modów: umieść pliki .hmod w folderze horizon-mods",
|
||||
"horizon.mods.stopped": " (zatrzymany)",
|
||||
"horizon.mods.not_built": " (niezbudowany)",
|
||||
"horizon.mods.dev": " (dev)",
|
||||
"horizon.mods.settings": "Ustawienia",
|
||||
"horizon.mods.open_folder": "Otwórz folder z modami",
|
||||
"horizon.shader_packs.empty": "Brak paczek shaderów: umieść je w folderze shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Ustawienia shaderów…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…",
|
||||
"horizon.agent.copy_details": "Kopiuj dane połączenia",
|
||||
"horizon.multiplayer.join": "Dołącz",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Anuluj ponowne łączenie",
|
||||
"horizon.multiplayer.join_friend": "Dołącz do znajomego",
|
||||
"horizon.multiplayer.invite_code": "Kod zaproszenia",
|
||||
"horizon.multiplayer.relay": "Przekaźnik (opcjonalnie)",
|
||||
"Preset": "Profil",
|
||||
"LOD Distance": "Zasięg LOD",
|
||||
"Far Detail": "Dalekie detale",
|
||||
"Cave Culling": "Pomijanie jaskiń",
|
||||
"Entity Culling": "Pomijanie bytów",
|
||||
"Chunk Updates": "Aktual. chunków",
|
||||
"Upload Budget": "Wysyłka",
|
||||
"Sky": "Niebo",
|
||||
"Fog": "Mgła",
|
||||
"Weather": "Pogoda",
|
||||
"Animated Textures": "Animowane tekstury",
|
||||
"Present Mode": "Prezentacja",
|
||||
"Frame Pacing": "Tempo klatek",
|
||||
"Worker Threads": "Wątki robocze",
|
||||
"Resolution Scale": "Skala rozdzielczości",
|
||||
"Anti-aliasing": "Antyaliasing",
|
||||
"Upscaling": "Skalowanie",
|
||||
"HDR Settings...": "Ustawienia HDR…",
|
||||
"Paper White": "Punkt bieli",
|
||||
"Peak Brightness": "Jasność maks.",
|
||||
"Black Level": "Poziom czerni",
|
||||
"UI Brightness": "Jasność interfejsu",
|
||||
"Battery Mode": "Tryb baterii",
|
||||
"Graphics API": "API grafiki",
|
||||
"Performance Overlay": "Nakładka wydajności",
|
||||
"Full Detail Distance": "Zasięg detali",
|
||||
"On": "Wł.",
|
||||
"Off": "Wył.",
|
||||
"Unlimited": "Bez limitu",
|
||||
"Auto": "Auto",
|
||||
"Custom": "Własne",
|
||||
"Performance": "Wydajność",
|
||||
"Balanced": "Zrównoważone",
|
||||
"Quality": "Jakość",
|
||||
"Ultra": "Ultra",
|
||||
"Ultra Performance": "Ultra wydajność",
|
||||
"Native": "Natywna",
|
||||
"Full": "Pełne",
|
||||
"Reduced": "Ograniczone",
|
||||
"Efficient": "Oszczędne",
|
||||
"Precise": "Dokładne",
|
||||
"Auto (VSync)": "Auto (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS i wykres",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Bez kosztu",
|
||||
"Low cost": "Niski koszt",
|
||||
"Medium cost": "Średni koszt",
|
||||
"High cost": "Wysoki koszt",
|
||||
"Saves time": "Oszczędza czas",
|
||||
"Rendering": "Renderowanie",
|
||||
"Display": "Wyświetlanie",
|
||||
"Preferences": "Preferencje",
|
||||
"Overlay": "Nakładka",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Grafika",
|
||||
"Graphics: Advanced": "Grafika: zaawansowane",
|
||||
"Graphics: HDR": "Grafika: HDR",
|
||||
"Advanced...": "Zaawansowane…",
|
||||
"Simple...": "Proste…",
|
||||
"Done": "Gotowe",
|
||||
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
|
||||
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
|
||||
"%s chunks": "%s chunków",
|
||||
"%s per frame": "%s na klatkę",
|
||||
"%s MiB/frame": "%s MiB/klatka",
|
||||
"%s KiB/frame": "%s KiB/klatka",
|
||||
"%s nits": "%s nitów",
|
||||
"Auto (%s)": "Auto (%s)",
|
||||
"Auto (%s nits)": "Auto (%s nitów)",
|
||||
"%.2f nits": "%.2f nitów",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Zasięg terenu w pełnych szczegółach. Zasięg LOD sięga dalej.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Uproszczony odległy teren w światach lokalnych. Zintegr. GPU: do 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ograniczone: odległy teren rysowany prościej, szybciej na tym GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Ograniczone: odległy teren prościej, dla GPU, które zwalniają.",
|
||||
"Field of view. Wider shows more terrain at once.": "Pole widzenia. Szersze pokazuje naraz więcej terenu.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Ogranicza klatki na sekundę; limit oszczędza energię i ciepło.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Czeka na odświeżenie ekranu: bez rozrywania obrazu, z niewielkim opóźnieniem.",
|
||||
"Size of menus and the HUD.": "Rozmiar menu i interfejsu HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Ustawia razem poniższe opcje. Zmiana jednej zmienia na „Własne”.",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lawa, ogień i słońce świecą jaśniej niż biel. Wymaga ekranu HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Najciemniejsza czerń twojego wyświetlacza. 0 dla OLED; LCD trochę się świecą.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Jak jasne mogą być światła. Auto: szczyt zgłaszany przez system.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Jak jasny wygląda biały papier i menu. Auto: biel twojego pulpitu.",
|
||||
"How bright the HUD and menus are, against paper white.": "Jak jasne są HUD i menu w stosunku do bieli papieru.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Przenika kolory trawy i wody między biomami podczas budowania chunków.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Warstwa chmur: wyłączona, płaska (szybka) lub 3D (dokładna).",
|
||||
"How far clouds reach, in chunks.": "Jak daleko sięgają chmury, w chunkach.",
|
||||
"Draws the sky, sun, moon and stars.": "Rysuje niebo, słońce, księżyc i gwiazdy.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Mgła w oddali. Wyłączona pokazuje krawędź załadowanego świata.",
|
||||
"Draws rain and snow.": "Rysuje deszcz i śnieg.",
|
||||
"How far rain and snow fall around you.": "Jak daleko wokół ciebie pada deszcz i śnieg.",
|
||||
"How many particles effects spawn.": "Ile cząstek tworzą efekty.",
|
||||
"Round shadows under mobs and items.": "Okrągłe cienie pod stworzeniami i przedmiotami.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Liście z prześwitami. Wyłączone rysuje je jako pełne.",
|
||||
"Sorts water and glass more exactly behind each other.": "Dokładniej sortuje wodę i szkło jedno za drugim.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Które przebudowy chunków czekają na klatkę: żadne, pobliskie lub twoje.",
|
||||
"Animates water, lava, fire and other moving textures.": "Animuje wodę, lawę, ogień i inne ruchome tekstury.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Piksele świata na piksel ekranu. Poniżej 100% GPU działa szybciej.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Wygładza krawędzie. FXAA: miękko. TAA: najgładziej, możliwe smugi.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderuje mniejszy świat, odtwarza przez TAA. Szybciej, miękko.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Wyjście HDR na ekranie HDR: lawa, ogień i słońce świecą jaśniej.",
|
||||
"How frames reach the screen while VSync is off.": "Jak klatki trafiają na ekran, gdy VSync jest wyłączony.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Oszczędne usypia do następnej klatki; Dokładne czeka aktywnie.",
|
||||
"Fills the screen (borderless).": "Wypełnia ekran (bez ramki).",
|
||||
"Brightens dark places.": "Rozjaśnia ciemne miejsca.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Obniża liczbę klatek po zminimalizowaniu, a także podczas bezczynności.",
|
||||
"Sways the view while walking.": "Kołysze widokiem podczas chodzenia.",
|
||||
"Fades newly loaded chunks in.": "Płynnie pokazuje nowo załadowane chunki.",
|
||||
"Darkens the screen's edges.": "Przyciemnia krawędzie ekranu.",
|
||||
"Shows when the world saves.": "Pokazuje, kiedy świat się zapisuje.",
|
||||
"Shows attack cooldown.": "Pokazuje czas odnowienia ataku.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
|
||||
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
|
||||
"horizon.loading.opening_world": "Otwieranie świata",
|
||||
"horizon.loading.cancelling_world": "Anulowanie",
|
||||
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
|
||||
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
|
||||
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
|
||||
"horizon.extras.header.lighting": "Oświetlenie",
|
||||
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
|
||||
"horizon.extras.header.inventory": "Ekwipunek",
|
||||
"horizon.extras.header.multiplayer": "Tryb wieloosobowy",
|
||||
"horizon.extras.fullbright": "Pełna jasność",
|
||||
"horizon.extras.dynamic_lights": "Dynamiczne światła",
|
||||
"horizon.extras.own_name_tag": "Własny nick",
|
||||
"horizon.extras.auto_reconnect": "Ponowne łączenie",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Przenoszenie kółkiem",
|
||||
"horizon.extras.sort": "Przyciski sortowania",
|
||||
"horizon.extras.sort_key": "Klawisz sort.",
|
||||
"horizon.extras.no_ai": "Bez AI",
|
||||
"horizon.extras.agent_control": "Sterowanie agentem",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Pokazuje bloki i byty w pełnym oświetleniu, nawet w ciemnych miejscach.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Pochodnie i inne źródła światła trzymane w dłoni, upuszczone lub płonące oświetlają otaczające bloki.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Pokazuje twój nick nad głową w widoku z trzeciej osoby, tak jak widzą go inni gracze. Ukryty, gdy serwer ukrywa nazwy twojej drużyny lub jesteś niewidzialny.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Ponownie dołącza do serwera po utracie połączenia lub restarcie, nigdy po wyrzuceniu lub banie.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Przeciąganie prawym przyciskiem układa po jednym na pole, lewym zbiera trzymany przedmiot, Shift + lewy przycisk przenosi każde pole.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Przewinięcie w dół wysyła jeden przedmiot z pola na drugą stronę, w górę przywraca jeden.",
|
||||
"horizon.extras.sort.tooltip": "Sortuje ekwipunek, skrzynie, beczki, skrzynie Endu i shulkerowe skrzynie w kolejności kart trybu kreatywnego, łącząc stosy.",
|
||||
"horizon.extras.sort_key.tooltip": "Sortuje część pod kursorem. Kliknij, a potem naciśnij klawisz; Escape usuwa przypisanie.",
|
||||
"horizon.extras.no_ai.tooltip": "Ukrywa i wyłącza wszystkie funkcje AI: sterowanie agentem, horizon-ctl i serwery MCP. Także przełącznik w launcherze.",
|
||||
"horizon.extras.agent_control.tooltip": "Twój własny agent AI może grać w tę grę przez horizon-ctl lub klienta MCP, tylko na tym komputerze.",
|
||||
"horizon.extras.discord_presence.tooltip": "Pokazuje „Gra w Horizon” w twoim profilu Discord, gdy Discord jest uruchomiony: menu, tryb jednoosobowy lub wieloosobowy, wymiar i czas gry. Nigdy nazwy świata ani adresu serwera.",
|
||||
"Calibrate...": "Kalibruj…",
|
||||
"Calibrate Anyway...": "Kalibruj mimo to…",
|
||||
"Use Automatic Setup": "Użyj automatycznej konfiguracji",
|
||||
"Shortcut: %s": "Skrót: %s",
|
||||
"Next": "Dalej",
|
||||
"HDR Calibration %s/%s: %s": "Kalibracja HDR %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Ustawia trzy wartości poniżej na pełnym ekranie, na obrazie testowym.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Klawisz otwierający ten ekran z poziomu gry. Na początku nieprzypisany.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Wyświetlacz nie podał jasności: skalibruj go, aby go skonfigurować.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Już skonfigurowane. Kalibruj tylko, gdy HDR wygląda źle.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Przywraca Jasność maks. i Punkt bieli do ustawień wyświetlacza.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Skonfigurowane dla twojego wyświetlacza. Zostaw Auto, chyba że wygląda źle.",
|
||||
"Lower it until the logo just disappears into the black.": "Zmniejszaj, aż logo właśnie zniknie w czerni.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Zwiększaj, aż logo właśnie zniknie w jasnym kwadracie.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Ustaw biel, na którą wygodnie patrzeć długo, w twoim oświetleniu.",
|
||||
"horizon.splitscreen.profiles.forget": "Zapomnij",
|
||||
"horizon.splitscreen.profiles.create": "Utwórz",
|
||||
"horizon.splitscreen.profiles.you": " - ty",
|
||||
"horizon.splitscreen.profiles.forget_named": "Zapomnij %s",
|
||||
"horizon.splitscreen.profiles.previous": "Poprzednia",
|
||||
"horizon.splitscreen.profiles.next": "Dalej",
|
||||
"horizon.splitscreen.profiles.new_button": "Nowy profil…",
|
||||
"horizon.splitscreen.profiles.forget_title": "Zapomnieć profil?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Znika z listy tej instancji.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Postępy w świecie i ustawienia zostają zachowane.",
|
||||
"horizon.splitscreen.profiles.new_title": "Nowy profil",
|
||||
"horizon.splitscreen.profiles.name_hint": "Nazwa (3–16 liter, cyfr lub _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Zmień profil",
|
||||
"horizon.splitscreen.profiles.offline_note": "Profile offline należą do tej instancji",
|
||||
"horizon.splitscreen.profiles.move": "Zmień miejsce gry",
|
||||
"horizon.splitscreen.profiles.move_place": "Zmień miejsce gry: %s",
|
||||
"horizon.friends.title": "Graj ze znajomymi",
|
||||
"horizon.friends.hint": "Znajomi wklejają kod po kliknięciu przycisku Znajomi na ekranie tytułowym, w Połączeniu bezpośrednim lub na stronie Graj w launcherze.",
|
||||
"horizon.friends.copied": "Skopiowano",
|
||||
"horizon.friends.copy_code": "Kopiuj kod",
|
||||
"horizon.friends.stop_sharing": "Przestań udostępniać",
|
||||
"horizon.friends.none_yet": "Nie ma jeszcze żadnych znajomych.",
|
||||
"horizon.friends.here_now": "Obecnie: %s",
|
||||
"horizon.friends.share_intro": "Udostępnij ten świat za pomocą kodu zaproszenia: otwiera się w sieci LAN, a znajomi przez internet dołączają za pomocą kodu. Nie korzystamy z żadnego naszego serwera.",
|
||||
"horizon.friends.start_sharing": "Zacznij udostępniać",
|
||||
"horizon.friends.troubleshooting": "Rozwiązywanie problemów…",
|
||||
"horizon.friends.stopped": "Przestano udostępniać kod zaproszenia.",
|
||||
"horizon.friends.short_code_changed": "Ktoś wciąż wpisywał zły krótki kod, więc został zmieniony.",
|
||||
"horizon.friends.wrong_code": "Ktoś wpisał zły kod.",
|
||||
"horizon.friends.code_ready": "Twój kod zaproszenia jest gotowy: Menu gry, Graj ze znajomymi. Skopiuj go i wyślij znajomym.",
|
||||
"horizon.friends.share_failed": "Nie udało się udostępnić świata: %s",
|
||||
"horizon.friends.finding_route": "Sprawdzanie, jak znajomi mogą się z tobą połączyć…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Spacja",
|
||||
"horizon.osk.delete": "Usuń",
|
||||
"horizon.osk.type": "Wpisz",
|
||||
"horizon.osk.symbols": "Symbole",
|
||||
"horizon.osk.close": "Zamknij"
|
||||
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
|
||||
}
|
||||
@@ -202,11 +202,5 @@
|
||||
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
|
||||
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
|
||||
"horizon.pages.shader_options": "Настройки шейдеров",
|
||||
"horizon.pages.split_profiles": "Кто играет?",
|
||||
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
|
||||
"horizon.shader_options.open": "Настройки шейдеров…",
|
||||
"horizon.shader_options.profile": "Профиль",
|
||||
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
|
||||
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
|
||||
}
|
||||
@@ -196,11 +196,5 @@
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Parçaları oluşturan, aydınlatan ve ağ verilerini hazırlayan iş parçacıklarının sayısı. Değişiklikler sonraki başlatmada uygulanır.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Önce Vulkan kullanılır. Çalışmazsa Windows'ta DirectX 12, Linux'ta OpenGL denenir. Değişiklikler sonraki başlatmada uygulanır.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Her karede GPU'ya gönderilen ağ verisi miktarı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir.",
|
||||
"horizon.pages.shader_options": "Gölgelendirici Seçenekleri",
|
||||
"horizon.pages.split_profiles": "Kim oynuyor?",
|
||||
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
|
||||
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
|
||||
"horizon.shader_options.profile": "Profil",
|
||||
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
|
||||
"Frame rate and frame times over the game, with a graph.": "Oyun ekranında FPS değerini ve kare sürelerini grafikle birlikte gösterir."
|
||||
}
|
||||
@@ -12,282 +12,11 @@
|
||||
"horizon.options.button_glyphs": "Значки кнопок",
|
||||
"horizon.options.menu_navigation": "Навігація в меню",
|
||||
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
|
||||
"horizon.options.chat_delay": "Затримка чату",
|
||||
"horizon.options.lightbar.health": "Здоров’я",
|
||||
"horizon.options.lightbar.dimension": "Вимір",
|
||||
"horizon.options.glyphs.auto": "Авто",
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.menu_focus": "Фокус",
|
||||
"horizon.options.menu_pointer": "Курсор",
|
||||
"horizon.options.keyboard.auto": "Авто",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
"horizon.options.unavailable": "Ця функція ще недоступна в Horizon.",
|
||||
"horizon.options.search": "Шукати налаштування…",
|
||||
"horizon.pages.mods": "Моди",
|
||||
"horizon.pages.shader_packs": "Пакети шейдерів",
|
||||
"horizon.pages.shader_options": "Налаштування шейдерів",
|
||||
"horizon.pages.controller": "Налаштування геймпада",
|
||||
"horizon.pages.controller_binds": "Призначення кнопок геймпада",
|
||||
"horizon.pages.extras": "Додатки Horizon",
|
||||
"horizon.pages.settings": "Налаштування Horizon",
|
||||
"horizon.pages.agent": "ШІ-агент",
|
||||
"horizon.pages.split_profiles": "Хто грає?",
|
||||
"horizon.pages.settings.tooltip": "Власні налаштування Horizon: графіка, геймпади, додатки та моди.",
|
||||
"horizon.pages.graphics.tooltip": "Графіка просто: кожен параметр показує, чого він коштує.",
|
||||
"horizon.pages.graphics_advanced.tooltip": "Усі параметри графіки, зокрема з Horizon, із зазначенням витрат кожного.",
|
||||
"horizon.pages.controller.tooltip": "Геймпади: їхні кнопки, стіки, вібрація та навігація в меню.",
|
||||
"horizon.pages.extras.tooltip": "Необов’язкові додатки, усі типово вимкнені: гра лишається ванільною, доки ви не ввімкнете якийсь.",
|
||||
"horizon.pages.agent.tooltip": "Дозвольте власному ШІ-агентові грати: horizon-ctl і MCP-клієнти на цьому комп’ютері.",
|
||||
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
|
||||
"horizon.pages.shader_packs.tooltip": "Пакети шейдерів із теки shaderpacks: власні пакети Horizon і пакети у форматі shaders/. Одночасно працює один.",
|
||||
"horizon.pages.mods.tooltip": "Моди, створені для Horizon, із теки horizon-mods. Кожен працює в ізольованому середовищі й залишається вимкненим, доки ви його не ввімкнете.",
|
||||
"horizon.pages.open.graphics": "Графіка…",
|
||||
"horizon.pages.open.graphics_advanced": "Розширена графіка…",
|
||||
"horizon.pages.open.shader_packs": "Пакети шейдерів…",
|
||||
"horizon.pages.open.controller": "Налаштування геймпада…",
|
||||
"horizon.pages.open.extras": "Додатки…",
|
||||
"horizon.pages.open.mods": "Моди…",
|
||||
"horizon.pages.open.agent": "ШІ-агент…",
|
||||
"horizon.pages.open.horizon_graphics": "Графіка Horizon…",
|
||||
"horizon.pages.open.controller_binds": "Кнопки геймпада…",
|
||||
"horizon.pages.open.horizon_settings": "Налаштування Horizon…",
|
||||
"horizon.mods.empty": "Немає модів: покладіть файли .hmod у теку horizon-mods",
|
||||
"horizon.mods.stopped": " (зупинено)",
|
||||
"horizon.mods.not_built": " (не зібрано)",
|
||||
"horizon.mods.dev": " (розробка)",
|
||||
"horizon.mods.settings": "Налаштування",
|
||||
"horizon.mods.open_folder": "Відкрити теку з модами",
|
||||
"horizon.shader_packs.empty": "Немає пакетів шейдерів: покладіть їх у теку shaderpacks",
|
||||
"horizon.shader_packs.kind_horizon": " (Horizon)",
|
||||
"horizon.shader_options.open": "Налаштування шейдерів…",
|
||||
"horizon.shader_options.profile": "Профіль",
|
||||
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…",
|
||||
"horizon.agent.copy_details": "Копіювати дані з’єднання",
|
||||
"horizon.multiplayer.join": "Приєднатися",
|
||||
"horizon.multiplayer.cancel_reconnecting": "Скасувати перепідключення",
|
||||
"horizon.multiplayer.join_friend": "Приєднатися до друга",
|
||||
"horizon.multiplayer.invite_code": "Код запрошення",
|
||||
"horizon.multiplayer.relay": "Ретранслятор (необов’язково)",
|
||||
"Preset": "Шаблон",
|
||||
"LOD Distance": "Дальність LOD",
|
||||
"Far Detail": "Деталі вдалині",
|
||||
"Cave Culling": "Відсікання печер",
|
||||
"Entity Culling": "Відсік. сутностей",
|
||||
"Chunk Updates": "Оновл. чанків",
|
||||
"Upload Budget": "Вивантаження",
|
||||
"Sky": "Небо",
|
||||
"Fog": "Туман",
|
||||
"Weather": "Погода",
|
||||
"Animated Textures": "Анімовані текстури",
|
||||
"Present Mode": "Виведення",
|
||||
"Frame Pacing": "Темп кадрів",
|
||||
"Worker Threads": "Робочі потоки",
|
||||
"Resolution Scale": "Масштаб зображення",
|
||||
"Anti-aliasing": "Згладжування",
|
||||
"Upscaling": "Апскейлінг",
|
||||
"HDR Settings...": "Налаштування HDR…",
|
||||
"Paper White": "Біла точка",
|
||||
"Peak Brightness": "Пік яскравості",
|
||||
"Black Level": "Рівень чорного",
|
||||
"UI Brightness": "Яскравість UI",
|
||||
"Battery Mode": "Режим батареї",
|
||||
"Graphics API": "API графіки",
|
||||
"Performance Overlay": "Накладка швидкодії",
|
||||
"Full Detail Distance": "Дальність деталей",
|
||||
"On": "Увімк.",
|
||||
"Off": "Вимк.",
|
||||
"Unlimited": "Без обмежень",
|
||||
"Auto": "Авто",
|
||||
"Custom": "Власний",
|
||||
"Performance": "Продуктивність",
|
||||
"Balanced": "Збалансований",
|
||||
"Quality": "Якість",
|
||||
"Ultra": "Ультра",
|
||||
"Ultra Performance": "Ультрапродукт.",
|
||||
"Native": "Нативна",
|
||||
"Full": "Повна",
|
||||
"Reduced": "Знижена",
|
||||
"Efficient": "Економний",
|
||||
"Precise": "Точний",
|
||||
"Auto (VSync)": "Авто (VSync)",
|
||||
"FIFO": "FIFO",
|
||||
"Mailbox": "Mailbox",
|
||||
"Immediate": "Immediate",
|
||||
"Vulkan": "Vulkan",
|
||||
"DirectX 12": "DirectX 12",
|
||||
"OpenGL": "OpenGL",
|
||||
"FPS and Graph": "FPS і графік",
|
||||
"FPS": "FPS",
|
||||
"TAA": "TAA",
|
||||
"FXAA": "FXAA",
|
||||
"No cost": "Без витрат",
|
||||
"Low cost": "Низькі витрати",
|
||||
"Medium cost": "Середні витрати",
|
||||
"High cost": "Високі витрати",
|
||||
"Saves time": "Заощаджує час",
|
||||
"Rendering": "Промальовування",
|
||||
"Display": "Дисплей",
|
||||
"Preferences": "Уподобання",
|
||||
"Overlay": "Накладка",
|
||||
"HDR": "HDR",
|
||||
"Graphics": "Графіка",
|
||||
"Graphics: Advanced": "Графіка: розширені",
|
||||
"Graphics: HDR": "Графіка: HDR",
|
||||
"Advanced...": "Розширені…",
|
||||
"Simple...": "Прості…",
|
||||
"Done": "Готово",
|
||||
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
|
||||
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
|
||||
"%s chunks": "%s чанків",
|
||||
"%s per frame": "%s на кадр",
|
||||
"%s MiB/frame": "%s MiB/кадр",
|
||||
"%s KiB/frame": "%s KiB/кадр",
|
||||
"%s nits": "%s ніт",
|
||||
"Auto (%s)": "Авто (%s)",
|
||||
"Auto (%s nits)": "Авто (%s ніт)",
|
||||
"%.2f nits": "%.2f ніт",
|
||||
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальність повних деталей. Дальність LOD сягає далі.",
|
||||
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Спрощена далека місцевість локальних світів. Інтегр. GPU: до 128.",
|
||||
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Знижена: далека місцевість простіша, швидше на цьому GPU.",
|
||||
"Reduced draws far terrain coarser, for GPUs it slows down.": "Знижена: простіша далека місцевість для повільних GPU.",
|
||||
"Field of view. Wider shows more terrain at once.": "Поле зору. Ширше показує більше місцевості одночасно.",
|
||||
"Caps frames per second; a cap saves power and heat.": "Обмежує к/с; обмеження заощаджує енергію й зменшує нагрів.",
|
||||
"Waits for the display's refresh: no tearing, a little latency.": "Чекає на оновлення дисплея: без розривів, невелика затримка.",
|
||||
"Size of menus and the HUD.": "Розмір меню та HUD.",
|
||||
"Sets the options below together. Changing one makes it Custom.": "Задає параметри нижче разом. Зміна одного дає «Власний».",
|
||||
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, вогонь і сонце світліші за білий. Потрібен HDR.",
|
||||
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Найтемніший чорний вашого дисплея. 0 для OLED; LCD трохи світяться.",
|
||||
"How bright highlights get. Auto: the peak your system reports.": "Наскільки яскравими бувають відблиски. Авто: пік, який повідомляє система.",
|
||||
"How bright white paper and menus look. Auto: your desktop's white.": "Яскравість білого паперу та меню. Авто: білий вашого стола.",
|
||||
"How bright the HUD and menus are, against paper white.": "Наскільки яскраві HUD і меню відносно білого паперу.",
|
||||
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
|
||||
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
|
||||
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
|
||||
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.",
|
||||
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
|
||||
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
|
||||
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
|
||||
"Blends grass and water colours across biomes when building chunks.": "Змішує кольори трави та води між біомами під час побудови чанків.",
|
||||
"Cloud layer: off, flat (fast) or 3D (fancy).": "Шар хмар: вимкнено, пласкі (швидкі) або 3D (красиві).",
|
||||
"How far clouds reach, in chunks.": "Як далеко сягають хмари, у чанках.",
|
||||
"Draws the sky, sun, moon and stars.": "Малює небо, сонце, місяць і зірки.",
|
||||
"Distance fog. Off shows the edge of the loaded world.": "Туман на відстані. Вимкнений показує межу завантаженого світу.",
|
||||
"Draws rain and snow.": "Малює дощ і сніг.",
|
||||
"How far rain and snow fall around you.": "Як далеко навколо вас падають дощ і сніг.",
|
||||
"How many particles effects spawn.": "Скільки частинок створюють ефекти.",
|
||||
"Round shadows under mobs and items.": "Круглі тіні під мобами та предметами.",
|
||||
"Leaves with see-through gaps. Off draws them solid.": "Листя з просвітами. Вимкнено — малюється суцільним.",
|
||||
"Sorts water and glass more exactly behind each other.": "Точніше сортує воду та скло одне за одним.",
|
||||
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Які перебудови чанків чекають на кадр: жодні, найближчі чи ваші.",
|
||||
"Animates water, lava, fire and other moving textures.": "Анімує воду, лаву, вогонь та інші рухомі текстури.",
|
||||
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пікселі світу на піксель екрана. Нижче 100% — швидше для GPU.",
|
||||
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Згладжує краї. FXAA: м’якше. TAA: найгладше, можливі шлейфи.",
|
||||
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Менший рендер, відновлення через TAA. Швидше, м’якше.",
|
||||
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Вивід HDR на HDR-дисплеї: лава, вогонь і сонце яскравіші.",
|
||||
"How frames reach the screen while VSync is off.": "Як кадри потрапляють на екран, коли VSync вимкнено.",
|
||||
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Економний» спить до наступного кадру; «Точний» чекає активно.",
|
||||
"Fills the screen (borderless).": "Заповнює екран (без рамки).",
|
||||
"Brightens dark places.": "Освітлює темні місця.",
|
||||
"Lowers the frame rate when minimised, or idle too.": "Знижує частоту кадрів при згортанні, а також при бездіяльності.",
|
||||
"Sways the view while walking.": "Розхитує вид під час ходьби.",
|
||||
"Fades newly loaded chunks in.": "Плавно показує щойно завантажені чанки.",
|
||||
"Darkens the screen's edges.": "Затемнює краї екрана.",
|
||||
"Shows when the world saves.": "Показує, коли світ зберігається.",
|
||||
"Shows attack cooldown.": "Показує перезарядку атаки.",
|
||||
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 у меню/без фокуса), половина потоків: економія.",
|
||||
"Threads that generate, light and mesh chunks. Next start.": "Потоки генерації, світла й сітки чанків. Після перезапуску.",
|
||||
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, інакше DirectX 12 (Windows)/OpenGL (Linux). Після рестарту.",
|
||||
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
|
||||
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
|
||||
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
|
||||
"horizon.loading.opening_world": "Відкриття світу",
|
||||
"horizon.loading.cancelling_world": "Скасування",
|
||||
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
|
||||
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
|
||||
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
|
||||
"horizon.title.not_affiliated": "Не пов’язано з Mojang чи Microsoft",
|
||||
"horizon.extras.header.lighting": "Освітлення",
|
||||
"horizon.extras.header.third_person": "Вигляд від третьої особи",
|
||||
"horizon.extras.header.inventory": "Інвентар",
|
||||
"horizon.extras.header.multiplayer": "Гра в мережі",
|
||||
"horizon.extras.fullbright": "Повна яскравість",
|
||||
"horizon.extras.dynamic_lights": "Динамічне світло",
|
||||
"horizon.extras.own_name_tag": "Власний нік",
|
||||
"horizon.extras.auto_reconnect": "Перепідключення",
|
||||
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
|
||||
"horizon.extras.mouse_tweaks_wheel": "Перенос коліщатком",
|
||||
"horizon.extras.sort": "Кнопки сортування",
|
||||
"horizon.extras.sort_key": "Сортувати",
|
||||
"horizon.extras.no_ai": "Без ШІ",
|
||||
"horizon.extras.agent_control": "Керування агентом",
|
||||
"horizon.extras.discord_presence": "Discord Rich Presence",
|
||||
"horizon.extras.fullbright.tooltip": "Показує блоки та сутності при повному освітленні, навіть у темних місцях.",
|
||||
"horizon.extras.dynamic_lights.tooltip": "Смолоскипи та інші джерела світла, які тримають у руках, викинуті чи палаючі, освітлюють блоки навколо.",
|
||||
"horizon.extras.own_name_tag.tooltip": "Показує ваше ім’я над головою від третьої особи, як його бачать інші гравці. Приховано, коли сервер ховає імена вашої команди або ви невидимі.",
|
||||
"horizon.extras.auto_reconnect.tooltip": "Знову приєднується до сервера після втрати з’єднання чи перезапуску, але ніколи після вигнання чи блокування.",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "Перетягування ПКМ кладе по одному в комірку, ЛКМ збирає утримуваний предмет, Shift + ЛКМ переміщує кожну комірку.",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "Прокрутка вниз надсилає один предмет із комірки на інший бік, вгору — повертає один назад.",
|
||||
"horizon.extras.sort.tooltip": "Сортує інвентар, скрині, діжки, скрині Енду та шалкерові коробки в порядку вкладок творчого режиму, об’єднуючи стеки.",
|
||||
"horizon.extras.sort_key.tooltip": "Сортує частину під курсором. Натисніть, а потім клавішу; Escape скасовує призначення.",
|
||||
"horizon.extras.no_ai.tooltip": "Приховує та вимикає всі функції ШІ: керування агентом, horizon-ctl і MCP-сервери. Також перемикач у лаунчері.",
|
||||
"horizon.extras.agent_control.tooltip": "Ваш власний ШІ-агент може грати в цю гру через horizon-ctl або MCP-клієнт, лише на цьому комп’ютері.",
|
||||
"horizon.extras.discord_presence.tooltip": "Показує «Грає в Horizon» у вашому профілі Discord, поки Discord запущено: меню, одиночна чи мережева гра, вимір і час гри. Ніколи не назву світу чи адресу сервера.",
|
||||
"Calibrate...": "Калібрувати…",
|
||||
"Calibrate Anyway...": "Все одно калібрувати…",
|
||||
"Use Automatic Setup": "Використати автоналаштування",
|
||||
"Shortcut: %s": "Гаряча клавіша: %s",
|
||||
"Next": "Далі",
|
||||
"HDR Calibration %s/%s: %s": "Калібрування HDR %s/%s: %s",
|
||||
"Sets the three below full screen, one test picture at a time.": "Три значення нижче налаштовуються на повному екрані.",
|
||||
"A key that opens this screen from the game. Not bound at first.": "Клавіша, що відкриває цей екран із гри. Спершу не призначена.",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "Дисплей не повідомив яскравість: відкалібруйте його.",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "Уже налаштовано. Калібруйте, лише якщо HDR виглядає погано.",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "Повертає Пік яскравості й Білу точку до налаштувань дисплея.",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "Налаштовано для дисплея. Залишайте «Авто», якщо все гаразд.",
|
||||
"Lower it until the logo just disappears into the black.": "Зменшуйте, доки логотип не зникне в чорному.",
|
||||
"Raise it until the logo just disappears into the bright square.": "Збільшуйте, доки логотип не зникне в яскравому квадраті.",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Білий, на який комфортно довго дивитися за вашого світла.",
|
||||
"horizon.splitscreen.profiles.forget": "Забути",
|
||||
"horizon.splitscreen.profiles.create": "Створити",
|
||||
"horizon.splitscreen.profiles.you": " - ви",
|
||||
"horizon.splitscreen.profiles.forget_named": "Забути %s",
|
||||
"horizon.splitscreen.profiles.previous": "Назад",
|
||||
"horizon.splitscreen.profiles.next": "Далі",
|
||||
"horizon.splitscreen.profiles.new_button": "Новий профіль…",
|
||||
"horizon.splitscreen.profiles.forget_title": "Забути профіль?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "Він зникне зі списку цього екземпляра.",
|
||||
"horizon.splitscreen.profiles.forget_kept": "Прогрес у світі та налаштування зберігаються.",
|
||||
"horizon.splitscreen.profiles.new_title": "Новий профіль",
|
||||
"horizon.splitscreen.profiles.name_hint": "Ім’я (3–16 літер, цифр або _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "Змінити профіль",
|
||||
"horizon.splitscreen.profiles.offline_note": "Офлайн-профілі належать цьому екземпляру",
|
||||
"horizon.splitscreen.profiles.move": "Змінити місце гри",
|
||||
"horizon.splitscreen.profiles.move_place": "Змінити місце гри: %s",
|
||||
"horizon.friends.title": "Грати з друзями",
|
||||
"horizon.friends.hint": "Друзі вставляють код після кнопки «Друзі» на головному екрані, у «Пряме з’єднання» або на сторінці «Грати» в лаунчері.",
|
||||
"horizon.friends.copied": "Скопійовано",
|
||||
"horizon.friends.copy_code": "Копіювати код",
|
||||
"horizon.friends.stop_sharing": "Припинити поширення",
|
||||
"horizon.friends.none_yet": "Друзів тут поки немає.",
|
||||
"horizon.friends.here_now": "Зараз тут: %s",
|
||||
"horizon.friends.share_intro": "Поділіться цим світом за допомогою коду запрошення: він відкривається для LAN, а друзі з інтернету приєднуються за кодом. Жоден наш сервер не залучається.",
|
||||
"horizon.friends.start_sharing": "Почати поширення",
|
||||
"horizon.friends.troubleshooting": "Усунення несправностей…",
|
||||
"horizon.friends.stopped": "Поширення коду запрошення припинено.",
|
||||
"horizon.friends.short_code_changed": "Хтось раз за разом вводив неправильний короткий код, тож його змінено.",
|
||||
"horizon.friends.wrong_code": "Хтось ввів неправильний код.",
|
||||
"horizon.friends.code_ready": "Ваш код запрошення готовий: Ігрове меню, Грати з друзями. Скопіюйте його та надішліть друзям.",
|
||||
"horizon.friends.share_failed": "Не вдалося поділитися світом: %s",
|
||||
"horizon.friends.finding_route": "З’ясовуємо, як друзі можуть до вас під’єднатися…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "Пробіл",
|
||||
"horizon.osk.delete": "Видалити",
|
||||
"horizon.osk.type": "Ввести",
|
||||
"horizon.osk.symbols": "Символи",
|
||||
"horizon.osk.close": "Закрити"
|
||||
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
|
||||
}
|
||||
@@ -203,108 +203,5 @@
|
||||
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
|
||||
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
|
||||
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
|
||||
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…",
|
||||
"horizon.pages.shader_options": "光影选项",
|
||||
"horizon.pages.split_profiles": "谁在玩?",
|
||||
"horizon.pages.split_profiles.tooltip": "谁在玩:保存在此游戏文件夹中的一份档案。",
|
||||
"horizon.shader_options.open": "光影选项…",
|
||||
"horizon.shader_options.profile": "档案",
|
||||
"horizon.shader_options.loading": "正在读取光影包的选项…",
|
||||
"horizon.pages.split_settings": "分屏设置",
|
||||
"horizon.pages.split_setup": "分屏",
|
||||
"horizon.pages.split_setup.tooltip": "在本机游玩的玩家:手柄可以加入你的世界。",
|
||||
"horizon.pages.open.split_setup": "分屏…",
|
||||
"horizon.title.not_affiliated": "与 Mojang 或 Microsoft 无关",
|
||||
"horizon.extras.header.lighting": "光照",
|
||||
"horizon.extras.header.third_person": "第三人称",
|
||||
"horizon.extras.header.particles": "粒子",
|
||||
"horizon.extras.header.inventory": "物品栏",
|
||||
"horizon.extras.header.chat": "聊天",
|
||||
"horizon.extras.header.multiplayer": "多人游戏",
|
||||
"horizon.extras.header.sound": "声音",
|
||||
"horizon.extras.fullbright": "全亮",
|
||||
"horizon.extras.dynamic_lights": "动态光源",
|
||||
"horizon.extras.own_name_tag": "自己的名字标签",
|
||||
"horizon.extras.hide_block_particles": "隐藏方块破坏粒子",
|
||||
"horizon.extras.auto_reconnect": "自动重连",
|
||||
"horizon.extras.ui_sound_reserve": "为界面音效保留声道",
|
||||
"horizon.extras.mouse_tweaks": "鼠标调整控制",
|
||||
"horizon.extras.mouse_tweaks_wheel": "滚轮移动物品",
|
||||
"horizon.extras.sort": "排序按钮",
|
||||
"horizon.extras.sort_key": "排序快捷键",
|
||||
"horizon.extras.no_ai": "关闭 AI",
|
||||
"horizon.extras.agent_control": "代理控制",
|
||||
"horizon.extras.discord_presence": "Discord 动态状态",
|
||||
"horizon.extras.splitscreen": "分屏",
|
||||
"horizon.extras.chat_animation": "平滑聊天",
|
||||
"horizon.extras.chat_indicator": "消息指示条",
|
||||
"horizon.extras.fullbright.tooltip": "即使在黑暗区域也以全亮显示方块和实体。",
|
||||
"horizon.extras.dynamic_lights.tooltip": "手持、掉落或燃烧中的火把等光源会照亮周围方块。",
|
||||
"horizon.extras.own_name_tag.tooltip": "在第三人称视角中把名字显示在头顶,与其它玩家看到的一致。当服务器隐藏团队名字或你处于隐身状态时不显示。",
|
||||
"horizon.extras.hide_block_particles.tooltip": "隐藏方块被破坏时迸出的碎屑粒子,以及挖掘时掉落的碎屑。其它粒子仍会显示。",
|
||||
"horizon.extras.auto_reconnect.tooltip": "连接中断或服务器重启后自动重新加入,被踢出或封禁后不会。",
|
||||
"horizon.extras.ui_sound_reserve.tooltip": "世界中的声音会为界面音效留出 247 个声道中的 16 个,这样大量声音(比如一次大爆炸)不会把菜单点击音挤掉。",
|
||||
"horizon.extras.mouse_tweaks.tooltip": "右键拖动每次放一个物品,左键拖动合并手持物品,Shift + 左键拖动移动所有槽位。",
|
||||
"horizon.extras.mouse_tweaks_wheel.tooltip": "向下把槽位中的一个物品送到另一侧,向上取回一个。",
|
||||
"horizon.extras.sort.tooltip": "按创造模式标签页顺序排序物品栏、箱子、木桶、末影箱和潜影盒,并合并堆叠。",
|
||||
"horizon.extras.sort_key.tooltip": "排序光标下的部分。点击后按一个键;按 Escape 取消绑定。",
|
||||
"horizon.extras.no_ai.tooltip": "隐藏并关闭所有 AI 功能:代理控制、horizon-ctl 和 MCP 服务器。启动器中的开关也一样。",
|
||||
"horizon.extras.agent_control.tooltip": "你自己的 AI 代理可以通过 horizon-ctl 或 MCP 客户端玩这个游戏,仅限本机。",
|
||||
"horizon.extras.discord_presence.tooltip": "Discord 运行时在你的 Discord 资料上显示“正在玩 Horizon”:菜单、单人、多人、维度以及游玩时长。绝不显示世界名称或服务器地址。",
|
||||
"Calibrate...": "校准…",
|
||||
"Calibrate Anyway...": "仍要校准…",
|
||||
"Use Automatic Setup": "使用自动设置",
|
||||
"Shortcut: %s": "快捷键:%s",
|
||||
"Next": "下一步",
|
||||
"HDR Calibration %s/%s: %s": "HDR 校准 %s/%s:%s",
|
||||
"Sets the three below full screen, one test picture at a time.": "以全屏方式逐张显示测试图,来设置下面三项。",
|
||||
"A key that opens this screen from the game. Not bound at first.": "在游戏中打开此界面的按键。初始未绑定。",
|
||||
"Your display didn't say how bright it gets: calibrate to set it up.": "显示器没有报告自身亮度:需要通过校准来设置。",
|
||||
"Already set up for your display. Only calibrate if HDR looks wrong.": "已按你的显示器设置好。只有在 HDR 显示异常时才需要校准。",
|
||||
"Puts Peak Brightness and Paper White back to your display's setup.": "将峰值亮度和纸白亮度恢复为你的显示器设置。",
|
||||
"Set up for your display. Leave on Auto unless it looks wrong.": "已按你的显示器设置。除非显示异常,否则保持“自动”。",
|
||||
"Lower it until the logo just disappears into the black.": "调低,直到标志刚好没入黑色背景。",
|
||||
"Raise it until the logo just disappears into the bright square.": "调高,直到标志刚好没入亮色方块。",
|
||||
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "在你游玩时的光照环境下,把卡片调成长时间观看也舒适的白色。",
|
||||
"horizon.splitscreen.profiles.forget": "忘记",
|
||||
"horizon.splitscreen.profiles.create": "创建",
|
||||
"horizon.splitscreen.profiles.you": " - 你",
|
||||
"horizon.splitscreen.profiles.forget_named": "忘记 %s",
|
||||
"horizon.splitscreen.profiles.previous": "上一个",
|
||||
"horizon.splitscreen.profiles.next": "下一个",
|
||||
"horizon.splitscreen.profiles.new_button": "新建档案…",
|
||||
"horizon.splitscreen.profiles.forget_title": "忘记档案?",
|
||||
"horizon.splitscreen.profiles.forget_leaves": "该档案将从此实例的列表中移除。",
|
||||
"horizon.splitscreen.profiles.forget_kept": "世界进度和设置会保留。",
|
||||
"horizon.splitscreen.profiles.new_title": "新建档案",
|
||||
"horizon.splitscreen.profiles.name_hint": "名称(3 到 16 个字母、数字或 _)",
|
||||
"horizon.splitscreen.profiles.switch_title": "切换档案",
|
||||
"horizon.splitscreen.profiles.offline_note": "离线档案属于此实例",
|
||||
"horizon.splitscreen.profiles.move": "移动游玩位置",
|
||||
"horizon.splitscreen.profiles.move_place": "把游玩位置移到:%s",
|
||||
"horizon.friends.title": "与好友一起玩",
|
||||
"horizon.friends.hint": "好友可以在标题画面的“好友”按钮后、直连界面或启动器的“开始游戏”页粘贴该代码。",
|
||||
"horizon.friends.copied": "已复制",
|
||||
"horizon.friends.copy_code": "复制代码",
|
||||
"horizon.friends.stop_sharing": "停止共享",
|
||||
"horizon.friends.none_yet": "还没有好友加入。",
|
||||
"horizon.friends.here_now": "当前在线:%s",
|
||||
"horizon.friends.share_intro": "通过邀请码共享这个世界:它会开放到局域网,互联网上的好友可以用代码加入。不经过我们的任何服务器。",
|
||||
"horizon.friends.start_sharing": "开始共享",
|
||||
"horizon.friends.troubleshooting": "疑难解答…",
|
||||
"horizon.friends.stopped": "已停止共享邀请码。",
|
||||
"horizon.friends.short_code_changed": "有人反复尝试错误的短代码,因此已更换。",
|
||||
"horizon.friends.wrong_code": "有人尝试了错误的代码。",
|
||||
"horizon.friends.code_ready": "你的邀请码已就绪:游戏菜单 → 与好友一起玩。复制后发给好友。",
|
||||
"horizon.friends.share_failed": "无法共享世界:%s",
|
||||
"horizon.friends.finding_route": "正在寻找好友连接你的方式…",
|
||||
"horizon.osk.shift": "Shift",
|
||||
"horizon.osk.space": "空格",
|
||||
"horizon.osk.delete": "删除",
|
||||
"horizon.osk.type": "输入",
|
||||
"horizon.osk.symbols": "符号",
|
||||
"horizon.osk.close": "关闭",
|
||||
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
|
||||
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
|
||||
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
|
||||
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
|
||||
}
|
||||
@@ -18,7 +18,6 @@ use horizon_client::{
|
||||
biome::Biomes,
|
||||
blocks::BlockFacts,
|
||||
chunk::HEIGHT,
|
||||
graphics::LeafCullingMode,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
models,
|
||||
@@ -141,8 +140,6 @@ fn main() -> Result<(), Error> {
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
|
||||
@@ -328,5 +328,5 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
if let Some(n) = c["scroll"].as_i64() {
|
||||
chat.scroll_by(n as i32, focused);
|
||||
}
|
||||
chat.layout(h, c["now"].as_f64().unwrap_or(0.0) as f32, focused)
|
||||
chat.layout(h, c["now"].as_u64().unwrap_or(0), focused)
|
||||
}
|
||||
@@ -1182,7 +1182,6 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
thundering: s.thunder,
|
||||
nether: false,
|
||||
block_tags: &items.block_tags,
|
||||
landing: &motion.landing,
|
||||
biomes: &biomes,
|
||||
mob_griefing: true,
|
||||
universal_anger: false,
|
||||
|
||||
@@ -7,7 +7,6 @@ use horizon_client::{
|
||||
biome::{BiomeInfo, Biomes, GrassModifier},
|
||||
blocks::BlockFacts,
|
||||
chunk::{Chunk, Light, MIN_Y, index},
|
||||
graphics::LeafCullingMode,
|
||||
mesher::{EMISSION_BITS, FLUID_FACE_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
|
||||
models,
|
||||
};
|
||||
@@ -138,8 +137,6 @@ fn scene(
|
||||
blend_radius,
|
||||
smooth_lighting,
|
||||
cutout_leaves,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let section_y = int(&row["section_y"])?;
|
||||
let started = Instant::now();
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! progress (`PlayerAdvancements`) and the predicates criteria test
|
||||
//! (`EntityPredicate`, `ItemPredicate`, `LocationPredicate`, the loot
|
||||
//! conditions advancements use).
|
||||
use horizon_world::lazy_json::LazyJson;
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
@@ -31,9 +30,7 @@ pub struct Display {
|
||||
pub struct Criterion {
|
||||
pub name: String,
|
||||
pub trigger: String,
|
||||
/// Read on first test: ~1,300 recipe advancements' criteria, most never
|
||||
/// tested in a game, took ~4 MB as `Value`s.
|
||||
pub conditions: LazyJson,
|
||||
pub conditions: Value,
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rewards {
|
||||
@@ -157,8 +154,7 @@ impl Tree {
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
// `read`: the index alone keeps none of the criteria read.
|
||||
match self.list[ai].criteria[ci].conditions.read()["recipes"].as_str() {
|
||||
match self.list[ai].criteria[ci].conditions["recipes"].as_str() {
|
||||
Some(r) => self
|
||||
.by_recipe
|
||||
.entry(r.to_owned())
|
||||
@@ -212,8 +208,7 @@ impl InventoryIndex {
|
||||
return index;
|
||||
};
|
||||
for &(ai, ci) in list {
|
||||
let conditions = tree.list[ai].criteria[ci].conditions.read();
|
||||
let items = &conditions["items"];
|
||||
let items = &tree.list[ai].criteria[ci].conditions["items"];
|
||||
// `id_matches`: only a string or a list limits the items.
|
||||
let limited = items.as_array().and_then(|a| {
|
||||
a.iter()
|
||||
@@ -271,7 +266,7 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
.map(|(name, c)| Criterion {
|
||||
name: name.clone(),
|
||||
trigger: c["trigger"].as_str().unwrap_or("").to_owned(),
|
||||
conditions: LazyJson::new(&c["conditions"]),
|
||||
conditions: c["conditions"].clone(),
|
||||
})
|
||||
.collect();
|
||||
let requirements = match v["requirements"].as_array() {
|
||||
@@ -1611,7 +1606,7 @@ mod tests {
|
||||
criteria: vec![Criterion {
|
||||
name: "c".into(),
|
||||
trigger: "minecraft:inventory_changed".into(),
|
||||
conditions: LazyJson::new(&conditions),
|
||||
conditions,
|
||||
}],
|
||||
requirements: vec![vec!["c".into()]],
|
||||
rewards: Rewards::default(),
|
||||
@@ -1660,7 +1655,7 @@ mod tests {
|
||||
criteria: vec![Criterion {
|
||||
name: "c".into(),
|
||||
trigger: "minecraft:recipe_unlocked".into(),
|
||||
conditions: LazyJson::new(&conditions),
|
||||
conditions,
|
||||
}],
|
||||
requirements: vec![vec!["c".into()]],
|
||||
rewards: Rewards::default(),
|
||||
|
||||
@@ -32,8 +32,8 @@ use horizon_world::{
|
||||
use std::{collections::HashSet, fmt};
|
||||
|
||||
/// `EntityType.ARROW` (and tipped/spectral arrows): 0.5 x 0.5, eyes at 0.13.
|
||||
pub const WIDTH: f32 = 0.5;
|
||||
pub const HEIGHT: f32 = 0.5;
|
||||
const WIDTH: f32 = 0.5;
|
||||
const HEIGHT: f32 = 0.5;
|
||||
const EYE_HEIGHT: f32 = 0.13;
|
||||
|
||||
/// `AbstractArrow.Pickup`.
|
||||
@@ -505,24 +505,6 @@ fn signum(v: f64) -> f64 {
|
||||
/// entity stops there and is not kept: the game hands it to
|
||||
/// `finish_entity_hit` once it has hurt the entity.
|
||||
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
|
||||
tick_with(p, scene, targets, true)
|
||||
}
|
||||
/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow
|
||||
/// moves on to where it would have hit, as `AbstractArrow.stepMoveAndHit` leaves
|
||||
/// it when the event is canceled, and flies on: not stuck, nothing hurt, no hit.
|
||||
pub fn tick_through(
|
||||
p: &mut Projectile,
|
||||
scene: &ArrowScene,
|
||||
targets: &[(Who, Aabb)],
|
||||
) -> (bool, Hit) {
|
||||
tick_with(p, scene, targets, false)
|
||||
}
|
||||
fn tick_with(
|
||||
p: &mut Projectile,
|
||||
scene: &ArrowScene,
|
||||
targets: &[(Who, Aabb)],
|
||||
impact: bool,
|
||||
) -> (bool, Hit) {
|
||||
let Some(mut st) = p.arrow.take() else {
|
||||
return (false, Hit::None);
|
||||
};
|
||||
@@ -535,7 +517,7 @@ fn tick_with(
|
||||
st: &mut st,
|
||||
scene,
|
||||
}
|
||||
.tick(targets, impact);
|
||||
.tick(targets);
|
||||
p.stuck = st.in_ground;
|
||||
let keep = !st.removed && !matches!(hit, Hit::Entity { .. });
|
||||
p.arrow = Some(st);
|
||||
@@ -618,7 +600,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
fn trident(&self) -> bool {
|
||||
matches!(self.p.kind, ProjectileKind::Trident)
|
||||
}
|
||||
fn tick(&mut self, targets: &[(Who, Aabb)], impact: bool) -> Hit {
|
||||
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
|
||||
// ThrownTrident.tick.
|
||||
if self.trident() && self.st.in_ground_time > 4 {
|
||||
self.st.dealt_damage = true;
|
||||
@@ -729,7 +711,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
original.z + movement.z,
|
||||
);
|
||||
if physics {
|
||||
hit = self.step_move_and_hit(original, end, targets, impact);
|
||||
hit = self.step_move_and_hit(original, end, targets);
|
||||
if matches!(hit, Hit::Entity { .. }) {
|
||||
return hit;
|
||||
}
|
||||
@@ -813,13 +795,7 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
|
||||
/// block. A piercing arrow that hits an entity here goes on from it next
|
||||
/// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick.
|
||||
fn step_move_and_hit(
|
||||
&mut self,
|
||||
from: Vec3,
|
||||
to: Vec3,
|
||||
targets: &[(Who, Aabb)],
|
||||
impact: bool,
|
||||
) -> Hit {
|
||||
fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit {
|
||||
let block = self.scene.clip(from, to);
|
||||
let end = block.map_or(to, |b| b.0);
|
||||
// findHitEntities: ProjectileUtil.computeMargin and the search box.
|
||||
@@ -852,10 +828,6 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
self.p.position = next;
|
||||
self.apply_effects_from_blocks(from, next);
|
||||
if let Some((who, at, _)) = best {
|
||||
// A canceled impact ends the search, the arrow where it would have hit.
|
||||
if !impact {
|
||||
return Hit::None;
|
||||
}
|
||||
// AbstractArrow.onHitEntity: past pierce level + 1 entities the
|
||||
// arrow is discarded before it hurts the next one.
|
||||
if self.st.pierce > 0 {
|
||||
@@ -877,9 +849,6 @@ impl Ticker<'_, '_, '_, '_> {
|
||||
if self.scene.is_air(state) {
|
||||
return Hit::None;
|
||||
}
|
||||
if !impact {
|
||||
return Hit::None;
|
||||
}
|
||||
self.on_hit_block(pos, state);
|
||||
Hit::Block {
|
||||
pos: [pos.x, pos.y, pos.z],
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
//! Breathing for native mods: `LivingEntity.baseTick`'s air supply, the part
|
||||
//! that raises or lowers it. A player (`motion/environment.rs`) and a mob
|
||||
//! (`mobs/ai.rs`) work out what vanilla does this tick, `breathe` lets the
|
||||
//! mods change it (`native_living_breathe`, NeoForge's `LivingBreatheEvent`),
|
||||
//! and the caller applies the answer. Servers only: the callers keep it off a
|
||||
//! client's prediction.
|
||||
|
||||
/// One tick of breathing: whether the entity can breathe, how much air it
|
||||
/// loses when it cannot and how much it gains when it can.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Breath {
|
||||
pub can_breathe: bool,
|
||||
/// `decreaseAirSupply`: 1, or 0 by the oxygen bonus's chance, for an
|
||||
/// entity that drowns; 1 for one that would not.
|
||||
pub consume: i32,
|
||||
/// `increaseAirSupply`: 4 for one that can breathe, 0 for one that drowns
|
||||
/// or is held under by the nautilus' breath.
|
||||
pub refill: i32,
|
||||
}
|
||||
|
||||
/// An entity's breathing this tick (`LivingBreatheEvent`, posted from
|
||||
/// `LivingEntity.baseTick` every tick for a living entity with the generic
|
||||
/// air logic): `entity` is its ID and `kind` its type (`minecraft:player`,
|
||||
/// `minecraft:zombie`). `can_breathe`, `consume` and `refill` come in as
|
||||
/// Horizon does it today (a mod-free game changes nothing); set them to
|
||||
/// change this tick. With `can_breathe` the air rises by `refill`, up to its
|
||||
/// maximum (300); without, it falls by `consume`, and at -20 it is set to 0
|
||||
/// and the entity takes 2 drowning damage. Negative amounts count as 0.
|
||||
/// Asked on servers for players and for mobs, except those with a
|
||||
/// `WaterAnimal`'s or an axolotl's own air logic. API @API:living_breathe@.
|
||||
#[inline(never)]
|
||||
pub fn native_living_breathe(
|
||||
entity: u32,
|
||||
kind: &str,
|
||||
can_breathe: &mut bool,
|
||||
consume: &mut i32,
|
||||
refill: &mut i32,
|
||||
) {
|
||||
let _ = (entity, kind, can_breathe, consume, refill);
|
||||
}
|
||||
|
||||
/// `vanilla`'s breathing after the mods have had their say.
|
||||
pub fn breathe(entity: u32, kind: &str, vanilla: Breath) -> Breath {
|
||||
let mut now = vanilla;
|
||||
native_living_breathe(
|
||||
entity,
|
||||
kind,
|
||||
&mut now.can_breathe,
|
||||
&mut now.consume,
|
||||
&mut now.refill,
|
||||
);
|
||||
Breath {
|
||||
can_breathe: now.can_breathe,
|
||||
consume: now.consume.max(0),
|
||||
refill: now.refill.max(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The air supply after `breath`: raised up to `max` when it can breathe,
|
||||
/// lowered otherwise. The second is whether the entity now drowns
|
||||
/// (`LivingEntity.shouldTakeDrowningDamage`: -20 or less), and its air is
|
||||
/// then 0.
|
||||
pub fn apply(air: i32, max: i32, breath: Breath) -> (i32, bool) {
|
||||
if breath.can_breathe {
|
||||
return (air.max((air + breath.refill).min(max)), false);
|
||||
}
|
||||
let air = air - breath.consume;
|
||||
if air <= -20 { (0, true) } else { (air, false) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const VANILLA: Breath = Breath {
|
||||
can_breathe: true,
|
||||
consume: 1,
|
||||
refill: 4,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn air_refills_up_to_the_maximum() {
|
||||
assert_eq!(apply(100, 300, VANILLA), (104, false));
|
||||
assert_eq!(apply(299, 300, VANILLA), (300, false));
|
||||
assert_eq!(apply(300, 300, VANILLA), (300, false));
|
||||
// A refill of 0 leaves it, as does one that would pass the maximum.
|
||||
let held = Breath {
|
||||
refill: 0,
|
||||
..VANILLA
|
||||
};
|
||||
assert_eq!(apply(120, 300, held), (120, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn air_runs_down_and_drowns_at_minus_twenty() {
|
||||
let drowning = Breath {
|
||||
can_breathe: false,
|
||||
consume: 1,
|
||||
refill: 0,
|
||||
};
|
||||
assert_eq!(apply(300, 300, drowning), (299, false));
|
||||
assert_eq!(apply(-18, 300, drowning), (-19, false));
|
||||
assert_eq!(apply(-19, 300, drowning), (0, true));
|
||||
// The oxygen bonus can spare a tick, and a mod can take more.
|
||||
let spared = Breath {
|
||||
consume: 0,
|
||||
..drowning
|
||||
};
|
||||
assert_eq!(apply(-19, 300, spared), (-19, false));
|
||||
let gas = Breath {
|
||||
consume: 30,
|
||||
..drowning
|
||||
};
|
||||
assert_eq!(apply(20, 300, gas), (-10, false));
|
||||
assert_eq!(apply(10, 300, gas), (0, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_changes_without_a_mod() {
|
||||
assert_eq!(breathe(1, "minecraft:zombie", VANILLA), VANILLA);
|
||||
let negative = Breath {
|
||||
can_breathe: false,
|
||||
consume: -3,
|
||||
refill: -2,
|
||||
};
|
||||
// Mods only ever answer with their own values, so what comes back is clamped.
|
||||
assert_eq!(breathe(1, "minecraft:player", negative).consume, 0);
|
||||
assert_eq!(breathe(1, "minecraft:player", negative).refill, 0);
|
||||
}
|
||||
}
|
||||
@@ -325,73 +325,10 @@ pub fn interact(
|
||||
}
|
||||
}
|
||||
|
||||
/// What `interact` would answer for `held`, changing nothing: a client's
|
||||
/// `useItemOn`, where every interaction checks the same conditions and acts
|
||||
/// only `if (!level.isClientSide())`.
|
||||
pub fn outcome(
|
||||
cauldron: &BlockState,
|
||||
under_water: bool,
|
||||
removes_dye: bool,
|
||||
held: &ItemStack,
|
||||
) -> Outcome {
|
||||
struct Dry(Option<ItemStack>);
|
||||
impl Player for Dry {
|
||||
fn creative(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn hand(&mut self) -> &mut Option<ItemStack> {
|
||||
&mut self.0
|
||||
}
|
||||
fn add(&mut self, _: &mut ItemStack) -> bool {
|
||||
true
|
||||
}
|
||||
fn contains(&self, _: &ItemStack) -> bool {
|
||||
false
|
||||
}
|
||||
fn drop_stack(&mut self, _: ItemStack) {}
|
||||
}
|
||||
interact(
|
||||
cauldron,
|
||||
under_water,
|
||||
removes_dye,
|
||||
&mut Dry(Some(held.clone())),
|
||||
)
|
||||
.outcome
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_client_sees_the_bucket_taken_without_acting() {
|
||||
let empty = state("minecraft:cauldron", None);
|
||||
let water = |l| state("minecraft:water_cauldron", Some(l));
|
||||
let stack = |id| ItemStack::new(id, 1);
|
||||
let outcome = |c: &BlockState, under_water, id| outcome(c, under_water, false, &stack(id));
|
||||
assert_eq!(
|
||||
outcome(&empty, false, "minecraft:water_bucket"),
|
||||
Outcome::Success
|
||||
);
|
||||
assert_eq!(
|
||||
outcome(&empty, false, "minecraft:lava_bucket"),
|
||||
Outcome::Success
|
||||
);
|
||||
// fillLavaInteraction under water: CONSUME, the bucket kept.
|
||||
assert_eq!(
|
||||
outcome(&empty, true, "minecraft:lava_bucket"),
|
||||
Outcome::Consume
|
||||
);
|
||||
assert_eq!(
|
||||
outcome(&water(3), false, "minecraft:bucket"),
|
||||
Outcome::Success
|
||||
);
|
||||
// fillBucket needs a full cauldron.
|
||||
assert_eq!(outcome(&water(2), false, "minecraft:bucket"), Outcome::Pass);
|
||||
assert_eq!(outcome(&empty, false, "minecraft:bucket"), Outcome::Pass);
|
||||
assert_eq!(outcome(&empty, false, "minecraft:stone"), Outcome::Pass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
|
||||
let water = |l| state("minecraft:water_cauldron", Some(l));
|
||||
|
||||
@@ -8,19 +8,18 @@ pub mod animate_tick;
|
||||
pub mod arrows;
|
||||
pub mod block_contact;
|
||||
pub use horizon_client_render::{
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
|
||||
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
|
||||
chunk_map, cit, clouds, collisions, command_block, containers, crash, datapack, dynamic_light,
|
||||
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
|
||||
level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
|
||||
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
|
||||
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain,
|
||||
potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, settings,
|
||||
shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview,
|
||||
spans, split_layout, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
|
||||
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
|
||||
world, worldborder, zip,
|
||||
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_entities, block_entity_anim,
|
||||
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, clouds,
|
||||
collisions, command_block, containers, crash, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud,
|
||||
hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, level_loading, light,
|
||||
lod, logging, menu_blur, mesher, minimap, mipmap, mod_list, model_library, models,
|
||||
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen,
|
||||
packs, par, particles, poi_sections, post_chain, potent_sulfur, preedit, providers,
|
||||
screen_draw, screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list,
|
||||
signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles,
|
||||
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
|
||||
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -32,7 +31,6 @@ pub mod block_entity_item;
|
||||
pub mod block_pattern;
|
||||
pub mod block_transform;
|
||||
pub mod boss_overlay;
|
||||
pub mod breathing;
|
||||
pub mod cauldron;
|
||||
pub mod chunk_io;
|
||||
pub mod clock;
|
||||
@@ -105,7 +103,6 @@ pub mod pose;
|
||||
pub mod pregen;
|
||||
pub mod projectiles;
|
||||
pub mod raid;
|
||||
pub mod ram_limit;
|
||||
pub mod random_tick_sections;
|
||||
pub mod recipe_book;
|
||||
pub mod recipe_book_screen;
|
||||
@@ -114,7 +111,6 @@ pub mod save;
|
||||
pub mod scoreboard_cmd;
|
||||
pub mod screen_layout;
|
||||
pub mod screen_narrator;
|
||||
pub mod social_screen;
|
||||
pub use horizon_server_files as server;
|
||||
pub mod server_packs;
|
||||
pub mod shadows;
|
||||
|
||||
@@ -278,16 +278,6 @@ impl ServerTickRateManager {
|
||||
const SPRINT_PER_FRAME: i64 = 40;
|
||||
const SAMPLES: usize = 100;
|
||||
|
||||
/// A step command's broadcast value and the ticks still owed to a new client.
|
||||
/// `ServerTickRateManager.updateStepTicks` sends the former immediately;
|
||||
/// `ServerTickRateManager.updateJoiningPlayer` sends the latter on a join.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StepState {
|
||||
pub revision: u32,
|
||||
pub requested: i32,
|
||||
pub remaining: i32,
|
||||
}
|
||||
|
||||
/// The game's clock: `DeltaTracker.Timer` pacing driven by the
|
||||
/// `ServerTickRateManager` the `/tick` command controls, over a virtual
|
||||
/// millisecond clock built from the frame times it is fed. The integrated
|
||||
@@ -310,7 +300,6 @@ pub struct FixedStep {
|
||||
/// How many times `/tick step` started or stopped a step
|
||||
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
|
||||
step_changes: u32,
|
||||
step_requested: i32,
|
||||
/// In-frame ticks dropped since the last overload warning (`drop_ticks`).
|
||||
dropped: u32,
|
||||
/// When the last overload warning was due, on the frame clock.
|
||||
@@ -331,7 +320,6 @@ impl Default for FixedStep {
|
||||
tick_count: 0,
|
||||
client: false,
|
||||
step_changes: 0,
|
||||
step_requested: 0,
|
||||
dropped: 0,
|
||||
last_overload_warning: std::time::Duration::ZERO,
|
||||
}
|
||||
@@ -500,29 +488,18 @@ impl FixedStep {
|
||||
let started = self
|
||||
.manager
|
||||
.step_game_if_paused(ticks.min(i32::MAX as u32) as i32);
|
||||
if started {
|
||||
self.step_changes += 1;
|
||||
self.step_requested = self.manager.base.frozen_ticks_to_run();
|
||||
}
|
||||
self.step_changes += u32::from(started);
|
||||
started
|
||||
}
|
||||
pub fn stop_step(&mut self) -> bool {
|
||||
let stopped = self.manager.stop_stepping();
|
||||
if stopped {
|
||||
self.step_changes += 1;
|
||||
self.step_requested = 0;
|
||||
}
|
||||
self.step_changes += u32::from(stopped);
|
||||
stopped
|
||||
}
|
||||
/// Preserve the command's count until the network pass, even if a world
|
||||
/// tick has already consumed it.
|
||||
/// New arrivals instead receive the current remaining ticks.
|
||||
pub fn step_state(&self) -> StepState {
|
||||
StepState {
|
||||
revision: self.step_changes,
|
||||
requested: self.step_requested,
|
||||
remaining: self.manager.base.frozen_ticks_to_run(),
|
||||
}
|
||||
/// What `ClientboundTickingStepPacket` carries (`frozenTicksToRun`), and
|
||||
/// a count that changes each time `updateStepTicks` would broadcast it.
|
||||
pub fn step_state(&self) -> (u32, i32) {
|
||||
(self.step_changes, self.manager.base.frozen_ticks_to_run())
|
||||
}
|
||||
/// Record how long a tick took (`MinecraftServer.tickServer` bookkeeping
|
||||
/// and `endTickWork`).
|
||||
@@ -630,55 +607,15 @@ mod tests {
|
||||
fn only_a_started_or_stopped_step_is_broadcast() {
|
||||
let mut clock = FixedStep::default();
|
||||
assert!(!clock.start_step(5), "not frozen");
|
||||
assert_eq!(clock.step_state(), StepState::default());
|
||||
assert_eq!(clock.step_state(), (0, 0));
|
||||
clock.set_frozen(true);
|
||||
assert!(clock.start_step(5));
|
||||
assert_eq!(
|
||||
clock.step_state(),
|
||||
StepState {
|
||||
revision: 1,
|
||||
requested: 5,
|
||||
remaining: 5
|
||||
}
|
||||
);
|
||||
assert_eq!(clock.step_state(), (1, 5));
|
||||
clock.world_runs();
|
||||
assert_eq!(
|
||||
clock.step_state(),
|
||||
StepState {
|
||||
revision: 1,
|
||||
requested: 5,
|
||||
remaining: 4
|
||||
},
|
||||
"consuming a tick preserves the command broadcast"
|
||||
);
|
||||
assert_eq!(clock.step_state(), (1, 4), "a step tick is not broadcast");
|
||||
assert!(clock.stop_step());
|
||||
assert!(!clock.stop_step());
|
||||
assert_eq!(
|
||||
clock.step_state(),
|
||||
StepState {
|
||||
revision: 2,
|
||||
requested: 0,
|
||||
remaining: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn a_consumed_single_step_still_advances_the_client_once() {
|
||||
let mut server = FixedStep::default();
|
||||
server.set_frozen(true);
|
||||
assert!(server.start_step(1));
|
||||
assert!(server.world_runs());
|
||||
assert!(!server.world_runs());
|
||||
let state = server.step_state();
|
||||
assert_eq!(state.remaining, 0, "a late join has no pending ticks");
|
||||
let mut client = FixedStep::client();
|
||||
client.set_frozen(true);
|
||||
client.set_frozen_ticks_to_run(state.requested);
|
||||
assert!(
|
||||
client.world_runs(),
|
||||
"the connected client runs the command's tick"
|
||||
);
|
||||
assert!(!client.world_runs(), "then remains frozen");
|
||||
assert_eq!(clock.step_state(), (2, 0));
|
||||
}
|
||||
#[test]
|
||||
fn a_servers_tick_rate_only_slows_its_client_down() {
|
||||
|
||||
@@ -1219,15 +1219,6 @@ pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
|
||||
Ok(named)
|
||||
}
|
||||
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
|
||||
/// A recipe's whole id from its name in the tables: vanilla's are kept bare
|
||||
/// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`).
|
||||
pub fn recipe_id(name: &str) -> String {
|
||||
if name.contains(':') {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{name}")
|
||||
}
|
||||
}
|
||||
fn custom_type(v: &Value) -> Option<&str> {
|
||||
v["type"]
|
||||
.as_str()
|
||||
@@ -1318,14 +1309,10 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
|
||||
}
|
||||
impl Recipes {
|
||||
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
|
||||
let sources = Self::sources(root, overlay)?;
|
||||
Self::from_sources(root, items, overlay, sources)
|
||||
}
|
||||
/// The recipe files the tables are built from, each id once: vanilla's by
|
||||
/// bare name, then the content mods' as `<ns>:<name>`, the world's packs'
|
||||
/// over them (`overlay`), parsed, and only those whose load conditions
|
||||
/// hold. A server sends some of them to its clients (`recipe_content`).
|
||||
pub fn sources(root: &Path, overlay: &PackOverlay) -> Result<Vec<(String, Value)>> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
|
||||
let mut meta = HashMap::new();
|
||||
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
|
||||
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
|
||||
@@ -1370,7 +1357,6 @@ impl Recipes {
|
||||
}
|
||||
}
|
||||
let filters = horizon_items::recipe_filters::RecipeFilters::load(root, overlay);
|
||||
let mut parsed = Vec::with_capacity(sources.len());
|
||||
for (name, bytes) in sources {
|
||||
// Vanilla's RecipeManager logs a file that does not parse and
|
||||
// goes on ("Couldn't parse data file").
|
||||
@@ -1389,26 +1375,13 @@ impl Recipes {
|
||||
continue;
|
||||
}
|
||||
// The data's recipe filters, over the final set.
|
||||
if !filters.apply(&recipe_id(&name), &mut v) {
|
||||
let full_id = match name.contains(':') {
|
||||
true => name.clone(),
|
||||
false => format!("minecraft:{name}"),
|
||||
};
|
||||
if !filters.apply(&full_id, &mut v) {
|
||||
continue;
|
||||
}
|
||||
parsed.push((name, v));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
/// The tables from `sources` (`Recipes::sources`' shape); `root` and
|
||||
/// `overlay` give the enchantment tags the tables keep.
|
||||
pub fn from_sources(
|
||||
root: &Path,
|
||||
items: &Items,
|
||||
overlay: &PackOverlay,
|
||||
sources: Vec<(String, Value)>,
|
||||
) -> Result<Self> {
|
||||
let mut crafting = Vec::new();
|
||||
let mut cooking = Vec::new();
|
||||
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
|
||||
let mut meta = HashMap::new();
|
||||
for (name, v) in sources {
|
||||
if let Some(kind) = custom_type(&v) {
|
||||
let id = if name.contains(':') {
|
||||
name
|
||||
|
||||
@@ -201,7 +201,6 @@ impl ItemEntity {
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
no_physics: bool,
|
||||
fluids: &crate::motion::FluidResources,
|
||||
entity_boxes: &[Aabb],
|
||||
) -> bool {
|
||||
self.ticks += 1;
|
||||
self.previous = self.position;
|
||||
@@ -242,20 +241,10 @@ impl ItemEntity {
|
||||
// Entity.move with noPhysics: straight through, the ground as it was.
|
||||
let moved = if no_physics {
|
||||
wanted
|
||||
} else if entity_boxes.is_empty() {
|
||||
} else {
|
||||
scene
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
} else {
|
||||
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
|
||||
// too (a boat, a minecart, a shulker, a happy ghast held still).
|
||||
crate::collisions::EntityCollisions {
|
||||
blocks: scene,
|
||||
entities: entity_boxes,
|
||||
border: None,
|
||||
}
|
||||
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
};
|
||||
self.position = Vec3::new(
|
||||
self.position.x + moved.x,
|
||||
@@ -416,71 +405,6 @@ impl ItemEntity {
|
||||
self.pickup_delay != u32::MAX && age < 6000 && self.stack.count < max_stack
|
||||
}
|
||||
}
|
||||
/// The boxes that stop a dropped item (boats, minecarts, shulkers, a held-still happy ghast),
|
||||
/// binned by 8-block cell, so an item looks only at the ones near it: a farm with many drops and
|
||||
/// many vehicles checks a handful of boxes per item, not every box in the level.
|
||||
#[derive(Default)]
|
||||
pub struct ObstacleGrid {
|
||||
cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>>,
|
||||
boxes: Vec<Aabb>,
|
||||
}
|
||||
impl ObstacleGrid {
|
||||
const CELL: f64 = 8.0;
|
||||
pub fn new(boxes: Vec<Aabb>) -> Self {
|
||||
let mut cells: crate::fast_hash::FxHashMap<(i32, i32, i32), Vec<u32>> = Default::default();
|
||||
for (i, b) in boxes.iter().enumerate() {
|
||||
let (lo, hi) = Self::span(*b);
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
cells.entry((x, y, z)).or_default().push(i as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { cells, boxes }
|
||||
}
|
||||
fn span(b: Aabb) -> ((i32, i32, i32), (i32, i32, i32)) {
|
||||
let cell = |v: Vec3| {
|
||||
(
|
||||
(v.x / Self::CELL).floor() as i32,
|
||||
(v.y / Self::CELL).floor() as i32,
|
||||
(v.z / Self::CELL).floor() as i32,
|
||||
)
|
||||
};
|
||||
(cell(b.min_position()), cell(b.max_position()))
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.boxes.is_empty()
|
||||
}
|
||||
/// The boxes meeting `area` (`getEntityCollisions`'s query box), in the order they were given.
|
||||
pub fn near(&self, area: Aabb, out: &mut Vec<Aabb>) {
|
||||
out.clear();
|
||||
if self.boxes.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (lo, hi) = Self::span(area);
|
||||
let mut found: Vec<u32> = Vec::new();
|
||||
for x in lo.0..=hi.0 {
|
||||
for y in lo.1..=hi.1 {
|
||||
for z in lo.2..=hi.2 {
|
||||
if let Some(list) = self.cells.get(&(x, y, z)) {
|
||||
found.extend(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found.sort_unstable();
|
||||
found.dedup();
|
||||
out.extend(
|
||||
found
|
||||
.into_iter()
|
||||
.map(|i| self.boxes[i as usize])
|
||||
.filter(|b| b.intersects(area)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropped items by block cell, for `ItemEntity.mergeWithNeighbours`'s
|
||||
/// search around an item. Holds indexes into the tick's item list.
|
||||
#[derive(Default)]
|
||||
@@ -896,6 +820,31 @@ fn towards_closest_space(
|
||||
_ => Vec3::new(v.x, v.y, push),
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
|
||||
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
|
||||
/// adding their counts and keeping the smaller age.
|
||||
pub fn merge_orbs(orbs: &mut Vec<ExperienceOrb>) {
|
||||
let mut removed = vec![false; orbs.len()];
|
||||
for i in 0..orbs.len() {
|
||||
if removed[i] || orbs[i].ticks % 20 != 1 {
|
||||
continue;
|
||||
}
|
||||
let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
|
||||
for j in 0..orbs.len() {
|
||||
if j != i
|
||||
&& !removed[j]
|
||||
&& orbs[j].can_merge(orbs[i].id, orbs[i].value)
|
||||
&& orbs[j].bounds().intersects(area)
|
||||
{
|
||||
orbs[i].count += orbs[j].count;
|
||||
orbs[i].age = orbs[i].age.min(orbs[j].age);
|
||||
removed[j] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut removed = removed.into_iter();
|
||||
orbs.retain(|_| !removed.next().unwrap_or(false));
|
||||
}
|
||||
/// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Experience {
|
||||
@@ -1132,12 +1081,6 @@ pub struct ArmorStand {
|
||||
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
|
||||
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
|
||||
pub disabled_slots: i32,
|
||||
/// Server-only `LivingEntity.lastPos` for location-based enchantments.
|
||||
pub enchantment_block: Option<BlockPos>,
|
||||
/// Travel accumulated for `LivingEntity.checkFallDamage`'s landing callback.
|
||||
pub enchantment_fall_distance: f64,
|
||||
/// Last equipment seen by `collectEquipmentChanges`; not saved to NBT.
|
||||
pub enchantment_equipment: [Option<ItemStack>; 6],
|
||||
}
|
||||
impl ArmorStand {
|
||||
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
@@ -1209,9 +1152,6 @@ impl ArmorStand {
|
||||
pose: Self::DEFAULT_POSE,
|
||||
vehicle: None,
|
||||
disabled_slots: 0,
|
||||
enchantment_block: None,
|
||||
enchantment_fall_distance: 0.0,
|
||||
enchantment_equipment: Default::default(),
|
||||
}
|
||||
}
|
||||
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
|
||||
@@ -1291,12 +1231,6 @@ impl ArmorStand {
|
||||
Vec3::new(p.x + w, p.y + h, p.z + w),
|
||||
)
|
||||
}
|
||||
/// Leather boots on its feet: it walks on powder snow (`PowderSnowBlock.canEntityWalkOnPowderSnow`).
|
||||
fn feet_are_leather_boots(&self) -> bool {
|
||||
self.equipment[2]
|
||||
.as_ref()
|
||||
.is_some_and(|s| &*s.item == "minecraft:leather_boots")
|
||||
}
|
||||
/// LivingEntity.travel (armor stands have no other movement): clamp tiny
|
||||
/// velocity components (`LivingEntity.aiStep`), move by the velocity (a
|
||||
/// landing stops the fall, `Block.updateEntityMovementAfterFallOn`, and a
|
||||
@@ -1312,16 +1246,9 @@ impl ArmorStand {
|
||||
in_water: bool,
|
||||
push: &mut dyn FnMut(Aabb, &mut Vec3),
|
||||
) {
|
||||
self.previous = self.position;
|
||||
if self.marker {
|
||||
return;
|
||||
}
|
||||
scene.subject =
|
||||
self.feet_are_leather_boots()
|
||||
.then_some(crate::collisions::PowderSnowSubject {
|
||||
leather_boots: true,
|
||||
..Default::default()
|
||||
});
|
||||
let v = self.velocity;
|
||||
let wanted = Vec3::new(
|
||||
if v.x.abs() < 0.003 { 0.0 } else { v.x },
|
||||
@@ -1794,15 +1721,7 @@ mod honey_slide_tests {
|
||||
item.id = 1;
|
||||
let mut ys = Vec::new();
|
||||
for _ in 0..8 {
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
ys.push(item.velocity.y);
|
||||
}
|
||||
// Idle ticks keep gravity; the 4th tick's move hits the floor and
|
||||
@@ -1858,15 +1777,7 @@ mod honey_slide_tests {
|
||||
item.id = 1;
|
||||
let before = item.position.y;
|
||||
for _ in 0..16 {
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
item.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
}
|
||||
assert!(
|
||||
item.position.y > before + 0.5,
|
||||
@@ -1876,7 +1787,7 @@ mod honey_slide_tests {
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
fn falling_item(x: f64, y: f64, z: f64, vx: f64, vy: f64, vz: f64) -> ItemEntity {
|
||||
let at = Vec3::new(x, y, z);
|
||||
ItemEntity {
|
||||
id: 1,
|
||||
@@ -2084,15 +1995,7 @@ mod slime_bounce_tests {
|
||||
falling.on_ground = false;
|
||||
falling.age = 0;
|
||||
falling.id = 1;
|
||||
falling.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
assert_eq!(falling.position.y, 4.0);
|
||||
assert!(falling.velocity.y > 0.0);
|
||||
assert!(falling.on_ground);
|
||||
@@ -2102,15 +2005,7 @@ mod slime_bounce_tests {
|
||||
// does not set needs_sync.
|
||||
falling.velocity = Vec3::ZERO;
|
||||
falling.needs_sync = false;
|
||||
falling.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
assert!(!falling.needs_sync);
|
||||
}
|
||||
}
|
||||
@@ -2165,227 +2060,3 @@ mod unstuck_tests {
|
||||
assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod entity_collision_tests {
|
||||
use super::honey_slide_tests::falling_item;
|
||||
use super::*;
|
||||
|
||||
/// `Entity.collide` stops an item at the entities that `canBeCollidedWith` it: dropped
|
||||
/// above a boat's box (or a still happy ghast's) it lands on the box's top and rests,
|
||||
/// where with no box it falls to the floor of the scene.
|
||||
#[test]
|
||||
fn an_item_lands_on_an_entity_box_under_it() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let stone = assets.registry.default_state("minecraft:stone").unwrap();
|
||||
world.set(20, 3, 20, stone).unwrap();
|
||||
let rest = |boxes: &[Aabb]| {
|
||||
let mut item = falling_item(20.5, 12.0, 20.5, 0.0, 0.0, 0.0);
|
||||
item.id = 1;
|
||||
for _ in 0..80 {
|
||||
item.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
boxes,
|
||||
);
|
||||
}
|
||||
item.position.y
|
||||
};
|
||||
// The stone's top is at 4.0; a box from 5.0 to 9.0 is in the way.
|
||||
let platform = Aabb::new(Vec3::new(19.0, 5.0, 19.0), Vec3::new(22.0, 9.0, 22.0));
|
||||
assert!((rest(&[]) - 4.0).abs() < 1.0e-6, "on the stone with no box");
|
||||
assert!(
|
||||
(rest(&[platform]) - 9.0).abs() < 1.0e-6,
|
||||
"on top of the box, at its top"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obstacle_grid_returns_only_the_boxes_near_an_item() {
|
||||
// A boat every 100 blocks along a line, 1000 of them.
|
||||
let boat = |x: f64| {
|
||||
Aabb::new(
|
||||
Vec3::new(x, 64.0, 0.0),
|
||||
Vec3::new(x + 1.375, 64.5625, 1.375),
|
||||
)
|
||||
};
|
||||
let grid = ObstacleGrid::new((0..1000).map(|i| boat(f64::from(i) * 100.0)).collect());
|
||||
let mut out = Vec::new();
|
||||
// Around the 7th boat: only it.
|
||||
let area = Aabb::new(Vec3::new(699.0, 63.0, -1.0), Vec3::new(701.0, 66.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert_eq!(out, vec![boat(700.0)]);
|
||||
// Between two boats: none.
|
||||
let area = Aabb::new(Vec3::new(750.0, 63.0, 0.0), Vec3::new(751.0, 66.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert!(out.is_empty());
|
||||
// A box spanning cells is found from any of them, once.
|
||||
let wide = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(20.0, 1.0, 1.0));
|
||||
let grid = ObstacleGrid::new(vec![wide]);
|
||||
let area = Aabb::new(Vec3::new(17.0, 0.0, 0.0), Vec3::new(18.0, 1.0, 1.0));
|
||||
grid.near(area, &mut out);
|
||||
assert_eq!(out, vec![wide]);
|
||||
}
|
||||
|
||||
/// A farm: 5000 drops and 1000 separated vehicles. Run with `-- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "timing"]
|
||||
fn obstacle_grid_beats_scanning_every_box_for_a_big_farm() {
|
||||
let boat = |i: u32| {
|
||||
let (x, z) = (f64::from(i % 40) * 6.0, f64::from(i / 40) * 6.0);
|
||||
Aabb::new(
|
||||
Vec3::new(x, 64.0, z),
|
||||
Vec3::new(x + 1.375, 64.5625, z + 1.375),
|
||||
)
|
||||
};
|
||||
let boxes: Vec<Aabb> = (0..1000).map(boat).collect();
|
||||
let items: Vec<Aabb> = (0..5000)
|
||||
.map(|i| {
|
||||
let (x, z) = (f64::from(i % 70) * 3.4, f64::from(i / 70) * 3.4);
|
||||
Aabb::new(Vec3::new(x, 64.0, z), Vec3::new(x + 0.25, 64.25, z + 0.25))
|
||||
})
|
||||
.collect();
|
||||
let start = std::time::Instant::now();
|
||||
let mut hits = 0;
|
||||
for item in &items {
|
||||
let area = item.inflate(Vec3::new(1.0, 1.0, 1.0));
|
||||
hits += boxes.iter().filter(|b| b.intersects(area)).count();
|
||||
}
|
||||
let scan = start.elapsed();
|
||||
let grid = ObstacleGrid::new(boxes);
|
||||
let start = std::time::Instant::now();
|
||||
let (mut out, mut found) = (Vec::new(), 0);
|
||||
for item in &items {
|
||||
grid.near(item.inflate(Vec3::new(1.0, 1.0, 1.0)), &mut out);
|
||||
found += out.len();
|
||||
}
|
||||
let binned = start.elapsed();
|
||||
assert_eq!(hits, found);
|
||||
println!("scan {scan:?}, grid {binned:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod armor_stand_motion_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_stand_starts_with_matching_previous() {
|
||||
let at = Vec3::new(1.0, 2.0, 3.0);
|
||||
let stand = ArmorStand::new(1, at, 90.0);
|
||||
assert_eq!(stand.previous, at);
|
||||
assert_eq!(stand.position, at);
|
||||
}
|
||||
|
||||
fn tick_stand(
|
||||
stand: &mut ArmorStand,
|
||||
world: &World,
|
||||
facts: &BlockFacts,
|
||||
collision: &BlockCollisionRegistry<'_>,
|
||||
) {
|
||||
let mut scene = SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
stand.tick(&mut scene, facts, false, &mut |_, _| {});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tick_keeps_the_previous_position_for_interpolation() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut stand = ArmorStand::new(1, start, 0.0);
|
||||
stand.on_ground = false;
|
||||
// A summoned stand has no motion yet: the first tick only builds it
|
||||
// (vanilla moves by the velocity, then applies gravity), so it falls
|
||||
// from the second tick on (measured on the real server: drop 0, 0, 0.0784 blocks).
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.position, start);
|
||||
assert!((stand.velocity.y + 0.0784).abs() < 1e-6);
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.previous, start);
|
||||
assert!(
|
||||
stand.position.y < start.y,
|
||||
"an airborne stand must fall, y={}",
|
||||
stand.position.y
|
||||
);
|
||||
let mid = stand.previous.y + (stand.position.y - stand.previous.y) * 0.5;
|
||||
assert!(
|
||||
mid < start.y && mid > stand.position.y,
|
||||
"alpha 0.5 must sit between previous and current"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leather_boots_walk_on_powder_snow() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = crate::assets::Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let collision = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let mut world = World::test_scene(&assets, &render, 3).unwrap();
|
||||
let snow = assets
|
||||
.registry
|
||||
.default_state("minecraft:powder_snow")
|
||||
.unwrap();
|
||||
for x in 19..=21 {
|
||||
for z in 19..=21 {
|
||||
world.set(x, 10, z, snow).unwrap();
|
||||
}
|
||||
}
|
||||
let start = Vec3::new(20.5, 11.0, 20.5);
|
||||
let mut bare = ArmorStand::new(1, start, 0.0);
|
||||
bare.on_ground = true;
|
||||
for _ in 0..8 {
|
||||
tick_stand(&mut bare, &world, &facts, &collision);
|
||||
}
|
||||
assert!(
|
||||
bare.position.y < start.y - 0.2,
|
||||
"bare feet must sink into powder snow, y={}",
|
||||
bare.position.y
|
||||
);
|
||||
|
||||
let mut booted = ArmorStand::new(2, start, 0.0);
|
||||
booted.on_ground = true;
|
||||
booted.equipment[2] = Some(ItemStack::new("minecraft:leather_boots", 1));
|
||||
for _ in 0..8 {
|
||||
tick_stand(&mut booted, &world, &facts, &collision);
|
||||
}
|
||||
assert!(
|
||||
(booted.position.y - start.y).abs() < 1.0e-6,
|
||||
"leather boots must walk on powder snow, y={}",
|
||||
booted.position.y
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -246,41 +246,17 @@ mod tests {
|
||||
needs_sync: false,
|
||||
};
|
||||
let mut still = item.clone();
|
||||
assert!(still.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
));
|
||||
assert!(still.tick(&world, assets.registry, &facts, &collision, false, &fluids));
|
||||
assert_eq!(still.position.x, at.x);
|
||||
assert_eq!(still.velocity.x, 0.0);
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let mut carried = item;
|
||||
assert!(carried.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[]
|
||||
));
|
||||
assert!(carried.tick(&world, assets.registry, &facts, &collision, false, &fluids));
|
||||
// First push is damped by buoyancy before motion, second follows drag.
|
||||
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
|
||||
assert!((carried.velocity.x - (0.014 * 0.99 * 0.98 + 0.014)).abs() < 1e-12);
|
||||
for _ in 0..20 {
|
||||
carried.tick(
|
||||
&world,
|
||||
assets.registry,
|
||||
&facts,
|
||||
&collision,
|
||||
false,
|
||||
&fluids,
|
||||
&[],
|
||||
);
|
||||
carried.tick(&world, assets.registry, &facts, &collision, false, &fluids);
|
||||
}
|
||||
assert!(carried.position.x > at.x + 0.5);
|
||||
}
|
||||
|
||||
@@ -40,39 +40,23 @@ impl Culler {
|
||||
start: usize,
|
||||
opaque: impl FnMut([i32; 3]) -> bool,
|
||||
) -> bool {
|
||||
if self.remaining.get() == 0 {
|
||||
if start >= vertices.len() || self.remaining.get() == 0 {
|
||||
return false;
|
||||
}
|
||||
let Some((min, max)) = Self::bounds(self.camera, vertices, start) else {
|
||||
return false;
|
||||
};
|
||||
self.occluded(min, max, opaque)
|
||||
}
|
||||
|
||||
/// Camera-relative bounds grown to include floating-point and rasterization
|
||||
/// edges. The asynchronous path sends these small queries to its worker.
|
||||
pub fn bounds(
|
||||
camera: [f64; 3],
|
||||
vertices: &[crate::mesher::Vertex],
|
||||
start: usize,
|
||||
) -> Option<([f64; 3], [f64; 3])> {
|
||||
if start >= vertices.len() {
|
||||
return None;
|
||||
}
|
||||
let mut min = [f64::INFINITY; 3];
|
||||
let mut max = [f64::NEG_INFINITY; 3];
|
||||
for vertex in &vertices[start..] {
|
||||
for a in 0..3 {
|
||||
let value = f64::from(vertex.pos[a]) + camera[a];
|
||||
let value = f64::from(vertex.pos[a]) + self.camera[a];
|
||||
if !value.is_finite() {
|
||||
return None;
|
||||
return false;
|
||||
}
|
||||
min[a] = min[a].min(value);
|
||||
max[a] = max[a].max(value);
|
||||
}
|
||||
}
|
||||
// Cover floating-point and rasterization edges as well as the vertices.
|
||||
Some((min.map(|v| v - 0.05), max.map(|v| v + 0.05)))
|
||||
self.occluded(min.map(|v| v - 0.05), max.map(|v| v + 0.05), opaque)
|
||||
}
|
||||
|
||||
/// Returns true only when a complete opaque block plane covers the bounds.
|
||||
|
||||
@@ -355,7 +355,6 @@ pub fn mob_to_nbt(
|
||||
),
|
||||
("Air", Tag::Short(m.air as i16)),
|
||||
("Health", Tag::Float(m.health.max(0.0).to_bits())),
|
||||
("AbsorptionAmount", Tag::Float(m.absorption.to_bits())),
|
||||
(
|
||||
"HurtTime",
|
||||
Tag::Short(m.hurt_time.clamp(0, i32::from(i16::MAX)) as i16),
|
||||
@@ -796,24 +795,7 @@ pub fn mob_to_nbt(
|
||||
put_memories(map, m);
|
||||
crate::mobs::villager::write_nbt(m, map);
|
||||
}
|
||||
"minecraft:wandering_trader" => {
|
||||
put_offers(map, m);
|
||||
// WanderingTrader.addAdditionalSaveData: the visit's countdown and
|
||||
// the place it is walking to, a BlockPos codec (an int array).
|
||||
map.insert("DespawnDelay".into(), Tag::Int(m.extra.ints[5]));
|
||||
if let Some(p) = m.extra.point {
|
||||
// The trader keeps its target as a position; the tag is a
|
||||
// BlockPos, so only the block it is in is saved.
|
||||
map.insert(
|
||||
"wander_target".into(),
|
||||
Tag::IntArray(vec![
|
||||
p[0].floor() as i32,
|
||||
p[1].floor() as i32,
|
||||
p[2].floor() as i32,
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
"minecraft:wandering_trader" => put_offers(map, m),
|
||||
"minecraft:axolotl" => {
|
||||
write_axolotl_memories(m, map);
|
||||
map.insert(
|
||||
@@ -1415,7 +1397,6 @@ pub fn mob_from_nbt(
|
||||
"minecraft:max_health" => m.attrs.max_health = v as f32,
|
||||
"minecraft:movement_speed" => m.attrs.speed = v,
|
||||
"minecraft:attack_damage" => m.attrs.attack_damage = v as f32,
|
||||
"minecraft:attack_knockback" => m.attrs.attack_knockback = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1426,9 +1407,6 @@ pub fn mob_from_nbt(
|
||||
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
|
||||
}
|
||||
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
|
||||
m.absorption = float(tag.get("AbsorptionAmount"))
|
||||
.map_or(0.0, |h| h as f32)
|
||||
.max(0.0);
|
||||
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
|
||||
// with the type's and saved before it drops to the one it rolled. (Only
|
||||
// the base is read here, so the clamp would cut a leader zombie's bonus.)
|
||||
@@ -2041,28 +2019,6 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// `PrimedTnt.addAdditionalSaveData`: its `fuse`.
|
||||
pub fn tnt_to_nbt(
|
||||
position: Vec3,
|
||||
motion: Vec3,
|
||||
on_ground: bool,
|
||||
fuse: i32,
|
||||
uuid: [i32; 4],
|
||||
raw: Option<&Tag>,
|
||||
) -> Tag {
|
||||
let mut tag = Base {
|
||||
id: "minecraft:tnt",
|
||||
uuid,
|
||||
position,
|
||||
motion,
|
||||
rotation: [0.0, 0.0],
|
||||
on_ground,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw);
|
||||
put(&mut tag, "fuse", Tag::Short(fuse as i16));
|
||||
tag
|
||||
}
|
||||
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
|
||||
let stack = item_from_nbt(tag.get("Item")?)?;
|
||||
let position = position(tag)?;
|
||||
@@ -3003,16 +2959,6 @@ fn read_villager(m: &mut Mob, tag: &Tag) {
|
||||
if m.species() == crate::mobs::Species::Villager {
|
||||
crate::mobs::villager::read_nbt(m, tag);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::WanderingTrader {
|
||||
// WanderingTrader.readAdditionalSaveData: both default to idle.
|
||||
m.extra.ints[5] = number(tag.get("DespawnDelay")).unwrap_or(0) as i32;
|
||||
m.extra.point = match tag.get("wander_target") {
|
||||
Some(Tag::IntArray(a)) if a.len() == 3 => {
|
||||
Some([f64::from(a[0]), f64::from(a[1]), f64::from(a[2])])
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
let offers_tag = tag.get("Offers");
|
||||
let xp = number(tag.get("Xp")).unwrap_or(0) as i32;
|
||||
if offers_tag.is_none() && xp == 0 && tag.get("LastRestock").is_none() {
|
||||
@@ -3488,8 +3434,6 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
|
||||
Some("minecraft:pig/variant"),
|
||||
Some("minecraft:pig/sound_variant"),
|
||||
),
|
||||
// ZombieNautilus.applyImplicitComponents: the variant only.
|
||||
crate::mobs::Species::ZombieNautilus => (Some("minecraft:zombie_nautilus/variant"), None),
|
||||
_ => (None, None),
|
||||
};
|
||||
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
|
||||
@@ -3965,49 +3909,6 @@ mod sheep_nbt_tests {
|
||||
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
|
||||
}
|
||||
|
||||
/// `WanderingTrader.addAdditionalSaveData`: the visit's countdown and the
|
||||
/// place it is walking to (a `BlockPos` codec, so an int array), which the
|
||||
/// despawn and stroll goals read back after a reload.
|
||||
#[test]
|
||||
fn a_wandering_traders_countdown_and_target_survive_a_save() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("items.json").exists() {
|
||||
return;
|
||||
}
|
||||
let data = crate::mobs::MobTypes::load(&root).unwrap();
|
||||
let types: HashMap<String, Arc<MobType>> = data
|
||||
.types
|
||||
.iter()
|
||||
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
|
||||
.collect();
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
types["minecraft:wandering_trader"].clone(),
|
||||
Vec3::new(0.5, 64.0, 0.5),
|
||||
0.0,
|
||||
);
|
||||
m.extra.ints[5] = 48000;
|
||||
m.extra.point = Some([12.0, 65.0, -30.0]);
|
||||
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
|
||||
assert_eq!(number(tag.get("DespawnDelay")), Some(48000));
|
||||
let Some(Tag::IntArray(target)) = tag.get("wander_target") else {
|
||||
panic!("wander_target missing");
|
||||
};
|
||||
assert_eq!(target, &vec![12, 65, -30]);
|
||||
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
|
||||
assert_eq!(back.extra.ints[5], 48000);
|
||||
assert_eq!(back.extra.point, Some([12.0, 65.0, -30.0]));
|
||||
// A save with neither field: idle, and nothing to walk to.
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:wandering_trader")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
]);
|
||||
let back = mob_from_nbt(&bare, &types, 3, None).unwrap();
|
||||
assert_eq!((back.extra.ints[5], back.extra.point), (0, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobs_keep_their_variant_puff_state_carried_block_and_conversion_times() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -4258,16 +4159,6 @@ mod item_component_tests {
|
||||
let mut sheep = spawn("minecraft:sheep", 2);
|
||||
apply_item_components(&mut sheep, &egg);
|
||||
assert_eq!(sheep.extra.color, 5, "lime, not the white it started as");
|
||||
// ZombieNautilus.applyImplicitComponents: the egg carries the coral
|
||||
// variant over the temperate one the biome picked.
|
||||
let mut egg = crate::items::ItemStack::new("minecraft:zombie_nautilus_spawn_egg", 1);
|
||||
egg.data_mut().set_other(
|
||||
"minecraft:zombie_nautilus/variant",
|
||||
Some(string("minecraft:warm")),
|
||||
);
|
||||
let mut nautilus = spawn("minecraft:zombie_nautilus", 4);
|
||||
apply_item_components(&mut nautilus, &egg);
|
||||
assert_eq!(nautilus.extra.variant, 1, "minecraft:warm, not temperate");
|
||||
}
|
||||
|
||||
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Startup shared by the gameplay library and its executable.
|
||||
use crate::{crash, game, logging, ram_limit};
|
||||
use crate::{crash, game, logging};
|
||||
|
||||
/// Start the game. The executable supplies the allocator and retains the mod API marker.
|
||||
pub fn main() {
|
||||
@@ -43,7 +43,6 @@ pub fn main() {
|
||||
// The log's first line names the build, so a bug report that carries the log says which one it was.
|
||||
log::info!("Horizon {}", crash::version());
|
||||
crash::install(home.clone(), side);
|
||||
ram_limit::start(&home, side);
|
||||
}
|
||||
raise_open_file_limit();
|
||||
if let Err(error) = game::run() {
|
||||
|
||||
@@ -10,16 +10,7 @@ use crate::{
|
||||
ui,
|
||||
};
|
||||
|
||||
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
|
||||
pub const TITLE: &str = "Play with Friends";
|
||||
/// A string of this screen: the language table's, or the English text.
|
||||
fn tr(key: &str, english: &str) -> String {
|
||||
crate::lang::get_or(key, english)
|
||||
}
|
||||
/// The title in the current language.
|
||||
pub fn title() -> String {
|
||||
tr("horizon.friends.title", TITLE)
|
||||
}
|
||||
/// What the screen shows (game/friends.rs fills it).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FriendsView {
|
||||
@@ -56,18 +47,13 @@ fn grey() -> u32 {
|
||||
rgba(160, 160, 160, 255)
|
||||
}
|
||||
const WHITE: u32 = u32::MAX;
|
||||
fn hint() -> String {
|
||||
tr(
|
||||
"horizon.friends.hint",
|
||||
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
|
||||
)
|
||||
}
|
||||
const HINT: &str = "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.";
|
||||
|
||||
struct Button {
|
||||
x: f32,
|
||||
y: f32,
|
||||
w: f32,
|
||||
label: String,
|
||||
label: &'static str,
|
||||
action: Action,
|
||||
active: bool,
|
||||
}
|
||||
@@ -90,11 +76,7 @@ fn layout(
|
||||
};
|
||||
let mut buttons = Vec::new();
|
||||
if view.sharing {
|
||||
lines.push((
|
||||
tr("horizon.multiplayer.invite_code", "Invite Code"),
|
||||
grey(),
|
||||
y,
|
||||
));
|
||||
lines.push(("Invite Code".to_owned(), grey(), y));
|
||||
y += 12.0;
|
||||
match &view.code {
|
||||
Some(code) => {
|
||||
@@ -110,11 +92,7 @@ fn layout(
|
||||
x: cx - 154.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: if view.copied {
|
||||
tr("horizon.friends.copied", "Copied")
|
||||
} else {
|
||||
tr("horizon.friends.copy_code", "Copy Code")
|
||||
},
|
||||
label: if view.copied { "Copied" } else { "Copy Code" },
|
||||
action: Action::Copy,
|
||||
active: view.code.is_some(),
|
||||
});
|
||||
@@ -122,7 +100,7 @@ fn layout(
|
||||
x: cx + 4.0,
|
||||
y,
|
||||
w: 150.0,
|
||||
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
|
||||
label: "Stop Sharing",
|
||||
action: Action::Stop,
|
||||
active: true,
|
||||
});
|
||||
@@ -135,40 +113,32 @@ fn layout(
|
||||
y += 4.0;
|
||||
}
|
||||
let here = if view.guests.is_empty() {
|
||||
tr("horizon.friends.none_yet", "No friends here yet.")
|
||||
"No friends here yet.".to_owned()
|
||||
} else {
|
||||
tr("horizon.friends.here_now", "Here now: %s").replacen(
|
||||
"%s",
|
||||
&view.guests.join(", "),
|
||||
1,
|
||||
)
|
||||
format!("Here now: {}", view.guests.join(", "))
|
||||
};
|
||||
add(&mut lines, &here, grey(), &mut y);
|
||||
if let Some(notice) = &view.notice {
|
||||
add(&mut lines, notice, WHITE, &mut y);
|
||||
}
|
||||
// Where friends paste the code, while it fits above the footer.
|
||||
let hint = hint();
|
||||
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, &hint, grey(), &mut y);
|
||||
if y + 10.0 * wrap(HINT, width, atlas).len() as f32 <= h - 33.0 {
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
}
|
||||
} else {
|
||||
add(
|
||||
&mut lines,
|
||||
&tr(
|
||||
"horizon.friends.share_intro",
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
),
|
||||
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
|
||||
WHITE,
|
||||
&mut y,
|
||||
);
|
||||
y += 4.0;
|
||||
add(&mut lines, &hint(), grey(), &mut y);
|
||||
add(&mut lines, HINT, grey(), &mut y);
|
||||
buttons.push(Button {
|
||||
x: cx - 100.0,
|
||||
y: y + 8.0,
|
||||
w: 200.0,
|
||||
label: tr("horizon.friends.start_sharing", "Start Sharing"),
|
||||
label: "Start Sharing",
|
||||
action: Action::Share,
|
||||
active: view.shown,
|
||||
});
|
||||
@@ -180,7 +150,7 @@ fn layout(
|
||||
x: cx - 154.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
|
||||
label: "Troubleshooting...",
|
||||
action: Action::Help,
|
||||
active: true,
|
||||
});
|
||||
@@ -188,7 +158,7 @@ fn layout(
|
||||
x: cx + 4.0,
|
||||
y: h - 27.0,
|
||||
w: 150.0,
|
||||
label: "gui.done".to_owned(),
|
||||
label: "gui.done",
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
@@ -197,7 +167,7 @@ fn layout(
|
||||
x: cx - 100.0,
|
||||
y: h - 27.0,
|
||||
w: 200.0,
|
||||
label: "gui.done".to_owned(),
|
||||
label: "gui.done",
|
||||
action: Action::Done,
|
||||
active: true,
|
||||
});
|
||||
@@ -234,7 +204,7 @@ pub fn draw(
|
||||
) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
let title = Text::literal(title());
|
||||
let title = Text::literal(TITLE);
|
||||
let tw = atlas.font.width_text(&title);
|
||||
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
|
||||
let (lines, buttons) = layout(atlas, (w, h), view);
|
||||
@@ -251,7 +221,7 @@ pub fn draw(
|
||||
b.y,
|
||||
b.w,
|
||||
20.0,
|
||||
&label(&b.label),
|
||||
&label(b.label),
|
||||
b.active,
|
||||
hover,
|
||||
);
|
||||
|
||||
+218
-761
File diff suppressed because it is too large.
Load diff
@@ -2006,7 +2006,7 @@ impl Game<'_> {
|
||||
criteria.push(adv::Criterion {
|
||||
name: name.clone(),
|
||||
trigger: String::new(),
|
||||
conditions: Value::Null.into(),
|
||||
conditions: Value::Null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +417,6 @@ impl Game<'_> {
|
||||
Screen::Advancements => "advancements",
|
||||
Screen::WorldOptions => "world-options",
|
||||
Screen::Friends => "friends",
|
||||
Screen::Social => "social",
|
||||
Screen::ServerLinks => "server-links",
|
||||
Screen::ConfirmLink => "confirm-link",
|
||||
Screen::DialogWarning => "dialog-warning",
|
||||
@@ -699,7 +698,7 @@ impl Game<'_> {
|
||||
push(
|
||||
[orb.position.x, orb.position.y, orb.position.z],
|
||||
"minecraft:experience_orb",
|
||||
json!({ "kind": "orb", "value": orb.value, "velocity": [orb.velocity.x, orb.velocity.y, orb.velocity.z] }),
|
||||
json!({ "kind": "orb", "value": orb.value }),
|
||||
);
|
||||
}
|
||||
list.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
|
||||
@@ -617,9 +617,6 @@ impl Game<'_> {
|
||||
magic.then_some(id as i32),
|
||||
);
|
||||
});
|
||||
// Player.onAttack: the hit starts the attack strength again, hurt or
|
||||
// not (skipAttackInteraction only asks mayInteract).
|
||||
self.attack_ticks = 0;
|
||||
if hurt {
|
||||
self.swing_arm();
|
||||
}
|
||||
@@ -730,20 +727,20 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
pub(super) fn tick_armor_stands(&mut self) {
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world: &self.world,
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut dead = Vec::new();
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
for i in 0..self.armor_stands.len() {
|
||||
let at = self.armor_stands[i].position;
|
||||
if !ticking(at.x.floor() as i32, at.z.floor() as i32) {
|
||||
self.armor_stands[i].previous = at;
|
||||
self.armor_stands[i].previous_yaw = self.armor_stands[i].yaw;
|
||||
continue;
|
||||
}
|
||||
self.armor_stand_location_effects(i, false);
|
||||
let a = &mut self.armor_stands[i];
|
||||
for (i, a) in self.armor_stands.iter_mut().enumerate() {
|
||||
a.previous = a.position;
|
||||
a.previous_yaw = a.yaw;
|
||||
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
|
||||
let (world, registry, facts, collision) = (
|
||||
&self.world,
|
||||
@@ -751,31 +748,14 @@ impl Game<'_> {
|
||||
self.res.facts(),
|
||||
self.collision,
|
||||
);
|
||||
let mut scene = horizon_client::collisions::SceneCollisions {
|
||||
world,
|
||||
binding: collision,
|
||||
subject: None,
|
||||
};
|
||||
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
|
||||
horizon_client::motion::bubble_column_push(
|
||||
world, registry, facts, collision, bounds, velocity,
|
||||
);
|
||||
});
|
||||
if in_water {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
} else {
|
||||
a.enchantment_fall_distance += (a.previous.y - a.position.y).max(0.0);
|
||||
}
|
||||
let landed = a.on_ground && a.enchantment_fall_distance > 0.0;
|
||||
if a.on_ground {
|
||||
a.enchantment_fall_distance = 0.0;
|
||||
}
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
}
|
||||
if landed {
|
||||
self.armor_stand_location_effects(i, true);
|
||||
}
|
||||
}
|
||||
for i in dead.into_iter().rev() {
|
||||
self.break_armor_stand_by_anything(i);
|
||||
|
||||
@@ -148,11 +148,6 @@ impl BlockEditScreen {
|
||||
dragging: None,
|
||||
}
|
||||
}
|
||||
/// Whether one of the block's text boxes has the keyboard's focus: the
|
||||
/// window asks the OS for an input method only then.
|
||||
pub(super) fn text_focused(&self) -> bool {
|
||||
self.focus.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
@@ -344,7 +339,7 @@ impl Game<'_> {
|
||||
.map_or([1, 1], |r| [r.config.width, r.config.height]);
|
||||
let b = HudBatch::new(size);
|
||||
let cursor = self.cursor;
|
||||
let shift = self.has_shift_down();
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let alt = self.keys.contains(&KeyCode::AltLeft);
|
||||
let atlas_click = |game: &Game<'_>, edit: &mut EditBox| {
|
||||
edit.click(&game.gui.atlas, cursor.0, false, false);
|
||||
@@ -437,8 +432,8 @@ impl Game<'_> {
|
||||
shortcut: Option<horizon_client::clipboard::EditShortcut>,
|
||||
text: Option<&str>,
|
||||
) {
|
||||
let ctrl = self.has_control_down_with_quirk();
|
||||
let shift = self.has_shift_down();
|
||||
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
|
||||
let shift = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
let types_text = self.types_text();
|
||||
let size = self
|
||||
.renderer
|
||||
|
||||
@@ -1,716 +0,0 @@
|
||||
//! A server mod's bulk block edit (`native_edit_blocks`): its palette and
|
||||
//! filters read as commands read blocks, its heights read, and its runs made
|
||||
//! at once through `World::edit_runs`, with what `fill ... strict` does for
|
||||
//! the block entities and the watched blocks of each block changed.
|
||||
use super::Game;
|
||||
use super::native_world_columns::NativeColumnBlocks as ColumnBlocks;
|
||||
use super::native_world_edit::{NativeBlockEdit as BlockEdit, NativeHeightmap as Heightmap};
|
||||
use horizon_client::{
|
||||
block_edit::Run,
|
||||
chunk::ColumnLayout,
|
||||
command::{Reader, args::BlockInput, block},
|
||||
containers::BlockEntity,
|
||||
fast_hash::{FxHashMap, FxHashSet},
|
||||
world::World,
|
||||
};
|
||||
use horizon_world::{
|
||||
heightmap::HeightmapType, registry::BlockStateId, worldgen::terrain::TerrainBlockStates,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A change `World::edit_runs` reported: position, previous and new state.
|
||||
type Tracked = ((i32, i32, i32), u32, u32);
|
||||
|
||||
impl Game<'_> {
|
||||
/// Make a mod's block edit; how many blocks changed, or why none did.
|
||||
/// Every palette entry, filter and run is checked before anything
|
||||
/// changes: an entry or filter that doesn't read, one with NBT (`{...}`,
|
||||
/// refused: the bulk path sets states only), a run naming a palette entry
|
||||
/// or filter past the end, or more than 65536 filters, and nothing is
|
||||
/// made.
|
||||
pub(super) fn edit_blocks(&mut self, edit: &BlockEdit) -> Result<u64, String> {
|
||||
let parse = |name: &str, testing: bool| -> Result<BlockInput, String> {
|
||||
let mut reader = Reader::new(name);
|
||||
let b = block::parse(&mut reader, &self.ui.registries, testing)
|
||||
.map_err(|e| format!("{name:?}: {}", e.message.plain()))?;
|
||||
if reader.can_read() {
|
||||
return Err(format!("{name:?}: more after the block"));
|
||||
}
|
||||
if b.nbt.is_some() {
|
||||
return Err(format!("{name:?}: block NBT is not supported here"));
|
||||
}
|
||||
Ok(b)
|
||||
};
|
||||
let mut palette = Vec::with_capacity(edit.palette.len());
|
||||
for name in &edit.palette {
|
||||
let b = parse(name, false)?;
|
||||
let state = self
|
||||
.block_state(&b)
|
||||
.map_err(|e| format!("{name:?}: {}", e.message.plain()))?;
|
||||
palette.push(state.0);
|
||||
}
|
||||
let filters = edit
|
||||
.filters
|
||||
.iter()
|
||||
.map(|f| parse(f, true))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let runs = resolve_runs(edit, &palette, filters.len(), |h, x, z| {
|
||||
self.edit_height(h, x, z)
|
||||
})?;
|
||||
// (what the registry, the tags and the facts tell of a state, beside the world being changed)
|
||||
let registry = self.pack.assets.registry;
|
||||
// (the tags as `block_matches` reads them for commands)
|
||||
let world_items = match &self.world_tables {
|
||||
Some(t) => &t.items,
|
||||
None => self.res.items(),
|
||||
};
|
||||
let tags = [&self.res.items().block_tags, &world_items.block_tags];
|
||||
let facts = self.res.facts();
|
||||
let watched = std::mem::take(&mut self.world.watched);
|
||||
let out = self.world.edit_runs(
|
||||
&runs,
|
||||
|f, s| {
|
||||
super::commands::block_input_matches(
|
||||
registry,
|
||||
tags,
|
||||
&filters[f as usize],
|
||||
BlockStateId(s),
|
||||
)
|
||||
},
|
||||
|s| {
|
||||
let f = facts.get(BlockStateId(s));
|
||||
watched.get(s as usize).copied().unwrap_or(false)
|
||||
|| f.block_entity
|
||||
|| BlockEntity::for_block(&f.name).is_some()
|
||||
},
|
||||
);
|
||||
self.world.watched = watched;
|
||||
// The block entities the new blocks bring, as `native_set_block` makes them (a mod block's own, API 15,
|
||||
// included), in the order `replace_block_entities` takes them: the positions whose block changed.
|
||||
let name = |s: u32| self.res.facts().get(BlockStateId(s)).name.clone();
|
||||
let wanted: Vec<Arc<str>> = out
|
||||
.tracked
|
||||
.iter()
|
||||
.filter(|&&(_, old, new)| name(old) != name(new))
|
||||
.map(|&(_, _, new)| name(new))
|
||||
.collect();
|
||||
let mut made = make_block_entities(&wanted, |n| self.new_block_entity(n)).into_iter();
|
||||
let facts = self.res.facts();
|
||||
let removed = replace_block_entities(
|
||||
&mut self.world,
|
||||
&out.tracked,
|
||||
|s| facts.get(BlockStateId(s)).name.clone(),
|
||||
|_| made.next().flatten(),
|
||||
);
|
||||
for pos in removed {
|
||||
self.jukebox_removed(pos);
|
||||
}
|
||||
Ok(out.changed)
|
||||
}
|
||||
|
||||
/// Each column's height on `h` as `edit_height` reads it, none where its chunk is not loaded.
|
||||
pub(super) fn surface_heights(&self, columns: &[(i32, i32)], h: Heightmap) -> Vec<Option<i32>> {
|
||||
loaded_heights(&self.world, columns, |x, z| self.edit_height(h, x, z))
|
||||
}
|
||||
|
||||
/// Each column's height on `h` (none where its chunk is not loaded) and its blocks from `below` under it to
|
||||
/// `above` - 1 over it, bottom first (air outside the column and where it is not loaded), as indices into the
|
||||
/// names seen, with the biome's grass, foliage and water tints at the column's top (0 where it is not loaded;
|
||||
/// unblended). `Err`, reading nothing, when `below + above` is 0 or more than the level's height, or the read
|
||||
/// would be more than `MAX_READ` blocks.
|
||||
pub(super) fn read_columns(
|
||||
&self,
|
||||
columns: &[(i32, i32)],
|
||||
h: Heightmap,
|
||||
below: u32,
|
||||
above: u32,
|
||||
) -> Result<ColumnBlocks, String> {
|
||||
read_size(columns.len(), below, above, self.world.layout().height())?;
|
||||
let bases = self.surface_heights(columns, h);
|
||||
let facts = self.res.facts();
|
||||
let (palette, blocks) = column_blocks(&self.world, columns, &bases, below, above, |s| {
|
||||
facts.get(s).name.clone()
|
||||
})
|
||||
.ok_or("the read is too big")?;
|
||||
let biomes = self.res.biomes();
|
||||
let mut tints = Vec::with_capacity(columns.len() * 3);
|
||||
for (&(x, z), base) in columns.iter().zip(&bases) {
|
||||
// (the biome tints at the column's top, as the block there is tinted)
|
||||
match base.and_then(|b| {
|
||||
self.world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.map(|c| c.biome(x, b - 1, z))
|
||||
}) {
|
||||
Some(id) => {
|
||||
let b = biomes.get(id);
|
||||
tints.extend([
|
||||
biomes.grass(id, f64::from(x), f64::from(z)),
|
||||
b.foliage,
|
||||
b.water,
|
||||
]);
|
||||
}
|
||||
None => tints.extend([0, 0, 0]),
|
||||
}
|
||||
}
|
||||
Ok(ColumnBlocks {
|
||||
palette,
|
||||
bases,
|
||||
blocks,
|
||||
tints,
|
||||
})
|
||||
}
|
||||
|
||||
/// One above the highest block of column (x, z) that counts on heightmap `h`, or the bottom of the column (also
|
||||
/// where it is not loaded).
|
||||
fn edit_height(&self, h: Heightmap, x: i32, z: i32) -> i32 {
|
||||
let properties = &self.pack.assets.properties;
|
||||
column_height(
|
||||
&self.world,
|
||||
h,
|
||||
(x, z),
|
||||
|s| self.res.facts().is_air(s),
|
||||
|t, s| t.is_opaque(properties.heightmap_state(s)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The most blocks one `read_columns` reads (2 MiB of indices): it runs on the
|
||||
/// server tick, so a mod reads a big area in pieces.
|
||||
pub(super) const MAX_READ: usize = 1 << 20;
|
||||
/// The most columns one `surface_heights` reads.
|
||||
pub(super) const MAX_COLUMNS: usize = 1 << 16;
|
||||
|
||||
/// The blocks a column read of `columns` columns, `below` + `above` each,
|
||||
/// reads, or why it can't: a span of 0 or more than `height` (the level's),
|
||||
/// or the whole more than `MAX_READ`.
|
||||
fn read_size(columns: usize, below: u32, above: u32, height: i32) -> Result<usize, String> {
|
||||
let per = below
|
||||
.checked_add(above)
|
||||
.filter(|&per| per > 0 && i64::from(per) <= i64::from(height))
|
||||
.ok_or_else(|| format!("below + above must be 1 to {height}, the level's height"))?;
|
||||
columns
|
||||
.checked_mul(per as usize)
|
||||
.filter(|&n| n <= MAX_READ)
|
||||
.ok_or_else(|| {
|
||||
format!("the read is more than {MAX_READ} blocks: read fewer columns a call")
|
||||
})
|
||||
}
|
||||
|
||||
/// One above the highest block of column `(x, z)` on heightmap `h`, or the
|
||||
/// bottom of the column (also where it is not loaded): `air` says which
|
||||
/// blocks the world surface passes over, and `counts(kind, state)` which
|
||||
/// count on the other heightmaps.
|
||||
fn column_height(
|
||||
world: &World,
|
||||
h: Heightmap,
|
||||
(x, z): (i32, i32),
|
||||
air: impl Fn(BlockStateId) -> bool,
|
||||
counts: impl Fn(HeightmapType, BlockStateId) -> bool,
|
||||
) -> i32 {
|
||||
if h == Heightmap::WorldSurface {
|
||||
return world.world_surface(x, z, air);
|
||||
}
|
||||
// (as `rain_height` reads it: both others count only blocks that block motion, so they are at or under it)
|
||||
let top = world.motion_blocking(x, z, |s| counts(HeightmapType::MotionBlocking, s));
|
||||
let kind = match h {
|
||||
Heightmap::MotionBlockingNoLeaves => HeightmapType::MotionBlockingNoLeaves,
|
||||
Heightmap::OceanFloor => HeightmapType::OceanFloor,
|
||||
_ => return top,
|
||||
};
|
||||
let bottom = world
|
||||
.chunks
|
||||
.get(&(x >> 4, z >> 4))
|
||||
.map_or(world.layout().min_y(), |c| c.min_y());
|
||||
let mut y = top;
|
||||
while y > bottom && !counts(kind, world.block(x, y - 1, z)) {
|
||||
y -= 1;
|
||||
}
|
||||
y
|
||||
}
|
||||
|
||||
/// `height(x, z)` of each column, none where its chunk is not loaded. (A
|
||||
/// chunk is looked up once for a run of columns in it.)
|
||||
fn loaded_heights(
|
||||
world: &World,
|
||||
columns: &[(i32, i32)],
|
||||
height: impl Fn(i32, i32) -> i32,
|
||||
) -> Vec<Option<i32>> {
|
||||
let mut last: Option<((i32, i32), bool)> = None;
|
||||
columns
|
||||
.iter()
|
||||
.map(|&(x, z)| {
|
||||
let key = (x >> 4, z >> 4);
|
||||
let loaded = match last {
|
||||
Some((k, l)) if k == key => l,
|
||||
_ => {
|
||||
let l = world.chunks.get(&key).is_some();
|
||||
last = Some((key, l));
|
||||
l
|
||||
}
|
||||
};
|
||||
loaded.then(|| height(x, z))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The blocks of each column from `below` under its base to `above` - 1 over
|
||||
/// it, bottom first, as indices into the names (`name`) seen: air outside the
|
||||
/// column's chunk's own height and where there is no base. None, reading
|
||||
/// nothing, past `read_size`'s bound.
|
||||
fn column_blocks(
|
||||
world: &World,
|
||||
columns: &[(i32, i32)],
|
||||
bases: &[Option<i32>],
|
||||
below: u32,
|
||||
above: u32,
|
||||
name: impl Fn(BlockStateId) -> Arc<str>,
|
||||
) -> Option<(Vec<String>, Vec<u16>)> {
|
||||
let total = read_size(
|
||||
columns.len(),
|
||||
below,
|
||||
above,
|
||||
ColumnLayout::HIGHEST_Y + 1 - ColumnLayout::LOWEST_Y,
|
||||
)
|
||||
.ok()?;
|
||||
let per = (below + above) as usize;
|
||||
let mut blocks = Vec::with_capacity(total);
|
||||
let mut palette: Vec<String> = Vec::new();
|
||||
// (a state's index into the names, worked out once a state)
|
||||
let mut seen: FxHashMap<u32, u16> = FxHashMap::default();
|
||||
let mut index = |s: BlockStateId| -> u16 {
|
||||
*seen.entry(s.0).or_insert_with(|| {
|
||||
let name = name(s);
|
||||
let i = palette
|
||||
.iter()
|
||||
.position(|n| **n == *name)
|
||||
.unwrap_or_else(|| {
|
||||
palette.push(name.to_string());
|
||||
palette.len() - 1
|
||||
});
|
||||
i as u16
|
||||
})
|
||||
};
|
||||
for (&(x, z), base) in columns.iter().zip(bases) {
|
||||
let chunk = world.chunks.get(&(x >> 4, z >> 4));
|
||||
match (base, chunk) {
|
||||
(Some(b), Some(chunk)) => {
|
||||
let (bottom, top) = (chunk.min_y(), chunk.max_y());
|
||||
let y0 = i64::from(*b) - i64::from(below);
|
||||
for y in y0..y0 + per as i64 {
|
||||
let s = match i32::try_from(y) {
|
||||
Ok(y) if (bottom..top).contains(&y) => chunk.block(x & 15, y, z & 15),
|
||||
_ => world.air,
|
||||
};
|
||||
blocks.push(index(s));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let air = index(world.air);
|
||||
blocks.extend(std::iter::repeat_n(air, per));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((palette, blocks))
|
||||
}
|
||||
|
||||
/// `edit`'s runs as `World::edit_runs` takes them: each palette entry `block`
|
||||
/// as its state in `palette`, each `y` counted from `height(surface, x, z)`
|
||||
/// when the run names a heightmap (read before any change, once a column).
|
||||
/// Err, with nothing resolved, for a run naming a palette entry or a filter
|
||||
/// past the end, or for more filters than a run can name (65536).
|
||||
fn resolve_runs(
|
||||
edit: &BlockEdit,
|
||||
palette: &[u32],
|
||||
filters: usize,
|
||||
mut height: impl FnMut(Heightmap, i32, i32) -> i32,
|
||||
) -> Result<Vec<Run>, String> {
|
||||
if filters > usize::from(u16::MAX) + 1 {
|
||||
return Err(format!("{filters} filters: 65536 at most"));
|
||||
}
|
||||
let mut heights: FxHashMap<(i32, i32, u8), i32> = FxHashMap::default();
|
||||
let mut runs = Vec::with_capacity(edit.runs.len());
|
||||
for (i, r) in edit.runs.iter().enumerate() {
|
||||
let Some(&state) = usize::try_from(r.block).ok().and_then(|b| palette.get(b)) else {
|
||||
return Err(format!(
|
||||
"run {i}: palette entry {} of {}",
|
||||
r.block,
|
||||
palette.len()
|
||||
));
|
||||
};
|
||||
let only = match r.only {
|
||||
None => None,
|
||||
Some(f) if (f as usize) < filters => Some(f as u16),
|
||||
Some(f) => return Err(format!("run {i}: filter {f} of {filters}")),
|
||||
};
|
||||
if r.height == 0 {
|
||||
continue;
|
||||
}
|
||||
let base = match r.surface {
|
||||
None => i64::from(r.y),
|
||||
Some(h) => {
|
||||
let top = *heights
|
||||
.entry((r.x, r.z, h as u8))
|
||||
.or_insert_with(|| height(h, r.x, r.z));
|
||||
i64::from(top) + i64::from(r.y)
|
||||
}
|
||||
};
|
||||
let clamp = |v: i64| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
|
||||
runs.push(Run {
|
||||
x: r.x,
|
||||
z: r.z,
|
||||
y0: clamp(base),
|
||||
y1: clamp(base + i64::from(r.height) - 1),
|
||||
state,
|
||||
only,
|
||||
});
|
||||
}
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
/// What `fill ... strict` does for the block entities and the watched blocks
|
||||
/// of `tracked`'s changes: the watched writes recorded, the block entities
|
||||
/// told of their new state, and where a different block replaced the block,
|
||||
/// its block entity removed (as `place_state` does) and `new_entity(name)`'s,
|
||||
/// if any, put in its place. Returns the positions whose block was replaced
|
||||
/// (a jukebox there stops).
|
||||
/// A block entity for each new block, in order: made for each position, as vanilla calls
|
||||
/// `EntityBlock.newBlockEntity` for each (a mod's may number its instances). A block that makes none (stone) is
|
||||
/// asked once.
|
||||
fn make_block_entities(
|
||||
names: &[Arc<str>],
|
||||
mut make: impl FnMut(&str) -> Option<BlockEntity>,
|
||||
) -> Vec<Option<BlockEntity>> {
|
||||
let mut none: FxHashSet<&str> = FxHashSet::default();
|
||||
names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
if none.contains(&**n) {
|
||||
return None;
|
||||
}
|
||||
let entity = make(n);
|
||||
if entity.is_none() {
|
||||
none.insert(n);
|
||||
}
|
||||
entity
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn replace_block_entities(
|
||||
world: &mut World,
|
||||
tracked: &[Tracked],
|
||||
name: impl Fn(u32) -> Arc<str>,
|
||||
mut new_entity: impl FnMut(&str) -> Option<BlockEntity>,
|
||||
) -> Vec<[i32; 3]> {
|
||||
let mut replaced = Vec::new();
|
||||
for &((x, y, z), old, new) in tracked {
|
||||
let watches = |id: u32| world.watched.get(id as usize).copied().unwrap_or(false);
|
||||
if watches(old) || watches(new) {
|
||||
world.watched_writes.push(((x, y, z), old, new));
|
||||
}
|
||||
world.block_entities.state_changed((x, y, z), old, new);
|
||||
let (was, now) = (name(old), name(new));
|
||||
if was != now {
|
||||
replaced.push([x, y, z]);
|
||||
world.block_entities.remove(&(x, y, z));
|
||||
if let Some(entity) = new_entity(&now) {
|
||||
world.block_entities.insert((x, y, z), entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
replaced
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::game::native_world_edit::NativeBlockRun as BlockRun;
|
||||
use horizon_client::chunk::Chunk;
|
||||
|
||||
fn run(block: u32, only: Option<u32>) -> BlockRun {
|
||||
BlockRun {
|
||||
x: 1,
|
||||
z: 2,
|
||||
y: 3,
|
||||
height: 4,
|
||||
surface: None,
|
||||
block,
|
||||
only,
|
||||
}
|
||||
}
|
||||
fn edit(runs: Vec<BlockRun>) -> BlockEdit {
|
||||
BlockEdit {
|
||||
palette: vec!["stone".into(), "air".into()],
|
||||
filters: vec!["#dirt".into()],
|
||||
runs,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_naming_entries_past_the_end_are_refused_whole() {
|
||||
let palette = [1, 0];
|
||||
let ok = resolve_runs(&edit(vec![run(1, Some(0))]), &palette, 1, |_, _, _| 0).unwrap();
|
||||
assert_eq!(
|
||||
ok,
|
||||
[Run {
|
||||
x: 1,
|
||||
z: 2,
|
||||
y0: 3,
|
||||
y1: 6,
|
||||
state: 0,
|
||||
only: Some(0),
|
||||
}]
|
||||
);
|
||||
// a bad run after a good one: nothing at all, and no height read
|
||||
let mut asked = 0;
|
||||
let mut bad = |runs| {
|
||||
resolve_runs(&edit(runs), &palette, 1, |_, _, _| {
|
||||
asked += 1;
|
||||
0
|
||||
})
|
||||
};
|
||||
assert!(bad(vec![run(0, None), run(2, None)]).is_err());
|
||||
assert!(bad(vec![run(0, None), run(u32::MAX, None)]).is_err());
|
||||
assert!(bad(vec![run(0, Some(1))]).is_err());
|
||||
assert!(bad(vec![run(0, Some(u32::MAX))]).is_err());
|
||||
assert_eq!(asked, 0);
|
||||
// more filters than a run's u16 names
|
||||
assert!(resolve_runs(&edit(vec![]), &palette, 65_537, |_, _, _| 0).is_err());
|
||||
assert!(resolve_runs(&edit(vec![]), &palette, 65_536, |_, _, _| 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_counts_from_its_heightmap_read_once_a_column() {
|
||||
let mut r = run(0, None);
|
||||
r.surface = Some(Heightmap::OceanFloor);
|
||||
r.y = -2;
|
||||
let mut asked = Vec::new();
|
||||
let runs = resolve_runs(&edit(vec![r, r]), &[7], 0, |h, x, z| {
|
||||
asked.push((h, x, z));
|
||||
70
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(asked, [(Heightmap::OceanFloor, 1, 2)]);
|
||||
assert_eq!((runs[0].y0, runs[0].y1, runs[0].state), (68, 71, 7));
|
||||
assert_eq!(runs[1], runs[0]);
|
||||
// a run far past the world's height clamps rather than wraps
|
||||
let mut high = run(0, None);
|
||||
high.y = i32::MAX;
|
||||
high.height = u32::MAX;
|
||||
let runs = resolve_runs(&edit(vec![high]), &[7], 0, |_, _, _| 0).unwrap();
|
||||
assert_eq!((runs[0].y0, runs[0].y1), (i32::MAX, i32::MAX));
|
||||
}
|
||||
|
||||
fn world() -> World {
|
||||
let mut w = World::new(BlockStateId(0), 0);
|
||||
w.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
w
|
||||
}
|
||||
fn names(s: u32) -> Arc<str> {
|
||||
match s {
|
||||
0 => "minecraft:air",
|
||||
1 => "minecraft:stone",
|
||||
2 => "minecraft:chest",
|
||||
_ => "machines:press",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chest_replaced_by_stone_loses_its_block_entity() {
|
||||
let mut w = world();
|
||||
w.set(1, 5, 1, BlockStateId(2)).unwrap();
|
||||
let chest = BlockEntity::for_block("minecraft:chest").expect("a chest's");
|
||||
w.block_entities.insert((1, 5, 1), chest);
|
||||
let out = w.edit_runs(
|
||||
&[Run {
|
||||
x: 1,
|
||||
z: 1,
|
||||
y0: 4,
|
||||
y1: 6,
|
||||
state: 1,
|
||||
only: None,
|
||||
}],
|
||||
|_, _| true,
|
||||
|s| s == 2,
|
||||
);
|
||||
assert_eq!(out.tracked, [((1, 5, 1), 2, 1)]);
|
||||
let replaced = replace_block_entities(&mut w, &out.tracked, names, BlockEntity::for_block);
|
||||
assert_eq!(replaced, [[1, 5, 1]]);
|
||||
assert!(w.block_entities.get(&(1, 5, 1)).is_none());
|
||||
assert_eq!(w.block(1, 5, 1).0, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_block_gets_the_block_entity_made_for_it() {
|
||||
let mut w = world();
|
||||
let out = w.edit_runs(
|
||||
&[Run {
|
||||
x: 3,
|
||||
z: 3,
|
||||
y0: 10,
|
||||
y1: 10,
|
||||
state: 3,
|
||||
only: None,
|
||||
}],
|
||||
|_, _| true,
|
||||
|s| s == 3,
|
||||
);
|
||||
// a mod block's, as `Game::new_block_entity` makes it
|
||||
let made = |n: &str| {
|
||||
(n == "machines:press").then(|| {
|
||||
BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity {
|
||||
id: "machines:press".into(),
|
||||
..Default::default()
|
||||
}))
|
||||
})
|
||||
};
|
||||
replace_block_entities(&mut w, &out.tracked, names, made);
|
||||
assert!(matches!(
|
||||
w.block_entities.get(&(3, 10, 3)),
|
||||
Some(BlockEntity::Mod(e)) if e.id == "machines:press"
|
||||
));
|
||||
}
|
||||
|
||||
/// air 0, stone 1, leaves 2, water 3: column (1, 1) stone to 60, water, then leaves to 64; column (2, 2)
|
||||
/// stone to 60, leaves, then water to 64; at (3, 3) stone at the top of the world.
|
||||
fn columns_world() -> World {
|
||||
let mut w = world();
|
||||
for (x, z, low, high) in [(1, 1, 3, 2), (2, 2, 2, 3)] {
|
||||
for y in -64..=64 {
|
||||
let s = match y {
|
||||
..=60 => 1,
|
||||
61..=62 => low,
|
||||
_ => high,
|
||||
};
|
||||
w.set(x, y, z, BlockStateId(s)).unwrap();
|
||||
}
|
||||
}
|
||||
w.set(3, 319, 3, BlockStateId(1)).unwrap();
|
||||
w
|
||||
}
|
||||
fn height(w: &World, h: Heightmap, x: i32, z: i32) -> i32 {
|
||||
column_height(
|
||||
w,
|
||||
h,
|
||||
(x, z),
|
||||
|s| s.0 == 0,
|
||||
|kind, s| match kind {
|
||||
HeightmapType::MotionBlockingNoLeaves => matches!(s.0, 1 | 3),
|
||||
HeightmapType::OceanFloor => matches!(s.0, 1 | 2),
|
||||
_ => s.0 != 0,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_heightmap_has_its_own_height() {
|
||||
let w = columns_world();
|
||||
assert_eq!(height(&w, Heightmap::WorldSurface, 1, 1), 65);
|
||||
assert_eq!(height(&w, Heightmap::MotionBlocking, 1, 1), 65);
|
||||
assert_eq!(height(&w, Heightmap::MotionBlockingNoLeaves, 1, 1), 63);
|
||||
assert_eq!(height(&w, Heightmap::OceanFloor, 1, 1), 65);
|
||||
assert_eq!(height(&w, Heightmap::OceanFloor, 2, 2), 63);
|
||||
assert_eq!(height(&w, Heightmap::MotionBlockingNoLeaves, 2, 2), 65);
|
||||
// an empty column, and one not loaded: the bottom
|
||||
assert_eq!(height(&w, Heightmap::OceanFloor, 5, 5), -64);
|
||||
assert_eq!(height(&w, Heightmap::WorldSurface, 100, 100), -64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_not_loaded_have_no_height() {
|
||||
let w = columns_world();
|
||||
let heights = loaded_heights(&w, &[(1, 1), (100, 100), (2, 2), (-1, 0)], |x, z| {
|
||||
height(&w, Heightmap::OceanFloor, x, z)
|
||||
});
|
||||
assert_eq!(heights, [Some(65), None, Some(63), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_blocks_read_air_past_the_column_and_where_not_loaded() {
|
||||
let w = columns_world();
|
||||
let (palette, blocks) = column_blocks(
|
||||
&w,
|
||||
&[(1, 1), (3, 3), (100, 100)],
|
||||
&[Some(65), Some(319), None],
|
||||
2,
|
||||
3,
|
||||
|s| names(s.0),
|
||||
)
|
||||
.unwrap();
|
||||
let read: Vec<&str> = blocks.iter().map(|&i| &*palette[i as usize]).collect();
|
||||
// (`names` calls state 2 a chest: here it stands for leaves)
|
||||
let (leaves, stone, air) = ("minecraft:chest", "minecraft:stone", "minecraft:air");
|
||||
assert_eq!(
|
||||
read,
|
||||
[
|
||||
leaves, leaves, air, air, air, // (1, 1) from 63
|
||||
air, air, stone, air, air, // (3, 3) from 317: stone at 319, nothing over 319
|
||||
air, air, air, air, air, // not loaded
|
||||
]
|
||||
);
|
||||
// names once each
|
||||
assert_eq!(palette.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_is_bounded() {
|
||||
assert_eq!(read_size(10, 200, 184, 384), Ok(3840));
|
||||
assert!(
|
||||
read_size(1, 200, 185, 384).is_err(),
|
||||
"more than the level's height"
|
||||
);
|
||||
assert!(read_size(1, 0, 0, 384).is_err(), "an empty span");
|
||||
assert!(read_size(1, u32::MAX, 1, 4064).is_err());
|
||||
assert!(read_size(1 << 14, 100, 0, 384).is_err(), "over MAX_READ");
|
||||
assert!(read_size(usize::MAX, 2, 0, 384).is_err());
|
||||
assert_eq!(read_size(1 << 12, 256, 0, 384), Ok(MAX_READ));
|
||||
let w = columns_world();
|
||||
assert!(column_blocks(&w, &[(1, 1)], &[Some(0)], u32::MAX, 2, |s| names(s.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_new_block_gets_its_own_block_entity() {
|
||||
// a mod's constructor numbering its instances; stone makes none
|
||||
let mut calls = Vec::new();
|
||||
let made = make_block_entities(
|
||||
&[
|
||||
"machines:press",
|
||||
"minecraft:stone",
|
||||
"machines:press",
|
||||
"minecraft:stone",
|
||||
]
|
||||
.map(Arc::<str>::from),
|
||||
|n| {
|
||||
calls.push(n.to_string());
|
||||
(n == "machines:press").then(|| {
|
||||
BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity {
|
||||
id: format!("press-{}", calls.len()),
|
||||
..Default::default()
|
||||
}))
|
||||
})
|
||||
},
|
||||
);
|
||||
let ids: Vec<Option<String>> = made
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Some(BlockEntity::Mod(e)) => Some(e.id.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
[
|
||||
Some("press-1".to_string()),
|
||||
None,
|
||||
Some("press-3".to_string()),
|
||||
None
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
calls,
|
||||
["machines:press", "minecraft:stone", "machines:press"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,6 @@ impl Game<'_> {
|
||||
self.world.block_entities.set_mod_containers(containers);
|
||||
let fluids = self.native_fluid_tables();
|
||||
self.world.block_entities.set_mod_fluids(fluids);
|
||||
let energy = self.native_energy_tables();
|
||||
self.world.block_entities.set_mod_energy(energy);
|
||||
}
|
||||
// Only lingering cooldowns are dropped; a hopper that ticks checks
|
||||
// its own block. Once a second is soon enough for the rest.
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
//! The native parity harness runs the actual client boat ticker without a
|
||||
//! renderer, on an empty world; movement ownership changes between ticks.
|
||||
use super::{Game, boats::Boat};
|
||||
use horizon_client::Result;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Case {
|
||||
name: String,
|
||||
velocity: [f64; 3],
|
||||
spin: f32,
|
||||
owned: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn run_boat_authority_fixture(&mut self, directory: &Path) -> Result<()> {
|
||||
let cases: Vec<Case> =
|
||||
serde_json::from_str(&std::fs::read_to_string(directory.join("inputs.json"))?)?;
|
||||
let mut output = Vec::new();
|
||||
for case in cases {
|
||||
self.boats.clear();
|
||||
let mut boat = Boat::new(1, "oak".into(), Vec3::new(0.0, 100.0, 0.0), 0.0);
|
||||
let [x, y, z] = case.velocity;
|
||||
boat.velocity = Vec3::new(x, y, z);
|
||||
boat.delta_rotation = case.spin;
|
||||
self.boats.push(boat);
|
||||
let mut steps = Vec::new();
|
||||
for owned in case.owned {
|
||||
self.riding = owned.then_some(1);
|
||||
if owned {
|
||||
let boat = self.boats.remove(0);
|
||||
let boat = self.tick_boat(boat, true);
|
||||
self.boats.push(boat);
|
||||
}
|
||||
self.tick_remote_boat_bubbles();
|
||||
let boat = &self.boats[0];
|
||||
steps.push(json!({
|
||||
"owned": owned,
|
||||
"spin": format!("{:08x}", boat.delta_rotation.to_bits()),
|
||||
"velocity": boat.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
|
||||
}));
|
||||
}
|
||||
output.push(json!({"name": case.name, "steps": steps}));
|
||||
}
|
||||
std::fs::write(directory.join("rust.json"), serde_json::to_vec(&output)?)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,8 @@ pub(super) struct Boat {
|
||||
above_bubble_column: bool,
|
||||
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
|
||||
bubble_down: bool,
|
||||
/// The server asked a rider whose client moves it (a player) to get out:
|
||||
/// `guest_vehicle_tick` honors it (`Entity.ejectPassengers`).
|
||||
/// A bubble column's drag asked a rider the server can't move itself (a
|
||||
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
|
||||
pub eject_riders: bool,
|
||||
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
|
||||
tick_count: i32,
|
||||
@@ -101,17 +101,6 @@ pub(super) const BUBBLE_UP_EVENT: i8 = 72;
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
/// `AbstractBoat.tick`: passengers lose control after 60 consecutive
|
||||
/// underwater ticks; any other status resets the counter.
|
||||
fn tick_submersion(&mut self) {
|
||||
self.out_of_control =
|
||||
if matches!(self.status, Status::UnderWater | Status::UnderFlowingWater) {
|
||||
self.out_of_control + 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
|
||||
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -570,7 +559,11 @@ impl Game<'_> {
|
||||
}
|
||||
b.old_status = b.status;
|
||||
b.status = self.boat_status(&mut b);
|
||||
b.tick_submersion();
|
||||
b.out_of_control = if matches!(b.status, Status::UnderWater | Status::UnderFlowingWater) {
|
||||
b.out_of_control + 1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
if !controlled {
|
||||
@@ -720,14 +713,9 @@ impl Game<'_> {
|
||||
pub(super) fn tick_boats(&mut self) {
|
||||
// ServerLevel.tick: only entities in entity-ticking chunks tick.
|
||||
let ticking = self.entity_ticking_area();
|
||||
let mut round = self.native_tick_round();
|
||||
// A server player's boat is driven by its client (lan_vehicles.rs).
|
||||
let driven = self.guest_vehicles();
|
||||
for i in 0..self.boats.len() {
|
||||
// A mod's hook may have removed boats.
|
||||
if i >= self.boats.len() {
|
||||
break;
|
||||
}
|
||||
let p = self.boats[i].position;
|
||||
if Some(self.boats[i].id) == self.riding
|
||||
|| driven.contains(&self.boats[i].id)
|
||||
@@ -741,25 +729,8 @@ impl Game<'_> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let id = self.boats[i].id;
|
||||
let kind = if round.listening() {
|
||||
self.boats[i].entity_type()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if !self.native_tick_pre_one(&mut round, id, &kind) {
|
||||
continue;
|
||||
}
|
||||
// The hook may have removed boats.
|
||||
let Some(i) = Some(i)
|
||||
.filter(|&i| self.boats.get(i).is_some_and(|b| b.id == id))
|
||||
.or_else(|| self.boats.iter().position(|b| b.id == id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
self.native_tick_post_one(&round, id, &kind);
|
||||
}
|
||||
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
|
||||
// boat, the client-driven ones too.
|
||||
@@ -767,7 +738,6 @@ impl Game<'_> {
|
||||
while i < self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
let skip = Some(self.boats[i].id) == self.riding
|
||||
|| round.canceled(self.boats[i].id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32);
|
||||
if skip || !self.boat_lava_tick(i) {
|
||||
i += 1;
|
||||
@@ -778,17 +748,11 @@ impl Game<'_> {
|
||||
// pushes what it touches.
|
||||
for i in 0..self.boats.len() {
|
||||
let p = self.boats[i].position;
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) || round.canceled(self.boats[i].id)
|
||||
{
|
||||
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
continue;
|
||||
}
|
||||
// `AbstractBoat.tick` counts submersion on the server even when
|
||||
// its controlling client owns the boat's movement.
|
||||
if driven.contains(&self.boats[i].id) {
|
||||
self.refresh_boat_status(i);
|
||||
}
|
||||
if self.boats[i].out_of_control >= 60.0 {
|
||||
self.eject_boat_riders(i);
|
||||
self.eject_boat_mobs(i, false);
|
||||
}
|
||||
self.boat_bubble_tick(i);
|
||||
self.boat_contacts(i);
|
||||
@@ -812,7 +776,6 @@ impl Game<'_> {
|
||||
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
|
||||
let status = self.boat_status(&mut probe);
|
||||
self.boats[i].status = status;
|
||||
self.boats[i].tick_submersion();
|
||||
}
|
||||
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
|
||||
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
|
||||
@@ -919,8 +882,8 @@ impl Game<'_> {
|
||||
v.y = 0.6;
|
||||
}
|
||||
}
|
||||
/// `Entity.ejectPassengers`: the mobs get out here, this window's player
|
||||
/// gets out, and a server's player is asked to
|
||||
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
|
||||
/// window's player gets out, and a server's player is asked to
|
||||
/// (`guest_vehicle_tick` lets it out through its client).
|
||||
fn eject_boat_riders(&mut self, i: usize) {
|
||||
self.eject_boat_mobs(i, false);
|
||||
@@ -955,6 +918,10 @@ impl Game<'_> {
|
||||
/// window is its own client.
|
||||
fn boat_bubble_tick(&mut self, i: usize) {
|
||||
let id = self.boats[i].id;
|
||||
// The boat a server's player drives is not ticked here: its status is.
|
||||
if self.guest_vehicles().contains(&id) {
|
||||
self.refresh_boat_status(i);
|
||||
}
|
||||
// An ejection nobody took (its player left the boat first) is void.
|
||||
if !self.guest_rides(id) {
|
||||
self.boats[i].eject_riders = false;
|
||||
@@ -1070,18 +1037,7 @@ impl Game<'_> {
|
||||
/// `getDismountLocationForPassenger`: in front of the player if there is
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
let id = self.boats[i].id;
|
||||
if self.riding == Some(id) && !self.native_may_leave(id) {
|
||||
return;
|
||||
}
|
||||
self.riding = None;
|
||||
// `AbstractBoat.tick`'s non-authoritative branch: the copy of a boat
|
||||
// that no player of this side drives drops its cached motion
|
||||
// (`setDeltaMovement(Vec3.ZERO)`, `deltaRotation = 0`) and is not
|
||||
// moved, so a boat is at a standstill when its rider gets out instead
|
||||
// of coasting on the velocity of the ride that just ended.
|
||||
self.boats[i].velocity = Vec3::ZERO;
|
||||
self.boats[i].delta_rotation = 0.0;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
@@ -1270,9 +1226,6 @@ impl Game<'_> {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !self.native_may_board(self.boats[i].id) {
|
||||
return true;
|
||||
}
|
||||
self.riding = Some(self.boats[i].id);
|
||||
self.yaw = self.boats[i].yaw;
|
||||
// ServerPlayer.startRiding.
|
||||
@@ -1338,8 +1291,6 @@ impl Game<'_> {
|
||||
let (damage, _, _, magic) = self.melee_damage(&kind, false);
|
||||
self.hurt_boat(i, damage, self.creative);
|
||||
self.vehicle_attack_sounds(area, magic.then_some(id));
|
||||
// Player.onAttack: the hit starts the attack strength again.
|
||||
self.attack_ticks = 0;
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
|
||||
Loaded 100 of 466 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user