Added Mystery Box Support

Added the event specific Mystery Box support to the exporter. Closes #7
This commit is contained in:
Golumpa 2026-07-16 00:50:14 +01:00
parent 2b33ea63af
commit 9eae9b4a15
24 changed files with 627 additions and 19 deletions

View file

@ -1 +1 @@
__version__ = "0.1.10"
__version__ = "0.1.11"

View file

@ -7,6 +7,10 @@ from typing import Any
from nte_history_exporter.decoder.boundary import select_continuous_run_from_page_1
from nte_history_exporter.decoder.arc import build_arc_rows_from_pairs, select_continuous_arc_run
from nte_history_exporter.decoder.run import build_rows_from_pairs
from nte_history_exporter.decoder.mystery_box import (
build_mystery_box_rows_from_pairs,
select_continuous_mystery_box_run,
)
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
from nte_history_exporter.live_capture.session import LiveHistorySession, UdpPacket
@ -101,12 +105,21 @@ def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> d
packet = UdpPacket(ts, remote_ip, local_ip, remote_port, local_port, content)
session.process_packet(packet)
pairs = [pair for pair in session.pairs if pair[7] != "arc_miracle_box"]
pairs = [
pair
for pair in session.pairs
if pair[7] not in {"arc_miracle_box", "mystery_box"}
]
arc_pairs = [pair for pair in session.pairs if pair[7] == "arc_miracle_box"]
mystery_box_pairs = [pair for pair in session.pairs if pair[7] == "mystery_box"]
best_run, run_warnings = select_continuous_run_from_page_1(pairs)
rows_out = build_rows_from_pairs(best_run)
best_arc_run, arc_warnings = select_continuous_arc_run(arc_pairs)
arc_rows = build_arc_rows_from_pairs(best_arc_run)
best_mystery_box_run, mystery_box_warnings = select_continuous_mystery_box_run(
mystery_box_pairs
)
mystery_box_rows = build_mystery_box_rows_from_pairs(best_mystery_box_run)
return {
"flow_index": resolved_flow_index,
@ -118,6 +131,10 @@ def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> d
"best_arc_run": best_arc_run,
"arc_rows": arc_rows,
"arc_warnings": arc_warnings,
"mystery_box_pairs": mystery_box_pairs,
"best_mystery_box_run": best_mystery_box_run,
"mystery_box_rows": mystery_box_rows,
"mystery_box_warnings": mystery_box_warnings,
"user_uid": session.user_uid or user_uid,
"capture_diagnostics": session.diagnostic_report(),
}

View file

@ -66,7 +66,13 @@ def main(argv: list[str] | None = None) -> int:
return 1
decoded = decode_mitmproxy_flows(args.capture_source, args.flow_index)
if decoded["arc_rows"] and not decoded["rows"]:
if decoded["mystery_box_rows"] and not decoded["rows"] and not decoded["arc_rows"]:
rows = decoded["mystery_box_rows"]
warnings = decoded["mystery_box_warnings"]
kind = "mystery_box"
best_run = decoded["best_mystery_box_run"]
pair_count = len(decoded["mystery_box_pairs"])
elif decoded["arc_rows"] and not decoded["rows"]:
rows = decoded["arc_rows"]
warnings = decoded["arc_warnings"]
kind = "arc_miracle_box"

View file

