Drop guest cursor and crafting inputs on disconnect

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-05 10:47:09 +01:00
1 parent 696d021699
commit 0e043eafae
6 files changed
+596 -21

No files matched your search

+1 -1
View File
@@ -2083,7 +2083,7 @@ impl Game<'_> {
let conn = guest.conn;
let at = host.hub.guests.len();
host.hub.guests.push(*guest);
self.guest_close_all(&mut host, conn);
self.guest_disconnected(&mut host, conn);
*guest = host.hub.guests.remove(at);
}
if let Err(e) = self.enter_level(Some(&mut host), back) {
+26 -3
View File
@@ -7,7 +7,7 @@
//! block log, the level events and the entity tracker.
use super::{
Dim, Game, GuestUse, Hit, InteractTarget, Mining, Screen, lan::LanHost,
respawn_anchor::RespawnConfig,
lan_crafting::MenuRemoval, respawn_anchor::RespawnConfig,
};
use horizon_client::{
command::vanilla,
@@ -1487,9 +1487,25 @@ impl Game<'_> {
/// whose server is closing. The guest must be in `host.hub.guests` and the
/// level they are in must be the one in place.
pub(super) fn guest_close_all(&mut self, host: &mut LanHost, conn: u32) {
self.guest_remove_menus(host, conn, MenuRemoval::Live);
}
/// `AbstractContainerMenu.removed` after the player has disconnected.
/// Only HostEvent::Left selects this policy; ordinary closes and shutdown
/// keep their existing return-to-inventory behavior.
pub(super) fn guest_disconnected(&mut self, host: &mut LanHost, conn: u32) {
self.guest_remove_menus(host, conn, MenuRemoval::Disconnected);
}
/// `AbstractContainerMenu.removed`: close the guest's active menu using
/// its live or disconnected disposition for cursor and crafting inputs.
fn guest_remove_menus(&mut self, host: &mut LanHost, conn: u32, removal: MenuRemoval) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
if removal == MenuRemoval::Disconnected {
self.guest_drop_carried(host, gi);
}
if let Some((window, slots)) = host.hub.guests[gi]
.menu
.as_ref()
@@ -1504,10 +1520,17 @@ impl Game<'_> {
host.hub.container_closed(conn, window);
}
self.lan_merchant_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.lan_crafting_removed(host, conn, None, removal);
if removal == MenuRemoval::Disconnected {
// The inventory grid is separate from an open table. Empty both
// before lan_crafting_left saves the guest; never drop result slots.
self.lan_crafting_removed(host, conn, Some(0), removal);
}
self.lan_station_closed(host, conn, None);
self.lan_mount_closed(host, conn, None);
self.guest_carried_back(host, gi);
if removal == MenuRemoval::Live {
self.guest_carried_back(host, gi);
}
// `PlayerContainerEvent.Close`, after the menu's `removed`.
self.player_menu_closing(host, conn);
}
+24 -1
View File
@@ -14,6 +14,14 @@ use horizon_client::{
};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// `AbstractContainerMenu.dropOrPlaceInInventory`: actual network disconnects
/// drop inputs; a live close still returns them to the inventory.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum MenuRemoval {
Live,
Disconnected,
}
/// The guest's menus the game runs: kept between clicks, so a drag (quick
/// craft) spans them as in vanilla.
#[derive(Default)]
@@ -675,6 +683,18 @@ impl Game<'_> {
host: &mut LanHost,
conn: u32,
window: Option<i32>,
) -> bool {
self.lan_crafting_removed(host, conn, window, MenuRemoval::Live)
}
/// `AbstractContainerMenu.clearContainer`: only input slots are removed;
/// `dropOrPlaceInInventory` chooses drops for a disconnected player.
pub(super) fn lan_crafting_removed(
&mut self,
host: &mut LanHost,
conn: u32,
window: Option<i32>,
removal: MenuRemoval,
) -> bool {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return false;
@@ -695,7 +715,10 @@ impl Game<'_> {
}
};
if !grid.is_empty() {
self.lan_place_back(host, gi, grid);
match removal {
MenuRemoval::Live => self.lan_place_back(host, gi, grid),
MenuRemoval::Disconnected => self.drop_guest_items(host, gi, grid),
}
}
true
}
@@ -117,6 +117,7 @@ public final class LocatorBarClient {
private static volatile boolean state;
private static volatile boolean events;
private static volatile boolean spawns;
private static volatile boolean menus;
private static final Map<UUID, String> NAMES = new LinkedHashMap<>();
public static void main(String[] args) throws Exception {
@@ -127,6 +128,7 @@ public final class LocatorBarClient {
int port = Integer.parseInt(args[1]);
Path output = Path.of(args[2]);
Map<String, Player> players = new LinkedHashMap<>();
List<Player> sessions = new ArrayList<>();
BufferedReader script = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
String line;
while ((line = script.readLine()) != null) {
@@ -137,10 +139,12 @@ public final class LocatorBarClient {
case "join" -> {
Player p = new Player(w[1]);
players.put(w[1], p);
sessions.add(p);
NAMES.put(UUIDUtil.createOfflinePlayerUUID(w[1]), w[1]);
p.join(host, port);
}
case "cmd" -> players.get(w[1]).command(w[2]);
case "send-cmd" -> players.get(w[1]).send(new ServerboundChatCommandPacket(w[2]));
case "shift" -> players.get(w[1]).send(new ServerboundPlayerInputPacket(
new Input(false, false, false, false, false, w[2].equals("on"), false)));
case "fly" -> {
@@ -163,6 +167,23 @@ public final class LocatorBarClient {
case "pickup" -> players.get(w[1]).send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
0, 0, Short.parseShort(w[2]), (byte) 0, net.minecraft.world.inventory.ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
// A stale state requests the server's complete authoritative post-click snapshot.
case "click" -> {
String[] a = w[2].split(" ");
Player p = players.get(w[1]);
int window = Integer.parseInt(a[0]);
p.send(new net.minecraft.network.protocol.game.ServerboundContainerClickPacket(
window, -1, Short.parseShort(a[1]), (byte) 0,
net.minecraft.world.inventory.ContainerInput.PICKUP,
new it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap<>(), net.minecraft.network.HashedStack.EMPTY));
}
case "close-menu" -> players.get(w[1]).send(
new net.minecraft.network.protocol.game.ServerboundContainerClosePacket(Integer.parseInt(w[2])));
case "menus" -> menus = w[1].equals("on");
case "dump" -> {
writeSessions(output, sessions);
stdout.println("dumped " + w[1]);
}
case "sync" -> {
try {
players.get(w[1]).sync();
@@ -184,14 +205,8 @@ public final class LocatorBarClient {
case "events" -> events = w[1].equals("on");
case "spawns" -> spawns = w[1].equals("on");
case "end" -> {
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(output, StandardCharsets.UTF_8))) {
for (Player p : players.values()) {
synchronized (p.lines) {
for (String l : p.lines) out.println(p.name + " " + l);
}
}
}
for (Player p : players.values()) p.close();
writeSessions(output, sessions);
for (Player p : sessions) p.close();
System.exit(0);
}
default -> throw new IllegalArgumentException("unknown script line " + line);
@@ -205,6 +220,28 @@ public final class LocatorBarClient {
System.exit(1);
}
private static void writeSessions(Path output, List<Player> sessions) throws Exception {
try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(output, StandardCharsets.UTF_8))) {
for (Player p : sessions) {
synchronized (p.lines) {
for (String line : p.lines) out.println(p.name + " " + line);
}
}
}
}
private static com.google.gson.JsonElement menuStack(net.minecraft.world.item.ItemStack stack) throws Exception {
if (stack.isEmpty()) return com.google.gson.JsonNull.INSTANCE;
var tag = net.minecraft.world.item.ItemStack.CODEC.encodeStart(
ParityData.access.createSerializationContext(net.minecraft.nbt.NbtOps.INSTANCE), stack).getOrThrow();
var bytes = new java.io.ByteArrayOutputStream();
net.minecraft.nbt.NbtIo.write((net.minecraft.nbt.CompoundTag) tag, new java.io.DataOutputStream(bytes));
var json = new com.google.gson.JsonObject();
json.addProperty("snbt", tag.toString());
json.addProperty("nbt", java.util.HexFormat.of().formatHex(bytes.toByteArray()));
return json;
}
/** The waypoint packet's fields, read back from its bytes (the operation and type are private enums). */
static String waypoint(byte[] payload) {
FriendlyByteBuf b = new FriendlyByteBuf(Unpooled.wrappedBuffer(payload));
@@ -237,6 +274,7 @@ public final class LocatorBarClient {
private static final class Player {
final String name;
final List<String> lines = new ArrayList<>();
final Map<Integer, Integer> menuStates = new java.util.concurrent.ConcurrentHashMap<>();
private Socket socket;
private DataInputStream in;
private OutputStream out;
@@ -266,9 +304,10 @@ public final class LocatorBarClient {
void command(String command) throws Exception {
synchronized (replyLock) {
int before = replies;
int beforeSync = syncs;
send(new ServerboundChatCommandPacket(command));
long until = System.nanoTime() + 30_000_000_000L;
while (replies == before) {
while (replies == before && syncs == beforeSync) {
long left = (until - System.nanoTime()) / 1_000_000L;
if (left <= 0) {
record("error no reply to /" + command);
@@ -453,6 +492,49 @@ public final class LocatorBarClient {
reader.start();
}
private void recordMenu(Packet<?> p, byte[] payload) throws Exception {
var j = new com.google.gson.JsonObject();
j.addProperty("packet", p.type().id().toString());
j.addProperty("wire", java.util.HexFormat.of().formatHex(payload));
if (p instanceof net.minecraft.network.protocol.game.ClientboundContainerSetContentPacket c) {
j.addProperty("kind", "content");
j.addProperty("window", c.containerId());
j.addProperty("state", c.stateId());
menuStates.put(c.containerId(), c.stateId());
var items = new com.google.gson.JsonArray();
for (var stack : c.items()) items.add(menuStack(stack));
j.add("items", items);
j.add("carried", menuStack(c.carriedItem()));
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundContainerSetSlotPacket c) {
j.addProperty("kind", "slot");
j.addProperty("window", c.getContainerId());
j.addProperty("state", c.getStateId());
menuStates.put(c.getContainerId(), c.getStateId());
j.addProperty("slot", c.getSlot());
j.add("item", menuStack(c.getItem()));
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundSetCursorItemPacket c) {
j.addProperty("kind", "cursor");
j.add("item", menuStack(c.contents()));
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundSetPlayerInventoryPacket c) {
j.addProperty("kind", "inventory");
j.addProperty("slot", c.slot());
j.add("item", menuStack(c.contents()));
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundOpenScreenPacket c) {
j.addProperty("kind", "open");
j.addProperty("window", c.getContainerId());
j.addProperty("type", net.minecraft.core.registries.BuiltInRegistries.MENU.getKey(c.getType()).toString());
} else if (p instanceof net.minecraft.network.protocol.game.ClientboundLoginPacket c) {
j.addProperty("kind", "login");
j.addProperty("mode", c.commonPlayerSpawnInfo().gameType().getId());
} else if (p instanceof ClientboundSystemChatPacket c) {
j.addProperty("kind", "chat");
j.add("text", net.minecraft.network.chat.ComponentSerialization.CODEC.encodeStart(
ParityData.access.createSerializationContext(com.mojang.serialization.JsonOps.INSTANCE), c.content()).getOrThrow());
j.addProperty("overlay", c.overlay());
} else return;
record("menu-wire " + j);
}
private void play() {
try {
while (true) {
@@ -467,6 +549,7 @@ public final class LocatorBarClient {
&& e.getPos().equals(new net.minecraft.core.BlockPos(0, 80, 0)))
record("piston-event " + e.getB0() + " " + e.getB1());
if (state) recordState(p);
if (menus) recordMenu(p, r.payload);
if (hooks && p instanceof net.minecraft.network.protocol.game.ClientboundAddEntityPacket a) {
entityTypes.put(a.getId(), net.minecraft.world.entity.EntityType.getKey(a.getType()).toString());
}
+445
View File
@@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""Real guest menu clicks, live close, disconnect drops, saves and same-UUID reconnect.
The raw transcript retains complete decoded packets and their bytes. Canonical menu
checkpoints replay authoritative content/slot/cursor packets, never a client prediction.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import shutil
import time
import uuid
import anvil_dump as nbt
import test_locator_bar_parity as net
OBSERVER, GUEST = 'H319Observer', 'H319Guest'
def offline(name):
raw = bytearray(hashlib.md5(f'OfflinePlayer:{name}'.encode()).digest())
raw[6], raw[8] = (raw[6] & 15) | 48, (raw[8] & 63) | 128
return str(uuid.UUID(bytes=bytes(raw)))
def uuid_words(name):
import struct
return list(struct.unpack('>4i', uuid.UUID(offline(name)).bytes))
def stack(value):
return None if value is None else nbt.read_named(bytes.fromhex(value['nbt']))
def menus(path, label):
windows, carried, opened = {}, None, None
for line in path.read_text().splitlines():
prefix = GUEST + ' step='
if not line.startswith(prefix) or ' menu-wire ' not in line:
continue
step, raw = line[len(prefix):].split(' menu-wire ', 1)
p = json.loads(raw)
kind = p['kind']
if kind == 'login':
windows, carried, opened = {}, None, None
elif kind == 'open':
opened = p['window']
elif kind == 'content':
windows[p['window']] = [stack(s) for s in p['items']]
carried = stack(p['carried'])
elif kind == 'slot':
if p['window'] in windows:
windows[p['window']][p['slot']] = stack(p['item'])
elif kind == 'cursor':
carried = stack(p['item'])
elif kind == 'inventory':
i = p['slot']
if 0 in windows:
slot = 36 + i if i < 9 else i if i < 36 else 44 - i if i < 40 else 45
windows[0][slot] = stack(p['item'])
if opened in windows and i < 36:
windows[opened][37 + i if i < 9 else 10 + i - 9] = stack(p['item'])
return {'windows': windows, 'carried': carried, 'opened': opened}
def wait_new(server, start, pattern):
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
if any(re.search(pattern, line) for line in server.lines[start:]):
return
if server.process.poll() is not None:
break
time.sleep(.02)
raise AssertionError(f'no new {pattern}: {server.lines[start:][-10:]}')
def generate(command, build):
directory = build / 'generate'
net.server_dir(directory)
server = net.Server(command, directory, 'generate')
try:
server.wait_for(r'Done \(')
for cmd, ack in [('tick freeze', 'frozen'), ('gamerule spawn_mobs false', 'spawn_mobs'),
('gamerule advance_time false', 'advance_time'), ('gamerule respawn_radius 0', 'respawn_radius'),
('forceload add 0 0 31 31', 'force loaded'), ('fill 0 99 0 31 99 31 stone', '1024'),
('fill 0 100 0 31 104 31 air', 'blocks'), ('setworldspawn 4 100 4', 'world spawn'),
('save-all flush', 'Saved the game')]:
start = len(server.lines)
server.console(cmd)
wait_new(server, start, ack)
finally:
server.stop()
return directory / 'world'
def run(label, command, client_command, build, world):
directory = build / label
port = net.server_dir(directory, world)
server = net.Server(command, directory, label)
out = build / f'{label}-wire.txt'
checkpoints = {}
log = (build / f'{label}-client.log').open('w')
client = None
try:
server.wait_for(r'Done \(')
client = net.Client(client_command, port, out, log)
client.tell('menus on')
client.tell('state on')
client.tell('chat on')
def sync():
client.sync([OBSERVER, GUEST])
def cmd(text, who=GUEST):
action = 'cmd' if 'run time query gametime' in text else 'send-cmd'
client.tell(f'{action} {who} {text}')
client.sync([who])
def phase(name):
client.tell(f'step {name}')
def checkpoint(name):
sync()
client.tell(f'dump {name}')
while (line := client.process.stdout.readline()) != f'dumped {name}\n':
assert line, 'client exited before menu dump'
log.write(line)
checkpoints[name] = menus(out, name)
(build / f'{label}-menus.json').write_text(json.dumps(checkpoints, indent=2))
return checkpoints[name]
def await_menu(name, expected):
deadline = time.monotonic() + 3
while True:
actual = checkpoint(name)
if expected(actual):
return actual
assert time.monotonic() < deadline, f'authoritative menu prerequisite {name}: {actual}'
time.sleep(.02)
def await_return(name, item):
def returned(_):
for line in out.read_text().splitlines():
if not line.startswith(f'{GUEST} step={name} menu-wire '):
continue
packet = json.loads(line.split(' menu-wire ', 1)[1])
player_slot = packet.get('kind') == 'inventory' and packet.get('slot') == 0
menu_slot = packet.get('kind') == 'slot' and packet.get('window') == 0 and packet.get('slot') == 36
if (player_slot or menu_slot) and stack(packet['item']) == item:
return True
return False
return await_menu(name, returned)
def snapshot(name, player=True):
cmd(f'data modify storage h319:proof {name}.clock set value 0L', OBSERVER)
cmd(f'execute store result storage h319:proof {name}.clock long 1 run time query gametime', OBSERVER)
if player:
cmd(f'data modify storage h319:proof {name}.player set from entity {GUEST}', OBSERVER)
cmd(f'data modify storage h319:proof {name}.drops set value []', OBSERVER)
cmd(f'execute as @e[type=minecraft:item] run data modify storage h319:proof {name}.drops append from entity @s', OBSERVER)
def disconnect(name):
phase(name)
start = len(server.lines)
client.tell(f'quit {GUEST}')
wait_new(server, start, GUEST + r'.*(left the game|lost connection)')
client.sync([OBSERVER])
cmd(f'execute store success storage h319:proof {name}.absent byte 1 run execute unless entity {offline(GUEST)}', OBSERVER)
cmd(f'data modify storage h319:proof {name}.observer set from entity {OBSERVER}', OBSERVER)
snapshot(name, False)
start = len(server.lines)
server.console('save-all flush')
wait_new(server, start, 'Saved the game')
files = list((directory / 'world').rglob(offline(GUEST) + '.dat'))
assert len(files) == 1, files
shutil.copy2(files[0], build / f'{label}-{name}-player.dat')
def join(name):
phase(name)
client.tell(f'join {GUEST}')
sync()
checkpoint(name)
snapshot(name)
client.tell(f'join {OBSERVER}')
client.sync([OBSERVER])
cmd('tick freeze', OBSERVER)
cmd('gamerule spawn_mobs false', OBSERVER)
cmd('gamerule advance_time false', OBSERVER)
cmd('tp @s 20.5 100 20.5', OBSERVER)
join('first-join')
cmd('gamemode survival')
cmd('tp @s 4.5 100 4.5 0 0')
cmd('clear @s')
cmd('kill @e[type=minecraft:item]', OBSERVER)
for i, (x, z) in enumerate([(0, 0), (0, 16), (16, 0), (16, 16)]):
cmd(f'execute store success storage h319:proof guards.loaded{i} byte 1 run execute if loaded {x} 100 {z}', OBSERVER)
for key, condition in [('floor', 'if block 4 99 4 minecraft:stone'), ('air', 'if block 4 100 4 minecraft:air')]:
cmd(f'execute store success storage h319:proof guards.{key} byte 1 run execute {condition}', OBSERVER)
cmd(f'execute store success storage h319:proof guards.guest_type byte 1 run execute if entity @a[nbt={{UUID:[I;{",".join(map(str, uuid_words(GUEST))) }]}}]', OBSERVER)
snapshot('inventory_ready')
cmd('item replace entity @s hotbar.0 with minecraft:dirt 10')
cmd('item replace entity @s hotbar.1 with minecraft:stone 5')
phase('inventory-inputs')
checkpoint('inventory-inputs')
for slot in (36, 1, 37):
phase(f'inventory-click-{slot}')
client.tell(f'click {GUEST} 0 {slot}')
expected = None if slot == 1 else {'count': 10 if slot == 36 else 5, 'id': 'minecraft:dirt' if slot == 36 else 'minecraft:stone'}
await_menu(f'inventory-click-{slot}', lambda m: m['carried'] == expected)
admitted = checkpoint('inventory-admitted')
assert admitted['windows'][0][1] == {'id': 'minecraft:dirt', 'count': 10}
assert admitted['carried'] == {'id': 'minecraft:stone', 'count': 5}
snapshot('inventory_admitted')
disconnect('inventory_disconnect')
join('inventory-reconnect')
cmd('kill @e[type=minecraft:item]', OBSERVER)
cmd('clear @s')
cmd('item replace entity @s hotbar.0 with minecraft:oak_log 3')
cmd('setblock 6 100 4 minecraft:crafting_table', OBSERVER)
cmd('execute store success storage h319:proof guards.table byte 1 run execute if block 6 100 4 minecraft:crafting_table', OBSERVER)
phase('table-open')
client.tell(f'use-on {GUEST} 6 100 4')
table = await_menu('table-open', lambda m: m['opened'] in m['windows'])
window = table['opened']
assert window is not None, 'no actual crafting-table window'
for slot in (37, 5):
phase(f'table-click-{slot}')
client.tell(f'click {GUEST} {window} {slot}')
expected = {'id': 'minecraft:oak_log', 'count': 3} if slot == 37 else None
await_menu(f'table-click-{slot}', lambda m: m['carried'] == expected)
admitted = checkpoint('table-admitted')
assert admitted['windows'][window][5] == {'id': 'minecraft:oak_log', 'count': 3}
assert admitted['windows'][window][0] == {'id': 'minecraft:oak_planks', 'count': 4}
snapshot('table_admitted')
disconnect('table_disconnect')
join('table-reconnect')
cmd('kill @e[type=minecraft:item]', OBSERVER)
cmd('clear @s')
cmd('item replace entity @s hotbar.0 with minecraft:stone[minecraft:custom_data={h319:7}] 5')
phase('live-cursor-pickup')
client.tell(f'click {GUEST} 0 36')
await_menu('live-cursor-admitted', lambda m: m['carried'] is not None)
phase('live-cursor-close')
client.tell(f'close-menu {GUEST} 0')
await_menu('live-cursor-returned', lambda m: m['carried'] is None)
snapshot('live_cursor_returned')
cmd('clear @s')
cmd('item replace entity @s hotbar.0 with minecraft:oak_log 3')
phase('live-table-open')
client.tell(f'use-on {GUEST} 6 100 4')
window = await_menu('live-table-open', lambda m: m['opened'] in m['windows'])['opened']
for slot in (37, 5):
client.tell(f'click {GUEST} {window} {slot}')
expected = {'id': 'minecraft:oak_log', 'count': 3} if slot == 37 else None
await_menu(f'live-table-click-{slot}', lambda m: m['carried'] == expected)
checkpoint('live-table-admitted')
phase('live-table-close')
client.tell(f'close-menu {GUEST} {window}')
await_return('live-table-close', {'id': 'minecraft:oak_log', 'count': 3})
checkpoint('live-table-returned')
snapshot('live_table_returned')
client.end()
finally:
if client:
client.kill()
server.stop()
log.close()
storage = list((directory / 'world').rglob('*command_storage*.dat'))
assert storage, 'no saved command-storage proof'
raw = {str(p.relative_to(directory)): nbt.read_dat(p) for p in storage}
(build / f'{label}-storage.json').write_text(json.dumps(raw, indent=2))
return checkpoints, raw
def facts(build, label):
"""Declared exact fields plus typed finite/local facts for random drop entities.
Complete packets, entity compounds and saves remain unmodified diagnostics.
State IDs are protocol revision counters; drop UUIDs/motion are random.
These are not compared as deterministic values or replaced in raw captures.
"""
raw = json.loads((build / f'{label}-storage.json').read_text())
proof = next(iter(raw.values()))['data']['contents']['proof']
checkpoints = json.loads((build / f'{label}-menus.json').read_text())
result = {}
for name, value in checkpoints.items():
active = value['opened'] if ('table' in name and name not in ('table-reconnect', 'live-table-returned', 'live-table-close')) else 0
result[f'menu/{name}/window'] = active
result[f'menu/{name}/slots'] = value['windows'][str(active)]
result[f'menu/{name}/cursor'] = value['carried']
start = proof['inventory_ready']['clock']
for name, value in sorted(proof.items()):
if name == 'guards':
for key, actual in value.items():
result[f'guard/{key}'] = actual
continue
result[f'{name}/clock-type'] = value['clock'][0]
result[f'{name}/clock-delta'] = value['clock'][1] - start[1]
if 'player' in value:
for key in ('UUID', 'Health', 'Pos', 'Rotation', 'playerGameType', 'Inventory'):
result[f'{name}/player/{key}'] = value['player'].get(key)
if 'absent' in value:
result[f'{name}/guest-absent'] = value['absent']
for key in ('UUID', 'Health', 'Pos', 'playerGameType'):
result[f'{name}/observer/{key}'] = value['observer'].get(key)
drops = value['drops']
result[f'{name}/drop-stacks'] = sorted((d['Item'] for d in drops), key=lambda s: s['id'])
result[f'{name}/drop-count'] = len(drops)
ids = [d.get('UUID') for d in drops]
result[f'{name}/drop-identity'] = all(
isinstance(v, list) and len(v) == 2 and v[0] == 'I' and len(v[1]) == 4
and any(v[1]) and all(type(i) is int for i in v[1]) for v in ids
) and len({json.dumps(v) for v in ids}) == len(ids)
result[f'{name}/drop-typed-state'] = all(
d.get('Health') == ['s', 5] and d.get('Age') == ['s', 0]
and d.get('PickupDelay') == ['s', 40]
and d.get('Pos') == [['d', 4.5], ['d', 101.31999999284744], ['d', 4.5]] for d in drops)
import math
result[f'{name}/drop-motion-local-finite'] = all(
len(d.get('Motion', [])) == 3 and all(
isinstance(v, list) and v[0] == 'd' and math.isfinite(v[1]) and abs(v[1]) <= 1
for v in d['Motion']) for d in drops)
for name in ('inventory_disconnect', 'table_disconnect'):
saved = nbt.read_dat(build / f'{label}-{name}-player.dat')
for key in ('UUID', 'Health', 'Pos', 'playerGameType', 'Inventory'):
result[f'save/{name}/{key}'] = saved.get(key)
# The complete relevant component JSON is retained alongside the raw packet bytes.
wires = [json.loads(line.split(' menu-wire ', 1)[1]) for line in (build / f'{label}-wire.txt').read_text().splitlines()
if line.startswith(GUEST + ' ') and ' menu-wire ' in line]
result['network/login-modes'] = [p['mode'] for p in wires if p['kind'] == 'login']
result['network/open-menu-types'] = [p['type'] for p in wires if p['kind'] == 'open']
return json.loads(json.dumps(result))
def validate_java(result):
"""Independent fixture expectations, checked before using Java as the paired reference."""
def item(name, count, **extra):
return {'id': 'minecraft:' + name, 'count': count, **extra}
def slots(entries):
return [entries.get(i) for i in range(46)]
custom = item('stone', 5, components={'minecraft:custom_data': {'h319': 7}})
expected = {
'first-join': ({}, None),
'inventory-inputs': ({36: item('dirt', 10), 37: item('stone', 5)}, None),
'inventory-click-36': ({37: item('stone', 5)}, item('dirt', 10)),
'inventory-click-1': ({1: item('dirt', 10), 37: item('stone', 5)}, None),
'inventory-click-37': ({1: item('dirt', 10)}, item('stone', 5)),
'inventory-admitted': ({1: item('dirt', 10)}, item('stone', 5)),
'inventory-reconnect': ({}, None),
'table-open': ({37: item('oak_log', 3)}, None),
'table-click-37': ({}, item('oak_log', 3)),
'table-click-5': ({0: item('oak_planks', 4), 5: item('oak_log', 3)}, None),
'table-admitted': ({0: item('oak_planks', 4), 5: item('oak_log', 3)}, None),
'table-reconnect': ({}, None),
'live-cursor-admitted': ({}, custom),
'live-cursor-returned': ({36: custom}, None),
'live-table-open': ({37: item('oak_log', 3)}, None),
'live-table-click-37': ({}, item('oak_log', 3)),
'live-table-click-5': ({0: item('oak_planks', 4), 5: item('oak_log', 3)}, None),
'live-table-admitted': ({0: item('oak_planks', 4), 5: item('oak_log', 3)}, None),
'live-table-close': ({36: item('oak_log', 3)}, None),
'live-table-returned': ({36: item('oak_log', 3)}, None),
}
# Closed table packets need not refresh the abandoned preview/grid; its active
# inventory is checked by full saved command state below, not stale client slots.
for name, (entries, cursor) in expected.items():
assert result[f'menu/{name}/slots'] == slots(entries), name
assert result[f'menu/{name}/cursor'] == cursor, name
for key, actual in result.items():
if key.endswith('/clock-delta'):
assert actual == 0, (key, actual)
if key.startswith('guard/') or key.endswith('/guest-absent'):
assert actual == ['b', 1], (key, actual)
if '/drop-' in key and key.endswith(('identity', 'typed-state', 'local-finite')):
assert actual is True, (key, actual)
for phase in ('inventory_disconnect', 'inventory-reconnect'):
assert result[f'{phase}/drop-stacks'] == [item('dirt', 10), item('stone', 5)]
for phase in ('table_disconnect', 'table-reconnect'):
assert result[f'{phase}/drop-stacks'] == [item('oak_log', 3)]
assert result['live_cursor_returned/player/Inventory'] == [dict(custom, Slot=['b', 0])]
assert result['live_table_returned/player/Inventory'] == [dict(item('oak_log', 3), Slot=['b', 0])]
for name in ('inventory_disconnect', 'table_disconnect'):
assert result[f'save/{name}/Inventory'] == []
for key, actual in result.items():
if key.endswith('/UUID'):
who = OBSERVER if '/observer/' in key else GUEST
assert actual == ['I', uuid_words(who)], (key, actual)
elif key.endswith('/Health'):
assert actual == ['f', 20.0], (key, actual)
elif key.endswith('/Pos'):
expected_pos = [20.5, 100., 20.5] if '/observer/' in key else [4.5, 100., 4.5]
assert actual == [['d', value] for value in expected_pos], (key, actual)
elif key.endswith('/playerGameType'):
expected_mode = 1 if '/observer/' in key or key.startswith('first-join/') else 0
assert actual == expected_mode, (key, actual)
elif key.endswith('/Rotation'):
assert actual == [['f', 0.], ['f', 0.]], (key, actual)
assert result['network/login-modes'] == [1, 0, 0]
assert result['network/open-menu-types'] == ['minecraft:crafting', 'minecraft:crafting']
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--side', choices=['java', 'native', 'both'], default='both')
parser.add_argument('--label')
parser.add_argument('--binary', type=Path)
parser.add_argument('--build-name', default='disconnect-crafting-parity')
parser.add_argument('--reference', type=Path, help='An unchanged fresh Java facts file for an old/fixed pair')
args = parser.parse_args()
net.PLAYERS = [OBSERVER, GUEST]
net.PROPERTIES['force-gamemode'] = 'false'
_, build, java, native, client = net.sides(args.build_name, args.binary.resolve() if args.binary else None)
world = build / 'generate/world'
if not world.exists():
world = generate(java, build)
reference = json.loads(args.reference.read_text()) if args.reference else None
for side in (['java', 'native'] if args.side == 'both' else [args.side]):
label = args.label or side
if args.side == 'both':
label = f'{label}-{side}' if args.label else side
run(label, java if side == 'java' else native, client, build, world)
actual = facts(build, label)
(build / f'{label}-facts.json').write_text(json.dumps(actual, indent=2, sort_keys=True))
if side == 'java':
validate_java(actual)
reference = actual
if reference is not None:
differences = [{'label': key, 'expected': reference[key], 'actual': actual.get(key)}
for key in sorted(reference) if reference[key] != actual.get(key)]
assert set(actual) == set(reference), 'the declared fact labels changed'
report = {'checks': len(reference), 'passed': len(reference) - len(differences),
'differences': differences, 'scope': __doc__}
(build / f'{label}-report.json').write_text(json.dumps(report, indent=2))
print(f'{label}: {report["passed"]}/{report["checks"]} exact facts')
if differences:
raise SystemExit(1)
else:
print(f'{label}: {len(actual)} declared facts captured')
if __name__ == '__main__':
main()
+8 -7
View File
@@ -243,7 +243,7 @@ def run_side(label, server_command, client_command, build, world):
return out.read_text(encoding='utf-8').splitlines()
def sides(name):
def sides(name, binary=None):
"""The vanilla and Horizon dedicated servers and the LocatorBarClient that joins them, built for harness
`name`: (lock, build directory, vanilla server, Horizon server, client) commands."""
lock, jars, libraries = base.verify_artifacts()
@@ -258,12 +258,13 @@ def sides(name):
subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', '-nowarn', '-sourcepath',
lab.ROOT / 'mods/horizon/src/test/java', '-cp', dependencies, '-d', classes, oracle],
cwd=lab.ROOT, check=True, capture_output=True)
subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
check=True, capture_output=True)
import json
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'fast' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
if binary is None:
subprocess.run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '--bin', 'horizon-client'], cwd=lab.ROOT,
check=True, capture_output=True)
import json
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'],
cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'fast' / ('horizon-client.exe' if os.name == 'nt' else 'horizon-client')
vanilla_server = ['java', '-Xmx2g', '-Duser.timezone=UTC', '-cp', dependencies, 'net.minecraft.server.Main', 'nogui']
horizon_server = [binary, '--resources', lab.LOCAL / 'horizon-client/26.3', '--dedicated', '--nogui']
client = ['java', '-Xmx1g', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.LocatorBarClient']