Companion
:::info[Verification boundary] Wire behavior described here is grounded in the live source (com.n3plugins.PacketUtils.companion) and the protocol v1 / ingest contracts. Dashboard rendering, the Discord OAuth flow, and ingest endpoint handling live in the separate n3 Companion repository and are not verified from this repo.
:::
Packet Utils collects and posts metrics to the n3 Companion dashboard through two independent transports, both owned by the core (com.n3plugins.PacketUtils.companion):
- Anonymous ingest (primary collection path). Plain HTTP posts, no pairing code, no pairing process. Metric data is sent and saved automatically and anonymously. Identifiers computed by the client (see below) keep each account’s history consistent even after a display-name change.
- Paired WebSocket gateway (optional). The original pairing flow, kept for remote-control sessions. It is not required for metrics collection; paired users get an additional live session.
startUp() and stops first during shutDown().
Stable Identifiers
accountHash variants, in resolution order:
- Login-service applied profile: when the login was started by the n3 Profiles service or Break Handler, the applied profile is announced on the event bus and — after it is verified against the live session (launcher label vs in-game display name; legacy login vs the client’s login username) — used directly as the identity source. No store re-matching or session probing is needed.
- Stored n3 Profiles profile matching the session: launcher profiles match on the profile label or the launcher account-id claim (
SHA-256("n3metrics:jl-account:" + characterId), session-token fallback); legacy profiles match on the login (SHA-256("n3metrics:" + login + ":" + password)from the stored credentials). - Observed live session capture (no stored profile): the client’s own login state is read directly — legacy credentials the client is holding, or the launcher session read through the same ASM-derived mappings the login service writes (session id, character id, account check). The identity is derived from that captured profile exactly as if it were stored, and a successful import persists it as a new stored profile (see below).
- No stable credential material: SHA-256 of the in-game account hash (
SHA-256("n3metrics:client:" + accountHash)).
Configuration
All settings live in the Packet Utils config groupn3packetutils under the collapsed Companion section of the Main Settings tab:
Panel Controls and Status
The Companion section footer provides three buttons and a live status label (refreshed every 5 seconds):- Pair with Companion — optional gateway pairing (unchanged from the original flow).
- Link with Discord — opens the dashboard web base at
/link?install=<installId>in your browser. The web page completes the Discord OAuth plus the collection-consent screen (consent version2026-09-12.1) and binds this install to your dashboard user; the plugin never handles the Discord token. The plugin picks up the new state by pollingGET /v1/ingest/status(every 60 seconds, plus immediately after you open the page). - Import Session Data — manual import of the active session’s credentials. Only available when the install is Discord-linked and the session resolved to a stable identity (a matching stored profile or an observed live capture). A confirmation dialog names exactly what will be sent (legacy: the account email and password; Jagex Launcher: launcher session tokens) before anything is sent. When no stored profile backs the session, a successful import creates a new stored profile from the captured information, so the account is stored for future sessions.
anonymous-collecting, linked (plus + auto-import when the toggle is on), paused (anonymous collection disabled), off (both transports disabled), not running, and · gateway connected appended while the optional paired socket is open. Pairing and import outcomes appear on the same label.
Ingest Wire Summary
All ingest endpoints are unauthenticated and rate-limited; the client applies a 60-second self-cooldown after any non-2xx response.POST /v1/ingest/telemetry—{installId, sessionUid, accountHash, username?, capturedAt, frames: [1 frame per flush, <=60]}with the protocol-v1 frame fields (state, world, player, xp, worldPoint,activity, the canonicalPacketUtilsplugins entry, andworkflow.packetutils). Returns 202. Collection begins with the first logged-in session, once anaccountHashis computable — including a session that was already open when the plugin started.POST /v1/ingest/account-event—{installId, sessionUid, accountHash, username, event: "login"|"logout", capturedAt}on every game-state login/logout.POST /v1/ingest/credentials— only when Discord-linked.{installId, accountHash, username, kind: "legacy"|"jagex_launcher", source: "manual"|"auto", payload: <envelope>, label?}.POST /v1/ingest/traces— only when Discord-linked. Opt-in behavior-trace upload: whenever a feature plugin (currently Ontology, on training save/stop) persists a behavior recording, the service reads the file, gzip-compresses it, and posts{installId, sessionUid, accountHash, username?, capturedAt, trace: {kind, formatVersion, graph, actionCount, sessionStart, payloadEncoding: "gzip+base64", payload}}. The recording is stored server-side verbatim (client-owned format, never interpreted server-side); uploads exceeding a 3 MiB compressed cap are skipped. Enabled bycompanionTraceUploadon top of anonymous collection.GET /v1/ingest/status?install=<installId>—{linked, consentVersion?, discordId?}; no PII beyond the Discord user id of the linked user.
hello, heartbeat, telemetry, account_event) and now also handles exactly one server command, credentials.deliver (below).
Credential Encryption
Credentials are sealed with an envelope derived from the linked Discord identity, reversible only server-side:masterSecret = "n3vault:" + discordId(Discord user id from the status endpoint).- Key: PBKDF2-HMAC-SHA256, 210000 iterations, 256-bit, with a random 16-byte per-record salt.
- Cipher: AES-256-GCM with a random 12-byte nonce and AAD binding the blob to this install and account (
aad = "n3vault:" + installId + ":" + accountHashfor imports; the server-deliver flow uses then3deliver:prefix the same way). - Envelope:
{"v":1,"kdf":"PBKDF2-HMAC-SHA256","iterations":210000,"salt":b64,"nonce":b64,"aad":b64,"ct":b64}.
{"kind":"legacy","email":...,"password":...} or {"kind":"jagex_launcher","sessionId":...,"characterId":...,"userHash":...}). Plaintext and key material are never logged, and plaintext is never written outside the n3 Profiles store.
Observed Session Capture
When no stored profile matches the active session, the collector observes the session directly instead of refusing to import:- Legacy sessions: the login email and password the client is holding. The public API only exposes
setPassword, so the getter is probed on the injected client implementation at runtime; when no getter is available, legacy capture degrades to the client-hash fallback (metrics continue; there is simply nothing to import). Logins started through the n3 Profiles service or Break Handler bypass this entirely: the applied profile is announced on the event bus and trusted directly. - Jagex Launcher sessions: the client’s static session state — session id, character id, display name, and the account-check value as the user hash — read through the same ASM-derived, revision-verified mappings the n3 Profiles login service uses. A character id is required; without one there is no stable identity and capture degrades to the client-hash fallback.
Combat Activity In Frames
While logged in, every frame carries anactivity field: in_combat when the local player is interacting with a living NPC, a living NPC in the scene is targeting the local player, or the local player’s health bar is showing (recent combat within its linger window); out_of_combat otherwise. The dashboard’s metrics views render this alongside the client lifecycle state.
Auto-Import (Opt-In)
With Auto-import session data enabled, every successful game login that resolves a stable identity and a Discord-linked install is imported automatically withsource:"auto" — including observed sessions with no stored profile (which then create one). Linking mid-session is picked up by the status poll and import fires without a re-login. A local hidden dedupe set (companionImportedHashes) remembers already-imported account hashes so each unique identity is imported once; the server remains the dedupe authority and a duplicate send is a cheap no-op. Clearing the hidden key re-syncs. For legacy accounts the hash includes the password, so a password change intentionally creates a new identity.
Server-Delivered Credentials (Paired Gateway)
While a paired gateway session is connected, the server may send one command:{t:"command", id, op:"credentials.deliver", args:{payload, kind, username, label?, accountHash?}}. The client decrypts the envelope with the linked Discord identity (unlinked installs acknowledge undeliverable), recomputes the stable accountHash from the plaintext, stores the credentials as an n3 Profiles profile (legacy or Jagex Launcher), and acknowledges with a redacted result only: {ok:true, result:{status:"stored"|"already_present", accountHashPrefix:<first 8 hex>}}. Re-delivery of identical plaintext acknowledges already_present; changed plaintext overwrites the stored profile. Local storage failures acknowledge profiles_store_locked (no unlock password configured) or profiles_store_failed without any credential material. The delivered hash joins the auto-import dedupe set so the account is not re-imported.
Relationship To Companion Uplink
The registered Companion Uplink plugin (com.n3plugins.telemetry.CompanionUplinkPlugin) is an optional, older sender of the paired-gateway protocol and remains untouched. Users enable one or the other; if both are enabled simultaneously they pair independently and the Companion server resolves duplicate accounting when reading metrics.
Validation
Focused tests live undersrc/test/java/com/n3plugins/PacketUtils/companion/:
CompanionIdentityTest— hash stability and format, session resolution (JL claim, session fallback, legacy login match, client-hash fallback), profile-derived identity hashing, same-identity stored matching across labels, envelope field shape, encrypt/decrypt roundtrips, AAD and tamper rejection, salt/nonce uniqueness, delivered-plaintext resolution, redaction, and dedupe/map persistence.CompanionLiveSessionTest— observed-session capture: legacy credentials the client holds, launcher capture shaping (required session + character id, label and user-hash fallbacks), pluggable password reader, and null-client/throwing-client tolerance.CompanionIngestClientTest— ingest request bodies (telemetry identifiers, credentialssource/kind/payload), 429 handling, duplicate responses, and status parsing, against a local HTTP stub.CompanionLinkStateTest— anonymous/linked state machine, sticky linked state on failed polls, unmerge/downgrade, and the anonymous-to-linked transition signal.CompanionFramesTest,CompanionConfigWiringTest,CompanionLifecycleWiringTest,CompanionTelemetryServiceTest— wire frames (including combatactivitystates), config keys/defaults/visibility, plugin wiring order, and pre-start behavior.