@ -82,6 +82,7 @@ def print_live_instructions(local_ip: str, backend: str = "windows_raw", detail:
print(style(" Monopoly > Standard Board history", CYAN))
print(style(" Monopoly > Limited Character Board history", CYAN))
print(style(" Gashapon > Arc Miracle Box history", CYAN))
print(style(" Gashapon > Mystery Box history", CYAN))
print(" 3. Start at page 1 and scroll down through every page")
print(" you want exported.")
print(" 4. Scroll one page past where you plan to stop so the")

View file

@ -10,8 +10,10 @@ LIMITED_CHARACTER_BANNER_ID = "Lottery_LimitedCharacter"
LIMITED_CHARACTER_BANNER_NAME = "Limited Character Board"
ARC_BANNER_ID = "Arc_MiracleBox"
ARC_BANNER_NAME = "Arc Miracle Box"
MYSTERY_BOX_BANNER_ID = "Gashapon_MysteryBox"
MYSTERY_BOX_BANNER_NAME = "Mystery Box"
EXPORTER_NAME = "nte-history-exporter"
EXPORTER_VERSION = "0.1.10"
EXPORTER_VERSION = "0.1.11"
HISTORY_REQUEST_BANNER = 4220
HISTORY_REQUEST_LENGTH = 45
@ -38,6 +40,12 @@ POOL_META = {
"system": ARC_SYSTEM_NAME,
"shared_pity": True,
},
"mystery_box": {
"id": MYSTERY_BOX_BANNER_ID,
"name": MYSTERY_BOX_BANNER_NAME,
"system": ARC_SYSTEM_NAME,
"shared_pity": False,
},
}
ARC_HISTORY_REQUEST_BANNER = 2060
@ -47,6 +55,15 @@ ARC_HISTORY_PAGE_CURSOR_MULTIPLIER = 2
ARC_RESPONSE_FIRST_RECORD_OFFSET = 0x4C
ARC_TIMESTAMP_TICKS_PER_SECOND = 20_000_000
MYSTERY_BOX_HISTORY_REQUEST_LENGTH = 54
MYSTERY_BOX_HISTORY_REQUEST_CONSTANT_OFFSET = 26
MYSTERY_BOX_HISTORY_REQUEST_CONSTANT = 2060
MYSTERY_BOX_HISTORY_CURSOR_OFFSET = 31
MYSTERY_BOX_HISTORY_PAGE_CURSOR_MULTIPLIER = 2
MYSTERY_BOX_HISTORY_REQUEST_KIND_OFFSET = 35
MYSTERY_BOX_HISTORY_REQUEST_KIND = 2110
MYSTERY_BOX_MARKER = b"FGashaponLotteryRecordData"
TIMESTAMP_TICKS_PER_SECOND = 40_000_000
DOTNET_UNIX_EPOCH_SECONDS = 62_135_596_800

View file

