Merge pull request #1 from Golumpa/dev/0.1.3

Enhance UID handling, improve output visibility, and add release workflow
This commit is contained in:
Golumpa 2026-06-17 14:58:54 +01:00 • committed by GitHub
commit 3eb73f28ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 619 additions and 65 deletions

166
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,166 @@
name: Release
on:
workflow_dispatch:
push:
branches: [main]
permissions:
contents: write
jobs:
build-windows:
name: Build Windows
if: github.ref == 'refs/heads/main'
runs-on: windows-latest
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Get Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[build]"
- name: Test
run: python -m unittest discover -s tests
- name: Build
run: python -m PyInstaller --clean --noconfirm "packaging/NTE History Exporter.spec"
- name: Smoke Test
run: .\dist\nte-history-exporter.exe --help
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: windows
path: dist/nte-history-exporter.exe
build-linux:
name: Build Linux
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Install libpcap
run: sudo apt-get update && sudo apt-get install -y libpcap-dev
- name: Get Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[build]"
- name: Test
run: python -m unittest discover -s tests
- name: Build
run: python -m PyInstaller --clean --noconfirm "packaging/NTE History Exporter.spec"
- name: Smoke Test
run: ./dist/nte-history-exporter --help
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: linux
path: dist/nte-history-exporter
build-macos:
name: Build Macos
if: github.ref == 'refs/heads/main'
runs-on: macos-15-intel
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Install libpcap
run: brew update && brew install libpcap
- name: Get Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[build]"
- name: Test
run: python -m unittest discover -s tests
- name: Build
run: python -m PyInstaller --clean --noconfirm "packaging/NTE History Exporter.spec"
- name: Smoke Test
run: ./dist/nte-history-exporter --help
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: macos
path: dist/nte-history-exporter
release:
name: Release
if: github.ref == 'refs/heads/main'
needs: [build-windows, build-linux, build-macos]
runs-on: ubuntu-latest
steps:
- name: Checkout Source Code
uses: actions/checkout@v4
- name: Get package version
id: package-version
run: |
version="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Download Artifacts
uses: actions/download-artifact@v4
- name: Rename
run: |
version="${{ steps.package-version.outputs.version }}"
cp windows/nte-history-exporter.exe nte-history-exporter.exe
zip nte-history-exporter-v${version}-x86_64-pc-windows-msvc.zip nte-history-exporter.exe
cp linux/nte-history-exporter nte-history-exporter
chmod +x nte-history-exporter
cp nte-history-exporter nte-history-exporter-linux
zip nte-history-exporter-v${version}-x86_64-unknown-linux-gnu.zip nte-history-exporter
cp macos/nte-history-exporter nte-history-exporter
chmod +x nte-history-exporter
cp nte-history-exporter nte-history-exporter-macos
zip nte-history-exporter-v${version}-x86_64-apple-darwin.zip nte-history-exporter
- name: Release
uses: marvinpinto/action-automatic-releases@latest
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
automatic_release_tag: v${{ steps.package-version.outputs.version }}
prerelease: false
title: v${{ steps.package-version.outputs.version }} Release
files: |
nte-history-exporter.exe
nte-history-exporter-linux
nte-history-exporter-macos
nte-history-exporter-v${{ steps.package-version.outputs.version }}-x86_64-pc-windows-msvc.zip
nte-history-exporter-v${{ steps.package-version.outputs.version }}-x86_64-unknown-linux-gnu.zip
nte-history-exporter-v${{ steps.package-version.outputs.version }}-x86_64-apple-darwin.zip

View file

