mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
179
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e06612e08 | ||
|
|
15c0246614 | ||
|
|
face534db3 | ||
|
|
0a6eca805d | ||
|
|
ed0be44325 | ||
|
|
97e46ce1d5 | ||
|
|
5669d66de3 | ||
|
|
f54fba8726 | ||
|
|
f86b98b203 | ||
|
|
9842e75046 | ||
|
|
99956fff22 | ||
|
|
b23ad347d1 | ||
|
|
350e156a34 | ||
|
|
502c15aba6 | ||
|
|
147980da63 | ||
|
|
97e99a45b4 | ||
|
|
37cda82c81 | ||
|
|
c76ed64570 | ||
|
|
f63ad014e1 | ||
|
|
7b33d702f5 | ||
|
|
dfae932fec | ||
|
|
bf3a5f162f | ||
|
|
14fc6d55e0 | ||
|
|
4e6a8131c2 | ||
|
|
01451da341 | ||
|
|
179ebb840a | ||
|
|
fca63b5eb8 | ||
|
|
85d56215e6 | ||
|
|
b13dbf0687 | ||
|
|
0cdb230b54 | ||
|
|
6e919e4356 | ||
|
|
92cf9ac888 | ||
|
|
0961939b71 | ||
|
|
2cd05e462e | ||
|
|
541f331f1e | ||
|
|
973287971d | ||
|
|
7648d874b9 | ||
|
|
b91f5f5892 | ||
|
|
f159a106a8 | ||
|
|
c805ee70c5 | ||
|
|
14bfb672f8 | ||
|
|
43832ec794 | ||
|
|
bc6016d19d | ||
|
|
40e3f368da | ||
|
|
b399f0ddc5 | ||
|
|
a3fd3597f4 | ||
|
|
1d9f455dac | ||
|
|
f97dffe988 | ||
|
|
fee1085bec | ||
|
|
272518c1f2 | ||
|
|
b95e34a990 | ||
|
|
e86ce41888 | ||
|
|
458f0e4134 | ||
|
|
29b3374b5d | ||
|
|
02042bf6b4 | ||
|
|
79e277451b | ||
|
|
e5c1ea0612 | ||
|
|
2f39e246fe | ||
|
|
2cd9e12ecd | ||
|
|
6d32a88a50 | ||
|
|
124e88e731 | ||
|
|
1a004614f8 | ||
|
|
1edf540479 | ||
|
|
9bccc1056f | ||
|
|
d144af4a11 | ||
|
|
616707f3d7 | ||
|
|
1c5744ac24 | ||
|
|
c124718cbc | ||
|
|
0b8f18dd40 | ||
|
|
e51c277db7 | ||
|
|
fc61e4e9ff | ||
|
|
2c300290d5 | ||
|
|
16260920c3 | ||
|
|
d84428c781 | ||
|
|
e5f23f8b40 | ||
|
|
17e4124d04 | ||
|
|
37eafb6c1b | ||
|
|
c56e9312ee | ||
|
|
9f52bf9a83 | ||
|
|
cf0b0d02c8 | ||
|
|
1b93764780 | ||
|
|
52aad2205d | ||
|
|
9bc110bae6 | ||
|
|
83535ddcc1 | ||
|
|
2c672eae2d | ||
|
|
796047f1e8 | ||
|
|
2dbcb75cfa | ||
|
|
34f528ffcb | ||
|
|
7f6f26a33a | ||
|
|
3d8924dcc8 | ||
|
|
8ad82f9ac0 | ||
|
|
77290ac3c1 | ||
|
|
daa43de927 | ||
|
|
3fb98b187f | ||
|
|
76db917ded | ||
|
|
37b79d1b63 | ||
|
|
702b0895bd | ||
|
|
4465238c9c | ||
|
|
43943df1ef | ||
|
|
8906102eb9 | ||
|
|
0a51d6dc5c | ||
|
|
21ca2fdd1d | ||
|
|
89a515a8d7 | ||
|
|
a0f9368edf | ||
|
|
1f86e14b9a | ||
|
|
5ec521bb7b | ||
|
|
80bbced3fc | ||
|
|
c30b863128 | ||
|
|
690d434afa | ||
|
|
f57811dd0d | ||
|
|
73e054f827 | ||
|
|
e1434f2ee4 | ||
|
|
67a39ecaac | ||
|
|
199d9d74f8 | ||
|
|
1005076a59 | ||
|
|
8189734106 | ||
|
|
1f29c88a49 | ||
|
|
bf0f0dd811 | ||
|
|
007458a627 | ||
|
|
9c30028167 | ||
|
|
0d49b282c0 | ||
|
|
099fb70e6a | ||
|
|
c7aef39dd5 | ||
|
|
d7c3fefcfa | ||
|
|
8b48b0d74e | ||
|
|
5664a950e8 | ||
|
|
538422f3c7 | ||
|
|
b528247824 | ||
|
|
e8da3a8e21 | ||
|
|
3004b64927 | ||
|
|
abdf311e3b | ||
|
|
f96e0fba0f | ||
|
|
653a4a99fe | ||
|
|
65c35da0dc | ||
|
|
772088790c | ||
|
|
ad475b0573 | ||
|
|
aa019cfaa4 | ||
|
|
9e962f6350 | ||
|
|
78ea4b6d02 | ||
|
|
596fda5618 | ||
|
|
46b257b1d5 | ||
|
|
8b0c8e40a6 | ||
|
|
d980cd810d | ||
|
|
196bea6f1a | ||
|
|
cdabdddb6b | ||
|
|
78efabcfa7 | ||
|
|
8d783c0ff0 | ||
|
|
cbe9ddfa3f | ||
|
|
749c5405f6 | ||
|
|
bc8912c13f | ||
|
|
40ad3a91c9 | ||
|
|
a61825404a | ||
|
|
aa2220fcf6 | ||
|
|
d94fdae0b9 | ||
|
|
d1dd934be2 | ||
|
|
a95c0e022d | ||
|
|
2f3743d282 | ||
|
|
1eb3b7f6c3 | ||
|
|
36add718c0 | ||
|
|
077164ff2f | ||
|
|
c953fe79cb | ||
|
|
92282c7476 | ||
|
|
052c2eb962 | ||
|
|
fdc056c8ac | ||
|
|
e8a5564059 | ||
|
|
70a0a29eec | ||
|
|
5cffcf1370 | ||
|
|
ff28824d42 | ||
|
|
45978aaf67 | ||
|
|
24fa04d753 | ||
|
|
1429b27098 | ||
|
|
c78882c7da | ||
|
|
bb22bf6570 | ||
|
|
c7f54b7461 | ||
|
|
199e8193fd | ||
|
|
55a54edebe | ||
|
|
c9f2efa210 | ||
|
|
8e0b157254 | ||
|
|
d689881e68 |
No files matched your search
@@ -46,6 +46,21 @@ thread_local! {
|
||||
}
|
||||
/// The current language's `bidirectional`, read for every drawn string.
|
||||
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
|
||||
/// Counts the languages set (`set`): another count is another language, so text looked up
|
||||
/// before it may read otherwise.
|
||||
static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
thread_local! {
|
||||
/// Counts the scopes this thread entered and left (`scoped`), which no other thread sees.
|
||||
static SCOPE_GENERATION: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
/// Which language text looks up in now, on this thread, as a count that changes whenever that
|
||||
/// can: both counts only grow, so their sum changes when either does.
|
||||
pub fn generation() -> u64 {
|
||||
GENERATION.load(Ordering::Relaxed) + SCOPE_GENERATION.get()
|
||||
}
|
||||
fn scope_changed() {
|
||||
SCOPE_GENERATION.set(SCOPE_GENERATION.get() + 1);
|
||||
}
|
||||
|
||||
/// A source of resource files: the prepared root plus packs, highest priority last.
|
||||
pub trait ResourceSource {
|
||||
@@ -524,6 +539,7 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
|
||||
pub fn set(language: Language) {
|
||||
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
|
||||
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
|
||||
GENERATION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub fn current() -> Option<Arc<Language>> {
|
||||
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
|
||||
@@ -543,6 +559,7 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
|
||||
scope_changed();
|
||||
}
|
||||
}
|
||||
let outer = SCOPE.with(|s| {
|
||||
@@ -552,6 +569,7 @@ pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>)
|
||||
})
|
||||
});
|
||||
let _restore = Restore(outer);
|
||||
scope_changed();
|
||||
let result = f();
|
||||
let missed = SCOPE.with(|s| {
|
||||
s.borrow_mut()
|
||||
|
||||
@@ -47,6 +47,53 @@ struct Channel {
|
||||
world: u32,
|
||||
/// The sound file it started and when, to tell another player's copy of it.
|
||||
started: Option<(String, std::time::Instant)>,
|
||||
/// Its sound event plays clearly through a mod's muffle (`Audio::muffle`).
|
||||
unmuffled: bool,
|
||||
}
|
||||
/// The cutoff of the muffle's low-pass at full strength: the world heard through ringing ears.
|
||||
const MUFFLED_CUTOFF: f32 = 300.0;
|
||||
/// The cutoff it sweeps down from as its strength rises.
|
||||
const OPEN_CUTOFF: f32 = 20_000.0;
|
||||
/// How long the muffle takes to glide to a new level: a game tick's step (50 ms) would click.
|
||||
const MUFFLE_GLIDE: f32 = 0.02;
|
||||
/// A mod's muffle (`Audio::muffle`) in the mixer: the other sounds are mixed apart, low-passed by
|
||||
/// two one-pole stages and turned down, then added to the sounds it leaves clear. While it is off
|
||||
/// (never set, or faded out) the sounds mix straight into the output, as without it.
|
||||
#[derive(Default)]
|
||||
struct Muffle {
|
||||
/// `[amount, lowpass]`, each 0 to 1, as the game's tick last set them.
|
||||
target: [f32; 2],
|
||||
/// Where the output is now, gliding to `target`.
|
||||
current: [f32; 2],
|
||||
/// The low-pass stages' state for the left and right output channels.
|
||||
stages: [[f32; 2]; 2],
|
||||
/// The muffled sounds of one buffer, reused.
|
||||
bus: Vec<f32>,
|
||||
}
|
||||
impl Muffle {
|
||||
fn active(&self) -> bool {
|
||||
self.target != [0.0; 2] || self.current.iter().any(|v| *v > 1e-4)
|
||||
}
|
||||
/// Adds `bus` (the muffled sounds) to `data`, low-passed and turned down.
|
||||
fn apply(&mut self, bus: &[f32], data: &mut [f32], channels: usize, rate: u32) {
|
||||
let rate = rate as f32;
|
||||
let glide = 1.0 - (-1.0 / (MUFFLE_GLIDE * rate)).exp();
|
||||
for (frame, muffled) in data.chunks_mut(channels).zip(bus.chunks(channels)) {
|
||||
for (current, target) in self.current.iter_mut().zip(self.target) {
|
||||
*current += (target - *current) * glide;
|
||||
}
|
||||
let [amount, lowpass] = self.current;
|
||||
let gain = 1.0 - amount;
|
||||
let cutoff = OPEN_CUTOFF * (MUFFLED_CUTOFF / OPEN_CUTOFF).powf(lowpass);
|
||||
let a = 1.0 - (-std::f32::consts::TAU * cutoff / rate).exp();
|
||||
for ((out, s), stage) in frame.iter_mut().zip(muffled).zip(&mut self.stages) {
|
||||
stage[0] += (s - stage[0]) * a;
|
||||
stage[1] += (stage[0] - stage[1]) * a;
|
||||
// Dry at no strength, so a muffle that only turns sounds down leaves them as they were.
|
||||
*out += (s + (stage[1] - s) * lowpass) * gain;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Mixer {
|
||||
@@ -65,6 +112,8 @@ struct Mixer {
|
||||
cameras: Vec<([f64; 3], Basis, u32)>,
|
||||
/// Per-source gain/pan/step cache for one buffer, reused across callbacks.
|
||||
voices: Vec<(ChannelId, [f32; 3], f64)>,
|
||||
/// A mod's muffle of the sounds it does not exempt.
|
||||
muffle: Muffle,
|
||||
limiter_peak: f32,
|
||||
/// The output device the stream was opened on (`Library.currentDeviceName`).
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -100,6 +149,21 @@ pub struct Heard {
|
||||
pub range: f32,
|
||||
pub looping: bool,
|
||||
}
|
||||
/// A mod's muffle (`Audio::muffle`): its full strength, and the ticks it fades over and has left.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct MuffleFade {
|
||||
amount: f32,
|
||||
lowpass: f32,
|
||||
ticks: u32,
|
||||
left: u32,
|
||||
}
|
||||
impl MuffleFade {
|
||||
/// `[amount, lowpass]` now: full when set, falling in a straight line to none.
|
||||
fn level(&self) -> [f32; 2] {
|
||||
let left = self.left as f32 / self.ticks as f32;
|
||||
[self.amount * left, self.lowpass * left]
|
||||
}
|
||||
}
|
||||
/// The mixer side of the engine (`Backend`).
|
||||
pub struct MixerBackend {
|
||||
mixer: Arc<Mutex<Mixer>>,
|
||||
@@ -247,6 +311,8 @@ pub struct Audio {
|
||||
pub stopped: std::collections::BTreeMap<String, u32>,
|
||||
/// The volume sliders' `SoundPreviewHandler`.
|
||||
preview: crate::sound_preview::SoundPreview,
|
||||
/// A mod's muffle fading out (`muffle`), advanced by `tick`.
|
||||
muffle: Option<MuffleFade>,
|
||||
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: Option<String>,
|
||||
@@ -439,6 +505,7 @@ impl Backend for MixerBackend {
|
||||
owner: self.owner,
|
||||
world: self.world,
|
||||
started: None,
|
||||
unmuffled: false,
|
||||
},
|
||||
);
|
||||
Some(id)
|
||||
@@ -466,6 +533,9 @@ impl Backend for MixerBackend {
|
||||
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
|
||||
self.with(channel, |c| c.relative = relative);
|
||||
}
|
||||
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
|
||||
self.with(channel, |c| c.unmuffled = unmuffled);
|
||||
}
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
@@ -702,6 +772,13 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
}
|
||||
// A mod's muffle: the sounds it doesn't exempt mix into a bus of their own first.
|
||||
let muffling = mixer.muffle.active();
|
||||
let mut bus = std::mem::take(&mut mixer.muffle.bus);
|
||||
if muffling {
|
||||
bus.clear();
|
||||
bus.resize(data.len(), 0.0);
|
||||
}
|
||||
for (id, [left, right, gain], step) in voices.iter().copied() {
|
||||
let Some(c) = mixer.channels.get_mut(&id) else {
|
||||
continue;
|
||||
@@ -711,7 +788,12 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
for frame in data.chunks_mut(channels) {
|
||||
let out: &mut [f32] = if muffling && !c.unmuffled {
|
||||
&mut bus
|
||||
} else {
|
||||
&mut *data
|
||||
};
|
||||
for frame in out.chunks_mut(channels) {
|
||||
let mut i = c.position as usize;
|
||||
if i + 1 >= frames {
|
||||
if c.looping && frames > 1 {
|
||||
@@ -746,6 +828,14 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
}
|
||||
mixer.voices = voices;
|
||||
if muffling {
|
||||
mixer.muffle.apply(&bus, data, channels, rate);
|
||||
} else {
|
||||
// Off: the next muffle starts from silence, not from the last one's filter.
|
||||
mixer.muffle.current = [0.0; 2];
|
||||
mixer.muffle.stages = [[0.0; 2]; 2];
|
||||
}
|
||||
mixer.muffle.bus = bus;
|
||||
// Limit the whole buffer together, preserving waveform and stereo balance
|
||||
// instead of flattening each overloaded sample; release over 200 ms
|
||||
// (OpenAL Soft's output limiter, `ALC_OUTPUT_LIMITER_SOFT`, releases
|
||||
@@ -1077,6 +1167,7 @@ impl Audio {
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
muffle: None,
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: if live { default_output_name() } else { None },
|
||||
#[cfg(feature = "audio")]
|
||||
@@ -1238,6 +1329,7 @@ impl Audio {
|
||||
heard: Default::default(),
|
||||
stopped: Default::default(),
|
||||
preview: Default::default(),
|
||||
muffle: None,
|
||||
// The device belongs to the first player's output, which looks after it.
|
||||
#[cfg(feature = "audio")]
|
||||
default_seen: self.default_seen.clone(),
|
||||
@@ -1370,6 +1462,50 @@ impl Audio {
|
||||
}
|
||||
self.sync_volumes();
|
||||
self.engine.tick(paused, ctx, &mut self.backend);
|
||||
// A paused game holds a mod's muffle where it is, as it holds the sounds.
|
||||
if !paused && let Some(fade) = &mut self.muffle {
|
||||
fade.left = fade.left.saturating_sub(1);
|
||||
let level = fade.level();
|
||||
if fade.left == 0 {
|
||||
self.muffle = None;
|
||||
self.engine.set_unmuffled(Vec::new(), &mut self.backend);
|
||||
}
|
||||
self.set_muffle_level(level);
|
||||
}
|
||||
}
|
||||
/// A mod's muffle (`native_muffle_sounds`): `amount` (0 to 1) of every sound's volume taken
|
||||
/// away and `lowpass` (0 to 1) dulling it, from untouched to a 300 Hz low-pass, fading in a
|
||||
/// straight line to none over `ticks` client ticks; the sound events in `exempt` (namespaced,
|
||||
/// `mymod:ear.ringing`) play clearly through it, those already playing too. It replaces any
|
||||
/// muffle in place; `ticks` 0 or no strength ends it. The output's, so in splitscreen every
|
||||
/// player hears it. False for an amount or strength outside 0 to 1.
|
||||
pub fn muffle(&mut self, amount: f32, lowpass: f32, ticks: u32, exempt: &[&str]) -> bool {
|
||||
if !(0.0..=1.0).contains(&amount) || !(0.0..=1.0).contains(&lowpass) {
|
||||
return false;
|
||||
}
|
||||
let on = ticks > 0 && (amount > 0.0 || lowpass > 0.0);
|
||||
let exempt = if on {
|
||||
exempt
|
||||
.iter()
|
||||
.map(|e| crate::sound_events::normalize(e))
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.engine.set_unmuffled(exempt, &mut self.backend);
|
||||
self.muffle = on.then_some(MuffleFade {
|
||||
amount,
|
||||
lowpass,
|
||||
ticks,
|
||||
left: ticks,
|
||||
});
|
||||
self.set_muffle_level(self.muffle.map_or([0.0; 2], |fade| fade.level()));
|
||||
true
|
||||
}
|
||||
fn set_muffle_level(&mut self, level: [f32; 2]) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
m.muffle.target = level;
|
||||
}
|
||||
}
|
||||
/// The `uiSoundReserve` extra: positional sounds leave
|
||||
/// `sound_engine::HEARD_RESERVE` static channels free (off: vanilla's pool).
|
||||
@@ -1835,6 +1971,7 @@ mod tests {
|
||||
owner: 0,
|
||||
world: 0,
|
||||
started: None,
|
||||
unmuffled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2255,6 +2392,123 @@ mod tests {
|
||||
assert_eq!(data, [0.5; 2]);
|
||||
}
|
||||
|
||||
/// One second of a `hz` sine at 48 kHz, amplitude 0.3 (looped, it repeats seamlessly).
|
||||
fn sine(hz: f32) -> Vec<f32> {
|
||||
(0..48_000)
|
||||
.map(|i| 0.3 * (std::f32::consts::TAU * hz * i as f32 / 48_000.0).sin())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rms(data: &[f32]) -> f32 {
|
||||
(data.iter().map(|s| s * s).sum::<f32>() / data.len() as f32).sqrt()
|
||||
}
|
||||
|
||||
/// `seconds` of the mixer's stereo output, in the 10 ms buffers an output stream asks for.
|
||||
fn listen(m: &mut Mixer, seconds: f32) -> Vec<f32> {
|
||||
let mut out = Vec::new();
|
||||
let mut buffer = [0.0; 960];
|
||||
for _ in 0..(seconds * 100.0).round() as usize {
|
||||
mix(m, &mut buffer, 2, 48_000);
|
||||
out.extend_from_slice(&buffer);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_muffle_dulls_and_quietens_every_sound_but_the_exempt_ones() {
|
||||
// A looping tone heard through a muffle of `[amount, lowpass]`, once it has settled.
|
||||
let heard = |hz: f32, muffle: [f32; 2], unmuffled: bool| {
|
||||
let mut m = Mixer::default();
|
||||
let mut c = channel(sine(hz), None, [0.0, 0.0, 2.0], true);
|
||||
c.unmuffled = unmuffled;
|
||||
m.channels.insert(1, c);
|
||||
m.muffle.target = muffle;
|
||||
listen(&mut m, 0.2);
|
||||
listen(&mut m, 0.2)
|
||||
};
|
||||
let plain = |hz: f32| rms(&heard(hz, [0.0; 2], false));
|
||||
// Fully low-passed, a 6 kHz whine all but goes and a 100 Hz hum keeps most of its level.
|
||||
assert!(rms(&heard(6000.0, [0.0, 1.0], false)) < plain(6000.0) * 0.05);
|
||||
assert!(rms(&heard(100.0, [0.0, 1.0], false)) > plain(100.0) * 0.8);
|
||||
// Half the volume taken and no low-pass: half the level, the tone itself untouched.
|
||||
let ducked = rms(&heard(6000.0, [0.5, 0.0], false)) / plain(6000.0);
|
||||
assert!((ducked - 0.5).abs() < 1e-3, "{ducked}");
|
||||
// An exempt sound plays through as if there were no muffle, sample for sample.
|
||||
assert_eq!(
|
||||
heard(6000.0, [0.5, 1.0], true),
|
||||
heard(6000.0, [0.0; 2], false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faded_muffle_leaves_the_sound_as_it_was() {
|
||||
let mixer = || {
|
||||
let mut m = Mixer::default();
|
||||
m.channels
|
||||
.insert(1, channel(sine(3000.0), None, [2.0, 0.0, 1.0], true));
|
||||
m
|
||||
};
|
||||
let (mut muffled, mut plain) = (mixer(), mixer());
|
||||
muffled.muffle.target = [0.7, 1.0];
|
||||
assert_ne!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
|
||||
muffled.muffle.target = [0.0; 2];
|
||||
listen(&mut muffled, 0.5);
|
||||
listen(&mut plain, 0.5);
|
||||
assert_eq!(listen(&mut muffled, 0.1), listen(&mut plain, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mods_muffle_fades_with_the_game_ticks_and_spares_its_exempt_sounds() {
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"ambient.cave":{"sounds":["ambient/cave/cave1"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
audio.engine.registry.add_file(
|
||||
"hztest",
|
||||
br#"{"ear.ringing":{"sounds":["ear/ringing"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// The engine makes its channels; with no output, none loads a file.
|
||||
audio.engine.loaded = true;
|
||||
let marks = |audio: &Audio| {
|
||||
let m = audio.backend.mixer.lock().unwrap();
|
||||
let mut marks: Vec<_> = m
|
||||
.channels
|
||||
.iter()
|
||||
.map(|(&id, c)| (id, c.unmuffled))
|
||||
.collect();
|
||||
marks.sort();
|
||||
marks
|
||||
};
|
||||
let level = |audio: &Audio| audio.backend.mixer.lock().unwrap().muffle.target;
|
||||
let close =
|
||||
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
|
||||
// Two sounds already playing, and one exempt sound started under the muffle.
|
||||
audio.play("ambient.cave", None, 1.0, 1.0);
|
||||
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
|
||||
assert!(audio.muffle(0.8, 1.0, 4, &["hztest:ear.ringing"]));
|
||||
audio.play("hztest:ear.ringing", None, 1.0, 1.0);
|
||||
assert_eq!(marks(&audio), [(1, false), (2, true), (3, true)]);
|
||||
assert!(close(level(&audio), [0.8, 1.0]));
|
||||
let ticks = TickContext::default();
|
||||
audio.tick(false, &ticks);
|
||||
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
|
||||
// A paused game holds it.
|
||||
audio.tick(true, &ticks);
|
||||
assert!(close(level(&audio), [0.6, 0.75]), "{:?}", level(&audio));
|
||||
for _ in 0..3 {
|
||||
audio.tick(false, &ticks);
|
||||
}
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
// Over: its exemptions go with it.
|
||||
assert_eq!(marks(&audio), [(1, false), (2, false), (3, false)]);
|
||||
// Out of range changes nothing.
|
||||
assert!(!audio.muffle(1.5, 0.0, 10, &[]));
|
||||
assert_eq!(level(&audio), [0.0; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
|
||||
// No fingerprint (not Linux, or the cards could not be read): the list
|
||||
|
||||
@@ -12,12 +12,13 @@ use crate::{
|
||||
block_entity_anim::{self, EnchantingBook},
|
||||
breaking::entity_geometry,
|
||||
fast_hash::FxHashMap,
|
||||
font::Glyph,
|
||||
font::{Font, Glyph},
|
||||
gfx::mark_oit,
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
models::load_visibility,
|
||||
signs::{self, SignData, SignText},
|
||||
text::{Style, Text},
|
||||
world::World,
|
||||
};
|
||||
use glam::{DVec3, Mat4, Quat, Vec3};
|
||||
@@ -620,30 +621,26 @@ fn frac(x: f32) -> f32 {
|
||||
x - x.floor()
|
||||
}
|
||||
|
||||
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
|
||||
/// wrapped at the last space that fits or mid-word when none does.
|
||||
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
|
||||
/// `StringSplitter.findLineBreak`: first line, with styled advances.
|
||||
fn first_line(line: &Text, max_width: f32, advance: &dyn Fn(char, &Style) -> f32) -> Text {
|
||||
let mut width = 0.0;
|
||||
let mut visible = false;
|
||||
let mut last_space = None;
|
||||
for (i, c) in line.char_indices() {
|
||||
for (i, (c, style)) in line.chars().enumerate() {
|
||||
if c == '\n' {
|
||||
return line[..i].to_owned();
|
||||
return line.slice(0, i);
|
||||
}
|
||||
if c == ' ' {
|
||||
last_space = Some(i);
|
||||
}
|
||||
width += advance(c);
|
||||
if width > max_width {
|
||||
let end = match last_space {
|
||||
Some(s) if s > 0 => s,
|
||||
_ if i > 0 => i,
|
||||
// The first char alone is too wide: take that one char.
|
||||
_ => c.len_utf8(),
|
||||
};
|
||||
return line[..end].to_owned();
|
||||
let step = advance(c, style);
|
||||
width += step;
|
||||
if visible && width > max_width.max(1.0) {
|
||||
return line.slice(0, last_space.unwrap_or(i));
|
||||
}
|
||||
visible |= step != 0.0;
|
||||
}
|
||||
line.to_owned()
|
||||
line.clone()
|
||||
}
|
||||
|
||||
/// `AbstractSignRenderer.isOutlineVisible`: the camera within 16 blocks of
|
||||
@@ -661,7 +658,7 @@ fn sign_text_near(pos: [i32; 3], camera: DVec3) -> bool {
|
||||
/// `AbstractSignRenderer.submitSignText`: four centred lines, glowing text
|
||||
/// full-bright with an 8x outline in the dark colour. The font colours each
|
||||
/// glyph by its style (`Font.StringRenderOutput`): a line's own colour wins,
|
||||
/// else the sign's. A line is one call per run of one colour.
|
||||
/// else the sign's. Keep style boundaries for glyphs and decoration effects.
|
||||
fn sign_text_calls(
|
||||
scene: &Scene,
|
||||
data: &SignData,
|
||||
@@ -684,17 +681,32 @@ fn sign_text_calls(
|
||||
} else {
|
||||
(dark, 0, vertex_light(packed), 0)
|
||||
};
|
||||
let advance = |c: char| (scene.glyph)(c).advance;
|
||||
let advance = |c: char, style: &Style| {
|
||||
let g = scene
|
||||
.font
|
||||
.map_or_else(|| (scene.glyph)(c), |f| f.glyph_styled(c, style));
|
||||
g.advance + if style.is_bold() { g.offset } else { 0.0 }
|
||||
};
|
||||
let mut calls: Vec<TextCall> = Vec::with_capacity(text.lines.len());
|
||||
for i in 0..text.lines.len() {
|
||||
// The line that fits, in the order it is drawn (getVisualOrder).
|
||||
let line = text.line_text(i);
|
||||
let line = line.resolved();
|
||||
let flat: String = line.chars().map(|(c, _)| c).collect();
|
||||
let fits = first_line(&flat, data.max_line_width(), &advance);
|
||||
let shown = line.slice(0, fits.chars().count());
|
||||
let shown = if let Some(font) = scene.font {
|
||||
font.split_formatted(&line, data.max_line_width())
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.resolved()
|
||||
.into_owned()
|
||||
} else {
|
||||
first_line(&line.resolved(), data.max_line_width(), &advance)
|
||||
};
|
||||
let shown = visual(&shown);
|
||||
let width = shown.chars().map(|(c, _)| advance(c)).sum::<f32>().ceil();
|
||||
let width = shown
|
||||
.chars()
|
||||
.map(|(c, s)| advance(c, s))
|
||||
.sum::<f32>()
|
||||
.ceil();
|
||||
let x = -((width as i32) / 2) as f32;
|
||||
let y = i as f32 * line_height - midpoint;
|
||||
let first = calls.len();
|
||||
@@ -704,20 +716,22 @@ fn sign_text_calls(
|
||||
.style
|
||||
.color
|
||||
.map_or(color, |rgb| 0xFF00_0000 | (rgb & 0xFF_FFFF));
|
||||
match calls[first..].last_mut() {
|
||||
Some(last) if last.color == run => last.text.push_str(&span.text),
|
||||
_ => calls.push(TextCall {
|
||||
matrix: m,
|
||||
x: x + pen,
|
||||
y,
|
||||
text: span.text.clone(),
|
||||
color: run,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
}),
|
||||
}
|
||||
pen += span.text.chars().map(advance).sum::<f32>();
|
||||
calls.push(TextCall {
|
||||
matrix: m,
|
||||
x: x + pen,
|
||||
y,
|
||||
text: span.text.clone(),
|
||||
style: span.style.clone(),
|
||||
color: run,
|
||||
outline,
|
||||
light,
|
||||
tier,
|
||||
});
|
||||
pen += span
|
||||
.text
|
||||
.chars()
|
||||
.map(|c| advance(c, &span.style))
|
||||
.sum::<f32>();
|
||||
}
|
||||
if calls.len() == first {
|
||||
// A line with no text is still a call, of no glyphs.
|
||||
@@ -726,6 +740,7 @@ fn sign_text_calls(
|
||||
x,
|
||||
y,
|
||||
text: String::new(),
|
||||
style: Style::default(),
|
||||
color,
|
||||
outline,
|
||||
light,
|
||||
@@ -736,6 +751,17 @@ fn sign_text_calls(
|
||||
calls
|
||||
}
|
||||
|
||||
fn sign_text_animated(data: &SignData) -> bool {
|
||||
[&data.front, &data.back].into_iter().any(|side| {
|
||||
(0..side.lines.len()).any(|i| {
|
||||
side.line_text(i)
|
||||
.resolved()
|
||||
.chars()
|
||||
.any(|(c, s)| c != ' ' && s.obfuscated == Some(true))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// How far the text of an outlined call sits in front of its outline, in
|
||||
/// text units (`Font.DisplayMode.POLYGON_OFFSET`, see `draw_text`).
|
||||
const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
@@ -749,72 +775,131 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
|
||||
/// reader instead (a twentieth of a font pixel, invisible) rather than
|
||||
/// z-fighting with the outline under it.
|
||||
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
text_vertices(scene.glyph, call, out);
|
||||
let glyph = |c| {
|
||||
let Some(font) = scene.font else {
|
||||
return (scene.glyph)(c);
|
||||
};
|
||||
let g = font.glyph_styled(c, &call.style);
|
||||
if call.style.obfuscated == Some(true) && c != ' ' {
|
||||
font.random_glyph(&call.style, g.advance)
|
||||
} else {
|
||||
g
|
||||
}
|
||||
};
|
||||
text_vertices(&glyph, scene.font.map(Font::white), call, out);
|
||||
}
|
||||
|
||||
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
|
||||
/// `BakedSheetGlyph.renderChar` / `Font.PreparedTextBuilder.accept`: the
|
||||
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
|
||||
/// y down as the font's, with its outline when it has one. A native mod's
|
||||
/// block entity draws its text this way (`native_draw_text`).
|
||||
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
pub fn text_vertices(
|
||||
glyph: &dyn Fn(char) -> Glyph,
|
||||
white: Option<Rect>,
|
||||
call: &TextCall,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
let bold = call.style.is_bold();
|
||||
let mut quad = |corners: [(f32, f32); 4], cell: Rect, z: f32, argb: u32| {
|
||||
let uvs = [
|
||||
[cell.u0, cell.v1],
|
||||
[cell.u1, cell.v1],
|
||||
[cell.u1, cell.v0],
|
||||
[cell.u0, cell.v0],
|
||||
];
|
||||
let color = 0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((x, y), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: call.matrix.transform_point3(Vec3::new(x, y, z)).to_array(),
|
||||
uv,
|
||||
layer: u32::from(call.tier) << 16,
|
||||
color,
|
||||
light: call.light,
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32, outline: bool| {
|
||||
let mut pen = call.x;
|
||||
for (i, ch) in call.text.chars().enumerate() {
|
||||
let g = glyph(ch);
|
||||
let x = pen + dx * g.offset;
|
||||
let y = call.y + dy * g.offset;
|
||||
let advance = g.advance + if bold { g.offset } else { 0.0 };
|
||||
if let Some(cell) = g.rect {
|
||||
// BakedSheetGlyph: the glyph's own box from the pen, not a
|
||||
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
|
||||
let (x0, x1) = (x + g.left, x + g.right);
|
||||
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
|
||||
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
|
||||
let uvs = [
|
||||
[cell.u0, cell.v1],
|
||||
[cell.u1, cell.v1],
|
||||
[cell.u1, cell.v0],
|
||||
[cell.u0, cell.v0],
|
||||
];
|
||||
let c =
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: call
|
||||
.matrix
|
||||
.transform_point3(Vec3::new(cx, cy, z))
|
||||
.to_array(),
|
||||
uv,
|
||||
layer: u32::from(call.tier) << 16,
|
||||
color: c,
|
||||
light: call.light,
|
||||
|
||||
glint_uv: [0.0, 0.0],
|
||||
});
|
||||
let thick = if bold { 0.1 } else { 0.0 };
|
||||
let shear = |v| {
|
||||
if call.style.is_italic() {
|
||||
1.0 - 0.25 * v
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
for copy in 0..=usize::from(bold) {
|
||||
let x0 = x + copy as f32 * g.offset + g.left - thick;
|
||||
let x1 = x + copy as f32 * g.offset + g.right + thick;
|
||||
let top = y + g.top - thick;
|
||||
let bottom = y + g.bottom + thick;
|
||||
quad(
|
||||
[
|
||||
(x0 + shear(g.bottom), bottom),
|
||||
(x1 + shear(g.bottom), bottom),
|
||||
(x1 + shear(g.top), top),
|
||||
(x0 + shear(g.top), top),
|
||||
],
|
||||
cell,
|
||||
z + copy as f32 * 0.001,
|
||||
argb,
|
||||
);
|
||||
}
|
||||
}
|
||||
x += g.advance;
|
||||
// `Font.prepare8xTextOutline` discards effects: only the front
|
||||
// text gets underline/strikethrough, including over spaces.
|
||||
if !outline && let Some(white) = white {
|
||||
let x0 = if i == 0 { x - 1.0 } else { x };
|
||||
for (enabled, top) in [
|
||||
(call.style.strikethrough, 3.5),
|
||||
(call.style.underlined, 8.0),
|
||||
] {
|
||||
if enabled == Some(true) {
|
||||
quad(
|
||||
[
|
||||
(x0, y + top + 1.0),
|
||||
(x + advance, y + top + 1.0),
|
||||
(x + advance, y + top),
|
||||
(x0, y + top),
|
||||
],
|
||||
white,
|
||||
z + 0.01,
|
||||
argb,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
pen += advance;
|
||||
}
|
||||
};
|
||||
if call.outline != 0 {
|
||||
for (dx, dy) in [
|
||||
(-1.0, -1.0),
|
||||
(0.0, -1.0),
|
||||
(1.0, -1.0),
|
||||
(-1.0, 0.0),
|
||||
(1.0, 0.0),
|
||||
(-1.0, 1.0),
|
||||
(0.0, 1.0),
|
||||
(1.0, 1.0),
|
||||
] {
|
||||
emit(dx, dy, 0.0, call.outline);
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if dx != 0 || dy != 0 {
|
||||
emit(dx as f32, dy as f32, 0.0, call.outline, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let z = if call.outline != 0 {
|
||||
TEXT_OVER_OUTLINE
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
emit(0.0, 0.0, z, call.color);
|
||||
emit(
|
||||
0.0,
|
||||
0.0,
|
||||
if call.outline != 0 {
|
||||
TEXT_OVER_OUTLINE
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
call.color,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Block-entity fields the level ticks, at one position (vanilla's own field
|
||||
@@ -847,6 +932,7 @@ pub struct TextCall {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub text: String,
|
||||
pub style: Style,
|
||||
/// ARGB.
|
||||
pub color: u32,
|
||||
/// ARGB outline colour, 0 for none.
|
||||
@@ -894,6 +980,8 @@ pub struct Scene<'a> {
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> Glyph,
|
||||
/// Font providers, effects and random glyphs for styled sign text.
|
||||
pub font: Option<&'a Font>,
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
@@ -1055,6 +1143,7 @@ struct SignTextEntry {
|
||||
/// Game time it was last drawn.
|
||||
used: i64,
|
||||
vertices: Vec<Vertex>,
|
||||
animated: bool,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct StaticKey {
|
||||
@@ -2361,9 +2450,11 @@ impl BlockEntityRenderer {
|
||||
let entry = match signs.entry(pos) {
|
||||
std::collections::hash_map::Entry::Occupied(mut occupied) => {
|
||||
let entry = occupied.get_mut();
|
||||
let changed = entry.data != *data;
|
||||
if (entry.state, entry.packed, entry.near, entry.glyphs)
|
||||
!= (state, packed, near, glyphs)
|
||||
|| entry.data != *data
|
||||
|| changed
|
||||
|| entry.animated
|
||||
{
|
||||
entry.vertices.clear();
|
||||
self.render_one(
|
||||
@@ -2378,7 +2469,10 @@ impl BlockEntityRenderer {
|
||||
&mut entry.vertices,
|
||||
);
|
||||
entry.state = state;
|
||||
entry.data.clone_from(data);
|
||||
if changed {
|
||||
entry.animated = sign_text_animated(data);
|
||||
entry.data.clone_from(data);
|
||||
}
|
||||
entry.packed = packed;
|
||||
entry.near = near;
|
||||
entry.glyphs = glyphs;
|
||||
@@ -2399,6 +2493,7 @@ impl BlockEntityRenderer {
|
||||
&mut vertices,
|
||||
);
|
||||
vacant.insert(SignTextEntry {
|
||||
animated: sign_text_animated(data),
|
||||
state,
|
||||
data: data.clone(),
|
||||
packed,
|
||||
@@ -3638,6 +3733,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
@@ -4032,6 +4128,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
@@ -4039,6 +4136,7 @@ mod tests {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
text: "Hi".into(),
|
||||
style: Style::default(),
|
||||
color: 0xFFFF_FF00,
|
||||
outline: 0xFF33_3300,
|
||||
light: 240 | 240 << 8,
|
||||
@@ -4106,6 +4204,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let call = TextCall {
|
||||
@@ -4113,6 +4212,7 @@ mod tests {
|
||||
x: 10.0,
|
||||
y: 2.0,
|
||||
text: "ab".into(),
|
||||
style: Style::default(),
|
||||
color: 0xFF00_0000,
|
||||
outline: 0,
|
||||
light: 240 | 240 << 8,
|
||||
@@ -4136,6 +4236,83 @@ mod tests {
|
||||
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_sign_quads_follow_vanilla_glyph_and_effect_bounds() {
|
||||
let white = Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.1,
|
||||
v1: 0.1,
|
||||
w: 1,
|
||||
h: 1,
|
||||
};
|
||||
let glyph = |c| Glyph {
|
||||
rect: (c != ' ').then_some(white),
|
||||
left: 0.0,
|
||||
right: 4.0,
|
||||
top: 0.0,
|
||||
bottom: 8.0,
|
||||
advance: 5.0,
|
||||
offset: 0.5,
|
||||
};
|
||||
let mut call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
text: "A ".into(),
|
||||
color: 0xff123456,
|
||||
outline: 0,
|
||||
light: 240,
|
||||
tier: crate::mesher::LOW,
|
||||
style: Style {
|
||||
bold: Some(true),
|
||||
italic: Some(true),
|
||||
underlined: Some(true),
|
||||
strikethrough: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
text_vertices(&glyph, Some(white), &call, &mut out);
|
||||
// BakedSheetGlyph: bold expands by 0.1, italic shears by 1 - y/4,
|
||||
// the second bold glyph is 0.5 along and 0.001 in front (Unihex).
|
||||
assert_eq!(out.len(), 24);
|
||||
for (got, expected) in out[0].pos.iter().zip([-1.1, 8.1, 0.0]) {
|
||||
assert!((got - expected).abs() < 1e-6);
|
||||
}
|
||||
assert!((out[4].pos[0] - out[0].pos[0] - 0.5).abs() < 1e-6);
|
||||
assert_eq!(out[4].pos[2], 0.001);
|
||||
assert_eq!(out[8].pos, [-1.0, 4.5, 0.01]);
|
||||
assert_eq!(out[12].pos, [-1.0, 9.0, 0.01]);
|
||||
assert_eq!(out[21].pos, [11.0, 9.0, 0.01]); // underline crosses the space
|
||||
assert!(out.iter().all(|v| v.color == 0xff563412 && v.light == 240));
|
||||
call.outline = 0xff010203;
|
||||
out.clear();
|
||||
text_vertices(&glyph, Some(white), &call, &mut out);
|
||||
// Eight outlines contain glyphs only; effects appear once, in front.
|
||||
assert_eq!(out.len(), 8 * 8 + 24);
|
||||
assert!((out[0].pos[0] - -1.6).abs() < 1e-6);
|
||||
assert!(out[64..].iter().all(|v| v.pos[2] >= TEXT_OVER_OUTLINE));
|
||||
// The glow tier reaches bold copies, outlines and decoration quads.
|
||||
assert!(out.iter().all(|v| v.layer >> 16 == 1));
|
||||
call.tier = 0;
|
||||
out.clear();
|
||||
text_vertices(&glyph, Some(white), &call, &mut out);
|
||||
assert!(out.iter().all(|v| v.layer >> 16 == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_sign_clipping_counts_bold_width_and_preserves_resets() {
|
||||
let line = Text::legacy("§lAB§r東Z");
|
||||
let advance = |_: char, s: &Style| 5.0 + if s.is_bold() { 0.5 } else { 0.0 };
|
||||
let shown = first_line(&line, 16.0, &advance);
|
||||
assert_eq!(shown.plain(), "AB東");
|
||||
assert_eq!(
|
||||
shown.chars().map(|(_, s)| s.is_bold()).collect::<Vec<_>>(),
|
||||
[true, true, false]
|
||||
);
|
||||
}
|
||||
|
||||
/// The front text calls of a sign whose every glyph advances 6.
|
||||
fn front_calls(data: &SignData, near: bool) -> Vec<(String, f32, f32, u32, u32)> {
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
@@ -4164,6 +4341,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
sign_text_calls(&scene, data, &data.front, Mat4::IDENTITY, 0, near)
|
||||
@@ -4303,7 +4481,9 @@ mod tests {
|
||||
..changed.clone()
|
||||
};
|
||||
let wide = std::cell::Cell::new(6.0);
|
||||
let glyph_reads = std::cell::Cell::new(0);
|
||||
let glyph = |c: char| {
|
||||
glyph_reads.set(glyph_reads.get() + 1);
|
||||
let u = f32::from(c as u8 % 16) / 16.0;
|
||||
let rect = Rect {
|
||||
u0: u,
|
||||
@@ -4346,6 +4526,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
|
||||
@@ -4389,6 +4570,23 @@ mod tests {
|
||||
wide.set(5.0);
|
||||
scene.glyphs = 1;
|
||||
check(&scene, pos, 1);
|
||||
// With the same game time and data, plain text reuses its vertices,
|
||||
// but obfuscated text must ask the font for fresh glyphs each frame.
|
||||
let mut vertices = Vec::new();
|
||||
let reads = glyph_reads.get();
|
||||
renderer.render(&scene, [(pos, 1)].iter(), &mut vertices);
|
||||
assert_eq!(glyph_reads.get(), reads);
|
||||
let animated = SignData {
|
||||
front: text("§kSecret", false),
|
||||
..Default::default()
|
||||
};
|
||||
let animated_sign = |_| Some(&animated);
|
||||
scene.sign = &animated_sign;
|
||||
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
|
||||
let reads = glyph_reads.get();
|
||||
renderer.render(&scene, [(pos, 0)].iter(), &mut vertices);
|
||||
assert!(glyph_reads.get() > reads);
|
||||
scene.sign = &first_sign;
|
||||
// Signs out of view for a while are dropped.
|
||||
scene.game_time = 2 * SIGN_TEXT_KEEP;
|
||||
check(&scene, pos, 0);
|
||||
@@ -4396,11 +4594,23 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn first_line_wraps_on_bytes_without_splitting_a_char() {
|
||||
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
|
||||
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
|
||||
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
|
||||
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
|
||||
let advance = |c: char, _: &Style| if c == ' ' { 4.0 } else { 6.0 };
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("ab cd ef"), 13.0, &advance).plain(),
|
||||
"ab"
|
||||
);
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("abcdef"), 13.0, &advance).plain(),
|
||||
"ab"
|
||||
);
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("\u{6771}"), 3.0, &advance).plain(),
|
||||
"\u{6771}"
|
||||
);
|
||||
assert_eq!(
|
||||
first_line(&Text::literal("ab\ncd"), 100.0, &advance).plain(),
|
||||
"ab"
|
||||
);
|
||||
}
|
||||
/// `rendertype_end_portal` culls back faces, and `AbstractEndPortalRenderer`
|
||||
/// submits the unit cube's two Y faces. Both are wound outward, so of each
|
||||
@@ -4504,6 +4714,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
@@ -4590,6 +4801,7 @@ mod tests {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
|
||||
@@ -358,6 +358,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
// Reverse local-position order to check that cache indexing does not
|
||||
@@ -447,6 +448,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -502,6 +504,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([100, 70, -32], 0)];
|
||||
@@ -689,6 +692,7 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -743,6 +747,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -845,6 +850,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -900,6 +906,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -962,6 +969,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1060,6 +1068,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1149,6 +1158,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
|
||||
@@ -1225,6 +1235,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1304,6 +1315,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1371,6 +1383,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1419,6 +1432,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -1525,6 +1539,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
|
||||
skull: &|_| None,
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
font: None,
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
|
||||
@@ -83,6 +83,13 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
|
||||
GlyphStyle::PlayStation => "Always shows PlayStation buttons.",
|
||||
GlyphStyle::Nintendo => "Always shows Nintendo buttons.",
|
||||
},
|
||||
Opt::PadNintendoButtons => {
|
||||
if pad.nintendo_letters {
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch."
|
||||
} else {
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers."
|
||||
}
|
||||
}
|
||||
Opt::PadKeyboard => match pad.keyboard {
|
||||
gamepad::TextKeyboard::Auto => {
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise."
|
||||
@@ -261,6 +268,10 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
|
||||
Opt::PadGlyphs,
|
||||
"Follow the controller, or always show one family's buttons.",
|
||||
),
|
||||
option(
|
||||
Opt::PadNintendoButtons,
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.",
|
||||
),
|
||||
info(Info::Using, "The buttons menus show right now."),
|
||||
],
|
||||
),
|
||||
@@ -1332,6 +1343,7 @@ mod tests {
|
||||
Opt::PadLookDeadzone,
|
||||
Opt::PadTriggerThreshold,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadPointerSpeed,
|
||||
Opt::PadScrollSpeed,
|
||||
@@ -1429,6 +1441,53 @@ mod tests {
|
||||
assert!((s.controller.move_deadzone - before - 0.01).abs() < 0.001);
|
||||
}
|
||||
|
||||
/// The Device page's Nintendo Buttons row sits with the glyphs, steps between its two
|
||||
/// values rather than ticking a box, and what it sets is what the controller file keeps.
|
||||
#[test]
|
||||
fn the_nintendo_buttons_row_flips_letters_and_the_file_keeps_it() {
|
||||
let (page, group) = rows()
|
||||
.into_iter()
|
||||
.flat_map(|(page, groups)| groups.into_iter().map(move |g| (page, g)))
|
||||
.find(|(_, (_, rows))| {
|
||||
rows.iter()
|
||||
.any(|r| r.item == Item::Option(Opt::PadNintendoButtons))
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!((page, group.0), (Page::Device, "Glyphs"));
|
||||
let mut s = Settings::default();
|
||||
let item = Item::Option(Opt::PadNintendoButtons);
|
||||
assert!(item.steps(&s));
|
||||
s.controller.nintendo_letters = false;
|
||||
let mut ui = ControllerUi {
|
||||
focus: Some(Target::Item(item)),
|
||||
..ControllerUi::default()
|
||||
};
|
||||
// Vanilla's caption and value template; Horizon's own words come bundled.
|
||||
struct Vanilla;
|
||||
impl lang::ResourceSource for Vanilla {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
(path == "assets/minecraft/lang/en_us.json")
|
||||
.then(|| br#"{"options.generic_value": "%s: %s"}"#.to_vec())
|
||||
}
|
||||
}
|
||||
let value = |ui: &ControllerUi, s: &Settings| {
|
||||
lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
|
||||
ui.parts(item, s)
|
||||
})
|
||||
.0
|
||||
};
|
||||
assert_eq!(value(&ui, &s).0, "Nintendo Buttons");
|
||||
assert_eq!(value(&ui, &s).1.as_deref(), Some("By Position"));
|
||||
let actions = key(&mut ui, &mut s, K::Enter);
|
||||
assert!(actions.contains(&Action::Changed), "{actions:?}");
|
||||
assert!(s.controller.nintendo_letters);
|
||||
assert_eq!(value(&ui, &s).1.as_deref(), Some("By Letter"));
|
||||
assert!(PadSettings::parse(&s.controller.serialize()).nintendo_letters);
|
||||
key(&mut ui, &mut s, K::Enter);
|
||||
assert!(!s.controller.nintendo_letters);
|
||||
assert!(!PadSettings::parse(&s.controller.serialize()).nintendo_letters);
|
||||
}
|
||||
|
||||
/// What the screen draws at the bottom of the page changes with the choice, in every one of
|
||||
/// these settings.
|
||||
#[test]
|
||||
@@ -1438,6 +1497,7 @@ mod tests {
|
||||
for (opt, choices) in [
|
||||
(Opt::PadGameplayGuide, 3),
|
||||
(Opt::PadGlyphs, 4),
|
||||
(Opt::PadNintendoButtons, 2),
|
||||
(Opt::PadKeyboard, 3),
|
||||
(Opt::PadLightbar, 3),
|
||||
] {
|
||||
@@ -1470,6 +1530,7 @@ mod tests {
|
||||
for opt in [
|
||||
Opt::PadGameplayGuide,
|
||||
Opt::PadGlyphs,
|
||||
Opt::PadNintendoButtons,
|
||||
Opt::PadKeyboard,
|
||||
Opt::PadLightbar,
|
||||
] {
|
||||
|
||||
@@ -278,6 +278,43 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec<crate::pack_screen::Pac
|
||||
}
|
||||
infos
|
||||
}
|
||||
/// The folder of a game folder whose packs every new world there starts with: a modpack's profile
|
||||
/// keeps the packs of its data-only jars here (`horizon-port-pack`). Each world gets its own copy,
|
||||
/// as Create World's packs do, so a world keeps working when the folder changes. Vanilla has no
|
||||
/// such folder, and without one nothing here happens.
|
||||
pub const NEW_WORLD_PACKS: &str = "new-world-datapacks";
|
||||
|
||||
/// Create World's temporary pack folder holding `game_dir`'s [`NEW_WORLD_PACKS`], and their ids,
|
||||
/// to show them selected in the Data Packs screen. `None` when there are none.
|
||||
pub fn seed_create_world(game_dir: &Path) -> Option<(PathBuf, Vec<String>)> {
|
||||
let from = game_dir.join(NEW_WORLD_PACKS);
|
||||
let ids: Vec<String> = discover_folder(&from).into_iter().map(|p| p.id).collect();
|
||||
if ids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let seeded = temp_folder()
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|dir| copy(&from, &dir).map(|()| dir));
|
||||
match seeded {
|
||||
Ok(dir) => Some((dir, ids)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: data packs for new worlds: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A world about to be created without Create World (`--world`, the control socket) gets
|
||||
/// `game_dir`'s [`NEW_WORLD_PACKS`]. The game turns on every pack it finds in a world.
|
||||
pub fn install_new_world_packs(game_dir: &Path, world: &Path) -> Result<(), String> {
|
||||
let from = game_dir.join(NEW_WORLD_PACKS);
|
||||
let exists = |name: &str| world.join(name).exists();
|
||||
if !from.is_dir() || exists("level.dat") || exists("level.dat_old") {
|
||||
return Ok(());
|
||||
}
|
||||
copy(&from, &world.join("datapacks"))
|
||||
}
|
||||
|
||||
/// A new empty folder for Create World's packs
|
||||
/// (`Files.createTempDirectory("mcworld-")`).
|
||||
pub fn temp_folder() -> std::io::Result<PathBuf> {
|
||||
@@ -1101,6 +1138,35 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_game_folder_s_new_world_packs_go_into_new_worlds_only() {
|
||||
let root = std::env::temp_dir().join(format!("hz-new-world-packs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (game, world, old) = (root.join("game"), root.join("new"), root.join("old"));
|
||||
assert!(seed_create_world(&game).is_none());
|
||||
assert_eq!(install_new_world_packs(&game, &world), Ok(()));
|
||||
assert!(!world.join("datapacks").exists());
|
||||
for pack in ["void", "islands"] {
|
||||
let dir = game.join(NEW_WORLD_PACKS).join(pack);
|
||||
std::fs::create_dir_all(dir.join("data/v/function")).unwrap();
|
||||
std::fs::write(dir.join("pack.mcmeta"), "{}").unwrap();
|
||||
std::fs::write(dir.join("data/v/function/a.mcfunction"), "say a").unwrap();
|
||||
}
|
||||
let (temp, ids) = seed_create_world(&game).unwrap();
|
||||
assert_eq!(ids, ["file/islands", "file/void"]);
|
||||
assert!(temp.join("void/data/v/function/a.mcfunction").is_file());
|
||||
let _ = std::fs::remove_dir_all(&temp);
|
||||
install_new_world_packs(&game, &world).unwrap();
|
||||
let ids: Vec<String> = discover(&world).into_iter().map(|p| p.id).collect();
|
||||
assert_eq!(ids, ["file/islands", "file/void"]);
|
||||
// A world that exists already keeps the packs it has.
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::write(old.join("level.dat"), "").unwrap();
|
||||
install_new_world_packs(&game, &old).unwrap();
|
||||
assert!(discover(&old).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_packs_override_worldgen_in_order() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-datapack-worldgen-{}", std::process::id()));
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
lang,
|
||||
options_ui::{Event, Page},
|
||||
settings::Settings,
|
||||
settings_shell::{Group, Output, Row, Screen, Sheet, Value},
|
||||
settings_shell::{self as shell, Group, Output, Row, Screen, Sheet, Value},
|
||||
};
|
||||
|
||||
const TITLE: &str = "horizon.pages.extras";
|
||||
@@ -85,6 +85,10 @@ impl Screen for ExtrasScreen {
|
||||
fn note_lines(&self) -> usize {
|
||||
NOTE_LINES
|
||||
}
|
||||
/// The rows read the extras, the mods and whether cosmetics are offered.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
|
||||
let mut groups = vec![Group {
|
||||
name: MODS,
|
||||
@@ -228,4 +232,37 @@ mod tests {
|
||||
assert!(s.extras.sort);
|
||||
assert_eq!(screen.wheel_step(&mut s, ExtraRow::Zoom(0), true), None);
|
||||
}
|
||||
|
||||
/// A page with no page list has Done under the search field: Down goes to the first row
|
||||
/// instead, from the keys and, as the frame declares it, from a controller's focus.
|
||||
#[test]
|
||||
fn down_from_the_search_field_lands_on_the_first_row() {
|
||||
use crate::{hud::HudBatch, settings_shell::Columns, ui};
|
||||
use winit::keyboard::KeyCode as K;
|
||||
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<ExtrasScreen>::default();
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
|
||||
crate::widgets::end_frame(1);
|
||||
let size = (batch.width, batch.height);
|
||||
let cols = Columns::of(size.0, size.1).with_note_lines(NOTE_LINES);
|
||||
// The Mods group's note can't take the focus: the first item can.
|
||||
let first = ExtraRow::Item(Item::OwnNameTag);
|
||||
let at = shell.point_at(&s, size, first);
|
||||
let [x, y, w, h] = crate::widgets::linked_step(cols.search(), true).unwrap();
|
||||
assert!(ui::hovered(at, x, y, w, h));
|
||||
assert_eq!(crate::widgets::linked_step(cols.search(), false), None);
|
||||
let [x, y, w, h] = cols.search();
|
||||
shell.click(
|
||||
&mut s,
|
||||
(x + w / 2.0, y + h / 2.0),
|
||||
size,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
shell.key(&mut s, K::ArrowDown, None, false, size);
|
||||
assert_eq!(shell.focused_row(), Some(first));
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,8 @@ mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
pub(crate) mod hud_coverage;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
mod hold;
|
||||
pub use hold::WorldHold;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
mod displace;
|
||||
@@ -445,6 +447,52 @@ struct GpuStaticEntitySection {
|
||||
vertices: u32,
|
||||
}
|
||||
pub type SectionKey = (i32, i32, i32);
|
||||
/// What a section's mesh brings with it besides its quads (`Renderer::upload`): placed with
|
||||
/// the mesh, now or when a hold lets go of it.
|
||||
struct MeshFacts<'a> {
|
||||
visibility: u64,
|
||||
block_entities: &'a [([i32; 3], u32)],
|
||||
lights: &'a [([i32; 3], u32)],
|
||||
bounds: Option<[glam::Vec3; 2]>,
|
||||
/// It has translucent quads.
|
||||
translucent: bool,
|
||||
/// It has no quads at all: that is its mesh.
|
||||
empty: bool,
|
||||
}
|
||||
impl<'a> MeshFacts<'a> {
|
||||
fn of(mesh: &'a SectionMesh) -> Self {
|
||||
Self {
|
||||
visibility: mesh.visibility,
|
||||
block_entities: &mesh.block_entities,
|
||||
lights: &mesh.lights,
|
||||
bounds: mesh.bounds,
|
||||
translucent: !mesh.translucent.is_empty(),
|
||||
empty: mesh.solid.is_empty() && mesh.translucent.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A section's new mesh waiting behind a hold (gfx/hold.rs): uploaded, with what goes with it.
|
||||
struct HeldMesh {
|
||||
gpu: Option<SectionGpu>,
|
||||
visibility: u64,
|
||||
block_entities: Vec<([i32; 3], u32)>,
|
||||
lights: Vec<([i32; 3], u32)>,
|
||||
bounds: Option<[glam::Vec3; 2]>,
|
||||
translucent: bool,
|
||||
empty: bool,
|
||||
}
|
||||
impl HeldMesh {
|
||||
fn facts(&self) -> MeshFacts<'_> {
|
||||
MeshFacts {
|
||||
visibility: self.visibility,
|
||||
block_entities: &self.block_entities,
|
||||
lights: &self.lights,
|
||||
bounds: self.bounds,
|
||||
translucent: self.translucent,
|
||||
empty: self.empty,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
|
||||
/// it, at half the world's resolution (the material stage's `reflection`).
|
||||
struct MirrorTarget {
|
||||
@@ -1193,6 +1241,8 @@ pub struct Renderer {
|
||||
static_entity_catalog_revision: u64,
|
||||
static_entity_frame_stats: StaticCacheFrameStats,
|
||||
hdr_mark_pipeline: wgpu::RenderPipeline,
|
||||
/// `hdr_mark_pipeline` at `entity_pipeline`'s depth bias, for what that drew.
|
||||
hdr_entity_mark_pipeline: wgpu::RenderPipeline,
|
||||
hdr_hand_unmark_pipeline: wgpu::RenderPipeline,
|
||||
hdr_mark_lightning_pipeline: wgpu::RenderPipeline,
|
||||
weather: weather::Weather,
|
||||
@@ -1330,6 +1380,9 @@ pub struct Renderer {
|
||||
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
|
||||
/// Removed sections fading out over far terrain (`leave_section`).
|
||||
lod_leaving: Vec<(SectionKey, std::time::Instant)>,
|
||||
/// A mod's hold on the terrain's picture (`hold_world`, gfx/hold.rs) and the new meshes
|
||||
/// waiting behind it.
|
||||
hold: hold::Hold<HeldMesh>,
|
||||
/// Finished tile meshes `upload_lods` has yet to upload.
|
||||
lod_pending: Option<lod::Pending>,
|
||||
detail_columns: FxHashMap<(i32, i32), SectionMask>,
|
||||
@@ -2454,6 +2507,14 @@ impl Renderer {
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
@@ -4007,17 +4068,19 @@ impl Renderer {
|
||||
// paths here, so their fp32 depths of the same plane differ by a few dozen
|
||||
// ulps and the tie came out either way, as a jagged strip of the trapdoor
|
||||
// through the hull's floor. The same constant as the shadows' covers that
|
||||
// spread; a model's feet sit 0.001 above the ground, far beyond it.
|
||||
// spread; a model's feet sit 0.001 above the ground, far beyond it. HDR output's
|
||||
// marks on what it draws take it too (`hdr_entity_mark_pipeline`).
|
||||
let entity_bias = wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
};
|
||||
let entity_pipeline = entity_pipeline(
|
||||
"Entities",
|
||||
None,
|
||||
true,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
wgpu::DepthBiasState {
|
||||
constant: 128,
|
||||
slope_scale: 0.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
entity_bias,
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
@@ -4089,12 +4152,26 @@ impl Renderer {
|
||||
"fs_hdr_mark",
|
||||
wgpu::ColorWrites::ALPHA,
|
||||
);
|
||||
// The same at `entity_pipeline`'s depth bias, for what it drew (entities, the hand,
|
||||
// translucent particles): without it the marks test a hair behind the depth it left
|
||||
// and never land.
|
||||
let hdr_entity_mark_pipeline = entity_pipeline_with(
|
||||
"HDR entity mark",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
entity_bias,
|
||||
wgpu::BlendState::REPLACE,
|
||||
false,
|
||||
"fs_hdr_mark",
|
||||
wgpu::ColorWrites::ALPHA,
|
||||
);
|
||||
let hdr_hand_unmark_pipeline = entity_pipeline_with(
|
||||
"HDR hand unmark",
|
||||
None,
|
||||
false,
|
||||
wgpu::CompareFunction::GreaterEqual,
|
||||
Default::default(),
|
||||
entity_bias,
|
||||
wgpu::BlendState::REPLACE,
|
||||
false,
|
||||
"fs_hdr_hand_unmark",
|
||||
@@ -4547,7 +4624,11 @@ impl Renderer {
|
||||
attributes: &TERRAIN_ATTRIBUTES,
|
||||
})];
|
||||
let info = adapter.get_info();
|
||||
// Metal's per-section path drops cutout leaves (the canopy above trunks vanishes),
|
||||
// and cave culling can drop the listed count below MIN_SECTIONS on a normal view:
|
||||
// so Metal draws vanilla terrain flat at any count.
|
||||
let min_sections = if flat::is_broken_intel_vulkan(&info)
|
||||
|| info.backend == wgpu::Backend::Metal
|
||||
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
|
||||
{
|
||||
0
|
||||
@@ -4799,6 +4880,7 @@ impl Renderer {
|
||||
entity_pipeline,
|
||||
static_entity_pipeline,
|
||||
hdr_mark_pipeline,
|
||||
hdr_entity_mark_pipeline,
|
||||
hdr_hand_unmark_pipeline,
|
||||
hdr_mark_lightning_pipeline,
|
||||
weather,
|
||||
@@ -4902,6 +4984,7 @@ impl Renderer {
|
||||
lod_cutout: Default::default(),
|
||||
lod_retiring: Default::default(),
|
||||
lod_leaving: Vec::new(),
|
||||
hold: Default::default(),
|
||||
lod_pending: None,
|
||||
detail_columns: Default::default(),
|
||||
detail_revision: 0,
|
||||
@@ -5006,7 +5089,7 @@ impl Renderer {
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
if self.hdr.is_some() {
|
||||
// Drawn into the world: the light sources it holds are marked as the world's are.
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
@@ -5497,12 +5580,28 @@ impl Renderer {
|
||||
if let Some(copies) = &mut self.capture_copies {
|
||||
copies.section(key, mesh);
|
||||
}
|
||||
if self.hold.holds(key) && self.shown(key) && self.upload_held(key, mesh) {
|
||||
return;
|
||||
}
|
||||
if let Some(gpu) = self.hold.take(key).and_then(|held| held.gpu) {
|
||||
self.release_mesh(gpu);
|
||||
}
|
||||
let facts = MeshFacts::of(mesh);
|
||||
let fading = self.before_mesh(key, &facts);
|
||||
let section = self.upload_mesh(mesh);
|
||||
self.after_mesh(key, &facts, section, fading);
|
||||
}
|
||||
/// What goes with section `key`'s new mesh, before its quads go in: its old mesh is
|
||||
/// released. The fade it keeps.
|
||||
fn before_mesh(&mut self, key: SectionKey, mesh: &MeshFacts) -> Option<Instant> {
|
||||
self.occlusion.set(key, mesh.visibility);
|
||||
let changed = if mesh.block_entities.is_empty() {
|
||||
self.block_entities.remove(&key).is_some()
|
||||
} else {
|
||||
let old = self.block_entities.insert(key, mesh.block_entities.clone());
|
||||
old.as_deref() != Some(&mesh.block_entities[..])
|
||||
let old = self
|
||||
.block_entities
|
||||
.insert(key, mesh.block_entities.to_vec());
|
||||
old.as_deref() != Some(mesh.block_entities)
|
||||
};
|
||||
if changed {
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
@@ -5521,8 +5620,8 @@ impl Renderer {
|
||||
let lights_changed = if mesh.lights.is_empty() {
|
||||
self.light_blocks.remove(&key).is_some()
|
||||
} else {
|
||||
let old = self.light_blocks.insert(key, mesh.lights.clone());
|
||||
old.as_deref() != Some(&mesh.lights[..])
|
||||
let old = self.light_blocks.insert(key, mesh.lights.to_vec());
|
||||
old.as_deref() != Some(mesh.lights)
|
||||
};
|
||||
if lights_changed {
|
||||
self.light_revision += 1;
|
||||
@@ -5530,7 +5629,7 @@ impl Renderer {
|
||||
if !self.lod_retiring.is_empty() {
|
||||
self.lod_retiring.remove(&key);
|
||||
}
|
||||
if !mesh.translucent.is_empty() && !self.sections.contains(key) {
|
||||
if mesh.translucent && !self.sections.contains(key) {
|
||||
// New translucent terrain, not an edit's remesh: large pages wait for it.
|
||||
self.shrink_gate.wait(Instant::now(), SHRINK_SETTLE);
|
||||
}
|
||||
@@ -5549,13 +5648,19 @@ impl Renderer {
|
||||
self.newest_upload = Some(now);
|
||||
}
|
||||
self.release_section(key);
|
||||
let section = self
|
||||
.upload_mesh(mesh)
|
||||
.map(|section| SectionGpu { fading, ..section });
|
||||
self.set_detail(
|
||||
key,
|
||||
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
|
||||
);
|
||||
fading
|
||||
}
|
||||
/// Section `key`'s new mesh drawn from now on, its quads uploaded (`section`: none when it
|
||||
/// has none, or the arena had no room).
|
||||
fn after_mesh(
|
||||
&mut self,
|
||||
key: SectionKey,
|
||||
mesh: &MeshFacts,
|
||||
section: Option<SectionGpu>,
|
||||
fading: Option<Instant>,
|
||||
) {
|
||||
let section = section.map(|section| SectionGpu { fading, ..section });
|
||||
self.set_detail(key, section.is_some() || mesh.empty);
|
||||
if let Some(mut section) = section {
|
||||
let sections = self.layout.sections();
|
||||
if let Some(gpu) = &mut self.gpu_culling
|
||||
@@ -5579,6 +5684,94 @@ impl Renderer {
|
||||
.insert_marked(key, section, mesh.bounds, marked);
|
||||
}
|
||||
}
|
||||
/// Whether section `key` has its mesh on screen (`set_detail`): an empty one counts.
|
||||
fn shown(&self, key: SectionKey) -> bool {
|
||||
self.detail_columns
|
||||
.get(&(key.0, key.2))
|
||||
.is_some_and(|mask| mask.contains(key.1 as usize))
|
||||
}
|
||||
/// The bytes a section's quads take in the arenas.
|
||||
fn mesh_bytes(gpu: Option<&SectionGpu>) -> u64 {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
gpu.map_or(0, |s| {
|
||||
(s.solid_slots + u64::from(s.translucent) * 4) * stride
|
||||
})
|
||||
}
|
||||
/// A mod's hold on the terrain's picture (gfx/hold.rs, `WorldHold`), asked once a frame
|
||||
/// before the frame's uploads: while held, a section on screen that is meshed again waits,
|
||||
/// uploaded, behind the mesh drawn. What no longer waits takes its place on this frame.
|
||||
pub fn hold_world(&mut self, ask: &WorldHold) {
|
||||
let waiting = match self.hold.ask(ask, Instant::now()) {
|
||||
hold::Release::None => return,
|
||||
hold::Release::All => self.hold.take_all(),
|
||||
hold::Release::Outside => self.hold.take_outside(),
|
||||
};
|
||||
self.place_held(waiting);
|
||||
}
|
||||
/// Whether a mod's hold covers chunk column `column` (`hold_world`): what changes there waits.
|
||||
pub fn world_holds_column(&self, column: (i32, i32)) -> bool {
|
||||
self.hold.holds((column.0, 0, column.1))
|
||||
}
|
||||
/// Section `key`'s new mesh waits behind the hold, uploaded. False when the arena has no
|
||||
/// room for it: what waited takes its place (giving the old meshes back), the hold is
|
||||
/// spent, and this one goes in as ever.
|
||||
fn upload_held(&mut self, key: SectionKey, mesh: &SectionMesh) -> bool {
|
||||
let facts = MeshFacts::of(mesh);
|
||||
let gpu = self.upload_mesh(mesh);
|
||||
if gpu.is_none() && !facts.empty {
|
||||
log::warn!("Horizon: no memory left for a held world's new sections: let go");
|
||||
self.hold.spend();
|
||||
let waiting = self.hold.take_all();
|
||||
self.place_held(waiting);
|
||||
return false;
|
||||
}
|
||||
let bytes = Self::mesh_bytes(gpu.as_ref());
|
||||
let held = HeldMesh {
|
||||
gpu,
|
||||
visibility: facts.visibility,
|
||||
block_entities: facts.block_entities.to_vec(),
|
||||
lights: facts.lights.to_vec(),
|
||||
bounds: facts.bounds,
|
||||
translucent: facts.translucent,
|
||||
empty: facts.empty,
|
||||
};
|
||||
let (old, over) = self.hold.wait(key, held, bytes);
|
||||
if let Some(gpu) = old.and_then(|old| old.gpu) {
|
||||
self.release_mesh(gpu);
|
||||
}
|
||||
if over {
|
||||
log::warn!(
|
||||
"Horizon: a held world's new sections came to {} MiB: let go",
|
||||
self.hold.bytes() >> 20
|
||||
);
|
||||
self.hold.spend();
|
||||
let waiting = self.hold.take_all();
|
||||
self.place_held(waiting);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// Sections that waited behind the hold take their place, on this frame.
|
||||
fn place_held(&mut self, waiting: Vec<(SectionKey, HeldMesh)>) {
|
||||
if waiting.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (count, started) = (waiting.len(), Instant::now());
|
||||
let bytes: u64 = waiting
|
||||
.iter()
|
||||
.map(|(_, held)| Self::mesh_bytes(held.gpu.as_ref()))
|
||||
.sum();
|
||||
for (key, mut held) in waiting {
|
||||
let gpu = held.gpu.take();
|
||||
let facts = held.facts();
|
||||
let fading = self.before_mesh(key, &facts);
|
||||
self.after_mesh(key, &facts, gpu, fading);
|
||||
}
|
||||
log::debug!(
|
||||
"Horizon: {count} held sections ({} MiB) took their place in {:.1} ms",
|
||||
bytes >> 20,
|
||||
started.elapsed().as_secs_f64() * 1e3
|
||||
);
|
||||
}
|
||||
fn upload_mesh(&mut self, mesh: &SectionMesh) -> Option<SectionGpu> {
|
||||
let solid = (mesh.solid.len() / 4) as u32;
|
||||
let translucent = (mesh.translucent.len() / 4) as u32;
|
||||
@@ -5871,7 +6064,13 @@ impl Renderer {
|
||||
let meshes = self
|
||||
.sections
|
||||
.values_mut()
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
|
||||
// (meshes waiting behind a hold move with the rest, or they would keep the page)
|
||||
.chain(
|
||||
self.hold
|
||||
.sections_mut()
|
||||
.filter_map(|held| held.gpu.as_mut()),
|
||||
);
|
||||
let flat = self
|
||||
.flat
|
||||
.as_mut()
|
||||
@@ -5914,7 +6113,12 @@ impl Renderer {
|
||||
let meshes = self
|
||||
.sections
|
||||
.values_mut()
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
|
||||
.chain(
|
||||
self.hold
|
||||
.sections_mut()
|
||||
.filter_map(|held| held.gpu.as_mut()),
|
||||
);
|
||||
match begin_shrink(arena, &self.device, meshes, *part) {
|
||||
Some(true) => break,
|
||||
Some(false) => wait = SHRINK_RETRY, // the other arena may still go
|
||||
@@ -5938,7 +6142,13 @@ impl Renderer {
|
||||
let meshes = self
|
||||
.sections
|
||||
.values_mut()
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
|
||||
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()))
|
||||
// (meshes waiting behind a hold move with the rest, or they would keep the page)
|
||||
.chain(
|
||||
self.hold
|
||||
.sections_mut()
|
||||
.filter_map(|held| held.gpu.as_mut()),
|
||||
);
|
||||
// As many quads a frame either way: a quad's indices are a quarter of its bytes.
|
||||
let cap = match part {
|
||||
Part::Indices => COMPACT_BUDGET / 4,
|
||||
@@ -6111,6 +6321,8 @@ impl Renderer {
|
||||
self.newest_lod_fade = None;
|
||||
self.block_entity_revision = next_block_entity_revision();
|
||||
self.sections.clear();
|
||||
// (a hold on land that is gone ends; the arenas' clearing below takes what waited)
|
||||
self.hold.clear();
|
||||
self.lods.clear();
|
||||
self.lod_cutout.clear();
|
||||
self.lod_retiring.clear();
|
||||
@@ -6181,6 +6393,13 @@ impl Renderer {
|
||||
if want == self.packed_quads {
|
||||
return;
|
||||
}
|
||||
let held_packed = |held: &HeldMesh| held.gpu.as_ref().is_some_and(|s| s.packed);
|
||||
if !want && self.hold.sections().any(held_packed) {
|
||||
// (held meshes in the layout going away take their place, and go with the rest)
|
||||
self.hold.spend();
|
||||
let waiting = self.hold.take_all();
|
||||
self.place_held(waiting);
|
||||
}
|
||||
self.packed_quads = want;
|
||||
packed::pack_quads(want);
|
||||
if !want && self.sections.values().any(|s| s.packed) {
|
||||
@@ -6288,6 +6507,11 @@ impl Renderer {
|
||||
if let Some(copies) = &mut self.capture_copies {
|
||||
copies.remove_columns(&columns);
|
||||
}
|
||||
if !self.hold.is_empty() && !columns.is_empty() {
|
||||
let gone = columns.iter().copied().collect();
|
||||
let held = self.hold.take_columns(&gone);
|
||||
self.release_meshes(held.into_iter().filter_map(|held| held.gpu).collect());
|
||||
}
|
||||
// Most of the view at once (a teleport at render distance 32 drops some 3700
|
||||
// columns): a pass over each store instead of a lookup per section key, which
|
||||
// took 10 ms of one frame. Far terrain fades the sections out instead.
|
||||
@@ -10338,7 +10562,7 @@ impl Renderer {
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
if self.hdr.is_some() {
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
@@ -10800,7 +11024,7 @@ impl Renderer {
|
||||
);
|
||||
if self.hdr.is_some() {
|
||||
// Fireworks: their sparks are translucent-layer particles.
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(
|
||||
0..translucent_particle_quads * 6,
|
||||
((base_quads - translucent_item_quads - translucent_particle_quads) * 4)
|
||||
@@ -11150,10 +11374,18 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
laps.lap("r_passes");
|
||||
// What an SDR picture of the frame shows: on HDR output's layered frame its HUD target,
|
||||
// the HUD over the world clipped to white.
|
||||
let sdr_texture = self
|
||||
.hdr
|
||||
.as_ref()
|
||||
.and_then(|h| h.hud())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| target_texture.clone());
|
||||
// Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame.
|
||||
let shown_texture = match &fg_frames {
|
||||
Some((_, generated)) if fg_made && frame_gen::generated_only() => generated,
|
||||
_ => &target_texture,
|
||||
_ => &sdr_texture,
|
||||
};
|
||||
let readback = readback.or_else(|| {
|
||||
self.screenshot
|
||||
@@ -11191,7 +11423,7 @@ impl Renderer {
|
||||
(Some(_), None) => {
|
||||
if let Some(image) = &self.offscreen {
|
||||
self.readback
|
||||
.draw_onto(&self.device, &mut encoder, &target_texture, image);
|
||||
.draw_onto(&self.device, &mut encoder, &sdr_texture, image);
|
||||
}
|
||||
}
|
||||
(None, _) => {}
|
||||
@@ -11382,8 +11614,15 @@ impl Renderer {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// HDR output's layered frame keeps the world after the hand and the camera overlays,
|
||||
// as light (`Hdr::layered`); else it is kept here, before them.
|
||||
let layered = self.hdr.as_ref().is_some_and(|h| h.layered());
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(encoder, timing.as_mut());
|
||||
if layered {
|
||||
hdr.begin(&self.queue, !frame.title);
|
||||
} else {
|
||||
hdr.keep_world(encoder, timing.as_mut());
|
||||
}
|
||||
}
|
||||
if overlay.hand_quads > 0 {
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -11422,7 +11661,14 @@ impl Renderer {
|
||||
drop(pass);
|
||||
if let Some(hdr) = &self.hdr {
|
||||
hdr.mark_hand(encoder, timing.as_mut());
|
||||
if let Some(view) = hdr.hand_light_view() {
|
||||
// On the layered frame the hand's marks go to the frame, before they become
|
||||
// light with the world's.
|
||||
let marks = if layered {
|
||||
Some(view_target.clone())
|
||||
} else {
|
||||
hdr.hand_light_view()
|
||||
};
|
||||
if let Some(view) = marks {
|
||||
// The light sources in the hand may shine, the rest of it stays at the
|
||||
// world's brightness: nothing the world kept here shows through it.
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
@@ -11454,11 +11700,19 @@ impl Renderer {
|
||||
let quads = 0..overlay.hand_quads * 6;
|
||||
pass.set_pipeline(&self.hdr_hand_unmark_pipeline);
|
||||
pass.draw_indexed(quads.clone(), overlay.first_hand_vertex, 0..1);
|
||||
pass.set_pipeline(&self.hdr_mark_pipeline);
|
||||
pass.set_pipeline(&self.hdr_entity_mark_pipeline);
|
||||
pass.draw_indexed(quads, overlay.first_hand_vertex, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The layered frame's marks become light: what draws from here draws on light, and
|
||||
// nothing is lifted after.
|
||||
if layered
|
||||
&& !frame.title
|
||||
&& let Some(hdr) = &self.hdr
|
||||
{
|
||||
hdr.resolve(encoder, timing.as_mut());
|
||||
}
|
||||
// A native mod's turn after the hand, on the real frame only.
|
||||
if overlay.real && !frame.title {
|
||||
native_render::native_hand_drawn(&mut native_render::HandDrawn {
|
||||
@@ -11591,6 +11845,16 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
if layered && let Some(hdr) = &self.hdr {
|
||||
hdr.keep_world(encoder, timing.as_mut());
|
||||
}
|
||||
// What the HUD and menus draw on: the layered frame's HUD target, else the frame.
|
||||
let hud = self
|
||||
.hdr
|
||||
.as_ref()
|
||||
.and_then(|h| h.hud())
|
||||
.map(|t| t.create_view(&Default::default()));
|
||||
let hud_target = hud.as_ref().unwrap_or(view_target);
|
||||
let coverage = self.hdr.as_ref().and_then(|h| h.coverage_view());
|
||||
if let Some(view) = &coverage {
|
||||
hud_coverage::clear(encoder, view);
|
||||
@@ -11599,7 +11863,7 @@ impl Renderer {
|
||||
// the screen effects.
|
||||
if overlay.crosshair > 0 {
|
||||
let mut attachments = vec![Some(wgpu::RenderPassColorAttachment {
|
||||
view: view_target,
|
||||
view: hud_target,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -11640,7 +11904,7 @@ impl Renderer {
|
||||
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
|
||||
self.hud.draw_range(
|
||||
encoder,
|
||||
view_target,
|
||||
hud_target,
|
||||
coverage.as_ref(),
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
@@ -11652,18 +11916,38 @@ impl Renderer {
|
||||
let format = self.frame_format;
|
||||
let device = &self.device;
|
||||
let software = is_software(self.info.device_type);
|
||||
self.menu_blur
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
|
||||
.apply(
|
||||
&self.device,
|
||||
let blur = self
|
||||
.menu_blur
|
||||
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software));
|
||||
let size = [self.config.width, self.config.height];
|
||||
match self.hdr.as_ref().filter(|_| layered) {
|
||||
// The blur keeps light above white: it blurs the picture so far, the HUD over
|
||||
// the world's light, which becomes the world the menu draws over.
|
||||
Some(hdr) => {
|
||||
let (_, source) = blur.source(device, size);
|
||||
hdr.composite(encoder, &source, timing.as_mut());
|
||||
blur.blur(
|
||||
device,
|
||||
&self.queue,
|
||||
encoder,
|
||||
view_target,
|
||||
size,
|
||||
radius,
|
||||
timing.as_mut(),
|
||||
);
|
||||
hdr.keep_world(encoder, timing.as_mut());
|
||||
}
|
||||
None => blur.apply(
|
||||
device,
|
||||
&self.queue,
|
||||
encoder,
|
||||
target_texture,
|
||||
view_target,
|
||||
[self.config.width, self.config.height],
|
||||
size,
|
||||
radius,
|
||||
timing.as_mut(),
|
||||
);
|
||||
),
|
||||
}
|
||||
if let Some(view) = &coverage {
|
||||
hud_coverage::clear(encoder, view);
|
||||
}
|
||||
@@ -11672,7 +11956,7 @@ impl Renderer {
|
||||
}
|
||||
self.hud.draw_range(
|
||||
encoder,
|
||||
view_target,
|
||||
hud_target,
|
||||
coverage.as_ref(),
|
||||
&self.depth,
|
||||
frame.hud,
|
||||
|
||||
@@ -72,9 +72,29 @@ fn named(name: Option<&str>) -> Option<Output> {
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
frame: wgpu::Texture,
|
||||
/// The world as `keep_world` kept it; on the layered frame, the frame as the world and the
|
||||
/// hand marked it, for `resolve`.
|
||||
world: wgpu::Texture,
|
||||
coverage: Option<wgpu::Texture>,
|
||||
/// The layered frame's HUD target: what the HUD draws on.
|
||||
hud: Option<wgpu::Texture>,
|
||||
group: wgpu::BindGroup,
|
||||
/// The layered frame's passes' bindings: `resolve`, `keep_world` and `composite`.
|
||||
passes: Option<[wgpu::BindGroup; 3]>,
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`hud_coverage::supported`, docs/design/hdr-linear-light.md
|
||||
/// "The HUD and menus"): the world's marks become light once, after the world and the hand
|
||||
/// (`resolve`); the camera overlays draw on that light; the HUD draws on a target of its own,
|
||||
/// the world clipped to white (`keep_world`); and the last pass composites the HUD over the
|
||||
/// world's light by its coverage (`fs_hdr_layered`), as the menu blur does before it blurs
|
||||
/// (`composite`).
|
||||
struct Layered {
|
||||
resolve: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
keep: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
composite: (wgpu::BindGroupLayout, wgpu::RenderPipeline),
|
||||
/// What `resolve` and `composite` read, written before they run (`begin`).
|
||||
params: wgpu::Buffer,
|
||||
}
|
||||
|
||||
/// How one tier of light sources (`mesher::hdr_tier`) shines: `share` of the headroom for
|
||||
@@ -156,8 +176,7 @@ pub struct Hdr {
|
||||
targets: Option<Targets>,
|
||||
/// `HORIZON_HDR_DEBUG=1`: grey what isn't expanded, tint what is magenta.
|
||||
debug: bool,
|
||||
coverage: bool,
|
||||
coverage_debug: bool,
|
||||
layered: Option<Layered>,
|
||||
/// Paper white and peak brightness, in nits.
|
||||
pub paper_white: f32,
|
||||
pub peak: f32,
|
||||
@@ -199,21 +218,23 @@ impl Hdr {
|
||||
debug: Option<&str>,
|
||||
) -> Self {
|
||||
let coverage_debug = coverage && debug == Some("coverage");
|
||||
// The layered frame's last pass, else today's.
|
||||
let entry_point = if coverage { "fs_hdr_layered" } else { "fs_hdr" };
|
||||
let mut source = include_str!("../hdr.wgsl").to_owned();
|
||||
if coverage_debug {
|
||||
// The debug view wraps the last pass as it is, so the picture under the tint is
|
||||
// exactly the one without it.
|
||||
let entry =
|
||||
"@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>";
|
||||
assert!(
|
||||
source.contains(entry),
|
||||
"hdr.wgsl's fs_hdr changed its signature"
|
||||
let entry = format!(
|
||||
"@fragment fn {entry_point}(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32>"
|
||||
);
|
||||
source = source.replace(entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
|
||||
assert!(
|
||||
source.contains(&entry),
|
||||
"hdr.wgsl's {entry_point} changed its signature"
|
||||
);
|
||||
source = source.replace(&entry, "fn picture_hdr(pos: vec4<f32>) -> vec4<f32>");
|
||||
source.push_str(&entry);
|
||||
source.push_str(
|
||||
r#"
|
||||
@group(0) @binding(4) var coverage: texture_2d<f32>;
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
r#" {
|
||||
let picture = picture_hdr(pos);
|
||||
let c = textureLoad(coverage, vec2<i32>(pos.xy), 0).r;
|
||||
// Magenta at paper white, in the output's own encoding.
|
||||
@@ -228,46 +249,45 @@ impl Hdr {
|
||||
label: Some("Horizon HDR"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let mut entries = vec![
|
||||
texture(0),
|
||||
texture(1),
|
||||
texture(3),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
// hdr.wgsl's bindings: the textures at `textures`, and its `Params` at 2 if `params`.
|
||||
let layout = |label, textures: &[u32], params: bool| {
|
||||
let texture = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
];
|
||||
if coverage_debug {
|
||||
entries.push(texture(4));
|
||||
}
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
entries: &entries,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline_for = |format| {
|
||||
};
|
||||
let mut entries: Vec<_> = textures.iter().map(|&b| texture(b)).collect();
|
||||
if params {
|
||||
entries.push(wgpu::BindGroupLayoutEntry {
|
||||
binding: 2,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
});
|
||||
}
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(label),
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
..Default::default()
|
||||
});
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
label: Some(label),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
@@ -280,7 +300,7 @@ impl Hdr {
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_hdr"),
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
@@ -292,6 +312,30 @@ impl Hdr {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
|
||||
let display = layout("Horizon HDR bindings", layout_bindings, true);
|
||||
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let layered = coverage.then(|| {
|
||||
let with = |label, textures: &[u32], params, entry| {
|
||||
let layout = layout(label, textures, params);
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
(layout, pipeline)
|
||||
};
|
||||
Layered {
|
||||
resolve: with("Horizon HDR marks to light", &[1], true, "fs_resolve"),
|
||||
keep: with("Horizon HDR HUD backdrop", &[0], false, "fs_keep"),
|
||||
composite: with("Horizon HDR composite", &[0, 4, 5], true, "fs_composite"),
|
||||
params: params(),
|
||||
}
|
||||
});
|
||||
let alpha_pass = |label, blend| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
@@ -334,29 +378,20 @@ impl Hdr {
|
||||
alpha: scale_alpha,
|
||||
}),
|
||||
);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
size: 64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
layout,
|
||||
pipeline: pipeline_for(output.format()),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
layout: display,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
params: params(),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
capture_params: params(),
|
||||
output,
|
||||
format,
|
||||
targets: None,
|
||||
debug: debug == Some("1"),
|
||||
coverage,
|
||||
coverage_debug,
|
||||
layered,
|
||||
paper_white: 200.0,
|
||||
peak: 1000.0,
|
||||
black: 0.0,
|
||||
@@ -398,25 +433,160 @@ impl Hdr {
|
||||
self.format,
|
||||
wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let coverage = self.coverage.then(|| {
|
||||
let layered = self.layered.is_some();
|
||||
let coverage = layered.then(|| {
|
||||
texture(
|
||||
"HUD coverage",
|
||||
super::hud_coverage::FORMAT,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
let group = self.group(device, &frame, &world, coverage.as_ref(), &self.params);
|
||||
// In the frame's format, so the HUD's pipelines draw on it as on the frame with HDR
|
||||
// off; the last pass clips it at white.
|
||||
let hud = layered.then(|| {
|
||||
texture(
|
||||
"HUD",
|
||||
self.format,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
)
|
||||
});
|
||||
let group = self.group(
|
||||
device,
|
||||
&frame,
|
||||
&world,
|
||||
coverage.as_ref(),
|
||||
hud.as_ref(),
|
||||
&self.params,
|
||||
);
|
||||
let passes = self
|
||||
.layered
|
||||
.as_ref()
|
||||
.zip(coverage.as_ref().zip(hud.as_ref()))
|
||||
.map(|(l, (coverage, hud))| {
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let (frame, world, coverage, hud) =
|
||||
(view(&frame), view(&world), view(coverage), view(hud));
|
||||
let entry = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
let params = wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: l.params.as_entire_binding(),
|
||||
};
|
||||
let group = |layout, entries: &[wgpu::BindGroupEntry]| {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR layered bindings"),
|
||||
layout,
|
||||
entries,
|
||||
})
|
||||
};
|
||||
[
|
||||
group(&l.resolve.0, &[entry(1, &world), params.clone()]),
|
||||
group(&l.keep.0, &[entry(0, &frame)]),
|
||||
group(
|
||||
&l.composite.0,
|
||||
&[
|
||||
entry(0, &frame),
|
||||
params,
|
||||
entry(4, &coverage),
|
||||
entry(5, &hud),
|
||||
],
|
||||
),
|
||||
]
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
frame,
|
||||
world,
|
||||
coverage,
|
||||
hud,
|
||||
group,
|
||||
passes,
|
||||
});
|
||||
}
|
||||
self.targets.as_ref().unwrap().frame.clone()
|
||||
}
|
||||
|
||||
/// The float frame with HUD coverage (`Layered`).
|
||||
pub fn layered(&self) -> bool {
|
||||
self.layered.is_some()
|
||||
}
|
||||
|
||||
/// The layered frame's HUD target, which the HUD and menus draw on instead of the frame
|
||||
/// once `keep_world` made it, and which an ordinary screenshot shows.
|
||||
pub fn hud(&self) -> Option<&wgpu::Texture> {
|
||||
self.targets.as_ref()?.hud.as_ref()
|
||||
}
|
||||
|
||||
/// On the layered frame, before `resolve` and `composite`: what they read, for a frame that
|
||||
/// drew a world (`world`) or not.
|
||||
pub fn begin(&self, queue: &wgpu::Queue, world: bool) {
|
||||
if let Some(l) = &self.layered {
|
||||
queue.write_buffer(
|
||||
&l.params,
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, false)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// On the layered frame, after the world and the hand drew and marked it: the marks
|
||||
/// become light in the frame (`fs_resolve`), read from a copy of it in the world target.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let (Some(l), Some(t)) = (&self.layered, &self.targets) else {
|
||||
return;
|
||||
};
|
||||
let Some([group, ..]) = &t.passes else {
|
||||
return;
|
||||
};
|
||||
encoder.copy_texture_to_texture(
|
||||
t.frame.as_image_copy(),
|
||||
t.world.as_image_copy(),
|
||||
t.frame.size(),
|
||||
);
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&l.resolve.1,
|
||||
group,
|
||||
&view,
|
||||
"HDR marks to light",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
|
||||
/// On the layered frame: the picture so far (`fs_composite`), as the frame stores light,
|
||||
/// into `target`, for the menu blur to blur it and make it the world (`keep_world` after).
|
||||
pub fn composite(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
if let (
|
||||
Some(l),
|
||||
Some(Targets {
|
||||
passes: Some([_, _, group]),
|
||||
..
|
||||
}),
|
||||
) = (&self.layered, &self.targets)
|
||||
{
|
||||
Self::draw(
|
||||
encoder,
|
||||
&l.composite.1,
|
||||
group,
|
||||
target,
|
||||
"HDR composite",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn coverage_view(&self) -> Option<wgpu::TextureView> {
|
||||
Some(
|
||||
self.targets
|
||||
@@ -440,6 +610,9 @@ impl Hdr {
|
||||
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
|
||||
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
|
||||
/// on raises the alpha where it covers the world.
|
||||
///
|
||||
/// On the layered frame the frame keeps the world, the hand and the camera overlays, as
|
||||
/// light, and this makes the HUD's backdrop from it: clipped to white, alpha 0 (`fs_keep`).
|
||||
pub fn keep_world(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
@@ -448,6 +621,11 @@ impl Hdr {
|
||||
let Some(t) = &self.targets else {
|
||||
return;
|
||||
};
|
||||
if let (Some(l), Some(hud), Some([_, group, _])) = (&self.layered, &t.hud, &t.passes) {
|
||||
let view = hud.create_view(&Default::default());
|
||||
Self::draw(encoder, &l.keep.1, group, &view, "HDR HUD backdrop", timing);
|
||||
return;
|
||||
}
|
||||
encoder.copy_texture_to_texture(
|
||||
t.frame.as_image_copy(),
|
||||
t.world.as_image_copy(),
|
||||
@@ -475,13 +653,14 @@ impl Hdr {
|
||||
|
||||
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
|
||||
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
|
||||
/// from the UI (UI Brightness).
|
||||
/// from the UI (UI Brightness). The layered frame keeps the hand before `keep_world`,
|
||||
/// so it needs no mark there.
|
||||
pub fn mark_hand(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some(t) = &self.targets else {
|
||||
let Some(t) = self.targets.as_ref().filter(|_| self.layered.is_none()) else {
|
||||
return;
|
||||
};
|
||||
let view = t.frame.create_view(&Default::default());
|
||||
@@ -512,9 +691,10 @@ impl Hdr {
|
||||
}
|
||||
|
||||
/// The world as `keep_world` kept it: where the hand's marks go (its alpha, which HDR output
|
||||
/// reads for it too), and what tints the world rather than covering it.
|
||||
/// reads for it too), and what tints the world rather than covering it. None on the
|
||||
/// layered frame, where both draw on the frame before `keep_world`.
|
||||
pub fn hand_light_view(&self) -> Option<wgpu::TextureView> {
|
||||
let t = self.targets.as_ref()?;
|
||||
let t = self.targets.as_ref().filter(|_| self.layered.is_none())?;
|
||||
Some(t.world.create_view(&Default::default()))
|
||||
}
|
||||
|
||||
@@ -540,7 +720,15 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, self.output == Output::Pq)),
|
||||
);
|
||||
Self::draw(encoder, &self.pipeline, &t.group, target, timing);
|
||||
let view = target.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&self.pipeline,
|
||||
&t.group,
|
||||
&view,
|
||||
"HDR output",
|
||||
timing,
|
||||
);
|
||||
}
|
||||
|
||||
/// The frame `present` writes, as HDR10 (BT.2020, PQ, 10 bits a channel) whatever
|
||||
@@ -568,6 +756,7 @@ impl Hdr {
|
||||
&t.frame,
|
||||
&t.world,
|
||||
t.coverage.as_ref(),
|
||||
t.hud.as_ref(),
|
||||
&self.capture_params,
|
||||
);
|
||||
queue.write_buffer(
|
||||
@@ -575,7 +764,15 @@ impl Hdr {
|
||||
0,
|
||||
bytemuck::cast_slice(&self.params(world, true)),
|
||||
);
|
||||
Self::draw(encoder, &self.capture_pipeline, &group, &texture, None);
|
||||
let view = texture.create_view(&Default::default());
|
||||
Self::draw(
|
||||
encoder,
|
||||
&self.capture_pipeline,
|
||||
&group,
|
||||
&view,
|
||||
"HDR output",
|
||||
None,
|
||||
);
|
||||
Some(texture)
|
||||
}
|
||||
|
||||
@@ -585,26 +782,27 @@ impl Hdr {
|
||||
frame: &wgpu::Texture,
|
||||
world: &wgpu::Texture,
|
||||
coverage: Option<&wgpu::Texture>,
|
||||
hud: Option<&wgpu::Texture>,
|
||||
params: &wgpu::Buffer,
|
||||
) -> wgpu::BindGroup {
|
||||
let frame = frame.create_view(&Default::default());
|
||||
let world = world.create_view(&Default::default());
|
||||
let coverage = coverage.map(|t| t.create_view(&Default::default()));
|
||||
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
|
||||
let (frame, world) = (view(frame), view(world));
|
||||
let layered = coverage.zip(hud).map(|(c, h)| (view(c), view(h)));
|
||||
let texture = |binding, view| wgpu::BindGroupEntry {
|
||||
binding,
|
||||
resource: wgpu::BindingResource::TextureView(view),
|
||||
};
|
||||
let mut entries = vec![
|
||||
texture(0, &frame),
|
||||
texture(1, &world),
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: params.as_entire_binding(),
|
||||
},
|
||||
texture(3, &self.logo),
|
||||
];
|
||||
if self.coverage_debug {
|
||||
entries.push(texture(4, coverage.as_ref().unwrap()));
|
||||
match &layered {
|
||||
Some((coverage, hud)) => entries.extend([texture(4, coverage), texture(5, hud)]),
|
||||
None => entries.push(texture(1, &world)),
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Horizon HDR bindings"),
|
||||
@@ -641,16 +839,17 @@ impl Hdr {
|
||||
params
|
||||
}
|
||||
|
||||
/// One full-screen pass of `pipeline` into `view`.
|
||||
fn draw(
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
group: &wgpu::BindGroup,
|
||||
target: &wgpu::Texture,
|
||||
view: &wgpu::TextureView,
|
||||
label: &'static str,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let view = target.create_view(&Default::default());
|
||||
let attachments = [Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
@@ -659,8 +858,8 @@ impl Hdr {
|
||||
},
|
||||
})];
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon HDR"),
|
||||
timestamp_writes: timing.and_then(|t| t.writes("HDR output")),
|
||||
label: Some(label),
|
||||
timestamp_writes: timing.and_then(|t| t.writes(label)),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
@@ -723,7 +922,7 @@ mod tests {
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
fn coverage_target_resizes_resets_and_is_sampled_only_by_the_debug_view() {
|
||||
fn coverage_target_resizes_resets_and_shows_in_the_debug_view() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
@@ -808,6 +1007,190 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn gpu() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
pollster::block_on(adapter.request_device(&Default::default())).ok()
|
||||
}
|
||||
|
||||
/// Clears all of `texture` to `color`.
|
||||
fn fill(encoder: &mut wgpu::CommandEncoder, texture: &wgpu::Texture, color: [f64; 4]) {
|
||||
let view = texture.create_view(&Default::default());
|
||||
let mut attachment = super::super::hud_coverage::attachment(&view);
|
||||
let [r, g, b, a] = color;
|
||||
attachment.ops.load = wgpu::LoadOp::Clear(wgpu::Color { r, g, b, a });
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(attachment)],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// The first texel of `texture` once `encoder` ran: four half floats, or 10-bit PQ codes
|
||||
/// for HDR10.
|
||||
fn texel(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
mut encoder: wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> [f32; 4] {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let bytes = buffer.get_mapped_range(..).unwrap();
|
||||
if texture.format() == Output::Pq.format() {
|
||||
let pixel = u32::from_le_bytes(bytes[..4].try_into().unwrap());
|
||||
return [0, 10, 20, 30].map(|shift| ((pixel >> shift) & 0x3ff) as f32);
|
||||
}
|
||||
std::array::from_fn(|i| half::f16::from_le_bytes([bytes[2 * i], bytes[2 * i + 1]]).to_f32())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_layered_frame_shows_the_world_as_the_last_pass_lifted_it() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
// What the last pass shows of a world pixel nothing covers, from today's frame
|
||||
// (`layered` false) or the layered one, whose marks turn into light before it.
|
||||
let shown = |layered: bool, world: [f64; 4]| {
|
||||
let mut hdr =
|
||||
Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, layered, None);
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, world);
|
||||
if layered {
|
||||
hdr.begin(&queue, true);
|
||||
hdr.resolve(&mut encoder, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
} else {
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
}
|
||||
let capture = hdr.capture(&device, &queue, &mut encoder, true).unwrap();
|
||||
texel(&device, &queue, encoder, &capture)
|
||||
};
|
||||
// Unmarked, below white and above it; HDR lighting; light sources of each tier.
|
||||
for world in [
|
||||
[0.5, 0.4, 0.3, 1.0],
|
||||
[1.5, 1.2, 0.6, 1.0],
|
||||
[0.8, 0.7, 0.6, 0.88],
|
||||
[0.9, 0.8, 0.4, 0.05],
|
||||
[0.9, 0.8, 0.4, 0.2],
|
||||
[0.9, 0.8, 0.4, 0.4],
|
||||
[0.9, 0.8, 0.4, 0.6],
|
||||
] {
|
||||
let (today, layered) = (shown(false, world), shown(true, world));
|
||||
// Half floats round the light apart by a step at most.
|
||||
assert!(
|
||||
today.iter().zip(layered).all(|(a, b)| (a - b).abs() <= 1.0),
|
||||
"{world:?}: {today:?} {layered:?}"
|
||||
);
|
||||
if world[3] < 1.0 {
|
||||
assert!(layered[0] > shown(true, [world[0], world[1], world[2], 1.0])[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hud_lays_over_the_worlds_light_by_its_coverage() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: F::Rgba16Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
// The world's light, the HUD's target over it (on the world clipped to white), its
|
||||
// coverage, UI Brightness, and the composite, all in gamma as the frame stores them.
|
||||
for (world, hud, coverage, ui, want) in [
|
||||
// Nothing over it: the world, light above white included.
|
||||
(3.0, 1.0, 0.0, 1.0, 3.0),
|
||||
// Half-transparent black: half of the world shows, as the HUD blends.
|
||||
(2.0, 0.5, 0.5, 1.0, 1.0),
|
||||
// Covered: the HUD as drawn, at UI Brightness.
|
||||
(2.0, 0.3, 1.0, 1.0, 0.3),
|
||||
(2.0, 0.3, 1.0, 0.5, 0.3 * 0.5f32.powf(1.0 / 2.2)),
|
||||
// Added over the world (the text highlight), which it doesn't hide.
|
||||
(0.6, 0.8, 0.0, 1.0, 0.8),
|
||||
// At or below white, at 100%: the HUD's picture as drawn.
|
||||
(0.7, 0.45, 0.5, 1.0, 0.45),
|
||||
] {
|
||||
hdr.ui = ui;
|
||||
let frame = hdr.frame(&device, [1, 1]);
|
||||
let t = hdr.targets.as_ref().unwrap();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [world, world, world, 1.0]);
|
||||
fill(&mut encoder, t.hud.as_ref().unwrap(), [hud, hud, hud, 0.0]);
|
||||
fill(&mut encoder, t.coverage.as_ref().unwrap(), [coverage; 4]);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.composite(&mut encoder, &target.create_view(&Default::default()), None);
|
||||
let got = texel(&device, &queue, encoder, &target);
|
||||
assert!(
|
||||
(got[0] - want).abs() < 0.01 && got[3] == 1.0,
|
||||
"{world} {hud} {coverage} {ui}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_menu_blur_keeps_the_light_it_blurs() {
|
||||
let Some((device, queue)) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
|
||||
let size = [8, 8];
|
||||
let frame = hdr.frame(&device, size);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
fill(&mut encoder, &frame, [4.0, 4.0, 4.0, 1.0]);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
let mut blur = crate::menu_blur::MenuBlur::new(&device, F::Rgba16Float, false);
|
||||
let (_, source) = blur.source(&device, size);
|
||||
hdr.begin(&queue, true);
|
||||
hdr.composite(&mut encoder, &source, None);
|
||||
let view = frame.create_view(&Default::default());
|
||||
blur.blur(&device, &queue, &mut encoder, &view, size, 3.0, None);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
let hud = hdr.hud().unwrap().clone();
|
||||
// The blurred world keeps its light, and the HUD's backdrop starts again from it.
|
||||
let [r, ..] = texel(&device, &queue, encoder, &frame);
|
||||
assert!((r - 4.0).abs() < 0.01, "{r}");
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
assert_eq!(texel(&device, &queue, encoder, &hud), [1.0, 1.0, 1.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_shaders_validate() {
|
||||
let entity = crate::overlay::wgsl_constants() + include_str!("../entity.wgsl");
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
//! A held world (`WorldHold`, the native hook `native_world_hold`): the terrain goes on being
|
||||
//! drawn as it stands while what changes under it is meshed, and all of it takes its place on
|
||||
//! one frame. A section already on screen that is meshed again is uploaded to the arena as ever,
|
||||
//! over as many frames as its mesh takes to come, but waits beside the mesh drawn (`Hold`);
|
||||
//! letting go only swaps which of the two is drawn, with nothing left to upload. Sections not on
|
||||
//! screen yet (terrain streaming in) are not held.
|
||||
//!
|
||||
//! Both meshes of a waiting section are in memory, so a hold costs up to what the changed land's
|
||||
//! new meshes come to. It is bounded, and each bound lets go at once rather than losing anything:
|
||||
//! `MAX_BYTES` of waiting meshes, `MAX_HOLD` of time, and the arena running out
|
||||
//! (`Renderer::upload`). It never outlives what asked for it: the mods are asked every frame,
|
||||
//! and the sections' being cleared (the world left, another dimension) drops what waits. A hold
|
||||
//! that ended by itself (spent) stays ended until no mod has asked for a frame.
|
||||
use super::SectionKey;
|
||||
use crate::fast_hash::{FxHashMap, FxHashSet};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The longest a hold lasts.
|
||||
pub(super) const MAX_HOLD: Duration = Duration::from_secs(30);
|
||||
/// The most bytes of new meshes that wait.
|
||||
pub(super) const MAX_BYTES: u64 = 1 << 30;
|
||||
/// The most circles a hold is kept to (`WorldHold::hold_within`).
|
||||
pub const MAX_AREAS: usize = 64;
|
||||
|
||||
/// What the mods ask of the terrain's picture this frame (`native_world_hold`): nothing, all of
|
||||
/// it held, or only the sections inside some circles. Several mods add up: one holding all of it
|
||||
/// holds all of it, and circles join.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct WorldHold {
|
||||
all: bool,
|
||||
within: Vec<[f64; 3]>,
|
||||
}
|
||||
impl WorldHold {
|
||||
/// Hold the whole world's terrain as it is drawn, this frame.
|
||||
pub fn hold(&mut self) {
|
||||
self.all = true;
|
||||
}
|
||||
/// Hold the sections whose middle lies within `radius` blocks of (`x`, `z`), this frame. A
|
||||
/// circle that isn't finite or has a negative radius is left out, as are those past
|
||||
/// `MAX_AREAS`.
|
||||
pub fn hold_within(&mut self, x: f64, z: f64, radius: f64) {
|
||||
let good = x.is_finite() && z.is_finite() && radius.is_finite() && radius >= 0.0;
|
||||
if good && self.within.len() < MAX_AREAS {
|
||||
self.within.push([x, z, radius]);
|
||||
}
|
||||
}
|
||||
/// Whether anything is held.
|
||||
pub fn is_held(&self) -> bool {
|
||||
self.all || !self.within.is_empty()
|
||||
}
|
||||
/// Whether section `key` is in what is held: all of the world, or a circle its middle lies in.
|
||||
pub fn covers(&self, key: SectionKey) -> bool {
|
||||
if self.all {
|
||||
return true;
|
||||
}
|
||||
let (x, z) = (f64::from(key.0) * 16.0 + 8.0, f64::from(key.2) * 16.0 + 8.0);
|
||||
self.within
|
||||
.iter()
|
||||
.any(|&[cx, cz, r]| (x - cx).powi(2) + (z - cz).powi(2) <= r * r)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a frame's ask means for the sections waiting.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(super) enum Release {
|
||||
/// They go on waiting.
|
||||
None,
|
||||
/// Those the hold no longer covers take their place.
|
||||
Outside,
|
||||
/// All of them take their place.
|
||||
All,
|
||||
}
|
||||
|
||||
/// A section waiting behind the hold and the bytes its new mesh takes.
|
||||
struct Waiting<S> {
|
||||
section: S,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
/// The renderer's side of a hold: whether it is on, what it covers, and the sections waiting.
|
||||
pub(super) struct Hold<S> {
|
||||
ask: WorldHold,
|
||||
since: Option<Instant>,
|
||||
spent: bool,
|
||||
waiting: FxHashMap<SectionKey, Waiting<S>>,
|
||||
bytes: u64,
|
||||
}
|
||||
impl<S> Default for Hold<S> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
ask: WorldHold::default(),
|
||||
since: None,
|
||||
spent: false,
|
||||
waiting: FxHashMap::default(),
|
||||
bytes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<S> Hold<S> {
|
||||
/// Whether a new mesh of section `key` waits.
|
||||
pub fn holds(&self, key: SectionKey) -> bool {
|
||||
self.since.is_some() && !self.spent && self.ask.covers(key)
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.waiting.is_empty()
|
||||
}
|
||||
pub fn bytes(&self) -> u64 {
|
||||
self.bytes
|
||||
}
|
||||
/// A frame's ask, at `now`.
|
||||
pub fn ask(&mut self, ask: &WorldHold, now: Instant) -> Release {
|
||||
if !ask.is_held() {
|
||||
let was = self.since.is_some();
|
||||
(self.since, self.spent) = (None, false);
|
||||
self.ask = WorldHold::default();
|
||||
return if was && !self.waiting.is_empty() {
|
||||
Release::All
|
||||
} else {
|
||||
Release::None
|
||||
};
|
||||
}
|
||||
if self.spent {
|
||||
return Release::None;
|
||||
}
|
||||
let since = *self.since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= MAX_HOLD {
|
||||
log::warn!(
|
||||
"Horizon: a mod held the world for {} s: let go",
|
||||
MAX_HOLD.as_secs()
|
||||
);
|
||||
self.spend();
|
||||
return Release::All;
|
||||
}
|
||||
if *ask == self.ask {
|
||||
return Release::None;
|
||||
}
|
||||
// (fewer circles or smaller: some of what waits is let go; more: nothing waits that didn't)
|
||||
self.ask.clone_from(ask);
|
||||
if self.waiting.is_empty() {
|
||||
Release::None
|
||||
} else {
|
||||
Release::Outside
|
||||
}
|
||||
}
|
||||
/// Ended by itself: nothing waits from now on, until no mod has asked for a frame.
|
||||
/// What waited is the caller's to place (`take_all`) or drop.
|
||||
pub fn spend(&mut self) {
|
||||
self.spent = self.since.is_some();
|
||||
}
|
||||
/// Section `key`'s new mesh waits: the one that waited before for it comes back, to free.
|
||||
/// The bool is whether what waits is now too much (`MAX_BYTES`).
|
||||
pub fn wait(&mut self, key: SectionKey, section: S, bytes: u64) -> (Option<S>, bool) {
|
||||
let old = self.take(key);
|
||||
self.bytes += bytes;
|
||||
self.waiting.insert(key, Waiting { section, bytes });
|
||||
(old, self.bytes > MAX_BYTES)
|
||||
}
|
||||
/// Section `key`'s waiting mesh, no longer waiting.
|
||||
pub fn take(&mut self, key: SectionKey) -> Option<S> {
|
||||
if self.waiting.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let w = self.waiting.remove(&key)?;
|
||||
self.bytes -= w.bytes;
|
||||
Some(w.section)
|
||||
}
|
||||
/// Everything waiting, no longer waiting.
|
||||
pub fn take_all(&mut self) -> Vec<(SectionKey, S)> {
|
||||
self.bytes = 0;
|
||||
self.waiting.drain().map(|(k, w)| (k, w.section)).collect()
|
||||
}
|
||||
/// What waits that the hold no longer covers.
|
||||
pub fn take_outside(&mut self) -> Vec<(SectionKey, S)> {
|
||||
let ask = &self.ask;
|
||||
let all = self.since.is_none() || self.spent;
|
||||
let out: Vec<_> = self
|
||||
.waiting
|
||||
.extract_if(|&k, _| all || !ask.covers(k))
|
||||
.collect();
|
||||
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
|
||||
out.into_iter().map(|(k, w)| (k, w.section)).collect()
|
||||
}
|
||||
/// What waits in these columns, which are going.
|
||||
pub fn take_columns(&mut self, columns: &FxHashSet<(i32, i32)>) -> Vec<S> {
|
||||
if self.waiting.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let out: Vec<_> = self
|
||||
.waiting
|
||||
.extract_if(|k, _| columns.contains(&(k.0, k.2)))
|
||||
.collect();
|
||||
self.bytes -= out.iter().map(|(_, w)| w.bytes).sum::<u64>();
|
||||
out.into_iter().map(|(_, w)| w.section).collect()
|
||||
}
|
||||
/// The sections are all gone: what waits goes with them and the hold is spent.
|
||||
pub fn clear(&mut self) {
|
||||
self.waiting.clear();
|
||||
self.bytes = 0;
|
||||
self.spend();
|
||||
}
|
||||
pub fn sections(&self) -> impl Iterator<Item = &S> {
|
||||
self.waiting.values().map(|w| &w.section)
|
||||
}
|
||||
pub fn sections_mut(&mut self) -> impl Iterator<Item = &mut S> {
|
||||
self.waiting.values_mut().map(|w| &mut w.section)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn all() -> WorldHold {
|
||||
let mut h = WorldHold::default();
|
||||
h.hold();
|
||||
h
|
||||
}
|
||||
fn within(circles: &[[f64; 3]]) -> WorldHold {
|
||||
let mut h = WorldHold::default();
|
||||
for &[x, z, r] in circles {
|
||||
h.hold_within(x, z, r);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hold_covers_sections_by_their_middle() {
|
||||
assert!(all().covers((900, 0, -900)));
|
||||
assert!(!WorldHold::default().covers((0, 0, 0)));
|
||||
assert!(within(&[[8.0, 8.0, 1.0]]).covers((0, 3, 0)));
|
||||
assert!(!within(&[[0.0, 0.0, 11.0]]).covers((0, 3, 0)));
|
||||
assert!(within(&[[0.0, 0.0, 12.0]]).covers((0, 3, 0)));
|
||||
assert!(within(&[[1000.0, 0.0, 1.0], [24.0, 8.0, 0.5]]).covers((1, 0, 0)));
|
||||
assert!(within(&[[-8.0, -8.0, 0.0]]).covers((-1, 0, -1)));
|
||||
}
|
||||
#[test]
|
||||
fn bad_circles_are_left_out_and_there_are_at_most_max_areas() {
|
||||
let h = within(&[
|
||||
[f64::NAN, 0.0, 5.0],
|
||||
[0.0, f64::INFINITY, 5.0],
|
||||
[0.0, 0.0, -1.0],
|
||||
[0.0, 0.0, f64::INFINITY],
|
||||
]);
|
||||
assert!(!h.is_held());
|
||||
let mut many = WorldHold::default();
|
||||
for i in 0..MAX_AREAS + 10 {
|
||||
many.hold_within(i as f64 * 100.0, 0.0, 1.0);
|
||||
}
|
||||
assert_eq!(many.within.len(), MAX_AREAS);
|
||||
// (several mods: all of it from one holds all of it)
|
||||
many.hold();
|
||||
assert!(many.covers((-5000, 0, 5000)));
|
||||
}
|
||||
#[test]
|
||||
fn a_hold_starts_and_lets_go_when_no_one_asks() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t), Release::None);
|
||||
assert!(g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t + Duration::from_secs(5)), Release::None);
|
||||
// (nothing waited: nothing to place)
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::None);
|
||||
g.ask(&all(), t);
|
||||
g.wait((0, 0, 0), 7, 100);
|
||||
assert_eq!(g.ask(&WorldHold::default(), t), Release::All);
|
||||
assert_eq!(g.take_all(), vec![((0, 0, 0), 7)]);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn a_hold_ends_by_itself_and_stays_ended_until_let_go() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
assert_eq!(g.ask(&all(), t + MAX_HOLD), Release::All);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
assert_eq!(g.ask(&all(), t + MAX_HOLD * 2), Release::None);
|
||||
assert!(!g.holds((0, 0, 0)));
|
||||
g.ask(&WorldHold::default(), t + MAX_HOLD * 2);
|
||||
g.ask(&all(), t + MAX_HOLD * 3);
|
||||
assert!(g.holds((0, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn smaller_circles_let_go_what_they_leave() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&within(&[[0.0, 0.0, 100.0]]), t);
|
||||
assert!(g.holds((0, 0, 0)) && g.holds((5, 0, 0)) && !g.holds((7, 0, 0)));
|
||||
g.wait((0, 0, 0), 1, 10);
|
||||
g.wait((5, 0, 0), 2, 20);
|
||||
assert_eq!(g.ask(&within(&[[0.0, 0.0, 100.0]]), t), Release::None);
|
||||
assert_eq!(g.ask(&within(&[[0.0, 0.0, 40.0]]), t), Release::Outside);
|
||||
assert_eq!(g.take_outside(), vec![((5, 0, 0), 2)]);
|
||||
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
|
||||
assert!(!g.holds((5, 0, 0)));
|
||||
// (to all of it again: nothing more is let go, and all of it waits again)
|
||||
assert_eq!(g.ask(&all(), t), Release::Outside);
|
||||
assert!(g.take_outside().is_empty());
|
||||
assert!(g.holds((5, 0, 0)));
|
||||
}
|
||||
#[test]
|
||||
fn what_waits_is_counted_and_capped() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
assert_eq!(g.wait((0, 0, 0), 1, MAX_BYTES - 10), (None, false));
|
||||
// (a newer mesh of the same section replaces it, and the old one comes back to free)
|
||||
assert_eq!(g.wait((0, 0, 0), 2, MAX_BYTES - 20), (Some(1), false));
|
||||
assert_eq!(g.bytes(), MAX_BYTES - 20);
|
||||
assert_eq!(g.wait((1, 0, 0), 3, 20), (None, false));
|
||||
assert_eq!(g.wait((2, 0, 0), 4, 1), (None, true));
|
||||
assert_eq!(g.take((1, 0, 0)), Some(3));
|
||||
assert_eq!(g.take((1, 0, 0)), None);
|
||||
assert_eq!(g.bytes(), MAX_BYTES - 19);
|
||||
}
|
||||
#[test]
|
||||
fn columns_going_and_the_sections_cleared_drop_what_waits() {
|
||||
let (mut g, t) = (Hold::<u32>::default(), Instant::now());
|
||||
g.ask(&all(), t);
|
||||
g.wait((0, 0, 0), 1, 10);
|
||||
g.wait((0, 5, 0), 2, 10);
|
||||
g.wait((3, 0, 0), 3, 10);
|
||||
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
|
||||
let mut dropped = g.take_columns(&gone);
|
||||
dropped.sort_unstable();
|
||||
assert_eq!(dropped, vec![1, 2]);
|
||||
assert_eq!((g.sections().count(), g.bytes()), (1, 10));
|
||||
g.clear();
|
||||
assert!(g.is_empty() && g.bytes() == 0 && !g.holds((3, 0, 0)));
|
||||
// (cleared while held: spent until let go; with no hold, nothing to end)
|
||||
assert_eq!(g.ask(&all(), t), Release::None);
|
||||
assert!(!g.holds((3, 0, 0)));
|
||||
let mut none = Hold::<u32>::default();
|
||||
none.clear();
|
||||
none.ask(&all(), t);
|
||||
assert!(none.holds((3, 0, 0)));
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,23 @@ struct HorizonMaterialOut {
|
||||
@location(14) @interpolate(flat) normal: vec3<f32>,
|
||||
}
|
||||
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);
|
||||
return horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
|
||||
light: vec2<u32>, origin: vec4<f32>) -> HorizonMaterialOut {
|
||||
let pos = horizon_material_displace(position + origin.xyz, uv, layer, color, light);
|
||||
var out = horizon_material_out(pos, uv, layer, color, vec2<f32>(light));
|
||||
// The section's fade-in (`RenderSection.getVisibility`), as how far it is from whole, in the
|
||||
// high 16 bits of `hdr_code`: no varying of its own, and 0 (whole) where nothing fades. It is
|
||||
// the same for the whole section, so the flat varying loses nothing.
|
||||
let instance = horizon_terrain_instance_visibility(origin.w);
|
||||
out.hdr_code |= u32(round((1.0 - instance) * 65535.0)) << 16u;
|
||||
return out;
|
||||
}
|
||||
// terrain.wgsl's fade-in (terrain.vsh's chunkVisibility): whole within 16 blocks, the section's
|
||||
// own visibility from 32 out, toward the fog colour, alpha kept. The distance blend runs here on
|
||||
// the interpolated distance, as vanilla's interpolated `visibility`, not once per triangle.
|
||||
fn horizon_material_faded(in: HorizonMaterialOut, color: vec4<f32>) -> vec4<f32> {
|
||||
let instance = 1.0 - f32(in.hdr_code >> 16u) / 65535.0;
|
||||
let visibility = mix(1.0, instance, clamp((in.spherical - 16.0) / 16.0, 0.0, 1.0));
|
||||
return vec4(mix(horizon_terrain_globals.fog_color.rgb, color.rgb, visibility), color.a);
|
||||
}
|
||||
// `light`: sky, block, 16 per level.
|
||||
fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
|
||||
@@ -94,7 +108,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
|
||||
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
|
||||
@location(5) origin: vec4<f32>) -> HorizonMaterialOut {
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
color, light, origin);
|
||||
}
|
||||
@vertex fn horizon_material_vs_flat(
|
||||
@builtin(vertex_index) vertex: u32,
|
||||
@@ -103,7 +117,7 @@ fn horizon_material_out(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f
|
||||
) -> HorizonMaterialOut {
|
||||
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[vertex >> 8u]];
|
||||
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
|
||||
color, light, origin.xyz);
|
||||
color, light, origin);
|
||||
}
|
||||
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
|
||||
// in the pack's frame group (3), one array each, a blank 1x1 without them.
|
||||
@@ -181,9 +195,10 @@ struct HorizonMaterialSolid {
|
||||
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
|
||||
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
|
||||
fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
if in.hdr_code > 0u {
|
||||
let whole = in.hdr_code > 4u;
|
||||
let tier = select(in.hdr_code, in.hdr_code - 4u, whole);
|
||||
let code = in.hdr_code & 15u;
|
||||
if code > 0u {
|
||||
let whole = code > 4u;
|
||||
let tier = select(code, code - 4u, whole);
|
||||
let l = in.layer & 0x7fffu;
|
||||
let t = textureLoad(horizon_terrain_emission_thresholds,
|
||||
vec2(i32(l % horizon_terrain_SPRITE_SPLIT), i32(l / horizon_terrain_SPRITE_SPLIT)), 0);
|
||||
@@ -202,7 +217,7 @@ fn horizon_material_mark(in: HorizonMaterialOut, texel: vec4<f32>) -> f32 {
|
||||
return horizon_material_solid(in);
|
||||
}
|
||||
fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
let color = horizon_material_color(in, false);
|
||||
let color = horizon_material_faded(in, horizon_material_color(in, false));
|
||||
let texel = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
|
||||
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
|
||||
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
|
||||
@@ -216,7 +231,7 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
|
||||
let color = horizon_material_color(in, true);
|
||||
let color = horizon_material_faded(in, horizon_material_color(in, true));
|
||||
if color.a < 0.01 { discard; }
|
||||
return horizon_material_fogged(color, in.spherical, in.cylindrical);
|
||||
}
|
||||
@@ -582,6 +597,25 @@ mod tests {
|
||||
report.stages.remove(0)
|
||||
}
|
||||
|
||||
/// The fade-in packed above the HDR code: a section a quarter visible (`section_instance`
|
||||
/// with step 1: mantissa `1 - visibility` = 0.75) keeps its visibility through the code, and
|
||||
/// a fragment 24 blocks away, half-way into the 16-block blend, is 1 - (1 - 0.25) / 2 = 0.625
|
||||
/// visible; the code's low nibble survives.
|
||||
#[test]
|
||||
fn the_fade_in_rides_the_hdr_code() {
|
||||
let w = f32::from_bits(1.0f32.to_bits() | (0.75 * 8_388_608.0) as u32);
|
||||
// `horizon_material_vertex`.
|
||||
let instance = 1.0 - (w.to_bits() & 0x7fffff) as f32 / 8_388_608.0;
|
||||
let code = 7u32 | (((1.0 - instance) * 65535.0).round() as u32) << 16;
|
||||
// `horizon_material_faded` and `horizon_material_mark`.
|
||||
let unpacked = 1.0 - (code >> 16) as f32 / 65535.0;
|
||||
assert!((unpacked - 0.25).abs() < 1.0 / 65535.0);
|
||||
let blend = ((24.0f32 - 16.0) / 16.0).clamp(0.0, 1.0);
|
||||
let visibility = 1.0 + (unpacked - 1.0) * blend;
|
||||
assert!((visibility - 0.625).abs() < 1.0 / 65535.0);
|
||||
assert_eq!(code & 15, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_terrain_takes_a_prefix_and_validates_with_a_pack() {
|
||||
let terrain = vanilla_terrain();
|
||||
|
||||
@@ -728,6 +728,13 @@ impl PackAtlas {
|
||||
),
|
||||
("mc_Entity", entity),
|
||||
("at_midBlock", mid_block),
|
||||
// Horizon's own: the face's HDR tier (`mesher::emitting`'s code in the colour's low
|
||||
// alpha nibble): 0 for none, 1 to 4 for low to very bright, plus 4 when the whole
|
||||
// face shines (`mesher::WHOLE`).
|
||||
(
|
||||
"horizonTier",
|
||||
"float(15u - (uint(round(hz_v_color.a * 255.0)) & 15u))",
|
||||
),
|
||||
(
|
||||
"mc_midTexCoord",
|
||||
"vec4((hz_cell + hz_mid) * hz_scale, 0.0, 1.0)",
|
||||
|
||||
@@ -157,6 +157,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
flat,
|
||||
geometry,
|
||||
origin,
|
||||
0x80ffffff,
|
||||
);
|
||||
let fade = if requested { expected } else { 1.0 };
|
||||
let expected = [
|
||||
@@ -182,6 +183,96 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
|
||||
assert_eq!(count, 98);
|
||||
}
|
||||
|
||||
/// `horizonTier`: the HDR tier the mesher marks a light source's faces with (`mesher::emitting`),
|
||||
/// read by a pack's terrain program, flat or instanced.
|
||||
#[test]
|
||||
fn terrain_tier_reaches_pack_pixels() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
|
||||
Ok(adapter) => adapter,
|
||||
Err(error) => {
|
||||
eprintln!("SKIP terrain tier GPU control: no adapter: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let array = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 2,
|
||||
height: 2,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
|
||||
queue.submit([encoder.finish()]);
|
||||
let folder = ProbePack(
|
||||
std::env::temp_dir().join(format!("horizon-terrain-tier-{}", std::process::id())),
|
||||
);
|
||||
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/gbuffers_terrain.vsh"),
|
||||
"#version 330\nin float horizonTier;\nout vec4 observation;\n\
|
||||
void main() {\nobservation = vec4(horizonTier, 0.0, 0.0, 0.0);\n\
|
||||
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
folder.0.join("shaders/gbuffers_terrain.fsh"),
|
||||
"#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
|
||||
void main() { pixel = floatBitsToUint(observation); }",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
use crate::mesher::{BRIGHT, LOW, VERY_BRIGHT, WHOLE, emitting};
|
||||
// No light, low, very bright, and bright all over (4 more).
|
||||
let cases = [
|
||||
(0, 0.0),
|
||||
(LOW, 1.0),
|
||||
(VERY_BRIGHT, 4.0),
|
||||
(BRIGHT | WHOLE, 7.0),
|
||||
];
|
||||
for flat in [false, true] {
|
||||
let post = horizon_shaders::load_post(
|
||||
&pack,
|
||||
"",
|
||||
&properties,
|
||||
¯os,
|
||||
&horizon_shaders::GameInputs {
|
||||
terrain: Some(atlas.terrain_inputs(flat)),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let program = post
|
||||
.terrain
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| panic!("{:?}", post.notes));
|
||||
for (tier, expected) in cases {
|
||||
let origin = section_instance([0.0; 3], 0, 1.0);
|
||||
let pixel = sample(
|
||||
&device,
|
||||
&queue,
|
||||
&atlas,
|
||||
program.program.as_ref().unwrap(),
|
||||
flat,
|
||||
false,
|
||||
origin,
|
||||
emitting(0xffff_ffff, tier),
|
||||
);
|
||||
assert_eq!(pixel[0], expected, "tier {tier} flat={flat}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
@@ -190,6 +281,7 @@ fn sample(
|
||||
flat: bool,
|
||||
geometry: bool,
|
||||
origin: [f32; 4],
|
||||
color: u32,
|
||||
) -> [f32; 4] {
|
||||
let buffer = |bytes: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
@@ -209,7 +301,7 @@ fn sample(
|
||||
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
|
||||
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
|
||||
.into_iter()
|
||||
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
|
||||
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, color, 0))
|
||||
.collect();
|
||||
let data = buffer(
|
||||
bytemuck::cast_slice(&vertices),
|
||||
|
||||
@@ -107,6 +107,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
"at_velocity",
|
||||
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
|
||||
),
|
||||
// Horizon's own, as for terrain: the HDR tier of a light-giving item, particle or glowing
|
||||
// text (`mesher::hdr_tier`, the overlay's high bits), 0 for none, 1 to 4 for low to very
|
||||
// bright.
|
||||
("horizonTier", "float((hz_v_overlay >> 16u) & 255u)"),
|
||||
];
|
||||
horizon_shaders::GbuffersInputs {
|
||||
declarations,
|
||||
@@ -1628,3 +1632,44 @@ mod velocity_translate_tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tier_translate_tests {
|
||||
/// A pack's entity and hand programs may read `horizonTier`, the overlay's HDR tier.
|
||||
#[test]
|
||||
fn a_pack_reading_horizon_tier_translates_for_entities_and_the_hand() {
|
||||
let dir = std::env::temp_dir().join(format!("hz-entity-tier-{}", std::process::id()));
|
||||
let shaders = dir.join("shaders");
|
||||
std::fs::create_dir_all(&shaders).unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.vsh"),
|
||||
"#version 120\nattribute float horizonTier;\nvarying float t;\n\
|
||||
void main() {\n gl_Position = ftransform();\n t = horizonTier;\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
shaders.join("gbuffers_entities.fsh"),
|
||||
"#version 120\nvarying float t;\nvoid main() {\n gl_FragData[0] = vec4(t);\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let pack = horizon_shaders::Pack::open(&dir).unwrap();
|
||||
let m = horizon_shaders::Macros::standard();
|
||||
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
|
||||
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
|
||||
for (who, inputs) in [
|
||||
("entities", super::inputs()),
|
||||
("hand", super::super::pack_hand::inputs()),
|
||||
] {
|
||||
let attribute = inputs.attributes.iter().find(|(n, _)| n == "horizonTier");
|
||||
assert!(attribute.is_some(), "{who}");
|
||||
horizon_shaders::translate_gbuffers(
|
||||
&vsh,
|
||||
&fsh,
|
||||
horizon_shaders::Target::Buffers,
|
||||
&inputs,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -355,6 +355,25 @@ pub const NAMES: &[&str] = &[
|
||||
"hasSkylight",
|
||||
"hasCeiling",
|
||||
"cloudTime",
|
||||
// Horizon's own: Horizon Extras' dynamic lights (`dynamic_light.rs`), nearest first: how
|
||||
// many, and each one's camera-relative position and light level (zeros past the count).
|
||||
"horizonDynamicLightCount",
|
||||
"horizonDynamicLight0",
|
||||
"horizonDynamicLight1",
|
||||
"horizonDynamicLight2",
|
||||
"horizonDynamicLight3",
|
||||
"horizonDynamicLight4",
|
||||
"horizonDynamicLight5",
|
||||
"horizonDynamicLight6",
|
||||
"horizonDynamicLight7",
|
||||
"horizonDynamicLight8",
|
||||
"horizonDynamicLight9",
|
||||
"horizonDynamicLight10",
|
||||
"horizonDynamicLight11",
|
||||
"horizonDynamicLight12",
|
||||
"horizonDynamicLight13",
|
||||
"horizonDynamicLight14",
|
||||
"horizonDynamicLight15",
|
||||
"isElytraFlying",
|
||||
"cloudHeight",
|
||||
];
|
||||
@@ -929,6 +948,12 @@ impl FrameUniforms {
|
||||
"gbufferProjectionInverse" => m4(projection_inverse),
|
||||
"gbufferPreviousModelView" => m4(previous_view),
|
||||
"gbufferPreviousProjection" => m4(previous_projection),
|
||||
"horizonDynamicLightCount" => Value::i(&[frame.dynamic_lights.len() as i32]),
|
||||
n if dynamic_light_index(n).is_some() => Value::f(
|
||||
&(dynamic_light_index(n).and_then(|k| frame.dynamic_lights.get(k)))
|
||||
.copied()
|
||||
.unwrap_or([0.0; 4]),
|
||||
),
|
||||
"heldItemId" => Value::i(&[inputs.held_items[0]]),
|
||||
"heldItemId2" => Value::i(&[inputs.held_items[1]]),
|
||||
"heldBlockLightValue" => Value::i(&[inputs.held_light[0]]),
|
||||
@@ -1785,6 +1810,29 @@ pub(super) mod initial_history_tests {
|
||||
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dynamic_lights_reach_the_pack_nearest_first() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
let mut f = frame(&hud);
|
||||
let lights = [[1.0, 2.0, 3.0, 14.0], [-4.0, 0.5, 6.0, 7.0]];
|
||||
f.dynamic_lights = &lights;
|
||||
let mut u = FrameUniforms::default();
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
let count = index("horizonDynamicLightCount").unwrap();
|
||||
assert_eq!(int(&u, count), 2);
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], lights[0]);
|
||||
assert_eq!(float(&u, "horizonDynamicLight1")[..4], lights[1]);
|
||||
// Past the count, and the last of the sixteen: zeros.
|
||||
assert_eq!(float(&u, "horizonDynamicLight2")[..4], [0.0; 4]);
|
||||
assert_eq!(float(&u, "horizonDynamicLight15")[..4], [0.0; 4]);
|
||||
assert_eq!(index("horizonDynamicLight16"), None);
|
||||
// Off (no lights): none.
|
||||
f.dynamic_lights = &[];
|
||||
u.update(&f, &PackInputs::default(), [800, 450]);
|
||||
assert_eq!(int(&u, count), 0);
|
||||
assert_eq!(float(&u, "horizonDynamicLight0")[..4], [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
|
||||
let hud = HudBatch::new([800, 450]);
|
||||
@@ -2534,6 +2582,12 @@ pub(super) mod initial_history_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizonDynamicLight<k>`'s `k`, for the lights `Frame::dynamic_lights` holds.
|
||||
fn dynamic_light_index(name: &str) -> Option<usize> {
|
||||
let k: usize = name.strip_prefix("horizonDynamicLight")?.parse().ok()?;
|
||||
(k < crate::dynamic_light::MAX).then_some(k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_uniforms/ftransform_test.rs"]
|
||||
mod ftransform_test;
|
||||
@@ -33,6 +33,11 @@ struct Params {
|
||||
@group(0) @binding(2) var<uniform> params: Params;
|
||||
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
|
||||
@group(0) @binding(3) var logo: texture_2d<f32>;
|
||||
// The float frame with HUD coverage (Hdr::layered): the frame keeps the world as light
|
||||
// (fs_resolve), the HUD draws on a picture of its own (`hud`, fs_keep), and `coverage` says how
|
||||
// much of the world it hides there (gfx/hud_coverage.rs).
|
||||
@group(0) @binding(4) var coverage: texture_2d<f32>;
|
||||
@group(0) @binding(5) var hud: texture_2d<f32>;
|
||||
|
||||
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
|
||||
// textures were drawn for SDR, so a curve on their brightest pixels blows out
|
||||
@@ -149,49 +154,29 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
return vec4(0.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let at = vec2<i32>(pos.xy);
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
|
||||
let nits = vec3(calibration_nits(pos.xy, size));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
|
||||
// drawn over them since: grey text over the white sun still looks like the sun
|
||||
// darkened, but it raised the frame's alpha. Its band of 0.175 is the tier, the
|
||||
// lower the brighter, and where in the band how much (hdr_mark). The hand
|
||||
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
// The light of colour `f` under the world's mark `a` (the alpha the world left, hdr_mark) where
|
||||
// the world `shows`, else the colour clipped at white; and whether a light source lifted it.
|
||||
struct Lit {
|
||||
light: vec3<f32>,
|
||||
lifted: bool,
|
||||
};
|
||||
|
||||
fn lit(f: vec3<f32>, a: f32, shows: bool) -> Lit {
|
||||
var lifted = false;
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
// Light over 1 (a float frame's) shows only where the world does and carries
|
||||
// no mark (alpha 1), up to the peak: light written as light. Marked pixels clip
|
||||
// at white before their lift, as on a unorm frame, so light sources, the sun and
|
||||
// HDR lighting look as they do there; so do the HUD, menus and HDR Lighting off.
|
||||
let c = select(min(f.rgb, vec3(1.0)), f.rgb, shows && w.a == 1.0);
|
||||
let c = select(min(f, vec3(1.0)), f, shows && a == 1.0);
|
||||
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
|
||||
var light = pow(c, vec3(2.2));
|
||||
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
|
||||
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
|
||||
// peak from 60% of it, so bright textures keep their detail. Unmarked light (alpha 1)
|
||||
// gets no gain and no knee: the clamp to the peak below is all it needs here.
|
||||
if shows && w.a >= 0.75 && w.a < 1.0 {
|
||||
if shows && a >= 0.75 && a < 1.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
|
||||
let gain = 1.0 + (params.scene - 1.0) * (1.0 - a) / 0.24;
|
||||
let v = top * gain;
|
||||
let knee = max(0.6 * params.peak, 1.0);
|
||||
var out = v;
|
||||
@@ -200,10 +185,13 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
light *= select(gain, out / max(top, 1e-4), v > knee);
|
||||
}
|
||||
if shows && w.a < 0.75 {
|
||||
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
|
||||
// The world's alpha is below 0.75 on light sources, the sun and the moon
|
||||
// (terrain.wgsl, entity.wgsl): its band of 0.175 is the tier, the lower the
|
||||
// brighter, and where in the band how much (hdr_mark).
|
||||
if shows && a < 0.75 {
|
||||
let band = u32(clamp(floor((a + 0.005) / 0.175), 0.0, 3.0));
|
||||
let tier = 3u - band;
|
||||
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
let lift = 1.0 - clamp((a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
|
||||
if lift > 0.0 {
|
||||
let top = max(light.r, max(light.g, light.b));
|
||||
let gain = min(1.0 + (params.peak - 1.0) * params.share[tier] * lift, params.peak / max(top, 1e-4));
|
||||
@@ -220,9 +208,27 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
light = vec3(dot(light, vec3(0.2126, 0.7152, 0.0722)));
|
||||
}
|
||||
}
|
||||
light = min(light, vec3(params.peak));
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
return Lit(min(light, vec3(params.peak)), lifted);
|
||||
}
|
||||
|
||||
// Calibration: above its controls the test picture, straight in nits, past
|
||||
// paper white, the lift and the black level.
|
||||
fn calibrating(pos: vec4<f32>) -> bool {
|
||||
let size = vec2<f32>(textureDimensions(frame));
|
||||
return params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z;
|
||||
}
|
||||
|
||||
fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
|
||||
let nits = vec3(calibration_nits(pos.xy, vec2<f32>(textureDimensions(frame))));
|
||||
if params.pq > 0.5 {
|
||||
return vec4(pq(nits), 1.0);
|
||||
}
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
|
||||
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
|
||||
// lifted.
|
||||
fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
@@ -233,3 +239,67 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
|
||||
}
|
||||
return vec4(scrgb, 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
let at = vec2<i32>(pos.xy);
|
||||
let f = frame_texel(textureLoad(frame, at, 0));
|
||||
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
|
||||
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
|
||||
// menu, by how much.
|
||||
let covered = f.a > 0.0;
|
||||
let hand = covered && f.a < 1.5 / 255.0;
|
||||
let ui = select(f.a, 0.0, hand);
|
||||
let w = frame_texel(textureLoad(world, at, 0));
|
||||
// The world's marks lift only where nothing was drawn over them since: grey text over
|
||||
// the white sun still looks like the sun darkened, but it raised the frame's alpha. The
|
||||
// hand writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
|
||||
let shows = params.world > 0.5 && params.peak > 1.0
|
||||
&& (hand || (!covered && shows_world(f.rgb, w.rgb)));
|
||||
var light = lit(f.rgb, w.a, shows).light;
|
||||
// The HUD and menus at their own brightness, by how much they cover.
|
||||
light *= mix(1.0, params.ui, ui);
|
||||
return display(light);
|
||||
}
|
||||
|
||||
// Hdr::resolve, after the world and the hand: the marks they left (`world` holds the frame as
|
||||
// they drew it) become light in the frame, in gamma as the frame stores it, alpha 1. What draws
|
||||
// after it (camera overlays, the menu blur) draws on light, and nothing is lifted later.
|
||||
@fragment fn fs_resolve(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = frame_texel(textureLoad(world, vec2<i32>(pos.xy), 0));
|
||||
let shows = params.world > 0.5 && params.peak > 1.0;
|
||||
return vec4(pow(lit(w.rgb, w.a, shows).light, vec3(1.0 / 2.2)), 1.0);
|
||||
}
|
||||
|
||||
// Hdr::keep_world: the HUD's backdrop, the world clipped to white with alpha 0, the frame as
|
||||
// the HUD found it before.
|
||||
@fragment fn fs_keep(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let w = textureLoad(frame, vec2<i32>(pos.xy), 0).rgb;
|
||||
return vec4(clamp(w, vec3(0.0), vec3(1.0)), 0.0);
|
||||
}
|
||||
|
||||
// The HUD over the world in gamma, the space the HUD blends in: its own part, what its backdrop
|
||||
// B doesn't explain (H = P - (1 - c) B, exact for the layering blends), at UI Brightness, over
|
||||
// (1 - c) of the world, light above white included. Where the world is at or below white, at
|
||||
// 100%, that is the HUD's picture as drawn.
|
||||
fn layered(at: vec2<i32>) -> vec3<f32> {
|
||||
let w = max(textureLoad(frame, at, 0).rgb, vec3(0.0));
|
||||
let p = clamp(textureLoad(hud, at, 0).rgb, vec3(0.0), vec3(1.0));
|
||||
let c = textureLoad(coverage, at, 0).r;
|
||||
let h = max(p - (1.0 - c) * min(w, vec3(1.0)), vec3(0.0));
|
||||
return h * pow(params.ui, 1.0 / 2.2) + (1.0 - c) * w;
|
||||
}
|
||||
|
||||
// Hdr::composite: the picture so far, stored as the frame stores light, for the menu blur.
|
||||
@fragment fn fs_composite(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
return vec4(layered(vec2<i32>(pos.xy)), 1.0);
|
||||
}
|
||||
|
||||
@fragment fn fs_hdr_layered(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
|
||||
}
|
||||
@@ -202,20 +202,44 @@ impl MenuBlur {
|
||||
target: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
radius: f32,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let copy = if self.half { 2 } else { 0 };
|
||||
let copy = self.source(device, size).0;
|
||||
encoder.copy_texture_to_texture(
|
||||
frame.as_image_copy(),
|
||||
self.targets[copy].0.as_image_copy(),
|
||||
copy.as_image_copy(),
|
||||
wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
self.blur(device, queue, encoder, target, size, radius, timing);
|
||||
}
|
||||
/// The blur's copy of a frame of `size`, for a picture drawn into it rather than copied
|
||||
/// (HDR output's composite, `Hdr::composite`), then `blur`.
|
||||
pub fn source(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
size: [u32; 2],
|
||||
) -> (wgpu::Texture, wgpu::TextureView) {
|
||||
self.ensure(device, size);
|
||||
self.targets[if self.half { 2 } else { 0 }].clone()
|
||||
}
|
||||
/// Blur what `source` holds into `target`, as `apply` blurs its copy of the frame.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn blur(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
target: &wgpu::TextureView,
|
||||
size: [u32; 2],
|
||||
radius: f32,
|
||||
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
|
||||
) {
|
||||
self.ensure(device, size);
|
||||
let work = self.work_size(size);
|
||||
let radius = if self.half { radius / 2.0 } else { radius };
|
||||
if self.half {
|
||||
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
|
||||
|
||||
@@ -42,7 +42,12 @@ struct OitFragment {
|
||||
@fragment fn fs_oit_cull(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
let b = textureLoad(oit_bounds, vec2<i32>(pos.xy), 0);
|
||||
let opaque = oit_linear_depth(b.a);
|
||||
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), b.a);
|
||||
// The later stages test GreaterEqual against this depth, so the opaque fragment itself must still
|
||||
// pass: Metal's `position.z` can differ from the rasterizer's depth by an ulp, which dropped
|
||||
// random pixels of an opaque body. A few ulps farther (a positive float's bits minus one is
|
||||
// the next smaller value) keeps it, and culls anything behind it by a visible distance.
|
||||
let culled_depth = select(0.0, bitcast<f32>(bitcast<u32>(b.a) - 4u), b.a > 0.0);
|
||||
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), culled_depth);
|
||||
}
|
||||
// The sum over the world, renormalized to the coverage the transform gives.
|
||||
@fragment fn fs_oit_composite(@builtin(position) pos: vec4<f32>) -> OitFragment {
|
||||
|
||||
@@ -317,6 +317,7 @@ impl Opt {
|
||||
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
|
||||
Opt::PadTriggerThreshold => "horizon.options.trigger_threshold",
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
|
||||
Opt::ModelPart(i) => [
|
||||
@@ -864,6 +865,11 @@ impl Opt {
|
||||
GlyphStyle::PlayStation => "horizon.options.glyphs.playstation",
|
||||
GlyphStyle::Nintendo => "horizon.options.glyphs.nintendo",
|
||||
}),
|
||||
Opt::PadNintendoButtons => tr(if s.controller.nintendo_letters {
|
||||
"horizon.options.nintendo_buttons.letter"
|
||||
} else {
|
||||
"horizon.options.nintendo_buttons.position"
|
||||
}),
|
||||
Opt::PadKeyboard => tr(match s.controller.keyboard {
|
||||
gamepad::TextKeyboard::Auto => "horizon.options.keyboard.auto",
|
||||
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
|
||||
@@ -1151,6 +1157,9 @@ impl Opt {
|
||||
Opt::PadInvertX => s.controller.invert_x = !s.controller.invert_x,
|
||||
Opt::PadInvertY => s.controller.invert_y = !s.controller.invert_y,
|
||||
Opt::PadToggleSneak => s.controller.toggle_sneak = !s.controller.toggle_sneak,
|
||||
Opt::PadNintendoButtons => {
|
||||
s.controller.nintendo_letters = !s.controller.nintendo_letters
|
||||
}
|
||||
Opt::PadLightbar => {
|
||||
let all = gamepad::Lightbar::ALL;
|
||||
let i = all
|
||||
@@ -2238,6 +2247,11 @@ impl OptionsUi {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
|
||||
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
|
||||
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
|
||||
self.shell_mut()?.reveal(s, size, rect)
|
||||
}
|
||||
/// What a shell screen's outputs do to these options.
|
||||
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
|
||||
let mut events = Vec::new();
|
||||
@@ -3708,6 +3722,8 @@ impl OptionsUi {
|
||||
let in_world = self.in_world;
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
shell.draw(s, batch, atlas, cursor, in_world, waiting);
|
||||
// The keys lay the screen out at the size it was drawn at.
|
||||
self.size = (batch.width, batch.height);
|
||||
return;
|
||||
}
|
||||
if self.page() == Page::SplitSetup {
|
||||
@@ -4923,10 +4939,12 @@ impl OptionsUi {
|
||||
}
|
||||
return events;
|
||||
}
|
||||
let gui = (
|
||||
self.size.0.max(MIN_KEY_GUI.0),
|
||||
self.size.1.max(MIN_KEY_GUI.1),
|
||||
);
|
||||
// The size it was drawn at; before the first draw, one its keys can lay out at.
|
||||
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
|
||||
self.size
|
||||
} else {
|
||||
MIN_KEY_GUI
|
||||
};
|
||||
if let Some(shell) = self.shell_mut() {
|
||||
let outputs = shell.key(s, key, text, shift, gui);
|
||||
return self.shell_events(s, outputs);
|
||||
|
||||
@@ -60,6 +60,10 @@ pub struct ParticleOptions {
|
||||
pub mod_particle: Option<String>,
|
||||
/// Pre-1.20.5 entity-effect providers use spawn velocity as RGB; true is the ambient provider.
|
||||
pub legacy_entity_effect: Option<bool>,
|
||||
/// A pre-1.21.4 server's trail, which had no duration: TrailParticle.Provider chose
|
||||
/// `nextInt(40) + 10` after constructing the particle. Set by the client for an older
|
||||
/// server only, never read from the wire.
|
||||
pub legacy_trail_lifetime: bool,
|
||||
}
|
||||
impl ParticleOptions {
|
||||
pub fn simple(ty: ParticleType) -> Self {
|
||||
@@ -75,6 +79,7 @@ impl ParticleOptions {
|
||||
vibration: None,
|
||||
mod_particle: None,
|
||||
legacy_entity_effect: None,
|
||||
legacy_trail_lifetime: false,
|
||||
}
|
||||
}
|
||||
/// A native mod's `SimpleParticleType`: the sprite set of
|
||||
@@ -3485,9 +3490,7 @@ impl ParticleEngine {
|
||||
scaled(o.color[2], jb),
|
||||
);
|
||||
q.size = 0.26;
|
||||
// The older-server adapter marks pre-1.21.4 TargetColorParticleOption with -1.
|
||||
// Its TrailParticle.Provider chose nextInt(40) + 10 after constructing the particle.
|
||||
q.lifetime = if duration == -1 {
|
||||
q.lifetime = if o.legacy_trail_lifetime {
|
||||
self.level_random.next_int_bound(40).unwrap_or(0) + 10
|
||||
} else {
|
||||
duration
|
||||
@@ -5764,12 +5767,12 @@ mod tests {
|
||||
fn legacy_trails_choose_a_lifetime_for_each_particle_from_the_level_random() {
|
||||
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
engine.seed_randoms(1, 42, 2);
|
||||
let legacy = ParticleOptions {
|
||||
legacy_trail_lifetime: true,
|
||||
..ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1)
|
||||
};
|
||||
for _ in 0..5 {
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
engine.create(&legacy, [0.0; 3], [0.0; 3]);
|
||||
}
|
||||
// java.util.Random(42).nextInt(40) + 10, as vanilla 1.21.3 TrailParticle.Provider does.
|
||||
assert_eq!(
|
||||
@@ -5786,6 +5789,13 @@ mod tests {
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, 37);
|
||||
// A current server's -1 is a lifetime like any other, not the old marker.
|
||||
engine.create(
|
||||
&ParticleOptions::trail([1.0, 2.0, 3.0], 0x7f3f1f, -1),
|
||||
[0.0; 3],
|
||||
[0.0; 3],
|
||||
);
|
||||
assert_eq!(engine.pending.last().unwrap().lifetime, -1);
|
||||
}
|
||||
#[test]
|
||||
fn sizes_follow_vanilla_curves() {
|
||||
|
||||
@@ -845,6 +845,8 @@ pub enum Opt {
|
||||
PadLookDeadzone,
|
||||
PadTriggerThreshold,
|
||||
PadGlyphs,
|
||||
/// A Nintendo pad's face buttons by place or by letter (`PadSettings::nintendo_letters`).
|
||||
PadNintendoButtons,
|
||||
PadKeyboard,
|
||||
PadGameplayGuide,
|
||||
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::{
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
/// The screen's parts in GUI pixels. Down the left: the mark, the search
|
||||
/// field, the page list, and Undo and Done at the foot. On the right: the
|
||||
@@ -865,6 +866,11 @@ pub(crate) trait Screen {
|
||||
NOTE_LINES
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<Self::Page, Self::Row>>;
|
||||
/// A fingerprint ([`stamp_of`]) of what `sheets` reads that can change while the screen is
|
||||
/// open, besides through `press`, `slide` and `wheel_step` (the shell builds again after
|
||||
/// those): the rows are built again only when it changes, or the search, the page, the size or
|
||||
/// the language does.
|
||||
fn stamp(&self, s: &Settings) -> u64;
|
||||
/// A row was pressed (`back`: Shift held); the shell has played the click.
|
||||
fn press(&mut self, s: &mut Settings, row: Self::Row, back: bool) -> Vec<Output>;
|
||||
/// A slider row moved to `position`.
|
||||
@@ -881,6 +887,23 @@ pub(crate) trait Screen {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fingerprint of `parts` for [`Screen::stamp`]: their `Debug` text, hashed as it is written,
|
||||
/// without keeping it.
|
||||
pub(crate) fn stamp_of(parts: &impl std::fmt::Debug) -> u64 {
|
||||
use std::{fmt::Write, hash::Hasher};
|
||||
struct Hashing(std::collections::hash_map::DefaultHasher);
|
||||
impl Write for Hashing {
|
||||
fn write_str(&mut self, text: &str) -> std::fmt::Result {
|
||||
self.0.write(text.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut hashing = Hashing(Default::default());
|
||||
// Writing into a hasher can't fail.
|
||||
let _ = write!(hashing, "{parts:?}");
|
||||
hashing.0.finish()
|
||||
}
|
||||
|
||||
/// A [`Shell`] of any screen, as `OptionsUi` drives it.
|
||||
pub(crate) trait Hosted {
|
||||
/// The screen opens, afresh.
|
||||
@@ -907,6 +930,10 @@ pub(crate) trait Hosted {
|
||||
) -> Vec<Output>;
|
||||
/// Lets go of a slider (true: it plays its click) or the scrollbar.
|
||||
fn release(&mut self) -> bool;
|
||||
/// A controller's or the keyboard's focus is on the row at `rect`: if the list hides part of
|
||||
/// it, it scrolls all into view (`AbstractSelectionList.setSelected`). How far the row moved
|
||||
/// down, when it moved.
|
||||
fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32>;
|
||||
/// The mouse wheel at `at`: it scrolls, unless the row under it takes the step (`wheel_step`).
|
||||
fn wheel(
|
||||
&mut self,
|
||||
@@ -961,6 +988,19 @@ struct View<P, R> {
|
||||
content: f32,
|
||||
}
|
||||
|
||||
/// What a [`View`] was built for: when any of it changes, the rows are built again.
|
||||
#[derive(PartialEq)]
|
||||
struct Built<P, R> {
|
||||
stamp: u64,
|
||||
/// Which language the rows' text is in (`lang::generation`).
|
||||
language: u64,
|
||||
size: (f32, f32),
|
||||
query: String,
|
||||
page: Option<P>,
|
||||
/// While searching, the rows the focus and a drag keep in the list.
|
||||
kept: (Option<R>, Option<R>),
|
||||
}
|
||||
|
||||
/// A screen on the shell, with all that isn't the screen's: where the list is, what has the focus,
|
||||
/// what is held and what is searched.
|
||||
pub(crate) struct Shell<S: Screen> {
|
||||
@@ -980,6 +1020,8 @@ pub(crate) struct Shell<S: Screen> {
|
||||
/// While searching, the page last picked in the page list: current until the list scrolls.
|
||||
picked: Option<S::Page>,
|
||||
waiting: Option<&'static str>,
|
||||
/// The rows as last built, and what for.
|
||||
built: Option<(Built<S::Page, S::Row>, Rc<View<S::Page, S::Row>>)>,
|
||||
}
|
||||
impl<S: Screen + Default> Default for Shell<S> {
|
||||
fn default() -> Self {
|
||||
@@ -998,6 +1040,7 @@ impl<S: Screen + Default> Default for Shell<S> {
|
||||
page: None,
|
||||
picked: None,
|
||||
waiting: None,
|
||||
built: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1008,10 +1051,43 @@ impl<S: Screen> Shell<S> {
|
||||
fn shows_focus(&self, target: Target<S::Page, S::Row>) -> bool {
|
||||
self.keyed && self.focus == Some(target)
|
||||
}
|
||||
/// The screen's rows now: the open page's or, while searching, every page's whose name or
|
||||
/// The screen's rows now, built again only when what they were built for changed
|
||||
/// ([`Built`]). The scroll is kept within them.
|
||||
fn view(&mut self, s: &Settings, size: (f32, f32)) -> Rc<View<S::Page, S::Row>> {
|
||||
let query = self.query();
|
||||
let searching = !query.is_empty();
|
||||
let focused = match self.focus {
|
||||
Some(Target::Row(row)) if searching => Some(row),
|
||||
_ => None,
|
||||
};
|
||||
let key = Built {
|
||||
stamp: self.screen.stamp(s),
|
||||
language: lang::generation(),
|
||||
size,
|
||||
query,
|
||||
page: self.page,
|
||||
kept: (focused, self.dragging.filter(|_| searching)),
|
||||
};
|
||||
let view = match &self.built {
|
||||
Some((built, view)) if *built == key => view.clone(),
|
||||
_ => {
|
||||
let view = Rc::new(self.build(s, size));
|
||||
self.built = Some((key, view.clone()));
|
||||
view
|
||||
}
|
||||
};
|
||||
self.scroll
|
||||
.clamp(view.cols.top, view.cols.bottom, view.content);
|
||||
view
|
||||
}
|
||||
/// A row may read otherwise now: the screen changed something.
|
||||
fn rebuild(&mut self) {
|
||||
self.built = None;
|
||||
}
|
||||
/// The screen's rows: the open page's or, while searching, every page's whose name or
|
||||
/// description has the search in it, under their pages' names. The row being changed stays
|
||||
/// whatever it reads now. The scroll is kept within it.
|
||||
fn view(&mut self, s: &Settings, (w, h): (f32, f32)) -> View<S::Page, S::Row> {
|
||||
/// whatever it reads now.
|
||||
fn build(&self, s: &Settings, (w, h): (f32, f32)) -> View<S::Page, S::Row> {
|
||||
let cols = Columns::of(w, h).with_note_lines(self.screen.note_lines());
|
||||
let query = self.query();
|
||||
let sheets = self.screen.sheets(s);
|
||||
@@ -1048,7 +1124,6 @@ impl<S: Screen> Shell<S> {
|
||||
}
|
||||
}
|
||||
let (lines, content) = lines.finish();
|
||||
self.scroll.clamp(cols.top, cols.bottom, content);
|
||||
View {
|
||||
cols,
|
||||
pages,
|
||||
@@ -1136,12 +1211,14 @@ impl<S: Screen> Shell<S> {
|
||||
};
|
||||
self.dragging = Some(row.id);
|
||||
self.screen.slide(s, row.id, position);
|
||||
self.rebuild();
|
||||
vec![DRAGGED]
|
||||
}
|
||||
_ => {
|
||||
self.can_change_value = false;
|
||||
let mut outputs = vec![CLICK];
|
||||
outputs.extend(self.screen.press(s, row.id, back));
|
||||
self.rebuild();
|
||||
outputs
|
||||
}
|
||||
}
|
||||
@@ -1171,6 +1248,22 @@ impl<S: Screen> Shell<S> {
|
||||
out.push((Target::Done, cols.button(0)));
|
||||
out
|
||||
}
|
||||
/// Where Down from the search field goes on a screen with no page list, whose foot is under the
|
||||
/// field instead: the first row to use that is all in view.
|
||||
fn first_row(
|
||||
&self,
|
||||
view: &View<S::Page, S::Row>,
|
||||
) -> Option<(Target<S::Page, S::Row>, [f32; 4])> {
|
||||
if !self.tabs(view).is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cols = &view.cols;
|
||||
let mut placed = self.placed(view);
|
||||
let first = placed.find(|&(row, y)| {
|
||||
row.live && row.value != Value::Note && y >= cols.top && y + style::ROW <= cols.bottom
|
||||
});
|
||||
first.map(|(row, y)| (Target::Row(row.id), cols.row(y)))
|
||||
}
|
||||
/// Focus `target`, scrolling its row into the list's view.
|
||||
fn focus_on(
|
||||
&mut self,
|
||||
@@ -1325,6 +1418,9 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
},
|
||||
);
|
||||
scroller(batch, atlas, &cols, view.content, self.scroll.amount);
|
||||
if let Some((_, row)) = self.first_row(&view) {
|
||||
crate::widgets::focus_link(cols.search(), true, row);
|
||||
}
|
||||
self.draw_dock(batch, atlas, &view, described, current);
|
||||
let done = cols.button(0);
|
||||
let label = Text::literal(lang::get("Done"));
|
||||
@@ -1358,6 +1454,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
}
|
||||
if let Some(row) = self.dragging {
|
||||
self.screen.slide(s, row, cols.slider_at(x));
|
||||
self.rebuild();
|
||||
return vec![DRAGGED];
|
||||
}
|
||||
return Vec::new();
|
||||
@@ -1409,6 +1506,22 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.scroll.release();
|
||||
self.dragging.take().is_some()
|
||||
}
|
||||
fn reveal(&mut self, s: &Settings, size: (f32, f32), [x, y, w, h]: [f32; 4]) -> Option<f32> {
|
||||
let view = self.view(s, size);
|
||||
let cols = &view.cols;
|
||||
let middle = (x + w / 2.0, y + h / 2.0);
|
||||
let (_, row_y) = self.placed(&view).find(|&(_, ry)| {
|
||||
let [rx, ry, rw, rh] = cols.row(ry);
|
||||
ui::hovered(middle, rx, ry, rw, rh)
|
||||
})?;
|
||||
let before = self.scroll.amount;
|
||||
scroll_into_view(&mut self.scroll, cols.row(row_y), cols, view.content);
|
||||
let moved = before - self.scroll.amount;
|
||||
(moved != 0.0).then(|| {
|
||||
self.picked = None;
|
||||
moved
|
||||
})
|
||||
}
|
||||
fn wheel(
|
||||
&mut self,
|
||||
s: &mut Settings,
|
||||
@@ -1421,6 +1534,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
if let Some(row) = self.row_at(&view, at).filter(|row| row.live)
|
||||
&& let Some(outputs) = self.screen.wheel_step(s, row.id, amount > 0.0)
|
||||
{
|
||||
self.rebuild();
|
||||
return outputs;
|
||||
}
|
||||
}
|
||||
@@ -1471,6 +1585,14 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
self.focus_on(focusables[next], &view);
|
||||
return Vec::new();
|
||||
}
|
||||
if key == K::ArrowDown
|
||||
&& self.focus == Some(Target::Search)
|
||||
&& let Some(first) = self.first_row(&view)
|
||||
{
|
||||
self.keyed = true;
|
||||
self.focus_on(first, &view);
|
||||
return Vec::new();
|
||||
}
|
||||
let focused = view
|
||||
.rows
|
||||
.iter()
|
||||
@@ -1486,6 +1608,7 @@ impl<S: Screen + Default> Hosted for Shell<S> {
|
||||
let width = view.cols.track().1 + 8.0;
|
||||
let next = slider_step(position, steps, width, key == K::ArrowRight);
|
||||
self.screen.slide(s, row.id, next);
|
||||
self.rebuild();
|
||||
return vec![DRAGGED];
|
||||
}
|
||||
if matches!(
|
||||
@@ -1576,6 +1699,13 @@ impl<S: Screen> Shell<S> {
|
||||
pub(crate) fn scroll_amount(&self) -> f32 {
|
||||
self.scroll.amount
|
||||
}
|
||||
/// The row the shell's own focus is on.
|
||||
pub(crate) fn focused_row(&self) -> Option<S::Row> {
|
||||
match self.focus {
|
||||
Some(Target::Row(row)) => Some(row),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The captions of the rows the list has now, as the widget tree reads them.
|
||||
pub(crate) fn row_labels(&mut self, s: &Settings) -> Vec<String> {
|
||||
let view = self.view(s, (854.0, 480.0));
|
||||
@@ -1601,6 +1731,8 @@ mod tests {
|
||||
slider: f32,
|
||||
choice: usize,
|
||||
pressed: Vec<(u32, bool)>,
|
||||
/// How many times its rows were built.
|
||||
built: std::cell::Cell<usize>,
|
||||
}
|
||||
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
|
||||
const SLIDER: u32 = 0;
|
||||
@@ -1614,7 +1746,12 @@ mod tests {
|
||||
fn title(&self) -> &'static str {
|
||||
"Probe"
|
||||
}
|
||||
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
/// Its rows read the field of view, besides its own state.
|
||||
fn stamp(&self, s: &Settings) -> u64 {
|
||||
stamp_of(&s.fov)
|
||||
}
|
||||
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
|
||||
self.built.set(self.built.get() + 1);
|
||||
let (value, chosen) = CHOICES[self.choice];
|
||||
let mut rows = vec![
|
||||
Row::new(
|
||||
@@ -1623,7 +1760,7 @@ mod tests {
|
||||
Value::Slider {
|
||||
position: self.slider,
|
||||
steps: Some(10),
|
||||
text: "x".to_owned(),
|
||||
text: s.fov.to_string(),
|
||||
},
|
||||
),
|
||||
Row {
|
||||
@@ -1677,6 +1814,90 @@ mod tests {
|
||||
shell.point_at(&Settings::default(), size, row)
|
||||
}
|
||||
|
||||
fn atlas() -> HudAtlas {
|
||||
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
|
||||
}
|
||||
|
||||
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
|
||||
/// away from any row, at the size it was drawn at.
|
||||
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
|
||||
let mut batch = HudBatch::new([1708, 960]);
|
||||
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
|
||||
let size = (batch.width, batch.height);
|
||||
shell.scroll(s, size, -1.0);
|
||||
shell.key(s, K::ArrowDown, None, false, size);
|
||||
shell.wheel(s, size, (0.0, 0.0), 1.0);
|
||||
size
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
|
||||
let atlas = atlas();
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let built = |shell: &Shell<Probe>| shell.screen.built.get();
|
||||
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
|
||||
let view = shell.view(s, size);
|
||||
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
|
||||
labels.any(|label| label.ends_with(end))
|
||||
};
|
||||
// Each of the four built them before: now the first frame builds them once, the next none.
|
||||
let size = frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), 1);
|
||||
// A press changes the screen's own state: its row reads the new value.
|
||||
let at = middle_of(&mut shell, CHOICE, size);
|
||||
shell.click(&mut s, at, size, false, false, false);
|
||||
assert!(reads(&mut shell, &s, size, "Blue"));
|
||||
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
|
||||
s.fov = 90.0;
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert!(reads(&mut shell, &s, size, "90"));
|
||||
let before = built(&shell);
|
||||
frame(&mut shell, &mut s, &atlas);
|
||||
assert_eq!(built(&shell), before);
|
||||
// Searching, and another size, build them again.
|
||||
shell.type_text("filler");
|
||||
shell.view(&s, size);
|
||||
assert_eq!(built(&shell), before + 1);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 2);
|
||||
// So does another language, and coming back from it.
|
||||
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
|
||||
assert_eq!(built(&shell), before + 3);
|
||||
shell.view(&s, SHORT);
|
||||
assert_eq!(built(&shell), before + 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
|
||||
let mut s = Settings::default();
|
||||
let mut shell = Shell::<Probe>::default();
|
||||
let cols = Columns::of(SHORT.0, SHORT.1);
|
||||
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
|
||||
let cut = FIRST_FILLER + 20;
|
||||
let view = shell.view(&s, SHORT);
|
||||
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
|
||||
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
|
||||
let rect = cols.row(y - shell.scroll.amount);
|
||||
assert!(
|
||||
rect[1] + rect[3] / 2.0 >= cols.bottom,
|
||||
"the row's middle is hidden"
|
||||
);
|
||||
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
|
||||
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
|
||||
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
|
||||
// All in view, it stays put.
|
||||
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
|
||||
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
|
||||
assert_eq!(
|
||||
shell.click(&mut s, middle, SHORT, false, false, false),
|
||||
vec![CLICK, CHANGED]
|
||||
);
|
||||
assert_eq!(shell.screen.pressed, [(cut, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -861,6 +861,9 @@ pub trait Backend {
|
||||
fn unpause(&mut self, channel: ChannelId);
|
||||
/// `Channel.stopped`.
|
||||
fn stopped(&mut self, channel: ChannelId) -> bool;
|
||||
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
|
||||
/// event is one the mod exempted. A backend without a muffle ignores it.
|
||||
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
|
||||
}
|
||||
/// A `SoundEventListener.onPlaySound` call.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -937,6 +940,9 @@ pub struct SoundEngine {
|
||||
/// listener: 0 as vanilla, `HEARD_RESERVE` with the `uiSoundReserve` extra.
|
||||
pub listener_reserve: usize,
|
||||
pub notifications: Vec<Notification>,
|
||||
/// The sound events a mod's muffle leaves clear (`Audio::muffle`), normalized: their channels
|
||||
/// are marked as they start, and those playing when the list changes are marked again.
|
||||
unmuffled: Vec<String>,
|
||||
}
|
||||
fn handle_channel(h: &Handle) -> Option<ChannelId> {
|
||||
h.channel
|
||||
@@ -966,6 +972,21 @@ impl SoundEngine {
|
||||
listening: false,
|
||||
listener_reserve: 0,
|
||||
notifications: Vec::new(),
|
||||
unmuffled: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The sound events that play clearly through a mod's muffle from now on, normalized
|
||||
/// (`minecraft:ui.button.click`); the channels playing now follow the new list too.
|
||||
pub fn set_unmuffled(&mut self, events: Vec<String>, backend: &mut impl Backend) {
|
||||
self.unmuffled = events;
|
||||
for (id, handle) in &self.instance_to_channel {
|
||||
let (Some(instance), Some(channel)) = (
|
||||
self.instances.get(id),
|
||||
self.handles.get(handle).and_then(handle_channel),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
backend.set_unmuffled(channel, self.unmuffled.contains(&instance.identifier));
|
||||
}
|
||||
}
|
||||
/// Isolate a fixture's dispatch from process-wide native installation.
|
||||
@@ -1315,6 +1336,7 @@ impl SoundEngine {
|
||||
}
|
||||
let position = instance.position;
|
||||
let looping = instance.looping && instance.delay <= 0;
|
||||
let unmuffled = self.unmuffled.contains(&instance.identifier);
|
||||
let streaming = sound.stream;
|
||||
let pool = usize::from(streaming);
|
||||
// With the `uiSoundReserve` extra, sounds out in the world leave a few static channels free, so a flood of
|
||||
@@ -1367,6 +1389,7 @@ impl SoundEngine {
|
||||
backend.set_looping(channel, looping && !streaming);
|
||||
backend.set_position(channel, position);
|
||||
backend.set_relative(channel, relative);
|
||||
backend.set_unmuffled(channel, unmuffled);
|
||||
backend.start(channel, &sound, looping);
|
||||
if tickable {
|
||||
self.ticking.push(id);
|
||||
|
||||
@@ -105,6 +105,8 @@ thread_local! {
|
||||
static ACTION_SCREENS: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
|
||||
/// The lists drawn with a scrollbar since `end_frame`, and the last frame's.
|
||||
static SCROLL_AREAS: RefCell<(Vec<ScrollArea>, Vec<ScrollArea>)> = const { RefCell::new((Vec::new(), Vec::new())) };
|
||||
/// The steps screens declared since `end_frame` ([`focus_link`]), and the last frame's.
|
||||
static FOCUS_LINKS: RefCell<(Vec<FocusLink>, Vec<FocusLink>)> = const { RefCell::new((Vec::new(), Vec::new())) };
|
||||
}
|
||||
|
||||
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
|
||||
@@ -117,6 +119,7 @@ pub struct Saved(
|
||||
bool,
|
||||
(u64, u64),
|
||||
(Vec<ScrollArea>, Vec<ScrollArea>),
|
||||
(Vec<FocusLink>, Vec<FocusLink>),
|
||||
);
|
||||
|
||||
/// This thread's buttons and targets, leaving none.
|
||||
@@ -128,6 +131,7 @@ pub fn take() -> Saved {
|
||||
ACTION_GLYPHS.take(),
|
||||
ACTION_SCREENS.take(),
|
||||
SCROLL_AREAS.take(),
|
||||
FOCUS_LINKS.take(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,6 +140,7 @@ pub fn put(saved: Saved) {
|
||||
WIDGETS.set(saved.0);
|
||||
TARGETS.set(saved.1);
|
||||
SCROLL_AREAS.set(saved.5);
|
||||
FOCUS_LINKS.set(saved.6);
|
||||
ACTIONS.set(saved.2);
|
||||
ACTION_GLYPHS.set(saved.3);
|
||||
ACTION_SCREENS.set(saved.4);
|
||||
@@ -282,6 +287,7 @@ pub fn cover_frame() {
|
||||
TARGETS.with_borrow_mut(|(current, _, _)| current.clear());
|
||||
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
|
||||
SCROLL_AREAS.with_borrow_mut(|(current, _)| current.clear());
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.clear());
|
||||
}
|
||||
|
||||
/// This frame drew a list with something to scroll, in `rect`, scrolled `amount`
|
||||
@@ -315,6 +321,38 @@ pub fn over_scroll_area(at: (f32, f32)) -> (f32, f32) {
|
||||
})
|
||||
}
|
||||
|
||||
/// A step the arrows and the D-pad take that the rectangles don't show: `down` (else up) from
|
||||
/// a focus whose middle is in `from`, to the target at `to`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FocusLink {
|
||||
pub from: [f32; 4],
|
||||
pub down: bool,
|
||||
pub to: [f32; 4],
|
||||
}
|
||||
|
||||
/// Down (or up) from what is in `from` goes to `to`: a screen whose layout puts the next control
|
||||
/// off to one side, where vanilla's step would pass it by (the settings shell's search field over
|
||||
/// its list, on a screen with no page list).
|
||||
pub fn focus_link(from: [f32; 4], down: bool, to: [f32; 4]) {
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, _)| current.push(FocusLink { from, down, to }));
|
||||
}
|
||||
|
||||
/// Where the last drawn frame said a step `down` (else up) from `focus` goes, if it said.
|
||||
pub fn linked_step(focus: [f32; 4], down: bool) -> Option<[f32; 4]> {
|
||||
let [x, y, w, h] = focus;
|
||||
let middle = (x + w / 2.0, y + h / 2.0);
|
||||
FOCUS_LINKS.with_borrow(|(_, last)| {
|
||||
last.iter()
|
||||
.find(|l| {
|
||||
let [lx, ly, lw, lh] = l.from;
|
||||
l.down == down
|
||||
&& (lx..lx + lw).contains(&middle.0)
|
||||
&& (ly..ly + lh).contains(&middle.1)
|
||||
})
|
||||
.map(|l| l.to)
|
||||
})
|
||||
}
|
||||
|
||||
/// The focus targets of the last drawn frame.
|
||||
pub fn last_targets() -> Vec<Target> {
|
||||
TARGETS.with_borrow(|(_, last, _)| last.clone())
|
||||
@@ -392,6 +430,9 @@ pub fn end_frame(frames: u64) {
|
||||
SCROLL_AREAS.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::take(current);
|
||||
});
|
||||
FOCUS_LINKS.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::take(current);
|
||||
});
|
||||
NARRATION.with_borrow_mut(|(current, last)| {
|
||||
*last = std::mem::replace(current, Narration::EMPTY);
|
||||
});
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
"horizon.options.glyphs.xbox": "Xbox",
|
||||
"horizon.options.glyphs.playstation": "PlayStation",
|
||||
"horizon.options.glyphs.nintendo": "Nintendo",
|
||||
"horizon.options.nintendo_buttons": "Nintendo Buttons",
|
||||
"horizon.options.nintendo_buttons.position": "By Position",
|
||||
"horizon.options.nintendo_buttons.letter": "By Letter",
|
||||
"horizon.options.keyboard.auto": "Auto",
|
||||
"horizon.options.keyboard.horizon": "Horizon",
|
||||
"horizon.options.keyboard.steam": "Steam",
|
||||
@@ -964,6 +967,8 @@
|
||||
"Always shows Xbox buttons.": "Always shows Xbox buttons.",
|
||||
"Always shows PlayStation buttons.": "Always shows PlayStation buttons.",
|
||||
"Always shows Nintendo buttons.": "Always shows Nintendo buttons.",
|
||||
"A, on the right, confirms and jumps and B goes back, as on a Switch.": "A, on the right, confirms and jumps and B goes back, as on a Switch.",
|
||||
"B, at the bottom, confirms and jumps and A goes back, as on other controllers.": "B, at the bottom, confirms and jumps and A goes back, as on other controllers.",
|
||||
"Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.": "Opens Steam's keyboard on a Steam Deck under Steam, and Horizon's otherwise.",
|
||||
"Always opens Horizon's keyboard.": "Always opens Horizon's keyboard.",
|
||||
"Always opens Steam's keyboard.": "Always opens Steam's keyboard.",
|
||||
@@ -982,6 +987,7 @@
|
||||
"The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.": "The USB vendor and product the system reports. Useful when reporting a controller that misbehaves.",
|
||||
"The glyph family the game picked from the controller's maker.": "The glyph family the game picked from the controller's maker.",
|
||||
"Follow the controller, or always show one family's buttons.": "Follow the controller, or always show one family's buttons.",
|
||||
"Whether a Nintendo controller's A and B work by where they sit or by their letters.": "Whether a Nintendo controller's A and B work by where they sit or by their letters.",
|
||||
"The buttons menus show right now.": "The buttons menus show right now.",
|
||||
"Rumble when something happens to you.": "Rumble when something happens to you.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "The controller keeps playing while this window isn't focused, and losing focus won't pause.",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"horizon.options.controller": "Mando",
|
||||
"horizon.options.three_d_skin_layers": "Capas de skin 3D",
|
||||
"horizon.options.vibration": "Vibración",
|
||||
"horizon.options.play_in_background": "Jugar en segundo plano",
|
||||
"horizon.options.adaptive_triggers": "Gatillos adaptativos",
|
||||
"horizon.options.lightbar": "Barra de luz",
|
||||
"horizon.options.look_sensitivity": "Sensibilidad de vista",
|
||||
@@ -34,6 +35,23 @@
|
||||
"horizon.studio.record.error": "Error de grabación de Studio: %s",
|
||||
"horizon.studio.record.status.active": "Grabación de Studio activa — %s s, %s paquetes, %s KB (%s)",
|
||||
"horizon.studio.record.status.inactive": "Grabación de Studio inactiva",
|
||||
"horizon.studio.replay.usage": "Uso: /studio replay <open RUTA|pause|resume|speed MULTIPLICADOR|seek MS|status|close>",
|
||||
"horizon.studio.replay.open.usage": "Uso: /studio replay open <RUTA>",
|
||||
"horizon.studio.replay.opened": "Replay de Studio abierto: %s (%s s, %s paquetes)",
|
||||
"horizon.studio.replay.open_error": "No se pudo abrir el replay: %s",
|
||||
"horizon.studio.replay.paused": "Replay de Studio en pausa",
|
||||
"horizon.studio.replay.resumed": "Replay de Studio reanudado",
|
||||
"horizon.studio.replay.inactive": "No hay ningún replay de Studio activo",
|
||||
"horizon.studio.replay.speed.usage": "Uso: /studio replay speed <MULTIPLICADOR>",
|
||||
"horizon.studio.replay.speed.set": "Velocidad del replay de Studio ajustada a %s×",
|
||||
"horizon.studio.replay.speed.invalid": "Velocidad de replay no válida",
|
||||
"horizon.studio.replay.seek.usage": "Uso: /studio replay seek <MILISEGUNDOS>",
|
||||
"horizon.studio.replay.seeked": "Replay de Studio situado en %s ms (%s s)",
|
||||
"horizon.studio.replay.seek.error": "No se pudo mover el replay: %s",
|
||||
"horizon.studio.replay.seek.backward_requires_standalone": "Rebobinar solo está disponible para un replay independiente.",
|
||||
"horizon.studio.replay.seek.invalid": "Posición de replay no válida",
|
||||
"horizon.studio.replay.status": "Replay de Studio: %s / %s ms (%s%%), velocidad %s×, en pausa %s, finalizado %s",
|
||||
"horizon.studio.replay.closed": "Replay de Studio cerrado",
|
||||
"horizon.pages.mods": "Mods",
|
||||
"horizon.pages.shader_packs": "Paquetes de shaders",
|
||||
"horizon.pages.shader_options": "Opciones de shaders",
|
||||
@@ -88,6 +106,7 @@
|
||||
"horizon.studio.empty": "No se han encontrado grabaciones en la carpeta de grabaciones.",
|
||||
"horizon.studio.empty_hint": "Usa /studio record start <nombre> en un mundo o deja archivos .mcpr allí.",
|
||||
"horizon.studio.open_folder": "Abrir grabaciones",
|
||||
"horizon.studio.open_replay": "Reproducir replay",
|
||||
"horizon.studio.refresh": "Actualizar",
|
||||
"horizon.studio.unknown_server": "Desconocido",
|
||||
"horizon.pages.open.music": "Música...",
|
||||
@@ -882,6 +901,7 @@
|
||||
"Follow the controller, or always show one family's buttons.": "Sigue al mando o muestra siempre los botones de una familia.",
|
||||
"The buttons menus show right now.": "Los botones que muestran los menús ahora mismo.",
|
||||
"Rumble when something happens to you.": "Vibra cuando te pasa algo.",
|
||||
"The controller keeps playing while this window isn't focused, and losing focus won't pause.": "El mando sigue funcionando aunque esta ventana no tenga el foco, y perder el foco no pone el juego en pausa.",
|
||||
"A DualSense's triggers push back on bows and tools.": "Los gatillos de un DualSense ofrecen resistencia con arcos y herramientas.",
|
||||
"What a DualSense's light shows, over USB or Bluetooth.": "Lo que muestra la luz de un DualSense, por USB o Bluetooth.",
|
||||
"Player profile": "Perfil de jugador",
|
||||
|
||||
@@ -7,6 +7,18 @@ fn main() {
|
||||
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
|
||||
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Client)
|
||||
.expect("generating native mod API");
|
||||
// A workspace horizon-mixins changed holds native mods, whichever tool ran it (the launcher,
|
||||
// or a mod's own script with no `native-mods.json` beside its game): `HORIZON_MIXIN_MODS`
|
||||
// names them for `game::mods::built_with_native_mods`. Watched only once it exists: a
|
||||
// bundle applied in place also edits `game/`, which `generate` watches.
|
||||
let stage = root.join(horizon_mod_api_gen::MIXINS_STAGE);
|
||||
if stage.is_file() {
|
||||
println!("cargo:rerun-if-changed={}", stage.display());
|
||||
}
|
||||
let staged = horizon_mod_api_gen::staged_mods(&root);
|
||||
if !staged.is_empty() {
|
||||
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
|
||||
}
|
||||
let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||
let ico = manifest_dir
|
||||
.join("../..")
|
||||
|
||||
@@ -10,6 +10,7 @@ use horizon_client::{
|
||||
block_entity_anim::EnchantingBook,
|
||||
block_entity_render::{
|
||||
BlockAnim, BlockEntityRenderer, END_GATEWAY_LAYER, END_PORTAL_LAYER, Scene, TextCall,
|
||||
text_vertices,
|
||||
},
|
||||
chunk::{Chunk, Light},
|
||||
hud::{HudAtlas, Rect},
|
||||
@@ -86,12 +87,19 @@ fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
|
||||
|
||||
fn sign_side(v: &Value) -> SignText {
|
||||
let mut side = SignText::default();
|
||||
for (line, text) in side
|
||||
.lines
|
||||
.iter_mut()
|
||||
.zip(v["lines"].as_array().into_iter().flatten())
|
||||
for (i, value) in v["lines"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.take(4)
|
||||
.enumerate()
|
||||
{
|
||||
*line = text.as_str().unwrap_or_default().to_owned();
|
||||
let line = horizon_client::scoreboard::comp_text(value);
|
||||
side.lines[i] = line.plain();
|
||||
side.saved[i] = Some(horizon_client::signs::SavedLine {
|
||||
text: line.plain(),
|
||||
component: horizon_client::lan::data::json_to_nbt(value),
|
||||
});
|
||||
}
|
||||
let color = v["color"].as_str().unwrap_or("black");
|
||||
side.color = DYE_TEXT.iter().position(|(n, _)| *n == color).unwrap_or(15) as u8;
|
||||
@@ -373,6 +381,7 @@ pub fn main() -> Result<()> {
|
||||
skull: &|_| None,
|
||||
glyph: &|c| atlas.glyph_cell(c),
|
||||
glyphs: atlas.font.epoch(),
|
||||
font: Some(&atlas.font),
|
||||
dynamic_visible: None,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
@@ -426,8 +435,26 @@ pub fn main() -> Result<()> {
|
||||
.iter()
|
||||
.filter(|t| !t.text.is_empty())
|
||||
.map(|t| {
|
||||
let mut x = t.x;
|
||||
let glyphs: Vec<_> = t.text.chars().map(|c| {
|
||||
let g = json!({"matrix": t.matrix.to_cols_array(), "x": x, "y": t.y,
|
||||
"text": c.to_string(), "color": t.color,
|
||||
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF)),
|
||||
"style": [t.style.is_bold(), t.style.is_italic(), t.style.underlined == Some(true),
|
||||
t.style.strikethrough == Some(true), t.style.obfuscated == Some(true)]});
|
||||
x += atlas.font.advance(c, &t.style);
|
||||
g
|
||||
}).collect();
|
||||
let mut vertices = Vec::new();
|
||||
let mut local = t.clone();
|
||||
local.matrix = Mat4::IDENTITY;
|
||||
text_vertices(&|c| atlas.font.glyph_styled(c, &t.style), Some(atlas.font.white()), &local, &mut vertices);
|
||||
let vertices: Vec<_> = vertices.iter().map(|v| {
|
||||
let c = v.color;
|
||||
json!([v.pos[0], v.pos[1], v.pos[2], (c & 0xff00ff00) | (c & 255) << 16 | (c >> 16 & 255)])
|
||||
}).collect();
|
||||
json!({"matrix": t.matrix.to_cols_array(), "x": t.x, "y": t.y, "text": t.text,
|
||||
"color": t.color, "outline": t.outline,
|
||||
"color": t.color, "outline": t.outline, "glyphs": glyphs, "textVertices": vertices,
|
||||
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF))})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -283,6 +283,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
loot: &loot,
|
||||
damage: Some(source),
|
||||
player: player.filter(|_| pkill),
|
||||
player_killed: pkill,
|
||||
luck: get("luck", "0").parse()?,
|
||||
vehicle: c.get("vehicle").map(|v| LootEntity::new(v)),
|
||||
mob_drops: true,
|
||||
|
||||
@@ -95,6 +95,7 @@ fn read_endpoint(path: &Path) -> io::Result<(u16, String)> {
|
||||
|
||||
/// Connect to a running game's control endpoint (and authenticate over TCP).
|
||||
pub fn connect(path: &Path) -> io::Result<Stream> {
|
||||
private_dir(path, false)?;
|
||||
#[cfg(unix)]
|
||||
if !uses_tcp(path) {
|
||||
return UnixStream::connect(path).map(Stream::Unix);
|
||||
@@ -158,10 +159,82 @@ pub fn default_path() -> PathBuf {
|
||||
if !cfg!(unix) {
|
||||
return std::env::temp_dir().join("horizon-control.json");
|
||||
}
|
||||
let dir = std::env::var_os("XDG_RUNTIME_DIR").map_or_else(std::env::temp_dir, PathBuf::from);
|
||||
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(fallback_dir)
|
||||
.unwrap_or_else(std::env::temp_dir);
|
||||
dir.join("horizon-control.sock")
|
||||
}
|
||||
|
||||
/// Without `$XDG_RUNTIME_DIR` (containers, some WSL and minimal setups), when the temporary
|
||||
/// directory is shared by every user (`/tmp`), a folder of this user's own in it,
|
||||
/// `horizon-<uid>`: a fixed name there let another user bind the socket first, so that
|
||||
/// `--control` failed and `horizon-ctl` talked to theirs. [`private_dir`] checks it before the
|
||||
/// socket is made or reached. A temporary directory of the user's own (macOS's `$TMPDIR`) is
|
||||
/// used as it is. The launcher finds the endpoint the same way (`game_tools.rs`).
|
||||
fn fallback_dir() -> Option<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = std::env::temp_dir();
|
||||
let shared = std::fs::metadata(&temp).is_ok_and(|m| m.permissions().mode() & 0o002 != 0);
|
||||
let uid = own_uid().filter(|_| shared)?;
|
||||
Some(temp.join(format!("horizon-{uid}")))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
None
|
||||
}
|
||||
|
||||
/// This process's user: the owner of `/proc/self` where there is one, else of the home folder.
|
||||
#[cfg(unix)]
|
||||
fn own_uid() -> Option<u32> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
std::fs::metadata("/proc/self")
|
||||
.ok()
|
||||
.or_else(|| std::fs::metadata(std::env::var_os("HOME")?).ok())
|
||||
.map(|m| m.uid())
|
||||
}
|
||||
|
||||
/// For an endpoint in [`fallback_dir`]: [`check_private`] on that folder. Elsewhere (the user's
|
||||
/// `$HORIZON_CONTROL`, the runtime directory) nothing to check.
|
||||
fn private_dir(path: &Path, make: bool) -> io::Result<()> {
|
||||
match fallback_dir() {
|
||||
Some(dir) if path.parent() == Some(dir.as_path()) => check_private(&dir, make),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `dir` made (mode 0700) when `make`, and refused unless it is a real folder, this user's, that
|
||||
/// no one else may enter.
|
||||
#[cfg(unix)]
|
||||
fn check_private(dir: &Path, make: bool) -> io::Result<()> {
|
||||
use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt};
|
||||
if make {
|
||||
match std::fs::DirBuilder::new().mode(0o700).create(dir) {
|
||||
Err(e) if e.kind() != io::ErrorKind::AlreadyExists => return Err(e),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let meta = std::fs::symlink_metadata(dir)?;
|
||||
if !meta.is_dir()
|
||||
|| meta.permissions().mode() & 0o077 != 0
|
||||
|| own_uid().is_some_and(|uid| uid != meta.uid())
|
||||
{
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"{} isn't a folder only this user can open; set $HORIZON_CONTROL or $XDG_RUNTIME_DIR",
|
||||
dir.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn check_private(_: &Path, _: bool) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `path` can be an endpoint at all, without touching it: a Unix socket's path has a
|
||||
/// length limit (`sockaddr_un`); a TCP endpoint is an ordinary file and has none.
|
||||
pub fn check_path(path: &Path) -> io::Result<()> {
|
||||
@@ -184,6 +257,7 @@ pub fn check_path(path: &Path) -> io::Result<()> {
|
||||
impl Server {
|
||||
pub fn start(path: &Path) -> io::Result<Self> {
|
||||
check_path(path)?;
|
||||
private_dir(path, true)?;
|
||||
// An endpoint left by a crashed game refuses connections; replace it.
|
||||
if path.exists() && connect(path).is_err() {
|
||||
std::fs::remove_file(path)?;
|
||||
@@ -392,6 +466,36 @@ fn serve(stream: Stream, sender: &mpsc::Sender<Request>) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn the_fallback_socket_folder_must_be_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let base = std::env::temp_dir().join(format!("horizon-control-dir-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
// Made 0700 when missing, and then fine to reach.
|
||||
let made = base.join("made");
|
||||
super::check_private(&made, true).unwrap();
|
||||
let mode = std::fs::metadata(&made).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700);
|
||||
super::check_private(&made, false).unwrap();
|
||||
// One another user could enter or write: refused, made or not.
|
||||
let open = base.join("open");
|
||||
std::fs::create_dir(&open).unwrap();
|
||||
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
|
||||
assert!(super::check_private(&open, true).is_err());
|
||||
assert!(super::check_private(&open, false).is_err());
|
||||
// A link to a private folder, or a file: refused.
|
||||
std::os::unix::fs::symlink(&made, base.join("link")).unwrap();
|
||||
assert!(super::check_private(&base.join("link"), false).is_err());
|
||||
std::fs::write(base.join("file"), b"").unwrap();
|
||||
assert!(super::check_private(&base.join("file"), true).is_err());
|
||||
// Missing and not to be made: an error, not a folder made by whoever connects.
|
||||
assert!(super::check_private(&base.join("missing"), false).is_err());
|
||||
assert!(!base.join("missing").exists());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The check agrees with the system at every length around its limit.
|
||||
|
||||
@@ -1118,13 +1118,15 @@ pub fn mob_to_nbt(
|
||||
);
|
||||
let mut memories: Vec<(&str, Tag)> = Vec::new();
|
||||
if m.extra.tame {
|
||||
memories.push((
|
||||
"minecraft:liked_player",
|
||||
Tag::compound([(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)]),
|
||||
));
|
||||
// `MemorySlot.visit`: the time to live only when it can expire.
|
||||
let mut liked = vec![(
|
||||
"value",
|
||||
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
|
||||
)];
|
||||
if let Some(ttl) = m.extra.allay.as_ref().and_then(|st| st.liked_player_ttl) {
|
||||
liked.push(("ttl", Tag::Long(ttl)));
|
||||
}
|
||||
memories.push(("minecraft:liked_player", Tag::compound(liked)));
|
||||
}
|
||||
// GlobalPos.CODEC, and the cooldown while it runs.
|
||||
if let Some(liked) = m.extra.allay.as_ref().and_then(|st| st.noteblock) {
|
||||
@@ -2218,6 +2220,20 @@ pub fn mob_from_nbt(
|
||||
m.extra.owner = liked
|
||||
.and_then(|l| l.get("value"))
|
||||
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
|
||||
// `Brain.readAdditionalSaveData`: a memory saved with a time to live
|
||||
// (`Codec.LONG`, read from any integer tag). `Long.MAX_VALUE` is
|
||||
// `MemorySlot.NEVER_EXPIRE`: that memory never expires.
|
||||
if let Some(st) = m.extra.allay.as_mut() {
|
||||
st.liked_player_ttl = liked
|
||||
.and_then(|l| match l.get("ttl")? {
|
||||
Tag::Byte(v) => Some(i64::from(*v)),
|
||||
Tag::Short(v) => Some(i64::from(*v)),
|
||||
Tag::Int(v) => Some(i64::from(*v)),
|
||||
Tag::Long(v) => Some(*v),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|&ttl| ttl != i64::MAX);
|
||||
}
|
||||
let memory = |key: &str| {
|
||||
tag.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
@@ -5951,6 +5967,15 @@ pub fn bucket_data(m: &Mob) -> Tag {
|
||||
if let Some(ttl) = crate::mobs::animal_brain::hunting_cooldown(m) {
|
||||
put(&mut data, "HuntingCooldown", Tag::Long(ttl));
|
||||
}
|
||||
// SulfurCube.saveToBucketTag: after the default data, its age.
|
||||
if m.species() == crate::mobs::Species::SulfurCube {
|
||||
put(&mut data, "age", Tag::Int(m.extra.age));
|
||||
put(
|
||||
&mut data,
|
||||
"age_locked",
|
||||
Tag::Byte(i8::from(m.extra.age_locked)),
|
||||
);
|
||||
}
|
||||
data
|
||||
}
|
||||
pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
|
||||
@@ -5962,6 +5987,15 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData {
|
||||
..Default::default()
|
||||
};
|
||||
data.set_other("minecraft:bucket_entity_data", Some(bucket_data(m)));
|
||||
// SulfurCube.saveToBucketTag: the body block, when it has one.
|
||||
if m.species() == crate::mobs::Species::SulfurCube
|
||||
&& let Some(body) = &m.extra.body
|
||||
{
|
||||
data.set_other(
|
||||
"minecraft:sulfur_cube_content",
|
||||
Some(Tag::compound([("id", string(body))])),
|
||||
);
|
||||
}
|
||||
if m.species() == crate::mobs::Species::Axolotl {
|
||||
let variant = AXOLOTL_VARIANTS
|
||||
.get(usize::from(m.extra.variant))
|
||||
|
||||
@@ -91,6 +91,8 @@ mod chat_filter;
|
||||
mod chiseled_bookshelf;
|
||||
mod client_ambient;
|
||||
mod clipboard;
|
||||
#[cfg(test)]
|
||||
mod clone_block_ticks_tests;
|
||||
mod code_of_conduct;
|
||||
mod combat;
|
||||
mod command_blocks;
|
||||
@@ -407,6 +409,7 @@ mod sniffers;
|
||||
mod social;
|
||||
mod spawner;
|
||||
mod spear;
|
||||
mod spear_players;
|
||||
mod spectator;
|
||||
mod spill;
|
||||
mod splash_water;
|
||||
@@ -457,6 +460,8 @@ mod world_entry;
|
||||
mod world_loading;
|
||||
mod world_primitives;
|
||||
mod world_session;
|
||||
#[cfg(test)]
|
||||
mod world_test_def_tests;
|
||||
mod zombie_reinforcements;
|
||||
mod zombie_villagers;
|
||||
use event_loop::EventLoopCtl;
|
||||
@@ -589,6 +594,9 @@ const AUTO_UPLOAD_TIME: Duration = Duration::from_millis(2);
|
||||
const AUTO_UPLOAD_TIME_ARRIVING: Duration = Duration::from_millis(6);
|
||||
/// A frame's finished meshes up to this many upload at once, unqueued.
|
||||
const AUTO_UPLOADS_AT_ONCE: usize = 8;
|
||||
/// The most changed columns far terrain waits on behind a mod's hold (`Game::lod_held`): past
|
||||
/// it, they go in as ever.
|
||||
const HELD_FAR_COLUMNS: usize = 1 << 13;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Options {
|
||||
@@ -2421,6 +2429,9 @@ struct Game<'a> {
|
||||
lod: Option<horizon_client::lod::Lod>,
|
||||
lod_source_attempted: bool,
|
||||
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
|
||||
/// Columns the single-player server changed in bulk inside a mod's hold on the terrain
|
||||
/// (`native_world_hold`): far terrain takes them in when the hold lets go of them.
|
||||
lod_held: horizon_client::fast_hash::FxHashMap<(i32, i32), Arc<horizon_client::chunk::Chunk>>,
|
||||
/// The server level far terrain keeps the chunks of.
|
||||
lod_level: Option<String>,
|
||||
/// The server's BlueMap far terrain draws from, when the player named one
|
||||
@@ -3117,6 +3128,9 @@ struct Game<'a> {
|
||||
structure_templates: HashMap<String, Option<structure_blocks::TemplateEntry>>,
|
||||
/// The game tests read so far, by id (the test instance registry).
|
||||
test_defs: std::cell::RefCell<HashMap<String, Option<test_instances::TestDef>>>,
|
||||
/// `test_defs` holds the world's whole test instance registry
|
||||
/// (`snapshot_test_defs`), so a miss is final.
|
||||
test_defs_snapshot: bool,
|
||||
/// `GameTestBlockHighlightRenderer.markers`: block, text, removal time.
|
||||
test_highlights: HashMap<(i32, i32, i32), (String, std::time::Instant)>,
|
||||
/// `GameTestTicker.SINGLETON`: the game tests running on this server.
|
||||
@@ -3216,6 +3230,12 @@ struct Game<'a> {
|
||||
/// `LivingEntity.recentKineticEnemies`: when each player (a guest, or the
|
||||
/// window's own) last stabbed each entity this use.
|
||||
stabbed: HashMap<(Option<u32>, u32), i64>,
|
||||
/// The other players the acting player's spear can reach this turn,
|
||||
/// taken from the hub before it (`spear_players`).
|
||||
stab_players: Vec<spear_players::StabPlayer>,
|
||||
/// Spear stabs on players worked out in a guest's turn, landed once the
|
||||
/// hub is back (`land_player_stabs`).
|
||||
player_stabs: Vec<spear_players::StabBatch>,
|
||||
/// The movement a guest's client reported this tick
|
||||
/// (`ServerPlayer.getKnownMovement`), for their turn.
|
||||
known_movement: Vec3,
|
||||
@@ -9912,6 +9932,7 @@ impl Game<'_> {
|
||||
skull: &|p| self.skull_skin_at(p),
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
font: Some(&self.gui.atlas.font),
|
||||
dynamic_visible: Some(&dynamic_visible),
|
||||
};
|
||||
let visible = camera_list.into_iter().chain(shadow_only.iter().copied());
|
||||
@@ -14485,6 +14506,7 @@ impl Game<'_> {
|
||||
self.lod = None;
|
||||
self.lod_source_attempted = false;
|
||||
self.lod_observe.clear();
|
||||
self.lod_held.clear();
|
||||
self.lod_blue_map = None;
|
||||
self.far_beacons.clear();
|
||||
self.lod_covered_at = None;
|
||||
@@ -14635,8 +14657,18 @@ impl Game<'_> {
|
||||
if self.remote.is_some() {
|
||||
let changed = horizon_client::lan::handoff::take_changed();
|
||||
if let Some(lod) = &mut self.lod {
|
||||
let renderer = self.renderer.as_ref();
|
||||
for chunk in changed {
|
||||
if !self.world.chunks.contains_key(&(chunk.x, chunk.z)) {
|
||||
let column = (chunk.x, chunk.z);
|
||||
if self.world.chunks.contains_key(&column) {
|
||||
continue;
|
||||
}
|
||||
// Inside a mod's hold, far terrain shows it with the sections (`native_world_hold`).
|
||||
if renderer.is_some_and(|r| r.world_holds_column(column))
|
||||
&& self.lod_held.len() < HELD_FAR_COLUMNS
|
||||
{
|
||||
self.lod_held.insert(column, chunk);
|
||||
} else {
|
||||
lod.observe(chunk, self.res.facts());
|
||||
}
|
||||
}
|
||||
@@ -14752,7 +14784,29 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])
|
||||
.and_then(|camera| self.lod_aim(camera));
|
||||
// A mod's hold on the terrain's picture, before this frame's uploads (`native_world_hold`).
|
||||
let world_hold = self
|
||||
.renderer
|
||||
.is_some()
|
||||
.then(|| self.native_world_hold_now());
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
if let Some(hold) = &world_hold {
|
||||
renderer.hold_world(hold);
|
||||
}
|
||||
// Far terrain takes in the columns the hold let go of, as the sections show.
|
||||
if !self.lod_held.is_empty() {
|
||||
let let_go: Vec<_> = self
|
||||
.lod_held
|
||||
.extract_if(|&column, _| !renderer.world_holds_column(column))
|
||||
.collect();
|
||||
if let Some(lod) = &mut self.lod {
|
||||
for (column, chunk) in let_go {
|
||||
if !self.world.chunks.contains_key(&column) {
|
||||
lod.observe(chunk, self.res.facts());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let g = &self.settings.horizon;
|
||||
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
|
||||
// The meshes, once copied, are freed by a worker (`Streaming::free`).
|
||||
@@ -16487,6 +16541,7 @@ impl<'a> Game<'a> {
|
||||
lod_source_attempted: false,
|
||||
far_beacons: HashMap::new(),
|
||||
lod_observe: Default::default(),
|
||||
lod_held: Default::default(),
|
||||
lod_level: None,
|
||||
lod_blue_map: None,
|
||||
lod_covered_at: None,
|
||||
@@ -16886,6 +16941,7 @@ impl<'a> Game<'a> {
|
||||
block_editor: None,
|
||||
structure_templates: HashMap::new(),
|
||||
test_defs: std::cell::RefCell::new(HashMap::new()),
|
||||
test_defs_snapshot: false,
|
||||
test_highlights: HashMap::new(),
|
||||
game_tests: game_tests::GameTests::default(),
|
||||
#[cfg(feature = "friends")]
|
||||
@@ -16934,6 +16990,8 @@ impl<'a> Game<'a> {
|
||||
lan_critical_hits: Vec::new(),
|
||||
lan_kinetic_hits: Vec::new(),
|
||||
stabbed: HashMap::new(),
|
||||
stab_players: Vec::new(),
|
||||
player_stabs: Vec::new(),
|
||||
known_movement: Vec3::ZERO,
|
||||
kinetic_feedback: HashMap::new(),
|
||||
lan_global_events: Vec::new(),
|
||||
@@ -18166,6 +18224,8 @@ fn write_level_for_role(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sulfur_bucket_tests;
|
||||
#[cfg(test)]
|
||||
mod vibration_tests;
|
||||
|
||||
|
||||
@@ -505,6 +505,9 @@ impl Game<'_> {
|
||||
"food": p.health.food.food,
|
||||
"saturation": p.health.food.saturation,
|
||||
"shield_cooldown": self.shield_cooldown,
|
||||
// ItemCooldowns.getCooldownPercent of the shield: what this window's hotbar draws
|
||||
// over it (on a server's client, only what the server's Cooldown packets told it).
|
||||
"shield_cooldown_shown": self.cooldowns.percent("minecraft:shield", 0.0),
|
||||
"xp": { "level": self.experience.level, "progress": self.experience.progress, "total": self.experience.total },
|
||||
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
|
||||
})
|
||||
|
||||
@@ -377,9 +377,8 @@ impl Game<'_> {
|
||||
// `Allay.mobInteract` hands over one item, its components and all.
|
||||
given.count = 1;
|
||||
m.extra.set_main_hand_stack(Some(given));
|
||||
m.extra.tame = true;
|
||||
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
|
||||
m.extra.owner = Some(acting);
|
||||
allay::like_player(m, acting);
|
||||
self.play_mob_bound_sound(
|
||||
index,
|
||||
"minecraft:entity.allay.item_given",
|
||||
@@ -402,8 +401,7 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:air", 1));
|
||||
m.extra.set_main_hand(None);
|
||||
let carried = m.extra.allay.as_mut().and_then(|st| st.inventory.take());
|
||||
m.extra.tame = false;
|
||||
m.extra.owner = None;
|
||||
allay::forget_liked_player(m);
|
||||
self.play_mob_bound_sound(
|
||||
index,
|
||||
"minecraft:entity.allay.item_taken",
|
||||
|
||||
@@ -1478,14 +1478,55 @@ impl Game<'_> {
|
||||
let Some(info) = self.items().get(&stack.item) else {
|
||||
return false;
|
||||
};
|
||||
let (index, slot) = match info.equip_slot.as_deref() {
|
||||
let components = self
|
||||
.pack
|
||||
.item_renderer
|
||||
.models
|
||||
.components(&horizon_client::item_render::stack_view(stack));
|
||||
let Some(equippable) = components.get("minecraft:equippable") else {
|
||||
return false;
|
||||
};
|
||||
let actual = stack
|
||||
.data()
|
||||
.and_then(|data| data.other("minecraft:equippable"));
|
||||
// Only adopt the default-allowed shape here. Restricted or otherwise
|
||||
// shaped overrides keep their existing admission until supported.
|
||||
let supported = actual.is_none_or(|tag| {
|
||||
matches!(tag, horizon_world::nbt::Tag::Compound(map)
|
||||
if map.keys().all(|key| matches!(key.to_string_lossy().as_str(), "slot" | "swappable")))
|
||||
&& matches!(tag.get("slot"), Some(horizon_world::nbt::Tag::String(_)))
|
||||
&& tag.get("swappable").is_none_or(|flag| {
|
||||
horizon_client::item_data::component_bool(flag).is_some()
|
||||
})
|
||||
});
|
||||
let slot = if supported {
|
||||
equippable.get("slot").and_then(serde_json::Value::as_str)
|
||||
} else {
|
||||
info.equip_slot.as_deref()
|
||||
};
|
||||
let (index, slot) = match slot {
|
||||
Some("head") => (0, "head"),
|
||||
Some("chest") => (1, "chest"),
|
||||
Some("legs") => (2, "legs"),
|
||||
Some("feet") => (3, "feet"),
|
||||
_ => return false,
|
||||
};
|
||||
if !info.swappable {
|
||||
// The component codec replaces the whole value: an override which
|
||||
// omits swappable uses true, rather than its item's prototype flag.
|
||||
let swappable = if !supported {
|
||||
info.swappable
|
||||
} else if let Some(actual) = actual {
|
||||
actual
|
||||
.get("swappable")
|
||||
.and_then(horizon_client::item_data::component_bool)
|
||||
.unwrap_or(true)
|
||||
} else {
|
||||
equippable
|
||||
.get("swappable")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or(true)
|
||||
};
|
||||
if !swappable {
|
||||
return false;
|
||||
}
|
||||
let current = self.armor[index].clone();
|
||||
@@ -1912,3 +1953,140 @@ mod layer_color_tests {
|
||||
assert_eq!(layer_color(None, Some(&dyed)), 0xFFFF_FFFF);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pumpkin_equippable_use_tests {
|
||||
use super::*;
|
||||
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_protocol::packets::{
|
||||
configuration::ClientInformation,
|
||||
play::{Serverbound, UseItem},
|
||||
};
|
||||
use std::sync::{atomic::AtomicI32, mpsc};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h207_connected_use_item_adopts_actual_pumpkin_equippable_and_preserves_controls() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.world.loaded(8, 8));
|
||||
game.lan_host(
|
||||
crate::game::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "pumpkin-use-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 207,
|
||||
name: "PumpkinGuest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[8.5, 200.0, 8.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.selected = 0;
|
||||
guest.permission = 2;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
let clock = game.world.game_time;
|
||||
let eq = "minecraft:equippable={slot:\"head\"}";
|
||||
let marker = "minecraft:custom_data={h207:207}";
|
||||
let helmet = "minecraft:diamond_helmet[minecraft:custom_data={old:1}]";
|
||||
let mut failures = Vec::new();
|
||||
for (i, (label, mode, component, count, old, changed)) in [
|
||||
("empty", "survival", Some(eq), 3, None, true),
|
||||
("swap", "survival", Some(eq), 1, Some(helmet), true),
|
||||
("creative", "creative", Some(eq), 3, None, true),
|
||||
("prototype", "survival", None, 3, None, false),
|
||||
("false", "survival", Some("minecraft:equippable={slot:\"head\",swappable:0b}"), 3, None, false),
|
||||
("binding", "survival", Some(eq), 3, Some("minecraft:diamond_helmet[minecraft:enchantments={\"minecraft:binding_curse\":1},minecraft:custom_data={old:1}]"), false),
|
||||
("same", "survival", Some(eq), 3, Some("minecraft:carved_pumpkin[minecraft:equippable={slot:\"head\"},minecraft:custom_data={h207:207}]"), false),
|
||||
].into_iter().enumerate() {
|
||||
let held = format!("minecraft:carved_pumpkin[{}{}{}]", component.unwrap_or(""), if component.is_some() { "," } else { "" }, marker);
|
||||
let before = game.as_guest(&mut host, 2, |g| {
|
||||
assert_eq!(g.ui.registries.permission, 2);
|
||||
assert!(g.run_command("clear @s") || g.inventory.iter().all(Option::is_none));
|
||||
g.run_command("item replace entity @s armor.head with air");
|
||||
g.run_command(&format!("gamemode {mode}"));
|
||||
assert!(g.run_command(&format!("item replace entity @s weapon.mainhand with {held} {count}")));
|
||||
if let Some(old) = old { assert!(g.run_command(&format!("item replace entity @s armor.head with {old}"))); }
|
||||
assert_eq!(g.player.as_ref().unwrap().position, Vec3::new(8.5, 200.0, 8.5));
|
||||
let hand = g.held().unwrap().clone();
|
||||
assert_eq!(hand.item.as_ref(), "minecraft:carved_pumpkin");
|
||||
assert_eq!(hand.count, count);
|
||||
assert!(hand.data().unwrap().other("minecraft:custom_data").is_some());
|
||||
let actual = hand.data().unwrap().other("minecraft:equippable");
|
||||
assert_eq!(actual.is_some(), component.is_some());
|
||||
if let Some(actual) = actual {
|
||||
assert_eq!(actual.get("slot"), Some(&horizon_world::nbt::Tag::String("head".into())));
|
||||
assert_eq!(actual.get("swappable").and_then(horizon_client::item_data::component_bool), if label == "false" { Some(false) } else { None });
|
||||
}
|
||||
if label == "binding" { assert_eq!(g.armor[0].as_ref().unwrap().enchantment_level("minecraft:binding_curse"), 1); }
|
||||
if label == "same" { assert!(g.armor[0].as_ref().unwrap().same_item_same_components(&hand)); }
|
||||
(g.inventory.clone(), g.armor.clone(), hand)
|
||||
}).unwrap();
|
||||
let sequence = 207 + i as i32;
|
||||
game.lan_guest_packet(&mut host, 2, Serverbound::UseItem(UseItem { hand: 0, yaw: 0.0, pitch: 0.0, sequence }));
|
||||
assert!(host.hub.guest(2).unwrap().ack_up_to.get() >= sequence);
|
||||
game.as_guest(&mut host, 2, |g| {
|
||||
let mut expected_inventory = before.0.clone();
|
||||
let mut expected_armor = before.1.clone();
|
||||
if changed {
|
||||
let mut worn = before.2.clone();
|
||||
worn.count = 1;
|
||||
expected_armor[0] = Some(worn);
|
||||
if label == "swap" { expected_inventory[0] = before.1[0].clone(); }
|
||||
else if label == "empty" { expected_inventory[0].as_mut().unwrap().count = 2; }
|
||||
}
|
||||
if g.inventory != expected_inventory || g.armor != expected_armor {
|
||||
failures.push((label, g.inventory.clone(), g.armor.clone()));
|
||||
}
|
||||
assert_eq!(g.world.game_time, clock);
|
||||
}).unwrap();
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual connected UseItem Equippable adoption failures: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ use horizon_core::{
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::collision_context::CollisionContext;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::{collections::HashMap, f32::consts::PI, sync::Arc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -80,6 +81,9 @@ pub(super) struct Boat {
|
||||
pub hurt_time: i32,
|
||||
pub hurt_dir: i32,
|
||||
pub damage: f32,
|
||||
/// `Entity.remainingFireTicks` (`Fire` in its save): burning while
|
||||
/// positive, a fire block's cooldown while negative.
|
||||
pub fire_ticks: i32,
|
||||
/// `Entity.noGravity` (`NoGravity` in its save).
|
||||
pub no_gravity: bool,
|
||||
pub paddles: [f32; 2],
|
||||
@@ -148,9 +152,19 @@ fn snowflake_velocity(known: Vec3, random: &mut impl RandomSource) -> Option<[f6
|
||||
let za = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
|
||||
Some([f64::from(xa), f64::from(0.05_f32), f64::from(za)])
|
||||
}
|
||||
/// `Entity.load`'s `Fire` (a short; 0 when absent).
|
||||
pub(super) fn saved_fire(tag: &Tag) -> i32 {
|
||||
i32::from(horizon_client::save::number(tag.get("Fire")).unwrap_or(0) as i16)
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
/// `AbstractBoat.tick`: the hurt wobble and the damage wear off, before
|
||||
/// `Entity.baseTick` can burn the boat.
|
||||
fn wear_off_hurt(&mut self) {
|
||||
self.hurt_time = (self.hurt_time - 1).max(0);
|
||||
self.damage = (self.damage - 1.0).max(0.0);
|
||||
}
|
||||
/// `AbstractBoat.tick`: passengers lose control after 60 consecutive
|
||||
/// underwater ticks; any other status resets the counter.
|
||||
fn tick_submersion(&mut self) {
|
||||
@@ -191,6 +205,7 @@ impl Boat {
|
||||
hurt_time: 0,
|
||||
hurt_dir: 1,
|
||||
damage: 0.0,
|
||||
fire_ticks: 0,
|
||||
no_gravity: false,
|
||||
paddles: [0.0; 2],
|
||||
paddling: [false; 2],
|
||||
@@ -224,6 +239,23 @@ impl Boat {
|
||||
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
|
||||
)
|
||||
}
|
||||
/// `Entity.saveWithoutId` with `AbstractChestBoat`'s items: the loaded
|
||||
/// compound `raw` under the live fields.
|
||||
pub(super) fn to_nbt(&self, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
|
||||
let mut tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
&self.entity_type(),
|
||||
self.position,
|
||||
self.velocity,
|
||||
[self.yaw, self.pitch],
|
||||
uuid,
|
||||
raw,
|
||||
self.chest.then_some(&self.items[..]),
|
||||
);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.insert("Fire".into(), Tag::Short(self.fire_ticks as i16));
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// The start of `Entity.move`: a boat made stuck since its last move goes its
|
||||
/// velocity times the multiplier, and its velocity and the multiplier are spent.
|
||||
fn take_movement(&mut self) -> Vec3 {
|
||||
@@ -648,8 +680,10 @@ impl Game<'_> {
|
||||
b.old_status = b.status;
|
||||
b.status = self.boat_status(&mut b);
|
||||
b.tick_submersion();
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
// `tick_boats_now` wears it off for the boats it ticks, before they burn.
|
||||
if controlled {
|
||||
b.wear_off_hurt();
|
||||
}
|
||||
// A remote client gets the controller from the passenger packet, even when
|
||||
// this window occupies the second seat. Local worlds derive it here.
|
||||
if self.remote.is_none() {
|
||||
@@ -854,7 +888,17 @@ impl Game<'_> {
|
||||
|| driven.contains(&id)
|
||||
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
|
||||
{
|
||||
// Its rider's client moves it, but the lead still ticks.
|
||||
// Its rider's client moves it, but the server still wears
|
||||
// off its damage and then burns it.
|
||||
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
|
||||
if driven.contains(&id) {
|
||||
self.boats[i].wear_off_hurt();
|
||||
}
|
||||
if self.boat_fire_tick(i) {
|
||||
break 'boat;
|
||||
}
|
||||
}
|
||||
// The lead still ticks too.
|
||||
if ticking(p.x.floor() as i32, p.z.floor() as i32) && self.remote.is_none() {
|
||||
let mut b = self.boats[i].clone();
|
||||
self.tick_boat_leash(&mut b);
|
||||
@@ -879,9 +923,13 @@ impl Game<'_> {
|
||||
else {
|
||||
break 'boat;
|
||||
};
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
self.boat_server_passes(id, &driven, &ticking, &round);
|
||||
self.boats[i].wear_off_hurt();
|
||||
// Entity.baseTick burns it before it moves; the fire may break it.
|
||||
if !self.boat_fire_tick(i) {
|
||||
let b = self.boats[i].clone();
|
||||
self.boats[i] = self.tick_boat(b, false);
|
||||
self.boat_server_passes(id, &driven, &ticking, &round);
|
||||
}
|
||||
self.native_tick_post_one(&round, id, &kind);
|
||||
}
|
||||
i = match self.boats.get(i) {
|
||||
@@ -916,6 +964,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
if self.remote.is_none() {
|
||||
if self.boat_fire_passes(i) {
|
||||
return;
|
||||
}
|
||||
self.boat_powder_snow_snowflakes(i);
|
||||
}
|
||||
// `AbstractBoat.tick` counts submersion on the server even when
|
||||
@@ -1125,6 +1176,121 @@ impl Game<'_> {
|
||||
}
|
||||
cells
|
||||
}
|
||||
/// `BaseFireBlock.fireDamage` of a fire block at `p`.
|
||||
fn fire_damage_at(&self, p: [i32; 3]) -> Option<f32> {
|
||||
match &*self
|
||||
.res
|
||||
.facts()
|
||||
.get(self.world.block(p[0], p[1], p[2]))
|
||||
.name
|
||||
{
|
||||
"minecraft:fire" => Some(1.0),
|
||||
"minecraft:soul_fire" => Some(2.0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `Entity.baseTick` on the server: a burning boat takes 1 fire damage
|
||||
/// every 20 ticks (none in lava) and burns a tick down. True when the fire
|
||||
/// broke it.
|
||||
fn boat_fire_tick(&mut self, i: usize) -> bool {
|
||||
if self.remote.is_some() || self.boats[i].fire_ticks <= 0 {
|
||||
return false;
|
||||
}
|
||||
let burn = self.boats[i].fire_ticks % 20 == 0 && !self.in_lava(self.boats[i].bounds());
|
||||
self.boats[i].fire_ticks -= 1;
|
||||
burn && self.burn_boat(i, 1.0)
|
||||
}
|
||||
/// The fire of the two `applyEffectsFromBlocks` in `AbstractBoat.tick`.
|
||||
/// `Entity.checkInsideBlocks` meets the blocks of the box the boat started
|
||||
/// its move in first, then the ones it reached, each block once; in each
|
||||
/// step fire blocks light it (`BaseFireBlock.fireIgnite`, a cooldown tick
|
||||
/// at a time) and each burns it, then a burning boat melts the powder snow
|
||||
/// (with `mob_griefing`) and water and powder snow put it out. Rain puts
|
||||
/// it out too, and a boat neither burning nor lit has its cooldown
|
||||
/// cleared. True when the fire broke it.
|
||||
fn boat_fire_passes(&mut self, i: usize) -> bool {
|
||||
let b = &self.boats[i];
|
||||
let mut steps = vec![Boat::bounds_at(b.previous)];
|
||||
if b.previous != b.position {
|
||||
steps.push(b.bounds());
|
||||
}
|
||||
// `Entity.collidedWithFluid` sweeps the box along the whole move:
|
||||
// water reaches it down to the lower of its two bottoms.
|
||||
let low = b.previous.y.min(b.position.y);
|
||||
let (at, top) = (b.position, b.bounds().max_position().y);
|
||||
let rain = self.in_rain(at, top);
|
||||
for _ in 0..2 {
|
||||
let before = self.boats[i].fire_ticks;
|
||||
let mut seen = Vec::new();
|
||||
for &bounds in &steps {
|
||||
let cells = powder_snow_cells(bounds, |p| !seen.contains(&p));
|
||||
seen.extend_from_slice(&cells);
|
||||
let fires: Vec<f32> = cells
|
||||
.iter()
|
||||
.filter_map(|&p| self.fire_damage_at(p))
|
||||
.collect();
|
||||
if !fires.is_empty() {
|
||||
let fire = &mut self.boats[i].fire_ticks;
|
||||
if *fire < 0 {
|
||||
*fire += 1;
|
||||
}
|
||||
if *fire >= 0 {
|
||||
// igniteForSeconds(8)
|
||||
*fire = (*fire).max(160);
|
||||
}
|
||||
for &damage in &fires {
|
||||
if self.burn_boat(i, damage) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let water = cells.iter().any(|&p| self.water_reaches(p, low));
|
||||
let snow: Vec<[i32; 3]> = cells
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&p| self.is_powder_snow(p))
|
||||
.collect();
|
||||
if self.boats[i].fire_ticks > 0 && self.ui.rules.bool("mob_griefing") {
|
||||
for &cell in &snow {
|
||||
self.powder_snow_melt(BlockPos::new(cell[0], cell[1], cell[2]));
|
||||
}
|
||||
}
|
||||
if water || !snow.is_empty() {
|
||||
// clearFire
|
||||
let fire = &mut self.boats[i].fire_ticks;
|
||||
*fire = (*fire).min(0);
|
||||
}
|
||||
}
|
||||
let fire = &mut self.boats[i].fire_ticks;
|
||||
if rain {
|
||||
*fire = (*fire).min(0);
|
||||
}
|
||||
let now = *fire;
|
||||
if now <= 0 && now <= before {
|
||||
*fire = 0;
|
||||
}
|
||||
if before > 0 && now <= 0 {
|
||||
self.extinguish_sound([at.x, at.y, at.z]);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Whether the water at cell `p` reaches a box whose bottom is `low`
|
||||
/// (`Entity.collidedWithFluid`).
|
||||
fn water_reaches(&self, p: [i32; 3], low: f64) -> bool {
|
||||
self.fluid_height_of(p[0], p[1], p[2], Some(Fluid::Water))
|
||||
.is_some_and(|(height, _)| low + 1.0e-5 < f64::from(p[1]) + f64::from(height))
|
||||
}
|
||||
/// `VehicleEntity.hurtServer` for fire (`inFire`, `onFire`), which an
|
||||
/// `Invulnerable` boat ignores. True when the boat broke.
|
||||
fn burn_boat(&mut self, i: usize, damage: f32) -> bool {
|
||||
let invulnerable = self
|
||||
.persist
|
||||
.raw
|
||||
.get(&self.boats[i].id)
|
||||
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
|
||||
!invulnerable && self.hurt_boat(i, damage, false)
|
||||
}
|
||||
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles`: a boat that moved
|
||||
/// horizontally in powder snow throws snowflakes the players around see. `AbstractBoat.tick`
|
||||
/// calls `applyEffectsFromBlocks` twice.
|
||||
@@ -1521,7 +1687,8 @@ impl Game<'_> {
|
||||
}
|
||||
// `VehicleEntity.destroy(level, dropItem)`: `kill` (the boat's own
|
||||
// ENTITY_DIE), then the boat item on the death path only, and only
|
||||
// with `entity_drops` on.
|
||||
// with `entity_drops` on, by `spawnAtLocation` (an ItemEntity's own
|
||||
// random motion).
|
||||
if !creative {
|
||||
self.entity_killed_event(at, false);
|
||||
}
|
||||
@@ -1535,7 +1702,7 @@ impl Game<'_> {
|
||||
extra: None,
|
||||
},
|
||||
at,
|
||||
[0.0, 0.1, 0.0],
|
||||
super::dispenser::new_item_motion(),
|
||||
10,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1883,6 +1883,8 @@ impl Game<'_> {
|
||||
}
|
||||
self.ui.chat.clear();
|
||||
self.build_registries();
|
||||
// The world opens with its test instances (`Registries.TEST_INSTANCE`).
|
||||
self.snapshot_test_defs();
|
||||
self.sync_rules();
|
||||
}
|
||||
/// Save the clock beside level.dat (Level.write saves the game rules: a
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! `CloneCommands.clone` ends with `LevelTicks.copyAreaFrom`: every block
|
||||
//! tick pending in the source box is scheduled at the same offset in the
|
||||
//! destination, with its trigger time, whatever the clone placed there
|
||||
//! (tools/test_clone_block_ticks_parity.py measures the same on vanilla 26.3).
|
||||
use super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::position::{BlockPos, ChunkPos};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
// A loaded chunk whose block tick queue is registered.
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
|
||||
game
|
||||
}
|
||||
|
||||
/// The block ticks queued at `pos`: (block, trigger tick).
|
||||
fn ticks_at(game: &Game<'_>, pos: BlockPos) -> Vec<(u32, i64)> {
|
||||
game.block_ticks
|
||||
.blocks
|
||||
.chunk(ChunkPos::new(0, 0))
|
||||
.unwrap()
|
||||
.queued()
|
||||
.iter()
|
||||
.filter(|t| t.pos == pos)
|
||||
.map(|t| (t.kind.0, t.trigger_tick))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_strict_clone_copies_the_pending_block_ticks_of_its_source() {
|
||||
let mut game = game();
|
||||
let source = BlockPos::new(1, 101, 1);
|
||||
assert!(game.run_command("setblock 1 101 1 sand"));
|
||||
let pending = ticks_at(&game, source);
|
||||
assert_eq!(pending.len(), 1, "the placed sand's fall is queued");
|
||||
// Strict: the clone places the sand without its own tick.
|
||||
assert!(game.run_command("clone 1 101 1 1 101 1 3 101 1 strict replace"));
|
||||
assert_eq!(
|
||||
ticks_at(&game, BlockPos::new(3, 101, 1)),
|
||||
pending,
|
||||
"copied as it was"
|
||||
);
|
||||
assert_eq!(ticks_at(&game, source), pending, "the source keeps its own");
|
||||
// Filtered: the sand's tick comes with it, the dirt below has none.
|
||||
assert!(game.run_command("setblock 5 100 1 dirt"));
|
||||
assert!(game.run_command("setblock 5 101 1 sand"));
|
||||
let pending = ticks_at(&game, BlockPos::new(5, 101, 1));
|
||||
assert!(game.run_command("clone 5 100 1 5 101 1 7 100 1 strict filtered minecraft:sand"));
|
||||
assert_eq!(ticks_at(&game, BlockPos::new(7, 101, 1)), pending);
|
||||
assert_eq!(ticks_at(&game, BlockPos::new(7, 100, 1)), vec![]);
|
||||
// Move: the copy is made after the source was cleared.
|
||||
assert!(game.run_command("setblock 9 101 1 sand"));
|
||||
let pending = ticks_at(&game, BlockPos::new(9, 101, 1));
|
||||
assert!(game.run_command("clone 9 101 1 9 101 1 11 101 1 strict replace move"));
|
||||
assert_eq!(ticks_at(&game, BlockPos::new(11, 101, 1)), pending);
|
||||
}
|
||||
|
||||
/// Added controls (after the baseline). A destination that already holds a
|
||||
/// tick for the same block keeps that one (`LevelChunkTicks.schedule` keeps
|
||||
/// one tick per position and type). A copied tick keeps its priority and
|
||||
/// comes after the ticks queued before it. The copy is made before the
|
||||
/// command fails on an empty count, as `copyAreaFrom` is called before
|
||||
/// `ERROR_FAILED`.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn copied_block_ticks_keep_order_priority_and_dedup() {
|
||||
let mut game = game();
|
||||
let queued = |game: &Game<'_>, pos: BlockPos| {
|
||||
game.block_ticks
|
||||
.blocks
|
||||
.chunk(ChunkPos::new(0, 0))
|
||||
.unwrap()
|
||||
.queued()
|
||||
.iter()
|
||||
.filter(|t| t.pos == pos)
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// The destination's own tick stays the only one.
|
||||
assert!(game.run_command("setblock 7 101 5 sand"));
|
||||
let own = queued(&game, BlockPos::new(7, 101, 5));
|
||||
assert_eq!(own.len(), 1);
|
||||
assert!(game.run_command("setblock 5 101 5 sand"));
|
||||
assert!(game.run_command("clone 5 101 5 5 101 5 7 101 5 strict replace"));
|
||||
assert_eq!(
|
||||
queued(&game, BlockPos::new(7, 101, 5)),
|
||||
own,
|
||||
"kept, not doubled"
|
||||
);
|
||||
// Priority kept; ordered after what was queued before.
|
||||
assert!(game.run_command("setblock 1 101 9 sand"));
|
||||
let source = queued(&game, BlockPos::new(1, 101, 9))[0];
|
||||
assert!(game.run_command("clone 1 101 9 1 101 9 3 101 9 strict replace"));
|
||||
let copied = queued(&game, BlockPos::new(3, 101, 9));
|
||||
assert_eq!(copied.len(), 1);
|
||||
assert_eq!(
|
||||
(copied[0].kind, copied[0].trigger_tick, copied[0].priority),
|
||||
(source.kind, source.trigger_tick, source.priority)
|
||||
);
|
||||
assert!(copied[0].sub_tick_order > source.sub_tick_order);
|
||||
// A clone that places nothing (a barrier) fails, its tick copied first.
|
||||
assert!(game.run_command("setblock 9 101 9 sand"));
|
||||
let pending = queued(&game, BlockPos::new(9, 101, 9));
|
||||
assert!(game.run_command("setblock 9 101 9 barrier strict"));
|
||||
assert_eq!(
|
||||
queued(&game, BlockPos::new(9, 101, 9)),
|
||||
pending,
|
||||
"the tick stays"
|
||||
);
|
||||
assert!(!game.run_command("clone 9 101 9 9 101 9 11 101 9 strict replace"));
|
||||
let moved: Vec<_> = queued(&game, BlockPos::new(11, 101, 9))
|
||||
.iter()
|
||||
.map(|t| (t.kind, t.trigger_tick))
|
||||
.collect();
|
||||
assert_eq!(moved, vec![(pending[0].kind, pending[0].trigger_tick)]);
|
||||
}
|
||||
@@ -680,6 +680,49 @@ impl Game<'_> {
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"mob_interact" => {
|
||||
// The owner's real Interact packet on a live mob, by UUID.
|
||||
let uuid = u128::from_str_radix(
|
||||
&op["uuid"]
|
||||
.as_str()
|
||||
.ok_or("mob click: missing UUID")?
|
||||
.replace('-', ""),
|
||||
16,
|
||||
)?;
|
||||
self.in_owner_level(|g| -> Result<()> {
|
||||
let id = g
|
||||
.mob_by_uuid(horizon_client::lan::playerdata::uuid_ints(uuid))
|
||||
.filter(|id| g.mobs.iter().any(|m| m.id == *id && !m.removed))
|
||||
.ok_or("mob click: missing live mob")?;
|
||||
let owner = g.serving.as_ref().ok_or("mob click: no server")?.owner;
|
||||
let conn = g
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| {
|
||||
h.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|p| p.uuid == owner && p.placed)
|
||||
.map(|p| p.conn)
|
||||
})
|
||||
.ok_or("mob click: owner not connected")?;
|
||||
let mut host = g.lan.take().ok_or("mob click: no hub")?;
|
||||
g.lan_guest_packet(
|
||||
&mut host,
|
||||
conn,
|
||||
horizon_protocol::packets::play::Serverbound::Interact(
|
||||
horizon_protocol::packets::play::Interact {
|
||||
id: id as i32,
|
||||
hand: 0,
|
||||
location: [0.0; 3],
|
||||
secondary: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
g.lan = Some(host);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
"use_item" => {
|
||||
let owner = self
|
||||
.serving
|
||||
|
||||
@@ -2638,8 +2638,23 @@ impl Game<'_> {
|
||||
.resolve(&state)
|
||||
.map_err(|_| err("argument.block.id.invalid", &[&b.id]))
|
||||
}
|
||||
/// BlockPredicateArgument test.
|
||||
fn block_matches(&self, b: &BlockInput, state: BlockStateId) -> bool {
|
||||
/// BlockPredicateArgument test of the block at `p`: its state, then any
|
||||
/// `{...}` against the block entity's `saveWithFullMetadata`
|
||||
/// (`NbtUtils.compareNbt(…, true)`); no block entity fails.
|
||||
fn block_matches(&self, b: &BlockInput, p: [i32; 3]) -> bool {
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
if !self.block_state_matches(b, state) {
|
||||
return false;
|
||||
}
|
||||
let Some(nbt) = &b.nbt else { return true };
|
||||
// (the argument's own grammar, `TagParser`, as the predicate was read)
|
||||
let Ok(pattern) = horizon_client::command::snbt::parse_fully(nbt) else {
|
||||
return false;
|
||||
};
|
||||
horizon_client::nbt_path::compare_nbt(&pattern, self.block_entity_nbt(p).as_ref())
|
||||
}
|
||||
/// The state part of `block_matches`: name or tag, then properties.
|
||||
fn block_state_matches(&self, b: &BlockInput, state: BlockStateId) -> bool {
|
||||
block_input_matches(
|
||||
self.pack.assets.registry,
|
||||
[&self.res.items().block_tags, &self.items().block_tags],
|
||||
@@ -3027,12 +3042,17 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter {
|
||||
let ok = match matched.get(¤t) {
|
||||
Some(&ok) => ok,
|
||||
None => {
|
||||
let ok = self.block_matches(f, current);
|
||||
matched.insert(current, ok);
|
||||
ok
|
||||
// (a filter with NBT reads each block's own block entity)
|
||||
let ok = if f.nbt.is_some() {
|
||||
self.block_matches(f, [x, y, z])
|
||||
} else {
|
||||
match matched.get(¤t) {
|
||||
Some(&ok) => ok,
|
||||
None => {
|
||||
let ok = self.block_state_matches(f, current);
|
||||
matched.insert(current, ok);
|
||||
ok
|
||||
}
|
||||
}
|
||||
};
|
||||
if !ok {
|
||||
@@ -3116,7 +3136,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter
|
||||
&& !self.block_matches(f, s)
|
||||
&& !self.block_matches(f, [x, y, z])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -3159,6 +3179,19 @@ impl Game<'_> {
|
||||
};
|
||||
}
|
||||
}
|
||||
// LevelTicks.copyAreaFrom: the block ticks pending anywhere in the
|
||||
// source box go to the same offset in the destination, before the
|
||||
// command can fail on an empty count.
|
||||
{
|
||||
use horizon_core::position::BlockPos;
|
||||
self.block_ticks.blocks.copy_area(
|
||||
horizon_core::ticks::TickArea::new(
|
||||
BlockPos::new(min[0], min[1], min[2]),
|
||||
BlockPos::new(max[0], max[1], max[2]),
|
||||
),
|
||||
BlockPos::new(dest[0] - min[0], dest[1] - min[1], dest[2] - min[2]),
|
||||
);
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(err("commands.clone.failed", &[]));
|
||||
}
|
||||
|
||||
@@ -144,15 +144,7 @@ impl Game<'_> {
|
||||
let uuid = self.uuid_for(id);
|
||||
let i = self.boats.iter().position(|b| b.id == id)?;
|
||||
let b = &self.boats[i];
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
[b.yaw, b.pitch],
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
let mut tag = b.to_nbt(uuid, self.persist.raw.get(&id));
|
||||
// AbstractBoat.addAdditionalSaveData: the live or pending leash.
|
||||
self.write_boat_leash(&mut tag, i);
|
||||
tag
|
||||
@@ -376,6 +368,11 @@ impl Game<'_> {
|
||||
if keep_liked_player {
|
||||
slot.extra.tame = old.extra.tame;
|
||||
slot.extra.owner = old.extra.owner;
|
||||
if let (Some(st), Some(was)) =
|
||||
(slot.extra.allay.as_mut(), old.extra.allay.as_ref())
|
||||
{
|
||||
st.liked_player_ttl = was.liked_player_ttl;
|
||||
}
|
||||
}
|
||||
// Entity.load mutates the existing living entity; its damage
|
||||
// cooldown and previous-hit amount are not read from saved NBT.
|
||||
@@ -453,7 +450,7 @@ impl Game<'_> {
|
||||
}
|
||||
Target::Boat(id) => {
|
||||
// AbstractBoat.readAdditionalSaveData over Entity.load: the
|
||||
// position, rotation, motion, NoGravity and a chest boat's items.
|
||||
// position, rotation, motion, NoGravity, Fire and a chest boat's items.
|
||||
let (position, velocity, yaw) =
|
||||
entity_nbt::boat_from_nbt(&tag).ok_or_else(stale)?;
|
||||
let uuid = self.uuid_for(id);
|
||||
@@ -469,6 +466,7 @@ impl Game<'_> {
|
||||
b.previous_yaw = yaw;
|
||||
b.pitch = entity_nbt::rotation(&tag)[1];
|
||||
b.no_gravity = entity_nbt::flag(&tag, "NoGravity");
|
||||
b.fire_ticks = crate::game::boats::saved_fire(&tag);
|
||||
if b.chest {
|
||||
b.items = save::decode_items(tag.get("Items"), crate::game::boats::CHEST_SLOTS);
|
||||
}
|
||||
|
||||
@@ -1039,8 +1039,7 @@ impl Game<'_> {
|
||||
let Some(Arg::Block(b)) = c.get("block").cloned() else {
|
||||
return Ok((false, 0));
|
||||
};
|
||||
let state = self.world.block(p[0], p[1], p[2]);
|
||||
return Ok(flag(self.block_matches(&b, state)));
|
||||
return Ok(flag(self.block_matches(&b, p)));
|
||||
}
|
||||
if c.has("biome") {
|
||||
let p = self.block_pos_arg(c, "pos")?;
|
||||
|
||||
@@ -1127,6 +1127,10 @@ impl Game<'_> {
|
||||
}
|
||||
"bench" => {
|
||||
let seconds: f64 = arg(1)?.parse()?;
|
||||
// `Duration::from_secs_f64` panics on a negative, NaN or huge number.
|
||||
if !(seconds.is_finite() && (0.0..=86_400.0).contains(&seconds)) {
|
||||
return Err("bench takes 0 to 86400 seconds".into());
|
||||
}
|
||||
if self.pending_bench.is_some() {
|
||||
return Err("a benchmark is already running".into());
|
||||
}
|
||||
|
||||
@@ -1691,16 +1691,24 @@ impl Game<'_> {
|
||||
self.villager_murdered(m, slain_by_player, killer.flatten());
|
||||
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
|
||||
let killer_entity = self.killer_loot_entity(killer.flatten());
|
||||
// LAST_DAMAGE_PLAYER only while the player-hurt memory lasts
|
||||
// (LivingEntity.dropFromLootTable's playerKilled): a mob that dies to
|
||||
// the environment long after a player's hit names no player.
|
||||
// LAST_DAMAGE_PLAYER only while the player is still in the world and
|
||||
// the hurt memory lasts (LivingEntity.dropFromLootTable). Experience
|
||||
// and equipment use the timer alone (dropExperience / playerKilled):
|
||||
// a guest who has left still counts.
|
||||
let player = killer
|
||||
.filter(|_| m.ai.last_hurt_by_player > 0)
|
||||
.and_then(|conn| self.killer_uuid(conn));
|
||||
if let Some(player) = player.filter(|_| m.extra.leash.is_some()) {
|
||||
self.lead_killers.insert(m.id, (player, self.dim));
|
||||
}
|
||||
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
|
||||
self.drop_death_loot(
|
||||
&mut dying,
|
||||
player,
|
||||
m.ai.last_hurt_by_player > 0,
|
||||
killer_entity,
|
||||
consumed,
|
||||
pos,
|
||||
);
|
||||
self.create_wither_rose(m);
|
||||
if m.species() == Species::CopperGolem {
|
||||
// CopperGolem.dropEquipment: the preserved equipment too.
|
||||
@@ -1866,7 +1874,7 @@ impl Game<'_> {
|
||||
&mut self,
|
||||
m: &mut Mob,
|
||||
player_uuid: Option<u128>,
|
||||
by_player: bool,
|
||||
player_killed: bool,
|
||||
killer: horizon_world::loot::LootEntity,
|
||||
consumed: bool,
|
||||
pos: [f64; 3],
|
||||
@@ -1933,7 +1941,8 @@ impl Game<'_> {
|
||||
let death = drops::Death {
|
||||
loot: &loot,
|
||||
damage,
|
||||
player: by_player.then(player),
|
||||
player: player_uuid.is_some().then(player),
|
||||
player_killed,
|
||||
luck: 0.0,
|
||||
vehicle,
|
||||
mob_drops: self.ui.rules.bool("mob_drops"),
|
||||
|
||||
@@ -1284,8 +1284,8 @@ impl Game<'_> {
|
||||
let mut braked = Instant::now();
|
||||
let mut overload_warned: Option<Instant> = None;
|
||||
// `/stop`, or a stop signal (the shutdown hook's `halt`): either way the players and levels are
|
||||
// saved below.
|
||||
while !state.lists.lock().unwrap().stop && horizon_threads::stop_requests() == 0 {
|
||||
// saved below, after the wait `[transfer]` may make (transfer.rs).
|
||||
while !self.stop_now(&state) {
|
||||
let tick_start = Instant::now();
|
||||
if let Some(watchdog) = &watchdog {
|
||||
watchdog.tick_due(next, self.clock.tick_duration());
|
||||
|
||||
@@ -30,7 +30,7 @@ impl ServerState {
|
||||
}
|
||||
}
|
||||
/// Ticks per second over the last few seconds.
|
||||
fn tps(&self) -> f64 {
|
||||
pub(super) fn tps(&self) -> f64 {
|
||||
match (self.ticks.front(), self.ticks.back()) {
|
||||
(Some(first), Some(last)) if self.ticks.len() > 1 => {
|
||||
(self.ticks.len() - 1) as f64 / last.duration_since(*first).as_secs_f64().max(1e-9)
|
||||
|
||||
@@ -50,6 +50,66 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the tick loop ends now. A stop (`/stop`, a stop signal) ends it at once, unless
|
||||
/// `[transfer]` is on and players are here: the stop then waits for a proxy to move them out
|
||||
/// (`arrivals::StopWait`), every login refused, the server ticking on. A second `stop` ends the
|
||||
/// wait; a second signal ends the process (`horizon_threads::catch_stop_signals`).
|
||||
pub(super) fn stop_now(&mut self, state: &super::ServerState) -> bool {
|
||||
let asked = std::mem::take(&mut state.lists.lock().unwrap().stop);
|
||||
let stop = asked || horizon_threads::stop_requests() > 0;
|
||||
let Some(host) = self.lan.as_mut() else {
|
||||
return stop;
|
||||
};
|
||||
let waiting = host
|
||||
.hub
|
||||
.transfers
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.stop_wait.is_some());
|
||||
if !stop && !waiting {
|
||||
return false;
|
||||
}
|
||||
let (placed, frozen) = arrivals::placed_and_frozen(&host.hub);
|
||||
let Some(transfers) = host.hub.transfers.as_mut() else {
|
||||
return stop;
|
||||
};
|
||||
let now = Instant::now();
|
||||
// A freeze lasts ticks: how long one takes now, for `check_freeze`.
|
||||
let period = arrivals::tick_period(self.clock.tick_duration(), state.tps());
|
||||
match &mut transfers.stop_wait {
|
||||
None => {
|
||||
transfers.stopping = true;
|
||||
let wait = arrivals::StopWait::start(&transfers.settings, now, period);
|
||||
let Some(wait) = wait.filter(|_| placed > 0) else {
|
||||
return true;
|
||||
};
|
||||
transfers.stop_wait = Some(wait);
|
||||
host.hub.lan.shared.refuse_logins();
|
||||
super::log(
|
||||
"INFO",
|
||||
&arrivals::stop_wait_line(transfers.settings.stop_wait),
|
||||
);
|
||||
false
|
||||
}
|
||||
Some(_) if asked => {
|
||||
super::log("INFO", "A second stop: stopping now");
|
||||
true
|
||||
}
|
||||
Some(wait) => {
|
||||
wait.period = period;
|
||||
let over = wait.over(now, placed, frozen);
|
||||
if over && frozen > 0 {
|
||||
super::log(
|
||||
"WARN",
|
||||
&format!(
|
||||
"Stopping with {frozen} hand-over(s) still frozen: their players are saved here"
|
||||
),
|
||||
);
|
||||
}
|
||||
over
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `transfer-status`: the counts a proxy reads before it sends players here
|
||||
/// (`arrivals::status_request`). A proxy asks it every few seconds, so it writes nothing to
|
||||
/// the log.
|
||||
@@ -279,6 +339,9 @@ impl Game<'_> {
|
||||
Ok(i) => i,
|
||||
Err(why) => return json!({ "error": why }),
|
||||
};
|
||||
if freeze && let Err(why) = arrivals::check_freeze(&host.hub, i, Instant::now()) {
|
||||
return json!({ "error": why });
|
||||
}
|
||||
// A repeat while the hand-over lasts: the same record, and nothing touched.
|
||||
if let Some(h) = &host.hub.guests[i].handover {
|
||||
return arrivals::export_reply(&host.hub, i, h.record.clone());
|
||||
|
||||
@@ -21,15 +21,46 @@ use serde_json::Value;
|
||||
/// `EquipmentSlot.getName` of each `mobs::finalize` slot (hands, feet to head).
|
||||
const MOB_SLOTS: [&str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
|
||||
|
||||
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
|
||||
/// One of the player's equipment slots (`EnchantedItemInUse.inSlot`).
|
||||
#[derive(Clone, Copy)]
|
||||
enum MendSlot {
|
||||
enum PlayerSlot {
|
||||
Main,
|
||||
Off,
|
||||
/// An `armor` index (head first).
|
||||
Armor(usize),
|
||||
}
|
||||
|
||||
/// `EquipmentSlot.VALUES` over what the player holds and wears: the main
|
||||
/// hand, the off hand, then feet, legs, chest and head (`armor` holds head
|
||||
/// first).
|
||||
const PLAYER_SLOTS: [PlayerSlot; 6] = [
|
||||
PlayerSlot::Main,
|
||||
PlayerSlot::Off,
|
||||
PlayerSlot::Armor(3),
|
||||
PlayerSlot::Armor(2),
|
||||
PlayerSlot::Armor(1),
|
||||
PlayerSlot::Armor(0),
|
||||
];
|
||||
|
||||
impl PlayerSlot {
|
||||
/// `EquipmentSlot.getName`.
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Main => "mainhand",
|
||||
Self::Off => "offhand",
|
||||
Self::Armor(i) => ["head", "chest", "legs", "feet"][i],
|
||||
}
|
||||
}
|
||||
fn equipment_slot(self) -> horizon_world::player_inventory::EquipmentSlot {
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
match self {
|
||||
Self::Main => EquipmentSlot::MainHand,
|
||||
Self::Off => EquipmentSlot::OffHand,
|
||||
Self::Armor(i) => super::armor_slot(i),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Player.getDestroySpeed` multiplies by `BLOCK_BREAK_SPEED`; a player
|
||||
/// without the attribute keeps the vanilla default of 1.0.
|
||||
fn block_break_speed_factor(value: Option<f64>) -> f32 {
|
||||
@@ -461,71 +492,158 @@ impl Game<'_> {
|
||||
let _ = self.wind_burst(feet, radius, knockback, false, false);
|
||||
}
|
||||
}
|
||||
/// Thorns on worn armor when the mob `attacker` hits the player: the
|
||||
/// `affected: attacker` effect of `Enchantment.doPostAttack` hurts the
|
||||
/// entity that caused the hit.
|
||||
/// `EnchantmentHelper.doPostAttackEffects`' `VICTIM` loop after the mob
|
||||
/// `attacker` hurt the player: Thorns on what the player holds or wears
|
||||
/// hurts the mob by `thorns` damage the player caused, so it is knocked
|
||||
/// back from the player, turns on them and is theirs to kill.
|
||||
pub(super) fn thorns(&mut self, attacker: u32) {
|
||||
let Some(index) = self.mobs.iter().position(|m| m.id == attacker && m.alive()) else {
|
||||
return;
|
||||
};
|
||||
for damage in self.thorns_damage() {
|
||||
let m = &mut self.mobs[index];
|
||||
m.hurt(damage, None, &mut self.world.random);
|
||||
let center = [m.position.x, m.position.y, m.position.z];
|
||||
self.audio
|
||||
.play("minecraft:enchant.thorns.hit", Some(center), 1.0, 1.0);
|
||||
}
|
||||
self.player_thorns(|g, min, max| {
|
||||
// DamageEntity: Mth.randomBetween from the hurt entity's own random.
|
||||
let Some(index) = g.mobs.iter().position(|m| m.id == attacker) else {
|
||||
return;
|
||||
};
|
||||
let damage = g.mobs[index].random.next_float() * (max - min) + min;
|
||||
let Some(p) = g.player.as_ref() else { return };
|
||||
let source = horizon_client::mobs::Damage {
|
||||
kind: horizon_client::mobs::DamageKind::Thorns,
|
||||
attacker: Some(Who::Player),
|
||||
from: Some([p.position.x, p.position.y, p.position.z]),
|
||||
attacker_species: None,
|
||||
};
|
||||
let alive = g.mobs[index].alive();
|
||||
if g.hurt_by_player(index, damage, source) {
|
||||
if alive && !g.mobs[index].alive() {
|
||||
g.native_kill_event(index);
|
||||
}
|
||||
g.last_hurt_by_player.insert(attacker, g.lan_acting);
|
||||
}
|
||||
});
|
||||
}
|
||||
/// The victim's side of thorns: for each worn piece whose chance comes up,
|
||||
/// the damage to deal the attacker (`DamageEntity` min 1, max 5) and the
|
||||
/// piece's wear (`ChangeItemDamage` 2).
|
||||
/// The victim's side of thorns when another player hit this one: the
|
||||
/// damage each piece deals that player, rolled from the attacker's own
|
||||
/// random (a player's, which no level draw shares).
|
||||
pub(super) fn thorns_damage(&mut self) -> Vec<f32> {
|
||||
let mut hits = Vec::new();
|
||||
for slot in 0..4 {
|
||||
let Some(stack) = self.armor[slot].clone() else {
|
||||
self.player_thorns(|_, min, max| {
|
||||
hits.push(random_source::entity().next_float() * (max - min) + min);
|
||||
});
|
||||
hits
|
||||
}
|
||||
/// `EnchantmentHelper.runIterationOnEquipment` over the player's hands and
|
||||
/// armor, in `EquipmentSlot` order, running `Enchantment.doPostAttack` for
|
||||
/// the `minecraft:post_attack` effects with `enchanted: victim` and
|
||||
/// `affected: attacker` (Thorns): the chance from the level's random
|
||||
/// (`Enchantment.damageContext`), then each `damage_entity` handed to `hit` (min,
|
||||
/// max) and each `change_item_damage` worn off the piece, in the effect's
|
||||
/// order. The attacker being dead stops neither the rolls nor the wear.
|
||||
fn player_thorns(&mut self, mut hit: impl FnMut(&mut Self, f32, f32)) {
|
||||
for slot in PLAYER_SLOTS {
|
||||
let Some(stack) = self.player_slot(slot).clone() else {
|
||||
continue;
|
||||
};
|
||||
let level = stack.enchantment_level("minecraft:thorns");
|
||||
if level == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(def) = self.stations().enchantments.get("minecraft:thorns") else {
|
||||
continue;
|
||||
};
|
||||
let effect = def.effects["minecraft:post_attack"][0].clone();
|
||||
let ctx = EffectContext {
|
||||
items: Some(world_items!(self)),
|
||||
..Default::default()
|
||||
};
|
||||
let mut random = self.world.random.clone();
|
||||
let ok = self.stations().enchantments.condition(
|
||||
&effect["requirements"],
|
||||
level,
|
||||
&ctx,
|
||||
&mut random,
|
||||
);
|
||||
self.world.random = random;
|
||||
if !ok {
|
||||
continue;
|
||||
}
|
||||
hits.push(1.0 + self.world.random.next_float() * 4.0);
|
||||
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
|
||||
let loss = self.wear(&stack, 2);
|
||||
if let Some(s) = self.armor[slot].as_mut() {
|
||||
s.damage += loss;
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.get(&s.item)
|
||||
.is_some_and(|i| s.damage >= i.max_damage)
|
||||
let mut effects = Vec::new();
|
||||
for (enchantment, level) in stack.enchantments() {
|
||||
let Some(def) = world_stations!(self).enchantments.get(enchantment) else {
|
||||
continue;
|
||||
};
|
||||
// Enchantment.matchingSlot
|
||||
let (name, hand) = (
|
||||
slot.name(),
|
||||
matches!(slot, PlayerSlot::Main | PlayerSlot::Off),
|
||||
);
|
||||
if !def.slots.iter().any(|s| {
|
||||
s == name || s == "any" || (s == "armor" && !hand) || (s == "hand" && hand)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
for effect in def.effects["minecraft:post_attack"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
self.armor[slot] = None;
|
||||
self.player_equipment_broke(super::armor_slot(slot), &stack);
|
||||
self.apply_equipment();
|
||||
if effect["enchanted"] == "victim" && effect["affected"] == "attacker" {
|
||||
effects.push((effect.clone(), *level));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (effect, level) in effects {
|
||||
let ctx = EffectContext {
|
||||
this: Some("minecraft:player"),
|
||||
items: Some(world_items!(self)),
|
||||
..Default::default()
|
||||
};
|
||||
let ok = effect.get("requirements").is_none_or(|r| {
|
||||
world_stations!(self).enchantments.condition(
|
||||
r,
|
||||
level,
|
||||
&ctx,
|
||||
&mut self.world.random,
|
||||
)
|
||||
});
|
||||
if ok {
|
||||
self.thorns_effect(&effect["effect"], level, slot, &mut hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
/// `AllOf.apply`, `DamageEntity.apply` and `ChangeItemDamage.apply` of one
|
||||
/// Thorns-like effect of the piece in `slot`.
|
||||
fn thorns_effect(
|
||||
&mut self,
|
||||
effect: &Value,
|
||||
level: u32,
|
||||
slot: PlayerSlot,
|
||||
hit: &mut impl FnMut(&mut Self, f32, f32),
|
||||
) {
|
||||
match effect["type"]
|
||||
.as_str()
|
||||
.map(|t| t.trim_start_matches("minecraft:"))
|
||||
{
|
||||
Some("all_of") => {
|
||||
for e in effect["effects"].as_array().into_iter().flatten() {
|
||||
self.thorns_effect(e, level, slot, hit);
|
||||
}
|
||||
}
|
||||
Some("damage_entity") => {
|
||||
let min = level_value(&effect["min_damage"], level);
|
||||
let max = level_value(&effect["max_damage"], level);
|
||||
hit(self, min, max);
|
||||
}
|
||||
Some("change_item_damage") => {
|
||||
let amount = level_value(&effect["amount"], level) as u32;
|
||||
self.wear_player_piece(slot, amount);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// `ItemStack.hurtAndBreak` for what the player has in `slot`: nothing for
|
||||
/// an item that takes no wear or a creative player, Unbreaking's share
|
||||
/// from the level's random, and a piece worn out leaves the slot.
|
||||
fn wear_player_piece(&mut self, slot: PlayerSlot, amount: u32) {
|
||||
let Some(stack) = self.player_slot(slot).clone() else {
|
||||
return;
|
||||
};
|
||||
if amount == 0
|
||||
|| self.creative
|
||||
|| !horizon_client::stations::is_damageable(world_items!(self), &stack)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let loss = self.wear(&stack, amount);
|
||||
let max = world_items!(self)
|
||||
.get(&stack.item)
|
||||
.map_or(0, |i| i.max_damage);
|
||||
let Some(s) = self.player_slot_mut(slot).as_mut() else {
|
||||
return;
|
||||
};
|
||||
s.damage += loss;
|
||||
if s.damage >= max {
|
||||
*self.player_slot_mut(slot) = None;
|
||||
self.player_equipment_broke(slot.equipment_slot(), &stack);
|
||||
if let PlayerSlot::Armor(_) = slot {
|
||||
self.apply_equipment();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource`'s `VICTIM` loop
|
||||
/// after the player's hit hurt the mob at `victim` (`direct` is the hit's
|
||||
@@ -678,16 +796,14 @@ impl Game<'_> {
|
||||
let mut amount = amount;
|
||||
loop {
|
||||
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged), in
|
||||
// EquipmentSlot order: the main hand, the off hand, then feet, legs,
|
||||
// chest and head (`armor` holds head first).
|
||||
// EquipmentSlot order.
|
||||
let mending = |s: &Option<ItemStack>| {
|
||||
s.as_ref()
|
||||
.is_some_and(|s| s.damage > 0 && s.enchantment_level("minecraft:mending") > 0)
|
||||
};
|
||||
let candidates: Vec<MendSlot> = [MendSlot::Main, MendSlot::Off]
|
||||
let candidates: Vec<PlayerSlot> = PLAYER_SLOTS
|
||||
.into_iter()
|
||||
.chain((0..self.armor.len()).rev().map(MendSlot::Armor))
|
||||
.filter(|&slot| mending(self.mend_slot(slot)))
|
||||
.filter(|&slot| mending(self.player_slot(slot)))
|
||||
.collect();
|
||||
if candidates.is_empty() {
|
||||
return amount;
|
||||
@@ -697,7 +813,7 @@ impl Game<'_> {
|
||||
.random
|
||||
.next_int_bound(candidates.len() as i32)
|
||||
.unwrap_or(0) as usize];
|
||||
let Some(stack) = self.mend_slot(slot).clone() else {
|
||||
let Some(stack) = self.player_slot(slot).clone() else {
|
||||
return amount;
|
||||
};
|
||||
let ctx = EffectContext {
|
||||
@@ -718,7 +834,7 @@ impl Game<'_> {
|
||||
}
|
||||
// A hook can re-enter experience granting or replace the slot. Only
|
||||
// repair the same eligible stack, using its current damage.
|
||||
let Some(current) = self.mend_slot_mut(slot).as_mut() else {
|
||||
let Some(current) = self.player_slot_mut(slot).as_mut() else {
|
||||
return amount;
|
||||
};
|
||||
let mut original = stack.clone();
|
||||
@@ -759,18 +875,19 @@ impl Game<'_> {
|
||||
amount = remaining;
|
||||
}
|
||||
}
|
||||
fn mend_slot(&self, slot: MendSlot) -> &Option<ItemStack> {
|
||||
/// `LivingEntity.getItemBySlot` for the player.
|
||||
fn player_slot(&self, slot: PlayerSlot) -> &Option<ItemStack> {
|
||||
match slot {
|
||||
MendSlot::Main => &self.inventory[self.selected],
|
||||
MendSlot::Off => &self.offhand,
|
||||
MendSlot::Armor(i) => &self.armor[i],
|
||||
PlayerSlot::Main => &self.inventory[self.selected],
|
||||
PlayerSlot::Off => &self.offhand,
|
||||
PlayerSlot::Armor(i) => &self.armor[i],
|
||||
}
|
||||
}
|
||||
fn mend_slot_mut(&mut self, slot: MendSlot) -> &mut Option<ItemStack> {
|
||||
fn player_slot_mut(&mut self, slot: PlayerSlot) -> &mut Option<ItemStack> {
|
||||
match slot {
|
||||
MendSlot::Main => &mut self.inventory[self.selected],
|
||||
MendSlot::Off => &mut self.offhand,
|
||||
MendSlot::Armor(i) => &mut self.armor[i],
|
||||
PlayerSlot::Main => &mut self.inventory[self.selected],
|
||||
PlayerSlot::Off => &mut self.offhand,
|
||||
PlayerSlot::Armor(i) => &mut self.armor[i],
|
||||
}
|
||||
}
|
||||
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
|
||||
|
||||
@@ -271,6 +271,10 @@ pub(super) trait Entity: EntityRules {
|
||||
let position = self.position_mut();
|
||||
*position = position.add_vector(movement);
|
||||
}
|
||||
/// `setOnGroundWithMovement` at the end of a piston's or shulker lid's
|
||||
/// `Entity.move`, for the kinds that keep the flag: on a server it runs
|
||||
/// for every push, sideways ones and those the collision turned away included.
|
||||
fn set_piston_on_ground(&mut self, _on_ground: bool) {}
|
||||
/// `PistonMovingBlockEntity.causeBounce`: a slime block sets the velocity
|
||||
/// along the movement axis.
|
||||
fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
@@ -1185,12 +1189,8 @@ impl Entity for ItemEntity {
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
fn move_by_piston(&mut self, movement: Vec3) {
|
||||
self.position = self.position.add_vector(movement);
|
||||
// Entity.move(MoverType.PISTON): onGround only if the push was down.
|
||||
if movement.y != 0.0 {
|
||||
self.on_ground = movement.y < 0.0;
|
||||
}
|
||||
fn set_piston_on_ground(&mut self, on_ground: bool) {
|
||||
self.on_ground = on_ground;
|
||||
}
|
||||
fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
ItemEntity::piston_slime_bounce(self, movement);
|
||||
@@ -1490,6 +1490,11 @@ impl Entity for ArmorStand {
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
/// A living entity's own flag too: keeping the old support flag would run
|
||||
/// Frost Walker while the stand is moving over open water.
|
||||
fn set_piston_on_ground(&mut self, on_ground: bool) {
|
||||
self.on_ground = on_ground;
|
||||
}
|
||||
fn is_living(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -1577,6 +1582,9 @@ impl Entity for PrimedTnt {
|
||||
fn on_ground(&self) -> bool {
|
||||
self.on_ground
|
||||
}
|
||||
fn set_piston_on_ground(&mut self, on_ground: bool) {
|
||||
self.on_ground = on_ground;
|
||||
}
|
||||
fn piston_slime_bounce(&mut self, movement: Direction) {
|
||||
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
|
||||
// Leave the ground so the next tick does not apply the on-ground
|
||||
|
||||
@@ -3325,15 +3325,21 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let at = g.position;
|
||||
let players = self.stab_players_for(host, conn);
|
||||
let sound = self
|
||||
.guest_hit_by(host, conn, |g| {
|
||||
if g.cannot_attack_with_item(g.attack_ticks as i32, 5) {
|
||||
None
|
||||
} else {
|
||||
g.jab()
|
||||
let outer = std::mem::replace(&mut g.stab_players, players);
|
||||
let sound = g.jab();
|
||||
g.stab_players = outer;
|
||||
sound
|
||||
}
|
||||
})
|
||||
.flatten();
|
||||
// The players the jab stabbed, now that the hub is back.
|
||||
self.land_player_stabs(host);
|
||||
// PiercingWeapon.makeSound: `playSound(causer, ...)`, so the guest's
|
||||
// own client plays it.
|
||||
if let Some(name) = sound {
|
||||
@@ -4180,7 +4186,14 @@ impl Game<'_> {
|
||||
let using = play.using.is_some();
|
||||
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
|
||||
if digging.is_some() || using || delayed {
|
||||
// A spear in use reaches the other players too.
|
||||
let players = if using {
|
||||
self.stab_players_for(host, conn)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.as_guest(host, conn, |g| {
|
||||
let outer = std::mem::replace(&mut g.stab_players, players);
|
||||
g.attack_ticks = g.attack_ticks.saturating_add(1);
|
||||
if let Some((pos, face)) = digging {
|
||||
g.forced_hit = Some(Hit {
|
||||
@@ -4197,10 +4210,12 @@ impl Game<'_> {
|
||||
g.forced_hit = None;
|
||||
g.right = g.using.is_some();
|
||||
g.tick_using();
|
||||
g.stab_players = outer;
|
||||
if g.use_delay > 0 {
|
||||
g.use_delay -= 1;
|
||||
}
|
||||
});
|
||||
self.land_player_stabs(host);
|
||||
// ServerPlayerGameMode.handleBlockBreakAction: creative and
|
||||
// instant survival STARTs finish without a STOP from the client.
|
||||
// tick_mining clears mining on completion; forget the START too,
|
||||
|
||||
@@ -446,7 +446,7 @@ impl Game<'_> {
|
||||
}
|
||||
|
||||
/// ServerGamePacketListenerImpl.handlePlaceRecipe: the guest's recipe
|
||||
/// book places a known recipe in their inventory grid or crafting table
|
||||
/// book places a known recipe in their inventory grid, crafting table or furnace
|
||||
/// (`ServerPlaceRecipe`), and answers with a ghost recipe when the items
|
||||
/// are missing.
|
||||
pub(super) fn lan_place_recipe(
|
||||
@@ -483,9 +483,62 @@ impl Game<'_> {
|
||||
let (player, name) = (g.uuid, g.name.clone());
|
||||
self.native_player_recipe_book_click(player, &name, &id, p.use_max);
|
||||
let creative = g.creative();
|
||||
let placed = self.lan_apply_recipe(host, gi, p.container, |menu, env| {
|
||||
menu.place_recipe(env, &id, p.use_max, creative, None)
|
||||
});
|
||||
// AbstractFurnaceMenu.handlePlacement: the current furnace's slots,
|
||||
// rather than the separate crafting-table menu, own cooking inputs.
|
||||
let furnace = host.hub.guests[gi]
|
||||
.menu
|
||||
.as_ref()
|
||||
.filter(|m| p.container != 0 && m.window == p.container)
|
||||
.and_then(|m| match m.slots {
|
||||
Slots::Block(pos) => Some(pos),
|
||||
_ => None,
|
||||
})
|
||||
.and_then(|pos| match self.world.block_entities.get(&pos) {
|
||||
Some(BlockEntity::Furnace(f)) => {
|
||||
let recipe = match f.kind.recipe_type() {
|
||||
"minecraft:blasting" => "blasting",
|
||||
"minecraft:smoking" => "smoking",
|
||||
_ => "smelting",
|
||||
};
|
||||
Some((pos, Kind::Furnace(recipe), f.items.clone()))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let placed = if let Some((pos, kind, items)) = furnace {
|
||||
// Opening need not have created the click/drag cache yet.
|
||||
let mut menu = host
|
||||
.play
|
||||
.entry(conn)
|
||||
.or_default()
|
||||
.crafting
|
||||
.machine
|
||||
.take()
|
||||
.filter(|(window, m)| *window == p.container && m.kind() == kind)
|
||||
.map_or_else(|| Box::new(Menu::new(kind)), |(_, m)| m);
|
||||
let placed = self.guest_menu_op(host, gi, &mut menu, |menu, env| {
|
||||
for (k, item) in items.iter().enumerate() {
|
||||
menu.set_slot(env, k, item.clone());
|
||||
}
|
||||
menu.place_recipe(env, &id, p.use_max, creative, None)
|
||||
});
|
||||
let changed = (0..3).any(|k| menu.slot(k) != items[k].as_ref());
|
||||
if let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) {
|
||||
for k in 0..3 {
|
||||
f.items[k] = menu.slot(k).cloned();
|
||||
}
|
||||
f.input_clicked(world_recipes!(self), items[0].clone());
|
||||
}
|
||||
if changed {
|
||||
self.container_changed(pos);
|
||||
}
|
||||
host.play.entry(conn).or_default().crafting.machine = Some((p.container, menu));
|
||||
// Hub::sync_containers broadcasts the actual changed slots.
|
||||
Some(placed)
|
||||
} else {
|
||||
self.lan_apply_recipe(host, gi, p.container, |menu, env| {
|
||||
menu.place_recipe(env, &id, p.use_max, creative, None)
|
||||
})
|
||||
};
|
||||
if placed == Some(PostPlace::Ghost) {
|
||||
host.hub.send_ghost_recipe(conn, p.container, &id);
|
||||
}
|
||||
@@ -824,3 +877,246 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod machine_recipe_placement_tests {
|
||||
use super::*;
|
||||
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_core::position::ChunkPos;
|
||||
use horizon_protocol::packets::{
|
||||
configuration::ClientInformation,
|
||||
play::{Serverbound, UseItemOn},
|
||||
recipes::PlaceRecipe,
|
||||
};
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h234_registered_place_recipe_reaches_actual_furnace_and_preserves_controls() {
|
||||
let mut game = crate::game::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(game.world.loaded(8, 8));
|
||||
game.lan_host(
|
||||
crate::game::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "machine-place-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 234,
|
||||
name: "RecipeGuest".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[8.5, 200.0, 8.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
guest.selected = 1;
|
||||
guest.permission = 2;
|
||||
let mut host = game.lan.take().unwrap();
|
||||
host.hub.guests.push(guest);
|
||||
let clock = game.world.game_time;
|
||||
let planks = game
|
||||
.stations()
|
||||
.recipe_index
|
||||
.first_display("minecraft:oak_planks")
|
||||
.unwrap();
|
||||
let charcoal = game
|
||||
.stations()
|
||||
.recipe_index
|
||||
.first_display("minecraft:charcoal")
|
||||
.unwrap();
|
||||
let command = |game: &mut Game<'_>, host: &mut LanHost, text: &str| {
|
||||
assert!(
|
||||
game.as_guest(host, 2, |g| g.run_command(text)).unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
};
|
||||
let place = |game: &mut Game<'_>, host: &mut LanHost, container, recipe| {
|
||||
game.lan_guest_packet(
|
||||
host,
|
||||
2,
|
||||
Serverbound::PlaceRecipe(PlaceRecipe {
|
||||
container,
|
||||
recipe,
|
||||
use_max: false,
|
||||
}),
|
||||
);
|
||||
};
|
||||
let open = |game: &mut Game<'_>, host: &mut LanHost, pos| {
|
||||
game.lan_guest_packet(
|
||||
host,
|
||||
2,
|
||||
Serverbound::UseItemOn(UseItemOn {
|
||||
hand: 0,
|
||||
pos,
|
||||
face: 1,
|
||||
cursor: [0.5, 1.0, 0.5],
|
||||
inside: false,
|
||||
world_border: false,
|
||||
sequence: 234,
|
||||
}),
|
||||
);
|
||||
assert!(host.hub.guest(2).unwrap().ack_up_to.get() >= 234);
|
||||
host.play
|
||||
.get(&2)
|
||||
.and_then(|p| p.crafting.table.as_ref())
|
||||
.map(|(window, ..)| *window)
|
||||
.or_else(|| host.hub.guest(2).unwrap().menu.as_ref().map(|m| m.window))
|
||||
.expect("actual table or machine window opened")
|
||||
};
|
||||
command(&mut game, &mut host, "recipe give @s minecraft:oak_planks");
|
||||
command(&mut game, &mut host, "recipe give @s minecraft:charcoal");
|
||||
assert!(
|
||||
host.hub
|
||||
.guest(2)
|
||||
.unwrap()
|
||||
.recipe_book
|
||||
.knows("minecraft:charcoal")
|
||||
);
|
||||
command(
|
||||
&mut game,
|
||||
&mut host,
|
||||
"item replace entity @s hotbar.0 with oak_log 3",
|
||||
);
|
||||
place(&mut game, &mut host, 0, planks);
|
||||
assert_eq!(
|
||||
host.hub.guest(2).unwrap().craft_grid[0],
|
||||
Some(ItemStack::new("minecraft:oak_log", 1))
|
||||
);
|
||||
assert_eq!(
|
||||
host.hub.guest(2).unwrap().craft_result,
|
||||
Some(ItemStack::new("minecraft:oak_planks", 4))
|
||||
);
|
||||
assert_eq!(
|
||||
host.hub.guest(2).unwrap().inventory[0]
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.count,
|
||||
2
|
||||
);
|
||||
game.lan_crafting_closed(&mut host, 2, Some(0));
|
||||
assert!(game.run_command("setblock 10 200 8 crafting_table"));
|
||||
let window = open(&mut game, &mut host, [10, 200, 8]);
|
||||
place(&mut game, &mut host, window, planks);
|
||||
let (_, _, table, _) = host.play[&2].crafting.table.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
table.slot(0),
|
||||
Some(&ItemStack::new("minecraft:oak_planks", 4))
|
||||
);
|
||||
game.guest_close_all(&mut host, 2);
|
||||
let mut failures = Vec::new();
|
||||
for (label, count, known) in [
|
||||
("place", 3, true),
|
||||
("ghost", 0, true),
|
||||
("forgotten", 3, false),
|
||||
] {
|
||||
game.as_guest(&mut host, 2, |g| {
|
||||
g.run_command("clear @s");
|
||||
})
|
||||
.unwrap();
|
||||
if count > 0 {
|
||||
command(
|
||||
&mut game,
|
||||
&mut host,
|
||||
"item replace entity @s hotbar.0 with oak_log 3",
|
||||
);
|
||||
}
|
||||
command(
|
||||
&mut game,
|
||||
&mut host,
|
||||
"item replace entity @s hotbar.8 with stick[minecraft:custom_data={h234:1}] 7",
|
||||
);
|
||||
assert!(game.run_command("setblock 10 200 8 air"));
|
||||
assert!(game.run_command("setblock 10 200 8 furnace"));
|
||||
assert!(game.run_command("item replace block 10 200 8 container.1 with coal[minecraft:custom_data={h234:2}] 2"));
|
||||
let window = open(&mut game, &mut host, [10, 200, 8]);
|
||||
if !known {
|
||||
command(&mut game, &mut host, "recipe take @s minecraft:charcoal");
|
||||
}
|
||||
assert_eq!(
|
||||
host.hub
|
||||
.guest(2)
|
||||
.unwrap()
|
||||
.recipe_book
|
||||
.knows("minecraft:charcoal"),
|
||||
known
|
||||
);
|
||||
let before = host.hub.guest(2).unwrap().inventory.clone();
|
||||
let pos = (10, 200, 8);
|
||||
let Some(BlockEntity::Furnace(f)) = game.world.block_entities.get(&pos) else {
|
||||
panic!("actual furnace missing")
|
||||
};
|
||||
assert!(f.items[0].is_none() && f.items[2].is_none());
|
||||
let fuel = f.items[1].clone();
|
||||
assert_eq!(fuel.as_ref().unwrap().count, 2);
|
||||
place(&mut game, &mut host, window, charcoal);
|
||||
let Some(BlockEntity::Furnace(f)) = game.world.block_entities.get(&pos) else {
|
||||
panic!("furnace removed")
|
||||
};
|
||||
assert_eq!(f.items[1], fuel);
|
||||
assert!(f.items[2].is_none());
|
||||
let mut expected = before.clone();
|
||||
let input = if label == "place" {
|
||||
expected[0].as_mut().unwrap().count = 2;
|
||||
Some(ItemStack::new("minecraft:oak_log", 1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if f.items[0] != input || host.hub.guest(2).unwrap().inventory != expected {
|
||||
failures.push((
|
||||
label,
|
||||
f.items.clone(),
|
||||
host.hub.guest(2).unwrap().inventory.clone(),
|
||||
));
|
||||
}
|
||||
assert_eq!(host.hub.guest(2).unwrap().uuid, 234);
|
||||
assert_eq!(host.hub.guest(2).unwrap().health, 20.0);
|
||||
assert_eq!(host.hub.guest(2).unwrap().position, [8.5, 200.0, 8.5]);
|
||||
assert_eq!(game.world.game_time, clock);
|
||||
game.guest_close_all(&mut host, 2);
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual registered PlaceRecipe furnace failures: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,10 @@ impl Game<'_> {
|
||||
e.leash = self.boat_leash_link(b, &guests);
|
||||
e.velocity = [b.velocity.x, b.velocity.y, b.velocity.z];
|
||||
e.meta = vehicle_meta(b.hurt_time, b.hurt_dir, b.damage);
|
||||
// Entity.DATA_SHARED_FLAGS_ID: FLAG_ONFIRE.
|
||||
if b.fire_ticks > 0 {
|
||||
e.meta.push((0, serializer::BYTE, Value::Byte(1)));
|
||||
}
|
||||
// AbstractBoat.DATA_ID_PADDLE_LEFT and DATA_ID_PADDLE_RIGHT.
|
||||
// Omitted false values must reset clients that saw a true value,
|
||||
// including when the controlling passenger dismounts mid-stroke.
|
||||
|
||||
@@ -456,6 +456,13 @@ impl Game<'_> {
|
||||
Some(Particle { id, data })
|
||||
}
|
||||
|
||||
/// This window plays on an older release's server through the adapter.
|
||||
fn older_server(&self) -> bool {
|
||||
self.remote.as_ref().is_some_and(|r| {
|
||||
r.session.server_version() != horizon_protocol::adapter::ServerVersion::Current
|
||||
})
|
||||
}
|
||||
|
||||
/// The options a server's particle stands for (None for a type or data
|
||||
/// this client doesn't draw).
|
||||
pub(super) fn received_particle(
|
||||
@@ -463,7 +470,9 @@ impl Game<'_> {
|
||||
p: &Particle,
|
||||
at: [f64; 3],
|
||||
) -> Option<ParticleOptions> {
|
||||
if let Some(options) = legacy_effect_options(p) {
|
||||
// The adapter's markers mean something only on an older server's connection: a
|
||||
// 26.3 server's ids and durations are read as they are.
|
||||
if let Some(options) = legacy_effect_options(p).filter(|_| self.older_server()) {
|
||||
return Some(options);
|
||||
}
|
||||
if p.id == MOD_PARTICLE_ID
|
||||
@@ -511,9 +520,10 @@ impl Game<'_> {
|
||||
Some(ParticleData::GeyserBase(water_blocks, burst_impulse_base)) => {
|
||||
ParticleOptions::geyser_base(ty, *water_blocks, *burst_impulse_base)
|
||||
}
|
||||
Some(ParticleData::Trail(target, color, duration)) => {
|
||||
ParticleOptions::trail(*target, *color as u32 & 0xFF_FFFF, *duration)
|
||||
}
|
||||
Some(ParticleData::Trail(target, color, duration)) => ParticleOptions {
|
||||
legacy_trail_lifetime: *duration == -1 && self.older_server(),
|
||||
..ParticleOptions::trail(*target, *color as u32 & 0xFF_FFFF, *duration)
|
||||
},
|
||||
Some(ParticleData::Vibration { source, ticks }) => ParticleOptions::vibration(
|
||||
source.clone(),
|
||||
self.vibration_destination(source)?,
|
||||
|
||||
@@ -54,7 +54,7 @@ pub(super) fn pvp_hit(victim_entity: i32) -> Option<PvpHit> {
|
||||
}
|
||||
|
||||
/// Names `hit` as the one being landed until it drops.
|
||||
struct PvpScope(Option<PvpHit>);
|
||||
pub(super) struct PvpScope(Option<PvpHit>);
|
||||
|
||||
impl Drop for PvpScope {
|
||||
fn drop(&mut self) {
|
||||
@@ -62,7 +62,7 @@ impl Drop for PvpScope {
|
||||
}
|
||||
}
|
||||
|
||||
fn pvp_scope(hit: Option<PvpHit>) -> PvpScope {
|
||||
pub(super) fn pvp_scope(hit: Option<PvpHit>) -> PvpScope {
|
||||
PvpScope(PVP_HIT.replace(hit))
|
||||
}
|
||||
|
||||
@@ -214,7 +214,7 @@ impl Game<'_> {
|
||||
|
||||
/// The held weapon's `minecraft:ignite` (Fire Aspect) seconds on a player
|
||||
/// victim (`EnchantmentHelper.doPostAttackEffects`).
|
||||
fn weapon_ignite_seconds(&mut self) -> f32 {
|
||||
pub(super) fn weapon_ignite_seconds(&mut self) -> f32 {
|
||||
let Some(held) = self.held().cloned() else {
|
||||
return 0.0;
|
||||
};
|
||||
@@ -326,7 +326,7 @@ impl Game<'_> {
|
||||
|
||||
/// The hit of `attacker` (a guest; none: this game's player) on `victim` as
|
||||
/// `native_player_hurt_by_player` names it, none when either has gone.
|
||||
fn pvp_hit_of(
|
||||
pub(super) fn pvp_hit_of(
|
||||
&mut self,
|
||||
host: &LanHost,
|
||||
attacker: Option<u32>,
|
||||
@@ -457,26 +457,6 @@ impl Game<'_> {
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
// ServerPlayer.awardKillScore: the blow that kills a player.
|
||||
if hurt {
|
||||
let died = match victim {
|
||||
// A totem that fired is applied on the next tick_totem, so the
|
||||
// victim is still at zero health but is not dying.
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.motion.as_deref())
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
};
|
||||
if died {
|
||||
match s.attacker {
|
||||
Some(conn) => {
|
||||
self.as_guest(host, conn, |g| g.award_custom_stat("player_kills", 1));
|
||||
}
|
||||
None => self.award_custom_stat("player_kills", 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
if hurt && (s.crit || s.magic) {
|
||||
let target = match victim {
|
||||
Victim::Guest(conn) => host
|
||||
@@ -499,25 +479,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let thorns_cause = self.player_attacker(host, victim.conn());
|
||||
for damage in thorns {
|
||||
match s.attacker {
|
||||
None => {
|
||||
if let Some((id, name)) = þs_cause {
|
||||
self.note_named_attacker("minecraft:thorns", *id, name.clone());
|
||||
}
|
||||
self.hurt_player_by("minecraft:thorns", damage);
|
||||
}
|
||||
Some(conn) => {
|
||||
self.guest_hit_by(host, conn, |g| {
|
||||
if let Some((id, name)) = þs_cause {
|
||||
g.note_named_attacker("minecraft:thorns", *id, name.clone());
|
||||
}
|
||||
g.hurt_player_by("minecraft:thorns", damage)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.pvp_aftermath(host, victim, s.attacker, hurt, thorns);
|
||||
let sound = match (hurt, s.crit, s.scale > 0.9) {
|
||||
(false, ..) => "minecraft:entity.player.attack.nodamage",
|
||||
(true, true, _) => "minecraft:entity.player.attack.crit",
|
||||
@@ -554,6 +516,58 @@ impl Game<'_> {
|
||||
hurt
|
||||
}
|
||||
|
||||
/// What follows a player's blow on a player as the victim's hurt ends:
|
||||
/// `ServerPlayer.awardKillScore` when it killed them, then the victim's
|
||||
/// thorns dealt back to the attacker.
|
||||
pub(super) fn pvp_aftermath(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
victim: Victim,
|
||||
attacker: Option<u32>,
|
||||
hurt: bool,
|
||||
thorns: Vec<f32>,
|
||||
) {
|
||||
// ServerPlayer.awardKillScore: the blow that kills a player.
|
||||
if hurt {
|
||||
let died = match victim {
|
||||
// A totem that fired is applied on the next tick_totem, so the
|
||||
// victim is still at zero health but is not dying.
|
||||
Victim::Guest(conn) => host
|
||||
.hub
|
||||
.guest(conn)
|
||||
.and_then(|g| g.motion.as_deref())
|
||||
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
|
||||
};
|
||||
if died {
|
||||
match attacker {
|
||||
Some(conn) => {
|
||||
self.as_guest(host, conn, |g| g.award_custom_stat("player_kills", 1));
|
||||
}
|
||||
None => self.award_custom_stat("player_kills", 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
let thorns_cause = self.player_attacker(host, victim.conn());
|
||||
for damage in thorns {
|
||||
match attacker {
|
||||
None => {
|
||||
if let Some((id, name)) = þs_cause {
|
||||
self.note_named_attacker("minecraft:thorns", *id, name.clone());
|
||||
}
|
||||
self.hurt_player_by("minecraft:thorns", damage);
|
||||
}
|
||||
Some(conn) => {
|
||||
self.guest_hit_by(host, conn, |g| {
|
||||
if let Some((id, name)) = þs_cause {
|
||||
g.note_named_attacker("minecraft:thorns", *id, name.clone());
|
||||
}
|
||||
g.hurt_player_by("minecraft:thorns", damage)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The sweep of the player in place, for the players it reaches: the
|
||||
/// damage (`1 + ratio * baseDamage`, plus the enchantments against a
|
||||
/// player, scaled by the attack strength) and who hits. The box is set
|
||||
|
||||
@@ -294,14 +294,9 @@ impl Game<'_> {
|
||||
}
|
||||
let mut ejected = false;
|
||||
if let Some(i) = boat {
|
||||
// AbstractBoat.tick on the server for a boat its rider drives:
|
||||
// only the hurt animation and the damage wear off here.
|
||||
let b = &mut self.boats[i];
|
||||
b.hurt_time = (b.hurt_time - 1).max(0);
|
||||
b.damage = (b.damage - 1.0).max(0.0);
|
||||
// `Entity.ejectPassengers`: every player in the boat must see
|
||||
// the request; the common boat tick clears it once nobody rides.
|
||||
ejected = b.eject_riders;
|
||||
ejected = self.boats[i].eject_riders;
|
||||
}
|
||||
// ServerGamePacketListenerImpl.tick: the vehicle's last good position
|
||||
// is where it is now, so a move the server made (a piston, a push)
|
||||
|
||||
@@ -572,11 +572,16 @@ impl Game<'_> {
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if moved {
|
||||
if moved && k + 1 < n {
|
||||
self.enter_level(None, dim)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(&first) = order.first()
|
||||
&& self.dim != first
|
||||
{
|
||||
let _ = self.enter_level(None, first);
|
||||
}
|
||||
self.level_pass = Pass::default();
|
||||
if result.is_err() {
|
||||
self.native_lifecycle.cancel_server_tick();
|
||||
|
||||
@@ -237,6 +237,7 @@ impl Game<'_> {
|
||||
damage_type: Some(match source.kind {
|
||||
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
|
||||
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
|
||||
horizon_client::mobs::DamageKind::Thorns => "minecraft:thorns",
|
||||
_ => "minecraft:player_attack",
|
||||
}),
|
||||
items: Some(world_items!(self)),
|
||||
|
||||
@@ -2347,6 +2347,30 @@ impl Game<'_> {
|
||||
}
|
||||
self.hurt_cart(i, damage, creative, false);
|
||||
}
|
||||
/// `Containers.dropContents(level, entity, container)` at a cart's
|
||||
/// position: each slot, an empty one too, from a spot in the cart's block
|
||||
/// in pieces of 10 to 30 with the level random's scatter, no pickup delay.
|
||||
fn scatter_cart_contents(&mut self, at: [f64; 3], items: &[Option<ItemStack>]) {
|
||||
let cell = horizon_core::position::BlockPos::new(
|
||||
at[0].floor() as i32,
|
||||
at[1].floor() as i32,
|
||||
at[2].floor() as i32,
|
||||
);
|
||||
for stack in items {
|
||||
let count = stack.as_ref().map_or(0, |s| s.count);
|
||||
let pieces =
|
||||
horizon_world::level::removal::drop_item_stack(&mut self.world.random, cell, count);
|
||||
if let Some(stack) = stack {
|
||||
for piece in pieces {
|
||||
let piece_stack = ItemStack {
|
||||
count: piece.count,
|
||||
..stack.clone()
|
||||
};
|
||||
self.spawn_item(piece_stack, piece.pos, piece.motion, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
|
||||
/// once for creative players); TNT minecarts prime instead when moving
|
||||
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
|
||||
@@ -2414,11 +2438,16 @@ impl Game<'_> {
|
||||
self.entity_killed_event(at, false);
|
||||
}
|
||||
if self.ui.rules.bool("entity_drops") {
|
||||
for s in c.items.iter().flatten() {
|
||||
self.spawn_item(s.clone(), at, [0.0, 0.1, 0.0], 10);
|
||||
}
|
||||
// AbstractMinecartContainer.remove: `Containers.dropContents`.
|
||||
self.scatter_cart_contents(at, &c.items);
|
||||
if !creative {
|
||||
self.spawn_item(ItemStack::new(c.kind.item(), 1), at, [0.0, 0.1, 0.0], 10);
|
||||
// `spawnAtLocation`: an ItemEntity's own motion.
|
||||
let motion = super::dispenser::new_item_motion();
|
||||
self.spawn_item(ItemStack::new(c.kind.item(), 1), at, motion, 10);
|
||||
// `chestVehicleDestroyed` drops the emptied container
|
||||
// again, which only draws each slot's spot.
|
||||
let empty = vec![None; c.items.len()];
|
||||
self.scatter_cart_contents(at, &empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2768,6 +2797,7 @@ impl Game<'_> {
|
||||
skull: &|_| None,
|
||||
glyph: &|c| self.gui.atlas.glyph_cell(c),
|
||||
glyphs: self.gui.atlas.font.epoch(),
|
||||
font: Some(&self.gui.atlas.font),
|
||||
dynamic_visible: None,
|
||||
};
|
||||
self.res
|
||||
|
||||
@@ -307,13 +307,17 @@ fn build_lists_mods(built: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether the game playing was built with native mods: the launcher writes their ids into
|
||||
/// [`BUILD_INFO`] beside the executable, and a release or Nightly has no such file.
|
||||
/// Whether the game playing was built with native mods: horizon-mixins applied bundles to its
|
||||
/// source (`HORIZON_MIXIN_MODS`, set by the build script from the record it leaves, as with a mod
|
||||
/// author's own build that has no [`BUILD_INFO`]), trait mods are installed, or the launcher wrote
|
||||
/// their ids into [`BUILD_INFO`] beside the executable. A release or Nightly has none of them.
|
||||
pub(super) fn built_with_native_mods() -> bool {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
|
||||
.is_some_and(|built| build_lists_mods(&built))
|
||||
option_env!("HORIZON_MIXIN_MODS").is_some_and(|mods| !mods.is_empty())
|
||||
|| !crate::mod_api::mods().is_empty()
|
||||
|| std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
|
||||
.is_some_and(|built| build_lists_mods(&built))
|
||||
}
|
||||
|
||||
/// The native mods the launcher built this game with, `id version, id version`, for the crash
|
||||
@@ -457,6 +461,7 @@ impl Game<'_> {
|
||||
}
|
||||
horizon_client::gfx::native_render::set_mods_built_in(
|
||||
built_with_mods
|
||||
|| built_with_native_mods()
|
||||
|| self
|
||||
.settings
|
||||
.mods
|
||||
|
||||
@@ -575,7 +575,16 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &self.mobs[i];
|
||||
let mut at = Vec3::new(m.position.x, m.position.y + seat(m.species()), m.position.z);
|
||||
if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
if &*m.kind.id == "minecraft:player" || &*m.kind.id == "minecraft:mannequin" {
|
||||
// Avatar attachments follow pose and minecraft:scale, just as for
|
||||
// remote avatars riding other avatars. Do not guess a standing height.
|
||||
at = horizon_client::mobs::riders::seat(
|
||||
horizon_client::mobs::riders::passenger_attachment(m),
|
||||
f64::from(0.6_f32),
|
||||
m.position,
|
||||
m.yaw,
|
||||
);
|
||||
} else if horizon_client::mobs::camel::is_camel(m.species()) {
|
||||
// Camel.getPassengerAttachmentPoint: the driver half a block
|
||||
// forward, at the body anchor (lower while sitting), turned with
|
||||
// the camel.
|
||||
|
||||
@@ -600,6 +600,7 @@ impl Game<'_> {
|
||||
x,
|
||||
y: at[1],
|
||||
text: shown,
|
||||
style: Default::default(),
|
||||
color: 0xFF00_0000 | (span.style.color.unwrap_or(color) & 0xFF_FFFF),
|
||||
outline: if outline == 0 {
|
||||
0
|
||||
@@ -609,7 +610,12 @@ impl Game<'_> {
|
||||
light,
|
||||
tier: 0,
|
||||
};
|
||||
text_vertices(&glyph, &call, &mut draw.vertices);
|
||||
text_vertices(
|
||||
&glyph,
|
||||
Some(self.gui.atlas.font.white()),
|
||||
&call,
|
||||
&mut draw.vertices,
|
||||
);
|
||||
x += width;
|
||||
}
|
||||
x - at[0]
|
||||
|
||||
@@ -1001,6 +1001,7 @@ use super::{
|
||||
use crate::mod_api::NativeTextField;
|
||||
use horizon_assets::item_model::StackView;
|
||||
use horizon_client::mod_api::EntityDataValue;
|
||||
use horizon_client::mod_api::WorldHold;
|
||||
use horizon_client::mod_api::{
|
||||
self, BlockProperties, HudMode, NativeBossBar, NativeCamera, NativeCommand, NativeDispensed,
|
||||
NativeFluidAction, NativeFluidDrain, NativeFluidStack, NativeFluidTankView,
|
||||
@@ -3199,6 +3200,15 @@ impl Game<'_> {
|
||||
pub(super) fn native_shader_params(&self, partial: f32, params: &mut [[f32; 4]; 8]) {
|
||||
mod_api::dispatch_shader_params(self, mod_api::mods(), partial, params);
|
||||
}
|
||||
/// Whether native mods hold the terrain's picture this frame: call `hold.hold()` to keep
|
||||
/// every section drawn as it is while the world changes under it, or `hold.hold_within(x, z,
|
||||
/// radius)` for only the sections inside circles. Not asking lets go, and every change
|
||||
/// shows on that frame. Asked once a frame while a world is shown, before the frame's
|
||||
/// section uploads, by each player's game (splitscreen: each its own). Client only.
|
||||
/// API @API:world_hold@.
|
||||
pub(super) fn native_world_hold(&self, hold: &mut WorldHold) {
|
||||
mod_api::dispatch_world_hold(self, mod_api::mods(), hold);
|
||||
}
|
||||
/// The minimap a native mod shows: set `minimap` when it is none yet.
|
||||
pub(super) fn native_minimap(&self, minimap: &mut Option<NativeMinimap>) {
|
||||
mod_api::dispatch_minimap(self, mod_api::mods(), minimap);
|
||||
@@ -10778,6 +10788,30 @@ impl Game<'_> {
|
||||
self.audio.play_after(sound, at, volume, pitch, delay);
|
||||
true
|
||||
}
|
||||
/// Muffles the game's sounds for a while, as ears ringing after a close
|
||||
/// blast hear them: `amount` (0 to 1) of every sound's volume is taken
|
||||
/// away and `lowpass` (0 to 1) dulls it, from untouched to a 300 Hz
|
||||
/// low-pass. Both start in full and fade back in a straight line over
|
||||
/// `ticks` client ticks, held while the game is paused. The sound events in
|
||||
/// `exempt` (`mymod:ear.ringing`) play clearly through it, those already
|
||||
/// playing too, so the mod's own ringing stays sharp. Everything else is
|
||||
/// muffled, music and the interface's clicks included. A new call replaces
|
||||
/// the muffle in place; `ticks` 0 ends it at once. Nothing changes while no
|
||||
/// mod calls it. False, with nothing changed, on a server, for an amount or
|
||||
/// strength outside 0 to 1, or for an exempt event that isn't
|
||||
/// `namespace:path` or more than 64 of them. API @API:muffle_sounds@.
|
||||
pub(super) fn native_muffle_sounds(
|
||||
&mut self,
|
||||
amount: f32,
|
||||
lowpass: f32,
|
||||
ticks: u32,
|
||||
exempt: &[&str],
|
||||
) -> bool {
|
||||
if !self.native_window() || exempt.len() > 64 || !exempt.iter().all(|e| sound_id(e)) {
|
||||
return false;
|
||||
}
|
||||
self.audio.muffle(amount, lowpass, ticks, exempt)
|
||||
}
|
||||
/// Draws one vanilla HUD layer between the mods' hooks: `native_gui_layer_pre`
|
||||
/// (which may hide it), the layer, then `native_gui_layer_post`. `None` when
|
||||
/// a mod hid the layer.
|
||||
@@ -12317,6 +12351,12 @@ impl Game<'_> {
|
||||
self.native_shader_params(partial, &mut params);
|
||||
finite_params(params)
|
||||
}
|
||||
/// What native mods ask of the terrain's picture this frame (`native_world_hold`).
|
||||
pub(super) fn native_world_hold_now(&self) -> WorldHold {
|
||||
let mut hold = WorldHold::default();
|
||||
self.native_world_hold(&mut hold);
|
||||
hold
|
||||
}
|
||||
/// The minimap a native mod shows.
|
||||
pub(super) fn native_minimap_now(&self) -> Option<NativeMinimap> {
|
||||
let mut minimap = None;
|
||||
|
||||
@@ -1078,15 +1078,7 @@ impl Game<'_> {
|
||||
self.release_vehicle_container(id);
|
||||
let uuid = self.uuid_for(id);
|
||||
let b = &self.boats[i];
|
||||
let mut tag = horizon_client::entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
[b.yaw, b.pitch],
|
||||
uuid,
|
||||
self.persist_raw(id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
let mut tag = b.to_nbt(uuid, self.persist_raw(id));
|
||||
// The mobs riding it cross with it and mount it again.
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
for rider in self.remove_mob_riders(id) {
|
||||
|
||||
@@ -44,6 +44,16 @@ use winit::{
|
||||
const STICK_STEP: f32 = 0.55;
|
||||
/// Scrolls a step may try to reveal the next row of a list.
|
||||
const SCROLL_TRIES: u8 = 4;
|
||||
/// How far apart two rectangles may be and still be one widget found again (GUI pixels).
|
||||
const SAME_RECT: f32 = 0.5;
|
||||
/// Frames after a scroll that may still have been drawn before it: their rectangles are stale.
|
||||
const STALE_FRAMES: u64 = 1;
|
||||
|
||||
/// Whether the frames drawn since `pad_reveal` scrolled a row, at frame `revealed`, may still
|
||||
/// show the list where it was.
|
||||
fn reveal_pending(revealed: Option<u64>, now: u64) -> bool {
|
||||
revealed.is_some_and(|at| now <= at + STALE_FRAMES)
|
||||
}
|
||||
|
||||
/// A focus step that ran off the visible part of a list: the list was
|
||||
/// scrolled and the step runs again once a new frame is drawn. When the
|
||||
@@ -131,6 +141,9 @@ pub(super) struct MenuCursor {
|
||||
/// The focused button A went down on: letting go presses it, unless the
|
||||
/// focus moved or the input was cancelled first.
|
||||
pub(super) armed: Option<Rect>,
|
||||
/// The frame count when `pad_reveal` scrolled the focused row into view: the frames drawn
|
||||
/// before it still show the row where it was, so the focus isn't looked for in them.
|
||||
pub(super) revealed: Option<u64>,
|
||||
/// The OS pointer is hidden while the pad's cursor is drawn.
|
||||
os_hidden: bool,
|
||||
/// Polls since a menu opened (its widgets are drawn after one frame).
|
||||
@@ -236,10 +249,12 @@ fn beside(rect: Rect, targets: &[Rect]) -> Option<usize> {
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
|
||||
/// Where letting go of A clicks: the armed button, if the focus is still on it.
|
||||
/// Where letting go of A clicks: the armed button, if the focus is still on it. A row scrolled
|
||||
/// into view under A (`pad_reveal`) is found again a fraction of a pixel off.
|
||||
fn armed_click(armed: Option<Rect>, focus: Option<Target>) -> Option<(f32, f32)> {
|
||||
let same = |a: Rect, b: Rect| a.iter().zip(b).all(|(a, b)| (a - b).abs() < SAME_RECT);
|
||||
focus
|
||||
.filter(|focus| armed == Some(focus.rect))
|
||||
.filter(|focus| armed.is_some_and(|armed| same(armed, focus.rect)))
|
||||
.map(|focus| focus.center())
|
||||
}
|
||||
|
||||
@@ -848,7 +863,8 @@ impl Game<'_> {
|
||||
Button::Select | Button::LeftStick if book_control => {}
|
||||
Button::East | Button::Start => return self.pad_back(),
|
||||
Button::South if self.pad_focusing() => {
|
||||
let focus = self.pad.menu.focus.expect("focusing");
|
||||
let focus = self.pad_reveal(self.pad.menu.focus.expect("focusing"));
|
||||
self.pad.menu.focus = Some(focus);
|
||||
match focus.kind {
|
||||
// A click would jump the handle: A takes hold of it, or lets it go.
|
||||
Kind::Slider { .. } => {
|
||||
@@ -1092,8 +1108,22 @@ impl Game<'_> {
|
||||
if self.pad.menu.pending.is_some() {
|
||||
return;
|
||||
}
|
||||
// Nor while a row scrolled into view: the targets still show it where it was, and a step
|
||||
// from them could land on its neighbour.
|
||||
if reveal_pending(self.pad.menu.revealed, widgets::generation()) {
|
||||
return;
|
||||
}
|
||||
let rects: Vec<Rect> = targets.iter().map(|t| t.rect).collect();
|
||||
let vertical = matches!(button, Button::DPadUp | Button::DPadDown);
|
||||
// A step the screen declared comes first (`widgets::focus_link`).
|
||||
let linked = vertical
|
||||
.then(|| widgets::linked_step(focus.rect, button == Button::DPadDown))
|
||||
.flatten()
|
||||
.and_then(|to| gamepad::refind(to, &rects, 0.0));
|
||||
if let Some(i) = linked {
|
||||
self.pad_focus_on(event_loop, targets[i]);
|
||||
return;
|
||||
}
|
||||
let areas = widgets::last_scroll_areas();
|
||||
let step = if vertical {
|
||||
gamepad::list_step(focus.rect, button, &rects, &areas)
|
||||
@@ -1137,7 +1167,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `AbstractSelectionList.setSelected`: a settings row the focus lands on, or presses, while
|
||||
/// its list hides part of it scrolls all into view first. Where it is now.
|
||||
fn pad_reveal(&mut self, target: Target) -> Target {
|
||||
if self.screen != Screen::Options {
|
||||
return target;
|
||||
}
|
||||
let size = self.gui_size();
|
||||
let mut moved = target;
|
||||
if let Some(dy) = self.ui.options.reveal(&self.settings, size, target.rect) {
|
||||
moved.rect[1] += dy;
|
||||
self.pad.menu.revealed = Some(widgets::generation());
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
fn pad_focus_on(&mut self, event_loop: &dyn EventLoopCtl, target: Target) {
|
||||
let target = self.pad_reveal(target);
|
||||
self.pad.menu.shortcut_focus = None;
|
||||
let left = self.pad.menu.focus.replace(target);
|
||||
if left.is_some_and(|l| l.rect != target.rect) {
|
||||
@@ -1706,7 +1752,10 @@ impl Game<'_> {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
} else if ready.is_none() {
|
||||
} else if ready.is_none()
|
||||
// Two frames on, as after a step that scrolled: the first may predate the scroll.
|
||||
&& !reveal_pending(self.pad.menu.revealed, widgets::generation())
|
||||
{
|
||||
if let Some(focus) = self.pad.menu.focus {
|
||||
let kind = std::mem::discriminant(&focus.kind);
|
||||
let named = self.pad_named_focus(focus, &rects);
|
||||
@@ -1967,6 +2016,8 @@ impl Game<'_> {
|
||||
self.pad_focus_move(event_loop, button, focus, &targets);
|
||||
}
|
||||
(Act::Press, Some(focus)) => {
|
||||
let focus = self.pad_reveal(focus);
|
||||
self.pad.menu.focus = Some(focus);
|
||||
// AbstractButton.keyPressed: onPress, with its click sound.
|
||||
self.pad_click_at(event_loop, focus.center(), false);
|
||||
}
|
||||
@@ -2553,6 +2604,15 @@ mod tests {
|
||||
let kind = Kind::Button;
|
||||
let focus = Some(Target { rect, kind });
|
||||
assert_eq!(armed_click(Some(rect), focus), Some((60.0, 30.0)));
|
||||
let refound = Some(Target {
|
||||
rect: [10.0, 20.000_002, 100.0, 20.0],
|
||||
kind,
|
||||
});
|
||||
assert!(armed_click(Some(rect), refound).is_some());
|
||||
// A row scrolled into view at frame 10 is looked for again from frame 12.
|
||||
assert!(!reveal_pending(None, 10));
|
||||
assert!(reveal_pending(Some(10), 10) && reveal_pending(Some(10), 11));
|
||||
assert!(!reveal_pending(Some(10), 12));
|
||||
let moved = Some(Target {
|
||||
rect: [10.0, 44.0, 100.0, 20.0],
|
||||
kind,
|
||||
|
||||
@@ -155,23 +155,42 @@ impl Game<'_> {
|
||||
.map(|(i, m)| (m.id, i))
|
||||
.collect();
|
||||
// Riders of riders settle over a few passes.
|
||||
for pass in 0..3 {
|
||||
for _ in 0..3 {
|
||||
for i in 0..self.mobs.len() {
|
||||
let Some(v) = self.mobs[i].extra.mount else {
|
||||
continue;
|
||||
};
|
||||
let Some(&j) = index.get(&v) else {
|
||||
// The display doesn't move between passes.
|
||||
if pass > 0 {
|
||||
// Boats, minecarts, primed TNT and cushions seated their
|
||||
// riders above, each in its own seat and from the
|
||||
// vehicle's drawn position; re-seating them here from the
|
||||
// shared seat 0 yanked boat riders between two places.
|
||||
if self.seats_own_riders(v) {
|
||||
continue;
|
||||
}
|
||||
// A mob on a text display: NPC plugins seat their sitting
|
||||
// NPCs on one. The server's position for a passenger
|
||||
// gives way to its seat, as `rideTick` places it.
|
||||
let Some((at, before)) = self.text_displays.get(&(v as i32)).map(|d| d.seat())
|
||||
else {
|
||||
let me = self.remote.as_ref().map(|r| r.entity_id as u32);
|
||||
let own_seat = self.player.as_ref().map(|p| {
|
||||
p.position
|
||||
.add_vector(Vec3::new(0.0, f64::from(p.dimensions.height), 0.0))
|
||||
});
|
||||
let Some(at) = self.passenger_riding_position(v, me, own_seat, &index) else {
|
||||
continue;
|
||||
};
|
||||
let before = self
|
||||
.text_displays
|
||||
.get(&(v as i32))
|
||||
.map(|d| d.seat().1)
|
||||
.unwrap_or_else(|| {
|
||||
let old = self.mobs[i].previous;
|
||||
glam::DVec3::new(
|
||||
old.x,
|
||||
old.y + riders::vehicle_attachment(&self.mobs[i], 0.0),
|
||||
old.z,
|
||||
)
|
||||
});
|
||||
let m = &mut self.mobs[i];
|
||||
// EntityType.TEXT_DISPLAY's box is zero wide.
|
||||
let offset = riders::vehicle_attachment(m, 0.0);
|
||||
@@ -297,13 +316,22 @@ impl Game<'_> {
|
||||
let Some(vehicle) = self.armor_stands[i].vehicle else {
|
||||
continue;
|
||||
};
|
||||
let Some(&j) = index.get(&vehicle).filter(|_| Some(vehicle) != me) else {
|
||||
let Some(at) = self.passenger_riding_position(vehicle, me, own_seat, &index) else {
|
||||
continue;
|
||||
};
|
||||
let vehicle = &self.mobs[j];
|
||||
let attach = riders::passenger_attachment(vehicle);
|
||||
let at = riders::seat(attach, 0.0, vehicle.position, vehicle.yaw);
|
||||
let before = riders::seat(attach, 0.0, vehicle.previous, vehicle.previous_yaw);
|
||||
let before = index
|
||||
.get(&vehicle)
|
||||
.map(|&j| {
|
||||
let v = &self.mobs[j];
|
||||
riders::seat(
|
||||
riders::passenger_attachment(v),
|
||||
0.0,
|
||||
v.previous,
|
||||
v.previous_yaw,
|
||||
)
|
||||
})
|
||||
.unwrap_or(self.armor_stands[i].previous);
|
||||
let at = Vec3::new(at.x, at.y, at.z);
|
||||
let stand = &mut self.armor_stands[i];
|
||||
stand.position = at;
|
||||
stand.previous = before;
|
||||
@@ -338,6 +366,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A vehicle whose `seat_*_mobs` pass places its mob riders itself.
|
||||
fn seats_own_riders(&self, vehicle: u32) -> bool {
|
||||
self.boats.iter().any(|b| b.id == vehicle)
|
||||
|| self.carts.iter().any(|c| c.id == vehicle)
|
||||
|| self.cushions.iter().any(|c| c.id == vehicle)
|
||||
|| self.tnts.iter().any(|t| t.id == vehicle && t.id != 0)
|
||||
}
|
||||
|
||||
/// `Entity.getPassengerRidingPosition` of a vehicle: where the feet of a
|
||||
/// passenger whose own vehicle attachment is 0 land.
|
||||
pub(super) fn passenger_riding_position(
|
||||
@@ -347,19 +383,44 @@ impl Game<'_> {
|
||||
own_seat: Option<Vec3>,
|
||||
index: &HashMap<u32, usize>,
|
||||
) -> Option<glam::DVec3> {
|
||||
self.passenger_riding_position_inner(vehicle, me, own_seat, index, &mut Vec::new())
|
||||
}
|
||||
|
||||
fn passenger_riding_position_inner(
|
||||
&self,
|
||||
vehicle: u32,
|
||||
me: Option<u32>,
|
||||
own_seat: Option<Vec3>,
|
||||
index: &HashMap<u32, usize>,
|
||||
path: &mut Vec<u32>,
|
||||
) -> Option<glam::DVec3> {
|
||||
if path.len() >= 64 || path.contains(&vehicle) {
|
||||
return None;
|
||||
}
|
||||
path.push(vehicle);
|
||||
if Some(vehicle) == me {
|
||||
let at = own_seat?;
|
||||
return Some(glam::DVec3::new(at.x, at.y, at.z));
|
||||
}
|
||||
if let Some(&j) = index.get(&vehicle) {
|
||||
let v = &self.mobs[j];
|
||||
if let Some(v) = index
|
||||
.get(&vehicle)
|
||||
.map(|&j| &self.mobs[j])
|
||||
.or_else(|| self.mobs.iter().find(|m| m.id == vehicle))
|
||||
{
|
||||
let at = riders::seat(riders::passenger_attachment(v), 0.0, v.position, v.yaw);
|
||||
return Some(glam::DVec3::new(at.x, at.y, at.z));
|
||||
}
|
||||
if let Some(d) = self.text_displays.get(&(vehicle as i32)) {
|
||||
return Some(d.position());
|
||||
return if let Some(parent) = d.vehicle {
|
||||
self.passenger_riding_position_inner(parent, me, own_seat, index, path)
|
||||
} else {
|
||||
Some(d.position())
|
||||
};
|
||||
}
|
||||
if let Some(i) = self.interactions.get(&(vehicle as i32)) {
|
||||
if let Some(parent) = i.vehicle {
|
||||
return self.passenger_riding_position_inner(parent, me, own_seat, index, path);
|
||||
}
|
||||
let at = i.position;
|
||||
return Some(glam::DVec3::new(at.x, at.y, at.z));
|
||||
}
|
||||
@@ -383,16 +444,24 @@ impl Game<'_> {
|
||||
} else {
|
||||
1.975
|
||||
};
|
||||
return Some(glam::DVec3::new(
|
||||
a.position.x,
|
||||
a.position.y + height,
|
||||
a.position.z,
|
||||
));
|
||||
let at = if let Some(parent) = a.vehicle {
|
||||
self.passenger_riding_position_inner(parent, me, own_seat, index, path)?
|
||||
} else {
|
||||
glam::DVec3::new(a.position.x, a.position.y, a.position.z)
|
||||
};
|
||||
return Some(at + glam::DVec3::Y * height);
|
||||
}
|
||||
// Any other tracked entity sits on its feet; an area effect cloud is
|
||||
// 0.5 tall, and ModelEngine parks a model's pivot half a block below
|
||||
// its feet for exactly that.
|
||||
let (kind, position) = self.remote.as_ref()?.tracked(vehicle as i32)?;
|
||||
let remote = self.remote.as_ref()?;
|
||||
let (kind, position) = remote.tracked(vehicle as i32)?;
|
||||
let position = if let Some(&parent) = remote.passenger_vehicles.get(&vehicle) {
|
||||
let at = self.passenger_riding_position_inner(parent, me, own_seat, index, path)?;
|
||||
[at.x, at.y, at.z]
|
||||
} else {
|
||||
position
|
||||
};
|
||||
let lift = if kind == "minecraft:area_effect_cloud" {
|
||||
0.5
|
||||
} else {
|
||||
|
||||
@@ -799,6 +799,7 @@ impl Game<'_> {
|
||||
let (wood, chest) = super::boats::any_boat_wood(k).unwrap_or(("oak", false));
|
||||
let mut b = super::boats::Boat::new(id, std::sync::Arc::from(wood), position, yaw);
|
||||
b.no_gravity = entity_nbt::flag(&tag, "NoGravity");
|
||||
b.fire_ticks = super::boats::saved_fire(&tag);
|
||||
b.pitch = entity_nbt::rotation(&tag)[1];
|
||||
if chest {
|
||||
b = b.with_chest();
|
||||
@@ -1165,15 +1166,7 @@ impl Game<'_> {
|
||||
if self.boats.iter().any(|b| b.id == root) {
|
||||
let uuid = self.uuid_for(root);
|
||||
let b = self.boats.iter().find(|b| b.id == root)?;
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
[b.yaw, b.pitch],
|
||||
uuid,
|
||||
self.persist.raw.get(&root),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
let mut tag = b.to_nbt(uuid, self.persist.raw.get(&root));
|
||||
self.put_mob_passengers(&mut tag, root);
|
||||
return Some(tag);
|
||||
}
|
||||
@@ -2067,15 +2060,7 @@ impl Game<'_> {
|
||||
return None;
|
||||
}
|
||||
let uuid = self.saved_uuid_or_random(id);
|
||||
let mut tag = entity_nbt::boat_to_nbt(
|
||||
&b.entity_type(),
|
||||
b.position,
|
||||
b.velocity,
|
||||
[b.yaw, b.pitch],
|
||||
uuid,
|
||||
self.persist.raw.get(&id),
|
||||
b.chest.then_some(&b.items[..]),
|
||||
);
|
||||
let mut tag = b.to_nbt(uuid, self.persist.raw.get(&id));
|
||||
self.put_mob_passengers(&mut tag, id);
|
||||
// AbstractBoat.addAdditionalSaveData: the lead's holder.
|
||||
self.write_boat_leash(&mut tag, i);
|
||||
|
||||
@@ -140,6 +140,7 @@ impl Game<'_> {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
self.set_pushed_on_ground(target, movement, moved);
|
||||
if moved.length_squared() > 0.0 {
|
||||
self.apply_push(target, moved);
|
||||
}
|
||||
@@ -507,11 +508,31 @@ impl Game<'_> {
|
||||
)
|
||||
.unwrap_or(Vec3::ZERO)
|
||||
});
|
||||
// Entity.move updates an authoritative living entity's ground flag
|
||||
// even for a horizontal piston push. Keeping the old support flag
|
||||
// would run Frost Walker while the stand is moving over open water.
|
||||
if let Target::Entity(EntityHandle::ArmorStand(i)) = target {
|
||||
self.armor_stands[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
|
||||
self.set_pushed_on_ground(target, limited, resolved);
|
||||
|
||||
// Entity.move updates ground before its tiny-collision return. This
|
||||
// native slice covers only unoccupied server NoAI horizontal moves.
|
||||
if let Target::Entity(EntityHandle::Mob(i)) = target {
|
||||
let m = &self.mobs[i];
|
||||
let eligible = !self.options.execution_role.is_client()
|
||||
&& self.remote.is_none()
|
||||
&& limited.y == 0.0
|
||||
&& m.no_ai
|
||||
&& !m.extra.passenger
|
||||
&& m.extra.mount.is_none()
|
||||
&& m.rider.is_none()
|
||||
&& m.extra.rider.is_none()
|
||||
&& self.riding != Some(m.id)
|
||||
&& self.lan.as_ref().is_none_or(|host| {
|
||||
!host
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.any(|g| host.hub.here(g) && g.vehicle == Some(m.id))
|
||||
});
|
||||
if eligible {
|
||||
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
|
||||
}
|
||||
}
|
||||
if resolved.length_squared() <= 1.0e-7
|
||||
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
|
||||
@@ -521,6 +542,16 @@ impl Game<'_> {
|
||||
self.apply_push(target, resolved);
|
||||
}
|
||||
|
||||
/// The end of a block's `Entity.move` push: a server entity's ground flag
|
||||
/// is set after every push, a sideways one and one the collision turns
|
||||
/// away included, on ground only when the push's downward part was blocked.
|
||||
fn set_pushed_on_ground(&mut self, target: Target, wanted: Vec3, moved: Vec3) {
|
||||
if let Target::Entity(handle) = target {
|
||||
let on_ground = wanted.y < 0.0 && wanted.y != moved.y;
|
||||
with_entity_mut!(self, handle, entity => entity.set_piston_on_ground(on_ground));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.limitPistonMovement`: each axis may travel at most 0.51 blocks
|
||||
/// per game tick, and a move touches only its first non-zero axis.
|
||||
fn limit_piston(
|
||||
@@ -805,3 +836,296 @@ mod order_tests {
|
||||
assert!(moved[1] < 0.02, "far stone still in the way: {}", moved[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod h255_mob_piston_ground_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{chunk::Chunk, motion::PlayerMotion};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos, random::LegacyRandom};
|
||||
use std::sync::Arc;
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
with_role_game(
|
||||
crate::game::execution_role::ExecutionRole::IntegratedServer,
|
||||
test,
|
||||
);
|
||||
}
|
||||
fn with_role_game(
|
||||
role: crate::game::execution_role::ExecutionRole,
|
||||
test: impl FnOnce(&mut Game<'_>),
|
||||
) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(
|
||||
std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
|
||||
.join(format!("h255-{:?}", std::thread::current().id())),
|
||||
);
|
||||
options.execution_role = role;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
|
||||
let mut center = Chunk::empty(0, 0);
|
||||
center.lit = true;
|
||||
game.world.chunks.insert((0, 0), Arc::new(center));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("difficulty normal"));
|
||||
assert!(game.run_command("forceload add 0 0"));
|
||||
for x in -1..=1 {
|
||||
for z in -1..=1 {
|
||||
if x == 0 && z == 0 {
|
||||
continue;
|
||||
}
|
||||
let mut chunk = Chunk::empty(x, z);
|
||||
chunk.lit = true;
|
||||
game.world.chunks.insert((x, z), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(x, z))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap());
|
||||
game.creative = true;
|
||||
assert!(game.run_command("fill 0 199 0 31 199 31 stone"));
|
||||
game.world.random = LegacyRandom::new(255);
|
||||
game.update_chunk_tickets();
|
||||
assert!(crate::game::ticking_chunk(&game.world, 0, 0));
|
||||
assert!(game.tickets.levels().block_ticking(0, 0));
|
||||
test(&mut game);
|
||||
}
|
||||
fn mob_index(game: &Game<'_>, n: i32) -> usize {
|
||||
let id = game
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find_map(|(id, u)| (*u == [255, 1, 0, n]).then_some(*id))
|
||||
.unwrap();
|
||||
game.mobs.iter().position(|m| m.id == id).unwrap()
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_real_honey_piston_lifecycle_clears_mob_ground_before_second_drag() {
|
||||
with_game(|game| {
|
||||
game.world.game_time = 0;
|
||||
for cmd in [
|
||||
"gamerule spawn_mobs false",
|
||||
"gamerule random_tick_speed 0",
|
||||
"gamerule advance_time false",
|
||||
"setblock 3 201 4 piston[facing=east]",
|
||||
"setblock 4 201 4 honey_block",
|
||||
"setblock 4 201 12 honey_block",
|
||||
"setblock 12 200 12 piston[facing=up]",
|
||||
"setblock 12 201 12 stone",
|
||||
"summon husk 4.5 202 4.5 {UUID:[I;255,1,0,1],Health:10f,NoAI:1b,NoGravity:1b,OnGround:1b,Motion:[0d,0d,0d]}",
|
||||
"summon husk 4.5 202 12.5 {UUID:[I;255,1,0,2],Health:10f,NoAI:1b,NoGravity:1b,OnGround:1b,Motion:[0d,0d,0d]}",
|
||||
"summon husk 12.5 202 12.5 {UUID:[I;255,1,0,3],Health:10f,NoAI:1b,NoGravity:1b,OnGround:0b,Motion:[0d,0d,0d]}",
|
||||
] {
|
||||
assert!(game.run_command(cmd), "{cmd}");
|
||||
}
|
||||
let main = mob_index(game, 1);
|
||||
let idle = mob_index(game, 2);
|
||||
let vertical = mob_index(game, 3);
|
||||
let id = game.mobs[main].id;
|
||||
let age = game.mobs[main].age;
|
||||
assert!(game.mobs[main].on_ground);
|
||||
assert!(game.mobs[idle].on_ground);
|
||||
assert!(!game.mobs[vertical].on_ground);
|
||||
assert_eq!(game.mobs[main].position, Vec3::new(4.5, 202.0, 4.5));
|
||||
assert_eq!(
|
||||
game.res
|
||||
.facts()
|
||||
.get(game.world.block(4, 201, 4))
|
||||
.name
|
||||
.as_ref(),
|
||||
"minecraft:honey_block"
|
||||
);
|
||||
assert!(game.run_command("setblock 3 201 5 redstone_block"));
|
||||
assert!(game.run_command("setblock 13 200 12 redstone_block"));
|
||||
let mut failures = Vec::new();
|
||||
for tick in 1..=2 {
|
||||
game.world.game_time += 1;
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.entity_ticking_area()(4, 4));
|
||||
game.tick_blocks().unwrap();
|
||||
game.tick_creature_entities().unwrap();
|
||||
// Real block-entity ticker generates and drains the piston pushes.
|
||||
game.tick_moving_pistons().unwrap();
|
||||
let p = game
|
||||
.block_ticks
|
||||
.moving_pistons
|
||||
.iter()
|
||||
.find(|(p, _)| *p == BlockPos::new(5, 201, 4))
|
||||
.expect("real moving honey block");
|
||||
assert_eq!(p.1.progress, 0.5 * tick as f32);
|
||||
assert_eq!(
|
||||
p.1.moved_state,
|
||||
game.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:honey_block")
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(game.mobs[main].id, id);
|
||||
assert_eq!(game.uuid_for(id), [255, 1, 0, 1]);
|
||||
assert_eq!(game.mobs[main].age, age + tick);
|
||||
assert!(game.mobs[main].no_ai);
|
||||
assert_eq!(game.mobs[main].health, 10.0);
|
||||
assert_eq!(game.mobs[main].velocity, Vec3::ZERO);
|
||||
if game.mobs[main].on_ground {
|
||||
failures.push(format!("tick{tick}: actual Mob still grounded"));
|
||||
}
|
||||
if game.mobs[main].position != Vec3::new(5.0, 202.0, 4.5) {
|
||||
failures.push(format!(
|
||||
"tick{tick}: position {:?}",
|
||||
game.mobs[main].position
|
||||
));
|
||||
}
|
||||
if game.mob_tree_nbt(main).get("OnGround")
|
||||
!= Some(&horizon_world::nbt::Tag::Byte(0))
|
||||
{
|
||||
failures.push(format!("tick{tick}: saved ground is stale"));
|
||||
}
|
||||
assert_eq!(game.mobs[idle].position, Vec3::new(4.5, 202.0, 12.5));
|
||||
assert!(game.mobs[idle].on_ground);
|
||||
assert_eq!(
|
||||
game.mobs[vertical].position,
|
||||
Vec3::new(12.5, if tick == 1 { 202.51 } else { 203.01 }, 12.5)
|
||||
);
|
||||
assert!(!game.mobs[vertical].on_ground);
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"actual lifecycle failures: {failures:#?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
// Added source/unit controls; these do not extend the Java live contract.
|
||||
fn added_mob(game: &mut Game<'_>, n: i32, kind: &str, no_ai: bool, x: f64) -> usize {
|
||||
let tame = if kind == "horse" { ",Tame:1b" } else { "" };
|
||||
assert!(game.run_command(&format!("summon {kind} {x} 202 4.5 {{UUID:[I;255,1,0,{n}],Health:10f,NoAI:{}b,NoGravity:1b,OnGround:1b,Motion:[0d,0d,0d]{tame}}}",i32::from(no_ai))));
|
||||
let i = mob_index(game, n);
|
||||
assert_eq!(game.mobs[i].no_ai, no_ai);
|
||||
assert!(game.mobs[i].on_ground);
|
||||
assert_eq!(game.uuid_for(game.mobs[i].id), [255, 1, 0, n]);
|
||||
i
|
||||
}
|
||||
fn added_push(game: &mut Game<'_>, i: usize, movement: Vec3) {
|
||||
let m = &game.mobs[i];
|
||||
let (bounds, at, step, ground) = (
|
||||
m.bounds(),
|
||||
[m.position.x, m.position.z],
|
||||
m.kind.step_height as f32,
|
||||
m.on_ground,
|
||||
);
|
||||
// Mob owns its actual piston budget; the map is for other entity kinds.
|
||||
game.move_pushed(
|
||||
Target::Entity(EntityHandle::Mob(i)),
|
||||
bounds,
|
||||
at,
|
||||
step,
|
||||
ground,
|
||||
movement,
|
||||
Direction::East,
|
||||
&mut std::collections::HashMap::new(),
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_no_request_and_exhausted_budget_keep_state() {
|
||||
with_game(|game| {
|
||||
let i = added_mob(game, 20, "horse", true, 8.5);
|
||||
added_push(game, i, Vec3::ZERO);
|
||||
assert!(game.mobs[i].on_ground);
|
||||
assert_eq!(game.mobs[i].position, Vec3::new(8.5, 202.0, 4.5));
|
||||
assert!(game.run_command("ride @s mount 000000ff-0000-0001-0000-000000000014"));
|
||||
assert_eq!(game.riding, Some(game.mobs[i].id));
|
||||
added_push(game, i, Vec3::new(0.51, 0.0, 0.0));
|
||||
assert!(game.mobs[i].on_ground, "occupied path remains excluded");
|
||||
assert_eq!(game.mobs[i].position, Vec3::new(9.01, 202.0, 4.5));
|
||||
assert!(game.run_command("ride @s dismount"));
|
||||
assert!(game.riding.is_none());
|
||||
assert!(game.mobs[i].rider.is_none());
|
||||
assert!(game.mobs[i].extra.rider.is_none());
|
||||
assert!(game.mobs[i].extra.mount.is_none());
|
||||
assert!(!game.mobs[i].extra.passenger);
|
||||
let before = game.mobs[i].position;
|
||||
added_push(game, i, Vec3::new(0.25, 0.0, 0.0));
|
||||
assert_eq!(
|
||||
game.mobs[i].position, before,
|
||||
"real same-tick piston budget is exhausted"
|
||||
);
|
||||
assert!(
|
||||
game.mobs[i].on_ground,
|
||||
"limitedZERO precedes the ground update"
|
||||
);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_blocked_request_updates_ground_before_tiny_return() {
|
||||
with_game(|game| {
|
||||
assert!(game.run_command("fill 9 202 4 9 204 4 stone"));
|
||||
// Match the existing Mob::bounds_at f32 width boundary exactly.
|
||||
let width = game.res.mob_types().get("minecraft:husk").unwrap().width as f32;
|
||||
let x = 9.0 - f64::from(width) * 0.5;
|
||||
let i = added_mob(game, 21, "husk", true, x);
|
||||
assert_eq!(game.mobs[i].bounds().max_position().x, 9.0);
|
||||
let before = game.mobs[i].position;
|
||||
added_push(game, i, Vec3::new(0.25, 0.0, 0.0));
|
||||
assert_eq!(
|
||||
game.mobs[i].position, before,
|
||||
"actual loaded wall blocks the nonzero request"
|
||||
);
|
||||
assert!(
|
||||
!game.mobs[i].on_ground,
|
||||
"Entity.move ground ordering, source/unit-only coverage"
|
||||
);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn h255_added_client_role_ai_and_vertical_paths_keep_native_state() {
|
||||
use crate::game::execution_role::ExecutionRole as Role;
|
||||
for (role, no_ai, movement) in [
|
||||
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0)),
|
||||
(Role::IntegratedServer, false, Vec3::new(0.25, 0.0, 0.0)),
|
||||
(Role::IntegratedServer, true, Vec3::new(0.0, 0.25, 0.0)),
|
||||
] {
|
||||
with_role_game(role, |game| {
|
||||
let i = added_mob(game, 22, "husk", no_ai, 8.5);
|
||||
let before = game.mobs[i].position;
|
||||
added_push(game, i, movement);
|
||||
assert_eq!(game.mobs[i].position, before.add_vector(movement));
|
||||
assert!(
|
||||
game.mobs[i].on_ground,
|
||||
"excluded native path remains unchanged; not Java parity"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -614,6 +614,8 @@ pub(super) struct Remote {
|
||||
/// follows (`horizon:menu_extra`).
|
||||
menu_extra: Option<horizon_protocol::menu_extra::Message>,
|
||||
entities: HashMap<i32, Tracked>,
|
||||
/// Complete network riding graph, including invisible plugin seat entities.
|
||||
pub(super) passenger_vehicles: HashMap<u32, u32>,
|
||||
lerps: HashMap<i32, Lerp>,
|
||||
/// The minecarts the server moves with `ClientboundMoveMinecartPacket`
|
||||
/// (`NewMinecartBehavior`), which have no interpolation handler.
|
||||
@@ -1165,6 +1167,7 @@ impl Game<'_> {
|
||||
use_target: None,
|
||||
menu_extra: None,
|
||||
entities: HashMap::new(),
|
||||
passenger_vehicles: HashMap::new(),
|
||||
lerps: HashMap::new(),
|
||||
cart_lerps: HashMap::new(),
|
||||
stepped: HashMap::new(),
|
||||
@@ -1388,6 +1391,7 @@ impl Game<'_> {
|
||||
use_target: None,
|
||||
menu_extra: None,
|
||||
entities: HashMap::new(),
|
||||
passenger_vehicles: HashMap::new(),
|
||||
lerps: HashMap::new(),
|
||||
cart_lerps: HashMap::new(),
|
||||
stepped: HashMap::new(),
|
||||
@@ -2602,6 +2606,7 @@ impl Game<'_> {
|
||||
self.pickups.clear();
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.entities.clear();
|
||||
r.passenger_vehicles.clear();
|
||||
r.lerps.clear();
|
||||
r.cart_lerps.clear();
|
||||
r.stepped.clear();
|
||||
@@ -3360,6 +3365,8 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.glowing.remove(&id);
|
||||
r.entities.remove(&id);
|
||||
r.passenger_vehicles.remove(&(id as u32));
|
||||
r.passenger_vehicles.retain(|_, v| *v != id as u32);
|
||||
r.lerps.remove(&id);
|
||||
r.cart_lerps.remove(&id);
|
||||
r.stepped.remove(&id);
|
||||
@@ -6366,6 +6373,25 @@ impl Game<'_> {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.last_teleport_sequence = r.sequence;
|
||||
}
|
||||
// handleMovePlayer: a passenger keeps its own position and look (its vehicle
|
||||
// carries it), so a vehicle a datapack teleports every tick doesn't snap the
|
||||
// rider's view; the teleport is still accepted, with where the player is.
|
||||
if self.riding.is_some()
|
||||
&& let Some(player) = self.player.as_ref()
|
||||
{
|
||||
let at = player.position;
|
||||
self.send(S::AcceptTeleportation(play::AcceptTeleportation {
|
||||
id: p.teleport,
|
||||
x: at.x,
|
||||
y: at.y,
|
||||
z: at.z,
|
||||
yaw: self.yaw,
|
||||
pitch: self.pitch,
|
||||
}));
|
||||
self.stop_remote_mining();
|
||||
self.mining = None;
|
||||
return Ok(());
|
||||
}
|
||||
match self.player.as_mut() {
|
||||
Some(player) => {
|
||||
player.position = position;
|
||||
@@ -6905,6 +6931,14 @@ impl Game<'_> {
|
||||
}
|
||||
// A stand riding the player follows this move (tickPassenger).
|
||||
self.seat_own_stands();
|
||||
// Other players on this player (possibly through a plugin seat) follow this tick too;
|
||||
// the full pass only when something rides this player.
|
||||
if self.remote.as_ref().is_some_and(|r| {
|
||||
let me = r.entity_id as u32;
|
||||
r.passenger_vehicles.values().any(|&v| v == me)
|
||||
}) {
|
||||
self.position_riders();
|
||||
}
|
||||
// The server owns health and hunger.
|
||||
self.apply_remote_health();
|
||||
self.tick_avatar(before, false);
|
||||
@@ -12074,6 +12108,129 @@ mod tests {
|
||||
assert_eq!(game.ui.chat_screen.as_ref().unwrap().input.value, "/tp ");
|
||||
}
|
||||
|
||||
/// The packet riding graph must retain invisible intermediary entities. A custom
|
||||
/// sit plugin can use an area-effect cloud, whose server position stops updating
|
||||
/// while it rides a player, with a player or marker stand as its own passenger.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn plugin_player_stack_follows_network_moves_and_local_carriers() {
|
||||
use super::super::{
|
||||
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use horizon_client::{motion::PlayerMotion, sprint::Keys};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = ExecutionRole::Client;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (client, _server) = horizon_protocol::connection::Pipe::pair();
|
||||
game.connect_local(client, "SeatTest").unwrap();
|
||||
game.remote.as_mut().unwrap().entity_id = 7;
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::ZERO).unwrap());
|
||||
game.spawn_entity(40, "minecraft:player", [2.0, 64.0, 3.0], [0.0; 3]);
|
||||
game.spawn_entity(41, "minecraft:player", [0.0, 64.0, 0.0], [0.0; 3]);
|
||||
game.spawn_entity(42, "minecraft:area_effect_cloud", [0.0; 3], [0.0; 3]);
|
||||
let set = |g: &mut Game<'_>, vehicle, passengers: Vec<i32>| {
|
||||
g.handle_packet(C::SetPassengers(play::SetPassengers {
|
||||
vehicle,
|
||||
passengers,
|
||||
}))
|
||||
.unwrap();
|
||||
};
|
||||
set(&mut game, 40, vec![42]);
|
||||
set(&mut game, 42, vec![7, 41]);
|
||||
let close = |at: Vec3, x: f64, y: f64, z: f64| {
|
||||
assert!(
|
||||
(at.x - x).abs() < 1e-5 && (at.y - y).abs() < 1e-5 && (at.z - z).abs() < 1e-5,
|
||||
"position {at:?}, expected [{x}, {y}, {z}]"
|
||||
);
|
||||
};
|
||||
assert!(game.remote_ride(Keys::default(), 0).unwrap());
|
||||
close(game.player.as_ref().unwrap().position, 2.0, 65.7, 3.0);
|
||||
close(
|
||||
game.mobs.iter().find(|m| m.id == 41).unwrap().position,
|
||||
2.0,
|
||||
65.7,
|
||||
3.0,
|
||||
);
|
||||
game.handle_packet(C::EntityPositionSync(play::EntityPositionSync {
|
||||
id: 40,
|
||||
path: play::PositionPath::Linear([12.0, 67.0, -4.0]),
|
||||
yaw: 45.0,
|
||||
pitch: 0.0,
|
||||
on_ground: true,
|
||||
}))
|
||||
.unwrap();
|
||||
for _ in 0..4 {
|
||||
game.tick_remote_entities(true);
|
||||
game.remote_ride(Keys::default(), 0).unwrap();
|
||||
}
|
||||
close(game.player.as_ref().unwrap().position, 12.0, 68.7, -4.0);
|
||||
close(
|
||||
game.mobs.iter().find(|m| m.id == 41).unwrap().position,
|
||||
12.0,
|
||||
68.7,
|
||||
-4.0,
|
||||
);
|
||||
// The intermediary's packet position must remain unchanged for relative decoding.
|
||||
assert_eq!(
|
||||
game.remote.as_ref().unwrap().tracked(42).unwrap().1,
|
||||
[0.0; 3]
|
||||
);
|
||||
// A remote player riding the local player must follow our own movement too.
|
||||
set(&mut game, 42, vec![41]);
|
||||
set(&mut game, 7, vec![42]);
|
||||
game.player.as_mut().unwrap().position = Vec3::new(20.0, 70.0, 25.0);
|
||||
game.position_riders();
|
||||
close(
|
||||
game.mobs.iter().find(|m| m.id == 41).unwrap().position,
|
||||
20.0,
|
||||
71.7,
|
||||
25.0,
|
||||
);
|
||||
// A direct stack has no intermediary's 0.5-height offset.
|
||||
set(&mut game, 40, vec![7, 41]);
|
||||
game.remote_ride(Keys::default(), 0).unwrap();
|
||||
close(game.player.as_ref().unwrap().position, 12.0, 68.2, -4.0);
|
||||
close(
|
||||
game.mobs.iter().find(|m| m.id == 41).unwrap().position,
|
||||
12.0,
|
||||
68.2,
|
||||
-4.0,
|
||||
);
|
||||
// Removing the carrier detaches passengers even without a final empty packet.
|
||||
game.remove_entity(40);
|
||||
assert_eq!(game.riding, None);
|
||||
assert_eq!(
|
||||
game.mobs.iter().find(|m| m.id == 41).unwrap().extra.mount,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// A mod's chat goes out only from a joined player, and only what the
|
||||
/// Chat option allows.
|
||||
#[test]
|
||||
|
||||
@@ -157,6 +157,13 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let vehicle_id = vehicle as u32;
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.passenger_vehicles
|
||||
.retain(|_, parent| *parent != vehicle_id);
|
||||
for &passenger in passengers {
|
||||
r.passenger_vehicles.insert(passenger as u32, vehicle_id);
|
||||
}
|
||||
}
|
||||
self.remote_seat_passengers(vehicle_id, passengers);
|
||||
let player_controls = passengers.first().is_some_and(|&id| {
|
||||
self.remote
|
||||
@@ -288,6 +295,9 @@ impl Game<'_> {
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.sprint_input_tick_riding(keys, sprint_window, can_sprint, local)?;
|
||||
}
|
||||
// Settle an invisible seat riding a moving avatar before reading it.
|
||||
// The normal end-of-tick pass alone leaves the local rider a tick behind.
|
||||
self.position_riders();
|
||||
if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
|
||||
// LocalPlayer.aiStep: the jump bar charges while jump is held.
|
||||
let jump = self.charge_mount_jump(i, keys);
|
||||
@@ -362,7 +372,7 @@ impl Game<'_> {
|
||||
self.boats[i] = b;
|
||||
// In front of a mob that shares the boat.
|
||||
Some(self.player_boat_seat(i))
|
||||
} else {
|
||||
} else if self.carts.iter().any(|c| c.id == id) {
|
||||
// Minecart.positionRider runs on the client after the cart tick.
|
||||
// The offset belongs to the cart, so switching vehicles cannot
|
||||
// transfer the previous vehicle's pending turn.
|
||||
@@ -375,18 +385,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// A minecart: the server moves it, the rider sits in it.
|
||||
let cart = self.carts.iter().find(|c| c.id == id).map(|c| {
|
||||
self.carts.iter().find(|c| c.id == id).map(|c| {
|
||||
c.position
|
||||
.add_vector(Vec3::new(0.0, super::minecarts::SEAT, 0.0))
|
||||
})
|
||||
} else if let Some(at) =
|
||||
self.passenger_riding_position(id, None, None, &std::collections::HashMap::new())
|
||||
{
|
||||
let scale = self.player.as_ref().map_or(1.0, |p| {
|
||||
p.dimensions.width / horizon_client::pose::Pose::Standing.dimensions(1.0).width
|
||||
});
|
||||
if cart.is_none() {
|
||||
// Entity.baseTick: a passenger whose vehicle is gone stops
|
||||
// riding it. Nothing with the id is in the level any more (it
|
||||
// died, or the world it rode in was left), so the seat goes.
|
||||
self.riding = None;
|
||||
return Ok(false);
|
||||
}
|
||||
cart
|
||||
Some(Vec3::new(at.x, at.y - f64::from(0.6_f32 * scale), at.z))
|
||||
} else {
|
||||
// Entity.baseTick: a passenger whose vehicle is gone stops
|
||||
// riding it. Nothing with the id is in the level any more (it
|
||||
// died, or the world it rode in was left), so the seat goes.
|
||||
self.riding = None;
|
||||
return Ok(false);
|
||||
};
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
// The tick a rider takes its seat is a teleport (`Entity.moveTo`,
|
||||
@@ -862,3 +877,83 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plugin_seat_tests {
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn local_rider_uses_attachments_and_follows_a_moving_seat_chain() {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{mobs::Mob, motion::PlayerMotion, sprint::Keys};
|
||||
use horizon_core::geometry::Vec3;
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::ZERO).unwrap());
|
||||
let mut stand =
|
||||
horizon_client::entities::ArmorStand::new(42, Vec3::new(2.0, 64.0, 3.0), 0.0);
|
||||
stand.marker = true;
|
||||
game.armor_stands.push(stand);
|
||||
game.riding = Some(42);
|
||||
assert!(game.remote_ride(Keys::default(), 0).unwrap());
|
||||
let close = |actual: f64, expected: f64| {
|
||||
assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}")
|
||||
};
|
||||
// EntityAttachments: zero-height marker minus Avatar's 0.6 attachment.
|
||||
close(game.player.as_ref().unwrap().position.y, 63.4);
|
||||
game.armor_stands[0].marker = false;
|
||||
game.armor_stands[0].small = true;
|
||||
game.remote_ride(Keys::default(), 0).unwrap();
|
||||
close(game.player.as_ref().unwrap().position.y, 64.3875);
|
||||
game.armor_stands[0].small = false;
|
||||
game.remote_ride(Keys::default(), 0).unwrap();
|
||||
close(game.player.as_ref().unwrap().position.y, 65.375);
|
||||
let kind = game.res.player_type().clone().unwrap();
|
||||
game.mobs
|
||||
.push(Mob::new(43, kind, Vec3::new(5.0, 70.0, 6.0), 0.0));
|
||||
game.armor_stands[0].marker = true;
|
||||
game.armor_stands[0].vehicle = Some(43);
|
||||
for at in [Vec3::new(5.0, 70.0, 6.0), Vec3::new(12.0, 72.0, -4.0)] {
|
||||
game.mobs[0].position = at;
|
||||
// No manual position_riders call: local ride must resolve the chain itself.
|
||||
game.remote_ride(Keys::default(), 0).unwrap();
|
||||
let p = game.player.as_ref().unwrap().position;
|
||||
close(p.x, at.x);
|
||||
close(p.z, at.z);
|
||||
close(p.y, at.y + 1.2);
|
||||
}
|
||||
// Direct riding and a scaled avatar share the same attachment calculation.
|
||||
game.riding = Some(43);
|
||||
game.mobs[0].extra.model_scale = 1.5;
|
||||
game.remote_ride(Keys::default(), 0).unwrap();
|
||||
close(game.player.as_ref().unwrap().position.y, 74.1);
|
||||
game.mobs.clear();
|
||||
game.armor_stands.clear();
|
||||
assert!(!game.remote_ride(Keys::default(), 0).unwrap());
|
||||
assert_eq!(game.riding, None);
|
||||
}
|
||||
}
|
||||
@@ -265,10 +265,11 @@ impl Game<'_> {
|
||||
{
|
||||
self.native_player_shield_disable(player, &name, ticks);
|
||||
}
|
||||
let item = self
|
||||
let used = self
|
||||
.using
|
||||
.and_then(|(slot, _)| self.stack_in(slot))
|
||||
.map(|stack| stack.item.clone());
|
||||
.cloned();
|
||||
let item = used.as_ref().map(|stack| stack.item.clone());
|
||||
let mut cancel = false;
|
||||
if let (Some(player), Some(name), Some(item)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone(), item)
|
||||
@@ -277,6 +278,17 @@ impl Game<'_> {
|
||||
}
|
||||
if !cancel {
|
||||
self.shield_cooldown = ticks;
|
||||
// ItemCooldowns.addCooldown(stack, ticks) on the shield's group: a guest is
|
||||
// sent it (ServerItemCooldowns), which keeps the shield down on their screen,
|
||||
// and the 0 that ends it.
|
||||
if let Some(stack) = &used {
|
||||
let default = self
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.use_cooldown.clone());
|
||||
let group = horizon_client::cooldowns::group(stack, default.as_ref());
|
||||
self.add_cooldown(group, ticks as i32);
|
||||
}
|
||||
}
|
||||
self.using = None;
|
||||
if let Some(sound) = &component.disabled_sound {
|
||||
|
||||
@@ -25,6 +25,14 @@ use horizon_core::{
|
||||
/// The key of this window's own player in `Game::kinetic_feedback`.
|
||||
pub(super) const OWN: i32 = i32::MIN;
|
||||
|
||||
/// What a stab reaches along the view: a mob (its index in `mobs`) or
|
||||
/// another player (its index in `stab_players`).
|
||||
#[derive(Clone, Copy)]
|
||||
enum Target {
|
||||
Mob(usize),
|
||||
Player(usize),
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Item.use` of a kinetic weapon: `startUsingItem` with a new
|
||||
/// `recentKineticEnemies`, and `KineticWeapon.makeSound` at the user.
|
||||
@@ -108,8 +116,9 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`.
|
||||
fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec<usize> {
|
||||
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`:
|
||||
/// the mobs and the other players along the view, nearest first.
|
||||
fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec<Target> {
|
||||
// AttackRange.effectiveMinRange and effectiveMaxRange for a player.
|
||||
let (min, max) = if self.creative {
|
||||
(REACH.min_creative, REACH.max_creative)
|
||||
@@ -118,10 +127,18 @@ impl Game<'_> {
|
||||
};
|
||||
let reach = (min, max, REACH.hitbox_margin);
|
||||
let clip = |from, to| self.clip_collider(from, to);
|
||||
let boxes = self.hittable_mob_boxes();
|
||||
let mobs = self.mobs.len();
|
||||
let mut boxes = self.hittable_mob_boxes();
|
||||
boxes.extend((self.stab_players.iter().enumerate()).map(|(k, p)| (mobs + k, p.bounds)));
|
||||
hits_in_reach(&boxes, eye, look, movement, reach, clip)
|
||||
.into_iter()
|
||||
.map(|(i, _)| i)
|
||||
.map(|(i, _)| {
|
||||
if i < mobs {
|
||||
Target::Mob(i)
|
||||
} else {
|
||||
Target::Player(i - mobs)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -132,7 +149,7 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let used = ticks as i32 - kinetic.delay_ticks;
|
||||
let (Some(eye), Some(feet)) = (
|
||||
let (Some(eye), Some(_)) = (
|
||||
self.eye_position(),
|
||||
self.player.as_ref().map(|p| p.position),
|
||||
) else {
|
||||
@@ -149,8 +166,16 @@ impl Game<'_> {
|
||||
let now = self.world.game_time;
|
||||
let actor = self.lan_acting;
|
||||
let mut affected = false;
|
||||
for i in self.stab_targets(eye, look, movement) {
|
||||
let key = (actor, self.mobs[i].id);
|
||||
let mut on_players = Vec::new();
|
||||
for target in self.stab_targets(eye, look, movement) {
|
||||
let (entity, target_motion) = match target {
|
||||
Target::Mob(i) => (self.mobs[i].id, self.mob_speed(i)),
|
||||
Target::Player(k) => {
|
||||
let p = &self.stab_players[k];
|
||||
(p.entity_id as u32, p.movement.scale(20.0))
|
||||
}
|
||||
};
|
||||
let key = (actor, entity);
|
||||
let recent = self
|
||||
.stabbed
|
||||
.get(&key)
|
||||
@@ -159,7 +184,7 @@ impl Game<'_> {
|
||||
continue;
|
||||
}
|
||||
self.stabbed.insert(key, now);
|
||||
let target_speed = look.dot(self.mob_speed(i));
|
||||
let target_speed = look.dot(target_motion);
|
||||
let relative = (attacker_speed - target_speed).max(0.0);
|
||||
// An entity factor of 1 for a player.
|
||||
let dismounts = kinetic.dismount.test(used, attacker_speed, relative, 1.0);
|
||||
@@ -168,20 +193,36 @@ impl Game<'_> {
|
||||
if dismounts || pushes || damages {
|
||||
// The attack damage attribute's base value (1) and the speed.
|
||||
let amount = 1.0 + (relative * f64::from(kinetic.damage_multiplier)).floor() as f32;
|
||||
affected |= self.stab_mob(i, amount, damages, pushes, dismounts);
|
||||
match target {
|
||||
Target::Mob(i) => {
|
||||
affected |= self.stab_mob(i, amount, damages, pushes, dismounts)
|
||||
}
|
||||
Target::Player(k) => on_players
|
||||
.extend(self.queue_player_stab(k, amount, damages, pushes, dismounts)),
|
||||
}
|
||||
}
|
||||
}
|
||||
if affected {
|
||||
// Level.broadcastEntityEvent(player, 2), felt by the player too.
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_kinetic_hits.push(actor);
|
||||
}
|
||||
let who = actor.map_or(OWN, |conn| conn as i32);
|
||||
self.kinetic_hit_feedback(who, &kinetic, [feet.x, feet.y, feet.z]);
|
||||
// SPEAR_MOBS_TRIGGER: the living entities stabbed in this use.
|
||||
let stabbed = self.stabbed.keys().filter(|(who, _)| *who == actor).count();
|
||||
self.trigger_spear_mobs(stabbed);
|
||||
self.spear_hit_feedback(&kinetic);
|
||||
}
|
||||
self.keep_player_stabs(kinetic, true, affected, on_players);
|
||||
}
|
||||
|
||||
/// What follows a charge tick that affected an entity:
|
||||
/// `Level.broadcastEntityEvent(player, 2)`, felt by the player too, and
|
||||
/// SPEAR_MOBS_TRIGGER with the living entities stabbed in this use.
|
||||
pub(super) fn spear_hit_feedback(&mut self, kinetic: &Kinetic) {
|
||||
let Some(feet) = self.player.as_ref().map(|p| p.position) else {
|
||||
return;
|
||||
};
|
||||
let actor = self.lan_acting;
|
||||
if self.audio.capture.is_some() {
|
||||
self.lan_kinetic_hits.push(actor);
|
||||
}
|
||||
let who = actor.map_or(OWN, |conn| conn as i32);
|
||||
self.kinetic_hit_feedback(who, kinetic, [feet.x, feet.y, feet.z]);
|
||||
let stabbed = self.stabbed.keys().filter(|(who, _)| *who == actor).count();
|
||||
self.trigger_spear_mobs(stabbed);
|
||||
}
|
||||
|
||||
/// `Player.stabAttack` on the mob at `index`: whether it was affected.
|
||||
@@ -264,6 +305,22 @@ impl Game<'_> {
|
||||
}
|
||||
// damageStatsAndHearts.
|
||||
let actual = old_health - self.mobs[index].health;
|
||||
self.damage_stats_and_hearts(actual, feet, height);
|
||||
if hurt {
|
||||
self.last_hurt_by_player.insert(id, self.lan_acting);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `Player.damageStatsAndHearts`: the damage dealt statistic, and the
|
||||
/// damage indicator particles over a target that lost more than 2 health
|
||||
/// (`feet` and `height`: the target's).
|
||||
pub(super) fn damage_stats_and_hearts(&mut self, actual: f32, feet: [f64; 3], height: f64) {
|
||||
self.award_custom_stat("damage_dealt", (actual * 10.0).round() as i32);
|
||||
if actual > 2.0 {
|
||||
let indicator = horizon_client::particles::ParticleOptions::simple(
|
||||
@@ -288,15 +345,6 @@ impl Game<'_> {
|
||||
randomization: 0,
|
||||
});
|
||||
}
|
||||
if hurt {
|
||||
self.last_hurt_by_player.insert(id, self.lan_acting);
|
||||
}
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `Player.cannotAttackWithItem` for the held item: a spear needs its
|
||||
@@ -321,8 +369,14 @@ impl Game<'_> {
|
||||
let damage = self.attack_damage();
|
||||
let movement = self.known_player_movement();
|
||||
let mut hit = false;
|
||||
for i in self.stab_targets(eye, look, movement) {
|
||||
hit |= self.stab_mob(i, damage, true, true, false);
|
||||
let mut on_players = Vec::new();
|
||||
for target in self.stab_targets(eye, look, movement) {
|
||||
match target {
|
||||
Target::Mob(i) => hit |= self.stab_mob(i, damage, true, true, false),
|
||||
Target::Player(k) => {
|
||||
on_players.extend(self.queue_player_stab(k, damage, true, true, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Player.onAttack, then postPiercingAttack.
|
||||
self.attack_ticks = 0;
|
||||
@@ -330,6 +384,7 @@ impl Game<'_> {
|
||||
if hit {
|
||||
self.play_at_feet(kinetic.hit_sound);
|
||||
}
|
||||
self.keep_player_stabs(kinetic, false, hit, on_players);
|
||||
// swingAndResetAttackStrength (ServerPlayer): a swing that starts
|
||||
// resets the attack strength.
|
||||
let swings = self.swings_started;
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
//! A player's spear on the other players of a server: the charge
|
||||
//! (`KineticWeapon.damageEntities`) and the jab (`PiercingWeapon.attack`)
|
||||
//! stab every living entity along the view, players too. The stab is worked
|
||||
//! out as the attacker in their turn (`Player.stabAttack`'s damage, magic
|
||||
//! boost and pushes), lands on the victim like a player's melee blow once the
|
||||
//! hub is back (`lan_pvp`), and the attacker's side of a landed stab
|
||||
//! (`attackVisualEffects`, `itemAttackInteraction`, `damageStatsAndHearts`,
|
||||
//! the exhaustion, and the hit's feedback) follows as the attacker again.
|
||||
use super::{
|
||||
Game,
|
||||
lan::LanHost,
|
||||
lan_pvp::{Victim, pvp_scope},
|
||||
};
|
||||
use horizon_client::{health::HurtSource, spear::Kinetic, text::Text};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
|
||||
/// Another player a spear can reach in the acting player's turn, taken from
|
||||
/// the hub before it: `PiercingWeapon.canHitEntity` holds for them.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct StabPlayer {
|
||||
pub(super) conn: u32,
|
||||
pub(super) entity_id: i32,
|
||||
pub(super) bounds: Aabb,
|
||||
/// `getKnownSpeed`: the movement their client reported, per tick.
|
||||
pub(super) movement: Vec3,
|
||||
}
|
||||
|
||||
/// One stab on a player, as the attacker worked it out.
|
||||
pub(super) struct PlayerStab {
|
||||
victim: u32,
|
||||
/// The damage after the enchantments (and the attack strength when the
|
||||
/// spear isn't in use); 0 when the stab deals none.
|
||||
amount: f32,
|
||||
deals_damage: bool,
|
||||
deals_knockback: bool,
|
||||
dismounts: bool,
|
||||
/// `magicBoost > 0`: the enchantments added damage.
|
||||
magic: bool,
|
||||
from: [f64; 3],
|
||||
/// `getKnockback`'s enchanted strength, and the attacker's yaw as a direction.
|
||||
knockback: (f32, f64, f64),
|
||||
disable_blocking: f32,
|
||||
item: Option<Text>,
|
||||
ignite: f32,
|
||||
}
|
||||
|
||||
/// The stabs of one charge tick or one jab on players.
|
||||
pub(super) struct StabBatch {
|
||||
/// The stabbing guest.
|
||||
attacker: u32,
|
||||
kinetic: Kinetic,
|
||||
/// A charge's tick (its hit feedback), else a jab (its hit sound).
|
||||
charge: bool,
|
||||
/// Whether a mob stabbed in the same tick gave the feedback already.
|
||||
felt: bool,
|
||||
stabs: Vec<PlayerStab>,
|
||||
}
|
||||
|
||||
/// What a landed stab did to the victim.
|
||||
struct Landed {
|
||||
hurt: bool,
|
||||
/// The stab's enchantments added damage (the magic crit).
|
||||
magic: bool,
|
||||
/// `stabAttack`'s result: hurt, pushed or dismounted.
|
||||
affected: bool,
|
||||
/// Health lost, for `damageStatsAndHearts`.
|
||||
actual: f32,
|
||||
id: i32,
|
||||
feet: [f64; 3],
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
/// A spear's push on a player as the native hooks name it.
|
||||
const SPEAR_SOURCE: HurtSource<'static> = HurtSource {
|
||||
damage_type: "minecraft:spear",
|
||||
causing: 0,
|
||||
direct: 0,
|
||||
};
|
||||
|
||||
impl Game<'_> {
|
||||
/// The players the guest `actor` could stab now: in their level, placed,
|
||||
/// alive and no spectators (`canBeHitByProjectile`), allowed by
|
||||
/// `canHarmPlayer` (the `pvp` game rule and the victim's team), and not
|
||||
/// on the attacker's vehicle.
|
||||
pub(super) fn stab_players_for(&self, host: &LanHost, actor: u32) -> Vec<StabPlayer> {
|
||||
let board = &self.score.board;
|
||||
let Some(attacker) = host.hub.guest(actor).filter(|_| self.ui.rules.bool("pvp")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let own_team = board.players_team(&attacker.name);
|
||||
let own_vehicle = attacker.vehicle;
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
g.conn != actor
|
||||
&& host.hub.here(g)
|
||||
&& g.placed
|
||||
&& g.loaded
|
||||
&& !g.dead
|
||||
&& !g.spectator()
|
||||
&& (own_vehicle.is_none() || g.vehicle != own_vehicle)
|
||||
&& horizon_client::scoreboard::teams_allow_damage(
|
||||
board.players_team(&g.name),
|
||||
own_team,
|
||||
)
|
||||
})
|
||||
.map(|g| {
|
||||
let [x, y, z] = g.known_movement;
|
||||
StabPlayer {
|
||||
conn: g.conn,
|
||||
entity_id: g.entity_id,
|
||||
bounds: g.bounds(),
|
||||
movement: Vec3::new(x, y, z),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The attacker's side of `Player.stabAttack` on the player `target`
|
||||
/// (an index in `stab_players`), queued to land once the hub is back.
|
||||
pub(super) fn queue_player_stab(
|
||||
&mut self,
|
||||
target: usize,
|
||||
base_damage: f32,
|
||||
deals_damage: bool,
|
||||
deals_knockback: bool,
|
||||
dismounts: bool,
|
||||
) -> Option<PlayerStab> {
|
||||
let victim = self.stab_players.get(target)?.conn;
|
||||
let from = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map(|p| [p.position.x, p.position.y, p.position.z])?;
|
||||
let magic_boost = self.enchant_damage_bonus("minecraft:player");
|
||||
let (base_damage, magic_boost) = if self.using.is_none() {
|
||||
let scale = self.attack_strength_scale();
|
||||
(
|
||||
base_damage * (0.2 + scale * scale * 0.8),
|
||||
magic_boost * scale,
|
||||
)
|
||||
} else {
|
||||
(base_damage, magic_boost)
|
||||
};
|
||||
let amount = if deals_damage {
|
||||
base_damage + magic_boost
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let (xd, zd) = self.extra_knockback_direction();
|
||||
let strength = if deals_knockback {
|
||||
// causeExtraKnockback: the attacker stops sprinting.
|
||||
let (strength, _) = self.extra_knockback("minecraft:player", 0.0);
|
||||
self.stop_sprint_after_extra_knockback();
|
||||
strength
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// LivingEntity.getSecondsToDisableBlocking: none while an item is in use.
|
||||
let disable_blocking = if self.using.is_some() {
|
||||
0.0
|
||||
} else {
|
||||
self.attack_item_info()
|
||||
.map_or(0.0, |i| i.disable_blocking_seconds)
|
||||
};
|
||||
Some(PlayerStab {
|
||||
victim,
|
||||
amount,
|
||||
deals_damage,
|
||||
deals_knockback,
|
||||
dismounts,
|
||||
magic: magic_boost > 0.0,
|
||||
from,
|
||||
knockback: (strength, xd, zd),
|
||||
disable_blocking,
|
||||
item: self.main_hand_custom_name(),
|
||||
ignite: self.weapon_ignite_seconds(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Keeps a guest's charge tick's or jab's stabs on players: they land
|
||||
/// once the guest's turn ends and the hub is back (`land_player_stabs`).
|
||||
pub(super) fn keep_player_stabs(
|
||||
&mut self,
|
||||
kinetic: Kinetic,
|
||||
charge: bool,
|
||||
felt: bool,
|
||||
stabs: Vec<PlayerStab>,
|
||||
) {
|
||||
let Some(attacker) = self.lan_acting.filter(|_| !stabs.is_empty()) else {
|
||||
return;
|
||||
};
|
||||
self.player_stabs.push(StabBatch {
|
||||
attacker,
|
||||
kinetic,
|
||||
charge,
|
||||
felt,
|
||||
stabs,
|
||||
});
|
||||
}
|
||||
|
||||
/// The queued stabs on players landing, each then followed by the
|
||||
/// attacker's side of a stab that affected its victim.
|
||||
pub(super) fn land_player_stabs(&mut self, host: &mut LanHost) {
|
||||
for batch in std::mem::take(&mut self.player_stabs) {
|
||||
let landed: Vec<Landed> = (batch.stabs.iter())
|
||||
.filter_map(|s| self.land_player_stab(host, batch.attacker, s))
|
||||
.collect();
|
||||
self.as_guest(host, batch.attacker, |g| {
|
||||
g.after_player_stabs(&batch, &landed)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// `stabAttack`'s hurt, pushes and dismount on the victim, then
|
||||
/// `awardKillScore` and the victim's thorns. None when the victim has gone.
|
||||
fn land_player_stab(
|
||||
&mut self,
|
||||
host: &mut LanHost,
|
||||
attacker: u32,
|
||||
s: &PlayerStab,
|
||||
) -> Option<Landed> {
|
||||
let victim = Victim::Guest(s.victim);
|
||||
let g = host.hub.guest(s.victim).filter(|g| !g.dead)?;
|
||||
let id = g.entity_id;
|
||||
let (attacker_id, attacker_name) = host
|
||||
.hub
|
||||
.guest(attacker)
|
||||
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))?;
|
||||
let hit = self.pvp_hit_of(host, Some(attacker), victim, "melee");
|
||||
let land = |g: &mut Self| {
|
||||
let _attacker = g.knockback_scope(attacker_id);
|
||||
g.combat.player_attacker = Some((s.from, s.disable_blocking));
|
||||
g.note_player_attacker(
|
||||
"minecraft:spear",
|
||||
attacker_id as u32,
|
||||
attacker_name.clone(),
|
||||
s.item.clone(),
|
||||
);
|
||||
let health = |g: &Self| g.player.as_ref().map_or(0.0, |p| p.health.health());
|
||||
let old_health = health(g);
|
||||
let hurt = s.deals_damage && {
|
||||
let _hit = pvp_scope(hit);
|
||||
g.mob_hits_player(s.amount, s.from, "minecraft:spear")
|
||||
};
|
||||
g.combat.player_attacker = None;
|
||||
// causeExtraKnockback: a push of 0.4, then the weapon's Knockback,
|
||||
// whether or not the hurt went through.
|
||||
let (strength, xd, zd) = s.knockback;
|
||||
if s.deals_knockback {
|
||||
g.knock_back_player(0.4, xd, zd, SPEAR_SOURCE);
|
||||
if strength > 0.0 {
|
||||
g.knock_back_player(f64::from(strength), xd, zd, SPEAR_SOURCE);
|
||||
}
|
||||
}
|
||||
if hurt
|
||||
&& s.ignite > 0.0
|
||||
&& let Some(p) = g.player.as_mut()
|
||||
{
|
||||
let _ = p.ignite_for_ticks((s.ignite * 20.0) as i32);
|
||||
}
|
||||
let dismounted = s.dismounts && g.riding.is_some();
|
||||
if dismounted {
|
||||
g.stop_riding();
|
||||
}
|
||||
let thorns = if hurt { g.thorns_damage() } else { Vec::new() };
|
||||
let (feet, width, height) = g.player.as_ref().map_or(([0.0; 3], 0.6, 1.8), |p| {
|
||||
(
|
||||
[p.position.x, p.position.y, p.position.z],
|
||||
f64::from(p.dimensions.width),
|
||||
f64::from(p.dimensions.height),
|
||||
)
|
||||
});
|
||||
let landed = Landed {
|
||||
hurt,
|
||||
magic: s.magic,
|
||||
affected: hurt || s.deals_knockback || dismounted,
|
||||
actual: old_health - health(g),
|
||||
id,
|
||||
feet,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
(landed, thorns)
|
||||
};
|
||||
let (landed, thorns) = self.guest_struck_by(host, s.victim, land)?;
|
||||
self.pvp_aftermath(host, victim, Some(attacker), landed.hurt, thorns);
|
||||
Some(landed)
|
||||
}
|
||||
|
||||
/// The attacker's side of the stabs that affected their victims
|
||||
/// (`stabAttack` past its hurt), then the charge's hit feedback
|
||||
/// (`broadcastEntityEvent(player, 2)`) or the jab's hit sound when no
|
||||
/// mob's stab gave it already.
|
||||
fn after_player_stabs(&mut self, batch: &StabBatch, landed: &[Landed]) {
|
||||
let mut affected = false;
|
||||
for l in landed.iter().filter(|l| l.affected) {
|
||||
affected = true;
|
||||
// attackVisualEffects: no attack sound, the enchantments' magic crit.
|
||||
if l.magic {
|
||||
self.critical_hit(self.lan_acting, l.id, l.feet, l.width, l.height, true);
|
||||
}
|
||||
// itemAttackInteraction: hurtEnemy (statistic, wear), and the
|
||||
// post-attack enchantments when the target was hurt.
|
||||
if let Some(stack) = self.held().cloned() {
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
if l.hurt {
|
||||
self.post_attack_bursts(&stack, "minecraft:player");
|
||||
}
|
||||
self.weapon_wear(&stack);
|
||||
}
|
||||
self.damage_stats_and_hearts(l.actual, l.feet, l.height);
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& !self.creative
|
||||
{
|
||||
p.cause_food_exhaustion(0.1);
|
||||
}
|
||||
}
|
||||
if !affected || batch.felt {
|
||||
return;
|
||||
}
|
||||
if batch.charge {
|
||||
self.spear_hit_feedback(&batch.kinetic);
|
||||
} else {
|
||||
self.play_at_feet(batch.kinetic.hit_sound);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! `SulfurCube.mobInteract`'s last step, `Bucketable.bucketMobPickup`: an
|
||||
//! empty bucket picks up an adult, unprimed sulfur cube, and the held stack
|
||||
//! becomes `ItemUtils.createFilledResult(held, player, sulfur_cube_bucket)`
|
||||
//! (tools/test_sulfur_bucket_parity.py measures the same on vanilla 26.3).
|
||||
use super::*;
|
||||
use horizon_client::{chunk::Chunk, mobs::Mob};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
|
||||
game.screen = Screen::Playing;
|
||||
game.selected = 0;
|
||||
game
|
||||
}
|
||||
|
||||
/// A fresh mob of `kind` in front of the player, alone; returns its id.
|
||||
fn mob(game: &mut Game<'_>, kind: &str, edit: impl FnOnce(&mut Mob)) -> u32 {
|
||||
game.mobs.clear();
|
||||
let kind = game.res.mob_types().get(kind).unwrap().clone();
|
||||
let mut m = Mob::new(42, kind, Vec3::new(0.5, 64.0, 2.5), 0.0);
|
||||
m.no_ai = true;
|
||||
edit(&mut m);
|
||||
game.mobs.push(m);
|
||||
42
|
||||
}
|
||||
|
||||
/// Clicks mob `id` holding `count` of `item` in `mode`: whether the click
|
||||
/// was used, whether the mob is still there, and the inventory after it.
|
||||
fn click(
|
||||
game: &mut Game<'_>,
|
||||
id: u32,
|
||||
mode: i32,
|
||||
item: &str,
|
||||
count: u32,
|
||||
) -> (bool, bool, Vec<(String, u32)>) {
|
||||
game.set_mode_id(mode);
|
||||
game.inventory.iter_mut().for_each(|s| *s = None);
|
||||
game.inventory[0] = Some(ItemStack::new(item, count));
|
||||
let used = game
|
||||
.interact_entity(Some(InteractTarget {
|
||||
id: id as i32,
|
||||
location: [0.0, 1.0, 0.0],
|
||||
}))
|
||||
.unwrap();
|
||||
let there = game.mobs.iter().any(|m| m.id == id && !m.removed);
|
||||
let held = game
|
||||
.inventory
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|s| (s.item.to_string(), s.count))
|
||||
.collect();
|
||||
(used, there, held)
|
||||
}
|
||||
|
||||
fn owned(items: &[(&str, u32)]) -> Vec<(String, u32)> {
|
||||
items.iter().map(|(i, n)| (i.to_string(), *n)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_empty_bucket_picks_up_an_adult_sulfur_cube() {
|
||||
let mut game = game();
|
||||
// Survival, two buckets: one is filled, the other stays in the hand.
|
||||
let id = mob(&mut game, "minecraft:sulfur_cube", |_| {});
|
||||
assert_eq!(
|
||||
click(&mut game, id, 0, "minecraft:bucket", 2),
|
||||
(
|
||||
true,
|
||||
false,
|
||||
owned(&[("minecraft:bucket", 1), ("minecraft:sulfur_cube_bucket", 1)])
|
||||
)
|
||||
);
|
||||
// The last bucket becomes the filled one.
|
||||
let id = mob(&mut game, "minecraft:sulfur_cube", |_| {});
|
||||
assert_eq!(
|
||||
click(&mut game, id, 0, "minecraft:bucket", 1),
|
||||
(true, false, owned(&[("minecraft:sulfur_cube_bucket", 1)]))
|
||||
);
|
||||
// Creative keeps its bucket and gets the filled one.
|
||||
let id = mob(&mut game, "minecraft:sulfur_cube", |_| {});
|
||||
assert_eq!(
|
||||
click(&mut game, id, 1, "minecraft:bucket", 1),
|
||||
(
|
||||
true,
|
||||
false,
|
||||
owned(&[("minecraft:bucket", 1), ("minecraft:sulfur_cube_bucket", 1)])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn no_pickup_without_an_empty_bucket_or_of_a_baby_or_primed_cube() {
|
||||
let mut game = game();
|
||||
fn plain(_: &mut Mob) {}
|
||||
fn baby(m: &mut Mob) {
|
||||
m.extra.age = -24000;
|
||||
}
|
||||
fn primed(m: &mut Mob) {
|
||||
m.extra.body = Some("minecraft:tnt".into());
|
||||
assert!(horizon_client::mobs::sulfur_cube::prime(m, false));
|
||||
}
|
||||
let cases: [(&str, fn(&mut Mob), &str); 3] = [
|
||||
("water bucket", plain, "minecraft:water_bucket"),
|
||||
("baby", baby, "minecraft:bucket"),
|
||||
("primed", primed, "minecraft:bucket"),
|
||||
];
|
||||
for (case, edit, item) in cases {
|
||||
let id = mob(&mut game, "minecraft:sulfur_cube", edit);
|
||||
let (_, there, held) = click(&mut game, id, 0, item, 1);
|
||||
assert!(there, "{case}: the cube stays");
|
||||
assert_eq!(held, owned(&[(item, 1)]), "{case}: the hand is unchanged");
|
||||
}
|
||||
// A cod still takes a water bucket.
|
||||
let id = mob(&mut game, "minecraft:cod", |_| {});
|
||||
assert_eq!(
|
||||
click(&mut game, id, 0, "minecraft:water_bucket", 1),
|
||||
(true, false, owned(&[("minecraft:cod_bucket", 1)]))
|
||||
);
|
||||
}
|
||||
|
||||
/// The filled bucket the click left in the inventory.
|
||||
fn filled(game: &Game<'_>, item: &str) -> ItemStack {
|
||||
game.inventory
|
||||
.iter()
|
||||
.flatten()
|
||||
.find(|s| &*s.item == item)
|
||||
.cloned()
|
||||
.unwrap()
|
||||
}
|
||||
fn component(stack: &ItemStack, id: &str) -> Option<Tag> {
|
||||
stack.extra.as_ref().and_then(|d| d.other(id)).cloned()
|
||||
}
|
||||
fn field(tag: &Option<Tag>, key: &str) -> Option<Tag> {
|
||||
match tag {
|
||||
Some(Tag::Compound(c)) => c.get(&key.into()).cloned(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Added controls (after the baseline): `saveToBucketTag` writes the live
|
||||
/// cube's age, age lock and body, and nothing for a cube with no body; a
|
||||
/// cod's bucket keeps its own data.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn the_sulfur_bucket_carries_the_live_cube_s_age_lock_and_body() {
|
||||
let mut game = game();
|
||||
let id = mob(&mut game, "minecraft:sulfur_cube", |m| {
|
||||
m.extra.age = 1200;
|
||||
m.extra.age_locked = true;
|
||||
m.extra.body = Some("minecraft:oak_planks".into());
|
||||
m.health = 3.0;
|
||||
});
|
||||
assert!(click(&mut game, id, 0, "minecraft:bucket", 1).0);
|
||||
let bucket = filled(&game, "minecraft:sulfur_cube_bucket");
|
||||
let data = component(&bucket, "minecraft:bucket_entity_data");
|
||||
assert_eq!(field(&data, "age"), Some(Tag::Int(1200)));
|
||||
assert_eq!(field(&data, "age_locked"), Some(Tag::Byte(1)));
|
||||
assert_eq!(field(&data, "Health"), Some(Tag::Float(3.0f32.to_bits())));
|
||||
assert_eq!(field(&data, "NoAI"), Some(Tag::Byte(1)));
|
||||
assert_eq!(
|
||||
field(&component(&bucket, "minecraft:sulfur_cube_content"), "id"),
|
||||
Some(Tag::String("minecraft:oak_planks".into()))
|
||||
);
|
||||
// No body, no content; an unlocked adult.
|
||||
let id = mob(&mut game, "minecraft:sulfur_cube", |_| {});
|
||||
assert!(click(&mut game, id, 0, "minecraft:bucket", 1).0);
|
||||
let bucket = filled(&game, "minecraft:sulfur_cube_bucket");
|
||||
let data = component(&bucket, "minecraft:bucket_entity_data");
|
||||
assert_eq!(
|
||||
(field(&data, "age"), field(&data, "age_locked")),
|
||||
(Some(Tag::Int(0)), Some(Tag::Byte(0)))
|
||||
);
|
||||
assert_eq!(component(&bucket, "minecraft:sulfur_cube_content"), None);
|
||||
// A cod's bucket has no sulfur fields.
|
||||
let id = mob(&mut game, "minecraft:cod", |_| {});
|
||||
assert!(click(&mut game, id, 0, "minecraft:water_bucket", 1).0);
|
||||
let bucket = filled(&game, "minecraft:cod_bucket");
|
||||
let data = component(&bucket, "minecraft:bucket_entity_data");
|
||||
assert_eq!(
|
||||
(field(&data, "age"), field(&data, "age_locked")),
|
||||
(None, None)
|
||||
);
|
||||
assert_eq!(component(&bucket, "minecraft:sulfur_cube_content"), None);
|
||||
}
|
||||
@@ -172,8 +172,43 @@ impl Game<'_> {
|
||||
self.post_game_event("minecraft:entity_interact", [at.x, at.y, at.z], ctx);
|
||||
return true;
|
||||
}
|
||||
if item == "minecraft:bucket" {
|
||||
self.bucket_sulfur_cube(index);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// `Bucketable.bucketMobPickup` for a sulfur cube
|
||||
/// (`SulfurCube.canBePickedUpWithBucket`: an empty bucket), as the water
|
||||
/// bucket's mobs are picked up in `interact_mob`.
|
||||
fn bucket_sulfur_cube(&mut self, index: usize) {
|
||||
let held = self.held().cloned();
|
||||
let m = &self.mobs[index];
|
||||
self.audio.play(
|
||||
"minecraft:item.bucket.fill_sulfur_cube",
|
||||
Some(m.center()),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
// getBucketItemStack, then saveToBucketTag.
|
||||
let mut filled = ItemStack::new("minecraft:sulfur_cube_bucket", 1);
|
||||
filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components(m)));
|
||||
self.trigger_item("minecraft:filled_bucket", &filled);
|
||||
// PlayerInteractTrigger: the bucket as it was, and the cube picked up.
|
||||
if let Some(stack) = &held {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
|
||||
}
|
||||
let id = self.mobs[index].id;
|
||||
// Leashable.dropLeash, then discard.
|
||||
self.break_leash(id, true, false);
|
||||
self.mobs[index].removed = true;
|
||||
// ItemUtils.createFilledResult.
|
||||
self.filled_result(filled);
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_bucket_entity(player, &name, id as i32, "minecraft:sulfur_cube");
|
||||
}
|
||||
}
|
||||
/// `SulfurCube.primeWhenOnPoweredPosition`: an explosive cube standing in
|
||||
/// or next to redstone power lights its fuse (`getBestOwnOrNeighbourSignal`;
|
||||
/// no block has an own signal, so the neighbour signal is all there is).
|
||||
|
||||
@@ -86,6 +86,80 @@ impl TestDef {
|
||||
}
|
||||
}
|
||||
|
||||
/// `GameTestInstance`'s codec, as the game reads a `test_instance` file.
|
||||
fn parse_test_def(json: &serde_json::Value) -> Option<TestDef> {
|
||||
let string = |key: &str| json.get(key).and_then(|v| v.as_str());
|
||||
let structure = string("structure")?.to_owned();
|
||||
let environment = string("environment")?.to_owned();
|
||||
let dimension = string("dimension").unwrap_or("minecraft:overworld");
|
||||
let function = string("function");
|
||||
// `ExtraCodecs.POSITIVE_INT`, 1 when absent.
|
||||
let positive = |key: &str| {
|
||||
json.get(key)
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(1)
|
||||
.clamp(1, i64::from(i32::MAX)) as i32
|
||||
};
|
||||
// GameTestInstance.describe: the type, the function (function tests
|
||||
// only), then the structure, batch and dimension.
|
||||
let block_based = string("type") == Some("minecraft:block_based");
|
||||
let mut description = Text::translate(
|
||||
"test_instance.description.type",
|
||||
&[Text::translate(
|
||||
if block_based {
|
||||
"test_instance.type.block_based"
|
||||
} else {
|
||||
"test_instance.type.function"
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.with_color(0x5555FF)],
|
||||
)
|
||||
.append(Text::literal("\n"));
|
||||
if !block_based && let Some(function) = function {
|
||||
description = description.append(row("test_instance.description.function", function));
|
||||
}
|
||||
description = description
|
||||
.append(row("test_instance.description.structure", &structure))
|
||||
.append(row("test_instance.description.batch", &environment))
|
||||
.append(row("test_instance.description.dimension", dimension));
|
||||
Some(TestDef {
|
||||
structure,
|
||||
rotation: string("rotation")
|
||||
.and_then(Rotation::from_name)
|
||||
.unwrap_or(Rotation::None),
|
||||
padding: json
|
||||
.get("padding")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
.clamp(0, 128) as i32,
|
||||
required: json
|
||||
.get("required")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true),
|
||||
sky_access: json
|
||||
.get("sky_access")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
block_based,
|
||||
function: function.map(str::to_owned),
|
||||
environment: environment.clone(),
|
||||
dimension: dimension.to_owned(),
|
||||
max_ticks: json
|
||||
.get("max_ticks")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(1)
|
||||
.clamp(1, i64::from(i32::MAX)) as i32,
|
||||
setup_ticks: json
|
||||
.get("setup_ticks")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
.clamp(0, i64::from(i32::MAX)) as i32,
|
||||
max_attempts: positive("max_attempts"),
|
||||
required_successes: positive("required_successes"),
|
||||
description,
|
||||
})
|
||||
}
|
||||
/// `GameTestInstance.descriptionRow`: the row's value in blue, then a line break.
|
||||
fn row(key: &str, value: &str) -> Text {
|
||||
Text::translate(key, &[Text::literal(value).with_color(0x5555FF)]).append(Text::literal("\n"))
|
||||
@@ -102,6 +176,10 @@ impl Game<'_> {
|
||||
if let Some(known) = self.test_defs.borrow().get(id) {
|
||||
return known.clone();
|
||||
}
|
||||
// A world's test instances are the ones it opened with.
|
||||
if self.test_defs_snapshot {
|
||||
return None;
|
||||
}
|
||||
let def = self.read_test_def(id);
|
||||
self.test_defs
|
||||
.borrow_mut()
|
||||
@@ -109,6 +187,50 @@ impl Game<'_> {
|
||||
def
|
||||
}
|
||||
|
||||
/// `Registries.TEST_INSTANCE` as a world loads it: the built-in pack's
|
||||
/// `test_instance` files, then those of the packs the world starts with
|
||||
/// enabled, later packs overriding earlier ones. The set stays as it is
|
||||
/// until the next world opens; `/reload` and `/datapack` do not change it.
|
||||
/// It replaces the previous world's definitions and selector ids.
|
||||
pub(super) fn snapshot_test_defs(&mut self) {
|
||||
let stack = super::functions::pack_stack(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
);
|
||||
let mut defs = std::collections::HashMap::new();
|
||||
for pack in stack.builtin.iter().chain(stack.enabled_packs()) {
|
||||
for file in pack.list_folder("test_instance") {
|
||||
let Some(id) = file
|
||||
.strip_prefix("data/")
|
||||
.and_then(|r| r.split_once("/test_instance/"))
|
||||
.and_then(|(ns, path)| Some(format!("{ns}:{}", path.strip_suffix(".json")?)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(json) = pack
|
||||
.read(&file)
|
||||
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(def) = parse_test_def(&json) {
|
||||
defs.insert(id, Some(def));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<String> = defs.keys().cloned().collect();
|
||||
ids.sort();
|
||||
self.ui
|
||||
.registries
|
||||
.by_registry
|
||||
.entry("minecraft:test_instance".into())
|
||||
.or_default()
|
||||
.ids = ids;
|
||||
*self.test_defs.borrow_mut() = defs;
|
||||
self.test_defs_snapshot = true;
|
||||
}
|
||||
fn read_test_def(&self, id: &str) -> Option<TestDef> {
|
||||
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let file = self
|
||||
@@ -119,77 +241,7 @@ impl Game<'_> {
|
||||
.join("test_instance")
|
||||
.join(format!("{path}.json"));
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(file).ok()?).ok()?;
|
||||
let string = |key: &str| json.get(key).and_then(|v| v.as_str());
|
||||
let structure = string("structure")?.to_owned();
|
||||
let environment = string("environment")?.to_owned();
|
||||
let dimension = string("dimension").unwrap_or("minecraft:overworld");
|
||||
let function = string("function");
|
||||
// `ExtraCodecs.POSITIVE_INT`, 1 when absent.
|
||||
let positive = |key: &str| {
|
||||
json.get(key)
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(1)
|
||||
.clamp(1, i64::from(i32::MAX)) as i32
|
||||
};
|
||||
// GameTestInstance.describe: the type, the function (function tests
|
||||
// only), then the structure, batch and dimension.
|
||||
let block_based = string("type") == Some("minecraft:block_based");
|
||||
let mut description = Text::translate(
|
||||
"test_instance.description.type",
|
||||
&[Text::translate(
|
||||
if block_based {
|
||||
"test_instance.type.block_based"
|
||||
} else {
|
||||
"test_instance.type.function"
|
||||
},
|
||||
&[],
|
||||
)
|
||||
.with_color(0x5555FF)],
|
||||
)
|
||||
.append(Text::literal("\n"));
|
||||
if !block_based && let Some(function) = function {
|
||||
description = description.append(row("test_instance.description.function", function));
|
||||
}
|
||||
description = description
|
||||
.append(row("test_instance.description.structure", &structure))
|
||||
.append(row("test_instance.description.batch", &environment))
|
||||
.append(row("test_instance.description.dimension", dimension));
|
||||
Some(TestDef {
|
||||
structure,
|
||||
rotation: string("rotation")
|
||||
.and_then(Rotation::from_name)
|
||||
.unwrap_or(Rotation::None),
|
||||
padding: json
|
||||
.get("padding")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
.clamp(0, 128) as i32,
|
||||
required: json
|
||||
.get("required")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true),
|
||||
sky_access: json
|
||||
.get("sky_access")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
block_based,
|
||||
function: function.map(str::to_owned),
|
||||
environment: environment.clone(),
|
||||
dimension: dimension.to_owned(),
|
||||
max_ticks: json
|
||||
.get("max_ticks")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(1)
|
||||
.clamp(1, i64::from(i32::MAX)) as i32,
|
||||
setup_ticks: json
|
||||
.get("setup_ticks")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0)
|
||||
.clamp(0, i64::from(i32::MAX)) as i32,
|
||||
max_attempts: positive("max_attempts"),
|
||||
required_successes: positive("required_successes"),
|
||||
description,
|
||||
})
|
||||
parse_test_def(&json)
|
||||
}
|
||||
|
||||
pub(super) fn test_instance_at(&self, pos: Pos) -> Option<&TestInstanceBlock> {
|
||||
|
||||
@@ -122,6 +122,14 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A new world opened without the Create screen still gets the game folder's packs.
|
||||
if create.is_none() {
|
||||
let game_dir = std::env::current_dir()?;
|
||||
let world = game_dir.join("saves").join(folder);
|
||||
if let Err(e) = horizon_client::datapack::install_new_world_packs(&game_dir, &world) {
|
||||
log::warn!("Horizon: data packs for {folder}: {e}");
|
||||
}
|
||||
}
|
||||
// IntegratedServer.initServer's line, which other launchers read in `latest.log` to
|
||||
// offer the world again.
|
||||
horizon_client::logging::write_as(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Test instances come from the data packs the world enables
|
||||
//! (`Registries.TEST_INSTANCE` is loaded from them), not only from the
|
||||
//! game's own data: a definition only a world pack has is found and runs
|
||||
//! (tools/test_world_test_def_parity.py measures the same on vanilla 26.3).
|
||||
use super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A world folder with one data pack holding `h275:broken`.
|
||||
fn world_with_pack(tag: &str) -> std::path::PathBuf {
|
||||
let world = std::env::temp_dir().join(format!("h275-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
let pack = world.join("datapacks/fixture");
|
||||
let tests = pack.join("data/h275/test_instance");
|
||||
std::fs::create_dir_all(&tests).unwrap();
|
||||
std::fs::write(
|
||||
pack.join("pack.mcmeta"),
|
||||
r#"{"pack":{"description":"H275 fixture","pack_format":100}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
tests.join("broken.json"),
|
||||
r#"{"type":"minecraft:block_based","structure":"minecraft:empty","environment":"minecraft:default","max_ticks":20,"required":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
world
|
||||
}
|
||||
|
||||
fn game(world: &std::path::Path) -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world_root = world.to_path_buf();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_test_instance_from_an_enabled_world_pack_is_found_and_runs() {
|
||||
let world = world_with_pack("found");
|
||||
let mut game = game(&world);
|
||||
// The world opens: its state, registries and test instances.
|
||||
game.load_world_state();
|
||||
game.ensure_datapacks();
|
||||
let enabled = &game.ui.registries.datapacks_enabled;
|
||||
assert!(
|
||||
enabled.iter().any(|id| id == "file/fixture"),
|
||||
"the world's pack is enabled: {enabled:?}"
|
||||
);
|
||||
let found = game.test_def("h275:broken").expect("the world pack's test");
|
||||
assert!(found.block_based && found.required, "its own settings");
|
||||
assert!(
|
||||
game.test_def("minecraft:always_pass").is_some(),
|
||||
"the built-in one"
|
||||
);
|
||||
assert!(game.test_def("h275:missing").is_none(), "no pack has it");
|
||||
// `/test run` places and starts it.
|
||||
for cx in -1..=2 {
|
||||
for cz in -1..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
|
||||
}
|
||||
}
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(24.5, 100.0, 8.5))
|
||||
.unwrap(),
|
||||
);
|
||||
game.chat_capture = Some(Vec::new());
|
||||
assert!(game.run_command("test run h275:broken"));
|
||||
let said: Vec<String> = game
|
||||
.chat_capture
|
||||
.take()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|t| t.plain())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
said.first().map(String::as_str),
|
||||
Some("Running 1 test(s)..."),
|
||||
"{said:?}"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
}
|
||||
|
||||
/// A world folder with one pack holding these `test_instance` files.
|
||||
fn world_with(tag: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
|
||||
let world = std::env::temp_dir().join(format!("h275-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&world);
|
||||
let pack = world.join("datapacks/fixture");
|
||||
std::fs::create_dir_all(&pack).unwrap();
|
||||
std::fs::write(
|
||||
pack.join("pack.mcmeta"),
|
||||
r#"{"pack":{"description":"H275 fixture","pack_format":100}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for (id, body) in files {
|
||||
let (ns, path) = id.split_once(':').unwrap();
|
||||
let file = pack.join(format!("data/{ns}/test_instance/{path}.json"));
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, body).unwrap();
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
const FAILING: &str = r#"{"type":"minecraft:block_based","structure":"minecraft:empty","environment":"minecraft:default","max_ticks":20,"required":true}"#;
|
||||
|
||||
/// Added controls (after the baseline): the world opens with its test
|
||||
/// instances and keeps them. Files changed after it opened, and packs
|
||||
/// disabled and reloaded, change nothing. The next world replaces them,
|
||||
/// cached misses and selector ids included. A pack overrides a built-in id.
|
||||
/// A game with no world opened reads the game's own data as before.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_world_keeps_the_test_instances_it_opened_with() {
|
||||
let a = world_with(
|
||||
"a",
|
||||
&[("h275:broken", FAILING), ("minecraft:always_pass", FAILING)],
|
||||
);
|
||||
let b = world_with("b", &[("h275:other", FAILING)]);
|
||||
let mut game = game(&a);
|
||||
// No world opened yet: the game's own data, as before.
|
||||
let builtin = game.test_def("minecraft:always_pass").expect("built in");
|
||||
assert!(!builtin.block_based, "the built-in function test");
|
||||
game.load_world_state();
|
||||
let ids = |game: &Game<'_>| game.ui.registries.ids("minecraft:test_instance").to_vec();
|
||||
assert_eq!(ids(&game), ["h275:broken", "minecraft:always_pass"]);
|
||||
assert!(
|
||||
game.test_def("minecraft:always_pass").unwrap().block_based,
|
||||
"the pack's override"
|
||||
);
|
||||
assert!(game.test_def("h275:other").is_none(), "a miss in world A");
|
||||
// A file changed after the world opened.
|
||||
let file = a.join("datapacks/fixture/data/h275/test_instance/broken.json");
|
||||
std::fs::write(
|
||||
&file,
|
||||
FAILING.replace("\"required\":true", "\"required\":false"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
game.test_def("h275:broken").unwrap().required,
|
||||
"the opened definition"
|
||||
);
|
||||
// The pack disabled and the data reloaded.
|
||||
assert!(game.run_command("datapack disable \"file/fixture\""));
|
||||
assert!(game.run_command("reload"));
|
||||
assert!(
|
||||
game.test_def("h275:broken").is_some(),
|
||||
"kept over /datapack and /reload"
|
||||
);
|
||||
assert_eq!(ids(&game), ["h275:broken", "minecraft:always_pass"]);
|
||||
// World B.
|
||||
game.world_root = b.clone();
|
||||
game.load_world_state();
|
||||
assert_eq!(ids(&game), ["h275:other", "minecraft:always_pass"]);
|
||||
assert!(
|
||||
game.test_def("h275:broken").is_none(),
|
||||
"nothing from world A"
|
||||
);
|
||||
assert!(
|
||||
game.test_def("h275:other").is_some(),
|
||||
"world A's miss is gone"
|
||||
);
|
||||
assert!(
|
||||
!game.test_def("minecraft:always_pass").unwrap().block_based,
|
||||
"built in again"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&a);
|
||||
let _ = std::fs::remove_dir_all(&b);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// `server.toml`'s `[transfer]`, as horizon-server passes it: `--transfers
|
||||
/// expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30,max_hold_seconds=60`.
|
||||
/// expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30,max_hold_seconds=60,stop_wait_seconds=10`.
|
||||
/// A key left out keeps its default.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Settings {
|
||||
@@ -42,6 +42,8 @@ pub struct Settings {
|
||||
/// Server ticks a held player may wait for its hand-over before the server
|
||||
/// drops it.
|
||||
pub max_hold_ticks: u64,
|
||||
/// How long a stop waits for a proxy to move the players out ([`StopWait`]); zero stops at once.
|
||||
pub stop_wait: Duration,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -52,13 +54,14 @@ impl Default for Settings {
|
||||
max_expected: 64,
|
||||
max_freeze_ticks: 20 * 30,
|
||||
max_hold_ticks: 20 * 60,
|
||||
stop_wait: Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Reads `--transfers`' value: `key=value` pairs separated by commas, each a
|
||||
/// whole number above zero.
|
||||
/// whole number above zero, but `stop_wait_seconds`, which is zero to [`MAX_STOP_WAIT`].
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let mut s = Self::default();
|
||||
for pair in text.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
@@ -70,9 +73,16 @@ impl Settings {
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|n| *n > 0)
|
||||
.filter(|n| *n > 0 || key == "stop_wait_seconds")
|
||||
.ok_or_else(|| format!("{key} must be a whole number above zero"))?;
|
||||
match key {
|
||||
"stop_wait_seconds" if n > MAX_STOP_WAIT.as_secs() => {
|
||||
return Err(format!(
|
||||
"stop_wait_seconds is above {}",
|
||||
MAX_STOP_WAIT.as_secs()
|
||||
));
|
||||
}
|
||||
"stop_wait_seconds" => s.stop_wait = Duration::from_secs(n),
|
||||
"expect_seconds" => s.expect = Duration::from_secs(n),
|
||||
"max_expect_seconds" => s.max_expect = Duration::from_secs(n),
|
||||
"max_expected" => {
|
||||
@@ -91,6 +101,15 @@ impl Settings {
|
||||
if s.expect > s.max_expect {
|
||||
return Err("expect_seconds is above max_expect_seconds".into());
|
||||
}
|
||||
// A stop's wait is cut at STOP_WAIT_LIMIT: past it, no hand-over could start during the wait.
|
||||
if !s.stop_wait.is_zero()
|
||||
&& s.stop_wait.as_secs() + s.max_freeze_ticks / 20 > STOP_WAIT_LIMIT.as_secs()
|
||||
{
|
||||
return Err(format!(
|
||||
"stop_wait_seconds and max_freeze_seconds add up to more than {}",
|
||||
STOP_WAIT_LIMIT.as_secs()
|
||||
));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +125,8 @@ pub struct Transfers {
|
||||
/// taken back): no player is expected, imported or placed any more, but the players here can
|
||||
/// still be exported and released, which is their way out.
|
||||
pub stopping: bool,
|
||||
/// The wait of that stop, while it lasts.
|
||||
pub stop_wait: Option<StopWait>,
|
||||
}
|
||||
|
||||
impl Transfers {
|
||||
@@ -118,6 +139,7 @@ impl Transfers {
|
||||
settings,
|
||||
expected,
|
||||
stopping: false,
|
||||
stop_wait: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +150,95 @@ impl Transfers {
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest `stop_wait_seconds` may be.
|
||||
pub const MAX_STOP_WAIT: Duration = Duration::from_secs(60);
|
||||
|
||||
/// The longest a stop waits, a hand-over's freeze included: horizon-server kills a game that has not
|
||||
/// stopped within 120 s (its `STOP_TIMEOUT`), and the saves need the rest.
|
||||
pub const STOP_WAIT_LIMIT: Duration = Duration::from_secs(90);
|
||||
|
||||
/// A stop with `[transfer]` on and players here waits, so a proxy told the server is stopping
|
||||
/// (`server/stopping`) can move them out first: no login comes in, the server ticks on and answers the
|
||||
/// proxy's requests. The wait ends as soon as no player is left, at `until` when no hand-over is frozen,
|
||||
/// and at `limit` whatever is under way: `until` and a freeze's longest time, so a hand-over started in
|
||||
/// time can finish, within [`STOP_WAIT_LIMIT`]. No freeze starts that the limit would cut: a player whose
|
||||
/// source saved it after the target placed it would be on both servers.
|
||||
///
|
||||
/// A freeze lasts `max_freeze_ticks` ticks, not seconds: its length in time is reckoned at the tick
|
||||
/// length the server has now (`period`, [`tick_period`]), which the game sets again at every tick, so a
|
||||
/// slow server or `/tick rate 10` refuses a freeze sooner. A server that slows down after a freeze began
|
||||
/// can still reach the limit with it: that is the warning of the stop.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct StopWait {
|
||||
pub until: Instant,
|
||||
pub limit: Instant,
|
||||
freeze_ticks: u64,
|
||||
/// The length of a tick now.
|
||||
pub period: Duration,
|
||||
}
|
||||
|
||||
impl StopWait {
|
||||
/// The wait of a stop at `now`, if the settings make one, with ticks of `period`.
|
||||
pub fn start(settings: &Settings, now: Instant, period: Duration) -> Option<Self> {
|
||||
let freeze = freeze_time(settings.max_freeze_ticks, period);
|
||||
(!settings.stop_wait.is_zero()).then(|| Self {
|
||||
until: now + settings.stop_wait,
|
||||
limit: now
|
||||
+ settings
|
||||
.stop_wait
|
||||
.saturating_add(freeze)
|
||||
.min(STOP_WAIT_LIMIT),
|
||||
freeze_ticks: settings.max_freeze_ticks,
|
||||
period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the stop goes on now, with these players placed and frozen here.
|
||||
pub fn over(&self, now: Instant, placed: usize, frozen: usize) -> bool {
|
||||
placed == 0 || (now >= self.until && frozen == 0) || now >= self.limit
|
||||
}
|
||||
|
||||
/// Whether a hand-over that starts now would end before the limit, at the tick length of now.
|
||||
pub fn may_freeze(&self, now: Instant) -> bool {
|
||||
now.checked_add(freeze_time(self.freeze_ticks, self.period))
|
||||
.is_some_and(|end| end <= self.limit)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long `ticks` ticks of `period` take.
|
||||
fn freeze_time(ticks: u64, period: Duration) -> Duration {
|
||||
period.saturating_mul(u32::try_from(ticks).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
/// The length of a tick: the rate's (`target`, which `/tick rate` sets), or longer when the server
|
||||
/// is slower than that (`tps`, the ticks it made a second lately; 0 when unknown).
|
||||
pub fn tick_period(target: Duration, tps: f64) -> Duration {
|
||||
let measured = if tps > 0.0 {
|
||||
Duration::try_from_secs_f64(1.0 / tps).unwrap_or(Duration::MAX)
|
||||
} else {
|
||||
Duration::ZERO
|
||||
};
|
||||
measured.max(target)
|
||||
}
|
||||
|
||||
/// The players placed here (a player a `moved` release took out is not counted) and those of them
|
||||
/// frozen for a hand-over.
|
||||
pub fn placed_and_frozen(hub: &Hub) -> (usize, usize) {
|
||||
let here = || hub.guests.iter().filter(|g| g.placed && !g.released);
|
||||
(
|
||||
here().count(),
|
||||
here().filter(|g| g.handover.is_some()).count(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The log line of a stop that waits.
|
||||
pub fn stop_wait_line(wait: Duration) -> String {
|
||||
format!(
|
||||
"Stopping in {} s: a proxy may move the players out first",
|
||||
wait.as_secs()
|
||||
)
|
||||
}
|
||||
|
||||
/// Why an expectation could not be recorded.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ExpectError {
|
||||
@@ -290,6 +401,16 @@ pub fn frozen_too_long(since: u64, now: u64, max: u64) -> bool {
|
||||
now.saturating_sub(since) >= max
|
||||
}
|
||||
|
||||
/// `player-export <uuid> freeze` during a stop's wait: refused once a hand-over that starts now could
|
||||
/// not finish before the stop's limit ([`StopWait`]). A player already frozen is answered as before.
|
||||
pub fn check_freeze(hub: &Hub, i: usize, now: Instant) -> Result<(), &'static str> {
|
||||
let wait = hub.transfers.as_ref().and_then(|t| t.stop_wait);
|
||||
if hub.guests[i].handover.is_none() && wait.is_some_and(|w| !w.may_freeze(now)) {
|
||||
return Err("the server is stopping: no hand-over starts this late");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why the connected player `uuid` cannot be exported now, or the index of its
|
||||
/// guest. A held player is not exportable: it has no state of its own yet.
|
||||
pub fn check_exportable(hub: &Hub, uuid: u128) -> Result<usize, &'static str> {
|
||||
@@ -597,15 +718,14 @@ pub fn status_request(hub: &Hub, now: Instant) -> serde_json::Value {
|
||||
let Some(transfers) = &hub.transfers else {
|
||||
return error(OFF);
|
||||
};
|
||||
let count =
|
||||
|keep: fn(&super::guest::Guest) -> bool| hub.guests.iter().filter(|g| keep(g)).count();
|
||||
let (online, max_players) = (hub.lan.shared.online(), hub.lan.shared.max_players());
|
||||
let (placed, frozen) = placed_and_frozen(hub);
|
||||
serde_json::json!({
|
||||
"ok": true,
|
||||
"players": count(|g| g.placed && !g.released),
|
||||
"held": count(|g| g.held),
|
||||
"players": placed,
|
||||
"held": hub.guests.iter().filter(|g| g.held).count(),
|
||||
"expected": transfers.table().alive(now),
|
||||
"frozen": count(|g| g.handover.is_some() && !g.released),
|
||||
"frozen": frozen,
|
||||
"online": online,
|
||||
"max_players": max_players,
|
||||
"full": online >= max_players,
|
||||
@@ -852,9 +972,97 @@ mod tests {
|
||||
"expect_seconds",
|
||||
"colour=blue",
|
||||
"expect_seconds=90",
|
||||
"stop_wait_seconds=61",
|
||||
"max_freeze_seconds=81",
|
||||
"stop_wait_seconds=60,max_freeze_seconds=31",
|
||||
] {
|
||||
assert!(Settings::parse(bad).is_err(), "{bad:?} was taken");
|
||||
}
|
||||
// A stop that does not wait is zero seconds.
|
||||
assert_eq!(Settings::default().stop_wait, secs(10));
|
||||
assert_eq!(
|
||||
Settings::parse("stop_wait_seconds=0").unwrap().stop_wait,
|
||||
secs(0)
|
||||
);
|
||||
assert_eq!(
|
||||
Settings::parse("stop_wait_seconds=60").unwrap().stop_wait,
|
||||
secs(60)
|
||||
);
|
||||
// The two fit in the stop's limit, or the stop does not wait.
|
||||
assert!(Settings::parse("stop_wait_seconds=60,max_freeze_seconds=30").is_ok());
|
||||
assert!(Settings::parse("stop_wait_seconds=0,max_freeze_seconds=300").is_ok());
|
||||
}
|
||||
|
||||
/// A stop's wait ends when nobody is left, at its time when no hand-over is frozen, and at its
|
||||
/// limit (its time and a freeze's) whatever is under way; no freeze starts that the limit would
|
||||
/// cut. Zero seconds is no wait.
|
||||
#[test]
|
||||
fn a_stop_waits_for_the_players_to_be_moved_out_but_not_for_ever() {
|
||||
let now = Instant::now();
|
||||
let settings = Settings::default();
|
||||
let tick = Duration::from_millis(50);
|
||||
let wait = StopWait::start(&settings, now, tick).unwrap();
|
||||
assert_eq!((wait.until, wait.limit), (now + secs(10), now + secs(40)));
|
||||
assert!(!wait.over(now, 2, 0));
|
||||
assert!(wait.over(now, 0, 0), "nobody is left");
|
||||
assert!(!wait.over(now + secs(9), 2, 0));
|
||||
assert!(
|
||||
wait.over(now + secs(10), 2, 0),
|
||||
"its time, and no hand-over"
|
||||
);
|
||||
assert!(!wait.over(now + secs(39), 2, 1), "a hand-over may finish");
|
||||
assert!(wait.over(now + secs(40), 2, 1), "the limit");
|
||||
assert!(wait.may_freeze(now + secs(10)));
|
||||
assert!(!wait.may_freeze(now + secs(11)));
|
||||
// However long the settings, the whole wait fits in the supervisor's time for a stop.
|
||||
let long = Settings {
|
||||
stop_wait: secs(60),
|
||||
max_freeze_ticks: 20 * 300,
|
||||
..Settings::default()
|
||||
};
|
||||
let wait = StopWait::start(&long, now, tick).unwrap();
|
||||
assert_eq!(wait.limit, now + STOP_WAIT_LIMIT);
|
||||
assert!(!wait.may_freeze(now), "a freeze of 300 s would be cut");
|
||||
let none = Settings {
|
||||
stop_wait: secs(0),
|
||||
..Settings::default()
|
||||
};
|
||||
assert_eq!(StopWait::start(&none, now, tick), None);
|
||||
}
|
||||
|
||||
/// A freeze lasts ticks: at 10 ticks a second it lasts twice as long, the limit with it, and a
|
||||
/// server that slows down during the wait refuses a freeze it would have let start at 20.
|
||||
#[test]
|
||||
fn a_stop_reckons_a_freeze_at_the_tick_length_of_now() {
|
||||
let now = Instant::now();
|
||||
let slow = Duration::from_millis(100);
|
||||
let wait = StopWait::start(&Settings::default(), now, slow).unwrap();
|
||||
assert_eq!((wait.until, wait.limit), (now + secs(10), now + secs(70)));
|
||||
assert!(wait.may_freeze(now + secs(10)));
|
||||
assert!(!wait.may_freeze(now + secs(11)));
|
||||
let mut wait =
|
||||
StopWait::start(&Settings::default(), now, Duration::from_millis(50)).unwrap();
|
||||
assert!(wait.may_freeze(now));
|
||||
wait.period = slow;
|
||||
assert!(
|
||||
!wait.may_freeze(now),
|
||||
"60 s of freeze would pass the limit at 40 s"
|
||||
);
|
||||
// The tick length: the rate's, or longer when the server is slower than its rate.
|
||||
let rate = Duration::from_millis(50);
|
||||
assert_eq!(tick_period(rate, 20.0), rate);
|
||||
assert_eq!(
|
||||
tick_period(rate, 40.0),
|
||||
rate,
|
||||
"sprinting is not a shorter freeze"
|
||||
);
|
||||
assert_eq!(tick_period(rate, 10.0), Duration::from_millis(100));
|
||||
assert_eq!(tick_period(rate, 0.0), rate, "no measure yet");
|
||||
assert_eq!(
|
||||
tick_period(Duration::from_millis(100), 20.0),
|
||||
slow,
|
||||
"/tick rate 10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1578,6 +1786,25 @@ mod hub_tests {
|
||||
assert_eq!(ids(&seen), Vec::<i32>::new());
|
||||
}
|
||||
|
||||
/// During a stop's wait a player is exported, and frozen while a hand-over can still finish in
|
||||
/// time; later only an export already frozen is answered.
|
||||
#[test]
|
||||
fn a_stop_lets_a_hand_over_start_only_while_it_can_finish() {
|
||||
let (mut hub, _seen, _mine, _h) = frozen_with_a_watcher();
|
||||
let now = Instant::now();
|
||||
let transfers = hub.transfers.as_mut().unwrap();
|
||||
transfers.stop_wait =
|
||||
super::StopWait::start(&transfers.settings, now, Duration::from_millis(50));
|
||||
assert_eq!(super::placed_and_frozen(&hub), (2, 1));
|
||||
assert_eq!(
|
||||
super::check_freeze(&hub, 0, now + Duration::from_secs(10)),
|
||||
Ok(())
|
||||
);
|
||||
let late = now + Duration::from_secs(11);
|
||||
assert!(super::check_freeze(&hub, 0, late).is_err());
|
||||
assert_eq!(super::check_freeze(&hub, 1, late), Ok(()), "already frozen");
|
||||
}
|
||||
|
||||
/// After the commit, a retry finds the player placed from that hand-over whatever came since:
|
||||
/// a stop that began meanwhile does not turn the retry into a refusal.
|
||||
#[test]
|
||||
|
||||
@@ -306,11 +306,44 @@ fn watched_outgoing(player: u128, body: Vec<u8>, watched: u8) -> Option<Vec<u8>>
|
||||
})
|
||||
}
|
||||
|
||||
/// The packets (and their bytes) a connection's network thread has handed the hub and the hub hasn't
|
||||
/// taken from its channel yet. The hub's inbox caps apply only as it drains that channel, at the start
|
||||
/// of a tick: while a tick runs long (an autosave, chunk generation) a client could otherwise queue
|
||||
/// tiny packets without bound, each a boxed `Serverbound` many times its size on the wire.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Undrained {
|
||||
packets: AtomicUsize,
|
||||
bytes: AtomicUsize,
|
||||
}
|
||||
impl Undrained {
|
||||
/// Twice the hub's inbox caps: a client within them never comes near.
|
||||
pub const MAX_PACKETS: usize = 2 * super::hub::INBOX_LIMIT;
|
||||
pub const MAX_BYTES: usize = 2 * super::hub::INBOX_BYTES;
|
||||
/// Count a packet of `bytes` handed to the hub, or `false` (counting nothing) when that would
|
||||
/// pass a cap: the network thread disconnects the client then.
|
||||
pub fn handed(&self, bytes: usize) -> bool {
|
||||
let packets = self.packets.fetch_add(1, Ordering::AcqRel);
|
||||
let total = self.bytes.fetch_add(bytes, Ordering::AcqRel);
|
||||
if packets >= Self::MAX_PACKETS || total.saturating_add(bytes) > Self::MAX_BYTES {
|
||||
self.taken(bytes);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The hub took a packet of `bytes` from its channel.
|
||||
pub fn taken(&self, bytes: usize) {
|
||||
self.packets.fetch_sub(1, Ordering::AcqRel);
|
||||
self.bytes.fetch_sub(bytes, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sending half given to the game thread.
|
||||
#[derive(Clone)]
|
||||
pub struct PlayerSender {
|
||||
/// The player it sends to, for the hooks on what it sends ([`outgoing`]); 0 for a test's.
|
||||
player: u128,
|
||||
/// What the player's network thread has handed the hub and the hub hasn't taken yet.
|
||||
pub undrained: Arc<Undrained>,
|
||||
/// The bytes of the bodies sent and not framed yet.
|
||||
pub pending: Arc<AtomicUsize>,
|
||||
held: Arc<Mutex<Vec<Vec<u8>>>>,
|
||||
@@ -331,6 +364,7 @@ impl PlayerSender {
|
||||
pub fn new(tx: Sender<Out>, pending: Arc<AtomicUsize>) -> Self {
|
||||
Self {
|
||||
player: 0,
|
||||
undrained: Arc::default(),
|
||||
pending,
|
||||
held: Arc::default(),
|
||||
to: To::Channel(tx),
|
||||
@@ -1426,6 +1460,20 @@ fn pack_rejected(shared: &Shared, name: &str, pack: u128) -> (horizon_world::nbt
|
||||
/// RateKickingConnection: a player whose packets a second passed rate-limit
|
||||
/// is disconnected ("disconnect.exceeded_packet_rate") with a WARN, as
|
||||
/// vanilla's kick logs it.
|
||||
/// The kick for a client whose packets wait past [`Undrained`]'s caps: the same message as the
|
||||
/// hub's own inbox check, and a line in the log.
|
||||
fn undrained_exceeded(shared: &Shared, name: &str) -> (horizon_world::nbt::Tag, String) {
|
||||
let reason = translatable("disconnect.exceeded_packet_rate", &[]);
|
||||
let Some(log) = &shared.config.log else {
|
||||
return (reason, String::new());
|
||||
};
|
||||
let line =
|
||||
format!("{name} sent more packets than the server handles before its tick took them");
|
||||
(log.write)("Server thread", "WARN", &line);
|
||||
let text = horizon_protocol::text::plain(&reason, &log.lang);
|
||||
(reason, text)
|
||||
}
|
||||
|
||||
fn rate_exceeded(shared: &Shared, average: f32) -> (horizon_world::nbt::Tag, String) {
|
||||
let reason = translatable("disconnect.exceeded_packet_rate", &[]);
|
||||
let Some(log) = &shared.config.log else {
|
||||
@@ -1573,8 +1621,10 @@ fn play(
|
||||
start: Instant::now(),
|
||||
alive: Arc::new(AtomicI64::new(-1)),
|
||||
});
|
||||
let undrained = Arc::<Undrained>::default();
|
||||
let sender = PlayerSender {
|
||||
player: parts.uuid,
|
||||
undrained: undrained.clone(),
|
||||
pending,
|
||||
held: Arc::default(),
|
||||
to: To::Pool(outbox.clone(), watch.clone()),
|
||||
@@ -1597,6 +1647,7 @@ fn play(
|
||||
next_keep_alive: watch.start + KEEP_ALIVE_EVERY,
|
||||
watch,
|
||||
counted,
|
||||
undrained,
|
||||
};
|
||||
let joined = Joined {
|
||||
conn,
|
||||
@@ -1641,6 +1692,8 @@ struct PlayConn {
|
||||
next_keep_alive: Instant,
|
||||
/// The connection's place among the server's, given back once closed.
|
||||
counted: Option<super::Counted>,
|
||||
/// Shared with the player's [`PlayerSender`]: what the hub hasn't taken yet.
|
||||
undrained: Arc<Undrained>,
|
||||
}
|
||||
impl io_pool::Session for PlayConn {
|
||||
/// Forward the player's packet, or say why it ends the connection: a
|
||||
@@ -1674,6 +1727,10 @@ impl io_pool::Session for PlayConn {
|
||||
kick(pack_rejected(&self.shared, &self.name, r.id))
|
||||
}
|
||||
Ok(p) => {
|
||||
// Bounded here, where packets are made, not only where the hub drains them.
|
||||
if !self.undrained.handed(payload.len()) {
|
||||
return kick(undrained_exceeded(&self.shared, &self.name));
|
||||
}
|
||||
let event = NetEvent::Packet(self.conn, Box::new(p), payload.len());
|
||||
match self.shared.events.send(event) {
|
||||
Ok(()) => Verdict::Go,
|
||||
@@ -1926,6 +1983,28 @@ impl KeepAliveWatch {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn undrained_packets_stop_at_twice_the_inbox() {
|
||||
let u = Undrained::default();
|
||||
// Up to the cap, counted; one more is refused and counts nothing.
|
||||
for _ in 0..Undrained::MAX_PACKETS {
|
||||
assert!(u.handed(3));
|
||||
}
|
||||
assert!(!u.handed(3));
|
||||
// The hub taking one gives its place back.
|
||||
u.taken(3);
|
||||
assert!(u.handed(3));
|
||||
// Bytes too: a few large packets reach the byte cap long before the count.
|
||||
let big = Undrained::default();
|
||||
let size = Undrained::MAX_BYTES / 4;
|
||||
for _ in 0..4 {
|
||||
assert!(big.handed(size));
|
||||
}
|
||||
assert!(!big.handed(1));
|
||||
big.taken(size);
|
||||
assert!(big.handed(size));
|
||||
}
|
||||
use super::*;
|
||||
|
||||
/// Having tables for Horizon to join an old server does not make the reverse direction supported.
|
||||
|
||||
@@ -2255,6 +2255,8 @@ impl Hub {
|
||||
let Some(g) = self.guest_mut(conn) else {
|
||||
continue;
|
||||
};
|
||||
// Out of the channel: the network thread may hand this player more.
|
||||
g.sender.undrained.taken(bytes);
|
||||
// A kicked player's packets wait for nothing.
|
||||
if !g.left_reason.is_empty() {
|
||||
continue;
|
||||
@@ -5164,10 +5166,10 @@ fn wrap_degrees(degrees: f32) -> f32 {
|
||||
const PACKETS_PER_TICK: usize = 400;
|
||||
/// The packets a player may have waiting (a second of its budget) before it is
|
||||
/// disconnected, as vanilla's rate-limit does: no real client gets near it.
|
||||
const INBOX_LIMIT: usize = 20 * PACKETS_PER_TICK;
|
||||
pub(crate) const INBOX_LIMIT: usize = 20 * PACKETS_PER_TICK;
|
||||
/// The same in bytes on the wire: 8000 full books would hold gigabytes. A
|
||||
/// real client's largest packets (a 100-page book) are a few hundred kB.
|
||||
const INBOX_BYTES: usize = 16 << 20;
|
||||
pub(crate) const INBOX_BYTES: usize = 16 << 20;
|
||||
|
||||
/// The `minecraft:gravity` attribute of a player.
|
||||
const PLAYER_GRAVITY: f64 = 0.08;
|
||||
|
||||
@@ -451,6 +451,8 @@ pub struct Shared {
|
||||
closing: AtomicUsize,
|
||||
/// Connections turned away since the last report of them, and when that was.
|
||||
turned_away: Mutex<(usize, Option<Instant>)>,
|
||||
/// A stop waits for a proxy to move the players out (`arrivals::StopWait`): no login comes in.
|
||||
shutting_down: AtomicBool,
|
||||
}
|
||||
impl Shared {
|
||||
/// The registries and tags a new connection is sent.
|
||||
@@ -473,6 +475,9 @@ impl Shared {
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
) -> Option<horizon_world::nbt::Tag> {
|
||||
if self.shutting_down.load(Ordering::Relaxed) {
|
||||
return Some(translatable("multiplayer.disconnect.server_shutdown", &[]));
|
||||
}
|
||||
if let Some(gate) = &self.config.gate
|
||||
&& let Some(reason) = gate(address, uuid, name)
|
||||
{
|
||||
@@ -499,6 +504,10 @@ impl Shared {
|
||||
self.online.lock().unwrap().push((uuid, name.to_owned()));
|
||||
None
|
||||
}
|
||||
/// A stop has begun: every login from now on is refused as the server's shutdown.
|
||||
pub fn refuse_logins(&self) {
|
||||
self.shutting_down.store(true, Ordering::Relaxed);
|
||||
}
|
||||
fn is_online(&self, uuid: u128) -> bool {
|
||||
self.online.lock().unwrap().iter().any(|(u, _)| *u == uuid)
|
||||
}
|
||||
@@ -687,6 +696,7 @@ impl Lan {
|
||||
next_connection: AtomicU64::new(0),
|
||||
closing: AtomicUsize::new(0),
|
||||
turned_away: Mutex::new((0, None)),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
random: Mutex::new(crate::server::java_properties::now_millis() as u64 | 1),
|
||||
config,
|
||||
});
|
||||
@@ -1138,6 +1148,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a stop waits for a proxy, no login takes a place, an op's neither.
|
||||
#[test]
|
||||
fn a_server_that_refuses_logins_takes_no_one_in() {
|
||||
let config = NetConfig {
|
||||
announce: false,
|
||||
listen: false,
|
||||
bypasses_limit: Some(Arc::new(|_| true)),
|
||||
..NetConfig::loopback(0, "Stopping".into(), 8)
|
||||
};
|
||||
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
|
||||
assert!(lan.shared.try_admit(MEMORY_ADDRESS, 1, "Player").is_none());
|
||||
lan.shared.refuse_logins();
|
||||
assert_eq!(
|
||||
lan.shared.try_admit(MEMORY_ADDRESS, 2, "Late"),
|
||||
Some(translatable("multiplayer.disconnect.server_shutdown", &[]))
|
||||
);
|
||||
assert_eq!(lan.shared.online(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_online_players_updates_status_response() {
|
||||
let config = NetConfig {
|
||||
|
||||
@@ -1225,6 +1225,9 @@ pub struct Extra {
|
||||
/// Species scratch values (the wither's head targets and clocks).
|
||||
pub ints: [i32; 6],
|
||||
pub counter: i32,
|
||||
/// Nautilus ATTACK_TARGET_COOLDOWN saved slot: None is absent,
|
||||
/// Some(MAX) is permanent, otherwise the remaining lifetime. The value is `counter`.
|
||||
pub nautilus_attack_cooldown_ttl: Option<i64>,
|
||||
pub counter2: i32,
|
||||
pub home: Option<[i32; 3]>,
|
||||
/// A copper golem's `openedChestPos`.
|
||||
@@ -3821,6 +3824,13 @@ impl Mob {
|
||||
.push(MobEvent::Sound(sound, at, ai::sound_volume(self), pitch));
|
||||
}
|
||||
}
|
||||
// playSecondaryHurtSound: a thorns hit sounds where the mob
|
||||
// stands, silent or not.
|
||||
if source.kind == DamageKind::Thorns {
|
||||
let at = [self.position.x, self.position.y, self.position.z];
|
||||
self.outbox
|
||||
.push(MobEvent::Sound("enchant.thorns.hit", at, 1.0, 1.0));
|
||||
}
|
||||
}
|
||||
// IronGolem.hurtServer: a new crack level plays the damage sound.
|
||||
if let Some(before) = crackiness
|
||||
@@ -5444,6 +5454,50 @@ mod magic_absorb_tests {
|
||||
assert_eq!(m.extra.effects, vec![(speed, 900, 0)]);
|
||||
}
|
||||
|
||||
/// `LivingEntity.playSecondaryHurtSound`: a `thorns` hit that lands whole
|
||||
/// sounds `enchant.thorns.hit` where the mob stands, after its own hurt
|
||||
/// sound and for a silent mob too (`level.playSound`, not the mob's). The
|
||||
/// rest of a stronger hit inside the hurt cooldown makes no sound, and
|
||||
/// another kind of hit makes none.
|
||||
#[test]
|
||||
fn a_thorns_hit_sounds_where_the_mob_stands() {
|
||||
let thorns = Damage {
|
||||
kind: DamageKind::Thorns,
|
||||
attacker: Some(Who::Player),
|
||||
from: Some([4.0, 2.0, 3.0]),
|
||||
attacker_species: None,
|
||||
};
|
||||
let sounds = |m: &Mob| -> Vec<(&'static str, [f64; 3], f32, f32)> {
|
||||
m.outbox
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
MobEvent::Sound(s, at, volume, pitch) => Some((*s, *at, *volume, *pitch)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut m = witch();
|
||||
m.position = Vec3::new(1.0, 2.0, 3.0);
|
||||
m.silent = true;
|
||||
assert!(m.damage(4.0, thorns));
|
||||
assert_eq!(
|
||||
sounds(&m),
|
||||
vec![("enchant.thorns.hit", [1.0, 2.0, 3.0], 1.0, 1.0)]
|
||||
);
|
||||
m.outbox.clear();
|
||||
m.damage(8.0, thorns);
|
||||
assert!(sounds(&m).is_empty(), "inside the hurt cooldown");
|
||||
|
||||
let mut loud = witch();
|
||||
assert!(loud.damage(4.0, thorns));
|
||||
let heard = sounds(&loud);
|
||||
assert_eq!(heard.last().map(|s| s.0), Some("enchant.thorns.hit"));
|
||||
let mut hit = witch();
|
||||
assert!(hit.damage(4.0, Damage::player([4.0, 2.0, 3.0])));
|
||||
assert_eq!(heard.len(), sounds(&hit).len() + 1, "{heard:?}");
|
||||
assert!(sounds(&hit).iter().all(|s| s.0 != "enchant.thorns.hit"));
|
||||
}
|
||||
|
||||
/// `Witch.getDamageAfterMagicAbsorb`: `#witch_resistant_to` (`magic`,
|
||||
/// `indirect_magic`, `sonic_boom`, `thorns`) takes 15% of the hit, which
|
||||
/// leaves a 10 health witch on 7 of a 20 hit it would not survive whole.
|
||||
|
||||
@@ -14,6 +14,9 @@ use horizon_core::geometry::trig::Mth;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::entity_data::PositionSource;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "allay_liked_player_tests.rs"]
|
||||
mod allay_liked_player_tests;
|
||||
pub mod interaction;
|
||||
|
||||
// Allay state: `main_hand` the held filter; `State::inventory` the fetched stack,
|
||||
@@ -96,6 +99,10 @@ pub struct State {
|
||||
pub inventory: Option<ItemStack>,
|
||||
pub vibration: sculk::Data,
|
||||
pub noteblock: Option<GlobalPos>,
|
||||
/// The `LIKED_PLAYER` memory's time to live (`MemorySlot.timeToLive`);
|
||||
/// None for a memory that never expires. The memory itself is
|
||||
/// `Extra::tame` and `Extra::owner`.
|
||||
pub liked_player_ttl: Option<i64>,
|
||||
}
|
||||
|
||||
/// The pocket is independent of the main-hand filter.
|
||||
@@ -412,6 +419,7 @@ pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>
|
||||
}
|
||||
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
|
||||
pub fn ai_step(m: &mut Mob) {
|
||||
tick_liked_player(m);
|
||||
passive_heal(m);
|
||||
for i in [2, 3] {
|
||||
if m.extra.ints[i] > 0 {
|
||||
@@ -421,6 +429,42 @@ pub fn ai_step(m: &mut Mob) {
|
||||
update_duplication_cooldown(m);
|
||||
animate(m);
|
||||
}
|
||||
/// `MemorySlot.tick` for `LIKED_PLAYER` in the Allay's brain tick
|
||||
/// (`customServerAiStep`, which `NoAI` skips): a memory with a time to live
|
||||
/// counts down, and is cleared on the tick after it reaches 0.
|
||||
fn tick_liked_player(m: &mut Mob) {
|
||||
if !m.extra.tame {
|
||||
return;
|
||||
}
|
||||
let Some(st) = m.extra.allay.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match st.liked_player_ttl {
|
||||
Some(ttl) if ttl <= 0 => {
|
||||
st.liked_player_ttl = None;
|
||||
m.extra.tame = false;
|
||||
m.extra.owner = None;
|
||||
}
|
||||
Some(ttl) => st.liked_player_ttl = Some(ttl - 1),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
/// `Brain.setMemory(LIKED_PLAYER, uuid)`: the memory never expires.
|
||||
pub fn like_player(m: &mut Mob, uuid: [i32; 4]) {
|
||||
m.extra.tame = true;
|
||||
m.extra.owner = Some(uuid);
|
||||
if let Some(st) = m.extra.allay.as_mut() {
|
||||
st.liked_player_ttl = None;
|
||||
}
|
||||
}
|
||||
/// `Brain.eraseMemory(LIKED_PLAYER)`.
|
||||
pub fn forget_liked_player(m: &mut Mob) {
|
||||
m.extra.tame = false;
|
||||
m.extra.owner = None;
|
||||
if let Some(st) = m.extra.allay.as_mut() {
|
||||
st.liked_player_ttl = None;
|
||||
}
|
||||
}
|
||||
/// `Allay.aiStep`: server-side healing runs independently of the AI gate.
|
||||
pub fn passive_heal(m: &mut Mob) {
|
||||
if m.alive() && m.age.is_multiple_of(10) {
|
||||
|
||||
@@ -43,8 +43,7 @@ pub fn predict(
|
||||
{
|
||||
given.count = 1;
|
||||
mob.extra.set_main_hand_stack(Some(given));
|
||||
mob.extra.tame = true;
|
||||
mob.extra.owner = Some(actor);
|
||||
super::like_player(mob, actor);
|
||||
return Some(Interaction::Give);
|
||||
}
|
||||
if main_hand
|
||||
@@ -52,8 +51,7 @@ pub fn predict(
|
||||
&& let Some(held) = held
|
||||
{
|
||||
mob.extra.set_main_hand(None);
|
||||
mob.extra.tame = false;
|
||||
mob.extra.owner = None;
|
||||
super::forget_liked_player(mob);
|
||||
mob.swing();
|
||||
return Some(Interaction::Take(held));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! An Allay's `minecraft:liked_player` memory and its time to live, as
|
||||
//! `Brain` reads, writes and ticks it (`MemorySlot`): vanilla 26.3 counts a
|
||||
//! finite memory down on each brain tick and clears it on the tick after 0;
|
||||
//! `NoAI` runs no brain tick; a memory without a time to live never expires
|
||||
//! (tools/test_allay_liked_player_parity.py measures the same).
|
||||
use super::*;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
|
||||
const OWNER: [i32; 4] = [95, 0, 0, 99];
|
||||
|
||||
/// The Allay as `/summon` would load it, with the memory absent (`None`),
|
||||
/// permanent (`Some(None)`) or with a time to live.
|
||||
fn saved_tag(memory: Option<Option<i64>>, no_ai: bool) -> Tag {
|
||||
let d = |v: f64| Tag::Double(v.to_bits());
|
||||
let mut entries = vec![
|
||||
("id", Tag::String("minecraft:allay".into())),
|
||||
("Pos", Tag::List(vec![d(8.5), d(200.0), d(8.5)])),
|
||||
("NoAI", Tag::Byte(i8::from(no_ai))),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
];
|
||||
if let Some(ttl) = memory {
|
||||
let mut liked = vec![("value", Tag::IntArray(OWNER.to_vec()))];
|
||||
if let Some(ttl) = ttl {
|
||||
liked.push(("ttl", Tag::Long(ttl)));
|
||||
}
|
||||
entries.push((
|
||||
"Brain",
|
||||
Tag::compound([(
|
||||
"memories",
|
||||
Tag::compound([("minecraft:liked_player", Tag::compound(liked))]),
|
||||
)]),
|
||||
));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
}
|
||||
|
||||
fn load(types: &crate::mobs::MobTypes, tag: &Tag) -> Mob {
|
||||
let kinds: std::collections::HashMap<String, Arc<crate::mobs::MobType>> = types
|
||||
.types
|
||||
.iter()
|
||||
.map(|(id, kind)| (id.clone(), Arc::new(kind.clone())))
|
||||
.collect();
|
||||
crate::entity_nbt::mob_from_nbt(tag, &kinds, 1, None).expect("an allay")
|
||||
}
|
||||
|
||||
/// The saved memory: None when absent, else its value and time to live.
|
||||
fn saved_memory(m: &Mob, raw: Option<&Tag>) -> Option<(Option<Tag>, Option<i64>)> {
|
||||
let tag = crate::entity_nbt::mob_to_nbt(m, [95, 0, 0, 1], raw, None, |_| None);
|
||||
let liked = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:liked_player"))?;
|
||||
let ttl = match liked.get("ttl") {
|
||||
Some(Tag::Long(t)) => Some(*t),
|
||||
None => None,
|
||||
other => panic!("ttl: {other:?}"),
|
||||
};
|
||||
Some((liked.get("value").cloned(), ttl))
|
||||
}
|
||||
|
||||
fn value() -> Option<Tag> {
|
||||
Some(Tag::IntArray(OWNER.to_vec()))
|
||||
}
|
||||
|
||||
/// `Brain.readAdditionalSaveData` keeps the time to live, and the save writes
|
||||
/// the mob's own memory: with no raw tag at all, and over a raw tag whose
|
||||
/// time to live the mob has since counted down.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in .local/horizon-client"]
|
||||
fn a_loaded_finite_liked_player_saves_its_own_time_to_live() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|_, _| {},
|
||||
|w, types| {
|
||||
for (memory, expected) in [
|
||||
(Some(Some(2)), Some((value(), Some(2)))),
|
||||
(Some(Some(0)), Some((value(), Some(0)))),
|
||||
(Some(None), Some((value(), None))),
|
||||
(None, None),
|
||||
] {
|
||||
let tag = saved_tag(memory, false);
|
||||
let m = load(types, &tag);
|
||||
assert_eq!(saved_memory(&m, None), expected, "{memory:?}, no raw tag");
|
||||
}
|
||||
let tag = saved_tag(Some(Some(2)), false);
|
||||
let mut m = load(types, &tag);
|
||||
crate::mobs::ai::tick(&mut m, w, &mut LegacyRandom::new(1), &mut Vec::new());
|
||||
assert_eq!(
|
||||
saved_memory(&m, Some(&tag)),
|
||||
Some((value(), Some(1))),
|
||||
"stale raw ttl 2"
|
||||
);
|
||||
},
|
||||
);
|
||||
assert!(ran, "the prepared resources were not found");
|
||||
}
|
||||
|
||||
/// `MemorySlot.tick` in the Allay's brain tick: 2, 1, 0, then gone; a memory
|
||||
/// at 0 goes on the first tick; `NoAI` keeps it as it was; a permanent one
|
||||
/// stays. A gone memory no longer makes the Allay like its player.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in .local/horizon-client"]
|
||||
fn a_finite_liked_player_expires_only_under_ai() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|_, _| {},
|
||||
|w, types| {
|
||||
let ttl_at = |memory, no_ai| {
|
||||
let tag = saved_tag(memory, no_ai);
|
||||
let mut m = load(types, &tag);
|
||||
let mut seen = vec![saved_memory(&m, None).map(|(_, t)| t)];
|
||||
for _ in 0..3 {
|
||||
crate::mobs::ai::tick(&mut m, w, &mut LegacyRandom::new(1), &mut Vec::new());
|
||||
seen.push(saved_memory(&m, None).map(|(_, t)| t));
|
||||
}
|
||||
let liked = is_liked_player(&m, Some(crate::mobs::Who::Player), OWNER);
|
||||
(seen, liked)
|
||||
};
|
||||
assert_eq!(
|
||||
ttl_at(Some(Some(2)), false),
|
||||
(
|
||||
vec![Some(Some(2)), Some(Some(1)), Some(Some(0)), None],
|
||||
false
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
ttl_at(Some(Some(0)), false),
|
||||
(vec![Some(Some(0)), None, None, None], false)
|
||||
);
|
||||
assert_eq!(ttl_at(Some(Some(2)), true), (vec![Some(Some(2)); 4], true));
|
||||
assert_eq!(ttl_at(Some(Some(0)), true), (vec![Some(Some(0)); 4], true));
|
||||
assert_eq!(ttl_at(Some(None), false), (vec![Some(None); 4], true));
|
||||
assert_eq!(ttl_at(Some(None), true), (vec![Some(None); 4], true));
|
||||
assert_eq!(ttl_at(None, false), (vec![None; 4], false));
|
||||
},
|
||||
);
|
||||
assert!(ran, "the prepared resources were not found");
|
||||
}
|
||||
|
||||
/// `Allay.mobInteract` sets `LIKED_PLAYER` with `Brain.setMemory`, which
|
||||
/// never expires, over a loaded finite memory; taking the item back erases
|
||||
/// it.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in .local/horizon-client"]
|
||||
fn a_given_item_makes_the_liked_player_permanent_again() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|_, _| {},
|
||||
|w, types| {
|
||||
let tag = saved_tag(Some(Some(2)), false);
|
||||
let mut m = load(types, &tag);
|
||||
let actor = [7, 8, 9, 10];
|
||||
let stick = crate::items::ItemStack::new("minecraft:stick", 1);
|
||||
assert!(interaction::predict(&mut m, Some(&stick), true, false, actor).is_some());
|
||||
let mine = Some(Tag::IntArray(actor.to_vec()));
|
||||
assert_eq!(saved_memory(&m, Some(&tag)), Some((mine.clone(), None)));
|
||||
for _ in 0..4 {
|
||||
crate::mobs::ai::tick(&mut m, w, &mut LegacyRandom::new(1), &mut Vec::new());
|
||||
}
|
||||
assert_eq!(
|
||||
saved_memory(&m, Some(&tag)),
|
||||
Some((mine, None)),
|
||||
"never expires"
|
||||
);
|
||||
assert!(interaction::predict(&mut m, None, true, false, actor).is_some());
|
||||
assert_eq!(saved_memory(&m, Some(&tag)), None, "erased");
|
||||
},
|
||||
);
|
||||
assert!(ran, "the prepared resources were not found");
|
||||
}
|
||||
|
||||
/// Added controls. `MemorySlot.canExpire` is `ttl != Long.MAX_VALUE`: a
|
||||
/// memory saved with that time to live never expires and is saved without
|
||||
/// one. A memory that expired and is given again is permanent. A new owner
|
||||
/// replaces the old one with no time to live carried over.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in .local/horizon-client"]
|
||||
fn never_expiring_readded_and_replaced_liked_players() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|_, _| {},
|
||||
|w, types| {
|
||||
let step = |m: &mut Mob| {
|
||||
crate::mobs::ai::tick(m, w, &mut LegacyRandom::new(1), &mut Vec::new());
|
||||
};
|
||||
// Long.MAX_VALUE: permanent.
|
||||
let tag = saved_tag(Some(Some(i64::MAX)), false);
|
||||
let mut m = load(types, &tag);
|
||||
for _ in 0..3 {
|
||||
step(&mut m);
|
||||
}
|
||||
assert_eq!(
|
||||
saved_memory(&m, Some(&tag)),
|
||||
Some((value(), None)),
|
||||
"MAX never expires"
|
||||
);
|
||||
// Expired, then given again.
|
||||
let tag = saved_tag(Some(Some(0)), false);
|
||||
let mut m = load(types, &tag);
|
||||
step(&mut m);
|
||||
assert_eq!(saved_memory(&m, Some(&tag)), None, "expired");
|
||||
let stick = crate::items::ItemStack::new("minecraft:stick", 1);
|
||||
let readded = [1, 2, 3, 4];
|
||||
assert!(interaction::predict(&mut m, Some(&stick), true, false, readded).is_some());
|
||||
for _ in 0..3 {
|
||||
step(&mut m);
|
||||
}
|
||||
assert_eq!(
|
||||
saved_memory(&m, Some(&tag)),
|
||||
Some((Some(Tag::IntArray(readded.to_vec())), None)),
|
||||
"re-added, permanent"
|
||||
);
|
||||
// A finite owner, replaced by another player through take and give.
|
||||
let tag = saved_tag(Some(Some(2)), false);
|
||||
let mut m = load(types, &tag);
|
||||
assert!(interaction::predict(&mut m, Some(&stick), true, false, OWNER).is_some());
|
||||
assert!(interaction::predict(&mut m, None, true, false, OWNER).is_some());
|
||||
let other = [5, 6, 7, 8];
|
||||
assert!(interaction::predict(&mut m, Some(&stick), true, false, other).is_some());
|
||||
for _ in 0..3 {
|
||||
step(&mut m);
|
||||
}
|
||||
assert_eq!(
|
||||
saved_memory(&m, None),
|
||||
Some((Some(Tag::IntArray(other.to_vec())), None))
|
||||
);
|
||||
assert!(!is_liked_player(&m, Some(crate::mobs::Who::Player), OWNER));
|
||||
assert!(is_liked_player(&m, Some(crate::mobs::Who::Player), other));
|
||||
},
|
||||
);
|
||||
assert!(ran, "the prepared resources were not found");
|
||||
}
|
||||
@@ -314,14 +314,16 @@ impl Goal for RandomSitting {
|
||||
MOVE
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
// checkExtraStartConditions: not leashed, and no controlling
|
||||
// passenger, a player's or a mob's (a husk on its back).
|
||||
// checkExtraStartConditions: not leashed, no controlling passenger
|
||||
// (a player's or a mob's, a husk on its back), and room for the pose
|
||||
// it would take (`Camel.canCamelChangePose`).
|
||||
!m.fluids.in_water
|
||||
&& pose_time(m) >= 400
|
||||
&& m.extra.leash.is_none()
|
||||
&& m.on_ground
|
||||
&& m.rider.is_none()
|
||||
&& !super::riders::mob_controlled(m, cx.w)
|
||||
&& can_change_pose(m, cx.w)
|
||||
&& m.random.next_int_bound(4).unwrap_or(1) == 0
|
||||
}
|
||||
fn can_continue(&mut self, _m: &mut Mob, _cx: &mut Cx) -> bool {
|
||||
@@ -678,4 +680,71 @@ mod tests {
|
||||
finish_load(&mut m, 5000);
|
||||
assert!(!refuse_to_move(&m));
|
||||
}
|
||||
|
||||
/// `CamelAi.RandomSitting.checkExtraStartConditions` ends with
|
||||
/// `body.canCamelChangePose()`. `Camel.canCamelChangePose` is
|
||||
/// `wouldNotSuffocateAtTargetPose(STANDING)` while it sits: the standing
|
||||
/// box is 2.375 tall against the sitting pose's 0.945, so a sitting camel
|
||||
/// under a two-block ceiling has no room to stand and must not try.
|
||||
#[test]
|
||||
fn a_camel_changes_pose_only_when_the_other_pose_fits() {
|
||||
let ran = crate::mobs::world::with_built_test_world(
|
||||
&[],
|
||||
|world, registry| {
|
||||
for x in -1..=1 {
|
||||
for z in -1..=1 {
|
||||
world.chunks.insert(
|
||||
(x, z),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(x, z)),
|
||||
);
|
||||
}
|
||||
}
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
world.set(x, 63, z, stone).unwrap();
|
||||
}
|
||||
}
|
||||
// In the standing camel's box (y 64..66.375) but above the
|
||||
// sitting one (y 64..64.945): blocks standing only.
|
||||
world.set(0, 65, 0, stone).unwrap();
|
||||
},
|
||||
|w, types| {
|
||||
let kind = std::sync::Arc::new(types.types["minecraft:camel"].clone());
|
||||
let sitting_camel = |id: u32, x: f64| {
|
||||
let mut m = Mob::new(id, kind.clone(), Vec3::new(x, 64.0, 0.5), 0.0);
|
||||
register(&mut m);
|
||||
m.extra.camel_sitting = true;
|
||||
// 20 seconds in the pose (`getPoseTime() >= 20 * 20`).
|
||||
m.extra.egg_time = (i64::from(m.age) - 401) as i32;
|
||||
m.on_ground = true;
|
||||
m.random = horizon_core::random::LegacyRandom::new(1234 + i64::from(id));
|
||||
m
|
||||
};
|
||||
// Under the ceiling, and out in the open.
|
||||
let mut blocked = sitting_camel(1, 0.5);
|
||||
let mut open = sitting_camel(2, 5.5);
|
||||
let mut events = Vec::new();
|
||||
let mut lr = horizon_core::random::LegacyRandom::new(0);
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
let mut goal = RandomSitting;
|
||||
// The random gate fires within a few asks when it is reached.
|
||||
for _ in 0..200 {
|
||||
assert!(
|
||||
!goal.can_use(&mut blocked, &mut cx),
|
||||
"a sitting camel stood up into the ceiling"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
(0..200).any(|_| goal.can_use(&mut open, &mut cx)),
|
||||
"with room to stand the goal should fire"
|
||||
);
|
||||
},
|
||||
);
|
||||
let _ = ran;
|
||||
}
|
||||
}
|
||||
@@ -422,8 +422,9 @@ impl Goal for CubeKeepJumping {
|
||||
fn flags(&self) -> u8 {
|
||||
JUMP | MOVE
|
||||
}
|
||||
fn can_use(&mut self, _: &mut Mob, _: &mut Cx) -> bool {
|
||||
true
|
||||
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
|
||||
// `CubeMobKeepOnJumpingGoal.canUse`: not while it is a passenger.
|
||||
!m.extra.passenger
|
||||
}
|
||||
fn tick(&mut self, m: &mut Mob, _: &mut Cx) {
|
||||
set_wanted(m, 1.0);
|
||||
@@ -590,3 +591,50 @@ mod golem_touch_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod keep_jumping_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::with_test_world;
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `CubeMobKeepOnJumpingGoal.canUse` is `!isPassenger()`: a cube riding
|
||||
/// a boat or another mob holds still, while one on its own keeps hopping.
|
||||
#[test]
|
||||
fn a_cube_that_rides_something_does_not_keep_jumping() {
|
||||
let mut starts = None;
|
||||
let ran = with_test_world(&[], |w, types| {
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
starts = Some([true, false].map(|passenger| {
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
Arc::new(types.types["minecraft:slime"].clone()),
|
||||
Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
m.extra.passenger = passenger;
|
||||
m.ai.goals.add(5, CubeKeepJumping);
|
||||
let mut goals = std::mem::take(&mut m.ai.goals);
|
||||
goals.tick(&mut m, &mut cx);
|
||||
let running = goals.is_running("CubeKeepJumping");
|
||||
m.ai.goals = goals;
|
||||
running
|
||||
}));
|
||||
});
|
||||
if !ran {
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
starts,
|
||||
Some([false, true]),
|
||||
"a cube riding anything must not start CubeMobKeepOnJumpingGoal"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,12 @@ pub struct Death<'a> {
|
||||
pub loot: &'a LootData,
|
||||
/// The killing `DamageSource`.
|
||||
pub damage: Option<LootDamage>,
|
||||
/// The player who last hurt the mob, when `lastHurtByPlayerMemoryTime > 0`.
|
||||
/// `LAST_DAMAGE_PLAYER`: the player who last hurt the mob, when they are
|
||||
/// still in the world. Gone after they leave, even while the memory lasts.
|
||||
pub player: Option<LootEntity>,
|
||||
/// `LivingEntity.lastHurtByPlayerMemoryTime > 0` (`playerKilled`):
|
||||
/// experience and equipment follow this, not whether `player` is present.
|
||||
pub player_killed: bool,
|
||||
/// That player's luck.
|
||||
pub luck: f32,
|
||||
/// The entity the mob rides.
|
||||
@@ -430,7 +434,7 @@ pub fn die(
|
||||
skulls = items.iter().any(|i| !i.is_empty());
|
||||
out.extend(items.into_iter().filter(|i| !i.is_empty()).map(Drop::Item));
|
||||
}
|
||||
let killed_by_player = d.player.is_some();
|
||||
let killed_by_player = d.player_killed;
|
||||
let s = m.species();
|
||||
// Fox.dropAllDeathLoot: whatever it holds in its mouth comes out first.
|
||||
if s == Species::Fox
|
||||
@@ -658,6 +662,7 @@ mod death_loot_table_tests {
|
||||
loot: &loot,
|
||||
damage: None,
|
||||
player: None,
|
||||
player_killed: false,
|
||||
luck: 0.0,
|
||||
vehicle: None,
|
||||
mob_drops: true,
|
||||
@@ -690,6 +695,7 @@ mod slot_tests {
|
||||
use super::*;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn mob(id: &str, species: Species) -> Mob {
|
||||
@@ -730,4 +736,78 @@ mod slot_tests {
|
||||
assert_eq!(body.item, "minecraft:iron_helmet");
|
||||
assert!(slot_item(&zombie, "head").is_none());
|
||||
}
|
||||
|
||||
/// `LivingEntity.dropExperience` and `Mob.dropCustomDeathLoot`: the
|
||||
/// memory timer (`playerKilled`) awards experience and equipment even
|
||||
/// when `LAST_DAMAGE_PLAYER` is gone (the player left).
|
||||
#[test]
|
||||
fn experience_and_equipment_follow_the_hurt_memory_not_the_player_entity() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-guest-left-xp-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("data/minecraft/loot_table")).unwrap();
|
||||
std::fs::write(root.join("items.json"), "{}").unwrap();
|
||||
let loot = LootData::load(&root, &PackOverlay::default()).unwrap();
|
||||
let death = |player_killed| Death {
|
||||
loot: &loot,
|
||||
damage: None,
|
||||
player: None,
|
||||
player_killed,
|
||||
luck: 0.0,
|
||||
vehicle: None,
|
||||
mob_drops: true,
|
||||
world_seed: 0,
|
||||
};
|
||||
let zombie = |helmet: bool| {
|
||||
let mut m = mob("minecraft:zombie", Species::Zombie);
|
||||
if helmet {
|
||||
m.extra.head = Some("minecraft:iron_helmet".into());
|
||||
m.extra.drop_chances = Some(Box::new([1.0; 8]));
|
||||
}
|
||||
m
|
||||
};
|
||||
let orbs = |out: &[Drop]| out.iter().filter(|d| matches!(d, Drop::Orb(_))).count();
|
||||
let helmet = |out: &[Drop]| {
|
||||
out.iter().any(|d| match d {
|
||||
Drop::Item(s) => s.item == "minecraft:iron_helmet",
|
||||
Drop::Stack(s) => &*s.item == "minecraft:iron_helmet",
|
||||
_ => false,
|
||||
})
|
||||
};
|
||||
|
||||
let mut m = zombie(false);
|
||||
let (out, _) = die(
|
||||
&mut m,
|
||||
&death(true),
|
||||
&mut RandomSequences::default(),
|
||||
&mut LegacyRandom::new(7),
|
||||
);
|
||||
assert!(orbs(&out) > 0, "memory timer awards experience: {out:?}");
|
||||
|
||||
let mut m = zombie(false);
|
||||
let (out, _) = die(
|
||||
&mut m,
|
||||
&death(false),
|
||||
&mut RandomSequences::default(),
|
||||
&mut LegacyRandom::new(7),
|
||||
);
|
||||
assert_eq!(orbs(&out), 0, "no timer, no experience: {out:?}");
|
||||
|
||||
let mut m = zombie(true);
|
||||
let (out, _) = die(
|
||||
&mut m,
|
||||
&death(true),
|
||||
&mut RandomSequences::default(),
|
||||
&mut LegacyRandom::new(7),
|
||||
);
|
||||
assert!(helmet(&out), "memory timer drops equipment: {out:?}");
|
||||
|
||||
let mut m = zombie(true);
|
||||
let (out, _) = die(
|
||||
&mut m,
|
||||
&death(false),
|
||||
&mut RandomSequences::default(),
|
||||
&mut LegacyRandom::new(7),
|
||||
);
|
||||
assert!(!helmet(&out), "no timer, no equipment: {out:?}");
|
||||
}
|
||||
}
|
||||
@@ -710,6 +710,7 @@ mod tests {
|
||||
loot: &loot,
|
||||
damage: None,
|
||||
player: None,
|
||||
player_killed: false,
|
||||
luck: 0.0,
|
||||
vehicle: None,
|
||||
mob_drops,
|
||||
|
||||
@@ -342,7 +342,9 @@ impl Goal for HappyGhastFloat {
|
||||
!on_still_timeout(m) && Float.can_use(m, cx)
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
Float.can_use(m, cx)
|
||||
// `HappyGhastFloatGoal` inherits `Goal.canContinueToUse`, which asks
|
||||
// the overridden `canUse`: the still timeout applies mid-float too.
|
||||
self.can_use(m, cx)
|
||||
}
|
||||
fn every_tick(&self) -> bool {
|
||||
true
|
||||
@@ -705,3 +707,54 @@ mod tests {
|
||||
assert!(m.ai.nav.can_float);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod still_float_tests {
|
||||
use super::*;
|
||||
use crate::mobs::{Species, world::with_test_world};
|
||||
|
||||
/// `HappyGhastFloatGoal` inherits `Goal.canContinueToUse` (`canUse`), so
|
||||
/// the still-timeout guard is re-checked on a running float goal too: a
|
||||
/// ghast a player stands on stops floating instead of hopping in water.
|
||||
#[test]
|
||||
fn a_happy_ghast_on_a_still_timeout_stops_floating() {
|
||||
let mut t = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
t.species = Species::HappyGhast;
|
||||
let mut m = Mob::new(
|
||||
1,
|
||||
std::sync::Arc::new(t),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
m.fluids.water = 1.0;
|
||||
let mut stopped = None;
|
||||
let ran = with_test_world(&[], |w, _| {
|
||||
let mut lr = horizon_core::random::LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Cx {
|
||||
w,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
let mut goals = std::mem::take(&mut m.ai.goals);
|
||||
goals.tick(&mut m, &mut cx);
|
||||
assert!(
|
||||
goals.is_running("HappyGhastFloatGoal"),
|
||||
"an unburdened happy ghast in deep water floats"
|
||||
);
|
||||
// `isOnStillTimeout`: an adult with serverStillTimeout > 0.
|
||||
m.extra.counter = 1;
|
||||
goals.tick(&mut m, &mut cx);
|
||||
stopped = Some(!goals.is_running("HappyGhastFloatGoal"));
|
||||
m.ai.goals = goals;
|
||||
});
|
||||
if !ran {
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
stopped,
|
||||
Some(true),
|
||||
"a happy ghast on a still timeout must stop floating"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use std::collections::HashMap;
|
||||
const DASH_COOLDOWN: i32 = 40;
|
||||
const TIME_BETWEEN_ATTACKS: i32 = 80;
|
||||
const ANGER_DURATION: i32 = 400;
|
||||
const ATTACK_TARGET_COOLDOWN: &str = "minecraft:attack_target_cooldown";
|
||||
/// `ZombieNautilusVariants`: the variant keys in index order.
|
||||
pub const VARIANTS: [&str; 2] = ["temperate", "warm"];
|
||||
|
||||
@@ -72,6 +73,7 @@ pub fn register(m: &mut Mob) {
|
||||
/// first looks for prey) and the zombie's variant by biome.
|
||||
pub fn finalize(m: &mut Mob, warm_ocean: bool) {
|
||||
m.extra.counter = 2400 + m.random.next_int_bound(1201).unwrap_or(0);
|
||||
m.extra.nautilus_attack_cooldown_ttl = Some(i64::MAX);
|
||||
if zombie(m) {
|
||||
m.extra.variant = u8::from(warm_ocean);
|
||||
}
|
||||
@@ -315,9 +317,18 @@ pub fn ai_step(
|
||||
if m.fluids.in_water {
|
||||
bubbles(m, events);
|
||||
}
|
||||
// CountDownCooldownTicks and the ANGRY_AT expiry.
|
||||
// Brain's saved slot lifetime is independent of the existing attack counter.
|
||||
tick_attack_memory(m);
|
||||
// CountDownCooldownTicks and the ANGRY_AT expiry. Keep the legacy
|
||||
// counter/target policy, including after the saved slot expires.
|
||||
if m.extra.counter > 0 {
|
||||
m.extra.counter -= 1;
|
||||
if m.extra.nautilus_attack_cooldown_ttl.is_some() {
|
||||
// CountDownCooldownTicks.tick writes with Brain.setMemory (permanent).
|
||||
m.extra.nautilus_attack_cooldown_ttl = Some(i64::MAX);
|
||||
}
|
||||
} else {
|
||||
m.extra.nautilus_attack_cooldown_ttl = None;
|
||||
}
|
||||
if m.extra.counter2 > 0 {
|
||||
m.extra.counter2 -= 1;
|
||||
@@ -329,6 +340,16 @@ pub fn ai_step(
|
||||
m.ai.target = find_target(m, w, level_random);
|
||||
}
|
||||
}
|
||||
/// `MemorySlot.tick`: expire before decrementing a finite saved lifetime.
|
||||
/// The existing NoAI gate controls this class step; this does not replace
|
||||
/// the legacy integer attack counter or its selection policy.
|
||||
fn tick_attack_memory(m: &mut Mob) {
|
||||
if let Some(ttl) = m.extra.nautilus_attack_cooldown_ttl
|
||||
&& ttl != i64::MAX
|
||||
{
|
||||
m.extra.nautilus_attack_cooldown_ttl = if ttl <= 0 { None } else { Some(ttl - 1) };
|
||||
}
|
||||
}
|
||||
/// `spawnBubbles`: more as it swims faster, from behind its mouth.
|
||||
fn bubbles(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
let v = m.velocity;
|
||||
@@ -402,6 +423,7 @@ fn find_target(
|
||||
}
|
||||
// The cooldown and the coin come from the level's random (`level.getRandom()`).
|
||||
m.extra.counter = 2400 + level_random.next_int_bound(1201).unwrap_or(0);
|
||||
m.extra.nautilus_attack_cooldown_ttl = Some(i64::MAX);
|
||||
if level_random.next_float() < 0.5 {
|
||||
return None;
|
||||
}
|
||||
@@ -632,15 +654,16 @@ pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
|
||||
}
|
||||
}
|
||||
map.insert("equipment".into(), Tag::Compound(equipment));
|
||||
let memory = m.extra.nautilus_attack_cooldown_ttl.map(|ttl| {
|
||||
let mut fields = vec![("value", Tag::Int(m.extra.counter))];
|
||||
if ttl != i64::MAX {
|
||||
fields.push(("ttl", Tag::Long(ttl)));
|
||||
}
|
||||
(ATTACK_TARGET_COOLDOWN, Tag::compound(fields))
|
||||
});
|
||||
map.insert(
|
||||
"Brain".into(),
|
||||
Tag::compound([(
|
||||
"memories",
|
||||
Tag::compound([(
|
||||
"minecraft:attack_target_cooldown",
|
||||
Tag::compound([("value", Tag::Int(m.extra.counter))]),
|
||||
)]),
|
||||
)]),
|
||||
Tag::compound([("memories", Tag::compound(memory))]),
|
||||
);
|
||||
}
|
||||
/// `TamableAnimal.readAdditionalSaveData`: Sitting is independent of Owner.
|
||||
@@ -658,13 +681,17 @@ pub fn from_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
|
||||
.and_then(|e| e.get("saddle"))
|
||||
.and_then(crate::save::item_from_nbt);
|
||||
m.set_saddle(saddle);
|
||||
if let Some(Tag::Int(v)) = tag
|
||||
let memory = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:attack_target_cooldown"))
|
||||
.and_then(|b| b.get("value"))
|
||||
{
|
||||
m.extra.counter = *v;
|
||||
.and_then(|b| b.get(ATTACK_TARGET_COOLDOWN));
|
||||
if let Some(Tag::Int(value)) = memory.and_then(|m| m.get("value")) {
|
||||
m.extra.counter = *value;
|
||||
m.extra.nautilus_attack_cooldown_ttl =
|
||||
Some(crate::save::number(memory.and_then(|m| m.get("ttl"))).unwrap_or(i64::MAX));
|
||||
} else {
|
||||
m.extra.counter = 0;
|
||||
m.extra.nautilus_attack_cooldown_ttl = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,3 +919,206 @@ mod hostile_target_tests {
|
||||
assert!(beyond.iter().all(Option::is_none), "{beyond:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cooldown_memory_tests {
|
||||
use super::*;
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
|
||||
const MEMORY: &str = "minecraft:attack_target_cooldown";
|
||||
fn raw(id: &str, state: Option<(i32, Option<i64>)>, no_ai: bool) -> Tag {
|
||||
let memory = state.map(|(value, ttl)| {
|
||||
let mut entries = vec![("value", Tag::Int(value))];
|
||||
if let Some(ttl) = ttl {
|
||||
entries.push(("ttl", Tag::Long(ttl)));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
});
|
||||
let memories = Tag::compound(memory.into_iter().map(|m| (MEMORY, m)));
|
||||
let mut fields = vec![
|
||||
("id", Tag::String(id.into())),
|
||||
(
|
||||
"Pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(4.5f64.to_bits()),
|
||||
Tag::Double(3.0f64.to_bits()),
|
||||
Tag::Double(4.5f64.to_bits()),
|
||||
]),
|
||||
),
|
||||
("NoAI", Tag::Byte(i8::from(no_ai))),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("Health", Tag::Float(10.0f32.to_bits())),
|
||||
("PersistenceRequired", Tag::Byte(1)),
|
||||
("Brain", Tag::compound([("memories", memories)])),
|
||||
];
|
||||
if !no_ai {
|
||||
fields.push(("Owner", Tag::IntArray(vec![1, 2, 3, 4])));
|
||||
}
|
||||
Tag::compound(fields)
|
||||
}
|
||||
fn memory(tag: &Tag) -> Option<&Tag> {
|
||||
tag.get("Brain")?.get("memories")?.get(MEMORY)
|
||||
}
|
||||
fn expected(state: Option<(i32, Option<i64>)>) -> Option<Tag> {
|
||||
state.map(|(value, ttl)| {
|
||||
let mut entries = vec![("value", Tag::Int(value))];
|
||||
if let Some(ttl) = ttl.filter(|&ttl| ttl != i64::MAX) {
|
||||
entries.push(("ttl", Tag::Long(ttl)));
|
||||
}
|
||||
Tag::compound(entries)
|
||||
})
|
||||
}
|
||||
fn saved(m: &Mob, stale: Option<&Tag>) -> Tag {
|
||||
crate::entity_nbt::mob_to_nbt(m, [140, 1, 0, 7], stale, None, |_| None)
|
||||
}
|
||||
#[test]
|
||||
fn decoded_cooldown_presence_ttl_and_model_mutation_save_without_raw() {
|
||||
let mut failures = Vec::new();
|
||||
for (id, species) in [
|
||||
("minecraft:nautilus", Species::Nautilus),
|
||||
("minecraft:zombie_nautilus", Species::ZombieNautilus),
|
||||
] {
|
||||
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
kind.id = id.into();
|
||||
kind.species = species;
|
||||
let types = std::collections::HashMap::from([(id.to_owned(), Arc::new(kind))]);
|
||||
for state in [
|
||||
None,
|
||||
Some((0, None)),
|
||||
Some((37, None)),
|
||||
Some((37, Some(5))),
|
||||
Some((37, Some(i64::MAX))),
|
||||
] {
|
||||
let tag = raw(id, state, true);
|
||||
let mut m = crate::entity_nbt::mob_from_nbt(&tag, &types, 7, None)
|
||||
.expect("actual class decode");
|
||||
assert_eq!(m.species(), species);
|
||||
assert!(m.no_ai && m.no_gravity && m.alive());
|
||||
assert_eq!(m.health, 10.0);
|
||||
assert_eq!(m.extra.counter, state.map_or(0, |s| s.0));
|
||||
for source in [None, Some(&tag)] {
|
||||
let out = saved(&m, source);
|
||||
if memory(&out).cloned() != expected(state) {
|
||||
failures.push(format!(
|
||||
"{id} {state:?} raw={} saved={:?}",
|
||||
source.is_some(),
|
||||
memory(&out)
|
||||
));
|
||||
}
|
||||
}
|
||||
if memory(&saved(&m.clone(), None)).cloned() != expected(state) {
|
||||
failures.push(format!("{id} cloned model lost memory presence/lifetime"));
|
||||
}
|
||||
if state.is_some() {
|
||||
m.extra.counter = 41; // The live value changes; the raw compound still holds the old value.
|
||||
let want = state.map(|(_, ttl)| (41, ttl));
|
||||
if memory(&saved(&m, Some(&tag))).cloned() != expected(want) {
|
||||
failures.push(format!("{id} stale raw value/TTL replaced modeled state"));
|
||||
}
|
||||
}
|
||||
// A later valid empty memory map clears the class memory, not merely its value.
|
||||
from_nbt(&mut m, &raw(id, None, true));
|
||||
if memory(&saved(&m, Some(&tag))).is_some() {
|
||||
failures.push(format!("{id} valid empty map retained stale memory"));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"saved nautilus cooldown state mismatches: {failures:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs actual prepared mob data; must execute, not skip"]
|
||||
fn actual_mob_ticks_retain_noai_lifetime_and_apply_active_memory_boundaries() {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES").expect("actual prepared resources"),
|
||||
);
|
||||
let mut failures = Vec::new();
|
||||
let ran = crate::mobs::world::with_resource_test_world(
|
||||
&root,
|
||||
&[],
|
||||
true,
|
||||
true,
|
||||
|world, registry| {
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
|
||||
for x in 0..16 {
|
||||
for z in 0..16 {
|
||||
world.set(x, 0, z, stone).unwrap();
|
||||
for y in 1..6 {
|
||||
world.set(x, y, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|world, types| {
|
||||
let types: std::collections::HashMap<_, _> = types
|
||||
.types
|
||||
.iter()
|
||||
.map(|(id, kind)| (id.clone(), Arc::new(kind.clone())))
|
||||
.collect();
|
||||
for id in ["minecraft:nautilus", "minecraft:zombie_nautilus"] {
|
||||
for (no_ai, state) in [
|
||||
(true, None),
|
||||
(true, Some((0, None))),
|
||||
(true, Some((37, None))),
|
||||
(true, Some((37, Some(5)))),
|
||||
(true, Some((37, Some(i64::MAX)))),
|
||||
(false, Some((37, Some(5)))),
|
||||
(false, Some((37, Some(1)))),
|
||||
(false, Some((37, Some(0)))),
|
||||
] {
|
||||
let tag = raw(id, state, no_ai);
|
||||
let mut m = crate::entity_nbt::mob_from_nbt(&tag, &types, 7, None).unwrap();
|
||||
m.attrs.speed = 0.0;
|
||||
let mut level_random = horizon_core::random::LegacyRandom::new(140);
|
||||
let mut events = Vec::new();
|
||||
for elapsed in 0..=6 {
|
||||
if elapsed > 0 {
|
||||
let mut w = world.clone();
|
||||
w.game_time += elapsed;
|
||||
assert!(m.tick(&w, &mut level_random, &mut events));
|
||||
assert!(m.fluids.in_water && m.fluids.eye_in_water);
|
||||
assert!(m.ai.target.is_none(), "safe no-attack control");
|
||||
assert!(
|
||||
!events.iter().any(|e| matches!(
|
||||
e,
|
||||
MobEvent::AttackPlayer(..) | MobEvent::AttackMob { .. }
|
||||
)),
|
||||
"no attack fixture"
|
||||
);
|
||||
}
|
||||
assert_eq!(m.health, 10.0);
|
||||
assert_eq!(m.no_ai, no_ai);
|
||||
assert!(m.no_gravity);
|
||||
let want = if no_ai || elapsed == 0 {
|
||||
state
|
||||
} else if state.unwrap().1 == Some(0) {
|
||||
None
|
||||
} else {
|
||||
Some((37 - elapsed as i32, None))
|
||||
};
|
||||
if memory(&saved(&m, None)).cloned() != expected(want) {
|
||||
failures.push(format!(
|
||||
"{id} NoAI={no_ai} {state:?} tick={elapsed}: {:?}",
|
||||
memory(&saved(&m, None))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
assert!(ran, "actual prepared world route must execute");
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"nautilus cooldown tick/save mismatches: {failures:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -317,6 +317,43 @@ struct Target {
|
||||
pos: [i32; 3],
|
||||
}
|
||||
|
||||
/// Reusable allocation buffers for pathfinding queries.
|
||||
#[derive(Default)]
|
||||
pub struct FinderBuffers {
|
||||
pub nodes: Vec<Node>,
|
||||
pub index: FxHashMap<(i32, i32, i32), u32>,
|
||||
pub heap: Vec<u32>,
|
||||
pub types: FxHashMap<(i32, i32, i32), PathType>,
|
||||
pub statics: std::cell::RefCell<FxHashMap<(i32, i32, i32), PathType>>,
|
||||
pub collisions: FxHashMap<[u64; 6], bool>,
|
||||
}
|
||||
|
||||
impl FinderBuffers {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
nodes: Vec::with_capacity(256),
|
||||
index: FxHashMap::default(),
|
||||
heap: Vec::with_capacity(256),
|
||||
types: FxHashMap::default(),
|
||||
statics: std::cell::RefCell::new(FxHashMap::default()),
|
||||
collisions: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.nodes.clear();
|
||||
self.index.clear();
|
||||
self.heap.clear();
|
||||
self.types.clear();
|
||||
self.statics.borrow_mut().clear();
|
||||
self.collisions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static TLS_BUFFERS: std::cell::RefCell<FinderBuffers> = std::cell::RefCell::new(FinderBuffers::new());
|
||||
}
|
||||
|
||||
/// One pathfinding query: the node arena, open set and per-query caches.
|
||||
pub struct Finder<'a, 'w> {
|
||||
w: &'a MobWorld<'w>,
|
||||
@@ -335,18 +372,61 @@ pub struct Finder<'a, 'w> {
|
||||
entity_depth: i32,
|
||||
}
|
||||
|
||||
impl<'a, 'w> Drop for Finder<'a, 'w> {
|
||||
fn drop(&mut self) {
|
||||
TLS_BUFFERS.with(|cell| {
|
||||
if let Ok(mut buf) = cell.try_borrow_mut() {
|
||||
self.nodes.clear();
|
||||
self.index.clear();
|
||||
self.heap.clear();
|
||||
self.types.clear();
|
||||
self.statics.borrow_mut().clear();
|
||||
self.collisions.clear();
|
||||
buf.nodes = std::mem::take(&mut self.nodes);
|
||||
buf.index = std::mem::take(&mut self.index);
|
||||
buf.heap = std::mem::take(&mut self.heap);
|
||||
buf.types = std::mem::take(&mut self.types);
|
||||
buf.statics = std::mem::take(&mut self.statics);
|
||||
buf.collisions = std::mem::take(&mut self.collisions);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'w> Finder<'a, 'w> {
|
||||
pub fn new(w: &'a MobWorld<'w>, mob: &'a PathMob, eval: Evaluator) -> Self {
|
||||
let (nodes, index, heap, types, statics, collisions) = TLS_BUFFERS.with(|cell| {
|
||||
if let Ok(mut buf) = cell.try_borrow_mut() {
|
||||
buf.clear();
|
||||
(
|
||||
std::mem::take(&mut buf.nodes),
|
||||
std::mem::take(&mut buf.index),
|
||||
std::mem::take(&mut buf.heap),
|
||||
std::mem::take(&mut buf.types),
|
||||
std::mem::take(&mut buf.statics),
|
||||
std::mem::take(&mut buf.collisions),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Vec::with_capacity(128),
|
||||
FxHashMap::default(),
|
||||
Vec::with_capacity(128),
|
||||
FxHashMap::default(),
|
||||
std::cell::RefCell::new(FxHashMap::default()),
|
||||
FxHashMap::default(),
|
||||
)
|
||||
}
|
||||
});
|
||||
Self {
|
||||
w,
|
||||
mob,
|
||||
eval,
|
||||
nodes: Vec::new(),
|
||||
index: FxHashMap::default(),
|
||||
heap: Vec::new(),
|
||||
types: FxHashMap::default(),
|
||||
statics: Default::default(),
|
||||
collisions: FxHashMap::default(),
|
||||
nodes,
|
||||
index,
|
||||
heap,
|
||||
types,
|
||||
statics,
|
||||
collisions,
|
||||
entity_width: (mob.width + 1.0).floor() as i32,
|
||||
entity_height: (mob.height + 1.0).floor() as i32,
|
||||
entity_depth: (mob.width + 1.0).floor() as i32,
|
||||
|
||||
@@ -2298,6 +2298,7 @@ mod crossbow_tests {
|
||||
loot: &loot,
|
||||
damage: None,
|
||||
player: None,
|
||||
player_killed: false,
|
||||
luck: 0.0,
|
||||
vehicle: None,
|
||||
mob_drops: true,
|
||||
|
||||
@@ -1077,6 +1077,11 @@ impl Goal for CatTempt {
|
||||
!m.extra.tame && self.0.can_use(m, cx)
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
// `CatTemptGoal` has no `canContinueToUse`: `TemptGoal`'s calls
|
||||
// `canUse()` again, so `!isTame()` is re-checked mid-tempt.
|
||||
if m.extra.tame {
|
||||
return false;
|
||||
}
|
||||
// CatTemptGoal.canScare: a selected player that is the tempter is not scared.
|
||||
let picked = self.1.is_some() && self.1 == cx.w.player.as_ref().map(|p| p.uuid);
|
||||
let scare = self.0.can_scare;
|
||||
@@ -2034,3 +2039,55 @@ mod universal_anger_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cat_tempt_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::{test_player, with_test_world};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `CatTemptGoal` overrides `canUse` with `!cat.isTame()`, and inherits
|
||||
/// `TemptGoal.canContinueToUse`, which calls `canUse()` again: taming the
|
||||
/// cat mid-tempt ends the goal. Horizon's `CatTempt::can_continue` ran the
|
||||
/// base tempt continue, which never asked whether the cat was tamed.
|
||||
#[test]
|
||||
fn a_tamed_cat_stops_being_tempted() {
|
||||
let mut outcomes = None;
|
||||
let ran = with_test_world(&[], |w, types| {
|
||||
let kind = Arc::new(types.types["minecraft:cat"].clone());
|
||||
let food = kind.food.clone();
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
let mut world = w.clone();
|
||||
let mut p = test_player(Vec3::new(2.5, 64.0, 0.5));
|
||||
p.main_hand = Some("minecraft:cod".into());
|
||||
world.player = Some(p);
|
||||
m.ai.goals.add(
|
||||
4,
|
||||
CatTempt(super::super::goals::Tempt::new(0.6, food, true), None),
|
||||
);
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Cx {
|
||||
w: &world,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
let mut goals = std::mem::take(&mut m.ai.goals);
|
||||
goals.tick(&mut m, &mut cx);
|
||||
let started = goals.is_running("CatTempt");
|
||||
m.extra.tame = true;
|
||||
goals.tick(&mut m, &mut cx);
|
||||
outcomes = Some((started, goals.is_running("CatTempt")));
|
||||
m.ai.goals = goals;
|
||||
});
|
||||
if !ran {
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
Some((true, false)),
|
||||
"a wild cat is tempted; once tamed the same goal must stop"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -180,8 +180,19 @@ impl Goal for TradeWithPlayer {
|
||||
fn flags(&self) -> u8 {
|
||||
MOVE | JUMP
|
||||
}
|
||||
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
|
||||
m.extra.flag2 && m.alive() && !m.fluids.in_water && m.on_ground
|
||||
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
// `TradeWithPlayerGoal.canUse`: alive, dry, on the ground, not hurt
|
||||
// recently, and within four blocks of the trading player.
|
||||
m.extra.flag2
|
||||
&& m.alive()
|
||||
&& m.hurt_time <= 0
|
||||
&& !m.fluids.in_water
|
||||
&& m.on_ground
|
||||
&& cx
|
||||
.w
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| m.distance_sqr(p.position) <= 16.0)
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
|
||||
m.nav_stop();
|
||||
@@ -393,3 +404,54 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trade_start_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::{test_player, with_test_world};
|
||||
use horizon_core::{geometry::Vec3, random::LegacyRandom};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `TradeWithPlayerGoal.canUse`: alive, dry, on the ground, not hurt
|
||||
/// recently and within four blocks of the trading player. A hurt trader,
|
||||
/// or one whose customer walked away, must not start the stand-still goal.
|
||||
#[test]
|
||||
fn a_trader_stops_trading_when_hurt_or_out_of_reach() {
|
||||
let mut outcomes = Vec::new();
|
||||
let ran = with_test_world(&[], |w, types| {
|
||||
let kind = Arc::new(types.types["minecraft:wandering_trader"].clone());
|
||||
for (label, hurt_time, distance) in [
|
||||
("trading", 0, 2.0),
|
||||
("hurt", 5, 2.0),
|
||||
("out of reach", 0, 5.0),
|
||||
] {
|
||||
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
m.extra.flag2 = true;
|
||||
m.on_ground = true;
|
||||
m.hurt_time = hurt_time;
|
||||
m.ai.goals.add(1, TradeWithPlayer);
|
||||
let mut world = w.clone();
|
||||
world.player = Some(test_player(Vec3::new(0.5 + distance, 64.0, 0.5)));
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Cx {
|
||||
w: &world,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
let mut goals = std::mem::take(&mut m.ai.goals);
|
||||
goals.tick(&mut m, &mut cx);
|
||||
outcomes.push((label, goals.is_running("TradeWithPlayer")));
|
||||
m.ai.goals = goals;
|
||||
}
|
||||
});
|
||||
if !ran {
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
vec![("trading", true), ("hurt", false), ("out of reach", false)],
|
||||
"TradeWithPlayerGoal starts only for an unhurt trader within four blocks"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,11 @@ impl Goal for DrownedTridentAttack {
|
||||
self.0.can_use(m, cx) && m.extra.main_hand.as_deref() == Some("minecraft:trident")
|
||||
}
|
||||
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
|
||||
self.0.can_continue(m, cx)
|
||||
// `RangedAttackGoal.canContinueToUse` asks `canUse()` again: the
|
||||
// trident test is re-checked, and the goal only falls back to a target
|
||||
// it is still walking towards once the trident is gone.
|
||||
self.0.can_use(m, cx)
|
||||
&& (m.extra.main_hand.as_deref() == Some("minecraft:trident") || !m.ai.nav.is_done())
|
||||
}
|
||||
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
|
||||
self.0.start(m, cx);
|
||||
@@ -1612,3 +1616,62 @@ mod wither_arrow_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trident_continue_tests {
|
||||
use super::*;
|
||||
use crate::mobs::world::{test_player, with_test_world};
|
||||
use horizon_core::random::LegacyRandom;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `DrownedTridentAttackGoal` overrides `canUse` with the trident test and
|
||||
/// inherits `RangedAttackGoal.canContinueToUse`, which calls `canUse()`
|
||||
/// again. Losing the trident mid-attack must therefore stop the goal;
|
||||
/// Horizon's continue asked only the base ranged goal.
|
||||
#[test]
|
||||
fn a_drowned_that_loses_its_trident_stops_the_trident_attack() {
|
||||
let mut outcomes = None;
|
||||
let ran = with_test_world(&[], |w, types| {
|
||||
let kind = Arc::new(types.types["minecraft:drowned"].clone());
|
||||
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
|
||||
m.extra.set_main_hand(Some("minecraft:trident".into()));
|
||||
m.ai.target = Some(Who::Player);
|
||||
let mut world = w.clone();
|
||||
world.player = Some(test_player(Vec3::new(3.5, 64.0, 0.5)));
|
||||
m.ai.goals.add(
|
||||
2,
|
||||
DrownedTridentAttack(crate::mobs::witch::RangedAttack::new(
|
||||
1.0,
|
||||
40,
|
||||
40,
|
||||
10.0,
|
||||
drowned_throw,
|
||||
)),
|
||||
);
|
||||
let mut lr = LegacyRandom::new(0);
|
||||
let mut events = Vec::new();
|
||||
let mut cx = Cx {
|
||||
w: &world,
|
||||
lr: &mut lr,
|
||||
events: &mut events,
|
||||
};
|
||||
let mut goals = std::mem::take(&mut m.ai.goals);
|
||||
goals.tick(&mut m, &mut cx);
|
||||
let started = goals.is_running("DrownedTridentAttackGoal");
|
||||
// No path left to follow: only the trident used to keep it going.
|
||||
m.nav_stop();
|
||||
m.extra.set_main_hand(None);
|
||||
goals.tick(&mut m, &mut cx);
|
||||
outcomes = Some((started, goals.is_running("DrownedTridentAttackGoal")));
|
||||
m.ai.goals = goals;
|
||||
});
|
||||
if !ran {
|
||||
return;
|
||||
}
|
||||
assert_eq!(
|
||||
outcomes,
|
||||
Some((true, false)),
|
||||
"a trident attack starts for a drowned holding one and stops when it is gone"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -171,6 +171,25 @@ impl MobWorld<'_> {
|
||||
self.clear_between_for(None, from, to, fluids_block)
|
||||
}
|
||||
/// The same for an entity whose `getY()` is `feet_y`: `ClipContext` hands
|
||||
#[inline]
|
||||
fn cached_block<'b>(
|
||||
&'b self,
|
||||
cached: &mut Option<((i32, i32), Option<&'b crate::chunk::Chunk>)>,
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: i32,
|
||||
) -> BlockStateId {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let chunk = match *cached {
|
||||
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
|
||||
_ => {
|
||||
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
|
||||
*cached = Some(((cx, cz), c));
|
||||
c
|
||||
}
|
||||
};
|
||||
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
|
||||
}
|
||||
/// its `CollisionContext.of(entity)` to every block, so scaffolding is
|
||||
/// solid for a viewer above it and open for one below.
|
||||
fn clear_between_for(
|
||||
@@ -186,8 +205,9 @@ impl MobWorld<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut cached = None;
|
||||
let result = traverse_blocks(from, to, 512, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
let state = self.cached_block(&mut cached, p.x, p.y, p.z);
|
||||
if fluids_block && self.facts.get(state).fluid != Fluid::None {
|
||||
return Some(());
|
||||
}
|
||||
@@ -222,7 +242,19 @@ impl MobWorld<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let state = |x, y, z| self.world.block(x, y, z);
|
||||
let cached = std::cell::Cell::new(None);
|
||||
let state = |x, y, z| {
|
||||
let (cx, cz) = (x >> 4, z >> 4);
|
||||
let chunk = match cached.get() {
|
||||
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
|
||||
_ => {
|
||||
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
|
||||
cached.set(Some(((cx, cz), c)));
|
||||
c
|
||||
}
|
||||
};
|
||||
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
|
||||
};
|
||||
visual_clip(self.facts, self.collision, &mut scene, &state, from, to)
|
||||
}
|
||||
/// Level.clip with COLLIDER shapes and no fluids: where the segment
|
||||
@@ -233,8 +265,9 @@ impl MobWorld<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut cached = None;
|
||||
let result = traverse_blocks(from, to, 512, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
let state = self.cached_block(&mut cached, p.x, p.y, p.z);
|
||||
if self.facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
@@ -254,8 +287,9 @@ impl MobWorld<'_> {
|
||||
binding: self.collision,
|
||||
subject: None,
|
||||
};
|
||||
let mut cached = None;
|
||||
let result = traverse_blocks(from, to, 512, |p: BlockPos| {
|
||||
let state = self.world.block(p.x, p.y, p.z);
|
||||
let state = self.cached_block(&mut cached, p.x, p.y, p.z);
|
||||
if self.facts.is_air(state) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ pub use crate::game::{
|
||||
NativeStructureTemplate, NativeWorldDraw,
|
||||
};
|
||||
pub use crate::gamerules::Value as GameRuleValue;
|
||||
/// What a mod asks of the terrain's picture each frame (`native_world_hold`). API @API:world_hold@.
|
||||
pub use crate::gfx::WorldHold;
|
||||
pub use crate::gfx::native_render::{
|
||||
BlockSpriteArrays, RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent,
|
||||
wgpu,
|
||||
|
||||
+130
-100
@@ -1693,7 +1693,6 @@ fn decode_column(
|
||||
}
|
||||
}
|
||||
let air = registry.default_state("minecraft:air")?.0;
|
||||
let mut cache: HashMap<String, u32> = HashMap::new();
|
||||
let Some(Tag::List(sections)) = tag.get("sections") else {
|
||||
return Some(chunk);
|
||||
};
|
||||
@@ -1709,30 +1708,7 @@ fn decode_column(
|
||||
{
|
||||
let palette: Vec<u32> = palette
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let state = BlockState::from_nbt(p).ok();
|
||||
let key = state
|
||||
.as_ref()
|
||||
.map(|s| format!("{}{:?}", s.name, s.properties))
|
||||
.unwrap_or_default();
|
||||
*cache.entry(key).or_insert_with(|| {
|
||||
state
|
||||
.and_then(|s| {
|
||||
registry.id(&s).or_else(|| {
|
||||
// Missing properties take the block's defaults.
|
||||
let default = registry.default_state(&s.name)?;
|
||||
let mut full = registry.state(default)?.clone();
|
||||
for (k, v) in &s.properties {
|
||||
if full.properties.contains_key(k) {
|
||||
full.properties.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
registry.id(&full).or(Some(default))
|
||||
})
|
||||
})
|
||||
.map_or(air, |id| id.0)
|
||||
})
|
||||
})
|
||||
.map(|p| registry.saved_state_id(p).map_or(air, |id| id.0))
|
||||
.collect();
|
||||
let target = Arc::make_mut(&mut chunk.sections[si]);
|
||||
let data = match states.get("data") {
|
||||
@@ -1934,70 +1910,91 @@ pub fn load_light(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
|
||||
};
|
||||
layers[si] = (layer("SkyLight"), layer("BlockLight"));
|
||||
}
|
||||
// Uniform saved layers need no expanded allocation. Both nibbles of
|
||||
// each byte must match, not merely the bytes across the layer.
|
||||
// A saved layer is already the resident `Light::Sky`/`Light::Block`
|
||||
// layer: byte `i` holds block `2i` in its low nibble and `2i + 1` in its
|
||||
// high one. Both nibbles of each byte must match for a layer to be
|
||||
// uniform; only then is the whole section one value.
|
||||
let uniform_layer = |layer: &[u8]| {
|
||||
let first = layer[0];
|
||||
(first & 15 == first >> 4 && layer.iter().all(|&v| v == first)).then_some(first & 15)
|
||||
};
|
||||
let copy_layer = |layer: &[u8]| {
|
||||
let mut out = Box::new([0u8; 2048]);
|
||||
out.copy_from_slice(layer);
|
||||
out
|
||||
};
|
||||
let mut above = [if sky_light { 15 } else { 0 }; 256];
|
||||
for (si, (sky, block)) in layers.into_iter().enumerate().rev() {
|
||||
let sky = sky.filter(|_| sky_light);
|
||||
let sky_uniform = sky.map_or_else(
|
||||
|| above.iter().all(|&v| v == above[0]).then_some(above[0]),
|
||||
uniform_layer,
|
||||
);
|
||||
let block_uniform = block.map_or(Some(0), uniform_layer);
|
||||
let sky_uniform = sky.and_then(uniform_layer);
|
||||
let sky_varies = match sky {
|
||||
Some(_) => sky_uniform.is_none(),
|
||||
None => above.iter().any(|&v| v != above[0]),
|
||||
};
|
||||
let block_uniform = block.and_then(uniform_layer);
|
||||
let block_varies = block.is_some() && block_uniform.is_none();
|
||||
let sky_first = sky.map_or(above[0], |layer| layer[0] & 15);
|
||||
let block_first = block.map_or(0, |layer| layer[0] & 15);
|
||||
let section = Arc::make_mut(&mut chunk.sections[si]);
|
||||
if let (Some(s), Some(b)) = (sky_uniform, block_uniform) {
|
||||
section.light = Light::Uniform(s << 4 | b);
|
||||
above.fill(s);
|
||||
} else {
|
||||
let mut data = Box::new([0u8; 4096]);
|
||||
match (sky, block) {
|
||||
(Some(sky), Some(block)) => {
|
||||
for ((pair, &s), &b) in
|
||||
data.as_chunks_mut::<2>().0.iter_mut().zip(sky).zip(block)
|
||||
{
|
||||
pair[0] = s << 4 | (b & 15);
|
||||
pair[1] = (s & 0xf0) | (b >> 4);
|
||||
section.compact();
|
||||
// The saved layer decides the resident variant directly: expand
|
||||
// only when both nibbles vary (which keeps `Light::Full`).
|
||||
section.light = match (sky_varies, block_varies) {
|
||||
(false, false) => Light::Uniform(sky_first << 4 | block_first),
|
||||
(false, true) => Light::Block(copy_layer(block.expect("block layer")), sky_first),
|
||||
(true, false) => {
|
||||
let layer = match sky {
|
||||
Some(layer) => copy_layer(layer),
|
||||
// No stored sky: the same 256-byte bottom row over and
|
||||
// over, which is exactly `Light::packed`'s layer here.
|
||||
None => {
|
||||
let mut layer = Box::new([0u8; 2048]);
|
||||
for row in layer.as_chunks_mut::<128>().0 {
|
||||
for (out, v) in
|
||||
row.iter_mut().zip(above.as_chunks::<2>().0.iter().cycle())
|
||||
{
|
||||
*out = v[0] | v[1] << 4;
|
||||
}
|
||||
}
|
||||
layer
|
||||
}
|
||||
}
|
||||
(Some(sky), None) => {
|
||||
for (pair, &s) in data.as_chunks_mut::<2>().0.iter_mut().zip(sky) {
|
||||
pair[0] = s << 4;
|
||||
pair[1] = s & 0xf0;
|
||||
}
|
||||
}
|
||||
(None, Some(block)) => {
|
||||
for (i, (pair, &b)) in data
|
||||
.as_chunks_mut::<2>()
|
||||
.0
|
||||
.iter_mut()
|
||||
.zip(block)
|
||||
.enumerate()
|
||||
{
|
||||
pair[0] = above[(i * 2) & 255] << 4 | (b & 15);
|
||||
pair[1] = above[(i * 2 + 1) & 255] << 4 | (b >> 4);
|
||||
}
|
||||
}
|
||||
(None, None) => {
|
||||
for layer in data.as_chunks_mut::<256>().0 {
|
||||
for (v, &s) in layer.iter_mut().zip(&above) {
|
||||
*v = s << 4;
|
||||
};
|
||||
Light::Sky(layer, block_first)
|
||||
}
|
||||
(true, true) => {
|
||||
let mut data = Box::new([0u8; 4096]);
|
||||
match (sky, block) {
|
||||
(Some(sky), Some(block)) => {
|
||||
for ((pair, &s), &b) in
|
||||
data.as_chunks_mut::<2>().0.iter_mut().zip(sky).zip(block)
|
||||
{
|
||||
pair[0] = s << 4 | (b & 15);
|
||||
pair[1] = (s & 0xf0) | (b >> 4);
|
||||
}
|
||||
}
|
||||
(None, Some(block)) => {
|
||||
for (i, (pair, &b)) in data
|
||||
.as_chunks_mut::<2>()
|
||||
.0
|
||||
.iter_mut()
|
||||
.zip(block)
|
||||
.enumerate()
|
||||
{
|
||||
pair[0] = above[(i * 2) & 255] << 4 | (b & 15);
|
||||
pair[1] = above[(i * 2 + 1) & 255] << 4 | (b >> 4);
|
||||
}
|
||||
}
|
||||
_ => unreachable!("both light kinds vary"),
|
||||
}
|
||||
Light::Full(data)
|
||||
}
|
||||
if let Some(sky) = sky {
|
||||
for (pair, &s) in above.as_chunks_mut::<2>().0.iter_mut().zip(sky) {
|
||||
pair[0] = s & 15;
|
||||
pair[1] = s >> 4;
|
||||
}
|
||||
};
|
||||
if let Some(sky) = sky {
|
||||
for (pair, &s) in above.as_chunks_mut::<2>().0.iter_mut().zip(sky) {
|
||||
pair[0] = s & 15;
|
||||
pair[1] = s >> 4;
|
||||
}
|
||||
section.light = Light::Full(data);
|
||||
}
|
||||
section.compact();
|
||||
}
|
||||
chunk.lit = true;
|
||||
chunk.light_loaded = true;
|
||||
@@ -6959,6 +6956,34 @@ mod tests {
|
||||
assert_eq!(nibbles(&light, false)[0], 0x23);
|
||||
assert_eq!(nibbles(&light, true)[0], 0x5F);
|
||||
}
|
||||
/// The independent reference: parse the saved layers and let
|
||||
/// `crate::light::light_from_layers` expand and repack them.
|
||||
fn reference(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
|
||||
if tag.get("isLightOn") != Some(&Tag::Byte(1)) {
|
||||
return;
|
||||
}
|
||||
let Some(Tag::List(sections)) = tag.get("sections") else {
|
||||
return;
|
||||
};
|
||||
let mut layers: Vec<(Option<&[u8]>, Option<&[u8]>)> = vec![(None, None); SECTIONS];
|
||||
for section in sections {
|
||||
let Some(y) = number(section.get("Y")) else {
|
||||
continue;
|
||||
};
|
||||
let si = y as i32 - (MIN_Y >> 4);
|
||||
if !(0..SECTIONS as i32).contains(&si) {
|
||||
continue;
|
||||
}
|
||||
let layer = |name: &str| match section.get(name) {
|
||||
Some(Tag::ByteArray(a)) if a.len() == 2048 => Some(&a[..]),
|
||||
_ => None,
|
||||
};
|
||||
layers[si as usize] = (layer("SkyLight"), layer("BlockLight"));
|
||||
}
|
||||
let above = [if sky_light { 15 } else { 0 }; 256];
|
||||
crate::light::light_from_layers(chunk, &layers, above, sky_light);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_light_loads_with_vanilla_defaults() {
|
||||
let mut sky = vec![0u8; 2048];
|
||||
@@ -7007,32 +7032,6 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn saved_light_matches_reference_for_layers_and_metadata() {
|
||||
fn reference(tag: &Tag, chunk: &mut Chunk, sky_light: bool) {
|
||||
if tag.get("isLightOn") != Some(&Tag::Byte(1)) {
|
||||
return;
|
||||
}
|
||||
let Some(Tag::List(sections)) = tag.get("sections") else {
|
||||
return;
|
||||
};
|
||||
let mut layers: Vec<(Option<&[u8]>, Option<&[u8]>)> = vec![(None, None); SECTIONS];
|
||||
for section in sections {
|
||||
let Some(y) = number(section.get("Y")) else {
|
||||
continue;
|
||||
};
|
||||
let si = y as i32 - (MIN_Y >> 4);
|
||||
if !(0..SECTIONS as i32).contains(&si) {
|
||||
continue;
|
||||
}
|
||||
let layer = |name: &str| match section.get(name) {
|
||||
Some(Tag::ByteArray(a)) if a.len() == 2048 => Some(&a[..]),
|
||||
_ => None,
|
||||
};
|
||||
layers[si as usize] = (layer("SkyLight"), layer("BlockLight"));
|
||||
}
|
||||
let above = [if sky_light { 15 } else { 0 }; 256];
|
||||
crate::light::light_from_layers(chunk, &layers, above, sky_light);
|
||||
}
|
||||
|
||||
let section = |y: Tag, sky: Option<Tag>, block: Option<Tag>| {
|
||||
let mut entries = vec![("Y", y)];
|
||||
if let Some(v) = sky {
|
||||
@@ -7150,6 +7149,37 @@ mod tests {
|
||||
section(Tag::Byte(0), None, Some(Tag::ByteArray(varied))),
|
||||
]));
|
||||
}
|
||||
/// A saved sky layer is the resident `Light::Sky` layer byte for byte,
|
||||
/// so a section whose block light is uniformly zero can adopt it.
|
||||
#[test]
|
||||
fn a_varying_sky_layer_is_adopted_without_expansion() {
|
||||
let saved: Vec<u8> = (0..2048).map(|i| (i as u8).wrapping_mul(29)).collect();
|
||||
let tag = Tag::compound([
|
||||
("isLightOn", Tag::Byte(1)),
|
||||
(
|
||||
"sections",
|
||||
Tag::List(vec![Tag::compound([
|
||||
("Y", Tag::Byte(3)),
|
||||
("SkyLight", Tag::ByteArray(saved.clone())),
|
||||
])]),
|
||||
),
|
||||
]);
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
let mut expected = chunk.clone();
|
||||
load_light(&tag, &mut chunk, true);
|
||||
reference(&tag, &mut expected, true);
|
||||
for i in 0..4096 {
|
||||
assert_eq!(chunk.sections[7].light(i), expected.sections[7].light(i));
|
||||
}
|
||||
let Light::Sky(layer, 0) = &chunk.sections[7].light else {
|
||||
panic!("varying sky with no block light is Light::Sky(_, 0)");
|
||||
};
|
||||
assert_eq!(
|
||||
&layer[..],
|
||||
&saved[..],
|
||||
"resident layer equals the saved one"
|
||||
);
|
||||
}
|
||||
/// #2571: the difficulty is a property of the world: the level data
|
||||
/// holds it, so a world that was set to hard is hard when it is opened
|
||||
/// again, and a world that was downloaded keeps the difficulty it was
|
||||
|
||||
@@ -830,7 +830,7 @@ impl Title {
|
||||
seed: String::new(),
|
||||
creative: false,
|
||||
focus: 0,
|
||||
options: WorldOptions::default(),
|
||||
options: WorldOptions::for_new_world(),
|
||||
}
|
||||
}
|
||||
/// WorldSelectionList.handleNewLevels: a singleplayer list that comes
|
||||
|
||||
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user