Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2553d9c
feat(sim): add per-category gravity constants and falling-block predi…
luizinhoh2o1 Jul 20, 2026
02239b5
feat(sim): add EntityIdExhausted error variant
luizinhoh2o1 Jul 20, 2026
7a37b49
feat(sim): add entity spawn, move, and despawn messages
luizinhoh2o1 Jul 20, 2026
b2e5772
feat(sim): add entity store and per-tick gravity physics
luizinhoh2o1 Jul 20, 2026
e40d3e5
docs(sim): document gravity and entity physics roadmap
luizinhoh2o1 Jul 20, 2026
66b601d
docs: record falling blocks under implemented features
luizinhoh2o1 Jul 20, 2026
0ec0ea7
feat(sim): tag EntitySpawned with the entity kind
luizinhoh2o1 Jul 20, 2026
5269f48
feat(session): render falling blocks as animated entities
luizinhoh2o1 Jul 20, 2026
f8ec8a0
docs: record animated falling blocks
luizinhoh2o1 Jul 20, 2026
ba55424
feat(sim): swept AABB entity collision
luizinhoh2o1 Jul 20, 2026
3aa3046
feat(sim): falling block breaks on an object in its resting cell
luizinhoh2o1 Jul 20, 2026
c0bb169
feat(sim): despawn falling block on landing, drop the deferred despawn
luizinhoh2o1 Jul 21, 2026
2d220e4
fix(sim): a gravity block placed on redstone or other non-cube suppor…
luizinhoh2o1 Jul 21, 2026
812029a
refactor(sim): extract block_state_at, shared by the two block-read p…
luizinhoh2o1 Jul 21, 2026
f4159de
feat(sim): break falling block on any non-replaceable object, not jus…
luizinhoh2o1 Jul 21, 2026
e438bc4
perf(sim): reuse broadphase buffer, skip empty entity step, name rest…
luizinhoh2o1 Jul 21, 2026
032fdd2
feat(sim): break falling block on non-full-height solid supports
luizinhoh2o1 Jul 21, 2026
fce1449
feat(sim): break falling block landing on pointed dripstone and ameth…
luizinhoh2o1 Jul 21, 2026
fa49db1
feat(sim): expand non-full solid set that breaks falling blocks
luizinhoh2o1 Jul 21, 2026
11c1421
feat(registry): vendor per-state collision-top heights and expose col…
luizinhoh2o1 Jul 21, 2026
cd329be
refactor(sim): drive falling-block break by real collision height, dr…
luizinhoh2o1 Jul 21, 2026
705963d
chore(registry): commit collision-height generator, pin and checksum …
luizinhoh2o1 Jul 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@
- *Test:* Try to break a block >6 blocks away in a loaded chunk — your client's ghost block snaps back (resync).
- **Block-update broadcast.** Edits are fanned out to other players who can see the chunk.
- *Test:* Two clients in view of each other; one places/breaks a block — the other sees the change.
- **Falling blocks (gravity), animated end-to-end.** A gravity-affected block (sand, red sand, gravel, all concrete powders, anvils, dragon egg, pointed dripstone) that loses support on place or break falls on the owning shard: the unsupported block becomes a simulated falling entity, drops under per-category gravity + air drag, lands on the first solid block below, and restores its block at the resting cell. Breaking the base of a stack collapses the whole gravity column at once and it re-stacks on landing. Runs deterministically in `ferrumc-sim` (fixed-order per-tick step); an entity that falls past y=-128 (64 blocks below the world floor) despawns instead of falling forever. The session layer now draws the fall: the shard's `EntitySpawned` / `EntityMoved` / `EntityDespawned` outputs are translated to a clientbound `SpawnEntity` (`minecraft:falling_block`, type 49, carrying the block-state), per-tick position updates, and `RemoveEntities` on landing — scoped to viewers within view distance, with a fresh network entity id per entity so it never collides with a player's.
- *Test:* Place sand/gravel with nothing under it, or break the block supporting a sand pillar — you see it **fall** as an animated block and settle on the ground (no more teleport-jump). Stack several sand on a one-block pillar and break the pillar; the whole stack falls and re-stacks. A second player in range sees the same animation.
- ⚠ **Non-block gravity entities still have no spawner.** The physics step and the session-side entity rendering can carry any entity (dropped items, primed TNT, projectiles, XP orbs), but nothing produces them yet — only falling blocks feed the pipeline. A generic (`Simple`) spawn has no wire entity type assigned, so it is simulated but not drawn.
- *Test:* Break a block — no item entity drops (creative has no drops anyway); there is no TNT/arrow/orb to observe.
- ⚠ **Some placement properties still default (out of v0 scope).** The engine covers the families above; it does **not** yet derive waterlogging, doors, beds, rails, signs/banners, fence gates, stair inner/outer corners, redstone, or double-slab merging — those are placed as their default/simple state.
- *Test:* Place a block "in" water (never waterlogs), or place a door/bed/sign (default state, no orientation logic yet).

