diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index b2a3eaebec..fe11b1518c 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -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(); diff --git a/docs/parity/non-mob-saved-flags.md b/docs/parity/non-mob-saved-flags.md new file mode 100644 index 0000000000..61a1990913 --- /dev/null +++ b/docs/parity/non-mob-saved-flags.md @@ -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. diff --git a/tools/test_non_mob_saved_flags_parity.py b/tools/test_non_mob_saved_flags_parity.py new file mode 100644 index 0000000000..d203658810 --- /dev/null +++ b/tools/test_non_mob_saved_flags_parity.py @@ -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()