Merge pull request #11250 from TheMrZZ/fix/h252-loaded-pig-tags

Normalize Mob Tags when adopting NBT
This commit is contained in:
rdbt authored and GitHub committed 2026-11-06 22:09:57 +00:00
commit 13f70ad482
4 files changed
+513

No files matched your search

@@ -313,6 +313,7 @@ impl Game<'_> {
return Err(err("commands.data.entity.invalid", &[]));
}
Target::Mob(id) => {
Self::normalize_loaded_mob_tags(&mut tag);
let uuid = self.uuid_for(id);
let kind = self
.mobs
+259
View File
@@ -798,6 +798,23 @@ impl Game<'_> {
self.persist.unknown.entry(key).or_default().push(tag);
}
}
/// `Entity.load`: adopt the bounded TAG_LIST_CODEC input as a set.
/// Keep that adopted Mob state in the retained compound for commands and saves.
pub(in crate::game) fn normalize_loaded_mob_tags(tag: &mut Tag) {
let Tag::Compound(raw) = tag else {
return;
};
let Some(Tag::List(mut tags)) = raw.remove(&"Tags".into()) else {
return;
};
tags.retain(|tag| matches!(tag, Tag::String(_)));
tags.truncate(1024);
let mut seen = HashSet::new();
tags.retain(|tag| seen.insert(tag.clone()));
if !tags.is_empty() {
raw.insert("Tags".into(), Tag::List(tags));
}
}
/// A saved mob and, under `Passengers`, the mobs riding it.
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
let uuid = entity_nbt::uuid(&tag);
@@ -808,6 +825,7 @@ impl Game<'_> {
// Structure mobs that generation left to finish: saves from before
// they were finalized at placement, or a placement that could not.
let mut tag = tag;
Self::normalize_loaded_mob_tags(&mut tag);
let mut finalize = false;
if let Tag::Compound(c) = &mut tag {
finalize = c.remove(&"horizon:finalize".into()).is_some();
@@ -2544,3 +2562,244 @@ mod interaction_saved_defaults_tests {
assert_eq!(game.world.game_time, time);
}
}
#[cfg(test)]
mod loaded_tag_tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, commands::Target, load_resources};
use horizon_client::mobs::Species;
fn check(
game: &mut Game<'_>,
target: Target,
expected: &[String],
phase: &str,
failures: &mut Vec<String>,
) {
let live = game.tags_of(target);
if live.len() != expected.len() || expected.iter().any(|t| !live.contains(t)) {
failures.push(format!(
"{phase}: live count {} expected {}",
live.len(),
expected.len()
));
}
let saved = game.entity_nbt(target).expect("actual entity save view");
assert_eq!(saved.get("Health"), Some(&Tag::Float(10.0_f32.to_bits())));
assert_eq!(saved.get("NoAI"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("NoGravity"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("PortalCooldown"), Some(&Tag::Int(17)));
let valid = match saved.get("Tags") {
None => expected.is_empty(),
Some(Tag::List(tags)) => {
!expected.is_empty()
&& tags.len() == expected.len()
&& tags.iter().all(|t| matches!(t, Tag::String(_)))
&& expected
.iter()
.all(|s| tags.contains(&Tag::String(s.as_str().into())))
}
_ => false,
};
if !valid {
failures.push(format!(
"{phase}: saved Tags count/type/membership mismatch"
));
}
}
fn action(game: &mut Game<'_>, command: &str, expected: bool, failures: &mut Vec<String>) {
if game.run_command(command) != expected {
failures.push(format!("command result: {command}"));
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn loaded_pig_tags_cap_before_dedup_and_live_rewrites_adopt_the_set() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert_eq!(
game.world.block(4, 199, 4),
game.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap()
);
let start = game.world.game_time;
let unique: Vec<String> = (0..1025).map(|i| format!("h252_{i:04}")).collect();
let mut duplicates = vec!["h252_repeat".to_string(); 1024];
duplicates.push("h252_late".into());
let small = vec![
"h252_beta".to_string(),
"h252_alpha".into(),
"h252_beta".into(),
];
let encode = |tags: &[String]| {
format!(
"[{}]",
tags.iter()
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(",")
)
};
let mut failures = Vec::new();
for (index, (input, expected)) in [
(Some(encode(&unique)), unique[..1024].to_vec()),
(Some(encode(&duplicates)), vec!["h252_repeat".to_string()]),
(
Some(encode(&small)),
vec!["h252_alpha".to_string(), "h252_beta".into()],
),
(Some("\"h252_scalar\"".into()), vec![]),
(None, vec![]),
(Some("[]".into()), vec![]),
]
.into_iter()
.enumerate()
{
let n = index + 1;
let uuid = format!("000000fc-0000-0000-0000-{n:012x}");
let x = 4.5 + index as f64 * 2.0;
let tags = input.map_or_else(String::new, |s| format!(",Tags:{s}"));
assert!(game.run_command(&format!(
"summon pig {x} 200 4.5 {{UUID:[I;252,0,0,{n}],Health:10f,NoAI:1b,NoGravity:1b,Motion:[0d,0d,0d],PortalCooldown:17{tags}}}"
)));
let target = game.target_with_uuid([252, 0, 0, n as i32]).unwrap();
let Target::Mob(id) = target else {
panic!("actual pig target");
};
let mob = game.mobs.iter().find(|m| m.id == id).unwrap();
assert_eq!(mob.species(), Species::Pig);
assert_eq!(mob.position, Vec3::new(x, 200.0, 4.5));
assert_eq!(mob.health, 10.0);
assert!(mob.no_ai && mob.no_gravity);
assert_eq!(game.uuid_for(id), [252, 0, 0, n as i32]);
check(
&mut game,
target,
&expected,
&format!("load {index}"),
&mut failures,
);
if index == 0 {
action(
&mut game,
&format!("tag {uuid} add h252_extra"),
false,
&mut failures,
);
action(
&mut game,
&format!("tag {uuid} remove h252_0000"),
true,
&mut failures,
);
action(
&mut game,
&format!("tag {uuid} add h252_extra"),
true,
&mut failures,
);
let mut after = unique[1..1024].to_vec();
after.push("h252_extra".into());
check(&mut game, target, &after, "full remove/add", &mut failures);
} else if index == 1 {
action(
&mut game,
&format!("tag {uuid} add h252_added"),
true,
&mut failures,
);
let after = vec!["h252_repeat".to_string(), "h252_added".into()];
check(
&mut game,
target,
&after,
"duplicate load then add",
&mut failures,
);
action(
&mut game,
&format!("tag {uuid} remove h252_added"),
true,
&mut failures,
);
check(
&mut game,
target,
&expected,
"remove added tag",
&mut failures,
);
} else if index == 2 {
action(
&mut game,
&format!("tag {uuid} add h252_alpha"),
false,
&mut failures,
);
assert!(game.run_command(&format!(
"data modify entity {uuid} Tags set value {}",
encode(&duplicates)
)));
check(
&mut game,
target,
&["h252_repeat".into()],
"live rewrite",
&mut failures,
);
}
assert_eq!(
game.mobs.iter().find(|m| m.id == id).unwrap().position,
Vec3::new(x, 200.0, 4.5)
);
assert_eq!(game.uuid_for(id), [252, 0, 0, n as i32]);
assert_eq!(game.world.game_time, start);
}
assert!(
failures.is_empty(),
"actual load/getter/save/mutation failures: {failures:#?}"
);
}
}
+115
View File
@@ -0,0 +1,115 @@
"""Six real UUID-bound pig loads, plus actual tag commands and a live rewrite."""
import json
from command_exec_corpus import cmd, setup
UNIQUE = [f"h252_{i:04d}" for i in range(1025)]
DUPLICATES = ["h252_repeat"] * 1024 + ["h252_late"]
SMALL = ["h252_beta", "h252_alpha", "h252_beta"]
def cases():
ops = setup("/tick freeze", "/gamerule spawn_mobs false",
"/gamerule advance_time false", "/gamerule random_tick_speed 0",
"/forceload add 0 0")
ops.append({"op": "settle", "required_chunks": [[0, 0]]})
ops += setup("/fill 0 199 0 15 199 15 stone",
"/scoreboard objectives add h252 dummy",
"/execute store result score start h252 run time query gametime")
def guard(command, expect_result=1):
op = cmd(command, expect_result=expect_result)
op["admission"] = True
return op
def stored(command, result, success=True, saved_count=None):
ops.extend(setup("/scoreboard players set result h252 31",
"/scoreboard players set success h252 17"))
op = cmd("/execute store result score result h252 store success score success h252 run " + command)
op["expect_callback"] = [success, result]
if saved_count is not None:
op.update(diagnostic="complete saved Tags; ordering remains raw",
saved_tags_count=saved_count)
ops.append(op)
ops.extend([cmd("/scoreboard players get result h252", expect_result=result),
cmd("/scoreboard players get success h252", expect_result=int(success))])
def member(uuid, tag, present=True):
ops.append(cmd(f"/execute as {uuid} {'if' if present else 'unless'} entity @s[tag={tag}]",
expect_result=1))
def clock():
ops.extend(setup("/execute store result score now h252 run time query gametime",
"/scoreboard players operation now h252 -= start h252"))
ops.append(guard("/scoreboard players get now h252", expect_result=0))
def saved(uuid, count):
stored(f"data get entity {uuid} Tags", count, bool(count), count)
ops.append(cmd(f"/execute {'if' if count else 'unless'} data entity {uuid} Tags",
expect_result=1))
# /tag list reads the live getter, independently of the saved-state read.
stored(f"tag {uuid} list", count)
actors = [
("unique", UNIQUE, 1024),
("duplicates", DUPLICATES, 1),
("small", SMALL, 2),
("scalar", "h252_scalar", 0),
("absent", None, 0),
("empty", [], 0),
]
for index, (name, tags, count) in enumerate(actors, 1):
uuid = f"000000fc-0000-0000-0000-{index:012x}"
x = 2.5 + index * 2
field = "" if tags is None else ",Tags:" + json.dumps(tags, separators=(",", ":"))
ops.append(guard(f"/summon pig {x} 200 4.5 {{UUID:[I;252,0,0,{index}],"
f"Health:10f,NoAI:1b,NoGravity:1b,Motion:[0d,0d,0d],PortalCooldown:17{field}}}",
expect_result=1))
identity = (f"/execute as {uuid} if entity @s[type=minecraft:pig,nbt={{"
f"UUID:[I;252,0,0,{index}],Health:10f,NoAI:1b,NoGravity:1b,"
f"Pos:[{x}d,200d,4.5d],Motion:[0d,0d,0d],PortalCooldown:17}}]")
ops.extend([guard(f"/execute if loaded {int(x)} 200 4", expect_result=1),
guard(f"/execute if block {int(x)} 199 4 stone", expect_result=1),
guard(identity, expect_result=1)])
body = cmd(f"/data get entity {uuid}")
body["diagnostic"] = "complete victim body"
ops.append(body)
saved(uuid, count)
member(uuid, "h252_never", False)
if name == "unique":
for tag in [UNIQUE[0], UNIQUE[1023]]:
member(uuid, tag)
member(uuid, UNIQUE[1024], False)
stored(f"tag {uuid} add h252_extra", 0, False)
stored(f"tag {uuid} remove {UNIQUE[0]}", 1)
stored(f"tag {uuid} add h252_extra", 1)
member(uuid, UNIQUE[0], False)
member(uuid, "h252_extra")
saved(uuid, 1024)
elif name == "duplicates":
member(uuid, "h252_repeat")
member(uuid, "h252_late", False)
stored(f"tag {uuid} add h252_added", 1)
member(uuid, "h252_added")
stored(f"tag {uuid} remove h252_added", 1)
member(uuid, "h252_added", False)
saved(uuid, 1)
elif name == "small":
member(uuid, "h252_alpha")
member(uuid, "h252_beta")
stored(f"tag {uuid} add h252_alpha", 0, False)
stored(f"data modify entity {uuid} Tags set value "
+ json.dumps(DUPLICATES, separators=(",", ":")), 1)
member(uuid, "h252_repeat")
member(uuid, "h252_late", False)
member(uuid, "h252_alpha", False)
saved(uuid, 1)
else:
member(uuid, "h252_scalar", False)
if name in ("unique", "duplicates", "small"):
ops.append(guard(identity, expect_result=1))
clock()
for index in range(1, 7):
ops.extend(setup(f"/kill 000000fc-0000-0000-0000-{index:012x}"))
ops.extend(setup("/scoreboard objectives remove h252", "/forceload remove 0 0"))
ops.append(guard("/tick unfreeze", expect_result=0))
return ops
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Actual pig Tags load/save/mutation proof; complete raw list order is diagnostic.
Admit fresh Java with --reference-only --work-dir REF. Reuse exact REF bytes for
both normal binaries with --reference-dir REF --binary GAME --work-dir NEW_OUT.
No output masks, ordering normalization or synthetic tag observer are applied.
"""
import json
from pathlib import Path
import shutil
import sys
import test_command_exec_parity as runner
from loaded_pig_tags_corpus import cases
def callback(row):
values = row.get("results")
assert isinstance(values, list) and len(values) == 1
assert isinstance(values[0], list) and len(values[0]) == 2
assert type(values[0][0]) is bool and type(values[0][1]) is int
return values
def saved_schema(row, count):
"""Validate actual saved-list type/count without changing the raw body."""
assert callback(row) == [[bool(count), count]]
if count:
assert len(row["messages"]) == 1
text = "".join(span[0] for span in row["messages"][0])
# These predeclared ASCII strings make the SNBT string-list a JSON array.
prefix, marker, literal = text.partition(" has the following entity data: ")
assert prefix and marker
values = json.loads(literal)
assert type(values) is list and len(values) == count
assert all(type(value) is str for value in values)
def admit(program, java):
ops = [op for op in program if op["op"] == "cmd" and op.get("record", True)]
assert len(ops) == len(java) and len(ops) > 0
for op, row in zip(ops, java):
assert op["cmd"] == row["cmd"]
if "expect_result" in op:
assert callback(row) == [[True, op["expect_result"]]], (op, row)
if "expect_callback" in op:
assert callback(row) == [op["expect_callback"]], (op, row)
if "saved_tags_count" in op:
saved_schema(row, op["saved_tags_count"])
return ops
def native_from_reference(reference, folder, program):
runner.base.verify_artifacts()
expected = "".join(json.dumps({k: v for k, v in op.items() if k != "expect_result"},
ensure_ascii=False) + "\n" for op in program)
assert (reference / "script.jsonl").read_text() == expected
java = [json.loads(line) for line in (reference / "vanilla.jsonl").read_text().splitlines()]
admit(program, java)
assert "--binary" in sys.argv, "an archived normal Game binary is required"
binary = Path(sys.argv[sys.argv.index("--binary") + 1]).resolve(strict=True)
folder.mkdir(parents=True, exist_ok=True)
for name in ["script.jsonl", "vanilla.jsonl"]:
shutil.copyfile(reference / name, folder / name)
scratch = folder / "rust"
scratch.mkdir(exist_ok=False)
env = runner.lab.game_env(HORIZON_PLAYER_NAME="Player",
HORIZON_COMMAND_SCRIPT=folder / "script.jsonl",
HORIZON_COMMAND_OUTPUT=folder / "rust.jsonl",
HORIZON_API_DISCOVERY="http://127.0.0.1:9/")
with (folder / "rust.log").open("w") as log:
result = runner.run_game([str(binary), "--resources", str(runner.lab.RESOURCES),
"--world", "harness", "--seed", str(runner.SEED), "--radius", "4"],
cwd=scratch, env=env, stdout=log, stderr=runner.subprocess.STDOUT)
assert result.returncode == 0, f"native process failed; see {folder / 'rust.log'}"
def main():
reference = None
if "--reference-dir" in sys.argv:
index = sys.argv.index("--reference-dir")
reference = Path(sys.argv[index + 1]).resolve(strict=True)
del sys.argv[index:index + 2]
program = [*runner.corpus_module.group_setup(), *cases()]
runner.corpus_module.groups = lambda: [("loaded pig Tags", program)]
if "--work-dir" not in sys.argv:
sys.argv += ["--work-dir", str(runner.lab.LOCAL / "loaded-pig-tags-parity")]
if "--allow-gaps" not in sys.argv:
sys.argv.append("--allow-gaps")
folder = Path(sys.argv[sys.argv.index("--work-dir") + 1]).resolve()
if reference is None:
assert "--reference-only" in sys.argv, "admit fresh Java, then reuse its frozen reference"
runner.main()
else:
assert "--reference-only" not in sys.argv
native_from_reference(reference, folder, program)
java = [json.loads(line) for line in (folder / "vanilla.jsonl").read_text().splitlines()]
ops = admit(program, java)
if "--reference-only" in sys.argv:
print(f"Fresh Java admitted {len(ops)} records and saved-list type/count guards.")
return 0
native = [json.loads(line) for line in (folder / "rust.jsonl").read_text().splitlines()]
assert len(ops) == len(native)
report = {"strict_records": 0, "strict_saved_list_projections": 0,
"strict_failures": [], "admission_records": 0, "admission_failures": [],
"diagnostic_records": 0, "diagnostic_differences": []}
mismatches = []
for index, (op, left, right) in enumerate(zip(ops, java, native)):
assert left["cmd"] == right["cmd"] == op["cmd"]
same = all(left.get(key) == right.get(key) for key in ["messages", "results", "state"])
if op.get("admission"):
report["admission_records"] += 1
if not same:
report["admission_failures"].append(index)
if not same:
mismatches.append({"row": index, "java": left, "native": right})
if op.get("diagnostic"):
report["diagnostic_records"] += 1
if not same:
report["diagnostic_differences"].append(index)
if "saved_tags_count" in op:
report["strict_saved_list_projections"] += 1
try:
saved_schema(right, op["saved_tags_count"])
except (AssertionError, ValueError, TypeError):
report["strict_failures"].append({"row": index, "kind": "saved type/count/callback"})
else:
report["strict_records"] += 1
if not same:
report["strict_failures"].append({"row": index, "kind": "exact record"})
(folder / "tags-report.json").write_text(json.dumps(report, indent=2) + "\n")
(folder / "raw-mismatches.jsonl").write_text("".join(json.dumps(row) + "\n" for row in mismatches))
print(json.dumps(report))
return bool(report["strict_failures"])
if __name__ == "__main__":
sys.exit(main())