Preserve Pillager and WanderingTrader saved inventories

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-06 14:13:19 +01:00
1 parent 41f822b659
commit 85b299df9d
6 files changed
+360 -3

No files matched your search

+160
View File
@@ -620,6 +620,19 @@ pub fn mob_to_nbt(
map.insert("LoveCause".into(), Tag::IntArray(cause.to_vec()));
}
}
// InventoryCarrier.writeInventoryToTag: the occupied stacks, even when empty.
if matches!(id, "minecraft:pillager" | "minecraft:wandering_trader") {
map.insert(
"Inventory".into(),
Tag::List(
m.extra
.inventory
.iter()
.map(crate::save::item_to_nbt)
.collect(),
),
);
}
let species = m.species();
// `VariantUtils.writeVariant`: only the species that store the name. A
// mooshroom saves `Type` (below), a fox `Type`, a horse and a llama a
@@ -1795,6 +1808,21 @@ pub fn mob_from_nbt(
m.extra.flag = flag(tag, "ignited");
m.max_swell = number(tag.get("Fuse")).unwrap_or(30) as i16 as i32;
}
// InventoryCarrier.readInventoryFromTag, using the existing occupied-stack model.
let carrier_slots = match m.species() {
crate::mobs::Species::Pillager => 5,
crate::mobs::Species::WanderingTrader => 8,
_ => 0,
};
if carrier_slots != 0
&& let Some(Tag::List(items)) = tag.get("Inventory")
{
m.extra.inventory = items
.iter()
.filter_map(crate::save::item_from_nbt)
.take(carrier_slots)
.collect();
}
if matches!(
m.species(),
crate::mobs::Species::Piglin
@@ -7556,3 +7584,135 @@ mod stand_fall_save_tests {
}
}
}
#[cfg(test)]
mod carrier_inventory_tests {
use super::*;
use crate::mobs::Species;
#[test]
fn carrier_inventory_is_adopted_and_saved_from_the_model() {
let stack = Tag::compound([
("id", string("minecraft:stone")),
("count", Tag::Int(3)),
(
"components",
Tag::compound([(
"minecraft:custom_data",
Tag::compound([("audit", Tag::Int(137))]),
)]),
),
]);
let offers = Tag::compound([(
"Recipes",
Tag::List(vec![Tag::compound([
(
"buy",
Tag::compound([("id", string("minecraft:emerald")), ("count", Tag::Int(1))]),
),
(
"sell",
Tag::compound([("id", string("minecraft:dirt")), ("count", Tag::Int(2))]),
),
])]),
)]);
for (species, id) in [
(Species::Pillager, "minecraft:pillager"),
(Species::WanderingTrader, "minecraft:wandering_trader"),
(Species::Piglin, "minecraft:piglin"),
] {
let mut kind = crate::mobs::sensor_scan_tests::kind();
Arc::make_mut(&mut kind).id = id.into();
Arc::make_mut(&mut kind).species = species;
let types = HashMap::from([(id.to_string(), kind)]);
for input in [
None,
Some(Tag::List(vec![])),
Some(Tag::List(vec![stack.clone()])),
] {
let mut raw = Tag::compound([
("id", string(id)),
("Pos", doubles(&[1.5, 200.0, 4.5])),
("CustomName", string("kept")),
("Tags", Tag::List(vec![string("kept")])),
]);
if let Some(input) = input.clone() {
put(&mut raw, "Inventory", input);
}
if species == Species::WanderingTrader {
put(&mut raw, "Offers", offers.clone());
put(&mut raw, "DespawnDelay", Tag::Int(48000));
}
if species == Species::Pillager {
put(&mut raw, "PatrolLeader", Tag::Byte(1));
put(&mut raw, "CanJoinRaid", Tag::Byte(0));
}
let mut mob = mob_from_nbt(&raw, &types, 1, None).unwrap();
let has_stack = matches!(&input, Some(Tag::List(v)) if !v.is_empty());
assert_eq!(
mob.extra.inventory.len(),
usize::from(has_stack),
"{id} adoption"
);
let expected = Tag::List(if has_stack {
vec![stack.clone()]
} else {
vec![]
});
let mut stale = raw.clone();
put(
&mut stale,
"Inventory",
Tag::List(vec![Tag::compound([
("id", string("minecraft:dirt")),
("count", Tag::Int(2)),
])]),
);
for base in [None, Some(&raw), Some(&stale)] {
let saved = mob_to_nbt(&mob, [137, 0, 0, 1], base, None, |_| None);
assert_eq!(saved.get("Inventory"), Some(&expected), "{id} save");
if base.is_some() {
assert_eq!(saved.get("CustomName"), raw.get("CustomName"));
assert_eq!(saved.get("Tags"), raw.get("Tags"));
}
if species == Species::WanderingTrader {
assert_eq!(saved.get("Offers"), Some(&offers));
assert_eq!(saved.get("DespawnDelay"), Some(&Tag::Int(48000)));
}
if species == Species::Pillager {
assert_eq!(saved.get("PatrolLeader"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("CanJoinRaid"), Some(&Tag::Byte(0)));
}
}
if has_stack {
mob.extra.inventory[0].count = 4;
let mut changed = stack.clone();
put(&mut changed, "count", Tag::Int(4));
assert_eq!(
mob_to_nbt(&mob, [137, 0, 0, 1], Some(&raw), None, |_| None)
.get("Inventory"),
Some(&Tag::List(vec![changed]))
);
}
mob.extra.inventory.clear();
assert_eq!(
mob_to_nbt(&mob, [137, 0, 0, 1], Some(&stale), None, |_| None).get("Inventory"),
Some(&Tag::List(vec![]))
);
}
}
// This InventoryCarrier projection does not add a pig inventory.
let mut kind = crate::mobs::sensor_scan_tests::kind();
Arc::make_mut(&mut kind).id = "minecraft:pig".into();
Arc::make_mut(&mut kind).species = Species::Pig;
let mut pig = Mob::new(2, kind, Vec3::ZERO, 0.0);
pig.extra
.inventory
.push(crate::save::item_from_nbt(&stack).unwrap());
assert!(
mob_to_nbt(&pig, [137, 0, 0, 2], None, None, |_| None)
.get("Inventory")
.is_none()
);
}
}
+1 -1
View File
@@ -1083,7 +1083,7 @@ pub struct Extra {
pub chested: bool,
/// The chest contents of a donkey or mule (15 slots).
pub items: Vec<Option<crate::items::ItemStack>>,
/// `Piglin.inventory` (`SimpleContainer(8)`): what it picked up and kept.
/// Occupied InventoryCarrier stacks: Piglin/trader (8 slots), Pillager (5).
pub inventory: Vec<crate::items::ItemStack>,
pub tame: bool,
pub sitting: bool,
+76
View File
@@ -0,0 +1,76 @@
# Pillager and WanderingTrader saved Inventory
Pinned Java 26.3 Pillager and AbstractVillager read/write Inventory through
InventoryCarrier. Their SimpleContainers have five and eight slots respectively.
The saved list contains occupied ItemStacks and is written even when empty.
Horizon now adopts these two species' input into the existing `extra.inventory`
model through the existing item codec and projects that model on save. It does
not substitute a constant empty list. Piglin code is unchanged; Villager keeps
its separate model. Existing AI/drop consumers of this field are Piglin-gated.
No pickup, raid, loot or trade path is changed.
## Actual Game proof
Frozen base: `41f822b659789780f58d32c63ddfb6887ea1b8bf`. Both own executables
use this base; the old build includes only the new cfg(test) regression before
any gameplay edit. One persistent checkout and fresh isolated game homes are used.
```sh
python3 tools/test_carrier_inventory_parity.py \
--binary /absolute/path/to/horizon-client --work-dir /fresh/output
```
The predeclared 131 complete-record program has independent absent, explicit-empty
and valid single-stack cases for each species, plus pig absence and Piglin
empty/nonempty controls. Actual UUID/type/Health/Pos/Motion/NoAI/NoGravity,
loaded floor/air and exact relative clock 0 are positive admissions. Inventory
reads use fresh result/success sentinels and exact list/presence/item types.
Stone 3 with custom_data.audit Int 137 is preserved. Trader positive Offers and
Int 48000 DespawnDelay, and Pillager PatrolLeader1/CanJoinRaid0 remain strict.
Fresh pinned Java runs with `_JAVA_OPTIONS=-Xmx1G` and owned prepared resources.
Untouched Game: **123/131**, all admissions passing. Eight differences are the
read/success/presence/type observations of the two missing empty defaults, not
eight independent defects. Fixed: **131/131**. Script and fresh reference bytes
must be identical across the pair.
- Program SHA256: `a376c401c2ce2ceb07055217698f135879a9eb15b37778fce18c7033f1fd7317`
- Reference SHA256: `017c5db07aae44a17735f39a4978032f6ca32e1e7a7ba4ed1ec84d4b35ba0fab`
The resource-free real decode/save regression
`entity_nbt::carrier_inventory_tests::carrier_inventory_is_adopted_and_saved_from_the_model`
fails old on absent Pillager Inventory. It checks actual model adoption, raw
absent/present, no-raw and stale-raw saves, single-stack components, changed count4
and cleared model output. Offers/DespawnDelay and Pillager flags are retained;
Piglin and pig controls protect existing behavior. The identical fixed regression passes in the required Rust suite.
## Diagnostics and scope
Nine whole bodies and two absolute clock reads are predeclared raw traces,
retained in full in java.log/rust.log separately from the 131-record contract.
Existing Pillager CanPickUpLoot is Java1/native 0; that load-flag gap is retained,
not repaired. The fixed raw residuals are:
| Raw records | Java | Native (unchanged apart from corrected Inventory defaults) |
|---|---|---|
| 3 Pillager bodies | CanPickUpLoot1, attributes[] | CanPickUpLoot0, movement_speed base0.3499999940395355 |
| 3 trader bodies | attributes[] | movement_speed base0.7 |
| pig body | attributes[] | movement_speed base0.25 |
| 2 Piglin bodies | CannotHunt0, CanPickUpLoot1 | CannotHunt absent, CanPickUpLoot0 |
| start/end absolute clocks | old Java305 / fixed Java269 | native 0 |
Both old and fixed retain all 11 raw diagnostic differences. Before the fix the
two absent-input bodies additionally lack Inventory; the fixed bodies contain
it. Complete styled records/callbacks are retained without normalization. No full-body or
absolute-clock equality claim; no masks/tolerances in the complete canonical records.
The first Java draft expected empty Offers.Recipes to remain present; Java saves
empty Offers as {}. Its script/reference/raws remain preserved as a rejected
setup expectation. The revised positive-offer fixture is independently Java-admitted.
Runtime coverage is these vanilla species and single valid stacks below capacity.
The existing occupied-stack representation and item codec are reused, with source
bounds 5/8. SimpleContainer.fromItemList aggregation (H138), multiple-stack merging,
max-stack/capacity redesign, numeric slot access, other species and lifecycle/reopen
behavior are outside this proof. The ledger retains these limits.
+2
View File
@@ -224,3 +224,5 @@ LingeringPotionItem.createPotion port crates/horizon-client/src/game/potions.rs:
LingeringPotionItem.use inline crates/horizon-client/src/game/potions.rs::Game::use_potion_item lingering potion throw
NameTagItem.interactLivingEntity port crates/horizon-client/src/game/names.rs::Game::name_mob a tag with a custom name names the mob under the crosshair, makes it persistent and is used up, in creative too
PlaceOnWaterBlockItem.use inline crates/horizon-client/src/game/block_items.rs::Game::apply_placed_item placed by generic block placement; the water rule is not checked here
Pillager.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt modeled occupied Inventory list, including empty; inherited Raider fields separate
Pillager.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt Inventory adoption into five-slot occupied-stack model; gap: aggregation and existing pickup-flag load remain separate
1 Squid.hurtServer inline crates/horizon-client/src/mobs.rs::Mob::damage_with_player_context after shared parent damage, require last living attacker for true; retain health loss and existing ink guard
224 LingeringPotionItem.use inline crates/horizon-client/src/game/potions.rs::Game::use_potion_item lingering potion throw
225 NameTagItem.interactLivingEntity port crates/horizon-client/src/game/names.rs::Game::name_mob a tag with a custom name names the mob under the crosshair, makes it persistent and is used up, in creative too
226 PlaceOnWaterBlockItem.use inline crates/horizon-client/src/game/block_items.rs::Game::apply_placed_item placed by generic block placement; the water rule is not checked here
227 Pillager.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt modeled occupied Inventory list, including empty; inherited Raider fields separate
228 Pillager.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt Inventory adoption into five-slot occupied-stack model; gap: aggregation and existing pickup-flag load remain separate
+2 -2
View File
@@ -19,8 +19,8 @@ CatSpawner.spawnInVillage port crates/horizon-client/src/game/cat_spawner.rs::Ga
CatSpawner.spawnCat port crates/horizon-client/src/game/cat_spawner.rs::Game::spawn_cat black variant inside cats_spawn_as_black structures; persistence flag from the caller
InventoryCarrier.pickUpItem port crates/horizon-client/src/game/villagers.rs::Game::villager_pickups gap: minor: villager arm only; piglins, sulfur cubes, pandas and others have their own pickup fns
AbstractVillager.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize villager arm: type, then the baby group
AbstractVillager.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt Offers in the villager arm; inventory via villager::write_nbt
AbstractVillager.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::read_villager Offers read here; inventory via villager::read_nbt
AbstractVillager.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt Offers; Villager inventory via villager::write_nbt, WanderingTrader inventory via extra.inventory, including empty
AbstractVillager.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt Offers via read_villager; Villager inventory via villager::read_nbt, WanderingTrader via eight-slot extra.inventory; aggregation gap retained
AbstractVillager.getSlot missing minor: commands cannot address a villager inventory slot (container.N); mob slot names cover weapon, armour and saddle only
AbstractVillager.addOffersFromItemListings port crates/horizon-client/src/merchant.rs::Trades::offers_from_set the allow_duplicates path: a picked trade stays in the pool
AbstractVillager.addOffersFromItemListingsWithoutDuplicates port crates/horizon-client/src/merchant.rs::Trades::offers_from_set the no-duplicates path: a picked trade leaves the pool
1 ZombieVillager.defineSynchedData skip:java SynchedEntityData definitions; Horizon keeps the fields on Mob and sets them at spawn
19 CatSpawner.spawnCat port crates/horizon-client/src/game/cat_spawner.rs::Game::spawn_cat black variant inside cats_spawn_as_black structures; persistence flag from the caller
20 InventoryCarrier.pickUpItem port crates/horizon-client/src/game/villagers.rs::Game::villager_pickups gap: minor: villager arm only; piglins, sulfur cubes, pandas and others have their own pickup fns
21 AbstractVillager.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize villager arm: type, then the baby group
22 AbstractVillager.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt Offers in the villager arm; inventory via villager::write_nbt Offers; Villager inventory via villager::write_nbt, WanderingTrader inventory via extra.inventory, including empty
23 AbstractVillager.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::read_villager crates/horizon-client/src/entity_nbt.rs::mob_from_nbt Offers read here; inventory via villager::read_nbt Offers via read_villager; Villager inventory via villager::read_nbt, WanderingTrader via eight-slot extra.inventory; aggregation gap retained
24 AbstractVillager.getSlot missing minor: commands cannot address a villager inventory slot (container.N); mob slot names cover weapon, armour and saddle only
25 AbstractVillager.addOffersFromItemListings port crates/horizon-client/src/merchant.rs::Trades::offers_from_set the allow_duplicates path: a picked trade stays in the pool
26 AbstractVillager.addOffersFromItemListingsWithoutDuplicates port crates/horizon-client/src/merchant.rs::Trades::offers_from_set the no-duplicates path: a picked trade leaves the pool
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Pillager/trader Inventory through real summon/get commands."""
import hashlib
import json
from pathlib import Path
import sys
import tempfile
import test_command_exec_parity as driver
STACK = '{id:"minecraft:stone",count:3,components:{"minecraft:custom_data":{audit:137}}}'
OFFER = '{buy:{id:"minecraft:emerald",count:1},sell:{id:"minecraft:dirt",count:2}}'
CASES = [(kind, state) for kind in ['pillager', 'wandering_trader']
for state in ['absent', 'empty', 'stack']]
CASES += [('pig', 'absent'), ('piglin', 'absent'), ('piglin', 'stack')]
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 h137 dummy', 'objective')
cmd('/execute store result score start h137 run time query gametime', 'clock-start', diagnostic=True)
for index, (kind, state) in enumerate(CASES, 1):
label = kind+'-'+state
uid = f'00000089-0000-0000-0000-{index:012x}'
x = 1.5 + 1.5 * (index - 1)
hp = {'pillager':24, 'wandering_trader':20, 'pig':10, 'piglin':16}[kind]
extra = '' if state == 'absent' else ',Inventory:' + ('[]' if state == 'empty' else '['+STACK+']')
if kind == 'pillager': extra += ',PatrolLeader:1b,CanJoinRaid:0b'
if kind == 'wandering_trader': extra += ',DespawnDelay:48000,Offers:{Recipes:['+OFFER+']}'
if kind == 'piglin': extra += ',IsImmuneToZombification:1b'
cmd(f'/summon {kind} {x} 200 4.5 {{UUID:[I;137,0,0,{index}],Health:{hp}f,NoAI:1b,NoGravity:1b,Motion:[0d,0d,0d],Tags:["h137_{index}"],CustomName:"h137_{index}"{extra}}}', label+'-summon', 1)
cmd(f'/execute if entity @e[type={kind},nbt={{UUID:[I;137,0,0,{index}],Health:{hp}f,Pos:[{x}d,200d,4.5d],Motion:[0d,0d,0d],NoAI:1b,NoGravity:1b,Tags:["h137_{index}"],CustomName:"h137_{index}"}}]', label+'-actor', 1)
cmd(f'/data get entity {uid} UUID', label+'-uuid', 4)
cmd(f'/data get entity {uid}', label+'-body', diagnostic=True)
for holder in ['result', 'success']:
cmd(f'/scoreboard players set {holder} h137 -99', label+'-reset-'+holder, -99)
cmd(f'/execute store result score result h137 store success score success h137 run data get entity {uid} Inventory', label+'-inventory-read', admission=False)
for holder in ['result', 'success']:
cmd(f'/scoreboard players get {holder} h137', label+'-inventory-'+holder, admission=False)
if kind == 'pig':
cmd(f'/execute unless data entity {uid} Inventory', label+'-inventory-absent', admission=False)
else:
cmd(f'/execute if data entity {uid} Inventory', label+'-inventory-present', admission=False)
expected = '[]' if state != 'stack' else '['+STACK+']'
cmd(f'/execute if data entity {uid} {{Inventory:{expected}}}', label+'-inventory-type', admission=False)
if state == 'stack':
cmd(f'/data get entity {uid} Inventory[0].id', label+'-item-id', admission=False)
cmd(f'/data get entity {uid} Inventory[0].count', label+'-item-count', admission=False)
cmd(f'/data get entity {uid} Inventory[0].components."minecraft:custom_data".audit', label+'-item-data', admission=False)
else:
cmd(f'/execute unless data entity {uid} Inventory[0]', label+'-no-item', admission=False)
if kind == 'pillager':
cmd(f'/execute if data entity {uid} {{PatrolLeader:1b,CanJoinRaid:0b}}', label+'-flags', 1)
if kind == 'wandering_trader':
cmd(f'/data get entity {uid} DespawnDelay', label+'-despawn', 48000)
cmd(f'/execute if data entity {uid} {{DespawnDelay:48000,Offers:{{Recipes:[{OFFER}]}}}}', label+'-trader-types', 1)
cmd(f'/data get entity {uid} Offers.Recipes[0].sell.count', label+'-offers', 2)
if kind == 'piglin':
cmd(f'/execute if data entity {uid} {{IsImmuneToZombification:1b}}', label+'-immune', 1)
cmd('/execute store result score end h137 run time query gametime', 'clock-end', diagnostic=True)
cmd('/scoreboard players operation end h137 -= start h137', 'clock-delta-operation', 0)
cmd('/scoreboard players get end h137', '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:
parent = Path(tempfile.mkdtemp(prefix='carrier-inventory-', dir=driver.lab.LOCAL))
sys.argv += ['--work-dir', str(parent / 'run')]
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),
cases=CASES, labels=[op['label'] for op in recorded],
raw_diagnostics=[op['label'] for op in ops if op.get('trace')],
comparison='complete records, no masks/tolerances; 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: [('Carrier inventory', 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()