Merge remote-tracking branch 'origin/main' into feat/management-gamerules-parity

# Conflicts:
#	docs/server.md
This commit is contained in:
Akirabane committed 2026-11-02 15:05:10 +01:00
commit 5dedbb4ccb
22 files changed
+1630 -454

No files matched your search

+93 -6
View File
@@ -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(),
+87
View File
@@ -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>];
File diff suppressed because it is too large. Load diff
+100 -1
View File
@@ -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`
+1 -1
View File
@@ -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)
}
+36 -3
View File
@@ -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\
+293 -37
View File
@@ -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
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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.
+42 -5
View File
@@ -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();
}
+145
View File
@@ -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
View File
@@ -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
View File
@@ -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]