"""Gear decoding, driven entirely by synthetic packets. The wire format is fully understood, so every packet here is constructed from the spec rather than captured: a 120-bit header, payload bits, two 1 bits and zero padding. No real capture is used - see tests/fixtures/README.md. """ import contextlib import io import json import os import struct import tempfile import unittest from nte_history_exporter import console from nte_history_exporter import __version__ from nte_history_exporter.decoder.gear import ( HEADER_BITS, extract_gear, payload_end_bit, splice_datagrams, ) from nte_history_exporter.export.gear_export import build_gear_export_json from nte_history_exporter.live_capture import gear_collector, runner from nte_history_exporter.live_capture.windows_raw import ParsedIpPacket from tests import support # --- synthetic wire construction ------------------------------------------- def string_frame(text: bytes) -> bytes: """A self-validating frame.""" return struct.pack(" bytes: """<4 zero bytes>.""" return string_frame(name) + b"\0\0\0\0" + struct.pack(" None: if len(buf) < byte_offset + len(raw): buf.extend(b"\0" * (byte_offset + len(raw) - len(buf))) buf[byte_offset : byte_offset + len(raw)] = raw def poke_u32_at_bit(buf: bytearray, bitpos: int, value: int) -> None: """Write a little-endian u32 at an arbitrary bit offset.""" needed = (bitpos + 32 + 7) // 8 + 1 if len(buf) < needed: buf.extend(b"\0" * (needed - len(buf))) whole = int.from_bytes(buf, "little") whole &= ~(0xFFFFFFFF << bitpos) whole |= value << bitpos packed = whole.to_bytes(len(buf) + 8, "little") buf[:] = packed[: len(buf)] def item_record( item_id: bytes, *, level: int = 20, entries=(), instance: bytes = b"\x11\x22\x33\x44\x55\x66\x77\x88", owner: bytes | None = None, ) -> bytes: """One item record laid out at the documented offsets past the id frame.""" body = bytearray(b"\0" * 64) poke(body, 5, instance) # Level is a u32 at +38 bytes; the owner reference starts at +39 bits-wise # (312 bits) and its first three bytes are always zero, so the two fields # coexist exactly as they do on the wire. poke(body, 38, struct.pack(" bytes: """An ability frame with the progress fields at their negative offsets.""" lead = 128 # bytes of room for the fields that sit before the frame buf = bytearray(b"\0" * lead) frame = string_frame(b"GA_" + name + b"_Melee") block_bit = lead * 8 poke_u32_at_bit(buf, block_bit - 466, 67806) poke_u32_at_bit(buf, block_bit - 434, level) poke_u32_at_bit(buf, block_bit - 402, breakthroughs) return bytes(buf) + frame def loadout_cell(item_id: bytes, row: int, column: int) -> bytes: """An id frame quoted with board coordinates and no stat chain.""" body = bytearray(b"\0" * 32) # Coordinates sit 96 and 128 bits past the id and are stored doubled. poke(body, 12, struct.pack("> start) & ((1 << nbits) - 1) header = bytearray(15) header[0:3] = b"\xaa\xbb\xcc" # 14-bit packet sequence at bit 24. header[3] = seq & 0xFF header[4] = (seq >> 8) & 0x3F header[12:15] = b"\x64\x06\xee" # payload bits, then the two 1 bits Unreal terminates with. value = int.from_bytes(header, "little") value |= chunk << HEADER_BITS value |= 0b11 << (HEADER_BITS + nbits) width = (HEADER_BITS + nbits + 2 + 7) // 8 datagrams.append(value.to_bytes(width, "little")) start += nbits seq = (seq + 1) & 0x3FFF return datagrams MODULE_ENTRIES = ( (b"HPMaxAdd", 0.0), (b"AtkAdd", 0.0), (b"CritDamageBase", 0.06), (b"CritBase", 0.03), (b"AtkUp", 0.04), (b"HPMaxUp", 0.04), ) CARTRIDGE_ENTRIES = ( (b"AtkUp", 0.0), (b"CritDamageBase", 0.06), (b"CritBase", 0.03), (b"DefUp", 0.07), (b"HPMaxUp", 0.04), ) OWNER = b"\0\0\0\x0d\x0d\x20\x3d\x6d\x00\x00\x00\x00" class FramingTests(unittest.TestCase): def test_payload_end_bit_finds_the_double_terminator(self): [datagram] = pack_datagrams(b"\x01\x02\x03\x04") self.assertEqual(payload_end_bit(datagram), HEADER_BITS + 32) def test_splice_round_trips_a_payload_across_datagrams(self): payload = bytes(range(256)) * 3 # A chunk width that is not a multiple of 8 forces the accumulator to # rejoin the stream at a bit offset, as the real capture does. datagrams = pack_datagrams(payload, chunk_bits=333) self.assertGreater(len(datagrams), 1) self.assertEqual(splice_datagrams(datagrams)[: len(payload)], payload) def test_pure_acks_are_skipped(self): payload = b"\x01\x02\x03\x04\x05\x06\x07\x08" ack = bytes(12) datagrams = pack_datagrams(payload) self.assertEqual(splice_datagrams([ack] + datagrams + [ack])[:8], payload) class ItemRecordTests(unittest.TestCase): def decode(self, payload: bytes, **kwargs): return extract_gear(pack_datagrams(b"\0" * 8 + payload, **kwargs)) def test_module_record_decodes(self): snapshot = self.decode( item_record(b"cell3_style6_1_Orange", level=20, entries=MODULE_ENTRIES) ) [item] = snapshot.items self.assertEqual(item.kind, "module") self.assertEqual(item.item_id, "cell3_style6_1_Orange") self.assertEqual(item.shape, "cell3_style6") self.assertEqual(item.module_type, "III") self.assertEqual(item.level, 20) self.assertEqual(item.rarity, "orange") self.assertEqual(item.instance, "1122334455667788") self.assertIsNone(item.owner_group) # Both mains are computed from the cell count, never transmitted. self.assertEqual(dict(item.main_stats), {"HPMaxAdd": 840.0, "AtkAdd": 63.0}) self.assertEqual(len(item.substats), 4) def test_cartridge_record_decodes_with_its_set(self): snapshot = self.decode( item_record(b"Incantation_orange", level=20, entries=CARTRIDGE_ENTRIES) ) [item] = snapshot.items self.assertEqual(item.kind, "cartridge") self.assertIsNotNone(item.set_name) self.assertEqual(len(item.main_stats), 1) self.assertEqual(len(item.substats), 4) def test_owner_reference_is_read_when_equipped(self): snapshot = self.decode( item_record( b"cell3_style6_1_Orange", level=20, entries=MODULE_ENTRIES, owner=OWNER, ) ) [item] = snapshot.items self.assertEqual(item.owner_group, OWNER.hex()) # The owner field overlaps the level word without corrupting it. self.assertEqual(item.level, 20) def test_record_spanning_a_datagram_boundary_decodes(self): record = item_record( b"cell3_style6_1_Orange", level=20, entries=MODULE_ENTRIES, owner=OWNER ) # Chunks far smaller than the record, at a width that is not a whole # number of bytes: every field lands across at least one boundary. snapshot = self.decode(record, chunk_bits=101) [item] = snapshot.items self.assertEqual(item.item_id, "cell3_style6_1_Orange") self.assertEqual(item.level, 20) self.assertEqual(item.owner_group, OWNER.hex()) self.assertEqual(dict(item.main_stats), {"HPMaxAdd": 840.0, "AtkAdd": 63.0}) def test_unframed_id_is_ignored(self): # Same bytes, wrong length prefix: the frame check must reject it. record = bytearray( item_record(b"cell3_style6_1_Orange", entries=MODULE_ENTRIES) ) record[0:4] = struct.pack("