Omit Mob-only flags from modeled non-Mob saves

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-06 11:13:36 +01:00
1 parent 0b2f2fda73
commit 3122ae98c0
3 files changed
+306 -3

No files matched your search

+102 -3
View File
@@ -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();
+90
View File
@@ -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.
+114
View File
@@ -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()