mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
243 lines
10 KiB
Python
243 lines
10 KiB
Python
"""The serverbound play stream of a capture (tools/net_capture.py), cut into client ticks and decoded field by
|
|
field, so two clients' streams can be compared tick by tick (tools/test_wire_stream_parity.py).
|
|
|
|
A client tick's packets end with ServerboundClientTickEndPacket (Minecraft.tick sends it last). A scenario
|
|
starts at the command /wire-mark, which both drivers send at the start of the scenario's first tick.
|
|
|
|
Some packets are not the tick's: the server's own timing decides them (keep-alive answers, pongs, chunk batch
|
|
acknowledgements, teleport acceptances and the move that follows one, chat acknowledgements). They are kept
|
|
apart (`frame`), never compared tick by tick, and `misplaced` checks vanilla's rule for them instead: the
|
|
client processes the server's packets at the start of a frame (Minecraft.runTick: processQueuedPackets before
|
|
the ticks), so none of them lands between two packets of one tick. Keep-alive answers are the exception: the
|
|
network thread sends them (ClientCommonPacketListenerImpl.handleKeepAlive).
|
|
"""
|
|
import struct
|
|
|
|
# Sent from the frame (the server's packets), not from a tick.
|
|
FRAME = {'keep_alive', 'pong', 'chunk_batch_received', 'accept_teleportation', 'chat_ack'}
|
|
# Answers to the server that also come from the frame; `misplaced` checks them.
|
|
ASYNC = FRAME - {'keep_alive'}
|
|
|
|
|
|
class Reader:
|
|
def __init__(self, data):
|
|
self.data, self.pos = data, 0
|
|
|
|
def take(self, n):
|
|
out = self.data[self.pos:self.pos + n]
|
|
if len(out) < n:
|
|
raise ValueError('short packet')
|
|
self.pos += n
|
|
return out
|
|
|
|
def varint(self):
|
|
value = shift = 0
|
|
while True:
|
|
b = self.take(1)[0]
|
|
value |= (b & 0x7F) << shift
|
|
if not b & 0x80:
|
|
return value - (1 << 32) if value >= 1 << 31 else value
|
|
shift += 7
|
|
|
|
def double(self):
|
|
return struct.unpack('>d', self.take(8))[0]
|
|
|
|
def float(self):
|
|
return struct.unpack('>f', self.take(4))[0]
|
|
|
|
def ubyte(self):
|
|
return self.take(1)[0]
|
|
|
|
def short(self):
|
|
return struct.unpack('>h', self.take(2))[0]
|
|
|
|
def bool(self):
|
|
return self.take(1)[0] != 0
|
|
|
|
def pos_long(self):
|
|
# BlockPos.asLong: x in the top 26 bits, z in the next 26, y in the low 12, each signed.
|
|
v = struct.unpack('>q', self.take(8))[0]
|
|
signed = lambda value, bits: value - (1 << bits) if value >= 1 << (bits - 1) else value # noqa: E731
|
|
return [v >> 38, signed(v & 0xFFF, 12), signed((v >> 12) & 0x3FFFFFF, 26)]
|
|
|
|
def string(self):
|
|
return self.take(self.varint()).decode('utf-8')
|
|
|
|
def rest(self):
|
|
out = self.data[self.pos:]
|
|
self.pos = len(self.data)
|
|
return out.hex()
|
|
|
|
|
|
def flags(v):
|
|
return {'on_ground': bool(v & 1), 'horizontal_collision': bool(v & 2)}
|
|
|
|
|
|
def decode(name, data):
|
|
"""The fields of one serverbound play packet (vanilla's codecs); `raw` holds what isn't decoded."""
|
|
r = Reader(data)
|
|
if name == 'move_player_pos':
|
|
return {'x': r.double(), 'y': r.double(), 'z': r.double(), **flags(r.ubyte())}
|
|
if name == 'move_player_pos_rot':
|
|
return {'x': r.double(), 'y': r.double(), 'z': r.double(), 'yaw': r.float(), 'pitch': r.float(),
|
|
**flags(r.ubyte())}
|
|
if name == 'move_player_rot':
|
|
return {'yaw': r.float(), 'pitch': r.float(), **flags(r.ubyte())}
|
|
if name == 'move_player_status_only':
|
|
return flags(r.ubyte())
|
|
if name == 'player_input':
|
|
v = r.ubyte()
|
|
keys = ['forward', 'backward', 'left', 'right', 'jump', 'shift', 'sprint']
|
|
return {'keys': [k for i, k in enumerate(keys) if v >> i & 1]}
|
|
if name == 'player_command':
|
|
actions = ['stop_sleeping', 'start_sprinting', 'stop_sprinting', 'start_riding_jump', 'stop_riding_jump',
|
|
'open_inventory', 'start_fall_flying']
|
|
entity, action, data_ = r.varint(), r.varint(), r.varint()
|
|
return {'entity': entity, 'action': actions[action] if 0 <= action < len(actions) else action,
|
|
'data': data_}
|
|
if name == 'set_carried_item':
|
|
return {'slot': r.short()}
|
|
if name == 'attack':
|
|
return {'entity': r.varint()}
|
|
if name == 'interact':
|
|
# The point (Vec3.LP_STREAM_CODEC, packed) and the sneak flag stay raw.
|
|
return {'entity': r.varint(), 'hand': r.varint(), 'raw': r.rest()}
|
|
if name == 'use_item':
|
|
return {'hand': r.varint(), 'sequence': r.varint(), 'yaw': r.float(), 'pitch': r.float()}
|
|
if name == 'use_item_on':
|
|
return {'hand': r.varint(), 'pos': r.pos_long(), 'face': r.varint(),
|
|
'cursor': [r.float(), r.float(), r.float()], 'inside': r.bool(), 'world_border': r.bool(),
|
|
'sequence': r.varint()}
|
|
if name == 'player_action':
|
|
return {'action': r.varint(), 'pos': r.pos_long(), 'direction': r.ubyte(), 'sequence': r.varint()}
|
|
if name == 'container_click':
|
|
return {'container': r.varint(), 'state': r.varint(), 'slot': r.short(), 'button': r.ubyte(),
|
|
'input': r.varint(), 'raw': r.rest()}
|
|
if name == 'container_close':
|
|
return {'container': r.varint()}
|
|
if name == 'chat_command':
|
|
return {'command': r.string()}
|
|
return {'raw': r.rest()} if data else {}
|
|
|
|
|
|
def self_id(entries):
|
|
"""The player's entity id: the first field (an Int) of the clientbound Login."""
|
|
for e in entries:
|
|
if e.get('dir') == 's2c' and e.get('state') == 'play' and e.get('name') == 'login':
|
|
return struct.unpack('>i', bytes.fromhex(e['data'])[:4])[0]
|
|
return None
|
|
|
|
|
|
def scenarios(entries):
|
|
"""One list of ticks for each /wire-mark in the play stream, in order (the ticks after a mark run to the
|
|
next mark); a tick is {'packets': [(name, fields)], 'frame': [(name, fields)], 'misplaced': [name]}.
|
|
|
|
A click's state id becomes `stale`: how far it is behind the last one the server sent for that container
|
|
(0 = up to date). The ids themselves count every change the server made since the login, so two runs
|
|
whose earlier scenarios differed number the same click differently."""
|
|
me = self_id(entries)
|
|
play = [e for e in entries if e.get('state') == 'play' and 'name' in e and e.get('dir') in ('c2s', 's2c')]
|
|
runs, current, server_state = [], None, {}
|
|
for e in play:
|
|
name = e['name']
|
|
if e['dir'] == 's2c':
|
|
if name in ('container_set_content', 'container_set_slot'):
|
|
r = Reader(bytes.fromhex(e.get('data', '')))
|
|
container = r.varint()
|
|
server_state[container] = r.varint()
|
|
continue
|
|
fields = decode(name, bytes.fromhex(e.get('data', '')))
|
|
if name == 'container_click':
|
|
last = server_state.get(fields['container'], fields['state'])
|
|
fields = {'container': fields['container'], 'stale': last - fields.pop('state'), **fields}
|
|
if name == 'chat_command' and fields.get('command') == 'wire-mark':
|
|
current = [new_tick()]
|
|
runs.append(current)
|
|
continue
|
|
if current is None:
|
|
continue
|
|
tick = current[-1]
|
|
if 'entity' in fields and fields['entity'] == me:
|
|
fields['entity'] = 'self'
|
|
if name in FRAME:
|
|
tick['frame'].append((name, fields))
|
|
if name in ASYNC and tick['packets']:
|
|
tick['misplaced'].append(name)
|
|
continue
|
|
tick['packets'].append((name, fields))
|
|
if name == 'client_tick_end':
|
|
current.append(new_tick())
|
|
for run in runs:
|
|
name_entities(run)
|
|
return runs
|
|
|
|
|
|
def first_frame(run, name):
|
|
"""The first tick whose frame sent `name` (the server's answer it carries), or None."""
|
|
return next((i for i, tick in enumerate(run) if any(n == name for n, _ in tick['frame'])), None)
|
|
|
|
|
|
def new_tick():
|
|
return {'packets': [], 'frame': [], 'misplaced': []}
|
|
|
|
|
|
def name_entities(run):
|
|
"""Other entities' ids differ from one run to the next: name them by first appearance (e1, e2...)."""
|
|
names = {}
|
|
for tick in run:
|
|
for _, fields in tick['packets']:
|
|
entity = fields.get('entity')
|
|
if isinstance(entity, int):
|
|
fields['entity'] = names.setdefault(entity, f'e{len(names) + 1}')
|
|
|
|
|
|
def compare(vanilla, horizon, ticks):
|
|
"""The ticks 0..ticks where the two streams differ: [(tick, vanilla packets, Horizon packets)]. Fields
|
|
compare exactly, floats included: they are what vanilla's client sends."""
|
|
out = []
|
|
for i in range(ticks + 1):
|
|
v = vanilla[i]['packets'] if i < len(vanilla) else None
|
|
h = horizon[i]['packets'] if i < len(horizon) else None
|
|
if v != h:
|
|
out.append((i, v, h))
|
|
return out
|
|
|
|
|
|
def compare_answered(vanilla, horizon, ticks, av, ah):
|
|
"""compare, for a scenario the server answers once (a teleport): the tick each client received the answer
|
|
in (`av`, `ah`) compares with the other client's, every other tick with the same tick, on the script's
|
|
clock. A loaded server answers a few ticks later in one run than in the other, while the script's own
|
|
packets (a button released, the position resent every 20 ticks) keep its clock. Numbers are vanilla's."""
|
|
out = [d for d in compare(vanilla, horizon, ticks) if d[0] not in (av, ah)]
|
|
v = vanilla[av]['packets'] if av < len(vanilla) else None
|
|
h = horizon[ah]['packets'] if ah < len(horizon) else None
|
|
if v != h:
|
|
out.append((av, v, h))
|
|
return sorted(out, key=lambda d: d[0])
|
|
|
|
|
|
def describe(packets):
|
|
if packets is None:
|
|
return ' (no such tick)'
|
|
if not packets:
|
|
return ' (nothing)'
|
|
lines = []
|
|
for name, fields in packets:
|
|
text = ', '.join(f'{k}={round(v, 6) if isinstance(v, float) else v}' for k, v in fields.items())
|
|
lines.append(f' {name}' + (f' {{{text}}}' if text else ''))
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def field_diff(v, h, names=('vanilla', 'Horizon')):
|
|
"""One line per differing field of packets paired by position, with the exact values."""
|
|
a, b = names
|
|
lines = []
|
|
for i, ((vn, vf), (hn, hf)) in enumerate(zip(v or [], h or [])):
|
|
if vn != hn:
|
|
lines.append(f' #{i}: {a} {vn}, {b} {hn}')
|
|
continue
|
|
for k in sorted(set(vf) | set(hf)):
|
|
if vf.get(k) != hf.get(k):
|
|
lines.append(f' #{i} {vn}.{k}: {a} {vf.get(k)!r}, {b} {hf.get(k)!r}')
|
|
return lines
|