diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index d3831230c9..d70906e1d5 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -433,6 +433,35 @@ impl Game<'_> { } set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec())); } + Target::Tnt(id) => { + // Entity.load and PrimedTnt.readAdditionalSaveData mutate the + // resident instance; load_tnt would allocate another id/join. + let position = entity_nbt::position(&tag).ok_or_else(stale)?; + let uuid = self.uuid_for(id); + let block_state = tag + .get("block_state") + .and_then(|v| entity_nbt::state_from_nbt(self.pack.assets.registry, v)); + let tnt = self + .tnts + .iter_mut() + .find(|tnt| tnt.id == id) + .ok_or_else(stale)?; + tnt.position = position; + tnt.previous = position; + tnt.velocity = entity_nbt::motion(&tag); + tnt.rotation = entity_nbt::rotation(&tag); + // Keep the existing loaded-fuse conversion policy (H090 is separate). + tnt.fuse = match tag.get("fuse") { + Some(Tag::Short(fuse)) => i32::from(*fuse), + _ => 80, + }; + tnt.on_ground = matches!(tag.get("OnGround"), Some(Tag::Byte(1))); + tnt.no_gravity = entity_nbt::flag(&tag, "NoGravity"); + tnt.power = entity_nbt::tnt_power(&tag); + tnt.block_state = block_state; + // Runtime id/join, owner binding and guest attribution stay live. + set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec())); + } Target::Cart(id) => { let uuid = self.uuid_for(id); let i = self @@ -475,6 +504,7 @@ impl Game<'_> { | Target::Frame(id) | Target::Boat(id) | Target::Cart(id) + | Target::Tnt(id) | Target::Other(id) | Target::Fangs(id) = t { diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index d249de0754..875691e973 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -1131,6 +1131,104 @@ mod tests { Some(game) } + #[test] + #[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"] + fn live_data_commands_adopt_into_the_existing_tnt() { + use crate::game::commands::Target; + use horizon_world::nbt::Tag; + let mut game = server_game().expect("prepared Game resources are required"); + game.build_registries(); + game.set_allow_commands(true); + game.block_ticks + .add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0)) + .unwrap(); + assert!(game.streaming.forced.contains(&(0, 0))); + // The existing unit helper supplies a resident column. The paired + // runtime harness separately admits actual ticket/streaming readiness. + assert!(game.world.chunks.contains_key(&(0, 0))); + assert!(game.run_command( + "summon pig 8.5 200 5.5 {UUID:[I;89,0,0,3],Health:10f,NoAI:1b,NoGravity:1b}" + )); + assert!(game.run_command("summon tnt 5.5 200 5.5 {UUID:[I;89,0,0,1],fuse:40s,Motion:[0d,0d,0d],NoGravity:1b,explosion_power:2f,owner:[I;89,0,0,3],Tags:[keep],Silent:1b}")); + let pig = game + .mobs + .iter() + .find(|m| game.persist.uuid_of(m.id) == Some([89, 0, 0, 3])) + .unwrap() + .id; + assert_eq!( + game.mobs.iter().find(|m| m.id == pig).unwrap().kind.id, + "minecraft:pig" + ); + assert_eq!(game.tnts.len(), 1); + let id = game.tnts[0].id; + assert_eq!(game.persist.uuid_of(id), Some([89, 0, 0, 1])); + assert_eq!(game.tnts[0].fuse, 40); + assert_eq!(game.tnts[0].velocity, Vec3::ZERO); + assert!(game.tnts[0].no_gravity); + // Preserve a live owner referencing an actually registered mob. + game.tnts[0].owner = Some(Who::Mob(pig)); + let before = game.tnts[0].clone(); + let next_id = game.next_entity; + let clock = game.world.game_time; + assert!(before.join.is_joined()); + let reference = Target::Tnt(id); + let saved_before = game.entity_nbt(reference).unwrap(); + assert_eq!(saved_before.get("NoGravity"), Some(&Tag::Byte(1))); + assert_eq!( + saved_before.get("owner"), + Some(&Tag::IntArray(vec![89, 0, 0, 3])) + ); + assert!( + !game.run_command("data merge entity 00000059-0000-0000-0000-000000000001 {fuse:40s}") + ); + assert!(game.run_command("data merge entity 00000059-0000-0000-0000-000000000001 {fuse:60s,Motion:[0.125d,0d,-0.25d],Tags:[adopted]}"), "valid TNT data must reach the existing live instance"); + assert_eq!( + game.next_entity, next_id, + "adoption must not spawn another entity" + ); + assert_eq!(game.tnts.len(), 1); + let live = game.tnts.iter().find(|t| t.id == id).unwrap(); + assert_eq!(live.fuse, 60, "the actual modeled counter changes"); + assert_eq!(live.velocity, Vec3::new(0.125, 0.0, -0.25)); + assert_eq!(live.position, before.position); + assert_eq!(live.previous, live.position); + assert_eq!(live.rotation, before.rotation); + assert_eq!(live.join, before.join); + assert_eq!(live.owner, before.owner); + assert_eq!(live.guest, before.guest); + assert_eq!(live.power, before.power); + assert_eq!(live.no_gravity, before.no_gravity); + assert_eq!(game.world.game_time, clock); + assert_eq!(game.persist.uuid_of(id), Some([89, 0, 0, 1])); + let saved = game.entity_nbt(reference).unwrap(); + assert_eq!(saved.get("fuse"), Some(&Tag::Short(60))); + assert_eq!( + saved.get("Tags"), + Some(&Tag::List(vec![Tag::String("adopted".into())])) + ); + for field in [ + "UUID", + "Pos", + "NoGravity", + "Rotation", + "owner", + "block_state", + "explosion_power", + "Silent", + ] { + assert!( + saved_before.get(field).is_some(), + "positive saved {field} control" + ); + assert_eq!( + saved.get(field), + saved_before.get(field), + "retained {field}" + ); + } + } + /// #10063: TNT lit between level ticks (flint and steel, a command) has its /// first tick in the next level tick, so its fuse reads 79 after it, and it /// explodes on the 80th (`PrimedTnt.tick`). Horizon held every TNT that diff --git a/docs/parity/tnt-data-adoption.md b/docs/parity/tnt-data-adoption.md new file mode 100644 index 0000000000..45e07b5688 --- /dev/null +++ b/docs/parity/tnt-data-adoption.md @@ -0,0 +1,38 @@ +# Live TNT command data adoption + +Own frozen main `41f822b659789780f58d32c63ddfb6887ea1b8bf`, actual Game/fresh pinned Java26.3: **92/100 → 100/100** complete strict records. The same protected script/reference bytes are used across the pair. No masks, tolerance, callback normalization or row selection. + +## Actual command proof + +Two independent fresh TNTs, UUID-bound, stationary with NoGravity, Short fuse40, Float power2 and a reference to an actually registered Pig. Actual TNT type/UUID/position/motion/fuse, floor/load, rotation, owner, block-state, tags and Silent are read before/after. The actual host's UUID/type/Health/mode/position and strict relative-clock0 are positive controls. A plain Pig Silent update proves the ordinary data route works. + +An unchanged update is executed against each independently admitted initial TNT state BEFORE its target mutation. No failed reset supplies setup. Target changes are Short fuse40→60 and Double motion `[0,0,0]→[0.125,0,-0.25]`, with no observation ticks. Every action has fresh result/success sentinels and complete feedback/styles/callbacks. Full saved bodies and absolute clocks are separate predeclared raw traces. + +Eight old records differ; all other records and every setup/control match: + +| Labels | Java expected | Untouched Game actual | +|---|---|---| +| fuse-update, motion-update | modified TNT feedback; callback true/1 | unchanged/failure feedback; callback false/0 | +| fuse-update-result, fuse-update-success, motion-update-result, motion-update-success | stored1 | stored0 | +| fuse-saved-fuse | Short60 | Short40 | +| motion-saved-Motion | exact Double vector `[0.125d,0.0d,-0.25d]` | `[0.0d,0.0d,0.0d]` (both length-result3) | + +## In-place boundary + +`apply_entity_nbt` lacked a TNT arm. The patch mutates the existing registered instance with current position/motion/rotation/flag/power/block-state converters and the existing Short fuse reader. It includes TNT in the boundary's normal raw-tag bookkeeping and keeps the actual UUID. `Entity.load` resets previous position to the loaded position; no world tick occurs. + +`load_tnt` is deliberately not called: it allocates an id/join, handles passengers and initializes runtime ownership. Existing runtime id/join, owner/guest binding and world clock stay intact. No field/schema, spawn route, global decoder, shared numeric helper, formatter, physics or explosion-policy change. + +H090 PR11121 and H091 PR11071 actual diffs were inspected, with no pending production imported. Their fuse numeric-load policy and default-power saved-key cleanup are separate. This proof uses a valid Short and nondefault Float2, and does not claim all numeric forms or H090/H091 closure. Preserve their loader/writer changes and tests when combining; keep this independent live adoption arm and test. Owner editing, attribution-policy, malformed input, generic Entity-load limits and cross-dimension/lifecycle behavior are not claimed. + +## Actual Game regression and prerequisites + +The registered Game command test admits the TNT model/UUID, unchanged-update result and a real owner Mob reference before asserting a valid update. Untouched fails at that actual command. Fixed passes verification of real modeled fuse/motion, unchanged runtime identity/join/owner/guest/next-id/clock, saved types/fields and supported raw-tag adoption. It does not supply a synthetic observer answer. + +The existing prepared-resource unit helper has a resident origin chunk, not a full ticket/streaming-readiness fixture. Runtime readiness remains strict in the independent100-record proof. Retained rejected unit prerequisites: a private-module import, a duplicate force-load assertion and an inappropriate streaming-ready assertion on that resident-only helper. They are not target failures. The first misplaced production draft was caught in the getter match, preserved, and its own obsolete build stopped before any runtime capture; the valid edit is solely in the adoption match/bookkeeping. + +Public replay `tools/test_tnt_data_adoption_parity.py` is byte-identical to the protected original. Complete raw/style/callback captures, invalid attempts, source/artifact hashes and known phase times remain in `.local/h089/`; unknown times are null. Required local run passed1,078 Game tests, rustfmt, workspace/server Clippy, public-tree and lock checks. The explicit prepared-resource Game regression passed (one actual test). Overall required-run exit remains nonzero for the three existing optional-release Python Bash-array failures (`musl[@]`/`arm64[@]: unbound variable`,708run,one skipped); no overall green claim. The382 broader harnesses were listed for CI, not run locally. One persistent checkout/git switch, protected perf checkpoint, no worktrees. + +## Raw diagnostics + +Eight predeclared raw traces remain complete: old2/8, fixed4/8 match. All four full TNT before/after bodies match fixed. The two Pig bodies retain unrelated differences, and absolute clocks remain old Java301/Game0 and fixed Java321/Game0 at both start/end. Relative delta0 stays strict; no raw-clock or all-entity-body equality claim. Original Java/native/reference/style/callback outputs remain protected. diff --git a/tools/test_tnt_data_adoption_parity.py b/tools/test_tnt_data_adoption_parity.py new file mode 100644 index 0000000000..0b1f7632f9 --- /dev/null +++ b/tools/test_tnt_data_adoption_parity.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Live TNT data adoption with independent stationary inputs and unchanged controls.""" +import hashlib,json,sys,tempfile,uuid +from pathlib import Path +import test_command_exec_parity as driver +CASES=[('fuse',1,'{fuse:40s}','{fuse:60s}'),('motion',2,'{Motion:[0d,0d,0d]}','{Motion:[0.125d,0d,-0.25d]}')] +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) + def action(text,label,admission=False): + for key in ['result','success']:cmd(f'/scoreboard players set {key} h089 -99',label+'-reset-'+key,-99) + cmd('/execute store result score result h089 store success score success h089 run '+text,label,admission=admission) + for key in ['result','success']:cmd(f'/scoreboard players get {key} h089',label+'-'+key,admission=admission) + 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') + cmd('/execute if entity @s[type=player,name=Player,nbt={Health:20f,playerGameType:1,Pos:[14.5d,200d,14.5d]}]','owner',1) + cmd('/data get entity @s UUID','owner-uuid',4) + cmd('/execute if loaded 4 200 4','loaded',1);cmd('/execute if block 4 199 4 stone','support',1) + cmd('/scoreboard objectives add h089 dummy','objective') + cmd('/execute store result score start h089 run time query gametime','clock-start',diagnostic=True) + pig=str(uuid.UUID(int=(890<<96)|3)) + cmd('/summon pig 11.5 200 5.5 {UUID:[I;890,0,0,3],Health:10f,NoAI:1b,NoGravity:1b}','pig-summon',1) + pig_guard='{UUID:[I;890,0,0,3],Health:10f,Pos:[11.5d,200d,5.5d],NoAI:1b,NoGravity:1b}' + cmd(f'/execute as {pig} if entity @s[type=pig,nbt='+pig_guard+']','pig-actor',1) + cmd(f'/data get entity {pig} UUID','pig-uuid',4) + cmd(f'/data get entity {pig}','pig-before-full',diagnostic=True) + for label,i,unchanged,update in CASES: + u=str(uuid.UUID(int=(890<<96)|i));x=3+2*i + nbt=f'{{UUID:[I;890,0,0,{i}],fuse:40s,NoGravity:1b,Motion:[0d,0d,0d],explosion_power:2f,owner:[I;890,0,0,3],Tags:[keep],Silent:1b}}' + cmd(f'/summon tnt {x}.5 200 5.5 '+nbt,label+'-summon',1) + guard=f'{{UUID:[I;890,0,0,{i}],Pos:[{x}.5d,200d,5.5d],NoGravity:1b}}' + cmd(f'/execute as {u} if entity @s[type=tnt,nbt='+guard+']',label+'-actor',1) + cmd(f'/execute if block {x} 199 5 stone',label+'-floor',1) + for field in ['UUID','Pos','Motion','fuse','NoGravity','Rotation','owner','block_state','explosion_power','Tags','Silent']: + cmd(f'/data get entity {u} {field}',label+'-initial-'+field) + cmd(f'/data get entity {u}',label+'-initial-full',diagnostic=True) + action(f'data merge entity {u} '+unchanged,label+'-unchanged',admission=True) + cmd(f'/data get entity {u} fuse',label+'-unchanged-fuse',40) + cmd(f'/data get entity {u} Motion',label+'-unchanged-motion',3) + action(f'data merge entity {u} '+update,label+'-update') + for field in ['UUID','Pos','Motion','fuse','NoGravity','Rotation','owner','block_state','explosion_power','Tags','Silent']: + cmd(f'/data get entity {u} {field}',label+'-saved-'+field,admission=field not in ['fuse','Motion']) + cmd(f'/execute as {u} if entity @s[type=tnt,nbt='+guard+']',label+'-actor-retained',1) + cmd(f'/data get entity {u}',label+'-saved-full',diagnostic=True) + action(f'data merge entity {pig} {{Silent:1b}}','pig-update',admission=True) + cmd(f'/execute as {pig} if entity @s[type=pig,nbt='+pig_guard+']','pig-actor-retained',1) + cmd(f'/data get entity {pig} Silent','pig-silent',1) + cmd(f'/data get entity {pig}','pig-after-full',diagnostic=True) + cmd('/execute store result score end h089 run time query gametime','clock-end',diagnostic=True) + cmd('/scoreboard players operation end h089 -= start h089','clock-delta-operation',0) + cmd('/scoreboard players get end h089','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='tnt-adoption-', 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: [('Live TNT data adoption', 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()