mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Merge remote-tracking branch 'origin/main' into feat/management-gamerules-parity
# Conflicts: # docs/server.md
This commit is contained in:
commit
5dedbb4ccb
22 files changed
+1630
-454
No files matched your search
@@ -20,6 +20,53 @@ thread_local! {
|
||||
static CONTAINED: Cell<u32> = const { Cell::new(0) };
|
||||
/// The message of the last panic `contain` caught on this thread, from the hook.
|
||||
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
/// The native mod hook this thread is inside, for the panic hook to name.
|
||||
static IN_NATIVE_MOD: Cell<Option<NativeModCall>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// A call into one hook of a native mod.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct NativeModCall {
|
||||
/// The mod's `[mod] id`, None in a build that didn't name its mods.
|
||||
pub id: Option<&'static str>,
|
||||
/// Its place in dispatch order, which names it then.
|
||||
pub index: usize,
|
||||
pub hook: &'static str,
|
||||
}
|
||||
|
||||
/// Marks this thread as inside `call` until the guard drops, which also happens while a panic
|
||||
/// unwinds. A hook called from a hook is named by the inner call.
|
||||
pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
|
||||
NativeModGuard {
|
||||
outer: IN_NATIVE_MOD.with(|c| c.replace(Some(call))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts back the call that `enter_native_mod` found.
|
||||
#[must_use]
|
||||
pub struct NativeModGuard {
|
||||
outer: Option<NativeModCall>,
|
||||
}
|
||||
|
||||
impl Drop for NativeModGuard {
|
||||
fn drop(&mut self) {
|
||||
IN_NATIVE_MOD.with(|c| c.set(self.outer));
|
||||
}
|
||||
}
|
||||
|
||||
/// `message`, with the native mod hook this thread is inside, if any.
|
||||
fn attributed(message: String) -> String {
|
||||
match IN_NATIVE_MOD.with(Cell::get) {
|
||||
Some(NativeModCall {
|
||||
id: Some(id), hook, ..
|
||||
}) => format!("{message}\nin native mod {id}, hook {hook}"),
|
||||
Some(NativeModCall {
|
||||
id: None,
|
||||
index,
|
||||
hook,
|
||||
}) => format!("{message}\nin native mod #{index}, hook {hook}"),
|
||||
None => message,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
|
||||
@@ -418,10 +465,10 @@ fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
|
||||
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
|
||||
let text = payload_text(info.payload());
|
||||
match info.location() {
|
||||
attributed(match info.location() {
|
||||
Some(at) => format!("panicked at {at}:\n{text}"),
|
||||
None => format!("panicked:\n{text}"),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A `std::backtrace` as one line a frame, `function (file:line:col)`. Captured in a panic hook
|
||||
@@ -537,7 +584,8 @@ pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str)
|
||||
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
|
||||
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
|
||||
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
|
||||
/// does. The default hook still prints the panic. A panic inside `contain` is only logged. A
|
||||
/// panic inside a native mod's hook names the mod and the hook.
|
||||
pub fn install(home: PathBuf, side: &'static str) {
|
||||
let default = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
@@ -676,6 +724,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_a_native_mods_hook_names_it() {
|
||||
let outer = enter_native_mod(NativeModCall {
|
||||
id: None,
|
||||
index: 1,
|
||||
hook: "render",
|
||||
});
|
||||
let inner = enter_native_mod(NativeModCall {
|
||||
id: Some("b"),
|
||||
index: 0,
|
||||
hook: "tick",
|
||||
});
|
||||
assert!(attributed("boom".into()).ends_with("\nin native mod b, hook tick"));
|
||||
drop(inner);
|
||||
assert!(attributed("boom".into()).ends_with("\nin native mod #1, hook render"));
|
||||
drop(outer);
|
||||
assert_eq!(attributed("boom".into()), "boom");
|
||||
}
|
||||
|
||||
fn report() -> Report {
|
||||
Report {
|
||||
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
|
||||
@@ -787,17 +854,37 @@ mod tests {
|
||||
#[test]
|
||||
fn a_panic_becomes_a_report() {
|
||||
// A real panic through a hook, as the game's would be.
|
||||
let caught = std::sync::Arc::new(Mutex::new(None));
|
||||
let caught = std::sync::Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = caught.clone();
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if panic_message(info).contains("the test crash") {
|
||||
*sink.lock().unwrap() = Some(panic_report(info, "client"));
|
||||
sink.lock().unwrap().push(panic_report(info, "client"));
|
||||
}
|
||||
}));
|
||||
let _ = std::panic::catch_unwind(|| panic!("the test crash"));
|
||||
// The same panic inside a native mod's hook, which the hook sees before the unwind.
|
||||
let _ = std::panic::catch_unwind(|| {
|
||||
let _guard = enter_native_mod(NativeModCall {
|
||||
id: Some("a"),
|
||||
index: 0,
|
||||
hook: "tick",
|
||||
});
|
||||
panic!("the test crash")
|
||||
});
|
||||
std::panic::set_hook(previous);
|
||||
let report = caught.lock().unwrap().take().expect("the hook ran");
|
||||
// A caught panic leaves the thread as it found it.
|
||||
assert_eq!(attributed("boom".into()), "boom");
|
||||
let mut reports = std::mem::take(&mut *caught.lock().unwrap());
|
||||
assert_eq!(reports.len(), 2, "the hook ran for both panics");
|
||||
let in_mod = reports.pop().unwrap();
|
||||
assert!(
|
||||
in_mod.error.ends_with("\nin native mod a, hook tick"),
|
||||
"{}",
|
||||
in_mod.error
|
||||
);
|
||||
let report = reports.pop().unwrap();
|
||||
assert!(!report.error.contains("native mod"));
|
||||
assert!(report.error.contains("the test crash"));
|
||||
assert!(report.error.contains("crash.rs"));
|
||||
// The first frame is the test's own code, not the panic machinery.
|
||||
|
||||
@@ -1987,6 +1987,14 @@ impl Streaming {
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let positions = if positions.len() > INLINE_RELIGHT_MAX {
|
||||
self.relight_on_workers(world, positions)
|
||||
} else {
|
||||
positions
|
||||
};
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut changed = LightChanges::default();
|
||||
crate::light::relight(
|
||||
&mut world.chunks,
|
||||
@@ -2005,6 +2013,25 @@ impl Streaming {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Hands a big batch of edits (a `/fill` arriving block by block) to the
|
||||
/// workers: the columns they touch and their neighbours get full light
|
||||
/// jobs, as `World::edit_runs` does, instead of relighting every changed
|
||||
/// cell in the frame (clearing a 64x64x16 volume in the air relit the
|
||||
/// sky below it: 43 ms of one frame). The sections wait for those jobs
|
||||
/// before they are meshed again. Returns the edits left to light in the
|
||||
/// frame: those in columns that can't be lit whole yet (the edge of the
|
||||
/// loaded area).
|
||||
fn relight_on_workers(
|
||||
&self,
|
||||
world: &mut World,
|
||||
positions: Vec<(i32, i32, i32)>,
|
||||
) -> Vec<(i32, i32, i32)> {
|
||||
let (columns, inline) = whole_relight_columns(positions, |x, z| {
|
||||
self.lit.contains(&(x, z)) && self.light_region_ready(world, x, z)
|
||||
});
|
||||
world.dirty_light.extend(columns);
|
||||
inline
|
||||
}
|
||||
/// A light job computed from blocks older than some edits: redo those
|
||||
/// edits on top of its result.
|
||||
fn replay_light_edits(&mut self, world: &mut World, x: i32, z: i32, snapshot: u64) {
|
||||
@@ -2693,6 +2720,36 @@ fn same_values(
|
||||
x == y
|
||||
}
|
||||
|
||||
/// The most changed blocks one frame relights itself (`Streaming::relight_edits`): a 16^3
|
||||
/// section's worth. Past it, the columns are lit again whole on the workers.
|
||||
const INLINE_RELIGHT_MAX: usize = 4096;
|
||||
|
||||
/// Splits edits at `positions` into the columns to light again whole and the
|
||||
/// edits to relight in place. An edited column is lit whole with its
|
||||
/// neighbours (light reaches 15 blocks, never past the next column) when all
|
||||
/// nine are `ready` to be lit whole; otherwise its edits stay in place.
|
||||
fn whole_relight_columns(
|
||||
positions: Vec<(i32, i32, i32)>,
|
||||
ready: impl Fn(i32, i32) -> bool,
|
||||
) -> (FxHashSet<(i32, i32)>, Vec<(i32, i32, i32)>) {
|
||||
let around =
|
||||
|(x, z): (i32, i32)| (-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)));
|
||||
let touched: FxHashSet<(i32, i32)> = positions
|
||||
.iter()
|
||||
.map(|&(x, _, z)| (x >> 4, z >> 4))
|
||||
.collect();
|
||||
let whole: FxHashSet<(i32, i32)> = touched
|
||||
.into_iter()
|
||||
.filter(|&column| around(column).all(|(x, z)| ready(x, z)))
|
||||
.collect();
|
||||
let columns = whole.iter().flat_map(|&column| around(column)).collect();
|
||||
let inline = positions
|
||||
.into_iter()
|
||||
.filter(|&(x, _, z)| !whole.contains(&(x >> 4, z >> 4)))
|
||||
.collect();
|
||||
(columns, inline)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2840,6 +2897,31 @@ mod tests {
|
||||
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
/// Edits in two neighbouring columns light those and the ten around
|
||||
/// them again; a column next to one that can't be lit whole keeps its
|
||||
/// edits in the frame.
|
||||
#[test]
|
||||
fn whole_relights_cover_the_edited_columns_and_their_neighbours() {
|
||||
// Blocks in columns (0, 0) and (1, 0).
|
||||
let positions = vec![(3, 70, 4), (15, 64, 15), (16, 80, 0), (31, 64, 9)];
|
||||
let (columns, inline) = whole_relight_columns(positions.clone(), |_, _| true);
|
||||
let mut columns: Vec<_> = columns.into_iter().collect();
|
||||
columns.sort();
|
||||
let expected: Vec<(i32, i32)> = (-1..=2)
|
||||
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
|
||||
.collect();
|
||||
assert_eq!(columns, expected);
|
||||
assert!(inline.is_empty());
|
||||
// (2, 1) is next to (1, 0) only: (0, 0) is still lit whole.
|
||||
let (columns, inline) = whole_relight_columns(positions, |x, z| (x, z) != (2, 1));
|
||||
assert_eq!(inline, vec![(16, 80, 0), (31, 64, 9)]);
|
||||
assert_eq!(columns.len(), 9);
|
||||
assert!(columns.contains(&(-1, -1)) && columns.contains(&(1, 1)));
|
||||
// Negative coordinates floor to their column.
|
||||
let (west, _) = whole_relight_columns(vec![(-1, 64, -17)], |_, _| true);
|
||||
assert!(west.contains(&(-2, -3)) && west.contains(&(0, -1)) && west.len() == 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_jobs_follow_the_centre() {
|
||||
let forced = HashSet::new();
|
||||
|
||||
@@ -890,6 +890,9 @@ impl Game<'_> {
|
||||
self.settle_spawn()?;
|
||||
let gate_lists = lists.clone();
|
||||
let gate: LoginGate = Arc::new(move |address, uuid, name| {
|
||||
// `ServerActivityMonitor.reportLoginActivity`: a login attempt, however it ends. The management
|
||||
// protocol's `server/activity` notification is rate limited from these.
|
||||
events::record_server("login_activity", serde_json::json!({}));
|
||||
let user = NameAndId {
|
||||
id: uuid,
|
||||
name: name.to_owned(),
|
||||
|
||||
@@ -403,6 +403,51 @@ pub fn mods() -> &'static [Box<dyn Hooks>] {
|
||||
MODS.get().map_or(&[], |mods| mods.as_ref())
|
||||
}
|
||||
|
||||
/// The bundles' `[mod] id`s, one a [`mods`] entry in the same order.
|
||||
static MOD_IDS: OnceLock<&'static [&'static str]> = OnceLock::new();
|
||||
|
||||
/// Names the entries of [`mods`] by their bundles' `[mod] id`s, so a panic in a hook names its mod.
|
||||
/// The launcher's generated executable calls it before `entry::main_with`.
|
||||
pub fn name_mods(ids: &'static [&'static str]) {
|
||||
if MOD_IDS.set(ids).is_err() {
|
||||
log::warn!("Trait mods already named; ignoring the second list");
|
||||
}
|
||||
}
|
||||
|
||||
/// `mods` in order, each marked as running `hook` until the next one is (or the loop ends), so a
|
||||
/// panic in it names the mod. The generated dispatch loops over it.
|
||||
fn entered<'a>(mods: &'a [Box<dyn Hooks>], hook: &'static str) -> Entered<'a> {
|
||||
Entered {
|
||||
mods: mods.iter().enumerate(),
|
||||
hook,
|
||||
guard: None,
|
||||
}
|
||||
}
|
||||
|
||||
struct Entered<'a> {
|
||||
mods: std::iter::Enumerate<std::slice::Iter<'a, Box<dyn Hooks>>>,
|
||||
hook: &'static str,
|
||||
guard: Option<crate::crash::NativeModGuard>,
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Entered<'a> {
|
||||
type Item = &'a Box<dyn Hooks>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
// The last mod's mark goes first, so the next one's puts back what was there before.
|
||||
drop(self.guard.take());
|
||||
let (index, hooks) = self.mods.next()?;
|
||||
self.guard = Some(crate::crash::enter_native_mod(
|
||||
crate::crash::NativeModCall {
|
||||
id: MOD_IDS.get().and_then(|ids| ids.get(index)).copied(),
|
||||
index,
|
||||
hook: self.hook,
|
||||
},
|
||||
));
|
||||
Some(hooks)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a mod claims a cancellable hook.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Flow {
|
||||
@@ -499,6 +544,17 @@ pub use generated::*;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct Quiet;
|
||||
impl Hooks for Quiet {}
|
||||
|
||||
struct Broken;
|
||||
impl Hooks for Broken {
|
||||
fn component_types(&self, _event: &mut ComponentTypesEvent<'_>) {
|
||||
panic!("a broken mod");
|
||||
}
|
||||
}
|
||||
|
||||
/// A double with the three `Host` methods the probe calls.
|
||||
struct Reader {
|
||||
@@ -564,6 +620,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_in_a_hook_names_the_mod_and_the_hook() {
|
||||
name_mods(&["quiet", "broken"]);
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Quiet), Box::new(Broken)];
|
||||
let caught = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = caught.clone();
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let error = crate::crash::panic_report(info, "client").error;
|
||||
if error.contains("a broken mod") {
|
||||
sink.lock().unwrap().push(error);
|
||||
}
|
||||
}));
|
||||
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
dispatch_component_types(&mods, &mut Vec::new())
|
||||
}));
|
||||
// Once the dispatch has unwound, the thread is in no mod.
|
||||
let _ = std::panic::catch_unwind(|| panic!("a broken mod's caller"));
|
||||
std::panic::set_hook(previous);
|
||||
// The game stops as before: the panic goes on to the dispatch's caller.
|
||||
assert!(unwound.is_err());
|
||||
let errors = caught.lock().unwrap();
|
||||
assert_eq!(errors.len(), 2);
|
||||
assert!(
|
||||
errors[0].ends_with("\nin native mod broken, hook component_types"),
|
||||
"{}",
|
||||
errors[0]
|
||||
);
|
||||
assert!(!errors[1].contains("in native mod"), "{}", errors[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_test_host_implements_only_what_its_hook_calls() {
|
||||
let mods = [Box::new(Probe) as Box<dyn Hooks>];
|
||||
|
||||
+380
-380
File diff suppressed because it is too large.
Load diff
@@ -21,7 +21,7 @@ use std::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
},
|
||||
time::Duration,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
/// The connections that have upgraded, and the queue that carries notifications to them, which
|
||||
@@ -34,8 +34,13 @@ pub struct Clients {
|
||||
/// `server/stopping` went out for this run of the game: a stop comes from the supervisor and from the
|
||||
/// game's own `stop`, and one run says it once.
|
||||
stopping_told: AtomicBool,
|
||||
/// When `server/activity` last went out.
|
||||
activity_told: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
/// `MinecraftServer.SERVER_ACTIVITY_MONITOR_SECONDS_BETWEEN_NOTIFICATIONS`.
|
||||
const ACTIVITY_EVERY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A connection in `Clients` for as long as this lives.
|
||||
struct Registered<'a>(&'a Shared, u32);
|
||||
|
||||
@@ -83,6 +88,13 @@ pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
|
||||
"leave" => ("notification/players/left", player()),
|
||||
"saving" => ("notification/server/saving", None),
|
||||
"saved" => ("notification/server/saved", None),
|
||||
// `ServerActivityMonitor.reportLoginActivity`: a login attempt, told at most once in thirty seconds.
|
||||
"login_activity" => {
|
||||
if activity_due(shared, Instant::now()) {
|
||||
notify(shared, "notification/server/activity", None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A change of a list, whoever made it: the console, a command in the game, the protocol's own methods.
|
||||
_ => match crate::management_methods::list_notification(kind, event) {
|
||||
Some((method, params)) => (method, Some(params)),
|
||||
@@ -92,6 +104,35 @@ pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
|
||||
notify(shared, method, params);
|
||||
}
|
||||
|
||||
/// `ServerActivityMonitor.processWithRateLimit`: an activity is told when thirty seconds have gone by since the
|
||||
/// last one told, and is lost otherwise (the monitor clears its flag whatever it did).
|
||||
fn activity_due(shared: &Shared, now: Instant) -> bool {
|
||||
let mut told = shared.management.activity_told.lock().unwrap();
|
||||
let due = told.is_none_or(|last| now.saturating_duration_since(last) >= ACTIVITY_EVERY);
|
||||
if due {
|
||||
*told = Some(now);
|
||||
}
|
||||
due
|
||||
}
|
||||
|
||||
/// `ManagementServer.scheduleHeartbeat`: `NotificationService.statusHeartbeat` every `seconds` (the first
|
||||
/// after as many), with the server's status as `server/status` answers it, up or not. Nothing is computed
|
||||
/// while no client is connected.
|
||||
fn heartbeat(shared: Arc<Shared>, seconds: u64) -> std::io::Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("management-heartbeat".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(seconds));
|
||||
if !shared.management.sockets.lock().unwrap().is_empty() {
|
||||
let status = crate::management_methods::status(&shared);
|
||||
notify(&shared, "notification/server/status", Some(status));
|
||||
}
|
||||
}
|
||||
})
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
/// `NotificationService.serverShuttingDown`, which `DedicatedServer.stopServer` sends first of all.
|
||||
pub fn stopping(shared: &Shared) {
|
||||
if !shared.management.stopping_told.swap(true, Ordering::SeqCst) {
|
||||
@@ -107,6 +148,8 @@ struct Settings {
|
||||
allowed_origins: Vec<String>,
|
||||
/// TLS, unless `management-server-tls-enabled=false`.
|
||||
tls: Option<management_tls::Tls>,
|
||||
/// `status-heartbeat-interval`: seconds between the status notifications, none when 0.
|
||||
heartbeat: u64,
|
||||
}
|
||||
|
||||
/// `SecurityConfig.isValid`: 40 letters and digits.
|
||||
@@ -127,6 +170,7 @@ fn settings(dir: &Path) -> Result<Option<Settings>, String> {
|
||||
if !p.management_server_enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
let heartbeat = p.status_heartbeat_interval.max(0) as u64;
|
||||
let mut secret = p.management_server_secret;
|
||||
if generated {
|
||||
secret = generate_secret_key(&mut SystemEntropy::default());
|
||||
@@ -184,6 +228,7 @@ fn settings(dir: &Path) -> Result<Option<Settings>, String> {
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
tls,
|
||||
heartbeat,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -231,6 +276,9 @@ fn serve(
|
||||
) -> std::io::Result<()> {
|
||||
let (outbox, queue) = mpsc::channel::<String>();
|
||||
*shared.management.outbox.lock().unwrap() = Some(outbox);
|
||||
if settings.heartbeat > 0 {
|
||||
heartbeat(shared.clone(), settings.heartbeat)?;
|
||||
}
|
||||
let carrier = shared.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("management-notify".into())
|
||||
@@ -556,6 +604,7 @@ mod tests {
|
||||
secret: SECRET.into(),
|
||||
allowed_origins: vec!["http://localhost:3000".into()],
|
||||
tls: None,
|
||||
heartbeat: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -910,6 +959,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_activity_is_told_once_in_thirty_seconds_and_otherwise_lost() {
|
||||
let shared = shared();
|
||||
let start = Instant::now();
|
||||
assert!(activity_due(&shared, start));
|
||||
assert!(!activity_due(&shared, start + Duration::from_secs(29)));
|
||||
// The one lost at 29 s is not told later: the next login after thirty seconds is.
|
||||
assert!(!activity_due(&shared, start + Duration::from_secs(29)));
|
||||
assert!(activity_due(&shared, start + Duration::from_secs(30)));
|
||||
assert!(!activity_due(&shared, start + Duration::from_secs(59)));
|
||||
assert!(activity_due(&shared, start + Duration::from_secs(60)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_login_is_an_activity_notification_and_the_heartbeat_carries_the_status() {
|
||||
let shared = shared();
|
||||
let address = listen_on(
|
||||
shared.clone(),
|
||||
Settings {
|
||||
heartbeat: 1,
|
||||
..settings()
|
||||
},
|
||||
);
|
||||
let mut client = upgrade(address);
|
||||
registered(&shared, 1);
|
||||
game_event(
|
||||
&shared,
|
||||
"login_activity",
|
||||
&json!({ "type": "login_activity" }),
|
||||
);
|
||||
game_event(
|
||||
&shared,
|
||||
"login_activity",
|
||||
&json!({ "type": "login_activity" }),
|
||||
);
|
||||
assert_eq!(
|
||||
next_text(&mut client),
|
||||
r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/activity"}"#
|
||||
);
|
||||
// The second login came inside the thirty seconds, so the next frame is the heartbeat, a second
|
||||
// after the start: the status of a game that isn't up.
|
||||
let beat: Value = serde_json::from_str(&next_text(&mut client)).unwrap();
|
||||
assert_eq!(beat["method"], "minecraft:notification/server/status");
|
||||
assert_eq!(beat["params"][0]["started"], false);
|
||||
assert!(beat["params"][0]["version"]["name"].is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_notification_sent_is_one_discover_lists() {
|
||||
let listed = schema::discover();
|
||||
@@ -934,6 +1030,9 @@ mod tests {
|
||||
"notification/bans/removed",
|
||||
"notification/ip_bans/added",
|
||||
"notification/ip_bans/removed",
|
||||
"notification/gamerules/updated",
|
||||
"notification/server/activity",
|
||||
"notification/server/status",
|
||||
] {
|
||||
assert!(
|
||||
names.contains(&format!("minecraft:{sent}").as_str()),
|
||||
|
||||
@@ -710,6 +710,11 @@ impl Lists<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerStateService.status`, as `server/status` answers it (and the heartbeat carries it).
|
||||
pub fn status(shared: &Shared) -> Value {
|
||||
Game { shared }.status()
|
||||
}
|
||||
|
||||
/// The notification a change of one of the lists is, from the game's typed event (`operators_added`,
|
||||
/// `bans_removed`...): `JsonRpcNotificationService`'s `playerOped`/`playerDeoped` (an `OperatorDto`),
|
||||
/// `playerAddedToAllowlist`/`playerRemovedFromAllowlist` and `playerUnbanned` (a `PlayerDto`), `playerBanned`
|
||||
|
||||
@@ -1190,7 +1190,7 @@ fn diagnostics(pp: &Preprocessed) -> Vec<String> {
|
||||
located(pp, &pp.diagnostics)
|
||||
}
|
||||
|
||||
/// `pp`'s warnings (conditions read as false), located.
|
||||
/// `pp`'s warnings (conditions read as false, unbalanced conditionals), located.
|
||||
fn warnings(pp: &Preprocessed) -> Vec<String> {
|
||||
located(pp, &pp.warnings)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ pub struct Preprocessed {
|
||||
pub lines: Vec<Origin>,
|
||||
/// The pack paths of the files read, the stage file first.
|
||||
pub files: Vec<String>,
|
||||
/// `#version` of the stage file: the number and the profile word, if any.
|
||||
/// The stage's `#version`, the first one read (an included file's too): the number and the
|
||||
/// profile word, if any.
|
||||
pub version: Option<(u32, Option<String>)>,
|
||||
/// `#extension` lines of live code, as written after `#extension`.
|
||||
pub extensions: Vec<String>,
|
||||
@@ -70,8 +71,12 @@ pub fn preprocess(pack: &Pack, path: &str, macros: &Macros) -> Preprocessed {
|
||||
texts: HashMap::new(),
|
||||
};
|
||||
run.file(path, 0, Origin::default());
|
||||
// Iris 1.11.7 draws a program whose `#if` stays open to its end: a warning, as there.
|
||||
if run.conditionals.open() {
|
||||
run.diagnose(Origin::default(), "`#if` without `#endif`");
|
||||
run.out.warnings.push(Diagnostic {
|
||||
origin: Origin::default(),
|
||||
message: "`#if` without `#endif`: read to the end of the file".into(),
|
||||
});
|
||||
}
|
||||
run.out.code = run.code.join("\n");
|
||||
run.out
|
||||
@@ -169,6 +174,11 @@ impl Run<'_> {
|
||||
Err(e) if e.ends_with("read as false") => {
|
||||
return self.out.warnings.push(Diagnostic { origin, message: e });
|
||||
}
|
||||
// Iris 1.11.7 logs a stray `#endif` and draws the program: it is ignored.
|
||||
Err(e) if word == "endif" => {
|
||||
let message = format!("{e}: ignored");
|
||||
return self.out.warnings.push(Diagnostic { origin, message });
|
||||
}
|
||||
Err(e) => return self.diagnose(origin, &e),
|
||||
Ok(true) => return,
|
||||
Ok(false) if !self.conditionals.active() => return,
|
||||
@@ -191,7 +201,8 @@ impl Run<'_> {
|
||||
None => self.diagnose(origin, &format!("`{target}` is outside the pack")),
|
||||
}
|
||||
}
|
||||
"version" if depth == 0 && self.out.version.is_none() => {
|
||||
// The first `#version`, also from an included file (a pack's common header).
|
||||
"version" if self.out.version.is_none() => {
|
||||
let mut words = rest.split_whitespace();
|
||||
let number = words.next().and_then(|v| v.parse().ok()).unwrap_or(110);
|
||||
self.out.version = Some((number, words.next().map(str::to_owned)));
|
||||
@@ -411,6 +422,28 @@ mod tests {
|
||||
assert_eq!(messages, ["#error live", "unknown directive `#bogus`"]);
|
||||
}
|
||||
|
||||
/// A stray `#endif` and an `#if` left open are warnings, as in Iris 1.11.7 (which draws such
|
||||
/// a program): the `#endif` is ignored, the open `#if` reads on to the end of the file.
|
||||
#[test]
|
||||
fn unbalanced_conditionals_warn_and_the_code_goes_on() {
|
||||
let p = run(&[(
|
||||
"program/final.fsh",
|
||||
"#version 330\n#ifdef NEVER\nfloat a;\n#else\nfloat b;\n#endif\n#endif\n\
|
||||
#if 1\nfloat c;\n",
|
||||
)]);
|
||||
assert!(p.diagnostics.is_empty(), "{:?}", p.diagnostics);
|
||||
let warnings: Vec<&str> = p.warnings.iter().map(|w| w.message.as_str()).collect();
|
||||
assert_eq!(
|
||||
warnings,
|
||||
[
|
||||
"`#endif` without `#if`: ignored",
|
||||
"`#if` without `#endif`: read to the end of the file"
|
||||
]
|
||||
);
|
||||
let code: Vec<&str> = code_lines(&p).iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(code, ["float b;", "float c;"]);
|
||||
}
|
||||
|
||||
/// A condition that can't be evaluated is a warning and reads as false; the code goes on.
|
||||
#[test]
|
||||
fn bad_conditions_warn_and_read_false() {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! Struct types without a name. GLSL lets a declaration define its struct type in place without
|
||||
//! naming it (GLSL 4.60, 4.1.8: `shared struct { uint sum; uint tile[64]; } lds;`); naga's
|
||||
//! frontend wants a name there ("Expected identifier, found LeftBrace"). Each such type gets
|
||||
//! one, `hz_struct<n>`; nothing else changes.
|
||||
|
||||
use super::scan::significant;
|
||||
use crate::macros::lex;
|
||||
|
||||
/// `code` with a name for each struct type declared without one, or `None` when it has none.
|
||||
pub(super) fn name(code: &str) -> Option<String> {
|
||||
let tokens = lex(code);
|
||||
let mut out = String::with_capacity(code.len() + 64);
|
||||
let mut named = 0;
|
||||
for (i, t) in tokens.iter().enumerate() {
|
||||
out.push_str(t);
|
||||
let opens = (i + 1..tokens.len())
|
||||
.find(|&j| significant(tokens[j]))
|
||||
.is_some_and(|j| tokens[j] == "{");
|
||||
if *t == "struct" && opens {
|
||||
out.push_str(&format!(" hz_struct{named}"));
|
||||
named += 1;
|
||||
}
|
||||
}
|
||||
(named > 0).then_some(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::name;
|
||||
|
||||
#[test]
|
||||
fn struct_types_without_a_name_get_one() {
|
||||
let code = "shared struct {\n uint sum;\n} lds;\nstruct Light { vec3 c; };\n\
|
||||
struct\n{ float a; } x, y[2];\n";
|
||||
assert_eq!(
|
||||
name(code).as_deref(),
|
||||
Some(
|
||||
"shared struct hz_struct0 {\n uint sum;\n} lds;\nstruct Light { vec3 c; };\n\
|
||||
struct hz_struct1\n{ float a; } x, y[2];\n"
|
||||
)
|
||||
);
|
||||
assert_eq!(name("struct Light { vec3 c; };\n"), None);
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,15 @@ pub(super) fn flatten_as(code: &str, inputs: &str) -> Option<String> {
|
||||
}
|
||||
let mut changed = false;
|
||||
for b in blocks {
|
||||
// A redeclared built-in block (GLSL 4.60, 7.5: `out gl_PerVertex { vec4 gl_Position;
|
||||
// };`) only restates built-ins: it goes, and they stay built-ins.
|
||||
if b.name == "gl_PerVertex" {
|
||||
for t in &mut tokens[b.start..=b.end] {
|
||||
blank(t);
|
||||
}
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
let inside = |i: usize| (b.start..=b.end).contains(&i);
|
||||
let prefix = if b.storage == "in" { inputs } else { "" };
|
||||
let renamed = |member: &str| match &b.instance {
|
||||
@@ -69,7 +78,16 @@ pub(super) fn flatten_as(code: &str, inputs: &str) -> Option<String> {
|
||||
let mut reads = Vec::new();
|
||||
if let Some((_, instance)) = &b.instance {
|
||||
let mut whole = false;
|
||||
for i in (0..tokens.len()).filter(|&i| !inside(i) && tokens[i] == *instance) {
|
||||
// A local variable or parameter of the same name, or a member (`light.v`), is not
|
||||
// the instance.
|
||||
let hidden = hidden(&tokens, instance);
|
||||
let other = |i: usize| {
|
||||
hidden.iter().any(|h| h.contains(&i))
|
||||
|| previous_significant(&tokens, i).is_some_and(|p| tokens[p] == ".")
|
||||
};
|
||||
for i in
|
||||
(0..tokens.len()).filter(|&i| !inside(i) && tokens[i] == *instance && !other(i))
|
||||
{
|
||||
let index = match b.array.is_empty() {
|
||||
true => Some(i),
|
||||
false => next_significant(&tokens, i + 1)
|
||||
@@ -132,6 +150,88 @@ fn next_significant(tokens: &[String], from: usize) -> Option<usize> {
|
||||
(from..tokens.len()).find(|&i| significant(&tokens[i]))
|
||||
}
|
||||
|
||||
fn previous_significant(tokens: &[String], before: usize) -> Option<usize> {
|
||||
(0..before).rev().find(|&i| significant(&tokens[i]))
|
||||
}
|
||||
|
||||
/// Where a local variable or parameter named `name` hides a global of that name (GLSL 4.60,
|
||||
/// 4.2): from its declaration to the end of the block it is declared in, or of its function's
|
||||
/// body for a parameter.
|
||||
fn hidden(tokens: &[String], name: &str) -> Vec<std::ops::Range<usize>> {
|
||||
let mut out = Vec::new();
|
||||
let mut opens: Vec<usize> = Vec::new();
|
||||
let mut parens = 0usize;
|
||||
for i in 0..tokens.len() {
|
||||
match tokens[i].as_str() {
|
||||
"{" => opens.push(i),
|
||||
"}" => {
|
||||
opens.pop();
|
||||
}
|
||||
"(" => parens += 1,
|
||||
")" => parens = parens.saturating_sub(1),
|
||||
t if t == name => {
|
||||
// A declaration: a type before the name, and after it what ends a declarator.
|
||||
let typed = previous_significant(tokens, i).is_some_and(|p| {
|
||||
let w = tokens[p].as_str();
|
||||
w.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
&& !["return", "else", "case"].contains(&w)
|
||||
});
|
||||
let declares = next_significant(tokens, i + 1)
|
||||
.is_some_and(|n| matches!(tokens[n].as_str(), "," | ")" | ";" | "=" | "["));
|
||||
if !(typed && declares) {
|
||||
continue;
|
||||
}
|
||||
let end = match opens.last() {
|
||||
Some(&open) => closing_brace(tokens, open),
|
||||
// A parameter: its function's body follows its parameter list.
|
||||
None if parens > 0 => parameter_list_end(tokens, i)
|
||||
.and_then(|close| next_significant(tokens, close + 1))
|
||||
.filter(|&body| tokens[body] == "{")
|
||||
.and_then(|body| closing_brace(tokens, body)),
|
||||
None => None,
|
||||
};
|
||||
if let Some(end) = end {
|
||||
out.push(i..end + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The `}` that closes the `{` at `open`.
|
||||
fn closing_brace(tokens: &[String], open: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for (j, t) in tokens.iter().enumerate().skip(open) {
|
||||
match t.as_str() {
|
||||
"{" => depth += 1,
|
||||
"}" => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(j);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `)` that closes the parameter list token `i` is in.
|
||||
fn parameter_list_end(tokens: &[String], i: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
for (j, t) in tokens.iter().enumerate().skip(i) {
|
||||
match t.as_str() {
|
||||
"(" => depth += 1,
|
||||
")" if depth == 0 => return Some(j),
|
||||
")" => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `]` that closes the `[` at `open`.
|
||||
fn closing(tokens: &[String], open: usize) -> Option<usize> {
|
||||
let mut depth = 0usize;
|
||||
@@ -306,6 +406,39 @@ fn block(tokens: &[String], start: usize, open: usize) -> Option<Block> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A local variable or parameter named like the instance hides it (GLSL 4.60, 4.2), and
|
||||
/// `light.v` is another struct's member: the block is still flattened, those stay.
|
||||
#[test]
|
||||
fn locals_named_like_the_instance_are_not_it() {
|
||||
let code = "out Data { vec2 uv; } v;\nstruct L { float v; };\n\
|
||||
float luma(vec3 v) { return dot(v, vec3(0.3)); }\n\
|
||||
void main() { L light; light.v = 1.0; v.uv = vec2(light.v);\n\
|
||||
{ vec2 v = vec2(2.0); v.x += 1.0; } v.uv += vec2(1.0); }\n";
|
||||
let out = flatten(code).expect("flattened");
|
||||
assert!(
|
||||
out.contains("float luma(vec3 v) { return dot(v, vec3(0.3)); }"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("light.v = 1.0; hz_Data_uv = vec2(light.v);"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("{ vec2 v = vec2(2.0); v.x += 1.0; } hz_Data_uv += vec2(1.0);"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A redeclared `gl_PerVertex` (GLSL 4.60, 7.5) goes: its members stay the built-ins.
|
||||
#[test]
|
||||
fn a_redeclared_gl_per_vertex_goes() {
|
||||
let code =
|
||||
"out gl_PerVertex { vec4 gl_Position; };\nvoid main() { gl_Position = vec4(1.0); }\n";
|
||||
let out = flatten(code).expect("changed");
|
||||
assert_eq!(out.lines().next().map(str::trim), Some(""));
|
||||
assert!(out.contains("gl_Position = vec4(1.0);"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_become_varyings_on_their_lines() {
|
||||
let code = "out VertexData {\n flat int id;\n vec2 uv, st[2];\n} vOut;\n\
|
||||
|
||||
@@ -27,6 +27,7 @@ use naga::valid::{Capabilities, ValidationFlags, Validator};
|
||||
|
||||
use crate::Preprocessed;
|
||||
|
||||
mod anonymous_structs;
|
||||
mod atomic_vectors;
|
||||
mod geometry;
|
||||
mod image_parameters;
|
||||
@@ -239,9 +240,14 @@ fn translate(
|
||||
} else {
|
||||
GBUFFERS_MATRICES.to_owned()
|
||||
};
|
||||
// Interface blocks travel as plain varyings (`interface_blocks`).
|
||||
// Struct types without a name get one (`anonymous_structs`), and interface blocks travel
|
||||
// as plain varyings (`interface_blocks`).
|
||||
let flat = |pp: &Preprocessed| {
|
||||
interface_blocks::flatten(&pp.code).map(|code| Preprocessed { code, ..pp.clone() })
|
||||
let named = anonymous_structs::name(&pp.code);
|
||||
let code = named.as_deref().unwrap_or(&pp.code);
|
||||
interface_blocks::flatten(code)
|
||||
.or(named)
|
||||
.map(|code| Preprocessed { code, ..pp.clone() })
|
||||
};
|
||||
let (vertex_flat, fragment_flat) = (flat(vertex), flat(fragment));
|
||||
let vertex = vertex_flat.as_ref().unwrap_or(vertex);
|
||||
@@ -625,6 +631,11 @@ fn resources(
|
||||
|
||||
/// Translates a compute program (`.csh`).
|
||||
pub fn translate_compute(compute: &Preprocessed) -> Result<ComputeProgram, Vec<Error>> {
|
||||
let named = anonymous_structs::name(&compute.code).map(|code| Preprocessed {
|
||||
code,
|
||||
..compute.clone()
|
||||
});
|
||||
let compute = named.as_ref().unwrap_or(compute);
|
||||
let split = atomic_vectors::rewrite(compute);
|
||||
let compute = split.as_ref().unwrap_or(compute);
|
||||
let mut c = Stage::scan(compute, ShaderStage::Compute);
|
||||
@@ -746,41 +757,160 @@ pub fn translate_compute(compute: &Preprocessed) -> Result<ComputeProgram, Vec<E
|
||||
/// `code` with `gl_WorkGroupSize` written as the constant its `layout(local_size_*)` declares:
|
||||
/// GLSL defines it so, and naga's HLSL writer has no such built-in.
|
||||
fn work_group_size(code: &str) -> Result<String, String> {
|
||||
let tokens = crate::macros::lex(code);
|
||||
if !tokens.contains(&"gl_WorkGroupSize") {
|
||||
return Ok(code.to_owned());
|
||||
}
|
||||
let sig: Vec<&str> = tokens
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| scan::significant(t))
|
||||
let mut tokens: Vec<String> = crate::macros::lex(code)
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
// A size is one number, the whole of its `local_size_* = N`; 1 when not declared.
|
||||
let size = |axis: &str| match sig.windows(4).find(|w| w[0] == axis && w[1] == "=") {
|
||||
None => Ok(1),
|
||||
Some(w) => w[2]
|
||||
.trim_end_matches(['u', 'U'])
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|_| matches!(w[3], "," | ")"))
|
||||
.ok_or_else(|| format!("`{axis}` is not a number gl_WorkGroupSize can take")),
|
||||
};
|
||||
let value = format!(
|
||||
"uvec3({}u, {}u, {}u)",
|
||||
size("local_size_x")?,
|
||||
size("local_size_y")?,
|
||||
size("local_size_z")?
|
||||
);
|
||||
Ok(tokens
|
||||
.iter()
|
||||
.map(|&t| {
|
||||
if t == "gl_WorkGroupSize" {
|
||||
value.as_str()
|
||||
} else {
|
||||
t
|
||||
let mut sizes = [1u32; 3];
|
||||
let axes = ["local_size_x", "local_size_y", "local_size_z"];
|
||||
let sig: Vec<usize> = (0..tokens.len())
|
||||
.filter(|&i| scan::significant(&tokens[i]))
|
||||
.collect();
|
||||
for (k, &at) in sig.iter().enumerate() {
|
||||
let Some(axis) = axes.iter().position(|a| tokens[at] == *a) else {
|
||||
continue;
|
||||
};
|
||||
if sig.get(k + 1).is_none_or(|&e| tokens[e] != "=") {
|
||||
continue;
|
||||
}
|
||||
// The size's expression runs to the `,` or `)` that ends the qualifier.
|
||||
let mut depth = 0usize;
|
||||
let expr: Vec<usize> = sig[k + 2..]
|
||||
.iter()
|
||||
.copied()
|
||||
.take_while(|&i| match tokens[i].as_str() {
|
||||
"(" => {
|
||||
depth += 1;
|
||||
true
|
||||
}
|
||||
")" if depth > 0 => {
|
||||
depth -= 1;
|
||||
true
|
||||
}
|
||||
"," | ")" if depth == 0 => false,
|
||||
_ => true,
|
||||
})
|
||||
.collect();
|
||||
let words: Vec<&str> = expr.iter().map(|&i| tokens[i].as_str()).collect();
|
||||
let size = ConstantSize::eval(&words)
|
||||
.ok_or_else(|| format!("`{}` is not a size Horizon can work out", axes[axis]))?;
|
||||
sizes[axis] = size;
|
||||
// naga takes a number there: the expression becomes its value.
|
||||
if let (Some(&first), Some(&last)) = (expr.first(), expr.last()) {
|
||||
tokens[first] = size.to_string();
|
||||
for t in &mut tokens[first + 1..=last] {
|
||||
t.retain(|c| c == '\n');
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
let value = format!("uvec3({}u, {}u, {}u)", sizes[0], sizes[1], sizes[2]);
|
||||
for t in &mut tokens {
|
||||
if t == "gl_WorkGroupSize" {
|
||||
*t = value.clone();
|
||||
}
|
||||
}
|
||||
Ok(tokens.concat())
|
||||
}
|
||||
|
||||
/// A work group size given as an integer constant expression (GLSL 4.60, 4.4 and 4.4.1):
|
||||
/// numbers, `+ - * / %`, brackets, `min`, `max` and `gl_MaxComputeWorkGroupSize`, the minimum
|
||||
/// GLSL requires (`ivec3(1024, 1024, 64)`, 7.3); the translation knows no device.
|
||||
struct ConstantSize<'w> {
|
||||
words: &'w [&'w str],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl<'w> ConstantSize<'w> {
|
||||
fn eval(words: &'w [&'w str]) -> Option<u32> {
|
||||
let mut p = Self { words, at: 0 };
|
||||
let v = p.sum()?;
|
||||
(p.at == words.len())
|
||||
.then_some(v)
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<&'w str> {
|
||||
self.words.get(self.at).copied()
|
||||
}
|
||||
|
||||
fn eat(&mut self, word: &str) -> bool {
|
||||
let found = self.peek() == Some(word);
|
||||
self.at += usize::from(found);
|
||||
found
|
||||
}
|
||||
|
||||
fn sum(&mut self) -> Option<i64> {
|
||||
let mut v = self.product()?;
|
||||
loop {
|
||||
if self.eat("+") {
|
||||
v = v.checked_add(self.product()?)?;
|
||||
} else if self.eat("-") {
|
||||
v = v.checked_sub(self.product()?)?;
|
||||
} else {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn product(&mut self) -> Option<i64> {
|
||||
let mut v = self.unary()?;
|
||||
loop {
|
||||
if self.eat("*") {
|
||||
v = v.checked_mul(self.unary()?)?;
|
||||
} else if self.eat("/") {
|
||||
v = v.checked_div(self.unary()?)?;
|
||||
} else if self.eat("%") {
|
||||
v = v.checked_rem(self.unary()?)?;
|
||||
} else {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unary(&mut self) -> Option<i64> {
|
||||
if self.eat("-") {
|
||||
return self.unary()?.checked_neg();
|
||||
}
|
||||
if self.eat("+") {
|
||||
return self.unary();
|
||||
}
|
||||
if self.eat("(") {
|
||||
let v = self.sum()?;
|
||||
return self.eat(")").then_some(v);
|
||||
}
|
||||
let word = self.peek()?;
|
||||
self.at += 1;
|
||||
match word {
|
||||
"min" | "max" => {
|
||||
if !self.eat("(") {
|
||||
return None;
|
||||
}
|
||||
let a = self.sum()?;
|
||||
if !self.eat(",") {
|
||||
return None;
|
||||
}
|
||||
let b = self.sum()?;
|
||||
if !self.eat(")") {
|
||||
return None;
|
||||
}
|
||||
Some(if word == "min" { a.min(b) } else { a.max(b) })
|
||||
}
|
||||
"gl_MaxComputeWorkGroupSize" => {
|
||||
if !self.eat(".") {
|
||||
return None;
|
||||
}
|
||||
let axis = self.peek()?;
|
||||
self.at += 1;
|
||||
match axis {
|
||||
"x" => Some(1024),
|
||||
"y" => Some(1024),
|
||||
"z" => Some(64),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
number => number.trim_end_matches(['u', 'U']).parse().ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The numbers of a constant's constructor, `const ivec3 name = ivec3(1, 2, 3);` → 1, 2, 3.
|
||||
@@ -1832,6 +1962,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `textureQueryLod` (GLSL 4.60, 8.9.1), which naga lacks, on 2D, 3D and integer samplers
|
||||
/// and through a pack function that takes the sampler: every form reaches SPIR-V, and a
|
||||
/// program that doesn't call it gets none of it.
|
||||
#[test]
|
||||
fn texture_query_lod_translates() {
|
||||
let fsh = "#version 430\nuniform sampler2D colortex0;\nuniform sampler3D lut;\n\
|
||||
uniform usampler2D colortex5;\nout vec4 c;\n\
|
||||
float level(sampler2D s, vec2 uv) { return textureQueryLod(s, uv).x; }\n\
|
||||
void main() {\n vec2 uv = gl_FragCoord.xy / 64.0;\n \
|
||||
vec2 a = textureQueryLod(colortex0, uv);\n \
|
||||
vec2 b = textureQueryLod(lut, vec3(uv, 0.5));\n \
|
||||
vec2 d = textureQueryLod(colortex5, uv);\n \
|
||||
c = vec4(a + b + d, level(colortex0, uv), 1.0);\n}\n";
|
||||
let p = program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let info = Validator::new(ValidationFlags::all(), Capabilities::all())
|
||||
.validate(&p.fragment)
|
||||
.unwrap();
|
||||
let entry = &p.fragment.entry_points[0];
|
||||
let pipeline = naga::back::spv::PipelineOptions {
|
||||
shader_stage: entry.stage,
|
||||
entry_point: entry.name.clone(),
|
||||
};
|
||||
naga::back::spv::write_vec(&p.fragment, &info, &Default::default(), Some(&pipeline))
|
||||
.unwrap();
|
||||
let plain = program(
|
||||
&[
|
||||
("composite.vsh", VSH_120),
|
||||
(
|
||||
"composite.fsh",
|
||||
"#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n",
|
||||
),
|
||||
],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!plain
|
||||
.fragment
|
||||
.functions
|
||||
.iter()
|
||||
.any(|(_, f)| f.name.as_deref() == Some("hz_textureQueryLod"))
|
||||
);
|
||||
}
|
||||
|
||||
/// A `return` with code after it in its block (Miniature's hand: `#ifdef GBUFFERS_HAND
|
||||
/// return 0.5 * diffuse; #endif` and the rest of the function), which naga's frontend
|
||||
/// leaves half-built.
|
||||
@@ -1916,6 +2094,25 @@ mod tests {
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
/// A vertex stage that redeclares `gl_PerVertex` and whose block instance shares its name
|
||||
/// with a function's parameter: it still writes the position, and the
|
||||
/// block still reaches the fragment stage.
|
||||
#[test]
|
||||
fn a_per_vertex_redeclaration_and_a_parameter_named_like_the_instance() {
|
||||
let vsh = "#version 460\nout gl_PerVertex { vec4 gl_Position; };\n\
|
||||
float luma(vec3 v) { return dot(v, vec3(0.3, 0.6, 0.1)); }\n\
|
||||
out VertexData {\n layout(location = 1, component = 0) vec2 coord;\n} v;\n\
|
||||
void main() { v.coord = vec2(luma(vec3(1.0))); gl_Position = vec4(0.0, 0.0, 0.5, 1.0); }\n";
|
||||
let fsh = "#version 460\nin VertexData {\n layout(location = 1, component = 0) vec2 coord;\n} v;\n\
|
||||
/* RENDERTARGETS: 0 */\nlayout(location = 0) out vec4 c;\n\
|
||||
void main() { c = vec4(v.coord, 0.0, 1.0); }\n";
|
||||
program(
|
||||
&[("composite.vsh", vsh), ("composite.fsh", fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
/// Interface blocks between the stages may give members their own interpolation (GLSL 4.60,
|
||||
/// 4.3.9; Shrimple's gbuffers programs): a `flat` member arrives flat.
|
||||
#[test]
|
||||
@@ -2157,6 +2354,20 @@ mod tests {
|
||||
}));
|
||||
}
|
||||
|
||||
/// A `shared` variable whose struct type has no name (GLSL 4.60, 4.1.8), as compute
|
||||
/// programs keep their work group's tiles.
|
||||
#[test]
|
||||
fn a_shared_struct_without_a_type_name_compiles() {
|
||||
let csh = "#version 460\nlayout(local_size_x = 8, local_size_y = 8) in;\n\
|
||||
shared struct {\n uint sum;\n uint tile[gl_WorkGroupSize.x][gl_WorkGroupSize.y];\n} lds;\n\
|
||||
layout(r32ui) uniform uimage2D colorimg4;\nvoid main() {\n \
|
||||
lds.tile[gl_LocalInvocationID.x][gl_LocalInvocationID.y] = gl_LocalInvocationIndex;\n \
|
||||
barrier();\n imageStore(colorimg4, ivec2(gl_GlobalInvocationID.xy), uvec4(lds.tile[1][1]));\n}\n";
|
||||
let pack = Pack::from_zip_bytes("P.zip", zip(&[("shaders/deferred_a.csh", csh)])).unwrap();
|
||||
translate_compute(&preprocess(&pack, "deferred_a.csh", &Macros::standard()))
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffer_blocks_bind_at_their_binding() {
|
||||
let csh = "#version 430\nlayout(local_size_x = 1) in;\n\
|
||||
@@ -2190,6 +2401,27 @@ mod tests {
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
/// A stage's `#version` may come from a header it includes: the program is that version
|
||||
/// (`shared` is a keyword in GLSL 4.30, not a name to rename).
|
||||
#[test]
|
||||
fn the_version_may_come_from_an_included_header() {
|
||||
let csh = "#include \"/prelude/core.glsl\"\nlayout(local_size_x = 4) in;\n\
|
||||
shared uint counts[4];\nlayout(r32ui) uniform uimage2D colorimg4;\n\
|
||||
void main() {\n counts[gl_LocalInvocationIndex] = 1u;\n barrier();\n \
|
||||
imageStore(colorimg4, ivec2(0), uvec4(counts[1]));\n}\n";
|
||||
let pack = Pack::from_zip_bytes(
|
||||
"P.zip",
|
||||
zip(&[
|
||||
("shaders/deferred_a.csh", csh),
|
||||
("shaders/prelude/core.glsl", "#version 460 core\n"),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let pp = preprocess(&pack, "deferred_a.csh", &Macros::standard());
|
||||
assert_eq!(pp.version, Some((460, Some("core".into()))));
|
||||
translate_compute(&pp).unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_group_size_is_its_layout() {
|
||||
let csh = "#version 430\nlayout(local_size_x = 8, local_size_y = 4) in;\n\
|
||||
@@ -2210,9 +2442,18 @@ mod tests {
|
||||
.iter()
|
||||
.any(|(_, e)| matches!(e, naga::Expression::Literal(naga::Literal::U32(8))))
|
||||
);
|
||||
// A constant expression (GLSL 4.60, 4.4) is worked out, also where naga reads it.
|
||||
assert_eq!(
|
||||
work_group_size("layout(local_size_x = 8 * 2) in;\nuvec3 s = gl_WorkGroupSize;"),
|
||||
Err("`local_size_x` is not a number gl_WorkGroupSize can take".into())
|
||||
work_group_size(
|
||||
"layout(local_size_x = min(gl_MaxComputeWorkGroupSize.x, 256 * 2), local_size_y = (3 + 1) / 2) in;\n\
|
||||
uvec3 s = gl_WorkGroupSize;"
|
||||
)
|
||||
.as_deref(),
|
||||
Ok("layout(local_size_x = 512, local_size_y = 2) in;\nuvec3 s = uvec3(512u, 2u, 1u);")
|
||||
);
|
||||
assert_eq!(
|
||||
work_group_size("layout(local_size_x = SIZE) in;\nvoid main() {}"),
|
||||
Err("`local_size_x` is not a size Horizon can work out".into())
|
||||
);
|
||||
assert!(
|
||||
work_group_size("layout(local_size_x = 16) in;\nuvec3 s = gl_WorkGroupSize;")
|
||||
@@ -2220,6 +2461,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A work group size given as a constant expression (GLSL 4.60, 4.4 and 4.4.1), kept within
|
||||
/// the implementation's: the program compiles with its value.
|
||||
#[test]
|
||||
fn a_work_group_size_may_be_a_constant_expression() {
|
||||
let csh = "#version 460\nlayout(local_size_x = min(gl_MaxComputeWorkGroupSize.x, 256), \
|
||||
local_size_y = 1, local_size_z = 1) in;\n\
|
||||
layout(r32ui) uniform uimage2D colorimg4;\n\
|
||||
void main() { imageStore(colorimg4, ivec2(gl_GlobalInvocationID.xy), uvec4(1u)); }\n";
|
||||
let pack = Pack::from_zip_bytes("P.zip", zip(&[("shaders/deferred_a.csh", csh)])).unwrap();
|
||||
let c = translate_compute(&preprocess(&pack, "deferred_a.csh", &Macros::standard()))
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let entry = &c.module.entry_points[0];
|
||||
assert_eq!(entry.workgroup_size, [256, 1, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_programs_translate() {
|
||||
let csh = "#version 430\nlayout(local_size_x = 64) in;\nconst ivec3 workGroups = ivec3(2, 1, 1);\n\
|
||||
|
||||
@@ -46,6 +46,9 @@ impl Stage<'_> {
|
||||
if self.gather_offsets {
|
||||
p.push_str(&gather_offsets());
|
||||
}
|
||||
if self.query_lod {
|
||||
p.push_str(&query_lod());
|
||||
}
|
||||
// GLSL's scalar `dot` and `normalize` (genType includes `float`); naga's are vector only.
|
||||
if self.scalar_dot {
|
||||
p.push_str("float dot(float a, float b) { return a * b; }\n");
|
||||
@@ -372,6 +375,7 @@ impl Stage<'_> {
|
||||
// GLSL's `modf`, which naga's frontend lacks.
|
||||
"modf" if called => Some("hz_modf".into()),
|
||||
"textureGatherOffsets" if called => Some("hz_textureGatherOffsets".into()),
|
||||
"textureQueryLod" if called => Some("hz_textureQueryLod".into()),
|
||||
// Matrix inverse, which naga writes for neither Metal nor WGSL.
|
||||
"inverse" if called && self.inverse => Some("hz_inverse".into()),
|
||||
"texture2D" | "texture1D" | "texture3D" | "textureCube" if called => {
|
||||
@@ -689,6 +693,48 @@ fn gather_offsets() -> String {
|
||||
p
|
||||
}
|
||||
|
||||
/// `textureQueryLod` (GLSL 4.60, 8.9.1), which naga's frontend lacks: `y` is the level of
|
||||
/// detail relative to the base level, worked out as texturing does (OpenGL 4.6, 8.14.1: the log2 of the
|
||||
/// larger of the coordinates' texel-space changes from one pixel to the next), and `x` the level
|
||||
/// that would be read, that clamped to the texture's levels. For a cube map the direction is
|
||||
/// first taken to its face.
|
||||
fn query_lod() -> String {
|
||||
let mut p = String::new();
|
||||
for prefix in ["", "i", "u"] {
|
||||
for (dim, coord, texel) in [
|
||||
("2D", "vec2", "p * vec2(textureSize({smp}(t, s), 0))"),
|
||||
(
|
||||
"2DArray",
|
||||
"vec2",
|
||||
"p * vec2(textureSize({smp}(t, s), 0).xy)",
|
||||
),
|
||||
("3D", "vec3", "p * vec3(textureSize({smp}(t, s), 0))"),
|
||||
(
|
||||
"Cube",
|
||||
"vec3",
|
||||
"p / max(max(abs(p.x), abs(p.y)), abs(p.z)) * 0.5 * float(textureSize({smp}(t, s), 0).x)",
|
||||
),
|
||||
] {
|
||||
let (tex, smp) = (
|
||||
format!("{prefix}texture{dim}"),
|
||||
format!("{prefix}sampler{dim}"),
|
||||
);
|
||||
let texel = texel.replace("{smp}", &smp);
|
||||
write!(
|
||||
p,
|
||||
"vec2 hz_textureQueryLod({tex} t, sampler s, {coord} p) {{\n\
|
||||
\x20 {coord} q = {texel};\n\
|
||||
\x20 {coord} dx = dFdx(q), dy = dFdy(q);\n\
|
||||
\x20 float lod = 0.5 * log2(max(dot(dx, dx), dot(dy, dy)));\n\
|
||||
\x20 float levels = float(textureQueryLevels({smp}(t, s)));\n\
|
||||
\x20 return vec2(clamp(lod, 0.0, levels - 1.0), lod);\n}}\n"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn is_zero_literal(t: &str) -> bool {
|
||||
let t = t.trim_end_matches(['f', 'F']);
|
||||
t.contains('.') && !t.contains(['e', 'E']) && t.chars().all(|c| c == '0' || c == '.')
|
||||
|
||||
@@ -59,6 +59,8 @@ pub(super) struct Stage<'p> {
|
||||
pub(super) inverse: bool,
|
||||
/// The stage calls `textureGatherOffsets`, which naga lacks: ours is in the prelude.
|
||||
pub(super) gather_offsets: bool,
|
||||
/// The stage calls `textureQueryLod`, which naga lacks: ours is in the prelude.
|
||||
pub(super) query_lod: bool,
|
||||
/// The stage calls `dot` / `normalize` and defines neither: the scalar overloads GLSL has
|
||||
/// (`dot(float, float)`, `normalize(float)`) and naga lacks go in the prelude.
|
||||
pub(super) scalar_dot: bool,
|
||||
@@ -261,6 +263,7 @@ impl<'p> Stage<'p> {
|
||||
out_copies: Vec::new(),
|
||||
inverse: false,
|
||||
gather_offsets: false,
|
||||
query_lod: false,
|
||||
scalar_dot: false,
|
||||
scalar_normalize: false,
|
||||
sampler_functions: HashMap::new(),
|
||||
@@ -311,6 +314,7 @@ impl<'p> Stage<'p> {
|
||||
}
|
||||
"gl_FragCoord" => s.frag_coord = true,
|
||||
"textureGatherOffsets" => s.gather_offsets = true,
|
||||
"textureQueryLod" => s.query_lod = true,
|
||||
"dot" | "normalize" => {
|
||||
// A definition at the top level: `float dot(float a, ...)`.
|
||||
let defines = depth == 0
|
||||
@@ -357,6 +361,11 @@ impl<'p> Stage<'p> {
|
||||
.entry("textureGatherOffsets".into())
|
||||
.or_insert_with(|| vec![true, false, false, false, false]);
|
||||
}
|
||||
if s.query_lod {
|
||||
s.sampler_functions
|
||||
.entry("textureQueryLod".into())
|
||||
.or_insert_with(|| vec![true, false]);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -79,8 +79,8 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
|
||||
- **Backups**: restore a single dimension.
|
||||
- **Mods on a dedicated server**: list, turn on and off, and their settings, once native mods can be built for the
|
||||
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)).
|
||||
- **Vanilla's management protocol**: `serversettings/*` (the live settings not yet done), the notifications of
|
||||
the server's activity and the world upgrade, and the status heartbeat (the others are sent). The methods answer
|
||||
- **Vanilla's management protocol**: `serversettings/*` (the live settings not yet done) and the world
|
||||
upgrade's notifications (the others are sent). The methods answer
|
||||
an internal error saying so; a kick's translatable message is sent as its key.
|
||||
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
|
||||
|
||||
|
||||
+8
-4
@@ -268,6 +268,11 @@ horizon-server report [SECONDS] record a performance report (below)
|
||||
horizon-server op|deop NAME
|
||||
horizon-server whitelist on|off|list
|
||||
horizon-server whitelist add|remove NAME
|
||||
horizon-server players [--json] who is online, the operators, the whitelist and the bans
|
||||
horizon-server kick|ban NAME [REASON] on the running server
|
||||
horizon-server pardon NAME
|
||||
horizon-server ban-ip ADDRESS|NAME [REASON]
|
||||
horizon-server pardon-ip ADDRESS
|
||||
horizon-server backup [NAME] back up now
|
||||
horizon-server backups [--json] list the backups
|
||||
horizon-server restore BACKUP [--yes]
|
||||
@@ -281,7 +286,9 @@ horizon-server rollback back to the release before
|
||||
```
|
||||
|
||||
While the server runs, `op`, `whitelist` and `cmd` go to the game's console, so they act exactly as vanilla's
|
||||
console commands do. While it's stopped, they edit vanilla's files directly. Any console command works through
|
||||
console commands do. While it's stopped, they edit vanilla's files directly. `kick`, `ban`, `pardon`, `ban-ip`
|
||||
and `pardon-ip` run the vanilla command on the running server, through its control API; `players` reads the
|
||||
lists from the files when the server is stopped. Any console command works through
|
||||
`cmd`: `horizon-server cmd ban Griefer`, `horizon-server cmd say Restarting in 5 minutes`.
|
||||
To generate the world around spawn ahead of the players, Horizon has its own `pregen` command, off until
|
||||
`server.toml` has `horizon-pregen = true` under `[properties]`: `horizon-server cmd pregen start 2500`
|
||||
@@ -340,9 +347,6 @@ that 0.2 comes after the dated releases and says it is up to date: run `horizon-
|
||||
once ([versioning](versioning.md)). A stopped server is switched and starts on the new release next
|
||||
time. Set `HORIZON_NO_UPDATE_CHECK=1` in the server's environment to turn the check off.
|
||||
|
||||
Coming: `console` (attach to a server running in the background), `ban` and `pardon` as their own
|
||||
commands, and `mcp` (an MCP server so an AI assistant can run the server).
|
||||
|
||||
## 6. Web panel
|
||||
|
||||
The panel runs inside `horizon-server` while the server runs. It shows live TPS, MSPT, RAM and CPU graphs for
|
||||
|
||||
@@ -2196,6 +2196,12 @@ another mod's hook object is not exposed.
|
||||
|
||||
Trait dispatch follows dependency order, with ties kept in bundle order. Mixin injection order remains
|
||||
horizon-mixins' priority, mod ID, then injection ID; dependency order does not change injection order.
|
||||
|
||||
A panic in a trait mod's hook adds `in native mod <id>, hook <hook>` to the panic's message, in the crash report
|
||||
and in the server thread's log line, with the bundle's `[mod] id` (the generated executable passes the ids to
|
||||
`mod_api::name_mods`; a build that doesn't gives the mod's place in dispatch order, `#0` first). The game still
|
||||
stops as before. Render hooks and trait injections are not named yet.
|
||||
|
||||
A bundle with an `[api]` package may declare its own injection in the trait:
|
||||
|
||||
```toml
|
||||
|
||||
+17
-10
@@ -215,8 +215,8 @@ comes out of the headless-engine work.
|
||||
**Docker** (`packaging/server/Dockerfile`, built the same way as the relay's image): a multi-stage build that
|
||||
ends on `debian:bookworm-slim` as user `horizon`, with the server build as `horizon-engine`, so the image needs
|
||||
no ALSA or udev. It uses volume `/data` for the server folder, takes the owner's prepared game files as a read-only
|
||||
mount on `/opt/horizon/.local/horizon-client/26.3`, exposes port `25565/tcp`, and keeps the control API (and
|
||||
later the panel) on 127.0.0.1 inside the container: `docker exec <name> horizon-server status|cmd|backup`.
|
||||
mount on `/opt/horizon/.local/horizon-client/26.3`, exposes port `25565/tcp`, and keeps the control API and
|
||||
the panel on 127.0.0.1 inside the container: `docker exec <name> horizon-server status|cmd|backup`.
|
||||
On the first start, `HORIZON_EULA=true` and `HORIZON_WORLD`, `HORIZON_SEED`, `HORIZON_PORT`,
|
||||
`HORIZON_WHITELIST`, `HORIZON_PLAYERS` and `HORIZON_OP` answer `init`'s questions; without the EULA answer
|
||||
the container exits and says why. `docker stop` sends SIGTERM, which saves and stops cleanly (give it
|
||||
@@ -293,10 +293,10 @@ keep = 12 # automatic backups kept (backups made by hand are
|
||||
keep = 1 # other releases kept by updates, besides the one in use and the one rollback goes to
|
||||
```
|
||||
|
||||
This is the file `init` writes today (PR 4 adds `[panel] bind = "127.0.0.1:8130"` and `allow_public =
|
||||
false`, section 6). The defaults are vanilla 26.3's (a test checks each one). Unknown keys are errors, as are
|
||||
a typed key set again under `[properties]` and values vanilla would refuse. Planned: `backups.before_update`
|
||||
(section 7).
|
||||
This is the file `init` writes today, and it also writes `[panel]` (`bind = "127.0.0.1:8130"` and
|
||||
`allow_public = false`, section 6), `[api]` and `[discord]`. The defaults are vanilla 26.3's (a test checks
|
||||
each one). Unknown keys are errors, as are a typed key set again under `[properties]` and values vanilla would
|
||||
refuse. There is no `backups.before_update` setting: an update always backs up the world first (section 7).
|
||||
|
||||
- **Import.** `horizon-server init` in a folder with a `server.properties`, or `horizon-server import
|
||||
server.properties`, reads it with vanilla's own parser (`ServerProperties`). Keys with typed fields go into
|
||||
@@ -394,8 +394,8 @@ runtime or new dependency. Server-sent events can replace the polling later if a
|
||||
|
||||
- **Backups.** The supervisor runs `save-off`, then `save-all flush`, then zips the world plus the vanilla
|
||||
files and `server.toml` into `backups/<UTC time>-<name>.zip`, then runs `save-on`. Players stay online. Zip
|
||||
works for everyone, Windows included. It runs on the `every` schedule and on demand (later also before an
|
||||
update). It keeps the last `keep` automatic backups and never deletes one made by hand. A backup is written
|
||||
works for everyone, Windows included. It runs on the `every` schedule, on demand and before an
|
||||
update. It keeps the last `keep` automatic backups and never deletes one made by hand. A backup is written
|
||||
to a temporary name and renamed when complete, so a crash never leaves a half-written backup that looks
|
||||
finished. A stopped server is backed up straight from its files.
|
||||
- **Restore.** `restore NAME` takes a file name, a unique prefix, or the name given to `backup` (the newest
|
||||
@@ -521,7 +521,13 @@ runtime or new dependency. Server-sent events can replace the polling later if a
|
||||
sets one (checked against its type and bounds with vanilla's error texts, and set, logged and told on every
|
||||
update, as vanilla's does) and `notification/gamerules/updated` follows every change, by that method or by
|
||||
`/gamerule` (`Game::set_game_rule`); the parity script compares both servers on all of it;
|
||||
- planned: the rest of `serversettings/*`, the activity and world-upgrade notifications and the heartbeat.
|
||||
- `server/activity`: a login attempt (the game records `login_activity` in its login gate, as
|
||||
`ServerActivityMonitor.reportLoginActivity` is called when a login starts), told at most once in thirty
|
||||
seconds and lost otherwise, as the monitor clears its flag whatever it did; and `server/status`, the
|
||||
heartbeat: every `status-heartbeat-interval` seconds (none at 0, the first after as many) the status
|
||||
`server/status` answers, up or not, to whoever is connected;
|
||||
- planned: the rest of `serversettings/*` and the world-upgrade notifications (after `WorldUpgrader`'s server
|
||||
side).
|
||||
|
||||
## 9. Benchmark plan (bots against vanilla and Paper)
|
||||
|
||||
@@ -585,7 +591,8 @@ reproducible by anyone, and show the same gameplay on both sides.
|
||||
- **Equal-work run (2026-10-17,** `benchmarks/server-equal-work-2026-10-17/`, on the ThinkPad with the bots
|
||||
on the Mac mini). Horizon now checks moves and paces chunks as vanilla does (#586), the bots wait for the
|
||||
chunk they stand in, an `--explore-speed` pass keeps both servers at the same rate, and vanilla runs with
|
||||
`gamerule locator_bar false` (Horizon has no locator bar yet). Results:
|
||||
`gamerule locator_bar false` (Horizon had no locator bar when this ran; since #692, merged the same day,
|
||||
it sends one to vanilla clients). Results:
|
||||
- Memory: Horizon's RSS is 1.9 to 2.6 times smaller in every run (vanilla's heap is fixed at 4 GB).
|
||||
- Walking near spawn: Horizon's tick is shorter with 1 and 10 bots (5.5 against 18.3 ms, 11.5 against
|
||||
16.8), and longer with 25 and 50 (20.7 against 16.0, 31.4 against 20.5). Both keep 20 TPS up to 50.
|
||||
|
||||
@@ -79,10 +79,24 @@ fn path_dep(path: &str) -> toml::Value {
|
||||
toml::Value::Table(toml::Table::from_iter([("path".into(), path.into())]))
|
||||
}
|
||||
|
||||
fn main_rs(count: usize) -> String {
|
||||
let entries = (0..count)
|
||||
/// Whether the staged revision names its trait mods (`mod_api::name_mods`, which a panic in a
|
||||
/// hook names its mod by); an older one takes the list alone.
|
||||
fn names_mods(stage: &Path) -> bool {
|
||||
fs::read_to_string(stage.join("crates/horizon-client/src/mod_api.rs"))
|
||||
.is_ok_and(|text| text.contains("pub fn name_mods("))
|
||||
}
|
||||
|
||||
/// The generated executable's `main`: the trait mods of bundles `ids`, in that order, named by
|
||||
/// those ids when `named`.
|
||||
fn main_rs(ids: &[&str], named: bool) -> String {
|
||||
let entries = (0..ids.len())
|
||||
.map(|i| format!(" mod_{i}::hooks(),\n"))
|
||||
.collect::<String>();
|
||||
let naming = if named {
|
||||
format!(" horizon_client::mod_api::name_mods(&{ids:?});\n")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
// Mirrors crates/horizon-client/src/main.rs, with trait mods passed to main_with.
|
||||
format!(
|
||||
r#"//! Generated by the launcher.
|
||||
@@ -97,7 +111,7 @@ fn main() {{
|
||||
std::hint::black_box(&MOD_API_LEVEL);
|
||||
let mods: horizon_client::mod_api::Mods = vec![
|
||||
{entries} ].into();
|
||||
horizon_client::entry::main_with(mods);
|
||||
{naming} horizon_client::entry::main_with(mods);
|
||||
}}
|
||||
"#
|
||||
)
|
||||
@@ -258,7 +272,9 @@ pub(super) fn stage(
|
||||
)])),
|
||||
);
|
||||
write(&game.join("Cargo.toml"), &manifest)?;
|
||||
fs::write(game.join("src/main.rs"), main_rs(mods.len())).map_err(|e| e.to_string())?;
|
||||
let ids: Vec<_> = mods.iter().map(|(_, bundle)| bundle.id.as_str()).collect();
|
||||
fs::write(game.join("src/main.rs"), main_rs(&ids, names_mods(stage)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let root = stage.join("Cargo.toml");
|
||||
let mut workspace = read(&root)?;
|
||||
let list = workspace
|
||||
@@ -330,16 +346,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generated_main_keeps_mod_order() {
|
||||
let ids = ["base", "extra"];
|
||||
for count in 0..=2 {
|
||||
let text = main_rs(count);
|
||||
let text = main_rs(&ids[..count], false);
|
||||
assert!(text.contains("entry::main_with(mods)"));
|
||||
assert_eq!(text.matches("::hooks()").count(), count);
|
||||
assert!(!text.contains("name_mods"));
|
||||
if count == 2 {
|
||||
assert!(
|
||||
text.find("mod_0::hooks()").unwrap() < text.find("mod_1::hooks()").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
// A revision that names its mods gets the ids in the same order, before main_with.
|
||||
let named = main_rs(&ids, true);
|
||||
let naming = named
|
||||
.find(r#"mod_api::name_mods(&["base", "extra"]);"#)
|
||||
.unwrap();
|
||||
assert!(named.find("mod_1::hooks()").unwrap() < naming);
|
||||
assert!(naming < named.find("entry::main_with(mods)").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -397,6 +422,18 @@ mod tests {
|
||||
game["features"]["default"][0].as_str(),
|
||||
Some("horizon-client/default")
|
||||
);
|
||||
let main = || fs::read_to_string(stage_dir.join(GAME).join("src/main.rs")).unwrap();
|
||||
assert!(!main().contains("name_mods"));
|
||||
// A revision with `mod_api::name_mods` gets each bundle's id.
|
||||
fs::create_dir_all(stage_dir.join("crates/horizon-client/src")).unwrap();
|
||||
fs::write(
|
||||
stage_dir.join("crates/horizon-client/src/mod_api.rs"),
|
||||
"pub fn name_mods(ids: &'static [&'static str]) {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let bundle = super::super::bundle(&dir, None).unwrap();
|
||||
stage(&stage_dir, &[bundle], &[vec![]], &resolution).unwrap();
|
||||
assert!(main().contains(r#"name_mods(&["example"]);"#));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that a boat in flowing water drifts with the current (#6435).
|
||||
|
||||
A headless single-player game opens a scratch world; a one-deep, three-wide stone
|
||||
trench is filled from a single source block at its head, an oak boat is summoned
|
||||
onto the flowing water, and its `Pos` is read every ten ticks. Vanilla's
|
||||
`Entity.updateFluidInteraction` pushes a boat by the water current it sits in
|
||||
(`AbstractBoat` does not opt out of `isPushedByFluid`), with water's 0.014 scale,
|
||||
and `AbstractBoat.tick` runs it before `floatBoat` scales the velocity; a boat
|
||||
that stays put is missing that step.
|
||||
|
||||
The reported case rides the boat, but a ridden boat ticks through the same
|
||||
`tick_boat` (the rider's client moves it), so the unridden boat covers the same
|
||||
current step.
|
||||
|
||||
python3 tools/boat_current_e2e.py --bin-dir target/fast
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from . import lab
|
||||
except ImportError:
|
||||
import lab
|
||||
|
||||
WORLD = 'BoatCurrent'
|
||||
SAMPLE_TICKS = 10
|
||||
SAMPLES = 4
|
||||
MIN_DRIFT = 1.0
|
||||
BOAT = '@e[type=minecraft:oak_boat,limit=1,sort=nearest]'
|
||||
|
||||
|
||||
class Failed(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def ctl(ctl_path, socket, cwd, env, *words):
|
||||
result = subprocess.run([str(ctl_path), '--socket', str(socket), *map(str, words)], cwd=cwd, env=env,
|
||||
capture_output=True, text=True, timeout=900)
|
||||
lines = result.stdout.strip().splitlines()
|
||||
reply = json.loads(lines[-1]) if lines else {'error': result.stderr.strip()}
|
||||
if result.returncode or 'error' in reply:
|
||||
raise Failed(f'{" ".join(map(str, words))}: exit {result.returncode}: {reply}')
|
||||
return reply
|
||||
|
||||
|
||||
def command(ctl_path, socket, cwd, env, text):
|
||||
return ctl(ctl_path, socket, cwd, env, 'command', text)
|
||||
|
||||
|
||||
def boat_pos(ctl_path, socket, cwd, env):
|
||||
reply = command(ctl_path, socket, cwd, env, f'data get entity {BOAT} Pos')
|
||||
for line in reply.get('chat') or []:
|
||||
if '[' in line and ']' in line:
|
||||
body = line[line.index('[') + 1: line.rindex(']')]
|
||||
return [float(v.strip().removesuffix('d')) for v in body.split(',')]
|
||||
raise Failed(f'no boat position in {reply}')
|
||||
|
||||
|
||||
def build_trench(ctl_path, socket, cwd, env):
|
||||
# The chunk has to be loaded before a fill reaches it: aim the player at the
|
||||
# trench, then retry until the floor lands.
|
||||
command(ctl_path, socket, cwd, env, 'tp @s 2.5 160 1')
|
||||
ctl(ctl_path, socket, cwd, env, 'wait-for', 'loaded', '--timeout', '120')
|
||||
ctl(ctl_path, socket, cwd, env, 'wait', '60')
|
||||
for _ in range(5):
|
||||
reply = command(ctl_path, socket, cwd, env, 'fill 0 150 -1 9 150 3 stone')
|
||||
if not any('not loaded' in line for line in reply.get('chat') or []):
|
||||
break
|
||||
ctl(ctl_path, socket, cwd, env, 'wait', '40')
|
||||
else:
|
||||
raise Failed('the trench floor never loaded')
|
||||
command(ctl_path, socket, cwd, env, 'fill -1 151 -1 -1 151 3 stone')
|
||||
command(ctl_path, socket, cwd, env, 'fill 0 151 -1 9 151 -1 stone')
|
||||
command(ctl_path, socket, cwd, env, 'fill 0 151 3 9 151 3 stone')
|
||||
command(ctl_path, socket, cwd, env, 'fill 0 151 0 0 151 2 water')
|
||||
# A source spreads one block every five ticks; nine blocks settle in 45.
|
||||
ctl(ctl_path, socket, cwd, env, 'wait', '120')
|
||||
# Stand on the south wall, out of the current, next to the boat.
|
||||
command(ctl_path, socket, cwd, env, 'tp @s 2.5 152 3.5')
|
||||
ctl(ctl_path, socket, cwd, env, 'wait', '20')
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--bin-dir', default='target/fast', help='folder with horizon-client and horizon-ctl')
|
||||
parser.add_argument('--resources', default=str(lab.RESOURCES), help='the prepared resources (default: .local)')
|
||||
parser.add_argument('--keep', action='store_true', help='keep the scratch game home')
|
||||
args = parser.parse_args()
|
||||
bindir = Path(args.bin_dir).resolve()
|
||||
client, ctl_path = bindir / 'horizon-client', bindir / 'horizon-ctl'
|
||||
for binary in (client, ctl_path):
|
||||
if not binary.exists():
|
||||
raise Failed(f'{binary} is missing; build it (cargo build --profile fast -p horizon-client '
|
||||
'-p horizon-client-lib --bins) or pass --bin-dir')
|
||||
resources = Path(args.resources).resolve()
|
||||
if not resources.exists():
|
||||
raise Failed(f'{resources} is missing; run tools/lab.py prepare')
|
||||
work = Path(tempfile.mkdtemp(prefix='hz-boat-current-'))
|
||||
(work / '.local/horizon-client').mkdir(parents=True)
|
||||
lab.link_dir(resources, work / '.local/horizon-client' / lab.VERSION)
|
||||
(work / 'options.txt').write_text('onboardAccessibility:false\n')
|
||||
env = lab.game_env(home=work, HORIZON_TELEMETRY_URL='off')
|
||||
socket = work / 'c.sock'
|
||||
launch = subprocess.run([str(ctl_path), '--socket', str(socket), 'launch', '--world', WORLD, '--seed', '7',
|
||||
'--creative', '--headless', '--wait'],
|
||||
cwd=work, env=env, capture_output=True, text=True, timeout=900)
|
||||
if launch.returncode:
|
||||
log = next(iter(sorted(work.glob('*.log'))), None)
|
||||
tail = log.read_text(errors='replace')[-2000:] if log else launch.stderr[-2000:]
|
||||
raise Failed(f'launch failed ({launch.returncode}): {tail}')
|
||||
try:
|
||||
ctl(ctl_path, socket, work, env, 'wait', '20')
|
||||
build_trench(ctl_path, socket, work, env)
|
||||
command(ctl_path, socket, work, env, 'summon minecraft:oak_boat 2.5 151.0 1.9')
|
||||
ctl(ctl_path, socket, work, env, 'wait', '5')
|
||||
xs = []
|
||||
for _ in range(SAMPLES):
|
||||
xs.append(boat_pos(ctl_path, socket, work, env)[0])
|
||||
ctl(ctl_path, socket, work, env, 'wait', str(SAMPLE_TICKS))
|
||||
drift = xs[-1] - xs[0]
|
||||
print(f'boat x over {(SAMPLES - 1) * SAMPLE_TICKS} ticks: {xs[0]:.3f} -> {xs[-1]:.3f} ({drift:+.3f})')
|
||||
if drift < MIN_DRIFT:
|
||||
raise Failed(f'the flowing water did not push the boat (drift {drift:+.3f}, want >= {MIN_DRIFT}); '
|
||||
'Entity.baseTick\'s fluid step is missing from the boat tick')
|
||||
print('PASS: the boat drifts with the flowing water')
|
||||
return 0
|
||||
finally:
|
||||
subprocess.run([str(ctl_path), '--socket', str(socket), 'quit'], cwd=work, env=env,
|
||||
capture_output=True, timeout=120)
|
||||
if not args.keep:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Failed as error:
|
||||
print(f'FAIL: {error}')
|
||||
sys.exit(1)
|
||||
+72
-1
@@ -21,6 +21,12 @@ p99/p99.9, the worst frame, frames over 33 ms) in the situations where players s
|
||||
screens the creative inventory and its 12 tabs, then the survival inventory and its recipe book,
|
||||
opened for the first time and once more
|
||||
reload F3+T, 15 s recorded
|
||||
tnt 50 TNT set off on a stone block, 10 s recorded (world changes: block updates, light and
|
||||
remeshing in one frame; each built in the air in front of the camera)
|
||||
fill /fill of a 64x64x16 stone volume (two fills, the default block limit), then cleared
|
||||
fluids water, then lava, poured down a 16-block slope, 15 s each
|
||||
fire fire lit at 5 places on a 25x25 grove's canopy of leaves over logs, 25 s
|
||||
pistons an observer clock driving 20 pistons, 15 s
|
||||
|
||||
One game per render distance, from a fresh world (seed 1234, creative), driven over horizon-ctl.
|
||||
The game runs with HORIZON_SPIKES so each slow frame logs its timed spans; the report keeps the
|
||||
@@ -49,7 +55,8 @@ except ImportError:
|
||||
|
||||
WORK = lab.ROOT / ".local/pacing-bench"
|
||||
SCENARIOS = ["fly16", "ocean", "fly64", "elytra", "teleport", "nether", "portal", "inventory", "mobs", "items",
|
||||
"block_entities", "screens", "reload", "join"]
|
||||
"block_entities", "screens", "reload", "tnt", "fill", "fluids", "fire", "pistons", "join"]
|
||||
WORLD_CHANGES = ["tnt", "fill", "fluids", "fire", "pistons"]
|
||||
|
||||
# First sight: what each kind of thing costs the first frame it is on screen.
|
||||
MOBS = ["zombie", "skeleton", "creeper", "spider", "enderman", "cow", "pig", "sheep", "chicken", "horse", "donkey",
|
||||
@@ -218,6 +225,9 @@ def scenario(name, control, distance):
|
||||
control("key t tap")
|
||||
control("key f3 up")
|
||||
add(record(control, reload, 15), "F3+T")
|
||||
elif name in WORLD_CHANGES:
|
||||
for step, result in world_change(name, control, distance, lambda action, seconds: record(control, action, seconds)):
|
||||
add(result, step)
|
||||
elif name == "inventory":
|
||||
def spam():
|
||||
for _ in range(20):
|
||||
@@ -234,6 +244,67 @@ def scenario(name, control, distance):
|
||||
return frames, cpu
|
||||
|
||||
|
||||
def world_change(name, control, distance, record):
|
||||
"""Builds `name`'s setup in the air (y 200, away from terrain) in front of the camera, then
|
||||
records it running: a list of (step, (frame times, cpu times))."""
|
||||
cx, y, cz = 9000 + 300 * WORLD_CHANGES.index(name), 200, 9000
|
||||
control(f"command execute in minecraft:overworld run tp @s {cx} {y + 22} {cz - 34} 0 30")
|
||||
control(f"fly {cx} {y + 22} {cz - 34} {cx} {y + 22} {cz - 34} 0.1 30")
|
||||
pb.wait_loaded(control, lambda p: True, 300, pb.view_chunks(distance))
|
||||
# Typed in chat, as a player does: `command` blocks the frame on the server tick.
|
||||
def chat(command):
|
||||
control(f"chat /{command}")
|
||||
time.sleep(0.06)
|
||||
steps = []
|
||||
if name == "tnt":
|
||||
chat(f"fill {cx - 10} {y - 10} {cz - 10} {cx + 10} {y - 1} {cz + 10} minecraft:stone")
|
||||
chat(f"fill {cx - 2} {y} {cz - 2} {cx + 2} {y + 1} {cz + 2} minecraft:tnt")
|
||||
time.sleep(2)
|
||||
steps.append(("50 TNT", record(lambda: chat(f"setblock {cx + 3} {y} {cz} minecraft:redstone_block"), 10)))
|
||||
elif name == "fill":
|
||||
fills = [(y, y + 7), (y + 8, y + 15)]
|
||||
steps.append(("fill 64x64x16 stone", record(lambda: [chat(
|
||||
f"fill {cx - 32} {lo} {cz - 32} {cx + 31} {hi} {cz + 31} minecraft:stone") for lo, hi in fills], 5)))
|
||||
steps.append(("clear it", record(lambda: [chat(
|
||||
f"fill {cx - 32} {lo} {cz - 32} {cx + 31} {hi} {cz + 31} minecraft:air") for lo, hi in fills], 5)))
|
||||
elif name == "fluids":
|
||||
# Two 9-wide slopes falling 16 blocks over 16, side by side: water on one, lava on the other.
|
||||
for i in range(16):
|
||||
chat(f"fill {cx - 14} {y - 20} {cz + i} {cx + 14} {y - i} {cz + i} minecraft:stone")
|
||||
chat(f"fill {cx - 14} {y - 20} {cz + 16} {cx + 14} {y - 15} {cz + 30} minecraft:stone")
|
||||
time.sleep(2)
|
||||
steps.append(("water", record(lambda: chat(f"fill {cx - 13} {y + 1} {cz} {cx - 5} {y + 1} {cz} minecraft:water"), 15)))
|
||||
steps.append(("lava", record(lambda: chat(f"fill {cx + 5} {y + 1} {cz} {cx + 13} {y + 1} {cz} minecraft:lava"), 15)))
|
||||
elif name == "fire":
|
||||
# A 25x25 grove: grass, a log every 4 blocks, persistent leaves around them.
|
||||
chat(f"fill {cx - 12} {y - 1} {cz - 12} {cx + 12} {y - 1} {cz + 12} minecraft:grass_block")
|
||||
chat(f"fill {cx - 12} {y + 3} {cz - 12} {cx + 12} {y + 6} {cz + 12} minecraft:oak_leaves[persistent=true]")
|
||||
for x in range(cx - 12, cx + 13, 4):
|
||||
for z in range(cz - 12, cz + 13, 4):
|
||||
chat(f"fill {x} {y} {z} {x} {y + 5} {z} minecraft:oak_log")
|
||||
time.sleep(2)
|
||||
spots = [(cx - 8, cz - 8), (cx + 8, cz - 8), (cx, cz), (cx - 8, cz + 8), (cx + 8, cz + 8)]
|
||||
steps.append(("fire spreading", record(lambda: [chat(f"setblock {x} {y + 7} {z} minecraft:fire")
|
||||
for x, z in spots], 25)))
|
||||
elif name == "pistons":
|
||||
# Two observers facing each other pulse every other tick into a dust line each way; each
|
||||
# line powers the stone it lies on, and 10 pistons beside that stone push a block.
|
||||
chat(f"fill {cx - 12} {y - 1} {cz - 1} {cx + 12} {y - 1} {cz + 3} minecraft:stone")
|
||||
for xs in (range(cx + 1, cx + 11), range(cx - 11, cx - 1)):
|
||||
for x in xs:
|
||||
chat(f"setblock {x} {y} {cz} minecraft:stone")
|
||||
chat(f"setblock {x} {y + 1} {cz} minecraft:redstone_wire")
|
||||
chat(f"setblock {x} {y} {cz + 1} minecraft:piston[facing=south]")
|
||||
chat(f"setblock {x} {y} {cz + 2} minecraft:oak_planks")
|
||||
chat(f"setblock {cx} {y} {cz} minecraft:stone")
|
||||
time.sleep(2)
|
||||
def start():
|
||||
chat(f"setblock {cx} {y + 1} {cz} minecraft:observer[facing=west]")
|
||||
chat(f"setblock {cx - 1} {y + 1} {cz} minecraft:observer[facing=east]")
|
||||
steps.append(("20-piston clock", record(start, 15)))
|
||||
return steps
|
||||
|
||||
|
||||
def launch(args, distance, tag):
|
||||
"""A fresh game on its title screen: (process, control, log path, log file, game dir, socket)."""
|
||||
import shutil
|
||||
|
||||
+25
-3
@@ -860,17 +860,23 @@ fn generate(root: &Path, check: bool) -> Result<(), String> {
|
||||
"/// What [`{trait_name}::{name}`] is told. Fields may be added: the struct is `#[non_exhaustive]`.\n#[non_exhaustive]\npub struct {event_type}{generics} {{\n{fields}}}\n"
|
||||
));
|
||||
let build = format!("let mut {event} = {event_type} {{ {} }};", inits.join(", "));
|
||||
// A trait mod's call is marked with the mod for a panic to name; render hooks are called bare.
|
||||
let each = if is_render {
|
||||
mod_list.clone()
|
||||
} else {
|
||||
format!("super::entered({mod_list}, \"{name}\")")
|
||||
};
|
||||
let body = match contract {
|
||||
"cancel" => format!(
|
||||
"if *{o} {{ return; }} {build} for {mod_item} in {mod_list} {{ if matches!({mod_item}.{name}({invoke}), Flow::Cancel) {{ *{o} = true; break; }} }} {write_back}",
|
||||
"if *{o} {{ return; }} {build} for {mod_item} in {each} {{ if matches!({mod_item}.{name}({invoke}), Flow::Cancel) {{ *{o} = true; break; }} }} {write_back}",
|
||||
o = out.unwrap()
|
||||
),
|
||||
"replace" => format!(
|
||||
"if {o}.is_some() {{ return; }} {build} for {mod_item} in {mod_list} {{ if let Some({replacement}) = {mod_item}.{name}({invoke}) {{ *{o} = Some({replacement}); break; }} }} {write_back}",
|
||||
"if {o}.is_some() {{ return; }} {build} for {mod_item} in {each} {{ if let Some({replacement}) = {mod_item}.{name}({invoke}) {{ *{o} = Some({replacement}); break; }} }} {write_back}",
|
||||
o = out.unwrap()
|
||||
),
|
||||
_ => format!(
|
||||
"{build} for {mod_item} in {mod_list} {{ {mod_item}.{name}({invoke}); }} {write_back}"
|
||||
"{build} for {mod_item} in {each} {{ {mod_item}.{name}({invoke}); }} {write_back}"
|
||||
),
|
||||
};
|
||||
dispatch.push_str(&format!(
|
||||
@@ -1153,6 +1159,19 @@ mod tests {
|
||||
));
|
||||
}
|
||||
use generated::*;
|
||||
thread_local! {
|
||||
/// The hooks each dispatch entered, a mod at a time.
|
||||
static ENTERED: std::cell::RefCell<Vec<(usize, &'static str)>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
fn entered<'a>(
|
||||
mods: &'a [Box<dyn Hooks>],
|
||||
hook: &'static str,
|
||||
) -> impl Iterator<Item = &'a Box<dyn Hooks>> {
|
||||
mods.iter().enumerate().map(move |(index, hooks)| {
|
||||
ENTERED.with(|entered| entered.borrow_mut().push((index, hook)));
|
||||
hooks
|
||||
})
|
||||
}
|
||||
struct Mod {
|
||||
id: u32,
|
||||
calls: Arc<Mutex<Vec<u32>>>,
|
||||
@@ -1327,6 +1346,9 @@ mod tests {
|
||||
dispatch_mouse_button_pre(&mut TestHost, &mods, 0, true, 0, &mut canceled);
|
||||
assert!(canceled);
|
||||
assert_eq!(*calls.lock().unwrap(), [1]);
|
||||
// Each call is entered under the hook's name; the cancelled one's is the last.
|
||||
let entered = ENTERED.with(|entered| entered.take());
|
||||
assert_eq!(entered, [(0, "mouse_button_pre")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user