@ -0,0 +1,208 @@
from __future__ import annotations
import hashlib
import struct
from collections import defaultdict
from datetime import datetime, timezone
from typing import Any, Iterator
from nte_history_exporter.constants import (
ARC_SYSTEM,
DOTNET_UNIX_EPOCH_SECONDS,
GAME_UID_PART,
MYSTERY_BOX_BANNER_ID,
MYSTERY_BOX_HISTORY_CURSOR_OFFSET,
MYSTERY_BOX_HISTORY_PAGE_CURSOR_MULTIPLIER,
MYSTERY_BOX_HISTORY_REQUEST_CONSTANT,
MYSTERY_BOX_HISTORY_REQUEST_CONSTANT_OFFSET,
MYSTERY_BOX_HISTORY_REQUEST_KIND,
MYSTERY_BOX_HISTORY_REQUEST_KIND_OFFSET,
MYSTERY_BOX_HISTORY_REQUEST_LENGTH,
MYSTERY_BOX_MARKER,
POOL_META,
)
from nte_history_exporter.decoder.boundary import select_continuous_run_from_page_1
from nte_history_exporter.decoder.protocol import infer_reward_type
from nte_history_exporter.decoder.run import fmt_packet_time
from nte_history_exporter.mappings import REWARDS_BY_ID
DOTNET_TICKS_PER_SECOND = 10_000_000
MAX_RECORDS_PER_BLOCK = 100
MAX_REWARD_ID_LENGTH = 256
REWARDS_BY_CASEFOLDED_ID = {
reward_id.casefold(): reward for reward_id, reward in REWARDS_BY_ID.items()
}
def is_mystery_box_history_request(content: bytes) -> bool:
if len(content) < MYSTERY_BOX_HISTORY_REQUEST_LENGTH:
return False
return (
struct.unpack_from("<I", content, MYSTERY_BOX_HISTORY_REQUEST_CONSTANT_OFFSET)[0]
== MYSTERY_BOX_HISTORY_REQUEST_CONSTANT
and struct.unpack_from("<I", content, MYSTERY_BOX_HISTORY_REQUEST_KIND_OFFSET)[0]
== MYSTERY_BOX_HISTORY_REQUEST_KIND
)
def mystery_box_request_page(content: bytes) -> int:
cursor = struct.unpack_from("<I", content, MYSTERY_BOX_HISTORY_CURSOR_OFFSET)[0]
return cursor // MYSTERY_BOX_HISTORY_PAGE_CURSOR_MULTIPLIER
def _protocol_views(payload: bytes) -> Iterator[bytes]:
yield payload
# History payloads can begin at a non-byte-aligned transport position.
for bit_shift in range(1, 8):
shifted = bytearray()
for index in range(max(0, len(payload) - 8)):
bit_pos = (8 + index) * 8 + bit_shift
byte_pos, shift = divmod(bit_pos, 8)
if byte_pos >= len(payload):
break
value = payload[byte_pos] >> shift
if shift and byte_pos + 1 < len(payload):
value |= payload[byte_pos + 1] << (8 - shift)
shifted.append(value & 0xFF)
yield bytes(shifted)
def _decode_timestamp(raw: bytes) -> tuple[int, float, str]:
ticks = struct.unpack("<Q", raw)[0]
unix_seconds = ticks / DOTNET_TICKS_PER_SECOND - DOTNET_UNIX_EPOCH_SECONDS
decoded = datetime.fromtimestamp(unix_seconds, timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
return ticks, unix_seconds, decoded
def _parse_view(data: bytes) -> list[dict[str, Any]]:
marker_pos = data.find(MYSTERY_BOX_MARKER)
if marker_pos < 0:
return []
pos = marker_pos + len(MYSTERY_BOX_MARKER)
if pos < len(data) and data[pos] == 0:
pos += 1
if pos + 12 > len(data):
return []
_reserved, _declared_size, row_count = struct.unpack_from("<III", data, pos)
pos += 12
if row_count > MAX_RECORDS_PER_BLOCK:
return []
rows: list[dict[str, Any]] = []
try:
for _ in range(row_count):
record_start = pos
reward_length = struct.unpack_from("<I", data, pos)[0]
pos += 4
if not 1 <= reward_length <= MAX_REWARD_ID_LENGTH or pos + reward_length + 13 > len(data):
return []
reward_raw = data[pos : pos + reward_length]
pos += reward_length
if not reward_raw.endswith(b"\0"):
return []
reward_id = reward_raw[:-1].decode("utf-8")
quantity = struct.unpack_from("<I", data, pos)[0]
pos += 4
record_flag = data[pos]
pos += 1
timestamp_raw = data[pos : pos + 8]
pos += 8
ticks, unix_seconds, timestamp_decoded = _decode_timestamp(timestamp_raw)
reward = REWARDS_BY_ID.get(reward_id) or REWARDS_BY_CASEFOLDED_ID.get(
reward_id.casefold(), {}
)
rows.append(
{
"record_start": record_start,
"record_end": pos,
"record_len": pos - record_start,
"reward_type": reward.get("type") or infer_reward_type(reward_id),
"reward_id": reward_id,
"reward_name": reward.get("name", ""),
"reward_rank": reward.get("rank"),
"quantity": quantity,
"source_type": "mystery_box",
"result_type": "single_pull",
"timestamp_raw_hex": timestamp_raw.hex(),
"timestamp_ticks": ticks,
"timestamp_unix": unix_seconds,
"timestamp_decoded": timestamp_decoded,
"record_flag": record_flag,
"record_hex": data[record_start:pos].hex(),
"decoder_mode": "structured",
}
)
except (OSError, OverflowError, UnicodeDecodeError, ValueError, struct.error):
return []
return rows
def parse_mystery_box_response(payload: bytes) -> list[dict[str, Any]]:
for view in _protocol_views(payload):
rows = _parse_view(view)
if rows:
return rows
return []
def make_mystery_box_uid(timestamp_raw: str, ordinal: int) -> str:
source = "|".join(
[GAME_UID_PART, ARC_SYSTEM, MYSTERY_BOX_BANNER_ID, timestamp_raw, str(ordinal)]
)
return hashlib.sha256(source.encode("utf-8")).hexdigest()[:32]
def annotate_mystery_box_rows(rows: list[dict[str, Any]]) -> None:
groups: dict[str, list[int]] = defaultdict(list)
for index, row in enumerate(rows):
groups[row["timestamp_raw_hex"]].append(index)
for group_index, (timestamp_raw, indexes) in enumerate(groups.items()):
for ordinal, index in enumerate(indexes):
row = rows[index]
row["timestamp_group_index"] = group_index
row["timestamp_group_ordinal"] = ordinal
row["timestamp_group_size_seen"] = len(indexes)
row["timestamp_group_record_size_seen"] = len(indexes)
row["timestamp_group_boundary"] = (
"oldest" if group_index == len(groups) - 1 else ("newest" if group_index == 0 else "")
)
row["uid"] = make_mystery_box_uid(timestamp_raw, ordinal)
row["uid_status"] = "stable"
row["export_record"] = True
row["skip_reason"] = ""
def build_mystery_box_rows_from_pairs(pairs: list[tuple]) -> list[dict[str, Any]]:
pool = POOL_META["mystery_box"]
rows: list[dict[str, Any]] = []
for pair in pairs:
page, offset, req_i, req_ts, resp_i, resp_ts, response = pair[:7]
records = parse_mystery_box_response(response)
if len(pair) > 9:
slice_start, slice_count = pair[8:10]
records = records[slice_start : slice_start + slice_count]
for row_index, record in enumerate(records, start=1):
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(records),
}
)
annotate_mystery_box_rows(rows)
return rows
def select_continuous_mystery_box_run(
pairs: list[tuple],
) -> tuple[list[tuple], list[dict[str, Any]]]:
return select_continuous_run_from_page_1(pairs)

