Pass the save round-trip harness
Horizon spawned mobs whatever the spawn_mobs and spawn_monsters game rules said; natural spawning now follows ServerChunkCache.tickChunks (the chunks are still shuffled every tick) and the custom spawners tick only with spawn_mobs. The slimes this put in the test world were what woke its sculk sensor. Falling blocks keep their UUID and loaded compound while they move, read NoGravity, DropItem and a signed Time, and drop themselves as an item after 600 ticks off the ground instead of landing. A crafter's saved triggered flag only follows the block state when the state changes (CrafterBlock.neighborChanged), and a horse saves the attribute instances it was loaded with, as AttributeMap.pack does. The harness compares vanilla log lines without their time stamps, accepts extra entities only in chunks vanilla never generated, treats a falling block's Time as a timer, and keeps the lit furnace's slots out of the vanilla view. Its scene starts the falling block far below the 600-tick limit and keeps the campfire from finishing during the run. The known-gap list is empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<bool>,
|
||||
}
|
||||
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()));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Box<([i32; 4], horizon_world::nbt::Tag)>>,
|
||||
}
|
||||
pub struct Resources {
|
||||
assets: Arc<Assets>,
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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}]'),
|
||||
|
||||
@@ -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)')
|
||||
|
||||
Reference in New Issue
Block a user