Add safe snapshot and segment assembly
Add envelope-aware snapshot and segment assembly for structured Monopoly and Arc history records.
This commit is contained in:
parent
a5312520e7
commit
73bb100cd6
8 changed files with 601 additions and 23 deletions
|
|
@ -13,6 +13,9 @@ Known limitations:
|
|||
- Structured Monopoly and Arc blocks are parsed in enrichment/fallback mode.
|
||||
The existing decoder remains authoritative when structured rows do not agree
|
||||
on record count, reward ID, and timestamp.
|
||||
- Structured snapshot/segment assembly is limited to runs where every decoded
|
||||
row came from structured fallback. Existing primary-decoder runs are never
|
||||
reordered. Ambiguous generations retain the last proven snapshot.
|
||||
- Live capture prefers Npcap on Windows and automatically falls back to the built-in raw-socket backend if Npcap is unavailable. Linux and macOS require the system libpcap runtime.
|
||||
- The file adapter reads mitmproxy `.flows` captures for research and testing.
|
||||
- Npcap is Windows-only and is not redistributed with this project; Linux and macOS use their system libpcap.
|
||||
|
|
|
|||
|
|
@ -21,8 +21,29 @@ The structured parser is deliberately compatibility-gated:
|
|||
- Malformed, incomplete, mismatched, or ambiguous structured data is ignored;
|
||||
it cannot overwrite a successfully decoded primary row.
|
||||
|
||||
Structured protocol envelopes identify a history stream, page, query side, and
|
||||
segment index. For an all-structured fallback run, the snapshot assembler:
|
||||
|
||||
- orders segments by their protocol index while retaining row order inside
|
||||
every segment;
|
||||
- ignores exact retransmissions;
|
||||
- starts a new generation when an existing segment index changes;
|
||||
- replaces an older snapshot only when the new generation covers at least the
|
||||
same segment range;
|
||||
- merges a partial generation only when its suffix has one unique overlap with
|
||||
the proven snapshot; and
|
||||
- retains the proven snapshot and records an assembly warning when a merge is
|
||||
ambiguous.
|
||||
|
||||
Assembly never runs on a history run containing a successfully decoded primary
|
||||
row. Such runs retain their existing packet/page order exactly. Timestamp-group
|
||||
ordinals and UIDs are calculated only after any fallback assembly, using the
|
||||
same inputs and algorithms as before.
|
||||
|
||||
`decoder_mode`, `structured_protocol_view`, `structured_pool_id`,
|
||||
`secondary_reward_id`, and `secondary_quantity` are research/debug CSV fields.
|
||||
`structured_generation_index`, `structured_assembly`,
|
||||
`structured_assembly_warning_count`, `secondary_reward_id`, and
|
||||
`secondary_quantity` are research/debug CSV fields.
|
||||
They are intentionally omitted from the public JSON export, whose format stays
|
||||
at version 1.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ from nte_history_exporter.constants import (
|
|||
from nte_history_exporter.decoder.boundary import select_continuous_run_from_page_1
|
||||
from nte_history_exporter.decoder.run import fmt_packet_time
|
||||
from nte_history_exporter.mappings import ARC_META
|
||||
from nte_history_exporter.decoder.structured_protocol import StructuredRecord, parse_structured_records
|
||||
from nte_history_exporter.decoder.structured_protocol import (
|
||||
StructuredProtocolAssembler,
|
||||
StructuredRecord,
|
||||
parse_structured_blocks,
|
||||
parse_structured_records,
|
||||
)
|
||||
|
||||
|
||||
def is_arc_history_request(content: bytes) -> bool:
|
||||
|
|
@ -125,7 +130,7 @@ def _arc_metadata(arc_id: str) -> dict[str, Any]:
|
|||
return next((meta for item_id, meta in ARC_META.items() if item_id.casefold() == folded), {})
|
||||
|
||||
|
||||
def _structured_arc_rows(structured_rows: list[StructuredRecord]) -> list[dict[str, Any]]:
|
||||
def structured_arc_rows(structured_rows: list[StructuredRecord]) -> list[dict[str, Any]]:
|
||||
records = []
|
||||
for structured in structured_rows:
|
||||
meta = _arc_metadata(structured.item_id)
|
||||
|
|
@ -149,6 +154,7 @@ def _structured_arc_rows(structured_rows: list[StructuredRecord]) -> list[dict[s
|
|||
"decoder_mode": "structured_fallback",
|
||||
"structured_pool_id": structured.pool_id,
|
||||
"structured_protocol_view": structured.protocol_view,
|
||||
"structured_generation_index": structured.generation_index,
|
||||
}
|
||||
)
|
||||
return records
|
||||
|
|
@ -176,10 +182,10 @@ def parse_arc_response(response: bytes) -> list[dict[str, Any]]:
|
|||
legacy_rows = _parse_legacy_arc_response(response)
|
||||
if legacy_rows:
|
||||
return _enrich_legacy_arc_rows(legacy_rows, structured_rows)
|
||||
return _structured_arc_rows(structured_rows)
|
||||
return structured_arc_rows(structured_rows)
|
||||
|
||||
|
||||
def build_arc_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
|
||||
def _build_primary_arc_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
|
||||
pool = POOL_META["arc_miracle_box"]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for pair in pairs:
|
||||
|
|
@ -209,6 +215,48 @@ def build_arc_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
|
|||
return rows
|
||||
|
||||
|
||||
def build_arc_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
|
||||
primary_rows = _build_primary_arc_rows_from_pairs(pairs)
|
||||
if primary_rows and any(row.get("decoder_mode") != "structured_fallback" for row in primary_rows):
|
||||
return primary_rows
|
||||
|
||||
assembler = StructuredProtocolAssembler()
|
||||
for source_index, pair in enumerate(pairs):
|
||||
assembler.add_blocks(parse_structured_blocks(pair[6], "fork", source_index=source_index))
|
||||
assembled = assembler.rows("fork")
|
||||
if not assembled:
|
||||
return primary_rows
|
||||
|
||||
pool = POOL_META["arc_miracle_box"]
|
||||
rows = []
|
||||
for row_index, (record, structured) in enumerate(
|
||||
zip(structured_arc_rows(assembled), assembled), start=1
|
||||
):
|
||||
source_index = structured.source_index or 0
|
||||
pair = pairs[source_index]
|
||||
page, offset, req_i, req_ts, resp_i, resp_ts, response = pair[:7]
|
||||
rows.append(
|
||||
{
|
||||
**record,
|
||||
"page": page,
|
||||
"offset": offset,
|
||||
"row": row_index,
|
||||
"pool_group_id": pool["id"],
|
||||
"pool_group_name": pool["name"],
|
||||
"request_msg": req_i,
|
||||
"request_time_utc": fmt_packet_time(req_ts),
|
||||
"response_msg": resp_i,
|
||||
"response_time_utc": fmt_packet_time(resp_ts),
|
||||
"response_len": len(response),
|
||||
"record_count": len(assembled),
|
||||
"structured_assembly": "snapshot_segments",
|
||||
"structured_assembly_warning_count": len(assembler.warnings),
|
||||
}
|
||||
)
|
||||
annotate_arc_groups(rows)
|
||||
return rows
|
||||
|
||||
|
||||
def make_arc_uid(timestamp_raw: str, ordinal: int) -> str:
|
||||
source = "|".join([GAME_UID_PART, ARC_SYSTEM, ARC_BANNER_ID, timestamp_raw, str(ordinal)])
|
||||
return hashlib.sha256(source.encode("utf-8")).hexdigest()[:32]
|
||||
|
|
|
|||
|
|
@ -368,7 +368,7 @@ def _reward_metadata(reward_id: str) -> dict[str, Any]:
|
|||
return next((meta for item_id, meta in REWARDS_BY_ID.items() if item_id.casefold() == folded), {})
|
||||
|
||||
|
||||
def _structured_monopoly_rows(structured_rows: list[StructuredRecord]) -> list[dict[str, Any]]:
|
||||
def structured_monopoly_rows(structured_rows: list[StructuredRecord]) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for row_index, structured in enumerate(structured_rows, start=1):
|
||||
dice, dice_raw, result_type, result_source = _structured_result(structured.roll_points_raw)
|
||||
|
|
@ -405,6 +405,7 @@ def _structured_monopoly_rows(structured_rows: list[StructuredRecord]) -> list[d
|
|||
"secondary_reward_id": structured.secondary_item_id,
|
||||
"secondary_quantity": structured.secondary_count,
|
||||
"structured_protocol_view": structured.protocol_view,
|
||||
"structured_generation_index": structured.generation_index,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
|
@ -425,4 +426,4 @@ def decode_response_records(response_content: bytes) -> list[dict[str, Any]]:
|
|||
break
|
||||
if heuristic_rows:
|
||||
return _enrich_heuristic_rows(heuristic_rows, structured_rows)
|
||||
return _structured_monopoly_rows(structured_rows)
|
||||
return structured_monopoly_rows(structured_rows)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ from datetime import datetime, timezone
|
|||
from typing import Any
|
||||
|
||||
from nte_history_exporter.constants import POOL_META
|
||||
from nte_history_exporter.decoder.protocol import decode_response_records
|
||||
from nte_history_exporter.decoder.protocol import decode_response_records, structured_monopoly_rows
|
||||
from nte_history_exporter.decoder.structured_protocol import (
|
||||
StructuredProtocolAssembler,
|
||||
parse_structured_blocks,
|
||||
)
|
||||
|
||||
|
||||
def fmt_packet_time(ts: float | None) -> str:
|
||||
|
|
@ -13,7 +17,7 @@ def fmt_packet_time(ts: float | None) -> str:
|
|||
return datetime.fromtimestamp(ts, timezone.utc).strftime("%H:%M:%S.%f")[:-3]
|
||||
|
||||
|
||||
def build_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
|
||||
def _build_primary_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
|
||||
rows_out: list[dict[str, Any]] = []
|
||||
for pair in pairs:
|
||||
page, offset, req_i, req_ts, resp_i, resp_ts, response_content = pair[:7]
|
||||
|
|
@ -59,3 +63,47 @@ def build_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
|
|||
}
|
||||
)
|
||||
return rows_out
|
||||
|
||||
|
||||
def build_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
|
||||
primary_rows = _build_primary_rows_from_pairs(pairs)
|
||||
decoded_rows = [row for row in primary_rows if row.get("record_count", 0)]
|
||||
if decoded_rows and any(row.get("decoder_mode") != "structured_fallback" for row in decoded_rows):
|
||||
return primary_rows
|
||||
|
||||
assembler = StructuredProtocolAssembler()
|
||||
for source_index, pair in enumerate(pairs):
|
||||
assembler.add_blocks(
|
||||
parse_structured_blocks(pair[6], "monopoly", source_index=source_index)
|
||||
)
|
||||
assembled = assembler.rows("monopoly")
|
||||
if not assembled:
|
||||
return primary_rows
|
||||
|
||||
rows_out: list[dict[str, Any]] = []
|
||||
converted = structured_monopoly_rows(assembled)
|
||||
for row_index, (record, structured) in enumerate(zip(converted, assembled), start=1):
|
||||
source_index = structured.source_index or 0
|
||||
pair = pairs[source_index]
|
||||
page, offset, req_i, req_ts, resp_i, resp_ts, response_content = pair[:7]
|
||||
kind = pair[7] if len(pair) > 7 else "permanent"
|
||||
pool = POOL_META.get(kind, POOL_META["permanent"])
|
||||
rows_out.append(
|
||||
{
|
||||
"page": page,
|
||||
"offset": offset,
|
||||
"pool_group_id": pool["id"],
|
||||
"pool_group_name": pool["name"],
|
||||
"request_msg": req_i,
|
||||
"request_time_utc": fmt_packet_time(req_ts),
|
||||
"response_msg": resp_i,
|
||||
"response_time_utc": fmt_packet_time(resp_ts),
|
||||
"response_len": len(response_content),
|
||||
"record_count": len(assembled),
|
||||
**record,
|
||||
"row": row_index,
|
||||
"structured_assembly": "snapshot_segments",
|
||||
"structured_assembly_warning_count": len(assembler.warnings),
|
||||
}
|
||||
)
|
||||
return rows_out
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
|
|
@ -16,6 +16,10 @@ DOTNET_EPOCH_TICKS = 621_355_968_000_000_000
|
|||
DOTNET_TICKS_PER_SECOND = 10_000_000
|
||||
MIN_UNIX_SECONDS = 1_500_000_000
|
||||
MAX_UNIX_SECONDS = 4_102_444_800
|
||||
PROTOCOL_CONSTANT = 0x03000000
|
||||
MONOPOLY_BLOCK_KIND = 527
|
||||
FORK_BLOCK_KIND = 5906
|
||||
MONOPOLY_ENVELOPE_FOOTER = 1_774_080
|
||||
|
||||
|
||||
class StructuredProtocolError(ValueError):
|
||||
|
|
@ -38,6 +42,132 @@ class StructuredRecord:
|
|||
record_end: int
|
||||
record_hex: str
|
||||
protocol_view: str
|
||||
source_index: int | None = None
|
||||
generation_index: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProtocolEnvelope:
|
||||
record_type: RecordType
|
||||
stream_key: str
|
||||
page_index: int
|
||||
query_high: bool
|
||||
segment_index: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StructuredBlock:
|
||||
record_type: RecordType
|
||||
marker_offset: int
|
||||
declared_size: int
|
||||
rows: tuple[StructuredRecord, ...]
|
||||
envelope: ProtocolEnvelope | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Generation:
|
||||
index: int
|
||||
segments: dict[int, StructuredBlock] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Stream:
|
||||
generations: list[_Generation] = field(default_factory=list)
|
||||
|
||||
|
||||
class StructuredProtocolAssembler:
|
||||
"""Assemble retransmitted and overlapping structured snapshot segments."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._stream_order: list[str] = []
|
||||
self._streams: dict[str, _Stream] = {}
|
||||
self._legacy_rows: list[StructuredRecord] = []
|
||||
self.warnings: list[dict[str, str | int]] = []
|
||||
|
||||
def add_blocks(self, blocks: list[StructuredBlock]) -> None:
|
||||
for block in blocks:
|
||||
self.add_block(block)
|
||||
|
||||
def add_block(self, block: StructuredBlock) -> bool:
|
||||
envelope = block.envelope
|
||||
if envelope is None:
|
||||
if not self._legacy_rows:
|
||||
self._stream_order.append("__legacy__")
|
||||
self._legacy_rows.extend(block.rows)
|
||||
return True
|
||||
|
||||
stream = self._streams.get(envelope.stream_key)
|
||||
if stream is None:
|
||||
stream = _Stream()
|
||||
self._streams[envelope.stream_key] = stream
|
||||
self._stream_order.append(envelope.stream_key)
|
||||
if not stream.generations:
|
||||
stream.generations.append(_Generation(0))
|
||||
generation = stream.generations[-1]
|
||||
existing = generation.segments.get(envelope.segment_index)
|
||||
if existing is not None:
|
||||
if _block_signature(existing) == _block_signature(block):
|
||||
return False
|
||||
generation = _Generation(len(stream.generations))
|
||||
stream.generations.append(generation)
|
||||
generation.segments[envelope.segment_index] = block
|
||||
return True
|
||||
|
||||
def rows(self, record_type: RecordType | None = None) -> list[StructuredRecord]:
|
||||
rows: list[StructuredRecord] = []
|
||||
for stream_key in self._stream_order:
|
||||
if stream_key == "__legacy__":
|
||||
rows.extend(
|
||||
row
|
||||
for row in self._legacy_rows
|
||||
if record_type is None or row.record_type == record_type
|
||||
)
|
||||
continue
|
||||
assembled = self._assemble_stream(stream_key, self._streams[stream_key])
|
||||
rows.extend(row for row in assembled if record_type is None or row.record_type == record_type)
|
||||
return rows
|
||||
|
||||
def _assemble_stream(self, stream_key: str, stream: _Stream) -> list[StructuredRecord]:
|
||||
result: list[StructuredRecord] = []
|
||||
result_max_segment: int | None = None
|
||||
for generation in stream.generations:
|
||||
if not generation.segments:
|
||||
continue
|
||||
generation_rows = _generation_rows(generation)
|
||||
segment_indexes = sorted(generation.segments)
|
||||
generation_min = segment_indexes[0]
|
||||
generation_max = segment_indexes[-1]
|
||||
if not result:
|
||||
result = generation_rows
|
||||
result_max_segment = generation_max
|
||||
continue
|
||||
if generation_min == 0:
|
||||
if result_max_segment is None or generation_max >= result_max_segment:
|
||||
result = generation_rows
|
||||
result_max_segment = generation_max
|
||||
continue
|
||||
merged = _partial_snapshot_merge(generation_rows, result)
|
||||
if merged is not None:
|
||||
result = merged
|
||||
else:
|
||||
self._warn(stream_key, generation, "partial snapshot cannot be merged safely")
|
||||
continue
|
||||
if result_max_segment is not None and generation_min > result_max_segment:
|
||||
result.extend(generation_rows)
|
||||
result_max_segment = generation_max
|
||||
continue
|
||||
self._warn(stream_key, generation, "non-zero snapshot reset cannot be merged safely")
|
||||
return result
|
||||
|
||||
def _warn(self, stream_key: str, generation: _Generation, message: str) -> None:
|
||||
warning = {
|
||||
"code": "AMBIGUOUS_STRUCTURED_SNAPSHOT",
|
||||
"stream_key": stream_key,
|
||||
"generation_index": generation.index,
|
||||
"message": message,
|
||||
}
|
||||
if warning not in self.warnings:
|
||||
self.warnings.append(warning)
|
||||
|
||||
|
||||
def parse_structured_records(payload: bytes, record_type: RecordType) -> list[StructuredRecord]:
|
||||
|
|
@ -47,24 +177,38 @@ def parse_structured_records(payload: bytes, record_type: RecordType) -> list[St
|
|||
as enrichment/fallback and retain the established decoder as their primary
|
||||
path.
|
||||
"""
|
||||
assembler = StructuredProtocolAssembler()
|
||||
assembler.add_blocks(parse_structured_blocks(payload, record_type))
|
||||
return assembler.rows(record_type)
|
||||
|
||||
|
||||
def parse_structured_blocks(
|
||||
payload: bytes,
|
||||
record_type: RecordType,
|
||||
*,
|
||||
source_index: int | None = None,
|
||||
) -> list[StructuredBlock]:
|
||||
marker = MONOPOLY_MARKER if record_type == "monopoly" else FORK_MARKER
|
||||
for view_name, data in _iter_protocol_views(payload):
|
||||
if marker not in data:
|
||||
continue
|
||||
records: list[StructuredRecord] = []
|
||||
blocks: list[StructuredBlock] = []
|
||||
search_from = 0
|
||||
while True:
|
||||
marker_pos = data.find(marker, search_from)
|
||||
if marker_pos < 0:
|
||||
break
|
||||
try:
|
||||
parsed = _parse_block(data, marker_pos, record_type, marker, view_name)
|
||||
parsed = _parse_block(
|
||||
data, marker_pos, record_type, marker, view_name, source_index
|
||||
)
|
||||
except (StructuredProtocolError, UnicodeDecodeError):
|
||||
parsed = []
|
||||
records.extend(parsed)
|
||||
parsed = None
|
||||
if parsed is not None:
|
||||
blocks.append(parsed)
|
||||
search_from = marker_pos + len(marker)
|
||||
if records:
|
||||
return records
|
||||
if blocks:
|
||||
return blocks
|
||||
return []
|
||||
|
||||
|
||||
|
|
@ -74,7 +218,9 @@ def _parse_block(
|
|||
record_type: RecordType,
|
||||
marker: bytes,
|
||||
view_name: str,
|
||||
) -> list[StructuredRecord]:
|
||||
source_index: int | None,
|
||||
) -> StructuredBlock:
|
||||
envelope = _parse_protocol_envelope(record_type, data, marker_pos, view_name)
|
||||
pos = marker_pos + len(marker)
|
||||
if _byte_at(data, pos) == 0:
|
||||
pos += 1
|
||||
|
|
@ -92,14 +238,22 @@ def _parse_block(
|
|||
for _row_index in range(row_count):
|
||||
row_start = reader.pos
|
||||
if record_type == "monopoly":
|
||||
record = _parse_monopoly_row(reader, row_start, view_name)
|
||||
record = _parse_monopoly_row(reader, row_start, view_name, source_index)
|
||||
else:
|
||||
record = _parse_fork_row(reader, row_start, view_name)
|
||||
record = _parse_fork_row(reader, row_start, view_name, source_index)
|
||||
records.append(record)
|
||||
return records
|
||||
return StructuredBlock(
|
||||
record_type=record_type,
|
||||
marker_offset=marker_pos,
|
||||
declared_size=declared_size,
|
||||
rows=tuple(records),
|
||||
envelope=envelope,
|
||||
)
|
||||
|
||||
|
||||
def _parse_monopoly_row(reader: "_Reader", row_start: int, view_name: str) -> StructuredRecord:
|
||||
def _parse_monopoly_row(
|
||||
reader: "_Reader", row_start: int, view_name: str, source_index: int | None
|
||||
) -> StructuredRecord:
|
||||
roll_points_raw = reader.u32()
|
||||
item_spec = reader.string()
|
||||
_reserved = reader.u32()
|
||||
|
|
@ -127,10 +281,13 @@ def _parse_monopoly_row(reader: "_Reader", row_start: int, view_name: str) -> St
|
|||
secondary_count,
|
||||
row_start,
|
||||
view_name,
|
||||
source_index,
|
||||
)
|
||||
|
||||
|
||||
def _parse_fork_row(reader: "_Reader", row_start: int, view_name: str) -> StructuredRecord:
|
||||
def _parse_fork_row(
|
||||
reader: "_Reader", row_start: int, view_name: str, source_index: int | None
|
||||
) -> StructuredRecord:
|
||||
item_spec = reader.string()
|
||||
pool_id = reader.string()
|
||||
ticks = reader.u64()
|
||||
|
|
@ -145,6 +302,7 @@ def _parse_fork_row(reader: "_Reader", row_start: int, view_name: str) -> Struct
|
|||
None,
|
||||
row_start,
|
||||
view_name,
|
||||
source_index,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -159,6 +317,7 @@ def _make_record(
|
|||
secondary_count: int | None,
|
||||
row_start: int,
|
||||
view_name: str,
|
||||
source_index: int | None,
|
||||
) -> StructuredRecord:
|
||||
item_id, count = _parse_item_spec(item_spec)
|
||||
if not item_id:
|
||||
|
|
@ -182,6 +341,7 @@ def _make_record(
|
|||
record_end=reader.pos,
|
||||
record_hex=reader.data[row_start : reader.pos].hex(),
|
||||
protocol_view=view_name,
|
||||
source_index=source_index,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -197,6 +357,108 @@ def _parse_item_spec(value: str) -> tuple[str, int]:
|
|||
return value, 1
|
||||
|
||||
|
||||
def _parse_protocol_envelope(
|
||||
record_type: RecordType,
|
||||
data: bytes,
|
||||
marker_pos: int,
|
||||
view_name: str,
|
||||
) -> ProtocolEnvelope | None:
|
||||
if marker_pos == 0 or not view_name.startswith("shift8:"):
|
||||
return None
|
||||
if record_type == "monopoly":
|
||||
if marker_pos < 26:
|
||||
raise StructuredProtocolError("monopoly envelope is truncated")
|
||||
protocol_constant = _relative_u32(data, marker_pos, -26)
|
||||
query_raw = _relative_u32(data, marker_pos, -22)
|
||||
page_raw = _relative_u32(data, marker_pos, -18)
|
||||
block_kind = _relative_u32(data, marker_pos, -14)
|
||||
pool_token = _relative_u32(data, marker_pos, -10)
|
||||
footer = _relative_u32(data, marker_pos, -6)
|
||||
if (
|
||||
protocol_constant != PROTOCOL_CONSTANT
|
||||
or block_kind != MONOPOLY_BLOCK_KIND
|
||||
or footer != MONOPOLY_ENVELOPE_FOOTER
|
||||
):
|
||||
raise StructuredProtocolError("invalid monopoly envelope constants")
|
||||
stream_key = f"monopoly:{pool_token}"
|
||||
else:
|
||||
if marker_pos < 17:
|
||||
raise StructuredProtocolError("fork envelope is truncated")
|
||||
protocol_constant = _relative_u32(data, marker_pos, -17)
|
||||
query_raw = _relative_u32(data, marker_pos, -13)
|
||||
page_raw = _relative_u32(data, marker_pos, -9)
|
||||
block_kind = _relative_u32(data, marker_pos, -5)
|
||||
if protocol_constant != PROTOCOL_CONSTANT or block_kind != FORK_BLOCK_KIND:
|
||||
raise StructuredProtocolError("invalid fork envelope constants")
|
||||
stream_key = "fork"
|
||||
page_index = page_raw & 0x7FFFFFFF
|
||||
query_high = bool(query_raw & 0x80000000)
|
||||
return ProtocolEnvelope(
|
||||
record_type=record_type,
|
||||
stream_key=stream_key,
|
||||
page_index=page_index,
|
||||
query_high=query_high,
|
||||
segment_index=_segment_index(page_index, query_high),
|
||||
)
|
||||
|
||||
|
||||
def _segment_index(page_index: int, query_high: bool) -> int:
|
||||
if query_high:
|
||||
return page_index * 2
|
||||
if page_index > 0:
|
||||
return page_index * 2 - 1
|
||||
raise StructuredProtocolError("low query cannot describe page zero")
|
||||
|
||||
|
||||
def _row_signature(row: StructuredRecord) -> tuple:
|
||||
return (
|
||||
row.record_type,
|
||||
row.ticks,
|
||||
row.pool_id,
|
||||
row.item_id,
|
||||
row.count,
|
||||
row.roll_points_raw,
|
||||
row.secondary_item_id,
|
||||
row.secondary_count,
|
||||
)
|
||||
|
||||
|
||||
def _block_signature(block: StructuredBlock) -> tuple:
|
||||
return block.record_type, tuple(_row_signature(row) for row in block.rows)
|
||||
|
||||
|
||||
def _generation_rows(generation: _Generation) -> list[StructuredRecord]:
|
||||
rows = []
|
||||
for segment_index in sorted(generation.segments):
|
||||
block = generation.segments[segment_index]
|
||||
rows.extend(replace(row, generation_index=generation.index) for row in block.rows)
|
||||
return rows
|
||||
|
||||
|
||||
def _partial_snapshot_merge(
|
||||
new_rows: list[StructuredRecord], old_rows: list[StructuredRecord]
|
||||
) -> list[StructuredRecord] | None:
|
||||
if not new_rows:
|
||||
return list(old_rows)
|
||||
if not old_rows:
|
||||
return list(new_rows)
|
||||
new_signatures = [_row_signature(row) for row in new_rows]
|
||||
old_signatures = [_row_signature(row) for row in old_rows]
|
||||
max_overlap = min(len(new_signatures), len(old_signatures))
|
||||
matches: list[tuple[int, int]] = []
|
||||
for overlap in range(max_overlap, 0, -1):
|
||||
suffix = new_signatures[-overlap:]
|
||||
for position in range(len(old_signatures) - overlap + 1):
|
||||
if old_signatures[position : position + overlap] == suffix:
|
||||
matches.append((overlap, position))
|
||||
if matches:
|
||||
break
|
||||
if len(matches) != 1:
|
||||
return None
|
||||
overlap, position = matches[0]
|
||||
return [*new_rows, *old_rows[position + overlap :]]
|
||||
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, data: bytes, pos: int) -> None:
|
||||
self.data = data
|
||||
|
|
@ -271,6 +533,13 @@ def _u32_at(data: bytes, pos: int) -> int:
|
|||
raise StructuredProtocolError("u32 exceeds payload") from exc
|
||||
|
||||
|
||||
def _relative_u32(data: bytes, marker_pos: int, relative_pos: int) -> int:
|
||||
pos = marker_pos + relative_pos
|
||||
if pos < 0:
|
||||
raise StructuredProtocolError("envelope position precedes payload")
|
||||
return _u32_at(data, pos)
|
||||
|
||||
|
||||
def _u64_at(data: bytes, pos: int) -> int:
|
||||
try:
|
||||
return struct.unpack_from("<Q", data, pos)[0]
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ FIELDNAMES = [
|
|||
"dice_raw_u32",
|
||||
"decoder_mode",
|
||||
"structured_protocol_view",
|
||||
"structured_generation_index",
|
||||
"structured_pool_id",
|
||||
"structured_assembly",
|
||||
"structured_assembly_warning_count",
|
||||
"reward_type",
|
||||
"reward_id",
|
||||
"reward_name",
|
||||
|
|
|
|||
|
|
@ -2,17 +2,31 @@ from __future__ import annotations
|
|||
|
||||
import csv
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from nte_history_exporter.decoder.arc import parse_arc_response
|
||||
from nte_history_exporter.decoder.arc import (
|
||||
build_arc_rows_from_pairs,
|
||||
make_arc_uid,
|
||||
parse_arc_response,
|
||||
)
|
||||
from nte_history_exporter.decoder.boundary import annotate_groups, make_uid
|
||||
from nte_history_exporter.decoder.protocol import decode_response_records
|
||||
from nte_history_exporter.decoder.structured_protocol import (
|
||||
FORK_BLOCK_KIND,
|
||||
FORK_MARKER,
|
||||
MONOPOLY_BLOCK_KIND,
|
||||
MONOPOLY_ENVELOPE_FOOTER,
|
||||
MONOPOLY_MARKER,
|
||||
PROTOCOL_CONSTANT,
|
||||
ProtocolEnvelope,
|
||||
StructuredProtocolAssembler,
|
||||
parse_structured_blocks,
|
||||
parse_structured_records,
|
||||
)
|
||||
from nte_history_exporter.decoder.run import build_rows_from_pairs
|
||||
from nte_history_exporter.export.csv_export import write_csv
|
||||
from nte_history_exporter.export.json_export import build_export_json
|
||||
from tests.support import fixture_payload
|
||||
|
|
@ -72,6 +86,57 @@ def bit_pack_after_eight_byte_header(payload: bytes, shift: int) -> bytes:
|
|||
return bytes(8) + packed.to_bytes(len(payload) + 1, "little")
|
||||
|
||||
|
||||
def enveloped_monopoly_payload(
|
||||
item_spec: str,
|
||||
*,
|
||||
page_index: int,
|
||||
query_high: bool,
|
||||
pool_token: int = 256,
|
||||
shift: int = 3,
|
||||
) -> bytes:
|
||||
envelope = (
|
||||
PROTOCOL_CONSTANT.to_bytes(4, "little")
|
||||
+ ((0x80000000 if query_high else 0)).to_bytes(4, "little")
|
||||
+ page_index.to_bytes(4, "little")
|
||||
+ MONOPOLY_BLOCK_KIND.to_bytes(4, "little")
|
||||
+ pool_token.to_bytes(4, "little")
|
||||
+ MONOPOLY_ENVELOPE_FOOTER.to_bytes(4, "little")
|
||||
+ b"\0\0"
|
||||
)
|
||||
return bit_pack_after_eight_byte_header(envelope + monopoly_payload(item_spec), shift)
|
||||
|
||||
|
||||
def enveloped_fork_payload(
|
||||
item_spec: str,
|
||||
*,
|
||||
page_index: int,
|
||||
query_high: bool,
|
||||
shift: int = 3,
|
||||
) -> bytes:
|
||||
envelope = (
|
||||
PROTOCOL_CONSTANT.to_bytes(4, "little")
|
||||
+ ((0x80000000 if query_high else 0)).to_bytes(4, "little")
|
||||
+ page_index.to_bytes(4, "little")
|
||||
+ FORK_BLOCK_KIND.to_bytes(4, "little")
|
||||
+ b"\0"
|
||||
)
|
||||
return bit_pack_after_eight_byte_header(envelope + fork_payload(item_spec), shift)
|
||||
|
||||
|
||||
def assembled_block(item_id: str, segment_index: int, source_index: int = 0):
|
||||
block = parse_structured_blocks(
|
||||
monopoly_payload(item_id), "monopoly", source_index=source_index
|
||||
)[0]
|
||||
envelope = ProtocolEnvelope(
|
||||
record_type="monopoly",
|
||||
stream_key="monopoly:256",
|
||||
page_index=segment_index // 2,
|
||||
query_high=segment_index % 2 == 0,
|
||||
segment_index=segment_index,
|
||||
)
|
||||
return replace(block, envelope=envelope)
|
||||
|
||||
|
||||
class StructuredProtocolTests(unittest.TestCase):
|
||||
def test_structured_monopoly_parser_enriches_the_existing_decoder(self):
|
||||
payload = monopoly_payload(
|
||||
|
|
@ -122,6 +187,126 @@ class StructuredProtocolTests(unittest.TestCase):
|
|||
self.assertEqual(rows[0].item_id, "1003")
|
||||
self.assertEqual(rows[0].protocol_view, "shift8:3")
|
||||
|
||||
def test_protocol_envelope_exposes_stream_and_segment_identity(self):
|
||||
payload = enveloped_monopoly_payload("1003", page_index=2, query_high=False)
|
||||
|
||||
block = parse_structured_blocks(payload, "monopoly")[0]
|
||||
|
||||
self.assertIsNotNone(block.envelope)
|
||||
self.assertEqual(block.envelope.stream_key, "monopoly:256")
|
||||
self.assertEqual(block.envelope.page_index, 2)
|
||||
self.assertFalse(block.envelope.query_high)
|
||||
self.assertEqual(block.envelope.segment_index, 3)
|
||||
|
||||
def test_assembler_orders_segments_and_ignores_retransmissions(self):
|
||||
assembler = StructuredProtocolAssembler()
|
||||
segment_one = assembled_block("1010", 1)
|
||||
assembler.add_blocks([segment_one, assembled_block("1003", 0), segment_one])
|
||||
|
||||
rows = assembler.rows("monopoly")
|
||||
|
||||
self.assertEqual([row.item_id for row in rows], ["1003", "1010"])
|
||||
self.assertEqual([row.generation_index for row in rows], [0, 0])
|
||||
self.assertEqual(assembler.warnings, [])
|
||||
|
||||
def test_assembler_never_deduplicates_blocks_without_envelopes(self):
|
||||
assembler = StructuredProtocolAssembler()
|
||||
block = parse_structured_blocks(monopoly_payload("1003"), "monopoly")[0]
|
||||
|
||||
assembler.add_blocks([block, block])
|
||||
|
||||
self.assertEqual([row.item_id for row in assembler.rows("monopoly")], ["1003", "1003"])
|
||||
|
||||
def test_new_complete_generation_replaces_old_snapshot(self):
|
||||
assembler = StructuredProtocolAssembler()
|
||||
assembler.add_blocks([assembled_block("1003", 0), assembled_block("1010", 1)])
|
||||
assembler.add_blocks([assembled_block("1020", 0), assembled_block("1021", 1)])
|
||||
|
||||
rows = assembler.rows("monopoly")
|
||||
|
||||
self.assertEqual([row.item_id for row in rows], ["1020", "1021"])
|
||||
self.assertEqual([row.generation_index for row in rows], [1, 1])
|
||||
|
||||
def test_partial_generation_merges_only_on_unique_overlap(self):
|
||||
assembler = StructuredProtocolAssembler()
|
||||
assembler.add_blocks(
|
||||
[
|
||||
assembled_block("1003", 0),
|
||||
assembled_block("1010", 1),
|
||||
assembled_block("1020", 2),
|
||||
]
|
||||
)
|
||||
assembler.add_blocks([assembled_block("1099", 0), assembled_block("1010", 1)])
|
||||
|
||||
rows = assembler.rows("monopoly")
|
||||
|
||||
self.assertEqual([row.item_id for row in rows], ["1099", "1010", "1020"])
|
||||
self.assertEqual(assembler.warnings, [])
|
||||
|
||||
def test_ambiguous_partial_generation_keeps_proven_snapshot(self):
|
||||
assembler = StructuredProtocolAssembler()
|
||||
assembler.add_blocks(
|
||||
[
|
||||
assembled_block("1003", 0),
|
||||
assembled_block("1010", 1),
|
||||
assembled_block("1003", 2),
|
||||
assembled_block("1010", 3),
|
||||
]
|
||||
)
|
||||
assembler.add_blocks([assembled_block("1099", 0), assembled_block("1010", 1)])
|
||||
|
||||
rows = assembler.rows("monopoly")
|
||||
|
||||
self.assertEqual([row.item_id for row in rows], ["1003", "1010", "1003", "1010"])
|
||||
self.assertEqual(assembler.warnings[0]["code"], "AMBIGUOUS_STRUCTURED_SNAPSHOT")
|
||||
|
||||
def test_pair_assembly_is_used_only_for_all_structured_fallback(self):
|
||||
segment_one = enveloped_monopoly_payload("1010", page_index=1, query_high=False)
|
||||
segment_zero = enveloped_monopoly_payload("1003", page_index=0, query_high=True)
|
||||
pairs = [
|
||||
(2, 8, 1, 1.0, 2, 1.1, segment_one, "permanent"),
|
||||
(1, 4, 3, 1.2, 4, 1.3, segment_zero, "permanent"),
|
||||
]
|
||||
|
||||
with patch("nte_history_exporter.decoder.protocol._decode_aligned_response_records", return_value=[]):
|
||||
rows = build_rows_from_pairs(pairs)
|
||||
annotated = annotate_groups(rows)
|
||||
|
||||
self.assertEqual([row["reward_id"] for row in annotated], ["1003", "1010"])
|
||||
self.assertTrue(all(row["structured_assembly"] == "snapshot_segments" for row in annotated))
|
||||
self.assertEqual(annotated[0]["uid"], make_uid(annotated[0], 0))
|
||||
|
||||
def test_pair_assembly_cannot_reorder_successful_heuristic_rows(self):
|
||||
segment_one = enveloped_monopoly_payload("1010", page_index=1, query_high=False)
|
||||
segment_zero = enveloped_monopoly_payload("1003", page_index=0, query_high=True)
|
||||
pairs = [
|
||||
(2, 8, 1, 1.0, 2, 1.1, segment_one, "permanent"),
|
||||
(1, 4, 3, 1.2, 4, 1.3, segment_zero, "permanent"),
|
||||
]
|
||||
|
||||
rows = build_rows_from_pairs(pairs)
|
||||
|
||||
self.assertEqual([row["reward_id"] for row in rows], ["1010", "1003"])
|
||||
self.assertTrue(all(row["decoder_mode"] == "heuristic_enriched" for row in rows))
|
||||
self.assertTrue(all("structured_assembly" not in row for row in rows))
|
||||
|
||||
def test_arc_fallback_uses_the_same_segment_assembly_and_uid_order(self):
|
||||
segment_one = enveloped_fork_payload("fork_vine", page_index=1, query_high=False)
|
||||
segment_zero = enveloped_fork_payload("fork_dustbin", page_index=0, query_high=True)
|
||||
pairs = [
|
||||
(2, 4, 1, 1.0, 2, 1.1, segment_one, "arc_miracle_box"),
|
||||
(1, 2, 3, 1.2, 4, 1.3, segment_zero, "arc_miracle_box"),
|
||||
]
|
||||
|
||||
rows = build_arc_rows_from_pairs(pairs)
|
||||
|
||||
self.assertEqual([row["reward_id"] for row in rows], ["fork_dustbin", "fork_vine"])
|
||||
self.assertTrue(all(row["structured_assembly"] == "snapshot_segments" for row in rows))
|
||||
self.assertEqual(
|
||||
rows[0]["uid"],
|
||||
make_arc_uid(rows[0]["timestamp_raw_hex"], rows[0]["timestamp_group_ordinal"]),
|
||||
)
|
||||
|
||||
def test_matching_structured_data_enriches_without_replacing_heuristic_identity(self):
|
||||
heuristic_payload = fixture_payload("limited-points-gift-1")
|
||||
original = decode_response_records(heuristic_payload)[0]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue