diff --git a/Cargo.lock b/Cargo.lock index e70180b38..ec7a0e306 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -250,10 +250,10 @@ dependencies = [ "hyperion-item", "hyperion-permission", "hyperion-utils", + "hyperion-web-console", "rayon", "roaring", "rustc-hash 2.1.3", - "tikv-jemallocator", "tracing", "uuid", "valence_protocol", @@ -1765,6 +1765,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -1788,6 +1794,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1973,6 +1980,7 @@ dependencies = [ "dotenvy", "envy", "hyperion", + "hyperion-web-console", "serde", "tracing", "tracing-subscriber", @@ -2155,6 +2163,10 @@ dependencies = [ "rkyv", ] +[[package]] +name = "hyperion-reload-client" +version = "0.1.0" + [[package]] name = "hyperion-utils" version = "0.1.0" @@ -2175,6 +2187,24 @@ dependencies = [ "valence_protocol", ] +[[package]] +name = "hyperion-web-console" +version = "0.1.0" +dependencies = [ + "flecs_ecs", + "http-body-util", + "hyper", + "hyper-util", + "hyperion", + "hyperion-command", + "hyperion-minecraft-proto", + "hyperion-permission", + "serde_json", + "serial_test", + "tokio", + "tracing", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -3889,23 +3919,33 @@ dependencies = [ "anyhow", "clap", "flecs_ecs", + "geometry", "glam", "hyperion", "hyperion-clap", "hyperion-event-runner", + "hyperion-hot-reload", "hyperion-inventory", "hyperion-item", "hyperion-minecraft-proto", "hyperion-permission", "hyperion-utils", + "hyperion-web-console", "proptest", - "rayon", - "tikv-jemallocator", "tracing", "valence_nbt", "valence_protocol", ] +[[package]] +name = "smash-rules" +version = "0.1.0" +dependencies = [ + "flecs_ecs", + "hyperion-hot-reload", + "tracing", +] + [[package]] name = "snafu" version = "0.9.2" diff --git a/Cargo.toml b/Cargo.toml index 7d10c0878..f983e45fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,10 +40,13 @@ members = [ 'crates/hyperion-permission', 'crates/hyperion-proxy', 'crates/hyperion-proxy-proto', + 'crates/hyperion-reload-client', 'crates/hyperion-utils', + 'crates/hyperion-web-console', 'crates/packet-channel', 'events/bedwars', 'events/smash', + 'events/smash-rules', 'tools/rust-mc-bot', ] resolver = '2' @@ -90,6 +93,13 @@ heapless = "0.9.3" heed = "0.22.1" hex = '0.4.3' humantime = "2.4.0" +# The console's web server. hyper and its two helper crates are already +# in the lock through reqwest, so naming them here adds no new tree -- +# axum would have. Nothing here needs routing or extractors: five paths +# matched on a string is the whole surface. +http-body-util = "0.1.4" +hyper = "1.11.0" +hyper-util = "0.1.20" hyperion-proxy = { path = "crates/hyperion-proxy" } itertools = "0.15.0" kanal = '0.1.1' @@ -253,6 +263,9 @@ path = 'crates/hyperion-proxy-proto' [workspace.dependencies.hyperion-utils] path = 'crates/hyperion-utils' +[workspace.dependencies.hyperion-web-console] +path = 'crates/hyperion-web-console' + [workspace.dependencies.packet-channel] path = 'crates/packet-channel' diff --git a/crates/geometry/src/lib.rs b/crates/geometry/src/lib.rs index 98020edaf..0482391b8 100644 --- a/crates/geometry/src/lib.rs +++ b/crates/geometry/src/lib.rs @@ -1,2 +1,3 @@ pub mod aabb; pub mod ray; +pub mod sweep; diff --git a/crates/geometry/src/ray.rs b/crates/geometry/src/ray.rs index 305effdb4..a0e422206 100644 --- a/crates/geometry/src/ray.rs +++ b/crates/geometry/src/ray.rs @@ -1,10 +1,6 @@ use std::ops::Mul; -use glam::{IVec3, Vec3}; - -const fn nan_as_inf(value: f32) -> f32 { - if value.is_nan() { f32::INFINITY } else { value } -} +use glam::Vec3; #[derive(Debug, Clone, Copy)] pub struct Ray { @@ -60,132 +56,4 @@ impl Ray { pub fn at(&self, t: f32) -> Vec3 { self.origin + self.direction * t } - - /// Efficiently traverse through grid cells that the ray intersects using the Amanatides and Woo algorithm. - /// Returns an iterator over the grid cells ([`IVec3`]) that the ray passes through. - #[inline] - pub fn voxel_traversal(&self, bounds_min: IVec3, bounds_max: IVec3) -> VoxelTraversal { - let current_pos = self.origin.as_ivec3(); - - // Determine stepping direction for each axis - let step = IVec3::new( - if self.direction.x > 0.0 { 1 } else { -1 }, - if self.direction.y > 0.0 { 1 } else { -1 }, - if self.direction.z > 0.0 { 1 } else { -1 }, - ); - - // Calculate distance to next voxel boundary for each axis - let next_boundary = Vec3::new( - if step.x > 0 { - current_pos.x as f32 + 1.0 - self.origin.x - } else { - self.origin.x - current_pos.x as f32 - }, - if step.y > 0 { - current_pos.y as f32 + 1.0 - self.origin.y - } else { - self.origin.y - current_pos.y as f32 - }, - if step.z > 0 { - current_pos.z as f32 + 1.0 - self.origin.z - } else { - self.origin.z - current_pos.z as f32 - }, - ); - - // Calculate t_max and t_delta using precomputed inv_direction - let t_max = (next_boundary * self.inv_direction.abs()).map(nan_as_inf); - let t_delta = self.inv_direction.abs(); - - VoxelTraversal { - current_pos, - step, - t_max, - t_delta, - bounds_min, - bounds_max, - } - } -} - -#[derive(Debug)] -#[must_use] -pub struct VoxelTraversal { - current_pos: IVec3, - step: IVec3, - t_max: Vec3, - t_delta: Vec3, - bounds_min: IVec3, - bounds_max: IVec3, -} - -impl Iterator for VoxelTraversal { - type Item = IVec3; - - fn next(&mut self) -> Option { - // Check if current position is within bounds - if self.current_pos.x < self.bounds_min.x - || self.current_pos.x > self.bounds_max.x - || self.current_pos.y < self.bounds_min.y - || self.current_pos.y > self.bounds_max.y - || self.current_pos.z < self.bounds_min.z - || self.current_pos.z > self.bounds_max.z - { - return None; - } - - let current = self.current_pos; - - // Determine which axis to step along (the one with minimum t_max) - if self.t_max.x < self.t_max.y { - if self.t_max.x < self.t_max.z { - self.current_pos.x += self.step.x; - self.t_max.x += self.t_delta.x; - } else { - self.current_pos.z += self.step.z; - self.t_max.z += self.t_delta.z; - } - } else if self.t_max.y < self.t_max.z { - self.current_pos.y += self.step.y; - self.t_max.y += self.t_delta.y; - } else { - self.current_pos.z += self.step.z; - self.t_max.z += self.t_delta.z; - } - - Some(current) - } -} - -#[cfg(test)] -mod tests { - use itertools::Itertools; - - use super::*; - - #[test] - fn test_traverse_axis_aligned_ray() { - static DIRECTIONS: [IVec3; 6] = [ - IVec3::new(-1, 0, 0), - IVec3::new(1, 0, 0), - IVec3::new(0, -1, 0), - IVec3::new(0, 1, 0), - IVec3::new(0, 0, -1), - IVec3::new(0, 0, 1), - ]; - - static ORIGIN: IVec3 = IVec3::new(-1, 0, 1); - - for direction in DIRECTIONS { - let ray = Ray::new(ORIGIN.as_vec3(), direction.as_vec3()); - let voxels = ray - .voxel_traversal(IVec3::MIN, IVec3::MAX) - .take(10) - .collect::>(); - assert_eq!(voxels[0], ORIGIN); - for (a, b) in voxels.iter().tuple_windows() { - assert_eq!(b - a, direction); - } - } - } } diff --git a/crates/geometry/src/sweep.rs b/crates/geometry/src/sweep.rs new file mode 100644 index 000000000..1b6edabac --- /dev/null +++ b/crates/geometry/src/sweep.rs @@ -0,0 +1,704 @@ +//! Sweeping a segment through a voxel grid and stopping at the first solid +//! surface it meets. +//! +//! A transcription of vanilla's own clip, not an implementation of the same +//! idea. `AbstractArrow.tick` resolves its movement through +//! `level().clipIncludingBorder(new ClipContext(from, to, Block.COLLIDER, +//! Fluid.NONE, this))`, and that call is three pieces of Mojang code: +//! `BlockGetter.traverseBlocks` walks the cells, `VoxelShape.clip` asks one +//! block, and `AABB.clip` finds the nearest face of that block's boxes. Each is +//! reproduced below with the divergence it closes named beside it, because +//! "the same algorithm" and "the same answers" are different claims and only +//! the second one is worth anything to a player watching an arrow. +//! +//! The decompiled originals are the ones in +//! `nix build .#minecraft-physics-sources`, at `BlockGetter.java:112`, +//! `VoxelShape.java:147` and `AABB.java:302`. A jar bump that moves them fails +//! that derivation's landmark checks rather than leaving these citations +//! pointing at nothing. +//! +//! # Why the arithmetic is `f64` +//! +//! Vanilla computes in `double` and leans on epsilons of `1.0E-7`: the +//! traversal pushes both endpoints outward by that much, and `AABB.clip` +//! allows that much slack when testing whether a hit lies within a face. At +//! `f32`, one ulp at a coordinate of 65 is `7.6e-6` -- seventy-six times the +//! epsilon -- so every one of those adjustments rounds away to nothing and the +//! transcription would be a transcription in name only. So the endpoints come +//! in as `f32`, widen once, and the whole clip runs in `f64`. +//! +//! That does not make this bit-identical to vanilla, and the remaining gap is +//! named rather than papered over: hyperion holds a projectile's position as +//! `Vec3`, so the *inputs* are already quantised to `f32` where vanilla's are +//! not. What the widening buys is that the boundary cases -- a segment running +//! exactly along `y == 65.0`, an endpoint exactly on a block face -- are +//! decided the way vanilla decides them, and those are exactly the cases where +//! the epsilons are load bearing and where a coordinate is exactly +//! representable in both. +//! +//! [`first_block_hit`] is generic over where the shapes come from, +//! deliberately. The block store answers from loaded chunks and a test answers +//! from a set of coordinates it wrote by hand, and the traversal that decides +//! which cells to ask about is the same code in both cases. That is what makes +//! the unit tests below evidence about the shipped path rather than about a +//! second copy of it. + +use glam::{DVec3, IVec3, Vec3}; + +use crate::aabb::Aabb; + +/// How far outside the segment vanilla pushes each endpoint before walking it. +/// +/// `BlockGetter.traverseBlocks` lerps both ends by `-1.0E-7`, which moves each +/// one away from the other. It is what stops a segment that ends exactly on a +/// block face from being a coin flip between the two cells that face divides. +const ENDPOINT_EPSILON: f64 = 1.0E-7; + +/// Where a swept segment first met a block. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BlockHit { + /// How far along the segment contact happened, as a fraction in `0.0..=1.0`. + /// + /// A fraction and not a distance in blocks, because that is what the caller + /// has to compare against: a hit at `time` is at `from.lerp(to, time)`, and + /// two candidate hits on the same segment are ordered by it without anyone + /// having to agree on a unit first. + pub time: f32, + /// The block that stopped it. + pub block: IVec3, + /// Where on that block's surface contact happened. + pub point: Vec3, + /// The unit normal of the face crossed, pointing out of the block. + /// + /// For a segment that began inside the shape this is + /// `Direction.getApproximateNearest(diff).getOpposite()` -- the face it + /// would have come in through, had it come in -- which is what vanilla + /// reports rather than nothing. [`Self::inside`] is how a caller tells the + /// two apart. + pub normal: Vec3, + /// Vanilla's `BlockHitResult.isInside`: the segment started within the + /// block's collision shape rather than crossing into it. + pub inside: bool, +} + +/// The first block surface on the segment `from` -> `to`, or `None` if it is +/// clear. +/// +/// `shapes` is asked for the collision boxes of one block, in that block's own +/// coordinates (a full cube is `(0,0,0)..(1,1,1)`); it is called at most once +/// per cell the segment passes through, in the order they are met. An empty +/// iterator means the segment passes through -- air, and anything else with no +/// collision box, are the same answer. +/// +/// A transcription of `BlockGetter.traverseBlocks` (`BlockGetter.java:112`). +/// The cell walk uses endpoints nudged outward by [`ENDPOINT_EPSILON`]; the +/// per-block clip is handed the *original* endpoints, because vanilla's +/// consumer closes over `context.getFrom()` rather than over the lerped locals. +pub fn first_block_hit( + from: Vec3, + to: Vec3, + mut shapes: impl FnMut(IVec3) -> I, +) -> Option +where + I: IntoIterator, +{ + // Divergence 3: `if (from.equals(to)) return missFactory.apply(context)`. + // A zero-length segment is a miss before anything else runs, so it never + // reaches the start-cell probe below and cannot report the block it is + // standing in. + if from == to { + return None; + } + + let origin = from.as_dvec3(); + let target = to.as_dvec3(); + + // Divergence 1: both endpoints pushed outward, `Mth.lerp(-1.0E-7, a, b)` + // being `a - 1e-7 * (b - a)`. Only the walk sees these; the clip below + // gets the originals. + let walk_end = lerp(target, origin, -ENDPOINT_EPSILON); + let walk_start = lerp(origin, target, -ENDPOINT_EPSILON); + + let mut block = floor_ivec3(walk_start); + if let Some(hit) = clip_block(&mut shapes, block, origin, target) { + return Some(hit); + } + + let delta = walk_end - walk_start; + let sign = IVec3::new(sign(delta.x), sign(delta.y), sign(delta.z)); + // `Double.MAX_VALUE` for an axis that does not move, so its `t` never wins + // a comparison and never advances. + let step = DVec3::new( + axis_step(sign.x, delta.x), + axis_step(sign.y, delta.y), + axis_step(sign.z, delta.z), + ); + let mut t = DVec3::new( + step.x * axis_offset(sign.x, walk_start.x), + step.y * axis_offset(sign.y, walk_start.y), + step.z * axis_offset(sign.z, walk_start.z), + ); + + // Divergence 2: the bound is `||`, not `&&` and not a bound on the cell's + // entry time. An axis that has not yet reached the end of the segment keeps + // the walk alive, so the last cell visited can be one entered past `t == 1` + // -- and vanilla clips inside it against the real segment, so a hit there + // is still a hit at `t <= 1`. Bounding on entry time instead dropped that + // cell entirely. + while t.x <= 1.0 || t.y <= 1.0 || t.z <= 1.0 { + if t.x < t.y { + if t.x < t.z { + block.x += sign.x; + t.x += step.x; + } else { + block.z += sign.z; + t.z += step.z; + } + } else if t.y < t.z { + block.y += sign.y; + t.y += step.y; + } else { + block.z += sign.z; + t.z += step.z; + } + + if let Some(hit) = clip_block(&mut shapes, block, origin, target) { + return Some(hit); + } + } + + None +} + +/// `Mth.lerp(t, a, b)`. +fn lerp(a: DVec3, b: DVec3, t: f64) -> DVec3 { + a + (b - a) * t +} + +/// `Mth.sign`. +fn sign(value: f64) -> i32 { + if value > 0.0 { + 1 + } else if value < 0.0 { + -1 + } else { + 0 + } +} + +/// `tDelta`: how much `t` advances per cell on this axis. +fn axis_step(sign: i32, delta: f64) -> f64 { + if sign == 0 { + f64::MAX + } else { + f64::from(sign) / delta + } +} + +/// How far into the first cell the start sits, as a fraction of that cell. +fn axis_offset(sign: i32, start: f64) -> f64 { + let frac = start - start.floor(); + if sign > 0 { 1.0 - frac } else { frac } +} + +const fn floor_ivec3(point: DVec3) -> IVec3 { + #[expect( + clippy::cast_possible_truncation, + reason = "a block coordinate outside i32 is outside any world; vanilla's Mth.floor casts \ + the same way" + )] + IVec3::new( + point.x.floor() as i32, + point.y.floor() as i32, + point.z.floor() as i32, + ) +} + +/// One block's answer: `VoxelShape.clip` (`VoxelShape.java:147`). +/// +/// `from` and `to` are the segment the caller asked about, not the walk's +/// nudged copy. +fn clip_block( + shapes: &mut impl FnMut(IVec3) -> I, + block: IVec3, + from: DVec3, + to: DVec3, +) -> Option +where + I: IntoIterator, +{ + let boxes: Vec = shapes(block).into_iter().collect(); + // `if (this.isEmpty()) return null`. + if boxes.is_empty() { + return None; + } + + let diff = to - from; + // Divergence 5: a segment too short to clip against is not clipped, even + // though it is long enough for the traversal above to have walked it. The + // bound is on the squared length, so this is a segment under about 3.2e-4 + // blocks. + if diff.length_squared() < 1.0E-7 { + return None; + } + + // Divergence 4: the start-inside probe. Vanilla asks whether the shape is + // solid a thousandth of the way along, not at `from` itself, and reports + // the hit *at that probe point* with the face the segment would have + // entered by. Reporting it at `from` with no normal -- which is what + // clamping a slab test to zero produces -- is a different point and a + // different face. + let probe = from + diff * 0.001; + let local = probe - block.as_dvec3(); + if boxes.iter().any(|shape| contains(*shape, local)) { + return Some(BlockHit { + time: 0.001, + block, + point: probe.as_vec3(), + normal: -approximate_nearest(diff), + inside: true, + }); + } + + clip_boxes(&boxes, block, from, diff) +} + +/// Is `point` -- in the block's own coordinates -- within this box? +/// +/// Half open, matching `VoxelShape.isFullWide` reached through `findIndex`: the +/// index of a coordinate sitting exactly on a face is the cell *above* it, so a +/// point on a shape's upper surface is outside the shape rather than in it. +fn contains(shape: Aabb, point: DVec3) -> bool { + let min = shape.min.as_dvec3(); + let max = shape.max.as_dvec3(); + (0..3).all(|axis| min[axis] <= point[axis] && point[axis] < max[axis]) +} + +/// `Direction.getApproximateNearest`: the axis-aligned direction most nearly +/// along `direction`. +/// +/// Vanilla maximises the dot product over `Direction.values()` in the order +/// `DOWN, UP, NORTH, SOUTH, WEST, EAST` with a strict `>`, so a tie goes to the +/// earliest of them -- and a zero vector, which beats nothing, comes out as +/// `NORTH`. Both are reproduced here because a tie is what an exactly diagonal +/// shot produces, which is not a rare input. +fn approximate_nearest(direction: DVec3) -> Vec3 { + const CANDIDATES: [Vec3; 6] = [ + Vec3::NEG_Y, + Vec3::Y, + Vec3::NEG_Z, + Vec3::Z, + Vec3::NEG_X, + Vec3::X, + ]; + + // Vanilla narrows to `float` before comparing, so the tie-breaking is + // decided at `f32` and this has to be too. + let direction = direction.as_vec3(); + let mut best = Vec3::NEG_Z; + let mut best_dot = f32::MIN_POSITIVE; + for candidate in CANDIDATES { + let dot = direction.dot(candidate); + if dot > best_dot { + best_dot = dot; + best = candidate; + } + } + best +} + +/// `AABB.clip(Iterable, from, to, pos)` (`AABB.java:302`). +/// +/// Divergence 6: the boxes are not sorted and not compared by distance. Vanilla +/// carries one running `scaleReference`, starting at `1.0`, and each box may +/// only lower it -- so the ordering is a running minimum and the box list's own +/// order cannot change the answer. A hit is accepted only for `0 < s < best`, +/// which is why a segment starting exactly on a face is not a hit here and is +/// left to the inside probe above. +/// +/// (The `distanceToSqr` comparison in `BlockGetter.clip` orders the *block* +/// result against the *fluid* one. An arrow's `ClipContext` uses `Fluid.NONE`, +/// so the fluid shape is always empty and that comparison always picks the +/// block. hyperion clips no fluids, so it is not reproduced.) +fn clip_boxes(boxes: &[Aabb], block: IVec3, from: DVec3, diff: DVec3) -> Option { + let offset = block.as_dvec3(); + let mut best = 1.0_f64; + let mut normal: Option = None; + + for shape in boxes { + let min = shape.min.as_dvec3() + offset; + let max = shape.max.as_dvec3() + offset; + + for axis in 0..3 { + // An axis the segment barely moves along is skipped outright rather + // than divided by: `dx > 1.0E-7` / `dx < -1.0E-7`, with the band + // between them belonging to neither branch. + let (face, outward) = if diff[axis] > 1.0E-7 { + (min[axis], -1.0) + } else if diff[axis] < -1.0E-7 { + (max[axis], 1.0) + } else { + continue; + }; + + let s = (face - from[axis]) / diff[axis]; + if !(s > 0.0 && s < best) { + continue; + } + + let point = from + diff * s; + let within = (0..3).all(|other| { + other == axis + || (min[other] - 1.0E-7 < point[other] && point[other] < max[other] + 1.0E-7) + }); + if !within { + continue; + } + + best = s; + let mut face_normal = Vec3::ZERO; + #[expect( + clippy::cast_possible_truncation, + reason = "an axis index, and the normal is a unit vector" + )] + { + face_normal[axis] = outward as f32; + } + normal = Some(face_normal); + } + } + + let normal = normal?; + #[expect( + clippy::cast_possible_truncation, + reason = "a fraction of one segment, reported at the precision the caller works in" + )] + Some(BlockHit { + time: best as f32, + block, + point: (from + diff * best).as_vec3(), + normal, + inside: false, + }) +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + /// A world of full cubes at the listed coordinates, which is what a test + /// about traversal wants: the question is which cells get looked at and in + /// what order, and a slab would only make the arithmetic harder to read. + fn cubes(solid: impl IntoIterator) -> impl Fn(IVec3) -> Option { + let solid: HashSet = solid.into_iter().collect(); + move |block| { + solid + .contains(&block) + .then(|| Aabb::new(Vec3::ZERO, Vec3::ONE)) + } + } + + #[test] + fn axis_aligned_shot_stops_at_the_near_face() { + let world = cubes([IVec3::new(5, 0, 0)]); + let hit = first_block_hit(Vec3::new(0.5, 0.5, 0.5), Vec3::new(10.5, 0.5, 0.5), &world) + .expect("a wall five blocks along +X is on the segment"); + + assert_eq!(hit.block, IVec3::new(5, 0, 0)); + // Entered at x == 5, having started at x == 0.5 and aiming for 10.5. + assert!((hit.point.x - 5.0).abs() < 1e-4, "point: {}", hit.point); + assert!((hit.time - 0.45).abs() < 1e-4, "time: {}", hit.time); + assert_eq!(hit.normal, Vec3::NEG_X); + } + + #[test] + fn open_ground_is_a_miss() { + let world = cubes([IVec3::new(5, 0, 0)]); + // Same wall, one block higher than the segment. + let hit = first_block_hit(Vec3::new(0.5, 1.5, 0.5), Vec3::new(10.5, 1.5, 0.5), &world); + assert_eq!(hit, None); + } + + #[test] + fn a_wall_beyond_the_end_of_the_segment_is_not_hit_yet() { + let world = cubes([IVec3::new(5, 0, 0)]); + // One tick's travel that stops short of the wall. The old unbounded + // scan reported this as a hit and an arrow stopped in mid-air metres + // before anything was in the way. + let hit = first_block_hit(Vec3::new(0.5, 0.5, 0.5), Vec3::new(3.5, 0.5, 0.5), &world); + assert_eq!(hit, None); + } + + #[test] + fn a_fast_shot_cannot_tunnel_through_a_one_block_wall() { + let world = cubes([IVec3::new(5, 0, 0)]); + // Sixty blocks in one step: both endpoints are in air and only the + // cells between them say otherwise. + let hit = first_block_hit(Vec3::new(0.5, 0.5, 0.5), Vec3::new(60.5, 0.5, 0.5), &world) + .expect("the wall is between the endpoints even though neither is in it"); + assert_eq!(hit.block, IVec3::new(5, 0, 0)); + } + + #[test] + fn a_diagonal_shot_meets_the_blocks_it_passes_through() { + // A staircase of blocks along the diagonal. The segment crosses the + // second one; the first sits behind the start. + let world = cubes([IVec3::new(3, 3, 0)]); + let hit = first_block_hit(Vec3::new(0.5, 0.5, 0.5), Vec3::new(6.5, 6.5, 0.5), &world) + .expect("the diagonal passes through (3, 3, 0)"); + assert_eq!(hit.block, IVec3::new(3, 3, 0)); + // Entered through whichever face it reached first; on an exact diagonal + // through a corner that is a tie, and either face is a truthful answer. + assert!( + hit.normal == Vec3::NEG_X || hit.normal == Vec3::NEG_Y, + "normal: {}", + hit.normal + ); + } + + #[test] + fn a_corner_the_segment_misses_does_not_stop_it() { + // Two blocks meeting at a corner with a gap on the diagonal between + // them. A traversal that steps both axes at once would step through the + // shared corner and report neither; one that steps a single axis per + // cell visits one of the two and stops. + let world = cubes([IVec3::new(1, 0, 0), IVec3::new(0, 1, 0)]); + let hit = first_block_hit(Vec3::new(0.5, 0.5, 0.5), Vec3::new(1.5, 1.5, 0.5), &world) + .expect("a segment through the shared corner is stopped by one of the two"); + assert!( + hit.block == IVec3::new(1, 0, 0) || hit.block == IVec3::new(0, 1, 0), + "block: {}", + hit.block + ); + } + + #[test] + fn negative_coordinates_traverse_the_same_as_positive_ones() { + // `as_ivec3` truncates towards zero, so a start at x == -0.5 used to be + // read as cell 0 rather than cell -1: everything fired in the negative + // half of a map began its traversal one cell off, and half of every map + // is in the negative half. + let world = cubes([IVec3::new(-5, -1, -1)]); + let hit = first_block_hit( + Vec3::new(-0.5, -0.5, -0.5), + Vec3::new(-10.5, -0.5, -0.5), + &world, + ) + .expect("a wall five blocks along -X is on the segment"); + + assert_eq!(hit.block, IVec3::new(-5, -1, -1)); + // Entered through the +X face, at x == -4. + assert!((hit.point.x + 4.0).abs() < 1e-4, "point: {}", hit.point); + assert_eq!(hit.normal, Vec3::X); + } + + #[test] + fn every_axis_and_sign_stops_at_the_same_distance() { + for axis in 0..3 { + for sign in [1.0_f32, -1.0] { + let mut direction = Vec3::ZERO; + direction[axis] = sign; + + let mut wall = IVec3::ZERO; + #[expect( + clippy::cast_possible_truncation, + reason = "5 and -6 are exactly representable" + )] + let along = (5.0 * sign) as i32 - i32::from(sign < 0.0); + wall[axis] = along; + + let world = cubes([wall]); + let from = Vec3::splat(0.5); + let hit = first_block_hit(from, from + direction * 10.0, &world) + .unwrap_or_else(|| panic!("axis {axis} sign {sign} should hit {wall}")); + + assert_eq!(hit.block, wall, "axis {axis} sign {sign}"); + let mut expected = Vec3::ZERO; + expected[axis] = -sign; + assert_eq!(hit.normal, expected, "axis {axis} sign {sign}"); + } + } + } + + /// Divergence 4. `VoxelShape.clip` probes `from + diff * 0.001` and, when + /// that is solid, reports the hit *there* -- not at `from` -- with the face + /// the segment would have entered by and `isInside` set. + /// + /// The version this replaced reported `from` itself with a zero normal, + /// which is what a slab test clamped to `t == 0` produces. Two different + /// points and two different faces, for the commonest case there is: an + /// arrow loosed by a player standing in a doorway. + #[test] + fn a_segment_starting_inside_a_block_is_stopped_at_the_probe_point() { + let world = cubes([IVec3::ZERO]); + let from = Vec3::splat(0.5); + let to = Vec3::new(10.5, 0.5, 0.5); + let hit = + first_block_hit(from, to, &world).expect("a shot from inside a wall does not get out"); + + assert_eq!(hit.block, IVec3::ZERO); + assert!(hit.inside, "the segment began inside the shape"); + // A thousandth of the way along, which for this ten-block segment is + // one hundredth of a block. + assert!((hit.time - 0.001).abs() < 1e-6, "time: {}", hit.time); + assert!( + (hit.point - from.lerp(to, 0.001)).length() < 1e-5, + "point: {}", + hit.point + ); + // Travelling +X, so the face it would have come in through is the one + // facing -X. + assert_eq!(hit.normal, Vec3::NEG_X); + } + + /// Divergence 4 again, at the boundary the probe exists to move. + /// + /// A segment starting exactly on a block's face is *not* inside it: the + /// probe has already moved a thousandth of the way in, and vanilla's + /// `findIndex` puts a coordinate sitting on a face in the cell above it. + /// So this is an ordinary crossing, reported at `t == 0` by neither of us. + #[test] + fn a_segment_starting_exactly_on_a_face_is_not_inside_it() { + let world = cubes([IVec3::new(1, 0, 0)]); + // Starts on the wall's -X face, heading away from it. + let hit = first_block_hit(Vec3::new(1.0, 0.5, 0.5), Vec3::new(0.0, 0.5, 0.5), &world); + assert_eq!(hit, None, "a segment leaving a face does not hit it"); + + // And heading into it: the probe lands inside, so this one is. + let hit = first_block_hit(Vec3::new(1.0, 0.5, 0.5), Vec3::new(2.0, 0.5, 0.5), &world) + .expect("a segment entering the wall meets it"); + assert!(hit.inside, "the probe point is within the cube"); + assert_eq!(hit.block, IVec3::new(1, 0, 0)); + } + + /// Divergence 5: `VoxelShape.clip` refuses a segment whose squared length + /// is under `1.0E-7`, even though the traversal above walked it happily. + /// + /// About 3.2e-4 blocks. Short enough that no projectile produces one, and + /// exactly the reason it is worth transcribing rather than reasoning + /// about: a caller that samples a resting entity gets vanilla's answer + /// instead of an arbitrary one. + #[test] + fn a_segment_too_short_to_clip_is_not_clipped() { + let world = cubes([IVec3::ZERO]); + let from = Vec3::splat(0.5); + + // Squared length 3 * (1e-4)^2 = 3e-8, under the bound. + let inside_bound = from + Vec3::splat(1e-4); + assert_eq!(first_block_hit(from, inside_bound, &world), None); + + // Squared length 3 * (1e-3)^2 = 3e-6, over it, and the segment is + // inside the cube, so it hits. + let over_bound = from + Vec3::splat(1e-3); + let hit = first_block_hit(from, over_bound, &world) + .expect("a segment over the length bound is clipped"); + assert!(hit.inside); + } + + /// Divergence 6: `AABB.clip` accepts a face only for `0 < s < best`, where + /// `best` starts at `1.0`. + /// + /// Both ends are strict, and both matter. A segment that reaches a face + /// exactly at its far end has `s == 1.0` and is *not* a hit -- it is a hit + /// on the next tick. A segment starting exactly on a face has `s == 0.0` + /// and is not a hit either; the inside probe is what decides that case. + #[test] + fn a_face_reached_exactly_at_the_end_of_the_segment_is_not_hit_yet() { + let world = cubes([IVec3::new(1, 0, 0)]); + // Ends exactly on the wall's near face. + assert_eq!( + first_block_hit(Vec3::new(0.5, 0.5, 0.5), Vec3::new(1.0, 0.5, 0.5), &world), + None + ); + // A hair further, and it is a hit. + let hit = first_block_hit(Vec3::new(0.5, 0.5, 0.5), Vec3::new(1.001, 0.5, 0.5), &world) + .expect("a segment that crosses the face meets it"); + assert_eq!(hit.block, IVec3::new(1, 0, 0)); + assert!(!hit.inside); + assert_eq!(hit.normal, Vec3::NEG_X); + } + + /// Divergence 1: the traversal walks endpoints pushed outward by 1e-7 of + /// the segment, so a start sitting exactly on a face begins in the cell + /// *below* that face rather than the one above it. + /// + /// The consequence is which cells get asked for shapes, and that is not + /// bookkeeping: a block's collision shape is not confined to its own cell. + /// A fence post is 1.5 blocks tall and a big dripleaf's stem starts at + /// -0.25, so a cell the segment only clips the corner of can still be the + /// one holding the box it hits. + #[test] + fn the_walk_starts_below_a_face_it_begins_exactly_on() { + let asked = std::cell::RefCell::new(Vec::new()); + let record = |block: IVec3| { + asked.borrow_mut().push(block); + None:: + }; + // Starts exactly on the x == 1 face, heading +X. + first_block_hit(Vec3::new(1.0, 0.5, 0.5), Vec3::new(3.0, 0.5, 0.5), record); + + let asked = asked.into_inner(); + assert_eq!( + asked.first().copied(), + Some(IVec3::new(0, 0, 0)), + "the walk asked about {asked:?}" + ); + } + + /// `Direction.getApproximateNearest` maximises the dot product over + /// `DOWN, UP, NORTH, SOUTH, WEST, EAST` with a strict `>`, so an exact + /// diagonal resolves to the earliest of the tied directions rather than to + /// whichever one an implementation happened to check last. + /// + /// Reached through the inside probe, which is the only thing that reports + /// it. + #[test] + fn a_tied_diagonal_resolves_the_way_vanillas_direction_order_does() { + let world = cubes([IVec3::ZERO]); + // Equal -Y and -Z, no X. Vanilla checks DOWN first, so DOWN wins the + // tie and the reported face is its opposite, UP. + let from = Vec3::splat(0.5); + let hit = first_block_hit(from, from + Vec3::new(0.0, -1.0, -1.0), &world) + .expect("a segment inside the cube hits it"); + assert!(hit.inside); + assert_eq!(hit.normal, Vec3::Y); + } + + #[test] + fn a_zero_length_segment_in_air_hits_nothing() { + let world = cubes([IVec3::new(5, 0, 0)]); + let at = Vec3::splat(0.5); + assert_eq!(first_block_hit(at, at, &world), None); + } + + #[test] + fn the_nearest_of_several_blocks_is_the_one_reported() { + let world = cubes([ + IVec3::new(2, 0, 0), + IVec3::new(5, 0, 0), + IVec3::new(9, 0, 0), + ]); + let hit = first_block_hit(Vec3::new(0.5, 0.5, 0.5), Vec3::new(10.5, 0.5, 0.5), &world) + .expect("three walls ahead, one of them first"); + assert_eq!(hit.block, IVec3::new(2, 0, 0)); + } + + #[test] + fn a_partial_shape_is_missed_where_a_full_cube_would_be_hit() { + // The bottom half of the cell only: a slab. A segment through the top + // half passes over it, which is the whole reason shapes are asked for + // per block rather than a solid/not-solid bit. + let slab = |block: IVec3| { + (block == IVec3::new(5, 0, 0)).then(|| Aabb::new(Vec3::ZERO, Vec3::new(1.0, 0.5, 1.0))) + }; + assert_eq!( + first_block_hit(Vec3::new(0.5, 0.8, 0.5), Vec3::new(10.5, 0.8, 0.5), &slab), + None + ); + let hit = first_block_hit(Vec3::new(0.5, 0.2, 0.5), Vec3::new(10.5, 0.2, 0.5), &slab) + .expect("a segment through the lower half meets the slab"); + assert_eq!(hit.block, IVec3::new(5, 0, 0)); + } +} diff --git a/crates/hyperion-event-runner/Cargo.toml b/crates/hyperion-event-runner/Cargo.toml index 8e822e2b1..78f29a016 100644 --- a/crates/hyperion-event-runner/Cargo.toml +++ b/crates/hyperion-event-runner/Cargo.toml @@ -4,6 +4,7 @@ clap = { workspace = true } dotenvy = { workspace = true } envy = "0.4" hyperion = { workspace = true } +hyperion-web-console = { workspace = true } serde = { version = "1.0", features = ["derive"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/hyperion-event-runner/src/lib.rs b/crates/hyperion-event-runner/src/lib.rs index 3f213a33f..fed87d0b4 100644 --- a/crates/hyperion-event-runner/src/lib.rs +++ b/crates/hyperion-event-runner/src/lib.rs @@ -13,14 +13,17 @@ //! which cannot be spelled wrong. use std::{ + io::IsTerminal, net::{IpAddr, Ipv4Addr, SocketAddr}, path::PathBuf, }; +use anyhow::{Context, bail}; use clap::Parser; use hyperion::Crypto; +use hyperion_web_console::Config as ConsoleConfig; use serde::Deserialize; -use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt}; +use tracing_subscriber::{EnvFilter, Registry, filter::LevelFilter, layer::SubscriberExt}; /// What every event binary takes. #[derive(Parser, Deserialize, Debug)] @@ -46,6 +49,74 @@ pub struct Args { /// The file path to the game server's private key. #[clap(long)] pub private_key: PathBuf, + + /// The reloadable rules dylib: loaded once at startup, and again on every + /// reload request. + /// + /// A stable path outside the store, so that deploying a new build of the + /// rules does not change this string and therefore does not change the + /// unit's `[Service]` section -- which is the condition under which systemd + /// reloads rather than restarts. See `nix/modules/game-server.nix`. + #[clap(long, requires_all = ["reload_socket", "build_stamp"])] + #[serde(default)] + pub rules: Option, + + /// Where to listen for reload requests. `ExecReload` runs a client that + /// connects here; see `hyperion-reload-client`. + #[clap(long, requires_all = ["rules", "build_stamp"])] + #[serde(default)] + pub reload_socket: Option, + + /// Directory holding the build stamp files the deploy writes. + /// + /// A directory read at runtime rather than three variables in the + /// environment, because a process's environment is fixed at `exec` and a + /// hot reload's entire point is that there is no new `exec` to fix it. A + /// server that reloaded would otherwise report the build it started as, + /// forever. + #[clap(long, requires_all = ["rules", "reload_socket"])] + #[serde(default)] + pub build_stamp: Option, + + /// Where to serve the operator console, or nowhere. + /// + /// Absent means no console at all: no socket is opened and nothing is + /// spent. An address here is an admin surface, so the value an operator + /// should reach for is a loopback or an internal one; see + /// [`hyperion_web_console`] for what it exposes. + /// + /// `requires` runs this way round and not the other because only one + /// direction is dangerous. A token file with no bind address is a console + /// that is simply off; a bind address with no token file is an open admin + /// port that looks configured. + #[clap(long, requires = "console_token_file")] + #[serde(default)] + pub console_bind: Option, + + /// The file holding the console's bearer token. + /// + /// A file rather than an argument or an environment variable, because both + /// of those are readable by anything that can list processes. A systemd + /// `LoadCredential` puts one here. + #[clap(long)] + #[serde(default)] + pub console_token_file: Option, +} + +/// The paths a packaged deployment hands the server. +/// +/// All three or none of them. They are separate options rather than one +/// directory because the deployment, not the event, decides where each lives, +/// and an event that derived `/-rules.so` would be a second +/// place that has to agree with the NixOS module about a filename. +#[derive(Debug, Clone)] +pub struct Deployment { + /// The rules dylib to load, and to reload. + pub rules: PathBuf, + /// The socket a reload is asked for on. + pub reload_socket: PathBuf, + /// The directory the build stamp files are in. + pub build_stamp: PathBuf, } impl Args { @@ -54,6 +125,78 @@ impl Args { pub const fn address(&self) -> SocketAddr { SocketAddr::new(self.ip, self.port) } + + /// The deployment paths, when this process was started by one. + /// + /// Clap enforces all-or-nothing on the command line; this is the same rule + /// for the environment, which `envy` deserializes without consulting clap + /// at all. Refusing a partial set rather than degrading to "no reload" is + /// deliberate: a deploy that passed two of the three and silently lost the + /// ability to reload would look exactly like one that never asked for it. + /// + /// # Errors + /// If some of the three are set and some are not, naming which are missing. + pub fn deployment(&self) -> anyhow::Result> { + match (&self.rules, &self.reload_socket, &self.build_stamp) { + (None, None, None) => Ok(None), + (Some(rules), Some(reload_socket), Some(build_stamp)) => Ok(Some(Deployment { + rules: rules.clone(), + reload_socket: reload_socket.clone(), + build_stamp: build_stamp.clone(), + })), + (rules, socket, stamp) => { + let missing: Vec<&str> = [ + ("--rules", rules.is_none()), + ("--reload-socket", socket.is_none()), + ("--build-stamp", stamp.is_none()), + ] + .into_iter() + .filter_map(|(name, absent)| absent.then_some(name)) + .collect(); + bail!("a partial deployment: {} not set", missing.join(", ")) + } + } + } + + /// How to run the operator console, or `None` when it was not asked for. + /// + /// # Errors + /// Fails when a bind address was given without a token file, when the file + /// cannot be read, or when it is empty once trimmed. All three are refused + /// at startup rather than warned about: every one of them ends in a console + /// that either is not there or has no password, and an operator finding + /// that out later finds it out the wrong way. + pub fn console(&self) -> anyhow::Result> { + let Some(address) = self.console_bind else { + return Ok(None); + }; + + // `clap` already refuses this on the command line; the environment path + // does not go through clap at all, so this is the check that covers + // `SMASH_CONSOLE_BIND` set without its token. Same shape, and same + // reason, as `deployment` above. + let Some(path) = self.console_token_file.as_ref() else { + bail!( + "the console was asked for at {address} with no --console-token-file, which would \ + be an admin port with no password" + ); + }; + + let token = std::fs::read_to_string(path) + .with_context(|| format!("reading the console token from {}", path.display()))?; + // Trimmed because a token file written by a person, or by systemd's + // credential machinery, ends in a newline that is not part of the + // secret. A token compared with the newline still on it fails every + // request and looks like a wrong password. + let token = token.trim().to_owned(); + anyhow::ensure!( + !token.is_empty(), + "the console token file {} is empty", + path.display() + ); + + Ok(Some(ConsoleConfig { address, token })) + } } const fn default_ip() -> IpAddr { @@ -64,17 +207,39 @@ const fn default_port() -> u16 { 35565 } +/// Logging, at a level and in a form that survives the trip to a journal. +/// +/// # `info` by default, rather than whatever `RUST_LOG` happens to say +/// +/// `EnvFilter::from_default_env()` with `RUST_LOG` unset builds a filter with no directives +/// at all, which passes nothing. A deployed unit sets no `RUST_LOG`, so the server was +/// silent: not quiet, silent -- no startup line, no map built, and no `hot reload accepted` +/// either, which is the one line an operator needs after a deploy that is supposed to be +/// invisible. Measured on dev-compute-6: the same server, same unit, with and without +/// `RUST_LOG`, is zero journal lines versus every line below. +/// +/// `RUST_LOG` still wins when it is set, so narrowing or widening is a variable away. +/// +/// # No ANSI unless something is there to render it +/// +/// `fmt::layer()` colours its output whatever it is writing to, so a service wrote +/// `\x1b[32m INFO\x1b[0m` into the journal and every `grep 'INFO'` over it matched +/// nothing. The escapes are dropped when stdout is not a terminal, which is exactly the +/// case where nobody can see them and something is probably grepping. fn setup_logging() { + let filter = EnvFilter::builder() + .with_default_directive(LevelFilter::INFO.into()) + .from_env_lossy(); + tracing::subscriber::set_global_default( - Registry::default() - .with(EnvFilter::from_default_env()) - .with( - tracing_subscriber::fmt::layer() - .with_target(false) - .with_thread_ids(false) - .with_file(true) - .with_line_number(true), - ), + Registry::default().with(filter).with( + tracing_subscriber::fmt::layer() + .with_ansi(std::io::stdout().is_terminal()) + .with_target(false) + .with_thread_ids(false) + .with_file(true) + .with_line_number(true), + ), ) .expect("setup tracing subscribers"); } @@ -90,7 +255,7 @@ fn setup_logging() { /// Returns whatever `init_game` returns, or an error reading the TLS material. pub fn run( env_prefix: &str, - init_game: impl FnOnce(SocketAddr, Crypto) -> anyhow::Result<()>, + init_game: impl FnOnce(&Args, Crypto) -> anyhow::Result<()>, ) -> anyhow::Result<()> { dotenvy::dotenv().ok(); @@ -109,11 +274,13 @@ pub fn run( let crypto = Crypto::new(&args.root_ca_cert, &args.cert, &args.private_key)?; - init_game(args.address(), crypto) + init_game(&args, crypto) } #[cfg(test)] mod tests { + use std::path::PathBuf; + use clap::Parser; use super::Args; @@ -140,6 +307,55 @@ mod tests { assert_eq!(args_from("::").address().to_string(), "[::]:35565"); } + /// Nothing set is the developer's server, and it is not a misconfiguration. + #[test] + fn a_server_with_no_deployment_paths_has_no_deployment() { + assert!( + args_from("::") + .deployment() + .expect("all three absent is legal") + .is_none() + ); + } + + /// Two of three has to fail, and has to say which one is missing. + /// + /// Clap refuses this on the command line -- `requires_all` -- so the way it + /// reaches a running server is `envy`, which reads the environment and + /// knows nothing about clap's argument groups. That is the path the + /// deployed server takes. + #[test] + fn a_partial_deployment_is_refused_by_name() { + let mut args = args_from("::"); + args.rules = Some(PathBuf::from("/etc/hyperion/smash-rules.so")); + args.reload_socket = Some(PathBuf::from("/run/hyperion/reload.sock")); + + let error = args + .deployment() + .expect_err("two of three must not read as a working deployment") + .to_string(); + assert!(error.contains("--build-stamp"), "{error}"); + assert!(!error.contains("--rules"), "{error}"); + } + + #[test] + fn all_three_together_are_a_deployment() { + let mut args = args_from("::"); + args.rules = Some(PathBuf::from("/etc/hyperion/smash-rules.so")); + args.reload_socket = Some(PathBuf::from("/run/hyperion/reload.sock")); + args.build_stamp = Some(PathBuf::from("/etc/hyperion")); + + let deployment = args + .deployment() + .expect("all three set") + .expect("all three set"); + assert_eq!( + deployment.rules, + PathBuf::from("/etc/hyperion/smash-rules.so") + ); + assert_eq!(deployment.build_stamp, PathBuf::from("/etc/hyperion")); + } + #[test] fn the_default_is_every_ipv4_address() { assert_eq!( diff --git a/crates/hyperion-hot-reload/Cargo.toml b/crates/hyperion-hot-reload/Cargo.toml index 8ea857e21..e31be70e8 100644 --- a/crates/hyperion-hot-reload/Cargo.toml +++ b/crates/hyperion-hot-reload/Cargo.toml @@ -18,6 +18,28 @@ version.workspace = true [lib] crate-type = ["dylib", "rlib"] +# NO `tracing`, AND NOTHING ELSE THAT `hyperion` ALSO STATICALLY LINKS. +# +# This crate and `crates/hyperion` are both dylibs, and under the packaged build's +# `-C prefer-dynamic` they are SIBLINGS in the graph: neither depends on the other, so +# each statically includes its own copy of every rlib it uses. A binary linking both is +# then refused outright, naming the shared crates: +# +# error: cannot satisfy dependencies so `tracing` only shows up once +# error: cannot satisfy dependencies so `tracing_core` only shows up once +# error: cannot satisfy dependencies so `once_cell` only shows up once +# error: cannot satisfy dependencies so `pin_project_lite` only shows up once +# +# Those four are `tracing` and its dependencies, and they were the entire list -- +# everything else this crate uses arrives inside `libflecs_ecs.so`, which is a dylib and +# therefore one copy. So this crate reports rather than logs: `service::Outcome` carries +# what happened to the host, and the host writes it wherever hosts write things. +# +# The obvious alternative -- making `hyperion` depend on this crate, which turns the +# siblings into a chain -- was tried and reverted. It fixes the packaged link and breaks +# a plain one: `cargo test -p hyperion-hot-reload -p smash` then builds `libhyperion.so` +# against `libhyperion_hot_reload.so`, neither with `prefer-dynamic`, and `std` is +# duplicated instead. [dependencies] flecs_ecs.workspace = true libloading = "0.9.0" diff --git a/crates/hyperion-hot-reload/demo/index-probe-host/src/main.rs b/crates/hyperion-hot-reload/demo/index-probe-host/src/main.rs index 785a178c3..1beda93bb 100644 --- a/crates/hyperion-hot-reload/demo/index-probe-host/src/main.rs +++ b/crates/hyperion-hot-reload/demo/index-probe-host/src/main.rs @@ -63,9 +63,9 @@ fn main() { "host and module allocate component indices from separate pools: the module got \ {module_index} after the host had already taken up to {host_max}.\nThis is the expected \ result on a default build, and the probe is the reason to know it. Passing needs the \ - dylib recipe in docs/hot-reload.md: `hyperion` built as a dylib and everything compiled \ - with `-C prefer-dynamic -C link-arg=-Wl,--undefined-version -C \ - link-arg=-Wl,--allow-shlib-undefined`." + dylib recipe in docs/hot-reload.md: `hyperion` and `flecs_ecs` built as dylibs and \ + everything compiled with `-C prefer-dynamic`. Run `nix run .#hot-reload-index-probe`, \ + which applies exactly that recipe." ); println!("PROBE_OK"); } diff --git a/crates/hyperion-hot-reload/src/host.rs b/crates/hyperion-hot-reload/src/host.rs index 6b066d487..ead708cef 100644 --- a/crates/hyperion-hot-reload/src/host.rs +++ b/crates/hyperion-hot-reload/src/host.rs @@ -3,7 +3,10 @@ //! Nothing in the live world is touched until [`gate::plan`] has accepted the candidate, //! which is why a refused reload leaves a running server exactly as it was. -use std::{collections::BTreeMap, path::Path}; +use std::{ + collections::{BTreeMap, BTreeSet}, + path::Path, +}; use flecs_ecs::{ core::{ @@ -30,6 +33,17 @@ struct Loaded { /// Why a load could not even be attempted. #[derive(Debug)] pub enum LoadError { + /// `dlopen` handed back an image it had already loaded, so the candidate's code + /// never ran. See [`HotReloader::seen_entries`]. + Deduped, + /// The candidate could not be copied somewhere `dlopen` has not seen before. + /// + /// Carries the path because `std::fs::copy`'s error does not: "No such file or + /// directory" with no name in it is the least useful thing a failed deploy can say. + Stage { + path: std::path::PathBuf, + source: std::io::Error, + }, Dlopen(libloading::Error), MissingEntry(libloading::Error), /// The module and the host disagree about the runtime they share. @@ -47,6 +61,19 @@ fn platform_detail(e: &libloading::Error) -> String { impl core::fmt::Display for LoadError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { + Self::Deduped => write!( + f, + "the loader was handed back a module image it had already loaded, so this build's \ + code would never have run. `dlopen` matches on the name it is given before it \ + looks at the file, so a candidate must reach it under a name nothing has used yet" + ), + Self::Stage { path, source } => { + write!( + f, + "could not read the module at {}: {source}", + path.display() + ) + } Self::Dlopen(e) => write!(f, "could not open module: {}", platform_detail(e)), Self::MissingEntry(e) => { write!( @@ -64,8 +91,9 @@ impl core::fmt::Display for LoadError { impl std::error::Error for LoadError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { + Self::Stage { source, .. } => Some(source), Self::Dlopen(e) | Self::MissingEntry(e) => Some(e), - Self::Abi(_) | Self::Refused(_) => None, + Self::Abi(_) | Self::Refused(_) | Self::Deduped => None, } } } @@ -95,6 +123,61 @@ pub struct HotReloader { /// So the handles are forgotten rather than stored. The cost is address-space growth /// proportional to the number of reloads. retained: Vec, + /// The address of every module entry point this loader has ever been handed. + /// + /// **This is the guard that makes a reload unable to lie.** `stage` exists so that + /// `dlopen` never sees a name twice; this exists so that a reload is refused rather + /// than silently repeated if it ever does. Nothing is unloaded, so two genuinely + /// distinct images occupy two address ranges and their entry points cannot collide -- + /// an address that is already in here means the platform returned an image that is + /// already mapped, and the candidate's code did not run. + /// + /// Written for the refactor that has not happened yet: somebody looking at `stage` + /// will eventually ask why it copies a file instead of just passing the path along, + /// and the answer is a defect (ENG-12113) whose every outward signal said the deploy + /// had landed -- `MainPID` unchanged, `NRestarts` unchanged, `hot reload accepted` in + /// the journal, the client printing `accepted`. If the copy goes, this turns the + /// silence back into a refusal with a reason. + seen_entries: BTreeSet, +} + +/// Copies `candidate` to a path `dlopen` has never been given before, and returns it. +/// +/// # Why a copy, and not the path the caller asked for +/// +/// **`dlopen` dedupes on the name it is handed, before it ever looks at the file.** glibc +/// searches its list of loaded objects by name first; a match returns the existing image +/// and the file on disk is never opened. So a host that loads its module through a stable +/// path -- which is exactly what `nix/modules/game-server.nix` arranges, because a path +/// that moves is a `[Service]` that moves and a restart instead of a reload -- would +/// `dlopen` `/etc/hyperion/smash-rules.so`, get back the image it loaded at startup, run +/// the OLD entry point, and report success. +/// +/// That is not hypothetical and it is not visible from anything but a running server. On +/// dev-compute-6 the reload answered `accepted smash-rules bbbbbbb`, `MainPID` and +/// `NRestarts` were unchanged, the journal said `hot reload accepted` -- and +/// `/proc//maps` still showed only the first build, with the old code still logging +/// its old string. Every signal said the deploy landed. +/// +/// Resolving the symlink instead of copying would work in this deployment, because the nix +/// store gives every build its own path. It would keep the bug for anybody rebuilding to a +/// fixed path outside the store, which is what a developer's inner loop looks like, and the +/// symptom would again be silence. A copy is a hundred kilobytes and has no such case. +/// +/// The copies are never removed, for the same reason the libraries are never `dlclose`d +/// (see [`HotReloader::retained`]). Under the deployed unit they live in the private `/tmp` +/// systemd tears down with the service. +fn stage(candidate: &Path, generation: usize) -> std::io::Result { + let name = candidate + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("module")); + // The pid, because two servers on one machine share `/tmp` unless something gives them + // private ones, and a collision here would hand one of them the other's module. + let dir = std::env::temp_dir().join(format!("hyperion-hot-reload-{}", std::process::id())); + std::fs::create_dir_all(&dir)?; + let staged = dir.join(format!("{generation}-{}", name.to_string_lossy())); + std::fs::copy(candidate, &staged)?; + Ok(staged) } impl Default for HotReloader { @@ -109,6 +192,7 @@ impl HotReloader { Self { loaded: BTreeMap::new(), retained: Vec::new(), + seen_entries: BTreeSet::new(), } } @@ -129,17 +213,32 @@ impl HotReloader { /// # Panics /// Panics if the module's registration panics. pub fn load(&mut self, world: &World, path: &Path) -> Result { - let lib = unsafe { libloading::Library::new(path) }.map_err(LoadError::Dlopen)?; + // Never the caller's path: see `stage`, which is the difference between a reload + // and a reload-shaped no-op that reports success. + let staged = stage(path, self.retained.len()).map_err(|source| LoadError::Stage { + path: path.to_owned(), + source, + })?; + let lib = unsafe { libloading::Library::new(&staged) }.map_err(LoadError::Dlopen)?; let descriptor = { let entry: libloading::Symbol<'_, ModuleEntry> = unsafe { lib.get(ENTRY_SYMBOL) }.map_err(LoadError::MissingEntry)?; + // Before the entry point is called, and therefore before anything the + // candidate does can be mistaken for the candidate having been loaded. + // Returning here drops `lib` and so calls `dlclose`, which is safe in exactly + // this case and nowhere else: the image was already open and its first handle + // was forgotten, so this second `dlopen` took the reference count to two and + // dropping ours takes it back to one. Nothing is unmapped. + if !self.seen_entries.insert(*entry as usize) { + return Err(LoadError::Deduped); + } let raw = unsafe { entry() }; *unsafe { Box::from_raw(raw) } }; // Leaked before any of its code runs, so an early return still leaves the mapping // alive for whatever already holds a pointer into it. core::mem::forget(lib); - self.retained.push(path.to_owned()); + self.retained.push(staged); if let Some(reason) = descriptor.token.incompatibility() { return Err(LoadError::Abi(reason)); @@ -367,3 +466,56 @@ pub fn read_raw(world: &World, entity: EntityView<'_>, component: &str) -> Optio Some(std::slice::from_raw_parts(ptr.cast::(), size).to_vec()) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The property the deployment rests on: one path, handed to the loader twice, is two + /// different names by the time `dlopen` sees it. + /// + /// Watched failing. Returning `candidate.to_owned()` from `stage` makes this report the + /// same path twice, which is precisely the state in which a reload runs the old build + /// and answers `accepted`. + #[test] + fn one_path_loaded_twice_becomes_two_names() { + let dir = std::env::temp_dir().join("hyperion-hot-reload-stage-test"); + drop(std::fs::remove_dir_all(&dir)); + std::fs::create_dir_all(&dir).expect("temp dir"); + let stable = dir.join("rules.so"); + + std::fs::write(&stable, b"first build").expect("write"); + let first = stage(&stable, 0).expect("stage the first build"); + + // What a deploy does: the same path, different bytes behind it. + std::fs::write(&stable, b"second build").expect("rewrite"); + let second = stage(&stable, 1).expect("stage the second build"); + + assert_ne!( + first, second, + "both builds would reach dlopen under one name, and the second would be ignored" + ); + assert_eq!(std::fs::read(&first).expect("read"), b"first build"); + assert_eq!(std::fs::read(&second).expect("read"), b"second build"); + + drop(std::fs::remove_dir_all(&dir)); + } + + /// A path that is not there fails here rather than at `dlopen`, and says which path. + #[test] + fn a_path_that_is_not_there_is_named_in_the_error() { + let mut reloader = HotReloader::new(); + let world = World::new(); + let error = reloader + .load(&world, Path::new("/nonexistent/hyperion-no-such-module.so")) + .expect_err("a module that is not there cannot load"); + assert!( + matches!(error, LoadError::Stage { .. }), + "expected a staging failure, got {error:?}" + ); + assert!( + error.to_string().contains("hyperion-no-such-module"), + "the message does not name the path: {error}" + ); + } +} diff --git a/crates/hyperion-hot-reload/src/lib.rs b/crates/hyperion-hot-reload/src/lib.rs index 8f17c2a67..4e0d2793d 100644 --- a/crates/hyperion-hot-reload/src/lib.rs +++ b/crates/hyperion-hot-reload/src/lib.rs @@ -12,6 +12,7 @@ pub mod gate; pub mod host; pub mod manifest; pub mod schema; +pub mod service; pub use abi::{ABI_VERSION, AbiToken, ENTRY_SYMBOL, Migration, ModuleDescriptor, ModuleEntry}; pub use flecs_ecs; @@ -23,6 +24,7 @@ pub use manifest::{ Manifest, ModuleManifest, Registrations, WorldSample, describe_module, read_component_schema, }; pub use schema::{ComponentSchema, FieldSchema, Layout, SchemaHash}; +pub use service::{ReloadService, Reloaded, run}; /// A field type the migration macro can describe to the gate. /// diff --git a/crates/hyperion-hot-reload/src/service.rs b/crates/hyperion-hot-reload/src/service.rs new file mode 100644 index 000000000..ffb2eac30 --- /dev/null +++ b/crates/hyperion-hot-reload/src/service.rs @@ -0,0 +1,400 @@ +//! Answering a service manager's reload request, between two ticks and never during one. +//! +//! # Why the host owns its tick loop +//! +//! A reload deletes and re-creates every system and observer a module registered, and may +//! rewrite stored component bytes. Doing that while flecs is mid-frame is not a race that +//! shows up as a wrong number; it is a system table being rebuilt underneath an iterator. +//! So the reload has to happen at a point where no frame is in progress, and the only such +//! point is between `world.progress()` calls -- which means the host has to be the thing +//! calling `progress`, rather than handing control to `ecs_app_run` and never getting it +//! back. [`run`] is that loop, and it is the whole reason this module is not simply a +//! flecs system. +//! +//! # The protocol is one verb, and the answer is one line +//! +//! `ExecReload` runs a client that connects to a unix socket in the unit's runtime +//! directory, writes `reload`, and prints whatever comes back. The reply is a single line: +//! +//! ```text +//! accepted +//! refused +//! ``` +//! +//! The client's exit status is taken from the first word, so a refused reload fails the +//! `systemctl reload` that asked for it instead of being a line in a log nobody reads. The +//! refusal text is the gate's own, which names the component and both layouts. +//! +//! Nothing in the request says *what* to load. The module path and the revision file are +//! fixed at startup, so `ExecReload` is a constant string and a deploy that changes the +//! rules dylib does not change the unit's `[Service]` section -- which is exactly the +//! condition under which systemd reloads rather than restarts. +//! +//! # The revision comes from a file, never from the environment +//! +//! A process's environment is fixed at `exec` time. Reading the build revision from +//! `HYPERION_BUILD_REV` would therefore report the build the process *started* as, forever, +//! and a hot reload's entire point is that the process does not restart. The revision is +//! read from a path on every accepted reload, so it says what was just loaded. +//! +//! A missing or unreadable file is reported as an unknown revision rather than as a failed +//! reload. The revision is a label for humans; refusing to load working code because a +//! label is missing would be the wrong trade. + +use std::{ + io::{ErrorKind, Read, Write}, + os::unix::net::{UnixListener, UnixStream}, + path::{Path, PathBuf}, + time::Duration, +}; + +use flecs_ecs::core::World; + +use crate::host::{Applied, HotReloader, LoadError}; + +/// How long a connected client is given to send its verb. +/// +/// This is a stall in the tick loop, so it is bounded and short. A reload happens on +/// deploy and at no other time, and one hitch of this length on a deploy is invisible +/// beside the alternative, which is every player being disconnected by a restart. The +/// timeout exists so that a client which connects and then says nothing -- a health probe, +/// a stray `nc`, a killed `systemctl` -- cannot stop the world. +const VERB_TIMEOUT: Duration = Duration::from_millis(100); + +/// The only thing a client may ask for. +const VERB: &str = "reload"; + +/// What answering one request did. +/// +/// Returned rather than logged, because this crate cannot depend on `tracing`: it and +/// `hyperion` are sibling dylibs and every rlib they both use is a duplicate the final +/// link refuses. See `Cargo.toml`. The host writes these wherever it writes things, at +/// whatever severity it thinks a failed deploy deserves. +#[derive(Debug, Clone)] +pub enum Outcome { + /// A new build is live in the world. + Applied(Reloaded), + /// Nothing changed, and this is why -- the same words the client was told, so an + /// operator reading `systemctl reload` and an operator reading the journal see one + /// message rather than two accounts of one event. + Refused(String), +} + +/// A reload that was applied to the running world. +#[derive(Debug, Clone)] +pub struct Reloaded { + /// The module name the dylib declared. + pub module: String, + /// What the revision file said just after the load, or `None` when nothing said. + pub revision: Option, + /// How many stored component instances a migration rewrote. + pub migrated_instances: usize, +} + +/// Where a host listens, what it loads, and what it calls the result. +pub struct ReloadService { + listener: UnixListener, + module: PathBuf, + revision: PathBuf, + reloader: HotReloader, +} + +impl ReloadService { + /// Binds the request socket and prepares to load `module`. + /// + /// A socket file left behind by a previous process is removed first: `bind` fails with + /// `AddrInUse` on an existing path whether or not anything is listening, and a server + /// that refuses to start because its predecessor was killed is a worse failure than a + /// stale socket. + /// + /// # Errors + /// Returns whatever binding the socket failed with. + pub fn bind(socket: &Path, module: PathBuf, revision: PathBuf) -> std::io::Result { + match std::fs::remove_file(socket) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + let listener = UnixListener::bind(socket)?; + // The tick loop polls; it must never wait for a client that may never arrive. + listener.set_nonblocking(true)?; + Ok(Self { + listener, + module, + revision, + reloader: HotReloader::new(), + }) + } + + /// Loads the module for the first time, before any tick has run. + /// + /// # Errors + /// Returns whatever the loader refused with. + pub fn load_initial(&mut self, world: &World) -> Result { + self.reloader.load(world, &self.module) + } + + /// The module and component tree currently live. + #[must_use] + pub fn manifest(&self) -> crate::manifest::Manifest { + self.reloader.manifest() + } + + /// What the revision file says right now. + fn revision(&self) -> Option { + let text = std::fs::read_to_string(&self.revision).ok()?; + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + } + + /// Answers every request waiting on the socket, applying each to `world`. + /// + /// One entry per request answered, refusals included: a refused reload means the + /// deploy did not take, and something has to say so at a severity an operator queries. + pub fn poll(&mut self, world: &World) -> Vec { + let mut answered = Vec::new(); + loop { + let stream = match self.listener.accept() { + Ok((stream, _)) => stream, + // Nothing waiting: the normal exit from this loop, once per tick. + Err(e) if e.kind() == ErrorKind::WouldBlock => return answered, + // Nobody is waiting on a reply this can be written to, so it goes back + // with the rest -- a reload was asked for and did not happen. + Err(e) => { + answered.push(Outcome::Refused(format!( + "could not accept a reload request: {e}" + ))); + return answered; + } + }; + answered.push(self.serve(world, stream)); + } + } + + /// One request, start to finish. + fn serve(&mut self, world: &World, mut stream: UnixStream) -> Outcome { + let outcome = match read_verb(&mut stream) { + Ok(verb) if verb == VERB => match self.reloader.load(world, &self.module) { + Ok(applied) => Outcome::Applied(Reloaded { + module: applied.module, + revision: self.revision(), + migrated_instances: applied.migrated_instances, + }), + Err(e) => Outcome::Refused(e.to_string()), + }, + Ok(verb) => Outcome::Refused(format!("unknown request `{verb}`")), + Err(e) => Outcome::Refused(format!("could not read request: {e}")), + }; + + // The reply is one line and its first word is the client's exit status. + let line = match &outcome { + Outcome::Applied(reloaded) => format!( + "accepted {} {}", + reloaded.module, + reloaded.revision.as_deref().unwrap_or("unknown") + ), + Outcome::Refused(reason) => format!("refused {reason}"), + }; + respond(&mut stream, &line); + outcome + } +} + +/// Writes one line back. +/// +/// A client that hung up before reading is not a reload failure -- the reload already +/// happened, and the world does not care whether anybody read the receipt -- so the error +/// is dropped rather than propagated or reported. This is the one thing in here that is +/// deliberately silent. +fn respond(stream: &mut UnixStream, line: &str) { + drop(writeln!(stream, "{line}").and_then(|()| stream.flush())); +} + +/// Reads the client's verb, giving up after [`VERB_TIMEOUT`]. +fn read_verb(stream: &mut UnixStream) -> std::io::Result { + // The listener is non-blocking and an accepted stream inherits that on some platforms, + // which would turn every read into `WouldBlock`. Set both explicitly. + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(VERB_TIMEOUT))?; + let mut buffer = [0u8; 64]; + let read = stream.read(&mut buffer)?; + let text = String::from_utf8_lossy(&buffer[..read]); + Ok(text.trim().to_owned()) +} + +/// Ticks the world until it stops, answering reload requests between frames. +/// +/// `on_reload` is the seam an event uses to tell its players -- and its journal -- what +/// just happened. It is called once per request answered, refusals included, with a world +/// that is between frames and safe to write to. It is deliberately a callback on the loop rather than a flecs observer, because a +/// reload is not a world event -- it is the thing that just replaced every observer. +/// +/// `service` is optional because a server started without a rules dylib -- a developer's +/// `nix run`, an end-to-end gate -- still needs a tick loop, and it must be *this* loop. +/// Two loops, one that can reload and one that cannot, is two things that have to agree +/// about how a frame is run, and the one nobody deploys is the one that would drift. +pub fn run(world: &World, mut service: Option<&mut ReloadService>, mut on_reload: F) +where + F: FnMut(&World, &Outcome), +{ + while world.progress() { + if let Some(service) = service.as_deref_mut() { + for outcome in service.poll(world) { + on_reload(world, &outcome); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead, BufReader}; + + use super::*; + + /// A socket, module and revision path under one temporary directory. + struct Fixture { + dir: PathBuf, + } + + impl Fixture { + fn new(name: &str) -> Self { + let dir = std::env::temp_dir().join(format!("hyperion-reload-test-{name}")); + drop(std::fs::remove_dir_all(&dir)); + std::fs::create_dir_all(&dir).expect("temp dir"); + Self { dir } + } + + fn socket(&self) -> PathBuf { + self.dir.join("reload.sock") + } + + fn revision_path(&self) -> PathBuf { + self.dir.join("build-rev") + } + + /// A module path that exists nowhere, so `dlopen` refuses and the world is + /// untouched -- which is what every protocol test wants. + fn absent_module(&self) -> PathBuf { + self.dir.join("no-such-module.so") + } + + fn service(&self) -> ReloadService { + ReloadService::bind(&self.socket(), self.absent_module(), self.revision_path()) + .expect("bind") + } + + /// Sends `verb` (or nothing at all), lets the service answer, and returns the + /// single line it wrote. + fn ask(&self, service: &mut ReloadService, world: &World, verb: Option<&[u8]>) -> String { + let mut client = UnixStream::connect(self.socket()).expect("connect"); + if let Some(verb) = verb { + client.write_all(verb).expect("write"); + client.flush().expect("flush"); + } + service.poll(world); + let mut line = String::new(); + BufReader::new(client) + .read_line(&mut line) + .expect("read reply"); + line.trim_end().to_owned() + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + drop(std::fs::remove_dir_all(&self.dir)); + } + } + + /// A killed predecessor leaves its socket file behind, and `bind` fails on an existing + /// path whether or not anything is listening. Refusing to start for that reason would + /// turn one crash into a server that never comes back. + #[test] + fn a_socket_left_by_a_dead_predecessor_does_not_stop_a_bind() { + let fixture = Fixture::new("stale-socket"); + std::fs::write(fixture.socket(), b"not really a socket").expect("write stale file"); + drop(fixture.service()); + } + + /// The world must be untouched by anything that is not the one verb, because a stray + /// connection is not a deploy. + #[test] + fn an_unrecognised_request_is_refused_without_loading_anything() { + let fixture = Fixture::new("unknown-verb"); + let world = World::new(); + let mut service = fixture.service(); + let reply = fixture.ask(&mut service, &world, Some(b"explode")); + assert_eq!(reply, "refused unknown request `explode`"); + } + + /// A client that connects and then says nothing must not stop the world. Without the + /// read timeout this test hangs forever rather than failing. + #[test] + fn a_client_that_says_nothing_is_refused_rather_than_stopping_the_world() { + let fixture = Fixture::new("silent-client"); + let world = World::new(); + let mut service = fixture.service(); + let reply = fixture.ask(&mut service, &world, None); + assert!( + reply.starts_with("refused could not read request"), + "expected a read refusal, got `{reply}`" + ); + } + + /// The refusal carries the loader's own words. An operator reading `systemctl reload` + /// output needs the reason, not a generic failure. + /// + /// A module that is not there fails while being staged rather than at `dlopen` -- see + /// `host::stage`, which copies every candidate to a name `dlopen` has not seen -- so + /// the words are the read's, and the path is in them. + #[test] + fn a_refused_load_answers_with_the_loaders_reason() { + let fixture = Fixture::new("bad-module"); + let world = World::new(); + let mut service = fixture.service(); + let reply = fixture.ask(&mut service, &world, Some(b"reload")); + assert!( + reply.starts_with("refused could not read the module at "), + "expected the staging refusal, got `{reply}`" + ); + assert!( + reply.contains("no-such-module.so"), + "the refusal does not name the module: `{reply}`" + ); + } + + /// Nothing is waiting on the socket for all but a handful of ticks in a process's life, + /// so the empty poll is the only path that has to be free of surprises. + #[test] + fn polling_an_idle_socket_reports_nothing_and_returns() { + let fixture = Fixture::new("idle"); + let world = World::new(); + let mut service = fixture.service(); + assert!(service.poll(&world).is_empty()); + assert!(service.poll(&world).is_empty()); + } + + /// A label for humans, so a missing file is "unknown" and never a failed reload. + #[test] + fn a_missing_revision_file_reads_as_unknown() { + let fixture = Fixture::new("revision-missing"); + assert_eq!(fixture.service().revision(), None); + } + + /// `environment.etc` writes the file with a trailing newline, and a title reading + /// "Reloading to build abc123\n" is a rendering bug nobody would look for here. + #[test] + fn a_revision_is_trimmed_and_an_empty_one_is_unknown() { + let fixture = Fixture::new("revision-trim"); + std::fs::write(fixture.revision_path(), b" abc1234\n").expect("write revision"); + assert_eq!(fixture.service().revision().as_deref(), Some("abc1234")); + + std::fs::write(fixture.revision_path(), b" \n").expect("write blank revision"); + assert_eq!(fixture.service().revision(), None); + } +} diff --git a/crates/hyperion-hot-reload/tests/load_errors.rs b/crates/hyperion-hot-reload/tests/load_errors.rs index 84374a99e..abc46ecf2 100644 --- a/crates/hyperion-hot-reload/tests/load_errors.rs +++ b/crates/hyperion-hot-reload/tests/load_errors.rs @@ -21,9 +21,11 @@ fn a_missing_dylib_names_the_path_the_loader_tried() { .load(&world, path) .expect_err("loading a path that does not exist must fail"); + // Staging, not `dlopen`: every candidate is copied to a name `dlopen` has never + // been given (see `host::stage`), so a path that is not there fails on the read. assert!( - matches!(error, LoadError::Dlopen(_)), - "expected a dlopen failure, got {error:?}" + matches!(error, LoadError::Stage { .. }), + "expected a staging failure, got {error:?}" ); let message = error.to_string(); diff --git a/crates/hyperion-minecraft-proto/src/collision_shape.rs b/crates/hyperion-minecraft-proto/src/collision_shape.rs new file mode 100644 index 000000000..3ab8f1d49 --- /dev/null +++ b/crates/hyperion-minecraft-proto/src/collision_shape.rs @@ -0,0 +1,2455 @@ +// @generated by nix/generate-collision-shapes.py from Minecraft 26.2 (protocol 776). +// Do not edit by hand. Regenerate with: nix run .#sync-minecraft-collision-shapes + +//! Block collision shapes for Minecraft 26.2. +//! +//! What an entity stops against. A block state's shape is a list of +//! axis-aligned boxes in the block's own coordinates, where a full cube is +//! `[0, 0, 0, 1, 1, 1]` and a bottom slab is `[0, 0, 0, 1, 0.5, 1]`; a state +//! with no boxes at all -- air, a torch, tall grass -- is passed through. +//! +//! The table is read out of the server jar rather than out of Mojang's data +//! generator, which describes a state as an id and a property map and stops +//! there. A collision shape is a `VoxelShape` constant compiled into each block +//! class, so the only thing that can answer is the running game: +//! `nix/java/VanillaShapes.java` calls `getCollisionShape` on all 32366 of +//! them, and this file is what it said. +//! +//! # 326 shapes for 32366 states +//! +//! Distinct box lists are stored once in [`SHAPES`] and [`STATE_SHAPES`] gives +//! each state's index into it, because the states repeat themselves heavily: +//! 3287 of them are the same unit cube and 5430 have no boxes at all. So a +//! lookup is two loads and no search, and the table holds 716 boxes rather than +//! 59825. +//! +//! # Precision +//! +//! The game computes these as `double`s, and every value it produces is a +//! multiple of 1/32, so all 26 of them are exactly representable in `f32` and +//! this table loses nothing by storing them that way. The generator re-checks +//! that on every run, so a version introducing a coordinate that needs more +//! precision fails the build rather than rounding it silently. + +/// One collision box, in the block's own coordinates: +/// `[min_x, min_y, min_z, max_x, max_y, max_z]`. +/// +/// A few shapes reach outside the unit cube -- a wall's post is 1.5 high and a +/// big dripleaf's stem starts at -0.25 -- so a consumer that assumes `0..=1` +/// is assuming something the game does not. +pub type CollisionBox = [f32; 6]; + +/// The collision boxes of the block state with network id `state_id`. +/// +/// `None` for an id this version does not have. For a caller holding an id +/// from [`crate::block_state`] that cannot happen, and would mean the two +/// tables came from different jars. +#[must_use] +pub fn collision_shape(state_id: u32) -> Option<&'static [CollisionBox]> { + let index = usize::try_from(state_id).ok()?; + let shape = *STATE_SHAPES.get(index)?; + Some(SHAPES[usize::from(shape)]) +} + +// The two tables describe one registry read out of one jar, so a disagreement +// about how many states it has means one of them was regenerated and the other +// was not. +const _: () = assert!( + STATE_SHAPES.len() == crate::block_state::STATE_COUNT as usize, + "the collision shape table and the block state table disagree about how many states exist" +); + +/// Every distinct box list, referenced by index from [`STATE_SHAPES`]. +pub static SHAPES: &[&[CollisionBox]] = &[ + &[], + &[[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]], + &[ + [0.0, 0.0, 0.0, 0.1875, 0.5625, 0.1875], + [0.8125, 0.0, 0.0, 1.0, 0.5625, 0.1875], + [0.0, 0.1875, 0.1875, 1.0, 0.5625, 1.0], + [0.1875, 0.1875, 0.0, 0.8125, 0.5625, 0.1875], + ], + &[ + [0.0, 0.0, 0.8125, 0.1875, 0.5625, 1.0], + [0.8125, 0.0, 0.8125, 1.0, 0.5625, 1.0], + [0.0, 0.1875, 0.0, 1.0, 0.5625, 0.8125], + [0.1875, 0.1875, 0.8125, 0.8125, 0.5625, 1.0], + ], + &[ + [0.0, 0.0, 0.0, 0.1875, 0.5625, 0.1875], + [0.0, 0.0, 0.8125, 0.1875, 0.5625, 1.0], + [0.0, 0.1875, 0.1875, 1.0, 0.5625, 0.8125], + [0.1875, 0.1875, 0.0, 1.0, 0.5625, 0.1875], + [0.1875, 0.1875, 0.8125, 1.0, 0.5625, 1.0], + ], + &[ + [0.8125, 0.0, 0.0, 1.0, 0.5625, 0.1875], + [0.8125, 0.0, 0.8125, 1.0, 0.5625, 1.0], + [0.0, 0.1875, 0.0, 0.8125, 0.5625, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.5625, 0.8125], + ], + &[[0.0, 0.0, 0.25, 1.0, 1.0, 1.0]], + &[[0.0, 0.0, 0.0, 0.75, 1.0, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 1.0, 0.75]], + &[[0.25, 0.0, 0.0, 1.0, 1.0, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 0.75, 1.0]], + &[[0.0, 0.25, 0.0, 1.0, 1.0, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 1.0, 0.25], [ + 0.375, 0.375, 0.25, 0.625, 0.625, 1.0, + ]], + &[[0.0, 0.0, 0.0, 1.0, 1.0, 0.25], [ + 0.375, 0.375, 0.25, 0.625, 0.625, 1.25, + ]], + &[[0.75, 0.0, 0.0, 1.0, 1.0, 1.0], [ + 0.0, 0.375, 0.375, 0.75, 0.625, 0.625, + ]], + &[[0.75, 0.0, 0.0, 1.0, 1.0, 1.0], [ + -0.25, 0.375, 0.375, 0.75, 0.625, 0.625, + ]], + &[[0.0, 0.0, 0.75, 1.0, 1.0, 1.0], [ + 0.375, 0.375, 0.0, 0.625, 0.625, 0.75, + ]], + &[[0.0, 0.0, 0.75, 1.0, 1.0, 1.0], [ + 0.375, 0.375, -0.25, 0.625, 0.625, 0.75, + ]], + &[[0.0, 0.0, 0.0, 0.25, 1.0, 1.0], [ + 0.25, 0.375, 0.375, 1.0, 0.625, 0.625, + ]], + &[[0.0, 0.0, 0.0, 0.25, 1.0, 1.0], [ + 0.25, 0.375, 0.375, 1.25, 0.625, 0.625, + ]], + &[ + [0.375, 0.0, 0.375, 0.625, 1.0, 0.625], + [0.0, 0.75, 0.0, 0.375, 1.0, 1.0], + [0.375, 0.75, 0.0, 1.0, 1.0, 0.375], + [0.375, 0.75, 0.625, 1.0, 1.0, 1.0], + [0.625, 0.75, 0.375, 1.0, 1.0, 0.625], + ], + &[ + [0.375, -0.25, 0.375, 0.625, 1.0, 0.625], + [0.0, 0.75, 0.0, 0.375, 1.0, 1.0], + [0.375, 0.75, 0.0, 1.0, 1.0, 0.375], + [0.375, 0.75, 0.625, 1.0, 1.0, 1.0], + [0.625, 0.75, 0.375, 1.0, 1.0, 0.625], + ], + &[[0.0, 0.0, 0.0, 1.0, 0.25, 1.0], [ + 0.375, 0.25, 0.375, 0.625, 1.0, 0.625, + ]], + &[[0.0, 0.0, 0.0, 1.0, 0.25, 1.0], [ + 0.375, 0.25, 0.375, 0.625, 1.25, 0.625, + ]], + &[ + [0.0, 0.0, 0.6875, 1.0, 0.25, 1.0], + [0.0, 0.25, 0.8125, 1.0, 1.0, 1.0], + [0.0, 0.75, 0.6875, 1.0, 1.0, 0.8125], + ], + &[ + [0.0, 0.0, 0.0, 1.0, 0.25, 0.3125], + [0.0, 0.25, 0.0, 1.0, 1.0, 0.1875], + [0.0, 0.75, 0.1875, 1.0, 1.0, 0.3125], + ], + &[ + [0.6875, 0.0, 0.0, 1.0, 0.25, 1.0], + [0.8125, 0.25, 0.0, 1.0, 1.0, 1.0], + [0.6875, 0.75, 0.0, 0.8125, 1.0, 1.0], + ], + &[ + [0.0, 0.0, 0.0, 0.3125, 0.25, 1.0], + [0.0, 0.25, 0.0, 0.1875, 1.0, 1.0], + [0.1875, 0.75, 0.0, 0.3125, 1.0, 1.0], + ], + &[[0.0, 0.0, 0.0, 1.0, 1.0, 0.5], [ + 0.0, 0.5, 0.5, 1.0, 1.0, 1.0, + ]], + &[ + [0.0, 0.0, 0.0, 0.5, 1.0, 1.0], + [0.5, 0.0, 0.0, 1.0, 1.0, 0.5], + [0.5, 0.5, 0.5, 1.0, 1.0, 1.0], + ], + &[ + [0.0, 0.0, 0.0, 1.0, 1.0, 0.5], + [0.5, 0.0, 0.5, 1.0, 1.0, 1.0], + [0.0, 0.5, 0.5, 0.5, 1.0, 1.0], + ], + &[ + [0.0, 0.0, 0.0, 0.5, 1.0, 0.5], + [0.0, 0.5, 0.5, 1.0, 1.0, 1.0], + [0.5, 0.5, 0.0, 1.0, 1.0, 0.5], + ], + &[ + [0.5, 0.0, 0.0, 1.0, 1.0, 0.5], + [0.0, 0.5, 0.0, 0.5, 1.0, 1.0], + [0.5, 0.5, 0.5, 1.0, 1.0, 1.0], + ], + &[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0], [ + 0.0, 0.5, 0.0, 1.0, 1.0, 0.5, + ]], + &[ + [0.0, 0.0, 0.0, 1.0, 0.5, 1.0], + [0.0, 0.5, 0.0, 0.5, 1.0, 1.0], + [0.5, 0.5, 0.0, 1.0, 1.0, 0.5], + ], + &[ + [0.0, 0.0, 0.0, 1.0, 0.5, 1.0], + [0.0, 0.5, 0.0, 1.0, 1.0, 0.5], + [0.5, 0.5, 0.5, 1.0, 1.0, 1.0], + ], + &[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0], [ + 0.0, 0.5, 0.0, 0.5, 1.0, 0.5, + ]], + &[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0], [ + 0.5, 0.5, 0.0, 1.0, 1.0, 0.5, + ]], + &[[0.0, 0.0, 0.5, 1.0, 1.0, 1.0], [ + 0.0, 0.5, 0.0, 1.0, 1.0, 0.5, + ]], + &[ + [0.0, 0.0, 0.5, 1.0, 1.0, 1.0], + [0.5, 0.0, 0.0, 1.0, 1.0, 0.5], + [0.0, 0.5, 0.0, 0.5, 1.0, 0.5], + ], + &[ + [0.0, 0.0, 0.0, 0.5, 1.0, 1.0], + [0.5, 0.0, 0.5, 1.0, 1.0, 1.0], + [0.5, 0.5, 0.0, 1.0, 1.0, 0.5], + ], + &[ + [0.5, 0.0, 0.5, 1.0, 1.0, 1.0], + [0.0, 0.5, 0.0, 0.5, 1.0, 1.0], + [0.5, 0.5, 0.0, 1.0, 1.0, 0.5], + ], + &[ + [0.0, 0.0, 0.5, 0.5, 1.0, 1.0], + [0.0, 0.5, 0.0, 1.0, 1.0, 0.5], + [0.5, 0.5, 0.5, 1.0, 1.0, 1.0], + ], + &[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0], [ + 0.0, 0.5, 0.5, 1.0, 1.0, 1.0, + ]], + &[ + [0.0, 0.0, 0.0, 1.0, 0.5, 1.0], + [0.0, 0.5, 0.5, 1.0, 1.0, 1.0], + [0.5, 0.5, 0.0, 1.0, 1.0, 0.5], + ], + &[ + [0.0, 0.0, 0.0, 1.0, 0.5, 1.0], + [0.0, 0.5, 0.0, 0.5, 1.0, 1.0], + [0.5, 0.5, 0.5, 1.0, 1.0, 1.0], + ], + &[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0], [ + 0.5, 0.5, 0.5, 1.0, 1.0, 1.0, + ]], + &[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0], [ + 0.0, 0.5, 0.5, 0.5, 1.0, 1.0, + ]], + &[[0.0, 0.0, 0.0, 0.5, 1.0, 1.0], [ + 0.5, 0.5, 0.0, 1.0, 1.0, 1.0, + ]], + &[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0], [ + 0.0, 0.5, 0.0, 0.5, 1.0, 1.0, + ]], + &[[0.5, 0.0, 0.0, 1.0, 1.0, 1.0], [ + 0.0, 0.5, 0.0, 0.5, 1.0, 1.0, + ]], + &[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0], [ + 0.5, 0.5, 0.0, 1.0, 1.0, 1.0, + ]], + &[[0.0625, 0.0, 0.0625, 0.9375, 0.875, 0.9375]], + &[[0.0625, 0.0, 0.0625, 1.0, 0.875, 0.9375]], + &[[0.0, 0.0, 0.0625, 0.9375, 0.875, 0.9375]], + &[[0.0625, 0.0, 0.0, 0.9375, 0.875, 0.9375]], + &[[0.0625, 0.0, 0.0625, 0.9375, 0.875, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 0.9375, 1.0]], + &[[0.0, 0.0, 0.0, 0.1875, 1.0, 1.0]], + &[[0.0, 0.0, 0.8125, 1.0, 1.0, 1.0]], + &[[0.8125, 0.0, 0.0, 1.0, 1.0, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 1.0, 0.1875]], + &[[0.0, 0.875, 0.375, 1.0, 1.0, 0.625]], + &[[0.375, 0.875, 0.0, 0.625, 1.0, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 0.125, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 0.25, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 0.375, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 0.625, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 0.875, 1.0]], + &[[0.0625, 0.0, 0.0625, 0.9375, 0.9375, 0.9375]], + &[ + [0.0, 0.0, 0.375, 1.0, 1.5, 0.625], + [0.375, 0.0, 0.0, 0.625, 1.5, 0.375], + [0.375, 0.0, 0.625, 0.625, 1.5, 1.0], + ], + &[[0.375, 0.0, 0.0, 0.625, 1.5, 1.0], [ + 0.625, 0.0, 0.375, 1.0, 1.5, 0.625, + ]], + &[[0.0, 0.0, 0.375, 1.0, 1.5, 0.625], [ + 0.375, 0.0, 0.0, 0.625, 1.5, 0.375, + ]], + &[[0.375, 0.0, 0.0, 0.625, 1.5, 0.625], [ + 0.625, 0.0, 0.375, 1.0, 1.5, 0.625, + ]], + &[[0.0, 0.0, 0.375, 1.0, 1.5, 0.625], [ + 0.375, 0.0, 0.625, 0.625, 1.5, 1.0, + ]], + &[[0.375, 0.0, 0.375, 0.625, 1.5, 1.0], [ + 0.625, 0.0, 0.375, 1.0, 1.5, 0.625, + ]], + &[[0.0, 0.0, 0.375, 1.0, 1.5, 0.625]], + &[[0.375, 0.0, 0.375, 1.0, 1.5, 0.625]], + &[ + [0.0, 0.0, 0.375, 0.625, 1.5, 0.625], + [0.375, 0.0, 0.0, 0.625, 1.5, 0.375], + [0.375, 0.0, 0.625, 0.625, 1.5, 1.0], + ], + &[[0.375, 0.0, 0.0, 0.625, 1.5, 1.0]], + &[[0.0, 0.0, 0.375, 0.625, 1.5, 0.625], [ + 0.375, 0.0, 0.0, 0.625, 1.5, 0.375, + ]], + &[[0.375, 0.0, 0.0, 0.625, 1.5, 0.625]], + &[[0.0, 0.0, 0.375, 0.625, 1.5, 0.625], [ + 0.375, 0.0, 0.625, 0.625, 1.5, 1.0, + ]], + &[[0.375, 0.0, 0.375, 0.625, 1.5, 1.0]], + &[[0.0, 0.0, 0.375, 0.625, 1.5, 0.625]], + &[[0.375, 0.0, 0.375, 0.625, 1.5, 0.625]], + &[[0.0625, 0.0, 0.0625, 0.9375, 0.5, 0.9375]], + &[[0.1875, 0.0, 0.0625, 0.9375, 0.5, 0.9375]], + &[[0.3125, 0.0, 0.0625, 0.9375, 0.5, 0.9375]], + &[[0.4375, 0.0, 0.0625, 0.9375, 0.5, 0.9375]], + &[[0.5625, 0.0, 0.0625, 0.9375, 0.5, 0.9375]], + &[[0.6875, 0.0, 0.0625, 0.9375, 0.5, 0.9375]], + &[[0.8125, 0.0, 0.0625, 0.9375, 0.5, 0.9375]], + &[[0.0, 0.8125, 0.0, 1.0, 1.0, 1.0]], + &[[0.0, 0.0, 0.0, 1.0, 0.1875, 1.0]], + &[ + [0.0, 0.0, 0.4375, 1.0, 1.0, 0.5625], + [0.4375, 0.0, 0.0, 0.5625, 1.0, 0.4375], + [0.4375, 0.0, 0.5625, 0.5625, 1.0, 1.0], + ], + &[[0.4375, 0.0, 0.0, 0.5625, 1.0, 1.0], [ + 0.5625, 0.0, 0.4375, 1.0, 1.0, 0.5625, + ]], + &[[0.0, 0.0, 0.4375, 1.0, 1.0, 0.5625], [ + 0.4375, 0.0, 0.0, 0.5625, 1.0, 0.4375, + ]], + &[[0.4375, 0.0, 0.0, 0.5625, 1.0, 0.5625], [ + 0.5625, 0.0, 0.4375, 1.0, 1.0, 0.5625, + ]], + &[[0.0, 0.0, 0.4375, 1.0, 1.0, 0.5625], [ + 0.4375, 0.0, 0.5625, 0.5625, 1.0, 1.0, + ]], + &[[0.4375, 0.0, 0.4375, 0.5625, 1.0, 1.0], [ + 0.5625, 0.0, 0.4375, 1.0, 1.0, 0.5625, + ]], + &[[0.0, 0.0, 0.4375, 1.0, 1.0, 0.5625]], + &[[0.4375, 0.0, 0.4375, 1.0, 1.0, 0.5625]], + &[ + [0.0, 0.0, 0.4375, 0.5625, 1.0, 0.5625], + [0.4375, 0.0, 0.0, 0.5625, 1.0, 0.4375], + [0.4375, 0.0, 0.5625, 0.5625, 1.0, 1.0], + ], + &[[0.4375, 0.0, 0.0, 0.5625, 1.0, 1.0]], + &[[0.0, 0.0, 0.4375, 0.5625, 1.0, 0.5625], [ + 0.4375, 0.0, 0.0, 0.5625, 1.0, 0.4375, + ]], + &[[0.4375, 0.0, 0.0, 0.5625, 1.0, 0.5625]], + &[[0.0, 0.0, 0.4375, 0.5625, 1.0, 0.5625], [ + 0.4375, 0.0, 0.5625, 0.5625, 1.0, 1.0, + ]], + &[[0.4375, 0.0, 0.4375, 0.5625, 1.0, 1.0]], + &[[0.0, 0.0, 0.4375, 0.5625, 1.0, 0.5625]], + &[[0.4375, 0.0, 0.4375, 0.5625, 1.0, 0.5625]], + &[[0.0, 0.40625, 0.40625, 1.0, 0.59375, 0.59375]], + &[[0.40625, 0.0, 0.40625, 0.59375, 1.0, 0.59375]], + &[[0.40625, 0.40625, 0.0, 0.59375, 0.59375, 1.0]], + &[[0.0625, 0.0, 0.0625, 0.9375, 0.09375, 0.9375]], + &[[0.0, 0.5, 0.0, 1.0, 1.0, 1.0]], + &[[0.25, 0.0, 0.25, 0.75, 1.5, 0.75]], + &[ + [0.0, 0.0, 0.3125, 0.75, 1.5, 0.6875], + [0.25, 0.0, 0.25, 0.75, 1.5, 0.3125], + [0.25, 0.0, 0.6875, 0.75, 1.5, 0.75], + ], + &[[0.0, 0.0, 0.3125, 0.6875, 1.5, 0.6875]], + &[[0.25, 0.0, 0.25, 0.75, 1.5, 0.75], [ + 0.3125, 0.0, 0.75, 0.6875, 1.5, 1.0, + ]], + &[ + [0.0, 0.0, 0.3125, 0.75, 1.5, 0.6875], + [0.25, 0.0, 0.25, 0.75, 1.5, 0.3125], + [0.25, 0.0, 0.6875, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.75, 0.6875, 1.5, 1.0], + ], + &[[0.3125, 0.0, 0.3125, 0.6875, 1.5, 1.0]], + &[[0.0, 0.0, 0.3125, 0.6875, 1.5, 0.6875], [ + 0.3125, 0.0, 0.6875, 0.6875, 1.5, 1.0, + ]], + &[[0.25, 0.0, 0.25, 0.75, 1.5, 0.75], [ + 0.3125, 0.0, 0.0, 0.6875, 1.5, 0.25, + ]], + &[ + [0.0, 0.0, 0.3125, 0.75, 1.5, 0.6875], + [0.25, 0.0, 0.25, 0.75, 1.5, 0.3125], + [0.25, 0.0, 0.6875, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.0, 0.6875, 1.5, 0.25], + ], + &[[0.3125, 0.0, 0.0, 0.6875, 1.5, 0.6875]], + &[[0.0, 0.0, 0.3125, 0.6875, 1.5, 0.6875], [ + 0.3125, 0.0, 0.0, 0.6875, 1.5, 0.3125, + ]], + &[ + [0.25, 0.0, 0.25, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.0, 0.6875, 1.5, 0.25], + [0.3125, 0.0, 0.75, 0.6875, 1.5, 1.0], + ], + &[ + [0.0, 0.0, 0.3125, 0.75, 1.5, 0.6875], + [0.25, 0.0, 0.25, 0.75, 1.5, 0.3125], + [0.25, 0.0, 0.6875, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.0, 0.6875, 1.5, 0.25], + [0.3125, 0.0, 0.75, 0.6875, 1.5, 1.0], + ], + &[[0.3125, 0.0, 0.0, 0.6875, 1.5, 1.0]], + &[ + [0.0, 0.0, 0.3125, 0.6875, 1.5, 0.6875], + [0.3125, 0.0, 0.0, 0.6875, 1.5, 0.3125], + [0.3125, 0.0, 0.6875, 0.6875, 1.5, 1.0], + ], + &[[0.25, 0.0, 0.25, 0.75, 1.5, 0.75], [ + 0.75, 0.0, 0.3125, 1.0, 1.5, 0.6875, + ]], + &[ + [0.0, 0.0, 0.3125, 1.0, 1.5, 0.6875], + [0.25, 0.0, 0.25, 0.75, 1.5, 0.3125], + [0.25, 0.0, 0.6875, 0.75, 1.5, 0.75], + ], + &[[0.3125, 0.0, 0.3125, 1.0, 1.5, 0.6875]], + &[[0.0, 0.0, 0.3125, 1.0, 1.5, 0.6875]], + &[ + [0.25, 0.0, 0.25, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.75, 0.6875, 1.5, 1.0], + [0.75, 0.0, 0.3125, 1.0, 1.5, 0.6875], + ], + &[ + [0.0, 0.0, 0.3125, 1.0, 1.5, 0.6875], + [0.25, 0.0, 0.25, 0.75, 1.5, 0.3125], + [0.25, 0.0, 0.6875, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.75, 0.6875, 1.5, 1.0], + ], + &[[0.3125, 0.0, 0.3125, 0.6875, 1.5, 1.0], [ + 0.6875, 0.0, 0.3125, 1.0, 1.5, 0.6875, + ]], + &[[0.0, 0.0, 0.3125, 1.0, 1.5, 0.6875], [ + 0.3125, 0.0, 0.6875, 0.6875, 1.5, 1.0, + ]], + &[ + [0.25, 0.0, 0.25, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.0, 0.6875, 1.5, 0.25], + [0.75, 0.0, 0.3125, 1.0, 1.5, 0.6875], + ], + &[ + [0.0, 0.0, 0.3125, 1.0, 1.5, 0.6875], + [0.25, 0.0, 0.25, 0.75, 1.5, 0.3125], + [0.25, 0.0, 0.6875, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.0, 0.6875, 1.5, 0.25], + ], + &[[0.3125, 0.0, 0.0, 0.6875, 1.5, 0.6875], [ + 0.6875, 0.0, 0.3125, 1.0, 1.5, 0.6875, + ]], + &[[0.0, 0.0, 0.3125, 1.0, 1.5, 0.6875], [ + 0.3125, 0.0, 0.0, 0.6875, 1.5, 0.3125, + ]], + &[ + [0.25, 0.0, 0.25, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.0, 0.6875, 1.5, 0.25], + [0.3125, 0.0, 0.75, 0.6875, 1.5, 1.0], + [0.75, 0.0, 0.3125, 1.0, 1.5, 0.6875], + ], + &[ + [0.0, 0.0, 0.3125, 1.0, 1.5, 0.6875], + [0.25, 0.0, 0.25, 0.75, 1.5, 0.3125], + [0.25, 0.0, 0.6875, 0.75, 1.5, 0.75], + [0.3125, 0.0, 0.0, 0.6875, 1.5, 0.25], + [0.3125, 0.0, 0.75, 0.6875, 1.5, 1.0], + ], + &[[0.3125, 0.0, 0.0, 0.6875, 1.5, 1.0], [ + 0.6875, 0.0, 0.3125, 1.0, 1.5, 0.6875, + ]], + &[ + [0.0, 0.0, 0.3125, 1.0, 1.5, 0.6875], + [0.3125, 0.0, 0.0, 0.6875, 1.5, 0.3125], + [0.3125, 0.0, 0.6875, 0.6875, 1.5, 1.0], + ], + &[[0.0625, 0.0, 0.0625, 0.9375, 0.125, 0.9375], [ + 0.4375, 0.125, 0.4375, 0.5625, 0.875, 0.5625, + ]], + &[ + [0.0, 0.0, 0.0, 0.125, 1.0, 0.25], + [0.0, 0.0, 0.75, 0.125, 1.0, 1.0], + [0.125, 0.0, 0.0, 0.25, 1.0, 0.125], + [0.125, 0.0, 0.875, 0.25, 1.0, 1.0], + [0.75, 0.0, 0.0, 1.0, 1.0, 0.125], + [0.75, 0.0, 0.875, 1.0, 1.0, 1.0], + [0.875, 0.0, 0.125, 1.0, 1.0, 0.25], + [0.875, 0.0, 0.75, 1.0, 1.0, 0.875], + [0.0, 0.1875, 0.25, 1.0, 0.25, 0.75], + [0.125, 0.1875, 0.125, 0.875, 0.25, 0.25], + [0.125, 0.1875, 0.75, 0.875, 0.25, 0.875], + [0.25, 0.1875, 0.0, 0.75, 1.0, 0.125], + [0.25, 0.1875, 0.875, 0.75, 1.0, 1.0], + [0.0, 0.25, 0.25, 0.125, 1.0, 0.75], + [0.875, 0.25, 0.25, 1.0, 1.0, 0.75], + ], + &[[0.0, 0.0, 0.0, 1.0, 0.8125, 1.0], [ + 0.25, 0.8125, 0.25, 0.75, 1.0, 0.75, + ]], + &[[0.0, 0.0, 0.0, 1.0, 0.8125, 1.0]], + &[[0.0625, 0.0, 0.0625, 0.9375, 1.0, 0.9375]], + &[[0.375, 0.4375, 0.0625, 0.625, 0.75, 0.3125]], + &[[0.375, 0.4375, 0.6875, 0.625, 0.75, 0.9375]], + &[[0.0625, 0.4375, 0.375, 0.3125, 0.75, 0.625]], + &[[0.6875, 0.4375, 0.375, 0.9375, 0.75, 0.625]], + &[[0.3125, 0.3125, 0.0625, 0.6875, 0.75, 0.4375]], + &[[0.3125, 0.3125, 0.5625, 0.6875, 0.75, 0.9375]], + &[[0.0625, 0.3125, 0.3125, 0.4375, 0.75, 0.6875]], + &[[0.5625, 0.3125, 0.3125, 0.9375, 0.75, 0.6875]], + &[[0.25, 0.1875, 0.0625, 0.75, 0.75, 0.5625]], + &[[0.25, 0.1875, 0.4375, 0.75, 0.75, 0.9375]], + &[[0.0625, 0.1875, 0.25, 0.5625, 0.75, 0.75]], + &[[0.4375, 0.1875, 0.25, 0.9375, 0.75, 0.75]], + &[[0.3125, 0.0, 0.3125, 0.6875, 0.375, 0.6875]], + &[[0.25, 0.0, 0.25, 0.75, 0.5, 0.75]], + &[[0.25, 0.25, 0.5, 0.75, 0.75, 1.0]], + &[[0.25, 0.25, 0.0, 0.75, 0.75, 0.5]], + &[[0.5, 0.25, 0.25, 1.0, 0.75, 0.75]], + &[[0.0, 0.25, 0.25, 0.5, 0.75, 0.75]], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.5, 0.8125]], + &[[0.1875, 0.25, 0.5, 0.8125, 0.75, 1.0]], + &[[0.1875, 0.25, 0.0, 0.8125, 0.75, 0.5]], + &[[0.5, 0.25, 0.1875, 1.0, 0.75, 0.8125]], + &[[0.0, 0.25, 0.1875, 0.5, 0.75, 0.8125]], + &[ + [0.125, 0.0, 0.125, 0.875, 0.25, 0.875], + [0.25, 0.25, 0.1875, 0.75, 0.3125, 0.8125], + [0.375, 0.3125, 0.25, 0.625, 1.0, 0.75], + [0.1875, 0.625, 0.0, 0.375, 1.0, 1.0], + [0.375, 0.625, 0.0, 0.8125, 1.0, 0.25], + [0.375, 0.625, 0.75, 0.8125, 1.0, 1.0], + [0.625, 0.625, 0.25, 0.8125, 1.0, 0.75], + ], + &[ + [0.125, 0.0, 0.125, 0.875, 0.25, 0.875], + [0.1875, 0.25, 0.25, 0.8125, 0.3125, 0.75], + [0.25, 0.3125, 0.375, 0.75, 1.0, 0.625], + [0.0, 0.625, 0.1875, 0.25, 1.0, 0.8125], + [0.25, 0.625, 0.1875, 1.0, 1.0, 0.375], + [0.25, 0.625, 0.625, 1.0, 1.0, 0.8125], + [0.75, 0.625, 0.375, 1.0, 1.0, 0.625], + ], + &[ + [0.375, 0.0, 0.375, 0.625, 0.6875, 0.625], + [0.25, 0.25, 0.25, 0.375, 0.6875, 0.75], + [0.375, 0.25, 0.25, 0.75, 0.6875, 0.375], + [0.375, 0.25, 0.625, 0.75, 0.6875, 0.75], + [0.625, 0.25, 0.375, 0.75, 0.6875, 0.625], + [0.0, 0.625, 0.0, 0.25, 0.6875, 1.0], + [0.25, 0.625, 0.0, 1.0, 0.6875, 0.25], + [0.25, 0.625, 0.75, 1.0, 0.6875, 1.0], + [0.75, 0.625, 0.25, 1.0, 0.6875, 0.75], + [0.0, 0.6875, 0.0, 0.125, 1.0, 1.0], + [0.125, 0.6875, 0.0, 1.0, 1.0, 0.125], + [0.125, 0.6875, 0.875, 1.0, 1.0, 1.0], + [0.875, 0.6875, 0.125, 1.0, 1.0, 0.875], + ], + &[ + [0.25, 0.25, 0.25, 0.75, 0.6875, 0.75], + [0.375, 0.25, 0.0, 0.625, 0.5, 0.25], + [0.0, 0.625, 0.0, 0.25, 0.6875, 1.0], + [0.25, 0.625, 0.0, 1.0, 0.6875, 0.25], + [0.25, 0.625, 0.75, 1.0, 0.6875, 1.0], + [0.75, 0.625, 0.25, 1.0, 0.6875, 0.75], + [0.0, 0.6875, 0.0, 0.125, 1.0, 1.0], + [0.125, 0.6875, 0.0, 1.0, 1.0, 0.125], + [0.125, 0.6875, 0.875, 1.0, 1.0, 1.0], + [0.875, 0.6875, 0.125, 1.0, 1.0, 0.875], + ], + &[ + [0.25, 0.25, 0.25, 0.75, 0.6875, 0.75], + [0.375, 0.25, 0.75, 0.625, 0.5, 1.0], + [0.0, 0.625, 0.0, 0.25, 0.6875, 1.0], + [0.25, 0.625, 0.0, 1.0, 0.6875, 0.25], + [0.25, 0.625, 0.75, 1.0, 0.6875, 1.0], + [0.75, 0.625, 0.25, 1.0, 0.6875, 0.75], + [0.0, 0.6875, 0.0, 0.125, 1.0, 1.0], + [0.125, 0.6875, 0.0, 1.0, 1.0, 0.125], + [0.125, 0.6875, 0.875, 1.0, 1.0, 1.0], + [0.875, 0.6875, 0.125, 1.0, 1.0, 0.875], + ], + &[ + [0.0, 0.25, 0.375, 0.75, 0.5, 0.625], + [0.25, 0.25, 0.25, 0.75, 0.6875, 0.375], + [0.25, 0.25, 0.625, 0.75, 0.6875, 0.75], + [0.25, 0.5, 0.375, 0.75, 0.6875, 0.625], + [0.0, 0.625, 0.0, 0.25, 0.6875, 1.0], + [0.25, 0.625, 0.0, 1.0, 0.6875, 0.25], + [0.25, 0.625, 0.75, 1.0, 0.6875, 1.0], + [0.75, 0.625, 0.25, 1.0, 0.6875, 0.75], + [0.0, 0.6875, 0.0, 0.125, 1.0, 1.0], + [0.125, 0.6875, 0.0, 1.0, 1.0, 0.125], + [0.125, 0.6875, 0.875, 1.0, 1.0, 1.0], + [0.875, 0.6875, 0.125, 1.0, 1.0, 0.875], + ], + &[ + [0.25, 0.25, 0.25, 0.75, 0.6875, 0.75], + [0.75, 0.25, 0.375, 1.0, 0.5, 0.625], + [0.0, 0.625, 0.0, 0.25, 0.6875, 1.0], + [0.25, 0.625, 0.0, 1.0, 0.6875, 0.25], + [0.25, 0.625, 0.75, 1.0, 0.6875, 1.0], + [0.75, 0.625, 0.25, 1.0, 0.6875, 0.75], + [0.0, 0.6875, 0.0, 0.125, 1.0, 1.0], + [0.125, 0.6875, 0.0, 1.0, 1.0, 0.125], + [0.125, 0.6875, 0.875, 1.0, 1.0, 1.0], + [0.875, 0.6875, 0.125, 1.0, 1.0, 0.875], + ], + &[[0.0, 0.0, 0.0, 1.0, 0.0625, 1.0]], + &[[0.375, 0.375, 0.0, 0.625, 0.625, 1.0]], + &[[0.0, 0.375, 0.375, 1.0, 0.625, 0.625]], + &[[0.375, 0.0, 0.375, 0.625, 1.0, 0.625]], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[[0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], [ + 0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125, + ]], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + ], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], [ + 0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125, + ]], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + ], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + ], + &[[0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], [ + 0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875, + ]], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + ], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], [ + 0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875, + ]], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + ], + &[[0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], [ + 0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0, + ]], + &[ + [0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], + [0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + ], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], [ + 0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0, + ]], + &[[0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125], [ + 0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125, + ]], + &[[0.1875, 0.0, 0.1875, 0.8125, 1.0, 0.8125]], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125], [ + 0.0, 0.1875, 0.1875, 0.1875, 0.8125, 0.8125, + ]], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.8125, 0.8125]], + &[ + [0.0, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125], + ], + &[ + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + [0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125], + ], + &[ + [0.0, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + ], + &[[0.1875, 0.1875, 0.0, 0.8125, 0.8125, 1.0], [ + 0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125, + ]], + &[ + [0.0, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125], + ], + &[ + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.8125], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + [0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125], + ], + &[[0.0, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], [ + 0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875, + ]], + &[[0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.8125], [ + 0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125, + ]], + &[ + [0.0, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125], + ], + &[ + [0.1875, 0.1875, 0.1875, 0.8125, 0.8125, 1.0], + [0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], + [0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125], + ], + &[[0.0, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], [ + 0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0, + ]], + &[[0.1875, 0.1875, 0.1875, 0.8125, 0.8125, 1.0], [ + 0.8125, 0.1875, 0.1875, 1.0, 0.8125, 0.8125, + ]], + &[[0.0, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], [ + 0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125, + ]], + &[[0.1875, 0.1875, 0.1875, 1.0, 0.8125, 0.8125], [ + 0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125, + ]], + &[[0.0, 0.1875, 0.1875, 1.0, 0.8125, 0.8125]], + &[[0.1875, 0.1875, 0.1875, 1.0, 0.8125, 0.8125]], + &[ + [0.0, 0.1875, 0.1875, 0.8125, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125], + ], + &[[0.1875, 0.1875, 0.0, 0.8125, 0.8125, 1.0], [ + 0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125, + ]], + &[ + [0.0, 0.1875, 0.1875, 0.8125, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + ], + &[[0.1875, 0.1875, 0.0, 0.8125, 0.8125, 1.0]], + &[ + [0.0, 0.1875, 0.1875, 0.8125, 0.8125, 0.8125], + [0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875], + [0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125], + ], + &[[0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.8125], [ + 0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125, + ]], + &[[0.0, 0.1875, 0.1875, 0.8125, 0.8125, 0.8125], [ + 0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.1875, + ]], + &[[0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.8125]], + &[ + [0.0, 0.1875, 0.1875, 0.8125, 0.8125, 0.8125], + [0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0], + [0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125], + ], + &[[0.1875, 0.1875, 0.1875, 0.8125, 0.8125, 1.0], [ + 0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125, + ]], + &[[0.0, 0.1875, 0.1875, 0.8125, 0.8125, 0.8125], [ + 0.1875, 0.1875, 0.8125, 0.8125, 0.8125, 1.0, + ]], + &[[0.1875, 0.1875, 0.1875, 0.8125, 0.8125, 1.0]], + &[[0.0, 0.1875, 0.1875, 0.8125, 0.8125, 0.8125], [ + 0.1875, 0.8125, 0.1875, 0.8125, 1.0, 0.8125, + ]], + &[[0.1875, 0.1875, 0.1875, 0.8125, 1.0, 0.8125]], + &[[0.0, 0.1875, 0.1875, 0.8125, 0.8125, 0.8125]], + &[[0.1875, 0.1875, 0.1875, 0.8125, 0.8125, 0.8125]], + &[[0.3125, -0.0625, 0.3125, 0.6875, 0.1875, 0.6875]], + &[[0.1875, -0.0625, 0.1875, 0.8125, 0.3125, 0.8125]], + &[[0.1875, 0.0, 0.1875, 0.75, 0.4375, 0.75]], + &[[0.0625, 0.0, 0.0625, 0.9375, 0.4375, 0.9375]], + &[[0.0625, 0.0, 0.125, 0.9375, 1.0, 0.875]], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.625, 0.8125]], + &[[0.375, 0.0, 0.375, 0.625, 0.375, 0.625]], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.375, 0.8125]], + &[[0.125, 0.0, 0.125, 0.875, 0.375, 0.875]], + &[[0.125, 0.0, 0.125, 0.875, 0.4375, 0.875]], + &[[0.3125, 0.3125, 0.3125, 0.6875, 0.6875, 0.6875]], + &[[0.15625, 0.0, 0.15625, 0.34375, 1.0, 0.34375]], + &[ + [0.0, 0.0, 0.0, 0.125, 1.0, 0.125], + [0.0, 0.0, 0.875, 0.125, 1.0, 1.0], + [0.875, 0.0, 0.0, 1.0, 1.0, 0.125], + [0.875, 0.0, 0.875, 1.0, 1.0, 1.0], + [0.0, 0.875, 0.125, 1.0, 1.0, 0.875], + [0.125, 0.875, 0.0, 0.875, 1.0, 0.125], + [0.125, 0.875, 0.875, 0.875, 1.0, 1.0], + ], + &[ + [0.125, 0.0, 0.375, 0.25, 0.8125, 0.625], + [0.75, 0.0, 0.375, 0.875, 0.8125, 0.625], + [0.25, 0.25, 0.125, 0.75, 1.0, 0.875], + [0.125, 0.4375, 0.3125, 0.25, 0.8125, 0.375], + [0.125, 0.4375, 0.625, 0.25, 0.8125, 0.6875], + [0.75, 0.4375, 0.3125, 0.875, 0.8125, 0.375], + [0.75, 0.4375, 0.625, 0.875, 0.8125, 0.6875], + ], + &[ + [0.375, 0.0, 0.125, 0.625, 0.8125, 0.25], + [0.375, 0.0, 0.75, 0.625, 0.8125, 0.875], + [0.125, 0.25, 0.25, 0.875, 1.0, 0.75], + [0.3125, 0.4375, 0.125, 0.375, 0.8125, 0.25], + [0.3125, 0.4375, 0.75, 0.375, 0.8125, 0.875], + [0.625, 0.4375, 0.125, 0.6875, 0.8125, 0.25], + [0.625, 0.4375, 0.75, 0.6875, 0.8125, 0.875], + ], + &[ + [0.25, 0.125, 0.0, 0.75, 0.875, 0.75], + [0.125, 0.3125, 0.1875, 0.25, 0.6875, 0.5625], + [0.75, 0.3125, 0.1875, 0.875, 0.6875, 0.5625], + [0.125, 0.375, 0.5625, 0.25, 0.625, 1.0], + [0.75, 0.375, 0.5625, 0.875, 0.625, 1.0], + ], + &[ + [0.25, 0.125, 0.25, 0.75, 0.875, 1.0], + [0.125, 0.3125, 0.4375, 0.25, 0.6875, 0.8125], + [0.75, 0.3125, 0.4375, 0.875, 0.6875, 0.8125], + [0.125, 0.375, 0.0, 0.25, 0.625, 0.4375], + [0.75, 0.375, 0.0, 0.875, 0.625, 0.4375], + ], + &[ + [0.0, 0.125, 0.25, 0.75, 0.875, 0.75], + [0.1875, 0.3125, 0.125, 0.5625, 0.6875, 0.25], + [0.1875, 0.3125, 0.75, 0.5625, 0.6875, 0.875], + [0.5625, 0.375, 0.125, 1.0, 0.625, 0.25], + [0.5625, 0.375, 0.75, 1.0, 0.625, 0.875], + ], + &[ + [0.25, 0.125, 0.25, 1.0, 0.875, 0.75], + [0.4375, 0.3125, 0.125, 0.8125, 0.6875, 0.25], + [0.4375, 0.3125, 0.75, 0.8125, 0.6875, 0.875], + [0.0, 0.375, 0.125, 0.4375, 0.625, 0.25], + [0.0, 0.375, 0.75, 0.4375, 0.625, 0.875], + ], + &[ + [0.25, 0.0, 0.125, 0.75, 0.75, 0.875], + [0.125, 0.1875, 0.3125, 0.25, 0.5625, 0.6875], + [0.75, 0.1875, 0.3125, 0.875, 0.5625, 0.6875], + [0.125, 0.5625, 0.375, 0.25, 1.0, 0.625], + [0.75, 0.5625, 0.375, 0.875, 1.0, 0.625], + ], + &[ + [0.125, 0.0, 0.25, 0.875, 0.75, 0.75], + [0.3125, 0.1875, 0.125, 0.6875, 0.5625, 0.25], + [0.3125, 0.1875, 0.75, 0.6875, 0.5625, 0.875], + [0.375, 0.5625, 0.125, 0.625, 1.0, 0.25], + [0.375, 0.5625, 0.75, 0.625, 1.0, 0.875], + ], + &[[0.0, 0.0, 0.0, 1.0, 0.125, 1.0], [ + 0.25, 0.125, 0.25, 0.75, 0.875, 0.75, + ]], + &[[0.0, 0.0, 0.0, 1.0, 0.5625, 1.0]], + &[[0.0, 0.0, 0.25, 1.0, 1.0, 0.75]], + &[[0.25, 0.0, 0.0, 0.75, 1.0, 1.0]], + &[ + [0.25, 0.25, 0.25, 0.75, 0.375, 0.75], + [0.3125, 0.375, 0.3125, 0.6875, 0.8125, 0.6875], + [0.4375, 0.8125, 0.4375, 0.5625, 1.0, 0.5625], + ], + &[ + [0.25, 0.25, 0.25, 0.75, 0.375, 0.75], + [0.3125, 0.375, 0.3125, 0.6875, 0.8125, 0.6875], + [0.4375, 0.8125, 0.0, 0.5625, 0.9375, 0.8125], + ], + &[ + [0.25, 0.25, 0.25, 0.75, 0.375, 0.75], + [0.3125, 0.375, 0.3125, 0.6875, 0.8125, 0.6875], + [0.4375, 0.8125, 0.1875, 0.5625, 0.9375, 1.0], + ], + &[ + [0.25, 0.25, 0.25, 0.75, 0.375, 0.75], + [0.3125, 0.375, 0.3125, 0.6875, 0.8125, 0.6875], + [0.0, 0.8125, 0.4375, 0.8125, 0.9375, 0.5625], + ], + &[ + [0.25, 0.25, 0.25, 0.75, 0.375, 0.75], + [0.3125, 0.375, 0.3125, 0.6875, 0.8125, 0.6875], + [0.1875, 0.8125, 0.4375, 1.0, 0.9375, 0.5625], + ], + &[ + [0.25, 0.25, 0.25, 0.75, 0.375, 0.75], + [0.3125, 0.375, 0.3125, 0.6875, 0.8125, 0.6875], + [0.4375, 0.8125, 0.0, 0.5625, 0.9375, 1.0], + ], + &[ + [0.25, 0.25, 0.25, 0.75, 0.375, 0.75], + [0.3125, 0.375, 0.3125, 0.6875, 0.8125, 0.6875], + [0.0, 0.8125, 0.4375, 1.0, 0.9375, 0.5625], + ], + &[[0.3125, 0.0625, 0.3125, 0.6875, 0.5, 0.6875], [ + 0.375, 0.5, 0.375, 0.625, 0.625, 0.625, + ]], + &[[0.3125, 0.0, 0.3125, 0.6875, 0.4375, 0.6875], [ + 0.375, 0.4375, 0.375, 0.625, 0.5625, 0.625, + ]], + &[[0.0, 0.0, 0.0, 1.0, 0.4375, 1.0]], + &[ + [0.0, 0.0, 0.0, 1.0, 0.125, 1.0], + [0.0, 0.125, 0.0, 0.125, 1.0, 1.0], + [0.125, 0.125, 0.0, 1.0, 1.0, 0.125], + [0.125, 0.125, 0.875, 1.0, 1.0, 1.0], + [0.875, 0.125, 0.125, 1.0, 1.0, 0.875], + ], + &[[0.4375, 0.0, 0.4375, 0.5625, 0.375, 0.5625]], + &[[0.3125, 0.0, 0.375, 0.6875, 0.375, 0.5625]], + &[[0.3125, 0.0, 0.375, 0.625, 0.375, 0.6875]], + &[[0.3125, 0.0, 0.3125, 0.6875, 0.375, 0.625]], + &[[0.0625, 0.0, 0.0625, 0.9375, 0.5, 0.9375], [ + 0.4375, 0.5, 0.4375, 0.5625, 0.875, 0.5625, + ]], + &[[0.1875, 0.1875, 0.5625, 0.8125, 0.8125, 1.0]], + &[[0.0, 0.1875, 0.1875, 0.4375, 0.8125, 0.8125]], + &[[0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.4375]], + &[[0.5625, 0.1875, 0.1875, 1.0, 0.8125, 0.8125]], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.4375, 0.8125]], + &[[0.1875, 0.5625, 0.1875, 0.8125, 1.0, 0.8125]], + &[[0.1875, 0.1875, 0.6875, 0.8125, 0.8125, 1.0]], + &[[0.0, 0.1875, 0.1875, 0.3125, 0.8125, 0.8125]], + &[[0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.3125]], + &[[0.6875, 0.1875, 0.1875, 1.0, 0.8125, 0.8125]], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.3125, 0.8125]], + &[[0.1875, 0.6875, 0.1875, 0.8125, 1.0, 0.8125]], + &[[0.1875, 0.1875, 0.75, 0.8125, 0.8125, 1.0]], + &[[0.0, 0.1875, 0.1875, 0.25, 0.8125, 0.8125]], + &[[0.1875, 0.1875, 0.0, 0.8125, 0.8125, 0.25]], + &[[0.75, 0.1875, 0.1875, 1.0, 0.8125, 0.8125]], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.25, 0.8125]], + &[[0.1875, 0.75, 0.1875, 0.8125, 1.0, 0.8125]], + &[[0.25, 0.25, 0.8125, 0.75, 0.75, 1.0]], + &[[0.0, 0.25, 0.25, 0.1875, 0.75, 0.75]], + &[[0.25, 0.25, 0.0, 0.75, 0.75, 0.1875]], + &[[0.8125, 0.25, 0.25, 1.0, 0.75, 0.75]], + &[[0.25, 0.0, 0.25, 0.75, 0.1875, 0.75]], + &[[0.25, 0.8125, 0.25, 0.75, 1.0, 0.75]], + &[[0.1875, 0.0, 0.1875, 0.8125, 0.875, 0.8125]], + &[[0.1875, 0.0, 0.1875, 0.5625, 1.0, 0.5625]], + &[[0.1875, 0.0, 0.1875, 0.5625, 0.6875, 0.5625]], + &[[0.1875, 0.3125, 0.1875, 0.5625, 1.0, 0.5625]], + &[[0.125, 0.0, 0.125, 0.625, 1.0, 0.625]], + &[[0.0625, 0.0, 0.0625, 0.6875, 1.0, 0.6875]], + &[[0.0, 0.0, 0.0, 0.75, 1.0, 0.75]], + &[ + [0.375, 0.0, 0.375, 0.625, 1.0, 0.625], + [0.0, 0.5, 0.0, 0.375, 1.0, 1.0], + [0.375, 0.5, 0.0, 1.0, 1.0, 0.375], + [0.375, 0.5, 0.625, 1.0, 1.0, 1.0], + [0.625, 0.5, 0.375, 1.0, 1.0, 0.625], + ], + &[[0.0, 0.6875, 0.0, 1.0, 0.9375, 1.0]], + &[[0.0, 0.6875, 0.0, 1.0, 0.8125, 1.0]], +]; + +/// Every state's index into [`SHAPES`], indexed by network id. +pub static STATE_SHAPES: &[u16] = &[ + 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 2, 3, 3, 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, + 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, + 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, + 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, + 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, + 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, + 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, + 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 2, 3, 2, 3, 3, + 2, 3, 2, 4, 5, 4, 5, 5, 4, 5, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 7, 8, 9, + 10, 11, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 7, 8, 9, 10, 11, 1, 1, 1, 1, 1, 1, + 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 24, 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 26, + 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27, + 27, 27, 27, 27, 27, 27, 27, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 25, + 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 26, + 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 24, + 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, + 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, + 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 24, 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 26, + 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27, + 27, 27, 27, 27, 27, 27, 27, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 25, + 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 26, + 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 24, + 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, + 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, + 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 24, 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 26, + 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27, + 27, 27, 27, 27, 27, 27, 27, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 25, + 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 26, + 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 24, + 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, + 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, + 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 24, 24, 24, 24, 24, 24, 24, 24, 24, + 24, 24, 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 26, + 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27, + 27, 27, 27, 27, 27, 27, 27, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 25, + 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 26, + 26, 26, 26, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 24, + 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 25, 25, 25, 25, 25, 25, 25, 25, 25, + 25, 25, 25, 25, 25, 25, 25, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 26, 27, + 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 27, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, + 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, + 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, + 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 52, 52, 53, 53, + 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, 55, 56, 56, 52, 52, 56, 56, 55, 55, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 57, 57, 57, 57, 57, 57, + 57, 57, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, + 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, + 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, + 58, 59, 59, 61, 61, 60, 60, 58, 58, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, + 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, + 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, + 35, 35, 44, 44, 37, 37, 46, 46, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 62, 62, 62, + 62, 63, 63, 63, 63, 62, 62, 62, 62, 63, 63, 63, 63, 62, 62, 62, 62, 63, 63, 63, 63, 62, 62, 62, + 62, 63, 63, 63, 63, 62, 62, 62, 62, 63, 63, 63, 63, 62, 62, 62, 62, 63, 63, 63, 63, 62, 62, 62, + 62, 63, 63, 63, 63, 62, 62, 62, 62, 63, 63, 63, 63, 62, 62, 62, 62, 63, 63, 63, 63, 62, 62, 62, + 62, 63, 63, 63, 63, 62, 62, 62, 62, 63, 63, 63, 63, 62, 62, 62, 62, 63, 63, 63, 63, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 58, 58, 59, 59, 60, 60, 59, + 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, + 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, + 58, 61, 61, 58, 58, 59, 59, 58, 58, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 64, 65, 66, 67, 68, 10, 69, 1, 1, 70, 70, 70, 70, 70, 70, 70, 70, 70, 70, + 70, 70, 70, 70, 70, 70, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 71, 72, 71, + 72, 73, 74, 73, 74, 75, 76, 75, 76, 77, 78, 77, 78, 79, 80, 79, 80, 81, 82, 81, 82, 83, 84, 83, + 84, 85, 86, 85, 86, 1, 69, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 1, + 1, 1, 1, 1, 1, 87, 88, 89, 90, 91, 92, 93, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, + 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, + 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, + 64, 64, 64, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 59, 59, 59, 59, 94, 94, 94, 94, 59, + 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, + 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, + 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, + 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, + 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, + 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, + 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, + 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, + 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, + 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, + 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, + 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, + 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, + 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, + 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, + 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, + 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, + 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, + 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, + 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, + 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, + 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, + 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, + 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, + 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, + 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, + 58, 58, 58, 95, 95, 95, 95, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 96, 97, 96, 97, 98, 99, 98, + 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, + 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, + 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, + 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, + 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, + 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, + 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, + 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, + 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, + 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, + 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, + 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, + 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, + 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, + 108, 109, 110, 111, 110, 111, 112, 112, 113, 113, 114, 114, 112, 112, 113, 113, 114, 114, 112, + 112, 113, 113, 114, 114, 112, 112, 113, 113, 114, 114, 112, 112, 113, 113, 114, 114, 112, 112, + 113, 113, 114, 114, 112, 112, 113, 113, 114, 114, 112, 112, 113, 113, 114, 114, 112, 112, 113, + 113, 114, 114, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, + 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 1, 1, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, + 77, 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 28, 28, 29, 29, 30, + 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, + 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, + 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, + 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, + 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, + 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, + 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, + 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, + 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, + 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 1, 1, 115, 1, 1, + 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, + 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, + 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, + 35, 35, 44, 44, 37, 37, 46, 46, 116, 116, 67, 67, 1, 1, 117, 118, 118, 117, 118, 118, 0, 119, + 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, + 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, + 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, + 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, + 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, + 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, + 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, + 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, + 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, + 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, + 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, + 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, + 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, + 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, + 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 1, 1, 71, 72, 71, 72, 73, 74, 73, + 74, 75, 76, 75, 76, 77, 78, 77, 78, 79, 80, 79, 80, 81, 82, 81, 82, 83, 84, 83, 84, 85, 86, 85, + 86, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, + 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, + 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, + 51, 35, 35, 44, 44, 37, 37, 46, 46, 0, 0, 0, 0, 10, 148, 148, 148, 148, 148, 148, 148, 148, + 149, 149, 149, 149, 149, 149, 149, 149, 0, 150, 150, 150, 150, 151, 151, 151, 151, 1, 152, 1, + 1, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 28, 28, 29, 29, 30, 30, 31, 31, + 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, + 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, + 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, + 1, 1, 52, 52, 52, 52, 52, 52, 52, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 28, + 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, + 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, + 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, + 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, + 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, + 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, + 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, + 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, + 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, + 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, + 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, + 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, + 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, + 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, + 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, + 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, + 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, + 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, + 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, + 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, + 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, 165, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, + 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 167, 167, 168, 168, 169, 169, + 170, 170, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, + 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 167, 167, 168, 168, + 169, 169, 170, 170, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, + 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 167, 167, + 168, 168, 169, 169, 170, 170, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, + 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, + 167, 167, 168, 168, 169, 169, 170, 170, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, + 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, + 166, 166, 167, 167, 168, 168, 169, 169, 170, 170, 166, 166, 166, 166, 166, 166, 166, 166, 166, + 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, 166, + 166, 166, 166, 166, 167, 167, 168, 168, 169, 169, 170, 170, 171, 171, 171, 171, 171, 171, 171, + 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, 171, + 171, 171, 171, 171, 171, 171, 172, 172, 173, 173, 174, 174, 175, 175, 176, 176, 177, 177, 176, + 176, 177, 177, 176, 176, 177, 177, 52, 52, 53, 53, 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, + 55, 56, 56, 52, 52, 56, 56, 55, 55, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, + 64, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, 66, + 66, 66, 66, 66, 66, 66, 66, 66, 66, 1, 1, 178, 179, 180, 181, 182, 178, 179, 180, 181, 182, 1, + 1, 1, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, + 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, + 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, + 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, + 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, + 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, + 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, + 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, + 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, + 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, + 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, + 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, + 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, + 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, + 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, + 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, + 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, + 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, + 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, + 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, + 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, + 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, + 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, + 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, + 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, + 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, + 98, 99, 98, 99, 100, 101, 100, 101, 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, + 108, 109, 108, 109, 110, 111, 110, 111, 96, 97, 96, 97, 98, 99, 98, 99, 100, 101, 100, 101, + 102, 103, 102, 103, 104, 105, 104, 105, 106, 107, 106, 107, 108, 109, 108, 109, 110, 111, 110, + 111, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, + 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, + 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, + 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, + 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, + 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, + 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, + 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, + 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, + 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, + 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, + 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, + 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, + 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, + 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, + 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, + 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, + 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, + 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, + 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, + 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, + 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, + 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, + 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, + 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, + 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, + 58, 95, 95, 95, 95, 1, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, + 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, + 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, + 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, + 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, + 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, + 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, + 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, + 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, + 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, + 35, 35, 44, 44, 37, 37, 46, 46, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, + 67, 1, 1, 1, 1, 1, 1, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, + 183, 183, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 28, 28, 29, 29, 30, 30, 31, + 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, + 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, + 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, + 46, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, + 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, + 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, + 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, + 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, + 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, + 1, 1, 116, 116, 67, 67, 1, 1, 1, 1, 1, 1, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, + 77, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, + 77, 77, 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 77, 77, 0, + 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, + 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, + 80, 0, 0, 80, 80, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, + 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, + 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, + 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 77, 77, 0, 0, 77, + 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, + 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 0, + 0, 80, 80, 71, 72, 71, 72, 73, 74, 73, 74, 75, 76, 75, 76, 77, 78, 77, 78, 79, 80, 79, 80, 81, + 82, 81, 82, 83, 84, 83, 84, 85, 86, 85, 86, 71, 72, 71, 72, 73, 74, 73, 74, 75, 76, 75, 76, 77, + 78, 77, 78, 79, 80, 79, 80, 81, 82, 81, 82, 83, 84, 83, 84, 85, 86, 85, 86, 71, 72, 71, 72, 73, + 74, 73, 74, 75, 76, 75, 76, 77, 78, 77, 78, 79, 80, 79, 80, 81, 82, 81, 82, 83, 84, 83, 84, 85, + 86, 85, 86, 71, 72, 71, 72, 73, 74, 73, 74, 75, 76, 75, 76, 77, 78, 77, 78, 79, 80, 79, 80, 81, + 82, 81, 82, 83, 84, 83, 84, 85, 86, 85, 86, 71, 72, 71, 72, 73, 74, 73, 74, 75, 76, 75, 76, 77, + 78, 77, 78, 79, 80, 79, 80, 81, 82, 81, 82, 83, 84, 83, 84, 85, 86, 85, 86, 71, 72, 71, 72, 73, + 74, 73, 74, 75, 76, 75, 76, 77, 78, 77, 78, 79, 80, 79, 80, 81, 82, 81, 82, 83, 84, 83, 84, 85, + 86, 85, 86, 71, 72, 71, 72, 73, 74, 73, 74, 75, 76, 75, 76, 77, 78, 77, 78, 79, 80, 79, 80, 81, + 82, 81, 82, 83, 84, 83, 84, 85, 86, 85, 86, 71, 72, 71, 72, 73, 74, 73, 74, 75, 76, 75, 76, 77, + 78, 77, 78, 79, 80, 79, 80, 81, 82, 81, 82, 83, 84, 83, 84, 85, 86, 85, 86, 71, 72, 71, 72, 73, + 74, 73, 74, 75, 76, 75, 76, 77, 78, 77, 78, 79, 80, 79, 80, 81, 82, 81, 82, 83, 84, 83, 84, 85, + 86, 85, 86, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, + 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, + 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, + 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, + 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, + 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, + 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, + 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, + 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, + 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, + 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, + 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, + 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, + 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, + 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, + 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, + 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, + 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, + 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, + 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, + 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, + 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, + 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, + 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, + 59, 58, 58, 184, 185, 184, 185, 186, 186, 187, 188, 189, 190, 191, 192, 193, 194, 195, 196, + 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, + 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, + 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 1, 0, 0, 0, 251, 0, 252, 0, 252, 0, 252, 0, + 252, 0, 0, 0, 0, 0, 0, 57, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 253, 253, 253, + 254, 254, 254, 254, 254, 254, 254, 254, 254, 255, 255, 255, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, 256, + 256, 256, 256, 256, 256, 256, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 257, 257, 258, 258, 259, 259, 260, 260, 1, 261, 261, 0, + 262, 262, 262, 262, 262, 262, 262, 262, 262, 262, 262, 262, 165, 0, 0, 0, 0, 28, 28, 29, 29, + 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, + 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, + 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, + 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, + 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, + 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, + 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, + 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, + 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, + 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, + 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, + 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, + 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, + 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, + 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, + 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, + 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, + 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, + 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, + 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, + 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, + 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, + 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, + 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, + 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, + 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, + 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, + 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, + 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, + 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, + 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, + 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, + 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, + 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, + 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 263, 263, 263, 263, + 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, 263, + 263, 263, 263, 263, 263, 263, 263, 263, 263, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 264, 264, 265, 265, 266, 267, 268, 269, 270, + 270, 271, 271, 272, 272, 272, 272, 272, 272, 272, 272, 272, 272, 272, 272, 272, 272, 272, 272, + 1, 273, 273, 273, 273, 274, 274, 274, 274, 275, 275, 275, 275, 276, 276, 276, 276, 276, 276, + 276, 276, 277, 277, 278, 278, 279, 279, 280, 280, 281, 281, 281, 281, 282, 282, 282, 282, 283, + 283, 284, 284, 283, 283, 284, 284, 283, 283, 284, 284, 283, 283, 284, 284, 283, 283, 284, 284, + 283, 283, 284, 284, 283, 283, 284, 284, 283, 283, 284, 284, 283, 283, 284, 284, 283, 283, 284, + 284, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, + 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, + 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, 285, + 285, 285, 285, 285, 285, 285, 285, 285, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, + 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 0, 0, 0, 0, + 71, 72, 71, 72, 73, 74, 73, 74, 75, 76, 75, 76, 77, 78, 77, 78, 79, 80, 79, 80, 81, 82, 81, 82, + 83, 84, 83, 84, 85, 86, 85, 86, 71, 72, 71, 72, 73, 74, 73, 74, 75, 76, 75, 76, 77, 78, 77, 78, + 79, 80, 79, 80, 81, 82, 81, 82, 83, 84, 83, 84, 85, 86, 85, 86, 59, 59, 59, 59, 94, 94, 94, 94, + 59, 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, + 60, 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, + 58, 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, + 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, + 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, + 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, 80, 80, 0, 0, 80, + 80, 0, 0, 80, 80, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 77, 77, 0, 0, 80, 80, 0, 0, + 80, 80, 0, 0, 80, 80, 0, 0, 80, 80, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, + 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, + 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, + 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, + 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, + 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, + 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, + 46, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, + 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, + 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, + 58, 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, + 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, + 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 286, 286, 286, 286, 286, 286, 286, 286, 286, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 70, 1, 1, 1, 1, 1, 1, 1, 1, 1, 165, 165, + 165, 165, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, + 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, + 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, + 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, + 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, + 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, + 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, + 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, + 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, + 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, + 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, + 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 116, 116, 67, 67, 1, 1, 1, 1, 1, 1, 116, 116, + 67, 67, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, + 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, + 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, + 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, + 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, + 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, + 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, + 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, + 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, + 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, + 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, + 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, + 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, + 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, + 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 116, + 116, 67, 67, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, + 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, + 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, + 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, + 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, + 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, + 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, + 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, + 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, + 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, + 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, + 147, 1, 1, 1, 287, 287, 287, 287, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, + 287, 287, 287, 287, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 287, 287, 287, + 287, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 287, 287, 287, 287, 288, 288, + 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 287, 287, 287, 287, 288, 288, 288, 288, 289, + 289, 289, 289, 290, 290, 290, 290, 287, 287, 287, 287, 288, 288, 288, 288, 289, 289, 289, 289, + 290, 290, 290, 290, 287, 287, 287, 287, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, + 290, 287, 287, 287, 287, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 287, 287, + 287, 287, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 287, 287, 287, 287, 288, + 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 287, 287, 287, 287, 288, 288, 288, 288, + 289, 289, 289, 289, 290, 290, 290, 290, 287, 287, 287, 287, 288, 288, 288, 288, 289, 289, 289, + 289, 290, 290, 290, 290, 287, 287, 287, 287, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, + 290, 290, 287, 287, 287, 287, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 287, + 287, 287, 287, 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 287, 287, 287, 287, + 288, 288, 288, 288, 289, 289, 289, 289, 290, 290, 290, 290, 287, 287, 287, 287, 288, 288, 288, + 288, 289, 289, 289, 289, 290, 290, 290, 290, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, + 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, 291, + 291, 291, 291, 291, 291, 1, 1, 292, 292, 293, 293, 294, 294, 295, 295, 296, 296, 297, 297, 298, + 298, 299, 299, 300, 300, 301, 301, 302, 302, 303, 303, 304, 304, 305, 305, 306, 306, 307, 307, + 308, 308, 309, 309, 310, 310, 311, 311, 312, 312, 313, 313, 314, 314, 315, 315, 1, 116, 116, + 67, 67, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, + 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, + 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, + 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, + 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, + 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, + 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, + 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, + 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, + 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, + 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, + 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 1, 116, 116, 67, 67, 1, 1, 28, 28, 29, 29, + 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, + 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, + 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, + 37, 37, 46, 46, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, + 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, + 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, + 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, + 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, + 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, + 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, + 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, + 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, + 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, + 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, + 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, + 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, + 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 1, 1, 116, 116, 67, 67, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, + 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, + 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, + 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 117, 118, + 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, + 123, 123, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, + 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, + 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, + 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, + 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, + 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, + 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, + 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, + 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, + 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 1, 1, 1, + 1, 1, 1, 1, 116, 116, 67, 67, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, + 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, + 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, + 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 117, 118, 118, 117, 118, + 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, + 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, + 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, + 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, + 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, + 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, + 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, + 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, + 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 1, 116, 116, 67, 67, 1, + 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, + 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, + 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, + 51, 35, 35, 44, 44, 37, 37, 46, 46, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, + 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, + 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, + 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, + 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, + 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 1, 116, 116, 67, 67, 1, 1, 28, 28, 29, 29, 30, 30, 31, + 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, + 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, + 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, + 46, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, + 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, + 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, + 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, + 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, + 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, + 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, + 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, + 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, + 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, + 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, + 147, 1, 1, 116, 116, 67, 67, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, + 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, + 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, + 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 117, 118, 118, 117, 118, + 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, + 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, + 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, + 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, + 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, + 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, + 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, + 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, + 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 1, 116, 116, 67, 67, 1, + 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, + 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, + 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, + 51, 35, 35, 44, 44, 37, 37, 46, 46, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, + 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, + 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, + 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, + 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, + 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 1, 116, 116, 67, 67, 1, 1, 28, 28, 29, 29, 30, 30, 31, + 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, + 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, + 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, + 46, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, + 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, + 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, + 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, + 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, + 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, + 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, + 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, + 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, + 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, + 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, + 147, 1, 1, 1, 0, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 67, 67, 67, 67, 67, 67, 67, 67, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, + 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, + 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, + 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, + 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, + 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, + 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, + 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, + 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, + 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, + 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, + 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, + 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, + 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, + 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, + 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, + 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, + 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 28, 28, 29, 29, + 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, + 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, + 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, + 37, 37, 46, 46, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, + 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, 116, 67, 67, 1, 1, 116, + 116, 67, 67, 1, 1, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, + 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, + 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, + 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, + 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, + 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, + 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, + 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, + 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, + 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, + 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, + 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, + 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, + 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, + 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, + 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, + 58, 59, 59, 58, 58, 58, 58, 59, 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, + 61, 58, 58, 61, 61, 60, 60, 61, 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, + 60, 61, 61, 60, 60, 61, 61, 58, 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 58, 58, 59, + 59, 60, 60, 59, 59, 58, 58, 59, 59, 60, 60, 59, 59, 60, 60, 61, 61, 58, 58, 61, 61, 60, 60, 61, + 61, 58, 58, 61, 61, 59, 59, 60, 60, 61, 61, 60, 60, 59, 59, 60, 60, 61, 61, 60, 60, 61, 61, 58, + 58, 59, 59, 58, 58, 61, 61, 58, 58, 59, 59, 58, 58, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, + 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, + 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, + 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, + 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, + 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, + 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, + 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, + 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, + 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, + 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, + 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, + 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, + 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, + 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, + 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, + 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, + 59, 95, 95, 95, 95, 61, 61, 61, 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, + 60, 94, 94, 94, 94, 60, 60, 60, 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, + 58, 95, 95, 95, 95, 59, 59, 59, 59, 94, 94, 94, 94, 59, 59, 59, 59, 95, 95, 95, 95, 61, 61, 61, + 61, 94, 94, 94, 94, 61, 61, 61, 61, 95, 95, 95, 95, 60, 60, 60, 60, 94, 94, 94, 94, 60, 60, 60, + 60, 95, 95, 95, 95, 58, 58, 58, 58, 94, 94, 94, 94, 58, 58, 58, 58, 95, 95, 95, 95, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 52, 52, 53, 53, 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, + 55, 56, 56, 52, 52, 56, 56, 55, 55, 52, 52, 53, 53, 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, + 55, 56, 56, 52, 52, 56, 56, 55, 55, 52, 52, 53, 53, 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, + 55, 56, 56, 52, 52, 56, 56, 55, 55, 52, 52, 53, 53, 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, + 55, 56, 56, 52, 52, 56, 56, 55, 55, 52, 52, 53, 53, 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, + 55, 56, 56, 52, 52, 56, 56, 55, 55, 52, 52, 53, 53, 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, + 55, 56, 56, 52, 52, 56, 56, 55, 55, 52, 52, 53, 53, 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, + 55, 56, 56, 52, 52, 56, 56, 55, 55, 52, 52, 53, 53, 54, 54, 52, 52, 54, 54, 53, 53, 52, 52, 55, + 55, 56, 56, 52, 52, 56, 56, 55, 55, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, + 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 316, 184, 184, 184, + 184, 185, 185, 185, 185, 184, 184, 184, 184, 185, 185, 185, 185, 186, 186, 186, 186, 186, 186, + 186, 186, 184, 184, 184, 184, 185, 185, 185, 185, 184, 184, 184, 184, 185, 185, 185, 185, 186, + 186, 186, 186, 186, 186, 186, 186, 184, 184, 184, 184, 185, 185, 185, 185, 184, 184, 184, 184, + 185, 185, 185, 185, 186, 186, 186, 186, 186, 186, 186, 186, 184, 184, 184, 184, 185, 185, 185, + 185, 184, 184, 184, 184, 185, 185, 185, 185, 186, 186, 186, 186, 186, 186, 186, 186, 184, 184, + 184, 184, 185, 185, 185, 185, 184, 184, 184, 184, 185, 185, 185, 185, 186, 186, 186, 186, 186, + 186, 186, 186, 184, 184, 184, 184, 185, 185, 185, 185, 184, 184, 184, 184, 185, 185, 185, 185, + 186, 186, 186, 186, 186, 186, 186, 186, 184, 184, 184, 184, 185, 185, 185, 185, 184, 184, 184, + 184, 185, 185, 185, 185, 186, 186, 186, 186, 186, 186, 186, 186, 184, 184, 184, 184, 185, 185, + 185, 185, 184, 184, 184, 184, 185, 185, 185, 185, 186, 186, 186, 186, 186, 186, 186, 186, 1, + 317, 317, 317, 317, 318, 318, 319, 319, 320, 320, 320, 320, 321, 321, 321, 321, 322, 322, 322, + 322, 317, 317, 317, 317, 318, 318, 319, 319, 320, 320, 320, 320, 321, 321, 321, 321, 322, 322, + 322, 322, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 323, 323, 183, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 324, 324, 324, 324, 325, 325, 0, 0, 324, + 324, 324, 324, 325, 325, 0, 0, 324, 324, 324, 324, 325, 325, 0, 0, 324, 324, 324, 324, 325, + 325, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 69, + 1, 1, 1, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, + 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, + 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, + 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 116, 116, 67, 67, 1, 1, 117, 118, 118, 117, 118, + 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, + 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, + 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, + 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, + 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, + 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, + 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, + 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, + 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 1, 28, 28, 29, 29, 30, + 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, + 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, + 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, + 37, 46, 46, 116, 116, 67, 67, 1, 1, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, + 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, + 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, + 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, + 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, + 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, + 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, + 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, + 34, 35, 35, 36, 36, 37, 37, 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, + 46, 47, 47, 48, 48, 40, 40, 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, + 50, 30, 30, 39, 39, 32, 32, 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 116, 116, 67, 67, + 1, 1, 117, 118, 118, 117, 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, + 122, 123, 123, 122, 123, 123, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, + 125, 125, 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, + 131, 130, 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, + 124, 125, 125, 126, 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, + 131, 131, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, + 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, + 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, + 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, + 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, + 145, 145, 146, 147, 147, 146, 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 1, 28, 28, 29, 29, 30, 30, 31, 31, 32, 32, 33, 33, 34, 34, 35, 35, 36, 36, 37, 37, + 38, 38, 39, 39, 40, 40, 41, 41, 42, 42, 43, 43, 44, 44, 45, 45, 46, 46, 47, 47, 48, 48, 40, 40, + 29, 29, 42, 42, 31, 31, 49, 49, 45, 45, 34, 34, 47, 47, 36, 36, 50, 50, 30, 30, 39, 39, 32, 32, + 41, 41, 51, 51, 35, 35, 44, 44, 37, 37, 46, 46, 116, 116, 67, 67, 1, 1, 117, 118, 118, 117, + 118, 118, 0, 119, 119, 0, 119, 119, 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, + 120, 121, 121, 120, 121, 121, 122, 123, 123, 122, 123, 123, 124, 125, 125, 124, 125, 125, 126, + 127, 127, 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, + 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 124, 125, 125, 124, 125, 125, 126, 127, 127, + 126, 127, 127, 128, 129, 129, 128, 129, 129, 130, 131, 131, 130, 131, 131, 128, 129, 129, 128, + 129, 129, 130, 131, 131, 130, 131, 131, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, + 135, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, + 138, 139, 139, 138, 139, 139, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, + 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, + 147, 146, 147, 147, 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, + 144, 145, 145, 146, 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, + 147, 147, 132, 133, 133, 132, 133, 133, 134, 135, 135, 134, 135, 135, 136, 137, 137, 136, 137, + 137, 138, 139, 139, 138, 139, 139, 136, 137, 137, 136, 137, 137, 138, 139, 139, 138, 139, 139, + 140, 141, 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, + 147, 147, 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 140, 141, + 141, 140, 141, 141, 142, 143, 143, 142, 143, 143, 144, 145, 145, 144, 145, 145, 146, 147, 147, + 146, 147, 147, 144, 145, 145, 144, 145, 145, 146, 147, 147, 146, 147, 147, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 165, 165, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 152, 152, 152, 152, 152, 152, 152, 152, + 152, 152, 152, 152, 152, 152, 152, 152, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 166, 166, 1, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, + 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, + 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, + 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, + 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 183, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 165, 165, 0, +]; diff --git a/crates/hyperion-minecraft-proto/src/lib.rs b/crates/hyperion-minecraft-proto/src/lib.rs index bb730341f..dec8bbd46 100644 --- a/crates/hyperion-minecraft-proto/src/lib.rs +++ b/crates/hyperion-minecraft-proto/src/lib.rs @@ -26,6 +26,7 @@ extern crate self as hyperion_minecraft_proto; pub mod block_state; pub mod codec; +pub mod collision_shape; pub mod framing; pub mod generated; pub mod item; diff --git a/crates/hyperion-minecraft-proto/src/packets/configuration.rs b/crates/hyperion-minecraft-proto/src/packets/configuration.rs index 8c8f012de..65b03780f 100644 --- a/crates/hyperion-minecraft-proto/src/packets/configuration.rs +++ b/crates/hyperion-minecraft-proto/src/packets/configuration.rs @@ -604,73 +604,7 @@ impl<'a> Decode<'a> for UpdateTags<'a> { } } -// --- keep alive, ping, disconnect ----------------------------------------- - -/// `minecraft:keep_alive`, both directions (`Clientbound`- and -/// `ServerboundKeepAlivePacket`). -/// -/// The two classes have the same one-field body, so one type covers both. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct KeepAlive { - /// Opaque value the client echoes back. - pub id: i64, -} - -impl Encode for KeepAlive { - fn encode(&self, writer: &mut Writer) -> Result<()> { - writer.i64(self.id); - Ok(()) - } -} - -impl Decode<'_> for KeepAlive { - fn decode(reader: &mut Reader<'_>) -> Result { - Ok(Self { id: reader.i64()? }) - } -} - -/// `minecraft:ping`, clientbound (`ClientboundPingPacket`). -/// -/// Distinct from the status-state ping: this one is a plain `int`, not the -/// `long` [`crate::packets::status::PingRequest`] carries. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Ping { - /// Opaque value returned in the [`Pong`]. - pub id: i32, -} - -impl Encode for Ping { - fn encode(&self, writer: &mut Writer) -> Result<()> { - writer.i32(self.id); - Ok(()) - } -} - -impl Decode<'_> for Ping { - fn decode(reader: &mut Reader<'_>) -> Result { - Ok(Self { id: reader.i32()? }) - } -} - -/// `minecraft:pong`, serverbound (`ServerboundPongPacket`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Pong { - /// The value from the matching [`Ping`]. - pub id: i32, -} - -impl Encode for Pong { - fn encode(&self, writer: &mut Writer) -> Result<()> { - writer.i32(self.id); - Ok(()) - } -} - -impl Decode<'_> for Pong { - fn decode(reader: &mut Reader<'_>) -> Result { - Ok(Self { id: reader.i32()? }) - } -} +// --- disconnect ----------------------------------------------------------- /// `minecraft:disconnect`, clientbound (`ClientboundDisconnectPacket`). /// diff --git a/crates/hyperion-minecraft-proto/tests/collision_shape.rs b/crates/hyperion-minecraft-proto/tests/collision_shape.rs new file mode 100644 index 000000000..fa0482634 --- /dev/null +++ b/crates/hyperion-minecraft-proto/tests/collision_shape.rs @@ -0,0 +1,91 @@ +//! The collision shape table's own contract, independent of any consumer. +//! +//! `crates/hyperion/src/simulation/blocks/translate.rs` checks the named +//! geometry through the translation a 1.20.1 world needs. These check the +//! table as the crate publishes it: every id the block state table can produce +//! resolves, and nothing else does. + +use hyperion_minecraft_proto::{ + block_state, + collision_shape::{SHAPES, STATE_SHAPES, collision_shape}, +}; + +#[test] +fn every_state_id_resolves_and_nothing_beyond_them_does() { + for id in 0..block_state::STATE_COUNT { + assert!( + collision_shape(id).is_some(), + "state {id} is inside the registry and has no shape" + ); + } + assert_eq!(collision_shape(block_state::STATE_COUNT), None); + assert_eq!(collision_shape(u32::MAX), None); +} + +#[test] +fn air_is_empty_and_stone_is_the_unit_cube() { + // The two ends of the table, by name rather than by literal id, so this + // still means what it says after a version bump renumbers everything. + let air = block_state::state_id("minecraft:air", &[]).expect("26.2 has air"); + assert_eq!(collision_shape(air), Some(&[][..])); + + let stone = block_state::state_id("minecraft:stone", &[]).expect("26.2 has stone"); + assert_eq!( + collision_shape(stone), + Some(&[[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]][..]) + ); +} + +/// Blocks that did not exist in 1.20.1, which is what the old table could not +/// describe at all. +/// +/// None of them can appear in a hyperion world today -- the world is read out +/// of 1.20.1 anvil regions -- so this is coverage rather than a fix. It is +/// here so that the day a 26.2 state does reach the collision path, the shape +/// is already right instead of missing. +#[test] +fn blocks_added_since_1_20_1_have_shapes() { + for name in [ + "minecraft:crafter", + "minecraft:trial_spawner", + "minecraft:vault", + "minecraft:pale_oak_log", + "minecraft:copper_chest", + ] { + let id = block_state::default_state_id(name).unwrap_or_else(|| panic!("26.2 has {name}")); + let shape = collision_shape(id).unwrap_or_else(|| panic!("{name} has no shape row")); + assert!( + !shape.is_empty(), + "{name} is solid but has no collision box" + ); + } +} + +/// Every shape in the table is reachable, and every box is a real box. +/// +/// A `min` above a `max` on any axis is an inside-out box: it intersects +/// nothing, so a block carrying one is one an entity falls through, and the +/// failure looks like a hole in the world rather than like bad data. +#[test] +fn the_table_is_well_formed() { + let mut used = vec![false; SHAPES.len()]; + for &index in STATE_SHAPES { + used[usize::from(index)] = true; + } + assert!( + used.iter().all(|&used| used), + "{} of {} shapes are referenced by no state", + used.iter().filter(|used| !**used).count(), + SHAPES.len() + ); + + for (index, shape) in SHAPES.iter().enumerate() { + for box_ in *shape { + let [min_x, min_y, min_z, max_x, max_y, max_z] = *box_; + assert!( + min_x < max_x && min_y < max_y && min_z < max_z, + "shape {index} has an inside-out box: {box_:?}" + ); + } + } +} diff --git a/crates/hyperion-minecraft-proto/tests/configuration.rs b/crates/hyperion-minecraft-proto/tests/configuration.rs index 61532e548..dba58dda9 100644 --- a/crates/hyperion-minecraft-proto/tests/configuration.rs +++ b/crates/hyperion-minecraft-proto/tests/configuration.rs @@ -21,9 +21,11 @@ use hyperion_minecraft_proto::{ nbt::{Compound, Tag}, packets::configuration::{ AcceptCodeOfConduct, ChatVisiblity, ClientInformation, CodeOfConduct, CustomPayload, - Disconnect, FinishConfiguration, FinishConfigurationAck, HumanoidArm, KeepAlive, KnownPack, - ParticleStatus, Ping, Pong, RegistryData, RegistryEntry, RegistryTags, ResetChat, - SelectKnownPacks, TagEntry, UpdateEnabledFeatures, UpdateTags, + Disconnect, FinishConfiguration, FinishConfigurationAck, HumanoidArm, KnownPack, + ParticleStatus, RegistryData, RegistryEntry, RegistryTags, ResetChat, SelectKnownPacks, + TagEntry, UpdateEnabledFeatures, UpdateTags, + clientbound::{KeepAlive as ClientboundKeepAlive, Ping}, + serverbound::{KeepAlive as ServerboundKeepAlive, Pong}, }, text::Component, }; @@ -376,23 +378,24 @@ fn empty_update_tags_matches_vanilla() { // --- keep alive, ping, disconnect, code of conduct ------------------------ #[test] -fn keep_alive_matches_vanilla() { - // Both ClientboundKeepAlivePacket and ServerboundKeepAlivePacket printed - // the same bytes for the same id, which is why one type covers both. - round_trip( - &KeepAlive { - id: 0x0123_4567_89ab_cdef, - }, - &hex("0123456789abcdef"), - ); +fn keep_alive_matches_vanilla_in_both_directions() { + // Both ClientboundKeepAlivePacket and ServerboundKeepAlivePacket print the + // same bytes for the same id. That used to be asserted by *asserting it*: + // one hand-written type stood in for both classes, and the claim that they + // agree was the comment above it rather than anything a test ran. The + // generator emits one type per direction, so both go through the same + // fixture and the agreement is checked instead of assumed. + let bytes = hex("0123456789abcdef"); + round_trip(&ClientboundKeepAlive(0x0123_4567_89ab_cdef), &bytes); + round_trip(&ServerboundKeepAlive(0x0123_4567_89ab_cdef), &bytes); } #[test] fn ping_and_pong_are_ints_not_longs() { // The configuration-state ping is an int; the status-state one is a long. let bytes = hex("0abcdef1"); - round_trip(&Ping { id: 0x0abc_def1 }, &bytes); - round_trip(&Pong { id: 0x0abc_def1 }, &bytes); + round_trip(&Ping(0x0abc_def1), &bytes); + round_trip(&Pong(0x0abc_def1), &bytes); } #[test] diff --git a/crates/hyperion-reload-client/Cargo.toml b/crates/hyperion-reload-client/Cargo.toml new file mode 100644 index 000000000..9a553dd80 --- /dev/null +++ b/crates/hyperion-reload-client/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition.workspace = true +license.workspace = true +name = "hyperion-reload-client" +publish = false +repository.workspace = true +version.workspace = true + +# NO DEPENDENCIES, AND THAT IS THE POINT OF A SEPARATE CRATE. +# +# This is what `ExecReload` runs, so it is the process that has to start in +# order for a reload to be attempted at all. Linking it against `flecs_ecs` +# -- directly, or through `hyperion-hot-reload` -- would make a mismatched +# engine dylib turn "the reload was refused, here is why" into "the client +# could not start", which is the one failure mode a reload client must not +# have. It speaks the protocol over a socket and knows nothing else. + +[dependencies] + +[lints] +workspace = true diff --git a/crates/hyperion-reload-client/src/main.rs b/crates/hyperion-reload-client/src/main.rs new file mode 100644 index 000000000..2c8144e85 --- /dev/null +++ b/crates/hyperion-reload-client/src/main.rs @@ -0,0 +1,157 @@ +//! `ExecReload`: ask a running game server to reload its rules, and report what it said. +//! +//! # Why this is its own binary rather than a flag on the server +//! +//! `systemctl reload` runs a *new process*, and its exit status is the reload's exit +//! status. So the thing systemd runs has to be something that can always start: this crate +//! has no dependencies at all, which means a rules dylib built against the wrong engine +//! cannot turn "the reload was refused, and here is the reason" into "the client failed to +//! start". The refusal has to survive to be read. +//! +//! # What it says, and what it exits with +//! +//! One verb out, one line back, straight from `hyperion_hot_reload::service`: +//! +//! ```text +//! accepted -> printed, exit 0 +//! refused -> printed, exit 1 +//! ``` +//! +//! The reply is printed either way, because the reason is the only part of a refused +//! deploy anybody can act on and `systemctl reload` shows it to whoever asked. + +// This program's entire output contract is one line on stdout -- the server's own answer, +// which a person or a deploy script reads. The workspace denies `print_stdout` because a +// library or a game server writing to stdout is a bug; for a one-shot CLI it is the API. +#![allow(clippy::print_stdout)] + +use std::{ + io::{BufRead, BufReader, Write}, + os::unix::net::UnixStream, + path::Path, + process::ExitCode, + time::Duration, +}; + +/// The only thing the server answers. +const VERB: &[u8] = b"reload"; + +/// How long to wait for the answer. +/// +/// The server polls the socket between two ticks, so the floor is one tick -- 50ms -- plus +/// however long the `dlopen`, the schema diff and any component migration take. Thirty +/// seconds is far above every measurement in `docs/hot-reload.md` and far below systemd's +/// default `TimeoutSec`, which is what would otherwise decide this: a client that hangs +/// forever turns a wedged server into a wedged `ix apply` with no message anywhere. +const REPLY_TIMEOUT: Duration = Duration::from_secs(30); + +/// Whether a reply means the reload happened. +/// +/// The first word and not a substring search: "refused could not open module" contains the +/// word "accepted" in no reading, but a reason quoted back from a build could contain +/// anything, and a status derived from a `contains` would be a status that can be talked +/// into lying. +fn accepted(reply: &str) -> bool { + reply.split_whitespace().next() == Some("accepted") +} + +/// Sends the verb and returns the single line the server answered with. +fn ask(socket: &Path) -> std::io::Result { + let mut stream = UnixStream::connect(socket)?; + stream.set_read_timeout(Some(REPLY_TIMEOUT))?; + stream.write_all(VERB)?; + stream.flush()?; + + let mut reply = String::new(); + BufReader::new(stream).read_line(&mut reply)?; + Ok(reply.trim_end().to_owned()) +} + +fn main() -> ExitCode { + let mut args = std::env::args_os().skip(1); + let Some(socket) = args.next() else { + eprintln!("usage: hyperion-reload-client "); + return ExitCode::FAILURE; + }; + + match ask(Path::new(&socket)) { + Ok(reply) if accepted(&reply) => { + println!("{reply}"); + ExitCode::SUCCESS + } + // A refusal is the server working: it looked at the build and said no. Printed the + // same way, because the words are the gate's own and they name the component. + Ok(reply) => { + println!("{reply}"); + ExitCode::FAILURE + } + // Nothing answered. `ECONNREFUSED` on an existing path is the interesting one -- + // the socket file outlived the process that bound it -- so the path is named. + Err(e) => { + eprintln!( + "could not reach the game server on {}: {e}", + socket.display() + ); + ExitCode::FAILURE + } + } +} + +#[cfg(test)] +mod tests { + use std::{io::Read, os::unix::net::UnixListener, thread}; + + use super::*; + + /// A socket that answers one request with `reply` and then stops. + fn server_saying( + name: &str, + reply: &'static str, + ) -> (std::path::PathBuf, thread::JoinHandle>) { + let path = std::env::temp_dir().join(format!("hyperion-reload-client-{name}.sock")); + drop(std::fs::remove_file(&path)); + let listener = UnixListener::bind(&path).expect("bind"); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + // One `read`, exactly like `hyperion_hot_reload::service::read_verb`. Reading + // to EOF instead would test a server this client never talks to. + let mut buffer = [0u8; 64]; + let read = stream.read(&mut buffer).expect("read"); + writeln!(stream, "{reply}").expect("write"); + buffer[..read].to_vec() + }); + (path, handle) + } + + /// The verb reaches the server and the answer comes back whole. + #[test] + fn the_server_hears_the_verb_and_its_answer_comes_back() { + let (path, handle) = server_saying("round-trip", "accepted smash-rules abc1234"); + let reply = ask(&path).expect("ask"); + assert_eq!(reply, "accepted smash-rules abc1234"); + assert_eq!(handle.join().expect("join"), VERB); + drop(std::fs::remove_file(&path)); + } + + /// A socket file with nothing behind it is what a killed server leaves. It has to read + /// as a failed reload rather than as a hang or a success. + #[test] + fn a_socket_nobody_is_listening_on_is_an_error() { + let path = std::env::temp_dir().join("hyperion-reload-client-dead.sock"); + drop(std::fs::remove_file(&path)); + drop(UnixListener::bind(&path).expect("bind")); + assert!(ask(&path).is_err()); + drop(std::fs::remove_file(&path)); + } + + /// The exit status comes from the first word, and only from the first word. + #[test] + fn only_an_accepted_first_word_is_a_success() { + assert!(accepted("accepted smash-rules abc1234")); + assert!(accepted("accepted smash-rules unknown")); + assert!(!accepted("refused component smash::Health changed layout")); + assert!(!accepted("")); + // A refusal whose reason quotes a build cannot talk its way into a zero exit. + assert!(!accepted("refused unknown request `accepted`")); + } +} diff --git a/crates/hyperion-web-console/Cargo.toml b/crates/hyperion-web-console/Cargo.toml new file mode 100644 index 000000000..bae1c2278 --- /dev/null +++ b/crates/hyperion-web-console/Cargo.toml @@ -0,0 +1,25 @@ +[package] +license.workspace = true +publish = false +name = "hyperion-web-console" +version.workspace = true +edition.workspace = true + +[dependencies] +flecs_ecs = { workspace = true } +http-body-util = { workspace = true } +hyper = { workspace = true, features = ["http1", "server"] } +hyper-util = { workspace = true, features = ["tokio"] } +hyperion = { workspace = true } +hyperion-command = { workspace = true } +hyperion-minecraft-proto = { workspace = true } +hyperion-permission = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt", "net", "sync", "macros", "io-util", "time"] } +tracing = { workspace = true } + +[dev-dependencies] +serial_test = { workspace = true } + +[lints] +workspace = true diff --git a/crates/hyperion-web-console/assets/console.html b/crates/hyperion-web-console/assets/console.html new file mode 100644 index 000000000..c72cc39c9 --- /dev/null +++ b/crates/hyperion-web-console/assets/console.html @@ -0,0 +1,325 @@ + + + +hyperion console + + + +
+ hyperion console + tps + build + connecting… +
+ +
+ +
+ + + +
+ + + +
+
this console needs its bearer token
+ + +
+ + diff --git a/crates/hyperion-web-console/src/feed.rs b/crates/hyperion-web-console/src/feed.rs new file mode 100644 index 000000000..918874134 --- /dev/null +++ b/crates/hyperion-web-console/src/feed.rs @@ -0,0 +1,186 @@ +//! What the console has seen, and everyone watching it. + +use std::{ + collections::VecDeque, + sync::{ + Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::{SystemTime, UNIX_EPOCH}, +}; + +use tokio::sync::broadcast; + +/// How many lines a browser is handed when it connects. +/// +/// A console opened after something went wrong is the normal case, so the +/// backlog is what makes the page useful at all rather than a nicety. Bounded +/// because this is a live server's memory: two hundred lines is a screenful +/// several times over and costs a few tens of kilobytes. +pub const BACKLOG: usize = 200; + +/// How many lines a slow browser may fall behind before it is cut loose. +/// +/// A dropped subscriber is told how many it missed rather than silently handed +/// a gap; see [`Feed::subscribe`]. +const CHANNEL_CAPACITY: usize = 512; + +/// Where a line came from, which is the only thing the page styles on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Source { + /// A player typed it. + Chat, + /// The console typed it, and every player saw it. + Console, + /// A reply addressed to the console: the output of a command it ran. + Reply, + /// The server saying something about itself. A join, a leave, a refusal. + System, +} + +impl Source { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Chat => "chat", + Self::Console => "console", + Self::Reply => "reply", + Self::System => "system", + } + } +} + +/// One line, as the page will draw it. +/// +/// The text keeps its section-sign codes. Rendering them is the browser's job +/// -- it is the only place that knows what a colour looks like -- and stripping +/// them here would throw away the thing that makes the page look like the game. +#[derive(Debug, Clone)] +pub struct Line { + /// Monotonic within a process, so a page can tell it has the whole story. + /// Not a timestamp: two lines in one tick share a millisecond. + pub seq: u64, + /// Unix milliseconds, for the clock the page draws. + pub at: u64, + pub source: Source, + pub text: String, +} + +impl Line { + /// This line as one SSE `data:` payload. + #[must_use] + pub fn to_json(&self) -> String { + serde_json::json!({ + "seq": self.seq, + "at": self.at, + "source": self.source.as_str(), + "text": self.text, + }) + .to_string() + } +} + +/// Every line the console has, and a tap for anyone watching. +#[derive(Debug)] +pub struct Feed { + next_seq: AtomicU64, + backlog: Mutex>, + live: broadcast::Sender, +} + +impl Default for Feed { + fn default() -> Self { + Self::new() + } +} + +impl Feed { + #[must_use] + pub fn new() -> Self { + let (live, _) = broadcast::channel(CHANNEL_CAPACITY); + Self { + next_seq: AtomicU64::new(1), + backlog: Mutex::new(VecDeque::with_capacity(BACKLOG)), + live, + } + } + + /// Record a line and hand it to everyone watching. + pub fn push(&self, source: Source, text: impl Into) { + let line = Line { + seq: self.next_seq.fetch_add(1, Ordering::Relaxed), + at: now_ms(), + source, + text: text.into(), + }; + + if let Ok(mut backlog) = self.backlog.lock() { + if backlog.len() == BACKLOG { + backlog.pop_front(); + } + backlog.push_back(line.clone()); + } + + // `Err` means nobody is watching, which is the usual state of an + // operator console and not a problem. + let _unused = self.live.send(line); + } + + /// The lines a page gets before the live ones start. + #[must_use] + pub fn backlog(&self) -> Vec { + self.backlog + .lock() + .map(|backlog| backlog.iter().cloned().collect()) + .unwrap_or_default() + } + + /// A live tap. Every subscriber gets every line pushed after this call. + #[must_use] + pub fn subscribe(&self) -> broadcast::Receiver { + self.live.subscribe() + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| u64::try_from(since.as_millis()).unwrap_or(0)) +} + +#[cfg(test)] +mod tests { + use super::{BACKLOG, Feed, Source}; + + #[test] + fn the_backlog_is_bounded_and_keeps_the_newest() { + let feed = Feed::new(); + for index in 0..(BACKLOG + 10) { + feed.push(Source::System, format!("line {index}")); + } + + let backlog = feed.backlog(); + assert_eq!(backlog.len(), BACKLOG); + assert_eq!(backlog[0].text, "line 10"); + assert_eq!(backlog[BACKLOG - 1].text, format!("line {}", BACKLOG + 9)); + } + + #[test] + fn sequence_numbers_are_dense_and_increasing() { + let feed = Feed::new(); + feed.push(Source::Chat, "one"); + feed.push(Source::Chat, "two"); + + let backlog = feed.backlog(); + assert_eq!(backlog[1].seq, backlog[0].seq + 1); + } + + #[test] + fn a_section_sign_survives_into_the_feed() { + // The browser renders these. Stripping them here would be the console + // quietly deciding the page should look like a terminal. + let feed = Feed::new(); + feed.push(Source::Console, "\u{a7}cred"); + assert_eq!(feed.backlog()[0].text, "\u{a7}cred"); + } +} diff --git a/crates/hyperion-web-console/src/http.rs b/crates/hyperion-web-console/src/http.rs new file mode 100644 index 000000000..34a3f7ef4 --- /dev/null +++ b/crates/hyperion-web-console/src/http.rs @@ -0,0 +1,401 @@ +//! The web server, and the whole of what it will answer. +//! +//! Five paths matched on a string. That is why this is hyper and not axum: +//! there is no routing to do, no extractors to derive, and hyper plus its two +//! helper crates are already in the lock through `reqwest`, so the console +//! costs no new dependency tree at all. +//! +//! # Authentication +//! +//! One shared secret, read from a file the server never writes and compared in +//! constant time. There are no accounts and no sessions on purpose: this is one +//! operator's window onto their own server, and everything an accounts system +//! would add is a second thing to get wrong. +//! +//! `POST` requests carry it as `Authorization: Bearer `. The event +//! stream carries it as `?token=` instead, because `EventSource` cannot set a +//! header -- the browser API simply has no argument for it. So the token +//! reaches the server's own access log, if it has one, and the browser's +//! history. That is stated rather than hidden, and it is why the bind address +//! defaults to loopback: this is not a port to put on the internet. + +use std::{ + convert::Infallible, + net::SocketAddr, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use http_body_util::{BodyExt, combinators::BoxBody}; +use hyper::{ + Method, Request as HttpRequest, Response, StatusCode, + body::{Body as HttpBody, Bytes, Frame, Incoming}, + server::conn::http1, + service::service_fn, +}; +use hyper_util::rt::TokioIo; +use tokio::{ + net::TcpListener, + sync::{broadcast::error::RecvError, mpsc}, +}; + +use crate::{ + Console, + feed::{Line, Source}, + state::Request, +}; + +/// The page itself, compiled in. +/// +/// One file, hand written, no build step. A console that needs `npm` before it +/// can be looked at is a console that stops working the first time nobody has +/// run `npm` recently. +const PAGE: &str = include_str!("../assets/console.html"); + +/// The largest body this will read. +/// +/// A chat line is 256 bytes at the protocol level and a command is not much +/// more, so anything past this is not an operator typing. +const MAX_BODY: usize = 8 * 1024; + +type Body = BoxBody; + +/// Serve on an already bound listener until the process ends. +/// +/// Takes the listener rather than an address because the bind is the one +/// failure a caller can still do something about, and it has to happen before +/// the thread this runs on is spawned. See [`crate::spawn`]. +pub async fn serve(console: Arc, listener: TcpListener) { + match listener.local_addr() { + Ok(address) => tracing::info!("console listening on http://{address}/"), + Err(error) => tracing::warn!("console listening, but on what: {error}"), + } + + loop { + let (stream, peer) = match listener.accept().await { + Ok(accepted) => accepted, + Err(error) => { + // One connection failing to come up is not the listener + // failing, so this must not end the loop. + tracing::warn!("console accept failed: {error}"); + continue; + } + }; + + let console = Arc::clone(&console); + tokio::spawn(async move { + let service = service_fn(move |request| route(Arc::clone(&console), request, peer)); + if let Err(error) = http1::Builder::new() + .serve_connection(TokioIo::new(stream), service) + .await + { + tracing::debug!("console connection from {peer} ended: {error}"); + } + }); + } +} + +async fn route( + console: Arc, + request: HttpRequest, + peer: SocketAddr, +) -> Result, Infallible> { + // Owned, because the body is read by value further down and a borrow of + // the request cannot outlive that. + let (path, query) = { + let (path, query) = + split_query(request.uri().path_and_query().map_or("/", |pq| pq.as_str())); + (path.to_owned(), query.to_owned()) + }; + + Ok(match (request.method(), path.as_str()) { + // The page is not behind the token. It has nothing in it: it is markup + // that asks for a token and then goes and gets the data. Gating it + // would mean an operator could not reach the box that asks for the + // secret without already having sent the secret. + (&Method::GET, "/") => html(PAGE), + + (&Method::GET, "/events") => { + if console.authorises(query_token(&query).unwrap_or_default().as_bytes()) { + events(&console) + } else { + refuse(peer, "/events") + } + } + + (&Method::GET, "/state") => { + if authorised_header(&console, &request) { + json(&console.snapshot()) + } else { + refuse(peer, "/state") + } + } + + (&Method::POST, "/say" | "/command") => { + if authorised_header(&console, &request) { + match read_body(request).await { + Err(message) => text(StatusCode::PAYLOAD_TOO_LARGE, message), + Ok(body) if body.trim().is_empty() => { + text(StatusCode::BAD_REQUEST, "nothing to send") + } + Ok(body) => { + console.submit(if path == "/say" { + Request::Say(body.trim().to_owned()) + } else { + Request::Command(body.trim().to_owned()) + }); + text(StatusCode::ACCEPTED, "queued") + } + } + } else { + refuse(peer, &path) + } + } + + _ => text(StatusCode::NOT_FOUND, "no such path"), + }) +} + +/// A refusal, said once per attempt at a real severity. +/// +/// `warn` and not `debug`: somebody probing an admin port is the thing an +/// operator wants to find in a journal, and a line below `info` is invisible to +/// every query that filters on severity. +fn refuse(peer: SocketAddr, path: &str) -> Response { + tracing::warn!("console rejected an unauthenticated {path} from {peer}"); + text(StatusCode::UNAUTHORIZED, "a bearer token is required") +} + +fn authorised_header(console: &Console, request: &HttpRequest) -> bool { + let offered = request + .headers() + .get(hyper::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .unwrap_or_default(); + console.authorises(offered.as_bytes()) +} + +/// The live stream: the backlog first, then everything as it happens. +/// +/// The backlog is taken *after* subscribing, so a line pushed between the two +/// can be sent twice but can never be lost. Duplicates the page can see and +/// drop by sequence number; a hole it cannot. +fn events(console: &Console) -> Response { + let mut live = console.feed.subscribe(); + let backlog = console.feed.backlog(); + + let (sender, receiver) = mpsc::channel::(64); + + tokio::spawn(async move { + for line in backlog { + if sender.send(sse(&line)).await.is_err() { + return; + } + } + + loop { + let chunk = match live.recv().await { + Ok(entry) => sse(&entry), + // The browser fell behind far enough to lose lines. Saying so + // is the point: a gap the page does not know about is a page + // that has quietly stopped being the truth. + Err(RecvError::Lagged(missed)) => Bytes::from(format!( + "data: {}\n\n", + Line { + seq: 0, + at: 0, + source: Source::System, + text: format!("\u{a7}8[{missed} lines dropped: this browser fell behind]"), + } + .to_json() + )), + Err(RecvError::Closed) => return, + }; + + if sender.send(chunk).await.is_err() { + return; + } + } + }); + + Response::builder() + .header("content-type", "text/event-stream") + .header("cache-control", "no-store") + // Any reverse proxy in front of this must not buffer, or a live stream + // arrives in silent bursts and looks like a hang. + .header("x-accel-buffering", "no") + .body(BodyExt::boxed(EventStream { receiver })) + .expect("a response with only static headers cannot fail to build") +} + +/// The SSE body: whatever the sender above puts on the channel. +/// +/// Hand written rather than reached for through a stream adapter crate, +/// because the whole of it is "forward one channel" and the alternative is a +/// dependency for eleven lines. +struct EventStream { + receiver: mpsc::Receiver, +} + +impl HttpBody for EventStream { + type Data = Bytes; + type Error = Infallible; + + fn poll_frame( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll, Infallible>>> { + self.receiver + .poll_recv(context) + .map(|chunk| chunk.map(|bytes| Ok(Frame::data(bytes)))) + } +} + +fn sse(line: &Line) -> Bytes { + Bytes::from(format!("data: {}\n\n", line.to_json())) +} + +async fn read_body(request: HttpRequest) -> Result { + let collected = request + .into_body() + .collect() + .await + .map_err(|_unused| "could not read the body")?; + let bytes = collected.to_bytes(); + if bytes.len() > MAX_BODY { + return Err("body too large"); + } + String::from_utf8(bytes.to_vec()).map_err(|_unused| "body was not utf-8") +} + +fn split_query(path_and_query: &str) -> (&str, &str) { + path_and_query + .split_once('?') + .map_or((path_and_query, ""), |(path, query)| (path, query)) +} + +fn query_token(query: &str) -> Option { + query + .split('&') + .find_map(|pair| pair.strip_prefix("token=").map(percent_decode)) +} + +/// Enough of percent decoding for a token. +/// +/// `%XX` and nothing else. A `+` decodes to a literal plus rather than to a +/// space: the page sends the token through `encodeURIComponent`, which spells +/// a space `%20` and leaves `+` untouched, so the form-encoding rule can never +/// recover a space anyone sent and can only corrupt a token that contains a +/// plus. Standard base64 emits `+` and `/`, so the obvious way to generate a +/// token produces exactly the value that rule breaks, and it breaks it as a +/// 401 with nothing in the log to say why. That is not hypothetical: it is +/// what `tools/console-check.py` hit on its first run against a live server. +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' if index + 2 < bytes.len() => { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap_or(""); + match u8::from_str_radix(hex, 16) { + Ok(byte) => { + out.push(byte); + index += 3; + } + Err(_unused) => { + out.push(b'%'); + index += 1; + } + } + } + byte => { + out.push(byte); + index += 1; + } + } + } + String::from_utf8(out).unwrap_or_default() +} + +fn html(body: &'static str) -> Response { + Response::builder() + .header("content-type", "text/html; charset=utf-8") + .body(full(Bytes::from_static(body.as_bytes()))) + .expect("a response with only static headers cannot fail to build") +} + +fn json(body: &str) -> Response { + Response::builder() + .header("content-type", "application/json") + .header("cache-control", "no-store") + .body(full(Bytes::from(body.to_owned()))) + .expect("a response with only static headers cannot fail to build") +} + +fn text(status: StatusCode, body: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain; charset=utf-8") + .body(full(Bytes::from(body.to_owned()))) + .expect("a response with only static headers cannot fail to build") +} + +fn full(bytes: Bytes) -> Body { + BodyExt::boxed(http_body_util::Full::new(bytes)) +} + +#[cfg(test)] +mod tests { + use super::{percent_decode, query_token}; + + /// What `head -c 24 /dev/urandom | base64` produces: standard base64, + /// which uses `+` and `/`. The console reads its token from a file an + /// operator generated, so this is the ordinary case rather than an edge. + const BASE64_TOKEN: &str = "z/qKP4ZL2WfCDIGq8Sh0+dt1i96XQRq1"; + + /// The bug this pins. Treating `+` as a space -- the form-encoding rule -- + /// turned a token a browser had sent correctly into a 401 that said + /// nothing about why. + #[test] + fn a_plus_is_a_plus_and_not_a_space() { + assert_eq!(percent_decode("a+b"), "a+b"); + assert_eq!(percent_decode(BASE64_TOKEN), BASE64_TOKEN); + } + + /// What the page actually sends: `encodeURIComponent` escapes both of + /// base64's awkward characters, and the decode has to give the token back + /// unchanged or the page cannot open its own event stream. + #[test] + fn the_encoding_the_page_uses_round_trips() { + let encoded = "z%2FqKP4ZL2WfCDIGq8Sh0%2Bdt1i96XQRq1"; + assert_eq!(percent_decode(encoded), BASE64_TOKEN); + assert_eq!( + query_token(&format!("token={encoded}")).as_deref(), + Some(BASE64_TOKEN) + ); + } + + /// A space still arrives when somebody really sent one: dropping the `+` + /// rule costs nothing, because `encodeURIComponent(' ')` is `%20`. + #[test] + fn a_percent_twenty_is_still_a_space() { + assert_eq!(percent_decode("a%20b"), "a b"); + } + + /// A `%` that does not begin an escape is a `%`, rather than eating the + /// bytes after it and silently shortening the token. + #[test] + fn a_stray_percent_is_literal() { + assert_eq!(percent_decode("100%"), "100%"); + assert_eq!(percent_decode("a%zzb"), "a%zzb"); + } + + #[test] + fn a_token_is_found_among_other_parameters() { + assert_eq!(query_token("since=4&token=abc").as_deref(), Some("abc")); + assert_eq!(query_token("since=4"), None); + } +} diff --git a/crates/hyperion-web-console/src/legacy.rs b/crates/hyperion-web-console/src/legacy.rs new file mode 100644 index 000000000..18cae9e5b --- /dev/null +++ b/crates/hyperion-web-console/src/legacy.rs @@ -0,0 +1,200 @@ +//! A component, written back out as the section-sign codes a page renders. +//! +//! The console reads replies off the wire as `SystemChat`, which carries a +//! component: a tree with colour and decoration as *fields*. The page renders +//! section signs. So something has to bridge the two, and +//! [`Component::plain`](hyperion::hyperion_minecraft_proto::text::Component::plain) +//! is not it -- it throws every field away, which turns a red refusal into a +//! sentence in the same colour as everything else and loses the one thing that +//! made it read as a refusal. +//! +//! Most replies survive `plain` by accident, because +//! [`hyperion::net::agnostic::chat`] builds its component from a string that +//! already has `§c` inside it. The ones that do not are exactly the ones a +//! game built properly, through a typed seam, which is the direction the +//! workspace is moving in. +//! +//! This is lossy in the other direction and deliberately so. Hover text, click +//! actions, fonts and true-colour RGB have no legacy spelling; RGB is mapped to +//! its nearest named colour rather than dropped, because a wrong-ish red reads +//! far closer to the intent than no colour at all. + +use hyperion::hyperion_minecraft_proto::text::{Component, NamedColor, Rgb24, TextColor}; + +/// The character the client's legacy formatter looks for. Spelled as an escape +/// for the same reason `hyperion::simulation::chat` spells it that way. +const SECTION_SIGN: char = '\u{a7}'; + +/// `component`, flattened into text with legacy codes. +#[must_use] +pub fn render(component: &Component<'_>) -> String { + let mut out = String::new(); + + // `runs` is the component already flattened with inheritance applied, so + // this never walks the tree itself. Walking it was the first version and + // it got nesting wrong at depth three: a child inherits its parent's + // style, a sibling must not, and re-deriving that by hand is exactly the + // work `runs` has already done correctly. + for run in component.runs() { + // A colour code resets every decoration in the legacy scheme, so the + // colour is written first and the decorations after it. The other + // order silently drops the decorations, which renders as "nearly + // right". + // + // `§r` first, because a run carries its whole resolved style and the + // previous run's codes are still in force at this point. + out.push(SECTION_SIGN); + out.push('r'); + + if let Some(colour) = run.style.color.map(code_for) { + out.push(SECTION_SIGN); + out.push(colour); + } + for (enabled, code) in [ + (run.style.bold, 'l'), + (run.style.italic, 'o'), + (run.style.underlined, 'n'), + (run.style.strikethrough, 'm'), + (run.style.obfuscated, 'k'), + ] { + if enabled == Some(true) { + out.push(SECTION_SIGN); + out.push(code); + } + } + + out.push_str(&run.text); + } + + out +} + +/// The legacy code for a colour. +/// +/// Total, because every colour has an answer: a named one has its own code and +/// a true-colour one has its nearest swatch. There is deliberately no `None` +/// here -- an `Option` would invite a caller to drop the colour, and dropping +/// it renders as "this line was never coloured", which is a worse lie than a +/// slightly wrong red. +fn code_for(colour: TextColor) -> char { + let named = match colour { + TextColor::Named(named) => named, + // No legacy code exists, so the nearest named one is the closest + // truthful answer. The alternative is dropping the colour, which reads + // as "this line was never coloured". + TextColor::Rgb(rgb) => nearest_named(rgb), + }; + + match named { + NamedColor::Black => '0', + NamedColor::DarkBlue => '1', + NamedColor::DarkGreen => '2', + NamedColor::DarkAqua => '3', + NamedColor::DarkRed => '4', + NamedColor::DarkPurple => '5', + NamedColor::Gold => '6', + NamedColor::Gray => '7', + NamedColor::DarkGray => '8', + NamedColor::Blue => '9', + NamedColor::Green => 'a', + NamedColor::Aqua => 'b', + NamedColor::Red => 'c', + NamedColor::LightPurple => 'd', + NamedColor::Yellow => 'e', + NamedColor::White => 'f', + } +} + +/// The named colour closest to `rgb`, by squared distance in plain RGB. +/// +/// Not a perceptual space. This is picking one of sixteen swatches for a +/// console pane, and the difference between a plain and a perceptual metric at +/// that resolution is not something a person reading chat can see. +fn nearest_named(rgb: Rgb24) -> NamedColor { + const SWATCHES: [(NamedColor, [i32; 3]); 16] = [ + (NamedColor::Black, [0x00, 0x00, 0x00]), + (NamedColor::DarkBlue, [0x00, 0x00, 0xaa]), + (NamedColor::DarkGreen, [0x00, 0xaa, 0x00]), + (NamedColor::DarkAqua, [0x00, 0xaa, 0xaa]), + (NamedColor::DarkRed, [0xaa, 0x00, 0x00]), + (NamedColor::DarkPurple, [0xaa, 0x00, 0xaa]), + (NamedColor::Gold, [0xff, 0xaa, 0x00]), + (NamedColor::Gray, [0xaa, 0xaa, 0xaa]), + (NamedColor::DarkGray, [0x55, 0x55, 0x55]), + (NamedColor::Blue, [0x55, 0x55, 0xff]), + (NamedColor::Green, [0x55, 0xff, 0x55]), + (NamedColor::Aqua, [0x55, 0xff, 0xff]), + (NamedColor::Red, [0xff, 0x55, 0x55]), + (NamedColor::LightPurple, [0xff, 0x55, 0xff]), + (NamedColor::Yellow, [0xff, 0xff, 0x55]), + (NamedColor::White, [0xff, 0xff, 0xff]), + ]; + + let channels = rgb.channels(); + let want = [ + i32::from(channels[0]), + i32::from(channels[1]), + i32::from(channels[2]), + ]; + SWATCHES + .into_iter() + .min_by_key(|(_named, swatch)| { + (0..3) + .map(|axis| (swatch[axis] - want[axis]).pow(2)) + .sum::() + }) + .map_or(NamedColor::White, |(named, _swatch)| named) +} + +#[cfg(test)] +mod tests { + use hyperion::hyperion_minecraft_proto::text::{ + Component, NamedColor, Rgb24, Style, TextColor, + }; + + use super::render; + + fn coloured(colour: TextColor) -> Style<'static> { + Style { + color: Some(colour), + ..Style::new() + } + } + + #[test] + fn plain_text_gets_one_reset_and_nothing_else() { + assert_eq!(render(&Component::text("hello")), "\u{a7}rhello"); + } + + #[test] + fn a_colour_becomes_its_legacy_code() { + let component = + Component::text("nope").with_style(coloured(TextColor::Named(NamedColor::Red))); + assert_eq!(render(&component), "\u{a7}r\u{a7}cnope"); + } + + #[test] + fn a_sibling_does_not_inherit_the_previous_run() { + // The bug this exists to prevent: without the reset per run, "after" + // is drawn red because "before" was. + let component = Component::text("") + .append( + Component::text("before").with_style(coloured(TextColor::Named(NamedColor::Red))), + ) + .append(Component::text("after")); + // One reset, not two: the empty root carries no text, and `runs` + // only emits a run for a non-empty `Contents::Text`, so the root + // contributes nothing to render. Asserting two was this test's own + // authoring error and it is the only thing that was ever red here. + assert_eq!(render(&component), "\u{a7}r\u{a7}cbefore\u{a7}rafter"); + } + + #[test] + fn true_colour_falls_back_to_the_nearest_swatch() { + // Dropping it would render as "this line was never coloured", which is + // a worse lie than a slightly wrong red. + let component = Component::text("close enough") + .with_style(coloured(TextColor::Rgb(Rgb24::new(0xf0, 0x50, 0x50)))); + assert_eq!(render(&component), "\u{a7}r\u{a7}cclose enough"); + } +} diff --git a/crates/hyperion-web-console/src/lib.rs b/crates/hyperion-web-console/src/lib.rs new file mode 100644 index 000000000..9ca127266 --- /dev/null +++ b/crates/hyperion-web-console/src/lib.rs @@ -0,0 +1,207 @@ +//! An operator's window onto a running hyperion server, in a browser. +//! +//! Watch chat as it happens, say something back, and run any command a player +//! could run -- as an operator, from outside the game, without a Minecraft +//! client. The page is styled like the game because that is what the thing it +//! shows looks like: section-sign colours are rendered rather than stripped, so +//! a line reads on the web the way it reads in the chat box. +//! +//! # Why this is engine-level +//! +//! Watching and administering a server is not a game concept. `smash` and +//! `bedwars` are two games on one engine and an operator's question --- who is +//! on, is it keeping up, what build is this, say something to everybody --- is +//! the same question for both. So nothing here knows what game is running: +//! chat is taken at the point hyperion decodes it, the roster is hyperion's own +//! [`Ping`](hyperion::egress::ping::Ping) and [`Name`], and commands go through +//! the registry every event already registers into. The precedent is +//! `hyperion::egress::server_load`, which is telemetry in the engine for the +//! same reason. +//! +//! # Commands run through the same dispatch players use +//! +//! There is no admin API here. A command typed on the web becomes +//! [`event::Command`], the same event a `chat_command` packet produces, and is +//! executed by `hyperion_command`'s own system against the same +//! [`CommandRegistry`](hyperion_command::CommandRegistry). One implementation +//! of `/perms`, not two. +//! +//! What that costs is the console needing an identity, because a command reads +//! its caller's permission group and answers to its caller's connection. So the +//! module makes one entity --- named `Console`, group `Admin` --- and gives it a +//! [`ConnectionId`](hyperion::net::ConnectionId) with no socket behind it, +//! registered through [`hyperion::net::VirtualConnection`]. Replies addressed +//! to it are intercepted before the proxy and decoded back into text for the +//! page, using the same [`FrameDecoder`](hyperion_minecraft_proto::framing::FrameDecoder) +//! a client would. Every command in the workspace answers +//! `caller.get::<&ConnectionId>()`; teaching all of them a second kind of reply +//! would have been the alternative, and it would have been a worse one. +//! +//! # Authentication, and what it is not +//! +//! One bearer token, read from a file at startup, compared in constant time. +//! No accounts, no sessions, no login page. The bind address defaults to +//! loopback and the NixOS option that exposes it is off by default, because a +//! console is an admin surface and the honest default for an admin surface is +//! "reachable from the box it runs on". See [`http`] for where the token +//! travels and the one place it is not in a header. + +pub mod feed; +pub mod http; +pub mod legacy; +pub mod module; +pub mod state; +mod virtual_connection; + +use std::{ + net::SocketAddr, + sync::{Arc, Mutex}, +}; + +pub use module::{ConsoleComponentsModule, ConsoleModule, install}; + +use crate::{ + feed::Feed, + state::{Inbox, Request, Snapshot}, +}; + +/// How the console was told to run. +#[derive(Debug, Clone)] +pub struct Config { + /// Where to listen. Loopback unless an operator says otherwise. + pub address: SocketAddr, + /// The shared secret every request must carry. + pub token: String, +} + +/// Everything the web server and the tick loop share. +/// +/// One `Arc`, handed to both. The web server never touches the world and the +/// tick loop never touches a socket; [`Inbox`] and [`Snapshot`] are the two +/// places they meet, and both are plain mutexes over small values written once +/// a tick at most. +#[derive(Debug)] +pub struct Console { + pub feed: Feed, + inbox: Inbox, + snapshot: Mutex, + token: String, +} + +impl Console { + #[must_use] + pub fn new(token: String) -> Self { + Self { + feed: Feed::new(), + inbox: Inbox::default(), + snapshot: Mutex::new(Snapshot::default()), + token, + } + } + + /// Whether `offered` is the configured token. + /// + /// Constant time in the length of the token, so a caller cannot learn it + /// one byte at a time from how long the comparison took. An empty token is + /// refused outright rather than matching an empty configuration, because + /// the failure mode of the other choice is a console with no password that + /// looks like it has one. + #[must_use] + pub fn authorises(&self, offered: &[u8]) -> bool { + if self.token.is_empty() || offered.is_empty() { + return false; + } + let expected = self.token.as_bytes(); + if expected.len() != offered.len() { + return false; + } + let mut difference = 0_u8; + for (left, right) in expected.iter().zip(offered) { + difference |= left ^ right; + } + difference == 0 + } + + /// Queue something for the next tick to carry out. + pub fn submit(&self, request: Request) { + self.inbox.push(request); + } + + /// Everything queued since the last tick. + #[must_use] + pub fn take_requests(&self) -> Vec { + self.inbox.drain() + } + + /// The state the page draws, as JSON. + #[must_use] + pub fn snapshot(&self) -> String { + self.snapshot + .lock() + .map_or_else(|_unused| "{}".to_owned(), |snapshot| snapshot.to_json()) + } + + /// Replace the state the page draws. + pub fn publish(&self, snapshot: Snapshot) { + if let Ok(mut held) = self.snapshot.lock() { + *held = snapshot; + } + } +} + +/// Start the web server on its own runtime. +/// +/// Its own, rather than the one hyperion runs the proxy connection on: an +/// operator console must not be able to starve the tick loop's I/O, and a +/// single worker thread is more than a page of text needs. +/// +/// # Errors +/// Fails if the runtime cannot be built or the address cannot be bound. Both +/// are startup failures an operator has to hear about: a console that silently +/// did not come up is indistinguishable from one nobody opened. Everything +/// after the bind is a per-connection failure and is logged rather than +/// returned, because there is nobody left to return it to. +pub fn spawn(console: Arc, address: SocketAddr) -> std::io::Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + + // Bound here and handed over, rather than bound here and bound again in + // the thread. Binding twice was the first version of this and it is two + // bugs: the port is released between the two calls, so something else can + // take it and the second bind fails for a reason the first one proved was + // not true; and on a platform with `SO_REUSEADDR` semantics that permit it, + // both binds succeed and the console serves on a socket nobody checked. + let listener = runtime.block_on(async { tokio::net::TcpListener::bind(address).await })?; + + std::thread::Builder::new() + .name("hyperion-console".to_owned()) + .spawn(move || { + runtime.block_on(async move { + http::serve(console, listener).await; + }); + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::Console; + + #[test] + fn the_right_token_is_accepted_and_a_wrong_one_is_not() { + let console = Console::new("s3cret".to_owned()); + assert!(console.authorises(b"s3cret")); + assert!(!console.authorises(b"s3cres")); + assert!(!console.authorises(b"s3cret ")); + } + + #[test] + fn an_empty_token_never_matches() { + // The dangerous shape: a console started with no token configured, + // answering every request that also sent nothing. + assert!(!Console::new(String::new()).authorises(b"")); + assert!(!Console::new("s3cret".to_owned()).authorises(b"")); + } +} diff --git a/crates/hyperion-web-console/src/module.rs b/crates/hyperion-web-console/src/module.rs new file mode 100644 index 000000000..168d9c75f --- /dev/null +++ b/crates/hyperion-web-console/src/module.rs @@ -0,0 +1,291 @@ +//! Wiring: the console entity, the tap, the web server, and one system a tick. + +use std::sync::Arc; + +use flecs_ecs::{macros::system, prelude::*}; +use hyperion::{ + console::{ChatObserver, ChatObservers}, + egress::{ping::Ping, tab_list::Tps}, + hyperion_minecraft_proto::{ + generated::packet_id::play::clientbound::PacketId, + packets::play::clientbound::SystemChat, + text::{Component, NamedColor}, + }, + net::{Compose, ConnectionId, protocol::Clientbound}, + simulation::{Name, PacketState, chat::strip_formatting, event}, + storage::Events, +}; +use hyperion_permission::Group; + +use crate::{ + Config, Console, + feed::Source, + state::{PlayerRow, Request, Snapshot}, + virtual_connection::ConsoleConnection, +}; + +/// How the console appears in chat, and to any command that names its caller. +pub const CONSOLE_NAME: &str = "Console"; + +/// How often the roster and the tick rate are rebuilt, in ticks. +/// +/// A second. The page is a person watching, and a person cannot read twenty +/// updates a second; rebuilding the roster every tick would cost a query per +/// tick to redraw something that has not changed. +const SNAPSHOT_EVERY: i64 = 20; + +/// The console, as an ECS singleton. +/// +/// A newtype rather than storing the `Arc` bare, because a bare `Arc` +/// is not a component and flecs keys on the type. +#[derive(Component)] +pub struct ConsoleHandle(pub Arc); + +/// The entity commands run as. +#[derive(Component)] +pub struct ConsoleCaller(pub Entity); + +/// Registration: the components this crate owns, and nothing else. +#[derive(Component)] +pub struct ConsoleComponentsModule; + +impl Module for ConsoleComponentsModule { + fn module(world: &World) { + world + .component::() + .add_trait::(); + world + .component::() + .add_trait::(); + } +} + +/// Behaviour: the web server, the chat tap, and the tick that carries out what +/// the operator asked for. +/// +/// Imported explicitly by a deployment rather than by `HyperionCore`, because a +/// console is a thing an operator turns on. Import it and pass a [`Config`]; +/// leave it out and nothing here costs anything. +#[derive(Component)] +pub struct ConsoleModule; + +impl Module for ConsoleModule { + fn module(world: &World) { + world.import::(); + // Every component this module's systems read, registered by the module + // that owns it. `HyperionCore` is not optional and not implied by the + // event having imported it already: flecs dedupes the import, and the + // failure mode of leaving it out is a use-before-register that a + // release build compiles the assert out of and a dev build aborts on + // (ENG-11000). `Compose`, `Events`, `Name`, `ConnectionId`, + // `ChatObservers`, `Ping` and `Tps` all come from here. + world.import::(); + // The registry commands are looked up in, and the `Group` a command's + // permission check reads off the caller. + world.import::(); + world.import::(); + } +} + +/// Turn the console on. +/// +/// Separate from [`ConsoleModule`] because a flecs module takes no arguments +/// and the address and the token are not compile-time facts. Import the module, +/// then call this. +/// +/// # Errors +/// Fails if the web server cannot bind. Deliberately fatal to the caller rather +/// than logged: an operator who asked for a console and did not get one has to +/// find out at startup. +pub fn install(world: &World, config: &Config) -> std::io::Result<()> { + let console = Arc::new(Console::new(config.token.clone())); + + // Leaked on purpose, and exactly once. The tap and the virtual connection + // are called from the packet path and from `IoBuf`, neither of which can + // hold a world reference, and the console lives as long as the process + // anyway. A leak with a bounded, known size beats threading a lifetime + // through the engine's hot path. + let shared: &'static Console = Box::leak(Box::new(Arc::clone(&console))); + + let caller = spawn_caller(world); + world.set(ConsoleCaller(caller)); + + world.get::<&mut ChatObservers>(|observers| { + observers.watch(Arc::new(ChatToFeed { + console: Arc::clone(&console), + })); + }); + + world.get::<&mut Compose>(|compose| { + // The server's own threshold, handed over as it is: turning it into + // what a decoder wants is `ConsoleConnection`'s job, next to the tests + // that pin the rule. + let threshold = compose.global().shared.compression_threshold; + compose + .io_buf_mut() + .attach_virtual_connection(Arc::new(ConsoleConnection::new(&shared.feed, threshold))); + }); + + crate::spawn(Arc::clone(&console), config.address)?; + console.feed.push( + Source::System, + format!("\u{a7}8console listening on http://{}/", config.address), + ); + + world.set(ConsoleHandle(console)); + install_systems(world); + Ok(()) +} + +/// The entity commands run as. +/// +/// It is not a player and must never be mistaken for one: no +/// [`PacketState::Play`], which is what events key "this is somebody who +/// joined" on, so no game promotes it, counts it in a lobby, or writes a +/// sidebar to it. What it does carry is exactly what a command reads --- a +/// name, a group, and a connection to answer. +fn spawn_caller(world: &World) -> Entity { + let caller = world + .entity_named(CONSOLE_NAME) + .set(Name::from(std::sync::Arc::::from(CONSOLE_NAME))) + .set(Group::Admin) + .set(ConnectionId::new( + crate::virtual_connection::CONSOLE_STREAM, + hyperion::net::ProxyId::new(crate::virtual_connection::CONSOLE_PROXY), + )); + + debug_assert!( + !caller.has_enum(PacketState::Play), + "the console caller must not look like a joined player" + ); + + caller.id() +} + +/// Player chat, on its way to the page. +struct ChatToFeed { + console: Arc, +} + +impl ChatObserver for ChatToFeed { + fn player_said(&self, _speaker: Entity, name: &str, message: &str) { + // Vanilla's shape, so a line reads on the page the way it reads in the + // chat box. Not the line the game broadcast, which this cannot see: + // see `hyperion::console` for why the tap is before the queue and what + // that trades away. + // + // The message keeps its section signs and the page renders them, which + // is a decision with a hazard attached: a player typing `\u{a7}c` paints + // their own text here. That is the same hazard the game has and the + // same answer -- the engine's `strip_formatting` -- applied at the one + // place that knows this text came off a wire. + self.console.feed.push( + Source::Chat, + format!("<{name}> {}", strip_formatting(message)), + ); + } +} + +fn install_systems(world: &World) { + system!( + "console_requests", + world, + &ConsoleHandle, + &ConsoleCaller, + &Compose, + &Events + ) + .each_iter(|it, _index, (handle, caller, compose, events)| { + let world = it.world(); + for request in handle.0.take_requests() { + match request { + Request::Say(message) => { + // Built as a component with the colour as a field, + // rather than as a string with `\u{a7}d` inside it. + // Both render the same on a client, and `nix/text.nix` + // exists because only one of them survives contact + // with anything that reads the text: the legacy codes + // are invisible to `Component::plain`, say nothing + // outside the sixteen named colours, and are a + // formatter kept alive for 1.8 servers. That gate does + // not reach this crate today (ENG-10796 tracks + // widening it), which is a reason to hold the line + // here rather than to relax it. + // + // The operator's own text is a child with no style of + // its own, so it cannot be styled by the prefix, and + // the feed gets the same component rendered back down + // to the codes the page draws. + let component = Component::text(format!("[{CONSOLE_NAME}]")) + .color(NamedColor::LightPurple) + .append(Component::text(format!(" {message}")).color(NamedColor::White)); + let line = crate::legacy::render(&component); + let packet = SystemChat { + content: component.to_tag(), + overlay: false, + }; + match compose + .broadcast(Clientbound::new(PacketId::SystemChat.to_raw(), &packet)) + .send() + { + Ok(()) => handle.0.feed.push(Source::Console, line), + Err(error) => { + // The operator pressed send and nothing + // happened, so the page has to say so rather + // than the journal alone. + handle.0.feed.push( + Source::System, + format!("\u{a7}cthe message was not sent: {error}"), + ); + } + } + } + Request::Command(raw) => { + let raw = raw.strip_prefix('/').unwrap_or(&raw).to_owned(); + handle + .0 + .feed + .push(Source::Console, format!("\u{a7}8> /{raw}")); + events.push( + event::Command { + raw: raw.into(), + by: caller.0, + }, + &world, + ); + } + } + } + }); + + system!("console_snapshot", world, &ConsoleHandle, &Compose, &Tps).each_iter( + |it, _index, (handle, compose, tps)| { + if compose.global().tick % SNAPSHOT_EVERY != 0 { + return; + } + + let mut players = Vec::new(); + it.world() + .query::<(&Name, &Ping)>() + .build() + .each(|(name, ping)| { + players.push(PlayerRow { + name: name.to_string(), + // `latency` reports the engine's own "no reading" + // sentinel as a negative, which the page must not draw + // as a fast connection. + latency_ms: Some(ping.latency()).filter(|latency| *latency >= 0), + }); + }); + players.sort_by(|left, right| left.name.cmp(&right.name)); + + handle.0.publish(Snapshot { + players, + tps: tps.rate, + target_tps: hyperion::TICKS_PER_SECOND, + build_rev: std::env::var("HYPERION_BUILD_REV").ok(), + build_dirty: std::env::var("HYPERION_BUILD_DIRTY").as_deref() == Ok("1"), + }); + }, + ); +} diff --git a/crates/hyperion-web-console/src/state.rs b/crates/hyperion-web-console/src/state.rs new file mode 100644 index 000000000..f7edca0a6 --- /dev/null +++ b/crates/hyperion-web-console/src/state.rs @@ -0,0 +1,131 @@ +//! What the console shows besides chat, and what it sends back into the world. + +use std::sync::Mutex; + +/// One player, as the console's roster draws them. +#[derive(Debug, Clone)] +pub struct PlayerRow { + pub name: String, + /// Round trip in milliseconds, or `None` when nothing has been measured. + /// + /// `None` rather than a zero, for the same reason + /// [`hyperion::egress::ping::UNKNOWN`] exists: a zero draws a healthy + /// connection for a client nobody has timed. + pub latency_ms: Option, +} + +/// Everything the page shows that is not a line of chat. +/// +/// Rebuilt whole once a second by a flecs system and read by the web server on +/// whatever thread it happens to be on, so it crosses a mutex rather than +/// being queried live: a query needs the world, and the world belongs to the +/// tick loop. +#[derive(Debug, Default, Clone)] +pub struct Snapshot { + pub players: Vec, + /// Ticks per second over the engine's own window, or `None` while it is + /// still sampling. Not defaulted to twenty: a number nobody has measured + /// is exactly what an operator is checking for. + pub tps: Option, + /// What the tick loop is paced to, so the rate above has a denominator. + pub target_tps: f32, + /// The commit this server was built from, `None` when nothing said. + pub build_rev: Option, + /// The working tree had uncommitted changes at build time. + pub build_dirty: bool, +} + +impl Snapshot { + /// This snapshot as the JSON the page reads. + #[must_use] + pub fn to_json(&self) -> String { + let players: Vec<_> = self + .players + .iter() + .map(|player| serde_json::json!({ "name": player.name, "latency": player.latency_ms })) + .collect(); + + serde_json::json!({ + "players": players, + "tps": self.tps, + "targetTps": self.target_tps, + "buildRev": self.build_rev, + "buildDirty": self.build_dirty, + }) + .to_string() + } +} + +/// What the operator asked for, waiting for a tick to carry it out. +/// +/// A web request arrives on a tokio thread and everything it wants to do needs +/// the world, which only the tick loop may touch. So a request lands here and +/// the next tick drains it. The operator's own latency is one tick, 50 ms, +/// which is under what a click can perceive. +#[derive(Debug, Clone)] +pub enum Request { + /// Say something to every player, as the console. + Say(String), + /// Run a command, as the console operator. + Command(String), +} + +/// The queue those requests wait in. +#[derive(Debug, Default)] +pub struct Inbox { + pending: Mutex>, +} + +impl Inbox { + pub fn push(&self, request: Request) { + if let Ok(mut pending) = self.pending.lock() { + pending.push(request); + } + } + + /// Everything queued since the last call. + #[must_use] + pub fn drain(&self) -> Vec { + self.pending + .lock() + .map(|mut pending| std::mem::take(&mut *pending)) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::{Inbox, PlayerRow, Request, Snapshot}; + + #[test] + fn draining_leaves_the_queue_empty() { + let inbox = Inbox::default(); + inbox.push(Request::Say("hello".to_owned())); + assert_eq!(inbox.drain().len(), 1); + assert!(inbox.drain().is_empty()); + } + + #[test] + fn an_unmeasured_ping_is_null_and_not_zero() { + // A zero here draws five bars for a client nobody has timed, which is + // the bug ENG-11113 fixed on the tab list. The console must not + // reintroduce it in a second place. + let snapshot = Snapshot { + players: vec![PlayerRow { + name: "Andrew".to_owned(), + latency_ms: None, + }], + ..Snapshot::default() + }; + assert!(snapshot.to_json().contains(r#""latency":null"#)); + } + + #[test] + fn a_tps_still_sampling_is_null_and_not_twenty() { + let snapshot = Snapshot { + target_tps: 20.0, + ..Snapshot::default() + }; + assert!(snapshot.to_json().contains(r#""tps":null"#)); + } +} diff --git a/crates/hyperion-web-console/src/virtual_connection.rs b/crates/hyperion-web-console/src/virtual_connection.rs new file mode 100644 index 000000000..356b606c8 --- /dev/null +++ b/crates/hyperion-web-console/src/virtual_connection.rs @@ -0,0 +1,169 @@ +//! The console's end of a connection that has no socket. +//! +//! Every command in the workspace answers its caller by reading the caller's +//! [`ConnectionId`] and unicasting to it. So the console has one, and this is +//! what is on the other side of it: frames arrive exactly as the proxy would +//! have received them, go through the same decoder a client uses, and any +//! `SystemChat` among them becomes a line on the page. +//! +//! Anything that is not chat is dropped. A command that answers by moving a +//! boss bar or opening an inventory has said something the console cannot +//! draw, and inventing a rendering for it would be worse than being quiet. + +use std::sync::Mutex; + +use hyperion::{ + hyperion_minecraft_proto::{ + framing::FrameDecoder, generated::packet_id::play::clientbound::PacketId, + packets::play::clientbound::SystemChat, text::Component, + }, + net::{ConnectionId, VirtualConnection}, + valence_protocol::CompressionThreshold, +}; + +use crate::feed::{Feed, Source}; + +/// The stream id the console answers to. +/// +/// Far above anything the proxy hands out --- ids come from a counter that +/// starts at zero and one server would need to outlive the heat death of +/// several suns to reach this --- so a real player can never be given it. +pub const CONSOLE_STREAM: u64 = u64::MAX - 1; + +/// The proxy the console pretends to be behind. +/// +/// Never used to route anything: the interception in +/// [`hyperion::net::IoBuf::unicast_raw`] happens before a proxy is chosen. It +/// exists because a [`ConnectionId`] has two halves and both are compared. +pub const CONSOLE_PROXY: u64 = u64::MAX - 1; + +/// Turns frames addressed to the console into lines on the page. +pub struct ConsoleConnection { + stream: ConnectionId, + /// The decoder carries state across frames --- a packet can be split over + /// several writes --- so it is one decoder for the life of the console + /// rather than one per frame. + decoder: Mutex, + feed: &'static Feed, +} + +impl ConsoleConnection { + /// A connection delivering into `feed`. + /// + /// Takes the server's own [`CompressionThreshold`] rather than a + /// `Option` a caller worked out, because working it out is the one + /// step here that can be silently wrong. See [`decoder_threshold`]. + #[must_use] + pub fn new(feed: &'static Feed, compression_threshold: CompressionThreshold) -> Self { + let mut decoder = FrameDecoder::new(); + decoder.set_compression_threshold(decoder_threshold(compression_threshold)); + + Self { + stream: ConnectionId::new(CONSOLE_STREAM, hyperion::net::ProxyId::new(CONSOLE_PROXY)), + decoder: Mutex::new(decoder), + feed, + } + } +} + +/// What to tell a [`FrameDecoder`] given the threshold the encoder holds. +/// +/// The two are not the same type and the mapping is not obvious. +/// `PacketEncoder::append_packet` switches on `threshold.0 >= 0`, so a +/// negative threshold means no compression at all and every other value -- +/// including zero -- means every frame carries a `data_len` varint, whether or +/// not that particular frame was deflated. `usize::try_from` is exactly that +/// predicate: `None` for the negative case, `Some` for the rest. +/// +/// This is a named function with tests rather than an expression at the call +/// site because getting it wrong is invisible. A decoder told there is no +/// compression reads the `data_len` varint as the packet id, decides the frame +/// is not a `SystemChat`, and drops it -- no error, no log line, an operator +/// console that shows a command being sent and never shows a reply. That state +/// was reproduced deliberately against a live server: every command reply +/// vanished and the server said nothing at any log level. +fn decoder_threshold(threshold: CompressionThreshold) -> Option { + usize::try_from(threshold.0).ok() +} + +impl VirtualConnection for ConsoleConnection { + fn stream(&self) -> ConnectionId { + self.stream + } + + fn deliver(&self, frame: &[u8]) { + let Ok(mut decoder) = self.decoder.lock() else { + return; + }; + + decoder.queue(frame); + + loop { + match decoder.next_packet() { + Ok(Some(packet)) => { + if packet.id != PacketId::SystemChat.to_raw() { + continue; + } + match packet.decode_body::>() { + // The page renders section signs, so the text goes on + // with its own formatting intact. + Ok(chat) => match Component::from_tag(&chat.content) { + // `legacy::render` and not `plain`: a component + // carries its colour as a field, the page renders + // section signs, and `plain` throws the field away + // -- which turns a red refusal into a sentence the + // same colour as everything else. + Ok(component) => { + self.feed + .push(Source::Reply, crate::legacy::render(&component)); + } + Err(error) => { + tracing::warn!("console could not read a reply component: {error}"); + } + }, + Err(error) => { + tracing::warn!("console could not decode a chat reply: {error}"); + } + } + } + Ok(None) => return, + // The stream position is no longer known, so every later frame + // would be read at the wrong offset. Saying so once and + // stopping beats a page filling with garbage. + Err(error) => { + tracing::error!("console reply stream is unreadable, giving up: {error}"); + return; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use hyperion::valence_protocol::CompressionThreshold; + + use super::decoder_threshold; + + /// The server's own default, from `crates/hyperion/src/lib.rs`. Frames + /// carry a `data_len`, so the decoder has to expect one. + #[test] + fn the_shipping_threshold_turns_compression_on() { + assert_eq!(decoder_threshold(CompressionThreshold(256)), Some(256)); + } + + /// `PacketEncoder::append_packet` treats `>= 0` as compression enabled, so + /// zero is on -- every frame framed with a `data_len`, and every one of + /// them deflated, since the encoder compresses when `data_len > 0`. + /// Reading zero as "off" is the plausible mistake this pins. + #[test] + fn zero_is_compression_on_and_not_off() { + assert_eq!(decoder_threshold(CompressionThreshold(0)), Some(0)); + } + + /// The only value that means off. + #[test] + fn a_negative_threshold_is_the_one_that_disables_it() { + assert_eq!(decoder_threshold(CompressionThreshold(-1)), None); + } +} diff --git a/crates/hyperion-web-console/tests/registration_modules.rs b/crates/hyperion-web-console/tests/registration_modules.rs new file mode 100644 index 000000000..3a56c6cfd --- /dev/null +++ b/crates/hyperion-web-console/tests/registration_modules.rs @@ -0,0 +1,86 @@ +//! Dev-profile guard: the console's registration module stands on its own. +//! +//! The same property `crates/hyperion/tests/registration_modules.rs` asserts +//! for the engine's modules, asserted for this crate's. It has to live here +//! rather than beside those: `hyperion` cannot depend on a crate that depends +//! on `hyperion`. +//! +//! This is the only shape of test that can see ENG-11000's class. flecs builds +//! with `flecs_manual_registration`, so using a component before registering it +//! aborts with `ECS_INVALID_OPERATION` -- but that guard is an `ecs_assert`, +//! which release builds compile out. Every e2e gate in this repo builds a +//! release binary and is therefore structurally blind to it. `cargo test` is +//! not. + +use flecs_ecs::core::{ComponentId, World, id}; +use hyperion_web_console::{ + ConsoleComponentsModule, + module::{ConsoleCaller, ConsoleHandle}, +}; +use serial_test::serial; + +/// Asserts `T` is registered without registering it as a side effect, which is +/// what a plain `id::()` or a `set` would do. +fn assert_registered(world: &World) { + assert!( + world.get_component_id::().is_some(), + "{} should be registered by its registration module alone", + core::any::type_name::() + ); +} + +#[test] +#[serial] +fn the_registration_module_registers_both_singletons_standalone() { + let world = World::new(); + world.import::(); + + assert_registered::(&world); + assert_registered::(&world); +} + +#[test] +#[serial] +fn registration_carries_no_behaviour() { + // The other half of the convention in the root `CLAUDE.md`: a consumer can + // import the types without importing the systems. If these ever appear + // here, "give me the components but not the behaviour" has stopped being + // expressible and the smash mock loses the seam it depends on. + let world = World::new(); + world.import::(); + + assert!( + world.try_lookup("console_requests").is_none(), + "the registration module must install no systems" + ); + assert!( + world.try_lookup("console_snapshot").is_none(), + "the registration module must install no systems" + ); +} + +/// The singletons are registered, but deliberately not *set*. +/// +/// Unlike `SpatialIndex` or `WorldTime`, neither of these has a meaningful +/// default: a `ConsoleHandle` is an `Arc` that only exists once an +/// operator has asked for a console and a port has been bound, and a +/// `ConsoleCaller` names an entity that `install` creates. So the registration +/// module registers the types and `install` sets the values, and that split is +/// the thing this asserts -- a future edit that "helpfully" adds a `Default` +/// and sets it here would give every server a console handle pointing at +/// nothing. +#[test] +#[serial] +fn the_singletons_are_registered_but_not_set() { + let world = World::new(); + world.import::(); + + assert!( + !world.has(id::()), + "a handle must not exist until `install` has bound a port" + ); + assert!( + !world.has(id::()), + "a caller must not exist until `install` has spawned the entity" + ); +} diff --git a/crates/hyperion/Cargo.toml b/crates/hyperion/Cargo.toml index 59adb2f3f..afd9f0768 100644 --- a/crates/hyperion/Cargo.toml +++ b/crates/hyperion/Cargo.toml @@ -92,6 +92,15 @@ serial_test = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } +[lib] +# A dylib as well as an rlib so a dynamically loaded game module and this host resolve +# `hyperion` -- and through it the one `flecs_ecs` that owns the process-global pool +# handing out component indices -- to a single shared image. Two copies index one world +# two different ways, consistently on each side, with nothing raising an error. See +# `docs/hot-reload.md`. The rlib stays first so a consumer linking statically is +# unaffected. +crate-type = ["rlib", "dylib"] + [lints] workspace = true diff --git a/crates/hyperion/build.rs b/crates/hyperion/build.rs new file mode 100644 index 000000000..d5f69a837 --- /dev/null +++ b/crates/hyperion/build.rs @@ -0,0 +1,88 @@ +//! Re-exports LMDB's C symbols from this crate's dylib. +//! +//! ## Why this is needed +//! +//! rustc links a `dylib` with its own anonymous version script ending in `local: *`, which +//! demotes every symbol it did not generate. `heed` pulls in `liblmdb.a`, so LMDB's C ends +//! up absorbed into `libhyperion.so` and hidden -- present and unreachable: +//! +//! ```text +//! $ readelf --dyn-syms libhyperion.so | grep -c mdb_ +//! 0 +//! $ readelf --syms libhyperion.so | grep mdb_env_open +//! 14356: 0000000000d0df3c 933 FUNC LOCAL DEFAULT 13 mdb_env_open +//! ``` +//! +//! 133 definitions, none reachable. That is fatal rather than merely wasteful because +//! `heed`'s API is generic: every consumer monomorphises heed's code into its own rlib and +//! emits its own calls to `mdb_*`. `hyperion-permission` is such a consumer and is linked +//! into an event's server binary statically, while rustc suppresses LMDB's own `-llmdb` on +//! the grounds that an upstream dylib already provides it. The result is a link that fails +//! only once `hyperion` is built as a dylib, which is to say only under hot reload: +//! +//! ```text +//! rust-lld: error: undefined symbol: mdb_dbi_open +//! ``` +//! +//! Re-exporting keeps ONE copy of LMDB in the process. Linking a second `liblmdb.a` into +//! the executable would also make the link succeed, and would put two copies of a C +//! library with process-global state in one process -- the same shape +//! `checks.hot-reload-index-probe` exists to reject for `flecs_ecs`. +//! +//! Neither `-Wl,--export-dynamic` nor `-Wl,--export-dynamic-symbol=mdb_*` reverses a +//! version-script demotion; the latter was measured here leaving the exported count at +//! zero, independently reproducing what `flecs_ecs/build.rs` documents for `ecs_*`. What +//! works is a second version script: ld merges them, and an explicit pattern beats a `*` +//! wildcard, so naming these globs promotes exactly them while leaving rustc's own export +//! list intact. +//! +//! ## The general rule +//! +//! Any native static library absorbed into this dylib, whose symbols a downstream +//! monomorphisation calls, needs its glob here. The signature is an undefined `foo_*` at +//! an event's final link whose definition is `LOCAL` in `libhyperion.so`'s `.symtab`. +//! `flecs_ecs` carries its own copy of this build script for `ecs_*`, because a build +//! script's `rustc-link-arg` applies to its own crate's artifacts and nothing else. + +// A build script talks to cargo over stdout and has no other channel, so the +// workspace-wide `print_stdout = deny` does not apply to this file. +#![allow( + clippy::print_stdout, + reason = "the cargo build-script protocol is stdout" +)] + +/// Spelled without the leading underscore Mach-O adds. +const LMDB_EXPORTS: [&str; 1] = ["mdb_*"]; + +fn main() { + println!("cargo::rerun-if-changed=build.rs"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if matches!(target_os.as_str(), "macos" | "ios") { + // ld64 unions -exported_symbol with the export list rustc generates, so no second + // script is needed and none is available. + for pattern in LMDB_EXPORTS { + println!("cargo::rustc-link-arg=-Wl,-exported_symbol,_{pattern}"); + } + return; + } + + let out_dir = std::env::var("OUT_DIR").expect("cargo always sets OUT_DIR"); + let script = std::path::Path::new(&out_dir).join("lmdb-exports.map"); + + let mut text = String::from("{\n global:\n"); + for pattern in LMDB_EXPORTS { + text.push_str(" "); + text.push_str(pattern); + text.push_str(";\n"); + } + // Deliberately no `local:` clause. This script adds to rustc's export list; a + // `local: *` here would hide every Rust symbol a consumer resolves through. + text.push_str("};\n"); + + std::fs::write(&script, text).expect("failed to write the lmdb version script"); + println!( + "cargo::rustc-link-arg=-Wl,--version-script={}", + script.display() + ); +} diff --git a/crates/hyperion/src/common/mod.rs b/crates/hyperion/src/common/mod.rs index 1db171ec6..5b60d2838 100644 --- a/crates/hyperion/src/common/mod.rs +++ b/crates/hyperion/src/common/mod.rs @@ -15,6 +15,14 @@ pub mod util; pub use command_channel::CommandChannel; +/// How many ticks a second the game loop is paced to. +/// +/// The number flecs is given in [`crate::HyperionCore`] and the ceiling +/// [`crate::egress::tab_list`] prints the measured rate against, so the two +/// cannot drift apart and the footer cannot quote a target the loop is not +/// actually aiming at. +pub const TICKS_PER_SECOND: f32 = 20.0; + /// Shared data that is shared between the ECS framework and the IO thread. pub struct Shared { /// The compression level to use for the server. This is how long a packet needs to be before it is compressed. diff --git a/crates/hyperion/src/console.rs b/crates/hyperion/src/console.rs new file mode 100644 index 000000000..47fc9ca2f --- /dev/null +++ b/crates/hyperion/src/console.rs @@ -0,0 +1,67 @@ +//! The engine's side of an operator console. +//! +//! A console needs to see chat, and chat is a single-consumer queue: whichever +//! system calls [`EventQueue::drain`](crate::storage::EventQueue::drain) first +//! takes every message, and the game is that system. A second reader is not +//! expressible, and making the queue multi-consumer to give one watcher a copy +//! would change how every event in the engine is delivered. +//! +//! So the tap is here, at the point the packet is decoded, before the queue. +//! What an observer sees is what the player typed, which is also the more +//! useful thing for an operator: the rendered line is a game's decision -- +//! bedwars colours the name by team, smash does not -- and a game that refuses +//! a message on a cooldown still leaves the operator wanting to know somebody +//! tried. +//! +//! Nothing in the engine installs an observer. With none installed this costs +//! one singleton read and an empty iteration per chat packet. + +use std::sync::Arc; + +use flecs_ecs::prelude::*; + +/// Something watching what players type. +/// +/// Called from the packet handler, on the tick thread, before the message +/// reaches any game. An implementation must not block: a slow observer is a +/// slow tick. +pub trait ChatObserver: Send + Sync { + /// `speaker`, whose username is `name`, said `message`. + /// + /// The name is passed rather than looked up because an observer is called + /// from the packet path and has no world to look it up in. Resolving it + /// there and handing over an entity id was the first shape of this and it + /// was useless: a chat pane reading `1234: hello` names nobody. + /// + /// `message` is unsanitised on purpose. What is safe to put in a component + /// is a question about a Minecraft client, and an observer that is not one + /// -- a web page, a log, a bridge -- has its own answer. + fn player_said(&self, speaker: Entity, name: &str, message: &str); +} + +/// Everyone watching. A singleton, empty unless something registers. +#[derive(Component, Default)] +pub struct ChatObservers { + observers: Vec>, +} + +impl ChatObservers { + /// Start watching. There is no way to stop: an observer lives as long as + /// the process, which is what every caller so far wants and is one less + /// piece of state than a handle nobody would return. + pub fn watch(&mut self, observer: Arc) { + self.observers.push(observer); + } + + /// Tell everyone watching. + pub fn player_said(&self, speaker: Entity, name: &str, message: &str) { + for observer in &self.observers { + observer.player_said(speaker, name, message); + } + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.observers.is_empty() + } +} diff --git a/crates/hyperion/src/egress/mod.rs b/crates/hyperion/src/egress/mod.rs index 42a79a514..bde35f8a6 100644 --- a/crates/hyperion/src/egress/mod.rs +++ b/crates/hyperion/src/egress/mod.rs @@ -18,19 +18,23 @@ use crate::{ pub mod boss_bar; mod channel; pub mod metadata; +pub mod ping; pub mod player_join; pub mod server_load; mod stats; pub mod sync_chunks; mod sync_entity_state; +pub mod tab_list; use boss_bar::BossBarModule; use channel::ChannelModule; +use ping::PingModule; use player_join::PlayerJoinModule; use server_load::ServerLoadModule; use stats::StatsModule; use sync_chunks::SyncChunksModule; use sync_entity_state::EntityStateSyncModule; +use tab_list::TabListModule; #[derive(Component)] pub struct EgressModule; @@ -49,6 +53,8 @@ impl Module for EgressModule { world.import::(); world.import::(); world.import::(); + world.import::(); + world.import::(); system!("broadcast_chunk_deltas", world, &Compose, &mut Blocks,) .kind(id::()) diff --git a/crates/hyperion/src/egress/ping.rs b/crates/hyperion/src/egress/ping.rs new file mode 100644 index 000000000..4ea46edee --- /dev/null +++ b/crates/hyperion/src/egress/ping.rs @@ -0,0 +1,492 @@ +//! Per-player round trip time, and the ping bars the tab list draws from it. +//! +//! # There was no measurement to be stale +//! +//! `roster::entry_of` sent `ping: 0` and `list.rs` forwarded it once, at join, +//! and that was the whole of it. The reason was not a forgotten update: it was +//! that **nothing on either side of the connection ever asked**. The game +//! server routed a serverbound `keep_alive` to `Route::Ignore` and never sent a +//! clientbound one, so the client -- which only ever answers a keep-alive and +//! never starts one -- had nothing to answer. `0` was not a reading taken once +//! and left; it was the absence of a reading, drawn as a full five bars. +//! +//! # The proxy does not answer keep-alives, so this measures the player +//! +//! This is the one place the design could quietly lie. hyperion puts a proxy +//! between the client and the game server, and a proxy that answered +//! keep-alives itself would leave the game server timing the *proxy*, which +//! would look like a plausible ping and be a measurement of the wrong thing. +//! +//! It does not. `crates/hyperion-proxy` contains no keep-alive handling at all +//! -- the player-to-server direction in `player.rs` reads bytes off the socket +//! and forwards the frames without parsing an id -- but that is an argument, +//! not a measurement. `tools/tab-list-check.py` is the measurement: a real +//! client answers keep-alives, watches a latency arrive, then **stops +//! answering** while staying otherwise busy. The reading falls back to +//! "unknown", and comes back when it answers again. Nothing between the client +//! and the game server can produce that, so the thing being timed is the +//! player. +//! +//! # What is in the number: one tick of it is this server +//! +//! Send to encode here, out through the proxy, to the client, back through the +//! proxy, and in again as far as the tick that decodes it. Two things ride +//! along with the network time and both are named rather than subtracted out: +//! +//! - **the proxy hops**, which are part of what a player waits for and belong +//! in a number labelled "ping", +//! - **one whole tick of inbound scheduling**, because `ingress::decode`'s +//! `recv_data` drains queued frames once per tick and the probe goes out at +//! the end of one, so an answer cannot be seen before the next. +//! +//! The second is not a worst case, it is the floor, and it is big. Measured in +//! the gate, on loopback, where the true round trip is under a millisecond: +//! **58 ms and 61 ms**, against a tick of 59 ms on a loop that was managing +//! 17 tps. Essentially all of the reading was this server waiting for its own +//! next tick. +//! +//! So read the number as *the player's round trip plus about a tick*, and note +//! what that costs at the bar thresholds below: with a ~50-60 ms offset, a +//! player whose real ping is 100 ms is drawn at four bars rather than five. +//! The bar is never wrong by more than one step, and it is biased one way. +//! +//! Removing it means timestamping a frame when it arrives rather than when it +//! is decoded, which is a change to the packet channel every connection shares +//! and is deliberately not made here. Having the *proxy* stamp arrival is the +//! other option and is worse: it needs a clock shared by two hosts to mean +//! anything. +//! +//! # One probe at a time +//! +//! A new keep-alive goes out only once the last one is answered or has timed +//! out, so an id can never be ambiguous and a slow client is probed less +//! rather than being handed a queue it cannot drain. A probe unanswered for +//! [`Global::keep_alive_timeout`](crate::Global::keep_alive_timeout) drops the +//! reading back to "no reading" and starts a new one. hyperion still does not +//! disconnect anyone over it -- `simulation::handlers` says the same -- so the +//! effect is confined to the readout. + +use std::time::{Duration, Instant}; + +use flecs_ecs::prelude::*; +use hyperion_minecraft_proto::{ + generated::packet_id::play::clientbound::PacketId, packets::play::clientbound::KeepAlive, +}; +use tracing::error; + +use crate::{ + egress::player_join::{PlayerInfoActions, PlayerList, PlayerListEntry}, + net::{Compose, ConnectionId, protocol::send}, + simulation::{PacketState, Uuid}, +}; + +/// How long to wait, after a probe is answered, before sending the next. +/// +/// Two seconds costs one ten-byte packet per player per two seconds -- 5 KB/s +/// across ten thousand players, against the per-player-per-tick position +/// updates the same link already carries -- and bounds how stale a bar can be +/// at two seconds plus one round trip. Probing faster would buy resolution the +/// five-bar display cannot show. +const PERIOD: Duration = Duration::from_secs(2); + +/// The latency that means "no reading", which is the client's own +/// `PING_UNKNOWN_SPRITE` case rather than a number invented to stand in for +/// one. +pub const UNKNOWN: i32 = -1; + +/// The ping bar the vanilla client draws for a latency in milliseconds. +/// +/// Read off `PlayerTabOverlay.extractPingIcon` in the 26.2 client jar (sha1 +/// `2dc72797acbc1b63fc16a11c4ac393605f453754`, which is the jar +/// `nix/minecraft-version.json` already pins): +/// +/// ```java +/// Identifier sprite = info.getLatency() < 0 ? PING_UNKNOWN_SPRITE +/// : (info.getLatency() < 150 ? PING_5_SPRITE +/// : (info.getLatency() < 300 ? PING_4_SPRITE +/// : (info.getLatency() < 600 ? PING_3_SPRITE +/// : (info.getLatency() < 1000 ? PING_2_SPRITE : PING_1_SPRITE)))); +/// ``` +/// +/// Six sprites, five of them bars. Vanilla draws the icon and never the +/// number, so this enum is the whole of what a player can see, which is why it +/// and not the millisecond is what decides whether an update is worth sending. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Bars { + /// No reading yet, or the last probe timed out. + Unknown, + /// A second or worse. + One, + /// 600..1000 ms. + Two, + /// 300..600 ms. + Three, + /// 150..300 ms. + Four, + /// Under 150 ms. + Five, +} + +/// Which bar `latency` milliseconds draws. +#[must_use] +pub const fn bars(latency: i32) -> Bars { + if latency < 0 { + Bars::Unknown + } else if latency < 150 { + Bars::Five + } else if latency < 300 { + Bars::Four + } else if latency < 600 { + Bars::Three + } else if latency < 1000 { + Bars::Two + } else { + Bars::One + } +} + +/// A probe waiting for its answer. +#[derive(Debug, Clone, Copy)] +struct Probe { + /// The value the client echoes back. + id: i64, + /// When it went out. + sent: Instant, +} + +/// One player's round trip time. +#[derive(Component, Debug, Default)] +pub struct Ping { + /// The probe waiting to be answered, if any. + pending: Option, + /// When the last probe went out, answered or not. + last_probe: Option, + /// Strictly increasing per connection, so an answer to a probe that + /// already timed out cannot be mistaken for the current one. + next_id: i64, + /// The last measured round trip. + /// + /// `None` before the first answer and again after one times out, which is + /// the difference between "not measured" and "measured as fast". + pub rtt: Option, + /// The latency this player's tab list entry was last published with. + published: Option, +} + +impl Ping { + /// The latency to put on the wire: whole milliseconds, or [`UNKNOWN`]. + /// + /// The real measurement and not the bucket. Only the *resend* is quantised + /// to bars, so anything that reads the number -- a client that draws it, + /// a mod, a capture -- gets a true reading that is merely updated at bar + /// granularity, rather than a rounded one. + #[must_use] + pub fn latency(&self) -> i32 { + let Some(rtt) = self.rtt else { + return UNKNOWN; + }; + i32::try_from(rtt.as_millis()).unwrap_or(i32::MAX) + } + + /// The id of the probe to send now, if one is due. + fn probe(&mut self, now: Instant, period: Duration, timeout: Duration) -> Option { + if let Some(pending) = self.pending { + if now.duration_since(pending.sent) < timeout { + return None; + } + // Nothing came back in time, so there is no reading any more. + // Keeping the old one would draw a live bar for a client that has + // gone quiet, which is the one thing a ping display must not do. + self.rtt = None; + self.pending = None; + } + + if let Some(last) = self.last_probe + && now.duration_since(last) < period + { + return None; + } + + let id = self.next_id; + self.next_id = self.next_id.wrapping_add(1); + self.pending = Some(Probe { id, sent: now }); + self.last_probe = Some(now); + Some(id) + } + + /// Fold an answer in, and say whether it was the one being waited on. + /// + /// A client that echoes something else, or answers twice, moves nothing: + /// a reading is only taken for the probe currently outstanding. + fn answer(&mut self, id: i64, now: Instant) -> bool { + let Some(pending) = self.pending else { + return false; + }; + if pending.id != id { + return false; + } + self.pending = None; + self.rtt = Some(now.saturating_duration_since(pending.sent)); + true + } + + /// Whether the bar this player draws differs from the one every client was + /// last told to draw. + fn moved(&self) -> bool { + self.published + .is_none_or(|published| bars(published) != bars(self.latency())) + } +} + +/// Fold a client's keep-alive answer into its [`Ping`]. +/// +/// Called from `simulation::handlers`, which owns the serverbound routing +/// table; the measurement lives here with the probe that started it. +pub fn absorb_answer(entity: EntityView<'_>, id: i64) { + entity.try_get::<&mut Ping>(|ping| { + ping.answer(id, Instant::now()); + }); +} + +/// Registration module for the ping readout: the [`Ping`] component. +/// +/// Registration only, per the flecs convention in the root `CLAUDE.md`, and +/// deliberately only the type. The other half of the wiring -- that every +/// `Player` carries a `Ping`, so nothing on the join path has to remember to +/// add one -- is a statement about `Player`, so it is declared by the module +/// that owns `Player`, alongside the identical statements about `CursorItem` +/// and `InventoryState`. `SimComponentsModule` imports this for the component +/// to point at. +#[derive(Component)] +pub struct PingComponentsModule; + +impl Module for PingComponentsModule { + fn module(world: &World) { + world.component::(); + } +} + +/// Behavior module for the ping readout: the probe that measures and the +/// change-only update that publishes. +#[derive(Component)] +pub struct PingModule; + +impl Module for PingModule { + fn module(world: &World) { + world.import::(); + + // PreStore, so an answer decoded this tick in OnUpdate is folded in + // before this decides whether to probe again or what to publish. + system!("probe_ping", world, &Compose, &ConnectionId, &mut Ping) + .with_enum(PacketState::Play) + .kind(id::()) + .each(|(compose, connection_id, ping)| { + let timeout = compose.global().keep_alive_timeout; + let Some(id) = ping.probe(Instant::now(), PERIOD, timeout) else { + return; + }; + let packet = KeepAlive(id); + if let Err(error) = send( + compose, + *connection_id, + PacketId::KeepAlive.to_raw(), + &packet, + ) { + error!("failed to send a keep-alive probe: {error}"); + } + }); + + let players = world + .query::<(&Uuid, &mut Ping)>() + .with_enum(PacketState::Play) + .build(); + + // One packet for everyone whose bar moved, not one packet per player + // and not the whole roster: `PlayerInfoUpdate` carries a list, and + // `UPDATE_LATENCY` alone writes a uuid and an int per entry. + world + .system_named::<&Compose>("publish_ping") + .kind(id::()) + .each(move |compose| { + let mut entries = Vec::new(); + players.each(|(uuid, ping)| { + if ping.moved() { + entries.push(PlayerListEntry { + uuid: uuid.0, + ping: ping.latency(), + ..PlayerListEntry::default() + }); + } + }); + if entries.is_empty() { + return; + } + + let update = PlayerList { + actions: PlayerInfoActions::UPDATE_LATENCY, + entries, + }; + if let Err(error) = compose.broadcast(&update).send() { + error!("failed to publish ping updates: {error}"); + // `published` is left alone, so the same set goes out + // again next tick rather than being recorded as delivered. + return; + } + + players.each(|(_, ping)| { + if ping.moved() { + let latency = ping.latency(); + ping.published = Some(latency); + } + }); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every threshold in `extractPingIcon`, on both sides. An off-by-one here + /// draws the wrong icon and nothing else notices. + #[test] + fn the_buckets_are_the_clients_own_thresholds() { + assert_eq!(bars(-1), Bars::Unknown); + assert_eq!(bars(UNKNOWN), Bars::Unknown); + assert_eq!(bars(0), Bars::Five); + assert_eq!(bars(149), Bars::Five); + assert_eq!(bars(150), Bars::Four); + assert_eq!(bars(299), Bars::Four); + assert_eq!(bars(300), Bars::Three); + assert_eq!(bars(599), Bars::Three); + assert_eq!(bars(600), Bars::Two); + assert_eq!(bars(999), Bars::Two); + assert_eq!(bars(1000), Bars::One); + assert_eq!(bars(i32::MAX), Bars::One); + } + + /// A round trip is the time between the probe going out and its own answer + /// coming back. + #[test] + fn an_answered_probe_is_the_time_it_took() { + let mut ping = Ping::default(); + let start = Instant::now(); + + assert_eq!(ping.latency(), UNKNOWN, "nothing has been measured yet"); + let id = ping.probe(start, PERIOD, Duration::from_secs(20)).unwrap(); + + assert!(ping.answer(id, start + Duration::from_millis(42))); + assert_eq!(ping.rtt, Some(Duration::from_millis(42))); + assert_eq!(ping.latency(), 42); + assert_eq!(bars(ping.latency()), Bars::Five); + } + + /// Only one probe is outstanding, so a second is not sent until the first + /// is answered and the period has passed. + #[test] + fn a_probe_waits_for_its_answer_and_then_for_the_period() { + let mut ping = Ping::default(); + let start = Instant::now(); + let timeout = Duration::from_secs(20); + + let first = ping.probe(start, PERIOD, timeout).unwrap(); + // Unanswered: nothing else goes out however long it has been, short of + // the timeout. + assert_eq!(ping.probe(start + PERIOD * 2, PERIOD, timeout), None); + + assert!(ping.answer(first, start + Duration::from_millis(10))); + // Answered, but the period has not passed. + assert_eq!( + ping.probe(start + Duration::from_millis(20), PERIOD, timeout), + None + ); + + let second = ping.probe(start + PERIOD, PERIOD, timeout).unwrap(); + assert_ne!( + first, second, + "ids must not repeat while a stale one is live" + ); + } + + /// An echo of something else moves nothing. A client answering a probe + /// that already timed out must not be credited to the one now in flight. + #[test] + fn an_answer_to_the_wrong_probe_is_ignored() { + let mut ping = Ping::default(); + let start = Instant::now(); + let timeout = Duration::from_secs(20); + + let first = ping.probe(start, PERIOD, timeout).unwrap(); + assert!(!ping.answer(first.wrapping_add(1), start + Duration::from_millis(5))); + assert_eq!(ping.rtt, None); + + // The real one still lands. + assert!(ping.answer(first, start + Duration::from_millis(5))); + assert_eq!(ping.latency(), 5); + + // And a repeat of it does not, because nothing is outstanding. + assert!(!ping.answer(first, start + Duration::from_secs(9))); + assert_eq!(ping.latency(), 5); + } + + /// A client that stops answering loses its reading rather than keeping a + /// stale live bar, and is probed again. + #[test] + fn a_timed_out_probe_drops_the_reading() { + let mut ping = Ping::default(); + let start = Instant::now(); + let timeout = Duration::from_secs(20); + + let first = ping.probe(start, PERIOD, timeout).unwrap(); + assert!(ping.answer(first, start + Duration::from_millis(30))); + assert_eq!(bars(ping.latency()), Bars::Five); + + let second = ping.probe(start + PERIOD, PERIOD, timeout).unwrap(); + // Still inside the timeout: the last good reading stands. + assert_eq!(ping.probe(start + PERIOD * 2, PERIOD, timeout), None); + assert_eq!(bars(ping.latency()), Bars::Five); + + // Past it: no reading, and a fresh probe. + let third = ping + .probe(start + PERIOD + timeout, PERIOD, timeout) + .unwrap(); + assert_eq!(ping.rtt, None); + assert_eq!(ping.latency(), UNKNOWN); + assert_eq!(bars(ping.latency()), Bars::Unknown); + assert_ne!(second, third); + } + + /// An update is sent when the bar moves and not when the millisecond does, + /// which is the whole reason the roster is not re-sent every tick. + #[test] + fn a_millisecond_that_does_not_move_a_bar_publishes_nothing() { + let mut ping = Ping::default(); + let start = Instant::now(); + let timeout = Duration::from_secs(20); + + // Nothing published yet, so the first reading always goes out -- + // including the "unknown" a player has before their first answer. + assert!(ping.moved()); + ping.published = Some(ping.latency()); + assert!(!ping.moved()); + + // A real reading in a different bucket moves the bar. + let id = ping.probe(start, PERIOD, timeout).unwrap(); + assert!(ping.answer(id, start + Duration::from_millis(40))); + assert!(ping.moved()); + ping.published = Some(ping.latency()); + + // 40 ms to 120 ms is a big change in the number and no change at all + // in what the player sees, so it sends nothing. + let id = ping.probe(start + PERIOD, PERIOD, timeout).unwrap(); + assert!(ping.answer(id, start + PERIOD + Duration::from_millis(120))); + assert_eq!(ping.latency(), 120); + assert!(!ping.moved()); + + // Crossing 150 ms does. + let id = ping.probe(start + PERIOD * 2, PERIOD, timeout).unwrap(); + assert!(ping.answer(id, start + PERIOD * 2 + Duration::from_millis(150))); + assert_eq!(bars(ping.latency()), Bars::Four); + assert!(ping.moved()); + } +} diff --git a/crates/hyperion/src/egress/player_join/roster.rs b/crates/hyperion/src/egress/player_join/roster.rs index 03f7dceac..718162982 100644 --- a/crates/hyperion/src/egress/player_join/roster.rs +++ b/crates/hyperion/src/egress/player_join/roster.rs @@ -64,6 +64,7 @@ use tracing::error; use crate::{ egress::{ metadata::show_all, + ping::{self, Ping}, player_join::{PlayerInfoActions, PlayerList, PlayerListEntry, SkinProperty}, }, net::{Channel, Compose, ConnectionId, DataBundle, protocol::Clientbound}, @@ -100,7 +101,13 @@ pub fn entry_of(entity: EntityView<'_>) -> Option { username, properties, listed: true, - ping: 0, + // The measurement, or `UNKNOWN` when there is not one yet, which is + // the state a player is in for their first couple of seconds. A `0` + // here used to draw five full bars for a reading nothing had taken; + // see `egress::ping`. + ping: entity + .try_get::<&Ping>(Ping::latency) + .unwrap_or(ping::UNKNOWN), game_mode: gamemode::of(entity).to_game_type(), // `None` makes the client fall back to the profile name, which is what // the player typed. Two players may share it; nothing on the wire is diff --git a/crates/hyperion/src/egress/sync_entity_state.rs b/crates/hyperion/src/egress/sync_entity_state.rs index a104b875f..7a00de6a1 100644 --- a/crates/hyperion/src/egress/sync_entity_state.rs +++ b/crates/hyperion/src/egress/sync_entity_state.rs @@ -35,8 +35,11 @@ use crate::{ entity_kind::EntityKind, event::{self, HitGroundEvent}, handlers::is_grounded, - metadata::{MetadataChanges, get_and_clear_metadata}, - projectile_motion::{MotionOrder, ProjectileMotion, lerp_rotation, look_angles}, + metadata::{MetadataChanges, arrow::InGround, get_and_clear_metadata}, + projectile_motion::{ + MotionOrder, ProjectileMotion, SHAKE_TICKS, ShakeTime, embed_point, lerp_rotation, + look_angles, + }, }, spatial::get_first_collision, storage::Events, @@ -515,18 +518,49 @@ impl Module for EntityStateSyncModule { &Owner, ?&ConnectionId, ?&mut Yaw, - ?&mut Pitch + ?&mut Pitch, + ?&mut InGround, + ?&mut ShakeTime ) .kind(id::()) .with_enum_wildcard::() .each_iter( - |it, row, (position, velocity, owner, connection_id, yaw, pitch)| { + |it, + row, + ( + position, + velocity, + owner, + connection_id, + mut yaw, + mut pitch, + in_ground, + mut shake, + )| { if let Some(_connection_id) = connection_id { return; } let world = it.world(); let arrow_entity = it.entity(row); + + // `AbstractArrow.tick:178-180`: the shake counts down at the + // top of the tick, before the early return below, so an + // embedded arrow's clock still runs. + if let Some(shake) = shake.as_deref_mut() + && shake.0 > 0 + { + shake.0 -= 1; + } + + // `AbstractArrow.tick:184-200`: an arrow in the ground does not + // move, does not lose speed to drag and does not fall. Vanilla + // returns here; so does this. It is also what stops the sweep + // re-reporting the face the arrow is resting on, every tick, + // forever. + if in_ground.as_ref().is_some_and(|flag| ***flag) { + return; + } // Prefer the per-instance `ProjectileMotion` that // `seed_projectile_motion` puts on every simulated kind, so an // ability can override one projectile's gravity or drag; fall @@ -543,10 +577,42 @@ impl Module for EntityStateSyncModule { if velocity.0 != Vec3::ZERO { let center = **position; - // getting max distance - let distance = velocity.0.length(); - - let ray = geometry::ray::Ray::new(center, velocity.0) * distance; + // The ray *is* this tick's travel: `Velocity` is blocks per + // tick, so the segment runs from here to here-plus-velocity + // and `t == 1` is the far end of it. It used to be scaled by + // the velocity's own length as well, which asked about a + // segment `|v|` times too long, and against an unbounded + // block scan that meant an arrow stopped dead as soon as + // anything was ahead of it on its heading rather than when + // it got there. + let ray = geometry::ray::Ray::new(center, velocity.0); + + // A projectile points where it is going, re-aimed off its + // velocity every tick. Without this an arrow keeps its + // launch orientation for its whole flight: the arc is right + // but it renders frozen at its loosed angle rather than + // nosing over as it falls. + // + // *When* it is re-aimed differs by integrator, and the + // difference is only visible on the tick a projectile stops. + // `AbstractArrow.tick` aims at lines 212-215, from the + // velocity it entered the tick with and **before** the clip + // at line 218 -- so an arrow that meets a wall this tick + // still turns to face the way it was going, and then holds + // that heading, because the in-ground branch returns at line + // 199 without reaching the rotation again. + // `ThrowableProjectile.tick:56` aims after its move instead. + // + // Doing this inside the miss branch, as it used to be, left + // the heading a tick stale on every arrow that landed. The + // differential gate caught it: `arrow-into-wall` pitch was + // -0.542 against vanilla's -1.018, exactly one `lerpRotation` + // step behind. + let aims_before_moving = + motion.is_some_and(|motion| motion.order == MotionOrder::MoveThenDecay); + if aims_before_moving { + aim_along(yaw.as_deref_mut(), pitch.as_deref_mut(), velocity.0); + } let Some(collision) = get_first_collision(ray, &world, Some(owner.entity)) else { @@ -556,34 +622,16 @@ impl Module for EntityStateSyncModule { // statements. `crates/hyperion/tests/differential.rs` // holds this against a recording of the real server. if let Some(motion) = motion { - // A projectile points where it is going, re-aimed off - // its velocity every tick. Without this an arrow keeps - // its launch orientation for its whole flight: the arc - // is right but it renders frozen at its loosed angle - // rather than nosing over as it falls. Vanilla reads - // the velocity at the moment its own tick calls - // `updateRotation`, and that moment differs by - // integrator: `AbstractArrow.tick` aims from the - // velocity it entered the tick with, before the move - // and decay, while `ThrowableProjectile.tick` applies - // gravity and drag first and aims from the result. So - // the arrow is aimed before the step and the thrown - // kind after it. - // The rotation easing still runs (it keeps the - // stored facing correct for anything server-side - // that reads it) but is no longer sent: the client - // re-derives an arrow's heading from its velocity. - let _rotation = match motion.order { - MotionOrder::MoveThenDecay => { - let rotation = aim_along(yaw, pitch, velocity.0); - motion.step(position, &mut velocity.0); - rotation - } - MotionOrder::DecayThenMove => { - motion.step(position, &mut velocity.0); - aim_along(yaw, pitch, velocity.0) - } - }; + motion.step(position, &mut velocity.0); + // The thrown kinds aim here, from the velocity the + // step left behind. The rotation easing still runs + // for both (it keeps the stored facing correct for + // anything server-side that reads it) but is not + // sent: the client re-derives a projectile's heading + // from its velocity. + if !aims_before_moving { + aim_along(yaw, pitch, velocity.0); + } // Tell every client watching this arrow how fast it // is going, every tick, and let the client dead-reckon @@ -620,7 +668,45 @@ impl Module for EntityStateSyncModule { }); } Either::Right(collision) => { - // send event + // `AbstractArrow.onHitBlock` + // (`AbstractArrow.java:484-502`), which is the whole + // of what an arrow does when it meets terrain: + // stand at the impact point backed off along the + // heading, stop dead, and go into the ground. + // + // In the engine and not left to each game module, + // because the three statements are one state: a + // module that zeroed the velocity a stage later -- + // which is what bedwars did -- left a tick in which + // the arrow was stopped by the world and still + // carrying its flight speed, and anything that read + // it in between got the stale one. + **position = embed_point(collision.point, velocity.0); + velocity.0 = Vec3::ZERO; + if let Some(in_ground) = in_ground { + **in_ground = true; + } + if let Some(shake) = shake { + shake.0 = SHAKE_TICKS; + } + + // `stepMoveAndHit` sets `needsSync` on a hit + // (`AbstractArrow.java:253`) so the stop reaches + // every watcher this tick. Without it the client + // keeps dead-reckoning the arrow it was last told + // about -- `AbstractArrow.tick` runs on the client + // too -- and draws it sailing on through the block + // the server stopped it at. + let id = arrow_entity.minecraft_id(); + world.get::<&Compose>(|compose| { + broadcast_projectile_velocity( + compose, + arrow_entity.into(), + id, + Vec3::ZERO, + ); + }); + world.get::<&mut Events>(|events| { events.push( event::ProjectileBlockEvent { diff --git a/crates/hyperion/src/egress/tab_list.rs b/crates/hyperion/src/egress/tab_list.rs new file mode 100644 index 000000000..5b16c68b2 --- /dev/null +++ b/crates/hyperion/src/egress/tab_list.rs @@ -0,0 +1,455 @@ +//! The tab list header and footer, and the server's own tick rate in it. +//! +//! Server telemetry is not a game concept, which is why the tick rate lives +//! here and not in an event crate -- the same reasoning as +//! [`crate::egress::server_load`], and the same honesty rule adapted to a +//! surface that has no bar to fill: +//! +//! > **both numbers are printed, the first is what the loop did and the second +//! > is what it was paced to.** +//! +//! ```text +//! TPS 19.8 / 20.0 +//! 3 players online +//! ``` +//! +//! A server keeping up prints them equal. That is the only way `20.0` can +//! appear, so a vanity constant cannot masquerade as a measurement: it would +//! have to survive [`Tps::absorb`], which counts real ticks. +//! +//! # Why a count of ticks and not flecs's frame time +//! +//! `world.info().frame_time_total` is the time spent *inside* frames, so it +//! excludes the sleep flecs does to hold 20 Hz. A rate derived from it answers +//! "how fast could this server tick" and reads near-infinite on an idle one, +//! which is a different question wearing the same units. Ticks per wall-clock +//! second is the number an operator means, and the only way to get it is to +//! count ticks against a clock that does not stop. +//! +//! One sample cannot carry a rate, so the first [`WINDOW`] reports +//! `TPS sampling` rather than a guess -- the same refusal +//! [`server_load`](crate::egress::server_load) makes for its first CPU window. +//! +//! # Who writes what +//! +//! The header is left for the event crate; hyperion writes the footer. The +//! split is arbitrary but it has to be *somewhere*, because both halves ride +//! in one packet and two writers with no rule fight every tick. That is not +//! hypothetical: bedwars used to broadcast a whole `TabList` unconditionally, +//! every tick, to every player. +//! +//! Nothing is sent unless the rendered text changed, which is what makes the +//! per-tick broadcast go away: at a steady 20.00 tps the label is stable and +//! this module sends nothing at all. A joining client is unicast the current +//! text instead, because a change-only broadcast is invisible to anyone who +//! was not connected when it happened. + +use std::{ + collections::VecDeque, + sync::atomic::Ordering, + time::{Duration, Instant}, +}; + +use flecs_ecs::prelude::*; +use hyperion_minecraft_proto::{ + generated::packet_id::play::clientbound::PacketId, + packets::play::clientbound::TabList as TabListPacket, text::NamedColor, +}; +use tracing::error; + +// Re-exported so an event crate writing the header names the text type +// without reaching into the boss bar module for it. +pub use crate::egress::boss_bar::Text; +use crate::{ + TICKS_PER_SECOND, + net::{Channel, Compose, ConnectionId, protocol::Clientbound}, +}; + +/// How long a tick-rate window is. +/// +/// Five seconds and not one: a one second window at 20 tps counts twenty +/// ticks, so a single scheduler hiccup reads as 19.0 and the footer flickers +/// at a number nobody can act on. A hundred ticks resolves to 0.2 tps, which +/// is finer than the one decimal the label prints. +const WINDOW: Duration = Duration::from_secs(5); + +/// The rolling window the tick rate is measured over. +#[derive(Component, Debug, Default)] +pub struct Tps { + /// When each tick inside the open window ran, oldest first. + ticks: VecDeque, + /// The very first tick, which is the only thing that can say whether this + /// server has been up for a whole window yet. + /// + /// Not derivable from `ticks`, and assuming otherwise is the bug this + /// field exists to fix: entries older than [`WINDOW`] are dropped, so the + /// oldest one retained is always *inside* the window and the span between + /// it and now is always a little short of a whole one. Gating on that span + /// therefore never opens -- except on perfectly regular ticks, where an + /// entry lands exactly on the boundary and is kept. That is precisely the + /// fixture the unit tests used, so they passed while a real server showed + /// `TPS sampling` forever. + first: Option, + /// Ticks per second over the last full window. + /// + /// `None` until [`WINDOW`] has elapsed. A partial window divided by the + /// whole window reads low, and a partial window divided by itself is one + /// sample pretending to be a rate. + pub rate: Option, +} + +impl Tps { + /// Fold one tick in. + fn absorb(&mut self, now: Instant) { + self.ticks.push_back(now); + let first = *self.first.get_or_insert(now); + + while let Some(&oldest) = self.ticks.front() { + if now.duration_since(oldest) > WINDOW { + self.ticks.pop_front(); + } else { + break; + } + } + + // Whether a whole window has passed is a question about the clock, not + // about what survived the pop above. See [`Self::first`]. + if now.duration_since(first) < WINDOW { + return; + } + + let Some(&oldest) = self.ticks.front() else { + return; + }; + let span = now.duration_since(oldest); + if span.is_zero() { + return; + } + + // `n` timestamps span `n - 1` intervals, and it is the intervals that + // have a rate. Counting the timestamps instead reports 20.2 tps on a + // server holding exactly 20, because both endpoints of a closed window + // are inside it. + // + // The rate is taken against the span of the entries actually retained + // rather than against `WINDOW`, so dropping the oldest tick does not + // read as a slower server. The subtraction cannot wrap: a non-zero + // span needs two distinct entries. + let intervals = self.ticks.len().saturating_sub(1); + self.rate = Some(intervals as f32 / span.as_secs_f32()); + } +} + +/// The two halves of the tab list, as the text they will be sent as. +/// +/// [`Text`] and never a `String` for the reason +/// [`boss_bar`](crate::egress::boss_bar) gives: a component cannot smuggle a +/// colour in as `§` markup. +#[derive(Component, Debug)] +pub struct TabList { + /// Drawn above the player list. Left for the event crate. + pub header: Text, + /// Drawn below it. Written by [`TabListModule`]. + pub footer: Text, + /// What every client currently has, so a tick that changes nothing sends + /// nothing. + sent: Option<(Text, Text)>, +} + +impl Default for TabList { + fn default() -> Self { + Self { + header: Text::text(""), + footer: Text::text(""), + sent: None, + } + } +} + +impl TabList { + /// Whether the text differs from what the clients were last told. + fn changed(&self) -> bool { + !self + .sent + .as_ref() + .is_some_and(|(header, footer)| *header == self.header && *footer == self.footer) + } +} + +/// The footer for a server that measured `rate` ticks per second while paced +/// to `target`, with `players` connected. +/// +/// Both numbers, always, and one colour regardless of either: a colour change +/// is an alarm, and `server_load`'s note on why its bars have no thresholds +/// applies here unchanged. +#[must_use] +pub fn footer_readout(rate: Option, target: f32, players: usize) -> Text { + let plural = if players == 1 { "player" } else { "players" }; + let tail = Text::text(format!("\n{players} {plural} online")).color(NamedColor::Gray); + + let Some(rate) = rate else { + return Text::text("TPS sampling") + .color(NamedColor::Gray) + .append(tail); + }; + Text::text(format!("TPS {rate:.1} / {target:.1}")) + .color(NamedColor::Aqua) + .append(tail) +} + +/// Send the current header and footer to one client. +fn unicast(compose: &Compose, list: &TabList, connection_id: ConnectionId) -> anyhow::Result<()> { + let packet = TabListPacket { + header: list.header.to_tag(), + footer: list.footer.to_tag(), + }; + compose.unicast( + Clientbound::new(PacketId::TabList.to_raw(), &packet), + connection_id, + ) +} + +/// Registration module for the tab list: the [`TabList`] text and the [`Tps`] +/// window, both singletons. +/// +/// Registration only, per the flecs convention in the root `CLAUDE.md`. An +/// event crate that wants to write the header imports this and nothing else. +#[derive(Component)] +pub struct TabListComponentsModule; + +impl Module for TabListComponentsModule { + fn module(world: &World) { + // Registered with the trait before the value is set. A bare `set` + // stores the value without registering the type, which is the + // dev-only ECS_INVALID_OPERATION abort of ENG-11000. + world.component::().add_trait::(); + world.set(Tps::default()); + + world.component::().add_trait::(); + world.set(TabList::default()); + } +} + +/// Behavior module for the tab list: the tick sampler, the change-only +/// broadcast, and the unicast that catches a joining client up. +#[derive(Component)] +pub struct TabListModule; + +impl Module for TabListModule { + fn module(world: &World) { + world.import::(); + + // PreStore, so a reading taken this tick is on `TabList` before + // `tab_list_sync` runs in OnStore and reaches the wire the same tick + // rather than the next one. Same placement, and the same reason, as + // `server_load_sample`. + world + .system_named::<(&Compose, &mut Tps, &mut TabList)>("tab_list_sample") + .kind(id::()) + .each(|(compose, tps, list)| { + tps.absorb(Instant::now()); + let players = compose.global().player_count.load(Ordering::Relaxed); + let footer = footer_readout(tps.rate, TICKS_PER_SECOND, players); + if list.footer != footer { + list.footer = footer; + } + }); + + world + .system_named::<(&Compose, &mut TabList)>("tab_list_sync") + .kind(id::()) + .each(|(compose, list)| { + if !list.changed() { + return; + } + let packet = TabListPacket { + header: list.header.to_tag(), + footer: list.footer.to_tag(), + }; + let sent = compose + .broadcast(Clientbound::new(PacketId::TabList.to_raw(), &packet)) + .send(); + if let Err(error) = sent { + error!("failed to broadcast the tab list: {error}"); + return; + } + // Recorded only on a send that worked, so a failed encode is + // retried next tick rather than remembered as delivered. + list.sent = Some((list.header.clone(), list.footer.clone())); + }); + + // A change-only broadcast is invisible to anyone who was not connected + // when the change happened, so a joining client is handed the current + // text. `Channel` is added at the end of `enter_world`, which is after + // the roster goes out, so this rides along with the rest of the join + // burst. + world + .observer_named::("tab_list_on_join") + .with(id::()) + .each_entity(|entity, ()| { + let Some(connection_id) = entity.try_get::<&ConnectionId>(|id| *id) else { + return; + }; + entity + .world() + .get::<(&Compose, &TabList)>(|(compose, list)| { + if let Err(error) = unicast(compose, list, connection_id) { + error!("failed to send the tab list to a joining player: {error}"); + } + }); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One tick has no rate in it, and neither has any number of ticks over + /// less than a window. + #[test] + fn a_partial_window_reports_nothing_rather_than_a_guess() { + let mut tps = Tps::default(); + let start = Instant::now(); + tps.absorb(start); + assert_eq!(tps.rate, None); + assert_eq!( + footer_readout(tps.rate, 20.0, 0).plain(), + "TPS sampling\n0 players online" + ); + + // Four seconds of perfect ticking is still not a window. + for tick in 1..=80 { + tps.absorb(start + Duration::from_millis(50 * tick)); + } + assert_eq!(tps.rate, None); + } + + /// A server holding the pace prints the pace, and prints it *because* it + /// counted a hundred intervals, not because 20.0 was typed anywhere. + #[test] + fn a_server_keeping_up_reads_exactly_the_target() { + let mut tps = Tps::default(); + let start = Instant::now(); + for tick in 0..=200 { + tps.absorb(start + Duration::from_millis(50 * tick)); + } + assert_eq!(tps.rate, Some(20.0)); + assert_eq!( + footer_readout(tps.rate, 20.0, 3).plain(), + "TPS 20.0 / 20.0\n3 players online" + ); + } + + /// The number the whole feature exists for: a server that fell behind says + /// so. Ten ticks a second is half the pace, and the label prints the half + /// beside the whole rather than normalising one away. + #[test] + fn a_server_falling_behind_prints_what_it_managed() { + let mut tps = Tps::default(); + let start = Instant::now(); + for tick in 0..=100 { + tps.absorb(start + Duration::from_millis(100 * tick)); + } + assert_eq!(tps.rate, Some(10.0)); + assert_eq!( + footer_readout(tps.rate, 20.0, 1).plain(), + "TPS 10.0 / 20.0\n1 player online" + ); + } + + /// The window slides: a burst of slow ticks ages out and the reading + /// recovers, rather than being held down by a stall that is over. + #[test] + fn the_window_forgets_a_stall_once_it_is_out_of_range() { + let mut tps = Tps::default(); + let start = Instant::now(); + // Six seconds at half pace. + for tick in 0..=60 { + tps.absorb(start + Duration::from_millis(100 * tick)); + } + assert_eq!(tps.rate, Some(10.0)); + + // Then six seconds at full pace, which is more than one window, so + // nothing from the slow stretch is left in it. + let recovered = start + Duration::from_millis(6000); + for tick in 1..=120 { + tps.absorb(recovered + Duration::from_millis(50 * tick)); + } + assert_eq!(tps.rate, Some(20.0)); + } + + /// Real ticks are not evenly spaced, and this is the test that says so. + /// + /// Every other test here feeds exact multiples of 50 ms, which quietly + /// makes one entry land *exactly* on the window boundary and survive the + /// pop -- so the retained span comes out at exactly [`WINDOW`]. A real + /// server jitters, no entry lands on the boundary, the retained span is + /// always a hair under a window, and a readiness check written against + /// that span never fires. That shipped: the gate showed `TPS sampling` + /// with `n=83 span=4988ms` for twenty seconds while six unit tests passed. + /// + /// So the fixture here is deliberately irregular, and the assertion is the + /// one those six could not make: that a rate appears at all. + #[test] + fn a_server_whose_ticks_jitter_still_reports_a_rate() { + let mut tps = Tps::default(); + let start = Instant::now(); + + // A small LCG, so the offsets neither repeat on a period that divides + // the window nor land on a whole millisecond boundary pattern. + let mut seed = 12_345_u64; + let mut at = start; + let mut ticks = 0_u32; + for _ in 0..400 { + seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + let jitter = u64::from((seed >> 33) as u32 % 9_000); + at += Duration::from_micros(46_000 + jitter); + tps.absorb(at); + ticks += 1; + if ticks > 120 { + assert!( + tps.rate.is_some(), + "after {ticks} jittered ticks over {:?} there is still no rate", + at.duration_since(start) + ); + } + } + + // ~50.5 ms a tick on average, so a shade under 20. + let rate = tps.rate.expect("a rate after 400 ticks"); + assert!( + (19.0..=20.5).contains(&rate), + "{rate} tps is not what a 46-55 ms tick produces" + ); + } + + /// The window is bounded, so a server that runs for a week holds a hundred + /// timestamps and not a week of them. + #[test] + fn the_window_does_not_grow_without_bound() { + let mut tps = Tps::default(); + let start = Instant::now(); + for tick in 0..=20_000 { + tps.absorb(start + Duration::from_millis(50 * tick)); + } + assert_eq!(tps.ticks.len(), 101); + } + + /// Nothing is sent for a tick that changed nothing, which is what stops + /// this being the per-tick broadcast it replaces. + #[test] + fn an_unchanged_label_is_not_resent() { + let mut list = TabList::default(); + assert!( + list.changed(), + "a client that has been told nothing needs telling" + ); + + list.sent = Some((list.header.clone(), list.footer.clone())); + assert!(!list.changed()); + + list.footer = footer_readout(Some(19.8), 20.0, 2); + assert!(list.changed()); + } +} diff --git a/crates/hyperion/src/lib.rs b/crates/hyperion/src/lib.rs index 6c67f2ab6..9adadcbba 100644 --- a/crates/hyperion/src/lib.rs +++ b/crates/hyperion/src/lib.rs @@ -73,6 +73,7 @@ use crate::{ util::mojang::ApiProvider, }; +pub mod console; pub mod effects; pub mod egress; pub mod ingress; @@ -80,6 +81,7 @@ pub mod net; pub mod simulation; pub mod spatial; pub mod storage; +pub mod tick_loop; /// Relationship for previous values #[derive(Component)] @@ -259,10 +261,8 @@ impl HyperionCore { value: shutdown.clone(), }); - world.component::(); - // Minecraft tick rate is 20 ticks per second - world.set_target_fps(20.0); + world.set_target_fps(TICKS_PER_SECOND); // todo: sadly this requires u32 // .bit("on_fire", *EntityFlags::ON_FIRE) @@ -303,6 +303,13 @@ impl HyperionCore { .component::() .add_trait::(); world.component::().add_trait::(); + // Empty unless an operator console registers. Registered here rather + // than in whatever installs an observer, so the packet handler can + // read it on a server that has no console at all. + world + .component::() + .add_trait::(); + world.set(console::ChatObservers::default()); world.component::().add_trait::(); world .component::() @@ -330,7 +337,6 @@ impl HyperionCore { .component::() .add_trait::(); world.component::(); - world.component::().add_trait::(); world .component::() diff --git a/crates/hyperion/src/net/mod.rs b/crates/hyperion/src/net/mod.rs index 87e1a60c9..2c5c47d44 100644 --- a/crates/hyperion/src/net/mod.rs +++ b/crates/hyperion/src/net/mod.rs @@ -1,6 +1,6 @@ //! All the networking related code. -use std::{cell::RefCell, fmt::Debug}; +use std::{cell::RefCell, fmt::Debug, sync::Arc}; use byteorder::WriteBytesExt; use bytes::{Bytes, BytesMut}; @@ -12,7 +12,7 @@ use hyperion_utils::EntityExt; use libdeflater::CompressionLvl; use rustc_hash::FxHashMap; use thread_local::ThreadLocal; -use tracing::error; +use tracing::{error, warn}; use crate::{ Global, PacketBundle, Scratch, @@ -387,6 +387,33 @@ impl Compose { } } +/// A [`ConnectionId`] with no socket behind it. +/// +/// Everything that answers a player -- a command's reply, a permission +/// refusal, a parse error -- is written as `caller.get::<&ConnectionId>()` and +/// a `unicast`, in dozens of places across `hyperion-clap` and both events. So +/// a caller that is not a player has exactly two options: teach every one of +/// those call sites about a second kind of reply, or give it a connection id +/// and answer to that. This is the second one. +/// +/// The frames arrive here already framed, exactly as the proxy would have +/// received them, because that is the last point where a packet is still one +/// contiguous thing. Whoever installs this is expected to run them back +/// through a [`hyperion_minecraft_proto::framing::FrameDecoder`], which is the +/// same code a client uses and so cannot drift from what was sent. +/// +/// Without this the id belongs to nobody and the proxy says so, once per +/// packet: `Player not found for id ...`, a warning that names the wrong +/// cause. +pub trait VirtualConnection: Send + Sync { + /// The id that means "me". Compared against every unicast, so this must be + /// cheap and must not change. + fn stream(&self) -> ConnectionId; + + /// One whole framed packet addressed to [`stream`](Self::stream). + fn deliver(&self, frame: &[u8]); +} + /// This is useful for the ECS, so we can use Single<&mut Broadcast> instead of having to use a marker struct #[derive(Component, Default)] pub struct IoBuf { @@ -394,9 +421,25 @@ pub struct IoBuf { // broadcast_buffer: ThreadLocal>, temp_buffer: ThreadLocal>, egress_comms: FxHashMap, + /// Installed by an operator console and absent otherwise, which is what + /// keeps this off the cost of a server nobody is watching: one + /// `Option::is_none` per unicast and nothing at all per broadcast. + virtual_connection: Option>, } impl IoBuf { + /// Route every unicast addressed to `connection.stream()` to it rather + /// than to a proxy. + /// + /// Replaces any previous one: there is one console per server, and two + /// silently sharing a stream id would each see half the traffic. + pub fn attach_virtual_connection(&mut self, connection: Arc) { + if self.virtual_connection.is_some() { + warn!("replacing an already attached virtual connection"); + } + self.virtual_connection = Some(connection); + } + pub(crate) fn add_proxy(&mut self, proxy_id: ProxyId, egress_comm: EgressComm) { let already_exists = self.egress_comms.insert(proxy_id, egress_comm).is_some(); @@ -674,6 +717,16 @@ impl IoBuf { } pub(crate) fn unicast_raw(&self, data: &[u8], stream: ConnectionId) { + // Before the proxy, because a virtual connection has none: an id with + // no socket behind it makes the proxy warn once per packet about a + // player that was never going to be there. + if let Some(virtual_connection) = self.virtual_connection.as_ref() + && virtual_connection.stream() == stream + { + virtual_connection.deliver(data); + return; + } + self.add_proxy_message(&IntermediateServerToProxyMessage::Unicast( intermediate::Unicast { stream, data }, )); @@ -783,11 +836,17 @@ pub mod test_util { pub(crate) mod tests { use std::sync::Arc; + // These three reach only `next_variant` and the `#[test]` functions at the + // bottom of this module, all of which are `cfg(test)`. Under `test-util` + // alone they are unused imports and `-D warnings` rejects them. + #[cfg(test)] use hyperion_proxy_proto::ArchivedServerToProxyMessage; + #[cfg(test)] + use super::{ChannelId, ConnectionId}; // Named rather than a glob: `test-util` compiles this module outside // `cfg(test)`, where the workspace's pedantic `wildcard_imports` applies. - use super::{ChannelId, Compose, CompressionLvl, ConnectionId, IoBuf, ProxyId}; + use super::{Compose, CompressionLvl, IoBuf, ProxyId}; use crate::{CompressionThreshold, Global, common::Shared, simulation::EgressComm}; /// A [`Compose`] with two proxies registered, and the receiving end of each proxy's channel. @@ -821,6 +880,14 @@ pub(crate) mod tests { } /// Reads one framed message off a proxy's channel and tells you which variant it was. + /// + /// `cfg(test)` and not `test-util`, because the only callers are this crate's own + /// tests. `test-util` compiles this module into the LIB target, where an item nothing + /// outside `cfg(test)` calls is dead code -- and `checks.clippy` runs + /// `--all-targets --all-features -- -D warnings`, so dead code there is a build + /// failure rather than a warning. Widen this to the whole module the day something + /// outside the crate needs it. + #[cfg(test)] pub fn next_variant( rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Option { diff --git a/crates/hyperion/src/simulation/blocks/mod.rs b/crates/hyperion/src/simulation/blocks/mod.rs index 2c41b4df0..36bd833a7 100644 --- a/crates/hyperion/src/simulation/blocks/mod.rs +++ b/crates/hyperion/src/simulation/blocks/mod.rs @@ -60,11 +60,23 @@ pub enum TrySetBlockDeltaError { #[derive(Debug, Copy, Clone)] pub struct RayCollision { + /// How far along the ray contact happened, in units of the ray's + /// direction: `0.0` at the origin and `1.0` at `origin + direction`. A + /// fraction and not a count of blocks, so it compares directly against + /// what [`geometry::aabb::Aabb::intersect_ray`] returns for the same ray. pub distance: f32, pub location: IVec3, pub point: Vec3, pub normal: Vec3, pub block: BlockState, + /// The ray began inside this block's collision shape rather than crossing + /// into it, which is vanilla's `BlockHitResult.isInside`. + /// + /// Worth carrying rather than collapsing: a projectile loosed from inside + /// a wall and one that flew into it are told apart by nothing else, and + /// the reported point is a probe a thousandth of the way along rather than + /// a surface. + pub inside: bool, } /// Accessor of blocks. @@ -120,42 +132,45 @@ impl Blocks { }) } + /// The first block surface on the segment `ray.origin()` -> + /// `ray.origin() + ray.direction()`, or `None` if it is clear. + /// + /// **The direction is the whole of the length asked about.** A caller with + /// a start and an end builds the ray with [`Ray::from_points`]; a caller + /// with a reach scales a unit direction by it. Anything beyond that is a + /// hit on a later tick and is not reported, which is the difference between + /// an arrow stopping at the wall in front of it and an arrow stopping in + /// mid-air because there is a wall somewhere out along its heading. + /// + /// Full block collision shapes, so a slab, a stair and a fence are each hit + /// where they actually are rather than anywhere in their cell. #[must_use] pub fn first_collision(&self, ray: Ray) -> Option { - // Define bounds for the voxel traversal - let bounds_min = IVec3::new(i32::MIN / 2, -64, i32::MIN / 2); - let bounds_max = IVec3::new(i32::MAX / 2, 320, i32::MAX / 2); - - // Use voxel traversal to efficiently walk through blocks - for cell in ray.voxel_traversal(bounds_min, bounds_max) { - if let Some(block) = self.get_block(cell) { - let origin = cell.as_vec3(); - - // Check collision with block shapes - let collision = block - .collision_shapes() - .map(|shape| Aabb::new(shape.min().as_vec3(), shape.max().as_vec3())) - .map(|shape| shape + origin) - .filter_map(|shape| shape.intersect_ray(&ray)) - .min(); - - if let Some(distance) = collision { - let distance = distance.into_inner(); - let collision_point = ray.origin() + ray.direction() * distance; - let collision_normal = (collision_point - origin).normalize(); - - return Some(RayCollision { - distance, - location: cell, - point: collision_point, - normal: collision_normal, - block, - }); - } - } - } - - None + let hit = geometry::sweep::first_block_hit( + ray.origin(), + ray.origin() + ray.direction(), + |cell| { + self.get_block(cell).into_iter().flat_map(|block| { + translate::collision_shapes(block) + .iter() + .copied() + .map(Aabb::from) + }) + }, + )?; + + Some(RayCollision { + distance: hit.time, + location: hit.block, + point: hit.point, + normal: hit.normal, + inside: hit.inside, + // A second lookup rather than carrying the state out through the + // traversal: the shape source is a `BlockState` here and a set of + // coordinates in a test, and threading a payload through + // `first_block_hit` for one of the two would buy one map lookup. + block: self.get_block(hit.block)?, + }) } #[must_use] diff --git a/crates/hyperion/src/simulation/blocks/translate.rs b/crates/hyperion/src/simulation/blocks/translate.rs index 66e3bae73..eac4c6876 100644 --- a/crates/hyperion/src/simulation/blocks/translate.rs +++ b/crates/hyperion/src/simulation/blocks/translate.rs @@ -16,10 +16,17 @@ //! //! Biomes have the same problem in miniature and are handled the same way, in //! [`biome_ids`]. +//! +//! Collision shapes go the same way for the same reason, in +//! [`collision_shapes`]: valence's table describes 1.20.1's geometry, and what +//! an entity stops against has to be the geometry the client is rendering. use std::sync::LazyLock; -use hyperion_minecraft_proto::block_state; +use hyperion_minecraft_proto::{ + block_state, + collision_shape::{self, CollisionBox}, +}; use valence_generated::block::BlockState; use valence_protocol::Ident; use valence_registry::{BiomeRegistry, RegistryIdx, biome::BiomeId}; @@ -54,6 +61,27 @@ pub fn block_state(state: BlockState) -> u32 { BLOCK_STATES[usize::from(state.to_raw())] } +/// The 26.2 collision boxes of a 1.20.1 block state, in the block's own +/// coordinates. +/// +/// Use this rather than [`BlockState::collision_shapes`], which answers out of +/// valence's checked-in 1.20.1 table. They agree on all but one of the 24135 +/// states a 1.20.1 world can hold, which the `shapes_changed_since_1_20_1` +/// test below both measures and pins; the reason to ask 26.2 anyway is that +/// 26.2 is what the client deciding where a player may stand is running. +/// +/// A state with no boxes is passed through -- air, a torch, tall grass -- so +/// an empty slice is the answer for "nothing to collide with" and not a +/// failure to look it up. +#[must_use] +pub fn collision_shapes(state: BlockState) -> &'static [CollisionBox] { + // Total by construction: every id [`block_state`] returns comes out of the + // same jar's registry that the shape table was extracted from, and the two + // tables assert against each other's state count at compile time. + collision_shape::collision_shape(block_state(state)) + .expect("every protocol 776 state id has a collision shape") +} + /// The name 26.2 knows a 1.20.1 block by. fn renamed(name: &str) -> &str { RENAMED_BLOCKS @@ -159,7 +187,114 @@ pub fn biome_name_to_id(biomes: &BiomeRegistry) -> std::collections::BTreeMap = state + .collision_shapes() + .map(|shape| { + let (min, max) = (shape.min().as_vec3(), shape.max().as_vec3()); + [min.x, min.y, min.z, max.x, max.y, max.z] + }) + .collect(); + if old != collision_shapes(state) { + changed.push(state); + } + } + + assert_eq!( + changed, + vec![expected], + "the shapes 26.2 disagrees with 1.20.1 about have moved; each entry is a block whose \ + collision geometry a player will feel change" + ); + } /// Named states, checked against `block_state.rs`'s own table. /// diff --git a/crates/hyperion/src/simulation/chat.rs b/crates/hyperion/src/simulation/chat.rs new file mode 100644 index 000000000..6d79cde73 --- /dev/null +++ b/crates/hyperion/src/simulation/chat.rs @@ -0,0 +1,58 @@ +//! Text a player typed, made safe to put inside a component. +//! +//! A `SystemChat` payload built from a literal string is rendered by the +//! client's `StringDecomposer`, which applies legacy section-sign codes as it +//! reads. That is deliberate and load bearing -- +//! [`crate::net::agnostic::chat`] carries `§c` for exactly this reason -- and +//! it is why player text cannot be dropped into a component unchanged: to the +//! client there is no difference between a colour the server chose and one a +//! player typed. + +/// U+00A7, the character the client's legacy formatter looks for. +/// +/// Spelled as an escape rather than as itself so that this stays greppable and +/// so that `nix/text.nix` -- which fails the build on a section sign anywhere +/// in smash's text path or the proto crate -- keeps meaning what it says. That +/// gate is about *emitting* one; this is the one place whose whole job is +/// removing them, and it lives here rather than in an event so no event has to +/// spell the character at all. +const SECTION_SIGN: char = '\u{a7}'; + +/// `message` with every formatting escape removed. +/// +/// Dropped rather than escaped: the legacy scheme has no escape for its own +/// introducer, and no message a person means to send contains one. What this +/// prevents is a client painting its own text -- `§k` scrambles the glyphs, +/// `§0`..`§f` recolours them, and `§4[Server] restarting` is a line that looks +/// like it came from the server. A vanilla client will not send one, which is +/// precisely why leaving it in only ever helps a bot. +#[must_use] +pub fn strip_formatting(message: &str) -> String { + message.replace(SECTION_SIGN, "") +} + +#[cfg(test)] +mod tests { + use super::strip_formatting; + + #[test] + fn ordinary_text_is_untouched() { + assert_eq!(strip_formatting("gg <3 100% !"), "gg <3 100% !"); + } + + #[test] + fn every_sign_goes_and_nothing_else_does() { + assert_eq!( + strip_formatting("\u{a7}4[Server] restarting \u{a7}kNOW"), + "4[Server] restarting kNOW" + ); + } + + #[test] + fn a_trailing_sign_with_no_code_after_it_goes_too() { + // The client's formatter needs a character after the sign, so a bare + // trailing one renders as nothing. Removing it anyway keeps this a + // statement about the character rather than about pairs. + assert_eq!(strip_formatting("bye\u{a7}"), "bye"); + } +} diff --git a/crates/hyperion/src/simulation/handlers.rs b/crates/hyperion/src/simulation/handlers.rs index 150ed09c8..ab6ccd29a 100644 --- a/crates/hyperion/src/simulation/handlers.rs +++ b/crates/hyperion/src/simulation/handlers.rs @@ -25,7 +25,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use anyhow::bail; -use flecs_ecs::core::{Entity, EntityView, EntityViewGet, World, id}; +use flecs_ecs::core::{Entity, EntityView, EntityViewGet, World, WorldGet, id}; use geometry::aabb::Aabb; use glam::{DVec3, IVec3, Vec3}; use hyperion_minecraft_proto::{ @@ -62,7 +62,9 @@ use crate::{ protocol::{decode_body, frame_body, send}, }, simulation::{ - Pitch, Yaw, aabb, event, gamemode, + Name, Pitch, Yaw, aabb, + blocks::translate, + event, gamemode, metadata::{ entity::Pose, living_entity::HandStates, @@ -149,6 +151,7 @@ pub fn route(id: i32) -> Route { PacketId::ClientInformation => Route::Act(client_information), PacketId::CommandSuggestion => Route::Act(command_suggestion), PacketId::Interact => Route::Act(interact), + PacketId::KeepAlive => Route::Act(keep_alive), PacketId::ContainerClose => Route::Act(container_close), PacketId::MovePlayerPos => Route::Act(move_player_pos), PacketId::MovePlayerPosRot => Route::Act(move_player_pos_rot), @@ -169,8 +172,9 @@ pub fn route(id: i32) -> Route { // // - `client_tick_end` arrives every tick, `chunk_batch_received` after // every batch, and this server sends chunks without pacing them. - // - `keep_alive`, `pong` and `ping_request` are liveness only; nothing - // here times a connection out on them yet. + // - `pong` and `ping_request` are liveness only; nothing here times a + // connection out on them yet. `keep_alive` used to be in this list + // and is not any more: it is what `egress::ping` measures with. // - `chat_ack` and `chat_session_update` belong to signed chat, which // this server does not verify. // - `player_loaded` is the client saying its terrain finished loading, @@ -184,7 +188,6 @@ pub fn route(id: i32) -> Route { | PacketId::ClientTickEnd | PacketId::CookieResponse | PacketId::CustomPayload - | PacketId::KeepAlive | PacketId::PingRequest | PacketId::PlayerLoaded | PacketId::Pong @@ -291,6 +294,22 @@ fn interact(body: &[u8], query: &mut PacketSwitchQuery<'_>) -> anyhow::Result<() fn chat(body: &[u8], query: &mut PacketSwitchQuery<'_>) -> anyhow::Result<()> { let packet: serverbound::Chat<'_> = decode_body(body)?; + // Before the queue, because the queue has one consumer and the game is it. + // See `crate::console`. The name is resolved here because this is the last + // place that has a world; an observer does not. + query + .world + .get::<&crate::console::ChatObservers>(|observers| { + if observers.is_empty() { + return; + } + let speaker = query.id.entity_view(query.world); + let name = speaker + .try_get::<&Name>(ToString::to_string) + .unwrap_or_else(|| query.id.to_string()); + observers.player_said(query.id, &name, packet.message); + }); + query.events.push( event::ChatMessage { msg: packet.message.to_owned().into(), @@ -377,6 +396,20 @@ fn command_suggestion(body: &[u8], query: &mut PacketSwitchQuery<'_>) -> anyhow: ) } +/// A client's answer to the keep-alive [`crate::egress::ping`] sent it. +/// +/// The clock is read here, at the first point in the process that has the +/// answer, because everything between this and the probe is what the number +/// is meant to contain. What it cannot see is the wait before this system ran +/// at all, which is up to one tick and is named in that module's docs. +fn keep_alive(body: &[u8], query: &mut PacketSwitchQuery<'_>) -> anyhow::Result<()> { + let c2s::KeepAlive(id) = decode_body(body)?; + + crate::egress::ping::absorb_answer(query.view, id); + + Ok(()) +} + fn container_close(body: &[u8], query: &mut PacketSwitchQuery<'_>) -> anyhow::Result<()> { let c2s::ContainerClose(_container_id) = decode_body(body)?; @@ -714,11 +747,10 @@ fn use_item_on(body: &[u8], query: &mut PacketSwitchQuery<'_>) -> anyhow::Result // todo(hack): technically players can do some crazy position stuff to abuse this probably let player_aabb = aabb(**query.position, *query.size); - let collides_player = block_state - .collision_shapes() - .map(|aabb| { - Aabb::new(aabb.min().as_vec3(), aabb.max().as_vec3()).move_by(position.as_vec3()) - }) + let collides_player = translate::collision_shapes(block_state) + .iter() + .copied() + .map(|shape| Aabb::from(shape).move_by(position.as_vec3())) .any(|block_aabb| Aabb::overlap(&block_aabb, &player_aabb).is_some()); if collides_player { @@ -900,9 +932,8 @@ fn has_block_collision(position: &Vec3, size: EntitySize, blocks: &Blocks) -> bo let res = blocks.get_blocks(min, max, |pos, block| { let pos = Vec3::new(pos.x as f32, pos.y as f32, pos.z as f32); - for aabb in block.collision_shapes() { - let aabb = Aabb::new(aabb.min().as_vec3(), aabb.max().as_vec3()); - let aabb = aabb.move_by(pos); + for shape in translate::collision_shapes(block) { + let aabb = Aabb::from(*shape).move_by(pos); if shrunk.collides(&aabb) { return ControlFlow::Break(false); diff --git a/crates/hyperion/src/simulation/metadata/arrow.rs b/crates/hyperion/src/simulation/metadata/arrow.rs new file mode 100644 index 000000000..9bbc57d46 --- /dev/null +++ b/crates/hyperion/src/simulation/metadata/arrow.rs @@ -0,0 +1,42 @@ +//! Tracked data every arrow has +//! (`net.minecraft.world.entity.projectile.arrow.AbstractArrow`). +//! +//! Provenance of the indices: see [`super::entity`]. Read out of the pinned +//! 26.2 jar the same way, by reflecting over `AbstractArrow`'s static +//! `EntityDataAccessor` fields and calling `id()` on each after +//! `SharedConstants.tryDetectVersion()` and `Bootstrap.bootStrap()`. The same +//! run reproduced `super::living_entity`'s table (8..14) unchanged, which is +//! what says the method is reading the same numbering that one was built from. +//! +//! It agrees with counting the declarations: `Entity` declares eight accessors +//! (`Entity.java:284-298`), `Projectile` declares none, and `AbstractArrow`'s +//! three follow at `AbstractArrow.java:71-73` in that order. +//! +//! ```text +//! index serializer accessor +//! 8 Byte (0) ID_FLAGS 0 +//! 9 Byte (0) PIERCE_LEVEL 0 +//! 10 Boolean (8) IN_GROUND false +//! ``` +//! +//! Only index 10 is here. The other two exist on the wire and nothing this +//! server does writes them: 8 is the crit and no-physics flag pair +//! (`AbstractArrow.java:74-75`) and 9 is piercing, which needs an enchantment. + +use flecs_ecs::prelude::*; + +use super::Metadata; +use crate::define_and_register_components; + +define_and_register_components! { + // 8 ID_FLAGS and 9 PIERCE_LEVEL are deliberately absent; see the module + // note. Leaving a gap rather than defining a field nothing writes keeps + // the table to what this server actually sends. + 10, InGround -> bool, +} + +impl Default for InGround { + fn default() -> Self { + Self::new(false) + } +} diff --git a/crates/hyperion/src/simulation/metadata/mod.rs b/crates/hyperion/src/simulation/metadata/mod.rs index bca0f6c30..7710427a4 100644 --- a/crates/hyperion/src/simulation/metadata/mod.rs +++ b/crates/hyperion/src/simulation/metadata/mod.rs @@ -37,6 +37,7 @@ use crate::{ }, }; +pub mod arrow; pub mod block_display; pub mod display; pub mod entity; @@ -48,6 +49,8 @@ pub mod player; pub struct MetadataPrefabs { pub entity_base: Entity, + pub arrow_base: Entity, + pub display_base: Entity, pub block_display_base: Entity, @@ -136,6 +139,12 @@ pub fn register_prefabs(world: &World) -> MetadataPrefabs { .component_and_track::() .id(); + // Arrows carry one tracked field of their own, `IN_GROUND`, and it is what + // stops a client dead-reckoning an arrow through the wall the server + // stopped it at: `AbstractArrow.tick` returns before moving whenever it is + // set (`AbstractArrow.java:184-200`). + let arrow_base = arrow::register_prefab(world, Some(entity_base)).id(); + let display_base = display::register_prefab(world, Some(entity_base)).id(); let block_display_base = block_display::register_prefab(world, Some(display_base)).id(); @@ -152,6 +161,7 @@ pub fn register_prefabs(world: &World) -> MetadataPrefabs { MetadataPrefabs { entity_base, + arrow_base, display_base, block_display_base, item_base, @@ -208,16 +218,6 @@ macro_rules! define_metadata_component { value: $type, } - #[allow(warnings)] - impl PartialOrd for $name - where - $type: PartialOrd, - { - fn partial_cmp(&self, other: &Self) -> Option { - self.value.partial_cmp(&other.value) - } - } - impl Metadata for $name { type Type = $type; @@ -357,6 +357,14 @@ impl MetadataChanges { EntityKind::Item => { item::encode_non_default_components(entity, self); } + // Every kind that reaches `AbstractArrow.tick`, which is the same + // set `projectile_motion::SIMULATED` gives `MotionOrder:: + // MoveThenDecay`. A subscriber joining after an arrow has already + // landed has to be told it is in the ground, or its client starts + // simulating a stopped arrow forwards. + EntityKind::Arrow | EntityKind::SpectralArrow | EntityKind::Trident => { + arrow::encode_non_default_components(entity, self); + } _ => {} } } diff --git a/crates/hyperion/src/simulation/mod.rs b/crates/hyperion/src/simulation/mod.rs index c3a26d8e3..adfa03a11 100644 --- a/crates/hyperion/src/simulation/mod.rs +++ b/crates/hyperion/src/simulation/mod.rs @@ -57,6 +57,7 @@ use crate::{ pub mod animation; pub mod blocks; +pub mod chat; pub mod command; pub mod entity_kind; pub mod event; @@ -284,6 +285,10 @@ impl Module for SimComponentsModule { // `Player`-implies-`InventoryState` traits below have a registered // component to point at. world.import::(); + // Same reason, for the `Player`-implies-`Ping` trait: the round trip + // measurement is `egress::ping`'s, but "a player has one" is a + // statement about `Player`, which this module owns. + world.import::(); // Registers every remaining simulation component and sets the // `MetadataPrefabs` singleton, which `SimModule`'s observers read back // to pick a prefab base per entity kind. @@ -335,6 +340,15 @@ impl Module for ReflectionComponentsModule { /// registers as an opaque serialised through `Display` because flecs aborts if /// a type is registered as both a struct and an opaque. fn register_reflection(world: &World) { + // `Prev` is a relation and `metadata::register_prefabs`, further down + // this module's own registration chain, builds `(Prev, T)` pairs out of + // it, so it has to be a registered entity before any of them exist. It + // used to be registered only by `HyperionCore`, which meant importing + // `SimComponentsModule` into a bare world -- the thing the convention in + // the root `CLAUDE.md` promises works -- aborted a dev build with + // `ECS_INVALID_OPERATION: Component hyperion::Prev is not registered`. + world.component::(); + component!(world, VarInt).member(id::(), "x"); // `EntitySize` registers as an opaque, which needs the component to exist @@ -416,6 +430,10 @@ fn register_components(world: &World) -> MetadataPrefabs { world.component::(); world.component::(); + // Registered with its trait before `component!` annotates it, and before + // anything sets it. Only `HyperionCore` used to do this, which is why + // importing this module alone aborted here in a dev build. + world.component::().add_trait::(); component!(world, IgnMap); world.component::(); @@ -452,6 +470,9 @@ fn register_components(world: &World) -> MetadataPrefabs { world .component::() .add_trait::<(flecs::With, hyperion_inventory::InventoryState)>(); + world + .component::() + .add_trait::<(flecs::With, crate::egress::ping::Ping)>(); prefabs } @@ -528,6 +549,15 @@ fn register_observers(world: &World, prefabs: MetadataPrefabs) { .flatten() { entity.set(motion); + // The shake clock belongs to the arrow tick and only to it: + // `ThrowableProjectile` has no such state, because a snowball + // that hits something is discarded rather than embedded + // (`ThrowableProjectile.java:59-61`). Keyed on the order rather + // than listing the kinds a second time -- `MoveThenDecay` *is* + // "this reaches `AbstractArrow.tick`". + if motion.order == projectile_motion::MotionOrder::MoveThenDecay { + entity.set(projectile_motion::ShakeTime::default()); + } } }); @@ -545,6 +575,13 @@ fn register_observers(world: &World, prefabs: MetadataPrefabs) { EntityKind::Player => { entity.is_a(prefabs.player_base); } + // The `IN_GROUND` tracked field, so a client stops simulating + // an arrow the server has stopped. Same set as the + // `MoveThenDecay` half of `projectile_motion::SIMULATED`: + // these are the kinds whose tick is `AbstractArrow.tick`. + EntityKind::Arrow | EntityKind::SpectralArrow | EntityKind::Trident => { + entity.is_a(prefabs.arrow_base); + } _ => {} }); }); diff --git a/crates/hyperion/src/simulation/projectile_motion.rs b/crates/hyperion/src/simulation/projectile_motion.rs index e41aa06ff..2dce4082b 100644 --- a/crates/hyperion/src/simulation/projectile_motion.rs +++ b/crates/hyperion/src/simulation/projectile_motion.rs @@ -49,7 +49,9 @@ pub enum MotionOrder { pub struct ProjectileMotion { /// The velocity is multiplied by this every tick, out of water. /// - /// `AbstractArrow.getAirDrag` and `ThrowableProjectile.getAirDrag` both + /// `AbstractArrow.getAirDrag` (`AbstractArrow.java:234-236`, returning the + /// `INERTIA` constant declared at line 65) and + /// `ThrowableProjectile.getAirDrag` (`ThrowableProjectile.java:80-83`) both /// return the `float` 0.99, and vanilla widens that to a `double` before /// multiplying, so the value it actually applies is 0.990000009536743. /// Stored as an `f32` here for the same reason: hyperion's velocities are @@ -59,7 +61,8 @@ pub struct ProjectileMotion { /// Subtracted from the vertical velocity every tick. /// /// `Entity.applyGravity` reads `getDefaultGravity`, which is 0.05 for - /// arrows and 0.03 for anything thrown. + /// arrows (`AbstractArrow.java:286-289`) and 0.03 for anything thrown + /// (`ThrowableProjectile.java:95-98`). pub gravity: f32, /// Which of the two tick shapes above this kind uses. pub order: MotionOrder, @@ -191,17 +194,92 @@ pub fn lerp_rotation(mut current: f32, target: f32) -> f32 { current + 0.2 * (target - current) } -/// Everything that reaches `AbstractArrow.tick`. +/// How long an arrow shakes after it embeds itself, in ticks. +/// +/// `AbstractArrow.SHAKE_TIME` (`AbstractArrow.java:63`), written by +/// `onHitBlock` (line 502) and counted down at the top of every tick (lines +/// 178-180). Never sent: a client sets its own copy from the `IN_GROUND` edge +/// (lines 157-159). What the server's copy is for is the pickup gate at line +/// 621, which refuses an arrow that is still shaking. +pub const SHAKE_TICKS: u8 = 7; + +/// How far back along its own heading an arrow is pushed when it embeds itself, +/// in blocks. +/// +/// `onHitBlock` scales `signum(movement)` by this and subtracts it from the +/// impact point (`AbstractArrow.java:495-498`), so the arrow's origin ends up +/// just outside the block it struck rather than exactly on its face. Without +/// it a resting arrow sits in the plane of the surface and z-fights it. +pub const GROUND_BACKOFF: f32 = 0.05; + +/// Ticks an arrow has left to shake, counted down by the integrator. +/// +/// A component and not a field of [`ProjectileMotion`], because it is state +/// rather than configuration: two arrows of one kind disagree about it. +/// Registered by [`ProjectileComponentsModule`]. +#[derive(Component, Debug, Copy, Clone, PartialEq, Eq, Default)] +pub struct ShakeTime(pub u8); + +/// `java.lang.Math.signum`, which is not [`f32::signum`]. +/// +/// Java answers zero for either zero and Rust answers ±1.0, and the difference +/// is not academic here: an arrow flying dead flat has `movement.y == 0`, so +/// [`f32::signum`] would back it off a twentieth of a block *downwards* as well +/// as along its heading, and every flat shot in the game would rest below the +/// face it hit. +fn java_signum(value: f32) -> f32 { + if value > 0.0 { + 1.0 + } else if value < 0.0 { + -1.0 + } else { + // Java returns the argument itself for ±0.0 and for NaN, which keeps + // the sign of a negative zero. Multiplying by it below gives zero + // either way, so the distinction never reaches a position. + value + } +} + +/// Where an arrow that struck a block comes to rest, given the impact point and +/// the velocity it arrived with. +/// +/// The whole of `AbstractArrow.onHitBlock`'s position statement +/// (`AbstractArrow.java:495-498`). Split out rather than inlined into the +/// integrator so it can be checked on its own: the interesting cases are a flat +/// shot and one that arrives exactly along an axis, and neither needs a world. +#[must_use] +pub fn embed_point(impact: Vec3, velocity: Vec3) -> Vec3 { + let offset_direction = Vec3::new( + java_signum(velocity.x), + java_signum(velocity.y), + java_signum(velocity.z), + ); + impact - offset_direction * GROUND_BACKOFF +} + +/// Everything that reaches `AbstractArrow.tick` (`AbstractArrow.java:162-231`). +/// +/// These are the defaults, not a smash or bedwars tuning: an ability that +/// wants something else overrides [`ProjectileMotion`] on the one projectile it +/// fired, at the call site, where the deviation is visible. Nothing in +/// `events/` relies on the numbers here being anything but vanilla's. const ARROW: ProjectileMotion = ProjectileMotion { + // AbstractArrow.java:65 (`INERTIA`), returned by getAirDrag at line 235. drag: 0.99, + // AbstractArrow.java:288 (`getDefaultGravity`). gravity: 0.05, + // AbstractArrow.java:218-229: clip and move, then drag, then gravity. order: MotionOrder::MoveThenDecay, }; -/// Everything that reaches `ThrowableProjectile.tick`. +/// Everything that reaches `ThrowableProjectile.tick` +/// (`ThrowableProjectile.java:48-62`). const THROWN: ProjectileMotion = ProjectileMotion { + // ThrowableProjectile.java:82 (`getAirDrag`). drag: 0.99, + // ThrowableProjectile.java:97 (`getDefaultGravity`). gravity: 0.03, + // ThrowableProjectile.java:51-55: gravity, then drag, then the move. order: MotionOrder::DecayThenMove, }; @@ -253,6 +331,7 @@ impl Module for ProjectileComponentsModule { fn module(world: &World) { world.import::(); world.component::(); + world.component::(); } } @@ -290,7 +369,9 @@ impl Module for ProjectilePhysicsModule { #[cfg(test)] mod tests { - use super::{MotionOrder, SIMULATED}; + use glam::Vec3; + + use super::{GROUND_BACKOFF, MotionOrder, SIMULATED, embed_point}; /// Every simulated kind must be something a client can be told about, /// since an entity nobody can see is not worth integrating. @@ -304,6 +385,46 @@ mod tests { } } + /// The case `f32::signum` gets wrong, and the common one: a flat shot has + /// `movement.y == 0`, and Java's `Math.signum` answers 0 there where Rust's + /// answers +1. Taking Rust's would sink every arrow in the game a + /// twentieth of a block below the face it hit. + #[test] + fn a_flat_shot_is_backed_off_along_its_heading_only() { + let impact = Vec3::new(10.0, 65.0, 0.0); + let resting = embed_point(impact, Vec3::new(3.0, 0.0, 0.0)); + + assert_eq!( + resting, + Vec3::new(10.0 - GROUND_BACKOFF, 65.0, 0.0), + "a flat shot should come to rest short of the wall on x alone" + ); + } + + /// Every axis the arrow was actually moving along backs off, and the sign + /// follows the heading rather than the geometry: an arrow travelling down + /// and west rests above and east of where it struck. + #[test] + fn the_back_off_follows_the_sign_of_every_moving_axis() { + let resting = embed_point(Vec3::ZERO, Vec3::new(-2.0, -1.0, 4.0)); + + assert_eq!( + resting, + Vec3::new(GROUND_BACKOFF, GROUND_BACKOFF, -GROUND_BACKOFF), + "the offset should be signum(velocity) * 0.05 subtracted from the impact" + ); + } + + /// The magnitude is `signum`, not the velocity: an arrow at three blocks a + /// tick and one at a tenth of a block a tick rest the same distance out. + #[test] + fn the_back_off_does_not_scale_with_speed() { + let fast = embed_point(Vec3::ZERO, Vec3::new(60.0, 0.0, 0.0)); + let slow = embed_point(Vec3::ZERO, Vec3::new(0.01, 0.0, 0.0)); + + assert_eq!(fast, slow, "onHitBlock scales signum, not the movement"); + } + /// The two orders are the reason this module exists, so a table that /// collapsed onto one of them would have lost the distinction. #[test] diff --git a/crates/hyperion/src/spatial/mod.rs b/crates/hyperion/src/spatial/mod.rs index 0f56b0f4b..9d471bb97 100644 --- a/crates/hyperion/src/spatial/mod.rs +++ b/crates/hyperion/src/spatial/mod.rs @@ -83,6 +83,15 @@ pub fn get_first_collision( (entity, distance_to_entity) }); + // The same length of ray both sides. `Blocks::first_collision` reports + // nothing past `t == 1`, and the BVH has no such bound, so without this an + // entity thirty blocks along the heading beats the wall a metre ahead -- + // and beats it *because* the block side was fixed, which is the shape of + // regression that arrives looking like a fix. + if distance_to_entity > 1.0 { + return block.map(Either::Right); + } + match block { Some(block_collision) if block_collision.distance <= distance_to_entity => { Some(Either::Right(block_collision)) diff --git a/crates/hyperion/src/tick_loop.rs b/crates/hyperion/src/tick_loop.rs new file mode 100644 index 000000000..c9cf99eea --- /dev/null +++ b/crates/hyperion/src/tick_loop.rs @@ -0,0 +1,67 @@ +//! Everything flecs's own `ecs_app_run` does to a world before it starts ticking, for a +//! host that ticks the world itself. +//! +//! # Why a host would want its own loop +//! +//! `App::run` is `ecs_app_run`, and it does not return until the world quits. A host that +//! has to do anything at all between two frames -- hot reload is the case this exists for, +//! because a module swap mid-frame rebuilds a system table underneath an iterator -- cannot +//! use it. See `hyperion_hot_reload::service::run`, which is that loop. +//! +//! # What `ecs_app_run` actually does, and what is left here +//! +//! Read from flecs's `addons/app.c` at the pinned `flecs_ecs_sys` version, in order: +//! +//! | `ecs_app_run` | here | +//! | --- | --- | +//! | `ecs_set_target_fps(world, desc->target_fps)` | [`prepare`] refuses a world with none | +//! | `ecs_set_threads(world, desc->threads)` | [`HyperionCore`](crate::HyperionCore) | +//! | `ECS_IMPORT(FlecsRest)` + `ecs_set(EcsWorld, EcsRest, {port})` | [`prepare`] | +//! | `ECS_IMPORT(FlecsStats)` | [`prepare`] | +//! | `while (ecs_progress(world, 0)) {}` | the host's loop | +//! +//! The two rows that are not here are not omissions: +//! +//! **Threads.** `HyperionCore` already calls `world.set_threads(rayon::current_num_threads())` +//! while it is being imported, which is before any event's `init_game` reaches its loop. +//! flecs's `flecs_set_threads_internal` returns without doing anything when the stage count +//! already equals the requested thread count, so the `App::set_threads(...)` every event +//! used to call with the same expression was provably a second no-op call rather than the +//! one that mattered. +//! +//! **Target frame rate.** `HyperionCore` sets it to `TICKS_PER_SECOND`. `App::new` would +//! have read that same value back out of the world and set it again -- and, had nothing set +//! one, would have quietly substituted 60. That substitution is the reason [`prepare`] +//! refuses instead of defaulting: a game server whose target rate is zero does not run +//! slowly or quickly, it spins a core per stage as fast as `ecs_progress` will return, and +//! the only outward symptom is a host that is hot. + +use anyhow::ensure; +use flecs_ecs::{addons::stats::Stats, core::World, prelude::*}; + +/// Bring the world to the state `App::enable_rest(0).enable_stats(true).run()` left it in, +/// short of the loop itself. +/// +/// The REST port is flecs's own default (27750) rather than a number chosen here, which is +/// what `enable_rest(0)` meant: `desc.port = 0` reaches `EcsRest` as zero, and flecs reads +/// zero as "unset". +/// +/// # Errors +/// If no target frame rate has been set on the world -- see the module docs for why that is +/// worth failing over rather than filling in. +pub fn prepare(world: &World) -> anyhow::Result<()> { + let target_fps = world.info().target_fps; + ensure!( + target_fps > 0.0, + "no target frame rate on this world: import HyperionCore before preparing the tick loop, \ + or the server will spin instead of tick" + ); + + // Both of these are what the flecs Explorer connects to. `FlecsRest` itself is already + // imported by `ecs_init`, so setting the singleton is the whole of `enable_rest`; + // `FlecsStats` is not, and is the whole of `enable_stats`. + world.set(flecs::rest::Rest::default()); + world.import::(); + + Ok(()) +} diff --git a/crates/hyperion/tests/arrow_ground.rs b/crates/hyperion/tests/arrow_ground.rs new file mode 100644 index 000000000..1f5baeef9 --- /dev/null +++ b/crates/hyperion/tests/arrow_ground.rs @@ -0,0 +1,146 @@ +//! An arrow that has landed stays landed. +//! +//! `AbstractArrow.tick` opens with two statements the integrator used to have +//! neither of: the shake counts down (`AbstractArrow.java:178-180`), and an +//! arrow already in the ground returns before it moves, drags or falls +//! (lines 184-200). Without the second one a stopped arrow is only stopped for +//! as long as its velocity happens to be zero -- gravity puts it back in motion +//! on the very next tick and it sinks through whatever it landed on. +//! +//! This drives a real `HyperionCore` world through `world.progress()`, so what +//! is under test is the shipped system and not a copy of its arithmetic. The +//! world has no terrain (`HyperionCore` installs `Blocks::empty`), which is why +//! the *entry* into the ground is checked against a real client in +//! `bedwars-bow-e2e` rather than here. What a blockless world can still say is +//! everything that follows from the state, and that is the half gravity was +//! quietly undoing. +//! +//! One test and not four, because `HyperionCore` builds rayon's global thread +//! pool and a second boot in the same process fails on it. Each phase below +//! uses its own entity, so they do not interact. + +use flecs_ecs::core::{EntityView, EntityViewGet, World}; +use glam::Vec3; +use hyperion::{ + HyperionCore, + simulation::{ + Owner, Pitch, Position, Velocity, Yaw, + entity_kind::EntityKind, + metadata::arrow::InGround, + projectile_motion::{SHAKE_TICKS, ShakeTime}, + }, +}; + +/// An arrow flying flat at one block a tick, owned by a shooter the ray cast +/// will exclude. +fn arrow(world: &World) -> EntityView<'_> { + let owner = world.entity(); + let entity = world.entity(); + entity + .add_enum(EntityKind::Arrow) + .set(Position::new(0.0, 100.0, 0.0)) + .set(Velocity::new(1.0, 0.0, 0.0)) + .set(Yaw::new(0.0)) + .set(Pitch::new(0.0)) + .set(Owner::new(*owner)); + entity +} + +fn state(entity: EntityView<'_>) -> (Vec3, Vec3) { + entity.get::<(&Position, &Velocity)>(|(position, velocity)| (**position, velocity.0)) +} + +fn tick(world: &World, times: u32) { + for _ in 0..times { + world.progress(); + } +} + +#[test] +fn a_landed_arrow_holds_still_and_a_flying_one_does_not() { + let world = World::new(); + world.import::(); + + // An arrow is told about `IN_GROUND` at all. The prefab carrying the + // tracked field has to be applied by kind, or the flag is a server-side + // boolean no client ever hears about and the client keeps flying an arrow + // the server has stopped. + let embedded = arrow(&world); + assert_eq!( + embedded.try_get::<&InGround>(|flag| **flag), + Some(false), + "every arrow should inherit the IN_GROUND tracked field, defaulted to false" + ); + // And the shake clock, seeded by `seed_projectile_motion` for every kind + // whose tick is `AbstractArrow.tick`. Without it the impact has nowhere to + // write the seven ticks and the countdown below has nothing to count. + assert_eq!( + embedded.try_get::<&ShakeTime>(|shake| *shake), + Some(ShakeTime(0)), + "every arrow should be seeded with a shake clock, at rest" + ); + + // What `onHitBlock` leaves behind, except for the velocity: flagged, and + // shaking. Set by hand because a blockless world has nothing to hit. + // + // The velocity is left at a block a tick on purpose, and that is the whole + // point of this phase. `update_projectile_positions` skips a projectile + // that is not moving anyway, so an embedded arrow whose velocity is also + // zero cannot tell "in the ground" from "not moving" -- it holds still + // either way, and a test built on it would pass with the in-ground branch + // deleted. Vanilla's rule is the stronger one: `AbstractArrow.tick` returns + // before it looks at the movement at all (lines 184-200), so an arrow in + // the ground stays put whatever its velocity says. A game module that + // knocks a stuck arrow loose has to clear the flag, not just write a + // velocity. + embedded.set(InGround::new(true)); + embedded.set(ShakeTime(SHAKE_TICKS)); + let (resting, _) = state(embedded); + + // One tick short of the full count, so the value is still positive: a + // countdown that jumped straight to zero would pass an "eventually zero" + // assertion just as loudly. + tick(&world, u32::from(SHAKE_TICKS) - 1); + assert_eq!( + embedded.get::<&ShakeTime>(|shake| *shake), + ShakeTime(1), + "the shake should count down one tick at a time" + ); + + tick(&world, 20); + assert_eq!( + embedded.get::<&ShakeTime>(|shake| *shake), + ShakeTime(0), + "the shake should stop at zero rather than wrap" + ); + + let (position, velocity) = state(embedded); + assert_eq!( + position, resting, + "an arrow in the ground should not move, whatever its velocity says; it drifted to \ + {position}" + ); + assert_eq!( + velocity, + Vec3::new(1.0, 0.0, 0.0), + "an arrow in the ground should lose nothing to drag and gain nothing from gravity; its \ + velocity became {velocity}" + ); + + // The guard for the guard: the same twenty ticks, without the flag, must + // move the arrow. Otherwise everything above passes on a world that never + // ticked at all. + let flying = arrow(&world); + let (start, _) = state(flying); + tick(&world, 20); + + let (position, velocity) = state(flying); + assert!( + position.x > start.x + 1.0, + "a flying arrow should have travelled; it is at {position}" + ); + assert!( + velocity.y < -0.5, + "a flying arrow should be falling by now; its velocity is {velocity}" + ); +} diff --git a/crates/hyperion/tests/differential.rs b/crates/hyperion/tests/differential.rs index c0eb40eeb..dd2674754 100644 --- a/crates/hyperion/tests/differential.rs +++ b/crates/hyperion/tests/differential.rs @@ -14,19 +14,23 @@ reason = "a failing comparison is only useful if it prints the tick and the numbers" )] -use std::{fs, path::Path}; +use std::{collections::BTreeSet, fs, path::Path}; use flecs_ecs::{ - core::{EntityViewGet, World}, + core::{EntityViewGet, World, WorldGet}, macros::Component, prelude::Module, }; use hyperion::{ - glam::Vec3, + BlockKind, BlockState, + glam::{I16Vec2, IVec3, Vec3}, + runtime::AsyncRuntime, simulation::{ Owner, Pitch, Position, Velocity, Yaw, + blocks::Blocks, entity_kind::EntityKind, - projectile_motion::{SIMULATED, look_angles}, + metadata::{Metadata, arrow::InGround}, + projectile_motion::{SIMULATED, ShakeTime, look_angles}, }, spatial::SpatialModule, }; @@ -58,9 +62,34 @@ struct Scenario { #[expect(dead_code, reason = "consumed by the recorder, not by this test")] seed: i64, entities: Vec, + /// Terrain the scenario wants, and nothing else. + /// + /// Absent from every scenario that flies through open sky, which is what + /// keeps their traces byte-identical: the recorder places nothing and this + /// replay stamps nothing, so the world both sides run in is the one they + /// always ran in. A scenario that names blocks opts *itself* into terrain; + /// there is no global flat-world switch to get wrong. + #[serde(default)] + blocks: Vec, compare: Tolerance, } +/// One block the scenario puts in the world before anything is fired. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct BlockSpec { + position: [i32; 3], + /// A block name, and only a name: `minecraft:stone`, not + /// `minecraft:stone_slab[type=top]`. + /// + /// Both sides place the block's *default* state, which means the two + /// registries' defaults have to agree. That is not taken on trust -- it is + /// what the comparison itself checks. A slab that came out `top` on one + /// side and `bottom` on the other moves the arrow's resting height half a + /// block, four orders of magnitude outside any tolerance here. + state: String, +} + #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct EntitySpec { @@ -111,6 +140,18 @@ struct Trace { )] seed: i64, ticks: usize, + /// The wire index of `AbstractArrow.IN_GROUND`, read out of the jar by the + /// recorder rather than transcribed. + /// + /// This is the one number in `metadata::arrow` that nothing else can check. + /// A field index never appears on the wire, so no packet capture recovers + /// it, and getting it wrong does not fail to compile or to send -- it sends + /// a boolean to whichever field Mojang moved into slot 10, and the arrow + /// quietly does something else on the client. Recording it here costs the + /// recorder one reflective read and turns a hand-transcribed constant into + /// one the jar has to agree with. See ENG-12106 for the general case. + #[serde(rename = "inGroundFieldIndex")] + in_ground_field_index: u8, samples: Vec, } @@ -135,6 +176,17 @@ struct State { reason = "recorded so a scenario can one day assert a despawn" )] removed: bool, + /// `AbstractArrow.isInGround`, present only for the kinds that have it. + /// + /// The reason a terrain scenario can assert anything at all. A resting + /// position on its own cannot tell "stopped by the wall" from "still + /// flying and happening to be there this tick"; this can. + #[serde(default, rename = "inGround")] + in_ground: Option, + /// `AbstractArrow.shakeTime`, which counts down from seven and so pins the + /// tick the arrow landed on rather than merely that it did. + #[serde(default, rename = "shakeTime")] + shake_time: Option, } /// Narrows a recorded double to the `f32` this server stores. @@ -184,6 +236,80 @@ fn kind_for(entity_type: &str) -> EntityKind { }) } +/// Puts a scenario's declared blocks into the replay world. +/// +/// `HyperionCore` installs `Blocks::empty`, so the replay world starts with no +/// chunks at all -- and `set_block` on an unloaded chunk returns +/// `ChunkNotLoaded` rather than placing anything, which would leave a scenario +/// whose wall silently was not there and a comparison that then blamed the +/// physics. Each chunk is loaded first, through the same `block_and_load` the +/// server uses. +/// +/// Nothing happens for a scenario with no blocks, which is every scenario that +/// flies through open sky: their replay world is untouched by this and their +/// committed traces are unchanged. +fn stamp_terrain(world: &World, blocks: &[BlockSpec]) { + set_terrain(world, blocks, |spec| block_state(&spec.state)); +} + +/// Takes it back out again. +/// +/// Every scenario shares one world -- `HyperionCore` can only be imported once +/// per process -- so a wall left standing would be in the next scenario's sky. +/// Vanilla records each scenario in a fresh level, and this is what makes the +/// replay side match that. The scenarios are also written not to overlap, but +/// relying on that would make every future scenario's author responsible for +/// every past one's geometry. +fn clear_terrain(world: &World, blocks: &[BlockSpec]) { + set_terrain(world, blocks, |_| BlockState::AIR); +} + +fn set_terrain(world: &World, blocks: &[BlockSpec], state_for: impl Fn(&BlockSpec) -> BlockState) { + if blocks.is_empty() { + return; + } + + // Deduplicated and ordered, so a scenario naming twenty blocks in one + // chunk loads it once and the loads happen in a fixed order. `I16Vec2` is + // not `Ord`, so the pair is the key and the vector is rebuilt from it. + let chunks: BTreeSet<(i16, i16)> = blocks + .iter() + .map(|spec| { + ( + i16::try_from(spec.position[0] >> 4).expect("block x is inside the world limit"), + i16::try_from(spec.position[2] >> 4).expect("block z is inside the world limit"), + ) + }) + .collect(); + + let runtime = world.get::<&AsyncRuntime>(AsyncRuntime::clone); + world.get::<&mut Blocks>(|store| { + for (x, z) in chunks { + store.block_and_load(I16Vec2::new(x, z), &runtime); + } + for spec in blocks { + let state = state_for(spec); + let position = IVec3::new(spec.position[0], spec.position[1], spec.position[2]); + store.set_block(position, state).unwrap_or_else(|error| { + panic!("could not place {} at {position}: {error:?}", spec.state) + }); + } + }); +} + +/// The default state of a block named the way a scenario names it. +/// +/// A name and nothing else, so `minecraft:stone`, not +/// `minecraft:stone_slab[type=top]`. See [`BlockSpec::state`] for why the two +/// registries' defaults agreeing is checked by the comparison rather than +/// asserted here. +fn block_state(name: &str) -> BlockState { + let bare = name.strip_prefix("minecraft:").unwrap_or(name); + let kind = BlockKind::from_str(bare) + .unwrap_or_else(|| panic!("no such block in this server's tables: {name}")); + BlockState::from_kind(kind) +} + /// Replays one scenario and reports the first tick that disagrees. /// /// Returns the failure as a string rather than asserting, so the caller can @@ -211,6 +337,20 @@ fn replay(world: &World, scenario: &Scenario, trace: &Trace) -> Result Result(|flag| **flag); + if in_ground != Some(expected_in_ground) { + outcome = Err(format!( + "{}: {id} inGround diverges at tick {}\n vanilla: \ + {expected_in_ground}\n hyperion: {in_ground:?}", + scenario.name, sample.tick, + )); + } + } + if let Some(expected_shake) = expected.shake_time { + let shake = entity.try_get::<&ShakeTime>(|shake| shake.0); + if shake != Some(expected_shake) { + outcome = Err(format!( + "{}: {id} shakeTime diverges at tick {}\n vanilla: {expected_shake}\n \ + hyperion: {shake:?}", + scenario.name, sample.tick, + )); + } + } + // The heading, in the same shape. Yaw and pitch rather than three // axes, and the shorter arc between the two angles so a reading of // 179 against -179 is two degrees apart, not 358. @@ -362,6 +531,7 @@ fn replay(world: &World, scenario: &Scenario, trace: &Trace) -> Result(&world); world.get::<&ServerPingResponse>(|_| ()); } + +#[test] +#[serial] +fn tab_list_components_module_registers_both_singletons_standalone() { + let world = World::new(); + world.import::(); + + assert_registered::(&world); + assert_registered::(&world); + // Both defaults are installed by the module that registers the type, so a + // `get` reaching them at all is the assertion. + world.get::<&TabList>(|_| ()); + world.get::<&Tps>(|_| ()); + + assert!( + world.try_lookup("tab_list_sample").is_none(), + "a registration module must install no systems" + ); + assert!( + world.try_lookup("tab_list_sync").is_none(), + "a registration module must install no systems" + ); +} + +#[test] +#[serial] +fn ping_components_module_registers_the_readout_standalone() { + let world = World::new(); + world.import::(); + + assert_registered::(&world); + + assert!( + world.try_lookup("probe_ping").is_none(), + "a registration module must install no systems" + ); + assert!( + world.try_lookup("publish_ping").is_none(), + "a registration module must install no systems" + ); +} + +/// The whole simulation registration layer, standing on its own. +/// +/// The premise of every test above, applied to the module they all sit under. +/// It did not hold: `Prev` and `IgnMap` were registered only by +/// `HyperionCore`, so importing `SimComponentsModule` into a bare world -- the +/// thing this convention exists to make possible -- aborted a dev build with +/// `ECS_INVALID_OPERATION` before reaching the first assertion. Both are now +/// registered by the simulation's own DAG, and this is the guard that keeps +/// the next one from going unnoticed until a consumer trips over it. +#[test] +#[serial] +fn sim_components_module_stands_alone() { + let world = World::new(); + world.import::(); + + assert_registered::(&world); + assert_registered::(&world); + + // A `Player` carries a `Ping` without anyone on the join path adding one. + // The trait is declared here rather than by `PingComponentsModule` because + // it is a statement about `Player`; the assertion is here for the same + // reason. + let player = world.entity().add(id::()); + assert!( + player.has(id::()), + "a Player should carry a Ping without anyone adding one" + ); + + assert!( + world.try_lookup("probe_ping").is_none(), + "a registration module must install no systems" + ); +} diff --git a/docs/differential-testing.md b/docs/differential-testing.md index 33487d153..a9e3bd14b 100644 --- a/docs/differential-testing.md +++ b/docs/differential-testing.md @@ -32,6 +32,9 @@ the wrong recording. "description": "A fully drawn bow fired dead level along +Z.", "ticks": 60, "seed": 4242, + "blocks": [ + { "position": [0, 120, 10], "state": "minecraft:stone" } + ], "entities": [ { "id": "arrow", @@ -57,6 +60,9 @@ the wrong recording. | `entities[].id` | Names this entity in the trace and in a failure message. | | `entities[].type` | A protocol entity type. It must appear in `hyperion::simulation::projectile_motion::SIMULATED`, or there is nothing here to compare. | | `entities[].position` | Where it starts. | +| `blocks` | Optional. Terrain to put in the world before anything is fired. | +| `blocks[].position` | Integer block coordinates. | +| `blocks[].state` | A block name and nothing else: `minecraft:stone`, not `minecraft:stone_slab[type=top]`. Both sides place the block's default state. | | `compare.position` | Tolerance in blocks. | | `compare.velocity` | Tolerance in blocks per tick. | | `compare.rotation` | Tolerance in degrees, for the arrow's client-facing yaw and pitch. | @@ -71,6 +77,51 @@ scenario: - `"knockback": { "power", "fromX", "fromZ", "damage", "onGround" }` runs `LivingEntity.knockback`, for entities that have one. +### Terrain is opt-in, per scenario + +A scenario with no `blocks` runs in an empty world on both sides and its trace +is unchanged by any of this: the recorder places nothing and the replay stamps +nothing. There is no global "load a flat world" switch to get wrong, and adding +the terrain scenarios changed the four sky scenarios' recorded numbers not at +all -- the only difference in those files is the two impact fields and the +header index described below. + +A scenario that *does* name blocks gets them in both places before any entity +exists. `VanillaTrace.placeBlocks` calls `setBlock` with `Block.UPDATE_CLIENTS` +rather than `UPDATE_ALL`, because a neighbour update runs block logic and block +logic is where a recording would start consuming randomness. On the hyperion +side `stamp_terrain` loads each containing chunk first -- `HyperionCore` +installs `Blocks::empty`, and `set_block` on an unloaded chunk quietly places +nothing -- and clears them again afterwards, because every scenario shares one +world where vanilla records each in a fresh level. + +Default states only. That means the two registries' defaults have to agree, and +that is checked rather than trusted: a slab that came out `top` on one side and +`bottom` on the other moves the arrow's resting height by half a block, four +orders of magnitude outside any tolerance here. + +### The impact state, and the field index that rides with it + +An arrow's trace also carries `inGround` and `shakeTime`, and they are compared +exactly, with no tolerance -- a flag and a countdown have no notion of "close". +They are the reason a terrain scenario can assert anything at all: a resting +position on its own cannot tell "stopped by the wall" from "still flying and +happening to be there this tick". A snowball's trace carries neither, because +`ThrowableProjectile` has no such state. + +`inGround` is read from the *synched* data rather than from `isInGround()`, +which is `protected` -- so it is the value a client is actually sent, which is +what `metadata::arrow::InGround` mirrors. Getting the accessor means reflection, +and since the reflection is happening anyway the trace header records +`inGroundFieldIndex` as well. That number is the one thing in +`crates/hyperion/src/simulation/metadata/` nothing else can check: a field index +never appears on the wire, so no packet capture recovers it, and getting it +wrong neither fails to compile nor fails to send -- it writes a boolean into +whichever field Mojang moved into slot 10 and the arrow quietly does something +else on the client. The replay asserts it against `InGround::INDEX` before it +compares a single tick. ENG-12106 is the general version of this for every other +hand-transcribed index. + ## What is deterministic, and how that is known rather than assumed An uncontrolled random process compared against itself passes and means @@ -107,9 +158,10 @@ since it is a property of the runtime and not of anything in this repository. by inaccuracy, and a real bow shot passes inaccuracy 1.0. Scenarios pass 0.0, so the spread distribution is untested. What is tested is the flight, from whatever state vanilla started it in. -- **Anything the arrow hits.** Every committed scenario flies through empty air. - Block and entity collision, `inGround`, and despawn are recorded in the trace - (`removed`) but no scenario exercises them yet. +- **Entity collision, and despawn.** `removed` is recorded and nothing asserts + on it, and no scenario puts a second entity in an arrow's path. Block + collision *is* covered now -- see the three terrain scenarios -- but what an + arrow does when it meets a player is not. - **Water, lava, portals, bubble columns, levitation.** All change the integration and none appear in a flat world's sky. - **Player movement, and so knockback as Super Smash Mobs uses it.** Two @@ -149,6 +201,26 @@ ok: snowball-throw (40 ticks); worst position delta 1.4786633116159464e-5 of 4e- Both sit about an order of magnitude inside the bound, which says the rounding errors are not accumulating in one direction. +The terrain scenarios are twenty ticks and reach 128 blocks (121 for the slab), +so the same arithmetic gives `20 * 2^-17 = 1.5e-4` and `20 * 2^-18 = 7.6e-5`; +the committed tolerances are `2e-4` and `1e-4`. What they actually measure is +smaller again, and this is the number worth reading, because the question going +in was whether a swept clip against block shapes would open a bigger gap than +free flight does: + +``` +ok: arrow-into-floor (20 ticks); worst position delta 3.386e-6 of 2e-4 +ok: arrow-into-wall (20 ticks); worst position delta 5.615e-6 of 2e-4 +ok: arrow-grazing-slab (20 ticks); worst position delta 8.309e-6 of 1e-4 +``` + +It does not. Those are *tighter* than the sixty-tick sky shots (3.4e-5 to +5.0e-5), for the ordinary reason that they run a third as long. The clip itself +contributes nothing measurable: `geometry::sweep::first_block_hit` computes in +`f64` internally, so the only rounding on the impact path is the `f32` the +segment's endpoints arrive as -- the same single source of disagreement free +flight has. **The residual gap is hyperion's `f32` positions, and nothing else.** + **This tolerance is larger than the wire can express, and that is a real finding rather than a detail.** Entity position deltas go on the wire in units of 1/4096 of a block, about `2.4e-4`, so hyperion's `f32` storage is on the edge @@ -257,3 +329,31 @@ column of every trace keeps them honest. Hyperion aims with `f32::atan2` where vanilla uses `Mth.atan2`, a table approximation; the two agree to under a thousandth of a degree across every committed scenario, which is why the rotation tolerance is a fifth of a degree rather than zero. + +### The heading of an arrow that stops + +The terrain scenarios found this on their first run, which is the reason to +write them. + +`AbstractArrow.tick` aims the arrow at lines 212-215, from the velocity it +entered the tick with, and **before** the clip at line 218. So an arrow that +meets a wall this tick still turns to face the way it was going, and then holds +that heading for as long as it stays embedded, because the in-ground branch +returns at line 199 without reaching the rotation again. + +Hyperion did the aiming inside the *miss* branch of +`update_projectile_positions`, so an arrow that landed kept the heading it had +one tick earlier -- exactly one `lerpRotation` step behind, forever: + +``` +arrow-into-wall: arrow pitch diverges at tick 4 + vanilla: -1.0176632 + hyperion: -0.5419483780860901 + delta: 4.757e-1 (tolerance 2e-1) +``` + +Nothing else could have found it. It is invisible in flight, where the next tick +corrects it; it is invisible to every sky scenario, because they never stop; and +it is invisible to the bow e2e checks, which read velocity rather than +orientation. The fix is one line moved above the clip, and the comment there +names the vanilla lines rather than the symptom. diff --git a/docs/hot-reload.md b/docs/hot-reload.md index faeaedec6..605ead4b7 100644 --- a/docs/hot-reload.md +++ b/docs/hot-reload.md @@ -420,7 +420,14 @@ Stated plainly, because these are the parts a reader cannot see for themselves. `nix build .#checks..hot-reload-demo .#checks..hot-reload-registry-guard`. - **The deployment half is designed, not shipped.** Nothing here is wired to `ix apply`, to a systemd unit, or to hyperion's own modules. See "Deploying a reload" below for the - shape and what is missing. + shape and what is missing, and "Packaging: three derivations" for the part that is now + measured rather than assumed. + +- **The packaged server does not satisfy the shared-pool precondition.** `hyperion` is a + dylib and the fleet's binary does not link it as one, because `cargoUnit` builds without + `-C prefer-dynamic`. `checks.hot-reload-index-probe` gates the recipe; nothing yet gates + the artifact a host runs. Until the packaging lands, loading a module into the deployed + server is the exact configuration the probe exists to reject. ## Linux, and the one copy of flecs everything depends on @@ -555,9 +562,22 @@ rebuild and a restart.** directory means the second configuration may not have taken effect. Treat the build-time cost as unmeasured rather than as shown to be zero, and measure it properly if CI wall time matters. -3. Host and every module build with - `-C prefer-dynamic -C link-arg=-Wl,--undefined-version -C link-arg=-Wl,--allow-shlib-undefined`, - plus rpaths to the rust sysroot and to wherever the dylibs land. +3. Host and every module build with `-C prefer-dynamic`, plus rpaths to the rust sysroot + and to wherever the dylibs land. On ELF add `-C link-arg=-Wl,--undefined-version`: the + version script `flecs_ecs`'s build script installs names four globs and both bfd and + lld treat a pattern matching nothing as an error. + + `nix/hot-reload/packaging.nix` is that recipe, written once, as flags on one + `cargoUnit` workspace. `checks.hot-reload-index-probe` runs the probe over units out of + that same workspace, so what the gate measures and what a host runs are the same + artifacts rather than two builds that agree by construction. + + `-C link-arg=-Wl,--undefined-version` is **not** in the recipe as built. The version + script `flecs_ecs`'s build script installs still names four globs, and bfd and lld still + error on a pattern that matches nothing, but the workspace's configured linker does not, + so nothing needs the flag today. It is left out rather than added defensively: if the + linker changes, the link fails and names the pattern, which is a better signal than a + flag nobody can explain. What makes the pool shared is step 1 and nothing else. It is tempting to think a module has to *reference* `hyperion-hot-reload` to end up on the shared runtime — an earlier version of @@ -565,17 +585,20 @@ the probe carried a call to `AbiToken::current()` with a comment claiming exactl Removing the dependency entirely leaves the probe passing. The dependency being a dylib is what shares it; a consumer's import list has nothing to do with it. -`--allow-shlib-undefined` is not a shrug. `simulation/metadata/mod.rs` hand-writes +**`--allow-shlib-undefined` is gone, and what it stood for is fixed.** +`simulation/metadata/mod.rs` used to hand-write `impl PartialOrd for $name where $type: PartialOrd`, and for 7 metadata types that bound is unsatisfiable because glam's `Quat` and `Vec3` have no `PartialOrd`. rustc never codegens -those `partial_cmp` bodies but still lists them in the dylib's export list. They cannot be -called — calling one fails to compile on the same unsatisfiable bound — so allowing them -undefined is sound. Removing the blanket impl from that macro would remove the need for the -flag, and is the better fix. +those `partial_cmp` bodies and still lists them in the dylib's export list, so a consumer +needed the flag to link at all. The blanket impl is deleted rather than tolerated: it had +exactly one caller in the whole workspace, `events/bedwars/src/module/regeneration.rs` +comparing two `Health`, and that now compares through `Health`'s own `Deref` to `f32`. So +the flag is not in the recipe, and if it ever needs to come back, that is the signal a +blanket impl came back with it. -**Steps 1 and 2 are not landed.** They were verified through a local `[patch]` against a -copy of the fork checkout. Landing them means a commit in `andrewgazelka/Flecs-Rust` and a -repin here. +**Steps 1 and 2 are landed.** `flecs_ecs` carries the dylib change at +`andrewgazelka/Flecs-Rust` `f09dc53` and `Cargo.toml` pins it; `crates/hyperion` carries +`crate-type = ["rlib", "dylib"]`. ## Deploying a reload @@ -602,9 +625,152 @@ Three pieces make that hold: A refused reload then surfaces as a failed activation with the reason in the deploy output, rather than as a silent no-op, and the world keeps running on the old build. -Not built. `app.run()` in an event's `init_game` is flecs's own main loop and offers no -per-tick Rust hook; it would become an explicit `while world.progress()` so reloads land -between ticks, which is also what the "reloads must happen between ticks" gap above needs. +All three are built. `nix/modules/game-server.nix` is the unit, +`crates/hyperion-reload-client` is the client, and `events/smash`'s `init_game` no longer +calls `app.run()` — see below. + +### What replaced `app.run()`, and what had to be reproduced by hand + +`App::run` is flecs's `ecs_app_run`, which does not return until the world quits and offers +no per-tick Rust hook. The host therefore calls `world.progress()` itself, which means +everything `ecs_app_run` does to a world *before* its loop has to be done by somebody. Read +out of flecs's own `addons/app.c` at the pinned `flecs_ecs_sys`, in order: + +| `ecs_app_run` | where it lives now | +| --- | --- | +| `ecs_set_target_fps(world, desc->target_fps)` | `hyperion::tick_loop::prepare` refuses a world with none | +| `ecs_set_threads(world, desc->threads)` | `HyperionCore`, which already did it | +| `ECS_IMPORT(FlecsRest)` + `ecs_set(EcsWorld, EcsRest, {port})` | `hyperion::tick_loop::prepare` | +| `ECS_IMPORT(FlecsStats)` | `hyperion::tick_loop::prepare` | +| `while (ecs_progress(world, 0)) {}` | `hyperion_hot_reload::service::run` | + +Two rows deserve a note, because both look like omissions and neither is. + +**Threads.** `HyperionCore` calls `world.set_threads(rayon::current_num_threads())` while it +is being imported. Every event's `init_game` then called `App::set_threads` with the *same +expression*, and flecs's `flecs_set_threads_internal` returns without doing anything when +the stage count already equals the request — so that second call was provably a no-op, and +deleting it changes nothing. + +**Target frame rate.** `HyperionCore` sets it to `TICKS_PER_SECOND`. `App::new` read that +same value back out of the world and set it again; what it *also* did was substitute 60 +when nothing had set one. `prepare` refuses instead of substituting, because a world with a +target rate of zero does not run slowly — it spins a core per stage as fast as +`ecs_progress` returns, and the only outward symptom is a host that is hot. + +The one thing a release gate cannot check here is the flecs registration asserts, which are +compiled out of release builds (CLAUDE.md, ENG-11000). `checks.smash-dev-boot-e2e` boots the +dev-profile binary for that reason, and it covers this loop. + +### The unit, as deployed + +```ini +[Unit] +X-Reload-Triggers=/nix/store/...-X-Reload-Triggers-hyperion-game-server + +[Service] +ExecStart=/nix/store/...-smash-server/bin/smash --ip :: --port 35565 \ + --root-ca-cert ... --cert ... --private-key ... \ + --rules /etc/hyperion/smash-rules.so \ + --reload-socket /run/hyperion-game-server/reload.sock \ + --build-stamp /etc/hyperion +ExecReload=/nix/store/...-hyperion-dylibs/bin/hyperion-reload-client /run/hyperion-game-server/reload.sock +RuntimeDirectory=hyperion-game-server +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +``` + +Four things in there are load bearing and none of them is obvious: + +- **Every path on `ExecStart` is stable.** The rules dylib's store path is in + `X-Reload-Triggers` and nowhere else — and note that nixpkgs does not inline the triggers, + it hashes them into a file of their own and names *that*. So "is the dylib in the right + line" is not a checkable property; "does changing the dylib move exactly one line" is, and + that is what `checks.hot-reload-unit-split` asserts, by rendering the unit twice. +- **`AF_UNIX` had to be granted.** `nix/modules/common.nix` hardens both services down to + `AF_INET` and `AF_INET6`. The reload socket is a unix socket, so without this the server + dies at startup on `Address family not supported by protocol` — on a real host, and + nowhere else, because nothing in a test or a gate runs under that filter. +- **`ExecReload` ships with the engine, not with the event.** It is part of `[Service]`, so a + client whose path moved when the rules moved would restart the server on exactly the + deploys this exists to make invisible. `hyperion-dylibs` moves only on an engine change, + which restarts anyway. +- **The build stamp is a reload trigger too.** A commit that changes nothing the server + links still changes `/etc/hyperion/build-rev`, and without a reload the bar would go on + naming the previous commit — the one question it exists to answer. That reload re-opens a + byte-identical dylib and cannot be refused, because a schema can only move when the dylib + does. + +### Three things only a running server said, and one gate each + +The deployment was built against static evidence -- store paths, `readelf`, `ldd`, rendered +unit files -- and all of it was green. Starting the thing on dev-compute-6 found three +defects in an afternoon, every one of them silent and two of them already shipped. + +**The packaged binary segfaulted on startup, in every build ever made of it.** Not under +load: `smash-server/bin/smash --help` exited 139. `#[global_allocator]` and +`-C prefer-dynamic` cannot coexist, because rustc gives each Rust dylib a version script +ending `local: *` -- the same fact this document already records about LMDB -- so each +dylib's `__rust_alloc` is local and uninterposable, and the process runs the system +allocator inside the dylibs and jemalloc inside the binary. The first pointer to cross +takes it out, inside clap, before `main`. Nothing caught it because the fourteen end-to-end +gates boot `gameBinaries.smash`, a `cargoUnit` build with no dylibs at all; the packaged +binary was inspected and never executed. `checks.hot-reload-server-starts` now runs +`--help` on it, which costs milliseconds and needs no certificates, no world and no +network. ENG-12112. + +**The reload loaded nothing and said `accepted`.** `dlopen` searches its list of loaded +objects by name before it stats the file, and the loader deliberately never `dlclose`s, so +a second load through `/etc/hyperion/-rules.so` returned the image from the first +and re-ran the old entry point. `MainPID` unchanged, `NRestarts` unchanged, the journal +saying `hot reload accepted`, the client printing `accepted smash-rules bbbbbbb`, the `/etc` +symlink pointing at the new build -- and `/proc//maps` naming only the old one. The +trigger is the very thing that makes reload-not-restart work: the path has to be stable, +so the deployed configuration is exactly the one `dlopen` dedupes. `HotReloader::load` now +copies each candidate to a fresh name first. ENG-12113. + +**The deployed server logged nothing at all.** `EnvFilter::from_default_env()` with +`RUST_LOG` unset builds a filter with no directives, which passes nothing, and a unit sets +no `RUST_LOG`. So `hot reload accepted` -- the one line that says an invisible deploy +landed -- would never have reached the journal. Measured both ways on the same binary and +unit: zero lines against every line. The default is now `info`, and ANSI is dropped when +stdout is not a terminal, because a service was writing `\x1b[32m INFO\x1b[0m` into the +journal and every severity grep over it matched nothing. + +The shape they share is worth more than any of them: **a derivation that is only ever +inspected is not a derivation that is known to work.** Two of the three were introduced by +changes whose evidence sections were entirely static analysis, and static analysis is what +they were correct about. + +### Two sibling dylibs may not share an rlib + +`crates/hyperion` and `crates/hyperion-hot-reload` are both dylibs and neither depends on +the other, so under `prefer-dynamic` each statically includes its own copy of every rlib it +uses, and a binary linking both is refused: + +``` +error: cannot satisfy dependencies so `tracing` only shows up once +error: cannot satisfy dependencies so `tracing_core` only shows up once +error: cannot satisfy dependencies so `once_cell` only shows up once +error: cannot satisfy dependencies so `pin_project_lite` only shows up once +``` + +Those four are `tracing` and its dependencies, and they were the whole list: everything else +`hyperion-hot-reload` uses arrives inside `libflecs_ecs.so`, which is a dylib and therefore +one copy. Three ways out, and only one of them is right here: + +- **Make one depend on the other.** Tried and reverted. It fixes the packaged link and + breaks a plain one -- `cargo test -p hyperion-hot-reload -p smash` then builds + `libhyperion.so` against `libhyperion_hot_reload.so`, neither with `prefer-dynamic`, and + duplicates `std` instead. +- **Make the shared crate a dylib.** Not available for a crate we do not own. +- **Stop sharing it.** `hyperion-hot-reload` gave up `tracing` and now returns + `service::Outcome::{Applied, Refused}` for the host to log. Better layering anyway: a + library that logs has decided your format, and the severities belong to whoever runs the + query. + +So: before adding a dependency to `hyperion-hot-reload`, check whether `hyperion` has it +too. `cargo tree -p hyperion-hot-reload` intersected with `cargo tree -p hyperion`, minus +whatever `libflecs_ecs.so` already carries, is the list that must stay empty. ### What a reload costs @@ -642,30 +808,352 @@ anything here. ## Handing this off: what is left, in order -The mechanism is proven and the deployment is not built. Four steps remain. The third is -the risky one; the rest are known work. +The mechanism is proven and the deployment is not built. The third step below is the risky +one; the rest are known work. -**1. Make `hyperion` a dylib and settle the build flags.** `crate-type = ["dylib", "rlib"]` -plus `-C prefer-dynamic -C link-arg=-Wl,--undefined-version --C link-arg=-Wl,--allow-shlib-undefined` everywhere. Small edit, wide blast radius: it -changes how every consumer links, and a plain `cargo test` without those flags will not -link the result. Confirm by running `demo/index-probe-host`, which should print `PROBE_OK`. +**Done: make `hyperion` a dylib and settle the build flags.** `crate-type` on +`crates/hyperion`, `-C prefer-dynamic` everywhere, and the ELF-only +`-Wl,--undefined-version`. Wide blast radius -- it changes how every consumer links, and a +plain `cargo build` without those flags builds a host whose pool a module cannot share. +`checks.hot-reload-index-probe` gates it, and the guard was watched failing: dropping +`-C prefer-dynamic` from the recipe reproduces this document's own unshared numbers, +module index 1 against a host that had already taken up to 4. -**2. Split `SmashModule` out of `events/smash` into its own crate, built as a dylib with +**1. Split `SmashModule` out of `events/smash` into its own crate, built as a dylib with `export_module!`.** The rules already avoid the host seam by design, but they reach into `crate::server`, `crate::flecs_ext` and about fifteen `hyperion::` items, so this is a real refactor rather than a file move. Registration modules stay in the host per the section above. -**3. Package it. This is the risky step.** The game server binary and the module dylib have -to be separate store paths, both built with the flags from step 1, with rpaths that resolve -in the nix store rather than in `target/debug`. Nothing here is verified — every -measurement in this document was taken from a cargo build, not a nix one. Expect the -surprises to be here. +**2. Package it.** No longer the unknown it was; see "Packaging: three derivations, because +two would restart" below, which replaces the guesswork with a measured design. + +**Done: wire the NixOS module and the fleet spec.** `reloadTriggers`, the stable `/etc` +path, and an `ExecReload` client that exits non-zero on a refusal, all as designed in +"Deploying a reload" above. `checks.hot-reload-unit-split` renders the unit for two builds +of the rules and asserts that the only line which moved is `X-Reload-Triggers`. + +### Do the deployment half first, against a module with nothing in it + +The order above is the order the pieces were designed in, and it is the wrong order to +build them in. Reversed: + +- **The rules split has no unknowns.** It is nineteen files, 111 `world.component::()` + calls and 30 system declarations, moved across a crate boundary. Large, mechanical, and + nothing about it can surprise anyone. +- **The deployment half has all of them.** systemd's reload-versus-restart decision, the + `/etc` indirection, rpaths that resolve in the store, the `makeWrapper` that used to put + a per-commit path inside `[Service]`, and whether a title reaches a connected player at + all. Every one of those is a fact about a running host. + +So build the loader, the socket, the `ExecReload` client, the title and the NixOS wiring +against a **trivial** rules module that registers nothing and does one visible thing. That +proves reload-not-restart, the surviving player connection and the title on a dev node +without waiting for the split. Then migrate smash's rules into that module a domain at a +time, each migration a small PR that is already covered by the existing test suite. + +The failure this avoids is the expensive one: finishing a nineteen-file refactor and only +then discovering that `[Service]` differs on every apply and nothing ever reloads. -**4. Wire the NixOS module and the fleet spec.** Designed in "Deploying a reload" above: -`reloadTriggers`, the stable `/etc` path, and an `ExecReload` client that exits non-zero on -a refusal. Small, and the design is settled. +## Packaging: one cargoUnit graph, three sets of store paths + +`nix/hot-reload/packaging.nix` builds every hot-reload artifact from one `cargoUnit` +workspace: one derivation per rustc invocation, each unit's source scoped to its own crate +directory. Which store paths move is therefore a fact about the unit graph rather than +something the packaging arranges, and the boundary table below is a description of the +dependency graph rather than a rule anyone has to maintain. + +Two flags are the packaging's own, because cargoUnit cannot infer either. `-C +prefer-dynamic`: generating a dylib without it makes rustc statically absorb every +dependency into that dylib, and linking an executable without it makes rustc prefer the +rlib of a crate offering both — either way the server and the rules dylib each get their +own `flecs_ecs`. And an rpath to the toolchain's lib directory, because prefer-dynamic +makes libstd dynamic too. cargoUnit supplies the rpaths for the dylibs inside the graph, +whose store paths only it knows. + +Neither flag reaches `-C metadata`: cargoUnit derives that from its own graph identity +hash, not from the rustc arguments it passes. That is what makes the ENG-12053 hazard +class structurally impossible here rather than merely handled — under cargo, changing +nothing but an rpath produced `libflecs_ecs-c1d9502659600761` and +`libflecs_ecs-13cef1f428680a8e`. + +### How this design was arrived at, and what it replaced + +> **Everything from here to "Checking that one `flecs_ecs` really reached both +> artifacts" is history, kept because the measurements in it are what the design rests +> on. The packaging it describes — three `runCommandCC` derivations each running `cargo +> build` over a tree of hand-written stub crates — no longer exists**, and neither do the +> hazards two of its subsections describe. What is still live and stated above: the +> boundary, the source split, and every reason a multi-output derivation is wrong. + +Three things were measured, and together they settled the design. + +**`cargoUnit` could not build the module dylib.** Its library support was rlib-only and +said so in an assertion rather than in a comment: + +``` +# M2: this builder is rlib-only (the filename and extern-path hardcode +# `.rlib`). Reject an artifact that is clearly not an rlib/rmeta so a +# cdylib/staticlib/proc-macro mistake fails loud at eval, not at link. + Only plain rlib libraries are supported (not cdylib/staticlib/proc-macro). +``` + +That is fixed (ENG-12078, index#4543): a `dylib` unit now publishes every linkable +artifact it produced and a consumer passes all of them to rustc, which is what cargo does +and what lets rustc pick dynamic linkage. `workspace.libraries.smash_rules` is the route to +`libsmash_rules.so` today. + +**The binary the fleet runs today links nothing from the workspace dynamically.** It is a +`cargoUnit` build with no `-C prefer-dynamic`, so `crate-type = ["rlib", "dylib"]` on +`hyperion` changes what is *available* and not what is *linked*: + +``` +$ otool -L /nix/store/yjlf...-smash-0.1.0/bin/smash + /System/Library/Frameworks/Security.framework/... + /System/Library/Frameworks/SystemConfiguration.framework/... + /System/Library/Frameworks/CoreFoundation.framework/... + /nix/store/0ky9...-libiconv-115.100.1/lib/libiconv.2.dylib + /usr/lib/libSystem.B.dylib +$ find /nix/store/yjlf...-smash-0.1.0 -name '*.dylib' -o -name '*.so' +(nothing) +``` + +Five entries, all system. No `libhyperion`, no `libflecs_ecs`, and no dylib shipped beside +the binary. A module loaded into *that* process gets its own pool, which is the +configuration the probe exists to reject. So the packaged server has to leave the +`cargoUnit` path too, not only the module. + +**A multi-output derivation would defeat the whole feature.** The obvious repair — one +cargo build emitting the binary and the dylib as two outputs — is wrong, and quietly so. +Every output of a derivation moves when any input does, so a rules-only edit would move the +binary's store path, `ExecStart` would differ, and systemd would restart. The gate would +pass, the reload would never be attempted, and the only symptom is that players get dropped +on a deploy that should have been invisible. + +What the boundary actually requires is that **the module's store path moves when the host's +does not**, and a store path is a function of a derivation's inputs. So the boundary is a +statement about which sources reach which derivation: + +| edit | `hyperion-dylibs` | `smash-server` (`ExecStart`) | `smash-rules` (`reloadTriggers`) | deploy | +| --- | --- | --- | --- | --- | +| a rules crate | — | — | moves | **reload** | +| a host crate | — | moves | — | restart | +| an engine crate | moves | moves | moves | restart | + +All three link the engine dylibs by rpath into the store, which is what keeps one +`flecs_ecs` in the process. A component's layout lives in the host crate, so changing it +moves `ExecStart` and systemd restarts. A system's body lives in the rules crate, so +changing it moves only the reload trigger and systemd reloads. Nobody has to remember the +rule; it is the dependency graph. + +**A host edit does not move the rules dylib, and that is deliberate (ENG-12078).** The +cargo-based packaging moved it, because `mkSource` put the host crate's directory in the +rules derivation's source tree — a consequence of filtering at directory granularity, not +of a dependency edge. `events/smash-rules` depends on `flecs_ecs`, `hyperion-hot-reload` +and `tracing`; it does not depend on `smash`, so cargoUnit correctly rebuilds nothing. A +rules system cannot name a host-owned component type; it reaches components through +`hyperion-hot-reload`'s registry by name, and the loader's layout check is what catches a +component whose shape moved underneath it. That check was always doing this work — on a +host edit the old packaging just also happened to recompile. The deploy is unchanged +either way: a host edit moves `ExecStart`, systemd restarts, and the fresh process loads +and layout-checks the rules dylib, so nothing stale survives. + +**The part that will actually cost time is the source filter, and it is worth naming now.** +Each derivation needs a `src` narrow enough that an unrelated commit does not move it, and +wide enough that cargo can resolve the workspace: the root `Cargo.toml`, `Cargo.lock`, and +the member directories in that crate's dependency graph. Get it wrong in the loose +direction and `smash-server` moves on every commit, which is the build-stamp wrapper's bug +wearing a different hat, and every apply restarts. There is a cheap gate for it: build the +three derivations, touch a file in the rules crate, rebuild, and assert that exactly one of +the three paths changed. + +#### One `flecs_ecs` needs one package selection, not one derivation + +> **Obsolete mechanism, live lesson.** Cargo resolving features per invocation is why the +> old packaging had to pass one identical `-p` selection to three `cargo build`s. There is +> one invocation now, so there is nothing to keep in step. The lesson that survives is what +> the failure looked like, and that `engineUnit` in `nix/hot-reload/packaging.nix` asserts +> exactly one `flecs_ecs` unit exists rather than assuming it. + + +Splitting the build across three derivations reintroduced the problem the split exists to +prevent. Each derivation runs its own `cargo build`, and building `-p hyperion` in one and +`-p smash-rules` in another put two `flecs_ecs` in one target directory: + +``` +libflecs_ecs-a576c74c3728f55c.so from -p hyperion +libflecs_ecs-af57d040ba838c15.so from -p smash-rules +``` + +Two `flecs_ecs` is two `INDEX_POOL`s, which is exactly what `checks.hot-reload-index-probe` +rejects. The first guess was that package *selection* and *source filtering* must differ per +derivation by design, so no arrangement of inputs could unify them — that fingerprint +equality across derivations was structurally impossible. That guess was wrong, and +`cargo build --unit-graph` says why in about a minute. + +The `flecs_ecs` unit is **byte-identical** under both selections: same 23 features, same +profile, same `crate-types = ["dylib", "rlib"]`. What differs is three of its transitive +dependencies, whose metadata hashes cargo folds into the dependent's: + +| crate | `-p hyperion` | `-p smash-rules` | +| --- | --- | --- | +| `bitflags` | `serde`, `serde_core`, `std` | (none) | +| `libc` | `default`, `std` | (none) | +| `syn` | ..., `fold`, `visit` | (fewer) | + +`bitflags` is a direct dependency of `flecs_ecs`. Cargo resolves features over the packages +named on the command line — `-p hyperion` reaches 473 units and `-p smash-rules` reaches 75 — +so package selection alone moves the hash. + +Which makes the fix cheap and structural rather than clever: **pass the same selection string +to every derivation.** Feature resolution reads manifests and never source, and `mkSource` +already puts every workspace member's `Cargo.toml` into every tree, stubbing only the `.rs` +bodies. So all three invocations resolve over identical inputs by construction. A derivation +whose source stubs a package still compiles that package's dependency graph — which is +exactly the seed the others want — and then compiles an empty `lib.rs` for the package +itself. + +The rule that falls out, and the one to keep: the selection may not be narrowed to "the +packages this derivation ships", and may not be a function of the event being built. Either +is the source split written out a second time, in a place where its only symptom is a reload +that silently indexes one world two different ways. + +#### The seed may only carry artifacts whose source the consumer agrees with + +> **Obsolete mechanism.** There is no seed. The three derivations shared one `target/` +> tarball because three `cargo build`s had to reuse each other's artifacts; cargoUnit +> shares artifacts by making each one its own derivation, so nothing is copied between +> builds and nothing can be stale. + + +`hyperion-dylibs` tars its target directory so the other two do not recompile the engine, and +they date everything to 2100 because cargo decides freshness by mtime and everything unpacked +from the store shares one normalised timestamp. Once the selection was unified, that seed +also contained a `libsmash.rlib` built from a **stub** — and dated into the future, so the +consuming derivation never rebuilt it from the real source: + +``` +error[E0432]: unresolved import `smash::init_game` + --> events/smash/src/main.rs:1:5 +1 | use smash::init_game; + | no `init_game` in the root +``` + +`hyperion-dylibs` therefore `cargo clean -p`s every stubbed event member before tarring, with +a guard that fails the build if a stub artifact survives. Cleaning before the copy is also +what keeps a stub `libsmash_rules.so` — same filename as the real rules dylib, none of its +systems — from being shipped beside the engine and landing on every event binary's rpath. + +#### An engine dylib hides the C libraries it swallows + +`smash-server` failed to link with eight undefined LMDB symbols, and the cause is not where +the error points. `libhyperion.so` contains LMDB's code and hides every byte of it: + +``` +$ readelf --dyn-syms libhyperion.so | grep -c mdb_ +0 +$ readelf --syms libhyperion.so | grep mdb_env_open + 14356: 0000000000d0df3c 933 FUNC LOCAL DEFAULT 13 mdb_env_open +``` + +133 definitions, every one `LOCAL`. rustc links a Rust `dylib` with its own anonymous version +script ending in `local: *`, which demotes every symbol arriving from a native static +archive. The discriminator is that `ecs_*` (77) and `flecs_*` (22) *are* exported while +`mdb_`, `AWS_LC` and `deflate` are all at zero — flecs is visible only because `flecs_ecs` +ships a `build.rs` that adds a second version script for precisely this reason. + +That is fatal rather than merely wasteful because `heed`'s API is generic: every consumer +monomorphises heed's code into its own rlib and emits its own `mdb_*` calls. +`hyperion-permission` is such a consumer and links into an event's binary statically, while +rustc suppresses lmdb's own `-llmdb` on the grounds that an upstream dylib already provides +it. + +`-Wl,--export-dynamic-symbol=mdb_*` does not fix it — measured here leaving the exported +count at zero, independently reproducing what `flecs_ecs/build.rs` documents for `ecs_*`. +A second version script does, and it has to live in the crate that *is* the dylib, because a +build script's `rustc-link-arg` applies to its own crate's artifacts and nothing else. That +is what `crates/hyperion/build.rs` is: 70 exported `mdb_*` after, `mdb_env_open` `GLOBAL`. + +This keeps one copy of LMDB in the process. Linking a second `liblmdb.a` into the executable +would also make the link succeed, and would put two copies of a C library with process-global +state in one process — the same shape the index probe exists to reject for flecs. The general +signature, for the next native library that hits this: an undefined `foo_*` at an event's +final link whose definition is `LOCAL` in `libhyperion.so`'s `.symtab`. + +### Checking that one `flecs_ecs` really reached both artifacts + +`checks.hot-reload-one-flecs` asserts it, on the artifacts that ship. It used to be a manual +`readelf`/`ldd` recipe here, with a note saying to write the check once the cargoUnit +migration made the property structural. It did, so this is that check (ENG-12078). + +Two questions, and only asking both is a check: + +1. **The same `DT_NEEDED` name.** Two different metadata hashes is two `INDEX_POOL`s: one + world indexed two different ways, with no crash and no error, components reading as each + other's neighbours. +2. **The same resolved store path.** The same hash reaching two store paths is the identical + aliasing fault wearing a nicer name, and a string comparison alone cannot see it. + +The build already refuses a dangling `DT_NEEDED` (`requireResolved` in +`nix/hot-reload/packaging.nix`), so each artifact resolves *something*; that is a weaker +property than the two above and does not imply either. + +The check fails closed. Each extraction is checked for emptiness before anything is +compared, because "no `flecs_ecs` line found" and "the two `flecs_ecs` lines agree" are +otherwise the same silence. It also asserts the resolved file is the one `hyperion-dylibs` +exposes, so a third copy that happens to be consistent between the two artifacts is still +caught. + +What it looks like when it holds: + +```console +$ readelf -d .../smash-server/bin/smash | grep flecs + 0x0000000000000001 (NEEDED) Shared library: [libflecs_ecs-fa19ab35c63d573f.so] +$ readelf -d .../smash-rules/lib/libsmash_rules.so | grep flecs + 0x0000000000000001 (NEEDED) Shared library: [libflecs_ecs-fa19ab35c63d573f.so] +$ ldd .../smash-rules/lib/libsmash_rules.so | grep flecs + libflecs_ecs-fa19ab35c63d573f.so => /nix/store/0hvrp...-flecs_ecs-0.2.2/lib/libflecs_ecs-fa19ab35c63d573f.so +``` + +Since ENG-12078 the eval-time half is stronger than the runtime half: one cargoUnit graph +resolves features once, so there is one `flecs_ecs` derivation, and `engineUnit` in the +packaging fails the build if the graph ever holds two. This check is what the loader +actually does with the resulting files. + +### The boundary, measured rather than asserted + +`checks.hot-reload-source-split` asks this directly rather than standing in for it. It +instantiates the packaging over a perturbed source tree and compares `drvPath`s, in both +directions, which is exactly the table above: + +``` + baseline after a rules-only edit +hyperion-dylibs +smash-server +smash-rules MOVED + + after a host (component) edit +hyperion-dylibs +smash-server MOVED +smash-rules +``` + +A `drvPath` that did not move guarantees an `outPath` that did not move, so the assertion +stays conservative in the safe direction even though every unit is content-addressed. + +A rules edit moves the reload trigger and leaves `ExecStart` alone, so systemd reloads. A +component edit moves `ExecStart`, so systemd restarts — which is the correct outcome, because +a system compiled against a layout the world no longer holds is memory corruption rather than +a stale build. + +Broken once and watched failing, by making the server derivation take the rules unit as an +input: + +``` +error: hot-reload-source-split: smash-server on a rules edit moved and must not have. + before: /nix/store/2mpmd2j7v1zy29ha5s0j6ixb9rsp100j-smash-server.drv + after: /nix/store/m0sclw0czzabkk2igicalc52pvc6xv62-smash-server.drv +``` ### Adopting it costs no scheduled restart diff --git a/docs/smash-design.md b/docs/smash-design.md index 0ce3c5546..8efba1505 100644 --- a/docs/smash-design.md +++ b/docs/smash-design.md @@ -511,12 +511,25 @@ Honest list. Judged against readability at each step, and where they conflicted the choice is recorded. -**Reads do not cross the seam.** Position, rotation and ground state are mirror -components written by the adapter once per tick, so the per-tick hot paths — the -cooldown tick, the arena bounds check, projectile integration — are plain -component iteration with no virtual calls. Only writes go through the `Server` -trait, and writes happen on hit, on death and on kit change, never per entity -per tick. +**Reads do not cross the `Server` seam.** Position, rotation and ground state +are mirror components written by the adapter once per tick, so the per-tick hot +paths — the cooldown tick, the arena bounds check, projectile integration — are +plain component iteration with no virtual calls. Only writes go through the +`Server` trait, and writes happen on hit, on death and on kit change, never per +entity per tick. + +**The one read that does cross a seam is terrain**, and it has its own: +`BlockWorld` in `src/module/blocks.rs`, one method, defaulting to `OpenAir`. +Terrain is the case the mirror cannot serve — millions of blocks, a handful +looked at per tick, and an authoritative copy that already exists on the host, +so copying it would be maintaining a second one that drifts the moment anybody +places a block. It is a separate trait rather than a tenth `Server` method +because `Server` is a list of things the game asks the host to *do*, and because +a default of "nothing is solid" means every test that is not about terrain, and +the whole of the mock, needs no implementation at all. The cost is one virtual +call per projectile per tick, and the call answers the whole segment rather than +one block, so the traversal stays in `geometry::sweep` where the host's block +store and the tests' `Cubes` share it. **Ability behaviour is a function pointer.** Zero allocation, one indirect call per activation. @@ -542,11 +555,15 @@ per hit. Stated plainly, because these are the parts nobody can see from the code. -1. **No block world.** Projectiles expire on a timer and on entity contact only; - they do not collide with terrain. Fissure resolves its fourteen columns - immediately instead of walking a block wall. Enderman's Block Toss does not - check that there is air above the block it picks up. All three need hyperion's - `Blocks`. +1. **Only projectiles read the block world.** Projectiles now sweep their + tick's travel against terrain and stop at the first surface, through the + read seam in `src/module/blocks.rs`; the rest of the list here still does + not. Fissure resolves its fourteen columns immediately instead of walking a + block wall, and Enderman's Block Toss does not check that there is air above + the block it picks up. Both want the same seam, which now exists. What a + projectile does *about* an impact is also still one thing for every kind -- + it sticks and expires -- so a Sulphur Bomb that meets a wall stops there + rather than detonating (ENG-12055). 2. **No Smash Crystal spawning.** The ultimates exist and are granted through the ordinary relationship; the beacon, the descent and the pickup are not built. 3. **Assists.** Only the last hit is tracked. diff --git a/events/bedwars/Cargo.toml b/events/bedwars/Cargo.toml index fe262823a..4a8ae50bb 100644 --- a/events/bedwars/Cargo.toml +++ b/events/bedwars/Cargo.toml @@ -14,6 +14,7 @@ hyperion-inventory = { workspace = true } hyperion-item = { workspace = true } hyperion-permission = { workspace = true } hyperion-utils = { workspace = true } +hyperion-web-console = { workspace = true } rayon = { workspace = true } roaring = { workspace = true } rustc-hash = { workspace = true } @@ -36,6 +37,3 @@ name = "bedwars" publish = false readme = "README.md" version.workspace = true - -[target.'cfg(not(target_os = "windows"))'.dependencies] -tikv-jemallocator.workspace = true diff --git a/events/bedwars/src/lib.rs b/events/bedwars/src/lib.rs index 04495b545..c485aeeb6 100644 --- a/events/bedwars/src/lib.rs +++ b/events/bedwars/src/lib.rs @@ -1,5 +1,3 @@ -#![feature(exact_size_is_empty)] - use std::net::SocketAddr; use flecs_ecs::prelude::*; @@ -14,7 +12,7 @@ use valence_text::IntoText; use crate::module::{ attack::AttackModule, block::BlockModule, bow::BowModule, chat::ChatModule, damage::DamageModule, regeneration::RegenerationModule, spawn::SpawnModule, - tab_list::TabListModule, vanish::VanishModule, + tab_list::BedwarsTabListModule, vanish::VanishModule, }; mod command; @@ -119,7 +117,7 @@ impl Module for BedwarsModule { world.import::(); world.import::(); - world.import::(); + world.import::(); world.import::(); world.import::(); world.import::(); @@ -156,7 +154,11 @@ impl Module for BedwarsModule { } } -pub fn init_game(address: SocketAddr, crypto: Crypto) -> anyhow::Result<()> { +pub fn init_game( + address: SocketAddr, + crypto: Crypto, + console: Option, +) -> anyhow::Result<()> { let world = World::new(); world.import::(); @@ -165,6 +167,15 @@ pub fn init_game(address: SocketAddr, crypto: Crypto) -> anyhow::Result<()> { world.set(crypto); world.set(GameServerEndpoint::from(address)); + // After the game's own modules, so a command registered by the event is + // already in the registry the console's caller will be dispatched + // against, and after `GameServerEndpoint`, so the first line on the page + // is a server that has finished coming up. + if let Some(config) = console { + world.import::(); + hyperion_web_console::install(&world, &config)?; + } + let mut app = world.app(); app.enable_rest(0) diff --git a/events/bedwars/src/main.rs b/events/bedwars/src/main.rs index aba85c88b..85d46847c 100644 --- a/events/bedwars/src/main.rs +++ b/events/bedwars/src/main.rs @@ -1,9 +1,19 @@ -use bedwars::init_game; +//! bedwars' entry point. +//! +//! No `#[global_allocator]`, for the reason written out in full in +//! `events/smash/src/main.rs`: a Rust global allocator and the `-C +//! prefer-dynamic` dylib split put two allocators in one process, and the first +//! pointer that crosses between them segfaults. bedwars is not packaged that way +//! yet, and adding an event to `hotReloadEvents` is meant to be one attrset -- +//! so the landmine is removed here rather than left for whoever does it. -#[cfg(not(target_env = "msvc"))] -#[global_allocator] -static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; +use bedwars::init_game; fn main() -> anyhow::Result<()> { - hyperion_event_runner::run("BEDWARS_", init_game) + // bedwars takes no deployment paths: it has no reloadable rules, so that + // part of `Args` is none of its business. It does take a console, which is + // engine-level and asks the same operator questions of either game. + hyperion_event_runner::run("BEDWARS_", |args, crypto| { + init_game(args.address(), crypto, args.console()?) + }) } diff --git a/events/bedwars/src/module/block.rs b/events/bedwars/src/module/block.rs index 883ce0167..c081d2687 100644 --- a/events/bedwars/src/module/block.rs +++ b/events/bedwars/src/module/block.rs @@ -93,7 +93,7 @@ impl Module for BlockModule { sequence, } in event_queue.drain() { - if block.collision_shapes().is_empty() { + if translate::collision_shapes(block).is_empty() { blocks .to_confirm .push(EntityAndSequence::new(from, sequence)); diff --git a/events/bedwars/src/module/bow.rs b/events/bedwars/src/module/bow.rs index 5dca811b8..1f3ead825 100644 --- a/events/bedwars/src/module/bow.rs +++ b/events/bedwars/src/module/bow.rs @@ -6,7 +6,6 @@ use flecs_ecs::{ }; use hyperion::{ ItemKind, ItemStack, - glam::Vec3, net::Channel, simulation::{ Owner, Pitch, Player, Position, Spawn, Uuid, Velocity, Yaw, @@ -312,25 +311,26 @@ impl Module for BowModule { } }); - // multi-threaded causes issues + // Stopping the arrow is no longer this module's job: `AbstractArrow. + // onHitBlock` is one statement -- pin, zero, embed -- and + // `update_projectile_positions` now performs all of it in the tick the + // hit happens. Doing it here as well used to overwrite the engine's + // backed-off resting point with the raw impact point, and doing it a + // pipeline stage later left a window in which the arrow was stopped by + // the world and still carrying its flight speed. + // + // The queue still has to be emptied. `EventQueue` has no cycle-end + // clear, so an unread event lives until someone drains it and a match + // that never did would grow one entry per arrow that ever landed. system!( "arrow_block_hit", world, &mut EventQueue, ) .kind(id::()) - .each_iter(move |it, _, event_queue| { - let world = it.world(); - + .each_iter(move |_, _, event_queue| { for event in event_queue.drain() { - event - .projectile - .entity_view(world) - .get::<(&mut Position, &mut Velocity)>(|(position, velocity)| { - debug!("Arrow hit block at {:?}", event.collision.point); - velocity.0 = Vec3::ZERO; - **position = event.collision.point; - }); + debug!("Arrow hit block at {:?}", event.collision.point); } }); diff --git a/events/bedwars/src/module/chat.rs b/events/bedwars/src/module/chat.rs index f0eac9612..f01b36791 100644 --- a/events/bedwars/src/module/chat.rs +++ b/events/bedwars/src/module/chat.rs @@ -10,7 +10,7 @@ use hyperion::{ text::{Component, NamedColor, Style, TextColor}, }, net::{ConnectionId, protocol::Clientbound}, - simulation::{Name, Player, Position, event}, + simulation::{Name, Player, Position, chat::strip_formatting, event}, storage::EventQueue, }; use tracing::info_span; @@ -111,7 +111,12 @@ impl Module for ChatModule { .with_style(colored(TextColor::Rgb(team.rgb()))), ) .append(Component::text("> ").with_style(colored(BRACKET_COLOR))) - .append(Component::text(msg)); + // Not `msg`. The client renders a literal string + // through its legacy formatter, so a section sign + // a player typed recolours their own text and lets + // them draw something that looks like a server + // notice. + .append(Component::text(strip_formatting(msg))); let packet = SystemChat { content: chat.to_tag(), diff --git a/events/bedwars/src/module/regeneration.rs b/events/bedwars/src/module/regeneration.rs index e6a61e116..9f1fb5126 100644 --- a/events/bedwars/src/module/regeneration.rs +++ b/events/bedwars/src/module/regeneration.rs @@ -44,7 +44,14 @@ impl Module for RegenerationModule { |(last_damaged, prev_health, health, compose)| { let current_tick = compose.global().tick; - if *health < *prev_health { + // Through `Health`'s `Deref` to `f32` rather than on `Health` itself. + // The metadata macro used to hand every component a blanket + // `impl PartialOrd where $type: PartialOrd`; for the seven whose + // inner type is a glam `Quat` or `Vec3` that bound is unsatisfiable, + // and rustc still listed those uncallable `partial_cmp` bodies in the + // dylib's export table, forcing `-Wl,--allow-shlib-undefined` on every + // consumer. See `docs/hot-reload.md`. + if **health < **prev_health { last_damaged.tick = current_tick; } diff --git a/events/bedwars/src/module/spawn.rs b/events/bedwars/src/module/spawn.rs index 8d8e25b84..4ca453f70 100644 --- a/events/bedwars/src/module/spawn.rs +++ b/events/bedwars/src/module/spawn.rs @@ -7,7 +7,10 @@ use flecs_ecs::{ }; use hyperion::{ runtime::AsyncRuntime, - simulation::{Position, Uuid, blocks::Blocks}, + simulation::{ + Position, Uuid, + blocks::{Blocks, translate}, + }, valence_protocol::{ BlockKind, math::{IVec2, IVec3, Vec3}, @@ -128,7 +131,7 @@ pub fn is_valid_spawn_block( return false; }; - if ground.collision_shapes().is_empty() { + if translate::collision_shapes(ground).is_empty() { return false; } @@ -139,7 +142,7 @@ pub fn is_valid_spawn_block( for displacement in DISPLACEMENTS { let above = pos + displacement; if let Some(block) = blocks.get_block(above) { - if !block.collision_shapes().is_empty() { + if !translate::collision_shapes(block).is_empty() { return false; } diff --git a/events/bedwars/src/module/tab_list.rs b/events/bedwars/src/module/tab_list.rs index 6c8df5a33..288bcbd98 100644 --- a/events/bedwars/src/module/tab_list.rs +++ b/events/bedwars/src/module/tab_list.rs @@ -1,29 +1,61 @@ +//! The tab list header, and the same numbers on the console. +//! +//! The packet itself is not sent from here. `hyperion::egress::tab_list` owns +//! the `TabList` singleton and broadcasts it when it changes; this module +//! writes the header half and lets that happen. Before that split existed both +//! halves were built and broadcast here, unconditionally, to every player, +//! twenty times a second. +//! +//! The tick-time averages are still worth showing next to the tick *rate* the +//! footer carries: a rate says whether the server kept up, and the three +//! averages say how much headroom it had while doing it. + use flecs_ecs::{ - core::{SystemAPI, World}, + core::{SystemAPI, World, WorldGet}, macros::{Component, system}, prelude::Module, }; use hyperion::{ - hyperion_minecraft_proto::{ - generated::packet_id::play::clientbound::PacketId, packets::play::clientbound::TabList, - text::Component, - }, - net::{Compose, protocol::Clientbound}, + egress::tab_list::{TabList, TabListComponentsModule, Text}, + hyperion_minecraft_proto::text::NamedColor, + net::Compose, }; use tracing::{info, info_span}; +/// Named for the crate it belongs to, not for what it does, and that is +/// load bearing: flecs names a module entity after the **last segment** of +/// its type path and nothing else, so this and +/// `hyperion::egress::tab_list::TabListModule` were one name in one flat +/// namespace. A dev build aborts on the collision -- +/// `entity symbol inconsistent: bedwars::module::tab_list::TabListModule +/// (provided) vs. hyperion::egress::tab_list::TabListModule (existing)` -- +/// and a release build, where that assert is compiled out, quietly treats +/// the import as already done and installs none of this. #[derive(Component)] -pub struct TabListModule; +pub struct BedwarsTabListModule; -/// One console line per second at the 20 Hz tick rate. +/// One console line, and one header rebuild, per second at the 20 Hz tick +/// rate. +/// +/// The header is rate limited rather than written every tick because a +/// millisecond average to two decimals moves on every single one, and +/// `tab_list_sync` sends whenever the text changes: writing it at tick rate +/// would put the per-tick broadcast straight back. Nobody reads a number that +/// changes twenty times a second anyway. const TICKS_PER_LOG: u32 = 20; -impl Module for TabListModule { - #[allow(clippy::excessive_nesting)] +/// How many samples the longest window holds. +const WINDOW_TICKS: usize = 20 * 60; + +impl Module for BedwarsTabListModule { fn module(world: &World) { + // The header is written into a component this module does not own, so + // the module that registers it is imported rather than assumed. + world.import::(); + let mode = env!("RUN_MODE"); - let mut tick_times = Vec::with_capacity(20 * 60); // 20 ticks per second, 60 seconds + let mut tick_times = Vec::with_capacity(WINDOW_TICKS); let mut last_frame_time_total = 0.0; let mut ticks_since_log = 0u32; @@ -43,7 +75,7 @@ impl Module for TabListModule { last_frame_time_total = current_frame_time_total; tick_times.push(ms_per_tick); - if tick_times.len() > 20 * 60 { + if tick_times.len() > WINDOW_TICKS { tick_times.remove(0); } @@ -51,38 +83,38 @@ impl Module for TabListModule { let avg_s15 = tick_times.iter().rev().take(20 * 15).sum::() / (20.0 * 15.0); let avg_s60 = tick_times.iter().sum::() / tick_times.len() as f32; - let title = format!( - "§b{mode}§r\n§aµ/5s: {avg_s05:.2} ms §r| §eµ/15s: {avg_s15:.2} ms §r| §cµ/1m: \ - {avg_s60:.2} ms" - ); - - let footer = format!("§d§l{player_count} players online"); - - // The components borrow the two strings and the tags borrow the - // components, so all four have to outlive the send. - let header_text = Component::text(title.as_str()); - let footer_text = Component::text(footer.as_str()); - let pkt = TabList { - header: header_text.to_tag(), - footer: footer_text.to_tag(), - }; + ticks_since_log += 1; + if ticks_since_log < TICKS_PER_LOG { + return; + } + ticks_since_log = 0; - compose - .broadcast(Clientbound::new(PacketId::TabList.to_raw(), &pkt)) - .send() - .unwrap(); + // Components and not `§` markup, which is the rule + // `hyperion::egress::boss_bar` states and the same `Text` type + // carries here: a colour a caller can smuggle in as text is a + // colour nothing can check. + let header = Text::text("").extend([ + Text::text(mode).color(NamedColor::Aqua), + Text::text("\n"), + Text::text(format!("µ/5s: {avg_s05:.2} ms")).color(NamedColor::Green), + Text::text(" | "), + Text::text(format!("µ/15s: {avg_s15:.2} ms")).color(NamedColor::Yellow), + Text::text(" | "), + Text::text(format!("µ/1m: {avg_s60:.2} ms")).color(NamedColor::Red), + ]); + world.get::<&mut TabList>(|list| { + if list.header != header { + list.header = header; + } + }); // The tab list carries this already, but an operator watching the // console has no other way to see whether anyone is connected or how // the tick budget is holding up. - ticks_since_log += 1; - if ticks_since_log >= TICKS_PER_LOG { - ticks_since_log = 0; - info!( - "{player_count} players online | tick µ/5s {avg_s05:.2} ms, µ/15s \ - {avg_s15:.2} ms, µ/1m {avg_s60:.2} ms" - ); - } + info!( + "{player_count} players online | tick µ/5s {avg_s05:.2} ms, µ/15s {avg_s15:.2} \ + ms, µ/1m {avg_s60:.2} ms" + ); }); } } diff --git a/events/smash-rules/Cargo.toml b/events/smash-rules/Cargo.toml new file mode 100644 index 000000000..7efaa5a6e --- /dev/null +++ b/events/smash-rules/Cargo.toml @@ -0,0 +1,21 @@ +[package] +authors = ["Andrew Gazelka "] +edition.workspace = true +name = "smash-rules" +publish = false +version.workspace = true + +# A dylib and not a cdylib. `cdylib` would give this its own copy of every Rust +# dependency, including `flecs_ecs`, and two `flecs_ecs` copies in one process +# is two `INDEX_POOL`s indexing one world two different ways -- +# `checks.hot-reload-index-probe` exists to catch exactly that. +[lib] +crate-type = ["dylib"] + +[dependencies] +flecs_ecs = { workspace = true } +hyperion-hot-reload = { workspace = true } +tracing = { workspace = true } + +[lints] +workspace = true diff --git a/events/smash-rules/src/lib.rs b/events/smash-rules/src/lib.rs new file mode 100644 index 000000000..2aa7e016d --- /dev/null +++ b/events/smash-rules/src/lib.rs @@ -0,0 +1,80 @@ +//! smash's reloadable rules. +//! +//! This crate is the unit of hot reload: its store path is what +//! `nix/modules/game-server.nix` puts in `X-Reload-Triggers`, so an edit here +//! reaches a running server as `systemctl reload` and an edit anywhere else +//! reaches it as a restart. That boundary is the source split and nothing else +//! -- see `docs/hot-reload.md`, "Packaging: three derivations". +//! +//! # What may live here, and what may not +//! +//! Systems and observers only. **No `world.component::()`.** A component's +//! layout has to have exactly one definition in the process, and that +//! definition lives in the host, because a system compiled against one layout +//! reading a world that holds another is silent memory corruption rather than +//! an error. The host registers; this crate reads and writes. +//! +//! The gate in `hyperion-hot-reload` enforces the consequence rather than the +//! rule: it refuses a reload whose component schemas moved. Keeping +//! registration out of here is what makes that refusal never fire in normal +//! work. +//! +//! # Deliberately trivial, for now +//! +//! One system that logs. The deployment half -- reload-not-restart, the +//! surviving player connection, the title -- is being proven against this +//! before smash's real rules are migrated into it, because the refactor has no +//! unknowns and the deployment has all of them. `docs/hot-reload.md` explains +//! why that order is the reverse of the order they were designed in. + +use flecs_ecs::prelude::*; + +/// How many ticks between heartbeat lines. +/// +/// A whole second at 20 tps. Frequent enough that a reload's effect shows up in +/// the journal while somebody is watching for it, rare enough that it is not +/// the reason the journal fills. +const TICKS_PER_BEAT: u64 = 20; + +/// What the heartbeat says. +/// +/// Editing this string is the canonical rules-only change: it moves this +/// crate's store path and nothing else's, so the deploy that carries it must +/// reload rather than restart. The reload proof drives exactly this edit. +const BEAT: &str = "smash rules heartbeat LIVE-RELOAD-1"; + +/// Behaviour, and no registration. See the module docs for why that split is +/// load bearing rather than tidy. +#[derive(Component)] +pub struct SmashRulesModule; + +impl Module for SmashRulesModule { + fn module(world: &World) { + world.module::("smash::Rules"); + + // Counted in the closure rather than read from a component, because a + // component would be registration and registration belongs to the + // host. A `static` inside the dylib is re-initialised by each new + // build, which is the honest behaviour for a counter that is not game + // state: nothing migrates it and nothing should. + let mut ticks: u64 = 0; + world + .system_named::<()>("smash_rules_heartbeat") + .kind(id::()) + .run(move |mut it| { + while it.next() { + ticks += 1; + if ticks.is_multiple_of(TICKS_PER_BEAT) { + tracing::info!("{BEAT}"); + } + } + }); + } +} + +hyperion_hot_reload::export_module! { + name: "smash-rules", + register: |world| { + world.import::(); + }, +} diff --git a/events/smash/Cargo.toml b/events/smash/Cargo.toml index e0b8ee2c1..0ba164617 100644 --- a/events/smash/Cargo.toml +++ b/events/smash/Cargo.toml @@ -10,16 +10,18 @@ version.workspace = true anyhow = { workspace = true } clap = { workspace = true } flecs_ecs = { workspace = true } +geometry = { workspace = true } glam = { workspace = true } hyperion = { workspace = true } hyperion-event-runner = { workspace = true } +hyperion-hot-reload = { workspace = true } hyperion-clap = { workspace = true } hyperion-inventory = { workspace = true } hyperion-item = { workspace = true } hyperion-minecraft-proto = { workspace = true } hyperion-permission = { workspace = true } hyperion-utils = { workspace = true } -rayon = { workspace = true } +hyperion-web-console = { workspace = true } tracing = { workspace = true } valence_nbt = { workspace = true } valence_protocol = { workspace = true } @@ -36,6 +38,3 @@ proptest = { workspace = true } [lints] workspace = true - -[target.'cfg(not(target_os = "windows"))'.dependencies] -tikv-jemallocator.workspace = true diff --git a/events/smash/src/chat.rs b/events/smash/src/chat.rs new file mode 100644 index 000000000..174e9c210 --- /dev/null +++ b/events/smash/src/chat.rs @@ -0,0 +1,128 @@ +//! Player chat. +//! +//! hyperion decodes a chat packet into [`event::ChatMessage`] and stops there: +//! nothing in the engine broadcasts it, because what a chat line looks like is +//! a game's decision. Until this file existed smash made no decision, so every +//! message a player typed was decoded, queued, and dropped when the queue was +//! recycled. Two clients could stand next to each other and neither could say +//! anything to the other. +//! +//! The line is vanilla's: ` message`, undecorated. Vanilla's +//! `chat.type.text` translation is `<%s> %s` with no style on either half, and +//! a fighting game has no teams or ranks to colour a name by, so there is +//! nothing here that vanilla would have coloured. +//! +//! # Why this is host-side and not under `src/module/` +//! +//! It reads a hyperion event queue. Everything under `src/module/` is the game +//! half and compiles against the [`crate::server`] seam alone, which is what +//! lets `tests/` run the whole game with no Minecraft server behind it. The +//! translation from a hyperion event to a game action is [`crate::input`]'s +//! job and this is one of those, kept in its own file only so that the chat +//! decision is somewhere a person can find it. + +use flecs_ecs::prelude::*; +use hyperion::{ + simulation::{Name, chat::strip_formatting, event}, + storage::EventQueue, +}; + +use crate::server::{Channel, ServerHandle, Text}; + +/// The chat line for one message, exactly as a client draws it. +/// +/// Split out from the system so the formatting is testable without a world: +/// the system's job is to find the speaker's name, and this is the whole of +/// what the game decides. +/// +/// [`strip_formatting`] is not optional. The client renders a literal string +/// through its legacy formatter, so a section sign a player typed is a +/// formatting instruction and not a character; see that function for what it +/// buys. +#[must_use] +pub fn line(speaker: &str, message: &str) -> Text { + Text::text(format!("<{speaker}> {}", strip_formatting(message))) +} + +/// Broadcasting what players type. Behavior only; the components it reads are +/// registered by the modules it imports. +#[derive(Component)] +pub struct SmashChatModule; + +impl Module for SmashChatModule { + fn module(world: &World) { + // `ServerHandle` and every game component below it, and with them the + // `hyperion::simulation` registrations that hyperion's own import + // brings in. A behavior module imports the registration for everything + // it touches even when a parent already did: flecs dedupes the import + // and a missing one is a dev-profile abort. + world.import::(); + + world + .system_named::<&mut EventQueue>("smash_chat") + .each_iter(|it, _index, queue| { + let world = it.world(); + + for event::ChatMessage { msg, by } in queue.drain() { + let speaker = world.entity_from_id(by); + + // A player who disconnected or died between the packet + // arriving and this tick draining the queue. Their message + // is still theirs, but there is no name to attribute it to. + if !speaker.is_alive() { + continue; + } + + // Whitespace only. The client will not send an empty + // string, but it will happily send a space. + let msg = msg.as_str(); + if msg.trim().is_empty() { + continue; + } + + let Some(name) = speaker.try_get::<&Name>(ToString::to_string) else { + // Every entity that can send a chat packet is a player + // and hyperion names a player during login, so this is + // a hole in that rather than a message to drop + // quietly. + tracing::warn!("dropping a chat message from an unnamed entity {by:?}"); + continue; + }; + + world.get::<&ServerHandle>(|server| { + server.broadcast(Channel::Chat, line(&name, msg)); + }); + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::line; + + #[test] + fn renders_the_vanilla_shape() { + assert_eq!(line("Andrew", "hello").plain(), " hello"); + } + + #[test] + fn a_section_sign_in_the_message_is_dropped() { + // Without this the client draws "kbold" scrambled and obfuscated, and + // the second line below reads as a server notice rather than as somebody + // talking. + assert_eq!(line("Andrew", "\u{a7}kbold").plain(), " kbold"); + assert_eq!( + line("Andrew", "\u{a7}4[Server] restarting").plain(), + " 4[Server] restarting" + ); + } + + #[test] + fn an_ordinary_message_is_untouched() { + assert_eq!( + line("Andrew", "gg <3 100% !").plain(), + " gg <3 100% !" + ); + } +} diff --git a/events/smash/src/lib.rs b/events/smash/src/lib.rs index 66a28eb65..a3669119c 100644 --- a/events/smash/src/lib.rs +++ b/events/smash/src/lib.rs @@ -9,6 +9,7 @@ //! [`adapter`], [`mirror`], [`input`] and [`command`]. pub mod adapter; +pub mod chat; pub mod command; pub mod draw; pub mod flecs_ext; @@ -18,19 +19,42 @@ pub mod mirror; pub mod module; pub mod server; pub mod terrain; +pub mod terrain_seam; -use std::net::SocketAddr; +use std::{ + net::SocketAddr, + path::{Path, PathBuf}, +}; +use anyhow::Context; use flecs_ecs::prelude::*; use hyperion::{Crypto, GameServerEndpoint, HyperionCore}; use hyperion_clap::hyperion_command::CommandRegistry; +use hyperion_event_runner::Deployment; +use hyperion_hot_reload::service::{self, Outcome, ReloadService}; -use crate::module::{ - ability::AbilityModule, arena::ArenaModule, build_stamp::BuildStampModule, - damage::DamageModule, effect::EffectModule, hud::HudModule, jump::JumpModule, kit::KitModule, - kits::StockKits, knockback::KnockbackModule, lives::LivesModule, lobby::LobbyModule, - player::PlayerModule, projectile::ProjectileModule, scoreboard::ScoreboardModule, - selector::SelectorModule, sound::SoundModule, vitals::VitalsModule, +use crate::{ + module::{ + ability::AbilityModule, + arena::ArenaModule, + build_stamp::{self, BuildStamp, BuildStampModule}, + damage::DamageModule, + effect::EffectModule, + hud::HudModule, + jump::JumpModule, + kit::KitModule, + kits::StockKits, + knockback::KnockbackModule, + lives::LivesModule, + lobby::LobbyModule, + player::PlayerModule, + projectile::ProjectileModule, + scoreboard::ScoreboardModule, + selector::SelectorModule, + sound::SoundModule, + vitals::VitalsModule, + }, + server::{NamedColor, Text, Title}, }; /// The whole game. @@ -103,9 +127,17 @@ impl Module for SmashHost { world.import::(); world.import::(); + // Chat: hyperion decodes it and broadcasts nothing, so without this a + // player's message reaches no one. Host-side because it reads a + // hyperion event queue; see `crate::chat`. + world.import::(); // After the adapter, because building the maps writes the `Arena` // singleton the game half registered. world.import::(); + // Points the game's terrain reads at hyperion's block store. Without + // it the game half keeps its `OpenAir` default and projectiles fly + // through the arena, which is what they did before this existed. + world.import::(); // After the adapter too: it draws the game half's projectiles, whose // `Projectile`, `Visual` and `Flight` the adapter's `SmashModule` // import is what registers. @@ -117,17 +149,135 @@ impl Module for SmashHost { } } +/// Say what the reload did, in the journal and on every player's screen. +/// +/// # Why the host does the logging +/// +/// `hyperion-hot-reload` reports rather than logs, because it cannot depend on `tracing` +/// without duplicating it against `hyperion`'s copy -- see that crate's `Cargo.toml`. So +/// the severities are chosen here, and they are not the same for the two arms. **A refusal +/// is an `error`**: it means the deploy did not take, and the operator has to find it in a +/// query filtered by severity rather than by reading a log. An accepted reload is `info`, +/// which is what `hyperion-event-runner` defaults to precisely so this line arrives. +/// +/// # Why the players are told at all +/// +/// A reload is invisible from a client: nothing disconnects, nothing respawns, and the +/// only outward sign is that a rule behaves differently than it did a second ago. Somebody +/// in a match who is about to lose to a number that just changed deserves to know it +/// changed, and somebody watching their own deploy land needs the confirmation that it +/// landed. A title is the one channel that reaches a player looking at the middle of their +/// screen, which in a fighting game is everyone. +/// +/// The build bar underneath is not a duplicate of it: the title is gone in a few seconds +/// and says "something just changed", the bar stays and says "to what". +/// +/// # Why this is a callback and not an observer +/// +/// A reload deletes and re-creates every system and observer the module registered, so an +/// observer is exactly the wrong shape -- the thing that would react to the event is the +/// thing the event just replaced. See `hyperion_hot_reload::service::run`, which calls this +/// between two frames. +fn announce_reload(world: &World, outcome: &Outcome, build_stamp: &Path) { + let reloaded = match outcome { + Outcome::Applied(reloaded) => reloaded, + Outcome::Refused(reason) => { + tracing::error!("hot reload refused, the world is unchanged: {reason}"); + return; + } + }; + + // Re-read, because the deploy that carried these rules rewrote these files and there is + // no new `exec` to pick them up. This is the whole reason the stamp is files rather + // than the environment it used to be. + world.set(BuildStamp::read(build_stamp)); + + // `reloaded.revision` is `/build-rev` read by the service on the same + // reload, so the title and the bar cannot name different builds. + let label = reloaded.revision.as_deref().unwrap_or("an unknown build"); + + tracing::info!( + module = %reloaded.module, + revision = label, + migrated_instances = reloaded.migrated_instances, + "hot reload accepted" + ); + + world.get::<&ServerHandle>(|server| { + server.broadcast_title(Title::new( + Text::text(format!("Reloading to build {label}")).color(NamedColor::Yellow), + )); + }); +} + +/// The reload half of a deployment, bound and loaded. +/// +/// The two travel together because [`announce_reload`] needs the stamp +/// directory and only a server that has a service can ever reach it. +struct Rules { + service: ReloadService, + build_stamp: PathBuf, +} + +impl Rules { + /// Bind the socket and load the rules for the first time. + /// + /// A first load that fails is fatal, unlike a reload that is refused: the + /// running world has nothing to protect yet, and a server that came up + /// silently missing every one of its rules is worse than one that did not + /// come up. + fn open(world: &World, deployment: Deployment) -> anyhow::Result { + world.set(BuildStamp::read(&deployment.build_stamp)); + + let mut service = ReloadService::bind( + &deployment.reload_socket, + deployment.rules.clone(), + deployment.build_stamp.join(build_stamp::REV_FILE), + ) + .with_context(|| format!("binding {}", deployment.reload_socket.display()))?; + + let applied = service + .load_initial(world) + // `LoadError` is not `Send + Sync`, which `anyhow::Error` wants: + // it carries a `libloading::Error`. The text is the whole value. + .map_err(|e| anyhow::anyhow!("loading {}: {e}", deployment.rules.display()))?; + tracing::info!(module = %applied.module, "rules loaded"); + + Ok(Self { + service, + build_stamp: deployment.build_stamp, + }) + } +} + /// Build the world and run it. The entry point `main.rs` calls. /// -/// `embedded_proxy` asks for a proxy inside this process, listening on that -/// address. Running one is the shortest path to a playable server, but it is -/// optional because the deployed shape is proxies on their own machines -- -/// and because two proxies racing for one port is the sort of thing that -/// leaves a dev stack half up with no obvious reason why. +/// `deployment` is the packaged shape: a rules dylib to load, a socket to be +/// asked to load it again on, and the directory the build stamp is written to. +/// `None` is a developer's server or an end-to-end gate -- same world, same +/// loop, no reloadable rules. +/// +/// # Why this does not call `App::run` +/// +/// It used to. `App::run` is flecs's `ecs_app_run`, which does not return until +/// the world quits, and a reload has to happen between two frames: swapping a +/// module mid-frame rebuilds a system table underneath an iterator. So the host +/// ticks the world itself. [`hyperion::tick_loop::prepare`] is what +/// `ecs_app_run` did to the world before its own loop, and +/// [`hyperion_hot_reload::service::run`] is the loop. /// /// # Errors -/// If the thread count does not fit in the `i32` flecs wants. -pub fn init_game(address: SocketAddr, crypto: Crypto) -> anyhow::Result<()> { +/// If the world has no target frame rate, if the reload socket cannot be bound, +/// or if the rules dylib is refused on the first load. A rules dylib that +/// cannot be loaded at startup is fatal, unlike one refused later: the running +/// world has nothing to protect yet, and a server that came up silently missing +/// every one of its rules is worse than one that did not come up. +pub fn init_game( + address: SocketAddr, + crypto: Crypto, + deployment: Option, + console: Option, +) -> anyhow::Result<()> { let world = World::new(); world.import::(); @@ -136,13 +286,31 @@ pub fn init_game(address: SocketAddr, crypto: Crypto) -> anyhow::Result<()> { world.set(crypto); world.set(GameServerEndpoint::from(address)); - let mut app = world.app(); + // After the game's own modules, so a command registered by the event is + // already in the registry the console's caller will be dispatched against, + // and after `GameServerEndpoint`, so the first line on the page is a server + // that has finished coming up. + if let Some(config) = console { + world.import::(); + hyperion_web_console::install(&world, &config)?; + } + + let mut rules = deployment.map(|it| Rules::open(&world, it)).transpose()?; - app.enable_rest(0) - .enable_stats(true) - .set_threads(i32::try_from(rayon::current_num_threads())?); + hyperion::tick_loop::prepare(&world)?; - app.run(); + match rules.as_mut() { + // A developer's server or an end-to-end gate: the same world and the + // same loop, with nothing to reload. The callback cannot be reached + // without a service, so it has nothing to do. + None => service::run(&world, None, |_, _| {}), + Some(rules) => { + let build_stamp = rules.build_stamp.clone(); + service::run(&world, Some(&mut rules.service), |world, outcome| { + announce_reload(world, outcome, &build_stamp); + }); + } + } Ok(()) } diff --git a/events/smash/src/main.rs b/events/smash/src/main.rs index 557623f9f..a955d6f92 100644 --- a/events/smash/src/main.rs +++ b/events/smash/src/main.rs @@ -1,9 +1,45 @@ -use smash::init_game; +//! smash's entry point. +//! +//! # There is no `#[global_allocator]` here, and that is not an oversight +//! +//! There was: `tikv_jemallocator::Jemalloc`. It cannot coexist with the dylib +//! split this server is packaged with, and the failure is a segfault before +//! `main` gets anywhere. +//! +//! `nix/hot-reload/packaging.nix` builds with `-C prefer-dynamic` so that the +//! server and the rules dylib resolve `hyperion`, and through it the one +//! `flecs_ecs` that owns the component index pool, to a shared image. That also +//! makes `std` a shared image, and rustc gives every Rust dylib an anonymous +//! version script ending `local: *` -- so each dylib's `__rust_alloc` is LOCAL +//! and cannot be interposed by the executable's. The process ends up with the +//! system allocator inside the dylibs and jemalloc inside the binary, and the +//! first pointer that crosses between them takes the process out: +//! +//! ```text +//! $ smash-server/bin/smash --help +//! Segmentation fault (core dumped) +//! +//! #0 _rjem_je_rtree_leaf_elm_lookup_hard () +//! #1 do_rallocx () +//! #2 ::finish_grow () +//! #4 ::args () +//! #5 ::augment_args () +//! ``` +//! +//! Every `smash-server` built before this comment existed crashed that way, and +//! nothing caught it: the end-to-end gates run `gameBinaries.smash`, which is a +//! `cargoUnit` build with no `-C prefer-dynamic`, so the packaged binary had +//! never been executed by anything. It was found by starting it on a dev node. +//! +//! Keeping jemalloc would mean loading it as an `LD_PRELOAD` malloc replacement +//! rather than as a Rust global allocator, so that one allocator serves the +//! whole process including the dylibs. That is a deployment change with its own +//! measurements to take; ENG-12112 carries it. -#[cfg(not(target_env = "msvc"))] -#[global_allocator] -static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; +use smash::init_game; fn main() -> anyhow::Result<()> { - hyperion_event_runner::run("SMASH_", init_game) + hyperion_event_runner::run("SMASH_", |args, crypto| { + init_game(args.address(), crypto, args.deployment()?, args.console()?) + }) } diff --git a/events/smash/src/module.rs b/events/smash/src/module.rs index 99ac3c934..13992f6ed 100644 --- a/events/smash/src/module.rs +++ b/events/smash/src/module.rs @@ -1,5 +1,6 @@ pub mod ability; pub mod arena; +pub mod blocks; pub mod build_stamp; pub mod damage; pub mod effect; diff --git a/events/smash/src/module/blocks.rs b/events/smash/src/module/blocks.rs new file mode 100644 index 000000000..3ea244c00 --- /dev/null +++ b/events/smash/src/module/blocks.rs @@ -0,0 +1,144 @@ +//! What the game is allowed to ask about the block world. +//! +//! The second seam, and the only read that crosses one. [`crate::server`] is +//! the write seam and carries no reads on purpose -- position, facing and +//! ground state arrive as mirrored components instead, because reading them is +//! a per-player-per-tick hot path and a mirror turns it into plain component +//! iteration. Terrain cannot be mirrored: it is millions of blocks, almost none +//! of which any tick looks at, and the one authoritative copy already exists on +//! the host. So this asks, rather than copies. +//! +//! Deliberately one method, and deliberately the *whole* question. A seam +//! spelled "is this block solid" would put the traversal on the game side and +//! cost a virtual call per cell; asking for the answer to the whole segment +//! costs one call per projectile per tick and leaves the traversal in +//! [`geometry::sweep`], where the block store and this crate's tests share it. +//! +//! The default is [`OpenAir`]. A world with [`crate::SmashModule`] and nothing +//! else -- which is every test under `tests/` and the whole of the mock -- has +//! no terrain, and saying so explicitly is what keeps the game half runnable +//! with no host anywhere near it. + +use std::{collections::HashSet, sync::Arc}; + +use flecs_ecs::prelude::*; +use geometry::aabb::Aabb; +/// Where a swept segment first met a block. +/// +/// Re-exported rather than redefined: the block store and this seam are +/// answering the same question, and a second struct with the same four fields +/// would be a conversion nobody reads and one field that eventually disagrees. +pub use geometry::sweep::BlockHit; +use glam::{IVec3, Vec3}; + +/// The block world, as the game sees it. +/// +/// `world` is handed in rather than captured because the host's block store is +/// a flecs singleton: an `Arc` outlives any borrow of flecs +/// storage, so the implementation looks the store up per call. Every caller has +/// a world in hand already, so this costs nothing at the call site. +pub trait BlockWorld: Send + Sync + 'static { + /// The first block surface on the segment `from` -> `to`, or `None` if it + /// is clear. + /// + /// A segment and not a point. A projectile is integrated a whole tick at a + /// time and Barrage's arrows travel sixty blocks a second, so an endpoint + /// test skips two of every three blocks on the path and a one-block wall is + /// something an arrow flies through. The same reasoning that made + /// `nearest_target` measure against a segment applies here, for the same + /// reason. + fn sweep(&self, world: WorldRef<'_>, from: Vec3, to: Vec3) -> Option; +} + +/// Nothing is solid. The default, and what every test that is not about terrain +/// gets. +#[derive(Debug, Default, Clone, Copy)] +pub struct OpenAir; + +impl BlockWorld for OpenAir { + fn sweep(&self, _: WorldRef<'_>, _: Vec3, _: Vec3) -> Option { + None + } +} + +/// A block world of full cubes at the listed coordinates. +/// +/// For tests, and for anything that wants terrain without a Minecraft server: +/// it answers through the same [`geometry::sweep::first_block_hit`] the host's +/// block store answers through, so a test built on it is evidence about the +/// shipped traversal rather than about a second copy of it. What it does not +/// carry is partial shapes -- everything here is a full cube. +#[derive(Debug, Default, Clone)] +pub struct Cubes(HashSet); + +impl Cubes { + #[must_use] + pub fn new(solid: impl IntoIterator) -> Self { + Self(solid.into_iter().collect()) + } + + /// An axis-aligned wall filling the inclusive box between two corners. + #[must_use] + pub fn wall(min: IVec3, max: IVec3) -> Self { + let mut solid = HashSet::new(); + for x in min.x..=max.x { + for y in min.y..=max.y { + for z in min.z..=max.z { + solid.insert(IVec3::new(x, y, z)); + } + } + } + Self(solid) + } +} + +impl BlockWorld for Cubes { + fn sweep(&self, _: WorldRef<'_>, from: Vec3, to: Vec3) -> Option { + geometry::sweep::first_block_hit(from, to, |block| { + self.0 + .contains(&block) + .then(|| Aabb::new(Vec3::ZERO, Vec3::ONE)) + }) + } +} + +/// Singleton holding the live [`BlockWorld`]. +/// +/// The same shape as [`crate::server::ServerHandle`], for the same reason: +/// systems name `&BlockWorldHandle` as an ordinary query term and flecs +/// resolves it once per table rather than once per entity. +#[derive(Component)] +pub struct BlockWorldHandle(pub Arc); + +impl BlockWorldHandle { + pub fn new(blocks: impl BlockWorld) -> Self { + Self(Arc::new(blocks)) + } +} + +impl core::ops::Deref for BlockWorldHandle { + type Target = dyn BlockWorld; + + fn deref(&self) -> &Self::Target { + &*self.0 + } +} + +/// Registration module for the block-world seam: types only, no systems. +/// +/// Installs [`OpenAir`] as the singleton's value in the same place the +/// singleton is registered, per the root `CLAUDE.md`: a bare `world.set` stores +/// a value without registering the type, which is an abort in a dev build and +/// silence in a release one. +#[derive(Component)] +pub struct BlockWorldComponentsModule; + +impl Module for BlockWorldComponentsModule { + fn module(world: &World) { + world.module::("smash::Blocks"); + world + .component::() + .add_trait::(); + world.set(BlockWorldHandle::new(OpenAir)); + } +} diff --git a/events/smash/src/module/build_stamp.rs b/events/smash/src/module/build_stamp.rs index 4d40950ef..26645fd4d 100644 --- a/events/smash/src/module/build_stamp.rs +++ b/events/smash/src/module/build_stamp.rs @@ -1,48 +1,74 @@ //! What build the server is running, on the player's screen. //! //! There is a live server that is redeployed as main moves, and until this -//! existed the only way to tell whether a change had reached it was to guess -//! at deploy timing from outside the game. The commit and the time it was made +//! existed the only way to tell whether a change had reached it was to guess at +//! deploy timing from outside the game. The commit and how long ago it was made //! are the two facts that answer it, so they go where a player is already //! looking. //! //! # Why a second bar and not the lobby's //! //! The other candidate was [`crate::module::hud::boss_bar`]'s `Phase::Waiting` -//! arm -- the "Waiting for players 1/2" strip -- and it was rejected for two -//! reasons that point the same way. -//! -//! It is **not always there.** The bar becomes a countdown, then a percentage, +//! arm -- the "Waiting for players 1/2" strip -- and it was rejected because +//! that bar is **not always there**. It becomes a countdown, then a percentage, //! the moment a match starts, so a stamp folded into it answers the question //! only for somebody who happens to arrive between matches. The question is //! asked at arbitrary times, including by a person who joined a running match //! to check, and half the time the lobby bar is a bar about something else. //! -//! It **changes.** That title is a function of the player count, so a stamp -//! carried inside it is re-sent every time anybody joins or leaves -- as a -//! whole `Add`, because the title and the fill move together and -//! `hyperion::egress::boss_bar` collapses two moved fields into one. A build -//! stamp is constant for the life of a process and should cost exactly one -//! packet per viewer. Its own bar is the only shape that does. +//! What its own bar gives up is a permanent strip of screen in a fighting game, +//! which is a real cost. It is paid down as far as it goes: the fill is left +//! empty so it draws no coloured length, and it is pushed after the match bar +//! so it sits under it rather than above. +//! +//! # The age is relative now, and here is what that costs +//! +//! This bar used to read `build 8f3a21c · 2026-07-29 18:04 UTC` and was sent +//! exactly once per player, on the argument that a build stamp is constant for +//! the life of a process and should cost exactly one packet per viewer. +//! +//! Two things ended that argument. The absolute form does not answer the +//! question the bar is on screen for -- "is my change live yet" is a question +//! about elapsed time, and a UTC minute has to be subtracted from a clock the +//! reader goes and finds, in a fighting game, mid-match. And the process is no +//! longer the unit anyway: a hot reload replaces the rules without restarting, +//! so "constant for the life of a process" stopped being true the moment +//! [`crate::init_game`] started re-reading this stamp on every accepted reload. //! -//! What that gives up is a permanent strip of screen in a fighting game, which -//! is a real cost. It is paid down as far as it goes: the bar is written once -//! per player and never again, its fill is left empty so it draws no coloured -//! length, and it is pushed after the match bar so it sits under it rather -//! than above. +//! So the wording moved to `build 8f3a21c · 2h ago`, and a relative age changes +//! on its own. The cost is bounded by coarsening the wording as the build ages, +//! which is [`relative_age`]'s whole job: a string reading to the minute changes +//! once a minute, `2h ago` changes once an hour, `3d ago` changes once a day. +//! One build therefore costs at most 60 packets per viewer in its first hour, +//! 23 more across the rest of its first day, and one a day after that. The +//! steady state a deployed server sits in is **one packet per viewer per hour**. +//! +//! `show_build_stamp` is what turns that bound into behaviour rather than +//! arithmetic: it sends only when the rendered string differs from the one that +//! player was last sent, so the packet count is a function of the wording and +//! not of the tick rate. //! //! # Where the values come from //! -//! Three environment variables, set by a wrapper the Nix build puts around the -//! server binary (see `flake.nix`). Not `env!` in a build script: baking a -//! commit hash into a crate makes every commit a full workspace recompile, and -//! the compile is already the floor on the pipeline. A wrapper is a symlink -//! and three assignments, so a new commit rebuilds that and nothing else. +//! Three files in a directory the deployment names on the command line +//! (`--build-stamp`), written by `nix/modules/game-server.nix`. +//! +//! **Files and not environment variables**, which is what they were until hot +//! reload existed. A process's environment is fixed at `exec`, so a server that +//! reloaded its rules without restarting would go on reporting the build it +//! started as, forever -- and not restarting is the entire point. +//! +//! Not `env!` in a build script either: baking a commit hash into a crate makes +//! every commit a full workspace recompile, and the compile is already the floor +//! on the pipeline. //! -//! A `cargo run` build has none of them set and says so, rather than claiming -//! a commit it was not built from. +//! A `cargo run` build is handed no directory at all and says so, rather than +//! claiming a commit it was not built from. -use std::env; +use std::{ + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; use flecs_ecs::prelude::*; @@ -51,22 +77,27 @@ use crate::{ server::{BarColour, BarSlot, BossBar, NamedColor, PlayerId, ServerHandle, Text}, }; -/// The short commit hash the server was built from, with no dirty marker on -/// it. That marker is [`DIRTY_VAR`]'s job. -pub const REV_VAR: &str = "HYPERION_BUILD_REV"; +/// The short commit hash the server was built from, with no dirty marker on it. +/// That marker is [`DIRTY_FILE`]'s job. +/// +/// Public because `hyperion_hot_reload::ReloadService` reads this same file to +/// name the build in its `accepted ` reply. One filename with +/// two spellings is one deploy away from a reload reporting a revision nobody +/// wrote. +pub const REV_FILE: &str = "build-rev"; /// When that commit was made, in whole seconds since the unix epoch. -pub const TIME_VAR: &str = "HYPERION_BUILD_TIME"; +pub const TIME_FILE: &str = "build-time"; /// `1` when the working tree had uncommitted changes at build time. -pub const DIRTY_VAR: &str = "HYPERION_BUILD_DIRTY"; +pub const DIRTY_FILE: &str = "build-dirty"; -/// What build this process is. +/// What build this process is currently running. /// -/// Every field is optional because the honest answer for a `cargo run` build -/// is that nobody said. A missing field reads as "unknown" on screen rather -/// than as a default that would be a lie -- a stamp that says `0000000` is -/// worse than one that says it does not know. +/// Every field is optional because the honest answer for a `cargo run` build is +/// that nobody said. A missing field reads as "unknown" on screen rather than +/// as a default that would be a lie -- a stamp that says `0000000` is worse +/// than one that says it does not know. #[derive(Component, Debug, Clone, PartialEq, Eq, Default)] pub struct BuildStamp { /// The short commit hash, `None` when nothing said. @@ -79,26 +110,30 @@ pub struct BuildStamp { } impl BuildStamp { - /// What the environment says this build is. + /// What the files in `dir` say this build is. + /// + /// A directory that is not there, or a file that cannot be read, is the + /// unpackaged case rather than an error. This is a label; a server that + /// refused to start over one would be trading a running game for a string. #[must_use] - pub fn from_env() -> Self { + pub fn read(dir: &Path) -> Self { + let field = |name: &str| std::fs::read_to_string(dir.join(name)).ok(); Self::parse( - env::var(REV_VAR).ok().as_deref(), - env::var(TIME_VAR).ok().as_deref(), - env::var(DIRTY_VAR).ok().as_deref(), + field(REV_FILE).as_deref(), + field(TIME_FILE).as_deref(), + field(DIRTY_FILE).as_deref(), ) } /// The same, from three strings. /// - /// Split out from [`Self::from_env`] because `std::env::set_var` is unsafe - /// in edition 2024 and racy in a test binary that runs its tests on - /// threads. Everything worth pinning is in here, and it is reachable - /// without touching the process environment at all. + /// Split out from [`Self::read`] so that every wording case is reachable + /// without a filesystem. /// - /// A field that is present but unusable -- an empty rev, a time that is - /// not a number -- is treated as absent. The bar is a readout and half of - /// one is better than none. + /// A field that is present but unusable -- an empty rev, a time that is not + /// a number -- is treated as absent. The bar is a readout and half of one is + /// better than none. Everything is trimmed, because `environment.etc` ends + /// each of these files with a newline. #[must_use] pub fn parse(rev: Option<&str>, committed_at: Option<&str>, dirty: Option<&str>) -> Self { Self { @@ -112,18 +147,48 @@ impl BuildStamp { } } -/// The bar a build stamp draws. +/// How long ago `committed_at` was, seen from `now`, in as few characters as it +/// can be said in. /// -/// **Empty, and that is deliberate.** A boss bar's fill is a fraction of -/// something, and a build is not a fraction of anything; leaving it at zero -/// means the strip draws its frame and its text and no coloured length, which -/// is the least ink this can cost on a screen somebody is fighting on. +/// # The granularity is the packet budget /// -/// **Red when the tree was dirty.** A dirty build is not the commit it names, -/// and the whole value of a stamp is that it can be trusted, so the one case -/// where it cannot be is the one case that is impossible to skim past. +/// Each unit is chosen so the string is stable for one whole unit of it, which +/// is what bounds how often the bar is redrawn. Minutes for the first hour, +/// because that is the window somebody watching their own deploy land is +/// actually in; hours for the first day; days after that. Nothing finer than a +/// minute, because a bar that changed every second would cost twenty times more +/// than the whole rest of this module and tell a reader nothing they did not +/// already know. +/// +/// A build that has not yet aged a minute reads `just now`, and so does one +/// whose timestamp is in the future. The future case is not hypothetical -- a +/// host whose clock is behind the machine that made the commit produces it -- +/// and `in -3m` on a boss bar is a worse answer than a slightly early `just +/// now`. +#[must_use] +pub fn relative_age(committed_at: i64, now: i64) -> String { + const MINUTE: i64 = 60; + const HOUR: i64 = 60 * MINUTE; + const DAY: i64 = 24 * HOUR; + + let age = now.saturating_sub(committed_at).max(0); + if age < MINUTE { + "just now".to_owned() + } else if age < HOUR { + format!("{}m ago", age / MINUTE) + } else if age < DAY { + format!("{}h ago", age / HOUR) + } else { + format!("{}d ago", age / DAY) + } +} + +/// The line across the top of the screen, as plain text. +/// +/// Split from [`stamp_bar`] because the colours are a function of one boolean +/// and the wording is the part worth pinning in a test. #[must_use] -pub fn stamp_bar(stamp: &BuildStamp) -> BossBar { +pub fn stamp_title(stamp: &BuildStamp, now: i64) -> String { let rev = match (stamp.rev.as_deref(), stamp.dirty) { (Some(rev), true) => format!("{rev} + uncommitted changes"), (Some(rev), false) => rev.to_owned(), @@ -132,12 +197,26 @@ pub fn stamp_bar(stamp: &BuildStamp) -> BossBar { // nothing is broken. (None, _) => "unpackaged build".to_owned(), }; - let title = stamp.committed_at.map_or_else( + stamp.committed_at.map_or_else( || format!("build {rev}"), - |at| format!("build {rev} \u{b7} {}", utc_minute(at)), - ); + |at| format!("build {rev} \u{b7} {}", relative_age(at, now)), + ) +} + +/// The bar a build stamp draws. +/// +/// **Empty, and that is deliberate.** A boss bar's fill is a fraction of +/// something, and a build is not a fraction of anything; leaving it at zero +/// means the strip draws its frame and its text and no coloured length, which +/// is the least ink this can cost on a screen somebody is fighting on. +/// +/// **Red when the tree was dirty.** A dirty build is not the commit it names, +/// and the whole value of a stamp is that it can be trusted, so the one case +/// where it cannot be is the one case that is impossible to skim past. +#[must_use] +pub fn stamp_bar(stamp: &BuildStamp, now: i64) -> BossBar { BossBar { - title: Text::text(title).color(if stamp.dirty { + title: Text::text(stamp_title(stamp, now)).color(if stamp.dirty { NamedColor::Red } else { NamedColor::Gray @@ -151,65 +230,40 @@ pub fn stamp_bar(stamp: &BuildStamp) -> BossBar { } } -/// `seconds` since the unix epoch as `YYYY-MM-DD HH:MM UTC`. -/// -/// **Absolute and not "two hours ago".** A relative age is the friendlier -/// thing to read and it is the one thing this bar cannot say: it would change -/// every minute, and a bar that changes is a packet per viewer per change -/// forever, which is exactly the cost this whole design is built to avoid. -/// -/// UTC and to the minute, because the reader is comparing it against a deploy -/// they watched and a commit timestamp they can read out of git, and both of -/// those are in UTC. +/// Wall-clock seconds since the unix epoch. /// -/// The date arithmetic is Howard Hinnant's `civil_from_days`, from -/// , which is exact for -/// every year this will ever be handed and needs no table. There are no leap -/// seconds in unix time, so a day is 86,400 seconds and the split is a -/// division. -#[must_use] -pub fn utc_minute(seconds: i64) -> String { - const SECONDS_PER_DAY: i64 = 86_400; - - let days = seconds.div_euclid(SECONDS_PER_DAY); - let within = seconds.rem_euclid(SECONDS_PER_DAY); - let (hour, minute) = (within / 3600, (within % 3600) / 60); - - // Shift the epoch to 0000-03-01, which puts the leap day at the end of the - // 400 year era and makes every month length a straight line. - let shifted = days + 719_468; - let era = shifted.div_euclid(146_097); - let day_of_era = shifted.rem_euclid(146_097); - let year_of_era = - (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let march_month = (5 * day_of_year + 2) / 153; - let day = day_of_year - (153 * march_month + 2) / 5 + 1; - let month = if march_month < 10 { - march_month + 3 - } else { - march_month - 9 - }; - let year = year_of_era + era * 400 + i64::from(month <= 2); - - format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02} UTC") +/// Wall clock and not the world's own tick counter, because the number this is +/// compared against is a git commit's timestamp, which lives on the same clock. +/// A host whose clock is wrong renders an age that is wrong by the same amount, +/// which is the honest failure for a readout of somebody else's timestamp. +fn now_epoch() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| { + i64::try_from(since.as_secs()).unwrap_or(i64::MAX) + }) } -/// Marks a player who has already been sent the stamp, so it is sent once. +/// The last thing this player was told about the build. /// -/// A tag and not a flag inside a record, because it is the query itself that -/// has to stop matching: once every connected player carries this, the system -/// below iterates nothing at all rather than iterating everybody to decide -/// there is nothing to do. +/// It holds the rendered string rather than a tag, and that is what makes the +/// system idempotent instead of edge-triggered: "have they been told" is a +/// question with a stale answer the moment the wording changes, and the wording +/// now changes on its own. Comparing against what was actually sent means a +/// player who joined ten seconds ago and a player who has been standing there +/// since the deploy are handled by one branch. #[derive(Component, Debug)] -pub struct StampShown; +pub struct StampShown { + /// Exactly the text of the bar this player last received. + pub text: String, +} -/// Registration: the types this file owns, and the stamp itself. +/// Registration: the types this file owns. /// -/// The environment is read here, once, where [`BuildStamp`] is registered. A -/// test that wants a particular build overwrites the singleton after importing -/// the game, which is why nothing else in this file ever looks at the -/// environment again. +/// The stamp singleton is registered here and left at its default. Filling it in +/// is [`crate::init_game`]'s job, because only the host knows whether it was +/// handed a `--build-stamp` directory -- and it re-fills it on every accepted +/// reload, which is a thing a registration module could not do. #[derive(Component)] pub struct BuildStampComponentsModule; @@ -221,11 +275,12 @@ impl Module for BuildStampComponentsModule { world .component::() .add_trait::(); - world.set(BuildStamp::from_env()); + world.set(BuildStamp::default()); } } -/// Behaviour: put the stamp on each player's screen, exactly once. +/// Behaviour: keep each player's build bar equal to what the stamp currently +/// says. #[derive(Component)] pub struct BuildStampModule; @@ -238,7 +293,9 @@ impl Module for BuildStampModule { // reason `smash::draw_projectiles` is one: a player is assembled over // several `set` calls and an observer on the first of them sees an // entity that has no `PlayerId` yet. Matching on what is needed and - // skipping what is done sees a whole player or none of one. + // skipping what is done sees a whole player or none of one. It is also + // the only shape that can notice the wording changing under it, which + // an observer on the player could not. // // Declared after `HudModule`'s `update_hud` -- `SmashModule` imports // this module last -- so on the tick a player joins, the match bar's @@ -248,14 +305,23 @@ impl Module for BuildStampModule { world .system_named::<&PlayerId>("show_build_stamp") .with(Player::id()) - .without(StampShown::id()) .each_iter(|it, row, id| { let world = it.world(); - let bar = world.get::<&BuildStamp>(stamp_bar); + let bar = world.get::<&BuildStamp>(|stamp| stamp_bar(stamp, now_epoch())); + let text = bar.title.plain(); + + let entity = it.entity(row); + let unchanged = entity + .try_get::<&StampShown>(|shown| shown.text == text) + .unwrap_or(false); + if unchanged { + return; + } + world.get::<&ServerHandle>(|server| { server.set_boss_bar(*id, BarSlot::Build, bar.clone()); }); - it.entity(row).add(StampShown::id()); + entity.set(StampShown { text }); }); } } diff --git a/events/smash/src/module/projectile.rs b/events/smash/src/module/projectile.rs index cbfa76f3a..dffc124fe 100644 --- a/events/smash/src/module/projectile.rs +++ b/events/smash/src/module/projectile.rs @@ -5,10 +5,13 @@ //! function pointer rather than four ability implementations each with their own //! flight loop. //! -//! There is no block collision here: that needs the host's world, which is on -//! the far side of the seam. Projectiles expire on a timer and on entity -//! contact. `docs/smash-design.md` lists this as one of the two places the -//! simulation is deliberately incomplete pending the hyperion wiring. +//! Block collision is a swept segment against [`crate::module::blocks`], the +//! read seam onto the host's terrain, and it is checked before the entity +//! search so a player standing behind a wall cannot be shot through it. +//! A projectile that meets a block stops on its surface, sticks there for +//! [`STUCK_SECONDS`], and expires. A world with no terrain seam installed -- +//! every test that is not about terrain, and the whole of the mock -- answers +//! "clear" and the flight is exactly what it was before. //! //! What the flight above is authoritative for is the *hit*. What a client sees //! is drawn separately, by `crate::draw` on the host, off the [`Visual`] this @@ -23,6 +26,7 @@ use hyperion::simulation::{entity_kind::EntityKind, projectile_motion::EYE_HEIGH use crate::{ flecs_ext::WorldRefExt, module::{ + blocks::{BlockWorldComponentsModule, BlockWorldHandle}, damage::{DamageKind, Damaged, hurt}, knockback::Knockback, player::{Health, Player, Position}, @@ -35,6 +39,27 @@ use crate::{ #[derive(Component, Debug)] pub struct Projectile; +/// A projectile that has hit a block and is embedded in it. +/// +/// A tag rather than a zeroed velocity, because "stopped" and "stopped by +/// something" need to be different states. `fly` excludes stuck projectiles +/// outright: a projectile whose sweep starts on the face it just hit hits that +/// face again every tick, which would replay the impact sound forever, and one +/// sitting still is a point a player can walk into and be shot by a projectile +/// that is no longer going anywhere. Neither is a check to add inside `fly` -- +/// they are the same statement, that a stuck projectile is done moving and done +/// hitting, and a query term says it once. +#[derive(Component, Debug)] +pub struct Stuck; + +/// How long a projectile stays visible in the block it hit. +/// +/// Vanilla leaves an arrow in a wall for a minute. This is a fighting game +/// played in a small arena: long enough to read the impact as an impact, short +/// enough that a Barrage volley does not leave the far wall bristling for the +/// rest of the match. +pub const STUCK_SECONDS: f32 = 1.0; + /// What a projectile is drawn as. /// /// A generated [`EntityKind`] and not a stand-in enum a table maps, the same @@ -131,15 +156,29 @@ pub fn fire( .add((FiredBy, shooter)); } +/// Registration module for projectiles: the component set and nothing else. +/// +/// Split from [`ProjectileModule`] per the root `CLAUDE.md`. A consumer that +/// wants the *types* -- `crate::draw` on the host decorates a projectile it +/// never integrates -- imports this without dragging in the flight systems. +/// +/// Prefixed, and it has to be. flecs registers a module entity under its bare +/// Rust type name before the body that renames it runs, so two crates in one +/// world cannot each have a `ProjectileComponentsModule` -- and +/// `hyperion::simulation::projectile_motion` already does. The collision is an +/// `ecs_assert`, which means a dev build aborts on boot and a release build, +/// where flecs asserts are compiled out, silently treats the two modules as one +/// (ENG-12054). This name is what keeps that from happening; do not shorten it. #[derive(Component)] -pub struct ProjectileModule; +pub struct SmashProjectileComponentsModule; -impl Module for ProjectileModule { +impl Module for SmashProjectileComponentsModule { fn module(world: &World) { world.module::("smash::Projectile"); // Final: a projectile is a leaf, never an inheritance base. world.component::().add_trait::(); + world.component::().add_trait::(); world.component::(); world.component::(); world.component::(); @@ -156,15 +195,40 @@ impl Module for ProjectileModule { .add(flecs::Exclusive) .add_trait::() .add_trait::<(flecs::OnDeleteTarget, flecs::Delete)>(); + } +} + +/// Behavior module for projectiles: integration, block collision and the hit. +#[derive(Component)] +pub struct ProjectileModule; + +impl Module for ProjectileModule { + fn module(world: &World) { + // Imports before the scope claim, and that order is load bearing. + // `world.module` creates `smash::Projectile` the first time and + // thereafter only sets the scope to it, so whichever of the two modules + // runs first owns the path. Letting the registration module own it is + // what keeps `Flight` and friends at `smash.Projectile.Flight` whether + // they were reached through this module or imported on their own. + world.import::(); + // `fly` reads the terrain seam's singleton, so the module that + // registers it is imported here rather than assumed. A full boot + // happens to register it first; a standalone import of this module + // would not, and the difference between the two is invisible in a + // release build. + world.import::(); + world.module::("smash::Projectile"); world .system_named::<(&mut Flight, &Payload)>("fly") + .without(Stuck::id()) .each_iter(|it, index, (flight, payload)| { let dt = it.delta_time(); let projectile = it.entity(index); + let world = projectile.world(); let from = flight.position; flight.velocity.y = flight.gravity.mul_add(-dt, flight.velocity.y); - flight.position += flight.velocity * dt; + let mut to = flight.velocity.mul_add(Vec3::splat(dt), from); flight.seconds_left -= dt; if flight.seconds_left <= 0.0 { @@ -172,18 +236,44 @@ impl Module for ProjectileModule { return; } + // The terrain sweep before the entity search, and the search + // then runs over the *clipped* segment. That ordering is the + // whole of "you cannot be shot through a wall": a player + // standing behind one is not on the segment the arrow got to + // travel, so `nearest_target` never sees them. + let impact = world.get::<&BlockWorldHandle>(|blocks| blocks.sweep(world, from, to)); + if let Some(impact) = impact { + to = impact.point; + } + flight.position = to; + let shooter = projectile.target(FiredBy, 0).map(|e| e.id()); - let Some((victim, at)) = nearest_target( - projectile.world(), - from, - flight.position, - flight.radius, - shooter, - ) else { + let Some((victim, at)) = nearest_target(world, from, to, flight.radius, shooter) + else { + if let Some(impact) = impact { + // Stopped on the surface it met, and left there to be + // seen. `Flight` is what `crate::draw` reads in + // `PostUpdate`, so writing the impact point above is + // what puts the picture in the wall rather than a + // tick's travel past it. + flight.velocity = Vec3::ZERO; + flight.gravity = 0.0; + flight.seconds_left = flight.seconds_left.min(STUCK_SECONDS); + projectile.add(Stuck::id()); + world.get::<&ServerHandle>(|server| { + // `Neutral`, not `Players`: an arrow striking + // terrain is a thing that happened in the world, + // and vanilla's own `AbstractArrow` puts it on that + // slider. The hit on a player stays on `Players`. + server.play_sound( + impact.point, + Sound::new(sound::PROJECTILE_HIT, SoundCategory::Neutral), + ); + }); + } return; }; - let world = projectile.world(); let victim = world.entity_at(victim); hurt(victim, Damaged { attacker: shooter, @@ -220,6 +310,20 @@ impl Module for ProjectileModule { }); projectile.destruct(); }); + + // Stuck projectiles are excluded from `fly` entirely, so something has + // to run their clock. A second system and not a branch inside `fly`, + // because the two states share nothing: this one neither moves nor + // hits, it only counts down. + world + .system_named::<&mut Flight>("expire_stuck") + .with(Stuck::id()) + .each_iter(|it, index, flight| { + flight.seconds_left -= it.delta_time(); + if flight.seconds_left <= 0.0 { + it.entity(index).destruct(); + } + }); } } diff --git a/events/smash/src/terrain_seam.rs b/events/smash/src/terrain_seam.rs new file mode 100644 index 000000000..a6429ed5e --- /dev/null +++ b/events/smash/src/terrain_seam.rs @@ -0,0 +1,60 @@ +//! The host half of the block-world seam: hyperion's `Blocks` answering the +//! game's terrain reads. +//! +//! The mirror in `crate::mirror` copies host state onto game components once a +//! tick, which is the right shape for a player's position and the wrong one for +//! a world of blocks: there are millions of them, a tick looks at a handful, and +//! copying them would be maintaining a second authority that drifts the moment +//! anything places a block. So terrain is asked for rather than copied, and this +//! is the answering half. +//! +//! It is three lines of substance because the traversal is not here. Both sides +//! of the seam go through [`geometry::sweep::first_block_hit`] -- the block +//! store's own [`Blocks::first_collision`] is a wrapper over it, and so is the +//! [`crate::module::blocks::Cubes`] a test builds a wall from. One traversal, +//! two sources of shapes, which is what makes a test about a `Cubes` wall +//! evidence about a real one. + +use flecs_ecs::prelude::*; +use glam::Vec3; +use hyperion::simulation::blocks::Blocks; + +use crate::module::blocks::{BlockHit, BlockWorld, BlockWorldComponentsModule, BlockWorldHandle}; + +/// hyperion's loaded chunks, as a [`BlockWorld`]. +/// +/// Carries no state: the block store is a singleton on the world handed to +/// [`BlockWorld::sweep`], because an `Arc` outlives any borrow +/// of flecs storage and so cannot hold one. +#[derive(Debug, Default, Clone, Copy)] +pub struct HyperionBlocks; + +impl BlockWorld for HyperionBlocks { + fn sweep(&self, world: WorldRef<'_>, from: Vec3, to: Vec3) -> Option { + world.get::<&Blocks>(|blocks| { + let hit = blocks.first_collision(geometry::ray::Ray::from_points(from, to))?; + Some(BlockHit { + time: hit.distance, + block: hit.location, + point: hit.point, + normal: hit.normal, + inside: hit.inside, + }) + }) + } +} + +/// Replaces the game half's `OpenAir` default with the real block store. +#[derive(Component)] +pub struct TerrainSeamModule; + +impl Module for TerrainSeamModule { + fn module(world: &World) { + // The singleton this overwrites is registered by the game half, so the + // module that registers it is imported rather than assumed: a `set` of + // an unregistered component is an abort in a dev build and silence in a + // release one. + world.import::(); + world.set(BlockWorldHandle::new(HyperionBlocks)); + } +} diff --git a/events/smash/tests/build_stamp.rs b/events/smash/tests/build_stamp.rs index c62a8e373..c6ce10f19 100644 --- a/events/smash/tests/build_stamp.rs +++ b/events/smash/tests/build_stamp.rs @@ -1,15 +1,19 @@ -//! The build stamp: what it says, and that it is said exactly once. +//! The build stamp: what it says, how often it says it, and that a reload +//! changes it. //! -//! Two halves, and the second one is the whole reason this file exists. The -//! wording is a pure function of a small struct and is pinned character for -//! character. The delivery is a whole world, run for hundreds of ticks through -//! every phase change a lobby has, asserting that the number of times a player -//! is told what build this is stays at one. +//! Three halves, and the middle one is the reason this file is as long as it +//! is. The wording is a pure function of a small struct and a clock, and is +//! pinned character for character. The *rate* is pinned too, because the bar +//! now says `2h ago` rather than a UTC minute and a relative age changes on its +//! own: `hyperion::egress::boss_bar` turns one `set_boss_bar` whose contents +//! moved into one packet per viewer, so an age that read to the second would be +//! a packet per player per second forever. And the delivery is a whole world, +//! run for hundreds of ticks through every phase change a lobby has. //! -//! That second claim is not a micro-optimisation. `hyperion::egress::boss_bar` -//! turns one `set_boss_bar` whose contents moved into one packet per viewer, -//! and a stamp that were pushed per tick would be twenty packets a second per -//! player carrying a string that cannot change until the process exits. +//! Where a test is about delivery rather than wording it uses a stamp with no +//! commit time, whose rendering is therefore constant. The alternative is +//! asserting a string that depends on how long ago 2026-07-29 was when the test +//! ran, which passes today and fails tomorrow. mod harness; @@ -17,7 +21,7 @@ use glam::Vec3; use harness::Game; use smash::{ module::{ - build_stamp::{BuildStamp, stamp_bar, utc_minute}, + build_stamp::{BuildStamp, relative_age, stamp_bar}, lobby::{Lobby, LobbyConfig, Phase}, }, server::{BarColour, BarSlot, NamedColor, PlayerId, TextColor, mock::Call}, @@ -27,23 +31,38 @@ use smash::{ fn clean() -> BuildStamp { BuildStamp { rev: Some("8f3a21c".to_owned()), - committed_at: Some(1_785_348_240), + committed_at: Some(COMMITTED_AT), dirty: false, } } +/// The same build with nothing said about when it was made, so its bar reads +/// the same string whenever the test happens to run. +fn timeless() -> BuildStamp { + BuildStamp { + committed_at: None, + ..clean() + } +} + +const COMMITTED_AT: i64 = 1_785_348_240; + +/// Two hours and five minutes after [`COMMITTED_AT`]. +const TWO_HOURS_LATER: i64 = COMMITTED_AT + 2 * 3600 + 5 * 60; + +const MINUTE: i64 = 60; +const HOUR: i64 = 60 * MINUTE; +const DAY: i64 = 24 * HOUR; + // --------------------------------------------------------------------------- // what it says // --------------------------------------------------------------------------- -/// The commit and the minute it was made, in that order, on one line. +/// The commit and how long ago it was made, in that order, on one line. #[test] -fn the_stamp_names_the_commit_and_when_it_was_made() { - let bar = stamp_bar(&clean()); - assert_eq!( - bar.title.plain(), - "build 8f3a21c \u{b7} 2026-07-29 18:04 UTC" - ); +fn the_stamp_names_the_commit_and_how_old_it_is() { + let bar = stamp_bar(&clean(), TWO_HOURS_LATER); + assert_eq!(bar.title.plain(), "build 8f3a21c \u{b7} 2h ago"); assert_eq!( bar.title.runs()[0].color(), Some(TextColor::Named(NamedColor::Gray)) @@ -61,13 +80,16 @@ fn the_stamp_names_the_commit_and_when_it_was_made() { /// whole strip rather than adding a word to the end of a line. #[test] fn a_dirty_build_says_so_and_turns_the_bar_red() { - let bar = stamp_bar(&BuildStamp { - dirty: true, - ..clean() - }); + let bar = stamp_bar( + &BuildStamp { + dirty: true, + ..clean() + }, + TWO_HOURS_LATER, + ); assert_eq!( bar.title.plain(), - "build 8f3a21c + uncommitted changes \u{b7} 2026-07-29 18:04 UTC" + "build 8f3a21c + uncommitted changes \u{b7} 2h ago" ); assert_eq!( bar.title.runs()[0].color(), @@ -79,12 +101,13 @@ fn a_dirty_build_says_so_and_turns_the_bar_red() { /// A build nobody stamped says that, rather than a commit it does not have. /// -/// This is the `cargo run` case, and the wording is aimed at the person who -/// will meet it: a developer on their own machine, who needs to know that the -/// blank is expected and not a broken deploy. +/// This is the `cargo run` case -- no `--build-stamp` directory on the command +/// line -- and the wording is aimed at the person who will meet it: a developer +/// on their own machine, who needs to know that the blank is expected and not a +/// broken deploy. #[test] fn an_unstamped_build_says_it_is_unpackaged() { - let bar = stamp_bar(&BuildStamp::default()); + let bar = stamp_bar(&BuildStamp::default(), TWO_HOURS_LATER); assert_eq!(bar.title.plain(), "build unpackaged build"); assert_eq!(bar.colour, BarColour::Blue); } @@ -93,14 +116,13 @@ fn an_unstamped_build_says_it_is_unpackaged() { /// after it. #[test] fn half_a_stamp_is_shown_rather_than_none_of_one() { - let bar = stamp_bar(&BuildStamp { - committed_at: None, - ..clean() - }); - assert_eq!(bar.title.plain(), "build 8f3a21c"); + assert_eq!( + stamp_bar(&timeless(), TWO_HOURS_LATER).title.plain(), + "build 8f3a21c" + ); } -/// The environment is parsed the way the wrapper writes it, and a field that +/// The three files are parsed the way the deploy writes them, and a field that /// cannot be used is dropped rather than shown as itself. #[test] fn a_field_that_is_not_usable_reads_as_absent() { @@ -108,52 +130,104 @@ fn a_field_that_is_not_usable_reads_as_absent() { BuildStamp::parse(Some("8f3a21c"), Some("1785348240"), Some("1")), BuildStamp { rev: Some("8f3a21c".to_owned()), - committed_at: Some(1_785_348_240), + committed_at: Some(COMMITTED_AT), dirty: true, } ); - // An empty rev is what an unset variable looks like when something exports - // it anyway, and a time that is not a number is what a broken wrapper + // `environment.etc` ends every file it writes with a newline, so the + // trimming is not defensive -- it is the format. + assert_eq!( + BuildStamp::parse(Some("8f3a21c\n"), Some("1785348240\n"), Some("0\n")), + clean() + ); + // An empty rev is what an unset value looks like when something writes the + // file anyway, and a time that is not a number is what a broken deploy // produces. Neither is worth putting on a screen. assert_eq!( BuildStamp::parse(Some(" "), Some("not-a-time"), Some("0")), BuildStamp::default() ); assert_eq!(BuildStamp::parse(None, None, None), BuildStamp::default()); - // What the wrapper writes for a source with no git in it: both variables - // set, both empty. The flake refuses to emit a time without a rev, because - // `lastModified` on such a source is a directory mtime, and an empty - // string has to read the same way here as an unset variable or the refusal - // would only have moved the problem. + // What the flake writes for a source with no git in it: both files present, + // both empty. The flake refuses to emit a time without a rev, because + // `lastModified` on such a source is a directory mtime, and an empty file + // has to read the same way here as an absent one or the refusal would only + // have moved the problem. assert_eq!( BuildStamp::parse(Some(""), Some(""), Some("0")), BuildStamp::default() ); - // Anything but exactly `1` is clean, so a wrapper that writes `false` or + // Anything but exactly `1` is clean, so a deploy that writes `false` or // `no` does not accidentally mark every build dirty. assert!(!BuildStamp::parse(None, None, Some("true")).dirty); } -/// The date arithmetic, at the instants that break a wrong implementation. +// --------------------------------------------------------------------------- +// how often it says it +// --------------------------------------------------------------------------- + +/// Each unit, and the second on either side of every boundary between them. +#[test] +fn the_age_reads_in_the_coarsest_unit_that_still_says_something() { + assert_eq!(relative_age(COMMITTED_AT, COMMITTED_AT), "just now"); + assert_eq!(relative_age(COMMITTED_AT, COMMITTED_AT + 59), "just now"); + assert_eq!(relative_age(COMMITTED_AT, COMMITTED_AT + MINUTE), "1m ago"); + assert_eq!( + relative_age(COMMITTED_AT, COMMITTED_AT + HOUR - 1), + "59m ago" + ); + assert_eq!(relative_age(COMMITTED_AT, COMMITTED_AT + HOUR), "1h ago"); + assert_eq!( + relative_age(COMMITTED_AT, COMMITTED_AT + DAY - 1), + "23h ago" + ); + assert_eq!(relative_age(COMMITTED_AT, COMMITTED_AT + DAY), "1d ago"); + assert_eq!( + relative_age(COMMITTED_AT, COMMITTED_AT + 90 * DAY), + "90d ago" + ); +} + +/// A host whose clock is behind the machine that made the commit reads the +/// build as being from the future. `in -3m` on a boss bar is a worse answer +/// than a slightly early "just now". +#[test] +fn a_build_from_the_future_reads_as_just_now() { + assert_eq!(relative_age(COMMITTED_AT, COMMITTED_AT - DAY), "just now"); + assert_eq!(relative_age(COMMITTED_AT, i64::MIN), "just now"); +} + +/// The packet budget, as arithmetic anybody can check. /// -/// A leap day, a century that is not a leap year, and a time before the epoch: -/// each of the three is a different way to get the arithmetic wrong, and none -/// of them is reachable by a test that only uses today's date. +/// This is the claim `module::build_stamp`'s own documentation makes -- at most +/// 60 packets per viewer in the first hour, 23 more across the rest of the +/// first day, one a day after -- restated as the number of distinct strings the +/// wording takes, because `show_build_stamp` sends exactly when that string +/// changes. Sampled every second, which is finer than the finest unit, so a +/// wording that read to the second would fail this by a factor of sixty. #[test] -fn the_clock_is_utc_and_survives_the_awkward_dates() { - assert_eq!(utc_minute(0), "1970-01-01 00:00 UTC"); - assert_eq!(utc_minute(1_000_000_000), "2001-09-09 01:46 UTC"); - assert_eq!(utc_minute(1_785_348_240), "2026-07-29 18:04 UTC"); - // 2000 is divisible by 400, so it has a 29th of February. - assert_eq!(utc_minute(951_782_400), "2000-02-29 00:00 UTC"); - // 2100 is divisible by 100 and not by 400, so it does not. - assert_eq!(utc_minute(4_107_542_400), "2100-03-01 00:00 UTC"); - // Before the epoch, which is where a truncating division goes wrong. - assert_eq!(utc_minute(-1), "1969-12-31 23:59 UTC"); +fn the_wording_changes_at_most_sixty_times_in_the_first_hour() { + let distinct = |from: i64, to: i64| { + let mut seen: Vec = Vec::new(); + for second in from..to { + let rendered = relative_age(COMMITTED_AT, COMMITTED_AT + second); + if seen.last() != Some(&rendered) { + seen.push(rendered); + } + } + seen.len() + }; + + // "just now", then one string per minute for the other fifty-nine. + assert_eq!(distinct(0, HOUR), 60); + // Twenty-three more for the rest of the day. + assert_eq!(distinct(HOUR, DAY), 23); + // And one for the whole of the second day. + assert_eq!(distinct(DAY, 2 * DAY), 1); } // --------------------------------------------------------------------------- -// that it reaches a player, once +// that it reaches a player // --------------------------------------------------------------------------- /// Every stamp a player was sent, in order. @@ -169,25 +243,47 @@ fn stamps_to(game: &Game, player: PlayerId) -> Vec { #[test] fn a_player_is_told_what_build_they_are_standing_in() { let mut game = Game::new(); - game.world.set(clean()); + game.world.set(timeless()); game.player("p", Vec3::new(0.0, 100.0, 0.0)); game.advance(0.05, 1); assert_eq!(stamps_to(&game, PlayerId(1)), vec![ - "build 8f3a21c \u{b7} 2026-07-29 18:04 UTC".to_owned() + "build 8f3a21c".to_owned() ]); } +/// The age is on the bar a real player receives, and not only in the pure +/// function's return value. +/// +/// The age itself cannot be pinned here -- it is measured against the wall +/// clock, so the exact string depends on when the test runs -- but its shape +/// can, and its shape is what would be missing if the system rendered the +/// stamp without a clock. +#[test] +fn the_bar_a_player_receives_carries_an_age() { + let mut game = Game::new(); + game.world.set(clean()); + game.player("p", Vec3::new(0.0, 100.0, 0.0)); + game.advance(0.05, 1); + + let stamps = stamps_to(&game, PlayerId(1)); + let [only] = stamps.as_slice() else { + panic!("expected exactly one stamp, got {stamps:?}"); + }; + assert!(only.starts_with("build 8f3a21c \u{b7} "), "{only}"); + assert!(only.ends_with(" ago"), "{only}"); +} + /// Once, and not once a tick. /// /// Four hundred ticks with a lobby short enough to run a whole match inside /// them, so the run crosses every phase change the game has and the match bar -/// beside this one is rewritten hundreds of times. The stamp is still one -/// call, because the tag the system adds is what stops the query matching. +/// beside this one is rewritten hundreds of times. The stamp is still one call, +/// because the system compares what it would send against what it sent. #[test] fn the_stamp_is_sent_once_and_not_once_a_tick() { let mut game = Game::new(); - game.world.set(clean()); + game.world.set(timeless()); game.world.set(LobbyConfig { min_players: 2, full_players: 4, @@ -218,9 +314,9 @@ fn the_stamp_is_sent_once_and_not_once_a_tick() { for player in [PlayerId(1), PlayerId(2)] { let stamps = stamps_to(&game, player); - // The count and one example, not the whole run: a bar resent every - // tick produces four hundred identical strings, and a failure nobody - // can read is one nobody acts on. + // The count and one example, not the whole run: a bar resent every tick + // produces four hundred identical strings, and a failure nobody can + // read is one nobody acts on. assert_eq!( stamps.len(), 1, @@ -239,7 +335,7 @@ fn the_stamp_is_sent_once_and_not_once_a_tick() { #[test] fn a_late_joiner_gets_the_stamp_and_nobody_else_gets_it_twice() { let mut game = Game::new(); - game.world.set(clean()); + game.world.set(timeless()); game.player("early", Vec3::new(0.0, 100.0, 0.0)); game.advance(1.0, 20); assert_eq!(stamps_to(&game, PlayerId(1)).len(), 1); @@ -260,19 +356,52 @@ fn a_late_joiner_gets_the_stamp_and_nobody_else_gets_it_twice() { ); } -/// Every slot is in `BarSlot::ALL`, and its index is a place inside an array -/// of `BarSlot::COUNT`. +/// A reload changes the stamp under a standing player, and their bar follows. /// -/// `adapter::PlayerBars` is `[Option; BarSlot::COUNT]` and is written -/// at `slot.index()`, in a system in the server's `PostUpdate`. If those two -/// ever disagree the failure is an out-of-bounds panic on a live server the -/// first tick anybody writes the new slot, which is the worst place to find -/// out and is why this is pinned here rather than trusted. +/// This is the behaviour hot reload needs and the old once-per-player tag could +/// not express: `init_game` re-reads `--build-stamp` on every accepted reload +/// and writes this singleton, and every player standing there has to end up +/// reading the new build rather than the one they joined under. The same +/// comparison is what makes the age advance on its own, and there is no way to +/// exercise that here without controlling the wall clock -- so it is exercised +/// through the other thing that changes the rendered string. +#[test] +fn a_stamp_that_changes_reaches_the_players_already_standing_there() { + let mut game = Game::new(); + game.world.set(timeless()); + game.player("a", Vec3::new(0.0, 100.0, 0.0)); + game.player("b", Vec3::new(4.0, 100.0, 0.0)); + game.advance(1.0, 20); + game.server.take(); + + game.world.set(BuildStamp { + rev: Some("deadbee".to_owned()), + ..timeless() + }); + game.advance(1.0, 20); + + for player in [PlayerId(1), PlayerId(2)] { + assert_eq!( + stamps_to(&game, player), + vec!["build deadbee".to_owned()], + "{player:?} is still being told about the build they joined under" + ); + } +} + +/// Every slot is in `BarSlot::ALL`, and its index is a place inside an array of +/// `BarSlot::COUNT`. +/// +/// `adapter::PlayerBars` is `[Option; BarSlot::COUNT]` and is written at +/// `slot.index()`, in a system in the server's `PostUpdate`. If those two ever +/// disagree the failure is an out-of-bounds panic on a live server the first +/// tick anybody writes the new slot, which is the worst place to find out and is +/// why this is pinned here rather than trusted. #[test] fn every_slot_is_in_all_and_indexes_into_it() { - // Exhaustive on purpose. A new variant makes this match fail to compile, - // so whoever adds one is sent here, and here is where `BarSlot::ALL` is - // checked to have grown with it. + // Exhaustive on purpose. A new variant makes this match fail to compile, so + // whoever adds one is sent here, and here is where `BarSlot::ALL` is checked + // to have grown with it. let listed: Vec = vec![BarSlot::Hud, BarSlot::Build] .into_iter() .inspect(|slot| match slot { @@ -297,13 +426,13 @@ fn every_slot_is_in_all_and_indexes_into_it() { /// The stamp goes to its own slot, so it can never overwrite the match bar. /// -/// Both bars are pushed on the tick a player joins. Without the slot the -/// second of the two would replace the first on the client, and which one -/// survived would depend on the order two systems happened to be declared in. +/// Both bars are pushed on the tick a player joins. Without the slot the second +/// of the two would replace the first on the client, and which one survived +/// would depend on the order two systems happened to be declared in. #[test] fn the_stamp_and_the_match_bar_are_different_bars() { let mut game = Game::new(); - game.world.set(clean()); + game.world.set(timeless()); game.player("p", Vec3::new(0.0, 100.0, 0.0)); game.advance(0.05, 1); diff --git a/events/smash/tests/kit_stats.rs b/events/smash/tests/kit_stats.rs index 20b6a985e..8814e50f0 100644 --- a/events/smash/tests/kit_stats.rs +++ b/events/smash/tests/kit_stats.rs @@ -236,12 +236,7 @@ fn melee_damage_changes_what_a_swing_takes_off() { // so a `melee_damage` that stopped reading the kit is a failure here rather // than a formula compared against a copy of itself. gate.each(|gate, side| { - let clock = gate - .game - .world - .cloned::<&smash::module::damage::MatchClock>() - .0; - let amount = smash::input::melee_damage(gate.view(side.player), side.foe, clock); + let amount = smash::input::melee_damage(gate.view(side.player), side.foe); gate.hit(side.foe, amount, DamageKind::Melee); }); gate.advance(0.05); diff --git a/events/smash/tests/projectile_blocks.rs b/events/smash/tests/projectile_blocks.rs new file mode 100644 index 000000000..e1957df9a --- /dev/null +++ b/events/smash/tests/projectile_blocks.rs @@ -0,0 +1,316 @@ +//! Arrows stop at walls. +//! +//! Projectiles were point particles that flew through the arena until their +//! timer ran out, so every shot on every map was taken as if the geometry were +//! not there: a player behind a pillar was as shootable as one in the open. +//! These are the game-level half of the fix. The traversal itself is unit +//! tested in `crates/geometry/src/sweep.rs`; what is checked here is that a +//! whole `smash` world, driven through its own tick, puts the arrow in the wall +//! and leaves the player behind it alone. + +mod harness; + +use flecs_ecs::prelude::*; +use glam::{IVec3, Vec3}; +use harness::{Game, TICK}; +use smash::{ + module::{ + blocks::{BlockWorldHandle, Cubes}, + damage::{DamageKind, Damaged, hurt}, + player::{Health, Position}, + projectile::{Flight, Payload, Projectile, Stuck, Visual, fire}, + }, + server::{Sound, SoundCategory, mock::Call}, +}; + +/// hyperion's `EntityKind::Arrow`, which is what a real bow fires. +const fn arrow_visual() -> Visual { + Visual(hyperion::simulation::entity_kind::EntityKind::Arrow) +} + +/// A world whose only terrain is a wall standing in the plane `x == 10`, +/// two blocks either side of the shooting line. +fn walled(game: &Game) { + game.world.set(BlockWorldHandle::new(Cubes::wall( + IVec3::new(10, -2, -2), + IVec3::new(10, 4, 2), + ))); +} + +/// The state of every projectile left in the world, as `(position, stuck)`. +fn projectiles(game: &Game) -> Vec<(Vec3, bool)> { + let mut found = Vec::new(); + game.world + .query::<&Flight>() + .with(Projectile::id()) + .build() + .each_entity(|entity, flight| { + found.push((flight.position, entity.has(Stuck::id()))); + }); + found +} + +/// Fire one flat, fast arrow along +X from the origin. +/// +/// Sixty blocks a second is a full-draw Barrage arrow, which is three blocks a +/// tick: fast enough that a one-block wall sits between two consecutive +/// endpoints and only the cells between them say it is there. +fn shoot(game: &Game, shooter: Entity) { + let shooter = game.world.entity_from_id(shooter); + fire( + shooter.world(), + shooter, + arrow_visual(), + Flight { + position: Vec3::new(0.5, 0.0, 0.5), + velocity: Vec3::X * 60.0, + gravity: 0.0, + seconds_left: 3.0, + radius: 0.4, + }, + Payload::new(6.0, 1.0), + ); +} + +#[test] +fn an_arrow_stops_at_the_wall_instead_of_passing_through_it() { + let mut game = Game::new(); + walled(&game); + let shooter = game.player("shooter", Vec3::new(0.0, 0.0, 0.0)); + + shoot(&game, shooter); + // One tick moves it three blocks, so it takes four to reach x == 10 -- + // and every one of those ticks is a three-block step that a point sample + // would have skipped two thirds of. + game.advance(TICK * 6.0, 6); + + let left = projectiles(&game); + assert_eq!( + left.len(), + 1, + "the arrow should still exist, stuck: {left:?}" + ); + let (at, stuck) = left[0]; + assert!(stuck, "an arrow that met a wall is stuck in it: {left:?}"); + assert!( + (at.x - 10.0).abs() < 1e-3, + "the arrow stopped at x = {}, and the wall's near face is x = 10", + at.x + ); +} + +#[test] +fn an_arrow_over_open_ground_flies_exactly_as_it_did_before() { + let mut game = Game::new(); + // No terrain seam installed: `OpenAir`, which is what every other test in + // this directory runs with. + let shooter = game.player("shooter", Vec3::new(0.0, 0.0, 0.0)); + + shoot(&game, shooter); + game.advance(TICK * 6.0, 6); + + let left = projectiles(&game); + assert_eq!(left.len(), 1, "nothing stops it: {left:?}"); + let (at, stuck) = left[0]; + assert!(!stuck, "there is nothing to stick in: {left:?}"); + // Six ticks at sixty blocks a second is eighteen blocks, well past where + // the wall stood in the test above. + assert!( + at.x > 17.0, + "the arrow should be eighteen blocks out, it is at x = {}", + at.x + ); +} + +#[test] +fn a_player_behind_the_wall_is_not_hit_through_it() { + let mut game = Game::new(); + walled(&game); + let shooter = game.player("shooter", Vec3::new(0.0, 0.0, 0.0)); + // Standing one block past the wall, dead on the shooting line. Before the + // sweep the arrow crossed the wall in a single step and took them with it. + let victim = game.player("victim", Vec3::new(11.5, 0.0, 0.5)); + + shoot(&game, shooter); + game.advance(TICK * 6.0, 6); + + let health = game + .world + .entity_from_id(victim) + .try_get::<&Health>(|health| health.current) + .expect("a player has health"); + let max = game + .world + .entity_from_id(victim) + .try_get::<&Health>(|health| health.max) + .expect("a player has health"); + assert!( + (health - max).abs() < 1e-6, + "the victim was shot through a wall: {health} of {max}" + ); + + // The control, in the same test, because without it this passes for a + // world where the arrow could never have reached them anyway -- a victim + // half a block off the flight line, a wall in the wrong place, a `fire` + // that silently did nothing. The identical setup with no wall must hit. + let mut open = Game::new(); + let shooter = open.player("shooter", Vec3::new(0.0, 0.0, 0.0)); + let victim = open.player("victim", Vec3::new(11.5, 0.0, 0.5)); + shoot(&open, shooter); + open.advance(TICK * 6.0, 6); + + let (health, max) = open + .world + .entity_from_id(victim) + .try_get::<&Health>(|health| (health.current, health.max)) + .expect("a player has health"); + assert!( + health < max, + "the control shot missed too, so the test above proves nothing: {health} of {max}" + ); +} + +#[test] +fn a_player_in_front_of_the_wall_is_still_hit() { + let mut game = Game::new(); + walled(&game); + let shooter = game.player("shooter", Vec3::new(0.0, 0.0, 0.0)); + // The mirror image of the test above: same wall, victim on this side of + // it. Clipping the segment must not clip away the hits that should land. + let victim = game.player("victim", Vec3::new(6.0, 0.0, 0.5)); + + shoot(&game, shooter); + game.advance(TICK * 6.0, 6); + + let health = game + .world + .entity_from_id(victim) + .try_get::<&Health>(|health| health.current) + .expect("a player has health"); + let max = game + .world + .entity_from_id(victim) + .try_get::<&Health>(|health| health.max) + .expect("a player has health"); + assert!( + health < max, + "the victim stood in the open and took nothing: {health} of {max}" + ); +} + +#[test] +fn the_impact_is_audible_at_the_point_it_happened() { + let mut game = Game::new(); + walled(&game); + let shooter = game.player("shooter", Vec3::new(0.0, 0.0, 0.0)); + + shoot(&game, shooter); + game.server.take(); + game.advance(TICK * 6.0, 6); + + let impacts: Vec = game + .server + .calls() + .iter() + .filter_map(|call| match call { + Call::Sound(at, sound) + if *sound + == Sound::new(smash::module::sound::PROJECTILE_HIT, SoundCategory::Neutral) => + { + Some(*at) + } + _ => None, + }) + .collect(); + + assert_eq!(impacts.len(), 1, "one arrow, one impact: {impacts:?}"); + assert!( + (impacts[0].x - 10.0).abs() < 1e-3, + "the sound played at x = {}, and the wall's near face is x = 10", + impacts[0].x + ); +} + +#[test] +fn a_stuck_arrow_does_not_shoot_whoever_walks_into_it() { + let mut game = Game::new(); + walled(&game); + let shooter = game.player("shooter", Vec3::new(0.0, 0.0, 0.0)); + let bystander = game.player("bystander", Vec3::new(30.0, 0.0, 30.0)); + + shoot(&game, shooter); + game.advance(TICK * 6.0, 6); + assert!( + projectiles(&game).iter().any(|(_, stuck)| *stuck), + "the arrow should be stuck before this test means anything" + ); + + // Walk them onto it. A stuck projectile is excluded from the flight system + // outright, so there is nothing left to hit them with. + game.world + .entity_from_id(bystander) + .set(Position(Vec3::new(9.8, 0.0, 0.5))); + game.advance(TICK * 4.0, 4); + + let health = game + .world + .entity_from_id(bystander) + .try_get::<&Health>(|health| health.current) + .expect("a player has health"); + let max = game + .world + .entity_from_id(bystander) + .try_get::<&Health>(|health| health.max) + .expect("a player has health"); + assert!( + (health - max).abs() < 1e-6, + "a stuck arrow shot a passer-by: {health} of {max}" + ); +} + +#[test] +fn a_stuck_arrow_stops_existing() { + let mut game = Game::new(); + walled(&game); + let shooter = game.player("shooter", Vec3::new(0.0, 0.0, 0.0)); + + shoot(&game, shooter); + game.advance(TICK * 6.0, 6); + assert!( + !projectiles(&game).is_empty(), + "it should be stuck in the wall at this point" + ); + + // Past `STUCK_SECONDS`. Left forever, a Barrage volley would carpet the + // far wall for the rest of the match. + game.advance(smash::module::projectile::STUCK_SECONDS + TICK, 25); + assert!( + projectiles(&game).is_empty(), + "the arrow is still in the wall: {:?}", + projectiles(&game) + ); +} + +/// The damage path is untouched by any of this: a hit is still a hit. +#[test] +fn direct_damage_is_unaffected() { + let mut game = Game::new(); + walled(&game); + let attacker = game.player("attacker", Vec3::ZERO); + let victim = game.player("victim", Vec3::new(1.0, 0.0, 0.0)); + + hurt(game.world.entity_from_id(victim), Damaged { + attacker: Some(attacker), + amount: 5.0, + knockback: smash::module::knockback::Knockback::from(Vec3::ZERO), + kind: DamageKind::Projectile, + }); + game.advance(TICK, 1); + + let health = game + .world + .entity_from_id(victim) + .try_get::<&Health>(|health| health.current) + .expect("a player has health"); + assert!(health < 20.0, "the victim took nothing: {health}"); +} diff --git a/flake.lock b/flake.lock index 77ed41eb2..386698a58 100644 --- a/flake.lock +++ b/flake.lock @@ -143,17 +143,17 @@ "git-src": { "flake": false, "locked": { - "lastModified": 1784960835, + "lastModified": 1785531994, "narHash": "sha256-9N1VyEYI7gpdR75evmQiO5fg546e4tc3W4zuwRFKbU0=", "owner": "indexable-inc", "repo": "git", - "rev": "69fbc5cfd883f5a45c88f202325ba08d20fdbdcb", + "rev": "eef38393cda1413ded72f0259d1618110cf38456", "type": "github" }, "original": { "owner": "indexable-inc", "repo": "git", - "rev": "69fbc5cfd883f5a45c88f202325ba08d20fdbdcb", + "rev": "eef38393cda1413ded72f0259d1618110cf38456", "type": "github" } }, @@ -208,17 +208,17 @@ "home-manager-src": { "flake": false, "locked": { - "lastModified": 1784724659, - "narHash": "sha256-eCwzFQYWJ5vW3nODZHPiaTuiRq9Z00S6jE5tfPtZe3s=", + "lastModified": 1785530609, + "narHash": "sha256-85GQQa8fT6mMGISL22IaZ99uDSHtRefSiy/Fae5rv90=", "owner": "indexable-inc", "repo": "home-manager", - "rev": "d27be2a29e5feb86a9196838b1bb0fdc44119cb8", + "rev": "7d29fa5cbf4b468b7d9692cfb500cb89291fb519", "type": "github" }, "original": { "owner": "indexable-inc", "repo": "home-manager", - "rev": "d27be2a29e5feb86a9196838b1bb0fdc44119cb8", + "rev": "7d29fa5cbf4b468b7d9692cfb500cb89291fb519", "type": "github" } }, @@ -238,7 +238,6 @@ "jj-src": "jj-src", "launchk-src": "launchk-src", "mesa-src": "mesa-src", - "nix-derivation-src": "nix-derivation-src", "nix-fast-build-src": "nix-fast-build-src", "nix-ninja-src": "nix-ninja-src", "nix-src": "nix-src", @@ -260,11 +259,11 @@ "tests": "tests" }, "locked": { - "lastModified": 1785376358, - "narHash": "sha256-2DE6TQE1tCyJyn6T6G7zpU7iOR+XCvHZg+peuyNv0Fk=", + "lastModified": 1785730910, + "narHash": "sha256-6ZQStDZWejuQADY9Yno/tg1mqQFVsTZcKBCS/yEUOsI=", "owner": "indexable-inc", "repo": "index", - "rev": "69904c6e20087c28d9adec1b78a179d45ab160af", + "rev": "efe77d641f76398ebf979735760df8e31e7153c2", "type": "github" }, "original": { @@ -276,17 +275,17 @@ "jj-src": { "flake": false, "locked": { - "lastModified": 1785212716, - "narHash": "sha256-uypz70OifGkGp2gcAUHrFFh1UUvmlVBiqjS7FkR8X3M=", + "lastModified": 1785704459, + "narHash": "sha256-/xoE3TN1l7NsismAQC058M54N+XU2QdWh6m+ngUC+Ns=", "owner": "indexable-inc", "repo": "jj", - "rev": "c1e8eece663170df3f461ee7a085721e535426e1", + "rev": "b6e967b88ce979c4e1a304472b90dc9e0f2b7f53", "type": "github" }, "original": { "owner": "indexable-inc", "repo": "jj", - "rev": "c1e8eece663170df3f461ee7a085721e535426e1", + "rev": "b6e967b88ce979c4e1a304472b90dc9e0f2b7f53", "type": "github" } }, @@ -324,23 +323,6 @@ "type": "github" } }, - "nix-derivation-src": { - "flake": false, - "locked": { - "lastModified": 1784724798, - "narHash": "sha256-2iW2bmKUlHZ+uVhb5JjZdIfICxMjSjCsdDzHKw/HFPA=", - "owner": "indexable-inc", - "repo": "Haskell-Nix-Derivation-Library", - "rev": "ba78008319f3517013a9fd70245ecee5ab2054b4", - "type": "github" - }, - "original": { - "owner": "indexable-inc", - "repo": "Haskell-Nix-Derivation-Library", - "rev": "ba78008319f3517013a9fd70245ecee5ab2054b4", - "type": "github" - } - }, "nix-fast-build-src": { "flake": false, "locked": { @@ -378,17 +360,17 @@ "nix-src": { "flake": false, "locked": { - "lastModified": 1785328166, - "narHash": "sha256-l9oDJNHEnXDf83/+FXKXFCSmmN6h8vWouazCDY9Iw1Y=", + "lastModified": 1785534682, + "narHash": "sha256-k1pNEeTmiYl+6gcK98O+fjJbdRk2Ho+92yqDK+SqmHQ=", "owner": "indexable-inc", "repo": "nix", - "rev": "0f356d7cf513ca074a2122079defeb95810b6a91", + "rev": "2d7585afe7b146f2bb07d834285ce8caefc7ba33", "type": "github" }, "original": { "owner": "indexable-inc", "repo": "nix", - "rev": "0f356d7cf513ca074a2122079defeb95810b6a91", + "rev": "2d7585afe7b146f2bb07d834285ce8caefc7ba33", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 5ad4d8531..c90459289 100644 --- a/flake.nix +++ b/flake.nix @@ -122,6 +122,15 @@ pkgs.pkg-config ]; + # What it takes to build this repository, named once. `devShells.default` + # below installs it, and so does the `hyperion-dev` fleet node + # (nix/fleet/dev.nix), so a VM built to develop hyperion on cannot come + # up with a different compiler than `nix develop` hands a contributor. + devEnvironment = { + packages = nativeBuildInputs ++ cargoTools ++ [ rustToolchain ]; + rustSrcPath = "${rustToolchain}/lib/rustlib/src/rust/library"; + }; + # Every dev command carries the tools it needs, so `nix run .#lint` # works on a machine with nothing but nix installed. mkScript = name: { text, deps ? [ ], toolchain ? rustToolchain }: @@ -843,7 +852,11 @@ # code in their working tree, rebuilt incrementally. The check # of the same name hands the same driver two store paths, and # that is the only difference between them. - export HYPERION_E2E_GAME_SERVER="cargo run --profile $profile -p $event --" + # `HYPERION_E2E_GAME_SERVER_ARGS` is the `nix run` twin of + # `mkCheck`'s `gameServerArgs`: extra flags for the server, + # for trying a gate's configuration by hand before writing it + # down. + export HYPERION_E2E_GAME_SERVER="cargo run --profile $profile -p $event -- ''${HYPERION_E2E_GAME_SERVER_ARGS:-}" export HYPERION_E2E_PROXY="cargo run --profile $profile --bin hyperion-proxy --" export HYPERION_E2E_CLIENT="''${HYPERION_E2E_CLIENT:-tools/client-26.2.py --name e2e}" # Certificates from the store rather than `nix run .#certs`, so @@ -1155,6 +1168,44 @@ } ); + # The hot-reload packaging: the engine's dylibs, the server binary + # `ExecStart` names, and the rules dylib `X-Reload-Triggers` names. + # Built by `cargoUnit` like everything else since ENG-12078 taught it + # dylib crate types; the split into separately-moving store paths is + # cargoUnit's per-unit source scoping rather than anything this flake + # arranges. See nix/hot-reload/packaging.nix. + # One entry per game. smash is the first consumer, not the shape + # (ENG-12067): every hot-reload derivation, check and NixOS option + # below is a function of this list rather than of smash. + hotReloadEvents = [ + { + name = "smash"; + hostCrate = "events/smash"; + rulesCrate = "events/smash-rules"; + } + ]; + + hotReload = import ./nix/hot-reload/packaging.nix { + inherit + lib + pkgs + cargoUnit + workspaceArgs + rustToolchain + ; + root = ./.; + events = hotReloadEvents; + }; + + # One `-server` and `-rules` per event, so adding a game + # to `hotReloadEvents` gives it packages without naming it again here. + hotReloadPackages = lib.listToAttrs ( + lib.concatMap (event: [ + (lib.nameValuePair "${event.name}-server" hotReload.events.${event.name}.server) + (lib.nameValuePair "${event.name}-rules" hotReload.events.${event.name}.rules) + ]) hotReloadEvents + ); + # The same game servers, compiled with `debug_assertions` on -- the # dev profile `nix run .#smash` runs and the operator actually plays. # This exists only for the boot gate: a release build compiles out the @@ -1191,10 +1242,13 @@ # Named once and used by both `packages` and the sandboxed checks, so # a gate runs the same binary the flake publishes rather than a second - # build of it. `packages.smash` wraps this one in the build stamp's - # environment rather than replacing it: the executable a gate runs and - # the executable a host runs are the same file, and what differs is - # three variables. See `stamped` for why the gates are left unwrapped. + # build of it. + # + # These are `cargoUnit` builds with no `-C prefer-dynamic`, so they + # link nothing from the workspace dynamically and a module loaded into + # one would get its own component-index pool. They are the developer's + # server and the gates' server; what a host runs is + # `hotReloadPackages.-server`. See nix/hot-reload/packaging.nix. gameBinaries = { bedwars = named "bedwars" workspace.binaries.bedwars; smash = named "smash" workspace.binaries.smash; @@ -1208,102 +1262,6 @@ bedwars = named "bedwars" devWorkspace.binaries.bedwars; smash = named "smash" devWorkspace.binaries.smash; }; - - # What build this is, for the strip across a player's screen. Read by - # `events/smash/src/module/build_stamp.rs`. - buildStamp = - let - # `self.shortRev` exists only on a clean tree and - # `self.dirtyShortRev` only on a dirty one, and a source with no - # git in it -- a plain directory, a tarball -- has neither. - # Dirtiness is carried by its own variable rather than by the - # `-dirty` suffix nix appends, so the game states the fact instead - # of parsing a string for it, and so the rev on screen is a hash a - # person can paste into `git show`. - rev = self.shortRev or (lib.removeSuffix "-dirty" (self.dirtyShortRev or "")); - in - { - inherit rev; - - # `self.lastModified` is the commit's COMMITTER date, and it is - # the same number on a dirty tree as on a clean one: nix asks git - # for the commit either way rather than falling back to a file - # mtime. Measured on this repo at d55a336 -- 1785386760 clean, - # 1785386760 dirty, `git log -1 --format=%ct` 1785386760. - # - # Committer and not author, which are 1785385579 and 1785386760 on - # that same commit because it was amended. So a rebased commit's - # bar reads when the rebase landed rather than when the work was - # written. That is the right answer for the question this bar - # exists for -- which build is deployed, and how long ago did that - # build come into being -- and it is the wrong answer for "when - # was this change authored", which the bar does not claim. - # - # Nothing at all when there is no rev, and that is the point of - # the conditional rather than a nicety. `lastModified` on a - # non-git source is the directory's mtime, so without this the bar - # renders `build unpackaged build · 2026-07-29 20:41 UTC`: a stamp - # that has just admitted it does not know what it is, timed to the - # minute. An empty string parses as absent in `BuildStamp::parse` - # and the bar drops the whole clause. - time = if rev == "" then "" else toString (self.lastModified or 0); - - dirty = if self ? dirtyShortRev then "1" else "0"; - }; - - # The stamp, as an environment a binary is started in. - # - # A wrapper and not `env!` in a build script, and the difference is - # the whole reason this exists: a commit hash compiled into a crate - # invalidates that crate and everything downstream on every commit, so - # every push would rebuild the workspace. This derivation is a symlink - # and three assignments, and it is the only thing a new commit - # rebuilds. - # - # Applied to `packages` and deliberately NOT to the binaries the e2e - # gates run. cargoUnit content-addresses every crate unit, so a commit - # that changes no smash source leaves the gate's binary bit for bit - # identical and the whole gate is reused from the store. Stamping it - # would move its path on every commit and re-run all fourteen e2e - # gates for a string none of them reads. The gates cover the *reading* - # of these variables instead, which is the half that can be wrong in - # Rust; `checks.build-stamp` below covers the writing. - # - # THE COST, AND IT LANDS ON PLAYERS RATHER THAN ON CI. This wrapper's - # store path moves on every commit, `packages.smash` is it, and - # `nix/modules/game-server.nix` builds `ExecStart` out of - # `packages.smash`. So the unit file changes on every commit and - # `switch-to-configuration` restarts `hyperion-game-server` on every - # deploy -- including deploys of commits that touch nothing in smash, - # which used to leave `ExecStart` byte-identical and the running - # server alone. A restart drops every connected player. - # - # The restart is required by the feature: a server cannot report a - # commit it was not started with. The SCOPE is not. Narrowing it means - # decoupling the stamp from the unit -- an `EnvironmentFile` the - # deploy writes, or a stamp read from a path rather than baked into - # `ExecStart` -- and that is a different change with its own failure - # mode, namely a stamp that can disagree with the binary beside it. - # Written down here rather than left to be discovered, because with - # continuous apply on, "redeployed as main moves" now means every - # player is dropped as main moves. - stamped = drv: - let - main = drv.meta.mainProgram; - in - pkgs.runCommand "${drv.name}-stamped" - { - inherit (drv) meta; - nativeBuildInputs = [ pkgs.makeWrapper ]; - passthru = (drv.passthru or { }) // { unstamped = drv; }; - } - '' - makeWrapper ${drv}/bin/${main} "$out/bin/${main}" \ - --set HYPERION_BUILD_REV ${lib.escapeShellArg buildStamp.rev} \ - --set HYPERION_BUILD_TIME ${lib.escapeShellArg buildStamp.time} \ - --set HYPERION_BUILD_DIRTY ${lib.escapeShellArg buildStamp.dirty} - ''; - # `nix run .#fmt -- --check` and `nix run .#lint`, as derivations the # gate realises. ENG-11424. # @@ -1382,6 +1340,380 @@ ${lib.getExe checkScripts.lint} touch $out ''; + + # The source split the reload boundary is made of, asserted on the + # derivations themselves rather than on a proxy. + # + # `ExecStart` must not move when only the rules change, and a store + # path is a function of a derivation's inputs, so the question is + # exactly whether the rules crate's code is an input to the server. + # This perturbs one crate's source and compares `drvPath`s: a + # `drvPath` that did not move guarantees an `outPath` that did not + # move, so the assertion is conservative in the safe direction even + # though every unit is content-addressed. + # + # It replaces a check that read the stub trees the old cargo-based + # packaging built by hand. cargoUnit scopes each unit's source to + # its own crate directory, so there are no stub trees left to read, + # and this asks the question the stubs were standing in for. + # + # NOTE, and this changed with ENG-12078: a HOST edit no longer moves + # the rules dylib. The old packaging put the host crate in the rules + # derivation's source tree, so it did. `smash-rules` does not depend + # on `smash` -- it reaches components through + # `hyperion-hot-reload`'s registry by name -- so cargoUnit correctly + # rebuilds nothing, and the layout check the loader runs is what + # catches a component whose shape moved underneath it. That is the + # guard that was always doing this work; the recompile was an + # artifact of the coarse source filter. + # + # Cost: two extra cargoUnit workspace instantiations at eval, each + # a cargo resolve and a render, not a compile. + hot-reload-source-split = + let + # A whole-tree copy with one file appended to. The copy is what + # makes it a different source tree; the content of the append is + # irrelevant as long as it changes the file's bytes. + perturbed = + label: file: + pkgs.runCommand "hyperion-source-perturbed-${label}" { } '' + cp -r ${./.} "$out" + chmod -R u+w "$out" + printf '\n// hot-reload-source-split perturbation\n' >> "$out/${file}" + ''; + + base = hotReload; + movedBy = label: file: hotReload.packagingFor (perturbed label file); + + # `throw` rather than a build-time comparison: these are eval + # facts, and forcing them here fails the check with the two + # paths in the message. + same = + what: a: b: + if a.drvPath == b.drvPath then + true + else + throw '' + hot-reload-source-split: ${what} moved and must not have. + before: ${a.drvPath} + after: ${b.drvPath} + A path in `ExecStart` that moves on an unrelated edit turns every + reload into a restart, dropping every connected player, with no + other symptom. + ''; + different = + what: a: b: + if a.drvPath != b.drvPath then + true + else + throw '' + hot-reload-source-split: ${what} did NOT move and must have. + both: ${a.drvPath} + An edit that changes nothing means the derivation does not carry + that crate's source, so the change would never reach a player. + ''; + + assertions = lib.concatMap ( + event: + let + afterRules = movedBy "${event.name}-rules" "${event.rulesCrate}/src/lib.rs"; + afterHost = movedBy "${event.name}-host" "${event.hostCrate}/src/lib.rs"; + in + [ + (different "${event.name}-rules on a rules edit" base.events.${event.name}.rules + afterRules.events.${event.name}.rules + ) + (same "${event.name}-server on a rules edit" base.events.${event.name}.server + afterRules.events.${event.name}.server + ) + (same "hyperion-dylibs on a rules edit" base.hyperion-dylibs afterRules.hyperion-dylibs) + (different "${event.name}-server on a host edit" base.events.${event.name}.server + afterHost.events.${event.name}.server + ) + (same "hyperion-dylibs on a host edit" base.hyperion-dylibs afterHost.hyperion-dylibs) + ] + ) hotReloadEvents; + in + assert lib.all (held: held) assertions; + pkgs.runCommand "hyperion-hot-reload-source-split" { } '' + touch $out + ''; + + # One `libflecs_ecs`, asserted on the shipped artifacts (ENG-12053). + # + # Two questions, and only asking both is a check. `requireResolved` + # in the packaging already refuses a dangling `DT_NEEDED`, so each + # artifact resolves *something*. The property the feature needs is + # that the server and the rules dylib resolve the SAME something: + # + # 1. the same `DT_NEEDED` name. Two different metadata hashes is + # two `INDEX_POOL`s -- one world indexed two different ways, + # with no crash and no error, components reading as each + # other's neighbours. + # 2. the same resolved store path. The same hash reaching two + # store paths is the identical aliasing fault wearing a nicer + # name, and a string comparison alone cannot see it. + # + # This replaces the manual `readelf`/`ldd` recipe docs/hot-reload.md + # carried while the property was not yet structural. Since ENG-12078 + # it is: one cargoUnit graph resolves features once, so there is one + # `flecs_ecs` derivation, and `engineUnit` fails the build at eval if + # the graph ever holds two. This is the runtime half of that -- what + # the loader actually does with the files. + # + # Written to fail closed. Every extraction is checked for emptiness + # before it is compared, because "no flecs line found" and "the two + # flecs lines agree" are the same silence otherwise. + hot-reload-one-flecs = + pkgs.runCommandCC "hyperion-hot-reload-one-flecs" + { + nativeBuildInputs = [ pkgs.binutils ]; + } + ( + '' + set -euo pipefail + '' + + lib.concatMapStrings (event: '' + echo "checking ${event.name}" + server=${hotReload.events.${event.name}.server}/bin/${ + (lib.importTOML (./. + "/${event.hostCrate}/Cargo.toml")).package.name + } + rules=${hotReload.events.${event.name}.rules}/lib/${hotReload.events.${event.name}.rulesLib} + + ${ + if pkgs.stdenv.hostPlatform.isElf then + '' + for artifact in "$server" "$rules"; do + readelf -d "$artifact" | sed -n 's/.*Shared library: \[\(libflecs_ecs[^]]*\)\].*//p' >> names.txt + ldd "$artifact" | awk '/libflecs_ecs/ { print $3 }' >> paths.txt + done + '' + else + '' + # Mach-O records the absolute install name, so the two + # questions have one answer there; ask it twice anyway + # so the shape of the check does not differ. + for artifact in "$server" "$rules"; do + otool -L "$artifact" | awk '/libflecs_ecs/ { print $1 }' >> paths.txt + otool -L "$artifact" | awk '/libflecs_ecs/ { print $1 }' | xargs -n1 basename >> names.txt + done + '' + } + + if [ "$(wc -l < names.txt)" -ne 2 ] || [ "$(wc -l < paths.txt)" -ne 2 ]; then + echo "expected one libflecs_ecs line from each of the two artifacts, got:" >&2 + echo "names:" >&2; cat names.txt >&2 + echo "paths:" >&2; cat paths.txt >&2 + echo "Neither naming it twice nor naming it zero times is the property;" >&2 + echo "this is the check failing to run, not the artifacts being clean." >&2 + exit 1 + fi + if [ "$(sort -u names.txt | wc -l)" -ne 1 ]; then + echo "${event.name}: the server and the rules dylib name DIFFERENT libflecs_ecs:" >&2 + cat names.txt >&2 + echo "Two metadata hashes is two INDEX_POOLs in one process; see ENG-12053." >&2 + exit 1 + fi + if [ "$(sort -u paths.txt | wc -l)" -ne 1 ]; then + echo "${event.name}: one libflecs_ecs name resolving to TWO store paths:" >&2 + cat paths.txt >&2 + echo "Same hash, two libraries loaded -- the same fault as two hashes." >&2 + exit 1 + fi + + # And it is the engine's copy, not some third one that + # happens to be consistent between the two. + resolved=$(head -1 paths.txt) + if [ ! "$resolved" -ef "${hotReload.hyperion-dylibs}/lib/$(basename "$resolved")" ]; then + echo "${event.name}: libflecs_ecs resolves outside hyperion-dylibs:" >&2 + echo " resolved: $resolved" >&2 + echo " engine: ${hotReload.hyperion-dylibs}/lib/" >&2 + ls -l ${hotReload.hyperion-dylibs}/lib >&2 + exit 1 + fi + + printf '%s %s\n' "${event.name}" "$resolved" >> "$out" + rm -f names.txt paths.txt + '') hotReloadEvents + ); + + # THE PACKAGED SERVER STARTS. Nothing else here runs it. + # + # Every gate that boots a game server boots `gameBinaries.smash`, a + # `cargoUnit` build with no `-C prefer-dynamic`. The binary a host + # actually runs is `smash-server`, and until this check existed it + # was only ever `readelf`-ed, `ldd`-ed and store-path-diffed -- + # never executed. It had been segfaulting on startup since the day + # it was first built, in every build, and every gate was green + # (ENG-12112): a `#[global_allocator]` cannot coexist with the + # dylib split, because rustc makes each dylib's `__rust_alloc` + # local and the process ends up with two allocators. + # + # `--help` is the whole test, and that is the point: it costs + # milliseconds, it needs no certificates, no world and no network, + # and it exercises the dynamic loader, every static initialiser and + # the first few thousand allocations -- which is where a binary + # that cannot start dies. + # + # THE REASON THIS EXISTS, IN ONE LINE: a derivation that is only + # ever inspected is not a derivation that is known to work. The + # store-path diffs, the `readelf` and the `ldd` were all correct, + # and all correct about something other than whether it runs. + hot-reload-server-starts = + pkgs.runCommand "hyperion-hot-reload-server-starts" { } + ( + lib.concatMapStrings (event: '' + server=${hotReload.events.${event.name}.server} + main=${hotReload.events.${event.name}.server.meta.mainProgram} + echo "starting ${event.name}: $server/bin/$main --help" + if ! "$server/bin/$main" --help > help.txt 2>&1; then + status=$? + echo "${event.name}-server could not even print its usage" >&2 + echo "(exit $status; 139 is SIGSEGV). See ENG-12112." >&2 + cat help.txt >&2 + exit 1 + fi + # A binary that exits 0 having printed nothing is not one + # that started; `--help` has to have reached clap. + grep -q -- "--reload-socket" help.txt || { + echo "${event.name}-server printed usage without the" >&2 + echo "deployment flags, so it is not the binary the" >&2 + echo "NixOS module builds an ExecStart out of." >&2 + cat help.txt >&2 + exit 1 + } + '') hotReloadEvents + + '' + touch "$out" + '' + ); + + # The other half of the boundary, one layer up. `source-split` + # proves a rules edit moves only the rules derivation; this proves + # that a moved rules derivation reaches a running server as a reload + # rather than as a restart. + # + # `switch-to-configuration` reloads a unit whose `[Service]` section + # is byte-identical and whose `X-Reload-Triggers` moved, and restarts + # it otherwise. So the property is not "the dylib is mentioned in the + # right place" -- nixpkgs hashes the triggers into a file of their + # own and the unit names that file, so the dylib path is not in the + # unit at all. The property is that changing the dylib moves exactly + # that one line. One stray reference anywhere else turns every + # invisible deploy into a mass disconnection, with every other gate + # green, the apply exiting 0 and the server coming back up. + # + # Costs an evaluation and not a build: `hello` stands in for the game + # binary, and each rendered unit's string context is discarded, so + # nothing here realises the engine or the event. + hot-reload-unit-split = + let + unitWithRules = + rules: + builtins.unsafeDiscardStringContext + (nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + self.nixosModules.game-server + { + boot.loader.grub.enable = false; + fileSystems."/" = { + device = "/dev/disk/by-label/nixos"; + fsType = "ext4"; + }; + system.stateVersion = "25.05"; + nixpkgs.hostPlatform = "x86_64-linux"; + + services.hyperion-game-server = { + inherit rules; + enable = true; + event = "under-test"; + # Stand-ins, so this check answers a question about + # an ini file without waiting thirteen minutes for a + # build. What it must NOT stand in for is the thing + # under test: `rules` is the only option that differs + # between the two renderings below. + package = pkgs.hello; + reloadClient = pkgs.hello; + pki = { + rootCaCert = "/var/lib/hyperion-pki/root_ca.crt"; + cert = "/var/lib/hyperion-pki/node.crt"; + privateKey = "/var/lib/hyperion-pki/node_private_key.pem"; + }; + }; + } + ]; + }).config.systemd.units."hyperion-game-server.service".text; + in + pkgs.runCommand "hyperion-hot-reload-unit-split" + { + before = unitWithRules "/nix/store/00000000000000000000000000000000-rules-before/lib/librules.so"; + after = unitWithRules "/nix/store/11111111111111111111111111111111-rules-after/lib/librules.so"; + } + '' + printf '%s\n' "$before" > before.ini + printf '%s\n' "$after" > after.ini + + # The control. Two identical units would satisfy every + # assertion below about what did not change, so the one thing + # that has to move is checked first. + if cmp -s before.ini after.ini; then + echo "a new build of the rules did not change the unit at all," >&2 + echo "so systemd would never be told to reload it." >&2 + exit 1 + fi + + if ! diff before.ini after.ini > delta; then :; fi + if grep -E '^[<>]' delta | grep -v '^[<>] X-Reload-Triggers='; then + echo "" >&2 + echo "a new build of the rules moved something other than" >&2 + echo "X-Reload-Triggers. switch-to-configuration restarts a unit" >&2 + echo "whose [Service] changed, so this deploy would drop every" >&2 + echo "connected player. See nix/modules/game-server.nix." >&2 + exit 1 + fi + + # A reload trigger with nothing to run it is a restart with + # extra steps: systemd refuses `systemctl reload` on a unit + # with no ExecReload. + grep -q '^ExecReload=' before.ini || { + echo "the unit has no ExecReload, so a reload cannot happen." >&2 + exit 1 + } + + cp before.ini "$out" + ''; + + # Runs the shared-pool probe over the artifacts that ship. + # + # This is the one invariant the reload gate cannot check for itself. + # `AbiToken` compares a rustc version, an ABI integer and the address + # of a static, and all three pass while the host and a module index + # one world through two different `INDEX_POOL`s. So the thing that + # would catch a regression is a build of both halves and a comparison + # of allocation order, which is what the probe is. + # + # Since ENG-12078 both halves come out of the same cargoUnit graph + # the server and the rules dylib do, so this costs the probe's own + # two units and nothing else -- it used to be a whole second + # dev-profile compile of `hyperion`, with its own hand-written + # prefer-dynamic string, proving the property for a build nobody + # deployed. + hot-reload-index-probe = pkgs.runCommand "hyperion-hot-reload-index-probe" { } '' + # `pipefail` because the probe's status is the one that matters and + # `tee` would otherwise report for it. Both assertions are kept: the + # exit code catches a probe that dies before saying anything, and + # the grep catches one that exits 0 having measured nothing. + set -euo pipefail + module=$(echo ${hotReload.indexProbe.module}/lib/libhyperion_hot_reload_index_probe_module-*${pkgs.stdenv.hostPlatform.extensions.sharedLibrary}) + if [ ! -f "$module" ]; then + echo "the index-probe module unit produced no shared library:" >&2 + ls -la ${hotReload.indexProbe.module}/lib >&2 + exit 1 + fi + ${hotReload.indexProbe.host}/bin/hot-reload-index-probe "$module" | tee $out + grep -q PROBE_OK $out + ''; }; # `nix flake check` builds every app, which is what proves each one @@ -1584,6 +1916,104 @@ timeout = 180; }; + # Player chat, which smash did not have. + # + # `PacketId::Chat` was routed, decoded and pushed onto + # `EventQueue`, and nothing drained that queue, + # so every message a player typed was thrown away when the queue + # was recycled. There was no code to unit-test and no packet to + # assert on; the only evidence that separates "wired up" from + # "decoded and dropped" is a second connection hearing the first + # one, which is what makes this a gate rather than a test. + # + # `chat-check.py` joins two clients, has one talk, and requires the + # line to reach both in vanilla's ` message` shape. It also + # sends a message full of section signs: a literal `SystemChat` + # string is rendered through the client's `StringDecomposer`, which + # applies legacy colour codes as it reads, so `§k` typed by a bot + # scrambles its own text and `§4[Server]` paints a fake notice. + # Both must arrive with the sign gone. + chat-e2e = e2e.mkCheck { + name = "hyperion-chat-e2e"; + gameServer = gameBinaries.smash; + proxy = gameBinaries.hyperion-proxy; + client = "chat-check.py"; + timeout = 180; + }; + + # The operator console, which needs a server and a browser at once + # and so cannot be tested from inside its own crate. + # + # `hyperion-web-console` has unit tests for everything that is a + # function of its inputs -- the token comparison, the backlog + # bound, the legacy colour rendering, the threshold rule. None of + # them can see the three things the console actually claims, all of + # which are about two processes agreeing: + # + # * a line a player types reaching a browser, with the section + # signs they typed stripped on the way. The stripping is + # per-source and the game's own path already does it, so a + # mirror of that path would look right and paint the operator's + # console anyway. + # * a command typed on the web running through the same registry + # a player's command runs through, and its reply coming back + # over a `ConnectionId` with no socket behind it. Nothing short + # of a real unicast exercises that. + # * that reply surviving both branches of the frame decoder. A + # short reply is framed with `data_len` zero and a long one is + # deflated, and a decoder told the wrong threshold reads the + # length as a packet id and silently drops every frame -- no + # error, no log line, an operator console that shows commands + # going out and nothing coming back. That state was reproduced + # deliberately, and this gate is the only thing that saw it. + # + # `console = true` has the driver pick a third port beside the + # other two and hand the same address and token file to the server + # and the client. No `e2eOffsets` entry, for the same reason + # `chat-e2e` has none: the offsets exist for the gates that also + # run as host apps, and a sandboxed check gets free ports from the + # driver instead. + console-e2e = e2e.mkCheck { + name = "hyperion-console-e2e"; + gameServer = gameBinaries.smash; + proxy = gameBinaries.hyperion-proxy; + client = "console-check.py"; + console = true; + timeout = 180; + }; + + # The tab list's two new numbers, and the one claim about them that + # no Rust test can settle. + # + # The tick rate half is ordinary: `tab-list-check.py` joins, reads + # the `TabList` (id 122) footer back, and checks it carries a + # measured rate against the rate the loop is paced to. smash sent no + # `TabList` at all before this, so the first assertion is red on an + # unpatched tree. + # + # The ping half is why this is a gate and not a unit test. hyperion + # measures round trip time by sending a keep-alive and timing the + # answer, and there is a proxy in between. If the proxy answered + # keep-alives itself, the game server would be timing the proxy and + # the number would look completely plausible -- a lie nothing in the + # crate could detect. So the client answers keep-alives, watches a + # real latency arrive, then **stops answering** while staying + # otherwise busy: the reading has to fall back to -1, which it can + # only do if the thing answering was the client. Then it answers + # again and the reading comes back, so the fallback is a timeout and + # not a dead connection. + # + # The mute window is `Global::keep_alive_timeout` (20 s) plus room + # for a probe to be sent and time out inside it, so this gate is + # slower than its neighbours by construction. + tab-list-e2e = e2e.mkCheck { + name = "hyperion-tab-list-e2e"; + gameServer = gameBinaries.smash; + proxy = gameBinaries.hyperion-proxy; + client = "tab-list-check.py"; + timeout = 300; + }; + # The dev-profile boot gate. ENG-11000 shipped a singleton that was # `world.set` but never registered as a component; a release build # compiles the flecs "component is not registered" assert out, so @@ -1616,6 +2046,32 @@ timeout = 300; }; + # The same boot, with the console turned on. + # + # `console-e2e` runs the release binary, because that is what + # ships, and a release build has the flecs assert compiled out. So + # everything `install` does -- registering two singletons, setting + # both, spawning the caller entity, attaching the virtual + # connection -- is reached by no gate that can see a + # use-before-register. That is the ENG-11000 shape exactly: a + # console that aborts `nix run .#smash` on boot while every gate + # above stays green. + # + # It runs the whole console client rather than a bare join, because + # the assert fires wherever the unregistered component is first + # used, and for the console that is as likely to be a command reply + # or a snapshot as it is the boot itself. + console-dev-boot-e2e = e2e.mkCheck { + name = "hyperion-console-dev-boot-e2e"; + gameServer = devGameBinaries.smash; + proxy = gameBinaries.hyperion-proxy; + client = "console-check.py"; + console = true; + # A dev build is unoptimised, so it gets the same generous + # deadline as the boot gate above rather than `console-e2e`'s. + timeout = 300; + }; + bedwars-dev-boot-e2e = e2e.mkCheck { name = "hyperion-bedwars-dev-boot-e2e"; gameServer = devGameBinaries.bedwars; @@ -1689,77 +2145,6 @@ e2e-ports-distinct = e2ePortsDistinct; test-util-is-dev-only = testUtilIsDevOnly; - # The deployed smash binary starts in an environment that says what - # build it is. See `stamped` and - # `events/smash/src/module/build_stamp.rs`. - # - # The Rust half is covered by `cargo test -p smash --test - # build_stamp`, which pins what each of these three values turns - # into on screen. Nothing covered that half's *input* until this: - # a rename, a dropped `--set`, or a `buildStamp` attribute that - # stopped being threaded all read as a server quietly saying - # "unpackaged build" to every player, which is exactly the state - # this whole change exists to end and is invisible from any test - # that does not look at the wrapper. - build-stamp = - pkgs.runCommand "hyperion-build-stamp" - { - wrapper = lib.getExe (stamped gameBinaries.smash); - binary = lib.getExe gameBinaries.smash; - inherit (buildStamp) rev time dirty; - } - '' - fail() { echo "FAIL: $1" >&2; exit 1; } - - # The control. Everything below is a grep, and a grep against - # a file that is not there, or is a binary, or is empty, - # fails for reasons that have nothing to do with the stamp. - [ -f "$wrapper" ] || fail "no wrapper at $wrapper" - grep -qF -- "$binary" "$wrapper" \ - || fail "the wrapper does not exec $binary" - - for name in REV TIME DIRTY; do - grep -q "HYPERION_BUILD_$name=" "$wrapper" \ - || fail "the wrapper sets no HYPERION_BUILD_$name" - done - - # A rev is empty exactly when nix had no git to ask, which is - # true of a tarball and of a plain directory, and is not a - # failure. - if [ -n "$rev" ]; then - # Hex and nothing else. Every other assertion here compares - # the wrapper against the same expression that built it, so - # on VALUES they are tautologies -- if nix changed the - # suffix `removeSuffix` strips, both sides would move - # together and this file would stay green while the bar read - # `abc1234-dirty + uncommitted changes`. What a short commit - # hash looks like is the one property of the value that does - # not come from the expression, so it is the only thing here - # that can catch a wrong one. - case "$rev" in - *[!0-9a-f]*) - fail "the rev is not a short commit hash: $rev" ;; - esac - [ -n "$time" ] \ - || fail "there is a rev but no timestamp beside it" - grep -q "HYPERION_BUILD_REV=.*$rev" "$wrapper" \ - || fail "the wrapper does not carry the rev $rev" - grep -q "HYPERION_BUILD_TIME=.*$time" "$wrapper" \ - || fail "the wrapper does not carry the timestamp $time" - else - # And no timestamp either. A precise minute next to a stamp - # that has just said it does not know what build it is comes - # from `lastModified` falling back to a directory mtime, and - # is worse than saying nothing. - [ -z "$time" ] \ - || fail "no rev, but a timestamp of $time: that is a directory mtime dressed as a commit" - echo "no rev: this flake source is not a git tree" >&2 - fi - grep -q "HYPERION_BUILD_DIRTY=.*'$dirty'" "$wrapper" \ - || fail "the wrapper does not carry dirty=$dirty" - - echo "rev=$rev time=$time dirty=$dirty" > "$out" - ''; # A colour reaches a client as a component field or not at all. smash-text-no-legacy-formatting = textGate; @@ -1774,6 +2159,7 @@ minecraft-tag-data = minecraft.tagDataUpToDate; minecraft-tags-load = minecraft.tagsLoadForClient; minecraft-block-states = minecraft.blockStatesUpToDate; + minecraft-collision-shapes = minecraft.collisionShapesUpToDate; minecraft-particles = minecraft.particlesUpToDate; minecraft-encoder-fixtures = minecraft.fixturesUpToDate; minecraft-proto-json = minecraft.protocolJsonUpToDate; @@ -1861,8 +2247,8 @@ in { devShells.default = pkgs.mkShell { - nativeBuildInputs = nativeBuildInputs ++ cargoTools ++ [ rustToolchain ]; - RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; + nativeBuildInputs = devEnvironment.packages; + RUST_SRC_PATH = devEnvironment.rustSrcPath; }; apps = lib.mapAttrs @@ -1883,6 +2269,7 @@ sync-minecraft-registry-data = minecraft.syncRegistryDataScript; sync-minecraft-tag-data = minecraft.syncTagDataScript; sync-minecraft-block-states = minecraft.syncBlockStatesScript; + sync-minecraft-collision-shapes = minecraft.syncCollisionShapesScript; sync-minecraft-particles = minecraft.syncParticlesScript; # Re-records the golden traces `crates/hyperion/tests/differential.rs` # compares against. See docs/differential-testing.md. @@ -1895,20 +2282,27 @@ minecraft-encode = minecraft.vanillaEncoder; }); - packages = { + packages = hotReloadPackages // { default = gameBinaries.bedwars; - # smash is stamped and the other two are not, because smash is the - # only one that reads the stamp: `nix/modules/game-server.nix` - # builds its ExecStart out of this package, so this is what puts a - # commit on a player's screen on the deployed server. - smash = stamped gameBinaries.smash; - inherit (gameBinaries) bedwars hyperion-proxy; + # `nix run .#smash` and the e2e gates. It says "unpackaged build" on + # its boss bar, which is the truth: the stamp is three files in a + # directory a deployment names on the command line, and nobody named + # one. `packages.smash-server` is what a host runs. + inherit (gameBinaries) smash bedwars hyperion-proxy; rust-mc-bot = named "rust-mc-bot" workspace.binaries.rust-mc-bot; + inherit (hotReload) hyperion-dylibs; minecraft-server-jar = minecraft.serverJar; minecraft-data = minecraft.generatedData; minecraft-decompiled = minecraft.decompiledSources; minecraft-physics-sources = minecraft.physicsSources; + # The extracted per-state collision shapes, and the harness that + # reads them out of the jar. The JSON is what a reader inspects and + # what `minecraft-collision-shapes-rust` is generated from; the + # harness is exposed too so a shape can be dumped by hand while + # debugging a clip. + minecraft-collision-shapes-json = minecraft.collisionShapes; + minecraft-shapes = minecraft.vanillaShapes; minecraft-client-skin-sources = minecraft.clientSkinSources; minecraft-protocol = minecraft.protocolJson; minecraft-proto-rust = minecraft.generatedRust; @@ -1920,12 +2314,17 @@ differential-recorder = differential.recorder; differential-traces = differential.recordedTraces; minecraft-block-states-rust = minecraft.generatedBlockStates; + minecraft-collision-shapes-rust = minecraft.generatedCollisionShapes; minecraft-particles-rust = minecraft.generatedParticles; }; # What CI enforces of this set is nix/ci/flake-gate.nix. inherit checks; + # Not a flake output: the fleet reads it to build the dev node, and + # `nix build .#devEnvironment` would be a name for something that is + # already `nix develop`. + inherit devEnvironment; }; # The deployed fleet. `nix/fleet/default.nix` says at length why it lives @@ -1937,11 +2336,69 @@ # machine evaluates them. Taken from `mkSystem` rather than from # `self.packages` so the fleet does not depend on the attribute set it # contributes to. - fleet = import ./nix/fleet { - inherit index; - guestPackages = (mkSystem "x86_64-linux").packages; - inherit (self) nixosModules; - }; + # What build this is, for the strip across a player's screen. Written into + # `/etc/hyperion` by `nix/modules/game-server.nix` and read at runtime by + # `events/smash/src/module/build_stamp.rs`. + # + # Out here rather than inside `mkSystem` because it is a property of this + # source and not of any machine, and because the fleet needs it: a stamp + # that were computed per system could disagree with itself across two + # evaluations of the same commit. + # + # FILES AND NOT AN ENVIRONMENT, and the difference is the whole reason + # this moved. It used to be three `--set`s on a `makeWrapper` around the + # smash binary, which put a per-commit store path inside `ExecStart` and + # therefore restarted the game server on every deploy -- including deploys + # of commits that touch nothing in smash. A restart drops every connected + # player. Now the stamp is beside the unit rather than inside it, so a + # commit can change what the bar says without changing `[Service]`. + buildStamp = + let + # `self.shortRev` exists only on a clean tree and `self.dirtyShortRev` + # only on a dirty one, and a source with no git in it -- a plain + # directory, a tarball -- has neither. Dirtiness is carried on its own + # rather than by the `-dirty` suffix nix appends, so the game states + # the fact instead of parsing a string for it, and so the rev on + # screen is a hash a person can paste into `git show`. + rev = self.shortRev or (nixpkgs.lib.removeSuffix "-dirty" (self.dirtyShortRev or "")); + in + { + inherit rev; + + # `self.lastModified` is the commit's COMMITTER date, and it is the + # same number on a dirty tree as on a clean one: nix asks git for the + # commit either way rather than falling back to a file mtime. Measured + # on this repo at d55a336 -- 1785386760 clean, 1785386760 dirty, + # `git log -1 --format=%ct` 1785386760. + # + # Committer and not author, which are 1785385579 and 1785386760 on + # that same commit because it was amended. So a rebased commit's bar + # reads when the rebase landed rather than when the work was written. + # That is the right answer for the question this bar exists for -- + # which build is deployed, and how long ago did that build come into + # being -- and it is the wrong answer for "when was this change + # authored", which the bar does not claim. + # + # Null when there is no rev, and that is the point of the conditional + # rather than a nicety. `lastModified` on a non-git source is the + # directory's mtime, so without this the bar renders `build unpackaged + # build · 3d ago`: a stamp that has just admitted it does not know what + # it is, aged to the day. + committedAt = if rev == "" then null else self.lastModified or 0; + + dirty = self ? dirtyShortRev; + }; + + fleet = + let + guest = mkSystem "x86_64-linux"; + in + import ./nix/fleet { + inherit index buildStamp; + guestPackages = guest.packages; + guestDevEnvironment = guest.devEnvironment; + inherit (self) nixosModules; + }; # Force every fleet node's toplevel and record what it resolved to, # WITHOUT building any of them. `unsafeDiscardStringContext` is what buys diff --git a/nix/ci/flake-gate.nix b/nix/ci/flake-gate.nix index 154be4646..d31e200a3 100644 --- a/nix/ci/flake-gate.nix +++ b/nix/ci/flake-gate.nix @@ -57,6 +57,29 @@ # bisection retries carry the same rate. `delta-gate.sh flake-rate` reports # this against the instability record. # +# The `bedwars-bow-e2e 11%` term is retired, and it was never a flake. +# The measurement stands as a measurement -- it is what those 18 runs did +# -- but what it measured was a check asserting on a packet that never +# described an impact, which passed or failed on whether a channel +# subscription happened to be answered before or after the arrow stopped. +# It read 11% here and ~75% two days later on the same tree for that +# reason. #1125 replaced the assertion; six forced-rebuild runs on +# e637f712 passed six times, with the per-run broadcast count varying 23 +# to 37, which is the same timing spread the old check was keyed on and +# the new one is not. Re-measure before quoting a rate for it. ENG-12085. +# +# Whoever re-measures LOCALLY: force every repeat. `nix build` of an +# already-successful check is answered from the store and does not run +# anything -- one sample in the six above came back rc=0 with three lines +# of log and had to be discarded -- so `--rebuild` on every repeat, and +# print the log length beside each result, or a stuck pass reads as a +# streak. Failures are not cached and do re-execute, which is exactly why +# an uncorrected count skews optimistic. Whether the same hazard reaches +# the CI-fed record below is untested: `dg_merge_instability` folds one +# run's results per CI run, and if that pipeline can substitute a check's +# output rather than build it, a substituted pass enters the record as a +# sample that never ran. Unverified, so not claimed -- ENG-12120. +# # 2. AT LEAST 30 RUNS in the instability record. P(a check flaking at rate q # is proven within n same-derivation samples) is 1 - q^n - (1-q)^n. At # n=18 an 11% flake is still 12% likely to be unproven; n=30 puts anything diff --git a/nix/e2e.nix b/nix/e2e.nix index 633e40962..183a6c967 100644 --- a/nix/e2e.nix +++ b/nix/e2e.nix @@ -142,19 +142,27 @@ let : "''${HYPERION_E2E_CLIENT:?the client script and its arguments}" : "''${HYPERION_E2E_CERTS:?a directory holding root_ca.crt and the two leaf pairs}" - # Unset means "find a free pair". A check has no reason to care which + # Unset means "find a free set". A check has no reason to care which # ports it uses and every reason not to collide: on Linux the sandbox # has its own network namespace, but on darwin there is no such thing # and a build shares the host's loopback with everything else on the # machine. A fixed 47565 lost that race to an unrelated server the first - # time this ran. Both sockets are held open until both numbers are read, - # so the pair cannot come back equal. + # time this ran. Every socket is held open until all the numbers are + # read, so no two can come back equal. + # + # Three and not two because a gate may ask for an operator console, which + # listens on a port of its own. Picked here rather than derived from one + # of the others -- `server_port + 1` is a free number right up until the + # run that finds it taken -- and picked whether or not this run wants + # one, so the held-open guarantee covers the same set every time. picked_player="" picked_server="" - if [ -z "''${HYPERION_PLAYER_PORT:-}" ] || [ -z "''${HYPERION_SERVER_PORT:-}" ]; then - read -r picked_player picked_server < <(python3 -c " + picked_console="" + if [ -z "''${HYPERION_PLAYER_PORT:-}" ] || [ -z "''${HYPERION_SERVER_PORT:-}" ] \ + || [ -z "''${HYPERION_CONSOLE_PORT:-}" ]; then + read -r picked_player picked_server picked_console < <(python3 -c " import socket - held = [socket.socket() for _ in range(2)] + held = [socket.socket() for _ in range(3)] for sock in held: sock.bind(('127.0.0.1', 0)) print(' '.join(str(sock.getsockname()[1]) for sock in held)) @@ -162,6 +170,7 @@ let fi player_port="''${HYPERION_PLAYER_PORT:-$picked_player}" server_port="''${HYPERION_SERVER_PORT:-$picked_server}" + console_port="''${HYPERION_CONSOLE_PORT:-$picked_console}" bind="''${HYPERION_E2E_BIND:-127.0.0.1}" certs="$HYPERION_E2E_CERTS" log="''${HYPERION_E2E_LOG:-$(mktemp -t hyperion-e2e.XXXXXX)}" @@ -174,6 +183,30 @@ let read -ra proxy <<< "$HYPERION_E2E_PROXY" read -ra client <<< "$HYPERION_E2E_CLIENT" + # An operator console, for the one gate whose question is about it. Off + # otherwise: a console is an admin port, and opening one on every gate + # would be a surface nothing else here needs. + # + # The server and the client are handed the same two facts from one place, + # because an address written down twice is the pair most likely to drift. + # Arrays rather than strings, so the absent case expands to no arguments + # at all rather than to one empty one. + game_server_console=() + client_console=() + if [ -n "''${HYPERION_E2E_CONSOLE:-}" ]; then + token_file="''${HYPERION_E2E_TOKEN_FILE:-$(mktemp -t hyperion-console-token.XXXXXX)}" + # Deliberately a token carrying `+` and `/`. Standard base64 emits + # both, an operator who generates one with `base64` gets them, and `+` + # in a query used to come back 401 because the decoder read it as a + # space. A token without them leaves that fix untested. + python3 -c " + import base64, os, sys + sys.stdout.write('+/' + base64.b64encode(os.urandom(18)).decode()) + " > "$token_file" + game_server_console=(--console-bind "$bind:$console_port" --console-token-file "$token_file") + client_console=(--console "$bind:$console_port" --token-file "$token_file") + fi + echo "stack log: $log" "''${game_server[@]}" \ @@ -181,6 +214,7 @@ let --root-ca-cert "$certs/root_ca.crt" \ --cert "$certs/server.crt" \ --private-key "$certs/server_private_key.pem" \ + ''${game_server_console[@]+"''${game_server_console[@]}"} \ < /dev/null >> "$log" 2>&1 & game_pid=$! @@ -258,6 +292,13 @@ let # rather than at once; a check has both already built. await_port "$server_port" "game server" "$game_pid" + # The console binds inside `init_game`, so a client that reaches for it + # before the server gets there sees connection refused and reports it as + # the console being broken. Waiting names the right thing instead. + if [ -n "''${HYPERION_E2E_CONSOLE:-}" ]; then + await_port "$console_port" "console" "$game_pid" + fi + # Best effort: a sandbox can hold a hard limit below this, and these # gates drive four clients rather than the few thousand bots it is for. ulimit -Sn ${fileDescriptors} || true @@ -279,7 +320,8 @@ let # both processes, and the next run would die on "address already in use". rc=0 client_log="$(mktemp -t hyperion-e2e-client.XXXXXX)" - python3 "''${client[@]}" --host 127.0.0.1 --port "$player_port" "$@" \ + python3 "''${client[@]}" --host 127.0.0.1 --port "$player_port" \ + ''${client_console[@]+"''${client_console[@]}"} "$@" \ 2>&1 | tee "$client_log" || rc=$? # A client that finished its checks proves nothing if the server died @@ -326,10 +368,21 @@ let proxy, client, clientArgs ? [ ], + # Extra arguments for the game server, after the five the driver always + # passes. For a gate whose question is about a server configured a + # particular way; `serverEnv` cannot answer it, because + # `hyperion_event_runner` reads the environment all-or-nothing and falls + # back to the command line the moment one variable is missing. + gameServerArgs ? [ ], # Environment for the game server process. A gate whose question needs a # server configured differently from the one the product ships says so # here, rather than the client inferring it. serverEnv ? { }, + # Start the game server with an operator console and tell the client + # where to find it. A boolean and not a number: the driver picks the port + # beside the other two, and a gate naming its own would be the fixed-port + # race this file already learned about once. + console ? false, needsGenMap ? false, timeout ? 300, }: @@ -365,7 +418,7 @@ let name: value: "export ${name}=${lib.escapeShellArg (toString value)}" ) serverEnv )} - export HYPERION_E2E_GAME_SERVER="${lib.getExe gameServer}" + export HYPERION_E2E_GAME_SERVER="${lib.getExe gameServer} ${lib.escapeShellArgs gameServerArgs}" export HYPERION_E2E_PROXY="${lib.getExe proxy}" export HYPERION_E2E_CLIENT="${clients}/tools/${client} ${lib.escapeShellArgs clientArgs}" export HYPERION_E2E_CERTS="${certs}" @@ -373,6 +426,12 @@ let # The binaries are already built, so anything past this is a hang # rather than a slow compile. export HYPERION_E2E_TIMEOUT=120 + ${lib.optionalString console '' + export HYPERION_E2E_CONSOLE=1 + # Named rather than left to `mktemp`, so a run that fails leaves + # the token beside the rest of the build's evidence. + export HYPERION_E2E_TOKEN_FILE="$NIX_BUILD_TOP/console-token" + ''} timeout ${toString timeout} hyperion-e2e-driver touch "$out" diff --git a/nix/fleet/README.md b/nix/fleet/README.md index 3a72187ba..b149a1284 100644 --- a/nix/fleet/README.md +++ b/nix/fleet/README.md @@ -26,6 +26,23 @@ ix apply .#hyperion-game .#hyperion-proxy-0 .#hyperion-proxy-1 .#hyperion-proxy- Change anything and run the same command again. Each VM is reused by name and switched in place, so only the units whose definition changed restart. +## What the fleet cost in the last 24 hours + +```sh +ix billing usage --since 24h --resource-prefix hyperion- +ix billing usage --since 7d --resource-prefix hyperion- --json +``` + +It prints dollars per VM plus a fleet total, and a remainder line for +everything else in the report, so a prefix that matches nothing is +distinguishable from a fleet that cost nothing. It needs a logged-in `ix` +and a provisioned billing account; without the account it fails with ix's +own error rather than printing zeros. + +`--resource-prefix` landed in ix `8d04237a` (2026-08-03); an older `ix` +rejects the flag. This replaced `nix/fleet/spend.py`, which did the same +filter in Python against the same `--json` output. + ## Build the fleet once, not once per VM The `nix build` line is what makes the apply finish in minutes instead of @@ -635,8 +652,8 @@ looking like it recovers the current one. ## Apply from a checkout of `main`, not from a branch -`nix build` and `ix apply` from a feature branch stamp that branch's commit into -`HYPERION_BUILD_REV`, and the game puts it on a boss bar in front of every +`nix build` and `ix apply` from a feature branch write that branch's commit into +`/etc/hyperion/build-rev`, and the game puts it on a boss bar in front of every player. A branch commit is not reachable from `main`, so `git show ` on a fresh clone returns nothing and the natural conclusion is that the clone is stale rather than that the stamp is meaningless. @@ -644,18 +661,175 @@ stale rather than that the stamp is meaningless. It happened on 2026-07-30: the live server advertised `33e0d33` for ten minutes. **Every other signal was green** -- apply exit 0, four `✓ ready`, `NRestarts=0`, the `drv^out` identity check matching, the endpoint serving. -Nothing surfaces this except reading the deployed wrapper: +Nothing surfaces this except reading the stamp on the host: ```sh -ix shell hyperion-game -- sh -c \ - 'E=$(systemctl cat hyperion-game-server.service | grep -o "/nix/store/[^ ]*-smash-0.1.0-stamped"); - grep -o "HYPERION_BUILD_[A-Z]*=.[^\x27]*." "$E/bin/smash"' +ix shell hyperion-game -- sh -c 'head -n1 /etc/hyperion/build-rev' ``` Treat that as a standing post-apply check, and confirm the rev is one `git merge-base --is-ancestor origin/main` accepts. ENG-11491 tracks making the apply refuse an unreachable rev rather than relying on the habit. +**The file is what the deploy wrote; the bar is what the server has read.** They +are the same as of the last reload or restart, and only then. A deploy whose +rules dylib did not move still reloads -- the stamp is one of the unit's reload +triggers for exactly this reason -- so in practice the two agree within a +second of the apply. If they disagree for longer, the reload was refused, and +`journalctl -u hyperion-game-server -p err` has the reason in the gate's own +words. + +## A dev machine in the fleet + +`hyperion-dev` is a fifth node and the only one that serves nothing. It exists +so that the machine hyperion is built on is described in the same evaluation as +the machines hyperion runs on -- the same argument the header makes for the +fleet living in this repository at all, one step further back. + +```sh +nix build .#hyperion-dev-system +ix apply .#hyperion-dev +ix shell hyperion-dev +``` + +Then, inside it, once: + +```sh +cd /work/ix +git clone https://github.com/hyperion-mc/hyperion +``` + +`/work/ix` is the platform's own workspace directory +(`ix.profiles.base.shellWorkspace.directory`): it is pre-created by a tmpfiles +rule and login shells land in it, so it is where a checkout is findable by +somebody who did not make it. **Nothing clones for you, deliberately.** A clone +is state rather than configuration -- activation would have to pick a revision, +and every later apply would then either fight your working copy or ignore it, +which is a worse contract than having no opinion. Reading is public and needs +no credential; pushing needs yours, forwarded from your own machine, which is +the point. + +The `nix build` line matters here for the same reason it does for the fleet +(see "Build the fleet once, not once per VM" above) and more so: this node's +closure carries a Rust toolchain the service nodes do not. + +### It is not in the fleet's network segment + +Every other node sets `ix.networking.groups = ["hyperion"]`, and that group is +the only thing keeping an unproxied client off the game server. This one +replaces it with `hyperion-dev`, so the box whose purpose is running code +nobody has reviewed yet has no route to the world. The build-and-push loop +needs none. + +Groups are joined at VM create, like `ipv4`, so this is not a property a +re-apply can change on a VM that already exists: moving this node between +segments is `ix rm` and apply again. + +### What the guest already has, and what it does not + +Nearly all of a dev box is answered by the platform, so `nix/fleet/dev.nix` +adds a compiler and little else. Checkable in one command rather than believed: + +```sh +nix eval --json .#nixosConfigurations.hyperion-dev.config --apply 'c: { + workspace = c.ix.profiles.base.shellWorkspace.directory; + git = c.programs.git.enable; + features = c.nix.settings.experimental-features; + substituters = c.nix.settings.substituters; +}' +``` + +which answers `/work/ix`, `true`, a feature list including `flakes` and +`ca-derivations`, and `cache.ix.dev` ahead of `cache.nixos.org`. That is what +this flake's `nixConfig` block asks of whatever evaluates it, already true +inside the guest, so the module restates none of it. + +**The `ix` CLI is not in there and cannot be added from this repository.** +index packages no `ix` binary, and the CLI's repository is private while this +one is public -- an unauthenticated `GET /repos/indexable-inc/ix` answers 404 +where `indexable-inc/index` answers 200 -- so a flake input naming it would +break evaluation for every outside contributor and for the gate that runs on +hosted runners. Type `ix` commands on your own machine against the VM. Anything +that needs the CLI *inside* the guest, such as an agent creating its own +sandbox, is out of reach until ENG-12081. + +**There is no RAM, CPU or disk knob to set.** A fleet node takes `modules`, +`deployment`, `tags`, `groups`, `dependsOn`, `replicas` and `updateStrategy`, +and none of those is a machine size; `ix apply` and `ix new` have no sizing +flag either. Measured rather than assumed, from a node of this fleet: + +```console +$ ix shell hyperion-game -- sh -c 'grep MemTotal /proc/meminfo; nproc' +MemTotal: 268435456 kB +64 +``` + +So sizing is the platform's answer and not a limit worth designing around here. + +### A temporary key, if something on the box needs the API + +The loop above needs no ix credential at all: `ix` runs on your machine, and +git pushes over yours. A key is only wanted when something *on* the box calls +the ix API directly -- an agent that wants its own sandboxes, say, which today +means the HTTP API rather than the CLI (ENG-12081 again). + +Mint it capped and narrow, store it, and attach it at create: + +```sh +ix keys create hyperion-dev-agent --limit 25 --scope vm:read,create --rate-limit 60 +ix secret set hyperion_dev_ix_key # reads the value from a hidden prompt +ix new --name hyperion-dev --group hyperion-dev \ + --secret-env hyperion_dev_ix_key=IX_API_KEY --no-shell +ix apply .#hyperion-dev +``` + +The order is not a style choice. **`ix apply` cannot attach a secret**: it has +no `--secret-env`, and this fleet's `deployment.secrets` would be read by the +deprecated `ix-fleet` alone rather than by the created VM -- the split +`lib/image/fleet.nix` draws between workflow keys and create-identity keys, and +the same class of silent drop as ENG-10846. A secret therefore reaches a VM at +create, which means creating with `ix new` and converging with `ix apply` +afterwards. Revoke the key when the box goes: `ix keys revoke ` is terminal +and takes every key beneath it with it. + +**None of those four commands has been run.** The node was added and evaluated, +no VM was created, and no key was minted; the sequence follows the CLI's own +contract (`ix apply` reuses a VM by name, `ix new` is the only path that +attaches a secret) rather than a run somebody watched. Expect to correct it the +first time, and correct it here. + +**That key is broader than it reads, and this is the reason to keep it +temporary.** `--scope` parses `RESOURCE:ACTIONS` and nothing else, so there is +no syntax for naming which VMs it covers; and even a token that carried the ids +would not be narrowed by them, because the conversion from a token's scopes to +the permission set the server checks drops `resource_ids` on the floor +(`crates/ix/server/src/acl/auth.rs`, both `parse_db_scopes` and +`auth_context_from_validated`). A `vm:read` key therefore reads **every VM the +account owns**, this fleet's four included, not only the dev box it was minted +for. ENG-12039. Until that lands, the controls that actually bound the damage +are the spend cap, the rate limit, and revoking the key when you are done with +the machine. + +### One node, no replicas, applied by name + +`replicas` says the proxies are interchangeable. A dev machine is the opposite: +it holds somebody's working copy, so a second one is a second person's box and +not another copy of this one. Whoever wants that adds a node with their own +name on it. + +Nothing stacks or scopes these per branch today. A `--stack` that gave each +branch its own copy of the fleet would change this section; there is no such +flag, so the fleet is applied by naming targets, and the dev node is applied on +its own when somebody wants it. Note the consequence of it being declared here: +a bare `ix apply .` converges every node this repository declares, which now +includes a dev box. Name your targets, which the commands above and at the top +of `nix/fleet/default.nix` already do. + +Finally, "Apply from a checkout of `main`, not from a branch" above still +holds, and a dev box makes it easier to get wrong: the checkout in `/work/ix` +is usually on a branch, and applying the *game* fleet from there stamps an +unreachable commit onto a boss bar in front of every player. + ## Checking the server answers: `mcping.py` moved It is `nix/fleet/mcping.py` in this repository. **The old path, diff --git a/nix/fleet/default.nix b/nix/fleet/default.nix index 78cfff893..0572f3092 100644 --- a/nix/fleet/default.nix +++ b/nix/fleet/default.nix @@ -10,6 +10,12 @@ # # Applied by hand. There is no CI deploy and no automatic apply, deliberately. # +# `hyperion-dev` is declared here too and is not in either line above: it is a +# machine to build on, not part of serving the game, and it is applied on its +# own when somebody wants it (README.md, "A dev machine in the fleet"). Naming +# targets is what keeps those separate -- a bare `ix apply .` converges every +# node this file declares, which now includes a dev box nobody asked for. +# # --------------------------------------------------------------------------- # WHY THIS LIVES HERE, AND WHY IT IS NOT ITS OWN FLAKE # --------------------------------------------------------------------------- @@ -48,6 +54,14 @@ guestPackages, # This repo's own service modules. No input, no pin -- that is the point. nixosModules, + # What `devShells.default` installs, for the dev node to install too. One + # binding rather than two lists, so a VM built to develop hyperion on cannot + # end up with a different compiler than `nix develop` hands a contributor. + guestDevEnvironment, + # What commit this is, and when it was made. Lands in `/etc/hyperion` on the + # game node rather than in the unit, which is what lets a deploy change the + # build without restarting the server. See flake.nix. + buildStamp, }: index.lib.mkFleet { # One private segment. A VM outside it has no route to the game server, @@ -56,8 +70,16 @@ index.lib.mkFleet { {ix.networking.groups = ["hyperion"];} { _module.args = { - hyperionGameServer = guestPackages.smash; + # `smash-server` and not `smash`: the latter is a `cargoUnit` build that + # links nothing from the workspace dynamically, so a rules dylib loaded + # into it would get its own component-index pool. Only this pair shares + # one engine image. See nix/hot-reload/packaging.nix. + hyperionGameServer = guestPackages.smash-server; + hyperionRules = guestPackages.smash-rules; + hyperionReloadClient = guestPackages.hyperion-dylibs; hyperionProxy = guestPackages.hyperion-proxy; + hyperionDevEnvironment = guestDevEnvironment; + inherit buildStamp; }; } ./pki.nix @@ -68,6 +90,17 @@ index.lib.mkFleet { nodes = { "hyperion-game".modules = [./game.nix]; + # One, and no `replicas`. The proxies are interchangeable and a digit says + # so; a dev machine is the opposite -- it holds somebody's working copy, so + # a second one is a second person's box rather than another copy of this + # one. Whoever needs that adds a node with their own name on it. + # + # It is in the fleet rather than beside it so that one evaluation covers + # the machine hyperion is built on and the machines it runs on, which is + # the same argument the header makes for the fleet living in this repo at + # all. `./dev.nix` says what it deliberately leaves to the platform. + "hyperion-dev".modules = [./dev.nix]; + # Interchangeable, and `replicas` is how that is said rather than implied: # three copy-pasted node entries would let one drift from its siblings. # Raising the digit adds a `-system` attr and an apply target, not a node diff --git a/nix/fleet/dev.nix b/nix/fleet/dev.nix new file mode 100644 index 000000000..8fee59a51 --- /dev/null +++ b/nix/fleet/dev.nix @@ -0,0 +1,118 @@ +# A machine to build hyperion on, declared in the fleet rather than kept beside +# it. It serves nothing, no player reaches it, and the game does not depend on +# it -- it exists so that "where do I build this" has the same answer as "where +# is this deployed", written in one file a reviewer can read. +# +# The loop it is for is in README.md ("A dev machine in the fleet"): apply it, +# `ix shell` in, edit and build on a Linux box that already has the platform's +# caches, push over your own forwarded credentials. +# +# --------------------------------------------------------------------------- +# WHAT THIS FILE DELIBERATELY DOES NOT DO +# --------------------------------------------------------------------------- +# +# Most of a dev box is already answered by the platform, and restating an +# answer is how two copies of it start to disagree. Named here so the next +# reader can check the claim rather than re-derive it, and so nobody adds a +# second copy believing it was missing: +# +# - Nix is already configured for this repository. `lib/image/platform.nix` +# sets `experimental-features` to a list including `flakes`, `nix-command` +# and `ca-derivations`, and `modules/profiles/base` puts `cache.ix.dev` in +# `substituters` with its `ix-workspace:` key trusted. That is exactly what +# this flake's own `nixConfig` block asks of the machine evaluating it, so +# a guest copy of those settings would buy nothing and could drift. +# +# - git is already installed, for every user rather than for one profile +# (`modules/profiles/base`, which also leaves a fallback identity so a +# first `git commit` in a fresh VM does not die on a missing email). +# +# - The working directory is already a convention: `/work/ix`, from +# `ix.profiles.base.shellWorkspace.directory`. The platform pre-creates it +# with a tmpfiles rule and login shells cd into it, so a second directory +# declared here would be one the shell does not land in. The checkout goes +# there. +# +# NOTHING CLONES THE REPOSITORY INTO IT, and that is a choice rather than an +# omission. A clone is state, not configuration: activation would have to pick +# a revision, and every later `ix apply` would then either fight the working +# copy or silently leave it alone, which is a worse contract than having no +# opinion at all. Reading this repository needs no credentials -- it is public +# -- but pushing needs the developer's own, and those are deliberately theirs +# and not the VM's. So: `git clone` once, by hand, into `/work/ix`. +# +# THE `ix` CLI IS NOT HERE, and cannot be added from this repository today. +# index packages no `ix` binary (its `packages/` tree has `ix-fleet`, +# `ix-credential`, `ix2nix`, and no CLI), and the CLI's own repository is +# private: an unauthenticated `GET /repos/indexable-inc/ix` answers 404 while +# the same call for `indexable-inc/index` answers 200. hyperion is public and +# its CI installs stock Nix, so a private flake input would break evaluation +# for every outside contributor and for the gate. The consequence to plan +# around: `ix` commands are typed on your machine against this VM, never from +# inside it, so anything needing the CLI in the guest -- a nested VM, an `ix +# apply` from the dev box -- is out of reach here. ENG-12081. +# +# THERE IS NO RAM OR DISK KNOB TO SET, which is why this file sets none. A +# fleet node takes `modules`, `deployment`, `tags`, `groups`, `dependsOn`, +# `replicas` and `updateStrategy` (`lib/image/fleet.nix`) and none of those +# describes a machine size; neither `ix apply` nor `ix new` carries a sizing +# flag either. Sizing belongs to the platform, and measured from inside a node +# of this fleet it is not the binding constraint anyway: +# +# $ ix shell hyperion-game -- sh -c 'grep MemTotal /proc/meminfo; nproc' +# MemTotal: 268435456 kB +# 64 +{ + config, + lib, + hyperionDevEnvironment, + ... +}: { + # Its own east-west segment, NOT the fleet's. `default.nix` puts every node in + # `hyperion`, and that group is the only thing keeping an unproxied client off + # the game server -- so the one box in this fleet whose purpose is running + # code nobody has reviewed yet is the one box that belongs outside it. The + # loop this node serves is build-and-push, which needs no route to the game. + # + # `mkForce` because `groups` is a `listOf str`: without it the fleet default + # merges in rather than being replaced. The slug is get-or-created under the + # deploying user at create, so it needs no separate setup. + # + # Groups are joined AT CREATE (`lib/image/platform.nix`), like `ipv4`, so this + # is not something a re-apply changes on a VM that already exists: moving this + # node between segments is `ix rm` and apply again. + ix.networking.groups = lib.mkForce ["hyperion-dev"]; + + # `./pki.nix` is a fleet default and gives `serverName` no default, so every + # node has to answer it -- including one that runs neither hyperion service. + # Answering it and then not minting is the honest pair: the unit exists to + # hand the two services their mutual-TLS material, nothing here reads that + # material, and the authority is the public throwaway committed next to this + # file, so a certificate minted here would attest to nothing. + hyperion.pki.serverName = "${config.ix.networking.eastWest.hostName}.ix.internal"; + systemd.services.hyperion-pki.enable = false; + + # The build environment, taken from the flake's own `devShells.default` + # rather than restated: `hyperionDevEnvironment` is the same binding that + # shell installs, so `nix develop` on a laptop and a login shell on this VM + # cannot disagree about which compiler builds hyperion. The channel under it + # is `rust-toolchain.toml`, which is where the version lives for rustup users + # and for CI too. + # + # x86_64-linux for the reason `guestPackages` is: this is a Linux guest + # whatever machine types `nix build`, which contributes a builder rather than + # an identity. + environment = { + systemPackages = hyperionDevEnvironment.packages; + # What rust-analyzer resolves `std` sources through. The devShell exports + # the same string; a shell with the compiler but not this one gives + # go-to-definition that lands nowhere. + variables.RUST_SRC_PATH = hyperionDevEnvironment.rustSrcPath; + }; + + # No health check, deliberately. The two service nodes declare theirs because + # a process can be running and unable to serve, a distinction only a probe + # makes. Nothing here serves: this node is ready when it boots, and a check + # re-asking a compiler for its version every interval would be probing a + # store path that cannot change without a switch. +} diff --git a/nix/fleet/game.nix b/nix/fleet/game.nix index 66bd62ef7..e17637307 100644 --- a/nix/fleet/game.nix +++ b/nix/fleet/game.nix @@ -2,7 +2,11 @@ # group, and a VM outside the group has no route here at all. { config, + lib, hyperionGameServer, + hyperionRules, + hyperionReloadClient, + buildStamp, ... }: let port = 35565; @@ -43,6 +47,22 @@ in { services.hyperion-game-server = { enable = true; package = hyperionGameServer; + event = "smash"; + + # The one store path that is allowed to move on a rules-only deploy. The + # module puts it in `X-Reload-Triggers` and nowhere else, so a build that + # changes only this reaches the running server as `systemctl reload` and + # nobody is disconnected. Everything else -- a component's layout, the + # engine -- moves `ExecStart` instead and is a restart, which is correct: a + # system compiled against a layout the world no longer holds is memory + # corruption rather than a stale build. + rules = "${hyperionRules}/lib/${hyperionRules.dylibName}"; + reloadClient = hyperionReloadClient; + + buildStamp = { + inherit (buildStamp) rev committedAt dirty; + }; + inherit port; pki = { rootCaCert = "/var/lib/hyperion-pki/root_ca.crt"; diff --git a/nix/generate-collision-shapes.py b/nix/generate-collision-shapes.py new file mode 100644 index 000000000..67c8f8c8b --- /dev/null +++ b/nix/generate-collision-shapes.py @@ -0,0 +1,238 @@ +"""Generate the block collision shape table for hyperion-minecraft-proto. + +The input is `collision-shapes.json`, which `nix/java/VanillaShapes.java` reads +out of the server jar by calling `getCollisionShape` on every state. See that +file for why the game itself is the only source: a collision shape is a +`VoxelShape` constant compiled into a block class, and Mojang's data generator +does not carry it. + +Two arrays come out. `SHAPES` holds each distinct box list once, because 32366 +states share 326 shapes -- every full cube is the same unit box whatever block +it belongs to. `STATE_SHAPES` is one index per state id, so a lookup is two +loads and no search. + +Every coordinate the game produces is a multiple of 1/32, so writing them as +`f32` is exact rather than rounded. That is re-checked on every run: a version +introducing a finer coordinate fails the build instead of quietly moving a +block surface. +""" + +from __future__ import annotations + +import argparse +import json +import re +import struct +import textwrap +from pathlib import Path + +HEADER = """\ +// @generated by nix/generate-collision-shapes.py from Minecraft {version} (protocol {protocol}). +// Do not edit by hand. Regenerate with: nix run .#sync-minecraft-collision-shapes +""" + +PREAMBLE = ''' +//! Block collision shapes for Minecraft {version}. +//! +//! What an entity stops against. A block state's shape is a list of +//! axis-aligned boxes in the block's own coordinates, where a full cube is +//! `[0, 0, 0, 1, 1, 1]` and a bottom slab is `[0, 0, 0, 1, 0.5, 1]`; a state +//! with no boxes at all -- air, a torch, tall grass -- is passed through. +//! +//! The table is read out of the server jar rather than out of Mojang's data +//! generator, which describes a state as an id and a property map and stops +//! there. A collision shape is a `VoxelShape` constant compiled into each +//! block class, so the only thing that can answer is the running game: +//! `nix/java/VanillaShapes.java` calls `getCollisionShape` on all {states} of +//! them, and this file is what it said. +//! +//! # {shape_count} shapes for {states} states +//! +//! Distinct box lists are stored once in [`SHAPES`] and [`STATE_SHAPES`] gives +//! each state's index into it, because the states repeat themselves heavily: +//! {full_cube} of them are the same unit cube and {empty} have no boxes at +//! all. So a lookup is two loads and no search, and the table holds {boxes} +//! boxes rather than {total_boxes}. +//! +//! # Precision +//! +//! The game computes these as `double`s, and every value it produces is a +//! multiple of 1/32, so all {distinct} of them are exactly representable in +//! `f32` and this table loses nothing by storing them that way. The generator +//! re-checks that on every run, so a version introducing a coordinate that +//! needs more precision fails the build rather than rounding it silently. + +/// One collision box, in the block's own coordinates: +/// `[min_x, min_y, min_z, max_x, max_y, max_z]`. +/// +/// A few shapes reach outside the unit cube -- a wall's post is 1.5 high and a +/// big dripleaf's stem starts at -0.25 -- so a consumer that assumes `0..=1` +/// is assuming something the game does not. +pub type CollisionBox = [f32; 6]; + +/// The collision boxes of the block state with network id `state_id`. +/// +/// `None` for an id this version does not have. For a caller holding an id +/// from [`crate::block_state`] that cannot happen, and would mean the two +/// tables came from different jars. +#[must_use] +pub fn collision_shape(state_id: u32) -> Option<&'static [CollisionBox]> {{ + let index = usize::try_from(state_id).ok()?; + let shape = *STATE_SHAPES.get(index)?; + Some(SHAPES[usize::from(shape)]) +}} + +// The two tables describe one registry read out of one jar, so a disagreement +// about how many states it has means one of them was regenerated and the other +// was not. +const _: () = assert!( + STATE_SHAPES.len() == crate::block_state::STATE_COUNT as usize, + "the collision shape table and the block state table disagree about how many states exist" +); + +/// Every distinct box list, referenced by index from [`STATE_SHAPES`]. +pub static SHAPES: &[&[CollisionBox]] = &[ +{shape_table}]; + +/// Every state's index into [`SHAPES`], indexed by network id. +pub static STATE_SHAPES: &[u16] = &[ +{state_table}]; +''' + + +def number(value: float) -> str: + """A Rust `f32` literal that round-trips to exactly `value`.""" + if struct.unpack("f", struct.pack("f", value))[0] != value: + raise SystemExit( + f"{value!r} is not exactly representable in f32, so the table would " + f"round a block surface; CollisionBox needs to become [f64; 6]" + ) + text = repr(float(value)) + return text if "." in text or "e" in text else f"{text}.0" + + +def reflow(text: str) -> str: + """Re-wrap the module doc comment, which the substitutions above unwrap. + + The counts are what the prose is about -- "326 shapes for 32366 states" -- + so they are interpolated into sentences rather than tacked on, and a + version whose numbers are a different width would otherwise leave the + paragraph ragged. Only `//!` lines are touched; the item docs below carry + no substitutions and are already wrapped as written. + """ + lines = text.split("\n") + out: list[str] = [] + paragraph: list[str] = [] + + def flush() -> None: + if not paragraph: + return + # A break inside an inline code span reads as two spans, so the + # spaces inside backticks are hidden from the wrapper and restored + # after it. + joined = re.sub( + r"`[^`]*`", lambda m: m.group(0).replace(" ", "\x00"), " ".join(paragraph) + ) + wrapped = textwrap.wrap( + joined, width=76, break_long_words=False, break_on_hyphens=False + ) + out.extend(f"//! {line}".replace("\x00", " ") for line in wrapped) + paragraph.clear() + + for line in lines: + if not line.startswith("//!"): + flush() + out.append(line) + continue + body = line[3:].strip() + # A blank line ends a paragraph and a heading is its own line; anything + # else joins the paragraph being built. + if not body: + flush() + out.append("//!") + elif body.startswith("#"): + flush() + out.append(f"//! {body}") + else: + paragraph.append(body) + flush() + return "\n".join(out) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--shapes", type=Path, required=True) + parser.add_argument("--version", required=True) + parser.add_argument("--protocol", required=True) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + + data = json.loads(args.shapes.read_text()) + shapes = data["shapes"] + state_shapes = data["stateShapes"] + + if data["minecraftVersion"] != args.version: + raise SystemExit( + f"the shape dump is Minecraft {data['minecraftVersion']} but the " + f"pin is {args.version}" + ) + if len(state_shapes) != data["stateCount"]: + raise SystemExit( + f"{len(state_shapes)} state entries for a stateCount of " + f"{data['stateCount']}" + ) + # The indices below are `u16`. 65536 distinct shapes is far beyond the few + # hundred the game has, but the failure would be a silently truncated index + # pointing at the wrong shape, so it is checked rather than assumed. + if len(shapes) > 0xFFFF: + raise SystemExit(f"{len(shapes)} distinct shapes does not fit a u16 index") + for index in state_shapes: + if not 0 <= index < len(shapes): + raise SystemExit(f"state shape index {index} is outside the shape table") + + # A shape's identity is its box list, so two equal lists mean the extractor + # stopped deduplicating -- which costs nothing but says the dump is not + # what this file claims it is. + keys = {tuple(tuple(box) for box in shape) for shape in shapes} + if len(keys) != len(shapes): + raise SystemExit( + f"{len(shapes)} shapes with only {len(keys)} distinct box lists; " + f"the dump is not deduplicated" + ) + + # Stone and air. Both exist in every version, and counting the states that + # share them is what makes the deduplication claim in the docs checkable. + unit_cube = [[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]] + if unit_cube not in shapes or [] not in shapes: + raise SystemExit( + "the shape table has no unit cube or no empty shape, which every " + "version has" + ) + + distinct = {value for shape in shapes for box in shape for value in box} + shape_table = "".join( + " &[{}],\n".format( + ", ".join("[{}]".format(", ".join(number(v) for v in box)) for box in shape) + ) + for shape in shapes + ) + state_table = "".join(f" {index},\n" for index in state_shapes) + + text = HEADER.format(version=args.version, protocol=args.protocol) + text += reflow(PREAMBLE.format( + version=args.version, + states=len(state_shapes), + shape_count=len(shapes), + boxes=sum(len(shape) for shape in shapes), + total_boxes=sum(len(shapes[index]) for index in state_shapes), + full_cube=state_shapes.count(shapes.index(unit_cube)), + empty=state_shapes.count(shapes.index([])), + distinct=len(distinct), + shape_table=shape_table, + state_table=state_table, + )) + args.out.write_text(text) + + +if __name__ == "__main__": + main() diff --git a/nix/hot-reload/packaging.nix b/nix/hot-reload/packaging.nix new file mode 100644 index 000000000..859b40e55 --- /dev/null +++ b/nix/hot-reload/packaging.nix @@ -0,0 +1,354 @@ +# Packaging for hot reload: one cargoUnit graph, three sets of store paths that +# move independently. +# +# The whole feature rests on one property. `nix/modules/game-server.nix` puts +# the rules dylib in `X-Reload-Triggers` and the server binary in `ExecStart`, +# and nixpkgs' unit handling reloads a unit whose `[Service]` section is +# unchanged and whose reload triggers differ. So a rules edit may move the rules +# dylib and must not move the server binary. If it moves both, the deploy +# degrades to a restart, every player is dropped, and every gate stays green -- +# which is why `checks.hot-reload-source-split` asserts the split instead of +# trusting this file to be right. +# +# hyperion-dylibs moves on an engine change +# -server moves on a component or engine change +# -rules moves on a rules, component or engine change +# +# Nobody has to remember the rule. A component's layout lives in the host crate +# and a system's body lives in the rules crate, so the source split is the rule. +# +# ## Why this is cargoUnit and not cargo (ENG-12078) +# +# It used to be three `runCommandCC` derivations, each running its own +# `cargo build` over a hand-filtered source tree in which every crate the +# derivation did not want was replaced by an empty `lib.rs`. That is how you get +# per-derivation source scoping out of a build system that has none, and it cost +# a whole class of hazard to operate: +# +# - Cargo resolves features over the packages named on the command line, so +# the three invocations had to pass one identical `-p` selection string or +# `flecs_ecs` got a different `-C metadata` in each -- two `libflecs_ecs` +# in one process, which is two component-index pools indexing one world two +# different ways (ENG-12053). +# - RUSTFLAGS folds into every unit's `-C metadata` too, so the rpaths had to +# be applied with `patchelf` after linking, because putting a store path in +# RUSTFLAGS recompiled the graph under different mangled symbol names. +# - The stub trees had to be cleaned out of the shared `target/` seed by hand, +# or a stub `libsmash_rules.so` shipped beside the engine. +# +# cargoUnit deletes all three. It is one derivation per rustc invocation with +# per-crate source scoping, so "which paths move" is a fact about the unit graph +# rather than something this file arranges. Features are resolved once for the +# whole workspace, so there is exactly one `flecs_ecs` unit by construction -- +# `engineDylib` asserts that rather than assuming it. And `-C metadata` comes +# from cargoUnit's own graph identity hash rather than from the flags it passes, +# so an rpath cannot perturb a symbol name. +# +# `smash` is the first consumer, not the shape (ENG-12067). One engine dylib set +# is shared by every event; each event named in `events` gets its own server and +# rules pair. +# +# See docs/hot-reload.md, "Packaging: three derivations, because two would +# restart", for the measurements behind this. +{ + lib, + pkgs, + cargoUnit, + # The same `src`/lock/vendor arguments the ordinary release workspace is built + # from, so the two read one definition and cannot drift. + workspaceArgs, + rustToolchain, + root, + # Each entry is one game: `{ name; hostCrate; rulesCrate; }`, where the two + # crates are workspace-member paths. `name` is what the NixOS module keys on + # (`/etc/hyperion/-rules.so`) and what the derivations are called. + events, +}: +let + # Package name, not directory basename: the two are only equal by convention. + packageNameOf = member: (lib.importTOML (root + "/${member}/Cargo.toml")).package.name; + # cargo spells a library after the crate name, which is the package name with + # dashes folded to underscores. + libNameOf = package: lib.replaceStrings [ "-" ] [ "_" ] package; + + sysrootLib = "${rustToolchain}/lib/rustlib/${pkgs.stdenv.hostPlatform.rust.rustcTarget}/lib"; + inherit (pkgs.stdenv.hostPlatform) extensions; + + # What `ExecReload` runs. Named once so the NixOS module and this file cannot + # disagree about it. + reloadClient = "hyperion-reload-client"; + + # The engine crates whose dylib every event resolves at run time. + # + # `hyperion` and `hyperion-hot-reload` declare `crate-type = ["rlib", + # "dylib"]` themselves; `flecs_ecs` is the fork pinned in Cargo.toml, patched + # for the same reason. It is named here rather than discovered because being + # in this list is a decision -- a crate joins it by getting a dylib crate type, + # and that is a deliberate act with a comment in its Cargo.toml. + enginePackages = [ + "hyperion" + "hyperion-hot-reload" + "flecs_ecs" + ]; + # `hyperion-reload-client` is deliberately NOT in that list, though the + # cargo-based packaging did put it there. Its reason was the selection string: + # every package had to be named in one `cargo build` or `flecs_ecs` got a + # different `-C metadata` per invocation. There is one invocation now, so that + # reason is gone, and the list means what it says -- crates whose *dylib* an + # event resolves at run time. The reload client is a dependency-free binary + # with no dylib, so `engineUnit` would find nothing to gather. It ships from + # the same output for the reason below. + + # The build every hot-reload artifact comes out of. Separate from the ordinary + # release workspace on purpose: `-C prefer-dynamic` changes how every artifact + # links, and hyperion's other packages are static binaries that should stay + # that way. + # + # `-C prefer-dynamic` is the load-bearing flag and cargoUnit cannot infer it. + # Generating a dylib without it makes rustc statically absorb every dependency + # into that dylib, and linking an executable without it makes rustc prefer the + # rlib of a crate offering both -- either way the server binary and the rules + # dylib each get their own `flecs_ecs`, and the one thing hot reload cannot + # tolerate is two component-index pools in one process. + # `checks.hot-reload-index-probe` is what proves it holds; this is the flag. + # + # The sysroot rpath is its consequence: prefer-dynamic makes libstd dynamic + # too, so a linked artifact asks the loader for `libstd-.so` and finds + # nothing without this. cargoUnit adds the rpaths for the dylibs inside the + # graph, whose store paths only it knows. + # + # Unlike the RUSTFLAGS string this replaces, neither flag reaches `-C + # metadata`: cargoUnit derives that from the unit graph, not from the rustc + # arguments it passes. An rpath here cannot rename a symbol. + workspaceFor = + src: + cargoUnit.buildWorkspace ( + workspaceArgs + // { + inherit src; + workspaceRoot = src; + pname = "hyperion-hot-reload"; + cargoArgs = [ "--workspace" ]; + extraRustcArgs = [ "-Cprefer-dynamic" ]; + extraLinkRustcArgsForPlatform = _platform: [ "-Clink-arg=-Wl,-rpath,${sysrootLib}" ]; + } + ); + + # A unit key is `--<16 hex>`. Matching that shape rather + # than a bare prefix keeps `flecs_ecs-build-script-run-...` out of the result. + unitsNamed = + workspace: libName: + lib.filter ( + name: builtins.match "${lib.escapeRegex libName}-[0-9][^-]*-[0-9a-f]{16}" name != null + ) (lib.attrNames workspace.units); + + # The single unit that produces one engine crate's shared library. + # + # The `== 1` is the guard, not a defensive length check. Two units for one + # crate is precisely the ENG-12053 failure -- two `libflecs_ecs`, two + # `INDEX_POOL`s, a reload that indexes one world two different ways -- and + # under one feature resolution for the whole workspace it cannot happen. If it + # ever does, this stops the build and names the crate instead of shipping a + # server that corrupts on reload. + engineUnit = + workspace: package: + let + libName = libNameOf package; + matches = unitsNamed workspace libName; + in + if lib.length matches == 1 then + workspace.units.${lib.head matches} + else + throw '' + hot-reload packaging: expected exactly one `${libName}` unit in the graph, found ${toString (lib.length matches)}: + ${lib.concatStringsSep "\n " matches} + More than one means the workspace resolved that crate two ways, which is two + copies of its process-global state in one server process (ENG-12053). None + means the crate is not in the graph under that name. + ''; + + # Every `DT_NEEDED` has to resolve, and the build is the place to find out. + # + # This is the guard that would have caught ENG-12053 the day it was written + # instead of at `ldd` time on a dev box: an artifact asking for a + # `libflecs_ecs` nobody ships is a dangling reference, and a dangling + # reference to THAT library specifically means two component-index pools. + # Written to fail closed. The obvious spelling -- `if ldd f | grep "not + # found"` -- passes when `ldd` is absent or errors, because then grep matches + # nothing and the guard's success and its own breakage are the same signal. + # So: require ldd to succeed, require it to have said something, and only then + # look for the failure string. + requireResolved = + file: + lib.optionalString pkgs.stdenv.hostPlatform.isElf '' + if ! ldd ${file} > needed.txt 2> ldd-err.txt; then + echo "ldd could not read ${file}; this guard cannot run:" >&2 + cat ldd-err.txt >&2 + exit 1 + fi + if [ ! -s needed.txt ]; then + echo "ldd reported no libraries at all for ${file}." >&2 + echo "A prefer-dynamic artifact always names at least libstd, so this is the" >&2 + echo "guard breaking rather than the artifact being clean." >&2 + exit 1 + fi + if grep "not found" needed.txt; then + echo "unresolved libraries in ${file}; see ENG-12053" >&2 + exit 1 + fi + ''; + + # Symlinks rather than copies, so the bytes of a shared library exist at + # exactly one store path however many places name it. "One libflecs_ecs" is + # then a fact about the store and not a claim about this file. + packagingFor = + src: + let + workspace = workspaceFor src; + + hyperion-dylibs = + # `runCommandCC`, not `runCommand`: `requireResolved` below needs `ldd`, + # which comes with the C toolchain. The fail-closed rewrite of that guard + # is what turned this into a build failure naming `ldd: command not + # found` rather than a silent pass -- `if ldd f | grep "not found"` + # succeeds when `ldd` is missing, because then grep matches nothing. + pkgs.runCommandCC "hyperion-dylibs" + { + # No patchelf. The old packaging rewrote rpaths after linking, + # because a store path in RUSTFLAGS changed every unit's + # `-C metadata`; cargoUnit derives that from the unit graph instead, + # so the rpaths are ordinary link args and nothing is rewritten. + passthru.units = lib.genAttrs enginePackages (engineUnit workspace); + } + '' + mkdir -p "$out/bin" "$out/lib" + + # The reload client ships from here, with the engine, and not from + # an event's derivation. `ExecReload` names it, and `[Service]` + # moving is exactly what turns a reload into a restart -- so the + # path in it must not be a function of any event's source. This + # output moves only on an engine change, which moves `ExecStart` too + # and is a restart anyway. + ln -s ${ + workspace.binaries.${reloadClient} or (throw '' + hot-reload packaging: the workspace has no binary `${reloadClient}`. + Got: ${lib.concatStringsSep ", " (lib.attrNames workspace.binaries)} + '') + }/bin/${reloadClient} "$out/bin/${reloadClient}" + ${requireResolved ''"$out/bin/${reloadClient}"''} + + for unit in ${lib.escapeShellArgs (map (package: engineUnit workspace package) enginePackages)}; do + for shared in "$unit"/lib/*${extensions.sharedLibrary}; do + [ -e "$shared" ] || continue + ln -s "$shared" "$out/lib/" + done + done + # An engine crate that stopped emitting a shared library would leave + # this empty and every event would silently link its own static copy. + if [ -z "$(ls -A "$out/lib")" ]; then + echo "no engine shared libraries found; is prefer-dynamic still set?" >&2 + exit 1 + fi + ''; + + forEvent = + event: + let + hostPackage = packageNameOf event.hostCrate; + rulesPackage = packageNameOf event.rulesCrate; + rulesLib = "lib${libNameOf rulesPackage}${extensions.sharedLibrary}"; + + rulesUnit = + workspace.libraries.${libNameOf rulesPackage} or (throw '' + hot-reload packaging: the workspace has no library target `${libNameOf rulesPackage}`. + Got: ${lib.concatStringsSep ", " (lib.attrNames workspace.libraries)} + ''); + + # What `ExecStart` names. Moves on a component or engine change, which + # is exactly when a restart is correct: a component's layout is defined + # in the host crate, and a system compiled against a layout the world no + # longer holds is memory corruption rather than a stale build. + server = + pkgs.runCommandCC "${event.name}-server" + { + meta.mainProgram = hostPackage; + passthru.unit = workspace.binaries.${hostPackage}; + } + '' + mkdir -p "$out/bin" + ln -s ${workspace.binaries.${hostPackage}}/bin/${hostPackage} "$out/bin/${hostPackage}" + ${requireResolved ''"$out/bin/${hostPackage}"''} + ''; + + # What `X-Reload-Triggers` names, and the only path a rules-only change + # is allowed to move. Renamed to the unhashed spelling the NixOS module + # installs as `/etc/hyperion/-rules.so`; cargoUnit's own filename + # carries the unit hash, which would put a build identity in a path an + # operator reads. + rules = + pkgs.runCommandCC "${event.name}-rules" + { + # passthru rather than a convention a consumer has to know: + # cargo spells the file after the crate, and `nix/fleet` would + # otherwise be a second place that has to agree with cargo + # about it. + passthru = { + dylibName = rulesLib; + unit = rulesUnit; + }; + } + '' + mkdir -p "$out/lib" + shared=$(echo ${rulesUnit}/lib/lib${libNameOf rulesPackage}-*${extensions.sharedLibrary}) + if [ ! -f "$shared" ]; then + echo "the ${rulesPackage} unit produced no shared library:" >&2 + ls -la ${rulesUnit}/lib >&2 + echo "A rules crate must be crate-type = [\"dylib\"]." >&2 + exit 1 + fi + ln -s "$shared" "$out/lib/${rulesLib}" + ${requireResolved ''"$out/lib/${rulesLib}"''} + ''; + in + { + inherit server rules rulesLib; + }; + # The shared-pool probe, built from the units the server actually links + # rather than from a second compile with hand-written RUSTFLAGS. + # + # It used to be `crates/hyperion-hot-reload/index-probe.sh`: a dev-profile + # `cargo build` with its own prefer-dynamic string and three rpaths into + # `target/`. That proved the property for a build nothing shipped. These + # are the same derivations the server and the rules dylib resolve, so a + # PROBE_OK here is a statement about the artifacts that go to a host. + indexProbe = { + host = + workspace.binaries.hot-reload-index-probe or (throw '' + hot-reload packaging: the workspace has no binary `hot-reload-index-probe`. + Got: ${lib.concatStringsSep ", " (lib.attrNames workspace.binaries)} + ''); + module = + workspace.libraries.hyperion_hot_reload_index_probe_module or (throw '' + hot-reload packaging: the workspace has no library `hyperion_hot_reload_index_probe_module`. + Got: ${lib.concatStringsSep ", " (lib.attrNames workspace.libraries)} + ''); + }; + in + { + inherit + hyperion-dylibs + indexProbe + reloadClient + workspace + ; + # Keyed by event name so a consumer names the game rather than a path. + events = lib.listToAttrs (map (event: lib.nameValuePair event.name (forEvent event)) events); + }; +in +packagingFor workspaceArgs.src +// { + # The source split, as a function of an arbitrary source tree, so a check can + # instantiate this packaging over a perturbed one and compare. See + # `checks.hot-reload-source-split`. + inherit packagingFor; +} diff --git a/nix/java/VanillaShapes.java b/nix/java/VanillaShapes.java new file mode 100644 index 000000000..4433fafe7 --- /dev/null +++ b/nix/java/VanillaShapes.java @@ -0,0 +1,165 @@ +// Dumps every block state's collision shape, straight out of the server's own +// code. +// +// This exists because the shapes are not data anywhere else. Mojang's data +// generator (`--reports`) describes a block state as an id and a property map +// and stops there -- 1196 blocks in 26.2 and not one mention of collision -- +// because a collision shape is a `VoxelShape` constant compiled into each +// block class, not an entry in a table. The only way to read it is to ask the +// game, which is what this does. +// +// The alternative it replaces was `valence_generated`'s checked-in +// `extracted/blocks.json`. That file is Minecraft 1.20.1: 1003 blocks and +// 24135 states against this jar's 1196 and 32366, so an arrow was clipped +// against 1.20.1's shapes while the client watching it ran 26.2. +// +// Measured, that cost less than it sounds. Of the 24135 states a 1.20.1 world +// can hold, exactly one has different geometry in 26.2, and it is one no world +// contains -- the `shapes_changed_since_1_20_1` test in hyperion's +// `simulation/blocks/translate.rs` names it and fails if that stops being +// true. What this buys is not a fixed collision but a source that moves with +// the jar under a check, and a shape for all 32366 of this version's states +// rather than for the 24135 the old table described. +// +// No `MinecraftServer` subclass here, unlike `VanillaTrace`. A collision shape +// is a pure function of the state: `getCollisionShape` takes a `BlockGetter` +// only so that a block *could* look at its neighbours, and the ones whose +// shape depends on a neighbour encode that dependency in their own properties +// (`fence[north=true]`) rather than by reading the level. So +// `EmptyBlockGetter.INSTANCE` is the honest argument, `Bootstrap.bootStrap()` +// is all the setup needed, and the whole thing runs in seconds without a world. + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.server.Bootstrap; +import net.minecraft.world.level.EmptyBlockGetter; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.AABB; +import net.minecraft.world.phys.shapes.VoxelShape; + +public final class VanillaShapes { + private VanillaShapes() {} + + public static void main(String[] args) throws Exception { + if (args.length != 1) { + System.err.println("usage: VanillaShapes "); + System.exit(2); + } + + // Same two lines every harness here opens with. Without them the block + // registry is empty and every lookup below returns air. + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + + // One entry per distinct box list, and an index per state. The same + // compression valence's table uses, and for the same reason: 32366 + // states share a few hundred shapes, since every one of the sixty-odd + // stone-cut full cubes is the same unit box. + List> shapes = new ArrayList<>(); + Map shapeIndex = new HashMap<>(); + + // The registry is indexed by the protocol state id -- the same number + // `hyperion_minecraft_proto::block_state` computes and the same one the + // wire carries -- so the table this writes is indexable by it directly + // with no name round trip. + int maxId = -1; + Map stateToShape = new HashMap<>(); + for (BlockState state : Block.BLOCK_STATE_REGISTRY) { + int id = Block.BLOCK_STATE_REGISTRY.getId(state); + if (id < 0) { + throw new IllegalStateException("state " + state + " is not in the registry"); + } + maxId = Math.max(maxId, id); + + VoxelShape shape = state.getCollisionShape(EmptyBlockGetter.INSTANCE, BlockPos.ZERO); + List boxes = shape.toAabbs(); + + String key = key(boxes); + Integer index = shapeIndex.get(key); + if (index == null) { + index = shapes.size(); + shapes.add(boxes); + shapeIndex.put(key, index); + } + stateToShape.put(id, index); + } + + if (maxId < 0) { + throw new IllegalStateException("the block state registry is empty"); + } + // Dense by construction: the registry numbers states contiguously from + // zero, and a hole would mean a state id that indexes into nothing. + // Checked rather than assumed, because the failure downstream is an + // arrow passing through one specific block rather than a crash. + if (stateToShape.size() != maxId + 1) { + throw new IllegalStateException( + "the state ids are not dense: " + stateToShape.size() + " states with a maximum id of " + maxId); + } + + JsonArray shapeArray = new JsonArray(); + for (List boxes : shapes) { + JsonArray boxArray = new JsonArray(); + for (AABB box : boxes) { + JsonArray corners = new JsonArray(); + corners.add(box.minX); + corners.add(box.minY); + corners.add(box.minZ); + corners.add(box.maxX); + corners.add(box.maxY); + corners.add(box.maxZ); + boxArray.add(corners); + } + shapeArray.add(boxArray); + } + + JsonArray perState = new JsonArray(); + for (int id = 0; id <= maxId; id++) { + perState.add(stateToShape.get(id).intValue()); + } + + JsonObject out = new JsonObject(); + out.addProperty("minecraftVersion", SharedConstants.getCurrentVersion().name()); + out.addProperty("stateCount", maxId + 1); + out.add("shapes", shapeArray); + out.add("stateShapes", perState); + + Path output = Path.of(args[0]); + Files.createDirectories(output.toAbsolutePath().getParent()); + Files.writeString( + output, + new GsonBuilder().create().toJson(out) + "\n", + StandardCharsets.UTF_8); + System.err.printf( + "wrote %d states over %d distinct shapes to %s%n", maxId + 1, shapes.size(), output); + } + + /// A box list's identity, for deduplication. + /// + /// The doubles are formatted rather than rounded: two shapes that differ in + /// the last bit are two shapes, and collapsing them would silently move a + /// block's surface. `AABB` has no `hashCode` worth relying on across a + /// list, so this is the key. + private static String key(List boxes) { + StringBuilder builder = new StringBuilder(boxes.size() * 48); + for (AABB box : boxes) { + builder.append(box.minX).append(',') + .append(box.minY).append(',') + .append(box.minZ).append(',') + .append(box.maxX).append(',') + .append(box.maxY).append(',') + .append(box.maxZ).append(';'); + } + return builder.toString(); + } +} diff --git a/nix/java/VanillaTrace.java b/nix/java/VanillaTrace.java index d2c06fb53..50984be67 100644 --- a/nix/java/VanillaTrace.java +++ b/nix/java/VanillaTrace.java @@ -52,6 +52,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.lang.reflect.Field; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; @@ -72,6 +73,7 @@ import net.minecraft.core.Registry; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.core.registries.Registries; +import net.minecraft.network.syncher.EntityDataAccessor; import net.minecraft.gizmos.GizmoCollector; import net.minecraft.gizmos.Gizmos; import net.minecraft.resources.Identifier; @@ -103,11 +105,14 @@ import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.projectile.Projectile; +import net.minecraft.world.entity.projectile.arrow.AbstractArrow; import net.minecraft.world.flag.FeatureFlags; import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.DataPackConfig; import net.minecraft.world.level.GameType; import net.minecraft.world.level.LevelSettings; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.WorldDataConfiguration; import net.minecraft.world.level.dimension.LevelStem; import net.minecraft.world.level.gamerules.GameRuleMap; @@ -422,6 +427,13 @@ private void forceChunks(ServerLevel level) { } } } + // A declared block outside every entity's radius would be placed into a + // chunk that is not loaded, which `setBlock` answers by doing nothing + // and returning false -- a scenario whose wall silently is not there. + // Claiming its chunk is cheaper than detecting that later. + for (Scenario.BlockSpec spec : scenario.blocks) { + forced.add(new ChunkPos(spec.position[0] >> 4, spec.position[2] >> 4)); + } for (ChunkPos pos : forced) { level.setChunkForced(pos.x(), pos.z(), true); } @@ -437,6 +449,37 @@ private boolean chunksReady(ServerLevel level) { return true; } + /// Puts the scenario's declared blocks into the level, before any entity + /// exists to fly into them. + /// + /// `Block.UPDATE_CLIENTS` rather than `UPDATE_ALL`: a neighbour update runs + /// block logic, and block logic is where a recording would start consuming + /// randomness. There are no clients either way. The recorder's three-seed + /// agreement check is what would catch it if this were wrong. + /// + /// Default states only, deliberately. Naming a property here would mean + /// hyperion's replay parsing the same property string out of valence's + /// 1.20.1 tables, and the two default states agreeing is not something this + /// has to take on trust: a slab that came out `top` on one side and + /// `bottom` on the other moves the arrow's resting height half a block, + /// which is four orders of magnitude outside the position tolerance. + private void placeBlocks(ServerLevel level) { + for (Scenario.BlockSpec spec : scenario.blocks) { + Block block = BuiltInRegistries.BLOCK.getValue(Identifier.parse(spec.state)); + if (block == null) { + throw new IllegalArgumentException("no such block: " + spec.state); + } + BlockPos pos = new BlockPos(spec.position[0], spec.position[1], spec.position[2]); + BlockState state = block.defaultBlockState(); + if (!level.setBlock(pos, state, Block.UPDATE_CLIENTS)) { + throw new IllegalStateException("level refused the block " + spec.state + " at " + pos); + } + } + if (!scenario.blocks.isEmpty()) { + LOGGER.info("placed {} blocks", scenario.blocks.size()); + } + } + private Entity spawn(ServerLevel level, Scenario.EntitySpec spec) { EntityType type = BuiltInRegistries.ENTITY_TYPE.getValue(Identifier.parse(spec.type)); if (type == null) { @@ -514,6 +557,7 @@ protected void tickServer(BooleanSupplier haveTime) { if (tracked.isEmpty()) { warmup++; if (chunksReady(level)) { + placeBlocks(level); for (Scenario.EntitySpec spec : scenario.entities) { tracked.put(spec.id, spawn(level, spec)); } @@ -533,6 +577,36 @@ protected void tickServer(BooleanSupplier haveTime) { } } + /// `AbstractArrow.IN_GROUND`, the tracked field the client is told about. + /// + /// Reflected rather than read through `isInGround()`, which is `protected`, + /// and deliberately the *synched* value rather than a private boolean: it + /// is the thing that reaches a client, and it is what hyperion's + /// `metadata::arrow::InGround` mirrors. Its index rides along in the trace + /// header, so the field number hyperion hand-transcribes is checked against + /// the jar on every recording instead of being trusted (ENG-12106). + /// + /// Resolved on first use rather than in a static initialiser, and that is + /// not style: reading the field forces `AbstractArrow`'s superclass to + /// initialise, and `Entity.` reaches `BuiltInRegistries` and throws + /// `Not bootstrapped`. A `static final` here runs before `main` has called + /// `Bootstrap.bootStrap()`, so it cannot work. + @SuppressWarnings("unchecked") + private static synchronized EntityDataAccessor arrowInGround() { + if (arrowInGround == null) { + try { + Field field = AbstractArrow.class.getDeclaredField("IN_GROUND"); + field.setAccessible(true); + arrowInGround = (EntityDataAccessor) field.get(null); + } catch (ReflectiveOperationException error) { + throw new IllegalStateException("AbstractArrow no longer has an IN_GROUND accessor", error); + } + } + return arrowInGround; + } + + private static EntityDataAccessor arrowInGround; + private void sample() { JsonObject entities = new JsonObject(); for (Map.Entry entry : tracked.entrySet()) { @@ -552,6 +626,19 @@ private void sample() { rotation.add(entity.getXRot()); state.add("rotation", rotation); state.addProperty("removed", entity.isRemoved()); + // The impact state, and the reason a block scenario can assert + // anything at all. An arrow's resting position alone cannot tell + // "stopped by the wall" from "still flying and happening to be + // there this tick"; `inGround` can, and `shakeTime` pins the tick + // it landed on, since it counts down from seven. + // + // Absent for anything that is not an arrow rather than defaulted: + // `ThrowableProjectile` has no such state, and writing a false + // would claim a snowball was known not to be in the ground. + if (entity instanceof AbstractArrow arrow) { + state.addProperty("inGround", arrow.getEntityData().get(arrowInGround())); + state.addProperty("shakeTime", arrow.shakeTime); + } entities.add(entry.getKey(), state); } JsonObject sample = new JsonObject(); @@ -620,6 +707,7 @@ private void write() throws IOException { trace.addProperty("minecraftVersion", SharedConstants.getCurrentVersion().name()); trace.addProperty("seed", seed); trace.addProperty("ticks", scenario.ticks); + trace.addProperty("inGroundFieldIndex", arrowInGround().id()); JsonArray array = new JsonArray(); samples.forEach(array::add); trace.add("samples", array); @@ -640,6 +728,15 @@ private static final class Scenario { private int ticks; private long seed; private List entities = List.of(); + /// Terrain the scenario wants, and nothing else. Empty for every + /// scenario that flies through open sky, which is what keeps their + /// recordings byte-identical across this change. + private List blocks = List.of(); + + private static final class BlockSpec { + private int[] position; + private String state; + } private static final class EntitySpec { private String id; @@ -694,6 +791,13 @@ private static Scenario read(Path path) throws IOException { if (scenario.entities.isEmpty()) { throw new IllegalArgumentException(scenario.name + ": no entities to record"); } + for (BlockSpec spec : scenario.blocks) { + Objects.requireNonNull(spec.state, scenario.name + ": block is missing a state"); + if (spec.position == null || spec.position.length != 3) { + throw new IllegalArgumentException( + scenario.name + ": block position must be three integers"); + } + } for (EntitySpec spec : scenario.entities) { Objects.requireNonNull(spec.id, "entity is missing an id"); Objects.requireNonNull(spec.type, spec.id + ": entity is missing a type"); diff --git a/nix/minecraft-data.nix b/nix/minecraft-data.nix index 6c32e0146..253a31e85 100644 --- a/nix/minecraft-data.nix +++ b/nix/minecraft-data.nix @@ -183,8 +183,8 @@ let cd classes # The projectile package is the whole of an arrow's, snowball's and - # trident's flight. The four named classes are the types that package's - # tick reaches for the numbers the parity work checks: + # trident's flight. The named classes are the types that package's tick + # reaches for the numbers the parity work checks: # # util/Mth the sine table and atan2 the launch heading and # the per-tick orientation are built from @@ -192,10 +192,46 @@ let # ops shoot and updateRotation call # world/entity/Entity applyGravity/getDefaultGravity and the base tick # world/item/BowItem getPowerForTime and the f * 3.0 launch speed + # + # The rest are what an arrow hits. `AbstractArrow.tick` resolves its + # movement through `level().clipIncludingBorder(new ClipContext(from, to, + # ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, so being + # 1:1 about where an arrow stops means reading that path rather than + # reimplementing a plausible one. None of it was in this set before, + # which is why the collision half was the part taken on trust: + # + # world/level/Level clip and clipIncludingBorder + # world/level/BlockGetter traverseBlocks, the voxel walk itself, + # including the epsilon it nudges the + # start point by + # world/level/ClipContext Block.COLLIDER vs OUTLINE vs VISUAL -- + # which shape a clip asks a block for + # world/level/EmptyBlockGetter the neighbourless view a collision shape + # is extracted against + # world/phys/shapes/VoxelShape toAabbs and clip, the box list a state's + # collision shape actually is + # world/phys/shapes/Shapes the shape constants blocks are built from + # world/phys/AABB clip, the slab test under all of it + # world/phys/BlockHitResult what a clip returns, and its face + # world/level/block/state/BlockBehaviour + # getCollisionShape, the per-state entry + # point the shape table is extracted from + # world/level/block/Block BLOCK_STATE_REGISTRY, the global id -> + # state mapping the extractor walks required="net/minecraft/util/Mth.class \ net/minecraft/world/phys/Vec3.class \ net/minecraft/world/entity/Entity.class \ - net/minecraft/world/item/BowItem.class" + net/minecraft/world/item/BowItem.class \ + net/minecraft/world/level/Level.class \ + net/minecraft/world/level/BlockGetter.class \ + net/minecraft/world/level/ClipContext.class \ + net/minecraft/world/level/EmptyBlockGetter.class \ + net/minecraft/world/phys/shapes/VoxelShape.class \ + net/minecraft/world/phys/shapes/Shapes.class \ + net/minecraft/world/phys/AABB.class \ + net/minecraft/world/phys/BlockHitResult.class \ + net/minecraft/world/level/block/state/BlockBehaviour.class \ + net/minecraft/world/level/block/Block.class" for class in $required; do if [ ! -e "$class" ]; then echo "expected physics class missing from the jar: $class" >&2 @@ -232,6 +268,22 @@ let echo "shootFromRotation missing from decompiled Projectile.java" >&2 exit 1 fi + + # The same landmark check for the collision half. `traverseBlocks` is the + # voxel walk every clip goes through and `getCollisionShape` is where a + # state's boxes come from; a jar bump that renames or moves either would + # otherwise leave the Rust citing an empty file, which is exactly the + # failure this check exists to make loud. + blockGetter="$out/net/minecraft/world/level/BlockGetter.java" + if ! grep -q "traverseBlocks" "$blockGetter"; then + echo "traverseBlocks missing from decompiled BlockGetter.java" >&2 + exit 1 + fi + behaviour="$out/net/minecraft/world/level/block/state/BlockBehaviour.java" + if ! grep -q "getCollisionShape" "$behaviour"; then + echo "getCollisionShape missing from decompiled BlockBehaviour.java" >&2 + exit 1 + fi ''; # The client jar, for the one question a server capture cannot answer: how the @@ -359,6 +411,62 @@ let --add-flags "-cp $out/share/java:$classpath VanillaEncoder" ''; + # Every block state's collision shape, read out of the server's own + # `getCollisionShape`. + # + # Not part of `generatedData`, because Mojang's data generator does not carry + # it: a state in `reports/blocks.json` is an id and a property map, and a + # collision shape is a `VoxelShape` constant compiled into a block class. The + # only source is the running game, so this is a harness like `vanillaEncoder` + # rather than another report to parse. See the header of VanillaShapes.java + # for what that replaces and why it needs no world. + vanillaShapes = pkgs.runCommand "minecraft-vanilla-shapes-${pin.id}" + { + nativeBuildInputs = [ jdk pkgs.makeWrapper ]; + meta = { + description = "Harness dumping Minecraft ${pin.id} block collision shapes"; + license = lib.licenses.unfree; + mainProgram = "minecraft-shapes"; + }; + } + '' + mkdir -p $out/share/java $out/bin + classpath=$(cat ${serverClasspath}/classpath) + + # javac insists a public class live in a file named after it, and a store + # path is prefixed with its hash, so the source is copied first. Mirrors + # `vanillaEncoder` above. + cp ${./java/VanillaShapes.java} VanillaShapes.java + javac -nowarn -cp "$classpath" -d $out/share/java VanillaShapes.java + + makeWrapper ${lib.getExe' jdk "java"} $out/bin/minecraft-shapes \ + --add-flags "-cp $out/share/java:$classpath VanillaShapes" + ''; + + # The extracted table, as a build product rather than a command someone + # remembers to run. Keyed on the jar, so a version bump moves it and anything + # generated from it moves with it. + collisionShapes = pkgs.runCommand "minecraft-collision-shapes-${pin.id}" + { + nativeBuildInputs = [ vanillaShapes ]; + meta.description = "Per-state block collision shapes for Minecraft ${pin.id}"; + } + '' + mkdir -p $out + minecraft-shapes $out/collision-shapes.json + + # A state count that disagrees with the block state table is a table that + # cannot be indexed by a state id, which is the whole point of it. Checked + # here rather than in Rust so the failure names the jar rather than + # surfacing as an out-of-bounds much later. + count=$(${lib.getExe pkgs.jq} -r '.stateCount' $out/collision-shapes.json) + if [ "$count" -lt 1000 ]; then + echo "only $count states extracted; the registry did not bootstrap" >&2 + exit 1 + fi + echo "extracted collision shapes for $count states" >&2 + ''; + # Named hex strings the Rust tests compare against. Regenerating them is a # rebuild rather than a manual run, so a protocol bump moves the fixtures # and the tests fail loudly instead of passing against stale bytes. @@ -470,6 +578,9 @@ let particleCodegen = pkgs.writers.writePython3Bin "generate-minecraft-particles" pythonWriterOptions (builtins.readFile ./generate-particles.py); + collisionShapeCodegen = pkgs.writers.writePython3Bin "generate-minecraft-collision-shapes" pythonWriterOptions + (builtins.readFile ./generate-collision-shapes.py); + coverageChecker = pkgs.writers.writePython3Bin "check-minecraft-proto-coverage" pythonWriterOptions (builtins.readFile ./check-proto-coverage.py); @@ -536,6 +647,24 @@ let rustfmt --edition 2024 --config-path ${../rustfmt.toml} $out/block_state.rs ''; + # The shapes the extractor read out of the game, as Rust. Two arrays rather + # than one: 32366 states share 326 box lists, so the distinct lists are + # stored once and the per-state table is an index into them. + generatedCollisionShapes = pkgs.runCommand "hyperion-minecraft-collision-shapes-${pin.id}" + { + nativeBuildInputs = [ collisionShapeCodegen rustfmt ]; + meta.description = "Generated Rust collision shape table for Minecraft ${pin.id}"; + } + '' + mkdir -p $out + generate-minecraft-collision-shapes \ + --shapes ${collisionShapes}/collision-shapes.json \ + --version ${pin.id} \ + --protocol ${toString pin.protocolVersion} \ + --out $out/collision_shape.rs + rustfmt --edition 2024 --config-path ${../rustfmt.toml} $out/collision_shape.rs + ''; + # The one registry that cannot be generated from protocol.json alone. # `ParticleTypes.STREAM_CODEC` is a dispatch, so the extractor marks it # unresolved and the JSON carries only the names; which of the 125 types @@ -740,6 +869,17 @@ let ''; }; + syncCollisionShapesScript = pkgs.writeShellApplication { + name = "sync-minecraft-collision-shapes"; + runtimeInputs = [ pkgs.coreutils pkgs.git ]; + text = '' + root=$(git rev-parse --show-toplevel) + dest="$root/crates/hyperion-minecraft-proto/src/collision_shape.rs" + install -m 644 ${generatedCollisionShapes}/collision_shape.rs "$dest" + echo "synced $dest" >&2 + ''; + }; + # Rewrites the raw-literal baseline. The same command tightens it after a # migration and records a deliberate new one, so the two cannot drift. syncLiteralsScript = pkgs.writeShellApplication { @@ -873,6 +1013,19 @@ let fi ''; + collisionShapesUpToDate = pkgs.runCommand "check-minecraft-collision-shapes" + { } + '' + committed=${../crates/hyperion-minecraft-proto/src/collision_shape.rs} + if diff -u "$committed" ${generatedCollisionShapes}/collision_shape.rs > diff.txt 2>&1; then + touch $out + else + echo "committed collision shape table is stale; run: nix run .#sync-minecraft-collision-shapes" >&2 + head -n 200 diff.txt >&2 + exit 1 + fi + ''; + particlesUpToDate = pkgs.runCommand "check-minecraft-particles" { } '' @@ -1026,18 +1179,23 @@ in generatedRegistryData generatedTagData generatedBlockStates + generatedCollisionShapes generatedParticles extractor + vanillaShapes + collisionShapes codegen registryCodegen tagDataCodegen blockStateCodegen + collisionShapeCodegen particleCodegen updateScript syncScript syncRegistryDataScript syncTagDataScript syncBlockStatesScript + syncCollisionShapesScript syncParticlesScript coverageChecker coverageRatchet @@ -1051,6 +1209,7 @@ in tagDataUpToDate tagsLoadForClient blockStatesUpToDate + collisionShapesUpToDate particlesUpToDate fixturesUpToDate protocolJsonUpToDate diff --git a/nix/modules/game-server.nix b/nix/modules/game-server.nix index ccc83c203..5ee0d5b07 100644 --- a/nix/modules/game-server.nix +++ b/nix/modules/game-server.nix @@ -5,12 +5,42 @@ # game server, not the other way round, so this node needs one address its # proxies can reach and nothing else. Put it on a private network and give the # proxies the public ones. +# +# --------------------------------------------------------------------------- +# RELOAD, AND THE ONE RULE THAT MAKES IT WORK +# --------------------------------------------------------------------------- +# +# `switch-to-configuration` reloads a unit whose `[Service]` section is +# byte-identical and whose `X-Reload-Triggers` differs, and restarts it +# otherwise. So the rules dylib's store path may appear in exactly one place in +# this file -- `reloadTriggers` -- and `ExecStart` must reach the same file +# through a path that does not move, which is what `/etc/hyperion` is for. +# +# That is a property worth checking rather than trusting, because getting it +# wrong is invisible: every gate passes, the deploy succeeds, and the only +# symptom is that players were dropped on a deploy that should have been +# invisible to them. `checks.hot-reload-unit-split` renders this unit for two +# builds of the rules and asserts that `[Service]` did not move. { hyperionPackages }: { config, lib, pkgs, ... }: let cfg = config.services.hyperion-game-server; common = import ./common.nix { inherit lib; }; - inherit (lib) mkOption mkEnableOption mkIf types escapeShellArgs; + inherit (lib) mkOption mkEnableOption mkIf types escapeShellArgs optionals; + + # Where the deploy writes what the server reads, and where the server reads it + # from. One directory, named after the engine rather than after a game, + # because a host runs one game server and the build stamp in it is the host's. + etcDir = "hyperion"; + rulesFile = "/etc/${etcDir}/${cfg.event}-rules.so"; + stampDir = "/etc/${etcDir}"; + + # `RuntimeDirectory` below is what creates this, and `ProtectSystem = strict` + # is what makes it the only writable place the unit has. + runtimeDir = "hyperion-game-server"; + socket = "/run/${runtimeDir}/reload.sock"; + + reloadable = cfg.rules != null; in { options.services.hyperion-game-server = { @@ -26,6 +56,88 @@ in ''; }; + event = mkOption { + type = types.str; + default = "hyperion"; + description = '' + What this deployment calls the game. Used only to name the rules file + in `/etc/hyperion`, so that somebody reading that directory on a host + can tell which game's rules are sitting in it. + ''; + example = "smash"; + }; + + rules = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + The reloadable rules dylib, as a full path to the `.so` inside its + store path. `null` runs the server with no reloadable rules at all, and + every deploy is then a restart. + + This store path is put in `X-Reload-Triggers` and **nowhere else in the + unit**. That is the whole mechanism: a deploy that changes only this + leaves `[Service]` untouched, and a unit whose `[Service]` did not move + is one systemd reloads instead of restarting. + ''; + example = lib.literalExpression ''"''${hyperionPackages.x86_64-linux.smash-rules}/lib/libsmash_rules.so"''; + }; + + reloadClient = mkOption { + type = types.package; + default = hyperionPackages.${pkgs.stdenv.hostPlatform.system}.hyperion-dylibs; + defaultText = "the engine dylib set for this system, which ships the client"; + description = '' + The package providing `hyperion-reload-client`, which is what + `ExecReload` runs. It ships with the engine rather than with an event + because `ExecReload` is part of `[Service]`: a path that moved when an + event's rules moved would restart the server on exactly the deploys + this whole feature exists to make invisible. + ''; + }; + + buildStamp = mkOption { + description = '' + What build this deployment is, written into `/etc/hyperion` for the + server to read at runtime. + + Files rather than environment variables, and that is the point: a + process's environment is fixed at `exec`, so a server that reloaded its + rules without restarting would report the build it started as forever. + See `events/smash/src/module/build_stamp.rs`. + ''; + default = { }; + type = types.submodule { + options = { + rev = mkOption { + type = types.str; + default = ""; + description = "The short commit hash, or empty when nothing knows."; + }; + committedAt = mkOption { + type = types.nullOr types.int; + default = null; + description = '' + When that commit was made, in whole seconds since the unix epoch. + `null` when there is no commit to date, which is the only honest + answer for a source with no git in it -- a directory's mtime is + not a commit date, and a bar reading `unpackaged build · 2h ago` + would be a stamp that had just admitted it does not know what it + is, timed to the minute. + ''; + }; + dirty = mkOption { + type = types.bool; + default = false; + description = '' + The working tree had uncommitted changes, so `rev` names a tree + this build was not made from. The game draws the bar in red. + ''; + }; + }; + }; + }; + address = mkOption { type = types.str; default = "::"; @@ -52,6 +164,18 @@ in }; config = mkIf cfg.enable { + # Read by the server at startup and on every reload. Nothing here reaches + # the unit file, which is why a new build of the rules can land without + # `[Service]` changing. + environment.etc = { + "${etcDir}/build-rev".text = cfg.buildStamp.rev; + "${etcDir}/build-time".text = + if cfg.buildStamp.committedAt == null then "" else toString cfg.buildStamp.committedAt; + "${etcDir}/build-dirty".text = if cfg.buildStamp.dirty then "1" else "0"; + } // lib.optionalAttrs reloadable { + "${etcDir}/${cfg.event}-rules.so".source = cfg.rules; + }; + systemd.services.hyperion-game-server = { description = "hyperion game server"; # network-online rather than network: the bind fails outright if the @@ -61,6 +185,24 @@ in wants = [ "network-online.target" ]; wantedBy = [ "multi-user.target" ]; + # THE ONLY PLACE THE RULES STORE PATH MAY APPEAR. See the header. + # + # The build stamp is in here too, and for a reason that is not obvious: a + # commit that changes nothing the server links -- documentation, CI, the + # proxy -- still changes what `/etc/hyperion/build-rev` says, and without + # a reload the running server would go on telling players about the commit + # before it. That is the exact question the bar exists to answer, so it + # has to stay true. Such a reload re-opens a byte-identical dylib and + # rewrites nothing; it is the code-only case `docs/hot-reload.md` measured + # at a few milliseconds, and it cannot be refused, because a schema can + # only move when the dylib does. + reloadTriggers = optionals reloadable [ + cfg.rules + cfg.buildStamp.rev + (toString cfg.buildStamp.committedAt) + (if cfg.buildStamp.dirty then "dirty" else "clean") + ]; + serviceConfig = common.hardening // { Type = "simple"; ExecStart = escapeShellArgs ([ @@ -70,7 +212,14 @@ in "--root-ca-cert" (toString cfg.pki.rootCaCert) "--cert" (toString cfg.pki.cert) "--private-key" (toString cfg.pki.privateKey) + ] ++ optionals reloadable [ + # Stable paths, every one of them. Nothing on this line moves when a + # new build of the rules is deployed. + "--rules" rulesFile + "--reload-socket" socket + "--build-stamp" stampDir ] ++ cfg.extraArgs); + Restart = "on-failure"; RestartSec = "2s"; StateDirectory = "hyperion-game-server"; @@ -79,6 +228,28 @@ in # connections' worth of buffers, so the default 1024 descriptors runs # out long before anything else does. LimitNOFILE = 1048576; + } // lib.optionalAttrs reloadable { + # The request carries no arguments -- the module path and the stamp + # directory were fixed at startup -- so this is a constant string, and a + # constant string is one that cannot move a deploy from reload back to + # restart. The client exits non-zero on a refusal, which fails the + # `systemctl reload` that asked rather than leaving the reason in a log + # nobody reads. + ExecReload = escapeShellArgs [ + (lib.getExe' cfg.reloadClient "hyperion-reload-client") + socket + ]; + + # Where the reload socket lives. `ProtectSystem = strict` leaves the + # unit nothing else it may write to. + RuntimeDirectory = runtimeDir; + + # AF_UNIX for that socket. `common.hardening` grants the two IP families + # and nothing else, so without this the server dies at startup on + # `socket(AF_UNIX): Address family not supported by protocol` -- and + # only on a real host, because nothing in a test or a gate runs under + # this filter. + RestrictAddressFamilies = common.hardening.RestrictAddressFamilies ++ [ "AF_UNIX" ]; }; }; }; diff --git a/tools/bow-check.py b/tools/bow-check.py index 569f4383a..09c51e51d 100755 --- a/tools/bow-check.py +++ b/tools/bow-check.py @@ -337,10 +337,14 @@ def check(ok, message): def arrows_seen(): """Every distinct arrow entity, as (launch, latest). - One arrow shows up in more than one `AddEntity`: the launch, and then - another when `arrow_block_hit` pins it into whatever it ran into. They - share an entity id, so the id is what counts an arrow and the order - gives the before and after. + One arrow can show up in more than one `AddEntity`: the launch, and + then again whenever a client subscribes to its channel and + `send_subscribe_channel_packets` replays the spawn. They share an + entity id, so the id is what counts an arrow. + + The second one is a subscription and not an impact -- reading it as one + is ENG-12085 -- so nothing below asserts on `latest`. What an impact + looks like on the wire is a zero `SetEntityMotion`, in `client.motions`. """ out = {} for entry in client.spawned: @@ -374,7 +378,7 @@ def draw(seconds, note): print("RESULT: failure (nothing was fired)", flush=True) return 1 - launch, latest = full[0] + launch, _latest = full[0] speed = launch["speed"] check( abs(speed - MAX_ARROW_SPEED) < 0.05, @@ -382,15 +386,24 @@ def draw(seconds, note): "old seconds-as-charge produced (got %.3f)" % (MAX_ARROW_SPEED, speed), ) - # `arrow_block_hit` zeroes the velocity and pins the arrow at the collision - # point, and the client is told again. Free to assert here because the - # world bedwars loads has something to hit in every direction. - check( - latest is not launch and latest["speed"] == 0.0, - "an arrow that hits a block stops: it was re-sent at (%.2f, %.2f, " - "%.2f) with |v|=%.3f" - % (latest["position"] + (latest["speed"],)), - ) + # Nothing about the impact is asserted here, and that is a measurement + # rather than an omission. A level shot from the ground meets something + # within a tick or two, which is before the client's subscription to the + # arrow's channel has landed, so it receives **zero** `SetEntityMotion` for + # this arrow -- the run that established this printed `0 velocity + # broadcasts`. There is no impact to see from here. + # + # What used to be asserted here was a *second* `AddEntity` carrying + # |v| == 0, on the stated grounds that the server re-sends a pinned arrow. + # It does not and never did: the second `AddEntity` is + # `send_subscribe_channel_packets` replaying the spawn for a client that + # has just subscribed, and it carries whatever the arrow's velocity is at + # that moment. On this shot that is one tick after launch, 3.0 * 0.99 == + # 2.970 -- exactly the number the failures printed, about three runs in + # four (ENG-12085). It was a race between the subscription and the wall, + # not a flake, and it is still a race whichever value the replay carries. + # + # The impact is asserted below instead, from a shot with room to be seen. pump(client, 0.5) after = client.count_of("minecraft:arrow") @@ -482,6 +495,81 @@ def draw(seconds, note): "heading bug); got %.1f" % wire_yaw, ) + # --- the arrow stops in the ground it was fired into --------------- + # + # Straight up, with a short draw, and let it fall back onto the block it + # was fired from. Every part of that is load bearing. + # + # The obvious shot -- level, or down at your feet -- cannot be seen at all. + # It meets something within a tick or two, which is before the client's + # subscription to the arrow's channel has landed, so the client receives + # **zero** `SetEntityMotion` for it. That is measured, not feared: the run + # that established it printed `impact: 0 velocity broadcasts`. Firing + # upwards buys the subscription time to arrive and then guarantees the + # impact anyway, because an arrow with no horizontal velocity comes down on + # the terrain it left. A short draw keeps the whole round trip inside two + # seconds. + # + # What used to be asserted here was a *second* `AddEntity` carrying + # |v| == 0, on the stated grounds that the server re-sends a pinned arrow. + # It does not and never did: the second `AddEntity` is + # `send_subscribe_channel_packets` replaying the spawn for a client that has + # just subscribed, and it carries whatever the arrow's velocity happens to + # be at that moment -- one tick after launch on a level shot, which is + # 3.0 * 0.99 == 2.970, exactly the number the failures printed about three + # runs in four (ENG-12085). It was a race between the subscription and the + # wall, not a flake. + pump(client, 0.5) + client.aim(0.0, -90.0) + client.send_position() + client.motions.clear() + up = draw(0.25, "(quarter draw, straight up)") + check(len(up) == 1, "the upward short draw fires one arrow (got %d)" % len(up)) + if up: + up_id = up[0][0]["id"] + pump(client, 2.0) + flight = client.motions.get(up_id, []) + speeds = [(vx * vx + vy * vy + vz * vz) ** 0.5 for vx, vy, vz in flight] + first_stop = next((i for i, v in enumerate(speeds) if v == 0.0), None) + print( + "impact: %d velocity broadcasts, first zero at %s, speeds %s" + % (len(speeds), first_stop, ["%.3f" % v for v in speeds[:6]]), + flush=True, + ) + # `onHitBlock` zeroes the velocity in the tick of the hit and broadcasts + # it (`AbstractArrow.java:499`, and the `needsSync` at line 253), so a + # zero `SetEntityMotion` is the impact as a client sees it -- and it is + # the packet that stops the client dead-reckoning the arrow onwards + # through the block. + # + # A non-zero broadcast has to come first, and that is the whole + # discrimination: "every broadcast at rest" is also what an arrow that + # never moved looks like, and a check that cannot tell those apart is + # evidence of neither. That vacuity is ENG-12082 on the smash side. + check( + first_stop is not None and first_stop >= 1, + "an arrow that falls back to the ground stops in it, and was seen " + "flying first: %d velocity broadcasts, first zero at index %s" + % (len(speeds), first_stop), + ) + if first_stop is not None: + after = speeds[first_stop:] + check( + all(v == 0.0 for v in after), + "a stopped arrow stays stopped: %d broadcasts after the first " + "zero, %s" % (len(after), ["%.3f" % v for v in after[:6]]), + ) + # It went up and it came back down: the sign of vy has to change, + # or the arrow was stopped on the way up by something and the round + # trip this assertion is built on never happened. + rising = [vy for _, vy, _ in flight[:first_stop] if vy > 0.0] + falling = [vy for _, vy, _ in flight[:first_stop] if vy < 0.0] + check( + len(rising) >= 1 and len(falling) >= 1, + "the arrow rose and then fell before it stopped (%d rising " + "ticks, %d falling)" % (len(rising), len(falling)), + ) + # --- the arrow actually flies, on the wire ------------------------ # # Everything above reads the launch. This is the part the operator cares diff --git a/tools/chat-check.py b/tools/chat-check.py new file mode 100755 index 000000000..485932607 --- /dev/null +++ b/tools/chat-check.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Player chat, proved the only way it can be: two clients, one talking. + +smash decoded chat and broadcast nothing. `PacketId::Chat` was routed to a +handler, the handler pushed `event::ChatMessage`, and no system drained the +queue, so a message a player typed reached exactly nobody. Nothing in the crate +could see it -- there was no code to test -- and the only shape of evidence that +distinguishes "wired up" from "decoded and dropped" is a second connection +receiving what the first one said. + +So this joins two clients and checks four things: + + 1. **The speaker hears themselves.** Vanilla echoes your own message back + from the server rather than drawing it locally, and a broadcast that + skipped the sender would look right to them and be wrong. + 2. **The other client hears it**, in the vanilla shape ` message`. + This is the assertion that is red on an unpatched tree: no `SystemChat` + carrying the message arrives at all. + 3. **A section sign a player typed is not a formatting code.** The client + renders a literal `SystemChat` string through `StringDecomposer`, which + applies legacy `§` codes as it goes, so `§k` from a bot scrambles the + glyphs and `§4[Server]` paints a fake server notice. Both must arrive with + the sign gone and the rest of the text intact. + 4. **A whitespace-only message is dropped**, and dropped rather than + broadcast as ` `. + +Assertions 1 to 3 are fail-then-pass by construction and have been watched +failing: with the module's import removed 1, 2 and 3 go red, and with the +section sign left in only 3 does. Assertion 4 is not -- a server that +broadcasts nothing satisfies "nothing was broadcast" -- which is why the +message after it has to arrive for the run to pass at all. + +Exits non-zero on anything that is not true, after printing everything it saw. +""" + +import argparse +import importlib.util +import pathlib +import struct +import sys +import time + +TOOLS = pathlib.Path(__file__).resolve().parent + + +def _load(name, filename): + spec = importlib.util.spec_from_file_location(name, TOOLS / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +match = _load("smash_match", "smash-match.py") + +var_int = match.base.var_int +mc_string = match.base.mc_string +take_nbt_string = match.take_nbt_string + +# crates/hyperion-minecraft-proto/src/generated/packet_id.rs, protocol 776: +# `minecraft:chat` serverbound. Distinct from `chat_command` (7), which is the +# one every other gate here sends. +C2S_CHAT = 0x09 + +# `LastSeenMessagesTracker.window` is 20 wide, so the acknowledged bitset is a +# fixed three bytes. See `hyperion::simulation::packet::serverbound`. +LAST_SEEN_ACKNOWLEDGED_BYTES = 3 + + +def chat_packet(message): + """`ServerboundChatPacket`, unsigned, acknowledging nothing. + + Layout from `ServerboundChatPacket#STREAM_CODEC`: the message, the client's + clock, the signature salt, an optional 256-byte signature, then the last + seen window. This server does not verify signatures -- `chat_ack` and + `chat_session_update` are both routed to `Route::Ignore` -- so the + signature is absent and the salt is zero, which is what a client with no + chat session sends. + """ + return ( + mc_string(message) + + struct.pack(">qq", int(time.time() * 1000), 0) + + b"\x00" + + var_int(0) + + b"\x00" * LAST_SEEN_ACKNOWLEDGED_BYTES + + b"\x00" + ) + + +class Talker(match.MatchClient): + """A scripted player that records every chat line it is sent.""" + + def __init__(self, host, port, name, started): + super().__init__(host, port, name, started) + self.joined = False + self.chats = [] + + def say(self, message): + self.log("-> chat %r" % message) + self.send(C2S_CHAT, chat_packet(message)) + + def absorb(self, packet_id, payload): + if packet_id == match.S2C_LOGIN: + self.joined = True + self.log("** in the world **") + elif packet_id == match.S2C_PLAYER_POSITION: + teleport_id, offset = match.base.take_var_int(payload) + x, y, z = struct.unpack(">ddd", payload[offset : offset + 24]) + self.position = (x, y, z) + self.send(match.C2S_ACCEPT_TELEPORTATION, var_int(teleport_id)) + elif packet_id == match.S2C_KEEP_ALIVE: + self.send(match.C2S_KEEP_ALIVE, payload[:8]) + elif packet_id == match.S2C_SYSTEM_CHAT: + text, _ = take_nbt_string(payload, 0) + self.chats.append(text) + self.log("<- chat %r" % text) + elif packet_id == match.S2C_DISCONNECT: + text, _ = take_nbt_string(payload, 0) + self.log("<- DISCONNECTED: %s" % text) + self.alive = False + + +def pump(clients, seconds): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + for client in clients: + if not client.alive: + continue + for packet_id, payload in client.drain(): + client.absorb(packet_id, payload) + if client.joined: + client.repeat_position() + time.sleep(0.01) + + +def wait_until(clients, predicate, seconds, what): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + pump(clients, 0.05) + if predicate(): + return True + print("TIMEOUT waiting for %s" % what, file=sys.stderr) + return False + + +def connect(host, port, name, started): + client = Talker(host, port, name, started) + client.handshake(host, port, 2) + client.login() + client.configuration() + client.enter_play() + return client + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=25565) + parser.add_argument("--speaker", default="Alice") + parser.add_argument("--listener", default="Bob") + args = parser.parse_args() + + started = time.time() + failures = [] + + def check(ok, message): + print("%s %s" % ("PASS" if ok else "FAIL", message), flush=True) + if not ok: + failures.append(message) + + speaker = connect(args.host, args.port, args.speaker, started) + listener = connect(args.host, args.port, args.listener, started) + clients = [speaker, listener] + + if not wait_until(clients, lambda: all(c.joined for c in clients), 60.0, "both clients"): + print("RESULT: failure (never joined)", flush=True) + return 1 + + # Anything the join path says -- the build stamp, the lobby -- lands before + # the first message and is not what any assertion below is about. + pump(clients, 2.0) + for client in clients: + client.chats.clear() + + def expect(sent, wanted, note, settle=5.0): + """`sent` is typed by the speaker; `wanted` must reach both clients.""" + speaker.say(sent) + arrived = wait_until( + clients, + lambda: all(wanted in c.chats for c in clients), + settle, + "%r on both clients" % wanted, + ) + check( + arrived, + "%s: sent %r, both clients receive %r (speaker saw %r, listener saw %r)" + % (note, sent, wanted, speaker.chats, listener.chats), + ) + return arrived + + # The whole feature. An unpatched tree fails here and only here matters. + hello = "hello from the other side" + expect( + hello, + "<%s> %s" % (args.speaker, hello), + "a message reaches every player in the vanilla shape", + ) + + # The speaker's own copy, called out separately because a broadcast that + # excluded the sender would still pass a listener-only check and would look + # wrong to the person typing. + check( + "<%s> %s" % (args.speaker, hello) in speaker.chats, + "the speaker is sent their own message rather than drawing it locally", + ) + + # Formatting injection. `§k` is the obfuscate code and `§4` is dark red; a + # client renders both out of a literal string, so leaving them in lets a bot + # scramble its own text and impersonate a server notice. + expect( + "§4[Server] restarting §kNOW", + "<%s> 4[Server] restarting kNOW" % args.speaker, + "a section sign a player typed is stripped, and nothing else is", + ) + + # A message that is only whitespace. Checked by sending a real one after it + # and requiring that the real one is the next thing anybody sees, so this + # cannot pass by the server simply being slow. + before = list(listener.chats) + speaker.say(" ") + pump(clients, 2.0) + blank = [line for line in listener.chats[len(before) :]] + check( + not any(line.startswith("<%s>" % args.speaker) for line in blank), + "a whitespace-only message is dropped rather than broadcast (saw %r)" % blank, + ) + expect( + "still here", + "<%s> still here" % args.speaker, + "chat still works after a dropped message", + ) + + print( + "RESULT: %s" % ("success" if not failures else "failure (%d)" % len(failures)), + flush=True, + ) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/console-check.py b/tools/console-check.py new file mode 100755 index 000000000..4d451a424 --- /dev/null +++ b/tools/console-check.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""The operator console, driven the way an operator drives it. + +Everything the console claims needs both halves present at once: a real client +in the world and a real HTTP client on the port. Nothing in the crate can test +that pairing, because the crate has neither. + +So this joins one scripted player, then talks to the console over HTTP and +checks the two directions meet: + + 1. **The page is served and the port is not open.** `GET /` answers, and + every path that carries data refuses a request with no bearer token. This + is the assertion an operator's safety rests on, so it is first. + 2. **What a player types reaches the web.** The client sends a chat packet + and the line has to arrive on `/events` in vanilla's ` message` + shape. + 3. **What the console says reaches the game.** `POST /say` has to arrive at + the client as a `SystemChat`, attributed to the console, and has to appear + on the feed too, so the operator sees their own message land. + 4. **A command runs through the real dispatch and answers back.** `POST + /command` with a command nobody registered has to come back on the feed as + `hyperion_command`'s own "Available commands" reply. That reply is written + by the engine, unicast to the caller's connection, and the caller here has + no socket -- so it arriving at all is the whole of the virtual-connection + mechanism working end to end. A second command asks for a reply too big to + fit under the compression threshold, because the short one and the long + one take different branches of the console's decoder and only one of them + is otherwise exercised. + 5. **The live state is live.** `/state` names the joined player and carries a + tick rate. + +Every assertion here has been watched failing against a live server, which is +the only reason to believe any of them. What was broken, and what went red: + + * the decoder handed the wrong compression threshold -- all three reply + assertions, and nothing else. Worth stating plainly: that failure is + **silent**. The decoder reads the `data_len` varint as a packet id, decides + the frame is not chat, and drops it; the server logs nothing at any level. + No unit test can see it either, because the crate has no server to unicast + through. This file is the only thing between that bug and an operator. + * `strip_formatting` removed from the chat tap -- both spoof assertions, with + ` \u00a74[Server] restarting` arriving on the feed exactly as the + attack intends. The in-game line stayed clean, which is the point: the two + paths trust the text differently. + * `id="log"` renamed in `console.html` -- the page assertion, naming the + marker it could not find. A title-substring check stayed green through the + same edit, which is why this one keys on the ids the page's own script + looks up. + * the right token offered where a refusal is demanded -- every refusal + assertion. `/events` needed the token in the query rather than a header to + go red at all, since that is the only way it authenticates. + +Exits non-zero on anything that is not true, after printing everything it saw. +""" + +import argparse +import importlib.util +import json +import pathlib +import struct +import sys +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +TOOLS = pathlib.Path(__file__).resolve().parent + + +def _load(name, filename): + spec = importlib.util.spec_from_file_location(name, TOOLS / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +match = _load("smash_match", "smash-match.py") +chat_check = _load("chat_check", "chat-check.py") + +take_nbt_string = match.take_nbt_string +var_int = match.base.var_int + +# `hyperion_web_console::module::CONSOLE_NAME`, as the console prefixes its own +# messages in game. +CONSOLE_PREFIX = "[Console]" + +# `hyperion::Global`'s own, set in `crates/hyperion/src/lib.rs`. The encoder +# compresses a packet whose body is longer than this and writes a `data_len` of +# zero otherwise, so a reply either side of it takes a different branch of the +# console's decoder. +COMPRESSION_THRESHOLD = 256 + + +class Watcher(chat_check.Talker): + """The scripted player, reused from the chat gate: it already knows how to + send a serverbound chat packet and record every `SystemChat` it is sent.""" + + +class Feed(threading.Thread): + """`/events`, read the way a browser reads it. + + A thread because SSE is a response that never ends: the read blocks, and + the rest of this file has a client to pump in the meantime. + """ + + def __init__(self, base, token): + super().__init__(daemon=True) + # Encoded the way the page encodes it. `console.html` builds this URL + # with `encodeURIComponent`, and a gate that interpolates the raw token + # is not driving the console the way an operator drives it: a standard + # base64 token contains `+` and `/`, and sending those raw asks the + # server a different question than the browser ever asks. + self.url = "%s/events?token=%s" % (base, urllib.parse.quote(token, safe="")) + self.lines = [] + self.error = None + self.connected = threading.Event() + + def run(self): + try: + with urllib.request.urlopen(self.url, timeout=60) as response: + self.connected.set() + for raw in response: + text = raw.decode("utf-8", "replace").strip() + if not text.startswith("data:"): + continue + self.lines.append(json.loads(text[5:].strip())) + except Exception as error: # noqa: BLE001 - reported, not handled + self.error = error + self.connected.set() + + def texts(self, source=None): + return [ + line["text"] + for line in list(self.lines) + if source is None or line["source"] == source + ] + + +def request(base, path, token=None, body=None, method=None): + """One HTTP request, returning (status, body). A refusal is a result here, + not an exception: half the assertions below are about refusals.""" + url = "%s%s" % (base, path) + data = body.encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + if token: + req.add_header("Authorization", "Bearer %s" % token) + try: + with urllib.request.urlopen(req, timeout=10) as response: + return response.status, response.read().decode("utf-8", "replace") + except urllib.error.HTTPError as error: + return error.code, error.read().decode("utf-8", "replace") + except urllib.error.URLError as error: + return 0, str(error) + # A read that never finishes is the shape `/events` has -- it is a stream, + # and a body that ends is the abnormal case. Reaching it here means a path + # that should have answered and closed did not, and that has to arrive as a + # status this file can assert on. Uncaught, it ends the run in a traceback + # partway down, which reports nothing about the checks that never ran. + except (TimeoutError, OSError) as error: + return 0, "no complete response: %r" % (error,) + + +def pump(client, seconds): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if not client.alive: + return + for packet_id, payload in client.drain(): + client.absorb(packet_id, payload) + if client.joined: + client.repeat_position() + time.sleep(0.01) + + +def wait_until(client, predicate, seconds, what): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + pump(client, 0.05) + if predicate(): + return True + print("TIMEOUT waiting for %s" % what, file=sys.stderr) + return False + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=25565) + parser.add_argument("--console", default="127.0.0.1:8791") + parser.add_argument("--token-file", required=True) + parser.add_argument("--name", default="Watcher") + args = parser.parse_args() + + token = pathlib.Path(args.token_file).read_text().strip() + base = "http://%s" % args.console + started = time.time() + failures = [] + + def check(ok, message): + print("%s %s" % ("PASS" if ok else "FAIL", message), flush=True) + if not ok: + failures.append(message) + + # --- 1. the port is not open ----------------------------------------- + # + # First, because everything after it assumes a console that is reachable, + # and a console reachable *without* the token is worse than one that does + # not work at all. + status, body = request(base, "/") + # The ids the page's own script looks up, and the URL it builds. A title + # or a heading is prose and can be reworded without anything breaking; if + # any of these three goes missing the page is served and does not work, + # which is the failure worth catching. See `assets/console.html`. + missing = [ + marker + for marker in ('id="log"', 'id="token"', "/events?token=") + if marker not in body + ] + check( + status == 200 and not missing, + "GET / serves a page the script can drive (%s, missing %r)" % (status, missing), + ) + + for path, method, payload in ( + ("/events", None, None), + ("/state", None, None), + ("/say", "POST", "hello"), + ("/command", "POST", "perms"), + ): + status, _ = request(base, path, body=payload, method=method) + check(status == 401, "%s without a token is refused (%s)" % (path, status)) + + status, _ = request(base, "/state", token="not-the-token") + check(status == 401, "a wrong token is refused (%s)" % status) + + # --- the world --------------------------------------------------------- + client = Watcher(args.host, args.port, args.name, started) + client.handshake(args.host, args.port, 2) + client.login() + client.configuration() + client.enter_play() + + if not wait_until(client, lambda: client.joined, 60.0, "the client to join"): + print("RESULT: failure (never joined)", flush=True) + return 1 + + feed = Feed(base, token) + feed.start() + feed.connected.wait(timeout=10) + if feed.error is not None: + print("FAIL /events with a token did not open: %r" % feed.error, flush=True) + print("RESULT: failure (no event stream)", flush=True) + return 1 + print("PASS /events with a token opens", flush=True) + + pump(client, 2.0) + client.chats.clear() + + # --- 2. the game reaches the web -------------------------------------- + spoken = "console gate is watching" + client.say(spoken) + wanted = "<%s> %s" % (args.name, spoken) + arrived = wait_until( + client, + lambda: wanted in feed.texts("chat"), + 10.0, + "%r on the console feed" % wanted, + ) + check(arrived, "a player's message reaches the web feed as %r (saw %r)" % (wanted, feed.texts("chat"))) + + # --- 2b. a player cannot paint their own line ------------------------- + # + # The page renders section signs, so a message carrying them is a player + # writing colour into an operator's console: `\u00a74[Server] restarting` + # arrives looking like the server said it. The engine's own + # `strip_formatting` runs on the way to the feed, and it takes the sign and + # leaves everything else, so the code letter stays as an ordinary + # character. Same rule, and same expected shape, as `chat-check.py`'s third + # assertion. + spoof = "\u00a74[Server] restarting \u00a7kNOW" + client.say(spoof) + stripped = "<%s> 4[Server] restarting kNOW" % args.name + spoofed = wait_until( + client, + lambda: any("[Server] restarting" in line for line in feed.texts("chat")), + 10.0, + "the spoof attempt on the console feed", + ) + painted = [line for line in feed.texts("chat") if "[Server] restarting" in line] + check( + spoofed and painted == [stripped], + "a player's section signs are stripped before the web feed " + "(wanted %r, saw %r)" % (stripped, painted), + ) + check( + all("\u00a7" not in line for line in painted), + "no section sign a player typed survives onto the feed (saw %r)" % painted, + ) + + # --- 3. the web reaches the game -------------------------------------- + announcement = "the console can talk" + status, body = request(base, "/say", token=token, body=announcement, method="POST") + check(status == 202, "POST /say is accepted (%s %s)" % (status, body)) + + heard = wait_until( + client, + lambda: any( + CONSOLE_PREFIX in line and announcement in line for line in client.chats + ), + 10.0, + "the announcement at the client", + ) + check( + heard, + "a console announcement reaches a real client attributed to the console (saw %r)" + % client.chats, + ) + check( + any(announcement in line for line in feed.texts("console")), + "the operator sees their own announcement on the feed (saw %r)" + % feed.texts("console"), + ) + + # --- 4. a command runs, and answers back ------------------------------ + # + # A command nobody registered, so the reply is `hyperion_command`'s own and + # does not depend on which event is running. It is unicast to the caller's + # connection, and the caller is the console, which has no socket -- so this + # arriving is the virtual connection working. + status, body = request( + base, "/command", token=token, body="notacommand", method="POST" + ) + check(status == 202, "POST /command is accepted (%s %s)" % (status, body)) + + replied = wait_until( + client, + lambda: any("Available commands" in line for line in feed.texts("reply")), + 10.0, + "a command reply on the feed", + ) + check( + replied, + "a command's reply comes back to the console over its virtual " + "connection (replies seen: %r)" % feed.texts("reply"), + ) + + # --- 4b. a reply the encoder compressed -------------------------------- + # + # The reply above is under two hundred bytes, so the encoder writes it with + # a `data_len` of zero and the decoder takes it verbatim. That leaves the + # other half of `FrameDecoder` -- the branch that inflates -- unproven, and + # a decoder told the wrong threshold reads a compressed frame as a raw one + # and produces nonsense rather than an error. So this asks for a reply that + # cannot fit under the threshold and checks it arrives whole. + # + # `perms --help` and not a smash command, because the console is engine + # level and this gate should not depend on which event is running. + status, body = request( + base, "/command", token=token, body="perms --help", method="POST" + ) + check(status == 202, "POST /command (a long one) is accepted (%s %s)" % (status, body)) + + long_replies = [] + + def long_reply(): + long_replies[:] = [ + text + for text in feed.texts("reply") + if len(text.encode("utf-8")) > COMPRESSION_THRESHOLD + ] + return bool(long_replies) + + inflated = wait_until(client, long_reply, 10.0, "a reply past the compression threshold") + check( + inflated, + "a reply larger than the %d byte compression threshold reaches the " + "console, so the decoder's inflate branch works (longest seen: %d bytes)" + % ( + COMPRESSION_THRESHOLD, + max((len(text.encode("utf-8")) for text in feed.texts("reply")), default=0), + ), + ) + check( + any("perms" in text for text in long_replies), + "that reply is the one asked for rather than any long line (saw %r)" + % [text[:60] for text in long_replies], + ) + + # --- 5. live state ----------------------------------------------------- + status, body = request(base, "/state", token=token) + check(status == 200, "GET /state with a token answers (%s)" % status) + state = json.loads(body) if status == 200 else {} + check( + any(player["name"] == args.name for player in state.get("players", [])), + "the roster names the joined player (%r)" % state.get("players"), + ) + check( + "tps" in state and "targetTps" in state, + "the state carries a tick rate against the rate it is paced to (%r)" % state, + ) + check( + state.get("targetTps") == 20.0, + "the target tick rate is the engine's own (%r)" % state.get("targetTps"), + ) + + print( + "RESULT: %s" % ("success" if not failures else "failure (%d)" % len(failures)), + flush=True, + ) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/packet_monitor.py b/tools/packet_monitor.py index 68f028721..d1a21f509 100644 --- a/tools/packet_monitor.py +++ b/tools/packet_monitor.py @@ -103,6 +103,21 @@ def _load(name, filename): ALL_SKIN_PARTS = 0x7F +def take_var_int_signed(payload, offset=0): + """One VarInt read back as the signed 32-bit value it was written from. + + Minecraft VarInts are two's complement and not zigzag, so `-1` goes on the + wire as five bytes and `take_var_int` hands it back as 4294967295. That is + the right answer for every count and id in this file and the wrong one for + a latency, where `-1` is the "no reading" the client draws its unknown ping + sprite for. + """ + value, offset = take_var_int(payload, offset) + if value >= 1 << 31: + value -= 1 << 32 + return value, offset + + def _take_optional_string(payload, offset): present = payload[offset] offset += 1 @@ -141,7 +156,7 @@ def parse_player_info_update(payload): entry["listed"] = bool(payload[offset]) offset += 1 if actions & UPDATE_LATENCY: - _, offset = take_var_int(payload, offset) + entry["latency"], offset = take_var_int_signed(payload, offset) if actions & UPDATE_DISPLAY_NAME: present = payload[offset] offset += 1 diff --git a/tools/smash-bow-check.py b/tools/smash-bow-check.py index a2d3b8625..96a166086 100644 --- a/tools/smash-bow-check.py +++ b/tools/smash-bow-check.py @@ -14,6 +14,7 @@ * its launch heading is `look_angles(velocity)`, not the raw look yaw * the server broadcasts the arrow's position every tick as it flies * a longer draw launches faster than a shorter one (the charge curve) + * an arrow fired into the ground stops in it, rather than falling through Exits non-zero on the first untrue claim, after printing what it saw. """ @@ -117,16 +118,16 @@ def check(ok, message): def arrows_from(spawned): return [e for e in spawned if e["type"] == arrow_type] - def draw(seconds, note): + def draw(seconds, note, yaw=35.0, pitch=-20.0, settle=1.5): client.spawned.clear() client.syncs.clear() client.motions.clear() - client.aim(35.0, -20.0) + client.aim(yaw, pitch) client.send_position() client.use_slot(bow_slot, "(nock) " + note) pump(client, seconds) client.release_slot(bow_slot, "(release) " + note) - pump(client, 1.5) + pump(client, settle) return arrows_from(client.spawned) full = draw(2.6, "full draw") @@ -236,6 +237,106 @@ def perp_distance(point): % (vy_first, vy_last), ) + # --- the arrow stops in the ground it was fired into --- + # + # Every other claim in this file is about a shot into open sky, which is + # what the arrow scenarios in `docs/differential-testing.md` are too: they + # prove the flight and say nothing about what it hits. This is the one that + # exercises the terrain seam against real loaded chunks, and it is here + # rather than in a Rust test because no Rust test can. `tests/ + # projectile_blocks.rs` drives a `Cubes` fixture; the host half -- + # `HyperionBlocks::sweep` reaching into hyperion's block store for the + # arena's actual blocks -- has no mock, so a bug in it passes every unit + # test in the crate. That is the shape of the `Flying` mirror bug recorded + # in the repo's CLAUDE.md, and this is the assertion that would have caught + # this feature's version of it. + # + # Straight up, with a short draw, and let it fall back onto the arena + # floor. Every part of that is load bearing. + # + # Down at your feet is the obvious shot and it cannot be seen. Standing on + # the floor there is one eye height of clearance, and + # `smash::draw_projectiles` only decorates the projectile after + # `smash::fly` has already integrated it in the same phase -- so the + # AddEntity the client is told about lands half a block into a flight that + # is over in two ticks, and every velocity broadcast after it is a tail of + # zeros. That is ENG-12082: the drop measured `-0.00` blocks and the check + # called it a PASS. Firing upwards gives the drawn entity a whole flight to + # exist for, and an arrow with no horizontal velocity comes back down on the + # terrain it left, so the impact is guaranteed without knowing anything + # about the map. + pump(client, 0.6) + up = draw(0.4, "straight up", pitch=-90.0, settle=2.5) + check(len(up) >= 1, "an upward draw fires (got %d arrows)" % len(up)) + if up: + launched = up[0] + up_id = launched["id"] + velocities = client.motions.get(up_id, []) + + # The launch, asserted before anything about the stop. Without this the + # checks below pass just as loudly for an arrow that never moved: "every + # broadcast at rest" is what a projectile fired at zero speed looks like + # too, and a gate that cannot tell the feature working from the feature + # never firing is evidence of neither. The AddEntity motion is the launch + # as the wire carried it, one packet before any collision could have + # touched it. + launch_vy = launched["motion"][1] + print("upward launch: speed %.3f, vy %.3f" + % (launched["speed"], launch_vy), flush=True) + check( + launch_vy > 0.2, + "the upward arrow launched upwards at speed (vy %.3f blocks a tick)" + % launch_vy, + ) + check( + len(velocities) >= 2, + "the upward arrow is broadcast while it flies (got %d SetEntityMotion)" + % len(velocities), + ) + + # The shape that says "it flew, then it stopped", and the one the old + # check could not see: a non-zero broadcast strictly before the first + # zero. `len(stopped) >= 1` on its own is satisfied *most* loudly by an + # arrow that never moved on the wire -- eighteen of eighteen ticks at + # rest was its best possible score. + first_stop = next( + (i for i, v in enumerate(velocities) if v == (0.0, 0.0, 0.0)), + None, + ) + moving = [i for i, v in enumerate(velocities) if v != (0.0, 0.0, 0.0)] + print("upward stream: %d broadcasts, %d moving, first zero at %s" + % (len(velocities), len(moving), first_stop), flush=True) + check( + first_stop is not None and first_stop >= 1, + "the arrow was seen flying and then seen stopping (%d broadcasts, " + "%d of them moving, first zero at index %s)" + % (len(velocities), len(moving), first_stop), + ) + + if first_stop is not None: + # It went up and it came back down. Without this the stop could be + # the arrow hitting a ceiling on the way up, and the claim being + # made -- that an arrow stops in the ground rather than falling + # through it -- would not have been exercised at all. + rising = [vy for _, vy, _ in velocities[:first_stop] if vy > 0.0] + falling = [vy for _, vy, _ in velocities[:first_stop] if vy < 0.0] + check( + len(rising) >= 1 and len(falling) >= 1, + "the arrow rose and then fell before it stopped (%d rising " + "ticks, %d falling)" % (len(rising), len(falling)), + ) + + # And it stays stopped: `smash::fly` zeroes the flight on impact, + # `advance_drawn_projectiles` puts that on the wire, and `Stuck` + # keeps it there. + after = velocities[first_stop:] + check( + all(v == (0.0, 0.0, 0.0) for v in after), + "a stopped arrow stays stopped: %d broadcasts after the first " + "zero, %d of them moving" + % (len(after), sum(1 for v in after if v != (0.0, 0.0, 0.0))), + ) + pump(client, 0.6) short = draw(0.4, "short draw") check(len(short) >= 1, "a short draw still fires (got %d arrows)" % len(short)) diff --git a/tools/tab-list-check.py b/tools/tab-list-check.py new file mode 100755 index 000000000..ca9d4927a --- /dev/null +++ b/tools/tab-list-check.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""The tab list's tick rate and ping, read off the wire by the client that joined. + +Two features, and one of them can only be proved by a real client. + +# The tick rate + +`ClientboundTabListPacket` was sent zero times by smash before this: only +bedwars built one, and it built both halves itself, every tick, for every +player. `hyperion::egress::tab_list` now owns the packet and writes the footer +with the rate the tick loop actually managed, against the rate it is paced to: + + TPS 19.8 / 20.0 + 3 players online + +So the first assertion here is fail-then-pass by construction -- an unpatched +server sends no `TabList` at all -- and the rest read the label back and check +it says something a loop could have produced. + +# The ping, and the one thing only a client can settle + +`roster.rs` sent `ping: 0` at join and nothing ever again, which drew five full +bars for a measurement nobody had taken: the game server routed a serverbound +`keep_alive` to `Route::Ignore` and never sent a clientbound one. + +hyperion now probes with a keep-alive and times the answer. hyperion also puts +a *proxy* between the client and the game server, and this is the one place the +design could quietly lie: if the proxy answered keep-alives itself, the game +server would be timing the proxy, the number would look entirely plausible, and +nothing in a Rust test could tell the difference. + +Reading `crates/hyperion-proxy` says it does not -- there is no keep-alive +handling in it at all -- but that is an argument, not a measurement. This is the +measurement, and it is the reason this gate exists rather than a unit test: + + 1. join, and read the latency out of the roster: it must be -1, the client's + own "no reading" sprite, and not the 0 that used to draw five bars. + 2. answer keep-alives for a few seconds. A real latency must arrive in an + `UPDATE_LATENCY` delta, and on loopback it must be in the top bucket. + 3. **stop answering, and keep the connection otherwise busy.** After the + server's keep-alive timeout the latency must fall back to -1. + +Step 3 is the whole argument. If anything between this script and the game +server were answering keep-alives, the server would go on measuring a healthy +round trip while this client sat mute, and the reading would never go unknown. +It only goes unknown if the thing answering is *this process*. + + 4. start answering again, and watch a real reading come back, so what step 3 + proved is a measurement resuming and not a connection that died. + +Exits non-zero on the first thing that is not true, after printing what it saw. +""" + +import argparse +import importlib.util +import pathlib +import re +import struct +import sys +import time + +TOOLS = pathlib.Path(__file__).resolve().parent + + +def _load(name, filename): + spec = importlib.util.spec_from_file_location(name, TOOLS / filename) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +match = _load("smash_match", "smash-match.py") +monitor = _load("packet_monitor", "packet_monitor.py") + +take_var_int = match.base.take_var_int +var_int = match.base.var_int +take_nbt_string = match.take_nbt_string +parse_player_info_update = monitor.parse_player_info_update + +# crates/hyperion-minecraft-proto/src/generated/packet_id.rs, protocol 776. +S2C_TAB_LIST = 0x7A +S2C_PLAYER_INFO_UPDATE = monitor.S2C_PLAYER_INFO_UPDATE + +UPDATE_LATENCY = monitor.UPDATE_LATENCY +ADD_PLAYER = monitor.ADD_PLAYER + +# `hyperion::egress::ping::UNKNOWN`, which is the client's own `latency < 0` +# branch in `PlayerTabOverlay.extractPingIcon` and draws `icon/ping_unknown`. +UNKNOWN = -1 + +# `PlayerTabOverlay.extractPingIcon` again: under 150 ms is the five bar +# sprite. Anything on loopback that is not in this bucket is not a round trip, +# it is a bug. +FIVE_BARS_BELOW = 150 + +# `hyperion::Global::keep_alive_timeout`. How long a probe goes unanswered +# before the readout gives up on it, plus room for the next probe to be sent, +# answered nowhere, and time out in turn. +KEEP_ALIVE_TIMEOUT = 20.0 +MUTE_SECONDS = KEEP_ALIVE_TIMEOUT + 10.0 + +# `hyperion::egress::tab_list::footer_readout`. Both numbers, because the +# second is what makes the first checkable: a label carrying only "19.8" could +# be anything. +TPS_LABEL = re.compile(r"^TPS (\d+\.\d) / (\d+\.\d)$") +SAMPLING_LABEL = "TPS sampling" +PLAYERS_LABEL = re.compile(r"^(\d+) players? online$") + +# `hyperion::TICKS_PER_SECOND`, held here as the test's own expectation rather +# than read from the label being tested. +TARGET_TPS = 20.0 + + +class Watcher(match.MatchClient): + """A scripted player that can be told to stop answering keep-alives.""" + + def __init__(self, host, port, name, started): + super().__init__(host, port, name, started) + self.entity_id = None + self.joined = False + # Whether to answer a keep-alive. Flipping this off is the experiment. + self.answer_keep_alives = True + # How many arrived, so "the server stopped probing" and "this client + # stopped answering" cannot be confused for one another. + self.keep_alives = 0 + # Every tab list, as the two plain strings a player would read. + self.tab_lists = [] + # Every latency this client was told about itself, in arrival order, + # tagged with whether it came in the joining roster or a later delta. + self.latencies = [] + + def absorb(self, packet_id, payload): + if packet_id == match.S2C_LOGIN: + self.entity_id = struct.unpack(">i", payload[:4])[0] + self.joined = True + self.log("** in the world ** entity_id=%d" % self.entity_id) + elif packet_id == match.S2C_PLAYER_POSITION: + teleport_id, offset = take_var_int(payload) + x, y, z = struct.unpack(">ddd", payload[offset : offset + 24]) + self.position = (x, y, z) + self.send(match.C2S_ACCEPT_TELEPORTATION, var_int(teleport_id)) + self.log("<- teleported to (%.1f, %.1f, %.1f)" % (x, y, z)) + elif packet_id == match.S2C_KEEP_ALIVE: + self.keep_alives += 1 + if self.answer_keep_alives: + self.send(match.C2S_KEEP_ALIVE, payload[:8]) + else: + self.log("<- keep-alive #%d, deliberately not answered" % self.keep_alives) + elif packet_id == S2C_TAB_LIST: + header, offset = take_nbt_string(payload, 0) + footer, _ = take_nbt_string(payload, offset) + self.tab_lists.append({"header": header, "footer": footer}) + self.log("<- tab list footer %r" % footer) + elif packet_id == S2C_PLAYER_INFO_UPDATE: + actions, entries = parse_player_info_update(payload) + if not actions & UPDATE_LATENCY: + return + for entry in entries: + self.latencies.append( + { + "uuid": entry["uuid"], + "latency": entry["latency"], + "roster": bool(actions & ADD_PLAYER), + "at": time.monotonic(), + } + ) + self.log( + "<- latency %d ms (%s)" + % (entry["latency"], "roster" if actions & ADD_PLAYER else "delta") + ) + elif packet_id == match.S2C_DISCONNECT: + text, _ = match.take_nbt_string(payload, 0) + self.log("<- DISCONNECTED: %s" % text) + self.alive = False + + +def pump(client, seconds): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if not client.alive: + return + for packet_id, payload in client.drain(): + client.absorb(packet_id, payload) + if client.joined: + # Keeps the connection busy while mute, so a lost reading in step 3 + # is the keep-alive going unanswered and not the whole client + # having gone quiet. + client.repeat_position() + time.sleep(0.01) + + +def wait_until(client, predicate, seconds, what): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + pump(client, 0.05) + if predicate(): + return True + print("TIMEOUT waiting for %s" % what, file=sys.stderr) + return False + + +def footer_lines(footer): + return footer.split("\n") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=25565) + parser.add_argument("--name", default="Tabby") + args = parser.parse_args() + + started = time.time() + failures = [] + + def check(ok, message): + print("%s %s" % ("PASS" if ok else "FAIL", message), flush=True) + if not ok: + failures.append(message) + + client = Watcher(args.host, args.port, args.name, started) + client.handshake(args.host, args.port, 2) + client.login() + client.configuration() + client.enter_play() + + if not wait_until(client, lambda: client.joined, 30.0, "the world"): + print("RESULT: failure (never joined)", flush=True) + return 1 + + # --- the tick rate ---------------------------------------------------- + # + # A joining client is unicast the current text, so one arrives without + # waiting for anything to change. The measured number needs a full window + # first, so allow for the label starting at "sampling". + wait_until( + client, + lambda: any( + TPS_LABEL.match(footer_lines(t["footer"])[0]) for t in client.tab_lists + ), + 20.0, + "a tab list carrying a measured tick rate", + ) + + check( + bool(client.tab_lists), + "a TabList (id 122) arrives at all -- this server sent none before " + "(got %d)" % len(client.tab_lists), + ) + if not client.tab_lists: + print("RESULT: failure (no tab list)", flush=True) + return 1 + + measured = [ + (t, TPS_LABEL.match(footer_lines(t["footer"])[0])) + for t in client.tab_lists + if TPS_LABEL.match(footer_lines(t["footer"])[0]) + ] + check( + bool(measured), + "the footer's first line carries a measured rate and the rate it is " + "paced to; the footers seen were %r" + % [t["footer"] for t in client.tab_lists], + ) + if not measured: + print("RESULT: failure (no TPS label)", flush=True) + return 1 + + last, groups = measured[-1] + rate, target = float(groups.group(1)), float(groups.group(2)) + check( + target == TARGET_TPS, + "the label prints the ceiling it is drawn against (%.1f, expected " + "%.1f)" % (target, TARGET_TPS), + ) + check( + 0.0 < rate <= target, + "the measured rate is a rate this loop could have produced: 0 < %.1f " + "<= %.1f" % (rate, target), + ) + players = PLAYERS_LABEL.match(footer_lines(last["footer"])[1]) + check( + players is not None and int(players.group(1)) >= 1, + "the footer's second line counts the players actually connected " + "(%r)" % footer_lines(last["footer"])[1], + ) + + # A constant cannot produce this line. If the client got in before the + # first measurement window closed, the server said so rather than guessing, + # which is the strongest evidence available here that the number is + # measured. Not required -- a client that joins later never sees it. + sampled = any( + footer_lines(t["footer"])[0] == SAMPLING_LABEL for t in client.tab_lists + ) + print( + "NOTE the first window %s observed as %r" + % ("was" if sampled else "was not", SAMPLING_LABEL), + flush=True, + ) + + # --- the ping --------------------------------------------------------- + roster = [entry for entry in client.latencies if entry["roster"]] + check( + bool(roster) and all(entry["latency"] == UNKNOWN for entry in roster), + "the joining roster carries %d (no reading yet) and not the 0 that " + "used to draw five full bars (got %r)" + % (UNKNOWN, [entry["latency"] for entry in roster]), + ) + + wait_until( + client, + lambda: any( + entry["latency"] >= 0 and not entry["roster"] for entry in client.latencies + ), + 20.0, + "a measured latency", + ) + real = [ + entry for entry in client.latencies if entry["latency"] >= 0 and not entry["roster"] + ] + check( + bool(real), + "a real round trip arrives as an UPDATE_LATENCY delta once keep-alives " + "are being answered (got %r)" % [e["latency"] for e in client.latencies], + ) + if not real: + print("RESULT: failure (no latency was ever measured)", flush=True) + return 1 + check( + 0 <= real[-1]["latency"] < FIVE_BARS_BELOW, + "the loopback round trip is in the client's top bucket (%d ms, must " + "be under %d)" % (real[-1]["latency"], FIVE_BARS_BELOW), + ) + check( + client.keep_alives >= 2, + "the server probes repeatedly rather than once (%d keep-alives)" + % client.keep_alives, + ) + + # --- who answers keep-alives ------------------------------------------ + # + # Go mute while staying otherwise busy. Only this process can answer a + # keep-alive, so only this process going quiet can take the reading away. + print( + "NOTE going mute for %.0f s: not answering keep-alives, still sending " + "position" % MUTE_SECONDS, + flush=True, + ) + before_mute = len(client.latencies) + keep_alives_before = client.keep_alives + client.answer_keep_alives = False + wait_until( + client, + lambda: any( + entry["latency"] == UNKNOWN for entry in client.latencies[before_mute:] + ), + MUTE_SECONDS, + "the reading to go unknown while nothing answers keep-alives", + ) + went_unknown = [ + entry for entry in client.latencies[before_mute:] if entry["latency"] == UNKNOWN + ] + check( + bool(went_unknown), + "with this client refusing to answer, the reading falls back to %d -- " + "so the thing answering keep-alives is the client and not the proxy " + "(latencies seen while mute: %r)" + % (UNKNOWN, [e["latency"] for e in client.latencies[before_mute:]]), + ) + check( + client.keep_alives > keep_alives_before, + "keep-alives kept arriving while mute (%d more), so the fallback is a " + "timeout and not the server having stopped probing" + % (client.keep_alives - keep_alives_before), + ) + check( + client.alive, + "the connection survives an unanswered keep-alive; hyperion does not " + "disconnect over one", + ) + + # --- and back --------------------------------------------------------- + print("NOTE answering keep-alives again", flush=True) + before_resume = len(client.latencies) + client.answer_keep_alives = True + wait_until( + client, + lambda: any( + entry["latency"] >= 0 for entry in client.latencies[before_resume:] + ), + 30.0, + "the reading to come back", + ) + recovered = [ + entry for entry in client.latencies[before_resume:] if entry["latency"] >= 0 + ] + check( + bool(recovered), + "the reading comes back once answers resume, so what went unknown was " + "a measurement and not a dead connection (got %r)" + % [e["latency"] for e in client.latencies[before_resume:]], + ) + + if failures: + print( + "RESULT: failure (%d checks failed): %s" + % (len(failures), "; ".join(failures)), + flush=True, + ) + return 1 + print( + "RESULT: success (tick rate %.1f / %.1f on the wire; ping measured " + "against the client, which is the only thing that answers a " + "keep-alive)" % (rate, target), + flush=True, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())