Expand Down
1 change: 1 addition & 0 deletions fixtures/protocol/1_21_8/block_collision_heights.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions fixtures/protocol/1_21_8/manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ data_version = 4440
# Packet id/type tables. The `spec_ids_match_vendored_protocol_json` test in
# ferrumc-proto-gen cross-checks every spec packet id against this file.
"protocol.json" = { sha256 = "06837c1cacd88149f951dde09951cc5ecfcd6280435e6e03729280f8b46e843a", bytes = 288649 }
# Derived (not raw upstream): per-state collision-box top heights reduced from this
# same pinned commit's `blockCollisionShapes.json` by `scripts/gen_collision_heights.py`.
# Consumed by ferrumc-registry's build script to generate the `collision_top` table.
"block_collision_heights.json" = { sha256 = "0af7b45337e1f523e476bbac2be777cf7c9edfccac44c06b029cd10af04d892d", bytes = 70614 }
98 changes: 98 additions & 0 deletions scripts/gen_collision_heights.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Regenerate the vendored per-state collision-top heights for ferrumc-registry.

The registry needs, per block state, the top height (max Y) of the block's
collision box so the simulation can decide falling-block landings precisely (a
support whose collision top is below a full cube breaks a faller; a full cube
settles it). The upstream PrismarineJS `minecraft-data` snapshot ships the full
collision shapes in `blockCollisionShapes.json`; this script reduces them to a
single top height per state, aligned to the block-state encoding used by
`blocks.json`, and writes the compact `block_collision_heights.json` the build
script consumes.

Provenance: both inputs are the SAME pinned snapshot recorded in
`fixtures/protocol/1_21_8/manifest.toml`:

repo = https://github.com/PrismarineJS/minecraft-data
commit = a8cf733fe44f3069f87f63e7ec3d74b521840ded
path = data/pc/1.21.8 (Minecraft 1.21.8, dataVersion 4440)

`blocks.json` is vendored (fixtures + registry/data); `blockCollisionShapes.json`
is fetched at that commit and passed in rather than vendored (it is ~1.7 MiB and
only its derived heights are needed).

Usage:

# fetch the collision shapes at the pinned commit, then derive:
curl -sSL -o /tmp/bcs.json \\
https://raw.githubusercontent.com/PrismarineJS/minecraft-data/a8cf733fe44f3069f87f63e7ec3d74b521840ded/data/pc/1.21.8/blockCollisionShapes.json
python3 scripts/gen_collision_heights.py /tmp/bcs.json

