mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Omit Mob-only flags from modeled non-Mob saves
Co-Authored-By: Codex <[email protected]>
This commit is contained in:
1 parent
0b2f2fda73
commit
3122ae98c0
3 files changed
+306
-3
No files matched your search
@@ -101,6 +101,16 @@ impl Base<'_> {
|
||||
}
|
||||
tag
|
||||
}
|
||||
/// Preserve Entity data without flags owned by Mob's save writer.
|
||||
/// Only the dedicated boat, item and armor-stand writers use this path.
|
||||
fn non_mob_tag(&self, raw: Option<&Tag>) -> Tag {
|
||||
let mut tag = self.tag(raw);
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"NoAI".into());
|
||||
map.remove(&"PersistenceRequired".into());
|
||||
}
|
||||
tag
|
||||
}
|
||||
}
|
||||
|
||||
/// The mobs that save `Age` and `AgeLocked`.
|
||||
@@ -2271,7 +2281,7 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
on_ground: e.on_ground,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
let mut tag = base.non_mob_tag(raw);
|
||||
for (key, value) in [
|
||||
("Item", item_to_nbt(&e.stack)),
|
||||
("Health", Tag::Short(e.health as i16)),
|
||||
@@ -3322,7 +3332,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
|
||||
on_ground: a.on_ground,
|
||||
fall_distance: a.enchantment_fall_distance,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
let mut tag = base.non_mob_tag(raw);
|
||||
let equipment: Vec<(&str, Tag)> = ArmorStand::SLOTS
|
||||
.iter()
|
||||
.zip(&a.equipment)
|
||||
@@ -3476,7 +3486,7 @@ pub fn boat_to_nbt(
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
}
|
||||
.tag(raw);
|
||||
.non_mob_tag(raw);
|
||||
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
|
||||
map.insert("Items".into(), crate::save::encode_items(items));
|
||||
}
|
||||
@@ -4517,6 +4527,95 @@ mod tests {
|
||||
let cleared = item_entity_to_nbt(&cleared, [1, 2, 3, 4], Some(&tag));
|
||||
assert!(cleared.get("Thrower").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn non_mob_saves_drop_mob_flags_without_losing_entity_fields() {
|
||||
let uuid = [104, 0, 0, 1];
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let def = Arc::make_mut(&mut kind);
|
||||
def.id = "minecraft:pig".into();
|
||||
def.species = crate::mobs::Species::Pig;
|
||||
let types = [("minecraft:pig".into(), kind)].into();
|
||||
for input in [None, Some(0), Some(1)] {
|
||||
for id in [
|
||||
"minecraft:oak_boat",
|
||||
"minecraft:oak_chest_boat",
|
||||
"minecraft:armor_stand",
|
||||
"minecraft:item",
|
||||
"minecraft:pig",
|
||||
] {
|
||||
let mut raw = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[4.5, 200.0, 4.5])),
|
||||
("Motion", doubles(&[0.0, 0.0, 0.0])),
|
||||
("Silent", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("Invulnerable", Tag::Byte(1)),
|
||||
("PortalCooldown", Tag::Int(17)),
|
||||
("Tags", Tag::List(vec![string("audit")])),
|
||||
("Unrelated", string("keep")),
|
||||
(
|
||||
"Item",
|
||||
item_to_nbt(&crate::items::ItemStack::new("minecraft:stone", 1)),
|
||||
),
|
||||
]);
|
||||
if let Some(value) = input {
|
||||
put(&mut raw, "NoAI", Tag::Byte(value));
|
||||
put(&mut raw, "PersistenceRequired", Tag::Byte(value));
|
||||
}
|
||||
let stand = armor_stand_from_nbt(&raw, 1).unwrap();
|
||||
let item = item_entity_from_nbt(&raw, 1, 0.0).unwrap();
|
||||
let boat = boat_from_nbt(&raw).unwrap();
|
||||
for source in [None, Some(&raw)] {
|
||||
let saved = match id {
|
||||
"minecraft:armor_stand" => armor_stand_to_nbt(&stand, uuid, source),
|
||||
"minecraft:item" => item_entity_to_nbt(&item, uuid, source),
|
||||
"minecraft:pig" => {
|
||||
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
|
||||
// The writer must use live state, even opposite stale raw values.
|
||||
mob.no_ai = input != Some(1);
|
||||
mob.persistent = input != Some(1);
|
||||
mob_to_nbt(&mob, uuid, source, None, |_| None)
|
||||
}
|
||||
_ => boat_to_nbt(
|
||||
id,
|
||||
boat.0,
|
||||
boat.1,
|
||||
[boat.2, 0.0],
|
||||
uuid,
|
||||
source,
|
||||
(id == "minecraft:oak_chest_boat").then_some(&[]),
|
||||
),
|
||||
};
|
||||
if id == "minecraft:pig" {
|
||||
let live = input != Some(1);
|
||||
assert_eq!(saved.get("NoAI"), live.then_some(&Tag::Byte(1)));
|
||||
assert_eq!(
|
||||
saved.get("PersistenceRequired"),
|
||||
Some(&Tag::Byte(i8::from(live)))
|
||||
);
|
||||
} else {
|
||||
assert!(saved.get("NoAI").is_none(), "{id} {input:?}");
|
||||
assert!(saved.get("PersistenceRequired").is_none(), "{id} {input:?}");
|
||||
}
|
||||
assert_eq!(saved.get("UUID"), Some(&Tag::IntArray(uuid.to_vec())));
|
||||
assert_eq!(saved.get("Pos"), raw.get("Pos"));
|
||||
if source.is_some() {
|
||||
for key in [
|
||||
"Silent",
|
||||
"NoGravity",
|
||||
"Invulnerable",
|
||||
"PortalCooldown",
|
||||
"Tags",
|
||||
"Unrelated",
|
||||
] {
|
||||
assert_eq!(saved.get(key), raw.get(key), "{id} {key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animal_integer_loads_save_modeled_values_without_raw_source() {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Mob-only flags in modeled non-Mob saves
|
||||
|
||||
`NoAI` and `PersistenceRequired` belong to `Mob.addAdditionalSaveData`.
|
||||
Pinned Java 26.3 writes the actual persistence boolean and only writes NoAI
|
||||
when true. `Entity.saveWithoutId`, `LivingEntity.addAdditionalSaveData`,
|
||||
`ItemEntity`, `ArmorStand`, `AbstractBoat` and `AbstractChestBoat` do not
|
||||
serialize those two fields for these non-Mob instances.
|
||||
|
||||
## Boundary
|
||||
|
||||
The raw-preserving `Base::tag` is deliberately unchanged. Its callers are not
|
||||
all non-Mob: the generic `vehicle_to_nbt` also supplies `Dragon::to_nbt`.
|
||||
Only `item_entity_to_nbt`, `armor_stand_to_nbt` and `boat_to_nbt` select the
|
||||
bounded non-Mob save path. It removes exactly those two keys after preserving
|
||||
shared Entity data. Mob reconstruction, numeric flag conversion and pickup
|
||||
getter/overwrite behavior are unchanged.
|
||||
|
||||
| Route | Boundary and coverage |
|
||||
| --- | --- |
|
||||
| Oak boat / oak chest boat | `boat_to_nbt`; actual Game/Java and decode/save unit |
|
||||
| Ordinary armor stand | `armor_stand_to_nbt`; actual Game/Java and decode/save unit |
|
||||
| Valid stone Item entity | `item_entity_to_nbt`; actual Game/Java and decode/save unit |
|
||||
| Pig control | `mob_to_nbt` unchanged; actual supported/default/false fields and live-versus-stale unit |
|
||||
| TNT, fangs, ominous spawner, projectiles, painting, cushion, frames | Other Base callers unchanged; untested live |
|
||||
| Orb / falling block | Base has no raw input; external raw merging remains outside this repair |
|
||||
| Carts / Dragon / crystal | Generic vehicle helper unchanged; untested live |
|
||||
| Marker / OtherEntity / unknown records | Separate raw/unmodeled routes unchanged; unresolved H104 limitation |
|
||||
|
||||
This is a staged supported-class repair, not whole-H104 closure. Before
|
||||
extending another route, independently admit that actual class and inspect
|
||||
its writer and any later raw merge. No generic schema or UUID policy changes.
|
||||
|
||||
## Frozen command proof
|
||||
|
||||
Base: `0b2f2fda7392a528b452af050a9c1d8e6f1b090f`.
|
||||
All five earlier `mob_to_nbt` resolver repairs are already upstream; this
|
||||
pair needs no common API patch or pending H045 production import.
|
||||
|
||||
Driver SHA-256:
|
||||
`017d65120fac2fe27e00d8bd07d61fb877a19196d5ff42205d66a60a80b96b78`.
|
||||
Fresh Java reference SHA-256:
|
||||
`beabc536fb2db824a96a056fb884a4bbe9c25b119e15c5f7004603d15558135d`.
|
||||
|
||||
The predeclared 315 complete command records use 15 distinct fixed UUIDs:
|
||||
five classes crossed with absent, Byte 0 and Byte 1 input. Every actor has
|
||||
positive type/UUID/tag/Pos/Motion admission, Health for stand/pig or an actual
|
||||
valid stone stack for Item, loaded stone/air geometry and exact relative
|
||||
clock zero. Silent, NoGravity, Invulnerable, PortalCooldown and Tags stay
|
||||
strict. Each target field has fresh result/success sentinels, actual read
|
||||
feedback/callbacks, presence checks, and typed pig values. Byte-zero presence
|
||||
is different from field absence; false NoAI omission differs from the pig's
|
||||
always-written false PersistenceRequired.
|
||||
|
||||
The untouched Game matches **259/315**: exactly 56 differences are the two
|
||||
supplied fields on four non-Mob classes (six per zero-input case, eight per
|
||||
one-input case). Absent inputs, pig controls and all setup admissions pass.
|
||||
The fixed Game matches **315/315**, using the unchanged script and identical
|
||||
fresh Java reference bytes, with no canonical residuals. These are not 56
|
||||
independent defects. The old no-raw/stale-raw decode/save
|
||||
unit fails at the intended boat Byte-zero omission assertion and passes fixed
|
||||
(1/1), including both source modes and opposite live Mob values. The same
|
||||
test source uses an optimized fast artifact before and the repository debug
|
||||
test artifact after; both actual Game executables use the matching fast profile.
|
||||
|
||||
Complete body and absolute game-time records were declared raw diagnostics
|
||||
before captures. They remain executable and retained in Java/native logs;
|
||||
raw-field retention and other class/default behavior are not whole-body
|
||||
parity claims. No masks or tolerance apply to the 315
|
||||
canonical records; no absolute startup-clock equality is claimed.
|
||||
|
||||
The separate 17 raw diagnostic records match 4/17 before and 12/17 after.
|
||||
The five fixed residuals are retained with full expected/actual records:
|
||||
|
||||
| Diagnostic | Java | Fixed Game |
|
||||
| --- | --- | --- |
|
||||
| Pig bodies, UUID suffixes 13/14/15 | `attributes: []` | `attributes: [{id: "minecraft:movement_speed", base: 0.25d}]` |
|
||||
| Start/end absolute clocks | 309 / 309 | 0 / 0 |
|
||||
|
||||
The old capture's absolute clocks are 289 / 289 versus native 0 / 0; its
|
||||
same three pig attribute residuals also remain raw. These separate diagnostics
|
||||
are not folded into a green aggregate or repaired here.
|
||||
|
||||
```sh
|
||||
python3 tools/test_non_mob_saved_flags_parity.py --reference-only --work-dir .local/h104/java
|
||||
python3 tools/test_non_mob_saved_flags_parity.py --binary /path/to/game --reference .local/h104/java/vanilla.jsonl --work-dir .local/h104/run
|
||||
```
|
||||
|
||||
Use fresh work directories: the driver preserves rejected attempts and
|
||||
asserts identical fresh reference bytes. Runtime commands use the existing
|
||||
Game and official command harness; no observer or packet implementation.
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mob-only saved fields through actual Game commands.
|
||||
|
||||
Complete declared records are strict; full bodies and absolute clocks remain
|
||||
predeclared raw traces. Modeled non-Mob routes and a supported pig control.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import test_command_exec_parity as driver
|
||||
|
||||
CLASSES = ['oak_boat', 'oak_chest_boat', 'armor_stand', 'item', 'pig']
|
||||
VALUES = [('absent', None), ('zero', '0b'), ('one', '1b')]
|
||||
|
||||
|
||||
def program():
|
||||
ops = []
|
||||
def cmd(text, label, result=None, admission=True, diagnostic=False):
|
||||
op = dict(op='cmd', cmd=text, label=label, admission=admission)
|
||||
if result is not None: op['expect_result'] = result
|
||||
if diagnostic: op.update(record=False, trace=True)
|
||||
ops.append(op)
|
||||
cmd('/tick freeze', 'freeze')
|
||||
cmd('/gamerule spawn_mobs false', 'no-spawn')
|
||||
cmd('/gamerule advance_time false', 'no-daylight')
|
||||
cmd('/forceload add 0 0', 'force', 1)
|
||||
ops.append(dict(op='settle', required_chunks=[[0, 0]]))
|
||||
cmd('/fill 0 199 0 15 199 15 stone', 'floor', 256)
|
||||
cmd('/tp @s 14.5 200 14.5 0 0', 'owner-position', 1)
|
||||
cmd('/gamemode creative @s', 'owner-mode')
|
||||
for condition, label in [('loaded 8 200 8', 'loaded'), ('block 8 199 8 stone', 'floor-state'),
|
||||
('block 8 200 8 air', 'air'), ('entity @s[type=player,name=Player,nbt={Health:20f,playerGameType:1}]', 'owner')]:
|
||||
cmd('/execute if '+condition, label, 1)
|
||||
cmd('/scoreboard objectives add h104 dummy', 'objective')
|
||||
cmd('/execute store result score start h104 run time query gametime', 'clock-start', diagnostic=True)
|
||||
index = 0
|
||||
for kind in CLASSES:
|
||||
for name, value in VALUES:
|
||||
index += 1; label = kind+'-'+name
|
||||
x,z = 1.5+4*((index-1)%3), 1.5+3*((index-1)//3)
|
||||
uid = f'00000068-0000-0000-0000-{index:012x}'
|
||||
specific = ',Item:{id:"minecraft:stone",count:1}' if kind=='item' else ',Health:10f' if kind=='pig' else ',Health:20f' if kind=='armor_stand' else ''
|
||||
supplied = '' if value is None else f',NoAI:{value},PersistenceRequired:{value}'
|
||||
cmd(f'/summon {kind} {x} 200 {z} {{UUID:[I;104,0,0,{index}],Tags:["h104_{index}"],Motion:[0d,0d,0d],Silent:1b,NoGravity:1b,Invulnerable:1b,PortalCooldown:17{specific}{supplied}}}', label+'-summon', 1)
|
||||
body = f'UUID:[I;104,0,0,{index}],Pos:[{x}d,200d,{z}d],Motion:[0d,0d,0d],Tags:["h104_{index}"]'
|
||||
cmd(f'/execute if entity @e[type={kind},tag=h104_{index},nbt={{{body}}}]', label+'-actor', 1)
|
||||
if kind in ['pig','armor_stand']:
|
||||
cmd(f'/data get entity {uid} Health', label+'-health', 10 if kind=='pig' else 20)
|
||||
if kind=='item':
|
||||
cmd(f'/data get entity {uid} Item', label+'-valid-item', 2)
|
||||
cmd(f'/execute if data entity {uid} {{Item:{{id:"minecraft:stone",count:1}}}}', label+'-stone', 1)
|
||||
cmd(f'/data get entity {uid}', label+'-body', diagnostic=True)
|
||||
for key in ['Silent','NoGravity','Invulnerable']:
|
||||
cmd(f'/execute if data entity {uid} {{{key}:1b}}', label+'-keep-'+key, 1)
|
||||
cmd(f'/data get entity {uid} PortalCooldown', label+'-keep-portal', 17)
|
||||
for key in ['NoAI','PersistenceRequired']:
|
||||
for holder in ['result','success']:
|
||||
cmd(f'/scoreboard players set {holder} h104 -99', label+'-'+key+'-reset-'+holder, -99)
|
||||
cmd(f'/execute store result score result h104 store success score success h104 run data get entity {uid} {key}', label+'-'+key+'-read', admission=False)
|
||||
for holder in ['result','success']:
|
||||
cmd(f'/scoreboard players get {holder} h104', label+'-'+key+'-'+holder, admission=False)
|
||||
cmd(f'/execute if data entity {uid} {key}', label+'-'+key+'-presence', admission=False)
|
||||
if kind=='pig' and (key=='PersistenceRequired' or value=='1b'):
|
||||
cmd(f'/execute if data entity {uid} {{{key}:{1 if value=="1b" else 0}b}}', label+'-'+key+'-byte', admission=False)
|
||||
cmd(f'/execute if entity @e[type={kind},tag=h104_{index},nbt={{{body}}}]', label+'-actor-after', 1)
|
||||
cmd('/execute store result score end h104 run time query gametime', 'clock-end', diagnostic=True)
|
||||
cmd('/scoreboard players operation end h104 -= start h104', 'clock-delta-operation', 0)
|
||||
cmd('/scoreboard players get end h104', 'clock-delta', 0)
|
||||
return ops
|
||||
|
||||
|
||||
def main():
|
||||
reference = None
|
||||
if '--reference' in sys.argv:
|
||||
i = sys.argv.index('--reference'); reference = Path(sys.argv[i+1]).resolve()
|
||||
del sys.argv[i:i+2]
|
||||
if '--work-dir' not in sys.argv:
|
||||
raise SystemExit('a fresh --work-dir is required')
|
||||
build = Path(sys.argv[sys.argv.index('--work-dir')+1]).resolve()
|
||||
assert not build.exists(), 'preserve attempts; choose a fresh directory'
|
||||
build.mkdir(parents=True)
|
||||
ops = program(); recorded = [op for op in ops if op['op']=='cmd' and op.get('record', True)]
|
||||
(build/'declaration.json').write_text(json.dumps(dict(
|
||||
script_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), records=len(recorded),
|
||||
classes=CLASSES, values=VALUES, labels=[op['label'] for op in recorded],
|
||||
raw_diagnostics=[op['label'] for op in ops if op.get('trace')],
|
||||
comparison='complete declared records, no masks/tolerances; whole bodies and absolute clocks are separate raw traces'), indent=2)+'\n')
|
||||
expected_bytes = reference.read_bytes() if reference else None
|
||||
driver.corpus_module.groups = lambda: [('non-Mob saved Mob-only fields', ops)]
|
||||
sys.argv.append('--allow-gaps'); driver.main()
|
||||
java_bytes = (build/'vanilla.jsonl').read_bytes()
|
||||
if expected_bytes is not None:
|
||||
assert java_bytes==expected_bytes and reference.read_bytes()==expected_bytes, 'fresh Java reference changed'
|
||||
java = [json.loads(s) for s in java_bytes.splitlines()]
|
||||
if '--reference-only' in sys.argv:
|
||||
return
|
||||
native = [json.loads(s) for s in (build/'rust.jsonl').read_bytes().splitlines()]
|
||||
assert len(recorded)==len(java)==len(native)
|
||||
residuals = [dict(label=op['label'],expected=a,actual=b) for op,a,b in zip(recorded,java,native) if a!=b]
|
||||
report = dict(records=len(recorded),passed=len(recorded)-len(residuals),residuals=residuals,
|
||||
reference_sha256=hashlib.sha256(java_bytes).hexdigest())
|
||||
(build/'strict-report.json').write_text(json.dumps(report,indent=2)+'\n')
|
||||
for op,a,b in zip(recorded,java,native):
|
||||
if op['admission']:
|
||||
assert a==b, ('admission',op,a,b)
|
||||
print(f'Strict records: {report["passed"]}/{report["records"]}')
|
||||
if residuals:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
Reference in new issue
Block a user