View file

@ -58,6 +58,7 @@ FIELDNAMES = [
"record_len",
"dice_offset_in_record",
"record_hex",
"record_flag",
]

View file

@ -9,6 +9,7 @@ from nte_history_exporter.constants import (
EXPORTER_NAME,
GAME_NAME,
POOL_META,
MYSTERY_BOX_BANNER_ID,
)
@ -83,6 +84,21 @@ def _record_for_export(row: dict[str, Any]) -> dict[str, Any]:
"source_type": row.get("source_type"),
}
if row.get("pool_group_id") == MYSTERY_BOX_BANNER_ID:
return {
"uid": row.get("uid"),
"pool_group_id": row.get("pool_group_id"),
"timestamp": row.get("timestamp_decoded"),
"timestamp_group_ordinal": row.get("timestamp_group_ordinal"),
"result_type": "single_pull",
"reward_type": row.get("reward_type"),
"reward_id": row.get("reward_id"),
"reward_name": row.get("reward_name"),
"reward_rank": row.get("reward_rank"),
"quantity": row.get("quantity"),
"source_type": row.get("source_type"),
}
return {
"uid": row.get("uid"),
"pool_group_id": row.get("pool_group_id", BANNER_ID),

View file

@ -23,6 +23,7 @@ EXPORT_PREFIXES = {
"permanent": "Permanent",
"limited_character": "Limited",
"arc_miracle_box": "Arc",
"mystery_box": "MysteryBox",
}
CAPTURE_SOURCE_LABELS = {
@ -148,7 +149,7 @@ def run_live_capture(
pairs = session.pairs_for_kind(kind)
best_run, run_warnings = select_continuous_run_from_page_1(pairs)
rows = session.build_rows(kind)
if kind != "arc_miracle_box":
if kind not in {"arc_miracle_box", "mystery_box"}:
rows = annotate_groups(rows)
warnings = run_warnings
pages_seen = [p[0] for p in best_run]

View file

@ -20,7 +20,15 @@ from nte_history_exporter.decoder.protocol import (
response_contains_history_marker,
)
from nte_history_exporter.decoder.run import build_rows_from_pairs
from nte_history_exporter.decoder.mystery_box import (
build_mystery_box_rows_from_pairs,
is_mystery_box_history_request,
mystery_box_request_page,
parse_mystery_box_response,
select_continuous_mystery_box_run,
)
from nte_history_exporter.decoder.structured_protocol import FORK_MARKER, MONOPOLY_MARKER
from nte_history_exporter.constants import MYSTERY_BOX_MARKER
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
from nte_history_exporter.live_capture.diagnostics import CaptureDiagnostics
@ -165,6 +173,30 @@ class LiveHistorySession:
self.last_page_seen = req.page
return False
if packet.src_ip == self.local_ip and is_mystery_box_history_request(packet.payload):
page = mystery_box_request_page(packet.payload)
req = PendingRequest(
page=page,
offset=page * 2,
kind="mystery_box",
request_msg=self.packet_count,
request_time=packet.timestamp,
src_ip=packet.src_ip,
dst_ip=packet.dst_ip,
src_port=packet.src_port,
dst_port=packet.dst_port,
)
self._queue_request(req)
self.diagnostics.counters["history_requests_recognized"] += 1
self.diagnostics.add_event(
"HISTORY_REQUEST_RECOGNIZED",
self.packet_count,
kind=req.kind,
page=req.page,
)
self.last_page_seen = req.page
return False
if packet.dst_ip != self.local_ip:
return False
@ -191,12 +223,24 @@ class LiveHistorySession:
monopoly_records = decode_response_records(packet.payload)
arc_records = parse_arc_response(packet.payload) if not monopoly_records else []
mystery_box_records = (
parse_mystery_box_response(packet.payload)
if not monopoly_records and not arc_records
else []
)
if monopoly_records:
candidates = [req for req in connection_candidates if req.kind != "arc_miracle_box"]
candidates = [
req
for req in connection_candidates
if req.kind not in {"arc_miracle_box", "mystery_box"}
]
records = monopoly_records
elif arc_records:
candidates = [req for req in connection_candidates if req.kind == "arc_miracle_box"]
records = arc_records
elif mystery_box_records:
candidates = [req for req in connection_candidates if req.kind == "mystery_box"]
records = mystery_box_records
else:
candidates = connection_candidates
records = []
@ -215,6 +259,7 @@ class LiveHistorySession:
if response_contains_history_marker(packet.payload)
or MONOPOLY_MARKER in packet.payload
or FORK_MARKER in packet.payload
or MYSTERY_BOX_MARKER in packet.payload
else "NO_HISTORY_MARKER"
)
self._record_rejected_candidate(candidates, packet.payload, reason)
@ -340,6 +385,9 @@ class LiveHistorySession:
if kind == "arc_miracle_box":
best_run, _warnings = select_continuous_arc_run(self.pairs_for_kind(kind))
return build_arc_rows_from_pairs(best_run)
if kind == "mystery_box":
best_run, _warnings = select_continuous_mystery_box_run(self.pairs_for_kind(kind))
return build_mystery_box_rows_from_pairs(best_run)
return build_rows_from_pairs(self.best_run(kind))
def best_run(self, kind: str | None = None) -> list[tuple]:

View file

@ -19,6 +19,8 @@ ASSET_TABLES = {
"capital_inventory": "DataTable/Inventory/DT_CapitalItemConfig.json",
"appearances": "DataTable/Character/Appearance/DT_AppearanceData.json",
"illustrations": "DataTable/Gacha/GachaIllustrate.json",
"mystery_box_pools": "DataTable/GashaponLottery/DT_GashaponLotteryGlobal.json",
"vehicle_inventory": "DataTable/Vehicle/DT_VehicleItemData.json",
"localization": "Localization/en/game.json",
}
REWARD_MAPPING_FILES = ("arcs.json", "characters.json", "items.json")
@ -282,27 +284,60 @@ def _build_item_mapping(
inventory = _casefold_index(tables["inventory"], "inventory")
capital = _casefold_index(tables["capital_inventory"], "capital inventory")
appearances = _casefold_index(tables["appearances"], "appearances")
vehicle_inventory = _casefold_index(tables["vehicle_inventory"], "vehicle inventory")
result: dict[str, dict[str, Any]] = {}
for illustrated_id in tables["illustrations"]:
folded = illustrated_id.casefold()
if illustrated_id.isdigit() or folded.startswith("fork_") or folded.startswith("characterawaken_"):
candidate_ids = _reward_candidate_ids(tables)
for candidate_id in candidate_ids:
folded = candidate_id.casefold()
if candidate_id.isdigit() or folded.startswith("fork_") or folded.startswith("characterawaken_"):
continue
sources = (inventory, capital, appearances)
sources = (inventory, capital, appearances, vehicle_inventory)
match = next((source.get(folded) for source in sources if folded in source), None)
if match is None:
raise MappingUpdateError(f"illustrated reward {illustrated_id} is missing from item tables")
raise MappingUpdateError(f"reward {candidate_id} is missing from item tables")
canonical_id, row = match
if folded.startswith("fashion_glide_") and folded in appearances:
canonical_id = appearances[folded][0]
meta = _normalise_row(canonical_id, row, translations)
result[canonical_id] = {
"type": "cosmetic" if canonical_id.casefold().startswith("fashion_") else "item",
"type": _item_mapping_type(canonical_id),
"name": meta["name"],
"rank": meta["rank"],
}
return dict(sorted(result.items(), key=lambda pair: (pair[0].casefold(), pair[0])))
def _reward_candidate_ids(tables: dict[str, dict[str, Any]]) -> list[str]:
"""Return rewards used by ordinary Gacha and every Mystery Box rotation."""
candidates: dict[str, str] = {
item_id.casefold(): item_id for item_id in tables["illustrations"]
}
for pool_id, pool in tables["mystery_box_pools"].items():
if not isinstance(pool, dict):
raise MappingUpdateError(f"Mystery Box pool {pool_id} must be an object")
gifts = pool.get("GiftList")
if not isinstance(gifts, list):
raise MappingUpdateError(f"Mystery Box pool {pool_id} has no GiftList")
for index, gift in enumerate(gifts):
if not isinstance(gift, dict):
raise MappingUpdateError(
f"Mystery Box pool {pool_id} gift {index} must be an object"
)
item_id = gift.get("ItemID")
if not isinstance(item_id, str) or not item_id:
raise MappingUpdateError(
f"Mystery Box pool {pool_id} gift {index} has no ItemID"
)
candidates.setdefault(item_id.casefold(), item_id)
return sorted(candidates.values(), key=lambda value: (value.casefold(), value))
def _item_mapping_type(item_id: str) -> str:
folded = item_id.casefold()
cosmetic_prefixes = ("fashion_", "frame_", "bussinesscard_")
return "cosmetic" if folded.startswith(cosmetic_prefixes) else "item"
def _casefold_index(rows: dict[str, Any], label: str) -> dict[str, tuple[str, Any]]:
result: dict[str, tuple[str, Any]] = {}
for item_id, row in rows.items():

View file

@ -8,6 +8,7 @@ POOL_MAPPING_FILES = {
"permanent": "permanent_board.json",
"limited_character": "limited_character_board.json",
"arc_miracle_box": "arc_miracle_box.json",
"mystery_box": "mystery_box.json",
}