diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs index b4a172011..46447eb1c 100644 --- a/crates/horizon-client/src/containers.rs +++ b/crates/horizon-client/src/containers.rs @@ -75,6 +75,9 @@ pub struct Crafter { pub disabled: [bool; 9], pub triggered: bool, pub crafting_ticks: i32, + /// The block state's `triggered` when last ticked (not saved): the block + /// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`). + pub state_triggered: Option, } impl Crafter { /// `slotCanBeDisabled`: only empty slots. @@ -184,7 +187,12 @@ impl BlockEntity { return Some(Self::Sculk(Box::new(sculk))); } if name == "minecraft:crafter" { - return Some(Self::Crafter(Crafter::default())); + // CrafterBlock.newBlockEntity copies the placed state's flag: the + // first tick sees it as a change from untriggered. + return Some(Self::Crafter(Crafter { + state_triggered: Some(false), + ..Crafter::default() + })); } if name.ends_with("_banner") { return Some(Self::Banner(Banner::default())); diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 32e793b67..b38fb8469 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -253,17 +253,37 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { } else { map.insert("equipment".into(), Tag::compound(equipment)); } - let attr = |id: &str, v: f64| { - Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))]) + let modelled = [ + ("minecraft:max_health", f64::from(m.attrs.max_health)), + ("minecraft:movement_speed", m.attrs.speed), + ("minecraft:jump_strength", m.extra.vector[0]), + ]; + // AttributeMap.pack writes the instances that exist: the ones the + // entity was loaded with, or all three randomized by finalizeSpawn. + let attributes = match map.get(&"attributes".into()) { + Some(Tag::List(list)) => list + .iter() + .map(|a| { + let id = text(a.get("id")); + let value = modelled.iter().find(|(m, _)| Some(*m) == id.as_deref()); + match (a, value) { + (Tag::Compound(c), Some((_, v))) => { + let mut c = c.clone(); + c.insert("base".into(), Tag::Double(v.to_bits())); + Tag::Compound(c) + } + _ => a.clone(), + } + }) + .collect(), + _ => modelled + .iter() + .map(|(id, v)| { + Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))]) + }) + .collect(), }; - map.insert( - "attributes".into(), - Tag::List(vec![ - attr("minecraft:max_health", f64::from(m.attrs.max_health)), - attr("minecraft:movement_speed", m.attrs.speed), - attr("minecraft:jump_strength", m.extra.vector[0]), - ]), - ); + map.insert("attributes".into(), Tag::List(attributes)); } "minecraft:pig" | "minecraft:strider" => { let mut equipment = match map.get(&"equipment".into()) { @@ -926,7 +946,8 @@ pub fn falling_block_to_nbt( state: Tag, position: Vec3, motion: Vec3, - time: u32, + time: i32, + drop_item: bool, uuid: [i32; 4], ) -> Tag { let base = Base { @@ -941,8 +962,8 @@ pub fn falling_block_to_nbt( let mut tag = base.tag(None); for (key, value) in [ ("BlockState", state), - ("Time", Tag::Int(time as i32)), - ("DropItem", Tag::Byte(1)), + ("Time", Tag::Int(time)), + ("DropItem", Tag::Byte(i8::from(drop_item))), ("HurtEntities", Tag::Byte(0)), ("CancelDrop", Tag::Byte(0)), ("FallHurtAmount", Tag::Float(0)), @@ -953,15 +974,23 @@ pub fn falling_block_to_nbt( tag } /// (block state tag, position, motion, time) -pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, u32)> { +pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32)> { Some(( tag.get("BlockState")?.clone(), position(tag)?, motion(tag), - number(tag.get("Time")).unwrap_or(0).max(0) as u32, + number(tag.get("Time")).unwrap_or(0) as i32, )) } +/// `NoGravity` and `DropItem` (true when absent) of a saved falling block. +pub fn falling_block_flags(tag: &Tag) -> (bool, bool) { + ( + flag(tag, "NoGravity"), + number(tag.get("DropItem")).is_none_or(|v| v != 0), + ) +} + /// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`. pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { let (lo, hi) = p.bounds(); diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index b81c403fd..bf12fea67 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -482,7 +482,14 @@ struct FallingBlock { position: Vec3, previous: Vec3, velocity: Vec3, - age: u32, + /// `FallingBlockEntity.time`: signed, a save can start it below zero. + age: i32, + /// `Entity.isNoGravity`. + no_gravity: bool, + /// `FallingBlockEntity.dropItem`. + drop_item: bool, + /// The UUID and compound it was loaded with, kept while it moves. + saved: Option>, } pub struct Resources { assets: Arc, @@ -1487,6 +1494,9 @@ impl Game<'_> { previous: p, velocity: Vec3::ZERO, age: 0, + no_gravity: false, + drop_item: true, + saved: None, }); } LevelEvent::Player { pos, effect, .. } => match effect { @@ -1695,10 +1705,14 @@ impl Game<'_> { fn tick_falling(&mut self) -> Result<()> { let mut falling = std::mem::take(&mut self.falling); let mut landed = Vec::new(); + let mut dropped = Vec::new(); + let entity_drops = self.ui.rules.bool("entity_drops"); falling.retain_mut(|f| { f.previous = f.position; - f.age += 1; - f.velocity.y -= 0.04; + f.age = f.age.wrapping_add(1); + if !f.no_gravity { + f.velocity.y -= 0.04; + } let next = Vec3::new( f.position.x + f.velocity.x, f.position.y + f.velocity.y, @@ -1709,8 +1723,18 @@ impl Game<'_> { next.y.floor() as i32, next.z.floor() as i32, ); - let solid = self.res.facts.get(below).full_collision; - if solid || f.age > 600 { + let solid = self.res.facts.get(below).full_collision && f.velocity.y < 0.0; + // FallingBlockEntity.tick: off the ground, it gives up after 600 + // ticks (100 outside the world) and drops itself as an item. + let y = next.y.floor() as i32; + let outside = y <= horizon_client::chunk::MIN_Y || y >= horizon_client::chunk::MIN_Y + 384; + if !solid && (f.age > 100 && outside || f.age > 600) { + if f.drop_item && entity_drops { + dropped.push((f.state, next)); + } + return false; + } + if solid { landed.push(( f.state, [ @@ -1730,6 +1754,25 @@ impl Game<'_> { true }); self.falling = falling; + for (state, at) in dropped { + // Entity.spawnAtLocation: an item entity where the block was. + let item = self.res.facts.get(state).item.clone(); + if &*item != "minecraft:air" { + let r = &mut self.world.random; + let v = [ + r.next_double() * 0.2 - 0.1, + 0.2, + r.next_double() * 0.2 - 0.1, + ]; + let stack = ItemStack { + item, + count: 1, + damage: 0, + extra: None, + }; + self.spawn_item(stack, [at.x, at.y, at.z], v, 10); + } + } for (state, pos) in landed { let landing = self.with_level(|b, level, ticks| { b.falling_block_landed( diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 26c1bf86f..9bbe3e546 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -536,9 +536,18 @@ impl Game<'_> { let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize; chunks.swap(i, j); } + // ServerChunkCache.tickChunks: the chunks are shuffled every tick, but + // no category spawns without spawn_mobs, and only friendly ones + // unless the level is spawning monsters (ServerLevel.isSpawningMonsters). + let spawn_mobs = self.ui.rules.bool("spawn_mobs"); + let spawn_enemies = spawn_mobs && self.ui.rules.bool("spawn_monsters"); for (cx, cz) in chunks { for (ci, (category, cap)) in CATEGORIES.iter().enumerate() { - if counts[ci] >= *cap || *category == "creature" && !creature_tick { + if !spawn_mobs + || counts[ci] >= *cap + || *category == "creature" && !creature_tick + || *category == "monster" && !spawn_enemies + { continue; } counts[ci] += self.spawn_category_for_chunk(category, cx, cz, p); @@ -688,9 +697,12 @@ impl Game<'_> { } self.tick_inhabited(); self.tick_leads(); - self.tick_wandering_trader(); self.spawn_natural(); - self.tick_patrol_spawner(); + // ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs. + if self.ui.rules.bool("spawn_mobs") { + self.tick_wandering_trader(); + self.tick_patrol_spawner(); + } self.tick_raids(); let uuid = self.level.player_uuid; let snapshot: Vec = self.mobs.iter().map(|m| seen(m, uuid)).collect(); diff --git a/crates/horizon-client/src/game/machines/crafter.rs b/crates/horizon-client/src/game/machines/crafter.rs index 1c8a558e6..af37192ff 100644 --- a/crates/horizon-client/src/game/machines/crafter.rs +++ b/crates/horizon-client/src/game/machines/crafter.rs @@ -72,7 +72,12 @@ impl Game<'_> { let triggered = self.containers().prop(state, "triggered") == Some("true"); let mut finished = false; if let Some(BlockEntity::Crafter(c)) = self.world.block_entities.get_mut(&pos) { - c.triggered = triggered; + // CrafterBlock.neighborChanged sets the flag with the state; a + // loaded crafter keeps its saved flag until then. + if c.state_triggered.is_some_and(|t| t != triggered) { + c.triggered = triggered; + } + c.state_triggered = Some(triggered); let remaining = c.crafting_ticks - 1; if remaining >= 0 { c.crafting_ticks = remaining; diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index edb385c64..2aa368383 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -48,7 +48,16 @@ fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag { // Defaults Horizon invents for fields it does not simulate give way to the save. let invented = matches!( k.to_string_lossy().as_str(), - "Fire" | "Air" | "Invulnerable" | "PortalCooldown" | "HasBeenShot" + "Fire" + | "Air" + | "Invulnerable" + | "PortalCooldown" + | "HasBeenShot" + // FallingBlockEntity fields Horizon does not simulate. + | "HurtEntities" + | "CancelDrop" + | "FallHurtAmount" + | "FallHurtMax" ); if !(invented && out.contains_key(k)) { out.insert(k.clone(), v.clone()); @@ -176,12 +185,16 @@ impl Game<'_> { entity_nbt::falling_block_from_nbt(&tag) && let Some(state) = self.state_from_nbt(&state) { + let (no_gravity, drop_item) = entity_nbt::falling_block_flags(&tag); self.falling.push(FallingBlock { state, position, previous: position, velocity, age, + no_gravity, + drop_item, + saved: uuid.map(|u| Box::new((u, tag.clone()))), }); } } @@ -497,12 +510,16 @@ impl Game<'_> { } for f in &self.falling { if keys.contains(&chunk_of(f.position)) { - let (uuid, raw) = anon(&self.persist, "minecraft:falling_block", f.position); + let (uuid, raw) = match &f.saved { + Some(saved) => (saved.0, Some(saved.1.clone())), + None => anon(&self.persist, "minecraft:falling_block", f.position), + }; let tag = entity_nbt::falling_block_to_nbt( self.state_to_nbt(f.state), f.position, f.velocity, f.age, + f.drop_item, uuid, ); out.entry(chunk_of(f.position)) diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 9324f7fd0..0211673ab 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -568,6 +568,7 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain } } c.triggered = int("triggered").unwrap_or(0) != 0; + c.state_triggered = None; c.crafting_ticks = int("crafting_ticks_remaining").unwrap_or(0); } BlockEntity::Jukebox(_) diff --git a/tools/save_roundtrip_scene.py b/tools/save_roundtrip_scene.py index 06956fa9b..efa0834b6 100644 --- a/tools/save_roundtrip_scene.py +++ b/tools/save_roundtrip_scene.py @@ -36,8 +36,10 @@ FULL = [ ('smoker', f'{{{NAME},cooking_total_time:100}}'), ('brewing_stand', f'{{Items:[{{Slot:0b,id:"minecraft:potion",count:1}},{{Slot:3b,id:"minecraft:nether_wart",count:2}},' '{Slot:4b,id:"minecraft:blaze_powder",count:1}],BrewTime:200,Fuel:10}'), + # The beef must not finish cooking while the scene runs: its item would land + # near the sculk sensors at a time that depends on how long each game ran. ('campfire', '{Items:[{Slot:0b,id:"minecraft:beef",count:1}],CookingTimes:[I;10,0,0,0],' - 'CookingTotalTimes:[I;600,0,0,0]}'), + 'CookingTotalTimes:[I;60000,0,0,0]}'), ('soul_campfire', ''), ('chiseled_bookshelf', '{Items:[{Slot:0b,id:"minecraft:book",count:1},{Slot:3b,id:"minecraft:book",count:1}],' 'last_interacted_slot:3}'), @@ -110,7 +112,8 @@ ENTITIES = [ ('glow_item_frame', 'Facing:2b'), ('painting', 'Facing:3b,variant:"minecraft:kebab"'), ('arrow', 'inGround:1b,life:5s,damage:2.0d'), - ('falling_block', 'BlockState:{id:"minecraft:anvil",properties:{facing:"east"}},Time:3,HurtEntities:1b,FallHurtAmount:2f'), + # Time starts far below 600, after which FallingBlockEntity drops itself as an item. + ('falling_block', 'BlockState:{id:"minecraft:anvil",properties:{facing:"east"}},Time:-100000,HurtEntities:1b,FallHurtAmount:2f'), ('tnt', 'fuse:50s,block_state:{id:"minecraft:tnt",properties:{unstable:"true"}}'), ('oak_boat', ''), ('chest_boat', ''), ('minecart', ''), ('chest_minecart', 'Items:[{Slot:0b,id:"minecraft:apple",count:2}]'), diff --git a/tools/test_save_roundtrip_parity.py b/tools/test_save_roundtrip_parity.py index bf0c63c43..18bea7465 100644 --- a/tools/test_save_roundtrip_parity.py +++ b/tools/test_save_roundtrip_parity.py @@ -59,7 +59,7 @@ IGNORED = { r'^chunk\.block_entities\..*\.(ticks_since_song_started|Age|BrewTime|warning_level|ticks_in_hive|last_vibration_frequency|listener\..*|RecipesUsed\..*)$': 'block entity timers advance', r'^entity\.(Age|Motion\[\]|Rotation\[\]|Pos\[\]|OnGround|HurtTime|Health|TimeInOverworld|fall_distance|' - r'wasOnGround|Fire|Air|DeathTime|Fuse|life|inGround|ticks_in_ground)$': 'entity simulation advances', + r'wasOnGround|Fire|Air|DeathTime|Fuse|life|inGround|ticks_in_ground|Time)$': 'entity simulation advances', r'^level\.dat\.Data\.(Time|LastPlayed)$': 'clock and wall time of the save', r'^level\.dat\.Data\.(ServerBrands.*|singleplayer_uuid)$': 'a client world names its brand and its player', r'^entity\.listener\.selector\.(event|tick)$': 'the warden listener holds the last vibration it heard; timing', @@ -72,12 +72,6 @@ IGNORED = { # Real differences that are known and not fixed yet: (pattern -> what is wrong). They are # reported, and do not fail the run, so that new differences still do. KNOWN_GAPS = { - r'^chunk\.block_entities\.\(26, -60, 5\)\.triggered': 'crafter: Horizon derives the BE flag from the block state each tick instead of in neighborChanged', - r'^chunk\.sections\.blocks|^chunk\.Heightmaps': 'a falling block entity with NoGravity lands anyway', - r'^entity \(28672, 0, 0, 11\)': 'the spider is gone from the Horizon save (not yet explained)', - r'^entity \(28672, 0, 0, 30\) minecraft:falling_block': 'the NoGravity falling block landed (above)', - r'^entity\.attributes': 'Horizon derives the attribute list of a horse from its own model', - r'^entity\.Brain\.memories': 'Horizon gives villagers memories (meeting point) that vanilla had not formed yet', } SUSPICIOUS = re.compile( @@ -85,7 +79,7 @@ SUSPICIOUS = re.compile( r'Recreating|upgrad|DataFix|out of bounds|missing|Mismatch|Discarding)', re.IGNORECASE) BENIGN = re.compile(r'(Unpacking |OFFLINE/INSECURE|online-mode|authenticate usernames|hackers|' r'Failed to load properties|Failed to load eula|Unknown or incomplete command|' - r'Can.t keep up|Ignoring unknown|^WARNING: |No key layers|has the following)') + r'Can.t keep up|Ignoring unknown|^WARNING: |No key layers|has the following|udev library)') # ---------------------------------------------------------------- vanilla server @@ -409,7 +403,14 @@ def compare_chunks(vanilla, horizon, report): report.append((f'{path} @{key}', p, q)) -def compare_entities(vanilla, horizon, report): +def chunk_of(entity): + pos = entity.get('Pos') + pos = pos[1] if isinstance(pos, tuple) else pos + x, z = (p[1] if isinstance(p, tuple) else p for p in (pos[0], pos[2])) + return int(x // 16), int(z // 16) + + +def compare_entities(vanilla, horizon, report, chunks=None): def collect(regions): out = {} for key, root in regions.items(): @@ -418,11 +419,13 @@ def compare_entities(vanilla, horizon, report): out[uuid] = e return out a, b = collect(vanilla), collect(horizon) - # Entities Horizon adds (chunk-generation spawns in the chunks vanilla never - # generated) are not part of the scene. - extra = [u for u in b if u not in a and not (u and u[0] == UUID_BASE)] + # Entities Horizon adds in the chunks vanilla never generated (chunk-generation + # spawns) are not part of the scene; anywhere else they are a difference. + vanilla_chunks = {chunk_of(e) for e in a.values()} | set(chunks or ()) + extra = [u for u in b if u not in a and not (u and u[0] == UUID_BASE) + and chunk_of(b[u]) not in vanilla_chunks] if extra: - print(f' note: Horizon saved {len(extra)} entities that are not in the scene (generated chunks)') + print(f' note: Horizon saved {len(extra)} entities in chunks vanilla never generated') for uuid in extra: del b[uuid] for uuid in sorted(set(a) | set(b), key=str): @@ -460,7 +463,8 @@ def compare_worlds(vanilla, horizon): report = [] va, ha = dimension(vanilla), dimension(horizon) compare_chunks(load_regions(va / 'region'), load_regions(ha / 'region'), report) - compare_entities(load_regions(va / 'entities'), load_regions(ha / 'entities'), report) + chunks = {(k[1], k[2]) for k in load_regions(va / 'region')} + compare_entities(load_regions(va / 'entities'), load_regions(ha / 'entities'), report, chunks) for rel in ['level.dat'] + sorted( str(p.relative_to(vanilla)) for pattern in ('data/**/*.dat', 'players/**/*.dat') for p in vanilla.glob(pattern)): @@ -531,13 +535,17 @@ def main(): shutil.rmtree(RUN / 'v0view', ignore_errors=True) shutil.copytree(v0, RUN / 'v0view' / 'world') views0, problems0 = view_of_world(RUN / 'v0view') + untimed = re.compile(r'^\[[0-9:]+\] ') + seen0 = {untimed.sub('', line) for line in problems0} for line in problems1: - if line not in problems0: + if untimed.sub('', line) not in seen0: print('vanilla log (Horizon save):', line) failures += 1 volatile = re.compile(r'(cooking_time_spent|lit_time_remaining|ticks_since_song_started|Age|BrewTime|' r'ticks_in_hive|triggered|state_updating_resumes_at|LastExecution|last_vibration_frequency|warning_level): -?[0-9]+[a-zA-Z]?') volatile2 = re.compile(r'(RecipesUsed: \{[^}]*\}|listener: \{.*\}(?=, x))') + # The lit furnace keeps smelting while each game runs: its slots move on. + volatile_items = re.compile(r'Items: \[[^\]]*\]') for label in views0: if label.startswith('entity'): # Entity data moves with the simulation: only require vanilla to load it. @@ -547,7 +555,10 @@ def main(): print(f'VANILLA cannot find {label} in the Horizon save') failures += 1 continue - if volatile2.sub('', volatile.sub('', views0[label])) != volatile2.sub('', volatile.sub('', views1.get(label, ''))): + def stable(text): + text = volatile2.sub('', volatile.sub('', text)) + return volatile_items.sub('', text) if label == 'block 10 furnace' else text + if stable(views0[label]) != stable(views1.get(label, '')): print(f'VANILLA VIEW differs: {label}\n V0 {views0[label][:900]}\n H {str(views1.get(label))[:900]}') failures += 1 print('save round-trip parity', 'FAILED' if failures else 'ok', f'({failures} unexplained differences)')