From 12cbedcd1be1cb00a3d0750f1ea0e48df92b8c2c Mon Sep 17 00:00:00 2001 From: MSedgeMC <151820093+MSedgeMC@users.noreply.github.com> Date: Fri, 30 Oct 2026 17:01:47 +0530 Subject: [PATCH] Add the dig_clock LAN regression and fix its probe The verifier asked for the server-thread regression this change needs: a survival guest under a block it can only reach near the jump apex, holding attack and jumping, with the block's dig clock measured from the first START. It currently fails: the block survives 308 ticks of held attack. The client aborts on every fall and the STOP that would read the retained clock is only sent when the client's own progress completes, which a five-tick in-reach window cannot reach. Reporting rather than papering over it. tests/test_remote_mining.py compiles an extracted copy of stop_remote_mining against a stub, so the stub needs the new flag and its assertion must stop expecting a stop to clear the target, which vanilla's stopDestroyBlock does not do. --- tests/test_remote_mining.py | 11 ++++- tools/lan_e2e.py | 80 +++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/tests/test_remote_mining.py b/tests/test_remote_mining.py index 86409607a8..954b53f8ec 100644 --- a/tests/test_remote_mining.py +++ b/tests/test_remote_mining.py @@ -28,7 +28,7 @@ struct Hit { pos: [i32; 3], face: u8 } struct Stack { item: &'static str } struct Mining { pos: [i32; 3], progress: f32, ticks: u32 } #[derive(Default)] -struct Remote { destroy_delay: u32, destroying: Option<([i32; 3], u8, f32)>, destroying_item: String } +struct Remote { destroy_delay: u32, destroying: Option<([i32; 3], u8, f32)>, destroying_item: String, is_destroying: bool } struct Ground { on_ground: bool } struct Player { ground: Ground } #[derive(Clone)] @@ -180,7 +180,14 @@ fn main() { game.creative = true; game.remote_mining(false); assert_eq!(game.packets.iter().map(|p| p.action).collect::>(), [0, 2]); - assert!(game.remote.as_ref().unwrap().destroying.is_none()); + // stopDestroyBlock keeps destroyBlockPos and the item and zeroes only the progress, so a + // resumed dig on the same block sends no new START (#4124). + let stopped = game.remote.as_ref().unwrap(); + assert_eq!( + stopped.destroying.map(|(p, _, progress)| (p, progress)), + Some((game.hit.pos, 0.0)) + ); + assert!(!stopped.is_destroying); assert!(game.mining.is_none()); assert!(game.world.broken.is_empty()); // An adventure player without can_break (#3027): startDestroyBlock diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index a9805f03f6..291bc35ada 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -298,6 +298,10 @@ address. The script then checks, from both sides, what a guest's game used to ge the server forgets the completed instant break (#1880). - dig_stop: a block the client breaks at once (a creative break, a dead bush) leaves the server no dig to keep: the block put back stays and no mining sound plays on (#1340, #1908). +- dig_clock: a guest in survival holds attack on a stone seven blocks above its feet: the standing + eye is 5.38 from its underside and only near a jump's apex (4.13) inside the 4.5 pick, + and the dig resumed after each fall is measured from the START that named the block, so + it breaks about one dig in rather than one dig per landing (#4124). Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of both sides go to --out (host-*.png, guest-*.png). @@ -9514,6 +9518,82 @@ CHECKS += ['dig_sounds'] CHECK_FNS['dig_sounds'] = check_dig_sounds +def check_dig_clock(host, guest, out): + """#4124: attack held on a block only the top of a jump can reach keeps one dig clock. + + Stone by hand is 150 ticks of digging (Player.getDestroySpeed: no correct tool, so speed 1 over + hardness 1.5 and 100). The stone hangs seven blocks above the guest's feet, so the standing + eye, 1.62 up, is 5.38 from the block's lowest face, past the 4.5 pick, while at the apex the + eye is 2.87 up and 4.13 away, inside it: the client digs the stone for the few ticks around + each apex only, and loses it on the way down. The server keeps the clock of the START that + named the block (ServerPlayerGameMode.destroyPos) across those falls, so the dig resumed on it + is measured from that first START and the stone breaks about one dig in (#4124), where before + the fix every landing re-sent START and re-anchored the clock. The face met is the underside: + the eye is below the block at the apex too, so a top face is never the one hit. + """ + x, y, z = (int(v // 1) for v in pos_of(guest)) + cell = (x, y + 7, z) + # The eye's distance to the stone's lowest face: past the 4.5 pick standing, inside it at apex. + gap = lambda lift: round(cell[1] - (y + 1.62 + lift), 2) + seen = {'cell': list(cell), 'gap': {'standing': gap(0.0), 'apex': gap(1.2522)}} + assert gap(0.0) > 4.5 > gap(1.2522), f'the stone is not out of reach standing and in it at the apex: {seen}' + mode = guest('state').get('gamemode', 'survival') + try: + guest('command', command='gamemode survival') + guest('command', command='clear @s') + host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y + 8} {z + 2} minecraft:air') + host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 2} {y - 1} {z + 2} minecraft:stone') + host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:stone') + # Straight up: the ray meets the stone's lowest face at every eye height. + guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 -90') + guest.wait(20) + standing = guest('state') + if 'looking_at' not in standing or standing.get('player') is None: + return 'skipped (the ctl state has no crosshair target or eyes)' + seen['standing'] = {'aim': standing['looking_at']['kind'], + 'eye': [round(v, 2) for v in standing['player']['eye']], + 'pitch': round(standing['player']['pitch'], 1)} + assert standing['looking_at']['kind'] != 'block' \ + or standing['looking_at']['block']['pos'] != list(cell), f'the stone is in reach standing: {seen}' + assert host.raw('block', *cell).get('block') == 'minecraft:stone', f'the stone did not stay: {seen}' + # Hold attack and keep jumping: each fall stops the client's dig (its progress is zeroed) + # and airborne digs five times slower, so only the server's clock can finish the stone. + before = standing['ticks'] + nearest, stretch, polls, broke = None, 0, 0, False + guest.raw('mouse', 'left', 'down') + guest.raw('key', 'space', 'down') + deadline = time.time() + 15 + while time.time() < deadline: + polls += 1 + now = guest('state') + target = now['looking_at'].get('block') + if target and target['pos'] == list(cell): + nearest = target['distance'] if nearest is None else min(nearest, target['distance']) + if (now.get('mining') or {}).get('pos') == list(cell): + stretch = max(stretch, now['mining']['ticks']) + if polls % 3 == 0 and host.raw('block', *cell).get('block') == 'minecraft:air': + broke = True + break + guest.wait(2) + seen.update(ticks=guest('state')['ticks'] - before, polls=polls, + nearest=None if nearest is None else round(nearest, 2), longest_dig=stretch, + guest_view=guest.raw('block', *cell).get('block')) + guest.shot(out, 'dig_clock') + assert nearest is not None and nearest < 4.5, f'the guest never had the stone at an apex: {seen}' + assert broke and seen['guest_view'] == 'minecraft:air', \ + f'attack held through 15 s of jumps did not break the stone: {seen}' + assert stretch < 20, f'one dig ran {stretch} ticks in reach: the stone is not the apex one: {seen}' + return seen + finally: + guest.raw('release') + host('command', command=f'setblock {cell[0]} {cell[1]} {cell[2]} minecraft:air') + guest('command', command=f'gamemode {mode}') + + +CHECKS += ['dig_clock'] +CHECK_FNS['dig_clock'] = check_dig_clock + + def check_popped_break_effects(host, guest, out): """Block.playerWillDestroy only skips the mined block's own level event: a block that pops off when its support is mined (a torch on the side, sugar cane above) plays its break event (sound and