@ -25,7 +25,7 @@ Prototype CLI exporter for **Neverness to Everness** pull history — decodes yo
The exporter decodes Permanent Board, Limited Character Board, and Arc Miracle Box history pages from captured UDP data, applies conservative timestamp-boundary handling, and writes sanitized JSON suitable for tracker import. The exporter decodes Permanent Board, Limited Character Board, and Arc Miracle Box history pages from captured UDP data, applies conservative timestamp-boundary handling, and writes sanitized JSON suitable for tracker import.
> [!NOTE] > [!NOTE]
> The import JSON contains decoded history rows only. It does **not** export tokens, account IDs, role IDs, device IDs, server IPs, raw packets, cookies, session data, or any other capture metadata. > The import JSON contains decoded history rows and the shareable NTE user UID when it can be detected. It does **not** export tokens, account IDs, role IDs, device IDs, server IPs, raw packets, cookies, session data, or other capture metadata.
## Requirements ## Requirements
@ -57,15 +57,17 @@ Use `--capture-backend libpcap` to require Npcap/libpcap without fallback, or `-
Or simply double-click **`run-exporter.cmd`** — it asks for confirmation before requesting Administrator privileges, and does nothing until you agree. Or simply double-click **`run-exporter.cmd`** — it asks for confirmation before requesting Administrator privileges, and does nothing until you agree.
> [!IMPORTANT] > [!IMPORTANT]
> Launch the tool **before pressing Start on the game's main menu** so the game's UDP connection can be captured. If you are already in game, log out to the main menu and enter again. > For automatic user UID detection, launch the tool **before pressing Start on the game's main menu**. If you are already in game, history capture can still work; the tool will ask for your UID before saving if it cannot detect it automatically.
Once running, open any supported history board in game. The tool keeps listening until you press any key. Exports are written under `exports\` as: Once running, open any supported history board in game. The tool keeps listening until you press any key. Exports are written under `exports\` as:
- `Permanent_<date_time>.json` - `<user_uid>_Permanent_<date_time>.json`
- `Limited_<date_time>.json` - `<user_uid>_Limited_<date_time>.json`
- `Arc_<date_time>.json` - `<user_uid>_Arc_<date_time>.json`
If only one banner is captured, the export JSON is copied to your clipboard. If multiple banners are captured in the same run, clipboard copy is skipped so one banner does not overwrite another. If the user UID is not detected automatically, the console asks for it before saving. Leaving it blank saves as `unknown_<banner>_<date_time>.json`, but may prevent import on some trackers.
Exports are not copied to the clipboard by default. Add `--copy-clipboard` to copy a single captured banner's JSON after saving. If multiple banners are captured in the same run, clipboard copy is skipped so one banner does not overwrite another.
If a page response is missed, the exporter reports the missing page number while capture is still running. Leave the exporter open, close and reopen that history board, then scroll down again. Scrolling backward within the existing view does not request the cached pages again. The replacement capture is accepted and the tool confirms when the gap has been recovered. If reopening the board still produces no page messages, return to the main menu and re-enter the game to start a fresh connection. If a page response is missed, the exporter reports the missing page number while capture is still running. Leave the exporter open, close and reopen that history board, then scroll down again. Scrolling backward within the existing view does not request the cached pages again. The replacement capture is accepted and the tool confirms when the gap has been recovered. If reopening the board still produces no page messages, return to the main menu and re-enter the game to start a fresh connection.
@ -96,6 +98,8 @@ Decodes a `mitmproxy .flows` capture instead of listening live — used for rese
| --------- | --------------------------------------------------- | | --------- | --------------------------------------------------- |
| `--live` | Capture live UDP traffic instead of reading a file. | | `--live` | Capture live UDP traffic instead of reading a file. |
| `--debug` | Also write the full research CSV next to each JSON. | | `--debug` | Also write the full research CSV next to each JSON. |
| `--user-uid <uid>` | Override the auto-detected NTE user UID in the JSON export. |
| `--copy-clipboard` | Copy a single live export JSON to clipboard after saving. |
Advanced live-capture selection: Advanced live-capture selection:
@ -107,13 +111,15 @@ Advanced live-capture selection:
The `--debug` CSV holds any extra information that might be needed for fixing bugs. It contains no dangerous personal account data — only the raw bytes of the captured history page. The `--debug` CSV holds any extra information that might be needed for fixing bugs. It contains no dangerous personal account data — only the raw bytes of the captured history page.
The exporter automatically includes the shareable NTE user UID when it appears in the capture. If a short capture does not include it, the console asks before saving; you can also pass it explicitly with `--user-uid`.
> [!TIP] > [!TIP]
> For reliable deduplication, start from page 1 and scroll through the pages. If you only want pages 1–5, scroll through to page 6 as well just to be on the safe side. > For reliable deduplication, start from page 1 and scroll through the pages. If you only want pages 1–5, scroll through to page 6 as well just to be on the safe side.
## Privacy ## Privacy
> [!CAUTION] > [!CAUTION]
> Do not commit packet captures, generated exports, research briefs, or personal account data. The repository keeps `exports/` as an empty output folder but ignores everything generated inside it. > Sanitized exports are intended for tracker import and should not contain anything especially harmful, but they can identify the game account via user UID and pull history. Share exports only with verified sources, such as known trackers. Do not commit packet captures, generated exports, research briefs, or personal account data. The repository keeps `exports/` as an empty output folder but ignores everything generated inside it.
## Boundary Policy ## Boundary Policy

View file

@ -8,9 +8,10 @@ The sanitized JSON export uses:
"format_version": 1, "format_version": 1,
"game": "Neverness to Everness", "game": "Neverness to Everness",
"source": "packet_capture", "source": "packet_capture",
"capture_source": "npcap",
"exporter": { "exporter": {
"name": "nte-history-exporter", "name": "nte-history-exporter",
"version": "0.1.1" "version": "0.1.3"
}, },
"banner": { "banner": {
"id": "Lottery_Permanent", "id": "Lottery_Permanent",
@ -26,6 +27,7 @@ The sanitized JSON export uses:
"skipped_records": 0, "skipped_records": 0,
"warnings": [] "warnings": []
}, },
"user_uid": "optional-user-uid",
"records": [] "records": []
} }
``` ```
@ -85,3 +87,6 @@ Example Arc record:
``` ```
Normal JSON exports do not include raw packets or capture-only metadata. Normal JSON exports do not include raw packets or capture-only metadata.
`user_uid` is included when detected automatically or supplied with `--user-uid`.
`capture_source` records the capture backend/source used for the export, such as
`npcap`, `libpcap`, `windows_packet`, or `mitmproxy_flows`.

45
docs/packaging.md Normal file
View file

@ -0,0 +1,45 @@
# Packaging
## Windows local build
Build the Windows console executable with:
```powershell
.\tools\build-windows.ps1
```
The output is:
```text
dist\nte-history-exporter.exe
```
Run live capture from an elevated PowerShell prompt:
```powershell
.\dist\nte-history-exporter.exe --live
```
## Packet capture runtime
Windows live capture defaults to `--capture-backend auto`, which tries an installed system Npcap runtime first, then the built-in raw-socket backend. If `--capture-backend libpcap` is specified, raw fallback is disabled. If `--capture-backend raw` is specified, only the Windows raw-socket backend is used.
Npcap is Windows-only; Linux and macOS use the system libpcap runtime.
The GitHub Actions release does not download or bundle Npcap. The Windows executable can use a user's installed Npcap runtime when present, then falls back to raw sockets. A future pktmon backend can provide the same kind of no-extra-install Windows path that stardb-exporter uses.
Npcap is not committed or bundled by default. Windows users who want the Npcap backend should install Npcap separately. Users who do not install Npcap can still use the Windows raw-socket fallback.
## Cross-platform CI shape
Use one GitHub Actions job per OS. PyInstaller must build on the target platform, so Windows produces `.exe`, macOS produces a macOS binary, and Linux produces a Linux binary.
This repository has a manual release workflow at:
```text
.github\workflows\release.yml
```
It builds Windows, Linux, and macOS artifacts, then publishes a `v<version>` GitHub release with raw binaries and versioned zip files.
Linux runners install the libpcap runtime for parity with live-capture usage, but the build itself uses `ctypes` and does not link against libpcap at package time.

View file

@ -0,0 +1,47 @@
# -*- mode: python ; coding: utf-8 -*-
from pathlib import Path
block_cipher = None
root = Path(SPECPATH).parent
a = Analysis(
[str(root / "src" / "nte_history_exporter" / "cli.py")],
pathex=[str(root / "src")],
binaries=[],
datas=[(str(root / "mappings"), "mappings")],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.zipfiles,
a.datas,
[],
name="nte-history-exporter",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View file

@ -1,10 +1,13 @@
[project] [project]
name = "nte-history-exporter" name = "nte-history-exporter"
version = "0.1.2" version = "0.1.3"
description = "Cross-platform Neverness to Everness pull-history exporter." description = "Cross-platform Neverness to Everness pull-history exporter."
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [] dependencies = []
[project.optional-dependencies]
build = ["pyinstaller>=6.0"]
[project.scripts] [project.scripts]
nte-history-exporter = "nte_history_exporter.cli:main" nte-history-exporter = "nte_history_exporter.cli:main"

View file

@ -1 +1 @@
__version__ = "0.1.2" __version__ = "0.1.3"

View file

@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
from collections import Counter
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from nte_history_exporter.decoder.boundary import select_continuous_run_from_page_1 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.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.run import build_rows_from_pairs
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
from nte_history_exporter.live_capture.session import LiveHistorySession, UdpPacket from nte_history_exporter.live_capture.session import LiveHistorySession, UdpPacket
@ -77,6 +79,12 @@ def find_udp_flow(flows: list[Any], preferred_index: int | None = None) -> tuple
def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> dict[str, Any]: def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> dict[str, Any]:
flows = read_flows(path) flows = read_flows(path)
user_uid_candidates: Counter[str] = Counter()
for flow in flows:
for msg in flow.get(b"messages", []):
user_uid_candidates.update(extract_user_uid_candidates(msg[1]))
user_uid = user_uid_candidates.most_common(1)[0][0] if user_uid_candidates else None
resolved_flow_index, flow = find_udp_flow(flows, flow_index) resolved_flow_index, flow = find_udp_flow(flows, flow_index)
messages = flow[b"messages"] messages = flow[b"messages"]
@ -110,4 +118,5 @@ def decode_mitmproxy_flows(path: str | Path, flow_index: int | None = None) -> d
"best_arc_run": best_arc_run, "best_arc_run": best_arc_run,
"arc_rows": arc_rows, "arc_rows": arc_rows,
"arc_warnings": arc_warnings, "arc_warnings": arc_warnings,
"user_uid": session.user_uid or user_uid,
} }

View file

@ -24,10 +24,14 @@ def build_parser() -> argparse.ArgumentParser:
"--capture-backend", "--capture-backend",
choices=["auto", "libpcap", "raw"], choices=["auto", "libpcap", "raw"],
default="auto", default="auto",
help="live capture backend; auto prefers Npcap/libpcap and falls back to raw sockets on Windows", help=(
"live capture backend; auto prefers Npcap/libpcap and falls back to raw sockets on Windows "
"(default: %(default)s)"
),
) )
parser.add_argument("--no-clipboard", action="store_true", help="do not copy live exports to clipboard") parser.add_argument("--copy-clipboard", action="store_true", help="copy a single live export to clipboard")
parser.add_argument("--debug", action="store_true", help="also write research CSVs next to the JSON exports") parser.add_argument("--debug", action="store_true", help="also write research CSVs next to the JSON exports")
parser.add_argument("--user-uid", default=None, help="override the auto-detected NTE user UID in the JSON export")
return parser return parser
@ -39,8 +43,9 @@ def main(argv: list[str] | None = None) -> int:
run_live_capture( run_live_capture(
interface_ip=args.interface_ip, interface_ip=args.interface_ip,
capture_backend=args.capture_backend, capture_backend=args.capture_backend,
copy_clipboard=not args.no_clipboard, copy_clipboard=args.copy_clipboard,
write_debug_csv=args.debug, write_debug_csv=args.debug,
user_uid=args.user_uid,
) )
return 0 return 0
except (LibpcapUnavailable, PermissionError) as exc: except (LibpcapUnavailable, PermissionError) as exc:
@ -62,13 +67,19 @@ def main(argv: list[str] | None = None) -> int:
best_run = decoded["best_run"] best_run = decoded["best_run"]
pair_count = len(decoded["pairs"]) pair_count = len(decoded["pairs"])
out_path, json_path = export_paths(kind) resolved_user_uid = args.user_uid or decoded.get("user_uid")
if not resolved_user_uid:
resolved_user_uid = console.prompt_user_uid()
out_path, json_path = export_paths(kind, resolved_user_uid)
if args.debug: if args.debug:
write_csv(out_path, rows) write_csv(out_path, rows)
export = build_export_json( export = build_export_json(
rows, rows,
warnings, warnings,
source="packet_capture", source="packet_capture",
capture_source="mitmproxy_flows",
user_uid=resolved_user_uid,
flow_index=decoded["flow_index"], flow_index=decoded["flow_index"],
candidate_request_response_pairs=pair_count, candidate_request_response_pairs=pair_count,
pages_seen=[p[0] for p in best_run], pages_seen=[p[0] for p in best_run],

View file

@ -13,6 +13,7 @@ DIM = "\x1b[2m"
CYAN = "\x1b[36m" CYAN = "\x1b[36m"
GREEN = "\x1b[32m" GREEN = "\x1b[32m"
YELLOW = "\x1b[33m" YELLOW = "\x1b[33m"
BRIGHT_WHITE = "\x1b[97m"
_ansi: bool | None = None _ansi: bool | None = None
@ -68,10 +69,10 @@ def print_live_instructions(local_ip: str, backend: str = "windows_raw", detail:
print(style(f" Capture backend: {backend}{backend_detail}", DIM)) print(style(f" Capture backend: {backend}{backend_detail}", DIM))
print() print()
print(style(" How to export your pull history", BOLD)) print(style(" How to export your pull history", BOLD))
print(" 1. This tool must be running BEFORE you press Start on") print(" 1. For automatic user UID detection, start this tool")
print(" the game's main menu, or the game connection cannot") print(" before pressing Start on the game's main menu.")
print(" be captured. Already in game? Log out to the main") print(" Already in game? You can still capture history;")
print(" menu and enter again.") print(" the tool will ask for your UID if it cannot detect it.")
print(" 2. Open a supported history screen:") print(" 2. Open a supported history screen:")
print(style(" Monopoly > Standard Board history", CYAN)) print(style(" Monopoly > Standard Board history", CYAN))
print(style(" Monopoly > Limited Character Board history", CYAN)) print(style(" Monopoly > Limited Character Board history", CYAN))
@ -91,17 +92,20 @@ def print_page_captured(label: str, page: int | None, *, recaptured: bool = Fals
print(style(" + ", GREEN, BOLD) + label + style(f" {action} {page}", DIM)) print(style(" + ", GREEN, BOLD) + label + style(f" {action} {page}", DIM))
def print_missing_pages(label: str, pages: list[int], reasons: dict[int, str] | None = None) -> None: def print_missing_pages(label: str, pages: list[int]) -> None:
page_list = ", ".join(str(page) for page in pages) page_list = ", ".join(str(page) for page in pages)
print(style(" ! ", YELLOW, BOLD) + f"{label} missing page(s): {page_list}.") print(style(" ! ", YELLOW, BOLD) + style(f"{label} missing page(s): {page_list}.", YELLOW, BOLD))
if reasons: print(
for page in pages: style(
print(style(f" Page {page}: {reasons[page]}.", DIM)) " Close and reopen this history board, then scroll down until page gap recovered is shown.",
print(" Close and reopen this history board, then scroll down again.") BRIGHT_WHITE,
BOLD,
)
)
def print_page_gap_recovered(label: str) -> None: def print_page_gap_recovered(label: str) -> None:
print(style(" + ", GREEN, BOLD) + f"{label} page gap recovered.") print(style(" + ", GREEN, BOLD) + style(f"{label} page gap recovered.", GREEN, BOLD))
def print_capture_stats(received: int, dropped: int, interface_dropped: int) -> None: def print_capture_stats(received: int, dropped: int, interface_dropped: int) -> None:
@ -151,3 +155,12 @@ def print_success(text: str) -> None:
def print_problem(text: str) -> None: def print_problem(text: str) -> None:
print(style(f" {text}", YELLOW, BOLD)) print(style(f" {text}", YELLOW, BOLD))
def prompt_user_uid() -> str | None:
print()
print_problem("User UID was not detected in this capture.")
print_note("Enter your NTE user UID so the export can be named and linked correctly.")
print_note("Leaving this blank may prevent import on some trackers.")
value = input(" User UID: ").strip()
return value or None

View file

@ -11,7 +11,7 @@ LIMITED_CHARACTER_BANNER_NAME = "Limited Character Board"
ARC_BANNER_ID = "Arc_MiracleBox" ARC_BANNER_ID = "Arc_MiracleBox"
ARC_BANNER_NAME = "Arc Miracle Box" ARC_BANNER_NAME = "Arc Miracle Box"
EXPORTER_NAME = "nte-history-exporter" EXPORTER_NAME = "nte-history-exporter"
EXPORTER_VERSION = "0.1.2" EXPORTER_VERSION = "0.1.3"
HISTORY_REQUEST_BANNER = 4220 HISTORY_REQUEST_BANNER = 4220
HISTORY_REQUEST_LENGTH = 45 HISTORY_REQUEST_LENGTH = 45

View file

@ -0,0 +1,46 @@
from __future__ import annotations
import struct
from collections import Counter
MIN_USER_UID = 100_000_000_000
MAX_USER_UID = 999_999_999_999
USER_UID_RECORD_OFFSETS = (
(b"TagOthers", (16,)),
(b"PrivateSpawnInfoRecord", (40,)),
(b"SimpleQuestRecord", (20, 52)),
(b"FurnitureLayoutDataRec", (56, 64)),
(b"StoreBrandItemSalesVolumeRecord", (16,)),
(b"StorePropertyRecord", (20,)),
)
def _plausible_user_uid(value: int) -> bool:
return MIN_USER_UID <= value <= MAX_USER_UID
def extract_user_uid_candidates(payload: bytes) -> list[str]:
results = []
for marker, distances in USER_UID_RECORD_OFFSETS:
search_from = 0
while True:
marker_pos = payload.find(marker, search_from)
if marker_pos == -1:
break
for distance in distances:
offset = marker_pos - distance
if offset < 0:
continue
value = struct.unpack_from("<Q", payload, offset)[0]
if _plausible_user_uid(value):
results.append(str(value))
search_from = marker_pos + len(marker)
return results
def extract_user_uid(payload: bytes) -> str | None:
candidates = extract_user_uid_candidates(payload)
if not candidates:
return None
return Counter(candidates).most_common(1)[0][0]

View file

@ -17,6 +17,8 @@ def build_export_json(
warnings: list[dict[str, Any]], warnings: list[dict[str, Any]],
*, *,
source: str = "packet_capture", source: str = "packet_capture",
capture_source: str | None = None,
user_uid: str | None = None,
flow_index: int | None = None, flow_index: int | None = None,
candidate_request_response_pairs: int | None = None, candidate_request_response_pairs: int | None = None,
pages_seen: list[int] | None = None, pages_seen: list[int] | None = None,
@ -40,12 +42,18 @@ def build_export_json(
if pages_seen is not None: if pages_seen is not None:
scan["pages_seen"] = pages_seen scan["pages_seen"] = pages_seen
return { normalized_user_uid = user_uid.strip() if user_uid else ""
export: dict[str, Any] = {
"format": "nte-history-export", "format": "nte-history-export",
"format_version": 1, "format_version": 1,
"game": GAME_NAME, "game": GAME_NAME,
"source": source, "source": source,
"exporter": {"name": EXPORTER_NAME, "version": __version__}, }
if capture_source:
export["capture_source"] = capture_source
export["exporter"] = {"name": EXPORTER_NAME, "version": __version__}
export.update(
{
"banner": { "banner": {
"id": pool["id"], "id": pool["id"],
"name": pool["name"], "name": pool["name"],
@ -53,8 +61,12 @@ def build_export_json(
"shared_pity": pool["shared_pity"], "shared_pity": pool["shared_pity"],
}, },
"scan": scan, "scan": scan,
"records": [_record_for_export(r) for r in exported],
} }
)
if normalized_user_uid:
export["user_uid"] = normalized_user_uid
export["records"] = [_record_for_export(r) for r in exported]
return export
def _record_for_export(row: dict[str, Any]) -> dict[str, Any]: def _record_for_export(row: dict[str, Any]) -> dict[str, Any]:

View file

@ -10,7 +10,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Iterator from typing import Iterator
from nte_history_exporter.live_capture.windows_raw import ParsedIpUdpPacket, parse_ipv4_udp_packet from nte_history_exporter.live_capture.windows_raw import ParsedIpUdpPacket, parse_ipv4_packet
PCAP_ERRBUF_SIZE = 256 PCAP_ERRBUF_SIZE = 256
SNAP_LENGTH = 65535 SNAP_LENGTH = 65535
@ -103,7 +103,8 @@ def _load_library() -> ctypes.CDLL:
npcap_dir = _windows_npcap_directory() npcap_dir = _windows_npcap_directory()
if npcap_dir.is_dir() and hasattr(os, "add_dll_directory"): if npcap_dir.is_dir() and hasattr(os, "add_dll_directory"):
dll_directory = os.add_dll_directory(str(npcap_dir)) dll_directory = os.add_dll_directory(str(npcap_dir))
candidates.extend([str(npcap_dir / "wpcap.dll"), "wpcap.dll"]) candidates.append(str(npcap_dir / "wpcap.dll"))
candidates.append("wpcap.dll")
elif sys.platform == "darwin": elif sys.platform == "darwin":
candidates.extend( candidates.extend(
[ [
@ -351,7 +352,7 @@ class LibpcapCapture:
raise LibpcapUnavailable(_pcap_error(self.lib, self.handle)) raise LibpcapUnavailable(_pcap_error(self.lib, self.handle))
program = _BpfProgram() program = _BpfProgram()
filter_expression = f"udp and host {local_ip}".encode("ascii") filter_expression = f"host {local_ip} and (udp or tcp)".encode("ascii")
if self.lib.pcap_compile(self.handle, ctypes.byref(program), filter_expression, 1, 0xFFFFFFFF) != 0: if self.lib.pcap_compile(self.handle, ctypes.byref(program), filter_expression, 1, 0xFFFFFFFF) != 0:
raise LibpcapUnavailable(_pcap_error(self.lib, self.handle)) raise LibpcapUnavailable(_pcap_error(self.lib, self.handle))
try: try:
@ -382,7 +383,7 @@ class LibpcapCapture:
ipv4_packet = _extract_ipv4_frame(frame, self.datalink) ipv4_packet = _extract_ipv4_frame(frame, self.datalink)
if ipv4_packet is None: if ipv4_packet is None:
continue continue
packet = parse_ipv4_udp_packet(ipv4_packet) packet = parse_ipv4_packet(ipv4_packet)
if packet is not None: if packet is not None:
yield packet yield packet

View file

@ -24,6 +24,10 @@ EXPORT_PREFIXES = {
"arc_miracle_box": "Arc", "arc_miracle_box": "Arc",
} }
CAPTURE_SOURCE_LABELS = {
"windows_raw": "windows_packet",
}
def copy_to_clipboard(text: str) -> bool: def copy_to_clipboard(text: str) -> bool:
try: try:
@ -60,8 +64,9 @@ def run_live_capture(
*, *,
interface_ip: str | None = None, interface_ip: str | None = None,
capture_backend: str = "auto", capture_backend: str = "auto",
copy_clipboard: bool = True, copy_clipboard: bool = False,
write_debug_csv: bool = False, write_debug_csv: bool = False,
user_uid: str | None = None,
) -> dict: ) -> dict:
local_ip = interface_ip or detect_local_ipv4() local_ip = interface_ip or detect_local_ipv4()
session = LiveHistorySession(local_ip) session = LiveHistorySession(local_ip)
@ -71,6 +76,7 @@ def run_live_capture(
if capture.fallback_reason: if capture.fallback_reason:
console.print_capture_fallback(capture.fallback_reason) console.print_capture_fallback(capture.fallback_reason)
reported_missing_pages: dict[str, tuple[int, ...]] = {} reported_missing_pages: dict[str, tuple[int, ...]] = {}
active_gap_notices: set[str] = set()
try: try:
with StopKeyMonitor() as stop_key: with StopKeyMonitor() as stop_key:
@ -88,6 +94,7 @@ def run_live_capture(
src_port=packet.src_port, src_port=packet.src_port,
dst_port=packet.dst_port, dst_port=packet.dst_port,
payload=packet.payload, payload=packet.payload,
protocol=packet.protocol,
) )
) )
if matched: if matched:
@ -109,14 +116,12 @@ def run_live_capture(
label = POOL_META.get(kind, POOL_META["permanent"])["name"] label = POOL_META.get(kind, POOL_META["permanent"])["name"]
missing_pages = tuple(session.missing_pages(kind)) missing_pages = tuple(session.missing_pages(kind))
previously_missing = reported_missing_pages.get(kind, ()) previously_missing = reported_missing_pages.get(kind, ())
if missing_pages and missing_pages != previously_missing: if missing_pages and kind not in active_gap_notices:
reasons = { console.print_missing_pages(label, list(missing_pages))
page: session.missing_page_reason(kind, page) active_gap_notices.add(kind)
for page in missing_pages
}
console.print_missing_pages(label, list(missing_pages), reasons)
elif previously_missing and not missing_pages: elif previously_missing and not missing_pages:
console.print_page_gap_recovered(label) console.print_page_gap_recovered(label)
active_gap_notices.discard(kind)
reported_missing_pages[kind] = missing_pages reported_missing_pages[kind] = missing_pages
finally: finally:
stats = capture.stats() stats = capture.stats()
@ -130,6 +135,10 @@ def run_live_capture(
) )
exports = [] exports = []
resolved_user_uid = user_uid or session.user_uid
if session.kinds_seen() and not resolved_user_uid:
resolved_user_uid = console.prompt_user_uid()
capture_source = CAPTURE_SOURCE_LABELS.get(capture.name, capture.name)
for kind in session.kinds_seen(): for kind in session.kinds_seen():
pairs = session.pairs_for_kind(kind) pairs = session.pairs_for_kind(kind)
best_run, run_warnings = select_continuous_run_from_page_1(pairs) best_run, run_warnings = select_continuous_run_from_page_1(pairs)
@ -138,13 +147,15 @@ def run_live_capture(
rows = annotate_groups(rows) rows = annotate_groups(rows)
warnings = run_warnings warnings = run_warnings
pages_seen = [p[0] for p in best_run] pages_seen = [p[0] for p in best_run]
csv_path, json_path = export_paths(kind) csv_path, json_path = export_paths(kind, resolved_user_uid)
if write_debug_csv: if write_debug_csv:
write_csv(csv_path, rows) write_csv(csv_path, rows)
export = build_export_json( export = build_export_json(
rows, rows,
warnings, warnings,
source="live_capture", source="live_capture",
capture_source=capture_source,
user_uid=resolved_user_uid,
pages_seen=pages_seen, pages_seen=pages_seen,
) )
payload = json.dumps(export, ensure_ascii=False, indent=2) payload = json.dumps(export, ensure_ascii=False, indent=2)
@ -162,9 +173,9 @@ def run_live_capture(
console.print_results_header() console.print_results_header()
if not exports: if not exports:
console.print_problem("No history pages were captured.") console.print_problem("No history pages were captured.")
console.print_note("This tool must already be running when you press Start on the") console.print_note("Make sure the capture backend is running, then reopen the")
console.print_note("game's main menu. Log out to the main menu, enter the game") console.print_note("history screen and scroll from page 1. If no page messages")
console.print_note("again, then reopen the history screen.") console.print_note("appear, return to the main menu and re-enter the game.")
return {"exports": []} return {"exports": []}
for item in exports: for item in exports:
@ -189,24 +200,33 @@ def run_live_capture(
console.print_success("Export copied to clipboard - paste it straight into your tracker.") console.print_success("Export copied to clipboard - paste it straight into your tracker.")
else: else:
console.print_note("Clipboard tool unavailable; use the JSON file shown above.") console.print_note("Clipboard tool unavailable; use the JSON file shown above.")
elif len(exports) > 1: elif copy_clipboard and len(exports) > 1:
console.print_note("Multiple banners captured; clipboard copy skipped so one export") console.print_note("Multiple banners captured; clipboard copy skipped so one export")
console.print_note("does not overwrite another.") console.print_note("does not overwrite another.")
return {"exports": exports} return {"exports": exports}
def export_paths(kind: str) -> tuple[Path, Path]: def export_paths(kind: str, user_uid: str | None = None) -> tuple[Path, Path]:
export_dir = Path("exports") export_dir = Path("exports")
export_dir.mkdir(parents=True, exist_ok=True) export_dir.mkdir(parents=True, exist_ok=True)
prefix = EXPORT_PREFIXES.get(kind, "History") prefix = EXPORT_PREFIXES.get(kind, "History")
stamp = datetime.now().strftime("%Y%m%d_%H%M%S") stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base = export_dir / f"{prefix}_{stamp}" uid_prefix = _safe_filename_part(user_uid) if user_uid else "unknown"
base_name = f"{uid_prefix}_{prefix}_{stamp}"
base = export_dir / base_name
csv_path = base.with_suffix(".csv") csv_path = base.with_suffix(".csv")
json_path = base.with_suffix(".json") json_path = base.with_suffix(".json")
counter = 2 counter = 2
while csv_path.exists() or json_path.exists(): while csv_path.exists() or json_path.exists():
base = export_dir / f"{prefix}_{stamp}_{counter}" base = export_dir / f"{base_name}_{counter}"
csv_path = base.with_suffix(".csv") csv_path = base.with_suffix(".csv")
json_path = base.with_suffix(".json") json_path = base.with_suffix(".json")
counter += 1 counter += 1
return csv_path, json_path return csv_path, json_path
def _safe_filename_part(value: str | None) -> str:
if not value:
return "unknown"
cleaned = "".join(ch for ch in value.strip() if ch.isalnum() or ch in ("-", "_"))
return cleaned or "unknown"

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections import deque from collections import Counter, deque
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
@ -19,6 +19,7 @@ from nte_history_exporter.decoder.protocol import (
request_page, request_page,
) )
from nte_history_exporter.decoder.run import build_rows_from_pairs from nte_history_exporter.decoder.run import build_rows_from_pairs
from nte_history_exporter.decoder.user_uid import extract_user_uid_candidates
@dataclass @dataclass
@ -29,6 +30,7 @@ class UdpPacket:
src_port: int src_port: int
dst_port: int dst_port: int
payload: bytes payload: bytes
protocol: str = "udp"
@dataclass @dataclass
@ -57,6 +59,8 @@ class LiveHistorySession:
self.last_capture_was_replacement = False self.last_capture_was_replacement = False
self.requested_pages: dict[str, set[int]] = {} self.requested_pages: dict[str, set[int]] = {}
self.unanswered_pages: dict[str, dict[int, str]] = {} self.unanswered_pages: dict[str, dict[int, str]] = {}
self.user_uid: str | None = None
self.user_uid_candidates: Counter[str] = Counter()
def _mark_unanswered(self, request: PendingRequest) -> None: def _mark_unanswered(self, request: PendingRequest) -> None:
if request.response_candidates: if request.response_candidates:
@ -93,6 +97,12 @@ class LiveHistorySession:
def process_packet(self, packet: UdpPacket) -> bool: def process_packet(self, packet: UdpPacket) -> bool:
self.packet_count += 1 self.packet_count += 1
candidates = extract_user_uid_candidates(packet.payload)
if candidates:
self.user_uid_candidates.update(candidates)
self.user_uid = self.user_uid_candidates.most_common(1)[0][0]
if packet.protocol != "udp":
return False
if packet.src_ip == self.local_ip and is_history_request(packet.payload): if packet.src_ip == self.local_ip and is_history_request(packet.payload):
offset = int.from_bytes(packet.payload[31:35], "little") offset = int.from_bytes(packet.payload[31:35], "little")

View file

@ -10,12 +10,16 @@ RECEIVE_BUFFER_SIZE = 4 * 1024 * 1024
@dataclass @dataclass
class ParsedIpUdpPacket: class ParsedIpPacket:
src_ip: str src_ip: str
dst_ip: str dst_ip: str
src_port: int src_port: int
dst_port: int dst_port: int
payload: bytes payload: bytes
protocol: str = "udp"
ParsedIpUdpPacket = ParsedIpPacket
def detect_local_ipv4() -> str: def detect_local_ipv4() -> str:
@ -42,24 +46,41 @@ def detect_local_ipv4() -> str:
return candidates[0] return candidates[0]
def parse_ipv4_udp_packet(data: bytes) -> ParsedIpUdpPacket | None: def parse_ipv4_packet(data: bytes) -> ParsedIpPacket | None:
if len(data) < 28: if len(data) < 28:
return None return None
version_ihl = data[0] version_ihl = data[0]
if version_ihl >> 4 != 4: if version_ihl >> 4 != 4:
return None return None
ihl = (version_ihl & 0x0F) * 4 ihl = (version_ihl & 0x0F) * 4
total_length = struct.unpack_from("!H", data, 2)[0]
if total_length > 0:
data = data[:total_length]
if len(data) < ihl + 8: if len(data) < ihl + 8:
return None return None
protocol = data[9] protocol = data[9]
if protocol != 17:
return None
src_ip = socket.inet_ntoa(data[12:16]) src_ip = socket.inet_ntoa(data[12:16])
dst_ip = socket.inet_ntoa(data[16:20]) dst_ip = socket.inet_ntoa(data[16:20])
if protocol == 17:
src_port, dst_port, udp_len, _checksum = struct.unpack_from("!HHHH", data, ihl) src_port, dst_port, udp_len, _checksum = struct.unpack_from("!HHHH", data, ihl)
payload = data[ihl + 8 : ihl + udp_len] payload = data[ihl + 8 : ihl + udp_len]
return ParsedIpUdpPacket(src_ip, dst_ip, src_port, dst_port, payload) return ParsedIpPacket(src_ip, dst_ip, src_port, dst_port, payload, "udp")
if protocol == 6:
if len(data) < ihl + 20:
return None
src_port, dst_port = struct.unpack_from("!HH", data, ihl)
tcp_header_len = (data[ihl + 12] >> 4) * 4
if tcp_header_len < 20 or len(data) < ihl + tcp_header_len:
return None
payload = data[ihl + tcp_header_len :]
return ParsedIpPacket(src_ip, dst_ip, src_port, dst_port, payload, "tcp")
return None
def parse_ipv4_udp_packet(data: bytes) -> ParsedIpPacket | None:
packet = parse_ipv4_packet(data)
return packet if packet and packet.protocol == "udp" else None
def open_raw_udp_socket(local_ip: str) -> socket.socket: def open_raw_udp_socket(local_ip: str) -> socket.socket:
@ -79,7 +100,7 @@ def read_packets(sock: socket.socket):
except socket.timeout: except socket.timeout:
yield None yield None
continue continue
packet = parse_ipv4_udp_packet(data) packet = parse_ipv4_packet(data)
if packet is None: if packet is None:
continue continue
yield packet yield packet

View file

@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import json import json
import sys
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[2]))
MAPPINGS_DIR = PROJECT_ROOT / "mappings" MAPPINGS_DIR = PROJECT_ROOT / "mappings"

View file

@ -20,6 +20,7 @@ from nte_history_exporter.decoder.protocol import decode_response_records, histo
from nte_history_exporter.constants import POOL_META from nte_history_exporter.constants import POOL_META
from nte_history_exporter.mappings import ARC_META, CHARACTERS, ITEMS, REWARDS_BY_ID from nte_history_exporter.mappings import ARC_META, CHARACTERS, ITEMS, REWARDS_BY_ID
from nte_history_exporter.decoder.protocol import decode_reward_key, infer_reward_type from nte_history_exporter.decoder.protocol import decode_reward_key, infer_reward_type
from nte_history_exporter.decoder.user_uid import extract_user_uid
from nte_history_exporter.decoder.arc import ( from nte_history_exporter.decoder.arc import (
arc_request_page, arc_request_page,
build_arc_rows_from_pairs, build_arc_rows_from_pairs,
@ -37,10 +38,13 @@ from nte_history_exporter.live_capture.libpcap import (
DLT_LOOP, DLT_LOOP,
DLT_RAW, DLT_RAW,
_extract_ipv4_frame, _extract_ipv4_frame,
_load_library,
LibpcapUnavailable, LibpcapUnavailable,
) )
from nte_history_exporter.live_capture.windows_raw import parse_ipv4_packet
from nte_history_exporter.live_capture.backends import open_capture_backend from nte_history_exporter.live_capture.backends import open_capture_backend
from nte_history_exporter.export.json_export import build_export_json from nte_history_exporter.export.json_export import build_export_json
from nte_history_exporter.live_capture.runner import export_paths
from nte_history_exporter.pool_mappings import load_pool_mappings, pool_meta_from_mapping from nte_history_exporter.pool_mappings import load_pool_mappings, pool_meta_from_mapping
@ -135,6 +139,29 @@ class BoundaryExportTests(unittest.TestCase):
raw_capture.assert_not_called() raw_capture.assert_not_called()
def test_windows_libpcap_load_prefers_system_then_path(self):
system_dir = Path("C:/Windows/System32/Npcap")
loaded = Mock()
with (
patch("nte_history_exporter.live_capture.libpcap.sys.platform", "win32"),
patch("nte_history_exporter.live_capture.libpcap._windows_npcap_directory", return_value=system_dir),
patch("nte_history_exporter.live_capture.libpcap.Path.is_dir", return_value=True),
patch("nte_history_exporter.live_capture.libpcap.os.add_dll_directory", return_value=Mock()),
patch(
"nte_history_exporter.live_capture.libpcap.ctypes.CDLL",
side_effect=[OSError("system missing"), loaded],
) as cdll,
):
self.assertIs(_load_library(), loaded)
self.assertEqual(
[call.args[0] for call in cdll.call_args_list],
[
str(system_dir / "wpcap.dll"),
"wpcap.dll",
],
)
def test_libpcap_link_layers_extract_ipv4_packets(self): def test_libpcap_link_layers_extract_ipv4_packets(self):
ip_packet = bytes.fromhex("4500001c0000000040110000c0000201c6336402") + bytes(8) ip_packet = bytes.fromhex("4500001c0000000040110000c0000201c6336402") + bytes(8)
ethernet = bytes(12) + bytes.fromhex("0800") + ip_packet ethernet = bytes(12) + bytes.fromhex("0800") + ip_packet
@ -347,10 +374,94 @@ class BoundaryExportTests(unittest.TestCase):
self.assertEqual(export["format"], "nte-history-export") self.assertEqual(export["format"], "nte-history-export")
self.assertIn("exporter", export) self.assertIn("exporter", export)
self.assertNotIn("user_uid", export)
self.assertNotIn("record_hex", export["records"][0]) self.assertNotIn("record_hex", export["records"][0])
self.assertNotIn("request_msg", export["records"][0]) self.assertNotIn("request_msg", export["records"][0])
self.assertNotIn("response_msg", export["records"][0]) self.assertNotIn("response_msg", export["records"][0])
def test_export_includes_user_uid_when_provided(self):
rows = load_reference_csv("monopoly_history_poc_13_pages_1_to_5_v4.csv")
annotated = annotate_groups(rows)
export = build_export_json(
annotated,
[],
capture_source="npcap",
user_uid="123456789",
)
self.assertEqual(list(export).index("user_uid"), list(export).index("records") - 1)
self.assertEqual(export["capture_source"], "npcap")
self.assertEqual(export["user_uid"], "123456789")
def test_export_paths_include_user_uid_banner_and_timestamp(self):
_csv_path, json_path = export_paths("limited_character", "218216016349")
self.assertRegex(
json_path.name,
r"^218216016349_Limited_\d{8}_\d{6}(?:_\d+)?\.json$",
)
def test_extracts_user_uid_from_record_context(self):
payload = (
b"\x00" * 24
+ (218216016349).to_bytes(8, "little")
+ b"\x00\x00\x00\x00\x09\x00\x00\x00TagOthers\x00"
)
self.assertEqual(extract_user_uid(payload), "218216016349")
def test_extracts_user_uid_from_private_spawn_record_context(self):
payload = (
b"\x88\x00\x00\x00\x10\x00\x00\x00"
+ (218216016349).to_bytes(8, "little")
+ b"\x08\x00\x0c\x00\x07\x00\x08\x00\x08\x00\x00\x00"
+ b"\x00\x00\x00\x01\x08\x00\x00\x00\x04\x00\x04\x00"
+ b"\x04\x00\x00\x00\x16\x00\x00\x00PrivateSpawnInfoRecord\x00"
)
self.assertEqual(extract_user_uid(payload), "218216016349")
def test_does_not_extract_user_uid_from_wrong_record_offset(self):
payload = (
b"\x00" * 28
+ (218216016349).to_bytes(8, "little")
+ b"\x00\x00\x00\x00TagOthers\x00"
)
self.assertIsNone(extract_user_uid(payload))
def test_does_not_extract_old_eight_digit_false_positive_as_user_uid(self):
payload = (
b"WholeVehicleData\x00\x00\x00\x00\x00o<\x00\x00\x05\x00\x00\x00"
b"\x0b\x00\x00\x00Vehicle015\x00\x0b\x00\x00\x00buyvehicle\x00"
b"\x09\x00\x00\x0015363624\x00\x06\x00\x00\x00"
)
self.assertIsNone(extract_user_uid(payload))
def test_ipv4_parser_extracts_tcp_payload_for_user_uid_detection(self):
payload = (
(218216016349).to_bytes(8, "little")
+ b"\x00\x00\x00\x00\x09\x00\x00\x00TagOthers\x00"
)
tcp_header = bytearray(20)
tcp_header[0:2] = (40000).to_bytes(2, "big")
tcp_header[2:4] = (30000).to_bytes(2, "big")
tcp_header[12] = 5 << 4
total_len = 20 + len(tcp_header) + len(payload)
ip_header = bytearray(20)
ip_header[0] = 0x45
ip_header[2:4] = total_len.to_bytes(2, "big")
ip_header[9] = 6
ip_header[12:16] = bytes([192, 0, 2, 1])
ip_header[16:20] = bytes([198, 51, 100, 2])
packet = parse_ipv4_packet(bytes(ip_header) + bytes(tcp_header) + payload)
self.assertIsNotNone(packet)
self.assertEqual(packet.protocol, "tcp")
self.assertEqual(packet.payload, payload)
def test_limited_selector_and_marker_decode(self): def test_limited_selector_and_marker_decode(self):
request = bytearray(45) request = bytearray(45)
request[31:35] = (4).to_bytes(4, "little") request[31:35] = (4).to_bytes(4, "little")

27
tools/build-windows.ps1 Normal file
View file

@ -0,0 +1,27 @@
$ErrorActionPreference = 'Stop'
Set-Location (Split-Path -Parent $PSScriptRoot)
$python = Join-Path $PWD '.venv\Scripts\python.exe'
if (-not (Test-Path $python)) {
python -m venv .venv
}
& $python -m pip install --upgrade pip
& $python -m pip install -e '.[build]'
& $python -m PyInstaller --clean --noconfirm 'packaging\NTE History Exporter.spec'
if ($LASTEXITCODE -ne 0) {
throw "PyInstaller failed with exit code $LASTEXITCODE"
}
$exe = Join-Path $PWD 'dist\nte-history-exporter.exe'
if (-not (Test-Path $exe)) {
throw "Expected build output was not created: $exe"
}
Write-Host "Built $exe"
& $exe --help
if ($LASTEXITCODE -ne 0) {
throw "Built executable smoke test failed with exit code $LASTEXITCODE"
}