Output format (`block_collision_heights.json`): a JSON object mapping a bare
block name to either a single height (uniform across every state) or an ordered
per-state list (states whose height varies, e.g. slabs, snow layers, dripstone),
indexed by `state_id - minStateId`. A block absent from the collision snapshot is
omitted; the build script then falls back to the coarse `boundingBox` flag.
"""

import json
import os
import sys

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BLOCKS = os.path.join(REPO_ROOT, "fixtures", "protocol", "1_21_8", "blocks.json")
OUT = os.path.join(
REPO_ROOT, "fixtures", "protocol", "1_21_8", "block_collision_heights.json"
)


def max_top(shape_boxes):
"""The highest collision-box top (max y1), or 0.0 for an empty shape."""
return round(max((box[4] for box in shape_boxes), default=0.0), 5)


def main(argv):
if len(argv) != 2:
sys.exit(f"usage: {argv[0]} <blockCollisionShapes.json>")
with open(argv[1], encoding="utf-8") as f:
collision = json.load(f)
with open(BLOCKS, encoding="utf-8") as f:
blocks = json.load(f)

block_index = collision["blocks"]
shapes = collision["shapes"]

out = {}
missing = []
for block in blocks:
name = block["name"]
state_count = block["maxStateId"] - block["minStateId"] + 1
entry = block_index.get(name)
if entry is None:
missing.append(name)
continue
if isinstance(entry, int):
out[name] = max_top(shapes[str(entry)])
continue
heights = [max_top(shapes[str(i)]) for i in entry]
if len(heights) != state_count:
# Length disagreement: emit a single conservative max so the build
# script never mis-indexes a state.
out[name] = max(heights)
elif all(h == heights[0] for h in heights):
out[name] = heights[0]
else:
out[name] = heights

with open(OUT, "w", encoding="utf-8") as f:
json.dump(out, f, separators=(",", ":"), sort_keys=True)

per_state = sum(1 for v in out.values() if isinstance(v, list))
print(f"wrote {OUT} ({os.path.getsize(OUT)} bytes)")
print(f"blocks: {len(out)} per-state: {per_state} missing: {len(missing)}")
if missing:
print(f"missing from collision snapshot (build.rs falls back): {missing}")


if __name__ == "__main__":
main(sys.argv)
59 changes: 56 additions & 3 deletions server/crates/ferrumc-registry/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,18 @@ fn main() {
let items_path = data_dir.join("items.json");
let mapping_path = data_dir.join("item_to_block_mapping.json");
let blocks_path = data_dir.join("blocks.json");
// Derived per-state collision-top heights live beside the checksummed vendored
// fixtures (integrity-guarded by `manifest.toml`), not in `data/`, so there is a
// single provenance-tracked copy. Regenerated by `scripts/gen_collision_heights.py`.
let collision_path =
manifest_dir.join("../../../fixtures/protocol/1_21_8/block_collision_heights.json");

// Re-run only when the vendored data (or this generator) changes.
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed={}", items_path.display());
println!("cargo:rerun-if-changed={}", mapping_path.display());
println!("cargo:rerun-if-changed={}", blocks_path.display());
println!("cargo:rerun-if-changed={}", collision_path.display());

let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));

Expand All @@ -148,7 +154,8 @@ fn main() {
let dest = out_dir.join("items_generated.rs");
fs::write(&dest, source).unwrap_or_else(|e| panic!("failed to write {}: {e}", dest.display()));

let blocks = load_blocks(&blocks_path);
let collision_heights = load_collision_heights(&collision_path);
let blocks = load_blocks(&blocks_path, &collision_heights);
let blocks_source = emit_blocks(&blocks);
let blocks_dest = out_dir.join("blocks_generated.rs");
fs::write(&blocks_dest, blocks_source)
Expand Down Expand Up @@ -204,13 +211,42 @@ struct Block {
max_state: u32,
is_solid_cube: bool,
properties: Vec<Property>,
/// Collision-box top height (max Y, in blocks) per state. Length `1` when every
/// state shares one height (the common case), otherwise one entry per state in
/// state-id order (slabs, snow layers, dripstone vary). Sourced from the vendored
/// `fixtures/protocol/1_21_8/block_collision_heights.json` (derived from the pinned
/// `PrismarineJS` `blockCollisionShapes` by `scripts/gen_collision_heights.py`); a
/// block absent from that file falls back to the coarse `boundingBox` (`1.0` for a
/// full cube, `0.0` for none).
collision_top: Vec<f32>,
}

/// Loads the derived per-state collision-top heights: a map of block name to either a
/// single height (uniform) or an ordered per-state list. Values are JSON numbers.
fn load_collision_heights(path: &Path) -> BTreeMap<String, Vec<f32>> {
let raw = fs::read_to_string(path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
let map: BTreeMap<String, serde_json::Value> = serde_json::from_str(&raw)
.unwrap_or_else(|e| panic!("block_collision_heights.json is not the expected schema: {e}"));
map.into_iter()
.map(|(name, value)| {
let heights = match value {
serde_json::Value::Array(items) => items
.iter()
.map(|v| v.as_f64().unwrap_or(0.0) as f32)
.collect(),
other => vec![other.as_f64().unwrap_or(0.0) as f32],
};
(name, heights)
})
.collect()
}

/// Parses `blocks.json` into normalised [`Block`]s, asserting the block-state
/// encoding invariants: `min <= default <= max`, the dense range width equals the
/// product of the property cardinalities (the little-endian linear encoding), all
/// names are unique, and no two blocks' state ranges overlap.
fn load_blocks(blocks_path: &Path) -> Vec<Block> {
fn load_blocks(blocks_path: &Path, heights: &BTreeMap<String, Vec<f32>>) -> Vec<Block> {
let raw = fs::read_to_string(blocks_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", blocks_path.display()));
// blocks.json is a JSON ARRAY of block entries.
Expand Down Expand Up @@ -281,14 +317,26 @@ fn load_blocks(blocks_path: &Path) -> Vec<Block> {

ranges.push((b.min_state_id, b.max_state_id, b.name.clone()));

// Per-state collision-top heights: use the derived list when it is either a
// single uniform value or exactly one value per state; otherwise fall back
// to the coarse `boundingBox` (a full cube tops at 1.0, no collision at 0.0)
// so a malformed or missing entry can never mis-index a state.
let nstates = (b.max_state_id - b.min_state_id + 1) as usize;
let is_solid_cube = b.bounding_box == "block";
let collision_top = match heights.get(&b.name) {
Some(hs) if hs.len() == 1 || hs.len() == nstates => hs.clone(),
_ => vec![if is_solid_cube { 1.0 } else { 0.0 }],
};

Block {
id: b.id,
name: b.name,
default_state: b.default_state,
min_state: b.min_state_id,
max_state: b.max_state_id,
is_solid_cube: b.bounding_box == "block",
is_solid_cube,
properties,
collision_top,
}
})
.collect();
Expand Down Expand Up @@ -346,6 +394,11 @@ fn emit_blocks(blocks: &[Block]) -> String {
"name: {:?}, id: {}, default_state: {}, min_state: {}, max_state: {}, is_solid_cube: {}, ",
b.name, b.id, b.default_state, b.min_state, b.max_state, b.is_solid_cube
);
out.push_str("collision_top: &[");
for h in &b.collision_top {
let _ = write!(out, "{h:?}, ");
}
out.push_str("], ");
if b.properties.is_empty() {
out.push_str("properties: &[] },\n");
} else {
Expand Down
64 changes: 62 additions & 2 deletions server/crates/ferrumc-registry/src/block_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ pub struct BlockMetadata {
/// Whether the block is a full solid cube (`boundingBox == "block"`), used by
/// the placement layer to decide fence connectivity to non-fence neighbours.
pub is_solid_cube: bool,
/// Collision-box top height (max Y, in blocks) per state. Length `1` when the
/// height is uniform across states (the common case), otherwise one entry per
/// state in state-id order (slabs, snow layers, dripstone differ across states).
/// See [`collision_top_y`] for the per-state lookup.
pub collision_top: &'static [f32],
/// The block's properties, in state-id encoding order.
pub properties: &'static [Property],
}
Expand Down Expand Up @@ -135,6 +140,34 @@ pub fn state_id_to_block_name(state_id: u32) -> Option<&'static str> {
(state_id <= max).then(|| BLOCKS[idx].name)
}

/// Returns the collision-box top height (max Y, in blocks) of the block state
/// `state_id`, or `None` when no block owns that id.
///
/// The value classifies how a falling block interacts with the state as a *support*:
/// `0.0` is no collision (a faller passes through, landing on the block below);
/// `>= 1.0` is a full-height top (a faller settles on top of it); `0.0 < h < 1.0` is
/// a non-full support (a slab, soul sand, dripstone tip, …) that a faller *lands on*
/// but breaks against instead of settling, matching vanilla. Resolved per state, so
/// a bottom slab (`0.5`) and a double slab (`1.0`) differ.
///
/// Binary-searches the same disjoint range table as [`state_id_to_block_name`], then
/// indexes the owning block's per-state height slice (a uniform block stores one
/// value shared by every state).
#[must_use]
pub fn collision_top_y(state_id: u32) -> Option<f32> {
let after = BLOCKS_BY_MIN_STATE.partition_point(|&(min, _, _)| min <= state_id);
let (min, max, idx) = *BLOCKS_BY_MIN_STATE.get(after.checked_sub(1)?)?;
if state_id > max {
return None;
}
let tops = BLOCKS[idx].collision_top;
if tops.len() == 1 {
tops.first().copied()
} else {
tops.get((state_id - min) as usize).copied()
}
}

/// Returns the place value (multiplier) of property `pi` in the block's linear
/// state encoding: the product of the cardinalities of all *later* properties.
fn multiplier(meta: &BlockMetadata, pi: usize) -> u32 {
Expand Down Expand Up @@ -195,8 +228,8 @@ mod catalog_tests {
use std::path::PathBuf;

use super::{
block_default_state, block_metadata, compute_state_id, state_id_to_block_name,
PropertyType, BLOCK_COUNT,
block_default_state, block_metadata, collision_top_y, compute_state_id,
state_id_to_block_name, PropertyType, BLOCK_COUNT,
};

/// The vendored snapshot the generated tables are built from.
Expand Down Expand Up @@ -479,4 +512,31 @@ mod catalog_tests {
// Past the end of the registry there is no owning block.
assert_eq!(state_id_to_block_name(u32::MAX), None);
}

#[test]
fn collision_top_y_classifies_supports() {
// Full cubes top at 1.0; open blocks at 0.0; partials in between.
let full = |name: &str| collision_top_y(block_default_state(name).unwrap());
assert_eq!(full("stone"), Some(1.0));
assert_eq!(full("torch"), Some(0.0));
assert_eq!(full("soul_sand"), Some(0.875));
assert_eq!(full("farmland"), Some(0.9375));
assert_eq!(full("repeater"), Some(0.125));

// Per-state: a bottom slab is half height, a double slab a full cube.
let bottom = compute_state_id(
"oak_slab",
&BTreeMap::from([("type", "bottom"), ("waterlogged", "false")]),
)
.unwrap();
let double = compute_state_id(
"oak_slab",
&BTreeMap::from([("type", "double"), ("waterlogged", "false")]),
)
.unwrap();
assert_eq!(collision_top_y(bottom), Some(0.5));
assert_eq!(collision_top_y(double), Some(1.0));

assert_eq!(collision_top_y(u32::MAX), None);
}
}
8 changes: 5 additions & 3 deletions server/crates/ferrumc-session/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ tokio = { workspace = true }
# `SessionError` is a thiserror enum so each failure mode is a classified variant
# with a derived `Display`, per the library error-handling rule.
thiserror = { workspace = true }
# Boss-bar packets are keyed by a 128-bit `uuid::Uuid`; the scoreboard builders
# accept one and forward it to the typed `BossBar` head (this crate never mints a
# UUID itself — the caller derives a stable id for each bar).
# A 128-bit `uuid::Uuid` is needed in two places: boss-bar packets are keyed by
# one (the scoreboard builders accept it and forward it to the typed `BossBar`
# head), and a non-player entity's clientbound `SpawnEntity` packet carries one
# (unlike a player there is no `PlayerId` to source it from, so the router mints
# a deterministic id from the entity's network id).
uuid = { workspace = true }

[dev-dependencies]
Expand Down
Loading
Loading